okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
f6da61d
main
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 ) { 18home := t .TempDir () 19setup := `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()` 36if out ,err := exec .Command ("python3" ,"-c" ,setup ,filepath .Join (home ,"albedo.sqlite" )).CombinedOutput ();err != nil { 37t .Fatalf ("setup SQLite: %v %s" ,err ,out ) 38 } 39if err := os .Mkdir (filepath .Join (home ,"kernels" ),0700 );err != nil { 40t .Fatal (err ) 41 } 42if err := os .Mkdir (filepath .Join (home ,"backups" ),0700 );err != nil { 43t .Fatal (err ) 44 } 45live := filepath .Join (home ,"kernels" ,"live.state" ) 46orphan := filepath .Join (home ,"kernels" ,"orphan.state" ) 47recent := filepath .Join (home ,"kernels" ,"recent.state" ) 48backup := filepath .Join (home ,"backups" ,"albedo-before-image-store-1.sqlite" ) 49protected := filepath .Join (home ,"backups" ,"albedo-before-image-store-2.sqlite" ) 50unrelated := filepath .Join (home ,"backups" ,"personal.sqlite" ) 51for _ ,path := range []string {live ,orphan ,recent ,backup ,protected ,unrelated } { 52if err := os .WriteFile (path , []byte ("data" ),0600 );err != nil { 53t .Fatal (err ) 54 } 55 } 56old := time .Now ().Add (- 31 * 24 * time .Hour ) 57for _ ,path := range []string {live ,orphan ,backup ,unrelated } { 58if err := os .Chtimes (path ,old ,old );err != nil { 59t .Fatal (err ) 60 } 61 } 62p ,reportErr := testService (home ).OfflineReport (t .Context ()) 63if reportErr != nil { 64t .Fatal (reportErr ) 65 } 66if len (p .DB .Sessions )!= 1 || p .DB .Sessions [0 ].Bytes != 20 || p .DB .Images != 4 { 67t .Fatalf ("unexpected storage estimates: %+v" ,p .DB ) 68 } 69if 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 { 70t .Fatalf ("unsafe candidates or missing free pages: %+v" ,p ) 71 } 72service := testService (home ) 73if _ ,err := os .Stat (orphan );err != nil { 74t .Fatalf ("preview deleted snapshot: %v" ,err ) 75 } 76if _ ,err := service .PlanCleanup (t .Context (),CleanupOptions {All :true ,Backups :true });err == nil { 77t .Fatal ("ambiguous --all accepted" ) 78 } 79for _ ,path := range []string {live ,orphan ,recent ,backup ,protected ,unrelated } { 80if _ ,err := os .Stat (path );err != nil { 81t .Fatalf ("refused cleanup changed %s: %v" ,path ,err ) 82 } 83 } 84plan ,planErr := service .PlanCleanup (t .Context (),CleanupOptions {All :true }) 85if planErr != nil { 86t .Fatal (planErr ) 87 } 88if _ ,err := service .ApplyCleanup (t .Context (),plan );err != nil { 89t .Fatal (err ) 90 } 91helper ,err := testService (home ).startMaintenance (t .Context (),nil ) 92if err != nil { 93t .Fatalf ("vacuum retained ownership: %v" ,err ) 94 } 95helper .close () 96for _ ,path := range []string {orphan ,backup } { 97if _ ,err := os .Stat (path );! os .IsNotExist (err ) { 98t .Errorf ("should remove %s: %v" ,path ,err ) 99 } 100 } 101for _ ,path := range []string {live ,recent ,protected ,unrelated } { 102if _ ,err := os .Stat (path );err != nil { 103t .Errorf ("should retain %s: %v" ,path ,err ) 104 } 105 } 106if after ,err := testService (home ).OfflineReport (t .Context ());err != nil || len (after .DB .Sessions )!= 1 || after .Database >= p .Database || after .DB .FreePages != 0 { 107t .Fatalf ("--all did not safely reclaim SQLite pages: %+v, %v" ,after ,err ) 108 } 109} 110 111func TestStorageRequiresDatabaseForOrphanCleanup (t * testing.T ) { 112home := t .TempDir () 113if _ ,err := testService (home ).PlanCleanup (t .Context (),CleanupOptions {OldKernels :true });err == nil { 114t .Fatalf ("unsafe cleanup without database: %v" ,err ) 115 } 116if _ ,err := testService (home ).PlanCleanup (t .Context (),CleanupOptions {Sessions : []string {"../sessions/escape" }});err == nil { 117t .Fatal ("accepted non-session ID" ) 118 } 119} 120 121func TestStorageRefusesSymlinkDirectory (t * testing.T ) { 122home := t .TempDir () 123outside := t .TempDir () 124if err := os .Symlink (outside ,filepath .Join (home ,"kernels" ));err != nil { 125t .Skipf ("symlinks unavailable: %v" ,err ) 126 } 127if _ ,err := testService (home ).OfflineReport (t .Context ());err == nil { 128t .Fatal ("followed symlinked kernel directory" ) 129 } 130} 131 132func testService (home string )* Service { 133return & 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 ) { 139for _ ,change := range []string {"daemon started" ,"candidate added" ,"same-size replacement" ,"symlink replacement" } { 140t .Run (change ,func (t * testing.T ) { 141home := t .TempDir () 142backups := filepath .Join (home ,"backups" ) 143if err := os .Mkdir (backups ,0700 );err != nil { 144t .Fatal (err ) 145 } 146approved := filepath .Join (backups ,"albedo-before-image-store-1.sqlite" ) 147untouched := filepath .Join (backups ,"albedo-before-image-store-2.sqlite" ) 148old := time .Now ().Add (- 31 * 24 * time .Hour ) 149for _ ,file := range []string {approved ,untouched } { 150if err := os .WriteFile (file , []byte ("data" ),0600 );err != nil { 151t .Fatal (err ) 152 } 153if err := os .Chtimes (file ,old ,old );err != nil { 154t .Fatal (err ) 155 } 156 } 157service := testService (home ) 158plan ,planErr := service .PlanCleanup (t .Context (),CleanupOptions {Backups :true }) 159if planErr != nil { 160t .Fatal (planErr ) 161 } 162switch change { 163case "daemon started" : 164service .Running = func () (bool ,error ) {return true ,nil } 165case "candidate added" : 166file := filepath .Join (backups ,"albedo-before-image-store-3.sqlite" ) 167if err := os .WriteFile (file , []byte ("data" ),0600 );err != nil { 168t .Fatal (err ) 169 } 170if err := os .Chtimes (file ,old ,old );err != nil { 171t .Fatal (err ) 172 } 173case "same-size replacement" : 174replacement := filepath .Join (home ,"replacement" ) 175if err := os .WriteFile (replacement , []byte ("data" ),0600 );err != nil { 176t .Fatal (err ) 177 } 178if err := os .Chtimes (replacement ,old ,old );err != nil { 179t .Fatal (err ) 180 } 181if err := os .Rename (replacement ,approved );err != nil { 182t .Fatal (err ) 183 } 184case "symlink replacement" : 185if err := os .Remove (approved );err != nil { 186t .Fatal (err ) 187 } 188if err := os .Symlink (untouched ,approved );err != nil { 189t .Fatal (err ) 190 } 191 } 192_ ,applyErr := service .ApplyCleanup (t .Context (),plan ) 193if applyErr == nil { 194t .Fatal ("cleanup accepted changed state" ) 195 } 196helper ,err := testService (home ).startMaintenance (t .Context (),nil ) 197if err != nil { 198t .Fatalf ("revalidation failure retained ownership: %v" ,err ) 199 } 200helper .close () 201if _ ,err := os .Stat (untouched );err != nil { 202t .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 ) { 211id := strings .Repeat ("a" ,32 ) 212home := filepath .Join (t .TempDir (),"not-local-storage" ) 213if err := os .WriteFile (home , []byte ("cannot inspect as a home" ),0600 );err != nil { 214t .Fatal (err ) 215 } 216deleted := false 217service := testService (home ) 218service .OnlineReport = func (context.Context ) (* daemon.StorageReport ,error ) { 219return & daemon.StorageReport { 220DB : daemon.StorageDatabase {Sessions : []daemon.StorageSession {{ID :id ,Bytes :7 }}}, 221OldKernels : []daemon.StorageFile {{Path :"/not-accessible/daemon/orphan.state" ,Bytes :5 }}, 222 },nil 223 } 224service .DeleteSessions = func (ctx context.Context ,ids []string )error { 225deleted = slices .Equal (ids , []string {id }) 226return nil 227 } 228plan ,err := service .PlanCleanup (t .Context (),CleanupOptions {Sessions : []string {id }}) 229if err != nil { 230t .Fatal (err ) 231 } 232if got := plan .Preview ();len (got .DB .Sessions )!= 1 || got .DB .Sessions [0 ].Bytes != 7 { 233t .Fatalf ("lost authoritative preview: %+v" ,got ) 234 } 235if _ ,err := service .ApplyCleanup (t .Context (),plan );err != nil || ! deleted { 236t .Fatalf ("session deletion was not delegated: %v" ,err ) 237 } 238service .OnlineReport = func (context.Context ) (* daemon.StorageReport ,error ) {return nil ,nil } 239if _ ,err := service .PlanCleanup (t .Context (),CleanupOptions {Sessions : []string {id }});err == nil || ! strings .Contains (err .Error (),"running daemon" ) { 240t .Fatalf ("missing daemon not actionable: %v" ,err ) 241 } 242} 243 244func TestKernelCleanupRefusesDatabaseLostAfterPreview (t * testing.T ) { 245home := t .TempDir () 246database := filepath .Join (home ,"albedo.sqlite" ) 247setup := `import sqlite3,sys 248with sqlite3.connect(sys.argv[1]) as db: 249db.executescript("CREATE TABLE sessions(id TEXT, pinned_context TEXT); CREATE TABLE transcript(session TEXT,payload BLOB);") 250` 251if out ,err := exec .Command ("python3" ,"-c" ,setup ,database ).CombinedOutput ();err != nil { 252t .Fatalf ("setup database: %v %s" ,err ,out ) 253 } 254kernels := filepath .Join (home ,"kernels" ) 255if err := os .Mkdir (kernels ,0700 );err != nil { 256t .Fatal (err ) 257 } 258candidate := filepath .Join (kernels ,"orphan.state" ) 259if err := os .WriteFile (candidate , []byte ("retain" ),0600 );err != nil { 260t .Fatal (err ) 261 } 262old := time .Now ().Add (- 31 * 24 * time .Hour ) 263if err := os .Chtimes (candidate ,old ,old );err != nil { 264t .Fatal (err ) 265 } 266service := testService (home ) 267plan ,planErr := service .PlanCleanup (t .Context (),CleanupOptions {OldKernels :true }) 268if planErr != nil { 269t .Fatal (planErr ) 270 } 271if err := os .Remove (database );err != nil { 272t .Fatal (err ) 273 } 274if _ ,err := service .ApplyCleanup (t .Context (),plan );err == nil || ! strings .Contains (err .Error (),"database disappeared" ) { 275t .Fatalf ("missing database authorized kernel deletion: %v" ,err ) 276 } 277if content ,err := os .ReadFile (candidate );err != nil || string (content )!= "retain" { 278t .Fatalf ("kernel changed: %q, %v" ,content ,err ) 279 } 280helper ,err := testService (home ).startMaintenance (t .Context (),nil ) 281if err != nil { 282t .Fatalf ("refused cleanup retained ownership: %v" ,err ) 283 } 284helper .close () 285}