From a5bd2866b8b85ad2351da5978c2810da147a8f27 Mon Sep 17 00:00:00 2001 From: Rian Stockbower Date: Tue, 19 May 2026 08:10:05 -0400 Subject: [PATCH 1/7] feat(statedir): credential-scope path resolver + hermetic test helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the cli-common state path components (working-with-state.md §5a): - statedir: Scope (credential-scope config dir, §3) and Cache (per-binary cache dir, §4.1) over os.UserConfigDir/os.UserCacheDir — no hand-rolled ~/.config, no %APPDATA% branch. ConfigDir/CacheDir are side-effect free; *Ensured is the only mkdir path (0700) and never re-chmods a pre-existing dir (per-port concern). LegacySource is the migration-source seam — a documented type with no behavior; the resolver never enumerates or reads legacy paths (per-port §3.2 policy stays per-CLI). - statedirtest: the single Hermetic helper isolating the full 7-var env set (HOME, USERPROFILE, AppData, LocalAppData, XDG_CONFIG_HOME, XDG_CACHE_HOME, XDG_DATA_HOME) so os.User*Dir never resolves to a real dir on any OS. HOME-only is a Windows real-dir leak. Pure stdlib; no go.mod/go.sum change. [MON-5364] --- statedir/resolver.go | 131 ++++++++++++++++++++++++++++++ statedir/resolver_test.go | 95 ++++++++++++++++++++++ statedirtest/statedirtest.go | 43 ++++++++++ statedirtest/statedirtest_test.go | 61 ++++++++++++++ 4 files changed, 330 insertions(+) create mode 100644 statedir/resolver.go create mode 100644 statedir/resolver_test.go create mode 100644 statedirtest/statedirtest.go create mode 100644 statedirtest/statedirtest_test.go diff --git a/statedir/resolver.go b/statedir/resolver.go new file mode 100644 index 0000000..4cef230 --- /dev/null +++ b/statedir/resolver.go @@ -0,0 +1,131 @@ +// 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.ContainsRune(v, '/') || strings.ContainsRune(v, os.PathSeparator): + 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, "..") + } + 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..79bd428 --- /dev/null +++ b/statedir/resolver_test.go @@ -0,0 +1,95 @@ +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 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", "../escape", 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..30e84e2 --- /dev/null +++ b/statedirtest/statedirtest.go @@ -0,0 +1,43 @@ +// 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. +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") + } +} From ee5f8dce2e0766e90c9a0e33f10b009f3abd2603 Mon Sep 17 00:00:00 2001 From: Rian Stockbower Date: Tue, 19 May 2026 08:10:14 -0400 Subject: [PATCH 2/7] feat(cache): directory-agnostic tier-1 envelope + freshness core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tier-1 cache core (working-with-state.md §5b), lifted from jtk's envelope/freshness shapes and made directory-agnostic via an injected Locator (receives Root, never derives it): - Envelope[T] + ReadResource[T]/WriteResource[T]; atomic temp-file-in-same-dir -> chmod 0600 -> rename (dir 0700), temp removed on every failure branch; version-mismatch-as-miss (self-healing schema bump); reads never check freshness. - Locator validates all three path inputs before composing: Root must be non-empty and absolute (a zero-value Locator must not write relative to cwd -> ErrInvalidRoot); InstanceKey and resource name pass the ^[A-Za-z0-9][A-Za-z0-9.-]*$ guard plus ".." rejection (ErrInvalidName). - Classify returns only Fresh|Stale|Manual; Uninitialized is caller-derived (ErrCacheMiss) and Unavailable is registry-derived (tier-2) — both kept for parity, exercised only via String(). Age: -/Ns/Nm/Nh/Nd. Tier 2 (registry/DAG/fetchers/refresh) deferred (rule-of-three). Pure stdlib; no go.mod/go.sum change. [MON-5364] --- cache/cache_test.go | 228 ++++++++++++++++++++++++++++++++++++++++++++ cache/envelope.go | 130 +++++++++++++++++++++++++ cache/freshness.go | 98 +++++++++++++++++++ cache/locator.go | 57 +++++++++++ 4 files changed, 513 insertions(+) create mode 100644 cache/cache_test.go create mode 100644 cache/envelope.go create mode 100644 cache/freshness.go create mode 100644 cache/locator.go diff --git a/cache/cache_test.go b/cache/cache_test.go new file mode 100644 index 0000000..bf90366 --- /dev/null +++ b/cache/cache_test.go @@ -0,0 +1,228 @@ +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 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 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", "../escape", ".hidden..x"} + 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}, + {"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..8a585d8 --- /dev/null +++ b/cache/envelope.go @@ -0,0 +1,130 @@ +// 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 absent or version-mismatched envelope. 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 or the on-disk Version +// differs from the current schema. +// - (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 is composed from an injected Locator + regex-validated components, not user input + if err != nil { + if os.IsNotExist(err) { + 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) + } + + if env.Version != Version { + 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 temp file is removed on every +// failure branch so a crash never leaves a partial 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..3ef3db1 --- /dev/null +++ b/cache/locator.go @@ -0,0 +1,57 @@ +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.\-]*$`) + +func validComponent(s string) bool { + return safeComponent.MatchString(s) && !strings.Contains(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 +} From 4b42eacdc6d5606a134286130f7e33eae5fe66b4 Mon Sep 17 00:00:00 2001 From: Rian Stockbower Date: Tue, 19 May 2026 08:10:18 -0400 Subject: [PATCH 3/7] =?UTF-8?q?docs:=20mark=20working-with-state=20=C2=A76?= =?UTF-8?q?=20step=201=20(commons=20buildout)=20delivered?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One status line on §6 step 1 recording that the cli-common state components (statedir, statedirtest, cache) now exist. No semantic change to the standard; no CLI ported; no INT-310 tag cut (the §5 release-train guardrail is unaffected). Closes #17 [MON-5364] --- docs/working-with-state.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/working-with-state.md b/docs/working-with-state.md index 4065bb9..9c21509 100644 --- a/docs/working-with-state.md +++ b/docs/working-with-state.md @@ -355,7 +355,13 @@ 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]`, + `Read`/`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 From 52b473750140028fdf2cf85c0059b50406c719d6 Mon Sep 17 00:00:00 2001 From: Rian Stockbower Date: Tue, 19 May 2026 08:14:43 -0400 Subject: [PATCH 4/7] fix(statedir): reject both path separators OS-independently MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Codex PR review: - validateComponent now rejects both / and \ on every OS (was OS-dependent: \ slipped through on Unix). Add a\b to the invalid-name tests (statedir + cache). - Narrow the atomicWriteEnvelope doc: rename is atomic, error branches remove temps, but a hard crash can orphan a *.tmp (superseded by the next write, never read as an envelope). - Repeat the no-t.Parallel constraint on Hermetic's own doc comment. - docs §6: ReadResource[T]/WriteResource[T] (was Read/WriteResource[T]). [MON-5364] --- cache/cache_test.go | 2 +- cache/envelope.go | 7 +++++-- docs/working-with-state.md | 3 ++- statedir/resolver.go | 5 ++++- statedir/resolver_test.go | 2 +- statedirtest/statedirtest.go | 4 ++++ 6 files changed, 17 insertions(+), 6 deletions(-) diff --git a/cache/cache_test.go b/cache/cache_test.go index bf90366..9da9192 100644 --- a/cache/cache_test.go +++ b/cache/cache_test.go @@ -144,7 +144,7 @@ func TestInvalidRoot_CreatesNothing(t *testing.T) { func TestUnsafeComponents(t *testing.T) { root := t.TempDir() - bad := []string{"", "..", "a/b", "../escape", ".hidden..x"} + bad := []string{"", "..", "a/b", `a\b`, "../escape", ".hidden..x"} for _, v := range bad { t.Run("instance="+v, func(t *testing.T) { loc := cache.Locator{Root: root, InstanceKey: v} diff --git a/cache/envelope.go b/cache/envelope.go index 8a585d8..6c6edc7 100644 --- a/cache/envelope.go +++ b/cache/envelope.go @@ -82,8 +82,11 @@ func WriteResource[T any](loc Locator, name, ttl string, data T) error { } // atomicWriteEnvelope marshals env and writes it to the cache path for name -// using a temp-file-in-same-dir → rename. The temp file is removed on every -// failure branch so a crash never leaves a partial envelope. +// 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 { diff --git a/docs/working-with-state.md b/docs/working-with-state.md index 9c21509..87ae715 100644 --- a/docs/working-with-state.md +++ b/docs/working-with-state.md @@ -359,7 +359,8 @@ act* — done together (not two horizontal sweeps). (`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]`, - `Read`/`WriteResource[T]`, atomic write, version-mismatch-as-miss, + `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 / diff --git a/statedir/resolver.go b/statedir/resolver.go index 4cef230..fb354ed 100644 --- a/statedir/resolver.go +++ b/statedir/resolver.go @@ -39,7 +39,10 @@ func validateComponent(kind, v string) error { return fmt.Errorf("%w: %s is empty", ErrInvalidName, kind) case v == "." || v == "..": return fmt.Errorf("%w: %s is %q", ErrInvalidName, kind, v) - case strings.ContainsRune(v, '/') || strings.ContainsRune(v, os.PathSeparator): + 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, "..") diff --git a/statedir/resolver_test.go b/statedir/resolver_test.go index 79bd428..e007018 100644 --- a/statedir/resolver_test.go +++ b/statedir/resolver_test.go @@ -81,7 +81,7 @@ func TestCacheDir_PerBinary(t *testing.T) { func TestInvalidNames(t *testing.T) { statedirtest.Hermetic(t) - bad := []string{"", ".", "..", "a/b", "a..b", "../escape", string(os.PathSeparator)} + bad := []string{"", ".", "..", "a/b", `a\b`, "a..b", "../escape", 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) { diff --git a/statedirtest/statedirtest.go b/statedirtest/statedirtest.go index 30e84e2..eb54ff1 100644 --- a/statedirtest/statedirtest.go +++ b/statedirtest/statedirtest.go @@ -33,6 +33,10 @@ var envSubdir = map[string]string{ // 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() From a5175742902913f50b4b5212b17c130452a51582 Mon Sep 17 00:00:00 2001 From: Rian Stockbower Date: Tue, 19 May 2026 08:17:18 -0400 Subject: [PATCH 5/7] test: close TDD coverage gaps (CacheDirEnsured, ReadResource I/O path) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address independent TDD assessment: - TestCacheDirEnsured_Creates0700 — the previously fully-untested CacheDirEnsured export. - TestReadResource_IOErrorIsNotMiss — resource path is a directory, so os.ReadFile returns a non-NotExist error: verifies the "I/O error, not a miss" contract. - Classify exact-boundary row (now-ttl == d -> stale, the >= edge). - Add "." to the cache unsafe-component list. [MON-5364] --- cache/cache_test.go | 17 ++++++++++++++++- statedir/resolver_test.go | 21 +++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/cache/cache_test.go b/cache/cache_test.go index 9da9192..5fbf503 100644 --- a/cache/cache_test.go +++ b/cache/cache_test.go @@ -83,6 +83,20 @@ func TestMalformedJSONIsError(t *testing.T) { } } +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"} @@ -144,7 +158,7 @@ func TestInvalidRoot_CreatesNothing(t *testing.T) { func TestUnsafeComponents(t *testing.T) { root := t.TempDir() - bad := []string{"", "..", "a/b", `a\b`, "../escape", ".hidden..x"} + bad := []string{"", ".", "..", "a/b", `a\b`, "../escape", ".hidden..x"} for _, v := range bad { t.Run("instance="+v, func(t *testing.T) { loc := cache.Locator{Root: root, InstanceKey: v} @@ -171,6 +185,7 @@ func TestClassify_OnlyFreshStaleManual(t *testing.T) { }{ {"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}, diff --git a/statedir/resolver_test.go b/statedir/resolver_test.go index e007018..0ebc463 100644 --- a/statedir/resolver_test.go +++ b/statedir/resolver_test.go @@ -52,6 +52,27 @@ func TestScopeConfigDirEnsured_Creates0700(t *testing.T) { } } +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) From 83b0a1f26399e4568cc65e5c31785155732edb92 Mon Sep 17 00:00:00 2001 From: Rian Stockbower Date: Tue, 19 May 2026 08:23:34 -0400 Subject: [PATCH 6/7] fix(cache): reject trailing-dot keys + envelope identity-mismatch-as-miss MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address pr-review-daemon (APPROVE) low findings worth taking: - validComponent / statedir.validateComponent now reject a trailing dot: Windows strips it, so "foo." and "foo" would collide cross-OS. - ReadResource treats env.Resource != name or env.Instance != loc.InstanceKey as a miss (same self-healing rule as version mismatch): a misplaced/hand-edited file is never returned under the wrong key. - os.IsNotExist -> errors.Is(err, os.ErrNotExist) (chain-safe idiom). - nolint rationale corrected: "validated by Locator.resourceFile", not "not user input". - Tests: identity-mismatch-as-miss; "a..b" and trailing-dot pinned in the unsafe-component lists (statedir + cache). Declined: adding "_" to the safe set — deliberate jtk isSafeInstanceKey parity (hostnames/cloud-ids); broadening it is out of ticket scope. [MON-5364] --- cache/cache_test.go | 20 +++++++++++++++++++- cache/envelope.go | 9 ++++++--- cache/locator.go | 8 +++++++- statedir/resolver.go | 4 ++++ statedir/resolver_test.go | 2 +- 5 files changed, 37 insertions(+), 6 deletions(-) diff --git a/cache/cache_test.go b/cache/cache_test.go index 5fbf503..6b2f3c3 100644 --- a/cache/cache_test.go +++ b/cache/cache_test.go @@ -67,6 +67,24 @@ func TestVersionMismatchIsMiss(t *testing.T) { } } +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"} @@ -158,7 +176,7 @@ func TestInvalidRoot_CreatesNothing(t *testing.T) { func TestUnsafeComponents(t *testing.T) { root := t.TempDir() - bad := []string{"", ".", "..", "a/b", `a\b`, "../escape", ".hidden..x"} + 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} diff --git a/cache/envelope.go b/cache/envelope.go index 6c6edc7..ebbc183 100644 --- a/cache/envelope.go +++ b/cache/envelope.go @@ -47,9 +47,9 @@ func ReadResource[T any](loc Locator, name string) (Envelope[T], error) { return Envelope[T]{}, err } - data, err := os.ReadFile(path) //nolint:gosec // path is composed from an injected Locator + regex-validated components, not user input + data, err := os.ReadFile(path) //nolint:gosec // path already validated + composed by Locator.resourceFile (Root absolute, components regex-checked) if err != nil { - if os.IsNotExist(err) { + if errors.Is(err, os.ErrNotExist) { return Envelope[T]{}, ErrCacheMiss } return Envelope[T]{}, fmt.Errorf("cache: reading resource file: %w", err) @@ -60,7 +60,10 @@ func ReadResource[T any](loc Locator, name string) (Envelope[T], error) { return Envelope[T]{}, fmt.Errorf("cache: parsing resource file: %w", err) } - if env.Version != Version { + // 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 diff --git a/cache/locator.go b/cache/locator.go index 3ef3db1..3fcb723 100644 --- a/cache/locator.go +++ b/cache/locator.go @@ -25,8 +25,14 @@ var ( // 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, "..") + return safeComponent.MatchString(s) && + !strings.Contains(s, "..") && + !strings.HasSuffix(s, ".") } // Locator is the injected cache location. The cache library is diff --git a/statedir/resolver.go b/statedir/resolver.go index fb354ed..797c68f 100644 --- a/statedir/resolver.go +++ b/statedir/resolver.go @@ -46,6 +46,10 @@ func validateComponent(kind, v string) error { 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 } diff --git a/statedir/resolver_test.go b/statedir/resolver_test.go index 0ebc463..d89256e 100644 --- a/statedir/resolver_test.go +++ b/statedir/resolver_test.go @@ -102,7 +102,7 @@ func TestCacheDir_PerBinary(t *testing.T) { func TestInvalidNames(t *testing.T) { statedirtest.Hermetic(t) - bad := []string{"", ".", "..", "a/b", `a\b`, "a..b", "../escape", string(os.PathSeparator)} + 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) { From 3004e6c3aaade28df0b1a4aa3df054be8a2ef8f9 Mon Sep 17 00:00:00 2001 From: Rian Stockbower Date: Tue, 19 May 2026 08:25:26 -0400 Subject: [PATCH 7/7] docs(cache): ReadResource/ErrCacheMiss contract includes identity mismatch Final Codex alignment nit: the public doc comments still described miss behavior as absent-or-version-mismatch only; the contract now also covers a stored resource/instance that disagrees with the requested key. Comment only; behavior already shipped in the prior commit. [MON-5364] --- cache/envelope.go | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/cache/envelope.go b/cache/envelope.go index ebbc183..a9ff937 100644 --- a/cache/envelope.go +++ b/cache/envelope.go @@ -20,8 +20,10 @@ import ( // cache miss so schema bumps self-heal on the next write. const Version = 1 -// ErrCacheMiss reports an absent or version-mismatched envelope. It is not an -// error condition for callers — it is the "fetch and populate" signal. +// 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. @@ -36,8 +38,9 @@ type Envelope[T any] struct { // ReadResource reads the envelope for name at loc. // - (envelope, nil) on success. -// - (zero, ErrCacheMiss) if the file does not exist or the on-disk Version -// differs from the current schema. +// - (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.