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"` } // 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 }