current commit
261101.4 · main.go splits by purpose
package main

import (
	"net/netip"
	"strings"
	"syscall"
)

type config struct {
	dir     string
	addr    string
	sharing bool
	share   string // the address --share names; "" takes the default route's
	secret  bool
	token   string // the secret path's token, from WWW_SECRET or drawn at random
	ep      netip.AddrPort
	fg      bool
	noopen  bool
	persist bool   // write a persistent lock, so the tree is served after reboot
	debug   bool   // show the build stamp and the index's times
	noBug   bool   // hide the bug button and refuse reports
	cache   string // the memory for cached git answers, eg 32M; "" takes defaultCache
	disk    string // the disk for online repos, eg 4G; "" takes defaultDisk
	public  bool   // serve only online repos, to the open web
	help    bool
	version bool
	restart bool
	force   bool // --restart takes servers of other binaries too
	list    bool
	kill    bool
	commit  string // the commit --commit looks up
}

// usageText is written in docopt notation, except that --share takes its
// HOST optionally, as GNU optional arguments do, which docopt cannot say.
// TestUsageIsDocopt checks it with docopt-ng. A pattern goes on over
// indented lines.
var usageText = strings.NewReplacer("@NAME@", progName, "@ENV@", envPrefix).Replace(`Usage:
  @NAME@ [-p] [--foreground] [--no-open] [-a ADDR] [--debug] [--no-bug]
      [--cache=SIZE] [--disk=SIZE] [--] [DIR]
  @NAME@ [-p] [--foreground] [--no-open] --share[=HOST] [--secret] [--debug]
      [--no-bug] [--cache=SIZE] [--disk=SIZE] [--] [DIR]
  @NAME@ --restart [-f] [--no-open] [--debug] [--no-bug]
      [-a ADDR | --share[=HOST]] [--] [DIR]
  @NAME@ -l [-a ADDR | --share[=HOST]] [--] [DIR]
  @NAME@ --kill [-a ADDR | --share[=HOST]]
  @NAME@ --commit=REV [--] [DIR]
  @NAME@ --public --disk=SIZE (-a ADDR | --share[=HOST]) [--debug]
      [--cache=SIZE]
  @NAME@ -h | --help
  @NAME@ -V | --version

Options:
  -a ADDR, --addr=ADDR  serve on this loopback address, IP or IP:PORT; the
                        port defaults to 80 when @NAME@ may bind it, else
                        8080, and the IP to 127.31.N.1, N = uid - 1000
  --share[=HOST]        share the tree on the address of the default route,
                        or on HOST, an address or name beyond loopback with
                        an optional :PORT, eg 192.168.1.5, 0.0.0.0 or
                        mate.peer.home; anyone who reaches it reads every
                        file in the tree that other users could read
  --secret              serve a shared tree under the token in @ENV@SECRET,
                        or a random one, so only those holding the link
                        reach it
  --foreground          when no server runs on the address yet, serve in the
                        foreground until Ctrl-C
  --no-open             print the URL without opening a browser
  -p, --persist         write a .LOCK instead of a .lock: the tree is served
                        again after a reboot or a stopped server, by the
                        next server on its address or by --restart
  --debug               show what serves debugging: above each page's
                        header, small and faint, the binary's build time,
                        the seconds since, its commit and the page's render
                        time; on the index, each server's build and start
                        and each lock's time; with --restart, on the new
                        servers
  --no-bug              hide the bug button at the foot of each page, which
                        files a numbered bug in this program, not in the
                        tree, with the page and the server's state; a share
                        never shows it; with --restart, on the new servers
  --cache=SIZE          memory a new server may spend on cached git answers,
                        eg 64M; 0 caches nothing [default: 32M]
  --disk=SIZE           disk a new server may fill with online repos, eg 4G;
                        it drops the repos read longest ago [default: 2G]
  --restart             replace every server this binary started with one
                        running it now, serving the same trees, and start
                        one for its persistent trees that have none; with an
                        address from -a or --share, only the server there,
                        whichever binary runs it; with DIR, serve it too and
                        open it unless a page of it was open
  -f, --force           with --restart, take the servers and persistent
                        trees of other binaries too, eg the package's
  -l, --list            print each served tree's directory, URL and the
                        binary that serves it as keyval; with DIR, only the
                        trees at or below it
  --kill                stop every server and print the trees it served,
                        as with --list; with an address from -a or --share,
                        only the server there
  --commit=REV          print as keyval the commit REV names in DIR's repo:
                        its full hash, its stardate and its subject; REV is
                        a stardate, eg 261024.4, a hash or any name git
                        takes
  --public              serve only repos fetched from a forge, never a tree
                        here, in the foreground, to anyone who reaches the
                        address, eg through a proxy on -a; it lists no
                        cache, shows no stop or bug button and opens nothing
  -V, --version         print when the binary was built and its commit
  -h, --help            print this help
`)

// switches maps each long boolean option to its field; shorts names the
// ones with a short form.
func (c *config) switches() map[string]*bool {
	return map[string]*bool{
		"foreground": &c.fg,
		"no-open":    &c.noopen,
		"persist":    &c.persist,
		"debug":      &c.debug,
		"no-bug":     &c.noBug,
		"help":       &c.help,
		"version":    &c.version,
		"restart":    &c.restart,
		"force":      &c.force,
		"list":       &c.list,
		"kill":       &c.kill,
		"public":     &c.public,
	}
}

// -f and -n are reserved for --force and --dry-run in every tool of the
// user's, so --foreground and --no-open have no short form.
var shorts = map[byte]string{'p': "persist", 'h': "help", 'V': "version", 'l': "list", 'f': "force"}

// parseArgs reads options the GNU way. Short ones combine, as in -pl, and
// take a value joined or apart: -aIP or -a IP. Long ones take --addr IP or
// --addr=IP. Options may follow the directory, and "--" ends them.
func parseArgs(args []string) (config, error) {
	var c config
	sw := c.switches()
	var dirs []string
	value := func(i *int, opt, joined string) (string, error) {
		if joined != "" {
			return joined, nil
		}
		if *i+1 == len(args) {
			return "", &errno{syscall.EINVAL, "option " + opt + " needs a value"}
		}
		*i++
		return args[*i], nil
	}
	for i := 0; i < len(args); i++ {
		a := args[i]
		switch {
		case a == "--":
			dirs = append(dirs, args[i+1:]...)
			i = len(args)
		case strings.HasPrefix(a, "--"):
			name, val, hasVal := strings.Cut(a[2:], "=")
			if name == "addr" {
				if hasVal && val == "" {
					return c, &errno{syscall.EINVAL, "option --addr needs a value"}
				}
				v, err := value(&i, "--addr", val)
				if err != nil {
					return c, err
				}
				c.addr = v
				continue
			}
			// --share, like --secret, takes its address only joined; alone
			// it takes the default route's.
			if name == "share" {
				if hasVal && val == "" {
					return c, &errno{syscall.EINVAL, "option --share= needs an address"}
				}
				c.sharing, c.share = true, val
				continue
			}
			// --secret takes no token: one on the command line shows in
			// ps to every local user and stays in shell history.
			if name == "secret" {
				if hasVal {
					return c, &errno{syscall.EINVAL, "--secret takes no token, since ps shows it; set " + secretEnv}
				}
				c.secret = true
				continue
			}
			if name == "commit" {
				v, err := value(&i, "--commit", val)
				if err != nil {
					return c, err
				}
				if v == "" {
					return c, &errno{syscall.EINVAL, "option --commit needs a value"}
				}
				c.commit = v
				continue
			}
			if name == "cache" || name == "disk" {
				v, err := value(&i, "--"+name, val)
				if err != nil {
					return c, err
				}
				if _, err := parseSize(v); err != nil {
					return c, err
				}
				if name == "cache" {
					c.cache = v
				} else {
					c.disk = v
				}
				continue
			}
			p, ok := sw[name]
			switch {
			case !ok:
				return c, &errno{syscall.EINVAL, "unknown option --" + name}
			case hasVal:
				return c, &errno{syscall.EINVAL, "option --" + name + " takes no value"}
			}
			*p = true
		case len(a) > 1 && a[0] == '-':
			for j := 1; j < len(a); j++ {
				if a[j] == 'a' {
					v, err := value(&i, "-a", a[j+1:])
					if err != nil {
						return c, err
					}
					c.addr = v
					break
				}
				name, ok := shorts[a[j]]
				if !ok {
					return c, &errno{syscall.EINVAL, "unknown option -" + string(a[j])}
				}
				*sw[name] = true
			}
		default:
			dirs = append(dirs, a)
		}
	}
	if c.commit != "" && (c.restart || c.list || c.kill || c.fg || c.noopen || c.persist || c.debug || c.noBug ||
		c.sharing || c.secret || c.addr != "" || c.cache != "" || c.disk != "" || c.public) {
		return c, &errno{syscall.EINVAL, "--commit takes a directory and no other option"}
	}
	// A public server runs under a service manager, so it stays in the
	// foreground, opens nothing and files no bugs; it serves no tree.
	if c.public && (c.restart || c.list || c.kill || c.fg || c.noopen || c.persist || c.noBug || c.secret || len(dirs) > 0) {
		return c, &errno{syscall.EINVAL, "--public takes an address, --disk, --cache and --debug, and no directory"}
	}
	if c.public && c.disk == "" {
		return c, &errno{syscall.EINVAL, "--public needs --disk, since any visitor can make it clone"}
	}
	// The default address is the user's own, where a reader's server runs.
	if c.public && c.addr == "" && !c.sharing {
		return c, &errno{syscall.EINVAL, "--public needs -a or --share"}
	}
	if c.force && !c.restart {
		return c, &errno{syscall.EINVAL, "--force goes with --restart"}
	}
	if c.restart && c.fg {
		return c, &errno{syscall.EINVAL, "--restart takes no --foreground"}
	}
	if c.persist && (c.restart || c.list || c.kill) {
		return c, &errno{syscall.EINVAL, "--persist goes with a start, not with --restart, --list or --kill"}
	}
	if c.list && (c.restart || c.fg) {
		return c, &errno{syscall.EINVAL, "--list takes no --restart or --foreground"}
	}
	if c.kill && (len(dirs) > 0 || c.fg || c.restart || c.list) {
		return c, &errno{syscall.EINVAL, "--kill takes no directory, --foreground, --restart or --list"}
	}
	if c.addr != "" && c.sharing {
		return c, &errno{syscall.EINVAL, "-a and --share name one address each; give one"}
	}
	if c.secret && (!c.sharing || c.restart || c.list || c.kill) {
		return c, &errno{syscall.EINVAL, "--secret goes with --share, and not with --restart, --list or --kill"}
	}
	switch len(dirs) {
	case 0:
		// --list without a directory lists every tree, and --restart
		// without one serves no tree beyond those it restarts.
		if !c.list && !c.kill && !c.restart && !c.public {
			c.dir = "."
		}
	case 1:
		c.dir = dirs[0]
	default:
		return c, &errno{syscall.EINVAL, "one directory at most, got " + strings.Join(dirs, " ")}
	}
	return c, nil
}