From 858b51ff554b823c9cf943723db48224ef130d99 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Mon, 2 Mar 2026 10:50:09 +0200 Subject: config: handle UserHomeDir failures (task 400) --- internal/config/config.go | 101 +++++++++++++++++++++++++++++++++-------- internal/config/config_test.go | 84 ++++++++++++++++++++++++++++++++++ 2 files changed, 165 insertions(+), 20 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index b9ae6c2..b8d0532 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -13,8 +13,12 @@ import ( "strings" ) -// configPath is the location of the optional user config file. -const configPath = "~/.config/foostore.json" +const ( + // configPath is the location of the optional user config file. + configPath = "~/.config/foostore.json" + // fallbackHomeDirName is used when we cannot resolve a valid home directory. + fallbackHomeDirName = "foostore-home" +) // Config holds all application-wide configuration values. // JSON field names use snake_case to match the original geheim.rb Config::DEFAULTS keys. @@ -31,14 +35,53 @@ type Config struct { SyncRepos []string `json:"sync_repos"` } -// defaultConfig returns a Config populated with built-in defaults. -// EditCmd honours the $EDITOR environment variable and falls back to "vi" -// when the variable is unset or empty, so users get their preferred editor -// automatically without touching the config file. -// It calls os.UserHomeDir() so that path fields expand correctly at runtime. -func defaultConfig() Config { - home, _ := os.UserHomeDir() +// resolveHomeDir resolves the current user's home directory from OS state. +// If resolution succeeds with os.UserHomeDir, error is nil. +// If resolution falls back to HOME or a temp-based directory, an explanatory +// non-nil error is returned so callers can warn without failing hard. +func resolveHomeDir() (string, error) { + return resolveHomeDirFrom(os.UserHomeDir, os.Getenv("HOME"), os.TempDir()) +} + +// resolveHomeDirFrom is a test seam for home directory resolution. +func resolveHomeDirFrom(userHomeDir func() (string, error), envHome, tempDir string) (string, error) { + home, err := userHomeDir() + if err == nil && home != "" { + return home, nil + } + + if envHome != "" && filepath.IsAbs(envHome) { + if err != nil { + return envHome, fmt.Errorf("os.UserHomeDir failed; using HOME=%q: %w", envHome, err) + } + return envHome, fmt.Errorf("os.UserHomeDir returned empty home; using HOME=%q", envHome) + } + + fallbackHome := filepath.Join(tempDir, fallbackHomeDirName) + if envHome != "" && !filepath.IsAbs(envHome) { + if err != nil { + return fallbackHome, fmt.Errorf("os.UserHomeDir failed; HOME is not absolute (%q), using %q: %w", envHome, fallbackHome, err) + } + return fallbackHome, fmt.Errorf("os.UserHomeDir returned empty home; HOME is not absolute (%q), using %q", envHome, fallbackHome) + } + + if err != nil { + return fallbackHome, fmt.Errorf("os.UserHomeDir failed and HOME is unavailable; using %q: %w", fallbackHome, err) + } + return fallbackHome, fmt.Errorf("os.UserHomeDir returned empty home and HOME is unavailable; using %q", fallbackHome) +} + +// homeDirOrFallback resolves a usable home path and logs fallback reasons. +func homeDirOrFallback() string { + home, err := resolveHomeDir() + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: %v\n", err) + } + return home +} +// defaultConfigWithHome returns built-in defaults using the supplied home path. +func defaultConfigWithHome(home string) Config { // Prefer $EDITOR; fall back to vi if not set. editCmd := os.Getenv("EDITOR") if editCmd == "" { @@ -59,22 +102,39 @@ func defaultConfig() Config { } } -// expandTilde replaces a leading "~" in path with the user's home directory. -// Non-tilde paths and empty strings are returned unchanged. -func expandTilde(path string) string { +// defaultConfig returns a Config populated with built-in defaults. +// EditCmd honours the $EDITOR environment variable and falls back to "vi" +// when the variable is unset or empty, so users get their preferred editor +// automatically without touching the config file. +func defaultConfig() Config { + return defaultConfigWithHome(homeDirOrFallback()) +} + +// expandTildeWithHome replaces a leading "~" in path with the supplied home. +func expandTildeWithHome(path, home string) string { if path == "" || !strings.HasPrefix(path, "~") { return path } - home, _ := os.UserHomeDir() // Replace only the leading "~"; preserve any subdirectory suffix. return home + path[1:] } +// expandTilde replaces a leading "~" in path with the user's home directory. +// Non-tilde paths and empty strings are returned unchanged. +func expandTilde(path string) string { + return expandTildeWithHome(path, homeDirOrFallback()) +} + +// expandPathFieldsWithHome tilde-expands every path-typed field in cfg in place. +func expandPathFieldsWithHome(cfg *Config, home string) { + cfg.DataDir = expandTildeWithHome(cfg.DataDir, home) + cfg.ExportDir = expandTildeWithHome(cfg.ExportDir, home) + cfg.KeyFile = expandTildeWithHome(cfg.KeyFile, home) +} + // expandPathFields tilde-expands every path-typed field in cfg in place. func expandPathFields(cfg *Config) { - cfg.DataDir = expandTilde(cfg.DataDir) - cfg.ExportDir = expandTilde(cfg.ExportDir) - cfg.KeyFile = expandTilde(cfg.KeyFile) + expandPathFieldsWithHome(cfg, homeDirOrFallback()) } // Load reads ~/.config/foostore.json and merges it over the built-in defaults. @@ -86,8 +146,9 @@ func expandPathFields(cfg *Config) { // Note: the Ruby reference uses puts (stdout) for this warning; we use stderr // intentionally because warnings belong on the error stream. func Load() Config { - cfg := defaultConfig() - path := expandTilde(configPath) + home := homeDirOrFallback() + cfg := defaultConfigWithHome(home) + path := expandTildeWithHome(configPath, home) data, err := os.ReadFile(path) if err != nil { @@ -103,12 +164,12 @@ func Load() Config { // JSON document are overwritten; all others retain their default values. if err := json.Unmarshal(data, &cfg); err != nil { fmt.Fprintf(os.Stderr, "Unable to read %s, using defaults! %v\n", path, err) - return defaultConfig() + return defaultConfigWithHome(home) } // Tilde-expand path fields that may have been supplied as "~/…" strings // in the JSON file (defaultConfig() already returns absolute paths, but // user-supplied values might use "~"). - expandPathFields(&cfg) + expandPathFieldsWithHome(&cfg, home) return cfg } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 96b6a9d..4b2e7d9 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -2,6 +2,7 @@ package config import ( "bytes" + "errors" "io" "os" "path/filepath" @@ -65,6 +66,89 @@ func TestExpandTilde(t *testing.T) { } } +func TestResolveHomeDirFrom(t *testing.T) { + lookupErr := errors.New("lookup failed") + + cases := []struct { + name string + userHome string + userErr error + envHome string + tempDir string + wantHome string + wantErrContains string + }{ + { + name: "user home success", + userHome: "/users/alice", + tempDir: "/tmp", + wantHome: "/users/alice", + }, + { + name: "falls back to absolute HOME when user lookup fails", + userErr: lookupErr, + envHome: "/env/home", + tempDir: "/tmp", + wantHome: "/env/home", + wantErrContains: "using HOME", + }, + { + name: "falls back to absolute HOME when user lookup is empty", + envHome: "/env/home", + tempDir: "/tmp", + wantHome: "/env/home", + wantErrContains: "returned empty home", + }, + { + name: "relative HOME falls back to temp-based path", + userErr: lookupErr, + envHome: "relative/home", + tempDir: "/tmp/runtime", + wantHome: "/tmp/runtime/foostore-home", + wantErrContains: "HOME is not absolute", + }, + { + name: "missing HOME falls back to temp-based path", + userErr: lookupErr, + tempDir: "/tmp/runtime", + wantHome: "/tmp/runtime/foostore-home", + wantErrContains: "HOME is unavailable", + }, + { + name: "empty user home and missing HOME falls back to temp-based path", + tempDir: "/tmp/runtime", + wantHome: "/tmp/runtime/foostore-home", + wantErrContains: "returned empty home and HOME is unavailable", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + gotHome, err := resolveHomeDirFrom( + func() (string, error) { return tc.userHome, tc.userErr }, + tc.envHome, + tc.tempDir, + ) + if gotHome != tc.wantHome { + t.Fatalf("home = %q; want %q", gotHome, tc.wantHome) + } + + if tc.wantErrContains == "" { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + return + } + if err == nil { + t.Fatalf("expected error containing %q; got nil", tc.wantErrContains) + } + if !strings.Contains(err.Error(), tc.wantErrContains) { + t.Fatalf("error = %q; want substring %q", err.Error(), tc.wantErrContains) + } + }) + } +} + // ---- Load() ---------------------------------------------------------------- // TestLoad_defaults verifies all 10 default values when no config file exists. -- cgit v1.2.3