Open the downloaded snapshot database read-only, on a private temp dir (closes #162)
check / check (pull_request) Successful in 1m21s
check / check (push) Successful in 3m4s

Restore and deep verify used to open the decrypted snapshot database read-write through the local-index constructor, which applied migrations against whatever the file carried, and left the decrypted file in the shared temp directory. A forged file could redefine what restore queries return, and an interrupted open left decrypted metadata on disk.

Add database.OpenReadOnly: opens the file read-only (mode=ro) with query_only and trusted_schema=OFF, never applies schema files, and refuses a file whose schema carries a trigger, view or virtual table or lacks an expected table. Restore and deep verify now both use it, each inside its own private (0700) temp directory removed on every return path. pickNextDownload returns (FileID, bool) so a genuine nil-UUID file is not mistaken for "nothing left".

Model: opus-4-8
This commit was merged in pull request #186.
This commit is contained in:
2026-09-22 12:45:53 +02:00
parent b4654f8e52
commit a6434de57f
7 changed files with 541 additions and 62 deletions
+56 -34
View File
@@ -39,8 +39,14 @@ var (
"refusing to restore path outside the target directory")
errTrailingRestoreData = errors.New(
"restored file has trailing data after its last chunk")
errRestoreIncomplete = errors.New(
"restore loop ended with files still pending")
)
// snapshotDBFilename is the name the decrypted snapshot database is
// written under inside its private temp directory.
const snapshotDBFilename = "snapshot.db"
// restoreDirMode is the permission mode for directories created while
// restoring (parent directories and the target root; restored
// directories themselves get their stored mode).
@@ -102,7 +108,7 @@ func (v *Vaultik) Restore(opts *RestoreOptions) error {
// Step 1: Download and decrypt the snapshot metadata database
log.Info("Downloading snapshot metadata...")
tempDB, err := v.downloadSnapshotDB(opts.SnapshotID, identity)
tempDB, tempDir, err := v.downloadSnapshotDB(opts.SnapshotID, identity)
if err != nil {
return fmt.Errorf("downloading snapshot database: %w", err)
}
@@ -112,10 +118,11 @@ func (v *Vaultik) Restore(opts *RestoreOptions) error {
if err != nil {
log.Debug("Failed to close temp database", "error", err)
}
// Clean up temp file
err = v.Fs.Remove(tempDB.Path())
// Remove the whole private directory, so the decrypted database
// and any SQLite side files it produced are gone on every path.
err = v.Fs.RemoveAll(tempDir)
if err != nil {
log.Debug("Failed to remove temp database", "error", err)
log.Debug("Failed to remove temp database directory", "error", err)
}
}()
@@ -368,6 +375,13 @@ func (v *Vaultik) runRestoreLoop(
totalBytesExpected, startTime, &lastStatusTime)
}
// The loop above stops as soon as nothing is ready and nothing more
// can be downloaded. If files still remain, they were abandoned
// rather than restored; fail loudly instead of reporting success.
if plan.hasPending() {
return errRestoreIncomplete
}
return nil
}
@@ -382,8 +396,8 @@ func (v *Vaultik) runRestoreLoop(
func (s *restoreSession) downloadNextBlobSet(plan *restorePlan) (bool, error) {
s.sweeper.sweep()
next := plan.pickNextDownload()
if next.IsZero() {
next, ok := plan.pickNextDownload()
if !ok {
return false, nil
}
@@ -594,10 +608,10 @@ func (v *Vaultik) handleRestoreVerification(
// index can restore the snapshots it can only see on the store.
func (v *Vaultik) downloadSnapshotDB(
snapshotID string, identity age.Identity,
) (*database.DB, error) {
) (*database.DB, string, error) {
remoteKey, err := v.resolveSnapshotRemoteKey(snapshotID)
if err != nil {
return nil, err
return nil, "", err
}
// Download encrypted database from storage
@@ -605,7 +619,7 @@ func (v *Vaultik) downloadSnapshotDB(
reader, err := v.Storage.Get(v.ctx, dbKey)
if err != nil {
return nil, fmt.Errorf("downloading %s: %w", dbKey, err)
return nil, "", fmt.Errorf("downloading %s: %w", dbKey, err)
}
defer func() { _ = reader.Close() }()
@@ -613,7 +627,7 @@ func (v *Vaultik) downloadSnapshotDB(
// Read all data
encryptedData, err := io.ReadAll(reader)
if err != nil {
return nil, fmt.Errorf("reading encrypted data: %w", err)
return nil, "", fmt.Errorf("reading encrypted data: %w", err)
}
log.Debug("Downloaded encrypted database",
@@ -622,7 +636,7 @@ func (v *Vaultik) downloadSnapshotDB(
// Decrypt and decompress using blobgen.Reader
blobReader, err := blobgen.NewReader(bytes.NewReader(encryptedData), identity)
if err != nil {
return nil, fmt.Errorf("creating decryption reader: %w", err)
return nil, "", fmt.Errorf("creating decryption reader: %w", err)
}
defer func() { _ = blobReader.Close() }()
@@ -630,44 +644,52 @@ func (v *Vaultik) downloadSnapshotDB(
// Read the binary SQLite database
dbData, err := io.ReadAll(blobReader)
if err != nil {
return nil, fmt.Errorf("decrypting and decompressing: %w", err)
return nil, "", fmt.Errorf("decrypting and decompressing: %w", err)
}
log.Debug("Decrypted database", "size", ubytes(int64(len(dbData))))
// Create a temporary database file and write the binary SQLite data directly
tempFile, err := afero.TempFile(v.Fs, "", "vaultik-restore-*.db")
return v.materializeSnapshotDB(dbData)
}
// materializeSnapshotDB writes the decrypted snapshot database bytes into
// a fresh private (0700) temp directory and opens the file read-only. On
// any failure it removes the directory before returning, so no decrypted
// metadata is left on disk when the open is interrupted or the payload is
// damaged. On success the returned directory is the caller's to remove.
func (v *Vaultik) materializeSnapshotDB(
dbData []byte,
) (*database.DB, string, error) {
tempDir, err := afero.TempDir(v.Fs, "", "vaultik-restore-")
if err != nil {
return nil, fmt.Errorf("creating temp file: %w", err)
return nil, "", fmt.Errorf("creating temp directory: %w", err)
}
tempPath := tempFile.Name()
success := false
// Write the binary SQLite database directly
_, err = tempFile.Write(dbData)
defer func() {
if !success {
_ = v.Fs.RemoveAll(tempDir)
}
}()
dbPath := filepath.Join(tempDir, snapshotDBFilename)
err = afero.WriteFile(v.Fs, dbPath, dbData, restoreFileMode)
if err != nil {
_ = tempFile.Close()
_ = v.Fs.Remove(tempPath)
return nil, fmt.Errorf("writing database file: %w", err)
return nil, "", fmt.Errorf("writing database file: %w", err)
}
err = tempFile.Close()
if err != nil {
_ = v.Fs.Remove(tempPath)
log.Debug("Created restore database", "path", dbPath)
return nil, fmt.Errorf("closing temp file: %w", err)
db, err := database.OpenReadOnly(v.ctx, dbPath)
if err != nil {
return nil, "", fmt.Errorf("opening restore database: %w", err)
}
log.Debug("Created restore database", "path", tempPath)
success = true
// Open the database
db, err := database.New(v.ctx, tempPath)
if err != nil {
return nil, fmt.Errorf("opening restore database: %w", err)
}
return db, nil
return db, tempDir, nil
}
// getFilesToRestore returns the list of files to restore based on path filters