| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "os" |
| 6 | "os/exec" |
| 7 | "path/filepath" |
| 8 | "strings" |
| 9 | "testing" |
| 10 | ) |
| 11 | |
| 12 | func writePortableFixture(t *testing.T, dir, name, content string) { |
| 13 | t.Helper() |
| 14 | if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o755); err != nil { |
| 15 | t.Fatal(err) |
| 16 | } |
| 17 | } |
| 18 | |
| 19 | func TestVerifyWindowsPortableVersionedLayout(t *testing.T) { |
| 20 | verify := filepath.Join("..", "scripts", "verify-windows-portable.sh") |
| 21 | good := t.TempDir() |
| 22 | // versioned-v1 root entries |
| 23 | writePortableFixture(t, good, "Reasonix.exe", "launcher") |
| 24 | writePortableFixture(t, good, "reasonix-cli.exe", "cli-entry") |
| 25 | ver := filepath.Join(good, "versions", "v1.20.0") |
| 26 | if err := os.MkdirAll(ver, 0o755); err != nil { |
| 27 | t.Fatal(err) |
| 28 | } |
| 29 | for _, name := range []string{"reasonix-desktop.exe", "reasonix-cli.exe", "reasonix-update-helper.exe"} { |
| 30 | writePortableFixture(t, ver, name, name) |
| 31 | } |
| 32 | // The Electron bundle is the app/ tree member of the active version. |
| 33 | appDir := filepath.Join(ver, "app") |
| 34 | if err := os.MkdirAll(filepath.Join(appDir, "resources", "app"), 0o755); err != nil { |
| 35 | t.Fatal(err) |
| 36 | } |
| 37 | if err := os.MkdirAll(filepath.Join(appDir, "resources", "bin"), 0o755); err != nil { |
| 38 | t.Fatal(err) |
| 39 | } |
| 40 | writePortableFixture(t, appDir, "Reasonix.exe", "shell") |
| 41 | writePortableFixture(t, filepath.Join(appDir, "resources"), "app.asar", "asar") |
| 42 | writePortableFixture(t, filepath.Join(appDir, "resources"), "build.json", "{}") |
| 43 | writePortableFixture(t, filepath.Join(appDir, "resources", "app"), "index.html", "<html></html>") |
| 44 | writePortableFixture(t, filepath.Join(appDir, "resources", "bin"), "reasonix-cli-launcher.exe", "cli-entry") |
| 45 | if err := os.WriteFile(filepath.Join(good, "current.json"), []byte(`{ |
| 46 | "schemaVersion": 1, |
| 47 | "activeVersion": "v1.20.0", |
| 48 | "activeDir": "versions/v1.20.0" |
| 49 | } |
| 50 | `), 0o644); err != nil { |
| 51 | t.Fatal(err) |
| 52 | } |
| 53 | if out, err := exec.Command("bash", verify, good).CombinedOutput(); err != nil { |
| 54 | t.Fatalf("valid versioned portable failed: %v\n%s", err, out) |
| 55 | } |
| 56 | writePortableFixture(t, good, "reasonix-launcher.exe", "launcher") |
| 57 | if out, err := exec.Command("bash", verify, good).CombinedOutput(); err == nil { |
| 58 | t.Fatalf("canonical package accepted legacy entry: %s", out) |
| 59 | } |
| 60 | if out, err := exec.Command("bash", verify, good, "legacy-dual").CombinedOutput(); err != nil { |
| 61 | t.Fatalf("explicit legacy package rejected: %v\n%s", err, out) |
| 62 | } |
| 63 | writePortableFixture(t, good, "reasonix-launcher.exe", "mismatch") |
| 64 | if out, err := exec.Command("bash", verify, good, "legacy-dual").CombinedOutput(); err == nil { |
| 65 | t.Fatalf("legacy package accepted mismatched entries: %s", out) |
| 66 | } |
| 67 | if err := os.Remove(filepath.Join(good, "reasonix-launcher.exe")); err != nil { |
| 68 | t.Fatal(err) |
| 69 | } |
| 70 | for _, mode := range []string{"legacy-dual", "auto"} { |
| 71 | if out, err := exec.Command("bash", verify, good, mode).CombinedOutput(); err == nil { |
| 72 | t.Fatalf("accepted mode %s: %s", mode, out) |
| 73 | } |
| 74 | } |
| 75 | writePortableFixture(t, good, "unexpected.EXE", "extra") |
| 76 | if out, err := exec.Command("bash", verify, good).CombinedOutput(); err == nil { |
| 77 | t.Fatalf("accepted extra executable: %s", out) |
| 78 | } |
| 79 | if err := os.Remove(filepath.Join(good, "unexpected.EXE")); err != nil { |
| 80 | t.Fatal(err) |
| 81 | } |
| 82 | if err := os.WriteFile(filepath.Join(good, "current.json"), []byte("broken"), 0o644); err != nil { |
| 83 | t.Fatal(err) |
| 84 | } |
| 85 | if out, err := exec.Command("bash", verify, good).CombinedOutput(); err == nil { |
| 86 | t.Fatalf("accepted damaged pointer: %s", out) |
| 87 | } |
| 88 | |
| 89 | // Flat Guard layout must be rejected. |
| 90 | flat := t.TempDir() |
| 91 | for _, name := range []string{ |
| 92 | "reasonix-desktop.exe", |
| 93 | "reasonix-guard.exe", |
| 94 | "reasonix-update-helper.exe", |
| 95 | "reasonix-launcher.exe", |
| 96 | "Reasonix.exe", |
| 97 | "reasonix-cli.exe", |
| 98 | } { |
| 99 | writePortableFixture(t, flat, name, name) |
| 100 | } |
| 101 | if out, err := exec.Command("bash", verify, flat).CombinedOutput(); err == nil { |
| 102 | t.Fatalf("flat portable with guard should fail, output=%s", out) |
| 103 | } |
| 104 | } |
| 105 | |
| 106 | func TestDesktopPackagesPreserveNativePlatformLaunchers(t *testing.T) { |
| 107 | buildData, err := os.ReadFile("../scripts/desktop-build.sh") |
| 108 | if err != nil { |
| 109 | t.Fatal(err) |
| 110 | } |
| 111 | build := string(buildData) |
| 112 | for _, want := range []string{ |
| 113 | `CLINAME="reasonix"`, |
| 114 | `WINDOWS_CLINAME="reasonix-cli"`, |
| 115 | `./cmd/reasonix`, |
| 116 | `./cmd/reasonix-legacy-migrator`, |
| 117 | `./cmd/reasonix-launcher`, |
| 118 | `cp "$cli_out" "$app/Contents/Resources/service/$CLINAME"`, |
| 119 | `ln -s "../Resources/service/$BINNAME" "$app/Contents/MacOS/$BINNAME"`, |
| 120 | `macOS bundle must not include $GUARDNAME`, |
| 121 | `[ "$bundle_executable" = "$APPNAME" ]`, |
| 122 | `Print :CFBundleIconFile`, |
| 123 | `darwin_icon="$ROOT/desktop/build/darwin/icon.icns"`, |
| 124 | `[ -s "$app/Contents/Resources/$bundle_icon" ]`, |
| 125 | `macOS bundle icon is missing: $bundle_icon`, |
| 126 | `-H windowsgui`, |
| 127 | `stamp_windows_executable "$guard_out" "Reasonix Legacy Migrator"`, |
| 128 | `stamp_windows_executable "$launcher_out" "Reasonix Launcher"`, |
| 129 | `stamp_windows_executable "$installer_dir/$UPDATE_HELPER" "Reasonix Update Helper"`, |
| 130 | `payload_dir="$ROOT/desktop/build/windows/signing-payload"`, |
| 131 | `for name in "$BINNAME.exe" "$GUARDNAME.exe" "$LAUNCHERNAME.exe" "$UPDATE_HELPER" "$WINDOWS_CLINAME.exe" "reasonix-uninstall.exe"; do`, |
| 132 | `cp "$installer_dir/$name" "$payload_dir/$name"`, |
| 133 | `cp -R "$installer_dir/app" "$payload_dir/app"`, |
| 134 | `node "$ROOT/desktop/packaging/signing-files.mjs" "$payload_dir"`, |
| 135 | `"$ROOT/scripts/package-windows-desktop.sh" "$arch" "$payload_dir"`, |
| 136 | `"$BINNAME" "$LAUNCHERNAME" "$GUARDNAME" "$CLINAME"`, |
| 137 | `Exec=reasonix-launcher`, |
| 138 | } { |
| 139 | if !strings.Contains(build, want) { |
| 140 | t.Errorf("desktop-build.sh missing packaging contract %q", want) |
| 141 | } |
| 142 | } |
| 143 | if strings.Contains(build, `Set :CFBundleExecutable $GUARDNAME`) { |
| 144 | t.Fatal("macOS package must not replace the Electron bundle executable with Guard") |
| 145 | } |
| 146 | launcherStamp := strings.Index(build, `stamp_windows_executable "$launcher_out" "Reasonix Launcher"`) |
| 147 | payloadCopy := strings.Index(build, `cp "$installer_dir/$name" "$payload_dir/$name"`) |
| 148 | if launcherStamp < 0 || payloadCopy < 0 || launcherStamp > payloadCopy { |
| 149 | t.Fatalf("Windows payload must copy the already-stamped launcher (stamp=%d copy=%d)", launcherStamp, payloadCopy) |
| 150 | } |
| 151 | if strings.Contains(build, `"$staging/$CLINAME.exe"`) { |
| 152 | t.Fatal("Windows package must not collide reasonix.exe with the Reasonix.exe launcher") |
| 153 | } |
| 154 | darwinIconCheck := strings.Index(build, `[ -s "$app/Contents/Resources/$bundle_icon" ]`) |
| 155 | developerIDSign := strings.Index(build, `node "$ROOT/desktop/packaging/sign-macos.mjs" "$app" "$identity"`) |
| 156 | if darwinIconCheck < 0 || developerIDSign < 0 || darwinIconCheck > developerIDSign { |
| 157 | t.Fatalf("macOS bundle icon must be verified before signing (icon=%d sign=%d)", darwinIconCheck, developerIDSign) |
| 158 | } |
| 159 | for _, copyCommand := range []string{ |
| 160 | `cp "$service_out" "$app/Contents/Resources/service/$BINNAME"`, |
| 161 | `ln -s "../Resources/service/$BINNAME" "$app/Contents/MacOS/$BINNAME"`, |
| 162 | `cp "$cli_out" "$app/Contents/Resources/service/$CLINAME"`, |
| 163 | } { |
| 164 | if index := strings.Index(build, copyCommand); index < 0 || index > developerIDSign { |
| 165 | t.Errorf("macOS sidecar must be installed before signing: %s", copyCommand) |
| 166 | } |
| 167 | } |
| 168 | |
| 169 | for _, want := range []string{ |
| 170 | `dpkg-deb --field "$deb_path" Package | grep -x 'reasonix-desktop'`, |
| 171 | `usr/lib/reasonix/reasonix-update-helper`, |
| 172 | `usr/share/polkit-1/actions/io.reasonix.desktop.update.policy`, |
| 173 | } { |
| 174 | if !strings.Contains(build, want) { |
| 175 | t.Errorf("desktop-build.sh missing Linux deb helper contract %q", want) |
| 176 | } |
| 177 | } |
| 178 | for _, unsafe := range []string{ |
| 179 | `dpkg-deb --field "$deb_path" Package | grep -qx`, |
| 180 | `dpkg-deb --field "$deb_path" Version | grep -qx`, |
| 181 | `dpkg-deb --field "$deb_path" Depends | grep -Fq`, |
| 182 | `dpkg-deb --contents "$deb_path" | grep -Eq`, |
| 183 | } { |
| 184 | if strings.Contains(build, unsafe) { |
| 185 | t.Errorf("desktop-build.sh uses early-exit grep under pipefail: %q", unsafe) |
| 186 | } |
| 187 | } |
| 188 | |
| 189 | desktopEntry, err := os.ReadFile("build/linux/reasonix.desktop") |
| 190 | if err != nil { |
| 191 | t.Fatal(err) |
| 192 | } |
| 193 | if !strings.Contains(string(desktopEntry), "Exec=reasonix-launcher") || strings.Contains(string(desktopEntry), "reasonix-guard") { |
| 194 | t.Fatal("Linux desktop entry must launch the permanent launcher without Guard") |
| 195 | } |
| 196 | nfpmData, err := os.ReadFile("build/linux/nfpm.yaml") |
| 197 | if err != nil { |
| 198 | t.Fatal(err) |
| 199 | } |
| 200 | nfpm := string(nfpmData) |
| 201 | if !strings.Contains(nfpm, "dst: /usr/bin/reasonix-launcher") || strings.Contains(nfpm, "dst: /usr/bin/reasonix-guard") { |
| 202 | t.Fatal("Linux deb must install the permanent launcher and must not persist Guard") |
| 203 | } |
| 204 | if !strings.Contains(nfpm, "postinstall: ./build/linux/postinstall.sh") { |
| 205 | t.Fatal("Linux deb must refresh native desktop icon caches after install and upgrade") |
| 206 | } |
| 207 | if !strings.Contains(nfpm, "dst: /usr/share/applications/reasonix.desktop") { |
| 208 | t.Fatal("Linux deb must install the Reasonix desktop entry") |
| 209 | } |
| 210 | postInstall, err := os.ReadFile("build/linux/postinstall.sh") |
| 211 | if err != nil { |
| 212 | t.Fatal(err) |
| 213 | } |
| 214 | for _, want := range []string{"gtk-update-icon-cache", "update-desktop-database"} { |
| 215 | if !strings.Contains(string(postInstall), want) { |
| 216 | t.Errorf("Linux post-install icon repair missing %q", want) |
| 217 | } |
| 218 | } |
| 219 | |
| 220 | windowsData, err := os.ReadFile("build/windows/installer/project.nsi") |
| 221 | if err != nil { |
| 222 | t.Fatal(err) |
| 223 | } |
| 224 | if !bytes.HasPrefix(windowsData, []byte{0xef, 0xbb, 0xbf}) { |
| 225 | t.Fatal("Windows installer script must have a UTF-8 BOM so native makensis accepts localized strings") |
| 226 | } |
| 227 | windows := string(windowsData) |
| 228 | for _, want := range []string{ |
| 229 | `File "/oname=${REASONIX_CLI}" "${REASONIX_CLI}"`, |
| 230 | `!define REASONIX_UNINST_FINALIZE 'cmd.exe /C copy /Y "%1" "reasonix-uninstall.exe" >NUL'`, |
| 231 | `!uninstfinalize '${REASONIX_UNINST_FINALIZE}'`, |
| 232 | `File "/oname=uninstall.exe" "${ARG_REASONIX_SIGNED_UNINSTALLER}"`, |
| 233 | `StrCpy $R9 "$INSTDIR\versions\.installer-${REASONIX_VERSION_TAG}-$R8"`, |
| 234 | `File "/oname=${REASONIX_LAYOUT_INSTALLER}" "${REASONIX_GUARD}"`, |
| 235 | `nsExec::ExecToLog /OEM`, |
| 236 | `Reasonix layout activator output:`, |
| 237 | `--activate-staging "$R9" --no-relaunch`, |
| 238 | `CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${REASONIX_PORTABLE_ENTRY}" "" "$INSTDIR\${REASONIX_PORTABLE_ENTRY}" 0`, |
| 239 | `CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${REASONIX_PORTABLE_ENTRY}" "" "$INSTDIR\${REASONIX_PORTABLE_ENTRY}" 0`, |
| 240 | `StrCmp $ReasonixStageMode "1" reasonix_stage_payload`, |
| 241 | `File "/oname=${REASONIX_GUARD}" "${REASONIX_GUARD}"`, |
| 242 | } { |
| 243 | if !strings.Contains(windows, want) { |
| 244 | t.Errorf("Windows installer missing versioned-layout contract %q", want) |
| 245 | } |
| 246 | } |
| 247 | if strings.Contains(windows, `FileOpen $0 "$INSTDIR\current.json" w`) || |
| 248 | strings.Contains(windows, `SetOutPath "$INSTDIR\versions\v${INFO_PRODUCTVERSION}"`) { |
| 249 | t.Fatal("normal Windows installer must not write the live version or current.json in place") |
| 250 | } |
| 251 | for _, leak := range []string{ |
| 252 | `$INSTDIR\versions\v${INFO_PRODUCTVERSION}`, |
| 253 | `.installer-v${INFO_PRODUCTVERSION}`, |
| 254 | `--version "v${INFO_PRODUCTVERSION}"`, |
| 255 | } { |
| 256 | if strings.Contains(windows, leak) { |
| 257 | t.Errorf("numeric Windows resource version leaked into release identity: %q", leak) |
| 258 | } |
| 259 | } |
| 260 | if strings.Contains(windows, `ExecWait '"$PLUGINSDIR\${REASONIX_LAYOUT_INSTALLER}"`) { |
| 261 | t.Fatal("Windows installer must not discard layout activator stdout/stderr") |
| 262 | } |
| 263 | if strings.Contains(windows, `CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${REASONIX_LAUNCHER}" "" "$INSTDIR\versions\v${INFO_PRODUCTVERSION}\${PRODUCT_EXECUTABLE}" 0`) || |
| 264 | strings.Contains(windows, `CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${REASONIX_LAUNCHER}" "" "$INSTDIR\versions\v${INFO_PRODUCTVERSION}\${PRODUCT_EXECUTABLE}" 0`) { |
| 265 | t.Fatal("Windows shortcut icon must not point into a version directory that retention removes") |
| 266 | } |
| 267 | } |
| 268 |