feat(record): add log and snapshot codec with fuzz test
- commit
- 15cd1c533fa64fa05d013dbceeffcccdd1811922
- author
- brice · · 2026-10-10 01:52 UTC
- committer
- brice · · 2026-10-10 01:57 UTC
- parent
- 72a9140
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +666 -0
A errors.go +24 -0
| @@ -0,0 +1,24 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package kv | |
| 4 | + | ||
| 5 | + | import "errors" | |
| 6 | + | ||
| 7 | + | var ( | |
| 8 | + | // ErrClosed is returned when the database is closed. | |
| 9 | + | ErrClosed = errors.New("kv: closed") | |
| 10 | + | // ErrFailed is returned once an I/O error put the database in the failed | |
| 11 | + | // state. It wraps the first I/O error. | |
| 12 | + | ErrFailed = errors.New("kv: database failed") | |
| 13 | + | // ErrConflict is returned when a batch precondition does not hold. | |
| 14 | + | ErrConflict = errors.New("kv: precondition failed") | |
| 15 | + | // ErrBatchTooLarge is returned when an encoded batch exceeds the limit. | |
| 16 | + | ErrBatchTooLarge = errors.New("kv: batch too large") | |
| 17 | + | // ErrInvalidKey is returned for an empty key or one longer than 64 KiB. | |
| 18 | + | ErrInvalidKey = errors.New("kv: invalid key") | |
| 19 | + | // ErrLocked is returned when another process holds the directory lock. | |
| 20 | + | ErrLocked = errors.New("kv: directory locked by another process") | |
| 21 | + | // ErrCorrupt is returned when on-disk data is damaged. It is wrapped with | |
| 22 | + | // the file and offset. | |
| 23 | + | ErrCorrupt = errors.New("kv: corrupt data") | |
| 24 | + | ) | |
| @@ -0,0 +1,24 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package kv | |||
| 4 | + | ||||
| 5 | + | import "errors" | |||
| 6 | + | ||||
| 7 | + | var ( | |||
| 8 | + | // ErrClosed is returned when the database is closed. | |||
| 9 | + | ErrClosed = errors.New("kv: closed") | |||
| 10 | + | // ErrFailed is returned once an I/O error put the database in the failed | |||
| 11 | + | // state. It wraps the first I/O error. | |||
| 12 | + | ErrFailed = errors.New("kv: database failed") | |||
| 13 | + | // ErrConflict is returned when a batch precondition does not hold. | |||
| 14 | + | ErrConflict = errors.New("kv: precondition failed") | |||
| 15 | + | // ErrBatchTooLarge is returned when an encoded batch exceeds the limit. | |||
| 16 | + | ErrBatchTooLarge = errors.New("kv: batch too large") | |||
| 17 | + | // ErrInvalidKey is returned for an empty key or one longer than 64 KiB. | |||
| 18 | + | ErrInvalidKey = errors.New("kv: invalid key") | |||
| 19 | + | // ErrLocked is returned when another process holds the directory lock. | |||
| 20 | + | ErrLocked = errors.New("kv: directory locked by another process") | |||
| 21 | + | // ErrCorrupt is returned when on-disk data is damaged. It is wrapped with | |||
| 22 | + | // the file and offset. | |||
| 23 | + | ErrCorrupt = errors.New("kv: corrupt data") | |||
| 24 | + | ) | |||
A record.go +352 -0
| @@ -0,0 +1,352 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package kv | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bufio" | |
| 7 | + | "bytes" | |
| 8 | + | "encoding/binary" | |
| 9 | + | "errors" | |
| 10 | + | "fmt" | |
| 11 | + | "hash/crc32" | |
| 12 | + | "io" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // Operation kinds. | |
| 16 | + | const ( | |
| 17 | + | opPut byte = 1 | |
| 18 | + | opDelete byte = 2 | |
| 19 | + | ) | |
| 20 | + | ||
| 21 | + | // Format limits. | |
| 22 | + | const ( | |
| 23 | + | maxRecord = 64 << 20 // maximum record payload size | |
| 24 | + | maxKeyLen = 64 << 10 // maximum key length | |
| 25 | + | ) | |
| 26 | + | ||
| 27 | + | const ( | |
| 28 | + | logMagic = "EKVLOG01" | |
| 29 | + | snapshotMagic = "EKVSNP01" | |
| 30 | + | logHeaderSize = 8 + 8 + 4 | |
| 31 | + | recordFrame = 4 + 4 // len + crc | |
| 32 | + | recordMinBody = 8 + 4 // seq + count | |
| 33 | + | snapHeaderSize = 8 + 8 + 8 + 4 | |
| 34 | + | minOpSize = 3 // kind + klen + 1 key byte | |
| 35 | + | ) | |
| 36 | + | ||
| 37 | + | var castagnoli = crc32.MakeTable(crc32.Castagnoli) | |
| 38 | + | ||
| 39 | + | // errUnreadable marks a log record that is not readable (see decodeRecord). | |
| 40 | + | var errUnreadable = errors.New("unreadable record") | |
| 41 | + | ||
| 42 | + | // op is one mutation of a batch. value is nil for opDelete. | |
| 43 | + | type op struct { | |
| 44 | + | kind byte | |
| 45 | + | key, value []byte | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // opSize returns the encoded size of o. | |
| 49 | + | func opSize(o op) int { | |
| 50 | + | n := 1 + uvarintLen(uint64(len(o.key))) + len(o.key) | |
| 51 | + | if o.kind == opPut { | |
| 52 | + | n += uvarintLen(uint64(len(o.value))) + len(o.value) | |
| 53 | + | } | |
| 54 | + | return n | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | func uvarintLen(v uint64) int { | |
| 58 | + | n := 1 | |
| 59 | + | for ; v >= 0x80; v >>= 7 { | |
| 60 | + | n++ | |
| 61 | + | } | |
| 62 | + | return n | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | // appendLogHeader appends the 20-byte log header for base to dst. | |
| 66 | + | func appendLogHeader(dst []byte, base uint64) []byte { | |
| 67 | + | start := len(dst) | |
| 68 | + | dst = append(dst, logMagic...) | |
| 69 | + | dst = binary.LittleEndian.AppendUint64(dst, base) | |
| 70 | + | return binary.LittleEndian.AppendUint32(dst, crc32.Checksum(dst[start:], castagnoli)) | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | // decodeLogHeader validates the log header at the start of b and returns its | |
| 74 | + | // base. Errors wrap ErrCorrupt. | |
| 75 | + | func decodeLogHeader(b []byte) (base uint64, err error) { | |
| 76 | + | if len(b) < logHeaderSize { | |
| 77 | + | return 0, fmt.Errorf("%w: short log header", ErrCorrupt) | |
| 78 | + | } | |
| 79 | + | if string(b[:8]) != logMagic { | |
| 80 | + | return 0, fmt.Errorf("%w: bad log magic", ErrCorrupt) | |
| 81 | + | } | |
| 82 | + | if crc32.Checksum(b[:16], castagnoli) != binary.LittleEndian.Uint32(b[16:20]) { | |
| 83 | + | return 0, fmt.Errorf("%w: bad log header checksum", ErrCorrupt) | |
| 84 | + | } | |
| 85 | + | return binary.LittleEndian.Uint64(b[8:16]), nil | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | // appendRecord appends one encoded record (len, crc, seq, count, ops) to dst. | |
| 89 | + | // The payload must not exceed maxRecord and ops must be non-empty with valid | |
| 90 | + | // keys; the caller enforces this (batches are validated before encoding). | |
| 91 | + | func appendRecord(dst []byte, seq uint64, ops []op) []byte { | |
| 92 | + | start := len(dst) | |
| 93 | + | dst = append(dst, make([]byte, recordFrame)...) | |
| 94 | + | dst = binary.LittleEndian.AppendUint64(dst, seq) | |
| 95 | + | dst = binary.LittleEndian.AppendUint32(dst, uint32(len(ops))) | |
| 96 | + | for _, o := range ops { | |
| 97 | + | dst = append(dst, o.kind) | |
| 98 | + | dst = binary.AppendUvarint(dst, uint64(len(o.key))) | |
| 99 | + | dst = append(dst, o.key...) | |
| 100 | + | if o.kind == opPut { | |
| 101 | + | dst = binary.AppendUvarint(dst, uint64(len(o.value))) | |
| 102 | + | dst = append(dst, o.value...) | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | payload := dst[start+recordFrame:] | |
| 106 | + | binary.LittleEndian.PutUint32(dst[start:], uint32(len(payload))) | |
| 107 | + | binary.LittleEndian.PutUint32(dst[start+4:], crc32.Checksum(payload, castagnoli)) | |
| 108 | + | return dst | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | // peekRecord returns the total encoded size (frame + payload) announced by | |
| 112 | + | // the record frame at the start of b. ok is false if fewer than 8 bytes are | |
| 113 | + | // present or the announced payload length is outside [12, maxRecord]. It does | |
| 114 | + | // not check that the payload is present. | |
| 115 | + | func peekRecord(b []byte) (total int, ok bool) { | |
| 116 | + | if len(b) < recordFrame { | |
| 117 | + | return 0, false | |
| 118 | + | } | |
| 119 | + | n := binary.LittleEndian.Uint32(b) | |
| 120 | + | if n < recordMinBody || n > maxRecord { | |
| 121 | + | return 0, false | |
| 122 | + | } | |
| 123 | + | return recordFrame + int(n), true | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | // decodeRecord decodes the record at the start of b. A record is readable | |
| 127 | + | // only if the frame is present, 12 ≤ len ≤ maxRecord, len payload bytes are | |
| 128 | + | // present, the CRC matches, and the payload holds exactly count ≥ 1 valid | |
| 129 | + | // ops consuming exactly len bytes. Otherwise the error wraps errUnreadable. | |
| 130 | + | // | |
| 131 | + | // The decoded ops are appended to ops[:0]; their keys and values alias b. | |
| 132 | + | // n is the number of bytes consumed. | |
| 133 | + | func decodeRecord(b []byte, ops []op) (seq uint64, out []op, n int, err error) { | |
| 134 | + | total, ok := peekRecord(b) | |
| 135 | + | if !ok { | |
| 136 | + | return 0, nil, 0, fmt.Errorf("%w: bad or short frame", errUnreadable) | |
| 137 | + | } | |
| 138 | + | if len(b) < total { | |
| 139 | + | return 0, nil, 0, fmt.Errorf("%w: short payload", errUnreadable) | |
| 140 | + | } | |
| 141 | + | payload := b[recordFrame:total] | |
| 142 | + | if crc32.Checksum(payload, castagnoli) != binary.LittleEndian.Uint32(b[4:8]) { | |
| 143 | + | return 0, nil, 0, fmt.Errorf("%w: checksum mismatch", errUnreadable) | |
| 144 | + | } | |
| 145 | + | seq, out, err = decodePayload(payload, ops) | |
| 146 | + | if err != nil { | |
| 147 | + | return 0, nil, 0, err | |
| 148 | + | } | |
| 149 | + | return seq, out, total, nil | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | // decodePayload decodes seq, count and ops from a whole payload. | |
| 153 | + | func decodePayload(p []byte, ops []op) (uint64, []op, error) { | |
| 154 | + | seq := binary.LittleEndian.Uint64(p) | |
| 155 | + | count := binary.LittleEndian.Uint32(p[8:]) | |
| 156 | + | p = p[recordMinBody:] | |
| 157 | + | if count == 0 || uint64(count) > uint64(len(p)/minOpSize) { | |
| 158 | + | return 0, nil, fmt.Errorf("%w: bad op count %d", errUnreadable, count) | |
| 159 | + | } | |
| 160 | + | ops = ops[:0] | |
| 161 | + | for range count { | |
| 162 | + | if len(p) == 0 { | |
| 163 | + | return 0, nil, fmt.Errorf("%w: truncated op", errUnreadable) | |
| 164 | + | } | |
| 165 | + | o := op{kind: p[0]} | |
| 166 | + | if o.kind != opPut && o.kind != opDelete { | |
| 167 | + | return 0, nil, fmt.Errorf("%w: bad op kind %d", errUnreadable, o.kind) | |
| 168 | + | } | |
| 169 | + | var ok bool | |
| 170 | + | if o.key, p, ok = cutBytes(p[1:], maxKeyLen); !ok || len(o.key) == 0 { | |
| 171 | + | return 0, nil, fmt.Errorf("%w: bad key", errUnreadable) | |
| 172 | + | } | |
| 173 | + | if o.kind == opPut { | |
| 174 | + | if o.value, p, ok = cutBytes(p, maxRecord); !ok { | |
| 175 | + | return 0, nil, fmt.Errorf("%w: bad value", errUnreadable) | |
| 176 | + | } | |
| 177 | + | } | |
| 178 | + | ops = append(ops, o) | |
| 179 | + | } | |
| 180 | + | if len(p) != 0 { | |
| 181 | + | return 0, nil, fmt.Errorf("%w: %d trailing payload bytes", errUnreadable, len(p)) | |
| 182 | + | } | |
| 183 | + | return seq, ops, nil | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | // cutBytes reads a uvarint length (at most limit) and that many bytes from | |
| 187 | + | // the start of b. rest is what follows. The returned slice has no spare | |
| 188 | + | // capacity. | |
| 189 | + | func cutBytes(b []byte, limit int) (v, rest []byte, ok bool) { | |
| 190 | + | l, n := binary.Uvarint(b) | |
| 191 | + | if n <= 0 || l > uint64(limit) || l > uint64(len(b)-n) { | |
| 192 | + | return nil, nil, false | |
| 193 | + | } | |
| 194 | + | end := n + int(l) | |
| 195 | + | return b[n:end:end], b[end:], true | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | // ownOps returns a copy of ops whose keys and values live in one new | |
| 199 | + | // allocation, so the result does not pin the buffer the ops were decoded from. | |
| 200 | + | func ownOps(ops []op) []op { | |
| 201 | + | size := 0 | |
| 202 | + | for _, o := range ops { | |
| 203 | + | size += len(o.key) + len(o.value) | |
| 204 | + | } | |
| 205 | + | buf := make([]byte, 0, size) | |
| 206 | + | out := make([]op, len(ops)) | |
| 207 | + | for i, o := range ops { | |
| 208 | + | out[i].kind = o.kind | |
| 209 | + | buf = append(buf, o.key...) | |
| 210 | + | out[i].key = buf[len(buf)-len(o.key) : len(buf) : len(buf)] | |
| 211 | + | if o.kind == opPut { | |
| 212 | + | buf = append(buf, o.value...) | |
| 213 | + | out[i].value = buf[len(buf)-len(o.value) : len(buf) : len(buf)] | |
| 214 | + | } | |
| 215 | + | } | |
| 216 | + | return out | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | // entryIter yields snapshot entries in strictly ascending key order. It stops | |
| 220 | + | // when yield returns false. The slices passed to yield need only stay valid | |
| 221 | + | // until yield returns. | |
| 222 | + | type entryIter = func(yield func(key, value []byte, version uint64) bool) | |
| 223 | + | ||
| 224 | + | // writeSnapshot writes a snapshot file of count entries at seq to w. entries | |
| 225 | + | // must yield exactly count entries sorted strictly ascending by key, with | |
| 226 | + | // 1 ≤ len(key) ≤ maxKeyLen and version ≤ seq; otherwise an error is returned | |
| 227 | + | // (the output is then incomplete and must be discarded). | |
| 228 | + | func writeSnapshot(w io.Writer, seq, count uint64, entries entryIter) error { | |
| 229 | + | hdr := append(make([]byte, 0, snapHeaderSize), snapshotMagic...) | |
| 230 | + | hdr = binary.LittleEndian.AppendUint64(hdr, seq) | |
| 231 | + | hdr = binary.LittleEndian.AppendUint64(hdr, count) | |
| 232 | + | hdr = binary.LittleEndian.AppendUint32(hdr, crc32.Checksum(hdr, castagnoli)) | |
| 233 | + | bw := bufio.NewWriterSize(w, 64<<10) | |
| 234 | + | if _, err := bw.Write(hdr); err != nil { | |
| 235 | + | return err | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | var ( | |
| 239 | + | sum uint32 | |
| 240 | + | buf []byte | |
| 241 | + | prev []byte | |
| 242 | + | written uint64 | |
| 243 | + | err error | |
| 244 | + | ) | |
| 245 | + | entries(func(key, value []byte, version uint64) bool { | |
| 246 | + | switch { | |
| 247 | + | case written == count: | |
| 248 | + | err = errors.New("snapshot: more entries than count") | |
| 249 | + | case len(key) == 0 || len(key) > maxKeyLen: | |
| 250 | + | err = fmt.Errorf("snapshot: invalid key length %d", len(key)) | |
| 251 | + | case written > 0 && bytes.Compare(prev, key) >= 0: | |
| 252 | + | err = errors.New("snapshot: keys not strictly ascending") | |
| 253 | + | case version > seq: | |
| 254 | + | err = fmt.Errorf("snapshot: version %d above seq %d", version, seq) | |
| 255 | + | } | |
| 256 | + | if err != nil { | |
| 257 | + | return false | |
| 258 | + | } | |
| 259 | + | buf = binary.AppendUvarint(buf[:0], uint64(len(key))) | |
| 260 | + | buf = append(buf, key...) | |
| 261 | + | buf = binary.AppendUvarint(buf, version) | |
| 262 | + | buf = binary.AppendUvarint(buf, uint64(len(value))) | |
| 263 | + | buf = append(buf, value...) | |
| 264 | + | sum = crc32.Update(sum, castagnoli, buf) | |
| 265 | + | if _, err = bw.Write(buf); err != nil { | |
| 266 | + | return false | |
| 267 | + | } | |
| 268 | + | prev = append(prev[:0], key...) | |
| 269 | + | written++ | |
| 270 | + | return true | |
| 271 | + | }) | |
| 272 | + | if err != nil { | |
| 273 | + | return err | |
| 274 | + | } | |
| 275 | + | if written != count { | |
| 276 | + | return fmt.Errorf("snapshot: %d entries yielded, want %d", written, count) | |
| 277 | + | } | |
| 278 | + | if _, err := bw.Write(binary.LittleEndian.AppendUint32(nil, sum)); err != nil { | |
| 279 | + | return err | |
| 280 | + | } | |
| 281 | + | return bw.Flush() | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | // snapshotView is a fully validated snapshot file held in memory. | |
| 285 | + | type snapshotView struct { | |
| 286 | + | seq, count uint64 | |
| 287 | + | body []byte // entries, without header and trailer | |
| 288 | + | } | |
| 289 | + | ||
| 290 | + | // openSnapshot validates the snapshot file contents b: header and trailer | |
| 291 | + | // checksums, exactly count entries, strictly ascending non-empty keys, | |
| 292 | + | // versions ≤ seq and no trailing bytes. Errors wrap ErrCorrupt. | |
| 293 | + | func openSnapshot(b []byte) (snapshotView, error) { | |
| 294 | + | if len(b) < snapHeaderSize+4 { | |
| 295 | + | return snapshotView{}, fmt.Errorf("%w: short snapshot", ErrCorrupt) | |
| 296 | + | } | |
| 297 | + | if string(b[:8]) != snapshotMagic { | |
| 298 | + | return snapshotView{}, fmt.Errorf("%w: bad snapshot magic", ErrCorrupt) | |
| 299 | + | } | |
| 300 | + | if crc32.Checksum(b[:snapHeaderSize-4], castagnoli) != binary.LittleEndian.Uint32(b[snapHeaderSize-4:]) { | |
| 301 | + | return snapshotView{}, fmt.Errorf("%w: bad snapshot header checksum", ErrCorrupt) | |
| 302 | + | } | |
| 303 | + | v := snapshotView{ | |
| 304 | + | seq: binary.LittleEndian.Uint64(b[8:]), | |
| 305 | + | count: binary.LittleEndian.Uint64(b[16:]), | |
| 306 | + | body: b[snapHeaderSize : len(b)-4], | |
| 307 | + | } | |
| 308 | + | if crc32.Checksum(v.body, castagnoli) != binary.LittleEndian.Uint32(b[len(b)-4:]) { | |
| 309 | + | return snapshotView{}, fmt.Errorf("%w: bad snapshot checksum", ErrCorrupt) | |
| 310 | + | } | |
| 311 | + | if err := v.walk(nil); err != nil { | |
| 312 | + | return snapshotView{}, err | |
| 313 | + | } | |
| 314 | + | return v, nil | |
| 315 | + | } | |
| 316 | + | ||
| 317 | + | // each calls fn for every entry in ascending key order. The slices alias the | |
| 318 | + | // file contents: fn must not modify them and must copy them to retain them | |
| 319 | + | // independently of the file buffer. | |
| 320 | + | func (v snapshotView) each(fn func(key, value []byte, version uint64)) { | |
| 321 | + | _ = v.walk(fn) // cannot fail: the view was validated | |
| 322 | + | } | |
| 323 | + | ||
| 324 | + | func (v snapshotView) walk(fn func(key, value []byte, version uint64)) error { | |
| 325 | + | body := v.body | |
| 326 | + | var prev []byte | |
| 327 | + | for i := uint64(0); i < v.count; i++ { | |
| 328 | + | var key, value []byte | |
| 329 | + | var ok bool | |
| 330 | + | if key, body, ok = cutBytes(body, maxKeyLen); !ok || len(key) == 0 { | |
| 331 | + | return fmt.Errorf("%w: bad snapshot key at entry %d", ErrCorrupt, i) | |
| 332 | + | } | |
| 333 | + | if i > 0 && bytes.Compare(prev, key) >= 0 { | |
| 334 | + | return fmt.Errorf("%w: snapshot keys not ascending at entry %d", ErrCorrupt, i) | |
| 335 | + | } | |
| 336 | + | version, n := binary.Uvarint(body) | |
| 337 | + | if n <= 0 || version > v.seq { | |
| 338 | + | return fmt.Errorf("%w: bad snapshot version at entry %d", ErrCorrupt, i) | |
| 339 | + | } | |
| 340 | + | if value, body, ok = cutBytes(body[n:], maxRecord); !ok { | |
| 341 | + | return fmt.Errorf("%w: bad snapshot value at entry %d", ErrCorrupt, i) | |
| 342 | + | } | |
| 343 | + | if fn != nil { | |
| 344 | + | fn(key, value, version) | |
| 345 | + | } | |
| 346 | + | prev = key | |
| 347 | + | } | |
| 348 | + | if len(body) != 0 { | |
| 349 | + | return fmt.Errorf("%w: %d trailing snapshot bytes", ErrCorrupt, len(body)) | |
| 350 | + | } | |
| 351 | + | return nil | |
| 352 | + | } | |
| @@ -0,0 +1,352 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package kv | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "bufio" | |||
| 7 | + | "bytes" | |||
| 8 | + | "encoding/binary" | |||
| 9 | + | "errors" | |||
| 10 | + | "fmt" | |||
| 11 | + | "hash/crc32" | |||
| 12 | + | "io" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | // Operation kinds. | |||
| 16 | + | const ( | |||
| 17 | + | opPut byte = 1 | |||
| 18 | + | opDelete byte = 2 | |||
| 19 | + | ) | |||
| 20 | + | ||||
| 21 | + | // Format limits. | |||
| 22 | + | const ( | |||
| 23 | + | maxRecord = 64 << 20 // maximum record payload size | |||
| 24 | + | maxKeyLen = 64 << 10 // maximum key length | |||
| 25 | + | ) | |||
| 26 | + | ||||
| 27 | + | const ( | |||
| 28 | + | logMagic = "EKVLOG01" | |||
| 29 | + | snapshotMagic = "EKVSNP01" | |||
| 30 | + | logHeaderSize = 8 + 8 + 4 | |||
| 31 | + | recordFrame = 4 + 4 // len + crc | |||
| 32 | + | recordMinBody = 8 + 4 // seq + count | |||
| 33 | + | snapHeaderSize = 8 + 8 + 8 + 4 | |||
| 34 | + | minOpSize = 3 // kind + klen + 1 key byte | |||
| 35 | + | ) | |||
| 36 | + | ||||
| 37 | + | var castagnoli = crc32.MakeTable(crc32.Castagnoli) | |||
| 38 | + | ||||
| 39 | + | // errUnreadable marks a log record that is not readable (see decodeRecord). | |||
| 40 | + | var errUnreadable = errors.New("unreadable record") | |||
| 41 | + | ||||
| 42 | + | // op is one mutation of a batch. value is nil for opDelete. | |||
| 43 | + | type op struct { | |||
| 44 | + | kind byte | |||
| 45 | + | key, value []byte | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | // opSize returns the encoded size of o. | |||
| 49 | + | func opSize(o op) int { | |||
| 50 | + | n := 1 + uvarintLen(uint64(len(o.key))) + len(o.key) | |||
| 51 | + | if o.kind == opPut { | |||
| 52 | + | n += uvarintLen(uint64(len(o.value))) + len(o.value) | |||
| 53 | + | } | |||
| 54 | + | return n | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | func uvarintLen(v uint64) int { | |||
| 58 | + | n := 1 | |||
| 59 | + | for ; v >= 0x80; v >>= 7 { | |||
| 60 | + | n++ | |||
| 61 | + | } | |||
| 62 | + | return n | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | // appendLogHeader appends the 20-byte log header for base to dst. | |||
| 66 | + | func appendLogHeader(dst []byte, base uint64) []byte { | |||
| 67 | + | start := len(dst) | |||
| 68 | + | dst = append(dst, logMagic...) | |||
| 69 | + | dst = binary.LittleEndian.AppendUint64(dst, base) | |||
| 70 | + | return binary.LittleEndian.AppendUint32(dst, crc32.Checksum(dst[start:], castagnoli)) | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | // decodeLogHeader validates the log header at the start of b and returns its | |||
| 74 | + | // base. Errors wrap ErrCorrupt. | |||
| 75 | + | func decodeLogHeader(b []byte) (base uint64, err error) { | |||
| 76 | + | if len(b) < logHeaderSize { | |||
| 77 | + | return 0, fmt.Errorf("%w: short log header", ErrCorrupt) | |||
| 78 | + | } | |||
| 79 | + | if string(b[:8]) != logMagic { | |||
| 80 | + | return 0, fmt.Errorf("%w: bad log magic", ErrCorrupt) | |||
| 81 | + | } | |||
| 82 | + | if crc32.Checksum(b[:16], castagnoli) != binary.LittleEndian.Uint32(b[16:20]) { | |||
| 83 | + | return 0, fmt.Errorf("%w: bad log header checksum", ErrCorrupt) | |||
| 84 | + | } | |||
| 85 | + | return binary.LittleEndian.Uint64(b[8:16]), nil | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | // appendRecord appends one encoded record (len, crc, seq, count, ops) to dst. | |||
| 89 | + | // The payload must not exceed maxRecord and ops must be non-empty with valid | |||
| 90 | + | // keys; the caller enforces this (batches are validated before encoding). | |||
| 91 | + | func appendRecord(dst []byte, seq uint64, ops []op) []byte { | |||
| 92 | + | start := len(dst) | |||
| 93 | + | dst = append(dst, make([]byte, recordFrame)...) | |||
| 94 | + | dst = binary.LittleEndian.AppendUint64(dst, seq) | |||
| 95 | + | dst = binary.LittleEndian.AppendUint32(dst, uint32(len(ops))) | |||
| 96 | + | for _, o := range ops { | |||
| 97 | + | dst = append(dst, o.kind) | |||
| 98 | + | dst = binary.AppendUvarint(dst, uint64(len(o.key))) | |||
| 99 | + | dst = append(dst, o.key...) | |||
| 100 | + | if o.kind == opPut { | |||
| 101 | + | dst = binary.AppendUvarint(dst, uint64(len(o.value))) | |||
| 102 | + | dst = append(dst, o.value...) | |||
| 103 | + | } | |||
| 104 | + | } | |||
| 105 | + | payload := dst[start+recordFrame:] | |||
| 106 | + | binary.LittleEndian.PutUint32(dst[start:], uint32(len(payload))) | |||
| 107 | + | binary.LittleEndian.PutUint32(dst[start+4:], crc32.Checksum(payload, castagnoli)) | |||
| 108 | + | return dst | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | // peekRecord returns the total encoded size (frame + payload) announced by | |||
| 112 | + | // the record frame at the start of b. ok is false if fewer than 8 bytes are | |||
| 113 | + | // present or the announced payload length is outside [12, maxRecord]. It does | |||
| 114 | + | // not check that the payload is present. | |||
| 115 | + | func peekRecord(b []byte) (total int, ok bool) { | |||
| 116 | + | if len(b) < recordFrame { | |||
| 117 | + | return 0, false | |||
| 118 | + | } | |||
| 119 | + | n := binary.LittleEndian.Uint32(b) | |||
| 120 | + | if n < recordMinBody || n > maxRecord { | |||
| 121 | + | return 0, false | |||
| 122 | + | } | |||
| 123 | + | return recordFrame + int(n), true | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | // decodeRecord decodes the record at the start of b. A record is readable | |||
| 127 | + | // only if the frame is present, 12 ≤ len ≤ maxRecord, len payload bytes are | |||
| 128 | + | // present, the CRC matches, and the payload holds exactly count ≥ 1 valid | |||
| 129 | + | // ops consuming exactly len bytes. Otherwise the error wraps errUnreadable. | |||
| 130 | + | // | |||
| 131 | + | // The decoded ops are appended to ops[:0]; their keys and values alias b. | |||
| 132 | + | // n is the number of bytes consumed. | |||
| 133 | + | func decodeRecord(b []byte, ops []op) (seq uint64, out []op, n int, err error) { | |||
| 134 | + | total, ok := peekRecord(b) | |||
| 135 | + | if !ok { | |||
| 136 | + | return 0, nil, 0, fmt.Errorf("%w: bad or short frame", errUnreadable) | |||
| 137 | + | } | |||
| 138 | + | if len(b) < total { | |||
| 139 | + | return 0, nil, 0, fmt.Errorf("%w: short payload", errUnreadable) | |||
| 140 | + | } | |||
| 141 | + | payload := b[recordFrame:total] | |||
| 142 | + | if crc32.Checksum(payload, castagnoli) != binary.LittleEndian.Uint32(b[4:8]) { | |||
| 143 | + | return 0, nil, 0, fmt.Errorf("%w: checksum mismatch", errUnreadable) | |||
| 144 | + | } | |||
| 145 | + | seq, out, err = decodePayload(payload, ops) | |||
| 146 | + | if err != nil { | |||
| 147 | + | return 0, nil, 0, err | |||
| 148 | + | } | |||
| 149 | + | return seq, out, total, nil | |||
| 150 | + | } | |||
| 151 | + | ||||
| 152 | + | // decodePayload decodes seq, count and ops from a whole payload. | |||
| 153 | + | func decodePayload(p []byte, ops []op) (uint64, []op, error) { | |||
| 154 | + | seq := binary.LittleEndian.Uint64(p) | |||
| 155 | + | count := binary.LittleEndian.Uint32(p[8:]) | |||
| 156 | + | p = p[recordMinBody:] | |||
| 157 | + | if count == 0 || uint64(count) > uint64(len(p)/minOpSize) { | |||
| 158 | + | return 0, nil, fmt.Errorf("%w: bad op count %d", errUnreadable, count) | |||
| 159 | + | } | |||
| 160 | + | ops = ops[:0] | |||
| 161 | + | for range count { | |||
| 162 | + | if len(p) == 0 { | |||
| 163 | + | return 0, nil, fmt.Errorf("%w: truncated op", errUnreadable) | |||
| 164 | + | } | |||
| 165 | + | o := op{kind: p[0]} | |||
| 166 | + | if o.kind != opPut && o.kind != opDelete { | |||
| 167 | + | return 0, nil, fmt.Errorf("%w: bad op kind %d", errUnreadable, o.kind) | |||
| 168 | + | } | |||
| 169 | + | var ok bool | |||
| 170 | + | if o.key, p, ok = cutBytes(p[1:], maxKeyLen); !ok || len(o.key) == 0 { | |||
| 171 | + | return 0, nil, fmt.Errorf("%w: bad key", errUnreadable) | |||
| 172 | + | } | |||
| 173 | + | if o.kind == opPut { | |||
| 174 | + | if o.value, p, ok = cutBytes(p, maxRecord); !ok { | |||
| 175 | + | return 0, nil, fmt.Errorf("%w: bad value", errUnreadable) | |||
| 176 | + | } | |||
| 177 | + | } | |||
| 178 | + | ops = append(ops, o) | |||
| 179 | + | } | |||
| 180 | + | if len(p) != 0 { | |||
| 181 | + | return 0, nil, fmt.Errorf("%w: %d trailing payload bytes", errUnreadable, len(p)) | |||
| 182 | + | } | |||
| 183 | + | return seq, ops, nil | |||
| 184 | + | } | |||
| 185 | + | ||||
| 186 | + | // cutBytes reads a uvarint length (at most limit) and that many bytes from | |||
| 187 | + | // the start of b. rest is what follows. The returned slice has no spare | |||
| 188 | + | // capacity. | |||
| 189 | + | func cutBytes(b []byte, limit int) (v, rest []byte, ok bool) { | |||
| 190 | + | l, n := binary.Uvarint(b) | |||
| 191 | + | if n <= 0 || l > uint64(limit) || l > uint64(len(b)-n) { | |||
| 192 | + | return nil, nil, false | |||
| 193 | + | } | |||
| 194 | + | end := n + int(l) | |||
| 195 | + | return b[n:end:end], b[end:], true | |||
| 196 | + | } | |||
| 197 | + | ||||
| 198 | + | // ownOps returns a copy of ops whose keys and values live in one new | |||
| 199 | + | // allocation, so the result does not pin the buffer the ops were decoded from. | |||
| 200 | + | func ownOps(ops []op) []op { | |||
| 201 | + | size := 0 | |||
| 202 | + | for _, o := range ops { | |||
| 203 | + | size += len(o.key) + len(o.value) | |||
| 204 | + | } | |||
| 205 | + | buf := make([]byte, 0, size) | |||
| 206 | + | out := make([]op, len(ops)) | |||
| 207 | + | for i, o := range ops { | |||
| 208 | + | out[i].kind = o.kind | |||
| 209 | + | buf = append(buf, o.key...) | |||
| 210 | + | out[i].key = buf[len(buf)-len(o.key) : len(buf) : len(buf)] | |||
| 211 | + | if o.kind == opPut { | |||
| 212 | + | buf = append(buf, o.value...) | |||
| 213 | + | out[i].value = buf[len(buf)-len(o.value) : len(buf) : len(buf)] | |||
| 214 | + | } | |||
| 215 | + | } | |||
| 216 | + | return out | |||
| 217 | + | } | |||
| 218 | + | ||||
| 219 | + | // entryIter yields snapshot entries in strictly ascending key order. It stops | |||
| 220 | + | // when yield returns false. The slices passed to yield need only stay valid | |||
| 221 | + | // until yield returns. | |||
| 222 | + | type entryIter = func(yield func(key, value []byte, version uint64) bool) | |||
| 223 | + | ||||
| 224 | + | // writeSnapshot writes a snapshot file of count entries at seq to w. entries | |||
| 225 | + | // must yield exactly count entries sorted strictly ascending by key, with | |||
| 226 | + | // 1 ≤ len(key) ≤ maxKeyLen and version ≤ seq; otherwise an error is returned | |||
| 227 | + | // (the output is then incomplete and must be discarded). | |||
| 228 | + | func writeSnapshot(w io.Writer, seq, count uint64, entries entryIter) error { | |||
| 229 | + | hdr := append(make([]byte, 0, snapHeaderSize), snapshotMagic...) | |||
| 230 | + | hdr = binary.LittleEndian.AppendUint64(hdr, seq) | |||
| 231 | + | hdr = binary.LittleEndian.AppendUint64(hdr, count) | |||
| 232 | + | hdr = binary.LittleEndian.AppendUint32(hdr, crc32.Checksum(hdr, castagnoli)) | |||
| 233 | + | bw := bufio.NewWriterSize(w, 64<<10) | |||
| 234 | + | if _, err := bw.Write(hdr); err != nil { | |||
| 235 | + | return err | |||
| 236 | + | } | |||
| 237 | + | ||||
| 238 | + | var ( | |||
| 239 | + | sum uint32 | |||
| 240 | + | buf []byte | |||
| 241 | + | prev []byte | |||
| 242 | + | written uint64 | |||
| 243 | + | err error | |||
| 244 | + | ) | |||
| 245 | + | entries(func(key, value []byte, version uint64) bool { | |||
| 246 | + | switch { | |||
| 247 | + | case written == count: | |||
| 248 | + | err = errors.New("snapshot: more entries than count") | |||
| 249 | + | case len(key) == 0 || len(key) > maxKeyLen: | |||
| 250 | + | err = fmt.Errorf("snapshot: invalid key length %d", len(key)) | |||
| 251 | + | case written > 0 && bytes.Compare(prev, key) >= 0: | |||
| 252 | + | err = errors.New("snapshot: keys not strictly ascending") | |||
| 253 | + | case version > seq: | |||
| 254 | + | err = fmt.Errorf("snapshot: version %d above seq %d", version, seq) | |||
| 255 | + | } | |||
| 256 | + | if err != nil { | |||
| 257 | + | return false | |||
| 258 | + | } | |||
| 259 | + | buf = binary.AppendUvarint(buf[:0], uint64(len(key))) | |||
| 260 | + | buf = append(buf, key...) | |||
| 261 | + | buf = binary.AppendUvarint(buf, version) | |||
| 262 | + | buf = binary.AppendUvarint(buf, uint64(len(value))) | |||
| 263 | + | buf = append(buf, value...) | |||
| 264 | + | sum = crc32.Update(sum, castagnoli, buf) | |||
| 265 | + | if _, err = bw.Write(buf); err != nil { | |||
| 266 | + | return false | |||
| 267 | + | } | |||
| 268 | + | prev = append(prev[:0], key...) | |||
| 269 | + | written++ | |||
| 270 | + | return true | |||
| 271 | + | }) | |||
| 272 | + | if err != nil { | |||
| 273 | + | return err | |||
| 274 | + | } | |||
| 275 | + | if written != count { | |||
| 276 | + | return fmt.Errorf("snapshot: %d entries yielded, want %d", written, count) | |||
| 277 | + | } | |||
| 278 | + | if _, err := bw.Write(binary.LittleEndian.AppendUint32(nil, sum)); err != nil { | |||
| 279 | + | return err | |||
| 280 | + | } | |||
| 281 | + | return bw.Flush() | |||
| 282 | + | } | |||
| 283 | + | ||||
| 284 | + | // snapshotView is a fully validated snapshot file held in memory. | |||
| 285 | + | type snapshotView struct { | |||
| 286 | + | seq, count uint64 | |||
| 287 | + | body []byte // entries, without header and trailer | |||
| 288 | + | } | |||
| 289 | + | ||||
| 290 | + | // openSnapshot validates the snapshot file contents b: header and trailer | |||
| 291 | + | // checksums, exactly count entries, strictly ascending non-empty keys, | |||
| 292 | + | // versions ≤ seq and no trailing bytes. Errors wrap ErrCorrupt. | |||
| 293 | + | func openSnapshot(b []byte) (snapshotView, error) { | |||
| 294 | + | if len(b) < snapHeaderSize+4 { | |||
| 295 | + | return snapshotView{}, fmt.Errorf("%w: short snapshot", ErrCorrupt) | |||
| 296 | + | } | |||
| 297 | + | if string(b[:8]) != snapshotMagic { | |||
| 298 | + | return snapshotView{}, fmt.Errorf("%w: bad snapshot magic", ErrCorrupt) | |||
| 299 | + | } | |||
| 300 | + | if crc32.Checksum(b[:snapHeaderSize-4], castagnoli) != binary.LittleEndian.Uint32(b[snapHeaderSize-4:]) { | |||
| 301 | + | return snapshotView{}, fmt.Errorf("%w: bad snapshot header checksum", ErrCorrupt) | |||
| 302 | + | } | |||
| 303 | + | v := snapshotView{ | |||
| 304 | + | seq: binary.LittleEndian.Uint64(b[8:]), | |||
| 305 | + | count: binary.LittleEndian.Uint64(b[16:]), | |||
| 306 | + | body: b[snapHeaderSize : len(b)-4], | |||
| 307 | + | } | |||
| 308 | + | if crc32.Checksum(v.body, castagnoli) != binary.LittleEndian.Uint32(b[len(b)-4:]) { | |||
| 309 | + | return snapshotView{}, fmt.Errorf("%w: bad snapshot checksum", ErrCorrupt) | |||
| 310 | + | } | |||
| 311 | + | if err := v.walk(nil); err != nil { | |||
| 312 | + | return snapshotView{}, err | |||
| 313 | + | } | |||
| 314 | + | return v, nil | |||
| 315 | + | } | |||
| 316 | + | ||||
| 317 | + | // each calls fn for every entry in ascending key order. The slices alias the | |||
| 318 | + | // file contents: fn must not modify them and must copy them to retain them | |||
| 319 | + | // independently of the file buffer. | |||
| 320 | + | func (v snapshotView) each(fn func(key, value []byte, version uint64)) { | |||
| 321 | + | _ = v.walk(fn) // cannot fail: the view was validated | |||
| 322 | + | } | |||
| 323 | + | ||||
| 324 | + | func (v snapshotView) walk(fn func(key, value []byte, version uint64)) error { | |||
| 325 | + | body := v.body | |||
| 326 | + | var prev []byte | |||
| 327 | + | for i := uint64(0); i < v.count; i++ { | |||
| 328 | + | var key, value []byte | |||
| 329 | + | var ok bool | |||
| 330 | + | if key, body, ok = cutBytes(body, maxKeyLen); !ok || len(key) == 0 { | |||
| 331 | + | return fmt.Errorf("%w: bad snapshot key at entry %d", ErrCorrupt, i) | |||
| 332 | + | } | |||
| 333 | + | if i > 0 && bytes.Compare(prev, key) >= 0 { | |||
| 334 | + | return fmt.Errorf("%w: snapshot keys not ascending at entry %d", ErrCorrupt, i) | |||
| 335 | + | } | |||
| 336 | + | version, n := binary.Uvarint(body) | |||
| 337 | + | if n <= 0 || version > v.seq { | |||
| 338 | + | return fmt.Errorf("%w: bad snapshot version at entry %d", ErrCorrupt, i) | |||
| 339 | + | } | |||
| 340 | + | if value, body, ok = cutBytes(body[n:], maxRecord); !ok { | |||
| 341 | + | return fmt.Errorf("%w: bad snapshot value at entry %d", ErrCorrupt, i) | |||
| 342 | + | } | |||
| 343 | + | if fn != nil { | |||
| 344 | + | fn(key, value, version) | |||
| 345 | + | } | |||
| 346 | + | prev = key | |||
| 347 | + | } | |||
| 348 | + | if len(body) != 0 { | |||
| 349 | + | return fmt.Errorf("%w: %d trailing snapshot bytes", ErrCorrupt, len(body)) | |||
| 350 | + | } | |||
| 351 | + | return nil | |||
| 352 | + | } | |||
A record_test.go +290 -0
| @@ -0,0 +1,290 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package kv | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bytes" | |
| 7 | + | "encoding/binary" | |
| 8 | + | "errors" | |
| 9 | + | "hash/crc32" | |
| 10 | + | "slices" | |
| 11 | + | "testing" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func sampleOps() []op { | |
| 15 | + | return []op{ | |
| 16 | + | {kind: opPut, key: []byte("a"), value: []byte("1")}, | |
| 17 | + | {kind: opDelete, key: []byte("b")}, | |
| 18 | + | {kind: opPut, key: []byte("c"), value: nil}, | |
| 19 | + | {kind: opPut, key: bytes.Repeat([]byte("k"), 200), value: bytes.Repeat([]byte("v"), 300)}, | |
| 20 | + | } | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | func opsEqual(a, b []op) bool { | |
| 24 | + | return slices.EqualFunc(a, b, func(x, y op) bool { | |
| 25 | + | return x.kind == y.kind && bytes.Equal(x.key, y.key) && bytes.Equal(x.value, y.value) | |
| 26 | + | }) | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func TestRecordRoundTrip(t *testing.T) { | |
| 30 | + | ops := sampleOps() | |
| 31 | + | enc := appendRecord([]byte("prefix"), 42, ops)[6:] | |
| 32 | + | size := 0 | |
| 33 | + | for _, o := range ops { | |
| 34 | + | size += opSize(o) | |
| 35 | + | } | |
| 36 | + | if want := recordFrame + recordMinBody + size; len(enc) != want { | |
| 37 | + | t.Fatalf("encoded size = %d, want %d", len(enc), want) | |
| 38 | + | } | |
| 39 | + | seq, got, n, err := decodeRecord(append(enc, 0xff, 0xee), nil) | |
| 40 | + | if err != nil { | |
| 41 | + | t.Fatal(err) | |
| 42 | + | } | |
| 43 | + | if seq != 42 || n != len(enc) || !opsEqual(got, ops) { | |
| 44 | + | t.Fatalf("got seq=%d n=%d ops=%v", seq, n, got) | |
| 45 | + | } | |
| 46 | + | owned := ownOps(got) | |
| 47 | + | clear(enc) | |
| 48 | + | if !opsEqual(owned, ops) { | |
| 49 | + | t.Fatal("ownOps aliases the source buffer") | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func TestLogHeader(t *testing.T) { | |
| 54 | + | h := appendLogHeader(nil, 0x0102030405) | |
| 55 | + | if len(h) != logHeaderSize { | |
| 56 | + | t.Fatalf("header size %d", len(h)) | |
| 57 | + | } | |
| 58 | + | if base, err := decodeLogHeader(h); err != nil || base != 0x0102030405 { | |
| 59 | + | t.Fatalf("base=%d err=%v", base, err) | |
| 60 | + | } | |
| 61 | + | for i := range h { | |
| 62 | + | bad := slices.Clone(h) | |
| 63 | + | bad[i] ^= 1 | |
| 64 | + | if _, err := decodeLogHeader(bad); !errors.Is(err, ErrCorrupt) { | |
| 65 | + | t.Fatalf("flip byte %d: err=%v", i, err) | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | if _, err := decodeLogHeader(h[:logHeaderSize-1]); !errors.Is(err, ErrCorrupt) { | |
| 69 | + | t.Fatalf("short header: err=%v", err) | |
| 70 | + | } | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | // rawRecord frames payload with a correct length and checksum. | |
| 74 | + | func rawRecord(payload []byte) []byte { | |
| 75 | + | b := binary.LittleEndian.AppendUint32(nil, uint32(len(payload))) | |
| 76 | + | b = binary.LittleEndian.AppendUint32(b, crc32.Checksum(payload, castagnoli)) | |
| 77 | + | return append(b, payload...) | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | func rawPayload(seq uint64, count uint32, body ...byte) []byte { | |
| 81 | + | p := binary.LittleEndian.AppendUint64(nil, seq) | |
| 82 | + | p = binary.LittleEndian.AppendUint32(p, count) | |
| 83 | + | return append(p, body...) | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | func TestDecodeRecordUnreadable(t *testing.T) { | |
| 87 | + | good := appendRecord(nil, 1, []op{{kind: opPut, key: []byte("k"), value: []byte("v")}}) | |
| 88 | + | badCRC := slices.Clone(good) | |
| 89 | + | badCRC[len(badCRC)-1] ^= 1 | |
| 90 | + | tooBig := binary.LittleEndian.AppendUint32(nil, maxRecord+1) | |
| 91 | + | tooBig = append(tooBig, make([]byte, 16)...) | |
| 92 | + | cases := map[string][]byte{ | |
| 93 | + | "empty": nil, | |
| 94 | + | "short frame": good[:7], | |
| 95 | + | "short payload": good[:len(good)-1], | |
| 96 | + | "bad crc": badCRC, | |
| 97 | + | "len too small": rawRecord(make([]byte, 11)), | |
| 98 | + | "len too big": tooBig, | |
| 99 | + | "zero count": rawRecord(rawPayload(1, 0)), | |
| 100 | + | "count too big": rawRecord(rawPayload(1, 2, opPut, 1, 'k', 1, 'v')), | |
| 101 | + | "count too small": rawRecord(rawPayload(1, 1, opPut, 1, 'k', 1, 'v', opDelete, 1, 'k')), | |
| 102 | + | "bad kind": rawRecord(rawPayload(1, 1, 3, 1, 'k')), | |
| 103 | + | "empty key": rawRecord(rawPayload(1, 1, opDelete, 0, 0)), | |
| 104 | + | "key past end": rawRecord(rawPayload(1, 1, opDelete, 9, 'k')), | |
| 105 | + | "value past end": rawRecord(rawPayload(1, 1, opPut, 1, 'k', 5, 'v')), | |
| 106 | + | "trailing byte": rawRecord(rawPayload(1, 1, opDelete, 1, 'k', 0)), | |
| 107 | + | "huge count": rawRecord(rawPayload(1, 0xffffffff, opDelete, 1, 'k')), | |
| 108 | + | } | |
| 109 | + | for name, b := range cases { | |
| 110 | + | t.Run(name, func(t *testing.T) { | |
| 111 | + | if _, _, _, err := decodeRecord(b, nil); !errors.Is(err, errUnreadable) { | |
| 112 | + | t.Fatalf("err = %v, want errUnreadable", err) | |
| 113 | + | } | |
| 114 | + | }) | |
| 115 | + | } | |
| 116 | + | if _, _, _, err := decodeRecord(rawRecord(rawPayload(1, 1, opDelete, 1, 'k')), nil); err != nil { | |
| 117 | + | t.Fatalf("control record: %v", err) | |
| 118 | + | } | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | type snapEntry struct { | |
| 122 | + | key, value []byte | |
| 123 | + | version uint64 | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | func iterOf(entries []snapEntry) entryIter { | |
| 127 | + | return func(yield func(key, value []byte, version uint64) bool) { | |
| 128 | + | for _, e := range entries { | |
| 129 | + | if !yield(e.key, e.value, e.version) { | |
| 130 | + | return | |
| 131 | + | } | |
| 132 | + | } | |
| 133 | + | } | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | func encodeSnapshot(t testing.TB, seq uint64, entries []snapEntry) []byte { | |
| 137 | + | t.Helper() | |
| 138 | + | var buf bytes.Buffer | |
| 139 | + | if err := writeSnapshot(&buf, seq, uint64(len(entries)), iterOf(entries)); err != nil { | |
| 140 | + | t.Fatal(err) | |
| 141 | + | } | |
| 142 | + | return buf.Bytes() | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | func TestSnapshotRoundTrip(t *testing.T) { | |
| 146 | + | entries := []snapEntry{ | |
| 147 | + | {[]byte("a"), []byte("1"), 3}, | |
| 148 | + | {[]byte("b"), nil, 7}, | |
| 149 | + | {[]byte("c"), bytes.Repeat([]byte("x"), 1000), 1}, | |
| 150 | + | } | |
| 151 | + | for _, in := range [][]snapEntry{nil, entries} { | |
| 152 | + | data := encodeSnapshot(t, 9, in) | |
| 153 | + | view, err := openSnapshot(data) | |
| 154 | + | if err != nil { | |
| 155 | + | t.Fatal(err) | |
| 156 | + | } | |
| 157 | + | if view.seq != 9 || view.count != uint64(len(in)) { | |
| 158 | + | t.Fatalf("view = %+v", view) | |
| 159 | + | } | |
| 160 | + | var got []snapEntry | |
| 161 | + | view.each(func(k, v []byte, ver uint64) { got = append(got, snapEntry{k, v, ver}) }) | |
| 162 | + | if len(got) != len(in) { | |
| 163 | + | t.Fatalf("got %d entries, want %d", len(got), len(in)) | |
| 164 | + | } | |
| 165 | + | for i := range in { | |
| 166 | + | if !bytes.Equal(got[i].key, in[i].key) || !bytes.Equal(got[i].value, in[i].value) || got[i].version != in[i].version { | |
| 167 | + | t.Fatalf("entry %d = %+v, want %+v", i, got[i], in[i]) | |
| 168 | + | } | |
| 169 | + | } | |
| 170 | + | } | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | func TestSnapshotCorrupt(t *testing.T) { | |
| 174 | + | entries := []snapEntry{{[]byte("a"), []byte("1"), 1}, {[]byte("b"), []byte("2"), 2}} | |
| 175 | + | data := encodeSnapshot(t, 2, entries) | |
| 176 | + | for i := range data { | |
| 177 | + | bad := slices.Clone(data) | |
| 178 | + | bad[i] ^= 0x40 | |
| 179 | + | if _, err := openSnapshot(bad); !errors.Is(err, ErrCorrupt) { | |
| 180 | + | t.Fatalf("flip byte %d: err=%v", i, err) | |
| 181 | + | } | |
| 182 | + | } | |
| 183 | + | for _, n := range []int{0, 1, snapHeaderSize, len(data) - 1} { | |
| 184 | + | if _, err := openSnapshot(data[:n]); !errors.Is(err, ErrCorrupt) { | |
| 185 | + | t.Fatalf("truncate to %d: err=%v", n, err) | |
| 186 | + | } | |
| 187 | + | } | |
| 188 | + | if _, err := openSnapshot(append(slices.Clone(data), 0)); !errors.Is(err, ErrCorrupt) { | |
| 189 | + | t.Fatalf("trailing byte: err=%v", err) | |
| 190 | + | } | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | // forgeSnapshot builds a snapshot with valid checksums around arbitrary body | |
| 194 | + | // bytes, to reach the structural checks. | |
| 195 | + | func forgeSnapshot(seq, count uint64, body []byte) []byte { | |
| 196 | + | b := append([]byte(snapshotMagic), 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) | |
| 197 | + | binary.LittleEndian.PutUint64(b[8:], seq) | |
| 198 | + | binary.LittleEndian.PutUint64(b[16:], count) | |
| 199 | + | b = binary.LittleEndian.AppendUint32(b, crc32.Checksum(b, castagnoli)) | |
| 200 | + | b = append(b, body...) | |
| 201 | + | return binary.LittleEndian.AppendUint32(b, crc32.Checksum(body, castagnoli)) | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | func TestSnapshotStructure(t *testing.T) { | |
| 205 | + | entry := func(k string, ver uint64, v string) []byte { | |
| 206 | + | b := binary.AppendUvarint(nil, uint64(len(k))) | |
| 207 | + | b = append(b, k...) | |
| 208 | + | b = binary.AppendUvarint(b, ver) | |
| 209 | + | b = binary.AppendUvarint(b, uint64(len(v))) | |
| 210 | + | return append(b, v...) | |
| 211 | + | } | |
| 212 | + | cat := func(parts ...[]byte) []byte { return bytes.Join(parts, nil) } | |
| 213 | + | cases := map[string][]byte{ | |
| 214 | + | "not ascending": forgeSnapshot(5, 2, cat(entry("b", 1, ""), entry("a", 1, ""))), | |
| 215 | + | "duplicate key": forgeSnapshot(5, 2, cat(entry("a", 1, ""), entry("a", 2, ""))), | |
| 216 | + | "version > seq": forgeSnapshot(5, 1, entry("a", 6, "")), | |
| 217 | + | "too few": forgeSnapshot(5, 2, entry("a", 1, "")), | |
| 218 | + | "too many": forgeSnapshot(5, 1, cat(entry("a", 1, ""), entry("b", 1, ""))), | |
| 219 | + | "empty key": forgeSnapshot(5, 1, entry("", 1, "")), | |
| 220 | + | "huge count": forgeSnapshot(5, 1<<62, entry("a", 1, "")), | |
| 221 | + | "value past end": forgeSnapshot(5, 1, []byte{1, 'a', 1, 9, 'v'}), | |
| 222 | + | } | |
| 223 | + | for name, b := range cases { | |
| 224 | + | t.Run(name, func(t *testing.T) { | |
| 225 | + | if _, err := openSnapshot(b); !errors.Is(err, ErrCorrupt) { | |
| 226 | + | t.Fatalf("err = %v, want ErrCorrupt", err) | |
| 227 | + | } | |
| 228 | + | }) | |
| 229 | + | } | |
| 230 | + | if _, err := openSnapshot(forgeSnapshot(5, 2, cat(entry("a", 1, ""), entry("b", 5, "x")))); err != nil { | |
| 231 | + | t.Fatalf("control snapshot: %v", err) | |
| 232 | + | } | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | func TestWriteSnapshotRejectsBadInput(t *testing.T) { | |
| 236 | + | long := bytes.Repeat([]byte("k"), maxKeyLen+1) | |
| 237 | + | cases := map[string]struct { | |
| 238 | + | count uint64 | |
| 239 | + | entries []snapEntry | |
| 240 | + | }{ | |
| 241 | + | "unsorted": {2, []snapEntry{{[]byte("b"), nil, 1}, {[]byte("a"), nil, 1}}}, | |
| 242 | + | "duplicate": {2, []snapEntry{{[]byte("a"), nil, 1}, {[]byte("a"), nil, 1}}}, | |
| 243 | + | "empty key": {1, []snapEntry{{nil, nil, 1}}}, | |
| 244 | + | "long key": {1, []snapEntry{{long, nil, 1}}}, | |
| 245 | + | "version": {1, []snapEntry{{[]byte("a"), nil, 4}}}, | |
| 246 | + | "too few": {2, []snapEntry{{[]byte("a"), nil, 1}}}, | |
| 247 | + | "too many": {1, []snapEntry{{[]byte("a"), nil, 1}, {[]byte("b"), nil, 1}}}, | |
| 248 | + | "unsorted mid": {3, []snapEntry{{[]byte("a"), nil, 1}, {[]byte("c"), nil, 1}, {[]byte("b"), nil, 1}}}, | |
| 249 | + | } | |
| 250 | + | for name, c := range cases { | |
| 251 | + | t.Run(name, func(t *testing.T) { | |
| 252 | + | var buf bytes.Buffer | |
| 253 | + | if err := writeSnapshot(&buf, 3, c.count, iterOf(c.entries)); err == nil { | |
| 254 | + | t.Fatal("want error") | |
| 255 | + | } | |
| 256 | + | }) | |
| 257 | + | } | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | func FuzzDecodeRecord(f *testing.F) { | |
| 261 | + | f.Add(appendRecord(nil, 1, sampleOps())) | |
| 262 | + | f.Add(appendRecord(nil, 1<<40, []op{{kind: opDelete, key: []byte("k")}})) | |
| 263 | + | f.Add(rawRecord(rawPayload(1, 1, opDelete, 1, 'k'))) | |
| 264 | + | f.Add([]byte{}) | |
| 265 | + | f.Add(make([]byte, 64)) | |
| 266 | + | f.Add(appendLogHeader(nil, 7)) | |
| 267 | + | f.Add(encodeSnapshot(f, 3, []snapEntry{{[]byte("a"), []byte("1"), 2}})) | |
| 268 | + | f.Fuzz(func(t *testing.T, b []byte) { | |
| 269 | + | // Decoders must not panic, whatever the input. | |
| 270 | + | _, _ = decodeLogHeader(b) | |
| 271 | + | if view, err := openSnapshot(b); err == nil { | |
| 272 | + | view.each(func(k, v []byte, ver uint64) {}) | |
| 273 | + | } | |
| 274 | + | peekRecord(b) | |
| 275 | + | seq, ops, n, err := decodeRecord(b, nil) | |
| 276 | + | if err != nil { | |
| 277 | + | return | |
| 278 | + | } | |
| 279 | + | // A readable record survives re-encoding (varints may be | |
| 280 | + | // non-canonical in the input, so the bytes can differ). | |
| 281 | + | if n > len(b) || len(ops) == 0 { | |
| 282 | + | t.Fatalf("n=%d len=%d ops=%d", n, len(b), len(ops)) | |
| 283 | + | } | |
| 284 | + | enc := appendRecord(nil, seq, ops) | |
| 285 | + | seq2, ops2, n2, err := decodeRecord(enc, nil) | |
| 286 | + | if err != nil || seq2 != seq || n2 != len(enc) || !opsEqual(ops2, ops) { | |
| 287 | + | t.Fatalf("round trip failed: err=%v", err) | |
| 288 | + | } | |
| 289 | + | }) | |
| 290 | + | } | |
| @@ -0,0 +1,290 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package kv | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "bytes" | |||
| 7 | + | "encoding/binary" | |||
| 8 | + | "errors" | |||
| 9 | + | "hash/crc32" | |||
| 10 | + | "slices" | |||
| 11 | + | "testing" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | func sampleOps() []op { | |||
| 15 | + | return []op{ | |||
| 16 | + | {kind: opPut, key: []byte("a"), value: []byte("1")}, | |||
| 17 | + | {kind: opDelete, key: []byte("b")}, | |||
| 18 | + | {kind: opPut, key: []byte("c"), value: nil}, | |||
| 19 | + | {kind: opPut, key: bytes.Repeat([]byte("k"), 200), value: bytes.Repeat([]byte("v"), 300)}, | |||
| 20 | + | } | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | func opsEqual(a, b []op) bool { | |||
| 24 | + | return slices.EqualFunc(a, b, func(x, y op) bool { | |||
| 25 | + | return x.kind == y.kind && bytes.Equal(x.key, y.key) && bytes.Equal(x.value, y.value) | |||
| 26 | + | }) | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | func TestRecordRoundTrip(t *testing.T) { | |||
| 30 | + | ops := sampleOps() | |||
| 31 | + | enc := appendRecord([]byte("prefix"), 42, ops)[6:] | |||
| 32 | + | size := 0 | |||
| 33 | + | for _, o := range ops { | |||
| 34 | + | size += opSize(o) | |||
| 35 | + | } | |||
| 36 | + | if want := recordFrame + recordMinBody + size; len(enc) != want { | |||
| 37 | + | t.Fatalf("encoded size = %d, want %d", len(enc), want) | |||
| 38 | + | } | |||
| 39 | + | seq, got, n, err := decodeRecord(append(enc, 0xff, 0xee), nil) | |||
| 40 | + | if err != nil { | |||
| 41 | + | t.Fatal(err) | |||
| 42 | + | } | |||
| 43 | + | if seq != 42 || n != len(enc) || !opsEqual(got, ops) { | |||
| 44 | + | t.Fatalf("got seq=%d n=%d ops=%v", seq, n, got) | |||
| 45 | + | } | |||
| 46 | + | owned := ownOps(got) | |||
| 47 | + | clear(enc) | |||
| 48 | + | if !opsEqual(owned, ops) { | |||
| 49 | + | t.Fatal("ownOps aliases the source buffer") | |||
| 50 | + | } | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | func TestLogHeader(t *testing.T) { | |||
| 54 | + | h := appendLogHeader(nil, 0x0102030405) | |||
| 55 | + | if len(h) != logHeaderSize { | |||
| 56 | + | t.Fatalf("header size %d", len(h)) | |||
| 57 | + | } | |||
| 58 | + | if base, err := decodeLogHeader(h); err != nil || base != 0x0102030405 { | |||
| 59 | + | t.Fatalf("base=%d err=%v", base, err) | |||
| 60 | + | } | |||
| 61 | + | for i := range h { | |||
| 62 | + | bad := slices.Clone(h) | |||
| 63 | + | bad[i] ^= 1 | |||
| 64 | + | if _, err := decodeLogHeader(bad); !errors.Is(err, ErrCorrupt) { | |||
| 65 | + | t.Fatalf("flip byte %d: err=%v", i, err) | |||
| 66 | + | } | |||
| 67 | + | } | |||
| 68 | + | if _, err := decodeLogHeader(h[:logHeaderSize-1]); !errors.Is(err, ErrCorrupt) { | |||
| 69 | + | t.Fatalf("short header: err=%v", err) | |||
| 70 | + | } | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | // rawRecord frames payload with a correct length and checksum. | |||
| 74 | + | func rawRecord(payload []byte) []byte { | |||
| 75 | + | b := binary.LittleEndian.AppendUint32(nil, uint32(len(payload))) | |||
| 76 | + | b = binary.LittleEndian.AppendUint32(b, crc32.Checksum(payload, castagnoli)) | |||
| 77 | + | return append(b, payload...) | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | func rawPayload(seq uint64, count uint32, body ...byte) []byte { | |||
| 81 | + | p := binary.LittleEndian.AppendUint64(nil, seq) | |||
| 82 | + | p = binary.LittleEndian.AppendUint32(p, count) | |||
| 83 | + | return append(p, body...) | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | func TestDecodeRecordUnreadable(t *testing.T) { | |||
| 87 | + | good := appendRecord(nil, 1, []op{{kind: opPut, key: []byte("k"), value: []byte("v")}}) | |||
| 88 | + | badCRC := slices.Clone(good) | |||
| 89 | + | badCRC[len(badCRC)-1] ^= 1 | |||
| 90 | + | tooBig := binary.LittleEndian.AppendUint32(nil, maxRecord+1) | |||
| 91 | + | tooBig = append(tooBig, make([]byte, 16)...) | |||
| 92 | + | cases := map[string][]byte{ | |||
| 93 | + | "empty": nil, | |||
| 94 | + | "short frame": good[:7], | |||
| 95 | + | "short payload": good[:len(good)-1], | |||
| 96 | + | "bad crc": badCRC, | |||
| 97 | + | "len too small": rawRecord(make([]byte, 11)), | |||
| 98 | + | "len too big": tooBig, | |||
| 99 | + | "zero count": rawRecord(rawPayload(1, 0)), | |||
| 100 | + | "count too big": rawRecord(rawPayload(1, 2, opPut, 1, 'k', 1, 'v')), | |||
| 101 | + | "count too small": rawRecord(rawPayload(1, 1, opPut, 1, 'k', 1, 'v', opDelete, 1, 'k')), | |||
| 102 | + | "bad kind": rawRecord(rawPayload(1, 1, 3, 1, 'k')), | |||
| 103 | + | "empty key": rawRecord(rawPayload(1, 1, opDelete, 0, 0)), | |||
| 104 | + | "key past end": rawRecord(rawPayload(1, 1, opDelete, 9, 'k')), | |||
| 105 | + | "value past end": rawRecord(rawPayload(1, 1, opPut, 1, 'k', 5, 'v')), | |||
| 106 | + | "trailing byte": rawRecord(rawPayload(1, 1, opDelete, 1, 'k', 0)), | |||
| 107 | + | "huge count": rawRecord(rawPayload(1, 0xffffffff, opDelete, 1, 'k')), | |||
| 108 | + | } | |||
| 109 | + | for name, b := range cases { | |||
| 110 | + | t.Run(name, func(t *testing.T) { | |||
| 111 | + | if _, _, _, err := decodeRecord(b, nil); !errors.Is(err, errUnreadable) { | |||
| 112 | + | t.Fatalf("err = %v, want errUnreadable", err) | |||
| 113 | + | } | |||
| 114 | + | }) | |||
| 115 | + | } | |||
| 116 | + | if _, _, _, err := decodeRecord(rawRecord(rawPayload(1, 1, opDelete, 1, 'k')), nil); err != nil { | |||
| 117 | + | t.Fatalf("control record: %v", err) | |||
| 118 | + | } | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | type snapEntry struct { | |||
| 122 | + | key, value []byte | |||
| 123 | + | version uint64 | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | func iterOf(entries []snapEntry) entryIter { | |||
| 127 | + | return func(yield func(key, value []byte, version uint64) bool) { | |||
| 128 | + | for _, e := range entries { | |||
| 129 | + | if !yield(e.key, e.value, e.version) { | |||
| 130 | + | return | |||
| 131 | + | } | |||
| 132 | + | } | |||
| 133 | + | } | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | func encodeSnapshot(t testing.TB, seq uint64, entries []snapEntry) []byte { | |||
| 137 | + | t.Helper() | |||
| 138 | + | var buf bytes.Buffer | |||
| 139 | + | if err := writeSnapshot(&buf, seq, uint64(len(entries)), iterOf(entries)); err != nil { | |||
| 140 | + | t.Fatal(err) | |||
| 141 | + | } | |||
| 142 | + | return buf.Bytes() | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | func TestSnapshotRoundTrip(t *testing.T) { | |||
| 146 | + | entries := []snapEntry{ | |||
| 147 | + | {[]byte("a"), []byte("1"), 3}, | |||
| 148 | + | {[]byte("b"), nil, 7}, | |||
| 149 | + | {[]byte("c"), bytes.Repeat([]byte("x"), 1000), 1}, | |||
| 150 | + | } | |||
| 151 | + | for _, in := range [][]snapEntry{nil, entries} { | |||
| 152 | + | data := encodeSnapshot(t, 9, in) | |||
| 153 | + | view, err := openSnapshot(data) | |||
| 154 | + | if err != nil { | |||
| 155 | + | t.Fatal(err) | |||
| 156 | + | } | |||
| 157 | + | if view.seq != 9 || view.count != uint64(len(in)) { | |||
| 158 | + | t.Fatalf("view = %+v", view) | |||
| 159 | + | } | |||
| 160 | + | var got []snapEntry | |||
| 161 | + | view.each(func(k, v []byte, ver uint64) { got = append(got, snapEntry{k, v, ver}) }) | |||
| 162 | + | if len(got) != len(in) { | |||
| 163 | + | t.Fatalf("got %d entries, want %d", len(got), len(in)) | |||
| 164 | + | } | |||
| 165 | + | for i := range in { | |||
| 166 | + | if !bytes.Equal(got[i].key, in[i].key) || !bytes.Equal(got[i].value, in[i].value) || got[i].version != in[i].version { | |||
| 167 | + | t.Fatalf("entry %d = %+v, want %+v", i, got[i], in[i]) | |||
| 168 | + | } | |||
| 169 | + | } | |||
| 170 | + | } | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | func TestSnapshotCorrupt(t *testing.T) { | |||
| 174 | + | entries := []snapEntry{{[]byte("a"), []byte("1"), 1}, {[]byte("b"), []byte("2"), 2}} | |||
| 175 | + | data := encodeSnapshot(t, 2, entries) | |||
| 176 | + | for i := range data { | |||
| 177 | + | bad := slices.Clone(data) | |||
| 178 | + | bad[i] ^= 0x40 | |||
| 179 | + | if _, err := openSnapshot(bad); !errors.Is(err, ErrCorrupt) { | |||
| 180 | + | t.Fatalf("flip byte %d: err=%v", i, err) | |||
| 181 | + | } | |||
| 182 | + | } | |||
| 183 | + | for _, n := range []int{0, 1, snapHeaderSize, len(data) - 1} { | |||
| 184 | + | if _, err := openSnapshot(data[:n]); !errors.Is(err, ErrCorrupt) { | |||
| 185 | + | t.Fatalf("truncate to %d: err=%v", n, err) | |||
| 186 | + | } | |||
| 187 | + | } | |||
| 188 | + | if _, err := openSnapshot(append(slices.Clone(data), 0)); !errors.Is(err, ErrCorrupt) { | |||
| 189 | + | t.Fatalf("trailing byte: err=%v", err) | |||
| 190 | + | } | |||
| 191 | + | } | |||
| 192 | + | ||||
| 193 | + | // forgeSnapshot builds a snapshot with valid checksums around arbitrary body | |||
| 194 | + | // bytes, to reach the structural checks. | |||
| 195 | + | func forgeSnapshot(seq, count uint64, body []byte) []byte { | |||
| 196 | + | b := append([]byte(snapshotMagic), 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) | |||
| 197 | + | binary.LittleEndian.PutUint64(b[8:], seq) | |||
| 198 | + | binary.LittleEndian.PutUint64(b[16:], count) | |||
| 199 | + | b = binary.LittleEndian.AppendUint32(b, crc32.Checksum(b, castagnoli)) | |||
| 200 | + | b = append(b, body...) | |||
| 201 | + | return binary.LittleEndian.AppendUint32(b, crc32.Checksum(body, castagnoli)) | |||
| 202 | + | } | |||
| 203 | + | ||||
| 204 | + | func TestSnapshotStructure(t *testing.T) { | |||
| 205 | + | entry := func(k string, ver uint64, v string) []byte { | |||
| 206 | + | b := binary.AppendUvarint(nil, uint64(len(k))) | |||
| 207 | + | b = append(b, k...) | |||
| 208 | + | b = binary.AppendUvarint(b, ver) | |||
| 209 | + | b = binary.AppendUvarint(b, uint64(len(v))) | |||
| 210 | + | return append(b, v...) | |||
| 211 | + | } | |||
| 212 | + | cat := func(parts ...[]byte) []byte { return bytes.Join(parts, nil) } | |||
| 213 | + | cases := map[string][]byte{ | |||
| 214 | + | "not ascending": forgeSnapshot(5, 2, cat(entry("b", 1, ""), entry("a", 1, ""))), | |||
| 215 | + | "duplicate key": forgeSnapshot(5, 2, cat(entry("a", 1, ""), entry("a", 2, ""))), | |||
| 216 | + | "version > seq": forgeSnapshot(5, 1, entry("a", 6, "")), | |||
| 217 | + | "too few": forgeSnapshot(5, 2, entry("a", 1, "")), | |||
| 218 | + | "too many": forgeSnapshot(5, 1, cat(entry("a", 1, ""), entry("b", 1, ""))), | |||
| 219 | + | "empty key": forgeSnapshot(5, 1, entry("", 1, "")), | |||
| 220 | + | "huge count": forgeSnapshot(5, 1<<62, entry("a", 1, "")), | |||
| 221 | + | "value past end": forgeSnapshot(5, 1, []byte{1, 'a', 1, 9, 'v'}), | |||
| 222 | + | } | |||
| 223 | + | for name, b := range cases { | |||
| 224 | + | t.Run(name, func(t *testing.T) { | |||
| 225 | + | if _, err := openSnapshot(b); !errors.Is(err, ErrCorrupt) { | |||
| 226 | + | t.Fatalf("err = %v, want ErrCorrupt", err) | |||
| 227 | + | } | |||
| 228 | + | }) | |||
| 229 | + | } | |||
| 230 | + | if _, err := openSnapshot(forgeSnapshot(5, 2, cat(entry("a", 1, ""), entry("b", 5, "x")))); err != nil { | |||
| 231 | + | t.Fatalf("control snapshot: %v", err) | |||
| 232 | + | } | |||
| 233 | + | } | |||
| 234 | + | ||||
| 235 | + | func TestWriteSnapshotRejectsBadInput(t *testing.T) { | |||
| 236 | + | long := bytes.Repeat([]byte("k"), maxKeyLen+1) | |||
| 237 | + | cases := map[string]struct { | |||
| 238 | + | count uint64 | |||
| 239 | + | entries []snapEntry | |||
| 240 | + | }{ | |||
| 241 | + | "unsorted": {2, []snapEntry{{[]byte("b"), nil, 1}, {[]byte("a"), nil, 1}}}, | |||
| 242 | + | "duplicate": {2, []snapEntry{{[]byte("a"), nil, 1}, {[]byte("a"), nil, 1}}}, | |||
| 243 | + | "empty key": {1, []snapEntry{{nil, nil, 1}}}, | |||
| 244 | + | "long key": {1, []snapEntry{{long, nil, 1}}}, | |||
| 245 | + | "version": {1, []snapEntry{{[]byte("a"), nil, 4}}}, | |||
| 246 | + | "too few": {2, []snapEntry{{[]byte("a"), nil, 1}}}, | |||
| 247 | + | "too many": {1, []snapEntry{{[]byte("a"), nil, 1}, {[]byte("b"), nil, 1}}}, | |||
| 248 | + | "unsorted mid": {3, []snapEntry{{[]byte("a"), nil, 1}, {[]byte("c"), nil, 1}, {[]byte("b"), nil, 1}}}, | |||
| 249 | + | } | |||
| 250 | + | for name, c := range cases { | |||
| 251 | + | t.Run(name, func(t *testing.T) { | |||
| 252 | + | var buf bytes.Buffer | |||
| 253 | + | if err := writeSnapshot(&buf, 3, c.count, iterOf(c.entries)); err == nil { | |||
| 254 | + | t.Fatal("want error") | |||
| 255 | + | } | |||
| 256 | + | }) | |||
| 257 | + | } | |||
| 258 | + | } | |||
| 259 | + | ||||
| 260 | + | func FuzzDecodeRecord(f *testing.F) { | |||
| 261 | + | f.Add(appendRecord(nil, 1, sampleOps())) | |||
| 262 | + | f.Add(appendRecord(nil, 1<<40, []op{{kind: opDelete, key: []byte("k")}})) | |||
| 263 | + | f.Add(rawRecord(rawPayload(1, 1, opDelete, 1, 'k'))) | |||
| 264 | + | f.Add([]byte{}) | |||
| 265 | + | f.Add(make([]byte, 64)) | |||
| 266 | + | f.Add(appendLogHeader(nil, 7)) | |||
| 267 | + | f.Add(encodeSnapshot(f, 3, []snapEntry{{[]byte("a"), []byte("1"), 2}})) | |||
| 268 | + | f.Fuzz(func(t *testing.T, b []byte) { | |||
| 269 | + | // Decoders must not panic, whatever the input. | |||
| 270 | + | _, _ = decodeLogHeader(b) | |||
| 271 | + | if view, err := openSnapshot(b); err == nil { | |||
| 272 | + | view.each(func(k, v []byte, ver uint64) {}) | |||
| 273 | + | } | |||
| 274 | + | peekRecord(b) | |||
| 275 | + | seq, ops, n, err := decodeRecord(b, nil) | |||
| 276 | + | if err != nil { | |||
| 277 | + | return | |||
| 278 | + | } | |||
| 279 | + | // A readable record survives re-encoding (varints may be | |||
| 280 | + | // non-canonical in the input, so the bytes can differ). | |||
| 281 | + | if n > len(b) || len(ops) == 0 { | |||
| 282 | + | t.Fatalf("n=%d len=%d ops=%d", n, len(b), len(ops)) | |||
| 283 | + | } | |||
| 284 | + | enc := appendRecord(nil, seq, ops) | |||
| 285 | + | seq2, ops2, n2, err := decodeRecord(enc, nil) | |||
| 286 | + | if err != nil || seq2 != seq || n2 != len(enc) || !opsEqual(ops2, ops) { | |||
| 287 | + | t.Fatalf("round trip failed: err=%v", err) | |||
| 288 | + | } | |||
| 289 | + | }) | |||
| 290 | + | } | |||