From 79399d0c22d35a98cc0bb9b12d35189059c7d2d8 Mon Sep 17 00:00:00 2001 From: huyengiang101086-spec Date: Sun, 25 Oct 2026 22:08:31 +0800 Subject: [PATCH] fix(uploads): invalidate edited markdown companions (#7348) * fix(uploads): include markdown modification time in ownership records * test(uploads): reject same-size edited companions on Windows * fix(uploads): distinguish legacy companion records --------- Co-authored-by: Willem Jiang --- README.md | 20 +++++---- .../harness/deerflow/uploads/AGENTS.md | 10 +++++ .../harness/deerflow/uploads/companions.py | 10 +++-- backend/tests/test_upload_companions.py | 42 +++++++++++++++++++ 4 files changed, 70 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index f4c78bb76b..e03379b9e3 100644 --- a/README.md +++ b/README.md @@ -2924,15 +2924,17 @@ Outline titles are limited to 200 characters and fallback previews to 2,000 characters per file, with truncation markers. Full uploaded files remain available for targeted reads. -Converted upload outlines and previews require a matching source version, including -modification timestamps. A detected source-version change invalidates its previous -conversion, including equal-length edits. Older ownership records without source timestamps -are also rejected; re-upload the source with `uploads.auto_convert_documents: true` -to restore conversion-backed outlines. Files remain available, and unvalidated -Markdown conversions appear as standalone files in the agent's historical listing. -On Windows, `st_ctime_ns` may represent creation time; a same-size rewrite that -restores the original `mtime` can evade validation. Timestamp checks are -conservative metadata validation, not a guarantee of content equality. +Converted upload outlines and previews require matching source and Markdown versions, +including modification timestamps. A detected change to either file invalidates its +previous conversion association, including equal-length edits. Older ownership records +without source timestamps or the Markdown modification timestamp are also rejected; +re-upload the source with `uploads.auto_convert_documents: true` to restore +conversion-backed outlines. Debug logs distinguish these legacy records, which need +re-registration, from a changed file. Files remain available, and unvalidated Markdown +conversions appear as standalone files in the agent's historical listing. +On Windows, `st_ctime_ns` may represent creation time; a same-size rewrite of either +file that restores the original `mtime` can evade validation. Timestamp checks are +conservative metadata validation, not a guarantee of content equality or tamper resistance. Sandbox image downloads enforce the 20 MiB vision limit while reading, including model-context rereads. Oversized streams stop at the first over-limit chunk. Custom sandbox providers must accept and enforce the `download_file(..., max_bytes=...)` keyword; older implementations fail the image read instead of falling back to an unrestricted download. diff --git a/backend/packages/harness/deerflow/uploads/AGENTS.md b/backend/packages/harness/deerflow/uploads/AGENTS.md index 45e6b33434..10645fa798 100644 --- a/backend/packages/harness/deerflow/uploads/AGENTS.md +++ b/backend/packages/harness/deerflow/uploads/AGENTS.md @@ -48,3 +48,13 @@ the descriptor whenever `fdopen` itself failed. Pass the raw descriptor only until `fdopen` succeeds, and keep cleanup best-effort so a leftover staged record is reported rather than becoming the caller's error. Coverage: `backend/tests/test_atomic_cleanup_preserves_primary_error.py`. + +Companion ownership validates both source and Markdown device, inode, size, +mtime and ctime. Legacy three-field source identities and four-field Markdown +identities fail closed with distinct debug diagnostics; never backfill their +missing timestamps on read. Re-upload/re-convert to register a fresh association. +Keep legacy rejection, unchanged record bytes, and explicit re-registration +coverage in `backend/tests/test_upload_companions.py`. +These metadata checks detect ordinary edits; they do not guarantee content +integrity against a sandbox writer that can restore timestamps. Content hashing +is a separate enhancement. diff --git a/backend/packages/harness/deerflow/uploads/companions.py b/backend/packages/harness/deerflow/uploads/companions.py index c79ccb22ab..98f9598654 100644 --- a/backend/packages/harness/deerflow/uploads/companions.py +++ b/backend/packages/harness/deerflow/uploads/companions.py @@ -51,7 +51,7 @@ def register_companion(original: Path, markdown: Path) -> None: "original": original.name, "markdown": markdown.name, "source_identity": [source_stat.st_dev, source_stat.st_ino, source_stat.st_size, source_stat.st_mtime_ns, source_stat.st_ctime_ns], - "markdown_identity": [markdown_stat.st_dev, markdown_stat.st_ino, markdown_stat.st_size, markdown_stat.st_ctime_ns], + "markdown_identity": [markdown_stat.st_dev, markdown_stat.st_ino, markdown_stat.st_size, markdown_stat.st_mtime_ns, markdown_stat.st_ctime_ns], }, output, ) @@ -137,8 +137,12 @@ def resolve_companion(original: Path) -> Path | None: if not markdown.is_file() or markdown.is_symlink(): return None markdown_stat = markdown.stat(follow_symlinks=False) - if data.get("markdown_identity") != [markdown_stat.st_dev, markdown_stat.st_ino, markdown_stat.st_size, markdown_stat.st_ctime_ns]: - logger.debug("Rejected upload companion for %r: Markdown identity or version timestamp does not match", original.name) + if data.get("markdown_identity") != [markdown_stat.st_dev, markdown_stat.st_ino, markdown_stat.st_size, markdown_stat.st_mtime_ns, markdown_stat.st_ctime_ns]: + markdown_identity = data.get("markdown_identity") + if isinstance(markdown_identity, list) and len(markdown_identity) == 4: + logger.debug("Rejected upload companion for %r: legacy Markdown identity lacks modification timestamp; re-register companion", original.name) + else: + logger.debug("Rejected upload companion for %r: Markdown identity or version timestamp does not match", original.name) return None return markdown except (OSError, ValueError, KeyError, TypeError): diff --git a/backend/tests/test_upload_companions.py b/backend/tests/test_upload_companions.py index 98935d9ac4..6bd46cf65a 100644 --- a/backend/tests/test_upload_companions.py +++ b/backend/tests/test_upload_companions.py @@ -100,6 +100,26 @@ def test_each_source_timestamp_invalidates_companion(converted_upload, monkeypat assert "source identity or version timestamps do not match" in caplog.text +def test_same_size_markdown_edit_invalidates_companion_on_windows(converted_upload, monkeypatch, caplog): + original, markdown = converted_upload + before = markdown.stat() + real_stat = Path.stat + + def changed_markdown_stat(path, *args, **kwargs): + result = real_stat(path, *args, **kwargs) + if path == markdown: + fields = {name: getattr(before, name) for name in ("st_mode", "st_dev", "st_ino", "st_size", "st_mtime_ns", "st_ctime_ns")} + fields["st_mtime_ns"] += 2_000_000_000 + return SimpleNamespace(**fields) + return result + + monkeypatch.setattr(Path, "stat", changed_markdown_stat) + with caplog.at_level("DEBUG", logger="deerflow.uploads.companions"): + assert resolve_companion(original) is None + assert "Markdown identity or version timestamp does not match" in caplog.text + assert "legacy Markdown identity" not in caplog.text + + def test_legacy_source_record_requires_reregistration(converted_upload, caplog): original, markdown = converted_upload record = next((original.parent.parent.parent / "upload-companions").glob("*.json")) @@ -118,6 +138,28 @@ def test_legacy_source_record_requires_reregistration(converted_upload, caplog): assert resolve_companion(original) == markdown +def test_legacy_markdown_record_requires_reregistration(converted_upload, caplog): + original, markdown = converted_upload + record = next((original.parent.parent.parent / "upload-companions").glob("*.json")) + data = json.loads(record.read_text(encoding="utf-8")) + # The old four-field format retained ctime but did not record mtime. + data["markdown_identity"] = data["markdown_identity"][:3] + data["markdown_identity"][4:] + record.write_text(json.dumps(data), encoding="utf-8") + legacy = record.read_bytes() + + with caplog.at_level("DEBUG", logger="deerflow.uploads.companions"): + assert resolve_companion(original) is None + assert "legacy Markdown identity lacks modification timestamp; re-register companion" in caplog.text + assert "Markdown identity or version timestamp does not match" not in caplog.text + assert companion_names(original.parent) == set() + assert extract_outline_for_file(original) == ([], []) + assert record.read_bytes() == legacy # Do not bless stale content on read. + + register_companion(original, markdown) + assert resolve_companion(original) == markdown + assert extract_outline_for_file(original)[0] == [{"title": "Original A", "line": 1}] + + def test_new_conversion_restores_outline_after_source_edit(converted_upload): original, markdown = converted_upload before = original.stat()