返回 DeepSeek-Reasonix
missing_shell_test.go
根目录 / cmd / reasonix-legacy-migrator / missing_shell_test.go
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
291 lines GO