| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "errors" |
| 5 | "os" |
| 6 | "path/filepath" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | |
| 10 | "reasonix/desktop/internal/update" |
| 11 | "reasonix/internal/repair" |
| 12 | ) |
| 13 | |
| 14 | func TestLoadWindowsStagedReleaseUnitPreflightsAllMembersAndPublishesDesktopLast(t *testing.T) { |
| 15 | staging := t.TempDir() |
| 16 | for name, content := range map[string]string{ |
| 17 | "reasonix-desktop.exe": "desktop-v2", |
| 18 | "reasonix-guard.exe": "guard-v2", |
| 19 | "reasonix-launcher.exe": "launcher-v2", |
| 20 | "reasonix-update-helper.exe": "helper-v2", |
| 21 | "reasonix-cli.exe": "cli-v2", |
| 22 | } { |
| 23 | if err := os.WriteFile(filepath.Join(staging, name), []byte(content), 0o700); err != nil { |
| 24 | t.Fatal(err) |
| 25 | } |
| 26 | } |
| 27 | useTestWindowsPayloadManifest(t, staging, "v2") |
| 28 | installDir := t.TempDir() |
| 29 | claimed := &repair.UpdateTransaction{ |
| 30 | SchemaVersion: 1, |
| 31 | ToVersion: "v2", |
| 32 | TargetKind: "file", |
| 33 | TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"), |
| 34 | Files: []repair.UpdateTransactionFile{ |
| 35 | {TargetPath: filepath.Join(installDir, "reasonix-desktop.exe")}, |
| 36 | {TargetPath: filepath.Join(installDir, "reasonix-guard.exe")}, |
| 37 | {TargetPath: filepath.Join(installDir, "reasonix-launcher.exe")}, |
| 38 | {TargetPath: filepath.Join(installDir, "reasonix-update-helper.exe")}, |
| 39 | {TargetPath: filepath.Join(installDir, "reasonix-cli.exe")}, |
| 40 | {TargetPath: filepath.Join(installDir, "Reasonix.exe")}, |
| 41 | }, |
| 42 | } |
| 43 | |
| 44 | members, err := loadWindowsStagedReleaseUnit(claimed, staging) |
| 45 | if err != nil { |
| 46 | t.Fatal(err) |
| 47 | } |
| 48 | if got := filepath.Base(members[len(members)-1].targetPath); !strings.EqualFold(got, "reasonix-desktop.exe") { |
| 49 | t.Fatalf("last published member = %q, want desktop", got) |
| 50 | } |
| 51 | var published []string |
| 52 | receipts, err := publishLoadedFileUpdateReleaseUnit(claimed, members, func(_ *repair.UpdateTransaction, target string, content []byte, _ os.FileMode) (repair.FileUpdateInstallReceipt, error) { |
| 53 | published = append(published, filepath.Base(target)+"="+string(content)) |
| 54 | return repair.FileUpdateInstallReceipt{TargetPath: target}, nil |
| 55 | }) |
| 56 | if err != nil { |
| 57 | t.Fatal(err) |
| 58 | } |
| 59 | if len(receipts) != len(members) { |
| 60 | t.Fatalf("publish receipts = %d, want %d", len(receipts), len(members)) |
| 61 | } |
| 62 | if got := strings.Join(published, ","); !strings.Contains(got, "Reasonix.exe=launcher-v2") { |
| 63 | t.Fatalf("portable alias did not reuse launcher payload: %s", got) |
| 64 | } |
| 65 | if !strings.HasPrefix(published[len(published)-1], "reasonix-desktop.exe=") { |
| 66 | t.Fatalf("publish order = %v", published) |
| 67 | } |
| 68 | } |
| 69 | |
| 70 | func TestLoadWindowsStagedReleaseUnitRejectsIncompletePayloadBeforePublish(t *testing.T) { |
| 71 | staging := t.TempDir() |
| 72 | if err := os.WriteFile(filepath.Join(staging, "reasonix-desktop.exe"), []byte("desktop-v2"), 0o700); err != nil { |
| 73 | t.Fatal(err) |
| 74 | } |
| 75 | useTestWindowsPayloadManifest(t, staging, "v2") |
| 76 | installDir := t.TempDir() |
| 77 | claimed := &repair.UpdateTransaction{ |
| 78 | SchemaVersion: 1, |
| 79 | ToVersion: "v2", |
| 80 | TargetKind: "file", |
| 81 | TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"), |
| 82 | Files: []repair.UpdateTransactionFile{ |
| 83 | {TargetPath: filepath.Join(installDir, "reasonix-desktop.exe")}, |
| 84 | {TargetPath: filepath.Join(installDir, "reasonix-guard.exe")}, |
| 85 | }, |
| 86 | } |
| 87 | if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err == nil { |
| 88 | t.Fatal("incomplete staged release unit was accepted") |
| 89 | } |
| 90 | } |
| 91 | |
| 92 | func TestValidateWindowsClaimedReleaseUnitRequiresExactTargets(t *testing.T) { |
| 93 | _, complete := completeWindowsStagedReleaseUnitForTest(t, "v2") |
| 94 | for i, file := range complete.Files { |
| 95 | t.Run("missing-"+strings.ToLower(filepath.Base(file.TargetPath)), func(t *testing.T) { |
| 96 | claimed := *complete |
| 97 | claimed.Files = append([]repair.UpdateTransactionFile(nil), complete.Files[:i]...) |
| 98 | claimed.Files = append(claimed.Files, complete.Files[i+1:]...) |
| 99 | if err := validateWindowsClaimedReleaseUnit(&claimed); err == nil || |
| 100 | !strings.Contains(err.Error(), "omits") { |
| 101 | t.Fatalf("missing target error = %v", err) |
| 102 | } |
| 103 | }) |
| 104 | } |
| 105 | t.Run("extra", func(t *testing.T) { |
| 106 | claimed := *complete |
| 107 | claimed.Files = append(append([]repair.UpdateTransactionFile(nil), complete.Files...), |
| 108 | repair.UpdateTransactionFile{TargetPath: filepath.Join(filepath.Dir(complete.TargetPath), "other.exe")}) |
| 109 | if err := validateWindowsClaimedReleaseUnit(&claimed); err == nil || |
| 110 | !strings.Contains(err.Error(), "unexpected") { |
| 111 | t.Fatalf("extra target error = %v", err) |
| 112 | } |
| 113 | }) |
| 114 | t.Run("duplicate", func(t *testing.T) { |
| 115 | claimed := *complete |
| 116 | claimed.Files = append(append([]repair.UpdateTransactionFile(nil), complete.Files...), complete.Files[0]) |
| 117 | if err := validateWindowsClaimedReleaseUnit(&claimed); err == nil || |
| 118 | !strings.Contains(err.Error(), "duplicate") { |
| 119 | t.Fatalf("duplicate target error = %v", err) |
| 120 | } |
| 121 | }) |
| 122 | t.Run("outside", func(t *testing.T) { |
| 123 | claimed := *complete |
| 124 | claimed.Files = append([]repair.UpdateTransactionFile(nil), complete.Files...) |
| 125 | claimed.Files[1].TargetPath = filepath.Join(t.TempDir(), filepath.Base(claimed.Files[1].TargetPath)) |
| 126 | if err := validateWindowsClaimedReleaseUnit(&claimed); err == nil || |
| 127 | !strings.Contains(err.Error(), "outside") { |
| 128 | t.Fatalf("outside target error = %v", err) |
| 129 | } |
| 130 | }) |
| 131 | t.Run("primary", func(t *testing.T) { |
| 132 | claimed := *complete |
| 133 | claimed.TargetPath = filepath.Join(filepath.Dir(complete.TargetPath), "reasonix-guard.exe") |
| 134 | if err := validateWindowsClaimedReleaseUnit(&claimed); err == nil || |
| 135 | !strings.Contains(err.Error(), "primary") { |
| 136 | t.Fatalf("primary target error = %v", err) |
| 137 | } |
| 138 | }) |
| 139 | } |
| 140 | |
| 141 | func TestLoadWindowsStagedReleaseUnitDoesNotCreateMissingPortableAlias(t *testing.T) { |
| 142 | staging := t.TempDir() |
| 143 | for _, name := range update.WindowsPayloadFileNames() { |
| 144 | content := "payload:" + name |
| 145 | if err := os.WriteFile(filepath.Join(staging, name), []byte(content), 0o700); err != nil { |
| 146 | t.Fatal(err) |
| 147 | } |
| 148 | } |
| 149 | useTestWindowsPayloadManifest(t, staging, "v2") |
| 150 | installDir := t.TempDir() |
| 151 | claimed := &repair.UpdateTransaction{ |
| 152 | SchemaVersion: 1, |
| 153 | ToVersion: "v2", |
| 154 | TargetKind: "file", |
| 155 | TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"), |
| 156 | Files: []repair.UpdateTransactionFile{ |
| 157 | {TargetPath: filepath.Join(installDir, "reasonix-desktop.exe")}, |
| 158 | {TargetPath: filepath.Join(installDir, "reasonix-guard.exe")}, |
| 159 | {TargetPath: filepath.Join(installDir, "reasonix-launcher.exe")}, |
| 160 | {TargetPath: filepath.Join(installDir, "reasonix-update-helper.exe")}, |
| 161 | {TargetPath: filepath.Join(installDir, "reasonix-cli.exe")}, |
| 162 | {TargetPath: filepath.Join(installDir, "Reasonix.exe"), MissingBefore: true}, |
| 163 | }, |
| 164 | } |
| 165 | |
| 166 | members, err := loadWindowsStagedReleaseUnit(claimed, staging) |
| 167 | if err != nil { |
| 168 | t.Fatal(err) |
| 169 | } |
| 170 | for _, member := range members { |
| 171 | if strings.EqualFold(filepath.Base(member.targetPath), "Reasonix.exe") { |
| 172 | t.Fatalf("missing portable alias was added to publish set: %+v", members) |
| 173 | } |
| 174 | } |
| 175 | } |
| 176 | |
| 177 | func TestPublishLoadedFileUpdateReleaseUnitStopsOnFirstFailedCompareAndPublish(t *testing.T) { |
| 178 | claimed := &repair.UpdateTransaction{TargetKind: "file"} |
| 179 | members := []stagedFileUpdateMember{ |
| 180 | {targetPath: "guard.exe", content: []byte("guard"), mode: 0o700}, |
| 181 | {targetPath: "desktop.exe", content: []byte("desktop"), mode: 0o700}, |
| 182 | } |
| 183 | var published []string |
| 184 | receipts, err := publishLoadedFileUpdateReleaseUnit(claimed, members, func(_ *repair.UpdateTransaction, target string, _ []byte, _ os.FileMode) (repair.FileUpdateInstallReceipt, error) { |
| 185 | published = append(published, target) |
| 186 | return repair.FileUpdateInstallReceipt{}, errors.New("concurrent recreation") |
| 187 | }) |
| 188 | if err == nil || !strings.Contains(err.Error(), "concurrent recreation") { |
| 189 | t.Fatalf("publish error = %v", err) |
| 190 | } |
| 191 | if len(published) != 1 { |
| 192 | t.Fatalf("published members = %v, want one attempted member", published) |
| 193 | } |
| 194 | if len(receipts) != 0 { |
| 195 | t.Fatalf("failed first publish returned receipts: %+v", receipts) |
| 196 | } |
| 197 | } |
| 198 | |
| 199 | func TestLoadWindowsStagedReleaseUnitRejectsUnverifiedPayloadBeforePublish(t *testing.T) { |
| 200 | staging := t.TempDir() |
| 201 | for _, name := range []string{ |
| 202 | "reasonix-desktop.exe", |
| 203 | "reasonix-guard.exe", |
| 204 | "reasonix-launcher.exe", |
| 205 | "reasonix-update-helper.exe", |
| 206 | "reasonix-cli.exe", |
| 207 | } { |
| 208 | if err := os.WriteFile(filepath.Join(staging, name), []byte(name), 0o700); err != nil { |
| 209 | t.Fatal(err) |
| 210 | } |
| 211 | } |
| 212 | writeWindowsPayloadManifestForTest(t, staging, "v2") |
| 213 | acceptWindowsPayloadManifestForTest(t) |
| 214 | _, claimed := completeWindowsStagedReleaseUnitForTest(t, "v2") |
| 215 | original := readVerifiedWindowsStagedPayloadFn |
| 216 | readVerifiedWindowsStagedPayloadFn = func(string) ([]byte, error) { |
| 217 | return nil, errors.New("payload signature rejected") |
| 218 | } |
| 219 | t.Cleanup(func() { readVerifiedWindowsStagedPayloadFn = original }) |
| 220 | |
| 221 | if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err == nil { |
| 222 | t.Fatal("unsigned staged payload must be rejected before publication") |
| 223 | } |
| 224 | } |
| 225 | |
| 226 | func TestLoadWindowsStagedReleaseUnitReadsEachSourceThroughVerifier(t *testing.T) { |
| 227 | staging := t.TempDir() |
| 228 | for name, content := range map[string]string{ |
| 229 | "reasonix-desktop.exe": "desktop-v2", |
| 230 | "reasonix-guard.exe": "guard-v2", |
| 231 | "reasonix-launcher.exe": "launcher-v2", |
| 232 | "reasonix-update-helper.exe": "helper-v2", |
| 233 | "reasonix-cli.exe": "cli-v2", |
| 234 | } { |
| 235 | if err := os.WriteFile(filepath.Join(staging, name), []byte(content), 0o700); err != nil { |
| 236 | t.Fatal(err) |
| 237 | } |
| 238 | } |
| 239 | writeWindowsPayloadManifestForTest(t, staging, "v2") |
| 240 | acceptWindowsPayloadManifestForTest(t) |
| 241 | _, claimed := completeWindowsStagedReleaseUnitForTest(t, "v2") |
| 242 | var verified []string |
| 243 | original := readVerifiedWindowsStagedPayloadFn |
| 244 | readVerifiedWindowsStagedPayloadFn = func(path string) ([]byte, error) { |
| 245 | verified = append(verified, filepath.Base(path)) |
| 246 | return os.ReadFile(path) |
| 247 | } |
| 248 | t.Cleanup(func() { readVerifiedWindowsStagedPayloadFn = original }) |
| 249 | |
| 250 | if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err != nil { |
| 251 | t.Fatal(err) |
| 252 | } |
| 253 | if len(verified) != len(update.WindowsPayloadFileNames()) { |
| 254 | t.Fatalf("verified payload count = %d, want %d (%v)", len(verified), len(update.WindowsPayloadFileNames()), verified) |
| 255 | } |
| 256 | } |
| 257 | |
| 258 | func TestLoadWindowsStagedReleaseUnitRejectsMissingManifest(t *testing.T) { |
| 259 | staging, claimed := completeWindowsStagedReleaseUnitForTest(t, "v2") |
| 260 | useUnverifiedPayloadReaderForTest(t) |
| 261 | if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err == nil || |
| 262 | !strings.Contains(err.Error(), update.WindowsPayloadManifestName) { |
| 263 | t.Fatalf("missing signed manifest error = %v", err) |
| 264 | } |
| 265 | } |
| 266 | |
| 267 | func TestLoadWindowsStagedReleaseUnitRejectsBadManifestSignature(t *testing.T) { |
| 268 | staging, claimed := completeWindowsStagedReleaseUnitForTest(t, "v2") |
| 269 | writeWindowsPayloadManifestForTest(t, staging, "v2") |
| 270 | useUnverifiedPayloadReaderForTest(t) |
| 271 | if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err == nil || |
| 272 | !strings.Contains(err.Error(), "verify signed release manifest") { |
| 273 | t.Fatalf("bad signed manifest error = %v", err) |
| 274 | } |
| 275 | } |
| 276 | |
| 277 | func TestLoadWindowsStagedReleaseUnitRejectsManifestVersionDrift(t *testing.T) { |
| 278 | staging, claimed := completeWindowsStagedReleaseUnitForTest(t, "v2") |
| 279 | writeWindowsPayloadManifestForTest(t, staging, "v3") |
| 280 | acceptWindowsPayloadManifestForTest(t) |
| 281 | useUnverifiedPayloadReaderForTest(t) |
| 282 | if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err == nil || |
| 283 | !strings.Contains(err.Error(), "identity does not match") { |
| 284 | t.Fatalf("release version drift error = %v", err) |
| 285 | } |
| 286 | } |
| 287 | |
| 288 | func TestLoadWindowsStagedReleaseUnitRejectsManifestMemberHashDrift(t *testing.T) { |
| 289 | staging, claimed := completeWindowsStagedReleaseUnitForTest(t, "v2") |
| 290 | writeWindowsPayloadManifestForTest(t, staging, "v2") |
| 291 | acceptWindowsPayloadManifestForTest(t) |
| 292 | useUnverifiedPayloadReaderForTest(t) |
| 293 | if err := os.WriteFile(filepath.Join(staging, "reasonix-guard.exe"), []byte("tampered"), 0o700); err != nil { |
| 294 | t.Fatal(err) |
| 295 | } |
| 296 | if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err == nil || |
| 297 | !strings.Contains(err.Error(), "does not match the signed release manifest") { |
| 298 | t.Fatalf("release member drift error = %v", err) |
| 299 | } |
| 300 | } |
| 301 | |
| 302 | func completeWindowsStagedReleaseUnitForTest(t *testing.T, version string) (string, *repair.UpdateTransaction) { |
| 303 | t.Helper() |
| 304 | staging := t.TempDir() |
| 305 | for _, name := range update.WindowsPayloadFileNames() { |
| 306 | if err := os.WriteFile(filepath.Join(staging, name), []byte("payload:"+name), 0o700); err != nil { |
| 307 | t.Fatal(err) |
| 308 | } |
| 309 | } |
| 310 | installDir := t.TempDir() |
| 311 | files := make([]repair.UpdateTransactionFile, 0, len(update.WindowsPayloadFileNames())+1) |
| 312 | for _, name := range update.WindowsPayloadFileNames() { |
| 313 | files = append(files, repair.UpdateTransactionFile{TargetPath: filepath.Join(installDir, name)}) |
| 314 | } |
| 315 | files = append(files, repair.UpdateTransactionFile{ |
| 316 | TargetPath: filepath.Join(installDir, "Reasonix.exe"), |
| 317 | MissingBefore: true, |
| 318 | }) |
| 319 | return staging, &repair.UpdateTransaction{ |
| 320 | SchemaVersion: 1, |
| 321 | ToVersion: version, |
| 322 | TargetKind: "file", |
| 323 | TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"), |
| 324 | Files: files, |
| 325 | } |
| 326 | } |
| 327 | |
| 328 | func writeWindowsPayloadManifestForTest(t *testing.T, staging, version string) { |
| 329 | t.Helper() |
| 330 | hashes := make(map[string]string) |
| 331 | for _, name := range update.WindowsPayloadFileNames() { |
| 332 | content, err := os.ReadFile(filepath.Join(staging, name)) |
| 333 | if os.IsNotExist(err) { |
| 334 | content = []byte("missing:" + name) |
| 335 | } else if err != nil { |
| 336 | t.Fatal(err) |
| 337 | } |
| 338 | hashes[name] = update.WindowsPayloadSHA256(content) |
| 339 | } |
| 340 | manifest, err := update.EncodeWindowsPayloadManifest(version, hashes) |
| 341 | if err != nil { |
| 342 | t.Fatal(err) |
| 343 | } |
| 344 | if err := os.WriteFile(filepath.Join(staging, update.WindowsPayloadManifestName), manifest, 0o600); err != nil { |
| 345 | t.Fatal(err) |
| 346 | } |
| 347 | if err := os.WriteFile(filepath.Join(staging, update.WindowsPayloadSignatureName), []byte("test signature"), 0o600); err != nil { |
| 348 | t.Fatal(err) |
| 349 | } |
| 350 | } |
| 351 | |
| 352 | func acceptWindowsPayloadManifestForTest(t *testing.T) { |
| 353 | t.Helper() |
| 354 | original := verifyWindowsPayloadManifestFn |
| 355 | verifyWindowsPayloadManifestFn = func(_, signature []byte) error { |
| 356 | if string(signature) != "test signature" { |
| 357 | return errors.New("unexpected test signature") |
| 358 | } |
| 359 | return nil |
| 360 | } |
| 361 | t.Cleanup(func() { verifyWindowsPayloadManifestFn = original }) |
| 362 | } |
| 363 | |
| 364 | func useTestWindowsPayloadManifest(t *testing.T, staging, version string) { |
| 365 | t.Helper() |
| 366 | writeWindowsPayloadManifestForTest(t, staging, version) |
| 367 | acceptWindowsPayloadManifestForTest(t) |
| 368 | useUnverifiedPayloadReaderForTest(t) |
| 369 | } |
| 370 | |
| 371 | func useUnverifiedPayloadReaderForTest(t *testing.T) { |
| 372 | t.Helper() |
| 373 | original := readVerifiedWindowsStagedPayloadFn |
| 374 | readVerifiedWindowsStagedPayloadFn = os.ReadFile |
| 375 | t.Cleanup(func() { readVerifiedWindowsStagedPayloadFn = original }) |
| 376 | } |
| 377 |