diff --git a/free/ci/cmd/agent/prodguard_test.go b/free/ci/cmd/agent/prodguard_test.go index d0ae18cde..df8732752 100644 --- a/free/ci/cmd/agent/prodguard_test.go +++ b/free/ci/cmd/agent/prodguard_test.go @@ -69,6 +69,12 @@ func TestServeStdioExitsNonZeroWithE663OnAProdStackMarker(t *testing.T) { } t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) dir := t.TempDir() + // The marker guard checks for docker on PATH before calling the injected runner. + bin = t.TempDir() + if err := os.WriteFile(filepath.Join(bin, "docker"), []byte("#!/bin/sh\nexit 1\n"), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) if err := os.WriteFile(filepath.Join(dir, ".env"), []byte("ENV=prod\n"), 0o600); err != nil { t.Fatal(err) } diff --git a/free/ci/cmd/approve_pending.go b/free/ci/cmd/approve_pending.go index aff24f345..372b227b5 100644 --- a/free/ci/cmd/approve_pending.go +++ b/free/ci/cmd/approve_pending.go @@ -1,5 +1,6 @@ package main +// List pending approvals after voiding stale approval bindings. import ( "context" "flag" diff --git a/free/ci/internal/dispatch/chokepoint_roles_test.go b/free/ci/internal/dispatch/chokepoint_roles_test.go new file mode 100644 index 000000000..e92636ed9 --- /dev/null +++ b/free/ci/internal/dispatch/chokepoint_roles_test.go @@ -0,0 +1,99 @@ +package dispatch + +import ( + "go/ast" + "go/token" + "go/types" + "path/filepath" + "strings" + "testing" + + "golang.org/x/tools/go/packages" +) + +// A role Grant is unrelated to lease admission. Resolve its object rather than +// allowlisting files or functions, which could also contain a lease bypass. +func isRoleGrant(obj types.Object) bool { + if obj == nil || obj.Name() != "Grant" || obj.Pkg() == nil { + return false + } + const base = "github.com/nself-org/plugins/free/ci/internal/" + switch obj.Pkg().Path() { + case base + "store": + _, ok := obj.(*types.TypeName) + return ok + case base + "roles": + fn, ok := obj.(*types.Func) + if !ok { + return false + } + sig, ok := fn.Type().(*types.Signature) + return ok && sig.Recv() != nil + } + return false +} + +func roleGrantPositions(t *testing.T, root string) map[string]map[token.Pos]bool { + t.Helper() + result := map[string]map[token.Pos]bool{} + // Planted modules deliberately contain unresolved identifiers. They retain + // the conservative syntactic scan, including their lease Grant violations. + if strings.Contains(filepath.ToSlash(root), "testdata/") { + return result + } + pkgs, err := packages.Load(&packages.Config{Dir: root, + Mode: packages.LoadSyntax}, "./...") + if err != nil { + t.Fatal(err) + } + for _, pkg := range pkgs { + if len(pkg.Errors) != 0 { + t.Fatalf("load %s: %v", pkg.PkgPath, pkg.Errors) + } + for _, objects := range []map[*ast.Ident]types.Object{pkg.TypesInfo.Uses, pkg.TypesInfo.Defs} { + for id, obj := range objects { + if !isRoleGrant(obj) { + continue + } + pos := pkg.Fset.Position(id.Pos()) + rel, err := filepath.Rel(root, pos.Filename) + if err != nil { + t.Fatal(err) + } + path := filepath.Clean(filepath.Join(root, rel)) + if result[path] == nil { + result[path] = map[token.Pos]bool{} + } + // scanChokepoint parses each file with a fresh FileSet (base position 1). + result[path][token.Pos(pos.Offset+1)] = true + } + } + } + return result +} + +func TestRoleGrantObjects(t *testing.T) { + const base = "github.com/nself-org/plugins/free/ci/internal/" + for _, tc := range []struct { + name, pkg string + method, want bool + }{ + {"role type", "store", false, true}, + {"role method", "roles", true, true}, + {"lease method", "lease", true, false}, + {"store method", "store", true, false}, + {"unrelated type", "other", false, false}, + } { + t.Run(tc.name, func(t *testing.T) { + pkg := types.NewPackage(base+tc.pkg, tc.pkg) + var obj types.Object = types.NewTypeName(token.NoPos, pkg, "Grant", nil) + if tc.method { + recv := types.NewVar(token.NoPos, pkg, "s", types.Typ[types.Int]) + obj = types.NewFunc(token.NoPos, pkg, "Grant", types.NewSignatureType(recv, nil, nil, nil, nil, false)) + } + if got := isRoleGrant(obj); got != tc.want { + t.Fatalf("isRoleGrant = %v, want %v", got, tc.want) + } + }) + } +} diff --git a/free/ci/internal/dispatch/chokepoint_test.go b/free/ci/internal/dispatch/chokepoint_test.go index 70d164349..643a1b112 100644 --- a/free/ci/internal/dispatch/chokepoint_test.go +++ b/free/ci/internal/dispatch/chokepoint_test.go @@ -87,7 +87,7 @@ func ownerOf(decl ast.Decl) (string, *ast.Ident) { } // scanFile reports the uses in one parsed file that the allowlist does not cover. -func scanFile(f *ast.File, rel string, add func(string)) { +func scanFile(f *ast.File, rel string, grants map[token.Pos]bool, add func(string)) { dir := filepath.ToSlash(filepath.Dir(rel)) usesReflect := false for _, imp := range f.Imports { @@ -108,6 +108,9 @@ func scanFile(f *ast.File, rel string, add func(string)) { switch x := n.(type) { case *ast.SelectorExpr: name := x.Sel.Name + if grants[x.Sel.Pos()] { + return true + } switch { case reflective[name], name == "Method" && usesReflect: report(name) @@ -119,7 +122,7 @@ func scanFile(f *ast.File, rel string, add func(string)) { report(name) } case *ast.Ident: - if banned[x.Name] && x != declName && !(x.Name == "Grant" && roleGrantFiles[rel]) && !(x.Name == "LocalAdmit" && localFiles[rel]) && (x.Name == "LocalAdmit" || !chokeFuncs[key]) { + if banned[x.Name] && !grants[x.Pos()] && x != declName && (x.Name != "LocalAdmit" || !localFiles[rel]) && (x.Name == "LocalAdmit" || !chokeFuncs[key]) { report(x.Name) } case *ast.BasicLit: @@ -136,6 +139,11 @@ func scanFile(f *ast.File, rel string, add func(string)) { // allowlist, and how many files it read. func scanChokepoint(t *testing.T, root string) (violations []string, files int) { t.Helper() + root, absErr := filepath.Abs(root) + if absErr != nil { + t.Fatal(absErr) + } + roleGrants := roleGrantPositions(t, root) seen := map[string]bool{} add := func(v string) { // a selector and its identifier are two nodes if !seen[v] { @@ -163,7 +171,7 @@ func scanChokepoint(t *testing.T, root string) (violations []string, files int) t.Fatalf("parse %s: %v", path, perr) } files++ - scanFile(f, filepath.ToSlash(rel), add) + scanFile(f, filepath.ToSlash(rel), roleGrants[filepath.Clean(path)], add) return nil }) if err != nil { diff --git a/free/ci/internal/gates/node_prepare_test.go b/free/ci/internal/gates/node_prepare_test.go index ba34db591..a63e56c3f 100644 --- a/free/ci/internal/gates/node_prepare_test.go +++ b/free/ci/internal/gates/node_prepare_test.go @@ -103,9 +103,15 @@ type result struct { func (h *nodeHost) run(id, workdir string) result { h.t.Helper() p := h.plan(id, workdir) + // macOS temp roots can include /var -> /private/var. The executor rejects + // symlinked workdirs, so use the real fixture home just as a configured CI home. + home, err := filepath.EvalSymlinks(h.t.TempDir()) + if err != nil { + h.t.Fatal(err) + } var out strings.Builder res := (&ciexec.Executor{Isolation: ciexec.BaselineIsolator{}}).Run(context.Background(), ciexec.JobSpec{AttemptID: "node-" + strings.ReplaceAll(id, ".", "-"), CoordinatorID: "coord", - Secrets: h.secrets, Job: model.Job{Kind: "quality", Workdir: workdir, Isolation: "process", Env: h.env, Secrets: h.refs}, Command: p.Command(), SourceDir: h.root, Home: realTempDir(h.t), + Secrets: h.secrets, Job: model.Job{Kind: "quality", Workdir: workdir, Isolation: "process", Env: h.env, Secrets: h.refs}, Command: p.Command(), SourceDir: h.root, Home: home, Output: func(b []byte) { out.Write(b) }}) r := result{exit: res.ExitCode, out: strings.TrimSpace(out.String())} if res.Err != nil { diff --git a/free/ci/internal/protect/template_test.go b/free/ci/internal/protect/template_test.go index 7005b3b99..131f6ad07 100644 --- a/free/ci/internal/protect/template_test.go +++ b/free/ci/internal/protect/template_test.go @@ -127,9 +127,9 @@ func TestHostedSourceWorkflowTrust(t *testing.T) { file, job, command, runner string calls int }{ - {"../../.github/workflows/nself-ci-source.yml", "nself-ci-source", `"$NSELF_CI_BIN"`, "ubuntu-latest", 1}, +{"../../.github/workflows/nself-ci-source.yml", "nself-ci-source", `"$NSELF_CI_BIN"`, "ubuntu-latest", 1}, {"../../.github/workflows/ci-scenarios.yml", "scenarios", ".drill-bin/nself-ci", "${{ matrix.os }}", 2}, - } { +} { t.Run(tc.job, func(t *testing.T) { d, raw := readWorkflow(t, tc.file) job, ok := d.Jobs[tc.job] diff --git a/free/ci/internal/run/isolation_wait_test.go b/free/ci/internal/run/isolation_wait_test.go index 9980fec1b..f88bf70c1 100644 --- a/free/ci/internal/run/isolation_wait_test.go +++ b/free/ci/internal/run/isolation_wait_test.go @@ -118,7 +118,9 @@ func TestRunWaitingStopsOnCancel(t *testing.T) { rs, spec := waitRun(t, iso) rs.sess.slots.take(slotDir(rs.sess.Home)) // slot 0 is held elsewhere, so the attempt waits on slot 1 ctx, cancel := context.WithCancel(context.Background()) - time.AfterFunc(50*time.Millisecond, cancel) + defer cancel() + // Cancel once the attempt enters its wait, independently of executor setup time. + spec.Output = func([]byte) { cancel() } start := time.Now() res := rs.runWaiting(ctx, spec) if string(res.Attempt.State) != "cancelled" || time.Since(start) > 5*time.Second {