diff --git a/.github/workflows/native-qualification.yml b/.github/workflows/native-qualification.yml index 0f85e6b..d31dff0 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,23 +31,26 @@ 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-linux-slice.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: Real native bundle CLI - run: '"$RUNNER_TEMP/vmpower-native/bundle/runtime/node" scripts/native-bundle-smoke.cjs' + - name: Stage exact retained public Debian package families before quarantine + run: python3 scripts/prepare-legacy-package-inputs.py --output "$RUNNER_TEMP/vmpower-native" - 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/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() @@ -73,6 +76,18 @@ 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' } + & 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 @@ -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/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/distribution/linux-debian-handoff.md b/distribution/linux-debian-handoff.md new file mode 100644 index 0000000..481d588 --- /dev/null +++ b/distribution/linux-debian-handoff.md @@ -0,0 +1,138 @@ +# 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. 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. `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. + 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. + It rejects pre-existing package registration, account/config/service or owned + package paths before reading any customer configuration. +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 + 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 **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. + +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. + +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 +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/packages/win32/node24/README.md b/packages/win32/node24/README.md new file mode 100644 index 0000000..3b81256 --- /dev/null +++ b/packages/win32/node24/README.md @@ -0,0 +1,188 @@ +# 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 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 +**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`. 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 +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, 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. +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, 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. + +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 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 +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. + +Local Python tests validate pack inputs using generated non-executable byte +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 +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..b87eb10 --- /dev/null +++ b/packages/win32/node24/tools/chocolateyInstall.ps1 @@ -0,0 +1,31 @@ +# 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') +# 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 +$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') -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 new file mode 100644 index 0000000..515aac1 --- /dev/null +++ b/packages/win32/node24/tools/chocolateyUninstall.ps1 @@ -0,0 +1,11 @@ +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'scripts\install-windows-bundle.ps1') +. (Join-Path $PSScriptRoot 'package-shim.ps1') +$previousShim=Get-VmpowerPackageShimState $PSScriptRoot +# 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) { + & (Join-Path $PSScriptRoot 'scripts\install-windows-bundle.ps1') -Apply -Action Uninstall +} +# 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..855287b --- /dev/null +++ b/packages/win32/node24/tools/package-shim.ps1 @@ -0,0 +1,68 @@ +# 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 + # 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')) { + $status='UNQUALIFIED_PRESERVED' + } + 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 -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 -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-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/build-windows-package.py b/scripts/build-windows-package.py new file mode 100644 index 0000000..91d7092 --- /dev/null +++ b/scripts/build-windows-package.py @@ -0,0 +1,128 @@ +#!/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 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') + 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(): + 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) + 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') + 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', '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 + 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/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/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/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/linux-package-quarantine.py b/scripts/linux-package-quarantine.py new file mode 100644 index 0000000..272e0b3 --- /dev/null +++ b/scripts/linux-package-quarantine.py @@ -0,0 +1,351 @@ +#!/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,uuid +from contextlib import contextmanager +from pathlib import Path +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') + +class AdmissionRefused(RuntimeError): + 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): + 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, + **({'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 + 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') + +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=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): + 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 064: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'): + 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) + shown=commands.run('systemctl','show',unit,'--property=LoadState','--property=Description','--property=ControlGroup').stdout + props=dict(line.split('=',1) for line in shown.splitlines() if '=' in line) + if props.get('LoadState')=='not-found': + if group.exists():raise RuntimeError('FIXTURE_ISOLATION_UNOWNED_CGROUP') + final_state='ABSENT' + else: + expected='/'+str(group.relative_to('/sys/fs/cgroup')) + if (data['unitStates'][key] not in ('CREATE_INTENT','OWNED','STOPPED') or + props.get('Description')!=description(data,key) or props.get('ControlGroup') not in ('',expected)): + raise RuntimeError('FIXTURE_ISOLATION_UNIT_OWNERSHIP_REFUSED') + # Creation is confirmed by the unique journaled description and exact + # cgroup, not inferred from a failed systemd-run attempt/name alone. + data['unitStates'][key]='OWNED';commands.atomic(out/'isolation.json',data) + if key=='slice' and group.exists() and any(p.read_text().strip() for p in group.rglob('cgroup.procs')): + raise RuntimeError('FIXTURE_ISOLATION_DESCENDANTS_REMAIN') + commands.run('systemctl','stop',unit,allowed=(0,5),timeout=15) + if group.exists() and any(p.read_text().strip() for p in group.rglob('cgroup.procs')):raise RuntimeError('FIXTURE_ISOLATION_DESCENDANTS_REMAIN') + final_state='STOPPED' # Do not call a still-loaded inactive unit absent. + data['unitStates'][key]=final_state;commands.atomic(out/'isolation.json',data) + +def stop_scope(unit,out): + keys=[key for key in ('qualify','cleanup') if names()[key]==unit] + if not keys:raise RuntimeError('FIXTURE_ISOLATION_SCOPE_REFUSED') + stop_owned(out,keys[0]) + +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();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') + 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;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): + data=journal(out);data['unitStates'][mode]='CREATE_INTENT';commands.atomic(record,data) + args=['systemd-run','--scope','--quiet','--unit='+n[mode],'--slice='+n['slice'], + '--property=Description='+description(data,mode),'--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 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():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: + error=exc + failure=exc.record if isinstance(exc,commands.CommandFailure) else {'classification':'SIGNAL','signal':exc.number} if isinstance(exc,commands.Interrupted) else {'classification':'QUALIFIER_REFUSED'} + result['qualifierOutcome']={'status':'FAIL','failure':failure} + # Persist before cleanup can replace the latest operation receipt. + 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. + 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') + 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'],out) + 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) + 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 + 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') + 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' + # 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('--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/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/qualify-linux-dpkg.py b/scripts/qualify-linux-dpkg.py new file mode 100644 index 0000000..50d1dd3 --- /dev/null +++ b/scripts/qualify-linux-dpkg.py @@ -0,0 +1,153 @@ +#!/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 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') + 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,)) + 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'] + 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=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' + 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..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,18 @@ 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 def fixed_ancestor_metadata(): """Read only an explicit public filesystem metadata allowlist; never repair it.""" rows=[] @@ -62,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') @@ -115,18 +130,31 @@ 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()) - 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)) + if ACTIVE_OUTPUT:isolation().require_empty('vmpower.service') command('systemctl','disable','vmpower.service',allowed=(0,1)) survivors=[] for process in Path('/proc').iterdir(): @@ -143,11 +171,12 @@ def cleanup(out): os.kill(pid,signal.SIGTERM) else:raise RuntimeError('RECEIVER_IDENTITY_MISMATCH') except (FileNotFoundError,ProcessLookupError):pass - 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 - for path in MANAGED: + # 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__]) + 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() command('systemctl','daemon-reload');command('systemctl','reset-failed','vmpower.service',allowed=(0,1)) @@ -159,30 +188,59 @@ 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.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} + 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 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) 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'} + 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() @@ -190,18 +248,17 @@ def qualify(out): 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'}) @@ -250,13 +307,26 @@ 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' + 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');args=parser.parse_args();guard() - if args.cleanup:cleanup(args.output) - else:qualify(args.output) + 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/qualify-native-windows.ps1 b/scripts/qualify-native-windows.ps1 index 900daad..95e00ae 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' @@ -46,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 } @@ -74,15 +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. + $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' @@ -186,9 +213,26 @@ 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.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 new file mode 100644 index 0000000..61fbf4d --- /dev/null +++ b/scripts/qualify-windows-package-ownership.ps1 @@ -0,0 +1,355 @@ +# 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 + # 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 ' ' + $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 + $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' + } + if ($process.ExitCode -ne 0) { + # Distinguish the intended crashing-child rollback from download, + # lock, command-line or package-validation failures. Keep raw logs private. + $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") + } finally { $process.Dispose() } +} + +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\.[1234])$') { throw 'Unexpected package registration; preserve for inspection' } + 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. + $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' + $script:cleanupPhase='package-cache-removal' + # 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' + } + $script:cleanupPhase='owned-legacy-shim-reconciliation' + 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=@() + $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-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' } + $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 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-unqualified-shim-preservation' + [IO.File]::WriteAllBytes($shim,(New-Object byte[] 121856)) + $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.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 + $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';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.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') { + # 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+=@' + +# 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 +} +'@ + [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') + $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';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;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 + 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-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' + 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. + $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 + $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-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) { + 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 + $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' + 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-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' + $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' + 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') + $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-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;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+=@('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; + 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 new file mode 100644 index 0000000..a141522 --- /dev/null +++ b/scripts/test-build-windows-package.py @@ -0,0 +1,194 @@ +#!/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 +from unittest import mock +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 +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): + 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) + 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/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') + # Enough PE structure for the actual shared runtime validator, never run. + node = bytearray(80) + node[:2] = b'MZ'; struct.pack_into('' + 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_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 = [ + 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/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-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() diff --git a/scripts/test-linux-deb.py b/scripts/test-linux-deb.py new file mode 100644 index 0000000..60e704f --- /dev/null +++ b/scripts/test-linux-deb.py @@ -0,0 +1,191 @@ +#!/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') +quarantine=load('deb_quarantine','linux-package-quarantine.py') + + +def ar_members(raw): + assert raw[:8]==b'!\n';position=8;result={} + while position','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): + for value in ['0::/system.slice/runner.service','0::/vmpowerfixture12345.slice-other/x','1::/vmpowerfixture12345.slice/x']: + with self.subTest(value=value),mock.patch.object(Path,'read_text',return_value=value): + with self.assertRaisesRegex(RuntimeError,'MEMBERSHIP'):isolation.member(1) + with mock.patch.object(Path,'read_text',return_value='0::/vmpowerfixture12345.slice/vmpower.service'): + self.assertEqual(isolation.member(1,'vmpower.service'),'/vmpowerfixture12345.slice/vmpower.service') + with self.assertRaisesRegex(RuntimeError,'UNIT_REFUSED'):isolation.member(1,'other.service') + def test_probe_fails_before_socket_if_membership_wrong(self): + with mock.patch.object(Path,'read_text',return_value='0::/system.slice/runner.service'),mock.patch.object(isolation.socket,'socket',side_effect=AssertionError('no socket')): + with self.assertRaisesRegex(RuntimeError,'MEMBERSHIP'):isolation.probe('12345',1) + def test_probe_external_success_is_not_denial(self): + connection=mock.MagicMock();connection.__enter__.return_value=connection + with mock.patch.object(Path,'read_text',return_value='0::/vmpowerfixture12345.slice/vmpower.service'),mock.patch.object(isolation.socket,'create_connection'),mock.patch.object(isolation.socket,'socket',return_value=connection): + with self.assertRaisesRegex(RuntimeError,'EXTERNAL_CONNECTED'):isolation.probe('12345',1) + def test_both_socket_families_and_loopback_are_tested(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: + 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) + def test_actual_package_hook_guard_precedes_each_mutation(self): + for operation in ['--install','--remove','--purge']: + with mock.patch.object(isolation,'verify'),mock.patch.object(isolation,'journal',return_value={'port':1234}),mock.patch.object(Path,'read_bytes',return_value=isolation.probe_bytes()): + args=isolation.package_args(Path('/fixture'),('dpkg',operation,'fixed.deb')) + self.assertTrue(args[1].startswith('--pre-invoke=/usr/bin/python3 /opt/')) + self.assertEqual(args[2:],(operation,'fixed.deb')) + def test_changed_hook_refused_before_package_command(self): + with mock.patch.object(isolation,'verify'),mock.patch.object(isolation,'journal',return_value={'port':1234}),mock.patch.object(Path,'read_bytes',return_value=b'changed'): + with self.assertRaisesRegex(RuntimeError,'HOOK_GUARD'):isolation.package_args(Path('/fixture'),('dpkg','--purge','fixed')) + def test_only_exact_owned_scope_can_be_stopped(self): + with mock.patch.object(commands,'run',side_effect=AssertionError('no system call')): + with self.assertRaisesRegex(RuntimeError,'SCOPE_REFUSED'):isolation.stop_scope('runner.service',Path('/unused')) + def test_cleanup_scope_failure_reaps_prior_scope_and_finalizes_failure(self): + with tempfile.TemporaryDirectory() as directory: + out=Path(directory);(out/'evidence').mkdir();(out/'owned.json').write_text('{}') + (out/'isolation.json').write_text(json.dumps({'identity':isolation.names(),'state':'READY','unitsAbsentAtAdmission':True,'ownershipToken':'a'*32,'unitStates':{'slice':'CREATE_INTENT','qualify':'CREATE_INTENT','cleanup':'NOT_CREATED'}})) + 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) + events=[] + def execute(*args,**kwargs): + self.assertEqual(args[0],'systemd-run');events.append('cleanup-start') + raise commands.CommandFailure({'operation':'OWNED_SCOPE','classification':'TIMEOUT'}) + with mock.patch.object(isolation,'guard'),mock.patch.object(isolation,'require_output',return_value=out),mock.patch.object(Path,'read_text',autospec=True,side_effect=read),mock.patch.object(isolation,'stop_scope',side_effect=lambda name,out:events.append(name)),mock.patch.object(commands,'run',side_effect=execute),mock.patch.object(commands,'EVIDENCE'),mock.patch.object(commands,'OPERATION_FILE'): + with self.assertRaises(commands.CommandFailure):isolation.supervise(out,cleanup_only=True) + self.assertEqual(events,['vmpowerqualify12345.scope','cleanup-start','vmpowercleanup12345.scope']) + self.assertEqual(json.loads((out/'evidence/cleanup-linux.json').read_text())['status'],'FAIL') + self.assertEqual(json.loads((out/'evidence/isolation-linux.json').read_text())['status'],'FAIL') + def test_service_descendants_must_be_gone_before_purge(self): + fake=mock.MagicMock();fake.read_text.return_value='42' + with mock.patch.object(Path,'exists',return_value=True),mock.patch.object(Path,'rglob',return_value=[fake]): + with self.assertRaisesRegex(RuntimeError,'DESCENDANTS_REMAIN'):isolation.require_empty('vmpower.service') + def test_effective_kernel_filter_query_uses_ingress_and_egress(self): + queried=[] + def query(number,operation,pointer,size): + queried.append((number,operation,pointer._obj.attach_type,pointer._obj.query_flags,size)) + pointer._obj.prog_cnt=1;return 0 + with mock.patch.object(isolation,'guard'),mock.patch.object(isolation.os,'open',return_value=9),mock.patch.object(isolation.os,'close'),mock.patch.object(isolation.ctypes,'CDLL',return_value=mock.Mock(syscall=query)): + self.assertEqual(isolation.effective_filters('/vmpowerfixture12345.slice/test.scope'),{'ingressPrograms':1,'egressPrograms':1}) + self.assertEqual([(n,op,kind,flag) for n,op,kind,flag,_ in queried],[(321,16,0,1),(321,16,1,1)]) + def test_each_existing_unit_or_cgroup_refused_before_setup_or_stop(self): + for collision in ('slice','qualify','cleanup'): + for kind in ('loaded-unit','cgroup'): + with self.subTest(collision=collision,kind=kind),tempfile.TemporaryDirectory() as directory: + out=Path(directory);(out/'evidence').mkdir();drop=out/'drop';calls=[] + 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) + def execute(*args,**kwargs): + 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),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'): + 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',): + 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') + 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): + 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(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): + 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: + with self.assertRaisesRegex(RuntimeError,'OWNERSHIP_REFUSED'):isolation.stop_scope(isolation.names()['qualify'],Path('/unused')) + self.assertEqual(len(execute.call_args_list),1);self.assertEqual(execute.call_args.args[:2],('systemctl','show')) + def test_matched_created_scope_is_journaled_before_stop(self): + with tempfile.TemporaryDirectory() as directory: + out=Path(directory) + data={'identity':isolation.names(),'state':'READY','unitsAbsentAtAdmission':True,'ownershipToken':'a'*32,'unitStates':{'slice':'CREATE_INTENT','qualify':'CREATE_INTENT','cleanup':'NOT_CREATED'}} + (out/'isolation.json').write_text(json.dumps(data));calls=[] + def execute(*args,**kwargs): + calls.append(args) + if args[1]=='show':return mock.Mock(stdout='LoadState=loaded\nDescription='+isolation.description(data,'qualify')+'\nControlGroup=/vmpowerfixture12345.slice/vmpowerqualify12345.scope') + self.assertEqual(json.loads((out/'isolation.json').read_text())['unitStates']['qualify'],'OWNED') + return mock.Mock(stdout='') + with mock.patch.object(commands,'run',side_effect=execute):isolation.stop_scope(isolation.names()['qualify'],out) + self.assertEqual([args[1] for args in calls],['show','stop']) + self.assertEqual(json.loads((out/'isolation.json').read_text())['unitStates']['qualify'],'STOPPED') + def test_unadmitted_journal_cannot_authorize_a_stop(self): + with tempfile.TemporaryDirectory() as directory: + out=Path(directory);(out/'isolation.json').write_text(json.dumps({'identity':isolation.names(),'state':'READY'})) + with mock.patch.object(commands,'run',side_effect=AssertionError('no command')): + with self.assertRaisesRegex(RuntimeError,'JOURNAL_REFUSED'):isolation.stop_scope(isolation.names()['qualify'],out) + def test_qualifier_timeout_or_signal_survives_successful_cleanup(self): + failures=[commands.CommandFailure({'operation':'OWNED_SCOPE','classification':'TIMEOUT','diagnosticLabels':['UNCLASSIFIED']}),commands.Interrupted(signal.SIGTERM)] + for failure in failures: + with self.subTest(failure=type(failure).__name__),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) + def execute(*args,**kwargs): + if args[0]=='systemd-run': + if '--cleanup' not in args: + (out/'owned.json').write_text('{}');raise failure + self.assertEqual(json.loads((out/'evidence/qualifier-outcome-linux.json').read_text())['status'],'FAIL') + commands.atomic(out/'evidence/operation-supervisor-linux.json',{'classification':'SUCCESS'}) + commands.atomic(out/'evidence/cleanup-linux.json',{'status':'PASS'}) + return mock.Mock(stdout='not-found') + 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(isolation,'admit_units'),mock.patch.object(Path,'read_text',autospec=True,side_effect=read),mock.patch.object(isolation,'stop_scope'),mock.patch.object(isolation,'stop_owned'),mock.patch.object(commands,'run',side_effect=execute),mock.patch.object(commands,'EVIDENCE'),mock.patch.object(commands,'OPERATION_FILE'): + with self.assertRaises(type(failure)):isolation.supervise(out) + saved=json.loads((out/'evidence/qualifier-outcome-linux.json').read_text()) + self.assertEqual(saved['status'],'FAIL') + self.assertEqual(saved['failure']['classification'],'TIMEOUT' if isinstance(failure,commands.CommandFailure) else 'SIGNAL') + final=json.loads((out/'evidence/isolation-linux.json').read_text()) + self.assertEqual(final['status'],'PASS');self.assertEqual(final['qualifierOutcome'],saved) + self.assertEqual(json.loads((out/'evidence/cleanup-linux.json').read_text())['status'],'PASS') + def test_purge_failure_always_writes_closed_cleanup_receipt(self): + with tempfile.TemporaryDirectory() as directory: + out=Path(directory);(out/'evidence').mkdir();(out/'owned.json').write_text(json.dumps({'run':'12345','paths':[str(p) for p in native.MANAGED]})) + failure=commands.CommandFailure({'operation':'DPKG_PURGE','classification':'EXIT_NONZERO','exitCode':2,'diagnosticLabels':['DPKG_LOCK_HELD']}) + with mock.patch.object(native,'guard'),mock.patch.object(native,'require_output',return_value=out),mock.patch.object(native,'cleanup_owned',side_effect=failure): + with self.assertRaises(commands.CommandFailure):native.cleanup(out) + saved=json.loads((out/'evidence/cleanup-linux.json').read_text()) + self.assertEqual(saved['status'],'FAIL');self.assertEqual(saved['resourceAbsence'],'Unknown');self.assertEqual(saved['failure']['diagnosticLabels'],['DPKG_LOCK_HELD']) + def test_slice_and_real_service_share_one_policy(self): + entries=isolation.policy(1234) + self.assertEqual(len(entries),2) + self.assertIn(b'IPAddressDeny=any',next(raw for target,raw in entries.items() if target.suffix=='.slice')) + drop=entries[isolation.DROP/'90-vmpower-fixture.conf'] + self.assertIn(b'Slice=vmpowerfixture12345.slice',drop);self.assertIn(b'ExecStartPre=',drop) + def test_linux_workflow_has_no_staged_cli_before_supervisor(self): + workflow=(Path(__file__).parents[1]/'.github/workflows/native-qualification.yml').read_text().split(' windows-native:')[0] + self.assertNotIn('bundle/runtime/node',workflow) + self.assertIn('linux-package-quarantine.py --output',workflow) + self.assertIn('linux-package-quarantine.py --cleanup',workflow) + +if __name__=='__main__':unittest.main() diff --git a/scripts/test-native-fixture-paths.cjs b/scripts/test-native-fixture-paths.cjs index ff14f4b..7b65fbd 100644 --- a/scripts/test-native-fixture-paths.cjs +++ b/scripts/test-native-fixture-paths.cjs @@ -3,10 +3,52 @@ 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) => { + // 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( + [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('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 { + 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 { const root = path.join(temp, 'vmpower-native'); const bundle = path.join(root, 'bundle'); @@ -27,63 +69,192 @@ 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 }); - } finally { fs.rmSync(temp, { recursive: true, force: true }); } + 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 }); + } catch (cleanupFailure) { + if (primaryFailure) throw new AggregateError([primaryFailure, cleanupFailure], 'Fixture and cleanup both failed'); + throw cleanupFailure; + } + } } +// 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 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( + '(' + 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() {} }); + 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); } -test('real smoke helper uses the executable bundle without a path argument', () => fixture(({ run }) => { - passes(run('native-bundle-smoke.cjs')); +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; + await assert.rejects(() => fixture(async ({ 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}`, async 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 { + await assert.rejects(() => fixture(async ({ 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(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'); @@ -96,7 +267,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); })); diff --git a/scripts/test-windows-package-cleanup.ps1 b/scripts/test-windows-package-cleanup.ps1 new file mode 100644 index 0000000..1b7e889 --- /dev/null +++ b/scripts/test-windows-package-cleanup.ps1 @@ -0,0 +1,121 @@ +# 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-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. + $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=@{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 } + & $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' + 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 +$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++ + . (Join-Path $PSScriptRoot 'test-windows-product-shim.ps1') + $productCases=Test-WindowsProductShim $fixture + $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 } +} diff --git a/scripts/test-windows-product-shim.ps1 b/scripts/test-windows-product-shim.ps1 new file mode 100644 index 0000000..acbe1b9 --- /dev/null +++ b/scripts/test-windows-product-shim.ps1 @@ -0,0 +1,146 @@ +# 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,[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 + # 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 + $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;fileIdentity='synthetic';acl='synthetic'} +} +'@ + [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 + $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') + & (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' + $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' + # 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') + 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 19 + } finally { + $env:ChocolateyInstall=$originalRoot + foreach ($name in @('productShimFile','productShimServiceCalls','productShimInstallerFails','productShimChanges','productShimProofCalls','productShimChangeOnRecheck')) { + Remove-Variable -Name $name -Scope Global -ErrorAction SilentlyContinue + } + } +} diff --git a/scripts/verify-windows-package.py b/scripts/verify-windows-package.py new file mode 100644 index 0000000..44915cd --- /dev/null +++ b/scripts/verify-windows-package.py @@ -0,0 +1,76 @@ +#!/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() + 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() + 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)))