deer-flow/backend/tests/test_uploads_manager.py
Nan Gao a83b244a8c
fix(uploads): keep in-flight staging files out of the startup sweep (#6445)
* fix(uploads): keep in-flight staging files out of the startup sweep

`cleanup_stale_upload_staging_files` removed every `.upload-*.part` it
found at Gateway startup. That assumed a single Gateway: nothing can be
uploading while it boots, so every staging file is a crash leftover.
With several replicas sharing one home volume (Helm RWX), a replica
starting during a rolling update deleted the staging file of an upload
another replica was still writing; that upload then failed at its
`os.link` commit and lost its bytes.

The sweep now skips staging files younger than `UPLOAD_STAGING_MIN_AGE`
(24 hours, the same guard as the project-document `.staging` orphan
sweep in `projects/trash.py`). Chunk writes refresh the mtime, so an
upload in flight stays younger than the guard; an unreadable mtime keeps
the file. The startup call site is unchanged and uses the default.

Tests: the existing sweep tests backdate their orphans; new cases pin
young-kept / old-removed, the configurable `min_age`, unreadable and
vanished mtimes, and a router test that runs the sweep while an upload
is blocked mid-write and asserts the upload still commits.

* docs(changelog): link #6445

* fix(uploads): reclaim published staging aliases at any age

Review follow-up (#6445): a Gateway that dies after the commit's
`os.link` but before the staged-name removal leaves the final file and
its `.upload-*.part` alias on one inode. With only the age guard, the
next startup kept that alias for 24 hours, and because the sweep runs
only at startup, `copy_upload_file_no_symlink` (embedded
`DeerFlowClient.upload_files`) kept rejecting the destination for its
multiple links.

The sweep now stats each staging name with `os.lstat` (a `DirEntry`
stat reports `st_nlink == 0` on Windows) and removes a name with
`st_nlink > 1` at any age: a staged part only ever gains its second
link through its own commit, so such a name is a published alias, never
an upload in flight. Lone parts keep the 24 hour mtime guard. The
destination hardlink check is unchanged.

Tests cover the alias next to a young lone part, the replacement upload
succeeding afterwards, and the crash reproduced through the real upload
path with the staged-name removal patched out.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-08 07:45:29 +08:00

608 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Tests for deerflow.uploads.manager — shared upload management logic."""
import errno
import os
import shutil
import stat
import time
from datetime import timedelta
from unittest.mock import patch
import pytest
from deerflow.uploads.manager import (
PathTraversalError,
UnsafeUploadPathError,
apply_upload_sandbox_permits,
claim_unique_filename,
cleanup_stale_upload_staging_files,
copy_upload_file_no_symlink,
delete_file_safe,
list_files_in_dir,
normalize_filename,
validate_path_traversal,
validate_upload_destination,
write_upload_file_no_symlink,
)
@pytest.mark.skipif(not (hasattr(os, "O_NOFOLLOW") and hasattr(os, "fchmod")), reason="POSIX-only: O_NOFOLLOW + fchmod")
def test_apply_upload_sandbox_permits_propagates_permission_errors(tmp_path):
upload = tmp_path / "attachment.bin"
upload.write_bytes(b"attachment")
upload.chmod(0o600)
with patch.object(os, "fchmod", side_effect=PermissionError("permission denied")):
with pytest.raises(PermissionError, match="permission denied"):
apply_upload_sandbox_permits(upload, stat.S_IRGRP | stat.S_IROTH)
assert stat.S_IMODE(upload.stat().st_mode) == 0o600
def test_apply_upload_sandbox_permits_fallback_propagates_permission_errors(tmp_path, monkeypatch):
upload = tmp_path / "attachment.bin"
upload.write_bytes(b"attachment")
upload.chmod(0o600)
monkeypatch.delattr(os, "O_NOFOLLOW", raising=False)
with patch.object(os, "chmod", side_effect=PermissionError("permission denied")):
with pytest.raises(PermissionError, match="permission denied"):
apply_upload_sandbox_permits(upload, stat.S_IRGRP | stat.S_IROTH)
# ---------------------------------------------------------------------------
# normalize_filename
# ---------------------------------------------------------------------------
class TestNormalizeFilename:
def test_safe_filename(self):
assert normalize_filename("report.pdf") == "report.pdf"
def test_strips_path_components(self):
assert normalize_filename("../../etc/passwd") == "passwd"
def test_rejects_empty(self):
with pytest.raises(ValueError, match="empty"):
normalize_filename("")
def test_rejects_dot_dot(self):
with pytest.raises(ValueError, match="unsafe"):
normalize_filename("..")
def test_strips_separators(self):
assert normalize_filename("path/to/file.txt") == "file.txt"
def test_dot_only(self):
with pytest.raises(ValueError, match="unsafe"):
normalize_filename(".")
def test_rejects_embedded_nul(self):
with pytest.raises(ValueError, match="NUL"):
normalize_filename("report\x00.pdf")
@pytest.mark.parametrize("filename", [".upload-notes.part", ".upload-.part", "folder/.upload-notes.part"])
def test_rejects_reserved_staging_names(self, filename):
with pytest.raises(ValueError, match="reserved upload staging"):
normalize_filename(filename)
@pytest.mark.parametrize("filename", [".upload-notes.txt", "notes.part", ".env", ".UPLOAD-notes.txt", "notes.PART"])
def test_keeps_non_staging_names(self, filename):
assert normalize_filename(filename) == filename
def test_reserved_name_rejected_before_writing_upload(self, tmp_path):
with pytest.raises(ValueError, match="reserved upload staging"):
write_upload_file_no_symlink(tmp_path, ".upload-notes.part", b"user document")
assert list(tmp_path.iterdir()) == []
@pytest.mark.parametrize(
"filename",
["CON", "con.txt", "PRN", "AUX", "NUL", "COM1", "COM9", "LPT1", "LPT9", "COM¹", "com².txt", "COM³.log", "LPT¹", "lpt².md", "LPT³.pdf", "file.txt.", "file.txt ", "a.", "folder/CON"],
)
def test_rejects_windows_incompatible_names(self, filename):
with pytest.raises(ValueError, match="not portable to Windows"):
normalize_filename(filename)
@pytest.mark.parametrize("filename", ["CONIN$", "conin$", "CONOUT$", "conout$", "CONIN$.txt", "folder/CONOUT$.log"])
def test_rejects_windows_console_device_names(self, filename):
with pytest.raises(ValueError, match="reserved Windows device name"):
normalize_filename(filename)
@pytest.mark.parametrize("filename", ["CONIN", "CONOUT", "CONIN$notes.txt", "CONOUT$notes.log"])
def test_allows_names_resembling_console_devices(self, filename):
assert normalize_filename(filename) == filename
@pytest.mark.parametrize("filename", ["report.pdf", "contour.txt", "console.log", "COM0", "COM10", "COM⁴.txt", "LPT⁵", "COM¹notes.txt", "LPT²0.md", ".gitignore"])
def test_allows_portable_names(self, filename):
assert normalize_filename(filename) == filename
def test_reserved_parent_is_stripped_with_the_directory(self):
assert normalize_filename("CON/notes.md") == "notes.md"
# ---------------------------------------------------------------------------
# claim_unique_filename
# ---------------------------------------------------------------------------
class TestDeduplicateFilename:
def test_no_collision(self):
seen: set[str] = set()
assert claim_unique_filename("data.txt", seen) == "data.txt"
assert "data.txt" in seen
def test_single_collision(self):
seen = {"data.txt"}
assert claim_unique_filename("data.txt", seen) == "data_1.txt"
assert "data_1.txt" in seen
def test_triple_collision(self):
seen = {"data.txt", "data_1.txt", "data_2.txt"}
assert claim_unique_filename("data.txt", seen) == "data_3.txt"
assert "data_3.txt" in seen
def test_mutates_seen(self):
seen: set[str] = set()
claim_unique_filename("a.txt", seen)
claim_unique_filename("a.txt", seen)
assert seen == {"a.txt", "a_1.txt"}
def test_max_length_name_stays_within_filename_limit(self):
# A 255-byte name passes normalize_filename; the deduplicated name
# must not exceed that limit, or the write path rejects it.
name = "a" * 251 + ".txt"
seen = {name}
deduped = claim_unique_filename(name, seen)
assert deduped != name
assert deduped.endswith("_1.txt")
assert len(deduped.encode("utf-8")) <= 255
# The truncated result must round-trip through normalize_filename.
assert normalize_filename(deduped) == deduped
def test_max_length_collisions_stay_unique_across_truncation(self):
name = "a" * 251 + ".txt"
seen = {name}
first = claim_unique_filename(name, seen)
second = claim_unique_filename(name, seen)
assert first != second
assert len(second.encode("utf-8")) <= 255
def test_multibyte_stem_is_truncated_on_a_codepoint_boundary(self):
# 85 CJK chars × 3 bytes = 255 bytes.
name = "深" * 85
seen = {name}
deduped = claim_unique_filename(name, seen)
assert len(deduped.encode("utf-8")) <= 255
assert deduped.endswith("_1")
# No replacement characters / decode artifacts.
deduped.encode("utf-8").decode("utf-8")
def test_short_names_keep_existing_dedupe_shape(self):
seen = {"data.txt"}
assert claim_unique_filename("data.txt", seen) == "data_1.txt"
# ---------------------------------------------------------------------------
# validate_path_traversal
# ---------------------------------------------------------------------------
class TestValidatePathTraversal:
def test_inside_base_ok(self, tmp_path):
child = tmp_path / "file.txt"
child.touch()
validate_path_traversal(child, tmp_path) # no exception
def test_outside_base_raises(self, tmp_path):
outside = tmp_path / ".." / "evil.txt"
with pytest.raises(PathTraversalError, match="traversal"):
validate_path_traversal(outside, tmp_path)
def test_symlink_escape(self, tmp_path):
target = tmp_path.parent / "secret.txt"
target.touch()
link = tmp_path / "escape"
try:
link.symlink_to(target)
except OSError as exc:
if getattr(exc, "winerror", None) == 1314:
pytest.skip("symlink creation requires Developer Mode or elevated privileges on Windows")
raise
with pytest.raises(PathTraversalError, match="traversal"):
validate_path_traversal(link, tmp_path)
# ---------------------------------------------------------------------------
# write_upload_file_no_symlink
# ---------------------------------------------------------------------------
class TestWriteUploadFileNoSymlink:
def test_writes_new_file(self, tmp_path):
dest = write_upload_file_no_symlink(tmp_path, "notes.txt", b"hello")
assert dest == tmp_path / "notes.txt"
assert dest.read_bytes() == b"hello"
def test_overwrites_existing_regular_file_with_single_link(self, tmp_path):
dest = tmp_path / "notes.txt"
dest.write_bytes(b"old contents")
assert os.stat(dest).st_nlink == 1
result = write_upload_file_no_symlink(tmp_path, "notes.txt", b"new contents")
assert result == dest
assert dest.read_bytes() == b"new contents"
assert os.stat(dest).st_nlink == 1
def test_fallback_without_no_follow_support_succeeds(self, tmp_path, monkeypatch):
monkeypatch.delattr(os, "O_NOFOLLOW", raising=False)
# When O_NOFOLLOW is absent (Windows), the function falls back to
# a dual-lstat + fstat approach and succeeds.
result = write_upload_file_no_symlink(tmp_path, "notes.txt", b"hello")
assert result == tmp_path / "notes.txt"
assert (tmp_path / "notes.txt").read_bytes() == b"hello"
def test_open_uses_nonblocking_flag_when_available(self, tmp_path):
if not hasattr(os, "O_NONBLOCK"):
pytest.skip("O_NONBLOCK not available on this platform")
with patch("deerflow.uploads.manager.os.open", side_effect=OSError(errno.ENXIO, "no reader")) as open_mock:
with pytest.raises(UnsafeUploadPathError, match="Unsafe upload destination"):
write_upload_file_no_symlink(tmp_path, "pipe.txt", b"hello")
flags = open_mock.call_args.args[1]
assert flags & os.O_NONBLOCK
@pytest.mark.parametrize("open_errno", [errno.ENXIO, errno.EAGAIN])
def test_nonblocking_special_file_open_errors_are_unsafe(self, tmp_path, open_errno):
if not hasattr(os, "O_NONBLOCK"):
pytest.skip("O_NONBLOCK not available on this platform")
with patch("deerflow.uploads.manager.os.open", side_effect=OSError(open_errno, "would block")):
with pytest.raises(UnsafeUploadPathError, match="Unsafe upload destination"):
write_upload_file_no_symlink(tmp_path, "pipe.txt", b"hello")
assert not (tmp_path / "pipe.txt").exists()
# ---------------------------------------------------------------------------
# copy_upload_file_no_symlink
# ---------------------------------------------------------------------------
class TestCopyUploadFileNoSymlink:
def test_copies_content_mode_and_timestamps(self, tmp_path):
uploads = tmp_path / "uploads"
uploads.mkdir()
src = tmp_path / "notes.txt"
src.write_bytes(b"hello")
os.chmod(src, 0o640)
os.utime(src, ns=(1_700_000_000_000_000_000, 1_700_000_000_000_000_000))
dest = copy_upload_file_no_symlink(uploads, "notes.txt", src)
assert dest == uploads / "notes.txt"
assert dest.read_bytes() == b"hello"
if os.chmod in os.supports_fd:
assert stat.S_IMODE(os.stat(dest).st_mode) == 0o640
if os.utime in os.supports_fd:
assert os.stat(dest).st_mtime_ns == 1_700_000_000_000_000_000
def test_overwrites_existing_regular_file(self, tmp_path):
uploads = tmp_path / "uploads"
uploads.mkdir()
(uploads / "notes.txt").write_bytes(b"old contents")
src = tmp_path / "notes.txt"
src.write_bytes(b"new contents")
dest = copy_upload_file_no_symlink(uploads, "notes.txt", src)
assert dest.read_bytes() == b"new contents"
def test_rejects_symlink_destination(self, tmp_path):
uploads = tmp_path / "uploads"
uploads.mkdir()
outside = tmp_path / "outside.txt"
outside.write_bytes(b"original")
link = uploads / "notes.txt"
try:
link.symlink_to(outside)
except OSError as exc:
if getattr(exc, "winerror", None) == 1314:
pytest.skip("symlink creation requires Developer Mode or elevated privileges on Windows")
raise
src = tmp_path / "notes.txt"
src.write_bytes(b"attacker-chosen target")
with pytest.raises(UnsafeUploadPathError):
copy_upload_file_no_symlink(uploads, "notes.txt", src)
assert outside.read_bytes() == b"original"
assert link.is_symlink()
def test_rejects_copying_a_file_onto_itself(self, tmp_path):
uploads = tmp_path / "uploads"
uploads.mkdir()
src = uploads / "notes.txt"
src.write_bytes(b"IMPORTANT")
with pytest.raises(shutil.SameFileError):
copy_upload_file_no_symlink(uploads, "notes.txt", src)
assert src.read_bytes() == b"IMPORTANT"
def test_rejects_a_hardlink_to_the_destination(self, tmp_path):
"""Identity, not path text: another name for the same inode is the same file."""
uploads = tmp_path / "uploads"
uploads.mkdir()
dest = uploads / "notes.txt"
dest.write_bytes(b"IMPORTANT")
src = uploads / "same-inode.txt"
try:
os.link(dest, src)
except (OSError, NotImplementedError) as exc:
pytest.skip(f"hardlinks unavailable on this platform: {exc}")
with pytest.raises(shutil.SameFileError):
copy_upload_file_no_symlink(uploads, "notes.txt", src)
assert dest.read_bytes() == b"IMPORTANT"
def test_missing_source_leaves_existing_destination_untouched(self, tmp_path):
uploads = tmp_path / "uploads"
uploads.mkdir()
(uploads / "notes.txt").write_bytes(b"keep me")
with pytest.raises(FileNotFoundError):
copy_upload_file_no_symlink(uploads, "notes.txt", tmp_path / "missing.txt")
assert (uploads / "notes.txt").read_bytes() == b"keep me"
# ---------------------------------------------------------------------------
# list_files_in_dir
# ---------------------------------------------------------------------------
class TestListFilesInDir:
def test_empty_dir(self, tmp_path):
result = list_files_in_dir(tmp_path)
assert result == {"files": [], "count": 0}
def test_nonexistent_dir(self, tmp_path):
result = list_files_in_dir(tmp_path / "nope")
assert result == {"files": [], "count": 0}
def test_multiple_files_sorted(self, tmp_path):
(tmp_path / "b.txt").write_text("b")
(tmp_path / "a.txt").write_text("a")
result = list_files_in_dir(tmp_path)
assert result["count"] == 2
assert result["files"][0]["filename"] == "a.txt"
assert result["files"][1]["filename"] == "b.txt"
for f in result["files"]:
assert set(f.keys()) == {"filename", "size", "path", "extension", "modified"}
def test_ignores_subdirectories(self, tmp_path):
(tmp_path / "file.txt").write_text("data")
(tmp_path / "subdir").mkdir()
result = list_files_in_dir(tmp_path)
assert result["count"] == 1
assert result["files"][0]["filename"] == "file.txt"
def test_filters_only_upload_staging_files(self, tmp_path):
(tmp_path / ".env").write_text("intentional dotfile")
(tmp_path / ".upload-active.part").write_text("partial")
(tmp_path / ".upload-note.txt").write_text("intentional upload")
(tmp_path / "draft.part").write_text("intentional upload")
(tmp_path / "visible.txt").write_text("visible")
result = list_files_in_dir(tmp_path)
assert result["count"] == 4
assert [f["filename"] for f in result["files"]] == [".env", ".upload-note.txt", "draft.part", "visible.txt"]
# ---------------------------------------------------------------------------
# cleanup_stale_upload_staging_files
# ---------------------------------------------------------------------------
def _set_age(path, age: timedelta) -> None:
"""Backdate *path*'s mtime so the cleanup sees it as *age* old."""
timestamp = time.time() - age.total_seconds()
os.utime(path, (timestamp, timestamp))
class TestCleanupStaleUploadStagingFiles:
def test_removes_only_stale_staging_files_from_all_upload_layouts(self, tmp_path):
legacy_uploads = tmp_path / "threads" / "thread-legacy" / "user-data" / "uploads"
user_uploads = tmp_path / "users" / "owner-1" / "threads" / "thread-owned" / "user-data" / "uploads"
unrelated_uploads = tmp_path / "misc" / "thread-other" / "user-data" / "uploads"
for uploads_dir in (legacy_uploads, user_uploads, unrelated_uploads):
uploads_dir.mkdir(parents=True)
(legacy_uploads / ".upload-old.part").write_text("legacy partial")
(user_uploads / ".upload-new.part").write_text("user partial")
(unrelated_uploads / ".upload-ignore.part").write_text("outside layout")
(legacy_uploads / ".env").write_text("intentional dotfile")
(legacy_uploads / ".upload-note.txt").write_text("intentional upload")
(legacy_uploads / "draft.part").write_text("intentional upload")
for path in (
legacy_uploads / ".upload-old.part",
user_uploads / ".upload-new.part",
unrelated_uploads / ".upload-ignore.part",
legacy_uploads / ".env",
legacy_uploads / ".upload-note.txt",
legacy_uploads / "draft.part",
):
_set_age(path, timedelta(days=2))
removed = cleanup_stale_upload_staging_files(tmp_path)
assert removed == 2
assert not (legacy_uploads / ".upload-old.part").exists()
assert not (user_uploads / ".upload-new.part").exists()
assert (unrelated_uploads / ".upload-ignore.part").exists()
assert (legacy_uploads / ".env").exists()
assert (legacy_uploads / ".upload-note.txt").exists()
assert (legacy_uploads / "draft.part").exists()
def test_keeps_staging_files_younger_than_the_default_guard(self, tmp_path):
"""On a volume shared by several Gateway replicas, a young ``.part`` may be
an upload another replica is still writing; only old ones are orphans."""
uploads_dir = tmp_path / "users" / "owner-1" / "threads" / "thread-1" / "user-data" / "uploads"
uploads_dir.mkdir(parents=True)
fresh = uploads_dir / ".upload-fresh.part"
recent = uploads_dir / ".upload-recent.part"
old = uploads_dir / ".upload-old.part"
for path in (fresh, recent, old):
path.write_text("partial")
_set_age(recent, timedelta(hours=23))
_set_age(old, timedelta(hours=25))
removed = cleanup_stale_upload_staging_files(tmp_path)
assert removed == 1
assert fresh.exists()
assert recent.exists()
assert not old.exists()
def test_min_age_is_configurable(self, tmp_path):
uploads_dir = tmp_path / "threads" / "thread-1" / "user-data" / "uploads"
uploads_dir.mkdir(parents=True)
young = uploads_dir / ".upload-young.part"
old = uploads_dir / ".upload-old.part"
for path in (young, old):
path.write_text("partial")
_set_age(young, timedelta(minutes=1))
_set_age(old, timedelta(minutes=10))
removed = cleanup_stale_upload_staging_files(tmp_path, min_age=timedelta(minutes=5))
assert removed == 1
assert young.exists()
assert not old.exists()
def test_keeps_staging_file_when_its_age_cannot_be_read(self, tmp_path, caplog):
uploads_dir = tmp_path / "threads" / "thread-1" / "user-data" / "uploads"
uploads_dir.mkdir(parents=True)
old = uploads_dir / ".upload-old.part"
old.write_text("partial")
_set_age(old, timedelta(days=2))
with (
patch("deerflow.uploads.manager._staging_entry_stat", side_effect=PermissionError(errno.EACCES, "denied")),
caplog.at_level("WARNING", logger="deerflow.uploads.manager"),
):
removed = cleanup_stale_upload_staging_files(tmp_path)
assert removed == 0
assert old.exists()
assert any("keeping it" in record.getMessage() for record in caplog.records)
def test_removes_published_alias_at_any_age_but_keeps_lone_young_part(self, tmp_path):
"""A crash between the commit's ``os.link`` and the staged-name removal
leaves the final file and its ``.part`` alias sharing one inode. The
alias is reclaimed on the next startup regardless of age, or the
destination would fail the multi-link check on its next replacement;
a lone young part next to it is still treated as in flight."""
uploads_dir = tmp_path / "users" / "owner-1" / "threads" / "thread-1" / "user-data" / "uploads"
uploads_dir.mkdir(parents=True)
published = uploads_dir / "notes.txt"
published.write_bytes(b"published bytes")
alias = uploads_dir / ".upload-abc123.part"
try:
os.link(published, alias)
except OSError as exc: # pragma: no cover - filesystems without hard links
pytest.skip(f"hard links unsupported here: {exc}")
in_flight = uploads_dir / ".upload-inflight.part"
in_flight.write_bytes(b"partial")
with pytest.raises(UnsafeUploadPathError, match="multiple links"):
validate_upload_destination(uploads_dir, "notes.txt")
removed = cleanup_stale_upload_staging_files(tmp_path)
assert removed == 1
assert not alias.exists()
assert in_flight.exists()
assert published.read_bytes() == b"published bytes"
assert os.lstat(published).st_nlink == 1
assert validate_upload_destination(uploads_dir, "notes.txt") == published
replacement = tmp_path / "replacement.txt"
replacement.write_bytes(b"replacement bytes")
assert copy_upload_file_no_symlink(uploads_dir, "notes.txt", replacement) == published
assert published.read_bytes() == b"replacement bytes"
def test_skips_staging_file_that_vanished_before_its_age_was_read(self, tmp_path, caplog):
uploads_dir = tmp_path / "threads" / "thread-1" / "user-data" / "uploads"
uploads_dir.mkdir(parents=True)
(uploads_dir / ".upload-gone.part").write_text("partial")
with (
patch("deerflow.uploads.manager._staging_entry_stat", side_effect=FileNotFoundError),
caplog.at_level("WARNING", logger="deerflow.uploads.manager"),
):
removed = cleanup_stale_upload_staging_files(tmp_path)
assert removed == 0
assert caplog.records == []
# ---------------------------------------------------------------------------
# delete_file_safe
# ---------------------------------------------------------------------------
class TestDeleteFileSafe:
def test_delete_existing_file(self, tmp_path):
f = tmp_path / "test.txt"
f.write_text("data")
result = delete_file_safe(tmp_path, "test.txt")
assert result["success"] is True
assert not f.exists()
def test_delete_nonexistent_raises(self, tmp_path):
with pytest.raises(FileNotFoundError):
delete_file_safe(tmp_path, "nope.txt")
def test_delete_traversal_raises(self, tmp_path):
with pytest.raises(PathTraversalError, match="traversal"):
delete_file_safe(tmp_path, "../outside.txt")
def test_delete_keeps_the_converted_markdown(self, tmp_path):
"""Companion ownership cannot be proven from the name, so nothing is guessed at."""
(tmp_path / "a.docx").write_bytes(b"DOCX")
(tmp_path / "a.md").write_text("converted from the docx", encoding="utf-8")
(tmp_path / "a.pdf").write_bytes(b"PDF")
(tmp_path / "a_1.md").write_text("converted from the pdf", encoding="utf-8")
result = delete_file_safe(tmp_path, "a.pdf")
assert result["success"] is True
assert not (tmp_path / "a.pdf").exists()
# a.md belongs to a.docx; deleting a.pdf used to remove it.
assert (tmp_path / "a.md").read_text(encoding="utf-8") == "converted from the docx"
assert (tmp_path / "a_1.md").read_text(encoding="utf-8") == "converted from the pdf"
def test_delete_symlink_to_sibling_upload_keeps_target(self, tmp_path):
"""A symlink planted in the uploads dir must not delete the upload it aliases."""
victim = tmp_path / "victim.pdf"
victim.write_bytes(b"pdf-bytes")
companion = tmp_path / "victim.md"
companion.write_text("converted", encoding="utf-8")
alias = tmp_path / "alias.pdf"
try:
alias.symlink_to(victim.name)
except OSError as exc:
if getattr(exc, "winerror", None) == 1314:
pytest.skip("symlink creation requires Developer Mode or elevated privileges on Windows")
raise
with pytest.raises(FileNotFoundError):
delete_file_safe(tmp_path, "alias.pdf")
assert victim.read_bytes() == b"pdf-bytes"
assert companion.exists()
assert alias.is_symlink()