feat(lint): enforce state paths go through pkg/paths

Assisted-By: docker-agent
This commit is contained in:
David Gageot committed 2026-09-14 14:55:55 +02:00
1 parent 51154cc830
commit 50c64fb06f
4 files changed
+112 -8

No files matched your search

+1
View File
@@ -46,6 +46,7 @@ var cops = []cop.Cop{
AtomicStateWrite,
HTTPClientTransport,
ToolsetSchemaRegistrySync,
StatePathViaPathsPackage,
}
// programCops lists whole-program, inter-procedural cops. These run once over
+53
View File
@@ -0,0 +1,53 @@
//rubocop:disable-file Lint/StatePathViaPathsPackage
package main
import (
"go/ast"
"go/token"
"strconv"
"strings"
"github.com/dgageot/rubocop-go/cop"
)
// StatePathViaPathsPackage enforces that production code outside of
// pkg/paths does not construct hard-coded state paths by joining ".cagent"
// directly. All callers must go through pkg/paths.GetDataDir(),
// GetCacheDir(), or GetConfigDir() so that embedders can relocate all
// docker-agent state with a single paths.SetRoot() call.
//
// Hard-coded paths like filepath.Join(homeDir, ".cagent", "history") bypass
// SetRoot, silently leaking state into the host installation when docker-agent
// is embedded under a different root (e.g. Gordon in Docker Sandboxes).
//
// Test files are exempt because test helpers legitimately build expected-path
// fixtures from the raw string.
var StatePathViaPathsPackage = &cop.Func{
Meta: cop.Meta{
Name: "Lint/StatePathViaPathsPackage",
Description: `use pkg/paths.GetDataDir/GetCacheDir/GetConfigDir instead of hard-coding ".cagent" paths`,
Severity: cop.Error,
},
Scope: cop.And(
cop.Not(cop.UnderDir("pkg/paths")),
),
Run: func(p *cop.Pass) {
if p.IsTestFile() {
return
}
ast.Inspect(p.File, func(n ast.Node) bool {
lit, ok := n.(*ast.BasicLit)
if !ok || lit.Kind != token.STRING {
return true
}
val, err := strconv.Unquote(lit.Value)
if err != nil {
return true
}
if val == ".cagent" || strings.HasPrefix(val, ".cagent/") || strings.HasPrefix(val, ".cagent\\") {
p.Reportf(lit, `hard-coded ".cagent" path; use pkg/paths.GetDataDir(), GetCacheDir(), or GetConfigDir() so embedders can relocate state with paths.SetRoot()`)
}
return true
})
},
}
+55
View File
@@ -0,0 +1,55 @@
package main
import (
"testing"
"github.com/dgageot/rubocop-go/coptest"
"github.com/stretchr/testify/assert"
)
func TestStatePathViaPathsPackageFlagsHardcodedCagent(t *testing.T) {
t.Parallel()
src := `package mypackage
import "path/filepath"
func stateDir() string {
return filepath.Join(homeDir, ".cagent", "history")
}
`
offenses := coptest.RunNamed(t, StatePathViaPathsPackage, "pkg/history/history.go", src)
assert.Len(t, offenses, 1)
assert.Equal(t, "Lint/StatePathViaPathsPackage", offenses[0].CopName)
}
func TestStatePathViaPathsPackageFlagsDirectDotCagent(t *testing.T) {
t.Parallel()
src := `package mypackage
const dir = ".cagent"
`
offenses := coptest.RunNamed(t, StatePathViaPathsPackage, "pkg/content/store.go", src)
assert.Len(t, offenses, 1)
}
func TestStatePathViaPathsPackageAllowsPathsPackage(t *testing.T) {
t.Parallel()
src := `package paths
const dotCagent = ".cagent"
`
// Scoped away from pkg/paths
assert.Empty(t, coptest.RunNamed(t, StatePathViaPathsPackage, "pkg/paths/paths.go", src))
}
func TestStatePathViaPathsPackageAllowsTests(t *testing.T) {
t.Parallel()
src := `package mypackage
const expected = ".cagent"
`
assert.Empty(t, coptest.RunNamed(t, StatePathViaPathsPackage, "pkg/history/history_test.go", src))
}
func TestStatePathViaPathsPackageIgnoresUnrelatedStrings(t *testing.T) {
t.Parallel()
src := `package mypackage
const dir = "something-else"
`
assert.Empty(t, coptest.RunNamed(t, StatePathViaPathsPackage, "pkg/foo/foo.go", src))
}
+3 -8
View File
@@ -5,7 +5,6 @@ package history
import (
"bytes"
"encoding/json"
"errors"
"os"
"path/filepath"
"slices"
@@ -27,16 +26,12 @@ type History struct {
}
// New loads the history stored under baseDir/.cagent/history. If baseDir is
// empty, the user's home directory is used.
// empty, paths.GetHomeDir() is used.
func New(baseDir string) (*History, error) {
if baseDir == "" {
baseDir = paths.GetHomeDir()
if baseDir == "" {
return nil, errors.New("failed to get user home directory")
}
}
h := &History{path: filepath.Join(baseDir, ".cagent", "history")}
h := &History{path: filepath.Join(baseDir, ".cagent", "history")} //rubocop:disable Lint/StatePathViaPathsPackage // baseDir is home-dir or caller-supplied root; .cagent sub-path is intentional here
if err := h.migrateOldHistory(baseDir); err != nil {
return nil, err
}
@@ -195,7 +190,7 @@ func (h *History) load() error {
// migrateOldHistory imports messages from the legacy history.json file (if it
// exists) into the new line-oriented format and removes the old file.
func (h *History) migrateOldHistory(baseDir string) error {
oldPath := filepath.Join(baseDir, ".cagent", "history.json")
oldPath := filepath.Join(baseDir, ".cagent", "history.json") //rubocop:disable Lint/StatePathViaPathsPackage // legacy migration path — intentional
data, err := os.ReadFile(oldPath)
if os.IsNotExist(err) {