mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
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:
parent
4285e2b60c
commit
79399d0c22
20
README.md
20
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.
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user