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") ) // 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 (h, m, s). func parseDuration(s string) (time.Duration, error) { d, err := time.ParseDuration(s) if err == nil { return d, nil } re := regexp.MustCompile(`(\d+)\s*([a-zA-Z]+)`) matches := re.FindAllStringSubmatch(s, -1) if len(matches) == 0 { return 0, fmt.Errorf("%w: %q", errInvalidDuration, s) } 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 }