aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJeff Halter <868228+jhalter@users.noreply.github.com>2026-07-09 18:28:47 -0700
committerJeff Halter <868228+jhalter@users.noreply.github.com>2026-07-09 18:28:47 -0700
commit631b7f1f99c2ff6295152210640a2f9b2c4f5a88 (patch)
treec9bc509610835ad5283928a4b317aa4ed228e13d
parent4bd4c14daff4cb8807a91305239cae1e8544f276 (diff)
Resolve FileRoot per storage backend to keep object keys host-independent
LoadConfig unconditionally rewrote a relative FileRoot to an absolute path under the config dir. R2FileStore.key() then embedded that local path in every object key (e.g. Users/jhalter/.../config/Files/...), so bucket contents were tied to the host's directory layout and orphaned by moving the config dir or pointing another server at the bucket. FileRoot is a path within the selected file store's namespace, so only the backend selection in main knows how to resolve it: the os backend resolves relative values against the config dir as before, while the memory and r2 backends keep the configured value verbatim, yielding portable keys like Files/foo.txt. An absolute FileRoot combined with an object-store backend now logs a warning. WithConfig copies the config struct, so the option is appended after the backend selection mutates FileRoot rather than before.
-rw-r--r--cmd/mobius-hotline-server/main.go24
-rw-r--r--internal/mobius/config.go7
-rw-r--r--internal/mobius/config_test.go39
3 files changed, 63 insertions, 7 deletions
diff --git a/cmd/mobius-hotline-server/main.go b/cmd/mobius-hotline-server/main.go
index 9e59fa8..52099ad 100644
--- a/cmd/mobius-hotline-server/main.go
+++ b/cmd/mobius-hotline-server/main.go
@@ -8,6 +8,7 @@ import (
"flag"
"fmt"
"io"
+ "log/slog"
"os"
"os/signal"
"path"
@@ -102,7 +103,6 @@ func main() {
hotline.WithInterface(*netInterface),
hotline.WithLogger(slogger),
hotline.WithPort(*basePort),
- hotline.WithConfig(*config),
}
if tlsConfig != nil {
opts = append(opts, hotline.WithTLS(tlsConfig, *tlsPort))
@@ -113,10 +113,17 @@ func main() {
// the concrete FileStore and pass it via hotline.WithFileStore.
switch *fileStoreBackend {
case "os", "":
- // Default OSFileStore is set by NewServer; nothing to do.
+ // Default OSFileStore is set by NewServer; nothing to do beyond resolving FileRoot,
+ // which is a host filesystem path for this backend only. Object-store backends treat
+ // FileRoot as a path within the store's own namespace and keep it as configured, so a
+ // relative FileRoot yields host-independent object keys.
+ if !filepath.IsAbs(config.FileRoot) {
+ config.FileRoot = filepath.Join(*configDir, config.FileRoot)
+ }
case "memory":
opts = append(opts, hotline.WithFileStore(hotline.NewMemFileStore()))
slogger.Warn("Using in-memory file store; uploaded files are not persisted")
+ warnAbsoluteFileRoot(slogger, config.FileRoot)
case "r2":
r2Store, err := newR2FileStore(ctx)
if err != nil {
@@ -125,11 +132,16 @@ func main() {
}
opts = append(opts, hotline.WithFileStore(r2Store))
slogger.Info("Using Cloudflare R2 file store", "bucket", os.Getenv("R2_BUCKET"))
+ warnAbsoluteFileRoot(slogger, config.FileRoot)
default:
slogger.Error("Unknown file-store backend", "backend", *fileStoreBackend)
os.Exit(1)
}
+ // The config is passed by value, so this must come after the backend selection above, which
+ // resolves config.FileRoot for the chosen backend.
+ opts = append(opts, hotline.WithConfig(*config))
+
srv, err := hotline.NewServer(opts...)
if err != nil {
slogger.Error("Error starting server", "err", err)
@@ -287,6 +299,14 @@ type namedReloader struct {
reloader mobius.Reloader
}
+// warnAbsoluteFileRoot flags an absolute FileRoot when an object-store backend is selected: the
+// path is used verbatim as the key namespace, so host filesystem layout would leak into every key.
+func warnAbsoluteFileRoot(logger *slog.Logger, fileRoot string) {
+ if filepath.IsAbs(fileRoot) {
+ logger.Warn("FileRoot is an absolute path; object keys will embed it verbatim. Use a relative FileRoot for host-independent keys.", "FileRoot", fileRoot)
+ }
+}
+
// findConfigPath searches for an existing config directory from the predefined search order.
// Returns the first directory that exists, or falls back to "config" as the default.
func findConfigPath() string {
diff --git a/internal/mobius/config.go b/internal/mobius/config.go
index 6e5fb9f..171f6c7 100644
--- a/internal/mobius/config.go
+++ b/internal/mobius/config.go
@@ -52,10 +52,7 @@ func LoadConfig(path string) (*hotline.Config, error) {
return nil, fmt.Errorf("validate config: %v", err)
}
- // If the FileRoot is an absolute path, use it, otherwise treat as a relative path to the config dir.
- if !filepath.IsAbs(config.FileRoot) {
- config.FileRoot = filepath.Join(path, "../", config.FileRoot)
- }
-
+ // FileRoot is returned verbatim: it is a path within the selected file store's namespace, so
+ // only the caller knows how to resolve it (e.g. against the config dir for the OS backend).
return &config, nil
}
diff --git a/internal/mobius/config_test.go b/internal/mobius/config_test.go
index b76b16d..bed739d 100644
--- a/internal/mobius/config_test.go
+++ b/internal/mobius/config_test.go
@@ -41,6 +41,45 @@ FileRoot: "files"
}
}
+// TestLoadConfig_FileRootKeptVerbatim guards against LoadConfig resolving FileRoot to a host
+// filesystem path. FileRoot is a path within the selected file store's namespace; rewriting it to
+// a local absolute path here would leak the host's directory layout into object-store keys.
+func TestLoadConfig_FileRootKeptVerbatim(t *testing.T) {
+ tests := []struct {
+ name string
+ fileRoot string
+ }{
+ {"relative path", "files"},
+ {"nested relative path", "library/files"},
+ {"absolute path", "/srv/hotline/files"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ tmpDir := t.TempDir()
+
+ configContent := `
+Name: "Test Server"
+Description: "Test Description"
+FileRoot: "` + tt.fileRoot + `"
+`
+ configPath := filepath.Join(tmpDir, "config.yaml")
+ if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
+ t.Fatalf("Failed to write config file: %v", err)
+ }
+
+ config, err := LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("Expected no error, got: %v", err)
+ }
+
+ if config.FileRoot != tt.fileRoot {
+ t.Errorf("Expected FileRoot to be %q, got %q", tt.fileRoot, config.FileRoot)
+ }
+ })
+ }
+}
+
func TestLoadConfig_ValidBannerFileExtensions(t *testing.T) {
tests := []struct {
name string