publish: carry the Windows product into the projection

The tree adds the installer, service and test paths admitted by this source commit. Nothing else changes about what may cross.

Source-Sha: 3e4a3aa4ce092e9e3f51bc78daf3ddc14d9638f8

Policy-Sha: 3e4a3aa4ce092e9e3f51bc78daf3ddc14d9638f8

Tree-Digest: 89d6fce3828a0e0de0c8eeb41ee6750f4462217e2a04b5190297134d7f4a50c3
This commit is contained in:
Fimeg 2026-09-09 12:47:34 -04:00
commit 765ec4188f
35 changed files with 1425 additions and 379 deletions

View file

@ -18,14 +18,14 @@
elements, explicit Directory nesting, no StandardDirectory shorthand) —
wixl's grammar, not v4's.
Scope (v1): install the already-built, already-signed server binary as a
Windows service, with a forward-only upgrade guard. It does NOT bundle
Scope (v1): install the staged server binary as a Windows service. Payload
custody is checked by build-msi.sh; this is not an Authenticode claim.
It does NOT bundle
PostgreSQL — docker-compose doesn't either (postgres:16-alpine is a
separate container); a native install is expected to point at a reachable
Postgres the same way the docker path does via config/.env. The installer's
job ends where the docker quick-start's does today: "service is running,
go to http://localhost:<port>/setup and finish configuration in the
browser" (README.md's existing docker flow, same UX, different transport).
service is left stopped until the operator supplies database credentials,
admin credentials and signing configuration in redflag.env, then starts it.
Config: the Go binary only ever reads OS environment variables
(server/internal/config/config.go). Docker gets those from compose's
@ -69,18 +69,24 @@
Forward-only, no downgrade — ETHOS doctrine elsewhere in this codebase
(refresh-token rotation, capability tokens) applied to the installer
itself. Windows Installer only compares the first three version
fields for upgrade detection — pass $(var.RedFlagVersion) as the
3-part form from CI, same 3-vs-4-part reconciliation bump-version.sh
already does for desktop/Cargo.toml vs tauri.conf.json.
fields for upgrade detection. msi_version.py encodes patch*100+revision
in the third field, so fourth-field RedFlag releases remain ordered.
Same-version package replacement is safe from fourth-field collisions
under that mapping and prevents duplicate product registrations.
-->
<MajorUpgrade DowngradeErrorMessage="A newer version of RedFlag Server is already installed. Forward-only upgrades only." />
<MajorUpgrade AllowSameVersionUpgrades="yes" DowngradeErrorMessage="A newer version of RedFlag Server is already installed. Forward-only upgrades only." />
<MediaTemplate EmbedCab="yes" />
<Property Id="ARPNOMODIFY" Value="1" />
<UIRef Id="RedFlagServerUI" />
<Directory Id="TARGETDIR" Name="SourceDir">
<Directory Id="ProgramFiles64Folder">
<Directory Id="INSTALLFOLDER" Name="RedFlag">
<Component Id="ServerBinary" Guid="7a2d5e9c-3f81-4c6b-9e0a-2b7d4f8c1a63">
<!-- New component identity for the corrected 64-bit component;
the product UpgradeCode remains the existing Server identity. -->
<Component Id="ServerBinary" Guid="0d252388-94fb-468f-a050-9087d11f03dd" Win64="yes">
<File Id="RedFlagServerExe"
Source="dist/redflag-server-windows-amd64.exe"
Name="redflag-server.exe"
@ -105,7 +111,6 @@
<ServiceControl Id="RedFlagServerServiceControl"
Name="RedFlagServer"
Start="install"
Stop="both"
Remove="uninstall"
Wait="yes" />
@ -124,7 +129,7 @@
-->
<Directory Id="CommonAppDataFolder">
<Directory Id="REDFLAGDATA" Name="RedFlag">
<Component Id="ConfigTemplate" Guid="1c6e8a2d-9b47-4f3e-a1c8-6d3e9b4a2f70">
<Component Id="ConfigTemplate" Guid="1c6e8a2d-9b47-4f3e-a1c8-6d3e9b4a2f70" Win64="no">
<File Id="RedFlagEnvExample"
Source="config/redflag.env.example"
Name="redflag.env.example"
@ -140,11 +145,11 @@
</Feature>
<!--
Desktop tray feature slots in here later (Casey, 2026-06-30: "checkbox
Desktop feature slots in here later (Casey, 2026-06-30: "checkbox
for adding the desktop alongside it" plus future screen-capture work
from the session-broker RAF). Deliberately not stubbed as a disabled
checkbox — a checkbox that does nothing when checked is worse than no
checkbox. Wire it once the desktop/Tauri artifact has a real merge
checkbox. Wire it once the complete Desktop/Qt payload has an owned
path into this installer, per Casey's "keep it separate for now."
-->

View file

@ -0,0 +1,98 @@
<?xml version="1.0" encoding="UTF-8"?>
<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi">
<Fragment>
<UI Id="RedFlagServerUI">
<TextStyle Id="Normal" FaceName="Segoe UI" Size="9" />
<TextStyle Id="Title" FaceName="Segoe UI" Size="14" Bold="yes" />
<Property Id="DefaultUIFont" Value="Normal" />
<Dialog Id="ServerWelcome" Width="370" Height="270" Title="[ProductName] Setup">
<Control Id="Title" Type="Text" X="20" Y="18" Width="330" Height="24" Text="{\Title}Install RedFlag Server" />
<Control Id="Scope" Type="Text" X="20" Y="55" Width="330" Height="50" Text="This installs the fleet authority and web dashboard as a Windows service. It does not install RedFlag Desktop, a local Agent, or PostgreSQL." />
<Control Id="Requirements" Type="Text" X="20" Y="115" Width="330" Height="70" Text="You need administrator rights and an existing PostgreSQL database. After installation, create redflag.env from the supplied example, set your credentials and signing key, then start RedFlagServer." />
<Control Id="Boundary" Type="Text" X="20" Y="190" Width="330" Height="35" Text="The service remains stopped after setup. Standalone local-machine installation is not part of this package." />
<Control Id="Next" Type="PushButton" X="230" Y="240" Width="60" Height="18" Default="yes" Text="Next">
<Publish Event="NewDialog" Value="ServerLicense">1</Publish>
</Control>
<Control Id="Cancel" Type="PushButton" X="300" Y="240" Width="60" Height="18" Cancel="yes" Text="Cancel">
<Publish Event="EndDialog" Value="Exit">1</Publish>
</Control>
</Dialog>
<Dialog Id="ServerLicense" Width="370" Height="270" Title="[ProductName] License">
<Control Id="Title" Type="Text" X="20" Y="15" Width="330" Height="24" Text="{\Title}GNU AGPL version 3" />
<Control Id="License" Type="ScrollableText" X="20" Y="45" Width="330" Height="150" Sunken="yes" TabSkip="no">
<Text SourceFile="dist/License.rtf" />
</Control>
<Control Id="Notice" Type="Text" X="20" Y="202" Width="330" Height="30" Text="RedFlag is free software under the license above. Click Install to install the Server files and register its service." />
<Control Id="Back" Type="PushButton" X="160" Y="240" Width="60" Height="18" Text="Back">
<Publish Event="NewDialog" Value="ServerWelcome">1</Publish>
</Control>
<Control Id="Install" Type="PushButton" X="230" Y="240" Width="60" Height="18" Default="yes" Text="Install">
<Publish Event="EndDialog" Value="Return">1</Publish>
</Control>
<Control Id="Cancel" Type="PushButton" X="300" Y="240" Width="60" Height="18" Cancel="yes" Text="Cancel">
<Publish Event="EndDialog" Value="Exit">1</Publish>
</Control>
</Dialog>
<Dialog Id="ServerMaintenance" Width="370" Height="270" Title="[ProductName] Maintenance">
<Control Id="Title" Type="Text" X="20" Y="18" Width="330" Height="24" Text="{\Title}Maintain RedFlag Server" />
<Control Id="Scope" Type="Text" X="20" Y="65" Width="330" Height="100" Text="Repair reinstalls the Server files. Remove stops and removes its service and package files. Your redflag.env, server keys and database are preserved. Neither action changes a separately installed Agent or Desktop. Start the service manually after repair." />
<Control Id="Repair" Type="PushButton" X="90" Y="200" Width="80" Height="22" Default="yes" Text="Repair">
<Publish Property="REINSTALL" Value="ALL" Order="1">1</Publish>
<Publish Property="REINSTALLMODE" Value="omus" Order="2">1</Publish>
<Publish Event="EndDialog" Value="Return" Order="3">1</Publish>
</Control>
<Control Id="Remove" Type="PushButton" X="190" Y="200" Width="80" Height="22" Text="Remove">
<Publish Property="REMOVE" Value="ALL" Order="1">1</Publish>
<Publish Event="EndDialog" Value="Return" Order="2">1</Publish>
</Control>
<Control Id="Cancel" Type="PushButton" X="300" Y="240" Width="60" Height="18" Cancel="yes" Text="Cancel">
<Publish Event="EndDialog" Value="Exit">1</Publish>
</Control>
</Dialog>
<Dialog Id="ServerProgress" Width="370" Height="180" Title="[ProductName] Setup" Modeless="yes">
<Control Id="Title" Type="Text" X="20" Y="20" Width="330" Height="24" Text="{\Title}Applying package changes" />
<Control Id="Action" Type="Text" X="20" Y="70" Width="330" Height="30">
<Subscribe Event="ActionText" Attribute="Text" />
</Control>
<Control Id="Progress" Type="ProgressBar" X="20" Y="110" Width="330" Height="16" ProgressBlocks="yes">
<Subscribe Event="SetProgress" Attribute="Progress" />
</Control>
</Dialog>
<Dialog Id="ServerComplete" Width="370" Height="270" Title="[ProductName] Setup">
<Control Id="Title" Type="Text" X="20" Y="18" Width="330" Height="24" Text="{\Title}Package operation complete" />
<Control Id="NextSteps" Type="Text" X="20" Y="65" Width="330" Height="135" Text="After installing or repairing: copy [CommonAppDataFolder]RedFlag\redflag.env.example to redflag.env in the same folder. Configure PostgreSQL, admin credentials, JWT secret and signing key. Start RedFlagServer in Services, then open http://localhost:8080 (or your configured port). Package completion does not verify database connectivity or application health. After removal, operator configuration and database remain." />
<Control Id="Finish" Type="PushButton" X="290" Y="240" Width="60" Height="18" Default="yes" Cancel="yes" Text="Finish">
<Publish Event="EndDialog" Value="Return">1</Publish>
</Control>
</Dialog>
<Dialog Id="ServerFailed" Width="370" Height="180" Title="[ProductName] Setup">
<Control Id="Message" Type="Text" X="20" Y="25" Width="330" Height="95" Text="Setup could not complete. Review the Windows Installer log before retrying. Do not assume the Server is installed or running." />
<Control Id="Finish" Type="PushButton" X="290" Y="145" Width="60" Height="18" Default="yes" Cancel="yes" Text="Finish">
<Publish Event="EndDialog" Value="Exit">1</Publish>
</Control>
</Dialog>
<Dialog Id="ServerCancelled" Width="370" Height="180" Title="[ProductName] Setup">
<Control Id="Message" Type="Text" X="20" Y="25" Width="330" Height="70" Text="Setup was cancelled. You can run this package again when ready." />
<Control Id="Finish" Type="PushButton" X="290" Y="145" Width="60" Height="18" Default="yes" Cancel="yes" Text="Finish">
<Publish Event="EndDialog" Value="Exit">1</Publish>
</Control>
</Dialog>
<InstallUISequence>
<Show Dialog="ServerWelcome" After="CostFinalize">NOT Installed</Show>
<Show Dialog="ServerMaintenance" After="ServerWelcome">Installed AND NOT REMOVE</Show>
<Show Dialog="ServerProgress" Before="ExecuteAction">1</Show>
<Show Dialog="ServerComplete" OnExit="success">1</Show>
<Show Dialog="ServerFailed" OnExit="error">1</Show>
<Show Dialog="ServerCancelled" OnExit="cancel">1</Show>
</InstallUISequence>
</UI>
</Fragment>
</Wix>

View file

@ -15,11 +15,15 @@ PROOF_JSON="$4"
SOURCE_COMMIT="$5"
SOURCE_REF="$6"
ROOT=$(cd "$(dirname "$0")/../.." && pwd)
MSI_VERSION=$(printf '%s\n' "$REDFLAG_VERSION" | cut -d. -f1-3)
for tool in jq msiinfo msiextract wixl; do
for tool in jq msiinfo msiextract wixl python3; do
command -v "$tool" >/dev/null || { echo "$tool not found" >&2; exit 2; }
done
[ "$(wixl --version)" = "0.106" ] || {
echo "wixl 0.106 is required for the Server UI; use provision-msitools.sh" >&2
exit 2
}
MSI_VERSION=$(python3 "$ROOT/installer/windows/msi_version.py" "$REDFLAG_VERSION")
[ -f "$SERVER_EXE" ] || { echo "no such Server executable: $SERVER_EXE" >&2; exit 2; }
SERVER_EXE=$(realpath "$SERVER_EXE")
@ -27,10 +31,12 @@ OUTPUT_MSI=$(realpath -m "$OUTPUT_MSI")
PROOF_JSON=$(realpath -m "$PROOF_JSON")
mkdir -p "$ROOT/installer/windows/dist" "$(dirname "$OUTPUT_MSI")" "$(dirname "$PROOF_JSON")"
cp "$SERVER_EXE" "$ROOT/installer/windows/dist/redflag-server-windows-amd64.exe"
python3 "$ROOT/installer/windows/render-license.py" \
"$ROOT/LICENSE" "$ROOT/installer/windows/dist/License.rtf"
(
cd "$ROOT/installer/windows"
wixl Product.wxs -D RedFlagVersion="$MSI_VERSION" -o "$OUTPUT_MSI"
wixl -a x64 --ext ui Product.wxs ServerUI.wxs -D RedFlagVersion="$MSI_VERSION" -o "$OUTPUT_MSI"
)
CAB_FILE=$(find "$(dirname "$OUTPUT_MSI")" "$ROOT/installer/windows" \
@ -52,6 +58,8 @@ fi
--source-ref "$SOURCE_REF" \
--proof-record "$PROOF_JSON"
python3 "$ROOT/installer/windows/verify-msi-boundary.py" "$OUTPUT_MSI"
FIXTURE="$ROOT/installer/windows/testdata/run3181-hollow.msi"
if "$ROOT/installer/windows/verify-msi.sh" "$FIXTURE" \
"$ROOT/installer/windows/dist/redflag-server-windows-amd64.exe" \

View file

@ -0,0 +1,24 @@
#!/usr/bin/env python3
"""Preserve RedFlag's fourth version field in MSI's three-field identity."""
import re
import sys
def product_version(version):
if not re.fullmatch(r"[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}(\.[0-9]{1,2})?", version):
raise ValueError("expected major.minor.patch[.revision], with revision 0..99")
parts = [int(part) for part in version.split(".")]
major, minor, patch = parts[:3]
revision = parts[3] if len(parts) == 4 else 0
build = patch * 100 + revision
if major > 255 or minor > 255 or build > 65535:
raise ValueError("version exceeds MSI limits (255.255.65535)")
return f"{major}.{minor}.{build}"
if __name__ == "__main__":
try:
print(product_version(sys.argv[1]))
except (ValueError, IndexError) as error:
sys.exit(f"Invalid RedFlag MSI version: {error}")

View file

@ -0,0 +1,30 @@
#!/usr/bin/env bash
# CI-only tool bootstrap shared by release and custody proof. wixl 0.101
# silently omits the Server UI tables; 0.106 implements the required grammar.
set -euo pipefail
[ "$#" -eq 1 ] || { echo "usage: $0 <empty-install-prefix>" >&2; exit 2; }
PREFIX=$(realpath -m "$1")
[ ! -e "$PREFIX" ] || { echo "tool prefix already exists: $PREFIX" >&2; exit 2; }
WORK=$(mktemp -d)
sudo apt-get update -qq
sudo apt-get install -y -qq build-essential valac bison gettext ninja-build \
libglib2.0-dev libgsf-1-dev libgcab-dev libxml2-dev gobject-introspection \
python3-venv curl jq
curl --fail --location --retry 3 \
https://download.gnome.org/sources/msitools/0.106/msitools-0.106.tar.xz \
-o "$WORK/msitools.tar.xz"
echo "1ed34279cf8080f14f1b8f10e649474125492a089912e7ca70e59dfa2e5a659b $WORK/msitools.tar.xz" | sha256sum -c -
curl --fail --location --retry 3 \
https://files.pythonhosted.org/packages/55/a6/47b9353c331318a13eb050887eacfd61eb075746285f9baf7ef7de6ae235/meson-1.5.2-py3-none-any.whl \
-o "$WORK/meson-1.5.2-py3-none-any.whl"
echo "77706e2368a00d789c097632ccf4fc39251fba56d03e1e1b262559a3c7a08f5b $WORK/meson-1.5.2-py3-none-any.whl" | sha256sum -c -
python3 -m venv "$WORK/venv"
"$WORK/venv/bin/pip" install --no-index --no-deps "$WORK/meson-1.5.2-py3-none-any.whl"
tar -xJf "$WORK/msitools.tar.xz" -C "$WORK"
"$WORK/venv/bin/meson" setup "$WORK/build" "$WORK/msitools-0.106" \
--prefix="$PREFIX" --libdir=lib
"$WORK/venv/bin/meson" compile -C "$WORK/build"
"$WORK/venv/bin/meson" install -C "$WORK/build"
LD_LIBRARY_PATH="$PREFIX/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" "$PREFIX/bin/wixl" --version

View file

@ -0,0 +1,17 @@
#!/usr/bin/env python3
"""Put the repository's complete license into the MSI's native RTF control."""
import pathlib
import sys
def render(text):
# The repository license is ASCII. Refuse lossy conversion if that changes.
text.encode("ascii")
escaped = text.replace("\\", "\\\\").replace("{", "\\{").replace("}", "\\}")
return "{\\rtf1\\ansi\\deff0{\\fonttbl{\\f0 Segoe UI;}}\\f0\\fs18\n" + escaped.replace("\n", "\\par\n") + "}\n"
if __name__ == "__main__":
source, destination = map(pathlib.Path, sys.argv[1:])
destination.write_text(render(source.read_text(encoding="utf-8")), encoding="ascii")

View file

@ -0,0 +1,84 @@
# Run in elevated Windows PowerShell 5.1 and PowerShell 7 with operator-installed
# OpenSSL 3 on PATH. Loads only verifier functions; never installs an agent.
$ErrorActionPreference = "Stop"
$Template = Join-Path $PSScriptRoot "../../server/internal/services/templates/install/scripts/windows.ps1.tmpl"
$Tokens = $null
$ParseErrors = $null
$AST = [System.Management.Automation.Language.Parser]::ParseFile(
(Resolve-Path $Template).Path, [ref]$Tokens, [ref]$ParseErrors)
if ($ParseErrors.Count -ne 0) { throw ($ParseErrors | Out-String) }
$Names = @("Convert-HexToBytes", "Stop-Install", "Remove-InstallerScratchDirectory",
"New-InstallerScratchDirectory", "Test-Ed25519Signature", "Assert-Ed25519Verifier")
foreach ($Name in $Names) {
$Function = $AST.Find({ param($Node)
$Node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $Node.Name -eq $Name
}, $true)
if (-not $Function) { throw "Missing function: $Name" }
. ([scriptblock]::Create($Function.Extent.Text))
}
Assert-Ed25519Verifier
$Key = Convert-HexToBytes "3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c"
$Sig = Convert-HexToBytes "92a009a9f0d4cab8720e820b5f642540a2b27b5416503f8fb3762223ebdb69da085ac1e43e15996e458f3613d0f11d8c387b2eaeb4302aeeb00d291612bb0c00"
$Message = [byte[]]@(0x72)
$WrongKey = Convert-HexToBytes "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a"
if (Test-Ed25519Signature $Message $Sig $WrongKey) { throw "Wrong key accepted" }
$Sig[0] = $Sig[0] -bxor 1
if (Test-Ed25519Signature $Message $Sig $Key) { throw "Tampered signature accepted" }
$Rejected = $false
try { Test-Ed25519Signature $Message ([byte[]]@(1)) $Key | Out-Null } catch { $Rejected = $true }
if (-not $Rejected) { throw "Malformed signature accepted" }
$Rejected = $false
try { Test-Ed25519Signature $Message $Sig ([byte[]]@(1)) | Out-Null } catch { $Rejected = $true }
if (-not $Rejected) { throw "Malformed key accepted" }
$script:Ed25519OpenSSL = $null
$Rejected = $false
try { Test-Ed25519Signature $Message $Sig $Key | Out-Null } catch { $Rejected = $true }
if (-not $Rejected) { throw "Missing verifier accepted" }
# Cleanup must report its own trouble as a warning and leave the real failure
# standing. Removing an absent directory is the cheapest way to force that path.
$Warnings = @()
Remove-InstallerScratchDirectory -Path (Join-Path ([System.IO.Path]::GetTempPath()) "redflag-absent-$([guid]::NewGuid().ToString('N'))") -WarningVariable +Warnings -WarningAction SilentlyContinue
if ($Warnings.Count -eq 0) { throw "Cleanup of a missing directory raised no warning" }
$script:Ed25519OpenSSL = $null
$Primary = $null
try {
try { throw "PRIMARY FAILURE" } finally {
Remove-InstallerScratchDirectory -Path (Join-Path ([System.IO.Path]::GetTempPath()) "redflag-absent-$([guid]::NewGuid().ToString('N'))") -WarningAction SilentlyContinue
}
} catch { $Primary = $_.Exception.Message }
if ($Primary -ne "PRIMARY FAILURE") { throw "Cleanup masked the primary failure: got '$Primary'" }
# Stop-Install exits; run it in a child PowerShell so this script survives.
# The regression it guards: with $ErrorActionPreference = "Stop", a bare
# Write-Error inside the desktop try/catch threw, was caught by that catch,
# printed as a skip warning, and the installer exited 0 on a hash mismatch.
$Self = (Get-Process -Id $PID).Path
$Probe = @"
`$ErrorActionPreference = "Stop"
$($AST.Find({ param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $n.Name -eq "Stop-Install" }, $true).Extent.Text)
try {
Stop-Install @("REFUSAL LINE 1", "REFUSAL LINE 2", "REFUSAL LINE 3")
} catch {
Write-Host "SWALLOWED BY CATCH"
}
Write-Host "CONTINUED PAST REFUSAL"
"@
$ProbeFile = Join-Path ([System.IO.Path]::GetTempPath()) "redflag-probe-$([guid]::NewGuid().ToString('N')).ps1"
Set-Content -LiteralPath $ProbeFile -Value $Probe
try {
$Output = & $Self -NoProfile -File $ProbeFile 2>&1 | Out-String
$Code = $LASTEXITCODE
} finally {
Remove-InstallerScratchDirectory -Path $ProbeFile
}
if ($Code -ne 1) { throw "Refusal exited $Code, expected 1" }
if ($Output -match "SWALLOWED BY CATCH") { throw "An enclosing catch swallowed the refusal" }
if ($Output -match "CONTINUED PAST REFUSAL") { throw "Installer continued past a refusal" }
foreach ($n in 1..3) {
if ($Output -notmatch "REFUSAL LINE $n") { throw "Diagnostic line $n never reached the operator" }
}
Write-Host "PASS: exact installer functions accept valid signatures and reject tampering, wrong keys, malformed inputs, and missing verifier; cleanup warns without masking."

View file

@ -0,0 +1,35 @@
import importlib.util
import pathlib
import unittest
from msi_version import product_version
spec = importlib.util.spec_from_file_location("render_license", pathlib.Path(__file__).with_name("render-license.py"))
license_module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(license_module)
class BuildInputs(unittest.TestCase):
def test_revision_upgrades_are_distinct_and_ordered(self):
releases = ["0.2.9.5", "0.2.9.6", "0.2.10", "0.3.0", "1.0.0"]
versions = [tuple(map(int, product_version(v).split("."))) for v in releases]
self.assertEqual(versions, sorted(set(versions)))
self.assertGreater(versions[0], (0, 2, 9)) # Previous MSI scheme.
self.assertEqual(product_version("0.2.9.6"), "0.2.906")
def test_three_part_release_equals_revision_zero(self):
self.assertEqual(product_version("0.3.1"), product_version("0.3.1.0"))
def test_out_of_range_or_nonrelease_versions_are_rejected(self):
for version in ["0.2.9.100", "256.1.0", "1.256.0", "1.0.656", "1.0.655.36", "0.2.9-rc1", "dev", "1.2", "1.2.3.4.5"]:
with self.subTest(version=version), self.assertRaises(ValueError):
product_version(version)
def test_license_escapes_rtf_syntax_without_losing_text(self):
rendered = license_module.render("{license}\\path\nsecond line")
self.assertIn(r"\{license\}\\path\par", rendered)
self.assertIn("second line", rendered)
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,47 @@
#!/usr/bin/env python3
"""Check Server package ownership and wizard tables after custody verification."""
import subprocess
import sys
def export(msi, table):
return subprocess.check_output(["msiinfo", "export", msi, table], text=True)
def rows(msi, table):
lines = export(msi, table).splitlines()
return [dict(zip(lines[0].split("\t"), line.split("\t"))) for line in lines[3:] if line]
def verify(msi):
properties = {row["Property"]: row["Value"] for row in rows(msi, "Property")}
assert properties["ProductName"] == "RedFlag Server", "wrong product"
assert properties["UpgradeCode"].lower().strip("{}") == "8f3c9e6a-4b1d-4a7f-9c2e-1d6a8b2f5e91", "Server upgrade identity changed"
files = {row["FileName"].split("|")[-1] for row in rows(msi, "File")}
assert files == {"redflag-server.exe", "redflag.env.example"}, f"unexpected Server payload: {files}"
assert {row["Feature"] for row in rows(msi, "Feature")} == {"ServerFeature"}, "unexpected product mode"
components = {row["Component"]: row for row in rows(msi, "Component")}
assert int(components["ServerBinary"]["Attributes"]) & 256, "Server component is not 64-bit"
services = rows(msi, "ServiceInstall")
assert len(services) == 1 and services[0]["Name"] == "RedFlagServer", "unexpected service ownership"
controls = rows(msi, "ServiceControl")
assert len(controls) == 1 and controls[0]["Name"] == "RedFlagServer", "unexpected service control"
events = int(controls[0]["Event"])
assert not events & (1 | 16), "MSI must not start an unconfigured service"
assert events & (2 | 32 | 128) == (2 | 32 | 128), "stop/remove lifecycle missing"
dialogs = {row["Dialog"] for row in rows(msi, "Dialog")}
assert {"ServerWelcome", "ServerLicense", "ServerMaintenance", "ServerProgress", "ServerComplete", "ServerFailed", "ServerCancelled"} <= dialogs, "wizard incomplete"
controls_text = export(msi, "Control")
assert "GNU AFFERO GENERAL PUBLIC LICENSE" in controls_text, "full license text missing"
assert "How to Apply These Terms to Your New Programs" in controls_text, "license text truncated"
summary = subprocess.check_output(["msiinfo", "suminfo", msi], text=True)
assert "x64;" in summary, "Server AMD64 payload needs an x64 MSI"
print("Server package boundary verified; Windows UI and installation still require native acceptance.")
if __name__ == "__main__":
try:
verify(sys.argv[1])
except (AssertionError, KeyError, ValueError, subprocess.CalledProcessError) as error:
sys.exit(f"Server package boundary failed: {error}")