package layout import ( "encoding/json" "errors" "os" "path/filepath" "testing" "github.com/carlosframework/platform/internal/registry" ) // presence9 is a presence vector in artifactNames order: db, db-wal, db-shm, // env, sidecar-env, config-env, socket, litestream-shadow, corrupt. The db // is marked present, since Validate now requires that of any journal it // accepts. var presence9 = []bool{true, false, false, true, false, true, false, false, false} func journalFor(t *testing.T, dd string, mode Mode) *Journal { t.Helper() p, err := PlanFor(dd, "/run/carlos", "h.example.com", mode) if err != nil { t.Fatal(err) } return p.Journal(presence9) } func TestJournalWriteIsExclusiveAndPhasesAdvance(t *testing.T) { dd := t.TempDir() j := journalFor(t, dd, Mode("nested")) if err := WriteJournal(dd, j); err != nil { t.Fatal(err) } if err := WriteJournal(dd, j); !errors.Is(err, ErrJournalExists) { t.Fatalf("second write = %v, want ErrJournalExists", err) } got, found, err := ReadJournal(dd, "h.example.com") if err != nil || !found || got.Phase != PhaseFiles || got.Mode != Mode("nested") || len(got.Artifacts) != 9 { t.Fatalf("read back = %+v %v %v", got, found, err) } if err := UpdatePhase(dd, got, PhaseRow); err != nil { t.Fatal(err) } got, _, _ = ReadJournal(dd, "h.example.com") if got.Phase != PhaseRow { t.Errorf("phase = %s", got.Phase) } if err := RemoveJournal(dd, "h.example.com"); err != nil { t.Fatal(err) } if _, found, _ := ReadJournal(dd, "h.example.com"); found { t.Error("journal survives removal") } } // tempFilesIn lists the WriteJournal/UpdatePhase temp names ("..tmp-*") // left in dir, so a test can assert none survive a call. func tempFilesIn(t *testing.T, dir string) []string { t.Helper() entries, err := os.ReadDir(dir) if err != nil { t.Fatal(err) } var names []string for _, e := range entries { if filepath.Ext(e.Name()) != ".json" { names = append(names, e.Name()) } } return names } // TestWriteJournalRefusesAPreexistingFinalPath pins that the link-based // create refuses exactly like the O_EXCL it replaces, even when the file // at the final path was never written by WriteJournal itself (a hand // dropped file, or debris from something else): os.Link cannot overwrite // an existing name any more than O_EXCL can, so the caller can trust // ErrJournalExists means "something is already there" regardless of how it // got there. func TestWriteJournalRefusesAPreexistingFinalPath(t *testing.T) { dd := t.TempDir() dir := filepath.Join(dd, "layout") if err := os.MkdirAll(dir, 0o700); err != nil { t.Fatal(err) } final := filepath.Join(dir, "h.example.com.json") if err := os.WriteFile(final, []byte("not a journal"), 0o600); err != nil { t.Fatal(err) } j := journalFor(t, dd, Mode("nested")) if err := WriteJournal(dd, j); !errors.Is(err, ErrJournalExists) { t.Fatalf("WriteJournal over a pre-existing file = %v, want ErrJournalExists", err) } if got := tempFilesIn(t, dir); len(got) != 0 { t.Errorf("temp files left behind after a refused write: %v", got) } } // TestWriteJournalLeavesNoTempOnSuccess pins the other half of the same // contract: a successful write's temp name must not survive either, since // the whole point of linking rather than renaming in place was to prove // the final name's content durable before anything reads it, not to leave // a second, forgotten copy sitting beside it. func TestWriteJournalLeavesNoTempOnSuccess(t *testing.T) { dd := t.TempDir() j := journalFor(t, dd, Mode("nested")) if err := WriteJournal(dd, j); err != nil { t.Fatal(err) } if got := tempFilesIn(t, journalDir(dd)); len(got) != 0 { t.Errorf("temp files left behind after a successful write: %v", got) } } func TestReadJournalRefusesASymlinkAndAFIFO(t *testing.T) { dd := t.TempDir() dir := filepath.Join(dd, "layout") os.MkdirAll(dir, 0o700) target := filepath.Join(dd, "elsewhere.json") os.WriteFile(target, []byte(`{"host":"h.example.com"}`), 0o600) if err := os.Symlink(target, filepath.Join(dir, "h.example.com.json")); err != nil { t.Skip(err) } if _, _, err := ReadJournal(dd, "h.example.com"); err == nil { t.Error("a symlinked journal was read") } } // TestReadJournalRefusesAHostMismatch pins that ReadJournal checks the // journal's own content against the path it was read from: a file whose // content names a different host than the file name itself is a sign // something replaced it (a copy, a stale hard link, a bug in whatever // wrote it) and must not be trusted for that host's recovery. func TestReadJournalRefusesAHostMismatch(t *testing.T) { dd := t.TempDir() dir := filepath.Join(dd, "layout") if err := os.MkdirAll(dir, 0o700); err != nil { t.Fatal(err) } j := journalFor(t, dd, Mode("nested")) j.Host = "other.example.com" raw, err := json.Marshal(j) if err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, "h.example.com.json"), raw, 0o600); err != nil { t.Fatal(err) } if _, _, err := ReadJournal(dd, "h.example.com"); err == nil { t.Error("a journal whose content names a different host than its path was read") } } func TestValidateChecksBothEndpointsFromTheJournalsOwnDirection(t *testing.T) { dd := t.TempDir() j := journalFor(t, dd, Mode("nested")) flat := registry.Route{Host: "h.example.com", DBPath: j.From.DB, Socket: j.From.Socket} nested := registry.Route{Host: "h.example.com", DBPath: j.To.DB, Socket: j.To.Socket} if atFrom, err := Validate(j, dd, "/run/carlos", flat); err != nil || !atFrom { t.Errorf("row at from: %v %v", atFrom, err) } if atFrom, err := Validate(j, dd, "/run/carlos", nested); err != nil || atFrom { t.Errorf("row at to: %v %v", atFrom, err) } other := registry.Route{Host: "h.example.com", DBPath: "/data/other.db", Socket: j.From.Socket} if _, err := Validate(j, dd, "/run/carlos", other); err == nil { t.Error("a row at neither endpoint was accepted") } // A forged journal whose `to` points somewhere else is refused whatever the row says. forged := *j forged.To.DB = "/data/console/console.db" if _, err := Validate(&forged, dd, "/run/carlos", flat); err == nil { t.Error("a journal with a foreign destination was accepted") } forged = *j forged.From.DB = "/data/console/console.db" if _, err := Validate(&forged, dd, "/run/carlos", flat); err == nil { t.Error("a journal with a foreign source was accepted") } // The flat direction swaps the two, and Validate derives from the mode. r := journalFor(t, dd, Mode("flat")) if atFrom, err := Validate(r, dd, "/run/carlos", nested); err != nil || !atFrom { t.Errorf("revert journal, row at from (nested): %v %v", atFrom, err) } } // TestValidateRefusesAWrongArtifactCount: a journal that predates the two // newest artifacts (or one that is simply malformed) must not be treated // as valid just because its endpoints happen to match. func TestValidateRefusesAWrongArtifactCount(t *testing.T) { dd := t.TempDir() j := journalFor(t, dd, Mode("nested")) short := *j short.Artifacts = j.Artifacts[:7] row := registry.Route{Host: "h.example.com", DBPath: j.From.DB, Socket: j.From.Socket} if _, err := Validate(&short, dd, "/run/carlos", row); err == nil { t.Error("a journal with only 7 of the 9 artifacts was accepted") } } // TestValidateRequiresTheDBArtifactPresent pins that a forged journal // cannot claim its db was never there and still be accepted: everything // else in an optional artifact list can be absent and the pass simply // skips it, but a journal with no db describes a move that cannot have // happened, whatever its endpoints say. func TestValidateRequiresTheDBArtifactPresent(t *testing.T) { dd := t.TempDir() j := journalFor(t, dd, Mode("nested")) row := registry.Route{Host: "h.example.com", DBPath: j.From.DB, Socket: j.From.Socket} forged := *j forged.Artifacts = append([]JournalArtifact(nil), j.Artifacts...) forged.Artifacts[0].Present = false if _, err := Validate(&forged, dd, "/run/carlos", row); err == nil { t.Error("a journal whose db artifact is not present was accepted") } } // TestValidateRefusesAnUndefinedPhase pins that Validate only accepts the // three phases the pass itself ever writes: an empty or unrecognized phase // is a sign of a forged or corrupted journal, not one from an earlier // build that Validate should try to interpret. func TestValidateRefusesAnUndefinedPhase(t *testing.T) { dd := t.TempDir() j := journalFor(t, dd, Mode("nested")) row := registry.Route{Host: "h.example.com", DBPath: j.From.DB, Socket: j.From.Socket} for _, phase := range []Phase{"", "bogus"} { forged := *j forged.Phase = phase if _, err := Validate(&forged, dd, "/run/carlos", row); err == nil { t.Errorf("phase %q was accepted", phase) } } } func TestJournalPresent(t *testing.T) { dd := t.TempDir() if JournalPresent(dd, "h.example.com") { t.Error("no journal written yet, JournalPresent = true") } j := journalFor(t, dd, Mode("nested")) if err := WriteJournal(dd, j); err != nil { t.Fatal(err) } if !JournalPresent(dd, "h.example.com") { t.Error("journal written, JournalPresent = false") } if err := RemoveJournal(dd, "h.example.com"); err != nil { t.Fatal(err) } if JournalPresent(dd, "h.example.com") { t.Error("journal removed, JournalPresent = true") } } // ReplaceJournal hands a host over from one journal to the next in one // rename: at every instant the final name holds one whole journal, so the // host is never without one. func TestReplaceJournalSwapsContentsInOneStep(t *testing.T) { dd := t.TempDir() old := journalFor(t, dd, ModeFlat) if err := WriteJournal(dd, old); err != nil { t.Fatal(err) } if err := UpdatePhase(dd, old, PhaseDone); err != nil { t.Fatal(err) } next := journalFor(t, dd, ModeNested) if err := ReplaceJournal(dd, old, next); err != nil { t.Fatalf("ReplaceJournal: %v", err) } got, found, err := ReadJournal(dd, "h.example.com") if err != nil || !found || got.Mode != ModeNested || got.Phase != PhaseFiles { t.Fatalf("after replace: %+v %v %v", got, found, err) } if left := tempFilesIn(t, journalDir(dd)); len(left) != 0 { t.Errorf("temp files left: %v", left) } if entries, _ := os.ReadDir(journalDir(dd)); len(entries) != 1 { t.Errorf("journal dir holds %d entries, want exactly one", len(entries)) } } // The file replaced must be the journal the caller holds: anything else // there (a newer phase, another direction, nothing at all) is refused and // left exactly as it is. func TestReplaceJournalRefusesAnythingButTheHeldJournal(t *testing.T) { dd := t.TempDir() held := journalFor(t, dd, ModeFlat) next := journalFor(t, dd, ModeNested) if err := ReplaceJournal(dd, held, next); err == nil { t.Fatal("replaced a journal that is not there") } if JournalPresent(dd, "h.example.com") { t.Fatal("a journal was created where none was held") } if err := WriteJournal(dd, held); err != nil { t.Fatal(err) } stale := *held stale.Phase = PhaseDone if err := ReplaceJournal(dd, &stale, next); err == nil { t.Fatal("replaced a journal whose contents differ from the held one") } other := journalFor(t, dd, ModeNested) other.Host = "o.example.com" if err := ReplaceJournal(dd, held, other); err == nil { t.Fatal("replaced one host's journal with another host's") } got, _, _ := ReadJournal(dd, "h.example.com") if got.Mode != ModeFlat || got.Phase != PhaseFiles { t.Fatalf("the held journal changed: %+v", got) } if left := tempFilesIn(t, journalDir(dd)); len(left) != 0 { t.Errorf("temp files left: %v", left) } } // JournalHosts names every host with a journal, and nothing for the temp // files WriteJournal and UpdatePhase leave while they work, or for a // directory that is not there. func TestJournalHostsIgnoresTempFiles(t *testing.T) { dd := t.TempDir() if hosts, err := JournalHosts(dd); err != nil || len(hosts) != 0 { t.Fatalf("no directory: %v %v", hosts, err) } dir := journalDir(dd) if err := os.MkdirAll(dir, 0o700); err != nil { t.Fatal(err) } for _, n := range []string{".h.example.com.tmp-1", ".x.json", "notes.txt"} { if err := os.WriteFile(filepath.Join(dir, n), nil, 0o600); err != nil { t.Fatal(err) } } if hosts, err := JournalHosts(dd); err != nil || len(hosts) != 0 { t.Fatalf("temp files only: %v %v", hosts, err) } if err := os.WriteFile(filepath.Join(dir, "h.example.com.json"), []byte("{}"), 0o600); err != nil { t.Fatal(err) } if hosts, err := JournalHosts(dd); err != nil || len(hosts) != 1 || hosts[0] != "h.example.com" { t.Fatalf("one journal: %v %v", hosts, err) } }