Record independent package and native Windows navigation acceptance
This commit is contained in:
@@ -0,0 +1,266 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user