Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
efdb5eeb2b | ||
|
|
3abe9cbd9e | ||
|
|
76a6917a35 |
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user