"""Environment probes read bounded stable files; all fixtures are local.""" import hashlib import os from pathlib import Path import sys from types import SimpleNamespace from unittest.mock import patch import pytest sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools/devkit")) from govoplan_devkit import environment from govoplan_devkit.common import digest from govoplan_devkit.workspace import Project, Repository def mutate_during_read(monkeypatch, callback): original = hashlib.sha256 mutated = False class MutatingHasher: def __init__(self): self.hasher = original() def update(self, chunk): nonlocal mutated self.hasher.update(chunk) if not mutated: mutated = True callback() def hexdigest(self): return self.hasher.hexdigest() monkeypatch.setattr(environment.hashlib, "sha256", MutatingHasher) @pytest.mark.parametrize("content", [b"", b"stable", b"x" * (1024 * 1024 + 3)]) def test_file_hash_keeps_existing_digest_and_exact_size_boundary(tmp_path, content): path = tmp_path / "input" path.write_bytes(content) assert ( environment._environment_file_hash(path, len(content)) == hashlib.sha256(content).hexdigest() ) def test_missing_optional_file_remains_optional_without_a_persistent_cache(tmp_path): path = tmp_path / "input" assert environment._environment_file_hash(path, 10) is None path.write_bytes(b"first") first = environment._environment_file_hash(path, 10) path.write_bytes(b"other") assert environment._environment_file_hash(path, 10) != first @pytest.mark.parametrize("kind", ["fifo", "directory", "oversized", "dangling"]) def test_present_unsafe_input_is_rejected_without_blocking(tmp_path, kind): path = tmp_path / "input" if kind == "fifo": os.mkfifo(path) elif kind == "directory": path.mkdir() elif kind == "oversized": path.write_bytes(b"too large") else: path.symlink_to(tmp_path / "missing") with pytest.raises(ValueError, match="Environment input"): environment._environment_file_hash(path, 2) def test_stable_venv_executable_symlink_keeps_original_path_and_identity(tmp_path): target = tmp_path / "real-python" target.write_bytes(b"fixture binary") executable = tmp_path / "venv" / "bin" / "python" executable.parent.mkdir(parents=True) executable.symlink_to(target) repo = tmp_path / "repo" metadata = repo / "node_modules" / ".package-lock.json" metadata.parent.mkdir(parents=True) metadata.write_bytes(b'{"fixture":true}') project = Project("Fixture", (Repository("repo", repo),), {}) tools = {"python": str(executable)} env = {"PATH": "fixture", "PWD": "ignored"} distributions = b'[["fixture", "1"]]\n' with ( patch.object(environment, "tool_version", return_value="fixture-version"), patch.object( environment, "require_capture", return_value=SimpleNamespace(returncode=0, stdout=distributions), ) as capture, ): actual = environment.environment_fingerprint(tmp_path, project, tools, env) assert actual == digest( { "environment": {"PATH": "fixture"}, "tools": { "python": { "path": str(executable), "version": "fixture-version", "sha256": hashlib.sha256(target.read_bytes()).hexdigest(), } }, "installed": { str(metadata): hashlib.sha256(metadata.read_bytes()).hexdigest(), "python_distributions": hashlib.sha256(distributions).hexdigest(), }, } ) assert capture.call_args.args[0][0] == str(executable) def test_symlinked_package_directory_is_allowed_when_stable(tmp_path): actual = tmp_path / "packages" actual.mkdir() (actual / ".package-lock.json").write_bytes(b"fixture") link = tmp_path / "node_modules" link.symlink_to(actual, target_is_directory=True) assert environment._environment_file_hash(link / ".package-lock.json", 10) def test_fifo_replacement_between_inspection_and_open_does_not_block( tmp_path, monkeypatch ): path = tmp_path / "input" path.write_bytes(b"fixture") real_open = os.open def replace_before_open(target, flags): path.unlink() os.mkfifo(path) assert flags & os.O_NONBLOCK return real_open(target, flags) monkeypatch.setattr(environment.os, "open", replace_before_open) with pytest.raises(ValueError, match="bounded regular"): environment._environment_file_hash(path, 10) def test_growth_between_inspection_and_open_cannot_bypass_size_bound( tmp_path, monkeypatch ): path = tmp_path / "input" path.write_bytes(b"a") real_open = os.open def grow_before_open(target, flags): path.write_bytes(b"x" * 11) return real_open(target, flags) monkeypatch.setattr(environment.os, "open", grow_before_open) with pytest.raises(ValueError, match="bounded regular"): environment._environment_file_hash(path, 10) def test_growth_during_read_cannot_bypass_size_bound(tmp_path, monkeypatch): path = tmp_path / "input" path.write_bytes(b"a") mutate_during_read(monkeypatch, lambda: path.write_bytes(b"x" * 11)) with pytest.raises(ValueError, match="grew beyond"): environment._environment_file_hash(path, 10) def test_same_size_edit_with_restored_mtime_is_not_a_stable_identity( tmp_path, monkeypatch ): path = tmp_path / "input" path.write_bytes(b"first") metadata = path.stat() def mutate(): path.write_bytes(b"other") os.utime(path, ns=(metadata.st_atime_ns, metadata.st_mtime_ns)) mutate_during_read(monkeypatch, mutate) with pytest.raises(ValueError, match="changed during"): environment._environment_file_hash(path, 10) def test_replacement_with_identical_bytes_is_not_a_stable_identity( tmp_path, monkeypatch ): path = tmp_path / "input" path.write_bytes(b"fixture") replacement = tmp_path / "replacement" replacement.write_bytes(b"fixture") mutate_during_read(monkeypatch, lambda: replacement.replace(path)) with pytest.raises(ValueError, match="changed during"): environment._environment_file_hash(path, 10) def test_symlink_retarget_with_identical_bytes_is_not_a_stable_identity( tmp_path, monkeypatch ): first, second, link = (tmp_path / name for name in ("first", "second", "python")) first.write_bytes(b"fixture") second.write_bytes(b"fixture") link.symlink_to(first) def retarget(): link.unlink() link.symlink_to(second) mutate_during_read(monkeypatch, retarget) with pytest.raises(ValueError, match="changed during"): environment._environment_file_hash(link, 10) def test_source_disappearing_during_read_fails_closed(tmp_path, monkeypatch): path = tmp_path / "input" path.write_bytes(b"fixture") mutate_during_read(monkeypatch, path.unlink) with pytest.raises(ValueError, match="Environment input"): environment._environment_file_hash(path, 10) def test_parent_symlink_retarget_is_rejected_even_for_the_same_target_inode( tmp_path, monkeypatch ): first, second, link = (tmp_path / name for name in ("first", "second", "bin")) first.mkdir() second.mkdir() (first / "python").write_bytes(b"fixture") os.link(first / "python", second / "python") link.symlink_to(first, target_is_directory=True) def retarget(): link.unlink() link.symlink_to(second, target_is_directory=True) mutate_during_read(monkeypatch, retarget) with pytest.raises(ValueError, match="changed during"): environment._environment_file_hash(link / "python", 10) def test_invalid_executable_is_rejected_before_any_version_process(tmp_path): executable = tmp_path / "python" os.mkfifo(executable) project = Project("Fixture", (), {}) with patch.object(environment, "tool_version") as version: with pytest.raises(ValueError, match="bounded regular"): environment.environment_fingerprint( tmp_path, project, {"python": str(executable)}, {} ) version.assert_not_called() def discovery_project(root, *, path="govoplan-backend"): repo = root / path (repo / "src").mkdir(parents=True) return Project( "GovOPlaN", (Repository("govoplan-backend", repo),), {"organization": "GovOPlaN"}, ) def test_native_shape_ignores_build_files_and_directory_timestamps(tmp_path): project = discovery_project(tmp_path) repo = project.repositories[0].path (repo / "webui").mkdir() before = environment._native_discovery_fingerprint(tmp_path, project) for directory in (repo, repo / "src", repo / "webui"): (directory / "temporary-build-output").write_text("changed") (directory / "temporary-build-directory").mkdir() assert environment._native_discovery_fingerprint(tmp_path, project) == before (repo / "webui" / "temporary-build-output").unlink() assert environment._native_discovery_fingerprint(tmp_path, project) == before def test_native_shape_detects_unknown_sibling_and_source_addition_removal(tmp_path): project = discovery_project(tmp_path) baseline = environment._native_discovery_fingerprint(tmp_path, project) sibling = tmp_path / "govoplan-unregistered" sibling.mkdir() empty = environment._native_discovery_fingerprint(tmp_path, project) assert empty != baseline for name in ("src", "webui"): (sibling / name).mkdir() assert environment._native_discovery_fingerprint(tmp_path, project) != empty (sibling / name).rmdir() assert environment._native_discovery_fingerprint(tmp_path, project) == empty sibling.rmdir() assert environment._native_discovery_fingerprint(tmp_path, project) == baseline @pytest.mark.parametrize("repo_path", ["govoplan-backend", "nonstandard-layout"]) def test_native_shape_detects_registered_backend_gaining_webui(tmp_path, repo_path): project = discovery_project(tmp_path, path=repo_path) baseline = environment._native_discovery_fingerprint(tmp_path, project) webui = project.repositories[0].path / "webui" webui.mkdir() assert environment._native_discovery_fingerprint(tmp_path, project) != baseline webui.rmdir() assert environment._native_discovery_fingerprint(tmp_path, project) == baseline def test_native_shape_binds_registered_ownership_names(tmp_path): project = discovery_project(tmp_path, path="nonstandard-layout") renamed = Project( project.name, (Repository("govoplan-renamed", project.repositories[0].path),), project.config, ) assert environment._native_discovery_fingerprint( tmp_path, project ) != environment._native_discovery_fingerprint(tmp_path, renamed) def test_native_shape_detects_source_link_retarget_and_dangling_target(tmp_path): project = discovery_project(tmp_path) first, second = tmp_path / "first", tmp_path / "second" first.mkdir() second.mkdir() link = project.repositories[0].path / "webui" link.symlink_to(first, target_is_directory=True) baseline = environment._native_discovery_fingerprint(tmp_path, project) link.unlink() link.symlink_to(second, target_is_directory=True) changed = environment._native_discovery_fingerprint(tmp_path, project) assert changed != baseline second.rmdir() assert environment._native_discovery_fingerprint(tmp_path, project) != changed def test_native_shape_encodes_non_directory_source_entries(tmp_path): project = discovery_project(tmp_path) webui = project.repositories[0].path / "webui" baseline = environment._native_discovery_fingerprint(tmp_path, project) webui.write_text("not a directory") file_shape = environment._native_discovery_fingerprint(tmp_path, project) assert file_shape != baseline webui.unlink() webui.mkdir() assert environment._native_discovery_fingerprint(tmp_path, project) != file_shape def test_native_shape_audit_is_bounded_including_nonmatching_children( tmp_path, monkeypatch ): project = discovery_project(tmp_path) (tmp_path / "unrelated-one").mkdir() (tmp_path / "unrelated-two").mkdir() monkeypatch.setattr(environment, "MAX_DISCOVERY_CHILDREN", 2) with pytest.raises(ValueError, match="bounded ownership"): environment._native_discovery_fingerprint(tmp_path, project) def test_native_shape_resolution_loop_fails_closed(tmp_path): project = discovery_project(tmp_path) link = project.repositories[0].path / "webui" link.symlink_to(link) with pytest.raises(ValueError, match="discovery cannot be resolved"): environment._native_discovery_fingerprint(tmp_path, project) @pytest.mark.parametrize("portable", [False, True]) def test_only_native_environment_identity_binds_discovery_shape(tmp_path, portable): project = discovery_project(tmp_path) if portable: project.config["schema_version"] = 1 executable = tmp_path / "fixture-python" executable.write_text("not executed") tools = {"python": str(executable)} with ( patch.object(environment, "tool_version", return_value="fixture"), patch.object( environment, "require_capture", return_value=SimpleNamespace(returncode=0, stdout=b"[]"), ), ): baseline = environment.environment_fingerprint(tmp_path, project, tools, {}) (tmp_path / "govoplan-new" / "src").mkdir(parents=True) changed = environment.environment_fingerprint(tmp_path, project, tools, {}) assert (baseline == changed) is portable