1// Package cache provides binary format serialization for fuzzy cache data structures.
2package cache
3
4import (
5 "encoding/binary"
6 "fmt"
7 "log"
8 "os"
9 "syscall"
10 "unsafe"
11
12 "github.com/bnema/dumber/internal/logging"
13)
14
15// Binary cache format layout:
16// Header: [magic:4][version:4][entryCount:4][indexOffset:8][lastModified:8]
17// Entries: [url_len:2][url:N][title_len:2][title:N][visit:2][lastVisit:4][score:2]
18// Index: [trigramCount:4][trigram:3][count:4][ids:4*count]...
19
20// Binary format constants for cache file structure
21const (
22 CacheMagic = 0x44554d42 // "DUMB" in little-endian
23 CacheVersion = 1
24 HeaderSize = 28 // Size of binary header
25)
26
27// BinaryHeader represents the cache file header.
28type BinaryHeader struct {
29 Magic uint32 // Magic number for format identification
30 Version uint32 // Cache format version
31 EntryCount uint32 // Number of entries in cache
32 IndexOffset uint64 // Offset to trigram index section
33 LastModified int64 // Unix timestamp of last modification
34}
35
36// SaveToBinary writes the cache to a binary file using memory mapping for performance.
37func (c *DmenuFuzzyCache) SaveToBinary(filename string) error {
38 c.mu.RLock()
39 defer c.mu.RUnlock()
40
41 // Calculate total file size
42 totalSize := c.calculateFileSize()
43
44 // Create the file with the exact size needed
45 file, err := os.Create(filename) //nolint:gosec // G304: filename is controlled by cache configuration, not user input
46 if err != nil {
47 return fmt.Errorf("failed to create cache file: %w", err)
48 }
49 defer func() {
50 if closeErr := file.Close(); closeErr != nil {
51 logging.Warn(fmt.Sprintf("failed to close cache file: %v", closeErr))
52 }
53 }()
54
55 // Pre-allocate file space
56 if err := file.Truncate(int64(totalSize)); err != nil {
57 return fmt.Errorf("failed to allocate file space: %w", err)
58 }
59
60 // Memory map the file for fast writing
61 data, err := syscall.Mmap(int(file.Fd()), 0, totalSize, syscall.PROT_WRITE, syscall.MAP_SHARED)
62 if err != nil {
63 return fmt.Errorf("failed to mmap file: %w", err)
64 }
65 defer func() {
66 if unmapErr := syscall.Munmap(data); unmapErr != nil {
67 logging.Warn(fmt.Sprintf("failed to unmap cache file: %v", unmapErr))
68 }
69 }()
70
71 // Write data using unsafe pointer operations for maximum speed
72 offset := 0
73
74 // Write header
75 offset = c.writeHeader(data, offset, totalSize)
76
77 // Write entries
78 entriesEndOffset := c.writeEntries(data, offset)
79
80 // Write trigram index
81 trigramEndOffset := c.writeTrigramIndex(data, entriesEndOffset)
82
83 // Write sorted index
84 c.writeSortedIndex(data, trigramEndOffset)
85
86 // Force sync to disk (Linux-specific, skip on error)
87 // syscall.Msync may not be available on all platforms
88 // _ = syscall.Msync(data, syscall.MS_SYNC)
89
90 return nil
91}
92
93// LoadFromBinary loads cache from a binary file using memory mapping.
94func (c *DmenuFuzzyCache) LoadFromBinary(filename string) error {
95 file, err := os.Open(filename) //nolint:gosec // G304: filename is controlled by cache configuration, not user input
96 if err != nil {
97 return fmt.Errorf("failed to open cache file: %w", err)
98 }
99 defer func() {
100 if closeErr := file.Close(); closeErr != nil {
101 logging.Warn(fmt.Sprintf("failed to close cache file: %v", closeErr))
102 }
103 }()
104
105 // Get file size
106 stat, err := file.Stat()
107 if err != nil {
108 return fmt.Errorf("failed to stat cache file: %w", err)
109 }
110 fileSize := int(stat.Size())
111
112 if fileSize < HeaderSize {
113 return fmt.Errorf("cache file too small: %d bytes", fileSize)
114 }
115
116 // Memory map the file for fast reading
117 data, err := syscall.Mmap(int(file.Fd()), 0, fileSize, syscall.PROT_READ, syscall.MAP_SHARED)
118 if err != nil {
119 return fmt.Errorf("failed to mmap cache file: %w", err)
120 }
121 // Note: We don't defer Munmap here as we want to keep the mapping alive
122 // The mapping will be cleaned up when the process exits
123
124 // Parse header
125 header, err := c.parseHeader(data)
126 if err != nil {
127 if unmapErr := syscall.Munmap(data); unmapErr != nil {
128 logging.Warn(fmt.Sprintf("failed to unmap cache file: %v", unmapErr))
129 }
130 return fmt.Errorf("failed to parse header: %w", err)
131 }
132
133 c.mu.Lock()
134 defer c.mu.Unlock()
135
136 // Parse entries
137 entries, err := c.parseEntries(data, HeaderSize, header.EntryCount)
138 if err != nil {
139 if unmapErr := syscall.Munmap(data); unmapErr != nil {
140 logging.Warn(fmt.Sprintf("failed to unmap cache file: %v", unmapErr))
141 }
142 return fmt.Errorf("failed to parse entries: %w", err)
143 }
144
145 // Parse trigram index
146 if header.IndexOffset > uint64(^uint(0)>>1) { // Check if it exceeds int max
147 return fmt.Errorf("index offset too large: %d", header.IndexOffset)
148 }
149 trigramIndex, trigramEndOffset, err := c.parseTrigramIndex(data, int(header.IndexOffset)) //nolint:gosec // G115: bounds checked above
150 if err != nil {
151 if unmapErr := syscall.Munmap(data); unmapErr != nil {
152 logging.Warn(fmt.Sprintf("failed to unmap cache file: %v", unmapErr))
153 }
154 return fmt.Errorf("failed to parse trigram index: %w", err)
155 }
156
157 // Parse sorted index
158 sortedIndex, err := c.parseSortedIndex(data, trigramEndOffset)
159 if err != nil {
160 if unmapErr := syscall.Munmap(data); unmapErr != nil {
161 logging.Warn(fmt.Sprintf("failed to unmap cache file: %v", unmapErr))
162 }
163 return fmt.Errorf("failed to parse sorted index: %w", err)
164 }
165
166 // Update cache state
167 c.entries = entries
168 c.trigramIndex = trigramIndex
169 c.sortedIndex = sortedIndex
170 c.entryCount = header.EntryCount
171 c.lastModified = header.LastModified
172 c.version = header.Version
173
174 return nil
175}
176
177// calculateFileSize estimates the total file size needed.
178func (c *DmenuFuzzyCache) calculateFileSize() int {
179 size := HeaderSize
180
181 // Calculate entries section size
182 for _, entry := range c.entries {
183 size += 2 + len(entry.URL) // url_len + url
184 size += 2 + len(entry.Title) // title_len + title
185 size += 2 + 4 + 2 // visit + lastVisit + score
186 }
187
188 // Calculate trigram index size
189 size += 4 // trigram count
190 for trigram, ids := range c.trigramIndex {
191 size += len(trigram) + 4 + len(ids)*4 // trigram + count + ids
192 }
193
194 // Calculate sorted index size
195 size += 4 + len(c.sortedIndex)*4 // count + indices
196
197 return size
198}
199
200// writeHeader writes the binary header.
201func (c *DmenuFuzzyCache) writeHeader(data []byte, offset int, totalSize int) int {
202 // Log header info to file only (not stdout) to avoid interfering with dmenu
203 if logger := logging.GetLogger(); logger != nil {
204 logger.WriteFileOnly(logging.LogLevelInfo(), fmt.Sprintf("writing header: totalSize=%d bytes", totalSize), "CACHE")
205 }
206 // Calculate index offset (header + all entries)
207 indexOffset := HeaderSize
208 for _, entry := range c.entries {
209 indexOffset += 2 + len(entry.URL) + 2 + len(entry.Title) + 8
210 }
211
212 // Check bounds for integer conversions
213 if len(c.entries) > 0xFFFFFFFF { // uint32 max
214 logging.Warn(fmt.Sprintf("too many entries for binary format: %d, truncating", len(c.entries)))
215 }
216 if indexOffset < 0 {
217 log.Printf("Warning: negative index offset: %d, using 0", indexOffset)
218 indexOffset = 0
219 }
220
221 header := BinaryHeader{
222 Magic: CacheMagic,
223 Version: CacheVersion,
224 EntryCount: uint32(len(c.entries)), //nolint:gosec // G115: bounds checked above
225 IndexOffset: uint64(indexOffset), //nolint:gosec // G115: bounds checked above
226 LastModified: c.lastModified,
227 }
228
229 // Write header using unsafe for maximum performance
230 *(*BinaryHeader)(unsafe.Pointer(&data[offset])) = header
231 return offset + HeaderSize
232}
233
234// writeEntries writes all cache entries in binary format.
235func (c *DmenuFuzzyCache) writeEntries(data []byte, offset int) int {
236 for _, entry := range c.entries {
237 // Write URL
238 urlLen := uint16(len(entry.URL))
239 *(*uint16)(unsafe.Pointer(&data[offset])) = urlLen
240 offset += 2
241 copy(data[offset:], entry.URL)
242 offset += len(entry.URL)
243
244 // Write Title
245 titleLen := uint16(len(entry.Title))
246 *(*uint16)(unsafe.Pointer(&data[offset])) = titleLen
247 offset += 2
248 copy(data[offset:], entry.Title)
249 offset += len(entry.Title)
250
251 // Write metadata
252 *(*uint16)(unsafe.Pointer(&data[offset])) = entry.VisitCount
253 offset += 2
254 *(*uint32)(unsafe.Pointer(&data[offset])) = entry.LastVisit
255 offset += 4
256 *(*uint16)(unsafe.Pointer(&data[offset])) = entry.Score
257 offset += 2
258 }
259 return offset
260}
261
262// writeTrigramIndex writes the trigram index in binary format.
263func (c *DmenuFuzzyCache) writeTrigramIndex(data []byte, offset int) int {
264 // Write trigram count
265 trigramCount := uint32(len(c.trigramIndex))
266 *(*uint32)(unsafe.Pointer(&data[offset])) = trigramCount
267 offset += 4
268
269 // Write trigrams and their entry lists
270 for trigram, ids := range c.trigramIndex {
271 // Write trigram (exactly 3 bytes)
272 copy(data[offset:offset+3], trigram)
273 offset += 3
274
275 // Write entry count
276 idCount := uint32(len(ids))
277 *(*uint32)(unsafe.Pointer(&data[offset])) = idCount
278 offset += 4
279
280 // Write entry IDs
281 for _, id := range ids {
282 *(*uint32)(unsafe.Pointer(&data[offset])) = id
283 offset += 4
284 }
285 }
286 return offset
287}
288
289// writeSortedIndex writes the sorted index in binary format.
290func (c *DmenuFuzzyCache) writeSortedIndex(data []byte, offset int) int {
291 // Write sorted index count
292 indexCount := uint32(len(c.sortedIndex))
293 *(*uint32)(unsafe.Pointer(&data[offset])) = indexCount
294 offset += 4
295
296 // Write sorted indices
297 for _, id := range c.sortedIndex {
298 *(*uint32)(unsafe.Pointer(&data[offset])) = id
299 offset += 4
300 }
301 return offset
302}
303
304// parseHeader parses the binary header from mapped data.
305func (c *DmenuFuzzyCache) parseHeader(data []byte) (*BinaryHeader, error) {
306 if len(data) < HeaderSize {
307 return nil, fmt.Errorf("insufficient data for header")
308 }
309
310 header := (*BinaryHeader)(unsafe.Pointer(&data[0]))
311
312 if header.Magic != CacheMagic {
313 return nil, fmt.Errorf("invalid magic number: 0x%x", header.Magic)
314 }
315
316 if header.Version != CacheVersion {
317 return nil, fmt.Errorf("unsupported cache version: %d", header.Version)
318 }
319
320 return header, nil
321}
322
323// parseEntries parses cache entries from binary data.
324func (c *DmenuFuzzyCache) parseEntries(data []byte, offset int, count uint32) ([]CompactEntry, error) {
325 entries := make([]CompactEntry, 0, count)
326
327 for i := uint32(0); i < count; i++ {
328 if offset+2 > len(data) {
329 return nil, fmt.Errorf("insufficient data for URL length at entry %d", i)
330 }
331
332 // Read URL
333 urlLen := *(*uint16)(unsafe.Pointer(&data[offset]))
334 offset += 2
335 if offset+int(urlLen) > len(data) {
336 return nil, fmt.Errorf("insufficient data for URL at entry %d", i)
337 }
338 url := string(data[offset : offset+int(urlLen)])
339 offset += int(urlLen)
340
341 // Read Title
342 if offset+2 > len(data) {
343 return nil, fmt.Errorf("insufficient data for title length at entry %d", i)
344 }
345 titleLen := *(*uint16)(unsafe.Pointer(&data[offset]))
346 offset += 2
347 if offset+int(titleLen) > len(data) {
348 return nil, fmt.Errorf("insufficient data for title at entry %d", i)
349 }
350 title := string(data[offset : offset+int(titleLen)])
351 offset += int(titleLen)
352
353 // Read metadata
354 if offset+8 > len(data) {
355 return nil, fmt.Errorf("insufficient data for metadata at entry %d", i)
356 }
357 visitCount := *(*uint16)(unsafe.Pointer(&data[offset]))
358 offset += 2
359 lastVisit := *(*uint32)(unsafe.Pointer(&data[offset]))
360 offset += 4
361 score := *(*uint16)(unsafe.Pointer(&data[offset]))
362 offset += 2
363
364 entries = append(entries, CompactEntry{
365 URL: url,
366 Title: title,
367 VisitCount: visitCount,
368 LastVisit: lastVisit,
369 Score: score,
370 })
371 }
372
373 return entries, nil
374}
375
376// parseTrigramIndex parses the trigram index from binary data.
377func (c *DmenuFuzzyCache) parseTrigramIndex(data []byte, offset int) (map[string][]uint32, int, error) {
378 if offset+4 > len(data) {
379 return nil, 0, fmt.Errorf("insufficient data for trigram count")
380 }
381
382 trigramCount := *(*uint32)(unsafe.Pointer(&data[offset]))
383 offset += 4
384
385 trigramIndex := make(map[string][]uint32, trigramCount)
386
387 for i := uint32(0); i < trigramCount; i++ {
388 // Read trigram
389 if offset+3 > len(data) {
390 return nil, 0, fmt.Errorf("insufficient data for trigram %d", i)
391 }
392 trigram := string(data[offset : offset+3])
393 offset += 3
394
395 // Read entry count
396 if offset+4 > len(data) {
397 return nil, 0, fmt.Errorf("insufficient data for entry count in trigram %d", i)
398 }
399 idCount := *(*uint32)(unsafe.Pointer(&data[offset]))
400 offset += 4
401
402 // Read entry IDs
403 if offset+int(idCount)*4 > len(data) {
404 return nil, 0, fmt.Errorf("insufficient data for entry IDs in trigram %d", i)
405 }
406
407 ids := make([]uint32, idCount)
408 for j := uint32(0); j < idCount; j++ {
409 ids[j] = *(*uint32)(unsafe.Pointer(&data[offset]))
410 offset += 4
411 }
412
413 trigramIndex[trigram] = ids
414 }
415
416 return trigramIndex, offset, nil
417}
418
419// parseSortedIndex parses the sorted index from binary data.
420func (c *DmenuFuzzyCache) parseSortedIndex(data []byte, offset int) ([]uint32, error) {
421 if offset+4 > len(data) {
422 return nil, fmt.Errorf("insufficient data for sorted index count")
423 }
424
425 indexCount := *(*uint32)(unsafe.Pointer(&data[offset]))
426 offset += 4
427
428 if offset+int(indexCount)*4 > len(data) {
429 return nil, fmt.Errorf("insufficient data for sorted index entries")
430 }
431
432 sortedIndex := make([]uint32, indexCount)
433 for i := uint32(0); i < indexCount; i++ {
434 sortedIndex[i] = *(*uint32)(unsafe.Pointer(&data[offset]))
435 offset += 4
436 }
437
438 return sortedIndex, nil
439}
440
441// IsValidCacheFile checks if a file is a valid cache file without fully loading it.
442func IsValidCacheFile(filename string) bool {
443 file, err := os.Open(filename)
444 if err != nil {
445 return false
446 }
447 defer func() {
448 if closeErr := file.Close(); closeErr != nil {
449 log.Printf("Warning: failed to close cache validation file: %v", closeErr)
450 }
451 }()
452
453 var header BinaryHeader
454 err = binary.Read(file, binary.LittleEndian, &header)
455 if err != nil {
456 return false
457 }
458
459 return header.Magic == CacheMagic && header.Version == CacheVersion
460}