package main // Gate: backup_list tells a host which backups this machine holds for it — // its own folder and the folders the person shared with it — with the // digests backup_push needs, and never reads a file inside the dispatch. import ( "crypto/sha256" "encoding/hex" "encoding/json" "os" "path/filepath" "strings" "testing" "time" ) func listVia(t *testing.T, root string) []backupListEntry { t.Helper() return listViaAs(t, "", root) } func listViaAs(t *testing.T, self, root string) []backupListEntry { t.Helper() out, errOut, code := driveBackupOpAs(t, self, "backup_list", "", root, map[string]string{"op": "list"}) if code != 0 { t.Fatalf("list exited %d: %s", code, errOut) } var reply struct { Backups []backupListEntry `json:"backups"` } if err := json.Unmarshal([]byte(out), &reply); err != nil || reply.Backups == nil { t.Fatalf("list reply: %v %s", err, out) } return reply.Backups } func sha(b []byte) string { s := sha256.Sum256(b) return hex.EncodeToString(s[:]) } // holdHashing replaces the hashing job's reader with one that waits until // released, and counts the files it was asked to read. func holdHashing(t *testing.T) (release func(), calls *int) { t.Helper() gate := make(chan struct{}) n := 0 prev := hashFileFn hashFileFn = func(p string) (string, int64, error) { hashMu.Lock() n++ hashMu.Unlock() <-gate return hashFile(p) } var once bool release = func() { if !once { once = true close(gate) } } t.Cleanup(func() { release(); hashFileFn = prev }) return release, &n } func awaitListed(t *testing.T, root string, ok func([]backupListEntry) bool) []backupListEntry { t.Helper() deadline := time.Now().Add(10 * time.Second) for time.Now().Before(deadline) { if l := listBackups("", root); ok(l) { return l } time.Sleep(10 * time.Millisecond) } t.Fatal("the listing never showed what was expected") return nil } func TestBackupListShowsTheBackupsAtTheTopOfTheFolder(t *testing.T) { root := t.TempDir() data := []byte("data of the backup") side := []byte("sidecar") _ = os.WriteFile(filepath.Join(root, fixtureStamp+".tar.zst.age"), data, 0o600) _ = os.WriteFile(filepath.Join(root, fixtureStamp+".key.age"), side, 0o600) _ = os.WriteFile(filepath.Join(root, "20260101T000000Z.key.age"), side, 0o600) _ = os.WriteFile(filepath.Join(root, "notes.txt"), []byte("mine"), 0o600) _ = os.MkdirAll(filepath.Join(root, "sub"), 0o700) _ = os.WriteFile(filepath.Join(root, "sub", "20260201T000000Z.tar.zst.age"), data, 0o600) _ = os.Symlink(filepath.Join(root, fixtureStamp+".tar.zst.age"), filepath.Join(root, "20260301T000000Z.tar.zst.age")) l := listVia(t, root) if len(l) != 2 || l[0].Stamp != fixtureStamp || l[1].Stamp != "20260101T000000Z" { t.Fatalf("listed %+v", l) } e := l[0] if e.Folder != "." || !e.HasData || !e.HasSidecar || e.DataBytes != int64(len(data)) || e.SidecarBytes != int64(len(side)) { t.Fatalf("entry %+v", e) } if l[1].HasData || !l[1].HasSidecar || l[1].DataBytes != 0 { t.Fatalf("a sidecar alone reads %+v", l[1]) } l = awaitListed(t, root, func(l []backupListEntry) bool { return l[0].DataSHA256 != "" && l[0].SidecarSHA256 != "" }) if l[0].DataSHA256 != sha(data) || l[0].SidecarSHA256 != sha(side) { t.Fatalf("digests %+v", l[0]) } } // A listing never waits for a file to be read: an unknown digest is "" and // one background job reads it; the digest appears on a later listing. func TestBackupListNeverWaitsForHashing(t *testing.T) { root := t.TempDir() data := []byte("a large backup, as far as this test is concerned") _ = os.WriteFile(filepath.Join(root, fixtureData), data, 0o600) release, calls := holdHashing(t) done := make(chan []backupListEntry, 1) go func() { done <- listBackups("", root) }() select { case l := <-done: if len(l) != 1 || l[0].DataSHA256 != "" { t.Fatalf("listed %+v", l) } case <-time.After(5 * time.Second): t.Fatal("the listing waited for the file to be hashed") } // Listing again while the job is reading the file starts no second read. deadline := time.Now().Add(5 * time.Second) for { hashMu.Lock() n := *calls hashMu.Unlock() if n == 1 { break } if time.Now().After(deadline) { t.Fatal("the hashing job never started") } time.Sleep(5 * time.Millisecond) } _ = listBackups("", root) release() l := awaitListed(t, root, func(l []backupListEntry) bool { return l[0].DataSHA256 != "" }) if l[0].DataSHA256 != sha(data) { t.Fatal("wrong digest") } hashMu.Lock() n := *calls hashMu.Unlock() if n != 1 { t.Fatalf("the file was read %d times", n) } } // A digest is known only for the file it was taken of: a new size or // modification time makes it unknown again, and the new one is read. func TestBackupListDigestFollowsTheFile(t *testing.T) { root := t.TempDir() p := filepath.Join(root, fixtureData) _ = os.WriteFile(p, []byte("first"), 0o600) awaitListed(t, root, func(l []backupListEntry) bool { return l[0].DataSHA256 == sha([]byte("first")) }) _ = os.WriteFile(p, []byte("second, longer"), 0o600) if l := listBackups("", root); l[0].DataSHA256 != "" { t.Fatalf("a changed size kept the old digest: %s", l[0].DataSHA256) } awaitListed(t, root, func(l []backupListEntry) bool { return l[0].DataSHA256 == sha([]byte("second, longer")) }) // Same size, new time. _ = os.WriteFile(p, []byte("third, longer!"), 0o600) later := time.Now().Add(time.Hour) _ = os.Chtimes(p, later, later) if l := listBackups("", root); l[0].DataSHA256 != "" { t.Fatalf("a changed modification time kept the old digest: %s", l[0].DataSHA256) } awaitListed(t, root, func(l []backupListEntry) bool { return l[0].DataSHA256 == sha([]byte("third, longer!")) }) } // What this bastion fetched is listed with the digest it recorded, without // reading the file. func TestBackupListUsesTheRecordOfWhatArrived(t *testing.T) { root := t.TempDir() data := []byte("fetched earlier") _ = os.WriteFile(filepath.Join(root, fixtureData), data, 0o600) if err := recordArrival(root, fixtureData, sha(data), time.Now()); err != nil { t.Fatal(err) } hashMu.Lock() hashCache = map[string]hashEntry{} // as after a restart hashMu.Unlock() _, calls := holdHashing(t) if l := listBackups("", root); l[0].DataSHA256 != sha(data) { t.Fatalf("the recorded digest was not used: %+v", l[0]) } time.Sleep(50 * time.Millisecond) hashMu.Lock() n := *calls hashMu.Unlock() if n != 0 { t.Fatal("a recorded file was read to hash it") } } // A folder shared with a pairing is listed for that pairing, under // shared/; not for another, not for an anonymous caller, and not once // taken back. func TestBackupListShowsOnlyFoldersSharedWithThisHost(t *testing.T) { lost := t.TempDir() _ = os.WriteFile(filepath.Join(lost, fixtureData), []byte("the lost host's"), 0o600) const newHost, other = "new.host.privatae.ai", "other.host.privatae.ai" root := t.TempDir() s, err := addBackupShare(newHost, root, lost, "Old Host.example") if err != nil { t.Fatal(err) } if s.Slug != "old-host.example" || strings.ContainsAny(s.Slug, "/ ") { t.Fatalf("slug %q", s.Slug) } if _, err := addBackupShare(newHost, root, lost, "again"); err == nil { t.Fatal("the same folder was shared twice") } if _, err := addBackupShare("", root, t.TempDir(), "nobody"); err == nil { t.Fatal("a folder was shared with no host") } if _, err := addBackupShare(newHost, root, root, "self"); err == nil { t.Fatal("a host's own folder was shared with it") } l := listViaAs(t, newHost, root) if len(l) != 1 || l[0].Folder != "shared/"+s.Slug { t.Fatalf("listed %+v", l) } if l := listViaAs(t, other, t.TempDir()); len(l) != 0 { t.Fatalf("a pairing it was not shared with lists %+v", l) } if l := listViaAs(t, "", root); len(l) != 0 { t.Fatalf("an anonymous caller with the same folder lists %+v", l) } if err := dropBackupShare(newHost, s.Slug); err != nil { t.Fatal(err) } if l := listViaAs(t, newHost, root); len(l) != 0 { t.Fatalf("a folder taken back is still listed: %+v", l) } }