1package persist
2
3import (
4 "encoding/binary"
5 "os"
6 "path/filepath"
7 "testing"
8
9 "github.com/stretchr/testify/require"
10
11 "github.com/bnema/vev/internal/domain"
12 "github.com/bnema/vev/internal/ports"
13 "github.com/bnema/vev/pkg/kv"
14 "github.com/bnema/vev/pkg/safedir"
15)
16
17func TestCatalogue(t *testing.T) {
18 t.Run("record-round-trip", testCatalogueRecordRoundTrip)
19 t.Run("duplicate-incarnations", testCatalogueDuplicateIncarnations)
20 t.Run("open-fails-closed", testCatalogueOpenFailsClosed)
21 t.Run("apply-rename-replace", testCatalogueApplyRenameReplace)
22 t.Run("metadata-update-preserves-authority", testCatalogueMetadataUpdatePreservesAuthority)
23 t.Run("metadata-update-is-deferred", testCatalogueMetadataUpdateIsDeferred)
24 t.Run("read-only-malformed", testCatalogueLoadReadOnlyRejectsMalformedValue)
25 t.Run("read-only-fresh-install", testCatalogueLoadReadOnlyFreshInstallHasNoSessions)
26 t.Run("read-only-ignores-stray-tmp", testCatalogueLoadReadOnlyIgnoresStrayTmp)
27}
28
29func testCatalogueRecordRoundTrip(t *testing.T) {
30 ref1 := &domain.CheckpointRef{Generation: 9, ManifestDigest: [32]byte{1}}
31 tests := []domain.CatalogueRecord{
32 {Name: "fresh", IncarnationID: domain.IncarnationID{1}, Cwd: "/tmp"},
33 {Name: "healthy", IncarnationID: domain.IncarnationID{2}, Committed: ref1},
34 {Name: "broken", IncarnationID: domain.IncarnationID{4}, Committed: ref1, DegradedReason: "invalid manifest"},
35 {Name: "other-fresh", IncarnationID: domain.IncarnationID{5}},
36 }
37 for _, record := range tests {
38 t.Run(record.Name, func(t *testing.T) {
39 encoded, err := encodeRecordValue(record)
40 require.NoError(t, err)
41 got, err := decodeRecordValue(record.Name, encoded)
42 require.NoError(t, err)
43 require.Equal(t, record, got)
44 for prefix := range len(encoded) {
45 _, err := decodeRecordValue(record.Name, encoded[:prefix])
46 require.Error(t, err, "prefix length %d", prefix)
47 }
48 require.Error(t, func() error { _, err := decodeRecordValue(record.Name, append(encoded, 0)); return err }())
49 })
50 }
51}
52
53func TestOpenOrCreateReportsRecordsFromOlderProtocol(t *testing.T) {
54 record := domain.CatalogueRecord{
55 Name: "work", IncarnationID: domain.IncarnationID{1}, Cwd: "/workspace",
56 CreatedAt: 11, UpdatedAt: 22, LastUsedSeq: 33,
57 TabNames: []string{"shell", "logs"},
58 Committed: &domain.CheckpointRef{Generation: 3, ManifestDigest: [32]byte{3}},
59 }
60 olderProtocol, err := encodeRecordValueForProtocol(record, ports.ProtocolVersion-1)
61 require.NoError(t, err)
62 protocolLess, err := encodeRecordValue(record)
63 require.NoError(t, err)
64 binary.BigEndian.PutUint16(protocolLess[len(catalogueMagic):], protocolLessCatalogueRecordVersion)
65 protocolLess = append(protocolLess[:len(catalogueMagic)+2], protocolLess[len(catalogueMagic)+4:]...)
66
67 currentProtocol, err := encodeRecordValue(record)
68 require.NoError(t, err)
69 for _, tt := range []struct {
70 name string
71 encoded []byte
72 incompatible bool
73 }{
74 {name: "current protocol", encoded: currentProtocol},
75 {name: "recorded older protocol", encoded: olderProtocol, incompatible: true},
76 {name: "protocol-less v4 record", encoded: protocolLess, incompatible: true},
77 } {
78 t.Run(tt.name, func(t *testing.T) {
79 dir := privateDir(t)
80 store, err := kv.Open(StorePath(dir))
81 require.NoError(t, err)
82 require.NoError(t, store.Set([]byte(record.Name), tt.encoded))
83 require.NoError(t, store.Close())
84
85 opened, err := OpenOrCreate(dir)
86 require.NoError(t, err)
87 defer func() { require.NoError(t, opened.Catalogue.Close()) }()
88 require.Equal(t, []domain.CatalogueRecord{record}, opened.Records)
89 if tt.incompatible {
90 require.Equal(t, []domain.CatalogueRecord{record}, opened.IncompatibleRecords)
91 } else {
92 require.Empty(t, opened.IncompatibleRecords)
93 }
94 })
95 }
96}
97
98func TestDecodeRecordValueRejectsMalformedCheckpointMarker(t *testing.T) {
99 encoded, err := encodeRecordValue(validRecord("work", 1))
100 require.NoError(t, err)
101 encoded[checkpointMarkerOffset(t, encoded)] = 99
102
103 _, err = decodeRecordValue("work", encoded)
104 require.ErrorIs(t, err, errMalformedRecord)
105}
106
107func checkpointMarkerOffset(t *testing.T, encoded []byte) int {
108 t.Helper()
109 r := valueReader{data: encoded}
110 _, ok := r.take(len(catalogueMagic) + 2 + 2 + len(domain.IncarnationID{}))
111 require.True(t, ok)
112 _, ok = r.str()
113 require.True(t, ok)
114 _, ok = r.take(8 + 8 + 8)
115 require.True(t, ok)
116 count, ok := r.u32()
117 require.True(t, ok)
118 for range count {
119 _, ok = r.str()
120 require.True(t, ok)
121 }
122 return len(encoded) - r.remaining()
123}
124
125func testCatalogueDuplicateIncarnations(t *testing.T) {
126 id := domain.IncarnationID{1}
127 require.Error(t, validateUniqueIncarnations([]domain.CatalogueRecord{
128 {Name: "one", IncarnationID: id},
129 {Name: "two", IncarnationID: id},
130 }))
131}
132
133func testCatalogueOpenFailsClosed(t *testing.T) {
134 dir := privateDir(t)
135 _, _, err := openCurrentCatalogue(dir, false)
136 require.Error(t, err)
137
138 require.NoError(t, safedir.EnsurePrivate(dir))
139 raw := []byte("corrupt catalogue")
140 require.NoError(t, os.WriteFile(StorePath(dir), raw, 0o600))
141 _, _, err = openCurrentCatalogue(dir, false)
142 require.ErrorIs(t, err, kv.ErrCorrupt)
143 after, readErr := os.ReadFile(StorePath(dir))
144 require.NoError(t, readErr)
145 require.Equal(t, raw, after)
146}
147
148func testCatalogueApplyRenameReplace(t *testing.T) {
149 dir := privateDir(t)
150 p, records, err := openCurrentCatalogue(dir, true)
151 require.NoError(t, err)
152 require.Empty(t, records)
153 defer func() { require.NoError(t, p.Close()) }()
154
155 one := domain.CatalogueRecord{Name: "one", IncarnationID: domain.IncarnationID{1}}
156 two := domain.CatalogueRecord{Name: "two", IncarnationID: domain.IncarnationID{2}}
157 require.NoError(t, p.Apply(map[string]*domain.CatalogueRecord{"one": &one, "two": &two}))
158 renamed := one
159 renamed.Name = "renamed"
160 require.NoError(t, p.Rename("one", renamed))
161 two.Cwd = "/next"
162 require.NoError(t, p.Replace("two", two))
163
164 got, err := p.LoadCatalogue()
165 require.NoError(t, err)
166 require.ElementsMatch(t, []domain.CatalogueRecord{renamed, two}, got)
167}
168
169func testCatalogueMetadataUpdatePreservesAuthority(t *testing.T) {
170 dir := privateDir(t)
171 p, _, err := openCurrentCatalogue(dir, true)
172 require.NoError(t, err)
173 defer func() { require.NoError(t, p.Close()) }()
174
175 committed := &domain.CheckpointRef{Generation: 9, ManifestDigest: [32]byte{1}}
176 original := domain.CatalogueRecord{
177 Name: "work", IncarnationID: domain.IncarnationID{1}, Cwd: "/old", CreatedAt: 11,
178 UpdatedAt: 12, LastUsedSeq: 13, TabNames: []string{"old"},
179 Committed: committed, DegradedReason: "repair pending",
180 }
181 require.NoError(t, p.Create(original))
182
183 next := domain.CatalogueRecord{Name: "work", IncarnationID: original.IncarnationID, Cwd: "/new", UpdatedAt: 22, LastUsedSeq: 23, TabNames: []string{"editor", "logs"}}
184 update := next.MetadataUpdate()
185 require.NoError(t, p.UpdateMetadata(update))
186 got, ok, err := p.Record("work")
187 require.NoError(t, err)
188 require.True(t, ok)
189 expected := original
190 expected.Cwd, expected.UpdatedAt, expected.LastUsedSeq, expected.TabNames = next.Cwd, next.UpdatedAt, next.LastUsedSeq, next.TabNames
191 require.Equal(t, expected, got)
192
193 lastUsedSeq := uint64(24)
194 require.NoError(t, p.UpdateMetadata(domain.CatalogueMetadataUpdate{
195 Name: "work", IncarnationID: original.IncarnationID, LastUsedSeq: &lastUsedSeq,
196 }))
197 expected.LastUsedSeq = lastUsedSeq
198 partiallyUpdated, ok, err := p.Record("work")
199 require.NoError(t, err)
200 require.True(t, ok)
201 require.Equal(t, expected, partiallyUpdated, "a partial metadata update must not clear other mutable or authority-owned fields")
202
203 stale := update
204 stale.IncarnationID = domain.IncarnationID{2}
205 require.Error(t, p.UpdateMetadata(stale))
206 unchanged, ok, err := p.Record("work")
207 require.NoError(t, err)
208 require.True(t, ok)
209 require.Equal(t, expected, unchanged)
210}
211
212func testCatalogueMetadataUpdateIsDeferred(t *testing.T) {
213 dir := privateDir(t)
214 p, _, err := openCurrentCatalogue(dir, true)
215 require.NoError(t, err)
216 defer func() { require.NoError(t, p.Close()) }()
217
218 record := domain.CatalogueRecord{Name: "work", IncarnationID: domain.IncarnationID{1}, Cwd: "/old"}
219 require.NoError(t, p.Create(record))
220 before, err := os.ReadFile(StorePath(dir))
221 require.NoError(t, err)
222
223 cwd := "/buffered"
224 require.NoError(t, p.UpdateMetadata(domain.CatalogueMetadataUpdate{
225 Name: record.Name, IncarnationID: record.IncarnationID, Cwd: &cwd,
226 }))
227 afterBuffered, err := os.ReadFile(StorePath(dir))
228 require.NoError(t, err)
229 require.Equal(t, before, afterBuffered, "metadata must remain buffered before Sync")
230
231 require.NoError(t, p.Sync())
232 afterSync, err := os.ReadFile(StorePath(dir))
233 require.NoError(t, err)
234 require.NotEqual(t, before, afterSync)
235
236 cwd = "/next-identity"
237 require.NoError(t, p.UpdateMetadata(domain.CatalogueMetadataUpdate{
238 Name: record.Name, IncarnationID: record.IncarnationID, Cwd: &cwd,
239 }))
240 record.Cwd = cwd
241 record.UpdatedAt = 2
242 require.NoError(t, p.Replace(record.Name, record))
243 afterIdentity, err := os.ReadFile(StorePath(dir))
244 require.NoError(t, err)
245 require.NotEqual(t, afterSync, afterIdentity, "identity writes must sync before returning")
246}
247
248func testCatalogueLoadReadOnlyRejectsMalformedValue(t *testing.T) {
249 dir := privateDir(t)
250 store, err := kv.Open(filepath.Join(dir, filename))
251 require.NoError(t, err)
252 require.NoError(t, store.Set([]byte("work"), []byte("malformed")))
253 require.NoError(t, store.Close())
254 _, err = LoadCatalogueReadOnly(dir)
255 require.Error(t, err)
256}
257
258// testCatalogueLoadReadOnlyFreshInstallHasNoSessions covers `vev ls` on a
259// machine with no daemon and no catalogue. This must yield an empty catalogue,
260// not an error.
261func testCatalogueLoadReadOnlyFreshInstallHasNoSessions(t *testing.T) {
262 dir := privateDir(t)
263 records, err := LoadCatalogueReadOnly(dir)
264 require.NoError(t, err)
265 require.Empty(t, records)
266}
267
268func testCatalogueLoadReadOnlyIgnoresStrayTmp(t *testing.T) {
269 dir := privateDir(t)
270 p, _, err := openCurrentCatalogue(dir, true)
271 require.NoError(t, err)
272 want := validRecord("work", 1)
273 require.NoError(t, p.Create(want))
274 require.NoError(t, p.Close())
275 require.NoError(t, os.WriteFile(StorePath(dir)+".tmp", []byte("partial rewrite"), 0o600))
276
277 records, err := LoadCatalogueReadOnly(dir)
278 require.NoError(t, err)
279 require.Equal(t, []domain.CatalogueRecord{want}, records)
280 tmp, err := os.ReadFile(StorePath(dir) + ".tmp")
281 require.NoError(t, err)
282 require.Equal(t, []byte("partial rewrite"), tmp)
283}