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 <willem.jiang@gmail.com>
This commit is contained in:
huyengiang101086-spec 2026-10-25 22:08:31 +08:00 committed by GitHub
parent 4285e2b60c
commit 79399d0c22
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 70 additions and 12 deletions

View File

@ -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.

View File

@ -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.

View File

@ -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):

View File

@ -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()