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