#!/usr/bin/env python3
"""
hash_manifest.py: write or verify a SHA-256 manifest for an evidence directory.
Security Artifacts, version 1.0, 2026-09-21. Released under CC0.

Standard library only, Python 3.8 or later, no network.

    python3 hash_manifest.py create  /cases/0142/collection
    python3 hash_manifest.py verify  /cases/0142/collection

`create` walks the directory, hashes every file and writes MANIFEST.sha256 in
the same format `sha256sum` produces, so the two tools can check each other.
It refuses to overwrite an existing manifest: the first manifest is the record
of what you collected, and replacing it silently is how a chain of custody
stops meaning anything.

`verify` re-hashes and reports three different things, because they are three
different findings: a file whose hash CHANGED, a file that is MISSING, and a
file that is NEW since the manifest was written. It exits non-zero if any of
the three occurred, so it can sit in a script.
"""
import argparse
import hashlib
import os
import sys

MANIFEST = "MANIFEST.sha256"
CHUNK = 1024 * 1024


def sha256_of(path):
    digest = hashlib.sha256()
    with open(path, "rb") as handle:
        for block in iter(lambda: handle.read(CHUNK), b""):
            digest.update(block)
    return digest.hexdigest()


def walk(root):
    """Relative paths of every regular file under root, sorted, manifest excluded."""
    found = []
    for directory, _subdirs, names in os.walk(root):
        for name in names:
            full = os.path.join(directory, name)
            rel = os.path.relpath(full, root)
            if rel == MANIFEST or os.path.islink(full) or not os.path.isfile(full):
                continue
            found.append(rel)
    return sorted(found)


def create(root):
    target = os.path.join(root, MANIFEST)
    if os.path.exists(target):
        sys.exit(
            f"{target} already exists. Verify against it, or move it aside deliberately; "
            "this tool will not replace a manifest."
        )
    files = walk(root)
    with open(target, "w", encoding="utf-8", newline="\n") as out:
        for rel in files:
            out.write(f"{sha256_of(os.path.join(root, rel))}  ./{rel.replace(os.sep, '/')}\n")
    print(f"{len(files)} files hashed")
    print(f"manifest: {target}")
    print(f"manifest sha256: {sha256_of(target)}")
    print("Record that last line in your case notes. It is what proves the manifest itself is unchanged.")


def read_manifest(path):
    recorded = {}
    with open(path, encoding="utf-8") as handle:
        for number, line in enumerate(handle, 1):
            line = line.rstrip("\n")
            if not line.strip():
                continue
            # sha256sum separates with two spaces, or " *" for binary mode.
            digest, sep, name = line.partition("  ")
            if not sep:
                digest, sep, name = line.partition(" *")
            if not sep or len(digest) != 64:
                sys.exit(f"{path}:{number}: not a sha256sum line")
            recorded[name[2:] if name.startswith("./") else name] = digest.lower()
    return recorded


def verify(root):
    target = os.path.join(root, MANIFEST)
    if not os.path.exists(target):
        sys.exit(f"no {MANIFEST} in {root}")
    recorded = read_manifest(target)
    present = {rel.replace(os.sep, "/") for rel in walk(root)}

    changed = [n for n in sorted(recorded) if n in present and sha256_of(os.path.join(root, n)) != recorded[n]]
    missing = [n for n in sorted(recorded) if n not in present]
    new = sorted(present - set(recorded))

    for label, names in (("CHANGED", changed), ("MISSING", missing), ("NEW", new)):
        for name in names:
            print(f"{label:8} {name}")

    intact = len(recorded) - len(changed) - len(missing)
    print(f"{intact} of {len(recorded)} recorded files intact; {len(changed)} changed, {len(missing)} missing, {len(new)} new")
    print(f"manifest sha256: {sha256_of(target)}")
    return 1 if (changed or missing or new) else 0


def main():
    parser = argparse.ArgumentParser(description="Write or verify a SHA-256 manifest for an evidence directory.")
    parser.add_argument("mode", choices=["create", "verify"])
    parser.add_argument("directory")
    args = parser.parse_args()
    if not os.path.isdir(args.directory):
        sys.exit(f"{args.directory} is not a directory")
    sys.exit(create(args.directory) if args.mode == "create" else verify(args.directory))


if __name__ == "__main__":
    main()
