diff --git a/.github/scripts/publish-tag-release.sh b/.github/scripts/publish-tag-release.sh index 97fa1a1..aca2160 100644 --- a/.github/scripts/publish-tag-release.sh +++ b/.github/scripts/publish-tag-release.sh @@ -13,42 +13,40 @@ gh api "repos/$GITHUB_REPOSITORY/actions/artifacts/$ARTIFACT_ID" | (.workflow_run.id | tostring) == $run and .workflow_run.head_sha == $source ' >/dev/null || fail 'artifact is not the exact immutable output of this source/run' directory="${RELEASE_DIRECTORY:?}" -archive="vx-bridge-windows-amd64-$RELEASE_VERSION-$SOURCE_REVISION.zip" -installer="vx-bridge-installer-windows-amd64-$RELEASE_VERSION-$SOURCE_REVISION.zip" -msi="vx-bridge-windows-amd64-$RELEASE_VERSION-$SOURCE_REVISION.msi" +installer="vx-bridge-installer-windows-amd64-$RELEASE_VERSION.exe" [[ -d "$directory" && ! -L "$directory" && - "$(find "$directory" -mindepth 1 -maxdepth 1 | wc -l)" -eq 6 ]] || - fail 'download must contain exactly app ZIP, installer ZIP, direct MSI and their checksums' -for file in "$archive" "$installer" "$msi"; do + "$(find "$directory" -mindepth 1 -maxdepth 1 | wc -l)" -eq 2 ]] || + fail 'download must contain exactly the installer EXE and its checksum' +for file in "$installer"; do [[ -f "$directory/$file" && ! -L "$directory/$file" && -f "$directory/$file.sha256" && ! -L "$directory/$file.sha256" ]] || fail 'unexpected asset/checksum file' line="$(cat "$directory/$file.sha256")" sum="${line%% *}" - [[ "$sum" =~ ^[0-9a-f]{64}$ && "$line" == "$sum $file" ]] || fail 'checksum must name only this exact asset' + [[ "$sum" =~ ^[0-9a-f]{64}$ && "$line" == "$sum $file" && + "$(wc -c < "$directory/$file.sha256")" -eq $((64 + 2 + ${#file} + 1)) ]] || + fail 'checksum must contain exactly one canonical line naming this asset' (cd "$directory" && sha256sum --strict --check "$file.sha256") done -REVISION="$SOURCE_REVISION" ARCHIVE="$directory/$archive" INSTALLER_ARCHIVE="$directory/$installer" make verify-installer -embedded="$(unzip -p "$directory/$installer" bridge.msi | sha256sum | cut -d' ' -f1)" -[[ "$embedded" == "$(sha256sum "$directory/$msi" | cut -d' ' -f1)" ]] || - fail 'direct MSI differs from the installer bundle matching Setup/MSI' +REVISION="$SOURCE_REVISION" INSTALLER_ARCHIVE="$directory/$installer" make verify-installer notes="${RUNNER_TEMP:?}/tag-release-notes.txt" cat > "$notes" < INPUT-SHA256SUMS ) diff --git a/.github/scripts/setup-build-only.ps1 b/.github/scripts/setup-build-only.ps1 index 75482f3..3424cb8 100644 --- a/.github/scripts/setup-build-only.ps1 +++ b/.github/scripts/setup-build-only.ps1 @@ -52,7 +52,7 @@ try { Assert-ExactBuildSource $identity=if ($env:RELEASE_VERSION -ceq 'development') { $env:REVISION } else { "$env:RELEASE_VERSION-$env:REVISION" } $archiveName = "vx-bridge-windows-amd64-$identity.zip" - $hashed = @('setup.exe','setup-actions.dll','native-inputs.txt','native-SHA256SUMS', + $hashed = @('setup.exe','setup-actions.dll','bundle-stub.exe','native-inputs.txt','native-SHA256SUMS', $archiveName,"$archiveName.sha256",'linux-payload.wxs','linux-setup-input.json', 'compiler.txt','packages.txt','mingw-copyright.txt') $allowed = $hashed + @('INPUT-SHA256SUMS','provenance.json') @@ -96,7 +96,7 @@ try { $sourceHashes[$relative]=$true } } - if ($sourceHashes.Count -ne 11) { throw 'Native source/resource inventory differs.' } + if ($sourceHashes.Count -ne 13) { throw 'Native source/resource inventory differs.' } $env:ARCHIVE=Join-Path $inputs $archiveName $env:NATIVE_DIR=$inputs $env:OUT=$staging @@ -140,6 +140,7 @@ try { $summary.msi_sha256=$msiHash $env:AUTHORED_SETUP=$output $env:MINGW_COPYRIGHT=Join-Path $inputs 'mingw-copyright.txt' + $env:BUNDLE_STUB=Join-Path $inputs 'bundle-stub.exe' $env:OUT=Join-Path $env:RUNNER_TEMP 'release-artifacts' Invoke-LoggedNativeTool -FilePath 'make' -LogPath $controlLog -Arguments @('-C',$source,'package-installer') | Out-Host $summary.success=$true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5da06fc..7cda2b0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -109,7 +109,7 @@ jobs: & 'C:\msys64\usr\bin\bash.exe' -lc 'pacman -S --needed --noconfirm make' if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } 'C:\msys64\usr\bin' | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append - - name: Author, inspect and package matching Setup/MSI without product execution + - name: Author, inspect and bundle matching Setup/MSI without product execution env: BUILD_INPUT_DIR: ${{ runner.temp }}/setup-build-input EVIDENCE_DIR: ${{ runner.temp }}/setup-build-evidence @@ -164,7 +164,7 @@ jobs: artifact-ids: ${{ needs.installer.outputs.artifact-id }} path: ${{ runner.temp }}/tag-release digest-mismatch: error - - name: Publish only this run's verified application and matching installer assets + - name: Publish only this run's verified installer EXE and checksum env: GH_TOKEN: ${{ github.token }} RELEASE_DIRECTORY: ${{ runner.temp }}/tag-release diff --git a/Makefile b/Makefile index c71fa09..6c3f8ce 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,7 @@ override GOTOOLCHAIN := local export GO GOFMT POWERSHELL CGO_ENABLED GOTOOLCHAIN REVISION RELEASE_VERSION OUT ARCHIVE NATIVE_DLL export NATIVE_DIR SETUP_STAGING WIX_ARCHIVE TOOL_LOG SETUP_BUILD_PROFILE export WORKFLOW_REVISION BUILD_INPUT_DIR EVIDENCE_DIR NATIVE_ARTIFACT_ID NATIVE_ARTIFACT_DIGEST -export AUTHORED_SETUP MINGW_COPYRIGHT INSTALLER_ARCHIVE LIFECYCLE_EVIDENCE +export AUTHORED_SETUP MINGW_COPYRIGHT BUNDLE_STUB INSTALLER_ARCHIVE LIFECYCLE_EVIDENCE .PHONY: help help-installer help-qualification build package verify-package install-setup-toolchain build-setup-input build-setup-artifacts package-installer verify-installer build-setup-native prepare-setup build-setup-msi build-lifecycle-input author-lifecycle test-installer-lifecycle restore-lifecycle-policy test-native-tools test test-race vet fmt fmt-check check ci ci-windows benchmark lint-workflows check-commands release-resolve release-verify release-publish @@ -46,11 +46,11 @@ help-installer: @printf '%s\n' \ 'Installer builds (static authoring/integrity only; no product execution):' \ ' make install-setup-toolchain Explicit pinned MinGW provisioning on the approved Linux builder' \ - ' make build-setup-native Pinned C Setup/actions cross-build; REVISION/OUT' \ + ' make build-setup-native Pinned C Setup/actions/bundle stub cross-build; REVISION/OUT' \ ' make prepare-setup Verify and stage application/native inputs; ARCHIVE/NATIVE_DIR/OUT' \ ' make build-setup-msi Windows WiX authoring, tables/CAB inspection; SETUP_STAGING/WIX_ARCHIVE/TOOL_LOG/OUT' \ - ' make package-installer Matching Setup/MSI bundle and original app ZIP; AUTHORED_SETUP/ARCHIVE/MINGW_COPYRIGHT/OUT' \ - ' make verify-installer Check existing bundle against app ZIP; INSTALLER_ARCHIVE/ARCHIVE' \ + ' make package-installer Self-contained EXE + checksum; AUTHORED_SETUP/ARCHIVE/MINGW_COPYRIGHT/BUNDLE_STUB/OUT' \ + ' make verify-installer Check EXE, embedded media and app ZIP; INSTALLER_ARCHIVE' \ ' make build-setup-input Shared exact-source Linux input job; source/run/version identity' \ ' make build-setup-artifacts Shared Windows author/inspect/package job; exact input artifact identity' \ ' make test-native-tools Fake-tool logging/exit regression; new external OUT' \ @@ -102,17 +102,17 @@ package-installer: : "$${AUTHORED_SETUP:?Set AUTHORED_SETUP to the inspected Windows authoring output}"; \ : "$${ARCHIVE:?Set ARCHIVE to the exact verified application ZIP}"; \ : "$${MINGW_COPYRIGHT:?Set MINGW_COPYRIGHT to the pinned compiler copyright file}"; \ + : "$${BUNDLE_STUB:?Set BUNDLE_STUB to the matching native bundle-stub.exe}"; \ : "$${OUT:?Set OUT to a new external release artifact directory}"; \ "$$GO" run -mod=readonly ./tools/msi/setup -revision "$${REVISION:-$$(git rev-parse HEAD)}" \ -version "$$RELEASE_VERSION" -archive "$$ARCHIVE" -installer "$$AUTHORED_SETUP" \ - -copyright "$$MINGW_COPYRIGHT" -out "$$OUT" + -copyright "$$MINGW_COPYRIGHT" -bundle-stub "$$BUNDLE_STUB" -out "$$OUT" verify-installer: @set -euo pipefail; \ - : "$${ARCHIVE:?Set ARCHIVE to the exact application ZIP}"; \ - : "$${INSTALLER_ARCHIVE:?Set INSTALLER_ARCHIVE to the matching installer ZIP}"; \ + : "$${INSTALLER_ARCHIVE:?Set INSTALLER_ARCHIVE to the self-contained installer EXE}"; \ "$$GO" run -mod=readonly ./tools/msi/setup -revision "$${REVISION:-$$(git rev-parse HEAD)}" \ - -version "$$RELEASE_VERSION" -archive "$$ARCHIVE" -verify-installer "$$INSTALLER_ARCHIVE" + -version "$$RELEASE_VERSION" -verify-installer "$$INSTALLER_ARCHIVE" build-setup-native: @REVISION="$${REVISION:-$$(git rev-parse HEAD)}" bash installer/build-setup-native.sh diff --git a/README.md b/README.md index 99e4d67..ef7b923 100644 --- a/README.md +++ b/README.md @@ -18,20 +18,19 @@ VX receiver. VX Bridge does not supply a game or replace an existing license. **[Open the latest release](https://github.com/devigned/vx-bridge/releases/latest)** for release notes and downloads. Under **Assets** (expand it if needed), download the file whose name starts with `vx-bridge-installer-windows-amd64-` and ends in -`.zip`. Do not choose the application-only ZIP, separate MSI, `.sha256` checksum -or **Source code** downloads. +`.exe`. The version is in the filename. The `.exe.sha256` file is a checksum; +**Source code** downloads are for developers, not installation. Older releases +without this EXE retain the files and instructions in their release notes. 1. In ProTee Labs, select **Game Options > Disconnect**. Close any existing receiver through its own controls. Do not run two receivers or replace the vendor's `GSPconnect.exe`. -2. Right-click the downloaded ZIP and select **Extract All**. Open the extracted - folder. Keep all extracted files together, including `setup.exe` and `bridge.msi`. - Do not run Setup from inside the ZIP or mix files from different builds. -3. Open `setup.exe` as the Windows user who will use the bridge, not as - administrator. Select **Install**. Do not double-click `bridge.msi`. -4. Leave **Auto-launch on Windows Startup** off unless the bridge should open +2. Open the downloaded installer EXE as the Windows user who will use the + bridge, not as administrator. Select **Install**. Everything Setup needs is + inside this one file; there is no ZIP to extract or separate MSI to download. +3. Leave **Auto-launch on Windows Startup** off unless the bridge should open when you sign in. It is off on a fresh install. -5. Wait for Setup to report success. At **Start the bridge now?**, select +4. Wait for Setup to report success. At **Start the bridge now?**, select **Start bridge** to open it once, or **Not now** to open **VX Bridge** from Start later. Starting it once does not enable auto-launch. diff --git a/contributors.md b/contributors.md index 9056c6c..da3faac 100644 --- a/contributors.md +++ b/contributors.md @@ -104,13 +104,17 @@ ARCHIVE="/absolute/path/to/artifacts/new-build/vx-bridge-windows-amd64-$revision This creates and inspects a Windows x64 application ZIP without running either executable. It is a development package, not an installer or a tested playing -release. It does not include the native Setup/MSI pair. The +release. It does not include the native Setup/MSI pair and is not uploaded separately +as a release asset. The [build and package reference](docs/technical/COMMANDS.md#build-and-verify-a-package) explains the two executable names, metadata and output rules. Installer builds use a separate native/Windows toolchain and explicit commands -in the [installer manual](docs/technical/INSTALLER.md). An MSI alone is not the -user install route: the current package requires its matching `setup.exe`. +in the [installer manual](docs/technical/INSTALLER.md). The public installer +is one self-contained EXE containing the matching native Setup/MSI pair, +support files and notices. Only that EXE and its checksum are uploaded for +future releases; GitHub also supplies source archives. An extracted MSI alone +is not the user install route. Don't copy a build over vendor files or onto a simulator just to see whether it works. Real-equipment trials require separate owner approval, a reviewed artifact diff --git a/docs/FAQ.md b/docs/FAQ.md index 2c5a4ba..cea620a 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -4,13 +4,14 @@ ## Where is the installer? -[Download the public v0.1.0 installer ZIP](https://github.com/devigned/vx-bridge/releases/download/v0.1.0/vx-bridge-installer-windows-amd64-v0.1.0-5401cf26d19238b04ba3dbb8ecfa60442616ca79.zip). -Select **Extract All**, keep all nine files together, and open `setup.exe` from -the extracted folder with matching `bridge.msi` beside it. Do not install the -bare MSI. - -The application ZIP, source archives and **BUILD ONLY** artifacts are not the -installer. See [file types](INSTALL.md#choose-the-right-files) and +[Open the latest release](https://github.com/devigned/vx-bridge/releases/latest). +Download `vx-bridge-installer-windows-amd64-vX.Y.Z.exe` and open it normally, +not as administrator. It contains Setup, the matching MSI and support files. +The `.exe.sha256` file is a checksum, not a second installer. + +Older releases, including [v0.1.0](releases/v0.1.0.md), retain their original +ZIP downloads and extraction instructions. Source archives and **BUILD ONLY** +artifacts are not the installer. See [file types](INSTALL.md#choose-the-right-files) and [tested scope](#which-versions-and-features-have-been-checked). ## Windows blocks Setup @@ -133,7 +134,8 @@ Don't launch an extra `GSPconnect.exe` to perform a control command. Ordinary startup does not run Windows Installer or attempt automatic repair. Open **VX Bridge Setup** from Start and choose **Repair / Reinstall**. The bridge need not work first. If Setup cannot find its saved source, use the -original verified `setup.exe` with its matching `bridge.msi`. +original verified installer EXE for that build. Older ZIP releases need their +original matching extracted Setup/MSI pair. **Repair does not always fix a failed upgrade.** It can restore files while leaving more than one registered version, so Setup must still refuse to start @@ -148,8 +150,10 @@ install fresh. Removing only one version is not completed recovery. Normal removal uses **Windows Settings > Apps > Installed apps > VX Bridge > Uninstall**. A damaged Windows Installer cache may require each recorded -version's verified original MSI with `REMOVE=ALL`. That explicit recovery route -is not a raw-MSI install instruction. Don't guess identities, delete installer +version's verified original MSI with `REMOVE=ALL`. A maintainer can use the +original EXE's `/extract` option to save that media without installing it. +That explicit recovery route is not a raw-MSI install instruction. +Don't guess identities, delete installer records or manually purge folders to make the remaining copy appear healthy. Remaining registrations or bridge-owned files are not successful recovery, even if one command reported success. diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 17a83a2..77c37c4 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -3,7 +3,7 @@ [User guides](README.md) / Installation VX Bridge connects ProTee Labs to your choice of Infinite Tees or GSPro OpenAPI. -This guide covers the public [v0.1.0 release](releases/v0.1.0.md). +This guide covers the self-contained Windows installer. Read [Limits](#limits) before installing. ## Before you start @@ -18,29 +18,25 @@ The bridge needs no separate .NET or browser runtime. ## Choose the right files -[Download the v0.1.0 installer ZIP](https://github.com/devigned/vx-bridge/releases/download/v0.1.0/vx-bridge-installer-windows-amd64-v0.1.0-5401cf26d19238b04ba3dbb8ecfa60442616ca79.zip). -The filename is -`vx-bridge-installer-windows-amd64-v0.1.0-5401cf26d19238b04ba3dbb8ecfa60442616ca79.zip`. -Use this ZIP, not the application ZIP or MSI alone. The -[release page](https://github.com/devigned/vx-bridge/releases/tag/v0.1.0) -contains these file types. In the patterns below, `vX.Y.Z` is the version and -`<40sha>` identifies the source commit. +[Open the latest release](https://github.com/devigned/vx-bridge/releases/latest). +Under **Assets**, download `vx-bridge-installer-windows-amd64-vX.Y.Z.exe`, +where `vX.Y.Z` is the release's version. Open that one file. No ZIP extraction, +adjacent MSI or separate bridge download is needed. | Files | What they are for | |---|---| -| `vx-bridge-installer-windows-amd64-vX.Y.Z-<40sha>.zip` | Normal installation. Extract it and run `setup.exe` with matching `bridge.msi` beside it. | -| `vx-bridge-windows-amd64-vX.Y.Z-<40sha>.zip` | Application-only package for separately approved manual/development use. Its six files do not include Setup/MSI. | -| `vx-bridge-windows-amd64-vX.Y.Z-<40sha>.msi` | Windows Installer package, **not a standalone installer**. It still requires matching Setup; choose the installer ZIP instead. | +| `vx-bridge-installer-windows-amd64-vX.Y.Z.exe` | Normal installation. Contains the matching Setup, MSI and support files. | +| `vx-bridge-installer-windows-amd64-vX.Y.Z.exe.sha256` | Optional integrity check for that EXE, not an installer. | | Source checkout or source archive | Contributing to the project, not a ready-to-run Windows app. | -Each ZIP/MSI has a separate checksum file named `.sha256`. +These are the only two uploaded assets. GitHub also supplies source ZIP/tar.gz +archives. A checksum checks file contents, not who published them. +Do not use an Actions artifact marked **BUILD ONLY** as an approved installer. -The installer ZIP contains nine files in one folder. Keep them together. -`setup.exe` and `bridge.msi` must be a matching pair. Do not mix builds or use an -Actions artifact marked **BUILD ONLY** as an approved installer. - -The separate `.sha256` files are optional integrity checks, not installers. -A checksum checks file contents, not who published them. +The existing [v0.1.0 release](releases/v0.1.0.md) predates this format. Its +installer ZIP, separate MSI and application ZIP remain unchanged; use that +release's recorded extraction steps if it is still the latest release. This +change does not replace historical downloads or qualify new binaries. Untagged developer application ZIPs use `vx-bridge-windows-amd64-.zip`. See @@ -52,16 +48,13 @@ not these setup steps, for development packages. 1. In ProTee Labs, select **Game Options > Disconnect**. Close any existing receiver through its own controls. For VX Bridge, select **Quit** after Labs disconnects. Do not run two receivers or overwrite the vendor's `GSPconnect.exe`. -2. Right-click the installer ZIP and select **Extract All**. Open the extracted - folder. Do not run Setup from inside the ZIP. -3. Open `setup.exe` as the Windows user who will use the bridge, not as - administrator. Select **Install** for a fresh install. Do not double-click - `bridge.msi`. -4. Leave **Auto-launch on Windows Startup** off unless the bridge should open +2. Open the downloaded installer EXE as the Windows user who will use the + bridge, not as administrator. Select **Install** for a fresh install. +3. Leave **Auto-launch on Windows Startup** off unless the bridge should open when you sign in. Its helper says **Starts when you sign in to Windows.** It is off on a fresh install; maintenance preserves a verified preference unless you change it. -5. Wait for Setup to report success. At **Start the bridge now?**, select +4. Wait for Setup to report success. At **Start the bridge now?**, select **Start bridge** to open it once, or **Not now** to open **VX Bridge** from the Windows Start menu later. @@ -100,10 +93,12 @@ See [everyday use](USAGE.md#choose-a-destination). ## Limits -v0.1.0 passed automated build and static file checks. Interactive installation, -Windows sign-in, installer servicing and live Labs/game use, including GSPro -and game switching, remain unqualified for these binaries. Earlier installer -and equipment observations used different builds; see +v0.1.0 passed automated build and static file checks. The self-contained +installer is a later source change, not a replacement or new qualification of +that release. Interactive installation, Windows sign-in, installer servicing +and live Labs/game use, including GSPro and game switching, remain unqualified +for rebuilt binaries. Earlier installer and equipment observations used +different builds; see [qualification](technical/QUALIFICATION.md). The Windows programs and installer are not publisher-signed (Authenticode). @@ -116,8 +111,11 @@ Opening VX Bridge from its executable, Start shortcut or Windows sign-in does For a damaged installation or **Repair required** message, open **VX Bridge Setup** from Start and choose **Repair / Reinstall**. The bridge does not need to -work first. If Setup's saved installation source is missing, use the original -verified `setup.exe` and its matching `bridge.msi`. Windows' raw Modify/Repair +work first. If Setup's saved installation source is missing, open the original +verified installer EXE for that installed build. Keep it for exceptional recovery; +normal repair uses the saved Setup/MSI and does not need the download. +For older ZIP releases, retain the original matching extracted pair. +Windows' raw Modify/Repair buttons are disabled; use Setup rather than trying to bypass its checks. Before maintenance, disconnect in Labs normally and Quit. If Setup says @@ -139,8 +137,10 @@ recovery or permission to start the remaining bridge. Normal uninstall uses Windows' registered package. If that cache is damaged, recovery may need the verified original MSI for each recorded version with -`REMOVE=ALL`; this is an explicit uninstall route, not permission to install a -raw MSI. Don't guess product identities, delete Windows Installer records or +`REMOVE=ALL`. A maintainer can extract matching media from the original EXE +without installing it; see the [Setup command reference](technical/COMMANDS.md#setup1). +This is an explicit uninstall route, not permission to install a raw MSI. +Don't guess product identities, delete Windows Installer records or manually purge settings to make a failed operation look successful. See the [recovery FAQ](FAQ.md#vx-bridge-reports-repair-required) and [installer manual](technical/INSTALLER.md) for the supported boundaries. diff --git a/docs/README.md b/docs/README.md index bf6aea3..0d53b46 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,7 +6,7 @@ The bridge remembers the choice; only one game receives shots. | Task | Guide | |---|---| -| Choose the right files, install and connect for the first time | [Installation](INSTALL.md) | +| Download the installer EXE, install and connect for the first time | [Installation](INSTALL.md) | | Choose a game, check status, find logs or quit safely | [Everyday use](USAGE.md) | | Fix a connection problem, missed shot or failed installation | [FAQ](FAQ.md) | | Read initial-release features, files and contributor credits | [v0.1.0 release notes](releases/v0.1.0.md) | @@ -16,6 +16,8 @@ The bridge remembers the choice; only one game receives shots. It passed automated build and file checks, not interactive installation, Windows sign-in or live Labs/game qualification. See [qualification](technical/QUALIFICATION.md) for the tested scope. +Its downloads remain in the original ZIP/MSI format. The self-contained EXE +format applies to future releases; older release notes retain their own steps. The [technical manual](technical/README.md) covers implementation, builds and testing. The [project README](../README.md) gives a shorter installation procedure. diff --git a/docs/technical/AUTOMATION.md b/docs/technical/AUTOMATION.md index a0b9ae5..c6bfb32 100644 --- a/docs/technical/AUTOMATION.md +++ b/docs/technical/AUTOMATION.md @@ -20,8 +20,8 @@ Portable tests, Linux race instrumentation, and native Windows fixtures are distinct checks. PR CI tests the merge checkout, not an asserted reviewed head. Ordinary artifacts use development identity; CI is not release promotion and does not run the product EXEs or operate a simulator. -Its build-only jobs produce the application archive, matching native Setup -inputs, and statically verified installer artifacts through shared commands. +Its build-only jobs produce the private application archive, matching native +Setup/bundle inputs, and statically verified installer artifacts through shared commands. The [version-tag workflow](../../.github/workflows/release.yml) builds an exact canonical tagged commit already in main, then publishes only that run's @@ -91,7 +91,8 @@ equivalent Repair media. See ## Installer artifacts -The installer ZIP contains nine flat entries: +The public installer is a native self-contained EXE. Its bounded flat overlay +contains ten entries in a fixed order: ```text setup.exe @@ -103,29 +104,49 @@ MINGW-COPYRIGHT.txt LICENSE THIRD_PARTY_NOTICES.txt SHA256SUMS +application.zip ``` `setup.exe` and `bridge.msi` must be the matching authored pair. Metadata, inspection, compiler copyright, and license notices are retained -with the actual bytes. The direct named MSI is identical to `bridge.msi` -inside the bundle; it is not a separate standalone-install interface. -Use verified Setup for installation/Repair and conventional registered-product -Uninstall as described in [Installation](INSTALLER.md). +with the actual bytes. `application.zip` is the authoritative original +six-file application archive, included internally for independent verification, +not a separate user download. `SHA256SUMS` covers the preceding eight installer +files; the overlay footer binds the native stub, exact source revision and all +ten entries by length and full SHA-256. No archive paths are interpreted. + +The outer stub is built before MSI authoring, separately from inner Setup. +The MSI still binds the unchanged inner Setup hash. This avoids a circular +EXE/MSI hash dependency without weakening owner, receipt or PackageCode checks. +Static verification checks the native stub/source, all overlay bounds/hashes, +authored MSI metadata and the embedded application through the authoritative +distribution verifier. Publication needs no separately downloaded app ZIP. + +Use the public EXE for installation/Repair and conventional registered-product +Uninstall as described in [Installation](INSTALLER.md#self-contained-media). For a canonical versioned build, filenames use the public repository name (persisted Windows compatibility identities are unchanged): ```text -vx-bridge-windows-amd64-vX.Y.Z-<40sha>.zip -vx-bridge-installer-windows-amd64-vX.Y.Z-<40sha>.zip -vx-bridge-windows-amd64-vX.Y.Z-<40sha>.msi +vx-bridge-installer-windows-amd64-vX.Y.Z.exe +vx-bridge-installer-windows-amd64-vX.Y.Z.exe.sha256 ``` -Each file has an appended `.sha256` companion. The public -[v0.1.0 release](../releases/v0.1.0.md) follows these naming rules and contains -the application ZIP, installer ZIP, matching MSI and three checksums. -`package-installer` and `verify-installer` own bundle assembly/integrity; -native compilation and WiX authoring remain separate stages. +These are **exactly the two uploaded release assets**. The checksum contains +one lowercase SHA-256, two spaces, that exact EXE basename and a newline. +GitHub's automatic Source code ZIP/tar.gz remain available. No standalone MSI, +application ZIP, installer ZIP, notice/metadata file or former checksum is +uploaded. Source SHA identity remains inside verified metadata and artifact +provenance, not in public filenames. Development EXEs use the exact source SHA +in place of a version; they are not published by the tag workflow. + +Private application build inputs retain their version-plus-source names. +`package-installer` emits only the EXE/checksum; `verify-installer` owns +self-contained integrity checks. Native compilation and WiX authoring remain +separate stages. The public [v0.1.0 release](../releases/v0.1.0.md) predates +this format and retains its application ZIP, installer ZIP, MSI and three +checksums unchanged. ## Artifact transport @@ -175,6 +196,10 @@ publication job rechecks tag/source identity and main ancestry, downloads only that run's exact artifact, and validates its contents and hashes before creating a draft with verified-tag/exact-target semantics. Existing releases/assets are not reused or overwritten. +The download directory must contain exactly the expected version-named EXE +and checksum, both regular nonsymlink files. Any additional asset, SHA-bearing +filename, wrong checksum basename or embedded source/version mismatch refuses +before release creation. After upload and a further identity check, publication keeps `prerelease=false` and `latest=false`. A late failure can leave a draft; diff --git a/docs/technical/COMMANDS.md b/docs/technical/COMMANDS.md index 98c58a6..a261318 100644 --- a/docs/technical/COMMANDS.md +++ b/docs/technical/COMMANDS.md @@ -219,7 +219,8 @@ These commands produce native inputs and static authoring evidence only. They do not install a product, load a callback DLL, start the bridge, or qualify a user machine. -**`make build-setup-native`** cross-builds native Setup/actions on the selected +**`make build-setup-native`** cross-builds native Setup/actions and the separate +outer `bundle-stub.exe` on the selected Linux-amd64 toolchain. It requires clean exact `REVISION`, a new external `OUT`, and the [pinned native prerequisites](INSTALLER.md#build-tooling-and-static-verification). @@ -249,17 +250,31 @@ native DLL. Old private/outer/wixl helpers are [investigation history](research/README.md#installer-and-automation-records), not alternate production build recipes. -**`make package-installer`** assembles verified authored Setup output with its -matching application archive. It requires `AUTHORED_SETUP`, `ARCHIVE`, -`MINGW_COPYRIGHT`, and new external `OUT`. The pinned compiler copyright -record is required package provenance, not optional decoration. - -**`make verify-installer`** checks an existing installer bundle against -`ARCHIVE`, `INSTALLER_ARCHIVE`, exact `REVISION`, and `RELEASE_VERSION`. -It is static integrity verification, not installation or a qualification +**`make package-installer`** embeds verified authored Setup output and its +matching original application archive in a native self-contained EXE. It +requires `AUTHORED_SETUP`, `ARCHIVE`, `MINGW_COPYRIGHT`, `BUNDLE_STUB`, and new +external `OUT`. The stub must come from the matching native build; the pinned +compiler copyright record is required package provenance, not optional +decoration. Output contains exactly the installer EXE and `.exe.sha256`, +not a separate MSI, ZIP or notices asset. + +**`make verify-installer`** checks the existing EXE named by +`INSTALLER_ARCHIVE`, exact `REVISION`, and `RELEASE_VERSION`. It verifies +the embedded original app ZIP as well as native/installer metadata and +hashes; no separate `ARCHIVE` input is needed. It is static integrity +verification, not installation or a qualification verdict. Bundle entries and release filenames are described in [Automation](AUTOMATION.md#installer-artifacts). +For an existing tagged installer, replace the example version/source with +its actual recorded identity: + +```sh +REVISION='' RELEASE_VERSION=vX.Y.Z \ + INSTALLER_ARCHIVE='/external/vx-bridge-installer-windows-amd64-vX.Y.Z.exe' \ + make verify-installer +``` + **`make install-setup-toolchain`** explicitly provisions the pinned compiler on the configured Linux builder. Unlike static verification, this changes the build environment. It is not a default check or instruction to provision @@ -284,16 +299,42 @@ setup - install or maintain a verified per-user VX Bridge package ### SYNOPSIS ```text +vx-bridge-installer-windows-amd64-vX.Y.Z.exe [/quiet] [/maintain] +vx-bridge-installer-windows-amd64-vX.Y.Z.exe [/quiet] /extract NEW_ABSOLUTE_DIRECTORY +vx-bridge-installer-windows-amd64-vX.Y.Z.exe [/quiet] /remove setup.exe [/quiet] [/maintain] [/package PATH] setup.exe [/quiet] /remove ``` ### DESCRIPTION -Native Setup is explicit maintenance, not an ordinary-start broker. -Run it normally as the installing user. With no explicit package, it uses +The public EXE contains the matching native Setup/MSI pair and support files. +Open it normally as the installing user. It verifies and privately stages +its media, runs inner Setup with that explicit MSI, waits through completion +or cancellation, then removes only its owned staging files. It does not +download dependencies, require administrator access or replace MSI servicing. + +`/extract` saves verified original media to a new absolute local folder +whose parent exists. It does not execute Setup/MSI or modify the managed +installation. Destinations in the managed application or state roots, +existing directories, reparse paths and noncanonical paths +refuse. This is for notice inspection or separately approved recovery, not +the normal install route. For example, from PowerShell with a real version +and a new folder under an existing parent: + +```powershell +.\vx-bridge-installer-windows-amd64-vX.Y.Z.exe /extract 'C:\Users\YOUR_USER\Downloads\vx-bridge-original-media' +``` + +Keep all extracted files together. Do not mix builds or install a raw MSI. +The embedded `application.zip` is verification evidence, not another public +download or migration route. + +The inner/cached `setup.exe` remains explicit maintenance, not an ordinary-start +broker. With no explicit package, it uses the verified recorded/cached source or adjacent `bridge.msi`. `/package` -selects source media for verification, not an ownership bypass. +selects source media for verification, not an ownership bypass, and is an +inner-Setup option rather than an outer-bundle option. `/maintain` is the cached maintenance entry, not a different transaction policy. `/quiet` suppresses choices and never launches the bridge. @@ -303,10 +344,13 @@ Normal Uninstall is Windows Installer-owned. ### EXIT STATUS -**0** means verified usable installation/maintenance completion. +**0** means verified usable installation/maintenance completion, or verified +media extraction when `/extract` was requested. **1** means refusal or incomplete maintenance. **2** means invalid arguments or canceled initial choice. **120** (`ERROR_CALL_NOT_IMPLEMENTED`) is the directions-only `/remove` result. +The outer launcher propagates inner Setup's result and reports its own +verification, process-observation or staging-cleanup failures as nonsuccess. MSI engine status is recorded separately; engine zero alone is not usable completion. See [Installation](INSTALLER.md#recovery-after-failure). diff --git a/docs/technical/INSTALLER.md b/docs/technical/INSTALLER.md index 2a3ffd6..03e22be 100644 --- a/docs/technical/INSTALLER.md +++ b/docs/technical/INSTALLER.md @@ -18,6 +18,52 @@ Interactive/client-PC operation, actual sign-in, Apps UI, Start bridge and attached-Labs maintenance remain unqualified. See [Qualification](QUALIFICATION.md#installer-observations) for the recorded scope. +## Self-contained media + +Future releases upload one version-named setup EXE and its `.exe.sha256`; +GitHub supplies source archives separately. The existing v0.1.0 downloads +remain in their original ZIP/MSI format. + +A thin native outer EXE carries the unchanged verified `setup.exe`/`bridge.msi` +pair, authoring metadata, notices, checksums and the original application +archive. Inner Setup remains hash-bound in the MSI. The outer EXE neither +implements a second MSI engine nor changes the persisted compatibility +identities. + +The outer launcher refuses elevation. It validates its exact-source overlay +and all full-byte hashes before creating a fresh GUID-named staging directory +under the current user's canonical LocalAppData. Ancestors are pinned; +directories/files reject reparse points, files reject hardlinks, and new +files use exclusive creation with an installing-user/System-only ACL. +Reopened file identity and SHA-256 must agree, and read pins deny writes and +deletion through the child process's entire lifetime. MSI receipt/source +and inner Setup-owner hash are checked before dispatch. + +Each invocation has its own staging directory. Inner Setup's existing owner +mutex and admission rules still serialize actual servicing. The outer launcher +passes the embedded MSI explicitly, not a possibly older cached selection. +Cancellation and guarded Quit remain inner Setup's responsibility. The +launcher waits for the child, never terminates it, and returns its result. +Verified cleanup removes only the objects created by that invocation, checking +file identity again under pinned ancestors. Unexpected contents or cleanup +failure reports nonsuccess; it does not claim rollback. An unknown child +lifetime preserves its media. A forcibly terminated outer process can leave +an orphan staging folder; no later invocation scans or recursively purges it. + +Successful installation caches the inner Setup and exact MSI in the existing +passive maintenance root. Repair/Uninstall use those owned records, not the +download location or another automatic extraction. The outer download is +neither adopted into the cache nor removed by Uninstall. Ordinary application +startup is unchanged and never invokes the bundle or MSI. + +For inspection or exceptional original-media recovery, `/extract` writes +the verified internal files to an explicitly selected new absolute local +directory without running Setup. It never overwrites an existing folder. +See [Setup commands](COMMANDS.md#setup1). Keep that original media together; +an MSI extracted this way is not a standalone-install interface. Installer +and compiler notices remain available inside the EXE and through extraction; +the application license/notices also remain in the six-file installed payload. + ## Managed-copy admission The application root is fixed: @@ -197,7 +243,8 @@ binutils, and official WiX 3.14.1 under MS-RL. WiX's public community security support ended on 2025-02-06; this is not a maintained-toolchain or private extended-support claim. -Native Setup and actions are C code using the actual MSI handle. WiX +Native outer bundle, Setup and actions are C code; Setup/actions use the actual +MSI handle. WiX candle/light need a compatible .NET Framework on the **build machine**; the build checks for it and does not install a runtime. The emitted native code imports Windows system DLLs, not a separately shipped CLR or compiler runtime. @@ -206,6 +253,8 @@ The build consumes the authoritative six-entry Go ZIP rather than relinking the application or harvesting arbitrary files. Prepared metadata has no guessed PackageCode: WiX generates it, and actual MSI Summary Information property 9 is bound to the delivered MSI hash in final metadata. +The bundle adds no hash cycle: the native outer stub is built separately, +then combined with the already authored, hash-bound inner Setup/MSI pair. An independently rebuilt same-source/same-version MSI can have a different PackageCode and is not equivalent Repair media. Its full-media hash is also not a hash of Windows Installer's cached `LocalPackage`. diff --git a/docs/technical/QUALIFICATION.md b/docs/technical/QUALIFICATION.md index b9a22eb..4e3e0b4 100644 --- a/docs/technical/QUALIFICATION.md +++ b/docs/technical/QUALIFICATION.md @@ -42,6 +42,11 @@ servicing or live Labs/game compatibility. The Windows programs and installer are not Authenticode-signed. Signed source commits and tags do not sign those files. +The later self-contained EXE pipeline is a source change, not a replacement +of these historical assets. Its build/static tests do not establish Windows +extraction, process lifetime, cancellation, cleanup or MSI servicing behavior. +No new installer lifecycle or equipment trial is authorized by this change. + ## Installer observations A prior private candidate passed ordinary CI and a separately authorized diff --git a/docs/technical/README.md b/docs/technical/README.md index e0d4897..cd2625d 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -28,7 +28,8 @@ departure, rather than a Quit request, controls teardown. [Desktop integration](DESKTOP.md) covers the tray, local control, and sign-in registration. [Installation and recovery](INSTALLER.md) describes managed-copy -admission, conventional MSI servicing, and the full-Uninstall contract. +admission, the self-contained native EXE, conventional MSI servicing, and the +full-Uninstall contract. [Security and data ownership](SECURITY.md) defines local storage, trust, and license boundaries. diff --git a/docs/technical/TESTING.md b/docs/technical/TESTING.md index 507f696..f0b5a9c 100644 --- a/docs/technical/TESTING.md +++ b/docs/technical/TESTING.md @@ -88,9 +88,18 @@ repositories, and stubbed GitHub calls. They must preserve argv quoting, environment selection, exact source identity, and failures without real publication. A local tag fixture does not prove the hosted release write path. -Installer bundle tests check the nine-entry allowlist, companion Setup/MSI -identity, final metadata, compiler/license records, per-entry hashes, and -agreement with the direct named MSI. Those static postconditions remain +Installer bundle tests check the fixed ten-entry overlay, unchanged inner +Setup/MSI identity, final metadata, compiler/license records, per-entry and +stub hashes, exact source/version, malformed sizes and trailing data. A +portable C fixture exercises the native layout parser against emitted bytes +when a native C compiler is available. Packaging/publication fixtures require +exactly the version-named EXE and `.exe.sha256`, verify checksum contents and +reject extra MSI/ZIP/notices assets, SHA-bearing names, wrong basenames and +symlinks. Native cross-builds check actual PE imports and source identity. + +These checks do not execute the self-contained EXE or qualify its Windows +extraction, child lifetime, cleanup, cancellation or servicing behavior. +Those static postconditions remain separate from `test-installer-lifecycle`, which executes full normal servicing, actual-mutation failure, supported recovery, and final owned cleanup under the guarded disposable-account harness. diff --git a/installer/NOTICE.txt b/installer/NOTICE.txt index b59e499..027fead 100644 --- a/installer/NOTICE.txt +++ b/installer/NOTICE.txt @@ -13,8 +13,8 @@ The reviewed cross compiler is GCC/MinGW-w64 from Ubuntu's pinned packages. Compiler tools are not redistributed. libgcc runtime portions, if included, are covered by GCC's GPLv3 plus GCC Runtime Library Exception 3.1: https://www.gnu.org/licenses/gcc-exception-3.1.html -Setup and its callback DLL have no libgcc/libwinpthread runtime DLL dependency; -their imported DLLs are Windows system DLLs. The installer ZIP includes the +Setup, its outer bundle and callback DLL have no libgcc/libwinpthread runtime DLL +dependency; their imported DLLs are Windows system DLLs. The installer EXE includes the actual pinned GCC and MinGW header package copyright records in MINGW-COPYRIGHT.txt. Compiler versions and source hashes are retained as build evidence, not installed application dependencies. diff --git a/installer/build-setup-native.sh b/installer/build-setup-native.sh index 74458ce..71a89b7 100644 --- a/installer/build-setup-native.sh +++ b/installer/build-setup-native.sh @@ -26,6 +26,9 @@ libraries=(-lmsi -ladvapi32 -lole32 -lshell32 -luuid -lbcrypt -lcomctl32 -luser3 x86_64-w64-mingw32-windres -I "$root/installer/setup" "$root/installer/setup/setup.rc" "$OUT/setup-resource.o" "$cc" "${flags[@]}" -municode -mwindows -o "$OUT/setup.exe" "$root/installer/setup/launcher.c" \ "${sources[@]}" "$OUT/setup-resource.o" "${libraries[@]}" +"$cc" "${flags[@]}" "-DVX_SOURCE_REVISION=\"$REVISION\"" -ffunction-sections -fdata-sections \ + -Wl,--gc-sections -municode -mwindows -o "$OUT/bundle-stub.exe" "$root/installer/setup/bundle.c" \ + "${sources[@]}" "$OUT/setup-resource.o" "${libraries[@]}" "$cc" "${flags[@]}" -municode -o "$OUT/private-root-tests.exe" "$root/installer/setup/test_root.c" \ "${sources[@]}" "${libraries[@]}" "$cc" "${flags[@]}" -municode -o "$OUT/private-remove-tests.exe" "$root/installer/setup/test_remove.c" \ @@ -34,4 +37,4 @@ printf 'source_revision=%s\n' "$REVISION" > "$OUT/native-inputs.txt" sha256sum "$root"/installer/setup/*.[ch] "$root/installer/setup/setup.rc" "$root/installer/setup/setup.manifest" \ "$root/installer/managed/state.c" "$root/installer/managed/state.h" >> "$OUT/native-inputs.txt" "$cc" --version >> "$OUT/native-inputs.txt" -(cd "$OUT" && sha256sum setup.exe setup-actions.dll private-root-tests.exe private-remove-tests.exe > native-SHA256SUMS) +(cd "$OUT" && sha256sum setup.exe setup-actions.dll bundle-stub.exe private-root-tests.exe private-remove-tests.exe > native-SHA256SUMS) diff --git a/installer/setup/bundle.c b/installer/setup/bundle.c new file mode 100644 index 0000000..4c9a4d6 --- /dev/null +++ b/installer/setup/bundle.c @@ -0,0 +1,228 @@ +/* SPDX-License-Identifier: MIT */ +#include "protocol.h" +#include "bundle.h" +#include +#include +#include +#include +#include + +static const char source_identity[]="VX_BUNDLE_SOURCE=" VX_SOURCE_REVISION; +static const WCHAR *names[VX_BUNDLE_COUNT]={ + L"setup.exe",L"bridge.msi",L"setup-input.json",L"msi-inspection.json", + L"INSTALLER-NOTICE.txt",L"MINGW-COPYRIGHT.txt",L"LICENSE", + L"THIRD_PARTY_NOTICES.txt",L"SHA256SUMS",L"application.zip" +}; +typedef struct { + HANDLE pin; + BY_HANDLE_FILE_INFORMATION identity; + BOOL created,identified; +} STAGED_FILE; + +static BOOL hash_bytes(const BYTE *data,DWORD size,const BYTE wanted[32]) +{ + BCRYPT_ALG_HANDLE alg=NULL; BCRYPT_HASH_HANDLE hash=NULL; + BYTE actual[32]; BOOL ok=FALSE; + if(BCryptOpenAlgorithmProvider(&alg,BCRYPT_SHA256_ALGORITHM,NULL,0)<0 || + BCryptCreateHash(alg,&hash,NULL,0,NULL,0,0)<0 || + BCryptHashData(hash,(BYTE *)data,size,0)<0 || + BCryptFinishHash(hash,actual,sizeof(actual),0)<0) goto done; + ok=!memcmp(actual,wanted,sizeof(actual)); +done: + if(hash && BCryptDestroyHash(hash)<0) ok=FALSE; + if(alg && BCryptCloseAlgorithmProvider(alg,0)<0) ok=FALSE; + return ok; +} +static BOOL same_file(const BY_HANDLE_FILE_INFORMATION *a,const BY_HANDLE_FILE_INFORMATION *b) +{ + return a->dwVolumeSerialNumber==b->dwVolumeSerialNumber && + a->nFileIndexHigh==b->nFileIndexHigh && a->nFileIndexLow==b->nFileIndexLow; +} +static BOOL within_directory(const WCHAR *path,const WCHAR *directory) +{ + size_t n=wcslen(directory); + return !_wcsnicmp(path,directory,n) && (!path[n] || path[n]==L'\\'); +} +static BOOL remove_owned(const WCHAR *path,BOOL directory,const BY_HANDLE_FILE_INFORMATION *identity) +{ + HANDLE file=vx_file(path,directory,DELETE|FILE_READ_ATTRIBUTES,0,OPEN_EXISTING); + if(file==INVALID_HANDLE_VALUE) return FALSE; + BY_HANDLE_FILE_INFORMATION actual; FILE_DISPOSITION_INFO disposition={TRUE}; + BOOL ok=GetFileInformationByHandle(file,&actual) && same_file(&actual,identity) && + SetFileInformationByHandle(file,FileDispositionInfo,&disposition,sizeof(disposition)); + if(!CloseHandle(file)) ok=FALSE; + return ok; +} +static BOOL check_media(const WCHAR *directory,const char *revision) +{ + WCHAR path[MAX_PATH],owner[65]; VX_RECEIPT receipt; MSIHANDLE db=0; + char expected[65],actual[65]; BOOL ok=FALSE; + if(!vx_path(path,directory,L"bridge.msi") || + MsiOpenDatabaseW(path,MSIDBOPEN_READONLY,&db)!=ERROR_SUCCESS || + !setup_receipt(db,&receipt,owner) || strcmp(receipt.revision,revision) || + !vx_ascii(expected,65,owner) || !vx_path(path,directory,L"setup.exe")) goto done; + HANDLE file=vx_file(path,FALSE,GENERIC_READ,FILE_SHARE_READ,OPEN_EXISTING); + if(file==INVALID_HANDLE_VALUE) goto done; + ok=vx_hash(file,actual) && !strcmp(actual,expected); + if(!CloseHandle(file)) ok=FALSE; +done: + if(db && MsiCloseHandle(db)!=ERROR_SUCCESS) ok=FALSE; + return ok; +} +static DWORD run_bundle(BOOL quiet,BOOL maintain,const WCHAR *extract) +{ + VX_CONTEXT context={0}; HANDLE self=INVALID_HANDLE_VALUE,mapping=NULL,directory=INVALID_HANDLE_VALUE; + const BYTE *bytes=NULL; WCHAR image[MAX_PATH],stage[MAX_PATH]={0},path[MAX_PATH]; + STAGED_FILE files[VX_BUNDLE_COUNT]={0}; BY_HANDLE_FILE_INFORMATION stage_identity; + BOOL created=FALSE,identified=FALSE,keep=FALSE,cleanup=TRUE,child_reported=FALSE; + DWORD result=1; const WCHAR *failure=L"Installer verification or staging failed. No installation was started."; + if(!vx_context(&context)) goto done; + DWORD length=GetModuleFileNameW(NULL,image,ARRAYSIZE(image)); + if(!length || length>=ARRAYSIZE(image)) goto done; + self=vx_file(image,FALSE,GENERIC_READ,FILE_SHARE_READ,OPEN_EXISTING); + LARGE_INTEGER size; + if(self==INVALID_HANDLE_VALUE || !GetFileSizeEx(self,&size) || + size.QuadPart<(LONGLONG)sizeof(VX_BUNDLE) || size.QuadPart>VX_BUNDLE_LIMIT) goto done; + mapping=CreateFileMappingW(self,NULL,PAGE_READONLY,0,0,NULL); + if(!mapping || !(bytes=MapViewOfFile(mapping,FILE_MAP_READ,0,0,0))) goto done; + VX_BUNDLE bundle; + memcpy(&bundle,bytes+size.QuadPart-sizeof(bundle),sizeof(bundle)); + if(!bundle_layout(&bundle,(uint64_t)size.QuadPart,source_identity+17) || + !hash_bytes(bytes,(DWORD)bundle.stub_size,bundle.stub_hash)) goto done; + uint64_t offset=bundle.stub_size; + for(unsigned int i=0;i=MAX_PATH || !GetFullPathNameW(extract,MAX_PATH,stage,NULL) || + _wcsicmp(extract,stage) || !vx_path(owned,context.base,L"VxInfiniteTeesBridge") || + within_directory(stage,context.root) || within_directory(stage,owned)) goto done; + wcscpy(path,stage); WCHAR *slash=wcsrchr(path,L'\\'); + if(!slash || !slash[1]) goto done; + *slash=0; + if(!vx_pin_tree(&context,path)) goto done; + } else { + GUID id; WCHAR unique[64],guid[39]; + if(CoCreateGuid(&id)!=S_OK || StringFromGUID2(&id,guid,39)!=39 || + swprintf(unique,ARRAYSIZE(unique),L"vx-bridge-setup-%ls",guid)<=0 || + !vx_path(stage,context.base,unique)) goto done; + } + WCHAR sddl[640]; PSECURITY_DESCRIPTOR sd=NULL; + if(swprintf(sddl,ARRAYSIZE(sddl),L"O:%lsD:P(A;OICI;GA;;;SY)(A;OICI;GA;;;%ls)",context.sid,context.sid)<=0 || + !ConvertStringSecurityDescriptorToSecurityDescriptorW(sddl,SDDL_REVISION_1,&sd,NULL)) goto done; + SECURITY_ATTRIBUTES sa={sizeof(sa),sd,FALSE}; + created=CreateDirectoryW(stage,&sa); + LocalFree(sd); + if(!created) goto done; + directory=vx_file(stage,TRUE,FILE_READ_ATTRIBUTES,FILE_SHARE_READ|FILE_SHARE_WRITE,OPEN_EXISTING); + if(directory==INVALID_HANDLE_VALUE || + !GetFileInformationByHandle(directory,&stage_identity)) goto done; + identified=TRUE; offset=bundle.stub_size; + for(unsigned int i=0;i Apps > Installed apps. Normal uninstall does not need the original download.", + L"VX Bridge Setup",MB_OK|MB_ICONINFORMATION); + result=ERROR_CALL_NOT_IMPLEMENTED; goto done; + } + HANDLE token=NULL; TOKEN_ELEVATION elevation; DWORD size=sizeof(elevation); + BOOL user=OpenProcessToken(GetCurrentProcess(),TOKEN_QUERY,&token) && + GetTokenInformation(token,TokenElevation,&elevation,size,&size) && !elevation.TokenIsElevated; + if(token && !CloseHandle(token)) user=FALSE; + if(!user) { + if(!quiet) MessageBoxW(NULL,L"Run Setup normally as the installing user, not as administrator.", + L"VX Bridge Setup",MB_OK|MB_ICONERROR); + result=1; goto done; + } + result=run_bundle(quiet,maintain,extract); +done: + if(usage && !quiet) MessageBoxW(NULL,L"Use Setup with no arguments, /quiet, /maintain, /remove, or /extract followed by a new absolute folder path.", + L"VX Bridge Setup",MB_OK|MB_ICONERROR); + LocalFree(argv); return (int)result; +} diff --git a/installer/setup/bundle.h b/installer/setup/bundle.h new file mode 100644 index 0000000..7b4b8ed --- /dev/null +++ b/installer/setup/bundle.h @@ -0,0 +1,34 @@ +/* SPDX-License-Identifier: MIT */ +#ifndef VX_BUNDLE_H +#define VX_BUNDLE_H +#include +#include + +#define VX_BUNDLE_COUNT 10 +#define VX_BUNDLE_LIMIT (64U * 1024U * 1024U) +#define VX_BUNDLE_FILE_LIMIT (32U * 1024U * 1024U) +#pragma pack(push, 1) +typedef struct { uint32_t size; unsigned char hash[32]; } VX_BUNDLE_ENTRY; +typedef struct { + char magic[8]; + uint64_t stub_size; + char revision[40]; + unsigned char stub_hash[32]; + VX_BUNDLE_ENTRY entries[VX_BUNDLE_COUNT]; +} VX_BUNDLE; +#pragma pack(pop) +_Static_assert(sizeof(VX_BUNDLE)==448,"bundle wire layout"); + +static int bundle_layout(const VX_BUNDLE *bundle,uint64_t size,const char *revision) +{ + if(size>VX_BUNDLE_LIMIT || sizemagic,"VXBDL001",8) || memcmp(bundle->revision,revision,40) || + !bundle->stub_size || bundle->stub_size>2U*1024U*1024U) return 0; + uint64_t total=bundle->stub_size+sizeof(*bundle); + for(unsigned int i=0;ientries[i].size || bundle->entries[i].size>VX_BUNDLE_FILE_LIMIT) return 0; + total+=bundle->entries[i].size; + } + return total==size; +} +#endif diff --git a/tools/commands/commands_test.go b/tools/commands/commands_test.go index f304597..90a1a9c 100644 --- a/tools/commands/commands_test.go +++ b/tools/commands/commands_test.go @@ -171,22 +171,23 @@ func TestMakeCommands(t *testing.T) { }}, {name: "installer_package", target: "package-installer", env: map[string]string{"REVISION": sha, "AUTHORED_SETUP": "authored with spaces", "ARCHIVE": "app.zip", - "MINGW_COPYRIGHT": "compiler notice", "OUT": "output with spaces"}, + "MINGW_COPYRIGHT": "compiler notice", "BUNDLE_STUB": "bundle stub with spaces.exe", "OUT": "output with spaces"}, want: []invocation{goCall("0", "run", "-mod=readonly", "./tools/msi/setup", "-revision", sha, "-version", "development", "-archive", "app.zip", "-installer", "authored with spaces", - "-copyright", "compiler notice", "-out", "output with spaces")}}, + "-copyright", "compiler notice", "-bundle-stub", "bundle stub with spaces.exe", "-out", "output with spaces")}}, {name: "installer_requires_authoring", target: "package-installer", fail: true, message: "Set AUTHORED_SETUP"}, {name: "installer_requires_app", target: "package-installer", env: map[string]string{"AUTHORED_SETUP": "authored"}, fail: true, message: "Set ARCHIVE"}, {name: "installer_requires_copyright", target: "package-installer", env: map[string]string{"AUTHORED_SETUP": "authored", "ARCHIVE": "app.zip"}, fail: true, message: "Set MINGW_COPYRIGHT"}, + {name: "installer_requires_stub", target: "package-installer", + env: map[string]string{"AUTHORED_SETUP": "authored", "ARCHIVE": "app.zip", "MINGW_COPYRIGHT": "notice"}, fail: true, message: "Set BUNDLE_STUB"}, {name: "installer_requires_output", target: "package-installer", - env: map[string]string{"AUTHORED_SETUP": "authored", "ARCHIVE": "app.zip", "MINGW_COPYRIGHT": "notice"}, fail: true, message: "Set OUT"}, + env: map[string]string{"AUTHORED_SETUP": "authored", "ARCHIVE": "app.zip", "MINGW_COPYRIGHT": "notice", "BUNDLE_STUB": "stub.exe"}, fail: true, message: "Set OUT"}, {name: "installer_verify", target: "verify-installer", - env: map[string]string{"REVISION": sha, "ARCHIVE": "app.zip", "INSTALLER_ARCHIVE": "installer with spaces.zip"}, + env: map[string]string{"REVISION": sha, "INSTALLER_ARCHIVE": "installer with spaces.exe"}, want: []invocation{goCall("0", "run", "-mod=readonly", "./tools/msi/setup", "-revision", sha, - "-version", "development", "-archive", "app.zip", "-verify-installer", "installer with spaces.zip")}}, - {name: "installer_verify_requires_archive", target: "verify-installer", fail: true, message: "Set ARCHIVE"}, + "-version", "development", "-verify-installer", "installer with spaces.exe")}}, {name: "installer_verify_requires_installer", target: "verify-installer", env: map[string]string{"ARCHIVE": "app.zip"}, fail: true, message: "Set INSTALLER_ARCHIVE"}, {name: "lifecycle_input", target: "build-lifecycle-input", want: []invocation{{"setup-lifecycle-input.sh", "local", "0", nil}}}, @@ -267,7 +268,7 @@ esac "GOTOOLCHAIN": "auto", "CGO_ENABLED": "0", "COMMAND_LOG": filepath.ToSlash(log), "OUT": "", "REVISION": "", "RELEASE_VERSION": "development", "ARCHIVE": "", "NATIVE_DLL": "", "FAIL_TOOL": "", "FAIL_COMMAND": "", "UNFORMATTED": "", "NATIVE_DIR": "", "SETUP_STAGING": "", "WIX_ARCHIVE": "", "TOOL_LOG": "", - "AUTHORED_SETUP": "", "MINGW_COPYRIGHT": "", "INSTALLER_ARCHIVE": "", "SETUP_BUILD_PROFILE": "normal", + "AUTHORED_SETUP": "", "MINGW_COPYRIGHT": "", "BUNDLE_STUB": "", "INSTALLER_ARCHIVE": "", "SETUP_BUILD_PROFILE": "normal", "MAKEFLAGS": "", "MAKELEVEL": "", "MFLAGS": "", } for key, value := range test.env { diff --git a/tools/dist/release_gate_test.go b/tools/dist/release_gate_test.go index 0ec3e6a..c00fde0 100644 --- a/tools/dist/release_gate_test.go +++ b/tools/dist/release_gate_test.go @@ -11,7 +11,6 @@ import ( "path/filepath" "strings" "testing" - "time" ) type tagFixture struct { @@ -123,7 +122,8 @@ fi makeStub := `#!/usr/bin/env bash set -euo pipefail [[ "$*" == verify-installer ]] -unzip -p "$ARCHIVE" build-manifest.json | + [[ -z "${ARCHIVE:-}" && "$INSTALLER_ARCHIVE" == *.exe ]] + unzip -p "$INSTALLER_ARCHIVE" build-manifest.json | jq -e --arg revision "$REVISION" --arg version "$RELEASE_VERSION" ' .schema == 2 and .source_revision == $revision and .release_version == $version and all(.binaries[]; .build_revision == $revision and .release_version == $version)' >/dev/null @@ -345,32 +345,40 @@ func TestTagPublicationFailuresRemainVisible(t *testing.T) { {"wrong_artifact_digest", "", ""}, {"wrong_artifact_run", "", ""}, {"missing_installer", "", ""}, - {"mismatched_direct_msi", "", ""}, + {"extra_msi", "", ""}, + {"extra_app_zip", "", ""}, + {"extra_installer_zip", "", ""}, + {"extra_notice", "", ""}, + {"sha_filename", "", ""}, + {"checksum_wrong_name", "", ""}, + {"checksum_no_newline", "", ""}, + {"checksum_extra_newline", "", ""}, + {"symlink_installer", "", ""}, {"different_package_version", "", ""}, {"manifest_version_mismatch", "", ""}, {"corrupt_checksum", "", ""}, } { t.Run(test.name, func(t *testing.T) { directory := t.TempDir() - files, err := os.ReadDir(build) - if err != nil { - t.Fatal(err) - } - for _, file := range files { - data, err := os.ReadFile(filepath.Join(build, file.Name())) - if err != nil { + var installer bytes.Buffer + writer := zip.NewWriter(&installer) + // A ZIP-shaped fake EXE lets the stub verifier read exact source/version + // metadata without coupling publication fixtures to PE/MSI authoring. + application := readTestArchive(t, filepath.Join(build, archiveName(f.source, "v1.0.2"))) + if test.name == "manifest_version_mismatch" { + var m manifest + if err := json.Unmarshal(application["build-manifest.json"], &m); err != nil { t.Fatal(err) } - if test.name == "corrupt_checksum" && strings.HasSuffix(file.Name(), ".sha256") { - data[0] = 'z' - } - if err := os.WriteFile(filepath.Join(directory, file.Name()), data, 0o644); err != nil { + m.Version = "v1.0.1" + data, err := json.Marshal(m) + if err != nil { t.Fatal(err) } + application["build-manifest.json"] = data } - var installer bytes.Buffer - writer := zip.NewWriter(&installer) - for name, data := range map[string][]byte{"setup.exe": []byte("synthetic Setup"), "bridge.msi": []byte("synthetic MSI")} { + for name, data := range map[string][]byte{"setup.exe": []byte("synthetic Setup"), + "bridge.msi": []byte("synthetic MSI"), "build-manifest.json": application["build-manifest.json"]} { entry, err := writer.Create(name) if err != nil { t.Fatal(err) @@ -382,20 +390,41 @@ func TestTagPublicationFailuresRemainVisible(t *testing.T) { if err := writer.Close(); err != nil { t.Fatal(err) } - installerName := "vx-bridge-installer-windows-amd64-v1.0.2-" + f.source + ".zip" - msiName := "vx-bridge-windows-amd64-v1.0.2-" + f.source + ".msi" - msiBytes := []byte("synthetic MSI") - if test.name == "mismatched_direct_msi" { - msiBytes = []byte("different MSI with valid external checksum") + installerName := "vx-bridge-installer-windows-amd64-v1.0.2.exe" + if test.name == "sha_filename" { + installerName = "vx-bridge-installer-windows-amd64-v1.0.2-" + f.source + ".exe" } - for name, data := range map[string][]byte{installerName: installer.Bytes(), msiName: msiBytes} { - if test.name == "missing_installer" && name == installerName { - continue + if test.name != "missing_installer" { + path := filepath.Join(directory, installerName) + if test.name == "symlink_installer" { + target := filepath.Join(t.TempDir(), "target.exe") + if err := os.WriteFile(target, installer.Bytes(), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, path); err != nil { + t.Fatal(err) + } + } else if err := os.WriteFile(path, installer.Bytes(), 0o644); err != nil { + t.Fatal(err) + } + sums := digest(installer.Bytes()) + " " + installerName + "\n" + if test.name == "corrupt_checksum" { + sums = "z" + sums[1:] + } else if test.name == "checksum_wrong_name" { + sums = digest(installer.Bytes()) + " another.exe\n" + } else if test.name == "checksum_no_newline" { + sums = strings.TrimSuffix(sums, "\n") + } else if test.name == "checksum_extra_newline" { + sums += "\n" } - if err := os.WriteFile(filepath.Join(directory, name), data, 0o644); err != nil { + if err := os.WriteFile(path+".sha256", []byte(sums), 0o644); err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(directory, name+".sha256"), []byte(digest(data)+" "+name+"\n"), 0o644); err != nil { + } + extra := map[string]string{"extra_msi": "bridge.msi", "extra_app_zip": "application.zip", + "extra_installer_zip": "installer.zip", "extra_notice": "NOTICE.txt"}[test.name] + if extra != "" { + if err := os.WriteFile(filepath.Join(directory, extra), []byte("must not publish"), 0o644); err != nil { t.Fatal(err) } } @@ -426,33 +455,6 @@ func TestTagPublicationFailuresRemainVisible(t *testing.T) { if test.name == "different_package_version" { f.env["RELEASE_VERSION"] = "v1.0.1" } - if test.name == "manifest_version_mismatch" { - name := archiveName(f.source, "v1.0.2") - path := filepath.Join(directory, name) - files := readTestArchive(t, path) - var m manifest - if err := json.Unmarshal(files["build-manifest.json"], &m); err != nil { - t.Fatal(err) - } - m.Version = "v1.0.1" - files["build-manifest.json"], err = json.Marshal(m) - if err != nil { - t.Fatal(err) - } - if err := os.Remove(path); err != nil { - t.Fatal(err) - } - if err := writeArchive(path, files, time.Unix(0, 0)); err != nil { - t.Fatal(err) - } - data, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path+".sha256", []byte(digest(data)+" "+name+"\n"), 0o644); err != nil { - t.Fatal(err) - } - } t.Cleanup(func() { fixtureGit(t, f.root, "--git-dir="+f.remote, "update-ref", "refs/tags/v1.0.2", f.env["TAG_OID"]) }) @@ -475,10 +477,19 @@ func TestTagPublicationFailuresRemainVisible(t *testing.T) { } for _, expected := range []string{"release create v1.0.2", "--verify-tag --target " + f.source, "--title v1.0.2", "release edit v1.0.2", "--draft=false --prerelease=false --latest=false", - installerName, installerName + ".sha256", msiName, msiName + ".sha256"} { + installerName, installerName + ".sha256"} { if !strings.Contains(string(calls), expected) { t.Fatalf("publication lacks exact version/source or normal-release flags: %s", calls) } + for _, line := range strings.Split(string(calls), "\n") { + if strings.HasPrefix(line, "release create ") { + args := strings.Fields(line) + if len(args) < 6 || args[3] != filepath.Join(directory, installerName) || + args[4] != filepath.Join(directory, installerName+".sha256") || args[5] != "--repo" { + t.Fatal("publication must upload exactly EXE and checksum", line) + } + } + } } } }) diff --git a/tools/msi/lifecycle/setup-lifecycle-author.ps1 b/tools/msi/lifecycle/setup-lifecycle-author.ps1 index 3630eba..fa80daa 100644 --- a/tools/msi/lifecycle/setup-lifecycle-author.ps1 +++ b/tools/msi/lifecycle/setup-lifecycle-author.ps1 @@ -70,7 +70,7 @@ foreach ($line in $records) { $sourceHashes[$path]=$true } } -if ($sourceHashes.Count -ne 11) { throw 'Native source inventory differs.' } +if ($sourceHashes.Count -ne 13) { throw 'Native source inventory differs.' } $workerInfo=Get-Content -Raw -LiteralPath (Join-Path $inputs 'worker-build-info.txt') $workerBytes=[IO.File]::ReadAllBytes((Join-Path $inputs 'lifecycle-worker.exe')) if ($workerInfo -notlike '*github.com/devigned/vx-bridge/tools/msi/lifecycle*' -or diff --git a/tools/msi/lifecycle/setup-lifecycle-input.sh b/tools/msi/lifecycle/setup-lifecycle-input.sh index 55e4bf3..6c3c09d 100644 --- a/tools/msi/lifecycle/setup-lifecycle-input.sh +++ b/tools/msi/lifecycle/setup-lifecycle-input.sh @@ -47,7 +47,7 @@ while IFS= read -r line; do count=$((count+1)) fi done < "$bundle/native/native-inputs.txt" -[[ "$count" == 11 ]] +[[ "$count" == 13 ]] ( cd "$bundle" sha256sum native/setup.exe native/setup-actions.dll native/native-inputs.txt native/native-SHA256SUMS \ diff --git a/tools/msi/setup/build_workflow_test.go b/tools/msi/setup/build_workflow_test.go index 774bb0f..3299019 100644 --- a/tools/msi/setup/build_workflow_test.go +++ b/tools/msi/setup/build_workflow_test.go @@ -75,9 +75,7 @@ func TestPublicRepositoryAndArtifactIdentity(t *testing.T) { }, "../../../.github/scripts/publish-tag-release.sh": { `--arg name "vx-bridge-tag-assets-$RELEASE_VERSION-$SOURCE_REVISION"`, - `archive="vx-bridge-windows-amd64-$RELEASE_VERSION-$SOURCE_REVISION.zip"`, - `installer="vx-bridge-installer-windows-amd64-$RELEASE_VERSION-$SOURCE_REVISION.zip"`, - `msi="vx-bridge-windows-amd64-$RELEASE_VERSION-$SOURCE_REVISION.msi"`, + `installer="vx-bridge-installer-windows-amd64-$RELEASE_VERSION.exe"`, }, "../lifecycle/workflow.yml": { "github.repository == 'devigned/vx-bridge' && github.event.repository.private && !github.event.repository.fork", @@ -295,12 +293,12 @@ func TestMinimalWorkflowsShareExactBuildAndReadOnlyBoundaries(t *testing.T) { } } for _, required := range []string{"make build-setup-native", "make package", "make prepare-setup", - "git show", "INPUT-SHA256SUMS", `"$count" == 11`} { + "git show", "INPUT-SHA256SUMS", `"$count" == 13`, "bundle-stub.exe"} { if !strings.Contains(input, required) { t.Fatal("native/input build lost authoritative provenance", required) } } - for _, required := range []string{"sourceHashes.Count -ne 11", "input_artifact_digest", + for _, required := range []string{"sourceHashes.Count -ne 13", "input_artifact_digest", "bundle-stub.exe", "'prepare-setup'", "'build-setup-msi'", "Windows regeneration differs", "native-tools.log", "outcome.json", "throw", "'package-installer'", "Language.Parser]::ParseFile"} { if !strings.Contains(windows, required) { diff --git a/tools/msi/setup/bundle_format.go b/tools/msi/setup/bundle_format.go new file mode 100644 index 0000000..fbdfac3 --- /dev/null +++ b/tools/msi/setup/bundle_format.go @@ -0,0 +1,132 @@ +package main + +import ( + "bytes" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "errors" + "fmt" + "os" + "path/filepath" + "slices" +) + +const bundleFooterSize = 448 +const bundleFileLimit = 32 << 20 + +func bundleNames() []string { + return append(slices.Clone(installerFiles), "SHA256SUMS", "application.zip") +} + +func checkBundleStub(stub []byte, revision string) error { + if err := checkNativePE(stub, false, []string{"CreateProcessW", "MsiOpenDatabaseW", + "BCryptHashData", "SetFileInformationByHandle", "GetFileInformationByHandle"}); err != nil { + return fmt.Errorf("native bundle stub: %w", err) + } + if !bytes.Contains(stub, []byte("VX_BUNDLE_SOURCE="+revision+"\x00")) { + return errors.New("bundle stub source differs") + } + return nil +} + +func installerEXE(stub []byte, payload map[string][]byte, revision string) ([]byte, error) { + if len(payload) != len(bundleNames()) { + return nil, errors.New("unexpected embedded installer entry count") + } + if err := checkBundleStub(stub, revision); err != nil { + return nil, err + } + footer := make([]byte, bundleFooterSize) + copy(footer, "VXBDL001") + binary.LittleEndian.PutUint64(footer[8:], uint64(len(stub))) + copy(footer[16:56], revision) + hash := sha256.Sum256(stub) + copy(footer[56:88], hash[:]) + data := slices.Clone(stub) + for i, name := range bundleNames() { + entry := payload[name] + if len(entry) == 0 || len(entry) > bundleFileLimit { + return nil, fmt.Errorf("missing or oversized embedded file: %s", name) + } + offset := 88 + i*36 + binary.LittleEndian.PutUint32(footer[offset:], uint32(len(entry))) + hash := sha256.Sum256(entry) + copy(footer[offset+4:], hash[:]) + data = append(data, entry...) + } + data = append(data, footer...) + if len(data) > 64<<20 { + return nil, errors.New("installer exceeds bundle size limit") + } + return data, nil +} + +func readInstallerEXE(data []byte, revision string) (map[string][]byte, error) { + if len(data) < bundleFooterSize || len(data) > 64<<20 { + return nil, errors.New("installer size outside bundle limits") + } + footer := data[len(data)-bundleFooterSize:] + stubSize := binary.LittleEndian.Uint64(footer[8:]) + if string(footer[:8]) != "VXBDL001" || string(footer[16:56]) != revision || + stubSize == 0 || stubSize > 2<<20 || stubSize > uint64(len(data)-bundleFooterSize) { + return nil, errors.New("installer bundle header/source differs") + } + stub := data[:stubSize] + if digest(stub) != hex.EncodeToString(footer[56:88]) { + return nil, errors.New("bundle stub hash differs") + } + if err := checkBundleStub(stub, revision); err != nil { + return nil, err + } + payload := make(map[string][]byte) + offset := int(stubSize) + for i, name := range bundleNames() { + record := footer[88+i*36 : 88+(i+1)*36] + size := int(binary.LittleEndian.Uint32(record)) + if size == 0 || size > bundleFileLimit || size > len(data)-bundleFooterSize-offset { + return nil, fmt.Errorf("invalid embedded file bounds: %s", name) + } + entry := data[offset : offset+size] + if digest(entry) != hex.EncodeToString(record[4:]) { + return nil, fmt.Errorf("embedded file hash differs: %s", name) + } + payload[name] = entry + offset += size + } + if offset != len(data)-bundleFooterSize { + return nil, errors.New("unexpected bytes outside bundle entries") + } + return payload, nil +} + +func verifyInstallerEXE(data []byte, revision, version string) (result error) { + payload, err := readInstallerEXE(data, revision) + if err != nil { + return err + } + appBytes := payload["application.zip"] + delete(payload, "application.zip") + app, err := readPackageZIPBytes(appBytes, files) + if err != nil { + return err + } + if err := verifyInstallerPayload(payload, app, revision, version); err != nil { + return err + } + directory, err := os.MkdirTemp("", "vx-bridge-verify-") + if err != nil { + return err + } + defer func() { result = errors.Join(result, os.RemoveAll(directory)) }() + applicationName, _ := installerNames(revision, version) + path := filepath.Join(directory, applicationName) + if err := os.WriteFile(path, appBytes, 0o600); err != nil { + return err + } + if err := command("go", "run", "-mod=readonly", "./tools/dist", "-revision", revision, + "-version", version, "-verify", path); err != nil { + return fmt.Errorf("embedded authoritative application verification: %w", err) + } + return nil +} diff --git a/tools/msi/setup/bundle_native_test.go b/tools/msi/setup/bundle_native_test.go new file mode 100644 index 0000000..37b773c --- /dev/null +++ b/tools/msi/setup/bundle_native_test.go @@ -0,0 +1,118 @@ +package main + +import ( + "encoding/binary" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "strings" + "testing" +) + +func TestNativeBundleParserAgreesWithEmittedLayout(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("portable C parser fixture uses the native POSIX compiler; Windows authoring checks the actual PE") + } + cc, err := exec.LookPath("cc") + if err != nil { + t.Skip("native C compiler unavailable") + } + header, err := filepath.Abs("../../../installer/setup/bundle.h") + if err != nil { + t.Fatal(err) + } + directory := t.TempDir() + source := "#include \"" + header + `" +#include +#include +int main(int argc,char **argv) { + if(argc!=3) return 2; + FILE *file=fopen(argv[1],"rb"); + if(!file || fseek(file,0,SEEK_END)) return 3; + long size=ftell(file); + VX_BUNDLE bundle; + if(size<(long)sizeof(bundle) || fseek(file,size-sizeof(bundle),SEEK_SET) || + fread(&bundle,1,sizeof(bundle),file)!=sizeof(bundle) || fclose(file)) return 4; + return bundle_layout(&bundle,(uint64_t)size,argv[2])?0:1; +} +` + path := filepath.Join(directory, "fixture.c") + if err := os.WriteFile(path, []byte(source), 0o600); err != nil { + t.Fatal(err) + } + fixture := filepath.Join(directory, "fixture") + if output, err := exec.Command(cc, "-std=c11", "-Wall", "-Wextra", "-Werror", path, "-o", fixture).CombinedOutput(); err != nil { + t.Fatal(string(output), err) + } + for _, name := range []string{"normal", "source", "magic", "stub-size", "entry-size", "entry-empty", "extra-byte"} { + t.Run(name, func(t *testing.T) { + payload, _, revision, _ := installerTestPayload(t) + payload["application.zip"] = []byte("synthetic archive") + data, err := installerEXE(installerTestStub(revision), payload, revision) + if err != nil { + t.Fatal(err) + } + footer := data[len(data)-bundleFooterSize:] + switch name { + case "source": + footer[16] = 'b' + case "magic": + footer[0] ^= 1 + case "stub-size": + binary.LittleEndian.PutUint64(footer[8:], ^uint64(0)) + case "entry-size": + binary.LittleEndian.PutUint32(footer[88:], ^uint32(0)) + case "entry-empty": + binary.LittleEndian.PutUint32(footer[88:], 0) + case "extra-byte": + data = append(append(data[:len(data)-bundleFooterSize:len(data)-bundleFooterSize], 0), footer...) + } + bundle := filepath.Join(directory, name+".exe") + if err := os.WriteFile(bundle, data, 0o600); err != nil { + t.Fatal(err) + } + output, err := exec.Command(fixture, bundle, revision).CombinedOutput() + if (err == nil) != (name == "normal") { + t.Fatal("native parser disagrees with emitted layout", name, string(output), err) + } + }) + } +} + +func TestBundleRetainsSafeInnerSetupAndStagingBoundaries(t *testing.T) { + source := setupSource(t, "../../../installer/setup/bundle.c") + start := strings.Index(source, "static const WCHAR *names[") + end := strings.Index(source[start:], "};") + start + literals := regexp.MustCompile(`L"([^"]+)"`).FindAllStringSubmatch(source[start:end], -1) + if len(literals) != len(bundleNames()) { + t.Fatal("native/Go bundle inventory count differs") + } + for i, name := range bundleNames() { + if literals[i][1] != name { + t.Fatal("native/Go bundle inventory order differs", name) + } + } + for _, required := range []string{ + "vx_context(&context)", "CreateDirectoryW(stage,&sa)", "OICI", "CREATE_NEW", + "FILE_SHARE_READ,OPEN_EXISTING", "same_file(&actual,&files[i].identity)", + "FlushFileBuffers(output)", "vx_hash(files[i].pin,hash)", + "setup_receipt(db,&receipt,owner)", "strcmp(receipt.revision,revision)", + `L"\"%ls\" /package \"%ls\"%ls%ls"`, "WaitForSingleObject(process.hProcess,INFINITE)", + "keep=TRUE; result=1", "remove_owned(path,FALSE,&files[i].identity)", + "remove_owned(stage,TRUE,&stage_identity)", "!elevation.TokenIsElevated", + "Normal uninstall does not need the original download", + "within_directory(stage,context.root)", "within_directory(stage,owned)", + } { + if !strings.Contains(source, required) { + t.Fatal("bundle lost a source/staging/lifetime boundary", required) + } + } + for _, forbidden := range []string{"MsiInstallProduct", "ShellExecute", "TerminateProcess", "MoveFileEx", + "FILE_FLAG_DELETE_ON_CLOSE", "msiexec", "CREATE_SUSPENDED"} { + if strings.Contains(source, forbidden) { + t.Fatal("outer bundle bypasses the existing Setup or source lifetime", forbidden) + } + } +} diff --git a/tools/msi/setup/installer_package.go b/tools/msi/setup/installer_package.go index 9728db3..34665f5 100644 --- a/tools/msi/setup/installer_package.go +++ b/tools/msi/setup/installer_package.go @@ -13,7 +13,6 @@ import ( "regexp" "slices" "strings" - "time" ) var installerFiles = []string{"setup.exe", "bridge.msi", "setup-input.json", "msi-inspection.json", @@ -22,26 +21,39 @@ var installerFiles = []string{"setup.exe", "bridge.msi", "setup-input.json", "ms var packageCodePattern = regexp.MustCompile(`^\{[0-9A-F]{8}(?:-[0-9A-F]{4}){3}-[0-9A-F]{12}\}$`) var packageHashPattern = regexp.MustCompile(`^[0-9a-f]{64}$`) -func installerNames(revision, version string) (string, string, string) { +func installerNames(revision, version string) (string, string) { identity := revision + publicIdentity := revision if version != "development" { identity = version + "-" + revision + publicIdentity = version } return "vx-bridge-windows-amd64-" + identity + ".zip", - "vx-bridge-installer-windows-amd64-" + identity + ".zip", - "vx-bridge-windows-amd64-" + identity + ".msi" + "vx-bridge-installer-windows-amd64-" + publicIdentity + ".exe" } -func installerPackage(revision, version, archive, authored, copyright, out, verify string, extra int) error { +func installerPackage(revision, version, archive, authored, copyright, stubPath, out, verify string, extra int) error { if len(revision) != 40 || strings.Trim(revision, "0123456789abcdef") != "" || extra != 0 || - archive == "" || (authored == "") == (verify == "") { - return errors.New("require exact revision/version/application ZIP and either -installer or -verify-installer") + (authored == "") == (verify == "") { + return errors.New("require exact revision/version and either -installer or -verify-installer") } if _, err := numeric(version); err != nil { return err } - if (verify != "" && (out != "" || copyright != "")) || (authored != "" && (out == "" || copyright == "")) { - return errors.New("only installer packaging requires -out and -copyright") + if (verify != "" && (out != "" || copyright != "" || stubPath != "" || archive != "")) || + (authored != "" && (out == "" || copyright == "" || stubPath == "" || archive == "")) { + return errors.New("only installer packaging requires -archive, -out, -copyright and -bundle-stub") + } + applicationName, installerName := installerNames(revision, version) + if verify != "" { + if filepath.Base(verify) != installerName { + return errors.New("installer filename differs from exact version") + } + data, err := readPackageFile(verify) + if err != nil { + return err + } + return verifyInstallerEXE(data, revision, version) } if err := command("go", "run", "-mod=readonly", "./tools/dist", "-revision", revision, "-version", version, "-verify", archive); err != nil { return fmt.Errorf("authoritative application verification: %w", err) @@ -50,17 +62,9 @@ func installerPackage(revision, version, archive, authored, copyright, out, veri if err != nil { return err } - applicationName, installerName, msiName := installerNames(revision, version) - if filepath.Base(archive) != applicationName || (verify != "" && filepath.Base(verify) != installerName) { + if filepath.Base(archive) != applicationName { return errors.New("archive filename differs from exact source/version") } - if verify != "" { - payload, err := readPackageZIP(verify, append(slices.Clone(installerFiles), "SHA256SUMS")) - if err != nil { - return err - } - return verifyInstallerPayload(payload, app, revision, version) - } head, err := exec.Command("git", "rev-parse", "HEAD").Output() if err != nil || strings.TrimSpace(string(head)) != revision { return errors.New("installer packaging requires exact HEAD") @@ -88,6 +92,18 @@ func installerPackage(revision, version, archive, authored, copyright, out, veri if err := verifyInstallerPayload(payload, app, revision, version); err != nil { return err } + stub, err := readPackageFile(stubPath) + if err != nil { + return err + } + payload["application.zip"], err = readPackageFile(archive) + if err != nil { + return err + } + bundle, err := installerEXE(stub, payload, revision) + if err != nil { + return err + } if !filepath.IsAbs(out) { return errors.New("installer output must be a new absolute external directory") } @@ -107,32 +123,23 @@ func installerPackage(revision, version, archive, authored, copyright, out, veri if err := os.Mkdir(out, 0o755); err != nil { return err } - if err := writeInstallerZIP(filepath.Join(out, installerName), payload); err != nil { - return err - } - archiveBytes, err := readPackageFile(archive) + file, err := os.OpenFile(filepath.Join(out, installerName), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644) if err != nil { return err } - for name, data := range map[string][]byte{applicationName: archiveBytes, msiName: payload["bridge.msi"]} { - if err := os.WriteFile(filepath.Join(out, name), data, 0o644); err != nil { - return err - } + _, writeErr := file.Write(bundle) + if err := errors.Join(writeErr, file.Close()); err != nil { + return err } - for _, name := range []string{applicationName, installerName, msiName} { - data, err := readPackageFile(filepath.Join(out, name)) - if err != nil { - return err - } - if err := os.WriteFile(filepath.Join(out, name+".sha256"), []byte(digest(data)+" "+name+"\n"), 0o644); err != nil { - return err - } + if err := os.WriteFile(filepath.Join(out, installerName+".sha256"), + []byte(digest(bundle)+" "+installerName+"\n"), 0o644); err != nil { + return err } - written, err := readPackageZIP(filepath.Join(out, installerName), append(slices.Clone(installerFiles), "SHA256SUMS")) + written, err := readPackageFile(filepath.Join(out, installerName)) if err != nil { return err } - if err := verifyInstallerPayload(written, app, revision, version); err != nil { + if err := verifyInstallerEXE(written, revision, version); err != nil { return err } fmt.Println(out) @@ -155,6 +162,10 @@ func readPackageZIP(path string, names []string) (map[string][]byte, error) { if err != nil { return nil, err } + return readPackageZIPBytes(data, names) +} + +func readPackageZIPBytes(data []byte, names []string) (map[string][]byte, error) { reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) if err != nil { return nil, err @@ -272,25 +283,3 @@ func verifyInstallerPayload(payload, app map[string][]byte, revision, version st } return nil } - -func writeInstallerZIP(path string, payload map[string][]byte) (result error) { - file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644) - if err != nil { - return err - } - defer func() { result = errors.Join(result, file.Close()) }() - writer := zip.NewWriter(file) - defer func() { result = errors.Join(result, writer.Close()) }() - for _, name := range append(slices.Clone(installerFiles), "SHA256SUMS") { - header := &zip.FileHeader{Name: name, Method: zip.Deflate, Modified: time.Unix(0, 0).UTC()} - header.SetMode(0o644) - entry, err := writer.CreateHeader(header) - if err != nil { - return err - } - if _, err := entry.Write(payload[name]); err != nil { - return err - } - } - return nil -} diff --git a/tools/msi/setup/installer_package_test.go b/tools/msi/setup/installer_package_test.go index c8e6238..db99f75 100644 --- a/tools/msi/setup/installer_package_test.go +++ b/tools/msi/setup/installer_package_test.go @@ -10,7 +10,6 @@ import ( "os/exec" "path/filepath" "runtime" - "slices" "strings" "testing" ) @@ -44,7 +43,8 @@ func installerTestPE() []byte { copy(data[640:], "msi.dll\x00") offset := 896 for i, name := range []string{"MsiInstallProductW", "MsiEnumProductsExW", "TaskDialogIndirect", - "CreateProcessW", "GetNamedPipeServerProcessId", "RegFlushKey"} { + "CreateProcessW", "GetNamedPipeServerProcessId", "RegFlushKey", "MsiOpenDatabaseW", + "BCryptHashData", "SetFileInformationByHandle", "GetFileInformationByHandle"} { binary.LittleEndian.PutUint64(data[768+i*8:], uint64(0x1000+offset-512)) copy(data[offset+2:], name+"\x00") offset += len(name) + 3 @@ -52,6 +52,10 @@ func installerTestPE() []byte { return data } +func installerTestStub(revision string) []byte { + return append(installerTestPE(), []byte("VX_BUNDLE_SOURCE="+revision+"\x00")...) +} + func installerTestPayload(t *testing.T) (map[string][]byte, map[string][]byte, string, string) { t.Helper() revision, version := strings.Repeat("a", 40), "v1.2.3" @@ -139,33 +143,32 @@ func TestInstallerBundleContract(t *testing.T) { if err := verifyInstallerPayload(payload, app, revision, version); err != nil { t.Fatal(err) } - applicationName, installerName, msiName := installerNames(revision, version) + applicationName, installerName := installerNames(revision, version) if applicationName != "vx-bridge-windows-amd64-"+version+"-"+revision+".zip" || - installerName != "vx-bridge-installer-windows-amd64-"+version+"-"+revision+".zip" || - msiName != strings.TrimSuffix(applicationName, ".zip")+".msi" { + installerName != "vx-bridge-installer-windows-amd64-"+version+".exe" || + strings.Contains(installerName, revision) { t.Fatal("unexpected version/source asset names") } - _, developmentName, _ := installerNames(revision, "development") + _, developmentName := installerNames(revision, "development") if strings.Contains(developmentName, "development") || !strings.Contains(developmentName, revision) { t.Fatal("development identity does not match app package convention") } - path := filepath.Join(t.TempDir(), installerName) - if err := writeInstallerZIP(path, payload); err != nil { + payload["application.zip"] = []byte("synthetic original app archive") + data, err := installerEXE(installerTestStub(revision), payload, revision) + if err != nil { t.Fatal(err) } - written, err := readPackageZIP(path, append(slices.Clone(installerFiles), "SHA256SUMS")) + written, err := readInstallerEXE(data, revision) if err != nil { t.Fatal(err) } + delete(written, "application.zip") if err := verifyInstallerPayload(written, app, revision, version); err != nil { t.Fatal(err) } if !bytes.Equal(written["bridge.msi"], payload["bridge.msi"]) || !bytes.Equal(written["setup.exe"], payload["setup.exe"]) { t.Fatal("bundle changed exact authored Setup/MSI bytes") } - if err := writeInstallerZIP(path, payload); err == nil { - t.Fatal("existing installer archive was overwritten") - } } func TestInstallerBundleRejectsChangedIdentity(t *testing.T) { @@ -279,7 +282,7 @@ func TestInstallerPackageFilesAreBoundedAndRegular(t *testing.T) { } } -func TestInstallerPackageWritesExactSixReleaseFiles(t *testing.T) { +func TestInstallerPackageWritesExactTwoReleaseFiles(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("POSIX fake-tool packaging fixture runs in portable CI; static bundle tests run on Windows") } @@ -325,7 +328,11 @@ func TestInstallerPackageWritesExactSixReleaseFiles(t *testing.T) { t.Fatal(err) } } - appName, installerName, msiName := installerNames(revision, version) + appName, installerName := installerNames(revision, version) + stub := filepath.Join(media, "bundle-stub.exe") + if err := os.WriteFile(stub, installerTestStub(revision), 0o644); err != nil { + t.Fatal(err) + } archive := filepath.Join(t.TempDir(), appName) var archiveBytes bytes.Buffer writer := zip.NewWriter(&archiveBytes) @@ -347,37 +354,90 @@ func TestInstallerPackageWritesExactSixReleaseFiles(t *testing.T) { stubs := t.TempDir() // Real application verification has separate end-to-end tests in tools/dist. // This stub isolates installer packaging from the synthetic application bytes. - stub := "#!/bin/sh\n[ \"$1 $2 $3 $4\" = 'run -mod=readonly ./tools/dist -revision' ]\n" - if err := os.WriteFile(filepath.Join(stubs, "go"), []byte(stub), 0o755); err != nil { + goStub := "#!/bin/sh\n[ \"$1 $2 $3 $4\" = 'run -mod=readonly ./tools/dist -revision' ]\n" + if err := os.WriteFile(filepath.Join(stubs, "go"), []byte(goStub), 0o755); err != nil { t.Fatal(err) } t.Setenv("PATH", stubs+string(os.PathListSeparator)+os.Getenv("PATH")) t.Chdir(root) out := filepath.Join(t.TempDir(), "release assets") - if err := installerPackage(revision, version, archive, media, filepath.Join(media, "MINGW-COPYRIGHT.txt"), out, "", 0); err != nil { + if err := installerPackage(revision, version, archive, media, filepath.Join(media, "MINGW-COPYRIGHT.txt"), stub, out, "", 0); err != nil { t.Fatal(err) } entries, err := os.ReadDir(out) - if err != nil || len(entries) != 6 { - t.Fatal("release output must have exactly three assets plus checksums", entries, err) + if err != nil || len(entries) != 2 || entries[0].Name() != installerName || entries[1].Name() != installerName+".sha256" { + t.Fatal("release output must have exactly the version-named EXE and checksum", entries, err) } - for _, name := range []string{appName, installerName, msiName} { + for _, name := range []string{installerName} { data, err := os.ReadFile(filepath.Join(out, name)) if err != nil { t.Fatal(err) } sums, err := os.ReadFile(filepath.Join(out, name+".sha256")) if err != nil || string(sums) != digest(data)+" "+name+"\n" { - t.Fatal("release checksum must bind the exact version/source filename", string(sums), err) + t.Fatal("release checksum must bind the exact version filename", string(sums), err) } - if name == appName && !bytes.Equal(data, archiveBytes.Bytes()) { - t.Fatal("application ZIP was repackaged") + written, err := readInstallerEXE(data, revision) + if err != nil { + t.Fatal(err) } - if name == msiName && !bytes.Equal(data, payload["bridge.msi"]) { - t.Fatal("direct MSI differs from authored bytes") + for _, file := range installerFiles { + if !bytes.Equal(written[file], payload[file]) { + t.Fatal("bundle changed authored bytes", file) + } + } + if !bytes.Equal(written["application.zip"], archiveBytes.Bytes()) { + t.Fatal("embedded application ZIP was repackaged") } } - if err := installerPackage(revision, version, filepath.Join(out, appName), "", "", "", filepath.Join(out, installerName), 0); err != nil { + if err := installerPackage(revision, version, "", "", "", "", "", filepath.Join(out, installerName), 0); err != nil { t.Fatal(err) } + if err := installerPackage(revision, version, archive, media, filepath.Join(media, "MINGW-COPYRIGHT.txt"), stub, out, "", 0); err == nil { + t.Fatal("existing release output directory was reused") + } +} + +func TestInstallerEXERejectsCorruptLayoutAndBytes(t *testing.T) { + for _, name := range []string{"truncated", "magic", "source", "stub-size", "stub-hash", + "stub-bytes", "entry-size", "empty-entry", "entry-hash", "entry-bytes", "trailing", "extra-byte"} { + t.Run(name, func(t *testing.T) { + payload, _, revision, _ := installerTestPayload(t) + payload["application.zip"] = []byte("synthetic app archive") + data, err := installerEXE(installerTestStub(revision), payload, revision) + if err != nil { + t.Fatal(err) + } + footer := data[len(data)-bundleFooterSize:] + switch name { + case "truncated": + data = data[:100] + case "magic": + footer[0] ^= 1 + case "source": + footer[16] = 'b' + case "stub-size": + binary.LittleEndian.PutUint64(footer[8:], ^uint64(0)) + case "stub-hash": + footer[56] ^= 1 + case "stub-bytes": + data[100] ^= 1 + case "entry-size": + binary.LittleEndian.PutUint32(footer[88:], ^uint32(0)) + case "empty-entry": + binary.LittleEndian.PutUint32(footer[88:], 0) + case "entry-hash": + footer[92] ^= 1 + case "entry-bytes": + data[len(installerTestStub(revision))+100] ^= 1 + case "trailing": + data = append(data, 0) + case "extra-byte": + data = append(append(data[:len(data)-bundleFooterSize:len(data)-bundleFooterSize], 0), footer...) + } + if _, err := readInstallerEXE(data, revision); err == nil { + t.Fatal("corrupt self-contained installer accepted") + } + }) + } } diff --git a/tools/msi/setup/main.go b/tools/msi/setup/main.go index 8131d35..2086b1e 100644 --- a/tools/msi/setup/main.go +++ b/tools/msi/setup/main.go @@ -45,7 +45,8 @@ func main() { checkAuthoring := flag.String("verify-authoring", "", "validate the exact intentional advertise-action omission policy") installer := flag.String("installer", "", "package existing statically inspected Windows authoring output") copyright := flag.String("copyright", "", "pinned MinGW compiler copyright for installer distribution") - verifyInstaller := flag.String("verify-installer", "", "verify an existing installer ZIP against the application ZIP") + bundleStub := flag.String("bundle-stub", "", "matching native outer bundle stub") + verifyInstaller := flag.String("verify-installer", "", "verify a self-contained installer EXE and its embedded application ZIP") profile := flag.String("profile", normalProfile, "normal, or explicitly gated private lifecycle fault authoring") flag.Parse() if *installer != "" || *verifyInstaller != "" { @@ -53,14 +54,14 @@ func main() { fmt.Fprintln(os.Stderr, "setup: installer packaging requires only the normal authoring profile") os.Exit(1) } - if err := installerPackage(*revision, *version, *archive, *installer, *copyright, *out, *verifyInstaller, flag.NArg()); err != nil { + if err := installerPackage(*revision, *version, *archive, *installer, *copyright, *bundleStub, *out, *verifyInstaller, flag.NArg()); err != nil { fmt.Fprintln(os.Stderr, "setup:", err) os.Exit(1) } return } - if *copyright != "" { - fmt.Fprintln(os.Stderr, "setup: -copyright requires -installer") + if *copyright != "" || *bundleStub != "" { + fmt.Fprintln(os.Stderr, "setup: -copyright and -bundle-stub require -installer") os.Exit(1) } if *checkAuthoring != "" { @@ -435,6 +436,15 @@ func checkedPE(path string, dll bool) ([]byte, error) { } func checkPEBytes(data []byte, dll bool) error { + required := []string{"MsiInstallProductW", "MsiEnumProductsExW", "TaskDialogIndirect", "CreateProcessW"} + if dll { + required = []string{"MsiGetActiveDatabase", "MsiGetMode", "MsiProcessMessage", "MsiGetPropertyW", + "MsiGetProductInfoExW", "MsiGetSummaryInformationW", "MsiDatabaseOpenViewW"} + } + return checkNativePE(data, dll, append(required, "GetNamedPipeServerProcessId", "RegFlushKey")) +} + +func checkNativePE(data []byte, dll bool, required []string) error { if len(data) == 0 || len(data) > 2<<20 { return errors.New("native input must be bounded") } @@ -465,12 +475,6 @@ func checkPEBytes(data []byte, dll bool) error { return fmt.Errorf("forbidden native import %s", name) } } - required := []string{"MsiInstallProductW", "MsiEnumProductsExW", "TaskDialogIndirect", "CreateProcessW"} - if dll { - required = []string{"MsiGetActiveDatabase", "MsiGetMode", "MsiProcessMessage", "MsiGetPropertyW", - "MsiGetProductInfoExW", "MsiGetSummaryInformationW", "MsiDatabaseOpenViewW"} - } - required = append(required, "GetNamedPipeServerProcessId", "RegFlushKey") for _, name := range required { found := false for _, imported := range imports {