Fix every finding surfaced by the canonical .golangci.yml with golangci-lint v2.12.2 (refs #61), behavior-preserving throughout: - err113: dynamic errors replaced with package-level sentinels and %w wrapping; direct comparisons converted to errors.Is - goprintffuncname: printf-style helpers renamed with an f suffix (ui.Writer message methods, cli.ReportErrorf, database.Fatalf, vaultik stdoutf) and all call sites updated - revive: stuttering type names renamed (blob.Handler, blob.WithReader, blob.ChunkPosition, storage.URL, storage.Info), doc comments added, unused parameters blanked, package comments added - contextcheck/noctx: ctx threaded through blob.Packer (AddChunk/Flush/FinalizeBlob/PackChunks) and scanner call sites; context-aware exec and sql variants used - funlen/cyclop/gocognit/nestif/dupl: oversized or duplicated functions split into focused helpers across production and test code - paralleltest/tparallel/thelper/usetesting/testpackage: tests parallelized where safe (global log.Initialize kept in the serial phase), helpers marked, t.TempDir adopted, external test packages where only exported API is used - gosec: integer conversions clamped or justified, header timeouts added, remaining findings suppressed with per-site justifications - mnd/goconst/lll/wsl_v5/nlreturn/noinlineerr/errcheck and other mechanical findings fixed directly Remove the deprecated log.LogOptions alias (callers migrated to log.Options). make check is green.
395 lines
8.4 KiB
Go
395 lines
8.4 KiB
Go
package vaultik
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
)
|
|
|
|
// Sentinel errors for blob cache lookups.
|
|
var (
|
|
errCacheKeyMissing = errors.New("key not in cache")
|
|
errCacheReadBeyondBlob = errors.New("read beyond blob size")
|
|
)
|
|
|
|
// blobCacheFileMode is the permission mode for cached blob files.
|
|
const blobCacheFileMode = 0o600
|
|
|
|
// blobDiskCacheEntry tracks a cached blob on disk.
|
|
type blobDiskCacheEntry struct {
|
|
key string
|
|
size int64
|
|
prev *blobDiskCacheEntry
|
|
next *blobDiskCacheEntry
|
|
}
|
|
|
|
// blobDiskCache stores blobs on disk keyed by hash. It exposes ReadAt
|
|
// for slice reads (the restore path uses this so chunk extraction
|
|
// never reads a whole blob into memory) plus Get/Put for whole-blob
|
|
// access.
|
|
//
|
|
// Eviction policy is caller-controlled. The cache keeps an LRU list
|
|
// internally and will fall back to LRU eviction if curBytes exceeds
|
|
// maxBytes. Restore passes math.MaxInt64 as maxBytes and drives
|
|
// eviction itself via Delete() through restoreSweeper, which deletes
|
|
// each blob the moment every file that references its chunks has been
|
|
// written. LRU never fires under that configuration; it is kept as a
|
|
// safety net for callers that don't manage eviction themselves.
|
|
//
|
|
// Get/ReadAt/peak-Len counters are debugging instrumentation used by
|
|
// tests to assert that the restore code path uses ReadAt rather than
|
|
// Get and to bound peak disk-cache occupancy.
|
|
type blobDiskCache struct {
|
|
mu sync.Mutex
|
|
dir string
|
|
maxBytes int64
|
|
curBytes int64
|
|
items map[string]*blobDiskCacheEntry
|
|
head *blobDiskCacheEntry // most recent
|
|
tail *blobDiskCacheEntry // least recent
|
|
|
|
// Instrumentation. Mutated under mu; readable via the methods below.
|
|
getCalls int
|
|
readAtCalls int
|
|
peakLen int
|
|
}
|
|
|
|
// newBlobDiskCache creates a new disk-based blob cache with the given max size.
|
|
func newBlobDiskCache(maxBytes int64) (*blobDiskCache, error) {
|
|
dir, err := os.MkdirTemp("", "vaultik-blobcache-*")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("creating blob cache dir: %w", err)
|
|
}
|
|
|
|
return &blobDiskCache{
|
|
dir: dir,
|
|
maxBytes: maxBytes,
|
|
items: make(map[string]*blobDiskCacheEntry),
|
|
}, nil
|
|
}
|
|
|
|
// Put writes blob data to disk cache. Entries larger than maxBytes are
|
|
// silently skipped.
|
|
func (c *blobDiskCache) Put(key string, data []byte) error {
|
|
entrySize := int64(len(data))
|
|
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
if entrySize > c.maxBytes {
|
|
return nil
|
|
}
|
|
|
|
// Remove old entry if updating
|
|
if e, ok := c.items[key]; ok {
|
|
c.unlink(e)
|
|
c.curBytes -= e.size
|
|
_ = os.Remove(c.path(key))
|
|
delete(c.items, key)
|
|
}
|
|
|
|
err := os.WriteFile(c.path(key), data, blobCacheFileMode)
|
|
if err != nil {
|
|
return fmt.Errorf("writing blob to cache: %w", err)
|
|
}
|
|
|
|
e := &blobDiskCacheEntry{key: key, size: entrySize}
|
|
c.pushFront(e)
|
|
c.items[key] = e
|
|
c.curBytes += entrySize
|
|
|
|
for c.curBytes > c.maxBytes && c.tail != nil {
|
|
c.evictLRU()
|
|
}
|
|
|
|
if n := len(c.items); n > c.peakLen {
|
|
c.peakLen = n
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// PutFromReader streams r into the cache file for key, returning the
|
|
// total number of bytes written. Unlike Put, the data never has to
|
|
// reside fully in memory at any point — io.Copy uses an internal
|
|
// 32 KiB buffer. Used by restore to land a freshly decrypted blob on
|
|
// disk without buffering its entire plaintext (which may be tens of GB)
|
|
// in RAM.
|
|
func (c *blobDiskCache) PutFromReader(key string, r io.Reader) (int64, error) {
|
|
c.mu.Lock()
|
|
// Remove any prior entry first; we'll re-link after the file is
|
|
// written successfully.
|
|
if e, ok := c.items[key]; ok {
|
|
c.unlink(e)
|
|
c.curBytes -= e.size
|
|
_ = os.Remove(c.path(key))
|
|
delete(c.items, key)
|
|
}
|
|
c.mu.Unlock()
|
|
|
|
f, err := os.OpenFile(
|
|
c.path(key), os.O_CREATE|os.O_TRUNC|os.O_WRONLY, blobCacheFileMode)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("creating cache file: %w", err)
|
|
}
|
|
|
|
written, copyErr := io.Copy(f, r)
|
|
closeErr := f.Close()
|
|
|
|
if copyErr != nil {
|
|
_ = os.Remove(c.path(key))
|
|
|
|
return written, fmt.Errorf("streaming to cache file: %w", copyErr)
|
|
}
|
|
|
|
if closeErr != nil {
|
|
_ = os.Remove(c.path(key))
|
|
|
|
return written, fmt.Errorf("closing cache file: %w", closeErr)
|
|
}
|
|
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
// If the entry would exceed maxBytes outright, drop it on the
|
|
// floor — but the restore path passes math.MaxInt64 as maxBytes
|
|
// so this branch is effectively unreachable there.
|
|
if written > c.maxBytes {
|
|
_ = os.Remove(c.path(key))
|
|
|
|
return written, nil
|
|
}
|
|
|
|
e := &blobDiskCacheEntry{key: key, size: written}
|
|
c.pushFront(e)
|
|
c.items[key] = e
|
|
c.curBytes += written
|
|
|
|
for c.curBytes > c.maxBytes && c.tail != nil {
|
|
c.evictLRU()
|
|
}
|
|
|
|
if n := len(c.items); n > c.peakLen {
|
|
c.peakLen = n
|
|
}
|
|
|
|
return written, nil
|
|
}
|
|
|
|
// Get reads a cached blob from disk. Returns data and true on hit.
|
|
func (c *blobDiskCache) Get(key string) ([]byte, bool) {
|
|
c.mu.Lock()
|
|
c.getCalls++
|
|
|
|
e, ok := c.items[key]
|
|
if !ok {
|
|
c.mu.Unlock()
|
|
|
|
return nil, false
|
|
}
|
|
|
|
c.unlink(e)
|
|
c.pushFront(e)
|
|
c.mu.Unlock()
|
|
|
|
data, err := os.ReadFile(c.path(key))
|
|
if err != nil {
|
|
c.mu.Lock()
|
|
if e2, ok2 := c.items[key]; ok2 && e2 == e {
|
|
c.unlink(e)
|
|
delete(c.items, key)
|
|
c.curBytes -= e.size
|
|
}
|
|
c.mu.Unlock()
|
|
|
|
return nil, false
|
|
}
|
|
|
|
return data, true
|
|
}
|
|
|
|
// ReadAt reads a slice of a cached blob without loading the entire blob into memory.
|
|
func (c *blobDiskCache) ReadAt(key string, offset, length int64) ([]byte, error) {
|
|
c.mu.Lock()
|
|
c.readAtCalls++
|
|
|
|
e, ok := c.items[key]
|
|
if !ok {
|
|
c.mu.Unlock()
|
|
|
|
return nil, fmt.Errorf("%w: %q", errCacheKeyMissing, key)
|
|
}
|
|
|
|
if offset+length > e.size {
|
|
c.mu.Unlock()
|
|
|
|
return nil, fmt.Errorf("%w: offset=%d length=%d size=%d",
|
|
errCacheReadBeyondBlob, offset, length, e.size)
|
|
}
|
|
|
|
c.unlink(e)
|
|
c.pushFront(e)
|
|
c.mu.Unlock()
|
|
|
|
f, err := os.Open(c.path(key))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() { _ = f.Close() }()
|
|
|
|
buf := make([]byte, length)
|
|
|
|
_, err = f.ReadAt(buf, offset)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return buf, nil
|
|
}
|
|
|
|
// Has returns whether a key exists in the cache.
|
|
func (c *blobDiskCache) Has(key string) bool {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
_, ok := c.items[key]
|
|
|
|
return ok
|
|
}
|
|
|
|
// Delete removes a blob from the cache and its disk file. No-op if absent.
|
|
// Used by restore's sweep logic to free blobs whose chunks have all been
|
|
// restored (so they will never be needed again during this restore).
|
|
func (c *blobDiskCache) Delete(key string) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
e, ok := c.items[key]
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
c.unlink(e)
|
|
delete(c.items, key)
|
|
c.curBytes -= e.size
|
|
_ = os.Remove(c.path(key))
|
|
}
|
|
|
|
// Keys returns a snapshot of all cached keys. Safe for iteration without
|
|
// holding the cache lock; the cache may change concurrently.
|
|
func (c *blobDiskCache) Keys() []string {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
keys := make([]string, 0, len(c.items))
|
|
for k := range c.items {
|
|
keys = append(keys, k)
|
|
}
|
|
|
|
return keys
|
|
}
|
|
|
|
// Size returns current total cached bytes.
|
|
func (c *blobDiskCache) Size() int64 {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
return c.curBytes
|
|
}
|
|
|
|
// Len returns number of cached entries.
|
|
func (c *blobDiskCache) Len() int {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
return len(c.items)
|
|
}
|
|
|
|
// GetCalls returns the number of times Get has been called.
|
|
func (c *blobDiskCache) GetCalls() int {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
return c.getCalls
|
|
}
|
|
|
|
// ReadAtCalls returns the number of times ReadAt has been called.
|
|
func (c *blobDiskCache) ReadAtCalls() int {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
return c.readAtCalls
|
|
}
|
|
|
|
// PeakLen returns the maximum number of cached entries ever held at
|
|
// once during this cache's lifetime.
|
|
func (c *blobDiskCache) PeakLen() int {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
return c.peakLen
|
|
}
|
|
|
|
// Close removes the cache directory and all cached blobs.
|
|
func (c *blobDiskCache) Close() error {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
c.items = nil
|
|
c.head = nil
|
|
c.tail = nil
|
|
c.curBytes = 0
|
|
|
|
return os.RemoveAll(c.dir)
|
|
}
|
|
|
|
func (c *blobDiskCache) path(key string) string {
|
|
return filepath.Join(c.dir, key)
|
|
}
|
|
|
|
func (c *blobDiskCache) unlink(e *blobDiskCacheEntry) {
|
|
if e.prev != nil {
|
|
e.prev.next = e.next
|
|
} else {
|
|
c.head = e.next
|
|
}
|
|
|
|
if e.next != nil {
|
|
e.next.prev = e.prev
|
|
} else {
|
|
c.tail = e.prev
|
|
}
|
|
|
|
e.prev = nil
|
|
e.next = nil
|
|
}
|
|
|
|
func (c *blobDiskCache) pushFront(e *blobDiskCacheEntry) {
|
|
e.prev = nil
|
|
|
|
e.next = c.head
|
|
if c.head != nil {
|
|
c.head.prev = e
|
|
}
|
|
|
|
c.head = e
|
|
if c.tail == nil {
|
|
c.tail = e
|
|
}
|
|
}
|
|
|
|
func (c *blobDiskCache) evictLRU() {
|
|
if c.tail == nil {
|
|
return
|
|
}
|
|
|
|
victim := c.tail
|
|
c.unlink(victim)
|
|
delete(c.items, victim.key)
|
|
c.curBytes -= victim.size
|
|
_ = os.Remove(c.path(victim.key))
|
|
}
|