@ 261101.7
current
package main

import (
	"encoding/binary"
	"encoding/json"
	"net/http"
	"net/netip"
	"os"
	"path/filepath"
	"strconv"
	"strings"
	"testing"
)

// shareTree builds a shared server with one tree at /docs/ and one under
// the token s3cret. The docs tree holds files other users can read, and
// some they cannot.
func shareTree(t *testing.T) *server {
	t.Helper()
	base := t.TempDir()
	dir := filepath.Join(base, "docs")
	hid := filepath.Join(base, "hid")
	files := []struct {
		path string
		mode os.FileMode
	}{
		{"docs/pub.txt", 0o644},
		{"docs/secret", 0o600},
		{"docs/open/x.txt", 0o644},
		{"docs/shut/y.txt", 0o644},
		{"hid/z.txt", 0o644},
	}
	for _, f := range files {
		p := filepath.Join(base, f.path)
		if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
			t.Fatal(err)
		}
		if err := os.WriteFile(p, []byte(f.path+"\n"), f.mode); err != nil {
			t.Fatal(err)
		}
		os.Chmod(p, f.mode)
	}
	for _, d := range []string{dir, filepath.Join(dir, "open"), hid} {
		os.Chmod(d, 0o755)
	}
	os.Chmod(filepath.Join(dir, "shut"), 0o700)
	s := newServer()
	s.shared = true
	for _, tc := range []struct{ dir, token string }{{dir, ""}, {hid, "s3cret"}} {
		prefix, label := shareMount(tc.dir, tc.token)
		tr := addTree(t, s, tc.dir, prefix, label)
		tr.shared, tr.secret = true, tc.token != ""
	}
	return s
}

func TestShareServesWhatOthersCanRead(t *testing.T) {
	h := shareTree(t).handler()
	for p, want := range map[string]string{
		"/docs/pub.txt?raw":    "docs/pub.txt",
		"/docs/open/x.txt?raw": "docs/open/x.txt",
		"/s3cret/z.txt?raw":    "hid/z.txt",
	} {
		rec := get(h, p)
		if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), want) {
			t.Errorf("%s: status %d, body %q; want 200 with %q", p, rec.Code, rec.Body.String(), want)
		}
	}
	for p, want := range map[string]string{
		"/docs/secret?raw":     "Refused: secret",
		"/docs/shut/y.txt?raw": "Refused: shut/y.txt",
	} {
		rec := get(h, p)
		body := rec.Body.String()
		if rec.Code != http.StatusForbidden || !strings.Contains(body, want) {
			t.Errorf("%s: status %d, body %q; want 403 with %q", p, rec.Code, body, want)
		}
		if strings.Contains(body, "mode") || strings.Contains(body, string(filepath.Separator)+"tmp") {
			t.Errorf("%s: the refusal names a path or mode: %q", p, body)
		}
	}
}

func TestShareListingHidesReasons(t *testing.T) {
	h := shareTree(t).handler()
	body := get(h, "/docs/").Body.String()
	if !strings.Contains(body, `<li class="refused">secret <span`) || !strings.Contains(body, `<li class="refused">shut/ <span`) {
		t.Errorf("listing does not mark private entries as refused:\n%s", body)
	}
	if strings.Contains(body, "title=\"secret") || strings.Contains(body, ownPath+"stop") {
		t.Errorf("listing carries a reason or a stop button:\n%s", body)
	}
}

func TestShareIndexListsOnlyOpenTrees(t *testing.T) {
	h := shareTree(t).handler()
	body := get(h, "/").Body.String()
	if !strings.Contains(body, `<a href="/docs/">docs/</a>`) {
		t.Errorf("index does not list docs:\n%s", body)
	}
	if strings.Contains(body, "s3cret") || strings.Contains(body, "hid") || strings.Contains(body, ownPath+"stop") {
		t.Errorf("index shows the secret tree or a button:\n%s", body)
	}
}

func TestShareTakesAnyHostAndNoStop(t *testing.T) {
	h := shareTree(t).handler()
	if rec := get(h, "/docs/pub.txt?raw", "Host", "laptop.lan"); rec.Code != http.StatusOK {
		t.Errorf("Host laptop.lan: status %d, want 200", rec.Code)
	}
	if rec := do(h, "POST", ownPath+"stop"); rec.Code == http.StatusOK {
		t.Error("a shared server answered a stop")
	}
}

// A lock names the tree's path, so a share hides it.
func TestShareHidesLocks(t *testing.T) {
	s := shareTree(t)
	dir := s.byPrefix("/docs/").dir
	if err := os.WriteFile(filepath.Join(dir, "10.0.0.1-80.lock"), []byte("root: "+dir+"\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	if rec := get(s.handler(), "/docs/10.0.0.1-80.lock?raw"); rec.Code != http.StatusNotFound {
		t.Errorf("lock: status %d, want 404", rec.Code)
	}
}

// .git holds the history and deleted files, so a share hides dot entries
// at any depth, while loopback shows them.
func TestShareHidesDotEntries(t *testing.T) {
	s := shareTree(t)
	dir := s.byPrefix("/docs/").dir
	for _, p := range []string{".git/HEAD", "open/.env", ".notes/n.md"} {
		f := filepath.Join(dir, filepath.FromSlash(p))
		if err := os.MkdirAll(filepath.Dir(f), 0o755); err != nil {
			t.Fatal(err)
		}
		os.Chmod(filepath.Dir(f), 0o755)
		if err := os.WriteFile(f, []byte(p+"\n"), 0o644); err != nil {
			t.Fatal(err)
		}
	}
	if err := os.Symlink(".git", filepath.Join(dir, "history")); err != nil {
		t.Fatal(err)
	}
	h := s.handler()
	for _, p := range []string{"/docs/.git/", "/docs/.git/HEAD?raw", "/docs/open/.env?raw", "/docs/.notes/n.md", "/docs/history/HEAD?raw"} {
		if rec := get(h, p); rec.Code != http.StatusNotFound {
			t.Errorf("%s: status %d, want 404", p, rec.Code)
		}
	}
	for _, p := range []string{"/docs/", "/docs/open/"} {
		body := get(h, p).Body.String()
		if strings.Contains(body, ".git") || strings.Contains(body, ".env") || strings.Contains(body, ".notes") || strings.Contains(body, "history") {
			t.Errorf("%s lists a dot entry:\n%s", p, body)
		}
	}
	s.byPrefix("/docs/").shared = false
	if rec := get(h, "/docs/.git/HEAD?raw"); rec.Code != http.StatusOK {
		t.Errorf("loopback .git/HEAD: status %d, want 200", rec.Code)
	}
}

func TestShareMount(t *testing.T) {
	for _, tc := range []struct{ dir, token, prefix, label string }{
		{"/home/u/scratch/notes", "", "/notes/", "notes"},
		{"/home/u/scratch/notes", "k3x9", "/k3x9/", "notes"},
		{"/", "", "/", "/"},
	} {
		if p, l := shareMount(tc.dir, tc.token); p != tc.prefix || l != tc.label {
			t.Errorf("shareMount(%q, %q) = %q, %q; want %q, %q", tc.dir, tc.token, p, l, tc.prefix, tc.label)
		}
	}
}

// -a takes loopback alone and --share everything else.
func TestShareEndpoint(t *testing.T) {
	for addr, want := range map[string]string{
		"192.168.1.5":    "192.168.1.5:8080",
		"0.0.0.0:9000":   "0.0.0.0:9000",
		"10.0.0.1:80":    "10.0.0.1:80",
		"127.31.1.1":     "",
		"::1":            "",
		"192.168.1.5:0":  "",
		"not-an-address": "",
	} {
		ep, err := shareEndpoint(addr)
		if want == "" {
			if err == nil {
				t.Errorf("%s: %v, want an error", addr, ep)
			}
			continue
		}
		if err != nil || ep != netip.MustParseAddrPort(want) {
			t.Errorf("%s: %v, %v; want %s", addr, ep, err, want)
		}
	}
	if _, err := endpointFor("192.168.1.5", 1001); err == nil {
		t.Error("-a took an address beyond loopback")
	}
}

// A registry link carries a shared tree's token after the lock path.
func TestSplitTarget(t *testing.T) {
	for target, want := range map[string][2]string{
		"/a/b/127.31.1.1-80.lock":        {"/a/b/127.31.1.1-80.lock", ""},
		"/a/b#c/10.0.0.1-80.lock":        {"/a/b#c/10.0.0.1-80.lock", ""},
		"/a/b#c/10.0.0.1-80.lock#k3x9_-": {"/a/b#c/10.0.0.1-80.lock", "k3x9_-"},
	} {
		if l, tok := splitTarget(target); l != want[0] || tok != want[1] {
			t.Errorf("splitTarget(%q) = %q, %q; want %q, %q", target, l, tok, want[0], want[1])
		}
	}
	if tok := newToken(); len(tok) != 26 || !validToken(tok) {
		t.Errorf("newToken() = %q, want 26 valid characters", tok)
	}
}

// Listeners come from /proc/net/tcp and tcp6, whose addresses the kernel
// prints in host order, little-endian here.
func TestListeners(t *testing.T) {
	if binary.NativeEndian.Uint16([]byte{1, 0}) != 1 {
		t.Skip("big-endian host")
	}
	table := `  sl  local_address rem_address   st
   0: 01011F7F:0050 00000000:0000 0A 00000000:00000000
   1: 00000000:1F90 00000000:0000 0A 00000000:00000000
   2: 0100007F:0050 0100007F:9C40 01 00000000:00000000
   3: 00000000000000000000000000000000:0050 00000000000000000000000000000000:0000 0A 0
   4: 0000000000000000FFFF00000501A8C0:0051 00000000000000000000000000000000:0000 0A 0
`
	var got []string
	for _, l := range listeners(table) {
		got = append(got, l.String())
	}
	want := []string{"127.31.1.1:80", "0.0.0.0:8080", "[::]:80", "192.168.1.5:81"}
	if strings.Join(got, " ") != strings.Join(want, " ") {
		t.Errorf("listeners: %v, want %v", got, want)
	}
}

// A bare --share takes the default route's address, which is never
// loopback; without a route it says to name one.
func TestShareDefaultRoute(t *testing.T) {
	ep, err := shareEndpoint("")
	if err != nil {
		if !strings.Contains(err.Error(), "--share=") {
			t.Errorf("no route: %v, want advice to name an address", err)
		}
		t.Skip("no default route: ", err)
	}
	if ep.Addr().IsLoopback() || ep.Port() != defaultPort {
		t.Errorf("shareEndpoint(\"\") = %v", ep)
	}
}

// A name resolves to an address this machine holds; localhost is loopback,
// which --share refuses.
func TestShareName(t *testing.T) {
	for _, addr := range []string{"localhost", "localhost:9000", "no-such-host.invalid", "localhost:0"} {
		if ep, err := shareEndpoint(addr); err == nil {
			t.Errorf("%s: %v, want an error", addr, ep)
		}
	}
}

// An address after a bare --share lands as the directory; the hint names
// the joined form, and a real directory or a plain typo gets none.
func TestSpacedShareHint(t *testing.T) {
	dir := t.TempDir()
	for _, tc := range []struct {
		c    config
		hint bool
	}{
		{config{sharing: true, dir: "192.168.1.5"}, true},
		{config{sharing: true, dir: "192.168.1.5:9000"}, true},
		{config{sharing: true, dir: dir}, false},
		{config{sharing: true, dir: filepath.Join(dir, "no such dir")}, false},
		{config{sharing: true, share: "10.0.0.1", dir: "192.168.1.5"}, false},
		{config{dir: "192.168.1.5"}, false},
	} {
		got := spacedShare(tc.c)
		if (got != "") != tc.hint || tc.hint && !strings.Contains(got, "--share="+tc.c.dir) {
			t.Errorf("%+v: %q", tc.c, got)
		}
	}
}

// WWW_SHARE fills a bare --share and WWW_ADDR a start without -a.
// Flags win, --list, --kill and --restart keep covering every server, and
// a bad value names its variable.
func TestEnvironmentDefaults(t *testing.T) {
	t.Setenv(shareEnv, "10.9.8.7:81")
	t.Setenv(addrEnv, "127.31.9.9:82")
	for _, tc := range []struct {
		c    config
		want string
	}{
		{config{sharing: true}, "10.9.8.7:81"},
		{config{sharing: true, share: "10.0.0.1:80"}, "10.0.0.1:80"},
		{config{sharing: true, kill: true}, "10.9.8.7:81"},
		{config{dir: "."}, "127.31.9.9:82"},
		{config{dir: ".", addr: "127.31.1.2:80"}, "127.31.1.2:80"},
		{config{list: true}, "127.31.0.1:" + strconv.Itoa(int(defaultPort))},
	} {
		ep, err := endpointOf(tc.c, 1000)
		if err != nil || ep.String() != tc.want {
			t.Errorf("%+v: %v, %v; want %s", tc.c, ep, err, tc.want)
		}
	}
	t.Setenv(addrEnv, "10.0.0.1")
	if _, err := endpointOf(config{dir: "."}, 1000); err == nil || !strings.HasPrefix(err.Error(), addrEnv+"=10.0.0.1: ") {
		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)
	}
}