| 1 | //go:build linux |
| 2 | |
| 3 | package provisioner |
| 4 | |
| 5 | import ( |
| 6 | family "amadan.net/rastrillo/rastrillo/crypto" |
| 7 | "bytes" |
| 8 | "context" |
| 9 | "errors" |
| 10 | "io" |
| 11 | "os" |
| 12 | "os/exec" |
| 13 | "path/filepath" |
| 14 | "testing" |
| 15 | ) |
| 16 | |
| 17 | func journalFixture(t *testing.T) *journal { |
| 18 | t.Helper() |
| 19 | f, k := floorFixture(t) |
| 20 | if e := f.initialize(k); e != nil { |
| 21 | t.Fatal(e) |
| 22 | } |
| 23 | d, e := openPrivateDirectory(persistentDirectory(t)) |
| 24 | if e != nil { |
| 25 | t.Fatal(e) |
| 26 | } |
| 27 | t.Cleanup(func() { d.close() }) |
| 28 | j, e := openJournal(d, f, k, true) |
| 29 | if e != nil { |
| 30 | t.Fatal(e) |
| 31 | } |
| 32 | t.Cleanup(func() { j.close() }) |
| 33 | return j |
| 34 | } |
| 35 | func TestJournalCrashRecoveryAndExactReplay(t *testing.T) { |
| 36 | for _, boundary := range []string{"prepared", "floor", "finalized"} { |
| 37 | t.Run(boundary, func(t *testing.T) { |
| 38 | j := journalFixture(t) |
| 39 | synthetic := []byte("private-owner-481@example.invalid") |
| 40 | stop := errors.New("synthetic interruption") |
| 41 | j.boundary = func(stage string) error { |
| 42 | if stage == boundary { |
| 43 | return stop |
| 44 | } |
| 45 | return nil |
| 46 | } |
| 47 | if _, e := j.append(context.Background(), testToken(5), testToken(6), "directory.seed-team", "unseeded", synthetic); e != stop { |
| 48 | t.Fatal("boundary not reached", e) |
| 49 | } |
| 50 | j.close() |
| 51 | reopened, e := openJournal(j.dir, j.floor, j.key, false) |
| 52 | if e != nil { |
| 53 | t.Fatal("resume", e) |
| 54 | } |
| 55 | defer reopened.close() |
| 56 | first, e := reopened.append(context.Background(), testToken(5), testToken(6), "directory.seed-team", "unseeded", synthetic) |
| 57 | if e != nil { |
| 58 | t.Fatal(e) |
| 59 | } |
| 60 | repeated, e := reopened.append(context.Background(), testToken(5), testToken(6), "directory.seed-team", "unseeded", synthetic) |
| 61 | if e != nil || !bytes.Equal(first, repeated) { |
| 62 | t.Fatal("replay changed record", e) |
| 63 | } |
| 64 | if _, e = reopened.append(context.Background(), testToken(5), testToken(6), "directory.seed-team", "unseeded", []byte("changed")); e != ErrRefused { |
| 65 | t.Fatal("changed replay", e) |
| 66 | } |
| 67 | var n int |
| 68 | if e = reopened.db.QueryRow("SELECT count(*) FROM records WHERE state='finalized'").Scan(&n); e != nil || n != 1 { |
| 69 | t.Fatal("non-atomic journal", e) |
| 70 | } |
| 71 | for _, suffix := range []string{"", "-wal", "-shm"} { |
| 72 | data, e := os.ReadFile(filepath.Join(j.dir.file.Name(), "registry.db") + suffix) |
| 73 | if e != nil && !errors.Is(e, os.ErrNotExist) { |
| 74 | t.Fatal(e) |
| 75 | } |
| 76 | if bytes.Contains(data, synthetic) { |
| 77 | t.Fatal("plaintext journal payload") |
| 78 | } |
| 79 | } |
| 80 | }) |
| 81 | } |
| 82 | } |
| 83 | func TestJournalSurvivingFloorRefusesOldBackup(t *testing.T) { |
| 84 | j := journalFixture(t) |
| 85 | if _, e := j.append(context.Background(), testToken(5), testToken(6), "directory.initialize", "absent", []byte("first private input")); e != nil { |
| 86 | t.Fatal(e) |
| 87 | } |
| 88 | if _, e := j.db.Exec("PRAGMA wal_checkpoint(TRUNCATE)"); e != nil { |
| 89 | t.Fatal(e) |
| 90 | } |
| 91 | backup, e := os.ReadFile(filepath.Join(j.dir.file.Name(), "registry.db")) |
| 92 | if e != nil { |
| 93 | t.Fatal(e) |
| 94 | } |
| 95 | if _, e = j.append(context.Background(), testToken(5), testToken(7), "directory.seed-team", "empty", []byte("second private input")); e != nil { |
| 96 | t.Fatal(e) |
| 97 | } |
| 98 | j.close() |
| 99 | if e = os.WriteFile(filepath.Join(j.dir.file.Name(), "registry.db"), backup, 0600); e != nil { |
| 100 | t.Fatal(e) |
| 101 | } |
| 102 | if restored, e := openJournal(j.dir, j.floor, j.key, false); e != ErrRefused { |
| 103 | if restored != nil { |
| 104 | restored.close() |
| 105 | } |
| 106 | t.Fatal("stale backup accepted", e) |
| 107 | } |
| 108 | } |
| 109 | |
| 110 | func TestJournalProcessDeathRecovery(t *testing.T) { |
| 111 | type childInput struct { |
| 112 | State, Floor, Boundary string |
| 113 | Identity floorIdentity |
| 114 | Key []byte |
| 115 | } |
| 116 | if os.Getenv("OFICINA_JOURNAL_CHILD") == "1" { |
| 117 | raw, e := io.ReadAll(io.LimitReader(os.Stdin, 16385)) |
| 118 | if e != nil || len(raw) > 16384 { |
| 119 | t.Fatal("private child input") |
| 120 | } |
| 121 | var in childInput |
| 122 | if closedJSON(raw, &in) != nil { |
| 123 | t.Fatal("child shape") |
| 124 | } |
| 125 | key, e := family.UnmarshalKeypair(in.Key) |
| 126 | if e != nil { |
| 127 | t.Fatal("child key") |
| 128 | } |
| 129 | dir, e := openPrivateDirectory(in.State) |
| 130 | if e != nil { |
| 131 | t.Fatal(e) |
| 132 | } |
| 133 | defer dir.close() |
| 134 | floorDir, e := openPrivateDirectory(in.Floor) |
| 135 | if e != nil { |
| 136 | t.Fatal(e) |
| 137 | } |
| 138 | defer floorDir.close() |
| 139 | floor, e := newNativeFloor(floorDir, in.Identity) |
| 140 | if e != nil { |
| 141 | t.Fatal(e) |
| 142 | } |
| 143 | j, e := openJournal(dir, floor, key, false) |
| 144 | if e != nil { |
| 145 | t.Fatal(e) |
| 146 | } |
| 147 | defer j.close() |
| 148 | floor.markerBoundary = func() error { |
| 149 | if in.Boundary == "marker-created" { |
| 150 | os.Exit(72) |
| 151 | } |
| 152 | return nil |
| 153 | } |
| 154 | j.boundary = func(stage string) error { |
| 155 | if stage == in.Boundary { |
| 156 | os.Exit(72) |
| 157 | } |
| 158 | return nil |
| 159 | } |
| 160 | if _, e = j.append(context.Background(), testToken(5), testToken(6), "directory.seed-team", "empty", []byte("synthetic process-death payload")); e != nil { |
| 161 | t.Fatal(e) |
| 162 | } |
| 163 | t.Fatal("death boundary not reached") |
| 164 | } |
| 165 | for _, stage := range []string{"prepared", "marker-created", "floor", "finalized"} { |
| 166 | t.Run(stage, func(t *testing.T) { |
| 167 | j := journalFixture(t) |
| 168 | j.close() |
| 169 | key, e := family.MarshalKeypair(j.key) |
| 170 | if e != nil { |
| 171 | t.Fatal(e) |
| 172 | } |
| 173 | input, e := canonicalJSON(childInput{j.dir.file.Name(), j.floor.dir.file.Name(), stage, j.floor.identity, key}) |
| 174 | if e != nil { |
| 175 | t.Fatal(e) |
| 176 | } |
| 177 | cmd := exec.Command(os.Args[0], "-test.run=^TestJournalProcessDeathRecovery$") |
| 178 | cmd.Env = append(os.Environ(), "OFICINA_JOURNAL_CHILD=1") |
| 179 | cmd.Stdin = bytes.NewReader(input) |
| 180 | output, e := cmd.CombinedOutput() |
| 181 | var exit *exec.ExitError |
| 182 | if !errors.As(e, &exit) || exit.ExitCode() != 72 { |
| 183 | t.Fatalf("child did not stop at durable boundary: %v %s", e, output) |
| 184 | } |
| 185 | reopened, e := openJournal(j.dir, j.floor, j.key, false) |
| 186 | if e != nil { |
| 187 | t.Fatal("process death resume", e) |
| 188 | } |
| 189 | defer reopened.close() |
| 190 | if _, e = reopened.append(context.Background(), testToken(5), testToken(6), "directory.seed-team", "empty", []byte("synthetic process-death payload")); e != nil { |
| 191 | t.Fatal(e) |
| 192 | } |
| 193 | point, _, e := j.floor.current() |
| 194 | if e != nil || point.Sequence != 1 { |
| 195 | t.Fatal("sequence reused", e) |
| 196 | } |
| 197 | if _, e = reopened.append(context.Background(), testToken(5), testToken(7), "directory.seed-team", "empty", []byte("next operation")); e != nil { |
| 198 | t.Fatal("next operation did not self-heal marker", e) |
| 199 | } |
| 200 | point, _, e = j.floor.current() |
| 201 | if e != nil || point.Sequence != 2 { |
| 202 | t.Fatal("next sequence", e) |
| 203 | } |
| 204 | }) |
| 205 | } |
| 206 | } |
| 207 | |
| 208 | func TestJournalPublicMetadataRejectsPrivateOrArbitraryText(t *testing.T) { |
| 209 | j := journalFixture(t) |
| 210 | for _, pair := range [][2]string{{"owner@example.invalid", "empty"}, {"directory.seed-team", "owner@example.invalid"}, {"arbitrary-sql", "empty"}} { |
| 211 | if _, e := j.append(context.Background(), testToken(5), testToken(6), pair[0], pair[1], []byte("sealed")); e != ErrRefused { |
| 212 | t.Fatal("arbitrary public journal metadata", e) |
| 213 | } |
| 214 | } |
| 215 | var n int |
| 216 | if e := j.db.QueryRow("SELECT count(*) FROM records").Scan(&n); e != nil || n != 0 { |
| 217 | t.Fatal("rejected metadata persisted", e) |
| 218 | } |
| 219 | } |
| 220 | |