Files
vaultik/internal/vaultik/helpers.go
T
clawbot c9a519c233
check / check (push) Failing after 3m36s
Reject a retention duration longer than about 292 years (closes #289)
parseDuration multiplied each number by the length of its unit with no
limit, so from 293y (or 15251w, 3559mo, 106752d, or a sum such as
200y200y) the result wrapped to a negative duration. The purge cutoff
then lay in the future and every snapshot of the name was selected, for
--older-than and --keep-newer-than alike. Each part is now checked
before it is added, and a total past the largest time.Duration is
rejected as an invalid duration, so nothing is deleted. Go units were
already safe: time.ParseDuration rejects an overflow.

With --prune the backup still runs before the duration is read; only
the prune step fails.

Model: opus-5-5
2026-10-13 01:51:24 +02:00

217 lines
6.6 KiB
Go

package vaultik
import (
"errors"
"fmt"
"math"
"regexp"
"strconv"
"strings"
"time"
"github.com/dustin/go-humanize"
"sneak.berlin/go/vaultik/internal/snapshot"
"sneak.berlin/go/vaultik/internal/types"
)
// percentScale converts a 0..1 ratio into a percentage.
const percentScale = 100
// progressLogEvery is how many processed items pass between progress
// log lines in long-running loops.
const progressLogEvery = 100
// ubytes renders a byte count with humanize.Bytes, clamping negative
// values to zero so the int64→uint64 conversion cannot overflow.
func ubytes(n int64) string {
if n < 0 {
n = 0
}
return humanize.Bytes(uint64(n))
}
// Sentinel errors for snapshot ID and duration parsing.
var (
errMalformedSnapshotID = errors.New(
"invalid snapshot ID format: expected hostname_snapshotname_timestamp")
errInvalidDuration = errors.New("invalid duration")
errUnknownTimeUnit = errors.New("unknown time unit")
errNegativeDuration = errors.New("negative durations are not supported")
)
// Time-unit lengths used by parseDuration.
const (
day = 24 * time.Hour
week = 7 * day
month = 30 * day
year = 365 * day
)
// maxDuration is the largest value a time.Duration can hold, about 292
// years.
const maxDuration = time.Duration(math.MaxInt64)
// A snapshot ID split on "_" has at least a hostname and a trailing
// timestamp.
const minSnapshotIDParts = 2
// SnapshotInfo contains information about a snapshot.
//
// LocallyTracked says which of the two sources this row came from, and
// therefore which fields are meaningful:
//
// - true: the snapshot is in the local index. ID is its human
// snapshot ID and UncompressedSize/NewChunkSize are populated.
// - false: the snapshot was found only on the destination store. ID
// is empty, because the human ID cannot be recovered from remote
// storage without the age secret key (see RemoteKey), and
// UncompressedSize/NewChunkSize are zero because they are computed
// from local index rows that do not exist.
//
// RemoteKey is always populated: for a locally tracked snapshot it is
// the key the snapshot would occupy on the destination store, and for a
// remote-only snapshot it is the only identifier available.
//
// RemotePresent reports whether the snapshot's metadata was seen on the
// destination store. It is nil when the destination could not be
// listed, so "absent" and "unknown" stay distinguishable.
//
//nolint:tagliatelle // snake_case is the established output format
type SnapshotInfo struct {
ID types.SnapshotID `json:"id"`
RemoteKey string `json:"remote_key"`
Timestamp time.Time `json:"timestamp"`
CompressedSize int64 `json:"compressed_size"`
UncompressedSize int64 `json:"uncompressed_size,omitempty"`
NewChunkSize int64 `json:"new_chunk_size,omitempty"`
LocallyTracked bool `json:"locally_tracked"`
RemotePresent *bool `json:"remote_present"`
}
// formatBytes formats bytes in a human-readable format
func formatBytes(bytes int64) string {
const unit = 1024
if bytes < unit {
return fmt.Sprintf("%d B", bytes)
}
div, exp := int64(unit), 0
for n := bytes / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
}
// parseSnapshotTimestamp extracts the timestamp from a snapshot ID
// Format: hostname_snapshotname_2026-01-12T14:41:15Z
func parseSnapshotTimestamp(snapshotID string) (time.Time, error) {
parts := strings.Split(snapshotID, "_")
if len(parts) < minSnapshotIDParts {
return time.Time{}, errMalformedSnapshotID
}
// Last part is the RFC3339 timestamp
timestampStr := parts[len(parts)-1]
timestamp, err := time.Parse(time.RFC3339, timestampStr)
if err != nil {
return time.Time{}, fmt.Errorf("invalid timestamp: %w", err)
}
return timestamp.UTC(), nil
}
// parseSnapshotName extracts the snapshot name from a snapshot ID of the
// form hostname_name_timestamp, given the hostname stored with that
// snapshot. The hostname and the name may both contain underscores, so the
// name is what is left after removing the short hostname and its "_" from
// the front and the last "_" and the timestamp from the end. Returns "" for
// an ID with no name (hostname_timestamp), and for an ID that does not start
// with that hostname, which CreateSnapshotWithName never writes.
func parseSnapshotName(snapshotID, hostname string) string {
prefix := snapshot.ShortHostname(hostname) + "_"
rest, ok := strings.CutPrefix(snapshotID, prefix)
if !ok {
return ""
}
end := strings.LastIndex(rest, "_")
if end < 0 {
return ""
}
return rest[:end]
}
// parseDuration parses a duration string with support for human-friendly units:
// d/day/days, w/week/weeks, mo/month/months, y/year/years, plus standard Go
// duration units. Following Go, m is minutes and mo is months. A bare number,
// an unknown unit, a negative value, and a total longer than maxDuration are
// all rejected. Outside the Go units the input must be whole numbers each
// followed directly by a unit (2w3d), so 1.0y and 30 days are errors;
// decimals work only in Go units (1.5h).
func parseDuration(s string) (time.Duration, error) {
if strings.HasPrefix(strings.TrimSpace(s), "-") {
return 0, errNegativeDuration
}
// A bare number has no unit, but time.ParseDuration accepts 0, which
// would put the cutoff at now and select every snapshot.
_, err := strconv.ParseFloat(s, 64)
if err == nil {
return 0, fmt.Errorf("%w: %q", errInvalidDuration, s)
}
d, err := time.ParseDuration(s)
if err == nil {
return d, nil
}
if !regexp.MustCompile(`^(\d+[a-zA-Z]+)+$`).MatchString(s) {
return 0, fmt.Errorf("%w: %q", errInvalidDuration, s)
}
re := regexp.MustCompile(`(\d+)([a-zA-Z]+)`)
matches := re.FindAllStringSubmatch(s, -1)
var total time.Duration
for _, match := range matches {
n, err := strconv.Atoi(match[1])
if err != nil {
return 0, fmt.Errorf("invalid number %q: %w", match[1], err)
}
var unitLength time.Duration
unit := strings.ToLower(match[2])
switch unit {
case "d", "day", "days":
unitLength = day
case "w", "week", "weeks":
unitLength = week
case "mo", "month", "months":
unitLength = month
case "y", "year", "years":
unitLength = year
default:
return 0, fmt.Errorf("%w %q", errUnknownTimeUnit, unit)
}
// Past maxDuration the sum wraps to a negative value, which would
// put the purge cutoff in the future.
if time.Duration(n) > (maxDuration-total)/unitLength {
return 0, fmt.Errorf("%w: %q is longer than about 292 years",
errInvalidDuration, s)
}
total += time.Duration(n) * unitLength
}
return total, nil
}