mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-27 21:50:36 +00:00
* fix(client): convert the captured upload source * test(uploads): cover ACL sources and scope capture failures * fix(uploads): preserve concurrent uploads from one captured source Combine the embedded client's source capture with no-replace publication for first-time filenames and Markdown companions. Hold one private capture across collision retries, retain original descriptor metadata, and keep replacement behavior for names present when the call starts. Port concurrent-upload and staging-publication regressions from PR #6687. Extend source-edit/removal and real XLSX cases to both publication paths, and cover edits or removal during two filename collisions in ordinary and event-loop calls. Document capture, retry, publication, and cleanup contracts. Validation: related suites 469 passed, 24 skipped; full offline suite 28,788 passed, 488 skipped; blocking-I/O suite 245 passed, 1 skipped; Ruff, guidance, and diff checks passed. Nine race/collision cases failed on the #6632 baseline. Co-authored-by: haotianqi666 <250384991+haotianqi666@users.noreply.github.com> * docs: keep agent guidance within CI budgets --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com> Co-authored-by: haotianqi666 <250384991+haotianqi666@users.noreply.github.com>
202 lines
7.4 KiB
Python
202 lines
7.4 KiB
Python
"""Upload publication rejects substituted staging paths and preserves outcomes."""
|
|
|
|
import os
|
|
import shutil
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from support.symlinks import symlink_or_skip
|
|
|
|
from deerflow.uploads.manager import UnsafeUploadPathError, copy_upload_file_no_replace
|
|
|
|
|
|
@pytest.fixture
|
|
def upload_paths(tmp_path):
|
|
uploads = tmp_path / "uploads"
|
|
uploads.mkdir()
|
|
source = tmp_path / "notes.txt"
|
|
source.write_bytes(b"uploaded content")
|
|
outside = tmp_path / "outside.txt"
|
|
outside.write_bytes(b"must not be published")
|
|
return uploads, source, outside
|
|
|
|
|
|
def replace_staging(staging, outside, replacement):
|
|
try:
|
|
staging.unlink()
|
|
except PermissionError:
|
|
if os.name == "nt":
|
|
pytest.skip("host prevents replacing an open staging file")
|
|
raise
|
|
if replacement == "symlink":
|
|
symlink_or_skip(staging, outside)
|
|
else:
|
|
staging.write_bytes(b"substituted content")
|
|
|
|
|
|
@pytest.mark.parametrize("replacement", ["symlink", "regular"])
|
|
@pytest.mark.parametrize("nofollow", [True, False])
|
|
def test_rejects_staging_replaced_during_copy(upload_paths, monkeypatch, replacement, nofollow):
|
|
uploads, source, outside = upload_paths
|
|
real_copy = shutil.copyfileobj
|
|
monkeypatch.setattr(os, "supports_follow_symlinks", {os.link} if nofollow else set())
|
|
|
|
def copy_and_swap(src, dst):
|
|
real_copy(src, dst)
|
|
staging = next(uploads.glob(".upload-*.part"))
|
|
replace_staging(staging, outside, replacement)
|
|
|
|
monkeypatch.setattr(shutil, "copyfileobj", copy_and_swap)
|
|
with pytest.raises(UnsafeUploadPathError):
|
|
copy_upload_file_no_replace(uploads, "notes.txt", source)
|
|
|
|
assert not os.path.lexists(uploads / "notes.txt")
|
|
assert outside.read_bytes() == b"must not be published"
|
|
assert outside.stat().st_nlink == 1
|
|
assert list(uploads.iterdir()) == []
|
|
|
|
|
|
@pytest.mark.parametrize("replacement", ["symlink", "regular"])
|
|
@pytest.mark.parametrize("nofollow", [True, False])
|
|
def test_rejects_staging_replaced_at_link(upload_paths, monkeypatch, replacement, nofollow):
|
|
uploads, source, outside = upload_paths
|
|
real_link = os.link
|
|
if nofollow and real_link not in os.supports_follow_symlinks:
|
|
pytest.skip("host does not support no-follow hard links")
|
|
calls = []
|
|
|
|
def swap_and_link(staging, dest, **kwargs):
|
|
replace_staging(staging, outside, replacement)
|
|
calls.append(kwargs)
|
|
real_link(staging, dest, **kwargs)
|
|
|
|
monkeypatch.setattr(os, "link", swap_and_link)
|
|
monkeypatch.setattr(os, "supports_follow_symlinks", {swap_and_link} if nofollow else set())
|
|
with pytest.raises(UnsafeUploadPathError):
|
|
copy_upload_file_no_replace(uploads, "notes.txt", source)
|
|
|
|
assert calls == [{"follow_symlinks": False} if nofollow else {}]
|
|
assert not os.path.lexists(uploads / "notes.txt")
|
|
assert outside.read_bytes() == b"must not be published"
|
|
assert outside.stat().st_nlink == 1
|
|
assert list(uploads.iterdir()) == []
|
|
|
|
|
|
@pytest.mark.parametrize("nofollow", [True, False])
|
|
def test_publishes_with_supported_link_options(upload_paths, monkeypatch, nofollow):
|
|
uploads, source, _outside = upload_paths
|
|
real_link = os.link
|
|
if nofollow and real_link not in os.supports_follow_symlinks:
|
|
pytest.skip("host does not support no-follow hard links")
|
|
calls = []
|
|
|
|
def record_link(staging, dest, **kwargs):
|
|
calls.append(kwargs)
|
|
real_link(staging, dest, **kwargs)
|
|
|
|
monkeypatch.setattr(os, "link", record_link)
|
|
monkeypatch.setattr(os, "supports_follow_symlinks", {record_link} if nofollow else set())
|
|
dest = copy_upload_file_no_replace(uploads, "notes.txt", source)
|
|
|
|
assert dest.read_bytes() == b"uploaded content"
|
|
assert dest.stat().st_nlink == 1
|
|
assert calls == [{"follow_symlinks": False} if nofollow else {}]
|
|
|
|
|
|
def test_cleanup_directory_swap_does_not_mask_success(upload_paths, monkeypatch, caplog):
|
|
uploads, source, _outside = upload_paths
|
|
real_link = os.link
|
|
planted = []
|
|
|
|
def publish_and_swap(staging, dest, **kwargs):
|
|
real_link(staging, dest, **kwargs)
|
|
try:
|
|
staging.unlink()
|
|
except PermissionError:
|
|
if os.name == "nt":
|
|
pytest.skip("host prevents replacing an open staging file")
|
|
raise
|
|
staging.mkdir()
|
|
planted.append(staging)
|
|
|
|
monkeypatch.setattr(os, "link", publish_and_swap)
|
|
dest = copy_upload_file_no_replace(uploads, "notes.txt", source)
|
|
|
|
assert dest.read_bytes() == b"uploaded content"
|
|
assert planted[0].is_dir()
|
|
assert "Failed to remove upload staging file" in caplog.text
|
|
|
|
|
|
def test_cleanup_error_does_not_mask_copy_failure(upload_paths, monkeypatch, caplog):
|
|
uploads, source, _outside = upload_paths
|
|
real_unlink = Path.unlink
|
|
|
|
def fail_copy(src, dst):
|
|
dst.write(b"partial")
|
|
raise OSError("original copy failure")
|
|
|
|
def fail_cleanup(path, *args, **kwargs):
|
|
if path.name.startswith(".upload-"):
|
|
raise PermissionError("cleanup failure")
|
|
return real_unlink(path, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(shutil, "copyfileobj", fail_copy)
|
|
monkeypatch.setattr(Path, "unlink", fail_cleanup)
|
|
with pytest.raises(OSError, match="original copy failure"):
|
|
copy_upload_file_no_replace(uploads, "notes.txt", source)
|
|
|
|
assert not (uploads / "notes.txt").exists()
|
|
assert "Failed to remove upload staging file" in caplog.text
|
|
|
|
|
|
def test_cleanup_error_does_not_mask_collision(upload_paths, monkeypatch, caplog):
|
|
uploads, source, _outside = upload_paths
|
|
dest = uploads / "notes.txt"
|
|
dest.write_bytes(b"keep existing upload")
|
|
real_unlink = Path.unlink
|
|
|
|
def fail_cleanup(path, *args, **kwargs):
|
|
if path.name.startswith(".upload-"):
|
|
raise PermissionError("cleanup failure")
|
|
return real_unlink(path, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(Path, "unlink", fail_cleanup)
|
|
with pytest.raises(FileExistsError):
|
|
copy_upload_file_no_replace(uploads, "notes.txt", source)
|
|
|
|
assert dest.read_bytes() == b"keep existing upload"
|
|
assert "Failed to remove upload staging file" in caplog.text
|
|
|
|
|
|
@pytest.mark.parametrize("collisions", [0, 2])
|
|
def test_publication_retry_uses_original_seed(upload_paths, collisions):
|
|
from deerflow.uploads.manager import publish_upload_copy
|
|
|
|
uploads, source, _outside = upload_paths
|
|
seen = {"notes.txt", "notes_1.txt"}
|
|
for index in range(1, collisions + 1):
|
|
(uploads / f"notes_{index}.txt").write_bytes(f"existing-{index}".encode())
|
|
|
|
dest = publish_upload_copy(uploads, "notes_1.txt", source, claim_seed="notes.txt", seen_names=seen)
|
|
|
|
assert dest.name == f"notes_{collisions + 1}.txt"
|
|
assert dest.read_bytes() == b"uploaded content"
|
|
assert dest.name in seen
|
|
for index in range(1, collisions + 1):
|
|
assert (uploads / f"notes_{index}.txt").read_bytes() == f"existing-{index}".encode()
|
|
assert not list(uploads.glob(".upload-*.part"))
|
|
|
|
|
|
def test_publication_retry_preserves_noncollision_errors(upload_paths, monkeypatch):
|
|
from deerflow.uploads import manager
|
|
|
|
uploads, source, _outside = upload_paths
|
|
|
|
def fail_link(*args, **kwargs):
|
|
raise PermissionError("publication denied")
|
|
|
|
monkeypatch.setattr(manager.os, "link", fail_link)
|
|
with pytest.raises(PermissionError, match="publication denied"):
|
|
manager.publish_upload_copy(uploads, "notes.txt", source, claim_seed="notes.txt", seen_names={"notes.txt"})
|
|
assert list(uploads.iterdir()) == []
|