selective_apply.py 5.6 KB
"""Selective, safe application of a change_manifest diff from an isolated
workspace (or a Supervisor candidate checkout) back into its source
workspace.

Adapted from graphloop's selective_apply.py (2026-07-30). Only change from
the original: `import change_manifest as cm` -> relative package import,
since this now lives in the `supervisor.isolation` package alongside
change_manifest.py instead of as a flat top-level module. Logic is
otherwise unchanged. See test_selective_apply.py in graphloop for the
original contract; graphlemon should add an equivalent test module when
this is wired into a real Enabler unit.
"""
import hashlib
import json
import os
import posixpath
import tempfile

from . import change_manifest as cm


class SelectiveApplyError(Exception):
    """Raised when apply_manifest rejects a call before any mutation of
    source_root, or refuses to write through a symlink it discovers."""


def _normalize_relative_path(path):
    if not isinstance(path, str) or path == "":
        raise SelectiveApplyError(f"empty or invalid path: {path!r}")
    if posixpath.isabs(path):
        raise SelectiveApplyError(f"absolute path not allowed: {path!r}")
    normalized = posixpath.normpath(path)
    segments = normalized.split("/")
    if normalized in (".", "") or any(segment == ".." for segment in segments):
        raise SelectiveApplyError(f"path traversal not allowed: {path!r}")
    return normalized


def _reject_symlink_components(root, rel_path):
    current = root
    for segment in rel_path.split("/"):
        current = os.path.join(current, segment)
        if os.path.islink(current):
            raise SelectiveApplyError(f"symlink encountered in path: {rel_path!r}")


def _manifest_digest(manifest):
    payload = json.dumps(manifest, sort_keys=True).encode("utf-8")
    return hashlib.sha256(payload).hexdigest()


def _hash_file(path):
    with open(path, "rb") as handle:
        return hashlib.sha256(handle.read()).hexdigest()


def apply_manifest(manifest, source_root, isolated_root, approved_paths,
                    expected_source_snapshot=None):
    if not isinstance(manifest, dict) or not isinstance(manifest.get("changed_files"), list):
        raise SelectiveApplyError("manifest must be a dict with a 'changed_files' list")
    if approved_paths is None:
        raise SelectiveApplyError("approved_paths is required and must not be None")
    if not os.path.isdir(source_root):
        raise SelectiveApplyError(f"source_root is not a directory: {source_root!r}")
    if not os.path.isdir(isolated_root):
        raise SelectiveApplyError(f"isolated_root is not a directory: {isolated_root!r}")

    source_root = os.path.realpath(source_root)
    isolated_root = os.path.realpath(isolated_root)

    entries_by_path = {entry["path"]: entry for entry in manifest["changed_files"]}

    normalized_approved = []
    for raw_path in approved_paths:
        normalized = _normalize_relative_path(raw_path)
        if normalized not in entries_by_path:
            raise SelectiveApplyError(f"path not present in manifest: {raw_path!r}")
        normalized_approved.append(normalized)

    if expected_source_snapshot is not None:
        current_snapshot = cm.snapshot_workspace(source_root)
        if current_snapshot != expected_source_snapshot:
            raise SelectiveApplyError(
                "source_root has drifted from expected_source_snapshot"
            )

    approved_entries = [entries_by_path[p] for p in normalized_approved]

    # Reject any destination that runs through a symlink (including
    # symlinked intermediate directories), and preload isolated bytes for
    # add/modify entries, so every possible read/validation failure surfaces
    # before source_root is touched.
    staged_data = {}
    for entry in approved_entries:
        rel = entry["path"]
        _reject_symlink_components(source_root, rel)

        dest = os.path.join(source_root, *rel.split("/"))
        if os.path.isdir(dest):
            raise SelectiveApplyError(f"destination is a directory: {rel!r}")

        if entry["status"] != "deleted":
            _reject_symlink_components(isolated_root, rel)
            src = os.path.join(isolated_root, *rel.split("/"))
            if not os.path.isfile(src):
                raise SelectiveApplyError(f"isolated source file missing: {rel!r}")
            with open(src, "rb") as handle:
                staged_data[rel] = handle.read()

    applied = []
    already_applied = []

    for entry in approved_entries:
        rel = entry["path"]
        dest = os.path.join(source_root, *rel.split("/"))

        if entry["status"] == "deleted":
            if os.path.exists(dest):
                os.remove(dest)
                applied.append(rel)
            else:
                already_applied.append(rel)
            continue

        data = staged_data[rel]
        if os.path.isfile(dest) and _hash_file(dest) == entry["new_hash"]:
            already_applied.append(rel)
            continue

        dest_dir = os.path.dirname(dest)
        os.makedirs(dest_dir, exist_ok=True)
        fd, tmp_path = tempfile.mkstemp(dir=dest_dir, prefix=".selective-apply-")
        try:
            with os.fdopen(fd, "wb") as tmp_handle:
                tmp_handle.write(data)
            os.replace(tmp_path, dest)
        except BaseException:
            if os.path.exists(tmp_path):
                os.remove(tmp_path)
            raise
        applied.append(rel)

    skipped = sorted(set(entries_by_path) - set(normalized_approved))

    return {
        "applied": sorted(applied),
        "skipped": skipped,
        "already_applied": sorted(already_applied),
        "manifest_id": _manifest_digest(manifest),
    }