From e5a14da6645eb709792bef74be0aa5262129210f Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:55:04 -0300 Subject: [PATCH 1/2] feat(pluginlib): read the agent library version a plugin binary was built with What a plugin does with a policy depends on the policy-manager compiled into it, not on the running agent. pluginlib.Version reads the github.com/compliance-framework/agent version from a plugin binary's Go build info without starting it ("" for a replace or devel build), and Cache memoizes it by path, size and modification time. The config report in the next PR lists it per plugin as diagnostics (R76); nothing is gated on it. Co-Authored-By: Claude Opus 5.5 --- internal/pluginlib/pluginlib.go | 81 ++++++++++++++++++++++++++++ internal/pluginlib/pluginlib_test.go | 33 ++++++++++++ 2 files changed, 114 insertions(+) create mode 100644 internal/pluginlib/pluginlib.go create mode 100644 internal/pluginlib/pluginlib_test.go diff --git a/internal/pluginlib/pluginlib.go b/internal/pluginlib/pluginlib.go new file mode 100644 index 0000000..6dac3d4 --- /dev/null +++ b/internal/pluginlib/pluginlib.go @@ -0,0 +1,81 @@ +// Package pluginlib reads which version of this module (the agent library) a plugin binary +// was built with (R76). The config report lists it per plugin as diagnostics. +// +// Plugins evaluate policies with the policy-manager they embed, so how a plugin evaluates a +// policy depends on the agent library it was compiled against, not on the running agent. +// The version comes from the binary's Go build info (debug/buildinfo), so the plugin is never +// started to find out. +package pluginlib + +import ( + "debug/buildinfo" + "os" + "sync" + "time" +) + +// AgentModule is the module path plugins import for runner and policy-manager. +const AgentModule = "github.com/compliance-framework/agent" + +// Version returns the version of AgentModule the plugin binary at path was built with, or "" +// when it is unknown: the file has no Go build info, does not depend on AgentModule (a +// non-Go or unrelated binary), or replaces it (a local build, where the version says nothing +// about the code). An error is returned only when the file cannot be read as a Go binary. +func Version(path string) (string, error) { + info, err := buildinfo.ReadFile(path) + if err != nil { + return "", err + } + for _, dep := range info.Deps { + if dep.Path != AgentModule { + continue + } + if dep.Replace != nil || dep.Version == "(devel)" { + return "", nil + } + return dep.Version, nil + } + return "", nil +} + +// Cache memoizes Version per binary. A binary is identified by its path, size and +// modification time, so a plugin replaced in place is read again. It is safe for concurrent +// use. +type Cache struct { + mu sync.Mutex + entries map[string]cacheEntry +} + +type cacheEntry struct { + size int64 + modTime time.Time + version string + err error +} + +// cacheLimit bounds the cache; plugins are few, so it is only a safety net. +const cacheLimit = 256 + +// Version is the package-level Version, memoized. +func (c *Cache) Version(path string) (string, error) { + st, err := os.Stat(path) + if err != nil { + return "", err + } + c.mu.Lock() + if e, ok := c.entries[path]; ok && e.size == st.Size() && e.modTime.Equal(st.ModTime()) { + c.mu.Unlock() + return e.version, e.err + } + c.mu.Unlock() + + version, err := Version(path) + + c.mu.Lock() + defer c.mu.Unlock() + if c.entries == nil || len(c.entries) >= cacheLimit { + c.entries = map[string]cacheEntry{} + } + c.entries[path] = cacheEntry{size: st.Size(), modTime: st.ModTime(), version: version, err: err} + return version, err +} diff --git a/internal/pluginlib/pluginlib_test.go b/internal/pluginlib/pluginlib_test.go new file mode 100644 index 0000000..c2b1777 --- /dev/null +++ b/internal/pluginlib/pluginlib_test.go @@ -0,0 +1,33 @@ +package pluginlib + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestVersion(t *testing.T) { + // The test binary is built from this module itself, so it does not depend on it. + self, err := os.Executable() + require.NoError(t, err) + version, err := Version(self) + require.NoError(t, err) + assert.Empty(t, version) + + notGo := filepath.Join(t.TempDir(), "plugin") + require.NoError(t, os.WriteFile(notGo, []byte("#!/bin/sh\necho hi\n"), 0o755)) + _, err = Version(notGo) + assert.Error(t, err) + + var cache Cache + version, err = cache.Version(self) + require.NoError(t, err) + assert.Empty(t, version) + _, err = cache.Version(notGo) + assert.Error(t, err) + _, err = cache.Version(filepath.Join(t.TempDir(), "missing")) + assert.Error(t, err) +} From 601c490c63f280724133819682ed90fedfba96cd Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Tue, 6 Oct 2026 07:25:07 -0300 Subject: [PATCH 2/2] test(pluginlib): cover the build-info lookup and cache invalidation Split the lookup into versionFromInfo and table-test it (version, pseudo-version, replace, (devel), absent); test that the cache re-reads a binary whose size or mtime changed. Co-Authored-By: Claude Opus 5.5 --- internal/pluginlib/pluginlib.go | 21 +++++++++-- internal/pluginlib/pluginlib_test.go | 55 ++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 4 deletions(-) diff --git a/internal/pluginlib/pluginlib.go b/internal/pluginlib/pluginlib.go index 6dac3d4..1f9d378 100644 --- a/internal/pluginlib/pluginlib.go +++ b/internal/pluginlib/pluginlib.go @@ -10,6 +10,7 @@ package pluginlib import ( "debug/buildinfo" "os" + "runtime/debug" "sync" "time" ) @@ -26,16 +27,22 @@ func Version(path string) (string, error) { if err != nil { return "", err } + return versionFromInfo(info), nil +} + +// versionFromInfo returns the version of AgentModule a binary with this build info depends +// on, or "" when it does not, or replaces it. +func versionFromInfo(info *debug.BuildInfo) string { for _, dep := range info.Deps { if dep.Path != AgentModule { continue } if dep.Replace != nil || dep.Version == "(devel)" { - return "", nil + return "" } - return dep.Version, nil + return dep.Version } - return "", nil + return "" } // Cache memoizes Version per binary. A binary is identified by its path, size and @@ -44,6 +51,8 @@ func Version(path string) (string, error) { type Cache struct { mu sync.Mutex entries map[string]cacheEntry + // read is Version; tests replace it to count reads. + read func(path string) (string, error) } type cacheEntry struct { @@ -69,7 +78,11 @@ func (c *Cache) Version(path string) (string, error) { } c.mu.Unlock() - version, err := Version(path) + read := c.read + if read == nil { + read = Version + } + version, err := read(path) c.mu.Lock() defer c.mu.Unlock() diff --git a/internal/pluginlib/pluginlib_test.go b/internal/pluginlib/pluginlib_test.go index c2b1777..5b1b82b 100644 --- a/internal/pluginlib/pluginlib_test.go +++ b/internal/pluginlib/pluginlib_test.go @@ -1,9 +1,12 @@ package pluginlib import ( + "fmt" "os" "path/filepath" + "runtime/debug" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -31,3 +34,55 @@ func TestVersion(t *testing.T) { _, err = cache.Version(filepath.Join(t.TempDir(), "missing")) assert.Error(t, err) } + +func TestVersionFromInfo(t *testing.T) { + other := &debug.Module{Path: "github.com/hashicorp/go-plugin", Version: "v1.6.0"} + for _, tc := range []struct { + name string + deps []*debug.Module + want string + }{ + {"released version", []*debug.Module{other, {Path: AgentModule, Version: "v0.5.0"}}, "v0.5.0"}, + {"pseudo-version", []*debug.Module{{Path: AgentModule, Version: "v0.5.1-0.20260101000000-abcdef123456"}}, "v0.5.1-0.20260101000000-abcdef123456"}, + {"replaced", []*debug.Module{{Path: AgentModule, Version: "v0.5.0", Replace: &debug.Module{Path: "../agent"}}}, ""}, + {"devel", []*debug.Module{{Path: AgentModule, Version: "(devel)"}}, ""}, + {"absent", []*debug.Module{other}, ""}, + {"no deps", nil, ""}, + } { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, versionFromInfo(&debug.BuildInfo{Deps: tc.deps})) + }) + } +} + +func TestCacheRereadsAChangedBinary(t *testing.T) { + path := filepath.Join(t.TempDir(), "plugin") + require.NoError(t, os.WriteFile(path, []byte("v1"), 0o755)) + reads := 0 + cache := Cache{read: func(string) (string, error) { + reads++ + return fmt.Sprintf("v0.%d.0", reads), nil + }} + + version, err := cache.Version(path) + require.NoError(t, err) + assert.Equal(t, "v0.1.0", version) + version, err = cache.Version(path) + require.NoError(t, err) + assert.Equal(t, "v0.1.0", version, "an unchanged binary is not read again") + assert.Equal(t, 1, reads) + + require.NoError(t, os.WriteFile(path, []byte("v2 longer"), 0o755)) + version, err = cache.Version(path) + require.NoError(t, err) + assert.Equal(t, "v0.2.0", version, "a new size is read again") + + st, err := os.Stat(path) + require.NoError(t, err) + later := st.ModTime().Add(time.Minute) + require.NoError(t, os.Chtimes(path, later, later)) + version, err = cache.Version(path) + require.NoError(t, err) + assert.Equal(t, "v0.3.0", version, "a new modification time is read again") + assert.Equal(t, 3, reads) +}