okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

Aly Raffaufrefactor(cli): invoke daemon storage helpersf6da61d

main
10.7 KiB285 linesraw
1// Storage pruning must never delete a live session snapshot or unapproved files;
2// the age and database checks are local safety boundaries that a daemon E2E cannot force.
3package storage
4
5import (
6	"albedo/cli/internal/daemon"
7	"context"
8	"os"
9	"os/exec"
10	"path/filepath"
11	"slices"
12	"strings"
13	"testing"
14	"time"
15)
16
17func TestStoragePreviewAndOfflinePrune(t *testing.T) {
18	home := t.TempDir()
19	setup := `import sqlite3,sys
20c=sqlite3.connect(sys.argv[1])
21c.execute('CREATE TABLE sessions(id TEXT,pinned_context BLOB)')
22c.execute('CREATE TABLE transcript(session TEXT,payload BLOB)')
23c.execute('CREATE TABLE images(data TEXT)')
24c.execute('CREATE TABLE cells(id TEXT,session TEXT,source TEXT,payload BLOB)')
25c.execute('CREATE TABLE cell_traces(id TEXT,payload BLOB)')
26c.execute('INSERT INTO sessions VALUES(?,?)',('live',b'abc'))
27c.execute('INSERT INTO transcript VALUES(?,?)',('live',b'abcd'))
28c.execute('INSERT INTO images VALUES(?)',('abcd',))
29c.execute('INSERT INTO cells VALUES(?,?,?,?)',('cell','live','hello',b'xyz'))
30c.execute('INSERT INTO cell_traces VALUES(?,?)',('cell',b'trace'))
31c.execute('CREATE TABLE reclaim(data BLOB)')
32c.execute('INSERT INTO reclaim VALUES(zeroblob(262144))')
33c.execute('DELETE FROM reclaim')
34c.commit()
35c.close()`
36	if out, err := exec.Command("python3", "-c", setup, filepath.Join(home, "albedo.sqlite")).CombinedOutput(); err != nil {
37		t.Fatalf("setup SQLite: %v %s", err, out)
38	}
39	if err := os.Mkdir(filepath.Join(home, "kernels"), 0700); err != nil {
40		t.Fatal(err)
41	}
42	if err := os.Mkdir(filepath.Join(home, "backups"), 0700); err != nil {
43		t.Fatal(err)
44	}
45	live := filepath.Join(home, "kernels", "live.state")
46	orphan := filepath.Join(home, "kernels", "orphan.state")
47	recent := filepath.Join(home, "kernels", "recent.state")
48	backup := filepath.Join(home, "backups", "albedo-before-image-store-1.sqlite")
49	protected := filepath.Join(home, "backups", "albedo-before-image-store-2.sqlite")
50	unrelated := filepath.Join(home, "backups", "personal.sqlite")
51	for _, path := range []string{live, orphan, recent, backup, protected, unrelated} {
52		if err := os.WriteFile(path, []byte("data"), 0600); err != nil {
53			t.Fatal(err)
54		}
55	}
56	old := time.Now().Add(-31 * 24 * time.Hour)
57	for _, path := range []string{live, orphan, backup, unrelated} {
58		if err := os.Chtimes(path, old, old); err != nil {
59			t.Fatal(err)
60		}
61	}
62	p, reportErr := testService(home).OfflineReport(t.Context())
63	if reportErr != nil {
64		t.Fatal(reportErr)
65	}
66	if len(p.DB.Sessions) != 1 || p.DB.Sessions[0].Bytes != 20 || p.DB.Images != 4 {
67		t.Fatalf("unexpected storage estimates: %+v", p.DB)
68	}
69	if len(p.OldKernels) != 1 || p.OldKernels[0].Path != orphan || len(p.OldBackups) != 1 || p.OldBackups[0].Path != backup || p.RecentBackupCount != 1 || p.RecentBackups != 4 || p.DB.FreePages == 0 {
70		t.Fatalf("unsafe candidates or missing free pages: %+v", p)
71	}
72	service := testService(home)
73	if _, err := os.Stat(orphan); err != nil {
74		t.Fatalf("preview deleted snapshot: %v", err)
75	}
76	if _, err := service.PlanCleanup(t.Context(), CleanupOptions{All: true, Backups: true}); err == nil {
77		t.Fatal("ambiguous --all accepted")
78	}
79	for _, path := range []string{live, orphan, recent, backup, protected, unrelated} {
80		if _, err := os.Stat(path); err != nil {
81			t.Fatalf("refused cleanup changed %s: %v", path, err)
82		}
83	}
84	plan, planErr := service.PlanCleanup(t.Context(), CleanupOptions{All: true})
85	if planErr != nil {
86		t.Fatal(planErr)
87	}
88	if _, err := service.ApplyCleanup(t.Context(), plan); err != nil {
89		t.Fatal(err)
90	}
91	helper, err := testService(home).startMaintenance(t.Context(), nil)
92	if err != nil {
93		t.Fatalf("vacuum retained ownership: %v", err)
94	}
95	helper.close()
96	for _, path := range []string{orphan, backup} {
97		if _, err := os.Stat(path); !os.IsNotExist(err) {
98			t.Errorf("should remove %s: %v", path, err)
99		}
100	}
101	for _, path := range []string{live, recent, protected, unrelated} {
102		if _, err := os.Stat(path); err != nil {
103			t.Errorf("should retain %s: %v", path, err)
104		}
105	}
106	if after, err := testService(home).OfflineReport(t.Context()); err != nil || len(after.DB.Sessions) != 1 || after.Database >= p.Database || after.DB.FreePages != 0 {
107		t.Fatalf("--all did not safely reclaim SQLite pages: %+v, %v", after, err)
108	}
109}
110
111func TestStorageRequiresDatabaseForOrphanCleanup(t *testing.T) {
112	home := t.TempDir()
113	if _, err := testService(home).PlanCleanup(t.Context(), CleanupOptions{OldKernels: true}); err == nil {
114		t.Fatalf("unsafe cleanup without database: %v", err)
115	}
116	if _, err := testService(home).PlanCleanup(t.Context(), CleanupOptions{Sessions: []string{"../sessions/escape"}}); err == nil {
117		t.Fatal("accepted non-session ID")
118	}
119}
120
121func TestStorageRefusesSymlinkDirectory(t *testing.T) {
122	home := t.TempDir()
123	outside := t.TempDir()
124	if err := os.Symlink(outside, filepath.Join(home, "kernels")); err != nil {
125		t.Skipf("symlinks unavailable: %v", err)
126	}
127	if _, err := testService(home).OfflineReport(t.Context()); err == nil {
128		t.Fatal("followed symlinked kernel directory")
129	}
130}
131
132func testService(home string) *Service {
133	return &Service{Command: testCommand, Home: home, Now: time.Now, Running: func() (bool, error) { return false, nil }, DeleteSessions: func(context.Context, []string) error { panic("offline test attempted session deletion") }}
134}
135
136// Candidate races occur during the confirmation wait. Force them locally so
137// their timing does not depend on a real daemon, terminal, or filesystem clock.
138func TestCleanupRevalidatesBeforeAnyDeletion(t *testing.T) {
139	for _, change := range []string{"daemon started", "candidate added", "same-size replacement", "symlink replacement"} {
140		t.Run(change, func(t *testing.T) {
141			home := t.TempDir()
142			backups := filepath.Join(home, "backups")
143			if err := os.Mkdir(backups, 0700); err != nil {
144				t.Fatal(err)
145			}
146			approved := filepath.Join(backups, "albedo-before-image-store-1.sqlite")
147			untouched := filepath.Join(backups, "albedo-before-image-store-2.sqlite")
148			old := time.Now().Add(-31 * 24 * time.Hour)
149			for _, file := range []string{approved, untouched} {
150				if err := os.WriteFile(file, []byte("data"), 0600); err != nil {
151					t.Fatal(err)
152				}
153				if err := os.Chtimes(file, old, old); err != nil {
154					t.Fatal(err)
155				}
156			}
157			service := testService(home)
158			plan, planErr := service.PlanCleanup(t.Context(), CleanupOptions{Backups: true})
159			if planErr != nil {
160				t.Fatal(planErr)
161			}
162			switch change {
163			case "daemon started":
164				service.Running = func() (bool, error) { return true, nil }
165			case "candidate added":
166				file := filepath.Join(backups, "albedo-before-image-store-3.sqlite")
167				if err := os.WriteFile(file, []byte("data"), 0600); err != nil {
168					t.Fatal(err)
169				}
170				if err := os.Chtimes(file, old, old); err != nil {
171					t.Fatal(err)
172				}
173			case "same-size replacement":
174				replacement := filepath.Join(home, "replacement")
175				if err := os.WriteFile(replacement, []byte("data"), 0600); err != nil {
176					t.Fatal(err)
177				}
178				if err := os.Chtimes(replacement, old, old); err != nil {
179					t.Fatal(err)
180				}
181				if err := os.Rename(replacement, approved); err != nil {
182					t.Fatal(err)
183				}
184			case "symlink replacement":
185				if err := os.Remove(approved); err != nil {
186					t.Fatal(err)
187				}
188				if err := os.Symlink(untouched, approved); err != nil {
189					t.Fatal(err)
190				}
191			}
192			_, applyErr := service.ApplyCleanup(t.Context(), plan)
193			if applyErr == nil {
194				t.Fatal("cleanup accepted changed state")
195			}
196			helper, err := testService(home).startMaintenance(t.Context(), nil)
197			if err != nil {
198				t.Fatalf("revalidation failure retained ownership: %v", err)
199			}
200			helper.close()
201			if _, err := os.Stat(untouched); err != nil {
202				t.Fatalf("cleanup partially deleted candidates: %v", err)
203			}
204		})
205	}
206}
207
208// Session deletion must use daemon authority even when its reported files are
209// inaccessible to this client. Local mutation plans still revalidate locally.
210func TestSessionDeletionPlanNeverInspectsDaemonReportedPaths(t *testing.T) {
211	id := strings.Repeat("a", 32)
212	home := filepath.Join(t.TempDir(), "not-local-storage")
213	if err := os.WriteFile(home, []byte("cannot inspect as a home"), 0600); err != nil {
214		t.Fatal(err)
215	}
216	deleted := false
217	service := testService(home)
218	service.OnlineReport = func(context.Context) (*daemon.StorageReport, error) {
219		return &daemon.StorageReport{
220			DB:         daemon.StorageDatabase{Sessions: []daemon.StorageSession{{ID: id, Bytes: 7}}},
221			OldKernels: []daemon.StorageFile{{Path: "/not-accessible/daemon/orphan.state", Bytes: 5}},
222		}, nil
223	}
224	service.DeleteSessions = func(ctx context.Context, ids []string) error {
225		deleted = slices.Equal(ids, []string{id})
226		return nil
227	}
228	plan, err := service.PlanCleanup(t.Context(), CleanupOptions{Sessions: []string{id}})
229	if err != nil {
230		t.Fatal(err)
231	}
232	if got := plan.Preview(); len(got.DB.Sessions) != 1 || got.DB.Sessions[0].Bytes != 7 {
233		t.Fatalf("lost authoritative preview: %+v", got)
234	}
235	if _, err := service.ApplyCleanup(t.Context(), plan); err != nil || !deleted {
236		t.Fatalf("session deletion was not delegated: %v", err)
237	}
238	service.OnlineReport = func(context.Context) (*daemon.StorageReport, error) { return nil, nil }
239	if _, err := service.PlanCleanup(t.Context(), CleanupOptions{Sessions: []string{id}}); err == nil || !strings.Contains(err.Error(), "running daemon") {
240		t.Fatalf("missing daemon not actionable: %v", err)
241	}
242}
243
244func TestKernelCleanupRefusesDatabaseLostAfterPreview(t *testing.T) {
245	home := t.TempDir()
246	database := filepath.Join(home, "albedo.sqlite")
247	setup := `import sqlite3,sys
248with sqlite3.connect(sys.argv[1]) as db:
249    db.executescript("CREATE TABLE sessions(id TEXT, pinned_context TEXT); CREATE TABLE transcript(session TEXT,payload BLOB);")
250`
251	if out, err := exec.Command("python3", "-c", setup, database).CombinedOutput(); err != nil {
252		t.Fatalf("setup database: %v %s", err, out)
253	}
254	kernels := filepath.Join(home, "kernels")
255	if err := os.Mkdir(kernels, 0700); err != nil {
256		t.Fatal(err)
257	}
258	candidate := filepath.Join(kernels, "orphan.state")
259	if err := os.WriteFile(candidate, []byte("retain"), 0600); err != nil {
260		t.Fatal(err)
261	}
262	old := time.Now().Add(-31 * 24 * time.Hour)
263	if err := os.Chtimes(candidate, old, old); err != nil {
264		t.Fatal(err)
265	}
266	service := testService(home)
267	plan, planErr := service.PlanCleanup(t.Context(), CleanupOptions{OldKernels: true})
268	if planErr != nil {
269		t.Fatal(planErr)
270	}
271	if err := os.Remove(database); err != nil {
272		t.Fatal(err)
273	}
274	if _, err := service.ApplyCleanup(t.Context(), plan); err == nil || !strings.Contains(err.Error(), "database disappeared") {
275		t.Fatalf("missing database authorized kernel deletion: %v", err)
276	}
277	if content, err := os.ReadFile(candidate); err != nil || string(content) != "retain" {
278		t.Fatalf("kernel changed: %q, %v", content, err)
279	}
280	helper, err := testService(home).startMaintenance(t.Context(), nil)
281	if err != nil {
282		t.Fatalf("refused cleanup retained ownership: %v", err)
283	}
284	helper.close()
285}