diff options
| author | Jeff Halter <868228+jhalter@users.noreply.github.com> | 2026-07-09 18:28:47 -0700 |
|---|---|---|
| committer | Jeff Halter <868228+jhalter@users.noreply.github.com> | 2026-07-09 18:28:47 -0700 |
| commit | 631b7f1f99c2ff6295152210640a2f9b2c4f5a88 (patch) | |
| tree | c9bc509610835ad5283928a4b317aa4ed228e13d | |
| parent | 4bd4c14daff4cb8807a91305239cae1e8544f276 (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.go | 24 | ||||
| -rw-r--r-- | internal/mobius/config.go | 7 | ||||
| -rw-r--r-- | internal/mobius/config_test.go | 39 |
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 |