@ 261101.7

261101.4 · 5735ff673a95384397d923bbcec92a411a6328d1

main.go splits by purpose

main.go held argument parsing, endpoint and token resolution and opening a browser, beside the program's start. They move to args.go, endpoint.go and browser.go, and main_test.go's tests go with the code they cover. main.go keeps main, the reporter, detaching and error codes.

Declarations moved unchanged, as a sorted comparison of every line outside the import blocks confirms. git blame -C -C traces them.

reading declarations

args.go

new file mode 100644@@ -0,0 +1,292 @@+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+}

args_test.go

new file mode 100644@@ -0,0 +1,151 @@+package main++import (+	"errors"+	"os"+	"os/exec"+	"path/filepath"+	"strings"+	"testing"+)++// Options parse the GNU way: short ones combine, values sit joined or apart,+// and options may follow the directory.+func TestParseArgs(t *testing.T) {+	for _, tc := range parseOK {+		got, err := parseArgs(tc.args)+		if err != nil || got != tc.want {+			t.Errorf("%q: %+v, %v; want %+v", tc.args, got, err, tc.want)+		}+	}+	for _, args := range [][]string{+		{"-x"},+		{"-f"},+		{"-n"},+		{"-fn"},+		{"-fg"},+		{"-nolock"},+		{"--no-lock"},+		{"--no-breaks"},+		{"--fg"},+		{"--foreground=yes"},+		{"-a"},+		{"--addr"},+		{"--addr="},+		{"one", "two"},+		{"--restart", "--foreground"},+		{"-l", "--restart"},+		{"--share="},+		{"--share", "a", "b"},+		{"-a", "127.31.1.2", "--share"},+		{"--secret"},+		{"--share", "--secret=k3x9"},+		{"--share", "--secret="},+		{"--restart", "--share", "--secret"},+		{"--kill", "docs"},+		{"--commit"},+		{"--commit="},+		{"--commit=HEAD", "-l"},+		{"--commit=HEAD", "-a", "127.31.1.2"},+		{"--commit=HEAD", "--no-open"},+		{"--kill", "-l"},+		{"--kill", "--restart"},+		{"--kill", "--share", "--secret"},+		{"--cache=lots"},+		{"--cache=-1M"},+		{"--cache"},+		{"--public", "-a", "127.0.0.1"},+		{"--public", "--disk=6G"},+		{"--public", "--disk=6G", "-a", "127.0.0.1", "docs"},+		{"--public", "--disk=6G", "-a", "127.0.0.1", "--foreground"},+		{"--public", "--disk=6G", "-a", "127.0.0.1", "-p"},+		{"--public", "--disk=6G", "--share", "--secret"},+		{"--public", "--disk=6G", "--restart", "-a", "127.0.0.1"},+		{"--commit=HEAD", "--public"},+	} {+		if _, err := parseArgs(args); err == nil {+			t.Errorf("%q: no error", args)+		}+	}+}++// parseOK holds command lines www takes, and what each parses to.+var parseOK = []struct {+	args []string+	want config+}{+	{nil, config{dir: "."}},+	{[]string{"--foreground", "--debug"}, config{dir: ".", fg: true, debug: true}},+	{[]string{"--cache=64M"}, config{dir: ".", cache: "64M"}},+	{[]string{"--cache", "0"}, config{dir: ".", cache: "0"}},+	{[]string{"docs", "--foreground", "--no-open"}, config{dir: "docs", fg: true, noopen: true}},+	{[]string{"-a", "127.31.1.2"}, config{dir: ".", addr: "127.31.1.2"}},+	{[]string{"-a127.31.1.2:9000"}, config{dir: ".", addr: "127.31.1.2:9000"}},+	{[]string{"-pa", "127.31.1.2"}, config{dir: ".", persist: true, addr: "127.31.1.2"}},+	{[]string{"--addr", "127.31.1.2"}, config{dir: ".", addr: "127.31.1.2"}},+	{[]string{"--addr=127.31.1.2"}, config{dir: ".", addr: "127.31.1.2"}},+	{[]string{"--", "-f"}, config{dir: "-f"}},+	{[]string{"-h"}, config{dir: ".", help: true}},+	{[]string{"--help"}, config{dir: ".", help: true}},+	{[]string{"--version"}, config{dir: ".", version: true}},+	{[]string{"-V"}, config{dir: ".", version: true}},+	{[]string{"--restart", "-a", "127.31.1.2"}, config{addr: "127.31.1.2", restart: true}},+	{[]string{"--restart", "-a", "127.31.1.2", "."}, config{dir: ".", addr: "127.31.1.2", restart: true}},+	{[]string{"--restart", "--debug", "--no-bug"}, config{restart: true, debug: true, noBug: true}},+	{[]string{"--disk=4G", "--no-bug", "docs"}, config{dir: "docs", disk: "4G", noBug: true}},+	{[]string{"-l"}, config{list: true}},+	{[]string{"--list", "docs"}, config{dir: "docs", list: true}},+	{[]string{"--share=0.0.0.0"}, config{dir: ".", sharing: true, share: "0.0.0.0"}},+	{[]string{"--share"}, config{dir: ".", sharing: true}},+	{[]string{"--share", "docs"}, config{dir: "docs", sharing: true}},+	{[]string{"--share=10.0.0.1:9000", "--secret", "docs"}, config{dir: "docs", sharing: true, share: "10.0.0.1:9000", secret: true}},+	{[]string{"--restart", "--share=10.0.0.1"}, config{sharing: true, share: "10.0.0.1", restart: true}},+	{[]string{"--kill"}, config{kill: true}},+	{[]string{"--commit=261024.4"}, config{dir: ".", commit: "261024.4"}},+	{[]string{"--commit", "HEAD", "docs"}, config{dir: "docs", commit: "HEAD"}},+	{[]string{"--kill", "--share"}, config{sharing: true, kill: true}},+	{[]string{"--public", "--disk=6G", "-a", "127.0.0.1:8080"}, config{public: true, disk: "6G", addr: "127.0.0.1:8080"}},+	{[]string{"--public", "--disk=6G", "--share=0.0.0.0", "--debug"}, config{public: true, disk: "6G", sharing: true, share: "0.0.0.0", debug: true}},+}++// The usage text is docopt, and docopt takes every command line in+// parseOK. docopt-ng runs through uv, and the test skips without it.+// docopt has no option with an optional value, so --share[=HOST] is+// checked as --share.+func TestUsageIsDocopt(t *testing.T) {+	uv, err := exec.LookPath("uv")+	if err != nil {+		t.Skip("uv is not installed")+	}+	usage := filepath.Join(t.TempDir(), "usage")+	if err := os.WriteFile(usage, []byte(strings.ReplaceAll(usageText, "--share[=HOST]", "--share")), 0o600); err != nil {+		t.Fatal(err)+	}+	var lines strings.Builder+	for _, tc := range parseOK {+		args := make([]string, len(tc.args))+		for i, a := range tc.args {+			if strings.HasPrefix(a, "--share=") {+				a = "--share"+			}+			args[i] = shellQuote(a)+		}+		lines.WriteString(strings.Join(args, " ") + "\n")+	}+	cmd := exec.Command(uv, "run", "--quiet", "--script", "testdata/docopt-check.py", usage)+	cmd.Stdin = strings.NewReader(lines.String())+	out, err := cmd.CombinedOutput()+	var ee *exec.ExitError+	switch {+	case err == nil:+	case errors.As(err, &ee) && ee.ExitCode() == 22:+		t.Errorf("docopt-ng:\n%s", out)+	default:+		t.Skipf("uv could not run docopt-ng: %v\n%s", err, out)+	}+}++// shellQuote quotes s for shlex.split.+func shellQuote(s string) string {+	return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"+}

browser.go

new file mode 100644@@ -0,0 +1,57 @@+package main++import (+	"fmt"+	"net/netip"+	neturl "net/url"+	"os"+	"os/exec"+	"strings"+)++// openBrowser opens url when a display exists. Over SSH there is none, and+// the printed URL has to do.+func openBrowser(url string) {+	if os.Getenv("DISPLAY") == "" && os.Getenv("WAYLAND_DISPLAY") == "" {+		return+	}+	args := openArgs(url, os.Getenv(openEnv))+	cmd := exec.Command(args[0], args[1:]...)+	if err := cmd.Start(); err != nil {+		fmt.Fprintf(os.Stderr, "E%d: cannot open %s with %s: %v\n", errnoOf(err), url, args[0], err)+		return+	}+	go cmd.Wait()+}++// openArgs is the command that opens url. A loopback URL takes WWW_OPEN,+// split at spaces, with each %s replaced by the URL, or the URL appended+// when none holds %s; eg 'brave-browser --app=%s' opens an app window. A+// shared URL, and any URL without the variable, goes to xdg-open.+func openArgs(url, open string) []string {+	fields := strings.Fields(open)+	if len(fields) == 0 || !loopbackURL(url) {+		return []string{"xdg-open", url}+	}+	placed := false+	for i, f := range fields {+		if strings.Contains(f, "%s") {+			fields[i] = strings.ReplaceAll(f, "%s", url)+			placed = true+		}+	}+	if !placed {+		fields = append(fields, url)+	}+	return fields+}++// loopbackURL reports whether u names a loopback host.+func loopbackURL(u string) bool {+	p, err := neturl.Parse(u)+	if err != nil {+		return false+	}+	a, err := netip.ParseAddr(p.Hostname())+	return err == nil && a.IsLoopback() || p.Hostname() == "localhost"+}

browser_test.go

new file mode 100644@@ -0,0 +1,23 @@+package main++import (+	"slices"+	"testing"+)++func TestOpenArgs(t *testing.T) {+	const lo, lan = "http://127.31.1.1/x/", "http://192.168.1.5/x/"+	for _, c := range []struct {+		url, open string+		want      []string+	}{+		{lo, "", []string{"xdg-open", lo}},+		{lo, "brave-browser --app=%s", []string{"brave-browser", "--app=" + lo}},+		{lo, "firefox", []string{"firefox", lo}},+		{lan, "brave-browser --app=%s", []string{"xdg-open", lan}},+	} {+		if got := openArgs(c.url, c.open); !slices.Equal(got, c.want) {+			t.Errorf("openArgs(%q, %q) = %q, want %q", c.url, c.open, got, c.want)+		}+	}+}

endpoint.go

new file mode 100644@@ -0,0 +1,271 @@+package main++import (+	"context"+	"crypto/rand"+	"encoding/base32"+	"errors"+	"fmt"+	"io/fs"+	"net"+	"net/netip"+	"os"+	"strconv"+	"strings"+	"syscall"+	"time"+)++// defaultPort is 8080 until main finds that this process may bind port 80.+var defaultPort uint16 = 8080++// capNetBindService is the capability bit that lets a process bind ports+// below 1024.+const capNetBindService = 10++// lowPortOK reports whether this process may bind port 80: it holds+// CAP_NET_BIND_SERVICE, as the Debian package grants, or the machine lets+// every user bind low ports.+func lowPortOK() bool {+	if b, err := os.ReadFile("/proc/sys/net/ipv4/ip_unprivileged_port_start"); err == nil {+		if n, err := strconv.Atoi(strings.TrimSpace(string(b))); err == nil && n <= 80 {+			return true+		}+	}+	b, err := os.ReadFile("/proc/self/status")+	return err == nil && hasCap(string(b), capNetBindService)+}++// hasCap reads the effective capability set from a /proc status file.+func hasCap(status string, bit uint) bool {+	for _, line := range strings.Split(status, "\n") {+		if v, ok := strings.CutPrefix(line, "CapEff:"); ok {+			caps, err := strconv.ParseUint(strings.TrimSpace(v), 16, 64)+			return err == nil && caps&(1<<bit) != 0+		}+	}+	return false+}++// Environment variables that set the default addresses, the command that+// opens a loopback URL, and a share's chosen token.+var (+	addrEnv   = envPrefix + "ADDR"+	shareEnv  = envPrefix + "SHARE"+	openEnv   = envPrefix + "OPEN"+	secretEnv = envPrefix + "SECRET"+)++// secretToken returns the token in WWW_SECRET, or a random one when it is+// unset. A bad token's error names the variable but not its value, which+// is a secret.+func secretToken() (string, error) {+	v := os.Getenv(secretEnv)+	switch {+	case v == "":+		return newToken(), nil+	case !validToken(v):+		return "", &errno{syscall.EINVAL, secretEnv + " holds a bad token: want letters, digits, - and _"}+	}+	return v, nil+}++// endpointOf returns where c serves. A bare --share takes WWW_SHARE,+// and a start without -a takes WWW_ADDR; --list, --kill and --restart+// without an address cover every server, so WWW_ADDR leaves them be.+// An error from a variable's value names the variable.+func endpointOf(c config, uid int) (netip.AddrPort, error) {+	if c.sharing {+		if c.share == "" && os.Getenv(shareEnv) != "" {+			ep, err := shareEndpoint(os.Getenv(shareEnv))+			return ep, fromEnv(shareEnv, err)+		}+		return shareEndpoint(c.share)+	}+	if c.addr == "" && !c.list && !c.kill && !c.restart && os.Getenv(addrEnv) != "" {+		ep, err := endpointFor(os.Getenv(addrEnv), uid)+		return ep, fromEnv(addrEnv, err)+	}+	return endpointFor(c.addr, uid)+}++func fromEnv(name string, err error) error {+	if err == nil {+		return nil+	}+	return &errno{errnoOf(err), name + "=" + os.Getenv(name) + ": " + err.Error()}+}++// environment is the help's account of the default addresses: each value+// and where it comes from.+func environment(uid int) string {+	addr := "built in"+	if ep, err := endpointFor("", uid); err == nil {+		addr = ep.String() + ", built in"+	}+	if v := os.Getenv(addrEnv); v != "" {+		addr = v + ", from " + addrEnv+	}+	share := "the default route's address"+	if a, err := routeAddr(); err == nil {+		share = a.String() + ", the default route's address"+	}+	if v := os.Getenv(shareEnv); v != "" {+		share = v + ", from " + shareEnv+	}+	open := "xdg-open, built in"+	if v := os.Getenv(openEnv); v != "" {+		open = v + ", from " + openEnv+	}+	secret := "unset, so a random token"+	if os.Getenv(secretEnv) != "" {+		secret = "set"+	}+	return "\nenvironment:\n" ++		"  " + addrEnv + "    default for a start without -a; now " + addr + "\n" ++		"  " + shareEnv + "   default for a bare --share; now " + share + "\n" ++		"  " + openEnv + "    command that opens a loopback URL, %s for the URL or\n" ++		"              appended, eg 'brave-browser --app=%s'; now " + open + "\n" ++		"  " + secretEnv + "  the token for --secret; now " + secret + "\n"+}++// endpointFor returns where to serve: addr when given, else the user's own+// 127.31.N.1 with N = uid - 1000, so users on one machine never collide.+// The port is defaultPort unless addr names one. -a takes only loopback, so+// a typo cannot share a tree; --share goes through shareEndpoint.+func endpointFor(addr string, uid int) (netip.AddrPort, error) {+	if addr == "" {+		n := uid - 1000+		if n < 0 || n > 255 {+			return netip.AddrPort{}, &errno{syscall.EINVAL, fmt.Sprintf("uid %d has no default address; pass --addr", uid)}+		}+		return netip.AddrPortFrom(netip.AddrFrom4([4]byte{127, 31, byte(n), 1}), defaultPort), nil+	}+	ep, err := parseEndpoint(addr, "127.31.1.2")+	if err != nil {+		return ep, err+	}+	if !ep.Addr().IsLoopback() {+		return netip.AddrPort{}, &errno{syscall.EINVAL, "bad address " + addr + ": -a takes a loopback address; --share takes the others"}+	}+	return ep, nil+}++// shareEndpoint returns where to share: any IPv4 address beyond loopback,+// 0.0.0.0 included, on defaultPort unless addr names one. With no addr it+// is the default route's address, so a share needs no address remembered.+func shareEndpoint(addr string) (netip.AddrPort, error) {+	if addr == "" {+		a, err := routeAddr()+		if err != nil {+			return netip.AddrPort{}, err+		}+		return netip.AddrPortFrom(a, defaultPort), nil+	}+	ep, err := parseEndpoint(addr, "192.168.1.5")+	if err != nil {+		if ep, err = resolveEndpoint(addr); err != nil {+			return ep, err+		}+	}+	if ep.Addr().IsLoopback() {+		return netip.AddrPort{}, &errno{syscall.EINVAL, "bad address " + addr + ": --share takes an address beyond loopback; -a takes loopback"}+	}+	return ep, nil+}++// resolveEndpoint reads name[:port] and resolves the name to an IPv4+// address that an interface here holds; only such an address can be bound.+func resolveEndpoint(addr string) (netip.AddrPort, error) {+	name, port := addr, defaultPort+	if h, p, err := net.SplitHostPort(addr); err == nil {+		n, err := strconv.ParseUint(p, 10, 16)+		if err != nil || n == 0 {+			return netip.AddrPort{}, &errno{syscall.EINVAL, "bad port in " + addr + ": want 1 to 65535"}+		}+		name, port = h, uint16(n)+	}+	ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)+	defer cancel()+	ips, err := net.DefaultResolver.LookupNetIP(ctx, "ip4", name)+	if err != nil {+		return netip.AddrPort{}, &errno{syscall.EINVAL, "bad address " + addr + ": want an IPv4 address or a name, eg 192.168.1.5 or mate.peer.home (" + err.Error() + ")"}+	}+	for _, ip := range ips {+		if ifaceOf(ip.Unmap()) != "any" {+			return netip.AddrPortFrom(ip.Unmap(), port), nil+		}+	}+	return netip.AddrPort{}, &errno{syscall.EADDRNOTAVAIL, fmt.Sprintf("%s resolves to %v, which no interface here holds", name, ips)}+}++// spacedShare catches --share 192.168.1.5, where the address became the+// directory: a bare --share with a directory that does not exist but reads+// or resolves as an address. It returns the hint, or "".+func spacedShare(c config) string {+	if !c.sharing || c.share != "" || c.dir == "" {+		return ""+	}+	if _, err := os.Stat(c.dir); !errors.Is(err, fs.ErrNotExist) {+		return ""+	}+	if _, err := parseEndpoint(c.dir, ""); err != nil {+		if _, err := resolveEndpoint(c.dir); errors.Is(err, syscall.EINVAL) {+			return ""+		}+	}+	return "no directory " + c.dir + "; to share on that address, write --share=" + c.dir+}++// routeAddr asks the kernel which source address it would use to reach+// beyond this machine. Connecting a UDP socket picks the route and sends+// nothing. 192.0.2.1 is reserved for documentation, so only the default+// route leads there.+func routeAddr() (netip.Addr, error) {+	conn, err := net.Dial("udp4", "192.0.2.1:9")+	if err != nil {+		return netip.Addr{}, &errno{errnoOf(err), "no default route to share on (" + err.Error() + "); name an address, eg --share=192.168.1.5"}+	}+	defer conn.Close()+	a := conn.LocalAddr().(*net.UDPAddr).AddrPort().Addr().Unmap()+	if a.IsLoopback() {+		return netip.Addr{}, &errno{syscall.ENETUNREACH, "the default route leaves from loopback; name an address, eg --share=192.168.1.5"}+	}+	return a, nil+}++// parseEndpoint reads ip[:port] as an IPv4 address and a port above 0.+func parseEndpoint(addr, example string) (netip.AddrPort, error) {+	ep, err := netip.ParseAddrPort(addr)+	if err != nil {+		a, aerr := netip.ParseAddr(addr)+		if aerr != nil {+			return netip.AddrPort{}, &errno{syscall.EINVAL, "bad address " + addr + ": want eg " + example + " or " + example + ":9000"}+		}+		ep = netip.AddrPortFrom(a, defaultPort)+	}+	if !ep.Addr().Is4() || ep.Port() == 0 {+		return netip.AddrPort{}, &errno{syscall.EINVAL, "bad address " + addr + ": want an IPv4 address and a port above 0"}+	}+	return ep, nil+}++// shared reports whether ep serves beyond this machine.+func shared(ep netip.AddrPort) bool {+	return !ep.Addr().IsLoopback()+}++// newToken draws 128 bits for a secret path, in lower-case base32 so the+// link survives being read aloud or typed.+func newToken() string {+	b := make([]byte, 16)+	rand.Read(b)+	return strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b))+}++// validToken keeps a token to one URL path segment that needs no escaping.+func validToken(s string) bool {+	return s != "" && !strings.ContainsFunc(s, func(c rune) bool {+		return !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '-' || c == '_')+	})+}

endpoint_test.go

new file mode 100644@@ -0,0 +1,39 @@+package main++import (+	"strings"+	"testing"+)++// CAP_NET_BIND_SERVICE is bit 10 of the effective set.+func TestHasCap(t *testing.T) {+	for status, want := range map[string]bool{+		"Name:\t" + progName + "\nCapInh:\t0000000000000000\nCapEff:\t0000000000000400\n": true,+		"CapEff:\t000001ffffffffff\n":                            true,+		"CapPrm:\t0000000000000400\nCapEff:\t0000000000000000\n": false,+		"Name:\t" + progName + "\n":                              false,+	} {+		if got := hasCap(status, capNetBindService); got != want {+			t.Errorf("%q: %v, want %v", status, got, want)+		}+	}+}++// A chosen token comes from WWW_SECRET, never the command line, and a bad+// one's error names the variable without echoing the secret.+func TestSecretToken(t *testing.T) {+	t.Setenv(secretEnv, "k3x9")+	if got, err := secretToken(); err != nil || got != "k3x9" {+		t.Errorf("set: %q, %v; want k3x9", got, err)+	}+	t.Setenv(secretEnv, "a/b")+	if _, err := secretToken(); err == nil || !strings.Contains(err.Error(), secretEnv) || strings.Contains(err.Error(), "a/b") {+		t.Errorf("bad token: %v; want an error naming %s without the token", err, secretEnv)+	}+	t.Setenv(secretEnv, "")+	a, errA := secretToken()+	b, errB := secretToken()+	if errA != nil || errB != nil || a == "" || a == b {+		t.Errorf("unset: %q, %q, %v, %v; want two different random tokens", a, b, errA, errB)+	}+}

list_test.go

@@ -63,3 +63,22 @@ func TestKvValue(t *testing.T) { 		} 	} }++// A tree is within a directory at or above it, never beside it.+func TestWithin(t *testing.T) {+	for _, tc := range []struct {+		dir, root string+		want      bool+	}{+		{"/a/b", "/a/b", true},+		{"/a/b/c", "/a", true},+		{"/a/b", "/", true},+		{"/a/bc", "/a/b", false},+		{"/a", "/a/b", false},+		{"/x/..y", "/x", true},+	} {+		if got := within(tc.dir, tc.root); got != tc.want {+			t.Errorf("within(%q, %q) = %v, want %v", tc.dir, tc.root, got, tc.want)+		}+	}+}

main.go

@@ -6,343 +6,19 @@ package main  import ( 	"bufio"-	"context"-	"crypto/rand"-	"encoding/base32" 	"errors" 	"fmt" 	"io"-	"io/fs"-	"net"-	"net/netip"-	neturl "net/url" 	"os" 	"os/exec" 	"strconv" 	"strings" 	"syscall"-	"time" )  // readyEnv names the descriptor a detached child reports its start on. var readyEnv = envPrefix + "READY_FD" -// defaultPort is 8080 until main finds that this process may bind port 80.-var defaultPort uint16 = 8080--// capNetBindService is the capability bit that lets a process bind ports-// below 1024.-const capNetBindService = 10--// lowPortOK reports whether this process may bind port 80: it holds-// CAP_NET_BIND_SERVICE, as the Debian package grants, or the machine lets-// every user bind low ports.-func lowPortOK() bool {-	if b, err := os.ReadFile("/proc/sys/net/ipv4/ip_unprivileged_port_start"); err == nil {-		if n, err := strconv.Atoi(strings.TrimSpace(string(b))); err == nil && n <= 80 {-			return true-		}-	}-	b, err := os.ReadFile("/proc/self/status")-	return err == nil && hasCap(string(b), capNetBindService)-}--// hasCap reads the effective capability set from a /proc status file.-func hasCap(status string, bit uint) bool {-	for _, line := range strings.Split(status, "\n") {-		if v, ok := strings.CutPrefix(line, "CapEff:"); ok {-			caps, err := strconv.ParseUint(strings.TrimSpace(v), 16, 64)-			return err == nil && caps&(1<<bit) != 0-		}-	}-	return false-}--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-}- func main() { 	c, err := parseArgs(os.Args[1:]) 	switch {@@ -416,229 +92,6 @@ func noteRun() { 	} } -// Environment variables that set the default addresses, the command that-// opens a loopback URL, and a share's chosen token.-var (-	addrEnv   = envPrefix + "ADDR"-	shareEnv  = envPrefix + "SHARE"-	openEnv   = envPrefix + "OPEN"-	secretEnv = envPrefix + "SECRET"-)--// secretToken returns the token in WWW_SECRET, or a random one when it is-// unset. A bad token's error names the variable but not its value, which-// is a secret.-func secretToken() (string, error) {-	v := os.Getenv(secretEnv)-	switch {-	case v == "":-		return newToken(), nil-	case !validToken(v):-		return "", &errno{syscall.EINVAL, secretEnv + " holds a bad token: want letters, digits, - and _"}-	}-	return v, nil-}--// endpointOf returns where c serves. A bare --share takes WWW_SHARE,-// and a start without -a takes WWW_ADDR; --list, --kill and --restart-// without an address cover every server, so WWW_ADDR leaves them be.-// An error from a variable's value names the variable.-func endpointOf(c config, uid int) (netip.AddrPort, error) {-	if c.sharing {-		if c.share == "" && os.Getenv(shareEnv) != "" {-			ep, err := shareEndpoint(os.Getenv(shareEnv))-			return ep, fromEnv(shareEnv, err)-		}-		return shareEndpoint(c.share)-	}-	if c.addr == "" && !c.list && !c.kill && !c.restart && os.Getenv(addrEnv) != "" {-		ep, err := endpointFor(os.Getenv(addrEnv), uid)-		return ep, fromEnv(addrEnv, err)-	}-	return endpointFor(c.addr, uid)-}--func fromEnv(name string, err error) error {-	if err == nil {-		return nil-	}-	return &errno{errnoOf(err), name + "=" + os.Getenv(name) + ": " + err.Error()}-}--// environment is the help's account of the default addresses: each value-// and where it comes from.-func environment(uid int) string {-	addr := "built in"-	if ep, err := endpointFor("", uid); err == nil {-		addr = ep.String() + ", built in"-	}-	if v := os.Getenv(addrEnv); v != "" {-		addr = v + ", from " + addrEnv-	}-	share := "the default route's address"-	if a, err := routeAddr(); err == nil {-		share = a.String() + ", the default route's address"-	}-	if v := os.Getenv(shareEnv); v != "" {-		share = v + ", from " + shareEnv-	}-	open := "xdg-open, built in"-	if v := os.Getenv(openEnv); v != "" {-		open = v + ", from " + openEnv-	}-	secret := "unset, so a random token"-	if os.Getenv(secretEnv) != "" {-		secret = "set"-	}-	return "\nenvironment:\n" +-		"  " + addrEnv + "    default for a start without -a; now " + addr + "\n" +-		"  " + shareEnv + "   default for a bare --share; now " + share + "\n" +-		"  " + openEnv + "    command that opens a loopback URL, %s for the URL or\n" +-		"              appended, eg 'brave-browser --app=%s'; now " + open + "\n" +-		"  " + secretEnv + "  the token for --secret; now " + secret + "\n"-}--// endpointFor returns where to serve: addr when given, else the user's own-// 127.31.N.1 with N = uid - 1000, so users on one machine never collide.-// The port is defaultPort unless addr names one. -a takes only loopback, so-// a typo cannot share a tree; --share goes through shareEndpoint.-func endpointFor(addr string, uid int) (netip.AddrPort, error) {-	if addr == "" {-		n := uid - 1000-		if n < 0 || n > 255 {-			return netip.AddrPort{}, &errno{syscall.EINVAL, fmt.Sprintf("uid %d has no default address; pass --addr", uid)}-		}-		return netip.AddrPortFrom(netip.AddrFrom4([4]byte{127, 31, byte(n), 1}), defaultPort), nil-	}-	ep, err := parseEndpoint(addr, "127.31.1.2")-	if err != nil {-		return ep, err-	}-	if !ep.Addr().IsLoopback() {-		return netip.AddrPort{}, &errno{syscall.EINVAL, "bad address " + addr + ": -a takes a loopback address; --share takes the others"}-	}-	return ep, nil-}--// shareEndpoint returns where to share: any IPv4 address beyond loopback,-// 0.0.0.0 included, on defaultPort unless addr names one. With no addr it-// is the default route's address, so a share needs no address remembered.-func shareEndpoint(addr string) (netip.AddrPort, error) {-	if addr == "" {-		a, err := routeAddr()-		if err != nil {-			return netip.AddrPort{}, err-		}-		return netip.AddrPortFrom(a, defaultPort), nil-	}-	ep, err := parseEndpoint(addr, "192.168.1.5")-	if err != nil {-		if ep, err = resolveEndpoint(addr); err != nil {-			return ep, err-		}-	}-	if ep.Addr().IsLoopback() {-		return netip.AddrPort{}, &errno{syscall.EINVAL, "bad address " + addr + ": --share takes an address beyond loopback; -a takes loopback"}-	}-	return ep, nil-}--// resolveEndpoint reads name[:port] and resolves the name to an IPv4-// address that an interface here holds; only such an address can be bound.-func resolveEndpoint(addr string) (netip.AddrPort, error) {-	name, port := addr, defaultPort-	if h, p, err := net.SplitHostPort(addr); err == nil {-		n, err := strconv.ParseUint(p, 10, 16)-		if err != nil || n == 0 {-			return netip.AddrPort{}, &errno{syscall.EINVAL, "bad port in " + addr + ": want 1 to 65535"}-		}-		name, port = h, uint16(n)-	}-	ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)-	defer cancel()-	ips, err := net.DefaultResolver.LookupNetIP(ctx, "ip4", name)-	if err != nil {-		return netip.AddrPort{}, &errno{syscall.EINVAL, "bad address " + addr + ": want an IPv4 address or a name, eg 192.168.1.5 or mate.peer.home (" + err.Error() + ")"}-	}-	for _, ip := range ips {-		if ifaceOf(ip.Unmap()) != "any" {-			return netip.AddrPortFrom(ip.Unmap(), port), nil-		}-	}-	return netip.AddrPort{}, &errno{syscall.EADDRNOTAVAIL, fmt.Sprintf("%s resolves to %v, which no interface here holds", name, ips)}-}--// spacedShare catches --share 192.168.1.5, where the address became the-// directory: a bare --share with a directory that does not exist but reads-// or resolves as an address. It returns the hint, or "".-func spacedShare(c config) string {-	if !c.sharing || c.share != "" || c.dir == "" {-		return ""-	}-	if _, err := os.Stat(c.dir); !errors.Is(err, fs.ErrNotExist) {-		return ""-	}-	if _, err := parseEndpoint(c.dir, ""); err != nil {-		if _, err := resolveEndpoint(c.dir); errors.Is(err, syscall.EINVAL) {-			return ""-		}-	}-	return "no directory " + c.dir + "; to share on that address, write --share=" + c.dir-}--// routeAddr asks the kernel which source address it would use to reach-// beyond this machine. Connecting a UDP socket picks the route and sends-// nothing. 192.0.2.1 is reserved for documentation, so only the default-// route leads there.-func routeAddr() (netip.Addr, error) {-	conn, err := net.Dial("udp4", "192.0.2.1:9")-	if err != nil {-		return netip.Addr{}, &errno{errnoOf(err), "no default route to share on (" + err.Error() + "); name an address, eg --share=192.168.1.5"}-	}-	defer conn.Close()-	a := conn.LocalAddr().(*net.UDPAddr).AddrPort().Addr().Unmap()-	if a.IsLoopback() {-		return netip.Addr{}, &errno{syscall.ENETUNREACH, "the default route leaves from loopback; name an address, eg --share=192.168.1.5"}-	}-	return a, nil-}--// parseEndpoint reads ip[:port] as an IPv4 address and a port above 0.-func parseEndpoint(addr, example string) (netip.AddrPort, error) {-	ep, err := netip.ParseAddrPort(addr)-	if err != nil {-		a, aerr := netip.ParseAddr(addr)-		if aerr != nil {-			return netip.AddrPort{}, &errno{syscall.EINVAL, "bad address " + addr + ": want eg " + example + " or " + example + ":9000"}-		}-		ep = netip.AddrPortFrom(a, defaultPort)-	}-	if !ep.Addr().Is4() || ep.Port() == 0 {-		return netip.AddrPort{}, &errno{syscall.EINVAL, "bad address " + addr + ": want an IPv4 address and a port above 0"}-	}-	return ep, nil-}--// shared reports whether ep serves beyond this machine.-func shared(ep netip.AddrPort) bool {-	return !ep.Addr().IsLoopback()-}--// newToken draws 128 bits for a secret path, in lower-case base32 so the-// link survives being read aloud or typed.-func newToken() string {-	b := make([]byte, 16)-	rand.Read(b)-	return strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b))-}--// validToken keeps a token to one URL path segment that needs no escaping.-func validToken(s string) bool {-	return s != "" && !strings.ContainsFunc(s, func(c rune) bool {-		return !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '-' || c == '_')-	})-}- // errno is an error with the code www exits with. type errno struct { 	code syscall.Errno@@ -693,53 +146,6 @@ func show(kind, text string, noopen bool) { 	} } -// openBrowser opens url when a display exists. Over SSH there is none, and-// the printed URL has to do.-func openBrowser(url string) {-	if os.Getenv("DISPLAY") == "" && os.Getenv("WAYLAND_DISPLAY") == "" {-		return-	}-	args := openArgs(url, os.Getenv(openEnv))-	cmd := exec.Command(args[0], args[1:]...)-	if err := cmd.Start(); err != nil {-		fmt.Fprintf(os.Stderr, "E%d: cannot open %s with %s: %v\n", errnoOf(err), url, args[0], err)-		return-	}-	go cmd.Wait()-}--// openArgs is the command that opens url. A loopback URL takes WWW_OPEN,-// split at spaces, with each %s replaced by the URL, or the URL appended-// when none holds %s; eg 'brave-browser --app=%s' opens an app window. A-// shared URL, and any URL without the variable, goes to xdg-open.-func openArgs(url, open string) []string {-	fields := strings.Fields(open)-	if len(fields) == 0 || !loopbackURL(url) {-		return []string{"xdg-open", url}-	}-	placed := false-	for i, f := range fields {-		if strings.Contains(f, "%s") {-			fields[i] = strings.ReplaceAll(f, "%s", url)-			placed = true-		}-	}-	if !placed {-		fields = append(fields, url)-	}-	return fields-}--// loopbackURL reports whether u names a loopback host.-func loopbackURL(u string) bool {-	p, err := neturl.Parse(u)-	if err != nil {-		return false-	}-	a, err := netip.ParseAddr(p.Hostname())-	return err == nil && a.IsLoopback() || p.Hostname() == "localhost"-}- // detach re-runs www in a new session and relays its start report. // Go cannot fork safely, so a re-exec is the way to background itself. func detach(c config) int {

main_test.go

deleted file mode 100644@@ -1,245 +0,0 @@-package main--import (-	"errors"-	"net/netip"-	"os"-	"os/exec"-	"path/filepath"-	"slices"-	"strings"-	"testing"-)--// Options parse the GNU way: short ones combine, values sit joined or apart,-// and options may follow the directory.-func TestParseArgs(t *testing.T) {-	for _, tc := range parseOK {-		got, err := parseArgs(tc.args)-		if err != nil || got != tc.want {-			t.Errorf("%q: %+v, %v; want %+v", tc.args, got, err, tc.want)-		}-	}-	for _, args := range [][]string{-		{"-x"},-		{"-f"},-		{"-n"},-		{"-fn"},-		{"-fg"},-		{"-nolock"},-		{"--no-lock"},-		{"--no-breaks"},-		{"--fg"},-		{"--foreground=yes"},-		{"-a"},-		{"--addr"},-		{"--addr="},-		{"one", "two"},-		{"--restart", "--foreground"},-		{"-l", "--restart"},-		{"--share="},-		{"--share", "a", "b"},-		{"-a", "127.31.1.2", "--share"},-		{"--secret"},-		{"--share", "--secret=k3x9"},-		{"--share", "--secret="},-		{"--restart", "--share", "--secret"},-		{"--kill", "docs"},-		{"--commit"},-		{"--commit="},-		{"--commit=HEAD", "-l"},-		{"--commit=HEAD", "-a", "127.31.1.2"},-		{"--commit=HEAD", "--no-open"},-		{"--kill", "-l"},-		{"--kill", "--restart"},-		{"--kill", "--share", "--secret"},-		{"--cache=lots"},-		{"--cache=-1M"},-		{"--cache"},-		{"--public", "-a", "127.0.0.1"},-		{"--public", "--disk=6G"},-		{"--public", "--disk=6G", "-a", "127.0.0.1", "docs"},-		{"--public", "--disk=6G", "-a", "127.0.0.1", "--foreground"},-		{"--public", "--disk=6G", "-a", "127.0.0.1", "-p"},-		{"--public", "--disk=6G", "--share", "--secret"},-		{"--public", "--disk=6G", "--restart", "-a", "127.0.0.1"},-		{"--commit=HEAD", "--public"},-	} {-		if _, err := parseArgs(args); err == nil {-			t.Errorf("%q: no error", args)-		}-	}-}--// parseOK holds command lines www takes, and what each parses to.-var parseOK = []struct {-	args []string-	want config-}{-	{nil, config{dir: "."}},-	{[]string{"--foreground", "--debug"}, config{dir: ".", fg: true, debug: true}},-	{[]string{"--cache=64M"}, config{dir: ".", cache: "64M"}},-	{[]string{"--cache", "0"}, config{dir: ".", cache: "0"}},-	{[]string{"docs", "--foreground", "--no-open"}, config{dir: "docs", fg: true, noopen: true}},-	{[]string{"-a", "127.31.1.2"}, config{dir: ".", addr: "127.31.1.2"}},-	{[]string{"-a127.31.1.2:9000"}, config{dir: ".", addr: "127.31.1.2:9000"}},-	{[]string{"-pa", "127.31.1.2"}, config{dir: ".", persist: true, addr: "127.31.1.2"}},-	{[]string{"--addr", "127.31.1.2"}, config{dir: ".", addr: "127.31.1.2"}},-	{[]string{"--addr=127.31.1.2"}, config{dir: ".", addr: "127.31.1.2"}},-	{[]string{"--", "-f"}, config{dir: "-f"}},-	{[]string{"-h"}, config{dir: ".", help: true}},-	{[]string{"--help"}, config{dir: ".", help: true}},-	{[]string{"--version"}, config{dir: ".", version: true}},-	{[]string{"-V"}, config{dir: ".", version: true}},-	{[]string{"--restart", "-a", "127.31.1.2"}, config{addr: "127.31.1.2", restart: true}},-	{[]string{"--restart", "-a", "127.31.1.2", "."}, config{dir: ".", addr: "127.31.1.2", restart: true}},-	{[]string{"--restart", "--debug", "--no-bug"}, config{restart: true, debug: true, noBug: true}},-	{[]string{"--disk=4G", "--no-bug", "docs"}, config{dir: "docs", disk: "4G", noBug: true}},-	{[]string{"-l"}, config{list: true}},-	{[]string{"--list", "docs"}, config{dir: "docs", list: true}},-	{[]string{"--share=0.0.0.0"}, config{dir: ".", sharing: true, share: "0.0.0.0"}},-	{[]string{"--share"}, config{dir: ".", sharing: true}},-	{[]string{"--share", "docs"}, config{dir: "docs", sharing: true}},-	{[]string{"--share=10.0.0.1:9000", "--secret", "docs"}, config{dir: "docs", sharing: true, share: "10.0.0.1:9000", secret: true}},-	{[]string{"--restart", "--share=10.0.0.1"}, config{sharing: true, share: "10.0.0.1", restart: true}},-	{[]string{"--kill"}, config{kill: true}},-	{[]string{"--commit=261024.4"}, config{dir: ".", commit: "261024.4"}},-	{[]string{"--commit", "HEAD", "docs"}, config{dir: "docs", commit: "HEAD"}},-	{[]string{"--kill", "--share"}, config{sharing: true, kill: true}},-	{[]string{"--public", "--disk=6G", "-a", "127.0.0.1:8080"}, config{public: true, disk: "6G", addr: "127.0.0.1:8080"}},-	{[]string{"--public", "--disk=6G", "--share=0.0.0.0", "--debug"}, config{public: true, disk: "6G", sharing: true, share: "0.0.0.0", debug: true}},-}--// The usage text is docopt, and docopt takes every command line in-// parseOK. docopt-ng runs through uv, and the test skips without it.-// docopt has no option with an optional value, so --share[=HOST] is-// checked as --share.-func TestUsageIsDocopt(t *testing.T) {-	uv, err := exec.LookPath("uv")-	if err != nil {-		t.Skip("uv is not installed")-	}-	usage := filepath.Join(t.TempDir(), "usage")-	if err := os.WriteFile(usage, []byte(strings.ReplaceAll(usageText, "--share[=HOST]", "--share")), 0o600); err != nil {-		t.Fatal(err)-	}-	var lines strings.Builder-	for _, tc := range parseOK {-		args := make([]string, len(tc.args))-		for i, a := range tc.args {-			if strings.HasPrefix(a, "--share=") {-				a = "--share"-			}-			args[i] = shellQuote(a)-		}-		lines.WriteString(strings.Join(args, " ") + "\n")-	}-	cmd := exec.Command(uv, "run", "--quiet", "--script", "testdata/docopt-check.py", usage)-	cmd.Stdin = strings.NewReader(lines.String())-	out, err := cmd.CombinedOutput()-	var ee *exec.ExitError-	switch {-	case err == nil:-	case errors.As(err, &ee) && ee.ExitCode() == 22:-		t.Errorf("docopt-ng:\n%s", out)-	default:-		t.Skipf("uv could not run docopt-ng: %v\n%s", err, out)-	}-}--// shellQuote quotes s for shlex.split.-func shellQuote(s string) string {-	return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"-}--// A tree is within a directory at or above it, never beside it.-func TestWithin(t *testing.T) {-	for _, tc := range []struct {-		dir, root string-		want      bool-	}{-		{"/a/b", "/a/b", true},-		{"/a/b/c", "/a", true},-		{"/a/b", "/", true},-		{"/a/bc", "/a/b", false},-		{"/a", "/a/b", false},-		{"/x/..y", "/x", true},-	} {-		if got := within(tc.dir, tc.root); got != tc.want {-			t.Errorf("within(%q, %q) = %v, want %v", tc.dir, tc.root, got, tc.want)-		}-	}-}--// CAP_NET_BIND_SERVICE is bit 10 of the effective set.-func TestHasCap(t *testing.T) {-	for status, want := range map[string]bool{-		"Name:\t" + progName + "\nCapInh:\t0000000000000000\nCapEff:\t0000000000000400\n": true,-		"CapEff:\t000001ffffffffff\n":                            true,-		"CapPrm:\t0000000000000400\nCapEff:\t0000000000000000\n": false,-		"Name:\t" + progName + "\n":                              false,-	} {-		if got := hasCap(status, capNetBindService); got != want {-			t.Errorf("%q: %v, want %v", status, got, want)-		}-	}-}--func TestOrigin(t *testing.T) {-	for ep, want := range map[string]string{-		"127.31.1.1:80":   "http://127.31.1.1",-		"127.31.1.1:8080": "http://127.31.1.1:8080",-		"10.0.0.1:80":     "http://10.0.0.1",-	} {-		if got := origin(netip.MustParseAddrPort(ep)); got != want {-			t.Errorf("%s: %s, want %s", ep, got, want)-		}-	}-}--// A server on 0.0.0.0 hands out the machine's name.-func TestOriginUnspecified(t *testing.T) {-	name, err := os.Hostname()-	if err != nil {-		t.Skip("no hostname")-	}-	if got, want := origin(netip.MustParseAddrPort("0.0.0.0:8080")), "http://"+name+":8080"; got != want {-		t.Errorf("0.0.0.0:8080: %s, want %s", got, want)-	}-}--func TestOpenArgs(t *testing.T) {-	const lo, lan = "http://127.31.1.1/x/", "http://192.168.1.5/x/"-	for _, c := range []struct {-		url, open string-		want      []string-	}{-		{lo, "", []string{"xdg-open", lo}},-		{lo, "brave-browser --app=%s", []string{"brave-browser", "--app=" + lo}},-		{lo, "firefox", []string{"firefox", lo}},-		{lan, "brave-browser --app=%s", []string{"xdg-open", lan}},-	} {-		if got := openArgs(c.url, c.open); !slices.Equal(got, c.want) {-			t.Errorf("openArgs(%q, %q) = %q, want %q", c.url, c.open, got, c.want)-		}-	}-}--// A chosen token comes from WWW_SECRET, never the command line, and a bad-// one's error names the variable without echoing the secret.-func TestSecretToken(t *testing.T) {-	t.Setenv(secretEnv, "k3x9")-	if got, err := secretToken(); err != nil || got != "k3x9" {-		t.Errorf("set: %q, %v; want k3x9", got, err)-	}-	t.Setenv(secretEnv, "a/b")-	if _, err := secretToken(); err == nil || !strings.Contains(err.Error(), secretEnv) || strings.Contains(err.Error(), "a/b") {-		t.Errorf("bad token: %v; want an error naming %s without the token", err, secretEnv)-	}-	t.Setenv(secretEnv, "")-	a, errA := secretToken()-	b, errB := secretToken()-	if errA != nil || errB != nil || a == "" || a == b {-		t.Errorf("unset: %q, %q, %v, %v; want two different random tokens", a, b, errA, errB)-	}-}

serve_test.go

@@ -274,3 +274,26 @@ func TestStopButtonEndsLastTree(t *testing.T) { 		t.Error("lock left behind") 	} }++func TestOrigin(t *testing.T) {+	for ep, want := range map[string]string{+		"127.31.1.1:80":   "http://127.31.1.1",+		"127.31.1.1:8080": "http://127.31.1.1:8080",+		"10.0.0.1:80":     "http://10.0.0.1",+	} {+		if got := origin(netip.MustParseAddrPort(ep)); got != want {+			t.Errorf("%s: %s, want %s", ep, got, want)+		}+	}+}++// A server on 0.0.0.0 hands out the machine's name.+func TestOriginUnspecified(t *testing.T) {+	name, err := os.Hostname()+	if err != nil {+		t.Skip("no hostname")+	}+	if got, want := origin(netip.MustParseAddrPort("0.0.0.0:8080")), "http://"+name+":8080"; got != want {+		t.Errorf("0.0.0.0:8080: %s, want %s", got, want)+	}+}