Compare commits

..
3 Commits
Author SHA1 Message Date
sneak efdb5eeb2b Default a scheme-less s3.* endpoint to TLS (closes #158)
check / check (pull_request) Successful in 2m35s
With the s3.* config form and an endpoint written without a scheme,
use_ssl being omitted built an http:// endpoint, while config.example.yml
documented use_ssl as defaulting to true. Over plain HTTP a network
observer sees manifests, object names, sizes and the access key id, and
can alter responses.

use_ssl is now *bool: omitted (nil) means the default, TLS; only an
explicit use_ssl: false forces plain HTTP. This matches the s3:// URL
form, which already defaults to TLS. The config init template dropped its
misleading use_ssl line from the s3:// block (that key is never read for
URLs; ?ssl=false controls TLS there) and points at ?ssl=false instead.

Model: opus-4-8
2026-09-22 09:02:31 +00:00
clawbot 3abe9cbd9e Scope the PID lock to mutating commands (closes #150)
check / check (push) Successful in 1m19s
check / check (pull_request) Successful in 1m16s
RunWithApp took the process-wide PID lock for every fx-backed command, so read-only commands (info, snapshot list, snapshot verify, remote info) failed with "already running" while a backup held it.

AppOptions now carries a lockMode declared at each call site: only mutating commands (snapshot create, snapshot purge, snapshot remove, prune, remote nuke) acquire the lock; read-only ones run without it. snapshot restore is classified read-only -- it writes only to its target directory, not the local index or remote store. The decision moves to a small acquireLockIfMutating helper, with a test that a read-only command runs while the lock is held and two mutators still exclude. The README locking section is rewritten to match.

Model: opus-4-8
2026-09-22 11:01:29 +02:00
clawbot 76a6917a35 Keep restore writes inside the target directory (closes #154)
check / check (pull_request) Successful in 1m21s
check / check (push) Successful in 2m51s
restoreFile and verifyRestoredFiles joined the stored path onto the target with no containment check, so a ".." segment or an absolute path escaped the target, and a restored symlink could redirect a later child write anywhere on disk. Since age decryption proves a snapshot is readable but not honest, and restore usually runs as root, a forged snapshot became an arbitrary file write.

Both call sites now go through containedRestorePath: it rejects a stored path unless filepath.IsLocal accepts it with the leading separator removed (barring "..", absolute, and empty paths), then Lstats each existing ancestor below the target and refuses to descend through a symlink. The target directory itself may be a symlink, and honest symlinks pointing outside the tree are still written verbatim.

Model: opus-4-8
2026-09-22 11:01:01 +02:00
11 changed files with 406 additions and 49 deletions
+12 -10
View File
@@ -171,18 +171,20 @@ vaultik version
### locking ### locking
Every command that opens the local index`snapshot create`, `snapshot Commands that write persistent state`snapshot create`, `snapshot
list`, `snapshot verify`, `snapshot purge`, `snapshot remove`, `snapshot purge`, `snapshot remove`, `prune`, and `remote nuke` — take a
restore`, `prune`, `info`, and `remote info`/`remote nuke` — takes a
process-wide lock at `$XDG_DATA_HOME/vaultik/vaultik.pid` process-wide lock at `$XDG_DATA_HOME/vaultik/vaultik.pid`
(`~/.local/share/vaultik/vaultik.pid` on Linux) for the whole run. Only (`~/.local/share/vaultik/vaultik.pid` on Linux) for the whole run. Only
one such command runs at a time: a second one exits immediately with an one of them runs at a time: a second one exits immediately with an
"already running" error rather than waiting. The lock is not scoped to "already running" error rather than waiting, so two writers can never
mutating commands, so read-only commands are affected too — `vaultik corrupt the local index or the destination store.
snapshot list` fails while a backup is in progress; scoping it so
read-only commands run during a backup is tracked in Read-only commands `info`, `snapshot list`, `snapshot verify`, and
[issue #150](https://git.eeqj.de/sneak/vaultik/issues/150). `config`, `remote info` — do not take the lock and are never blocked, so they run
`database delete`, `completion`, and `version` do not take the lock. even while a backup is in progress. `snapshot restore` does not take the
lock either: it writes only to the target directory you name, not the
local index or the destination store. `config`, `database delete`,
`completion`, and `version` do not take the lock.
### stdout and stderr ### stdout and stderr
+13
View File
@@ -38,6 +38,19 @@ release" is exactly the contradiction
recorded with no remote backend are marked uploaded so this invariant recorded with no remote backend are marked uploaded so this invariant
holds uniformly. holds uniformly.
- 2026-09-22: Made restore refuse any snapshot path that would write
outside the target directory
([issue #154](https://git.eeqj.de/sneak/vaultik/issues/154)).
`restoreFile` and `verifyRestoredFiles` joined the stored path onto the
target with no containment check, so a `..` segment or an absolute path
escaped the target and a restored symlink could redirect a later child
write anywhere on disk. Every stored path is now rejected unless
`filepath.IsLocal` accepts it with the leading separator removed, and
each existing ancestor directory below the target is `Lstat`ed to refuse
descending through a symlink; honest symlinks pointing outside the tree
are still written verbatim. age decryption proves a snapshot is
readable, not honest, and restore usually runs as root.
- 2026-09-21: Stopped `--json` from silencing stderr diagnostics - 2026-09-21: Stopped `--json` from silencing stderr diagnostics
([issue #112](https://git.eeqj.de/sneak/vaultik/issues/112)). `--json` ([issue #112](https://git.eeqj.de/sneak/vaultik/issues/112)). `--json`
used to be folded into `Quiet`, which pinned the log level to `WARN`, used to be folded into `Quiet`, which pinned the log level to `WARN`,
+63 -24
View File
@@ -33,14 +33,33 @@ import (
// may take before we give up. // may take before we give up.
const shutdownTimeout = 30 * time.Second const shutdownTimeout = 30 * time.Second
// AppOptions contains common options for creating the fx application. // lockMode says whether a command mutates persistent state — the local
// It includes the configuration file path, logging options, and additional // index database or the remote store — and so must hold the process-wide
// fx modules and invocations that should be included in the application. // PID lock, or only reads that state and may run alongside a mutator.
type lockMode int
const (
// mutating commands (snapshot create, snapshot purge, snapshot remove,
// prune, remote nuke) write the local index or the remote store. They
// hold the PID lock so that at most one runs at a time.
mutating lockMode = iota
// readOnly commands (info, snapshot list, snapshot verify, remote info,
// snapshot restore) do not write the local index or the remote store,
// so they run without the lock and are never blocked by a running
// mutator. restore writes only to the target directory it is given.
readOnly
)
// AppOptions contains common options for creating and running the fx
// application: the configuration file path, logging options, additional fx
// modules and invocations, and whether the command mutates persistent
// state (which decides whether it takes the PID lock).
type AppOptions struct { type AppOptions struct {
ConfigPath string ConfigPath string
LogOptions log.Options LogOptions log.Options
Modules []fx.Option Modules []fx.Option
Invokes []fx.Option Invokes []fx.Option
Mode lockMode
} }
// setupGlobals records the startup time and, when an output-suppression // setupGlobals records the startup time and, when an output-suppression
@@ -281,14 +300,15 @@ func RunOperation(
} }
// runVaultikApp runs the standard single-operation command lifecycle // runVaultikApp runs the standard single-operation command lifecycle
// shared by the list/purge/verify/remove/remote-info subcommands: // shared by the snapshot list/purge/remove and remote nuke subcommands:
// resolve the config, then run op against the Vaultik instance through // resolve the config, then run op against the Vaultik instance through
// RunOperation, reporting a failure prefixed with failMsg (suppressed // RunOperation, reporting a failure prefixed with failMsg (suppressed
// while suppressErrors is true, e.g. under --json). jsonOutput marks a // while suppressErrors is true, e.g. under --json). mode says whether the
// command whose stdout is a JSON document: it quiets the UI but, unlike // command takes the PID lock. jsonOutput marks a command whose stdout is a
// Quiet, leaves the stderr log level alone. // JSON document: it quiets the UI but, unlike Quiet, leaves the stderr log
// level alone.
func runVaultikApp( func runVaultikApp(
cmd *cobra.Command, jsonOutput, suppressErrors bool, cmd *cobra.Command, mode lockMode, jsonOutput, suppressErrors bool,
failMsg string, op func(v *vaultik.Vaultik) error, failMsg string, op func(v *vaultik.Vaultik) error,
) error { ) error {
configPath, err := ResolveConfigPath() configPath, err := ResolveConfigPath()
@@ -306,6 +326,7 @@ func runVaultikApp(
Quiet: rootFlags.Quiet, Quiet: rootFlags.Quiet,
JSON: jsonOutput, JSON: jsonOutput,
}, },
Mode: mode,
}, op, func(err error) { }, op, func(err error) {
if suppressErrors { if suppressErrors {
return return
@@ -319,28 +340,46 @@ func runVaultikApp(
// RunWithApp is a helper that creates and runs an fx app with the given options. // RunWithApp is a helper that creates and runs an fx app with the given options.
// It combines NewApp and RunApp into a single convenient function. This is the // It combines NewApp and RunApp into a single convenient function. This is the
// preferred way to run CLI commands that need the full application context. // preferred way to run CLI commands that need the full application context.
// It acquires a PID lock before starting to prevent concurrent instances. // A mutating command takes the process-wide PID lock before starting so that
// only one runs at a time; a read-only command runs without it and is not
// blocked while a mutator holds the lock (opts.Mode).
func RunWithApp(ctx context.Context, opts AppOptions) error { func RunWithApp(ctx context.Context, opts AppOptions) error {
// Acquire PID lock to prevent concurrent instances release, err := acquireLockIfMutating(opts.Mode,
lockDir := filepath.Join(xdg.DataHome, "vaultik") filepath.Join(xdg.DataHome, "vaultik"))
lock, err := pidlock.Acquire(lockDir)
if err != nil { if err != nil {
if errors.Is(err, pidlock.ErrAlreadyRunning) { return err
return fmt.Errorf("cannot start: %w", err)
} }
return fmt.Errorf("failed to acquire lock: %w", err) defer release()
}
defer func() {
err := lock.Release()
if err != nil {
log.Warn("Failed to release PID lock", "error", err)
}
}()
app := NewApp(opts) app := NewApp(opts)
return RunApp(ctx, app) return RunApp(ctx, app)
} }
// acquireLockIfMutating takes the process-wide PID lock in lockDir for a
// mutating command and returns a function that releases it. A read-only
// command takes no lock, so it returns a no-op release and is never blocked
// while a mutator holds the lock. ErrAlreadyRunning (another mutator holds
// the lock) is surfaced as a "cannot start" error.
func acquireLockIfMutating(mode lockMode, lockDir string) (func(), error) {
if mode != mutating {
return func() {}, nil
}
lock, err := pidlock.Acquire(lockDir)
if err != nil {
if errors.Is(err, pidlock.ErrAlreadyRunning) {
return nil, fmt.Errorf("cannot start: %w", err)
}
return nil, fmt.Errorf("failed to acquire lock: %w", err)
}
return func() {
err := lock.Release()
if err != nil {
log.Warn("Failed to release PID lock", "error", err)
}
}, nil
}
+42
View File
@@ -2,7 +2,10 @@ package cli //nolint:testpackage // needs access to unexported cleanStartupError
import ( import (
"errors" "errors"
"path/filepath"
"testing" "testing"
"sneak.berlin/go/vaultik/internal/pidlock"
) )
func TestCleanStartupError(t *testing.T) { func TestCleanStartupError(t *testing.T) {
@@ -53,3 +56,42 @@ func TestCleanStartupError(t *testing.T) {
}) })
} }
} }
// TestLockScopedToMutatingCommands proves the partition the PID lock now
// enforces: a read-only command runs while a mutator holds the lock, and
// two mutating commands still mutually exclude.
func TestLockScopedToMutatingCommands(t *testing.T) {
t.Parallel()
lockDir := filepath.Join(t.TempDir(), "vaultik")
// A mutating command takes the process-wide lock.
releaseMutator, err := acquireLockIfMutating(mutating, lockDir)
if err != nil {
t.Fatalf("mutating command could not acquire lock: %v", err)
}
// A read-only command runs to completion even while the lock is held.
releaseReader, err := acquireLockIfMutating(readOnly, lockDir)
if err != nil {
t.Fatalf("read-only command was blocked by held lock: %v", err)
}
releaseReader()
// A second mutating command is refused while the first holds the lock.
_, err = acquireLockIfMutating(mutating, lockDir)
if !errors.Is(err, pidlock.ErrAlreadyRunning) {
t.Fatalf("second mutating command was not excluded, got: %v", err)
}
// Once the first mutator releases, another mutating command may run.
releaseMutator()
release, err := acquireLockIfMutating(mutating, lockDir)
if err != nil {
t.Fatalf("mutating command could not acquire released lock: %v", err)
}
release()
}
+1
View File
@@ -35,6 +35,7 @@ func NewInfoCommand() *cobra.Command {
Debug: rootFlags.Debug, Debug: rootFlags.Debug,
Quiet: rootFlags.Quiet, Quiet: rootFlags.Quiet,
}, },
Mode: readOnly,
}, func(v *vaultik.Vaultik) error { }, func(v *vaultik.Vaultik) error {
return v.ShowInfo() return v.ShowInfo()
}, func(err error) { }, func(err error) {
+1
View File
@@ -44,6 +44,7 @@ work (e.g. after a crashed backup or to reclaim storage).`,
Quiet: rootFlags.Quiet, Quiet: rootFlags.Quiet,
JSON: opts.JSON, JSON: opts.JSON,
}, },
Mode: mutating,
}, func(v *vaultik.Vaultik) error { }, func(v *vaultik.Vaultik) error {
return v.Prune(opts) return v.Prune(opts)
}, func(err error) { }, func(err error) {
+2 -1
View File
@@ -45,7 +45,7 @@ This is destructive and irreversible. Requires --force.`,
return errNukeNeedsForce return errNukeNeedsForce
} }
return runVaultikApp(cmd, false, false, "Remote nuke failed", return runVaultikApp(cmd, mutating, false, false, "Remote nuke failed",
func(v *vaultik.Vaultik) error { func(v *vaultik.Vaultik) error {
return v.NukeRemote(true) return v.NukeRemote(true)
}) })
@@ -88,6 +88,7 @@ func newRemoteInfoCommand() *cobra.Command {
Quiet: rootFlags.Quiet, Quiet: rootFlags.Quiet,
JSON: jsonOutput, JSON: jsonOutput,
}, },
Mode: readOnly,
}, func(v *vaultik.Vaultik) error { }, func(v *vaultik.Vaultik) error {
return v.RemoteInfo(jsonOutput) return v.RemoteInfo(jsonOutput)
}, func(err error) { }, func(err error) {
+5 -3
View File
@@ -92,6 +92,7 @@ specifying a path using --config or by setting VAULTIK_CONFIG to a path.`,
Cron: opts.Cron, Cron: opts.Cron,
Quiet: rootFlags.Quiet, Quiet: rootFlags.Quiet,
}, },
Mode: mutating,
}, func(v *vaultik.Vaultik) error { }, func(v *vaultik.Vaultik) error {
return v.CreateSnapshot(opts) return v.CreateSnapshot(opts)
}, func(err error) { }, func(err error) {
@@ -125,7 +126,7 @@ func newSnapshotListCommand() *cobra.Command {
Long: "Lists all snapshots with their ID, timestamp, and compressed size", Long: "Lists all snapshots with their ID, timestamp, and compressed size",
Args: cobra.NoArgs, Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error { RunE: func(cmd *cobra.Command, _ []string) error {
return runVaultikApp(cmd, false, false, return runVaultikApp(cmd, readOnly, false, false,
"Failed to list snapshots", "Failed to list snapshots",
func(v *vaultik.Vaultik) error { func(v *vaultik.Vaultik) error {
return v.ListSnapshots(jsonOutput) return v.ListSnapshots(jsonOutput)
@@ -161,7 +162,7 @@ restrict the operation to specific snapshot names.`,
return errPurgeCriteriaBoth return errPurgeCriteriaBoth
} }
return runVaultikApp(cmd, false, false, return runVaultikApp(cmd, mutating, false, false,
"Failed to purge snapshots", "Failed to purge snapshots",
func(v *vaultik.Vaultik) error { func(v *vaultik.Vaultik) error {
return v.PurgeSnapshotsWithOptions(opts) return v.PurgeSnapshotsWithOptions(opts)
@@ -212,6 +213,7 @@ func newSnapshotVerifyCommand() *cobra.Command {
Quiet: rootFlags.Quiet, Quiet: rootFlags.Quiet,
JSON: opts.JSON, JSON: opts.JSON,
}, },
Mode: readOnly,
}, func(v *vaultik.Vaultik) error { }, func(v *vaultik.Vaultik) error {
return v.VerifySnapshotWithOptions(snapshotID, opts) return v.VerifySnapshotWithOptions(snapshotID, opts)
}, func(err error) { }, func(err error) {
@@ -259,7 +261,7 @@ To wipe the entire destination store and start over, use 'vaultik remote
nuke --force' — it is the single supported entry point for that.`, nuke --force' — it is the single supported entry point for that.`,
Args: requireSnapshotIDArg, Args: requireSnapshotIDArg,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
return runVaultikApp(cmd, opts.JSON, opts.JSON, return runVaultikApp(cmd, mutating, opts.JSON, opts.JSON,
"Failed to remove snapshot", "Failed to remove snapshot",
func(v *vaultik.Vaultik) error { func(v *vaultik.Vaultik) error {
_, err := v.RemoveSnapshot(args[0], opts) _, err := v.RemoveSnapshot(args[0], opts)
+1
View File
@@ -88,6 +88,7 @@ func runRestore(cmd *cobra.Command, args []string, opts *RestoreOptions) error {
Debug: rootFlags.Debug, Debug: rootFlags.Debug,
Quiet: rootFlags.Quiet, Quiet: rootFlags.Quiet,
}, },
Mode: readOnly,
}, func(v *vaultik.Vaultik) error { }, func(v *vaultik.Vaultik) error {
return v.Restore(&vaultik.RestoreOptions{ return v.Restore(&vaultik.RestoreOptions{
SnapshotID: snapshotID, SnapshotID: snapshotID,
+87 -7
View File
@@ -11,6 +11,7 @@ import (
"math" "math"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"time" "time"
"filippo.io/age" "filippo.io/age"
@@ -34,6 +35,8 @@ var (
errChunkNotInAnyBlob = errors.New("chunk not found in any blob") errChunkNotInAnyBlob = errors.New("chunk not found in any blob")
errBlobIDNotInHashIndex = errors.New("blob id missing from hash index") errBlobIDNotInHashIndex = errors.New("blob id missing from hash index")
errShortChunkRead = errors.New("short read") errShortChunkRead = errors.New("short read")
errRestorePathEscapesTarget = errors.New(
"refusing to restore path outside the target directory")
) )
// restoreDirMode is the permission mode for directories created while // restoreDirMode is the permission mode for directories created while
@@ -760,13 +763,85 @@ type restoreSession struct {
runningAsRoot bool runningAsRoot bool
} }
// containedRestorePath resolves rel — a path read from the snapshot
// database — to its location under targetDir and confirms the write will
// stay inside the target.
//
// age decryption proves a snapshot is readable, not that it is honest, so
// every stored path is treated as hostile. rel is rejected unless
// filepath.IsLocal accepts it once the leading separator is stripped:
// stored paths are absolute and the join to targetDir drops that
// separator, so "/etc/passwd" is judged as the relative "etc/passwd" it
// becomes on disk. This bars "..", absolute, and empty paths.
//
// A stored symlink whose target points outside the tree is still honest
// (and restored verbatim), but a later entry must not be written through
// it. Each existing ancestor directory below the target is therefore
// Lstat'ed and a symlink among them is refused. The leaf itself is not
// traversed: honest snapshots restore symlinks at leaf positions, and the
// unique-path constraint keeps a leaf from being both a symlink and a
// regular file. The target directory itself may be a symlink; only
// components below it are checked.
func containedRestorePath(fs afero.Fs, targetDir, rel string) (string, error) {
local := strings.TrimPrefix(rel, string(filepath.Separator))
if !filepath.IsLocal(local) {
return "", fmt.Errorf("%w: %s", errRestorePathEscapesTarget, rel)
}
local = filepath.Clean(local)
targetPath := filepath.Join(targetDir, local)
relDir := filepath.Dir(local)
if relDir == "." {
return targetPath, nil
}
current := targetDir
for component := range strings.SplitSeq(relDir, string(filepath.Separator)) {
current = filepath.Join(current, component)
info, err := lstatIfPossible(fs, current)
if err != nil {
if os.IsNotExist(err) {
continue
}
return "", fmt.Errorf("checking restore path %s: %w", current, err)
}
if info.Mode()&os.ModeSymlink != 0 {
return "", fmt.Errorf("%w: %s descends through symlink %s",
errRestorePathEscapesTarget, rel, current)
}
}
return targetPath, nil
}
// lstatIfPossible performs a symlink-aware stat when the filesystem
// supports it. afero.OsFs does; MemMapFs, which has no symlinks, reports
// that Lstat was not used and its result never carries ModeSymlink.
func lstatIfPossible(fs afero.Fs, name string) (os.FileInfo, error) {
if lstater, ok := fs.(afero.Lstater); ok {
info, _, err := lstater.LstatIfPossible(name)
return info, err
}
return fs.Stat(name)
}
// restoreFile dispatches to the right per-kind restorer. // restoreFile dispatches to the right per-kind restorer.
func (s *restoreSession) restoreFile(file *database.File) error { func (s *restoreSession) restoreFile(file *database.File) error {
targetPath := filepath.Join(s.opts.TargetDir, file.Path.String()) targetPath, err := containedRestorePath(
s.v.Fs, s.opts.TargetDir, file.Path.String())
if err != nil {
return err
}
parentDir := filepath.Dir(targetPath) parentDir := filepath.Dir(targetPath)
err := s.v.Fs.MkdirAll(parentDir, restoreDirMode) err = s.v.Fs.MkdirAll(parentDir, restoreDirMode)
if err != nil { if err != nil {
return fmt.Errorf("creating parent directory: %w", err) return fmt.Errorf("creating parent directory: %w", err)
} }
@@ -1062,17 +1137,22 @@ func (v *Vaultik) verifyRestoredFiles(
return ctx.Err() return ctx.Err()
} }
targetPath := filepath.Join(targetDir, file.Path.String()) targetPath, err := containedRestorePath(v.Fs, targetDir, file.Path.String())
if err == nil {
var bytesVerified int64
bytesVerified, err = v.verifyFile(ctx, repos, file, targetPath)
if err == nil {
result.FilesVerified++
result.BytesVerified += bytesVerified
}
}
bytesVerified, err := v.verifyFile(ctx, repos, file, targetPath)
if err != nil { if err != nil {
log.Error("File verification failed", "path", file.Path, "error", err) log.Error("File verification failed", "path", file.Path, "error", err)
result.FilesFailed++ result.FilesFailed++
result.FailedFiles = append(result.FailedFiles, file.Path.String()) result.FailedFiles = append(result.FailedFiles, file.Path.String())
} else {
result.FilesVerified++
result.BytesVerified += bytesVerified
} }
bytesProcessed += file.Size bytesProcessed += file.Size
@@ -0,0 +1,175 @@
package vaultik //nolint:testpackage // drives unexported restore internals
import (
"context"
"io"
"os"
"path/filepath"
"testing"
"time"
"github.com/spf13/afero"
"github.com/stretchr/testify/require"
"sneak.berlin/go/vaultik/internal/config"
"sneak.berlin/go/vaultik/internal/database"
"sneak.berlin/go/vaultik/internal/log"
"sneak.berlin/go/vaultik/internal/types"
"sneak.berlin/go/vaultik/internal/ui"
)
// These tests exercise the path-containment guard that keeps restore from
// writing outside its target directory. age decryption proves only that a
// snapshot is readable, not that its recorded paths are honest, so restore
// treats every stored path as hostile: a compromised backed-up host could
// forge a snapshot that decrypts cleanly, and restore usually runs as root.
//
// They drive restoreAllFiles directly (rather than the full Restore, which
// downloads and decrypts the metadata database from storage) so a snapshot
// database with adversarial rows can be handed to the restore loop without
// the surrounding blob/storage machinery. Directory and symlink entries
// carry no chunks, so no blobs are needed.
// containmentDirMode marks a File row as a directory for the restore loop.
const containmentDirMode = uint32(os.ModeDir | 0o755)
// newContainmentVaultik builds the minimal Vaultik needed to run
// restoreAllFiles against fs.
func newContainmentVaultik(ctx context.Context, fs afero.Fs) *Vaultik {
v := &Vaultik{
Config: &config.Config{
BlobSizeLimit: config.Size(10 * 1024 * 1024),
},
Fs: fs,
Stdout: io.Discard,
Stderr: io.Discard,
UI: ui.NewWithColor(io.Discard, false),
}
v.SetContext(ctx)
return v
}
// makeFiles inserts the given rows into a fresh in-memory snapshot database
// and returns them (with IDs assigned) plus the repositories.
func makeFiles(
ctx context.Context, t *testing.T, rows []*database.File,
) ([]*database.File, *database.Repositories) {
t.Helper()
db, err := database.New(ctx, filepath.Join(t.TempDir(), "index.sqlite"))
require.NoError(t, err)
t.Cleanup(func() { _ = db.Close() })
repos := database.NewRepositories(db)
for _, f := range rows {
require.NoError(t, repos.Files.Create(ctx, nil, f))
}
return rows, repos
}
func TestRestoreRejectsPathTraversal(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
tests := []struct {
name string
// rows are inserted in order; the escape entry is restored after
// any entry it depends on (the symlink case needs its link first).
rows func(outsideDir string) []*database.File
// escaped is the path, outside the target, that must not appear.
escaped func(tempDir, outsideDir string) string
}{
{
name: "relative dotdot",
rows: func(_ string) []*database.File {
return []*database.File{{
Path: "../escaped-relative",
Mode: containmentDirMode,
}}
},
escaped: func(tempDir, _ string) string {
return filepath.Join(tempDir, "escaped-relative")
},
},
{
name: "absolute with dotdot",
rows: func(_ string) []*database.File {
return []*database.File{{
Path: "/a/../../escaped-absolute",
Mode: containmentDirMode,
}}
},
escaped: func(tempDir, _ string) string {
return filepath.Join(tempDir, "escaped-absolute")
},
},
{
name: "child through symlink",
rows: func(outsideDir string) []*database.File {
return []*database.File{
// Restored first: an in-target symlink pointing out.
{Path: "linkdir", LinkTarget: types.FilePath(outsideDir)},
// Restored second: a child written through that link.
{Path: "linkdir/child", Mode: containmentDirMode},
}
},
escaped: func(_, outsideDir string) string {
return filepath.Join(outsideDir, "child")
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
ctx := context.Background()
fs := afero.NewOsFs()
tempDir := t.TempDir()
targetDir := filepath.Join(tempDir, "target")
outsideDir := filepath.Join(tempDir, "outside")
require.NoError(t, fs.MkdirAll(outsideDir, 0o755))
rows, repos := makeFiles(ctx, t, tc.rows(outsideDir))
v := newContainmentVaultik(ctx, fs)
_, err := v.restoreAllFiles(rows, repos,
&RestoreOptions{TargetDir: targetDir}, nil, nil)
require.ErrorIs(t, err, errRestorePathEscapesTarget)
escaped := tc.escaped(tempDir, outsideDir)
_, statErr := os.Lstat(escaped)
require.Truef(t, os.IsNotExist(statErr),
"restore wrote outside the target at %s", escaped)
})
}
}
// TestRestoreAllowsSymlinkPointingOutsideTree confirms the guard does not
// over-block: an honest snapshot may contain a symlink whose target lies
// outside the restored tree, and it must still be restored verbatim.
func TestRestoreAllowsSymlinkPointingOutsideTree(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
ctx := context.Background()
fs := afero.NewOsFs()
tempDir := t.TempDir()
targetDir := filepath.Join(tempDir, "target")
linkTarget := filepath.Join(tempDir, "outside", "data")
rows, repos := makeFiles(ctx, t, []*database.File{
{Path: "goodlink", LinkTarget: types.FilePath(linkTarget), MTime: time.Unix(0, 0)},
})
v := newContainmentVaultik(ctx, fs)
_, err := v.restoreAllFiles(rows, repos,
&RestoreOptions{TargetDir: targetDir}, nil, nil)
require.NoError(t, err)
got, err := os.Readlink(filepath.Join(targetDir, "goodlink"))
require.NoError(t, err)
require.Equal(t, linkTarget, got)
}