261021.24 · e6a571cefd9377bad7daa04fcae550796e746185
Fix the issues a review of the server found
- --persist on a server's only tree removed the plain lock before
joining, so the server looked gone and the start failed with E98.
The lock now goes only once the new link is in place.
- Each join makes a registry link of a new name, so the server tells a
re-join from its own link even when the new link reuses the inode.
The name is cut short to stay under NAME_MAX, so deep directories
can be served, and a link that fails to register is removed.
- A share's search no longer names dot entries its listing hides.
- serve waits for its loop and signal handler before closing the
registry, which they may still lock or unlock: a data race that
could release another registry's flock. The git cache is reset in
place rather than replaced under an earlier server's goroutines.
- A persistent lock counts as served only if the running server wrote
it, so a lock kept from before a reboot no longer reports a URL that
another tree has taken.
- A secret token spelled with escapes no longer reaches the log.
- A share's DNS lookup runs before the registry is locked, so it holds
up no other www.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
reading declarations
@@ -66,6 +66,17 @@ func (c *cache) put(key string, val any, size int64) { } } +// reset empties c and gives it a new budget. A server resets the cache+// it shares with what is left of an earlier server in the same process,+// as in tests, rather than replace it under that server's goroutines.+func (c *cache) reset(max int64) {+ c.mu.Lock()+ defer c.mu.Unlock()+ c.max, c.used = max, 0+ c.order.Init()+ clear(c.items)+}+ func (c *cache) drop(key string) { c.mu.Lock() defer c.mu.Unlock()
@@ -71,6 +71,7 @@ type lockInfo struct { pid int url string bootID string+ since time.Time // when a server record was written, as the server started } func parseLock(b []byte) lockInfo {@@ -118,7 +119,9 @@ func pidLive(pid int, was, now string) bool { // readLive reads the lock at path and reports whether its server runs. A // persistent lock holds no PID, so it takes the PID of the server that-// the registry of ep names.+// the registry of ep names. That server serves the tree only if it wrote+// the lock, after it started: a lock from an earlier server, kept for+// the next, may name a URL that another tree has taken since. func readLive(ep netip.AddrPort, path string) (lockInfo, bool) { b, err := os.ReadFile(path) if err != nil {@@ -130,6 +133,10 @@ func readLive(ep netip.AddrPort, path string) (lockInfo, bool) { if !ok || l.url == "" { return l, false }+ info, err := os.Stat(path)+ if err != nil || info.ModTime().Before(srv.since) {+ return l, false+ } l.pid, l.bootID = srv.pid, srv.bootID } return l, l.live(bootID())
@@ -5,6 +5,7 @@ import ( "net/netip" "os" "path/filepath"+ "strings" "testing" ) @@ -91,7 +92,34 @@ func TestRemoveSparesReplacedLock(t *testing.T) { } func TestLinkNameEscapes(t *testing.T) {- if got := linkName("/home/u/100%/a"); got != "%2Fhome%2Fu%2F100%25%2Fa" {+ got := linkName("/home/u/100%/a")+ if !strings.HasPrefix(got, "%2Fhome%2Fu%2F100%25%2Fa~") || len(got) != len("%2Fhome%2Fu%2F100%25%2Fa~")+8 { t.Errorf("link name %q", got) }+ if linkName("/a") == linkName("/a") {+ t.Error("two joins of a tree got links of one name")+ }+}++// A deep directory's link still fits in a file name.+func TestLinkNameFitsDeepDir(t *testing.T) {+ dir := "/" + strings.Repeat("deep/", 60)+ if n := len(linkName(dir)); n > 255 {+ t.Errorf("link name of %d bytes", n)+ }+ reg := ®istry{dir: t.TempDir()}+ if _, _, err := reg.add(filepath.Join(dir, "127.31.1.1-8080.lock"), ""); err != nil {+ t.Fatal(err)+ }+}++// hasLink reports whether the registry in regDir links a lock in dir.+func hasLink(regDir, dir string) bool {+ links, _ := readLinks(regDir)+ for _, target := range links {+ if lockPath, _ := splitTarget(target); filepath.Dir(lockPath) == dir {+ return true+ }+ }+ return false }
@@ -142,11 +142,14 @@ func TestRequestLog(t *testing.T) { tr.prefix, tr.secret = "/s3cr3t/", true get(h, "/s3cr3t/index.md?raw") get(h, "/s3cr3t?log")+ // Escapes spell the token another way.+ get(h, "/%733cr3t/index.md?raw")+ get(h, "/%733cr3t?log") path, _ := logFile() b, _ := os.ReadFile(path) log := string(b)- for _, skip := range []string{"style.", "favicon", ownPath + "live", "s3cr3t"} {+ for _, skip := range []string{"style.", "favicon", ownPath + "live", "s3cr3t", "3cr3t"} { if strings.Contains(log, skip) { t.Errorf("log holds %s:\n%s", skip, log) }
@@ -47,7 +47,7 @@ func TestPersistentLock(t *testing.T) { if _, err := os.Stat(kept); err != nil { t.Fatal("stopping the server removed the persistent lock") }- if _, err := os.Lstat(filepath.Join(reg, linkName(one))); err != nil {+ if !hasLink(reg, one) { t.Fatal("stopping the server removed the persistent link") } if _, ok := readLive(ep, kept); ok {@@ -78,7 +78,7 @@ func TestPersistentLock(t *testing.T) { os.Remove(kept) waitFor(t, "the kept tree to go", func() bool { return status(t, base+one+"/a.txt?raw") == http.StatusNotFound })- if _, err := os.Lstat(filepath.Join(reg, linkName(one))); err == nil {+ if hasLink(reg, one) { t.Error("a deleted persistent lock keeps its link") } os.Remove(filepath.Join(two, lockName(ep)))@@ -117,3 +117,45 @@ func TestPersistUpgradesLock(t *testing.T) { t.Fatal("server outlived its last lock") } }++// --persist on a server's only tree keeps the server and the tree.+func TestPersistUpgradesOnlyTree(t *testing.T) {+ ep := testEndpoint(t)+ one := t.TempDir()+ base := "http://" + ep.String()+ _, served := start(t, config{dir: one, ep: ep})+ lines, code := start(t, config{dir: one, ep: ep, persist: true})+ if <-code != 0 || lines[0] != "url "+base+one+"/" {+ t.Fatalf("persist: %q", lines)+ }+ if _, ok := readLive(ep, filepath.Join(one, persistName(ep))); !ok {+ t.Error("no live persistent lock")+ }+ os.Remove(filepath.Join(one, persistName(ep)))+ select {+ case <-served:+ case <-time.After(3 * time.Second):+ t.Fatal("server outlived its last lock")+ }+}++// A persistent lock that an earlier server wrote does not count as served+// by the server that runs now, which may serve another tree at its URL.+func TestPersistentLockOfEarlierServer(t *testing.T) {+ ep := testEndpoint(t)+ one, two := t.TempDir(), t.TempDir()+ kept := filepath.Join(one, persistName(ep))+ os.WriteFile(kept, []byte("url: http://"+ep.String()+"/x/\nroot: "+one+"\n"), 0o644)+ old := time.Now().Add(-time.Hour)+ os.Chtimes(kept, old, old)+ _, served := start(t, config{dir: two, ep: ep})+ if _, ok := readLive(ep, kept); ok {+ t.Error("a lock from before the server counts as live")+ }+ os.Remove(filepath.Join(two, lockName(ep)))+ select {+ case <-served:+ case <-time.After(3 * time.Second):+ t.Fatal("server outlived its last lock")+ }+}
@@ -1,8 +1,10 @@ package main import (+ "crypto/rand" "errors" "fmt"+ "io" "io/fs" "net/netip" "os"@@ -77,33 +79,51 @@ func flock(f *os.File, how int) error { } } -// linkName turns a directory into one file name: % and / become %25 and %2F.+// linkName turns a directory into one file name for its link: % and /+// become %25 and %2F, cut short to keep within NAME_MAX, and a random+// suffix follows. So every join makes a link of a new name, which the+// server tells from the link it already has without trusting the inode,+// which a new file may reuse. The name only helps a reader of the+// registry; www reads the tree from the link's target. func linkName(dir string) string {- return strings.NewReplacer("%", "%25", "/", "%2F").Replace(dir)+ name := strings.NewReplacer("%", "%25", "/", "%2F").Replace(dir)+ if len(name) > linkNameMax {+ name = name[:linkNameMax]+ }+ return name + "~" + rand.Text()[:8] } -func (r *registry) linkPath(lockPath string) string {- return filepath.Join(r.dir, linkName(filepath.Dir(lockPath)))-}+// linkNameMax leaves room under NAME_MAX, 255 bytes, for the suffix.+const linkNameMax = 200 -// add links lockPath into the registry. An old link for the same tree is-// replaced, so the server can tell a new join from the link it already has.+// add links lockPath into the registry. Any older link for the same tree+// goes, and the new one takes a new name, so the server can tell a new+// join from the link it already has. // A shared tree's secret token follows the lock path after a #, which the // lock name never holds; the registry is private, so the token stays so. func (r *registry) add(lockPath, token string) (string, os.FileInfo, error) {- link := r.linkPath(lockPath)- if err := os.Remove(link); err != nil && !errors.Is(err, fs.ErrNotExist) {+ links, err := r.links()+ if err != nil { return "", nil, err }+ for link, target := range links {+ if old, _ := splitTarget(target); filepath.Dir(old) == filepath.Dir(lockPath) {+ if err := os.Remove(link); err != nil && !errors.Is(err, fs.ErrNotExist) {+ return "", nil, err+ }+ }+ } target := lockPath if token != "" { target += "#" + token }+ link := filepath.Join(r.dir, linkName(filepath.Dir(lockPath))) if err := os.Symlink(target, link); err != nil { return "", nil, err } info, err := os.Lstat(link) if err != nil {+ os.Remove(link) return "", nil, err } return link, info, nil@@ -184,10 +204,20 @@ func serverOf(ep netip.AddrPort) (lockInfo, bool) { if err != nil { return lockInfo{}, false }- b, err := os.ReadFile(filepath.Join(dir, serverFile))+ f, err := os.Open(filepath.Join(dir, serverFile))+ if err != nil {+ return lockInfo{}, false+ }+ defer f.Close()+ info, err := f.Stat()+ if err != nil {+ return lockInfo{}, false+ }+ b, err := io.ReadAll(f) if err != nil { return lockInfo{}, false } l := parseLock(b)+ l.since = info.ModTime() return l, pidLive(l.pid, l.bootID, bootID()) }
@@ -119,7 +119,11 @@ func (t *tree) search(ctx context.Context, dir, q string) found { res.Lines = append(res.Lines, hit{Href: t.href(p, i+1), Path: p, Line: i + 1, Text: lineAt(b, i), Here: here}) } }- skip := func(d fs.DirEntry) bool { return isLockName(d.Name()) || d.IsDir() && unsearched[d.Name()] }+ // What a listing leaves out, search leaves out too: a share hides dot+ // entries, though its file system lists them.+ skip := func(p string, d fs.DirEntry) bool {+ return t.unlisted(path.Dir(p), d.Name()) || d.IsDir() && unsearched[d.Name()]+ } // The directory's own entries come first, so the hit limits cannot // crowd them out with deeper ones; then each subdirectory in turn. entries, err := fs.ReadDir(t.fsys, dir)@@ -132,22 +136,22 @@ func (t *tree) search(ctx context.Context, dir, q string) found { res.Cut = true return res }- if !skip(d) {- visit(path.Join(dir, d.Name()), d)+ if p := path.Join(dir, d.Name()); !skip(p, d) {+ visit(p, d) } } errStop := errors.New("stop") for _, e := range entries {- if !e.IsDir() || skip(e) {+ sub := path.Join(dir, e.Name())+ if !e.IsDir() || skip(sub, e) { continue }- sub := path.Join(dir, e.Name()) err := fs.WalkDir(t.fsys, sub, func(p string, d fs.DirEntry, err error) error { if ctx.Err() != nil { res.Cut = true return errStop }- if err != nil || (p != sub && skip(d)) {+ if err != nil || (p != sub && skip(p, d)) { if d != nil && d.IsDir() { return fs.SkipDir }
@@ -71,6 +71,9 @@ func run(c config, rep *reporter) int { if err != nil { return rep.fail(errnoOf(err), err.Error()) }+ // A share's name comes from DNS, which may take a second; asked+ // before the registry is locked, it holds up no other www.+ origin := shareOrigin(c.ep) reg, err := openRegistry(c.ep) if err != nil { return rep.fail(errnoOf(err), "cannot open the registry: "+err.Error())@@ -90,6 +93,10 @@ func run(c config, rep *reporter) int { report(rep, c.ep, l.url) return 0 }+ // upgrade is the plain lock that --persist replaces, which goes only+ // once the new link is in place: a server whose last tree it is would+ // otherwise look gone, and the address taken by a stranger.+ upgrade := "" if l, ok := findLive(c.ep, lockPath); ok && !c.persist { reg.close() logInfo("already served: %s", dir)@@ -102,7 +109,7 @@ func run(c config, rep *reporter) int { // --persist on a tree served until reboot: the server withdraws the // tree once its lock goes, and takes it again from the new link. logInfo("persists %s, served until now by %s", dir, lockPath)- os.Remove(lockPath)+ upgrade = lockPath } // A .LOCK whose server is down still marks the tree as persistent; a // plain start there keeps it so.@@ -119,14 +126,17 @@ func run(c config, rep *reporter) int { ln, err := net.Listen("tcp", c.ep.String()) switch { case err == nil:- return serve(c, rep, ln, reg, dir, home)+ if upgrade != "" {+ os.Remove(upgrade)+ }+ return serve(c, rep, ln, reg, dir, home, origin) case errors.Is(err, syscall.EADDRINUSE) && !reg.serves(c.ep): // The registry is locked, so a server of ours on ep is either up // with a tree or gone. Something else holds the address. reg.close() return rep.fail(syscall.EADDRINUSE, taken(c.ep)) case errors.Is(err, syscall.EADDRINUSE):- return join(c.ep, rep, reg, dir, lockPath, c.token)+ return join(c.ep, rep, reg, dir, lockPath, upgrade, c.token) default: reg.close() return rep.fail(errnoOf(err), "cannot listen on "+c.ep.String()+": "+err.Error())@@ -135,9 +145,14 @@ func run(c config, rep *reporter) int { // join hands dir to the server already on ep and waits until it writes the // lock. The registry stays locked until the link exists, so the server-// cannot be on its way out.-func join(ep netip.AddrPort, rep *reporter, reg *registry, dir, lockPath, token string) int {+// cannot be on its way out. upgrade, when set, is the plain lock that a+// persistent one replaces; the server withdraws the tree once it goes, and+// takes it again from the new link.+func join(ep netip.AddrPort, rep *reporter, reg *registry, dir, lockPath, upgrade, token string) int { link, _, err := reg.add(lockPath, token)+ if err == nil && upgrade != "" {+ os.Remove(upgrade)+ } reg.close() if err != nil { return rep.fail(errnoOf(err), "cannot join the server on "+ep.String()+": "+err.Error())@@ -237,20 +252,23 @@ func ifaceOf(a netip.Addr) string { // serve runs the server on ln with dir as its first tree. The caller holds // the registry lock, which serve releases once dir is adopted and a crashed // server's leftovers are gone.-func serve(c config, rep *reporter, ln net.Listener, reg *registry, dir, home string) int {+func serve(c config, rep *reporter, ln net.Listener, reg *registry, dir, home, origin string) int { logInfo("server starts on %s: %s", c.ep, build()) go warmLexers() showBuilt = c.stamp- gitCache = newCache(defaultCache)+ size := int64(defaultCache) if c.cache != "" {- n, _ := parseSize(c.cache)- gitCache = newCache(n)+ size, _ = parseSize(c.cache) }+ gitCache.reset(size) h := &hub{ep: c.ep, home: home, reg: reg, ln: ln, srv: newServer(), w: newWatcher(lockMask), done: make(chan struct{}), fresh: map[net.Conn]bool{}} defer h.w.close() defer reg.close() defer reg.unmarkServer()+ // The loop and the signal handler may still hold or release the+ // registry flock after done closes, so its file closes only after them.+ defer h.bg.Wait() h.dropLeftovers() if err := reg.markServer(); err != nil { reg.unlock()@@ -259,7 +277,7 @@ func serve(c config, rep *reporter, ln net.Listener, reg *registry, dir, home st } h.w.add(reg.dir) h.srv.shared = shared(c.ep)- h.origin = shareOrigin(c.ep)+ h.origin = origin first := lockName(c.ep) if c.persist { first = persistName(c.ep)@@ -280,14 +298,14 @@ func serve(c config, rep *reporter, ln net.Listener, reg *registry, dir, home st sigs := make(chan os.Signal, 1) signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) defer signal.Stop(sigs)- go func() {+ h.bg.Go(func() { select { case sig := <-sigs: h.shutdown(signalName(sig)) case <-h.done: }- }()- go h.loop()+ })+ h.bg.Go(h.loop) url := h.origin + t.prefixURL() report(rep, c.ep, url)@@ -335,7 +353,8 @@ type hub struct { // flock does across processes. mu sync.Mutex halted atomic.Bool- done chan struct{} // closed once open connections are drained+ done chan struct{} // closed once open connections are drained+ bg sync.WaitGroup // the loop and the signal handler // fresh holds the connections that have sent no request yet. fresh map[net.Conn]bool freshMu sync.Mutex@@ -422,14 +441,12 @@ func (h *hub) adopt(lockPath, link, token string) (*tree, error) { return nil, fmt.Errorf("cannot write lockfile: %w", err) } if link == "" {+ // add removes its link when it fails. t.link, t.linkInfo, err = h.reg.add(lockPath, token) } else { t.link = link t.linkInfo, err = os.Lstat(link) }- if err == nil {- t.linkTo, err = os.Readlink(t.link)- } if err != nil { t.lock.remove() root.Close()@@ -470,16 +487,11 @@ func (h *hub) withdraw(t *tree, why string, keep bool) { } // ownsLink reports whether t's registry link is still the one this server-// took, not a new join for the same directory. A new link can reuse the-// old one's inode, so its target must match too: a join that moves the-// tree to a persistent lock names the other lock.+// took. A new join for the same directory removes it and makes a link of+// another name, so a link of this name is this one. func (t *tree) ownsLink() bool { info, err := os.Lstat(t.link)- if err != nil || !os.SameFile(info, t.linkInfo) {- return false- }- to, err := os.Readlink(t.link)- return err == nil && to == t.linkTo+ return err == nil && os.SameFile(info, t.linkInfo) } // sync withdraws every tree whose lock is gone and adopts every new link.
@@ -171,7 +171,7 @@ func TestTreesShareOneServer(t *testing.T) { t.Fatalf("second tree went with the first: status %d", code) } reg := filepath.Join(os.Getenv("XDG_STATE_HOME"), progName, endpointName(ep))- if _, err := os.Lstat(filepath.Join(reg, linkName(one))); err == nil {+ if hasLink(reg, one) { t.Error("withdrawn tree keeps its registry link") }
@@ -199,7 +199,6 @@ type tree struct { lock *lock // nil only in tests link string // registry link linkInfo os.FileInfo // the link this server knows, to tell it from a new join- linkTo string // its target, since a new link may reuse its inode wd int // inotify watch on dir, or -1 shared bool // served beyond loopback: only what other users could read secret bool // mounted under a token, and left off the index@@ -336,11 +335,13 @@ func (s *server) logged(next http.Handler) http.Handler { return } uri := r.URL.RequestURI()+ // The token is cut from the decoded path, which the lookup matched;+ // the raw one may spell it in escapes, eg /%733cret/. if t := s.lookup(p); t != nil && t.secret {- uri = "/<token>/" + strings.TrimPrefix(uri, t.prefix)+ uri = "/<token>" + hideToken(r.URL, strings.TrimPrefix(p, t.prefix)) } else if t := s.byPrefix(p + "/"); t != nil && t.secret { // The secret path without its slash, which redirects.- uri = "/<token>" + strings.TrimPrefix(uri, p)+ uri = "/<token>" + hideToken(r.URL, "") } start := time.Now() sw := &statusWriter{ResponseWriter: w, status: http.StatusOK}@@ -357,6 +358,19 @@ func (s *server) logged(next http.Handler) http.Handler { }) } +// hideToken is the request URI of u for the log, with rest, the decoded+// path below a secret tree's prefix, in place of its path.+func hideToken(u *url.URL, rest string) string {+ uri := (&url.URL{Path: "/" + rest}).EscapedPath()+ if rest == "" && !strings.HasSuffix(u.Path, "/") {+ uri = ""+ }+ if u.RawQuery != "" {+ uri += "?" + u.RawQuery+ }+ return uri+}+ // statusWriter keeps the status a handler sent, for the log. type statusWriter struct { http.ResponseWriter
@@ -2,6 +2,7 @@ package main import ( "encoding/binary"+ "encoding/json" "net/http" "net/netip" "os"@@ -312,3 +313,22 @@ func TestEnvironmentDefaults(t *testing.T) { t.Errorf("non-loopback "+envPrefix+"ADDR: %v, want an error naming it", err) } }++// A share's search names no dot entry, as its listing shows none.+func TestShareSearchHidesDotEntries(t *testing.T) {+ s := shareTree(t)+ dir := s.lookup("/docs/").dir+ os.WriteFile(filepath.Join(dir, ".envkey"), []byte("env\n"), 0o644)+ os.MkdirAll(filepath.Join(dir, "open", ".envdir"), 0o755)+ os.WriteFile(filepath.Join(dir, "open", ".envhidden"), []byte("env\n"), 0o644)+ os.WriteFile(filepath.Join(dir, "open", "env.txt"), []byte("env\n"), 0o644)+ var res found+ json.Unmarshal(get(s.handler(), "/docs/?search=env").Body.Bytes(), &res)+ var names []string+ for _, h := range res.Names {+ names = append(names, h.Path)+ }+ if strings.Join(names, " ") != "open/env.txt" {+ t.Errorf("names: %v", names)+ }+}