| 1 | package layout |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "errors" |
| 6 | "os" |
| 7 | "path/filepath" |
| 8 | "testing" |
| 9 | |
| 10 | "github.com/carlosframework/platform/internal/registry" |
| 11 | ) |
| 12 | |
| 13 | // presence9 is a presence vector in artifactNames order: db, db-wal, db-shm, |
| 14 | // env, sidecar-env, config-env, socket, litestream-shadow, corrupt. The db |
| 15 | // is marked present, since Validate now requires that of any journal it |
| 16 | // accepts. |
| 17 | var presence9 = []bool{true, false, false, true, false, true, false, false, false} |
| 18 | |
| 19 | func journalFor(t *testing.T, dd string, mode Mode) *Journal { |
| 20 | t.Helper() |
| 21 | p, err := PlanFor(dd, "/run/carlos", "h.example.com", mode) |
| 22 | if err != nil { |
| 23 | t.Fatal(err) |
| 24 | } |
| 25 | return p.Journal(presence9) |
| 26 | } |
| 27 | |
| 28 | func TestJournalWriteIsExclusiveAndPhasesAdvance(t *testing.T) { |
| 29 | dd := t.TempDir() |
| 30 | j := journalFor(t, dd, Mode("nested")) |
| 31 | if err := WriteJournal(dd, j); err != nil { |
| 32 | t.Fatal(err) |
| 33 | } |
| 34 | if err := WriteJournal(dd, j); !errors.Is(err, ErrJournalExists) { |
| 35 | t.Fatalf("second write = %v, want ErrJournalExists", err) |
| 36 | } |
| 37 | got, found, err := ReadJournal(dd, "h.example.com") |
| 38 | if err != nil || !found || got.Phase != PhaseFiles || got.Mode != Mode("nested") || len(got.Artifacts) != 9 { |
| 39 | t.Fatalf("read back = %+v %v %v", got, found, err) |
| 40 | } |
| 41 | if err := UpdatePhase(dd, got, PhaseRow); err != nil { |
| 42 | t.Fatal(err) |
| 43 | } |
| 44 | got, _, _ = ReadJournal(dd, "h.example.com") |
| 45 | if got.Phase != PhaseRow { |
| 46 | t.Errorf("phase = %s", got.Phase) |
| 47 | } |
| 48 | if err := RemoveJournal(dd, "h.example.com"); err != nil { |
| 49 | t.Fatal(err) |
| 50 | } |
| 51 | if _, found, _ := ReadJournal(dd, "h.example.com"); found { |
| 52 | t.Error("journal survives removal") |
| 53 | } |
| 54 | } |
| 55 | |
| 56 | // tempFilesIn lists the WriteJournal/UpdatePhase temp names (".<host>.tmp-*") |
| 57 | // left in dir, so a test can assert none survive a call. |
| 58 | func tempFilesIn(t *testing.T, dir string) []string { |
| 59 | t.Helper() |
| 60 | entries, err := os.ReadDir(dir) |
| 61 | if err != nil { |
| 62 | t.Fatal(err) |
| 63 | } |
| 64 | var names []string |
| 65 | for _, e := range entries { |
| 66 | if filepath.Ext(e.Name()) != ".json" { |
| 67 | names = append(names, e.Name()) |
| 68 | } |
| 69 | } |
| 70 | return names |
| 71 | } |
| 72 | |
| 73 | // TestWriteJournalRefusesAPreexistingFinalPath pins that the link-based |
| 74 | // create refuses exactly like the O_EXCL it replaces, even when the file |
| 75 | // at the final path was never written by WriteJournal itself (a hand |
| 76 | // dropped file, or debris from something else): os.Link cannot overwrite |
| 77 | // an existing name any more than O_EXCL can, so the caller can trust |
| 78 | // ErrJournalExists means "something is already there" regardless of how it |
| 79 | // got there. |
| 80 | func TestWriteJournalRefusesAPreexistingFinalPath(t *testing.T) { |
| 81 | dd := t.TempDir() |
| 82 | dir := filepath.Join(dd, "layout") |
| 83 | if err := os.MkdirAll(dir, 0o700); err != nil { |
| 84 | t.Fatal(err) |
| 85 | } |
| 86 | final := filepath.Join(dir, "h.example.com.json") |
| 87 | if err := os.WriteFile(final, []byte("not a journal"), 0o600); err != nil { |
| 88 | t.Fatal(err) |
| 89 | } |
| 90 | j := journalFor(t, dd, Mode("nested")) |
| 91 | if err := WriteJournal(dd, j); !errors.Is(err, ErrJournalExists) { |
| 92 | t.Fatalf("WriteJournal over a pre-existing file = %v, want ErrJournalExists", err) |
| 93 | } |
| 94 | if got := tempFilesIn(t, dir); len(got) != 0 { |
| 95 | t.Errorf("temp files left behind after a refused write: %v", got) |
| 96 | } |
| 97 | } |
| 98 | |
| 99 | // TestWriteJournalLeavesNoTempOnSuccess pins the other half of the same |
| 100 | // contract: a successful write's temp name must not survive either, since |
| 101 | // the whole point of linking rather than renaming in place was to prove |
| 102 | // the final name's content durable before anything reads it, not to leave |
| 103 | // a second, forgotten copy sitting beside it. |
| 104 | func TestWriteJournalLeavesNoTempOnSuccess(t *testing.T) { |
| 105 | dd := t.TempDir() |
| 106 | j := journalFor(t, dd, Mode("nested")) |
| 107 | if err := WriteJournal(dd, j); err != nil { |
| 108 | t.Fatal(err) |
| 109 | } |
| 110 | if got := tempFilesIn(t, journalDir(dd)); len(got) != 0 { |
| 111 | t.Errorf("temp files left behind after a successful write: %v", got) |
| 112 | } |
| 113 | } |
| 114 | |
| 115 | func TestReadJournalRefusesASymlinkAndAFIFO(t *testing.T) { |
| 116 | dd := t.TempDir() |
| 117 | dir := filepath.Join(dd, "layout") |
| 118 | os.MkdirAll(dir, 0o700) |
| 119 | target := filepath.Join(dd, "elsewhere.json") |
| 120 | os.WriteFile(target, []byte(`{"host":"h.example.com"}`), 0o600) |
| 121 | if err := os.Symlink(target, filepath.Join(dir, "h.example.com.json")); err != nil { |
| 122 | t.Skip(err) |
| 123 | } |
| 124 | if _, _, err := ReadJournal(dd, "h.example.com"); err == nil { |
| 125 | t.Error("a symlinked journal was read") |
| 126 | } |
| 127 | } |
| 128 | |
| 129 | // TestReadJournalRefusesAHostMismatch pins that ReadJournal checks the |
| 130 | // journal's own content against the path it was read from: a file whose |
| 131 | // content names a different host than the file name itself is a sign |
| 132 | // something replaced it (a copy, a stale hard link, a bug in whatever |
| 133 | // wrote it) and must not be trusted for that host's recovery. |
| 134 | func TestReadJournalRefusesAHostMismatch(t *testing.T) { |
| 135 | dd := t.TempDir() |
| 136 | dir := filepath.Join(dd, "layout") |
| 137 | if err := os.MkdirAll(dir, 0o700); err != nil { |
| 138 | t.Fatal(err) |
| 139 | } |
| 140 | j := journalFor(t, dd, Mode("nested")) |
| 141 | j.Host = "other.example.com" |
| 142 | raw, err := json.Marshal(j) |
| 143 | if err != nil { |
| 144 | t.Fatal(err) |
| 145 | } |
| 146 | if err := os.WriteFile(filepath.Join(dir, "h.example.com.json"), raw, 0o600); err != nil { |
| 147 | t.Fatal(err) |
| 148 | } |
| 149 | if _, _, err := ReadJournal(dd, "h.example.com"); err == nil { |
| 150 | t.Error("a journal whose content names a different host than its path was read") |
| 151 | } |
| 152 | } |
| 153 | |
| 154 | func TestValidateChecksBothEndpointsFromTheJournalsOwnDirection(t *testing.T) { |
| 155 | dd := t.TempDir() |
| 156 | j := journalFor(t, dd, Mode("nested")) |
| 157 | flat := registry.Route{Host: "h.example.com", DBPath: j.From.DB, Socket: j.From.Socket} |
| 158 | nested := registry.Route{Host: "h.example.com", DBPath: j.To.DB, Socket: j.To.Socket} |
| 159 | if atFrom, err := Validate(j, dd, "/run/carlos", flat); err != nil || !atFrom { |
| 160 | t.Errorf("row at from: %v %v", atFrom, err) |
| 161 | } |
| 162 | if atFrom, err := Validate(j, dd, "/run/carlos", nested); err != nil || atFrom { |
| 163 | t.Errorf("row at to: %v %v", atFrom, err) |
| 164 | } |
| 165 | other := registry.Route{Host: "h.example.com", DBPath: "/data/other.db", Socket: j.From.Socket} |
| 166 | if _, err := Validate(j, dd, "/run/carlos", other); err == nil { |
| 167 | t.Error("a row at neither endpoint was accepted") |
| 168 | } |
| 169 | // A forged journal whose `to` points somewhere else is refused whatever the row says. |
| 170 | forged := *j |
| 171 | forged.To.DB = "/data/console/console.db" |
| 172 | if _, err := Validate(&forged, dd, "/run/carlos", flat); err == nil { |
| 173 | t.Error("a journal with a foreign destination was accepted") |
| 174 | } |
| 175 | forged = *j |
| 176 | forged.From.DB = "/data/console/console.db" |
| 177 | if _, err := Validate(&forged, dd, "/run/carlos", flat); err == nil { |
| 178 | t.Error("a journal with a foreign source was accepted") |
| 179 | } |
| 180 | // The flat direction swaps the two, and Validate derives from the mode. |
| 181 | r := journalFor(t, dd, Mode("flat")) |
| 182 | if atFrom, err := Validate(r, dd, "/run/carlos", nested); err != nil || !atFrom { |
| 183 | t.Errorf("revert journal, row at from (nested): %v %v", atFrom, err) |
| 184 | } |
| 185 | } |
| 186 | |
| 187 | // TestValidateRefusesAWrongArtifactCount: a journal that predates the two |
| 188 | // newest artifacts (or one that is simply malformed) must not be treated |
| 189 | // as valid just because its endpoints happen to match. |
| 190 | func TestValidateRefusesAWrongArtifactCount(t *testing.T) { |
| 191 | dd := t.TempDir() |
| 192 | j := journalFor(t, dd, Mode("nested")) |
| 193 | short := *j |
| 194 | short.Artifacts = j.Artifacts[:7] |
| 195 | row := registry.Route{Host: "h.example.com", DBPath: j.From.DB, Socket: j.From.Socket} |
| 196 | if _, err := Validate(&short, dd, "/run/carlos", row); err == nil { |
| 197 | t.Error("a journal with only 7 of the 9 artifacts was accepted") |
| 198 | } |
| 199 | } |
| 200 | |
| 201 | // TestValidateRequiresTheDBArtifactPresent pins that a forged journal |
| 202 | // cannot claim its db was never there and still be accepted: everything |
| 203 | // else in an optional artifact list can be absent and the pass simply |
| 204 | // skips it, but a journal with no db describes a move that cannot have |
| 205 | // happened, whatever its endpoints say. |
| 206 | func TestValidateRequiresTheDBArtifactPresent(t *testing.T) { |
| 207 | dd := t.TempDir() |
| 208 | j := journalFor(t, dd, Mode("nested")) |
| 209 | row := registry.Route{Host: "h.example.com", DBPath: j.From.DB, Socket: j.From.Socket} |
| 210 | forged := *j |
| 211 | forged.Artifacts = append([]JournalArtifact(nil), j.Artifacts...) |
| 212 | forged.Artifacts[0].Present = false |
| 213 | if _, err := Validate(&forged, dd, "/run/carlos", row); err == nil { |
| 214 | t.Error("a journal whose db artifact is not present was accepted") |
| 215 | } |
| 216 | } |
| 217 | |
| 218 | // TestValidateRefusesAnUndefinedPhase pins that Validate only accepts the |
| 219 | // three phases the pass itself ever writes: an empty or unrecognized phase |
| 220 | // is a sign of a forged or corrupted journal, not one from an earlier |
| 221 | // build that Validate should try to interpret. |
| 222 | func TestValidateRefusesAnUndefinedPhase(t *testing.T) { |
| 223 | dd := t.TempDir() |
| 224 | j := journalFor(t, dd, Mode("nested")) |
| 225 | row := registry.Route{Host: "h.example.com", DBPath: j.From.DB, Socket: j.From.Socket} |
| 226 | for _, phase := range []Phase{"", "bogus"} { |
| 227 | forged := *j |
| 228 | forged.Phase = phase |
| 229 | if _, err := Validate(&forged, dd, "/run/carlos", row); err == nil { |
| 230 | t.Errorf("phase %q was accepted", phase) |
| 231 | } |
| 232 | } |
| 233 | } |
| 234 | |
| 235 | func TestJournalPresent(t *testing.T) { |
| 236 | dd := t.TempDir() |
| 237 | if JournalPresent(dd, "h.example.com") { |
| 238 | t.Error("no journal written yet, JournalPresent = true") |
| 239 | } |
| 240 | j := journalFor(t, dd, Mode("nested")) |
| 241 | if err := WriteJournal(dd, j); err != nil { |
| 242 | t.Fatal(err) |
| 243 | } |
| 244 | if !JournalPresent(dd, "h.example.com") { |
| 245 | t.Error("journal written, JournalPresent = false") |
| 246 | } |
| 247 | if err := RemoveJournal(dd, "h.example.com"); err != nil { |
| 248 | t.Fatal(err) |
| 249 | } |
| 250 | if JournalPresent(dd, "h.example.com") { |
| 251 | t.Error("journal removed, JournalPresent = true") |
| 252 | } |
| 253 | } |
| 254 | |
| 255 | // ReplaceJournal hands a host over from one journal to the next in one |
| 256 | // rename: at every instant the final name holds one whole journal, so the |
| 257 | // host is never without one. |
| 258 | func TestReplaceJournalSwapsContentsInOneStep(t *testing.T) { |
| 259 | dd := t.TempDir() |
| 260 | old := journalFor(t, dd, ModeFlat) |
| 261 | if err := WriteJournal(dd, old); err != nil { |
| 262 | t.Fatal(err) |
| 263 | } |
| 264 | if err := UpdatePhase(dd, old, PhaseDone); err != nil { |
| 265 | t.Fatal(err) |
| 266 | } |
| 267 | next := journalFor(t, dd, ModeNested) |
| 268 | if err := ReplaceJournal(dd, old, next); err != nil { |
| 269 | t.Fatalf("ReplaceJournal: %v", err) |
| 270 | } |
| 271 | got, found, err := ReadJournal(dd, "h.example.com") |
| 272 | if err != nil || !found || got.Mode != ModeNested || got.Phase != PhaseFiles { |
| 273 | t.Fatalf("after replace: %+v %v %v", got, found, err) |
| 274 | } |
| 275 | if left := tempFilesIn(t, journalDir(dd)); len(left) != 0 { |
| 276 | t.Errorf("temp files left: %v", left) |
| 277 | } |
| 278 | if entries, _ := os.ReadDir(journalDir(dd)); len(entries) != 1 { |
| 279 | t.Errorf("journal dir holds %d entries, want exactly one", len(entries)) |
| 280 | } |
| 281 | } |
| 282 | |
| 283 | // The file replaced must be the journal the caller holds: anything else |
| 284 | // there (a newer phase, another direction, nothing at all) is refused and |
| 285 | // left exactly as it is. |
| 286 | func TestReplaceJournalRefusesAnythingButTheHeldJournal(t *testing.T) { |
| 287 | dd := t.TempDir() |
| 288 | held := journalFor(t, dd, ModeFlat) |
| 289 | next := journalFor(t, dd, ModeNested) |
| 290 | if err := ReplaceJournal(dd, held, next); err == nil { |
| 291 | t.Fatal("replaced a journal that is not there") |
| 292 | } |
| 293 | if JournalPresent(dd, "h.example.com") { |
| 294 | t.Fatal("a journal was created where none was held") |
| 295 | } |
| 296 | if err := WriteJournal(dd, held); err != nil { |
| 297 | t.Fatal(err) |
| 298 | } |
| 299 | stale := *held |
| 300 | stale.Phase = PhaseDone |
| 301 | if err := ReplaceJournal(dd, &stale, next); err == nil { |
| 302 | t.Fatal("replaced a journal whose contents differ from the held one") |
| 303 | } |
| 304 | other := journalFor(t, dd, ModeNested) |
| 305 | other.Host = "o.example.com" |
| 306 | if err := ReplaceJournal(dd, held, other); err == nil { |
| 307 | t.Fatal("replaced one host's journal with another host's") |
| 308 | } |
| 309 | got, _, _ := ReadJournal(dd, "h.example.com") |
| 310 | if got.Mode != ModeFlat || got.Phase != PhaseFiles { |
| 311 | t.Fatalf("the held journal changed: %+v", got) |
| 312 | } |
| 313 | if left := tempFilesIn(t, journalDir(dd)); len(left) != 0 { |
| 314 | t.Errorf("temp files left: %v", left) |
| 315 | } |
| 316 | } |
| 317 | |
| 318 | // JournalHosts names every host with a journal, and nothing for the temp |
| 319 | // files WriteJournal and UpdatePhase leave while they work, or for a |
| 320 | // directory that is not there. |
| 321 | func TestJournalHostsIgnoresTempFiles(t *testing.T) { |
| 322 | dd := t.TempDir() |
| 323 | if hosts, err := JournalHosts(dd); err != nil || len(hosts) != 0 { |
| 324 | t.Fatalf("no directory: %v %v", hosts, err) |
| 325 | } |
| 326 | dir := journalDir(dd) |
| 327 | if err := os.MkdirAll(dir, 0o700); err != nil { |
| 328 | t.Fatal(err) |
| 329 | } |
| 330 | for _, n := range []string{".h.example.com.tmp-1", ".x.json", "notes.txt"} { |
| 331 | if err := os.WriteFile(filepath.Join(dir, n), nil, 0o600); err != nil { |
| 332 | t.Fatal(err) |
| 333 | } |
| 334 | } |
| 335 | if hosts, err := JournalHosts(dd); err != nil || len(hosts) != 0 { |
| 336 | t.Fatalf("temp files only: %v %v", hosts, err) |
| 337 | } |
| 338 | if err := os.WriteFile(filepath.Join(dir, "h.example.com.json"), []byte("{}"), 0o600); err != nil { |
| 339 | t.Fatal(err) |
| 340 | } |
| 341 | if hosts, err := JournalHosts(dd); err != nil || len(hosts) != 1 || hosts[0] != "h.example.com" { |
| 342 | t.Fatalf("one journal: %v %v", hosts, err) |
| 343 | } |
| 344 | } |
| 345 | |