Initial commit
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
// Package config holds the server's runtime options and the flag plumbing that
|
||||
// populates them.
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config is the fully-resolved server configuration.
|
||||
type Config struct {
|
||||
Listen string
|
||||
DataDir string
|
||||
BasePath string // normalised: always "/" or "/prefix/"
|
||||
PublicURL string // absolute origin+path for generated links; "" => relative
|
||||
|
||||
MaxSize int64 // per-upload cap; Unlimited means no cap
|
||||
MaxExpiry time.Duration // longest lifetime a caller may request
|
||||
DefaultExpiry time.Duration // lifetime when the caller does not ask
|
||||
MaxTotalBytes int64 // whole-store quota
|
||||
MinFreeBytes int64 // refuse uploads below this much free disk
|
||||
|
||||
TokensPath string
|
||||
TrustedProxy string // comma-separated CIDRs allowed to set X-Forwarded-For
|
||||
SweepInterval time.Duration
|
||||
|
||||
UploadRate int // uploads per hour per client
|
||||
UploadBurst int
|
||||
MaxConcurrent int
|
||||
trustedProxyNets []*net.IPNet
|
||||
}
|
||||
|
||||
const EnvPrefix = "SEND_"
|
||||
|
||||
// Register wires every option onto s. Short forms exist only for the options
|
||||
// reached often; everything else is long-only, by design.
|
||||
func (c *Config) Register(s *Set) {
|
||||
s.String(&c.Listen, "listen", "l", "127.0.0.1:8080", "ADDR",
|
||||
"address to listen on; keep it on loopback behind a reverse proxy")
|
||||
s.String(&c.DataDir, "data", "d", "./data", "DIR",
|
||||
"directory holding uploaded objects and their metadata")
|
||||
s.String(&c.BasePath, "base-url", "b", "/", "PATH",
|
||||
"path prefix this service is mounted under")
|
||||
s.String(&c.PublicURL, "public-url", "u", "", "URL",
|
||||
"absolute base URL used in generated links; relative links when empty")
|
||||
|
||||
s.Size(&c.MaxSize, "max-size", "s", "2GiB",
|
||||
"largest upload accepted from an anonymous caller")
|
||||
s.Duration(&c.MaxExpiry, "max-expiry", "e", "3d",
|
||||
"longest lifetime an anonymous caller may request")
|
||||
s.Duration(&c.DefaultExpiry, "default-expiry", "", "3d",
|
||||
"lifetime applied when the caller does not ask for one")
|
||||
|
||||
s.Size(&c.MaxTotalBytes, "max-total-bytes", "", "unlimited",
|
||||
"refuse uploads once stored data exceeds this total")
|
||||
s.Size(&c.MinFreeBytes, "min-free-bytes", "", "1GiB",
|
||||
"refuse uploads when the filesystem has less free space than this")
|
||||
s.String(&c.TokensPath, "tokens", "", "", "FILE",
|
||||
"token file location (default <data>/tokens.json)")
|
||||
s.String(&c.TrustedProxy, "trusted-proxy", "", "", "CIDRS",
|
||||
"comma-separated networks whose X-Forwarded-For header is believed")
|
||||
s.Duration(&c.SweepInterval, "sweep-interval", "", "1m",
|
||||
"how often expired objects are swept from disk")
|
||||
|
||||
s.Int(&c.UploadRate, "upload-rate", "", 60,
|
||||
"uploads permitted per hour per client address")
|
||||
s.Int(&c.UploadBurst, "upload-burst", "", 10,
|
||||
"uploads permitted back-to-back before the rate applies")
|
||||
s.Int(&c.MaxConcurrent, "max-concurrent", "", 8,
|
||||
"uploads allowed to be in flight at once")
|
||||
}
|
||||
|
||||
// Normalise validates interdependent options and canonicalises the derived
|
||||
// ones. It must be called after parsing and before the config is used.
|
||||
func (c *Config) Normalise() error {
|
||||
c.BasePath = NormalisePath(c.BasePath)
|
||||
|
||||
if c.PublicURL != "" {
|
||||
u, err := url.Parse(c.PublicURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("--public-url: %w", err)
|
||||
}
|
||||
if !u.IsAbs() {
|
||||
return fmt.Errorf("--public-url: %q is not absolute", c.PublicURL)
|
||||
}
|
||||
c.PublicURL = strings.TrimSuffix(u.String(), "/")
|
||||
}
|
||||
|
||||
if c.TokensPath == "" {
|
||||
c.TokensPath = c.DataDir + "/tokens.json"
|
||||
}
|
||||
if c.MaxExpiry != Unlimited && (c.DefaultExpiry == Unlimited || c.DefaultExpiry > c.MaxExpiry) {
|
||||
return fmt.Errorf("--default-expiry (%s) exceeds --max-expiry (%s)",
|
||||
FormatDuration(c.DefaultExpiry), FormatDuration(c.MaxExpiry))
|
||||
}
|
||||
if c.SweepInterval <= 0 {
|
||||
return fmt.Errorf("--sweep-interval must be positive")
|
||||
}
|
||||
if c.MaxConcurrent < 1 {
|
||||
return fmt.Errorf("--max-concurrent must be at least 1")
|
||||
}
|
||||
|
||||
for _, cidr := range strings.Split(c.TrustedProxy, ",") {
|
||||
cidr = strings.TrimSpace(cidr)
|
||||
if cidr == "" {
|
||||
continue
|
||||
}
|
||||
// Accept both a bare address and a network.
|
||||
if ip := net.ParseIP(cidr); ip != nil {
|
||||
bits := 32
|
||||
if ip.To4() == nil {
|
||||
bits = 128
|
||||
}
|
||||
c.trustedProxyNets = append(c.trustedProxyNets,
|
||||
&net.IPNet{IP: ip, Mask: net.CIDRMask(bits, bits)})
|
||||
continue
|
||||
}
|
||||
_, n, err := net.ParseCIDR(cidr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("--trusted-proxy: %w", err)
|
||||
}
|
||||
c.trustedProxyNets = append(c.trustedProxyNets, n)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TrustsProxy reports whether X-Forwarded-For from ip should be believed.
|
||||
func (c *Config) TrustsProxy(ip net.IP) bool {
|
||||
for _, n := range c.trustedProxyNets {
|
||||
if n.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// NormalisePath canonicalises a mount prefix to "/" or "/prefix/".
|
||||
func NormalisePath(p string) string {
|
||||
p = strings.Trim(strings.TrimSpace(p), "/")
|
||||
if p == "" {
|
||||
return "/"
|
||||
}
|
||||
return "/" + p + "/"
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParseSize(t *testing.T) {
|
||||
cases := map[string]int64{
|
||||
"": Unlimited,
|
||||
"0": Unlimited,
|
||||
"unlimited": Unlimited,
|
||||
"512": 512,
|
||||
"512B": 512,
|
||||
"2GiB": 2 << 30,
|
||||
"2 GiB": 2 << 30,
|
||||
"2G": 2 << 30,
|
||||
"500MB": 500_000_000,
|
||||
"1.5MiB": 1572864,
|
||||
"2gib": 2 << 30,
|
||||
}
|
||||
for in, want := range cases {
|
||||
got, err := ParseSize(in)
|
||||
if err != nil {
|
||||
t.Errorf("ParseSize(%q): %v", in, err)
|
||||
continue
|
||||
}
|
||||
if got != want {
|
||||
t.Errorf("ParseSize(%q) = %d, want %d", in, got, want)
|
||||
}
|
||||
}
|
||||
for _, in := range []string{"-1", "lots", "2XiB", "GiB2"} {
|
||||
if _, err := ParseSize(in); err == nil {
|
||||
t.Errorf("ParseSize(%q) accepted an invalid size", in)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDuration(t *testing.T) {
|
||||
cases := map[string]time.Duration{
|
||||
"": Unlimited,
|
||||
"never": Unlimited,
|
||||
"0": Unlimited,
|
||||
"90m": 90 * time.Minute,
|
||||
"72h": 72 * time.Hour,
|
||||
"3d": 72 * time.Hour,
|
||||
"1w": 168 * time.Hour,
|
||||
"1.5d": 36 * time.Hour,
|
||||
}
|
||||
for in, want := range cases {
|
||||
got, err := ParseDuration(in)
|
||||
if err != nil {
|
||||
t.Errorf("ParseDuration(%q): %v", in, err)
|
||||
continue
|
||||
}
|
||||
if got != want {
|
||||
t.Errorf("ParseDuration(%q) = %s, want %s", in, got, want)
|
||||
}
|
||||
}
|
||||
for _, in := range []string{"-3d", "soon", "3days"} {
|
||||
if _, err := ParseDuration(in); err == nil {
|
||||
t.Errorf("ParseDuration(%q) accepted an invalid duration", in)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The formatters are human-facing and round to two decimals, so they only
|
||||
// round-trip exactly for values that land on a unit boundary. Nothing in the
|
||||
// program re-parses their output; this pins the cases that are shown most.
|
||||
func TestFormatRoundTrips(t *testing.T) {
|
||||
for _, n := range []int64{0, 512, 2 << 30, 100 << 20, 1536} {
|
||||
if got, err := ParseSize(FormatSize(n)); err != nil || got != n {
|
||||
t.Errorf("FormatSize(%d) = %q, which parses back to %d (%v)", n, FormatSize(n), got, err)
|
||||
}
|
||||
}
|
||||
for _, d := range []time.Duration{0, time.Hour, 72 * time.Hour, 90 * time.Minute} {
|
||||
if got, err := ParseDuration(FormatDuration(d)); err != nil || got != d {
|
||||
t.Errorf("FormatDuration(%s) = %q, which parses back to %s (%v)", d, FormatDuration(d), got, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The convention is: one hyphen for a letter, two for a word. It is enforced
|
||||
// here because the stdlib flag package treats both forms as the same.
|
||||
func TestHyphenConvention(t *testing.T) {
|
||||
newSet := func() (*Set, *string) {
|
||||
var listen string
|
||||
s := NewSet("test", "SEND_TEST_")
|
||||
s.String(&listen, "listen", "l", "default", "ADDR", "listen address")
|
||||
return s, &listen
|
||||
}
|
||||
|
||||
for _, args := range [][]string{{"--listen", "x"}, {"-l", "x"}, {"--listen=x"}, {"-l=x"}} {
|
||||
s, listen := newSet()
|
||||
if err := s.Parse(args); err != nil {
|
||||
t.Errorf("%v: %v", args, err)
|
||||
continue
|
||||
}
|
||||
if *listen != "x" {
|
||||
t.Errorf("%v: listen = %q", args, *listen)
|
||||
}
|
||||
}
|
||||
|
||||
for _, args := range [][]string{{"-listen", "x"}, {"--l", "x"}, {"-listen=x"}} {
|
||||
s, _ := newSet()
|
||||
err := s.Parse(args)
|
||||
if err == nil {
|
||||
t.Errorf("%v was accepted; it breaks the hyphen convention", args)
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(err.Error(), "hyphen") {
|
||||
t.Errorf("%v: error does not explain the convention: %v", args, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvFallback(t *testing.T) {
|
||||
t.Setenv("SEND_TEST_MAX_SIZE", "4GiB")
|
||||
var size int64
|
||||
s := NewSet("test", "SEND_TEST_")
|
||||
s.Size(&size, "max-size", "s", "2GiB", "cap")
|
||||
if err := s.Parse(nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if size != 4<<30 {
|
||||
t.Errorf("size = %d, want 4GiB from the environment", size)
|
||||
}
|
||||
|
||||
// An explicit flag still wins over the environment.
|
||||
s = NewSet("test", "SEND_TEST_")
|
||||
s.Size(&size, "max-size", "s", "2GiB", "cap")
|
||||
if err := s.Parse([]string{"--max-size", "1GiB"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if size != 1<<30 {
|
||||
t.Errorf("size = %d, want the flag to win", size)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalisePath(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"": "/",
|
||||
"/": "/",
|
||||
"send": "/send/",
|
||||
"/send": "/send/",
|
||||
"/send/": "/send/",
|
||||
"a/b": "/a/b/",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := NormalisePath(in); got != want {
|
||||
t.Errorf("NormalisePath(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultExpiryMustFitWithinMax(t *testing.T) {
|
||||
c := &Config{BasePath: "/", DataDir: "d", SweepInterval: time.Minute, MaxConcurrent: 1,
|
||||
MaxExpiry: time.Hour, DefaultExpiry: 24 * time.Hour}
|
||||
if err := c.Normalise(); err == nil {
|
||||
t.Error("a default longer than the maximum was accepted")
|
||||
}
|
||||
c.DefaultExpiry = Unlimited
|
||||
if err := c.Normalise(); err == nil {
|
||||
t.Error("an unlimited default under a finite maximum was accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Set wraps flag.FlagSet to enforce this project's option convention:
|
||||
// a single hyphen always introduces a single letter (-s), a double hyphen
|
||||
// always introduces a full word (--max-size). The stdlib flag package treats
|
||||
// the two forms as interchangeable, so the convention is enforced here rather
|
||||
// than by the package.
|
||||
type Set struct {
|
||||
fs *flag.FlagSet
|
||||
envPrefix string
|
||||
specs []*spec
|
||||
longs map[string]bool
|
||||
shorts map[string]bool
|
||||
}
|
||||
|
||||
type spec struct {
|
||||
long, short string
|
||||
placeholder string
|
||||
usage string
|
||||
def string
|
||||
}
|
||||
|
||||
func NewSet(name, envPrefix string) *Set {
|
||||
s := &Set{
|
||||
fs: flag.NewFlagSet(name, flag.ContinueOnError),
|
||||
envPrefix: envPrefix,
|
||||
longs: map[string]bool{},
|
||||
shorts: map[string]bool{},
|
||||
}
|
||||
s.fs.Usage = func() {}
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Set) Args() []string { return s.fs.Args() }
|
||||
func (s *Set) SetOutput(w io.Writer) { s.fs.SetOutput(w) }
|
||||
func (s *Set) register(sp *spec) { s.specs = append(s.specs, sp) }
|
||||
func (s *Set) note(long, short string) {
|
||||
s.longs[long] = true
|
||||
if short != "" {
|
||||
s.shorts[short] = true
|
||||
}
|
||||
}
|
||||
|
||||
// envName maps --max-size to SEND_MAX_SIZE.
|
||||
func (s *Set) envName(long string) string {
|
||||
return s.envPrefix + strings.ToUpper(strings.ReplaceAll(long, "-", "_"))
|
||||
}
|
||||
|
||||
func (s *Set) String(p *string, long, short, def, placeholder, usage string) {
|
||||
*p = def
|
||||
s.fs.StringVar(p, long, def, usage)
|
||||
if short != "" {
|
||||
s.fs.StringVar(p, short, def, usage)
|
||||
}
|
||||
s.note(long, short)
|
||||
s.register(&spec{long, short, placeholder, usage, def})
|
||||
}
|
||||
|
||||
func (s *Set) Bool(p *bool, long, short string, def bool, usage string) {
|
||||
*p = def
|
||||
s.fs.BoolVar(p, long, def, usage)
|
||||
if short != "" {
|
||||
s.fs.BoolVar(p, short, def, usage)
|
||||
}
|
||||
s.note(long, short)
|
||||
s.register(&spec{long, short, "", usage, fmt.Sprint(def)})
|
||||
}
|
||||
|
||||
func (s *Set) Int(p *int, long, short string, def int, usage string) {
|
||||
*p = def
|
||||
s.fs.IntVar(p, long, def, usage)
|
||||
if short != "" {
|
||||
s.fs.IntVar(p, short, def, usage)
|
||||
}
|
||||
s.note(long, short)
|
||||
s.register(&spec{long, short, "N", usage, fmt.Sprint(def)})
|
||||
}
|
||||
|
||||
type sizeValue struct{ p *int64 }
|
||||
|
||||
func (v sizeValue) String() string {
|
||||
if v.p == nil {
|
||||
return ""
|
||||
}
|
||||
return FormatSize(*v.p)
|
||||
}
|
||||
|
||||
func (v sizeValue) Set(s string) error {
|
||||
n, err := ParseSize(s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*v.p = n
|
||||
return nil
|
||||
}
|
||||
|
||||
// Size registers a byte-count option accepting "2GiB", "500MB" or "unlimited".
|
||||
func (s *Set) Size(p *int64, long, short, def, usage string) {
|
||||
n, err := ParseSize(def)
|
||||
if err != nil {
|
||||
panic("config: bad default size for --" + long + ": " + err.Error())
|
||||
}
|
||||
*p = n
|
||||
v := sizeValue{p}
|
||||
s.fs.Var(v, long, usage)
|
||||
if short != "" {
|
||||
s.fs.Var(v, short, usage)
|
||||
}
|
||||
s.note(long, short)
|
||||
s.register(&spec{long, short, "SIZE", usage, def})
|
||||
}
|
||||
|
||||
type durationValue struct{ p *time.Duration }
|
||||
|
||||
func (v durationValue) String() string {
|
||||
if v.p == nil {
|
||||
return ""
|
||||
}
|
||||
return FormatDuration(*v.p)
|
||||
}
|
||||
|
||||
func (v durationValue) Set(s string) error {
|
||||
d, err := ParseDuration(s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*v.p = d
|
||||
return nil
|
||||
}
|
||||
|
||||
// Duration registers a time option accepting "3d", "90m" or "never".
|
||||
func (s *Set) Duration(p *time.Duration, long, short, def, usage string) {
|
||||
d, err := ParseDuration(def)
|
||||
if err != nil {
|
||||
panic("config: bad default duration for --" + long + ": " + err.Error())
|
||||
}
|
||||
*p = d
|
||||
v := durationValue{p}
|
||||
s.fs.Var(v, long, usage)
|
||||
if short != "" {
|
||||
s.fs.Var(v, short, usage)
|
||||
}
|
||||
s.note(long, short)
|
||||
s.register(&spec{long, short, "DURATION", usage, def})
|
||||
}
|
||||
|
||||
// checkConvention rejects a long name given with one hyphen, or a short name
|
||||
// given with two. Only names actually registered are considered, so an option
|
||||
// *value* that happens to start with a hyphen is never mistaken for a flag.
|
||||
func (s *Set) checkConvention(args []string) error {
|
||||
for _, a := range args {
|
||||
if a == "--" {
|
||||
return nil
|
||||
}
|
||||
if len(a) < 2 || a[0] != '-' {
|
||||
continue
|
||||
}
|
||||
hyphens := 1
|
||||
name := a[1:]
|
||||
if name[0] == '-' {
|
||||
hyphens = 2
|
||||
name = name[1:]
|
||||
}
|
||||
name, _, _ = strings.Cut(name, "=")
|
||||
switch {
|
||||
case hyphens == 1 && len(name) > 1 && s.longs[name]:
|
||||
return fmt.Errorf("use --%s, not -%s: long options take two hyphens", name, name)
|
||||
case hyphens == 2 && len(name) == 1 && s.shorts[name]:
|
||||
return fmt.Errorf("use -%s, not --%s: short options take one hyphen", name, name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyEnv seeds every option from its SEND_-prefixed environment variable.
|
||||
// Called before parsing so an explicit command-line flag always wins.
|
||||
func (s *Set) applyEnv() error {
|
||||
for _, sp := range s.specs {
|
||||
v, ok := os.LookupEnv(s.envName(sp.long))
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if err := s.fs.Set(sp.long, v); err != nil {
|
||||
return fmt.Errorf("%s: %w", s.envName(sp.long), err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Parse applies environment fallbacks, checks the hyphen convention and then
|
||||
// parses args (which must exclude the program name).
|
||||
func (s *Set) Parse(args []string) error {
|
||||
if err := s.applyEnv(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.checkConvention(args); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.fs.Parse(args)
|
||||
}
|
||||
|
||||
// PrintUsage renders the options in the "-x, --xxx" form the convention implies.
|
||||
func (s *Set) PrintUsage(w io.Writer, header string) {
|
||||
fmt.Fprint(w, header)
|
||||
for _, sp := range s.specs {
|
||||
lead := " "
|
||||
if sp.short != "" {
|
||||
lead = " -" + sp.short + ","
|
||||
}
|
||||
name := lead + " --" + sp.long
|
||||
if sp.placeholder != "" {
|
||||
name += " " + sp.placeholder
|
||||
}
|
||||
fmt.Fprintf(w, "%s\n", name)
|
||||
fmt.Fprintf(w, " %s", sp.usage)
|
||||
if sp.def != "" && sp.def != "false" {
|
||||
fmt.Fprintf(w, " (default %s)", sp.def)
|
||||
}
|
||||
fmt.Fprintf(w, "\n [%s]\n", s.envName(sp.long))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Unlimited is the zero value used by both size and duration limits to mean
|
||||
// "no limit". Callers must therefore always compare against 0 before enforcing.
|
||||
const Unlimited = 0
|
||||
|
||||
var sizeUnits = []struct {
|
||||
suffix string
|
||||
mult int64
|
||||
}{
|
||||
{"KIB", 1 << 10}, {"MIB", 1 << 20}, {"GIB", 1 << 30}, {"TIB", 1 << 40},
|
||||
{"KB", 1e3}, {"MB", 1e6}, {"GB", 1e9}, {"TB", 1e12},
|
||||
{"K", 1 << 10}, {"M", 1 << 20}, {"G", 1 << 30}, {"T", 1 << 40},
|
||||
{"B", 1},
|
||||
}
|
||||
|
||||
// ParseSize accepts "2GiB", "500MB", "1048576", "unlimited" or "0". Binary
|
||||
// units (KiB) and bare "K"/"M"/"G" are powers of 1024; "KB"/"MB"/"GB" are
|
||||
// powers of 1000, per SI.
|
||||
func ParseSize(s string) (int64, error) {
|
||||
t := strings.ToUpper(strings.TrimSpace(s))
|
||||
if t == "" || t == "0" || t == "UNLIMITED" || t == "NONE" {
|
||||
return Unlimited, nil
|
||||
}
|
||||
mult := int64(1)
|
||||
for _, u := range sizeUnits {
|
||||
if strings.HasSuffix(t, u.suffix) {
|
||||
t = strings.TrimSpace(strings.TrimSuffix(t, u.suffix))
|
||||
mult = u.mult
|
||||
break
|
||||
}
|
||||
}
|
||||
n, err := strconv.ParseFloat(t, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid size %q", s)
|
||||
}
|
||||
if n < 0 {
|
||||
return 0, fmt.Errorf("invalid size %q: negative", s)
|
||||
}
|
||||
v := int64(n * float64(mult))
|
||||
if v < 0 {
|
||||
return 0, fmt.Errorf("invalid size %q: overflow", s)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// FormatSize renders a *limit* for humans, where zero means "no limit". Use
|
||||
// FormatBytes for an actual quantity, where zero means zero.
|
||||
func FormatSize(n int64) string {
|
||||
if n == Unlimited {
|
||||
return "unlimited"
|
||||
}
|
||||
return FormatBytes(n)
|
||||
}
|
||||
|
||||
// FormatBytes renders a byte count for humans.
|
||||
func FormatBytes(n int64) string {
|
||||
units := []string{"B", "KiB", "MiB", "GiB", "TiB"}
|
||||
f, i := float64(n), 0
|
||||
for f >= 1024 && i < len(units)-1 {
|
||||
f /= 1024
|
||||
i++
|
||||
}
|
||||
if i == 0 {
|
||||
return fmt.Sprintf("%d B", n)
|
||||
}
|
||||
return strings.TrimSuffix(strconv.FormatFloat(f, 'f', 2, 64), ".00") + " " + units[i]
|
||||
}
|
||||
|
||||
// ParseDuration extends time.ParseDuration with "d" (day) and "w" (week)
|
||||
// suffixes, and maps "never"/"0" to Unlimited. Durations are the one place the
|
||||
// stdlib's vocabulary is genuinely too small: "72h" is a poor way to say "3d".
|
||||
func ParseDuration(s string) (time.Duration, error) {
|
||||
t := strings.ToLower(strings.TrimSpace(s))
|
||||
if t == "" || t == "0" || t == "never" || t == "unlimited" || t == "none" {
|
||||
return Unlimited, nil
|
||||
}
|
||||
// Expand a trailing d/w into hours so time.ParseDuration can finish the job.
|
||||
for suffix, hours := range map[string]float64{"d": 24, "w": 168} {
|
||||
if num, ok := strings.CutSuffix(t, suffix); ok {
|
||||
n, err := strconv.ParseFloat(num, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid duration %q", s)
|
||||
}
|
||||
t = strconv.FormatFloat(n*hours, 'f', -1, 64) + "h"
|
||||
break
|
||||
}
|
||||
}
|
||||
d, err := time.ParseDuration(t)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid duration %q", s)
|
||||
}
|
||||
if d < 0 {
|
||||
return 0, fmt.Errorf("invalid duration %q: negative", s)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// FormatDuration is the human-facing inverse of ParseDuration.
|
||||
func FormatDuration(d time.Duration) string {
|
||||
switch {
|
||||
case d == Unlimited:
|
||||
return "never"
|
||||
case d%(24*time.Hour) == 0:
|
||||
return fmt.Sprintf("%dd", d/(24*time.Hour))
|
||||
case d%time.Hour == 0:
|
||||
return fmt.Sprintf("%dh", d/time.Hour)
|
||||
default:
|
||||
return d.String()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user