diff --git a/internal/pluginlib/pluginlib.go b/internal/pluginlib/pluginlib.go new file mode 100644 index 0000000..1f9d378 --- /dev/null +++ b/internal/pluginlib/pluginlib.go @@ -0,0 +1,94 @@ +// 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" + "runtime/debug" + "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 + } + 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 "" + } + return dep.Version + } + return "" +} + +// 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 + // read is Version; tests replace it to count reads. + read func(path string) (string, error) +} + +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() + + read := c.read + if read == nil { + read = Version + } + version, err := read(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..5b1b82b --- /dev/null +++ b/internal/pluginlib/pluginlib_test.go @@ -0,0 +1,88 @@ +package pluginlib + +import ( + "fmt" + "os" + "path/filepath" + "runtime/debug" + "testing" + "time" + + "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) +} + +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) +}