diff --git a/cache/cache_test.go b/cache/cache_test.go new file mode 100644 index 0000000..6b2f3c3 --- /dev/null +++ b/cache/cache_test.go @@ -0,0 +1,261 @@ +package cache_test + +import ( + "errors" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/open-cli-collective/cli-common/cache" +) + +type payload struct { + Name string `json:"name"` + N int `json:"n"` +} + +func TestRoundTrip(t *testing.T) { + loc := cache.Locator{Root: t.TempDir(), InstanceKey: "monit.atlassian.net"} + + in := payload{Name: "fields", N: 7} + if err := cache.WriteResource(loc, "fields", "1h", in); err != nil { + t.Fatalf("WriteResource: %v", err) + } + + env, err := cache.ReadResource[payload](loc, "fields") + if err != nil { + t.Fatalf("ReadResource: %v", err) + } + if env.Data != in { + t.Fatalf("Data = %+v, want %+v", env.Data, in) + } + if env.Resource != "fields" || env.Instance != "monit.atlassian.net" { + t.Fatalf("Resource/Instance = %q/%q", env.Resource, env.Instance) + } + if env.Version != cache.Version || env.TTL != "1h" { + t.Fatalf("Version/TTL = %d/%q", env.Version, env.TTL) + } + if env.FetchedAt.IsZero() || env.FetchedAt.Location() != time.UTC { + t.Fatalf("FetchedAt = %v, want non-zero UTC", env.FetchedAt) + } +} + +func TestReadMissing(t *testing.T) { + loc := cache.Locator{Root: t.TempDir(), InstanceKey: "i"} + if _, err := cache.ReadResource[payload](loc, "absent"); !errors.Is(err, cache.ErrCacheMiss) { + t.Fatalf("err = %v, want ErrCacheMiss", err) + } +} + +func TestVersionMismatchIsMiss(t *testing.T) { + root := t.TempDir() + loc := cache.Locator{Root: root, InstanceKey: "i"} + dir := filepath.Join(root, "i") + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + // A different schema version on disk must read as a miss (self-healing). + raw := `{"resource":"r","instance":"i","fetched_at":"2026-01-01T00:00:00Z","ttl":"1h","version":999,"data":{"name":"x","n":1}}` + if err := os.WriteFile(filepath.Join(dir, "r.json"), []byte(raw), 0o600); err != nil { + t.Fatal(err) + } + if _, err := cache.ReadResource[payload](loc, "r"); !errors.Is(err, cache.ErrCacheMiss) { + t.Fatalf("err = %v, want ErrCacheMiss", err) + } +} + +func TestIdentityMismatchIsMiss(t *testing.T) { + root := t.TempDir() + loc := cache.Locator{Root: root, InstanceKey: "host-a"} + dir := filepath.Join(root, "host-a") + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + // Correct version + path, but the envelope's own metadata names a + // different resource/instance (misplaced or hand-edited file). + raw := `{"resource":"OTHER","instance":"host-b","fetched_at":"2026-01-01T00:00:00Z","ttl":"1h","version":1,"data":{"name":"x","n":1}}` + if err := os.WriteFile(filepath.Join(dir, "r.json"), []byte(raw), 0o600); err != nil { + t.Fatal(err) + } + if _, err := cache.ReadResource[payload](loc, "r"); !errors.Is(err, cache.ErrCacheMiss) { + t.Fatalf("err = %v, want ErrCacheMiss on identity mismatch", err) + } +} + +func TestMalformedJSONIsError(t *testing.T) { + root := t.TempDir() + loc := cache.Locator{Root: root, InstanceKey: "i"} + dir := filepath.Join(root, "i") + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "r.json"), []byte("{not json"), 0o600); err != nil { + t.Fatal(err) + } + _, err := cache.ReadResource[payload](loc, "r") + if err == nil || errors.Is(err, cache.ErrCacheMiss) { + t.Fatalf("err = %v, want a non-miss decode error", err) + } +} + +func TestReadResource_IOErrorIsNotMiss(t *testing.T) { + root := t.TempDir() + loc := cache.Locator{Root: root, InstanceKey: "i"} + // Make the resource path a directory: os.ReadFile then returns an error + // that is NOT os.IsNotExist, exercising the "I/O error, not a miss" path. + if err := os.MkdirAll(filepath.Join(root, "i", "r.json"), 0o700); err != nil { + t.Fatal(err) + } + _, err := cache.ReadResource[payload](loc, "r") + if err == nil || errors.Is(err, cache.ErrCacheMiss) { + t.Fatalf("err = %v, want a non-miss I/O error", err) + } +} + +func TestAtomicWrite_NoTempLeak_Perms(t *testing.T) { + root := t.TempDir() + loc := cache.Locator{Root: root, InstanceKey: "inst"} + if err := cache.WriteResource(loc, "res", "1h", payload{Name: "a", N: 1}); err != nil { + t.Fatalf("WriteResource: %v", err) + } + dir := filepath.Join(root, "inst") + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + for _, e := range entries { + if strings.Contains(e.Name(), ".tmp") { + t.Fatalf("temp file leaked: %s", e.Name()) + } + } + if runtime.GOOS != "windows" { + fi, err := os.Stat(filepath.Join(dir, "res.json")) + if err != nil { + t.Fatal(err) + } + if perm := fi.Mode().Perm(); perm != 0o600 { + t.Fatalf("envelope mode = %o, want 0600", perm) + } + di, err := os.Stat(dir) + if err != nil { + t.Fatal(err) + } + if perm := di.Mode().Perm(); perm != 0o700 { + t.Fatalf("cache dir mode = %o, want 0700", perm) + } + } +} + +func TestInvalidRoot_CreatesNothing(t *testing.T) { + // Run inside an empty temp cwd so we can prove a relative/empty root + // never writes relative to the process working directory. + cwd := t.TempDir() + t.Chdir(cwd) + + for _, root := range []string{"", "foo/bar", "relative"} { + loc := cache.Locator{Root: root, InstanceKey: "i"} + if err := cache.WriteResource(loc, "r", "1h", payload{}); !errors.Is(err, cache.ErrInvalidRoot) { + t.Fatalf("root=%q WriteResource err = %v, want ErrInvalidRoot", root, err) + } + if _, err := cache.ReadResource[payload](loc, "r"); !errors.Is(err, cache.ErrInvalidRoot) { + t.Fatalf("root=%q ReadResource err = %v, want ErrInvalidRoot", root, err) + } + } + + entries, err := os.ReadDir(cwd) + if err != nil { + t.Fatal(err) + } + if len(entries) != 0 { + t.Fatalf("cwd not untouched, contains: %v", entries) + } +} + +func TestUnsafeComponents(t *testing.T) { + root := t.TempDir() + bad := []string{"", ".", "..", "a..b", "a/b", `a\b`, "../escape", ".hidden..x", "trailingdot."} + for _, v := range bad { + t.Run("instance="+v, func(t *testing.T) { + loc := cache.Locator{Root: root, InstanceKey: v} + if err := cache.WriteResource(loc, "r", "1h", payload{}); !errors.Is(err, cache.ErrInvalidName) { + t.Fatalf("instanceKey=%q err = %v, want ErrInvalidName", v, err) + } + }) + t.Run("name="+v, func(t *testing.T) { + loc := cache.Locator{Root: root, InstanceKey: "ok"} + if err := cache.WriteResource(loc, v, "1h", payload{}); !errors.Is(err, cache.ErrInvalidName) { + t.Fatalf("name=%q err = %v, want ErrInvalidName", v, err) + } + }) + } +} + +func TestClassify_OnlyFreshStaleManual(t *testing.T) { + now := time.Date(2026, 5, 19, 12, 0, 0, 0, time.UTC) + tests := []struct { + name string + fetchedAt time.Time + ttl string + want cache.Status + }{ + {"fresh", now.Add(-30 * time.Minute), "1h", cache.StatusFresh}, + {"elapsed-stale", now.Add(-2 * time.Hour), "1h", cache.StatusStale}, + {"exact-boundary-stale", now.Add(-1 * time.Hour), "1h", cache.StatusStale}, + {"zero-time-stale", time.Time{}, "1h", cache.StatusStale}, + {"manual-sentinel", now, "manual", cache.StatusManual}, + {"unparseable-ttl-stale", now, "not-a-duration", cache.StatusStale}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := cache.Classify(tt.fetchedAt, tt.ttl, now) + if got != tt.want { + t.Fatalf("Classify = %v, want %v", got, tt.want) + } + if got == cache.StatusUninitialized || got == cache.StatusUnavailable { + t.Fatalf("Classify must never return %v", got) + } + }) + } +} + +func TestStatusString(t *testing.T) { + cases := map[cache.Status]string{ + cache.StatusUninitialized: "uninitialized", + cache.StatusFresh: "fresh", + cache.StatusStale: "stale", + cache.StatusManual: "manual", + cache.StatusUnavailable: "unavailable", + cache.Status(99): "unknown", + } + for s, want := range cases { + if got := s.String(); got != want { + t.Fatalf("Status(%d).String() = %q, want %q", int(s), got, want) + } + } +} + +func TestAge(t *testing.T) { + now := time.Date(2026, 5, 19, 12, 0, 0, 0, time.UTC) + tests := []struct { + name string + fetchedAt time.Time + want string + }{ + {"zero", time.Time{}, "-"}, + {"negative-clamped", now.Add(time.Hour), "0s"}, + {"seconds", now.Add(-45 * time.Second), "45s"}, + {"minutes", now.Add(-5 * time.Minute), "5m"}, + {"hours", now.Add(-3 * time.Hour), "3h"}, + {"days", now.Add(-50 * time.Hour), "2d"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := cache.Age(tt.fetchedAt, now); got != tt.want { + t.Fatalf("Age = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/cache/envelope.go b/cache/envelope.go new file mode 100644 index 0000000..a9ff937 --- /dev/null +++ b/cache/envelope.go @@ -0,0 +1,139 @@ +// Package cache is the directory-agnostic tier-1 cache core +// (working-with-state.md §5b): a self-describing JSON envelope with atomic +// temp-file-rename writes, version-mismatch-as-miss, and freshness +// classification. Location is always injected via Locator; this package never +// resolves a directory and never imports a CLI's config. Tier 2 (resource +// registry / dependency DAG / fetchers / refresh wiring) is deliberately out +// of scope (§5b, rule-of-three, deferred). +package cache + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "time" +) + +// Version is the on-disk envelope schema version. A mismatch is treated as a +// cache miss so schema bumps self-heal on the next write. +const Version = 1 + +// ErrCacheMiss reports an envelope that is absent, version-mismatched, or +// whose stored identity (resource/instance) disagrees with the key it was +// read under. It is not an error condition for callers — it is the "fetch +// and populate" signal. +var ErrCacheMiss = errors.New("cache: miss") + +// Envelope is the on-disk JSON shape for a single cached resource. +type Envelope[T any] struct { + Resource string `json:"resource"` + Instance string `json:"instance"` + FetchedAt time.Time `json:"fetched_at"` + TTL string `json:"ttl"` + Version int `json:"version"` + Data T `json:"data"` +} + +// ReadResource reads the envelope for name at loc. +// - (envelope, nil) on success. +// - (zero, ErrCacheMiss) if the file does not exist, the on-disk Version +// differs from the current schema, or the stored resource/instance does +// not match the requested name / loc.InstanceKey. +// - (zero, error) on path validation, I/O, or JSON decode failure. +// +// ReadResource does NOT check freshness; callers use Classify. +func ReadResource[T any](loc Locator, name string) (Envelope[T], error) { + path, err := loc.resourceFile(name) + if err != nil { + return Envelope[T]{}, err + } + + data, err := os.ReadFile(path) //nolint:gosec // path already validated + composed by Locator.resourceFile (Root absolute, components regex-checked) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return Envelope[T]{}, ErrCacheMiss + } + return Envelope[T]{}, fmt.Errorf("cache: reading resource file: %w", err) + } + + var env Envelope[T] + if err := json.Unmarshal(data, &env); err != nil { + return Envelope[T]{}, fmt.Errorf("cache: parsing resource file: %w", err) + } + + // Version or identity mismatch ⇒ treat as a miss (same self-healing rule): + // a hand-edited or misplaced file whose metadata disagrees with the key it + // was read under must not be returned as if it were that resource. + if env.Version != Version || env.Resource != name || env.Instance != loc.InstanceKey { + return Envelope[T]{}, ErrCacheMiss + } + return env, nil +} + +// WriteResource atomically writes an envelope for name at loc. Resource, +// Instance (= loc.InstanceKey), Version, and FetchedAt (UTC now) are set here; +// ttl comes from the caller (a hard-coded per-resource value — §4.4). +func WriteResource[T any](loc Locator, name, ttl string, data T) error { + env := Envelope[T]{ + Resource: name, + Instance: loc.InstanceKey, + FetchedAt: time.Now().UTC(), + TTL: ttl, + Version: Version, + Data: data, + } + return atomicWriteEnvelope(loc, name, env) +} + +// atomicWriteEnvelope marshals env and writes it to the cache path for name +// using a temp-file-in-same-dir → rename. The rename makes the final file +// appear atomically (a reader sees either the old envelope or the new one, +// never a partial one). The temp file is removed on every error branch; a +// hard process/host crash can still leave an orphan *.json.tmp, which the +// next successful write supersedes (it is never read as an envelope). +func atomicWriteEnvelope[T any](loc Locator, name string, env Envelope[T]) error { + path, err := loc.resourceFile(name) + if err != nil { + return err + } + + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o700); err != nil { + return fmt.Errorf("cache: creating cache directory: %w", err) + } + + jsonData, err := json.MarshalIndent(env, "", " ") + if err != nil { + return fmt.Errorf("cache: marshaling envelope: %w", err) + } + + tmp, err := os.CreateTemp(dir, name+"-*.json.tmp") + if err != nil { + return fmt.Errorf("cache: creating temp file: %w", err) + } + tmpPath := tmp.Name() + + if _, err := tmp.Write(jsonData); err != nil { + _ = tmp.Close() + _ = os.Remove(tmpPath) + return fmt.Errorf("cache: writing temp file: %w", err) + } + + if err := tmp.Close(); err != nil { + _ = os.Remove(tmpPath) + return fmt.Errorf("cache: closing temp file: %w", err) + } + + if err := os.Chmod(tmpPath, 0o600); err != nil { + _ = os.Remove(tmpPath) + return fmt.Errorf("cache: setting file mode: %w", err) + } + + if err := os.Rename(tmpPath, path); err != nil { + _ = os.Remove(tmpPath) + return fmt.Errorf("cache: moving temp file to final path: %w", err) + } + return nil +} diff --git a/cache/freshness.go b/cache/freshness.go new file mode 100644 index 0000000..3227c19 --- /dev/null +++ b/cache/freshness.go @@ -0,0 +1,98 @@ +package cache + +import ( + "fmt" + "time" +) + +// Status is the coarse freshness classification. +// +// Classify returns ONLY Fresh, Stale, or Manual. Uninitialized is +// caller-derived (a ReadResource ErrCacheMiss — no envelope on disk) and +// Unavailable is registry-derived (a tier-2 concern). Both are defined here +// for jtk parity and a clean tier-2 lift, and are exercised only via String(); +// Classify never returns them. +type Status int + +const ( + StatusUninitialized Status = iota // no envelope on disk (caller-derived from ErrCacheMiss) + StatusFresh // on disk, FetchedAt + TTL still in the future + StatusStale // on disk, FetchedAt + TTL elapsed (or zero FetchedAt) + StatusManual // TTL == "manual"; never auto-expires + StatusUnavailable // registry-derived; never returned by Classify +) + +// String returns the status label. +func (s Status) String() string { + switch s { + case StatusUninitialized: + return "uninitialized" + case StatusFresh: + return "fresh" + case StatusStale: + return "stale" + case StatusManual: + return "manual" + case StatusUnavailable: + return "unavailable" + default: + return "unknown" + } +} + +// ttlManual is the TTL sentinel meaning "never auto-expire". +const ttlManual = "manual" + +// parseTTL returns the TTL as a duration. The "manual" sentinel returns +// (0, true, nil). +func parseTTL(ttl string) (time.Duration, bool, error) { + if ttl == ttlManual { + return 0, true, nil + } + d, err := time.ParseDuration(ttl) + if err != nil { + return 0, false, fmt.Errorf("cache: parsing TTL %q: %w", ttl, err) + } + return d, false, nil +} + +// Classify inspects an envelope's FetchedAt + TTL at now and returns one of +// StatusFresh, StatusStale, or StatusManual. A zero FetchedAt (the +// uninitialized / Touch-ed state) and an unparseable TTL both classify as +// StatusStale — callers that need to distinguish "never fetched" check +// FetchedAt.IsZero() themselves. +func Classify(fetchedAt time.Time, ttl string, now time.Time) Status { + d, manual, err := parseTTL(ttl) + if err != nil { + return StatusStale + } + if manual { + return StatusManual + } + if fetchedAt.IsZero() || now.Sub(fetchedAt) >= d { + return StatusStale + } + return StatusFresh +} + +// Age returns a short human-readable age ("8h", "3d", "2m", "45s") for status +// output. A zero fetchedAt returns "-"; a negative delta is clamped to 0. +func Age(fetchedAt, now time.Time) string { + if fetchedAt.IsZero() { + return "-" + } + delta := now.Sub(fetchedAt) + if delta < 0 { + delta = 0 + } + switch { + case delta >= 24*time.Hour: + return fmt.Sprintf("%dd", int(delta/(24*time.Hour))) + case delta >= time.Hour: + return fmt.Sprintf("%dh", int(delta/time.Hour)) + case delta >= time.Minute: + return fmt.Sprintf("%dm", int(delta/time.Minute)) + default: + return fmt.Sprintf("%ds", int(delta/time.Second)) + } +} diff --git a/cache/locator.go b/cache/locator.go new file mode 100644 index 0000000..3fcb723 --- /dev/null +++ b/cache/locator.go @@ -0,0 +1,63 @@ +package cache + +import ( + "errors" + "fmt" + "path/filepath" + "regexp" + "strings" +) + +var ( + // ErrInvalidRoot is returned when Locator.Root is empty or not absolute. A + // zero-value Locator would otherwise filepath.Join("", key, name) and + // write relative to the process working directory. + ErrInvalidRoot = errors.New("cache: locator root must be a non-empty absolute path") + // ErrInvalidName is returned when an instance key or resource name is + // unsafe as a path component. + ErrInvalidName = errors.New("cache: unsafe path component") +) + +// safeComponent bounds instance keys and resource names to the subset that is +// safe to compose into a filesystem path: letters, digits, dot, hyphen, +// starting alphanumeric. Path separators, whitespace, and control characters +// are rejected rather than trusted (the values are caller-supplied — e.g. a +// hostname derived from config). +var safeComponent = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9.\-]*$`) + +// validComponent also rejects a ".." substring (traversal) and a trailing dot. +// A trailing dot matters cross-OS: Windows (NTFS/FAT) silently strips it, so +// "foo." and "foo" would resolve to the same directory and two distinct +// instance keys could collide. +func validComponent(s string) bool { + return safeComponent.MatchString(s) && + !strings.Contains(s, "..") && + !strings.HasSuffix(s, ".") +} + +// Locator is the injected cache location. The cache library is +// directory-agnostic: Root is supplied by the caller (typically +// statedir.Cache.CacheDir / CacheDirEnsured), never derived here. +type Locator struct { + // Root is the non-empty absolute cache root for one tool. + Root string + // InstanceKey is the per-instance subdir (jtk: hostname / cloud-id; + // gro: a constant; a single-instance CLI: "default"). + InstanceKey string +} + +// resourceFile validates all three path inputs, then composes +// //.json. Any invalid input returns a typed error +// and never composes or creates a path. +func (l Locator) resourceFile(name string) (string, error) { + if l.Root == "" || !filepath.IsAbs(l.Root) { + return "", fmt.Errorf("%w: %q", ErrInvalidRoot, l.Root) + } + if !validComponent(l.InstanceKey) { + return "", fmt.Errorf("%w: instance key %q", ErrInvalidName, l.InstanceKey) + } + if !validComponent(name) { + return "", fmt.Errorf("%w: resource name %q", ErrInvalidName, name) + } + return filepath.Join(l.Root, l.InstanceKey, name+".json"), nil +} diff --git a/docs/working-with-state.md b/docs/working-with-state.md index 4065bb9..87ae715 100644 --- a/docs/working-with-state.md +++ b/docs/working-with-state.md @@ -355,7 +355,14 @@ act* — done together (not two horizontal sweeps). 1. **Build the `cli-common` state components first** (§5a resolver + §5a 7-var test helper + §5b tier-1 cache core). Nothing ports until this - exists. + exists. **DELIVERED 2026-05-19 (MON-5364):** `cli-common/statedir` + (`Scope`/`Cache` resolver, create-vs-no-create split, `LegacySource` + seam), `cli-common/statedirtest` (the 7-var `Hermetic` helper), and + `cli-common/cache` (directory-agnostic `Envelope[T]`, + `ReadResource[T]`/`WriteResource[T]`, atomic write, + version-mismatch-as-miss, + `Classify`/`Age`/`Status`, injected `Locator`). No CLI ported yet; no + INT-310 tag cut (the §5 release-train guardrail is unaffected). 2. **Port one unit at a time** (unit per §6.4 = a CLI / a credential scope / a cache-only surface). A unit is *one PR* but **decomposed into separate, independently-reviewable commits, each with its own acceptance diff --git a/statedir/resolver.go b/statedir/resolver.go new file mode 100644 index 0000000..797c68f --- /dev/null +++ b/statedir/resolver.go @@ -0,0 +1,138 @@ +// Package statedir is the shared path/dir resolver for non-secret on-disk +// state (working-with-state.md §5a). It owns the genuinely-common policy that +// is easy to get subtly wrong per-CLI: the credential-scope config-dir naming +// rule (§3), the per-binary cache-dir rule (§4.1), and the create-vs-no-create +// split. It is deliberately NOT a blanket "no file may call os.User*Dir()" +// ban — a CLI's bespoke legacy-source probing legitimately computes its own +// paths; that stays per-CLI (see LegacySource). +// +// Resolution is always os.UserConfigDir()/os.UserCacheDir() + the scope/tool +// name. No hand-rolled ~/.config and no %APPDATA% branch: the stdlib helpers +// honor $XDG_* on Linux and return the OS-native dir on macOS/Windows — that +// is the standard. A relative $XDG_* yields the stdlib error unchanged (the +// §1.1 intentional tightening). +package statedir + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" +) + +// dirPerm is the §3 directory permission for config and cache dirs. +const dirPerm = 0o700 + +// ErrInvalidName is returned when a scope or tool name is unusable as a single +// path component (empty, ".", "..", or containing a path separator). The name +// is composed into a filesystem path, so it is validated rather than trusted. +var ErrInvalidName = errors.New("statedir: invalid scope/tool name") + +// validateComponent rejects any value that is not safe as a single path +// component. Scope/tool names in this family are short slugs +// ("slck", "nrq", "atlassian-cli", ...) — there is no legitimate reason for +// one to contain a separator or a "..". +func validateComponent(kind, v string) error { + switch { + case v == "": + return fmt.Errorf("%w: %s is empty", ErrInvalidName, kind) + case v == "." || v == "..": + return fmt.Errorf("%w: %s is %q", ErrInvalidName, kind, v) + case strings.ContainsAny(v, `/\`): + // Reject BOTH separators on every OS so the "single path component" + // contract is platform-independent (a name valid on Linux must not + // become a traversal on Windows). + return fmt.Errorf("%w: %s %q contains a path separator", ErrInvalidName, kind, v) + case strings.Contains(v, ".."): + return fmt.Errorf("%w: %s %q contains %q", ErrInvalidName, kind, v, "..") + case strings.HasSuffix(v, "."): + // Windows (NTFS/FAT) silently strips a trailing dot, so "foo." and + // "foo" would resolve to the same dir on one OS but not another. + return fmt.Errorf("%w: %s %q has a trailing dot", ErrInvalidName, kind, v) + } + return nil +} + +// Scope is the config-dir naming key (§3): the credential scope, not +// necessarily the binary. A single-binary CLI uses its tool name; a +// shared-credential repo uses the shared scope (atlassian-cli ⇒ one config +// dir, one config.yml, one keyring bundle). +type Scope struct { + Name string +} + +// ConfigDir resolves the config directory WITHOUT creating it. Side-effect +// free for dry-run / `config clear --all` / resolve-before-migrate paths. +func (s Scope) ConfigDir() (string, error) { + if err := validateComponent("scope name", s.Name); err != nil { + return "", err + } + base, err := os.UserConfigDir() + if err != nil { + return "", fmt.Errorf("statedir: resolving user config dir: %w", err) + } + return filepath.Join(base, s.Name), nil +} + +// ConfigDirEnsured is ConfigDir plus os.MkdirAll(dir, 0700). It does NOT +// re-chmod a pre-existing wrong-mode dir — MkdirAll only sets the mode on +// components it creates. Hardening an already-present mis-moded dir is +// per-port work (§6.4), not a commons concern. +func (s Scope) ConfigDirEnsured() (string, error) { + dir, err := s.ConfigDir() + if err != nil { + return "", err + } + if err := os.MkdirAll(dir, dirPerm); err != nil { + return "", fmt.Errorf("statedir: creating config dir: %w", err) + } + return dir, nil +} + +// Cache is the cache-dir naming key (§4.1): always the binary/tool name, even +// inside a shared-credential repo — jtk and cfl cache different domains and +// never share a cache dir. +type Cache struct { + Tool string +} + +// CacheDir resolves the cache directory WITHOUT creating it. +func (c Cache) CacheDir() (string, error) { + if err := validateComponent("tool name", c.Tool); err != nil { + return "", err + } + base, err := os.UserCacheDir() + if err != nil { + return "", fmt.Errorf("statedir: resolving user cache dir: %w", err) + } + return filepath.Join(base, c.Tool), nil +} + +// CacheDirEnsured is CacheDir plus os.MkdirAll(dir, 0700). Same no-re-chmod +// rule as ConfigDirEnsured. +func (c Cache) CacheDirEnsured() (string, error) { + dir, err := c.CacheDir() + if err != nil { + return "", err + } + if err := os.MkdirAll(dir, dirPerm); err != nil { + return "", fmt.Errorf("statedir: creating cache dir: %w", err) + } + return dir, nil +} + +// LegacySource is the migration-source enumeration seam (§5a). The resolver +// never enumerates, reads, or interprets these: each CLI computes its own +// legacy probe paths and decides copy/move/conflict policy per-port (§3.2). +// This type exists only so the shape and intent are shared and documented; it +// deliberately carries no behavior. A Migrate(...) orchestrator is explicitly +// NOT provided here — that would pre-decide per-port §3.2 policy without a +// consumer. +type LegacySource struct { + // Label is a human-readable name for conflict / one-line-notice messages + // (e.g. "legacy ~/.config/cfl"). Never a value, never a secret. + Label string + // Path is the absolute path the CLI computed itself. + Path string +} diff --git a/statedir/resolver_test.go b/statedir/resolver_test.go new file mode 100644 index 0000000..d89256e --- /dev/null +++ b/statedir/resolver_test.go @@ -0,0 +1,116 @@ +package statedir_test + +import ( + "errors" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/open-cli-collective/cli-common/statedir" + "github.com/open-cli-collective/cli-common/statedirtest" +) + +func TestScopeConfigDir_NoCreate(t *testing.T) { + statedirtest.Hermetic(t) + + got, err := statedir.Scope{Name: "atlassian-cli"}.ConfigDir() + if err != nil { + t.Fatalf("ConfigDir: %v", err) + } + base, err := os.UserConfigDir() + if err != nil { + t.Fatalf("os.UserConfigDir: %v", err) + } + want := filepath.Join(base, "atlassian-cli") + if got != want { + t.Fatalf("ConfigDir = %q, want %q", got, want) + } + if _, err := os.Stat(got); !os.IsNotExist(err) { + t.Fatalf("ConfigDir must not create the directory; stat err = %v", err) + } +} + +func TestScopeConfigDirEnsured_Creates0700(t *testing.T) { + statedirtest.Hermetic(t) + + dir, err := statedir.Scope{Name: "slck"}.ConfigDirEnsured() + if err != nil { + t.Fatalf("ConfigDirEnsured: %v", err) + } + info, err := os.Stat(dir) + if err != nil { + t.Fatalf("stat created dir: %v", err) + } + if !info.IsDir() { + t.Fatalf("%q is not a directory", dir) + } + if runtime.GOOS != "windows" { + if perm := info.Mode().Perm(); perm != 0o700 { + t.Fatalf("created config dir mode = %o, want 0700", perm) + } + } +} + +func TestCacheDirEnsured_Creates0700(t *testing.T) { + statedirtest.Hermetic(t) + + dir, err := statedir.Cache{Tool: "nrq"}.CacheDirEnsured() + if err != nil { + t.Fatalf("CacheDirEnsured: %v", err) + } + info, err := os.Stat(dir) + if err != nil { + t.Fatalf("stat created dir: %v", err) + } + if !info.IsDir() { + t.Fatalf("%q is not a directory", dir) + } + if runtime.GOOS != "windows" { + if perm := info.Mode().Perm(); perm != 0o700 { + t.Fatalf("created cache dir mode = %o, want 0700", perm) + } + } +} + +func TestCacheDir_PerBinary(t *testing.T) { + statedirtest.Hermetic(t) + + jtk, err := statedir.Cache{Tool: "jtk"}.CacheDir() + if err != nil { + t.Fatalf("jtk CacheDir: %v", err) + } + cfl, err := statedir.Cache{Tool: "cfl"}.CacheDir() + if err != nil { + t.Fatalf("cfl CacheDir: %v", err) + } + if jtk == cfl { + t.Fatalf("per-binary cache dirs must differ: both = %q", jtk) + } + base, err := os.UserCacheDir() + if err != nil { + t.Fatalf("os.UserCacheDir: %v", err) + } + if want := filepath.Join(base, "jtk"); jtk != want { + t.Fatalf("jtk CacheDir = %q, want %q", jtk, want) + } + if _, err := os.Stat(jtk); !os.IsNotExist(err) { + t.Fatalf("CacheDir must not create the directory; stat err = %v", err) + } +} + +func TestInvalidNames(t *testing.T) { + statedirtest.Hermetic(t) + + bad := []string{"", ".", "..", "a/b", `a\b`, "a..b", "../escape", "trailingdot.", string(os.PathSeparator)} + for _, name := range bad { + t.Run("scope="+name, func(t *testing.T) { + if _, err := (statedir.Scope{Name: name}).ConfigDir(); !errors.Is(err, statedir.ErrInvalidName) { + t.Fatalf("Scope{%q}.ConfigDir() err = %v, want ErrInvalidName", name, err) + } + if _, err := (statedir.Cache{Tool: name}).CacheDir(); !errors.Is(err, statedir.ErrInvalidName) { + t.Fatalf("Cache{%q}.CacheDir() err = %v, want ErrInvalidName", name, err) + } + }) + } +} diff --git a/statedirtest/statedirtest.go b/statedirtest/statedirtest.go new file mode 100644 index 0000000..eb54ff1 --- /dev/null +++ b/statedirtest/statedirtest.go @@ -0,0 +1,47 @@ +// Package statedirtest provides the single hermetic environment helper for +// state-component tests (working-with-state.md §3.1). It points the full +// 7-var env set at a per-test temp dir so os.UserConfigDir / os.UserCacheDir +// never resolve to the developer's real directories on any OS. +// +// HOME-only isolation is a Windows real-dir leak: os.UserConfigDir reads +// %AppData% and os.UserCacheDir reads %LocalAppData%, neither of which is +// derived from %USERPROFILE%/HOME. This helper is the one definition of the +// list; no CLI re-derives it. +// +// Not usable under t.Parallel: t.Setenv mutates process-global env and Go +// panics if it is called on a parallel test. Per-instance overrides are the +// parallel-safe alternative and remain a per-port choice. +package statedirtest + +import ( + "path/filepath" + "testing" +) + +// envSubdir maps each isolated env var to a subdirectory of the test temp +// root. HOME and USERPROFILE share one logical home; the rest are distinct so +// config/cache/data cannot collide. +var envSubdir = map[string]string{ + "HOME": "home", + "USERPROFILE": "home", + "AppData": "appdata", + "LocalAppData": "localappdata", + "XDG_CONFIG_HOME": "xdgconfig", + "XDG_CACHE_HOME": "xdgcache", + "XDG_DATA_HOME": "xdgdata", +} + +// Hermetic isolates the full §3.1 7-var env set under t.TempDir() and returns +// the temp root. Every override is restored by t.Setenv's own cleanup. +// +// Must NOT be called from a test that has called t.Parallel: t.Setenv mutates +// process-global env and Go panics in that case. Use a per-instance override +// for parallel tests instead. +func Hermetic(t *testing.T) string { + t.Helper() + root := t.TempDir() + for env, sub := range envSubdir { + t.Setenv(env, filepath.Join(root, sub)) + } + return root +} diff --git a/statedirtest/statedirtest_test.go b/statedirtest/statedirtest_test.go new file mode 100644 index 0000000..c8488b3 --- /dev/null +++ b/statedirtest/statedirtest_test.go @@ -0,0 +1,61 @@ +package statedirtest_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/open-cli-collective/cli-common/statedirtest" +) + +func TestHermetic_IsolatesAll7Vars(t *testing.T) { + root := statedirtest.Hermetic(t) + + vars := []string{ + "HOME", "USERPROFILE", "AppData", "LocalAppData", + "XDG_CONFIG_HOME", "XDG_CACHE_HOME", "XDG_DATA_HOME", + } + for _, v := range vars { + got := os.Getenv(v) + if got == "" { + t.Errorf("%s not set by Hermetic", v) + continue + } + if !strings.HasPrefix(got, root) { + t.Errorf("%s = %q, want a path under temp root %q", v, got, root) + } + } +} + +func TestHermetic_OSHelpersResolveUnderTemp(t *testing.T) { + root := statedirtest.Hermetic(t) + + cfg, err := os.UserConfigDir() + if err != nil { + t.Fatalf("os.UserConfigDir: %v", err) + } + if !strings.HasPrefix(cfg, root) { + t.Fatalf("os.UserConfigDir() = %q, want under temp root %q", cfg, root) + } + + cache, err := os.UserCacheDir() + if err != nil { + t.Fatalf("os.UserCacheDir: %v", err) + } + if !strings.HasPrefix(cache, root) { + t.Fatalf("os.UserCacheDir() = %q, want under temp root %q", cache, root) + } +} + +func TestHermetic_HomeAndUserprofileShareLogicalHome(t *testing.T) { + statedirtest.Hermetic(t) + + home := os.Getenv("HOME") + if up := os.Getenv("USERPROFILE"); up != home { + t.Fatalf("USERPROFILE = %q, want it to equal HOME %q", up, home) + } + if filepath.Base(home) != "home" { + t.Fatalf("HOME = %q, want its base to be %q", home, "home") + } +}