// Package blob stores the KDBX payloads. Each database keeps a numbered // version history, which makes a rejected push recoverable and a bad save // reversible. package blob import ( "crypto/sha256" "encoding/hex" "fmt" "os" "path/filepath" "sort" "strconv" "strings" ) // KeepVersions is how many historical versions of each database are retained. const KeepVersions = 20 type Store struct{ dir string } // Open prepares dir as a blob store. func Open(dir string) (*Store, error) { if err := os.MkdirAll(dir, 0o700); err != nil { return nil, err } return &Store{dir: dir}, nil } // SHA256 is the lowercase hex digest used everywhere as a content fingerprint. func SHA256(data []byte) string { sum := sha256.Sum256(data) return hex.EncodeToString(sum[:]) } // Put writes one version of a database. Writing the same version twice with // the same bytes is a no-op, so a retried request is harmless. func (s *Store) Put(dbID string, version int64, data []byte) error { dir := filepath.Join(s.dir, dbID) if err := os.MkdirAll(dir, 0o700); err != nil { return err } path := s.versionPath(dbID, version) if old, err := os.ReadFile(path); err == nil && SHA256(old) == SHA256(data) { return nil } tmp := path + ".tmp" f, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) if err != nil { return err } if _, err := f.Write(data); err != nil { f.Close() os.Remove(tmp) return err } if err := f.Sync(); err != nil { f.Close() os.Remove(tmp) return err } if err := f.Close(); err != nil { os.Remove(tmp) return err } return os.Rename(tmp, path) } // Get reads one version of a database. func (s *Store) Get(dbID string, version int64) ([]byte, error) { return os.ReadFile(s.versionPath(dbID, version)) } // Versions lists the stored versions of a database, oldest first. func (s *Store) Versions(dbID string) []int64 { entries, err := os.ReadDir(filepath.Join(s.dir, dbID)) if err != nil { return nil } out := []int64{} for _, e := range entries { name := e.Name() if !strings.HasSuffix(name, ".kdbx") { continue } if v, err := strconv.ParseInt(strings.TrimSuffix(name, ".kdbx"), 10, 64); err == nil { out = append(out, v) } } sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) return out } // Prune removes all but the newest KeepVersions versions. func (s *Store) Prune(dbID string) error { vs := s.Versions(dbID) if len(vs) <= KeepVersions { return nil } for _, v := range vs[:len(vs)-KeepVersions] { if err := os.Remove(s.versionPath(dbID, v)); err != nil { return err } } return nil } // Forget deletes every version of a database. func (s *Store) Forget(dbID string) error { return os.RemoveAll(filepath.Join(s.dir, dbID)) } func (s *Store) versionPath(dbID string, version int64) string { return filepath.Join(s.dir, dbID, fmt.Sprintf("%020d.kdbx", version)) }