From 090549c48694418729202c503fcb1e8e4a0019bc Mon Sep 17 00:00:00 2001 From: Michael Eischer Date: Sun, 14 Apr 2024 22:44:32 +0200 Subject: [PATCH 1/3] forget: refuse deleting the last snapshot in a snapshot group `--keep-tag invalid-tag` was previously able to wipe all snapshots in a repository. As a user specified a `--keep-*` option this is likely unintentional. This forbid deleting all snapshot if a `--keep-*` option was specified to prevent data loss. (Not specifying such an option currently also causes the command to abort) --- cmd/restic/cmd_forget.go | 5 +++++ internal/restic/snapshot_find.go | 4 ++-- internal/restic/snapshot_group.go | 14 ++++++++++++++ 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/cmd/restic/cmd_forget.go b/cmd/restic/cmd_forget.go index d634576c0..6810afbd8 100644 --- a/cmd/restic/cmd_forget.go +++ b/cmd/restic/cmd_forget.go @@ -3,6 +3,7 @@ package main import ( "context" "encoding/json" + "fmt" "io" "strconv" @@ -240,6 +241,10 @@ func runForget(ctx context.Context, opts ForgetOptions, pruneOptions PruneOption keep, remove, reasons := restic.ApplyPolicy(snapshotGroup, policy) + if !policy.Empty() && len(keep) == 0 { + return fmt.Errorf("refusing to delete last snapshot of snapshot group %v", key) + } + if len(keep) != 0 && !gopts.Quiet && !gopts.JSON { Printf("keep %d snapshots:\n", len(keep)) PrintSnapshots(globalOptions.stdout, keep, reasons, opts.Compact) diff --git a/internal/restic/snapshot_find.go b/internal/restic/snapshot_find.go index cb761aee3..6d1ab9a7a 100644 --- a/internal/restic/snapshot_find.go +++ b/internal/restic/snapshot_find.go @@ -24,7 +24,7 @@ type SnapshotFilter struct { TimestampLimit time.Time } -func (f *SnapshotFilter) empty() bool { +func (f *SnapshotFilter) Empty() bool { return len(f.Hosts)+len(f.Tags)+len(f.Paths) == 0 } @@ -173,7 +173,7 @@ func (f *SnapshotFilter) FindAll(ctx context.Context, be Lister, loader LoaderUn } // Give the user some indication their filters are not used. - if !usedFilter && !f.empty() { + if !usedFilter && !f.Empty() { return fn("filters", nil, errors.Errorf("explicit snapshot ids are given")) } return nil diff --git a/internal/restic/snapshot_group.go b/internal/restic/snapshot_group.go index 964a230b3..f4e1ed384 100644 --- a/internal/restic/snapshot_group.go +++ b/internal/restic/snapshot_group.go @@ -66,6 +66,20 @@ type SnapshotGroupKey struct { Tags []string `json:"tags"` } +func (s *SnapshotGroupKey) String() string { + var parts []string + if s.Hostname != "" { + parts = append(parts, fmt.Sprintf("host %v", s.Hostname)) + } + if len(s.Paths) != 0 { + parts = append(parts, fmt.Sprintf("path %v", s.Paths)) + } + if len(s.Tags) != 0 { + parts = append(parts, fmt.Sprintf("tags %v", s.Tags)) + } + return strings.Join(parts, ", ") +} + // GroupSnapshots takes a list of snapshots and a grouping criteria and creates // a grouped list of snapshots. func GroupSnapshots(snapshots Snapshots, groupBy SnapshotGroupByOptions) (map[string]Snapshots, bool, error) { From d8ba7c0889ad67f4990b66500bd7b1806f8373df Mon Sep 17 00:00:00 2001 From: Michael Eischer Date: Sun, 14 Apr 2024 22:46:49 +0200 Subject: [PATCH 2/3] forget: return error if no policy was specified --- cmd/restic/cmd_forget.go | 82 +++++++++++++++++++--------------------- 1 file changed, 39 insertions(+), 43 deletions(-) diff --git a/cmd/restic/cmd_forget.go b/cmd/restic/cmd_forget.go index 6810afbd8..403ef1ea3 100644 --- a/cmd/restic/cmd_forget.go +++ b/cmd/restic/cmd_forget.go @@ -210,62 +210,58 @@ func runForget(ctx context.Context, opts ForgetOptions, pruneOptions PruneOption Tags: opts.KeepTags, } - if policy.Empty() && len(args) == 0 { - if !gopts.JSON { - Verbosef("no policy was specified, no snapshots will be removed\n") - } + if policy.Empty() { + return fmt.Errorf("no policy was specified, no snapshots will be removed") } - if !policy.Empty() { - if !gopts.JSON { - Verbosef("Applying Policy: %v\n", policy) - } + if !gopts.JSON { + Verbosef("Applying Policy: %v\n", policy) + } - for k, snapshotGroup := range snapshotGroups { - if gopts.Verbose >= 1 && !gopts.JSON { - err = PrintSnapshotGroupHeader(globalOptions.stdout, k) - if err != nil { - return err - } - } - - var key restic.SnapshotGroupKey - if json.Unmarshal([]byte(k), &key) != nil { + for k, snapshotGroup := range snapshotGroups { + if gopts.Verbose >= 1 && !gopts.JSON { + err = PrintSnapshotGroupHeader(globalOptions.stdout, k) + if err != nil { return err } + } - var fg ForgetGroup - fg.Tags = key.Tags - fg.Host = key.Hostname - fg.Paths = key.Paths + var key restic.SnapshotGroupKey + if json.Unmarshal([]byte(k), &key) != nil { + return err + } - keep, remove, reasons := restic.ApplyPolicy(snapshotGroup, policy) + var fg ForgetGroup + fg.Tags = key.Tags + fg.Host = key.Hostname + fg.Paths = key.Paths - if !policy.Empty() && len(keep) == 0 { - return fmt.Errorf("refusing to delete last snapshot of snapshot group %v", key) - } + keep, remove, reasons := restic.ApplyPolicy(snapshotGroup, policy) - if len(keep) != 0 && !gopts.Quiet && !gopts.JSON { - Printf("keep %d snapshots:\n", len(keep)) - PrintSnapshots(globalOptions.stdout, keep, reasons, opts.Compact) - Printf("\n") - } - fg.Keep = asJSONSnapshots(keep) + if !policy.Empty() && len(keep) == 0 { + return fmt.Errorf("refusing to delete last snapshot of snapshot group %v", key) + } - if len(remove) != 0 && !gopts.Quiet && !gopts.JSON { - Printf("remove %d snapshots:\n", len(remove)) - PrintSnapshots(globalOptions.stdout, remove, nil, opts.Compact) - Printf("\n") - } - fg.Remove = asJSONSnapshots(remove) + if len(keep) != 0 && !gopts.Quiet && !gopts.JSON { + Printf("keep %d snapshots:\n", len(keep)) + PrintSnapshots(globalOptions.stdout, keep, reasons, opts.Compact) + Printf("\n") + } + fg.Keep = asJSONSnapshots(keep) - fg.Reasons = asJSONKeeps(reasons) + if len(remove) != 0 && !gopts.Quiet && !gopts.JSON { + Printf("remove %d snapshots:\n", len(remove)) + PrintSnapshots(globalOptions.stdout, remove, nil, opts.Compact) + Printf("\n") + } + fg.Remove = asJSONSnapshots(remove) - jsonGroups = append(jsonGroups, &fg) + fg.Reasons = asJSONKeeps(reasons) - for _, sn := range remove { - removeSnIDs.Insert(*sn.ID()) - } + jsonGroups = append(jsonGroups, &fg) + + for _, sn := range remove { + removeSnIDs.Insert(*sn.ID()) } } } From 4e17d8cfb75594d001e558c090c20316780c5285 Mon Sep 17 00:00:00 2001 From: Michael Eischer Date: Mon, 15 Apr 2024 22:02:14 +0200 Subject: [PATCH 3/3] forget: Add --unsafe-allow-remove-all option To prevent accidentally wiping all snapshots from a repository, that option can only be used if either a snapshot filter or a keep policy is specified. Essentially, the option allows `forget --tag something --unsafe-allow-remove-all` calls to remove all snapshots with a specific tag. --- cmd/restic/cmd_forget.go | 12 +++++++++++- internal/restic/snapshot_policy.go | 16 +++++----------- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/cmd/restic/cmd_forget.go b/cmd/restic/cmd_forget.go index 403ef1ea3..9d83d9d07 100644 --- a/cmd/restic/cmd_forget.go +++ b/cmd/restic/cmd_forget.go @@ -89,6 +89,8 @@ type ForgetOptions struct { WithinYearly restic.Duration KeepTags restic.TagLists + UnsafeAllowRemoveAll bool + restic.SnapshotFilter Compact bool @@ -118,6 +120,7 @@ func init() { f.VarP(&forgetOptions.WithinMonthly, "keep-within-monthly", "", "keep monthly snapshots that are newer than `duration` (eg. 1y5m7d2h) relative to the latest snapshot") f.VarP(&forgetOptions.WithinYearly, "keep-within-yearly", "", "keep yearly snapshots that are newer than `duration` (eg. 1y5m7d2h) relative to the latest snapshot") f.Var(&forgetOptions.KeepTags, "keep-tag", "keep snapshots with this `taglist` (can be specified multiple times)") + f.BoolVar(&forgetOptions.UnsafeAllowRemoveAll, "unsafe-allow-remove-all", false, "allow deleting all snapshots of a snapshot group") initMultiSnapshotFilter(f, &forgetOptions.SnapshotFilter, false) f.StringArrayVar(&forgetOptions.Hosts, "hostname", nil, "only consider snapshots with the given `hostname` (can be specified multiple times)") @@ -211,7 +214,14 @@ func runForget(ctx context.Context, opts ForgetOptions, pruneOptions PruneOption } if policy.Empty() { - return fmt.Errorf("no policy was specified, no snapshots will be removed") + if opts.UnsafeAllowRemoveAll { + if opts.SnapshotFilter.Empty() { + return errors.Fatal("--unsafe-allow-remove-all is not allowed unless a snapshot filter option is specified") + } + // UnsafeAllowRemoveAll together with snapshot filter is fine + } else { + return errors.Fatal("no policy was specified, no snapshots will be removed") + } } if !gopts.JSON { diff --git a/internal/restic/snapshot_policy.go b/internal/restic/snapshot_policy.go index 0ff0c5ec8..950c26c91 100644 --- a/internal/restic/snapshot_policy.go +++ b/internal/restic/snapshot_policy.go @@ -94,7 +94,11 @@ func (e ExpirePolicy) String() (s string) { s += fmt.Sprintf("all snapshots within %s of the newest", e.Within) } - s = "keep " + s + if s == "" { + s = "remove" + } else { + s = "keep " + s + } return s } @@ -186,16 +190,6 @@ func ApplyPolicy(list Snapshots, p ExpirePolicy) (keep, remove Snapshots, reason // sort newest snapshots first sort.Stable(list) - if p.Empty() { - for _, sn := range list { - reasons = append(reasons, KeepReason{ - Snapshot: sn, - Matches: []string{"policy is empty"}, - }) - } - return list, remove, reasons - } - if len(list) == 0 { return list, nil, nil }