Files
vault/store.go
T
Andy DixonandClaude Opus 5.5 0d45ecea59 Add rm -r for folders, and make ls list one folder level
rm -r FOLDER deletes a folder and everything under it, records and
objects, so the space is freed. It lists what will go (the first 20
names, then a count) with the total size and asks on the terminal; no
answer or no terminal deletes nothing. -f skips the question and makes a
missing name a no-op, -rf and -fr combine them, and rm -r / is refused.
Objects still in use are worked out once per delete, not once per file.

ls now shows one level, like ls(1): sub-folders once with a trailing
slash, their total size and newest date, then the files, with names
relative to the folder. list keeps the recursive listing of full names
that scripts pipe through cut -c31-, and ls -r is the same.

Docs, man pages, the guide (new rm-r screenshot) and README follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 18:15:28 +01:00

675 lines
18 KiB
Go

// Copyright 2026 The vault Authors.
// SPDX-License-Identifier: GPL-3.0-or-later
package main
import (
"bytes"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"math"
mrand "math/rand/v2"
"os"
"path"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"unicode"
"unicode/utf8"
"github.com/andydixon/vault/internal/pqcrypt"
)
// On disk a vault is:
//
// DIR/.vault marker
// DIR/records/<xx>/<id> one encrypted catalogue record per file
// DIR/objects/<xx>/<id> the encrypted body of a file, in one or more chunks
//
// <xx> is the first two hex digits of <id>, spreading entries over 256
// shards. IDs look random: record IDs are, and a file's object IDs derive
// from a secret seed in its record, so nothing on disk links a file's
// chunks. Records are padded to one fixed size. A body is cut into
// chunkSize objects and the last is padded to a power-of-two bucket, so
// every object is 64 KiB to 4 MiB whatever the file size. Every ciphertext's
// and shard's mtime is pinned to the epoch.
const (
markerName = ".vault"
markerContent = "VAULT 2\n"
recordsDir = "records"
objectsDir = "objects"
recordSize = 4096
nameLimit = 1024
fileLimit = int64(1) << 40
minPadding = int64(64 << 10)
chunkSize = minPadding << 6 // 4 MiB, the largest bucket
chunkData = chunkSize - idSize
idSize = 16
objectSeedSize = 32
fillerMin = 8
fillerMax = 32
)
var epoch = time.Unix(0, 0)
type record struct {
Name string `json:"name"`
Seed string `json:"seed"` // derives the file's object IDs
Size int64 `json:"size"`
Mode uint32 `json:"mode"`
Modified time.Time `json:"modified"`
Added time.Time `json:"added"`
id string // record file ID; not stored
}
type store struct {
root string
id *pqcrypt.PQIdentity
noSync bool // filler needs no per-file durability
}
// initStore creates a vault at root holding at least filler bytes of filler.
func initStore(root string, filler int64) error {
if _, err := os.Stat(filepath.Join(root, markerName)); err == nil {
return fmt.Errorf("%s is already a vault", root)
}
// Every shard exists from the start, so the tree's shape says nothing.
for _, dir := range []string{recordsDir, objectsDir} {
for i := range 256 {
if err := os.MkdirAll(filepath.Join(root, dir, fmt.Sprintf("%02x", i)), 0o700); err != nil {
return err
}
}
}
if err := writeFiller(root, filler); err != nil {
return err
}
for _, dir := range []string{recordsDir, objectsDir} {
shards, _ := filepath.Glob(filepath.Join(root, dir, "??"))
for _, p := range append(shards, filepath.Join(root, dir)) {
if err := os.Chtimes(p, epoch, epoch); err != nil {
return err
}
}
}
return os.WriteFile(filepath.Join(root, markerName), []byte(markerContent), 0o600)
}
// writeFiller stores random entries sealed to a key that is thrown away.
// Every vault therefore holds entries nobody can open, so a file hidden from
// a decoy passphrase is indistinguishable from filler. Filler entries are
// real entries, written by the same code as add, of log-uniformly random
// sizes: fillerMin to fillerMax of them, or more until they total target
// bytes.
func writeFiller(root string, target int64) error {
throwaway, err := pqcrypt.GeneratePQIdentity()
if err != nil {
return err
}
defer throwaway.Close()
s := &store{root: root, id: throwaway, noSync: true}
// Bigger targets allow bigger files, so the filler holds multi-chunk
// files in about the proportion a real collection would.
upper := float64(min(max(target/8, chunkSize), 1<<30))
count := fillerMin + mrand.IntN(fillerMax-fillerMin+1)
for i, total := 0, int64(0); i < count || total < target; i++ {
size := int64(1024 * math.Exp(mrand.Float64()*math.Log(upper/1024)))
seed, err := randomHex(objectSeedSize)
if err != nil {
return err
}
if err := s.writeObjects(rand.Reader, seed, size); err != nil {
return err
}
name, err := randomHex(8)
if err != nil {
return err
}
now := time.Now().UTC()
if err := s.writeRecord(&record{Name: name, Seed: seed, Size: size, Mode: 0o600, Modified: now, Added: now}); err != nil {
return err
}
total += size
}
return nil
}
func openStore(root string, id *pqcrypt.PQIdentity) (*store, error) {
marker, err := os.ReadFile(filepath.Join(root, markerName))
if err != nil || string(marker) != markerContent {
return nil, fmt.Errorf("%s is not a vault; run vault init", root)
}
return &store{root: root, id: id}, nil
}
func randomHex(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
func validHex(s string, n int) bool {
b, err := hex.DecodeString(s)
return err == nil && len(b) == n && hex.EncodeToString(b) == s
}
func validID(s string) bool { return validHex(s, idSize) }
// objectID derives the ID of a file's i-th object from its record's seed.
// Without the seed the IDs are unlinkable, so a file's chunks can't be told
// apart from any other object.
func objectID(seed string, i int64) string {
key, _ := hex.DecodeString(seed)
m := hmac.New(sha256.New, key)
binary.Write(m, binary.BigEndian, i)
return hex.EncodeToString(m.Sum(nil)[:idSize])
}
// objectCount is how many objects hold a file of size bytes.
func objectCount(size int64) int64 {
return max(1, (size+chunkData-1)/chunkData)
}
func (s *store) path(dir, id string) string {
return filepath.Join(s.root, dir, id[:2], id)
}
// removeFile deletes dir/id and re-pins its shard's mtime.
func (s *store) removeFile(dir, id string) error {
p := s.path(dir, id)
if err := os.Remove(p); err != nil {
return err
}
return os.Chtimes(filepath.Dir(p), epoch, epoch)
}
func validName(name string) bool {
if name == "" || len(name) > nameLimit || !utf8.ValidString(name) || path.Clean(name) != name ||
path.IsAbs(name) || name == "." || name == ".." || strings.HasPrefix(name, "../") || strings.Contains(name, "\\") {
return false
}
for _, r := range name {
if unicode.IsControl(r) {
return false
}
}
return true
}
// paddedSize rounds size up to the next power-of-two bucket.
func paddedSize(size int64) int64 {
p := minPadding
for p < size {
p *= 2
}
return p
}
// writeCiphertext encrypts to dir/id via a temporary file, so a partial
// ciphertext never appears under a real ID.
func (s *store) writeCiphertext(dir, id string, write func(io.Writer) error) error {
dst := s.path(dir, id)
dir = filepath.Dir(dst)
tmp, err := os.CreateTemp(dir, ".pending-")
if err != nil {
return err
}
defer os.Remove(tmp.Name())
defer tmp.Close()
w, err := pqcrypt.Encrypt(tmp, s.id.Recipient())
if err != nil {
return err
}
if err := write(w); err != nil {
return err
}
if err := w.Close(); err != nil {
return err
}
if !s.noSync {
if err := tmp.Sync(); err != nil {
return err
}
}
if err := tmp.Close(); err != nil {
return err
}
if err := os.Chtimes(tmp.Name(), epoch, epoch); err != nil {
return err
}
if err := os.Rename(tmp.Name(), dst); err != nil {
return err
}
if err := os.Chtimes(dir, epoch, epoch); err != nil || s.noSync {
return err
}
return syncDir(dir)
}
func syncDir(dir string) error {
d, err := os.Open(dir)
if err != nil {
return err
}
defer d.Close()
return d.Sync()
}
func (s *store) writeRecord(r *record) error {
data, err := json.Marshal(r)
if err != nil {
return err
}
if len(data) > recordSize-4 {
return errors.New("catalogue record too large")
}
plain := make([]byte, recordSize)
binary.BigEndian.PutUint32(plain, uint32(len(data)))
copy(plain[4:], data)
if _, err := rand.Read(plain[4+len(data):]); err != nil {
return err
}
if r.id, err = randomHex(idSize); err != nil {
return err
}
return s.writeCiphertext(recordsDir, r.id, func(w io.Writer) error {
_, err := w.Write(plain)
return err
})
}
// catalogue decrypts every record this identity can open. Records sealed to
// a different key are skipped silently: they are simply not ours.
func (s *store) catalogue() (map[string][]*record, error) {
shards, err := os.ReadDir(filepath.Join(s.root, recordsDir))
if err != nil {
return nil, err
}
byName := map[string][]*record{}
for _, shard := range shards {
if !shard.IsDir() {
continue
}
entries, err := os.ReadDir(filepath.Join(s.root, recordsDir, shard.Name()))
if err != nil {
return nil, err
}
for _, e := range entries {
if !validID(e.Name()) || e.Name()[:2] != shard.Name() {
continue
}
r, err := s.readRecord(e.Name())
var noMatch *pqcrypt.NoIdentityMatchError
if errors.As(err, &noMatch) {
continue
}
if err != nil {
fmt.Fprintf(os.Stderr, "vault: skipping unreadable record %s: %v\n", e.Name(), err)
continue
}
byName[r.Name] = append(byName[r.Name], r)
}
}
// Newest first, so [0] is current. Duplicates only exist after an
// interrupted replace; the next write of that name cleans them up.
for _, rs := range byName {
sort.Slice(rs, func(i, j int) bool { return rs[i].Added.After(rs[j].Added) })
}
return byName, nil
}
func (s *store) readRecord(id string) (*record, error) {
f, err := os.Open(s.path(recordsDir, id))
if err != nil {
return nil, err
}
defer f.Close()
pr, err := pqcrypt.Decrypt(f, s.id)
if err != nil {
return nil, err
}
plain, err := io.ReadAll(io.LimitReader(pr, recordSize+1))
if err != nil {
return nil, err
}
if len(plain) != recordSize {
return nil, errors.New("wrong record size")
}
n := binary.BigEndian.Uint32(plain)
if n > recordSize-4 {
return nil, errors.New("malformed record")
}
r := &record{id: id}
dec := json.NewDecoder(bytes.NewReader(plain[4 : 4+n]))
dec.DisallowUnknownFields()
if err := dec.Decode(r); err != nil {
return nil, err
}
if !validName(r.Name) || !validHex(r.Seed, objectSeedSize) || r.Size < 0 || r.Size > fileLimit {
return nil, errors.New("malformed record")
}
return r, nil
}
// add stores the file at src as name, replacing any existing file of that
// name, and updates cat to match. It reports whether something was replaced.
func (s *store) add(cat map[string][]*record, src, name string) (bool, error) {
if !validName(name) {
return false, fmt.Errorf("invalid vault path %q", name)
}
f, err := os.Open(src)
if err != nil {
return false, err
}
defer f.Close()
info, err := f.Stat()
if err != nil {
return false, err
}
if !info.Mode().IsRegular() || info.Size() > fileLimit {
return false, fmt.Errorf("%s: not a regular file of at most 1 TiB", src)
}
replaced, err := s.addReader(cat, f, info.Size(), info.Mode().Perm(), info.ModTime(), name)
if errors.Is(err, io.ErrUnexpectedEOF) || errors.Is(err, io.EOF) {
err = fmt.Errorf("%s changed while being added", src)
}
return replaced, err
}
// addReader stores size bytes from r as name, like add. Reading fewer than
// size bytes stores nothing and fails with io.ErrUnexpectedEOF or io.EOF.
func (s *store) addReader(cat map[string][]*record, r io.Reader, size int64, mode fs.FileMode, mtime time.Time, name string) (bool, error) {
if !validName(name) {
return false, fmt.Errorf("invalid vault path %q", name)
}
if size < 0 || size > fileLimit {
return false, fmt.Errorf("%s: larger than 1 TiB", name)
}
seed, err := randomHex(objectSeedSize)
if err != nil {
return false, err
}
if err := s.writeObjects(r, seed, size); err != nil {
s.dropObjects(seed, size)
return false, err
}
rec := &record{
Name: name, Seed: seed, Size: size, Mode: uint32(mode.Perm()),
Modified: mtime.UTC(), Added: time.Now().UTC(),
}
if err := s.writeRecord(rec); err != nil {
s.dropObjects(seed, size)
return false, err
}
replaced := len(cat[name]) > 0
if err := s.remove(cat, name, true); err != nil {
return replaced, err
}
cat[name] = []*record{rec}
return replaced, nil
}
// writeObjects reads size bytes from f into the objects derived from seed.
// Chunks are read in order but encrypted, written and synced by a few
// workers at once, so their fsyncs overlap instead of queueing.
func (s *store) writeObjects(f io.Reader, seed string, size int64) error {
var (
wg sync.WaitGroup
mu sync.Mutex
firstErr error
)
fail := func(err error) {
mu.Lock()
defer mu.Unlock()
if firstErr == nil {
firstErr = err
}
}
failed := func() bool {
mu.Lock()
defer mu.Unlock()
return firstErr != nil
}
slots := make(chan struct{}, 8) // at most 8 chunks, 32 MiB, in memory
remaining := size
for i := range objectCount(size) {
if failed() {
break
}
// The object ID leads the plaintext so a record can only ever
// open the objects it derives, each in its place, not one
// swapped in under its ID.
id := objectID(seed, i)
part := min(remaining, chunkData)
remaining -= part
slots <- struct{}{}
buf := make([]byte, idSize+part)
hex.Decode(buf, []byte(id))
if _, err := io.ReadFull(f, buf[idSize:]); err != nil {
<-slots
fail(err)
break
}
wg.Add(1)
go func() {
defer wg.Done()
defer func() { <-slots }()
err := s.writeCiphertext(objectsDir, id, func(w io.Writer) error {
if _, err := w.Write(buf); err != nil {
return err
}
_, err := io.CopyN(w, rand.Reader, paddedSize(int64(len(buf)))-int64(len(buf)))
return err
})
if err != nil {
fail(err)
}
}()
}
wg.Wait()
return firstErr
}
// remove deletes every record for name and, when dropObjects is set, any of
// their objects no other record still uses. Records go first, so an
// interruption only ever leaves an orphaned object.
func (s *store) remove(cat map[string][]*record, name string, dropObjects bool) error {
inUse := map[string]bool{}
for n, rs := range cat {
for _, r := range rs {
if n != name {
inUse[r.Seed] = true
}
}
}
for _, r := range cat[name] {
if err := s.removeFile(recordsDir, r.id); err != nil {
return err
}
if dropObjects && !inUse[r.Seed] {
if err := s.dropObjects(r.Seed, r.Size); err != nil {
return err
}
}
}
return nil
}
// dropObjects deletes the objects holding a file of size bytes, skipping any
// already gone.
func (s *store) dropObjects(seed string, size int64) error {
for i := range objectCount(size) {
if err := s.removeFile(objectsDir, objectID(seed, i)); err != nil && !os.IsNotExist(err) {
return err
}
}
return nil
}
func (s *store) lookup(name string) (map[string][]*record, *record, error) {
cat, err := s.catalogue()
if err != nil {
return nil, nil, err
}
rs := cat[name]
if len(rs) == 0 {
return nil, nil, fmt.Errorf("%s: not in the vault", name)
}
return cat, rs[0], nil
}
func (s *store) delete(name string) error {
cat, _, err := s.lookup(name)
if err != nil {
return err
}
return s.removeAll(cat, []string{name}, func(string) {})
}
// under returns the names in cat at or beneath the folder prefix, sorted.
func under(cat map[string][]*record, prefix string) []string {
var names []string
for name := range cat {
if name == prefix || strings.HasPrefix(name, prefix+"/") {
names = append(names, name)
}
}
sort.Strings(names)
return names
}
// removeAll deletes every record for names and the objects they hold, then
// drops them from cat, calling done after each name. Like remove, but it
// works out once which objects other records still use, so deleting a large
// folder stays linear.
func (s *store) removeAll(cat map[string][]*record, names []string, done func(name string)) error {
gone := map[string]bool{}
for _, name := range names {
gone[name] = true
}
inUse := map[string]bool{}
for name, rs := range cat {
if !gone[name] {
for _, r := range rs {
inUse[r.Seed] = true
}
}
}
for _, name := range names {
for _, r := range cat[name] {
if err := s.removeFile(recordsDir, r.id); err != nil {
return err
}
if !inUse[r.Seed] {
if err := s.dropObjects(r.Seed, r.Size); err != nil {
return err
}
}
}
delete(cat, name)
done(name)
}
return nil
}
func (s *store) rename(oldName, newName string) error {
if !validName(newName) {
return fmt.Errorf("invalid vault path %q", newName)
}
cat, r, err := s.lookup(oldName)
if err != nil {
return err
}
if len(cat[newName]) > 0 {
return fmt.Errorf("%s already exists", newName)
}
moved := *r
moved.Name = newName
if err := s.writeRecord(&moved); err != nil {
return err
}
return s.remove(cat, oldName, false)
}
// get decrypts name to out. Nothing appears at out until every object,
// padding included, has authenticated.
func (s *store) get(name, out string) error {
_, r, err := s.lookup(name)
if err != nil {
return err
}
if _, err := os.Lstat(out); err == nil {
return fmt.Errorf("%s already exists", out)
}
tmp, err := os.CreateTemp(filepath.Dir(out), ".vault-get-")
if err != nil {
return err
}
defer os.Remove(tmp.Name())
defer tmp.Close()
remaining := r.Size
for i := range objectCount(r.Size) {
part := min(remaining, chunkData)
if err := s.readObject(tmp, objectID(r.Seed, i), part); err != nil {
return err
}
remaining -= part
}
if err := tmp.Chmod(os.FileMode(r.Mode) & 0o777); err != nil {
return err
}
if err := tmp.Close(); err != nil {
return err
}
if err := os.Chtimes(tmp.Name(), r.Modified, r.Modified); err != nil {
return err
}
if _, err := os.Lstat(out); err == nil {
return fmt.Errorf("%s already exists", out)
}
return os.Rename(tmp.Name(), out)
}
// readObject decrypts object id to w, checking it leads with its own ID and
// holds part bytes padded to their bucket.
func (s *store) readObject(w io.Writer, id string, part int64) error {
f, err := os.Open(s.path(objectsDir, id))
if err != nil {
return err
}
defer f.Close()
pr, err := pqcrypt.Decrypt(f, s.id)
if err != nil {
return err
}
head := make([]byte, idSize)
if _, err := io.ReadFull(pr, head); err != nil || hex.EncodeToString(head) != id {
return errors.New("object does not match its catalogue record")
}
if _, err := io.CopyN(w, pr, part); err != nil {
return err
}
pad, err := io.Copy(io.Discard, pr)
if err != nil {
return err
}
if idSize+part+pad != paddedSize(idSize+part) {
return errors.New("object size does not match its catalogue record")
}
return nil
}