Add licensed public-resource archive plan and verified LFS tooling

This commit is contained in:
Digital Drosophila Archive
2026-10-01 03:21:48 +08:00
parent be41af186c
commit 7dc85baea9
9 changed files with 1133 additions and 0 deletions
@@ -0,0 +1,85 @@
"""Read-only source guard; write receipt only to a new publication path."""
import argparse
from datetime import datetime, timezone
import hashlib
import json
from pathlib import Path
import shutil
import subprocess
from publish_archives import no_links, sha_file, canonical
ROOT = Path('/data/digital-drosophila')
REGISTERED = 'b63e6281b6505ca3b60acb467dd0b3fae274cf513dcbfd653649ba52e9ba9740'
SOURCE_COMMIT = '80aeb2093db655329774eff7b32fb6e417b8b40b'
def git(*args, cwd=ROOT):
return subprocess.check_output(['git', '-C', str(cwd), *args], text=True).strip()
def capture(plan_path, output):
output = no_links(output)
if output == ROOT or ROOT in output.parents or output.exists():
raise ValueError('Receipt must be new and outside the scientific source')
plan = json.loads(Path(plan_path).read_bytes())
path = ROOT / 'experiments/phase4ar-normal-conditioning/protocol/preregistered.json'
raw = path.read_bytes()
if hashlib.sha256(raw).hexdigest() != REGISTERED:
raise ValueError('Phase 4AR registration hash mismatch')
registration = json.loads(raw)
records = []
for filename, expected in sorted(registration['source_sha256'].items()):
source = no_links(filename)
if ROOT not in source.parents:
raise ValueError('Registered source outside project')
actual = sha_file(source)
if actual['sha256'] != expected:
raise ValueError('Phase 4AR source mismatch: ' + filename)
records.append(dict(path=filename, **actual))
if len(records) != 16:
raise ValueError('Registered source inventory differs from reviewed scope')
if git('cat-file', '-t', SOURCE_COMMIT) != 'commit':
raise ValueError('Registered scientific commit unavailable')
evidence = []
for group in plan['groups']:
licence = group['license']
if 'evidence_file' in licence:
source = ROOT / group['paths'][0] / licence['evidence_file']
actual = sha_file(source)
if actual['sha256'] != licence['evidence_sha256']:
raise ValueError('License evidence hash mismatch: ' + group['id'])
evidence.append(dict(group=group['id'], path=str(source), **actual))
provenance = group['provenance']
source = ROOT / group['paths'][0]
if (source / '.git').exists():
if git('rev-parse', 'HEAD', cwd=source) != provenance['commit']:
raise ValueError('Upstream commit mismatch: ' + group['id'])
if git('status', '--porcelain', cwd=source):
raise ValueError('Upstream checkout changed: ' + group['id'])
result = dict(schema='lfs-publication-source-guard-v1',
observed_utc=datetime.now(timezone.utc).isoformat(),
registration_sha256=REGISTERED, registered_scientific_commit=SOURCE_COMMIT,
phase4ar_registered_sources=records, license_evidence=evidence,
original_checkout_head=git('rev-parse', 'HEAD'),
original_checkout_status=git('status', '--porcelain'),
original_checkout_branch=git('branch', '--show-current'),
disk_free_bytes={str(p): shutil.disk_usage(p).free for p in (ROOT, output.parent)},
pass_all=True,
scope='16 registered Phase 4AR source files, registration, pinned upstream checkout and license evidence; not a scientific rerun or full historical checksum audit')
if result['original_checkout_status']:
raise ValueError('Original checkout is no longer clean; preserve and investigate')
if any(x < 100 * 1024**3 for x in result['disk_free_bytes'].values()):
raise ValueError('Less than 100 GiB free reserve')
with output.open('xb') as handle:
handle.write(canonical(result))
return dict(pass_all=True, receipt=str(output), registered_sources=len(records),
original_checkout_head=result['original_checkout_head'])
if __name__ == '__main__':
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--plan', required=True)
parser.add_argument('--out', required=True)
args = parser.parse_args()
print(json.dumps(capture(args.plan, args.out), sort_keys=True))
+534
View File
@@ -0,0 +1,534 @@
"""Whitelist-only LFS archives; content verification, not a redistribution grant.
No source writes, cache-based trust, extraction, or Git operations. Failed packs
remain incomplete and cannot be retried into the same output/work directories.
File stability checks are observations, not an atomic filesystem snapshot.
"""
from __future__ import annotations
import argparse
import gzip
import hashlib
import io
import json
import os
from pathlib import Path, PurePosixPath
import re
import stat
import subprocess
import tarfile
import tempfile
import threading
SOURCE_ROOT = "/data/digital-drosophila"
CHUNK_BYTES = 64 * 1024 * 1024
BUFFER = 1024 * 1024
DENIED_DIRS = {".git", ".venv", "venv", "env", "__pycache__", ".cache",
"node_modules", ".pytest_cache", ".mypy_cache", "extension-build"}
LOG_DIRS = {"logs", "_logs"}
BINARY_SUFFIXES = {".npy", ".npz", ".feather", ".parquet", ".gz", ".zst", ".zip",
".tar", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".pdf",
".woff", ".woff2", ".ttf", ".ico", ".so", ".o", ".a", ".bin"}
HEX = re.compile(r"^[0-9a-f]{64}$")
SECRET_RULES = (
("private-key", re.compile(r"-----BEGIN (?:[A-Z ]+ )?PRIVATE KEY-----")),
("github-token", re.compile(r"\b(?:ghp_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})\b")),
("aws-access-key", re.compile(r"\bAKIA[0-9A-Z]{16}\b")),
("url-user-password", re.compile(r"\b[a-zA-Z][a-zA-Z0-9+.-]*://[^\s/:@]+:[^\s/@]+@")),
("secret-literal", re.compile(r'''(?ix)\b(?:password|passwd|api[_-]?key|secret[_-]?key)\b["']?\s*[:=]\s*(?:["']([^"'\r\n]+)["']|([A-Za-z0-9_+/@.-]{4,}))''')),
)
def canonical(value):
return (json.dumps(value, sort_keys=True, indent=2, ensure_ascii=False, allow_nan=False) + "\n").encode()
def json_bytes(data):
def unique(pairs):
value = {}
for key, item in pairs:
if key in value:
raise ValueError("Duplicate JSON key")
value[key] = item
return value
return json.loads(data, object_pairs_hook=unique)
def json_read(path):
return json_bytes(Path(path).read_bytes())
def relpath(value):
if not isinstance(value, str) or not value or "\\" in value or "\x00" in value:
raise ValueError("Invalid relative path")
p = PurePosixPath(value)
if p.is_absolute() or any(x in ("", ".", "..") for x in value.split("/")) or ":" in value:
raise ValueError("Unsafe relative path")
return p.as_posix()
def forbidden(relative):
parts = PurePosixPath(relative).parts
lower = [x.lower() for x in parts]
if any(x in DENIED_DIRS or x.startswith(".venv") for x in lower):
return "git-environment-or-cache"
if any(x.startswith("phase4ar") for x in lower):
return "active-phase4ar"
if any(x in LOG_DIRS for x in lower) or lower[-1].endswith((".log", ".log.gz")):
return "log"
name = lower[-1]
if (name == ".env" or name.startswith(".env.") or name in
{"id_rsa", "id_ed25519", "id_dsa", ".git-credentials", ".netrc"}
or any(x in name for x in ("credentials", "secrets"))
or name.endswith((".pem", ".key", ".p12", ".pfx"))):
return "credential-file"
return None
def no_links(path):
if ".." in Path(path).parts:
raise ValueError("Parent path components refused")
p = Path(path).absolute()
for part in (p, *p.parents):
if part.exists() or part.is_symlink():
s = part.lstat()
if (stat.S_ISLNK(s.st_mode) or
getattr(s, "st_file_attributes", 0) & getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0)):
raise ValueError("Symlink refused: " + str(part))
return p
def identity(s):
return (s.st_dev, s.st_ino, s.st_mode, s.st_size, s.st_mtime_ns, s.st_ctime_ns)
def opened_identity(handle, expected):
actual = identity(os.fstat(handle.fileno()))
# Some Windows Python builds expose birth time via stat().ctime but change
# time via fstat().ctime. Check each clock against itself before/after; never
# omit ctime from either endpoint's stability check. Linux uses all fields.
same = actual[:-1] == expected[:-1] if os.name == "nt" else actual == expected
if not same:
raise ValueError("File changed while opening")
return actual
def sha_file(path):
p = no_links(path)
before = p.stat()
if not stat.S_ISREG(before.st_mode):
raise ValueError("Not a regular file: " + str(p))
h = hashlib.sha256()
size = 0
with p.open("rb") as f:
opened = opened_identity(f, identity(before))
while block := f.read(BUFFER):
h.update(block)
size += len(block)
if identity(os.fstat(f.fileno())) != opened:
raise ValueError("File changed while hashing: " + str(p))
if identity(p.lstat()) != identity(before) or size != before.st_size:
raise ValueError("File changed while hashing: " + str(p))
return {"sha256": h.hexdigest(), "bytes": size}
def write_new(path, data):
with Path(path).open("xb") as f:
f.write(data)
def validate_plan(plan):
if not isinstance(plan, dict) or set(plan) - {"schema", "source_root", "groups", "exclude_paths"}:
raise ValueError("Unknown plan fields")
if plan.get("schema") != "lfs-publication-plan-v1" or plan.get("source_root") != SOURCE_ROOT:
raise ValueError("Wrong plan schema/source_root")
groups = plan.get("groups")
if not isinstance(groups, list) or not groups:
raise ValueError("Nonempty groups required")
seen, all_paths = set(), []
for g in groups:
if not isinstance(g, dict) or set(g) != {"id", "paths", "license", "provenance"}:
raise ValueError("Group requires id/paths/license/provenance only")
group_id = g["id"]
if not isinstance(group_id, str) or not re.fullmatch(r"[a-z0-9][a-z0-9._-]{0,99}", group_id) or group_id in seen:
raise ValueError("Invalid or duplicate group id")
seen.add(group_id)
if not isinstance(g["paths"], list) or not g["paths"] or not g["license"] or not g["provenance"]:
raise ValueError("Nonempty paths/license/provenance required")
for value in g["paths"]:
p = relpath(value)
if forbidden(p):
raise ValueError("Explicit forbidden path: " + p)
if any(p == old or p.startswith(old + "/") or old.startswith(p + "/") for old in all_paths):
raise ValueError("Overlapping whitelist paths")
all_paths.append(p)
excludes = plan.get("exclude_paths", [])
if not isinstance(excludes, list):
raise ValueError("exclude_paths must be a list")
for p in excludes:
relpath(p)
canonical(plan)
def enumerate_group(root, group, excludes):
files, omitted = [], []
def visit(p):
relative = p.relative_to(root).as_posix()
s = p.lstat()
if stat.S_ISLNK(s.st_mode):
raise ValueError("Symlink refused: " + relative)
reason = forbidden(relative)
if any(relative == x or relative.startswith(x + "/") for x in excludes):
reason = "plan-exclusion"
if reason:
omitted.append({"path": relative, "reason": reason,
"scope": "subtree" if stat.S_ISDIR(s.st_mode) else "file",
"bytes": s.st_size if stat.S_ISREG(s.st_mode) else None})
elif stat.S_ISDIR(s.st_mode):
for child in sorted(p.iterdir(), key=lambda x: x.name):
visit(child)
elif stat.S_ISREG(s.st_mode):
files.append((relative, identity(s)))
else:
raise ValueError("Nonregular source refused: " + relative)
for relative in group["paths"]:
path = no_links(root / relative)
visit(path)
files.sort(key=lambda x: x[0])
omitted.sort(key=lambda x: x["path"])
if not files:
raise ValueError("Empty group refused")
return files, omitted
def scan_secrets(root, files):
for relative, original in files:
p = root / relative
if original[3] > 2 * 1024 * 1024 or p.suffix.lower() in BINARY_SUFFIXES:
continue
data = p.read_bytes()
if identity(p.lstat()) != original:
raise ValueError("Source changed during secret scan: " + relative)
try:
text = data.decode("utf-8")
except UnicodeDecodeError:
continue
if "\x00" in text:
continue
# Per-rule ASCII necessary fragments avoid repeated scans of numeric SWC
# bodies (their header includes a public URL). Non-ASCII text still uses
# every regex because Unicode IGNORECASE has broader equivalences.
lower = text.lower()
fragments = {
"private-key": ("private key",),
"github-token": ("ghp_", "github_pat_"),
"aws-access-key": ("akia",),
"url-user-password": ("://",),
"secret-literal": ("password", "passwd", "api", "secret"),
}
for rule, pattern in SECRET_RULES:
if text.isascii() and not any(x in lower for x in fragments[rule]):
continue
for match in pattern.finditer(text):
if rule == "secret-literal":
literal = match.group(1) or match.group(2)
if literal.lower() in {"none", "null", "false", "true"}:
continue
raise ValueError("Secret scan refused: " + relative + " [" + rule + "]")
def gzip_rows(path, rows):
with Path(path).open("xb") as raw:
with gzip.GzipFile(filename="", mode="wb", fileobj=raw, mtime=0) as out:
for row in rows:
out.write((json.dumps(row, sort_keys=True, ensure_ascii=False, allow_nan=False) + "\n").encode())
class HashReader:
def __init__(self, handle):
self.handle, self.hash, self.size = handle, hashlib.sha256(), 0
def read(self, count=-1):
data = self.handle.read(count)
self.hash.update(data)
self.size += len(data)
return data
def zstd_command(decompress=False, threads=2):
return ["zstd", "-q", "-d", "-c"] if decompress else ["zstd", "-q", "-3", "-T" + str(threads), "-c"]
def make_archive(root, files, destination, threads):
rows = []
with destination.open("xb") as output, tempfile.TemporaryFile() as errors:
proc = subprocess.Popen(zstd_command(threads=threads), stdin=subprocess.PIPE,
stdout=output, stderr=errors)
try:
with tarfile.open(fileobj=proc.stdin, mode="w|", format=tarfile.PAX_FORMAT) as archive:
for relative, original in files:
p = no_links(root / relative)
if identity(p.lstat()) != original:
raise ValueError("Source changed before archive: " + relative)
with p.open("rb") as source:
opened = opened_identity(source, original)
reader = HashReader(source)
info = tarfile.TarInfo(relative)
info.size, info.mode, info.mtime = original[3], stat.S_IMODE(original[2]) & 0o777, 0
archive.addfile(info, reader)
if (reader.size != original[3] or identity(os.fstat(source.fileno())) != opened
or identity(p.lstat()) != original):
raise ValueError("Source changed during archive: " + relative)
rows.append({"path": relative, "bytes": reader.size, "sha256": reader.hash.hexdigest()})
proc.stdin.close()
if proc.wait() != 0:
raise RuntimeError("zstd compression failed; no complete catalog published")
except BaseException:
proc.kill()
proc.wait()
raise
finally:
proc.stdin.close()
return rows
def split_archive(archive, group_dir):
chunks, full = [], hashlib.sha256()
with archive.open("rb") as source:
index = 0
while first := source.read(min(BUFFER, CHUNK_BYTES)):
name = "payload-%06d.bin" % index
h, size = hashlib.sha256(), 0
with (group_dir / name).open("xb") as out:
block = first
while block:
out.write(block)
full.update(block)
h.update(block)
size += len(block)
if size == CHUNK_BYTES:
break
block = source.read(min(BUFFER, CHUNK_BYTES - size))
chunks.append({"path": name, "bytes": size, "sha256": h.hexdigest(), "index": index})
index += 1
if not chunks:
raise ValueError("Empty compressed archive")
return chunks, full.hexdigest()
def pack(plan_path, out, work, *, threads=2):
if type(threads) is not int or not 1 <= threads <= 64:
raise ValueError("threads must be 1..64")
plan_bytes = Path(plan_path).read_bytes()
plan = json_bytes(plan_bytes)
validate_plan(plan)
root, out, work = map(no_links, (Path(plan["source_root"]), Path(out), Path(work)))
for a, b in ((root, out), (root, work), (out, work)):
if a == b or a in b.parents or b in a.parents:
raise ValueError("source, out and work must be disjoint trees")
if out.exists() or work.exists():
raise FileExistsError("out/work must both be new exclusive paths")
out.mkdir(parents=True, exist_ok=False)
work.mkdir(parents=True, exist_ok=False)
write_new(out / "plan.json", plan_bytes)
plan_sha = hashlib.sha256(plan_bytes).hexdigest()
results = []
for group in plan["groups"]:
files, exclusions = enumerate_group(root, group, plan.get("exclude_paths", []))
scan_secrets(root, files)
group_dir = out / group["id"]
group_dir.mkdir()
archive = work / (group["id"] + ".tar.zst")
members = make_archive(root, files, archive, threads)
# A second metadata enumeration catches file additions/removals during pack.
after_files, after_exclusions = enumerate_group(root, group, plan.get("exclude_paths", []))
if after_files != files or after_exclusions != exclusions:
raise ValueError("Source inventory changed during pack")
gzip_rows(group_dir / "members.jsonl.gz", members)
gzip_rows(group_dir / "exclusions.jsonl.gz", exclusions)
chunks, combined = split_archive(archive, group_dir)
catalog = dict(schema="lfs-publication-group-v1", group_id=group["id"], source_root=str(root),
source_paths=group["paths"], source_files=len(members),
source_bytes=sum(x["bytes"] for x in members), license=group["license"],
provenance=group["provenance"], plan_sha256=plan_sha,
archive_format="tar.pax+zstd", archive_sha256=combined,
archive_bytes=sum(x["bytes"] for x in chunks), chunk_bytes=CHUNK_BYTES,
chunks=chunks, members=sha_file(group_dir / "members.jsonl.gz"),
exclusions=sha_file(group_dir / "exclusions.jsonl.gz"),
exclusion_records=len(exclusions),
verification_scope="content snapshot, not a license grant or nested-archive secret audit",
secret_scan="UTF-8 files <=2 MiB, known binary suffixes excluded; fail closed on matching rules")
write_new(group_dir / "catalog.json", canonical(catalog))
result = {"group": group["id"], "files": len(members), "source_bytes": catalog["source_bytes"],
"archive_bytes": catalog["archive_bytes"], "chunks": len(chunks),
"catalog_sha256": sha_file(group_dir / "catalog.json")["sha256"]}
results.append(result)
print(json.dumps({"group_complete": result}), flush=True)
summary = {"schema": "lfs-publication-pack-v1", "complete": True, "plan_sha256": plan_sha,
"groups": results, "source_files": sum(x["files"] for x in results),
"source_bytes": sum(x["source_bytes"] for x in results)}
write_new(out / "pack-summary.json", canonical(summary))
return summary
def check_digest_row(row):
if not isinstance(row, dict) or type(row.get("bytes")) is not int or row["bytes"] < 0 or not HEX.fullmatch(str(row.get("sha256", ""))):
raise ValueError("Invalid SHA/size metadata")
def iter_members(path):
previous = None
with gzip.open(path, "rt", encoding="utf-8") as inp:
for line in inp:
row = json.loads(line)
if set(row) != {"path", "bytes", "sha256"}:
raise ValueError("Invalid member row")
check_digest_row(row)
p = relpath(row["path"])
if forbidden(p) or (previous is not None and p <= previous):
raise ValueError("Forbidden/duplicate/unsorted member: " + p)
previous = p
yield row
def verify(group_dir):
group_dir = no_links(group_dir)
controls_before = {name: sha_file(group_dir / name) for name in
("catalog.json", "members.jsonl.gz", "exclusions.jsonl.gz")}
catalog = json_read(group_dir / "catalog.json")
if catalog.get("schema") != "lfs-publication-group-v1" or catalog.get("chunk_bytes") != CHUNK_BYTES or catalog.get("archive_format") != "tar.pax+zstd":
raise ValueError("Wrong catalog schema/format/chunk size")
chunks = catalog.get("chunks")
if not isinstance(chunks, list) or not chunks:
raise ValueError("Missing payload chunks")
expected = {"catalog.json", "members.jsonl.gz", "exclusions.jsonl.gz"}
for i, row in enumerate(chunks):
check_digest_row(row)
if set(row) != {"path", "bytes", "sha256", "index"} or type(row["index"]) is not int or row["index"] != i or row["path"] != "payload-%06d.bin" % i:
raise ValueError("Wrong chunk name/order")
if not 0 < row["bytes"] <= CHUNK_BYTES or (i < len(chunks) - 1 and row["bytes"] != CHUNK_BYTES):
raise ValueError("Wrong chunk size")
expected.add(row["path"])
if {p.name for p in group_dir.iterdir()} != expected:
raise ValueError("Extra/missing downloaded files")
for p in group_dir.iterdir():
no_links(p)
if not p.is_file():
raise ValueError("Nonfile downloaded entry")
for name, key in (("members.jsonl.gz", "members"), ("exclusions.jsonl.gz", "exclusions")):
check_digest_row(catalog[key])
if controls_before[name] != catalog[key]:
raise ValueError("Metadata digest mismatch: " + name)
with gzip.open(group_dir / "exclusions.jsonl.gz", "rt", encoding="utf-8") as source:
omitted = list(map(json.loads, source))
if len(omitted) != catalog.get("exclusion_records"):
raise ValueError("Exclusion inventory mismatch")
for row in omitted:
relpath(row["path"])
errors, combined, compressed = [], hashlib.sha256(), [0]
with tempfile.TemporaryFile() as diagnostics:
proc = subprocess.Popen(zstd_command(decompress=True), stdin=subprocess.PIPE,
stdout=subprocess.PIPE, stderr=diagnostics)
def feed():
try:
for row in chunks:
p = group_dir / row["path"]
before = identity(p.lstat())
h, size = hashlib.sha256(), 0
with p.open("rb") as source:
opened = opened_identity(source, before)
while data := source.read(BUFFER):
h.update(data)
combined.update(data)
size += len(data)
compressed[0] += len(data)
proc.stdin.write(data)
if identity(os.fstat(source.fileno())) != opened or identity(p.lstat()) != before:
raise ValueError("Chunk changed during read")
if size != row["bytes"] or h.hexdigest() != row["sha256"]:
raise ValueError("Payload digest mismatch: " + row["path"])
except BaseException as exc:
errors.append(exc)
finally:
try:
proc.stdin.close()
except OSError:
pass
producer = threading.Thread(target=feed, daemon=True)
producer.start()
count, total = 0, 0
expected_rows = iter(iter_members(group_dir / "members.jsonl.gz"))
try:
with tarfile.open(fileobj=proc.stdout, mode="r|") as archive:
for member in archive:
row = next(expected_rows, None)
if not member.isreg() or member.linkname or member.sparse is not None:
raise ValueError("Nonregular/sparse/link tar member refused")
path = relpath(member.name)
if forbidden(path) or row is None or path != row["path"] or member.size != row["bytes"]:
raise ValueError("Tar member set/size/path mismatch: " + path)
reader = HashReader(archive.extractfile(member))
while reader.read(BUFFER):
pass
if reader.size != row["bytes"] or reader.hash.hexdigest() != row["sha256"]:
raise ValueError("Tar member content mismatch: " + path)
count += 1
total += reader.size
# tarfile stops at the first end marker; reject a hidden second
# archive/nonzero trailer, including bytes in its read-ahead buf.
while trailer := archive.fileobj.read(BUFFER):
if any(trailer):
raise ValueError("Nonzero data after tar end marker")
if next(expected_rows, None) is not None:
raise ValueError("Missing tar members")
# Drain to validate zstd's complete stream/checksum and finish the feeder.
while proc.stdout.read(BUFFER):
pass
producer.join()
code = proc.wait()
if errors:
raise ValueError("Payload streaming failed: " + str(errors[0]))
if code != 0:
raise ValueError("zstd decompression failed")
except BaseException:
proc.kill()
proc.wait()
producer.join(timeout=10)
raise
finally:
proc.stdout.close()
expected_rows.close()
if (count != catalog.get("source_files") or total != catalog.get("source_bytes")
or compressed[0] != catalog.get("archive_bytes") or combined.hexdigest() != catalog.get("archive_sha256")):
raise ValueError("Final inventory/archive digest mismatch")
for name, before in controls_before.items():
if sha_file(group_dir / name) != before:
raise ValueError("Downloaded control changed during verification: " + name)
return {"schema": "lfs-publication-verify-v1", "pass_all": True, "group": catalog["group_id"],
"source_files": count, "source_bytes": total, "archive_bytes": compressed[0],
"archive_sha256": combined.hexdigest(), "catalog_sha256": controls_before["catalog.json"]["sha256"],
"scope": "downloaded payload bytes and streamed members; no extraction, no LFS-cache trust; catalog authenticity is caller responsibility"}
def main():
parser = argparse.ArgumentParser(description=__doc__)
sub = parser.add_subparsers(dest="command", required=True)
command = sub.add_parser("pack")
command.add_argument("--plan", required=True)
command.add_argument("--out", required=True)
command.add_argument("--work", required=True)
command.add_argument("--threads", type=int, default=2)
command = sub.add_parser("verify")
command.add_argument("--group-dir", required=True)
args = parser.parse_args()
result = (pack(args.plan, args.out, args.work, threads=args.threads) if args.command == "pack"
else verify(args.group_dir))
print(json.dumps(result, sort_keys=True, ensure_ascii=False, allow_nan=False), flush=True)
if __name__ == "__main__":
main()
@@ -0,0 +1,245 @@
"""Temporary local fixtures only. Identity codec tests do not certify zstd.
The optional real-zstd test automatically runs when the executable is present.
"""
import contextlib
import gzip
import hashlib
import importlib.util
import io
import json
import os
from pathlib import Path
import shutil
import sys
import tarfile
import tempfile
import unittest
from unittest.mock import patch
SPEC = importlib.util.spec_from_file_location("lfs_archive_tested", Path(__file__).with_name("publish_archives.py"))
M = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(M)
def codec(*args, **kwargs):
return [sys.executable, "-c", "import sys,shutil;shutil.copyfileobj(sys.stdin.buffer,sys.stdout.buffer)"]
class ArchiveTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.base = Path(self.temp.name)
self.root = self.base / "source"
(self.root / "data").mkdir(parents=True)
(self.root / "data" / "a.txt").write_text("public source\n", encoding="utf-8")
(self.root / "data" / "b.bin").write_bytes(bytes(range(256)) * 80)
(self.root / "data" / "zero.txt").write_bytes(b"")
self.plan = {"schema": "lfs-publication-plan-v1", "source_root": str(self.root),
"groups": [{"id": "data", "paths": ["data"], "license": "test-only",
"provenance": {"source": "synthetic"}}], "exclude_paths": []}
self.plan_path = self.base / "plan.json"
self.plan_path.write_bytes(M.canonical(self.plan))
self.out, self.work = self.base / "out", self.base / "work"
self.addCleanup(patch.stopall)
patch.object(M, "SOURCE_ROOT", str(self.root)).start()
patch.object(M, "CHUNK_BYTES", 4096).start()
self.codec_patch = patch.object(M, "zstd_command", codec)
self.codec_patch.start()
def pack(self):
with contextlib.redirect_stdout(io.StringIO()):
return M.pack(self.plan_path, self.out, self.work)
def catalog(self):
return json.loads((self.out / "data" / "catalog.json").read_bytes())
def save_catalog(self, cat):
(self.out / "data" / "catalog.json").write_bytes(M.canonical(cat))
def rewrite_archive(self, data):
group = self.out / "data"
cat = self.catalog()
for row in cat["chunks"]:
(group / row["path"]).unlink()
new = self.base / "mutant.tar"
new.write_bytes(data)
cat["chunks"], cat["archive_sha256"] = M.split_archive(new, group)
cat["archive_bytes"] = len(data)
self.save_catalog(cat)
def test_pack_verify_full_member_content_and_no_extraction(self):
before = {p.relative_to(self.root).as_posix(): p.read_bytes() for p in self.root.rglob("*") if p.is_file()}
summary = self.pack()
result = M.verify(self.out / "data")
self.assertTrue(result["pass_all"])
self.assertEqual(result["source_files"], 3)
self.assertEqual(result["source_bytes"], sum(map(len, before.values())))
self.assertGreater(summary["groups"][0]["chunks"], 1)
self.assertEqual(before, {p.relative_to(self.root).as_posix(): p.read_bytes() for p in self.root.rglob("*") if p.is_file()})
self.assertFalse((self.out / "data" / "a.txt").exists())
def test_plan_executes_first_read_bytes_only(self):
with patch.object(M, "json_read", side_effect=AssertionError("Must not read plan a second time")):
self.pack()
self.assertEqual(self.catalog()["plan_sha256"], hashlib.sha256(self.plan_path.read_bytes()).hexdigest())
def test_exclusions_logged_without_descending(self):
for name in (".git", "venv", "__pycache__", ".cache", "node_modules", "logs", "phase4ar-normal-conditioning"):
d = self.root / "data" / name
d.mkdir()
(d / "hidden.txt").write_text("skip")
(self.root / "data" / "private.pem").write_text("private")
(self.root / "data" / "run.log").write_text("log")
self.pack()
with gzip.open(self.out / "data" / "exclusions.jsonl.gz", "rt") as f:
rows = [json.loads(x) for x in f]
self.assertEqual(len(rows), 9)
self.assertEqual(self.catalog()["source_files"], 3)
self.assertTrue(M.verify(self.out / "data")["pass_all"])
def test_unsafe_explicit_paths(self):
for name in ("../data", "/data", "data/../data", "data//x", "data\\x", "data/.git", "data/phase4ar-normal-conditioning"):
plan = json.loads(json.dumps(self.plan))
plan["groups"][0]["paths"] = [name]
with self.subTest(name=name), self.assertRaises(ValueError):
M.validate_plan(plan)
def test_overlapping_groups_refused(self):
self.plan["groups"].append({"id": "nested", "paths": ["data/a.txt"], "license": "test", "provenance": "test"})
with self.assertRaisesRegex(ValueError, "Overlapping"):
M.validate_plan(self.plan)
def test_symlink_refused(self):
try:
(self.root / "data" / "link").symlink_to(self.root / "data" / "a.txt")
except OSError:
self.skipTest("OS does not permit temporary symlinks")
with self.assertRaisesRegex(ValueError, "Symlink"):
self.pack()
def test_exclusive_and_disjoint_outputs(self):
self.out.mkdir()
with self.assertRaises(FileExistsError):
self.pack()
with self.assertRaisesRegex(ValueError, "disjoint"):
M.pack(self.plan_path, self.root / "output", self.work)
def test_parent_components_cannot_bypass_disjoint_gate(self):
malicious = self.base / "unused" / ".." / "source" / "new-output"
with self.assertRaisesRegex(ValueError, "Parent path components"):
M.pack(self.plan_path, malicious, self.work)
self.assertFalse((self.root / "new-output").exists())
def test_secret_rules_hide_values(self):
# Build synthetic fixtures at runtime; keep actual credential-like
# strings out of Git blobs so the repository secret guard stays useful.
examples = ["-----BEGIN " + "OPENSSH PRIVATE " + "KEY-----", "ghp_" + "a" * 36,
"github_pat_" + "b" * 40, "AKIA" + "A" * 16,
"https://" + "account:" + "privatevalue" + "@example.org/",
'password' + ' = "privatevalue"',
'api_key' + ': "privatevalue"', 'secret_key' + '=ABCDEF123456',
'pa\u017f\u017fword' + '="privatevalue"',
'ap\u0131_key' + '="privatevalue"',
'\u017fecret_key' + '="privatevalue"']
path = self.root / "data" / "literal.txt"
for value in examples:
path.write_text(value)
files, _ = M.enumerate_group(self.root, self.plan["groups"][0], [])
with self.subTest(rule=value[:8]), self.assertRaisesRegex(ValueError, "Secret scan refused") as caught:
M.scan_secrets(self.root, files)
self.assertNotIn(value, str(caught.exception))
self.assertNotIn("privatevalue", str(caught.exception))
def test_mutated_source_metadata_refused(self):
files, _ = M.enumerate_group(self.root, self.plan["groups"][0], [])
(self.root / "data" / "a.txt").write_text("changed bytes")
with self.assertRaisesRegex(ValueError, "Source changed"):
M.make_archive(self.root, files, self.base / "bad.tar", 1)
def test_payload_corruption_refused(self):
self.pack()
p = self.out / "data" / self.catalog()["chunks"][0]["path"]
data = bytearray(p.read_bytes())
data[-1] ^= 1
p.write_bytes(data)
with self.assertRaises((ValueError, tarfile.TarError)):
M.verify(self.out / "data")
def test_missing_extra_or_reordered_payload_refused(self):
self.pack()
extra = self.out / "data" / "extra.bin"
extra.write_bytes(b"x")
with self.assertRaisesRegex(ValueError, "Extra/missing"):
M.verify(self.out / "data")
extra.unlink()
cat = self.catalog()
cat["chunks"] = list(reversed(cat["chunks"]))
self.save_catalog(cat)
with self.assertRaisesRegex(ValueError, "chunk name/order"):
M.verify(self.out / "data")
def test_tar_traversal_rejected_even_with_rehashed_chunks(self):
self.pack()
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w") as archive:
info = tarfile.TarInfo("../escape")
info.size = 1
archive.addfile(info, io.BytesIO(b"x"))
self.rewrite_archive(buf.getvalue())
with self.assertRaisesRegex(ValueError, "Unsafe relative path"):
M.verify(self.out / "data")
def test_tar_link_rejected_even_with_rehashed_chunks(self):
self.pack()
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w") as archive:
info = tarfile.TarInfo("data/a.txt")
info.type, info.linkname = tarfile.SYMTYPE, "/tmp/escape"
archive.addfile(info)
self.rewrite_archive(buf.getvalue())
with self.assertRaisesRegex(ValueError, "Nonregular"):
M.verify(self.out / "data")
def test_hidden_tar_after_end_refused(self):
self.pack()
raw = (self.work / "data.tar.zst").read_bytes()
self.rewrite_archive(raw + b"nonzero hidden second archive")
with self.assertRaisesRegex(ValueError, "Nonzero data after tar"):
M.verify(self.out / "data")
def test_second_tar_after_end_refused(self):
self.pack()
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w") as archive:
info = tarfile.TarInfo("unlisted-secret.txt")
info.size = 1
archive.addfile(info, io.BytesIO(b"x"))
self.rewrite_archive((self.work / "data.tar.zst").read_bytes() + buf.getvalue())
with self.assertRaisesRegex(ValueError, "Nonzero data after tar"):
M.verify(self.out / "data")
def test_member_hash_mutation_refused(self):
self.pack()
p = self.out / "data" / "members.jsonl.gz"
with gzip.open(p, "rt") as f:
rows = [json.loads(x) for x in f]
rows[0]["sha256"] = "0" * 64
p.unlink()
M.gzip_rows(p, rows)
cat = self.catalog()
cat["members"] = M.sha_file(p)
self.save_catalog(cat)
with self.assertRaisesRegex(ValueError, "member content mismatch"):
M.verify(self.out / "data")
@unittest.skipUnless(shutil.which("zstd"), "Real zstd unavailable; run this integration test on Linux")
def test_real_zstd_roundtrip(self):
self.codec_patch.stop()
self.pack()
self.assertTrue(M.verify(self.out / "data")["pass_all"])
if __name__ == "__main__":
unittest.main()