1package kv
2
3import (
4 "encoding/binary"
5 "errors"
6 "os"
7 "path/filepath"
8 "testing"
9
10 "github.com/stretchr/testify/require"
11)
12
13func TestSyncCrashBoundaries(t *testing.T) {
14 t.Parallel()
15 tests := []struct {
16 name string
17 leaveTmp bool
18 corrupt bool
19 wantOpen bool
20 wantValue string
21 }{
22 {name: "clean file opens", wantOpen: true, wantValue: "v1"},
23 {name: "stray tmp is ignored", leaveTmp: true, wantOpen: true, wantValue: "v1"},
24 {name: "corrupt file fails closed", corrupt: true},
25 }
26 for _, tt := range tests {
27 t.Run(tt.name, func(t *testing.T) {
28 t.Parallel()
29 path := privateStorePath(t)
30 store, err := Open(path)
31 require.NoError(t, err)
32 require.NoError(t, store.Set([]byte("k"), []byte("v1")))
33 require.NoError(t, store.Sync())
34 require.NoError(t, store.Close())
35
36 if tt.leaveTmp {
37 require.NoError(t, os.WriteFile(path+".tmp", []byte("half written"), 0o600))
38 }
39 if tt.corrupt {
40 data, err := os.ReadFile(path)
41 require.NoError(t, err)
42 data[len(data)-1] ^= 0xff
43 require.NoError(t, os.WriteFile(path, data, 0o600))
44 }
45
46 reopened, err := Open(path)
47 if !tt.wantOpen {
48 require.ErrorIs(t, err, ErrCorrupt)
49 return
50 }
51 require.NoError(t, err)
52 t.Cleanup(func() { _ = reopened.Close() })
53 got, ok := reopened.Get([]byte("k"))
54 require.True(t, ok)
55 require.Equal(t, tt.wantValue, string(got))
56 })
57 }
58}
59
60func TestSyncIsAtomicAcrossKeys(t *testing.T) {
61 t.Parallel()
62 path := privateStorePath(t)
63 store, err := Open(path)
64 require.NoError(t, err)
65 require.NoError(t, store.Set([]byte("old"), []byte("x")))
66 require.NoError(t, store.Sync())
67 require.NoError(t, store.Delete([]byte("old")))
68 require.NoError(t, store.Set([]byte("new"), []byte("x")))
69 require.NoError(t, store.Sync())
70 require.NoError(t, store.Close())
71
72 reopened, err := Open(path)
73 require.NoError(t, err)
74 t.Cleanup(func() { _ = reopened.Close() })
75 _, hadOld := reopened.Get([]byte("old"))
76 _, hasNew := reopened.Get([]byte("new"))
77 require.False(t, hadOld)
78 require.True(t, hasNew)
79}
80
81func TestOpenFailsClosedWithoutMutation(t *testing.T) {
82 t.Parallel()
83 tests := []struct {
84 name string
85 raw []byte
86 }{
87 {name: "empty", raw: []byte{}},
88 {name: "short magic", raw: []byte("VE")},
89 {name: "unknown magic", raw: func() []byte {
90 payload := append([]byte(nil), validEmptyFile()[:filePrefixLen]...)
91 copy(payload[:4], "NOPE")
92 return appendCRC(payload)
93 }()},
94 {name: "unknown version", raw: func() []byte {
95 payload := append([]byte(nil), validEmptyFile()[:filePrefixLen]...)
96 binary.BigEndian.PutUint16(payload[4:6], 99)
97 return appendCRC(payload)
98 }()},
99 {name: "truncated entry", raw: func() []byte {
100 payload := append([]byte(nil), validEmptyFile()[:filePrefixLen]...)
101 binary.BigEndian.PutUint32(payload[6:10], 1)
102 payload = append(payload, 0, 0, 0, 1)
103 return appendCRC(payload)
104 }()},
105 }
106 for _, tt := range tests {
107 t.Run(tt.name, func(t *testing.T) {
108 t.Parallel()
109 path := privateStorePath(t)
110 require.NoError(t, os.WriteFile(path, tt.raw, 0o600))
111 _, err := Open(path)
112 require.ErrorIs(t, err, ErrCorrupt)
113 after, readErr := os.ReadFile(path)
114 require.NoError(t, readErr)
115 require.Equal(t, tt.raw, after)
116 })
117 }
118}
119
120func TestCloseSyncsDirtyStore(t *testing.T) {
121 t.Parallel()
122 path := privateStorePath(t)
123 store, err := Open(path)
124 require.NoError(t, err)
125 require.NoError(t, store.Set([]byte("k"), []byte("v")))
126 require.NoError(t, store.Close())
127
128 reopened, err := Open(path)
129 require.NoError(t, err)
130 t.Cleanup(func() { _ = reopened.Close() })
131 got, ok := reopened.Get([]byte("k"))
132 require.True(t, ok)
133 require.Equal(t, []byte("v"), got)
134}
135
136func TestStoreCopiesValuesAndRangesInKeyOrder(t *testing.T) {
137 t.Parallel()
138 store, err := Open(privateStorePath(t))
139 require.NoError(t, err)
140 t.Cleanup(func() { _ = store.Close() })
141 value := []byte("two")
142 require.NoError(t, store.Set([]byte("b"), value))
143 value[0] = 'x'
144 require.NoError(t, store.Set([]byte("a"), []byte("one")))
145 got, ok := store.Get([]byte("b"))
146 require.True(t, ok)
147 require.Equal(t, []byte("two"), got)
148 got[0] = 'x'
149 gotAgain, _ := store.Get([]byte("b"))
150 require.Equal(t, []byte("two"), gotAgain)
151
152 var keys []string
153 store.Range(func(k, v []byte) bool {
154 keys = append(keys, string(k))
155 return true
156 })
157 require.Equal(t, []string{"a", "b"}, keys)
158}
159
160func TestStoreLockAndClosedErrors(t *testing.T) {
161 t.Parallel()
162 path := privateStorePath(t)
163 store, err := Open(path)
164 require.NoError(t, err)
165 _, err = Open(path)
166 require.Error(t, err)
167 require.NoError(t, store.Close())
168 require.NoError(t, store.Close())
169 require.ErrorIs(t, store.Set([]byte("k"), []byte("v")), os.ErrClosed)
170 require.ErrorIs(t, store.Delete([]byte("k")), os.ErrClosed)
171 require.ErrorIs(t, store.Sync(), os.ErrClosed)
172}
173
174func privateStorePath(t *testing.T) string {
175 t.Helper()
176 dir := filepath.Join(t.TempDir(), "vev")
177 require.NoError(t, os.Mkdir(dir, 0o700))
178 return filepath.Join(dir, "sessions.kv")
179}
180
181func validEmptyFile() []byte {
182 return encodeFile(map[string][]byte{})
183}
184
185func TestDecodeRejectsDuplicateKeysAndTrailingData(t *testing.T) {
186 t.Parallel()
187 payload := make([]byte, 0)
188 payload = append(payload, fileMagic[:]...)
189 payload = binary.BigEndian.AppendUint16(payload, fileVersion)
190 payload = binary.BigEndian.AppendUint32(payload, 2)
191 for range 2 {
192 payload = binary.BigEndian.AppendUint32(payload, 1)
193 payload = append(payload, 'k')
194 payload = binary.BigEndian.AppendUint32(payload, 1)
195 payload = append(payload, 'v')
196 }
197 raw := appendCRC(payload)
198 _, err := decodeFile(raw)
199 require.ErrorIs(t, err, ErrCorrupt)
200
201 payload = append([]byte(nil), validEmptyFile()[:filePrefixLen]...)
202 payload = append(payload, 0)
203 _, err = decodeFile(appendCRC(payload))
204 require.True(t, errors.Is(err, ErrCorrupt))
205}
206
207func TestCloseWithoutSyncPreventsLaterCloseFromSyncing(t *testing.T) {
208 t.Parallel()
209 path := privateStorePath(t)
210 store, err := Open(path)
211 require.NoError(t, err)
212 require.NoError(t, store.Set([]byte("rejected"), []byte("value")))
213 require.NoError(t, store.CloseWithoutSync())
214 require.NoError(t, store.Close(), "a later normal Close must remain a no-op")
215
216 reopened, err := Open(path)
217 require.NoError(t, err)
218 t.Cleanup(func() { _ = reopened.Close() })
219 _, ok := reopened.Get([]byte("rejected"))
220 require.False(t, ok)
221}