Files
pages/internal/cliutil/input.go
T
2026-08-15 07:13:00 +00:00

132 lines
3.8 KiB
Go

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) }