diff --git a/Cargo.lock b/Cargo.lock index 03744e0..646c627 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2295,7 +2295,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.99.9" +version = "0.99.10" dependencies = [ "async-trait", "axum", diff --git a/Cargo.toml b/Cargo.toml index e93a1f1..4ae4506 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,7 @@ edition = "2021" # the ROOT manifest (`[workspace.package].version`), so it MUST be set here for a # release to fire (§3.6). The library crates (dig-node-core/dig-runtime/dig-wallet) # keep their own independent versions — only the released binary tracks the workspace version. -version = "0.99.9" +version = "0.99.10" # Release hardening, matching digstore: keep integer-overflow checks ON in release. # The node parses untrusted serialized input and does offset/length arithmetic over diff --git a/SPEC.md b/SPEC.md index c515bfc..95178d6 100644 --- a/SPEC.md +++ b/SPEC.md @@ -2605,7 +2605,7 @@ whole chain, so a privileged-owned leaf under a user-writable parent is refused, cannot act on a refusal that does not say which level failed. A **user-scope** install runs as the very user who owns the binary, crosses no privilege boundary, and is always allowed. The canonical install paths (native OS package, §9.7; the dig-installer's root-owned `/opt/dig/bin`) place the -binary in a protected admin-owned location (`%ProgramFiles%\DIG Network\dig-node\`, `/usr/…`), so +binary in a protected admin-owned location (`%ProgramFiles%\DIG\bin\`, `/usr/…`), so they satisfy the gate; a manual system-scope `dig-node install` from a user-writable download directory is what the gate refuses — and it is refused loudly, never downgraded to user scope. **The program FILE itself MUST also clear the bar** — owned by root/SYSTEM, no group/other write bit, not a symlink/reparse point — and not merely sit inside a privileged directory: directory @@ -2650,23 +2650,64 @@ out to both listeners (§4.1). ### 9.7. Native install packages (#503) The canonical end-user install path is a NATIVE OS PACKAGE built by this repo's CI (`package.yml`), -published as GitHub Release assets on each `vX.Y.Z` tag. The `dig-installer` simply fetches + runs -the right package; it does not re-implement service registration. Each package installs the binary, +published as GitHub Release assets on each `vX.Y.Z` tag. `dig-updater` fetches + runs the right +package on every update; on Windows `dig-installer` currently places a raw binary instead of running +the `.msi` (unifying that is planned, and until it lands the `.msi` MUST tolerate a foreign binary +already present in the install root — see the Windows entry below). Each package installs the binary, registers the OS service, registers the `chia://` scheme handler (→ `dig-node open`, §8.5), creates the machine-wide state dir (§7.3a), and sets the `dig.local` → `127.0.0.2` hosts entry (via the idempotent, no-shell `dig-node ensure-hosts`, §8.1). The `dig-node install`/`uninstall` CLI (§9.1) remains for manual/dev use. -- **Windows `.msi`** (WiX; `dig-node--windows-x64.msi`). Installs `dig-node.exe` under - `%ProgramFiles%\DIG Network\dig-node\`; `ServiceInstall`+`ServiceControl` register +- **Windows `.msi`** (WiX; `dig-node--windows-x64.msi`). **`dig-updater` runs this package on + every Windows update** (`msiexec /i /qn /norestart`; dig-node's Windows `InstallMethod` is + `WindowsMsi`), so it is load-bearing for auto-update. `dig-installer` does NOT currently run it — + it places a raw binary in the install root itself. + + Installs `dig-node.exe` under `%ProgramFiles%\DIG\bin\` — the CANONICAL protected install root. + That root is MANDATORY for two independent reasons: + + 1. **Auto-update convergence.** `dig-updater` reads the installed version from + `\dig-node.exe` after running the package. If the package installs anywhere else, + the probe reads a file the install never wrote: the probed version never changes, every beacon + cycle re-runs the same install, and the host never advances — a non-convergent update loop, not + a cosmetic path difference. + 2. **The installer's own audit.** `dig-installer` verifies the registered service image and the + fresh-session PATH resolution of `dig-node.exe` against that root, so a package installing + elsewhere makes every install fail a check against its own payload. + + `ServiceInstall`+`ServiceControl` register `net.dignetwork.dig-node` (DisplayName **"DIG NETWORK: NODE"**) running `dig-node.exe run-service` as LocalSystem, auto-start, STARTED on install, STOPPED+REMOVED on uninstall; creates `C:\ProgramData\DigNode` with a **restrictive DACL — inheritance broken, only SYSTEM + Administrators (never Users)** so the token is not world-readable (§7.3a; dig-node leaves a pre-existing dir's ACL intact); registers `chia://` under `HKLM\Software\Classes\chia` - (`shell\open\command` = `"…\dig-node.exe" open "%1"`); appends the install dir to the system PATH; + (`shell\open\command` = `"…\dig-node.exe" open "%1"`); MUST NOT modify the machine `PATH` (the + install root's PATH entry has exactly ONE owner, `dig-installer`, which writes it in the USER hive + — a machine-hive entry from this package precedes it in a fresh session and shadows it); runs `dig-node ensure-hosts` as a deferred (SYSTEM) custom action. A stable `UpgradeCode` + `MajorUpgrade` give clean in-place upgrades. + + **Upgrade sequencing (normative).** `MajorUpgrade` MUST schedule `RemoveExistingProducts` BEFORE + the new files install (`afterInstallValidate`). The previous product's binary, machine-`PATH` row + and `net.dignetwork.dig-node` registration are then removed, and the service reinstalled and + started, inside ONE transaction: an interrupted upgrade rolls back to the previous product with + its service intact, and a completed upgrade ends with the service registered against the new + image. No reachable resting state has a registered product and no service. Scheduling the removal + LATER is forbidden: the previous product's `ServiceControl Remove="uninstall"` matches the service + by NAME and would delete the service the new product had just registered. `REINSTALLMODE=amus` + MUST NOT be used to force file replacement: it turns a repair into a silent downgrade. + + The package MUST also remove any pre-existing `dig-node.exe` in the shared root before installing + its own (`RemoveFile`, on install). The root is shared and this package is not its only writer — + `dig-installer` drops a raw `dig-node.exe` there — and Windows Installer's file-versioning rules + KEEP such a foreign, unversioned-looking file rather than overwrite it. Without the removal the + package completes over a binary it did not install, and the version `dig-updater` probes next is + the stale file's. The removal MUST be scoped to that one file by name: the root also holds + `digstore`, `dig-dns`, `dig-updater` and `dig-app`. + + All four requirements above — root, no machine `PATH` row, removal schedule, and the scoped + `RemoveFile` — are asserted by `scripts/tests/msi-install-root.test.sh`. - **macOS `.pkg`** (`dig-node--macos.pkg`, universal arm64+x86_64). Installs `dig-node` to `/usr/local/bin`; a LaunchDaemon `/Library/LaunchDaemons/net.dignetwork.dig-node.plist` (`RunAtLoad`+`KeepAlive`, `run` with `DIG_NODE_RUN_CONTEXT=service`); a tiny AppleScript app diff --git a/packaging/windows/dig-node.wxs b/packaging/windows/dig-node.wxs index 216ddee..c865a59 100644 --- a/packaging/windows/dig-node.wxs +++ b/packaging/windows/dig-node.wxs @@ -1,10 +1,31 @@ + scripts/tests/package-version.test.sh. + + Schedule is stated EXPLICITLY (it happens to be WiX's default) because the migration off the + superseded `%ProgramFiles%\DIG Network\dig-node\` root depends on it, and the plausible + "improvement" is the broken one (#2251). afterInstallValidate removes the old product FIRST, + so the sequence over one transaction is: + + StopServices → DeleteServices → (old product removed: its files, its machine-PATH + Environment row, its Add/Remove registration) → InstallFiles into the new root → + InstallServices → StartServices + + All three things the old package owned — binary, PATH row and service — move in that one + transaction, and `net.dignetwork.dig-node` is absent only INSIDE it: an interruption rolls + the whole transaction back to the old product, service included, and a completed run ends + with the service registered against the new image. There is no reachable resting state with + a registered product and no service. + + The tempting alternative — scheduling RemoveExistingProducts late (afterInstallExecute) to + avoid re-copying files — inverts that into the exact failure a hand-repair already produced + on a real machine: the OLD package's `ServiceControl Remove="uninstall"` matches the service + by NAME, so removing it after the new install DELETES the service the new product just + registered, leaving files and no service. Asserted by + scripts/tests/msi-install-root.test.sh. --> @@ -64,10 +111,12 @@ - + - - + + @@ -79,6 +128,22 @@ + + - - - - - + diff --git a/packaging/windows/verify-upgrade-migration.ps1 b/packaging/windows/verify-upgrade-migration.ps1 new file mode 100644 index 0000000..6821673 --- /dev/null +++ b/packaging/windows/verify-upgrade-migration.ps1 @@ -0,0 +1,131 @@ +<# +.SYNOPSIS + Verify, on a real Windows host, that upgrading from a pre-0.99.10 dig-node .msi migrates the + install off the superseded `%ProgramFiles%\DIG Network\dig-node\` root and never leaves the host + without the `net.dignetwork.dig-node` service (dig_ecosystem#2251). + +.DESCRIPTION + CI builds the .msi but never installs it, so the migration is unprovable there. This script is the + manual acceptance test, written down so it is repeatable rather than re-derived. It: + + 1. installs -OldMsi (a released pre-0.99.10 package) to RECREATE the old layout, + 2. asserts the old layout is actually present — otherwise the upgrade proves nothing, + 3. installs -NewMsi over it, + 4. asserts the end state: service image under the protected root and RUNNING, both old + directories gone, no DIG entry left on the machine PATH, exactly one Add/Remove entry, + 5. resolves `dig-node.exe` in a FRESH ENVIRONMENT BLOCK via the Task Scheduler. + + Step 5 cannot be done from the calling shell. The stored machine PATH contains a literal `%PATH%` + self-reference, so expanding it inside a running shell splices that shell's own PATH in and + inverts the ordering — that has misled three separate diagnoses of this bug. A scheduled task is + launched with an environment block composed fresh from the registry, which is what a new logon + sees. + + MUST be run ELEVATED. It changes machine state; run it on a test host, or on a host you are + willing to leave with -NewMsi installed. + +.EXAMPLE + powershell -ExecutionPolicy Bypass -File verify-upgrade-migration.ps1 ` + -OldMsi C:\dl\dig-node-0.99.4-windows-x64.msi -NewMsi C:\out\dig-node-0.99.10-windows-x64.msi +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][string]$OldMsi, + [Parameter(Mandatory = $true)][string]$NewMsi, + [string]$LogDir = "$env:TEMP\dig-node-migration-check" +) + +$ErrorActionPreference = 'Stop' + +$SERVICE = 'net.dignetwork.dig-node' +$PROTECTED_DIR = Join-Path $env:ProgramFiles 'DIG\bin' +$OLD_DIR = Join-Path $env:ProgramFiles 'DIG Network\dig-node' +$OLD_ROOT = Join-Path $env:ProgramFiles 'DIG Network' + +if (-not ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent() + ).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + throw 'must be run elevated: installing a perMachine .msi and reading service config require admin' +} +New-Item -ItemType Directory -Force -Path $LogDir | Out-Null + +$script:failures = 0 +function Assert-That([string]$What, [bool]$Ok, [string]$Detail) { + if ($Ok) { Write-Host "ok $What" } else { Write-Host "FAIL $What`n $Detail"; $script:failures++ } +} + +function Get-ServiceImage { + (Get-ItemProperty "HKLM:\SYSTEM\CurrentControlSet\Services\$SERVICE" -EA SilentlyContinue).ImagePath +} + +# The machine PATH exactly as STORED. Never expand it here (see .DESCRIPTION). +function Get-MachinePathEntries { + (Get-Item 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Environment').GetValue( + 'Path', '', 'DoNotExpandEnvironmentNames') -split ';' +} + +function Get-NodeArpEntries { + Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*', + 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*' -EA SilentlyContinue | + Where-Object { $_.DisplayName -eq 'DIG NETWORK: NODE' } +} + +# Resolve dig-node.exe the way a NEW logon would: a scheduled task gets a fresh environment block. +function Get-FreshSessionResolution { + $out = Join-Path $LogDir 'freshpath.txt' + $name = 'DIGMigrationCheckPathProbe' + Remove-Item $out -EA SilentlyContinue + schtasks /delete /tn $name /f 2>&1 | Out-Null + schtasks /create /tn $name /tr "cmd.exe /c where dig-node.exe > `"$out`" 2>&1" /sc once /st 23:59 /f | Out-Null + schtasks /run /tn $name | Out-Null + for ($i = 0; $i -lt 40 -and -not (Test-Path $out); $i++) { Start-Sleep -Milliseconds 500 } + Start-Sleep -Milliseconds 500 + schtasks /delete /tn $name /f 2>&1 | Out-Null + if (Test-Path $out) { @(Get-Content $out | Where-Object { $_ -match '\S' }) } else { @() } +} + +function Install-Msi([string]$Path, [string]$Tag) { + $log = Join-Path $LogDir "msi-$Tag.log" + $p = Start-Process msiexec -Wait -PassThru -ArgumentList @('/i', "`"$Path`"", '/qn', '/l*v', "`"$log`"") + if ($p.ExitCode -ne 0) { throw "msiexec /i $Path exited $($p.ExitCode); see $log" } +} + +Write-Host "== 1. install the OLD package to recreate the superseded layout ==" +Install-Msi $OldMsi 'old' + +Write-Host "`n== 2. the precondition: the old layout is really present ==" +# Without this the upgrade below could pass vacuously on a host that never had the old layout. +Assert-That 'the superseded directory exists' (Test-Path $OLD_DIR) "expected $OLD_DIR" +Assert-That 'the service points into the superseded directory' ` + ((Get-ServiceImage) -like "*DIG Network*") "image is $(Get-ServiceImage)" +if ($script:failures -gt 0) { throw 'precondition not met — the upgrade would prove nothing' } + +$before = Get-FreshSessionResolution +Write-Host " fresh-session resolution before: $($before -join ' | ')" + +Write-Host "`n== 3. upgrade with the NEW package ==" +Install-Msi $NewMsi 'new' + +Write-Host "`n== 4. the end state ==" +$image = Get-ServiceImage +Assert-That 'the service image is under the protected root' ($image -like "*$PROTECTED_DIR*") "image is $image" +Assert-That 'the service is RUNNING' ` + ((Get-Service $SERVICE -EA SilentlyContinue).Status -eq 'Running') ` + "status is $((Get-Service $SERVICE -EA SilentlyContinue).Status)" +Assert-That 'the superseded directory is gone' (-not (Test-Path $OLD_DIR)) "still present: $OLD_DIR" +Assert-That 'the superseded root is gone' (-not (Test-Path $OLD_ROOT)) "still present: $OLD_ROOT" + +$digPath = @(Get-MachinePathEntries | Where-Object { $_ -match 'DIG' }) +Assert-That 'no DIG entry remains on the machine PATH' ($digPath.Count -eq 0) "found: $($digPath -join ' | ')" + +$arp = @(Get-NodeArpEntries) +Assert-That 'exactly one Add/Remove entry, no orphan' ($arp.Count -eq 1) ` + "found $($arp.Count): $(($arp | ForEach-Object { $_.DisplayVersion }) -join ', ')" + +Write-Host "`n== 5. fresh-environment resolution ==" +$after = Get-FreshSessionResolution +Write-Host " $($after -join ' | ')" +Assert-That 'dig-node.exe resolves ONLY to the protected copy in a fresh session' ` + ($after.Count -eq 1 -and $after[0] -like "$PROTECTED_DIR*") "resolved to: $($after -join ' | ')" + +if ($script:failures -gt 0) { Write-Host "`n$($script:failures) check(s) FAILED"; exit 1 } +Write-Host "`nall migration checks passed" diff --git a/scripts/tests/msi-install-root.test.sh b/scripts/tests/msi-install-root.test.sh new file mode 100644 index 0000000..b178239 --- /dev/null +++ b/scripts/tests/msi-install-root.test.sh @@ -0,0 +1,195 @@ +#!/usr/bin/env bash +# +# Tests for the install LOCATION and the upgrade SEQUENCING declared by +# packaging/windows/dig-node.wxs (dig_ecosystem#2251). +# +# The defect these pin was measured on a real machine twice: the MSI installed dig-node.exe to +# `%ProgramFiles%\DIG Network\dig-node\`, added THAT directory to the machine PATH, and pointed the +# `net.dignetwork.dig-node` service image at it. dig-installer places its copy in the canonical +# protected root `%ProgramFiles%\DIG\bin` and then verifies the service image and the PATH +# resolution against that root — so every install failed a safety check against a directory its own +# payload had just created, and deleting the directory by hand did not survive the next install. +# +# Three properties, each stated as a property and each pinned against the implementation that is +# nearest to it and still wrong: +# +# 1. INSTALLFOLDER resolves to the CANONICAL root `%ProgramFiles%\DIG\bin`. The nearest wrong +# implementations are a different manufacturer folder and a `DIG\` leaf that merely +# contains the right word, so the whole directory CHAIN is composed and compared, not grepped +# for a substring. +# 2. The package declares NO machine-PATH entry. Two owners of one PATH entry is how the shadowing +# arose, and an MSI `Environment` row is removed on uninstall — which, once the package points +# at the SHARED bin root, would strip that root from PATH out from under every other component +# dig-installer put there. Absence is the property; a component that merely adds the *right* +# directory still has the wrong owner. +# 3. The major upgrade removes the old product BEFORE installing the new one. This is the property +# most easily broken by a plausible "improvement": scheduling RemoveExistingProducts late +# (afterInstallExecute / afterInstallFinalize) is the standard advice for preserving files, but +# the OLD package's `ServiceControl Remove="uninstall"` owns the SAME service name, so a late +# removal deletes the service the new product just installed and leaves the machine with files +# and no `net.dignetwork.dig-node`. Files and the service must be recreated by the same +# transaction that removed them, which is what the early schedule plus `Start="install"` gives. +# +# These run on ubuntu CI. They assert the SHIPPED package source — not a copy of it — but they +# cannot execute msiexec, so the on-machine upgrade evidence is recorded in the PR, not here. +set -uo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +WXS="$HERE/../../packaging/windows/dig-node.wxs" + +failures=0 + +fail() { + printf 'FAIL %s\n' "$1" + failures=$((failures + 1)) +} + +pass() { + printf 'ok %s\n' "$1" +} + +# The canonical protected install root, spelled once (the `canonical` skill / SYSTEM.md install-root +# section; dig-installer's `install_root`). A second spelling of this root is the bug under test. +CANONICAL_ROOT='%ProgramFiles%\DIG\bin' + +# Compose the Windows path INSTALLFOLDER resolves to, by walking the nesting from the +# that anchors it. Emits nothing when INSTALLFOLDER is not reachable that way. +# +# A hand-rolled walk over a known-shaped document, because CI has no XML toolchain and the shape is +# fixed by WiX: then a chain of either nested +# or self-closing. +installfolder_path() { + awk ' + /]*Id="ProgramFiles64Folder"/ { inpf = 1; next } + inpf && /<\/StandardDirectory>/ { inpf = 0; next } + inpf && /]*Name="/ { + match($0, /Name="[^"]*"/); name = substr($0, RSTART + 6, RLENGTH - 7) + chain = chain "\\" name + if ($0 ~ /Id="INSTALLFOLDER"/) { print "%ProgramFiles%" chain; exit } + } + ' "$WXS" +} + +printf '== the package installs into the canonical protected root ==\n' +got="$(installfolder_path)" +if [ -z "$got" ]; then + fail "INSTALLFOLDER is not reachable from — the + install location could not be determined, so it cannot be held to the canonical root." +elif [ "$got" != "$CANONICAL_ROOT" ]; then + fail "INSTALLFOLDER resolves to $got, want $CANONICAL_ROOT. + dig-installer verifies the service image and PATH resolution against $CANONICAL_ROOT, so any + other location makes every install fail a check against this package's own payload (#2251)." +else + pass "INSTALLFOLDER = $CANONICAL_ROOT" +fi + +# The old root must not survive anywhere in the package — not as a directory, not in a registry +# value, not in a custom-action path. This is the substring check the composed comparison above +# deliberately is not: it catches a SECOND, leftover reference that the primary chain hides. +if grep -q 'Name="DIG Network"' "$WXS"; then + fail 'the package still declares a "DIG Network" directory — the superseded root (#2251).' +else + pass 'no "DIG Network" install directory remains' +fi + +printf '\n== the package declares no machine-PATH entry ==\n' +# Flattened before matching: WiX attributes are routinely wrapped across lines, and a line-oriented +# `grep` fails OPEN on exactly that — a real component split over two +# lines would reinstate the defect while this assertion passed. Verified: the same component on one +# line FAILs, wrapped it did not, until this flatten. +if tr '\n' ' ' < "$WXS" | grep -qi ']*Name="PATH"'; then + fail 'the package still owns a machine-PATH Environment row. dig-installer owns PATH for the + shared install root; an MSI-owned row is removed on uninstall and would strip that root from + PATH for every other component installed there (#2251).' +else + pass 'PATH is left to dig-installer, the single owner of the shared root' +fi + +printf '\n== a foreign dig-node.exe in the shared root is cleared before install ==\n' +# The root is shared and this package is not its only writer (dig-installer drops a raw binary +# there). Windows Installer KEEPS a foreign unversioned-looking file rather than overwrite it, so +# without this removal the package completes over a binary it did not install and dig-updater then +# probes the STALE version — the non-convergent update loop, not a cosmetic issue. +remove_file="$(tr '\n' ' ' < "$WXS" | grep -o ']*>')" +if [ -z "$remove_file" ]; then + fail 'no : an msiexec /i over a foreign dig-node.exe in the shared root can leave that + file in place, and the version dig-updater probes afterwards is the stale one (#2251).' +elif ! printf '%s' "$remove_file" | grep -q 'Name="dig-node.exe"'; then + fail "the does not name dig-node.exe: $remove_file" +elif ! printf '%s' "$remove_file" | grep -qE 'On="(install|both)"'; then + fail "the does not run on INSTALL, so it cannot clear the file before the install + writes: $remove_file" +else + pass 'a pre-existing dig-node.exe is removed before the package installs its own' +fi + +# The removal must stay scoped to ONE file. The shared root also holds digstore, dig-dns, +# dig-updater and dig-app, so a directory-wide removal (a with no Name, or a wildcard) +# would delete another component's binary — a far worse defect than the one being fixed. +unscoped='' +while IFS= read -r el; do + [ -n "$el" ] || continue + # A with no Name, or a Name carrying a wildcard, removes more than one file. + if ! printf '%s' "$el" | grep -q 'Name="'; then + unscoped="$unscoped $el" + elif printf '%s' "$el" | grep -q 'Name="[^"]*[*?]'; then + unscoped="$unscoped $el" + fi +done <]*>') +EOF +if [ -n "$unscoped" ]; then + fail "a removal is not scoped to a single named file, and the install root is SHARED:$unscoped" +elif tr '\n' ' ' < "$WXS" | grep -q ']*Directory="INSTALLFOLDER"'; then + fail 'the package removes the shared install DIRECTORY, which other components live in.' +else + pass 'every removal is scoped to a single named file, never the shared directory' +fi + +printf '\n== the upgrade never leaves the machine without net.dignetwork.dig-node ==\n' + +# MajorUpgrade may be written across several lines; flatten the element to inspect its attributes. +major_upgrade="$(tr '\n' ' ' < "$WXS" | grep -o ']*>')" +if [ -z "$major_upgrade" ]; then + fail 'no element — an in-place upgrade would install a SECOND product, two + Add/Remove entries both owning net.dignetwork.dig-node.' +else + schedule="$(printf '%s' "$major_upgrade" | grep -o 'Schedule="[^"]*"' | cut -d'"' -f2)" + case "${schedule:-afterInstallValidate}" in + afterInstallValidate) + pass "RemoveExistingProducts runs BEFORE the new files install (${schedule:-default})" + ;; + *) + fail "MajorUpgrade Schedule=\"$schedule\" removes the old product AFTER the new one installs. + The old package's ServiceControl Remove=\"uninstall\" owns the SAME service name, so it would + delete the net.dignetwork.dig-node the new product just registered, leaving files and no + service (#2251)." + ;; + esac +fi + +# The service must be reinstalled AND started by the same transaction that removed the old one — +# otherwise the early schedule above is exactly the window the migration must not open. +if grep -q 'Name="net.dignetwork.dig-node"' "$WXS" \ + && grep -q ']*Start="install"'; then + pass 'the same transaction reinstalls and STARTS net.dignetwork.dig-node' +else + fail 'the package does not both install and start net.dignetwork.dig-node — an early-scheduled + RemoveExistingProducts would then leave an upgraded machine with no service (#2251).' +fi + +# The service NAME is itself a migration hazard: renaming it would leave the old product'"'"'s service +# behind (its ServiceControl matches by name) while the new one registers a second. +if [ "$(grep -c 'net\.dignetwork\.dig-node' "$WXS")" -ge 2 ]; then + pass 'the service name is unchanged across the migration' +else + fail 'the service name appears fewer than twice (ServiceInstall + ServiceControl) — a renamed or + half-declared service orphans the one the previous version registered.' +fi + +if [ "$failures" -ne 0 ]; then + printf '\n%s MSI install-root test(s) FAILED\n' "$failures" + exit 1 +fi +printf '\nall MSI install-root tests passed\n'