Files
ctrld/cmd/cli/provision_boundary_guard_test.go
T

210 lines
8.0 KiB
Go

package cli
import (
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"runtime"
"testing"
)
// packageSourcePath resolves a file in this package's source directory, or
// skips the test when the source is not available. The Windows CI job runs
// pre-built test binaries with no checkout, so neither the working directory
// nor the build-time path recorded by runtime.Caller reaches the source
// there. Skipping is safe for these static checks: they analyze the same
// source on every platform, and the jobs with a checkout still enforce them.
func packageSourcePath(t *testing.T, name string) string {
t.Helper()
if _, err := os.Stat(name); err == nil {
return name
}
if _, thisFile, _, ok := runtime.Caller(0); ok {
p := filepath.Join(filepath.Dir(thisFile), name)
if _, err := os.Stat(p); err == nil {
return p
}
}
t.Skipf("package source for %s not available (pre-built test binary without a checkout); this static check runs where source is present", name)
return ""
}
// provisionBoundaryFiles are the files this lane's provisioning-hardening
// work audited: every terminal path they can reach must report a classified
// failure (a result file, an output line, a stage-scoped exit code), not a
// bare crash installer tooling cannot read. TestProvisioningTerminalPathAudit
// documents the audit by scenario; this test enforces it structurally so a
// later change cannot reintroduce a bare fatal without either classifying it
// or adding it here with a reason.
//
// Scope: the check sees direct calls (".Fatal()", "os.Exit", "panic") in the
// files listed here. A helper defined in one of these files is caught once,
// at the body that holds the call, so an indirect exit through it needs no
// second rule. A helper defined outside the list, such as
// checkHasElevatedPrivilege in service.go wired as the "start" PreRun, is
// outside the reach of this check. TestProvisioningTerminalPathAudit lists
// those by hand in its exclusion comment.
var provisionBoundaryFiles = []string{
"cli.go",
"commands_service_start.go",
"commands_run.go",
"prog.go",
"provision_result.go",
}
// provisionBoundaryFatalAllowlist is every call in the files above that
// still reaches a bare ".Fatal()" (on any logger chain), "os.Exit", or
// "panic", keyed
// by "file.go:FuncName" (FuncName includes the receiver type for methods,
// e.g. "prog.setDNS"). Each entry names why it is not a provisioning
// failure, or why it predates and sits outside the --cd-org boundary this
// lane hardened.
var provisionBoundaryFatalAllowlist = map[string]string{
"cli.go:RunMobile": `panic on a nil AppConfig is a programming error in the mobile host ` +
`app, at an entry point the CLI command tree never calls. Not reachable from ` +
`"ctrld start --cd-org".`,
"cli.go:run": `os.Exit(0) is the successful exit of the daemon-respawn launcher, right ` +
`after it starts the real background process. Not a failure.`,
"cli.go:readConfigFile": `shared CLI config-parsing helper used the same way by every ` +
`invocation mode (config-file, no-config, nextdns, --cd, --cd-org). Predates and is ` +
`orthogonal to the --cd-org provisioning boundary.`,
"cli.go:processNoConfigFlags": `shared CLI helper enforcing --listen/--primary_upstream in ` +
`no-config mode. Applies uniformly across every invocation mode, not specifically to ` +
`provisioning.`,
"cli.go:processListenFlag": `shared CLI helper parsing --listen. Applies uniformly across ` +
`every invocation mode, not specifically to provisioning.`,
"cli.go:readConfigWithNotice": `shared CLI helper (userHomeDir failure while locating the ` +
`default config file). Applies uniformly across every invocation mode, not specifically ` +
`to provisioning.`,
"cli.go:checkStrFlagEmpty": `shared flag-emptiness helper. Only an explicit empty --cd-org ` +
`is a provisioning-token value, classified separately inside this function; every other ` +
`flag (currently --cd) keeps its bare fatal.`,
"commands_service_start.go:ServiceCommand.Start": `the deactivation pin check's ` +
`os.Exit(126). Out of scope for this lane by contract ("pin-check 126 untouched").`,
"prog.go:prog.Stop": `the deactivation pin check's os.Exit(126). Out of scope for this ` +
`lane by contract ("pin-check 126 untouched").`,
"prog.go:prog.setDNS": `the runtime --intercept-mode validation (validInterceptMode), ` +
`distinct from the early "ctrld start" check that already reports ` +
`INTERCEPT_MODE_INVALID. Left alone per T6's own doc comment.`,
}
// funcDeclKey names a function declaration the way
// provisionBoundaryFatalAllowlist keys it: "FuncName" for a plain function,
// "ReceiverType.FuncName" for a method.
func funcDeclKey(fn *ast.FuncDecl) string {
if fn.Recv == nil || len(fn.Recv.List) == 0 {
return fn.Name.Name
}
recvType := fn.Recv.List[0].Type
if star, ok := recvType.(*ast.StarExpr); ok {
recvType = star.X
}
if ident, ok := recvType.(*ast.Ident); ok {
return ident.Name + "." + fn.Name.Name
}
return fn.Name.Name
}
// isBareFatalOrExitCall reports whether call is "<expr>.Fatal(...)" on any
// logger chain, "os.Exit(...)", or a bare "panic(...)".
func isBareFatalOrExitCall(call *ast.CallExpr) bool {
if ident, ok := call.Fun.(*ast.Ident); ok && ident.Name == "panic" {
return true
}
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok {
return false
}
if sel.Sel.Name == "Fatal" {
return true
}
if sel.Sel.Name == "Exit" {
if ident, ok := sel.X.(*ast.Ident); ok && ident.Name == "os" {
return true
}
}
return false
}
// TestProvisionBoundaryHasNoUnlistedBareFatal parses every provisioning
// boundary file and flags a bare Fatal/os.Exit that is not in the allowlist
// above. A new one appearing here means a change added a crash the
// provisioning boundary's contract does not allow: classify it through
// failProvision/failProvisionUnclassified, or add it to the allowlist with a
// reason if it genuinely sits outside the boundary.
func TestProvisionBoundaryHasNoUnlistedBareFatal(t *testing.T) {
fset := token.NewFileSet()
for _, file := range provisionBoundaryFiles {
node, err := parser.ParseFile(fset, packageSourcePath(t, file), nil, 0)
if err != nil {
t.Fatalf("could not parse %s: %v", file, err)
}
for _, decl := range node.Decls {
fn, ok := decl.(*ast.FuncDecl)
if !ok || fn.Body == nil {
continue
}
key := file + ":" + funcDeclKey(fn)
ast.Inspect(fn.Body, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok || !isBareFatalOrExitCall(call) {
return true
}
if _, allowed := provisionBoundaryFatalAllowlist[key]; !allowed {
pos := fset.Position(call.Pos())
t.Errorf("%s:%d: unlisted bare fatal/exit in %s - classify it through "+
"failProvision/failProvisionUnclassified, or add it to "+
"provisionBoundaryFatalAllowlist with a reason", file, pos.Line, key)
}
return true
})
}
}
}
// TestProvisionBoundaryAllowlistHasNoStaleEntries is the converse check: every
// allowlisted key must still name a real bare Fatal/os.Exit. A stale entry
// (the call was removed or reclassified) would silently widen the allowlist
// and hide a real regression the next time this test runs.
func TestProvisionBoundaryAllowlistHasNoStaleEntries(t *testing.T) {
fset := token.NewFileSet()
found := make(map[string]bool, len(provisionBoundaryFatalAllowlist))
for _, file := range provisionBoundaryFiles {
node, err := parser.ParseFile(fset, packageSourcePath(t, file), nil, 0)
if err != nil {
t.Fatalf("could not parse %s: %v", file, err)
}
for _, decl := range node.Decls {
fn, ok := decl.(*ast.FuncDecl)
if !ok || fn.Body == nil {
continue
}
key := file + ":" + funcDeclKey(fn)
ast.Inspect(fn.Body, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok || !isBareFatalOrExitCall(call) {
return true
}
found[key] = true
return true
})
}
}
for key := range provisionBoundaryFatalAllowlist {
if !found[key] {
t.Errorf("provisionBoundaryFatalAllowlist[%q] no longer matches any bare fatal/exit; remove the stale entry", key)
}
}
}