返回 DeepSeek-Reasonix
guard_packaging_test.go
根目录 / desktop / guard_packaging_test.go
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
268 lines GO