diff --git a/cmd/cloudflared/tunnel/cmd.go b/cmd/cloudflared/tunnel/cmd.go index 8896c8d800e..23f6058eafa 100644 --- a/cmd/cloudflared/tunnel/cmd.go +++ b/cmd/cloudflared/tunnel/cmd.go @@ -252,7 +252,7 @@ func TunnelCommand(c *cli.Context) error { // Run a quick tunnel // A unauthenticated named tunnel hosted on ..com - shouldRunQuickTunnel := c.IsSet("url") || c.IsSet(ingress.HelloWorldFlag) + shouldRunQuickTunnel := c.IsSet("url") || c.IsSet(ingress.HelloWorldFlag) || c.IsSet(ingress.FileFlag) if c.String("quick-service") != "" && shouldRunQuickTunnel { return RunQuickTunnel(sc) } @@ -967,6 +967,12 @@ func configureProxyFlags(shouldHide bool) []cli.Flag { EnvVars: []string{"TUNNEL_URL"}, Hidden: shouldHide, }), + altsrc.NewStringFlag(&cli.StringFlag{ + Name: ingress.FileFlag, + Usage: "Serve the local directory at PATH read-only instead of connecting to --url.", + EnvVars: []string{"TUNNEL_FILE"}, + Hidden: shouldHide, + }), altsrc.NewBoolFlag(&cli.BoolFlag{ Name: ingress.HelloWorldFlag, Value: false, diff --git a/cmd/cloudflared/tunnel/quick_tunnel.go b/cmd/cloudflared/tunnel/quick_tunnel.go index 805b2ba9cfd..bdcd9ff9f58 100644 --- a/cmd/cloudflared/tunnel/quick_tunnel.go +++ b/cmd/cloudflared/tunnel/quick_tunnel.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "net/http" + "path/filepath" "strings" "time" @@ -50,6 +51,13 @@ func describeQuickTunnelLocalOrigin(c *cli.Context, hasConfiguredIngress bool) ( if c.IsSet(ingress.HelloWorldFlag) { return quickTunnelHelloWorldOrigin, nil } + if c.IsSet(ingress.FileFlag) { + dir, err := filepath.Abs(c.String(ingress.FileFlag)) + if err != nil { + return "", fmt.Errorf("resolve --file directory: %w", err) + } + return "file:" + dir, nil + } originURL, err := config.ValidateUrl(c, false) if err != nil { diff --git a/ingress/ingress.go b/ingress/ingress.go index f5758fc91d3..ba0627a32f1 100644 --- a/ingress/ingress.go +++ b/ingress/ingress.go @@ -276,6 +276,12 @@ func newDefaultOrigin(c *cli.Context, log *zerolog.Logger) Ingress { // Get a single origin service from the CLI/config. func parseSingleOriginService(c *cli.Context, allowURLFromArgs bool) (OriginService, error) { + if c.IsSet(FileFlag) { + if c.IsSet("url") || c.IsSet("unix-socket") || c.IsSet(HelloWorldFlag) || c.IsSet(config.BastionFlag) { + return nil, errors.New("--file must be used exclusively: it conflicts with --url, --unix-socket, --hello-world and --bastion") + } + return newFileServer(c.String(FileFlag)) + } if c.IsSet(HelloWorldFlag) { return new(helloWorld), nil } @@ -363,7 +369,13 @@ func validateIngress(ingress []config.UnvalidatedIngressRule, defaults OriginReq cfg := setConfig(defaults, r.OriginRequest) var service OriginService - if prefix := "unix:"; strings.HasPrefix(r.Service, prefix) { + if prefix := "file:"; strings.HasPrefix(r.Service, prefix) { + fileServer, err := newFileServer(strings.TrimPrefix(r.Service, prefix)) + if err != nil { + return Ingress{}, err + } + service = fileServer + } else if prefix := "unix:"; strings.HasPrefix(r.Service, prefix) { // No validation necessary for unix socket filepath services path := strings.TrimPrefix(r.Service, prefix) service = &unixSocketPath{path: path, scheme: "http"} diff --git a/ingress/origin_file.go b/ingress/origin_file.go new file mode 100644 index 00000000000..a18f63c859e --- /dev/null +++ b/ingress/origin_file.go @@ -0,0 +1,253 @@ +package ingress + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/hex" + "encoding/json" + "fmt" + "net" + "net/http" + "net/url" + "os" + "path" + "path/filepath" + "runtime" + "strings" + "time" + + "github.com/pkg/errors" + "github.com/rs/zerolog" +) + +// FileFlag is the CLI flag used to serve a local directory through the tunnel. +const FileFlag = "file" + +// localFileAuthHeader marks requests that came through the tunnel proxy rather +// than from another process on the machine listening on the loopback port. +const localFileAuthHeader = "X-Cloudflared-File-Server" + +// fileServer is an OriginService that serves a local directory read-only. +// +// It starts an in-process HTTP server bound to loopback and points the embedded +// httpService at it, in the same way helloWorld does. Nothing is ever written. +type fileServer struct { + httpService + root string + token string +} + +// newFileServer records the directory to serve. +// +// The path is only made absolute here. Resolving and validating it happens in +// start(), so that validating a configuration file does not require the +// directory to exist on the machine doing the validation. +func newFileServer(dir string) (*fileServer, error) { + if strings.TrimSpace(dir) == "" { + return nil, errors.New("file service requires a directory") + } + abs, err := filepath.Abs(dir) + if err != nil { + return nil, errors.Wrap(err, "invalid directory") + } + token, err := newLocalToken() + if err != nil { + return nil, err + } + return &fileServer{root: abs, token: token}, nil +} + +func newLocalToken() (string, error) { + buf := make([]byte, 16) + if _, err := rand.Read(buf); err != nil { + return "", errors.Wrap(err, "cannot generate loopback token") + } + return hex.EncodeToString(buf), nil +} + +// resolveRoot resolves symlinks and checks that root is an existing directory. +func (o *fileServer) resolveRoot() error { + resolved, err := filepath.EvalSymlinks(o.root) + if err != nil { + return errors.Wrapf(err, "cannot resolve directory %s", o.root) + } + info, err := os.Stat(resolved) + if err != nil { + return errors.Wrapf(err, "cannot stat directory %s", resolved) + } + if !info.IsDir() { + return fmt.Errorf("%s is not a directory", resolved) + } + o.root = resolved + return nil +} + +func (o *fileServer) String() string { return "file:" + o.root } + +func (o fileServer) MarshalJSON() ([]byte, error) { return json.Marshal(o.String()) } + +// RoundTrip marks requests that come through the tunnel so that the loopback +// listener can tell them apart from other local processes. +func (o *fileServer) RoundTrip(req *http.Request) (*http.Response, error) { + clone := req.Clone(req.Context()) + clone.Header.Set(localFileAuthHeader, o.token) + return o.httpService.RoundTrip(clone) +} + +func (o *fileServer) start(log *zerolog.Logger, shutdownC <-chan struct{}, cfg OriginRequestConfig) error { + if err := o.resolveRoot(); err != nil { + return err + } + if err := o.httpService.start(log, shutdownC, cfg); err != nil { + return err + } + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return errors.Wrap(err, "cannot start local file server") + } + server := &http.Server{ + Handler: o, + ReadHeaderTimeout: 10 * time.Second, + IdleTimeout: 2 * time.Minute, + } + go func() { _ = server.Serve(listener) }() + go func() { + <-shutdownC + // Close also tears down keep-alive connections the shared transport + // may still be holding open. + _ = server.Close() + }() + o.httpService.url = &url.URL{Scheme: "http", Host: listener.Addr().String()} + log.Info(). + Str("root", o.root). + Str("addr", listener.Addr().String()). + Msg("Serving local directory") + return nil +} + +// ServeHTTP serves files under root. It is deliberately stricter than +// http.FileServer: no directory listings, no dotfiles, no symlink escapes and +// only GET/HEAD. +// +// Only the root path maps to index.html. Requests for a directory such as +// "/sub/" are 404 by design, so a site whose links end in a slash needs the +// page to be requested explicitly ("/sub/index.html"). +func (o *fileServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + w.Header().Set("Allow", "GET, HEAD") + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + // Only the tunnel may talk to this listener. + if subtle.ConstantTimeCompare([]byte(r.Header.Get(localFileAuthHeader)), []byte(o.token)) != 1 { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + + rel := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/") + if rel == "" { + rel = "index.html" + } + if !safeRequestPath(rel) { + http.NotFound(w, r) + return + } + + target := filepath.Join(o.root, filepath.FromSlash(rel)) + resolved, err := filepath.EvalSymlinks(target) + if err != nil { + http.NotFound(w, r) + return + } + + // This is the check that actually contains the request. The filesystem may + // have expanded it into something the policy above never saw: a Windows 8.3 + // short name such as "GIT~1" that aliases ".git", or a symlink inside the + // root pointing at a dotfile. Applying the same policy to the resolved path + // rejects both, and an escape from the root surfaces here as a leading "..". + resolvedRel, err := filepath.Rel(o.root, resolved) + if err != nil || !safeRelativePath(resolvedRel) { + http.NotFound(w, r) + return + } + + file, err := os.Open(resolved) + if err != nil { + http.NotFound(w, r) + return + } + defer file.Close() + + info, err := file.Stat() + if err != nil || !info.Mode().IsRegular() { + http.NotFound(w, r) + return + } + + w.Header().Set("X-Content-Type-Options", "nosniff") + http.ServeContent(w, r, info.Name(), info.ModTime(), file) +} + +// safeRequestPath applies the policy to a path that came straight from the +// request. It additionally rejects backslashes: on Windows they separate path +// components for the filesystem but are opaque to path.Clean, which would give +// one file two URLs ("/sub%5ca.txt" and "/sub/a.txt"). +// +// That is a normalisation rule rather than the safety boundary: what stops a +// backslash from reaching a dotfile is safeRelativePath mapping it through +// filepath.ToSlash, and ultimately the resolved-path check in ServeHTTP. +func safeRequestPath(rel string) bool { + if strings.ContainsRune(rel, '\\') { + return false + } + return safeRelativePath(rel) +} + +// safeRelativePath reports whether every component of rel is something we are +// willing to serve. It is applied both to the request path and to the path the +// filesystem resolved to. +func safeRelativePath(rel string) bool { + for _, segment := range strings.Split(filepath.ToSlash(rel), "/") { + if segment == "" || segment == "." || segment == ".." { + return false + } + // No hidden files or directories. + if strings.HasPrefix(segment, ".") { + return false + } + // Windows silently strips trailing dots and spaces, so "a.txt." would + // alias "a.txt". Rejecting on every platform keeps behaviour identical + // rather than aliasing on one of them. + if strings.TrimRight(segment, ". ") != segment { + return false + } + if isReservedName(segment) { + return false + } + } + return true +} + +// isReservedName reports Windows device names, which are not regular files and +// can block on open. +func isReservedName(segment string) bool { + if runtime.GOOS != "windows" { + return false + } + name := strings.ToUpper(segment) + if idx := strings.IndexByte(name, '.'); idx >= 0 { + name = name[:idx] + } + switch name { + case "CON", "PRN", "AUX", "NUL": + return true + } + if len(name) == 4 { + prefix, digit := name[:3], name[3] + if digit >= '1' && digit <= '9' && (prefix == "COM" || prefix == "LPT") { + return true + } + } + return false +} diff --git a/ingress/origin_file_flag_test.go b/ingress/origin_file_flag_test.go new file mode 100644 index 00000000000..e892532e5dd --- /dev/null +++ b/ingress/origin_file_flag_test.go @@ -0,0 +1,84 @@ +package ingress + +import ( + "flag" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v2" + + "github.com/cloudflare/cloudflared/config" +) + +func newFileFlagContext(t *testing.T, args ...string) *cli.Context { + t.Helper() + flagSet := flag.NewFlagSet(t.Name(), flag.ContinueOnError) + for _, cliFlag := range []cli.Flag{ + &cli.StringFlag{Name: FileFlag}, + &cli.StringFlag{Name: "url"}, + &cli.StringFlag{Name: "unix-socket"}, + &cli.StringFlag{Name: "hostname"}, + &cli.BoolFlag{Name: HelloWorldFlag}, + &cli.BoolFlag{Name: config.BastionFlag}, + } { + require.NoError(t, cliFlag.Apply(flagSet)) + } + require.NoError(t, flagSet.Parse(args)) + return cli.NewContext(cli.NewApp(), flagSet, nil) +} + +func TestParseCLIIngressFileFlag(t *testing.T) { + dir := t.TempDir() + ing, err := parseCLIIngress(newFileFlagContext(t, "--"+FileFlag, dir), false) + require.NoError(t, err) + require.Len(t, ing.Rules, 1) + + svc, ok := ing.Rules[0].Service.(*fileServer) + require.Truef(t, ok, "expected *fileServer, got %T", ing.Rules[0].Service) + + abs, err := filepath.Abs(dir) + require.NoError(t, err) + require.Equal(t, abs, svc.root) + require.Equal(t, "file:"+abs, svc.String()) +} + +func TestFileFlagIsExclusive(t *testing.T) { + dir := t.TempDir() + + cases := map[string][]string{ + "with --url": {"--" + FileFlag, dir, "--url", "http://localhost:8080"}, + "with --unix-socket": {"--" + FileFlag, dir, "--unix-socket", "/tmp/sock"}, + "with --hello-world": {"--" + FileFlag, dir, "--" + HelloWorldFlag}, + "with --bastion": {"--" + FileFlag, dir, "--" + config.BastionFlag}, + } + for name, args := range cases { + t.Run(name, func(t *testing.T) { + _, err := parseCLIIngress(newFileFlagContext(t, args...), false) + require.Error(t, err) + }) + } +} + +func TestValidateIngressFileService(t *testing.T) { + dir := t.TempDir() + ing, err := validateIngress([]config.UnvalidatedIngressRule{ + {Service: "file:" + dir}, + }, OriginRequestConfig{}) + require.NoError(t, err) + require.Len(t, ing.Rules, 1) + + svc, ok := ing.Rules[0].Service.(*fileServer) + require.Truef(t, ok, "expected *fileServer, got %T", ing.Rules[0].Service) + + abs, err := filepath.Abs(dir) + require.NoError(t, err) + require.Equal(t, abs, svc.root) +} + +func TestValidateIngressFileServiceRejectsEmptyPath(t *testing.T) { + _, err := validateIngress([]config.UnvalidatedIngressRule{ + {Service: "file:"}, + }, OriginRequestConfig{}) + require.Error(t, err) +} diff --git a/ingress/origin_file_loopback_test.go b/ingress/origin_file_loopback_test.go new file mode 100644 index 00000000000..4e60fe64dc1 --- /dev/null +++ b/ingress/origin_file_loopback_test.go @@ -0,0 +1,56 @@ +package ingress + +import ( + "io" + "net/http" + "os" + "path/filepath" + "testing" + + "github.com/rs/zerolog" + "github.com/stretchr/testify/require" +) + +// A symlink inside the root that points at a dotfile is only caught by the +// resolved-path check, so this pins that step. +func TestFileServerRejectsSymlinkToDotfile(t *testing.T) { + root := t.TempDir() + mustWriteFile(t, filepath.Join(root, ".env"), "SECRET=1") + if err := os.Symlink(filepath.Join(root, ".env"), filepath.Join(root, "link_env")); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + + svc := newTestFileServer(t, root) + if rec := serve(svc, "GET", "/link_env"); rec.Code != 404 { + t.Errorf("symlink to dotfile: code=%d want 404", rec.Code) + } +} + +// The loopback listener must only answer requests that came through the tunnel. +// This fails if RoundTrip stops injecting the header, which no other test covers. +func TestFileServerLoopbackRequiresTunnelRoundTrip(t *testing.T) { + root := t.TempDir() + mustWriteFile(t, filepath.Join(root, "a.txt"), "AAA") + svc := newTestFileServer(t, root) + + shutdown := make(chan struct{}) + defer close(shutdown) + logger := zerolog.Nop() + require.NoError(t, svc.start(&logger, shutdown, OriginRequestConfig{})) + + // Through the tunnel proxy: RoundTrip adds the header. + client := &http.Client{Transport: svc} + resp, err := client.Get("http://example.com/a.txt") + require.NoError(t, err) + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, 200, resp.StatusCode) + require.Equal(t, "AAA", string(body)) + + // Straight at the loopback port, the way another local process would. + direct, err := http.Get("http://" + svc.httpService.url.Host + "/a.txt") + require.NoError(t, err) + defer direct.Body.Close() + require.Equal(t, http.StatusForbidden, direct.StatusCode) +} diff --git a/ingress/origin_file_policy_test.go b/ingress/origin_file_policy_test.go new file mode 100644 index 00000000000..61aa2613387 --- /dev/null +++ b/ingress/origin_file_policy_test.go @@ -0,0 +1,77 @@ +package ingress + +import ( + "runtime" + "testing" + + "github.com/stretchr/testify/require" +) + +// The path policy is the single place that decides what may be served. It is +// tested directly, because going through ServeHTTP cannot distinguish "the rule +// rejected this" from "the file simply does not exist on this platform". +func TestSafeRelativePath(t *testing.T) { + cases := []struct { + rel string + want bool + }{ + {"index.html", true}, + {"sub/ok.txt", true}, + {"a.txt", true}, + {"sub/deeper/x.css", true}, + {"", false}, + {".", false}, + {"..", false}, + {"a/../b", false}, + {".env", false}, + {".git/config", false}, + {"sub/.hidden", false}, + {"a.txt.", false}, + {"a.txt ", false}, + {"sub/a.txt. ", false}, + } + for _, c := range cases { + require.Equalf(t, c.want, safeRelativePath(c.rel), "safeRelativePath(%q)", c.rel) + } + + // Windows hands back OS-separated paths from filepath.Rel; they must be + // accepted after normalisation rather than rejected as unsafe. + if runtime.GOOS == "windows" { + require.True(t, safeRelativePath("sub\\ok.txt")) + require.False(t, safeRelativePath("sub\\.hidden")) + } +} + +func TestSafeRequestPath(t *testing.T) { + cases := []struct { + rel string + want bool + }{ + {"index.html", true}, + {"sub/ok.txt", true}, + {"a\\b", false}, + {"\\.env", false}, + {"sub\\ok.txt", false}, + {"sub\\..\\.env", false}, + {".env", false}, + {"a.txt.", false}, + } + for _, c := range cases { + require.Equalf(t, c.want, safeRequestPath(c.rel), "safeRequestPath(%q)", c.rel) + } +} + +func TestSafeRelativePathReservedNames(t *testing.T) { + reserved := []string{"NUL", "nul", "CON", "PRN", "AUX", "COM1", "com9", "LPT1", "NUL.txt", "con.log"} + for _, name := range reserved { + got := safeRelativePath(name) + if runtime.GOOS == "windows" { + require.Falsef(t, got, "reserved device name %q must be rejected on Windows", name) + } else { + require.Truef(t, got, "%q is an ordinary name outside Windows", name) + } + } + // Names that merely start with a device name are fine on every platform. + require.True(t, safeRelativePath("console.txt")) + require.True(t, safeRelativePath("nullable.md")) +} diff --git a/ingress/origin_file_test.go b/ingress/origin_file_test.go new file mode 100644 index 00000000000..559cef8801c --- /dev/null +++ b/ingress/origin_file_test.go @@ -0,0 +1,180 @@ +package ingress + +import ( + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +// serve issues a request the way the tunnel proxy would: with the loopback +// auth header the file server expects. +func serve(svc *fileServer, method, target string) *httptest.ResponseRecorder { + req := httptest.NewRequest(method, "http://example.com"+target, nil) + req.Header.Set(localFileAuthHeader, svc.token) + rec := httptest.NewRecorder() + svc.ServeHTTP(rec, req) + return rec +} + +func newTestFileServer(t *testing.T, root string) *fileServer { + t.Helper() + svc, err := newFileServer(root) + if err != nil { + t.Fatalf("newFileServer: %v", err) + } + if err := svc.resolveRoot(); err != nil { + t.Fatalf("resolveRoot: %v", err) + } + return svc +} + +func TestFileServerServesFiles(t *testing.T) { + root := t.TempDir() + mustWriteFile(t, filepath.Join(root, "index.html"), "INDEX") + mustWriteFile(t, filepath.Join(root, "a.txt"), "AAA") + if err := os.MkdirAll(filepath.Join(root, "sub"), 0o700); err != nil { + t.Fatal(err) + } + mustWriteFile(t, filepath.Join(root, "sub", "b.txt"), "BBB") + + svc := newTestFileServer(t, root) + if !strings.HasPrefix(svc.String(), "file:") { + t.Fatalf("unexpected String(): %s", svc.String()) + } + + cases := []struct { + name string + path string + code int + body string + }{ + {"root serves index", "/", 200, "INDEX"}, + {"plain file", "/a.txt", 200, "AAA"}, + {"nested file", "/sub/b.txt", 200, "BBB"}, + {"missing", "/nope.txt", 404, ""}, + } + for _, c := range cases { + rec := serve(svc, "GET", c.path) + if rec.Code != c.code { + t.Errorf("%s: code=%d want %d", c.name, rec.Code, c.code) + } + if c.body != "" && !strings.Contains(rec.Body.String(), c.body) { + t.Errorf("%s: body=%q", c.name, rec.Body.String()) + } + } +} + +func TestFileServerRejectsUnsafePaths(t *testing.T) { + root := t.TempDir() + mustWriteFile(t, filepath.Join(root, "a.txt"), "AAA") + mustWriteFile(t, filepath.Join(root, ".env"), "SECRET=1") + if err := os.MkdirAll(filepath.Join(root, ".git"), 0o700); err != nil { + t.Fatal(err) + } + mustWriteFile(t, filepath.Join(root, ".git", "config"), "TOKEN=1") + if err := os.MkdirAll(filepath.Join(root, "sub"), 0o700); err != nil { + t.Fatal(err) + } + + outside := t.TempDir() + mustWriteFile(t, filepath.Join(outside, "secret.txt"), "OUTSIDE") + linkErr := os.Symlink(filepath.Join(outside, "secret.txt"), filepath.Join(root, "evil.txt")) + + svc := newTestFileServer(t, root) + + cases := []struct { + name string + method string + path string + want int + }{ + {"dotdot", "GET", "/../secret.txt", 404}, + {"dotdot encoded", "GET", "/%2e%2e/secret.txt", 404}, + {"dotfile", "GET", "/.env", 404}, + {"dot directory", "GET", "/.git/config", 404}, + {"directory listing", "GET", "/sub/", 404}, + {"trailing dot", "GET", "/a.txt.", 404}, + {"trailing dot on dotfile", "GET", "/.env.", 404}, + {"backslash dotfile", "GET", "/%5c.env", 404}, + {"backslash dot directory", "GET", "/%5c.git%5cconfig", 404}, + {"backslash dotdot", "GET", "/sub%5c..%5c.env", 404}, + {"8.3 short name", "GET", "/GIT~1/config", 404}, + {"reserved device", "GET", "/NUL", 404}, + {"post not allowed", "POST", "/a.txt", 405}, + {"put not allowed", "PUT", "/a.txt", 405}, + } + for _, p := range cases { + rec := serve(svc, p.method, p.path) + if rec.Code != p.want { + t.Errorf("%s: code=%d want %d (body=%q)", p.name, rec.Code, p.want, rec.Body.String()) + } + } + + if linkErr != nil { + t.Skipf("symlink unavailable: %v", linkErr) + } + if rec := serve(svc, "GET", "/evil.txt"); rec.Code != 404 { + t.Errorf("symlink escape: code=%d want 404", rec.Code) + } +} + +func TestFileServerRequiresLoopbackToken(t *testing.T) { + root := t.TempDir() + mustWriteFile(t, filepath.Join(root, "a.txt"), "AAA") + svc := newTestFileServer(t, root) + + req := httptest.NewRequest("GET", "http://example.com/a.txt", nil) + rec := httptest.NewRecorder() + svc.ServeHTTP(rec, req) + if rec.Code != 403 { + t.Errorf("request without the loopback header: code=%d want 403", rec.Code) + } + + wrong := httptest.NewRequest("GET", "http://example.com/a.txt", nil) + wrong.Header.Set(localFileAuthHeader, "not-the-token") + rec = httptest.NewRecorder() + svc.ServeHTTP(rec, wrong) + if rec.Code != 403 { + t.Errorf("request with a wrong header: code=%d want 403", rec.Code) + } +} + +func TestNewFileServerOnlyChecksSyntax(t *testing.T) { + // Configuration parsing must not touch the filesystem: a config file may be + // validated on a machine where the directory does not exist. + if _, err := newFileServer(""); err == nil { + t.Error("expected error for an empty path, got nil") + } + + missing := filepath.Join(t.TempDir(), "does-not-exist") + svc, err := newFileServer(missing) + if err != nil { + t.Fatalf("newFileServer must not stat the directory, got: %v", err) + } + if err := svc.resolveRoot(); err == nil { + t.Error("resolveRoot must fail for a missing directory") + } +} + +func TestResolveRootRejectsNonDirectory(t *testing.T) { + root := t.TempDir() + file := filepath.Join(root, "f.txt") + mustWriteFile(t, file, "x") + + svc, err := newFileServer(file) + if err != nil { + t.Fatalf("newFileServer: %v", err) + } + if err := svc.resolveRoot(); err == nil { + t.Error("resolveRoot must reject a regular file") + } +} + +func mustWriteFile(t *testing.T, path, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } +}