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
178 changes: 178 additions & 0 deletions internal/agentstate/cache.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
package agentstate

import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"time"

"github.com/compliance-framework/api/pkg/agentconfig"
)

const (
cacheFile = "remote-config.json"
cacheVersion = 1
)

// ErrCacheCorrupt is returned by LoadCache when the cache file does not parse or its checksum
// does not match. The agent reports failed/cache-corrupt once and continues without the cache.
var ErrCacheCorrupt = errors.New("remote config cache corrupt")

// Identity binds a cache to the API and credentials it was fetched with (R7). A cache whose
// identity differs from the current one is discarded, so a new client_id or API URL never
// applies another agent's overlay.
type Identity struct {
APIURL string `json:"api_url"`
ClientID string `json:"client_id"`
}

// OverlayRecord is one overlay document received from the API.
type OverlayRecord struct {
Revision int64 `json:"revision"`
// ETag is the RAW ETag header of the 200 response (opaque, e.g. "r<rev>-<uuid>"). It is
// sent back verbatim as If-None-Match and never built from a revision number (R7).
ETag string `json:"etag"`
Overlay json.RawMessage `json:"overlay"`
FetchedAt time.Time `json:"fetched_at"`
}

// RejectedRecord remembers that a fetched overlay was rejected against a given base, so it
// is not re-prepared on every poll. It is keyed by (ETag, BaseFingerprint); the revision is
// informational only because a reset API can reuse revision numbers, except when the
// response carried no ETag: then (Revision, OverlaySHA256) stands in for it.
type RejectedRecord struct {
Revision int64 `json:"revision"`
ETag string `json:"etag"`
// OverlaySHA256 keys the record with the revision when the response had no ETag.
OverlaySHA256 string `json:"overlay_sha256,omitempty"`
BaseFingerprint string `json:"base_fingerprint"`
Status string `json:"status"`
Reason string `json:"reason"`
Error string `json:"error"`
// Unsafe completes the outcome re-reported after a restart. It is optional: caches
// written before it existed load (and checksum) unchanged.
Unsafe []agentconfig.Change `json:"unsafe,omitempty"`
}

// Cache is the persisted remote configuration state (0600, it may hold values an admin typed).
type Cache struct {
Version int `json:"version"`
Identity Identity `json:"identity"`
Applied *OverlayRecord `json:"applied,omitempty"`
Fetched *OverlayRecord `json:"fetched,omitempty"` // newest 200 body; may be the rejected one
Rejected *RejectedRecord `json:"rejected,omitempty"`
Checksum string `json:"checksum"` // sha256 of the JSON with Checksum = ""
}

// IfNoneMatch is the ETag to present on the next fetch: the last 200's raw ETag, falling back
// to the applied one, or "" for an unconditional fetch.
func (c *Cache) IfNoneMatch() string {
if c == nil {
return ""
}
if c.Fetched != nil && c.Fetched.ETag != "" {
return c.Fetched.ETag
}
if c.Applied != nil {
return c.Applied.ETag
}
return ""
}

// CanonicalOverlay returns raw in the form a save/load round trip of the cache yields: compact,
// with <, > and & escaped as encoding/json escapes them. SaveCache re-indents the overlay and
// LoadCache would otherwise return those re-indented bytes, so anything that hashes or compares
// overlay bytes (the no-ETag rejection key) must use this form, for a fresh 200 body as well
// as a cached one. An empty overlay is returned unchanged.
func CanonicalOverlay(raw json.RawMessage) (json.RawMessage, error) {
if len(raw) == 0 {
return raw, nil
}
return json.Marshal(raw)
}

// canonicalize rewrites the overlays of c to their CanonicalOverlay form. It does not change
// the checksum, which is computed over the same compact form.
func (c *Cache) canonicalize() error {
for _, rec := range []*OverlayRecord{c.Applied, c.Fetched} {
if rec == nil {
continue
}
canon, err := CanonicalOverlay(rec.Overlay)
if err != nil {
return err
}
rec.Overlay = canon
}
return nil
}

func (c Cache) checksum() (string, error) {
c.Checksum = ""
raw, err := json.Marshal(c)
if err != nil {
return "", err
}
sum := sha256.Sum256(raw)
return hex.EncodeToString(sum[:]), nil
}

// CachePath returns the cache file path.
func (s *Store) CachePath() string { return filepath.Join(s.dir, cacheFile) }

// LoadCache reads the cache for identity id. A missing file yields an empty cache. A corrupt
// file yields an empty cache and ErrCacheCorrupt. A cache bound to another identity is
// discarded (empty cache, nil error, one INFO). Overlays are returned in CanonicalOverlay form.
func (s *Store) LoadCache(id Identity) (*Cache, error) {
empty := &Cache{Version: cacheVersion, Identity: id}
raw, err := os.ReadFile(s.CachePath())
if errors.Is(err, os.ErrNotExist) {
return empty, nil
}
if err != nil {
return empty, fmt.Errorf("%w: %v", ErrCacheCorrupt, err)
}
var c Cache
if err := json.Unmarshal(raw, &c); err != nil {
return empty, fmt.Errorf("%w: %v", ErrCacheCorrupt, err)
}
want, err := c.checksum()
if err != nil || c.Checksum != want || c.Version != cacheVersion {
return empty, fmt.Errorf("%w: checksum or version mismatch", ErrCacheCorrupt)
}
if c.Identity != id {
s.logger.Info("Discarding the remote config cache: it belongs to another API URL or client ID")
return empty, nil
}
if err := c.canonicalize(); err != nil {
return empty, fmt.Errorf("%w: %v", ErrCacheCorrupt, err)
}
return &c, nil
}

// SaveCache writes the cache atomically (temp file, fsync, rename) with mode 0600. It is a
// no-op error when the store is not writable. It rewrites c's overlays to their
// CanonicalOverlay form, so c holds the same bytes a later LoadCache returns.
func (s *Store) SaveCache(c *Cache) error {
if !s.writable {
return errors.New("state directory is not writable")
}
c.Version = cacheVersion
if err := c.canonicalize(); err != nil {
return err
}
sum, err := c.checksum()
if err != nil {
return err
}
c.Checksum = sum
raw, err := json.MarshalIndent(c, "", " ")
if err != nil {
return err
}
return WriteFileAtomic(s.CachePath(), raw, 0o600)
}
122 changes: 122 additions & 0 deletions internal/agentstate/cache_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package agentstate

import (
"bytes"
"encoding/json"
"errors"
"os"
"testing"
"time"
)

func TestCache_RoundTripAndIfNoneMatch(t *testing.T) {
s := Open(t.TempDir(), nil)
id := Identity{APIURL: "http://api.test", ClientID: "c"}
c, err := s.LoadCache(id)
if err != nil || c.IfNoneMatch() != "" {
t.Fatalf("empty cache: %+v %v", c, err)
}
c.Applied = &OverlayRecord{Revision: 1, ETag: `"r1-a"`, Overlay: json.RawMessage(`{"plugins": {}}`), FetchedAt: time.Now().UTC()}
if got := c.IfNoneMatch(); got != `"r1-a"` {
t.Fatalf("IfNoneMatch falls back to applied, got %q", got)
}
c.Fetched = &OverlayRecord{Revision: 2, ETag: `W/"r2-b"`, Overlay: json.RawMessage(`{}`)}
if got := c.IfNoneMatch(); got != `W/"r2-b"` {
t.Fatalf("IfNoneMatch prefers the raw fetched ETag, got %q", got)
}
if err := s.SaveCache(c); err != nil {
t.Fatal(err)
}
loaded, err := s.LoadCache(id)
if err != nil {
t.Fatalf("reload: %v", err)
}
if loaded.Fetched.ETag != `W/"r2-b"` || loaded.Applied.Revision != 1 {
t.Fatalf("round trip lost data: %+v", loaded)
}
}

func TestCache_CorruptAndIdentity(t *testing.T) {
s := Open(t.TempDir(), nil)
id := Identity{APIURL: "http://api.test", ClientID: "c"}
c, _ := s.LoadCache(id)
c.Applied = &OverlayRecord{Revision: 1, ETag: `"r1-a"`, Overlay: json.RawMessage(`{}`)}
if err := s.SaveCache(c); err != nil {
t.Fatal(err)
}
other, err := s.LoadCache(Identity{APIURL: "http://api.test", ClientID: "d"})
if err != nil || other.Applied != nil {
t.Fatalf("an identity mismatch must yield an empty cache: %+v %v", other, err)
}
if err := os.WriteFile(s.CachePath(), []byte(`{"version":1,"checksum":"nope"}`), 0o600); err != nil {
t.Fatal(err)
}
empty, err := s.LoadCache(id)
if !errors.Is(err, ErrCacheCorrupt) || empty == nil || empty.Applied != nil {
t.Fatalf("expected ErrCacheCorrupt with an empty cache, got %+v %v", empty, err)
}
}

// The overlay bytes, and so the no-ETag rejection key hashed from them, must not change across
// a save/load round trip: SaveCache re-indents a json.RawMessage and escapes <, > and &.
func TestCache_OverlayBytesSurviveRoundTrip(t *testing.T) {
s := Open(t.TempDir(), nil)
id := Identity{APIURL: "http://api.test", ClientID: "c"}
fresh := json.RawMessage("{\n \"plugins\": {\"p\": {\"config\": {\"q\": \"a<b && c>d\"}}},\n \"x\": [1, 2]\n}")
canon, err := CanonicalOverlay(fresh)
if err != nil {
t.Fatal(err)
}
if again, _ := CanonicalOverlay(canon); !bytes.Equal(again, canon) {
t.Fatalf("CanonicalOverlay is not idempotent: %s != %s", again, canon)
}

c, _ := s.LoadCache(id)
c.Fetched = &OverlayRecord{Revision: 3, Overlay: append(json.RawMessage(nil), fresh...)}
c.Applied = &OverlayRecord{Revision: 2, ETag: `"r2-a"`, Overlay: append(json.RawMessage(nil), fresh...)}
if err := s.SaveCache(c); err != nil {
t.Fatal(err)
}
if !bytes.Equal(c.Fetched.Overlay, canon) {
t.Fatalf("SaveCache must leave the canonical bytes in memory: %s", c.Fetched.Overlay)
}
for i := 0; i < 2; i++ {
loaded, err := s.LoadCache(id)
if err != nil {
t.Fatalf("reload %d: %v", i, err)
}
if !bytes.Equal(loaded.Fetched.Overlay, canon) || !bytes.Equal(loaded.Applied.Overlay, canon) {
t.Fatalf("reload %d changed the overlay bytes:\n got %s\nwant %s", i, loaded.Fetched.Overlay, canon)
}
if err := s.SaveCache(loaded); err != nil {
t.Fatal(err)
}
}
}

// A cache written before overlays were canonicalized (an indented overlay, checksummed over its
// compact form) still loads, with its overlay in canonical form.
func TestCache_LoadsLegacyIndentedOverlay(t *testing.T) {
s := Open(t.TempDir(), nil)
id := Identity{APIURL: "http://api.test", ClientID: "c"}
c := &Cache{Version: cacheVersion, Identity: id, Fetched: &OverlayRecord{Revision: 1, Overlay: json.RawMessage("{ \"a\": \"<\" }")}}
sum, err := c.checksum()
if err != nil {
t.Fatal(err)
}
c.Checksum = sum
raw, err := json.MarshalIndent(c, "", " ")
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(s.CachePath(), raw, 0o600); err != nil {
t.Fatal(err)
}
loaded, err := s.LoadCache(id)
if err != nil {
t.Fatal(err)
}
if got, want := string(loaded.Fetched.Overlay), `{"a":"\u003c"}`; got != want {
t.Fatalf("legacy overlay: got %s, want %s", got, want)
}
}
3 changes: 2 additions & 1 deletion internal/agentstate/store.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
// Package agentstate owns the agent's per-instance state directory: the stable instance ID
// (R31).
// (R31) and the remote configuration cache (R7).
//
// Layout (the OCI download caches under .compliance-framework/{plugins,policies} are shared
// and unchanged):
//
// .compliance-framework/state/<key>/ key = hex(sha256(abs config path))[:16]; dir 0700
// instance-id 0644, UUID + "\n"
// remote-config.json 0600
//
// The package is a leaf: it never imports cmd.
package agentstate
Expand Down
Loading