mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-15 09:08:38 +00:00
852 lines
31 KiB
Python
852 lines
31 KiB
Python
"""Tests for deerflow.uploads.manager — shared upload management logic."""
|
|
|
|
import asyncio
|
|
import errno
|
|
import multiprocessing
|
|
import os
|
|
import threading
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from pathlib import Path
|
|
from queue import Empty
|
|
from typing import Any
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from deerflow.uploads.async_helpers import release_published_upload_async
|
|
from deerflow.uploads.layout import (
|
|
artifact_url_for_virtual_path,
|
|
conversion_path_for_upload,
|
|
conversion_virtual_path,
|
|
)
|
|
from deerflow.uploads.lease import UploadNameLease
|
|
from deerflow.uploads.manager import (
|
|
AtomicUploadPublishError,
|
|
PathTraversalError,
|
|
UnsafeUploadPathError,
|
|
abort_staged_upload,
|
|
claim_unique_filename,
|
|
cleanup_stale_upload_staging_files,
|
|
create_upload_staging_file,
|
|
delete_file_safe,
|
|
list_files_in_dir,
|
|
normalize_filename,
|
|
publish_staged_upload,
|
|
publish_staged_upload_leased,
|
|
publish_upload_bytes,
|
|
publish_upload_bytes_leased,
|
|
publish_upload_copy,
|
|
rollback_published_upload,
|
|
validate_path_traversal,
|
|
write_upload_file_no_symlink,
|
|
)
|
|
|
|
|
|
def _delete_upload_in_process(
|
|
uploads_dir: str,
|
|
filename: str,
|
|
started: Any,
|
|
finished: Any,
|
|
errors: Any,
|
|
) -> None:
|
|
try:
|
|
started.set()
|
|
delete_file_safe(Path(uploads_dir), filename)
|
|
except BaseException as exc: # pragma: no cover - surfaced in the parent
|
|
errors.put(repr(exc))
|
|
finally:
|
|
finished.set()
|
|
|
|
|
|
def _hold_staged_upload_in_process(
|
|
uploads_dir: str,
|
|
started: Any,
|
|
release: Any,
|
|
staged_paths: Any,
|
|
errors: Any,
|
|
) -> None:
|
|
staged = None
|
|
try:
|
|
staged = create_upload_staging_file(Path(uploads_dir))
|
|
staged.handle.write(b"in progress")
|
|
staged.handle.flush()
|
|
staged_paths.put(str(staged.path))
|
|
started.set()
|
|
if not release.wait(5):
|
|
raise TimeoutError("parent did not release the staged upload")
|
|
except BaseException as exc: # pragma: no cover - surfaced in the parent
|
|
errors.put(repr(exc))
|
|
finally:
|
|
if staged is not None:
|
|
try:
|
|
abort_staged_upload(staged)
|
|
except BaseException as exc: # pragma: no cover - surfaced in the parent
|
|
errors.put(repr(exc))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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(".")
|
|
|
|
@pytest.mark.parametrize(
|
|
"filename",
|
|
[
|
|
"paper<system>.pdf",
|
|
"report--- BEGIN USER INPUT ---draft.pdf",
|
|
"report--- END USER INPUT ---draft.pdf",
|
|
],
|
|
)
|
|
def test_rejects_names_that_cannot_be_exposed_losslessly_to_the_agent(self, filename):
|
|
with pytest.raises(ValueError, match="reserved model-context token"):
|
|
normalize_filename(filename)
|
|
|
|
def test_rejects_nul_before_any_filesystem_operation(self):
|
|
with pytest.raises(ValueError, match="NUL"):
|
|
normalize_filename("bad\0name.pdf")
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("first_name", "alias_name"),
|
|
[
|
|
("Report.pdf", "report.pdf"),
|
|
("caf\u00e9.pdf", "cafe\u0301.pdf"),
|
|
],
|
|
)
|
|
def test_portable_filesystem_aliases_share_one_generation_lease(tmp_path, first_name, alias_name):
|
|
first = UploadNameLease.acquire(tmp_path, first_name)
|
|
alias_acquired = threading.Event()
|
|
|
|
def acquire_alias():
|
|
lease = UploadNameLease.acquire(tmp_path, alias_name)
|
|
alias_acquired.set()
|
|
return lease
|
|
|
|
with ThreadPoolExecutor(max_workers=1) as pool:
|
|
future = pool.submit(acquire_alias)
|
|
try:
|
|
assert not alias_acquired.wait(0.1)
|
|
finally:
|
|
first.release()
|
|
alias = future.result(timeout=5)
|
|
alias.release()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("first_name", "alias_name", "expected_alias_name"),
|
|
[
|
|
("Report.pdf", "report.pdf", "report_1.pdf"),
|
|
("caf\u00e9.pdf", "cafe\u0301.pdf", "cafe\u0301_1.pdf"),
|
|
],
|
|
)
|
|
def test_batch_portable_alias_reservation_chooses_distinct_name(
|
|
tmp_path,
|
|
first_name,
|
|
alias_name,
|
|
expected_alias_name,
|
|
):
|
|
reserved_keys: set[str] = set()
|
|
publications = []
|
|
try:
|
|
for filename, payload in [(first_name, b"first"), (alias_name, b"second")]:
|
|
staged = create_upload_staging_file(tmp_path)
|
|
staged.handle.write(payload)
|
|
publications.append(
|
|
publish_staged_upload_leased(
|
|
staged,
|
|
filename,
|
|
reserved_coordination_keys=reserved_keys,
|
|
)
|
|
)
|
|
finally:
|
|
for publication in reversed(publications):
|
|
publication.release()
|
|
|
|
assert [publication.path.name for publication in publications] == [first_name, expected_alias_name]
|
|
assert {publication.path.read_bytes() for publication in publications} == {b"first", b"second"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_release_commit_delays_and_swallows_new_cancellation(tmp_path, monkeypatch):
|
|
publication = publish_upload_bytes_leased(tmp_path, "report.pdf", b"payload")
|
|
release_started = threading.Event()
|
|
allow_release = threading.Event()
|
|
real_release = UploadNameLease.release
|
|
|
|
def paused_release(lease):
|
|
release_started.set()
|
|
assert allow_release.wait(5)
|
|
real_release(lease)
|
|
|
|
monkeypatch.setattr(UploadNameLease, "release", paused_release)
|
|
task = asyncio.create_task(release_published_upload_async(publication))
|
|
assert await asyncio.to_thread(release_started.wait, 5)
|
|
try:
|
|
task.cancel()
|
|
await asyncio.sleep(0.05)
|
|
assert not task.done()
|
|
finally:
|
|
allow_release.set()
|
|
|
|
await task
|
|
assert not publication.is_active
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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"}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# upload publication
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestUploadPublication:
|
|
def test_unrelated_names_that_shared_a_legacy_stripe_do_not_block(self, tmp_path):
|
|
first = UploadNameLease.acquire(tmp_path, "f0.txt")
|
|
second = None
|
|
pool = ThreadPoolExecutor(max_workers=1)
|
|
try:
|
|
future = pool.submit(UploadNameLease.acquire, tmp_path, "f15.txt")
|
|
second = future.result(timeout=1)
|
|
finally:
|
|
first.release()
|
|
if second is not None:
|
|
second.release()
|
|
pool.shutdown()
|
|
|
|
def test_windows_lock_retries_until_the_holder_releases(self, monkeypatch):
|
|
import deerflow.uploads.lease as lease_module
|
|
|
|
attempts = 0
|
|
|
|
class FakeMsvcrt:
|
|
LK_NBLCK = 1
|
|
|
|
@staticmethod
|
|
def locking(_fd, mode, _length):
|
|
nonlocal attempts
|
|
assert mode == FakeMsvcrt.LK_NBLCK
|
|
attempts += 1
|
|
if attempts < 3:
|
|
raise OSError(errno.EACCES, "locked")
|
|
|
|
class FakeLockFile:
|
|
@staticmethod
|
|
def fileno():
|
|
return 7
|
|
|
|
@staticmethod
|
|
def seek(_offset):
|
|
return None
|
|
|
|
monkeypatch.setattr(lease_module, "fcntl", None)
|
|
monkeypatch.setattr(lease_module, "msvcrt", FakeMsvcrt, raising=False)
|
|
monkeypatch.setattr(lease_module.time, "sleep", lambda _seconds: None)
|
|
|
|
lease_module._lock_file(FakeLockFile())
|
|
|
|
assert attempts == 3
|
|
|
|
def test_reserved_staging_name_is_rejected_before_stage_creation(self, tmp_path):
|
|
with patch("deerflow.uploads.manager.create_upload_staging_file") as create_stage:
|
|
with pytest.raises(ValueError, match="reserved"):
|
|
publish_upload_bytes(tmp_path, ".upload-user.part", b"payload")
|
|
|
|
create_stage.assert_not_called()
|
|
|
|
def test_leased_publication_blocks_delete_until_release(self, tmp_path):
|
|
publication = publish_upload_bytes_leased(tmp_path, "report.pdf", b"old")
|
|
started = threading.Event()
|
|
finished = threading.Event()
|
|
|
|
def delete():
|
|
started.set()
|
|
delete_file_safe(tmp_path, "report.pdf")
|
|
finished.set()
|
|
|
|
worker = threading.Thread(target=delete)
|
|
worker.start()
|
|
try:
|
|
assert started.wait(1)
|
|
assert not finished.wait(0.1)
|
|
finally:
|
|
publication.release()
|
|
worker.join(2)
|
|
|
|
assert finished.is_set()
|
|
|
|
def test_leased_publication_blocks_delete_across_processes(self, tmp_path):
|
|
publication = publish_upload_bytes_leased(tmp_path, "report.pdf", b"old")
|
|
context = multiprocessing.get_context("spawn")
|
|
started = context.Event()
|
|
finished = context.Event()
|
|
errors = context.Queue()
|
|
worker = context.Process(
|
|
target=_delete_upload_in_process,
|
|
args=(str(tmp_path), "report.pdf", started, finished, errors),
|
|
)
|
|
worker.start()
|
|
try:
|
|
assert started.wait(10)
|
|
assert not finished.wait(0.2)
|
|
finally:
|
|
publication.release()
|
|
worker.join(10)
|
|
if worker.is_alive():
|
|
worker.terminate()
|
|
worker.join(2)
|
|
|
|
assert worker.exitcode == 0
|
|
try:
|
|
child_error = errors.get_nowait()
|
|
except Empty:
|
|
child_error = None
|
|
assert child_error is None
|
|
assert finished.is_set()
|
|
|
|
def test_lease_for_one_filename_does_not_block_another(self, tmp_path):
|
|
publication = publish_upload_bytes_leased(tmp_path, "report.pdf", b"old")
|
|
try:
|
|
with ThreadPoolExecutor(max_workers=1) as pool:
|
|
other = pool.submit(publish_upload_bytes, tmp_path, "notes.txt", b"new")
|
|
assert other.result(timeout=1) == tmp_path / "notes.txt"
|
|
finally:
|
|
publication.release()
|
|
|
|
def test_held_existing_name_does_not_block_next_collision_candidate(self, tmp_path):
|
|
first = publish_upload_bytes_leased(tmp_path, "report.pdf", b"first")
|
|
pool = ThreadPoolExecutor(max_workers=1)
|
|
future = pool.submit(publish_upload_bytes_leased, tmp_path, "report.pdf", b"second")
|
|
second = None
|
|
timed_out = False
|
|
try:
|
|
second = future.result(timeout=1)
|
|
except TimeoutError:
|
|
timed_out = True
|
|
finally:
|
|
first.release()
|
|
if second is None:
|
|
second = future.result(timeout=2)
|
|
if second is not None:
|
|
second.release()
|
|
pool.shutdown()
|
|
|
|
assert not timed_out
|
|
assert second is not None
|
|
assert second.path == tmp_path / "report_1.pdf"
|
|
assert second.path.read_bytes() == b"second"
|
|
|
|
def test_rollback_does_not_remove_reused_path(self, tmp_path):
|
|
publication = publish_upload_bytes_leased(tmp_path, "report.pdf", b"old")
|
|
try:
|
|
publication.path.unlink()
|
|
publication.path.write_bytes(b"new")
|
|
rollback_published_upload(publication)
|
|
finally:
|
|
publication.release()
|
|
|
|
assert (tmp_path / "report.pdf").read_bytes() == b"new"
|
|
|
|
def test_rollback_preserves_primary_when_conversion_removal_fails(self, tmp_path):
|
|
publication = publish_upload_bytes_leased(tmp_path, "report.pdf", b"old")
|
|
owned_conversion = conversion_path_for_upload(publication.path)
|
|
owned_conversion.parent.mkdir(exist_ok=True)
|
|
owned_conversion.write_text("generated", encoding="utf-8")
|
|
real_unlink = Path.unlink
|
|
|
|
def fail_conversion_unlink(path, *args, **kwargs):
|
|
if path == owned_conversion:
|
|
raise OSError("cannot unlink conversion")
|
|
return real_unlink(path, *args, **kwargs)
|
|
|
|
try:
|
|
with patch.object(Path, "unlink", autospec=True, side_effect=fail_conversion_unlink):
|
|
with pytest.raises(OSError, match="cannot unlink conversion"):
|
|
rollback_published_upload(publication)
|
|
finally:
|
|
publication.release()
|
|
|
|
assert publication.path.read_bytes() == b"old"
|
|
assert owned_conversion.read_text(encoding="utf-8") == "generated"
|
|
|
|
def test_staging_unlink_failure_is_not_reported_as_success(self, tmp_path):
|
|
staged = create_upload_staging_file(tmp_path)
|
|
staged.handle.write(b"payload")
|
|
real_unlink = Path.unlink
|
|
|
|
def fail_only_for_stage(path, *args, **kwargs):
|
|
if path == staged.path:
|
|
raise OSError("cannot unlink stage")
|
|
return real_unlink(path, *args, **kwargs)
|
|
|
|
with patch.object(Path, "unlink", autospec=True, side_effect=fail_only_for_stage):
|
|
with pytest.raises(AtomicUploadPublishError, match="staging"):
|
|
publish_staged_upload(staged, "report.pdf")
|
|
|
|
assert not (tmp_path / "report.pdf").exists()
|
|
|
|
def test_abort_unlinks_stage_when_close_raises(self, tmp_path):
|
|
staged = create_upload_staging_file(tmp_path)
|
|
|
|
class CloseFailingHandle:
|
|
def __init__(self, wrapped):
|
|
self._wrapped = wrapped
|
|
|
|
@property
|
|
def closed(self):
|
|
return self._wrapped.closed
|
|
|
|
def close(self):
|
|
self._wrapped.close()
|
|
raise OSError("close failed")
|
|
|
|
staged.handle = CloseFailingHandle(staged.handle)
|
|
with pytest.raises(OSError, match="close failed"):
|
|
abort_staged_upload(staged)
|
|
|
|
assert not staged.path.exists()
|
|
|
|
def test_abort_releases_stage_lease_when_unlink_raises(self, tmp_path):
|
|
staged = create_upload_staging_file(tmp_path)
|
|
|
|
with patch.object(Path, "unlink", autospec=True, side_effect=OSError("unlink failed")):
|
|
with pytest.raises(OSError, match="unlink failed"):
|
|
abort_staged_upload(staged)
|
|
|
|
assert not staged.lease.is_active
|
|
staged.path.unlink()
|
|
|
|
def test_compatibility_wrapper_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_existing_regular_file_is_renamed_not_overwritten(self, tmp_path):
|
|
dest = tmp_path / "notes.txt"
|
|
dest.write_bytes(b"old contents")
|
|
assert os.stat(dest).st_nlink == 1
|
|
|
|
result = publish_upload_bytes(tmp_path, "notes.txt", b"new contents")
|
|
|
|
assert result == tmp_path / "notes_1.txt"
|
|
assert dest.read_bytes() == b"old contents"
|
|
assert result.read_bytes() == b"new contents"
|
|
assert os.stat(dest).st_nlink == 1
|
|
|
|
def test_existing_symlink_is_preserved_and_skipped(self, tmp_path):
|
|
outside = tmp_path / "outside.txt"
|
|
outside.write_bytes(b"protected")
|
|
planted = tmp_path / "notes.txt"
|
|
planted.symlink_to(outside)
|
|
|
|
result = publish_upload_bytes(tmp_path, "notes.txt", b"new")
|
|
|
|
assert result == tmp_path / "notes_1.txt"
|
|
assert planted.is_symlink()
|
|
assert outside.read_bytes() == b"protected"
|
|
assert result.read_bytes() == b"new"
|
|
|
|
def test_existing_hard_link_is_preserved_and_skipped(self, tmp_path):
|
|
outside = tmp_path / "outside.txt"
|
|
outside.write_bytes(b"protected")
|
|
planted = tmp_path / "notes.txt"
|
|
os.link(outside, planted)
|
|
|
|
result = publish_upload_bytes(tmp_path, "notes.txt", b"new")
|
|
|
|
assert result == tmp_path / "notes_1.txt"
|
|
assert outside.read_bytes() == b"protected"
|
|
assert planted.read_bytes() == b"protected"
|
|
assert result.read_bytes() == b"new"
|
|
|
|
def test_existing_directory_is_preserved_and_skipped(self, tmp_path):
|
|
planted = tmp_path / "notes.txt"
|
|
planted.mkdir()
|
|
|
|
result = publish_upload_bytes(tmp_path, "notes.txt", b"new")
|
|
|
|
assert result == tmp_path / "notes_1.txt"
|
|
assert planted.is_dir()
|
|
assert result.read_bytes() == b"new"
|
|
|
|
def test_parallel_publication_preserves_every_payload(self, tmp_path):
|
|
payloads = [f"payload-{i}".encode() for i in range(12)]
|
|
|
|
with ThreadPoolExecutor(max_workers=len(payloads)) as pool:
|
|
paths = list(pool.map(lambda payload: publish_upload_bytes(tmp_path, "same.txt", payload), payloads))
|
|
|
|
assert {path.name for path in paths} == {
|
|
"same.txt",
|
|
*(f"same_{i}.txt" for i in range(1, len(payloads))),
|
|
}
|
|
assert {path.read_bytes() for path in paths} == set(payloads)
|
|
assert not list(tmp_path.glob(".upload-*.part"))
|
|
|
|
def test_unsupported_atomic_publish_fails_and_cleans_stage(self, tmp_path):
|
|
with patch(
|
|
"deerflow.uploads.manager.os.link",
|
|
side_effect=OSError(errno.EOPNOTSUPP, "hard links unsupported"),
|
|
):
|
|
with pytest.raises(AtomicUploadPublishError, match="atomic no-replace"):
|
|
publish_upload_bytes(tmp_path, "same.txt", b"payload")
|
|
|
|
assert not (tmp_path / "same.txt").exists()
|
|
assert not list(tmp_path.glob(".upload-*.part"))
|
|
|
|
@pytest.mark.parametrize(
|
|
("name", "expected"),
|
|
[
|
|
("archive.tar.gz", "archive.tar_1.gz"),
|
|
("README", "README_1"),
|
|
(".env", ".env_1"),
|
|
],
|
|
)
|
|
def test_suffix_is_inserted_before_final_extension(self, tmp_path, name, expected):
|
|
(tmp_path / name).write_bytes(b"old")
|
|
|
|
result = publish_upload_bytes(tmp_path, name, b"new")
|
|
|
|
assert result.name == expected
|
|
assert result.read_bytes() == b"new"
|
|
|
|
def test_collision_suffix_keeps_filename_within_255_utf8_bytes(self, tmp_path):
|
|
name = f"{'é' * 125}.txt"
|
|
assert len(name.encode("utf-8")) == 254
|
|
(tmp_path / name).write_bytes(b"old")
|
|
|
|
result = publish_upload_bytes(tmp_path, name, b"new")
|
|
|
|
assert result.name.endswith("_1.txt")
|
|
assert len(result.name.encode("utf-8")) <= 255
|
|
assert result.read_bytes() == b"new"
|
|
|
|
def test_publish_upload_copy_stages_complete_source(self, tmp_path):
|
|
source = tmp_path / "source.bin"
|
|
source.write_bytes(b"source bytes")
|
|
uploads = tmp_path / "uploads"
|
|
uploads.mkdir()
|
|
|
|
result = publish_upload_copy(uploads, "copied.bin", source)
|
|
|
|
assert result == uploads / "copied.bin"
|
|
assert result.read_bytes() == b"source bytes"
|
|
assert not list(uploads.glob(".upload-*.part"))
|
|
|
|
|
|
class TestUploadLayout:
|
|
def test_conversion_layout_uses_full_primary_name(self, tmp_path):
|
|
upload = tmp_path / "user-data" / "uploads" / "report.pdf"
|
|
|
|
assert conversion_path_for_upload(upload) == tmp_path / "user-data" / ".upload-conversions" / "report.pdf.md"
|
|
assert conversion_virtual_path("report.pdf") == "/mnt/user-data/.upload-conversions/report.pdf.md"
|
|
assert artifact_url_for_virtual_path("thread-1", conversion_virtual_path("report #1.pdf")) == ("/api/threads/thread-1/artifacts/mnt/user-data/.upload-conversions/report%20%231.pdf.md")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCleanupStaleUploadStagingFiles:
|
|
def test_skips_stage_held_by_another_process(self, tmp_path):
|
|
uploads = tmp_path / "threads" / "thread-live" / "user-data" / "uploads"
|
|
uploads.mkdir(parents=True)
|
|
context = multiprocessing.get_context("spawn")
|
|
started = context.Event()
|
|
release = context.Event()
|
|
staged_paths = context.Queue()
|
|
errors = context.Queue()
|
|
worker = context.Process(
|
|
target=_hold_staged_upload_in_process,
|
|
args=(str(uploads), started, release, staged_paths, errors),
|
|
)
|
|
worker.start()
|
|
try:
|
|
assert started.wait(5)
|
|
staged_path = Path(staged_paths.get(timeout=1))
|
|
|
|
assert cleanup_stale_upload_staging_files(tmp_path) == 0
|
|
assert staged_path.exists()
|
|
finally:
|
|
release.set()
|
|
worker.join(timeout=5)
|
|
if worker.is_alive():
|
|
worker.terminate()
|
|
worker.join(timeout=5)
|
|
|
|
assert worker.exitcode == 0
|
|
with pytest.raises(Empty):
|
|
errors.get_nowait()
|
|
|
|
def test_skips_live_stage_and_removes_it_after_lease_is_abandoned(self, tmp_path):
|
|
uploads = tmp_path / "threads" / "thread-live" / "user-data" / "uploads"
|
|
uploads.mkdir(parents=True)
|
|
staged = create_upload_staging_file(uploads)
|
|
staged.handle.write(b"in progress")
|
|
|
|
assert cleanup_stale_upload_staging_files(tmp_path) == 0
|
|
assert staged.path.exists()
|
|
|
|
staged.handle.close()
|
|
staged.lease.release()
|
|
assert cleanup_stale_upload_staging_files(tmp_path) == 1
|
|
assert not staged.path.exists()
|
|
|
|
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"
|
|
legacy_conversions = legacy_uploads.parent / ".upload-conversions"
|
|
user_conversions = user_uploads.parent / ".upload-conversions"
|
|
unrelated_uploads = tmp_path / "misc" / "thread-other" / "user-data" / "uploads"
|
|
for uploads_dir in (
|
|
legacy_uploads,
|
|
user_uploads,
|
|
legacy_conversions,
|
|
user_conversions,
|
|
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")
|
|
(legacy_conversions / ".upload-converted-old.part").write_text("legacy conversion partial")
|
|
(user_conversions / ".upload-converted-new.part").write_text("user conversion 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")
|
|
|
|
removed = cleanup_stale_upload_staging_files(tmp_path)
|
|
|
|
assert removed == 4
|
|
assert not (legacy_uploads / ".upload-old.part").exists()
|
|
assert not (user_uploads / ".upload-new.part").exists()
|
|
assert not (legacy_conversions / ".upload-converted-old.part").exists()
|
|
assert not (user_conversions / ".upload-converted-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_does_not_follow_symlinked_conversion_directory(self, tmp_path):
|
|
user_data = tmp_path / "threads" / "thread-legacy" / "user-data"
|
|
(user_data / "uploads").mkdir(parents=True)
|
|
outside = tmp_path / "outside"
|
|
outside.mkdir()
|
|
staged = outside / ".upload-outside.part"
|
|
staged.write_text("outside")
|
|
(user_data / ".upload-conversions").symlink_to(outside, target_is_directory=True)
|
|
|
|
assert cleanup_stale_upload_staging_files(tmp_path) == 0
|
|
assert staged.read_text() == "outside"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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_from_nonexistent_directory_raises_file_not_found(self, tmp_path):
|
|
with pytest.raises(FileNotFoundError, match="ghost.txt"):
|
|
delete_file_safe(tmp_path / "missing-uploads", "ghost.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_rejects_path_components(self, tmp_path):
|
|
primary = tmp_path / "report.pdf"
|
|
primary.write_bytes(b"PDF")
|
|
|
|
with pytest.raises(PathTraversalError, match="traversal"):
|
|
delete_file_safe(tmp_path, "folder/report.pdf")
|
|
|
|
assert primary.exists()
|
|
|
|
def test_delete_rejects_symlink_instead_of_unlinking_target(self, tmp_path):
|
|
outside = tmp_path / "outside.txt"
|
|
outside.write_text("protected", encoding="utf-8")
|
|
uploads = tmp_path / "uploads"
|
|
uploads.mkdir()
|
|
planted = uploads / "report.pdf"
|
|
planted.symlink_to(outside)
|
|
|
|
with pytest.raises(UnsafeUploadPathError):
|
|
delete_file_safe(uploads, "report.pdf")
|
|
|
|
assert planted.is_symlink()
|
|
assert outside.read_text(encoding="utf-8") == "protected"
|
|
|
|
def test_delete_removes_owned_conversion_but_preserves_legacy_sibling(self, tmp_path):
|
|
uploads = tmp_path / "user-data" / "uploads"
|
|
uploads.mkdir(parents=True)
|
|
primary = uploads / "report.pdf"
|
|
primary.write_bytes(b"PDF")
|
|
legacy_or_user = uploads / "report.md"
|
|
legacy_or_user.write_text("user markdown", encoding="utf-8")
|
|
owned = conversion_path_for_upload(primary)
|
|
owned.parent.mkdir()
|
|
owned.write_text("generated", encoding="utf-8")
|
|
|
|
delete_file_safe(uploads, "report.pdf")
|
|
|
|
assert not primary.exists()
|
|
assert not owned.exists()
|
|
assert legacy_or_user.read_text(encoding="utf-8") == "user markdown"
|
|
|
|
def test_delete_preserves_primary_when_conversion_removal_fails(self, tmp_path):
|
|
uploads = tmp_path / "user-data" / "uploads"
|
|
uploads.mkdir(parents=True)
|
|
primary = uploads / "report.pdf"
|
|
primary.write_bytes(b"PDF")
|
|
owned_conversion = conversion_path_for_upload(primary)
|
|
owned_conversion.parent.mkdir()
|
|
owned_conversion.write_text("generated", encoding="utf-8")
|
|
real_unlink = Path.unlink
|
|
|
|
def fail_conversion_unlink(path, *args, **kwargs):
|
|
if path == owned_conversion:
|
|
raise OSError("cannot unlink conversion")
|
|
return real_unlink(path, *args, **kwargs)
|
|
|
|
with patch.object(Path, "unlink", autospec=True, side_effect=fail_conversion_unlink):
|
|
with pytest.raises(OSError, match="cannot unlink conversion"):
|
|
delete_file_safe(uploads, "report.pdf")
|
|
|
|
assert primary.read_bytes() == b"PDF"
|
|
assert owned_conversion.read_text(encoding="utf-8") == "generated"
|