返回 DeepSeek-Reasonix
update_debris_test.go
根目录 / internal / repair / update_debris_test.go
1 package repair
2
3 import (
4 "encoding/json"
5 "os"
6 "path/filepath"
7 "strings"
8 "testing"
9 "time"
10 )
11
12 // writePendingUpdateRaw drops arbitrary bytes where the transaction lives.
13 func writePendingUpdateRaw(t *testing.T, body string) string {
14 t.Helper()
15 path := PendingUpdatePath()
16 if path == "" {
17 t.Fatal("pending update path unavailable")
18 }
19 if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
20 t.Fatal(err)
21 }
22 if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
23 t.Fatal(err)
24 }
25 return path
26 }
27
28 func quarantinedCopies(t *testing.T) []string {
29 t.Helper()
30 path := PendingUpdatePath()
31 entries, err := os.ReadDir(filepath.Dir(path))
32 if err != nil {
33 t.Fatal(err)
34 }
35 var out []string
36 prefix := filepath.Base(path) + ".unusable-"
37 for _, entry := range entries {
38 if strings.HasPrefix(entry.Name(), prefix) {
39 out = append(out, entry.Name())
40 }
41 }
42 return out
43 }
44
45 // A marker that cannot describe a recoverable transaction used to block every
46 // future update forever: preparation refused over it and reconciliation could
47 // not act on it, so neither retrying nor reinstalling helped (#7342).
48 func TestPrepareUpdateRecoversFromUnusablePendingTransaction(t *testing.T) {
49 cases := []struct {
50 name string
51 body string
52 }{
53 {"truncated write", `{"schema_version":1,"to_version":"v2"`},
54 {"not json at all", "\x00\x00garbage"},
55 {"wrong types", `{"schema_version":"one"}`},
56 {"empty file", ""},
57 {"no target release", `{"schema_version":1,"to_version":""}`},
58 {"missing identity", `{"schema_version":1,"to_version":"v2"}`},
59 {"unparseable creation time", `{"schema_version":1,"to_version":"v2","platform":"test","created_at":"yesterday"}`},
60 }
61 for _, tc := range cases {
62 t.Run(tc.name, func(t *testing.T) {
63 home := t.TempDir()
64 t.Setenv("REASONIX_HOME", home)
65 target := filepath.Join(t.TempDir(), "reasonix-desktop")
66 originalExecutable := repairExecutable
67 repairExecutable = func() (string, error) { return filepath.Join(filepath.Dir(target), "reasonix-guard"), nil }
68 t.Cleanup(func() { repairExecutable = originalExecutable })
69 if err := os.WriteFile(target, []byte("old"), 0o700); err != nil {
70 t.Fatal(err)
71 }
72 writePendingUpdateRaw(t, tc.body)
73
74 if _, err := PrepareFileUpdate("v1", "v2", target); err != nil {
75 t.Fatalf("PrepareFileUpdate over an unusable transaction: %v", err)
76 }
77 if got := quarantinedCopies(t); len(got) != 1 {
78 t.Fatalf("quarantined copies = %v, want the unusable marker preserved exactly once", got)
79 }
80 tx, err := ReadPendingUpdate()
81 if err != nil {
82 t.Fatalf("ReadPendingUpdate after recovery: %v", err)
83 }
84 if tx.ToVersion != "v2" {
85 t.Fatalf("pending transaction = %+v, want the newly prepared one", tx)
86 }
87 })
88 }
89 }
90
91 // The guard still has to hold for a transaction that can actually be recovered:
92 // preparing over one would overwrite the fixed backup paths it still owns.
93 func TestPrepareUpdateStillRefusesOverRecoverableTransaction(t *testing.T) {
94 home := t.TempDir()
95 t.Setenv("REASONIX_HOME", home)
96 target := filepath.Join(t.TempDir(), "reasonix-desktop")
97 originalExecutable := repairExecutable
98 repairExecutable = func() (string, error) { return filepath.Join(filepath.Dir(target), "reasonix-guard"), nil }
99 t.Cleanup(func() { repairExecutable = originalExecutable })
100 if err := os.WriteFile(target, []byte("old"), 0o700); err != nil {
101 t.Fatal(err)
102 }
103 if _, err := PrepareFileUpdate("v1", "v2", target); err != nil {
104 t.Fatal(err)
105 }
106
107 if _, err := PrepareFileUpdate("v2", "v3", target); err == nil {
108 t.Fatal("PrepareFileUpdate over a recoverable transaction = nil error, want refusal")
109 } else if !strings.Contains(err.Error(), "a pending update already exists") {
110 t.Fatalf("error = %v, want the pending-update refusal", err)
111 }
112 if got := quarantinedCopies(t); len(got) != 0 {
113 t.Fatalf("quarantined copies = %v, want a recoverable transaction left alone", got)
114 }
115 }
116
117 // Reconciliation must clear debris too, otherwise startup keeps reporting a
118 // recovery failure that nothing can resolve.
119 func TestReconcilePendingUpdateQuarantinesDebris(t *testing.T) {
120 t.Setenv("REASONIX_HOME", t.TempDir())
121 writePendingUpdateRaw(t, `{"schema_version":1,"to_version":"v2"`)
122
123 result, err := ReconcilePendingUpdate("v1")
124 if err != nil {
125 t.Fatalf("ReconcilePendingUpdate over debris: %v", err)
126 }
127 if !result.Cleared {
128 t.Fatalf("result = %+v, want the debris reported as cleared", result)
129 }
130 if _, err := os.Lstat(PendingUpdatePath()); !os.IsNotExist(err) {
131 t.Fatalf("pending marker still present after reconciliation: %v", err)
132 }
133 if got := quarantinedCopies(t); len(got) != 1 {
134 t.Fatalf("quarantined copies = %v, want the debris preserved for diagnosis", got)
135 }
136 }
137
138 // Quarantine preserves evidence rather than deleting it, and repeated recovery
139 // never overwrites an earlier copy.
140 func TestQuarantinePendingUpdatePreservesEveryCopy(t *testing.T) {
141 t.Setenv("REASONIX_HOME", t.TempDir())
142 for i := range 3 {
143 writePendingUpdateRaw(t, `{"broken":`)
144 aside, err := quarantinePendingUpdate("test")
145 if err != nil {
146 t.Fatalf("quarantine %d: %v", i, err)
147 }
148 body, err := os.ReadFile(aside)
149 if err != nil {
150 t.Fatalf("read quarantined copy %d: %v", i, err)
151 }
152 if string(body) != `{"broken":` {
153 t.Fatalf("quarantined body = %q, want the original bytes", body)
154 }
155 }
156 if got := quarantinedCopies(t); len(got) != 3 {
157 t.Fatalf("quarantined copies = %v, want all three preserved", got)
158 }
159 }
160
161 // A transaction that describes itself but does not validate for this
162 // installation is not debris: it may own real rollback material and simply be
163 // observed from the wrong install, so it must survive classification.
164 func TestSelfDescribingTransactionIsNotTreatedAsDebris(t *testing.T) {
165 t.Setenv("REASONIX_HOME", t.TempDir())
166 body, err := json.Marshal(UpdateTransaction{
167 SchemaVersion: updateTransactionVersion,
168 ToVersion: "v2",
169 FromVersion: "v1",
170 Platform: "test",
171 CreatedAt: time.Now().UTC().Format(time.RFC3339Nano),
172 TargetKind: "file",
173 TargetPath: filepath.Join("elsewhere", "reasonix-desktop"),
174 })
175 if err != nil {
176 t.Fatal(err)
177 }
178 writePendingUpdateRaw(t, string(body))
179
180 disposition, tx, classifyErr := classifyPendingUpdate()
181 if classifyErr != nil {
182 t.Fatalf("classifyPendingUpdate: %v", classifyErr)
183 }
184 if disposition != pendingUpdateActionable {
185 t.Fatalf("disposition = %v, want a self-describing transaction treated as actionable", disposition)
186 }
187 if tx != nil {
188 t.Fatal("a transaction that fails installation validation must not be handed back as usable")
189 }
190 if got := quarantinedCopies(t); len(got) != 0 {
191 t.Fatalf("quarantined copies = %v, want none", got)
192 }
193 }
194
194 lines GO