224 lines
11 KiB
Python
224 lines
11 KiB
Python
"""Archive f659 source evidence, or verify it; never stage, apply, build, or commit.
|
|
|
|
From repository root:
|
|
python .local/archive-lifecycle-union-f6595eb.py create
|
|
python .local/archive-lifecycle-union-f6595eb.py verify
|
|
# After the integration owner stages the archive:
|
|
python .local/archive-lifecycle-union-f6595eb.py verify-index
|
|
|
|
The archived tools/ copy supports the same commands from repository root.
|
|
Creation refuses overwrite. A failed partial archive is retained for diagnosis.
|
|
"""
|
|
from pathlib import Path, PurePosixPath
|
|
from datetime import datetime, timezone
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import subprocess
|
|
|
|
BASE = 'd61f1f38779ab70a77efcde523ae31c432aefcfd'
|
|
SOURCE = 'f6595ebf66e88ec42c37b5c1a3c014f5e52cb32d'
|
|
PREFIX = 'harness/experiments/nested-lifecycle-union-20260912'
|
|
SCRIPT_NAME = 'archive-lifecycle-union-f6595eb.py'
|
|
PATCH_PATH = 'patches/d61f1f3-to-f6595eb.patch'
|
|
FILES = {
|
|
'crates/oa-desktop/src/behavior.rs': ('0a0c4df479ff1c46f935735e508144ca2ec3052f', 133741),
|
|
'crates/oa-desktop/src/behavior/scenario_activation.rs': ('88fa4d6e861fc96f4dc5497088cd9119cf25e151', 17694),
|
|
'crates/oa-desktop/src/behavior/scenario_lifecycle.rs': ('6fe044890864ecf3095d0fedf590104f6121abf7', 9348),
|
|
'crates/oa-desktop/src/behavior/scenario_lifecycle/render.rs': ('989fb2d5b173fc68e50705f8d406f7786ba9c979', 14820),
|
|
'crates/oa-desktop/src/behavior/scenario_lifecycle/render_tests.rs': ('e0d9ee812a49fb1f8b24b0e9e460e08cd42aa34f', 35358),
|
|
'crates/oa-desktop/src/behavior/scenario_lifecycle/union_tests.rs': ('86fa0cceff11ff66497c3514e43659a0ca003a52', 18024),
|
|
}
|
|
NOTES = {
|
|
'source-handoff.md': ('.local/nested-lifecycle-union-handoff-20260912.md', 8668,
|
|
'2c28f4ffca8e84626cf4bf7580504a008fe971dda7eb0e9b83d94695bcb42410'),
|
|
'independent-review.md': ('.local/nested-lifecycle-union-independent-review-20260912.md', 9736,
|
|
'3b5b116ca7a15f2b5bac313fab9a91966e2fa64f75cc7fc4509332208dd8f29c'),
|
|
}
|
|
README = '''# Nested lifecycle / typed activation source union
|
|
|
|
Source: `f6595ebf66e88ec42c37b5c1a3c014f5e52cb32d`.
|
|
Base: `d61f1f38779ab70a77efcde523ae31c432aefcfd`.
|
|
|
|
- [Source handoff](source-handoff.md)
|
|
- [Independent source review](independent-review.md)
|
|
- [Exact committed source snapshot](source/crates/oa-desktop/src/behavior.rs)
|
|
- [Full-index binary Git patch](patches/d61f1f3-to-f6595eb.patch)
|
|
- [Payload manifest](manifest.json)
|
|
- [Archive/verification helper](tools/archive-lifecycle-union-f6595eb.py)
|
|
|
|
The six source files are extracted from committed Git blobs; they are evidence,
|
|
not an additional production tree. Notes retain their original bytes, including
|
|
historical local paths and source-scoped statements. The manifest lists portable
|
|
archive-relative paths plus original source provenance. It does not claim tests,
|
|
native acceptance, import, or publication succeeded. Subsequent gates remain
|
|
separate source-identified records owned by the integration owner.
|
|
|
|
From repository root, run the helper with `verify` to compare payload bytes,
|
|
manifest metadata, source blobs and regenerated patch. After owner-controlled
|
|
staging, run `verify-index` to additionally compare every payload and manifest
|
|
with Git index blobs. Neither mode stages or rewrites files. If Git attributes
|
|
normalize archived bytes, index verification fails; the owner must resolve that
|
|
explicitly. No patch is applied by this helper.
|
|
'''.encode('utf-8')
|
|
|
|
|
|
def require(condition, message):
|
|
if not condition:
|
|
raise RuntimeError(message)
|
|
|
|
|
|
def git(root, *args, data=None):
|
|
return subprocess.run(['git', '--no-pager', *args], cwd=root, input=data,
|
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
check=True).stdout
|
|
|
|
|
|
def digest(raw):
|
|
return {'bytes': len(raw), 'sha256': hashlib.sha256(raw).hexdigest()}
|
|
|
|
|
|
def safe_path(base, relative):
|
|
parts = PurePosixPath(relative)
|
|
require(not parts.is_absolute() and '..' not in parts.parts and '\\' not in relative,
|
|
f'Unsafe relative path: {relative}')
|
|
path = base.joinpath(*parts.parts)
|
|
require(path.resolve().is_relative_to(base.resolve()), f'Path escapes archive: {relative}')
|
|
for ancestor in [path, *path.parents]:
|
|
require(not ancestor.is_symlink(), f'Symlinks are not archive evidence: {ancestor}')
|
|
if ancestor == base:
|
|
break
|
|
return path
|
|
|
|
|
|
def committed_payloads(root):
|
|
require(git(root, 'cat-file', '-t', SOURCE).strip() == b'commit', 'Source is not a commit')
|
|
require(git(root, 'cat-file', '-t', BASE).strip() == b'commit', 'Base is not a commit')
|
|
require(git(root, 'rev-parse', SOURCE + '^').decode().strip() == BASE,
|
|
'Source parent differs from reviewed base')
|
|
changed = git(root, 'diff', '--no-ext-diff', '--no-textconv', '--name-only', '-z',
|
|
BASE, SOURCE, '--').decode().rstrip('\0').split('\0')
|
|
require(set(changed) == set(FILES) and len(changed) == len(FILES),
|
|
'Commit delta differs from the reviewed six files')
|
|
payloads = {}
|
|
for path, (oid, size) in FILES.items():
|
|
actual = git(root, 'rev-parse', f'{SOURCE}:{path}').decode().strip()
|
|
require(actual == oid, f'Unexpected committed blob: {path}')
|
|
require(git(root, 'cat-file', '-t', oid).strip() == b'blob', path)
|
|
require(int(git(root, 'cat-file', '-s', oid)) == size, path)
|
|
raw = git(root, 'cat-file', 'blob', oid)
|
|
require(len(raw) == size, f'Incomplete cat-file data: {path}')
|
|
require(git(root, 'hash-object', '--stdin', data=raw).decode().strip() == oid,
|
|
f'Git object identity mismatch: {path}')
|
|
payloads['source/' + path] = (raw, {
|
|
'kind': 'committed-source', 'source_commit': SOURCE,
|
|
'source_path': path, 'git_blob': oid,
|
|
})
|
|
# All changed paths are enforced above; full repository diff retains binary,
|
|
# executable-mode, rename and new-file metadata. No working-tree diff is read.
|
|
patch = git(root, 'diff', '--binary', '--full-index', '--no-renames',
|
|
'--diff-algorithm=myers', '--no-indent-heuristic', '--unified=3',
|
|
'--inter-hunk-context=0', '--no-ext-diff', '--no-textconv',
|
|
'--no-color', '--src-prefix=a/', '--dst-prefix=b/', BASE, SOURCE, '--')
|
|
require(patch.startswith(b'diff --git '), 'Missing binary Git patch')
|
|
payloads[PATCH_PATH] = (patch, {'kind': 'git-binary-patch',
|
|
'base_commit': BASE, 'source_commit': SOURCE})
|
|
return payloads
|
|
|
|
|
|
def expected_paths():
|
|
return {'source/' + p for p in FILES} | set(NOTES) | {
|
|
PATCH_PATH, 'README.md', 'tools/' + SCRIPT_NAME,
|
|
}
|
|
|
|
|
|
def verify(root, destination, index=False):
|
|
manifest_raw = safe_path(destination, 'manifest.json').read_bytes()
|
|
manifest = json.loads(manifest_raw)
|
|
require(manifest['schema'] == 1 and manifest['source_commit'] == SOURCE
|
|
and manifest['base_commit'] == BASE and manifest['archive_path'] == PREFIX,
|
|
'Manifest provenance mismatch')
|
|
rows = manifest['files']
|
|
require({r['path'] for r in rows} == expected_paths()
|
|
and len(rows) == len(expected_paths()), 'Manifest payload list mismatch')
|
|
actual = {p.relative_to(destination).as_posix() for p in destination.rglob('*')
|
|
if p.is_file() or p.is_symlink()}
|
|
require(actual == expected_paths() | {'manifest.json'}, 'Unexpected/missing archive file')
|
|
committed = committed_payloads(root)
|
|
for row in rows:
|
|
path = safe_path(destination, row['path'])
|
|
raw = path.read_bytes()
|
|
require(digest(raw) == {k: row[k] for k in ('bytes', 'sha256')},
|
|
f'Payload digest mismatch: {path}')
|
|
if row['path'] in committed:
|
|
expected, metadata = committed[row['path']]
|
|
require(raw == expected, f'Committed bytes/patch mismatch: {path}')
|
|
require(all(row.get(k) == v for k, v in metadata.items()), f'Provenance mismatch: {path}')
|
|
elif row['path'] in NOTES:
|
|
origin, size, sha = NOTES[row['path']]
|
|
require(digest(raw) == {'bytes': size, 'sha256': sha}
|
|
and row['source_path'] == origin, f'Approved note changed: {path}')
|
|
elif row['path'] == 'README.md':
|
|
require(raw == README, 'Archive navigation changed')
|
|
if index:
|
|
indexed = git(root, 'cat-file', 'blob', ':' + PREFIX + '/' + row['path'])
|
|
require(indexed == raw, f'Git index bytes differ: {path}')
|
|
if index:
|
|
require(git(root, 'cat-file', 'blob', ':' + PREFIX + '/manifest.json') == manifest_raw,
|
|
'Git index manifest differs')
|
|
print(f'PASS {len(rows)} payloads: manifest, workspace, committed source and patch'
|
|
+ ('; payloads and manifest also match Git index' if index else ''))
|
|
|
|
|
|
def create(root, destination):
|
|
require(not destination.exists() and not destination.is_symlink(),
|
|
f'Refusing to overwrite existing archive: {destination}')
|
|
payloads = committed_payloads(root)
|
|
# Preflight every approved local input and gather all bytes before creating anything.
|
|
for target, (origin, size, sha) in NOTES.items():
|
|
raw = safe_path(root, origin).read_bytes()
|
|
require(digest(raw) == {'bytes': size, 'sha256': sha}, f'Approved note changed: {origin}')
|
|
payloads[target] = (raw, {'kind': 'source-review-record', 'source_path': origin,
|
|
'reviewed_source_commit': SOURCE})
|
|
payloads['README.md'] = (README, {'kind': 'archive-navigation'})
|
|
payloads['tools/' + SCRIPT_NAME] = (Path(__file__).read_bytes(), {
|
|
'kind': 'archive-helper', 'source_path': '.local/' + SCRIPT_NAME,
|
|
})
|
|
require(set(payloads) == expected_paths(), 'Internal payload list mismatch')
|
|
manifest = {
|
|
'schema': 1, 'archive_path': PREFIX, 'base_commit': BASE, 'source_commit': SOURCE,
|
|
'created_utc': datetime.now(timezone.utc).isoformat(),
|
|
'evidence_scope': 'Committed source and source-only review; no combined test/native pass asserted',
|
|
'files': [{'path': path, **metadata, **digest(raw)}
|
|
for path, (raw, metadata) in sorted(payloads.items())],
|
|
}
|
|
destination.mkdir(parents=False, exist_ok=False)
|
|
for relative, (raw, _) in sorted(payloads.items()):
|
|
target = safe_path(destination, relative)
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
with target.open('xb') as stream:
|
|
stream.write(raw)
|
|
with (destination / 'manifest.json').open('xb') as stream:
|
|
stream.write((json.dumps(manifest, indent=2) + '\n').encode('utf-8'))
|
|
verify(root, destination)
|
|
print(f'Created {PREFIX}; not staged, applied, tested, integrated or published')
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument('mode', choices=('create', 'verify', 'verify-index'))
|
|
parser.add_argument('--repo', type=Path, default=Path.cwd())
|
|
args = parser.parse_args()
|
|
root = Path(git(args.repo.resolve(), 'rev-parse', '--show-toplevel').decode().strip()).resolve()
|
|
destination = safe_path(root, PREFIX)
|
|
require(destination.parent.is_dir(), 'Existing harness/experiments directory required')
|
|
if args.mode == 'create':
|
|
create(root, destination)
|
|
else:
|
|
require(destination.is_dir(), 'Archive does not exist')
|
|
verify(root, destination, index=args.mode == 'verify-index')
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|