deer-flow/backend/tests/test_sandbox_quarantine.py
万家良 c324f351ee
fix(sandbox): truncate quarantine path keys to stay under Windows MAX_PATH (#6880)
* fix(sandbox): truncate quarantine path keys to stay under Windows MAX_PATH

The quarantine nests three hashed path components (scope, sandbox id,
container id). Three full 64-char SHA-256 hexdigests exceed Windows'
260-char MAX_PATH once the quarantine root lives under a long path, so
mark() fails with ENOENT creating the generation record. Truncate the
digest to 16 hex chars, matching the sandbox-naming convention used
elsewhere in this module.

* test(sandbox): pin quarantine key truncation to 16 hex chars

Guard against a regression that reverts the key digest to a full 64-char
SHA-256 hexdigest: three nested 64-char components exceed Windows' 260-char
MAX_PATH and re-break record creation, while Linux CI still passes.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-15 11:00:01 +08:00

104 lines
4.8 KiB
Python

"""Quarantine follows a container generation across provider restarts."""
import pytest
from deerflow.community.aio_sandbox.quarantine import SandboxQuarantine
from deerflow.community.aio_sandbox.sandbox_info import SandboxInfo
def test_quarantine_survives_restart_and_does_not_taint_replacement(tmp_path):
old = SandboxInfo("thread-sandbox", "http://sandbox", container_id="old-generation")
replacement = SandboxInfo("thread-sandbox", "http://sandbox", container_id="new-generation")
SandboxQuarantine(tmp_path, "provisioner-a").mark(old)
restarted = SandboxQuarantine(tmp_path, "provisioner-a")
assert restarted.contains(old)
assert not restarted.contains(replacement)
assert not SandboxQuarantine(tmp_path, "provisioner-b").contains(old)
def test_discovery_without_generation_cannot_bypass_quarantine(tmp_path):
store = SandboxQuarantine(tmp_path, "provisioner")
store.mark(SandboxInfo("thread-sandbox", "http://sandbox", container_id="old-generation"))
assert store.contains(SandboxInfo("thread-sandbox", "http://sandbox"))
assert not store.contains(SandboxInfo("another-thread", "http://sandbox"))
def test_quarantine_storage_failure_is_not_treated_as_safe(tmp_path, monkeypatch):
store = SandboxQuarantine(tmp_path, "provisioner")
def unavailable(*_args, **_kwargs):
raise PermissionError("quarantine storage unavailable")
monkeypatch.setattr(type(tmp_path), "stat", unavailable)
with pytest.raises(PermissionError):
store.contains(SandboxInfo("thread-sandbox", "http://sandbox", container_id="generation"))
def test_crash_residue_between_directory_and_record_still_fences(tmp_path):
# mark() mkdirs the per-sandbox directory before writing the generation
# record; a process dying in between must not silently drop the fence.
store = SandboxQuarantine(tmp_path, "provisioner")
directory = tmp_path / store._key("provisioner") / store._key("thread-sandbox")
directory.mkdir(parents=True)
assert store.contains(SandboxInfo("thread-sandbox", "http://sandbox"))
assert store.contains(SandboxInfo("thread-sandbox", "http://sandbox", container_id="crashed-generation"))
def test_retired_quarantine_allows_replacements_without_generation(tmp_path):
store = SandboxQuarantine(tmp_path, "provisioner")
old = SandboxInfo("thread-sandbox", "http://sandbox")
store.mark(old)
store.mark(SandboxInfo(old.sandbox_id, old.sandbox_url, container_id="old-generation"))
other = SandboxInfo("another-thread", "http://sandbox")
store.mark(other)
store.retire(old)
restarted = SandboxQuarantine(tmp_path, "provisioner")
assert not restarted.contains(old)
assert not restarted.contains(SandboxInfo(old.sandbox_id, old.sandbox_url, container_id="new-generation"))
assert restarted.contains(other)
def test_retire_replaced_generations_drops_only_absent_fences(tmp_path):
"""A live replacement proves the fenced old generation is gone; its record
is pruned while the live generation's own fence survives."""
store = SandboxQuarantine(tmp_path, "provisioner")
old = SandboxInfo("thread-sandbox", "http://sandbox", container_id="old-generation")
live = SandboxInfo("thread-sandbox", "http://sandbox", container_id="new-generation")
store.mark(old)
directory = tmp_path / store._key("provisioner") / store._key("thread-sandbox")
store.retire_replaced_generations(live)
assert not store.contains(old)
assert not directory.exists() # fully pruned, no stale record strands future creates
directory.mkdir() # a peer fences the live generation mid-flight
directory.joinpath(store._key("new-generation")).touch()
store.retire_replaced_generations(live)
assert store.contains(live)
def test_retire_replaced_generations_keeps_unversioned_crash_fence(tmp_path):
store = SandboxQuarantine(tmp_path, "provisioner")
store.mark(SandboxInfo("thread-sandbox", "http://sandbox")) # container_id-less mark → "unknown"
directory = tmp_path / store._key("provisioner") / store._key("thread-sandbox")
store.retire_replaced_generations(SandboxInfo("thread-sandbox", "http://sandbox", container_id="new-generation"))
assert (directory / "unknown").exists()
def test_quarantine_keys_are_truncated_for_windows_max_path():
# The quarantine nests three hashed path components (scope, sandbox id,
# container id). Three full 64-char SHA-256 hexdigests exceed Windows'
# 260-char MAX_PATH under a long root, so each key is truncated to 16 hex
# chars (64 bits). Pin that bound so a revert can't silently re-break
# Windows record creation while Linux CI still passes.
assert len(SandboxQuarantine._key("thread-sandbox")) == 16
assert len(SandboxQuarantine._key("provisioner-a")) == 16
assert len(SandboxQuarantine._key("old-generation")) == 16