Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions cache/cache_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
29 changes: 28 additions & 1 deletion cache/envelope.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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
Expand Down
Loading