Open the downloaded snapshot database read-only, on a private temp dir (closes #162)
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:
+56
-34
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user