267 lines
12 KiB
Python
267 lines
12 KiB
Python
"""Packaged navigation trial; GUI input and release acceptance belong to root.
|
|
|
|
No execution is admitted until binding.json is completed and its SHA is pinned
|
|
after independent artifact review. Nothing here operates the GUI or compiles.
|
|
"""
|
|
import argparse
|
|
import ctypes
|
|
from ctypes import wintypes
|
|
from datetime import datetime, timezone
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from pathlib import Path, PurePosixPath
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import zipfile
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
BASE = Path(__file__).resolve().parent
|
|
TRIAL = BASE / 'trial'
|
|
BINDING_SHA = '82606e6b3a5fb5dada3e55adcd5cd76792c9f938e6907313539dae7fc5125657'
|
|
SOURCE = '33c6d83e9ba9a5b1310b1b627d7c40affcbcf15c'
|
|
NAME = 'ArcadiaStudio-0.2.0-preview.navigation.20260911-x86_64-pc-windows-gnullvm'
|
|
EXPECTED = {
|
|
'arcadia-studio.exe': 'b183ef31f16b3986538b3f2aba8435eab45978635c47f99ca01be6e72b168848',
|
|
'arcadia-server.exe': 'efeeb89c0d2e0f93df4472cb3e9f48b2b722a37ae1043c90a5a56f2e845fe665',
|
|
'libunwind.dll': 'f45604a12e8bcd839fd86f667c1be1cc7c4f9da2b3c69ff381ac708f0e1ae7d4',
|
|
}
|
|
FIXTURES = ROOT / '.local/text-navigation-native-20260911-preparation'
|
|
FIXTURE_SHA = 'edd4157fc31823f4494dee91862728b87f12ef51c49b8d1fd86a954bc9ecc1ed'
|
|
ORACLE = ROOT / '.local/staged-source-oracle-20260911/staged-source-authoring-contract.exe'
|
|
ORACLE_SHA = '967d9ba3c6fb3a7efb410833903ec94556ca2d48a67d5072c8bb160f40a00f9c'
|
|
|
|
|
|
def now():
|
|
return datetime.now(timezone.utc).isoformat()
|
|
|
|
|
|
def ordinary(path):
|
|
path = Path(path).absolute()
|
|
for p in (path, *path.parents):
|
|
if p.exists():
|
|
assert not p.is_symlink() and not (getattr(p.lstat(), 'st_file_attributes', 0) & 0x400), str(p)
|
|
return path
|
|
|
|
|
|
def sha(path):
|
|
with ordinary(path).open('rb') as stream:
|
|
return hashlib.file_digest(stream, 'sha256').hexdigest()
|
|
|
|
|
|
def load(path):
|
|
return json.loads(ordinary(path).read_text(encoding='utf-8-sig'))
|
|
|
|
|
|
def record(path, value):
|
|
ordinary(path)
|
|
with path.open('x', encoding='utf-8', newline='\n') as out:
|
|
json.dump(value, out, ensure_ascii=False, indent=2)
|
|
out.write('\n')
|
|
|
|
|
|
def relative(name):
|
|
p = PurePosixPath(name)
|
|
assert name == p.as_posix() and not p.is_absolute() and p.parts
|
|
assert all(x not in ('.', '..') for x in p.parts)
|
|
assert not any(c in name for c in '\\:\r\n')
|
|
return name
|
|
|
|
|
|
def local(name):
|
|
p = ordinary(ROOT / relative(name))
|
|
assert p.resolve().is_relative_to(ROOT / '.local')
|
|
return p
|
|
|
|
|
|
def inventory(folder):
|
|
ordinary(folder)
|
|
found = {}
|
|
for p in folder.rglob('*'):
|
|
ordinary(p)
|
|
if p.is_file():
|
|
found[p.relative_to(folder).as_posix()] = sha(p)
|
|
assert len({p.casefold() for p in found}) == len(found)
|
|
return found
|
|
|
|
|
|
def copy_exact(source, destination):
|
|
before = inventory(source)
|
|
assert not destination.exists()
|
|
shutil.copytree(source, destination)
|
|
assert inventory(source) == inventory(destination) == before
|
|
return before
|
|
|
|
|
|
def environment():
|
|
parent = {k.upper(): v for k, v in os.environ.items()}
|
|
standard = {k: parent[k.upper()] for k in ('SystemRoot', 'SystemDrive', 'ProgramData', 'ALLUSERSPROFILE', 'PUBLIC')}
|
|
assert all(v and '%' not in v for v in standard.values())
|
|
system = standard['SystemRoot']
|
|
return {**standard, 'WINDIR': system, 'PATH': system + '\\System32;' + system,
|
|
'USERPROFILE': str(TRIAL / 'profile'), 'APPDATA': str(TRIAL / 'profile/roaming'),
|
|
'LOCALAPPDATA': str(TRIAL / 'profile/local'), 'TEMP': str(TRIAL / 'tmp'), 'TMP': str(TRIAL / 'tmp')}
|
|
|
|
|
|
def process_identity(pid):
|
|
"""Read-only process query; invalid PID means absent, access errors refuse."""
|
|
api = ctypes.WinDLL('kernel32', use_last_error=True)
|
|
api.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
|
|
api.OpenProcess.restype = wintypes.HANDLE
|
|
api.CloseHandle.argtypes = [wintypes.HANDLE]
|
|
api.GetProcessTimes.argtypes = [wintypes.HANDLE] + [ctypes.POINTER(wintypes.FILETIME)] * 4
|
|
api.GetProcessTimes.restype = wintypes.BOOL
|
|
api.QueryFullProcessImageNameW.argtypes = [wintypes.HANDLE, wintypes.DWORD, wintypes.LPWSTR, ctypes.POINTER(wintypes.DWORD)]
|
|
api.QueryFullProcessImageNameW.restype = wintypes.BOOL
|
|
handle = api.OpenProcess(0x1000, False, pid)
|
|
if not handle:
|
|
error = ctypes.get_last_error()
|
|
assert error == 87, f'Process query failed: {error}'
|
|
return None
|
|
try:
|
|
created, exited, kernel, user = (wintypes.FILETIME() for _ in range(4))
|
|
assert api.GetProcessTimes(handle, ctypes.byref(created), ctypes.byref(exited), ctypes.byref(kernel), ctypes.byref(user))
|
|
buffer, count = ctypes.create_unicode_buffer(32768), wintypes.DWORD(32768)
|
|
assert api.QueryFullProcessImageNameW(handle, 0, buffer, ctypes.byref(count))
|
|
return {'path': str(Path(buffer.value).resolve()), 'creation_filetime': (created.dwHighDateTime << 32) | created.dwLowDateTime}
|
|
finally:
|
|
api.CloseHandle(handle)
|
|
|
|
|
|
def admission(verify_inventory=True):
|
|
assert BINDING_SHA and sha(BASE / 'binding.json') == BINDING_SHA, 'Package binding is pending or changed.'
|
|
binding = load(BASE / 'binding.json')
|
|
assert binding['status'] == 'READY' and binding['source_commit'] == SOURCE
|
|
archive, review_path, producer_path, package = (local(binding[k]) for k in ('archive_path', 'review_path', 'producer_readback_path', 'package_path'))
|
|
assert archive.name == NAME + '.zip' and package.name == NAME
|
|
assert package.resolve() != (producer_path.parent / 'packages' / NAME).resolve(), 'Use independent extraction, not producer stage.'
|
|
assert sha(archive) == binding['archive_sha256'] and archive.stat().st_size == binding['archive_bytes']
|
|
assert sha(review_path) == binding['review_sha256'] and sha(producer_path) == binding['producer_readback_sha256']
|
|
review, producer = load(review_path), load(producer_path)
|
|
assert review['status'] == producer['status'] == 'PASS'
|
|
assert review['archive']['sha256'] == producer['archive_sha256'] == binding['archive_sha256']
|
|
assert review['archive']['bytes'] == producer['archive_bytes'] == binding['archive_bytes']
|
|
assert Path(review['archive']['path']).resolve() == archive.resolve()
|
|
expected = review['archive']['files']
|
|
assert expected == {p: v['sha256'] for p, v in producer['files'].items()}
|
|
assert review['archive']['file_count'] == producer['file_count'] == len(expected)
|
|
if verify_inventory:
|
|
assert inventory(package) == expected
|
|
for p, h in EXPECTED.items():
|
|
assert expected[p] == h
|
|
zipped = {}
|
|
with zipfile.ZipFile(archive) as z:
|
|
for item in z.infolist():
|
|
assert item.filename.startswith(NAME + '/') and not item.is_dir()
|
|
p = relative(item.filename[len(NAME) + 1:])
|
|
assert p not in zipped
|
|
zipped[p] = hashlib.sha256(z.read(item)).hexdigest()
|
|
assert zipped == expected
|
|
return binding, package, expected
|
|
|
|
|
|
def fixture():
|
|
assert sha(FIXTURES / 'fixture-manifest.json') == FIXTURE_SHA
|
|
expected = {e['path'].removeprefix('valid-baseline/'): e['sha256'] for e in load(FIXTURES / 'fixture-manifest.json') if e['path'].startswith('valid-baseline/')}
|
|
assert len(expected) == 4 and inventory(FIXTURES / 'valid-baseline') == expected
|
|
assert sha(ORACLE) == ORACLE_SHA
|
|
return FIXTURES / 'valid-baseline', expected
|
|
|
|
|
|
def launch():
|
|
assert not TRIAL.exists(), 'Preserve old trial; never overwrite.'
|
|
binding, package, expected = admission()
|
|
baseline, files = fixture()
|
|
TRIAL.mkdir()
|
|
for folder in ('profile/local', 'profile/roaming', 'profile/Desktop', 'tmp', 'screenshots', 'checkpoints'):
|
|
(TRIAL / folder).mkdir(parents=True)
|
|
assert copy_exact(baseline, TRIAL / 'work') == files
|
|
record(TRIAL / 'package-before.json', expected)
|
|
record(TRIAL / 'source-before.json', files)
|
|
record(TRIAL / 'admission.json', {'atUtc': now(), 'binding_sha256': BINDING_SHA, 'binding': binding, 'fixture_sha256': FIXTURE_SHA, 'scope': 'Artifact/fixture admission, not native acceptance.'})
|
|
env = environment()
|
|
startup = subprocess.STARTUPINFO()
|
|
startup.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
|
startup.wShowWindow = 0
|
|
with (TRIAL / 'stdout.log').open('xb') as out, (TRIAL / 'stderr.log').open('xb') as err:
|
|
process = subprocess.Popen([str(package / 'arcadia-studio.exe')], cwd=package, env=env, stdout=out, stderr=err, startupinfo=startup)
|
|
# Preserve PID immediately even if a subsequent read-only identity query fails.
|
|
receipt = {'atUtc': now(), 'pid': process.pid, 'package': str(package), 'environment': env,
|
|
'binding_sha256': BINDING_SHA, 'source': str(TRIAL / 'work/project.arcadia'), 'scope': 'Identified packaged GUI launched; native acceptance pending.'}
|
|
record(TRIAL / 'launch.json', receipt)
|
|
identity = process_identity(process.pid)
|
|
assert identity and Path(identity['path']) == (package / 'arcadia-studio.exe').resolve()
|
|
record(TRIAL / 'process-identity.json', identity)
|
|
print(json.dumps(receipt, ensure_ascii=False, indent=2))
|
|
|
|
|
|
def package_guard(label):
|
|
binding, package, expected = admission(verify_inventory=False)
|
|
launch_receipt = load(TRIAL / 'launch.json')
|
|
assert launch_receipt['binding_sha256'] == BINDING_SHA and Path(launch_receipt['package']).resolve() == package.resolve()
|
|
assert load(TRIAL / 'package-before.json') == expected
|
|
actual = inventory(package)
|
|
unchanged = actual == expected
|
|
record(TRIAL / (label + '-package.json'), {'atUtc': now(), 'archive_sha256': binding['archive_sha256'], 'files': actual,
|
|
'added': sorted(actual.keys() - expected.keys()), 'removed': sorted(expected.keys() - actual.keys()),
|
|
'changed': sorted(p for p in actual.keys() & expected.keys() if actual[p] != expected[p]),
|
|
'completeInventoryUnchanged': unchanged})
|
|
assert unchanged, 'Package changed; exact delta receipt preserved.'
|
|
return package
|
|
|
|
|
|
def saved(args):
|
|
assert args.after_visible_save, 'Root must observe successful Save first.'
|
|
assert re.fullmatch('[a-z0-9][a-z0-9-]{0,79}', args.label)
|
|
folder = TRIAL / 'checkpoints' / args.label
|
|
assert not folder.exists()
|
|
package = package_guard(args.label)
|
|
baseline, expected = fixture()
|
|
folder.mkdir()
|
|
actual = copy_exact(TRIAL / 'work', folder / 'sources')
|
|
record(folder / 'sources.json', actual)
|
|
assert actual == expected, 'Saved exact baseline files differ; preserve and inspect.'
|
|
env = environment()
|
|
env['PATH'] = str(package) + ';' + env['PATH']
|
|
command = [str(ORACLE), 'same', str(baseline / 'project.arcadia'), str(folder / 'sources/project.arcadia')]
|
|
result = subprocess.run(command, cwd=folder, env=env, capture_output=True)
|
|
(folder / 'stdout.log').write_bytes(result.stdout)
|
|
(folder / 'stderr.log').write_bytes(result.stderr)
|
|
record(folder / 'result.json', {'atUtc': now(), 'command': command, 'exitCode': result.returncode, 'oracle_sha256': ORACLE_SHA,
|
|
'scope': 'Saved disk/model oracle after root-observed Save; no independent UI assertion.'})
|
|
assert result.returncode == 0
|
|
package_guard(args.label + '-after-oracle')
|
|
print(json.dumps({'action': 'saved', 'oracle_exit': 0, 'source_files': len(actual)}))
|
|
|
|
|
|
def closed(args):
|
|
assert args.observed_absent, 'Root must first observe the GUI/window absent.'
|
|
launch_receipt = load(TRIAL / 'launch.json')
|
|
current = process_identity(launch_receipt['pid'])
|
|
original = load(TRIAL / 'process-identity.json')
|
|
assert current is None or current['creation_filetime'] != original['creation_filetime'], 'Original GUI process remains present.'
|
|
package = package_guard('after-close')
|
|
record(TRIAL / 'closed.json', {'atUtc': now(), 'pid': launch_receipt['pid'], 'originalProcessAbsent': True,
|
|
'pidReused': current is not None, 'completePackageUnchanged': True,
|
|
'scope': 'Root-observed window absence plus read-only process identity and package check; no numeric exit code observed.'})
|
|
print(json.dumps({'action': 'closed', 'package': str(package), 'result': 'PASS'}))
|
|
|
|
|
|
if __name__ == '__main__':
|
|
sys.stdout.reconfigure(encoding='utf-8')
|
|
sys.stderr.reconfigure(encoding='utf-8')
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument('action', choices=('launch', 'saved', 'closed'))
|
|
parser.add_argument('--label')
|
|
parser.add_argument('--after-visible-save', action='store_true')
|
|
parser.add_argument('--observed-absent', action='store_true')
|
|
args = parser.parse_args()
|
|
if args.action == 'launch':
|
|
launch()
|
|
elif args.action == 'saved':
|
|
saved(args)
|
|
else:
|
|
closed(args)
|