Add snapshot files to list a snapshot's files without restoring it (closes #435)
check / check (push) In progress
check / check (push) In progress
`vaultik snapshot files <snapshot-id> [path] [--json]` downloads and decrypts the snapshot database, as restore does, and prints mode, size, modification time and path for every file at or under the path. A path that matches nothing is an error. It needs the private key. The mode is in Go's file-mode notation, which the README spells out. The table quotes a path that holds a non-printable character. Named `files` because `ls` is already the alias of `snapshot list`. The missing-key error no longer says "for restore", since this command and deep verify return it too. The README opening now states the private-key rule first. Model: opus-5-5
This commit is contained in:
1 parent
5ca0d46932
commit
88f2ea6f06
14 files changed
+552
-82
No files matched your search
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
`vaultik` is an incremental backup tool written in Go. It encrypts data using an
|
`vaultik` is an incremental backup tool written in Go. It encrypts data using an
|
||||||
`age` public key and uploads each encrypted blob directly to a remote
|
`age` public key and uploads each encrypted blob directly to a remote
|
||||||
S3-compatible object store. It requires no private keys, secrets, or credentials
|
S3-compatible object store. The backed-up system never needs the private key. A
|
||||||
(other than those required to PUT to encrypted object storage, such as S3 API
|
backup needs only the public key and the credentials to write to the object
|
||||||
keys) stored on the backed-up system.
|
store, such as S3 API keys.
|
||||||
|
|
||||||
## quickstart
|
## quickstart
|
||||||
|
|
||||||
@@ -171,6 +171,7 @@ vaultik [--config <path>] config get <key>
|
|||||||
vaultik [--config <path>] config set <key> <value>
|
vaultik [--config <path>] config set <key> <value>
|
||||||
vaultik [--config <path>] snapshot create [snapshot-names...] [--cron] [--prune] [--keep-newer-than <duration>]
|
vaultik [--config <path>] snapshot create [snapshot-names...] [--cron] [--prune] [--keep-newer-than <duration>]
|
||||||
vaultik [--config <path>] snapshot list [--json] # alias: ls
|
vaultik [--config <path>] snapshot list [--json] # alias: ls
|
||||||
|
vaultik [--config <path>] snapshot files <snapshot-id> [path] [--json]
|
||||||
vaultik [--config <path>] snapshot verify <snapshot-id> [--deep] [--json]
|
vaultik [--config <path>] snapshot verify <snapshot-id> [--deep] [--json]
|
||||||
vaultik [--config <path>] snapshot purge [--keep-latest | --older-than <duration>] [--snapshot <name>...] [--force]
|
vaultik [--config <path>] snapshot purge [--keep-latest | --older-than <duration>] [--snapshot <name>...] [--force]
|
||||||
vaultik [--config <path>] snapshot remove <snapshot-id> [--dry-run] [--force] [--local-only] [--json] # alias: rm
|
vaultik [--config <path>] snapshot remove <snapshot-id> [--dry-run] [--force] [--local-only] [--json] # alias: rm
|
||||||
@@ -223,12 +224,24 @@ the backup can delete it; the new snapshot then cannot be restored.
|
|||||||
`remote nuke` deletes everything regardless. To not depend on the grace period,
|
`remote nuke` deletes everything regardless. To not depend on the grace period,
|
||||||
give each host or user its own prefix (or directory) on the destination.
|
give each host or user its own prefix (or directory) on the destination.
|
||||||
|
|
||||||
Read-only commands — `info`, `snapshot list`, `snapshot verify`, and
|
These read-only commands do not take the lock, so they are never blocked, even
|
||||||
`remote info` — do not take the lock and are never blocked, so they run even
|
while a backup is in progress:
|
||||||
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
|
- `info`
|
||||||
destination store. `config`, `database delete`, `completion`, and `version` do
|
- `snapshot list`
|
||||||
not take the lock.
|
- `snapshot files`
|
||||||
|
- `snapshot verify`
|
||||||
|
- `remote info`
|
||||||
|
|
||||||
|
`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.
|
||||||
|
|
||||||
|
These commands do not take the lock either:
|
||||||
|
|
||||||
|
- `config`
|
||||||
|
- `database delete`
|
||||||
|
- `completion`
|
||||||
|
- `version`
|
||||||
|
|
||||||
### stdout and stderr
|
### stdout and stderr
|
||||||
|
|
||||||
@@ -261,9 +274,9 @@ work as written.
|
|||||||
### environment variables
|
### environment variables
|
||||||
|
|
||||||
- `VAULTIK_AGE_SECRET_KEY`: Age private key for decryption (required for
|
- `VAULTIK_AGE_SECRET_KEY`: Age private key for decryption (required for
|
||||||
`snapshot restore` and `snapshot verify --deep`). May hold the whole
|
`snapshot restore`, `snapshot files` and `snapshot verify --deep`). May hold
|
||||||
`age-keygen` file — comments and every identity in it are accepted. Set it
|
the whole `age-keygen` file — comments and every identity in it are accepted.
|
||||||
from the file, e.g.
|
Set it from the file, e.g.
|
||||||
`export VAULTIK_AGE_SECRET_KEY="$(cat vaultik_backup_private_key.txt)"`, so
|
`export VAULTIK_AGE_SECRET_KEY="$(cat vaultik_backup_private_key.txt)"`, so
|
||||||
the key is not typed into your shell history.
|
the key is not typed into your shell history.
|
||||||
- `VAULTIK_CONFIG`: Path to config file (overridden by `--config`)
|
- `VAULTIK_CONFIG`: Path to config file (overridden by `--config`)
|
||||||
@@ -408,6 +421,26 @@ point behind.
|
|||||||
truncated listing all go to stderr through the logger, so stdout stays a
|
truncated listing all go to stderr through the logger, so stdout stays a
|
||||||
single parseable document.
|
single parseable document.
|
||||||
|
|
||||||
|
**`snapshot files`**: List the files in a snapshot without restoring it. Each
|
||||||
|
row of the table shows mode, size in bytes, modification time and path, sorted
|
||||||
|
by path.
|
||||||
|
|
||||||
|
- Requires `VAULTIK_AGE_SECRET_KEY`: the file list is only in the snapshot's
|
||||||
|
encrypted database, which the command downloads and decrypts.
|
||||||
|
- Accepts the same identifiers as `snapshot restore`.
|
||||||
|
- An optional path limits the list to that path and what is under it. A path
|
||||||
|
that matches nothing in the snapshot is an error.
|
||||||
|
- `--json`: Output an array of objects with `path`, `size`, `mode` and `mtime`.
|
||||||
|
- In the table, a path that holds a newline or another non-printable character
|
||||||
|
is shown in double quotes, with that character escaped, as in `"a\nb"`.
|
||||||
|
- The mode is in Go's file-mode notation, in the table and in `--json`. Letters
|
||||||
|
before the nine permission characters show the file type and any special bit,
|
||||||
|
as in `dtrwxrwxrwx` for a sticky directory. These letters differ from `ls -l`:
|
||||||
|
- `L`: a symbolic link, which `ls -l` shows as `l`.
|
||||||
|
- `u`: setuid, which `ls -l` shows as `s` in place of the owner's `x`.
|
||||||
|
- `g`: setgid, which `ls -l` shows as `s` in place of the group's `x`.
|
||||||
|
- `t`: sticky, which `ls -l` shows as `t` in place of the others' `x`.
|
||||||
|
|
||||||
**`snapshot verify`**: Verify snapshot integrity.
|
**`snapshot verify`**: Verify snapshot integrity.
|
||||||
|
|
||||||
- Default (shallow): checks that every blob the manifest lists is present in
|
- Default (shallow): checks that every blob the manifest lists is present in
|
||||||
@@ -722,7 +755,7 @@ annotated example also lives in
|
|||||||
| Field | Default | Description |
|
| Field | Default | Description |
|
||||||
| -------------------- | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| -------------------- | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `age_recipients` | (required by `snapshot create`) | Age public keys for encryption. Other commands run without one, so a machine that only restores can leave it empty |
|
| `age_recipients` | (required by `snapshot create`) | Age public keys for encryption. Other commands run without one, so a machine that only restores can leave it empty |
|
||||||
| `age_secret_key` | (unset) | Age private key for decryption (`snapshot restore`, `snapshot verify --deep`). Setting it in the config file places the private key on the backed-up host, defeating the public-key-only design (see "why" above). Prefer the `VAULTIK_AGE_SECRET_KEY` environment variable, supplied only on the machine you restore from. |
|
| `age_secret_key` | (unset) | Age private key for decryption (`snapshot restore`, `snapshot files`, `snapshot verify --deep`). Setting it in the config file places the private key on the backed-up host, defeating the public-key-only design (see "why" above). Prefer the `VAULTIK_AGE_SECRET_KEY` environment variable, supplied only on the machine you restore from. |
|
||||||
| `snapshots` | (required) | Named snapshot definitions with paths and excludes |
|
| `snapshots` | (required) | Named snapshot definitions with paths and excludes |
|
||||||
| `storage_url` | | Storage backend URL (`s3://`, `file://`, `rclone://`) |
|
| `storage_url` | | Storage backend URL (`s3://`, `file://`, `rclone://`) |
|
||||||
| `s3.*` | | Legacy S3 configuration (endpoint, bucket, credentials) |
|
| `s3.*` | | Legacy S3 configuration (endpoint, bucket, credentials) |
|
||||||
@@ -839,15 +872,27 @@ environment variable is unset (https://no-color.org/).
|
|||||||
`database delete`.
|
`database delete`.
|
||||||
- **The data a command exists to produce** is written plain, with no marker and
|
- **The data a command exists to produce** is written plain, with no marker and
|
||||||
no color, because a marker would corrupt a table or a parsed document. This
|
no color, because a marker would corrupt a table or a parsed document. This
|
||||||
covers the `version`, `info`, `remote info` and `snapshot verify` reports, the
|
covers the report or table of each of these commands:
|
||||||
`snapshot list` table, `config get` values, and every `--json` document.
|
- `version`
|
||||||
`--quiet` silences the human reports and tables (`version`, `info`,
|
- `info`
|
||||||
`remote info`, `snapshot verify`, `snapshot list`) but never the
|
- `remote info`
|
||||||
machine-consumed `config get` value or the `--json` documents, which a script
|
- `snapshot verify`
|
||||||
depends on. The confirmation prompts of `database delete`, `prune`,
|
- `snapshot list`
|
||||||
`snapshot purge` and `snapshot remove`, each with what it asks to delete, are
|
- `snapshot files`
|
||||||
also written this way and always shown: they are an interactive exchange the
|
|
||||||
operator must see.
|
`config get` values and every `--json` document are written plain too.
|
||||||
|
`--quiet` silences the reports and tables above, but never a `config get`
|
||||||
|
value or a `--json` document, which a script depends on.
|
||||||
|
|
||||||
|
These commands write their confirmation prompt plain too, with what it asks
|
||||||
|
to delete:
|
||||||
|
- `database delete`
|
||||||
|
- `prune`
|
||||||
|
- `snapshot purge`
|
||||||
|
- `snapshot remove`
|
||||||
|
|
||||||
|
`--quiet` never silences these prompts: each is an interactive exchange the
|
||||||
|
operator must see.
|
||||||
|
|
||||||
`internal/ui` writes to stdout; it is the output the user asked for. The
|
`internal/ui` writes to stdout; it is the output the user asked for. The
|
||||||
exceptions are the startup banner and the error a failed command ends with,
|
exceptions are the startup banner and the error a failed command ends with,
|
||||||
|
|||||||
+17
-9
@@ -265,15 +265,23 @@ so an operator can weigh them rather than discover them.
|
|||||||
blob holds hundreds of chunks, so individual chunk lengths are not visible in
|
blob holds hundreds of chunks, so individual chunk lengths are not visible in
|
||||||
the blob's size; lowering the limit toward the chunk size begins to expose
|
the blob's size; lowering the limit toward the chunk size begins to expose
|
||||||
them.
|
them.
|
||||||
4. **Decrypted data on local disk.** Several commands stage plaintext under
|
4. **Decrypted data on local disk.** These commands stage plaintext under
|
||||||
`$TMPDIR`: `snapshot restore` writes decrypted blobs under
|
`$TMPDIR`:
|
||||||
`vaultik-blobcache-*/` (no size cap) and the decrypted metadata database at
|
- `snapshot restore`: decrypted blobs under `vaultik-blobcache-*/`, with no
|
||||||
`vaultik-restore-*/snapshot.db`; `verify --deep` writes that database at
|
size cap, and the decrypted metadata database at
|
||||||
`vaultik-verify-*/snapshot.db`; `snapshot create` keeps a plaintext copy of
|
`vaultik-restore-*/snapshot.db`.
|
||||||
the index at `vaultik-snapshot-*/snapshot.db`. These files are created `0600`
|
- `snapshot files`: the decrypted metadata database at
|
||||||
and removed on success, but a `kill -9` or a power loss leaves them behind —
|
`vaultik-restore-*/snapshot.db`.
|
||||||
delete any leftover `vaultik-*` directory under `$TMPDIR` by hand. `$TMPDIR`
|
- `verify --deep`: the decrypted metadata database at
|
||||||
should be trusted to the same degree as the restore target.
|
`vaultik-verify-*/snapshot.db`.
|
||||||
|
- `snapshot create`: a plaintext copy of the index at
|
||||||
|
`vaultik-snapshot-*/snapshot.db`.
|
||||||
|
|
||||||
|
These files are created `0600` and removed on success, but a `kill -9` or a
|
||||||
|
power loss leaves them behind — delete any leftover `vaultik-*` directory
|
||||||
|
under `$TMPDIR` by hand. `$TMPDIR` should be trusted to the same degree as
|
||||||
|
the restore target.
|
||||||
|
|
||||||
5. **Store permissions differ per command.** The backed-up host needs only PUT
|
5. **Store permissions differ per command.** The backed-up host needs only PUT
|
||||||
to run `snapshot create`: it writes blobs and metadata and neither reads nor
|
to run `snapshot create`: it writes blobs and metadata and neither reads nor
|
||||||
deletes them. Other commands need more — `snapshot verify`,
|
deletes them. Other commands need more — `snapshot verify`,
|
||||||
|
|||||||
+6
-5
@@ -38,10 +38,11 @@ const (
|
|||||||
// prune, remote nuke) write the local index or the remote store. They
|
// 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.
|
// hold the PID lock so that at most one runs at a time.
|
||||||
mutating lockMode = iota
|
mutating lockMode = iota
|
||||||
// readOnly commands (info, snapshot list, snapshot verify, remote info,
|
// readOnly commands (info, snapshot list, snapshot files, snapshot
|
||||||
// snapshot restore) do not write the local index or the remote store,
|
// verify, remote info, snapshot restore) do not write the local index
|
||||||
// so they run without the lock and are never blocked by a running
|
// or the remote store, so they run without the lock and are never
|
||||||
// mutator. restore writes only to the target directory it is given.
|
// blocked by a running mutator. restore writes only to the target
|
||||||
|
// directory it is given.
|
||||||
readOnly
|
readOnly
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -310,7 +311,7 @@ func RunOperation(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// runVaultikApp runs the standard single-operation command lifecycle
|
// runVaultikApp runs the standard single-operation command lifecycle
|
||||||
// shared by the snapshot list/purge/remove and remote nuke subcommands:
|
// shared by the snapshot list/files/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 on stderr. mode
|
// RunOperation, reporting a failure prefixed with failMsg on stderr. mode
|
||||||
// says whether the command takes the PID lock. jsonOutput marks a command
|
// says whether the command takes the PID lock. jsonOutput marks a command
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package cli //nolint:testpackage // shares runEntry and the backup config helpers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||||
|
)
|
||||||
|
|
||||||
|
// cmdFiles is the word of `snapshot files`, used to build argument
|
||||||
|
// vectors below.
|
||||||
|
const cmdFiles = "files"
|
||||||
|
|
||||||
|
// TestEntrySnapshotFilesListsOnlyThePath backs up two files and runs
|
||||||
|
// `snapshot files` with the snapshot ID and the path of one of them,
|
||||||
|
// which must be the only file listed.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
|
||||||
|
func TestEntrySnapshotFilesListsOnlyThePath(t *testing.T) {
|
||||||
|
configPath, _ := writeBackupConfigWithPrivateKey(t)
|
||||||
|
|
||||||
|
// writeBackupConfig puts file.txt in the source directory beside the
|
||||||
|
// config file.
|
||||||
|
sourceDir := filepath.Join(filepath.Dir(configPath), "source")
|
||||||
|
listed := filepath.Join(sourceDir, "file.txt")
|
||||||
|
|
||||||
|
require.NoError(t, os.WriteFile(
|
||||||
|
filepath.Join(sourceDir, "other.txt"), []byte("other"), 0o600))
|
||||||
|
|
||||||
|
code, _, stderr := runEntry(t,
|
||||||
|
flagConfig, configPath, cmdSnapshot, cmdCreate)
|
||||||
|
require.Equal(t, 0, code, stderr)
|
||||||
|
|
||||||
|
code, stdout, stderr := runEntry(t, flagConfig, configPath, cmdSnapshot,
|
||||||
|
cmdFiles, onlySnapshotID(t, configPath), listed, flagJSON)
|
||||||
|
require.Equal(t, 0, code, stderr)
|
||||||
|
|
||||||
|
var entries []vaultik.ListedFile
|
||||||
|
|
||||||
|
require.NoError(t, json.Unmarshal([]byte(stdout), &entries))
|
||||||
|
require.Len(t, entries, 1, "only the path given may be listed")
|
||||||
|
assert.Equal(t, listed, entries[0].Path)
|
||||||
|
}
|
||||||
@@ -48,7 +48,7 @@ func TestCLIEntry(t *testing.T) {
|
|||||||
} else {
|
} else {
|
||||||
// Check snapshot subcommands
|
// Check snapshot subcommands
|
||||||
expectedSubCommands := []string{
|
expectedSubCommands := []string{
|
||||||
"create", "list", "purge", "verify", "remove", "restore",
|
"create", "list", "files", "purge", "verify", "remove", "restore",
|
||||||
}
|
}
|
||||||
for _, expected := range expectedSubCommands {
|
for _, expected := range expectedSubCommands {
|
||||||
found := false
|
found := false
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ import (
|
|||||||
"sneak.berlin/go/vaultik/internal/vaultik"
|
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// filesMaxArgs is the maximum positional argument count of
|
||||||
|
// `snapshot files <snapshot-id> [path]`.
|
||||||
|
const filesMaxArgs = 2
|
||||||
|
|
||||||
var (
|
var (
|
||||||
errSnapshotIDRequired = errors.New("snapshot ID required")
|
errSnapshotIDRequired = errors.New("snapshot ID required")
|
||||||
errWrongArgCount = errors.New("wrong argument count")
|
errWrongArgCount = errors.New("wrong argument count")
|
||||||
@@ -52,6 +56,7 @@ func NewSnapshotCommand() *cobra.Command {
|
|||||||
// Add subcommands
|
// Add subcommands
|
||||||
cmd.AddCommand(newSnapshotCreateCommand())
|
cmd.AddCommand(newSnapshotCreateCommand())
|
||||||
cmd.AddCommand(newSnapshotListCommand())
|
cmd.AddCommand(newSnapshotListCommand())
|
||||||
|
cmd.AddCommand(newSnapshotFilesCommand())
|
||||||
cmd.AddCommand(newSnapshotPurgeCommand())
|
cmd.AddCommand(newSnapshotPurgeCommand())
|
||||||
cmd.AddCommand(newSnapshotVerifyCommand())
|
cmd.AddCommand(newSnapshotVerifyCommand())
|
||||||
cmd.AddCommand(newSnapshotRemoveCommand())
|
cmd.AddCommand(newSnapshotRemoveCommand())
|
||||||
@@ -172,6 +177,44 @@ func newSnapshotListCommand() *cobra.Command {
|
|||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// newSnapshotFilesCommand creates the 'snapshot files' subcommand
|
||||||
|
func newSnapshotFilesCommand() *cobra.Command {
|
||||||
|
var jsonOutput bool
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "files <snapshot-id> [path]",
|
||||||
|
Short: "List the files in a snapshot",
|
||||||
|
Long: `Lists the path, size, mode and modification time of every file in a
|
||||||
|
snapshot, without restoring it. With a path, lists only that path and
|
||||||
|
what is under it; a path that matches nothing is an error.
|
||||||
|
|
||||||
|
The snapshot may be named by its ID or, on a host with no local index,
|
||||||
|
by the remote key that 'snapshot list' prints for a remote-only snapshot
|
||||||
|
(an unambiguous leading part is enough).
|
||||||
|
|
||||||
|
The file list is only in the snapshot's encrypted database, so this
|
||||||
|
downloads and decrypts it, and requires the age private key in the
|
||||||
|
VAULTIK_AGE_SECRET_KEY environment variable, as 'snapshot restore' does.`,
|
||||||
|
Args: cobra.RangeArgs(1, filesMaxArgs),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
path := ""
|
||||||
|
if len(args) == filesMaxArgs {
|
||||||
|
path = args[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
return runVaultikApp(cmd, readOnly, jsonOutput,
|
||||||
|
"Failed to list snapshot files",
|
||||||
|
func(v *vaultik.Vaultik) error {
|
||||||
|
return v.ListSnapshotFiles(args[0], path, jsonOutput)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().BoolVar(&jsonOutput, "json", false, "Output in JSON format")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
// newSnapshotPurgeCommand creates the 'snapshot purge' subcommand
|
// newSnapshotPurgeCommand creates the 'snapshot purge' subcommand
|
||||||
func newSnapshotPurgeCommand() *cobra.Command {
|
func newSnapshotPurgeCommand() *cobra.Command {
|
||||||
opts := &vaultik.SnapshotPurgeOptions{}
|
opts := &vaultik.SnapshotPurgeOptions{}
|
||||||
|
|||||||
@@ -219,7 +219,8 @@ func retryOpen(ctx context.Context, path string) (*DB, error) {
|
|||||||
|
|
||||||
// errUntrustedSnapshotSchema is returned when a downloaded snapshot
|
// errUntrustedSnapshotSchema is returned when a downloaded snapshot
|
||||||
// database carries schema objects the real schema never defines, or is
|
// database carries schema objects the real schema never defines, or is
|
||||||
// missing a table the restore and deep-verify queries read.
|
// missing a table the restore, deep-verify and `snapshot files` queries
|
||||||
|
// read.
|
||||||
var errUntrustedSnapshotSchema = errors.New(
|
var errUntrustedSnapshotSchema = errors.New(
|
||||||
"downloaded snapshot database has an untrusted schema")
|
"downloaded snapshot database has an untrusted schema")
|
||||||
|
|
||||||
@@ -241,12 +242,12 @@ func snapshotReadOnlyDSN(path string) string {
|
|||||||
|
|
||||||
// OpenReadOnly opens an already-materialized SQLite file for read-only
|
// OpenReadOnly opens an already-materialized SQLite file for read-only
|
||||||
// querying of a snapshot database downloaded from the store, used by
|
// querying of a snapshot database downloaded from the store, used by
|
||||||
// restore and deep verify. Unlike New it never applies schema migrations
|
// restore, deep verify and `snapshot files`. Unlike New it never applies
|
||||||
// and never writes: the connection is opened read-only with query_only
|
// schema migrations and never writes: the connection is opened read-only
|
||||||
// and trusted_schema=OFF. It refuses any file whose schema carries a
|
// with query_only and trusted_schema=OFF. It refuses any file whose schema
|
||||||
// trigger, view or virtual table, or lacks an expected table, so a forged
|
// carries a trigger, view or virtual table, or lacks an expected table, so
|
||||||
// file cannot redefine what the restore queries return. The caller owns
|
// a forged file cannot redefine what the restore queries return. The
|
||||||
// the file and must remove it.
|
// caller owns the file and must remove it.
|
||||||
func OpenReadOnly(ctx context.Context, path string) (*DB, error) {
|
func OpenReadOnly(ctx context.Context, path string) (*DB, error) {
|
||||||
conn, err := sql.Open("sqlite", snapshotReadOnlyDSN(path))
|
conn, err := sql.Open("sqlite", snapshotReadOnlyDSN(path))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -276,9 +277,9 @@ func OpenReadOnly(ctx context.Context, path string) (*DB, error) {
|
|||||||
// the plain table set the real schema defines. Any trigger, view or
|
// the plain table set the real schema defines. Any trigger, view or
|
||||||
// virtual table, or a missing expected table, fails the open.
|
// virtual table, or a missing expected table, fails the open.
|
||||||
func verifySnapshotSchema(ctx context.Context, conn *sql.DB) error {
|
func verifySnapshotSchema(ctx context.Context, conn *sql.DB) error {
|
||||||
// expectedSnapshotTables are the tables the restore and deep-verify
|
// expectedSnapshotTables are the tables the restore, deep-verify and
|
||||||
// queries read. A downloaded database missing any of them is not a
|
// `snapshot files` queries read. A downloaded database missing any of
|
||||||
// genuine snapshot database and is refused.
|
// them is not a genuine snapshot database and is refused.
|
||||||
expectedSnapshotTables := []string{
|
expectedSnapshotTables := []string{
|
||||||
"blob_chunks",
|
"blob_chunks",
|
||||||
"blobs",
|
"blobs",
|
||||||
|
|||||||
@@ -238,10 +238,11 @@ func (r *SnapshotRepository) GetByID(
|
|||||||
|
|
||||||
// GetOnlySnapshot returns the sole snapshot in an exported per-snapshot
|
// GetOnlySnapshot returns the sole snapshot in an exported per-snapshot
|
||||||
// metadata database. The backup path writes each snapshot's database with
|
// metadata database. The backup path writes each snapshot's database with
|
||||||
// exactly one snapshot row (see cleanSnapshotDB), so restore and deep
|
// exactly one snapshot row (see cleanSnapshotDB), so restore, deep verify
|
||||||
// verify expect exactly one. Zero rows return ErrNoSnapshotInDatabase and
|
// and `snapshot files` expect exactly one. Zero rows return
|
||||||
// more than one returns ErrMultipleSnapshotsInDatabase; callers treat
|
// ErrNoSnapshotInDatabase and more than one returns
|
||||||
// either as a failed identity check on the downloaded database.
|
// ErrMultipleSnapshotsInDatabase; callers treat either as a failed
|
||||||
|
// identity check on the downloaded database.
|
||||||
func (r *SnapshotRepository) GetOnlySnapshot(ctx context.Context) (*Snapshot, error) {
|
func (r *SnapshotRepository) GetOnlySnapshot(ctx context.Context) (*Snapshot, error) {
|
||||||
query := `
|
query := `
|
||||||
SELECT id, hostname, vaultik_version, vaultik_git_revision,
|
SELECT id, hostname, vaultik_version, vaultik_git_revision,
|
||||||
|
|||||||
+10
-6
@@ -17,6 +17,9 @@
|
|||||||
// Count, Percent) return ANSI-colored strings the caller composes into
|
// Count, Percent) return ANSI-colored strings the caller composes into
|
||||||
// the message body. When color is disabled (non-TTY output or NO_COLOR
|
// the message body. When color is disabled (non-TTY output or NO_COLOR
|
||||||
// set) all formatters return plain text.
|
// set) all formatters return plain text.
|
||||||
|
//
|
||||||
|
// QuoteUnprintable is the same escaping without colour, for plain tables
|
||||||
|
// that print paths from the snapshot database.
|
||||||
package ui
|
package ui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -227,17 +230,17 @@ func (w *Writer) Hex(s string) string {
|
|||||||
short = s[:hexAbbrevLen] + "..."
|
short = s[:hexAbbrevLen] + "..."
|
||||||
}
|
}
|
||||||
|
|
||||||
return w.paint(ansiCyan, sanitize(short))
|
return w.paint(ansiCyan, QuoteUnprintable(short))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Snapshot colorizes a snapshot ID (full, no abbreviation).
|
// Snapshot colorizes a snapshot ID (full, no abbreviation).
|
||||||
func (w *Writer) Snapshot(id string) string {
|
func (w *Writer) Snapshot(id string) string {
|
||||||
return w.paint(ansiCyan+ansiBold, sanitize(id))
|
return w.paint(ansiCyan+ansiBold, QuoteUnprintable(id))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Path colorizes a filesystem path.
|
// Path colorizes a filesystem path.
|
||||||
func (w *Writer) Path(p string) string {
|
func (w *Writer) Path(p string) string {
|
||||||
return w.paint(ansiBlue, sanitize(p))
|
return w.paint(ansiBlue, QuoteUnprintable(p))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Size colorizes a byte count using humanize.Bytes.
|
// Size colorizes a byte count using humanize.Bytes.
|
||||||
@@ -312,14 +315,15 @@ func (w *Writer) paint(color, s string) string {
|
|||||||
return color + s + ansiReset
|
return color + s + ansiReset
|
||||||
}
|
}
|
||||||
|
|
||||||
// sanitize returns s unchanged when every rune in it is printable, and a
|
// QuoteUnprintable returns s unchanged when every rune in it is printable,
|
||||||
// double-quoted, backslash-escaped form (\n, \x1b, …) otherwise. The
|
// and a double-quoted, backslash-escaped form (\n, \x1b, …) otherwise. The
|
||||||
// string value formatters escape their argument through this before
|
// string value formatters escape their argument through this before
|
||||||
// painting: identifiers, paths and symlink targets they render come from
|
// painting: identifiers, paths and symlink targets they render come from
|
||||||
// the snapshot database, which is not trusted, and escaping must happen
|
// the snapshot database, which is not trusted, and escaping must happen
|
||||||
// before colour is applied — the painted result already contains the
|
// before colour is applied — the painted result already contains the
|
||||||
// escape codes the raw text would otherwise be indistinguishable from.
|
// escape codes the raw text would otherwise be indistinguishable from.
|
||||||
func sanitize(s string) string {
|
// Plain tables call it directly.
|
||||||
|
func QuoteUnprintable(s string) string {
|
||||||
for _, r := range s {
|
for _, r := range s {
|
||||||
if !unicode.IsPrint(r) {
|
if !unicode.IsPrint(r) {
|
||||||
return strconv.Quote(s)
|
return strconv.Quote(s)
|
||||||
|
|||||||
@@ -160,7 +160,8 @@ func (v *Vaultik) FetchBlob(
|
|||||||
// Not every backend's reader stops on cancel by itself (the file
|
// Not every backend's reader stops on cancel by itself (the file
|
||||||
// backend's does not), and a read stalled on a slow or hung source would
|
// backend's does not), and a read stalled on a slow or hung source would
|
||||||
// otherwise outlast the shutdown timeout, so the process would exit
|
// otherwise outlast the shutdown timeout, so the process would exit
|
||||||
// before restore or deep verify removed its decrypted temporary files.
|
// before restore, deep verify or `snapshot files` removed its decrypted
|
||||||
|
// temporary files.
|
||||||
type closeOnCancelReader struct {
|
type closeOnCancelReader struct {
|
||||||
io.ReadCloser
|
io.ReadCloser
|
||||||
|
|
||||||
|
|||||||
+29
-23
@@ -31,7 +31,7 @@ var (
|
|||||||
errPathsNotInSnapshot = errors.New("requested path(s) not in the snapshot")
|
errPathsNotInSnapshot = errors.New("requested path(s) not in the snapshot")
|
||||||
errFilesFailedVerify = errors.New("files failed verification")
|
errFilesFailedVerify = errors.New("files failed verification")
|
||||||
errDecryptionKeyRequired = errors.New(
|
errDecryptionKeyRequired = errors.New(
|
||||||
"decryption key required for restore\n\n" +
|
"decryption key required\n\n" +
|
||||||
"Set the VAULTIK_AGE_SECRET_KEY environment variable to your " +
|
"Set the VAULTIK_AGE_SECRET_KEY environment variable to your " +
|
||||||
"age private key file:\n" +
|
"age private key file:\n" +
|
||||||
" export VAULTIK_AGE_SECRET_KEY=\"$(cat vaultik_backup_private_key.txt)\"")
|
" export VAULTIK_AGE_SECRET_KEY=\"$(cat vaultik_backup_private_key.txt)\"")
|
||||||
@@ -153,18 +153,7 @@ func (v *Vaultik) Restore(opts *RestoreOptions) error {
|
|||||||
return fmt.Errorf("downloading snapshot database: %w", err)
|
return fmt.Errorf("downloading snapshot database: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() {
|
defer v.closeSnapshotDB(tempDB, tempDir)
|
||||||
err := tempDB.Close()
|
|
||||||
if err != nil {
|
|
||||||
log.Debug("Failed to close temp database", "error", err)
|
|
||||||
}
|
|
||||||
// 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 directory", "error", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
repos := database.NewRepositories(tempDB)
|
repos := database.NewRepositories(tempDB)
|
||||||
|
|
||||||
@@ -325,9 +314,10 @@ func (v *Vaultik) reportUnmatchedPaths(unmatched []string) error {
|
|||||||
// identity it contains. The value may be a single key line or a whole
|
// identity it contains. The value may be a single key line or a whole
|
||||||
// age-keygen file with several identities; all of them are returned so
|
// age-keygen file with several identities; all of them are returned so
|
||||||
// blobgen (via age.Decrypt) can read a blob encrypted to any of their
|
// blobgen (via age.Decrypt) can read a blob encrypted to any of their
|
||||||
// recipients. This is the first step of both restore and deep verify, so
|
// recipients. This is the first step of restore, deep verify and
|
||||||
// a missing or unparseable key fails before anything is downloaded. The
|
// `snapshot files`, so a missing or unparseable key fails before anything
|
||||||
// error names the configuration source but never the key value.
|
// is downloaded. The error names the configuration source but never the
|
||||||
|
// key value.
|
||||||
func (v *Vaultik) restoreIdentities() ([]age.Identity, error) {
|
func (v *Vaultik) restoreIdentities() ([]age.Identity, error) {
|
||||||
if v.Config.AgeSecretKey == "" {
|
if v.Config.AgeSecretKey == "" {
|
||||||
return nil, errDecryptionKeyRequired
|
return nil, errDecryptionKeyRequired
|
||||||
@@ -880,18 +870,34 @@ func (v *Vaultik) downloadSnapshotDB(
|
|||||||
return db, tempDir, nil
|
return db, tempDir, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// closeSnapshotDB closes a database returned by downloadSnapshotDB and
|
||||||
|
// removes its private directory, so the decrypted database and any SQLite
|
||||||
|
// side files it produced are gone.
|
||||||
|
func (v *Vaultik) closeSnapshotDB(db *database.DB, tempDir string) {
|
||||||
|
err := db.Close()
|
||||||
|
if err != nil {
|
||||||
|
log.Debug("Failed to close temp database", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = v.Fs.RemoveAll(tempDir)
|
||||||
|
if err != nil {
|
||||||
|
log.Debug("Failed to remove temp database directory", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// verifySnapshotDBIdentity confirms the decrypted metadata database really
|
// verifySnapshotDBIdentity confirms the decrypted metadata database really
|
||||||
// is the snapshot named by remoteKey. age decryption proves the database
|
// is the snapshot named by remoteKey. age decryption proves the database
|
||||||
// is readable, not that the object served at
|
// is readable, not that the object served at
|
||||||
// metadata/<remoteKey>/db.zst.age is the snapshot that was requested: an
|
// metadata/<remoteKey>/db.zst.age is the snapshot that was requested: an
|
||||||
// attacker who swaps in another valid db.zst.age (which needs no key
|
// attacker who swaps in another valid db.zst.age (which needs no key
|
||||||
// material) would otherwise redirect restore and deep verify to a
|
// material) would otherwise redirect restore, deep verify and
|
||||||
// different snapshot's contents. The exported per-snapshot database holds
|
// `snapshot files` to a different snapshot's contents. The exported
|
||||||
// exactly one snapshot row, and a snapshot's remote key is derived from
|
// per-snapshot database holds exactly one snapshot row, and a snapshot's
|
||||||
// that row's ID, so the database is the requested one exactly when its
|
// remote key is derived from that row's ID, so the database is the
|
||||||
// sole snapshot hashes back to remoteKey. Comparing the requested
|
// requested one exactly when its sole snapshot hashes back to remoteKey.
|
||||||
// identifier directly would not do: it may be a remote-key prefix a
|
// Comparing the requested identifier directly would not do: it may be a
|
||||||
// recovery host uses in place of a human snapshot ID it cannot know.
|
// remote-key prefix a recovery host uses in place of a human snapshot ID
|
||||||
|
// it cannot know.
|
||||||
func (v *Vaultik) verifySnapshotDBIdentity(
|
func (v *Vaultik) verifySnapshotDBIdentity(
|
||||||
db *database.DB, requested, remoteKey string,
|
db *database.DB, requested, remoteKey string,
|
||||||
) error {
|
) error {
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package vaultik
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"text/tabwriter"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/vaultik/internal/database"
|
||||||
|
"sneak.berlin/go/vaultik/internal/ui"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ListedFile is one entry printed by `snapshot files`. Mode is the Go
|
||||||
|
// file mode string, which shows the entry type and the permission bits
|
||||||
|
// (for example -rw-r--r-- or drwxr-xr-x).
|
||||||
|
type ListedFile struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
MTime time.Time `json:"mtime"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListSnapshotFiles prints the path, size, mode and modification time of
|
||||||
|
// every file the snapshot holds at or under path, or of every file when
|
||||||
|
// path is empty. The file list is only in the snapshot's encrypted
|
||||||
|
// database, so this downloads and decrypts it as restore does, and needs
|
||||||
|
// the private key.
|
||||||
|
func (v *Vaultik) ListSnapshotFiles(snapshotID, path string, jsonOutput bool) error {
|
||||||
|
identities, err := v.restoreIdentities()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
remoteKey, err := v.resolveSnapshotRemoteKey(snapshotID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
db, tempDir, err := v.downloadSnapshotDB(snapshotID, remoteKey, identities)
|
||||||
|
if errors.Is(err, errWrongPrivateKey) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("downloading snapshot database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer v.closeSnapshotDB(db, tempDir)
|
||||||
|
|
||||||
|
repos := database.NewRepositories(db)
|
||||||
|
|
||||||
|
var files []*database.File
|
||||||
|
|
||||||
|
if path == "" {
|
||||||
|
files, err = repos.Files.ListAll(v.ctx)
|
||||||
|
} else {
|
||||||
|
files, err = repos.Files.ListUnderPath(v.ctx, filepath.Clean(path))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("listing files: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if path != "" && len(files) == 0 {
|
||||||
|
return fmt.Errorf("%w: %s", errPathsNotInSnapshot, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
entries := make([]ListedFile, 0, len(files))
|
||||||
|
|
||||||
|
for _, file := range files {
|
||||||
|
entries = append(entries, ListedFile{
|
||||||
|
Path: file.Path.String(),
|
||||||
|
Size: file.Size,
|
||||||
|
Mode: os.FileMode(file.Mode).String(),
|
||||||
|
MTime: file.MTime,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if jsonOutput {
|
||||||
|
encoder := json.NewEncoder(v.Stdout)
|
||||||
|
encoder.SetIndent("", " ")
|
||||||
|
|
||||||
|
return encoder.Encode(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
if v.UI.Quiet() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return v.printListedFileTable(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
// printListedFileTable writes the entries as a plain table to stdout.
|
||||||
|
func (v *Vaultik) printListedFileTable(entries []ListedFile) error {
|
||||||
|
w := tabwriter.NewWriter(v.Stdout, 0, 0, tabPadding, ' ', 0)
|
||||||
|
|
||||||
|
_, err := fmt.Fprintln(w, "MODE\tSIZE\tMODIFIED\tPATH")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
_, err = fmt.Fprintf(w, "%s\t%d\t%s\t%s\n",
|
||||||
|
entry.Mode, entry.Size,
|
||||||
|
entry.MTime.Format(time.RFC3339), ui.QuoteUnprintable(entry.Path))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return w.Flush()
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
package vaultik_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/vaultik/internal/database"
|
||||||
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
|
"sneak.berlin/go/vaultik/internal/storage"
|
||||||
|
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||||
|
)
|
||||||
|
|
||||||
|
// These tests cover https://git.eeqj.de/sneak/vaultik/issues/435:
|
||||||
|
// `snapshot files` lists what a snapshot holds without restoring it.
|
||||||
|
|
||||||
|
// unprintableFileName is a file name holding a newline and an escape
|
||||||
|
// sequence, which the table must not print raw.
|
||||||
|
const unprintableFileName = "odd\nname\x1b[31m"
|
||||||
|
|
||||||
|
// backUpForFileListing backs up the fault source tree, plus a file named
|
||||||
|
// unprintableFileName at its top, and returns a reader whose stdout is
|
||||||
|
// out, the snapshot ID, and the source directory.
|
||||||
|
func backUpForFileListing(
|
||||||
|
t *testing.T, out *bytes.Buffer,
|
||||||
|
) (*vaultik.Vaultik, string, string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
fs := afero.NewOsFs()
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
dataDir := filepath.Join(tempDir, "src")
|
||||||
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
cfg := faultTestConfig()
|
||||||
|
|
||||||
|
writeFaultSourceTree(t, fs, dataDir)
|
||||||
|
require.NoError(t, afero.WriteFile(fs,
|
||||||
|
filepath.Join(dataDir, unprintableFileName), []byte("odd"), 0o644))
|
||||||
|
|
||||||
|
store, err := storage.NewFileStorer(t.TempDir())
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
db, err := database.New(ctx, dbPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
id := fullFaultBackup(ctx, t, fs, store, cfg,
|
||||||
|
database.NewRepositories(db), dataDir, dbPath, "files")
|
||||||
|
require.NoError(t, db.Close())
|
||||||
|
|
||||||
|
v := newReaderVaultik(ctx, cfg, store, nil, fs)
|
||||||
|
v.Stdout = out
|
||||||
|
|
||||||
|
return v, id, dataDir
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSnapshotFilesJSONListsEveryFileWithItsMetadata(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
|
||||||
|
v, id, dataDir := backUpForFileListing(t, &out)
|
||||||
|
|
||||||
|
require.NoError(t, v.ListSnapshotFiles(id, "", true))
|
||||||
|
|
||||||
|
var entries []vaultik.ListedFile
|
||||||
|
|
||||||
|
require.NoError(t, json.Unmarshal(out.Bytes(), &entries))
|
||||||
|
|
||||||
|
byPath := make(map[string]vaultik.ListedFile, len(entries))
|
||||||
|
for _, entry := range entries {
|
||||||
|
byPath[entry.Path] = entry
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, rel := range []string{"small.txt", "a.bin", "sub", "sub/b.bin", "sub/c.bin"} {
|
||||||
|
path := filepath.Join(dataDir, rel)
|
||||||
|
|
||||||
|
entry, ok := byPath[path]
|
||||||
|
require.True(t, ok, "%s must be listed", path)
|
||||||
|
|
||||||
|
info, err := afero.NewOsFs().Stat(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, info.Mode().String(), entry.Mode, path)
|
||||||
|
assert.True(t, info.ModTime().Equal(entry.MTime),
|
||||||
|
"%s: mtime %v, listed %v", path, info.ModTime(), entry.MTime)
|
||||||
|
|
||||||
|
if !info.IsDir() {
|
||||||
|
assert.Equal(t, info.Size(), entry.Size, path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSnapshotFilesListsOnlyWhatIsUnderThePath(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
|
||||||
|
v, id, dataDir := backUpForFileListing(t, &out)
|
||||||
|
|
||||||
|
require.NoError(t, v.ListSnapshotFiles(id, filepath.Join(dataDir, "sub"), false))
|
||||||
|
|
||||||
|
text := out.String()
|
||||||
|
assert.Contains(t, text, filepath.Join(dataDir, "sub", "b.bin"))
|
||||||
|
assert.Contains(t, text, filepath.Join(dataDir, "sub", "c.bin"))
|
||||||
|
assert.NotContains(t, text, filepath.Join(dataDir, "small.txt"))
|
||||||
|
assert.True(t, strings.HasPrefix(text, "MODE"), "the table must have a header")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSnapshotFilesTableEscapesAnUnprintablePath checks that the table
|
||||||
|
// prints a path holding a newline and an escape sequence quoted and
|
||||||
|
// escaped on one row: the newline must not start a forged row, and the
|
||||||
|
// escape sequence must not reach the terminal.
|
||||||
|
func TestSnapshotFilesTableEscapesAnUnprintablePath(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
|
||||||
|
v, id, dataDir := backUpForFileListing(t, &out)
|
||||||
|
|
||||||
|
require.NoError(t, v.ListSnapshotFiles(id, "", false))
|
||||||
|
|
||||||
|
text := out.String()
|
||||||
|
assert.Contains(t, text,
|
||||||
|
strconv.Quote(filepath.Join(dataDir, unprintableFileName)))
|
||||||
|
assert.NotContains(t, text, "\x1b", "no escape byte may reach the output")
|
||||||
|
|
||||||
|
for line := range strings.SplitSeq(text, "\n") {
|
||||||
|
assert.False(t, strings.HasPrefix(line, "name"),
|
||||||
|
"the newline in the name must not start a row: %q", line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSnapshotFilesQuietSuppressesTableNotJSON is the --quiet contract
|
||||||
|
// for `snapshot files`: the table is silenced, but the --json document a
|
||||||
|
// script depends on is still written.
|
||||||
|
func TestSnapshotFilesQuietSuppressesTableNotJSON(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
|
||||||
|
v, id, _ := backUpForFileListing(t, &out)
|
||||||
|
v.UI.SetQuiet(true)
|
||||||
|
|
||||||
|
require.NoError(t, v.ListSnapshotFiles(id, "", false))
|
||||||
|
require.Empty(t, out.String(), "the table must be silenced under --quiet")
|
||||||
|
|
||||||
|
require.NoError(t, v.ListSnapshotFiles(id, "", true))
|
||||||
|
|
||||||
|
var entries []vaultik.ListedFile
|
||||||
|
|
||||||
|
require.NoError(t, json.Unmarshal(out.Bytes(), &entries))
|
||||||
|
assert.NotEmpty(t, entries,
|
||||||
|
"the --json document must still be written under --quiet")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSnapshotFilesFailsOnAPathThatMatchesNothing(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
|
||||||
|
v, id, dataDir := backUpForFileListing(t, &out)
|
||||||
|
missing := filepath.Join(dataDir, "typo.txt")
|
||||||
|
|
||||||
|
err := v.ListSnapshotFiles(id, missing, false)
|
||||||
|
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), missing, "the error must name the path")
|
||||||
|
assert.Empty(t, out.String(), "nothing must be printed")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSnapshotFilesRequiresThePrivateKey(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
|
||||||
|
v, id, _ := backUpForFileListing(t, &out)
|
||||||
|
v.Config.AgeSecretKey = ""
|
||||||
|
|
||||||
|
err := v.ListSnapshotFiles(id, "", false)
|
||||||
|
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "VAULTIK_AGE_SECRET_KEY")
|
||||||
|
}
|
||||||
@@ -39,7 +39,8 @@ const maxRemoteOnlyRows = 1000
|
|||||||
// manifest reads need throttling.
|
// manifest reads need throttling.
|
||||||
const remoteManifestFetchConcurrency = 8
|
const remoteManifestFetchConcurrency = 8
|
||||||
|
|
||||||
// tabPadding is the tabwriter cell padding for the snapshot table.
|
// tabPadding is the tabwriter cell padding for the `snapshot list` and
|
||||||
|
// `snapshot files` tables.
|
||||||
const tabPadding = 3
|
const tabPadding = 3
|
||||||
|
|
||||||
// ListSnapshots prints the table of snapshots known to this host: the
|
// ListSnapshots prints the table of snapshots known to this host: the
|
||||||
|
|||||||
Reference in new issue
Block a user