From 61690ba3b2316f8372260fee618255d473c113e1 Mon Sep 17 00:00:00 2001 From: Spencer Albrecht Date: Mon, 26 Oct 2026 05:38:00 -0700 Subject: [PATCH 01/18] Prepare versioned Linux package ownership and rollback gate --- distribution/linux-debian-handoff.md | 111 ++++++++++++++++ scripts/build-linux-deb.py | 90 +++++++++++++ scripts/install-linux-bundle.py | 16 ++- scripts/linux-deb-hook.py | 169 ++++++++++++++++++++++++ scripts/qualify-linux-dpkg.py | 148 +++++++++++++++++++++ scripts/qualify-native-linux.py | 37 ++++-- scripts/test-linux-deb.py | 187 +++++++++++++++++++++++++++ 7 files changed, 746 insertions(+), 12 deletions(-) create mode 100644 distribution/linux-debian-handoff.md create mode 100644 scripts/build-linux-deb.py create mode 100644 scripts/linux-deb-hook.py create mode 100644 scripts/qualify-linux-dpkg.py create mode 100644 scripts/test-linux-deb.py diff --git a/distribution/linux-debian-handoff.md b/distribution/linux-debian-handoff.md new file mode 100644 index 0000000..e9584da --- /dev/null +++ b/distribution/linux-debian-handoff.md @@ -0,0 +1,111 @@ +# Linux Debian ownership and rollback candidate + +This source prepares one package builder and the actual maintainer hooks for a +future release. It is not published or customer-qualified. The optional native +gate below has not run. The current public binaries remain unchanged. + +The two cached public Linux packages are both `vmpower-agent` version `0.1`, with +different executable bytes. They own `/usr/bin/vmpower`, +`/etc/systemd/system/vmpower.service`, and `/etc/vmpower-template.conf`. Replacing +the first two files with the standalone bundle installer leaves their old dpkg +ownership intact. The hosted negative controls must demonstrate both old removal +and old reinstall replacing those modern entrypoints, for **both** exact packages: + +| Input | Bytes | SHA256 | +| --- | ---: | --- | +| dashboard-linux.deb | 10794560 | 06bf0d767e4625db0477f3e23e30b4e36ac6e7a7d50882b3c655c472f45875b1 | +| docs-linux.deb | 11090764 | e8c0028ea78322e5937becba766654f178e4dfbe1c9817af33e024d93b588311 | + +`scripts/build-linux-deb.py` uses a verified `linux-x64` bundle and an explicit +package version. The supported version series is `0.2.0`, optionally with a +prerelease suffix such as `0.2.0~rc1`; the hosted gate compares versions with real +dpkg. Application version `0.0.2` is deliberately not used as a Debian upgrade +over `0.1`. Choosing the final published version remains a release decision. + +The resulting amd64 package owns the launcher, service unit, packaged bundle and +installer helpers. The launcher/unit bytes come from the same rendering functions +used by the existing Linux installer. The packaged bundle is installed by that +qualified installer into retained, immutable `/opt/vmpower-agent/releases` paths. +These retained rollback releases, `/etc/vmpower.conf`, the dedicated service +account and root-only transaction snapshots are not removed by package removal +or purge. They remain explicitly managed state; this is an intentional retention +policy, not a claim that dpkg owns every retained file. + +The actual preinst accepts both pinned legacy binary families and fails closed +on an unknown legacy executable. It journals the recognized unit/launcher and service +state before dpkg unpacks replacement files. Existing JSON configuration bytes +are preserved. First installation retains the previous `API_KEY`, `API_SECRET`, +`VM_ID` environment contract and creates configuration exclusively as 0600. +Postinst restores the pre-unpack service files and calls the existing verified +installer, which creates/validates a dedicated nonroot service account, assigns +root:vmpower 0640 configuration access, preserves stopped/enabled state, checks a +stable child, and restores runtime state on activation failure. Prerm stops and +disables the service on removal; postrm reloads systemd. Upgrade hooks retain the +predecessor until the installer's transaction replaces it. + +If postinst fails, a running predecessor is restored, but dpkg correctly reports +the candidate as **half-configured**. Do not call that a complete rollback. +Reinstall the retained, verified previous deb with `dpkg --install PREVIOUS.deb` +to restore the installed version and package database ownership. No hook runs a +nested dpkg operation under its lock. The gate tests both the immediate runtime +restoration and this explicit package rollback. Retain the prior exact deb and +its hash before a real upgrade; do not delete the snapshots or old public URLs. + +## Bounded hosted gate proposal — not dispatched + +Use only an explicitly admitted disposable Ubuntu 24 x64 runner with systemd and +the existing 15-minute Linux native ceiling. No workflow/events/permissions are +changed by this candidate. Root must admit the exact source and invocation. + +1. Retain the existing source tests and native input preparation. Run + `python3 scripts/test-linux-deb.py` and the existing Linux installer tests. + Stage the same pinned Node24 bundle/upgrade/crashing inputs with the unchanged + preparation helper. Copy the two exact cached debs above into + `$RUNNER_TEMP/vmpower-native/legacy-debs/` without installing or executing them. + The qualifier independently verifies both byte counts and SHA256 values. +2. Run the existing guarded native qualifier with its optional flag: + `sudo --preserve-env=GITHUB_ACTIONS,RUNNER_ENVIRONMENT,GITHUB_RUN_ID,ImageOS,ImageVersion,RUNNER_TEMP python3 scripts/qualify-native-linux.py --output "$RUNNER_TEMP/vmpower-native" --legacy-dpkg`. + It retains the original nine native phases and their network/cleanup controls. + It rejects pre-existing package registration, account/config/service or owned + package paths before reading any customer configuration. +3. Under established service-user IPv4/IPv6 outbound denial, install each exact + legacy deb with real dpkg and pre-existing synthetic config. Only its known + postinst executes; **no legacy Node10 executable starts**. Run both collision + controls. Build and install real `0.2.0~rc1`, `rc2`, `rc3` packages with the same + builder/hooks intended for release; use rc3's existing crashing Node24 fixture + for rollback. Verify actual dpkg ownership/version/checksums, unchanged config + bytes, root:vmpower 0640 access, service-user readability and ordinary-user + denial, nonroot stable startup and two reports, actual hook-driven upgrade, + half-configured failure plus explicit prior-deb rollback, and actual package + remove/reinstall/purge retention. Do not substitute mocked package operations. +4. Always invoke the existing `--cleanup` entrypoint. Its journal now includes + the exact fixture-owned package registration and additional fixed paths when + this option is used; the cleanup command needs no extra flag. It purges only + that registration, verifies absence, removes owned state, stops all owned + processes/receivers, removes firewall rules and restores the original `/opt` + permissions. Upload sanitized lifecycle/cleanup JSON only; no packages, + configuration bytes, raw environments or snapshots. + +Expected incremental gate work is two package-family matrices within the existing +15-minute Linux job. The two-report waits alone need about 60 seconds per family; +package compression, installation and rollback add time. This is an unmeasured +bound proposal; timeout or incomplete cleanup is failure, not permission to +increase resources or rerun automatically. + +## Remaining release limits + +Only modern runtime execution on the admitted Ubuntu 24 host can be established +by this gate. Active legacy Node10 runtime compatibility, other distributions, +older customer kernels/libc/systemd/Python, customer architectures, actual fleet +versions and custom unit/account configurations remain unqualified. The package +requires Python 3.9+, systemd and compatible native libraries; those dependencies +do not prove any untested host is supported. Unknown units and unsafe ownership +fail closed. Exact old binary families are retained; Windows packaging is outside +this change. Existing HTTPS/source/hash provenance is preserved; no new signing +or publisher-authentication claim is introduced. Root owns execution admission, +final release version, immutable package publication, rollback inventory and +consumer readback after the live-usage audit. + +References: [Debian maintainer-script upgrade ordering](https://www.debian.org/doc/debian-policy/ch-maintainerscripts.html), +[configuration handling](https://www.debian.org/doc/debian-policy/ap-pkg-conffiles.html), +and [dpkg version/ownership/verification behavior](https://manpages.debian.org/unstable/dpkg/dpkg.1.en.html). diff --git a/scripts/build-linux-deb.py b/scripts/build-linux-deb.py new file mode 100644 index 0000000..522e245 --- /dev/null +++ b/scripts/build-linux-deb.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +"""Build the versioned Linux deb from a verified bundle; never install or execute it. + +The actual hooks are shared with disposable dpkg qualification. Version 0.2.0 +(or an explicit prerelease thereof) exceeds both observed legacy 0.1 families. +Publication and supported-host qualification remain separate root-owned gates. +""" +import argparse +import gzip +import hashlib +import importlib.util +import io +import json +import os +from pathlib import Path +import re +import sys +import tarfile + +sys.dont_write_bytecode=True +ROOT=Path(__file__).resolve().parents[1] +spec=importlib.util.spec_from_file_location('deb_installer',ROOT/'scripts/install-linux-bundle.py') +installer=importlib.util.module_from_spec(spec);spec.loader.exec_module(installer) + + +def tar_bytes(files): + output=io.BytesIO() + with tarfile.open(fileobj=output,mode='w',format=tarfile.USTAR_FORMAT) as archive: + directories=sorted({str(parent) for name in files for parent in Path(name).parents if str(parent)!='.'},key=lambda x:(x.count('/'),x)) + for name in directories: + entry=tarfile.TarInfo('./'+name);entry.type=tarfile.DIRTYPE;entry.mode=0o755;entry.uid=entry.gid=0;archive.addfile(entry) + for name,(raw,mode) in sorted(files.items()): + entry=tarfile.TarInfo('./'+name);entry.size=len(raw);entry.mode=mode;entry.uid=entry.gid=0 + archive.addfile(entry,io.BytesIO(raw)) + return gzip.compress(output.getvalue(),mtime=0) + + +def ar_bytes(members): + data=bytearray(b'!\n') + for name,body in members: + header=f'{name+"/":<16}{0:<12}{0:<6}{0:<6}{"100644":<8}{len(body):<10}`\n'.encode() + if len(header)!=60:raise ValueError('Invalid ar member') + data.extend(header);data.extend(body) + if len(body)%2:data.extend(b'\n') + return bytes(data) + + +def package_files(bundle,expected,version): + if not re.fullmatch(r'0\.2\.0(?:~[a-z0-9][a-z0-9.]*)?',version):raise ValueError('Explicit 0.2.0 release/prerelease required') + if not re.fullmatch(r'[0-9a-f]{64}',expected):raise ValueError('Exact lowercase bundle SHA256 required') + if bundle.is_symlink() or not bundle.is_dir():raise ValueError('Real bundle directory required') + manifest=installer.stage.verify_bundle(bundle) + raw_manifest=(bundle/'bundle-manifest.json').read_bytes() + if hashlib.sha256(raw_manifest).hexdigest()!=expected or manifest['target']!='linux-x64':raise ValueError('Qualified Linux manifest mismatch') + payload={} + for name,digest in manifest['files'].items(): + raw=(bundle/name).read_bytes() + if hashlib.sha256(raw).hexdigest()!=digest:raise ValueError('Bundle changed during packaging') + payload['usr/lib/vmpower-agent/bundle/'+name]=(raw,0o755 if name=='runtime/node' else 0o644) + payload['usr/lib/vmpower-agent/bundle/bundle-manifest.json']=(raw_manifest,0o644) + for name in ('install-linux-bundle.py','stage-distribution.py'): + payload['usr/lib/vmpower-agent/scripts/'+name]=((ROOT/'scripts'/name).read_bytes(),0o644) + legacy='packages/debian/vmpower-agent/etc/systemd/system/vmpower.service' + payload['usr/lib/vmpower-agent/'+legacy]=((ROOT/legacy).read_bytes(),0o644) + payload['usr/bin/vmpower']=(installer.launcher_bytes(expected),0o755) + payload['etc/systemd/system/vmpower.service']=(installer.unit_bytes(),0o644) + # Configuration and retained /opt releases intentionally are not dpkg data files. + control=(f'Package: vmpower-agent\nVersion: {version}\nArchitecture: amd64\n' + 'Maintainer: VMPower\nPriority: optional\n' + 'Pre-Depends: python3 (>= 3.9)\nDepends: systemd, passwd, libc6 (>= 2.28), libstdc++6, libgcc-s1\n' + 'Description: VMPower agent with a pinned Node24 runtime\n').encode() + template=(ROOT/'scripts/linux-deb-hook.py').read_text() + hook=template.replace('VERSION = None # BUILDER_VERSION','VERSION = '+repr(version)).replace('EXPECTED = None # BUILDER_EXPECTED','EXPECTED = '+repr(expected)).encode() + controls={'control':(control,0o644),'md5sums':((''.join(hashlib.md5(raw,usedforsecurity=False).hexdigest()+' '+name+'\n' for name,(raw,_) in sorted(payload.items()))).encode(),0o644)} + controls.update({phase:(hook,0o755) for phase in ('preinst','postinst','prerm','postrm')}) + return payload,controls + + +def build(bundle,expected,version,output): + if output.exists() or output.is_symlink():raise ValueError('Output already exists') + payload,controls=package_files(bundle,expected,version) + raw=ar_bytes([('debian-binary',b'2.0\n'),('control.tar.gz',tar_bytes(controls)),('data.tar.gz',tar_bytes(payload))]) + fd=os.open(output,os.O_WRONLY|os.O_CREAT|os.O_EXCL,0o600) + with os.fdopen(fd,'wb') as handle:handle.write(raw);handle.flush();os.fsync(handle.fileno()) + return {'package':'vmpower-agent','version':version,'architecture':'amd64','sha256':hashlib.sha256(raw).hexdigest(),'bytes':len(raw),'bundleManifestSha256':expected,'maintainerHookSha256':hashlib.sha256(controls['postinst'][0]).hexdigest(),'files':len(payload)} + + +if __name__=='__main__': + parser=argparse.ArgumentParser(description=__doc__);parser.add_argument('--bundle',type=Path,required=True);parser.add_argument('--expected-manifest-sha256',required=True);parser.add_argument('--version',required=True);parser.add_argument('--output',type=Path,required=True);args=parser.parse_args() + print(json.dumps(build(args.bundle,args.expected_manifest_sha256,args.version,args.output))) diff --git a/scripts/install-linux-bundle.py b/scripts/install-linux-bundle.py index d41307e..cc728dc 100644 --- a/scripts/install-linux-bundle.py +++ b/scripts/install-linux-bundle.py @@ -28,6 +28,16 @@ LEGACY_UNIT=(Path(__file__).parents[1]/'packages/debian/vmpower-agent/etc/systemd/system/vmpower.service').read_bytes() +def launcher_bytes(expected): + if not re.fullmatch(r'[0-9a-f]{64}',expected): raise ValueError('Invalid release digest') + executable='/opt/vmpower-agent/releases/'+expected + return ('#!/bin/sh\n'+MARKER+'exec "'+executable+'/runtime/node" "'+executable+'/app/index.js" "$@"\n').encode() + + +def unit_bytes(): + return (MARKER+'[Unit]\nDescription=VMPower agent\nAfter=network.target\n\n[Service]\nType=simple\nExecStart=/usr/bin/vmpower\nUser=vmpower\nRestart=on-failure\nRestartSec=10\nNoNewPrivileges=true\n\n[Install]\nWantedBy=multi-user.target\n').encode() + + def config_bytes(existing, incoming): raw=existing if existing is not None else incoming if raw is None: raise ValueError('First install requires configuration JSON on stdin') @@ -171,10 +181,8 @@ def install(bundle,expected,incoming=None,root=Path('/'),system=None): if was_active: system.call('systemctl','stop','vmpower.service') start=old_unit is None or was_active try: - executable='/opt/vmpower-agent/releases/'+approved_digest - atomic_write(launcher,('#!/bin/sh\n'+MARKER+'exec "'+executable+'/runtime/node" "'+executable+'/app/index.js" "$@"\n').encode(),0o755) - text=MARKER+'[Unit]\nDescription=VMPower agent\nAfter=network.target\n\n[Service]\nType=simple\nExecStart=/usr/bin/vmpower\nUser=vmpower\nRestart=on-failure\nRestartSec=10\nNoNewPrivileges=true\n\n[Install]\nWantedBy=multi-user.target\n' - atomic_write(unit,text.encode(),0o644) + atomic_write(launcher,launcher_bytes(approved_digest),0o755) + atomic_write(unit,unit_bytes(),0o644) system.call('systemctl','daemon-reload') if old_unit is None or was_enabled: system.call('systemctl','enable','vmpower.service') if start: diff --git a/scripts/linux-deb-hook.py b/scripts/linux-deb-hook.py new file mode 100644 index 0000000..4c7a6ba --- /dev/null +++ b/scripts/linux-deb-hook.py @@ -0,0 +1,169 @@ +#!/usr/bin/python3 +"""Debian maintainer hook template; builder binds VERSION and EXPECTED. + +Do not execute this source template. The same generated hook is installed in all +four maintainer-script slots. Failed activation restores runtime state; dpkg can +remain half-configured until the operator reinstalls the retained previous deb. +""" +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import stat +import subprocess +import sys +import tempfile +import uuid +sys.dont_write_bytecode=True + +VERSION = None # BUILDER_VERSION +EXPECTED = None # BUILDER_EXPECTED +STATE=Path('/var/lib/vmpower-agent') +UNIT=Path('/etc/systemd/system/vmpower.service') +LAUNCHER=Path('/usr/bin/vmpower') +CONFIG=Path('/etc/vmpower.conf') +LIB=Path('/usr/lib/vmpower-agent') +LEGACY_UNIT_SHA='6fce1ebea22adb4f5ada3d6a8add52cad5264d6b4ff3bc78b6796a6ab34c2564' +LEGACY_BINARY_SHAS={'6e26bae20a2d0a45135c0b8591d41982175cff5b844ffcd2c3a610b1b49e3a80', + 'fd8bee37cb01a23aff850eb93096a6c8da7aef7ba4e62c548649399420c6849b'} +MARKER=b'# VMPower Node24 bundle installer\n' + + +def command(*args,allowed=(0,)): + result=subprocess.run(args,capture_output=True,timeout=35,env={'PATH':'/usr/sbin:/usr/bin:/sbin:/bin'}) + if result.returncode not in allowed: raise RuntimeError('Package operation failed: '+args[0]) + return result.returncode + + +def protected(path): + for item in [path,*path.parents]: + if item.is_symlink(): raise ValueError('Linked package state/destination') + if item.exists(): + info=item.stat() + if info.st_uid!=0 or info.st_mode & 0o022: raise ValueError('Unprotected package state/destination') + if not (stat.S_ISDIR(info.st_mode) or stat.S_ISREG(info.st_mode)):raise ValueError('Special package state/destination') + if path.exists() and path.is_file() and path.stat().st_nlink!=1: raise ValueError('Linked package file') + + +def write(path,data,mode=0o600): + protected(path) + fd,name=tempfile.mkstemp(prefix='.package-',dir=path.parent) + try: + os.fchmod(fd,mode) + with os.fdopen(fd,'wb') as handle: handle.write(data);handle.flush();os.fsync(handle.fileno()) + os.replace(name,path) + finally: + if os.path.exists(name):os.unlink(name) + + +def journal(): + protected(STATE/'pending.json') + value=json.loads((STATE/'pending.json').read_bytes()) + if value.get('schema')!=1 or not isinstance(value.get('id'),str) or len(value['id'])!=32 or any(c not in '0123456789abcdef' for c in value['id']): + raise ValueError('Unrecognized package transaction') + return value + + +def save(value):write(STATE/'pending.json',(json.dumps(value)+'\n').encode()) + + +def restore(value): + directory=STATE/value['id'];protected(directory) + for name,target in [('unit',UNIT),('launcher',LAUNCHER)]: + protected(target) + entry=value['files'][name] + if entry is None: + if target.exists():target.unlink() + else: + source=directory/name;protected(source);raw=source.read_bytes() + if hashlib.sha256(raw).hexdigest()!=entry['sha256']:raise ValueError('Rollback snapshot mismatch') + write(target,raw,entry['mode']) + + +def configuration(): + protected(CONFIG) + existing=CONFIG.exists() + if existing: + if not CONFIG.is_file() or CONFIG.stat().st_size>65536:raise ValueError('Invalid configuration file') + raw=CONFIG.read_bytes();value=json.loads(raw) + else: + value={key:os.environ.get(env,'') for key,env in [('key','API_KEY'),('secret','API_SECRET'),('vm_id','VM_ID')]} + raw=(json.dumps(value)+'\n').encode() + if not isinstance(value,dict) or any(not isinstance(value.get(k),str) or not value[k].strip() for k in ('key','secret','vm_id')): + raise ValueError('Existing JSON configuration or API_KEY/API_SECRET/VM_ID required') + if not existing: + # Exclusive 0600 creation preserves the legacy first-install environment contract. + fd=os.open(CONFIG,os.O_WRONLY|os.O_CREAT|os.O_EXCL,0o600) + with os.fdopen(fd,'wb') as handle:handle.write(raw);handle.flush();os.fsync(handle.fileno()) + + +def preinst(): + for target in (STATE,UNIT,LAUNCHER,CONFIG):protected(target) + if UNIT.exists(): + unit=UNIT.read_bytes() + if hashlib.sha256(unit).hexdigest()==LEGACY_UNIT_SHA: + if not LAUNCHER.is_file() or LAUNCHER.stat().st_size>128*1024*1024 or hashlib.sha256(LAUNCHER.read_bytes()).hexdigest() not in LEGACY_BINARY_SHAS: + raise ValueError('Unqualified legacy executable family') + elif not unit.startswith(MARKER):raise ValueError('Unrecognized existing service unit') + if (STATE/'pending.json').exists() and journal().get('state') not in ('complete','runtime-restored'): + raise ValueError('Unfinished package transaction requires reconciliation') + configuration() + STATE.mkdir(mode=0o700,exist_ok=True);STATE.chmod(0o700) + value={'schema':1,'id':uuid.uuid4().hex,'version':VERSION,'state':'prepared','files':{}, + 'active':command('systemctl','is-active','--quiet','vmpower.service',allowed=(0,3,4))==0, + 'enabled':command('systemctl','is-enabled','--quiet','vmpower.service',allowed=(0,1,3,4))==0} + directory=STATE/value['id'];directory.mkdir(mode=0o700) + for name,target in [('unit',UNIT),('launcher',LAUNCHER)]: + if target.exists(): + if not target.is_file() or target.stat().st_size>128*1024*1024:raise ValueError('Invalid existing package file') + raw=target.read_bytes();write(directory/name,raw) + value['files'][name]={'sha256':hashlib.sha256(raw).hexdigest(),'mode':stat.S_IMODE(target.stat().st_mode)} + else:value['files'][name]=None + save(value) + + +def activate(): + spec=importlib.util.spec_from_file_location('package_installer',LIB/'scripts/install-linux-bundle.py') + installer=importlib.util.module_from_spec(spec);spec.loader.exec_module(installer) + installer.install(LIB/'bundle',EXPECTED) + + +def postinst(): + value=journal() + if value['version']!=VERSION:raise ValueError('Package transaction version mismatch') + if value['state'] not in ('prepared','runtime-restored','complete'):raise ValueError('Invalid package transaction state') + if value['state']=='complete': + preinst();value=journal() # Reconfigure snapshots the currently installed release. + restore(value) + try: + activate() + except Exception: + command('systemctl','stop','vmpower.service',allowed=(0,5)) + restore(value);command('systemctl','daemon-reload') + command('systemctl','enable' if value['enabled'] else 'disable','vmpower.service',allowed=(0,1)) + if value['active']:command('systemctl','start','vmpower.service') + value['state']='runtime-restored';save(value) + raise RuntimeError('Activation failed; runtime restored. Reinstall the retained previous deb to reconcile dpkg.') from None + value['state']='complete';save(value) + + +def main(phase,args): + if VERSION is None or EXPECTED is None:raise ValueError('Unbound maintainer-hook template') + if sys.platform!='linux' or os.geteuid()!=0:raise ValueError('Root on Linux required') + action=args[0] if args else '' + if phase=='preinst' and action in ('install','upgrade'):preinst() + elif phase=='postinst' and action=='configure':postinst() + elif phase=='prerm' and action=='remove': + command('systemctl','stop','vmpower.service',allowed=(0,5)) + command('systemctl','disable','vmpower.service',allowed=(0,1)) + elif phase=='postrm' and action in ('remove','purge'):command('systemctl','daemon-reload') + # Upgrade prerm/postrm deliberately retain the live predecessor until the + # verified installer's transaction stops it. Config/account/releases survive removal. + + +if __name__=='__main__': + try:main(Path(sys.argv[0]).name.rsplit('.',1)[-1],sys.argv[1:]) + except Exception as exc: + # Configuration, arguments and environment are never printed. + print('VMPower package hook failed: '+type(exc).__name__+'. Check /var/lib/vmpower-agent/pending.json; if runtime-restored, reinstall the retained previous deb.',file=sys.stderr);sys.exit(1) diff --git a/scripts/qualify-linux-dpkg.py b/scripts/qualify-linux-dpkg.py new file mode 100644 index 0000000..a781a1e --- /dev/null +++ b/scripts/qualify-linux-dpkg.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +"""Optional real-package phases called only by the guarded native Linux fixture. + +Both exact legacy packages are installed as data/metadata, with their known +postinst and pre-existing synthetic config. Their Node10 executables never run. +Only the built package's actual hooks activate the qualified modern runtime. +""" +import hashlib +import importlib.util +import json +from pathlib import Path +import pwd +import stat +import sys + +sys.dont_write_bytecode=True +PACKAGE='vmpower-agent' +EXTRA_PATHS=[Path('/usr/lib/vmpower-agent'),Path('/var/lib/vmpower-agent'),Path('/etc/vmpower-template.conf')] +LEGACY={ + 'dashboard-linux.deb':{'bytes':10794560,'sha256':'06bf0d767e4625db0477f3e23e30b4e36ac6e7a7d50882b3c655c472f45875b1','binarySha256':'6e26bae20a2d0a45135c0b8591d41982175cff5b844ffcd2c3a610b1b49e3a80'}, + 'docs-linux.deb':{'bytes':11090764,'sha256':'e8c0028ea78322e5937becba766654f178e4dfbe1c9817af33e024d93b588311','binarySha256':'fd8bee37cb01a23aff850eb93096a6c8da7aef7ba4e62c548649399420c6849b'}, +} + + +def verify_legacy(directory): + if directory.is_symlink() or not directory.is_dir():raise ValueError('Real legacy-debs directory required') + result={} + for name,expected in LEGACY.items(): + path=directory/name + if path.is_symlink() or not path.is_file() or path.stat().st_nlink!=1:raise ValueError('Regular single-link legacy package required') + raw=path.read_bytes() + if len(raw)!=expected['bytes'] or hashlib.sha256(raw).hexdigest()!=expected['sha256']:raise ValueError('Exact cached legacy package required: '+name) + result[name]=path + return result + + +def package_state(native): + result=native.command('dpkg-query','--show','--showformat=${db:Status-Status}|${Version}',PACKAGE,allowed=(0,1)) + return result.stdout.strip() if result.returncode==0 else 'absent' + + +def require_absent(native): + if package_state(native)!='absent':raise RuntimeError('Existing package registration is not fixture-owned') + + +def assert_config(native,raw): + config=Path('/etc/vmpower.conf');info=config.stat();account=pwd.getpwnam('vmpower') + assert config.read_bytes()==raw and info.st_uid==0 and info.st_gid==account.pw_gid and stat.S_IMODE(info.st_mode)==0o640 + native.command('runuser','-u','vmpower','--','test','-r',str(config)) + native.command('runuser','-u','nobody','--','test','-r',str(config),allowed=(1,)) + + +def assert_owned(native,expected,version): + assert package_state(native)=='installed|'+version + for path in ['/usr/bin/vmpower','/etc/systemd/system/vmpower.service','/usr/lib/vmpower-agent/bundle/runtime/node']: + assert native.command('dpkg-query','--search',path).stdout.strip()==PACKAGE+': '+path + assert not native.command('dpkg','--verify',PACKAGE).stdout.strip() + assert expected in Path('/usr/bin/vmpower').read_text() + native.command('dpkg-query','--search','/etc/vmpower.conf',allowed=(1,)) + + +def assert_nonroot_child(native,expected): + executable=Path('/opt/vmpower-agent/releases')/expected/'runtime/node' + pid=native.child(executable) + uid_line=next(row for row in Path('/proc/'+str(pid)+'/status').read_text().splitlines() if row.startswith('Uid:')) + assert {int(n) for n in uid_line.split()[1:]}=={pwd.getpwnam('vmpower').pw_uid} + assert pwd.getpwnam('vmpower').pw_uid!=0 + return pid + + +def qualify(native,out,installer,hashes,result): + native.guard();out=native.require_output(out) + legacy=verify_legacy(out/'legacy-debs') + require_absent(native) + owned=json.loads((out/'owned.json').read_text()) + assert owned.get('dpkgPackage')==PACKAGE and owned['paths']==[str(p) for p in native.MANAGED+EXTRA_PATHS] + spec=importlib.util.spec_from_file_location('native_deb_builder',native.ROOT/'scripts/build-linux-deb.py') + builder=importlib.util.module_from_spec(spec);spec.loader.exec_module(builder) + packages=out/'deb-packages';packages.mkdir() + receipts=result['packages'] + for name,version in [('bundle','0.2.0~rc1'),('upgrade','0.2.0~rc2'),('crashing','0.2.0~rc3')]: + native.command('dpkg','--compare-versions',version,'gt','0.1') + receipts[name]=builder.build(out/name,hashes[name],version,packages/(name+'.deb')) + native.command('dpkg','--compare-versions','0.0.2','gt','0.1',allowed=(1,)) + def install(name,allowed=(0,)): + path=packages/(name+'.deb') + assert hashlib.sha256(path.read_bytes()).hexdigest()==receipts[name]['sha256'] + return native.command('dpkg','--install',str(path),allowed=allowed) + def install_legacy(name): + # Recheck the exact source immediately before every package operation. + path=verify_legacy(out/'legacy-debs')[name] + return native.command('dpkg','--install',str(path)) + config=Path('/etc/vmpower.conf');raw=config.read_bytes();unit=Path('/etc/systemd/system/vmpower.service');launcher=Path('/usr/bin/vmpower') + families=result['families'] + for name in legacy: + phases=[] + families.append({'name':name,**LEGACY[name],'legacyBinaryExecuted':False,'legacyActiveRuntimeCompatibility':'UNRUN','phases':phases}) + install_legacy(name);native.command('systemctl','daemon-reload') + assert package_state(native)=='installed|0.1' + assert hashlib.sha256(launcher.read_bytes()).hexdigest()==LEGACY[name]['binarySha256'] + assert config.read_bytes()==raw + native.command('systemctl','is-active','--quiet','vmpower.service',allowed=(3,4)) + # Reproduce the old ownership bug with real dpkg and the standalone installer. + installer.install(out/'bundle',hashes['bundle']) + assert native.command('dpkg-query','--search',str(launcher)).stdout.strip()==PACKAGE+': '+str(launcher) + install_legacy(name) + assert hashlib.sha256(launcher.read_bytes()).hexdigest()==LEGACY[name]['binarySha256'] + phases.append({'phase':'old-reinstall-replaces-standalone-modern-launcher','status':'REPRODUCED'}) + installer.install(out/'bundle',hashes['bundle']) + native.command('dpkg','--remove',PACKAGE) + assert not launcher.exists() and not unit.exists() and config.read_bytes()==raw + phases.append({'phase':'old-remove-deletes-standalone-modern-entrypoints','status':'REPRODUCED'}) + install_legacy(name);native.command('systemctl','daemon-reload') + config.chmod(0o644) # The exact old template/migration baseline was world readable. + install('bundle') # Actual candidate preinst/postinst, no direct activation substitute. + assert_owned(native,hashes['bundle'],receipts['bundle']['version']);assert_config(native,raw) + native.command('systemctl','is-active','--quiet','vmpower.service',allowed=(3,4)) + native.command('systemctl','is-enabled','--quiet','vmpower.service',allowed=(1,3,4)) + phases.append({'phase':'versioned-upgrade-owns-paths-preserves-stopped-state-and-config','status':'PASS'}) + before=native.state(out)['count'];native.command('systemctl','start','vmpower.service') + installer.System().stable_child(Path('/opt/vmpower-agent/releases')/hashes['bundle']/'runtime/node') + pid=assert_nonroot_child(native,hashes['bundle']) + native.wait(lambda:native.state(out)['count']>=before+2,85) + assert assert_nonroot_child(native,hashes['bundle'])==pid and native.state(out)['rejected']==0 + phases.append({'phase':'actual-hook-install-nonroot-stable-child-and-two-reports','status':'PASS','reportsObserved':native.state(out)['count']-before}) + install('upgrade');assert_owned(native,hashes['upgrade'],receipts['upgrade']['version']);assert_config(native,raw) + assert_nonroot_child(native,hashes['upgrade']) + phases.append({'phase':'actual-hook-active-upgrade','status':'PASS'}) + failed=install('crashing',allowed=(1,)) + assert failed.returncode==1 and package_state(native)=='half-configured|'+receipts['crashing']['version'] + installer.System().stable_child(Path('/opt/vmpower-agent/releases')/hashes['upgrade']/'runtime/node') + assert_nonroot_child(native,hashes['upgrade']);assert_config(native,raw) + assert json.loads(Path('/var/lib/vmpower-agent/pending.json').read_text())['state']=='runtime-restored' + phases.append({'phase':'failed-postinst-restores-runtime-and-exposes-half-configured-dpkg','status':'PASS'}) + install('upgrade') # Explicit retained-deb rollback reconciles package ownership/version. + assert_owned(native,hashes['upgrade'],receipts['upgrade']['version']);assert_config(native,raw) + pid=assert_nonroot_child(native,hashes['upgrade']) + phases.append({'phase':'retained-prior-deb-restores-installed-registration-and-runtime','status':'PASS'}) + native.command('dpkg','--remove',PACKAGE);native.wait(lambda:native.dead(pid)) + assert not launcher.exists() and not unit.exists() and config.read_bytes()==raw + assert (Path('/opt/vmpower-agent/releases')/hashes['upgrade']).exists() + install('upgrade');assert_owned(native,hashes['upgrade'],receipts['upgrade']['version']);assert_config(native,raw) + pid=assert_nonroot_child(native,hashes['upgrade']) + native.command('dpkg','--purge',PACKAGE);native.wait(lambda:native.dead(pid));require_absent(native) + assert not launcher.exists() and not unit.exists() and config.read_bytes()==raw + phases.append({'phase':'actual-remove-reinstall-purge-preserve-config-and-retained-releases','status':'PASS'}) + result.update({'status':'PASS','packageRegistrationAbsent':True,'actualCandidateHooksExecuted':True,'customerOSCompatibility':'Unknown','retainedConfigSha256':hashlib.sha256(raw).hexdigest()}) + return result diff --git a/scripts/qualify-native-linux.py b/scripts/qualify-native-linux.py index 366440b..2020e94 100644 --- a/scripts/qualify-native-linux.py +++ b/scripts/qualify-native-linux.py @@ -10,6 +10,9 @@ FIXED_ANCESTORS=('/', '/opt', '/etc', '/etc/systemd', '/etc/systemd/system', '/usr', '/usr/bin') OPT_PATH=Path('/opt') OPT_CHANGE={'path':'/opt','uid':0,'gid':0,'originalMode':'0777','temporaryMode':'0755'} +def package_fixture(): + spec=importlib.util.spec_from_file_location('linux_dpkg_fixture',ROOT/'scripts/qualify-linux-dpkg.py') + module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module);return module def fixed_ancestor_metadata(): """Read only an explicit public filesystem metadata allowlist; never repair it.""" rows=[] @@ -124,7 +127,12 @@ def cleanup(out): guard();out=require_output(out);journal=out/'owned.json' if not journal.exists():return {'status':'NO_OWNED_RESOURCES'} owned=json.loads(journal.read_text()) - if owned['run']!=os.environ['GITHUB_RUN_ID'] or owned['paths']!=[str(p) for p in MANAGED]:raise RuntimeError('Cleanup ownership mismatch') + managed=list(MANAGED);dpkg=None + if 'dpkgPackage' in owned: + dpkg=package_fixture() + if owned['dpkgPackage']!=dpkg.PACKAGE:raise RuntimeError('Package cleanup ownership mismatch') + managed+=dpkg.EXTRA_PATHS + if owned['run']!=os.environ['GITHUB_RUN_ID'] or owned['paths']!=[str(p) for p in managed]:raise RuntimeError('Cleanup ownership mismatch') validate_opt_journal(owned) command('systemctl','stop','vmpower.service',allowed=(0,5)) command('systemctl','disable','vmpower.service',allowed=(0,1)) @@ -147,7 +155,10 @@ def cleanup(out): binary,args=rule[0],rule[1:] if command(binary,'-w','-C','OUTPUT',*args,allowed=(0,1)).returncode==0:command(binary,'-w','-D','OUTPUT',*args) assert command(binary,'-w','-C','OUTPUT',*args,allowed=(0,1)).returncode==1 - for path in MANAGED: + if dpkg: + if dpkg.package_state(sys.modules[__name__])!='absent':command('dpkg','--purge',dpkg.PACKAGE) + dpkg.require_absent(sys.modules[__name__]) + for path in managed: if path.is_dir():shutil.rmtree(path) elif path.exists() or path.is_symlink():path.unlink() command('systemctl','daemon-reload');command('systemctl','reset-failed','vmpower.service',allowed=(0,1)) @@ -159,28 +170,35 @@ def cleanup(out): # A child owned by this process can be a zombie until waited; reap it here. try:os.waitpid(owned['receiverPid'],0) except ChildProcessError:wait(lambda:dead(owned['receiverPid'])) - assert all(not p.exists() for p in MANAGED) + assert all(not p.exists() for p in managed) assert command('systemctl','show','vmpower.service','--property=LoadState','--value').stdout.strip()=='not-found' for path in out.iterdir(): if path.name in ['evidence','owned.json']:continue if path.is_dir():shutil.rmtree(path) else:path.unlink() result={'status':'FAIL' if survivors else 'PASS','serviceAbsent':True,'childrenAbsent':not survivors,'managedFilesAbsent':True,'fixtureInputsRemoved':True,'firewallRulesRemoved':True,'receiverStopped':True} + if dpkg:result['packageRegistrationAbsent']=True write(out/'evidence/cleanup-linux.json',result) if survivors:raise RuntimeError('Service left children; forced cleanup performed') return complete_cleanup(out,owned,result) -def qualify(out): +def qualify(out,legacy_dpkg=False): guard() out=require_output(out) if (out/'owned.json').exists():raise RuntimeError('Existing ownership journal requires cleanup reconciliation') # Do not read any pre-existing configuration: reject the entire occupied host. - if any(p.exists() or p.is_symlink() for p in MANAGED):raise RuntimeError('Managed destination already exists') + managed=list(MANAGED);dpkg=None + if legacy_dpkg: + dpkg=package_fixture();dpkg.require_absent(sys.modules[__name__]);dpkg.verify_legacy(out/'legacy-debs') + managed+=dpkg.EXTRA_PATHS + if any(p.exists() or p.is_symlink() for p in managed):raise RuntimeError('Managed destination already exists') if command('systemctl','show','vmpower.service','--property=LoadState','--value').stdout.strip()!='not-found':raise RuntimeError('Service already exists') for lookup in [pwd.getpwnam,grp.getgrnam]: try:lookup('vmpower') except KeyError:continue raise RuntimeError('Service account/group already exists') - owned={'run':os.environ['GITHUB_RUN_ID'],'paths':[str(p) for p in MANAGED],'firewall':[]};write(out/'owned.json',owned) + owned={'run':os.environ['GITHUB_RUN_ID'],'paths':[str(p) for p in managed],'firewall':[]} + if dpkg:owned['dpkgPackage']=dpkg.PACKAGE + write(out/'owned.json',owned) prepared=json.loads((out/'evidence/prepared.json').read_text());hashes=prepared['bundles'];node=SERVICE_FIXTURE/'bundle/runtime/node' phases=[];result={'status':'FAIL','head':prepared['head'],'platform':platform.platform(),'image':os.environ.get('ImageOS'),'imageVersion':os.environ.get('ImageVersion'),'architecture':platform.machine(),'libc':platform.libc_ver(),'phases':phases,'customerOSCompatibility':'Unknown'} try: @@ -250,6 +268,9 @@ def qualify(out): assert hashlib.sha256(MANAGED[1].read_bytes()).hexdigest()==config_digest installer.uninstall() phases.append({'phase':'synthetic-legacy-unit-migration','status':'PASS','legacyPublishedBinary':'UNRUN'}) + if dpkg: + result['realPackageOwnership']={'status':'FAIL','families':[],'packages':{}} + dpkg.qualify(sys.modules[__name__],out,installer,hashes,result['realPackageOwnership']) result['status']='PASS' finally: try:result['cleanup']=cleanup(out) @@ -257,6 +278,6 @@ def qualify(out): finally:write(out/'evidence/lifecycle-linux.json',result) print(json.dumps({'nativeLifecycle':'PASS','phases':len(phases),'cleanup':result['cleanup']['status']})) if __name__=='__main__': - parser=argparse.ArgumentParser();parser.add_argument('--output',type=Path,required=True);parser.add_argument('--cleanup',action='store_true');args=parser.parse_args();guard() + parser=argparse.ArgumentParser();parser.add_argument('--output',type=Path,required=True);parser.add_argument('--cleanup',action='store_true');parser.add_argument('--legacy-dpkg',action='store_true');args=parser.parse_args();guard() if args.cleanup:cleanup(args.output) - else:qualify(args.output) + else:qualify(args.output,args.legacy_dpkg) diff --git a/scripts/test-linux-deb.py b/scripts/test-linux-deb.py new file mode 100644 index 0000000..6f5fdf2 --- /dev/null +++ b/scripts/test-linux-deb.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +"""Offline package bytes/hook transaction tests; no dpkg, service or binary runs.""" +import hashlib +import importlib.util +import io +import json +import os +from pathlib import Path +import stat +import sys +import tarfile +import tempfile +import unittest +from unittest import mock + +sys.dont_write_bytecode=True +def load(name,file): + spec=importlib.util.spec_from_file_location(name,Path(__file__).with_name(file)) + module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module);return module +builder=load('deb_builder','build-linux-deb.py') +hook=load('deb_hook','linux-deb-hook.py') +fixtures=load('deb_fixtures','test-stage-distribution.py') +dpkg_fixture=load('deb_native_fixture','qualify-linux-dpkg.py') +native=load('deb_native','qualify-native-linux.py') + + +def ar_members(raw): + assert raw[:8]==b'!\n';position=8;result={} + while position Date: Mon, 26 Oct 2026 08:56:29 -0700 Subject: [PATCH 02/18] Bound real Debian package phases with whole-runner quarantine --- distribution/linux-debian-handoff.md | 27 +++++- scripts/linux-package-quarantine.py | 130 +++++++++++++++++++++++++++ scripts/qualify-linux-dpkg.py | 5 ++ scripts/qualify-native-linux.py | 8 +- scripts/test-linux-deb.py | 49 ++++++++++ 5 files changed, 214 insertions(+), 5 deletions(-) create mode 100644 scripts/linux-package-quarantine.py diff --git a/distribution/linux-debian-handoff.md b/distribution/linux-debian-handoff.md index e9584da..37e4217 100644 --- a/distribution/linux-debian-handoff.md +++ b/distribution/linux-debian-handoff.md @@ -68,7 +68,20 @@ changed by this candidate. Root must admit the exact source and invocation. It retains the original nine native phases and their network/cleanup controls. It rejects pre-existing package registration, account/config/service or owned package paths before reading any customer configuration. -3. Under established service-user IPv4/IPv6 outbound denial, install each exact +3. Keep the existing service-user IPv4/IPv6 rules. Immediately before any actual + package install, `linux-package-quarantine.py` adds two journaled whole-runner + OUTPUT rejects for every nonloopback IPv4/IPv6 protocol and identity. Root and + service-user socket probes must reach owned loopback and receive explicit + external denial; the IPv4 reject counter must cover both probes. IPv6 rule + readback plus an explicit denial is retained even on a host without an IPv6 + route. No package download or dependency resolution occurs under quarantine. + This temporarily disconnects the GitHub runner's control connection as well. + A separate-session watchdog must acknowledge readiness before rules are added. + After 420 seconds it terminates the qualifier's private process group (including + dpkg/maintainer descendants) before removing only the two whole-runner rules. + Existing service-UID rules still deny any systemd child until ordinary cleanup. + Deadline expiry is a failed run, never acceptance or an automatic retry. + Under this quarantine, install each exact legacy deb with real dpkg and pre-existing synthetic config. Only its known postinst executes; **no legacy Node10 executable starts**. Run both collision controls. Build and install real `0.2.0~rc1`, `rc2`, `rc3` packages with the same @@ -81,7 +94,8 @@ changed by this candidate. Root must admit the exact source and invocation. 4. Always invoke the existing `--cleanup` entrypoint. Its journal now includes the exact fixture-owned package registration and additional fixed paths when this option is used; the cleanup command needs no extra flag. It purges only - that registration, verifies absence, removes owned state, stops all owned + that registration **before dropping quarantine**, verifies absence, stops the + exact watchdog, removes owned state, stops all owned processes/receivers, removes firewall rules and restores the original `/opt` permissions. Upload sanitized lifecycle/cleanup JSON only; no packages, configuration bytes, raw environments or snapshots. @@ -92,6 +106,15 @@ package compression, installation and rollback add time. This is an unmeasured bound proposal; timeout or incomplete cleanup is failure, not permission to increase resources or rerun automatically. +The prior candidate incorrectly treated service-UID denial as sufficient for +root package hooks. The package entrypoint now enforces whole-runner quarantine; +the watchdog is necessary because a blocked runner cannot receive its next +cleanup action. Offline tests cover the exact rule scope, process-group identity, +deadline kill-before-restore ordering and reused-process refusal. Hosted proof of +watchdog recovery, root/service denial, runner reconnection and package cleanup is +still required. Do not bypass this gate based on the old postinst's current +no-op behavior with existing synthetic configuration. + ## Remaining release limits Only modern runtime execution on the admitted Ubuntu 24 host can be established diff --git a/scripts/linux-package-quarantine.py b/scripts/linux-package-quarantine.py new file mode 100644 index 0000000..f1a8ab9 --- /dev/null +++ b/scripts/linux-package-quarantine.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +"""Bounded whole-runner package quarantine; only the disposable native fixture. + +No package script runs until both root and service-user direct socket controls +pass. A separate-session watchdog restores runner connectivity after terminating +the qualification process group on deadline. It never removes the service-UID +rules, so a systemd child stays denied until the existing always-cleanup runs. +""" +import argparse +import errno +import inspect +import json +import os +from pathlib import Path +import re +import select +import signal +import socket +import subprocess +import sys +import time + +LIMIT_SECONDS=420 +SELF=Path(__file__).resolve() + +def guard(): + if not (sys.platform=='linux' and os.geteuid()==0 and os.environ.get('GITHUB_ACTIONS')=='true' and os.environ.get('RUNNER_ENVIRONMENT')=='github-hosted' and os.environ.get('ImageOS')=='ubuntu24'): + raise RuntimeError('Package quarantine requires admitted disposable Ubuntu24') + +def rules(run): + if not re.fullmatch(r'[0-9]+',run):raise ValueError('Numeric native run identity required') + return [[binary,'!','-o','lo','-m','comment','--comment','VMPOWER_PACKAGE_'+run,'-j','REJECT'] for binary in ('iptables','ip6tables')] + +def command(*args,allowed=(0,)): + result=subprocess.run(args,capture_output=True,text=True,timeout=15) + if result.returncode not in allowed:raise RuntimeError('Package quarantine command failed: '+args[0]) + return result + +def remove_rules(run): + for binary,*args in rules(run): + if command(binary,'-w','-C','OUTPUT',*args,allowed=(0,1)).returncode==0: + command(binary,'-w','-D','OUTPUT',*args) + if command(binary,'-w','-C','OUTPUT',*args,allowed=(0,1)).returncode!=1: + raise RuntimeError('Package quarantine rule remains') + +def identity(pid): + try:return Path('/proc/'+str(pid)+'/stat').read_text().split(') ',1)[1].split()[19] + except FileNotFoundError:return None + +def watchdog(pid,start,run): + guard();rules(run) + # Never accept an arbitrary group from an unvalidated caller. + argv=Path('/proc/'+str(pid)+'/cmdline').read_bytes().split(b'\0') + if identity(pid)!=start or os.getpgid(pid)!=pid or not any(item.endswith(b'/qualify-native-linux.py') for item in argv) or b'--legacy-dpkg' not in argv: + raise RuntimeError('Quarantine watchdog owner mismatch') + print('READY',flush=True) + time.sleep(LIMIT_SECONDS) + # Kill package/maintainer descendants before restoring host connectivity. + # The pre-existing service-UID rules continue to cover systemd children. + if identity(pid) in (None,start): + try:os.killpg(pid,signal.SIGKILL) + except ProcessLookupError:pass + remove_rules(run) + +def probe(port): + with socket.create_connection(('127.0.0.1',port),timeout=5):pass + codes={} + for family,address in [(socket.AF_INET,'1.1.1.1'),(socket.AF_INET6,'2606:4700:4700::1111')]: + with socket.socket(family,socket.SOCK_STREAM) as connection: + connection.settimeout(5) + try:connection.connect((address,443)) + except OSError as exc: + if exc.errno not in (errno.EACCES,errno.EPERM,errno.EHOSTUNREACH,errno.ENETUNREACH,errno.ECONNREFUSED): + raise RuntimeError('Explicit package egress denial unproven') from None + codes['ipv4' if family==socket.AF_INET else 'ipv6']=errno.errorcode[exc.errno] + else:raise RuntimeError('Package quarantine allowed external connection') + return {'loopback':'PASS','external':'DENIED','codes':codes} + +def begin(native,out,port): + guard();run=os.environ['GITHUB_RUN_ID'];expected=rules(run) + owned=json.loads((out/'owned.json').read_text()) + if 'packageQuarantine' in owned:raise RuntimeError('Package quarantine already journaled') + for binary,*args in expected: + if command(binary,'-w','-C','OUTPUT',*args,allowed=(0,1)).returncode!=1: + raise RuntimeError('Package quarantine rule already exists') + # The watchdog may kill this group, never the workflow shell/runner group. + if os.getpgrp()!=os.getpid():os.setsid() + if os.getpgrp()!=os.getpid():raise RuntimeError('Private qualifier process group required') + parent=os.getpid();start=identity(parent) + watcher=subprocess.Popen([sys.executable,str(SELF),'--watchdog',str(parent),start,run],start_new_session=True,stdout=subprocess.PIPE,stderr=subprocess.DEVNULL) + owned['packageQuarantine']={'run':run,'watchdogPid':watcher.pid,'watchdogStart':identity(watcher.pid),'seconds':LIMIT_SECONDS} + native.write(out/'owned.json',owned) + ready,_,_=select.select([watcher.stdout],[],[],5) + if not ready or watcher.stdout.readline()!=b'READY\n':raise RuntimeError('Package quarantine watchdog did not become ready') + watcher.stdout.close() + # Journal intent before each rule; normal cleanup reconciles partial setup. + for binary,*args in expected: + owned['firewall'].append([binary,*args]);native.write(out/'owned.json',owned) + command(binary,'-w','-I','OUTPUT','1',*args) + command(binary,'-w','-C','OUTPUT',*args) + root=probe(port) + # The checkout can be below private runner ancestors. Pass only this fixed, + # public probe source; do not widen those ancestors for the service account. + probe_source='import errno,json,socket\n'+inspect.getsource(probe)+'\nprint(json.dumps(probe('+str(int(port))+')))' + service=json.loads(command('runuser','-u','vmpower','--',sys.executable,'-c',probe_source).stdout) + counters=command('iptables','-w','-L','OUTPUT','-n','-v','-x').stdout.splitlines() + if not any('VMPOWER_PACKAGE_'+run in row.split() and int(row.split()[0])>=2 for row in counters): + raise RuntimeError('Root and service quarantine packet counters unproven') + if watcher.poll() is not None:raise RuntimeError('Package quarantine watchdog unavailable') + return {'status':'PASS','scope':'all identities and protocols, nonloopback IPv4/IPv6','root':root,'service':service,'ipv4RejectPacketsAtLeastTwo':True,'ipv6RuleInstalled':True,'deadlineSeconds':LIMIT_SECONDS} + +def finish(owned): + value=owned.get('packageQuarantine') + if value is None:return + guard() + if value['run']!=os.environ['GITHUB_RUN_ID'] or value['seconds']!=LIMIT_SECONDS:raise RuntimeError('Package quarantine journal mismatch') + remove_rules(value['run']) + pid=value['watchdogPid'] + if identity(pid)==value['watchdogStart']: + argv=Path('/proc/'+str(pid)+'/cmdline').read_bytes().split(b'\0') + if str(SELF).encode() not in argv or b'--watchdog' not in argv:raise RuntimeError('Package quarantine watchdog mismatch') + os.kill(pid,signal.SIGTERM) + try:os.waitpid(pid,0) + except ChildProcessError:pass + +if __name__=='__main__': + parser=argparse.ArgumentParser();parser.add_argument('--watchdog',nargs=3);parser.add_argument('--probe',type=int);args=parser.parse_args() + if args.watchdog:watchdog(int(args.watchdog[0]),args.watchdog[1],args.watchdog[2]) + elif args.probe:print(json.dumps(probe(args.probe))) + else:parser.error('Explicit fixture mode required') diff --git a/scripts/qualify-linux-dpkg.py b/scripts/qualify-linux-dpkg.py index a781a1e..be5e9e8 100644 --- a/scripts/qualify-linux-dpkg.py +++ b/scripts/qualify-linux-dpkg.py @@ -21,6 +21,10 @@ 'docs-linux.deb':{'bytes':11090764,'sha256':'e8c0028ea78322e5937becba766654f178e4dfbe1c9817af33e024d93b588311','binarySha256':'fd8bee37cb01a23aff850eb93096a6c8da7aef7ba4e62c548649399420c6849b'}, } +def quarantine(): + spec=importlib.util.spec_from_file_location('package_quarantine',Path(__file__).with_name('linux-package-quarantine.py')) + module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module);return module + def verify_legacy(directory): if directory.is_symlink() or not directory.is_dir():raise ValueError('Real legacy-debs directory required') @@ -82,6 +86,7 @@ def qualify(native,out,installer,hashes,result): native.command('dpkg','--compare-versions',version,'gt','0.1') receipts[name]=builder.build(out/name,hashes[name],version,packages/(name+'.deb')) native.command('dpkg','--compare-versions','0.0.2','gt','0.1',allowed=(1,)) + result['quarantine']=quarantine().begin(native,out,native.state(out)['port']) def install(name,allowed=(0,)): path=packages/(name+'.deb') assert hashlib.sha256(path.read_bytes()).hexdigest()==receipts[name]['sha256'] diff --git a/scripts/qualify-native-linux.py b/scripts/qualify-native-linux.py index 2020e94..7bc353c 100644 --- a/scripts/qualify-native-linux.py +++ b/scripts/qualify-native-linux.py @@ -151,13 +151,15 @@ def cleanup(out): os.kill(pid,signal.SIGTERM) else:raise RuntimeError('RECEIVER_IDENTITY_MISMATCH') except (FileNotFoundError,ProcessLookupError):pass + # Keep whole-runner quarantine through the final actual package hook. + if dpkg: + if dpkg.package_state(sys.modules[__name__])!='absent':command('dpkg','--purge',dpkg.PACKAGE) + dpkg.require_absent(sys.modules[__name__]) for rule in owned.get('firewall',[]): binary,args=rule[0],rule[1:] if command(binary,'-w','-C','OUTPUT',*args,allowed=(0,1)).returncode==0:command(binary,'-w','-D','OUTPUT',*args) assert command(binary,'-w','-C','OUTPUT',*args,allowed=(0,1)).returncode==1 - if dpkg: - if dpkg.package_state(sys.modules[__name__])!='absent':command('dpkg','--purge',dpkg.PACKAGE) - dpkg.require_absent(sys.modules[__name__]) + if dpkg:dpkg.quarantine().finish(owned) for path in managed: if path.is_dir():shutil.rmtree(path) elif path.exists() or path.is_symlink():path.unlink() diff --git a/scripts/test-linux-deb.py b/scripts/test-linux-deb.py index 6f5fdf2..6edda3b 100644 --- a/scripts/test-linux-deb.py +++ b/scripts/test-linux-deb.py @@ -22,6 +22,7 @@ def load(name,file): fixtures=load('deb_fixtures','test-stage-distribution.py') dpkg_fixture=load('deb_native_fixture','qualify-linux-dpkg.py') native=load('deb_native','qualify-native-linux.py') +quarantine=load('deb_quarantine','linux-package-quarantine.py') def ar_members(raw): @@ -167,6 +168,54 @@ def test_complete_transaction_is_resnapshotted_for_reconfigure(self): class NativeBoundaryTests(unittest.TestCase): + def test_quarantine_never_blocks_runner_without_ready_watchdog(self): + with tempfile.TemporaryDirectory() as directory: + out=Path(directory);(out/'owned.json').write_text(json.dumps({'firewall':[]})) + watcher=mock.Mock(pid=321) + with mock.patch.object(quarantine,'guard'),mock.patch.dict(os.environ,{'GITHUB_RUN_ID':'12345'}),mock.patch.object(quarantine,'command',return_value=mock.Mock(returncode=1)) as commands,mock.patch.object(quarantine.os,'getpgrp',return_value=os.getpid()),mock.patch.object(quarantine,'identity',return_value='original'),mock.patch.object(quarantine.subprocess,'Popen',return_value=watcher),mock.patch.object(quarantine.select,'select',return_value=([],[],[])): + with self.assertRaisesRegex(RuntimeError,'did not become ready'):quarantine.begin(native,out,1234) + self.assertFalse(any('-I' in call.args for call in commands.call_args_list)) + self.assertEqual(json.loads((out/'owned.json').read_text())['packageQuarantine']['watchdogPid'],321) + def test_quarantine_probe_rejects_success_and_timeout(self): + for outcome in [None,quarantine.socket.timeout('timed out')]: + connection=mock.MagicMock();connection.__enter__.return_value=connection;connection.connect.side_effect=outcome + with mock.patch.object(quarantine.socket,'create_connection'),mock.patch.object(quarantine.socket,'socket',return_value=connection): + with self.assertRaises(RuntimeError):quarantine.probe(1234) + def test_quarantine_rules_cover_root_and_all_nonloopback_protocols(self): + rules=quarantine.rules('12345') + self.assertEqual([r[0] for r in rules],['iptables','ip6tables']) + for rule in rules: + self.assertEqual(rule[1:4],['!','-o','lo']) + self.assertNotIn('--uid-owner',rule);self.assertNotIn('-p',rule) + self.assertIn('VMPOWER_PACKAGE_12345',rule) + for run in ['','abc','123;command']: + with self.assertRaises(ValueError):quarantine.rules(run) + def test_quarantine_refuses_mac_before_reading_ownership(self): + with mock.patch.object(quarantine.sys,'platform','darwin'): + with self.assertRaisesRegex(RuntimeError,'disposable'):quarantine.begin(native,Path('/not-used'),1234) + def test_watchdog_kills_owned_group_before_network_restore(self): + for current in ['original',None]: + events=[] + with mock.patch.object(quarantine,'guard'),mock.patch.object(quarantine,'identity',side_effect=['original',current]),mock.patch.object(quarantine.os,'getpgid',return_value=123),mock.patch.object(Path,'read_bytes',return_value=b'python\0/repo/scripts/qualify-native-linux.py\0--legacy-dpkg\0'),mock.patch.object(quarantine.time,'sleep'),mock.patch.object(quarantine.os,'killpg',side_effect=lambda *args:events.append(('kill',args))),mock.patch.object(quarantine,'remove_rules',side_effect=lambda run:events.append(('restore',run))): + quarantine.watchdog(123,'original','12345') + self.assertEqual(events,[('kill',(123,quarantine.signal.SIGKILL)),('restore','12345')]) + def test_watchdog_never_kills_reused_process_group(self): + with mock.patch.object(quarantine,'guard'),mock.patch.object(quarantine,'identity',side_effect=['original','new-process']),mock.patch.object(quarantine.os,'getpgid',return_value=123),mock.patch.object(Path,'read_bytes',return_value=b'python\0/repo/scripts/qualify-native-linux.py\0--legacy-dpkg\0'),mock.patch.object(quarantine.time,'sleep'),mock.patch.object(quarantine.os,'killpg') as kill,mock.patch.object(quarantine,'remove_rules') as restore: + quarantine.watchdog(123,'original','12345') + kill.assert_not_called();restore.assert_called_once_with('12345') + def test_watchdog_rejects_workflow_shell_group_before_wait(self): + with mock.patch.object(quarantine,'guard'),mock.patch.object(quarantine,'identity',return_value='original'),mock.patch.object(quarantine.os,'getpgid',return_value=122),mock.patch.object(Path,'read_bytes',return_value=b'python\0/repo/scripts/qualify-native-linux.py\0--legacy-dpkg\0'),mock.patch.object(quarantine.time,'sleep') as sleep: + with self.assertRaisesRegex(RuntimeError,'owner mismatch'):quarantine.watchdog(123,'original','12345') + sleep.assert_not_called() + def test_quarantine_cleanup_removes_only_exact_run_rules(self): + calls=[] + def command(*args,allowed=(0,)): + calls.append(args) + return mock.Mock(returncode=1 if args[2]=='-C' and sum(c==args for c in calls)>1 else 0) + with mock.patch.object(quarantine,'command',side_effect=command):quarantine.remove_rules('12345') + self.assertEqual(len(calls),6) + self.assertTrue(all('VMPOWER_PACKAGE_12345' in c for c in calls)) + self.assertTrue(all('-F' not in c and '-P' not in c for c in calls)) def test_optional_package_gate_rejects_non_host_before_any_package_operation(self): with mock.patch.object(native.sys,'platform','darwin'),mock.patch.object(native,'command',side_effect=AssertionError('No host operations')): with self.assertRaisesRegex(RuntimeError,'admitted ephemeral'):native.qualify(Path('/not-used'),True) From 69f4a5731e7e4d13a4a87aa2a3a2ae5f1cf5e8ae Mon Sep 17 00:00:00 2001 From: Spencer Albrecht Date: Mon, 26 Oct 2026 05:21:18 -0700 Subject: [PATCH 03/18] Prepare Windows Chocolatey ownership upgrade qualification --- packages/win32/node24/README.md | 105 +++++++++++++++ .../win32/node24/tools/chocolateyInstall.ps1 | 18 +++ .../node24/tools/chocolateyUninstall.ps1 | 6 + scripts/build-windows-package.py | 114 +++++++++++++++++ scripts/qualify-native-windows.ps1 | 13 +- scripts/qualify-windows-package-ownership.ps1 | 112 ++++++++++++++++ scripts/test-build-windows-package.py | 121 ++++++++++++++++++ 7 files changed, 488 insertions(+), 1 deletion(-) create mode 100644 packages/win32/node24/README.md create mode 100644 packages/win32/node24/tools/chocolateyInstall.ps1 create mode 100644 packages/win32/node24/tools/chocolateyUninstall.ps1 create mode 100644 scripts/build-windows-package.py create mode 100644 scripts/qualify-windows-package-ownership.ps1 create mode 100644 scripts/test-build-windows-package.py diff --git a/packages/win32/node24/README.md b/packages/win32/node24/README.md new file mode 100644 index 0000000..a027c80 --- /dev/null +++ b/packages/win32/node24/README.md @@ -0,0 +1,105 @@ +# Windows Chocolatey ownership handoff (prepared, not distributed) + +Use the existing `vmpower-agent` package ID with an explicit version greater than +`0.0.1`. A normal Chocolatey upgrade invokes the **new** install script after the +**old** before-modify script. It does not run the old uninstall script. Both +observed published packages lack a before-modify script; both old uninstall +scripts delete `C:\ProgramData\vmpower\vmpower.conf` and the old executable. +An uninstall-first migration would therefore destroy configuration unnecessarily. + +Primary references, checked 2026-10-26: + +- [Package script lifecycle](https://docs.chocolatey.org/docs/create-packages/) +- [Install script ordering](https://docs.chocolatey.org/en-us/chocolatey-install-ps1/) +- [Upgrade command](https://docs.chocolatey.org/en-us/choco/commands/upgrade/) +- [Rollback limitations](https://docs.chocolatey.org/en-us/why/) +- [Registration without scripts](https://docs.chocolatey.org/en-us/guides/usage/install-upgrade-package-without-scripts/) +- [Uninstall and cleanup flags](https://docs.chocolatey.org/en-us/choco/commands/uninstall/) + +The two cached, statically inspected published inputs are: + +| Input | SHA-256 | +| --- | --- | +| `docs-windows.nupkg` | `3b34023ffa2d05b54bdf10abfe9b3c515ab5dccf70af823407dcd1cb976a8956` | +| `dashboard-windows.nupkg` | `1e72a1db993a3b013b8503e8cde2f0492a8bcee18539ee9e1056c2eb203893af` | + +They share the ID, version, scripts and unbounded `nssm` dependency but contain +different executable bytes. Their scripts were inspected, never executed. + +## Package inputs + +`scripts/build-windows-package.py` prepares inputs for Chocolatey's own `pack` +command. It does not download, install, execute or publish anything. Supply an +already qualified `win-x64` bundle, its admitted manifest SHA-256, the exact Node +archive and NSSM archive named by existing distribution pins, an explicit package +version, and a new output directory: + +```text +python scripts/build-windows-package.py --bundle BUNDLE --expected-manifest-sha256 SHA256 --node-archive NODE_ZIP --nssm-archive NSSM_ZIP --version VERSION --output NEW_DIRECTORY +``` + +The builder verifies the sealed bundle, current source lock, pinned archives and +actual bundled Node bytes. It embeds the existing installer unchanged, pinned +NSSM, vendor license text and package scripts. There is no package dependency +fetch, new runtime format, signing scheme or service launcher. Chocolatey shim +markers stay outside the installed sealed runtime. The install script temporarily +removes only its package marker while the original bundle validator runs. + +The package is for upgrades with existing configuration. It accepts no secret +package parameters. Fresh enrollment still uses the existing bundle installer's +stdin entrypoint. The installer preserves configuration, service startup mode and +running/stopped state, retains content-addressed releases, and restores its prior +SCM snapshot when a new child fails. Its existing custom-identity/non-NSSM and +non-C-drive refusals remain in force. The new uninstall script removes the owned +service and retains configuration, releases, wrapper and old executable. + +Chocolatey's own rollback restores package files only; it does not restore files +outside the package folder or rerun an installer. Both package ownership rollback +and installer SCM rollback must pass the native fixture. Do not use a downgrade +to the old package as a rollback procedure: its old install script overwrites +configuration. The installer snapshot is an immediate failure recovery path, +not a general post-success rollback command. + +## Held native qualification + +The existing Windows qualifier accepts optional `-LegacyPackages DIRECTORY +-ChocolateyVersion EXACT_VERSION`. No workflow invokes this extension yet. +Root must first admit the exact candidate and existing Chocolatey 2.x runner +version. No Chocolatey install/update is performed by the extension. + +Use the existing disposable, elevated GitHub-hosted Windows 2025 fixture, empty +VMPower destinations, owned-resource journal, loopback receiver and executable +firewall rules. Stage only the two fixed-hash cached packages and already pinned +modern inputs. The job must have no customer/provider/package secrets and no +persisted checkout credential; any job token is read-only. Do not run on a +customer host, self-hosted runner or machine with existing agent registration. + +For each exact old package, the fixture registers the real bytes using +`--skip-automation-scripts --ignore-dependencies` and a local-only source. This is +**registration-only evidence**, not proof that the old software was installed or +works. The old executable and both old PowerShell scripts are never invoked. +The SCM fixture uses only the qualified Node runtime with synthetic configuration +and a loopback receiver. It then performs normal Chocolatey upgrades and the new +uninstall, without script-skipping, to test: + +- replacement of the actual `0.0.1` package registration; +- running/manual and stopped/disabled service preservation and configuration ACLs; +- a crashing modern child restoring prior package registration and service progress; +- new uninstall preserving configuration and retained runtime; +- removal of owned package registration, exact caches, legacy shim, service, + processes, firewall rules and fixture inputs. + +Versions `0.0.2` and crashing `0.0.3` in this fixture are test inputs and must never +be published. Cleanup skips all old automation and the auto-uninstaller, including +when upgrade failed while `0.0.1` remains registered. Exact package directories and +the legacy shim must be absent before ownership is recorded. A cleanup failure +fails qualification and retains evidence; the disposable host must be discarded. +No future test may execute unsupported old telemetry without a separately reviewed +quarantine design. This fixture intentionally avoids that need. + +Local Python tests validate pack inputs using generated non-executable byte +fixtures. They do not prove NuGet packing, PowerShell/SCM behavior, old runtime +compatibility, customer OS support, customer usage, signer trust or distribution +rights. Actual old runtime and older Windows compatibility remain **unqualified**. +Native packing, install/upgrade/rollback/uninstall and complete cleanup are release +gates; this source preparation grants no native execution or distribution authority. diff --git a/packages/win32/node24/tools/chocolateyInstall.ps1 b/packages/win32/node24/tools/chocolateyInstall.ps1 new file mode 100644 index 0000000..3f8e66b --- /dev/null +++ b/packages/win32/node24/tools/chocolateyInstall.ps1 @@ -0,0 +1,18 @@ +# Same package ID upgrades 0.0.1 without invoking its destructive uninstaller. +# No before-modify hook: the installer must observe the previous active state. +$ErrorActionPreference = 'Stop' +$inputRecord = Get-Content -Raw -LiteralPath (Join-Path $PSScriptRoot 'package-input.json') | ConvertFrom-Json +$bundle = Join-Path $PSScriptRoot 'bundle' +# Chocolatey shim suppression is package metadata, not a runtime bundle member. +$ignore = Join-Path $bundle 'runtime\node.exe.ignore' +if (Test-Path -LiteralPath $ignore) { Remove-Item -LiteralPath $ignore } +# This upgrade package accepts no credentials in package parameters or logs. +# Existing configuration is required; fresh enrollment uses the stdin installer. +try { + & (Join-Path $PSScriptRoot 'scripts\install-windows-bundle.ps1') -Apply ` + -Bundle $bundle -ExpectedManifestSha256 $inputRecord.bundleManifestSha256 ` + -NssmExe (Join-Path $PSScriptRoot 'nssm.exe') +} finally { + # Chocolatey scans executables after this script, including failed installs. + [IO.File]::WriteAllBytes($ignore, [byte[]]@()) +} diff --git a/packages/win32/node24/tools/chocolateyUninstall.ps1 b/packages/win32/node24/tools/chocolateyUninstall.ps1 new file mode 100644 index 0000000..b6f5b00 --- /dev/null +++ b/packages/win32/node24/tools/chocolateyUninstall.ps1 @@ -0,0 +1,6 @@ +$ErrorActionPreference = 'Stop' +# Repeated removal is harmless; never call the legacy uninstall script. +if (Get-Service -Name vmpoweragent -ErrorAction SilentlyContinue) { + & (Join-Path $PSScriptRoot 'scripts\install-windows-bundle.ps1') -Apply -Action Uninstall +} +# Configuration, content-addressed releases, wrapper and old executable remain. diff --git a/scripts/build-windows-package.py b/scripts/build-windows-package.py new file mode 100644 index 0000000..4591e1e --- /dev/null +++ b/scripts/build-windows-package.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +"""Prepare local Chocolatey pack inputs from an already qualified Windows bundle. + +No download, package-manager invocation or executable is run. Chocolatey's own +pack command creates the nupkg on the separately admitted Windows fixture. +""" +import argparse +import importlib.util +import json +from pathlib import Path +import re +import shutil +import tempfile +import xml.etree.ElementTree as ET +import zipfile + +ROOT = Path(__file__).resolve().parents[1] +spec = importlib.util.spec_from_file_location('agent_stage', ROOT / 'scripts/stage-distribution.py') +stage = importlib.util.module_from_spec(spec) +spec.loader.exec_module(stage) + + +def regular(path): + if path.is_symlink() or not path.is_file(): + raise ValueError('Expected a regular input file') + return path.read_bytes() + + +def prepare(bundle, expected, node_archive, nssm_archive, version, output, repo=ROOT): + if not re.fullmatch(r'[a-f0-9]{64}', expected): + raise ValueError('Qualified manifest SHA256 required') + if not re.fullmatch(r'(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)', version): + raise ValueError('An explicit three-part package version is required') + if tuple(map(int, version.split('.'))) <= (0, 0, 1): + raise ValueError('Package must upgrade published 0.0.1') + if output.exists() or output.is_symlink(): + raise ValueError('Output exists; retain previous package inputs') + if bundle.is_symlink() or not bundle.is_dir(): + raise ValueError('Expected an unlinked bundle directory') + if stage.digest(regular(bundle / 'bundle-manifest.json')) != expected: + raise ValueError('Bundle manifest hash mismatch') + manifest = stage.verify_bundle(bundle) + runtime_pins = json.loads(regular(repo / 'distribution/runtime-pins.json')) + if manifest['target'] != 'win-x64' or manifest['nodeVersion'] != runtime_pins['nodeVersion']: + raise ValueError('Expected pinned Windows x64 bundle') + if manifest['runtimeArchiveSha256'] != runtime_pins['targets']['win-x64']['sha256']: + raise ValueError('Runtime provenance mismatch') + if manifest['sourceLockSha256'] != stage.digest(regular(repo / 'package-lock.json')): + raise ValueError('Bundle dependency lock differs from this source') + regular(node_archive) + runtime = stage.runtime_files(node_archive, 'win-x64', runtime_pins) + for name, raw in runtime.items(): + if regular(bundle / 'runtime' / name) != raw: + raise ValueError('Bundle runtime differs from official pinned archive') + pins = json.loads(regular(repo / 'distribution/nssm-pins.json')) + if stage.digest(regular(nssm_archive)) != pins['archiveSha256']: + raise ValueError('NSSM archive hash mismatch') + with zipfile.ZipFile(nssm_archive) as archive: + wrapper = archive.read(pins['x64Member']) + license_bytes = archive.read('nssm-' + pins['version'] + '/README.txt') + if stage.digest(wrapper) != pins['x64Sha256'] or b'NSSM is public domain' not in license_bytes: + raise ValueError('NSSM executable or licence mismatch') + output.parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix='.windows-package-', dir=output.parent) as temp: + root = Path(temp) / 'package' + tools = root / 'tools' + tools.mkdir(parents=True) + shutil.copytree(bundle, tools / 'bundle') + stage.verify_bundle(tools / 'bundle') + if stage.digest(regular(tools / 'bundle/bundle-manifest.json')) != expected: + raise ValueError('Bundle changed while preparing package inputs') + # Do not expose bundled node or NSSM as global Chocolatey shims. + # Ignore markers live outside the sealed bundle; copy node's marker into + # the package file mapping separately, not into its verified manifest. + (tools / 'nssm.exe').write_bytes(wrapper) + (tools / 'nssm.exe.ignore').write_bytes(b'') + (tools / 'node.exe.ignore').write_bytes(b'') + (tools / 'NSSM-LICENSE.txt').write_bytes(license_bytes) + for name in ('chocolateyInstall.ps1', 'chocolateyUninstall.ps1'): + (tools / name).write_bytes(regular(repo / 'packages/win32/node24/tools' / name)) + for relative in ('scripts/install-windows-bundle.ps1', 'distribution/nssm-pins.json'): + target = tools / relative + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(regular(repo / relative)) + (tools / 'package-input.json').write_text(json.dumps({ + 'packageVersion': version, 'bundleManifestSha256': expected, + 'nodeArchiveSha256': manifest['runtimeArchiveSha256'], + 'nssmArchiveSha256': pins['archiveSha256'], 'nssmExeSha256': pins['x64Sha256'] + }, indent=2) + '\n') + package = ET.Element('package', xmlns='http://schemas.microsoft.com/packaging/2015/06/nuspec.xsd') + metadata = ET.SubElement(package, 'metadata') + for key, value in [('id', 'vmpower-agent'), ('version', version), ('authors', 'VMPower'), + ('description', 'Offline Node 24 VMPower agent upgrade; preserves existing configuration.'), + ('requireLicenseAcceptance', 'false'), ('projectUrl', 'https://github.com/vmpowerio/agent')]: + ET.SubElement(metadata, key).text = value + # No unversioned nssm dependency: the verified wrapper is embedded. + files = ET.SubElement(package, 'files') + ET.SubElement(files, 'file', src='tools\\**', target='tools', exclude='tools\\node.exe.ignore') + ET.SubElement(files, 'file', src='tools\\node.exe.ignore', target='tools\\bundle\\runtime') + ET.ElementTree(package).write(root / 'vmpower-agent.nuspec', encoding='utf-8', xml_declaration=True) + root.rename(output) + return {'state': 'PACK_INPUTS_ONLY', 'version': version, 'bundleManifestSha256': expected, + 'nativePackageManagerQualified': False, 'output': str(output)} + + +if __name__ == '__main__': + p = argparse.ArgumentParser(description=__doc__) + for flag in ('bundle', 'node-archive', 'nssm-archive', 'output'): + p.add_argument('--' + flag, type=Path, required=True) + p.add_argument('--expected-manifest-sha256', required=True) + p.add_argument('--version', required=True) + a = p.parse_args() + print(json.dumps(prepare(a.bundle, a.expected_manifest_sha256, a.node_archive, + a.nssm_archive, a.version, a.output))) diff --git a/scripts/qualify-native-windows.ps1 b/scripts/qualify-native-windows.ps1 index 900daad..7a66251 100644 --- a/scripts/qualify-native-windows.ps1 +++ b/scripts/qualify-native-windows.ps1 @@ -1,4 +1,5 @@ -param([Parameter(Mandatory=$true)][string]$Output,[switch]$CleanupOnly) +param([Parameter(Mandatory=$true)][string]$Output,[switch]$CleanupOnly, + [string]$LegacyPackages,[string]$ChocolateyVersion) Set-StrictMode -Version Latest $ErrorActionPreference='Stop' function Assert-Hosted { @@ -8,6 +9,7 @@ function Assert-Hosted { } Assert-Hosted . (Join-Path $PSScriptRoot 'install-windows-bundle.ps1') +. (Join-Path $PSScriptRoot 'qualify-windows-package-ownership.ps1') $Output=[IO.Path]::GetFullPath($Output) if ($Output -ine [IO.Path]::GetFullPath((Join-Path $env:RUNNER_TEMP 'vmpower-native'))) { throw 'Fixed runner fixture directory required' } $product=Join-Path $env:ProgramFiles 'VMPower';$configDir='C:\ProgramData\vmpower';$config=Join-Path $configDir 'vmpower.conf' @@ -74,6 +76,12 @@ function Cleanup-Owned { $null=Wait-Condition { -not (Get-CimInstance Win32_Service -Filter "Name='vmpoweragent'") } Assert-True (-not (Test-Path -LiteralPath $product) -and -not (Test-Path -LiteralPath $configDir)) 'Managed path cleanup failed' Assert-True (-not $owned.receiverPid -or -not (Get-Process -Id $owned.receiverPid -ErrorAction SilentlyContinue)) 'Receiver cleanup failed' + if ($owned.PSObject.Properties['chocolatey']) { + # Clean processes and firewall first, even if package cleanup then fails. + # Never execute the retained legacy uninstall script during cleanup. + $ownedTable=@{chocolatey=$owned.chocolatey;chocolateyDirectories=$owned.chocolateyDirectories;chocolateyShim=$owned.chocolateyShim} + & { $owned=$ownedTable;Remove-OwnedChocolateyRegistration } + } foreach ($entry in Get-ChildItem -LiteralPath $Output -Force) { if ($entry.Name -notin @('evidence','owned-windows.json')) { Remove-Item -LiteralPath $entry.FullName -Recurse -Force } } @@ -186,6 +194,9 @@ try { $null=Uninstall-AgentBundle $null=Wait-Condition { -not (Get-CimInstance Win32_Service -Filter "Name='vmpoweragent'") } $result.phases+=@(@{phase='synthetic-legacy-NSSM-migration';status='PASS';legacyPublishedBinary='UNRUN'}) + if ($LegacyPackages) { + $result.phases+=@(Invoke-WindowsPackageOwnership $LegacyPackages $ChocolateyVersion) + } $result.status='PASS' } finally { try { $result.cleanup=Cleanup-Owned } catch { $result.status='FAIL';$result.cleanup=@{status='FAIL'};throw } diff --git a/scripts/qualify-windows-package-ownership.ps1 b/scripts/qualify-windows-package-ownership.ps1 new file mode 100644 index 0000000..a7e701d --- /dev/null +++ b/scripts/qualify-windows-package-ownership.ps1 @@ -0,0 +1,112 @@ +# Opt-in extension of the existing isolated Windows lifecycle fixture. Never run +# standalone: it reuses that fixture's receiver, firewall, journal and cleanup. +function Get-OwnedChocolateyVersion { + $line=Invoke-CheckedNative $script:chocolatey @('list','--exact','vmpower-agent','--limit-output') + if ([string]::IsNullOrWhiteSpace($line)) { return $null } + if ($line -notmatch '^vmpower-agent\|(0\.0\.[123])$') { throw 'Unexpected package registration; preserve for inspection' } + return $Matches[1] +} + +function Remove-OwnedChocolateyRegistration { + if (-not $owned.ContainsKey('chocolatey') -or -not $owned.chocolatey) { return } + $script:chocolatey=$owned.chocolatey + $version=Get-OwnedChocolateyVersion + if ($version) { + # Cleanup must NEVER execute the retained 0.0.1 uninstall script. + $null=Invoke-CheckedNative $script:chocolatey @('uninstall','vmpower-agent',('--version='+$version), + '--yes','--skip-automation-scripts','--skip-autouninstaller','--limit-output') + } + Assert-True (-not (Get-OwnedChocolateyVersion)) 'Owned package registration survived cleanup' + # Exact directories were proved absent before setting journal ownership. + foreach ($directory in $owned.chocolateyDirectories) { + Assert-NoReparsePoint $directory + if (Test-Path -LiteralPath $directory) { + foreach ($entry in Get-ChildItem -LiteralPath $directory -Recurse -Force) { Assert-NoReparsePoint $entry.FullName } + Remove-Item -LiteralPath $directory -Recurse -Force + } + Assert-True (-not (Test-Path -LiteralPath $directory)) 'Owned package cache survived cleanup' + } + Assert-True (-not (Test-Path -LiteralPath $owned.chocolateyShim)) 'Owned legacy shim survived package cleanup' +} + +function Invoke-WindowsPackageOwnership([string]$LegacyPackages,[string]$ExpectedChocolateyVersion) { + Assert-Hosted + if ($ExpectedChocolateyVersion -notmatch '^2\.[0-9]+\.[0-9]+$') { throw 'Explicit reviewed Chocolatey 2.x version required' } + $script:chocolatey=Join-Path $env:ChocolateyInstall 'bin\choco.exe' + Assert-NoReparsePoint $script:chocolatey + Assert-True ((Invoke-CheckedNative $script:chocolatey @('--version')) -ceq $ExpectedChocolateyVersion) 'Chocolatey version differs from admission' + Assert-True (-not (Get-OwnedChocolateyVersion)) 'Package already registered; not an empty fixture' + $directories=@('lib\vmpower-agent','lib-bad\vmpower-agent','lib-bkp\vmpower-agent') | ForEach-Object { Join-Path $env:ChocolateyInstall $_ } + foreach ($directory in $directories) { Assert-NoReparsePoint $directory;Assert-True (-not (Test-Path -LiteralPath $directory)) 'Package directory already exists' } + $shim=Join-Path $env:ChocolateyInstall 'bin\vmpower-agent.amd64.exe' + Assert-NoReparsePoint $shim;Assert-True (-not (Test-Path -LiteralPath $shim)) 'Legacy shim already exists' + $owned.chocolatey=$script:chocolatey;$owned.chocolateyDirectories=@($directories);$owned.chocolateyShim=$shim;Save-Json $journal $owned + $packages=Join-Path $Output 'chocolatey-ownership';$null=New-Item -ItemType Directory -Path $packages + $runtimePins=Get-Content -Raw (Join-Path $PSScriptRoot '../distribution/runtime-pins.json') | ConvertFrom-Json + $nodeArchive=Join-Path $Output $runtimePins.targets.'win-x64'.archive + foreach ($variant in @(@{name='upgrade';version='0.0.2'},@{name='crashing';version='0.0.3'})) { + $inputs=Join-Path $packages $variant.name + $buildArguments=@((Join-Path $PSScriptRoot 'build-windows-package.py'),'--bundle',(Join-Path $Output $variant.name), + '--expected-manifest-sha256',$prepared.bundles.($variant.name),'--node-archive',$nodeArchive, + '--nssm-archive',(Join-Path $Output 'nssm.zip'),'--version',$variant.version,'--output',$inputs) + $null=Invoke-CheckedNative (Get-Command python).Source $buildArguments + $null=Invoke-CheckedNative $script:chocolatey @('pack',(Join-Path $inputs 'vmpower-agent.nuspec'),('--output-directory='+$packages),'--limit-output') + } + $families=@( + @{name='docs-windows.nupkg';sha='3b34023ffa2d05b54bdf10abfe9b3c515ab5dccf70af823407dcd1cb976a8956'}, + @{name='dashboard-windows.nupkg';sha='1e72a1db993a3b013b8503e8cde2f0492a8bcee18539ee9e1056c2eb203893af'}) + $proof=@() + foreach ($family in $families) { + Assert-True (-not (Get-CimInstance Win32_Service -Filter "Name='vmpoweragent'")) 'Service must be absent between package cases' + $legacy=Join-Path $LegacyPackages $family.name;Assert-NoReparsePoint $legacy + Assert-True ((Get-FileHash -LiteralPath $legacy -Algorithm SHA256).Hash.ToLowerInvariant() -ceq $family.sha) 'Legacy package family hash mismatch' + $localPackage=Join-Path $packages 'vmpower-agent.0.0.1.nupkg' + Copy-Item -LiteralPath $legacy -Destination $localPackage + # Actual package-manager registration of exact published bytes. Do not run + # unsupported telemetry or its destructive installer; no dependency fetch. + $null=Invoke-CheckedNative $script:chocolatey @('install','vmpower-agent','--version=0.0.1',('--source='+$packages), + '--yes','--skip-automation-scripts','--ignore-dependencies','--limit-output') + Assert-True ((Get-OwnedChocolateyVersion) -ceq '0.0.1') 'Legacy ownership not registered' + Assert-True (-not (Get-CimInstance Win32_Service -Filter "Name='vmpoweragent'")) 'Skipped legacy install created a service' + # Reuse only qualified modern Node bytes for the legacy SCM contract. + $null=Invoke-CheckedNative $wrapper @('install','vmpoweragent',$node) + $null=Invoke-CheckedNative $wrapper @('set','vmpoweragent','AppDirectory',$configDir) + $null=Invoke-CheckedNative $wrapper @('set','vmpoweragent','AppParameters',('"'+(Join-Path $Output 'bundle\app\index.js')+'"')) + $disabled=$family.name -eq 'dashboard-windows.nupkg' + if ($disabled) { Set-Service -Name vmpoweragent -StartupType Disabled } + else { + Set-Service -Name vmpoweragent -StartupType Manual + $count=(Receiver-State).count;Start-Service -Name vmpoweragent + $null=Wait-Condition { (Receiver-State).count -gt $count } + } + $configDigest=(Get-FileHash -LiteralPath $config -Algorithm SHA256).Hash + $count=(Receiver-State).count + $null=Invoke-CheckedNative $script:chocolatey @('upgrade','vmpower-agent','--version=0.0.2',('--source='+$packages),'--yes','--limit-output') + Assert-True ((Get-OwnedChocolateyVersion) -ceq '0.0.2') 'New package did not replace legacy registration' + $modern=Join-Path $product ('Agent\releases\'+$prepared.bundles.upgrade+'\runtime\node.exe') + $service=Get-CimInstance Win32_Service -Filter "Name='vmpoweragent'" + if ($disabled) { + Assert-True ($service.State -eq 'Stopped' -and $service.StartMode -eq 'Disabled') 'Package upgrade changed stopped/disabled state' + Set-Service -Name vmpoweragent -StartupType Manual;Start-Service -Name vmpoweragent + } else { Assert-True ($service.State -eq 'Running' -and $service.StartMode -eq 'Manual') 'Package upgrade changed active/manual state' } + $null=Wait-Condition { (Receiver-State).count -gt $count };$null=Service-Child $modern + Assert-True ((Get-FileHash -LiteralPath $config -Algorithm SHA256).Hash -ceq $configDigest) 'Chocolatey upgrade changed configuration' + Assert-Acl $config $false + $failed=$false;$count=(Receiver-State).count + try { $null=Invoke-CheckedNative $script:chocolatey @('upgrade','vmpower-agent','--version=0.0.3',('--source='+$packages),'--yes','--limit-output') } catch { $failed=$true } + Assert-True $failed 'Crashing package upgrade was accepted' + $null=Wait-Condition { (Receiver-State).count -gt $count };$null=Service-Child $modern + Assert-True ((Get-OwnedChocolateyVersion) -ceq '0.0.2') 'Failed package did not restore prior registration' + Assert-True ((Get-FileHash -LiteralPath $config -Algorithm SHA256).Hash -ceq $configDigest) 'Package rollback changed configuration' + # Run the NEW uninstaller normally, proving old destructive script is gone. + $null=Invoke-CheckedNative $script:chocolatey @('uninstall','vmpower-agent','--yes','--limit-output') + Assert-True (-not (Get-OwnedChocolateyVersion)) 'Modern package registration remains' + $null=Wait-Condition { -not (Get-CimInstance Win32_Service -Filter "Name='vmpoweragent'") } + Assert-True ((Get-FileHash -LiteralPath $config -Algorithm SHA256).Hash -ceq $configDigest) 'Modern package uninstall deleted configuration' + Assert-True (Test-Path -LiteralPath $modern) 'Package uninstall deleted rollback runtime' + $proof+=@(@{family=$family.name;packageSha256=$family.sha;registrationUpgrade='PASS';previousState=$(if ($disabled) {'Stopped/Disabled'} else {'Running/Manual'});serviceRollback='PASS';uninstallPreservesConfig='PASS';legacyPublishedExecutable='UNRUN'}) + Remove-OwnedChocolateyRegistration + Remove-Item -LiteralPath $localPackage + } + return @{phase='real-chocolatey-ownership-synthetic-service';status='PASS';chocolateyVersion=$ExpectedChocolateyVersion;families=$proof;oldRuntimeCompatibility='UNQUALIFIED'} +} diff --git a/scripts/test-build-windows-package.py b/scripts/test-build-windows-package.py new file mode 100644 index 0000000..f8b0bd9 --- /dev/null +++ b/scripts/test-build-windows-package.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +"""Offline pack-input tests with generated, non-executable vendor byte fixtures.""" +import importlib.util +import json +from pathlib import Path +import shutil +import struct +import tempfile +import unittest +import xml.etree.ElementTree as ET +import zipfile + +ROOT = Path(__file__).resolve().parents[1] +spec = importlib.util.spec_from_file_location('windows_package', ROOT / 'scripts/build-windows-package.py') +package = importlib.util.module_from_spec(spec) +spec.loader.exec_module(package) +digest = package.stage.digest + + +class WindowsPackageInputs(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix='windows-package-control-') + self.root = Path(self.temp.name) + self.repo = self.root / 'repo' + self.bundle = self.root / 'bundle' + for relative in ('distribution', 'scripts', 'packages/win32/node24/tools'): + (self.repo / relative).mkdir(parents=True) + for relative in ('scripts/install-windows-bundle.ps1', + 'packages/win32/node24/tools/chocolateyInstall.ps1', + 'packages/win32/node24/tools/chocolateyUninstall.ps1'): + shutil.copyfile(ROOT / relative, self.repo / relative) + (self.repo / 'package-lock.json').write_bytes(b'fixture lock, not a dependency graph') + # Enough PE structure for the actual shared runtime validator, never run. + node = bytearray(80) + node[:2] = b'MZ'; struct.pack_into(' Date: Mon, 26 Oct 2026 08:49:11 -0700 Subject: [PATCH 04/18] Bind Windows ownership qualification to packed payloads --- packages/win32/node24/README.md | 18 ++++- scripts/qualify-windows-package-ownership.ps1 | 59 ++++++++++++--- scripts/test-build-windows-package.py | 40 +++++++++++ scripts/verify-windows-package.py | 71 +++++++++++++++++++ 4 files changed, 175 insertions(+), 13 deletions(-) create mode 100644 scripts/verify-windows-package.py diff --git a/packages/win32/node24/README.md b/packages/win32/node24/README.md index a027c80..cb01731 100644 --- a/packages/win32/node24/README.md +++ b/packages/win32/node24/README.md @@ -90,7 +90,11 @@ uninstall, without script-skipping, to test: processes, firewall rules and fixture inputs. Versions `0.0.2` and crashing `0.0.3` in this fixture are test inputs and must never -be published. Cleanup skips all old automation and the auto-uninstaller, including +be published by qualification. The successful `0.0.2` archive contains the +unmodified product `bundle`, not the synthetic comment-bearing `upgrade` bundle; +the default lifecycle fixture still checks that separate synthetic upgrade. +A final version and retained deliverable artifact require separate root admission. +Cleanup skips all old automation and the auto-uninstaller, including when upgrade failed while `0.0.1` remains registered. Exact package directories and the legacy shim must be absent before ownership is recorded. A cleanup failure fails qualification and retains evidence; the disposable host must be discarded. @@ -98,7 +102,17 @@ No future test may execute unsupported old telemetry without a separately review quarantine design. This fixture intentionally avoids that need. Local Python tests validate pack inputs using generated non-executable byte -fixtures. They do not prove NuGet packing, PowerShell/SCM behavior, old runtime +fixtures. The native extension additionally runs `verify-windows-package.py` +against each actual `choco pack` archive before registration. It checks all +payload bytes, same-ID/version metadata, no dependencies or extra payload, and +the actual Node/NSSM ignore-marker mapping, then records each nupkg SHA-256. +Offline archive controls use generated ZIP fixtures, not Chocolatey itself. +Each Chocolatey transaction has a 180-second watchdog; expiry kills its process +tree and fails qualification. A failing-child control must observe the existing +installer's fixed recovery classification as well as restored package/service +state, so a package lock, fetch error or timeout cannot count as rollback proof. +The outer Windows job remains bounded at 20 minutes; no automatic retry is added. +These checks do not prove NuGet packing, PowerShell/SCM behavior, old runtime compatibility, customer OS support, customer usage, signer trust or distribution rights. Actual old runtime and older Windows compatibility remain **unqualified**. Native packing, install/upgrade/rollback/uninstall and complete cleanup are release diff --git a/scripts/qualify-windows-package-ownership.ps1 b/scripts/qualify-windows-package-ownership.ps1 index a7e701d..2ab9e74 100644 --- a/scripts/qualify-windows-package-ownership.ps1 +++ b/scripts/qualify-windows-package-ownership.ps1 @@ -1,7 +1,35 @@ # Opt-in extension of the existing isolated Windows lifecycle fixture. Never run # standalone: it reuses that fixture's receiver, firewall, journal and cleanup. +function Invoke-OwnedChocolatey([string[]]$Arguments) { + # Package extraction, ACL work and failing-child recovery can exceed the + # installer's individual 30-second native-operation limit. Bound the whole + # package transaction separately; timeout is never rollback qualification. + $script:packageRecoveryObserved=$false + $process=New-Object Diagnostics.Process + $process.StartInfo.FileName=$script:chocolatey + $process.StartInfo.Arguments=($Arguments | ForEach-Object { Quote-NativeArgument $_ }) -join ' ' + $process.StartInfo.UseShellExecute=$false;$process.StartInfo.CreateNoWindow=$true + $process.StartInfo.RedirectStandardOutput=$true;$process.StartInfo.RedirectStandardError=$true + try { + Assert-True ($process.Start()) 'Chocolatey did not start' + $stdout=$process.StandardOutput.ReadToEndAsync();$stderr=$process.StandardError.ReadToEndAsync() + if (-not $process.WaitForExit(180000)) { + $script:packageTimedOut=$true + $null=Invoke-CheckedNative 'taskkill.exe' @('/PID',[string]$process.Id,'/T','/F') + throw 'Chocolatey transaction timed out; native qualification failed' + } + if ($process.ExitCode -ne 0) { + # Distinguish the intended crashing-child rollback from download, + # lock, command-line or package-validation failures. Keep raw logs private. + $script:packageRecoveryObserved=($stdout.GetAwaiter().GetResult()+$stderr.GetAwaiter().GetResult()).Contains('Install failed; previous service restored, configuration and all releases retained') + throw 'Chocolatey transaction failed' + } + return $stdout.GetAwaiter().GetResult().TrimEnd([char[]]"`r`n") + } finally { $process.Dispose() } +} + function Get-OwnedChocolateyVersion { - $line=Invoke-CheckedNative $script:chocolatey @('list','--exact','vmpower-agent','--limit-output') + $line=Invoke-OwnedChocolatey @('list','--exact','vmpower-agent','--limit-output') if ([string]::IsNullOrWhiteSpace($line)) { return $null } if ($line -notmatch '^vmpower-agent\|(0\.0\.[123])$') { throw 'Unexpected package registration; preserve for inspection' } return $Matches[1] @@ -13,7 +41,7 @@ function Remove-OwnedChocolateyRegistration { $version=Get-OwnedChocolateyVersion if ($version) { # Cleanup must NEVER execute the retained 0.0.1 uninstall script. - $null=Invoke-CheckedNative $script:chocolatey @('uninstall','vmpower-agent',('--version='+$version), + $null=Invoke-OwnedChocolatey @('uninstall','vmpower-agent',('--version='+$version), '--yes','--skip-automation-scripts','--skip-autouninstaller','--limit-output') } Assert-True (-not (Get-OwnedChocolateyVersion)) 'Owned package registration survived cleanup' @@ -31,10 +59,11 @@ function Remove-OwnedChocolateyRegistration { function Invoke-WindowsPackageOwnership([string]$LegacyPackages,[string]$ExpectedChocolateyVersion) { Assert-Hosted + $script:packageTimedOut=$false if ($ExpectedChocolateyVersion -notmatch '^2\.[0-9]+\.[0-9]+$') { throw 'Explicit reviewed Chocolatey 2.x version required' } $script:chocolatey=Join-Path $env:ChocolateyInstall 'bin\choco.exe' Assert-NoReparsePoint $script:chocolatey - Assert-True ((Invoke-CheckedNative $script:chocolatey @('--version')) -ceq $ExpectedChocolateyVersion) 'Chocolatey version differs from admission' + Assert-True ((Invoke-OwnedChocolatey @('--version')) -ceq $ExpectedChocolateyVersion) 'Chocolatey version differs from admission' Assert-True (-not (Get-OwnedChocolateyVersion)) 'Package already registered; not an empty fixture' $directories=@('lib\vmpower-agent','lib-bad\vmpower-agent','lib-bkp\vmpower-agent') | ForEach-Object { Join-Path $env:ChocolateyInstall $_ } foreach ($directory in $directories) { Assert-NoReparsePoint $directory;Assert-True (-not (Test-Path -LiteralPath $directory)) 'Package directory already exists' } @@ -44,13 +73,19 @@ function Invoke-WindowsPackageOwnership([string]$LegacyPackages,[string]$Expecte $packages=Join-Path $Output 'chocolatey-ownership';$null=New-Item -ItemType Directory -Path $packages $runtimePins=Get-Content -Raw (Join-Path $PSScriptRoot '../distribution/runtime-pins.json') | ConvertFrom-Json $nodeArchive=Join-Path $Output $runtimePins.targets.'win-x64'.archive - foreach ($variant in @(@{name='upgrade';version='0.0.2'},@{name='crashing';version='0.0.3'})) { + $packedProof=@() + # Successful ownership uses the unmodified product bundle; only the failing + # successor is synthetic. Final release/version admission remains separate. + foreach ($variant in @(@{name='bundle';version='0.0.2'},@{name='crashing';version='0.0.3'})) { $inputs=Join-Path $packages $variant.name $buildArguments=@((Join-Path $PSScriptRoot 'build-windows-package.py'),'--bundle',(Join-Path $Output $variant.name), '--expected-manifest-sha256',$prepared.bundles.($variant.name),'--node-archive',$nodeArchive, '--nssm-archive',(Join-Path $Output 'nssm.zip'),'--version',$variant.version,'--output',$inputs) $null=Invoke-CheckedNative (Get-Command python).Source $buildArguments - $null=Invoke-CheckedNative $script:chocolatey @('pack',(Join-Path $inputs 'vmpower-agent.nuspec'),('--output-directory='+$packages),'--limit-output') + $null=Invoke-OwnedChocolatey @('pack',(Join-Path $inputs 'vmpower-agent.nuspec'),('--output-directory='+$packages),'--limit-output') + $packed=Join-Path $packages ('vmpower-agent.'+$variant.version+'.nupkg') + $verified=Invoke-CheckedNative (Get-Command python).Source @((Join-Path $PSScriptRoot 'verify-windows-package.py'),'--inputs',$inputs,'--package',$packed) + $packedProof+=@($verified | ConvertFrom-Json) } $families=@( @{name='docs-windows.nupkg';sha='3b34023ffa2d05b54bdf10abfe9b3c515ab5dccf70af823407dcd1cb976a8956'}, @@ -64,7 +99,7 @@ function Invoke-WindowsPackageOwnership([string]$LegacyPackages,[string]$Expecte Copy-Item -LiteralPath $legacy -Destination $localPackage # Actual package-manager registration of exact published bytes. Do not run # unsupported telemetry or its destructive installer; no dependency fetch. - $null=Invoke-CheckedNative $script:chocolatey @('install','vmpower-agent','--version=0.0.1',('--source='+$packages), + $null=Invoke-OwnedChocolatey @('install','vmpower-agent','--version=0.0.1',('--source='+$packages), '--yes','--skip-automation-scripts','--ignore-dependencies','--limit-output') Assert-True ((Get-OwnedChocolateyVersion) -ceq '0.0.1') 'Legacy ownership not registered' Assert-True (-not (Get-CimInstance Win32_Service -Filter "Name='vmpoweragent'")) 'Skipped legacy install created a service' @@ -81,9 +116,9 @@ function Invoke-WindowsPackageOwnership([string]$LegacyPackages,[string]$Expecte } $configDigest=(Get-FileHash -LiteralPath $config -Algorithm SHA256).Hash $count=(Receiver-State).count - $null=Invoke-CheckedNative $script:chocolatey @('upgrade','vmpower-agent','--version=0.0.2',('--source='+$packages),'--yes','--limit-output') + $null=Invoke-OwnedChocolatey @('upgrade','vmpower-agent','--version=0.0.2',('--source='+$packages),'--yes','--limit-output') Assert-True ((Get-OwnedChocolateyVersion) -ceq '0.0.2') 'New package did not replace legacy registration' - $modern=Join-Path $product ('Agent\releases\'+$prepared.bundles.upgrade+'\runtime\node.exe') + $modern=Join-Path $product ('Agent\releases\'+$prepared.bundles.bundle+'\runtime\node.exe') $service=Get-CimInstance Win32_Service -Filter "Name='vmpoweragent'" if ($disabled) { Assert-True ($service.State -eq 'Stopped' -and $service.StartMode -eq 'Disabled') 'Package upgrade changed stopped/disabled state' @@ -93,13 +128,15 @@ function Invoke-WindowsPackageOwnership([string]$LegacyPackages,[string]$Expecte Assert-True ((Get-FileHash -LiteralPath $config -Algorithm SHA256).Hash -ceq $configDigest) 'Chocolatey upgrade changed configuration' Assert-Acl $config $false $failed=$false;$count=(Receiver-State).count - try { $null=Invoke-CheckedNative $script:chocolatey @('upgrade','vmpower-agent','--version=0.0.3',('--source='+$packages),'--yes','--limit-output') } catch { $failed=$true } + try { $null=Invoke-OwnedChocolatey @('upgrade','vmpower-agent','--version=0.0.3',('--source='+$packages),'--yes','--limit-output') } catch { $failed=$true } + Assert-True (-not $script:packageTimedOut) 'Timed-out package is not a valid rollback control' Assert-True $failed 'Crashing package upgrade was accepted' + Assert-True $script:packageRecoveryObserved 'Failure did not reach installer service recovery' $null=Wait-Condition { (Receiver-State).count -gt $count };$null=Service-Child $modern Assert-True ((Get-OwnedChocolateyVersion) -ceq '0.0.2') 'Failed package did not restore prior registration' Assert-True ((Get-FileHash -LiteralPath $config -Algorithm SHA256).Hash -ceq $configDigest) 'Package rollback changed configuration' # Run the NEW uninstaller normally, proving old destructive script is gone. - $null=Invoke-CheckedNative $script:chocolatey @('uninstall','vmpower-agent','--yes','--limit-output') + $null=Invoke-OwnedChocolatey @('uninstall','vmpower-agent','--yes','--limit-output') Assert-True (-not (Get-OwnedChocolateyVersion)) 'Modern package registration remains' $null=Wait-Condition { -not (Get-CimInstance Win32_Service -Filter "Name='vmpoweragent'") } Assert-True ((Get-FileHash -LiteralPath $config -Algorithm SHA256).Hash -ceq $configDigest) 'Modern package uninstall deleted configuration' @@ -108,5 +145,5 @@ function Invoke-WindowsPackageOwnership([string]$LegacyPackages,[string]$Expecte Remove-OwnedChocolateyRegistration Remove-Item -LiteralPath $localPackage } - return @{phase='real-chocolatey-ownership-synthetic-service';status='PASS';chocolateyVersion=$ExpectedChocolateyVersion;families=$proof;oldRuntimeCompatibility='UNQUALIFIED'} + return @{phase='real-chocolatey-ownership-synthetic-service';status='PASS';chocolateyVersion=$ExpectedChocolateyVersion;packages=$packedProof;families=$proof;oldRuntimeCompatibility='UNQUALIFIED'} } diff --git a/scripts/test-build-windows-package.py b/scripts/test-build-windows-package.py index f8b0bd9..baf4a63 100644 --- a/scripts/test-build-windows-package.py +++ b/scripts/test-build-windows-package.py @@ -15,6 +15,9 @@ package = importlib.util.module_from_spec(spec) spec.loader.exec_module(package) digest = package.stage.digest +verify_spec = importlib.util.spec_from_file_location('verify_windows_package', ROOT / 'scripts/verify-windows-package.py') +verification = importlib.util.module_from_spec(verify_spec) +verify_spec.loader.exec_module(verification) class WindowsPackageInputs(unittest.TestCase): @@ -116,6 +119,43 @@ def test_foreign_lock_and_linked_bundle_rejected(self): self.bundle = link with self.assertRaisesRegex(ValueError, 'unlinked'): self.prepare() + def packed_fixture(self, mutate=None): + members = {'vmpower-agent.nuspec': (self.output / 'vmpower-agent.nuspec').read_bytes()} + for file in (self.output / 'tools').rglob('*'): + if file.is_file(): + name = file.relative_to(self.output).as_posix() + if name == 'tools/node.exe.ignore': name = 'tools/bundle/runtime/node.exe.ignore' + members[name] = file.read_bytes() + members['[Content_Types].xml'] = b'' + if mutate: mutate(members) + packed = self.root / 'fixture.nupkg' + with zipfile.ZipFile(packed, 'w') as archive: + for name, raw in members.items(): archive.writestr(name, raw) + return packed + + def test_packed_payload_and_shim_mapping_are_bound_to_inputs(self): + self.prepare() + packed = self.packed_fixture() + result = verification.verify(self.output, packed) + self.assertEqual(result['nupkgSha256'], digest(packed.read_bytes())) + self.assertEqual(result['bundleManifestSha256'], self.expected) + + def test_packed_tamper_missing_shim_and_extra_executable_rejected(self): + self.prepare() + mutations = [ + lambda files: files.update({'tools/bundle/runtime/node.exe': b'tampered'}), + lambda files: files.pop('tools/bundle/runtime/node.exe.ignore'), + lambda files: files.update({'tools/extra.exe': b'unreviewed'}), + lambda files: files.update({'../escape': b'escape'}), + lambda files: files.update({'TOOLS/NSSM.EXE': b'alias'}), + lambda files: files.update({'tools/chocolateyBeforeModify.ps1': b'unreviewed hook'}), + lambda files: files.update({'vmpower-agent.nuspec': files['vmpower-agent.nuspec'].replace(b'0.0.2', b'0.0.1')}), + ] + for mutation in mutations: + with self.subTest(mutation=mutations.index(mutation)): + with self.assertRaises(ValueError): + verification.verify(self.output, self.packed_fixture(mutation)) + if __name__ == '__main__': unittest.main() diff --git a/scripts/verify-windows-package.py b/scripts/verify-windows-package.py new file mode 100644 index 0000000..307582c --- /dev/null +++ b/scripts/verify-windows-package.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""Inspect a locally packed nupkg without extracting or executing its contents.""" +import argparse +import hashlib +import json +from pathlib import Path +import re +import xml.etree.ElementTree as ET +import zipfile + + +def verify(inputs, packed): + if inputs.is_symlink() or not inputs.is_dir() or packed.is_symlink() or not packed.is_file(): + raise ValueError('Regular pack inputs and archive required') + expected = {} + for file in (inputs / 'tools').rglob('*'): + if file.is_symlink(): + raise ValueError('Linked package input') + if file.is_file(): + name = file.relative_to(inputs).as_posix() + if name == 'tools/node.exe.ignore': + name = 'tools/bundle/runtime/node.exe.ignore' + expected[name] = file.read_bytes() + record = json.loads(expected['tools/package-input.json']) + with zipfile.ZipFile(packed) as archive: + names = archive.namelist() + if len(names) != len(set(name.casefold() for name in names)): + raise ValueError('Duplicate package member') + if len(names) > len(expected) + 4: + raise ValueError('Unexpected package members') + seen = set() + for entry in archive.infolist(): + name = entry.filename + if '\\' in name or name.startswith('/') or any(part in ('', '.', '..') for part in name.split('/')): + raise ValueError('Noncanonical package member') + if name in expected: + if entry.file_size != len(expected[name]) or archive.read(entry) != expected[name]: + raise ValueError('Packed member differs from verified inputs') + seen.add(name) + elif name == 'vmpower-agent.nuspec': + if entry.file_size > 65536: + raise ValueError('Oversized metadata') + metadata = ET.fromstring(archive.read(entry)) + match = re.fullmatch(r'\{(http://schemas\.microsoft\.com/packaging/[0-9]{4}/[0-9]{2}/nuspec\.xsd)\}package', metadata.tag) + if not match: + raise ValueError('Unexpected nuspec schema') + ns = {'n': match.group(1)} + if (metadata.findtext('n:metadata/n:id', namespaces=ns) != 'vmpower-agent' + or metadata.findtext('n:metadata/n:version', namespaces=ns) != record['packageVersion'] + or metadata.find('n:metadata/n:dependencies', ns) is not None): + raise ValueError('Packed identity or dependencies differ') + elif (name in ('[Content_Types].xml', '_rels/.rels') or re.fullmatch( + r'package/services/metadata/core-properties/[A-Za-z0-9-]+\.psmdcp', name)): + if entry.file_size > 65536: + raise ValueError('Oversized metadata') + else: + raise ValueError('Unexpected package member') + if seen != set(expected) or 'vmpower-agent.nuspec' not in names: + raise ValueError('Missing packed member') + return {'status': 'PASS', 'packageVersion': record['packageVersion'], + 'bundleManifestSha256': record['bundleManifestSha256'], + 'nupkgSha256': hashlib.sha256(packed.read_bytes()).hexdigest(), + 'verifiedPayloadFiles': len(expected), 'shimMarkers': 'PASS'} + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--inputs', type=Path, required=True) + parser.add_argument('--package', type=Path, required=True) + args = parser.parse_args() + print(json.dumps(verify(args.inputs, args.package))) From 443399a5f45436f854ed8982c9eaf062ffaea04a Mon Sep 17 00:00:00 2001 From: Spencer Albrecht Date: Mon, 26 Oct 2026 09:06:36 -0700 Subject: [PATCH 05/18] Integrate bounded native package ownership qualification --- .github/workflows/native-qualification.yml | 27 +++++++++-- distribution/linux-debian-handoff.md | 12 +++-- packages/win32/node24/README.md | 11 +++-- scripts/prepare-legacy-package-inputs.py | 52 ++++++++++++++++++++++ scripts/test-legacy-package-inputs.py | 51 +++++++++++++++++++++ 5 files changed, 143 insertions(+), 10 deletions(-) create mode 100644 scripts/prepare-legacy-package-inputs.py create mode 100644 scripts/test-legacy-package-inputs.py diff --git a/.github/workflows/native-qualification.yml b/.github/workflows/native-qualification.yml index 0f85e6b..f8ee7be 100644 --- a/.github/workflows/native-qualification.yml +++ b/.github/workflows/native-qualification.yml @@ -2,7 +2,7 @@ name: Agent native qualification on: push: - branches: [master, security/agent-node24-delivery-20261025] + branches: [master, security/agent-node24-delivery-20261025, security/agent-package-ownership-20261026] pull_request: branches: [master] @@ -31,17 +31,21 @@ jobs: npm run build python3 scripts/test-stage-distribution.py python3 scripts/test-install-linux-bundle.py + python3 scripts/test-linux-deb.py python3 scripts/test-native-preparation.py + python3 scripts/test-legacy-package-inputs.py node scripts/test-native-report-fixture.cjs node scripts/test-native-fixture-paths.cjs - name: Stage pinned official runtime and labeled fixtures run: python3 scripts/prepare-native-inputs.py --output "$RUNNER_TEMP/vmpower-native" + - name: Stage exact retained public Debian package families before quarantine + run: python3 scripts/prepare-legacy-package-inputs.py --output "$RUNNER_TEMP/vmpower-native" - name: Real native bundle CLI run: '"$RUNNER_TEMP/vmpower-native/bundle/runtime/node" scripts/native-bundle-smoke.cjs' - name: Isolated systemd lifecycle and progress run: | sudo --preserve-env=GITHUB_ACTIONS,RUNNER_ENVIRONMENT,GITHUB_RUN_ID,ImageOS,ImageVersion,RUNNER_TEMP \ - python3 scripts/qualify-native-linux.py --output "$RUNNER_TEMP/vmpower-native" + python3 scripts/qualify-native-linux.py --output "$RUNNER_TEMP/vmpower-native" --legacy-dpkg - name: Always reconcile owned fixture cleanup if: always() run: | @@ -73,6 +77,17 @@ jobs: with: node-version: 24.22.1 registry-url: https://registry.npmjs.org + - name: Parse PowerShell and verify admitted existing Chocolatey + run: | + $ErrorActionPreference='Stop' + foreach ($file in (Get-ChildItem scripts,packages/win32/node24/tools -Filter '*.ps1' -Recurse -File)) { + $tokens=$null;$parseErrors=$null + $null=[System.Management.Automation.Language.Parser]::ParseFile($file.FullName,[ref]$tokens,[ref]$parseErrors) + if ($parseErrors.Count -ne 0) { throw ('PowerShell parse failed: '+$file.Name) } + } + # Official runner-images Windows2025 image 20261021.299.1, read 2026-10-26. + $chocolateyVersion=& (Join-Path $env:ChocolateyInstall 'bin\choco.exe') --version + if ($LASTEXITCODE -ne 0 -or ([string]$chocolateyVersion).Trim() -cne '2.7.4') { throw 'Existing Chocolatey differs from admitted 2.7.4; no installation or update allowed' } - name: Install exact public lock and compile run: | npm ci --legacy-peer-deps --ignore-scripts --no-audit --no-fund --registry=https://registry.npmjs.org @@ -83,6 +98,10 @@ jobs: if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } python scripts/test-native-preparation.py if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + python scripts/test-build-windows-package.py + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + python scripts/test-legacy-package-inputs.py + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } node scripts/test-native-report-fixture.cjs if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } node scripts/test-native-fixture-paths.cjs @@ -92,6 +111,8 @@ jobs: & scripts/test-windows-bootstrap.ps1 python scripts/prepare-native-inputs.py --output "$env:RUNNER_TEMP\vmpower-native" if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + python scripts/prepare-legacy-package-inputs.py --output "$env:RUNNER_TEMP\vmpower-native" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - name: Real native bundle CLI and installer argv ACL controls run: | $root=Join-Path $env:RUNNER_TEMP 'vmpower-native' @@ -100,7 +121,7 @@ jobs: $prepared=Get-Content -Raw "$root\evidence\prepared.json" | ConvertFrom-Json & scripts/test-install-windows-bundle.ps1 -Bundle "$root\bundle" -ExpectedManifestSha256 $prepared.bundles.bundle -NssmExe "$root\nssm.exe" - name: Isolated NSSM lifecycle and progress - run: '& scripts/qualify-native-windows.ps1 -Output "$env:RUNNER_TEMP\vmpower-native"' + run: '& scripts/qualify-native-windows.ps1 -Output "$env:RUNNER_TEMP\vmpower-native" -LegacyPackages "$env:RUNNER_TEMP\vmpower-native\legacy-packages" -ChocolateyVersion 2.7.4' - name: Always reconcile owned fixture cleanup if: always() run: | diff --git a/distribution/linux-debian-handoff.md b/distribution/linux-debian-handoff.md index 37e4217..481d588 100644 --- a/distribution/linux-debian-handoff.md +++ b/distribution/linux-debian-handoff.md @@ -54,15 +54,19 @@ its hash before a real upgrade; do not delete the snapshots or old public URLs. ## Bounded hosted gate proposal — not dispatched Use only an explicitly admitted disposable Ubuntu 24 x64 runner with systemd and -the existing 15-minute Linux native ceiling. No workflow/events/permissions are -changed by this candidate. Root must admit the exact source and invocation. +the existing 15-minute Linux native ceiling. The integrated existing workflow +enables this extension on the explicit combined candidate branch and retains its +events, permissions and timeout. Root must admit the exact source and invocation +before any push; this source preparation has not dispatched CI. 1. Retain the existing source tests and native input preparation. Run `python3 scripts/test-linux-deb.py` and the existing Linux installer tests. Stage the same pinned Node24 bundle/upgrade/crashing inputs with the unchanged - preparation helper. Copy the two exact cached debs above into + preparation helper. `prepare-legacy-package-inputs.py` stages the two exact + archived public families above from their fixed HTTPS origins into `$RUNNER_TEMP/vmpower-native/legacy-debs/` without installing or executing them. - The qualifier independently verifies both byte counts and SHA256 values. + It reuses the existing no-redirect downloader and verifies hashes and sizes; + the qualifier independently verifies both byte counts and SHA256 values. 2. Run the existing guarded native qualifier with its optional flag: `sudo --preserve-env=GITHUB_ACTIONS,RUNNER_ENVIRONMENT,GITHUB_RUN_ID,ImageOS,ImageVersion,RUNNER_TEMP python3 scripts/qualify-native-linux.py --output "$RUNNER_TEMP/vmpower-native" --legacy-dpkg`. It retains the original nine native phases and their network/cleanup controls. diff --git a/packages/win32/node24/README.md b/packages/win32/node24/README.md index cb01731..f019566 100644 --- a/packages/win32/node24/README.md +++ b/packages/win32/node24/README.md @@ -63,9 +63,14 @@ not a general post-success rollback command. ## Held native qualification The existing Windows qualifier accepts optional `-LegacyPackages DIRECTORY --ChocolateyVersion EXACT_VERSION`. No workflow invokes this extension yet. -Root must first admit the exact candidate and existing Chocolatey 2.x runner -version. No Chocolatey install/update is performed by the extension. +-ChocolateyVersion EXACT_VERSION`. The integrated native workflow enables this +extension with Chocolatey **2.7.4**, the version listed by the official +[Windows 2025 image manifest](https://github.com/actions/runner-images/blob/main/images/windows/Windows2025-Readme.md) +for image `20261021.299.1` when read on 2026-10-26. Both workflow preflight and the +extension reject version drift; neither installs nor updates Chocolatey. The +workflow parses the candidate PowerShell before service work and stages both +exact legacy archives through the existing no-redirect HTTPS/hash downloader. +Root must still admit the exact source and bounded native execution. Use the existing disposable, elevated GitHub-hosted Windows 2025 fixture, empty VMPower destinations, owned-resource journal, loopback receiver and executable diff --git a/scripts/prepare-legacy-package-inputs.py b/scripts/prepare-legacy-package-inputs.py new file mode 100644 index 0000000..eb3e977 --- /dev/null +++ b/scripts/prepare-legacy-package-inputs.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +"""Stage exact public package bytes before native isolation; never execute them.""" +import argparse +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import platform +import sys + +sys.dont_write_bytecode=True +spec=importlib.util.spec_from_file_location('native_preparation',Path(__file__).with_name('prepare-native-inputs.py')) +preparation=importlib.util.module_from_spec(spec);spec.loader.exec_module(preparation) +ORIGIN='https://vmpower.blob.core.windows.net/' +PINS={ + 'linux-x64':{'directory':'legacy-debs','assets':[ + ('dashboard-linux.deb','repo/dist/debian/vmpower-agent.amd64.deb',10794560,'06bf0d767e4625db0477f3e23e30b4e36ac6e7a7d50882b3c655c472f45875b1'), + ('docs-linux.deb','cdn/dist/debian/vmpower-agent.deb',11090764,'e8c0028ea78322e5937becba766654f178e4dfbe1c9817af33e024d93b588311')]}, + 'win-x64':{'directory':'legacy-packages','assets':[ + ('dashboard-windows.nupkg','repo/dist/win32/vmpower-agent/vmpower-agent.nupkg',10731882,'1e72a1db993a3b013b8503e8cde2f0492a8bcee18539ee9e1056c2eb203893af'), + ('docs-windows.nupkg','cdn/dist/win32/vmpower-agent/vmpower-agent.0.0.1.nupkg',10744853,'3b34023ffa2d05b54bdf10abfe9b3c515ab5dccf70af823407dcd1cb976a8956')]}, +} + +def stage(out,target,download=preparation.download): + if os.environ.get('GITHUB_ACTIONS')!='true' or os.environ.get('RUNNER_ENVIRONMENT')!='github-hosted': + raise ValueError('Ephemeral hosted runner required') + out=preparation.require_output(out) + if any((out/name).exists() or (out/name).is_symlink() for name in ('owned.json','owned-windows.json')): + raise ValueError('Legacy inputs must be staged before fixture ownership begins') + if target not in PINS:raise ValueError('Pinned native target required') + prepared=json.loads((out/'evidence/prepared.json').read_text()) + if prepared['target']!=target:raise ValueError('Prepared native target mismatch') + pin=PINS[target];destination=out/pin['directory'];destination.mkdir(exist_ok=False) + assets=[] + for name,object_path,size,digest in pin['assets']: + file=destination/name + download(ORIGIN+object_path,file,digest) + if file.is_symlink() or not file.is_file() or file.stat().st_nlink!=1: + raise ValueError('Regular single-link package required') + raw=file.read_bytes() + if len(raw)!=size or hashlib.sha256(raw).hexdigest()!=digest:raise ValueError('Pinned package bytes differ') + assets.append({'name':name,'bytes':size,'sha256':digest}) + receipt={'target':target,'assets':assets,'executed':False,'stagedBeforeQuarantine':True} + (out/'evidence/legacy-inputs.json').write_text(json.dumps(receipt,indent=2)+'\n') + return receipt + +if __name__=='__main__': + parser=argparse.ArgumentParser();parser.add_argument('--output',type=Path,required=True);args=parser.parse_args() + target={'Linux':'linux-x64','Windows':'win-x64'}.get(platform.system()) + if platform.machine().lower() not in ('x86_64','amd64'):raise ValueError('Native x64 required') + print(json.dumps(stage(args.output,target))) diff --git a/scripts/test-legacy-package-inputs.py b/scripts/test-legacy-package-inputs.py new file mode 100644 index 0000000..e8cf66e --- /dev/null +++ b/scripts/test-legacy-package-inputs.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +"""Offline staging boundaries, with tiny non-executable package byte controls.""" +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import tempfile +import unittest +from unittest import mock +import sys +sys.dont_write_bytecode=True +spec=importlib.util.spec_from_file_location('legacy_inputs',Path(__file__).with_name('prepare-legacy-package-inputs.py')) +legacy=importlib.util.module_from_spec(spec);spec.loader.exec_module(legacy) + +class LegacyInputsTests(unittest.TestCase): + def setUp(self): + self.temp=tempfile.TemporaryDirectory();self.addCleanup(self.temp.cleanup);self.out=Path(self.temp.name) + (self.out/'evidence').mkdir();(self.out/'evidence/prepared.json').write_text(json.dumps({'target':'linux-x64'})) + self.raw=b'nonexecutable package fixture';self.sha=hashlib.sha256(self.raw).hexdigest() + for patch in [mock.patch.dict(os.environ,{'GITHUB_ACTIONS':'true','RUNNER_ENVIRONMENT':'github-hosted'}),mock.patch.object(legacy.preparation,'require_output',side_effect=lambda value:value),mock.patch.object(legacy,'PINS',{'linux-x64':{'directory':'legacy-debs','assets':[('fixture.deb','repo/fixture.deb',len(self.raw),self.sha)]}})]: + patch.start();self.addCleanup(patch.stop) + def test_exact_bytes_and_fixed_origin_produce_sanitized_receipt(self): + def download(url,file,digest): + self.assertEqual(url,'https://vmpower.blob.core.windows.net/repo/fixture.deb');self.assertEqual(digest,self.sha);file.write_bytes(self.raw) + result=legacy.stage(self.out,'linux-x64',download) + self.assertFalse(result['executed']);self.assertEqual(result,json.loads((self.out/'evidence/legacy-inputs.json').read_text())) + def test_wrong_size_or_hash_never_produces_success_receipt(self): + for raw in [b'x',b'x'*len(self.raw)]: + with self.subTest(raw=raw): + with self.assertRaisesRegex(ValueError,'bytes differ'):legacy.stage(self.out,'linux-x64',lambda url,file,digest:file.write_bytes(raw)) + self.assertFalse((self.out/'evidence/legacy-inputs.json').exists()) + (self.out/'legacy-debs/fixture.deb').unlink();(self.out/'legacy-debs').rmdir() + def test_wrong_target_refuses_before_download(self): + (self.out/'evidence/prepared.json').write_text(json.dumps({'target':'win-x64'})) + with self.assertRaisesRegex(ValueError,'target mismatch'):legacy.stage(self.out,'linux-x64',mock.Mock(side_effect=AssertionError('No download'))) + self.assertFalse((self.out/'legacy-debs').exists()) + def test_existing_directory_preserved(self): + destination=self.out/'legacy-debs';destination.mkdir();(destination/'keep').write_bytes(b'keep') + with self.assertRaises(FileExistsError):legacy.stage(self.out,'linux-x64',mock.Mock(side_effect=AssertionError('No download'))) + self.assertEqual((destination/'keep').read_bytes(),b'keep') + def test_active_or_stale_fixture_ownership_blocks_network(self): + for name in ('owned.json','owned-windows.json'): + journal=self.out/name;journal.write_text('{}') + with self.assertRaisesRegex(ValueError,'before fixture ownership'):legacy.stage(self.out,'linux-x64',mock.Mock(side_effect=AssertionError('No download'))) + self.assertFalse((self.out/'legacy-debs').exists());journal.unlink() + def test_non_host_refuses_before_path_or_network(self): + with mock.patch.dict(os.environ,{'GITHUB_ACTIONS':'false'}),mock.patch.object(legacy.preparation,'require_output',side_effect=AssertionError('No path read')): + with self.assertRaisesRegex(ValueError,'hosted'):legacy.stage(self.out,'linux-x64') + +if __name__=='__main__':unittest.main() From 18d6aefe1d51ba9a72add8bac6045a9faa9168de Mon Sep 17 00:00:00 2001 From: Spencer Albrecht Date: Mon, 26 Oct 2026 09:39:11 -0700 Subject: [PATCH 06/18] Reconcile owned Windows shim and preserve primary failures --- .github/workflows/native-qualification.yml | 1 + packages/win32/node24/README.md | 11 +++ scripts/qualify-native-windows.ps1 | 43 ++++++++- scripts/qualify-windows-package-ownership.ps1 | 62 +++++++++++- scripts/test-windows-package-cleanup.ps1 | 95 +++++++++++++++++++ 5 files changed, 203 insertions(+), 9 deletions(-) create mode 100644 scripts/test-windows-package-cleanup.ps1 diff --git a/.github/workflows/native-qualification.yml b/.github/workflows/native-qualification.yml index f8ee7be..6deac25 100644 --- a/.github/workflows/native-qualification.yml +++ b/.github/workflows/native-qualification.yml @@ -88,6 +88,7 @@ jobs: # Official runner-images Windows2025 image 20261021.299.1, read 2026-10-26. $chocolateyVersion=& (Join-Path $env:ChocolateyInstall 'bin\choco.exe') --version if ($LASTEXITCODE -ne 0 -or ([string]$chocolateyVersion).Trim() -cne '2.7.4') { throw 'Existing Chocolatey differs from admitted 2.7.4; no installation or update allowed' } + & scripts/test-windows-package-cleanup.ps1 - name: Install exact public lock and compile run: | npm ci --legacy-peer-deps --ignore-scripts --no-audit --no-fund --registry=https://registry.npmjs.org diff --git a/packages/win32/node24/README.md b/packages/win32/node24/README.md index f019566..669c900 100644 --- a/packages/win32/node24/README.md +++ b/packages/win32/node24/README.md @@ -103,6 +103,17 @@ Cleanup skips all old automation and the auto-uninstaller, including when upgrade failed while `0.0.1` remains registered. Exact package directories and the legacy shim must be absent before ownership is recorded. A cleanup failure fails qualification and retains evidence; the disposable host must be discarded. +Package operations preserve completed fixed phase markers and a primary failure +classification independently of cleanup, so a cleanup exception cannot hide the +operation that failed first. Raw native output and exception messages never enter +those receipts. After actual package deregistration, cleanup reconciles only the +exact journal-owned `bin\vmpower-agent.amd64.exe` orphan: explicit absent-before +ownership, matching package-manager root, protected parents, no reparse point or +directory, and a single-link regular file are required. It records the removed +file's hash/size and asserts absence; it never glob-deletes the shared bin folder. +Filesystem controls and the actual qualifier catch/finally regression controls +run before native service work. Their source is preparation until admitted CI +has executed them; a prior cleanup failure is not proof of package success. No future test may execute unsupported old telemetry without a separately reviewed quarantine design. This fixture intentionally avoids that need. diff --git a/scripts/qualify-native-windows.ps1 b/scripts/qualify-native-windows.ps1 index 7a66251..95e00ae 100644 --- a/scripts/qualify-native-windows.ps1 +++ b/scripts/qualify-native-windows.ps1 @@ -48,9 +48,11 @@ function Assert-Acl([string]$Path,[bool]$PublicRead) { } } function Cleanup-Owned { + $script:cleanupPhase='ownership-journal' if (-not (Test-Path -LiteralPath $journal)) { return @{status='NO_OWNED_RESOURCES'} } $owned=Get-Content -Raw -LiteralPath $journal | ConvertFrom-Json Assert-True ($owned.run -eq $env:GITHUB_RUN_ID -and $owned.product -eq $product -and $owned.configDirectory -eq $configDir) 'Cleanup ownership mismatch' + $script:cleanupPhase='service-and-child-stop' $service=Get-CimInstance Win32_Service -Filter "Name='vmpoweragent'" if ($service) { if ($service.State -ne 'Stopped') { Stop-Service -Name vmpoweragent -Force } @@ -76,21 +78,38 @@ function Cleanup-Owned { $null=Wait-Condition { -not (Get-CimInstance Win32_Service -Filter "Name='vmpoweragent'") } Assert-True (-not (Test-Path -LiteralPath $product) -and -not (Test-Path -LiteralPath $configDir)) 'Managed path cleanup failed' Assert-True (-not $owned.receiverPid -or -not (Get-Process -Id $owned.receiverPid -ErrorAction SilentlyContinue)) 'Receiver cleanup failed' + $shimCleanup=$null if ($owned.PSObject.Properties['chocolatey']) { # Clean processes and firewall first, even if package cleanup then fails. # Never execute the retained legacy uninstall script during cleanup. - $ownedTable=@{chocolatey=$owned.chocolatey;chocolateyDirectories=$owned.chocolateyDirectories;chocolateyShim=$owned.chocolateyShim} - & { $owned=$ownedTable;Remove-OwnedChocolateyRegistration } + $script:cleanupPhase='package-cleanup' + $ownedTable=@{chocolatey=$owned.chocolatey;chocolateyDirectories=$owned.chocolateyDirectories;chocolateyShim=$owned.chocolateyShim;chocolateyShimAbsent=($owned.PSObject.Properties['chocolateyShimAbsent'] -and $owned.chocolateyShimAbsent)} + $shimCleanup=& { $owned=$ownedTable;Remove-OwnedChocolateyRegistration } } + $script:cleanupPhase='fixture-input-removal' foreach ($entry in Get-ChildItem -LiteralPath $Output -Force) { if ($entry.Name -notin @('evidence','owned-windows.json')) { Remove-Item -LiteralPath $entry.FullName -Recurse -Force } } $result=@{status='PASS';serviceAbsent=$true;childrenAbsent=($survivors.Count -eq 0);managedFilesAbsent=$true;fixtureInputsRemoved=$true;firewallRulesRemoved=$true;receiverStopped=$true} + if ($shimCleanup) { $result.shimCleanup=$shimCleanup;$result.packageRegistrationAbsent=$true } Save-Json (Join-Path $Output 'evidence\cleanup-windows.json') $result Assert-True ($survivors.Count -eq 0) 'Service left children; forced cleanup performed' return $result } -if ($CleanupOnly) { Cleanup-Owned | ConvertTo-Json;return } +function Recorded-Cleanup { + try { return Cleanup-Owned } + catch { + $failure=@{status='FAIL';classification='OWNED_RESOURCE_CLEANUP_FAILED';phase=$script:cleanupPhase} + Save-Json (Join-Path $Output 'evidence\cleanup-windows.json') $failure + return $failure + } +} +if ($CleanupOnly) { + $cleanup=Recorded-Cleanup + $cleanup | ConvertTo-Json + if ($cleanup.status -eq 'FAIL') { throw 'Owned fixture cleanup failed; see sanitized cleanup receipt' } + return +} # Reject occupied destinations before reading config or changing ACLs/services. Assert-True (-not (Test-Path -LiteralPath $product) -and -not (Test-Path -LiteralPath $configDir)) 'Managed destination already exists' Assert-True (-not (Get-CimInstance Win32_Service -Filter "Name='vmpoweragent'")) 'Service already exists' @@ -195,11 +214,25 @@ try { $null=Wait-Condition { -not (Get-CimInstance Win32_Service -Filter "Name='vmpoweragent'") } $result.phases+=@(@{phase='synthetic-legacy-NSSM-migration';status='PASS';legacyPublishedBinary='UNRUN'}) if ($LegacyPackages) { - $result.phases+=@(Invoke-WindowsPackageOwnership $LegacyPackages $ChocolateyVersion) + $result.packageOwnership=@{phase='package-entry';status='FAIL';packages=@();families=@()} + $result.phases+=@(Invoke-WindowsPackageOwnership $LegacyPackages $ChocolateyVersion $result.packageOwnership) } $result.status='PASS' +} catch { + # Capture the primary operation before cleanup can fail or reset package state. + # Never persist exception messages, native stdout/stderr, config or arguments. + $result.status='FAIL' + $result.primaryFailure=@{classification='NATIVE_QUALIFICATION_FAILED';lastCompletedPhase=$(if ($result.phases.Count) {$result.phases[-1].phase} else {'none'})} + if ($result.ContainsKey('packageOwnership')) { + $result.primaryFailure.phase=$result.packageOwnership.phase + $result.primaryFailure.packageCommandFailure=$script:lastPackageFailure + } } finally { - try { $result.cleanup=Cleanup-Owned } catch { $result.status='FAIL';$result.cleanup=@{status='FAIL'};throw } + try { + $result.cleanup=Recorded-Cleanup + if ($result.cleanup.status -eq 'FAIL') { $result.status='FAIL' } + } finally { Save-Json (Join-Path $Output 'evidence\lifecycle-windows.json') $result } } +if ($result.status -ne 'PASS') { throw 'Native qualification failed; see separate sanitized primary and cleanup receipts' } @{nativeLifecycle='PASS';phases=$result.phases.Count;cleanup=$result.cleanup.status} | ConvertTo-Json diff --git a/scripts/qualify-windows-package-ownership.ps1 b/scripts/qualify-windows-package-ownership.ps1 index 2ab9e74..86efad1 100644 --- a/scripts/qualify-windows-package-ownership.ps1 +++ b/scripts/qualify-windows-package-ownership.ps1 @@ -1,10 +1,13 @@ # Opt-in extension of the existing isolated Windows lifecycle fixture. Never run # standalone: it reuses that fixture's receiver, firewall, journal and cleanup. +$script:lastPackageFailure=$null function Invoke-OwnedChocolatey([string[]]$Arguments) { # Package extraction, ACL work and failing-child recovery can exceed the # installer's individual 30-second native-operation limit. Bound the whole # package transaction separately; timeout is never rollback qualification. $script:packageRecoveryObserved=$false + $script:lastPackageFailure=$null + $operation=if ($Arguments[0] -in @('--version','list','pack','install','upgrade','uninstall')) {$Arguments[0]} else {'UNCLASSIFIED'} $process=New-Object Diagnostics.Process $process.StartInfo.FileName=$script:chocolatey $process.StartInfo.Arguments=($Arguments | ForEach-Object { Quote-NativeArgument $_ }) -join ' ' @@ -15,6 +18,7 @@ function Invoke-OwnedChocolatey([string[]]$Arguments) { $stdout=$process.StandardOutput.ReadToEndAsync();$stderr=$process.StandardError.ReadToEndAsync() if (-not $process.WaitForExit(180000)) { $script:packageTimedOut=$true + $script:lastPackageFailure=@{classification='CHOCOLATEY_TIMEOUT';operation=$operation} $null=Invoke-CheckedNative 'taskkill.exe' @('/PID',[string]$process.Id,'/T','/F') throw 'Chocolatey transaction timed out; native qualification failed' } @@ -22,6 +26,7 @@ function Invoke-OwnedChocolatey([string[]]$Arguments) { # Distinguish the intended crashing-child rollback from download, # lock, command-line or package-validation failures. Keep raw logs private. $script:packageRecoveryObserved=($stdout.GetAwaiter().GetResult()+$stderr.GetAwaiter().GetResult()).Contains('Install failed; previous service restored, configuration and all releases retained') + $script:lastPackageFailure=@{classification='CHOCOLATEY_EXIT_NONZERO';operation=$operation;exitCode=$process.ExitCode;installerRecoveryObserved=$script:packageRecoveryObserved} throw 'Chocolatey transaction failed' } return $stdout.GetAwaiter().GetResult().TrimEnd([char[]]"`r`n") @@ -35,9 +40,40 @@ function Get-OwnedChocolateyVersion { return $Matches[1] } +function Remove-OwnedLegacyShim([hashtable]$Ownership,[bool]$RegistrationAbsent) { + Assert-True $RegistrationAbsent 'Package registration must be absent before shim reconciliation' + $expectedChoco=[IO.Path]::GetFullPath((Join-Path $env:ChocolateyInstall 'bin\choco.exe')) + $expectedShim=[IO.Path]::GetFullPath((Join-Path $env:ChocolateyInstall 'bin\vmpower-agent.amd64.exe')) + Assert-True ($Ownership.ContainsKey('chocolateyShimAbsent') -and $Ownership.chocolateyShimAbsent -eq $true -and + $Ownership.chocolatey -ieq $expectedChoco -and $Ownership.chocolateyShim -ieq $expectedShim) 'Exact absent-before-fixture shim ownership required' + Assert-NoReparsePoint $expectedShim + # Never trust a writable bin directory or a replaceable Chocolatey root. + $trusted=@('S-1-5-18','S-1-5-32-544',[Security.Principal.WindowsIdentity]::GetCurrent().User.Value) + $writeRights=[Security.AccessControl.FileSystemRights]'Write,Delete,DeleteSubdirectoriesAndFiles,ChangePermissions,TakeOwnership' + foreach ($parent in @($env:ChocolateyInstall,(Split-Path -Parent $expectedShim))) { + Assert-True (Test-Path -LiteralPath $parent -PathType Container) 'Shim parent must be a directory' + $acl=Get-Acl -LiteralPath $parent + Assert-True ($acl.GetOwner([Security.Principal.SecurityIdentifier]).Value -in $trusted) 'Untrusted shim parent owner' + foreach ($rule in $acl.GetAccessRules($true,$true,[Security.Principal.SecurityIdentifier])) { + if ($rule.AccessControlType -eq 'Allow' -and ($rule.FileSystemRights -band $writeRights) -ne 0) { + Assert-True ($rule.IdentityReference.Value -in $trusted) 'Untrusted write access to shim parent' + } + } + } + if (-not (Test-Path -LiteralPath $expectedShim)) { return @{status='ALREADY_ABSENT';packageRegistrationAbsent=$true} } + $file=Get-Item -LiteralPath $expectedShim -Force + Assert-True ($file -is [IO.FileInfo] -and -not $file.PSIsContainer) 'Owned shim must be a regular file' + Assert-SingleLinkFile $expectedShim + $receipt=@{status='REMOVED_OWNED_ORPHAN';packageRegistrationAbsent=$true;bytes=$file.Length;sha256=(Get-FileHash -LiteralPath $expectedShim -Algorithm SHA256).Hash.ToLowerInvariant()} + Remove-Item -LiteralPath $expectedShim -Force + Assert-True (-not (Test-Path -LiteralPath $expectedShim)) 'Owned legacy shim survived package cleanup' + return $receipt +} + function Remove-OwnedChocolateyRegistration { if (-not $owned.ContainsKey('chocolatey') -or -not $owned.chocolatey) { return } $script:chocolatey=$owned.chocolatey + $script:cleanupPhase='package-deregistration' $version=Get-OwnedChocolateyVersion if ($version) { # Cleanup must NEVER execute the retained 0.0.1 uninstall script. @@ -45,6 +81,7 @@ function Remove-OwnedChocolateyRegistration { '--yes','--skip-automation-scripts','--skip-autouninstaller','--limit-output') } Assert-True (-not (Get-OwnedChocolateyVersion)) 'Owned package registration survived cleanup' + $script:cleanupPhase='package-cache-removal' # Exact directories were proved absent before setting journal ownership. foreach ($directory in $owned.chocolateyDirectories) { Assert-NoReparsePoint $directory @@ -54,11 +91,13 @@ function Remove-OwnedChocolateyRegistration { } Assert-True (-not (Test-Path -LiteralPath $directory)) 'Owned package cache survived cleanup' } - Assert-True (-not (Test-Path -LiteralPath $owned.chocolateyShim)) 'Owned legacy shim survived package cleanup' + $script:cleanupPhase='owned-legacy-shim-reconciliation' + return Remove-OwnedLegacyShim $owned $true } -function Invoke-WindowsPackageOwnership([string]$LegacyPackages,[string]$ExpectedChocolateyVersion) { +function Invoke-WindowsPackageOwnership([string]$LegacyPackages,[string]$ExpectedChocolateyVersion,[hashtable]$Evidence) { Assert-Hosted + $Evidence.phase='package-preflight';$Evidence.status='FAIL';$Evidence.packages=@();$Evidence.families=@() $script:packageTimedOut=$false if ($ExpectedChocolateyVersion -notmatch '^2\.[0-9]+\.[0-9]+$') { throw 'Explicit reviewed Chocolatey 2.x version required' } $script:chocolatey=Join-Path $env:ChocolateyInstall 'bin\choco.exe' @@ -69,7 +108,7 @@ function Invoke-WindowsPackageOwnership([string]$LegacyPackages,[string]$Expecte foreach ($directory in $directories) { Assert-NoReparsePoint $directory;Assert-True (-not (Test-Path -LiteralPath $directory)) 'Package directory already exists' } $shim=Join-Path $env:ChocolateyInstall 'bin\vmpower-agent.amd64.exe' Assert-NoReparsePoint $shim;Assert-True (-not (Test-Path -LiteralPath $shim)) 'Legacy shim already exists' - $owned.chocolatey=$script:chocolatey;$owned.chocolateyDirectories=@($directories);$owned.chocolateyShim=$shim;Save-Json $journal $owned + $owned.chocolatey=$script:chocolatey;$owned.chocolateyDirectories=@($directories);$owned.chocolateyShim=$shim;$owned.chocolateyShimAbsent=$true;Save-Json $journal $owned $packages=Join-Path $Output 'chocolatey-ownership';$null=New-Item -ItemType Directory -Path $packages $runtimePins=Get-Content -Raw (Join-Path $PSScriptRoot '../distribution/runtime-pins.json') | ConvertFrom-Json $nodeArchive=Join-Path $Output $runtimePins.targets.'win-x64'.archive @@ -77,21 +116,27 @@ function Invoke-WindowsPackageOwnership([string]$LegacyPackages,[string]$Expecte # Successful ownership uses the unmodified product bundle; only the failing # successor is synthetic. Final release/version admission remains separate. foreach ($variant in @(@{name='bundle';version='0.0.2'},@{name='crashing';version='0.0.3'})) { + $Evidence.phase='build-'+$variant.name $inputs=Join-Path $packages $variant.name $buildArguments=@((Join-Path $PSScriptRoot 'build-windows-package.py'),'--bundle',(Join-Path $Output $variant.name), '--expected-manifest-sha256',$prepared.bundles.($variant.name),'--node-archive',$nodeArchive, '--nssm-archive',(Join-Path $Output 'nssm.zip'),'--version',$variant.version,'--output',$inputs) $null=Invoke-CheckedNative (Get-Command python).Source $buildArguments + $Evidence.phase='pack-'+$variant.name $null=Invoke-OwnedChocolatey @('pack',(Join-Path $inputs 'vmpower-agent.nuspec'),('--output-directory='+$packages),'--limit-output') $packed=Join-Path $packages ('vmpower-agent.'+$variant.version+'.nupkg') + $Evidence.phase='verify-packed-'+$variant.name $verified=Invoke-CheckedNative (Get-Command python).Source @((Join-Path $PSScriptRoot 'verify-windows-package.py'),'--inputs',$inputs,'--package',$packed) $packedProof+=@($verified | ConvertFrom-Json) + $Evidence.packages=@($packedProof) } $families=@( @{name='docs-windows.nupkg';sha='3b34023ffa2d05b54bdf10abfe9b3c515ab5dccf70af823407dcd1cb976a8956'}, @{name='dashboard-windows.nupkg';sha='1e72a1db993a3b013b8503e8cde2f0492a8bcee18539ee9e1056c2eb203893af'}) $proof=@() foreach ($family in $families) { + $familyEvidence=@{family=$family.name;packageSha256=$family.sha;phases=@()};$Evidence.families+=@($familyEvidence) + $Evidence.phase='legacy-registration';$Evidence.family=$family.name Assert-True (-not (Get-CimInstance Win32_Service -Filter "Name='vmpoweragent'")) 'Service must be absent between package cases' $legacy=Join-Path $LegacyPackages $family.name;Assert-NoReparsePoint $legacy Assert-True ((Get-FileHash -LiteralPath $legacy -Algorithm SHA256).Hash.ToLowerInvariant() -ceq $family.sha) 'Legacy package family hash mismatch' @@ -103,6 +148,8 @@ function Invoke-WindowsPackageOwnership([string]$LegacyPackages,[string]$Expecte '--yes','--skip-automation-scripts','--ignore-dependencies','--limit-output') Assert-True ((Get-OwnedChocolateyVersion) -ceq '0.0.1') 'Legacy ownership not registered' Assert-True (-not (Get-CimInstance Win32_Service -Filter "Name='vmpoweragent'")) 'Skipped legacy install created a service' + $familyEvidence.phases+=@('legacy-registration-scripts-skipped') + $Evidence.phase='synthetic-service-setup' # Reuse only qualified modern Node bytes for the legacy SCM contract. $null=Invoke-CheckedNative $wrapper @('install','vmpoweragent',$node) $null=Invoke-CheckedNative $wrapper @('set','vmpoweragent','AppDirectory',$configDir) @@ -116,6 +163,7 @@ function Invoke-WindowsPackageOwnership([string]$LegacyPackages,[string]$Expecte } $configDigest=(Get-FileHash -LiteralPath $config -Algorithm SHA256).Hash $count=(Receiver-State).count + $Evidence.phase='modern-package-upgrade' $null=Invoke-OwnedChocolatey @('upgrade','vmpower-agent','--version=0.0.2',('--source='+$packages),'--yes','--limit-output') Assert-True ((Get-OwnedChocolateyVersion) -ceq '0.0.2') 'New package did not replace legacy registration' $modern=Join-Path $product ('Agent\releases\'+$prepared.bundles.bundle+'\runtime\node.exe') @@ -127,6 +175,7 @@ function Invoke-WindowsPackageOwnership([string]$LegacyPackages,[string]$Expecte $null=Wait-Condition { (Receiver-State).count -gt $count };$null=Service-Child $modern Assert-True ((Get-FileHash -LiteralPath $config -Algorithm SHA256).Hash -ceq $configDigest) 'Chocolatey upgrade changed configuration' Assert-Acl $config $false + $familyEvidence.phases+=@('modern-upgrade-state-config-acl');$Evidence.phase='crashing-package-rollback' $failed=$false;$count=(Receiver-State).count try { $null=Invoke-OwnedChocolatey @('upgrade','vmpower-agent','--version=0.0.3',('--source='+$packages),'--yes','--limit-output') } catch { $failed=$true } Assert-True (-not $script:packageTimedOut) 'Timed-out package is not a valid rollback control' @@ -135,15 +184,20 @@ function Invoke-WindowsPackageOwnership([string]$LegacyPackages,[string]$Expecte $null=Wait-Condition { (Receiver-State).count -gt $count };$null=Service-Child $modern Assert-True ((Get-OwnedChocolateyVersion) -ceq '0.0.2') 'Failed package did not restore prior registration' Assert-True ((Get-FileHash -LiteralPath $config -Algorithm SHA256).Hash -ceq $configDigest) 'Package rollback changed configuration' + $familyEvidence.phases+=@('package-and-service-rollback');$Evidence.phase='modern-package-uninstall' # Run the NEW uninstaller normally, proving old destructive script is gone. $null=Invoke-OwnedChocolatey @('uninstall','vmpower-agent','--yes','--limit-output') Assert-True (-not (Get-OwnedChocolateyVersion)) 'Modern package registration remains' $null=Wait-Condition { -not (Get-CimInstance Win32_Service -Filter "Name='vmpoweragent'") } Assert-True ((Get-FileHash -LiteralPath $config -Algorithm SHA256).Hash -ceq $configDigest) 'Modern package uninstall deleted configuration' Assert-True (Test-Path -LiteralPath $modern) 'Package uninstall deleted rollback runtime' + $familyEvidence.phases+=@('modern-uninstall-retains-config-runtime') $proof+=@(@{family=$family.name;packageSha256=$family.sha;registrationUpgrade='PASS';previousState=$(if ($disabled) {'Stopped/Disabled'} else {'Running/Manual'});serviceRollback='PASS';uninstallPreservesConfig='PASS';legacyPublishedExecutable='UNRUN'}) - Remove-OwnedChocolateyRegistration + $Evidence.phase='family-package-cleanup' + $familyEvidence.shimCleanup=Remove-OwnedChocolateyRegistration + $familyEvidence.phases+=@('package-and-shim-cleanup') Remove-Item -LiteralPath $localPackage } + $Evidence.phase='complete';$Evidence.status='PASS';$Evidence.chocolateyVersion=$ExpectedChocolateyVersion return @{phase='real-chocolatey-ownership-synthetic-service';status='PASS';chocolateyVersion=$ExpectedChocolateyVersion;packages=$packedProof;families=$proof;oldRuntimeCompatibility='UNQUALIFIED'} } diff --git a/scripts/test-windows-package-cleanup.ps1 b/scripts/test-windows-package-cleanup.ps1 new file mode 100644 index 0000000..9cfddc2 --- /dev/null +++ b/scripts/test-windows-package-cleanup.ps1 @@ -0,0 +1,95 @@ +# Filesystem-only controls for the actual shim reconciliation helper. No package, +# service, legacy executable, network request or broad cleanup is invoked. +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +if ($env:OS -ne 'Windows_NT') { throw 'Windows PowerShell required' } +. (Join-Path $PSScriptRoot 'install-windows-bundle.ps1') +. (Join-Path $PSScriptRoot 'qualify-windows-package-ownership.ps1') +function Assert-True($Condition,[string]$Message) { if (-not $Condition) { throw $Message } } +function Assert-Rejected([scriptblock]$Action,[string]$Name) { + $failed=$false + try { $null=& $Action } catch { $failed=$true } + Assert-True $failed ('Unsafe cleanup accepted: '+$Name) +} +function Test-FailurePreservation([bool]$PrimaryFails,[bool]$CleanupFails) { + # Execute the actual qualifier catch/finally blocks with a controlled try + # body. This detects the original masking bug without running any lifecycle. + $tokens=$null;$errors=$null + $ast=[System.Management.Automation.Language.Parser]::ParseFile((Join-Path $PSScriptRoot 'qualify-native-windows.ps1'),[ref]$tokens,[ref]$errors) + Assert-True ($errors.Count -eq 0) 'Qualifier parser failure' + $main=@($ast.EndBlock.Statements | Where-Object { $_ -is [System.Management.Automation.Language.TryStatementAst] -and $_.Extent.Text.Contains('NATIVE_QUALIFICATION_FAILED') }) + Assert-True ($main.Count -eq 1 -and $main[0].CatchClauses.Count -eq 1) 'Primary failure capture block missing' + $body=if ($PrimaryFails) { "throw 'synthetic-primary-do-not-persist'" } else { '$result.status="PASS"' } + $control=[ScriptBlock]::Create('try {'+$body+'} catch '+$main[0].CatchClauses[0].Body.Extent.Text+' finally '+$main[0].Finally.Extent.Text) + $result=@{status='PASS';phases=@(@{phase='known-completed-phase'});packageOwnership=@{phase='known-package-phase'}} + $script:lastPackageFailure=$null;$script:failureReceipt=$null + $Output=$fixture + function Recorded-Cleanup { return @{status=$(if ($CleanupFails) {'FAIL'} else {'PASS'});classification='controlled-cleanup'} } + function Save-Json([string]$Path,$Value) { $script:failureReceipt=$Value | ConvertTo-Json -Depth 20 } + & $control + $saved=$script:failureReceipt | ConvertFrom-Json + Assert-True ($saved.status -eq 'FAIL') 'Qualification failure was hidden' + Assert-True ($saved.cleanup.status -eq $(if ($CleanupFails) {'FAIL'} else {'PASS'})) 'Cleanup classification lost' + Assert-True (-not $script:failureReceipt.Contains('synthetic-primary-do-not-persist')) 'Raw exception persisted' + if ($PrimaryFails) { + Assert-True ($saved.primaryFailure.phase -eq 'known-package-phase' -and $saved.primaryFailure.classification -eq 'NATIVE_QUALIFICATION_FAILED') 'Primary failure masked by cleanup' + } else { Assert-True (-not $saved.PSObject.Properties['primaryFailure']) 'Cleanup failure invented a primary failure' } +} +$originalRoot=$env:ChocolateyInstall +$fixture=Join-Path ([IO.Path]::GetTempPath()) ('vmpower shim controls '+[Guid]::NewGuid().ToString()) +$cases=0 +try { + $env:ChocolateyInstall=$fixture + $bin=Join-Path $fixture 'bin';$null=New-Item -ItemType Directory -Path $bin -Force + Set-ProtectedAcl $fixture $true $true;Set-ProtectedAcl $bin $true $true + $shim=Join-Path $bin 'vmpower-agent.amd64.exe' + $neighbor=Join-Path $fixture 'unrelated-retained.txt';[IO.File]::WriteAllText($neighbor,'keep') + $owned=@{chocolatey=(Join-Path $bin 'choco.exe');chocolateyShim=$shim;chocolateyShimAbsent=$true} + [IO.File]::WriteAllText($shim,'synthetic nonexecutable shim') + $expected=(Get-FileHash -LiteralPath $shim -Algorithm SHA256).Hash.ToLowerInvariant() + $result=Remove-OwnedLegacyShim $owned $true + Assert-True ($result.status -eq 'REMOVED_OWNED_ORPHAN' -and $result.sha256 -ceq $expected -and -not (Test-Path -LiteralPath $shim)) 'Exact orphan was not reconciled' + Assert-True ([IO.File]::ReadAllText($neighbor) -ceq 'keep') 'Unrelated file changed';$cases++ + Assert-True ((Remove-OwnedLegacyShim $owned $true).status -eq 'ALREADY_ABSENT') 'Repeated cleanup not idempotent';$cases++ + + [IO.File]::WriteAllText($shim,'keep') + Assert-Rejected { Remove-OwnedLegacyShim $owned $false } 'registered package' + Assert-True (Test-Path -LiteralPath $shim) 'Registered shim removed';$cases++ + $unowned=$owned.Clone();$unowned.Remove('chocolateyShimAbsent') + Assert-Rejected { Remove-OwnedLegacyShim $unowned $true } 'missing absence journal' + Assert-True (Test-Path -LiteralPath $shim) 'Unjournaled shim removed';$cases++ + $foreign=$owned.Clone();$foreign.chocolateyShim=$neighbor + Assert-Rejected { Remove-OwnedLegacyShim $foreign $true } 'foreign path' + Assert-True ([IO.File]::ReadAllText($neighbor) -ceq 'keep') 'Foreign file removed';$cases++ + $foreign=$owned.Clone();$foreign.chocolatey=Join-Path $fixture 'other-choco.exe' + Assert-Rejected { Remove-OwnedLegacyShim $foreign $true } 'foreign package-manager path';$cases++ + + $acl=Get-Acl -LiteralPath $bin + $users=New-Object Security.Principal.SecurityIdentifier 'S-1-5-32-545' + $rule=New-Object Security.AccessControl.FileSystemAccessRule($users,'Write','Allow') + $acl.AddAccessRule($rule);Set-Acl -LiteralPath $bin -AclObject $acl + Assert-Rejected { Remove-OwnedLegacyShim $owned $true } 'writable parent' + Assert-True (Test-Path -LiteralPath $shim) 'Shim removed under unsafe parent' + Set-ProtectedAcl $bin $true $true;$cases++ + + Remove-Item -LiteralPath $shim + $null=New-Item -ItemType Directory -Path $shim + Assert-Rejected { Remove-OwnedLegacyShim $owned $true } 'directory alias' + Assert-True (Test-Path -LiteralPath $shim -PathType Container) 'Directory removed' + Remove-Item -LiteralPath $shim;$cases++ + $null=New-Item -ItemType SymbolicLink -Path $shim -Target $neighbor + Assert-Rejected { Remove-OwnedLegacyShim $owned $true } 'reparse alias' + Assert-True ([IO.File]::ReadAllText($neighbor) -ceq 'keep') 'Linked target modified' + Remove-Item -LiteralPath $shim;$cases++ + $null=New-Item -ItemType HardLink -Path $shim -Target $neighbor + Assert-Rejected { Remove-OwnedLegacyShim $owned $true } 'hardlink alias' + Assert-True ([IO.File]::ReadAllText($neighbor) -ceq 'keep') 'Hardlinked target modified' + Remove-Item -LiteralPath $shim;$cases++ + Test-FailurePreservation $true $true;$cases++ + Test-FailurePreservation $true $false;$cases++ + Test-FailurePreservation $false $true;$cases++ + @{status='PASS';cases=$cases;packageOperations=0;serviceOperations=0;legacyExecutableRuns=0} | ConvertTo-Json -Compress +} finally { + $env:ChocolateyInstall=$originalRoot + if (Test-Path -LiteralPath $fixture) { Remove-Item -LiteralPath $fixture -Recurse -Force } +} From 0416667d7cf97883625778b2e4a7f9d13863b047 Mon Sep 17 00:00:00 2001 From: Spencer Albrecht Date: Mon, 26 Oct 2026 20:03:06 -0700 Subject: [PATCH 07/18] Reconcile verified legacy shim in Windows product hooks --- packages/win32/node24/README.md | 31 ++++++++ .../win32/node24/tools/chocolateyInstall.ps1 | 7 ++ .../node24/tools/chocolateyUninstall.ps1 | 4 + packages/win32/node24/tools/package-shim.ps1 | 52 +++++++++++++ scripts/build-windows-package.py | 2 +- scripts/qualify-windows-package-ownership.ps1 | 18 +++++ scripts/test-build-windows-package.py | 3 + scripts/test-windows-package-cleanup.ps1 | 4 +- scripts/test-windows-product-shim.ps1 | 78 +++++++++++++++++++ 9 files changed, 197 insertions(+), 2 deletions(-) create mode 100644 packages/win32/node24/tools/package-shim.ps1 create mode 100644 scripts/test-windows-product-shim.ps1 diff --git a/packages/win32/node24/README.md b/packages/win32/node24/README.md index 669c900..a55795a 100644 --- a/packages/win32/node24/README.md +++ b/packages/win32/node24/README.md @@ -1,5 +1,36 @@ # Windows Chocolatey ownership handoff (prepared, not distributed) +Native run `40437980932` passed the prior `18d6aef` source on Windows 2025, +PowerShell 5.1 and Chocolatey 2.7.4. That pass included fixture removal of an +orphan legacy shim; it did not prove product-side shim cleanup. The following +product-hook correction is source-only until a new admitted native run passes. + +The product hooks now preflight the exact `bin\vmpower-agent.amd64.exe` path and +remove it only after successful activation/removal. Existing shims must match +one of the two exact SHA-256/size pairs observed from the pinned legacy packages +in that run, under `C:\ProgramData\chocolatey`, with protected ownership/ACLs, +no reparse points and a single link. Unknown bytes, custom roots, unsafe parents +or an intervening replacement fail closed and preserve the file. Missing shims +are harmless. No shim or legacy executable is executed to identify its target. +Older Chocolatey-generated shim variants are not qualified by these two hashes. + +Both old package scripts register the service against the copied +`C:\ProgramData\vmpower\vmpower.exe`, not Chocolatey's automatic global shim. +The inspected dashboard install command and published help pages use Chocolatey +installation/service reporting, not that shim. Its removal therefore cleans up +an obsolete automatic command; the old copied executable is retained for rollback +and the modern release keeps its existing `vmpower.cmd`. Uninspected external +scripts that invoke the old global EXE are not claimed compatible. + +The native package test requires the legacy shim before upgrade, its absence +immediately after the product upgrade and after ordinary product uninstall, all +before emergency fixture cleanup. The existing preflight also runs product-hook +wiring controls with synthetic ownership/service boundaries, plus real helper +refusal controls. Those local wiring controls do not replace actual shim proof. +Post-success rollback to legacy is still unqualified: in-memory installer failure +recovery is not a persistent operator rollback command, and normal old-package +downgrade would overwrite configuration. + Use the existing `vmpower-agent` package ID with an explicit version greater than `0.0.1`. A normal Chocolatey upgrade invokes the **new** install script after the **old** before-modify script. It does not run the old uninstall script. Both diff --git a/packages/win32/node24/tools/chocolateyInstall.ps1 b/packages/win32/node24/tools/chocolateyInstall.ps1 index 3f8e66b..85af626 100644 --- a/packages/win32/node24/tools/chocolateyInstall.ps1 +++ b/packages/win32/node24/tools/chocolateyInstall.ps1 @@ -1,6 +1,10 @@ # Same package ID upgrades 0.0.1 without invoking its destructive uninstaller. # No before-modify hook: the installer must observe the previous active state. $ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'scripts\install-windows-bundle.ps1') +. (Join-Path $PSScriptRoot 'package-shim.ps1') +# Reject ambiguous legacy shim ownership before any service/configuration change. +$previousShim=Get-VmpowerPackageShimState $PSScriptRoot $inputRecord = Get-Content -Raw -LiteralPath (Join-Path $PSScriptRoot 'package-input.json') | ConvertFrom-Json $bundle = Join-Path $PSScriptRoot 'bundle' # Chocolatey shim suppression is package metadata, not a runtime bundle member. @@ -12,6 +16,9 @@ try { & (Join-Path $PSScriptRoot 'scripts\install-windows-bundle.ps1') -Apply ` -Bundle $bundle -ExpectedManifestSha256 $inputRecord.bundleManifestSha256 ` -NssmExe (Join-Path $PSScriptRoot 'nssm.exe') + # The old service uses its copied ProgramData executable, not this auto-shim. + # Retain it on activation failure; remove it only after successful handoff. + Remove-VmpowerPackageShim $PSScriptRoot $previousShim } finally { # Chocolatey scans executables after this script, including failed installs. [IO.File]::WriteAllBytes($ignore, [byte[]]@()) diff --git a/packages/win32/node24/tools/chocolateyUninstall.ps1 b/packages/win32/node24/tools/chocolateyUninstall.ps1 index b6f5b00..58c05be 100644 --- a/packages/win32/node24/tools/chocolateyUninstall.ps1 +++ b/packages/win32/node24/tools/chocolateyUninstall.ps1 @@ -1,6 +1,10 @@ $ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'scripts\install-windows-bundle.ps1') +. (Join-Path $PSScriptRoot 'package-shim.ps1') +$previousShim=Get-VmpowerPackageShimState $PSScriptRoot # Repeated removal is harmless; never call the legacy uninstall script. if (Get-Service -Name vmpoweragent -ErrorAction SilentlyContinue) { & (Join-Path $PSScriptRoot 'scripts\install-windows-bundle.ps1') -Apply -Action Uninstall } +Remove-VmpowerPackageShim $PSScriptRoot $previousShim # Configuration, content-addressed releases, wrapper and old executable remain. diff --git a/packages/win32/node24/tools/package-shim.ps1 b/packages/win32/node24/tools/package-shim.ps1 new file mode 100644 index 0000000..539d9c6 --- /dev/null +++ b/packages/win32/node24/tools/package-shim.ps1 @@ -0,0 +1,52 @@ +# Product ownership is different from disposable-fixture absent-before ownership. +# These exact shims were created by Chocolatey 2.7.4 from the two pinned 0.0.1 +# packages on the default root in native run 40437980932, job 121971195778. +# No candidate shim is executed, including with --shimgen-noop/help. +function Get-VmpowerPackageShimState([string]$ToolsDirectory) { + $root=[IO.Path]::GetFullPath($env:ChocolateyInstall).TrimEnd('\') + $expectedTools=Join-Path $root 'lib\vmpower-agent\tools' + if ([IO.Path]::GetFullPath($ToolsDirectory).TrimEnd('\') -ine $expectedTools) { + throw 'Unexpected package tools location; shim preserved' + } + $shim=Join-Path $root 'bin\vmpower-agent.amd64.exe' + Assert-NoReparsePoint $shim + if (-not (Test-Path -LiteralPath $shim)) { return @{status='ABSENT';path=$shim} } + if ($root -ine 'C:\ProgramData\chocolatey') { + throw 'Legacy shim root is unqualified; shim preserved' + } + $trusted=@('S-1-5-18','S-1-5-32-544',[Security.Principal.WindowsIdentity]::GetCurrent().User.Value) + $writeRights=[Security.AccessControl.FileSystemRights]'Write,Delete,DeleteSubdirectoriesAndFiles,ChangePermissions,TakeOwnership' + foreach ($entry in @($root,(Split-Path -Parent $shim),$shim)) { + $item=Get-Item -LiteralPath $entry -Force + if ($entry -ne $shim -and -not $item.PSIsContainer) { throw 'Unsafe shim parent; shim preserved' } + $acl=Get-Acl -LiteralPath $entry + if ($acl.GetOwner([Security.Principal.SecurityIdentifier]).Value -notin $trusted) { throw 'Untrusted shim owner; shim preserved' } + foreach ($rule in $acl.GetAccessRules($true,$true,[Security.Principal.SecurityIdentifier])) { + if ($rule.AccessControlType -eq 'Allow' -and ($rule.FileSystemRights -band $writeRights) -ne 0 -and + $rule.IdentityReference.Value -notin $trusted) { throw 'Untrusted shim write access; shim preserved' } + } + } + $file=Get-Item -LiteralPath $shim -Force + if ($file -isnot [IO.FileInfo] -or $file.PSIsContainer -or $file.Length -ne 121856) { + throw 'Unrecognized legacy shim file; shim preserved' + } + Assert-SingleLinkFile $shim + $sha=(Get-FileHash -LiteralPath $shim -Algorithm SHA256).Hash.ToLowerInvariant() + if ($sha -cnotin @('7d7dad5068622170edda0e1fa6ecfe906ed39cc8727186b404c4c1add1482795', + '00e537c680019d69bba7a2906c1338f600059ca27c40de93f710faf25f0015b6')) { + throw 'Unqualified legacy shim bytes; shim preserved' + } + return @{status='QUALIFIED_LEGACY';path=$shim;sha256=$sha;bytes=$file.Length} +} + +function Remove-VmpowerPackageShim([string]$ToolsDirectory,[hashtable]$Before) { + # Recheck after the service transaction; a changed/new file is not ours to delete. + $current=Get-VmpowerPackageShimState $ToolsDirectory + if ($current.path -ine $Before.path -or $current.status -cne $Before.status -or + ($current.status -eq 'QUALIFIED_LEGACY' -and $current.sha256 -cne $Before.sha256)) { + throw 'Shim changed during package operation; shim preserved' + } + if ($current.status -eq 'ABSENT') { return } + Remove-Item -LiteralPath $current.path -Force + if (Test-Path -LiteralPath $current.path) { throw 'Qualified legacy shim removal failed' } +} diff --git a/scripts/build-windows-package.py b/scripts/build-windows-package.py index 4591e1e..76d9b36 100644 --- a/scripts/build-windows-package.py +++ b/scripts/build-windows-package.py @@ -76,7 +76,7 @@ def prepare(bundle, expected, node_archive, nssm_archive, version, output, repo= (tools / 'nssm.exe.ignore').write_bytes(b'') (tools / 'node.exe.ignore').write_bytes(b'') (tools / 'NSSM-LICENSE.txt').write_bytes(license_bytes) - for name in ('chocolateyInstall.ps1', 'chocolateyUninstall.ps1'): + for name in ('chocolateyInstall.ps1', 'chocolateyUninstall.ps1', 'package-shim.ps1'): (tools / name).write_bytes(regular(repo / 'packages/win32/node24/tools' / name)) for relative in ('scripts/install-windows-bundle.ps1', 'distribution/nssm-pins.json'): target = tools / relative diff --git a/scripts/qualify-windows-package-ownership.ps1 b/scripts/qualify-windows-package-ownership.ps1 index 86efad1..e202b09 100644 --- a/scripts/qualify-windows-package-ownership.ps1 +++ b/scripts/qualify-windows-package-ownership.ps1 @@ -109,6 +109,18 @@ function Invoke-WindowsPackageOwnership([string]$LegacyPackages,[string]$Expecte $shim=Join-Path $env:ChocolateyInstall 'bin\vmpower-agent.amd64.exe' Assert-NoReparsePoint $shim;Assert-True (-not (Test-Path -LiteralPath $shim)) 'Legacy shim already exists' $owned.chocolatey=$script:chocolatey;$owned.chocolateyDirectories=@($directories);$owned.chocolateyShim=$shim;$owned.chocolateyShimAbsent=$true;Save-Json $journal $owned + # Actual product proof must reject an unrelated regular file, even at the + # right protected path and exact expected size. Never execute this sentinel. + . (Join-Path $PSScriptRoot '../packages/win32/node24/tools/package-shim.ps1') + $Evidence.phase='product-shim-ownership-refusal' + [IO.File]::WriteAllBytes($shim,(New-Object byte[] 121856)) + $sentinelHash=(Get-FileHash -LiteralPath $shim -Algorithm SHA256).Hash + $rejected=$false + try { $null=Get-VmpowerPackageShimState (Join-Path $env:ChocolateyInstall 'lib\vmpower-agent\tools') } catch { $rejected=$true } + Assert-True ($rejected -and (Test-Path -LiteralPath $shim) -and + (Get-FileHash -LiteralPath $shim -Algorithm SHA256).Hash -ceq $sentinelHash) 'Product proof accepted or changed an unrelated shim' + Remove-Item -LiteralPath $shim + $Evidence.unrelatedShimRefusal='PASS' $packages=Join-Path $Output 'chocolatey-ownership';$null=New-Item -ItemType Directory -Path $packages $runtimePins=Get-Content -Raw (Join-Path $PSScriptRoot '../distribution/runtime-pins.json') | ConvertFrom-Json $nodeArchive=Join-Path $Output $runtimePins.targets.'win-x64'.archive @@ -148,6 +160,7 @@ function Invoke-WindowsPackageOwnership([string]$LegacyPackages,[string]$Expecte '--yes','--skip-automation-scripts','--ignore-dependencies','--limit-output') Assert-True ((Get-OwnedChocolateyVersion) -ceq '0.0.1') 'Legacy ownership not registered' Assert-True (-not (Get-CimInstance Win32_Service -Filter "Name='vmpoweragent'")) 'Skipped legacy install created a service' + Assert-True (Test-Path -LiteralPath $shim -PathType Leaf) 'Legacy automatic shim missing; migration control not exercised' $familyEvidence.phases+=@('legacy-registration-scripts-skipped') $Evidence.phase='synthetic-service-setup' # Reuse only qualified modern Node bytes for the legacy SCM contract. @@ -166,6 +179,9 @@ function Invoke-WindowsPackageOwnership([string]$LegacyPackages,[string]$Expecte $Evidence.phase='modern-package-upgrade' $null=Invoke-OwnedChocolatey @('upgrade','vmpower-agent','--version=0.0.2',('--source='+$packages),'--yes','--limit-output') Assert-True ((Get-OwnedChocolateyVersion) -ceq '0.0.2') 'New package did not replace legacy registration' + # Product behavior must pass before any fixture reconciliation can run. + Assert-True (-not (Test-Path -LiteralPath $shim)) 'Product upgrade left the obsolete legacy shim' + $familyEvidence.phases+=@('product-removes-qualified-legacy-shim') $modern=Join-Path $product ('Agent\releases\'+$prepared.bundles.bundle+'\runtime\node.exe') $service=Get-CimInstance Win32_Service -Filter "Name='vmpoweragent'" if ($disabled) { @@ -183,11 +199,13 @@ function Invoke-WindowsPackageOwnership([string]$LegacyPackages,[string]$Expecte Assert-True $script:packageRecoveryObserved 'Failure did not reach installer service recovery' $null=Wait-Condition { (Receiver-State).count -gt $count };$null=Service-Child $modern Assert-True ((Get-OwnedChocolateyVersion) -ceq '0.0.2') 'Failed package did not restore prior registration' + Assert-True (-not (Test-Path -LiteralPath $shim)) 'Failed modern successor recreated a legacy shim' Assert-True ((Get-FileHash -LiteralPath $config -Algorithm SHA256).Hash -ceq $configDigest) 'Package rollback changed configuration' $familyEvidence.phases+=@('package-and-service-rollback');$Evidence.phase='modern-package-uninstall' # Run the NEW uninstaller normally, proving old destructive script is gone. $null=Invoke-OwnedChocolatey @('uninstall','vmpower-agent','--yes','--limit-output') Assert-True (-not (Get-OwnedChocolateyVersion)) 'Modern package registration remains' + Assert-True (-not (Test-Path -LiteralPath $shim)) 'Product uninstall requires fixture shim rescue' $null=Wait-Condition { -not (Get-CimInstance Win32_Service -Filter "Name='vmpoweragent'") } Assert-True ((Get-FileHash -LiteralPath $config -Algorithm SHA256).Hash -ceq $configDigest) 'Modern package uninstall deleted configuration' Assert-True (Test-Path -LiteralPath $modern) 'Package uninstall deleted rollback runtime' diff --git a/scripts/test-build-windows-package.py b/scripts/test-build-windows-package.py index baf4a63..27e8a40 100644 --- a/scripts/test-build-windows-package.py +++ b/scripts/test-build-windows-package.py @@ -30,6 +30,7 @@ def setUp(self): (self.repo / relative).mkdir(parents=True) for relative in ('scripts/install-windows-bundle.ps1', 'packages/win32/node24/tools/chocolateyInstall.ps1', + 'packages/win32/node24/tools/package-shim.ps1', 'packages/win32/node24/tools/chocolateyUninstall.ps1'): shutil.copyfile(ROOT / relative, self.repo / relative) (self.repo / 'package-lock.json').write_bytes(b'fixture lock, not a dependency graph') @@ -85,6 +86,8 @@ def test_same_id_upgrade_inputs_bind_runtime_and_retain_rollback_installer(self) self.assertEqual((self.output / 'tools/scripts/install-windows-bundle.ps1').read_bytes(), (ROOT / 'scripts/install-windows-bundle.ps1').read_bytes()) self.assertFalse((self.output / 'tools/chocolateyBeforeModify.ps1').exists()) + self.assertEqual((self.output / 'tools/package-shim.ps1').read_bytes(), + (ROOT / 'packages/win32/node24/tools/package-shim.ps1').read_bytes()) self.assertFalse(any(p.name == 'vmpower-agent.amd64.exe' for p in self.output.rglob('*'))) self.assertEqual(json.loads((self.output / 'tools/package-input.json').read_text())['bundleManifestSha256'], self.expected) diff --git a/scripts/test-windows-package-cleanup.ps1 b/scripts/test-windows-package-cleanup.ps1 index 9cfddc2..db496fb 100644 --- a/scripts/test-windows-package-cleanup.ps1 +++ b/scripts/test-windows-package-cleanup.ps1 @@ -88,7 +88,9 @@ try { Test-FailurePreservation $true $true;$cases++ Test-FailurePreservation $true $false;$cases++ Test-FailurePreservation $false $true;$cases++ - @{status='PASS';cases=$cases;packageOperations=0;serviceOperations=0;legacyExecutableRuns=0} | ConvertTo-Json -Compress + . (Join-Path $PSScriptRoot 'test-windows-product-shim.ps1') + $productCases=Test-WindowsProductShim $fixture + @{status='PASS';cases=($cases+$productCases);originalControls=$cases;productHookControls=$productCases;productHookOwnershipProof='synthetic in wiring controls; actual proof in native package phases';packageOperations=0;serviceOperations=0;legacyExecutableRuns=0} | ConvertTo-Json -Compress } finally { $env:ChocolateyInstall=$originalRoot if (Test-Path -LiteralPath $fixture) { Remove-Item -LiteralPath $fixture -Recurse -Force } diff --git a/scripts/test-windows-product-shim.ps1 b/scripts/test-windows-product-shim.ps1 new file mode 100644 index 0000000..4be5502 --- /dev/null +++ b/scripts/test-windows-product-shim.ps1 @@ -0,0 +1,78 @@ +# Run from the existing filesystem-only preflight. Actual package ownership proof +# is exercised separately by both real Chocolatey families in the native fixture. +function Test-WindowsProductShim([string]$FixtureRoot) { + $product=Join-Path $FixtureRoot 'product-hook-controls' + $tools=Join-Path $product 'lib\vmpower-agent\tools' + $bin=Join-Path $product 'bin';$shim=Join-Path $bin 'vmpower-agent.amd64.exe' + $null=New-Item -ItemType Directory -Path (Join-Path $tools 'scripts'),(Join-Path $tools 'bundle\runtime'),$bin -Force + $source=Join-Path $PSScriptRoot '../packages/win32/node24/tools' + foreach ($name in @('chocolateyInstall.ps1','chocolateyUninstall.ps1')) { + Copy-Item -LiteralPath (Join-Path $source $name) -Destination (Join-Path $tools $name) + } + [IO.File]::WriteAllText((Join-Path $tools 'package-input.json'),'{"bundleManifestSha256":"fixture"}') + # Stub only the service installer; real product hook order/removal is tested. + [IO.File]::WriteAllText((Join-Path $tools 'scripts\install-windows-bundle.ps1'),@' +param([switch]$Apply,[string]$Action,[string]$Bundle,[string]$ExpectedManifestSha256,[string]$NssmExe) +if ($MyInvocation.InvocationName -ne '.') { + $global:productShimServiceCalls++ + if ($global:productShimInstallerFails) { throw 'synthetic activation failure' } + if ($global:productShimChanges) { [IO.File]::WriteAllText($global:productShimFile,'changed by another owner') } +} +'@) + # The positive wiring control uses generated nonexecutables, not a forged real + # ownership claim. Replace only the proof producer; keep actual removal code. + $helper=[IO.File]::ReadAllText((Join-Path $source 'package-shim.ps1')) + $helper=$helper.Replace('function Get-VmpowerPackageShimState(', 'function Get-UnusedRealPackageShimState(') + $helper+=@' + +function Get-VmpowerPackageShimState([string]$ToolsDirectory) { + $file=$global:productShimFile + if (-not (Test-Path -LiteralPath $file)) { return @{status='ABSENT';path=$file} } + if ([IO.File]::ReadAllText($file) -cne 'owned synthetic shim') { throw 'unowned fixture shim' } + return @{status='QUALIFIED_LEGACY';path=$file;sha256=(Get-FileHash -LiteralPath $file -Algorithm SHA256).Hash;bytes=(Get-Item $file).Length} +} +'@ + [IO.File]::WriteAllText((Join-Path $tools 'package-shim.ps1'),$helper) + $originalRoot=$env:ChocolateyInstall + try { + $env:ChocolateyInstall=$product + $global:productShimFile=$shim;$global:productShimServiceCalls=0 + $global:productShimInstallerFails=$false;$global:productShimChanges=$false + $neighbor=Join-Path $bin 'unrelated.exe';[IO.File]::WriteAllText($neighbor,'keep') + function Get-Service { return $true } # No service-manager call in these controls. + [IO.File]::WriteAllText($shim,'owned synthetic shim') + & (Join-Path $tools 'chocolateyInstall.ps1') + Assert-True (-not (Test-Path -LiteralPath $shim) -and $global:productShimServiceCalls -eq 1) 'Product install did not remove shim after activation' + [IO.File]::WriteAllText($shim,'owned synthetic shim');$global:productShimInstallerFails=$true + Assert-Rejected { & (Join-Path $tools 'chocolateyInstall.ps1') } 'activation failure' + Assert-True ([IO.File]::ReadAllText($shim) -ceq 'owned synthetic shim') 'Failed activation lost predecessor shim' + $global:productShimInstallerFails=$false + [IO.File]::WriteAllText($shim,'custom unrelated shim');$before=$global:productShimServiceCalls + Assert-Rejected { & (Join-Path $tools 'chocolateyInstall.ps1') } 'unowned product install shim' + Assert-True ($global:productShimServiceCalls -eq $before -and [IO.File]::ReadAllText($shim) -ceq 'custom unrelated shim') 'Ambiguous install mutated service or shim' + Assert-Rejected { & (Join-Path $tools 'chocolateyUninstall.ps1') } 'unowned product uninstall shim' + Assert-True ($global:productShimServiceCalls -eq $before -and [IO.File]::ReadAllText($shim) -ceq 'custom unrelated shim') 'Ambiguous uninstall mutated service or shim' + [IO.File]::WriteAllText($shim,'owned synthetic shim') + & (Join-Path $tools 'chocolateyUninstall.ps1') + Assert-True (-not (Test-Path -LiteralPath $shim)) 'Product uninstall left shim for fixture rescue' + & (Join-Path $tools 'chocolateyInstall.ps1') + Assert-True (-not (Test-Path -LiteralPath $shim)) 'Absent shim recreated' + [IO.File]::WriteAllText($shim,'owned synthetic shim');$global:productShimChanges=$true + Assert-Rejected { & (Join-Path $tools 'chocolateyInstall.ps1') } 'changed during activation' + Assert-True ([IO.File]::ReadAllText($shim) -ceq 'changed by another owner') 'Intervening shim replacement deleted' + Assert-True ([IO.File]::ReadAllText($neighbor) -ceq 'keep') 'Neighbor modified' + # Actual proof helper, without stubbing: custom roots/locations must fail + # closed, and an absent shim needs no inferred ownership. + . (Join-Path $source 'package-shim.ps1') + Assert-Rejected { Get-VmpowerPackageShimState $tools } 'unqualified existing shim root' + Assert-Rejected { Get-VmpowerPackageShimState $bin } 'foreign tools location' + Remove-Item -LiteralPath $shim + Assert-True ((Get-VmpowerPackageShimState $tools).status -ceq 'ABSENT') 'Actual absent-shim proof failed' + return 10 + } finally { + $env:ChocolateyInstall=$originalRoot + foreach ($name in @('productShimFile','productShimServiceCalls','productShimInstallerFails','productShimChanges')) { + Remove-Variable -Name $name -Scope Global -ErrorAction SilentlyContinue + } + } +} From 0e9dc09800090a26ed43c8dbf1f083ffe57a2c95 Mon Sep 17 00:00:00 2001 From: Spencer Albrecht Date: Mon, 26 Oct 2026 20:27:37 -0700 Subject: [PATCH 08/18] Keep Windows package finalization within service rollback --- packages/win32/node24/README.md | 20 ++++++- .../win32/node24/tools/chocolateyInstall.ps1 | 17 ++++-- .../node24/tools/chocolateyUninstall.ps1 | 3 +- scripts/install-windows-bundle.ps1 | 12 +++- scripts/qualify-windows-package-ownership.ps1 | 48 ++++++++++++++-- scripts/test-install-windows-bundle.ps1 | 56 ++++++++++++++++++- scripts/test-windows-product-shim.ps1 | 27 ++++++++- 7 files changed, 160 insertions(+), 23 deletions(-) diff --git a/packages/win32/node24/README.md b/packages/win32/node24/README.md index a55795a..72f4013 100644 --- a/packages/win32/node24/README.md +++ b/packages/win32/node24/README.md @@ -6,7 +6,8 @@ orphan legacy shim; it did not prove product-side shim cleanup. The following product-hook correction is source-only until a new admitted native run passes. The product hooks now preflight the exact `bin\vmpower-agent.amd64.exe` path and -remove it only after successful activation/removal. Existing shims must match +remove it inside the recoverable activation transaction, or before service removal +on uninstall. Existing shims must match one of the two exact SHA-256/size pairs observed from the pinned legacy packages in that run, under `C:\ProgramData\chocolatey`, with protected ownership/ACLs, no reparse points and a single link. Unknown bytes, custom roots, unsafe parents @@ -31,6 +32,18 @@ Post-success rollback to legacy is still unqualified: in-memory installer failur recovery is not a persistent operator rollback command, and normal old-package downgrade would overwrite configuration. +The first product correction (`0416667`) incorrectly removed the shim after the +service transaction returned. A changed/locked/denied shim could then fail the +package operation while leaving the service on the new runtime. The successor +places both Chocolatey ignore-marker restoration and shim removal inside the +installer's activation catch boundary. Compensation restores wrapper, application, +arguments, directory, NSSM startup mode and prior running/stopped state; failures +remain failures. Uninstall reconciles the shim before touching the service. +Controls exercise changed, locked, denied and marker failures through the actual +installer with modeled SCM; a synthetic native package additionally requires +real Chocolatey registration and actual service/child/config restoration while +preserving the unrelated replacement. This is source preparation, not native proof. + Use the existing `vmpower-agent` package ID with an explicit version greater than `0.0.1`. A normal Chocolatey upgrade invokes the **new** install script after the **old** before-modify script. It does not run the old uninstall script. Both @@ -125,11 +138,14 @@ uninstall, without script-skipping, to test: - removal of owned package registration, exact caches, legacy shim, service, processes, firewall rules and fixture inputs. -Versions `0.0.2` and crashing `0.0.3` in this fixture are test inputs and must never +Versions `0.0.2`, crashing `0.0.3`, and finalization-failure `0.0.4` in this fixture are test inputs and must never be published by qualification. The successful `0.0.2` archive contains the unmodified product `bundle`, not the synthetic comment-bearing `upgrade` bundle; the default lifecycle fixture still checks that separate synthetic upgrade. A final version and retained deliverable artifact require separate root admission. +The `0.0.4` fixture uses the explicitly synthetic upgrade bundle and a labeled +test finalizer. It must fail after activation, preserve the replacement sentinel, +and restore `0.0.2` package/service state before ordinary uninstall is tested. Cleanup skips all old automation and the auto-uninstaller, including when upgrade failed while `0.0.1` remains registered. Exact package directories and the legacy shim must be absent before ownership is recorded. A cleanup failure diff --git a/packages/win32/node24/tools/chocolateyInstall.ps1 b/packages/win32/node24/tools/chocolateyInstall.ps1 index 85af626..2088649 100644 --- a/packages/win32/node24/tools/chocolateyInstall.ps1 +++ b/packages/win32/node24/tools/chocolateyInstall.ps1 @@ -5,6 +5,7 @@ $ErrorActionPreference = 'Stop' . (Join-Path $PSScriptRoot 'package-shim.ps1') # Reject ambiguous legacy shim ownership before any service/configuration change. $previousShim=Get-VmpowerPackageShimState $PSScriptRoot +$packageTools=$PSScriptRoot $inputRecord = Get-Content -Raw -LiteralPath (Join-Path $PSScriptRoot 'package-input.json') | ConvertFrom-Json $bundle = Join-Path $PSScriptRoot 'bundle' # Chocolatey shim suppression is package metadata, not a runtime bundle member. @@ -15,11 +16,15 @@ if (Test-Path -LiteralPath $ignore) { Remove-Item -LiteralPath $ignore } try { & (Join-Path $PSScriptRoot 'scripts\install-windows-bundle.ps1') -Apply ` -Bundle $bundle -ExpectedManifestSha256 $inputRecord.bundleManifestSha256 ` - -NssmExe (Join-Path $PSScriptRoot 'nssm.exe') - # The old service uses its copied ProgramData executable, not this auto-shim. - # Retain it on activation failure; remove it only after successful handoff. - Remove-VmpowerPackageShim $PSScriptRoot $previousShim -} finally { - # Chocolatey scans executables after this script, including failed installs. + -NssmExe (Join-Path $PSScriptRoot 'nssm.exe') -AfterActivation { + # Both operations can throw; keep them inside SCM/runtime rollback. + [IO.File]::WriteAllBytes($ignore, [byte[]]@()) + Remove-VmpowerPackageShim $packageTools $previousShim + } +} catch { + # Activation/finalization has already restored the prior service. Chocolatey + # still scans failed installs, so restore its marker without claiming success. + $activationFailure=$_ [IO.File]::WriteAllBytes($ignore, [byte[]]@()) + throw $activationFailure } diff --git a/packages/win32/node24/tools/chocolateyUninstall.ps1 b/packages/win32/node24/tools/chocolateyUninstall.ps1 index 58c05be..b22468e 100644 --- a/packages/win32/node24/tools/chocolateyUninstall.ps1 +++ b/packages/win32/node24/tools/chocolateyUninstall.ps1 @@ -2,9 +2,10 @@ $ErrorActionPreference = 'Stop' . (Join-Path $PSScriptRoot 'scripts\install-windows-bundle.ps1') . (Join-Path $PSScriptRoot 'package-shim.ps1') $previousShim=Get-VmpowerPackageShimState $PSScriptRoot +# A changed/locked/denied shim must fail before the service can be removed. +Remove-VmpowerPackageShim $PSScriptRoot $previousShim # Repeated removal is harmless; never call the legacy uninstall script. if (Get-Service -Name vmpoweragent -ErrorAction SilentlyContinue) { & (Join-Path $PSScriptRoot 'scripts\install-windows-bundle.ps1') -Apply -Action Uninstall } -Remove-VmpowerPackageShim $PSScriptRoot $previousShim # Configuration, content-addressed releases, wrapper and old executable remain. diff --git a/scripts/install-windows-bundle.ps1 b/scripts/install-windows-bundle.ps1 index bcf98cd..b6519c0 100644 --- a/scripts/install-windows-bundle.ps1 +++ b/scripts/install-windows-bundle.ps1 @@ -4,6 +4,7 @@ param( [string]$ExpectedManifestSha256, [string]$NssmExe, [switch]$ConfigStdin, + [scriptblock]$AfterActivation, [switch]$Apply ) Set-StrictMode -Version Latest @@ -156,6 +157,7 @@ function Get-ServiceSnapshot([string]$Name, [string]$Nssm) { if ($service.StartName -ne 'LocalSystem') { throw 'Existing service uses a custom identity; preserve it for explicit migration qualification' } if ($service.PathName -notmatch '(?i)(^|[\\"])nssm\.exe"?$') { throw 'Existing service is not the NSSM contract' } return @{ ImagePath=$service.PathName; Active=($service.State -eq 'Running'); + Start=(Invoke-CheckedNative $Nssm @('get',$Name,'Start')); Application=(Invoke-CheckedNative $Nssm @('get',$Name,'Application')); AppDirectory=(Invoke-CheckedNative $Nssm @('get',$Name,'AppDirectory')); AppParameters=(Invoke-CheckedNative $Nssm @('get',$Name,'AppParameters')) } @@ -186,7 +188,8 @@ function Wait-StableServiceChild([string]$Name,[string]$Executable) { throw 'Stable service child was not observed' } -function Install-AgentBundle([string]$Source, [string]$Expected, [string]$Wrapper, [string]$IncomingJson) { +function Install-AgentBundle([string]$Source, [string]$Expected, [string]$Wrapper, [string]$IncomingJson, + [scriptblock]$AfterActivation = $null) { $pins = Get-Content -Raw -LiteralPath (Join-Path $PSScriptRoot '../distribution/nssm-pins.json') | ConvertFrom-Json Assert-NoReparsePoint $Wrapper if ((Get-FileHash -LiteralPath $Wrapper -Algorithm SHA256).Hash.ToLowerInvariant() -ne $pins.x64Sha256) { throw 'Unqualified NSSM executable' } @@ -250,13 +253,16 @@ function Install-AgentBundle([string]$Source, [string]$Expected, [string]$Wrappe if ((Get-Service -Name $serviceName).Status -ne 'Running') { throw 'Service failed to start' } Wait-StableServiceChild $serviceName $node } + # Package finalization is part of activation: its failure must restore + # SCM/runtime before Chocolatey rolls back its own package files. + if ($AfterActivation) { & $AfterActivation } } catch { try { $current=Get-Service -Name $serviceName -ErrorAction SilentlyContinue if ($current -and $current.Status -ne 'Stopped') { $null=Invoke-CheckedNative $installedWrapper @('stop',$serviceName) } if ($created) { $null=Invoke-CheckedNative $installedWrapper @('remove',$serviceName,'confirm') } elseif ($previous) { - foreach ($setting in @('Application','AppDirectory','AppParameters')) { $null=Invoke-CheckedNative $installedWrapper @('set',$serviceName,$setting,$previous[$setting]) } + foreach ($setting in @('Application','AppDirectory','AppParameters','Start')) { $null=Invoke-CheckedNative $installedWrapper @('set',$serviceName,$setting,$previous[$setting]) } $null=Invoke-CheckedNative 'sc.exe' @('config',$serviceName,'binPath=',$previous.ImagePath) if ($previous.Active) { Start-Service -Name $serviceName } } @@ -287,6 +293,6 @@ if ($MyInvocation.InvocationName -ne '.') { if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw 'Administrator required' } if ($Action -eq 'Install') { $json=if ($ConfigStdin) { [Console]::In.ReadToEnd() } else { $null } - Install-AgentBundle $Bundle $ExpectedManifestSha256 $NssmExe $json | ConvertTo-Json + Install-AgentBundle $Bundle $ExpectedManifestSha256 $NssmExe $json $AfterActivation | ConvertTo-Json } else { Uninstall-AgentBundle | ConvertTo-Json } } diff --git a/scripts/qualify-windows-package-ownership.ps1 b/scripts/qualify-windows-package-ownership.ps1 index e202b09..b0b39d5 100644 --- a/scripts/qualify-windows-package-ownership.ps1 +++ b/scripts/qualify-windows-package-ownership.ps1 @@ -36,7 +36,7 @@ function Invoke-OwnedChocolatey([string[]]$Arguments) { function Get-OwnedChocolateyVersion { $line=Invoke-OwnedChocolatey @('list','--exact','vmpower-agent','--limit-output') if ([string]::IsNullOrWhiteSpace($line)) { return $null } - if ($line -notmatch '^vmpower-agent\|(0\.0\.[123])$') { throw 'Unexpected package registration; preserve for inspection' } + if ($line -notmatch '^vmpower-agent\|(0\.0\.[1234])$') { throw 'Unexpected package registration; preserve for inspection' } return $Matches[1] } @@ -127,13 +127,33 @@ function Invoke-WindowsPackageOwnership([string]$LegacyPackages,[string]$Expecte $packedProof=@() # Successful ownership uses the unmodified product bundle; only the failing # successor is synthetic. Final release/version admission remains separate. - foreach ($variant in @(@{name='bundle';version='0.0.2'},@{name='crashing';version='0.0.3'})) { + foreach ($variant in @(@{name='bundle';source='bundle';version='0.0.2'},@{name='crashing';source='crashing';version='0.0.3'}, + @{name='shim-failure';source='upgrade';version='0.0.4'})) { $Evidence.phase='build-'+$variant.name $inputs=Join-Path $packages $variant.name - $buildArguments=@((Join-Path $PSScriptRoot 'build-windows-package.py'),'--bundle',(Join-Path $Output $variant.name), - '--expected-manifest-sha256',$prepared.bundles.($variant.name),'--node-archive',$nodeArchive, + $buildArguments=@((Join-Path $PSScriptRoot 'build-windows-package.py'),'--bundle',(Join-Path $Output $variant.source), + '--expected-manifest-sha256',$prepared.bundles.($variant.source),'--node-archive',$nodeArchive, '--nssm-archive',(Join-Path $Output 'nssm.zip'),'--version',$variant.version,'--output',$inputs) $null=Invoke-CheckedNative (Get-Command python).Source $buildArguments + if ($variant.name -eq 'shim-failure') { + # Explicit synthetic package only: replace an absent shim after the + # real installer activates a different content-addressed runtime. + # Invoke the actual proof/removal code, which must refuse it and + # trigger the existing SCM transaction plus Chocolatey rollback. + $helperPath=Join-Path $inputs 'tools\package-shim.ps1' + $helper=[IO.File]::ReadAllText($helperPath).Replace('function Remove-VmpowerPackageShim(', 'function Remove-VerifiedVmpowerPackageShim(') + $helper+=@' + +# SYNTHETIC FINALIZATION FAILURE FIXTURE; NEVER PUBLISH. +function Remove-VmpowerPackageShim([string]$ToolsDirectory,[hashtable]$Before) { + $sentinel=Join-Path $env:ChocolateyInstall 'bin\vmpower-agent.amd64.exe' + if (Test-Path -LiteralPath $sentinel) { throw 'Unexpected shim before synthetic replacement' } + [IO.File]::WriteAllText($sentinel,'synthetic unrelated replacement after activation') + Remove-VerifiedVmpowerPackageShim $ToolsDirectory $Before +} +'@ + [IO.File]::WriteAllText($helperPath,$helper) + } $Evidence.phase='pack-'+$variant.name $null=Invoke-OwnedChocolatey @('pack',(Join-Path $inputs 'vmpower-agent.nuspec'),('--output-directory='+$packages),'--limit-output') $packed=Join-Path $packages ('vmpower-agent.'+$variant.version+'.nupkg') @@ -201,7 +221,25 @@ function Invoke-WindowsPackageOwnership([string]$LegacyPackages,[string]$Expecte Assert-True ((Get-OwnedChocolateyVersion) -ceq '0.0.2') 'Failed package did not restore prior registration' Assert-True (-not (Test-Path -LiteralPath $shim)) 'Failed modern successor recreated a legacy shim' Assert-True ((Get-FileHash -LiteralPath $config -Algorithm SHA256).Hash -ceq $configDigest) 'Package rollback changed configuration' - $familyEvidence.phases+=@('package-and-service-rollback');$Evidence.phase='modern-package-uninstall' + $familyEvidence.phases+=@('package-and-service-rollback') + $Evidence.phase='post-activation-shim-failure-rollback' + $priorService=Get-ServiceSnapshot 'vmpoweragent' $wrapper + $count=(Receiver-State).count;$failed=$false + try { $null=Invoke-OwnedChocolatey @('upgrade','vmpower-agent','--version=0.0.4',('--source='+$packages),'--yes','--limit-output') } catch { $failed=$true } + Assert-True ($failed -and -not $script:packageTimedOut -and $script:packageRecoveryObserved) 'Finalization failure did not invoke service compensation' + Assert-True ((Get-OwnedChocolateyVersion) -ceq '0.0.2') 'Finalization failure did not restore prior package registration' + $restoredService=Get-ServiceSnapshot 'vmpoweragent' $wrapper + foreach ($setting in @('ImagePath','Application','AppDirectory','AppParameters','Active','Start')) { + Assert-True ($restoredService[$setting] -ceq $priorService[$setting]) ('Finalization did not restore service '+$setting) + } + $null=Wait-Condition { (Receiver-State).count -gt $count };$null=Service-Child $modern + Assert-True ((Get-FileHash -LiteralPath $config -Algorithm SHA256).Hash -ceq $configDigest) 'Finalization rollback changed configuration' + Assert-True ((Test-Path -LiteralPath $shim) -and [IO.File]::ReadAllText($shim) -ceq 'synthetic unrelated replacement after activation') 'Finalization rollback changed unrelated shim' + $familyEvidence.phases+=@('post-activation-shim-failure-restores-package-service') + # Remove only the exact sentinel this adverse control created, after its + # preservation and service/package compensation assertions succeeded. + Remove-Item -LiteralPath $shim + $Evidence.phase='modern-package-uninstall' # Run the NEW uninstaller normally, proving old destructive script is gone. $null=Invoke-OwnedChocolatey @('uninstall','vmpower-agent','--yes','--limit-output') Assert-True (-not (Get-OwnedChocolateyVersion)) 'Modern package registration remains' diff --git a/scripts/test-install-windows-bundle.ps1 b/scripts/test-install-windows-bundle.ps1 index c164a7d..efe8a09 100644 --- a/scripts/test-install-windows-bundle.ps1 +++ b/scripts/test-install-windows-bundle.ps1 @@ -39,6 +39,55 @@ function Invoke-CheckedNative([string]$Exe,[string[]]$Arguments) { if ($Arguments[0] -eq 'start' -and $script:failStart) { $script:failStart=$false;throw 'Synthetic startup failure' } return '' } +function Test-FinalizationRollback([string]$Kind) { + # Run the actual installer transaction; substitute only SCM/NSSM operations. + # A post-activation callback must restore state, not merely preserve a file. + $script:transactionState=@{ImagePath='"C:\prior wrapper\nssm.exe"';Active=($Kind -ne 'denied'); + Start=$(if ($Kind -eq 'denied') {'SERVICE_DISABLED'} else {'SERVICE_DEMAND_START'}); + Application='C:\prior runtime\node.exe';AppDirectory='C:\prior runtime';AppParameters='"prior app.js"'} + $expectedState=$script:transactionState.Clone() + function Get-ServiceSnapshot { return $script:transactionState.Clone() } + function Get-Service { return @{Status=$(if ($script:transactionState.Active) {'Running'} else {'Stopped'})} } + function Start-Service { $script:transactionState.Active=$true } + function Wait-StableServiceChild { } + function Invoke-CheckedNative([string]$Exe,[string[]]$Arguments) { + if ($Exe -eq 'sc.exe') { $script:transactionState.ImagePath=$Arguments[3] } + elseif ($Arguments[0] -eq 'set') { $script:transactionState[$Arguments[2]]=$Arguments[3] } + elseif ($Arguments[0] -eq 'stop') { $script:transactionState.Active=$false } + elseif ($Arguments[0] -eq 'start') { $script:transactionState.Active=$true } + else { throw 'Unexpected native operation in finalization control' } + return '' + } + $sentinel=Join-Path $temporary ('finalization-'+$Kind+'.exe') + [IO.File]::WriteAllText($sentinel,'retained shim') + $lock=$null;$script:finalizationCallbackReached=$false;$failed=$false + try { + if ($Kind -eq 'locked') { $lock=[IO.File]::Open($sentinel,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::Read) } + try { + $null=Install-AgentBundle $Bundle $ExpectedManifestSha256 $NssmExe $null { + Assert-True ($script:transactionState.Application -ne $expectedState.Application) 'Callback did not run after new runtime activation' + $script:finalizationCallbackReached=$true + if ($Kind -eq 'changed') { + [IO.File]::WriteAllText($sentinel,'replacement owned by another actor') + throw 'Synthetic changed shim refusal' + } + if ($Kind -eq 'denied') { throw (New-Object UnauthorizedAccessException 'Synthetic shim deletion denial') } + if ($Kind -eq 'marker') { throw (New-Object IO.IOException 'Synthetic marker restoration failure') } + Remove-Item -LiteralPath $sentinel -Force -ErrorAction Stop + } + } catch { $failed=$true } + Assert-True ($failed -and $script:finalizationCallbackReached) 'Post-activation failure control did not reach rollback' + foreach ($setting in $expectedState.Keys) { + Assert-True ($script:transactionState[$setting] -ceq $expectedState[$setting]) ('Finalization failed to restore '+$setting) + } + Assert-True ([Convert]::ToBase64String([IO.File]::ReadAllBytes($config)) -eq [Convert]::ToBase64String($before)) 'Finalization failure changed configuration' + Assert-True (Test-Path -LiteralPath $sentinel) 'Finalization compensation deleted shim' + if ($Kind -eq 'changed') { Assert-True ([IO.File]::ReadAllText($sentinel) -ceq 'replacement owned by another actor') 'Compensation overwrote unrelated replacement' } + } finally { + if ($lock) { $lock.Dispose() } + $script:finalizationCallbackReached=$false + } +} try { $null=New-Item -ItemType Directory -Path $temporary $null=New-Item -ItemType Directory -Path (Join-Path $temporary 'Program Files'),(Join-Path $temporary 'ProgramData') @@ -74,10 +123,10 @@ try { $null=New-Item -ItemType HardLink -Path $hardlink -Target $config Assert-Throws { Ensure-Config $config $json } Remove-Item -LiteralPath $hardlink - $script:previous=@{ImagePath='"C:\legacy wrapper\nssm.exe"';Active=$true;Application='C:\legacy agent\vmpower.exe';AppDirectory='C:\legacy agent';AppParameters='--fixture "two words"'} + $script:previous=@{ImagePath='"C:\legacy wrapper\nssm.exe"';Active=$true;Start='SERVICE_DEMAND_START';Application='C:\legacy agent\vmpower.exe';AppDirectory='C:\legacy agent';AppParameters='--fixture "two words"'} $script:failStart=$true Assert-Throws { Install-AgentBundle $Bundle $ExpectedManifestSha256 $NssmExe $null } - foreach ($setting in @('Application','AppDirectory','AppParameters')) { + foreach ($setting in @('Application','AppDirectory','AppParameters','Start')) { $restored=@($script:events | Where-Object { $_.Count -eq 5 -and $_[1] -eq 'set' -and $_[3] -eq $setting -and $_[4] -ceq $script:previous[$setting] }) Assert-True ($restored.Count -eq 1) ('Previous '+$setting+' not restored') } @@ -103,7 +152,8 @@ try { Assert-True (@($script:events | Where-Object { $_.Count -eq 4 -and $_[1] -eq 'remove' -and $_[2] -eq 'vmpoweragent' -and $_[3] -eq 'confirm' }).Count -eq 1) 'Uninstall did not remove the mocked service' Assert-True (Test-Path -LiteralPath $config) 'Uninstall removed config' Assert-True (Test-Path -LiteralPath $installedNode) 'Uninstall removed retained release' - @{status='PASS';nativeArgv='PASS';configuration='PASS';ACL='PASS';mockedServiceRollback='PASS';serviceLifecycle='NOT_TESTED'} | ConvertTo-Json + foreach ($fault in @('changed','locked','denied','marker')) { Test-FinalizationRollback $fault } + @{status='PASS';nativeArgv='PASS';configuration='PASS';ACL='PASS';mockedServiceRollback='PASS';postActivationFailureStateControls=4;serviceLifecycle='NOT_TESTED'} | ConvertTo-Json } finally { if (Test-Path -LiteralPath $temporary) { Remove-Item -LiteralPath $temporary -Recurse -Force } } diff --git a/scripts/test-windows-product-shim.ps1 b/scripts/test-windows-product-shim.ps1 index 4be5502..176b87a 100644 --- a/scripts/test-windows-product-shim.ps1 +++ b/scripts/test-windows-product-shim.ps1 @@ -12,11 +12,12 @@ function Test-WindowsProductShim([string]$FixtureRoot) { [IO.File]::WriteAllText((Join-Path $tools 'package-input.json'),'{"bundleManifestSha256":"fixture"}') # Stub only the service installer; real product hook order/removal is tested. [IO.File]::WriteAllText((Join-Path $tools 'scripts\install-windows-bundle.ps1'),@' -param([switch]$Apply,[string]$Action,[string]$Bundle,[string]$ExpectedManifestSha256,[string]$NssmExe) +param([switch]$Apply,[string]$Action,[string]$Bundle,[string]$ExpectedManifestSha256,[string]$NssmExe,[scriptblock]$AfterActivation) if ($MyInvocation.InvocationName -ne '.') { $global:productShimServiceCalls++ if ($global:productShimInstallerFails) { throw 'synthetic activation failure' } if ($global:productShimChanges) { [IO.File]::WriteAllText($global:productShimFile,'changed by another owner') } + if ($AfterActivation) { & $AfterActivation } } '@) # The positive wiring control uses generated nonexecutables, not a forged real @@ -27,6 +28,8 @@ if ($MyInvocation.InvocationName -ne '.') { function Get-VmpowerPackageShimState([string]$ToolsDirectory) { $file=$global:productShimFile + $global:productShimProofCalls++ + if ($global:productShimChangeOnRecheck -and $global:productShimProofCalls -eq 2) { [IO.File]::WriteAllText($file,'replacement during uninstall') } if (-not (Test-Path -LiteralPath $file)) { return @{status='ABSENT';path=$file} } if ([IO.File]::ReadAllText($file) -cne 'owned synthetic shim') { throw 'unowned fixture shim' } return @{status='QUALIFIED_LEGACY';path=$file;sha256=(Get-FileHash -LiteralPath $file -Algorithm SHA256).Hash;bytes=(Get-Item $file).Length} @@ -38,6 +41,7 @@ function Get-VmpowerPackageShimState([string]$ToolsDirectory) { $env:ChocolateyInstall=$product $global:productShimFile=$shim;$global:productShimServiceCalls=0 $global:productShimInstallerFails=$false;$global:productShimChanges=$false + $global:productShimProofCalls=0;$global:productShimChangeOnRecheck=$false $neighbor=Join-Path $bin 'unrelated.exe';[IO.File]::WriteAllText($neighbor,'keep') function Get-Service { return $true } # No service-manager call in these controls. [IO.File]::WriteAllText($shim,'owned synthetic shim') @@ -61,6 +65,23 @@ function Get-VmpowerPackageShimState([string]$ToolsDirectory) { Assert-Rejected { & (Join-Path $tools 'chocolateyInstall.ps1') } 'changed during activation' Assert-True ([IO.File]::ReadAllText($shim) -ceq 'changed by another owner') 'Intervening shim replacement deleted' Assert-True ([IO.File]::ReadAllText($neighbor) -ceq 'keep') 'Neighbor modified' + $global:productShimChanges=$false;$global:productShimChangeOnRecheck=$true;$global:productShimProofCalls=0 + [IO.File]::WriteAllText($shim,'owned synthetic shim');$before=$global:productShimServiceCalls + Assert-Rejected { & (Join-Path $tools 'chocolateyUninstall.ps1') } 'changed shim during uninstall' + Assert-True ($global:productShimServiceCalls -eq $before -and [IO.File]::ReadAllText($shim) -ceq 'replacement during uninstall') 'Failed shim reconciliation removed service or unrelated replacement' + $global:productShimChangeOnRecheck=$false + [IO.File]::WriteAllText($shim,'owned synthetic shim');$before=$global:productShimServiceCalls + $lock=[IO.File]::Open($shim,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::Read) + try { + Assert-Rejected { & (Join-Path $tools 'chocolateyUninstall.ps1') } 'locked shim during uninstall' + Assert-True ($global:productShimServiceCalls -eq $before -and (Test-Path -LiteralPath $shim)) 'Locked shim failure removed service' + } finally { $lock.Dispose() } + $before=$global:productShimServiceCalls + & { + function Remove-Item { throw (New-Object UnauthorizedAccessException 'Synthetic deletion denial') } + Assert-Rejected { & (Join-Path $tools 'chocolateyUninstall.ps1') } 'denied shim during uninstall' + } + Assert-True ($global:productShimServiceCalls -eq $before -and (Test-Path -LiteralPath $shim)) 'Denied shim failure removed service' # Actual proof helper, without stubbing: custom roots/locations must fail # closed, and an absent shim needs no inferred ownership. . (Join-Path $source 'package-shim.ps1') @@ -68,10 +89,10 @@ function Get-VmpowerPackageShimState([string]$ToolsDirectory) { Assert-Rejected { Get-VmpowerPackageShimState $bin } 'foreign tools location' Remove-Item -LiteralPath $shim Assert-True ((Get-VmpowerPackageShimState $tools).status -ceq 'ABSENT') 'Actual absent-shim proof failed' - return 10 + return 13 } finally { $env:ChocolateyInstall=$originalRoot - foreach ($name in @('productShimFile','productShimServiceCalls','productShimInstallerFails','productShimChanges')) { + foreach ($name in @('productShimFile','productShimServiceCalls','productShimInstallerFails','productShimChanges','productShimProofCalls','productShimChangeOnRecheck')) { Remove-Variable -Name $name -Scope Global -ErrorAction SilentlyContinue } } From 9fb4a604097c273f7f979a8d7329cb07db141621 Mon Sep 17 00:00:00 2001 From: Spencer Albrecht Date: Mon, 26 Oct 2026 20:53:02 -0700 Subject: [PATCH 09/18] Retain fixed Windows package failure diagnostics --- packages/win32/node24/README.md | 20 ++++++++ scripts/qualify-windows-package-ownership.ps1 | 46 ++++++++++++++++++- scripts/test-windows-package-cleanup.ps1 | 28 ++++++++++- 3 files changed, 90 insertions(+), 4 deletions(-) diff --git a/packages/win32/node24/README.md b/packages/win32/node24/README.md index 72f4013..cee448e 100644 --- a/packages/win32/node24/README.md +++ b/packages/win32/node24/README.md @@ -5,6 +5,26 @@ PowerShell 5.1 and Chocolatey 2.7.4. That pass included fixture removal of an orphan legacy shim; it did not prove product-side shim cleanup. The following product-hook correction is source-only until a new admitted native run passes. +Run `40445133666` at `0e9dc09` subsequently passed 26 preflight controls, four +modeled finalization-state controls and the original nine lifecycle phases, but +failed at the first docs-family modern package upgrade. Cleanup passed separately. +No actual product shim handoff or post-activation package compensation passed in +that run. Its underlying Chocolatey error was not retained, so the cause remains +Unknown. The qualifier now maps captured output only to a closed set of fixed +shim/installer labels; unrecognized errors become `UNKNOWN`. It never writes raw +output, paths, credentials or excerpts. Six redaction controls include secret-like +unknown errors and recognized errors mixed with secret-like text. New diagnostic +execution remains unrun until an admitted native job proves it. + +Do not assume repeatable shim SHA-256 from a fixed Chocolatey version. The +[official ShimGen repository](https://github.com/chocolatey/shimgen) says its +generator is closed source; its published shim code is reference source, not the +generator/build pipeline. Chocolatey's versioned generation caller supplies path, +output, icon and optional GUI inputs, but provides no byte-reproducibility contract. +The two observed hashes remain narrowly accepted evidence, not proof of all +future generated shims. Hash drift is only an untested explanation for this run; +do not add hashes or weaken ownership without actual discriminating evidence. + The product hooks now preflight the exact `bin\vmpower-agent.amd64.exe` path and remove it inside the recoverable activation transaction, or before service removal on uninstall. Existing shims must match diff --git a/scripts/qualify-windows-package-ownership.ps1 b/scripts/qualify-windows-package-ownership.ps1 index b0b39d5..503dec0 100644 --- a/scripts/qualify-windows-package-ownership.ps1 +++ b/scripts/qualify-windows-package-ownership.ps1 @@ -1,6 +1,47 @@ # Opt-in extension of the existing isolated Windows lifecycle fixture. Never run # standalone: it reuses that fixture's receiver, firewall, journal and cleanup. $script:lastPackageFailure=$null +function Get-OwnedChocolateyFailureLabels([string]$CapturedOutput) { + # Output is a closed vocabulary only. Never return captured text, excerpts, + # paths, matches, exception messages or unknown property values. + $known=[ordered]@{ + 'Unexpected package tools location; shim preserved'='SHIM_PACKAGE_PATH_UNEXPECTED' + 'Legacy shim root is unqualified; shim preserved'='SHIM_ROOT_UNQUALIFIED' + 'Unsafe shim parent; shim preserved'='SHIM_PARENT_UNSAFE' + 'Untrusted shim owner; shim preserved'='SHIM_OWNER_UNTRUSTED' + 'Untrusted shim write access; shim preserved'='SHIM_ACL_UNTRUSTED' + 'Unrecognized legacy shim file; shim preserved'='SHIM_FILE_UNRECOGNIZED' + 'Unqualified legacy shim bytes; shim preserved'='SHIM_HASH_UNQUALIFIED' + 'Shim changed during package operation; shim preserved'='SHIM_CHANGED_DURING_TRANSACTION' + 'Qualified legacy shim removal failed'='SHIM_REMOVAL_FAILED' + 'Reparse point in managed path'='MANAGED_PATH_REPARSE' + 'Managed file must have exactly one link'='MANAGED_FILE_LINKS' + 'Unqualified NSSM executable'='INSTALL_NSSM_UNQUALIFIED' + 'Retained NSSM mismatch'='INSTALL_NSSM_RETAINED_MISMATCH' + 'Bundle manifest hash mismatch'='INSTALL_BUNDLE_MANIFEST_MISMATCH' + 'Bundle file hash mismatch'='INSTALL_BUNDLE_FILE_MISMATCH' + 'Unexpected bundle files'='INSTALL_BUNDLE_MEMBERS_UNEXPECTED' + 'First install requires configuration JSON on stdin'='INSTALL_CONFIG_MISSING' + 'Invalid configuration JSON'='INSTALL_CONFIG_INVALID' + 'Native operation timed out'='INSTALL_NATIVE_TIMEOUT' + 'Native operation failed'='INSTALL_NATIVE_FAILED' + 'Service failed to start'='INSTALL_SERVICE_START_FAILED' + 'Service child exited during startup'='INSTALL_CHILD_EXITED' + 'Service child restarted during startup'='INSTALL_CHILD_RESTARTED' + 'Stable service child was not observed'='INSTALL_CHILD_NOT_STABLE' + 'Existing service uses a custom identity; preserve it for explicit migration qualification'='INSTALL_IDENTITY_UNSUPPORTED' + 'Existing service is not the NSSM contract'='INSTALL_SERVICE_CONTRACT_UNSUPPORTED' + 'Install failed; previous service restored, configuration and all releases retained'='INSTALL_RUNTIME_RESTORED' + 'Install failed and service rollback requires inspection; configuration and all releases retained'='INSTALL_ROLLBACK_FAILED' + } + $labels=@() + foreach ($message in $known.Keys) { + if ($CapturedOutput -and $CapturedOutput.Contains($message)) { $labels+=@($known[$message]) } + } + if ($labels.Count -eq 0) { return 'UNKNOWN' } + return $labels +} + function Invoke-OwnedChocolatey([string[]]$Arguments) { # Package extraction, ACL work and failing-child recovery can exceed the # installer's individual 30-second native-operation limit. Bound the whole @@ -25,8 +66,9 @@ function Invoke-OwnedChocolatey([string[]]$Arguments) { if ($process.ExitCode -ne 0) { # Distinguish the intended crashing-child rollback from download, # lock, command-line or package-validation failures. Keep raw logs private. - $script:packageRecoveryObserved=($stdout.GetAwaiter().GetResult()+$stderr.GetAwaiter().GetResult()).Contains('Install failed; previous service restored, configuration and all releases retained') - $script:lastPackageFailure=@{classification='CHOCOLATEY_EXIT_NONZERO';operation=$operation;exitCode=$process.ExitCode;installerRecoveryObserved=$script:packageRecoveryObserved} + $diagnosticLabels=@(Get-OwnedChocolateyFailureLabels ($stdout.GetAwaiter().GetResult()+"`n"+$stderr.GetAwaiter().GetResult())) + $script:packageRecoveryObserved='INSTALL_RUNTIME_RESTORED' -cin $diagnosticLabels + $script:lastPackageFailure=@{classification='CHOCOLATEY_EXIT_NONZERO';operation=$operation;exitCode=$process.ExitCode;installerRecoveryObserved=$script:packageRecoveryObserved;diagnosticLabels=$diagnosticLabels} throw 'Chocolatey transaction failed' } return $stdout.GetAwaiter().GetResult().TrimEnd([char[]]"`r`n") diff --git a/scripts/test-windows-package-cleanup.ps1 b/scripts/test-windows-package-cleanup.ps1 index db496fb..1b7e889 100644 --- a/scripts/test-windows-package-cleanup.ps1 +++ b/scripts/test-windows-package-cleanup.ps1 @@ -11,6 +11,26 @@ function Assert-Rejected([scriptblock]$Action,[string]$Name) { try { $null=& $Action } catch { $failed=$true } Assert-True $failed ('Unsafe cleanup accepted: '+$Name) } +function Test-PackageFailureRedaction { + $privateText='secret=synthetic-do-not-emit; Authorization: Bearer synthetic-token; C:\private\customer.conf' + $cases=@( + @{text=$privateText;labels=@('UNKNOWN')}, + @{text='';labels=@('UNKNOWN')}, + @{text=('Unqualified legacy shim bytes; shim preserved'+"`n"+$privateText);labels=@('SHIM_HASH_UNQUALIFIED')}, + @{text=($privateText+' Untrusted shim owner; shim preserved Untrusted shim write access; shim preserved');labels=@('SHIM_OWNER_UNTRUSTED','SHIM_ACL_UNTRUSTED')}, + @{text='Install failed; previous service restored, configuration and all releases retained';labels=@('INSTALL_RUNTIME_RESTORED')}, + @{text=('Unrecognized error: {"diagnosticLabels":["'+$privateText+'"]}');labels=@('UNKNOWN')} + ) + foreach ($case in $cases) { + $labels=@(Get-OwnedChocolateyFailureLabels $case.text) + Assert-True (($labels -join ',') -ceq ($case.labels -join ',')) 'Fixed diagnostic classification differs' + $serialized=@{diagnosticLabels=$labels} | ConvertTo-Json -Compress + foreach ($forbidden in @('synthetic-do-not-emit','synthetic-token','customer.conf','Authorization','private')) { + Assert-True (-not $serialized.Contains($forbidden)) 'Captured text leaked into diagnostic receipt' + } + } + return $cases.Count +} function Test-FailurePreservation([bool]$PrimaryFails,[bool]$CleanupFails) { # Execute the actual qualifier catch/finally blocks with a controlled try # body. This detects the original masking bug without running any lifecycle. @@ -22,7 +42,8 @@ function Test-FailurePreservation([bool]$PrimaryFails,[bool]$CleanupFails) { $body=if ($PrimaryFails) { "throw 'synthetic-primary-do-not-persist'" } else { '$result.status="PASS"' } $control=[ScriptBlock]::Create('try {'+$body+'} catch '+$main[0].CatchClauses[0].Body.Extent.Text+' finally '+$main[0].Finally.Extent.Text) $result=@{status='PASS';phases=@(@{phase='known-completed-phase'});packageOwnership=@{phase='known-package-phase'}} - $script:lastPackageFailure=$null;$script:failureReceipt=$null + $script:lastPackageFailure=@{classification='CHOCOLATEY_EXIT_NONZERO';operation='upgrade';diagnosticLabels=@(Get-OwnedChocolateyFailureLabels 'Unqualified legacy shim bytes; shim preserved secret=synthetic-do-not-emit')} + $script:failureReceipt=$null $Output=$fixture function Recorded-Cleanup { return @{status=$(if ($CleanupFails) {'FAIL'} else {'PASS'});classification='controlled-cleanup'} } function Save-Json([string]$Path,$Value) { $script:failureReceipt=$Value | ConvertTo-Json -Depth 20 } @@ -31,8 +52,10 @@ function Test-FailurePreservation([bool]$PrimaryFails,[bool]$CleanupFails) { Assert-True ($saved.status -eq 'FAIL') 'Qualification failure was hidden' Assert-True ($saved.cleanup.status -eq $(if ($CleanupFails) {'FAIL'} else {'PASS'})) 'Cleanup classification lost' Assert-True (-not $script:failureReceipt.Contains('synthetic-primary-do-not-persist')) 'Raw exception persisted' + Assert-True (-not $script:failureReceipt.Contains('synthetic-do-not-emit')) 'Captured package output persisted' if ($PrimaryFails) { Assert-True ($saved.primaryFailure.phase -eq 'known-package-phase' -and $saved.primaryFailure.classification -eq 'NATIVE_QUALIFICATION_FAILED') 'Primary failure masked by cleanup' + Assert-True (($saved.primaryFailure.packageCommandFailure.diagnosticLabels -join ',') -ceq 'SHIM_HASH_UNQUALIFIED') 'Fixed package diagnostic lost during receipt serialization' } else { Assert-True (-not $saved.PSObject.Properties['primaryFailure']) 'Cleanup failure invented a primary failure' } } $originalRoot=$env:ChocolateyInstall @@ -90,7 +113,8 @@ try { Test-FailurePreservation $false $true;$cases++ . (Join-Path $PSScriptRoot 'test-windows-product-shim.ps1') $productCases=Test-WindowsProductShim $fixture - @{status='PASS';cases=($cases+$productCases);originalControls=$cases;productHookControls=$productCases;productHookOwnershipProof='synthetic in wiring controls; actual proof in native package phases';packageOperations=0;serviceOperations=0;legacyExecutableRuns=0} | ConvertTo-Json -Compress + $redactionCases=Test-PackageFailureRedaction + @{status='PASS';cases=($cases+$productCases+$redactionCases);originalControls=$cases;productHookControls=$productCases;diagnosticRedactionControls=$redactionCases;productHookOwnershipProof='synthetic in wiring controls; actual proof in native package phases';packageOperations=0;serviceOperations=0;legacyExecutableRuns=0} | ConvertTo-Json -Compress } finally { $env:ChocolateyInstall=$originalRoot if (Test-Path -LiteralPath $fixture) { Remove-Item -LiteralPath $fixture -Recurse -Force } From 683692febd84e101ef2d514148d2fd37330d0139 Mon Sep 17 00:00:00 2001 From: Spencer Albrecht Date: Mon, 26 Oct 2026 21:31:24 -0700 Subject: [PATCH 10/18] Bound native path fixture cleanup and retain both failures --- scripts/test-native-fixture-paths.cjs | 51 ++++++++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/scripts/test-native-fixture-paths.cjs b/scripts/test-native-fixture-paths.cjs index ff14f4b..15dc90b 100644 --- a/scripts/test-native-fixture-paths.cjs +++ b/scripts/test-native-fixture-paths.cjs @@ -7,6 +7,7 @@ const { spawnSync } = require('node:child_process'); const { test } = require('node:test'); function fixture(check) { const temp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'native-path-control-'))); + let primaryFailure; try { const root = path.join(temp, 'vmpower-native'); const bundle = path.join(root, 'bundle'); @@ -32,10 +33,58 @@ function fixture(check) { env: { ...process.env, NODE_OPTIONS: '', GITHUB_ACTIONS: 'true', RUNNER_ENVIRONMENT: 'github-hosted' } }); check({ temp, root, bundle, node, run, preload }); - } finally { fs.rmSync(temp, { recursive: true, force: true }); } + } catch (error) { + primaryFailure = error; + throw error; + } finally { + try { + // Allow brief Windows sharing delays after synchronous children exit. + // Node handles readonly files; persistent denial must still fail the test. + fs.rmSync(temp, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); + } catch (cleanupFailure) { + if (primaryFailure) throw new AggregateError([primaryFailure, cleanupFailure], 'Fixture and cleanup both failed'); + throw cleanupFailure; + } + } } function passes(result) { assert.equal(result.error, undefined); assert.equal(result.signal, null); assert.equal(result.status, 0, result.stderr); } function rejects(result) { assert.equal(result.error, undefined); assert.equal(result.signal, null); assert.notEqual(result.status, 0); } +test('fixture cleans readonly files after a primary failure and preserves that failure', () => { + const primary = new Error('FIXTURE_PRIMARY_FAILURE'); + let owned; + assert.throws(() => fixture(({ temp }) => { + owned = temp; + const file = path.join(temp, 'readonly'); + fs.writeFileSync(file, 'fixture'); fs.chmodSync(file, 0o400); + throw primary; + }), error => error === primary); + assert.equal(fs.existsSync(owned), false); +}); +for (const primaryFails of [false, true]) { + test(`fixture preserves persistent cleanup failure with primary failure ${primaryFails}`, context => { + const primary = new Error('FIXTURE_PRIMARY_FAILURE'); + const denied = Object.assign(new Error('FIXTURE_CLEANUP_DENIED'), { code: 'EPERM' }); + const remove = fs.rmSync; + let owned; + const removal = context.mock.method(fs, 'rmSync', (target, options) => { + if (target !== owned) return remove(target, options); + // Model native removal exhausting its bounded retry, not a successful delete. + throw denied; + }); + try { + assert.throws(() => fixture(({ temp }) => { + owned = temp; + if (primaryFails) throw primary; + }), error => primaryFails + ? error instanceof AggregateError && error.errors[0] === primary && error.errors[1] === denied + : error === denied); + assert.equal(fs.existsSync(owned), true); + } finally { + removal.mock.restore(); + if (owned) remove(owned, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); + } + }); +} test('real smoke helper uses the executable bundle without a path argument', () => fixture(({ run }) => { passes(run('native-bundle-smoke.cjs')); })); From e6b320ac919357ef1eed6dda3a909c7aa459d01f Mon Sep 17 00:00:00 2001 From: Spencer Albrecht Date: Mon, 26 Oct 2026 21:39:55 -0700 Subject: [PATCH 11/18] Reap owned fixture process trees before runtime cleanup --- scripts/test-native-fixture-paths.cjs | 140 +++++++++++++++++++------- 1 file changed, 105 insertions(+), 35 deletions(-) diff --git a/scripts/test-native-fixture-paths.cjs b/scripts/test-native-fixture-paths.cjs index 15dc90b..a9f1cfb 100644 --- a/scripts/test-native-fixture-paths.cjs +++ b/scripts/test-native-fixture-paths.cjs @@ -3,9 +3,45 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); -const { spawnSync } = require('node:child_process'); +const { spawn, spawnSync } = require('node:child_process'); const { test } = require('node:test'); -function fixture(check) { +// Three inner CLI calls each have a 5s deadline; allow another 5s for startup. +const helperDeadline = 20000; +function runHelper(executable, args, env, timeout = helperDeadline) { + return new Promise((resolve, reject) => { + const child = spawn(executable, args, { env, windowsHide: true, detached: process.platform !== 'win32' }); + let stdout = '', stderr = '', timeoutError, reapFailure, closed; + const failedReap = code => Object.assign(new AggregateError( + [timeoutError, new Error(code)], 'OWNED_HELPER_TIMEOUT_AND_TREE_REAP_FAILED'), { cleanupUnsafe: true }); + child.stdout.setEncoding('utf8'); child.stderr.setEncoding('utf8'); + child.stdout.on('data', data => { stdout += data; }); + child.stderr.on('data', data => { stderr += data; }); + const timer = setTimeout(() => { + timeoutError = Object.assign(new Error('OWNED_HELPER_TIMEOUT'), { code: 'ETIMEDOUT' }); + try { + // Reap the captured live leader's tree before awaiting its close. Never + // use spawnSync's timeout: it kills the leader without its descendants. + if (process.platform === 'win32') { + const killed = spawnSync(path.join(process.env.SystemRoot, 'System32', 'taskkill.exe'), + ['/PID', String(child.pid), '/T', '/F'], { timeout: 5000, windowsHide: true, encoding: 'utf8' }); + if (killed.error || killed.status !== 0) throw new Error('OWNED_TREE_TERMINATION_FAILED'); + } else { + process.kill(-child.pid, 'SIGKILL'); + } + } catch (error) { reapFailure = error; } + closed = setTimeout(() => reject(failedReap('OWNED_TREE_CLOSE_TIMEOUT')), 5000); + }, timeout); + child.on('error', error => { clearTimeout(timer); clearTimeout(closed); reject(error); }); + child.on('close', (status, signal) => { + clearTimeout(timer); clearTimeout(closed); + if (reapFailure) return reject(failedReap('OWNED_TREE_TERMINATION_FAILED')); + if (!timeoutError && signal) return reject(Object.assign(new Error('UNEXPECTED_HELPER_SIGNAL'), { cleanupUnsafe: true })); + resolve({ status, signal, stdout, stderr, pid: child.pid, + error: timeoutError }); + }); + }); +} +async function fixture(check) { const temp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'native-path-control-'))); let primaryFailure; try { @@ -28,16 +64,16 @@ function fixture(check) { Object.defineProperty(process,'arch',{value:'x64'}); require('node:http').createServer=()=>({listen(port,host,ready){assert.equal(port,0);assert.equal(host,'127.0.0.1');ready();},address(){return {port:12345}},close(done){done();},closeAllConnections(){}}); `); - const run = (name, args = [], executable = node) => spawnSync(executable, ['--require', preload, path.join(__dirname, name), ...args], { - encoding: 'utf8', timeout: 8000, - env: { ...process.env, NODE_OPTIONS: '', GITHUB_ACTIONS: 'true', RUNNER_ENVIRONMENT: 'github-hosted' } - }); - check({ temp, root, bundle, node, run, preload }); + const run = (name, args = [], executable = node, timeout = helperDeadline) => runHelper(executable, + ['--require', preload, path.join(__dirname, name), ...args], + { ...process.env, NODE_OPTIONS: '', GITHUB_ACTIONS: 'true', RUNNER_ENVIRONMENT: 'github-hosted' }, timeout); + await check({ temp, root, bundle, node, run, preload }); } catch (error) { primaryFailure = error; throw error; } finally { try { + if (primaryFailure && primaryFailure.cleanupUnsafe) throw new Error('FIXTURE_RETAINED_TREE_CLEANUP_UNPROVED'); // Allow brief Windows sharing delays after synchronous children exit. // Node handles readonly files; persistent denial must still fail the test. fs.rmSync(temp, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); @@ -49,10 +85,44 @@ function fixture(check) { } function passes(result) { assert.equal(result.error, undefined); assert.equal(result.signal, null); assert.equal(result.status, 0, result.stderr); } function rejects(result) { assert.equal(result.error, undefined); assert.equal(result.signal, null); assert.notEqual(result.status, 0); } -test('fixture cleans readonly files after a primary failure and preserves that failure', () => { +async function assertExited(pid) { + const deadline = Date.now() + 3000; + while (true) { + try { process.kill(pid, 0); } + catch (error) { if (error.code === 'ESRCH') return; throw error; } + assert(Date.now() < deadline, 'OWNED_PROCESS_SURVIVED'); + await new Promise(resolve => setTimeout(resolve, 25)); + } +} +test('outer budget lets three individually bounded slow CLI children finish', () => fixture(async ({ bundle, run }) => { + fs.writeFileSync(path.join(bundle, 'app/index.js'), "const a=process.argv[2];setTimeout(()=>{console.log(a==='--version'?'0.0.2':a==='--help'?'--virtualMachine':'required option');process.exit(a?0:1);},3000);"); + const result = await run('native-bundle-smoke.cjs'); + passes(result); + await assertExited(result.pid); +})); +test('outer timeout reaps its real descendant before copied runtime cleanup', async () => { + let owned; + await fixture(async ({ temp, preload, run }) => { + owned = temp; + const journal = path.join(temp, 'owned-child.json'); + const childSource = `require('node:fs').writeFileSync(${JSON.stringify(journal)},JSON.stringify({pid:process.pid}));setInterval(()=>{},1000);`; + fs.appendFileSync(preload, ` + require('node:child_process').spawn(process.execPath,['-e',${JSON.stringify(childSource)}],{stdio:'inherit'}); + setInterval(()=>{},1000); + `); + const result = await run('native-report-fixture.cjs', [], undefined, 4000); + assert.equal(result.error.code, 'ETIMEDOUT'); + const childPid = JSON.parse(fs.readFileSync(journal)).pid; + assert(Number.isSafeInteger(childPid) && childPid > 0 && childPid !== result.pid); + await assertExited(result.pid); + await assertExited(childPid); + }); + assert.equal(fs.existsSync(owned), false); +}); +test('fixture cleans readonly files after a primary failure and preserves that failure', async () => { const primary = new Error('FIXTURE_PRIMARY_FAILURE'); let owned; - assert.throws(() => fixture(({ temp }) => { + await assert.rejects(() => fixture(async ({ temp }) => { owned = temp; const file = path.join(temp, 'readonly'); fs.writeFileSync(file, 'fixture'); fs.chmodSync(file, 0o400); @@ -61,7 +131,7 @@ test('fixture cleans readonly files after a primary failure and preserves that f assert.equal(fs.existsSync(owned), false); }); for (const primaryFails of [false, true]) { - test(`fixture preserves persistent cleanup failure with primary failure ${primaryFails}`, context => { + test(`fixture preserves persistent cleanup failure with primary failure ${primaryFails}`, async context => { const primary = new Error('FIXTURE_PRIMARY_FAILURE'); const denied = Object.assign(new Error('FIXTURE_CLEANUP_DENIED'), { code: 'EPERM' }); const remove = fs.rmSync; @@ -72,7 +142,7 @@ for (const primaryFails of [false, true]) { throw denied; }); try { - assert.throws(() => fixture(({ temp }) => { + await assert.rejects(() => fixture(async ({ temp }) => { owned = temp; if (primaryFails) throw primary; }), error => primaryFails @@ -85,54 +155,54 @@ for (const primaryFails of [false, true]) { } }); } -test('real smoke helper uses the executable bundle without a path argument', () => fixture(({ run }) => { - passes(run('native-bundle-smoke.cjs')); +test('real smoke helper uses the executable bundle without a path argument', () => fixture(async ({ run }) => { + passes(await run('native-bundle-smoke.cjs')); })); -test('real receiver writes only its fixed new state file without a path argument', () => fixture(({ root, run }) => { - passes(run('native-report-fixture.cjs')); +test('real receiver writes only its fixed new state file without a path argument', () => fixture(async ({ root, run }) => { + passes(await run('native-report-fixture.cjs')); const file = path.join(root, 'receiver.json'); assert.equal(JSON.parse(fs.readFileSync(file)).port, 12345); if (process.platform !== 'win32') assert.equal(fs.statSync(file).mode & 0o777, 0o600); })); -test('smoke rejects an otherwise valid caller-selected bundle', () => fixture(({ bundle, run }) => { - rejects(run('native-bundle-smoke.cjs', [bundle])); +test('smoke rejects an otherwise valid caller-selected bundle', () => fixture(async ({ bundle, run }) => { + rejects(await run('native-bundle-smoke.cjs', [bundle])); })); -test('receiver rejects an arbitrary overwrite target and preserves sentinel', () => fixture(({ temp, run }) => { +test('receiver rejects an arbitrary overwrite target and preserves sentinel', () => fixture(async ({ temp, run }) => { const sentinel = path.join(temp, 'must-preserve.json');fs.writeFileSync(sentinel, 'sentinel'); - rejects(run('native-report-fixture.cjs', [sentinel]));assert.equal(fs.readFileSync(sentinel, 'utf8'), 'sentinel'); + rejects(await run('native-report-fixture.cjs', [sentinel]));assert.equal(fs.readFileSync(sentinel, 'utf8'), 'sentinel'); })); -test('both helpers reject a non-staged runtime', () => fixture(({ run }) => { - for (const name of ['native-bundle-smoke.cjs', 'native-report-fixture.cjs']) rejects(run(name, [], process.execPath)); +test('both helpers reject a non-staged runtime', () => fixture(async ({ run }) => { + for (const name of ['native-bundle-smoke.cjs', 'native-report-fixture.cjs']) rejects(await run(name, [], process.execPath)); })); -test('both helpers reject a wrong runtime filename', () => fixture(({ node, run }) => { +test('both helpers reject a wrong runtime filename', () => fixture(async ({ node, run }) => { const wrong = path.join(path.dirname(node), process.platform === 'win32' ? 'other.exe' : 'other'); fs.copyFileSync(node, wrong);fs.chmodSync(wrong, 0o755); - for (const name of ['native-bundle-smoke.cjs', 'native-report-fixture.cjs']) rejects(run(name, [], wrong)); + for (const name of ['native-bundle-smoke.cjs', 'native-report-fixture.cjs']) rejects(await run(name, [], wrong)); })); -test('both helpers reject wrong bundle layout', () => fixture(({ bundle, node, run }) => { +test('both helpers reject wrong bundle layout', () => fixture(async ({ bundle, node, run }) => { const wrong = path.join(path.dirname(bundle), 'other-bundle');fs.renameSync(bundle, wrong); - for (const name of ['native-bundle-smoke.cjs', 'native-report-fixture.cjs']) rejects(run(name, [], path.join(wrong, 'runtime', path.basename(node)))); + for (const name of ['native-bundle-smoke.cjs', 'native-report-fixture.cjs']) rejects(await run(name, [], path.join(wrong, 'runtime', path.basename(node)))); })); for (const file of ['bundle-manifest.json', 'app/index.js']) { - test('both helpers reject a symbolic link at ' + file, () => fixture(({ temp, bundle, run }) => { + test('both helpers reject a symbolic link at ' + file, () => fixture(async ({ temp, bundle, run }) => { const target = path.join(bundle, file);const outside = path.join(temp, 'outside');fs.renameSync(target, outside);fs.symlinkSync(outside, target, 'file'); - for (const name of ['native-bundle-smoke.cjs', 'native-report-fixture.cjs']) rejects(run(name)); + for (const name of ['native-bundle-smoke.cjs', 'native-report-fixture.cjs']) rejects(await run(name)); })); - test('both helpers reject a hard link at ' + file, () => fixture(({ temp, bundle, run }) => { + test('both helpers reject a hard link at ' + file, () => fixture(async ({ temp, bundle, run }) => { fs.linkSync(path.join(bundle, file), path.join(temp, 'outside')); - for (const name of ['native-bundle-smoke.cjs', 'native-report-fixture.cjs']) rejects(run(name)); + for (const name of ['native-bundle-smoke.cjs', 'native-report-fixture.cjs']) rejects(await run(name)); })); } -test('receiver refuses an existing regular file', () => fixture(({ root, run }) => { +test('receiver refuses an existing regular file', () => fixture(async ({ root, run }) => { const target = path.join(root, 'receiver.json');fs.writeFileSync(target, 'sentinel'); - rejects(run('native-report-fixture.cjs'));assert.equal(fs.readFileSync(target, 'utf8'), 'sentinel'); + rejects(await run('native-report-fixture.cjs'));assert.equal(fs.readFileSync(target, 'utf8'), 'sentinel'); })); -test('receiver refuses a symbolic-link overwrite', () => fixture(({ temp, root, run }) => { +test('receiver refuses a symbolic-link overwrite', () => fixture(async ({ temp, root, run }) => { const sentinel = path.join(temp, 'outside');fs.writeFileSync(sentinel, 'sentinel'); fs.symlinkSync(sentinel, path.join(root, 'receiver.json'), 'file'); - rejects(run('native-report-fixture.cjs'));assert.equal(fs.readFileSync(sentinel, 'utf8'), 'sentinel'); + rejects(await run('native-report-fixture.cjs'));assert.equal(fs.readFileSync(sentinel, 'utf8'), 'sentinel'); })); -test('replacing receiver pathname cannot redirect a subsequent report write', () => fixture(({ root, run, preload }) => { +test('replacing receiver pathname cannot redirect a subsequent report write', () => fixture(async ({ root, run, preload }) => { fs.appendFileSync(preload, ` const fs=require('node:fs'),path=require('node:path'); const output=path.join(path.dirname(path.dirname(path.dirname(process.execPath))),'receiver.json'); @@ -145,7 +215,7 @@ test('replacing receiver pathname cannot redirect a subsequent report write', () },address(){return {port:12345}},close(done){done();},closeAllConnections(){} }); `); - passes(run('native-report-fixture.cjs')); + passes(await run('native-report-fixture.cjs')); assert.equal(fs.readFileSync(path.join(root, 'receiver.json'), 'utf8'), 'sentinel'); assert.equal(JSON.parse(fs.readFileSync(path.join(root, 'receiver.json.held'))).count, 1); })); From 890929459a3b27405a48c1faf3d429f2565592a3 Mon Sep 17 00:00:00 2001 From: Spencer Albrecht Date: Mon, 26 Oct 2026 22:16:57 -0700 Subject: [PATCH 12/18] Preserve unqualified global shims during service migration --- packages/win32/node24/README.md | 120 ++++++++---------- .../win32/node24/tools/chocolateyInstall.ps1 | 3 +- .../node24/tools/chocolateyUninstall.ps1 | 2 +- packages/win32/node24/tools/package-shim.ps1 | 26 +++- scripts/build-windows-package.py | 2 + scripts/qualify-windows-package-ownership.ps1 | 108 ++++++++++++---- scripts/test-build-windows-package.py | 20 +++ scripts/test-windows-product-shim.ps1 | 51 +++++++- scripts/verify-windows-package.py | 5 + 9 files changed, 234 insertions(+), 103 deletions(-) diff --git a/packages/win32/node24/README.md b/packages/win32/node24/README.md index cee448e..3b81256 100644 --- a/packages/win32/node24/README.md +++ b/packages/win32/node24/README.md @@ -1,68 +1,51 @@ -# Windows Chocolatey ownership handoff (prepared, not distributed) - -Native run `40437980932` passed the prior `18d6aef` source on Windows 2025, -PowerShell 5.1 and Chocolatey 2.7.4. That pass included fixture removal of an -orphan legacy shim; it did not prove product-side shim cleanup. The following -product-hook correction is source-only until a new admitted native run passes. - -Run `40445133666` at `0e9dc09` subsequently passed 26 preflight controls, four -modeled finalization-state controls and the original nine lifecycle phases, but -failed at the first docs-family modern package upgrade. Cleanup passed separately. -No actual product shim handoff or post-activation package compensation passed in -that run. Its underlying Chocolatey error was not retained, so the cause remains -Unknown. The qualifier now maps captured output only to a closed set of fixed -shim/installer labels; unrecognized errors become `UNKNOWN`. It never writes raw -output, paths, credentials or excerpts. Six redaction controls include secret-like -unknown errors and recognized errors mixed with secret-like text. New diagnostic -execution remains unrun until an admitted native job proves it. - -Do not assume repeatable shim SHA-256 from a fixed Chocolatey version. The -[official ShimGen repository](https://github.com/chocolatey/shimgen) says its -generator is closed source; its published shim code is reference source, not the -generator/build pipeline. Chocolatey's versioned generation caller supplies path, -output, icon and optional GUI inputs, but provides no byte-reproducibility contract. -The two observed hashes remain narrowly accepted evidence, not proof of all -future generated shims. Hash drift is only an untested explanation for this run; -do not add hashes or weaken ownership without actual discriminating evidence. - -The product hooks now preflight the exact `bin\vmpower-agent.amd64.exe` path and -remove it inside the recoverable activation transaction, or before service removal -on uninstall. Existing shims must match -one of the two exact SHA-256/size pairs observed from the pinned legacy packages -in that run, under `C:\ProgramData\chocolatey`, with protected ownership/ACLs, -no reparse points and a single link. Unknown bytes, custom roots, unsafe parents -or an intervening replacement fail closed and preserve the file. Missing shims -are harmless. No shim or legacy executable is executed to identify its target. -Older Chocolatey-generated shim variants are not qualified by these two hashes. +# Windows Chocolatey service migration (prepared, not distributed) + +Run `40451298149` at `e6b320a` passed the Windows path controls, 32 PowerShell +controls, four modeled transaction controls and nine bundle lifecycle phases. +Its first docs-family package upgrade failed with `SHIM_HASH_UNQUALIFIED`; +independent fixture cleanup passed. Actual package upgrade and compensation did +not pass. The earlier `0e9dc09` failure remains Unknown; this later diagnosis does +not retroactively identify its cause. No replacement package has been published. + +The product now distinguishes service migration from the global legacy command. +At the exact protected `C:\ProgramData\chocolatey\bin\vmpower-agent.amd64.exe` +path, a regular 121856-byte single-link file with safe parents and ACLs may be +`UNQUALIFIED_PRESERVED` when its SHA-256 is outside the two observed pins. This +is a preservation decision, never an ownership or executable-safety claim. +The file is never executed, deleted, moved, overwritten or adopted by the product. +Other root/path/type/link/size/ACL failures still refuse migration. + +The before/after snapshot includes bytes, SHA-256, ACL and Windows file identity. +A replacement (even identical bytes), ACL change or denied read fails within the +existing service activation rollback transaction. Only an unchanged shim with +one of the two exact approved hashes may be removed. Uninstall reconciles this +same decision before removing the service and preserves configuration, releases, +wrapper and old copied executable. Missing shims are harmless. + +The modern archive cannot contain the legacy EXE basename; the builder and packed +archive verifier independently enforce this. Both Node and NSSM require empty +Chocolatey ignore markers. Actual Chocolatey upgrade/rollback/uninstall must still +prove that its automatic shim handling leaves unknown bytes, ACL and file identity +unchanged. Native checks cover both generated package-family shims and a separate +same-size unrelated sentinel. Fixture-only reconciliation runs after preservation +assertions and actual package deregistration; it is not product cleanup. Both old package scripts register the service against the copied -`C:\ProgramData\vmpower\vmpower.exe`, not Chocolatey's automatic global shim. -The inspected dashboard install command and published help pages use Chocolatey -installation/service reporting, not that shim. Its removal therefore cleans up -an obsolete automatic command; the old copied executable is retained for rollback -and the modern release keeps its existing `vmpower.cmd`. Uninspected external -scripts that invoke the old global EXE are not claimed compatible. - -The native package test requires the legacy shim before upgrade, its absence -immediately after the product upgrade and after ordinary product uninstall, all -before emergency fixture cleanup. The existing preflight also runs product-hook -wiring controls with synthetic ownership/service boundaries, plus real helper -refusal controls. Those local wiring controls do not replace actual shim proof. -Post-success rollback to legacy is still unqualified: in-memory installer failure -recovery is not a persistent operator rollback command, and normal old-package -downgrade would overwrite configuration. - -The first product correction (`0416667`) incorrectly removed the shim after the -service transaction returned. A changed/locked/denied shim could then fail the -package operation while leaving the service on the new runtime. The successor -places both Chocolatey ignore-marker restoration and shim removal inside the -installer's activation catch boundary. Compensation restores wrapper, application, -arguments, directory, NSSM startup mode and prior running/stopped state; failures -remain failures. Uninstall reconciles the shim before touching the service. -Controls exercise changed, locked, denied and marker failures through the actual -installer with modeled SCM; a synthetic native package additionally requires -real Chocolatey registration and actual service/child/config restoration while -preserving the unrelated replacement. This is source preparation, not native proof. +`C:\ProgramData\vmpower\vmpower.exe`, not the automatic global shim. The modern +service uses its qualified runtime and existing launcher. A preserved unknown +command remains unsupported and potentially points at old software; service +migration must not be reported as full legacy cleanup or zero security exposure. +Post-success rollback to the old package remains unqualified: its old install +script overwrites configuration, and an in-memory failure snapshot is not a +persistent operator rollback command. + +Do not infer deterministic shim bytes from a pinned Chocolatey version. The +[official ShimGen repository](https://github.com/chocolatey/shimgen) provides +reference shim source, but its generator is closed source. No hashes are added by +this change. Raw package output is mapped to fixed diagnostic labels only; +unrecognized errors stay `UNKNOWN`, without paths, credentials or excerpts. +This source and its new Windows assertions remain unrun until separately admitted +native qualification. Use the existing `vmpower-agent` package ID with an explicit version greater than `0.0.1`. A normal Chocolatey upgrade invokes the **new** install script after the @@ -143,7 +126,8 @@ modern inputs. The job must have no customer/provider/package secrets and no persisted checkout credential; any job token is read-only. Do not run on a customer host, self-hosted runner or machine with existing agent registration. -For each exact old package, the fixture registers the real bytes using +For each exact old package, plus one separate unrelated-sentinel case after both +generated-shim cases, the fixture registers the real bytes using `--skip-automation-scripts --ignore-dependencies` and a local-only source. This is **registration-only evidence**, not proof that the old software was installed or works. The old executable and both old PowerShell scripts are never invoked. @@ -154,7 +138,7 @@ uninstall, without script-skipping, to test: - replacement of the actual `0.0.1` package registration; - running/manual and stopped/disabled service preservation and configuration ACLs; - a crashing modern child restoring prior package registration and service progress; -- new uninstall preserving configuration and retained runtime; +- new uninstall preserving configuration, retained runtime and unqualified command bytes/ACL/file identity; - removal of owned package registration, exact caches, legacy shim, service, processes, firewall rules and fixture inputs. @@ -164,8 +148,10 @@ unmodified product `bundle`, not the synthetic comment-bearing `upgrade` bundle; the default lifecycle fixture still checks that separate synthetic upgrade. A final version and retained deliverable artifact require separate root admission. The `0.0.4` fixture uses the explicitly synthetic upgrade bundle and a labeled -test finalizer. It must fail after activation, preserve the replacement sentinel, -and restore `0.0.2` package/service state before ordinary uninstall is tested. +test finalizer. It denies reads of a preserved shim, or introduces a replacement +when the known shim was already removed. Failure must occur after activation, +restore `0.0.2` package/service state, and preserve the adverse or unqualified +file before ordinary uninstall is tested. Cleanup skips all old automation and the auto-uninstaller, including when upgrade failed while `0.0.1` remains registered. Exact package directories and the legacy shim must be absent before ownership is recorded. A cleanup failure diff --git a/packages/win32/node24/tools/chocolateyInstall.ps1 b/packages/win32/node24/tools/chocolateyInstall.ps1 index 2088649..b87eb10 100644 --- a/packages/win32/node24/tools/chocolateyInstall.ps1 +++ b/packages/win32/node24/tools/chocolateyInstall.ps1 @@ -3,7 +3,8 @@ $ErrorActionPreference = 'Stop' . (Join-Path $PSScriptRoot 'scripts\install-windows-bundle.ps1') . (Join-Path $PSScriptRoot 'package-shim.ps1') -# Reject ambiguous legacy shim ownership before any service/configuration change. +# Refuse unsafe structure before service/configuration changes. Unknown bytes at +# the protected path remain unqualified and must survive the whole transaction. $previousShim=Get-VmpowerPackageShimState $PSScriptRoot $packageTools=$PSScriptRoot $inputRecord = Get-Content -Raw -LiteralPath (Join-Path $PSScriptRoot 'package-input.json') | ConvertFrom-Json diff --git a/packages/win32/node24/tools/chocolateyUninstall.ps1 b/packages/win32/node24/tools/chocolateyUninstall.ps1 index b22468e..515aac1 100644 --- a/packages/win32/node24/tools/chocolateyUninstall.ps1 +++ b/packages/win32/node24/tools/chocolateyUninstall.ps1 @@ -2,7 +2,7 @@ $ErrorActionPreference = 'Stop' . (Join-Path $PSScriptRoot 'scripts\install-windows-bundle.ps1') . (Join-Path $PSScriptRoot 'package-shim.ps1') $previousShim=Get-VmpowerPackageShimState $PSScriptRoot -# A changed/locked/denied shim must fail before the service can be removed. +# Changed/denied paths fail before service removal; safe unknown bytes remain. Remove-VmpowerPackageShim $PSScriptRoot $previousShim # Repeated removal is harmless; never call the legacy uninstall script. if (Get-Service -Name vmpoweragent -ErrorAction SilentlyContinue) { diff --git a/packages/win32/node24/tools/package-shim.ps1 b/packages/win32/node24/tools/package-shim.ps1 index 539d9c6..855287b 100644 --- a/packages/win32/node24/tools/package-shim.ps1 +++ b/packages/win32/node24/tools/package-shim.ps1 @@ -31,22 +31,38 @@ function Get-VmpowerPackageShimState([string]$ToolsDirectory) { throw 'Unrecognized legacy shim file; shim preserved' } Assert-SingleLinkFile $shim - $sha=(Get-FileHash -LiteralPath $shim -Algorithm SHA256).Hash.ToLowerInvariant() + # Read without write/delete sharing: this is a preservation snapshot, not an + # ownership claim for unknown bytes. Include identity and ACL to reject a + # same-byte replacement or permission change during the service transaction. + $stream=[IO.File]::Open($shim,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::Read) + $hasher=$null + try { + $hasher=[Security.Cryptography.SHA256]::Create() + $info=New-Object VMPower.FileLinks+Info + if (-not [VMPower.FileLinks]::GetFileInformationByHandle($stream.SafeFileHandle,[ref]$info) -or + $info.Links -ne 1 -or $stream.Length -ne 121856) { throw 'Unrecognized legacy shim file; shim preserved' } + $sha=([BitConverter]::ToString($hasher.ComputeHash($stream))).Replace('-','').ToLowerInvariant() + $identity=('{0:x8}:{1:x8}:{2:x8}' -f $info.Volume,$info.IndexHigh,$info.IndexLow) + $sddl=(Get-Acl -LiteralPath $shim).Sddl + } finally { if ($hasher) { $hasher.Dispose() };$stream.Dispose() } + $status='QUALIFIED_LEGACY' if ($sha -cnotin @('7d7dad5068622170edda0e1fa6ecfe906ed39cc8727186b404c4c1add1482795', '00e537c680019d69bba7a2906c1338f600059ca27c40de93f710faf25f0015b6')) { - throw 'Unqualified legacy shim bytes; shim preserved' + $status='UNQUALIFIED_PRESERVED' } - return @{status='QUALIFIED_LEGACY';path=$shim;sha256=$sha;bytes=$file.Length} + return @{status=$status;path=$shim;sha256=$sha;bytes=$file.Length;fileIdentity=$identity;acl=$sddl} } function Remove-VmpowerPackageShim([string]$ToolsDirectory,[hashtable]$Before) { # Recheck after the service transaction; a changed/new file is not ours to delete. $current=Get-VmpowerPackageShimState $ToolsDirectory if ($current.path -ine $Before.path -or $current.status -cne $Before.status -or - ($current.status -eq 'QUALIFIED_LEGACY' -and $current.sha256 -cne $Before.sha256)) { + ($current.status -ne 'ABSENT' -and ($current.sha256 -cne $Before.sha256 -or + $current.bytes -ne $Before.bytes -or $current.fileIdentity -cne $Before.fileIdentity -or + $current.acl -cne $Before.acl))) { throw 'Shim changed during package operation; shim preserved' } - if ($current.status -eq 'ABSENT') { return } + if ($current.status -in @('ABSENT','UNQUALIFIED_PRESERVED')) { return } Remove-Item -LiteralPath $current.path -Force if (Test-Path -LiteralPath $current.path) { throw 'Qualified legacy shim removal failed' } } diff --git a/scripts/build-windows-package.py b/scripts/build-windows-package.py index 76d9b36..92fdc99 100644 --- a/scripts/build-windows-package.py +++ b/scripts/build-windows-package.py @@ -40,6 +40,8 @@ def prepare(bundle, expected, node_archive, nssm_archive, version, output, repo= if stage.digest(regular(bundle / 'bundle-manifest.json')) != expected: raise ValueError('Bundle manifest hash mismatch') manifest = stage.verify_bundle(bundle) + if any(Path(name).name.casefold() == 'vmpower-agent.amd64.exe' for name in manifest['files']): + raise ValueError('Modern package must not claim the legacy shim basename') runtime_pins = json.loads(regular(repo / 'distribution/runtime-pins.json')) if manifest['target'] != 'win-x64' or manifest['nodeVersion'] != runtime_pins['nodeVersion']: raise ValueError('Expected pinned Windows x64 bundle') diff --git a/scripts/qualify-windows-package-ownership.ps1 b/scripts/qualify-windows-package-ownership.ps1 index 503dec0..61fbf4d 100644 --- a/scripts/qualify-windows-package-ownership.ps1 +++ b/scripts/qualify-windows-package-ownership.ps1 @@ -137,6 +137,29 @@ function Remove-OwnedChocolateyRegistration { return Remove-OwnedLegacyShim $owned $true } +function Get-FixtureShimFingerprint([string]$Path) { + Assert-NoReparsePoint $Path;Assert-SingleLinkFile $Path + $stream=[IO.File]::Open($Path,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::Read) + try { + $info=New-Object VMPower.FileLinks+Info + Assert-True ([VMPower.FileLinks]::GetFileInformationByHandle($stream.SafeFileHandle,[ref]$info)) 'Shim identity unavailable' + return @{sha256=(Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant();bytes=$stream.Length; + acl=(Get-Acl -LiteralPath $Path).Sddl;fileIdentity=('{0:x8}:{1:x8}:{2:x8}' -f $info.Volume,$info.IndexHigh,$info.IndexLow)} + } finally { $stream.Dispose() } +} + +function Assert-FixtureShimOutcome([string]$Path,[hashtable]$Before) { + if ($Before.status -eq 'QUALIFIED_LEGACY') { + Assert-True (-not (Test-Path -LiteralPath $Path)) 'Qualified legacy shim still requires fixture rescue' + } else { + Assert-True ($Before.status -eq 'UNQUALIFIED_PRESERVED') 'Unexpected preservation classification' + $after=Get-FixtureShimFingerprint $Path + foreach ($field in @('sha256','bytes','acl','fileIdentity')) { + Assert-True ($after[$field] -ceq $Before[$field]) ('Unqualified shim changed: '+$field) + } + } +} + function Invoke-WindowsPackageOwnership([string]$LegacyPackages,[string]$ExpectedChocolateyVersion,[hashtable]$Evidence) { Assert-Hosted $Evidence.phase='package-preflight';$Evidence.status='FAIL';$Evidence.packages=@();$Evidence.families=@() @@ -151,18 +174,19 @@ function Invoke-WindowsPackageOwnership([string]$LegacyPackages,[string]$Expecte $shim=Join-Path $env:ChocolateyInstall 'bin\vmpower-agent.amd64.exe' Assert-NoReparsePoint $shim;Assert-True (-not (Test-Path -LiteralPath $shim)) 'Legacy shim already exists' $owned.chocolatey=$script:chocolatey;$owned.chocolateyDirectories=@($directories);$owned.chocolateyShim=$shim;$owned.chocolateyShimAbsent=$true;Save-Json $journal $owned - # Actual product proof must reject an unrelated regular file, even at the - # right protected path and exact expected size. Never execute this sentinel. + # Actual product code must preserve, not adopt, safe unrelated same-size + # bytes. Never execute this sentinel or infer its ownership from its path. . (Join-Path $PSScriptRoot '../packages/win32/node24/tools/package-shim.ps1') - $Evidence.phase='product-shim-ownership-refusal' + $Evidence.phase='product-unqualified-shim-preservation' [IO.File]::WriteAllBytes($shim,(New-Object byte[] 121856)) - $sentinelHash=(Get-FileHash -LiteralPath $shim -Algorithm SHA256).Hash - $rejected=$false - try { $null=Get-VmpowerPackageShimState (Join-Path $env:ChocolateyInstall 'lib\vmpower-agent\tools') } catch { $rejected=$true } - Assert-True ($rejected -and (Test-Path -LiteralPath $shim) -and - (Get-FileHash -LiteralPath $shim -Algorithm SHA256).Hash -ceq $sentinelHash) 'Product proof accepted or changed an unrelated shim' + $packageTools=Join-Path $env:ChocolateyInstall 'lib\vmpower-agent\tools' + $sentinelBefore=Get-FixtureShimFingerprint $shim;$sentinelBefore.status='UNQUALIFIED_PRESERVED' + $sentinelState=Get-VmpowerPackageShimState $packageTools + Assert-True ($sentinelState.status -ceq 'UNQUALIFIED_PRESERVED') 'Product adopted unrelated bytes' + Remove-VmpowerPackageShim $packageTools $sentinelState + Assert-FixtureShimOutcome $shim $sentinelBefore Remove-Item -LiteralPath $shim - $Evidence.unrelatedShimRefusal='PASS' + $Evidence.unrelatedShimPreservation='PASS' $packages=Join-Path $Output 'chocolatey-ownership';$null=New-Item -ItemType Directory -Path $packages $runtimePins=Get-Content -Raw (Join-Path $PSScriptRoot '../distribution/runtime-pins.json') | ConvertFrom-Json $nodeArchive=Join-Path $Output $runtimePins.targets.'win-x64'.archive @@ -178,10 +202,9 @@ function Invoke-WindowsPackageOwnership([string]$LegacyPackages,[string]$Expecte '--nssm-archive',(Join-Path $Output 'nssm.zip'),'--version',$variant.version,'--output',$inputs) $null=Invoke-CheckedNative (Get-Command python).Source $buildArguments if ($variant.name -eq 'shim-failure') { - # Explicit synthetic package only: replace an absent shim after the - # real installer activates a different content-addressed runtime. - # Invoke the actual proof/removal code, which must refuse it and - # trigger the existing SCM transaction plus Chocolatey rollback. + # Synthetic finalizer only: deny reading a preserved shim, or create + # a replacement if the qualified predecessor was removed. The real + # product check must fail inside SCM and Chocolatey rollback. $helperPath=Join-Path $inputs 'tools\package-shim.ps1' $helper=[IO.File]::ReadAllText($helperPath).Replace('function Remove-VmpowerPackageShim(', 'function Remove-VerifiedVmpowerPackageShim(') $helper+=@' @@ -189,6 +212,11 @@ function Invoke-WindowsPackageOwnership([string]$LegacyPackages,[string]$Expecte # SYNTHETIC FINALIZATION FAILURE FIXTURE; NEVER PUBLISH. function Remove-VmpowerPackageShim([string]$ToolsDirectory,[hashtable]$Before) { $sentinel=Join-Path $env:ChocolateyInstall 'bin\vmpower-agent.amd64.exe' + if ($Before.status -eq 'UNQUALIFIED_PRESERVED') { + $lock=[IO.File]::Open($sentinel,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::None) + try { Remove-VerifiedVmpowerPackageShim $ToolsDirectory $Before } finally { $lock.Dispose() } + return + } if (Test-Path -LiteralPath $sentinel) { throw 'Unexpected shim before synthetic replacement' } [IO.File]::WriteAllText($sentinel,'synthetic unrelated replacement after activation') Remove-VerifiedVmpowerPackageShim $ToolsDirectory $Before @@ -205,11 +233,12 @@ function Remove-VmpowerPackageShim([string]$ToolsDirectory,[hashtable]$Before) { $Evidence.packages=@($packedProof) } $families=@( - @{name='docs-windows.nupkg';sha='3b34023ffa2d05b54bdf10abfe9b3c515ab5dccf70af823407dcd1cb976a8956'}, - @{name='dashboard-windows.nupkg';sha='1e72a1db993a3b013b8503e8cde2f0492a8bcee18539ee9e1056c2eb203893af'}) + @{name='docs-windows.nupkg';sha='3b34023ffa2d05b54bdf10abfe9b3c515ab5dccf70af823407dcd1cb976a8956';shimCase='generated'}, + @{name='dashboard-windows.nupkg';sha='1e72a1db993a3b013b8503e8cde2f0492a8bcee18539ee9e1056c2eb203893af';shimCase='generated'}, + @{name='docs-windows.nupkg';sha='3b34023ffa2d05b54bdf10abfe9b3c515ab5dccf70af823407dcd1cb976a8956';shimCase='unrelated-sentinel'}) $proof=@() foreach ($family in $families) { - $familyEvidence=@{family=$family.name;packageSha256=$family.sha;phases=@()};$Evidence.families+=@($familyEvidence) + $familyEvidence=@{family=$family.name;packageSha256=$family.sha;shimCase=$family.shimCase;phases=@()};$Evidence.families+=@($familyEvidence) $Evidence.phase='legacy-registration';$Evidence.family=$family.name Assert-True (-not (Get-CimInstance Win32_Service -Filter "Name='vmpoweragent'")) 'Service must be absent between package cases' $legacy=Join-Path $LegacyPackages $family.name;Assert-NoReparsePoint $legacy @@ -223,6 +252,23 @@ function Remove-VmpowerPackageShim([string]$ToolsDirectory,[hashtable]$Before) { Assert-True ((Get-OwnedChocolateyVersion) -ceq '0.0.1') 'Legacy ownership not registered' Assert-True (-not (Get-CimInstance Win32_Service -Filter "Name='vmpoweragent'")) 'Skipped legacy install created a service' Assert-True (Test-Path -LiteralPath $shim -PathType Leaf) 'Legacy automatic shim missing; migration control not exercised' + if ($family.shimCase -eq 'unrelated-sentinel') { + # Adverse input setup in the third disposable case, after both real + # generated-shim cases completed all preservation/removal assertions. + # This path was journaled absent before the fixture created it. + $generated=Get-FixtureShimFingerprint $shim + $generatedState=Get-VmpowerPackageShimState $packageTools + $generated.status=$generatedState.status + Remove-VmpowerPackageShim $packageTools $generatedState + Assert-FixtureShimOutcome $shim $generated + [IO.File]::WriteAllBytes($shim,(New-Object byte[] 121856)) + } + $shimBefore=Get-FixtureShimFingerprint $shim + $shimBefore.status=(Get-VmpowerPackageShimState $packageTools).status + if ($family.shimCase -eq 'unrelated-sentinel') { + Assert-True ($shimBefore.status -ceq 'UNQUALIFIED_PRESERVED') 'Unrelated sentinel was qualified' + } + $familyEvidence.shimBefore=@{classification=$shimBefore.status;sha256=$shimBefore.sha256;bytes=$shimBefore.bytes} $familyEvidence.phases+=@('legacy-registration-scripts-skipped') $Evidence.phase='synthetic-service-setup' # Reuse only qualified modern Node bytes for the legacy SCM contract. @@ -242,8 +288,9 @@ function Remove-VmpowerPackageShim([string]$ToolsDirectory,[hashtable]$Before) { $null=Invoke-OwnedChocolatey @('upgrade','vmpower-agent','--version=0.0.2',('--source='+$packages),'--yes','--limit-output') Assert-True ((Get-OwnedChocolateyVersion) -ceq '0.0.2') 'New package did not replace legacy registration' # Product behavior must pass before any fixture reconciliation can run. - Assert-True (-not (Test-Path -LiteralPath $shim)) 'Product upgrade left the obsolete legacy shim' - $familyEvidence.phases+=@('product-removes-qualified-legacy-shim') + Assert-FixtureShimOutcome $shim $shimBefore + $familyEvidence.shimAfterUpgrade='PASS' + $familyEvidence.phases+=@('product-shim-preservation-or-qualified-removal') $modern=Join-Path $product ('Agent\releases\'+$prepared.bundles.bundle+'\runtime\node.exe') $service=Get-CimInstance Win32_Service -Filter "Name='vmpoweragent'" if ($disabled) { @@ -261,7 +308,8 @@ function Remove-VmpowerPackageShim([string]$ToolsDirectory,[hashtable]$Before) { Assert-True $script:packageRecoveryObserved 'Failure did not reach installer service recovery' $null=Wait-Condition { (Receiver-State).count -gt $count };$null=Service-Child $modern Assert-True ((Get-OwnedChocolateyVersion) -ceq '0.0.2') 'Failed package did not restore prior registration' - Assert-True (-not (Test-Path -LiteralPath $shim)) 'Failed modern successor recreated a legacy shim' + Assert-FixtureShimOutcome $shim $shimBefore + $familyEvidence.shimAfterCrashingSuccessor='PASS' Assert-True ((Get-FileHash -LiteralPath $config -Algorithm SHA256).Hash -ceq $configDigest) 'Package rollback changed configuration' $familyEvidence.phases+=@('package-and-service-rollback') $Evidence.phase='post-activation-shim-failure-rollback' @@ -276,26 +324,32 @@ function Remove-VmpowerPackageShim([string]$ToolsDirectory,[hashtable]$Before) { } $null=Wait-Condition { (Receiver-State).count -gt $count };$null=Service-Child $modern Assert-True ((Get-FileHash -LiteralPath $config -Algorithm SHA256).Hash -ceq $configDigest) 'Finalization rollback changed configuration' - Assert-True ((Test-Path -LiteralPath $shim) -and [IO.File]::ReadAllText($shim) -ceq 'synthetic unrelated replacement after activation') 'Finalization rollback changed unrelated shim' + if ($shimBefore.status -eq 'UNQUALIFIED_PRESERVED') { + Assert-FixtureShimOutcome $shim $shimBefore + } else { + Assert-True ((Test-Path -LiteralPath $shim) -and [IO.File]::ReadAllText($shim) -ceq 'synthetic unrelated replacement after activation') 'Finalization rollback changed unrelated shim' + # Exact adverse sentinel created above, removed only after assertions. + Remove-Item -LiteralPath $shim + } + $familyEvidence.shimAfterFinalizationFailure='PASS' $familyEvidence.phases+=@('post-activation-shim-failure-restores-package-service') - # Remove only the exact sentinel this adverse control created, after its - # preservation and service/package compensation assertions succeeded. - Remove-Item -LiteralPath $shim $Evidence.phase='modern-package-uninstall' # Run the NEW uninstaller normally, proving old destructive script is gone. $null=Invoke-OwnedChocolatey @('uninstall','vmpower-agent','--yes','--limit-output') Assert-True (-not (Get-OwnedChocolateyVersion)) 'Modern package registration remains' - Assert-True (-not (Test-Path -LiteralPath $shim)) 'Product uninstall requires fixture shim rescue' + Assert-FixtureShimOutcome $shim $shimBefore + $familyEvidence.shimAfterNormalUninstall='PASS' $null=Wait-Condition { -not (Get-CimInstance Win32_Service -Filter "Name='vmpoweragent'") } Assert-True ((Get-FileHash -LiteralPath $config -Algorithm SHA256).Hash -ceq $configDigest) 'Modern package uninstall deleted configuration' Assert-True (Test-Path -LiteralPath $modern) 'Package uninstall deleted rollback runtime' $familyEvidence.phases+=@('modern-uninstall-retains-config-runtime') - $proof+=@(@{family=$family.name;packageSha256=$family.sha;registrationUpgrade='PASS';previousState=$(if ($disabled) {'Stopped/Disabled'} else {'Running/Manual'});serviceRollback='PASS';uninstallPreservesConfig='PASS';legacyPublishedExecutable='UNRUN'}) + $proof+=@(@{family=$family.name;shimCase=$family.shimCase;shimDisposition=$shimBefore.status;packageSha256=$family.sha;registrationUpgrade='PASS';previousState=$(if ($disabled) {'Stopped/Disabled'} else {'Running/Manual'});serviceRollback='PASS';uninstallPreservesConfig='PASS';legacyPublishedExecutable='UNRUN'}) $Evidence.phase='family-package-cleanup' $familyEvidence.shimCleanup=Remove-OwnedChocolateyRegistration - $familyEvidence.phases+=@('package-and-shim-cleanup') + $familyEvidence.phases+=@('fixture-package-and-shim-cleanup') Remove-Item -LiteralPath $localPackage } $Evidence.phase='complete';$Evidence.status='PASS';$Evidence.chocolateyVersion=$ExpectedChocolateyVersion - return @{phase='real-chocolatey-ownership-synthetic-service';status='PASS';chocolateyVersion=$ExpectedChocolateyVersion;packages=$packedProof;families=$proof;oldRuntimeCompatibility='UNQUALIFIED'} + return @{phase='real-chocolatey-ownership-synthetic-service';status='PASS';chocolateyVersion=$ExpectedChocolateyVersion;packages=$packedProof;families=$proof; + serviceMigration='PASS';unqualifiedCommandDisposition='PRESERVED_BY_PRODUCT';fullLegacyCleanup='NOT_CLAIMED';oldRuntimeCompatibility='UNQUALIFIED'} } diff --git a/scripts/test-build-windows-package.py b/scripts/test-build-windows-package.py index 27e8a40..883b397 100644 --- a/scripts/test-build-windows-package.py +++ b/scripts/test-build-windows-package.py @@ -143,6 +143,26 @@ def test_packed_payload_and_shim_mapping_are_bound_to_inputs(self): self.assertEqual(result['nupkgSha256'], digest(packed.read_bytes())) self.assertEqual(result['bundleManifestSha256'], self.expected) + def test_resealed_bundle_cannot_claim_legacy_shim_basename(self): + (self.bundle / 'app/VMPower-Agent.AMD64.EXE').write_bytes(b'synthetic unrelated bytes') + self.seal() + with self.assertRaisesRegex(ValueError, 'legacy shim basename'): self.prepare() + self.assertFalse(self.output.exists()) + + def test_consistent_pack_inputs_cannot_adopt_legacy_command_or_drop_suppression(self): + self.prepare() + extra = self.output / 'tools/VMPower-Agent.AMD64.EXE' + extra.write_bytes(b'synthetic unrelated bytes') + with self.assertRaisesRegex(ValueError, 'legacy shim basename'): + verification.verify(self.output, self.packed_fixture()) + extra.unlink() + for name in ('tools/node.exe.ignore', 'tools/nssm.exe.ignore'): + marker = self.output / name + marker.write_bytes(b'changed marker') + with self.assertRaisesRegex(ValueError, 'suppression marker'): + verification.verify(self.output, self.packed_fixture()) + marker.write_bytes(b'') + def test_packed_tamper_missing_shim_and_extra_executable_rejected(self): self.prepare() mutations = [ diff --git a/scripts/test-windows-product-shim.ps1 b/scripts/test-windows-product-shim.ps1 index 176b87a..acbe1b9 100644 --- a/scripts/test-windows-product-shim.ps1 +++ b/scripts/test-windows-product-shim.ps1 @@ -32,7 +32,7 @@ function Get-VmpowerPackageShimState([string]$ToolsDirectory) { if ($global:productShimChangeOnRecheck -and $global:productShimProofCalls -eq 2) { [IO.File]::WriteAllText($file,'replacement during uninstall') } if (-not (Test-Path -LiteralPath $file)) { return @{status='ABSENT';path=$file} } if ([IO.File]::ReadAllText($file) -cne 'owned synthetic shim') { throw 'unowned fixture shim' } - return @{status='QUALIFIED_LEGACY';path=$file;sha256=(Get-FileHash -LiteralPath $file -Algorithm SHA256).Hash;bytes=(Get-Item $file).Length} + return @{status='QUALIFIED_LEGACY';path=$file;sha256=(Get-FileHash -LiteralPath $file -Algorithm SHA256).Hash;bytes=(Get-Item $file).Length;fileIdentity='synthetic';acl='synthetic'} } '@ [IO.File]::WriteAllText((Join-Path $tools 'package-shim.ps1'),$helper) @@ -82,6 +82,53 @@ function Get-VmpowerPackageShimState([string]$ToolsDirectory) { Assert-Rejected { & (Join-Path $tools 'chocolateyUninstall.ps1') } 'denied shim during uninstall' } Assert-True ($global:productShimServiceCalls -eq $before -and (Test-Path -LiteralPath $shim)) 'Denied shim failure removed service' + # Exercise the real proof/hash/ACL/identity and hook code. Only the fixed + # supported root is mapped into this owned test directory; no global + # Chocolatey path or service is touched by these modeled controls. + $realHelper=[IO.File]::ReadAllText((Join-Path $source 'package-shim.ps1')).Replace( + "'C:\ProgramData\chocolatey'",("'"+$product.Replace("'","''")+"'")) + [IO.File]::WriteAllText((Join-Path $tools 'package-shim.ps1'),$realHelper) + . ([ScriptBlock]::Create($realHelper)) + Set-ProtectedAcl $product $true;Set-ProtectedAcl $bin $true + [IO.File]::WriteAllBytes($shim,(New-Object byte[] 121856));Set-ProtectedAcl $shim $false + $unknown=Get-VmpowerPackageShimState $tools + Assert-True ($unknown.status -ceq 'UNQUALIFIED_PRESERVED') 'Unknown bytes were adopted or refused solely by hash' + function Assert-UnknownUnchanged { + $after=Get-VmpowerPackageShimState $tools + foreach ($field in @('status','sha256','bytes','acl','fileIdentity')) { + Assert-True ($after[$field] -ceq $unknown[$field]) ('Unknown shim changed: '+$field) + } + } + $before=$global:productShimServiceCalls + & (Join-Path $tools 'chocolateyInstall.ps1') + Assert-UnknownUnchanged + Assert-True ($global:productShimServiceCalls -eq ($before+1)) 'Preserved unknown shim blocked independent activation' + $global:productShimInstallerFails=$true + Assert-Rejected { & (Join-Path $tools 'chocolateyInstall.ps1') } 'unknown shim with failed activation' + $global:productShimInstallerFails=$false;Assert-UnknownUnchanged + $before=$global:productShimServiceCalls + & (Join-Path $tools 'chocolateyUninstall.ps1') + Assert-UnknownUnchanged + Assert-True ($global:productShimServiceCalls -eq ($before+1)) 'Preserved unknown shim blocked independent service removal' + # Adversarial fixture replacement, not product behavior: retain the old + # inode while creating identical bytes, so identity must distinguish it. + $held=Join-Path $bin 'fixture-held-shim' + Move-Item -LiteralPath $shim -Destination $held + [IO.File]::WriteAllBytes($shim,(New-Object byte[] 121856));Set-ProtectedAcl $shim $false + Assert-Rejected { Remove-VmpowerPackageShim $tools $unknown } 'same-byte file replacement' + Assert-True ((Get-VmpowerPackageShimState $tools).sha256 -ceq $unknown.sha256) 'Replacement bytes changed' + Remove-Item -LiteralPath $shim;Move-Item -LiteralPath $held -Destination $shim + Set-ProtectedAcl $shim $false $true + Assert-Rejected { Remove-VmpowerPackageShim $tools $unknown } 'unknown shim ACL changed' + Assert-True (Test-Path -LiteralPath $shim) 'ACL-changed unknown shim deleted' + Set-ProtectedAcl $shim $false;Assert-UnknownUnchanged + $before=$global:productShimServiceCalls + $lock=[IO.File]::Open($shim,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::None) + try { + Assert-Rejected { & (Join-Path $tools 'chocolateyInstall.ps1') } 'unknown shim read denied' + Assert-True ($global:productShimServiceCalls -eq $before) 'Unreadable unknown shim allowed service mutation' + } finally { $lock.Dispose() } + Assert-UnknownUnchanged # Actual proof helper, without stubbing: custom roots/locations must fail # closed, and an absent shim needs no inferred ownership. . (Join-Path $source 'package-shim.ps1') @@ -89,7 +136,7 @@ function Get-VmpowerPackageShimState([string]$ToolsDirectory) { Assert-Rejected { Get-VmpowerPackageShimState $bin } 'foreign tools location' Remove-Item -LiteralPath $shim Assert-True ((Get-VmpowerPackageShimState $tools).status -ceq 'ABSENT') 'Actual absent-shim proof failed' - return 13 + return 19 } finally { $env:ChocolateyInstall=$originalRoot foreach ($name in @('productShimFile','productShimServiceCalls','productShimInstallerFails','productShimChanges','productShimProofCalls','productShimChangeOnRecheck')) { diff --git a/scripts/verify-windows-package.py b/scripts/verify-windows-package.py index 307582c..44915cd 100644 --- a/scripts/verify-windows-package.py +++ b/scripts/verify-windows-package.py @@ -21,6 +21,11 @@ def verify(inputs, packed): if name == 'tools/node.exe.ignore': name = 'tools/bundle/runtime/node.exe.ignore' expected[name] = file.read_bytes() + if any(Path(name).name.casefold() == 'vmpower-agent.amd64.exe' for name in expected): + raise ValueError('Modern package must not claim the legacy shim basename') + for marker in ('tools/nssm.exe.ignore', 'tools/bundle/runtime/node.exe.ignore'): + if expected.get(marker) != b'': + raise ValueError('Required empty shim suppression marker missing or changed') record = json.loads(expected['tools/package-input.json']) with zipfile.ZipFile(packed) as archive: names = archive.namelist() From bff04c0ebbae9734ba50aa25b9f207dcb5936bca Mon Sep 17 00:00:00 2001 From: Spencer Albrecht Date: Mon, 26 Oct 2026 22:40:09 -0700 Subject: [PATCH 13/18] Isolate Linux native qualification in an owned systemd slice --- .github/workflows/native-qualification.yml | 7 +- scripts/linux-package-quarantine.py | 332 ++++++++++++++------- scripts/native_linux_commands.py | 96 ++++++ scripts/qualify-linux-dpkg.py | 2 +- scripts/qualify-native-linux.py | 103 +++++-- scripts/test-linux-deb.py | 49 +-- scripts/test-linux-slice.py | 165 ++++++++++ 7 files changed, 558 insertions(+), 196 deletions(-) create mode 100644 scripts/native_linux_commands.py create mode 100644 scripts/test-linux-slice.py diff --git a/.github/workflows/native-qualification.yml b/.github/workflows/native-qualification.yml index 6deac25..d31dff0 100644 --- a/.github/workflows/native-qualification.yml +++ b/.github/workflows/native-qualification.yml @@ -32,6 +32,7 @@ jobs: python3 scripts/test-stage-distribution.py python3 scripts/test-install-linux-bundle.py python3 scripts/test-linux-deb.py + python3 scripts/test-linux-slice.py python3 scripts/test-native-preparation.py python3 scripts/test-legacy-package-inputs.py node scripts/test-native-report-fixture.cjs @@ -40,18 +41,16 @@ jobs: run: python3 scripts/prepare-native-inputs.py --output "$RUNNER_TEMP/vmpower-native" - name: Stage exact retained public Debian package families before quarantine run: python3 scripts/prepare-legacy-package-inputs.py --output "$RUNNER_TEMP/vmpower-native" - - name: Real native bundle CLI - run: '"$RUNNER_TEMP/vmpower-native/bundle/runtime/node" scripts/native-bundle-smoke.cjs' - name: Isolated systemd lifecycle and progress run: | sudo --preserve-env=GITHUB_ACTIONS,RUNNER_ENVIRONMENT,GITHUB_RUN_ID,ImageOS,ImageVersion,RUNNER_TEMP \ - python3 scripts/qualify-native-linux.py --output "$RUNNER_TEMP/vmpower-native" --legacy-dpkg + python3 scripts/linux-package-quarantine.py --output "$RUNNER_TEMP/vmpower-native" --legacy-dpkg - name: Always reconcile owned fixture cleanup if: always() run: | if [ -f scripts/qualify-native-linux.py ]; then sudo --preserve-env=GITHUB_ACTIONS,RUNNER_ENVIRONMENT,GITHUB_RUN_ID,ImageOS,ImageVersion,RUNNER_TEMP \ - python3 scripts/qualify-native-linux.py --cleanup --output "$RUNNER_TEMP/vmpower-native" + python3 scripts/linux-package-quarantine.py --cleanup --output "$RUNNER_TEMP/vmpower-native" fi - name: Sanitized qualification receipts only if: always() diff --git a/scripts/linux-package-quarantine.py b/scripts/linux-package-quarantine.py index f1a8ab9..63f30b7 100644 --- a/scripts/linux-package-quarantine.py +++ b/scripts/linux-package-quarantine.py @@ -1,130 +1,230 @@ #!/usr/bin/env python3 -"""Bounded whole-runner package quarantine; only the disposable native fixture. - -No package script runs until both root and service-user direct socket controls -pass. A separate-session watchdog restores runner connectivity after terminating -the qualification process group on deadline. It never removes the service-UID -rules, so a systemd child stays denied until the existing always-cleanup runs. -""" -import argparse -import errno -import inspect -import json -import os +"""Fixture slice supervision. Never change host firewall or runner networking.""" +import argparse,ctypes,errno,inspect,json,os,platform,re,socket,sys,threading +from contextlib import contextmanager from pathlib import Path -import re -import select -import signal -import socket -import subprocess -import sys -import time - -LIMIT_SECONDS=420 -SELF=Path(__file__).resolve() +import native_linux_commands as commands +from native_fixture_paths import require_output +ROOT=Path(__file__).resolve().parents[1] +SYSTEM=Path('/run/systemd/system') +DROP=SYSTEM/'vmpower.service.d' +PROBE=Path('/opt/vmpower-agent-qualification/slice-probe.py') def guard(): - if not (sys.platform=='linux' and os.geteuid()==0 and os.environ.get('GITHUB_ACTIONS')=='true' and os.environ.get('RUNNER_ENVIRONMENT')=='github-hosted' and os.environ.get('ImageOS')=='ubuntu24'): - raise RuntimeError('Package quarantine requires admitted disposable Ubuntu24') + if not (sys.platform=='linux' and os.geteuid()==0 and platform.machine()=='x86_64' and os.environ.get('GITHUB_ACTIONS')=='true' and os.environ.get('RUNNER_ENVIRONMENT')=='github-hosted' and os.environ.get('ImageOS')=='ubuntu24'): + raise RuntimeError('FIXTURE_ISOLATION_HOST_REFUSED') -def rules(run): - if not re.fullmatch(r'[0-9]+',run):raise ValueError('Numeric native run identity required') - return [[binary,'!','-o','lo','-m','comment','--comment','VMPOWER_PACKAGE_'+run,'-j','REJECT'] for binary in ('iptables','ip6tables')] +def names(): + run=os.environ.get('GITHUB_RUN_ID','') + if not re.fullmatch('[0-9]+',run):raise ValueError('FIXTURE_ISOLATION_RUN_REFUSED') + return {'run':run,'slice':'vmpowerfixture'+run+'.slice','qualify':'vmpowerqualify'+run+'.scope','cleanup':'vmpowercleanup'+run+'.scope'} -def command(*args,allowed=(0,)): - result=subprocess.run(args,capture_output=True,text=True,timeout=15) - if result.returncode not in allowed:raise RuntimeError('Package quarantine command failed: '+args[0]) - return result +def member(pid,unit=None): + expected='/'+names()['slice']+'/' + rows=Path('/proc/'+str(pid)+'/cgroup').read_text().splitlines() + if len(rows)!=1 or not rows[0].startswith('0::'+expected):raise RuntimeError('FIXTURE_ISOLATION_MEMBERSHIP_REFUSED') + group=rows[0][3:] + if unit and group!=expected+unit:raise RuntimeError('FIXTURE_ISOLATION_UNIT_REFUSED') + return group + +def effective_filters(group): + """Read-only x86_64 Linux BPF_PROG_QUERY; unsupported fails before sockets. + + Stable linux/bpf.h query ABI, effective ancestor programs, no BPF load/write. + This avoids installing a new privileged utility on the hosted runner. + """ + guard() + if not group.startswith('/'+names()['slice']+'/') or '..' in group:raise RuntimeError('FIXTURE_ISOLATION_CGROUP_REFUSED') + class Query(ctypes.Structure): + _fields_=[('target_fd',ctypes.c_uint32),('attach_type',ctypes.c_uint32),('query_flags',ctypes.c_uint32),('attach_flags',ctypes.c_uint32),('prog_ids',ctypes.c_uint64),('prog_cnt',ctypes.c_uint32)] + counts=[];libc=ctypes.CDLL(None,use_errno=True) + fd=os.open('/sys/fs/cgroup'+group,os.O_RDONLY|os.O_DIRECTORY) + try: + for attach in (0,1): # BPF_CGROUP_INET_INGRESS / EGRESS + ids=(ctypes.c_uint32*64)();q=Query(fd,attach,1,0,ctypes.addressof(ids),64) + if libc.syscall(321,16,ctypes.byref(q),ctypes.sizeof(q))!=0 or not 0=2 for row in counters): - raise RuntimeError('Root and service quarantine packet counters unproven') - if watcher.poll() is not None:raise RuntimeError('Package quarantine watchdog unavailable') - return {'status':'PASS','scope':'all identities and protocols, nonloopback IPv4/IPv6','root':root,'service':service,'ipv4RejectPacketsAtLeastTwo':True,'ipv6RuleInstalled':True,'deadlineSeconds':LIMIT_SECONDS} - -def finish(owned): - value=owned.get('packageQuarantine') - if value is None:return - guard() - if value['run']!=os.environ['GITHUB_RUN_ID'] or value['seconds']!=LIMIT_SECONDS:raise RuntimeError('Package quarantine journal mismatch') - remove_rules(value['run']) - pid=value['watchdogPid'] - if identity(pid)==value['watchdogStart']: - argv=Path('/proc/'+str(pid)+'/cmdline').read_bytes().split(b'\0') - if str(SELF).encode() not in argv or b'--watchdog' not in argv:raise RuntimeError('Package quarantine watchdog mismatch') - os.kill(pid,signal.SIGTERM) - try:os.waitpid(pid,0) - except ChildProcessError:pass + filters=verify(out);root=probe(names()['run'],port) + descendant=commands.run(sys.executable,str(PROBE),names()['run'],str(port),on_spawn=lambda pid:member(pid)) + return {'status':'PASS','scope':'owned qualifier/package-hook/service cgroups only','kernelFilters':filters, + 'root':root,'rootDescendant':json.loads(descendant.stdout),'serviceExecStartPre':'required on every start', + 'hostFirewallChanged':False,'runnerControlOutsideSlice':True} + +def package_args(out,args): + if Path(args[0]).name=='dpkg' and any(a in args for a in ('--install','--remove','--purge')): + verify(out);data=journal(out) + if not data.get('port') or PROBE.read_bytes()!=probe_bytes():raise RuntimeError('FIXTURE_ISOLATION_HOOK_GUARD_REFUSED') + hook='/usr/bin/python3 '+str(PROBE)+' '+names()['run']+' '+str(data['port']) + return (args[0],'--pre-invoke='+hook,*args[1:]) + return args + +@contextmanager +def cleanup_receiver(out): + # The old receiver died with the previous scope. Cleanup pre-invoke hooks + # still require a positive fixture-only loopback control, not the application. + data=journal(out);port=data.get('port') + if not port:yield;return + stopped=threading.Event() + with socket.socket() as server: + server.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1) + server.bind(('127.0.0.1',port));server.listen();server.settimeout(.1) + def receive(): + while not stopped.is_set(): + try:connection,_=server.accept();connection.close() + except socket.timeout:continue + worker=threading.Thread(target=receive,daemon=True);worker.start() + try:yield + finally: + stopped.set();worker.join(1) + if worker.is_alive():raise RuntimeError('FIXTURE_ISOLATION_RECEIVER_REMAINS') + +def stop_scope(unit): + if unit not in (names()['qualify'],names()['cleanup']):raise RuntimeError('FIXTURE_ISOLATION_SCOPE_REFUSED') + commands.run('systemctl','stop',unit,allowed=(0,5),timeout=15) + require_empty(unit) + +def require_empty(unit): + if unit not in (names()['qualify'],names()['cleanup'],'vmpower.service'):raise RuntimeError('FIXTURE_ISOLATION_SCOPE_REFUSED') + group=Path('/sys/fs/cgroup')/names()['slice']/unit + if group.exists() and any(p.read_text().strip() for p in group.rglob('cgroup.procs')):raise RuntimeError('FIXTURE_ISOLATION_DESCENDANTS_REMAIN') + +def supervise(out,cleanup_only=False,legacy=False): + guard();out=require_output(out);n=names();record=out/'isolation.json';commands.EVIDENCE=out/'evidence' + commands.OPERATION_FILE='operation-supervisor-linux.json' + # The runner/control supervisor must never enter the fixture slice itself. + if any(row.startswith('0::/'+n['slice']) for row in Path('/proc/self/cgroup').read_text().splitlines()):raise RuntimeError('FIXTURE_ISOLATION_SUPERVISOR_INSIDE') + if cleanup_only and not record.exists():return {'status':'NO_ISOLATION_JOURNAL'} + if record.exists(): + saved=json.loads(record.read_text()) + if saved.get('identity')!=n:raise RuntimeError('FIXTURE_ISOLATION_JOURNAL_REFUSED') + if saved.get('state')=='REMOVED':return {'status':'ALREADY_REMOVED'} + result={'status':'FAIL','qualifierScopeEmpty':False,'cleanupScopeEmpty':False,'isolationRemoved':False};error=None + def scope(mode): + args=['systemd-run','--scope','--quiet','--unit='+n[mode],'--slice='+n['slice'], + '--property=TimeoutStopSec=5s','--',sys.executable,str(ROOT/'scripts/qualify-native-linux.py'), + '--inside-slice','--output',str(out)] + if mode=='cleanup':args.append('--cleanup') + elif legacy:args.append('--legacy-dpkg') + return commands.run(*args,timeout=750) + try: + if not cleanup_only: + if record.exists() or DROP.exists() or any(p.exists() for p in policy()):raise RuntimeError('FIXTURE_ISOLATION_OCCUPIED') + if commands.run('systemctl','show','vmpower.service','--property=LoadState','--value').stdout.strip()!='not-found':raise RuntimeError('FIXTURE_ISOLATION_SERVICE_OCCUPIED') + commands.atomic(record,{'identity':n,'state':'INTENT'}) + DROP.mkdir(mode=0o755) + for target,raw in policy().items():target.write_bytes(raw);target.chmod(0o644) + commands.run('systemctl','daemon-reload') + commands.atomic(record,{'identity':n,'state':'READY'}) + try:scope('qualify') + except BaseException as exc:error=exc + # Previous cancellation can leave root dpkg/hooks. Stop and reap the + # entire owned scope before any fresh cleanup hooks, never kill by name. + stop_scope(n['qualify']);result['qualifierScopeEmpty']=True + if (out/'owned.json').exists(): + commands.atomic(out/'evidence/cleanup-linux.json',{'status':'RUNNING','resourceAbsence':'Unknown'}) + try:scope('cleanup') + except BaseException as exc: + # Keep the inner, more specific DPKG/lock/hook failure if it + # finalized. Otherwise persist the outer interruption category. + receipt=out/'evidence/cleanup-linux.json' + if json.loads(receipt.read_text()).get('status')!='FAIL': + commands.atomic(receipt,{'status':'FAIL','resourceAbsence':'Unknown', + 'failure':exc.record if isinstance(exc,commands.CommandFailure) else {'classification':'CLEANUP_SCOPE_INTERRUPTED_OR_REFUSED'}}) + raise + finally:stop_scope(n['cleanup']) + else: + # Qualifier journals intent before any app/account/package mutation. + # Unsupported BPF can therefore safely remove only its owned policy. + if commands.run('systemctl','show','vmpower.service','--property=LoadState','--value').stdout.strip()!='not-found':raise RuntimeError('FIXTURE_ISOLATION_UNJOURNALED_SERVICE') + commands.atomic(out/'evidence/cleanup-linux.json',{'status':'PASS','serviceAbsent':True,'childrenAbsent':True,'noOwnedResources':True}) + result['cleanupScopeEmpty']=True + cleaned=json.loads((out/'evidence/cleanup-linux.json').read_text()) + if cleaned.get('status')!='PASS':raise RuntimeError('FIXTURE_ISOLATION_CLEANUP_INCOMPLETE') + lifecycle=out/'evidence/lifecycle-linux.json' + if lifecycle.exists(): + accepted=json.loads(lifecycle.read_text());accepted['cleanup']=cleaned + commands.atomic(lifecycle,accepted) + data=journal(out) + for target,raw in policy(data.get('port',0)).items(): + if not target.exists() and data['state']=='INTENT':continue + if target.is_symlink() or target.read_bytes()!=raw:raise RuntimeError('FIXTURE_ISOLATION_POLICY_DRIFT') + target.unlink() + if DROP.exists():DROP.rmdir() + commands.run('systemctl','daemon-reload');commands.run('systemctl','stop',n['slice'],allowed=(0,5)) + result.update(status='PASS',isolationRemoved=True);commands.atomic(record,{'identity':n,'state':'REMOVED'}) + finally: + commands.atomic(out/'evidence/isolation-linux.json',result) + receipt=out/'evidence/cleanup-linux.json' + # Includes setup/preflight interruption and failure to reap before purge. + # SIGKILL/runner loss cannot execute finally; the always step retries. + if result['status']!='PASS' and not receipt.exists(): + commands.atomic(receipt,{'status':'FAIL','resourceAbsence':'Unknown','failure':{'classification':'CLEANUP_NOT_COMPLETED'}}) + if error:raise error + return result if __name__=='__main__': - parser=argparse.ArgumentParser();parser.add_argument('--watchdog',nargs=3);parser.add_argument('--probe',type=int);args=parser.parse_args() - if args.watchdog:watchdog(int(args.watchdog[0]),args.watchdog[1],args.watchdog[2]) - elif args.probe:print(json.dumps(probe(args.probe))) - else:parser.error('Explicit fixture mode required') + parser=argparse.ArgumentParser();parser.add_argument('--output',type=Path,required=True);parser.add_argument('--cleanup',action='store_true');parser.add_argument('--legacy-dpkg',action='store_true');args=parser.parse_args() + try: + commands.handle_signals() + supervise(args.output,args.cleanup,args.legacy_dpkg) + except BaseException as exc: + print(json.dumps({'status':'FAIL','classification':exc.record['classification'] if isinstance(exc,commands.CommandFailure) else 'ISOLATION_OR_INTERRUPTION_REFUSED'}));sys.exit(1) diff --git a/scripts/native_linux_commands.py b/scripts/native_linux_commands.py new file mode 100644 index 0000000..63aaa6c --- /dev/null +++ b/scripts/native_linux_commands.py @@ -0,0 +1,96 @@ +"""Bounded fixture commands. Raw output is memory-only; failures use closed labels.""" +import json, os, selectors, signal, subprocess, tempfile, time +from pathlib import Path +LIMIT=65536 +EVIDENCE=None +OPERATION_FILE='operation-linux.json' +class Interrupted(RuntimeError): + def __init__(self,number):self.number=number;super().__init__('FIXTURE_SIGNAL') +def handle_signals(): + def received(number,frame):raise Interrupted(number) + for number in (signal.SIGTERM,signal.SIGINT):signal.signal(number,received) + +class CommandFailure(RuntimeError): + def __init__(self,record): + self.record=record + super().__init__('NATIVE_COMMAND_'+record['classification']) + +def atomic(path,value): + fd,name=tempfile.mkstemp(prefix='.native-json-',dir=path.parent) + try: + with os.fdopen(fd,'w') as stream: + os.fchmod(stream.fileno(),0o644) + json.dump(value,stream,indent=2);stream.write('\n');stream.flush();os.fsync(stream.fileno()) + os.replace(name,path) + finally: + if os.path.exists(name):os.unlink(name) + +def category(args): + name=Path(args[0]).name + if name=='dpkg': + for flag,label in [('--install','DPKG_INSTALL'),('--remove','DPKG_REMOVE'),('--purge','DPKG_PURGE')]: + if flag in args:return label + return 'DPKG_QUERY' + return {'systemctl':'SYSTEMCTL','systemd-run':'OWNED_SCOPE','dpkg-query':'DPKG_QUERY', + 'runuser':'SERVICE_USER','python3':'PYTHON','node':'NODE'}.get(name,'FIXED_HELPER') + +def labels(raw): + text=raw.decode('utf-8','replace').lower() + return [label for label,markers in { + 'DPKG_LOCK_HELD':('could not get lock','frontend lock was locked','database lock was locked','unable to lock'), + 'DPKG_INTERRUPTED':('dpkg was interrupted',), + 'MAINTAINER_SCRIPT_FAILED':('subprocess returned error exit status','script subprocess returned error'), + 'PERMISSION_DENIED':('permission denied',), + 'ISOLATION_REFUSED':('fixture_isolation_',), + }.items() if any(marker in text for marker in markers)] or ['UNCLASSIFIED'] + +def run(*args,allowed=(0,),timeout=35,input=None,env=None,cwd=None,on_spawn=None): + args=tuple(str(a) for a in args) + record={'operation':category(args),'state':'STARTED','startedMonotonic':time.monotonic()} + if EVIDENCE:atomic(EVIDENCE/OPERATION_FILE,record) + buffers={'stdout':bytearray(),'stderr':bytearray()};proc=None;problem=None + try: + raw_input=input.encode() if isinstance(input,str) else input + if raw_input is not None and len(raw_input)>LIMIT:raise ValueError('FIXTURE_INPUT_LIMIT') + with tempfile.TemporaryFile() as stdin: + if raw_input:stdin.write(raw_input) + stdin.seek(0) + proc=subprocess.Popen(args,stdin=stdin,stdout=subprocess.PIPE,stderr=subprocess.PIPE, + env=env,cwd=cwd,start_new_session=True) + if on_spawn:on_spawn(proc.pid) + with selectors.DefaultSelector() as selected: + for key in buffers: + stream=getattr(proc,key);os.set_blocking(stream.fileno(),False) + selected.register(stream,selectors.EVENT_READ,key) + while selected.get_map(): + if time.monotonic()-record['startedMonotonic']>timeout:problem='TIMEOUT';break + for event,_ in selected.select(.05): + chunk=os.read(event.fileobj.fileno(),4096) + if not chunk:selected.unregister(event.fileobj);continue + buffers[event.data].extend(chunk) + if sum(map(len,buffers.values()))>LIMIT:problem='OUTPUT_LIMIT';break + if problem:break + if not problem: + try:proc.wait(timeout=max(.01,timeout-(time.monotonic()-record['startedMonotonic']))) + except subprocess.TimeoutExpired:problem='TIMEOUT' + except BaseException as exc: + problem=problem or 'INTERRUPTED' + if isinstance(exc,Interrupted):record['signal']=exc.number + raise + finally: + if proc is not None: + if problem: + try:os.killpg(proc.pid,signal.SIGKILL) + except ProcessLookupError:pass + try:proc.wait(timeout=5) + except subprocess.TimeoutExpired:problem='REAP_TIMEOUT' + for key in buffers: + stream=getattr(proc,key) + if stream:stream.close() + record.update(state='FINISHED',elapsedSeconds=round(time.monotonic()-record['startedMonotonic'],3), + exitCode=proc.returncode if proc else None, + classification=problem or ('SIGNAL' if proc.returncode<0 else 'SUCCESS' if proc.returncode in allowed else 'EXIT_NONZERO')) + record['diagnosticLabels']=labels(bytes(buffers['stderr'])) if record['classification']!='SUCCESS' else [] + if EVIDENCE:atomic(EVIDENCE/OPERATION_FILE,record) + if problem or proc.returncode not in allowed:raise CommandFailure(record) + return subprocess.CompletedProcess(args,proc.returncode,bytes(buffers['stdout']).decode('utf-8','replace'),bytes(buffers['stderr']).decode('utf-8','replace')) diff --git a/scripts/qualify-linux-dpkg.py b/scripts/qualify-linux-dpkg.py index be5e9e8..50d1dd3 100644 --- a/scripts/qualify-linux-dpkg.py +++ b/scripts/qualify-linux-dpkg.py @@ -98,7 +98,7 @@ def install_legacy(name): config=Path('/etc/vmpower.conf');raw=config.read_bytes();unit=Path('/etc/systemd/system/vmpower.service');launcher=Path('/usr/bin/vmpower') families=result['families'] for name in legacy: - phases=[] + phases=native.Phases(out,'package-'+name) families.append({'name':name,**LEGACY[name],'legacyBinaryExecuted':False,'legacyActiveRuntimeCompatibility':'UNRUN','phases':phases}) install_legacy(name);native.command('systemctl','daemon-reload') assert package_state(native)=='installed|0.1' diff --git a/scripts/qualify-native-linux.py b/scripts/qualify-native-linux.py index 7bc353c..3c85f62 100644 --- a/scripts/qualify-native-linux.py +++ b/scripts/qualify-native-linux.py @@ -2,6 +2,7 @@ """Real systemd lifecycle on an otherwise empty ephemeral Ubuntu runner only.""" import argparse,hashlib,importlib.util,json,os,platform,pwd,grp,shlex,shutil,signal,stat,subprocess,sys,tempfile,time from pathlib import Path +import native_linux_commands as bounded sys.dont_write_bytecode=True from native_fixture_paths import require_output ROOT=Path(__file__).resolve().parents[1] @@ -10,6 +11,15 @@ FIXED_ANCESTORS=('/', '/opt', '/etc', '/etc/systemd', '/etc/systemd/system', '/usr', '/usr/bin') OPT_PATH=Path('/opt') OPT_CHANGE={'path':'/opt','uid':0,'gid':0,'originalMode':'0777','temporaryMode':'0755'} +ACTIVE_OUTPUT=None +def isolation():return package_fixture().quarantine() +class Phases(list): + def __init__(self,out,lane): + super().__init__();self.out=out;self.lane=lane + write(out/'evidence/progress-linux.json',{'lane':lane,'state':'STARTED','completed':[]}) + def append(self,value): + super().append(value) + write(self.out/'evidence/progress-linux.json',{'lane':self.lane,'state':'PROGRESS','completed':list(self)}) def package_fixture(): spec=importlib.util.spec_from_file_location('linux_dpkg_fixture',ROOT/'scripts/qualify-linux-dpkg.py') module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module);return module @@ -65,9 +75,11 @@ def complete_cleanup(out,owned,result): write(out/'evidence/cleanup-linux.json',result) return result def command(*args,allowed=(0,),**kwargs): - result=subprocess.run(args,capture_output=True,text=True,timeout=35,**kwargs) - if result.returncode not in allowed:raise RuntimeError('Native command failed: '+str(args[0])+' returncode='+str(result.returncode)) - return result + if ACTIVE_OUTPUT: + args=isolation().package_args(ACTIVE_OUTPUT,args) + if Path(args[0]).name=='dpkg' and any(a in args for a in ('--install','--remove','--purge')): + kwargs['on_spawn']=lambda pid:isolation().member(pid) + return bounded.run(*args,allowed=allowed,**kwargs) def service_fixture(source,expected,probe,destination): """Copy public fixture bytes out of runner-private ancestors; no service action.""" spec=importlib.util.spec_from_file_location('fixture_installer',ROOT/'scripts/install-linux-bundle.py') @@ -118,12 +130,19 @@ def mainpid():return int(command('systemctl','show','vmpower.service','--propert def child(expected): pid=mainpid() if pid<2 or os.readlink('/proc/'+str(pid)+'/exe')!=str(expected):raise RuntimeError('Wrong service child') + if ACTIVE_OUTPUT: + guard_scope=isolation();guard_scope.verify(ACTIVE_OUTPUT) + guard_scope.effective_filters(guard_scope.member(pid,'vmpower.service')) + pre=command('systemctl','show','vmpower.service','--property=ExecStartPre','--value').stdout + # Actual systemd-executed guard, not a probe run as another service user. + if str(guard_scope.PROBE) not in pre or 'code=exited' not in pre or 'status=0' not in pre: + raise RuntimeError('FIXTURE_ISOLATION_SERVICE_PRESTART_UNPROVEN') return pid def dead(pid):return not Path('/proc/'+str(pid)).exists() def receiver_matches(out,executable,argv): node=str(out/'bundle/runtime/node') return executable==node and argv==[node.encode(),str(ROOT/'scripts/native-report-fixture.cjs').encode(),b''] -def cleanup(out): +def cleanup_owned(out): guard();out=require_output(out);journal=out/'owned.json' if not journal.exists():return {'status':'NO_OWNED_RESOURCES'} owned=json.loads(journal.read_text()) @@ -135,6 +154,7 @@ def cleanup(out): if owned['run']!=os.environ['GITHUB_RUN_ID'] or owned['paths']!=[str(p) for p in managed]:raise RuntimeError('Cleanup ownership mismatch') validate_opt_journal(owned) command('systemctl','stop','vmpower.service',allowed=(0,5)) + if ACTIVE_OUTPUT:isolation().require_empty('vmpower.service') command('systemctl','disable','vmpower.service',allowed=(0,1)) survivors=[] for process in Path('/proc').iterdir(): @@ -151,15 +171,11 @@ def cleanup(out): os.kill(pid,signal.SIGTERM) else:raise RuntimeError('RECEIVER_IDENTITY_MISMATCH') except (FileNotFoundError,ProcessLookupError):pass - # Keep whole-runner quarantine through the final actual package hook. + # All package cleanup hooks remain in the isolated cleanup scope. if dpkg: if dpkg.package_state(sys.modules[__name__])!='absent':command('dpkg','--purge',dpkg.PACKAGE) dpkg.require_absent(sys.modules[__name__]) - for rule in owned.get('firewall',[]): - binary,args=rule[0],rule[1:] - if command(binary,'-w','-C','OUTPUT',*args,allowed=(0,1)).returncode==0:command(binary,'-w','-D','OUTPUT',*args) - assert command(binary,'-w','-C','OUTPUT',*args,allowed=(0,1)).returncode==1 - if dpkg:dpkg.quarantine().finish(owned) + if owned.get('firewall'):raise RuntimeError('Historical firewall journal requires separate reconciliation') for path in managed: if path.is_dir():shutil.rmtree(path) elif path.exists() or path.is_symlink():path.unlink() @@ -175,7 +191,7 @@ def cleanup(out): assert all(not p.exists() for p in managed) assert command('systemctl','show','vmpower.service','--property=LoadState','--value').stdout.strip()=='not-found' for path in out.iterdir(): - if path.name in ['evidence','owned.json']:continue + if path.name in ['evidence','owned.json','isolation.json']:continue if path.is_dir():shutil.rmtree(path) else:path.unlink() result={'status':'FAIL' if survivors else 'PASS','serviceAbsent':True,'childrenAbsent':not survivors,'managedFilesAbsent':True,'fixtureInputsRemoved':True,'firewallRulesRemoved':True,'receiverStopped':True} @@ -183,6 +199,28 @@ def cleanup(out): write(out/'evidence/cleanup-linux.json',result) if survivors:raise RuntimeError('Service left children; forced cleanup performed') return complete_cleanup(out,owned,result) +def cleanup(out): + # Validate the destination before creating any receipt. Foreign paths/journals + # never become writable merely because this is an always-cleanup invocation. + guard();out=require_output(out) + if (out/'owned.json').exists(): + owned=json.loads((out/'owned.json').read_text());managed=list(MANAGED) + if 'dpkgPackage' in owned: + fixture=package_fixture() + if owned['dpkgPackage']!=fixture.PACKAGE:raise RuntimeError('Package cleanup ownership mismatch') + managed+=fixture.EXTRA_PATHS + if owned['run']!=os.environ['GITHUB_RUN_ID'] or owned['paths']!=[str(p) for p in managed]:raise RuntimeError('Cleanup ownership mismatch') + result={'status':'FAIL','resourceAbsence':'Unknown'} + try: + if not (out/'owned.json').exists(): + result={'status':'PASS','serviceAbsent':True,'childrenAbsent':True,'noOwnedResources':True} + return result + result=cleanup_owned(out) + return result + except BaseException as exc: + result['failure']=exc.record if isinstance(exc,bounded.CommandFailure) else {'classification':'CLEANUP_REFUSED'} + raise + finally:write(out/'evidence/cleanup-linux.json',result) def qualify(out,legacy_dpkg=False): guard() out=require_output(out) @@ -202,7 +240,7 @@ def qualify(out,legacy_dpkg=False): if dpkg:owned['dpkgPackage']=dpkg.PACKAGE write(out/'owned.json',owned) prepared=json.loads((out/'evidence/prepared.json').read_text());hashes=prepared['bundles'];node=SERVICE_FIXTURE/'bundle/runtime/node' - phases=[];result={'status':'FAIL','head':prepared['head'],'platform':platform.platform(),'image':os.environ.get('ImageOS'),'imageVersion':os.environ.get('ImageVersion'),'architecture':platform.machine(),'libc':platform.libc_ver(),'phases':phases,'customerOSCompatibility':'Unknown'} + phases=Phases(out,'lifecycle');result={'status':'FAIL','head':prepared['head'],'platform':platform.platform(),'image':os.environ.get('ImageOS'),'imageVersion':os.environ.get('ImageVersion'),'architecture':platform.machine(),'libc':platform.libc_ver(),'phases':phases,'customerOSCompatibility':'Unknown'} try: spec=importlib.util.spec_from_file_location('installer',ROOT/'scripts/install-linux-bundle.py');installer=importlib.util.module_from_spec(spec);spec.loader.exec_module(installer) result['fixedAncestorMetadata']=fixed_ancestor_metadata() @@ -210,18 +248,17 @@ def qualify(out,legacy_dpkg=False): installer.System().destination(SERVICE_FIXTURE) result['serviceFixture']=service_fixture(out/'bundle',hashes['bundle'],ROOT/'scripts/native-egress-probe.cjs',SERVICE_FIXTURE) command('useradd','--system','--user-group','--no-create-home','--home-dir','/nonexistent','--shell','/usr/sbin/nologin','vmpower') - uid=pwd.getpwnam('vmpower').pw_uid - for binary,address in [('iptables','127.0.0.1/32'),('ip6tables','::1/128')]: - args=['-m','owner','--uid-owner',str(uid),'!','-d',address,'-m','comment','--comment','VMPOWER_NATIVE_FIXTURE','-j','REJECT'] - owned['firewall'].append([binary,*args]);write(out/'owned.json',owned) - command(binary,'-w','-I','OUTPUT','1',*args) + guard_scope=isolation();result['kernelIsolation']=guard_scope.verify(out) receiver=subprocess.Popen([str(out/'bundle/runtime/node'),str(ROOT/'scripts/native-report-fixture.cjs')],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL) owned['receiverPid']=receiver.pid;write(out/'owned.json',owned) + guard_scope.member(receiver.pid) port=wait(lambda:state(out)['port']) - proof=egress_probe(node,SERVICE_FIXTURE/'native-egress-probe.cjs',port) - counters=command('iptables','-w','-L','OUTPUT','-n','-v','-x').stdout.splitlines() - assert any('VMPOWER_NATIVE_FIXTURE' in row and int(row.split()[0])>0 for row in counters) - phases.append({'phase':'outbound-denial','status':'PASS','probe':json.loads(proof.stdout),'ipv4RulePacketsPositive':True,'ipv6RuleInstalled':True}) + guard_scope.prepare_service(out,port) + root_proof=guard_scope.begin(sys.modules[__name__],out,port) + proof=command('runuser','-u','vmpower','--','/usr/bin/python3',str(guard_scope.PROBE),guard_scope.names()['run'],str(port)) + phases.append({'phase':'outbound-denial','status':'PASS','rootAndDescendant':root_proof,'serviceUser':json.loads(proof.stdout),'hostFirewallChanged':False}) + smoke=command(str(out/'bundle/runtime/node'),str(ROOT/'scripts/native-bundle-smoke.cjs')) + result['nativeBundleSmoke']=json.loads(smoke.stdout) no_config=command('runuser','-u','vmpower','--',str(node),str(SERVICE_FIXTURE/'bundle/app/index.js'),allowed=(1,)) assert 'required option' in no_config.stderr phases.append({'phase':'real-cli-no-config','status':'PASS'}) @@ -274,12 +311,22 @@ def qualify(out,legacy_dpkg=False): result['realPackageOwnership']={'status':'FAIL','families':[],'packages':{}} dpkg.qualify(sys.modules[__name__],out,installer,hashes,result['realPackageOwnership']) result['status']='PASS' + except BaseException as exc: + result['failure']=exc.record if isinstance(exc,bounded.CommandFailure) else {'classification':'LIFECYCLE_REFUSED_OR_INTERRUPTED'} + raise finally: - try:result['cleanup']=cleanup(out) - except Exception:result['status']='FAIL';result['cleanup']={'status':'FAIL'};raise - finally:write(out/'evidence/lifecycle-linux.json',result) - print(json.dumps({'nativeLifecycle':'PASS','phases':len(phases),'cleanup':result['cleanup']['status']})) + result['cleanup']={'status':'PENDING_OUTER_SCOPE_RECONCILIATION'} + write(out/'evidence/lifecycle-linux.json',result) + print(json.dumps({'nativeLifecycle':'PASS','phases':len(phases),'cleanup':'PENDING'})) if __name__=='__main__': - parser=argparse.ArgumentParser();parser.add_argument('--output',type=Path,required=True);parser.add_argument('--cleanup',action='store_true');parser.add_argument('--legacy-dpkg',action='store_true');args=parser.parse_args();guard() - if args.cleanup:cleanup(args.output) - else:qualify(args.output,args.legacy_dpkg) + parser=argparse.ArgumentParser();parser.add_argument('--output',type=Path,required=True);parser.add_argument('--cleanup',action='store_true');parser.add_argument('--legacy-dpkg',action='store_true');parser.add_argument('--inside-slice',action='store_true');args=parser.parse_args() + try: + bounded.handle_signals() + guard();ACTIVE_OUTPUT=require_output(args.output);bounded.EVIDENCE=ACTIVE_OUTPUT/'evidence' + if not args.inside_slice:raise RuntimeError('FIXTURE_ISOLATION_SUPERVISOR_REQUIRED') + isolation().verify(ACTIVE_OUTPUT) + if args.cleanup: + with isolation().cleanup_receiver(ACTIVE_OUTPUT):cleanup(ACTIVE_OUTPUT) + else:qualify(ACTIVE_OUTPUT,args.legacy_dpkg) + except BaseException as exc: + print(json.dumps({'status':'FAIL','classification':exc.record['classification'] if isinstance(exc,bounded.CommandFailure) else 'NATIVE_REFUSED_OR_INTERRUPTED'}));sys.exit(1) diff --git a/scripts/test-linux-deb.py b/scripts/test-linux-deb.py index 6edda3b..60e704f 100644 --- a/scripts/test-linux-deb.py +++ b/scripts/test-linux-deb.py @@ -168,54 +168,9 @@ def test_complete_transaction_is_resnapshotted_for_reconfigure(self): class NativeBoundaryTests(unittest.TestCase): - def test_quarantine_never_blocks_runner_without_ready_watchdog(self): - with tempfile.TemporaryDirectory() as directory: - out=Path(directory);(out/'owned.json').write_text(json.dumps({'firewall':[]})) - watcher=mock.Mock(pid=321) - with mock.patch.object(quarantine,'guard'),mock.patch.dict(os.environ,{'GITHUB_RUN_ID':'12345'}),mock.patch.object(quarantine,'command',return_value=mock.Mock(returncode=1)) as commands,mock.patch.object(quarantine.os,'getpgrp',return_value=os.getpid()),mock.patch.object(quarantine,'identity',return_value='original'),mock.patch.object(quarantine.subprocess,'Popen',return_value=watcher),mock.patch.object(quarantine.select,'select',return_value=([],[],[])): - with self.assertRaisesRegex(RuntimeError,'did not become ready'):quarantine.begin(native,out,1234) - self.assertFalse(any('-I' in call.args for call in commands.call_args_list)) - self.assertEqual(json.loads((out/'owned.json').read_text())['packageQuarantine']['watchdogPid'],321) - def test_quarantine_probe_rejects_success_and_timeout(self): - for outcome in [None,quarantine.socket.timeout('timed out')]: - connection=mock.MagicMock();connection.__enter__.return_value=connection;connection.connect.side_effect=outcome - with mock.patch.object(quarantine.socket,'create_connection'),mock.patch.object(quarantine.socket,'socket',return_value=connection): - with self.assertRaises(RuntimeError):quarantine.probe(1234) - def test_quarantine_rules_cover_root_and_all_nonloopback_protocols(self): - rules=quarantine.rules('12345') - self.assertEqual([r[0] for r in rules],['iptables','ip6tables']) - for rule in rules: - self.assertEqual(rule[1:4],['!','-o','lo']) - self.assertNotIn('--uid-owner',rule);self.assertNotIn('-p',rule) - self.assertIn('VMPOWER_PACKAGE_12345',rule) - for run in ['','abc','123;command']: - with self.assertRaises(ValueError):quarantine.rules(run) - def test_quarantine_refuses_mac_before_reading_ownership(self): + def test_slice_refuses_mac_before_reading_ownership(self): with mock.patch.object(quarantine.sys,'platform','darwin'): - with self.assertRaisesRegex(RuntimeError,'disposable'):quarantine.begin(native,Path('/not-used'),1234) - def test_watchdog_kills_owned_group_before_network_restore(self): - for current in ['original',None]: - events=[] - with mock.patch.object(quarantine,'guard'),mock.patch.object(quarantine,'identity',side_effect=['original',current]),mock.patch.object(quarantine.os,'getpgid',return_value=123),mock.patch.object(Path,'read_bytes',return_value=b'python\0/repo/scripts/qualify-native-linux.py\0--legacy-dpkg\0'),mock.patch.object(quarantine.time,'sleep'),mock.patch.object(quarantine.os,'killpg',side_effect=lambda *args:events.append(('kill',args))),mock.patch.object(quarantine,'remove_rules',side_effect=lambda run:events.append(('restore',run))): - quarantine.watchdog(123,'original','12345') - self.assertEqual(events,[('kill',(123,quarantine.signal.SIGKILL)),('restore','12345')]) - def test_watchdog_never_kills_reused_process_group(self): - with mock.patch.object(quarantine,'guard'),mock.patch.object(quarantine,'identity',side_effect=['original','new-process']),mock.patch.object(quarantine.os,'getpgid',return_value=123),mock.patch.object(Path,'read_bytes',return_value=b'python\0/repo/scripts/qualify-native-linux.py\0--legacy-dpkg\0'),mock.patch.object(quarantine.time,'sleep'),mock.patch.object(quarantine.os,'killpg') as kill,mock.patch.object(quarantine,'remove_rules') as restore: - quarantine.watchdog(123,'original','12345') - kill.assert_not_called();restore.assert_called_once_with('12345') - def test_watchdog_rejects_workflow_shell_group_before_wait(self): - with mock.patch.object(quarantine,'guard'),mock.patch.object(quarantine,'identity',return_value='original'),mock.patch.object(quarantine.os,'getpgid',return_value=122),mock.patch.object(Path,'read_bytes',return_value=b'python\0/repo/scripts/qualify-native-linux.py\0--legacy-dpkg\0'),mock.patch.object(quarantine.time,'sleep') as sleep: - with self.assertRaisesRegex(RuntimeError,'owner mismatch'):quarantine.watchdog(123,'original','12345') - sleep.assert_not_called() - def test_quarantine_cleanup_removes_only_exact_run_rules(self): - calls=[] - def command(*args,allowed=(0,)): - calls.append(args) - return mock.Mock(returncode=1 if args[2]=='-C' and sum(c==args for c in calls)>1 else 0) - with mock.patch.object(quarantine,'command',side_effect=command):quarantine.remove_rules('12345') - self.assertEqual(len(calls),6) - self.assertTrue(all('VMPOWER_PACKAGE_12345' in c for c in calls)) - self.assertTrue(all('-F' not in c and '-P' not in c for c in calls)) + with self.assertRaisesRegex(RuntimeError,'HOST_REFUSED'):quarantine.begin(native,Path('/not-used'),1234) def test_optional_package_gate_rejects_non_host_before_any_package_operation(self): with mock.patch.object(native.sys,'platform','darwin'),mock.patch.object(native,'command',side_effect=AssertionError('No host operations')): with self.assertRaisesRegex(RuntimeError,'admitted ephemeral'):native.qualify(Path('/not-used'),True) diff --git a/scripts/test-linux-slice.py b/scripts/test-linux-slice.py new file mode 100644 index 0000000..917f4c9 --- /dev/null +++ b/scripts/test-linux-slice.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +"""No root/systemd/firewall/network operations: source mocks and real child tests.""" +import errno,importlib.util,json,os,signal,subprocess,sys,tempfile,time,unittest +from pathlib import Path +from unittest import mock +sys.dont_write_bytecode=True +import native_linux_commands as commands +def load(name,file): + spec=importlib.util.spec_from_file_location(name,Path(__file__).with_name(file)) + module=importlib.util.module_from_spec(spec);sys.modules[name]=module;spec.loader.exec_module(module);return module +isolation=load('tested_slice','linux-package-quarantine.py') +native=load('tested_native','qualify-native-linux.py') + +class CommandTests(unittest.TestCase): + def setUp(self): + self.temp=tempfile.TemporaryDirectory();self.addCleanup(self.temp.cleanup);self.root=Path(self.temp.name) + self.patch=mock.patch.object(commands,'EVIDENCE',self.root);self.patch.start();self.addCleanup(self.patch.stop) + self.filepatch=mock.patch.object(commands,'OPERATION_FILE','operation-linux.json');self.filepatch.start();self.addCleanup(self.filepatch.stop) + def receipt(self):return json.loads((self.root/'operation-linux.json').read_text()) + def test_real_delayed_success(self): + result=commands.run(sys.executable,'-c','import time;time.sleep(.08);print("ok")',timeout=2) + self.assertEqual(result.stdout,'ok\n');self.assertEqual(self.receipt()['classification'],'SUCCESS') + def test_nonzero_closed_labels_hide_stderr_and_arguments(self): + with self.assertRaises(commands.CommandFailure) as caught: + commands.run(sys.executable,'-c','import sys;print("dpkg frontend lock was locked synthetic-secret",file=sys.stderr);sys.exit(2)') + self.assertEqual(caught.exception.record['exitCode'],2) + self.assertEqual(caught.exception.record['diagnosticLabels'],['DPKG_LOCK_HELD']) + self.assertNotIn('synthetic-secret',json.dumps(self.receipt())+str(caught.exception)) + def test_real_parent_and_descendant_killed_on_timeout(self): + marker=self.root/'escaped';pidfile=self.root/'child.pid' + child='import pathlib,time;time.sleep(.6);pathlib.Path('+repr(str(marker))+').write_text("escaped")' + parent='import subprocess,sys,time,pathlib;p=subprocess.Popen([sys.executable,"-c",'+repr(child)+']);pathlib.Path('+repr(str(pidfile))+').write_text(str(p.pid));time.sleep(10)' + with self.assertRaises(commands.CommandFailure) as caught:commands.run(sys.executable,'-c',parent,timeout=.2) + self.assertEqual(caught.exception.record['classification'],'TIMEOUT') + time.sleep(.65);self.assertFalse(marker.exists());self.assertTrue(pidfile.exists()) + def test_baseline_direct_child_timeout_leaves_descendant(self): + # Safe counterexample: old subprocess.run(timeout=...) kills its direct + # child but cannot prevent this short-lived descendant's delayed action. + marker=self.root/'baseline-escaped';pidfile=self.root/'baseline.pid' + child='import pathlib,time;time.sleep(.3);pathlib.Path('+repr(str(marker))+').write_text("escaped")' + parent='import subprocess,sys,time,pathlib;p=subprocess.Popen([sys.executable,"-c",'+repr(child)+']);pathlib.Path('+repr(str(pidfile))+').write_text(str(p.pid));time.sleep(10)' + try: + with self.assertRaises(subprocess.TimeoutExpired):subprocess.run([sys.executable,'-c',parent],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,timeout=.1) + time.sleep(.4);self.assertTrue(marker.exists()) + finally: + if pidfile.exists(): + try:os.kill(int(pidfile.read_text()),signal.SIGKILL) + except ProcessLookupError:pass + def test_pipe_holding_descendant_cannot_defeat_deadline(self): + parent='import subprocess,sys;subprocess.Popen([sys.executable,"-c","import time;time.sleep(10)"])' + start=time.monotonic() + with self.assertRaises(commands.CommandFailure):commands.run(sys.executable,'-c',parent,timeout=.15) + self.assertLess(time.monotonic()-start,2) + def test_unbounded_output_is_stopped_and_never_saved(self): + with self.assertRaises(commands.CommandFailure) as caught:commands.run(sys.executable,'-c','import sys;sys.stderr.write("secret"*50000)',timeout=2) + self.assertEqual(caught.exception.record['classification'],'OUTPUT_LIMIT') + self.assertLess((self.root/'operation-linux.json').stat().st_size,1000) + def test_signal_exit_is_not_success(self): + with self.assertRaises(commands.CommandFailure) as caught:commands.run(sys.executable,'-c','import os,signal;os.kill(os.getpid(),signal.SIGTERM)') + self.assertEqual(caught.exception.record['classification'],'SIGNAL');self.assertEqual(caught.exception.record['exitCode'],-signal.SIGTERM) + def test_real_supervisor_signal_finalizes_command_receipt(self): + code='import sys,pathlib;sys.path.insert(0,'+repr(str(Path(__file__).parent))+');import native_linux_commands as c;c.EVIDENCE=pathlib.Path('+repr(str(self.root))+');c.handle_signals();c.run(sys.executable,"-c","import time;time.sleep(10)")' + proc=subprocess.Popen([sys.executable,'-B','-c',code],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL) + try: + deadline=time.monotonic()+2 + while not (self.root/'operation-linux.json').exists() and time.monotonic() Date: Mon, 26 Oct 2026 22:53:28 -0700 Subject: [PATCH 14/18] Preserve qualifier failures and verify fixture unit ownership --- scripts/linux-package-quarantine.py | 94 ++++++++++++++++++++++------- scripts/test-linux-slice.py | 69 ++++++++++++++++++++- 2 files changed, 139 insertions(+), 24 deletions(-) diff --git a/scripts/linux-package-quarantine.py b/scripts/linux-package-quarantine.py index 63f30b7..9eef4e9 100644 --- a/scripts/linux-package-quarantine.py +++ b/scripts/linux-package-quarantine.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """Fixture slice supervision. Never change host firewall or runner networking.""" -import argparse,ctypes,errno,inspect,json,os,platform,re,socket,sys,threading +import argparse,ctypes,errno,inspect,json,os,platform,re,socket,sys,threading,uuid from contextlib import contextmanager from pathlib import Path import native_linux_commands as commands @@ -73,19 +73,23 @@ def probe_bytes(): return ('import errno,json,re,socket,sys\nfrom pathlib import Path\n'+inspect.getsource(probe)+ '\ntry: print(json.dumps(probe(sys.argv[1],int(sys.argv[2]))))\nexcept Exception: print("FIXTURE_ISOLATION_PROBE_REFUSED",file=sys.stderr);sys.exit(1)\n').encode() -def policy(port=0): +def description(data,key):return 'VMPower fixture '+data['ownershipToken']+' '+key + +def policy(port=0,token='UNADMITTED'): n=names() - return {SYSTEM/n['slice']:b'[Slice]\nIPAddressDeny=any\nIPAddressAllow=127.0.0.1/32 ::1/128\n', + return {SYSTEM/n['slice']:('[Unit]\nDescription=VMPower fixture '+token+' slice\n[Slice]\nIPAddressDeny=any\nIPAddressAllow=127.0.0.1/32 ::1/128\n').encode(), DROP/'90-vmpower-fixture.conf':('[Service]\nSlice='+n['slice']+'\nExecStartPre=/usr/bin/python3 '+str(PROBE)+' '+n['run']+' '+str(port)+'\n').encode()} def journal(out): data=json.loads((out/'isolation.json').read_text()) - if data['identity']!=names() or data['state'] not in ('INTENT','READY'):raise RuntimeError('FIXTURE_ISOLATION_JOURNAL_REFUSED') + if (data['identity']!=names() or data['state'] not in ('INTENT','READY') or + data.get('unitsAbsentAtAdmission') is not True or not re.fullmatch('[0-9a-f]{32}',data.get('ownershipToken','')) or + set(data.get('unitStates',{}))!={'slice','qualify','cleanup'}):raise RuntimeError('FIXTURE_ISOLATION_JOURNAL_REFUSED') return data def verify(out): guard();out=require_output(out);data=journal(out) - for target,raw in policy(data.get('port',0)).items(): + for target,raw in policy(data.get('port',0),data['ownershipToken']).items(): if target.is_symlink() or target.read_bytes()!=raw:raise RuntimeError('FIXTURE_ISOLATION_POLICY_DRIFT') # Check the loaded policy as well as exact runtime files. deny=commands.run('systemctl','show',names()['slice'],'--property=IPAddressDeny','--value').stdout.strip() @@ -98,7 +102,7 @@ def prepare_service(out,port): if not isinstance(port,int) or not 0 Date: Mon, 26 Oct 2026 23:13:10 -0700 Subject: [PATCH 15/18] Check existing fixture units without loading implicit slices --- README.md | 6 +++ scripts/linux-package-quarantine.py | 75 +++++++++++++++++++++++---- scripts/test-linux-slice.py | 78 +++++++++++++++++++++++++++-- 3 files changed, 146 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 19f6b15..9b59723 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,12 @@ network. A real transport negative control verifies that namespace before testing. Tests additionally deny Node transports and machine agent-config reads; notification pipes, processes and provider calls remain synthetic. +Linux native fixtures check existing manager units, unit lookup paths and cgroups +before creating their owned slice. Do not use a named `systemctl show` query to +prove slice absence: systemd can load an implicit slice during that query. +Sanitized supervisor receipts distinguish the primary refusal from later cleanup; +a successful workflow step alone does not establish resource cleanup. + Drone's ordinary branch pushes, pull requests and `master` merges run only the modern source-validation step. Every legacy packaging and Azure publishing step requires both the `tag` event and a `refs/tags/v*` reference. A release tag diff --git a/scripts/linux-package-quarantine.py b/scripts/linux-package-quarantine.py index 9eef4e9..c3b5bb1 100644 --- a/scripts/linux-package-quarantine.py +++ b/scripts/linux-package-quarantine.py @@ -10,6 +10,22 @@ DROP=SYSTEM/'vmpower.service.d' PROBE=Path('/opt/vmpower-agent-qualification/slice-probe.py') +class AdmissionRefused(RuntimeError): + def __init__(self,reason,resource='local'): + self.reason=reason;self.resource=resource + super().__init__('FIXTURE_ISOLATION_UNIT_COLLISION' if reason in ('LOADED_UNIT','UNIT_FILE','DROPIN','CGROUP') else 'FIXTURE_ISOLATION_ADMISSION_REFUSED') + +def failure_record(exc,phase): + reason=(exc.reason if isinstance(exc,AdmissionRefused) else + 'QUERY_FAILED' if isinstance(exc,commands.CommandFailure) else + 'SIGNAL' if isinstance(exc,commands.Interrupted) else + 'IO_FAILED' if isinstance(exc,OSError) else 'UNKNOWN') + return {'phase':phase,'resource':exc.resource if isinstance(exc,AdmissionRefused) else 'local','reason':reason} + +def occupied(target): + try:Path(target).lstat();return True + except FileNotFoundError:return False + def guard(): if not (sys.platform=='linux' and os.geteuid()==0 and platform.machine()=='x86_64' and os.environ.get('GITHUB_ACTIONS')=='true' and os.environ.get('RUNNER_ENVIRONMENT')=='github-hosted' and os.environ.get('ImageOS')=='ubuntu24'): raise RuntimeError('FIXTURE_ISOLATION_HOST_REFUSED') @@ -147,10 +163,37 @@ def unit_group(key): def admit_units(): # All three units and fixed cgroup paths must be absent BEFORE intent, - # file creation, systemd-run or stop. Never adopt a loaded same-name unit. + # file creation, systemd-run or stop. Named property queries load an absent + # implicit slice, so enumerate existing objects without querying those names. + n=names();keys=('slice','qualify','cleanup');units=[n[key] for key in keys] + for verb,reason in (('list-units','LOADED_UNIT'),('list-unit-files','UNIT_FILE')): + args=['systemctl',verb,'--plain','--full','--no-legend','--no-pager'] + if verb=='list-units':args.append('--all') + rows=commands.run(*args,*units).stdout.splitlines() + for row in rows: + if not row.strip():continue + unit=row.split()[0] + if unit not in units:raise AdmissionRefused('PARSE_FAILED') + raise AdmissionRefused(reason,keys[units.index(unit)]) + # The manager's lookup paths include control, transient and generator units. + # Drop-in-only definitions and dangling masks may be absent from both lists. + raw=commands.run('systemctl','show','--property=UnitPath','--value').stdout.strip() + paths=raw.split() + if not paths or len(paths)>64:raise AdmissionRefused('PARSE_FAILED') + for directory in paths: + if not re.fullmatch(r'/[A-Za-z0-9_./-]+',directory) or '..' in Path(directory).parts: + raise AdmissionRefused('PARSE_FAILED') + for key in keys: + target=Path(directory)/n[key] + if occupied(target):raise AdmissionRefused('UNIT_FILE',key) + if occupied(str(target)+'.d'):raise AdmissionRefused('DROPIN',key) for key in ('slice','qualify','cleanup'): - loaded=commands.run('systemctl','show',names()[key],'--property=LoadState','--value').stdout.strip() - if loaded!='not-found' or unit_group(key).exists():raise RuntimeError('FIXTURE_ISOLATION_UNIT_COLLISION') + if occupied(unit_group(key)):raise AdmissionRefused('CGROUP',key) + +def create_policy(target,raw): + # Never overwrite a file that appeared after the admission snapshot. + with target.open('xb') as stream: + os.fchmod(stream.fileno(),0o644);stream.write(raw) def stop_owned(out,key): data=journal(out);unit=names()[key];group=unit_group(key) @@ -189,12 +232,14 @@ def supervise(out,cleanup_only=False,legacy=False): commands.OPERATION_FILE='operation-supervisor-linux.json' # The runner/control supervisor must never enter the fixture slice itself. if any(row.startswith('0::/'+n['slice']) for row in Path('/proc/self/cgroup').read_text().splitlines()):raise RuntimeError('FIXTURE_ISOLATION_SUPERVISOR_INSIDE') - if cleanup_only and not record.exists():return {'status':'NO_ISOLATION_JOURNAL'} + if cleanup_only and not record.exists():raise AdmissionRefused('NO_JOURNAL') if record.exists(): saved=json.loads(record.read_text()) if saved.get('identity')!=n:raise RuntimeError('FIXTURE_ISOLATION_JOURNAL_REFUSED') if saved.get('state')=='REMOVED':return {'status':'ALREADY_REMOVED'} - result={'status':'FAIL','qualifierScopeEmpty':False,'cleanupScopeEmpty':False,'isolationRemoved':False};error=None + result={'status':'FAIL','qualifierScopeEmpty':False,'cleanupScopeEmpty':False,'isolationRemoved':False};error=None;phase='LOCAL_PREFLIGHT' + supervisor=out/'evidence/supervisor-outcome-linux.json' + if cleanup_only and supervisor.exists():result['primarySupervisorOutcome']=json.loads(supervisor.read_text()) outcome=out/'evidence/qualifier-outcome-linux.json' if cleanup_only and outcome.exists():result['qualifierOutcome']=json.loads(outcome.read_text()) def scope(mode): @@ -207,17 +252,21 @@ def scope(mode): return commands.run(*args,timeout=750) try: if not cleanup_only: - if record.exists() or DROP.exists() or any(p.exists() for p in policy()):raise RuntimeError('FIXTURE_ISOLATION_OCCUPIED') - if commands.run('systemctl','show','vmpower.service','--property=LoadState','--value').stdout.strip()!='not-found':raise RuntimeError('FIXTURE_ISOLATION_SERVICE_OCCUPIED') + if occupied(record) or occupied(DROP) or any(occupied(p) for p in policy()):raise AdmissionRefused('LOCAL_PATH') + phase='SERVICE_PREFLIGHT' + if commands.run('systemctl','show','vmpower.service','--property=LoadState','--value').stdout.strip()!='not-found':raise AdmissionRefused('LOADED_UNIT','service') + phase='UNIT_ADMISSION' admit_units() data={'identity':n,'state':'INTENT','unitsAbsentAtAdmission':True,'ownershipToken':uuid.uuid4().hex, 'unitStates':{'slice':'CREATE_INTENT','qualify':'NOT_CREATED','cleanup':'NOT_CREATED'}} commands.atomic(record,data) + phase='POLICY_CREATION' DROP.mkdir(mode=0o755) - for target,raw in policy(token=data['ownershipToken']).items():target.write_bytes(raw);target.chmod(0o644) + for target,raw in policy(token=data['ownershipToken']).items():create_policy(target,raw) commands.run('systemctl','daemon-reload') data['state']='READY';commands.atomic(record,data) commands.atomic(outcome,{'status':'RUNNING'}) + phase='QUALIFIER' try: scope('qualify');result['qualifierOutcome']={'status':'PASS'} except BaseException as exc: @@ -228,7 +277,8 @@ def scope(mode): commands.atomic(outcome,result['qualifierOutcome']) # Previous cancellation can leave root dpkg/hooks. Stop and reap the # entire owned scope before any fresh cleanup hooks, never kill by name. - stop_scope(n['qualify'],out);result['qualifierScopeEmpty']=True + phase='REAP_QUALIFIER';stop_scope(n['qualify'],out);result['qualifierScopeEmpty']=True + phase='CLEANUP' if (out/'owned.json').exists(): commands.atomic(out/'evidence/cleanup-linux.json',{'status':'RUNNING','resourceAbsence':'Unknown'}) try:scope('cleanup') @@ -254,6 +304,7 @@ def scope(mode): accepted=json.loads(lifecycle.read_text());accepted['cleanup']=cleaned commands.atomic(lifecycle,accepted) data=journal(out) + phase='REMOVE_POLICY' stop_owned(out,'slice') for target,raw in policy(data.get('port',0),data['ownershipToken']).items(): if not target.exists() and data['state']=='INTENT':continue @@ -263,6 +314,12 @@ def scope(mode): commands.run('systemctl','daemon-reload') data=journal(out);data['state']='REMOVED' result.update(status='PASS',isolationRemoved=True);commands.atomic(record,data) + if not cleanup_only:commands.atomic(supervisor,{'status':'FAIL' if error else 'PASS','phase':'QUALIFIER' if error else 'COMPLETE',**({'failure':failure_record(error,'QUALIFIER')} if error else {})}) + except BaseException as exc: + result['failure']=failure_record(exc,phase) + # A later always-cleanup invocation cannot replace the original refusal. + if not cleanup_only or not supervisor.exists():commands.atomic(supervisor,{'status':'FAIL','failure':result['failure']}) + raise finally: commands.atomic(out/'evidence/isolation-linux.json',result) receipt=out/'evidence/cleanup-linux.json' diff --git a/scripts/test-linux-slice.py b/scripts/test-linux-slice.py index 82639b2..97b0067 100644 --- a/scripts/test-linux-slice.py +++ b/scripts/test-linux-slice.py @@ -151,13 +151,83 @@ def test_each_existing_unit_or_cgroup_refused_before_setup_or_stop(self): def read(target,*args,**kwargs): return '0::/system.slice/runner.service' if str(target)=='/proc/self/cgroup' else original_read(target,*args,**kwargs) def execute(*args,**kwargs): - calls.append(args);self.assertEqual(args[:2],('systemctl','show')) - loaded=kind=='loaded-unit' and args[2]==isolation.names()[collision] - return mock.Mock(stdout='loaded' if loaded else 'not-found') - def group(key):return mock.Mock(exists=lambda:kind=='cgroup' and key==collision) + calls.append(args) + if args[:2]==('systemctl','list-units'): + return mock.Mock(stdout=(isolation.names()[collision]+' loaded inactive dead fixture') if kind=='loaded-unit' else '') + if args[:2]==('systemctl','list-unit-files'):return mock.Mock(stdout='') + if args==('systemctl','show','--property=UnitPath','--value'):return mock.Mock(stdout=str(out/'units')) + self.assertEqual(args[2],'vmpower.service');return mock.Mock(stdout='not-found') + def group(key):return out/('cgroup-'+key) + if kind=='cgroup':group(collision).mkdir() with mock.patch.object(isolation,'guard'),mock.patch.object(isolation,'require_output',return_value=out),mock.patch.object(isolation,'DROP',drop),mock.patch.object(isolation,'policy',return_value={}),mock.patch.object(isolation,'unit_group',side_effect=group),mock.patch.object(Path,'read_text',autospec=True,side_effect=read),mock.patch.object(commands,'run',side_effect=execute),mock.patch.object(commands,'EVIDENCE'),mock.patch.object(commands,'OPERATION_FILE'),mock.patch.object(isolation,'stop_scope') as stopped: with self.assertRaisesRegex(RuntimeError,'UNIT_COLLISION'):isolation.supervise(out) self.assertFalse((out/'isolation.json').exists());self.assertFalse(drop.exists());stopped.assert_not_called() + failure=json.loads((out/'evidence/supervisor-outcome-linux.json').read_text())['failure'] + self.assertEqual(failure,{'phase':'UNIT_ADMISSION','resource':collision,'reason':'LOADED_UNIT' if kind=='loaded-unit' else 'CGROUP'}) + def test_absent_implicit_slice_is_not_loaded_by_admission(self): + with tempfile.TemporaryDirectory() as directory: + root=Path(directory);calls=[] + def execute(*args,**kwargs): + calls.append(args) + if args[1] in ('list-units','list-unit-files'):return mock.Mock(stdout='') + if args==('systemctl','show','--property=UnitPath','--value'):return mock.Mock(stdout=str(root/'units')) + # systemd255 loads optional-fragment slices during named GetAll. + return mock.Mock(stdout='loaded') + with mock.patch.object(commands,'run',side_effect=execute),mock.patch.object(isolation,'unit_group',side_effect=lambda key:root/key):isolation.admit_units() + self.assertEqual(len(calls),3);self.assertIn('--all',calls[0]) + self.assertEqual(calls[-1],('systemctl','show','--property=UnitPath','--value')) + def test_unloaded_files_dropins_and_dangling_masks_are_collisions(self): + for key in ('slice','qualify','cleanup'): + for kind in ('file','dropin','dangling-mask'): + with self.subTest(key=key,kind=kind),tempfile.TemporaryDirectory() as directory: + root=Path(directory);units=root/'units';units.mkdir();target=units/isolation.names()[key] + if kind=='file':target.write_text('foreign') + elif kind=='dropin':Path(str(target)+'.d').mkdir() + else:target.symlink_to(root/'absent') + def execute(*args,**kwargs):return mock.Mock(stdout=str(units) if args[1]=='show' else '') + with mock.patch.object(commands,'run',side_effect=execute),mock.patch.object(isolation,'unit_group',side_effect=lambda key:root/('cg-'+key)): + with self.assertRaises(isolation.AdmissionRefused) as caught:isolation.admit_units() + self.assertEqual(caught.exception.reason,'DROPIN' if kind=='dropin' else 'UNIT_FILE');self.assertEqual(caught.exception.resource,key) + def test_memory_and_installed_enumeration_refuse_any_exact_unit(self): + for verb in ('list-units','list-unit-files'): + for state in ('loaded inactive dead','not-found inactive dead','loaded active running transient'): + with self.subTest(verb=verb,state=state): + def execute(*args,**kwargs):return mock.Mock(stdout=isolation.names()['slice']+' '+state if args[1]==verb else '') + with mock.patch.object(commands,'run',side_effect=execute): + with self.assertRaises(isolation.AdmissionRefused) as caught:isolation.admit_units() + self.assertEqual(caught.exception.reason,'LOADED_UNIT' if verb=='list-units' else 'UNIT_FILE') + def test_malformed_queries_and_unreadable_metadata_fail_closed(self): + for raw in ('','relative/path','/safe/../other','/safe\nunsafe','/safe;unexpected'): + with self.subTest(raw=raw): + def execute(*args,**kwargs):return mock.Mock(stdout=raw if args[1]=='show' else '') + with mock.patch.object(commands,'run',side_effect=execute): + with self.assertRaises(isolation.AdmissionRefused) as caught:isolation.admit_units() + self.assertEqual(caught.exception.reason,'PARSE_FAILED') + with mock.patch.object(commands,'run',return_value=mock.Mock(stdout='unexpected sensitive text')): + with self.assertRaises(isolation.AdmissionRefused) as caught:isolation.admit_units() + self.assertEqual(caught.exception.reason,'PARSE_FAILED');self.assertNotIn('sensitive',str(caught.exception)) + failure=commands.CommandFailure({'operation':'SYSTEMCTL','classification':'EXIT_NONZERO'}) + with mock.patch.object(commands,'run',side_effect=failure): + with self.assertRaises(commands.CommandFailure):isolation.admit_units() + with mock.patch.object(Path,'lstat',side_effect=PermissionError('withheld')): + with self.assertRaises(PermissionError):isolation.occupied(Path('/unreadable')) + def test_exclusive_policy_creation_preserves_raced_file(self): + with tempfile.TemporaryDirectory() as directory: + target=Path(directory)/'unit';target.write_bytes(b'foreign') + with self.assertRaises(FileExistsError):isolation.create_policy(target,b'fixture') + self.assertEqual(target.read_bytes(),b'foreign') + def test_preflight_failure_survives_no_journal_cleanup_refusal(self): + with tempfile.TemporaryDirectory() as directory: + out=Path(directory);(out/'evidence').mkdir();system=out/'system';system.mkdir() + original_read=Path.read_text + def read(target,*args,**kwargs):return '0::/system.slice/runner.service' if str(target)=='/proc/self/cgroup' else original_read(target,*args,**kwargs) + with mock.patch.object(isolation,'guard'),mock.patch.object(isolation,'require_output',return_value=out),mock.patch.object(isolation,'SYSTEM',system),mock.patch.object(isolation,'DROP',system/'vmpower.service.d'),mock.patch.object(Path,'read_text',autospec=True,side_effect=read),mock.patch.object(isolation,'admit_units',side_effect=isolation.AdmissionRefused('LOADED_UNIT','slice')),mock.patch.object(commands,'run',return_value=mock.Mock(stdout='not-found')): + with self.assertRaises(isolation.AdmissionRefused):isolation.supervise(out) + receipt=(out/'evidence/supervisor-outcome-linux.json').read_bytes() + self.assertEqual(json.loads(receipt)['failure'],{'phase':'UNIT_ADMISSION','resource':'slice','reason':'LOADED_UNIT'}) + with self.assertRaises(isolation.AdmissionRefused) as caught:isolation.supervise(out,cleanup_only=True) + self.assertEqual(caught.exception.reason,'NO_JOURNAL');self.assertEqual((out/'evidence/supervisor-outcome-linux.json').read_bytes(),receipt) + self.assertEqual(json.loads((out/'evidence/cleanup-linux.json').read_text())['status'],'FAIL') def test_unowned_loaded_scope_cannot_be_stopped_after_admission(self): data={'identity':isolation.names(),'ownershipToken':'a'*32,'unitStates':{'qualify':'CREATE_INTENT'}} with mock.patch.object(isolation,'journal',return_value=data),mock.patch.object(commands,'run',return_value=mock.Mock(stdout='LoadState=loaded\nDescription=foreign\nControlGroup=/vmpowerfixture12345.slice/vmpowerqualify12345.scope')) as execute: From 4c539e610d07fbae7ac8ba23975e8247db146244 Mon Sep 17 00:00:00 2001 From: Spencer Albrecht Date: Mon, 26 Oct 2026 23:34:15 -0700 Subject: [PATCH 16/18] Remove redundant unit-file query and label admission failures --- scripts/linux-package-quarantine.py | 32 ++++++++++++++++------------- scripts/test-linux-slice.py | 29 ++++++++++++++++++++++---- 2 files changed, 43 insertions(+), 18 deletions(-) diff --git a/scripts/linux-package-quarantine.py b/scripts/linux-package-quarantine.py index c3b5bb1..17fe591 100644 --- a/scripts/linux-package-quarantine.py +++ b/scripts/linux-package-quarantine.py @@ -11,8 +11,9 @@ PROBE=Path('/opt/vmpower-agent-qualification/slice-probe.py') class AdmissionRefused(RuntimeError): - def __init__(self,reason,resource='local'): - self.reason=reason;self.resource=resource + def __init__(self,reason,resource='local',query=None): + if query not in (None,'LIST_UNITS','MANAGER_UNIT_PATH'):raise ValueError('FIXTURE_QUERY_TAG_REFUSED') + self.reason=reason;self.resource=resource;self.query=query super().__init__('FIXTURE_ISOLATION_UNIT_COLLISION' if reason in ('LOADED_UNIT','UNIT_FILE','DROPIN','CGROUP') else 'FIXTURE_ISOLATION_ADMISSION_REFUSED') def failure_record(exc,phase): @@ -20,7 +21,12 @@ def failure_record(exc,phase): 'QUERY_FAILED' if isinstance(exc,commands.CommandFailure) else 'SIGNAL' if isinstance(exc,commands.Interrupted) else 'IO_FAILED' if isinstance(exc,OSError) else 'UNKNOWN') - return {'phase':phase,'resource':exc.resource if isinstance(exc,AdmissionRefused) else 'local','reason':reason} + return {'phase':phase,'resource':exc.resource if isinstance(exc,AdmissionRefused) else 'local','reason':reason, + **({'query':exc.query} if isinstance(exc,AdmissionRefused) and exc.query else {})} + +def admission_query(tag,*args): + try:return commands.run(*args).stdout + except commands.CommandFailure:raise AdmissionRefused('QUERY_FAILED',query=tag) from None def occupied(target): try:Path(target).lstat();return True @@ -166,18 +172,16 @@ def admit_units(): # file creation, systemd-run or stop. Named property queries load an absent # implicit slice, so enumerate existing objects without querying those names. n=names();keys=('slice','qualify','cleanup');units=[n[key] for key in keys] - for verb,reason in (('list-units','LOADED_UNIT'),('list-unit-files','UNIT_FILE')): - args=['systemctl',verb,'--plain','--full','--no-legend','--no-pager'] - if verb=='list-units':args.append('--all') - rows=commands.run(*args,*units).stdout.splitlines() - for row in rows: - if not row.strip():continue - unit=row.split()[0] - if unit not in units:raise AdmissionRefused('PARSE_FAILED') - raise AdmissionRefused(reason,keys[units.index(unit)]) + rows=admission_query('LIST_UNITS','systemctl','list-units','--plain','--full','--no-legend','--no-pager','--all',*units).splitlines() + for row in rows: + if not row.strip():continue + unit=row.split()[0] + if unit not in units:raise AdmissionRefused('PARSE_FAILED') + raise AdmissionRefused('LOADED_UNIT',keys[units.index(unit)]) # The manager's lookup paths include control, transient and generator units. - # Drop-in-only definitions and dangling masks may be absent from both lists. - raw=commands.run('systemctl','show','--property=UnitPath','--value').stdout.strip() + # Exact lstat covers unloaded fragments and masks as well as drop-in-only + # definitions. A redundant list-unit-files query exits 1 on an empty match. + raw=admission_query('MANAGER_UNIT_PATH','systemctl','show','--property=UnitPath','--value').strip() paths=raw.split() if not paths or len(paths)>64:raise AdmissionRefused('PARSE_FAILED') for directory in paths: diff --git a/scripts/test-linux-slice.py b/scripts/test-linux-slice.py index 97b0067..ffaaf75 100644 --- a/scripts/test-linux-slice.py +++ b/scripts/test-linux-slice.py @@ -174,7 +174,7 @@ def execute(*args,**kwargs): # systemd255 loads optional-fragment slices during named GetAll. return mock.Mock(stdout='loaded') with mock.patch.object(commands,'run',side_effect=execute),mock.patch.object(isolation,'unit_group',side_effect=lambda key:root/key):isolation.admit_units() - self.assertEqual(len(calls),3);self.assertIn('--all',calls[0]) + self.assertEqual(len(calls),2);self.assertIn('--all',calls[0]) self.assertEqual(calls[-1],('systemctl','show','--property=UnitPath','--value')) def test_unloaded_files_dropins_and_dangling_masks_are_collisions(self): for key in ('slice','qualify','cleanup'): @@ -189,13 +189,33 @@ def execute(*args,**kwargs):return mock.Mock(stdout=str(units) if args[1]=='show with self.assertRaises(isolation.AdmissionRefused) as caught:isolation.admit_units() self.assertEqual(caught.exception.reason,'DROPIN' if kind=='dropin' else 'UNIT_FILE');self.assertEqual(caught.exception.resource,key) def test_memory_and_installed_enumeration_refuse_any_exact_unit(self): - for verb in ('list-units','list-unit-files'): + for verb in ('list-units',): for state in ('loaded inactive dead','not-found inactive dead','loaded active running transient'): with self.subTest(verb=verb,state=state): def execute(*args,**kwargs):return mock.Mock(stdout=isolation.names()['slice']+' '+state if args[1]==verb else '') with mock.patch.object(commands,'run',side_effect=execute): with self.assertRaises(isolation.AdmissionRefused) as caught:isolation.admit_units() - self.assertEqual(caught.exception.reason,'LOADED_UNIT' if verb=='list-units' else 'UNIT_FILE') + self.assertEqual(caught.exception.reason,'LOADED_UNIT') + def test_empty_unit_file_match_exit_one_never_becomes_an_admission_query(self): + with tempfile.TemporaryDirectory() as directory: + root=Path(directory);calls=[] + def execute(*args,**kwargs): + calls.append(args) + if args[1]=='list-unit-files':raise commands.CommandFailure({'operation':'SYSTEMCTL','classification':'EXIT_NONZERO','exitCode':1}) + return mock.Mock(stdout=str(root/'units') if args[1]=='show' else '') + with mock.patch.object(commands,'run',side_effect=execute),mock.patch.object(isolation,'unit_group',side_effect=lambda key:root/key):isolation.admit_units() + self.assertFalse(any(args[1]=='list-unit-files' for args in calls)) + def test_each_query_failure_has_a_closed_query_tag(self): + for tag in ('LIST_UNITS','MANAGER_UNIT_PATH'): + with self.subTest(tag=tag),tempfile.TemporaryDirectory() as directory: + root=Path(directory) + def execute(*args,**kwargs): + current='LIST_UNITS' if args[1]=='list-units' else 'MANAGER_UNIT_PATH' + if current==tag:raise commands.CommandFailure({'operation':'SYSTEMCTL','classification':'EXIT_NONZERO','exitCode':1}) + return mock.Mock(stdout='') + with mock.patch.object(commands,'run',side_effect=execute): + with self.assertRaises(isolation.AdmissionRefused) as caught:isolation.admit_units() + self.assertEqual(isolation.failure_record(caught.exception,'UNIT_ADMISSION'),{'phase':'UNIT_ADMISSION','resource':'local','reason':'QUERY_FAILED','query':tag}) def test_malformed_queries_and_unreadable_metadata_fail_closed(self): for raw in ('','relative/path','/safe/../other','/safe\nunsafe','/safe;unexpected'): with self.subTest(raw=raw): @@ -208,7 +228,8 @@ def execute(*args,**kwargs):return mock.Mock(stdout=raw if args[1]=='show' else self.assertEqual(caught.exception.reason,'PARSE_FAILED');self.assertNotIn('sensitive',str(caught.exception)) failure=commands.CommandFailure({'operation':'SYSTEMCTL','classification':'EXIT_NONZERO'}) with mock.patch.object(commands,'run',side_effect=failure): - with self.assertRaises(commands.CommandFailure):isolation.admit_units() + with self.assertRaises(isolation.AdmissionRefused) as caught:isolation.admit_units() + self.assertEqual(caught.exception.reason,'QUERY_FAILED');self.assertEqual(caught.exception.query,'LIST_UNITS') with mock.patch.object(Path,'lstat',side_effect=PermissionError('withheld')): with self.assertRaises(PermissionError):isolation.occupied(Path('/unreadable')) def test_exclusive_policy_creation_preserves_raced_file(self): From 6f22828fc0a9c5352843b3f2bb26052cb60487b8 Mon Sep 17 00:00:00 2001 From: Spencer Albrecht Date: Tue, 27 Oct 2026 00:52:56 -0700 Subject: [PATCH 17/18] Bound fixture identities and fix Windows timeout executable --- scripts/build-windows-package.py | 20 ++++++++-- scripts/linux-package-quarantine.py | 18 ++++++--- scripts/test-build-windows-package.py | 10 +++++ scripts/test-linux-slice.py | 26 ++++++++++++- scripts/test-native-fixture-paths.cjs | 55 ++++++++++++++++++++++++++- 5 files changed, 118 insertions(+), 11 deletions(-) diff --git a/scripts/build-windows-package.py b/scripts/build-windows-package.py index 92fdc99..91d7092 100644 --- a/scripts/build-windows-package.py +++ b/scripts/build-windows-package.py @@ -26,13 +26,25 @@ def regular(path): return path.read_bytes() +def package_version(value): + # Current callers use 0.0.2/3/4; allow three canonical nonnegative Int32 + # components (the System.Version bound), at most 32 ASCII characters total. + if not isinstance(value,str) or not 5<=len(value)<=32: + raise ValueError('An explicit bounded three-part package version is required') + parts=value.split('.') + if len(parts)!=3 or any(not 1<=len(p)<=10 or any(c<'0' or c>'9' for c in p) for p in parts): + raise ValueError('An explicit bounded three-part package version is required') + numbers=tuple(int(p) for p in parts) + if any(n>2147483647 or str(n)!=p for p,n in zip(parts,numbers)): + raise ValueError('Canonical Int32 package version components required') + if numbers <= (0,0,1):raise ValueError('Package must upgrade published 0.0.1') + return '.'.join(str(n) for n in numbers) + + def prepare(bundle, expected, node_archive, nssm_archive, version, output, repo=ROOT): if not re.fullmatch(r'[a-f0-9]{64}', expected): raise ValueError('Qualified manifest SHA256 required') - if not re.fullmatch(r'(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)', version): - raise ValueError('An explicit three-part package version is required') - if tuple(map(int, version.split('.'))) <= (0, 0, 1): - raise ValueError('Package must upgrade published 0.0.1') + version=package_version(version) if output.exists() or output.is_symlink(): raise ValueError('Output exists; retain previous package inputs') if bundle.is_symlink() or not bundle.is_dir(): diff --git a/scripts/linux-package-quarantine.py b/scripts/linux-package-quarantine.py index 17fe591..272e0b3 100644 --- a/scripts/linux-package-quarantine.py +++ b/scripts/linux-package-quarantine.py @@ -36,9 +36,17 @@ def guard(): if not (sys.platform=='linux' and os.geteuid()==0 and platform.machine()=='x86_64' and os.environ.get('GITHUB_ACTIONS')=='true' and os.environ.get('RUNNER_ENVIRONMENT')=='github-hosted' and os.environ.get('ImageOS')=='ubuntu24'): raise RuntimeError('FIXTURE_ISOLATION_HOST_REFUSED') +def canonical_run_id(value): + # Hosted run identity: positive ASCII decimal, at most 20 digits, no aliases. + # Reconstruct only after bounding conversion; never silently merge 01 and 1. + if not isinstance(value,str) or not 1<=len(value)<=20 or any(c<'0' or c>'9' for c in value): + raise ValueError('FIXTURE_ISOLATION_RUN_REFUSED') + run=str(int(value)) + if run=='0' or run!=value:raise ValueError('FIXTURE_ISOLATION_RUN_REFUSED') + return run + def names(): - run=os.environ.get('GITHUB_RUN_ID','') - if not re.fullmatch('[0-9]+',run):raise ValueError('FIXTURE_ISOLATION_RUN_REFUSED') + run=canonical_run_id(os.environ.get('GITHUB_RUN_ID','')) return {'run':run,'slice':'vmpowerfixture'+run+'.slice','qualify':'vmpowerqualify'+run+'.scope','cleanup':'vmpowercleanup'+run+'.scope'} def member(pid,unit=None): @@ -72,7 +80,7 @@ class Query(ctypes.Structure): def probe(run,port): # Copied verbatim for actual service ExecStartPre and dpkg pre-invoke hooks. # No private checkout access, environment output or application imports. - if not re.fullmatch('[0-9]+',run):raise RuntimeError('FIXTURE_ISOLATION_RUN_REFUSED') + run=canonical_run_id(run) expected='/vmpowerfixture'+run+'.slice/' rows=Path('/proc/self/cgroup').read_text().splitlines() if len(rows)!=1 or not rows[0].startswith('0::'+expected):raise RuntimeError('FIXTURE_ISOLATION_MEMBERSHIP_REFUSED') @@ -92,7 +100,7 @@ def probe(run,port): return {'membership':'PASS','loopback':'PASS',**outcomes} def probe_bytes(): - return ('import errno,json,re,socket,sys\nfrom pathlib import Path\n'+inspect.getsource(probe)+ + return ('import errno,json,socket,sys\nfrom pathlib import Path\n'+inspect.getsource(canonical_run_id)+'\n'+inspect.getsource(probe)+ '\ntry: print(json.dumps(probe(sys.argv[1],int(sys.argv[2]))))\nexcept Exception: print("FIXTURE_ISOLATION_PROBE_REFUSED",file=sys.stderr);sys.exit(1)\n').encode() def description(data,key):return 'VMPower fixture '+data['ownershipToken']+' '+key @@ -232,7 +240,7 @@ def require_empty(unit): if group.exists() and any(p.read_text().strip() for p in group.rglob('cgroup.procs')):raise RuntimeError('FIXTURE_ISOLATION_DESCENDANTS_REMAIN') def supervise(out,cleanup_only=False,legacy=False): - guard();out=require_output(out);n=names();record=out/'isolation.json';commands.EVIDENCE=out/'evidence' + guard();n=names();out=require_output(out);record=out/'isolation.json';commands.EVIDENCE=out/'evidence' commands.OPERATION_FILE='operation-supervisor-linux.json' # The runner/control supervisor must never enter the fixture slice itself. if any(row.startswith('0::/'+n['slice']) for row in Path('/proc/self/cgroup').read_text().splitlines()):raise RuntimeError('FIXTURE_ISOLATION_SUPERVISOR_INSIDE') diff --git a/scripts/test-build-windows-package.py b/scripts/test-build-windows-package.py index 883b397..a141522 100644 --- a/scripts/test-build-windows-package.py +++ b/scripts/test-build-windows-package.py @@ -7,6 +7,7 @@ import struct import tempfile import unittest +from unittest import mock import xml.etree.ElementTree as ET import zipfile @@ -21,6 +22,15 @@ class WindowsPackageInputs(unittest.TestCase): + def test_bounded_canonical_version_protocol(self): + for value in ['0.0.2','0.0.3','0.0.4','0.2.0','2147483647.2147483647.2147483647']: + self.assertEqual(package.package_version(value),value) + for value in ['',None,'0.0.1','0.0.0','01.2.3','1.02.3','1.2.03','+1.2.3', + '1.2.3\n','1.2.3','1.2.3.4','2147483648.0.2','1'*1000000+'.2.3']: + with self.subTest(value=str(value)[:40]),mock.patch.object(Path,'exists',side_effect=AssertionError('filesystem reached')),mock.patch.object(package.stage,'verify_bundle',side_effect=AssertionError('bundle reached')): + with self.assertRaises(ValueError): + package.prepare(Path('/unused'), 'a'*64, Path('/unused'), Path('/unused'), value, Path('/unused')) + def setUp(self): self.temp = tempfile.TemporaryDirectory(prefix='windows-package-control-') self.root = Path(self.temp.name) diff --git a/scripts/test-linux-slice.py b/scripts/test-linux-slice.py index ffaaf75..c88b72c 100644 --- a/scripts/test-linux-slice.py +++ b/scripts/test-linux-slice.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """No root/systemd/firewall/network operations: source mocks and real child tests.""" -import errno,importlib.util,json,os,signal,subprocess,sys,tempfile,time,unittest +import errno,importlib.util,io,json,os,signal,subprocess,sys,tempfile,time,unittest from pathlib import Path from unittest import mock sys.dont_write_bytecode=True @@ -77,6 +77,23 @@ def test_atomic_progress_survives_next_operation(self): self.assertEqual(json.loads((evidence/'progress-linux.json').read_text())['completed'][0]['phase'],'completed') class SliceTests(unittest.TestCase): + def test_run_id_bounded_canonical_identity(self): + for run in ['1','12345','99999999999999999999']: + with mock.patch.dict(os.environ,{'GITHUB_RUN_ID':run}): + self.assertEqual(isolation.names(),{'run':run,'slice':'vmpowerfixture'+run+'.slice','qualify':'vmpowerqualify'+run+'.scope','cleanup':'vmpowercleanup'+run+'.scope'}) + for run in ['', '0', '01', '1\n', '+1', '1', '1/2', '1;2', '1'*21, '1'*1000000]: + with self.subTest(run=run[:30]),mock.patch.dict(os.environ,{'GITHUB_RUN_ID':run}),mock.patch.object(Path,'read_text',side_effect=AssertionError('filesystem reached')) as reads,mock.patch.object(isolation,'require_output',side_effect=AssertionError('output reached')) as output,mock.patch.object(commands,'run',side_effect=AssertionError('command reached')) as command,mock.patch.object(isolation.socket,'create_connection',side_effect=AssertionError('socket reached')) as connect,mock.patch.object(isolation,'guard'): + with self.assertRaisesRegex(ValueError,'RUN_REFUSED'):isolation.names() + with self.assertRaisesRegex(ValueError,'RUN_REFUSED'):isolation.probe(run,1) + with self.assertRaisesRegex(ValueError,'RUN_REFUSED'):isolation.supervise(Path('/unused')) + # Execute the exact independently installed hook, not a mock copy. + with mock.patch.object(sys,'argv',['slice-probe.py',run,'1']),mock.patch.object(sys,'stderr',new_callable=io.StringIO) as stderr: + with self.assertRaises(SystemExit) as stopped: + exec(compile(isolation.probe_bytes(),'','exec'),{'__name__':'__main__'}) + self.assertEqual(stopped.exception.code,1) + self.assertEqual(stderr.getvalue(),'FIXTURE_ISOLATION_PROBE_REFUSED\n') + for effect in [reads,output,command,connect]:effect.assert_not_called() + def setUp(self): self.env=mock.patch.dict(os.environ,{'GITHUB_RUN_ID':'12345'});self.env.start();self.addCleanup(self.env.stop) def test_wrong_cgroup_and_descendant_prefix_rejected(self): @@ -99,6 +116,13 @@ def test_both_socket_families_and_loopback_are_tested(self): result=isolation.probe('12345',1234) self.assertEqual(result['ipv4'],'EPERM');self.assertEqual(result['ipv6'],'EPERM');positive.assert_called_once_with(('127.0.0.1',1234),timeout=3) self.assertEqual([c.args[0] for c in sockets.call_args_list],[isolation.socket.AF_INET,isolation.socket.AF_INET6]) + def test_exact_installed_probe_includes_canonical_parser_and_runs_both_families(self): + connection=mock.MagicMock();connection.__enter__.return_value=connection;connection.connect.side_effect=PermissionError(errno.EPERM,'denied') + with mock.patch.object(Path,'read_text',return_value='0::/vmpowerfixture12345.slice/vmpower.service'),mock.patch.object(isolation.socket,'create_connection') as positive,mock.patch.object(isolation.socket,'socket',return_value=connection) as sockets,mock.patch.object(sys,'argv',['slice-probe.py','12345','1234']),mock.patch.object(sys,'stdout',new_callable=io.StringIO) as stdout: + exec(compile(isolation.probe_bytes(),'','exec'),{'__name__':'__main__'}) + self.assertEqual(json.loads(stdout.getvalue()),{'membership':'PASS','loopback':'PASS','ipv4':'EPERM','ipv6':'EPERM'}) + positive.assert_called_once_with(('127.0.0.1',1234),timeout=3) + self.assertEqual([c.args[0] for c in sockets.call_args_list],[isolation.socket.AF_INET,isolation.socket.AF_INET6]) def test_missing_kernel_support_prevents_root_probe(self): with mock.patch.object(isolation,'verify',side_effect=RuntimeError('FIXTURE_ISOLATION_BPF_UNSUPPORTED')),mock.patch.object(isolation,'probe',side_effect=AssertionError('no probe')): with self.assertRaisesRegex(RuntimeError,'BPF_UNSUPPORTED'):isolation.begin(None,Path('/fixture'),1) diff --git a/scripts/test-native-fixture-paths.cjs b/scripts/test-native-fixture-paths.cjs index a9f1cfb..9c43938 100644 --- a/scripts/test-native-fixture-paths.cjs +++ b/scripts/test-native-fixture-paths.cjs @@ -9,6 +9,11 @@ const { test } = require('node:test'); const helperDeadline = 20000; function runHelper(executable, args, env, timeout = helperDeadline) { return new Promise((resolve, reject) => { + // The admitted Windows fixture is C:\Windows. Refuse before creating any + // child; cleanup must never select an executable from an environment path. + if (process.platform === 'win32' && (process.env.SystemRoot !== 'C:\\Windows' || env.SystemRoot !== 'C:\\Windows')) { + throw new Error('WINDOWS_SYSTEM_ROOT_REFUSED'); + } const child = spawn(executable, args, { env, windowsHide: true, detached: process.platform !== 'win32' }); let stdout = '', stderr = '', timeoutError, reapFailure, closed; const failedReap = code => Object.assign(new AggregateError( @@ -22,7 +27,7 @@ function runHelper(executable, args, env, timeout = helperDeadline) { // Reap the captured live leader's tree before awaiting its close. Never // use spawnSync's timeout: it kills the leader without its descendants. if (process.platform === 'win32') { - const killed = spawnSync(path.join(process.env.SystemRoot, 'System32', 'taskkill.exe'), + const killed = spawnSync('C:\\Windows\\System32\\taskkill.exe', ['/PID', String(child.pid), '/T', '/F'], { timeout: 5000, windowsHide: true, encoding: 'utf8' }); if (killed.error || killed.status !== 0) throw new Error('OWNED_TREE_TERMINATION_FAILED'); } else { @@ -83,6 +88,54 @@ async function fixture(check) { } } } +// Exercise this exact helper under a modeled Windows host on every platform. +// No Windows executable is invoked; real native qualification remains separate. +function windowsHelperModel(root = 'C:\\Windows', killStatus = 0) { + const { EventEmitter } = require('node:events'); + const { PassThrough } = require('node:stream'); + const source = fs.readFileSync(__filename, 'utf8'); + const child = new EventEmitter(); + child.pid = 1234; child.stdout = new PassThrough(); child.stderr = new PassThrough(); + const calls = [], timers = [], host = { platform: 'win32', env: { SystemRoot: root } }; + const helper = require('node:vm').runInNewContext( + source.slice(source.indexOf('function runHelper('), source.indexOf('async function fixture(')) + '\nrunHelper', + { process: host, helperDeadline, path, + spawn(...args) { calls.push(['spawn', ...args]); return child; }, + spawnSync(...args) { calls.push(['kill', ...args]); return { status: killStatus }; }, + setTimeout(fn) { timers.push(fn); return timers.length; }, clearTimeout() {} }); + return { helper, child, calls, timers, host }; +} +test('Windows helper refuses missing or unadmitted roots before any child exists', async () => { + for (const root of [undefined, '', 'D:\\Windows', 'C:\\Windows\\evil', 'C:Windows', 'c:\\windows']) { + const m = windowsHelperModel(root); + // Explicit undefined must model a missing environment entry, not the default. + m.host.env.SystemRoot = root; + await assert.rejects(m.helper('unused', [], { SystemRoot: 'C:\\Windows' }), /WINDOWS_SYSTEM_ROOT_REFUSED/); + assert.equal(m.calls.length, 0); assert.equal(m.timers.length, 0); + } + for (const env of [{}, { SystemRoot: 'D:\\Windows' }]) { + const m = windowsHelperModel(); + await assert.rejects(m.helper('unused', [], env), /WINDOWS_SYSTEM_ROOT_REFUSED/); + assert.equal(m.calls.length, 0); assert.equal(m.timers.length, 0); + } +}); +test('Windows timeout fixes taskkill identity and awaits captured child close', async () => { + for (const killStatus of [0, 1]) { + const m = windowsHelperModel('C:\\Windows', killStatus); + let settled = false; + const pending = m.helper('owned-node', [], { SystemRoot: 'C:\\Windows' }); + pending.then(() => { settled = true; }, () => { settled = true; }); + m.host.env.SystemRoot = 'D:\\changed-after-spawn'; + m.timers[0](); + assert.equal(m.calls[1][1], 'C:\\Windows\\System32\\taskkill.exe'); + assert.deepEqual(Array.from(m.calls[1][2]), ['/PID', '1234', '/T', '/F']); + assert.equal(m.calls[1][3].timeout, 5000); + await Promise.resolve(); assert.equal(settled, false); + m.child.emit('close', null, 'SIGKILL'); + if (killStatus) await assert.rejects(pending, error => error.cleanupUnsafe === true && error.errors.length === 2); + else assert.equal((await pending).error.code, 'ETIMEDOUT'); + } +}); function passes(result) { assert.equal(result.error, undefined); assert.equal(result.signal, null); assert.equal(result.status, 0, result.stderr); } function rejects(result) { assert.equal(result.error, undefined); assert.equal(result.signal, null); assert.notEqual(result.status, 0); } async function assertExited(pid) { From 47c5d8a7359b13472de3cf49e559870fe065303a Mon Sep 17 00:00:00 2001 From: Spencer Albrecht Date: Tue, 27 Oct 2026 01:17:18 -0700 Subject: [PATCH 18/18] Model the already loaded fixture helper directly --- scripts/test-native-fixture-paths.cjs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/scripts/test-native-fixture-paths.cjs b/scripts/test-native-fixture-paths.cjs index 9c43938..7b65fbd 100644 --- a/scripts/test-native-fixture-paths.cjs +++ b/scripts/test-native-fixture-paths.cjs @@ -93,13 +93,12 @@ async function fixture(check) { function windowsHelperModel(root = 'C:\\Windows', killStatus = 0) { const { EventEmitter } = require('node:events'); const { PassThrough } = require('node:stream'); - const source = fs.readFileSync(__filename, 'utf8'); const child = new EventEmitter(); child.pid = 1234; child.stdout = new PassThrough(); child.stderr = new PassThrough(); const calls = [], timers = [], host = { platform: 'win32', env: { SystemRoot: root } }; const helper = require('node:vm').runInNewContext( - source.slice(source.indexOf('function runHelper('), source.indexOf('async function fixture(')) + '\nrunHelper', - { process: host, helperDeadline, path, + '(' + runHelper.toString() + ')', + { process: host, helperDeadline, spawn(...args) { calls.push(['spawn', ...args]); return child; }, spawnSync(...args) { calls.push(['kill', ...args]); return { status: killStatus }; }, setTimeout(fn) { timers.push(fn); return timers.length; }, clearTimeout() {} });