parseDuration fell back to an unanchored search for number-and-unit pieces when time.ParseDuration failed, and skipped everything in between. 1.0y became 0, so `snapshot create --prune --keep-newer-than 1.0y` deleted every snapshot of the backed-up names, the new one included. 2.1w became one week and 1,5y five years. The fallback now requires the whole input to be whole-number-and-unit parts with nothing between them. A bare number is rejected before time.ParseDuration sees it, since Go reads 0 and +0 as zero with no unit. Judgement call: a space between number and unit (`30 days`) was accepted and is now an error, matching Go's own units. Model: opus-5-5
196 lines
6.2 KiB
Go
196 lines
6.2 KiB
Go
package vaultik
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/dustin/go-humanize"
|
|
"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
|
|
)
|
|
|
|
// Snapshot IDs split on "_" into hostname, optional name parts, and a
|
|
// trailing timestamp.
|
|
const (
|
|
minSnapshotIDParts = 2
|
|
minSnapshotIDNameParts = 3
|
|
)
|
|
|
|
// 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.
|
|
// Format: hostname_snapshotname_timestamp — the middle part(s) between hostname
|
|
// and the RFC3339 timestamp are the snapshot name (may contain underscores).
|
|
// Returns the snapshot name, or empty string if the ID is malformed.
|
|
func parseSnapshotName(snapshotID string) string {
|
|
parts := strings.Split(snapshotID, "_")
|
|
if len(parts) < minSnapshotIDNameParts {
|
|
// Format: hostname_timestamp — no snapshot name
|
|
return ""
|
|
}
|
|
// Format: hostname_name_timestamp — middle parts are the name.
|
|
// The last part is the RFC3339 timestamp, the first part is the hostname,
|
|
// everything in between is the snapshot name (which may itself contain underscores).
|
|
return strings.Join(parts[1:len(parts)-1], "_")
|
|
}
|
|
|
|
// 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, and a negative value 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)
|
|
}
|
|
|
|
unit := strings.ToLower(match[2])
|
|
switch unit {
|
|
case "d", "day", "days":
|
|
total += time.Duration(n) * day
|
|
case "w", "week", "weeks":
|
|
total += time.Duration(n) * week
|
|
case "mo", "month", "months":
|
|
total += time.Duration(n) * month
|
|
case "y", "year", "years":
|
|
total += time.Duration(n) * year
|
|
default:
|
|
return 0, fmt.Errorf("%w %q", errUnknownTimeUnit, unit)
|
|
}
|
|
}
|
|
|
|
return total, nil
|
|
}
|