收紧错误报告归档与私有对象边界

按用户隔离提交幂等并处理重复日志名

拒绝 agc 私有前缀的公共对象读写与链接日志读取

保持归档和元数据清理时间一致并补充回归测试

同步错误报告技术文档与项目决策记录
This commit is contained in:
2026-09-01 11:44:20 +08:00
parent 6f810b6d01
commit a0daa550c3
6 changed files with 119 additions and 14 deletions
@@ -1598,6 +1598,12 @@ fn read_diagnostic_logs() -> Result<Vec<ErrorReportLogInput>, String> {
let mut files = Vec::new();
for name in ["application.log", "application.previous.log", "startup.log"] {
let path = directory.join(name);
let Ok(metadata) = fs::symlink_metadata(&path) else {
continue;
};
if metadata.file_type().is_symlink() || !metadata.is_file() {
continue;
}
let Ok(content) = fs::read_to_string(&path) else {
continue;
};
@@ -7864,3 +7864,4 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
- `/bug-report` 仅作为打开该面板的快捷入口,追加简短提示,不再生成包含项目、run 或截图口径的缺陷模板。
- 2026-08-31 追加:事件 DTO 精简为 `eventId/fingerprint/source/message/stack/occurredAt/count`,提交请求携带 `submissionId` 做幂等。归档固定为 `events.jsonl`,服务端使用 `agc/error-reports/v1/{yyyy}/{mm}/{dd}/{batchId}.zip` 私有 OSS key;元数据只保留 batch、用户、状态、大小、SHA-256 和 OSS key,事件正文/说明/日志从归档读取。OSS 不可用时状态为 `failed`,不自动重试。
- 2026-09-01 追加:`application.log` 不再写结构化错误事件;Rust `app_log!` 和 WebView console 都写入普通文本 raw log,结构化事件仅保留在当前进程内,提交时才生成 ZIP 内的 `events.jsonl`
- 2026-09-01 review 收口:错误报告修复详情请求竞态、下载 anchor 生命周期、客户端采集脱敏/指纹降级与 4xx 噪声、用户级幂等隔离、`agc` 私有 OSS 前缀越权、日志读取链接检查、ZIP 同名日志和元数据/归档清理一致性;同步在 `review.txt` 标注仍需产品/运维决定的架构项。
@@ -8,6 +8,8 @@ AI Game Creator Shell 采用 IDEA 风格的当前进程错误报告:错误事
- 捕获 React render error、`window.onerror``unhandledrejection` 以及显式标记的 Tauri/API/Agent 错误。
- 事件字段包括 eventId、fingerprint、source、message、stack、时间和次数;重复事件合并。不再携带 severity、errorCode、page、action、requestId 等无法稳定关联的字段。
- 指纹计算可使用调用方的 page/action 作为进程内区分输入,但这些上下文不会作为事件字段上传;消息与 stack 在入池前统一脱敏,WebCrypto 失败时降级为稳定可读指纹,采集本身不得产生新的未处理拒绝。
- 客户端 API 自动采集只覆盖网络错误、408 和 5xx;预期的 4xx 登录/鉴权失败不进入错误报告池。
- Rust 侧通过 `app_log!` 将普通文本日志同时输出到 stderr 和 AppData `diagnostics/application.log`,超出 256 KiB 滚动到 `application.previous.log`WebView 的 console 输出通过 `append_application_log` 镜像到同一 raw log`read_diagnostic_logs` 只读取应用级日志。
- 报告面板允许填写最多 2,000 字中文描述并取消日志附件;本版本不支持截图或任意文件附件。
- 上传失败只在当前进程显示失败并允许用户再次提交,不跨重启恢复事件池,不后台自动重试。
@@ -17,6 +19,7 @@ AI Game Creator Shell 采用 IDEA 风格的当前进程错误报告:错误事
- 登录态客户端使用 `POST /api/error-reports`,请求 DTO 位于 `shared-contracts::error_reports`
- api-server 对请求体设置 24 MiB 上限,并校验 schemaVersion、submissionId、事件/日志数量和 20 MiB 压缩包上限;事件字段、用户说明和日志名/内容均做长度限制与基础脱敏,归档使用 `events.jsonl`(每行一个事件)。结构化事件只保存在当前进程内,用户提交时才生成 `events.jsonl`,不在磁盘单独持久化。submissionId 提供重放幂等。
- 归档对象使用固定私有 OSS key:`agc/error-reports/v1/{yyyy}/{mm}/{dd}/{batchId}.zip`api-server 先写 `uploading` 元数据,上传成功后记录 `ossObjectKey`、SHA-256、大小和 `ready` 状态。完整事件、说明和日志不进入元数据记录。
- `agc` 是服务端专用私有前缀;公共直传票据、通用 object-key 规范化和 legacy 公开路径均拒绝该前缀。归档内同名日志会自动加数字后缀,读取本机诊断日志时拒绝符号链接/非普通文件。
- 后台接口:`GET/PATCH /admin/api/error-reports/{batchId}``GET /admin/api/error-reports` 和受保护的 `/download`
- admin viewer 仅接受 error-reports Tab 权限,支持列表筛选、详情、状态 `new/in-progress/resolved`、处理备注和受控下载。
- 当前兼容实现仍在 api-server 配置目录旁保留元数据与本地归档副本,便于无 OSS 配置的开发环境运行;生产配置启用 OSS 后以 OSS 对象为完整内容来源。SpacetimeDB `error_report` 私有表接入及 30 天 OSS/元数据清理 worker 为后续门禁,HTTP DTO 与管理员权限保持不变。
+4 -1
View File
@@ -105,7 +105,10 @@ pub(crate) async fn create_direct_upload_ticket_for_owner(
"supported": platform_oss::LEGACY_PUBLIC_PREFIXES,
}))
})?;
if matches!(legacy_prefix, LegacyAssetPrefix::EditorAgent) {
if matches!(
legacy_prefix,
LegacyAssetPrefix::EditorAgent | LegacyAssetPrefix::AgcErrorReports
) {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"field": "legacyPrefix",
+100 -12
View File
@@ -7,36 +7,38 @@ use std::{
};
use axum::{
Json, Router,
extract::{DefaultBodyLimit, Extension, Path as AxumPath, Query, State},
http::{header, HeaderValue, StatusCode},
http::{HeaderValue, StatusCode, header},
middleware,
response::Response,
routing::{get, post},
Json, Router,
};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::{Digest, Sha256};
use time::{format_description::well_known::Rfc3339, OffsetDateTime};
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
use tokio::sync::Mutex;
use uuid::Uuid;
use zip::{write::FileOptions, ZipWriter};
use zip::{ZipWriter, write::FileOptions};
use platform_oss::{LegacyAssetPrefix, OssObjectAccess, OssPutObjectRequest};
#[cfg(test)]
use shared_contracts::error_reports::ErrorReportLogInput;
use shared_contracts::error_reports::{
AdminErrorReportEntry, AdminErrorReportListQuery, AdminErrorReportListResponse,
AdminUpdateErrorReportRequest, CreateErrorReportBatchRequest, CreateErrorReportBatchResponse,
ErrorReportLogInput, Event,
Event,
};
use crate::{
admin::{require_admin_auth, AuthenticatedAdmin},
admin::{AuthenticatedAdmin, require_admin_auth},
api_response::json_success_body,
auth::{require_bearer_auth, AuthenticatedAccessToken},
auth::{AuthenticatedAccessToken, require_bearer_auth},
http_error::AppError,
request_context::RequestContext,
state::AppState,
tracking::{record_tracking_event_after_success, TrackingEventDraft},
tracking::{TrackingEventDraft, record_tracking_event_after_success},
};
const MAX_EVENTS: usize = 100;
@@ -363,6 +365,7 @@ pub(crate) fn build_error_report_archive(
zip.write_all(description.as_bytes())
.map_err(|error| error.to_string())?;
}
let mut used_log_names = HashSet::new();
for (name, content) in logs {
let safe_name = name
.chars()
@@ -373,7 +376,13 @@ pub(crate) fn build_error_report_archive(
if safe_name.is_empty() {
continue;
}
zip.start_file(format!("system-logs/{safe_name}"), options)
let mut archive_name = safe_name.clone();
let mut suffix = 2;
while !used_log_names.insert(archive_name.clone()) {
archive_name = format!("{safe_name}.{suffix}");
suffix += 1;
}
zip.start_file(format!("system-logs/{archive_name}"), options)
.map_err(|error| error.to_string())?;
zip.write_all(content.as_bytes())
.map_err(|error| error.to_string())?;
@@ -498,7 +507,9 @@ impl ErrorReportStore {
}
if let Ok(bytes) = fs::read(item.path()) {
if let Ok(existing) = serde_json::from_slice::<StoredErrorReport>(&bytes) {
if existing.submission_id == stored.submission_id {
if existing.user_id == stored.user_id
&& existing.submission_id == stored.submission_id
{
return Ok(existing);
}
}
@@ -624,6 +635,7 @@ impl ErrorReportStore {
&path,
&serde_json::to_vec_pretty(&report).map_err(|error| error.to_string())?,
)?;
refresh_archive_mtime(&self.archive_path(batch_id));
Ok(report)
}
@@ -655,6 +667,7 @@ impl ErrorReportStore {
&path,
&serde_json::to_vec_pretty(&report).map_err(|e| e.to_string())?,
)?;
refresh_archive_mtime(&self.archive_path(batch_id));
Ok(report)
}
@@ -673,6 +686,7 @@ impl ErrorReportStore {
&path,
&serde_json::to_vec_pretty(&report).map_err(|e| e.to_string())?,
)?;
refresh_archive_mtime(&self.archive_path(batch_id));
Ok(report)
}
@@ -690,21 +704,42 @@ impl ErrorReportStore {
let cutoff = std::time::SystemTime::now()
.checked_sub(std::time::Duration::from_secs(30 * 24 * 60 * 60));
for item in directory.flatten() {
let Ok(metadata) = item.metadata() else {
let path = item.path();
if path.extension().and_then(|value| value.to_str()) != Some("json") {
continue;
}
let Ok(metadata) = fs::symlink_metadata(&path) else {
continue;
};
if metadata.file_type().is_symlink() || !metadata.is_file() {
continue;
}
let Some(cutoff) = cutoff else { continue };
if metadata
.modified()
.ok()
.is_some_and(|modified| modified < cutoff)
{
let _ = fs::remove_file(item.path());
let _ = fs::remove_file(&path);
let _ = fs::remove_file(path.with_extension("zip"));
}
}
}
}
fn refresh_archive_mtime(path: &Path) {
let Ok(metadata) = fs::symlink_metadata(path) else {
return;
};
if metadata.file_type().is_symlink() || !metadata.is_file() {
return;
}
let Ok(file) = fs::OpenOptions::new().append(true).open(path) else {
return;
};
let _ = file.set_modified(std::time::SystemTime::now());
}
fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), String> {
let temp_path = path.with_extension(format!("tmp-{}", Uuid::new_v4()));
fs::write(&temp_path, bytes).map_err(|error| error.to_string())?;
@@ -838,6 +873,22 @@ mod tests {
assert!(zip.by_name("system-logs/application.log").is_ok());
}
#[test]
fn archive_disambiguates_duplicate_log_names() {
let archive = build_error_report_archive(
&[],
None,
&[
("application.log".to_string(), "one".to_string()),
("application.log".to_string(), "two".to_string()),
],
)
.expect("archive should build");
let mut zip = zip::ZipArchive::new(Cursor::new(archive)).expect("zip should open");
assert!(zip.by_name("system-logs/application.log").is_ok());
assert!(zip.by_name("system-logs/application.log.2").is_ok());
}
#[test]
fn sanitize_report_text_redacts_credentials_and_paths() {
assert_eq!(
@@ -912,4 +963,41 @@ mod tests {
assert_eq!(log.len(), 3_000);
let _ = fs::remove_dir_all(directory);
}
#[tokio::test]
async fn store_scopes_submission_idempotency_to_user() {
let directory =
std::env::temp_dir().join(format!("agc-error-reports-idempotency-{}", Uuid::new_v4()));
let store = ErrorReportStore::new(&directory);
let payload = || CreateErrorReportBatchRequest {
schema_version: 1,
submission_id: "same-submission".to_string(),
events: vec![Event {
event_id: "event".to_string(),
fingerprint: "fingerprint".to_string(),
source: "source".to_string(),
message: "message".to_string(),
stack: None,
occurred_at: "now".to_string(),
count: 1,
}],
user_description: None,
logs: Vec::new(),
};
let first = store
.create("user-1".to_string(), payload())
.await
.expect("first report should be stored");
let second = store
.create("user-2".to_string(), payload())
.await
.expect("second report should be stored");
assert_ne!(first.batch_id, second.batch_id);
let replay = store
.create("user-1".to_string(), payload())
.await
.expect("replay should be idempotent");
assert_eq!(replay.batch_id, first.batch_id);
let _ = fs::remove_dir_all(directory);
}
}
+5 -1
View File
@@ -339,7 +339,11 @@ impl LegacyAssetPrefix {
pub fn from_object_key(raw: &str) -> Option<Self> {
let normalized = raw.trim().trim_start_matches('/').trim();
let prefix = normalized.split('/').next()?;
Self::parse(prefix)
match Self::parse(prefix) {
// agc/error-reports is a server-only write prefix, never a caller-supplied object key.
Some(Self::AgcErrorReports) => None,
other => other,
}
}
}