| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "errors" |
| 6 | "fmt" |
| 7 | "os" |
| 8 | "os/exec" |
| 9 | "path/filepath" |
| 10 | "runtime" |
| 11 | "strings" |
| 12 | "testing" |
| 13 | "time" |
| 14 | |
| 15 | "reasonix/internal/config" |
| 16 | "reasonix/internal/installlayout" |
| 17 | "reasonix/internal/repair" |
| 18 | ) |
| 19 | |
| 20 | const ( |
| 21 | legacyChildModeEnv = "REASONIX_LEGACY_MIGRATOR_TEST_CHILD" |
| 22 | legacyChildRootEnv = "REASONIX_LEGACY_MIGRATOR_TEST_ROOT" |
| 23 | legacyFromVersion = "v1.19.2" |
| 24 | legacyToVersion = "v1.39.4" |
| 25 | childShellMissing = 3 |
| 26 | ) |
| 27 | |
| 28 | func writeShellTree(t *testing.T, root, label string) { |
| 29 | t.Helper() |
| 30 | for _, name := range installlayout.ShellRequiredNames(runtime.GOOS) { |
| 31 | p := filepath.Join(root, filepath.FromSlash(name)) |
| 32 | if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { |
| 33 | t.Fatal(err) |
| 34 | } |
| 35 | if err := os.WriteFile(p, []byte(label+"-"+name), 0o755); err != nil { |
| 36 | t.Fatal(err) |
| 37 | } |
| 38 | } |
| 39 | } |
| 40 | |
| 41 | func snapshotFlat(t *testing.T, root string) map[string]string { |
| 42 | t.Helper() |
| 43 | entries, err := os.ReadDir(root) |
| 44 | if err != nil { |
| 45 | t.Fatal(err) |
| 46 | } |
| 47 | out := map[string]string{} |
| 48 | for _, entry := range entries { |
| 49 | if !entry.Type().IsRegular() { |
| 50 | out[entry.Name()] = "<dir>" |
| 51 | continue |
| 52 | } |
| 53 | data, err := os.ReadFile(filepath.Join(root, entry.Name())) |
| 54 | if err != nil { |
| 55 | t.Fatal(err) |
| 56 | } |
| 57 | out[entry.Name()] = string(data) |
| 58 | } |
| 59 | return out |
| 60 | } |
| 61 | |
| 62 | func assertNothingCommitted(t *testing.T, root string) { |
| 63 | t.Helper() |
| 64 | if _, err := os.Lstat(filepath.Join(root, installlayout.CurrentFileName)); !os.IsNotExist(err) { |
| 65 | t.Fatalf("current.json written for a release unit without a shell: %v", err) |
| 66 | } |
| 67 | if _, err := os.Lstat(filepath.Join(root, "versions")); !os.IsNotExist(err) { |
| 68 | t.Fatalf("versions/ created for a release unit without a shell: %v", err) |
| 69 | } |
| 70 | } |
| 71 | |
| 72 | func assertRefusalLogged(t *testing.T, want ...string) { |
| 73 | t.Helper() |
| 74 | data, err := os.ReadFile(filepath.Join(config.ReasonixHomeDir(), "desktop-shell", "logs", migrateLogName)) |
| 75 | if err != nil { |
| 76 | t.Fatalf("refusal was not logged: %v", err) |
| 77 | } |
| 78 | for _, w := range append([]string{"code=shell_missing"}, want...) { |
| 79 | if !strings.Contains(string(data), w) { |
| 80 | t.Fatalf("refusal log %q lacks %q", data, w) |
| 81 | } |
| 82 | } |
| 83 | } |
| 84 | |
| 85 | func TestMigrateRefusesFlatUnitWithoutShell(t *testing.T) { |
| 86 | cases := map[string]func(t *testing.T, root string){ |
| 87 | "absent": func(*testing.T, string) {}, |
| 88 | "incomplete": func(t *testing.T, root string) { |
| 89 | p := filepath.Join(root, installlayout.AppShellDirName, "resources", "app.asar") |
| 90 | if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { |
| 91 | t.Fatal(err) |
| 92 | } |
| 93 | if err := os.WriteFile(p, []byte("asar"), 0o644); err != nil { |
| 94 | t.Fatal(err) |
| 95 | } |
| 96 | }, |
| 97 | } |
| 98 | for name, shell := range cases { |
| 99 | t.Run(name, func(t *testing.T) { |
| 100 | t.Setenv("REASONIX_HOME", t.TempDir()) |
| 101 | root := t.TempDir() |
| 102 | writeFlatUnit(t, root, "new") |
| 103 | if err := os.WriteFile(filepath.Join(root, "reasonix-guard"), []byte("migrator"), 0o755); err != nil { |
| 104 | t.Fatal(err) |
| 105 | } |
| 106 | shell(t, root) |
| 107 | before := snapshotFlat(t, root) |
| 108 | |
| 109 | err := migrateWithRelaunch(root, legacyToVersion, false) |
| 110 | if !errors.Is(err, errShellMissing) { |
| 111 | t.Fatalf("migrate = %v, want errShellMissing", err) |
| 112 | } |
| 113 | assertNothingCommitted(t, root) |
| 114 | if after := snapshotFlat(t, root); fmt.Sprint(after) != fmt.Sprint(before) { |
| 115 | t.Fatalf("install root changed on refusal:\nbefore %v\nafter %v", before, after) |
| 116 | } |
| 117 | assertRefusalLogged(t, "rolled_back=false") |
| 118 | }) |
| 119 | } |
| 120 | } |
| 121 | |
| 122 | // legacyUpdatedInstall reproduces what a 1.18–1.19.2 updater leaves behind: it |
| 123 | // runs the updater's own transaction steps from inside the install, publishing |
| 124 | // only desktop, CLI and the migrator under the Guard name. |
| 125 | func legacyUpdatedInstall(t *testing.T, withShell bool) (root string, oldDesktop []byte) { |
| 126 | t.Helper() |
| 127 | if runtime.GOOS == "windows" { |
| 128 | t.Skip("the file-by-file legacy updater is the Unix portable path") |
| 129 | } |
| 130 | t.Setenv("REASONIX_HOME", t.TempDir()) |
| 131 | self, err := os.Executable() |
| 132 | if err != nil { |
| 133 | t.Fatal(err) |
| 134 | } |
| 135 | oldDesktop, err = os.ReadFile(self) |
| 136 | if err != nil { |
| 137 | t.Fatal(err) |
| 138 | } |
| 139 | root = t.TempDir() |
| 140 | for name, body := range map[string][]byte{ |
| 141 | "reasonix-desktop": oldDesktop, |
| 142 | "reasonix-guard": []byte("old-guard"), |
| 143 | "reasonix": []byte("old-cli"), |
| 144 | } { |
| 145 | if err := os.WriteFile(filepath.Join(root, name), body, 0o755); err != nil { |
| 146 | t.Fatal(err) |
| 147 | } |
| 148 | } |
| 149 | if withShell { |
| 150 | writeShellTree(t, root, "new") |
| 151 | } |
| 152 | if code, out := runLegacyChildProcess(t, filepath.Join(root, "reasonix-desktop"), "legacy-update", root); code != 0 { |
| 153 | t.Fatalf("legacy updater child exited %d: %s", code, out) |
| 154 | } |
| 155 | if got, _ := os.ReadFile(filepath.Join(root, "reasonix-desktop")); string(got) != "new-desktop" { |
| 156 | t.Fatalf("legacy updater did not publish the new desktop: %q", got) |
| 157 | } |
| 158 | return root, oldDesktop |
| 159 | } |
| 160 | |
| 161 | func runLegacyChildProcess(t *testing.T, exe, mode, root string) (int, string) { |
| 162 | t.Helper() |
| 163 | cmd := exec.Command(exe, "-test.run=^$") |
| 164 | cmd.Env = append(os.Environ(), legacyChildModeEnv+"="+mode, legacyChildRootEnv+"="+root) |
| 165 | var out bytes.Buffer |
| 166 | cmd.Stdout, cmd.Stderr = &out, &out |
| 167 | err := cmd.Run() |
| 168 | var exit *exec.ExitError |
| 169 | if errors.As(err, &exit) { |
| 170 | return exit.ExitCode(), out.String() |
| 171 | } |
| 172 | if err != nil { |
| 173 | t.Fatalf("run %s child: %v", mode, err) |
| 174 | } |
| 175 | return 0, out.String() |
| 176 | } |
| 177 | |
| 178 | func TestLegacyUpdaterWithoutShellRestoresPreviousRelease(t *testing.T) { |
| 179 | root, oldDesktop := legacyUpdatedInstall(t, false) |
| 180 | code, out := runLegacyChildProcess(t, filepath.Join(root, "reasonix-guard"), "migrate", root) |
| 181 | if code != childShellMissing { |
| 182 | t.Fatalf("migrator child exited %d, want errShellMissing: %s", code, out) |
| 183 | } |
| 184 | assertNothingCommitted(t, root) |
| 185 | for name, want := range map[string][]byte{ |
| 186 | "reasonix-desktop": oldDesktop, |
| 187 | "reasonix-guard": []byte("old-guard"), |
| 188 | "reasonix": []byte("old-cli"), |
| 189 | } { |
| 190 | if got, err := os.ReadFile(filepath.Join(root, name)); err != nil || !bytes.Equal(got, want) { |
| 191 | t.Fatalf("%s was not restored to the previous release (err %v)", name, err) |
| 192 | } |
| 193 | } |
| 194 | if _, err := os.Lstat(filepath.Join(root, installlayout.LauncherBinaryName())); !os.IsNotExist(err) { |
| 195 | t.Fatalf("thin launcher installed beside a restored legacy release: %v", err) |
| 196 | } |
| 197 | if repair.PendingUpdateExists() { |
| 198 | t.Fatal("legacy transaction still pending after rollback") |
| 199 | } |
| 200 | assertRefusalLogged(t, "rolled_back=true") |
| 201 | } |
| 202 | |
| 203 | func TestLegacyUpdaterWithShellStillCommitsTransaction(t *testing.T) { |
| 204 | root, _ := legacyUpdatedInstall(t, true) |
| 205 | if code, out := runLegacyChildProcess(t, filepath.Join(root, "reasonix-guard"), "migrate", root); code != 0 { |
| 206 | t.Fatalf("migrator child exited %d: %s", code, out) |
| 207 | } |
| 208 | ptr, err := installlayout.ReadCurrent(root) |
| 209 | if err != nil || ptr.ActiveVersion != legacyToVersion { |
| 210 | t.Fatalf("pointer=%+v err=%v", ptr, err) |
| 211 | } |
| 212 | desktop, err := installlayout.ActiveDesktopPath(root) |
| 213 | if err != nil { |
| 214 | t.Fatal(err) |
| 215 | } |
| 216 | if got, _ := os.ReadFile(desktop); string(got) != "new-desktop" { |
| 217 | t.Fatalf("active desktop = %q", got) |
| 218 | } |
| 219 | if !installlayout.HasActiveShell(root) { |
| 220 | t.Fatal("active version lost its shell") |
| 221 | } |
| 222 | if repair.PendingUpdateExists() { |
| 223 | t.Fatal("legacy transaction was not committed") |
| 224 | } |
| 225 | } |
| 226 | |
| 227 | func runLegacyChild(mode string) int { |
| 228 | root := os.Getenv(legacyChildRootEnv) |
| 229 | var err error |
| 230 | switch mode { |
| 231 | case "legacy-update": |
| 232 | err = applyLegacyUpdate(root) |
| 233 | case "migrate": |
| 234 | err = migrateWithRelaunch(root, legacyToVersion, false) |
| 235 | if errors.Is(err, errShellMissing) { |
| 236 | fmt.Fprintln(os.Stderr, err) |
| 237 | return childShellMissing |
| 238 | } |
| 239 | default: |
| 240 | err = fmt.Errorf("unknown child mode %q", mode) |
| 241 | } |
| 242 | if err != nil { |
| 243 | fmt.Fprintln(os.Stderr, err) |
| 244 | return 1 |
| 245 | } |
| 246 | return 0 |
| 247 | } |
| 248 | |
| 249 | // applyLegacyUpdate follows applyLinux from v1.19.2 desktop/updater.go step |
| 250 | // for step, with the running desktop as the updater. |
| 251 | func applyLegacyUpdate(root string) error { |
| 252 | exe, err := os.Executable() |
| 253 | if err != nil { |
| 254 | return err |
| 255 | } |
| 256 | migrator, err := os.ReadFile(exe) |
| 257 | if err != nil { |
| 258 | return err |
| 259 | } |
| 260 | desktop := filepath.Join(root, "reasonix-desktop") |
| 261 | guard := filepath.Join(root, "reasonix-guard") |
| 262 | cli := filepath.Join(root, "reasonix") |
| 263 | tx, err := repair.PrepareFileUpdate(legacyFromVersion, legacyToVersion, desktop, guard, cli) |
| 264 | if err != nil { |
| 265 | return fmt.Errorf("prepare: %w", err) |
| 266 | } |
| 267 | claimed, release, err := repair.ClaimPendingFileUpdateExact(tx.ToVersion, tx.CreatedAt, repair.UpdateTransactionID(tx), desktop, []string{desktop, guard, cli}, 2*time.Minute) |
| 268 | if err != nil { |
| 269 | return fmt.Errorf("claim: %w", err) |
| 270 | } |
| 271 | defer release() |
| 272 | if err := repair.MarkUpdateApplyFailedExact(claimed, "Linux update publish did not complete"); err != nil { |
| 273 | return err |
| 274 | } |
| 275 | var receipts []repair.FileUpdateInstallReceipt |
| 276 | for _, member := range []struct { |
| 277 | path string |
| 278 | body []byte |
| 279 | }{{cli, []byte("new-cli")}, {guard, migrator}, {desktop, []byte("new-desktop")}} { |
| 280 | receipt, err := repair.PublishClaimedFileUpdateMemberExact(claimed, member.path, member.body, 0o700) |
| 281 | if err != nil { |
| 282 | return fmt.Errorf("publish %s: %w", filepath.Base(member.path), err) |
| 283 | } |
| 284 | receipts = append(receipts, receipt) |
| 285 | } |
| 286 | if _, err := repair.RecordClaimedFileUpdateInstalled(claimed, receipts...); err != nil { |
| 287 | return fmt.Errorf("record installed: %w", err) |
| 288 | } |
| 289 | return repair.ClearUpdateApplyFailureExact(claimed) |
| 290 | } |
| 291 |