diff --git a/internal/cache/cache.go b/internal/cache/cache.go index f7a85b9..bfbc892 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -4,8 +4,9 @@ package cache import ( + "crypto/sha256" "crypto/x509" - "encoding/base64" + "encoding/hex" "encoding/json" "encoding/pem" "errors" @@ -22,6 +23,15 @@ import ( const defaultTTL = time.Hour +const ( + entrySuffix = ".json" + metaSuffix = ".meta.json" +) + +type metadata struct { + URL string `json:"url"` +} + // Entry is a cached HTTP response. type Entry struct { URL string `json:"url"` @@ -91,7 +101,7 @@ func (c *Cache) loadAll() error { if err != nil { return err } - if d.IsDir() || !strings.HasSuffix(path, ".json") { + if d.IsDir() || !strings.HasSuffix(path, entrySuffix) || strings.HasSuffix(path, metaSuffix) { return nil } f, err := os.Open(path) @@ -108,28 +118,81 @@ func (c *Cache) loadAll() error { // Written by a version that still cached error responses. Dropping it here // keeps a poisoned entry from outliving the restart that was meant to clear it. slog.Warn("dropping cached error response", "url", e.URL, "status", e.Status) - if err := os.Remove(path); err != nil { + if err := removeEntry(path); err != nil { slog.Warn("removing cached error response failed", "path", path, "err", err) } return nil } + if prev, ok := c.entries[e.URL]; ok && !e.FreshUntil.After(prev.FreshUntil) { + // A copy of the same URL under a different file name, and not the fresher one. + if err := removeEntry(path); err != nil { + slog.Warn("removing duplicate cache entry failed", "path", path, "err", err) + } + return nil + } c.entries[e.URL] = &e + if path != c.entryPath(e.URL) { + if err := c.writeDisk(&e); err != nil { + slog.Warn("migrating cache entry failed", "path", path, "err", err) + return nil + } + if err := removeEntry(path); err != nil { + slog.Warn("removing migrated cache entry failed", "path", path, "err", err) + } + } return nil }) } +func (c *Cache) entryPath(url string) string { + sum := sha256.Sum256([]byte(url)) + return filepath.Join(c.dir, hex.EncodeToString(sum[:])+entrySuffix) +} + +// metaPath is the sidecar belonging to the entry stored at path. +func metaPath(path string) string { + return strings.TrimSuffix(path, entrySuffix) + metaSuffix +} + +// removeEntry deletes an entry and its sidecar, if it has one. +func removeEntry(path string) error { + err := os.Remove(path) + if metaErr := os.Remove(metaPath(path)); metaErr != nil && !errors.Is(metaErr, fs.ErrNotExist) { + err = errors.Join(err, metaErr) + } + return err +} + func (c *Cache) writeDisk(e *Entry) error { - path := filepath.Join(c.dir, base64.RawURLEncoding.EncodeToString([]byte(e.URL))+".json") - f, err := os.CreateTemp(c.dir, "tmp-*") + blob, err := json.Marshal(e) if err != nil { return err } + path := c.entryPath(e.URL) + if err := c.writeFile(path, append(blob, '\n')); err != nil { + return err + } + // The sidecar is a debugging aid, so failing to write it should not fail the Put. + meta, err := json.Marshal(metadata{URL: e.URL}) + if err == nil { + err = c.writeFile(metaPath(path), append(meta, '\n')) + } + if err != nil { + slog.Warn("writing cache entry sidecar failed", "url", e.URL, "err", err) + } + return nil +} - if err := json.NewEncoder(f).Encode(e); err != nil { - return errors.Join(err, f.Close()) +func (c *Cache) writeFile(path string, data []byte) error { + f, err := os.CreateTemp(c.dir, "tmp-*") + if err != nil { + return err + } + if _, err := f.Write(data); err != nil { + return errors.Join(err, f.Close(), os.Remove(f.Name())) } if err := f.Close(); err != nil { - return err + return errors.Join(err, os.Remove(f.Name())) } return os.Rename(f.Name(), path) } diff --git a/internal/cache/cache_test.go b/internal/cache/cache_test.go index d527c6c..834d66a 100644 --- a/internal/cache/cache_test.go +++ b/internal/cache/cache_test.go @@ -16,6 +16,7 @@ import ( "net/http" "os" "path/filepath" + "strings" "testing" "time" @@ -160,6 +161,89 @@ func TestPoisonedEntryDroppedOnLoad(t *testing.T) { assert.NoFileExists(t, path) } +func TestLongURLIsStored(t *testing.T) { + dir := t.TempDir() + c, err := New(dir) + require.NoError(t, err) + + url := "https://kdsintf.amd.com/vcek/v1/Genoa/" + strings.Repeat("ab", 64) + "?blSPL=10&teeSPL=0&snpSPL=27&ucodeSPL=84" + _, err = c.Put(url, 200, http.Header{}, []byte("cert")) + require.NoError(t, err) + + c2, err := New(dir) + require.NoError(t, err) + got, fresh := c2.Get(url) + require.NotNil(t, got, "entry did not survive a reopen") + assert.True(t, fresh) + assert.Equal(t, "cert", string(got.Body)) +} + +func TestSidecarRecordsURL(t *testing.T) { + dir := t.TempDir() + c, err := New(dir) + require.NoError(t, err) + url := "https://kdsintf.amd.com/vcek/v1/Genoa/" + strings.Repeat("ab", 64) + _, err = c.Put(url, 200, http.Header{}, []byte("cert")) + require.NoError(t, err) + + raw, err := os.ReadFile(metaPath(c.entryPath(url))) + require.NoError(t, err, "no sidecar next to the entry") + var meta metadata + require.NoError(t, json.Unmarshal(raw, &meta)) + assert.Equal(t, url, meta.URL, "sidecar should name the URL the file was stored under") + + c2, err := New(dir) + require.NoError(t, err) + assert.Len(t, c2.entries, 1) + got, fresh := c2.Get(url) + require.NotNil(t, got) + assert.True(t, fresh) + assert.FileExists(t, metaPath(c.entryPath(url))) +} + +func TestSidecarRemovedWithEntry(t *testing.T) { + dir := t.TempDir() + c, err := New(dir) + require.NoError(t, err) + url := "https://example/x" + _, err = c.Put(url, 200, http.Header{}, []byte("hi")) + require.NoError(t, err) + + poisoned := Entry{URL: url, Status: 500, Header: http.Header{}, FreshUntil: time.Now().Add(time.Hour)} + raw, err := json.Marshal(poisoned) + require.NoError(t, err) + require.NoError(t, os.WriteFile(c.entryPath(url), raw, 0o600)) + + _, err = New(dir) + require.NoError(t, err) + assert.NoFileExists(t, c.entryPath(url)) + assert.NoFileExists(t, metaPath(c.entryPath(url)), "sidecar outlived its entry") +} + +func TestLegacyEntryMigratedOnLoad(t *testing.T) { + dir := t.TempDir() + legacy := Entry{ + URL: "https://example/x", + Status: 200, + Header: http.Header{}, + Body: []byte("hi"), + FreshUntil: time.Now().Add(time.Hour), + } + raw, err := json.Marshal(legacy) + require.NoError(t, err) + legacyPath := filepath.Join(dir, base64.RawURLEncoding.EncodeToString([]byte(legacy.URL))+".json") + require.NoError(t, os.WriteFile(legacyPath, raw, 0o600)) + + c, err := New(dir) + require.NoError(t, err) + got, fresh := c.Get(legacy.URL) + require.NotNil(t, got, "entry written by an older version should still be loaded") + assert.True(t, fresh) + assert.NoFileExists(t, legacyPath, "legacy file should have been migrated away") + assert.FileExists(t, c.entryPath(legacy.URL)) + assert.FileExists(t, metaPath(c.entryPath(legacy.URL)), "migration should write a sidecar") +} + func TestStaleEntryStillReturned(t *testing.T) { dir := t.TempDir() c, err := New(dir)