test_p3_wp14_local_config_validator.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488
  1. from __future__ import annotations
  2. import hashlib
  3. import os
  4. import sys
  5. import threading
  6. import time
  7. from pathlib import Path
  8. import pytest
  9. import subprocess
  10. ROOT = Path(__file__).resolve().parents[1]
  11. sys.path.insert(0, str(ROOT / "scripts"))
  12. import validate_p3_wp14_local_configs as validator # noqa: E402
  13. def _snapshot() -> dict:
  14. return {
  15. "schema_version": "1.0",
  16. "environment": {"label": "LOCAL_ISOLATED_A", "is_enterprise": False},
  17. "runtime": {"DATAOPS_LOCAL_ENV_LABEL": "LOCAL_ISOLATED_A", "BACKEND_PORT": "19014"},
  18. "compose": {"file": "deploy/docker/docker-compose.yml", "render_command": validator.RENDER_COMMAND},
  19. }
  20. @pytest.mark.parametrize("mutate", [
  21. lambda x: x["runtime"].update({"BACKEND_PORT": "0"}),
  22. lambda x: x["runtime"].update({"DATAOPS_LOCAL_ENV_LABEL": "A"}),
  23. lambda x: x["runtime"].update({"TOKEN": "secret"}),
  24. lambda x: x["environment"].update({"label": 1}),
  25. ])
  26. def test_closed_snapshot_rejects_malicious_scalars(mutate) -> None:
  27. snapshot = _snapshot()
  28. mutate(snapshot)
  29. with pytest.raises(ValueError):
  30. validator._validate_snapshot(snapshot)
  31. def test_render_diff_rejects_extra_path() -> None:
  32. with pytest.raises(ValueError):
  33. validator._assert_render_diff({"/services/backend/labels/com.dataops.local_env"}, {"/extra"})
  34. def test_secure_reader_rejects_symlink_and_hardlink(monkeypatch, tmp_path: Path) -> None:
  35. import p3_wp14_secure_io as secure
  36. monkeypatch.setattr(secure, "TRUSTED_ROOT", tmp_path)
  37. read_bytes_once = secure.read_bytes_once
  38. target = tmp_path / "target.json"
  39. target.write_text("{}")
  40. link = tmp_path / "link.json"
  41. link.symlink_to(target)
  42. hard = tmp_path / "hard.json"
  43. hard.hardlink_to(target)
  44. with pytest.raises(ValueError): read_bytes_once(link)
  45. with pytest.raises(ValueError): read_bytes_once(hard)
  46. def test_secure_reader_rejects_parent_symlink_and_lock_replacement(monkeypatch, tmp_path: Path) -> None:
  47. import p3_wp14_secure_io as secure
  48. monkeypatch.setattr(secure, "TRUSTED_ROOT", tmp_path)
  49. exclusive_lock, read_bytes_once = secure.exclusive_lock, secure.read_bytes_once
  50. real = tmp_path / "real"; real.mkdir()
  51. (real / "input.json").write_text("{}")
  52. parent_link = tmp_path / "parent-link"; parent_link.symlink_to(real, target_is_directory=True)
  53. with pytest.raises(OSError): read_bytes_once(parent_link / "input.json")
  54. marker = tmp_path / "no-marker.lock"
  55. with exclusive_lock(marker): pass
  56. assert not marker.exists()
  57. def test_config_timeout_marks_prior_render_invalid(monkeypatch, tmp_path: Path) -> None:
  58. import p3_wp14_secure_io as secure
  59. monkeypatch.setattr(secure, "TRUSTED_ROOT", tmp_path)
  60. monkeypatch.setattr(validator, "RENDER_RECORD", tmp_path / "render.json")
  61. monkeypatch.setattr(validator, "LOCK", tmp_path / "config-lock/lock")
  62. (tmp_path / "config-lock").mkdir()
  63. monkeypatch.setattr(validator, "_main_locked", lambda: (_ for _ in ()).throw(subprocess.TimeoutExpired(["docker"], 30)))
  64. assert validator.main() == 1
  65. import json
  66. result = json.loads((tmp_path / "render.json").read_text())
  67. assert result["status"] == "FAILED" and result["invalidates_prior_pass"] is True
  68. def test_ledger_timeout_marks_prior_ledger_invalid(monkeypatch, tmp_path: Path) -> None:
  69. import generate_p3_wp14_verification_ledger as ledger
  70. import p3_wp14_secure_io as secure
  71. monkeypatch.setattr(secure, "TRUSTED_ROOT", tmp_path)
  72. monkeypatch.setattr(ledger, "STATE", tmp_path / "state.json")
  73. monkeypatch.setattr(ledger, "LOCK", tmp_path / "state.lock")
  74. monkeypatch.setattr(ledger, "_main_locked", lambda *_: (_ for _ in ()).throw(subprocess.TimeoutExpired(["pytest"], 300)))
  75. assert ledger.main() == 1
  76. import json
  77. state = json.loads((tmp_path / "state.json").read_text())
  78. assert state["status"] == "FAILED" and state["invalidates_prior_ledger_and_trace"] is True
  79. def test_expired_ledger_is_not_fresh() -> None:
  80. import generate_p3_wp14_verification_ledger as ledger
  81. from datetime import datetime, timezone
  82. assert not ledger.ledger_is_fresh({"expires_at": "2000-01-01T00:00:00Z"}, datetime.now(timezone.utc))
  83. def test_dependency_drift_for_nonfirst_target_and_secure_io_is_detectable() -> None:
  84. import hashlib, json
  85. ledger = json.loads((ROOT / "docs/validation/P3_WP14_LOCAL_VERIFICATION_LEDGER.json").read_text())
  86. run = next(item for item in ledger["runs"] if item["command_id"] == "p3_targeted_regression")
  87. for path in ("tests/test_wp13_plugin_platform_api.py", "scripts/p3_wp14_secure_io.py"):
  88. actual = hashlib.sha256((ROOT / path).read_bytes()).hexdigest()
  89. assert run["dependency_sha256"][path] != "0" * 64
  90. assert ("0" * 64) != actual
  91. def test_manifest_minimal_env_rejects_git_injection(monkeypatch) -> None:
  92. import generate_p3_wp14_release_manifest as manifest
  93. monkeypatch.setenv("GIT_DIR", "/attacker")
  94. monkeypatch.setenv("GIT_WORK_TREE", "/attacker")
  95. monkeypatch.setenv("COMPOSE_FILE", "/attacker/compose.yml")
  96. monkeypatch.setenv("HOME", "/attacker/home-with-gitconfig")
  97. seen = {}
  98. def fake_run(command, **kwargs):
  99. seen.update(command=command, **kwargs)
  100. return subprocess.CompletedProcess(command, 0, stdout="ok", stderr="")
  101. monkeypatch.setattr(manifest.subprocess, "run", fake_run)
  102. command_env = manifest._minimal_command_env()
  103. manifest._run_command(["git", "rev-parse", "HEAD"], command_env, text=True)
  104. assert set(command_env) == {"PATH", "HOME", "DOCKER_CONFIG", "GIT_CONFIG_NOSYSTEM", "GIT_CONFIG_GLOBAL"}
  105. assert "GIT_DIR" not in seen["env"] and "GIT_WORK_TREE" not in seen["env"]
  106. assert "COMPOSE_FILE" not in seen["env"]
  107. assert seen["env"]["HOME"] == str(manifest.COMMAND_HOME)
  108. assert seen["env"]["GIT_CONFIG_NOSYSTEM"] == "1"
  109. assert seen["env"]["GIT_CONFIG_GLOBAL"] == "/dev/null"
  110. assert seen["cwd"] == manifest.ROOT and seen["timeout"] == manifest.COMMAND_TIMEOUT_SECONDS
  111. @pytest.mark.parametrize("operation", ["git", "compose"])
  112. def test_manifest_git_and_compose_timeout_fail_closed(monkeypatch, operation: str) -> None:
  113. import generate_p3_wp14_release_manifest as manifest
  114. def timeout(*_args, **_kwargs):
  115. raise subprocess.TimeoutExpired([operation], manifest.COMMAND_TIMEOUT_SECONDS)
  116. monkeypatch.setattr(manifest, "_run_command", timeout)
  117. command_env = manifest._minimal_command_env()
  118. with pytest.raises(subprocess.TimeoutExpired):
  119. if operation == "git":
  120. manifest._collect_git_status(command_env, str(manifest.ROOT))
  121. else:
  122. manifest._compose_services(command_env)
  123. def test_manifest_dirty_status_counts_and_digest_are_recomputed(monkeypatch) -> None:
  124. import generate_p3_wp14_release_manifest as manifest
  125. porcelain = b" M tracked.py\0?? untracked.txt\0A staged.py\0"
  126. def status_only(command, _env, *, text):
  127. assert command == ["git", "status", "--porcelain=v1", "-z"] and text is False
  128. return subprocess.CompletedProcess(command, 0, stdout=porcelain, stderr=b"")
  129. monkeypatch.setattr(manifest, "_run_command", status_only)
  130. status = manifest._collect_git_status(manifest._minimal_command_env(), str(manifest.ROOT))
  131. assert status["entry_count"] == 3
  132. assert status["tracked"] == 2 and status["untracked"] == 1 and status["staged"] == 1
  133. assert status["sha256"] == hashlib.sha256(porcelain).hexdigest()
  134. def test_manifest_porcelain_rename_copy_paths_count_once(monkeypatch) -> None:
  135. import generate_p3_wp14_release_manifest as manifest
  136. porcelain = b"R renamed.py\0old.py\0C copied.py\0source.py\0?? new.py\0 M changed.py\0"
  137. def status_only(command, _env, *, text):
  138. assert command == ["git", "status", "--porcelain=v1", "-z"] and text is False
  139. return subprocess.CompletedProcess(command, 0, stdout=porcelain, stderr=b"")
  140. monkeypatch.setattr(manifest, "_run_command", status_only)
  141. status = manifest._collect_git_status(manifest._minimal_command_env(), str(manifest.ROOT))
  142. assert status["entry_count"] == 4
  143. assert status["tracked"] == 3 and status["untracked"] == 1 and status["staged"] == 2
  144. def test_manifest_root_context_obeys_dockerignore_gz_rules(monkeypatch, tmp_path: Path) -> None:
  145. import generate_p3_wp14_release_manifest as manifest
  146. import p3_wp14_secure_io as secure
  147. monkeypatch.setattr(secure, "TRUSTED_ROOT", tmp_path)
  148. (tmp_path / ".dockerignore").write_text("*.tar.gz\n")
  149. assets = tmp_path / "assets"
  150. assets.mkdir()
  151. model = assets / "model.gz"
  152. archive = assets / "model.tar.gz"
  153. model.write_bytes(b"model-v1")
  154. archive.write_bytes(b"archive-v1")
  155. initial = manifest._tree_digest(".", root=tmp_path)
  156. model.write_bytes(b"model-v2")
  157. model_changed = manifest._tree_digest(".", root=tmp_path)
  158. archive.write_bytes(b"archive-v2")
  159. archive_changed = manifest._tree_digest(".", root=tmp_path)
  160. assert model_changed != initial
  161. assert archive_changed == model_changed
  162. def test_directory_lock_stays_exclusive_when_marker_is_renamed(monkeypatch, tmp_path: Path) -> None:
  163. import p3_wp14_secure_io as secure
  164. monkeypatch.setattr(secure, "TRUSTED_ROOT", tmp_path)
  165. lock = tmp_path / "run.lock"
  166. lock.write_text("marker")
  167. entered = threading.Event()
  168. def second_holder() -> None:
  169. with secure.exclusive_lock(lock):
  170. entered.set()
  171. with secure.exclusive_lock(lock):
  172. lock.rename(tmp_path / "run.lock.old")
  173. lock.write_text("attacker replacement")
  174. thread = threading.Thread(target=second_holder)
  175. thread.start()
  176. time.sleep(0.1)
  177. assert not entered.is_set()
  178. thread.join(timeout=1)
  179. assert entered.is_set()
  180. def test_atomic_write_rejects_replaced_temp_inode(monkeypatch, tmp_path: Path) -> None:
  181. import p3_wp14_secure_io as secure
  182. monkeypatch.setattr(secure, "TRUSTED_ROOT", tmp_path)
  183. target = tmp_path / "record.json"
  184. parent, _ = secure._parent(target)
  185. temporary = ".record.json.attacker.tmp"
  186. fd = os.open(temporary, os.O_RDWR | os.O_CREAT | os.O_EXCL, 0o600, dir_fd=parent)
  187. try:
  188. os.write(fd, b"original")
  189. os.fsync(fd)
  190. replacement = tmp_path / "replacement"
  191. replacement.write_bytes(b"attacker")
  192. os.replace(replacement.name, temporary, src_dir_fd=parent, dst_dir_fd=parent)
  193. with pytest.raises(ValueError, match="temp swapped|unsafe path"):
  194. secure._verify_temp_matches_open_fd(parent, temporary, fd)
  195. finally:
  196. os.close(fd)
  197. try:
  198. os.unlink(temporary, dir_fd=parent)
  199. except FileNotFoundError:
  200. pass
  201. os.close(parent)
  202. def test_atomic_write_failure_removes_temp_and_syncs_parent(monkeypatch, tmp_path: Path) -> None:
  203. import p3_wp14_secure_io as secure
  204. monkeypatch.setattr(secure, "TRUSTED_ROOT", tmp_path)
  205. original_write = secure.os.write
  206. calls = {"count": 0}
  207. def failing_write(fd, data):
  208. calls["count"] += 1
  209. if calls["count"] == 1:
  210. raise OSError("injected write failure")
  211. return original_write(fd, data)
  212. monkeypatch.setattr(secure.os, "write", failing_write)
  213. with pytest.raises(OSError, match="injected"):
  214. secure.atomic_write_bytes(tmp_path / "record.json", b"payload")
  215. assert not list(tmp_path.glob(".record.json.*.tmp"))
  216. def test_atomic_write_cooperative_reader_observes_only_old_or_new(monkeypatch, tmp_path: Path) -> None:
  217. import p3_wp14_secure_io as secure
  218. monkeypatch.setattr(secure, "TRUSTED_ROOT", tmp_path)
  219. target = tmp_path / "record.json"
  220. target.write_bytes(b"old")
  221. prefix_written = threading.Event()
  222. release_writer = threading.Event()
  223. reader_done = threading.Event()
  224. observed: list[bytes] = []
  225. original_write = secure.os.write
  226. first_write = True
  227. def staged_write(fd, data):
  228. nonlocal first_write
  229. if first_write:
  230. first_write = False
  231. written = original_write(fd, data[:1])
  232. prefix_written.set()
  233. assert release_writer.wait(timeout=2)
  234. return written
  235. return original_write(fd, data)
  236. monkeypatch.setattr(secure.os, "write", staged_write)
  237. writer = threading.Thread(target=lambda: secure.atomic_write_bytes(target, b"new-value"))
  238. writer.start()
  239. assert prefix_written.wait(timeout=1)
  240. def reader() -> None:
  241. observed.append(secure.read_bytes_once(target))
  242. reader_done.set()
  243. reader_thread = threading.Thread(target=reader)
  244. reader_thread.start()
  245. time.sleep(0.1)
  246. assert not reader_done.is_set(), "cooperative reader saw an in-progress leaf"
  247. release_writer.set()
  248. writer.join(timeout=2)
  249. reader_thread.join(timeout=2)
  250. assert observed == [b"new-value"]
  251. @pytest.mark.parametrize("failure", ["write", "fsync", "rename"])
  252. def test_atomic_write_failure_preserves_prior_verified_record(monkeypatch, tmp_path: Path, failure: str) -> None:
  253. import p3_wp14_secure_io as secure
  254. monkeypatch.setattr(secure, "TRUSTED_ROOT", tmp_path)
  255. target = tmp_path / "record.json"
  256. target.write_bytes(b"old-verified")
  257. if failure == "write":
  258. monkeypatch.setattr(secure.os, "write", lambda *_args: (_ for _ in ()).throw(OSError("write failure")))
  259. elif failure == "fsync":
  260. monkeypatch.setattr(secure.os, "fsync", lambda *_args: (_ for _ in ()).throw(OSError("fsync failure")))
  261. else:
  262. monkeypatch.setattr(secure.os, "replace", lambda *_args, **_kwargs: (_ for _ in ()).throw(OSError("rename failure")))
  263. with pytest.raises(OSError):
  264. secure.atomic_write_bytes(target, b"new")
  265. assert secure.read_bytes_once(target) == b"old-verified"
  266. def test_atomic_write_verify_then_replace_attack_restores_old_before_reader(monkeypatch, tmp_path: Path) -> None:
  267. import p3_wp14_secure_io as secure
  268. monkeypatch.setattr(secure, "TRUSTED_ROOT", tmp_path)
  269. target = tmp_path / "record.json"
  270. target.write_bytes(b"old-verified")
  271. original = secure._verify_temp_matches_open_fd
  272. reader_done = threading.Event()
  273. observed: list[bytes] = []
  274. def swap_after_verify(parent, temporary, fd):
  275. original(parent, temporary, fd)
  276. attacker = tmp_path / "attacker.tmp"
  277. attacker.write_bytes(b"attacker-bytes")
  278. os.replace(attacker.name, temporary, src_dir_fd=parent, dst_dir_fd=parent)
  279. reader = threading.Thread(target=lambda: (observed.append(secure.read_bytes_once(target)), reader_done.set()))
  280. reader.start()
  281. time.sleep(0.05)
  282. assert not reader_done.is_set(), "reader escaped the cooperative publish lock"
  283. reader.join(timeout=0.01)
  284. monkeypatch.setattr(secure, "_verify_temp_matches_open_fd", swap_after_verify)
  285. with pytest.raises(ValueError, match="published leaf"):
  286. secure.atomic_write_bytes(target, b"expected")
  287. assert secure.read_bytes_once(target) == b"old-verified"
  288. assert observed == [b"old-verified"]
  289. def test_manifest_input_census_rejects_root_context_change_during_generation(monkeypatch, tmp_path: Path) -> None:
  290. import generate_p3_wp14_release_manifest as manifest
  291. import p3_wp14_secure_io as secure
  292. monkeypatch.setattr(secure, "TRUSTED_ROOT", tmp_path)
  293. monkeypatch.setattr(manifest, "ROOT", tmp_path)
  294. monkeypatch.setattr(manifest, "OUTPUT", tmp_path / "manifest.json")
  295. monkeypatch.setattr(manifest, "TREE_ROOTS", ())
  296. monkeypatch.setattr(manifest, "FILE_PATHS", ())
  297. monkeypatch.setattr(manifest, "COMPOSE", "compose.yml")
  298. monkeypatch.setattr(manifest, "COMPOSE_INPUTS", (("build_context", "."),))
  299. (tmp_path / ".dockerignore").write_text("")
  300. (tmp_path / "compose.yml").write_text("services: {}\n")
  301. root_only = tmp_path / "root-only.py"
  302. root_only.write_text("before\n")
  303. monkeypatch.setattr(manifest, "_git_top_level", lambda _env: str(tmp_path))
  304. monkeypatch.setattr(manifest, "_collect_git_status", lambda *_args: {"entry_count": 1, "sha256": "status"})
  305. monkeypatch.setattr(manifest, "_compose_services", lambda _env: [])
  306. monkeypatch.setattr(manifest, "_image_reference_closure", lambda: [])
  307. monkeypatch.setattr(
  308. manifest,
  309. "_run_command",
  310. lambda command, _env, *, text: subprocess.CompletedProcess(command, 0, stdout="head\n", stderr=""),
  311. )
  312. original_census = manifest._input_census
  313. calls = 0
  314. def census_then_mutate(command_env):
  315. nonlocal calls
  316. value = original_census(command_env)
  317. calls += 1
  318. if calls == 1:
  319. root_only.write_text("after\n")
  320. return value
  321. monkeypatch.setattr(manifest, "_input_census", census_then_mutate)
  322. with pytest.raises(RuntimeError, match="input closure changed"):
  323. manifest._main_locked()
  324. def test_compose_commands_force_empty_env_file_and_ignore_parent_dotenv(monkeypatch) -> None:
  325. import generate_p3_wp14_release_manifest as manifest
  326. monkeypatch.setenv("BACKEND_PORT", "attacker-port")
  327. assert validator.RENDER_COMMAND[1:3] == ["--env-file", "/dev/null"]
  328. captured = {}
  329. def fake_run(command, command_env, *, text):
  330. captured.update(command=command, env=command_env, text=text)
  331. return subprocess.CompletedProcess(command, 0, stdout='{"services": {}}', stderr="")
  332. monkeypatch.setattr(manifest, "_run_command", fake_run)
  333. manifest._compose_services(manifest._minimal_command_env())
  334. assert captured["command"][1:3] == ["--env-file", "/dev/null"]
  335. assert "BACKEND_PORT" not in captured["env"]
  336. assert captured["env"]["HOME"] == str(manifest.COMMAND_HOME)
  337. def test_dockerignore_matcher_rejects_linked_source(monkeypatch, tmp_path: Path) -> None:
  338. import generate_p3_wp14_release_manifest as manifest
  339. import p3_wp14_secure_io as secure
  340. monkeypatch.setattr(secure, "TRUSTED_ROOT", tmp_path)
  341. target = tmp_path / "rules"
  342. target.write_text("*.tar.gz\n")
  343. linked = tmp_path / ".dockerignore"
  344. linked.symlink_to(target)
  345. with pytest.raises(ValueError):
  346. manifest.DockerIgnoreMatcher.from_root(tmp_path)
  347. def test_dockerignore_matcher_refuses_unsupported_glob(monkeypatch, tmp_path: Path) -> None:
  348. import generate_p3_wp14_release_manifest as manifest
  349. import p3_wp14_secure_io as secure
  350. monkeypatch.setattr(secure, "TRUSTED_ROOT", tmp_path)
  351. (tmp_path / ".dockerignore").write_text("[ab].txt\n")
  352. with pytest.raises(ValueError, match="refusing approximation"):
  353. manifest.DockerIgnoreMatcher.from_root(tmp_path)
  354. def test_image_closure_marks_every_mutable_tag_blocked() -> None:
  355. import generate_p3_wp14_release_manifest as manifest
  356. records = manifest._image_reference_closure(
  357. "services:\n api:\n image: registry.example/model:latest\n pinned:\n image: registry.example/pinned@sha256:abc\n",
  358. {"Dockerfile": "FROM registry.example/base:stable AS build\nFROM build\n"},
  359. )
  360. by_reference = {item["reference"]: item for item in records}
  361. assert by_reference["registry.example/model:latest"]["status"] == "UNRESOLVED_MUTABLE_TAG"
  362. assert by_reference["registry.example/base:stable"]["gate"] == "BLOCKED_EXTERNAL"
  363. assert by_reference["registry.example/pinned@sha256:abc"]["status"] == "IMMUTABLE_DIGEST"
  364. assert by_reference["build"]["status"] == "INTERNAL_BUILD_STAGE"
  365. def test_tree_digest_fails_closed_on_resource_boundaries(monkeypatch, tmp_path: Path) -> None:
  366. import generate_p3_wp14_release_manifest as manifest
  367. import p3_wp14_secure_io as secure
  368. monkeypatch.setattr(secure, "TRUSTED_ROOT", tmp_path)
  369. (tmp_path / ".dockerignore").write_text("")
  370. (tmp_path / "one").write_text("1")
  371. (tmp_path / "two").write_text("2")
  372. monkeypatch.setattr(manifest, "MAX_TREE_FILES", 1)
  373. with pytest.raises(ValueError, match="resource boundary"):
  374. manifest._tree_digest(".", root=tmp_path)
  375. @pytest.mark.parametrize("shape", ["deep", "many"])
  376. def test_tree_digest_rejects_ignored_directory_resource_exhaustion(monkeypatch, tmp_path: Path, shape: str) -> None:
  377. import generate_p3_wp14_release_manifest as manifest
  378. import p3_wp14_secure_io as secure
  379. monkeypatch.setattr(secure, "TRUSTED_ROOT", tmp_path)
  380. # The negation means a matcher cannot safely prune the ignored directory.
  381. (tmp_path / ".dockerignore").write_text("ignored/\n!ignored/keep.txt\n")
  382. ignored = tmp_path / "ignored"
  383. ignored.mkdir()
  384. if shape == "deep":
  385. current = ignored
  386. for number in range(manifest.MAX_TREE_DEPTH + 8):
  387. current = current / f"level{number}"
  388. current.mkdir()
  389. else:
  390. monkeypatch.setattr(manifest, "MAX_TREE_FILES", 4)
  391. for number in range(5):
  392. (ignored / f"branch{number}").mkdir()
  393. with pytest.raises(ValueError, match="resource boundary|path boundary"):
  394. manifest._tree_digest(".", root=tmp_path)