test_change_manifest.py 13.2 KB
"""U-036: real unittest coverage for change_manifest.py -- adapted from
graphloop's test_change_manifest.py (2026-08-02). Module logic is unchanged
from graphloop ("adapted verbatim", see change_manifest.py's own docstring)
except graphlemon's `unified_diff` collapses "@@ -l,s +l,s @@" hunk headers
to a single leading "@@" marker (see that module's own comment) -- the
"only text gets hunks" assertion below counts "@@" substring occurrences,
which still holds 1-to-1 with graphlemon's collapsed format for these
single-hunk fixtures.

Run: python3 -m unittest discover -s supervisor/isolation -p "test_*.py" -v
"""
import hashlib
import os
import shutil
import tempfile
import unittest

from supervisor.isolation import change_manifest as cm

TEXT_A = "hello\n"
TEXT_A_MODIFIED = "hello modified\n"
BINARY_BYTES = b"\x89PNG\r\n\x1a\n\x00\x01\x02\x03BINARYDATA\xff\xfe"


def sha256_of(data):
    return hashlib.sha256(data).hexdigest()


class ChangeManifestTestCase(unittest.TestCase):
    def setUp(self):
        self.source_dir = tempfile.mkdtemp(prefix="cm-src-")
        self.addCleanup(shutil.rmtree, self.source_dir, ignore_errors=True)
        self._extra_dirs = []
        self.addCleanup(self._cleanup_extra_dirs)

    def _cleanup_extra_dirs(self):
        for path in self._extra_dirs:
            shutil.rmtree(path, ignore_errors=True)

    def _new_isolated_dir(self):
        path = tempfile.mkdtemp(prefix="cm-iso-")
        self._extra_dirs.append(path)
        return path

    @staticmethod
    def _write_text(root, rel_path, content):
        full = os.path.join(root, *rel_path.split("/"))
        os.makedirs(os.path.dirname(full), exist_ok=True)
        with open(full, "w", encoding="utf-8") as handle:
            handle.write(content)
        return full

    @staticmethod
    def _write_bytes(root, rel_path, data):
        full = os.path.join(root, *rel_path.split("/"))
        os.makedirs(os.path.dirname(full), exist_ok=True)
        with open(full, "wb") as handle:
            handle.write(data)
        return full

    def _copy_source_to_isolated(self):
        isolated_dir = self._new_isolated_dir()
        shutil.rmtree(isolated_dir)
        shutil.copytree(self.source_dir, isolated_dir)
        return isolated_dir


class SnapshotWorkspaceTests(ChangeManifestTestCase):
    def test_records_text_file_with_hash_size_and_kind(self):
        self._write_text(self.source_dir, "a.txt", TEXT_A)

        snapshot = cm.snapshot_workspace(self.source_dir)

        entry = snapshot["files"]["a.txt"]
        self.assertEqual(entry["sha256"], sha256_of(TEXT_A.encode("utf-8")))
        self.assertEqual(entry["size"], len(TEXT_A.encode("utf-8")))
        self.assertEqual(entry["kind"], "text")

    def test_records_binary_file_as_binary_kind(self):
        self._write_bytes(self.source_dir, "logo.bin", BINARY_BYTES)

        snapshot = cm.snapshot_workspace(self.source_dir)

        entry = snapshot["files"]["logo.bin"]
        self.assertEqual(entry["sha256"], sha256_of(BINARY_BYTES))
        self.assertEqual(entry["size"], len(BINARY_BYTES))
        self.assertEqual(entry["kind"], "binary")

    def test_uses_relative_posix_paths_for_nested_files(self):
        self._write_text(self.source_dir, "subdir/nested/file.txt", "x")

        snapshot = cm.snapshot_workspace(self.source_dir)

        self.assertIn("subdir/nested/file.txt", snapshot["files"])

    def test_ignores_git_pycache_pytest_cache_and_dsstore(self):
        self._write_text(self.source_dir, "keep.txt", "keep")
        self._write_text(self.source_dir, ".git/config", "git config")
        self._write_text(self.source_dir, ".git/objects/ab/cdef", "blob")
        self._write_text(self.source_dir, "__pycache__/mod.cpython-39.pyc", "pyc")
        self._write_text(self.source_dir, ".pytest_cache/v/cache/lastfailed", "{}")
        self._write_text(self.source_dir, ".DS_Store", "junk")

        snapshot = cm.snapshot_workspace(self.source_dir)

        self.assertEqual(set(snapshot["files"]), {"keep.txt"})

    def test_skips_symlinks_but_keeps_their_real_target(self):
        target = self._write_text(self.source_dir, "real.txt", "real content")
        link_path = os.path.join(self.source_dir, "link.txt")
        os.symlink(target, link_path)

        snapshot = cm.snapshot_workspace(self.source_dir)

        self.assertIn("real.txt", snapshot["files"])
        self.assertNotIn("link.txt", snapshot["files"])


class BuildChangeManifestTests(ChangeManifestTestCase):
    def _baseline_snapshot(self):
        self._write_text(self.source_dir, "a.txt", TEXT_A)
        self._write_text(self.source_dir, "b.txt", "world\n")
        self._write_text(self.source_dir, "c.txt", "keep me\n")
        return cm.snapshot_workspace(self.source_dir)

    def test_added_modified_deleted_and_unchanged_classification(self):
        snapshot = self._baseline_snapshot()
        isolated_dir = self._copy_source_to_isolated()

        self._write_text(isolated_dir, "a.txt", TEXT_A_MODIFIED)
        os.remove(os.path.join(isolated_dir, "b.txt"))
        self._write_text(isolated_dir, "d.txt", "brand new\n")

        manifest = cm.build_change_manifest(snapshot, isolated_dir)
        by_path = {entry["path"]: entry for entry in manifest["changed_files"]}

        self.assertEqual(set(by_path), {"a.txt", "b.txt", "d.txt"})
        self.assertNotIn("c.txt", by_path)

        modified = by_path["a.txt"]
        self.assertEqual(modified["status"], "modified")
        self.assertEqual(modified["old_hash"], sha256_of(TEXT_A.encode("utf-8")))
        self.assertEqual(modified["new_hash"], sha256_of(TEXT_A_MODIFIED.encode("utf-8")))

        deleted = by_path["b.txt"]
        self.assertEqual(deleted["status"], "deleted")
        self.assertIsNone(deleted["new_hash"])
        self.assertEqual(deleted["old_hash"], sha256_of(b"world\n"))

        added = by_path["d.txt"]
        self.assertEqual(added["status"], "added")
        self.assertIsNone(added["old_hash"])
        self.assertEqual(added["new_hash"], sha256_of(b"brand new\n"))

    def test_unchanged_files_never_appear_in_changed_files(self):
        snapshot = self._baseline_snapshot()
        isolated_dir = self._copy_source_to_isolated()

        manifest = cm.build_change_manifest(snapshot, isolated_dir)

        self.assertEqual(manifest["changed_files"], [])
        self.assertEqual(cm.unified_diff(manifest), "")

    def test_ignored_files_excluded_even_when_added_in_isolated(self):
        snapshot = self._baseline_snapshot()
        isolated_dir = self._copy_source_to_isolated()

        self._write_text(isolated_dir, ".git/config", "new git config")
        self._write_text(isolated_dir, "__pycache__/mod.cpython-39.pyc", "pyc")
        self._write_text(isolated_dir, ".DS_Store", "junk")

        manifest = cm.build_change_manifest(snapshot, isolated_dir)

        self.assertEqual(manifest["changed_files"], [])

    def test_binary_file_added_is_classified_binary(self):
        snapshot = self._baseline_snapshot()
        isolated_dir = self._copy_source_to_isolated()
        self._write_bytes(isolated_dir, "logo.bin", BINARY_BYTES)

        manifest = cm.build_change_manifest(snapshot, isolated_dir)

        self.assertEqual(len(manifest["changed_files"]), 1)
        entry = manifest["changed_files"][0]
        self.assertEqual(entry["path"], "logo.bin")
        self.assertEqual(entry["status"], "added")
        self.assertEqual(entry["kind"], "binary")
        self.assertEqual(entry["new_hash"], sha256_of(BINARY_BYTES))

    def test_changed_files_are_sorted_deterministically(self):
        snapshot = self._baseline_snapshot()
        isolated_dir = self._copy_source_to_isolated()

        self._write_text(isolated_dir, "z_file.txt", "z\n")
        self._write_text(isolated_dir, "0_file.txt", "0\n")
        self._write_text(isolated_dir, "m_file.txt", "m\n")

        manifest = cm.build_change_manifest(snapshot, isolated_dir)
        paths = [entry["path"] for entry in manifest["changed_files"]]

        self.assertEqual(paths, sorted(paths))
        self.assertEqual(paths, ["0_file.txt", "m_file.txt", "z_file.txt"])

    def test_rejects_symlink_found_in_isolated_workspace(self):
        snapshot = self._baseline_snapshot()
        isolated_dir = self._copy_source_to_isolated()
        target = os.path.join(isolated_dir, "a.txt")
        os.symlink(target, os.path.join(isolated_dir, "sneaky_link.txt"))

        with self.assertRaises(cm.ChangeManifestError):
            cm.build_change_manifest(snapshot, isolated_dir)

    def test_rejects_symlink_escaping_isolated_workspace(self):
        snapshot = self._baseline_snapshot()
        isolated_dir = self._copy_source_to_isolated()
        outside_dir = self._new_isolated_dir()
        outside_file = self._write_text(outside_dir, "outside.txt", "outside")
        os.symlink(outside_file, os.path.join(isolated_dir, "escape_link.txt"))

        with self.assertRaises(cm.ChangeManifestError):
            cm.build_change_manifest(snapshot, isolated_dir)

    def test_rejects_tampered_snapshot_with_traversal_path(self):
        snapshot = self._baseline_snapshot()
        isolated_dir = self._copy_source_to_isolated()

        for bad_path in ("../evil.txt", "/etc/passwd", "sub/../../escape.txt"):
            with self.subTest(bad_path=bad_path):
                tampered = {"root": snapshot["root"], "files": dict(snapshot["files"])}
                tampered["files"][bad_path] = {"sha256": "0" * 64, "size": 0, "kind": "text"}
                with self.assertRaises(cm.ChangeManifestError):
                    cm.build_change_manifest(tampered, isolated_dir)


class UnifiedDiffTests(ChangeManifestTestCase):
    def test_modified_text_file_produces_readable_unified_diff(self):
        self._write_text(self.source_dir, "a.txt", TEXT_A)
        snapshot = cm.snapshot_workspace(self.source_dir)
        isolated_dir = self._copy_source_to_isolated()
        self._write_text(isolated_dir, "a.txt", TEXT_A_MODIFIED)

        manifest = cm.build_change_manifest(snapshot, isolated_dir)
        diff_text = cm.unified_diff(manifest)
        lines = diff_text.splitlines()

        self.assertTrue(any(l.startswith("---") and "a.txt" in l for l in lines))
        self.assertTrue(any(l.startswith("+++") and "a.txt" in l for l in lines))
        self.assertTrue(any(l.startswith("@@") for l in lines))
        self.assertTrue(any(l.startswith("-") and "hello" in l and not l.startswith("---") for l in lines))
        self.assertTrue(any(l.startswith("+") and "hello modified" in l and not l.startswith("+++") for l in lines))

    def test_added_text_file_diffs_against_dev_null(self):
        snapshot = cm.snapshot_workspace(self.source_dir)
        isolated_dir = self._copy_source_to_isolated()
        self._write_text(isolated_dir, "new.txt", "brand new content\n")

        manifest = cm.build_change_manifest(snapshot, isolated_dir)
        diff_text = cm.unified_diff(manifest)
        lines = diff_text.splitlines()

        self.assertTrue(any("/dev/null" in l for l in lines))
        self.assertTrue(any(l.startswith("+") and "brand new content" in l for l in lines))
        removed_content_lines = [l for l in lines if l.startswith("-") and not l.startswith("---")]
        self.assertEqual(removed_content_lines, [])

    def test_deleted_text_file_diffs_against_dev_null(self):
        self._write_text(self.source_dir, "gone.txt", "will be deleted\n")
        snapshot = cm.snapshot_workspace(self.source_dir)
        isolated_dir = self._copy_source_to_isolated()
        os.remove(os.path.join(isolated_dir, "gone.txt"))

        manifest = cm.build_change_manifest(snapshot, isolated_dir)
        diff_text = cm.unified_diff(manifest)
        lines = diff_text.splitlines()

        self.assertTrue(any("/dev/null" in l for l in lines))
        self.assertTrue(any(l.startswith("-") and "will be deleted" in l for l in lines))
        added_content_lines = [l for l in lines if l.startswith("+") and not l.startswith("+++")]
        self.assertEqual(added_content_lines, [])

    def test_binary_file_diff_is_marker_not_content_diff(self):
        snapshot = cm.snapshot_workspace(self.source_dir)
        isolated_dir = self._copy_source_to_isolated()
        self._write_bytes(isolated_dir, "logo.bin", BINARY_BYTES)

        manifest = cm.build_change_manifest(snapshot, isolated_dir)
        diff_text = cm.unified_diff(manifest)

        self.assertIn("Binary files", diff_text)
        self.assertIn("logo.bin", diff_text)
        self.assertNotIn("@@", diff_text)

    def test_text_and_binary_changes_together_only_text_gets_hunks(self):
        self._write_text(self.source_dir, "a.txt", TEXT_A)
        self._write_text(self.source_dir, "gone.txt", "will be deleted\n")
        snapshot = cm.snapshot_workspace(self.source_dir)
        isolated_dir = self._copy_source_to_isolated()

        self._write_text(isolated_dir, "a.txt", TEXT_A_MODIFIED)
        os.remove(os.path.join(isolated_dir, "gone.txt"))
        self._write_text(isolated_dir, "new.txt", "added text\n")
        self._write_bytes(isolated_dir, "logo.bin", BINARY_BYTES)

        manifest = cm.build_change_manifest(snapshot, isolated_dir)
        diff_text = cm.unified_diff(manifest)

        text_change_count = sum(1 for entry in manifest["changed_files"] if entry["kind"] == "text")
        self.assertEqual(diff_text.count("@@"), text_change_count)
        self.assertIn("Binary files", diff_text)


if __name__ == "__main__":
    unittest.main()