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
82 changes: 71 additions & 11 deletions pkg/catalog/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,53 @@ import (
"os"
)

// ErrCacheStale is returned by LoadCache when the source XML is newer than
// the cache file. Callers should fall back to ParseTemplate.
var ErrCacheStale = errors.New("cache is older than source")
// ErrCacheStale is returned by LoadCache when the on-disk cache cannot
// be safely loaded into the running build. Three independent triggers:
//
// - the source XML's mtime is newer than the cache file's mtime
// (the catalog changed since the cache was written);
// - the cache's magic header doesn't match cacheFileMagic (a
// pre-versioning legacy cache file or a corrupted one);
// - the cache's parser-schema version doesn't equal
// parserSchemaVersion (the in-memory schema changed since the
// cache was written, even if the source XML didn't).
//
// In every case the right answer is the same: drop the cache and let
// the caller fall back to ParseTemplate, which writes a fresh cache.
var ErrCacheStale = errors.New("cache is stale or incompatible with this build")

// parserSchemaVersion bumps any time the in-memory shape that gets gob-
// encoded (Template / Group / Data / Class / CompoundFieldOverride /
// related catalog structs) gains, drops, or rewrites a field, OR any
// time a load-time pass — like resolveTypedefs — changes how the same
// XML decodes into those structs. Bumping invalidates every pre-
// existing .cache sibling on disk on the next load, forcing a clean
// reparse so users running a new mythy build don't transparently
// consume an old cache produced by the old parser.
//
// Bumping policy: when a PR touches any file under pkg/catalog/ that
// affects parse output, bump this. Reviewers should treat
// pkg/catalog/ diffs without a version bump as suspicious. Version 1
// is the first build that emits the versioned format; pre-version
// caches are detected via the magic header and rejected as stale.
const parserSchemaVersion = 1

// cacheFileMagic is a sentinel written at the start of the gob stream.
// Pre-versioning caches encoded a bare *Template directly, so any
// decode attempt with the new wrapper fails fast on the magic field
// and routes to ErrCacheStale. Don't change this string — bump
// parserSchemaVersion instead.
const cacheFileMagic = "mythy-catalog-cache"

// cacheFile is the on-disk wrapper around the parsed Template. The
// gob encoding is "the entire cacheFile struct", so adding fields
// here automatically reaches every existing cache via the version
// check.
type cacheFile struct {
Magic string
SchemaVersion int
Template *Template
}

// SaveCache writes a parsed Template to disk as gob. The decoder needs a
// concrete type, so gob registrations live here.
Expand All @@ -20,14 +64,20 @@ func SaveCache(tpl *Template, path string) error {
}
defer f.Close()
enc := gob.NewEncoder(f)
if err := enc.Encode(tpl); err != nil {
if err := enc.Encode(cacheFile{
Magic: cacheFileMagic,
SchemaVersion: parserSchemaVersion,
Template: tpl,
}); err != nil {
return fmt.Errorf("gob encode: %w", err)
}
return nil
}

// LoadCache reads a cached Template from disk. Returns ErrCacheStale if the
// source XML's mtime is newer than the cache's mtime.
// LoadCache reads a cached Template from disk. Returns ErrCacheStale if
// the source XML is newer, if the cache lacks the versioned header
// (legacy format), or if the version doesn't match the current
// parserSchemaVersion.
func LoadCache(cachePath, sourcePath string) (*Template, error) {
cInfo, err := os.Stat(cachePath)
if err != nil {
Expand All @@ -45,23 +95,33 @@ func LoadCache(cachePath, sourcePath string) (*Template, error) {
return nil, err
}
defer f.Close()
var tpl Template
if err := gob.NewDecoder(f).Decode(&tpl); err != nil {
return nil, fmt.Errorf("gob decode: %w", err)
var wrapped cacheFile
if err := gob.NewDecoder(f).Decode(&wrapped); err != nil {
// Most commonly hit when reading a pre-versioning cache file
// that gob-decodes into the wrapper as a malformed struct.
// Treat any decode failure as stale so the caller reparses.
return nil, ErrCacheStale
}
if wrapped.Magic != cacheFileMagic || wrapped.SchemaVersion != parserSchemaVersion {
return nil, ErrCacheStale
}
tpl := wrapped.Template
if tpl == nil {
return nil, ErrCacheStale
}
if tpl.Menu != nil {
reparent(tpl.Menu, nil)
}
// Re-link DATA/COMMAND.Message — they're pointers into tpl.Messages,
// gob serializes them by value (deep copies). Re-link by name.
if tpl.Menu != nil {
linkMenuToMessages(&tpl)
linkMenuToMessages(tpl)
}
// Normalize TIPO aliases. Idempotent: caches written by newer
// builds already carry resolved Tipo, and the typedef map lookup
// is a no-op for primitives.
tpl.resolveTypedefs()
return &tpl, nil
return tpl, nil
}

// reparent rebuilds the Group.Parent back-pointers after gob decode.
Expand Down
69 changes: 69 additions & 0 deletions pkg/catalog/cache_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package catalog

import (
"encoding/gob"
"os"
"path/filepath"
"testing"
Expand Down Expand Up @@ -105,6 +106,74 @@ func TestLoadCacheResolvesTypedefsForStaleCaches(t *testing.T) {
}
}

func TestLoadCacheRejectsLegacyUnwrappedFormat(t *testing.T) {
// Before issue #5 was fixed, SaveCache wrote a bare gob-encoded
// *Template (no magic, no version). After the fix, LoadCache must
// detect this and route to ErrCacheStale so the caller reparses
// the XML — otherwise a user upgrading mythy without wiping their
// Templates/ would silently keep running on the old parser's output.
tmp := t.TempDir()
src := filepath.Join(tmp, "TEST")
if err := os.WriteFile(src, []byte("<DEVICE/>"), 0o644); err != nil {
t.Fatal(err)
}
cachePath := filepath.Join(tmp, "TEST.cache")
f, err := os.Create(cachePath)
if err != nil {
t.Fatal(err)
}
// Write a legacy-format cache: bare *Template, no wrapper.
if err := gob.NewEncoder(f).Encode(&Template{Identification: 7}); err != nil {
t.Fatal(err)
}
f.Close()

// Ensure the cache mtime is newer than the source, so the only
// remaining stale-trigger is the missing header.
future := time.Now().Add(1 * time.Second)
if err := os.Chtimes(cachePath, future, future); err != nil {
t.Fatal(err)
}

if _, err := LoadCache(cachePath, src); err != ErrCacheStale {
t.Errorf("LoadCache on legacy format: err = %v, want ErrCacheStale", err)
}
}

func TestLoadCacheRejectsWrongSchemaVersion(t *testing.T) {
// When the parser changes the on-disk shape, parserSchemaVersion
// is bumped. A cache written by an older build must be rejected
// so the caller reparses with the current parser.
tmp := t.TempDir()
src := filepath.Join(tmp, "TEST")
if err := os.WriteFile(src, []byte("<DEVICE/>"), 0o644); err != nil {
t.Fatal(err)
}
cachePath := filepath.Join(tmp, "TEST.cache")
f, err := os.Create(cachePath)
if err != nil {
t.Fatal(err)
}
// Write a cacheFile with a deliberately-wrong schema version.
if err := gob.NewEncoder(f).Encode(cacheFile{
Magic: cacheFileMagic,
SchemaVersion: parserSchemaVersion + 1, // pretend we're an older build
Template: &Template{Identification: 7},
}); err != nil {
t.Fatal(err)
}
f.Close()

future := time.Now().Add(1 * time.Second)
if err := os.Chtimes(cachePath, future, future); err != nil {
t.Fatal(err)
}

if _, err := LoadCache(cachePath, src); err != ErrCacheStale {
t.Errorf("LoadCache on wrong-version cache: err = %v, want ErrCacheStale", err)
}
}

func copyFile(t *testing.T, src, dst string) error {
t.Helper()
b, err := os.ReadFile(src)
Expand Down