117 lines
4.3 KiB
Python
117 lines
4.3 KiB
Python
"""Mechanism distribution and drift detection.
|
|
|
|
Gitea 1.25.5 cannot read a reusable workflow out of a private repository
|
|
(that is PR #32562, milestone 1.26.0), so the invariant scripts are copied into
|
|
consuming repositories. Copying is only safe if drift is detectable, so every
|
|
copy is pinned three ways: file hashes against a manifest, the manifest against
|
|
its own digest, and that digest against the policy record, which the consuming
|
|
repository cannot write.
|
|
"""
|
|
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import shutil
|
|
|
|
PIN_FILENAME = ".publication-mechanism.json"
|
|
|
|
|
|
def canonical_json(obj):
|
|
return json.dumps(obj, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
|
|
|
|
def sha256_file(path):
|
|
h = hashlib.sha256()
|
|
with open(path, "rb") as fh:
|
|
for chunk in iter(lambda: fh.read(65536), b""):
|
|
h.update(chunk)
|
|
return h.hexdigest()
|
|
|
|
|
|
def sha256_bytes(data):
|
|
return hashlib.sha256(data).hexdigest()
|
|
|
|
|
|
def compute_manifest(root, relpaths, version):
|
|
files = {}
|
|
for rel in sorted(relpaths):
|
|
path = os.path.join(root, rel)
|
|
if not os.path.isfile(path):
|
|
raise FileNotFoundError(f"mechanism: {rel} not found under {root}")
|
|
files[rel] = sha256_file(path)
|
|
return {"mechanism_version": version, "files": files}
|
|
|
|
|
|
def manifest_digest(manifest):
|
|
return sha256_bytes(canonical_json(manifest))
|
|
|
|
|
|
def write_manifest(path, manifest):
|
|
with open(path, "w", encoding="utf-8") as fh:
|
|
fh.write(json.dumps(manifest, sort_keys=True, indent=2) + "\n")
|
|
|
|
|
|
def read_manifest(path):
|
|
with open(path, "r", encoding="utf-8") as fh:
|
|
return json.load(fh)
|
|
|
|
|
|
def install(source_root, target_root, manifest, pin_dir=None):
|
|
"""Copy the manifest's files into target_root and write the pin file."""
|
|
for rel in sorted(manifest["files"]):
|
|
src = os.path.join(source_root, rel)
|
|
dst = os.path.join(target_root, rel)
|
|
os.makedirs(os.path.dirname(dst), exist_ok=True)
|
|
shutil.copyfile(src, dst)
|
|
os.chmod(dst, 0o755 if rel.endswith(".sh") or "/bin/" in rel else 0o644)
|
|
pin = {
|
|
"mechanism_version": manifest["mechanism_version"],
|
|
"manifest_sha256": manifest_digest(manifest),
|
|
}
|
|
pin_path = os.path.join(pin_dir or target_root, PIN_FILENAME)
|
|
with open(pin_path, "w", encoding="utf-8") as fh:
|
|
fh.write(json.dumps(pin, sort_keys=True, indent=2) + "\n")
|
|
return pin_path
|
|
|
|
|
|
def verify(target_root, manifest, policy_manifest_sha256, policy_mechanism_version):
|
|
"""Return failure reasons. Empty means the local copy is the pinned mechanism."""
|
|
reasons = []
|
|
digest = manifest_digest(manifest)
|
|
|
|
if digest != policy_manifest_sha256:
|
|
reasons.append(
|
|
"mechanism: canonical manifest digest does not match the digest pinned in "
|
|
"policy; the mechanism was changed without a policy change")
|
|
if manifest["mechanism_version"] != policy_mechanism_version:
|
|
reasons.append(
|
|
f"mechanism: manifest version {manifest['mechanism_version']} does not match "
|
|
f"policy version {policy_mechanism_version}")
|
|
|
|
pin_path = os.path.join(target_root, PIN_FILENAME)
|
|
if not os.path.isfile(pin_path):
|
|
reasons.append(f"mechanism: {PIN_FILENAME} is absent from {target_root}")
|
|
else:
|
|
try:
|
|
with open(pin_path, "r", encoding="utf-8") as fh:
|
|
pin = json.load(fh)
|
|
except json.JSONDecodeError as exc:
|
|
reasons.append(f"mechanism: {PIN_FILENAME} is not valid JSON ({exc})")
|
|
pin = None
|
|
if pin is not None:
|
|
if pin.get("manifest_sha256") != policy_manifest_sha256:
|
|
reasons.append(
|
|
"mechanism: in-repo pin disagrees with policy; a repository cannot "
|
|
"bless its own mechanism version")
|
|
if pin.get("mechanism_version") != policy_mechanism_version:
|
|
reasons.append("mechanism: in-repo pin version disagrees with policy")
|
|
|
|
for rel, expected in sorted(manifest["files"].items()):
|
|
path = os.path.join(target_root, rel)
|
|
if not os.path.isfile(path):
|
|
reasons.append(f"mechanism: {rel} is missing from the consuming repository")
|
|
continue
|
|
actual = sha256_file(path)
|
|
if actual != expected:
|
|
reasons.append(f"mechanism: {rel} has drifted from the canonical copy")
|
|
return reasons
|