mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-09 02:47:45 +08:00
feat(security): harden gateway boundaries and usage policies
Consolidate subscription usage policy enforcement, privacy-safe persistence, and gateway security hardening into one reviewable change. Includes bounded HTTP and execution envelopes, header and protocol guards, DNS and relay validation, authentication and secret projection hardening, secure backup/install paths, and regression coverage.
This commit is contained in:
@@ -1,13 +1,14 @@
|
||||
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
|
||||
use crate::handlers::admin::shared::{
|
||||
attach_admin_audit_response, query_param_value, unix_secs_to_rfc3339,
|
||||
attach_admin_audit_response, mark_sensitive_admin_response_no_store, query_param_value,
|
||||
unix_secs_to_rfc3339,
|
||||
};
|
||||
use crate::task_runtime::{
|
||||
self, set_cancel_signal, TASK_KEY_PROVIDER_DELETE, TASK_KEY_PROVIDER_OAUTH_BATCH_IMPORT,
|
||||
};
|
||||
use crate::GatewayError;
|
||||
use aether_data_contracts::repository::background_tasks::{
|
||||
BackgroundTaskKind, BackgroundTaskListQuery, BackgroundTaskStatus,
|
||||
BackgroundTaskKind, BackgroundTaskListQuery, BackgroundTaskStatus, StoredBackgroundTaskRun,
|
||||
};
|
||||
use axum::{
|
||||
body::{Body, Bytes},
|
||||
@@ -21,6 +22,30 @@ const DEFAULT_PAGE_SIZE: usize = 20;
|
||||
const MAX_PAGE_SIZE: usize = 100;
|
||||
const DEFAULT_EVENTS_PAGE_SIZE: usize = 50;
|
||||
|
||||
fn build_background_task_list_item(run: &StoredBackgroundTaskRun) -> serde_json::Value {
|
||||
json!({
|
||||
"id": run.id,
|
||||
"task_key": run.task_key,
|
||||
"kind": run.kind.as_database(),
|
||||
"trigger": run.trigger,
|
||||
"status": run.status.as_database(),
|
||||
"attempt": run.attempt,
|
||||
"max_attempts": run.max_attempts,
|
||||
"owner_instance": run.owner_instance,
|
||||
"progress_percent": run.progress_percent,
|
||||
"progress_message": run.progress_message,
|
||||
"has_payload": run.payload_json.is_some(),
|
||||
"has_result": run.result_json.is_some(),
|
||||
"has_error": run.error_message.is_some(),
|
||||
"cancel_requested": run.cancel_requested,
|
||||
"created_by": run.created_by,
|
||||
"created_at": unix_secs_to_rfc3339(run.created_at_unix_secs),
|
||||
"started_at": run.started_at_unix_secs.and_then(unix_secs_to_rfc3339),
|
||||
"finished_at": run.finished_at_unix_secs.and_then(unix_secs_to_rfc3339),
|
||||
"updated_at": unix_secs_to_rfc3339(run.updated_at_unix_secs),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) async fn maybe_build_local_admin_background_tasks_response(
|
||||
state: &AdminAppState<'_>,
|
||||
request_context: &AdminRequestContext<'_>,
|
||||
@@ -71,29 +96,7 @@ pub(super) async fn maybe_build_local_admin_background_tasks_response(
|
||||
let items = response
|
||||
.items
|
||||
.iter()
|
||||
.map(|run| {
|
||||
json!({
|
||||
"id": run.id,
|
||||
"task_key": run.task_key,
|
||||
"kind": run.kind.as_database(),
|
||||
"trigger": run.trigger,
|
||||
"status": run.status.as_database(),
|
||||
"attempt": run.attempt,
|
||||
"max_attempts": run.max_attempts,
|
||||
"owner_instance": run.owner_instance,
|
||||
"progress_percent": run.progress_percent,
|
||||
"progress_message": run.progress_message,
|
||||
"payload": run.payload_json,
|
||||
"result": run.result_json,
|
||||
"error_message": run.error_message,
|
||||
"cancel_requested": run.cancel_requested,
|
||||
"created_by": run.created_by,
|
||||
"created_at": unix_secs_to_rfc3339(run.created_at_unix_secs),
|
||||
"started_at": run.started_at_unix_secs.and_then(unix_secs_to_rfc3339),
|
||||
"finished_at": run.finished_at_unix_secs.and_then(unix_secs_to_rfc3339),
|
||||
"updated_at": unix_secs_to_rfc3339(run.updated_at_unix_secs),
|
||||
})
|
||||
})
|
||||
.map(build_background_task_list_item)
|
||||
.collect::<Vec<_>>();
|
||||
let definitions = task_runtime::task_definitions()
|
||||
.iter()
|
||||
@@ -153,8 +156,9 @@ pub(super) async fn maybe_build_local_admin_background_tasks_response(
|
||||
.into_response(),
|
||||
));
|
||||
};
|
||||
return Ok(Some(attach_admin_audit_response(
|
||||
Json(json!({
|
||||
return Ok(Some(mark_sensitive_admin_response_no_store(
|
||||
attach_admin_audit_response(
|
||||
Json(json!({
|
||||
"id": run.id,
|
||||
"task_key": run.task_key,
|
||||
"kind": run.kind.as_database(),
|
||||
@@ -174,12 +178,13 @@ pub(super) async fn maybe_build_local_admin_background_tasks_response(
|
||||
"started_at": run.started_at_unix_secs.and_then(unix_secs_to_rfc3339),
|
||||
"finished_at": run.finished_at_unix_secs.and_then(unix_secs_to_rfc3339),
|
||||
"updated_at": unix_secs_to_rfc3339(run.updated_at_unix_secs),
|
||||
}))
|
||||
.into_response(),
|
||||
"admin_task_detail_viewed",
|
||||
"view_task_detail",
|
||||
"background_task",
|
||||
run_id,
|
||||
}))
|
||||
.into_response(),
|
||||
"admin_task_detail_viewed",
|
||||
"view_task_detail",
|
||||
"background_task",
|
||||
run_id,
|
||||
),
|
||||
)));
|
||||
}
|
||||
Some("events") if request_context.method() == http::Method::GET => {
|
||||
@@ -205,7 +210,7 @@ pub(super) async fn maybe_build_local_admin_background_tasks_response(
|
||||
let events = state
|
||||
.list_background_task_events(run_id, offset, page_size)
|
||||
.await?;
|
||||
return Ok(Some(
|
||||
return Ok(Some(mark_sensitive_admin_response_no_store(
|
||||
Json(json!({
|
||||
"items": events.into_iter().map(|event| {
|
||||
json!({
|
||||
@@ -221,7 +226,7 @@ pub(super) async fn maybe_build_local_admin_background_tasks_response(
|
||||
"page_size": page_size,
|
||||
}))
|
||||
.into_response(),
|
||||
));
|
||||
)));
|
||||
}
|
||||
Some("cancel") if request_context.method() == http::Method::POST => {
|
||||
let Some(run_id) = nested_task_id_from_path(request_context.path(), "/cancel") else {
|
||||
@@ -371,3 +376,55 @@ fn parse_json_payload(request_body: Option<&Bytes>) -> Result<serde_json::Value,
|
||||
serde_json::from_slice::<serde_json::Value>(body)
|
||||
.map_err(|err| GatewayError::Internal(format!("invalid json body: {err}")))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::build_background_task_list_item;
|
||||
use aether_data_contracts::repository::background_tasks::{
|
||||
BackgroundTaskKind, BackgroundTaskStatus, StoredBackgroundTaskRun,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn task_list_item_exposes_only_safe_diagnostic_presence_flags() {
|
||||
let run = StoredBackgroundTaskRun {
|
||||
id: "run-1".to_string(),
|
||||
task_key: "provider.oauth.import".to_string(),
|
||||
kind: BackgroundTaskKind::OnDemand,
|
||||
trigger: "manual".to_string(),
|
||||
status: BackgroundTaskStatus::Failed,
|
||||
attempt: 1,
|
||||
max_attempts: 3,
|
||||
owner_instance: Some("gateway-1".to_string()),
|
||||
progress_percent: 100,
|
||||
progress_message: Some("task failed".to_string()),
|
||||
payload_json: Some(json!({"refresh_token": "secret-refresh-token"})),
|
||||
result_json: Some(json!({"access_token": "secret-access-token"})),
|
||||
error_message: Some("upstream error containing secret-api-key".to_string()),
|
||||
cancel_requested: false,
|
||||
created_by: Some("admin".to_string()),
|
||||
created_at_unix_secs: 1,
|
||||
started_at_unix_secs: Some(2),
|
||||
finished_at_unix_secs: Some(3),
|
||||
updated_at_unix_secs: 3,
|
||||
};
|
||||
|
||||
let item = build_background_task_list_item(&run);
|
||||
assert_eq!(item["status"], "failed");
|
||||
assert_eq!(item["has_payload"], true);
|
||||
assert_eq!(item["has_result"], true);
|
||||
assert_eq!(item["has_error"], true);
|
||||
assert!(item.get("payload").is_none());
|
||||
assert!(item.get("result").is_none());
|
||||
assert!(item.get("error_message").is_none());
|
||||
|
||||
let serialized = item.to_string();
|
||||
for secret in [
|
||||
"secret-refresh-token",
|
||||
"secret-access-token",
|
||||
"secret-api-key",
|
||||
] {
|
||||
assert!(!serialized.contains(secret));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,6 +173,7 @@ pub(super) async fn maybe_build_local_admin_gemini_files_read_response(
|
||||
let mappings = state
|
||||
.list_gemini_file_mappings(
|
||||
&aether_data::repository::gemini_file_mappings::GeminiFileMappingListQuery {
|
||||
user_id: None,
|
||||
include_expired: page.include_expired,
|
||||
search: page.search.clone(),
|
||||
offset: (page.page - 1).saturating_mul(page.page_size),
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
|
||||
use crate::handlers::shared::{
|
||||
find_multipart_boundary, find_multipart_boundary_after_crlf, parse_multipart_boundary,
|
||||
MAX_MULTIPART_PARTS, MAX_MULTIPART_PART_HEADER_BYTES,
|
||||
};
|
||||
use aether_data_contracts::repository::gemini_file_mappings::{
|
||||
GEMINI_FILE_MAPPING_MAX_DISPLAY_NAME_CHARS, GEMINI_FILE_MAPPING_MAX_MIME_TYPE_CHARS,
|
||||
};
|
||||
use axum::body::Bytes;
|
||||
use base64::Engine as _;
|
||||
|
||||
@@ -21,7 +28,8 @@ pub(super) fn admin_gemini_files_parse_upload_request(
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| "Content-Type 缺失".to_string())?;
|
||||
let boundary = admin_gemini_files_multipart_boundary(content_type)?;
|
||||
let boundary = parse_multipart_boundary(content_type)
|
||||
.ok_or_else(|| "multipart boundary 缺失或无效".to_string())?;
|
||||
let body = request_body
|
||||
.filter(|body| !body.is_empty())
|
||||
.ok_or_else(|| "上传文件不能为空".to_string())?;
|
||||
@@ -35,47 +43,32 @@ pub(super) fn admin_gemini_files_parse_upload_request(
|
||||
})
|
||||
}
|
||||
|
||||
fn admin_gemini_files_multipart_boundary(content_type: &str) -> Result<String, String> {
|
||||
let normalized = content_type.trim();
|
||||
if !normalized
|
||||
.to_ascii_lowercase()
|
||||
.starts_with("multipart/form-data")
|
||||
{
|
||||
return Err("Content-Type 必须是 multipart/form-data".to_string());
|
||||
}
|
||||
for part in normalized.split(';').skip(1) {
|
||||
let Some((key, value)) = part.trim().split_once('=') else {
|
||||
continue;
|
||||
};
|
||||
if !key.trim().eq_ignore_ascii_case("boundary") {
|
||||
continue;
|
||||
}
|
||||
let boundary = value.trim().trim_matches('"').trim();
|
||||
if !boundary.is_empty() {
|
||||
return Ok(boundary.to_string());
|
||||
}
|
||||
}
|
||||
Err("multipart boundary 缺失".to_string())
|
||||
}
|
||||
|
||||
fn admin_gemini_files_extract_file_part(
|
||||
body: &[u8],
|
||||
boundary: &str,
|
||||
) -> Result<(String, String, Vec<u8>), String> {
|
||||
let boundary_marker = format!("--{boundary}");
|
||||
let next_boundary_marker = format!("\r\n--{boundary}");
|
||||
let boundary_bytes = boundary_marker.as_bytes();
|
||||
let next_boundary_bytes = next_boundary_marker.as_bytes();
|
||||
|
||||
let mut cursor = 0usize;
|
||||
let mut part_count = 0usize;
|
||||
let mut file_part = None;
|
||||
while cursor < body.len() {
|
||||
if !body[cursor..].starts_with(boundary_bytes) {
|
||||
if find_multipart_boundary(&body[cursor..], boundary_bytes) != Some(0) {
|
||||
return Err("multipart body 格式无效".to_string());
|
||||
}
|
||||
cursor += boundary_bytes.len();
|
||||
if body[cursor..].starts_with(b"--") {
|
||||
let closing_suffix = body.get(cursor + 2..).unwrap_or_default();
|
||||
if !(closing_suffix.is_empty() || closing_suffix.starts_with(b"\r\n")) {
|
||||
return Err("multipart 结束边界格式无效".to_string());
|
||||
}
|
||||
break;
|
||||
}
|
||||
part_count = part_count.saturating_add(1);
|
||||
if part_count > MAX_MULTIPART_PARTS {
|
||||
return Err("multipart part 数量超过上限".to_string());
|
||||
}
|
||||
if !body[cursor..].starts_with(b"\r\n") {
|
||||
return Err("multipart body 缺少头部分隔符".to_string());
|
||||
}
|
||||
@@ -85,34 +78,60 @@ fn admin_gemini_files_extract_file_part(
|
||||
return Err("multipart part 缺少头部".to_string());
|
||||
};
|
||||
let headers_end = cursor + headers_end_rel;
|
||||
if headers_end_rel > MAX_MULTIPART_PART_HEADER_BYTES {
|
||||
return Err("multipart part 头部超过大小上限".to_string());
|
||||
}
|
||||
let headers_text = std::str::from_utf8(&body[cursor..headers_end])
|
||||
.map_err(|_| "multipart part 头部编码无效".to_string())?;
|
||||
cursor = headers_end + 4;
|
||||
let Some(next_boundary_rel) =
|
||||
admin_gemini_files_find_subslice(&body[cursor..], next_boundary_bytes)
|
||||
find_multipart_boundary_after_crlf(&body[cursor..], boundary_bytes)
|
||||
else {
|
||||
return Err("multipart body 缺少结束边界".to_string());
|
||||
};
|
||||
let content_end = cursor + next_boundary_rel;
|
||||
let content = &body[cursor..content_end];
|
||||
cursor = content_end + 2;
|
||||
// The CRLF immediately before the delimiter belongs to the
|
||||
// multipart framing, not to the uploaded file bytes.
|
||||
let content = body[cursor..content_end]
|
||||
.strip_suffix(b"\r\n")
|
||||
.unwrap_or(&body[cursor..content_end]);
|
||||
cursor = content_end;
|
||||
|
||||
let Some((field_name, file_name, mime_type)) =
|
||||
admin_gemini_files_parse_part_headers(headers_text)
|
||||
else {
|
||||
continue;
|
||||
return Err("multipart part 头部无效".to_string());
|
||||
};
|
||||
if field_name != "file" {
|
||||
continue;
|
||||
}
|
||||
return Ok((
|
||||
if file_part.is_some() {
|
||||
return Err("multipart body 包含多个 file 字段".to_string());
|
||||
}
|
||||
file_part = Some((
|
||||
file_name.unwrap_or_else(|| "uploaded-file".to_string()),
|
||||
mime_type.unwrap_or_else(|| "application/octet-stream".to_string()),
|
||||
content.to_vec(),
|
||||
));
|
||||
}
|
||||
|
||||
Err("multipart body 中缺少 file 字段".to_string())
|
||||
let (display_name, mime_type, content) =
|
||||
file_part.ok_or_else(|| "multipart body 中缺少 file 字段".to_string())?;
|
||||
if display_name
|
||||
.chars()
|
||||
.nth(GEMINI_FILE_MAPPING_MAX_DISPLAY_NAME_CHARS)
|
||||
.is_some()
|
||||
{
|
||||
return Err("上传文件名超过长度上限".to_string());
|
||||
}
|
||||
if mime_type
|
||||
.chars()
|
||||
.nth(GEMINI_FILE_MAPPING_MAX_MIME_TYPE_CHARS)
|
||||
.is_some()
|
||||
{
|
||||
return Err("上传文件 Content-Type 超过长度上限".to_string());
|
||||
}
|
||||
Ok((display_name, mime_type, content))
|
||||
}
|
||||
|
||||
fn admin_gemini_files_parse_part_headers(
|
||||
@@ -121,27 +140,29 @@ fn admin_gemini_files_parse_part_headers(
|
||||
let mut field_name = None;
|
||||
let mut file_name = None;
|
||||
let mut mime_type = None;
|
||||
let mut disposition_seen = false;
|
||||
let mut content_type_seen = false;
|
||||
|
||||
for line in headers_text.split("\r\n") {
|
||||
let Some((header_name, header_value)) = line.split_once(':') else {
|
||||
continue;
|
||||
};
|
||||
let (header_name, header_value) = line.split_once(':')?;
|
||||
let header_name = header_name.trim();
|
||||
let header_value = header_value.trim();
|
||||
if header_name.eq_ignore_ascii_case("content-disposition") {
|
||||
for part in header_value.split(';').skip(1) {
|
||||
let Some((key, value)) = part.trim().split_once('=') else {
|
||||
continue;
|
||||
};
|
||||
let key = key.trim();
|
||||
let value = value.trim().trim_matches('"').trim();
|
||||
if key.eq_ignore_ascii_case("name") && !value.is_empty() {
|
||||
field_name = Some(value.to_string());
|
||||
} else if key.eq_ignore_ascii_case("filename") && !value.is_empty() {
|
||||
file_name = Some(value.to_string());
|
||||
}
|
||||
if disposition_seen {
|
||||
return None;
|
||||
}
|
||||
} else if header_name.eq_ignore_ascii_case("content-type") && !header_value.is_empty() {
|
||||
disposition_seen = true;
|
||||
let (name, filename) = admin_gemini_files_parse_content_disposition(header_value)?;
|
||||
field_name = Some(name);
|
||||
file_name = filename;
|
||||
} else if header_name.eq_ignore_ascii_case("content-type") {
|
||||
if content_type_seen
|
||||
|| header_value.is_empty()
|
||||
|| header_value.chars().any(char::is_control)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
content_type_seen = true;
|
||||
mime_type = Some(header_value.to_string());
|
||||
}
|
||||
}
|
||||
@@ -149,6 +170,150 @@ fn admin_gemini_files_parse_part_headers(
|
||||
field_name.map(|field_name| (field_name, file_name, mime_type))
|
||||
}
|
||||
|
||||
fn admin_gemini_files_parse_content_disposition(value: &str) -> Option<(String, Option<String>)> {
|
||||
let segments = admin_gemini_files_split_header_parameters(value)?;
|
||||
if !segments.first()?.trim().eq_ignore_ascii_case("form-data") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut seen_keys = Vec::new();
|
||||
let mut name = None;
|
||||
let mut filename = None;
|
||||
for segment in segments.into_iter().skip(1) {
|
||||
let segment = segment.trim();
|
||||
if segment.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let (raw_key, raw_value) = segment.split_once('=')?;
|
||||
let key = raw_key.trim();
|
||||
if key.is_empty()
|
||||
|| !key
|
||||
.as_bytes()
|
||||
.iter()
|
||||
.copied()
|
||||
.all(admin_gemini_files_is_token_byte)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
if seen_keys
|
||||
.iter()
|
||||
.any(|seen: &String| seen.eq_ignore_ascii_case(key))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
seen_keys.push(key.to_ascii_lowercase());
|
||||
|
||||
let parsed_value = admin_gemini_files_parse_parameter_value(raw_value.trim())?;
|
||||
if key.eq_ignore_ascii_case("name") {
|
||||
if parsed_value.is_empty() {
|
||||
return None;
|
||||
}
|
||||
name = Some(parsed_value);
|
||||
} else if key.eq_ignore_ascii_case("filename") {
|
||||
if !parsed_value.is_empty() {
|
||||
filename = Some(parsed_value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some((name?, filename))
|
||||
}
|
||||
|
||||
fn admin_gemini_files_split_header_parameters(value: &str) -> Option<Vec<&str>> {
|
||||
let mut segments = Vec::new();
|
||||
let mut start = 0usize;
|
||||
let mut in_quotes = false;
|
||||
let mut escaped = false;
|
||||
|
||||
for (index, byte) in value.as_bytes().iter().copied().enumerate() {
|
||||
if in_quotes {
|
||||
if escaped {
|
||||
escaped = false;
|
||||
} else if byte == b'\\' {
|
||||
escaped = true;
|
||||
} else if byte == b'"' {
|
||||
in_quotes = false;
|
||||
}
|
||||
} else if byte == b'"' {
|
||||
in_quotes = true;
|
||||
} else if byte == b';' {
|
||||
segments.push(&value[start..index]);
|
||||
start = index + 1;
|
||||
}
|
||||
}
|
||||
|
||||
if in_quotes || escaped {
|
||||
return None;
|
||||
}
|
||||
segments.push(&value[start..]);
|
||||
Some(segments)
|
||||
}
|
||||
|
||||
fn admin_gemini_files_parse_parameter_value(value: &str) -> Option<String> {
|
||||
if value.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if value.starts_with('"') {
|
||||
if value.len() < 2 || !value.ends_with('"') {
|
||||
return None;
|
||||
}
|
||||
let inner = &value[1..value.len() - 1];
|
||||
let mut parsed = String::with_capacity(inner.len());
|
||||
let mut escaped = false;
|
||||
for character in inner.chars() {
|
||||
if escaped {
|
||||
if character.is_control() {
|
||||
return None;
|
||||
}
|
||||
parsed.push(character);
|
||||
escaped = false;
|
||||
} else if character == '\\' {
|
||||
escaped = true;
|
||||
} else {
|
||||
if character == '"' || character.is_control() {
|
||||
return None;
|
||||
}
|
||||
parsed.push(character);
|
||||
}
|
||||
}
|
||||
if escaped {
|
||||
return None;
|
||||
}
|
||||
return Some(parsed);
|
||||
}
|
||||
|
||||
value
|
||||
.as_bytes()
|
||||
.iter()
|
||||
.copied()
|
||||
.all(admin_gemini_files_is_token_byte)
|
||||
.then(|| value.to_string())
|
||||
}
|
||||
|
||||
fn admin_gemini_files_is_token_byte(byte: u8) -> bool {
|
||||
matches!(
|
||||
byte,
|
||||
b'0'..=b'9'
|
||||
| b'A'..=b'Z'
|
||||
| b'a'..=b'z'
|
||||
| b'!'
|
||||
| b'#'
|
||||
| b'$'
|
||||
| b'%'
|
||||
| b'&'
|
||||
| b'\''
|
||||
| b'*'
|
||||
| b'+'
|
||||
| b'-'
|
||||
| b'.'
|
||||
| b'^'
|
||||
| b'_'
|
||||
| b'`'
|
||||
| b'|'
|
||||
| b'~'
|
||||
)
|
||||
}
|
||||
|
||||
fn admin_gemini_files_find_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> {
|
||||
if haystack.is_empty() || needle.is_empty() || haystack.len() < needle.len() {
|
||||
return None;
|
||||
@@ -157,3 +322,224 @@ fn admin_gemini_files_find_subslice(haystack: &[u8], needle: &[u8]) -> Option<us
|
||||
.windows(needle.len())
|
||||
.position(|window| window == needle)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use aether_data_contracts::repository::gemini_file_mappings::{
|
||||
GEMINI_FILE_MAPPING_MAX_DISPLAY_NAME_CHARS, GEMINI_FILE_MAPPING_MAX_MIME_TYPE_CHARS,
|
||||
};
|
||||
|
||||
use super::{
|
||||
admin_gemini_files_extract_file_part, MAX_MULTIPART_PARTS, MAX_MULTIPART_PART_HEADER_BYTES,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn multipart_upload_rejects_metadata_beyond_storage_limits() {
|
||||
let boundary = "metadata-limits";
|
||||
let oversized_filename = "f".repeat(GEMINI_FILE_MAPPING_MAX_DISPLAY_NAME_CHARS + 1);
|
||||
let oversized_mime_type = "m".repeat(GEMINI_FILE_MAPPING_MAX_MIME_TYPE_CHARS + 1);
|
||||
|
||||
for (filename, mime_type, expected) in [
|
||||
(
|
||||
oversized_filename.as_str(),
|
||||
"application/octet-stream",
|
||||
"上传文件名超过长度上限",
|
||||
),
|
||||
(
|
||||
"payload.bin",
|
||||
oversized_mime_type.as_str(),
|
||||
"上传文件 Content-Type 超过长度上限",
|
||||
),
|
||||
] {
|
||||
let body = format!(
|
||||
"--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"{filename}\"\r\nContent-Type: {mime_type}\r\n\r\nfile-body\r\n--{boundary}--\r\n"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
admin_gemini_files_extract_file_part(body.as_bytes(), boundary),
|
||||
Err(expected.to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multipart_upload_rejects_excessive_part_count() {
|
||||
let boundary = "bounded-parts";
|
||||
let mut body = Vec::new();
|
||||
for index in 0..(MAX_MULTIPART_PARTS + 1) {
|
||||
body.extend_from_slice(
|
||||
format!(
|
||||
"--{boundary}\r\nContent-Disposition: form-data; name=\"field-{index}\"\r\n\r\nvalue\r\n"
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
}
|
||||
body.extend_from_slice(format!("--{boundary}--\r\n").as_bytes());
|
||||
|
||||
assert_eq!(
|
||||
admin_gemini_files_extract_file_part(&body, boundary),
|
||||
Err("multipart part 数量超过上限".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multipart_upload_rejects_oversized_part_headers() {
|
||||
let boundary = "bounded-header";
|
||||
let mut body =
|
||||
format!("--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; x=\"")
|
||||
.into_bytes();
|
||||
body.extend(std::iter::repeat_n(b'x', MAX_MULTIPART_PART_HEADER_BYTES));
|
||||
body.extend_from_slice(format!("\"\r\n\r\nfile-body\r\n--{boundary}--\r\n").as_bytes());
|
||||
|
||||
assert_eq!(
|
||||
admin_gemini_files_extract_file_part(&body, boundary),
|
||||
Err("multipart part 头部超过大小上限".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multipart_upload_preserves_boundary_like_payload() {
|
||||
let boundary = "payload-boundary";
|
||||
let body = format!(
|
||||
concat!(
|
||||
"--{boundary}\r\n",
|
||||
"Content-Disposition: form-data; name=\"file\"; filename=\"payload.bin\"\r\n",
|
||||
"Content-Type: application/octet-stream\r\n\r\n",
|
||||
"prefix\r\n--{boundary}X\r\nsuffix--{boundary}\r\n",
|
||||
"--{boundary}--\r\n"
|
||||
),
|
||||
boundary = boundary,
|
||||
);
|
||||
|
||||
let (_, mime_type, content) =
|
||||
admin_gemini_files_extract_file_part(body.as_bytes(), boundary).expect("file part");
|
||||
assert_eq!(mime_type, "application/octet-stream");
|
||||
assert_eq!(
|
||||
content,
|
||||
format!("prefix\r\n--{boundary}X\r\nsuffix--{boundary}").into_bytes()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multipart_upload_rejects_invalid_suffix_without_closing_boundary() {
|
||||
let boundary = "invalid-suffix";
|
||||
let body = format!(
|
||||
concat!(
|
||||
"--{boundary}\r\n",
|
||||
"Content-Disposition: form-data; name=\"file\"\r\n\r\n",
|
||||
"file-body\r\n--{boundary}X\r\n"
|
||||
),
|
||||
boundary = boundary,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
admin_gemini_files_extract_file_part(body.as_bytes(), boundary),
|
||||
Err("multipart body 缺少结束边界".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multipart_upload_rejects_garbage_after_closing_boundary() {
|
||||
let boundary = "closing-suffix";
|
||||
let body = format!(
|
||||
concat!(
|
||||
"--{boundary}\r\n",
|
||||
"Content-Disposition: form-data; name=\"file\"\r\n\r\n",
|
||||
"file-body\r\n",
|
||||
"--{boundary}--junk"
|
||||
),
|
||||
boundary = boundary,
|
||||
);
|
||||
|
||||
assert!(admin_gemini_files_extract_file_part(body.as_bytes(), boundary).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multipart_upload_validates_parts_after_file_before_returning() {
|
||||
let boundary = "trailing-invalid";
|
||||
let body = format!(
|
||||
concat!(
|
||||
"--{boundary}\r\n",
|
||||
"Content-Disposition: form-data; name=\"file\"\r\n\r\n",
|
||||
"file-body\r\n",
|
||||
"--{boundary}\r\n",
|
||||
"Content-Disposition: form-data; name=\"metadata\"\r\n\r\n",
|
||||
"metadata\r\n--{boundary}X\r\n"
|
||||
),
|
||||
boundary = boundary,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
admin_gemini_files_extract_file_part(body.as_bytes(), boundary),
|
||||
Err("multipart body 缺少结束边界".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multipart_upload_parses_quoted_parameters_without_filename_confusion() {
|
||||
let boundary = "quoted-parameters";
|
||||
let body = format!(
|
||||
concat!(
|
||||
"--{boundary}\r\n",
|
||||
"Content-Disposition: form-data; filename=\"prefix; name=\\\"decoy\\\".bin\"; name=\"file\"\r\n",
|
||||
"Content-Type: application/octet-stream\r\n\r\n",
|
||||
"file-body\r\n",
|
||||
"--{boundary}--\r\n"
|
||||
),
|
||||
boundary = boundary,
|
||||
);
|
||||
|
||||
let (filename, _, content) =
|
||||
admin_gemini_files_extract_file_part(body.as_bytes(), boundary).expect("file part");
|
||||
assert_eq!(filename, "prefix; name=\"decoy\".bin");
|
||||
assert_eq!(content, b"file-body");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multipart_upload_rejects_filename_embedded_name_and_duplicate_parameters() {
|
||||
let boundary = "ambiguous-parameters";
|
||||
for content_disposition in [
|
||||
"form-data; filename=\"name=\\\"file\\\"\"",
|
||||
"form-data; name=\"file\"; name=\"metadata\"",
|
||||
"form-data; name=\"file\"; filename=\"unterminated",
|
||||
] {
|
||||
let body = format!(
|
||||
concat!(
|
||||
"--{boundary}\r\n",
|
||||
"Content-Disposition: {content_disposition}\r\n\r\n",
|
||||
"file-body\r\n",
|
||||
"--{boundary}--\r\n"
|
||||
),
|
||||
boundary = boundary,
|
||||
content_disposition = content_disposition,
|
||||
);
|
||||
assert_eq!(
|
||||
admin_gemini_files_extract_file_part(body.as_bytes(), boundary),
|
||||
Err("multipart part 头部无效".to_string()),
|
||||
"header should be rejected: {content_disposition}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multipart_upload_rejects_duplicate_file_parts() {
|
||||
let boundary = "duplicate-file";
|
||||
let body = format!(
|
||||
concat!(
|
||||
"--{boundary}\r\n",
|
||||
"Content-Disposition: form-data; name=\"file\"\r\n\r\n",
|
||||
"first\r\n",
|
||||
"--{boundary}\r\n",
|
||||
"Content-Disposition: form-data; name=\"file\"\r\n\r\n",
|
||||
"second\r\n",
|
||||
"--{boundary}--\r\n"
|
||||
),
|
||||
boundary = boundary,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
admin_gemini_files_extract_file_part(body.as_bytes(), boundary),
|
||||
Err("multipart body 包含多个 file 字段".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
use super::super::admin_gemini_files_key_capable;
|
||||
use super::request::AdminGeminiFilesUploadRequest;
|
||||
use crate::execution_runtime::transport::{
|
||||
apply_upstream_response_body_limit, decode_base64_body_with_limit,
|
||||
json_value_fits_serialized_limit,
|
||||
};
|
||||
use crate::handlers::admin::request::AdminAppState;
|
||||
use crate::GatewayError;
|
||||
use aether_contracts::{ExecutionPlan, ExecutionResult, RequestBody};
|
||||
use aether_data_contracts::repository::gemini_file_mappings::{
|
||||
GEMINI_FILE_MAPPING_MAX_DISPLAY_NAME_CHARS, GEMINI_FILE_MAPPING_MAX_MIME_TYPE_CHARS,
|
||||
};
|
||||
use aether_data_contracts::repository::provider_catalog::{
|
||||
StoredProviderCatalogEndpoint, StoredProviderCatalogKey,
|
||||
};
|
||||
use axum::http;
|
||||
use base64::Engine;
|
||||
use serde_json::json;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
const MAX_GEMINI_UPLOAD_RESPONSE_JSON_BYTES: usize = 8 * 1024 * 1024;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct AdminGeminiFilesUploadExecutionSuccess {
|
||||
file_name: String,
|
||||
@@ -138,7 +146,7 @@ async fn admin_gemini_files_upload_single_key(
|
||||
let transport = state
|
||||
.read_provider_transport_snapshot(&key.provider_id, &endpoint.id, &key.id)
|
||||
.await
|
||||
.map_err(|err| format!("{err:?}"))?
|
||||
.map_err(|_| "无法读取 Key 传输配置".to_string())?
|
||||
.ok_or_else(|| "无法读取 Key 传输配置".to_string())?;
|
||||
if !state.supports_local_gemini_transport_with_network(&transport, "gemini:generate_content") {
|
||||
return Err("Key 传输配置不支持 Gemini Files 上传".to_string());
|
||||
@@ -188,7 +196,7 @@ async fn admin_gemini_files_upload_single_key(
|
||||
.build_gemini_files_passthrough_url(&transport.endpoint.base_url, upload_path, upload_query)
|
||||
.ok_or_else(|| "无法构建 Gemini Files 上传地址".to_string())?;
|
||||
|
||||
let plan = ExecutionPlan {
|
||||
let mut plan = ExecutionPlan {
|
||||
request_id: format!("{trace_id}:admin-gemini-upload:{}", key.id),
|
||||
candidate_id: None,
|
||||
provider_name: Some(transport.provider.name.clone()),
|
||||
@@ -215,10 +223,11 @@ async fn admin_gemini_files_upload_single_key(
|
||||
transport_profile: state.resolve_transport_profile(&transport),
|
||||
timeouts: state.resolve_transport_execution_timeouts(&transport),
|
||||
};
|
||||
apply_upstream_response_body_limit(&mut plan, MAX_GEMINI_UPLOAD_RESPONSE_JSON_BYTES);
|
||||
|
||||
let result = admin_gemini_files_execute_upload_plan(state, trace_id, &plan)
|
||||
.await
|
||||
.map_err(|error| format!("{error:?}"))?;
|
||||
.map_err(|_| "Gemini Files 上传执行失败".to_string())?;
|
||||
if result.status_code >= 400 {
|
||||
return Err(admin_gemini_files_execution_error_message(&result));
|
||||
}
|
||||
@@ -241,7 +250,7 @@ async fn admin_gemini_files_upload_single_key(
|
||||
.or(Some(upload.mime_type.as_str())),
|
||||
)
|
||||
.await
|
||||
.map_err(|err| format!("上传成功但本地映射写入失败: {err:?}"))?;
|
||||
.map_err(|_| "上传成功但本地映射写入失败".to_string())?;
|
||||
Ok(success)
|
||||
}
|
||||
|
||||
@@ -261,7 +270,8 @@ fn admin_gemini_files_execution_json_body(result: &ExecutionResult) -> Option<se
|
||||
.as_ref()
|
||||
.and_then(|body| body.json_body.as_ref())
|
||||
{
|
||||
return Some(body_json.clone());
|
||||
return json_value_fits_serialized_limit(body_json, MAX_GEMINI_UPLOAD_RESPONSE_JSON_BYTES)
|
||||
.then(|| body_json.clone());
|
||||
}
|
||||
let content_type = result
|
||||
.headers
|
||||
@@ -278,9 +288,12 @@ fn admin_gemini_files_execution_json_body(result: &ExecutionResult) -> Option<se
|
||||
.body
|
||||
.as_ref()
|
||||
.and_then(|body| body.body_bytes_b64.as_deref())?;
|
||||
let decoded = base64::engine::general_purpose::STANDARD
|
||||
.decode(body_bytes_b64)
|
||||
.ok()?;
|
||||
let decoded = decode_base64_body_with_limit(
|
||||
body_bytes_b64,
|
||||
crate::headers::max_internal_buffered_body_bytes()
|
||||
.min(MAX_GEMINI_UPLOAD_RESPONSE_JSON_BYTES),
|
||||
)
|
||||
.ok()?;
|
||||
serde_json::from_slice(&decoded).ok()
|
||||
}
|
||||
|
||||
@@ -296,13 +309,20 @@ fn admin_gemini_files_upload_success_from_body(
|
||||
.get("name")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())?;
|
||||
.filter(|value| !value.is_empty())
|
||||
.filter(|value| aether_usage_runtime::normalize_gemini_file_name(value).is_some())?;
|
||||
let display_name = file_object
|
||||
.get("displayName")
|
||||
.or_else(|| file_object.get("display_name"))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.filter(|value| {
|
||||
value
|
||||
.chars()
|
||||
.nth(GEMINI_FILE_MAPPING_MAX_DISPLAY_NAME_CHARS)
|
||||
.is_none()
|
||||
})
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| Some(upload.display_name.clone()));
|
||||
let mime_type = file_object
|
||||
@@ -311,6 +331,12 @@ fn admin_gemini_files_upload_success_from_body(
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.filter(|value| {
|
||||
value
|
||||
.chars()
|
||||
.nth(GEMINI_FILE_MAPPING_MAX_MIME_TYPE_CHARS)
|
||||
.is_none()
|
||||
})
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| Some(upload.mime_type.clone()));
|
||||
Some(AdminGeminiFilesUploadExecutionSuccess {
|
||||
@@ -330,7 +356,7 @@ fn admin_gemini_files_execution_error_message(result: &ExecutionResult) -> Strin
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
return message.to_string();
|
||||
return bound_gemini_files_error_message(message);
|
||||
}
|
||||
if let Some(message) = body_json
|
||||
.get("message")
|
||||
@@ -338,7 +364,7 @@ fn admin_gemini_files_execution_error_message(result: &ExecutionResult) -> Strin
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
return message.to_string();
|
||||
return bound_gemini_files_error_message(message);
|
||||
}
|
||||
}
|
||||
if let Some(error) = result
|
||||
@@ -347,7 +373,112 @@ fn admin_gemini_files_execution_error_message(result: &ExecutionResult) -> Strin
|
||||
.map(|error| error.message.trim())
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
return error.to_string();
|
||||
return bound_gemini_files_error_message(error);
|
||||
}
|
||||
format!("上传失败,状态码 {}", result.status_code)
|
||||
}
|
||||
|
||||
fn bound_gemini_files_error_message(value: &str) -> String {
|
||||
let value = value.trim();
|
||||
let end = value.floor_char_boundary(value.len().min(crate::MAX_ERROR_BODY_BYTES));
|
||||
value[..end].to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use aether_contracts::{ExecutionResult, ResponseBody};
|
||||
use aether_data_contracts::repository::gemini_file_mappings::{
|
||||
GEMINI_FILE_MAPPING_MAX_DISPLAY_NAME_CHARS, GEMINI_FILE_MAPPING_MAX_FILE_NAME_CHARS,
|
||||
GEMINI_FILE_MAPPING_MAX_MIME_TYPE_CHARS,
|
||||
};
|
||||
|
||||
use super::super::request::AdminGeminiFilesUploadRequest;
|
||||
use super::{
|
||||
admin_gemini_files_execution_error_message, admin_gemini_files_execution_json_body,
|
||||
admin_gemini_files_upload_success_from_body,
|
||||
};
|
||||
|
||||
fn sample_upload() -> AdminGeminiFilesUploadRequest {
|
||||
AdminGeminiFilesUploadRequest {
|
||||
display_name: "fallback.bin".to_string(),
|
||||
mime_type: "application/octet-stream".to_string(),
|
||||
body_bytes: vec![1],
|
||||
body_bytes_b64: "AQ==".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gemini_upload_result_rejects_file_name_beyond_storage_limit() {
|
||||
let body = serde_json::json!({
|
||||
"file": {"name": "n".repeat(GEMINI_FILE_MAPPING_MAX_FILE_NAME_CHARS + 1)}
|
||||
});
|
||||
|
||||
assert!(admin_gemini_files_upload_success_from_body(&body, &sample_upload()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gemini_upload_result_ignores_oversized_optional_metadata() {
|
||||
let body = serde_json::json!({
|
||||
"file": {
|
||||
"name": "files/safe",
|
||||
"displayName": "d".repeat(GEMINI_FILE_MAPPING_MAX_DISPLAY_NAME_CHARS + 1),
|
||||
"mimeType": "m".repeat(GEMINI_FILE_MAPPING_MAX_MIME_TYPE_CHARS + 1),
|
||||
}
|
||||
});
|
||||
|
||||
let success = admin_gemini_files_upload_success_from_body(&body, &sample_upload())
|
||||
.expect("valid file name should remain usable");
|
||||
assert_eq!(success.display_name.as_deref(), Some("fallback.bin"));
|
||||
assert_eq!(
|
||||
success.mime_type.as_deref(),
|
||||
Some("application/octet-stream")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gemini_upload_result_rejects_oversized_base64_before_decode() {
|
||||
let encoded_limit =
|
||||
crate::execution_runtime::transport::maximum_base64_len_for_decoded_limit(
|
||||
super::MAX_GEMINI_UPLOAD_RESPONSE_JSON_BYTES,
|
||||
);
|
||||
let result = ExecutionResult {
|
||||
request_id: "gemini-upload-oversized".to_string(),
|
||||
candidate_id: None,
|
||||
status_code: 200,
|
||||
headers: BTreeMap::from([("content-type".to_string(), "application/json".to_string())]),
|
||||
response_observation: None,
|
||||
body: Some(ResponseBody {
|
||||
json_body: None,
|
||||
body_bytes_b64: Some("A".repeat(encoded_limit + 1)),
|
||||
}),
|
||||
telemetry: None,
|
||||
error: None,
|
||||
};
|
||||
|
||||
assert!(admin_gemini_files_execution_json_body(&result).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gemini_upload_error_message_is_bounded_without_splitting_utf8() {
|
||||
let message = format!("{}界", "x".repeat(crate::MAX_ERROR_BODY_BYTES));
|
||||
let result = ExecutionResult {
|
||||
request_id: "gemini-upload-oversized-error".to_string(),
|
||||
candidate_id: None,
|
||||
status_code: 500,
|
||||
headers: BTreeMap::new(),
|
||||
response_observation: None,
|
||||
body: Some(ResponseBody {
|
||||
json_body: Some(serde_json::json!({"error": {"message": message}})),
|
||||
body_bytes_b64: None,
|
||||
}),
|
||||
telemetry: None,
|
||||
error: None,
|
||||
};
|
||||
|
||||
let detail = admin_gemini_files_execution_error_message(&result);
|
||||
assert_eq!(detail.len(), crate::MAX_ERROR_BODY_BYTES);
|
||||
assert!(detail.bytes().all(|byte| byte == b'x'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,20 @@ pub(super) fn admin_video_task_status_name(status: VideoTaskStatus) -> &'static
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn admin_video_task_error_projection(task: &StoredVideoTask) -> Option<String> {
|
||||
if task.error_message.as_deref().is_none_or(str::is_empty)
|
||||
&& task.error_code.as_deref().is_none_or(str::is_empty)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
task.error_code
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| Some("provider_error".to_string()))
|
||||
}
|
||||
|
||||
pub(super) fn admin_video_task_timestamp(unix_secs: Option<u64>) -> Option<String> {
|
||||
unix_secs.and_then(|value| {
|
||||
chrono::DateTime::<Utc>::from_timestamp(value as i64, 0)
|
||||
@@ -91,7 +105,7 @@ pub(super) fn build_admin_video_task_list_item(
|
||||
"aspect_ratio": task.aspect_ratio,
|
||||
"video_url": task.video_url,
|
||||
"error_code": task.error_code,
|
||||
"error_message": task.error_message,
|
||||
"error_message": admin_video_task_error_projection(task),
|
||||
"poll_count": task.poll_count,
|
||||
"max_poll_count": task.max_poll_count,
|
||||
"created_at": admin_video_task_timestamp(Some(task.created_at_unix_ms)),
|
||||
@@ -127,3 +141,65 @@ pub(super) fn current_admin_video_task_unix_secs() -> u64 {
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{admin_video_task_error_projection, build_admin_video_task_list_item};
|
||||
use aether_data_contracts::repository::video_tasks::{StoredVideoTask, VideoTaskStatus};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn failed_task() -> StoredVideoTask {
|
||||
StoredVideoTask::new(
|
||||
"task-1".to_string(),
|
||||
None,
|
||||
"request-1".to_string(),
|
||||
Some("user-1".to_string()),
|
||||
None,
|
||||
Some("alice".to_string()),
|
||||
None,
|
||||
None,
|
||||
Some("provider-1".to_string()),
|
||||
Some("endpoint-1".to_string()),
|
||||
Some("key-1".to_string()),
|
||||
Some("openai:video".to_string()),
|
||||
Some("openai:video".to_string()),
|
||||
false,
|
||||
Some("video-model".to_string()),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
VideoTaskStatus::Failed,
|
||||
100,
|
||||
None,
|
||||
0,
|
||||
10,
|
||||
None,
|
||||
1,
|
||||
10,
|
||||
1,
|
||||
None,
|
||||
Some(2),
|
||||
2,
|
||||
Some("authentication_error".to_string()),
|
||||
Some("Authorization: Bearer live-secret at https://api.example?key=secret".to_string()),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("task")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_task_payload_does_not_return_historical_raw_error_text() {
|
||||
let task = failed_task();
|
||||
assert_eq!(
|
||||
admin_video_task_error_projection(&task).as_deref(),
|
||||
Some("authentication_error")
|
||||
);
|
||||
let payload = build_admin_video_task_list_item(&task, &BTreeMap::new());
|
||||
assert_eq!(payload["error_message"], "authentication_error");
|
||||
assert!(!payload.to_string().contains("live-secret"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,9 +15,10 @@ use axum::{
|
||||
use serde_json::json;
|
||||
|
||||
use super::builders::{
|
||||
admin_video_task_detail_id_from_path, admin_video_task_nested_id_from_path,
|
||||
admin_video_task_status_name, admin_video_task_timestamp, build_admin_video_task_list_item,
|
||||
build_admin_video_task_provider_names, current_admin_video_task_unix_secs,
|
||||
admin_video_task_detail_id_from_path, admin_video_task_error_projection,
|
||||
admin_video_task_nested_id_from_path, admin_video_task_status_name, admin_video_task_timestamp,
|
||||
build_admin_video_task_list_item, build_admin_video_task_provider_names,
|
||||
current_admin_video_task_unix_secs,
|
||||
};
|
||||
|
||||
pub(super) async fn maybe_build_local_admin_video_tasks_response(
|
||||
@@ -259,7 +260,10 @@ pub(super) async fn maybe_build_local_admin_video_tasks_response(
|
||||
payload.insert("stored_video_path".to_string(), serde_json::Value::Null);
|
||||
payload.insert("storage_provider".to_string(), serde_json::Value::Null);
|
||||
payload.insert("error_code".to_string(), json!(task.error_code));
|
||||
payload.insert("error_message".to_string(), json!(task.error_message));
|
||||
payload.insert(
|
||||
"error_message".to_string(),
|
||||
json!(admin_video_task_error_projection(&task)),
|
||||
);
|
||||
payload.insert("retry_count".to_string(), json!(task.retry_count));
|
||||
payload.insert("max_retries".to_string(), serde_json::Value::Null);
|
||||
payload.insert(
|
||||
|
||||
Reference in New Issue
Block a user