feat(install): Plan-21 hybrid global install + casan init (pin+verify)
Adopt CASAN like a normal tool (codegraph-style): install the harness ONCE
per machine, then `casan init` per project writes CONFIG ONLY — the harness
is no longer copied into every repo.
- install.sh / install.ps1: global bootstrap (curl|sh / irm|iex or local
source). Installs harness to $CASAN_HOME/versions/<ver>, writes a `casan`
launcher that resolves the shared harness + the current project's .specify,
and records a gate-code integrity hash. CASAN_NO_PATH_LINK for tests.
- harness_hash.py: deterministic content hash over gate code (scripts/bash,
scripts/python, security, level5) — the pin+verify anchor.
- casan-init.py: `casan init` writes .casan/{config,version.lock,agentic.env},
.specify/ marker, and the Plan-20 client hooks — no harness copy. `verify`
recomputes the harness hash LIVE and compares to the project pin (drift/
tamper -> rc 3), preserving the Plan-16 trusted-gates guarantee off-repo.
- bin/casan: new `init` and `verify-harness` commands.
- hybrid-install-tests.sh: 21/21 (install, config-only init, no-copy, pin,
verify ok, tamper drift, bridge runs against project state via global harness).
- docs: CASAN_INSTALL_HYBRID.md + Plan-21.
The path model (casan-paths.sh) already separated harness/state/domain roots,
so this is installer + init, not a core rewrite. Remote dist tarball, real
Windows run, and signed .harness-hash are the documented next steps.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
f6d28a3163
commit
8450f8ca1a
Executable
+294
@@ -0,0 +1,294 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""`casan init` / `casan verify-harness` (Plan-21 hybrid adoption).
|
||||
|
||||
Adopt CASAN into an EXISTING project by writing only per-project config — the
|
||||
shared harness stays under $CASAN_HOME and is NOT copied into the repo. This is
|
||||
the codegraph-style flow: global install once, then `casan init` per project.
|
||||
|
||||
What init writes into the target repo:
|
||||
.casan/config.json project id, enforcement/integration mode, clients
|
||||
.casan/version.lock pinned harness version + gate-code integrity hash
|
||||
.casan/agentic.env Plan-20 bridge feature flags
|
||||
.specify/ runtime state root marker (logs/traces/admissions)
|
||||
.claude/settings.json Plan-20 Claude Code hooks (--client claude|all)
|
||||
.codex/hooks.json+config Plan-20 Codex hooks (--client codex|all)
|
||||
|
||||
`verify` recomputes the resolved harness gate-code hash and compares it to
|
||||
version.lock — the pin+VERIFY half. Drift/tamper of the global harness relative
|
||||
to what the project pinned is caught here (preserves the Plan-16 "gates are
|
||||
trusted code" guarantee even though the harness lives outside the repo).
|
||||
|
||||
stdlib-only. Resolves the harness via CASAN_HARNESS_ROOT (set by the global
|
||||
launcher) or --harness.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
|
||||
PROJECT_RE = re.compile(r"^[a-z][a-z0-9-]{1,62}$")
|
||||
|
||||
|
||||
def now_iso():
|
||||
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
|
||||
|
||||
def resolve_harness(explicit):
|
||||
for cand in (explicit, os.environ.get("CASAN_HARNESS_ROOT")):
|
||||
if cand and os.path.isdir(os.path.join(cand, "scripts", "bash")):
|
||||
return os.path.abspath(cand)
|
||||
install = os.environ.get("CASAN_INSTALL_ROOT")
|
||||
if install:
|
||||
c = os.path.join(install, "packages", "casan-harness")
|
||||
if os.path.isdir(c):
|
||||
return os.path.abspath(c)
|
||||
# Fallback: this file lives at packages/casan-devkit/casan-init.py.
|
||||
c = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "casan-harness"))
|
||||
return c if os.path.isdir(c) else None
|
||||
|
||||
|
||||
def install_root(harness):
|
||||
if os.environ.get("CASAN_INSTALL_ROOT"):
|
||||
return os.path.abspath(os.environ["CASAN_INSTALL_ROOT"])
|
||||
return os.path.abspath(os.path.join(harness, "..", ".."))
|
||||
|
||||
|
||||
def harness_version(harness):
|
||||
for p in (os.path.join(install_root(harness), "VERSION"),
|
||||
os.path.join(harness, "..", "..", "VERSION")):
|
||||
try:
|
||||
with open(p, "r", encoding="utf-8") as fh:
|
||||
v = fh.read().strip()
|
||||
if v:
|
||||
return v
|
||||
except (OSError, IOError):
|
||||
continue
|
||||
return "0.0.0"
|
||||
|
||||
|
||||
def compute_live(harness):
|
||||
"""ALWAYS recompute the gate-code hash from the actual files on disk. Used by
|
||||
verify so a tampered harness cannot hide behind a stale recorded hash."""
|
||||
sys.path.insert(0, os.path.join(harness, "scripts", "python"))
|
||||
try:
|
||||
import harness_hash # noqa: E402
|
||||
return harness_hash.compute(harness), "computed"
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return "unavailable:%s" % exc, "error"
|
||||
|
||||
|
||||
def compute_harness_hash(harness):
|
||||
"""For PINNING at init: use the value recorded at install time if present
|
||||
(it equals a live compute of the same files), else compute live. Verify must
|
||||
NOT use this — it must call compute_live() to detect drift."""
|
||||
recorded = os.path.join(install_root(harness), ".harness-hash")
|
||||
try:
|
||||
with open(recorded, "r", encoding="utf-8") as fh:
|
||||
v = fh.read().strip()
|
||||
if v:
|
||||
return v, "recorded"
|
||||
except (OSError, IOError):
|
||||
pass
|
||||
return compute_live(harness)
|
||||
|
||||
|
||||
def _write(path, text, backups):
|
||||
if os.path.exists(path):
|
||||
bak = path + ".casan-bak"
|
||||
if not os.path.exists(bak):
|
||||
with open(path, "r", encoding="utf-8", errors="replace") as fh:
|
||||
old = fh.read()
|
||||
with open(bak, "w", encoding="utf-8") as fh:
|
||||
fh.write(old)
|
||||
backups.append(bak)
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
fh.write(text)
|
||||
|
||||
|
||||
def _copy_template(src, dst, backups):
|
||||
try:
|
||||
with open(src, "r", encoding="utf-8") as fh:
|
||||
_write(dst, fh.read(), backups)
|
||||
return True
|
||||
except (OSError, IOError):
|
||||
return False
|
||||
|
||||
|
||||
def cmd_init(args):
|
||||
harness = resolve_harness(args.harness)
|
||||
if not harness:
|
||||
sys.stderr.write("casan init: cannot locate the harness. Install CASAN first "
|
||||
"(install.sh) or set CASAN_HARNESS_ROOT.\n")
|
||||
return 1
|
||||
target = os.path.abspath(args.target or os.getcwd())
|
||||
if not os.path.isdir(target):
|
||||
sys.stderr.write("casan init: target is not a directory: %s\n" % target)
|
||||
return 66
|
||||
|
||||
project = args.project or re.sub(r"[^a-z0-9-]", "-", os.path.basename(target).lower()).strip("-")
|
||||
if not PROJECT_RE.match(project):
|
||||
sys.stderr.write("casan init: --project must match ^[a-z][a-z0-9-]{1,62}$ (got %r)\n" % project)
|
||||
return 64
|
||||
|
||||
clients = ["claude", "codex"] if args.client == "all" else [args.client]
|
||||
version = harness_version(harness)
|
||||
hhash, hsource = compute_harness_hash(harness)
|
||||
created = []
|
||||
backups = []
|
||||
|
||||
def created_add(p):
|
||||
created.append(os.path.relpath(p, target))
|
||||
|
||||
# ── .casan/config.json ──
|
||||
cfg_dir = os.path.join(target, ".casan")
|
||||
cfg = {
|
||||
"schema_version": "21.1",
|
||||
"project_id": project,
|
||||
"created_at": now_iso(),
|
||||
"enforcement_mode": args.mode,
|
||||
"integration_mode": args.integration_mode,
|
||||
"clients": clients,
|
||||
"harness_version": version,
|
||||
"adoption_model": "hybrid-global",
|
||||
}
|
||||
p = os.path.join(cfg_dir, "config.json")
|
||||
_write(p, json.dumps(cfg, ensure_ascii=False, indent=2) + "\n", backups); created_add(p)
|
||||
|
||||
# ── .casan/version.lock (pin) ──
|
||||
lock = {
|
||||
"harness_version": version,
|
||||
"harness_hash": hhash,
|
||||
"hash_algo": "sha256",
|
||||
"hash_source": hsource,
|
||||
"install_root": install_root(harness),
|
||||
"recorded_at": now_iso(),
|
||||
}
|
||||
p = os.path.join(cfg_dir, "version.lock")
|
||||
_write(p, json.dumps(lock, ensure_ascii=False, indent=2) + "\n", backups); created_add(p)
|
||||
|
||||
# ── .casan/agentic.env (Plan-20 flags) ──
|
||||
env_lines = [
|
||||
"# CASAN Plan-20 agentic bridge flags. Source before starting the client.",
|
||||
"CASAN_AGENTIC_BRIDGE_ENABLED=1",
|
||||
"CASAN_AGENTIC_ENFORCEMENT_MODE=%s" % args.mode,
|
||||
"CASAN_AGENTIC_INTEGRATION_MODE=%s" % args.integration_mode,
|
||||
"",
|
||||
]
|
||||
p = os.path.join(cfg_dir, "agentic.env")
|
||||
_write(p, "\n".join(env_lines), backups); created_add(p)
|
||||
|
||||
# ── .specify/ state root marker ──
|
||||
specify = os.path.join(target, ".specify")
|
||||
os.makedirs(os.path.join(specify, "state"), exist_ok=True)
|
||||
os.makedirs(os.path.join(specify, "logs"), exist_ok=True)
|
||||
gi = os.path.join(specify, ".gitignore")
|
||||
if not os.path.exists(gi):
|
||||
_write(gi, "# CASAN runtime state — do not commit\nlogs/\nstate/\n", backups); created_add(gi)
|
||||
|
||||
# ── Plan-20 client hooks from the harness adapters ──
|
||||
ad = os.path.join(harness, "adapters")
|
||||
if "claude" in clients:
|
||||
if _copy_template(os.path.join(ad, "claude-code", "settings.template.json"),
|
||||
os.path.join(target, ".claude", "settings.json"), backups):
|
||||
created_add(os.path.join(target, ".claude", "settings.json"))
|
||||
if "codex" in clients:
|
||||
for src, dst in (("hooks.template.json", "hooks.json"),
|
||||
("config.template.toml", "config.toml")):
|
||||
if _copy_template(os.path.join(ad, "codex", src),
|
||||
os.path.join(target, ".codex", dst), backups):
|
||||
created_add(os.path.join(target, ".codex", dst))
|
||||
|
||||
# ── manifest (so uninstall/verify know what init created) ──
|
||||
manifest = {
|
||||
"created": created,
|
||||
"backups": [os.path.relpath(b, target) for b in backups],
|
||||
"project_id": project,
|
||||
"at": now_iso(),
|
||||
}
|
||||
p = os.path.join(cfg_dir, "init-manifest.json")
|
||||
_write(p, json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", backups)
|
||||
|
||||
print(json.dumps({
|
||||
"status": "initialized",
|
||||
"project_id": project,
|
||||
"target": target,
|
||||
"harness_version": version,
|
||||
"harness_hash": hhash,
|
||||
"enforcement_mode": args.mode,
|
||||
"clients": clients,
|
||||
"created": created,
|
||||
"note": ("harness NOT copied into repo (hybrid model); "
|
||||
"run `casan verify-harness` to check the pin"),
|
||||
}, ensure_ascii=False, indent=2))
|
||||
if hsource == "error":
|
||||
sys.stderr.write("casan init: WARNING — could not compute harness hash; "
|
||||
"pin verification will be unavailable.\n")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_verify(args):
|
||||
harness = resolve_harness(args.harness)
|
||||
if not harness:
|
||||
sys.stderr.write("casan verify-harness: cannot locate the harness.\n")
|
||||
return 1
|
||||
target = os.path.abspath(args.target or os.getcwd())
|
||||
lock_path = os.path.join(target, ".casan", "version.lock")
|
||||
if not os.path.exists(lock_path):
|
||||
sys.stderr.write("casan verify-harness: no .casan/version.lock (run `casan init` first).\n")
|
||||
return 1
|
||||
with open(lock_path, "r", encoding="utf-8") as fh:
|
||||
lock = json.load(fh)
|
||||
expected = lock.get("harness_hash")
|
||||
actual, _src = compute_live(harness) # live recompute — never the cached hash
|
||||
ok = (expected == actual) and expected and not str(expected).startswith("unavailable")
|
||||
result = {
|
||||
"status": "ok" if ok else "drift",
|
||||
"expected": expected,
|
||||
"actual": actual,
|
||||
"harness_version_lock": lock.get("harness_version"),
|
||||
"harness_version_now": harness_version(harness),
|
||||
"harness_root": harness,
|
||||
}
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
if not ok:
|
||||
sys.stderr.write("HARNESS_INTEGRITY_DRIFT — the resolved harness does not match the "
|
||||
"project pin. The global harness changed or was tampered.\n")
|
||||
return 3
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(prog="casan-init", description="CASAN hybrid adoption")
|
||||
sub = parser.add_subparsers(dest="cmd")
|
||||
|
||||
pi = sub.add_parser("init", help="adopt CASAN into the current project (config only)")
|
||||
pi.add_argument("--target", help="project root (default: cwd)")
|
||||
pi.add_argument("--project", help="project id (^[a-z][a-z0-9-]{1,62}$; default: dir name)")
|
||||
pi.add_argument("--client", choices=["claude", "codex", "all"], default="all")
|
||||
pi.add_argument("--mode", choices=["observe", "enforce"], default="observe")
|
||||
pi.add_argument("--integration-mode", dest="integration_mode",
|
||||
choices=["project_hook", "managed_hook", "casan_owned"], default="project_hook")
|
||||
pi.add_argument("--harness", help="override harness root")
|
||||
|
||||
pv = sub.add_parser("verify", help="verify the resolved harness matches the project pin")
|
||||
pv.add_argument("--target", help="project root (default: cwd)")
|
||||
pv.add_argument("--harness", help="override harness root")
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
if args.cmd == "init":
|
||||
return cmd_init(args)
|
||||
if args.cmd == "verify":
|
||||
return cmd_verify(args)
|
||||
parser.print_help()
|
||||
return 64
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env bash
|
||||
set -uo pipefail
|
||||
|
||||
# CASAN Plan-21 — hybrid install + `casan init` acceptance tests.
|
||||
#
|
||||
# Proves the codegraph-style flow: global install once, then per-project
|
||||
# `casan init` that writes CONFIG ONLY (no harness copy), with a pin+verify
|
||||
# integrity guarantee on the shared harness. Deterministic, offline.
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
|
||||
|
||||
PASS=0; FAIL=0
|
||||
pass() { echo "PASS: $1"; PASS=$((PASS + 1)); }
|
||||
fail() { echo "FAIL: $1"; FAIL=$((FAIL + 1)); }
|
||||
|
||||
WORK="$(mktemp -d)"
|
||||
export CASAN_HOME="$WORK/home/.casan"
|
||||
export CASAN_SRC="$REPO_ROOT"
|
||||
export CASAN_NO_PATH_LINK=1
|
||||
cleanup() { rm -rf "$WORK"; }
|
||||
trap cleanup EXIT
|
||||
|
||||
CASAN="$CASAN_HOME/bin/casan"
|
||||
|
||||
echo "===== ① global install ====="
|
||||
if sh "$REPO_ROOT/install.sh" >/dev/null 2>&1; then pass "install.sh completes"; else fail "install.sh failed"; fi
|
||||
[[ -x "$CASAN" ]] && pass "global launcher created" || fail "launcher missing"
|
||||
[[ -f "$CASAN_HOME/current/.harness-hash" ]] && pass "integrity hash recorded at install" || fail "no .harness-hash"
|
||||
"$CASAN" version >/dev/null 2>&1 && pass "casan version works via launcher" || fail "casan version failed"
|
||||
|
||||
echo "===== ② casan init (config only, no harness copy) ====="
|
||||
PROJ="$WORK/proj/my-app"; mkdir -p "$PROJ"; echo '{"name":"x"}' > "$PROJ/package.json"
|
||||
( cd "$PROJ" && "$CASAN" init --project my-app --mode enforce >/dev/null 2>&1 ) \
|
||||
&& pass "casan init completes" || fail "casan init failed"
|
||||
for f in .casan/config.json .casan/version.lock .casan/agentic.env .claude/settings.json .codex/hooks.json .specify/.gitignore; do
|
||||
[[ -f "$PROJ/$f" ]] && pass "init wrote $f" || fail "init missing $f"
|
||||
done
|
||||
if [[ -d "$PROJ/packages/casan-harness" ]]; then fail "harness was copied into the repo (should not be)"; else pass "harness NOT copied into repo (hybrid model)"; fi
|
||||
|
||||
echo "===== ③ version.lock pins the installed harness ====="
|
||||
LOCK_HASH="$(python3 -c 'import json;print(json.load(open("'"$PROJ"'/.casan/version.lock"))["harness_hash"])')"
|
||||
REC_HASH="$(cat "$CASAN_HOME/current/.harness-hash")"
|
||||
[[ -n "$LOCK_HASH" && "$LOCK_HASH" == "$REC_HASH" ]] && pass "version.lock pins the installed gate-code hash" || fail "lock hash mismatch ($LOCK_HASH vs $REC_HASH)"
|
||||
|
||||
echo "===== ④ verify-harness: ok before tamper, drift after ====="
|
||||
( cd "$PROJ" && "$CASAN" verify-harness >/dev/null 2>&1 ) && pass "verify-harness OK on a clean install" || fail "verify-harness reported drift on clean install"
|
||||
echo "# tampered $(date)" >> "$CASAN_HOME/current/packages/casan-harness/scripts/bash/security-check.sh"
|
||||
VRC=0; ( cd "$PROJ" && "$CASAN" verify-harness >/dev/null 2>"$WORK/vh.err" ) || VRC=$?
|
||||
[[ "$VRC" -eq 3 ]] && pass "verify-harness detects tamper (rc=3)" || fail "tamper not detected (rc=$VRC)"
|
||||
grep -q "HARNESS_INTEGRITY_DRIFT" "$WORK/vh.err" && pass "drift message emitted" || fail "no drift message"
|
||||
|
||||
echo "===== ⑤ agentic bridge runs against the PROJECT state via GLOBAL harness ====="
|
||||
BR="$CASAN_HOME/current/packages/casan-harness/scripts/python/agentic_bridge.py"
|
||||
B=$(CASAN_APP_ROOT="$PROJ" CASAN_AGENTIC_ENFORCEMENT_MODE=enforce \
|
||||
bash -c 'echo '\''{"op":"begin","client":"claude-code","project":"'"$PROJ"'","session":"s","prompt":"add fn","integration_mode":"project_hook"}'\'' | python3 "'"$BR"'" run')
|
||||
echo "$B" | grep -q '"decision": "allow"' && pass "bridge admits a normal turn via the global harness" || fail "bridge begin failed ($B)"
|
||||
if find "$PROJ/.specify" -name 'turn-*.json' | grep -q .; then pass "admission state lands in the PROJECT .specify"; else fail "no admission state in project"; fi
|
||||
if [[ -d "$CASAN_HOME/current/.specify" ]]; then fail "runtime state leaked into the global install"; else pass "no runtime state in the global install"; fi
|
||||
|
||||
echo "===== ⑥ init defaults project id from dir name + is re-runnable ====="
|
||||
PROJ2="$WORK/proj2/Some_App"; mkdir -p "$PROJ2"
|
||||
( cd "$PROJ2" && "$CASAN" init >/dev/null 2>&1 ) && pass "init works with a defaulted project id" || fail "init default id failed"
|
||||
PID=$(python3 -c 'import json;print(json.load(open("'"$PROJ2"'/.casan/config.json"))["project_id"])' 2>/dev/null)
|
||||
[[ "$PID" =~ ^[a-z][a-z0-9-]{1,62}$ ]] && pass "defaulted project id is sanitized ($PID)" || fail "bad default project id ($PID)"
|
||||
|
||||
echo ""
|
||||
echo "===== HYBRID INSTALL SUMMARY: PASS=$PASS FAIL=$FAIL ====="
|
||||
[[ "$FAIL" -eq 0 ]] || exit 1
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Deterministic harness integrity hash (Plan-21 hybrid install pin+verify).
|
||||
|
||||
Computes a stable content hash over the CASAN gate code so a globally-installed
|
||||
harness can be PINNED by a project (`.casan/version.lock`) and VERIFIED at runtime.
|
||||
If the resolved global harness drifts or is tampered relative to the pin, the
|
||||
project can fail closed — preserving the "gates are trusted, not arbitrary code"
|
||||
guarantee from Plan-16 even when the harness lives outside the repo.
|
||||
|
||||
Only GATE-relevant trees are hashed (the code that makes security/governance
|
||||
decisions), never runtime state/logs:
|
||||
|
||||
scripts/bash scripts/python security level5
|
||||
|
||||
stdlib-only, deterministic (sorted paths), text-mode agnostic (hashes raw bytes).
|
||||
|
||||
Usage:
|
||||
harness_hash.py compute <harness_root> # prints "<algo>:<hex>"
|
||||
harness_hash.py manifest <harness_root> # prints JSON {file: sha256}
|
||||
harness_hash.py verify <harness_root> <expected> # exit 0 match / 3 drift
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
ALGO = "sha256"
|
||||
HASHED_SUBTREES = ("scripts/bash", "scripts/python", "security", "level5")
|
||||
SKIP_DIR_NAMES = {"__pycache__", ".git", "node_modules"}
|
||||
SKIP_SUFFIXES = (".pyc", ".pyo", ".log", ".tmp", ".DS_Store")
|
||||
# Within level5, only policy/config, not regenerated runtime artifacts.
|
||||
SKIP_BASENAMES = {"policy-manifest.json", "policy-manifest.sig", "project-registry.json"}
|
||||
|
||||
|
||||
def _iter_files(root):
|
||||
for sub in HASHED_SUBTREES:
|
||||
base = os.path.join(root, sub)
|
||||
if not os.path.isdir(base):
|
||||
continue
|
||||
for dirpath, dirnames, filenames in os.walk(base):
|
||||
dirnames[:] = sorted(d for d in dirnames if d not in SKIP_DIR_NAMES)
|
||||
for name in sorted(filenames):
|
||||
if name in SKIP_BASENAMES:
|
||||
continue
|
||||
if any(name.endswith(s) for s in SKIP_SUFFIXES):
|
||||
continue
|
||||
full = os.path.join(dirpath, name)
|
||||
rel = os.path.relpath(full, root).replace(os.sep, "/")
|
||||
yield rel, full
|
||||
|
||||
|
||||
def manifest(root):
|
||||
out = {}
|
||||
for rel, full in _iter_files(root):
|
||||
try:
|
||||
with open(full, "rb") as fh:
|
||||
out[rel] = hashlib.sha256(fh.read()).hexdigest()
|
||||
except (OSError, IOError):
|
||||
out[rel] = "UNREADABLE"
|
||||
return out
|
||||
|
||||
|
||||
def compute(root):
|
||||
m = manifest(root)
|
||||
joiner = "\n".join("%s:%s" % (rel, m[rel]) for rel in sorted(m))
|
||||
digest = hashlib.sha256(joiner.encode("utf-8")).hexdigest()
|
||||
return "%s:%s" % (ALGO, digest)
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
argv = argv if argv is not None else sys.argv[1:]
|
||||
if len(argv) < 2:
|
||||
sys.stderr.write("usage: harness_hash.py <compute|manifest|verify> <harness_root> [expected]\n")
|
||||
return 64
|
||||
cmd, root = argv[0], argv[1]
|
||||
if not os.path.isdir(root):
|
||||
sys.stderr.write("harness_hash: not a directory: %s\n" % root)
|
||||
return 66
|
||||
if cmd == "compute":
|
||||
print(compute(root))
|
||||
return 0
|
||||
if cmd == "manifest":
|
||||
print(json.dumps(manifest(root), ensure_ascii=False, indent=2, sort_keys=True))
|
||||
return 0
|
||||
if cmd == "verify":
|
||||
if len(argv) < 3:
|
||||
sys.stderr.write("harness_hash: verify needs an expected hash\n")
|
||||
return 64
|
||||
actual = compute(root)
|
||||
expected = argv[2].strip()
|
||||
if actual == expected:
|
||||
print("HARNESS_INTEGRITY_OK %s" % actual)
|
||||
return 0
|
||||
sys.stderr.write("HARNESS_INTEGRITY_DRIFT expected=%s actual=%s\n" % (expected, actual))
|
||||
return 3
|
||||
sys.stderr.write("harness_hash: unknown command %s\n" % cmd)
|
||||
return 64
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user