diff --git a/cache/cache_test.go b/cache/cache_test.go index 6b2f3c3..bf99877 100644 --- a/cache/cache_test.go +++ b/cache/cache_test.go @@ -259,3 +259,73 @@ func TestAge(t *testing.T) { }) } } + +func TestWriteEnvelope_VerbatimPreservation(t *testing.T) { + loc := cache.Locator{Root: t.TempDir(), InstanceKey: "host-x"} + + // A zero FetchedAt is the "stale marker" invalidation case — it must + // survive the round-trip exactly (WriteResource would re-stamp it). + env := cache.Envelope[payload]{ + Resource: "fields", + Instance: "host-x", + FetchedAt: time.Time{}, + TTL: "1h", + Version: cache.Version, + Data: payload{Name: "f", N: 3}, + } + if err := cache.WriteEnvelope(loc, env); err != nil { + t.Fatalf("WriteEnvelope: %v", err) + } + got, err := cache.ReadResource[payload](loc, "fields") + if err != nil { + t.Fatalf("ReadResource after WriteEnvelope: %v (must not be a self-inflicted miss)", err) + } + if !got.FetchedAt.IsZero() { + t.Fatalf("FetchedAt not preserved verbatim: got %v, want zero", got.FetchedAt) + } + if got.TTL != "1h" || got.Data != env.Data || got.Resource != "fields" || got.Instance != "host-x" { + t.Fatalf("envelope not preserved verbatim: %+v", got) + } +} + +func TestWriteEnvelope_InstanceMismatchWritesNothing(t *testing.T) { + root := t.TempDir() + loc := cache.Locator{Root: root, InstanceKey: "host-a"} + env := cache.Envelope[payload]{ + Resource: "r", Instance: "host-b", TTL: "1h", Version: cache.Version, + Data: payload{Name: "x", N: 1}, + } + if err := cache.WriteEnvelope(loc, env); !errors.Is(err, cache.ErrInstanceMismatch) { + t.Fatalf("err = %v, want ErrInstanceMismatch", err) + } + if entries, _ := os.ReadDir(filepath.Join(root, "host-a")); len(entries) != 0 { + t.Fatalf("WriteEnvelope wrote files despite instance mismatch: %v", entries) + } + if entries, _ := os.ReadDir(root); len(entries) != 0 { + t.Fatalf("WriteEnvelope created paths despite instance mismatch: %v", entries) + } +} + +func TestWriteEnvelope_UnsafeResourceName(t *testing.T) { + loc := cache.Locator{Root: t.TempDir(), InstanceKey: "i"} + env := cache.Envelope[payload]{Resource: "../escape", Instance: "i", TTL: "1h", Version: cache.Version} + if err := cache.WriteEnvelope(loc, env); !errors.Is(err, cache.ErrInvalidName) { + t.Fatalf("err = %v, want ErrInvalidName", err) + } +} + +func TestWriteResource_StillStampsFetchedAt(t *testing.T) { + // Regression: WriteResource now delegates to WriteEnvelope but must still + // stamp a fresh non-zero FetchedAt (its existing contract is unchanged). + loc := cache.Locator{Root: t.TempDir(), InstanceKey: "i"} + if err := cache.WriteResource(loc, "r", "1h", payload{Name: "a", N: 1}); err != nil { + t.Fatalf("WriteResource: %v", err) + } + got, err := cache.ReadResource[payload](loc, "r") + if err != nil { + t.Fatalf("ReadResource: %v", err) + } + if got.FetchedAt.IsZero() { + t.Fatalf("WriteResource must stamp a non-zero FetchedAt") + } +} diff --git a/cache/envelope.go b/cache/envelope.go index a9ff937..966638f 100644 --- a/cache/envelope.go +++ b/cache/envelope.go @@ -26,6 +26,12 @@ const Version = 1 // and populate" signal. var ErrCacheMiss = errors.New("cache: miss") +// ErrInstanceMismatch reports that a WriteEnvelope call supplied an envelope +// whose Instance does not match the Locator's InstanceKey. Writing it would +// produce a file the next ReadResource immediately rejects as a miss, so the +// write is refused and nothing is created. +var ErrInstanceMismatch = errors.New("cache: envelope instance does not match locator") + // Envelope is the on-disk JSON shape for a single cached resource. type Envelope[T any] struct { Resource string `json:"resource"` @@ -84,7 +90,28 @@ func WriteResource[T any](loc Locator, name, ttl string, data T) error { Version: Version, Data: data, } - return atomicWriteEnvelope(loc, name, env) + return WriteEnvelope(loc, env) +} + +// WriteEnvelope atomically writes a caller-supplied envelope verbatim: +// FetchedAt, TTL, and Version are preserved exactly as given (unlike +// WriteResource, which stamps a fresh FetchedAt/Version). This is the +// invalidation primitive — e.g. a "stale" marker writes an envelope whose +// FetchedAt is the zero time and expects that to survive the round-trip. +// +// The resource name is taken from env.Resource (so the file the next +// ReadResource looks up is exactly the one written), and env.Instance MUST +// equal loc.InstanceKey. Because ReadResource treats a resource/instance +// mismatch as a miss, WriteEnvelope refuses to write an envelope that the +// next read would immediately reject: a mismatch returns ErrInstanceMismatch +// and writes nothing. env.Resource is validated by the shared path guard +// (ErrInvalidName on an unsafe name). +func WriteEnvelope[T any](loc Locator, env Envelope[T]) error { + if env.Instance != loc.InstanceKey { + return fmt.Errorf("%w: envelope instance %q != locator instance %q", + ErrInstanceMismatch, env.Instance, loc.InstanceKey) + } + return atomicWriteEnvelope(loc, env.Resource, env) } // atomicWriteEnvelope marshals env and writes it to the cache path for name