diff --git a/go.mod b/go.mod index 0041b1d..533cc5c 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.24.0 require ( github.com/fatih/color v1.18.0 - github.com/open-cli-collective/cli-common v0.0.0-20260516182733-b753d5c62d14 + github.com/open-cli-collective/cli-common v0.0.0-20260519134256-e67b2fc81f9d github.com/spf13/cobra v1.8.0 github.com/stretchr/testify v1.11.1 golang.org/x/term v0.27.0 diff --git a/go.sum b/go.sum index 7bb6b28..d979eb2 100644 --- a/go.sum +++ b/go.sum @@ -30,8 +30,8 @@ github.com/mtibben/percent v0.2.1 h1:5gssi8Nqo8QU/r2pynCm+hBQHpkB/uNK7BJCFogWdzs github.com/mtibben/percent v0.2.1/go.mod h1:KG9uO+SZkUp+VkRHsCdYQV3XSZrrSpR3O9ibNBTZrns= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= -github.com/open-cli-collective/cli-common v0.0.0-20260516182733-b753d5c62d14 h1:78EW5uCbAzbAO32+oY4HDaFOqS2sPYnc4AT+G5UjdL0= -github.com/open-cli-collective/cli-common v0.0.0-20260516182733-b753d5c62d14/go.mod h1:5i4MkFToMVPLBW29O01lsHS9d1m9pC0BxSOYjFDz7ds= +github.com/open-cli-collective/cli-common v0.0.0-20260519134256-e67b2fc81f9d h1:fiyfxc/Wvpuen/u6Mk3bCUG0DLyOylF0K+eol4y9C64= +github.com/open-cli-collective/cli-common v0.0.0-20260519134256-e67b2fc81f9d/go.mod h1:5i4MkFToMVPLBW29O01lsHS9d1m9pC0BxSOYjFDz7ds= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= diff --git a/internal/cmd/configcmd/config.go b/internal/cmd/configcmd/config.go index acb0884..cf5995d 100644 --- a/internal/cmd/configcmd/config.go +++ b/internal/cmd/configcmd/config.go @@ -201,11 +201,15 @@ func runSet(opts *root.Options, accountID, region string) error { if err := cfg.Save(); err != nil { return fmt.Errorf("save config: %w", err) } + cfgPath, err := config.Path() + if err != nil { + return err + } if accountID != "" { - v.Success("account_id set to %s in %s", accountID, config.Path()) + v.Success("account_id set to %s in %s", accountID, cfgPath) } if region != "" { - v.Success("region set to %s in %s", strings.ToUpper(region), config.Path()) + v.Success("region set to %s in %s", strings.ToUpper(region), cfgPath) } return nil } @@ -290,7 +294,11 @@ func newShowCmd(opts *root.Options) *cobra.Command { func runShow(opts *root.Options) error { v := opts.View() - cfg, err := config.Load() + // `config show` is a pure-read diagnostic and must remain usable during + // an unresolved §1.8 / MON-5373 relocation conflict — LoadForRuntime + // soft-degrades (canonical wins, one-shot stderr warning) when both old + // and new diverge, instead of refusing to display anything. + cfg, err := config.LoadForRuntime() if err != nil { return err } @@ -454,29 +462,59 @@ config.yml}.`, func runClear(o *clearOptions) error { v := o.View() - // OpenNoMigrate: clear is the advertised §1.8 conflict remediation; if - // migration ran first it would fail with the conflict error before clear - // could delete the keyring entry, leaving the user no way out. + + // `clear --all` is cleanup/conflict-remediation — it must work even when + // config.yml is unparseable, credential_ref is invalid, or keyring.backend + // is unrecognized. Resolve paths up front (no Load() call) so file scrub + // is unconditional; treat store-open as best-effort under --all so an + // unrecoverable config can't block the very command meant to wipe it. + configPaths, perr := configPathsForClear() + if perr != nil { + return perr + } + credPaths, cerr := config.CredentialFileCandidates() + if cerr != nil { + return cerr + } + st, err := keychain.OpenNoMigrate() if err != nil { - return err + if !o.all { + return err + } + // --all: report and proceed — file scrub still runs and is the + // recovery path. Without --all the user just asked to clear the + // keyring, so surface the error. + v.Warning("could not open keyring (%v) — proceeding with file scrub only", err) + st = nil + } else { + defer func() { _ = st.Close() }() } - defer func() { _ = st.Close() }() if o.dryRun { - if st.HasAPIKey() { + switch { + case st == nil: + v.Println("would remove: (keyring inaccessible; --all file scrub only)") + case st.HasAPIKey(): v.Println("would remove: api_key from keyring " + st.Ref()) - } else { + default: v.Println("would remove: (no api_key in keyring)") } if o.all { - if _, statErr := os.Stat(config.Path()); statErr == nil { - v.Println("would remove: " + config.Path()) - } else { + anyConfig := false + for _, p := range configPaths { + if _, statErr := os.Stat(p); statErr == nil { + v.Println("would remove: " + p) + anyConfig = true + } + } + if !anyConfig { v.Println("would remove: (no config.yml)") } - if _, statErr := os.Stat(config.LegacyCredentialsPath()); statErr == nil { - v.Println("would remove: " + config.LegacyCredentialsPath() + " (legacy plaintext)") + for _, p := range credPaths { + if _, statErr := os.Stat(p); statErr == nil { + v.Println("would remove: " + p + " (legacy plaintext)") + } } if runtime.GOOS == "darwin" { v.Println("would remove: legacy macOS Keychain accounts for service \"newrelic-cli\" (if present)") @@ -485,37 +523,47 @@ func runClear(o *clearOptions) error { return nil } - removed, err := st.Clear() - if err != nil { - return fmt.Errorf("clear keyring bundle: %w", err) - } - if len(removed) > 0 { - v.Success("Removed %d key(s) from keyring %s", len(removed), st.Ref()) - } else { - v.Println("No keyring keys to remove (already clear)") + if st != nil { + removed, err := st.Clear() + if err != nil { + return fmt.Errorf("clear keyring bundle: %w", err) + } + if len(removed) > 0 { + v.Success("Removed %d key(s) from keyring %s", len(removed), st.Ref()) + } else { + v.Println("No keyring keys to remove (already clear)") + } } if o.all { - if err := os.Remove(config.Path()); err != nil { - if !os.IsNotExist(err) { - return fmt.Errorf("remove %s: %w", config.Path(), err) + anyConfig := false + for _, p := range configPaths { + if err := os.Remove(p); err != nil { + if !os.IsNotExist(err) { + return fmt.Errorf("remove %s: %w", p, err) + } + continue } + v.Success("Removed %s", p) + anyConfig = true + } + if !anyConfig { v.Println("No config.yml to remove (already clear)") - } else { - v.Success("Removed %s", config.Path()) } - // Also scrub the legacy plaintext credentials file. Without this a + // Also scrub the plaintext credentials file(s). Without this a // `clear --all` on a workstation that never ran the §1.8 migration // leaves the legacy secret on disk, and the next Open() re-migrates - // it back into the keyring — silently undoing the wipe. - if lp := config.LegacyCredentialsPath(); lp != "" { + // it back into the keyring — silently undoing the wipe. Dual-path: + // both the post-MON-5373 canonical location and the pre-port hand- + // rolled location. + for _, lp := range credPaths { if err := os.Remove(lp); err != nil { if !os.IsNotExist(err) { return fmt.Errorf("remove %s: %w", lp, err) } - } else { - v.Success("Removed %s (legacy plaintext)", lp) + continue } + v.Success("Removed %s (legacy plaintext)", lp) } // Also scrub the legacy macOS Keychain accounts: otherwise a // pre-migration `clear --all` on macOS is silently undone by the @@ -526,3 +574,18 @@ func runClear(o *clearOptions) error { } return nil } + +// configPathsForClear returns the deduped set of config.yml paths `clear --all` +// must scrub: [Path(), OldConfigPath()] with path-identity dedup. Linux +// collapses to one path (statedir ≡ old hand-rolled location). +func configPathsForClear() ([]string, error) { + canonical, err := config.Path() + if err != nil { + return nil, err + } + old, oerr := config.OldConfigPath() + if oerr == nil && old != canonical { + return []string{canonical, old}, nil + } + return []string{canonical}, nil +} diff --git a/internal/cmd/initcmd/init.go b/internal/cmd/initcmd/init.go index a55a366..80b9fa7 100644 --- a/internal/cmd/initcmd/init.go +++ b/internal/cmd/initcmd/init.go @@ -89,6 +89,19 @@ func runInit(opts *initOptions) error { return errors.New("provide at most one of --account-id / --account-id-from-env") } + // MON-5373 relocation gate: runs BEFORE keychain.OpenForInit so a + // divergent old↔new config aborts BEFORE the §1.8 migration could scrub + // the legacy plaintext file or write the canonical config. On an + // old-only-readable surface we copy old→new (leave-old) so subsequent + // loads see the canonical location. + if reloc, err := config.DetectConfigRelocation(); err != nil { + return fmt.Errorf("detecting config relocation: %w", err) + } else if reloc.CopyNeeded { + if err := config.ApplyConfigRelocation(reloc); err != nil { + return fmt.Errorf("relocating config from %s to %s: %w", reloc.OldPath, reloc.NewPath, err) + } + } + // wantPrompt gates every interactive fallback. --non-interactive forces // fail-loud (cli-deployment-manifest §1.3) so the central installer's // `nrq init` is deterministic regardless of TTY. @@ -200,7 +213,11 @@ func runInit(opts *initOptions) error { // OR config.yml already exists (keep it consistent / persist a folded // migration). A secret-only ingress in a pipeline must not create or // touch config.yml just to restate the default credential_ref. - _, statErr := os.Stat(config.Path()) + cfgPath, perr := config.Path() + if perr != nil { + return perr + } + _, statErr := os.Stat(cfgPath) if accountID != "" || region != "" || statErr == nil { if err := cfg.Save(); err != nil { return fmt.Errorf("save config: %w", err) diff --git a/internal/cmd/initcmd/init_relocate_test.go b/internal/cmd/initcmd/init_relocate_test.go new file mode 100644 index 0000000..4de568b --- /dev/null +++ b/internal/cmd/initcmd/init_relocate_test.go @@ -0,0 +1,123 @@ +// Init-gate ordering tests for the MON-5373 relocation gate. These pin the +// invariant that DetectConfigRelocation / ApplyConfigRelocation runs BEFORE +// keychain.OpenForInit (which would otherwise scrub the legacy plaintext +// credentials file as part of the §1.8 migration). MON-5372's lesson: +// asserting "no Save() happened" is not enough — seed a legacy artifact and +// assert it's still present post-abort to prove the gate ran before the +// migration could touch it. +package initcmd_test + +import ( + "bytes" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/open-cli-collective/newrelic-cli/internal/cmd/initcmd" + "github.com/open-cli-collective/newrelic-cli/internal/cmd/root" + "github.com/open-cli-collective/newrelic-cli/internal/config" + "github.com/open-cli-collective/newrelic-cli/internal/testutil" +) + +// TestRunInit_RelocationGate_OldOnlyCopied: the old hand-rolled location has +// a config.yml and the canonical doesn't — init copies old → new BEFORE +// running migration. Skipped on Linux (old==new). +func TestRunInit_RelocationGate_OldOnlyCopied(t *testing.T) { + if runtime.GOOS == "linux" { + t.Skip("Linux: old==new (statedir ≡ $XDG_CONFIG_HOME); gate is a no-op") + } + testutil.Setup(t) + + // Seed old hand-rolled config.yml. + oldDir := filepath.Dir(testutil.LegacyCredentialsPath(t)) // …/.config/newrelic-cli + require.NoError(t, os.WriteFile(filepath.Join(oldDir, "config.yml"), + []byte("credential_ref: newrelic-cli/default\naccount_id: \"42\"\nregion: EU\n"), 0o600)) + + // Run init with --no-verify and a fresh api-key via stdin so it doesn't + // fail loud — the gate should fire before keychain.OpenForInit either way. + _, _, err := runInitStdin(t, "NRAK-test-relocate-001\n", + "--api-key-stdin", "--no-verify", "--non-interactive") + require.NoError(t, err) + + // Canonical config.yml now exists with copied content. + cfgPath, err := config.Path() + require.NoError(t, err) + raw, err := os.ReadFile(cfgPath) + require.NoError(t, err) + assert.Contains(t, string(raw), `account_id: "42"`) + assert.Contains(t, string(raw), "region: EU") + + // Old config.yml still present (leave-old invariant). + _, statErr := os.Stat(filepath.Join(oldDir, "config.yml")) + assert.NoError(t, statErr, "old config.yml must remain (leave-old recovery point)") +} + +// TestRunInit_RelocationGate_DivergentAbortsBeforeMutation: both paths hold +// materially-different configs AND a legacy credentials file is present at +// the OLD path. Init must abort BEFORE keychain.OpenForInit could run the +// §1.8 migration and scrub the legacy file. Asserting the legacy file is +// still present post-abort PROVES the gate runs ahead of migration (the +// MON-5372 ordering lesson). +func TestRunInit_RelocationGate_DivergentAbortsBeforeMutation(t *testing.T) { + if runtime.GOOS == "linux" { + t.Skip("Linux: old==new; gate is a no-op") + } + testutil.Setup(t) + + // Seed divergent configs at both paths. + oldDir := filepath.Dir(testutil.LegacyCredentialsPath(t)) + require.NoError(t, os.WriteFile(filepath.Join(oldDir, "config.yml"), + []byte("credential_ref: newrelic-cli/old\n"), 0o600)) + + canonical := testutil.ConfigDir(t) + require.NoError(t, os.WriteFile(filepath.Join(canonical, "config.yml"), + []byte("credential_ref: newrelic-cli/canonical\n"), 0o600)) + + // Seed a plaintext legacy credentials file at the OLD path. If the gate + // were to run AFTER keychain.OpenForInit, the §1.8 migration would + // discover this and scrub it. Asserting it's still here post-abort is + // the proof. + legacyFile := testutil.LegacyCredentialsPath(t) + require.NoError(t, os.WriteFile(legacyFile, + []byte("api_key=NRAK-init-ordering-proof\n"), 0o600)) + + _, _, err := runInitStdin(t, "NRAK-test-divergent-002\n", + "--api-key-stdin", "--no-verify", "--non-interactive") + require.Error(t, err, "init must abort on a relocation conflict") + + // Two-part proof the GATE (not keychain.OpenForInit) rejected: + // (1) error message identifies the gate wrapper from runInit (Codex + // PR-r1 catch: a strict-Load failure inside OpenForInit would + // carry a different message — keychain.OpenForInit doesn't wrap + // with "detecting config relocation"). + // (2) the legacy credentials file is UNTOUCHED — if the gate had run + // AFTER OpenForInit, the §1.8 migration would have scrubbed it + // (and the absent file would be visible here). + assert.Contains(t, err.Error(), "detecting config relocation", + "error must come from runInit's gate wrapper, not a downstream strict-Load failure") + _, statErr := os.Stat(legacyFile) + assert.NoError(t, statErr, + "legacy credentials file must still exist — proves the gate ran before §1.8 migration scrub") +} + +// runInitStdin is runInit with a piped stdin string for --api-key-stdin. +func runInitStdin(t *testing.T, stdin string, args ...string) (string, string, error) { + t.Helper() + rootCmd, opts := root.NewRootCmd() + var out, errb bytes.Buffer + opts.Stdout, opts.Stderr = &out, &errb + opts.Stdin = bytes.NewBufferString(stdin) + rootCmd.SetOut(&out) + rootCmd.SetErr(&errb) + root.RegisterAll(rootCmd, opts, func(c *cobra.Command, o *root.Options) { + initcmd.Register(c, o) + }) + rootCmd.SetArgs(append([]string{"init"}, args...)) + err := rootCmd.Execute() + return out.String(), errb.String(), err +} diff --git a/internal/config/config.go b/internal/config/config.go index 305d67c..afc6ccc 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -16,11 +16,13 @@ package config import ( + "errors" "fmt" "os" "path/filepath" "strings" + "github.com/open-cli-collective/cli-common/statedir" "gopkg.in/yaml.v3" ) @@ -37,11 +39,21 @@ const ( appDirName = "newrelic-cli" configFileName = "config.yml" + credFileName = "credentials" + + // DirPerm/FilePerm are the family-wide on-disk perms (§1.2): 0700 for + // the config dir, 0600 for config.yml and any temp file in flight. + DirPerm = 0o700 + FilePerm = 0o600 envAccountID = "NEWRELIC_ACCOUNT_ID" envRegion = "NEWRELIC_REGION" ) +// configScope is the family-wide shared resolver for nrq's config dir. +// Single-binary CLI ⇒ scope name = tool/repo name (working-with-state.md §6.4). +var configScope = statedir.Scope{Name: appDirName} + // Source describes where a resolved non-secret value came from, for // `config show` (§2.5: show the source of each non-secret value). type Source string @@ -73,39 +85,139 @@ type KeyringConfig struct { Backend string `yaml:"backend,omitempty"` } -// Dir is the cross-platform config directory: $XDG_CONFIG_HOME/newrelic-cli -// else ~/.config/newrelic-cli. Identical on Linux, macOS, and Windows — this -// matches the released legacy layout (the legacy code has no %APPDATA% -// branch), so config.yml sits beside the legacy credentials file it -// supersedes and migration discovery needs no platform special-casing. -func Dir() string { - if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" { - return filepath.Join(xdg, appDirName) - } - home, _ := os.UserHomeDir() - return filepath.Join(home, ".config", appDirName) -} +// Dir is the cross-platform config directory, resolved by cli-common's +// statedir.Scope: native per OS (Linux `$XDG_CONFIG_HOME`/`~/.config`, macOS +// `~/Library/Application Support`, Windows `%APPDATA%`). A relative +// `$XDG_CONFIG_HOME` is a hard error (§1.1: no cwd fallback). +func Dir() (string, error) { return configScope.ConfigDir() } // Path is the config.yml location. -func Path() string { return filepath.Join(Dir(), configFileName) } +func Path() (string, error) { + d, err := Dir() + if err != nil { + return "", err + } + return filepath.Join(d, configFileName), nil +} + +// CanonicalCredentialsPath is the post-port location of nrq's plaintext +// credentials file (Dir() + "credentials"). After the resolver switch this +// is where a hand-rolled write WOULD land on macOS/Windows — symmetric with +// the pre-port location on Linux. +func CanonicalCredentialsPath() (string, error) { + d, err := Dir() + if err != nil { + return "", err + } + return filepath.Join(d, credFileName), nil +} + +// CredentialFileCandidates returns the plaintext credentials-file paths the +// §1.8 migrator scans AND `clear --all` scrubs: [OldHandRolledCredentialsPath, +// CanonicalCredentialsPath], path-identity deduped (Linux old≡new collapses +// to one). The single shared helper means migrator and clear can never drift. +func CredentialFileCandidates() ([]string, error) { + canonical, err := CanonicalCredentialsPath() + if err != nil { + return nil, err + } + old, oerr := OldHandRolledCredentialsPath() + if oerr == nil && old != canonical { + return []string{old, canonical}, nil + } + return []string{canonical}, nil +} // Load reads config.yml. An absent file is not an error: defaults are applied // (CredentialRef = DefaultCredentialRef) and a usable Config is returned. +// Relocation-aware: on a macOS/Windows resolver-switch transition where only +// the old hand-rolled path holds a config.yml, Load reads that as a fallback +// (mutation-free — init owns the actual copy). On a both-present divergence +// it returns the canonical config AND a non-nil ErrRelocationConflict-wrapped +// error so non-init callers can choose to hard-fail (strict Load) vs +// soft-degrade (LoadForRuntime). func Load() (*Config, error) { - c := &Config{} - data, err := os.ReadFile(Path()) + newDir, err := Dir() + if err != nil { + return nil, err + } + return loadFromNewDir(newDir) +} + +// loadFromNewDir is the testable seam — production AND tests both call this. +// Splitting it out avoids the "test helper as parallel implementation" trap +// (slck MON-5372 Codex r1 lesson): production can never silently regress +// past a passing test. +func loadFromNewDir(newDir string) (*Config, error) { + newYML := filepath.Join(newDir, configFileName) + reloc, relErr := detectRelocation(newDir) + + // Try canonical first (the post-port location). + c, read, err := readConfigYML(newYML) + if err != nil { + // Canonical exists but is unreadable/malformed — propagate. + // Under a relocation conflict, the canonical-malformed signal must + // surface as ErrRelocationConflict so LoadForRuntime cannot + // soft-degrade and silently swap CredentialRef to default (the + // MON-5371 contract). + if relErr != nil && errors.Is(relErr, ErrRelocationConflict) { + return nil, relErr + } + return nil, err + } + if read { + // Canonical readable. Apply defaults; if also under conflict, return + // the cfg + the wrapped conflict error so LoadForRuntime can warn- + // and-soft-degrade with a real cfg (cfg != nil contract). + c.applyDefaults() + out := c + if relErr != nil { + return &out, relErr + } + return &out, nil + } + + // Canonical absent. Try the old hand-rolled location as a runtime + // fallback (Linux: same path, already covered). Mutation-free — init's + // gate owns the actual copy. + if reloc.Kind == relocOldOnly { + oldYML := filepath.Join(reloc.OldPath, configFileName) + oc, oread, oerr := readConfigYML(oldYML) + if oerr != nil { + return nil, oerr + } + if oread { + oc.applyDefaults() + return &oc, nil + } + } + + // Neither — absent on both sides; defaults. + if relErr != nil && errors.Is(relErr, ErrRelocationConflict) { + // Conflict without a readable canonical: the only way this happens + // is a malformed pair detected by detectRelocation; hard-fail. + return nil, relErr + } + out := &Config{} + out.applyDefaults() + return out, nil +} + +// readConfigYML parses one path; returns (cfg, found, err). Missing file +// returns (zero, false, nil) — distinct from a malformed parse. +func readConfigYML(path string) (Config, bool, error) { + data, err := os.ReadFile(path) //nolint:gosec // path composed from validated config dir if err != nil { if os.IsNotExist(err) { - c.applyDefaults() - return c, nil + return Config{}, false, nil } - return nil, fmt.Errorf("read config %s: %w", Path(), err) + return Config{}, false, fmt.Errorf("read config %s: %w", path, err) } - if err := yaml.Unmarshal(data, c); err != nil { - return nil, fmt.Errorf("parse config %s: %w", Path(), err) + var c Config + if uerr := yaml.Unmarshal(data, &c); uerr != nil { + return Config{}, false, fmt.Errorf("parse config %s: %w", path, uerr) } - c.applyDefaults() - return c, nil + return c, true, nil } func (c *Config) applyDefaults() { @@ -121,20 +233,22 @@ func (c *Config) applyDefaults() { // the next Open() — pointing at a different keyring bundle than the user // configured. Non-secret, but no reason to be world-readable. func (c *Config) Save() error { - if err := os.MkdirAll(Dir(), 0o700); err != nil { + dir, err := configScope.ConfigDirEnsured() + if err != nil { return fmt.Errorf("create config dir: %w", err) } - data, err := yaml.Marshal(c) - if err != nil { - return fmt.Errorf("marshal config: %w", err) + path := filepath.Join(dir, configFileName) + data, merr := yaml.Marshal(c) + if merr != nil { + return fmt.Errorf("marshal config: %w", merr) } - tmp, err := os.CreateTemp(Dir(), ".config-*.yml.tmp") - if err != nil { - return fmt.Errorf("create temp config: %w", err) + tmp, terr := os.CreateTemp(dir, ".config-*.yml.tmp") + if terr != nil { + return fmt.Errorf("create temp config: %w", terr) } tmpName := tmp.Name() defer func() { _ = os.Remove(tmpName) }() // no-op once renamed - if err := tmp.Chmod(0o600); err != nil { + if err := tmp.Chmod(FilePerm); err != nil { _ = tmp.Close() return fmt.Errorf("chmod temp config: %w", err) } @@ -145,18 +259,12 @@ func (c *Config) Save() error { if err := tmp.Close(); err != nil { return fmt.Errorf("close temp config: %w", err) } - if err := os.Rename(tmpName, Path()); err != nil { - return fmt.Errorf("rename config into place %s: %w", Path(), err) + if err := os.Rename(tmpName, path); err != nil { + return fmt.Errorf("rename config into place %s: %w", path, err) } return nil } -// LegacyCredentialsPath is the pre-credstore plaintext file location. It is -// derived from Dir() so it provably tracks the SAME XDG/HOME resolution as -// config.yml — the old code respected XDG_CONFIG_HOME, and the §1.8 -// migration must look exactly where the old code wrote. -func LegacyCredentialsPath() string { return filepath.Join(Dir(), "credentials") } - // ResolveAccountID returns the effective account ID and its source. // Precedence is env > config.yml (§2.5: non-secret runtime override useful // for multi-account scripting). Empty config + no env → ("", SourceUnset). diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 7c102e8..a29ed1f 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -3,6 +3,7 @@ package config_test import ( "os" "path/filepath" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -25,7 +26,9 @@ func TestSaveLoad_RoundTrip_NoSecretField(t *testing.T) { c := &config.Config{CredentialRef: "newrelic-cli/default", AccountID: "12345", Region: "EU"} require.NoError(t, c.Save()) - raw, err := os.ReadFile(config.Path()) + cfgPath, err := config.Path() + require.NoError(t, err) + raw, err := os.ReadFile(cfgPath) require.NoError(t, err) assert.NotContains(t, string(raw), "api_key", "config.yml must never carry a secret field") @@ -74,7 +77,45 @@ func TestResolveRegion_Precedence_DefaultUS(t *testing.T) { assert.Equal(t, config.SourceEnv, src) } -func TestDir_XDGRespected(t *testing.T) { +// TestDir_StatedirContract verifies the cli-common statedir.Scope resolver +// is what backs config.Dir(): the path equals os.UserConfigDir()/newrelic-cli +// under the hermetic 7-var harness, is absolute, and sits under the hermetic +// root. (A behavioral round-trip alone would still pass if the hand-rolled +// resolver accidentally remained — Codex r1 catch.) +func TestDir_StatedirContract(t *testing.T) { tmp := testutil.Setup(t) - assert.Equal(t, filepath.Join(tmp, "xdgconfig", "newrelic-cli"), config.Dir()) + dir, err := config.Dir() + require.NoError(t, err) + + osCfg, err := os.UserConfigDir() + require.NoError(t, err) + assert.Equal(t, filepath.Join(osCfg, "newrelic-cli"), dir, + "Dir() must equal os.UserConfigDir()/newrelic-cli — i.e. routed through cli-common/statedir") + assert.True(t, filepath.IsAbs(dir), "Dir() must be absolute") + assert.True(t, strings.HasPrefix(dir, tmp), "Dir() must sit under the hermetic root %q (got %q)", tmp, dir) + + // Save / Load round-trip + perm checks (no stale temp). + c := &config.Config{CredentialRef: "newrelic-cli/default", AccountID: "42"} + require.NoError(t, c.Save()) + + di, err := os.Stat(dir) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o700), di.Mode().Perm(), "config dir must be 0700") + + cfgPath, err := config.Path() + require.NoError(t, err) + fi, err := os.Stat(cfgPath) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o600), fi.Mode().Perm(), "config.yml must be 0600") + + entries, err := os.ReadDir(dir) + require.NoError(t, err) + for _, e := range entries { + assert.False(t, strings.HasSuffix(e.Name(), ".tmp"), + "no stale temp file should remain post-Save (got %s)", e.Name()) + } + + got, err := config.Load() + require.NoError(t, err) + assert.Equal(t, "42", got.AccountID) } diff --git a/internal/config/relocate.go b/internal/config/relocate.go new file mode 100644 index 0000000..584988a --- /dev/null +++ b/internal/config/relocate.go @@ -0,0 +1,265 @@ +package config + +import ( + "errors" + "fmt" + "io" + "os" + "path/filepath" + "reflect" + "sync" +) + +// ErrRelocationConflict is returned by Load (and surfaced through +// LoadForRuntime) when both the old hand-rolled config dir and the new +// statedir-resolved config dir contain a config.yml whose default-applied +// content differs. Mutation-free: nothing is copied, nothing is overwritten. +// The user reconciles by running `nrq init` (which aborts at its pre-write +// gate) or by deleting one side. +var ErrRelocationConflict = errors.New("config: shared old/new config diverge") + +// relocKind is the four-way classification used by the relocation detector. +// Linux always collapses to relocNone because old and new paths are identical +// (statedir on Linux ≡ $XDG_CONFIG_HOME else $HOME/.config). +type relocKind int + +const ( + relocNone relocKind = iota // old absent OR old==new (Linux short-circuit) + relocOldOnly // only the old hand-rolled config.yml exists + relocBothEqual // both exist with materially-equal Configs + relocBothDivergent // both exist with materially-different Configs (or malformed) +) + +// SharedRelocation is the result of DetectConfigRelocation. +type SharedRelocation struct { + Kind relocKind + OldPath string // old hand-rolled config dir; "" on no-HOME edge + NewPath string // statedir-resolved config dir + CopyNeeded bool // relocOldOnly only +} + +// oldHandRolledConfigDir reproduces the pre-MON-5373 resolver: +// $XDG_CONFIG_HOME if set, else $HOME/.config; then "/newrelic-cli". Same +// shape across OSes (the released layout had no %APPDATA% branch). A missing +// HOME is an error (matches the original). +func oldHandRolledConfigDir() (string, error) { + configHome := os.Getenv("XDG_CONFIG_HOME") + if configHome == "" { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + configHome = filepath.Join(home, ".config") + } + return filepath.Join(configHome, appDirName), nil +} + +// OldConfigPath returns the pre-MON-5373 hand-rolled config.yml location. +// Exported so `nrq config clear --all` can scrub it alongside the new path +// (an old file would otherwise silently resurrect config post-clear on the +// next runtime-fallback Load). +func OldConfigPath() (string, error) { + dir, err := oldHandRolledConfigDir() + if err != nil { + return "", err + } + return filepath.Join(dir, configFileName), nil +} + +// OldHandRolledCredentialsPath returns the pre-MON-5373 location of the +// plaintext credentials file ($XDG_CONFIG_HOME/$HOME/.config/newrelic-cli/ +// credentials). The §1.8 keychain migrator scans this AND the new-canonical +// location (see CredentialFileCandidates in config.go). +func OldHandRolledCredentialsPath() (string, error) { + dir, err := oldHandRolledConfigDir() + if err != nil { + return "", err + } + return filepath.Join(dir, credFileName), nil +} + +// DetectConfigRelocation classifies the old/new pair without touching disk +// beyond stats and reads. Never copies, never writes. On Linux (old==new) it +// short-circuits to relocNone. +func DetectConfigRelocation() (SharedRelocation, error) { + newDir, err := Dir() + if err != nil { + return SharedRelocation{}, err + } + return detectRelocation(newDir) +} + +// detectRelocation is the testable core: the new-dir path is injected so +// macOS/Windows divergence can be exercised on Linux CI. +func detectRelocation(newDir string) (SharedRelocation, error) { + oldDir, err := oldHandRolledConfigDir() + if err != nil { + // Old path unresolvable (no HOME): treat as relocNone with new-only. + // Load still works against new; the gate is harmless. + return SharedRelocation{Kind: relocNone, NewPath: newDir}, nil + } + if oldDir == newDir { + // Linux: identical paths — nothing to relocate. + return SharedRelocation{Kind: relocNone, OldPath: oldDir, NewPath: newDir}, nil + } + + oldYML := filepath.Join(oldDir, configFileName) + newYML := filepath.Join(newDir, configFileName) + oldPresent := fileExists(oldYML) + newPresent := fileExists(newYML) + switch { + case !oldPresent && !newPresent: + return SharedRelocation{Kind: relocNone, OldPath: oldDir, NewPath: newDir}, nil + case oldPresent && !newPresent: + // Validate old parses cleanly BEFORE signaling CopyNeeded — otherwise + // init's ApplyConfigRelocation would propagate a malformed legacy + // file into the new dir and Load would die parsing it post-copy. + // §3.2 malformed-old: fail loud, mutate nothing. (MON-5371 lesson.) + if _, _, oerr := readConfigYML(oldYML); oerr != nil { + return SharedRelocation{Kind: relocBothDivergent, OldPath: oldDir, NewPath: newDir}, + fmt.Errorf("%w: old %s is malformed: %w", ErrRelocationConflict, oldYML, oerr) + } + return SharedRelocation{Kind: relocOldOnly, OldPath: oldDir, NewPath: newDir, CopyNeeded: true}, nil + case !oldPresent && newPresent: + return SharedRelocation{Kind: relocNone, OldPath: oldDir, NewPath: newDir}, nil + } + + // Both present — load and compare comparable subset. + oldCfg, _, oerr := readConfigYML(oldYML) + newCfg, _, nerr := readConfigYML(newYML) + if oerr != nil { + return SharedRelocation{Kind: relocBothDivergent, OldPath: oldDir, NewPath: newDir}, + fmt.Errorf("%w: old %s unreadable: %w", ErrRelocationConflict, oldYML, oerr) + } + if nerr != nil { + return SharedRelocation{Kind: relocBothDivergent, OldPath: oldDir, NewPath: newDir}, + fmt.Errorf("%w: new %s unreadable: %w", ErrRelocationConflict, newYML, nerr) + } + if configsMaterialEqual(oldCfg, newCfg) { + return SharedRelocation{Kind: relocBothEqual, OldPath: oldDir, NewPath: newDir}, nil + } + return SharedRelocation{Kind: relocBothDivergent, OldPath: oldDir, NewPath: newDir}, + fmt.Errorf("%w: old %s and new %s have different settings; reconcile (or delete one) before running nrq init", + ErrRelocationConflict, oldYML, newYML) +} + +// configsMaterialEqual compares two Configs after applying defaults on both +// sides (so an omitted credential_ref — semantically DefaultCredentialRef — +// compares equal to an explicit DefaultCredentialRef). reflect.DeepEqual on +// the whole default-applied struct: future Config fields are automatic +// divergence-triggers, no maintenance burden. +func configsMaterialEqual(a, b Config) bool { + a.applyDefaults() + b.applyDefaults() + return reflect.DeepEqual(a, b) +} + +// ApplyConfigRelocation copies the single config.yml file from old → new +// atomically; idempotent (skips if new already exists). The old dir is NOT +// modified — leave-old gives the user a recovery point and matches the +// MON-5370/5371/5372 family invariant. Called only from `nrq init`'s +// pre-write gate. +func ApplyConfigRelocation(r SharedRelocation) error { + if !r.CopyNeeded { + return nil + } + if r.OldPath == "" || r.NewPath == "" { + return fmt.Errorf("config: ApplyConfigRelocation called with empty path") + } + if err := os.MkdirAll(r.NewPath, DirPerm); err != nil { + return fmt.Errorf("create new config dir: %w", err) + } + src := filepath.Join(r.OldPath, configFileName) + dst := filepath.Join(r.NewPath, configFileName) + if fileExists(dst) { + return nil // idempotent + } + return copyFileAtomic(src, dst) +} + +func copyFileAtomic(src, dst string) error { + in, err := os.Open(src) //nolint:gosec // path from old config dir + if err != nil { + return err + } + defer func() { _ = in.Close() }() + + dir := filepath.Dir(dst) + tmp, err := os.CreateTemp(dir, filepath.Base(dst)+"-*.tmp") + if err != nil { + return err + } + tmpPath := tmp.Name() + if _, err := io.Copy(tmp, in); err != nil { + _ = tmp.Close() + _ = os.Remove(tmpPath) + return err + } + if err := tmp.Close(); err != nil { + _ = os.Remove(tmpPath) + return err + } + if err := os.Chmod(tmpPath, FilePerm); err != nil { + _ = os.Remove(tmpPath) + return err + } + if err := os.Rename(tmpPath, dst); err != nil { + _ = os.Remove(tmpPath) + return err + } + return nil +} + +// fileExists distinguishes "not present" from other stat errors. A +// permission-denied (or any other non-IsNotExist) error must NOT silently +// degrade to "absent" — that would let an oddly-permissioned old dir +// collapse an old-only relocation to a no-op. Treat unknown errors as +// "present" so the relocation flow's subsequent open/read surfaces the +// real error instead of skipping the gate. +func fileExists(path string) bool { + _, err := os.Stat(path) + if err == nil { + return true + } + return !os.IsNotExist(err) +} + +// LoadForRuntime is the soft-conflict variant of Load for pure-read callers +// (config show, OpenNoMigrate). On ErrRelocationConflict it prints a one-shot +// stderr warning and returns the canonical (new-dir) config so the command +// can keep working — BUT only when a canonical config was actually read. If +// Load couldn't populate cfg (malformed YAML on the canonical side), the +// runtime must hard-fail instead of warning-and-defaulting; otherwise it +// would silently swap CredentialRef back to DefaultCredentialRef and mask +// the corrupt file (MON-5371 contract). +// +// Mutating callers (keychain.Open with §1.8 migration, config set, init +// post-gate) MUST use strict Load — under unresolved conflict the migration +// would scrub legacy + write canonical exactly the gate exists to prevent. +func LoadForRuntime() (*Config, error) { + newDir, err := Dir() + if err != nil { + return nil, err + } + return loadForRuntimeFromNewDir(newDir) +} + +// loadForRuntimeFromNewDir is the testable seam — LoadForRuntime and tests +// both call this. The unexported-seam pattern prevents the test-helper-as- +// parallel-implementation trap (slck MON-5372 lesson). +func loadForRuntimeFromNewDir(newDir string) (*Config, error) { + cfg, err := loadFromNewDir(newDir) + if err != nil && errors.Is(err, ErrRelocationConflict) && cfg != nil { + warnReloConflictOnce(err) + return cfg, nil + } + return cfg, err +} + +var reloConflictOnce sync.Once + +func warnReloConflictOnce(err error) { + reloConflictOnce.Do(func() { + fmt.Fprintf(os.Stderr, "warning: %v; using the new config. Run `nrq init` to reconcile.\n", err) + }) +} diff --git a/internal/config/relocate_test.go b/internal/config/relocate_test.go new file mode 100644 index 0000000..f438fcf --- /dev/null +++ b/internal/config/relocate_test.go @@ -0,0 +1,187 @@ +// Package config relocation tests (§3.2 8-row matrix + LoadForRuntime +// soft-degrade contract). These use the unexported testable seams so +// production code paths are what the tests exercise — no parallel +// implementations (slck MON-5372 lesson). +package config + +import ( + "errors" + "os" + "path/filepath" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/open-cli-collective/cli-common/statedirtest" +) + +// setupRelocPair gives the relocation tests two GUARANTEED-DISTINCT dirs even +// on Linux (where statedir's resolver collapses old==new). The unexported +// seams (detectRelocation/loadFromNewDir) accept newDir as a parameter, so +// we can synthesize an "alternate canonical" dir under the hermetic root and +// exercise the macOS/Windows divergent layout on Linux CI. Without this, +// every relocation row would skip on the cheapest CI lane. +func setupRelocPair(t *testing.T) (oldDir, newDir string) { + t.Helper() + tmp := statedirtest.Hermetic(t) + old, err := oldHandRolledConfigDir() + require.NoError(t, err) + + // Synthetic distinct new-dir — anywhere outside oldDir. Using a sibling + // of the hermetic root prevents accidental old-vs-new ambiguity. + new := filepath.Join(tmp, "synthetic-statedir", "newrelic-cli") + require.NoError(t, os.MkdirAll(old, 0o700)) + require.NoError(t, os.MkdirAll(new, 0o700)) + if old == new { + t.Fatalf("setupRelocPair: synthetic newDir collided with oldDir: %s", old) + } + return old, new +} + +func writeYAML(t *testing.T, path, body string) { + t.Helper() + require.NoError(t, os.WriteFile(path, []byte(body), 0o600)) +} + +// Row 1: new-only. +func TestRelocate_NewOnly_NoOp(t *testing.T) { + _, new := setupRelocPair(t) + writeYAML(t, filepath.Join(new, configFileName), "credential_ref: newrelic-cli/default\n") + + got, err := detectRelocation(new) + require.NoError(t, err) + assert.Equal(t, relocNone, got.Kind) + assert.False(t, got.CopyNeeded) + + cfg, err := loadFromNewDir(new) + require.NoError(t, err) + assert.Equal(t, "newrelic-cli/default", cfg.CredentialRef) +} + +// Row 2: old-only well-formed. +func TestRelocate_OldOnly_CopyNeeded(t *testing.T) { + old, new := setupRelocPair(t) + writeYAML(t, filepath.Join(old, configFileName), "credential_ref: newrelic-cli/legacy\naccount_id: \"42\"\n") + + got, err := detectRelocation(new) + require.NoError(t, err) + assert.Equal(t, relocOldOnly, got.Kind) + assert.True(t, got.CopyNeeded) + + // Runtime fallback reads old without copying. + cfg, err := loadFromNewDir(new) + require.NoError(t, err) + assert.Equal(t, "newrelic-cli/legacy", cfg.CredentialRef) + assert.Equal(t, "42", cfg.AccountID) + + _, statErr := os.Stat(filepath.Join(new, configFileName)) + assert.True(t, os.IsNotExist(statErr), "Load must not write to new dir") +} + +// Row 3: old-only malformed (must fail loud BEFORE CopyNeeded — MON-5371). +func TestRelocate_OldOnly_Malformed_FailsLoud(t *testing.T) { + old, new := setupRelocPair(t) + writeYAML(t, filepath.Join(old, configFileName), "[unclosed_array: yes\n") + + got, err := detectRelocation(new) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrRelocationConflict), "must wrap ErrRelocationConflict") + assert.Equal(t, relocBothDivergent, got.Kind) + assert.False(t, got.CopyNeeded, "must NOT set CopyNeeded on malformed-old — would propagate corrupt bytes to new dir") +} + +// Row 4: both materially equal (defaults-applied). +func TestRelocate_BothEqual_DefaultOmittedVsExplicit_IsEqual(t *testing.T) { + old, new := setupRelocPair(t) + // Old omits credential_ref (defaults to DefaultCredentialRef); new is explicit. + writeYAML(t, filepath.Join(old, configFileName), "account_id: \"42\"\n") + writeYAML(t, filepath.Join(new, configFileName), "credential_ref: newrelic-cli/default\naccount_id: \"42\"\n") + + got, err := detectRelocation(new) + require.NoError(t, err) + assert.Equal(t, relocBothEqual, got.Kind) + assert.False(t, got.CopyNeeded) +} + +// Row 5: both, divergent → ErrRelocationConflict. +func TestRelocate_BothDivergent_Conflict(t *testing.T) { + old, new := setupRelocPair(t) + writeYAML(t, filepath.Join(old, configFileName), "credential_ref: newrelic-cli/old\n") + writeYAML(t, filepath.Join(new, configFileName), "credential_ref: newrelic-cli/new\n") + + got, err := detectRelocation(new) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrRelocationConflict)) + assert.Equal(t, relocBothDivergent, got.Kind) +} + +// Row 6: both, malformed-new — canonical unreadable; runtime hard-fail. +func TestRelocate_MalformedNew_HardFail(t *testing.T) { + old, new := setupRelocPair(t) + writeYAML(t, filepath.Join(old, configFileName), "credential_ref: newrelic-cli/old\n") + writeYAML(t, filepath.Join(new, configFileName), "[unclosed_array: yes\n") + + cfg, err := loadFromNewDir(new) + require.Error(t, err, "malformed canonical under conflict must hard-fail") + assert.Nil(t, cfg, "no Config returned on canonical-malformed conflict (MON-5371 contract)") + assert.True(t, errors.Is(err, ErrRelocationConflict)) +} + +// Row 7: both, malformed-old. +func TestRelocate_MalformedOld_Conflict(t *testing.T) { + old, new := setupRelocPair(t) + writeYAML(t, filepath.Join(old, configFileName), "[unclosed_array: yes\n") + writeYAML(t, filepath.Join(new, configFileName), "credential_ref: newrelic-cli/default\n") + + got, err := detectRelocation(new) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrRelocationConflict)) + assert.Equal(t, relocBothDivergent, got.Kind) +} + +// Row 8: neither. +func TestRelocate_Neither_Defaults(t *testing.T) { + _, new := setupRelocPair(t) + got, err := detectRelocation(new) + require.NoError(t, err) + assert.Equal(t, relocNone, got.Kind) + + cfg, err := loadFromNewDir(new) + require.NoError(t, err) + assert.Equal(t, DefaultCredentialRef, cfg.CredentialRef) +} + +// LoadForRuntime soft-degrade contract: under both-divergent with a readable +// canonical, returns canonical + nil error (after warning) — never silently +// swaps CredentialRef to default. +func TestLoadForRuntime_DivergentReadableCanonical_SoftDegrade(t *testing.T) { + old, new := setupRelocPair(t) + writeYAML(t, filepath.Join(old, configFileName), "credential_ref: newrelic-cli/old\n") + writeYAML(t, filepath.Join(new, configFileName), "credential_ref: newrelic-cli/canonical\n") + // Reset the once gate so this test gets the warning side-effect deterministic + reloConflictOnce = resetOnce() + + cfg, err := loadForRuntimeFromNewDir(new) + require.NoError(t, err, "soft-degrade: readable canonical under conflict → warn + return canonical, no error") + require.NotNil(t, cfg) + assert.Equal(t, "newrelic-cli/canonical", cfg.CredentialRef, "must NOT swap to default") +} + +// LoadForRuntime cfg!=nil contract: malformed canonical under conflict must +// hard-fail (no warn-and-default — that would mask corruption). +func TestLoadForRuntime_DivergentMalformedCanonical_HardFail(t *testing.T) { + old, new := setupRelocPair(t) + writeYAML(t, filepath.Join(old, configFileName), "credential_ref: newrelic-cli/old\n") + writeYAML(t, filepath.Join(new, configFileName), "[unclosed_array: yes\n") + reloConflictOnce = resetOnce() + + cfg, err := loadForRuntimeFromNewDir(new) + require.Error(t, err, "malformed canonical under conflict must hard-fail (MON-5371 contract)") + assert.Nil(t, cfg) +} + +// resetOnce returns a fresh sync.Once so warn-once side effects don't bleed +// between tests in this file. +func resetOnce() sync.Once { return sync.Once{} } diff --git a/internal/keychain/keychain.go b/internal/keychain/keychain.go index 7f240be..04667a1 100644 --- a/internal/keychain/keychain.go +++ b/internal/keychain/keychain.go @@ -72,7 +72,16 @@ func OpenForInit(overwrite, nonInteractive bool) (*Store, error) { func OpenNoMigrate() (*Store, error) { return open(false, false, false) } func open(overwrite, runMigration, nonInteractive bool) (*Store, error) { - cfg, err := config.Load() + // MON-5373 contract: mutating Open paths (migration=true) MUST strict-load + // — a divergent old/new config under unresolved conflict cannot be + // allowed to scrub legacy + write canonical. Non-migrating Open paths + // (clear-conflict-remediation, OpenRef ingress) soft-degrade via + // LoadForRuntime so cleanup is not blocked by an unresolved conflict. + loadCfg := config.Load + if !runMigration { + loadCfg = config.LoadForRuntime + } + cfg, err := loadCfg() if err != nil { return nil, err } @@ -86,7 +95,9 @@ func open(overwrite, runMigration, nonInteractive bool) (*Store, error) { // configured ref. set-credential is pure ingress; migration still runs on the // next init / first API call via the default Open path. func OpenRef(ref string) (*Store, error) { - cfg, err := config.Load() + // Pure ingress; migration does not run here. Soft-degrade on relocation + // conflict so set-credential can complete during a transitional state. + cfg, err := config.LoadForRuntime() if err != nil { return nil, err } diff --git a/internal/keychain/keychain_test.go b/internal/keychain/keychain_test.go index 2d764d4..af6dea2 100644 --- a/internal/keychain/keychain_test.go +++ b/internal/keychain/keychain_test.go @@ -3,7 +3,6 @@ package keychain_test import ( "errors" "os" - "path/filepath" "testing" "github.com/stretchr/testify/assert" @@ -92,15 +91,14 @@ func TestOpenRef_OverridesConfiguredRef(t *testing.T) { // to the keyring, account_id/region folded into config.yml, the legacy file // scrubbed — on the first Open(). func TestMigration_LegacyFile_EndToEnd(t *testing.T) { - tmp := testutil.Setup(t) - legacyDir := filepath.Join(tmp, ".config", "newrelic-cli") - require.NoError(t, os.MkdirAll(legacyDir, 0o700)) - legacy := filepath.Join(legacyDir, "credentials") + testutil.Setup(t) + // Use the testutil helper that resolves under the hermetic envs — do NOT + // hand-build the path or rewrite XDG_CONFIG_HOME after Setup, which + // would break the statedirtest 7-var layout post-MON-5373 (Codex PR-r2 + // minor). + legacy := testutil.LegacyCredentialsPath(t) require.NoError(t, os.WriteFile(legacy, []byte("api_key=NRAK-legacy\naccount_id=42\nregion=EU\n"), 0o600)) - // discover() uses ~/.config (HOME), not XDG; point XDG at HOME so - // config.yml lands beside it deterministically for the assertion. - t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmp, ".config")) st, err := keychain.Open() // runs the one-time migration require.NoError(t, err) @@ -118,7 +116,9 @@ func TestMigration_LegacyFile_EndToEnd(t *testing.T) { _, statErr := os.Stat(legacy) assert.True(t, os.IsNotExist(statErr), "legacy file must be scrubbed after migration") - raw, err := os.ReadFile(config.Path()) + cfgPath, err := config.Path() + require.NoError(t, err) + raw, err := os.ReadFile(cfgPath) require.NoError(t, err) assert.NotContains(t, string(raw), "NRAK-legacy", "secret must never land in config.yml") diff --git a/internal/keychain/migrate.go b/internal/keychain/migrate.go index ac8c188..c902f8f 100644 --- a/internal/keychain/migrate.go +++ b/internal/keychain/migrate.go @@ -168,12 +168,16 @@ func migrateLegacyOverwrite(s *Store, cfg *config.Config, overwrite bool) error "(the keyring already held it); this is a one-time operation\n", secretField, s.ref) } + cfgPath, perr := config.Path() + if perr != nil { + return perr + } for _, f := range plan.movedNonSecret { // Distinct human line: non-secret moves go to config.yml, // NOT the keyring — do not reuse the keyring wording. fmt.Fprintf(os.Stderr, "migrated %s to config %s; this is a one-time operation\n", - f, config.Path()) + f, cfgPath) } } } @@ -284,8 +288,12 @@ func planMigration(service, profile, ref string, cfg *config.Config, d discovere p.foldRegion = c.value } p.movedNonSecret = append(p.movedNonSecret, field) + cfgPath, perr := config.Path() + if perr != nil { + return migrationPlan{}, perr + } p.changes = append(p.changes, credstore.MigrationJSONEntry( - field, c.location, fmt.Sprintf("config:%s#%s", config.Path(), field))) + field, c.location, fmt.Sprintf("config:%s#%s", cfgPath, field))) } sort.Strings(p.movedNonSecret) return p, nil @@ -391,11 +399,51 @@ func discover() (discovered, error) { } } - path := legacyCredentialsPath() - fileVals := readLegacyFile(path) - if len(fileVals) > 0 { - // One file, one idempotent deleter shared by every field it - // contributed (the first cleanup removes it; the rest are no-ops). + // Plaintext credentials file: enumerate BOTH the old hand-rolled location + // AND the new canonical (post-MON-5373) location, path-identity deduped. + // The file lives in the same dir as config.yml, so the resolver switch + // relocates it on macOS/Windows — without dual-probe a workstation + // upgraded across the port would silently retain a plaintext secret in + // the old dir. The resolver is a package var so unit tests can exercise + // the dual-path matrix on Linux CI (where statedir collapses old≡new). + paths, err := credentialFileCandidates() + if err != nil { + return discovered{}, err + } + type pathVals struct { + path string + vals map[string]string + } + var present []pathVals + for _, p := range paths { + v := readLegacyFile(p) + if len(v) > 0 { + present = append(present, pathVals{path: p, vals: v}) + } + } + + // Conflict semantics on the parsed/effective projection (api_key / + // account_id / region). Byte-equal would false-conflict on harmless + // ordering or trailing-newline differences (Codex r2 catch). + if len(present) > 1 { + fields := append([]string{secretField}, nonSecretFields...) + for _, f := range fields { + a, ok1 := present[0].vals[f] + b, ok2 := present[1].vals[f] + if ok1 && ok2 && a != b { + return discovered{}, fmt.Errorf( + "legacy credentials diverge between %s and %s on field %q; reconcile (delete one) before re-running", + present[0].path, present[1].path, f) + } + } + } + + // Per-path enumeration: each path contributes its own location string and + // its own deleter. On Linux the dedup above collapses to one path; on + // macOS/Windows BOTH paths are reported and BOTH are scrubbed on success. + for _, pv := range present { + path := pv.path + fileVals := pv.vals fileDeleter := func() error { if err := os.Remove(path); err != nil && !os.IsNotExist(err) { return err @@ -452,11 +500,11 @@ func ScrubLegacyKeychain() error { return errors.Join(errs...) } -// legacyCredentialsPath delegates to config.LegacyCredentialsPath so the -// §1.8 discovery looks at exactly the same XDG/HOME-resolved location the -// old code wrote to and the new config.yml uses (single source of truth — -// no XDG-divergence gap for users who ran the old nrq with XDG_CONFIG_HOME). -func legacyCredentialsPath() string { return config.LegacyCredentialsPath() } +// credentialFileCandidates is the package-level seam for credentials-file +// discovery. Production routes through config.CredentialFileCandidates; +// tests can override to exercise the §1.8 dual-probe matrix on Linux CI +// where statedir's resolver collapses old≡new (Codex PR-r1 portability fix). +var credentialFileCandidates = config.CredentialFileCandidates // readLegacyFile parses the legacy flat key=value credentials file (NOT an // INI; no [sections]). Missing file → nil (the steady state, not an error). diff --git a/internal/keychain/migrate_dualprobe_test.go b/internal/keychain/migrate_dualprobe_test.go new file mode 100644 index 0000000..622e891 --- /dev/null +++ b/internal/keychain/migrate_dualprobe_test.go @@ -0,0 +1,113 @@ +// MON-5373 dual-probe tests: the §1.8 migrator must enumerate the plaintext +// credentials file at BOTH the old hand-rolled location AND the new +// statedir-resolved canonical location. These exercise the matrix through +// the package-level `credentialFileCandidates` seam so Linux CI sees the +// macOS/Windows divergent layout (where statedir's resolver collapses old≡new +// the seam injects synthetic distinct paths instead). +package keychain + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/open-cli-collective/newrelic-cli/internal/testutil" +) + +func withSyntheticCandidates(t *testing.T) (oldPath, newPath string) { + t.Helper() + tmp := testutil.Setup(t) + oldDir := filepath.Join(tmp, "synthetic-old", "newrelic-cli") + newDir := filepath.Join(tmp, "synthetic-new", "newrelic-cli") + require.NoError(t, os.MkdirAll(oldDir, 0o700)) + require.NoError(t, os.MkdirAll(newDir, 0o700)) + oldPath = filepath.Join(oldDir, "credentials") + newPath = filepath.Join(newDir, "credentials") + + prev := credentialFileCandidates + credentialFileCandidates = func() ([]string, error) { + return []string{oldPath, newPath}, nil + } + t.Cleanup(func() { credentialFileCandidates = prev }) + return oldPath, newPath +} + +func writeFile(t *testing.T, path, body string) { + t.Helper() + require.NoError(t, os.WriteFile(path, []byte(body), 0o600)) +} + +// Old-only: only the pre-port hand-rolled location holds a credentials file. +func TestDiscover_DualProbe_OldOnly(t *testing.T) { + oldPath, _ := withSyntheticCandidates(t) + writeFile(t, oldPath, "api_key=NRAK-old-only\naccount_id=42\n") + + d, err := discover() + require.NoError(t, err) + require.Len(t, d.secrets, 1) + assert.Equal(t, "NRAK-old-only", d.secrets[0].value) + assert.Contains(t, d.secrets[0].location, oldPath) +} + +// New-only: only the post-port canonical (Dir() + "credentials") holds one. +func TestDiscover_DualProbe_NewOnly(t *testing.T) { + _, newPath := withSyntheticCandidates(t) + writeFile(t, newPath, "api_key=NRAK-new-only\n") + + d, err := discover() + require.NoError(t, err) + require.Len(t, d.secrets, 1) + assert.Equal(t, "NRAK-new-only", d.secrets[0].value) + assert.Contains(t, d.secrets[0].location, newPath) +} + +// Both equal on the parsed projection: BOTH files contribute, BOTH deleters +// are added so a successful migration scrubs both files. +func TestDiscover_DualProbe_BothEqual_DeletesBoth(t *testing.T) { + oldPath, newPath := withSyntheticCandidates(t) + writeFile(t, oldPath, "api_key=NRAK-equal-007\naccount_id=42\n") + writeFile(t, newPath, "api_key=NRAK-equal-007\naccount_id=42\n") + + d, err := discover() + require.NoError(t, err) + + var deletePaths []string + for _, del := range d.deleters { + deletePaths = append(deletePaths, del.label) + } + joined := strings.Join(deletePaths, "|") + assert.Contains(t, joined, oldPath, "old path must be in deleter set") + assert.Contains(t, joined, newPath, "new path must be in deleter set") +} + +// Divergent on api_key → fail loud, mutate nothing. +func TestDiscover_DualProbe_Divergent_FailsLoud(t *testing.T) { + oldPath, newPath := withSyntheticCandidates(t) + writeFile(t, oldPath, "api_key=NRAK-old-divergent\n") + writeFile(t, newPath, "api_key=NRAK-new-divergent\n") + + _, err := discover() + require.Error(t, err, "divergent legacy credentials must fail loud") + assert.Contains(t, err.Error(), "diverge") + + // Both files must be untouched after fail-loud. + _, statErr := os.Stat(oldPath) + assert.NoError(t, statErr, "old file must remain after fail-loud") + _, statErr = os.Stat(newPath) + assert.NoError(t, statErr, "new file must remain after fail-loud") +} + +// Divergent on a non-secret field (account_id) → also fails loud. +func TestDiscover_DualProbe_Divergent_NonSecret_FailsLoud(t *testing.T) { + oldPath, newPath := withSyntheticCandidates(t) + writeFile(t, oldPath, "api_key=NRAK-shared\naccount_id=11\n") + writeFile(t, newPath, "api_key=NRAK-shared\naccount_id=22\n") + + _, err := discover() + require.Error(t, err, "divergent non-secret field must also fail loud") + assert.Contains(t, err.Error(), "diverge") +} diff --git a/internal/noleak/noleak_test.go b/internal/noleak/noleak_test.go index 5236535..4633f4c 100644 --- a/internal/noleak/noleak_test.go +++ b/internal/noleak/noleak_test.go @@ -10,6 +10,7 @@ import ( "errors" "os" "path/filepath" + "runtime" "strings" "testing" @@ -29,6 +30,16 @@ import ( const sentinel = "NRAK-SUPERSECRETSENTINEL-do-not-leak" +// mustConfigPath resolves the canonical config.yml path under the hermetic +// harness; tests fail-fast on the §1.1 relative-XDG error (would never occur +// under Setup). +func mustConfigPath(t *testing.T) string { + t.Helper() + p, err := config.Path() + require.NoError(t, err) + return p +} + // probeRegister adds hidden commands that go through the REAL lazy // chokepoint `opts.APIClient()` (which opens the keyring and runs the §1.8 // migration) — exactly what an API command does, minus the network call. So @@ -92,13 +103,14 @@ func run(t *testing.T, args ...string) (string, string, error) { return out.String(), errb.String() + pipeBuf.String(), err } -func plantLegacyFile(t *testing.T, tmp string) { +func plantLegacyFile(t *testing.T, _ string) { t.Helper() - dir := filepath.Join(tmp, ".config", "newrelic-cli") - require.NoError(t, os.MkdirAll(dir, 0o700)) - require.NoError(t, os.WriteFile(filepath.Join(dir, "credentials"), + // Use the testutil helper that resolves under the existing hermetic envs + // — do NOT hand-build $TMP/.config/newrelic-cli and rewrite + // XDG_CONFIG_HOME, which would break the 7-var statedirtest layout. + path := testutil.LegacyCredentialsPath(t) + require.NoError(t, os.WriteFile(path, []byte("api_key="+sentinel+"\naccount_id=42\nregion=EU\n"), 0o600)) - t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmp, ".config")) } // §1.12: the secret never appears in any human-facing surface or config.yml. @@ -115,7 +127,7 @@ func TestNoLeak_ShowAndConfigFile(t *testing.T) { assert.NotContains(t, out, sentinel) assert.NotContains(t, errOut, sentinel) - raw, _ := os.ReadFile(config.Path()) + raw, _ := os.ReadFile(mustConfigPath(t)) assert.NotContains(t, string(raw), sentinel, "secret must never be in config.yml") } @@ -321,7 +333,7 @@ func TestInit_FromEnvIngress(t *testing.T) { rootCmd.SetArgs([]string{"init", "--api-key-from-env", "NRQ_INGRESS_VAR", "--account-id", "42", "--no-verify"}) require.NoError(t, rootCmd.Execute()) assert.NotContains(t, o.String()+e.String(), sentinel) - raw, _ := os.ReadFile(config.Path()) + raw, _ := os.ReadFile(mustConfigPath(t)) assert.NotContains(t, string(raw), sentinel) st, err := keychain.OpenNoMigrate() @@ -375,7 +387,7 @@ func TestConfigClear_DryRunAndAll(t *testing.T) { require.NoError(t, err) assert.True(t, st.HasAPIKey(), "dry-run must not delete") _ = st.Close() - _, statErr := os.Stat(config.Path()) + _, statErr := os.Stat(mustConfigPath(t)) require.NoError(t, statErr, "dry-run must not remove config.yml") _, _, err = run(t, "config", "clear", "--all") @@ -384,7 +396,7 @@ func TestConfigClear_DryRunAndAll(t *testing.T) { require.NoError(t, err) assert.False(t, st2.HasAPIKey()) _ = st2.Close() - _, statErr = os.Stat(config.Path()) + _, statErr = os.Stat(mustConfigPath(t)) assert.True(t, os.IsNotExist(statErr), "--all removes config.yml") // Idempotent: a second clear --all still exits 0. @@ -422,7 +434,7 @@ func TestNoLeak_PositionalArgRejected_NoEcho(t *testing.T) { func TestConfigClear_All_ScrubsLegacyFile(t *testing.T) { tmp := testutil.Setup(t) plantLegacyFile(t, tmp) // legacy credentials file present, migration NOT yet run - legacy := config.LegacyCredentialsPath() + legacy := testutil.LegacyCredentialsPath(t) _, statErr := os.Stat(legacy) require.NoError(t, statErr, "precondition: legacy file exists") @@ -455,12 +467,73 @@ func TestConfigShow_JSON_NoSecretValue(t *testing.T) { assert.NotContains(t, out, sentinel, "config show JSON must never contain the value") } +// MON-5373: `clear --all` must scrub BOTH config.yml paths (canonical AND +// old hand-rolled) AND BOTH plaintext credentials paths. Without this, a +// stale file at the old hand-rolled location would silently resurrect on the +// next runtime fallback Load() (or, for credentials, re-migrate). Skipped on +// Linux where the two paths collapse to one. +func TestConfigClear_All_ScrubsBothConfigAndLegacyPaths(t *testing.T) { + if runtime.GOOS == "linux" { + t.Skip("Linux: old==new (statedir ≡ $XDG_CONFIG_HOME); paths collapse") + } + testutil.Setup(t) + + // Seed BOTH config paths AND BOTH credentials paths. + canonicalDir := testutil.ConfigDir(t) + canonicalConfig := filepath.Join(canonicalDir, "config.yml") + require.NoError(t, os.WriteFile(canonicalConfig, + []byte("credential_ref: newrelic-cli/default\n"), 0o600)) + + oldConfig, err := config.OldConfigPath() + require.NoError(t, err) + require.NoError(t, os.MkdirAll(filepath.Dir(oldConfig), 0o700)) + require.NoError(t, os.WriteFile(oldConfig, + []byte("credential_ref: newrelic-cli/default\n"), 0o600)) + + oldCreds := testutil.LegacyCredentialsPath(t) + require.NoError(t, os.WriteFile(oldCreds, []byte("api_key=NRAK-old\n"), 0o600)) + + canonicalCreds, err := config.CanonicalCredentialsPath() + require.NoError(t, err) + require.NoError(t, os.WriteFile(canonicalCreds, []byte("api_key=NRAK-new\n"), 0o600)) + + _, _, err = run(t, "config", "clear", "--all") + require.NoError(t, err) + + for _, p := range []string{canonicalConfig, oldConfig, oldCreds, canonicalCreds} { + _, statErr := os.Stat(p) + assert.True(t, os.IsNotExist(statErr), "clear --all must remove %s", p) + } +} + +// MON-5373: `clear --all` must remain the recovery path even when config.yml +// is unparseable. OpenNoMigrate would fail on parse error; --all must still +// scrub files (the whole purpose of --all is "wipe this broken state"). +// Codex PR-r2 Major. +func TestConfigClear_All_MalformedConfig_StillScrubsFiles(t *testing.T) { + testutil.Setup(t) + canonicalDir := testutil.ConfigDir(t) + canonicalConfig := filepath.Join(canonicalDir, "config.yml") + require.NoError(t, os.WriteFile(canonicalConfig, + []byte("[unclosed_array: yes\n"), 0o600)) + legacyCreds := testutil.LegacyCredentialsPath(t) + require.NoError(t, os.WriteFile(legacyCreds, []byte("api_key=NRAK-stale\n"), 0o600)) + + _, _, err := run(t, "config", "clear", "--all") + require.NoError(t, err, "clear --all must succeed against malformed config") + + _, statErr := os.Stat(canonicalConfig) + assert.True(t, os.IsNotExist(statErr), "malformed config.yml must be removed") + _, statErr = os.Stat(legacyCreds) + assert.True(t, os.IsNotExist(statErr), "legacy credentials must be scrubbed") +} + // §1.10 fresh-install automation primitive: on a box with NO config.yml, // `nrq set-credential --key api_key --stdin` (no --ref) must work, falling // back to the default ref — not error demanding --ref. func TestSetCredential_FreshInstall_NoRefNoConfig(t *testing.T) { testutil.Setup(t) - _, statErr := os.Stat(config.Path()) + _, statErr := os.Stat(mustConfigPath(t)) require.True(t, os.IsNotExist(statErr), "precondition: no config.yml") rootCmd, opts := root.NewRootCmd() @@ -508,7 +581,7 @@ func TestInit_SecretOnly_NoConfigYMLCreated(t *testing.T) { rootCmd.SetArgs([]string{"init", "--api-key-stdin", "--no-verify"}) require.NoError(t, rootCmd.Execute()) - _, statErr := os.Stat(config.Path()) + _, statErr := os.Stat(mustConfigPath(t)) assert.True(t, os.IsNotExist(statErr), "secret-only init must not create config.yml") st, err := keychain.OpenNoMigrate() @@ -529,7 +602,7 @@ func TestInit_NoSecretInConfigYML(t *testing.T) { rootCmd.SetArgs([]string{"init", "--api-key-stdin", "--account-id", "42", "--region", "US", "--no-verify"}) require.NoError(t, rootCmd.Execute()) - raw, err := os.ReadFile(config.Path()) + raw, err := os.ReadFile(mustConfigPath(t)) require.NoError(t, err) assert.NotContains(t, string(raw), sentinel) assert.Contains(t, string(raw), "account_id: \"42\"") @@ -553,7 +626,7 @@ func TestInit_AccountIDFromEnv_InstallerInvocation(t *testing.T) { require.NoError(t, rootCmd.Execute()) assert.NotContains(t, o.String()+e.String(), sentinel) - raw, err := os.ReadFile(config.Path()) + raw, err := os.ReadFile(mustConfigPath(t)) require.NoError(t, err) assert.NotContains(t, string(raw), sentinel, "secret never in config.yml") assert.Contains(t, string(raw), "account_id: \"98765\"") diff --git a/internal/testutil/testutil.go b/internal/testutil/testutil.go index d441a20..2990f95 100644 --- a/internal/testutil/testutil.go +++ b/internal/testutil/testutil.go @@ -1,8 +1,8 @@ // Package testutil provides a hermetic credential environment for tests -// (§1.12 test obligation / §2 deliverable 11). It forces credstore's -// encrypted-file backend inside a per-test temp HOME with a fixed -// passphrase, so no test ever touches the real OS keyring, shells out to -// `security`, or depends on machine state. +// (§1.12 test obligation / §2 deliverable 11). It delegates state-dir +// isolation to the shared cli-common/statedirtest helper (the full 7-var +// env set per §3.1 — closes the Windows real-dir leak the old HOME/XDG-only +// setup had), then layers nrq's keyring-backend selection on top. package testutil import ( @@ -10,20 +10,24 @@ import ( "path/filepath" "testing" + "github.com/open-cli-collective/cli-common/statedirtest" + + "github.com/open-cli-collective/newrelic-cli/internal/config" "github.com/open-cli-collective/newrelic-cli/internal/output" ) -// Setup isolates HOME/XDG to a temp dir and forces the file backend with a -// known passphrase via the §1.4 named env vars. It also clears every -// NEWRELIC_* env var so a developer's real shell environment cannot leak -// into a test, and neutralizes the darwin legacy-Keychain probe so the -// suite is hermetic. Returns the temp dir so a test can plant legacy -// artifacts (e.g. a legacy credentials file) under it. +// Setup isolates the full §3.1 7-var env set under t.TempDir() (via +// statedirtest.Hermetic) and forces credstore's file backend with a known +// passphrase. It also clears every NEWRELIC_* env var so a developer's real +// shell environment cannot leak into a test, and neutralizes the darwin +// legacy-Keychain probe so the suite is hermetic. Returns the temp root. +// Tests should resolve paths via ConfigDir(t) / LegacyCredentialsPath(t) +// below rather than hand-building subdirs, because os.UserConfigDir is +// platform-native (macOS ~/Library/Application Support, Windows %APPDATA%) +// and not derived from any single env var. func Setup(t *testing.T) string { t.Helper() - tmp := t.TempDir() - t.Setenv("HOME", tmp) - t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmp, "xdgconfig")) + tmp := statedirtest.Hermetic(t) // Force credstore's encrypted-file backend (never the real Keychain / // Secret Service / Credential Manager), passphrase supplied non- @@ -48,3 +52,40 @@ func Setup(t *testing.T) string { t.Cleanup(output.ResetMigration) return tmp } + +// ConfigDir resolves the post-statedirtest hermetic CANONICAL config dir +// (statedir-resolved per OS) and creates it. Tests that plant or inspect +// `config.yml` should use this rather than hand-building subdirs. +func ConfigDir(t *testing.T) string { + t.Helper() + dir, err := config.Dir() + if err != nil { + t.Fatalf("testutil.ConfigDir: %v", err) + } + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatalf("testutil.ConfigDir mkdir: %v", err) + } + return dir +} + +// LegacyCredentialsPath returns the pre-MON-5373 hand-rolled `credentials` +// file path: $XDG_CONFIG_HOME/newrelic-cli/credentials else +// $HOME/.config/newrelic-cli/credentials. Distinct from ConfigDir on +// macOS/Windows where the canonical resolver returns a different OS-native +// path. Tests that seed/inspect the legacy plaintext credentials file (the +// secret-bearing key=value fixture, NOT config.yml) must use this helper. +// Returns the absolute path; the parent dir is created so tests can +// os.WriteFile immediately. +func LegacyCredentialsPath(t *testing.T) string { + t.Helper() + configHome := os.Getenv("XDG_CONFIG_HOME") + if configHome == "" { + home, _ := os.UserHomeDir() + configHome = filepath.Join(home, ".config") + } + dir := filepath.Join(configHome, "newrelic-cli") + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatalf("testutil.LegacyCredentialsPath mkdir: %v", err) + } + return filepath.Join(dir, "credentials") +}