Author SHA1 Message Date
sneak a9e73cf5aa Count a file as deleted only if the previous snapshot of its name had it (closes #304)
check / check (push) Waiting to run
A backup counted as deleted every file in the local index under the
source path that the walk did not find. That took in files the
snapshot excludes but another snapshot backs up, and files removed long
ago, which every later backup, and the first backup of another name
over the same path, reported as deleted again.

The backup now counts only the files that the most recent completed
snapshot of the same name lists, the walk did not find, and are gone
from disk. A file that is excluded, or inside an excluded directory, is
not counted, nor is one the walk could not reach under --skip-errors.

The snapshots row stores no deleted figure; only the backup's output
changes.

Model: opus-5-5
2026-10-13 21:05:41 +00:00
clawbot 37d525c322 Make remote nuke delete every object under metadata/ and blobs/ (closes #291)
check / check (push) Waiting to run
remote nuke deleted only the snapshot metadata and blobs the snapshot
and blob listings recognise, so a snapshot's database whose manifest
was never uploaded, and any name under blobs/ that is not a blob hash,
survived while nuke reported the store empty.

After removing every snapshot, nuke now deletes every object listed
under metadata/ and blobs/, whatever its name, in place of the blob
prune step. A delete that fails is logged and the others still run. A
listing afterwards decides the result: nuke reports the store empty
only when it finds nothing, and otherwise names each object left and
fails.

Judgement call: nuke no longer prints the blob prune summary.

Model: opus-5-5
2026-10-13 22:50:09 +02:00
clawbot f89fac34f7 Make config set create a list for an index under a missing key (closes #308)
check / check (push) Waiting to run
`config set age_recipients.0 age1...` on a file without `age_recipients`
wrote a map keyed `0`, after which no command could load the config. A
missing key is now created as a list when the next path element is a
number.

Before writing, `config set` checks the result with the config loader,
refusing only a failure the change brings in: a config that loaded must
still load; one that does not (still being filled in, or with a `${...}`
reference that cannot be filled in here) is checked on the changed
setting alone. The new `config.Parse`, split out of `config.Load`,
decodes config data, filling in references, without validating.

Rule suppressed: gosec G304 where `config.Load` and the new tests read a
file.

Model: opus-5-5
2026-10-13 22:43:41 +02:00
clawbot 5d8c515192 List a file:// destination through a symlinked directory (closes #296)
check / check (push) Waiting to run
List and ListStream walked the destination with afero.Walk, which uses
Lstat and so saw a symlinked metadata/ or blobs/ as one file, while Put,
Get and Stat follow the link. The store then listed as empty, and prune
dropped the local snapshot records and deleted every blob. The listings,
and DeletePartialUploads (used by remote nuke), now walk a view without
Lstat, so afero falls back to Stat and enters the link. A prefix lists
as empty only when truly missing; a missing symlink target on it or
above it, such as an unmounted volume, fails the listing.

Judgement call: a symlink loop is not detected; it is followed until the
system refuses the path, and the listing fails.

Model: opus-5-5
2026-10-13 21:36:23 +02:00
17 changed files with 1379 additions and 141 deletions
+4 -4
View File
@@ -279,8 +279,8 @@ Key methods:
- `Scan(ctx, path, snapshotID)` → Main scan operation - `Scan(ctx, path, snapshotID)` → Main scan operation
Scan phases: Scan phases:
1. **Phase 0**: Detect deleted files from previous snapshots 1. **Phase 1**: Walk directory, identify files needing processing
2. **Phase 1**: Walk directory, identify files needing processing 2. **Phase 1b**: Count as deleted the files that the previous snapshot of the same name lists, the walk did not find, and are gone from disk; a file that is excluded, or inside an excluded directory, is not counted
3. **Phase 2**: Process files (chunk → pack → upload) 3. **Phase 2**: Process files (chunk → pack → upload)
#### SnapshotManager #### SnapshotManager
@@ -320,13 +320,13 @@ CreateSnapshot(opts)
│ │ │ │
│ ├─► scanner.Scan(ctx, path, snapshotID) │ ├─► scanner.Scan(ctx, path, snapshotID)
│ │ │ │ │ │
│ │ ├─► Phase 0: detectDeletedFiles()
│ │ │
│ │ ├─► Phase 1: scanPhase() │ │ ├─► Phase 1: scanPhase()
│ │ │ Walk directory │ │ │ Walk directory
│ │ │ Check file metadata changes │ │ │ Check file metadata changes
│ │ │ Build list of files to process │ │ │ Build list of files to process
│ │ │ │ │ │
│ │ ├─► Phase 1b: detectDeletedFiles()
│ │ │
│ │ └─► Phase 2: processPhase() │ │ └─► Phase 2: processPhase()
│ │ For each file: │ │ For each file:
│ │ chunker.ChunkReaderStreaming() │ │ chunker.ChunkReaderStreaming()
+17 -7
View File
@@ -271,8 +271,17 @@ existing file. Created with mode `0600` since it will contain credentials.
**`config set`**: Set a scalar config value by dotted YAML path **`config set`**: Set a scalar config value by dotted YAML path
(e.g. `vaultik config set compression_level 9`, (e.g. `vaultik config set compression_level 9`,
`vaultik config set storage_url "file:///mnt/backups"`). Comments and `vaultik config set storage_url "file:///mnt/backups"`). Comments and
formatting in the file are preserved; intermediate maps are created as formatting in the file are preserved. Missing keys along the path are
needed. created: as a list when the next path element is a number (so
`vaultik config set age_recipients.0 age1...` works on a file without
`age_recipients`), otherwise as a map. If the config loads, a change
after which it would not, such as `vaultik config set compression_level 99`,
is refused and the file is left unchanged. A config that does not load,
such as the one `config init` writes before `storage_url` is set or one
with a `${ENV:...}` reference to a variable not set in this shell, is
checked one setting at a time: only a change after which the changed
setting cannot be read, such as one giving it the wrong type (a single
value where a list belongs or a word where a number belongs), is refused.
**`snapshot create`**: Perform incremental backup of configured snapshots. **`snapshot create`**: Perform incremental backup of configured snapshots.
* Optional snapshot names argument to create specific snapshots (default: all) * Optional snapshot names argument to create specific snapshots (default: all)
@@ -413,11 +422,12 @@ under `blobs/` whose name is not a blob hash is not counted as a blob,
since `prune` never deletes it. since `prune` never deletes it.
* `--json`: Output as JSON * `--json`: Output as JSON
**`remote nuke`**: Delete every snapshot's metadata and every blob from the **`remote nuke`**: Delete every object under `metadata/` and `blobs/` on
backup destination store, leaving the bucket prefix empty. Destructive and the backup destination store, whatever its name, leaving the bucket prefix
irreversible. This is the single supported way to wipe the entire empty. Destructive and irreversible. This is the single supported way to
destination store. If anything cannot be deleted, the command exits wipe the entire destination store. If a listing afterwards still finds
non-zero; running it again deletes what is left. anything there, the command names each object left and exits non-zero;
running it again deletes what is left.
* `--force`: Required to confirm destruction. * `--force`: Required to confirm destruction.
**`database delete`**: Delete the local SQLite state database file **`database delete`**: Delete the local SQLite state database file
+120 -25
View File
@@ -8,6 +8,7 @@ import (
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"reflect" "reflect"
"slices"
"strconv" "strconv"
"strings" "strings"
"unicode/utf8" "unicode/utf8"
@@ -44,6 +45,9 @@ var (
errNeedNumericIndex = errors.New("key is a list; use a numeric index") errNeedNumericIndex = errors.New("key is a list; use a numeric index")
errIndexOutOfRange = errors.New("index out of range") errIndexOutOfRange = errors.New("index out of range")
errNotMapOrList = errors.New("key is not a map or list") errNotMapOrList = errors.New("key is not a map or list")
errWouldNotLoad = errors.New(
"vaultik would not load the config after this change, " +
"so the file was not changed")
) )
const defaultConfigTemplate = `# vaultik configuration const defaultConfigTemplate = `# vaultik configuration
@@ -391,8 +395,17 @@ func newConfigSetCommand() *cobra.Command {
Use: "set <key> <value>", Use: "set <key> <value>",
Short: "Set a config value by dotted path (e.g. compression_level 5)", Short: "Set a config value by dotted path (e.g. compression_level 5)",
Long: `Sets a scalar config value addressed by dotted YAML path and writes Long: `Sets a scalar config value addressed by dotted YAML path and writes
the file back, preserving comments and formatting. Intermediate maps the file back, preserving comments and formatting. Missing keys along
are created as needed. the path are created: as a list when the next path element is a
number, otherwise as a map.
If the config loads, a change after which it would not, such as
compression_level 99, is refused and the file is left unchanged. A
config that does not load, such as the one config init writes before
storage_url is set or one with a ${ENV:...} reference to a variable
not set here, is checked one setting at a time: only a change after
which the changed setting cannot be read, such as one giving it the
wrong type, is refused.
Examples: Examples:
vaultik config set storage_url "file:///mnt/backups" vaultik config set storage_url "file:///mnt/backups"
@@ -422,7 +435,14 @@ func writeConfigSet(out *ui.Writer, path, key, value string) error {
return err return err
} }
err = yamlPathSet(root, strings.Split(key, "."), value) oldData, err := marshalConfigYAML(root)
if err != nil {
return fmt.Errorf("marshaling config: %w", err)
}
keys := strings.Split(key, ".")
err = yamlPathSet(root, keys, value)
if err != nil { if err != nil {
return err return err
} }
@@ -432,6 +452,21 @@ func writeConfigSet(out *ui.Writer, path, key, value string) error {
return fmt.Errorf("marshaling config: %w", err) return fmt.Errorf("marshaling config: %w", err)
} }
setting, err := yamlPathOnly(root, keys)
if err != nil {
return err
}
settingData, err := marshalConfigYAML(setting)
if err != nil {
return fmt.Errorf("marshaling config: %w", err)
}
err = checkConfigChange(oldData, data, settingData)
if err != nil {
return fmt.Errorf("%w: %w", errWouldNotLoad, err)
}
err = os.WriteFile(path, data, configFileMode) err = os.WriteFile(path, data, configFileMode)
if err != nil { if err != nil {
return fmt.Errorf("writing config file: %w", err) return fmt.Errorf("writing config file: %w", err)
@@ -453,6 +488,58 @@ func writeConfigSet(out *ui.Writer, path, key, value string) error {
return nil return nil
} }
// checkConfigChange returns the reason vaultik would not load newData, the
// config after a change, if the change brings that failure in. If oldData,
// the config before it, loads, the new one must load too. A config that
// does not load, such as one still being filled in or one with a ${...}
// reference that cannot be filled in here, is checked one setting at a
// time: settingData, the changed setting on its own, must decode into the
// config type.
func checkConfigChange(oldData, newData, settingData []byte) error {
oldCfg, err := config.Parse(oldData)
if err == nil && oldCfg.Validate() == nil {
newCfg, err := config.Parse(newData)
if err != nil {
return err
}
return newCfg.Validate()
}
_, err = config.Parse(settingData)
return err
}
// yamlPathOnly returns a document holding only the value at the dotted key
// path in root, inside the maps and lists that lead to it there.
func yamlPathOnly(root *yaml.Node, keys []string) (*yaml.Node, error) {
node, err := yamlPathGet(root, keys)
if err != nil {
return nil, err
}
for i, key := range slices.Backward(keys) {
parent, err := yamlPathGet(root, keys[:i])
if err != nil {
return nil, err
}
if parent.Kind == yaml.SequenceNode {
node = &yaml.Node{
Kind: yaml.SequenceNode, Content: []*yaml.Node{node},
}
} else {
keyNode := &yaml.Node{Kind: yaml.ScalarNode, Value: key}
node = &yaml.Node{
Kind: yaml.MappingNode, Content: []*yaml.Node{keyNode, node},
}
}
}
return &yaml.Node{Kind: yaml.DocumentNode, Content: []*yaml.Node{node}}, nil
}
// marshalConfigYAML renders a config document tree with 2-space indentation, // marshalConfigYAML renders a config document tree with 2-space indentation,
// matching defaultConfigTemplate. yaml.Marshal defaults to 4 spaces, which // matching defaultConfigTemplate. yaml.Marshal defaults to 4 spaces, which
// would reindent the whole file on the first `config set` despite the promise // would reindent the whole file on the first `config set` despite the promise
@@ -559,8 +646,8 @@ func yamlPathGet(root *yaml.Node, keys []string) (*yaml.Node, error) {
return node, nil return node, nil
} }
// yamlPathSet navigates a dotted key path, creating intermediate maps as // yamlPathSet navigates a dotted key path, creating missing keys as needed,
// needed, and sets the final key to the given scalar value. Numeric path // and sets the final key to the given scalar value. Numeric path
// components index into sequences; an index equal to the sequence length // components index into sequences; an index equal to the sequence length
// appends a new element (e.g. "age_recipients.1" on a 1-element list). // appends a new element (e.g. "age_recipients.1" on a 1-element list).
func yamlPathSet(root *yaml.Node, keys []string, value string) error { func yamlPathSet(root *yaml.Node, keys []string, value string) error {
@@ -573,12 +660,12 @@ func yamlPathSet(root *yaml.Node, keys []string, value string) error {
node = node.Content[0] node = node.Content[0]
} }
for i, key := range keys { for i := range keys {
last := i == len(keys)-1 last := i == len(keys)-1
switch node.Kind { switch node.Kind {
case yaml.MappingNode: case yaml.MappingNode:
node = yamlSetInMapping(node, key, value, last) node = yamlSetInMapping(node, keys, i, value, last)
case yaml.SequenceNode: case yaml.SequenceNode:
next, err := yamlSetInSequence(node, keys, i, value, last) next, err := yamlSetInSequence(node, keys, i, value, last)
if err != nil { if err != nil {
@@ -647,14 +734,16 @@ func yamlField(typ reflect.Type, key string) (reflect.StructField, bool) {
return reflect.StructField{}, false return reflect.StructField{}, false
} }
// yamlSetInMapping resolves (creating if needed) the value node for key // yamlSetInMapping resolves (creating if needed) the value node for the
// within a mapping node, setting it to value when it is the final path // path element keys[i] within a mapping node, setting it to value when it
// element, and returns the node to descend into. // is the final path element, and returns the node to descend into.
func yamlSetInMapping(node *yaml.Node, key, value string, last bool) *yaml.Node { func yamlSetInMapping(
node *yaml.Node, keys []string, i int, value string, last bool,
) *yaml.Node {
var valueNode *yaml.Node var valueNode *yaml.Node
for j := 0; j+1 < len(node.Content); j += 2 { for j := 0; j+1 < len(node.Content); j += 2 {
if node.Content[j].Value == key { if node.Content[j].Value == keys[i] {
valueNode = node.Content[j+1] valueNode = node.Content[j+1]
break break
@@ -662,13 +751,8 @@ func yamlSetInMapping(node *yaml.Node, key, value string, last bool) *yaml.Node
} }
if valueNode == nil { if valueNode == nil {
keyNode := &yaml.Node{Kind: yaml.ScalarNode, Value: key} keyNode := &yaml.Node{Kind: yaml.ScalarNode, Value: keys[i]}
valueNode = newPathNode(keys, i, value)
valueNode = &yaml.Node{Kind: yaml.MappingNode}
if last {
valueNode = &yaml.Node{Kind: yaml.ScalarNode, Value: value}
}
node.Content = append(node.Content, keyNode, valueNode) node.Content = append(node.Content, keyNode, valueNode)
} else if last { } else if last {
setScalar(valueNode, value) setScalar(valueNode, value)
@@ -696,12 +780,7 @@ func yamlSetInSequence(
} }
if idx == len(node.Content) { if idx == len(node.Content) {
newNode := &yaml.Node{Kind: yaml.MappingNode} node.Content = append(node.Content, newPathNode(keys, i, value))
if last {
newNode = &yaml.Node{Kind: yaml.ScalarNode, Value: value}
}
node.Content = append(node.Content, newNode)
} else if last { } else if last {
setScalar(node.Content[idx], value) setScalar(node.Content[idx], value)
} }
@@ -709,6 +788,22 @@ func yamlSetInSequence(
return node.Content[idx], nil return node.Content[idx], nil
} }
// newPathNode returns the node to create for the missing path element
// keys[i]: the value when it is the final path element, otherwise an empty
// list when the next path element is a number and an empty map when not.
func newPathNode(keys []string, i int, value string) *yaml.Node {
if i == len(keys)-1 {
return &yaml.Node{Kind: yaml.ScalarNode, Value: value}
}
_, err := strconv.Atoi(keys[i+1])
if err == nil {
return &yaml.Node{Kind: yaml.SequenceNode}
}
return &yaml.Node{Kind: yaml.MappingNode}
}
// setScalar overwrites a node in place with a plain scalar value. // setScalar overwrites a node in place with a plain scalar value.
func setScalar(n *yaml.Node, value string) { func setScalar(n *yaml.Node, value string) {
n.Kind = yaml.ScalarNode n.Kind = yaml.ScalarNode
+211 -5
View File
@@ -58,14 +58,17 @@ func TestDefaultConfigTemplateParses(t *testing.T) {
} }
} }
// testRecipient is a valid age public key.
const testRecipient = "age1278m9q7dp3chsh2dcy82qk27v047zywyvtxwnj4cvt0z65jw6a7q5dqhfj"
// TestConfigSetRecipientOnFreshConfig follows the README quickstart: on the // TestConfigSetRecipientOnFreshConfig follows the README quickstart: on the
// file `config init` writes, `config set age_recipients.0` and // file `config init` writes, `config set age_recipients.0` and
// `config set storage_url` give a config that loads with that recipient. // `config set storage_url` give a config that loads with that recipient.
// Neither is refused, although the config does not load until both are set;
// once it loads, a recipient that is not a valid key is.
func TestConfigSetRecipientOnFreshConfig(t *testing.T) { func TestConfigSetRecipientOnFreshConfig(t *testing.T) {
t.Parallel() t.Parallel()
const recipient = "age1278m9q7dp3chsh2dcy82qk27v047zywyvtxwnj4cvt0z65jw6a7q5dqhfj"
path := filepath.Join(t.TempDir(), "config.yml") path := filepath.Join(t.TempDir(), "config.yml")
err := os.WriteFile(path, []byte(defaultConfigTemplate), configFileMode) err := os.WriteFile(path, []byte(defaultConfigTemplate), configFileMode)
@@ -75,7 +78,7 @@ func TestConfigSetRecipientOnFreshConfig(t *testing.T) {
out := ui.NewWithColor(&bytes.Buffer{}, false) out := ui.NewWithColor(&bytes.Buffer{}, false)
err = writeConfigSet(out, path, "age_recipients.0", recipient) err = writeConfigSet(out, path, "age_recipients.0", testRecipient)
if err != nil { if err != nil {
t.Fatalf("config set age_recipients.0: %v", err) t.Fatalf("config set age_recipients.0: %v", err)
} }
@@ -90,8 +93,211 @@ func TestConfigSetRecipientOnFreshConfig(t *testing.T) {
t.Fatalf("config.Load: %v", err) t.Fatalf("config.Load: %v", err)
} }
if len(cfg.AgeRecipients) != 1 || cfg.AgeRecipients[0] != recipient { if len(cfg.AgeRecipients) != 1 || cfg.AgeRecipients[0] != testRecipient {
t.Errorf("age_recipients = %v, want [%s]", cfg.AgeRecipients, recipient) t.Errorf("age_recipients = %v, want [%s]", cfg.AgeRecipients, testRecipient)
}
err = writeConfigSet(out, path, "age_recipients.0", "notakey")
if err == nil || !strings.Contains(err.Error(), "not a valid recipient") {
t.Errorf("config set age_recipients.0 notakey gives error %v, "+
"want one saying it is not a valid recipient", err)
}
_, err = config.Load(path)
if err != nil {
t.Errorf("config.Load after the refused change: %v", err)
}
}
// configWithoutRecipients loads, but has no age_recipients key and no
// snapshot named work.
const configWithoutRecipients = `storage_url: file:///mnt/backups
snapshots:
home:
paths:
- "~"
`
// configWithUnsetReference loads only where VAULTIK_TEST_UNSET_SECRET is
// set, as a backup job's environment might set it. No test sets it.
const configWithUnsetReference = configWithoutRecipients + `s3:
secret_access_key: ${ENV:VAULTIK_TEST_UNSET_SECRET}
`
// configWithLevelReference returns configWithoutRecipients with a ${FILE:...}
// reference on compression_level to a file holding 9, so that the config
// loads once the reference is filled in.
func configWithLevelReference(t *testing.T) string {
t.Helper()
levelPath := filepath.Join(t.TempDir(), "level")
err := os.WriteFile(levelPath, []byte("9"), configFileMode)
if err != nil {
t.Fatalf("write level: %v", err)
}
return configWithoutRecipients +
"compression_level: ${FILE:" + levelPath + "}\n"
}
// TestConfigSetIndexUnderMissingKey checks that a list index under a key
// the file does not have creates a list, and that the result loads.
func TestConfigSetIndexUnderMissingKey(t *testing.T) {
t.Parallel()
tests := []struct {
key string
value string
field func(cfg *config.Config) []string
}{
{"age_recipients.0", testRecipient,
func(cfg *config.Config) []string { return cfg.AgeRecipients }},
{"snapshots.work.paths.0", "/srv/work",
func(cfg *config.Config) []string { return cfg.Snapshots["work"].Paths }},
}
for _, tt := range tests {
t.Run(tt.key, func(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "config.yml")
err := os.WriteFile(path, []byte(configWithoutRecipients), configFileMode)
if err != nil {
t.Fatalf("write config: %v", err)
}
err = writeConfigSet(ui.NewWithColor(&bytes.Buffer{}, false),
path, tt.key, tt.value)
if err != nil {
t.Fatalf("config set %s: %v", tt.key, err)
}
cfg, err := config.Load(path)
if err != nil {
t.Fatalf("config.Load: %v", err)
}
got := tt.field(cfg)
if len(got) != 1 || got[0] != tt.value {
t.Errorf("%s gives %v, want [%s]", tt.key, got, tt.value)
}
})
}
}
// TestConfigSetKeepsFileThatWouldNotLoad checks that config set refuses a
// change after which the config would not load, with an error giving the
// reason, and leaves the file as it was. On the file `config init` writes,
// which does not load until storage_url is set, and on one that cannot be
// read here, only a value of the wrong type is refused.
func TestConfigSetKeepsFileThatWouldNotLoad(t *testing.T) {
t.Parallel()
levelReference := configWithLevelReference(t)
tests := []struct {
config string
key string
value string
reason string
}{
// Index 1 of the empty list the missing key gives.
{configWithoutRecipients, "age_recipients.1", testRecipient,
"index out of range"},
// A list set to a single value.
{configWithoutRecipients, "snapshots.home.paths", "/srv",
"into []string"},
{configWithoutRecipients, "chunk_size", "fast",
"invalid size format"},
// Values of the right type that fail validation.
{configWithoutRecipients, "compression_level", "99",
"compression_level must be between 1 and 19"},
{configWithoutRecipients, "chunk_size", "1KB",
"chunk_size must be at least 1MB"},
// With its reference filled in, the config loads.
{levelReference, "blob_size_limit", "1MB",
"blob_size_limit must be at least"},
// The type check on a config that does not load yet.
{defaultConfigTemplate, "snapshots.apps.paths", "/srv",
"into []string"},
// The type check on a config that cannot be read here.
{configWithUnsetReference, "snapshots.home.paths", "/x",
"cannot unmarshal !!str `/x`"},
}
for _, tt := range tests {
t.Run(tt.key+" "+tt.value, func(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "config.yml")
err := os.WriteFile(path, []byte(tt.config), configFileMode)
if err != nil {
t.Fatalf("write config: %v", err)
}
err = writeConfigSet(ui.NewWithColor(&bytes.Buffer{}, false),
path, tt.key, tt.value)
if err == nil || !strings.Contains(err.Error(), tt.reason) {
t.Errorf("config set %s %s gives error %v, want one saying %q",
tt.key, tt.value, err, tt.reason)
}
data, err := os.ReadFile(path) //nolint:gosec // G304: test-controlled path
if err != nil {
t.Fatalf("read config: %v", err)
}
if string(data) != tt.config {
t.Errorf("config set %s changed the file to:\n%s",
tt.key, data)
}
})
}
}
// TestConfigSetRefusesOnlyItsOwnFailure checks that setting another key
// works on a config with a ${...} reference on compression_level, which
// loads because loading fills the reference in, on one whose
// compression_level already fails to load, and on one with a reference
// that cannot be filled in here.
func TestConfigSetRefusesOnlyItsOwnFailure(t *testing.T) {
t.Parallel()
configs := map[string]string{
"reference": configWithLevelReference(t),
"failing": configWithoutRecipients + "compression_level: fast\n",
"unset reference": configWithUnsetReference,
}
for name, configYAML := range configs {
t.Run(name, func(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "config.yml")
err := os.WriteFile(path, []byte(configYAML), configFileMode)
if err != nil {
t.Fatalf("write config: %v", err)
}
err = writeConfigSet(ui.NewWithColor(&bytes.Buffer{}, false),
path, "hostname", "myhost")
if err != nil {
t.Fatalf("config set hostname: %v", err)
}
data, err := os.ReadFile(path) //nolint:gosec // G304: test-controlled path
if err != nil {
t.Fatalf("read config: %v", err)
}
if !strings.Contains(string(data), "hostname: myhost") {
t.Errorf("config set hostname wrote:\n%s", data)
}
})
} }
} }
+41 -24
View File
@@ -4,6 +4,7 @@
package config package config
import ( import (
"bytes"
"errors" "errors"
"fmt" "fmt"
"os" "os"
@@ -233,14 +234,48 @@ func New(path Path) (*Config, error) {
return cfg, nil return cfg, nil
} }
// Load reads and parses the configuration file from the specified path. // Load reads the configuration file at path, parses it with Parse, warns
// It applies default values for optional fields, performs environment variable // when others can read the file, and validates the configuration.
// substitution using smartconfig, and validates the configuration. // Returns an error if the file cannot be read, parsed, or if validation
// The configuration file should be in YAML format. Returns an error if the file // fails.
// cannot be read, parsed, or if validation fails.
func Load(path string) (*Config, error) { func Load(path string) (*Config, error) {
data, err := os.ReadFile(path) //nolint:gosec // G304: config path is operator-supplied
if err != nil {
return nil, fmt.Errorf("failed to load config file: %w", err)
}
cfg, err := Parse(data)
if err != nil {
return nil, err
}
// Check config file permissions (warn if world or group readable)
//nolint:gosec // G703: config path is operator-supplied by design
info, statErr := os.Stat(path)
if statErr == nil {
mode := info.Mode().Perm()
if mode&0044 != 0 { // group or world readable
log.Warn(cfg.readableByOthersWarning(),
"path", path,
"mode", fmt.Sprintf("%04o", mode),
"recommendation", "chmod 600 "+path)
}
}
err = cfg.Validate()
if err != nil {
return nil, fmt.Errorf("invalid config: %w", err)
}
return cfg, nil
}
// Parse reads YAML config data as Load does, without validating it. It
// applies default values for optional fields, fills in ${...} references
// using smartconfig, and expands ~ in paths.
func Parse(data []byte) (*Config, error) {
// Load config using smartconfig for interpolation // Load config using smartconfig for interpolation
sc, err := smartconfig.NewFromConfigPath(path) sc, err := smartconfig.NewFromReader(bytes.NewReader(data))
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to load config file: %w", err) return nil, fmt.Errorf("failed to load config file: %w", err)
} }
@@ -302,24 +337,6 @@ func Load(path string) (*Config, error) {
cfg.S3.Region = "us-east-1" cfg.S3.Region = "us-east-1"
} }
// Check config file permissions (warn if world or group readable)
//nolint:gosec // G703: config path is operator-supplied by design
info, statErr := os.Stat(path)
if statErr == nil {
mode := info.Mode().Perm()
if mode&0044 != 0 { // group or world readable
log.Warn(cfg.readableByOthersWarning(),
"path", path,
"mode", fmt.Sprintf("%04o", mode),
"recommendation", "chmod 600 "+path)
}
}
err = cfg.Validate()
if err != nil {
return nil, fmt.Errorf("invalid config: %w", err)
}
return cfg, nil return cfg, nil
} }
+39
View File
@@ -267,6 +267,45 @@ func (r *FileRepository) ListUnderPath(
return files, rows.Err() return files, rows.Err()
} }
// ListUnderPathInSnapshot returns the files that ListUnderPath returns
// for path and that the snapshot lists, in no particular order.
func (r *FileRepository) ListUnderPathInSnapshot(
ctx context.Context, path, snapshotID string,
) ([]*File, error) {
path = strings.TrimRight(path, "/")
dirPrefix := path + "/"
query := `
SELECT f.id, f.path, f.source_path, f.mtime, f.mtime_nsec, f.size,
f.mode, f.uid, f.gid, f.link_target
FROM files f
JOIN snapshot_files sf ON sf.file_id = f.id
WHERE sf.snapshot_id = ?
AND (f.path = ? OR substr(f.path, 1, length(?)) = ?)
`
rows, err := r.db.conn.QueryContext(ctx, query,
snapshotID, path, dirPrefix, dirPrefix)
if err != nil {
return nil, fmt.Errorf("querying files: %w", err)
}
defer func() { _ = rows.Close() }()
var files []*File
for rows.Next() {
file, err := r.scanFileRows(rows)
if err != nil {
return nil, fmt.Errorf("scanning file: %w", err)
}
files = append(files, file)
}
return files, rows.Err()
}
// ListIDsWithChunksNotInUploadedBlobs returns the IDs of the files whose // ListIDsWithChunksNotInUploadedBlobs returns the IDs of the files whose
// path starts with prefix and that list at least one chunk held by no // path starts with prefix and that list at least one chunk held by no
// blob whose upload has completed (uploaded_ts set). A new snapshot // blob whose upload has completed (uploaded_ts set). A new snapshot
+59
View File
@@ -212,6 +212,65 @@ func TestFileRepositoryListUnderPath(t *testing.T) {
} }
} }
// ListUnderPathInSnapshot leaves out files outside path and files the
// snapshot does not list, though another snapshot lists them.
func TestFileRepositoryListUnderPathInSnapshot(t *testing.T) {
t.Parallel()
db, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
files := database.NewFileRepository(db)
snapshots := database.NewSnapshotRepository(db)
listed := map[string][]string{
"one": {"/src/a.txt", "/src2/c.txt"},
"two": {"/src/b.txt"},
}
for snapshotID, paths := range listed {
err := snapshots.Create(ctx, nil, &database.Snapshot{
ID: types.SnapshotID(snapshotID),
Hostname: "host",
StartedAt: time.Now(),
})
if err != nil {
t.Fatalf("failed to create snapshot %s: %v", snapshotID, err)
}
for _, path := range paths {
err := files.Create(ctx, nil, &database.File{
Path: types.FilePath(path),
MTime: time.Now().Truncate(time.Second),
Mode: 0644,
})
if err != nil {
t.Fatalf("failed to create %s: %v", path, err)
}
err = snapshots.AddFile(ctx, nil, snapshotID, path)
if err != nil {
t.Fatalf("failed to add %s to %s: %v", path, snapshotID, err)
}
}
}
found, err := files.ListUnderPathInSnapshot(ctx, "/src", "one")
if err != nil {
t.Fatalf("failed to list files: %v", err)
}
got := make([]string, 0, len(found))
for _, f := range found {
got = append(got, f.Path.String())
}
if !slices.Equal(got, []string{"/src/a.txt"}) {
t.Errorf("got %q, want only /src/a.txt", got)
}
}
func TestFileRepositorySymlink(t *testing.T) { func TestFileRepositorySymlink(t *testing.T) {
t.Parallel() t.Parallel()
+15 -11
View File
@@ -16,6 +16,9 @@ type ScannerParams struct {
Fs afero.Fs Fs afero.Fs
Exclude []string // Exclude patterns (combined global + snapshot-specific) Exclude []string // Exclude patterns (combined global + snapshot-specific)
SkipErrors bool // Skip file read errors (log loudly but continue) SkipErrors bool // Skip file read errors (log loudly but continue)
// PreviousSnapshotID is the most recent completed snapshot of the
// same name, "" for none.
PreviousSnapshotID string
} }
// Module exports backup functionality as an fx module. // Module exports backup functionality as an fx module.
@@ -44,17 +47,18 @@ func provideScannerFactory(
} }
return NewScanner(ScannerConfig{ return NewScanner(ScannerConfig{
FS: params.Fs, FS: params.Fs,
ChunkSize: cfg.ChunkSize.Int64(), ChunkSize: cfg.ChunkSize.Int64(),
Repositories: repos, Repositories: repos,
Storage: storer, Storage: storer,
MaxBlobSize: cfg.BlobSizeLimit.Int64(), MaxBlobSize: cfg.BlobSizeLimit.Int64(),
CompressionLevel: cfg.CompressionLevel, CompressionLevel: cfg.CompressionLevel,
AgeRecipients: cfg.AgeRecipients, AgeRecipients: cfg.AgeRecipients,
EnableProgress: params.EnableProgress, EnableProgress: params.EnableProgress,
UI: params.UI, UI: params.UI,
Exclude: excludes, Exclude: excludes,
SkipErrors: params.SkipErrors, SkipErrors: params.SkipErrors,
PreviousSnapshotID: params.PreviousSnapshotID,
}) })
} }
} }
+72 -19
View File
@@ -58,6 +58,10 @@ type Scanner struct {
compressionLevel int compressionLevel int
ageRecipient string ageRecipient string
snapshotID string // Current snapshot being processed snapshotID string // Current snapshot being processed
// previousSnapshotID is the most recent completed snapshot of the
// same name, "" for none. Only a file it lists can be counted as
// deleted.
previousSnapshotID string
// currentSourcePath is the source directory being scanned, stored with // currentSourcePath is the source directory being scanned, stored with
// each file record. // each file record.
currentSourcePath string currentSourcePath string
@@ -129,6 +133,9 @@ type ScannerConfig struct {
// SkipErrors skips files that cannot be opened or read (log loudly but // SkipErrors skips files that cannot be opened or read (log loudly but
// continue); packer, database, encryption, and upload errors still abort. // continue); packer, database, encryption, and upload errors still abort.
SkipErrors bool SkipErrors bool
// PreviousSnapshotID is the most recent completed snapshot of the
// same name, "" for none.
PreviousSnapshotID string
} }
// ScanResult contains the results of a scan operation. Files and bytes // ScanResult contains the results of a scan operation. Files and bytes
@@ -207,6 +214,7 @@ func NewScanner(cfg ScannerConfig) *Scanner {
compiledExclude: compiledExclude, compiledExclude: compiledExclude,
progress: progress, progress: progress,
skipErrors: cfg.SkipErrors, skipErrors: cfg.SkipErrors,
previousSnapshotID: cfg.PreviousSnapshotID,
ui: uiw, ui: uiw,
pendingChunkHashes: make(map[string]struct{}), pendingChunkHashes: make(map[string]struct{}),
} }
@@ -258,8 +266,9 @@ func (s *Scanner) Scan(
filesToProcess := scanResult.FilesToProcess filesToProcess := scanResult.FilesToProcess
// Phase 1b: Detect deleted files by comparing DB against scanned files // Phase 1b: Detect deleted files by comparing the previous snapshot
err = s.detectDeletedFilesFromMap(ctx, knownFiles, existingFiles, result) // of this name against scanned files
err = s.detectDeletedFiles(ctx, path, existingFiles, result)
if err != nil { if err != nil {
return nil, fmt.Errorf("detecting deleted files: %w", err) return nil, fmt.Errorf("detecting deleted files: %w", err)
} }
@@ -439,9 +448,8 @@ func (s *Scanner) summarizeScanPhase(
} }
// loadKnownFiles loads the known files at and beneath path from the // loadKnownFiles loads the known files at and beneath path from the
// database into a map for fast lookup. Every loaded file the scan does // database into a map for fast lookup. This avoids per-file database
// not find is counted as deleted. This avoids per-file database queries // queries during the scan phase.
// during the scan phase.
func (s *Scanner) loadKnownFiles( func (s *Scanner) loadKnownFiles(
ctx context.Context, path string, ctx context.Context, path string,
) (map[string]*database.File, error) { ) (map[string]*database.File, error) {
@@ -1954,21 +1962,29 @@ func (s *Scanner) queueFileForBatchInsert(
}) })
} }
// detectDeletedFilesFromMap finds files that existed in previous snapshots // detectDeletedFiles counts as deleted each file at or beneath path that
// but no longer exist. Uses pre-loaded maps to avoid any filesystem or // the previous snapshot of this name lists, the walk did not find, and
// database access. // is gone from disk. A file that is excluded, or inside an excluded
func (s *Scanner) detectDeletedFilesFromMap( // directory, is not counted: the walk skips it whether or not it is
// still on disk.
func (s *Scanner) detectDeletedFiles(
ctx context.Context, ctx context.Context,
knownFiles map[string]*database.File, path string,
existingFiles map[string]struct{}, existingFiles map[string]struct{},
result *ScanResult, result *ScanResult,
) error { ) error {
if len(knownFiles) == 0 { if s.previousSnapshotID == "" {
return nil return nil
} }
// Check each known file against the enumerated set (no filesystem access needed) previousFiles, err := s.repos.Files.ListUnderPathInSnapshot(
for path, file := range knownFiles { ctx, path, s.previousSnapshotID)
if err != nil {
return fmt.Errorf("listing files of snapshot %s: %w",
s.previousSnapshotID, err)
}
for _, file := range previousFiles {
// Check context cancellation periodically // Check context cancellation periodically
select { select {
case <-ctx.Done(): case <-ctx.Done():
@@ -1976,13 +1992,22 @@ func (s *Scanner) detectDeletedFilesFromMap(
default: default:
} }
// Check if the file exists in our enumerated set filePath := file.Path.String()
if _, exists := existingFiles[path]; !exists {
// File has been deleted _, exists := existingFiles[filePath]
result.FilesDeleted++ if exists || s.excludedOrInExcludedDir(filePath, path) {
result.BytesDeleted += file.Size continue
log.Debug("Detected deleted file", "path", path, "size", file.Size)
} }
// Under --skip-errors the walk also misses what it cannot reach,
// such as the files in a directory it cannot open.
if !s.goneFromDisk(filePath) {
continue
}
result.FilesDeleted++
result.BytesDeleted += file.Size
log.Debug("Detected deleted file", "path", filePath, "size", file.Size)
} }
if result.FilesDeleted > 0 { if result.FilesDeleted > 0 {
@@ -1993,6 +2018,34 @@ func (s *Scanner) detectDeletedFilesFromMap(
return nil return nil
} }
// excludedOrInExcludedDir reports whether filePath, which is at or
// beneath rootPath, or any directory between rootPath and it, matches an
// exclude pattern. A pattern such as */.local/share/Trash matches the
// directory but not the files inside it, which the walk skips too.
func (s *Scanner) excludedOrInExcludedDir(filePath, rootPath string) bool {
for p := filePath; len(p) > len(rootPath); p = filepath.Dir(p) {
if s.shouldExclude(p, rootPath) {
return true
}
}
return false
}
// goneFromDisk reports whether looking filePath up on disk finds that
// it does not exist. Like the walk, it does not follow a symlink, so a
// symlink whose target is gone is still on disk.
func (s *Scanner) goneFromDisk(filePath string) bool {
var err error
if lstater, ok := s.fs.(afero.Lstater); ok {
_, _, err = lstater.LstatIfPossible(filePath)
} else {
_, err = s.fs.Stat(filePath)
}
return errors.Is(err, os.ErrNotExist)
}
// wrapPermissionError augments permission errors with platform-specific // wrapPermissionError augments permission errors with platform-specific
// remediation instructions. On macOS, TCC-protected directories (Calendars, // remediation instructions. On macOS, TCC-protected directories (Calendars,
// Reminders, Photos, etc.) return EPERM unless the running application has // Reminders, Photos, etc.) return EPERM unless the running application has
+63 -13
View File
@@ -120,9 +120,10 @@ func (f *FileStorer) Delete(_ context.Context, key string) error {
return nil return nil
} }
// List returns all keys with the given prefix. It fails when the // List returns all keys with the given prefix, reading through symlinked
// destination directory is missing; a missing prefix under it is an // directories. It fails when the destination directory is missing, or
// empty listing. // when the prefix or a directory above it is a symlink whose target is
// missing; a prefix that is truly missing is an empty listing.
func (f *FileStorer) List(ctx context.Context, prefix string) ([]string, error) { func (f *FileStorer) List(ctx context.Context, prefix string) ([]string, error) {
var keys []string var keys []string
@@ -133,8 +134,7 @@ func (f *FileStorer) List(ctx context.Context, prefix string) ([]string, error)
basePath := f.fullPath(prefix) basePath := f.fullPath(prefix)
// Check if the prefix exists exists, err := f.prefixExists(prefix)
exists, err := afero.Exists(f.fs, basePath)
if err != nil { if err != nil {
return nil, fmt.Errorf("checking path: %w", err) return nil, fmt.Errorf("checking path: %w", err)
} }
@@ -143,7 +143,7 @@ func (f *FileStorer) List(ctx context.Context, prefix string) ([]string, error)
return keys, nil // Empty list for non-existent prefix return keys, nil // Empty list for non-existent prefix
} }
err = afero.Walk(f.fs, basePath, func(path string, info os.FileInfo, err error) error { err = f.walk(basePath, func(path string, info os.FileInfo, err error) error {
if err != nil { if err != nil {
return err return err
} }
@@ -176,7 +176,9 @@ func (f *FileStorer) List(ctx context.Context, prefix string) ([]string, error)
} }
// ListStream returns a channel of ObjectInfo for large result sets. Like // ListStream returns a channel of ObjectInfo for large result sets. Like
// List, it sends an error when the destination directory is missing. // List, it reads through symlinked directories and sends an error when
// the destination directory is missing, or when the prefix or a directory
// above it is a symlink whose target is missing.
func (f *FileStorer) ListStream(ctx context.Context, prefix string) <-chan ObjectInfo { func (f *FileStorer) ListStream(ctx context.Context, prefix string) <-chan ObjectInfo {
ch := make(chan ObjectInfo) ch := make(chan ObjectInfo)
@@ -192,8 +194,7 @@ func (f *FileStorer) ListStream(ctx context.Context, prefix string) <-chan Objec
basePath := f.fullPath(prefix) basePath := f.fullPath(prefix)
// Check if the prefix exists exists, err := f.prefixExists(prefix)
exists, err := afero.Exists(f.fs, basePath)
if err != nil { if err != nil {
ch <- ObjectInfo{Err: fmt.Errorf("checking path: %w", err)} ch <- ObjectInfo{Err: fmt.Errorf("checking path: %w", err)}
@@ -204,7 +205,7 @@ func (f *FileStorer) ListStream(ctx context.Context, prefix string) <-chan Objec
return // Empty channel for non-existent prefix return // Empty channel for non-existent prefix
} }
_ = afero.Walk(f.fs, basePath, func(path string, info os.FileInfo, err error) error { _ = f.walk(basePath, func(path string, info os.FileInfo, err error) error {
// Check context cancellation // Check context cancellation
select { select {
case <-ctx.Done(): case <-ctx.Done():
@@ -243,11 +244,12 @@ func (f *FileStorer) ListStream(ctx context.Context, prefix string) <-chan Objec
} }
// DeletePartialUploads removes every file under prefix whose name ends in // DeletePartialUploads removes every file under prefix whose name ends in
// tempSuffix. A missing prefix has none to remove. // tempSuffix. Like List, it reads through symlinked directories and fails
// on one whose target is missing. A missing prefix has none to remove.
func (f *FileStorer) DeletePartialUploads(ctx context.Context, prefix string) error { func (f *FileStorer) DeletePartialUploads(ctx context.Context, prefix string) error {
basePath := f.fullPath(prefix) basePath := f.fullPath(prefix)
exists, err := afero.Exists(f.fs, basePath) exists, err := f.prefixExists(prefix)
if err != nil { if err != nil {
return fmt.Errorf("checking path: %w", err) return fmt.Errorf("checking path: %w", err)
} }
@@ -256,7 +258,7 @@ func (f *FileStorer) DeletePartialUploads(ctx context.Context, prefix string) er
return nil return nil
} }
err = afero.Walk(f.fs, basePath, func(path string, info os.FileInfo, err error) error { err = f.walk(basePath, func(path string, info os.FileInfo, err error) error {
if err != nil { if err != nil {
return err return err
} }
@@ -352,6 +354,54 @@ func (f *FileStorer) writeAtomic(
return nil return nil
} }
// walk is afero.Walk, except that it reads through a symlinked directory
// the way Get and Stat do, where afero.Walk reports the link as one file.
// afero.Walk uses Lstat, which does not follow a link, whenever the
// filesystem has one; the struct below carries only the afero.Fs methods,
// so the walk uses Stat instead. A link back up the tree is followed until
// the operating system refuses the path for too many levels of links, and
// the walk then fails.
func (f *FileStorer) walk(root string, fn filepath.WalkFunc) error {
return afero.Walk(struct{ afero.Fs }{f.fs}, root, fn)
}
// prefixExists reports whether prefix exists under the destination
// directory. It checks each directory on the way down to prefix in turn,
// and the first one Stat cannot find decides: if it is not there at all,
// prefix is missing; if it is a symlink whose target is missing, such as
// a link to a volume that is not mounted, prefixExists fails, since
// listing it as empty would let prune delete every blob.
func (f *FileStorer) prefixExists(prefix string) (bool, error) {
lstater, canLstat := f.fs.(afero.Lstater)
path := f.basePath
for name := range strings.SplitSeq(prefix, "/") {
path = filepath.Join(path, name)
_, err := f.fs.Stat(path)
if err == nil {
continue
}
if !os.IsNotExist(err) {
return false, err
}
// Lstat does not follow a symlink at path, so it finds the link
// that Stat could not follow.
if canLstat {
_, _, lstatErr := lstater.LstatIfPossible(path)
if lstatErr == nil {
return false, err
}
}
return false, nil
}
return true, nil
}
// fullPath returns the full filesystem path for a key. // fullPath returns the full filesystem path for a key.
func (f *FileStorer) fullPath(key string) string { func (f *FileStorer) fullPath(key string) string {
return filepath.Join(f.basePath, key) return filepath.Join(f.basePath, key)
+232
View File
@@ -4,7 +4,10 @@ import (
"context" "context"
"errors" "errors"
"io/fs" "io/fs"
"os"
"path/filepath" "path/filepath"
"reflect"
"strings"
"testing" "testing"
"sneak.berlin/go/vaultik/internal/storage" "sneak.berlin/go/vaultik/internal/storage"
@@ -60,3 +63,232 @@ func TestFileStorerListMissingDestination(t *testing.T) {
t.Errorf("ListStream error = %v, want a not-exist error", streamErr) t.Errorf("ListStream error = %v, want a not-exist error", streamErr)
} }
} }
// The two directories at the top of a destination, either of which the
// tests below replace with a symlink.
const (
metadataDir = "metadata"
blobsDir = "blobs"
)
// moveBehindSymlink moves base/dir to a directory elsewhere and puts a
// symlink to it in its place.
func moveBehindSymlink(t *testing.T, base, dir string) {
t.Helper()
linkPath := filepath.Join(base, dir)
movedPath := filepath.Join(t.TempDir(), dir)
err := os.Rename(linkPath, movedPath)
if err != nil {
t.Fatalf("moving %s away: %v", dir, err)
}
err = os.Symlink(movedPath, linkPath)
if err != nil {
t.Fatalf("linking %s: %v", dir, err)
}
}
// TestFileStorerListsThroughSymlinkedDirectory checks that List and
// ListStream read through a symlinked metadata/ or blobs/ directory, as Get
// and Stat do, instead of listing the link as one key. Listing the whole
// destination meets the link part-way through the walk; listing its prefix
// starts the walk at the link. A prefix missing behind the link lists as
// empty.
func TestFileStorerListsThroughSymlinkedDirectory(t *testing.T) {
t.Parallel()
for _, dir := range []string{metadataDir, blobsDir} {
t.Run(dir, func(t *testing.T) {
t.Parallel()
base := t.TempDir()
key := dir + "/aa/bb/object"
s, err := storage.NewFileStorer(base)
if err != nil {
t.Fatalf("NewFileStorer: %v", err)
}
err = s.Put(context.Background(), key, strings.NewReader("data"))
if err != nil {
t.Fatalf("Put: %v", err)
}
moveBehindSymlink(t, base, dir)
want := []string{key}
for _, prefix := range []string{"", dir + "/"} {
got := listSorted(t, s, prefix)
if !reflect.DeepEqual(got, want) {
t.Errorf("List(%q) = %v, want %v", prefix, got, want)
}
got = listStreamKeys(t, s, prefix)
if !reflect.DeepEqual(got, want) {
t.Errorf("ListStream(%q) = %v, want %v", prefix, got, want)
}
}
absent := dir + "/absent/"
if got := listSorted(t, s, absent); len(got) != 0 {
t.Errorf("List(%q) = %v, want empty", absent, got)
}
if got := listStreamKeys(t, s, absent); len(got) != 0 {
t.Errorf("ListStream(%q) = %v, want empty", absent, got)
}
})
}
}
// TestFileStorerListFailsOnSymlinkWithMissingTarget checks that List and
// ListStream fail on a symlinked metadata/ or blobs/ directory whose target
// is missing, such as a link to a volume that is not mounted, as they do on
// a missing destination directory, instead of listing it as empty. That
// holds for a prefix below the link too.
func TestFileStorerListFailsOnSymlinkWithMissingTarget(t *testing.T) {
t.Parallel()
ctx := context.Background()
for _, dir := range []string{metadataDir, blobsDir} {
t.Run(dir, func(t *testing.T) {
t.Parallel()
base := t.TempDir()
s, err := storage.NewFileStorer(base)
if err != nil {
t.Fatalf("NewFileStorer: %v", err)
}
unmounted := filepath.Join(t.TempDir(), "unmounted")
err = os.Symlink(unmounted, filepath.Join(base, dir))
if err != nil {
t.Fatalf("Symlink: %v", err)
}
for _, prefix := range []string{"", dir + "/", dir + "/aa/"} {
keys, err := s.List(ctx, prefix)
if !errors.Is(err, fs.ErrNotExist) {
t.Errorf("List(%q) = %v, %v; want a not-exist error",
prefix, keys, err)
}
var streamErr error
for object := range s.ListStream(ctx, prefix) {
if object.Err != nil {
streamErr = object.Err
}
}
if !errors.Is(streamErr, fs.ErrNotExist) {
t.Errorf("ListStream(%q) error = %v, want a not-exist error",
prefix, streamErr)
}
}
})
}
}
// TestFileStorerListFailsOnSymlinkLoop checks that a symlink pointing back
// at the destination makes List and ListStream fail instead of walking it
// forever.
func TestFileStorerListFailsOnSymlinkLoop(t *testing.T) {
t.Parallel()
ctx := context.Background()
base := t.TempDir()
s, err := storage.NewFileStorer(base)
if err != nil {
t.Fatalf("NewFileStorer: %v", err)
}
err = s.Put(ctx, testBlobKey, strings.NewReader("data"))
if err != nil {
t.Fatalf("Put: %v", err)
}
err = os.Symlink(base, filepath.Join(base, "blobs", "loop"))
if err != nil {
t.Fatalf("Symlink: %v", err)
}
keys, err := s.List(ctx, "blobs/")
if err == nil {
t.Errorf("List = %d keys and no error, want an error", len(keys))
}
var streamErr error
for object := range s.ListStream(ctx, "blobs/") {
if object.Err != nil {
streamErr = object.Err
}
}
if streamErr == nil {
t.Error("ListStream sent no error, want one")
}
}
// TestFileStorerDeletePartialUploadsThroughSymlinkedDirectory checks that
// DeletePartialUploads, which remote nuke uses, removes a leftover temp
// file behind a symlinked metadata/ or blobs/ directory and keeps the
// object at the real key.
func TestFileStorerDeletePartialUploadsThroughSymlinkedDirectory(t *testing.T) {
t.Parallel()
ctx := context.Background()
for _, dir := range []string{metadataDir, blobsDir} {
t.Run(dir, func(t *testing.T) {
t.Parallel()
base := t.TempDir()
key := dir + "/aa/bb/object"
s, err := storage.NewFileStorer(base)
if err != nil {
t.Fatalf("NewFileStorer: %v", err)
}
err = s.Put(ctx, key, strings.NewReader("data"))
if err != nil {
t.Fatalf("Put: %v", err)
}
leftover := filepath.Join(base, key+"-123456.partial")
err = os.WriteFile(leftover, []byte("half"), 0o600)
if err != nil {
t.Fatalf("writing leftover temp file: %v", err)
}
moveBehindSymlink(t, base, dir)
err = s.DeletePartialUploads(ctx, dir+"/")
if err != nil {
t.Fatalf("DeletePartialUploads: %v", err)
}
// leftover runs through the link.
_, err = os.Stat(leftover)
if !os.IsNotExist(err) {
t.Errorf("leftover temp file was not removed: %v", err)
}
_, err = s.Stat(ctx, key)
if err != nil {
t.Errorf("Stat of the real key: %v", err)
}
})
}
}
+12 -11
View File
@@ -661,17 +661,18 @@ func faultScannerFactory(
) snapshot.ScannerFactory { ) snapshot.ScannerFactory {
return func(params snapshot.ScannerParams) *snapshot.Scanner { return func(params snapshot.ScannerParams) *snapshot.Scanner {
return snapshot.NewScanner(snapshot.ScannerConfig{ return snapshot.NewScanner(snapshot.ScannerConfig{
FS: params.Fs, FS: params.Fs,
Storage: storer, Storage: storer,
ChunkSize: faultChunkSize, ChunkSize: faultChunkSize,
MaxBlobSize: faultMaxBlobSize, MaxBlobSize: faultMaxBlobSize,
CompressionLevel: cfg.CompressionLevel, CompressionLevel: cfg.CompressionLevel,
AgeRecipients: cfg.AgeRecipients, AgeRecipients: cfg.AgeRecipients,
Repositories: repos, Repositories: repos,
EnableProgress: params.EnableProgress, EnableProgress: params.EnableProgress,
UI: params.UI, UI: params.UI,
Exclude: params.Exclude, Exclude: params.Exclude,
SkipErrors: params.SkipErrors, SkipErrors: params.SkipErrors,
PreviousSnapshotID: params.PreviousSnapshotID,
}) })
} }
} }
+33 -1
View File
@@ -18,7 +18,8 @@ import (
// TestNukeRemoteLeavesNoFiles checks that remote nuke leaves no file // TestNukeRemoteLeavesNoFiles checks that remote nuke leaves no file
// under a file:// destination, including the `.partial` files uploads // under a file:// destination, including the `.partial` files uploads
// killed part-way leave next to a snapshot's metadata and next to where a // killed part-way leave next to a snapshot's metadata and next to where a
// blob would have been. // blob would have been, a snapshot's database whose manifest was never
// uploaded, and a file under blobs/ whose name is not a blob hash.
func TestNukeRemoteLeavesNoFiles(t *testing.T) { func TestNukeRemoteLeavesNoFiles(t *testing.T) {
log.Initialize(log.Config{}) log.Initialize(log.Config{})
t.Parallel() t.Parallel()
@@ -38,6 +39,9 @@ func TestNukeRemoteLeavesNoFiles(t *testing.T) {
"db.zst.age-123456.partial"), "db.zst.age-123456.partial"),
filepath.Join(storeDir, "blobs", hash[:2], hash[2:4], filepath.Join(storeDir, "blobs", hash[:2], hash[2:4],
hash+"-123456.partial"), hash+"-123456.partial"),
filepath.Join(storeDir, "metadata",
snapshot.RemoteSnapshotKey("no-manifest"), "db.zst.age"),
filepath.Join(storeDir, "blobs", "0b", "01", ".DS_Store"),
} }
for _, leftover := range leftovers { for _, leftover := range leftovers {
@@ -100,6 +104,34 @@ func TestNukeRemoteFailsWhenADeleteFails(t *testing.T) {
} }
} }
// TestNukeRemoteNamesWhatIsLeft checks that remote nuke, when an object
// it does not recognise cannot be deleted, does not report the store
// empty but names that object and fails.
func TestNukeRemoteNamesWhatIsLeft(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
ctx := context.Background()
storeDir := filepath.Join(t.TempDir(), "store")
v, _, out := backUpToFileDestination(ctx, t, storeDir)
const leftKey = "blobs/0b/01/.DS_Store"
leftPath := filepath.Join(storeDir, filepath.FromSlash(leftKey))
require.NoError(t, os.MkdirAll(filepath.Dir(leftPath), 0o750))
require.NoError(t, os.WriteFile(leftPath, []byte("finder"), 0o600))
store := faultstore.New(v.Storage)
store.OnDelete = func(key string) bool { return key == leftKey }
v.Storage = store
require.Error(t, v.NukeRemote(true))
assert.Contains(t, out.String(), leftKey)
assert.NotContains(t, out.String(), "now empty")
assert.Contains(t, out.String(), "vaultik remote nuke --force")
}
// TestNukeRemoteRefusesAnotherDestinationWithoutRetryAdvice checks that // TestNukeRemoteRefusesAnotherDestinationWithoutRetryAdvice checks that
// remote nuke, when the local index belongs to a different destination // remote nuke, when the local index belongs to a different destination
// than the configured one, fails without advising to run nuke again: // than the configured one, fails without advising to run nuke again:
+60 -16
View File
@@ -22,6 +22,11 @@ var errNukeRequiresForce = errors.New(
// errBlobDeleteFailed reports unreferenced blobs prune could not delete. // errBlobDeleteFailed reports unreferenced blobs prune could not delete.
var errBlobDeleteFailed = errors.New("blob(s) could not be deleted") var errBlobDeleteFailed = errors.New("blob(s) could not be deleted")
// errObjectsLeft reports objects remote nuke still finds on the backup
// destination store after deleting.
var errObjectsLeft = errors.New(
"object(s) still on the backup destination store")
// nukeCommandHint is the command suggested when a remote nuke could not // nukeCommandHint is the command suggested when a remote nuke could not
// delete everything: running it again deletes what is left, which // delete everything: running it again deletes what is left, which
// `vaultik prune` does not do for snapshot metadata. // `vaultik prune` does not do for snapshot metadata.
@@ -35,10 +40,11 @@ const metadataDirName = "metadata"
// (blobs/<aa>/<bb>/<hash>). // (blobs/<aa>/<bb>/<hash>).
const blobKeyParts = 4 const blobKeyParts = 4
// NukeRemote deletes every snapshot's metadata and every blob from remote // NukeRemote deletes every object under metadata/ and blobs/ in remote
// storage, along with any object an upload cut off part-way left under a // storage, whatever its name, along with any object an upload cut off
// temporary `.partial` name. After this returns successfully the bucket // part-way left under a temporary `.partial` name. It returns
// prefix is empty and the next backup starts from scratch. // successfully only when a listing afterwards finds nothing left there,
// and the next backup then starts from scratch.
// //
// Refuses to run unless force is true. The caller is responsible for // Refuses to run unless force is true. The caller is responsible for
// confirming with the user. // confirming with the user.
@@ -68,7 +74,8 @@ func (v *Vaultik) NukeRemote(force bool) error {
} }
// emptyDestinationStore does the deleting for NukeRemote and stops at // emptyDestinationStore does the deleting for NukeRemote and stops at
// the first step that fails. // the first step that fails. It also fails when objects are still
// listed at the end, naming each one.
func (v *Vaultik) emptyDestinationStore() error { func (v *Vaultik) emptyDestinationStore() error {
v.UI.Beginf("Removing all snapshot metadata from backup destination store.") v.UI.Beginf("Removing all snapshot metadata from backup destination store.")
@@ -77,25 +84,62 @@ func (v *Vaultik) emptyDestinationStore() error {
return fmt.Errorf("removing all snapshots: %w", err) return fmt.Errorf("removing all snapshots: %w", err)
} }
v.UI.Beginf("Removing any blobs still present in backup destination store.") v.UI.Beginf("Removing everything still present in backup destination store.")
err = v.PruneBlobs(&PruneOptions{Force: true}) var left []string
if err != nil {
return fmt.Errorf("pruning blobs: %w", err) for _, prefix := range []string{"metadata/", "blobs/"} {
keys, err := v.deleteEverythingUnder(prefix)
if err != nil {
return err
}
left = append(left, keys...)
} }
// The file and rclone listings skip `.partial` objects, so the two for _, key := range left {
// steps above never delete them. v.UI.Warningf("Still on the backup destination store: %s", key)
for _, prefix := range []string{"metadata/", "blobs/"} { }
err = v.Storage.DeletePartialUploads(v.ctx, prefix)
if err != nil { if len(left) > 0 {
return fmt.Errorf("deleting partial uploads: %w", err) return fmt.Errorf("%d %w", len(left), errObjectsLeft)
}
} }
return nil return nil
} }
// deleteEverythingUnder deletes every object under prefix, whatever its
// name, and returns the keys a listing afterwards still finds. A delete
// that fails is logged and the others still run; that object then
// shows up in the listing afterwards.
func (v *Vaultik) deleteEverythingUnder(prefix string) ([]string, error) {
keys, err := v.Storage.List(v.ctx, prefix)
if err != nil {
return nil, fmt.Errorf("listing %s: %w", prefix, err)
}
for _, key := range keys {
err = v.Storage.Delete(v.ctx, key)
if err != nil {
log.Error("Failed to delete object", "key", key, "error", err)
}
}
// The file and rclone listings skip `.partial` objects, so the
// deletes above never reach them.
err = v.Storage.DeletePartialUploads(v.ctx, prefix)
if err != nil {
return nil, fmt.Errorf("deleting partial uploads: %w", err)
}
left, err := v.Storage.List(v.ctx, prefix)
if err != nil {
return nil, fmt.Errorf("listing %s: %w", prefix, err)
}
return left, nil
}
// PruneBlobsResult contains the result of a blob prune operation // PruneBlobsResult contains the result of a blob prune operation
// //
//nolint:tagliatelle // snake_case is the established JSON output format //nolint:tagliatelle // snake_case is the established JSON output format
+29 -5
View File
@@ -243,12 +243,18 @@ func (v *Vaultik) createNamedSnapshot(
return err return err
} }
previousSnapshotID, err := v.previousSnapshotID(snapName)
if err != nil {
return err
}
scanner := v.ScannerFactory(snapshot.ScannerParams{ scanner := v.ScannerFactory(snapshot.ScannerParams{
EnableProgress: !opts.Cron, EnableProgress: !opts.Cron,
UI: v.UI, UI: v.UI,
Fs: v.Fs, Fs: v.Fs,
Exclude: v.Config.GetExcludes(snapName), Exclude: v.Config.GetExcludes(snapName),
SkipErrors: opts.SkipErrors, SkipErrors: opts.SkipErrors,
PreviousSnapshotID: previousSnapshotID,
}) })
snapshotID, err := v.SnapshotManager.CreateSnapshotWithName( snapshotID, err := v.SnapshotManager.CreateSnapshotWithName(
@@ -283,6 +289,24 @@ func (v *Vaultik) createNamedSnapshot(
return nil return nil
} }
// previousSnapshotID returns the ID of the most recent completed snapshot
// named snapName in the local index, or "" when there is none.
func (v *Vaultik) previousSnapshotID(snapName string) (string, error) {
snapshots, err := v.Repositories.Snapshots.ListRecent(v.ctx, listRecentLimit)
if err != nil {
return "", fmt.Errorf("listing snapshots: %w", err)
}
for _, s := range snapshots {
if s.CompletedAt != nil &&
parseSnapshotName(s.ID.String(), s.Hostname.String()) == snapName {
return s.ID.String(), nil
}
}
return "", nil
}
// resolveSnapshotPaths resolves source directories to absolute paths // resolveSnapshotPaths resolves source directories to absolute paths
// with symlink resolution. // with symlink resolution.
func (v *Vaultik) resolveSnapshotPaths(snapName string) ([]string, error) { func (v *Vaultik) resolveSnapshotPaths(snapName string) ([]string, error) {
@@ -0,0 +1,225 @@
package vaultik_test
import (
"bytes"
"context"
"fmt"
"os"
"path/filepath"
"testing"
"time"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"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/storage"
"sneak.berlin/go/vaultik/internal/ui"
"sneak.berlin/go/vaultik/internal/vaultik"
)
// These tests cover https://git.eeqj.de/sneak/vaultik/issues/304: a
// backup counts a file as deleted only when the previous snapshot of
// the same name had it and it is gone, and never counts an excluded
// file.
// deletedCountEnv is a backup setup over the source directory src whose
// user-facing output is kept in out. Each test configures its snapshots
// with setSnapshot.
type deletedCountEnv struct {
v *vaultik.Vaultik
out *bytes.Buffer
src string
}
// newDeletedCountEnv writes files, keyed by path relative to src.
func newDeletedCountEnv(t *testing.T, files map[string][]byte) *deletedCountEnv {
t.Helper()
ctx := context.Background()
fs := afero.NewOsFs()
tempDir := t.TempDir()
srcDir := filepath.Join(tempDir, "src")
for rel, content := range files {
path := filepath.Join(srcDir, rel)
require.NoError(t, fs.MkdirAll(filepath.Dir(path), 0o755))
require.NoError(t, afero.WriteFile(fs, path, content, 0o644))
}
cfg := faultTestConfig()
cfg.IndexPath = filepath.Join(tempDir, "index.sqlite")
cfg.Snapshots = map[string]config.SnapshotConfig{}
store, err := storage.NewFileStorer(filepath.Join(tempDir, "remote"))
require.NoError(t, err)
db, err := database.New(ctx, cfg.IndexPath)
require.NoError(t, err)
t.Cleanup(func() { _ = db.Close() })
repos := database.NewRepositories(db)
out := &bytes.Buffer{}
v := newBackupVaultik(ctx, cfg, store, repos, db, fs)
v.UI = ui.NewWithColor(out, false)
return &deletedCountEnv{v: v, out: out, src: srcDir}
}
// setSnapshot configures the snapshot name to back up the path rel
// beneath src, with the given excludes.
func (e *deletedCountEnv) setSnapshot(name, rel string, exclude ...string) {
e.v.Config.Snapshots[name] = config.SnapshotConfig{
Paths: []string{filepath.Join(e.src, rel)},
Exclude: exclude,
}
}
// backUp runs a backup of the named snapshot and returns its output.
func (e *deletedCountEnv) backUp(t *testing.T, name string) string {
t.Helper()
e.out.Reset()
require.NoError(t, e.v.CreateSnapshot(&vaultik.SnapshotCreateOptions{
Snapshots: []string{name},
}))
return e.out.String()
}
// A file this snapshot excludes is not counted as deleted: neither one
// that another snapshot backs up, nor one that the previous snapshot of
// this name had before the exclude was added.
func TestBackupDoesNotCountExcludedFileAsDeleted(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
env := newDeletedCountEnv(t, map[string][]byte{
"top.txt": []byte("top"),
"cache/tmp.bin": []byte("cache"),
"photos/a.jpg": []byte("photo a"),
"photos/b.jpg": []byte("photo b"),
"photos/c.jpg": []byte("photo c"),
})
env.setSnapshot("photos", "photos")
env.setSnapshot("home", "", "/photos")
env.backUp(t, "photos")
assert.Contains(t, env.backUp(t, "home"), filesLine(2, 2, 0))
env.setSnapshot("home", "", "/photos", "/cache")
assert.Contains(t, env.backUp(t, "home"), filesLine(1, 0, 1))
}
// A file inside a directory that an exclude added after a backup
// matches is not counted as deleted, though the pattern matches only the
// directory.
func TestBackupDoesNotCountFileInExcludedDirectoryAsDeleted(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
env := newDeletedCountEnv(t, map[string][]byte{
"top.txt": []byte("top"),
"user/.local/share/Trash/old.txt": []byte("old"),
})
env.setSnapshot("home", "")
assert.Contains(t, env.backUp(t, "home"), filesLine(2, 2, 0))
env.setSnapshot("home", "", "*/.local/share/Trash")
assert.Contains(t, env.backUp(t, "home"), filesLine(1, 0, 1))
}
// Under --skip-errors a file inside a directory that the walk cannot
// open is not counted as deleted: it is still on disk.
func TestBackupDoesNotCountFileInUnopenableDirectoryAsDeleted(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
env := newDeletedCountEnv(t, map[string][]byte{
"open.txt": []byte("open"),
"locked/inside.txt": []byte("inside"),
})
env.setSnapshot("work", "")
assert.Contains(t, env.backUp(t, "work"), filesLine(2, 2, 0))
// A directory's mode does not lock out root, who runs the tests, so
// opening the directory is made to fail instead.
env.v.Fs = &openFailFs{
path: filepath.Join(env.src, "locked"),
err: os.ErrPermission,
}
env.out.Reset()
require.NoError(t, env.v.CreateSnapshot(&vaultik.SnapshotCreateOptions{
SkipErrors: true,
Snapshots: []string{"work"},
}))
assert.Contains(t, env.out.String(), filesLine(1, 0, 1))
}
// A backup counts only files that the previous snapshot of its own name
// had, not one that a later snapshot of another name over the same path
// had and that is since removed.
func TestBackupIgnoresSnapshotOfAnotherName(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
env := newDeletedCountEnv(t, map[string][]byte{
"kept.txt": []byte("kept"),
})
env.setSnapshot("home", "")
env.setSnapshot("other", "")
env.backUp(t, "home")
// Snapshot start times are kept in whole seconds: wait so that other's
// snapshot is strictly the most recent.
time.Sleep(time.Second)
extra := filepath.Join(env.src, "extra.txt")
require.NoError(t, os.WriteFile(extra, []byte("extra"), 0o600))
env.backUp(t, "other")
require.NoError(t, os.Remove(extra))
assert.Contains(t, env.backUp(t, "home"), filesLine(1, 0, 1))
}
// A file removed from disk is counted as deleted by the next backup of
// the snapshot that had it, and not by any later backup of that
// snapshot, nor by the first backup of another name over the same path.
func TestBackupCountsDeletedFileOnce(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
aContent := bytes.Repeat([]byte("a"), 1000)
bContent := bytes.Repeat([]byte("b"), 2000)
keptContent := bytes.Repeat([]byte("k"), 500)
env := newDeletedCountEnv(t, map[string][]byte{
"a.txt": aContent,
"b.txt": bContent,
"kept.txt": keptContent,
})
env.setSnapshot("home", "")
env.setSnapshot("other", "")
assert.Contains(t, env.backUp(t, "home"), filesLine(3, 3, 0))
require.NoError(t, os.Remove(filepath.Join(env.src, "a.txt")))
require.NoError(t, os.Remove(filepath.Join(env.src, "b.txt")))
summary := env.backUp(t, "home")
assert.Contains(t, summary,
"Files: 1 examined, 0 backed up, 1 unchanged, 2 deleted.")
assert.Contains(t, summary,
fmt.Sprintf("Data: %s total (%s backed up), %s deleted.",
env.v.UI.Size(int64(len(keptContent))), env.v.UI.Size(0),
env.v.UI.Size(int64(len(aContent)+len(bContent)))))
assert.Contains(t, env.backUp(t, "home"), filesLine(1, 0, 1))
assert.Contains(t, env.backUp(t, "other"), filesLine(1, 0, 1))
}
@@ -0,0 +1,147 @@
package vaultik_test
import (
"context"
"io/fs"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/vaultik/internal/log"
"sneak.berlin/go/vaultik/internal/vaultik"
)
// TestPruneDeletesNothingThroughSymlinkedDirectory checks that prune keeps
// every blob and the local snapshot record when the metadata/ or blobs/
// directory of a file:// destination is a symlink to a directory elsewhere
// (https://git.eeqj.de/sneak/vaultik/issues/296). A backup writes through
// such a link, so prune has to list through it too.
func TestPruneDeletesNothingThroughSymlinkedDirectory(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
for _, dir := range []string{"metadata", "blobs"} {
t.Run(dir, func(t *testing.T) {
t.Parallel()
ctx := context.Background()
storeDir := filepath.Join(t.TempDir(), "store")
v, repos, _ := backUpToFileDestination(ctx, t, storeDir)
blobFiles := blobFilesIn(t, storeDir)
moveBehindSymlink(t, storeDir, dir)
require.NoError(t, v.Prune(&vaultik.PruneOptions{Force: true}))
// When blobs/ is the link, these paths run through it.
for _, blobFile := range blobFiles {
assert.FileExists(t, blobFile, "prune must delete no blob")
}
snapshots, err := repos.Snapshots.ListRecent(ctx, listRecentTestLimit)
require.NoError(t, err)
assert.Len(t, snapshots, 1, "prune must delete no local snapshot record")
})
}
}
// TestPruneFailsOnSymlinkedDirectoryWithMissingTarget checks that prune
// fails, deleting no blob and keeping the local snapshot record, when the
// metadata/ or blobs/ directory of a file:// destination is a symlink
// whose target is missing, as a link to a volume that is not mounted is.
func TestPruneFailsOnSymlinkedDirectoryWithMissingTarget(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
for _, dir := range []string{"metadata", "blobs"} {
t.Run(dir, func(t *testing.T) {
t.Parallel()
ctx := context.Background()
storeDir := filepath.Join(t.TempDir(), "store")
v, repos, _ := backUpToFileDestination(ctx, t, storeDir)
blobFiles := blobFilesIn(t, storeDir)
targetPath := moveBehindSymlink(t, storeDir, dir)
// Unmount the volume the link points to, and mount it again
// once prune has run.
unmountedPath := targetPath + "-unmounted"
require.NoError(t, os.Rename(targetPath, unmountedPath))
err := v.Prune(&vaultik.PruneOptions{Force: true})
require.ErrorIs(t, err, fs.ErrNotExist)
require.NoError(t, os.Rename(unmountedPath, targetPath))
// When blobs/ is the link, these paths run through it.
for _, blobFile := range blobFiles {
assert.FileExists(t, blobFile, "prune must delete no blob")
}
snapshots, err := repos.Snapshots.ListRecent(ctx, listRecentTestLimit)
require.NoError(t, err)
assert.Len(t, snapshots, 1, "prune must delete no local snapshot record")
})
}
}
// TestRemoveSnapshotWarnsOnSymlinkedMetadataWithMissingTarget checks that
// snapshot remove warns that the metadata could not be removed from the
// destination when metadata/ is a symlink whose target is missing, as it
// does when the destination directory is missing, instead of finding no
// metadata to remove.
func TestRemoveSnapshotWarnsOnSymlinkedMetadataWithMissingTarget(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
ctx := context.Background()
storeDir := filepath.Join(t.TempDir(), "store")
v, repos, out := backUpToFileDestination(ctx, t, storeDir)
targetPath := moveBehindSymlink(t, storeDir, "metadata")
require.NoError(t, os.Rename(targetPath, targetPath+"-unmounted"))
result, err := v.RemoveSnapshot(localSnapshotID(ctx, t, repos, "first"),
&vaultik.RemoveOptions{Force: true})
require.NoError(t, err)
assert.False(t, result.RemoteRemoved)
assert.Contains(t, out.String(),
"Could not remove snapshot metadata from remote")
assert.NotContains(t, out.String(),
"Removed snapshot metadata from remote storage")
}
// blobFilesIn returns the paths of the blob files under storeDir/blobs, and
// fails the test when there are none.
func blobFilesIn(t *testing.T, storeDir string) []string {
t.Helper()
var blobFiles []string
err := filepath.WalkDir(filepath.Join(storeDir, "blobs"),
func(path string, entry fs.DirEntry, err error) error {
if err == nil && !entry.IsDir() {
blobFiles = append(blobFiles, path)
}
return err
})
require.NoError(t, err)
require.NotEmpty(t, blobFiles)
return blobFiles
}
// moveBehindSymlink moves storeDir/dir to a directory elsewhere, puts a
// symlink to it in its place, and returns where it went.
func moveBehindSymlink(t *testing.T, storeDir, dir string) string {
t.Helper()
linkPath := filepath.Join(storeDir, dir)
targetPath := filepath.Join(t.TempDir(), dir)
require.NoError(t, os.Rename(linkPath, targetPath))
require.NoError(t, os.Symlink(targetPath, linkPath))
return targetPath
}