package cliutil import ( "bufio" "errors" "fmt" "io" "os" "strconv" "strings" "time" ) // ParseDuration accepts Go's duration syntax plus the day, week and year // suffixes an operator actually types for a key lifetime ("90d", "1y"). // // A year is 365 days and a day is 24 hours: no calendar arithmetic, no time // zones, no leap seconds. The value becomes an absolute expiry timestamp on the // client precisely so a difference of a few hours cannot matter. func ParseDuration(s string) (time.Duration, error) { s = strings.TrimSpace(s) if s == "" { return 0, errors.New("empty duration") } var mult time.Duration switch s[len(s)-1] { case 'd': mult = 24 * time.Hour case 'w': mult = 7 * 24 * time.Hour case 'y': mult = 365 * 24 * time.Hour default: d, err := time.ParseDuration(s) if err != nil { return 0, fmt.Errorf("invalid duration %q: use 30s, 5m, 12h, 90d, 2w or 1y", s) } return d, nil } n, err := strconv.ParseFloat(s[:len(s)-1], 64) if err != nil { return 0, fmt.Errorf("invalid duration %q: use 30s, 5m, 12h, 90d, 2w or 1y", s) } return time.Duration(n * float64(mult)), nil } // ParseBytes accepts a plain byte count or one with a unit suffix: "1048576", // "1MiB", "256M", "1.5G". // // K, M, G and T all mean the binary multiple, whether or not the suffix is // spelled with an "i". Disk-quota flags are set to round binary numbers, and a // tool that quietly read "256MB" as 256,000,000 would produce a limit its user // did not ask for. func ParseBytes(s string) (int64, error) { t := strings.TrimSpace(s) if t == "" { return 0, errors.New("empty size") } digits := strings.TrimRight(t, "bBiIkKmMgGtT") // Uppercase before trimming so "MiB", "MIB" and "mib" all reduce to "M". unit := strings.ToUpper(t[len(digits):]) unit = strings.TrimSuffix(unit, "B") unit = strings.TrimSuffix(unit, "I") n, err := strconv.ParseFloat(strings.TrimSpace(digits), 64) if err != nil || n < 0 { return 0, fmt.Errorf("invalid size %q: use a byte count or 256MiB, 2GiB", s) } var mult float64 = 1 switch unit { case "": case "K": mult = 1 << 10 case "M": mult = 1 << 20 case "G": mult = 1 << 30 case "T": mult = 1 << 40 default: return 0, fmt.Errorf("invalid size %q: unknown unit %q", s, unit) } return int64(n * mult), nil } // KeyValue splits a "k=v" argument. The value may contain further '=' signs. func KeyValue(s string) (key, value string, err error) { k, v, ok := strings.Cut(s, "=") if !ok || k == "" { return "", "", fmt.Errorf("expected key=value, got %q", s) } return k, v, nil } // ErrAborted is returned when the user declines a confirmation prompt. var ErrAborted = errors.New("aborted") // Confirm asks for a yes before a destructive operation. // // It refuses rather than prompts when stdin is not a terminal: a pipeline that // blocks forever on a prompt nobody can see is worse than one that fails and // tells the operator to pass --yes. func Confirm(in io.Reader, out io.Writer, prompt string) error { if f, ok := in.(*os.File); ok && !isTerminal(f) { return fmt.Errorf("%w: refusing to prompt for confirmation with no terminal; pass --yes", ErrAborted) } fmt.Fprintf(out, "%s [y/N]: ", prompt) line, err := bufio.NewReader(in).ReadString('\n') if err != nil && line == "" { return ErrAborted } switch strings.ToLower(strings.TrimSpace(line)) { case "y", "yes": return nil } return ErrAborted } // isTerminal reports whether f is a character device. This is the cheap // stdlib-only approximation of a tty check; it is used to decide whether to // prompt and whether to draw progress, never for anything security-relevant. func isTerminal(f *os.File) bool { fi, err := f.Stat() if err != nil { return false } return fi.Mode()&os.ModeCharDevice != 0 } // IsTerminal reports whether f is attached to a terminal. func IsTerminal(f *os.File) bool { return isTerminal(f) }