diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..af3616c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,71 @@ +name: CI + +on: + push: + branches: [main, dev] + pull_request: + branches: [main, dev] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + go: + name: Go ${{ matrix.go }} on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + strategy: + fail-fast: true + matrix: + os: [ubuntu-latest, windows-latest] + go: ['1.27.x'] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version: ${{ matrix.go }} + cache: true + cache-dependency-path: go.sum + - run: go test ./... -count=1 + - run: go vet ./... + - run: go build ./cmd/contract-trace + + lint: + name: Pre-commit and configuration checks + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.12' + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version: '1.27.x' + - run: python -m pip install -r requirements-dev.txt + - run: pre-commit run --all-files --hook-stage pre-commit + + hook-integration: + name: Hook helpers on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + timeout-minutes: 10 + strategy: + fail-fast: true + matrix: + os: [ubuntu-latest, windows-latest] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.12' + - run: python -m unittest discover -s tools -p 'test_*.py' diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..1a3f930 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,74 @@ +fail_fast: true +minimum_pre_commit_version: '4.6.2' +default_install_hook_types: [pre-commit] +default_stages: [pre-commit] + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: 3e8a8703264a2f4a69428a0aa4dcb512790b2c8c + hooks: + - id: check-yaml + stages: [pre-commit] + - id: check-json + stages: [pre-commit] + - id: trailing-whitespace + stages: [pre-commit] + - id: end-of-file-fixer + stages: [pre-commit] + - id: check-merge-conflict + stages: [pre-commit] + - id: detect-private-key + stages: [pre-commit] + + - repo: https://github.com/rhysd/actionlint + rev: 914e7df21a07ef503a81201c76d2b11c789d3fca # v1.7.12 + hooks: + - id: actionlint + language_version: system + stages: [pre-commit] + + - repo: local + hooks: + - id: gofmt-check + name: Check Go formatting + entry: python tools/check_gofmt.py + language: system + types: [go] + exclude: ^testdata/ + pass_filenames: true + stages: [pre-commit] + - id: check-push-state + name: Verify pushed revision and clean worktree + entry: python tools/check_push_state.py + language: system + pass_filenames: false + always_run: true + stages: [pre-push] + - id: validate-hook-config + name: Validate hook configuration + entry: pre-commit validate-config + language: system + pass_filenames: false + always_run: true + stages: [pre-push] + - id: go-vet + name: Go vet + entry: go vet ./... + language: system + pass_filenames: false + always_run: true + stages: [pre-push] + - id: go-test + name: Go tests + entry: go test ./... -count=1 + language: system + pass_filenames: false + always_run: true + stages: [pre-push] + - id: go-build + name: Build CLI + entry: go build ./cmd/contract-trace + language: system + pass_filenames: false + always_run: true + stages: [pre-push] diff --git a/README.md b/README.md index 17f0def..faed650 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,100 @@ # Contract Tracer -A Go tool for discovering source-backed investigation scope across calls, values, storage, events, and lifecycle relationships. +Contract Tracer is a standalone Go library and command-line tool for discovering a bounded, evidence-backed investigation scope from source symbols or locations and an invariant hypothesis. It reports modeled calls, references, value flow, storage, event and lifecycle relationships together with candidate alternatives, coverage limits and unresolved boundaries. It does not prove a runtime invariant or guarantee complete caller recall. -The current source, documentation, CI, and contributor hooks are on the [dev branch](https://github.com/dominicnunez/contract-tracer/tree/dev). +## Requirements + +- Go 1.27.0 or newer, as selected by the module's `go` directive. +- Dependencies declared in `go.mod`; Go package loading may resolve target-module dependencies using the normal Go toolchain. + +The target is analyzed, not executed as an application. + +## Install + +Install the command-line tool with Go 1.27 or newer: + +```sh +go install github.com/dominicnunez/contract-tracer/cmd/contract-trace@latest +``` + +The library module is `github.com/dominicnunez/contract-tracer` and its Go package name is `contracttrace`. + +## Build and test + +```sh +go test ./... +go build -trimpath -buildvcs=false -o contract-trace ./cmd/contract-trace +``` + +On Windows, use `-o contract-trace.exe`. For byte-identical builds in the same environment, use the same Go toolchain and build flags. This command was verified with native Go 1.27.1 from two independent fresh source copies using shared Go caches; it is not a cold-cache, cross-toolchain or clean-machine reproducibility guarantee. + +## Run an investigation + +```sh +./contract-trace \ + -root ./testdata/sample \ + -seed Validate \ + -invariant 'all accepted values are validated' \ + -depth 3 -max-nodes 100 \ + -output scope.json +``` + +On Windows, run `.\contract-trace.exe` from PowerShell. Seeds may be function names, method names, fully qualified function IDs, or discovered resource IDs. `-location file.go:line` seeds an indexed function, global or field declaration. Multiple seeds or locations are comma-separated. Use `-focus` to name symbols or resources that belong to the hypothesis; focus explains graph paths and does not remove structural scope. + +Important options include `-tests`, `-tags`, `-expand-callbacks`, `-timeout`, `-format json|markdown`, `-save-analysis file.json.gz`, and `-resume file.json.gz`. The default traversal limits are depth 3 and 250 nodes. Reports retain truncation, frontiers, candidate inventories and unresolved boundaries when analysis is bounded. Saved exploration reuses the discovered graph after checking source and build identities, including the selected Go source-file set and root-local embedded asset matches in selected target packages under the saved tests, tags and Go environment; it does not rerun analysis to recover omitted relationships. Saved analyses created before loaded-source inventory was recorded must be regenerated before exploration. + +On Windows, `-output` and `-save-analysis` destinations must use ordinary file names without a trailing dot, trailing space or colon. These forms can alias another file or select an alternate data stream. + +An explicit `-config` file must not be replaced by `-save-analysis` or `-output`, including report-only runs. A report-only `-output` cannot replace an existing analyzed input, including root Go and SQL files, `go.mod` and `go.sum`, captured embedded assets, loaded Go sources, or module-resolution inputs. It may create a new source-shaped path such as `report.go`. When saving or resuming an analysis, the snapshot destination and any paired `-output` destination must not replace a captured input or add a fingerprinted source file under the analyzed root. JSON or gzip snapshots and reports may be placed inside the root unless their paths alias captured inputs. The check uses the filesystem state at publication time and assumes the analyzed files and directory layout remain stable while the command runs; it is not a concurrent filesystem-write lock. + +## Configuration + +`-config config.json` accepts one strict JSON object. Unknown fields, invalid selectors and unsupported rule kinds are errors. Omitted fields keep defaults; explicitly setting an array to `[]` disables that default list. Call and lifecycle symbols use `::`. A storage scope's `database_origins` may also use `::(package init)` for a database constructor declared in a package-level variable initializer. + +Each `-root` selects one Go module tree. SQL inventory stops at a nested directory containing another `go.mod`, even if a configured `sql_files` glob matches files below it. Analyze a nested module separately by selecting its directory as `-root`. + +Configured call rules describe typed SQL or event API signatures. Argument indexes exclude a method receiver; `handler_argument` is valid for `event_subscribe`. + +```json +{ + "call_rules": [ + {"symbol":"example.com/service::Publish","kind":"event_publish","argument":1,"namespace":"orders"}, + {"symbol":"example.com/service::Subscribe","kind":"event_subscribe","argument":0,"handler_argument":1,"namespace":"orders"}, + {"symbol":"example.com/store::Query","kind":"sql_query","argument":0,"namespace":"orders"} + ], + "storage_scopes": [ + {"namespace":"orders","go_files":["internal/orders/**/*.go"],"sql_files":["migrations/orders/**/*.sql"]} + ], + "lifecycle_rules": [ + {"symbol":"example.com/service::Acquire","role":"acquire","namespace":"worker","identity":"origin","result":0}, + {"symbol":"example.com/service::Release","role":"release","namespace":"worker","identity":"origin","argument":0} + ] +} +``` + +Lifecycle rules map one typed selector (`argument`, `result`, or `receiver`) to a role and namespace. `identity` is `origin` for modeled handle candidates or `value` for string keys. These mappings describe configured structural API roles; they do not prove success, ownership or ordering. `profiles/go-core.json` is an example that disables default event-field, lifecycle-name and SQL-method heuristics. + +## Output and limits + +JSON reports include nodes, source evidence, relationships, coverage, boundaries and `contract_complete: false`. Markdown is a summary. Saved analysis can be written as JSON or gzip JSON using a `.gz` suffix; resume validates the saved graph and source/build inputs before exploring it with new seeds, locations, focus or budgets. Build identity includes Go architecture feature settings, `GOEXPERIMENT` and `GOFIPS140`; snapshots missing those settings or reopened under a different setting must be regenerated. + +Markdown treats report values as literal text. It escapes structural punctuation and shows line breaks and control characters visibly; use JSON when exact text or complete evidence is needed. + +The analyzer uses typed Go syntax/SSA and bounded value-flow models. Interface and callback targets may be possible candidates. Reflection, unsafe/cgo, unavailable dependency bodies and unselected build configurations are not fully modeled. Candidate flow is context-insensitive and bounded; it does not establish runtime object identity, invocation pairing, path feasibility, SQL transaction success, event delivery, cleanup success, goroutine scheduling or eventual termination. SQLite storage analysis is built in; other storage systems require configured adapters and their semantics remain unresolved. + +SQL file symlinks are not supported. A `.sql` symlink found during source fingerprinting or SQL discovery is rejected, even when a SQL file selector would otherwise exclude it or the file is under a nested module. Source fingerprinting remains conservative across the selected directory tree, excluding `.git`, `vendor` and `.gograph`, and includes descendant Go files, SQL files and `go.mod`/`go.sum` manifests. Changes to nested-module files can therefore invalidate a saved analysis even though nested SQL is outside that root's SQL inventory. Go sources loaded from workspaces or replacements and their module inputs are also checked through separate source and resolution identities. File reads used for fingerprinting and SQL inventory are rooted under the analysis directory; this does not sandbox Go package loading or the Go toolchain. + +## Contributing + +Install Go and Python 3.12 or newer, then install the pinned development hook runner and enable the commit and push checks: + +```sh +python -m pip install -r requirements-dev.txt +python tools/install_hooks.py +``` + +The installer keeps an existing pre-commit-managed commit hook, installs the standard commit checks, and installs a raw Git pre-push hook. It refuses to replace unrecognized hooks; move or explicitly chain a custom hook before installing these checks. Run the installer again after changing the Python interpreter used by the repository. + +Commit checks validate YAML/JSON, whitespace, merge markers and private-key markers, lint GitHub Actions workflows, and verify that staged production Go files already pass `gofmt`. Before running push checks, the raw hook examines every Git ref update. Every non-deletion update must resolve to the checked-out `HEAD` commit, and the worktree must be clean. This permits annotated or lightweight tags that resolve to `HEAD`; tags pointing elsewhere and refs that do not resolve to a commit are rejected. Deletion-only pushes skip validation. Once all updates pass these checks, the hook runs `go vet ./...`, `go test ./... -count=1`, and `go build ./cmd/contract-trace` once. CI runs the Go checks on Linux and Windows with Go 1.27, and runs the repository-wide pre-commit checks and hook-helper tests. + +Use [Conventional Commits 1.0.0](https://www.conventionalcommits.org/en/v1.0.0/) for commit messages and squash titles, such as `fix(storage): preserve rollback edges`. Mark a breaking change with `!` after the type or scope (for example, `feat(api)!: change the config format`) or a `BREAKING CHANGE:` footer. diff --git a/adapters.go b/adapters.go new file mode 100644 index 0000000..0f42963 --- /dev/null +++ b/adapters.go @@ -0,0 +1,354 @@ +package contracttrace + +import ( + "fmt" + "go/ast" + "go/types" + "strings" + "unicode" + + "golang.org/x/tools/go/ssa" +) + +func validateConfig(c Config) error { + for _, rule := range c.LifecycleRules { + if !validQualifiedSymbol(rule.Symbol) { + return fmt.Errorf("lifecycle rule requires a fully qualified symbol: %q", rule.Symbol) + } + if rule.Namespace == "" || strings.TrimSpace(rule.Namespace) != rule.Namespace { + return fmt.Errorf("lifecycle rule namespace must be nonempty and have no surrounding whitespace") + } + switch rule.Role { + case "acquire", "release", "observe", "transfer", "recover", "shutdown": + default: + return fmt.Errorf("unsupported lifecycle role %q", rule.Role) + } + if rule.Identity != "value" && rule.Identity != "origin" { + return fmt.Errorf("lifecycle identity must be value or origin") + } + selectors := 0 + for _, index := range []*int{rule.Argument, rule.Result} { + if index != nil { + selectors++ + if *index < 0 { + return fmt.Errorf("lifecycle selector indexes must be nonnegative") + } + } + } + if rule.Receiver { + selectors++ + } + if selectors != 1 { + return fmt.Errorf("lifecycle rule requires exactly one argument, result or receiver selector") + } + } + originNamespaces := map[string]string{} + if c.SQLDialect != "sqlite" && c.SQLDialect != "inventory" { + return fmt.Errorf("unsupported SQL dialect %q; use sqlite or explicit inventory mode", c.SQLDialect) + } + for _, pattern := range c.SQLFiles { + if err := validateFileGlob(pattern); err != nil { + return err + } + } + for _, scope := range c.StorageScopes { + if strings.TrimSpace(scope.Namespace) == "" || strings.TrimSpace(scope.Namespace) != scope.Namespace { + return fmt.Errorf("storage namespace must be nonempty and have no surrounding whitespace") + } + if len(scope.GoFiles)+len(scope.SQLFiles)+len(scope.DatabaseOrigins) == 0 { + return fmt.Errorf("storage scope %q needs file or database-origin selectors", scope.Namespace) + } + for _, patterns := range [][]string{scope.GoFiles, scope.SQLFiles} { + for _, pattern := range patterns { + if err := validateFileGlob(pattern); err != nil { + return err + } + } + } + for _, origin := range scope.DatabaseOrigins { + if !validDatabaseOriginSymbol(origin) { + return fmt.Errorf("database origin requires a fully qualified function or package initializer owner: %q", origin) + } + if previous := originNamespaces[origin]; previous != "" && previous != scope.Namespace { + return fmt.Errorf("ambiguous database origin %s: %s and %s", origin, previous, scope.Namespace) + } + originNamespaces[origin] = scope.Namespace + } + } + for _, list := range [][]string{c.EventFields, c.LifecycleNames, c.SQLMethods} { + for _, name := range list { + if strings.TrimSpace(name) == "" { + return fmt.Errorf("configuration names must not be empty") + } + if strings.TrimSpace(name) != name { + return fmt.Errorf("configuration names must not have surrounding whitespace") + } + if strings.IndexFunc(name, unicode.IsSpace) >= 0 { + return fmt.Errorf("configuration names must not contain whitespace") + } + } + } + for _, r := range c.CallRules { + if strings.TrimSpace(r.Namespace) != r.Namespace { + return fmt.Errorf("call-rule namespace must have no surrounding whitespace") + } + if !validQualifiedSymbol(r.Symbol) { + return fmt.Errorf("call rule requires a fully qualified symbol: %q", r.Symbol) + } + if r.Argument < 0 || r.HandlerArgument != nil && *r.HandlerArgument < 0 { + return fmt.Errorf("call-rule argument indexes must be nonnegative") + } + switch r.Kind { + case "sql_query", "event_publish", "event_subscribe", "event_dispatch": + default: + return fmt.Errorf("unsupported call-rule kind %q", r.Kind) + } + if r.HandlerArgument != nil && r.Kind != "event_subscribe" { + return fmt.Errorf("handler_argument is only valid for event_subscribe") + } + } + return nil +} + +func validQualifiedSymbol(symbol string) bool { + if strings.IndexFunc(symbol, unicode.IsSpace) >= 0 { + return false + } + parts := strings.Split(symbol, "::") + return len(parts) == 2 && parts[0] != "" && parts[1] != "" +} + +func validDatabaseOriginSymbol(symbol string) bool { + const packageInitializer = "(package init)" + if !strings.HasSuffix(symbol, "::"+packageInitializer) { + return validQualifiedSymbol(symbol) + } + return validQualifiedSymbol(strings.TrimSuffix(symbol, packageInitializer) + "init") +} + +func callObject(expr ast.Expr, f *function) *types.Func { + switch v := expr.(type) { + case *ast.Ident: + obj, _ := f.pkg.TypesInfo.Uses[v].(*types.Func) + return obj + case *ast.SelectorExpr: + obj, _ := f.pkg.TypesInfo.Uses[v.Sel].(*types.Func) + return obj + case *ast.IndexExpr: + return callObject(v.X, f) + case *ast.IndexListExpr: + return callObject(v.X, f) + case *ast.ParenExpr: + return callObject(v.X, f) + } + return nil +} +func (ix *index) applyCallRules(id string, f *function, call *ast.CallExpr, a assignments, config Config, matched map[string]bool) (bool, error) { + if len(config.CallRules) == 0 { + return false, nil + } + handledSQL := false + objects := map[*types.Func]bool{} + if obj := callObject(call.Fun, f); obj != nil { + objects[obj.Origin()] = true + } + callable := flowValue{} + if ix.flow != nil { + callable = ix.flow.callTargets[call.Lparen] + ix.flow.merge(&callable, ix.flow.positions[call.Fun.Pos()]) + for candidate := range callable.functions { + if candidate != nil { + if obj, ok := candidate.Object().(*types.Func); ok && obj.Pkg() != nil { + objects[obj.Origin()] = true + } + } + } + } + if len(objects) == 0 { + return false, nil + } + for _, r := range config.CallRules { + var matchedObject *types.Func + matchedTargets := map[*ssa.Function]bool{} + for obj := range objects { + if obj.Pkg() != nil && r.Symbol == obj.Pkg().Path()+"::"+functionName(obj) { + matchedObject = obj + break + } + } + if matchedObject == nil { + continue + } + for _, target := range sortedFunctions(callable.functions) { + if obj, ok := target.Object().(*types.Func); ok && obj.Pkg() != nil && r.Symbol == obj.Pkg().Path()+"::"+functionName(obj.Origin()) { + matchedTargets[target] = true + } + } + matched[r.Symbol] = true + if r.Kind == "sql_query" { + handledSQL = true + } + argumentOffset := callRuleReceiverOffset(call, f, matchedObject) + argumentIndex, validArgument := callRuleArgumentIndex(r.Argument, argumentOffset, len(call.Args)) + handlerIndex, validHandler := 0, true + if r.HandlerArgument != nil { + handlerIndex, validHandler = callRuleArgumentIndex(*r.HandlerArgument, argumentOffset, len(call.Args)) + if !validHandler { + ix.boundaries = append(ix.boundaries, Boundary{Node: id, Kind: "invalid_rule_site", Reason: "configured handler argument does not exist for " + r.Symbol, Evidence: ix.evidence(call.Pos())}) + } + } + if !validArgument { + ix.boundaries = append(ix.boundaries, Boundary{Node: id, Kind: "invalid_rule_site", Reason: "configured argument does not exist for " + r.Symbol, Evidence: ix.evidence(call.Pos())}) + continue + } + namespace := r.Namespace + namespaces := []string{namespace} + if r.Kind == "sql_query" { + var err error + receivers, unknown := ix.callRuleSQLReceivers(f, call, matchedObject, callable, matchedTargets, argumentOffset) + namespaces, err = ix.receiverNamespaces(id, ix.evidence(call.Pos()), receivers, unknown, r.Namespace, config) + if err != nil { + return handledSQL, err + } + } + values, complete := ix.stringCandidates(call.Args[argumentIndex], f, a) + if !complete { + ix.boundaries = append(ix.boundaries, Boundary{Node: id, Kind: "dynamic_api_value", Reason: "configured API value uses flow candidates; additional runtime values may exist", Evidence: ix.evidence(call.Args[argumentIndex].Pos())}) + } + for _, value := range values { + if r.Kind == "sql_query" { + for _, found := range ix.sqlAccesses(id, value.text, ix.evidence(call.Pos())) { + if tableHasHole(found.access.table, value) { + continue + } + access := found.access + for _, namespace := range namespaces { + ix.sqlTableAccess(id, resourceID("table", namespace, access.table), access.table, access.role, call) + } + } + continue + } + if len(value.holes) > 0 { + continue + } + key := resourceID("event", r.Namespace, value.text) + ix.resource(id, key, value.text, "event", r.Kind, call.Pos()) + if r.HandlerArgument != nil { + if !validHandler { + continue + } + expression := call.Args[handlerIndex] + targets := map[string]bool{} + if handler := callObject(expression, f); handler != nil { + if target := ix.objects[handler.Origin()]; target != "" { + targets[target] = true + } + } + unknownHandler, nilHandler, outsideHandler := false, false, false + if ix.flow != nil { + flow := ix.flow.positions[expression.Pos()] + unknownHandler = flow.functionUnknown || flow.functionNil || flow.interfaceUnknown + nilHandler = flow.functionNil + outsideHandler = flow.functionUnknown || flow.interfaceUnknown + for fn := range flow.functions { + if target := ix.owner(fn); target != "" { + targets[target] = true + } + } + } + if len(targets) == 0 || unknownHandler { + reason := "subscription handler target was not resolved" + if len(targets) > 0 && unknownHandler { + reason = "known subscription handler candidates coexist with an unresolved outside or nil handler alternative" + if nilHandler && outsideHandler { + reason = "known subscription handler candidates coexist with typed nil and outside handler alternatives; no successful handler invocation is inferred" + } else if nilHandler { + reason = "known subscription handler candidates coexist with a typed nil handler alternative; no successful handler invocation is inferred" + } + } else if nilHandler { + reason = "subscription handler has a typed nil callback candidate; no successful handler invocation is inferred" + } + ix.boundaries = append(ix.boundaries, Boundary{Node: id, Kind: "unresolved_handler", Reason: reason, Evidence: ix.evidence(expression.Pos())}) + } + for target := range targets { + ix.edge(target, key, "event_handler", "possible", expression.Pos()) + } + } + } + } + return handledSQL, nil +} + +func callRuleArgumentIndex(selector, receiverOffset, argumentCount int) (int, bool) { + if selector < 0 || receiverOffset < 0 || receiverOffset > argumentCount || selector >= argumentCount-receiverOffset { + return 0, false + } + return selector + receiverOffset, true +} + +// A method expression includes its receiver in the function value signature and +// invocation arguments. Method values and SSA bound-method wrappers do not. +func callRuleReceiverOffset(call *ast.CallExpr, f *function, method *types.Func) int { + if method == nil || method.Type() == nil { + return 0 + } + methodSig, ok := method.Type().(*types.Signature) + if !ok || methodSig.Recv() == nil { + return 0 + } + callType := f.pkg.TypesInfo.TypeOf(call.Fun) + if callType == nil { + return 0 + } + callSig, ok := callType.Underlying().(*types.Signature) + if !ok { + return 0 + } + if callSig.Params().Len() == methodSig.Params().Len()+1 { + return 1 + } + return 0 +} + +func (ix *index) callRuleSQLReceivers(f *function, call *ast.CallExpr, method *types.Func, callable flowValue, targets map[*ssa.Function]bool, offset int) ([]string, bool) { + if ix.flow == nil { + return nil, true + } + combined := emptyFlow() + if modeled := ix.flow.sqlReceivers[call.Lparen]; len(modeled.addresses) != 0 || modeled.sqlUnknown { + ix.flow.merge(&combined, modeled) + } + unknown := callable.functionUnknown || callable.functionNil || callable.interfaceUnknown || callable.boundReceiverUnknown + for _, target := range sortedFunctions(callable.functions) { + if !targets[target] { + continue + } + if receiver, ok := callable.boundReceivers[target]; ok { + for _, address := range sortedKeys(receiver.addresses) { + if len(combined.addresses) >= maxFlowValues && !combined.addresses[address] { + unknown = true + ix.flow.coverage.Widened = true + continue + } + combined.addresses[address] = true + } + unknown = unknown || receiver.unknown + } + } + if offset > 0 && offset-1 < len(call.Args) { + receiverExpr := call.Args[offset-1] + receiver := ix.flow.positions[receiverExpr.Pos()] + receiverType := f.pkg.TypesInfo.TypeOf(receiverExpr) + projected, _, status := ix.flow.projectMethodExpressionReceiver(receiver, receiverType, method) + ix.flow.merge(&combined, projected) + if status == methodReceiverProjectionUnsupported { + // A method-expression receiver must be evaluated through its selected + // method-set path. Keep this unresolved when metadata cannot establish + // that path; treating the outer wrapper as the database would invent a + // namespace candidate. + unknown = true + } + } + unknown = unknown || combined.sqlUnknown || combined.interfaceUnknown || combined.boundReceiverUnknown || len(combined.addresses) == 0 + return sortedKeys(combined.addresses), unknown +} diff --git a/afterfunc.go b/afterfunc.go new file mode 100644 index 0000000..c1d6950 --- /dev/null +++ b/afterfunc.go @@ -0,0 +1,151 @@ +package contracttrace + +import ( + "fmt" + "strings" + + "golang.org/x/tools/go/ssa" +) + +type afterFuncSite struct { + call ssa.CallInstruction + context, callback flowValue +} + +func (a *flowAnalysis) modelAfterFunc(call ssa.CallInstruction, ix *index) bool { + common := call.Common() + if common.IsInvoke() || len(common.Args) != 2 { + return false + } + modeled, outside := false, a.get(common.Value).functionUnknown + for target := range a.targets(common) { + if isAfterFunc(target) { + modeled = true + } else if ix.owner(target) == "" || len(target.Blocks) == 0 { + outside = true + } + } + if !modeled { + return false + } + evidence := ix.callEvidence(call) + key := resourceID("afterfunc", "", fmt.Sprintf("%s:%d:%d", evidence.File, evidence.Line, evidence.Column)) + if a.afterFuncs == nil { + a.afterFuncs = map[string]afterFuncSite{} + } + site := a.afterFuncs[key] + if site.call == nil { + site.call = call + } + changed := a.merge(&site.context, a.get(common.Args[0])) + changed = a.merge(&site.callback, a.get(common.Args[1])) || changed + a.afterFuncs[key] = site + if result, ok := call.(*ssa.Call); ok { + if a.special == nil { + a.special = map[*ssa.Call][]flowValue{} + } + stop := emptyFlow() + stop.effects["afterfunc_stop:"+key] = true + stop.functionUnknown = outside + a.special[result] = []flowValue{stop} + } + if ix.funcs[key] == nil { + ix.funcs[key] = &function{node: Node{ID: key, Name: "AfterFunc at " + evidence.File + fmt.Sprintf(":%d:%d", evidence.Line, evidence.Column), Kind: "callback_registration", Evidence: evidence}} + } + return changed +} + +func isAfterFunc(target *ssa.Function) bool { + return target != nil && target.Object() != nil && target.Object().Pkg() != nil && target.Object().Pkg().Path() == "context" && target.Object().Name() == "AfterFunc" && target.Signature.Recv() == nil +} + +func afterFuncKind(call ssa.CallInstruction, kind string) string { + switch call.(type) { + case *ssa.Defer: + return "deferred_" + kind + case *ssa.Go: + return "goroutine_" + kind + } + return kind +} + +func (a *flowAnalysis) afterFuncStopUse(call ssa.CallInstruction, from string, ix *index) { + for _, effect := range sortedKeys(a.get(call.Common().Value).effects) { + if strings.HasPrefix(effect, "afterfunc_dispatch:") { + key := strings.TrimPrefix(effect, "afterfunc_dispatch:") + if _, modeled := a.afterFuncs[key]; modeled { + ix.edges = append(ix.edges, Relationship{From: from, To: key, Kind: afterFuncKind(call, "cancellation_scheduler_dispatch"), Certainty: "possible", Evidence: ix.callEvidence(call)}) + } + } + if !strings.HasPrefix(effect, "afterfunc_stop:") { + continue + } + key := strings.TrimPrefix(effect, "afterfunc_stop:") + if _, modeled := a.afterFuncs[key]; !modeled { + continue + } + ix.edges = append(ix.edges, Relationship{From: from, To: key, Kind: afterFuncKind(call, "cancellation_callback_stop"), Certainty: "possible", Evidence: ix.callEvidence(call)}) + } +} + +func (a *flowAnalysis) afterFuncRelationships(ix *index) { + for _, key := range sortedKeys(a.afterFuncs) { + site := a.afterFuncs[key] + evidence := ix.callEvidence(site.call) + certainty := "fact" + if _, direct := site.call.(*ssa.Call); !direct || site.call.Common().StaticCallee() == nil { + certainty = "possible" + } + ix.edges = append(ix.edges, Relationship{From: ix.owner(site.call.Parent()), To: key, Kind: afterFuncKind(site.call, "cancellation_callback_register"), Certainty: certainty, Evidence: evidence}) + ix.boundaries = append(ix.boundaries, Boundary{Node: key, Kind: "cancellation_callback_model", Reason: "source registration links a context, callback candidates and returned stop handle; runtime registration count, custom Context scheduling, cancellation/stop races, stop result, callback execution and completion ordering are not proved; calling stop does not establish callback completion", Evidence: evidence}) + a.afterFuncSchedulerRelationships(key, site, ix) + contextValue := site.context + known := false + for _, context := range sortedKeys(contextValue.addresses) { + creation, found := a.contextKeys[context] + if !found { + continue + } + known = true + ix.edges = append(ix.edges, Relationship{From: key, To: context, Kind: "cancellation_callback_context", Certainty: "possible", Evidence: evidence}) + if contextHasName(creation, "Background") || contextHasName(creation, "TODO") || contextHasName(creation, "WithoutCancel") { + ix.boundaries = append(ix.boundaries, Boundary{Node: key, Kind: "noncanceling_callback_context", Reason: "registration has a known context candidate with no cancellation signal; this candidate does not inherit parent cancellation, while other aliases or runtime values may remain possible", Evidence: evidence}) + } + } + if !known || contextValue.interfaceUnknown { + ix.boundaries = append(ix.boundaries, Boundary{Node: key, Kind: "unresolved_callback_context", Reason: "cancellation registration retains an outside or unmodeled context candidate; known local creation sites do not close its runtime origin set", Evidence: evidence}) + } + callback := site.callback + known = false + for _, effect := range sortedKeys(callback.effects) { + if strings.HasPrefix(effect, "cancel:") { + context := strings.TrimPrefix(effect, "cancel:") + if _, modeled := a.contextKeys[context]; modeled { + known = true + ix.edges = append(ix.edges, Relationship{From: key, To: context, Kind: "cancellation_callback_cancel", Certainty: "possible", Evidence: evidence}) + } + } + } + for _, target := range sortedFunctions(callback.functions) { + owner := ix.owner(target) + if owner == "" { + continue + } + known = true + ix.edges = append(ix.edges, Relationship{From: key, To: owner, Kind: "cancellation_callback_target", Certainty: "possible", Evidence: evidence}) + } + if !known || callback.functionUnknown || callback.functionNil { + reason := "registration retains an outside or unidentified callback candidate alongside any known local targets; nil safety and invocation are not established" + if callback.functionNil && known && callback.functionUnknown { + reason = "registration retains known callback candidates alongside typed nil and outside callback alternatives; callback invocation is not established" + } else if callback.functionNil && known { + reason = "registration retains known callback candidates alongside a typed nil callback alternative; callback invocation is not established" + } else if callback.functionNil && callback.functionUnknown { + reason = "registration retains typed nil and outside callback alternatives; callback invocation is not established" + } else if callback.functionNil { + reason = "registration retains only a typed nil callback candidate; callback invocation is not established" + } + ix.boundaries = append(ix.boundaries, Boundary{Node: key, Kind: "unresolved_cancellation_callback", Reason: reason, Evidence: evidence}) + } + } +} diff --git a/afterfunc_generic_test.go b/afterfunc_generic_test.go new file mode 100644 index 0000000..bde8a18 --- /dev/null +++ b/afterfunc_generic_test.go @@ -0,0 +1,167 @@ +package contracttrace + +import ( + "bytes" + "context" + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestGenericAfterFuncSourceSiteMergesInstantiations(t *testing.T) { + root := t.TempDir() + write := func(name, contents string) { + t.Helper() + if err := os.WriteFile(filepath.Join(root, name), []byte(contents), 0600); err != nil { + t.Fatal(err) + } + } + write("go.mod", "module example.com/genericafterfunc\n\ngo 1.27.0\n") + fixture := `package callbacks + +import "context" + +func register[T any](ctx context.Context, callback func()) func() bool { + return context.AfterFunc(ctx, callback) +} + +func firstCallback() {} +func secondCallback() {} +func directCallback() {} + +func inspectRegistrations() { + firstContext, cancelFirst := context.WithCancel(context.Background()) + defer cancelFirst() + secondContext, cancelSecond := context.WithCancel(context.Background()) + defer cancelSecond() + firstStop := register[int](firstContext, firstCallback) + secondStop := register[string](secondContext, secondCallback) + firstStop() + secondStop() + + directContext, cancelDirect := context.WithCancel(context.Background()) + defer cancelDirect() + directStop := context.AfterFunc(directContext, directCallback) + directStop() +} + ` + write("callbacks.go", fixture) + options := Options{Root: root, Seeds: []string{"inspectRegistrations"}, Depth: 5, MaxNodes: 300} + fset := token.NewFileSet() + source, err := parser.ParseFile(fset, "callbacks.go", fixture, 0) + if err != nil { + t.Fatal(err) + } + contextPositions := map[[2]int]bool{} + sourceLines := strings.Split(fixture, "\n") + ast.Inspect(source, func(node ast.Node) bool { + call, ok := node.(*ast.CallExpr) + if !ok { + return true + } + selector, ok := call.Fun.(*ast.SelectorExpr) + if !ok || selector.Sel.Name != "WithCancel" { + return true + } + packageName, ok := selector.X.(*ast.Ident) + if ok && packageName.Name == "context" { + position := fset.Position(call.Lparen) + if strings.Contains(sourceLines[position.Line-1], "firstContext") || strings.Contains(sourceLines[position.Line-1], "secondContext") { + contextPositions[[2]int{position.Line, position.Column}] = true + } + } + return true + }) + fresh, analysis, err := TraceWithAnalysis(context.Background(), options) + if err != nil { + t.Fatal(err) + } + var compressed bytes.Buffer + if err := WriteAnalysis(&compressed, analysis, true); err != nil { + t.Fatal(err) + } + loaded, err := ReadAnalysis(bytes.NewReader(compressed.Bytes())) + if err != nil { + t.Fatal(err) + } + resumed, err := Explore(context.Background(), loaded, ExploreOptions{Seeds: options.Seeds, Depth: options.Depth, MaxNodes: options.MaxNodes}) + if err != nil { + t.Fatal(err) + } + + check := func(name string, report Report) { + t.Helper() + registrations := map[string]bool{} + contexts := map[string]bool{} + callbacks := map[string]bool{} + stops := map[int]bool{} + for _, edge := range report.Relationships { + if edge.Kind == "cancellation_callback_register" && strings.HasSuffix(edge.From, "::register") { + registrations[edge.To] = true + } + } + if len(registrations) != 1 { + t.Fatalf("%s: generic source call should have one registration identity, got %v", name, registrations) + } + var registration string + for candidate := range registrations { + registration = candidate + } + for _, edge := range report.Relationships { + if edge.From != registration { + continue + } + switch edge.Kind { + case "cancellation_callback_context": + contexts[edge.To] = true + case "cancellation_callback_target": + callbacks[edge.To] = true + } + } + for _, edge := range report.Relationships { + if edge.Kind == "cancellation_callback_stop" && edge.To == registration && strings.HasSuffix(edge.From, "::inspectRegistrations") { + stops[edge.Evidence.Line] = true + } + } + wantCallbacks := map[string]bool{ + "example.com/genericafterfunc::firstCallback": true, + "example.com/genericafterfunc::secondCallback": true, + } + genericContexts := map[string]bool{} + for _, node := range report.Nodes { + if node.Kind == "context" && contextPositions[[2]int{node.Evidence.Line, node.Evidence.Column}] { + genericContexts[node.ID] = true + } + } + if len(contextPositions) != 2 || len(genericContexts) != 2 || len(contexts) != 2 { + t.Errorf("%s: generic registration contexts lost: source=%v edges=%v", name, genericContexts, contexts) + } else { + for context := range genericContexts { + if !contexts[context] { + t.Errorf("%s: generic registration missing source context %s: %v", name, context, contexts) + } + } + } + for callback := range wantCallbacks { + if !callbacks[callback] { + t.Errorf("%s: missing callback candidate %s at %s: %v", name, callback, registration, callbacks) + } + } + if len(stops) != 2 { + t.Errorf("%s: source registration should retain both distinct stop call sites, got %v", name, stops) + } + direct := false + for _, edge := range report.Relationships { + direct = direct || edge.Kind == "cancellation_callback_target" && edge.To == "example.com/genericafterfunc::directCallback" + } + if !direct { + t.Errorf("%s: non-generic AfterFunc control missing", name) + } + } + check("fresh", fresh) + check("resumed", resumed) +} diff --git a/afterfunc_scheduler.go b/afterfunc_scheduler.go new file mode 100644 index 0000000..d33755c --- /dev/null +++ b/afterfunc_scheduler.go @@ -0,0 +1,201 @@ +package contracttrace + +import ( + "fmt" + "go/types" + "strings" + + "golang.org/x/tools/go/ssa" +) + +func cancellationSchedulerSignature(fn *ssa.Function) bool { + return fn.Object() != nil && fn.Object().Name() == "AfterFunc" && schedulerSignature(fn.Signature) +} + +func schedulerSignature(signature *types.Signature) bool { + if signature.Recv() == nil || signature.Params().Len() != 1 || signature.Results().Len() != 1 { + return false + } + callback, ok := types.Unalias(signature.Params().At(0).Type()).(*types.Signature) + if !ok || callback.Params().Len() != 0 || callback.Results().Len() != 0 || callback.Variadic() { + return false + } + stop, ok := types.Unalias(signature.Results().At(0).Type()).(*types.Signature) + return ok && stop.Params().Len() == 0 && stop.Results().Len() == 1 && !stop.Variadic() && types.Identical(stop.Results().At(0).Type(), types.Typ[types.Bool]) && !signature.Variadic() +} + +func (a *flowAnalysis) modelAfterFuncInterface(value ssa.Value, result *flowValue) { + if a.afterFuncTypeCache == nil { + a.afterFuncTypeCache = map[types.Type]string{} + } + if effect, cached := a.afterFuncTypeCache[value.Type()]; cached { + if effect != "" { + result.effects[effect] = true + } + return + } + a.afterFuncTypeCache[value.Type()] = "" + selection := types.NewMethodSet(value.Type()).Lookup(nil, "AfterFunc") + if selection == nil || !schedulerSignature(selection.Obj().Type().(*types.Signature)) { + return + } + for _, id := range sortedKeys(a.afterFuncSchedulers) { + target := a.afterFuncSchedulers[id] + if target.Object() != selection.Obj() { + continue + } + result.effects["afterfunc_scheduler:"+id] = true + a.afterFuncTypeCache[value.Type()] = "afterfunc_scheduler:" + id + return + } + result.effects["afterfunc_scheduler_unmodeled"] = true + a.afterFuncTypeCache[value.Type()] = "afterfunc_scheduler_unmodeled" +} + +// The standard-library hook receives an internal delivery wrapper rather than +// the user's callback. Preserve it as a callable relay summary, not as the same +// function value. Receiver and stored relay candidates join the normal flow. +func (a *flowAnalysis) afterFuncSchedulerFlow() bool { + changed := false + for _, key := range sortedKeys(a.afterFuncs) { + site := a.afterFuncs[key] + if a.bindCancellationScheduler(site.context, "afterfunc_dispatch:"+key) { + changed = true + } + } + for _, key := range sortedKeys(a.contextKeys) { + site := a.contextKeys[key] + if !site.needsCancel { + continue + } + parent := emptyFlow() + for _, name := range site.names { + if contextConstructorClass(name) != "cancelable" { + continue + } + for _, value := range sortedSSAValues(site.parents[name]) { + a.merge(&parent, a.get(value)) + } + } + if site.parentUnknown || site.unknown { + parent.interfaceUnknown = true + } + if a.bindCancellationScheduler(parent, "cancel:"+key) { + changed = true + } + } + return changed +} + +func (a *flowAnalysis) bindCancellationScheduler(receiver flowValue, delivery string) bool { + changed := false + for _, effect := range sortedKeys(receiver.effects) { + target := a.afterFuncSchedulers[strings.TrimPrefix(effect, "afterfunc_scheduler:")] + if !strings.HasPrefix(effect, "afterfunc_scheduler:") || target == nil || len(target.Params) != 2 { + continue + } + if a.put(target.Params[0], a.schedulerReceiver(receiver, target)) { + changed = true + } + relay := emptyFlow() + relay.effects[delivery] = true + if a.put(target.Params[1], relay) { + changed = true + } + } + return changed +} + +// Optional methods can be promoted through embedded fields. Resolve each known +// root's method selection separately; unrelated context candidates must not be +// bound as this method's receiver. Pointer fields use their stored aliases and +// value fields keep their own field address. +func (a *flowAnalysis) schedulerReceiver(value flowValue, target *ssa.Function) flowValue { + result := emptyFlow() + for _, root := range sortedKeys(value.addresses) { + typ := a.addressTypes[root] + if typ == nil { + continue + } + selection := types.NewMethodSet(typ).Lookup(nil, "AfterFunc") + if selection == nil || selection.Obj() != target.Object() { + continue + } + addresses := map[string]bool{root: true} + path := selection.Index() + for _, field := range path[:len(path)-1] { + if pointer, ok := typ.Underlying().(*types.Pointer); ok { + typ = pointer.Elem() + } + record, ok := typ.Underlying().(*types.Struct) + if !ok || field >= record.NumFields() { + addresses = nil + break + } + typ = record.Field(field).Type() + next := map[string]bool{} + for _, address := range sortedKeys(addresses) { + cell := fmt.Sprintf("%s.field:%d", address, field) + if _, pointer := typ.Underlying().(*types.Pointer); pointer { + for _, alias := range sortedKeys(a.memory[cell].addresses) { + next[alias] = true + } + } else { + next[cell] = true + } + } + addresses = next + } + candidate := emptyFlow() + candidate.addresses = addresses + a.merge(&result, candidate) + } + return result +} + +func (a *flowAnalysis) afterFuncSchedulerRelationships(key string, site afterFuncSite, ix *index) { + receiver := site.context + evidence := ix.callEvidence(site.call) + a.cancellationSchedulerRelationships(key, receiver, evidence, "cancellation_callback_scheduler", "cancellation_scheduler_stop_target", ix) +} + +func (a *flowAnalysis) cancellationSchedulerRelationships(key string, receiver flowValue, evidence Evidence, schedulerKind, stopKind string, ix *index) { + for _, effect := range sortedKeys(receiver.effects) { + if !strings.HasPrefix(effect, "afterfunc_scheduler:") { + continue + } + target := a.afterFuncSchedulers[strings.TrimPrefix(effect, "afterfunc_scheduler:")] + if target == nil { + continue + } + ix.edges = append(ix.edges, Relationship{From: key, To: ix.owner(target), Kind: schedulerKind, Certainty: "possible", Evidence: evidence}) + ix.boundaries = append(ix.boundaries, Boundary{Node: key, Kind: "cancellation_scheduler_model", Reason: "a known context type supplies the optional AfterFunc hook; receiver and internal delivery-wrapper relay candidates are modeled, but parent Done/Err conditions, scheduler selection, callback execution, returned-stop invocation and ordering are not proved; the internal wrapper is not the user's callback value", Evidence: evidence}) + if len(a.returns[target]) == 0 { + continue + } + stopCandidates := a.returns[target][0] + outsideStop := stopCandidates.functionUnknown + knownStop := len(stopCandidates.functions) > 0 + for _, stop := range sortedFunctions(a.returns[target][0].functions) { + if owner := ix.owner(stop); owner != "" { + ix.edges = append(ix.edges, Relationship{From: key, To: owner, Kind: stopKind, Certainty: "possible", Evidence: evidence}) + } else { + outsideStop = true + } + } + if outsideStop || stopCandidates.functionNil { + reason := "custom scheduler retains an outside returned-stop candidate alongside known targets" + if stopCandidates.functionNil && knownStop && !stopCandidates.functionUnknown && !outsideStop { + reason = "custom scheduler retains known returned-stop targets alongside a typed nil stop-function alternative; successful stopping is not established" + } else if stopCandidates.functionNil && !knownStop && !stopCandidates.functionUnknown && !outsideStop { + reason = "custom scheduler returns only a typed nil stop-function candidate; successful stopping is not established" + } else if stopCandidates.functionNil { + reason = "custom scheduler retains typed nil and outside returned-stop alternatives; successful stopping is not established" + } + ix.boundaries = append(ix.boundaries, Boundary{Node: key, Kind: "unresolved_cancellation_scheduler_stop", Reason: reason, Evidence: evidence}) + } + } + if receiver.interfaceUnknown || receiver.effects["afterfunc_scheduler_unmodeled"] { + ix.boundaries = append(ix.boundaries, Boundary{Node: key, Kind: "unresolved_cancellation_scheduler", Reason: "outside context types, promoted or adapted scheduler receivers and omitted dependency implementations can supply additional optional AfterFunc hooks; known candidates do not establish the complete scheduler set", Evidence: evidence}) + } +} diff --git a/afterfunc_scheduler_test.go b/afterfunc_scheduler_test.go new file mode 100644 index 0000000..3cec05e --- /dev/null +++ b/afterfunc_scheduler_test.go @@ -0,0 +1,135 @@ +package contracttrace + +import ( + "context" + "encoding/json" + "strings" + "testing" +) + +func TestCustomSchedulerValueAndNestedReceiverStorage(t *testing.T) { + seeds := []string{"ValuePromotedSchedulingCaller", "NestedPromotedSchedulingCaller", "ValueReceiverSchedulingCaller"} + _, analysis, err := TraceWithAnalysis(context.Background(), Options{Root: "testdata/sample", Seeds: seeds, Depth: 5, MaxNodes: 300}) + if err != nil { + t.Fatal(err) + } + encoded, err := json.Marshal(analysis) + if err != nil { + t.Fatal(err) + } + saved, err := ReadAnalysis(strings.NewReader(string(encoded))) + if err != nil { + t.Fatal(err) + } + r, err := Explore(context.Background(), saved, ExploreOptions{Seeds: seeds, Depth: 5, MaxNodes: 300}) + if err != nil { + t.Fatal(err) + } + for _, caller := range []string{"ValuePromotedSchedulingCaller", "NestedPromotedSchedulingCaller", "ValueReceiverSchedulingCaller"} { + registration, channel := "", "" + for _, edge := range r.Relationships { + if !strings.HasSuffix(edge.From, "::"+caller) { + continue + } + if edge.Kind == "cancellation_callback_register" { + registration = edge.To + } + if edge.Kind == "channel_create" && strings.Contains(edge.Evidence.Snippet, "stopped :=") { + channel = edge.To + } + } + scheduler, stop, closed, dispatched := false, false, false, caller == "ValueReceiverSchedulingCaller" + for _, edge := range r.Relationships { + scheduler = scheduler || edge.From == registration && edge.Kind == "cancellation_callback_scheduler" + stop = stop || edge.From == registration && edge.Kind == "cancellation_scheduler_stop_target" + closed = closed || edge.To == channel && edge.Kind == "channel_close" + dispatched = dispatched || edge.To == registration && edge.Kind == "cancellation_scheduler_dispatch" + } + if registration == "" || channel == "" || !scheduler || !stop || !closed || !dispatched { + t.Errorf("%s missing receiver path: registration=%s channel=%s scheduler=%t stop=%t close=%t relay=%t", caller, registration, channel, scheduler, stop, closed, dispatched) + } + } +} + +func TestCustomCancellationSchedulerRetainsRelayReceiverAndStop(t *testing.T) { + r, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"CustomSchedulingCaller", "WrongSchedulingCaller", "KnownOutsideCustomSchedulingCaller", "NamedSchedulingCaller", "ChildCustomSchedulingCaller", "OtherChildCustomSchedulingCaller", "PromotedSchedulingCaller"}, Depth: 5, MaxNodes: 300}) + if err != nil { + t.Fatal(err) + } + registration, wrong, outside, named, child, stoppedResource := "", "", "", "", "", "" + for _, e := range r.Relationships { + if e.Kind == "channel_create" && strings.HasSuffix(e.From, "::CustomSchedulingCaller") && strings.Contains(e.Evidence.Snippet, "stopped :=") { + stoppedResource = e.To + } + if e.Kind == "context_create" && strings.HasSuffix(e.From, "::ChildCustomSchedulingCaller") && strings.Contains(e.Evidence.Snippet, "WithCancel(parent)") { + child = e.To + } + if e.Kind != "cancellation_callback_register" { + continue + } + if strings.HasSuffix(e.From, "::CustomSchedulingCaller") { + registration = e.To + } + if strings.HasSuffix(e.From, "::WrongSchedulingCaller") { + wrong = e.To + } + if strings.HasSuffix(e.From, "::OutsideCustomScheduling") { + outside = e.To + } + if strings.HasSuffix(e.From, "::NamedSchedulingCaller") { + named = e.To + } + } + scheduler, relay, stop, stoppedChannel := false, false, false, false + childScheduler, childCancel, childStop := false, false, false + deferred, goroutine := false, false + otherChildren := map[string]bool{} + promotedRegistration, promotedChannel := "", "" + for _, e := range r.Relationships { + if e.Kind == "cancellation_callback_register" && strings.HasSuffix(e.From, "::PromotedSchedulingCaller") { + promotedRegistration = e.To + } + if e.Kind == "channel_create" && strings.HasSuffix(e.From, "::PromotedSchedulingCaller") && strings.Contains(e.Evidence.Snippet, "stopped :=") { + promotedChannel = e.To + } + } + promotedScheduler, promotedClose := false, false + for _, e := range r.Relationships { + promotedScheduler = promotedScheduler || e.From == promotedRegistration && e.Kind == "cancellation_callback_scheduler" + promotedClose = promotedClose || e.To == promotedChannel && e.Kind == "channel_close" && strings.Contains(e.From, "customSchedulingContext.stop") + deferred = deferred || e.To == registration && e.Kind == "deferred_cancellation_scheduler_dispatch" + goroutine = goroutine || e.To == registration && e.Kind == "goroutine_cancellation_scheduler_dispatch" + if e.Kind == "context_cancellation_scheduler" && strings.Contains(e.Evidence.Snippet, "parent,") { + otherChildren[e.From] = true + } + if e.Kind == "context_cancellation_scheduler" && strings.Contains(e.Evidence.Snippet, "WithCancelCause(parent)") { + otherChildren[e.From] = true + } + childScheduler = childScheduler || e.From == child && e.Kind == "context_cancellation_scheduler" && strings.Contains(e.To, "customSchedulingContext.AfterFunc") + childCancel = childCancel || e.To == child && e.Kind == "context_cancel" && strings.Contains(e.From, "customSchedulingContext.fire") + childStop = childStop || e.From == child && e.Kind == "context_scheduler_stop_target" && strings.Contains(e.To, "customSchedulingContext.stop") + scheduler = scheduler || e.From == registration && e.Kind == "cancellation_callback_scheduler" && strings.Contains(e.To, "customSchedulingContext.AfterFunc") + relay = relay || e.To == registration && e.Kind == "cancellation_scheduler_dispatch" && strings.Contains(e.From, "customSchedulingContext.fire") + stop = stop || e.From == registration && e.Kind == "cancellation_scheduler_stop_target" && strings.Contains(e.To, "customSchedulingContext.stop") + stoppedChannel = stoppedChannel || e.Kind == "channel_close" && strings.Contains(e.From, "customSchedulingContext.stop") && e.To == stoppedResource + if (e.From == wrong || e.From == named) && e.Kind == "cancellation_callback_scheduler" { + t.Errorf("wrong signature became a scheduler: %+v", e) + } + } + unknown := false + for _, b := range r.Boundaries { + unknown = unknown || b.Node == outside && b.Kind == "unresolved_cancellation_scheduler" + } + if child == "" || stoppedResource == "" || !childScheduler || !childCancel || !childStop { + t.Errorf("child scheduler missing: child=%s stopped=%s scheduler=%t cancel=%t stop=%t", child, stoppedResource, childScheduler, childCancel, childStop) + } + if !deferred || !goroutine || len(otherChildren) != 5 { + t.Errorf("scheduler sibling paths: defer=%t go=%t constructors=%d", deferred, goroutine, len(otherChildren)) + } + if promotedRegistration == "" || promotedChannel == "" || !promotedScheduler || !promotedClose { + t.Errorf("promoted receiver missing: registration=%s channel=%s scheduler=%t close=%t", promotedRegistration, promotedChannel, promotedScheduler, promotedClose) + } + if registration == "" || wrong == "" || named == "" || outside == "" || !scheduler || !relay || !stop || !stoppedChannel || !unknown { + t.Fatalf("custom scheduling paths: register=%s wrong=%s outside=%s scheduler=%t relay=%t stop=%t receiver-close=%t unknown=%t", registration, wrong, outside, scheduler, relay, stop, stoppedChannel, unknown) + } +} diff --git a/afterfunc_test.go b/afterfunc_test.go new file mode 100644 index 0000000..08548e4 --- /dev/null +++ b/afterfunc_test.go @@ -0,0 +1,185 @@ +package contracttrace + +import ( + "context" + "strings" + "testing" +) + +func TestAliasedCancellationRetainsBoundMethodAndOutsideAlternative(t *testing.T) { + r, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"AliasedCancellationCaller", "KnownOutsideAliasedCancellationCaller", "MixedAliasedCancellation"}, Depth: 5, MaxNodes: 250}) + if err != nil { + t.Fatal(err) + } + registrations := map[string]string{} + targets := map[string]bool{} + channel := "" + deferred, goroutine, localStop, mixedStop := false, false, false, false + for _, edge := range r.Relationships { + if strings.HasSuffix(edge.From, "::registerAliasedVoid") { + deferred = deferred || edge.Kind == "deferred_cancellation_callback_register" + goroutine = goroutine || edge.Kind == "goroutine_cancellation_callback_register" + } + if strings.HasSuffix(edge.From, "::MixedAliasedCancellation") { + localStop = localStop || edge.Kind == "resolved_callback_call" && strings.HasSuffix(edge.To, "::localAliasedStop") + mixedStop = mixedStop || edge.Kind == "cancellation_callback_stop" + } + if edge.Kind == "cancellation_callback_register" { + if edge.Certainty != "possible" { + t.Errorf("dynamic registration is not a fact: %+v", edge) + } + registrations[edge.From] = edge.To + } + if edge.Kind == "cancellation_callback_target" { + targets[edge.To] = true + } + if edge.Kind == "channel_create" && strings.HasSuffix(edge.From, "::AliasedCancellationCaller") { + channel = edge.To + } + } + for _, caller := range []string{"registerAliasedCancellation", "OutsideAliasedCancellation"} { + if registrations["example.com/sample::"+caller] == "" { + t.Errorf("missing aliased registration %s", caller) + } + } + closed, stopped, partialRegistration, partialStop, falseUnknown := false, false, false, false, false + for _, edge := range r.Relationships { + closed = closed || edge.Kind == "channel_close" && strings.Contains(edge.From, "cancellationOwner.finish") && edge.To == channel + stopped = stopped || edge.Kind == "cancellation_callback_stop" && strings.HasSuffix(edge.From, "::AliasedCancellationCaller") && edge.To == registrations["example.com/sample::registerAliasedCancellation"] + } + for _, boundary := range r.Boundaries { + if strings.HasSuffix(boundary.Node, "::OutsideAliasedCancellation") { + partialRegistration = partialRegistration || boundary.Kind == "partial_function_call" && strings.Contains(boundary.Evidence.Snippet, "register(") + partialStop = partialStop || boundary.Kind == "partial_function_call" && boundary.Evidence.Snippet == "stop()" + } + falseUnknown = falseUnknown || strings.HasSuffix(boundary.Node, "::AliasedCancellationCaller") && boundary.Evidence.Snippet == "stop()" && strings.Contains(boundary.Kind, "function_call") + falseUnknown = falseUnknown || strings.HasSuffix(boundary.Node, "::MixedAliasedCancellation") && boundary.Evidence.Snippet == "stop()" && strings.Contains(boundary.Kind, "function_call") + } + bound := false + for target := range targets { + bound = bound || strings.Contains(target, "cancellationOwner.finish") + } + if !deferred || !goroutine || !localStop || !mixedStop { + t.Errorf("alias alternate paths missing: defer=%t go=%t local-stop=%t mixed-stop=%t", deferred, goroutine, localStop, mixedStop) + } + if !bound || channel == "" || !closed || !stopped || !partialRegistration || !partialStop || falseUnknown { + t.Fatalf("alias/binding coverage: bound=%t channel=%s close=%t stop=%t partial-register=%t partial-stop=%t false-unknown=%t", bound, channel, closed, stopped, partialRegistration, partialStop, falseUnknown) + } +} + +func TestKnownStopMixtureAndCancelCallbackBridge(t *testing.T) { + r, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"MixedKnownStop", "AfterFuncCancelBridge"}, Depth: 3, MaxNodes: 200}) + if err != nil { + t.Fatal(err) + } + local, stop := false, false + registration, context, canceled := "", "", "" + for _, edge := range r.Relationships { + local = local || edge.Kind == "resolved_callback_call" && strings.HasSuffix(edge.From, "::MixedKnownStop") && strings.HasSuffix(edge.To, "::localFunctionStop") + stop = stop || edge.Kind == "cancellation_callback_stop" && strings.HasSuffix(edge.From, "::MixedKnownStop") + if edge.Kind == "cancellation_callback_register" && strings.HasSuffix(edge.From, "::AfterFuncCancelBridge") { + registration = edge.To + } + } + for _, edge := range r.Relationships { + if edge.From == registration && edge.Kind == "cancellation_callback_context" { + context = edge.To + } + if edge.From == registration && edge.Kind == "cancellation_callback_cancel" { + canceled = edge.To + } + } + for _, boundary := range r.Boundaries { + if strings.HasSuffix(boundary.Node, "::MixedKnownStop") && boundary.Evidence.Snippet == "stop()" && strings.Contains(boundary.Kind, "function_call") { + t.Errorf("fully modeled mixture became unknown: %+v", boundary) + } + if boundary.Node == registration && boundary.Kind == "unresolved_cancellation_callback" { + t.Error("modeled cancel callback became unknown") + } + } + if !local || !stop || registration == "" || context == "" || canceled == "" || context == canceled { + t.Fatalf("mixture/bridge missing: local=%t stop=%t registration=%s context=%s canceled=%s", local, stop, registration, context, canceled) + } +} + +func TestCancellationCallbacksRetainRegistrationContextAndStop(t *testing.T) { + r, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"AfterFuncCaller"}, Depth: 4, MaxNodes: 300}) + if err != nil { + t.Fatal(err) + } + registrations := map[string]bool{} + targets := map[string]bool{} + contexts := map[string]bool{} + stops := map[string]bool{} + for _, edge := range r.Relationships { + if strings.HasSuffix(edge.Kind, "cancellation_callback_register") { + registrations[edge.To] = true + } + if edge.Kind == "cancellation_callback_target" { + targets[edge.To] = true + } + if edge.Kind == "cancellation_callback_context" { + contexts[edge.From] = true + } + if strings.HasSuffix(edge.Kind, "cancellation_callback_stop") { + stops[edge.Kind] = true + } + } + if len(registrations) != 6 || len(contexts) != 6 { + t.Errorf("independent registration/context sites: %v / %v", registrations, contexts) + } + for _, target := range []string{"firstCancellationCallback", "secondCancellationCallback", "detachedCancellationCallback", "outsideCancellationCallback", "deferredRegistrationCallback", "goroutineRegistrationCallback"} { + if !targets["example.com/sample::"+target] { + t.Errorf("missing cancellation target %s", target) + } + } + for _, kind := range []string{"cancellation_callback_stop", "deferred_cancellation_callback_stop", "goroutine_cancellation_callback_stop"} { + if !stops[kind] { + t.Errorf("missing stop relationship %s", kind) + } + } + unknownContext, unknownCallback, detached, ordering := false, false, false, false + for _, boundary := range r.Boundaries { + unknownContext = unknownContext || boundary.Kind == "unresolved_callback_context" + unknownCallback = unknownCallback || boundary.Kind == "unresolved_cancellation_callback" + detached = detached || boundary.Kind == "noncanceling_callback_context" + ordering = ordering || boundary.Kind == "cancellation_callback_model" + if strings.Contains(boundary.Kind, "unresolved_function_call") && (boundary.Evidence.Snippet == "record.Stop()" || boundary.Evidence.Snippet == "defer second()" || boundary.Evidence.Snippet == "go third()") { + t.Errorf("modeled stop looks unresolved: %+v", boundary) + } + } + if !unknownContext || !unknownCallback || !detached || !ordering { + t.Errorf("callback limits: context=%t callback=%t detached=%t ordering=%t", unknownContext, unknownCallback, detached, ordering) + } +} + +func TestCancellationCallbackCompletionKeepsSeparateChannelPath(t *testing.T) { + r, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"AfterFuncCompletionCaller"}, Depth: 4, MaxNodes: 150}) + if err != nil { + t.Fatal(err) + } + registration, channel := "", "" + for _, edge := range r.Relationships { + if edge.Kind == "cancellation_callback_register" && strings.HasSuffix(edge.From, "::AfterFuncCompletionCaller") { + registration = edge.To + } + if edge.Kind == "channel_create" && strings.HasSuffix(edge.From, "::AfterFuncCompletionCaller") { + channel = edge.To + } + } + targets := map[string]bool{} + for _, edge := range r.Relationships { + if edge.Kind == "cancellation_callback_target" && edge.From == registration { + targets[edge.To] = true + } + } + closed, waited, stopped := false, false, false + for _, edge := range r.Relationships { + closed = closed || edge.Kind == "channel_close" && targets[edge.From] && edge.To == channel + waited = waited || edge.Kind == "channel_receive" && strings.HasSuffix(edge.From, "::AfterFuncCompletionCaller") && edge.To == channel + stopped = stopped || edge.Kind == "cancellation_callback_stop" && edge.To == registration + } + if registration == "" || channel == "" || !closed || !waited || !stopped { + t.Fatalf("separate completion path: registration=%s channel=%s close=%t wait=%t stop=%t", registration, channel, closed, waited, stopped) + } +} diff --git a/aggregate_callback_inputs_test.go b/aggregate_callback_inputs_test.go new file mode 100644 index 0000000..bbcae2a --- /dev/null +++ b/aggregate_callback_inputs_test.go @@ -0,0 +1,51 @@ +package contracttrace + +import ( + "context" + "strings" + "testing" +) + +func TestEscapedCallbacksRetainAggregateOutsideInputs(t *testing.T) { + r, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"EscapedAggregateInputCaller"}, Depth: 4, MaxNodes: 300, + Config: Config{StorageScopes: []StorageScope{{Namespace: "aggregate", DatabaseOrigins: []string{"example.com/sample::knownAggregateDatabase"}}}}}) + if err != nil { + t.Fatal(err) + } + for owner, query := range map[string]string{ + "returnedAggregateCallback": "returnedAggregateQuery", + "globalAggregateCallback": "globalAggregateQuery", + "dependencyAggregateCallback": "dependencyAggregateQuery", + "closedAggregateCallback": "privateAggregateQuery", + } { + known, outside := false, false + for _, edge := range r.Relationships { + known = known || edge.Kind == "resolved_callback_call" && strings.HasSuffix(edge.From, "::"+owner) && strings.HasSuffix(edge.To, "::"+query) + } + for _, boundary := range r.Boundaries { + outside = outside || boundary.Kind == "partial_function_call" && strings.HasSuffix(boundary.Node, "::"+owner) + } + if !known || outside != (owner != "closedAggregateCallback") { + t.Errorf("%s known=%t outside=%t", owner, known, outside) + } + } + for query, table := range map[string]string{ + "returnedAggregateQuery": "returned_aggregate_records", "globalAggregateQuery": "global_aggregate_records", + "dependencyAggregateQuery": "dependency_aggregate_records", "privateAggregateQuery": "private_aggregate_records", + } { + tables := map[string]bool{} + for _, edge := range r.Relationships { + if edge.Kind == "sql_read" && strings.HasSuffix(edge.From, "::"+query) { + tables[edge.To] = true + } + } + outside := query != "privateAggregateQuery" + count := 1 + if outside { + count = 2 + } + if len(tables) != count || !tables["table:aggregate:"+table] || tables["table:"+table] != outside { + t.Errorf("%s origins: %v", query, tables) + } + } +} diff --git a/aggregate_inputs.go b/aggregate_inputs.go new file mode 100644 index 0000000..b1184c4 --- /dev/null +++ b/aggregate_inputs.go @@ -0,0 +1,259 @@ +package contracttrace + +import ( + "fmt" + "go/types" + + "golang.org/x/tools/go/ssa" +) + +func sensitiveInputType(typ types.Type, seen map[types.Type]bool) bool { + if isSyncWaitGroupType(typ) { + return true + } + if seen[typ] { + return false + } + seen[typ] = true + if isFunctionType(typ) || isSQLHandleType(typ) || isErrorType(typ) || isStringType(typ) || isScalarType(typ) { + return true + } + switch shape := typ.Underlying().(type) { + case *types.Interface: + return true + case *types.Chan: + return true + case *types.Pointer: + return sensitiveInputType(shape.Elem(), seen) + case *types.Array: + return sensitiveInputType(shape.Elem(), seen) + case *types.Slice: + return sensitiveInputType(shape.Elem(), seen) + case *types.Map: + return sensitiveInputType(shape.Key(), seen) || sensitiveInputType(shape.Elem(), seen) + case *types.Struct: + for i := 0; i < shape.NumFields(); i++ { + field := shape.Field(i) + if (field.Exported() || embeddedRecord(field)) && sensitiveInputType(field.Type(), seen) { + return true + } + } + } + return false +} + +func isSyncWaitGroupType(typ types.Type) bool { + for { + unaliased := types.Unalias(typ) + if pointer, ok := unaliased.(*types.Pointer); ok { + typ = pointer.Elem() + continue + } + named, ok := unaliased.(*types.Named) + return ok && named.Obj().Name() == "WaitGroup" && named.Obj().Pkg() != nil && named.Obj().Pkg().Path() == "sync" + } +} + +// Input roots are source-backed type summaries, not runtime allocations. Reuse +// pointee type roots to bound cycles; concrete local candidates remain separate. +func (a *flowAnalysis) seedAggregateInput(parameter *ssa.Parameter, ix *index) bool { + root := fmt.Sprintf("input:%s:%s", parameter.Parent().String(), parameter.Name()) + return a.seedAggregateOrigin(parameter, root, false, ix.evidence(parameter.Pos()), ix) +} + +func (a *flowAnalysis) seedAggregateOrigin(source ssa.Value, root string, result bool, evidence Evidence, ix *index) bool { + typ := source.Type() + if isFunctionType(typ) || isSQLHandleType(typ) || !sensitiveInputType(typ, map[types.Type]bool{}) && !a.lifecycleSensitiveType(typ, map[types.Type]bool{}) { + return false + } + if a.inputRoots == nil { + a.inputRoots = map[string]bool{} + } + if a.inputRoots[root] { + return false + } + a.inputRoots[root] = true + owner := "" + if global, ok := source.(*ssa.Global); ok { + owner = globalID(global) + } else { + owner = ix.owner(source.Parent()) + } + if evidence.Line < 1 { + evidence = ix.funcs[owner].node.Evidence + evidence.Origin = "synthetic_declaration" + } + changed := false + representatives := map[types.Type]string{} + type identity struct { + address string + typ types.Type + } + visited := map[identity]bool{} + work := 0 + var shape func(string, types.Type) + var cell func(string, types.Type) + containerCell := func(address string, typ types.Type) { + if target := representatives[typ]; target != "" && inlineAggregate(typ) { + value := emptyFlow() + value.addresses[target] = true + if a.store(address, value) { + changed = true + } + } else { + cell(address, typ) + } + } + cell = func(address string, typ types.Type) { + value := emptyFlow() + if isScalarType(typ) { + value = a.outsideScalar(address, root, evidence, ix) + } + if isErrorType(typ) { + id := "error-input:" + address + value.errors = map[string]bool{id: true} + value.interfaceUnknown = isInterfaceType(typ) + if ix.funcs[id] == nil { + ix.funcs[id] = &function{node: Node{ID: id, Name: "unresolved error in " + address, Kind: "error_input", Evidence: evidence}} + ix.edges = append(ix.edges, Relationship{From: root, To: id, Kind: "external_input", Certainty: "possible", Evidence: evidence}) + ix.boundaries = append(ix.boundaries, Boundary{Node: id, Kind: "outside_error_input", Reason: "Public error-typed aggregate storage can contain outside error values alongside modeled local origins. Shared type/container summaries do not prove invocation, object identity, a non-nil error or correct handling.", Evidence: evidence}) + } + } + switch { + case isStringType(typ): + value.stringUnknown = true + ix.boundaries = append(ix.boundaries, Boundary{Node: root, Kind: "outside_string_input", Reason: "Outside string storage at " + address + " retains unknown values alongside local strings; known local formats, queries or event names do not close its origin set.", Evidence: evidence}) + case isInterfaceType(typ): + value.interfaceUnknown = true + case isFunctionType(typ): + value.functionUnknown = true + case isSQLHandleType(typ): + value.sqlUnknown = true + default: + switch concrete := typ.Underlying().(type) { + case *types.Pointer: + target := representatives[concrete.Elem()] + if target == "" { + target = address + ".pointee" + shape(target, concrete.Elem()) + } + value.addresses[target] = true + case *types.Chan: + target := representatives[typ] + if target == "" { + target = address + ".channel" + shape(target, typ) + } + value.addresses["channel:outside:"+target] = true + case *types.Struct, *types.Array, *types.Slice, *types.Map: + shape(address, typ) + value.addresses[address] = true + } + } + if a.store(address, value) { + changed = true + } + } + shape = func(address string, typ types.Type) { + work++ + if work > maxFlowValues*maxFlowValues { + a.coverage.Widened = true + return + } + key := identity{address, typ} + if visited[key] { + return + } + visited[key] = true + if representatives[typ] == "" { + representatives[typ] = address + } + a.addressTypes[address] = typ + if isFunctionType(typ) || isSQLHandleType(typ) || isStringType(typ) || isScalarType(typ) { + cell(address, typ) + return + } + // Implementing error does not make a record's public fields opaque. + if isErrorType(typ) { + cell(address, typ) + } + // A pointer to a slice/map root must load the summarized container, + // including when there is no local allocation or initializer. + switch typ.Underlying().(type) { + case *types.Slice, *types.Map: + alias := emptyFlow() + alias.addresses[address] = true + if a.store(address, alias) { + changed = true + } + } + switch concrete := typ.Underlying().(type) { + case *types.Interface: + cell(address, typ) + case *types.Pointer: + cell(address, typ) + case *types.Struct: + for i := 0; i < concrete.NumFields(); i++ { + field := concrete.Field(i) + if field.Exported() || embeddedRecord(field) { + cell(fmt.Sprintf("%s.field:%d", address, i), field.Type()) + } + } + case *types.Array: + containerCell(address+".element", concrete.Elem()) + case *types.Slice: + containerCell(address+".element", concrete.Elem()) + case *types.Map: + containerCell(address+".keys", concrete.Key()) + containerCell(address+".key:*", concrete.Elem()) + case *types.Chan: + key := "channel:outside:" + address + alias := emptyFlow() + alias.addresses[key] = true + if a.store(address, alias) { + changed = true + } + if ix.funcs[key] == nil { + ix.funcs[key] = &function{node: Node{ID: key, Name: "outside channel " + address, Kind: "channel", Evidence: evidence}} + ix.edges = append(ix.edges, Relationship{From: root, To: key, Kind: "external_input", Certainty: "possible", Evidence: evidence}) + ix.boundaries = append(ix.boundaries, Boundary{Node: key, Kind: "outside_channel", Reason: "Outside channel identity and typed contents remain candidates alongside local channels. Nil values, send/receive pairing, closure, callback execution and temporal ownership are not proved; repeated channel types share bounded summary storage.", Evidence: evidence}) + } + containerCell(key+".sent", concrete.Elem()) + } + } + // The source pointer names its pointee object. Pointers stored inside that + // object retain their own cells, so each dereference consumes one layer. + if pointer, ok := typ.Underlying().(*types.Pointer); ok { + shape(root, pointer.Elem()) + } else { + shape(root, typ) + } + value := emptyFlow() + if isScalarType(typ) { + a.mergeScalar(&value, a.memory[root]) + } + value.interfaceUnknown = isInterfaceType(typ) + value.stringUnknown = isStringType(typ) + if _, channel := typ.Underlying().(*types.Chan); channel { + value.addresses["channel:outside:"+root] = true + } else { + value.addresses[root] = true + } + if a.put(source, value) { + changed = true + } + kind, edgeKind, boundaryKind := "input", "external_input", "aggregate_input_model" + reason := "outside aggregate/interface input through a public API or escaped callback retains synthetic public function/SQL/interface/error storage candidates alongside local values; pointee types are shared to bound cycles; private fields, other outside value domains and runtime object identity are not established" + if result { + kind, edgeKind, boundaryKind = "result", "external_result", "aggregate_result_model" + reason = "outside or unresolved aggregate/interface result retains synthetic public function/SQL/interface/error storage candidates alongside local results; pointee types are shared to bound cycles; private fields, other outside value domains and runtime object identity are not established" + } + name := source.Name() + if _, global := source.(*ssa.Global); global { + name = "outside storage of " + name + } + ix.funcs[root] = &function{node: Node{ID: root, Name: name, Kind: kind, Evidence: evidence}} + ix.edges = append(ix.edges, Relationship{From: owner, To: root, Kind: edgeKind, Certainty: "possible", Evidence: evidence}) + ix.boundaries = append(ix.boundaries, Boundary{Node: root, Kind: boundaryKind, Reason: reason, Evidence: evidence}) + return changed +} diff --git a/aggregate_inputs_test.go b/aggregate_inputs_test.go new file mode 100644 index 0000000..36f69d5 --- /dev/null +++ b/aggregate_inputs_test.go @@ -0,0 +1,72 @@ +package contracttrace + +import ( + "context" + "strings" + "testing" +) + +func TestAggregateAPIInputsKeepKnownAndOutsideCandidates(t *testing.T) { + r, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"AggregateInputCaller"}, Depth: 4, MaxNodes: 250, + Config: Config{StorageScopes: []StorageScope{{Namespace: "aggregate", DatabaseOrigins: []string{"example.com/sample::knownAggregateDatabase"}}}}}) + if err != nil { + t.Fatal(err) + } + for _, owner := range []string{"consumeValueRecord", "PublicPointerRecordInput", "PublicSliceInput", "PublicMapInput"} { + known, open := false, false + for _, e := range r.Relationships { + known = known || e.Kind == "resolved_callback_call" && strings.HasSuffix(e.From, "::"+owner) && strings.HasSuffix(e.To, "::aggregateInputQuery") + } + for _, b := range r.Boundaries { + open = open || b.Kind == "partial_function_call" && strings.HasSuffix(b.Node, "::"+owner) + } + if !known || !open { + t.Errorf("%s record callback: known=%t outside=%t", owner, known, open) + } + } + for owner, table := range map[string]string{"aggregateInputQuery": "aggregate_input_records", "closedAggregateQuery": "closed_aggregate_input_records"} { + tables := map[string]bool{} + for _, e := range r.Relationships { + if e.Kind == "sql_read" && strings.HasSuffix(e.From, "::"+owner) { + tables[e.To] = true + } + } + outside := owner == "aggregateInputQuery" + count := 1 + if outside { + count = 2 + } + if len(tables) != count || !tables["table:aggregate:"+table] || tables["table:"+table] != outside { + t.Errorf("%s database origins: %v", owner, tables) + } + } +} + +func TestRepeatedAggregateFieldsAndFunctionPointersStayOpen(t *testing.T) { + r, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"AggregatePairCaller"}, Depth: 3, MaxNodes: 200}) + if err != nil { + t.Fatal(err) + } + for _, snippet := range []string{"pair.First.Run(pair.First.Database)", "pair.Second.Run(pair.Second.Database)", "(*pair.FunctionPointer)(pair.Second.Database)"} { + found := false + for _, boundary := range r.Boundaries { + found = found || boundary.Kind == "partial_function_call" && strings.HasSuffix(boundary.Node, "::PublicRecordPair") && boundary.Evidence.Snippet == snippet + } + if !found { + t.Errorf("aggregate type reuse hid %s", snippet) + } + } +} + +func TestRecursiveAggregateContainerUsesBoundedTypeRoots(t *testing.T) { + r, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"RecursiveContainerCaller"}, Depth: 3, MaxNodes: 150}) + if err != nil { + t.Fatal(err) + } + for _, boundary := range r.Boundaries { + if boundary.Kind == "partial_function_call" && strings.HasSuffix(boundary.Node, "::PublicRecursiveContainer") { + return + } + } + t.Fatal("recursive container lost outside callable origin") +} diff --git a/aggregate_pointers_test.go b/aggregate_pointers_test.go new file mode 100644 index 0000000..dc3bbcd --- /dev/null +++ b/aggregate_pointers_test.go @@ -0,0 +1,44 @@ +package contracttrace + +import ( + "context" + "strings" + "testing" +) + +func TestAggregatePointerChainsRetainOutsideOrigins(t *testing.T) { + r, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"PointerChainCaller"}, Depth: 4, MaxNodes: 300, + Config: Config{StorageScopes: []StorageScope{{Namespace: "aggregate", DatabaseOrigins: []string{"example.com/sample::knownAggregateDatabase"}}}}}) + if err != nil { + t.Fatal(err) + } + for _, c := range []struct { + owner, query, table, snippet string + outside bool + }{ + {"PublicPointerChain", "pointerChainInputQuery", "pointer_chain_input_records", "(*record).Run((*record).Database)", true}, + {"PublicPointerFields", "pointerChainInputQuery", "pointer_chain_input_records", "(*record.Record).Run((*record.Record).Database)", true}, + {"PublicPointerFields", "pointerFieldQuery", "pointer_field_records", "(**record.Callback)(**record.Database)", true}, + {"OpenPointerChainResult", "pointerChainResultQuery", "pointer_chain_result_records", "(*record).Run((*record).Database)", true}, + {"closedPointerChain", "closedPointerChainQuery", "closed_pointer_chain_records", "(*record).Run((*record).Database)", false}, + } { + known, outside := false, false + tables := map[string]bool{} + for _, e := range r.Relationships { + known = known || e.Kind == "resolved_callback_call" && strings.HasSuffix(e.From, "::"+c.owner) && strings.HasSuffix(e.To, "::"+c.query) + if e.Kind == "sql_read" && strings.HasSuffix(e.From, "::"+c.query) { + tables[e.To] = true + } + } + for _, b := range r.Boundaries { + outside = outside || b.Kind == "partial_function_call" && strings.HasSuffix(b.Node, "::"+c.owner) && b.Evidence.Snippet == c.snippet + } + count := 1 + if c.outside { + count = 2 + } + if !known || outside != c.outside || len(tables) != count || !tables["table:aggregate:"+c.table] || tables["table:"+c.table] != c.outside { + t.Errorf("%s %s: known=%t outside=%t tables=%v", c.owner, c.snippet, known, outside, tables) + } + } +} diff --git a/aggregate_results.go b/aggregate_results.go new file mode 100644 index 0000000..6be3eaa --- /dev/null +++ b/aggregate_results.go @@ -0,0 +1,50 @@ +package contracttrace + +import ( + "fmt" + + "golang.org/x/tools/go/ssa" +) + +func (a *flowAnalysis) seedAggregateResult(value ssa.Value, call *ssa.Call, ix *index, unresolved bool) bool { + if call == nil || a.special[call] != nil || iteratorFactory(call.Common()) != "" { + return false + } + if _, builtin := call.Common().Value.(*ssa.Builtin); builtin { + return false + } + if unresolved { + if len(a.targets(call.Common())) != 0 { + return false + } + } else if !a.outsideFunctionResult(call, ix) { + return false + } + root := fmt.Sprintf("input:result:%s:%s", value.Parent().String(), value.Name()) + return a.seedAggregateOrigin(value, root, true, ix.evidence(call.Pos()), ix) +} + +// Missing indirect targets are classified only after local flow converges, so +// a resolved private factory does not gain a premature outside origin. +func (a *flowAnalysis) seedUnresolvedAggregateResults(functions []*ssa.Function, ix *index) bool { + changed := false + for _, function := range functions { + for _, block := range function.Blocks { + for _, instruction := range block.Instrs { + var value ssa.Value + var call *ssa.Call + switch candidate := instruction.(type) { + case *ssa.Call: + value, call = candidate, candidate + case *ssa.Extract: + value = candidate + call, _ = candidate.Tuple.(*ssa.Call) + } + if value != nil && a.seedAggregateResult(value, call, ix, true) { + changed = true + } + } + } + } + return changed +} diff --git a/aggregate_results_test.go b/aggregate_results_test.go new file mode 100644 index 0000000..37de808 --- /dev/null +++ b/aggregate_results_test.go @@ -0,0 +1,64 @@ +package contracttrace + +import ( + "context" + "strings" + "testing" +) + +func TestUnresolvedAggregateResultRetainsSourceBoundary(t *testing.T) { + r, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"unresolvedRecordResult"}, Depth: 2, MaxNodes: 100}) + if err != nil { + t.Fatal(err) + } + call, result, table := false, false, false + for _, boundary := range r.Boundaries { + call = call || boundary.Kind == "unresolved_function_call" && boundary.Evidence.Snippet == "record.Run(record.Database)" + result = result || boundary.Kind == "aggregate_result_model" && boundary.Evidence.Snippet == "record := factory()" + } + for _, edge := range r.Relationships { + table = table || edge.Kind == "sql_read" && strings.HasSuffix(edge.From, "::unresolvedRecordResult") && edge.To == "table:unresolved_result_records" + } + if !call || !result || !table { + t.Fatalf("unresolved aggregate: call=%t result=%t table=%t", call, result, table) + } +} + +func TestOutsideAggregateResultsKeepKnownAndUnknownOrigins(t *testing.T) { + r, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"AggregateResultCaller"}, Depth: 4, MaxNodes: 350, + Config: Config{StorageScopes: []StorageScope{{Namespace: "aggregate", DatabaseOrigins: []string{"example.com/sample::knownAggregateDatabase"}}}}}) + if err != nil { + t.Fatal(err) + } + for _, c := range []struct { + owner, query, table string + outside bool + }{ + {"OpenAggregateResult", "recordResultQuery", "record_result_records", true}, + {"OpenPointerAggregateResult", "pointerResultQuery", "pointer_result_records", true}, + {"OpenTupleAggregateResult", "tupleResultQuery", "tuple_result_records", true}, + {"OpenSliceAggregateResult", "sliceResultQuery", "slice_result_records", true}, + {"OpenMapAggregateResult", "mapResultQuery", "map_result_records", true}, + {"OpenArrayAggregateResult", "arrayResultQuery", "array_result_records", true}, + {"closedAggregateResult", "closedResultQuery", "closed_result_records", false}, + } { + known, outside := false, false + tables := map[string]bool{} + for _, e := range r.Relationships { + known = known || e.Kind == "resolved_callback_call" && strings.HasSuffix(e.From, "::"+c.owner) && strings.HasSuffix(e.To, "::"+c.query) + if e.Kind == "sql_read" && strings.HasSuffix(e.From, "::"+c.query) { + tables[e.To] = true + } + } + for _, b := range r.Boundaries { + outside = outside || b.Kind == "partial_function_call" && strings.HasSuffix(b.Node, "::"+c.owner) && strings.Contains(b.Evidence.Snippet, ".Run(") + } + count := 1 + if c.outside { + count = 2 + } + if !known || outside != c.outside || len(tables) != count || !tables["table:aggregate:"+c.table] || tables["table:"+c.table] != c.outside { + t.Errorf("%s known=%t outside=%t tables=%v", c.owner, known, outside, tables) + } + } +} diff --git a/allocation_roots.go b/allocation_roots.go new file mode 100644 index 0000000..66be764 --- /dev/null +++ b/allocation_roots.go @@ -0,0 +1,28 @@ +package contracttrace + +import "strings" + +// longestAllocationRoot finds the deepest allocation ID that is either the +// complete address or a field-path prefix. Looking up each peeled prefix keeps +// lookup work proportional to path depth rather than the number of allocations. +// When allowEmpty is false, the empty map key is deliberately ignored; callers +// that historically accepted an empty allocation ID pass true. +func longestAllocationRoot[V any](address string, roots map[string]V, allowEmpty bool) (string, bool) { + if _, exists := roots[address]; exists && (allowEmpty || address != "") { + return address, true + } + for end := len(address); end > 0; { + marker := strings.LastIndex(address[:end], ".field:") + if marker < 0 { + break + } + candidate := address[:marker] + if candidate != "" || allowEmpty { + if _, exists := roots[candidate]; exists { + return candidate, true + } + } + end = marker + } + return "", false +} diff --git a/allocation_roots_test.go b/allocation_roots_test.go new file mode 100644 index 0000000..ef5e628 --- /dev/null +++ b/allocation_roots_test.go @@ -0,0 +1,201 @@ +package contracttrace + +import ( + "fmt" + "math/rand" + "strings" + "testing" +) + +// legacyAllocationRoot is an independent reference for the former full scan. +func legacyAllocationRoot[V any](address string, roots map[string]V, allowEmpty bool) (string, bool) { + selected := "" + found := false + for root := range roots { + matches := address == root || strings.HasPrefix(address, root+".field:") + if !matches || root == "" && !allowEmpty { + continue + } + if !found || len(root) > len(selected) { + selected, found = root, true + } + } + return selected, found +} + +func TestLongestAllocationRootMatchesLegacyMaximumPrefix(t *testing.T) { + cases := []struct { + name string + address string + roots map[string]*int + allowEmpty bool + want string + found bool + }{ + {name: "exact", address: "alloc:one", roots: map[string]*int{"alloc:one": nil}, want: "alloc:one", found: true}, + {name: "deepest field", address: "alloc:one.field:2.field:0", roots: map[string]*int{"alloc:one": nil, "alloc:one.field:2": nil}, want: "alloc:one.field:2", found: true}, + {name: "delimiter in allocation id", address: "alloc.field:embedded.field:1", roots: map[string]*int{"alloc.field:embedded": nil, "alloc": nil}, want: "alloc.field:embedded", found: true}, + {name: "malformed field suffix still selects allocation", address: "alloc.field:nope", roots: map[string]*int{"alloc": nil}, want: "alloc", found: true}, + {name: "miss", address: "other.field:0", roots: map[string]*int{"alloc": nil}, found: false}, + {name: "empty root excluded by field and array state lookup", address: ".field:0", roots: map[string]*int{"": nil}, found: false}, + {name: "empty root included by historical any-array predicate", address: ".field:0", roots: map[string]*int{"": nil}, allowEmpty: true, want: "", found: true}, + {name: "empty exact root included", address: "", roots: map[string]*int{"": nil}, allowEmpty: true, want: "", found: true}, + {name: "empty map excluded", address: "", roots: nil, found: false}, + {name: "plain textual prefix is not a field prefix", address: "allocfoo.field:1", roots: map[string]*int{"alloc": nil}, found: false}, + } + shorter := 7 + deepest := 11 + cases[1].roots["alloc:one"] = &shorter + cases[1].roots["alloc:one.field:2"] = &deepest + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + before := cloneRootTestMap(tc.roots) + got, found := longestAllocationRoot(tc.address, tc.roots, tc.allowEmpty) + if got != tc.want || found != tc.found { + t.Fatalf("longestAllocationRoot(%q) = (%q,%v), want (%q,%v)", tc.address, got, found, tc.want, tc.found) + } + legacy, legacyFound := legacyAllocationRoot(tc.address, tc.roots, tc.allowEmpty) + if got != legacy || found != legacyFound { + t.Fatalf("reference mismatch: optimized=(%q,%v), legacy=(%q,%v)", got, found, legacy, legacyFound) + } + if !equalRootTestMaps(tc.roots, before) { + t.Fatalf("lookup mutated allocation map: before=%v after=%v", before, tc.roots) + } + }) + } +} + +func cloneRootTestMap(input map[string]*int) map[string]*int { + if input == nil { + return nil + } + result := make(map[string]*int, len(input)) + for key, value := range input { + result[key] = value + } + return result +} + +func equalRootTestMaps(left, right map[string]*int) bool { + if len(left) != len(right) || (left == nil) != (right == nil) { + return false + } + for key, value := range left { + other, ok := right[key] + if !ok || other != value { + return false + } + } + return true +} + +func TestLongestAllocationRootGeneratedDifferential(t *testing.T) { + rng := rand.New(rand.NewSource(204871)) + for iteration := 0; iteration < 1500; iteration++ { + roots := map[string]*int{} + base := fmt.Sprintf("alloc:%d", rng.Intn(12)) + address := base + depth := rng.Intn(6) + for field := 0; field < depth; field++ { + if rng.Intn(2) == 0 { + roots[address] = nil + } + address += fmt.Sprintf(".field:%d", rng.Intn(5)) + } + if rng.Intn(2) == 0 { + roots[address] = nil + } + // Add unrelated and delimiter-bearing candidate IDs to exercise exact + // dictionary membership independently of the generated path. + roots[fmt.Sprintf("unrelated:%d", iteration)] = nil + if iteration%3 == 0 { + roots[base+".field:embedded"] = nil + } + allowEmpty := iteration%2 == 0 + if iteration%7 == 0 { + roots[""] = nil + } + got, gotFound := longestAllocationRoot(address, roots, allowEmpty) + want, wantFound := legacyAllocationRoot(address, roots, allowEmpty) + if got != want || gotFound != wantFound { + t.Fatalf("iteration %d address %q: optimized=(%q,%v), legacy=(%q,%v)", iteration, address, got, gotFound, want, wantFound) + } + if gotFound { + if _, keyExists := roots[got]; !keyExists || got == "" && !allowEmpty || address != got && !strings.HasPrefix(address, got+".field:") { + t.Fatalf("iteration %d selected invalid prefix %q for %q", iteration, got, address) + } + for candidate := range roots { + if candidate == "" && !allowEmpty { + continue + } + if (address == candidate || strings.HasPrefix(address, candidate+".field:")) && len(candidate) > len(got) { + t.Fatalf("iteration %d selected %q instead of longer matching root %q", iteration, got, candidate) + } + } + } else { + for candidate := range roots { + if candidate != "" || allowEmpty { + if address == candidate || strings.HasPrefix(address, candidate+".field:") { + t.Fatalf("iteration %d missed matching root %q for %q", iteration, candidate, address) + } + } + } + } + } +} + +func TestLongestAllocationRootDoesNotCacheMissesOrMutateMap(t *testing.T) { + short := 7 + roots := map[string]*int{"short": &short} + before := len(roots) + if got, found := longestAllocationRoot("later.field:0", roots, false); found || got != "" { + t.Fatalf("initial miss = (%q,%v), want empty miss", got, found) + } + // Allocations are discovered incrementally during flow rounds. A previous + // rootless miss must not hide a later inserted allocation. + roots["later"] = &short + if got, found := longestAllocationRoot("later.field:0", roots, false); !found || got != "later" { + t.Fatalf("lookup after insertion = (%q,%v), want later root", got, found) + } + // A nil-valued map entry is still a present, deeper allocation key and must + // shadow a shorter valid root exactly as the old full scan did. + roots["later.field:0"] = nil + if got, found := longestAllocationRoot("later.field:0.field:1", roots, false); !found || got != "later.field:0" { + t.Fatalf("nil-valued deepest root = (%q,%v), want deepest key", got, found) + } + if len(roots) != before+2 || roots["short"] != &short { + t.Fatalf("lookup mutated allocation roots unexpectedly: %#v", roots) + } +} + +func BenchmarkLongestAllocationRootUnrelatedAllocations(b *testing.B) { + for _, count := range []int{100, 10000} { + b.Run(fmt.Sprintf("unrelated_%d", count), func(b *testing.B) { + roots := make(map[string]*int, count) + for i := 0; i < count; i++ { + roots[fmt.Sprintf("allocation:%d", i)] = nil + } + const address = "rootless.field:3.field:1" + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + allocationRootBenchmarkSink, allocationRootBenchmarkFound = longestAllocationRoot(address, roots, false) + } + }) + b.Run(fmt.Sprintf("legacy_scan_%d", count), func(b *testing.B) { + roots := make(map[string]*int, count) + for i := 0; i < count; i++ { + roots[fmt.Sprintf("allocation:%d", i)] = nil + } + const address = "rootless.field:3.field:1" + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + allocationRootBenchmarkSink, allocationRootBenchmarkFound = legacyAllocationRoot(address, roots, false) + } + }) + } +} + +var allocationRootBenchmarkSink string +var allocationRootBenchmarkFound bool diff --git a/analysis_identity.go b/analysis_identity.go new file mode 100644 index 0000000..11d580e --- /dev/null +++ b/analysis_identity.go @@ -0,0 +1,40 @@ +package contracttrace + +import ( + "context" + "fmt" +) + +// verifyAnalysisInputs checks the Go environment and selected package sources +// before rereading every file identity component. Go commands are external +// processes, so final input reads must follow them at completion. +func verifyAnalysisInputs(ctx context.Context, root string, build map[string]string, sourceHash string, assets []string, assetHash string, loaded, resolution []LoadedSource, tests bool, tags string) error { + if err := verifyBuildEnvironment(ctx, root, build); err != nil { + return err + } + if err := verifySelectedPackageSources(ctx, root, build, tests, tags, loaded, assets); err != nil { + return err + } + + currentAssetHash, err := hashFiles(root, assets) + if err != nil { + return err + } + if currentAssetHash != assetHash { + return fmt.Errorf("embedded assets changed during analysis") + } + if err := verifyLoadedSources(ctx, loaded); err != nil { + return err + } + if err := verifyLoadedSources(ctx, resolution); err != nil { + return fmt.Errorf("resolution input check: %w", err) + } + currentSourceHash, _, err := fingerprint(root) + if err != nil { + return err + } + if currentSourceHash != sourceHash { + return fmt.Errorf("source changed during analysis; rerun on a stable revision") + } + return ctx.Err() +} diff --git a/builtin_sql_receiver_test.go b/builtin_sql_receiver_test.go new file mode 100644 index 0000000..c5ba3df --- /dev/null +++ b/builtin_sql_receiver_test.go @@ -0,0 +1,88 @@ +package contracttrace + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestBuiltinSQLCloseTracksBoundAndMethodExpressionReceivers(t *testing.T) { + root := t.TempDir() + files := map[string]string{ + "go.mod": `module example.com/sqlcloseadapters + +go 1.27.0 +`, + "adapters.go": `package sqlcloseadapters +import "database/sql" + +func openAlpha() (*sql.DB, error) { return sql.Open("unused", "alpha") } +func openBeta() (*sql.DB, error) { return sql.Open("unused", "beta") } +func callClose(fn func() error) { _ = fn() } + +func SQLSites() { + alpha, _ := openAlpha() + beta, _ := openBeta() + alpha.Close() // direct alpha control + beta.Close() // direct beta control + closeAlpha := alpha.Close + closeBeta := beta.Close + callClose(closeAlpha) // bound helper alpha + callClose(closeBeta) // bound helper beta + closeExpr := (*sql.DB).Close + _ = closeExpr(alpha) // method-expression alpha +} +`, + } + for name, contents := range files { + path := filepath.Join(root, name) + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(contents), 0600); err != nil { + t.Fatal(err) + } + } + report, err := Trace(context.Background(), Options{Root: root, Seeds: []string{"SQLSites"}, Depth: 7, MaxNodes: 300}) + if err != nil { + t.Fatal(err) + } + resourcesAt := func(snippet string) map[string]bool { + resources := map[string]bool{} + for _, edge := range report.Relationships { + if edge.Kind == "database_close" && strings.Contains(edge.Evidence.Snippet, snippet) { + resources[edge.To] = true + } + } + return resources + } + alpha := resourcesAt("alpha.Close() // direct alpha control") + beta := resourcesAt("beta.Close() // direct beta control") + if len(alpha) != 1 || len(beta) != 1 || alpha == nil || beta == nil { + t.Fatalf("direct Close controls did not establish resource identities: alpha=%v beta=%v", alpha, beta) + } + bound := map[string]bool{} + for _, edge := range report.Relationships { + if edge.From == "example.com/sqlcloseadapters::callClose" && edge.Kind == "database_close" && strings.Contains(edge.Evidence.Snippet, "fn()") { + bound[edge.To] = true + } + } + for resource := range alpha { + if !bound[resource] { + t.Errorf("bound SQL Close helper lost alpha resource %s: observed %v", resource, bound) + } + } + for resource := range beta { + if !bound[resource] { + t.Errorf("bound SQL Close helper lost beta resource %s: observed %v", resource, bound) + } + } + expression := resourcesAt("closeExpr(alpha)") + for resource := range alpha { + if !expression[resource] { + t.Errorf("SQL Close method expression lost its explicit alpha receiver %s: observed %v", resource, expression) + } + } +} diff --git a/call_rules_dispatch_test.go b/call_rules_dispatch_test.go new file mode 100644 index 0000000..95db4a4 --- /dev/null +++ b/call_rules_dispatch_test.go @@ -0,0 +1,210 @@ +package contracttrace + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestConfiguredCallRulesFollowTypedAliasesAndMethodExpressions(t *testing.T) { + root := t.TempDir() + files := map[string]string{ + "go.mod": "module example.com/callclient\n\ngo 1.27.0\nrequire example.com/callapi v0.0.0\nreplace example.com/callapi => ./api\n", + "api/go.mod": "module example.com/callapi\n\ngo 1.27.0\n", + "api/api.go": `package callapi +type Bus struct{} +type Publisher interface { Publish(string, string) } +type ArchiveBus struct{} +type DB struct{} +func (*Bus) Publish(string, string) {} +func (*Bus) Subscribe(string, func(string)) {} +func (*ArchiveBus) Publish(string, string) {} +func (*DB) Query(string) {} +func Dispatch(string, string) {} +`, + "client.go": `package callclient +import ( + "database/sql" + "example.com/callapi" +) +var bus callapi.Bus +var archive callapi.ArchiveBus +var db callapi.DB +var flag bool +func Scenario() { + bus.Publish("docs.ready", "payload") + publish := bus.Publish + invokePublish(publish, "docs.ready", "payload") + (*callapi.Bus).Publish(&bus, "docs.ready", "payload") + (*callapi.Bus).Subscribe(&bus, "docs.ready", Handler) + var publisher callapi.Publisher = &bus + interfacePublish := publisher.Publish + interfacePublish("docs.ready", "payload") + dispatch := callapi.Dispatch + invokePublish(dispatch, "docs.ready", "payload") + subscribe := bus.Subscribe + invokeSubscribe(subscribe, "docs.ready", Handler) + PublishMaybe(bus.Publish, "docs.ready", "payload") + SubscribeMaybe(bus.Subscribe, "docs.ready", Handler) + unrelated := archive.Publish + unrelated("docs.ready", "payload") + query := db.Query + query("SELECT id FROM documents") + (*callapi.DB).Query(&db, "SELECT id FROM documents") + QueryPrimary() + QueryPrimaryExpr() + QueryArchive() + QueryMixed(nil) + PublicOutside(nil) +} +func Handler(string) {} +func invokePublish(fn func(string, string), topic, payload string) { fn(topic, payload) } +func invokeSubscribe(fn func(string, func(string)), topic string, handler func(string)) { fn(topic, handler) } +func PublishMaybe(fn func(string, string), topic, payload string) { fn(topic, payload) } +func SubscribeMaybe(fn func(string, func(string)), topic string, handler func(string)) { fn(topic, handler) } +func OpenPrimary() *sql.DB { db, _ := sql.Open("sqlite3", ":memory:"); return db } +func OpenArchive() *sql.DB { db, _ := sql.Open("sqlite3", ":memory:"); return db } +func QueryPrimary() { db := OpenPrimary(); queryPrimary(db.Exec) } +func QueryPrimaryExpr() { db := OpenPrimary(); (*sql.DB).Exec(db, "SELECT id FROM documents") } +func QueryArchive() { db := OpenArchive(); queryArchive(db.Exec) } +func queryPrimary(exec func(string, ...any) (sql.Result, error)) { exec("SELECT id FROM documents") } +func queryArchive(exec func(string, ...any) (sql.Result, error)) { exec("SELECT id FROM documents") } +func QueryMixed(outside *sql.DB) { + exec := outside.Exec + if flag { exec = OpenPrimary().Exec } + exec("SELECT id FROM documents") +} +func PublicOutside(db *sql.DB) { exec := db.Exec; exec("SELECT id FROM documents") } +`, + "migrations/schema.sql": "CREATE TABLE documents (id INTEGER PRIMARY KEY);\n", + "migrations/archive.sql": "CREATE TABLE documents (id INTEGER PRIMARY KEY);\n", + } + for name, contents := range files { + path := filepath.Join(root, name) + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(contents), 0600); err != nil { + t.Fatal(err) + } + } + one := 1 + config := DefaultConfig() + config.SQLFiles = []string{"migrations/*.sql"} + config.StorageScopes = []StorageScope{ + {Namespace: "primary", SQLFiles: []string{"migrations/schema.sql"}, DatabaseOrigins: []string{"example.com/callclient::OpenPrimary"}}, + {Namespace: "archive", SQLFiles: []string{"migrations/archive.sql"}, DatabaseOrigins: []string{"example.com/callclient::OpenArchive"}}, + } + config.CallRules = []CallRule{ + {Symbol: "example.com/callapi::Bus.Publish", Kind: "event_publish", Argument: 0, Namespace: "documents"}, + {Symbol: "example.com/callapi::Publisher.Publish", Kind: "event_publish", Argument: 0, Namespace: "documents"}, + {Symbol: "example.com/callapi::Bus.Subscribe", Kind: "event_subscribe", Argument: 0, HandlerArgument: &one, Namespace: "documents"}, + {Symbol: "example.com/callapi::Dispatch", Kind: "event_dispatch", Argument: 0, Namespace: "documents"}, + {Symbol: "example.com/callapi::DB.Query", Kind: "sql_query", Argument: 0, Namespace: "primary"}, + {Symbol: "database/sql::DB.Exec", Kind: "sql_query", Argument: 0}, + } + maxSelector := int(^uint(0) >> 1) + config.CallRules = append(config.CallRules, + CallRule{Symbol: "example.com/callapi::Bus.Publish", Kind: "event_publish", Argument: maxSelector, Namespace: "documents"}, + CallRule{Symbol: "example.com/callapi::Bus.Subscribe", Kind: "event_subscribe", Argument: 0, HandlerArgument: &maxSelector, Namespace: "documents"}, + ) + report, analysis, err := TraceWithAnalysis(context.Background(), Options{Root: root, Seeds: []string{"Scenario"}, Depth: 8, MaxNodes: 300, Config: config}) + if err != nil { + t.Fatal(err) + } + topic := "event:documents:docs.ready" + table := "table:primary:documents" + require := func(from, to, kind, snippet string) bool { + for _, edge := range analysis.Relationships { + if edge.From == from && edge.To == to && edge.Kind == kind && edge.Certainty == "possible" && strings.Contains(edge.Evidence.Snippet, snippet) { + return true + } + } + return false + } + if !require("example.com/callclient::invokePublish", topic, "event_publish", "fn(topic, payload)") { + t.Fatal("typed CallRule did not follow a bound-method alias through its helper to the publish target") + } + if !require("example.com/callclient::Scenario", topic, "event_publish", `bus.Publish("docs.ready"`) { + t.Fatal("direct configured method call lost its source-site event role") + } + if !require("example.com/callclient::Scenario", topic, "event_publish", "(*callapi.Bus).Publish") { + t.Fatal("method-expression CallRule did not skip its explicit receiver when selecting argument zero") + } + if !require("example.com/callclient::Scenario", topic, "event_subscribe", "(*callapi.Bus).Subscribe") || !require("example.com/callclient::Handler", topic, "event_handler", "(*callapi.Bus).Subscribe") { + t.Fatal("method-expression Subscribe selectors did not skip its explicit receiver") + } + if !require("example.com/callclient::Scenario", topic, "event_publish", "interfacePublish(\"docs.ready\"") { + t.Fatal("bound interface method did not preserve its declared Publisher.Publish API identity") + } + if !require("example.com/callclient::invokePublish", topic, "event_dispatch", "fn(topic, payload)") { + t.Fatal("typed event_dispatch CallRule did not follow a package function alias through its helper") + } + if !require("example.com/callclient::invokeSubscribe", topic, "event_subscribe", "fn(topic, handler)") { + t.Fatal("bound Subscribe alias did not publish a registration candidate at the API invocation") + } + if !require("example.com/callclient::Handler", topic, "event_handler", "handler") { + t.Fatal("bound Subscribe handler target was not connected from its declaration to the event resource") + } + if !require("example.com/callclient::Scenario", table, "sql_read", "query(\"SELECT id FROM documents\")") { + t.Fatal("configured SQL alias did not use its invocation query and storage namespace") + } + if !require("example.com/callclient::Scenario", table, "sql_read", "(*callapi.DB).Query") { + t.Fatal("SQL method-expression selector did not skip its explicit receiver") + } + if !require("example.com/callclient::queryPrimary", table, "sql_read", "exec(\"SELECT id FROM documents\")") { + t.Fatal("aliased database/sql method did not retain its configured constructor namespace and schema") + } + if !require("example.com/callclient::QueryPrimaryExpr", table, "sql_read", "(*sql.DB).Exec") { + t.Fatal("database/sql method-expression query or receiver selector offset was lost") + } + if !require("example.com/callclient::queryArchive", "table:archive:documents", "sql_read", "exec(\"SELECT id FROM documents\")") { + t.Fatal("second bound database alias did not retain its own configured namespace") + } + if require("example.com/callclient::queryPrimary", "table:archive:documents", "sql_read", "exec(\"SELECT id FROM documents\")") || require("example.com/callclient::queryArchive", table, "sql_read", "exec(\"SELECT id FROM documents\")") { + t.Fatal("distinct bound database closures cross-joined primary and archive namespaces") + } + if !require("example.com/callclient::QueryMixed", table, "sql_read", "exec(\"SELECT id FROM documents\")") { + t.Fatal("known receiver candidate was lost when merged with an outside receiver") + } + if require("example.com/callclient::Scenario", topic, "event_publish", "unrelated(") { + t.Fatal("same-named method on a different receiver type entered the configured rule") + } + knownOutsideHandler := false + for _, boundary := range report.Boundaries { + knownOutsideHandler = knownOutsideHandler || boundary.Node == "example.com/callclient::SubscribeMaybe" && boundary.Kind == "unresolved_handler" + } + if !knownOutsideHandler { + t.Fatal("known callback plus public outside handler alternatives lack unresolved_handler boundary") + } + invalidArgument, invalidHandler := false, false + for _, boundary := range report.Boundaries { + if boundary.Node != "example.com/callclient::Scenario" || boundary.Kind != "invalid_rule_site" { + continue + } + invalidArgument = invalidArgument || strings.Contains(boundary.Reason, "configured argument") && strings.Contains(boundary.Evidence.Snippet, "(*callapi.Bus).Publish") + invalidHandler = invalidHandler || strings.Contains(boundary.Reason, "configured handler") && strings.Contains(boundary.Evidence.Snippet, "(*callapi.Bus).Subscribe") + } + if !invalidArgument || !invalidHandler { + t.Fatalf("overflowing method-expression selectors lacked invalid_rule_site evidence: argument=%t handler=%t", invalidArgument, invalidHandler) + } + unknownNamespace := false + for _, boundary := range report.Boundaries { + unknownNamespace = unknownNamespace || boundary.Node == "example.com/callclient::PublicOutside" && boundary.Kind == "unresolved_database_namespace" + } + if !unknownNamespace { + t.Fatal("public opaque database receiver lacked an unresolved namespace boundary") + } + knownOutsideNamespace := false + for _, boundary := range report.Boundaries { + knownOutsideNamespace = knownOutsideNamespace || boundary.Node == "example.com/callclient::QueryMixed" && boundary.Kind == "unresolved_database_namespace" + } + if !knownOutsideNamespace { + t.Fatal("known and outside bound database receivers lost their uncertainty boundary") + } + if report.ContractComplete { + t.Fatal("configured call rules must retain incomplete contract coverage") + } +} diff --git a/callback_bound_escape_test.go b/callback_bound_escape_test.go new file mode 100644 index 0000000..f79904a --- /dev/null +++ b/callback_bound_escape_test.go @@ -0,0 +1,158 @@ +package contracttrace + +import ( + "bytes" + "context" + "os" + "path/filepath" + "testing" +) + +func TestBoundExternalMethodRetainsReceiverCallbacks(t *testing.T) { + root := t.TempDir() + write := func(name, contents string) { + t.Helper() + path := filepath.Join(root, name) + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(contents), 0600); err != nil { + t.Fatal(err) + } + } + write("go.mod", "module example.com/app\n\ngo 1.27.0\n\nrequire example.com/dep v0.0.0\nreplace example.com/dep => ./dep\n") + write("dep/go.mod", "module example.com/dep\n\ngo 1.27.0\n") + write("dep/record.go", `package dep + +import "database/sql" + +type Record struct { Callback func(*sql.DB, error, func()) } +type Outer struct { *Record } +type Runner interface { ExternalMethod() } + +func (*Record) ExternalMethod() {} +`) + write("app.go", `package app + +import ( + "database/sql" + "example.com/dep" +) + +func retainedQuery(db *sql.DB, err error, next func()) { + db.Query("SELECT id FROM bound_callback_records") + if err != nil { return } + next() +} + +func localOnlyQuery(db *sql.DB, err error, next func()) {} +func expressionQuery(db *sql.DB, err error, next func()) {} + +type LocalRecord struct { Callback func(*sql.DB, error, func()) } +func (*LocalRecord) LocalMethod() {} + +func Caller() { + record := &dep.Record{Callback: retainedQuery} + retainedQuery(nil, nil, func() {}) + run := record.ExternalMethod + run() +} + +func MixedCaller(useExternal bool) { + external := &dep.Record{Callback: retainedQuery} + local := &LocalRecord{Callback: localOnlyQuery} + var run func() + if useExternal { run = external.ExternalMethod } else { run = local.LocalMethod } + run() +} + +func LocalOnlyCaller() { + local := &LocalRecord{Callback: localOnlyQuery} + run := local.LocalMethod + run() +} + +func PromotedCaller() { + outer := &dep.Outer{Record: &dep.Record{Callback: retainedQuery}} + run := outer.ExternalMethod + run() +} + +func SQLHandleCaller(db *sql.DB) { + run := db.Query + run("SELECT id FROM opaque_sql_records") +} + +func UnknownReceiverCaller(record dep.Runner) { + run := record.ExternalMethod + run() +} + +func ExpressionCaller() { + record := &dep.Record{Callback: expressionQuery} + var run func(*dep.Record) + run = (*dep.Record).ExternalMethod + run(record) +} +`) + + options := Options{Root: root, Seeds: []string{"Caller", "MixedCaller", "LocalOnlyCaller", "PromotedCaller", "ExpressionCaller", "SQLHandleCaller", "UnknownReceiverCaller"}, Depth: 4, MaxNodes: 300} + report, analysis, err := TraceWithAnalysis(context.Background(), options) + if err != nil { + t.Fatal(err) + } + assertBoundReceiverCallbacks(t, report) + + var snapshot bytes.Buffer + if err := WriteAnalysis(&snapshot, analysis, true); err != nil { + t.Fatal(err) + } + resumed, err := ReadAnalysis(bytes.NewReader(snapshot.Bytes())) + if err != nil { + t.Fatal(err) + } + resumedReport, err := Explore(context.Background(), resumed, ExploreOptions{Seeds: options.Seeds, Depth: options.Depth, MaxNodes: options.MaxNodes}) + if err != nil { + t.Fatal(err) + } + assertBoundReceiverCallbacks(t, resumedReport) +} + +func assertBoundReceiverCallbacks(t *testing.T, report Report) { + t.Helper() + wantEscapes := map[string]bool{ + "example.com/app::Caller": false, + "example.com/app::MixedCaller": false, + "example.com/app::PromotedCaller": false, + } + localEscape, localOnlyEscape, expressionEscape := false, false, false + spuriousSQLReceiverBoundary, unknownCallbackReceiverBoundary := false, false + externalCallback, escapedSQL, outsideError, unresolvedFunction, tableRead := false, false, false, false, false + for _, edge := range report.Relationships { + if edge.To == "example.com/app::retainedQuery" && edge.Kind == "callback_escape" { + if _, ok := wantEscapes[edge.From]; ok { + wantEscapes[edge.From] = true + } + } + expressionEscape = expressionEscape || edge.From == "example.com/app::ExpressionCaller" && edge.To == "example.com/app::expressionQuery" && edge.Kind == "callback_escape" + localEscape = localEscape || edge.From == "example.com/app::MixedCaller" && edge.To == "example.com/app::localOnlyQuery" && edge.Kind == "callback_escape" + localOnlyEscape = localOnlyEscape || edge.From == "example.com/app::LocalOnlyCaller" && edge.To == "example.com/app::localOnlyQuery" && edge.Kind == "callback_escape" + tableRead = tableRead || edge.From == "example.com/app::retainedQuery" && edge.To == "table:bound_callback_records" && edge.Kind == "sql_read" + } + for _, boundary := range report.Boundaries { + externalCallback = externalCallback || boundary.Node == "example.com/app::retainedQuery" && boundary.Kind == "external_callback" + escapedSQL = escapedSQL || boundary.Node == "example.com/app::retainedQuery" && boundary.Kind == "escaped_sql_input" + outsideError = outsideError || boundary.Kind == "outside_error_input" && boundary.Evidence.File == "app.go" && boundary.Evidence.Snippet == "func retainedQuery(db *sql.DB, err error, next func()) {" + unresolvedFunction = unresolvedFunction || boundary.Node == "example.com/app::retainedQuery" && (boundary.Kind == "partial_function_call" || boundary.Kind == "unresolved_function_call") && boundary.Evidence.Snippet == "next()" + spuriousSQLReceiverBoundary = spuriousSQLReceiverBoundary || boundary.Kind == "unresolved_callback_receiver" && boundary.Evidence.Snippet == `run("SELECT id FROM opaque_sql_records")` + unknownCallbackReceiverBoundary = unknownCallbackReceiverBoundary || boundary.Node == "example.com/app::UnknownReceiverCaller" && boundary.Kind == "unresolved_callback_receiver" && boundary.Evidence.Snippet == "run()" + } + for owner, found := range wantEscapes { + if !found { + t.Errorf("%s lost callback from its bound receiver or explicit receiver argument", owner) + } + } + if localEscape || localOnlyEscape || !expressionEscape || spuriousSQLReceiverBoundary || !unknownCallbackReceiverBoundary || !externalCallback || !escapedSQL || !outsideError || !unresolvedFunction || !tableRead { + t.Fatalf("bound method callback scope is wrong: unrelatedLocalEscape=%t localOnlyEscape=%t expressionEscape=%t spuriousSQLReceiverBoundary=%t unknownCallbackReceiver=%t external=%t sqlInput=%t errorInput=%t functionInput=%t tableRead=%t", localEscape, localOnlyEscape, expressionEscape, spuriousSQLReceiverBoundary, unknownCallbackReceiverBoundary, externalCallback, escapedSQL, outsideError, unresolvedFunction, tableRead) + } +} diff --git a/callback_cache.go b/callback_cache.go new file mode 100644 index 0000000..765ab1b --- /dev/null +++ b/callback_cache.go @@ -0,0 +1,41 @@ +package contracttrace + +import ( + "go/types" + "maps" + + "golang.org/x/tools/go/ssa" +) + +// Only callback candidate walks are cached. Storage-cell enumeration and +// iterator/effect walks remain live; their dependencies exceed these reads. +type callbackStorageCache struct { + roots flowValue + reads map[string]uint64 + types map[string]types.Type + mapSizes map[string]int + functions map[*ssa.Function]bool + volatile bool +} + +func (c *callbackStorageCache) valid(a *flowAnalysis, roots flowValue) bool { + if !maps.Equal(c.roots.functions, roots.functions) || !maps.Equal(c.roots.addresses, roots.addresses) || !maps.Equal(c.roots.effects, roots.effects) { + return false + } + for address, version := range c.reads { + if a.memoryVersions[address] != version { + return false + } + } + for address, typ := range c.types { + if a.addressTypes[address] != typ { + return false + } + } + for address, size := range c.mapSizes { + if len(a.mapEntries[address]) != size { + return false + } + } + return true +} diff --git a/callback_cache_test.go b/callback_cache_test.go new file mode 100644 index 0000000..3bd921b --- /dev/null +++ b/callback_cache_test.go @@ -0,0 +1,82 @@ +package contracttrace + +import ( + "fmt" + "go/token" + "go/types" + "testing" + + "golang.org/x/tools/go/ssa" +) + +func TestCallbackCacheTracksMapMembershipAndDynamicTypes(t *testing.T) { + signature := types.NewSignatureType(nil, nil, nil, nil, nil, false) + first, second := &ssa.Function{Signature: signature}, &ssa.Function{Signature: signature} + a := &flowAnalysis{values: map[ssa.Value]flowValue{}, memory: map[string]flowValue{}, addressTypes: map[string]types.Type{}} + value := ssa.NewConst(nil, types.NewMap(types.Typ[types.String], signature)) + root := emptyFlow() + root.addresses["map"] = true + a.put(value, root) + callback := emptyFlow() + callback.functions[first] = true + a.storeMap("map", "a", callback) + if !a.accessibleCallbacks(value)[first] { + t.Fatal("initial map callback missing") + } + callback = emptyFlow() + callback.functions[second] = true + a.storeMap("map", "b", callback) + if !a.accessibleCallbacks(value)[second] { + t.Fatal("new map entry hidden by cached walk") + } + iface := ssa.NewConst(nil, types.NewInterfaceType(nil, nil).Complete()) + root = emptyFlow() + root.addresses["object"] = true + a.put(iface, root) + a.addressTypes["object"] = types.NewStruct(nil, nil) + if len(a.accessibleCallbacks(iface)) != 0 { + t.Fatal("empty object invented callback") + } + a.addressTypes["object"] = types.NewStruct([]*types.Var{types.NewVar(token.NoPos, nil, "Run", signature)}, nil) + a.store("object.field:0", callback) + if !a.accessibleCallbacks(iface)[second] { + t.Fatal("new dynamic object shape hidden by cached walk") + } +} + +func TestCallbackStorageWarmWalkRetainsMutation(t *testing.T) { + signature := types.NewSignatureType(nil, nil, nil, nil, nil, false) + fields := []*types.Var{} + for i := 0; i < 32; i++ { + fields = append(fields, types.NewVar(token.NoPos, nil, fmt.Sprintf("Callback%d", i), signature)) + } + value := ssa.NewConst(nil, types.NewPointer(types.NewStruct(fields, nil))) + a := &flowAnalysis{values: map[ssa.Value]flowValue{}, memory: map[string]flowValue{}} + root := emptyFlow() + root.addresses["record"] = true + a.put(value, root) + first, second := &ssa.Function{Signature: signature}, &ssa.Function{Signature: signature} + callback := emptyFlow() + callback.functions[first] = true + for i := 0; i < 32; i++ { + a.store(fmt.Sprintf("record.field:%d", i), callback) + } + if !a.accessibleCallbacks(value)[first] { + t.Fatal("cold walk lost callback") + } + allocations := testing.AllocsPerRun(5, func() { + if !a.accessibleCallbacks(value)[first] { + t.Fatal("warm walk lost callback") + } + }) + if allocations > 40 { + t.Errorf("unchanged 32-field storage walk allocates %.0f objects; want <=40", allocations) + } + callback = emptyFlow() + callback.functions[second] = true + a.store("record.field:31", callback) + found := a.accessibleCallbacks(value) + if !found[first] || !found[second] { + t.Fatal("function-only mutation was hidden") + } +} diff --git a/callback_escape.go b/callback_escape.go new file mode 100644 index 0000000..d8ec839 --- /dev/null +++ b/callback_escape.go @@ -0,0 +1,441 @@ +package contracttrace + +import ( + "fmt" + "go/token" + "go/types" + + "golang.org/x/tools/go/ssa" +) + +// Dependency calls can retain and invoke function arguments outside the local +// graph. This records escape candidates, not proof of callback invocation. +func (a *flowAnalysis) callbackEscape(call ssa.CallInstruction, ix *index) bool { + if !a.callbackOutside(call.Common(), ix) { + return false + } + changed := false + record := func(callback *ssa.Function) { + if callback == nil || ix.owner(callback) == "" { + return + } + if a.callbackEscapes == nil { + a.callbackEscapes = map[ssa.CallInstruction]map[*ssa.Function]bool{} + } + if a.callbackEscapes[call] == nil { + a.callbackEscapes[call] = map[*ssa.Function]bool{} + } + a.callbackEscapes[call][callback] = true + if a.markCallbackInputs(callback, ix) { + changed = true + } + } + for _, argument := range call.Common().Args { + for _, callback := range sortedFunctions(a.accessibleCallbacks(argument)) { + record(callback) + } + } + callable := a.get(call.Common().Value) + for _, target := range sortedFunctions(a.targets(call.Common())) { + if !callbackTargetOutside(target, ix) { + continue + } + receiver, ok := callable.boundReceivers[target] + if !ok || receiver.typ == nil { + continue + } + for _, address := range sortedKeys(receiver.addresses) { + root := emptyFlow() + root.addresses[address] = true + for _, callback := range sortedFunctions(a.accessibleStorageTrackedFlow(root, receiver.typ, nil, nil, nil, nil, nil, nil)) { + record(callback) + } + } + } + return changed +} + +func callbackTargetOutside(target *ssa.Function, ix *index) bool { + return target != nil && ix != nil && (ix.owner(target) == "" || len(target.Blocks) == 0) +} + +func callbackStorageType(typ types.Type, seen map[types.Type]bool) bool { + if typ == nil { + return false + } + typ = types.Unalias(typ) + if seen[typ] { + return false + } + seen[typ] = true + switch underlying := typ.Underlying().(type) { + case *types.Signature: + return true + case *types.Pointer: + return callbackStorageType(underlying.Elem(), seen) + case *types.Array: + return callbackStorageType(underlying.Elem(), seen) + case *types.Slice: + return callbackStorageType(underlying.Elem(), seen) + case *types.Chan: + return callbackStorageType(underlying.Elem(), seen) + case *types.Map: + return callbackStorageType(underlying.Key(), seen) || callbackStorageType(underlying.Elem(), seen) + case *types.Interface: + return true + case *types.Struct: + for field := 0; field < underlying.NumFields(); field++ { + member := underlying.Field(field) + if (member.Exported() || embeddedRecord(member)) && callbackStorageType(member.Type(), seen) { + return true + } + } + } + return false +} + +func (a *flowAnalysis) callbackOutside(common *ssa.CallCommon, ix *index) bool { + if iteratorFactory(common) != "" { + return false + } + outside := a.get(common.Value).functionUnknown || common.IsInvoke() && a.get(common.Value).interfaceUnknown + for target := range a.targets(common) { + outside = outside || callbackTargetOutside(target, ix) + } + return outside +} + +func (a *flowAnalysis) boundCallbackReceiverUnknown(common *ssa.CallCommon, ix *index) bool { + callable := a.get(common.Value) + for _, target := range sortedFunctions(a.targets(common)) { + if !callbackTargetOutside(target, ix) { + continue + } + if receiver, ok := callable.boundReceivers[target]; ok && callbackStorageType(receiver.typ, map[types.Type]bool{}) { + if receiver.unknown || callable.boundReceiverUnknown { + return true + } + } + } + return false +} + +func (a *flowAnalysis) markCallbackInputs(callback *ssa.Function, ix *index) bool { + changed := false + for _, parameter := range callback.Params { + if a.seedFormatInput(parameter) { + changed = true + } + if a.seedErrorInput(parameter, ix, "This callback can escape through an exported result, public storage or a dependency/unmodeled call and receive outside error values.") { + changed = true + } + if a.seedAggregateInput(parameter, ix) { + changed = true + } + if isFunctionType(parameter.Type()) { + unknown := emptyFlow() + unknown.functionUnknown = true + if a.put(parameter, unknown) { + changed = true + } + } + if !isSQLHandleType(parameter.Type()) { + continue + } + value := emptyFlow() + value.sqlUnknown = true + if a.put(parameter, value) { + changed = true + } + } + return changed +} + +func (a *flowAnalysis) callbackReturnEscape(returned *ssa.Return, ix *index) bool { + fn := returned.Parent() + if fn.Object() == nil || !fn.Object().Exported() { + return false + } + changed := false + for _, result := range returned.Results { + for _, callback := range sortedFunctions(a.accessibleCallbacks(result)) { + if ix.owner(callback) == "" { + continue + } + if a.callbackReturns == nil { + a.callbackReturns = map[*ssa.Return]map[*ssa.Function]bool{} + } + if a.callbackReturns[returned] == nil { + a.callbackReturns[returned] = map[*ssa.Function]bool{} + } + a.callbackReturns[returned][callback] = true + if a.markCallbackInputs(callback, ix) { + changed = true + } + } + } + return changed +} + +// Follow public fields and container contents through modeled memory. Address/type pairs break +// pointer cycles; the total traversal budget exposes widening instead of making +// a recursive or very large record appear completely analyzed. +func (a *flowAnalysis) accessibleCallbacks(value ssa.Value) map[*ssa.Function]bool { + roots := a.get(value) + if cached := a.callbackCache[value]; cached != nil && cached.valid(a, roots) { + return cached.functions + } + cached := &callbackStorageCache{roots: roots, reads: map[string]uint64{}, types: map[string]types.Type{}, mapSizes: map[string]int{}} + found := a.accessibleStorageTrackedFlow(roots, value.Type(), nil, nil, nil, nil, cached, nil) + if !cached.volatile { + if a.callbackCache == nil { + a.callbackCache = map[ssa.Value]*callbackStorageCache{} + } + cached.functions = found + a.callbackCache[value] = cached + } + return found +} + +func (a *flowAnalysis) accessibleValues(value ssa.Value, sqlCells map[string]bool) map[*ssa.Function]bool { + return a.accessibleStorage(value, sqlCells, nil, nil) +} + +func (a *flowAnalysis) accessibleStorage(value ssa.Value, sqlCells, functionCells, interfaceCells map[string]bool) map[*ssa.Function]bool { + return a.accessibleStorageTracked(value, sqlCells, functionCells, interfaceCells, nil, nil, nil) +} + +func (a *flowAnalysis) accessibleStorageTracked(value ssa.Value, sqlCells, functionCells, interfaceCells map[string]bool, outsideCells map[string]types.Type, cache *callbackStorageCache, effects map[string]bool) map[*ssa.Function]bool { + return a.accessibleStorageTrackedFlow(a.get(value), value.Type(), sqlCells, functionCells, interfaceCells, outsideCells, cache, effects) +} + +func (a *flowAnalysis) accessibleStorageTrackedFlow(root flowValue, typ types.Type, sqlCells, functionCells, interfaceCells map[string]bool, outsideCells map[string]types.Type, cache *callbackStorageCache, effects map[string]bool) map[*ssa.Function]bool { + markOutsideCell := func(address string, typ types.Type) { + if outsideCells != nil && (isErrorType(typ) || isStringType(typ) || isScalarType(typ)) { + outsideCells[address] = typ + } + } + read := func(address string) flowValue { + if cache != nil { + cache.reads[address] = a.memoryVersions[address] + } + return a.memory[address] + } + type entry struct { + value flowValue + typ types.Type + } + type identity struct { + address string + typ types.Type + } + queue := []entry{{root, typ}} + enqueue := func(contents flowValue, typ types.Type) { + if len(contents.functions) == 0 && len(contents.addresses) == 0 && len(contents.effects) == 0 { + return + } + if len(queue) >= maxFlowValues*maxFlowValues { + a.coverage.Widened = true + return + } + queue = append(queue, entry{contents, typ}) + } + seen := map[identity]bool{} + found := emptyFlow() + seenIterators := map[string]bool{} + for head := 0; head < len(queue); head++ { + if head >= maxFlowValues*maxFlowValues { + a.coverage.Widened = true + break + } + current := queue[head] + if current.typ == nil { + continue + } + if cache != nil && len(current.value.effects) != 0 { + cache.volatile = true + } + for _, effect := range sortedKeys(current.value.effects) { + if effects != nil { + effects[effect] = true + } + if summary, ok := a.iterators[effect]; ok && !seenIterators[effect] { + seenIterators[effect] = true + for _, candidate := range a.iteratorValues(summary) { + enqueue(candidate.value, candidate.typ) + } + } + } + functions := emptyFlow() + functions.functions = current.value.functions + a.merge(&found, functions) + for _, address := range sortedKeys(current.value.addresses) { + key := identity{address, current.typ} + if seen[key] { + continue + } + seen[key] = true + switch typ := current.typ.Underlying().(type) { + case *types.Interface: + if cache != nil { + cache.types[address] = a.addressTypes[address] + } + if concrete := a.addressTypes[address]; concrete != nil { + contents := emptyFlow() + contents.addresses[address] = true + enqueue(contents, concrete) + } + case *types.Pointer: + markOutsideCell(address, typ.Elem()) + if interfaceCells != nil && isInterfaceType(typ.Elem()) { + interfaceCells[address] = true + } + if functionCells != nil && isFunctionType(typ.Elem()) { + functionCells[address] = true + } + pointed := emptyFlow() + a.merge(&pointed, read(address)) + if inlineAggregate(typ.Elem()) { + pointed.addresses[address] = true + } + enqueue(pointed, typ.Elem()) + case *types.Array, *types.Slice, *types.Chan: + var element types.Type + suffix := ".element" + if array, ok := typ.(*types.Array); ok { + element = array.Elem() + } else if slice, ok := typ.(*types.Slice); ok { + element = slice.Elem() + } else { + element = typ.(*types.Chan).Elem() + suffix = ".sent" + } + cell := address + suffix + markOutsideCell(cell, element) + contents := emptyFlow() + if interfaceCells != nil && isInterfaceType(element) { + interfaceCells[cell] = true + } + if sqlCells != nil && isSQLHandleType(element) { + sqlCells[cell] = true + } + if functionCells != nil && isFunctionType(element) { + functionCells[cell] = true + } + a.merge(&contents, read(cell)) + if inlineAggregate(element) { + contents.addresses[cell] = true + } + enqueue(contents, element) + case *types.Map: + markOutsideCell(address+".key:*", typ.Elem()) + markOutsideCell(address+".keys", typ.Key()) + for entryAddress := range a.mapEntries[address] { + markOutsideCell(entryAddress, typ.Elem()) + } + if cache != nil { + cache.mapSizes[address] = len(a.mapEntries[address]) + } + if interfaceCells != nil && isInterfaceType(typ.Elem()) { + interfaceCells[address+".key:*"] = true + } + if interfaceCells != nil && isInterfaceType(typ.Key()) { + interfaceCells[address+".keys"] = true + } + if functionCells != nil && isFunctionType(typ.Elem()) { + functionCells[address+".key:*"] = true + } + if sqlCells != nil && isSQLHandleType(typ.Elem()) { + sqlCells[address+".key:*"] = true + } + for _, entryAddress := range sortedKeys(a.mapEntries[address]) { + enqueue(read(entryAddress), typ.Elem()) + } + enqueue(read(address+".keys"), typ.Key()) + case *types.Struct: + for field := 0; field < typ.NumFields(); field++ { + member := typ.Field(field) + if !member.Exported() && !embeddedRecord(member) { + continue + } + fieldAddress := fmt.Sprintf("%s.field:%d", address, field) + contents := emptyFlow() + a.merge(&contents, read(fieldAddress)) + fieldType := typ.Field(field).Type() + markOutsideCell(fieldAddress, fieldType) + if interfaceCells != nil && isInterfaceType(fieldType) { + interfaceCells[fieldAddress] = true + } + if functionCells != nil && isFunctionType(fieldType) { + functionCells[fieldAddress] = true + } + if sqlCells != nil && isSQLHandleType(fieldType) { + sqlCells[fieldAddress] = true + } + if inlineAggregate(fieldType) { + contents.addresses[fieldAddress] = true + } + enqueue(contents, fieldType) + } + } + } + } + return found.functions +} + +func embeddedRecord(field *types.Var) bool { + if !field.Embedded() { + return false + } + typ := field.Type().Underlying() + if pointer, ok := typ.(*types.Pointer); ok { + typ = pointer.Elem().Underlying() + } + _, record := typ.(*types.Struct) + return record +} + +func (a *flowAnalysis) callbackReturnRelationships(returned *ssa.Return, ix *index) { + evidence := ix.evidence(returned.Pos()) + if returned.Pos() == token.NoPos { + evidence = ix.funcs[ix.owner(returned.Parent())].node.Evidence + evidence.Origin = "synthetic_declaration" + } + for _, callback := range sortedFunctions(a.callbackReturns[returned]) { + owner := ix.owner(callback) + ix.edges = append(ix.edges, Relationship{From: ix.owner(returned.Parent()), To: owner, Kind: "callback_return_escape", Certainty: "possible", Evidence: evidence}) + ix.boundaries = append(ix.boundaries, Boundary{Node: owner, Kind: "external_callback", Reason: "function-value candidate is returned by an exported function; callers outside the selected build may retain/invoke it with unknown arguments and timing", Evidence: evidence}) + for _, parameter := range callback.Params { + if isSQLHandleType(parameter.Type()) { + ix.boundaries = append(ix.boundaries, Boundary{Node: owner, Kind: "escaped_sql_input", Reason: "callback with SQL-handle parameters is returned through an exported API; outside callback inputs remain possible", Evidence: evidence}) + break + } + } + } +} + +func (a *flowAnalysis) callbackEscapeRelationships(call ssa.CallInstruction, ix *index) { + for _, callback := range sortedFunctions(a.callbackEscapes[call]) { + owner := ix.owner(callback) + ix.callEdge(ix.owner(call.Parent()), owner, "callback_escape", "possible", call) + ix.boundaries = append(ix.boundaries, Boundary{Node: owner, Kind: "external_callback", Reason: "function-value candidate is passed to a dependency or unmodeled body; retention, invocation, supplied arguments and timing require investigation", Evidence: ix.callEvidence(call)}) + for _, parameter := range callback.Params { + if isSQLHandleType(parameter.Type()) { + ix.boundaries = append(ix.boundaries, Boundary{Node: owner, Kind: "escaped_sql_input", Reason: "callback with SQL-handle parameters is passed to a dependency or unmodeled body; unknown callback inputs remain possible, but invocation is not established", Evidence: ix.callEvidence(call)}) + break + } + } + } + if a.callbackOutside(call.Common(), ix) { + for _, argument := range call.Common().Args { + callback := a.get(argument) + if callback.functionNil { + ix.boundaries = append(ix.boundaries, Boundary{Node: ix.owner(call.Parent()), Kind: "unresolved_callback_nil", Reason: "an escaping function argument retains a typed nil callback alternative alongside any local callback candidates; retention or successful invocation of the nil value is not inferred", Evidence: ix.callEvidence(call)}) + break + } + } + } + if a.boundCallbackReceiverUnknown(call.Common(), ix) { + ix.boundaries = append(ix.boundaries, Boundary{Node: ix.owner(call.Parent()), Kind: "unresolved_callback_receiver", Reason: "a bound external method receiver has widened or unresolved storage candidates; exported callback fields on omitted receiver alternatives may also escape", Evidence: ix.callEvidence(call)}) + } +} diff --git a/candidates_test.go b/candidates_test.go new file mode 100644 index 0000000..106f46e --- /dev/null +++ b/candidates_test.go @@ -0,0 +1,68 @@ +package contracttrace + +import ( + "encoding/json" + "reflect" + "strings" + "testing" +) + +func TestGroupedCandidatesRemainInspectableAfterSerialization(t *testing.T) { + want := []string{"external.a", "external.b", "external.c", "external.d", "external.e", "external.f", "external.g"} + r := Report{} + // Duplicates and out-of-order discovery must not lose any distinct target. + for _, name := range []string{"external.g", "external.b", "external.a", "external.e", "external.f", "external.c", "external.d", "external.b"} { + r.Boundaries = append(r.Boundaries, Boundary{Node: "caller", Kind: "external_call", Reason: "external bodies excluded", Examples: []string{name}}) + } + groupBoundaries(&r) + bytes, err := json.Marshal(r) + if err != nil { + t.Fatal(err) + } + var restored Report + if err := json.Unmarshal(bytes, &restored); err != nil { + t.Fatal(err) + } + groupBoundaries(&restored) + if len(restored.Boundaries) != 1 { + t.Fatalf("want one grouped source site, got %d", len(restored.Boundaries)) + } + b := restored.Boundaries[0] + if b.CandidateCount != 7 || !reflect.DeepEqual(b.Candidates, want) || !reflect.DeepEqual(b.Examples, want[:5]) { + t.Fatalf("discovered targets lost across grouping/serialization: %+v", b) + } +} + +func TestCandidateInventoryAvailabilityIsExplicitInMarkdown(t *testing.T) { + legacy := Markdown(Report{Boundaries: []Boundary{{CandidateCount: 9, Examples: []string{"a"}}}}) + if !strings.Contains(legacy, "1 entries lack a full retained candidate inventory") { + t.Fatal("older snapshot lost targets without a visible warning") + } + fresh := Markdown(Report{Boundaries: []Boundary{{CandidateCount: 1, Examples: []string{"a"}, Candidates: []string{"a"}}}}) + if strings.Contains(fresh, "entries lack") { + t.Fatal("fully retained inventory was reported missing") + } +} + +func TestFrontierRetainsEachSourceSite(t *testing.T) { + ix := &index{funcs: map[string]*function{ + "seed": {node: Node{ID: "seed", Name: "Seed"}}, + "target": {node: Node{ID: "target", Name: "Target"}}, + }, edges: []Relationship{ + {From: "seed", To: "target", Kind: "call", Evidence: Evidence{File: "main.go", Line: 10}}, + {From: "seed", To: "target", Kind: "call", Evidence: Evidence{File: "main.go", Line: 20}}, + }} + r, err := ix.expand(Options{Seeds: []string{"Seed"}, Depth: 1, MaxNodes: 1}, Coverage{}) + if err != nil { + t.Fatal(err) + } + groupBoundaries(&r) + if len(r.Boundaries) != 2 || r.Boundaries[0].Evidence.Line != 10 || r.Boundaries[1].Evidence.Line != 20 { + t.Fatalf("distinct excluded call sites were collapsed: %+v", r.Boundaries) + } + for _, b := range r.Boundaries { + if b.CandidateCount != 1 || !reflect.DeepEqual(b.Candidates, []string{"target"}) { + t.Fatalf("call-site candidate missing: %+v", b) + } + } +} diff --git a/channel_zero_functions.go b/channel_zero_functions.go new file mode 100644 index 0000000..cc65995 --- /dev/null +++ b/channel_zero_functions.go @@ -0,0 +1,113 @@ +package contracttrace + +import ( + "go/token" + "go/types" + "sort" + + "golang.org/x/tools/go/ssa" +) + +// channelClosureIndex contains typed builtin-close sites and the channel +// allocation candidates that have reached those sites in the flow fixed +// point. Sites are collected once; refreshChannelClosures is intended to run +// once per flow round, rather than scanning all functions for every receive. +type channelClosureIndex struct { + calls []ssa.CallInstruction + closed map[string]map[token.Pos]bool + widened bool +} + +func newChannelClosureIndex(funcs []*ssa.Function) *channelClosureIndex { + index := &channelClosureIndex{closed: map[string]map[token.Pos]bool{}} + for _, function := range funcs { + for _, block := range function.Blocks { + for _, instruction := range block.Instrs { + call, ok := instruction.(ssa.CallInstruction) + if !ok { + continue + } + common := call.Common() + builtin, ok := common.Value.(*ssa.Builtin) + if ok && builtin.Name() == "close" && len(common.Args) == 1 { + index.calls = append(index.calls, call) + } + } + } + } + return index +} + +// refreshChannelClosures monotonically adds allocation/source pairs whose +// values now reach a typed close operand. Call, defer and go instructions all +// implement ssa.CallInstruction, so they share this source-backed handling. +func (a *flowAnalysis) refreshChannelClosures(index *channelClosureIndex) bool { + if index == nil { + return false + } + changed := false + for _, call := range index.calls { + common := call.Common() + if len(common.Args) != 1 { + continue + } + for _, address := range sortedKeys(a.get(common.Args[0]).addresses) { + sources := index.closed[address] + if sources[common.Pos()] { + continue + } + if len(sources) >= maxFlowValues { + index.widened = true + a.coverage.Widened = true + continue + } + if sources == nil { + sources = map[token.Pos]bool{} + index.closed[address] = sources + } + sources[common.Pos()] = true + changed = true + } + } + return changed +} + +// receiveWithClosedChannelValue retains the existing unordered send model and +// adds a possible typed zero value when a modeled channel allocation has a +// source-backed close site. A closed channel can eventually yield the element +// zero value, but this deliberately does not model queue occupancy or +// scheduling: for a channel with modeled sends, the candidate is paired with +// an explicit receive-order boundary by the caller. +func (a *flowAnalysis) receiveWithClosedChannelValue(channel ssa.Value, index *channelClosureIndex) (flowValue, []token.Pos, bool) { + result := a.receive(channel) + orderUncertain := len(result.functions) > 0 || len(result.addresses) > 0 || result.functionUnknown || result.functionNil || result.zeroAggregateUnknown || len(result.zeroAggregates) > 0 + if channel == nil || index == nil { + return result, nil, false + } + channelType, ok := types.Unalias(channel.Type()).Underlying().(*types.Chan) + if !ok || !isFunctionType(channelType.Elem()) && !inlineAggregate(channelType.Elem()) { + return result, nil, false + } + + positions := map[token.Pos]bool{} + for _, address := range sortedKeys(a.get(channel).addresses) { + for pos := range index.closed[address] { + positions[pos] = true + } + } + closeSites := make([]token.Pos, 0, len(positions)) + for pos := range positions { + closeSites = append(closeSites, pos) + } + sort.Slice(closeSites, func(i, j int) bool { return closeSites[i] < closeSites[j] }) + if len(closeSites) == 0 { + return result, nil, false + } + + if isFunctionType(channelType.Elem()) { + result.functionNil = true + } else { + a.addZeroAggregate(&result, channelType.Elem()) + } + return result, closeSites, orderUncertain +} diff --git a/channel_zero_functions_test.go b/channel_zero_functions_test.go new file mode 100644 index 0000000..181100a --- /dev/null +++ b/channel_zero_functions_test.go @@ -0,0 +1,125 @@ +package contracttrace + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestClosedChannelZeroCallbackCandidatesKeepSourceAndKnownAlternatives(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example.com/channelzero\n\ngo 1.27.0\n"), 0o600); err != nil { + t.Fatal(err) + } + source := `package channelzero +import "sync" +func knownTask() {} +type Holder struct { Run func() } +type Nested struct { Inner Holder } +func closeTasks(ch chan func()) { close(ch) } +func BufferedDrain() { ch:=make(chan func(),1); ch<-knownTask; close(ch); _= <-ch; group:=&sync.WaitGroup{}; group.Go(<-ch) } +func HelperDrain() { ch:=make(chan func(),1); ch<-knownTask; closeTasks(ch); _= <-ch; group:=&sync.WaitGroup{}; group.Go(<-ch) } +func EmptyClosed() { ch:=make(chan func()); close(ch); group:=&sync.WaitGroup{}; group.Go(<-ch) } +func SelectDrain() { ch:=make(chan func(),1); ch<-knownTask; close(ch); _= <-ch; group:=&sync.WaitGroup{}; select { case task:=<-ch: group.Go(task); default: } } +func ClosedHolderDrain() { ch:=make(chan Holder,1); ch<-Holder{Run:knownTask}; close(ch); _= <-ch; holder:=<-ch; group:=&sync.WaitGroup{}; group.Go(holder.Run) } +func ClosedEmptyHolder() { ch:=make(chan Holder); close(ch); holder:=<-ch; group:=&sync.WaitGroup{}; group.Go(holder.Run) } +func ClosedEmptyNested() { ch:=make(chan Nested); close(ch); holder:=<-ch; group:=&sync.WaitGroup{}; group.Go(holder.Inner.Run) } +func ClosedPointerDrain() { ch:=make(chan *Holder,1); ch<-&Holder{Run:knownTask}; close(ch); _= <-ch; holder:=<-ch; group:=&sync.WaitGroup{}; group.Go(holder.Run) } +func UnclosedKnown() { ch:=make(chan func(),1); ch<-knownTask; group:=&sync.WaitGroup{}; group.Go(<-ch) } +` + if err := os.WriteFile(filepath.Join(root, "channels.go"), []byte(source), 0o600); err != nil { + t.Fatal(err) + } + seeds := []string{"BufferedDrain", "HelperDrain", "EmptyClosed", "SelectDrain", "ClosedHolderDrain", "ClosedEmptyHolder", "ClosedEmptyNested", "ClosedPointerDrain", "UnclosedKnown"} + report, err := Trace(context.Background(), Options{Root: root, Seeds: seeds, Depth: 5, MaxNodes: 300}) + if err != nil { + t.Fatal(err) + } + if report.Coverage.Truncated || report.ContractComplete { + t.Fatalf("probe must be untruncated and incomplete: truncated=%t complete=%t", report.Coverage.Truncated, report.ContractComplete) + } + + for _, test := range []struct { + name string + snippet string + known bool + nilCallback bool + closed bool + }{ + {"BufferedDrain", "group.Go(<-ch)", true, true, true}, + {"HelperDrain", "group.Go(<-ch)", true, true, true}, + {"EmptyClosed", "group.Go(<-ch)", false, true, true}, + {"SelectDrain", "group.Go(task)", true, true, true}, + {"ClosedHolderDrain", "group.Go(holder.Run)", true, true, true}, + {"ClosedEmptyHolder", "group.Go(holder.Run)", false, true, true}, + {"ClosedEmptyNested", "group.Go(holder.Inner.Run)", false, true, true}, + {"ClosedPointerDrain", "group.Go(holder.Run)", true, false, false}, + {"UnclosedKnown", "group.Go(<-ch)", true, false, false}, + } { + t.Run(test.name, func(t *testing.T) { + line := sourceLine(source, "func "+test.name+"(") + var resource string + operationCount := 0 + for _, edge := range report.Relationships { + if edge.Kind != "waitgroup_go" || !strings.HasSuffix(edge.From, "::"+test.name) || !strings.Contains(edge.Evidence.Snippet, test.snippet) { + continue + } + if edge.Evidence.File != "channels.go" || edge.Evidence.Line != line { + t.Fatalf("WaitGroup.Go edge has wrong source evidence: %+v", edge.Evidence) + } + resource = edge.To + operationCount++ + } + if operationCount != 1 { + t.Fatalf("expected one source-backed WaitGroup.Go edge, got %d", operationCount) + } + + known := false + for _, edge := range report.Relationships { + if edge.Kind == "waitgroup_task" && edge.From == resource && strings.HasSuffix(edge.To, "::knownTask") { + if edge.Evidence.File != "channels.go" || edge.Evidence.Line != line || !strings.Contains(edge.Evidence.Snippet, test.snippet) { + t.Fatalf("known task edge lost invocation evidence: %+v", edge.Evidence) + } + known = true + } + } + if known != test.known { + t.Fatalf("known task candidate=%t, want %t", known, test.known) + } + + nilCandidate, closedBoundary := false, false + for _, boundary := range report.Boundaries { + if !strings.HasSuffix(boundary.Node, "::"+test.name) { + continue + } + nilReason := strings.Contains(boundary.Reason, "typed nil function value") || strings.Contains(boundary.Reason, "nil callback alternative") || strings.Contains(boundary.Reason, "possible nil") + if boundary.Kind == "unresolved_waitgroup_task" && boundary.Evidence.File == "channels.go" && boundary.Evidence.Line == line && strings.Contains(boundary.Evidence.Snippet, test.snippet) && nilReason { + nilCandidate = true + } + if boundary.Kind == "closed_function_channel_zero" && boundary.Evidence.File == "channels.go" && boundary.Evidence.Line == line && strings.Contains(boundary.Reason, "element zero value") { + if len(boundary.Examples) == 0 { + t.Fatalf("closed-channel boundary lacks source close evidence: %+v", boundary) + } + closedBoundary = true + } + } + if nilCandidate != test.nilCallback { + t.Fatalf("typed nil callback alternative=%t, want %t", nilCandidate, test.nilCallback) + } + if closedBoundary != test.closed { + t.Fatalf("closed-channel zero boundary=%t, want %t", closedBoundary, test.closed) + } + }) + } +} + +func sourceLine(source, prefix string) int { + for number, line := range strings.Split(source, "\n") { + if strings.HasPrefix(line, prefix) { + return number + 1 + } + } + return 0 +} diff --git a/cleanup.go b/cleanup.go new file mode 100644 index 0000000..29f2362 --- /dev/null +++ b/cleanup.go @@ -0,0 +1,127 @@ +package contracttrace + +import ( + "fmt" + "go/token" + "strings" + + "golang.org/x/tools/go/ssa" +) + +// Registrations and CFG exit candidates describe ownership and local paths. +// They do not establish registration frequency, execution or termination. +func (a *flowAnalysis) cleanupUse(call ssa.CallInstruction, ix *index) { + registration, ok := call.(*ssa.Defer) + if !ok { + return + } + owner := ix.owner(call.Parent()) + evidence := ix.callEvidence(call) + id := resourceID("defer", "", fmt.Sprintf("%s:%d:%d:%s", evidence.File, evidence.Line, evidence.Column, call.Parent().String())) + ix.funcs[id] = &function{node: Node{ID: id, Name: "defer registration", Kind: "defer", Evidence: evidence}} + link := func(to, kind, certainty string) { + ix.edges = append(ix.edges, Relationship{From: id, To: to, Kind: kind, Certainty: certainty, Evidence: evidence}) + } + ix.edges = append(ix.edges, Relationship{From: owner, To: id, Kind: "defer_registration", Certainty: "fact", Evidence: evidence}) + resolved := false + sqlInvocations, unknownSQLTarget := a.sqlInvocations(call.Common()) + for _, invocation := range sqlInvocations { + receiver, name := invocation.receiverKind, invocation.name + kind := "" + if (receiver == "Stmt" || receiver == "DB" || receiver == "Conn") && name == "Close" { + kind = "cleanup_statement_close" + if receiver == "DB" { + kind = "cleanup_database_close" + } else if receiver == "Conn" { + kind = "cleanup_connection_close" + } + } else if receiver == "Tx" && (name == "Commit" || name == "Rollback") { + kind = "cleanup_transaction_" + strings.ToLower(name) + } + if kind == "" { + continue + } + resolved = true + resources := a.sqlResources(invocation.receiver, receiver) + for _, resource := range resources { + link(resource, kind, "possible") + } + if len(resources) == 0 || invocation.unknownReceiver || unknownSQLTarget { + ix.boundaries = append(ix.boundaries, Boundary{Node: id, Kind: "unresolved_cleanup_resource", Reason: "deferred SQL cleanup retains a receiver without a modeled origin or an outside target; known handles remain possible", Evidence: evidence}) + } + } + for _, use := range a.waitGroupResources(call, ix) { + for _, resource := range use.resources { + link(resource, "cleanup_waitgroup_"+strings.ToLower(use.name), "possible") + resolved = true + } + } + for _, effect := range sortedKeys(a.get(call.Common().Value).effects) { + if strings.HasPrefix(effect, "cancel:") { + link(strings.TrimPrefix(effect, "cancel:"), "cleanup_cancel", "possible") + resolved = true + } + } + if builtin, ok := call.Common().Value.(*ssa.Builtin); ok && builtin.Name() == "close" && len(call.Common().Args) == 1 { + for _, channel := range sortedKeys(a.get(call.Common().Args[0]).addresses) { + if strings.HasPrefix(channel, "channel:") { + link(channel, "cleanup_close", "possible") + resolved = true + } + } + } + for _, target := range sortedFunctions(a.targets(call.Common())) { + if to := ix.owner(target); to != "" { + certainty := "possible" + if call.Common().StaticCallee() == target { + certainty = "fact" + } + link(to, "cleanup_call", certainty) + for _, site := range ix.directRecoverSites(target) { + link(site, "cleanup_recover_candidate", "possible") + } + resolved = true + } + } + if !resolved { + ix.boundaries = append(ix.boundaries, Boundary{Node: id, Kind: "unresolved_cleanup_target", Reason: "deferred target/resource has no modeled local body or resource identity; dependency code, unknown values or unsupported builtins require investigation", Evidence: evidence}) + } + queue := []*ssa.BasicBlock{registration.Block()} + visited := map[*ssa.BasicBlock]bool{} + exitCount := 0 + for head := 0; head < len(queue); head++ { + block := queue[head] + if visited[block] { + continue + } + visited[block] = true + for ordinal, ins := range block.Instrs { + name := "" + switch ins.(type) { + case *ssa.Return: + name = "return exit" + case *ssa.Panic: + name = "panic exit" + } + if name == "" { + continue + } + exitEvidence := ix.evidence(ins.Pos()) + if ins.Pos() == token.NoPos { + exitEvidence = ix.evidence(call.Parent().Pos()) + if call.Parent().Pos() == token.NoPos { + exitEvidence = ix.funcs[owner].node.Evidence + } + exitEvidence.Origin = "synthetic_declaration" + } + exit := resourceID("exit", "", fmt.Sprintf("%s:%s:%d:%d", owner, call.Parent().String(), block.Index, ordinal)) + ix.funcs[exit] = &function{node: Node{ID: exit, Name: name, Kind: "function_exit", Evidence: exitEvidence}} + link(exit, "cleanup_exit_candidate", "possible") + exitCount++ + } + queue = append(queue, block.Succs...) + } + if exitCount == 0 { + ix.boundaries = append(ix.boundaries, Boundary{Node: id, Kind: "cleanup_exit_unresolved", Reason: "no explicit return/panic reachable from registration in local SSA control flow; nontermination, implicit panic or external termination remains unresolved", Evidence: evidence}) + } +} diff --git a/cleanup_test.go b/cleanup_test.go new file mode 100644 index 0000000..1d3ee84 --- /dev/null +++ b/cleanup_test.go @@ -0,0 +1,98 @@ +package contracttrace + +import ( + "context" + "encoding/json" + "strings" + "testing" +) + +func TestDeferredCleanupOwnershipAndReachableExits(t *testing.T) { + r, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"CleanupBranches"}, Depth: 4, MaxNodes: 250}) + if err != nil { + t.Fatal(err) + } + registrations := map[string]bool{} + exits := map[string]Node{} + ordinary := "" + for _, n := range r.Nodes { + if n.Kind == "function_exit" { + exits[n.ID] = n + } + if n.Kind == "channel" && strings.Contains(n.Evidence.Snippet, "ordinary :=") { + ordinary = n.ID + } + } + for _, e := range r.Relationships { + if strings.HasSuffix(e.From, "::CleanupBranches") && e.Kind == "defer_registration" { + registrations[e.To] = true + } + } + if len(registrations) != 2 { + t.Fatalf("want cancel and close registrations, got %d", len(registrations)) + } + cancel, close := false, false + for registration := range registrations { + returnExit, panicExit := false, false + for _, e := range r.Relationships { + if e.From != registration { + continue + } + switch e.Kind { + case "cleanup_cancel": + cancel = strings.HasPrefix(e.To, "context:") + case "cleanup_close": + if e.To == ordinary { + t.Fatal("ordinary close was assigned to a defer registration") + } + close = strings.HasPrefix(e.To, "channel:") + case "cleanup_exit_candidate": + n := exits[e.To] + if n.Evidence.Line == 7 { + t.Fatal("return before registration was treated as its cleanup exit") + } + returnExit = returnExit || n.Name == "return exit" + panicExit = panicExit || n.Name == "panic exit" + } + } + if !returnExit || !panicExit { + t.Fatalf("registration %s missed return/panic paths", registration) + } + } + if !cancel || !close { + t.Fatalf("resource ownership missing: cancel=%t close=%t", cancel, close) + } +} + +func TestDeferredHelperAndUnresolvedLoopSurviveSavedExploration(t *testing.T) { + _, analysis, err := TraceWithAnalysis(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"DeferredHelper"}, Depth: 2, MaxNodes: 100}) + if err != nil { + t.Fatal(err) + } + data, err := json.Marshal(analysis) + if err != nil { + t.Fatal(err) + } + saved, err := ReadAnalysis(strings.NewReader(string(data))) + if err != nil { + t.Fatal(err) + } + r, err := Explore(context.Background(), saved, ExploreOptions{Seeds: []string{"DeferredHelper", "DeferredLoop"}, Depth: 4, MaxNodes: 250}) + if err != nil { + t.Fatal(err) + } + helper, loop := false, false + for _, e := range r.Relationships { + if e.Kind == "cleanup_call" && strings.HasSuffix(e.To, "::CleanupHelper") { + helper = true + } + } + for _, b := range r.Boundaries { + if b.Kind == "cleanup_exit_unresolved" && strings.Contains(b.Evidence.Snippet, "defer func()") { + loop = true + } + } + if !helper || !loop { + t.Fatalf("saved lifecycle scope missed helper or loop boundary: helper=%t loop=%t", helper, loop) + } +} diff --git a/cmd/contract-trace/analysis_output_paths.go b/cmd/contract-trace/analysis_output_paths.go new file mode 100644 index 0000000..4469ae4 --- /dev/null +++ b/cmd/contract-trace/analysis_output_paths.go @@ -0,0 +1,67 @@ +package main + +import ( + "fmt" + "path/filepath" + + trace "github.com/dominicnunez/contract-tracer" +) + +type analysisArtifactPath struct { + flag string + path string +} + +func rejectAnalysisInputWrites(analysis trace.Analysis, destinations ...analysisArtifactPath) error { + return rejectInputWrites(analysis.Root, analysis.Coverage, true, destinations...) +} + +func rejectReportInputWrites(report trace.Report, destinations ...analysisArtifactPath) error { + return rejectInputWrites(report.Root, report.Coverage, false, destinations...) +} + +func rejectInputWrites(root string, coverage trace.Coverage, rejectNewInputs bool, destinations ...analysisArtifactPath) error { + inputs := analysisInputPaths(root, coverage) + for _, destination := range destinations { + if destination.path == "" { + continue + } + identity, err := inspectOutputPath(destination.path) + if err != nil { + return fmt.Errorf("cannot safely inspect -%s %q: %w", destination.flag, destination.path, err) + } + if rejectNewInputs || identity.exists { + rootInput, err := trace.IsFingerprintInputPath(root, destination.path) + if err != nil { + return fmt.Errorf("cannot safely compare -%s %q with the analyzed source tree: %w", destination.flag, destination.path, err) + } + if rootInput { + return fmt.Errorf("-%s %q would replace or add a file in the analyzed source fingerprint", destination.flag, destination.path) + } + } + for _, input := range inputs { + same, err := pathsAlias(destination.path, input) + if err != nil { + return fmt.Errorf("cannot safely compare -%s %q with analyzed input %q: %w", destination.flag, destination.path, input, err) + } + if same { + return fmt.Errorf("-%s %q aliases an analyzed input", destination.flag, destination.path) + } + } + } + return nil +} + +func analysisInputPaths(root string, coverage trace.Coverage) []string { + inputs := make([]string, 0, len(coverage.EmbeddedFiles)+len(coverage.LoadedSources)+len(coverage.ResolutionInputs)) + for _, file := range coverage.EmbeddedFiles { + inputs = append(inputs, filepath.Join(root, filepath.FromSlash(file))) + } + for _, source := range coverage.LoadedSources { + inputs = append(inputs, filepath.FromSlash(source.Path)) + } + for _, source := range coverage.ResolutionInputs { + inputs = append(inputs, filepath.FromSlash(source.Path)) + } + return inputs +} diff --git a/cmd/contract-trace/config_output_paths_test.go b/cmd/contract-trace/config_output_paths_test.go new file mode 100644 index 0000000..236b7c1 --- /dev/null +++ b/cmd/contract-trace/config_output_paths_test.go @@ -0,0 +1,142 @@ +package main + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestConfigCannotBeReplacedByAnalysisArtifacts(t *testing.T) { + t.Run("saved analysis", func(t *testing.T) { + root := t.TempDir() + writeCLIInput(t, root) + config, original := writeCLIConfig(t, root) + result := runCLI(t, "-root", root, "-seed", "Validate", "-config", config, "-save-analysis", config) + requireConfigCollisionRejected(t, result, config, original, "-save-analysis") + }) + + t.Run("report only", func(t *testing.T) { + root := t.TempDir() + writeCLIInput(t, root) + config, original := writeCLIConfig(t, root) + result := runCLI(t, "-root", root, "-seed", "Validate", "-config", config, "-output", config) + requireConfigCollisionRejected(t, result, config, original, "-output") + }) + + t.Run("paired report", func(t *testing.T) { + root := t.TempDir() + writeCLIInput(t, root) + config, original := writeCLIConfig(t, root) + snapshot := filepath.Join(root, "analysis.json") + result := runCLI(t, "-root", root, "-seed", "Validate", "-config", config, "-save-analysis", snapshot, "-output", config) + requireConfigCollisionRejected(t, result, config, original, "-output") + if _, err := os.Stat(snapshot); !os.IsNotExist(err) { + t.Fatalf("rejected paired write left snapshot behind: %v", err) + } + }) + + t.Run("hard-linked saved analysis", func(t *testing.T) { + root := t.TempDir() + writeCLIInput(t, root) + config, original := writeCLIConfig(t, root) + snapshot := filepath.Join(root, "analysis.json") + if err := os.Link(config, snapshot); err != nil { + t.Skipf("hard links unavailable: %v", err) + } + result := runCLI(t, "-root", root, "-seed", "Validate", "-config", config, "-save-analysis", snapshot) + requireConfigCollisionRejected(t, result, config, original, "-save-analysis") + if !sameFile(t, config, snapshot) { + t.Fatal("rejected save changed the configuration hard link") + } + }) + + t.Run("symlink parent report", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("the symlink/.. parent path has platform-specific meaning") + } + base := t.TempDir() + root := filepath.Join(base, "root") + outside := filepath.Join(base, "outside") + if err := os.MkdirAll(filepath.Join(root, "child"), 0755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(outside, 0755); err != nil { + t.Fatal(err) + } + writeCLIInput(t, root) + config, original := writeCLIConfig(t, root) + if err := os.Symlink(filepath.Join(root, "child"), filepath.Join(outside, "link")); err != nil { + t.Skipf("directory symlinks unavailable: %v", err) + } + output := outside + string(os.PathSeparator) + "link" + string(os.PathSeparator) + ".." + string(os.PathSeparator) + "config.json" + if !sameFile(t, config, output) { + t.Skip("host does not resolve the symlink/.. fixture to the configuration file") + } + result := runCLI(t, "-root", root, "-seed", "Validate", "-config", config, "-output", output) + requireConfigCollisionRejected(t, result, config, original, "-output") + }) +} + +func TestConfigAndDistinctAnalysisArtifactsCanBeWritten(t *testing.T) { + root := t.TempDir() + writeCLIInput(t, root) + config, original := writeCLIConfig(t, root) + snapshot := filepath.Join(root, "analysis.json.gz") + report := filepath.Join(root, "report.json") + result := runCLI(t, "-root", root, "-seed", "Validate", "-config", config, "-save-analysis", snapshot, "-output", report) + if result.err != nil { + t.Fatalf("distinct config and artifact paths: %v: %s", result.err, result.stderr) + } + if after, err := os.ReadFile(config); err != nil || string(after) != string(original) { + t.Fatalf("successful run changed its config: read err=%v", err) + } + for _, path := range []string{snapshot, report} { + if _, err := os.Stat(path); err != nil { + t.Errorf("expected artifact %q: %v", filepath.Base(path), err) + } + } +} + +func writeCLIConfig(t *testing.T, root string) (string, []byte) { + t.Helper() + config := filepath.Join(root, "config.json") + contents := []byte(`{"event_fields":["EventType"]}`) + if err := os.WriteFile(config, contents, 0600); err != nil { + t.Fatal(err) + } + return config, contents +} + +func requireConfigCollisionRejected(t *testing.T, result cliResult, config string, original []byte, artifactFlag string) { + t.Helper() + if result.err == nil { + after, err := os.ReadFile(config) + if err != nil { + t.Errorf("run accepted %s that aliases its config, then config read failed: %v", artifactFlag, err) + } else { + t.Errorf("run accepted %s that aliases its config; config now starts %q", artifactFlag, after[:min(len(after), 80)]) + } + } + if result.err != nil && (!strings.Contains(result.stderr, "-config") || !strings.Contains(result.stderr, artifactFlag)) { + t.Errorf("collision error should identify config and artifact flags, got %q", result.stderr) + } + after, err := os.ReadFile(config) + if err != nil || string(after) != string(original) { + t.Errorf("rejected write changed config bytes: read err=%v", err) + } +} + +func sameFile(t *testing.T, first, second string) bool { + t.Helper() + firstInfo, err := os.Stat(first) + if err != nil { + t.Fatal(err) + } + secondInfo, err := os.Stat(second) + if err != nil { + t.Fatal(err) + } + return os.SameFile(firstInfo, secondInfo) +} diff --git a/cmd/contract-trace/main.go b/cmd/contract-trace/main.go new file mode 100644 index 0000000..d0663fb --- /dev/null +++ b/cmd/contract-trace/main.go @@ -0,0 +1,189 @@ +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "os" + "os/signal" + "path/filepath" + "strings" + "time" + + trace "github.com/dominicnunez/contract-tracer" +) + +func main() { os.Exit(run()) } +func run() int { + root := flag.String("root", ".", "Go module directory") + seeds := flag.String("seed", "", "comma-separated function names or fully qualified IDs") + locations := flag.String("location", "", "comma-separated source locations relative to root (file:line)") + invariant := flag.String("invariant", "", "initial invariant hypothesis; not automatically proven") + focus := flag.String("focus", "", "comma-separated explicit contract symbols/resource IDs; explains paths without filtering scope") + depth := flag.Int("depth", 3, "maximum relationship hops, counting resource nodes") + budget := flag.Int("max-nodes", 250, "maximum included functions and resources") + tests := flag.Bool("tests", false, "include test packages") + tags := flag.String("tags", "", "Go build tags") + expandCallbacks := flag.Bool("expand-callbacks", false, "expand same-signature CHA callback candidates; can greatly increase noise") + configPath := flag.String("config", "", "JSON configuration; omitted fields use defaults, [] disables a rule") + output := flag.String("output", "", "JSON report path; defaults to stdout") + saveAnalysis := flag.String("save-analysis", "", "save the complete discovered graph; .gz suffix selects gzip JSON") + resume := flag.String("resume", "", "explore a saved analysis; source snapshot must still match") + format := flag.String("format", "json", "json or markdown (summary)") + timeout := flag.Duration("timeout", 3*time.Minute, "cooperative analysis timeout; SSA stages finish before checking cancellation") + flag.Parse() + if *resume != "" { + invalid := "" + flag.Visit(func(f *flag.Flag) { + switch f.Name { + case "root", "config", "tests", "tags", "save-analysis": + invalid = f.Name + } + }) + if invalid != "" { + fmt.Fprintln(os.Stderr, "-resume cannot be combined with -"+invalid+"; rerun analysis to change its inputs") + return 1 + } + } + if *format != "json" && *format != "markdown" { + fmt.Fprintln(os.Stderr, "format must be json or markdown") + return 1 + } + snapshotPath, snapshotFlag := *saveAnalysis, "save-analysis" + if *resume != "" { + snapshotPath, snapshotFlag = *resume, "resume" + } + if err := rejectOutputSnapshotAlias(*output, snapshotPath, snapshotFlag); err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 + } + if err := rejectConfigArtifactAlias(*configPath, + analysisArtifactPath{flag: "save-analysis", path: *saveAnalysis}, + analysisArtifactPath{flag: "output", path: *output}, + ); err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 + } + config := trace.DefaultConfig() + if *configPath != "" { + f, err := os.Open(*configPath) + if err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 + } + config, err = trace.ReadConfig(f) + f.Close() + if err != nil { + fmt.Fprintln(os.Stderr, "configuration:", err) + return 1 + } + } + symbols := []string{} + for _, s := range strings.Split(*seeds, ",") { + if s = strings.TrimSpace(s); s != "" { + symbols = append(symbols, s) + } + } + positions := []string{} + focusAnchors := []string{} + for _, anchor := range strings.Split(*focus, ",") { + if anchor = strings.TrimSpace(anchor); anchor != "" { + focusAnchors = append(focusAnchors, anchor) + } + } + for _, p := range strings.Split(*locations, ",") { + if p = strings.TrimSpace(p); p != "" { + positions = append(positions, p) + } + } + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + defer stop() + ctx, cancel := context.WithTimeout(ctx, *timeout) + defer cancel() + var r trace.Report + var err error + options := trace.Options{Root: *root, Seeds: symbols, Locations: positions, Focus: focusAnchors, Invariant: *invariant, Depth: *depth, MaxNodes: *budget, Tests: *tests, Tags: *tags, Config: config, ExpandCallbacks: *expandCallbacks} + if *resume != "" { + f, openErr := os.Open(*resume) + if openErr != nil { + fmt.Fprintln(os.Stderr, openErr) + return 1 + } + analysis, readErr := trace.ReadAnalysis(f) + f.Close() + if readErr != nil { + fmt.Fprintln(os.Stderr, "saved analysis:", readErr) + return 1 + } + if err := rejectAnalysisInputWrites(analysis, analysisArtifactPath{flag: "output", path: *output}); err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 + } + r, err = trace.Explore(ctx, analysis, trace.ExploreOptions{Seeds: symbols, Locations: positions, Focus: focusAnchors, Invariant: *invariant, Depth: *depth, MaxNodes: *budget, ExpandCallbacks: *expandCallbacks}) + } else if *saveAnalysis != "" { + var analysis trace.Analysis + r, analysis, err = trace.TraceWithAnalysis(ctx, options) + if err == nil { + err = rejectAnalysisInputWrites(analysis, + analysisArtifactPath{flag: "save-analysis", path: *saveAnalysis}, + analysisArtifactPath{flag: "output", path: *output}, + ) + } + if err == nil { + err = saveSnapshot(*saveAnalysis, analysis) + } + if err == nil { + err = rejectOutputSnapshotAlias(*output, *saveAnalysis, "save-analysis") + } + } else { + r, err = trace.Trace(ctx, options) + if err == nil { + err = rejectReportInputWrites(r, analysisArtifactPath{flag: "output", path: *output}) + } + } + if err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 + } + data, err := json.MarshalIndent(r, "", " ") + if err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 + } + data = append(data, '\n') + if *format == "markdown" { + data = []byte(trace.Markdown(r)) + } + if *output == "" { + _, err = os.Stdout.Write(data) + } else { + err = os.WriteFile(*output, data, 0644) + } + if err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 + } + return 0 +} + +func saveSnapshot(destination string, analysis trace.Analysis) error { + directory, _ := filepath.Split(destination) + if directory == "" { + directory = "." + } + file, err := os.CreateTemp(directory, ".contract-analysis-*") + if err != nil { + return err + } + temporary := file.Name() + defer os.Remove(temporary) + if err := trace.WriteAnalysis(file, analysis, strings.EqualFold(filepath.Ext(destination), ".gz")); err != nil { + file.Close() + return err + } + if err := file.Close(); err != nil { + return err + } + return os.Rename(temporary, destination) +} diff --git a/cmd/contract-trace/output_alias_test.go b/cmd/contract-trace/output_alias_test.go new file mode 100644 index 0000000..2b107d0 --- /dev/null +++ b/cmd/contract-trace/output_alias_test.go @@ -0,0 +1,373 @@ +package main + +import ( + "flag" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestOutputCannotReplaceSavedAnalysis(t *testing.T) { + root := t.TempDir() + writeCLIInput(t, root) + snapshot := filepath.Join(root, "analysis.json") + original := []byte("preserve this snapshot") + if err := os.WriteFile(snapshot, original, 0644); err != nil { + t.Fatal(err) + } + + result := runCLI(t, "-root", root, "-seed", "Validate", "-save-analysis", snapshot, "-output", snapshot) + if result.err == nil { + after, err := os.ReadFile(snapshot) + if err != nil { + t.Fatalf("read overwritten snapshot: %v", err) + } + t.Fatalf("run accepted output that aliases the new saved analysis; snapshot now starts %q", after[:min(len(after), 80)]) + } + after, err := os.ReadFile(snapshot) + if err != nil { + t.Fatal(err) + } + if string(after) != string(original) { + t.Fatalf("rejected collision changed snapshot bytes: got %q", after) + } + if !strings.Contains(result.stderr, "-output") || !strings.Contains(result.stderr, "-save-analysis") { + t.Fatalf("collision error should name both flags, got %q", result.stderr) + } +} + +func TestOutputCollisionPrecedesAnalysis(t *testing.T) { + root := t.TempDir() + snapshot := filepath.Join(root, "analysis.json") + result := runCLI(t, "-root", filepath.Join(root, "missing"), "-seed", "Validate", "-save-analysis", snapshot, "-output", snapshot) + if result.err == nil { + t.Fatal("run accepted colliding output and snapshot paths") + } + if !strings.Contains(result.stderr, "-output") || !strings.Contains(result.stderr, "-save-analysis") { + t.Fatalf("collision should be rejected before root analysis, got %q", result.stderr) + } + if _, err := os.Stat(snapshot); !os.IsNotExist(err) { + t.Fatalf("collision should be rejected before creating snapshot, stat error=%v", err) + } +} + +func TestOutputCannotReplaceResumedAnalysis(t *testing.T) { + root := t.TempDir() + writeCLIInput(t, root) + snapshot := filepath.Join(root, "analysis.json") + created := runCLI(t, "-root", root, "-seed", "Validate", "-save-analysis", snapshot) + if created.err != nil { + t.Fatalf("create valid saved analysis: %v: %s", created.err, created.stderr) + } + original, err := os.ReadFile(snapshot) + if err != nil { + t.Fatal(err) + } + + result := runCLI(t, "-resume", snapshot, "-seed", "Validate", "-output", snapshot) + if result.err == nil { + t.Fatal("run accepted output that aliases the resumed analysis") + } + after, err := os.ReadFile(snapshot) + if err != nil { + t.Fatal(err) + } + if string(after) != string(original) { + t.Fatalf("rejected collision changed snapshot bytes: got %q", after) + } + if !strings.Contains(result.stderr, "-output") || !strings.Contains(result.stderr, "-resume") { + t.Fatalf("collision error should name both flags, got %q", result.stderr) + } +} + +func TestOutputCannotReplaceSymlinkedSavedAnalysis(t *testing.T) { + if testing.Short() { + t.Skip("filesystem alias integration test") + } + root := t.TempDir() + writeCLIInput(t, root) + snapshot := filepath.Join(root, "analysis.json") + created := runCLI(t, "-root", root, "-seed", "Validate", "-save-analysis", snapshot) + if created.err != nil { + t.Fatalf("create valid saved analysis: %v: %s", created.err, created.stderr) + } + original, err := os.ReadFile(snapshot) + if err != nil { + t.Fatal(err) + } + outputAlias := filepath.Join(root, "report.json") + if err := os.Symlink(snapshot, outputAlias); err != nil { + t.Skipf("file symlinks unavailable: %v", err) + } + + result := runCLI(t, "-resume", snapshot, "-seed", "Validate", "-output", outputAlias) + if result.err == nil { + t.Fatal("run accepted output symlink that aliases the resumed analysis") + } + after, err := os.ReadFile(snapshot) + if err != nil || string(after) != string(original) { + t.Fatalf("rejected symlink collision changed snapshot: read err=%v", err) + } +} + +func TestOutputCannotReplaceAnalysisViaSymlinkParentTraversal(t *testing.T) { + base := t.TempDir() + root := filepath.Join(base, "app") + other := filepath.Join(base, "other") + if err := os.MkdirAll(root, 0755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(other, "nested"), 0755); err != nil { + t.Fatal(err) + } + writeCLIInput(t, root) + alias := filepath.Join(root, "alias") + if err := os.Symlink(filepath.Join(other, "nested"), alias); err != nil { + t.Skipf("directory symlinks unavailable: %v", err) + } + snapshot := filepath.Join(other, "analysis.json") + created := runCLI(t, "-root", root, "-seed", "Validate", "-save-analysis", snapshot) + if created.err != nil { + t.Fatalf("create valid saved analysis: %v: %s", created.err, created.stderr) + } + original, err := os.ReadFile(snapshot) + if err != nil { + t.Fatal(err) + } + output := root + string(os.PathSeparator) + "alias" + string(os.PathSeparator) + ".." + string(os.PathSeparator) + "analysis.json" + outputInfo, err := os.Stat(output) + if err != nil { + t.Skipf("this platform does not resolve symlink/.. to the target's parent: %v", err) + } + snapshotInfo, err := os.Stat(snapshot) + if err != nil { + t.Fatal(err) + } + if !os.SameFile(outputInfo, snapshotInfo) { + t.Skip("this platform resolves symlink/.. differently from the POSIX alias under test") + } + + result := runCLI(t, "-resume", snapshot, "-seed", "Validate", "-output", output) + if result.err == nil { + t.Fatal("run accepted output that reaches the resumed analysis through symlink/.. traversal") + } + after, err := os.ReadFile(snapshot) + if err != nil || string(after) != string(original) { + t.Fatalf("rejected symlink traversal changed snapshot: read err=%v", err) + } + + freshSnapshot := filepath.Join(other, "fresh-analysis.json") + freshOutput := root + string(os.PathSeparator) + "alias" + string(os.PathSeparator) + ".." + string(os.PathSeparator) + "fresh-analysis.json" + fresh := runCLI(t, "-root", root, "-seed", "Validate", "-save-analysis", freshSnapshot, "-output", freshOutput) + if fresh.err == nil { + t.Fatal("run accepted fresh output that reaches the new snapshot through symlink/.. traversal") + } + if _, err := os.Stat(freshSnapshot); !os.IsNotExist(err) { + t.Fatalf("fresh symlink traversal should be rejected before snapshot creation, stat error=%v", err) + } +} + +func TestOutputCannotAliasUncreatedSaveThroughDirectorySymlink(t *testing.T) { + root := t.TempDir() + writeCLIInput(t, root) + aliasDir := filepath.Join(root, "alias") + if err := os.Symlink(root, aliasDir); err != nil { + t.Skipf("directory symlinks unavailable: %v", err) + } + snapshot := filepath.Join(root, "analysis.json") + output := filepath.Join(aliasDir, "nested", "..", "analysis.json") + result := runCLI(t, "-root", root, "-seed", "Validate", "-save-analysis", snapshot, "-output", output) + if result.err == nil { + t.Fatal("run accepted uncreated output path through a directory symlink") + } + if _, err := os.Stat(snapshot); !os.IsNotExist(err) { + t.Fatalf("collision should be rejected before creating snapshot, stat error=%v", err) + } +} + +func TestOutputCannotAliasUncreatedSaveThroughDanglingFileSymlink(t *testing.T) { + root := t.TempDir() + writeCLIInput(t, root) + snapshot := filepath.Join(root, "analysis.json") + outputAlias := filepath.Join(root, "report.json") + if err := os.Symlink(snapshot, outputAlias); err != nil { + t.Skipf("file symlinks unavailable: %v", err) + } + + result := runCLI(t, "-root", root, "-seed", "Validate", "-save-analysis", snapshot, "-output", outputAlias) + if result.err == nil { + t.Fatal("run accepted output symlink to the not-yet-created saved analysis") + } + if _, err := os.Stat(snapshot); !os.IsNotExist(err) { + t.Fatalf("collision should be rejected before creating snapshot, stat error=%v", err) + } + linkInfo, err := os.Lstat(outputAlias) + if err != nil || linkInfo.Mode()&os.ModeSymlink == 0 { + t.Fatalf("rejection should leave output symlink intact, lstat error=%v", err) + } +} + +func TestOutputCannotAliasSavedAnalysisThroughCleanedRelativePath(t *testing.T) { + root := t.TempDir() + writeCLIInput(t, root) + t.Chdir(root) + snapshot := filepath.Join(root, "analysis.json") + if err := os.Mkdir(filepath.Join(filepath.Dir(snapshot), "unused"), 0755); err != nil { + t.Fatal(err) + } + output := "unused" + string(os.PathSeparator) + ".." + string(os.PathSeparator) + filepath.Base(snapshot) + result := runCLI(t, "-root", root, "-seed", "Validate", "-save-analysis", snapshot, "-output", output) + if result.err == nil { + t.Fatal("run accepted output with a cleaned relative path alias") + } + if _, err := os.Stat(snapshot); !os.IsNotExist(err) { + t.Fatalf("collision should be rejected before creating snapshot, stat error=%v", err) + } +} + +func TestOutputCannotReplaceHardLinkedResumedAnalysis(t *testing.T) { + root := t.TempDir() + writeCLIInput(t, root) + snapshot := filepath.Join(root, "analysis.json") + created := runCLI(t, "-root", root, "-seed", "Validate", "-save-analysis", snapshot) + if created.err != nil { + t.Fatalf("create valid saved analysis: %v: %s", created.err, created.stderr) + } + original, err := os.ReadFile(snapshot) + if err != nil { + t.Fatal(err) + } + outputAlias := filepath.Join(root, "report.json") + if err := os.Link(snapshot, outputAlias); err != nil { + t.Skipf("hard links unavailable: %v", err) + } + + result := runCLI(t, "-resume", snapshot, "-seed", "Validate", "-output", outputAlias) + if result.err == nil { + t.Fatal("run accepted output hard link that aliases the resumed analysis") + } + after, err := os.ReadFile(snapshot) + if err != nil || string(after) != string(original) { + t.Fatalf("rejected hard-link collision changed snapshot: read err=%v", err) + } +} + +func TestDistinctReportAndSavedAnalysisPathsWork(t *testing.T) { + root := t.TempDir() + writeCLIInput(t, root) + snapshot := filepath.Join(root, "analysis.json.gz") + report := filepath.Join(root, "report.json") + created := runCLI(t, "-root", root, "-seed", "Validate", "-save-analysis", snapshot, "-output", report) + if created.err != nil { + t.Fatalf("save and report: %v: %s", created.err, created.stderr) + } + if _, err := os.Stat(snapshot); err != nil { + t.Fatalf("saved analysis missing: %v", err) + } + if _, err := os.Stat(report); err != nil { + t.Fatalf("report missing: %v", err) + } + + resumedReport := filepath.Join(root, "resumed.json") + resumed := runCLI(t, "-resume", snapshot, "-seed", "Validate", "-output", resumedReport) + if resumed.err != nil { + t.Fatalf("resume and report: %v: %s", resumed.err, resumed.stderr) + } + if _, err := os.Stat(resumedReport); err != nil { + t.Fatalf("resumed report missing: %v", err) + } +} + +func TestWindowsUnusualOutputNamesCannotReplaceNewAnalysis(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("Windows path alias behavior") + } + for _, suffix := range []string{".", " ", "::$DATA"} { + t.Run(strings.ReplaceAll(suffix, ":", "_"), func(t *testing.T) { + root := t.TempDir() + writeCLIInput(t, root) + snapshot := filepath.Join(root, "analysis.json") + output := snapshot + suffix + if _, err := os.Lstat(snapshot); !os.IsNotExist(err) { + t.Fatalf("snapshot unexpectedly exists before run: %v", err) + } + if _, err := os.Lstat(output); !os.IsNotExist(err) { + t.Fatalf("output unexpectedly exists before run: %v", err) + } + + result := runCLI(t, "-root", root, "-seed", "Validate", "-save-analysis", snapshot, "-output", output) + if result.err == nil { + contents, readErr := os.ReadFile(snapshot) + t.Fatalf("run accepted ambiguous Windows output %q (snapshot read error %v, bytes %q)", output, readErr, contents[:min(len(contents), 80)]) + } + if !strings.Contains(result.stderr, "ambiguous Windows file name") { + t.Fatalf("error should explain the unsupported Windows name, got %q", result.stderr) + } + if _, err := os.Stat(snapshot); !os.IsNotExist(err) { + t.Fatalf("ambiguous output should be rejected before creating snapshot, stat error=%v", err) + } + }) + } +} + +type cliResult struct { + err error + stderr string + stdout string +} + +func runCLI(t *testing.T, args ...string) cliResult { + t.Helper() + stdout, err := os.CreateTemp(t.TempDir(), "stdout-*") + if err != nil { + t.Fatal(err) + } + stderr, err := os.CreateTemp(t.TempDir(), "stderr-*") + if err != nil { + t.Fatal(err) + } + oldArgs, oldFlags := os.Args, flag.CommandLine + oldStdout, oldStderr := os.Stdout, os.Stderr + defer func() { + os.Args, flag.CommandLine = oldArgs, oldFlags + os.Stdout, os.Stderr = oldStdout, oldStderr + }() + os.Args = append([]string{"contract-trace"}, args...) + flag.CommandLine = flag.NewFlagSet("contract-trace", flag.ContinueOnError) + flag.CommandLine.SetOutput(stderr) + os.Stdout, os.Stderr = stdout, stderr + code := run() + if err := stdout.Close(); err != nil { + t.Fatal(err) + } + if err := stderr.Close(); err != nil { + t.Fatal(err) + } + message, err := os.ReadFile(stderr.Name()) + if err != nil { + t.Fatal(err) + } + output, err := os.ReadFile(stdout.Name()) + if err != nil { + t.Fatal(err) + } + if code != 0 { + return cliResult{err: fmt.Errorf("run returned %d", code), stderr: string(message), stdout: string(output)} + } + return cliResult{stderr: string(message), stdout: string(output)} +} + +func writeCLIInput(t *testing.T, root string) { + t.Helper() + for name, content := range map[string]string{ + "go.mod": "module example.com/outputalias\n\ngo 1.27.0\n", + "source.go": "package outputalias\nfunc Validate() bool { return true }\n", + } { + if err := os.WriteFile(filepath.Join(root, name), []byte(content), 0644); err != nil { + t.Fatal(err) + } + } +} diff --git a/cmd/contract-trace/output_paths.go b/cmd/contract-trace/output_paths.go new file mode 100644 index 0000000..30662cd --- /dev/null +++ b/cmd/contract-trace/output_paths.go @@ -0,0 +1,119 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "strings" +) + +func rejectOutputSnapshotAlias(output, snapshot, snapshotFlag string) error { + if output == "" || snapshot == "" { + return nil + } + same, err := pathsAlias(output, snapshot) + if err != nil { + return fmt.Errorf("cannot safely compare -output %q with -%s %q: %w", output, snapshotFlag, snapshot, err) + } + if same { + return fmt.Errorf("-output %q aliases -%s %q; choose distinct paths", output, snapshotFlag, snapshot) + } + return nil +} + +func rejectConfigArtifactAlias(config string, artifacts ...analysisArtifactPath) error { + if config == "" { + return nil + } + for _, artifact := range artifacts { + if artifact.path == "" { + continue + } + same, err := pathsAlias(config, artifact.path) + if err != nil { + return fmt.Errorf("cannot safely compare -config %q with -%s %q: %w", config, artifact.flag, artifact.path, err) + } + if same { + return fmt.Errorf("-config %q aliases -%s %q; choose distinct paths", config, artifact.flag, artifact.path) + } + } + return nil +} + +func pathsAlias(first, second string) (bool, error) { + firstPath, err := inspectOutputPath(first) + if err != nil { + return false, err + } + secondPath, err := inspectOutputPath(second) + if err != nil { + return false, err + } + if firstPath.exists && secondPath.exists { + return os.SameFile(firstPath.file, secondPath.file), nil + } + if firstPath.exists || secondPath.exists { + return false, nil + } + if !os.SameFile(firstPath.parent, secondPath.parent) { + return false, nil + } + if firstPath.leaf == secondPath.leaf { + return true, nil + } + if runtime.GOOS == "windows" && strings.EqualFold(firstPath.leaf, secondPath.leaf) { + return true, nil + } + return false, nil +} + +type outputPathIdentity struct { + file os.FileInfo + parent os.FileInfo + leaf string + exists bool +} + +// inspectOutputPath lets the operating system resolve the original path, +// including symlink/.. components. A missing leaf is identified by its +// existing parent directory and leaf name; missing parents cannot be written +// by the current output and snapshot writers, so those paths fail closed. +func inspectOutputPath(path string) (outputPathIdentity, error) { + if runtime.GOOS == "windows" { + _, leaf := filepath.Split(path) + if strings.HasSuffix(leaf, ".") || strings.HasSuffix(leaf, " ") || strings.Contains(leaf, ":") { + return outputPathIdentity{}, fmt.Errorf("%q has an ambiguous Windows file name; use a name without a trailing dot, trailing space, or colon", path) + } + } + + if info, err := os.Stat(path); err == nil { + return outputPathIdentity{file: info, exists: true}, nil + } else if !os.IsNotExist(err) { + return outputPathIdentity{}, err + } + + if info, err := os.Lstat(path); err == nil { + if info.Mode()&os.ModeSymlink != 0 { + return outputPathIdentity{}, fmt.Errorf("%q is a dangling or unresolvable symbolic link", path) + } + } else if !os.IsNotExist(err) { + return outputPathIdentity{}, err + } + + directory, leaf := filepath.Split(path) + if leaf == "" { + return outputPathIdentity{}, fmt.Errorf("%q has no file name", path) + } + if directory == "" { + directory = "." + } + parent, err := os.Stat(directory) + if err != nil { + return outputPathIdentity{}, fmt.Errorf("resolve parent of %q: %w", path, err) + } + if !parent.IsDir() { + return outputPathIdentity{}, fmt.Errorf("parent of %q is not a directory", path) + } + return outputPathIdentity{parent: parent, leaf: leaf}, nil +} diff --git a/cmd/contract-trace/report_only_input_paths_test.go b/cmd/contract-trace/report_only_input_paths_test.go new file mode 100644 index 0000000..1991c64 --- /dev/null +++ b/cmd/contract-trace/report_only_input_paths_test.go @@ -0,0 +1,259 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestReportOnlyCannotReplaceRootInputs(t *testing.T) { + for _, name := range []string{"source.go", "excluded.go", "schema.sql", "go.mod", "go.sum"} { + t.Run(name, func(t *testing.T) { + root := t.TempDir() + writeCLIInput(t, root) + for file, contents := range map[string]string{ + "schema.sql": "CREATE TABLE records (id INTEGER PRIMARY KEY);\n", + "go.sum": "example.com/unused v1.0.0 h1:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n", + "excluded.go": "//go:build report_only_never\n\npackage outputalias\nfunc excluded() {}\n", + } { + if err := os.WriteFile(filepath.Join(root, file), []byte(contents), 0644); err != nil { + t.Fatal(err) + } + } + destination := filepath.Join(root, name) + before, err := os.ReadFile(destination) + if err != nil { + t.Fatal(err) + } + + result := runCLI(t, "-root", root, "-seed", "Validate", "-output", destination) + if result.err == nil { + after, _ := os.ReadFile(destination) + t.Fatalf("report-only output replaced analyzed root input %q (bytes changed: %t)", name, string(after) != string(before)) + } + assertReportOutputRejected(t, result) + after, err := os.ReadFile(destination) + if err != nil || string(after) != string(before) { + t.Fatalf("rejected report changed root input %q: read error=%v", name, err) + } + }) + } +} + +func TestReportOnlyCannotReplaceEmbeddedAsset(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example.com/reportasset\n\ngo 1.27.0\n"), 0644); err != nil { + t.Fatal(err) + } + asset := []byte("embedded source text") + if err := os.WriteFile(filepath.Join(root, "asset.txt"), asset, 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "source.go"), []byte("package reportasset\nimport _ \"embed\"\n//go:embed asset.txt\nvar content string\nfunc Validate() bool { return content != \"\" }\n"), 0644); err != nil { + t.Fatal(err) + } + + result := runCLI(t, "-root", root, "-seed", "Validate", "-output", filepath.Join(root, "asset.txt")) + if result.err == nil { + t.Fatal("report-only output replaced a captured embedded asset") + } + assertReportOutputRejected(t, result) + after, err := os.ReadFile(filepath.Join(root, "asset.txt")) + if err != nil || string(after) != string(asset) { + t.Fatalf("rejected report changed embedded asset: read error=%v", err) + } +} + +func TestReportOnlyCannotReplaceExternalDependencyInputs(t *testing.T) { + for _, name := range []string{"source.go", "go.mod"} { + t.Run(name, func(t *testing.T) { + root, dependency := writeExternalDependency(t) + destination := filepath.Join(dependency, name) + before, err := os.ReadFile(destination) + if err != nil { + t.Fatal(err) + } + + result := runCLI(t, "-root", root, "-seed", "Validate", "-output", destination) + if result.err == nil { + t.Fatalf("report-only output replaced captured dependency input %q", name) + } + assertReportOutputRejected(t, result) + after, err := os.ReadFile(destination) + if err != nil || string(after) != string(before) { + t.Fatalf("rejected report changed dependency input %q: read error=%v", name, err) + } + }) + } +} + +func TestReportOnlyCannotReplaceWorkspaceFile(t *testing.T) { + base := t.TempDir() + root := filepath.Join(base, "app") + dependency := filepath.Join(base, "dependency") + for _, directory := range []string{root, dependency} { + if err := os.MkdirAll(directory, 0755); err != nil { + t.Fatal(err) + } + } + files := map[string]string{ + filepath.Join(root, "go.mod"): "module example.com/workspaceapp\n\ngo 1.27.0\n\nrequire example.com/workspacedep v0.0.0\n", + filepath.Join(root, "source.go"): "package workspaceapp\nimport \"example.com/workspacedep\"\nfunc Validate() bool { return workspacedep.Check() }\n", + filepath.Join(dependency, "go.mod"): "module example.com/workspacedep\n\ngo 1.27.0\n", + filepath.Join(dependency, "source.go"): "package workspacedep\nfunc Check() bool { return true }\n", + } + for path, contents := range files { + if err := os.WriteFile(path, []byte(contents), 0644); err != nil { + t.Fatal(err) + } + } + workfile := filepath.Join(base, "go.work") + workContents := []byte("go 1.27.0\n\nuse (\n ./app\n ./dependency\n)\n") + if err := os.WriteFile(workfile, workContents, 0644); err != nil { + t.Fatal(err) + } + t.Setenv("GOWORK", workfile) + + result := runCLI(t, "-root", root, "-seed", "Validate", "-output", workfile) + if result.err == nil { + t.Fatal("report-only output replaced the active workspace file") + } + assertReportOutputRejected(t, result) + after, err := os.ReadFile(workfile) + if err != nil || string(after) != string(workContents) { + t.Fatalf("rejected report changed workspace file: read error=%v", err) + } +} + +func TestReportOnlyCannotReplaceRootInputByHardLink(t *testing.T) { + root := t.TempDir() + writeCLIInput(t, root) + source := filepath.Join(root, "source.go") + before, err := os.ReadFile(source) + if err != nil { + t.Fatal(err) + } + report := filepath.Join(root, "report.json") + if err := os.Link(source, report); err != nil { + t.Skipf("hard links unavailable: %v", err) + } + result := runCLI(t, "-root", root, "-seed", "Validate", "-output", report) + if result.err == nil { + t.Fatal("report-only output replaced a root source through a hard link") + } + assertReportOutputRejected(t, result) + after, err := os.ReadFile(source) + if err != nil || string(after) != string(before) { + t.Fatalf("rejected hard-link report changed source: read error=%v", err) + } +} + +func TestReportOnlyCannotReplaceSourceThroughSymlinkDotDot(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("the symlink/.. path meaning differs on Windows") + } + base := t.TempDir() + root := filepath.Join(base, "root") + outside := filepath.Join(base, "outside") + if err := os.MkdirAll(filepath.Join(root, "child"), 0755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(outside, 0755); err != nil { + t.Fatal(err) + } + writeCLIInput(t, root) + if err := os.Symlink(filepath.Join(root, "child"), filepath.Join(outside, "link")); err != nil { + t.Skipf("directory symlinks unavailable: %v", err) + } + alias := outside + string(os.PathSeparator) + "link" + string(os.PathSeparator) + ".." + string(os.PathSeparator) + "source.go" + source := filepath.Join(root, "source.go") + aliasInfo, err := os.Stat(alias) + if err != nil { + t.Fatal(err) + } + sourceInfo, err := os.Stat(source) + if err != nil || !os.SameFile(aliasInfo, sourceInfo) { + t.Skipf("host does not resolve symlink/.. output to the root source: stat error=%v", err) + } + before, err := os.ReadFile(source) + if err != nil { + t.Fatal(err) + } + + result := runCLI(t, "-root", root, "-seed", "Validate", "-output", alias) + if result.err == nil { + t.Fatal("report-only output replaced source through symlink/.. traversal") + } + assertReportOutputRejected(t, result) + after, err := os.ReadFile(source) + if err != nil || string(after) != string(before) { + t.Fatalf("rejected symlink/.. report changed source: read error=%v", err) + } +} + +func TestReportOnlyRejectsAmbiguousWindowsName(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("Windows file-name aliases") + } + root := t.TempDir() + writeCLIInput(t, root) + source := filepath.Join(root, "source.go") + before, err := os.ReadFile(source) + if err != nil { + t.Fatal(err) + } + result := runCLI(t, "-root", root, "-seed", "Validate", "-output", source+".") + if result.err == nil || !strings.Contains(result.stderr, "ambiguous Windows file name") { + t.Fatalf("report-only accepted an ambiguous Windows output path: %v %q", result.err, result.stderr) + } + after, err := os.ReadFile(source) + if err != nil || string(after) != string(before) { + t.Fatalf("rejected ambiguous report changed source: read error=%v", err) + } +} + +func assertReportOutputRejected(t *testing.T, result cliResult) { + t.Helper() + if !strings.Contains(result.stderr, "-output") || !(strings.Contains(result.stderr, "analyzed input") || strings.Contains(result.stderr, "analyzed source fingerprint") || strings.Contains(result.stderr, "cannot safely inspect")) { + t.Fatalf("report-only output was rejected for an unrelated reason: %v: %q", result.err, result.stderr) + } +} + +func TestReportOnlyCanWriteDistinctOutputAndStdout(t *testing.T) { + root := t.TempDir() + writeCLIInput(t, root) + external := t.TempDir() + report := filepath.Join(external, "report.go") + result := runCLI(t, "-root", root, "-seed", "Validate", "-output", report) + if result.err != nil { + t.Fatalf("distinct external report was rejected: %v: %s", result.err, result.stderr) + } + data, err := os.ReadFile(report) + if err != nil || !json.Valid(data) { + t.Fatalf("external report is not valid JSON: read error=%v", err) + } + stdout := runCLI(t, "-root", root, "-seed", "Validate") + if stdout.err != nil || !strings.Contains(stdout.stdout, `"schema": "contract-tracer.v0.1"`) { + t.Fatalf("stdout report was rejected or missing JSON: err=%v stdout=%q", stdout.err, stdout.stdout) + } +} + +func TestReportOnlyCanReplaceExistingJSONReport(t *testing.T) { + root := t.TempDir() + writeCLIInput(t, root) + report := filepath.Join(root, "report.json") + if err := os.WriteFile(report, []byte(`{"old":true}`), 0644); err != nil { + t.Fatal(err) + } + result := runCLI(t, "-root", root, "-seed", "Validate", "-output", report) + if result.err != nil { + t.Fatalf("existing non-input report was rejected: %v: %s", result.err, result.stderr) + } + data, err := os.ReadFile(report) + if err != nil || !json.Valid(data) || strings.Contains(string(data), `"old"`) { + t.Fatalf("existing report was not replaced with valid JSON: read error=%v contents=%q", err, data) + } +} diff --git a/cmd/contract-trace/snapshot_input_paths_test.go b/cmd/contract-trace/snapshot_input_paths_test.go new file mode 100644 index 0000000..c942310 --- /dev/null +++ b/cmd/contract-trace/snapshot_input_paths_test.go @@ -0,0 +1,335 @@ +package main + +import ( + "os" + "path/filepath" + "runtime" + "testing" +) + +func TestSaveAnalysisCannotReplaceRootInputs(t *testing.T) { + for _, name := range []string{"new.go", "new.sql", "go.mod", "go.sum"} { + t.Run(name, func(t *testing.T) { + root := t.TempDir() + writeCLIInput(t, root) + destination := filepath.Join(root, name) + var original []byte + if contents, err := os.ReadFile(destination); err == nil { + original = contents + } else if !os.IsNotExist(err) { + t.Fatal(err) + } + + result := runCLI(t, "-root", root, "-seed", "Validate", "-save-analysis", destination) + if result.err == nil { + t.Fatalf("save-analysis accepted root input destination %q", name) + } + after, err := os.ReadFile(destination) + if len(original) == 0 { + if !os.IsNotExist(err) { + t.Fatalf("rejected save created %q: read err=%v", name, err) + } + } else if err != nil || string(after) != string(original) { + t.Fatalf("rejected save changed %q: read err=%v", name, err) + } + }) + } +} + +func TestReportOnlyMayWriteSourceShapedPathInRoot(t *testing.T) { + root := t.TempDir() + writeCLIInput(t, root) + report := filepath.Join(root, "report.go") + result := runCLI(t, "-root", root, "-seed", "Validate", "-output", report) + if result.err != nil { + t.Fatalf("report-only output behavior changed: %v: %s", result.err, result.stderr) + } + if _, err := os.Stat(report); err != nil { + t.Fatalf("report-only output missing: %v", err) + } +} + +func TestSaveAndReportMayWriteExcludedFingerprintDirectory(t *testing.T) { + root := t.TempDir() + writeCLIInput(t, root) + vendor := filepath.Join(root, "vendor") + if err := os.Mkdir(vendor, 0755); err != nil { + t.Fatal(err) + } + snapshot := filepath.Join(root, "analysis.json") + report := filepath.Join(vendor, "report.go") + result := runCLI(t, "-root", root, "-seed", "Validate", "-save-analysis", snapshot, "-output", report) + if result.err != nil { + t.Fatalf("excluded fingerprint directory should remain writable: %v: %s", result.err, result.stderr) + } +} + +func TestSaveRejectsNewSourceThroughDirectoryAlias(t *testing.T) { + root := t.TempDir() + writeCLIInput(t, root) + alias := filepath.Join(root, "alias") + if err := os.Symlink(root, alias); err != nil { + t.Skipf("directory symlinks unavailable: %v", err) + } + snapshot := filepath.Join(alias, "new.go") + result := runCLI(t, "-root", root, "-seed", "Validate", "-save-analysis", snapshot) + if result.err == nil { + t.Fatal("save-analysis accepted a new source path through a directory alias") + } + if _, err := os.Stat(filepath.Join(root, "new.go")); !os.IsNotExist(err) { + t.Fatalf("rejected source alias created a Go file: %v", err) + } +} + +func TestSaveAndReportRejectSourcePathThroughSymlinkDotDot(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("the symlink/.. path meaning differs on Windows") + } + fixture := func(t *testing.T) (string, string) { + t.Helper() + base := t.TempDir() + root := filepath.Join(base, "root") + outside := filepath.Join(base, "outside") + if err := os.MkdirAll(filepath.Join(root, "child"), 0755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(outside, 0755); err != nil { + t.Fatal(err) + } + writeCLIInput(t, root) + if err := os.Symlink(filepath.Join(root, "child"), filepath.Join(outside, "link")); err != nil { + t.Skipf("directory symlinks unavailable: %v", err) + } + linkParent := outside + string(os.PathSeparator) + "link" + string(os.PathSeparator) + ".." + aliasedSource := linkParent + string(os.PathSeparator) + "new.go" + parentInfo, err := os.Stat(linkParent) + if err != nil { + t.Fatal(err) + } + rootInfo, err := os.Stat(root) + if err != nil || !os.SameFile(parentInfo, rootInfo) { + t.Skipf("host does not resolve the fixture path to the root: stat err=%v", err) + } + return root, aliasedSource + } + + t.Run("snapshot", func(t *testing.T) { + root, aliasedSource := fixture(t) + result := runCLI(t, "-root", root, "-seed", "Validate", "-save-analysis", aliasedSource) + if result.err == nil { + t.Fatal("save-analysis accepted a new source path through symlink/.. traversal") + } + if _, err := os.Stat(filepath.Join(root, "new.go")); !os.IsNotExist(err) { + t.Fatalf("rejected snapshot created source through symlink/.. path: %v", err) + } + }) + t.Run("paired report", func(t *testing.T) { + root, aliasedSource := fixture(t) + snapshot := filepath.Join(root, "analysis.json") + result := runCLI(t, "-root", root, "-seed", "Validate", "-save-analysis", snapshot, "-output", aliasedSource) + if result.err == nil { + t.Fatal("paired report accepted a new source path through symlink/.. traversal") + } + if _, err := os.Stat(snapshot); !os.IsNotExist(err) { + t.Fatalf("rejected paired report left snapshot behind: %v", err) + } + if _, err := os.Stat(filepath.Join(root, "new.go")); !os.IsNotExist(err) { + t.Fatalf("rejected report created source through symlink/.. path: %v", err) + } + }) +} + +func TestSaveAnalysisCannotReplaceCaseAliasedRootInput(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("Windows file-name aliases") + } + root := t.TempDir() + writeCLIInput(t, root) + source := filepath.Join(root, "source.go") + original, err := os.ReadFile(source) + if err != nil { + t.Fatal(err) + } + result := runCLI(t, "-root", root, "-seed", "Validate", "-save-analysis", filepath.Join(root, "SOURCE.GO")) + if result.err == nil { + t.Fatal("save-analysis accepted a case-aliased source destination") + } + after, err := os.ReadFile(source) + if err != nil || string(after) != string(original) { + t.Fatalf("rejected case alias changed source: read err=%v", err) + } +} + +func TestPairedReportCannotAddRootInput(t *testing.T) { + root := t.TempDir() + writeCLIInput(t, root) + snapshot := filepath.Join(root, "analysis.json") + report := filepath.Join(root, "new.go") + result := runCLI(t, "-root", root, "-seed", "Validate", "-save-analysis", snapshot, "-output", report) + if result.err == nil { + t.Fatal("save-analysis and paired report accepted a new root Go input") + } + for _, path := range []string{snapshot, report} { + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("rejected paired publication left %q behind: %v", filepath.Base(path), err) + } + } +} + +func TestResumeReportCannotAddRootInput(t *testing.T) { + root := t.TempDir() + writeCLIInput(t, root) + snapshot := filepath.Join(root, "analysis.json") + created := runCLI(t, "-root", root, "-seed", "Validate", "-save-analysis", snapshot) + if created.err != nil { + t.Fatalf("create saved analysis: %v: %s", created.err, created.stderr) + } + original, err := os.ReadFile(snapshot) + if err != nil { + t.Fatal(err) + } + report := filepath.Join(root, "new.sql") + result := runCLI(t, "-resume", snapshot, "-seed", "Validate", "-output", report) + if result.err == nil { + t.Fatal("resume accepted a report destination that adds a root SQL input") + } + if _, err := os.Stat(report); !os.IsNotExist(err) { + t.Fatalf("rejected resume left report behind: %v", err) + } + after, err := os.ReadFile(snapshot) + if err != nil || string(after) != string(original) { + t.Fatalf("rejected resume changed snapshot: read err=%v", err) + } +} + +func TestSaveAnalysisCannotReplaceEmbeddedAsset(t *testing.T) { + root := t.TempDir() + asset := []byte("asset contents that define the analysis") + if err := os.WriteFile(filepath.Join(root, "asset.txt"), asset, 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "source.go"), []byte("package outputalias\nimport _ \"embed\"\n//go:embed asset.txt\nvar embedded string\nfunc Validate() bool { return embedded != \"\" }\n"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example.com/outputalias\n\ngo 1.27.0\n"), 0644); err != nil { + t.Fatal(err) + } + + result := runCLI(t, "-root", root, "-seed", "Validate", "-save-analysis", filepath.Join(root, "asset.txt")) + if result.err == nil { + t.Fatal("save-analysis accepted a captured embedded asset") + } + after, err := os.ReadFile(filepath.Join(root, "asset.txt")) + if err != nil || string(after) != string(asset) { + t.Fatalf("rejected save changed embedded asset: read err=%v", err) + } +} + +func TestPairedReportCannotAliasRootSourceByHardLink(t *testing.T) { + root := t.TempDir() + writeCLIInput(t, root) + source := filepath.Join(root, "source.go") + original, err := os.ReadFile(source) + if err != nil { + t.Fatal(err) + } + report := filepath.Join(root, "report.json") + if err := os.Link(source, report); err != nil { + t.Skipf("hard links unavailable: %v", err) + } + snapshot := filepath.Join(root, "analysis.json") + result := runCLI(t, "-root", root, "-seed", "Validate", "-save-analysis", snapshot, "-output", report) + if result.err == nil { + t.Fatal("paired report accepted a hard link to an analyzed Go source") + } + if _, err := os.Stat(snapshot); !os.IsNotExist(err) { + t.Fatalf("rejected paired publication left snapshot behind: %v", err) + } + after, err := os.ReadFile(source) + if err != nil || string(after) != string(original) { + t.Fatalf("rejected hard-link report changed source: read err=%v", err) + } +} + +func TestSaveAndReportMayUseExternalSourceShapedName(t *testing.T) { + root := t.TempDir() + writeCLIInput(t, root) + external := t.TempDir() + snapshot := filepath.Join(root, "analysis.json.gz") + report := filepath.Join(external, "report.go") + created := runCLI(t, "-root", root, "-seed", "Validate", "-save-analysis", snapshot, "-output", report) + if created.err != nil { + t.Fatalf("save with external Go-named report: %v: %s", created.err, created.stderr) + } + if _, err := os.Stat(report); err != nil { + t.Fatalf("external report missing: %v", err) + } + resumed := runCLI(t, "-resume", snapshot, "-seed", "Validate", "-output", filepath.Join(external, "resumed.sql")) + if resumed.err != nil { + t.Fatalf("resume with external SQL-named report: %v: %s", resumed.err, resumed.stderr) + } +} + +func TestSaveAnalysisCannotReplaceExternalModuleInputs(t *testing.T) { + for _, destinationName := range []string{"source.go", "go.mod"} { + t.Run(destinationName, func(t *testing.T) { + root, dependency := writeExternalDependency(t) + destination := filepath.Join(dependency, destinationName) + original, err := os.ReadFile(destination) + if err != nil { + t.Fatal(err) + } + result := runCLI(t, "-root", root, "-seed", "Validate", "-save-analysis", destination) + if result.err == nil { + t.Fatalf("save-analysis accepted captured external input %q", destinationName) + } + after, err := os.ReadFile(destination) + if err != nil || string(after) != string(original) { + t.Fatalf("rejected save changed external input: read err=%v", err) + } + }) + } + + root, dependency := writeExternalDependency(t) + snapshot := filepath.Join(root, "analysis.json") + output := filepath.Join(dependency, "source.go") + original, err := os.ReadFile(output) + if err != nil { + t.Fatal(err) + } + result := runCLI(t, "-root", root, "-seed", "Validate", "-save-analysis", snapshot, "-output", output) + if result.err == nil { + t.Fatal("paired report accepted a loaded external Go source") + } + if _, err := os.Stat(snapshot); !os.IsNotExist(err) { + t.Fatalf("rejected paired publication left snapshot behind: %v", err) + } + after, err := os.ReadFile(output) + if err != nil || string(after) != string(original) { + t.Fatalf("rejected paired publication changed external source: read err=%v", err) + } +} + +func writeExternalDependency(t *testing.T) (string, string) { + t.Helper() + base := t.TempDir() + root := filepath.Join(base, "app") + dependency := filepath.Join(base, "dependency") + for _, directory := range []string{root, dependency} { + if err := os.MkdirAll(directory, 0755); err != nil { + t.Fatal(err) + } + } + files := map[string]string{ + filepath.Join(root, "go.mod"): "module example.com/app\n\ngo 1.27.0\n\nrequire example.com/dependency v0.0.0\nreplace example.com/dependency => ../dependency\n", + filepath.Join(root, "source.go"): "package app\nimport \"example.com/dependency\"\nfunc Validate() bool { return dependency.Check() }\n", + filepath.Join(dependency, "go.mod"): "module example.com/dependency\n\ngo 1.27.0\n", + filepath.Join(dependency, "source.go"): "package dependency\nfunc Check() bool { return true }\n", + } + for path, content := range files { + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } + } + return root, dependency +} diff --git a/cmd/contract-trace/snapshot_test.go b/cmd/contract-trace/snapshot_test.go new file mode 100644 index 0000000..c5a030b --- /dev/null +++ b/cmd/contract-trace/snapshot_test.go @@ -0,0 +1,51 @@ +package main + +import ( + "context" + "os" + "path/filepath" + "testing" + + trace "github.com/dominicnunez/contract-tracer" +) + +func TestSaveSnapshotCompressionAndFailedReplacement(t *testing.T) { + root := t.TempDir() + for name, contents := range map[string]string{"go.mod": "module example.com/snapshotcli\n\ngo 1.27.0\n", "source.go": "package snapshotcli\nfunc Validate(n int) bool {return n>0}\n"} { + if err := os.WriteFile(filepath.Join(root, name), []byte(contents), 0644); err != nil { + t.Fatal(err) + } + } + _, analysis, err := trace.TraceWithAnalysis(context.Background(), trace.Options{Root: root, Seeds: []string{"Validate"}, Depth: 3, MaxNodes: 100}) + if err != nil { + t.Fatal(err) + } + for _, name := range []string{"snapshot.json", "snapshot.json.gz", "snapshot.GZ"} { + destination := filepath.Join(root, name) + if err := saveSnapshot(destination, analysis); err != nil { + t.Fatal(err) + } + before, err := os.ReadFile(destination) + if err != nil { + t.Fatal(err) + } + compressed := len(before) > 2 && before[0] == 0x1f && before[1] == 0x8b + if compressed != (name != "snapshot.json") { + t.Fatalf("unexpected compression for %s", name) + } + if err := saveSnapshot(destination, trace.Analysis{}); err == nil { + t.Fatal("invalid snapshot replaced destination") + } + after, err := os.ReadFile(destination) + if err != nil || string(after) != string(before) { + t.Fatal("failed write damaged existing snapshot") + } + if err := saveSnapshot(destination, analysis); err != nil { + t.Fatal("valid replacement failed", err) + } + } + files, err := filepath.Glob(filepath.Join(root, ".contract-analysis-*")) + if err != nil || len(files) != 0 { + t.Fatal("temporary snapshots leaked", files, err) + } +} diff --git a/completion_identity_test.go b/completion_identity_test.go new file mode 100644 index 0000000..712ecf2 --- /dev/null +++ b/completion_identity_test.go @@ -0,0 +1,196 @@ +package contracttrace + +import ( + "bytes" + "context" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "testing" +) + +type identityMutation struct { + name string + path string + contents string + wantError string +} + +func TestTraceRejectsInputsChangedByFinalGoEnv(t *testing.T) { + mutator, realGo := buildIdentityMutator(t) + for _, mutation := range identityMutations() { + t.Run(mutation.name, func(t *testing.T) { + fixture := newIdentityFixture(t) + marker, countFile := configureIdentityMutator(t, mutator, realGo, fixture.root, mutation, 2) + report, analysis, err := TraceWithAnalysis(context.Background(), fixture.options()) + assertIdentityMutationRan(t, marker, countFile, 2) + if err == nil { + t.Fatalf("Trace returned a report after final go env changed %s: report schema=%q nodes=%d analysis schema=%q", mutation.name, report.Schema, len(report.Nodes), analysis.Schema) + } + if !strings.Contains(err.Error(), mutation.wantError) { + t.Fatalf("Trace rejected changed %s for the wrong reason: %v", mutation.name, err) + } + if report.Schema != "" || len(report.Nodes) != 0 || analysis.Schema != "" { + t.Fatalf("Trace returned partial graph after input identity failure: report schema=%q nodes=%d analysis schema=%q", report.Schema, len(report.Nodes), analysis.Schema) + } + }) + } +} + +func TestExploreRejectsInputsChangedByFinalGoEnv(t *testing.T) { + mutator, realGo := buildIdentityMutator(t) + for _, mutation := range identityMutations() { + t.Run(mutation.name, func(t *testing.T) { + fixture := newIdentityFixture(t) + _, analysis, err := TraceWithAnalysis(context.Background(), fixture.options()) + if err != nil { + t.Fatalf("create saved analysis: %v", err) + } + var compressed bytes.Buffer + if err := WriteAnalysis(&compressed, analysis, true); err != nil { + t.Fatalf("write saved analysis: %v", err) + } + saved, err := ReadAnalysis(&compressed) + if err != nil { + t.Fatalf("read saved analysis: %v", err) + } + + call := 1 + if mutation.name == "added dependency Go source" || mutation.name == "added embedded asset match" { + call = 2 + } + marker, countFile := configureIdentityMutator(t, mutator, realGo, fixture.root, mutation, call) + report, err := Explore(context.Background(), saved, ExploreOptions{Seeds: []string{"Seed"}, Depth: 2, MaxNodes: 50}) + assertIdentityMutationRan(t, marker, countFile, call) + if err == nil { + t.Fatalf("Explore returned a stale graph after final go env changed %s", mutation.name) + } + if !strings.Contains(err.Error(), mutation.wantError) { + t.Fatalf("Explore rejected changed %s for the wrong reason: %v", mutation.name, err) + } + if report.Schema != "" || len(report.Nodes) != 0 { + t.Fatalf("Explore returned partial graph after input identity failure: %+v", report) + } + }) + } +} + +func identityMutations() []identityMutation { + return []identityMutation{ + {name: "root Go source", path: "ignored.go", contents: "//go:build identity_mutator_off\n\npackage app\n\nconst ignored = 2\n", wantError: "source changed during analysis"}, + {name: "root SQL source", path: "schema.sql", contents: "\n-- changed after identity check\n", wantError: "source changed during analysis"}, + {name: "root go.mod", path: "go.mod", contents: "module example.com/app\n\ngo 1.27.0\n\nrequire example.com/dependency v0.0.0\nreplace example.com/dependency => ../dependency\n\n// changed after identity check\n", wantError: "resolution input check: loaded source changed"}, + {name: "root go.sum", path: "go.sum", contents: "example.com/unused v1.0.0 h1:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n", wantError: "source changed during analysis"}, + {name: "selected embedded asset", path: "asset.txt", contents: "changed asset", wantError: "embedded assets changed during analysis"}, + {name: "added embedded asset match", path: filepath.Join("assets", "late.txt"), contents: "late", wantError: "embedded asset set changed"}, + {name: "loaded dependency Go source", path: filepath.Join("..", "dependency", "dependency.go"), contents: "package dependency\nfunc Marker() int { return 2 }\n", wantError: "loaded source changed"}, + {name: "added dependency Go source", path: filepath.Join("..", "dependency", "added.go"), contents: "package dependency\nfunc Added() int { return 2 }\n", wantError: "selected Go source set changed"}, + {name: "dependency resolution manifest", path: filepath.Join("..", "dependency", "go.mod"), contents: "module example.com/dependency\n\ngo 1.27.0\n\n// changed after identity check\n", wantError: "resolution input check: loaded source changed"}, + } +} + +type identityFixture struct { + root string +} + +func newIdentityFixture(t *testing.T) identityFixture { + t.Helper() + base := t.TempDir() + root := filepath.Join(base, "app") + dependency := filepath.Join(base, "dependency") + for _, directory := range []string{root, dependency} { + if err := os.MkdirAll(directory, 0700); err != nil { + t.Fatal(err) + } + } + files := map[string]string{ + filepath.Join(root, "go.mod"): "module example.com/app\n\ngo 1.27.0\n\nrequire example.com/dependency v0.0.0\nreplace example.com/dependency => ../dependency\n", + filepath.Join(root, "go.sum"): "", + filepath.Join(root, "app.go"): "package app\n\nimport (\n\t\"embed\"\n\t\"example.com/dependency\"\n)\n\n//go:embed asset.txt\nvar asset []byte\n\n//go:embed assets/*.txt\nvar allAssets embed.FS\n\nfunc Seed() { _ = dependency.Marker(); _ = asset; _ = allAssets }\n", + filepath.Join(root, "ignored.go"): "//go:build identity_mutator_off\n\npackage app\n\nconst ignored = 1\n", + filepath.Join(root, "asset.txt"): "embedded asset", + filepath.Join(root, "schema.sql"): "CREATE TABLE records (id INTEGER PRIMARY KEY);\n", + filepath.Join(dependency, "go.mod"): "module example.com/dependency\n\ngo 1.27.0\n", + filepath.Join(dependency, "dependency.go"): "package dependency\nfunc Marker() int { return 1 }\n", + } + for path, contents := range files { + if err := os.WriteFile(path, []byte(contents), 0600); err != nil { + t.Fatal(err) + } + } + if err := os.Mkdir(filepath.Join(root, "assets"), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "assets", "initial.txt"), []byte("initial"), 0600); err != nil { + t.Fatal(err) + } + t.Setenv("GOWORK", "off") + return identityFixture{root: root} +} + +func (f identityFixture) options() Options { + return Options{Root: f.root, Seeds: []string{"Seed", "schema.sql"}, Depth: 2, MaxNodes: 50} +} + +func buildIdentityMutator(t *testing.T) (string, string) { + t.Helper() + realGo, err := exec.LookPath("go") + if err != nil { + t.Fatalf("find real Go command: %v", err) + } + _, testFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("locate test file") + } + root := filepath.Dir(testFile) + outputDir := t.TempDir() + name := "go" + if runtime.GOOS == "windows" { + name += ".exe" + } + mutator := filepath.Join(outputDir, name) + command := exec.Command(realGo, "build", "-buildvcs=false", "-o", mutator, "./testdata/go-env-mutator") + command.Dir = root + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("build local go env mutator: %v\n%s", err, output) + } + return mutator, realGo +} + +func configureIdentityMutator(t *testing.T, mutator, realGo, root string, mutation identityMutation, call int) (string, string) { + t.Helper() + binDir := filepath.Dir(mutator) + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("CONTRACT_TRACE_REAL_GO", realGo) + countFile := filepath.Join(t.TempDir(), "env-count") + marker := filepath.Join(t.TempDir(), "mutated") + t.Setenv("CONTRACT_TRACE_ENV_COUNT", countFile) + t.Setenv("CONTRACT_TRACE_MUTATE_ON", strconv.Itoa(call)) + t.Setenv("CONTRACT_TRACE_MUTATE_PATH", filepath.Join(root, mutation.path)) + t.Setenv("CONTRACT_TRACE_MUTATE_CONTENT", mutation.contents) + t.Setenv("CONTRACT_TRACE_MUTATION_MARKER", marker) + return marker, countFile +} + +func assertIdentityMutationRan(t *testing.T, marker, countFile string, expectedCall int) { + t.Helper() + markerContents, err := os.ReadFile(marker) + if err != nil { + t.Fatalf("controlled go env did not mutate on full environment call %d: %v", expectedCall, err) + } + contents, err := os.ReadFile(countFile) + if err != nil { + t.Fatalf("read controlled go env call count: %v", err) + } + count, err := strconv.Atoi(string(contents)) + if err != nil || count < expectedCall { + t.Fatalf("controlled go env call count %q, want at least %d", contents, expectedCall) + } + mutationCall, err := strconv.Atoi(string(markerContents)) + if err != nil || mutationCall != expectedCall { + t.Fatalf("controlled mutation happened on go env call %q, want call %d", markerContents, expectedCall) + } +} diff --git a/compressed_analysis_test.go b/compressed_analysis_test.go new file mode 100644 index 0000000..99a5d9f --- /dev/null +++ b/compressed_analysis_test.go @@ -0,0 +1,108 @@ +package contracttrace + +import ( + "bytes" + "compress/gzip" + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "reflect" + "testing" +) + +func TestCompressedAnalysisRetainsGraphAndStrictInputChecks(t *testing.T) { + _, original, err := TraceWithAnalysis(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"Validate"}, Depth: 3, MaxNodes: 100}) + if err != nil { + t.Fatal(err) + } + plain, err := json.Marshal(original) + if err != nil { + t.Fatal(err) + } + compress := func(data []byte) []byte { + var output bytes.Buffer + writer := gzip.NewWriter(&output) + if _, err := writer.Write(data); err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + return output.Bytes() + } + compressed := compress(plain) + loaded, err := ReadAnalysis(bytes.NewReader(compressed)) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(original, loaded) { + t.Fatal("compressed graph changed content") + } + before, err := Explore(context.Background(), original, ExploreOptions{Seeds: []string{"Validate"}, Depth: 3, MaxNodes: 100}) + if err != nil { + t.Fatal(err) + } + after, err := Explore(context.Background(), loaded, ExploreOptions{Seeds: []string{"Validate"}, Depth: 3, MaxNodes: 100}) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(before, after) { + t.Fatal("compressed saved exploration changed scope") + } + for _, gzipCompressed := range []bool{false, true} { + var output bytes.Buffer + if err := WriteAnalysis(&output, original, gzipCompressed); err != nil { + t.Fatal(err) + } + written, err := ReadAnalysis(bytes.NewReader(output.Bytes())) + if err != nil || !reflect.DeepEqual(original, written) { + t.Fatalf("writer round trip gzip=%v: %v", gzipCompressed, err) + } + if err := WriteAnalysis(analysisFailWriter{}, original, gzipCompressed); err == nil { + t.Fatalf("writer failure lost gzip=%v", gzipCompressed) + } + } + corrupt := append([]byte(nil), compressed...) + corrupt[len(corrupt)-1] ^= 1 + unknown := append([]byte(`{"unexpected":true,`), plain[1:]...) + for name, data := range map[string][]byte{"checksum": corrupt, "truncated": compressed[:len(compressed)-5], "second JSON": compress(append(append([]byte(nil), plain...), []byte(" {}")...)), "unknown field": compress(unknown), "trailing bytes": append(append([]byte(nil), compressed...), []byte("garbage")...)} { + t.Run(name, func(t *testing.T) { + if _, err := ReadAnalysis(bytes.NewReader(data)); err == nil { + t.Fatal("invalid compressed input accepted") + } + }) + } +} + +type analysisFailWriter struct{} + +func (analysisFailWriter) Write([]byte) (int, error) { return 0, errors.New("write rejected") } + +func TestCompressedAnalysisStillRejectsChangedSource(t *testing.T) { + root := t.TempDir() + for name, contents := range map[string]string{"go.mod": "module example.com/compressed\n\ngo 1.27.0\n", "source.go": "package compressed\nfunc Validate(n int) bool {return n>0}\n"} { + if err := os.WriteFile(filepath.Join(root, name), []byte(contents), 0644); err != nil { + t.Fatal(err) + } + } + _, original, err := TraceWithAnalysis(context.Background(), Options{Root: root, Seeds: []string{"Validate"}, Depth: 3, MaxNodes: 100}) + if err != nil { + t.Fatal(err) + } + var output bytes.Buffer + if err := WriteAnalysis(&output, original, true); err != nil { + t.Fatal(err) + } + loaded, err := ReadAnalysis(bytes.NewReader(output.Bytes())) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "source.go"), []byte("package compressed\nfunc Validate(n int) bool {return n>1}\n"), 0644); err != nil { + t.Fatal(err) + } + if _, err := Explore(context.Background(), loaded, ExploreOptions{Seeds: []string{"Validate"}, Depth: 3, MaxNodes: 100}); err == nil { + t.Fatal("compression bypassed source identity check") + } +} diff --git a/config.go b/config.go new file mode 100644 index 0000000..5cf2828 --- /dev/null +++ b/config.go @@ -0,0 +1,32 @@ +package contracttrace + +import ( + "encoding/json" + "fmt" + "io" +) + +func ReadConfig(reader io.Reader) (Config, error) { + config := DefaultConfig() + decoder := json.NewDecoder(reader) + decoder.DisallowUnknownFields() + target := &config + if err := decoder.Decode(&target); err != nil { + return Config{}, err + } + if target == nil { + return Config{}, fmt.Errorf("configuration must be a JSON object") + } + var extra any + if err := decoder.Decode(&extra); err != io.EOF { + if err == nil { + return Config{}, fmt.Errorf("configuration must contain one JSON object") + } + return Config{}, err + } + config = normalizeConfig(config) + if err := validateConfig(config); err != nil { + return Config{}, err + } + return config, nil +} diff --git a/config_test.go b/config_test.go new file mode 100644 index 0000000..a846a4a --- /dev/null +++ b/config_test.go @@ -0,0 +1,227 @@ +package contracttrace + +import ( + "context" + "fmt" + "strings" + "testing" +) + +func TestTypedAPIRules(t *testing.T) { + handler := 1 + config := Config{CallRules: []CallRule{ + {Symbol: "example.com/sample::execute", Kind: "sql_query", Argument: 2}, + {Symbol: "example.com/sample::sendAPI", Kind: "event_publish", Argument: 1, Namespace: "orders"}, + {Symbol: "example.com/sample::subscribeAPI", Kind: "event_subscribe", Argument: 0, HandlerArgument: &handler, Namespace: "orders"}, + }} + r, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"APIWrite", "APIPublish"}, Depth: 4, MaxNodes: 200, Config: config}) + if err != nil { + t.Fatal(err) + } + for _, name := range []string{"APIRead", "APISetup", "APIHandler"} { + if !hasName(r, name) { + t.Errorf("missing API contract path %s", name) + } + } + registered := false + for _, e := range r.Relationships { + if strings.HasSuffix(e.From, "::APIHandler") && e.To == "event:orders:order.ready" && e.Kind == "event_handler" { + registered = true + } + } + if !registered { + t.Error("subscription did not connect its callback handler to the event resource") + } +} +func TestInvalidCallRule(t *testing.T) { + _, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"Validate"}, Depth: 2, MaxNodes: 50, Config: Config{CallRules: []CallRule{{Symbol: "anything", Kind: "invented", Argument: -1}}}}) + if err == nil { + t.Error("invalid rule accepted") + } +} + +func TestStrictConfigAndResourceIdentity(t *testing.T) { + for _, text := range []string{`null`, `{"typo":true}`, `{} {}`, `{} trailing`, `{"call_rules":[{"symbol":"x::y","kind":"invented","argument":0}]}`} { + if _, err := ReadConfig(strings.NewReader(text)); err == nil { + t.Errorf("invalid config accepted: %s", text) + } + } + c, err := ReadConfig(strings.NewReader(`{"event_fields":[]}`)) + if err != nil { + t.Fatal(err) + } + if len(c.EventFields) != 0 || len(c.SQLMethods) == 0 { + t.Error("empty arrays and default fields were not distinguished") + } + if resourceID("event", "orders", "ready") == resourceID("event", "", "orders:ready") { + t.Error("namespace collided with an event value") + } +} + +func TestSQLFilePatternsRejectSilentExclusions(t *testing.T) { + for _, pattern := range []string{"[", "../schema.sql", "/schema.sql", "C:/schema.sql", "", "a//b.sql", `a\b.sql`} { + config := DefaultConfig() + config.SQLFiles = []string{pattern} + if err := validateConfig(config); err == nil { + t.Errorf("invalid SQL file pattern accepted: %q", pattern) + } + } + for _, pattern := range []string{"**/*.sql", "migrations/**/*.sql", "schema.sql", "migrations/[0-9]*.sql"} { + config := DefaultConfig() + config.SQLFiles = []string{pattern} + if err := validateConfig(config); err != nil { + t.Errorf("valid SQL file pattern rejected: %q: %v", pattern, err) + } + } +} + +func TestConfigurationNameWhitespaceRejectedByBothEntrypoints(t *testing.T) { + type nameList struct { + field string + canonical string + set func(*Config, string) + } + lists := []nameList{ + {"sql_methods", "Query", func(c *Config, value string) { c.SQLMethods = []string{value} }}, + {"event_fields", "EventType", func(c *Config, value string) { c.EventFields = []string{value} }}, + {"lifecycle_names", "shutdown", func(c *Config, value string) { c.LifecycleNames = []string{value} }}, + } + for _, list := range lists { + values := []string{"", " " + list.canonical, list.canonical + " "} + switch list.field { + case "sql_methods": + values = append(values, "Que ry", "Que\u2009ry") + case "event_fields": + values = append(values, "Event Type", "Event\u00a0Type") + case "lifecycle_names": + values = append(values, "shut down", "shut\u2009down") + } + for _, value := range values { + t.Run(list.field+fmt.Sprintf("/%q", value), func(t *testing.T) { + text := fmt.Sprintf(`{"%s":[%q]}`, list.field, value) + if _, err := ReadConfig(strings.NewReader(text)); err == nil { + t.Errorf("ReadConfig accepted whitespace-padded %s name %q", list.field, value) + } + + config := DefaultConfig() + list.set(&config, value) + _, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"Validate"}, Depth: 1, MaxNodes: 20, Config: config}) + if err == nil { + t.Errorf("Trace accepted whitespace-padded %s name %q", list.field, value) + } + }) + } + + text := fmt.Sprintf(`{"%s":[%q]}`, list.field, list.canonical) + if _, err := ReadConfig(strings.NewReader(text)); err != nil { + t.Errorf("ReadConfig canonical %s name: %v", list.field, err) + } + } + + config := DefaultConfig() + config.SQLMethods = []string{"Query"} + config.EventFields = []string{"EventType"} + config.LifecycleNames = []string{"shutdown"} + if _, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"Validate"}, Depth: 1, MaxNodes: 20, Config: config}); err != nil { + t.Fatalf("Trace with canonical configured names: %v", err) + } +} + +func TestCallRuleSymbolMustHaveTrimmedQualifiedParts(t *testing.T) { + for _, symbol := range []string{ + " example.com/sample::sendAPI", + "example.com/sample::sendAPI ", + "example.com/sample ::sendAPI", + "example.com/sample:: sendAPI", + "::sendAPI", + "example.com/sample::", + "example.com/sample::sendAPI::extra", + } { + t.Run(fmt.Sprintf("%q", symbol), func(t *testing.T) { + text := fmt.Sprintf(`{"call_rules":[{"symbol":%q,"kind":"event_publish","argument":0}]}`, symbol) + if _, err := ReadConfig(strings.NewReader(text)); err == nil { + t.Errorf("ReadConfig accepted malformed call-rule symbol %q", symbol) + } + _, err := Trace(context.Background(), Options{ + Root: "testdata/sample", Seeds: []string{"Validate"}, Depth: 1, MaxNodes: 20, + Config: Config{CallRules: []CallRule{{Symbol: symbol, Kind: "event_publish", Argument: 0}}}, + }) + if err == nil { + t.Errorf("Trace accepted malformed call-rule symbol %q", symbol) + } + }) + } + + const canonical = "example.com/sample::sendAPI" + text := fmt.Sprintf(`{"call_rules":[{"symbol":%q,"kind":"event_publish","argument":0}]}`, canonical) + if _, err := ReadConfig(strings.NewReader(text)); err != nil { + t.Fatalf("ReadConfig canonical call-rule symbol: %v", err) + } + if _, err := Trace(context.Background(), Options{ + Root: "testdata/sample", Seeds: []string{"Validate"}, Depth: 1, MaxNodes: 20, + Config: Config{CallRules: []CallRule{{Symbol: canonical, Kind: "event_publish", Argument: 0}}}, + }); err != nil { + t.Fatalf("Trace with canonical call-rule symbol: %v", err) + } +} + +func TestOtherConfiguredSymbolsRejectWhitespaceWithinQualifiedParts(t *testing.T) { + tests := []struct { + name string + canonical string + json string + configure func(string) Config + }{ + { + name: "lifecycle rule", + canonical: "example.com/sample::Acquire", + json: `{"lifecycle_rules":[{"symbol":%q,"role":"acquire","namespace":"leases","identity":"origin","result":0}]}`, + configure: func(symbol string) Config { + return Config{LifecycleRules: []LifecycleRule{{Symbol: symbol, Role: "acquire", Namespace: "leases", Identity: "origin", Result: intPointer(0)}}} + }, + }, + { + name: "database origin", + canonical: "example.com/sample::OpenPrimary", + json: `{"storage_scopes":[{"namespace":"primary","database_origins":[%q]}]}`, + configure: func(symbol string) Config { + return Config{StorageScopes: []StorageScope{{Namespace: "primary", DatabaseOrigins: []string{symbol}}}} + }, + }, + } + invalid := []string{ + " example.com/sample::Acquire", + "example.com/sample::Acquire ", + "example.com/sample ::Acquire", + "example.com/sample:: Acquire", + "example.com/sample::Ac quire", + "::Acquire", + "example.com/sample::", + "example.com/sample::Acquire::extra", + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + for _, symbol := range invalid { + t.Run(fmt.Sprintf("%q", symbol), func(t *testing.T) { + text := fmt.Sprintf(tt.json, symbol) + if _, err := ReadConfig(strings.NewReader(text)); err == nil { + t.Errorf("ReadConfig accepted malformed %s symbol %q", tt.name, symbol) + } + _, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"Validate"}, Depth: 1, MaxNodes: 20, Config: tt.configure(symbol)}) + if err == nil { + t.Errorf("Trace accepted malformed %s symbol %q", tt.name, symbol) + } + }) + } + text := fmt.Sprintf(tt.json, tt.canonical) + if _, err := ReadConfig(strings.NewReader(text)); err != nil { + t.Errorf("ReadConfig rejected canonical %s symbol: %v", tt.name, err) + } + if _, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"Validate"}, Depth: 1, MaxNodes: 20, Config: tt.configure(tt.canonical)}); err != nil { + t.Errorf("Trace rejected canonical %s symbol: %v", tt.name, err) + } + }) + } +} + +func intPointer(value int) *int { return &value } diff --git a/constructor_instruction_test.go b/constructor_instruction_test.go new file mode 100644 index 0000000..aad3c7f --- /dev/null +++ b/constructor_instruction_test.go @@ -0,0 +1,146 @@ +package contracttrace + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestGoAndDeferDiscardedConstructorsRemainPossibleCandidates(t *testing.T) { + root := t.TempDir() + files := map[string]string{ + "go.mod": "module example.com/instructionconstructors\n\ngo 1.27.0\n", + "calls.go": `package app + +import ( + "context" + "database/sql" + "time" +) + +func GoContext(parent context.Context) { + go context.WithTimeout(parent, time.Second) +} + +func DeferContext(parent context.Context) { + defer context.WithCancel(parent) +} + +func GoSQL() { + go sql.Open("sqlite", ":memory:") +} + +func DeferSQL() { + defer sql.Open("sqlite", ":memory:") +} + +func DirectContext(parent context.Context) { + ctx, cancel := context.WithCancel(parent) + defer cancel() + _ = ctx +} + +func DirectSQL() { + db, _ := sql.Open("sqlite", ":memory:") + _ = db +} +`, + } + for name, source := range files { + path := filepath.Join(root, name) + if err := os.WriteFile(path, []byte(source), 0600); err != nil { + t.Fatal(err) + } + } + owners := []string{"GoContext", "DeferContext", "GoSQL", "DeferSQL", "DirectContext", "DirectSQL"} + seeds := make([]string, len(owners)) + for i, owner := range owners { + seeds[i] = owner + } + report, err := Trace(context.Background(), Options{Root: root, Seeds: seeds, Depth: 3, MaxNodes: 100}) + if err != nil { + t.Fatal(err) + } + + for _, want := range []struct { + owner, kind, call string + certainty string + }{ + {"GoContext", "context_create", "go context.WithTimeout", "possible"}, + {"DeferContext", "context_create", "defer context.WithCancel", "possible"}, + {"GoSQL", "sql_handle_create", "go sql.Open", "possible"}, + {"DeferSQL", "sql_handle_create", "defer sql.Open", "possible"}, + {"DirectContext", "context_create", "context.WithCancel", "fact"}, + {"DirectSQL", "sql_handle_create", "sql.Open", "fact"}, + } { + owner := "example.com/instructionconstructors::" + want.owner + found := false + for _, edge := range report.Relationships { + if edge.From == owner && edge.Kind == want.kind && edge.Evidence.File == "calls.go" && + strings.Contains(edge.Evidence.Snippet, want.call) { + if edge.Certainty != want.certainty { + t.Errorf("%s %s candidate certainty=%s, want %s", want.owner, want.kind, edge.Certainty, want.certainty) + } + if want.kind == "sql_handle_create" && !reportHasNode(report, edge.To, "database") { + t.Errorf("%s SQL constructor candidate %s has no database resource node", want.owner, edge.To) + } + found = true + break + } + } + if !found { + t.Errorf("%s missing %s candidate with source evidence %q", want.owner, want.kind, want.call) + } + } + + for _, want := range []struct{ owner, call string }{ + {"GoContext", "go context.WithTimeout"}, + {"DeferContext", "defer context.WithCancel"}, + } { + if !reportHasBoundary(report, "example.com/instructionconstructors::"+want.owner, "unobserved_cancel", "calls.go", want.call) { + t.Errorf("%s missing unobserved-cancel uncertainty for %s", want.owner, want.call) + } + } + for _, want := range []struct{ owner, call string }{ + {"GoSQL", "go sql.Open"}, + {"DeferSQL", "defer sql.Open"}, + } { + count := 0 + for _, boundary := range report.Boundaries { + if boundary.Node == "example.com/instructionconstructors::"+want.owner && boundary.Kind == "discarded_sql_handle" && + boundary.Evidence.File == "calls.go" && strings.Contains(boundary.Evidence.Snippet, want.call) { + count++ + } + } + if count != 1 { + t.Errorf("%s has %d discarded-database-handle boundaries for %s, want exactly one", want.owner, count, want.call) + } + } + if !reportHasBoundary(report, "example.com/instructionconstructors::GoSQL", "concurrency", "calls.go", "go sql.Open") { + t.Error("go SQL constructor lost goroutine execution uncertainty") + } + if !reportHasBoundary(report, "example.com/instructionconstructors::DeferSQL", "cleanup", "calls.go", "defer sql.Open") { + t.Error("deferred SQL constructor lost cleanup-registration uncertainty") + } +} + +func reportHasNode(report Report, id, kind string) bool { + for _, node := range report.Nodes { + if node.ID == id && node.Kind == kind { + return true + } + } + return false +} + +func reportHasBoundary(report Report, owner, kind, file, snippet string) bool { + for _, boundary := range report.Boundaries { + if boundary.Node == owner && boundary.Kind == kind && boundary.Evidence.File == file && + strings.Contains(boundary.Evidence.Snippet, snippet) { + return true + } + } + return false +} diff --git a/containers.go b/containers.go new file mode 100644 index 0000000..2367bc8 --- /dev/null +++ b/containers.go @@ -0,0 +1,420 @@ +package contracttrace + +import ( + "fmt" + "go/ast" + "go/constant" + "go/token" + "go/types" + "strconv" + "strings" + + "golang.org/x/tools/go/ssa" +) + +func inlineAggregate(t types.Type) bool { + switch t.Underlying().(type) { + case *types.Struct, *types.Array: + return true + } + return false +} + +func (a *flowAnalysis) sequenceElements(value ssa.Value) flowValue { + result := emptyFlow() + sequence, ok := value.Type().Underlying().(*types.Slice) + if !ok { + return result + } + for _, root := range sortedKeys(a.get(value).addresses) { + address := root + ".element" + a.merge(&result, a.memory[address]) + if sequenceMayHaveElement(value) { + result.functionNil = result.functionNil || a.zeroFunctionElements[address] + result.functionUnknown = result.functionUnknown || a.unknownFunctionElements[address] + } + if inlineAggregate(sequence.Elem()) { + result.addresses[address] = true + marker := a.get(value) + if sequenceMayHaveElement(value) && exactZeroAggregateMatches(marker, sequence.Elem()) { + a.addZeroAggregate(&result, sequence.Elem()) + } + if sequenceMayHaveElement(value) && marker.zeroAggregateUnknown { + a.markZeroAggregateUnknown(&result) + } + } + } + return result +} + +// Element candidates are unordered and monotone. Append may reuse its backing +// array or allocate a new one; copy may move any prefix up to the shorter length. +// Preserve both append storage candidates and all modeled source elements. +func (a *flowAnalysis) sliceBuiltin(call ssa.CallInstruction, ix *index, result *flowValue) bool { + common := call.Common() + builtin, ok := common.Value.(*ssa.Builtin) + if !ok || len(common.Args) != 2 || (builtin.Name() != "append" && builtin.Name() != "copy") { + return false + } + destinations := a.get(common.Args[0]) + elements := a.sequenceElements(common.Args[1]) + if builtin.Name() == "append" { + value, ok := call.(*ssa.Call) + if !ok { + return false + } + a.merge(&elements, a.sequenceElements(common.Args[0])) + root := ix.allocationID("append", call.Parent(), value) + candidate := emptyFlow() + candidate.addresses[root] = true + a.merge(&destinations, candidate) + a.addressTypes[root] = value.Type() + a.merge(result, destinations) + } + changed := false + for _, root := range sortedKeys(destinations.addresses) { + if a.store(root+".element", elements) { + changed = true + } + } + return changed +} + +func (a *flowAnalysis) store(key string, value flowValue) bool { + old := a.memory[key] + aliases := len(old.addresses) + changed := a.merge(&old, value) + a.memory[key] = old + if changed { + if a.memoryVersions == nil { + a.memoryVersions = map[string]uint64{} + } + a.memoryVersions[key]++ + } + if len(old.addresses) != aliases { + if a.aliasVersions == nil { + a.aliasVersions = map[string]uint64{} + } + a.aliasVersions[key]++ + } + return changed +} + +func (a *flowAnalysis) storeMap(root, key string, value flowValue) bool { + if a.mapEntries == nil { + a.mapEntries = map[string]map[string]bool{} + } + if a.mapEntries[root] == nil { + a.mapEntries[root] = map[string]bool{} + } + address := root + ".key:" + key + a.mapEntries[root][address] = true + return a.store(address, value) +} + +func isMapValue(value ssa.Value) bool { + if value == nil || value.Type() == nil { + return false + } + return isMapType(value.Type()) +} + +func isMapType(t types.Type) bool { + if t == nil { + return false + } + _, ok := types.Unalias(t).Underlying().(*types.Map) + return ok +} + +func (a *flowAnalysis) openMap(root string) bool { + if a.mapOpen == nil { + a.mapOpen = map[string]bool{} + } + if a.mapOpen[root] { + return false + } + a.mapOpen[root] = true + return true +} + +func (a *flowAnalysis) openMapValue(value ssa.Value) bool { + changed := false + for _, root := range sortedKeys(a.get(value).addresses) { + if a.openMap(root) { + changed = true + } + } + return changed +} + +// Literal MapUpdates are the only writes for which the analyzer currently +// retains a closed key set. Later updates and wildcard keys invalidate absence +// claims rather than making a historical entry definite. +func (a *flowAnalysis) mapUpdateOpens(function *ssa.Function, update *ssa.MapUpdate, ix *index) bool { + if update == nil || !isMapValue(update.Map) { + return false + } + mapValue, ok := update.Map.(*ssa.MakeMap) + if !ok || !a.mapUpdateInLiteral(function, update, mapValue, ix) { + return true + } + keys := a.mapKeys(update.Key) + return len(keys) != 1 || keys[0] == "*" +} + +func (a *flowAnalysis) mapUpdateInLiteral(function *ssa.Function, update *ssa.MapUpdate, allocation *ssa.MakeMap, ix *index) bool { + if function == nil || function.Syntax() == nil || update == nil || allocation == nil || ix == nil || ix.fset == nil { + return false + } + owner := ix.funcs[ix.owner(function)] + if owner == nil || owner.pkg == nil || owner.pkg.TypesInfo == nil { + return false + } + updatePos, allocationPos := update.Pos(), allocation.Pos() + if updatePos == token.NoPos || allocationPos == token.NoPos { + return false + } + matched := false + ast.Inspect(function.Syntax(), func(node ast.Node) bool { + literal, ok := node.(*ast.CompositeLit) + if !ok || literal.Pos() > updatePos || literal.End() < updatePos || literal.Pos() > allocationPos || literal.End() < allocationPos { + return true + } + typ := owner.pkg.TypesInfo.TypeOf(literal) + if typ == nil { + return true + } + _, mapLiteral := typ.Underlying().(*types.Map) + if mapLiteral && types.Identical(types.Unalias(typ), types.Unalias(allocation.Type())) { + matched = true + return false + } + return true + }) + return matched +} + +func (a *flowAnalysis) openMutatedMaps(call *ssa.Call) bool { + if call == nil || len(call.Common().Args) == 0 { + return false + } + builtin, ok := call.Common().Value.(*ssa.Builtin) + if !ok || (builtin.Name() != "delete" && builtin.Name() != "clear") { + return false + } + return a.openMapValue(call.Common().Args[0]) +} + +// Quoted strings and typed constants avoid collisions with the unknown-key marker. +func (a *flowAnalysis) mapKeys(value ssa.Value) []string { + if c, ok := value.(*ssa.Const); ok && c.Value != nil && c.Value.Kind() != constant.String { + return []string{c.Type().String() + ":" + c.Value.ExactString()} + } + keys := []string{} + candidates := a.get(value) + for _, s := range sortedKeys(candidates.strings) { + keys = append(keys, strconv.Quote(s)) + } + if len(keys) == 0 { + return []string{"*"} + } + if candidates.stringUnknown { + keys = append(keys, "*") + } + return keys +} +func (a *flowAnalysis) mapLookup(value, key ssa.Value) flowValue { + result := emptyFlow() + mapFlow := a.get(value) + mapType, isMap := types.Unalias(value.Type()).Underlying().(*types.Map) + if !isMap { + return result + } + keys := a.mapKeys(key) + for _, p := range sortedKeys(mapFlow.addresses) { + prefix := p + ".key:" + for _, k := range keys { + if k == "*" { + for _, address := range sortedKeys(a.mapEntries[p]) { + a.merge(&result, a.memory[address]) + } + } else { + a.merge(&result, a.memory[prefix+k]) + } + a.merge(&result, a.memory[prefix+"*"]) + missing := (k == "*" && !a.mapEntries[p][prefix+"*"]) || + (k != "*" && !a.mapEntries[p][prefix+k] && !a.mapEntries[p][prefix+"*"]) + zeroAlternative := a.mapClosed[p] && (missing || a.mapOpen[p]) + unknownAlternative := !a.mapClosed[p] || a.mapOpen[p] + addTypedAlternative(a, &result, mapType.Elem(), zeroAlternative, unknownAlternative) + } + } + if mapFlow.mapNil { + addTypedAlternative(a, &result, mapType.Elem(), true, false) + } + if mapFlow.interfaceUnknown { + addTypedAlternative(a, &result, mapType.Elem(), false, true) + } + return result +} + +func addTypedAlternative(a *flowAnalysis, value *flowValue, element types.Type, zero, unknown bool) { + if value == nil || (!zero && !unknown) { + return + } + switch { + case isFunctionType(element): + value.functionNil = value.functionNil || zero + value.functionUnknown = value.functionUnknown || unknown + case inlineAggregate(element): + if zero { + a.addZeroAggregate(value, element) + } + if unknown { + a.markZeroAggregateUnknown(value) + } + case isSQLHandleType(element): + value.sqlUnknown = true + case isMapType(element): + value.mapNil = value.mapNil || zero + value.interfaceUnknown = value.interfaceUnknown || unknown + default: + markMapElementAlternative(value, element, true) + } +} + +func markMapElementAlternative(value *flowValue, element types.Type, possible bool) { + if !possible { + return + } + if isSQLHandleType(element) { + value.sqlUnknown = true + return + } + switch types.Unalias(element).Underlying().(type) { + case *types.Pointer, *types.Interface, *types.Chan, *types.Map, *types.Slice: + value.interfaceUnknown = true + } +} + +// SSA Next slots are (ok, key, value). Each iteration retains an unordered +// union, with keys and values separate; pairing and iteration order are lost. +func (a *flowAnalysis) mapIterationValue(iterator ssa.Value, slot int, typ types.Type) flowValue { + result := emptyFlow() + if slot != 1 && slot != 2 { + return result + } + for _, root := range sortedKeys(a.get(iterator).addresses) { + if slot == 1 { + a.merge(&result, a.memory[root+".keys"]) + continue + } + addresses := map[string]bool{root + ".key:*": true} + for address := range a.mapEntries[root] { + addresses[address] = true + } + for _, address := range sortedKeys(addresses) { + a.merge(&result, a.memory[address]) + if inlineAggregate(typ) { + result.addresses[address] = true + } + } + } + return result +} + +func (a *flowAnalysis) send(channel, value ssa.Value, block *ssa.BasicBlock) bool { + changed := false + sent := a.get(value) + if isScalarType(value.Type()) { + a.mergeScalar(&sent, a.scalarControlValue(block)) + } + for _, p := range sortedKeys(a.get(channel).addresses) { + if a.store(p+".sent", sent) { + changed = true + } + } + return changed +} +func (a *flowAnalysis) receive(channel ssa.Value) flowValue { + result := emptyFlow() + for _, p := range sortedKeys(a.get(channel).addresses) { + a.merge(&result, a.memory[p+".sent"]) + } + return result +} +func (ix *index) channelID(pos token.Pos) string { + e := ix.evidence(pos) + return resourceID("channel", "", fmt.Sprintf("%s:%d:%d", e.File, e.Line, e.Column)) +} +func (a *flowAnalysis) channelUse(ins ssa.Instruction, from string, ix *index) { + link := func(value ssa.Value, kind string, pos token.Pos) { + found := false + flow := a.get(value) + if kind == "channel_receive" || kind == "channel_select_receive" { + _, closeSites, orderUncertain := a.receiveWithClosedChannelValue(value, a.channelClosures) + if len(closeSites) > 0 { + examples := make([]string, 0, len(closeSites)) + for _, closePos := range closeSites { + evidence := ix.evidence(closePos) + examples = append(examples, fmt.Sprintf("close at %s:%d:%d", evidence.File, evidence.Line, evidence.Column)) + } + reason := "a modeled close permits the element zero value, which can carry a nil callback through an inline aggregate, after queued values drain; send/close ordering and queue occupancy are not proved" + if orderUncertain { + reason = "a modeled close and send candidates coexist; receive ordering, queue occupancy and whether a queued element or element zero value (which can carry a nil callback through an inline aggregate) is returned are unresolved" + } + ix.boundaries = append(ix.boundaries, Boundary{Node: from, Kind: "closed_function_channel_zero", Reason: reason, Evidence: ix.evidence(pos), Examples: examples}) + } + } + for _, effect := range sortedKeys(flow.effects) { + if strings.HasPrefix(effect, "nil_done:") { + found = true + if kind == "channel_receive" { + ix.boundaries = append(ix.boundaries, Boundary{Node: from, Kind: "nil_context_done_wait", Reason: "receive has a modeled nil Done channel candidate; it cannot be released by cancellation of that context", Evidence: ix.evidence(pos)}) + } else if kind == "channel_select_receive" { + ix.boundaries = append(ix.boundaries, Boundary{Node: from, Kind: "nil_context_done_select", Reason: "select has a modeled nil Done channel candidate; that arm is disabled for this candidate", Evidence: ix.evidence(pos)}) + } + } + } + for key := range a.get(value).addresses { + if strings.HasPrefix(key, "channel:") { + found = true + ix.edge(from, key, kind, "possible", pos) + } + } + if !found || flow.interfaceUnknown || flow.sqlUnknown || flow.boundReceiverUnknown { + reason := "channel operation has no modeled creation site; input or alias may be external" + if found { + reason = "channel operation has a modeled creation site and an unresolved receiver or alias candidate" + } + ix.boundaries = append(ix.boundaries, Boundary{Node: from, Kind: "unresolved_channel", Reason: reason, Evidence: ix.evidence(pos)}) + } + } + switch v := ins.(type) { + case *ssa.MakeChan: + key := ix.channelID(v.Pos()) + if ix.funcs[key] == nil { + ix.funcs[key] = &function{node: Node{ID: key, Name: key, Kind: "channel", Evidence: ix.evidence(v.Pos())}} + } + ix.edge(from, key, "channel_create", "fact", v.Pos()) + case *ssa.Send: + link(v.Chan, "channel_send", v.Pos()) + case *ssa.UnOp: + if v.Op == token.ARROW { + link(v.X, "channel_receive", v.Pos()) + } + case *ssa.Select: + for _, state := range v.States { + kind := "channel_select_receive" + if state.Dir == types.SendOnly { + kind = "channel_select_send" + } + link(state.Chan, kind, state.Pos) + } + case ssa.CallInstruction: + if builtin, ok := v.Common().Value.(*ssa.Builtin); ok && builtin.Name() == "close" && len(v.Common().Args) == 1 { + link(v.Common().Args[0], "channel_close", v.Pos()) + } + } +} diff --git a/containers_bench_test.go b/containers_bench_test.go new file mode 100644 index 0000000..160834e --- /dev/null +++ b/containers_bench_test.go @@ -0,0 +1,38 @@ +package contracttrace + +import ( + "fmt" + "go/types" + "testing" + + "golang.org/x/tools/go/ssa" +) + +// Wildcard lookup should scale with the selected map, rather than all modeled +// memory in the repository. Setup stays outside the measured operation. +func BenchmarkWildcardMapLookup(b *testing.B) { + for _, unrelated := range []int{100, 10000} { + b.Run(fmt.Sprintf("unrelated_%d", unrelated), func(b *testing.B) { + a := &flowAnalysis{memory: map[string]flowValue{}, values: map[ssa.Value]flowValue{}} + for i := 0; i < unrelated; i++ { + a.memory[fmt.Sprintf("unrelated:%d", i)] = emptyFlow() + } + value := emptyFlow() + value.strings["EVENT"] = true + a.storeMap("map:selected", `"topic"`, value) + mapValue := ssa.NewConst(nil, types.NewMap(types.Typ[types.String], types.Typ[types.String])) + address := emptyFlow() + address.addresses["map:selected"] = true + a.values[mapValue] = address + unknownKey := &ssa.Parameter{} + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + result := a.mapLookup(mapValue, unknownKey) + if !result.strings["EVENT"] { + b.Fatal("map payload lost") + } + } + }) + } +} diff --git a/context_done.go b/context_done.go new file mode 100644 index 0000000..ea66f9d --- /dev/null +++ b/context_done.go @@ -0,0 +1,240 @@ +package contracttrace + +import ( + "go/types" + "strings" + + "golang.org/x/tools/go/ssa" +) + +func contextAdapterName(method *types.Func) string { + if method == nil { + return "" + } + name := method.Name() + for _, suffix := range []string{"$bound", "$thunk"} { + name = strings.TrimSuffix(name, suffix) + } + return name +} + +// contextMethod identifies the standard context interface method by its +// declared object. A same-named method on a concrete context is not enough. +func contextMethod(method *types.Func, name string) bool { + if method == nil || method.Pkg() == nil || method.Pkg().Path() != "context" || method.Name() != name { + return false + } + object, ok := method.Origin().Type().(*types.Signature) + if !ok || object.Recv() == nil { + return false + } + named, ok := method.Pkg().Scope().Lookup("Context").Type().Underlying().(*types.Interface) + if !ok { + return false + } + named.Complete() + for i := 0; i < named.NumMethods(); i++ { + candidate := named.Method(i) + if candidate.Name() == name && candidate.Origin() == method.Origin() { + return true + } + } + return false +} + +func contextSelectedMethod(receiverType types.Type, name string) *types.Func { + if receiverType == nil || (name != "Done" && name != "Err") { + return nil + } + selection := types.NewMethodSet(receiverType).Lookup(nil, name) + if selection == nil { + return nil + } + method, ok := selection.Obj().(*types.Func) + if !ok || !contextMethod(method, name) { + return nil + } + return method +} + +// contextCallReceiver returns the actual receiver flow for a standard +// context method call, including generated method expressions and bound +// method values. The method projection helper follows only the SSA adapter's +// declared selector path and never falls back to its outer wrapper value. +func (a *flowAnalysis) contextCallReceiver(common *ssa.CallCommon, name string) (flowValue, bool, bool) { + if common == nil { + return emptyFlow(), false, false + } + if common.IsInvoke() { + method := common.Method + if !contextMethod(method, name) { + method = contextSelectedMethod(common.Value.Type(), name) + } + if method == nil || !contextMethod(method, name) { + return emptyFlow(), false, false + } + receiver := a.get(common.Value) + projected, _, status := a.projectMethodExpressionReceiver(receiver, common.Value.Type(), method) + if status == methodReceiverProjectionUnsupported { + projected = unresolvedProjectedReceiver() + } + return projected, true, status == methodReceiverProjectionUnsupported + } + targets := a.targets(common) + result := emptyFlow() + matched, uncertain := false, false + calleeFlow := a.get(common.Value) + uncertain = calleeFlow.functionUnknown || calleeFlow.functionNil || calleeFlow.boundReceiverUnknown + for _, target := range sortedFunctions(targets) { + functionObject, ok := target.Object().(*types.Func) + if !ok || contextAdapterName(functionObject) != name { + uncertain = true + continue + } + declaredMethod := functionObject + declared, ok := declaredMethod.Type().(*types.Signature) + if !ok || declared.Recv() == nil || target.Signature == nil { + uncertain = true + continue + } + callable := target.Signature + if len(common.Args) > callable.Params().Len() { + uncertain = true + continue + } + // A method expression carries the receiver as its first explicit + // argument. A bound method wrapper instead recovers its captured receiver. + if callable.Params().Len() > declared.Params().Len() { + if len(common.Args) == 0 { + uncertain = true + continue + } + receiver := a.get(common.Args[0]) + method := contextSelectedMethod(common.Args[0].Type(), name) + if method == nil { + continue + } + matched = true + projected, _, status := a.projectMethodExpressionReceiver(receiver, common.Args[0].Type(), method) + if status == methodReceiverProjectionUnsupported { + projected = unresolvedProjectedReceiver() + uncertain = true + } + a.merge(&result, projected) + continue + } + bound := a.get(common.Value) + receiver, exists := bound.boundReceivers[target] + if !exists { + continue + } + matched = true + for address := range receiver.addresses { + result.addresses[address] = true + } + uncertain = uncertain || receiver.unknown || bound.boundReceiverUnknown || bound.interfaceUnknown || bound.sqlUnknown || bound.functionUnknown || bound.functionNil + } + if !matched { + return emptyFlow(), false, false + } + result.interfaceUnknown = result.interfaceUnknown || uncertain || len(result.addresses) == 0 + return result, true, result.interfaceUnknown +} + +func (a *flowAnalysis) modelDone(call *ssa.Call, ix *index) { + common := call.Common() + receiver, matched, uncertain := a.contextCallReceiver(common, "Done") + if !matched { + return + } + if a.special == nil { + a.special = map[*ssa.Call][]flowValue{} + } + result := emptyFlow() + result.interfaceUnknown = uncertain || receiver.interfaceUnknown || receiver.boundReceiverUnknown || receiver.sqlUnknown + visited := map[string]bool{} + queue := []string{} + for _, key := range sortedKeys(receiver.addresses) { + if strings.HasPrefix(key, "context:") { + queue = append(queue, key) + } else { + result.interfaceUnknown = true + } + } + resolved := false + for head := 0; head < len(queue); head++ { + key := queue[head] + if visited[key] { + continue + } + visited[key] = true + site, known := a.contextKeys[key] + if !known { + result.interfaceUnknown = true + continue + } + resolved = true + if site.unknown { + result.interfaceUnknown = true + } + if site.needsCancel { + channel := resourceID("channel", "context_done", key) + value := emptyFlow() + value.addresses[channel] = true + a.merge(&result, value) + if a.doneChannels == nil { + a.doneChannels = map[string]string{} + } + a.doneChannels[channel] = key + if ix.funcs[channel] == nil { + ix.funcs[channel] = &function{node: Node{ID: channel, Name: "Done signal for " + site.name, Kind: "channel", Evidence: ix.evidence(site.position)}} + } + } + hasNilDone := false + hasWithValue := false + for _, constructor := range site.names { + hasNilDone = hasNilDone || constructor == "Background" || constructor == "TODO" || constructor == "WithoutCancel" + hasWithValue = hasWithValue || constructor == "WithValue" + } + if hasNilDone { + value := emptyFlow() + value.effects["nil_done:"+key] = true + a.merge(&result, value) + if site.needsCancel || hasWithValue { + result.interfaceUnknown = true + } + } + if hasWithValue { + parents := site.parents["WithValue"] + parentFlow := emptyFlow() + for _, parent := range sortedSSAValues(parents) { + a.merge(&parentFlow, a.get(parent)) + } + if site.parentUnknown || site.unknown || parentFlow.interfaceUnknown || parentFlow.sqlUnknown || parentFlow.boundReceiverUnknown || len(parentFlow.addresses) == 0 { + result.interfaceUnknown = true + } + for _, parent := range sortedKeys(parentFlow.addresses) { + if strings.HasPrefix(parent, "context:") { + queue = append(queue, parent) + } else { + result.interfaceUnknown = true + } + } + if site.needsCancel || hasNilDone { + result.interfaceUnknown = true + } + } + } + if !resolved { + result.interfaceUnknown = true + } + a.special[call] = []flowValue{result} +} + +func (a *flowAnalysis) doneRelationships(ix *index) { + for _, channel := range sortedKeys(a.doneChannels) { + key := a.doneChannels[channel] + site := a.contextKeys[key] + ix.edge(key, channel, "context_done_signal", "possible", site.position) + } +} diff --git a/context_done_bound_test.go b/context_done_bound_test.go new file mode 100644 index 0000000..d3ccf23 --- /dev/null +++ b/context_done_bound_test.go @@ -0,0 +1,362 @@ +package contracttrace + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestContextDoneTracksBoundAndMethodExpressionReceivers(t *testing.T) { + root := t.TempDir() + files := map[string]string{ + "go.mod": "module example.com/contextdoneprobe\n\ngo 1.27.0\n", + "context.go": `package contextdoneprobe +import ( + "context" + "time" +) + +func DirectDone(ctx context.Context) { <-ctx.Done() } +func BoundDone(ctx context.Context) { + done := ctx.Done + alias := done + <-alias() +} +func ConsumeDone(fn func() <-chan struct{}) { <-fn() } +func BoundDoneHelper(ctx context.Context) { + done := ctx.Done + ConsumeDone(done) +} +func ExpressionDone(ctx context.Context) { <-context.Context.Done(ctx) } +func ConsumeExpression(fn func(context.Context) <-chan struct{}, ctx context.Context) { <-fn(ctx) } +func ExpressionDoneHelper(ctx context.Context) { + done := context.Context.Done + ConsumeExpression(done, ctx) +} +type contextWrapper struct { context.Context } +func PromotedDirectDone(ctx context.Context) { + wrapper := contextWrapper{Context: ctx} + <-wrapper.Done() +} +func PromotedBoundDone(ctx context.Context) { + wrapper := contextWrapper{Context: ctx} + done := wrapper.Done + <-done() +} +func PromotedExpressionDone(ctx context.Context) { + wrapper := contextWrapper{Context: ctx} + <-(*contextWrapper).Done(&wrapper) +} +func DirectErr(ctx context.Context) { _ = ctx.Err() } +func BoundErr(ctx context.Context) { err := ctx.Err; _ = err() } +func ExpressionErr(ctx context.Context) { _ = context.Context.Err(ctx) } +func ConsumeErr(fn func() error) { _ = fn() } +func BoundErrHelper(ctx context.Context) { err := ctx.Err; ConsumeErr(err) } +func ConsumeExpressionErr(fn func(context.Context) error, ctx context.Context) { _ = fn(ctx) } +func ExpressionErrHelper(ctx context.Context) { + err := context.Context.Err + ConsumeExpressionErr(err, ctx) +} +func BoundValueDone(ctx context.Context) { + value := context.WithValue(ctx, "key", "value") + done := value.Done + <-done() +} +func NilBackgroundBound() { + background := context.Background() + done := background.Done + <-done() +} +func NilTodoExpression() { <-context.Context.Done(context.TODO()) } +func NilWithValueBound() { + todo := context.WithValue(context.TODO(), "key", "value") + done := todo.Done + <-done() +} +func NilWithoutCancelBound(parent context.Context) { + detached := context.WithoutCancel(parent) + done := detached.Done + <-done() +} + +type customContext struct { done <-chan struct{} } +func (customContext) Deadline() (time.Time, bool) { return time.Time{}, false } +func (c customContext) Done() <-chan struct{} { return c.done } +func (customContext) Err() error { return nil } +func (customContext) Value(any) any { return nil } +type unrelatedContextName struct{} +func (unrelatedContextName) Done() <-chan struct{} { return nil } +func (unrelatedContextName) Err() error { return nil } +func UnrelatedNames() { + var value unrelatedContextName + _ = value.Done() + _ = value.Err() +} +func CustomContext() { + done := make(chan struct{}) + var ctx context.Context = customContext{done: done} + <-ctx.Done() +} + +func MixedBound(known, outside context.Context) { + selected := known + if externalChoice { selected = outside } + done := selected.Done + <-done() +} +func UnknownBoundHelper(ctx context.Context) { + done := ctx.Done + ConsumeDone(done) +} +func UnknownExpression(ctx context.Context) { <-context.Context.Done(ctx) } + +var externalChoice bool + +func ConstructorAliasDone() { + background := context.Background + base := background() + makeContext := context.WithCancel + ctx, cancel := makeContext(base) + defer cancel() + <-ctx.Done() +} +func invokeContextFactory(factory func(context.Context) (context.Context, context.CancelFunc), parent context.Context) (context.Context, context.CancelFunc) { + return factory(parent) +} +func ConstructorHelperDone() { + makeContext := context.WithCancel + ctx, cancel := invokeContextFactory(makeContext, context.Background()) + defer cancel() + <-ctx.Done() +} +func chooseBackground(external bool) func() context.Context { + if external { return context.TODO } + return context.Background +} +func ConstructorCompatibleUnion() { + ctx := chooseBackground(externalChoice)() + <-ctx.Done() +} + +func Scenario(outside context.Context) { + base := context.Background() + ctx, cancel := context.WithCancel(base) + defer cancel() + DirectDone(ctx) + BoundDone(ctx) + BoundDoneHelper(ctx) + ExpressionDone(ctx) + ExpressionDoneHelper(ctx) + PromotedDirectDone(ctx) + PromotedBoundDone(ctx) + PromotedExpressionDone(ctx) + DirectErr(ctx) + BoundErr(ctx) + BoundErrHelper(ctx) + ExpressionErr(ctx) + ExpressionErrHelper(ctx) + BoundValueDone(ctx) + NilBackgroundBound() + NilTodoExpression() + NilWithValueBound() + NilWithoutCancelBound(ctx) + MixedBound(ctx, outside) +} +`, + } + for name, contents := range files { + if err := os.WriteFile(filepath.Join(root, name), []byte(contents), 0600); err != nil { + t.Fatal(err) + } + } + + report, err := Trace(context.Background(), Options{Root: root, Seeds: []string{"Scenario", "UnknownExpression", "UnknownBoundHelper", "CustomContext", "UnrelatedNames", "ConstructorAliasDone", "ConstructorHelperDone", "ConstructorCompatibleUnion"}, Depth: 7, MaxNodes: 500}) + if err != nil { + t.Fatal(err) + } + contextID := "" + for _, edge := range report.Relationships { + if edge.From == "example.com/contextdoneprobe::Scenario" && edge.Kind == "context_create" && strings.Contains(edge.Evidence.Snippet, "context.WithCancel(base)") { + contextID = edge.To + } + } + if contextID == "" { + t.Fatalf("fixture did not establish its source-backed WithCancel context: %+v", report.Relationships) + } + signal := "" + for _, edge := range report.Relationships { + if edge.From == contextID && edge.Kind == "context_done_signal" { + signal = edge.To + } + } + if signal == "" { + t.Fatalf("direct Done control did not establish its cancellation signal for %s", contextID) + } + + owners := []string{ + "DirectDone", + "BoundDone", + "ConsumeDone", + "ExpressionDone", + "ConsumeExpression", + "PromotedDirectDone", + "PromotedBoundDone", + "PromotedExpressionDone", + "BoundValueDone", + "MixedBound", + } + observed := map[string]map[string]bool{} + for _, owner := range owners { + observed[owner] = map[string]bool{} + } + for _, edge := range report.Relationships { + if edge.Kind != "channel_receive" && edge.Kind != "channel_select_receive" { + continue + } + for _, owner := range owners { + if edge.From == "example.com/contextdoneprobe::"+owner { + observed[owner][edge.To] = true + } + } + } + for _, owner := range owners { + if !observed[owner][signal] { + t.Errorf("%s did not receive the exact known Done signal %s; observed=%v", owner, signal, observed[owner]) + } + } + + observedContext := map[string]bool{} + for _, edge := range report.Relationships { + if edge.Kind == "context_observe" && edge.To == contextID { + observedContext[edge.From] = true + } + } + for _, owner := range []string{"DirectErr", "BoundErr", "ExpressionErr", "ConsumeErr", "BoundErrHelper", "ConsumeExpressionErr", "ExpressionErrHelper"} { + if owner == "ConsumeErr" || owner == "ConsumeExpressionErr" || owner == "BoundErrHelper" || owner == "ExpressionErrHelper" { + continue + } + if !observedContext["example.com/contextdoneprobe::"+owner] { + t.Errorf("%s did not report context observation of %s", owner, contextID) + } + } + for _, owner := range []string{"ConsumeErr", "ConsumeExpressionErr"} { + if !observedContext["example.com/contextdoneprobe::"+owner] { + t.Errorf("context Err callback invocation in %s did not retain the context observation", owner) + } + } + + nilOwners := map[string]bool{"NilBackgroundBound": false, "NilTodoExpression": false, "NilWithValueBound": false, "NilWithoutCancelBound": false} + for _, boundary := range report.Boundaries { + if boundary.Kind != "nil_context_done_wait" { + continue + } + for owner := range nilOwners { + if boundary.Node == "example.com/contextdoneprobe::"+owner { + nilOwners[owner] = true + } + } + } + for owner, found := range nilOwners { + if !found { + t.Errorf("%s lost its source-backed nil Done boundary", owner) + } + } + if observedContext["example.com/contextdoneprobe::CustomContext"] { + t.Error("custom concrete Done implementation was misclassified as a standard context identity") + } + if observedContext["example.com/contextdoneprobe::UnrelatedNames"] { + t.Error("unrelated same-name methods were misclassified as standard context observations") + } + for _, boundary := range report.Boundaries { + if boundary.Node == "example.com/contextdoneprobe::UnrelatedNames" && boundary.Kind == "unresolved_context" { + t.Errorf("unrelated same-name methods received a context boundary: %+v", boundary) + } + } + customChannels := map[string]bool{} + customReceives := map[string]bool{} + for _, edge := range report.Relationships { + if edge.From != "example.com/contextdoneprobe::CustomContext" { + continue + } + if edge.Kind == "channel_create" { + customChannels[edge.To] = true + } + if edge.Kind == "channel_receive" || edge.Kind == "channel_select_receive" { + customReceives[edge.To] = true + } + } + customReturn := false + for channel := range customChannels { + customReturn = customReturn || customReceives[channel] + } + if !customReturn { + t.Errorf("custom context implementation's ordinary local channel return was lost: created=%v received=%v", customChannels, customReceives) + } + unresolvedCustom := false + for _, boundary := range report.Boundaries { + unresolvedCustom = unresolvedCustom || boundary.Kind == "unresolved_context" && boundary.Node == "example.com/contextdoneprobe::CustomContext" + } + if !unresolvedCustom { + t.Error("custom context interface dispatch did not retain its outside standard-context identity boundary") + } + unresolvedMixed := false + for _, boundary := range report.Boundaries { + unresolvedMixed = unresolvedMixed || boundary.Kind == "unresolved_context" && boundary.Node == "example.com/contextdoneprobe::MixedBound" + } + if !unresolvedMixed { + t.Error("known plus outside bound receiver did not retain an explicit unresolved context boundary") + } + unresolvedBoundHelper := false + for _, boundary := range report.Boundaries { + unresolvedBoundHelper = unresolvedBoundHelper || boundary.Kind == "unresolved_context" && boundary.Node == "example.com/contextdoneprobe::ConsumeDone" + } + if !unresolvedBoundHelper { + t.Error("outside bound receiver passed through a helper did not retain an explicit context boundary") + } + unresolvedExpression := false + for _, boundary := range report.Boundaries { + unresolvedExpression = unresolvedExpression || boundary.Kind == "unresolved_context" && boundary.Node == "example.com/contextdoneprobe::UnknownExpression" + } + if !unresolvedExpression { + t.Error("unknown method-expression receiver did not retain an explicit context boundary") + } + unionNilBoundary := false + for _, boundary := range report.Boundaries { + unionNilBoundary = unionNilBoundary || boundary.Kind == "nil_context_done_wait" && boundary.Node == "example.com/contextdoneprobe::ConstructorCompatibleUnion" + } + if !unionNilBoundary { + t.Error("compatible Background/TODO callback candidates did not retain their shared nil Done behavior") + } + for _, constructor := range []struct{ callOwner, siteOwner, snippet string }{ + {callOwner: "ConstructorAliasDone", siteOwner: "ConstructorAliasDone", snippet: "makeContext(base)"}, + {callOwner: "ConstructorHelperDone", siteOwner: "invokeContextFactory", snippet: "factory(parent)"}, + } { + var constructorContext string + for _, edge := range report.Relationships { + if edge.From == "example.com/contextdoneprobe::"+constructor.siteOwner && edge.Kind == "context_create" && strings.Contains(edge.Evidence.Snippet, constructor.snippet) { + constructorContext = edge.To + } + } + if constructorContext == "" { + t.Errorf("%s lost exact context constructor identity through its alias/helper", constructor.callOwner) + continue + } + constructorSignal := "" + for _, edge := range report.Relationships { + if edge.From == constructorContext && edge.Kind == "context_done_signal" { + constructorSignal = edge.To + } + } + foundReceive := false + for _, edge := range report.Relationships { + if edge.From == "example.com/contextdoneprobe::"+constructor.callOwner && edge.To == constructorSignal && edge.Kind == "channel_receive" { + foundReceive = true + } + } + if constructorSignal == "" || !foundReceive { + t.Errorf("%s failed to connect its exact alias/helper constructor to its Done receive; context=%s signal=%s", constructor.callOwner, constructorContext, constructorSignal) + } + } +} diff --git a/database_handles_test.go b/database_handles_test.go new file mode 100644 index 0000000..1132874 --- /dev/null +++ b/database_handles_test.go @@ -0,0 +1,713 @@ +package contracttrace + +import ( + "context" + "strings" + "testing" +) + +func TestDatabaseHandlesAcrossSharedQueryAndConnection(t *testing.T) { + r, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"DatabasePaths", "UnknownDatabaseQuery", "ConnectorDatabase"}, Depth: 5, MaxNodes: 250}) + if err != nil { + t.Fatal(err) + } + databases := map[string]bool{} + firstDatabase := "" + expectedShared := map[string]bool{} + for _, node := range r.Nodes { + if node.Kind == "database" { + databases[node.ID] = true + if strings.Contains(node.Evidence.Snippet, "sql.Open(") { + expectedShared[node.ID] = true + } + if strings.Contains(node.Evidence.Snippet, "first, _ := sql.Open") { + firstDatabase = node.ID + } + } + } + operation := "" + for _, edge := range r.Relationships { + if edge.Kind == "sql_operation" && strings.HasSuffix(edge.From, "::SharedDatabaseQuery") { + operation = edge.To + } + } + queryHandles := map[string]bool{} + connectionID := "" + transactionParents := map[string]bool{} + cleanupDB := map[string]bool{} + cleanupConn := map[string]bool{} + queryTable := false + for _, edge := range r.Relationships { + if edge.Kind == "sql_receiver" && edge.From == operation && databases[edge.To] { + queryHandles[edge.To] = true + } + if edge.Kind == "connection_database" && edge.To == firstDatabase && strings.Contains(edge.Evidence.Snippet, "first.Conn") { + connectionID = edge.From + } + if edge.Kind == "transaction_connection" && strings.Contains(edge.Evidence.Snippet, "connection.BeginTx") { + transactionParents[edge.To] = true + } + if edge.Kind == "cleanup_database_close" && (strings.Contains(edge.Evidence.Snippet, "first.Close") || strings.Contains(edge.Evidence.Snippet, "second.Close")) { + cleanupDB[edge.To] = true + } + if edge.Kind == "cleanup_connection_close" { + cleanupConn[edge.To] = true + } + queryTable = queryTable || edge.From == operation && edge.To == "table:shared_records" && edge.Kind == "sql_write" + } + unknown := false + for _, boundary := range r.Boundaries { + unknown = unknown || boundary.Kind == "unresolved_sql_handle" && strings.Contains(boundary.Evidence.Snippet, "external_records") + } + for id := range expectedShared { + if !queryHandles[id] { + t.Fatalf("shared helper lost independently identified Open handle: %s", id) + } + } + if len(databases) != 5 || len(expectedShared) != 4 || len(queryHandles) != 4 || firstDatabase == "" || connectionID == "" || !transactionParents[connectionID] || len(cleanupDB) != 2 || !cleanupConn[connectionID] || !queryTable || !unknown { + t.Fatalf("database contract links missing: databases=%d sharedReceivers=%d first=%q connection=%q transaction=%t cleanupDB=%d cleanupConn=%t table=%t unknown=%t", len(databases), len(queryHandles), firstDatabase, connectionID, transactionParents[connectionID], len(cleanupDB), cleanupConn[connectionID], queryTable, unknown) + } +} + +func TestDatabaseOriginNamespacesAcrossSharedHelpers(t *testing.T) { + config := DefaultConfig() + config.StorageScopes = []StorageScope{ + {Namespace: "orders", DatabaseOrigins: []string{"example.com/sample::OpenOrdersDatabase"}}, + {Namespace: "audit", DatabaseOrigins: []string{"example.com/sample::OpenAuditDatabase"}}, + } + r, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"NamespacedDatabasePaths"}, Depth: 5, MaxNodes: 250, Config: config}) + if err != nil { + t.Fatal(err) + } + writes := map[string]bool{} + prepared := false + for _, edge := range r.Relationships { + if strings.HasSuffix(edge.From, "::SharedDatabaseQuery") && edge.Kind == "sql_write" { + writes[edge.To] = true + } + prepared = prepared || strings.HasSuffix(edge.From, "::NamespacedDatabasePaths") && edge.Kind == "sql_read" && edge.To == "table:orders:scoped_records" + } + if !writes["table:orders:shared_records"] || !writes["table:audit:shared_records"] || !prepared { + t.Fatalf("origin namespaces lost across wrappers or transaction preparation: writes=%v prepared=%t", writes, prepared) + } + // Other unconfigured callers also reach the shared helper. Do not hide them. + unresolved := false + for _, boundary := range r.Boundaries { + unresolved = unresolved || boundary.Kind == "unresolved_database_namespace" + } + if !unresolved { + t.Fatal("unconfigured receiver candidates were hidden") + } +} + +func TestDatabaseOriginConfigurationRejectsAmbiguity(t *testing.T) { + for _, origins := range [][]StorageScope{ + {{Namespace: "orders", DatabaseOrigins: []string{"unqualified"}}}, + {{Namespace: "orders", DatabaseOrigins: []string{"example.com/sample::OpenOrdersDatabase"}}, {Namespace: "audit", DatabaseOrigins: []string{"example.com/sample::OpenOrdersDatabase"}}}, + } { + config := DefaultConfig() + config.StorageScopes = origins + if _, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"NamespacedDatabasePaths"}, Config: config}); err == nil { + t.Fatal("invalid/ambiguous origin declaration was accepted") + } + } +} + +func TestTypedSQLNamespaceOverridesDatabaseOrigins(t *testing.T) { + config := DefaultConfig() + config.StorageScopes = []StorageScope{{Namespace: "orders", DatabaseOrigins: []string{"example.com/sample::OpenOrdersDatabase"}}} + config.CallRules = []CallRule{{Symbol: "database/sql::DB.Exec", Kind: "sql_query", Argument: 0, Namespace: "explicit"}} + r, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"NamespacedDatabasePaths"}, Depth: 4, MaxNodes: 250, Config: config}) + if err != nil { + t.Fatal(err) + } + found := false + for _, edge := range r.Relationships { + if strings.HasSuffix(edge.From, "::SharedDatabaseQuery") && edge.Kind == "sql_write" { + if edge.To != "table:explicit:shared_records" { + t.Fatalf("origin overrode explicit API namespace: %+v", edge) + } + found = true + } + } + if !found { + t.Fatal("explicit API namespace lost") + } +} + +func TestUnmatchedDatabaseOriginIsDisclosed(t *testing.T) { + config := DefaultConfig() + config.StorageScopes = []StorageScope{{Namespace: "orders", DatabaseOrigins: []string{"example.com/sample::MissingConstructor"}}} + r, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"NamespacedDatabasePaths"}, Depth: 2, MaxNodes: 100, Config: config}) + if err != nil { + t.Fatal(err) + } + for _, boundary := range r.Boundaries { + if boundary.Kind == "unmatched_database_origin" && strings.Contains(boundary.Reason, "MissingConstructor") { + return + } + } + t.Fatal("unmatched database declaration silently looked usable") +} + +func TestKnownAndUnknownDatabaseInputsRetainUncertainty(t *testing.T) { + config := DefaultConfig() + config.StorageScopes = []StorageScope{{Namespace: "orders", DatabaseOrigins: []string{"example.com/sample::OpenOrdersDatabase"}}} + r, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"MixedDatabaseInput"}, Depth: 4, MaxNodes: 250, Config: config}) + if err != nil { + t.Fatal(err) + } + reads := map[string]bool{} + for _, edge := range r.Relationships { + if strings.HasSuffix(edge.From, "::mixedDatabaseQuery") && edge.Kind == "sql_read" { + reads[edge.To] = true + } + } + for _, table := range []string{"mixed_receiver_records", "mixed_statement_records"} { + if !reads["table:orders:"+table] || !reads["table:"+table] { + t.Fatalf("known namespace hid unknown receiver candidate for %s: %v", table, reads) + } + } + boundary := false + cleanup := false + for _, b := range r.Boundaries { + boundary = boundary || b.Kind == "unresolved_database_namespace" && strings.HasSuffix(b.Node, "::mixedDatabaseQuery") + cleanup = cleanup || b.Kind == "unresolved_cleanup_resource" && strings.Contains(b.Evidence.Snippet, "db.Close") + } + if !boundary || !cleanup { + t.Fatal("mixed known/unknown input lost its namespace boundary") + } +} + +func TestMixedStatementRebindingRetainsUnknownOrigin(t *testing.T) { + r, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"MixedStatementInput"}, Depth: 4, MaxNodes: 250}) + if err != nil { + t.Fatal(err) + } + query, rebind, unresolved := false, false, false + for _, edge := range r.Relationships { + query = query || strings.HasSuffix(edge.From, "::mixedStatementRebind") && edge.Kind == "sql_read" && edge.To == "table:mixed_rebind_records" + } + for _, b := range r.Boundaries { + rebind = rebind || b.Kind == "unresolved_statement_rebind" && strings.Contains(b.Evidence.Snippet, "transaction.Stmt") + unresolved = unresolved || b.Kind == "unresolved_prepared_sql" && strings.HasSuffix(b.Node, "::mixedStatementRebind") + } + if !query || !rebind || !unresolved { + t.Fatalf("mixed statement origin path missing: query=%t rebind=%t unresolved=%t", query, rebind, unresolved) + } +} + +func TestBoundPrepareAliasesRetainKnownOrigins(t *testing.T) { + r, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"OpaqueStatementInput"}, Depth: 4, MaxNodes: 250}) + if err != nil { + t.Fatal(err) + } + known, opaque := false, false + for _, edge := range r.Relationships { + known = known || strings.HasSuffix(edge.From, "::opaqueStatementQuery") && edge.Kind == "sql_read" && edge.To == "table:unknown_candidate_records" + opaque = opaque || strings.HasSuffix(edge.From, "::opaqueStatementQuery") && edge.Kind == "sql_read" && edge.To == "table:opaque_records" + } + for _, b := range r.Boundaries { + if strings.HasSuffix(b.Node, "::opaqueStatementQuery") && b.Kind == "unresolved_prepared_sql" { + t.Fatalf("modeled bound Prepare alias retained an obsolete unknown prepared-query boundary: %+v", b) + } + } + if !known || !opaque { + t.Fatalf("bound Prepare aliases lost known query candidates: known=%t opaque=%t", known, opaque) + } +} + +func TestOpaqueAndNilStatementCandidatesDoNotDisappear(t *testing.T) { + r, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"UnknownStatementInput", "NilStatementInput"}, Depth: 4, MaxNodes: 250}) + if err != nil { + t.Fatal(err) + } + for _, owner := range []string{"unknownStatementQuery", "NilStatementInput"} { + known, unresolved := false, false + for _, edge := range r.Relationships { + known = known || strings.HasSuffix(edge.From, "::"+owner) && edge.Kind == "sql_read" && edge.To == "table:unknown_candidate_records" + } + for _, b := range r.Boundaries { + unresolved = unresolved || strings.HasSuffix(b.Node, "::"+owner) && b.Kind == "unresolved_prepared_sql" + } + if !known || !unresolved { + t.Fatalf("%s discarded a known or unknown statement candidate: known=%t unresolved=%t", owner, known, unresolved) + } + } +} + +func TestExportedSQLHelperRetainsExternalInputDespiteLocalCaller(t *testing.T) { + config := DefaultConfig() + config.StorageScopes = []StorageScope{{Namespace: "orders", DatabaseOrigins: []string{"example.com/sample::OpenOrdersDatabase"}}} + r, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"PublicDatabaseCaller"}, Depth: 3, MaxNodes: 200, Config: config}) + if err != nil { + t.Fatal(err) + } + known, unknown, entry := false, false, false + for _, edge := range r.Relationships { + if strings.HasSuffix(edge.From, "::PublicDatabaseQuery") && edge.Kind == "sql_read" { + known = known || edge.To == "table:orders:public_input_records" + unknown = unknown || edge.To == "table:public_input_records" + } + } + for _, b := range r.Boundaries { + entry = entry || b.Kind == "external_sql_input" && strings.HasSuffix(b.Node, "::PublicDatabaseQuery") && strings.Contains(b.Evidence.Snippet, "PublicDatabaseQuery") + } + if !known || !unknown || !entry { + t.Fatalf("local caller incorrectly closed exported SQL input: known=%t unknown=%t entry=%t", known, unknown, entry) + } +} + +func TestPrivateSQLHelperKeepsKnownLocalOrigin(t *testing.T) { + config := DefaultConfig() + config.StorageScopes = []StorageScope{{Namespace: "orders", DatabaseOrigins: []string{"example.com/sample::OpenOrdersDatabase"}}} + r, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"PrivateDatabaseCaller"}, Depth: 3, MaxNodes: 200, Config: config}) + if err != nil { + t.Fatal(err) + } + found := false + for _, edge := range r.Relationships { + if strings.HasSuffix(edge.From, "::privateDatabaseQuery") && edge.Kind == "sql_read" { + if edge.To != "table:orders:private_input_records" { + t.Fatalf("private closed helper acquired an unrelated namespace: %+v", edge) + } + found = true + } + } + for _, b := range r.Boundaries { + if b.Kind == "external_sql_input" && strings.HasSuffix(b.Node, "::privateDatabaseQuery") { + t.Fatal("private helper with known caller was classified as an exported entry") + } + } + if !found { + t.Fatal("private SQL helper lost its known namespace") + } +} + +func TestEscapingPrivateSQLCallbackRetainsUnknownInput(t *testing.T) { + config := DefaultConfig() + config.StorageScopes = []StorageScope{{Namespace: "orders", DatabaseOrigins: []string{"example.com/sample::OpenOrdersDatabase"}}} + r, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"EscapingSQLCaller"}, Depth: 3, MaxNodes: 200, Config: config}) + if err != nil { + t.Fatal(err) + } + known, unknown, escape, boundary := false, false, false, false + for _, edge := range r.Relationships { + if strings.HasSuffix(edge.From, "::escapingSQLQuery") && edge.Kind == "sql_read" { + known = known || edge.To == "table:orders:escaping_input_records" + unknown = unknown || edge.To == "table:escaping_input_records" + } + escape = escape || edge.Kind == "callback_escape" && strings.HasSuffix(edge.To, "::escapingSQLQuery") && strings.Contains(edge.Evidence.Snippet, "reflect.ValueOf(callback)") + } + for _, b := range r.Boundaries { + boundary = boundary || b.Kind == "escaped_sql_input" && strings.HasSuffix(b.Node, "::escapingSQLQuery") + } + if !known || !unknown || !escape || !boundary { + t.Fatalf("escaped private callback origin was closed: known=%t unknown=%t escape=%t boundary=%t", known, unknown, escape, boundary) + } +} + +func TestFunctionEscapeIsReportedWithoutSQLParameters(t *testing.T) { + r, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"GenericCallbackEscape"}, Depth: 2, MaxNodes: 100}) + if err != nil { + t.Fatal(err) + } + escape, boundary := false, false + for _, edge := range r.Relationships { + escape = escape || edge.Kind == "callback_escape" && edge.Certainty == "possible" && strings.HasSuffix(edge.To, "::privateTextConsumer") + } + for _, b := range r.Boundaries { + boundary = boundary || b.Kind == "external_callback" && strings.HasSuffix(b.Node, "::privateTextConsumer") + } + if !escape || !boundary { + t.Fatalf("generic callback escape missing: edge=%t boundary=%t", escape, boundary) + } +} + +func TestExportedCallbackReturnRetainsUnknownInputs(t *testing.T) { + config := DefaultConfig() + config.StorageScopes = []StorageScope{{Namespace: "orders", DatabaseOrigins: []string{"example.com/sample::OpenOrdersDatabase"}}} + r, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"ReturnedCallbackCaller"}, Depth: 3, MaxNodes: 200, Config: config}) + if err != nil { + t.Fatal(err) + } + known, unknown, escape, boundary := false, false, false, false + for _, edge := range r.Relationships { + if strings.HasSuffix(edge.From, "::privateReturnedQuery") && edge.Kind == "sql_read" { + known = known || edge.To == "table:orders:returned_callback_records" + unknown = unknown || edge.To == "table:returned_callback_records" + } + escape = escape || edge.Kind == "callback_return_escape" && strings.HasSuffix(edge.From, "::PublicCallbackFactory") && strings.HasSuffix(edge.To, "::privateReturnedQuery") && strings.Contains(edge.Evidence.Snippet, "return privateCallbackFactory") + } + for _, b := range r.Boundaries { + boundary = boundary || b.Kind == "escaped_sql_input" && strings.HasSuffix(b.Node, "::privateReturnedQuery") + } + if !known || !unknown || !escape || !boundary { + t.Fatalf("public callback return hid inputs: known=%t unknown=%t escape=%t boundary=%t", known, unknown, escape, boundary) + } +} + +func TestPrivateReturnedCallbackUsesResolvedLocalCaller(t *testing.T) { + config := DefaultConfig() + config.StorageScopes = []StorageScope{{Namespace: "orders", DatabaseOrigins: []string{"example.com/sample::OpenOrdersDatabase"}}} + r, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"ClosedCallbackCaller"}, Depth: 3, MaxNodes: 200, Config: config}) + if err != nil { + t.Fatal(err) + } + found := false + for _, edge := range r.Relationships { + if strings.HasSuffix(edge.From, "::closedCallbackQuery") && edge.Kind == "sql_read" { + if edge.To != "table:orders:closed_callback_records" { + t.Fatalf("resolved local callback retained a fabricated external namespace: %+v", edge) + } + found = true + } + } + for _, b := range r.Boundaries { + if b.Kind == "external_sql_input" && strings.HasSuffix(b.Node, "::closedCallbackQuery") { + t.Fatal("resolved local callback still claimed to have no local caller") + } + } + if !found { + t.Fatal("resolved callback lost its local namespace") + } +} + +func TestResolvedLocalDatabaseFactoryDoesNotInventExternalResult(t *testing.T) { + config := DefaultConfig() + config.StorageScopes = []StorageScope{{Namespace: "orders", DatabaseOrigins: []string{"example.com/sample::OpenOrdersDatabase"}}} + r, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"ClosedDatabaseFactoryCaller"}, Depth: 3, MaxNodes: 200, Config: config}) + if err != nil { + t.Fatal(err) + } + found := false + for _, edge := range r.Relationships { + if strings.HasSuffix(edge.From, "::closedDatabaseConsumer") && edge.Kind == "sql_read" { + if edge.To != "table:orders:closed_factory_records" { + t.Fatalf("resolved local factory invented an external result: %+v", edge) + } + found = true + } + } + if !found { + t.Fatal("local factory origin was lost") + } +} + +func TestExportedRecordReturnTracesAccessibleCallbacks(t *testing.T) { + config := DefaultConfig() + config.StorageScopes = []StorageScope{{Namespace: "orders", DatabaseOrigins: []string{"example.com/sample::OpenOrdersDatabase"}}} + r, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"RecordCallbackCaller"}, Depth: 4, MaxNodes: 250, Config: config}) + if err != nil { + t.Fatal(err) + } + known, unknown, escape := false, false, false + for _, edge := range r.Relationships { + if strings.HasSuffix(edge.From, "::recordCallbackQuery") && edge.Kind == "sql_read" { + known = known || edge.To == "table:orders:record_callback_records" + unknown = unknown || edge.To == "table:record_callback_records" + } + escape = escape || edge.Kind == "callback_return_escape" && strings.HasSuffix(edge.From, "::PublicRecordFactory") && strings.HasSuffix(edge.To, "::recordCallbackQuery") + if edge.Kind == "callback_return_escape" && strings.HasSuffix(edge.To, "::hiddenRecordQuery") { + t.Fatal("private field was treated as accessible to outside callers") + } + if strings.HasSuffix(edge.From, "::hiddenRecordQuery") && edge.Kind == "sql_read" && edge.To != "table:orders:hidden_record_records" { + t.Fatalf("private field acquired an outside SQL origin: %+v", edge) + } + } + if !known || !unknown || !escape { + t.Fatalf("returned record hid public callback: known=%t unknown=%t escape=%t", known, unknown, escape) + } +} + +func TestDependencyRecordArgumentRetainsCallbackEscape(t *testing.T) { + config := DefaultConfig() + config.StorageScopes = []StorageScope{{Namespace: "orders", DatabaseOrigins: []string{"example.com/sample::OpenOrdersDatabase"}}} + r, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"RecordArgumentCaller"}, Depth: 3, MaxNodes: 200, Config: config}) + if err != nil { + t.Fatal(err) + } + known, unknown, escape := false, false, false + for _, edge := range r.Relationships { + if strings.HasSuffix(edge.From, "::argumentRecordQuery") && edge.Kind == "sql_read" { + known = known || edge.To == "table:orders:argument_record_records" + unknown = unknown || edge.To == "table:argument_record_records" + } + escape = escape || edge.Kind == "callback_escape" && strings.HasSuffix(edge.To, "::argumentRecordQuery") && strings.Contains(edge.Evidence.Snippet, "reflect.ValueOf(record)") + } + if !known || !unknown || !escape { + t.Fatalf("dependency record hid callback escape: known=%t unknown=%t escape=%t", known, unknown, escape) + } +} + +func TestPromotedPublicCallbackThroughPrivateEmbeddedRecord(t *testing.T) { + config := DefaultConfig() + config.StorageScopes = []StorageScope{{Namespace: "orders", DatabaseOrigins: []string{"example.com/sample::OpenOrdersDatabase"}}} + r, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"PromotedRecordCaller"}, Depth: 4, MaxNodes: 250, Config: config}) + if err != nil { + t.Fatal(err) + } + known, unknown, escape := false, false, false + for _, edge := range r.Relationships { + if strings.HasSuffix(edge.From, "::promotedRecordQuery") && edge.Kind == "sql_read" { + known = known || edge.To == "table:orders:promoted_record_records" + unknown = unknown || edge.To == "table:promoted_record_records" + } + escape = escape || edge.Kind == "callback_return_escape" && strings.HasSuffix(edge.From, "::PublicPromotedRecordFactory") && strings.HasSuffix(edge.To, "::promotedRecordQuery") + } + if !known || !unknown || !escape { + t.Fatalf("promoted public callback hidden by embedding: known=%t unknown=%t escape=%t", known, unknown, escape) + } +} + +func TestExportedContainerReturnsRetainCallbackInputs(t *testing.T) { + config := DefaultConfig() + config.StorageScopes = []StorageScope{{Namespace: "orders", DatabaseOrigins: []string{"example.com/sample::OpenOrdersDatabase"}}} + r, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"ContainerCallbackCaller"}, Depth: 4, MaxNodes: 250, Config: config}) + if err != nil { + t.Fatal(err) + } + for _, shape := range []string{"slice", "array", "map"} { + known, unknown, escape, invoked := false, false, false, false + for _, edge := range r.Relationships { + if strings.HasSuffix(edge.From, "::"+shape+"CallbackQuery") && edge.Kind == "sql_read" { + known = known || edge.To == "table:orders:"+shape+"_callback_records" + unknown = unknown || edge.To == "table:"+shape+"_callback_records" + } + escape = escape || edge.Kind == "callback_return_escape" && strings.HasSuffix(edge.To, "::"+shape+"CallbackQuery") + invoked = invoked || edge.Kind == "resolved_callback_call" && strings.HasSuffix(edge.From, "::ContainerCallbackCaller") && strings.HasSuffix(edge.To, "::"+shape+"CallbackQuery") + } + if !known || !unknown || !escape || !invoked { + t.Errorf("%s container lost flow: known=%t unknown=%t escape=%t invoked=%t", shape, known, unknown, escape, invoked) + } + } +} + +func TestInterfaceContainerArgumentsTraceMadeSlicesAndMapKeys(t *testing.T) { + config := DefaultConfig() + config.StorageScopes = []StorageScope{{Namespace: "orders", DatabaseOrigins: []string{"example.com/sample::OpenOrdersDatabase"}}} + r, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"ContainerArgumentCaller"}, Depth: 4, MaxNodes: 250, Config: config}) + if err != nil { + t.Fatal(err) + } + for owner, table := range map[string]string{"madeSliceQuery": "made_slice_records", "mapKeyQuery": "map_key_records"} { + known, unknown, escape := false, false, false + for _, edge := range r.Relationships { + if strings.HasSuffix(edge.From, "::"+owner) && edge.Kind == "sql_read" { + known = known || edge.To == "table:orders:"+table + unknown = unknown || edge.To == "table:"+table + } + escape = escape || edge.Kind == "callback_escape" && strings.HasSuffix(edge.From, "::privateRecordArgument") && strings.HasSuffix(edge.To, "::"+owner) + } + if !known || !unknown || !escape { + t.Errorf("%s interface container lost escape: known=%t unknown=%t escape=%t", owner, known, unknown, escape) + } + } +} + +func TestPrivateArrayFactoryHasOnlyResolvedLocalInputs(t *testing.T) { + config := DefaultConfig() + config.StorageScopes = []StorageScope{{Namespace: "orders", DatabaseOrigins: []string{"example.com/sample::OpenOrdersDatabase"}}} + r, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"ClosedArrayCaller"}, Depth: 3, MaxNodes: 200, Config: config}) + if err != nil { + t.Fatal(err) + } + read, invoked := false, false + for _, edge := range r.Relationships { + if strings.HasSuffix(edge.From, "::closedArrayQuery") && edge.Kind == "sql_read" { + if edge.To != "table:orders:closed_array_records" { + t.Fatalf("closed array factory invented outside SQL input: %+v", edge) + } + read = true + } + invoked = invoked || edge.Kind == "resolved_callback_call" && strings.HasSuffix(edge.From, "::ClosedArrayCaller") && strings.HasSuffix(edge.To, "::closedArrayQuery") + if edge.Kind == "callback_return_escape" && strings.HasSuffix(edge.To, "::closedArrayQuery") { + t.Fatal("private array return was treated as a public escape") + } + } + if !read || !invoked { + t.Fatalf("closed array callback lost local flow: read=%t invoked=%t", read, invoked) + } +} + +func TestSliceBuiltinsRetainCallbackFlow(t *testing.T) { + config := DefaultConfig() + config.StorageScopes = []StorageScope{{Namespace: "orders", DatabaseOrigins: []string{"example.com/sample::OpenOrdersDatabase"}}} + r, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"SliceBuiltinCaller"}, Depth: 4, MaxNodes: 250, Config: config}) + if err != nil { + t.Fatal(err) + } + for _, name := range []string{"appended", "copied"} { + known, unknown, escape, invoked := false, false, false, false + for _, edge := range r.Relationships { + if strings.HasSuffix(edge.From, "::"+name+"Query") && edge.Kind == "sql_read" { + known = known || edge.To == "table:orders:"+name+"_records" + unknown = unknown || edge.To == "table:"+name+"_records" + } + escape = escape || edge.Kind == "callback_return_escape" && strings.HasSuffix(edge.To, "::"+name+"Query") + invoked = invoked || edge.Kind == "resolved_callback_call" && strings.HasSuffix(edge.From, "::SliceBuiltinCaller") && strings.HasSuffix(edge.To, "::"+name+"Query") + } + if !known || !unknown || !escape || !invoked { + t.Errorf("%s slice lost callback flow: known=%t unknown=%t escape=%t invoked=%t", name, known, unknown, escape, invoked) + } + } +} + +func TestSliceBuiltinSiblingPathsRetainNestedAndDeferredFlow(t *testing.T) { + config := DefaultConfig() + config.StorageScopes = []StorageScope{{Namespace: "orders", DatabaseOrigins: []string{"example.com/sample::OpenOrdersDatabase"}}} + r, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"SliceBuiltinSiblingCaller"}, Depth: 4, MaxNodes: 250, Config: config}) + if err != nil { + t.Fatal(err) + } + for owner, table := range map[string]string{ + "chainFirstQuery": "chain_first_records", "chainSecondQuery": "chain_second_records", + "copiedRecordQuery": "copied_record_records", "deferredCopyQuery": "deferred_copy_records", + "goCopyQuery": "go_copy_records", + } { + known, unknown, escape, invoked := false, false, false, false + for _, edge := range r.Relationships { + if strings.HasSuffix(edge.From, "::"+owner) && edge.Kind == "sql_read" { + known = known || edge.To == "table:orders:"+table + unknown = unknown || edge.To == "table:"+table + } + escape = escape || edge.Kind == "callback_return_escape" && strings.HasSuffix(edge.To, "::"+owner) + invoked = invoked || edge.Kind == "resolved_callback_call" && strings.HasSuffix(edge.From, "::SliceBuiltinSiblingCaller") && strings.HasSuffix(edge.To, "::"+owner) + } + if !known || !unknown || !escape || !invoked { + t.Errorf("%s sibling slice lost flow: known=%t unknown=%t escape=%t invoked=%t", owner, known, unknown, escape, invoked) + } + } +} + +func TestPrivateSliceBuiltinsRetainOnlyLocalInputs(t *testing.T) { + config := DefaultConfig() + config.StorageScopes = []StorageScope{{Namespace: "orders", DatabaseOrigins: []string{"example.com/sample::OpenOrdersDatabase"}}} + r, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"ClosedSliceBuiltinCaller"}, Depth: 3, MaxNodes: 200, Config: config}) + if err != nil { + t.Fatal(err) + } + read, invoked := false, false + for _, edge := range r.Relationships { + if strings.HasSuffix(edge.From, "::closedSliceBuiltinQuery") && edge.Kind == "sql_read" { + if edge.To != "table:orders:closed_slice_builtin_records" { + t.Fatalf("private append/copy invented outside origin: %+v", edge) + } + read = true + } + invoked = invoked || edge.Kind == "resolved_callback_call" && strings.HasSuffix(edge.From, "::ClosedSliceBuiltinCaller") && strings.HasSuffix(edge.To, "::closedSliceBuiltinQuery") + } + for _, boundary := range r.Boundaries { + if strings.HasSuffix(boundary.Node, "::closedSliceBuiltinQuery") && (boundary.Kind == "external_sql_input" || boundary.Kind == "escaped_sql_input") { + t.Fatal("private append/copy callback acquired an outside-input boundary") + } + } + if !read || !invoked { + t.Fatalf("private append/copy lost local flow: read=%t invoked=%t", read, invoked) + } +} + +func TestPublicGlobalCallbacksRetainOutsideInputs(t *testing.T) { + config := DefaultConfig() + config.StorageScopes = []StorageScope{{Namespace: "orders", DatabaseOrigins: []string{"example.com/sample::OpenOrdersDatabase"}}} + r, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"GlobalCallbackCaller"}, Depth: 4, MaxNodes: 250, Config: config}) + if err != nil { + t.Fatal(err) + } + for owner, table := range map[string]string{"globalDirectQuery": "global_direct_records", "globalFieldQuery": "global_field_records", "globalPointerQuery": "global_pointer_records"} { + known, unknown, escape := false, false, false + for _, edge := range r.Relationships { + if strings.HasSuffix(edge.From, "::"+owner) && edge.Kind == "sql_read" { + known = known || edge.To == "table:orders:"+table + unknown = unknown || edge.To == "table:"+table + } + escape = escape || edge.Kind == "callback_global_escape" && strings.HasSuffix(edge.To, "::"+owner) && strings.Contains(edge.Evidence.Snippet, "Public") + } + if !known || !unknown || !escape { + t.Errorf("%s global hid outside inputs: known=%t unknown=%t escape=%t", owner, known, unknown, escape) + } + } + privateRead := false + for _, edge := range r.Relationships { + if strings.HasSuffix(edge.From, "::privateGlobalQuery") && edge.Kind == "sql_read" { + if edge.To != "table:orders:private_global_records" { + t.Fatalf("private global invented outside origin: %+v", edge) + } + privateRead = true + } + if edge.Kind == "callback_global_escape" && strings.HasSuffix(edge.To, "::privateGlobalQuery") { + t.Fatal("private global was exposed as a public variable") + } + } + if !privateRead { + t.Fatal("private global lost its local SQL origin") + } + pointerWrite := false + for _, edge := range r.Relationships { + pointerWrite = pointerWrite || edge.Kind == "global_write" && strings.HasSuffix(edge.From, "::setGlobalPointer") && strings.HasSuffix(edge.To, "::PublicPointerRecord") + } + if !pointerWrite { + t.Fatal("public pointer global lost its helper writer") + } +} + +func TestPublicSQLGlobalsRetainOutsideReplacementOrigins(t *testing.T) { + config := DefaultConfig() + config.StorageScopes = []StorageScope{{Namespace: "orders", DatabaseOrigins: []string{"example.com/sample::OpenOrdersDatabase"}}} + r, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"GlobalDatabaseCaller"}, Depth: 4, MaxNodes: 250, Config: config}) + if err != nil { + t.Fatal(err) + } + for _, table := range []string{"global_database_records", "global_connection_records", "global_transaction_records", "global_statement_records", "global_record_database_records", "global_sql_slice_records", "global_sql_array_records", "global_sql_map_records"} { + known, unknown := false, false + for _, edge := range r.Relationships { + if edge.Kind == "sql_read" { + known = known || edge.To == "table:orders:"+table + unknown = unknown || edge.To == "table:"+table + } + } + if !known || !unknown { + t.Errorf("public SQL global lost replacement origin for %s: known=%t unknown=%t", table, known, unknown) + } + } + privateRead := false + for _, edge := range r.Relationships { + if strings.HasSuffix(edge.From, "::privateGlobalDatabaseQuery") && edge.Kind == "sql_read" { + if edge.To != "table:orders:private_global_database_records" { + t.Fatalf("private SQL global acquired outside origin: %+v", edge) + } + privateRead = true + } + } + if !privateRead { + t.Fatal("private SQL global lost its known origin") + } + privateFieldRead := false + for _, edge := range r.Relationships { + if strings.HasSuffix(edge.From, "::privateGlobalRecordQuery") && edge.Kind == "sql_read" { + if edge.To != "table:orders:private_global_record_records" { + t.Fatalf("private record field acquired an outside origin: %+v", edge) + } + privateFieldRead = true + } + } + if !privateFieldRead { + t.Fatal("private SQL field lost its local origin") + } + for _, global := range []string{"PublicGlobalDatabase", "PublicGlobalConnection", "PublicGlobalTransaction", "PublicGlobalStatement", "PublicGlobalSQLRecord", "PublicGlobalSQLSlice", "PublicGlobalSQLArray", "PublicGlobalSQLMap"} { + boundary := false + for _, b := range r.Boundaries { + boundary = boundary || b.Kind == "external_sql_global" && strings.HasSuffix(b.Node, "::"+global) + } + if !boundary { + t.Errorf("%s lost its SQL replacement boundary", global) + } + } + preparedUnknown := false + for _, b := range r.Boundaries { + preparedUnknown = preparedUnknown || b.Kind == "unresolved_prepared_sql" && strings.Contains(b.Evidence.Snippet, "PublicGlobalStatement.Query") + } + if !preparedUnknown { + t.Fatal("public statement lost its unknown preparation boundary") + } +} diff --git a/determinism.go b/determinism.go new file mode 100644 index 0000000..a3e4c02 --- /dev/null +++ b/determinism.go @@ -0,0 +1,75 @@ +package contracttrace + +import ( + "fmt" + "sort" + + "golang.org/x/tools/go/ssa" +) + +func sortedKeys[V any](values map[string]V) []string { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + +// Ordering affects the result only when a candidate budget can be reached. +func mergeKeys(values, existing map[string]bool) []string { + if len(existing) >= maxFlowValues { + for key := range values { + if !existing[key] { + // Every caller discards the candidate at capacity, while setting + // the same widening and uncertainty flags. One witness suffices. + return []string{key} + } + } + return nil + } + var result []string + for key := range values { + if !existing[key] { + result = append(result, key) + } + } + if len(existing)+len(result) > maxFlowValues { + sort.Strings(result) + } + return result +} +func mergeFunctions(values, existing map[*ssa.Function]bool) []*ssa.Function { + var result []*ssa.Function + for fn := range values { + if !existing[fn] { + result = append(result, fn) + } + } + if len(existing)+len(result) > maxFlowValues { + sort.Slice(result, func(i, j int) bool { + if result[i].String() != result[j].String() { + return result[i].String() < result[j].String() + } + return result[i].Pos() < result[j].Pos() + }) + } + return result +} +func sortedFunctions(values map[*ssa.Function]bool) []*ssa.Function { + result := make([]*ssa.Function, 0, len(values)) + for function := range values { + result = append(result, function) + } + sort.Slice(result, func(i, j int) bool { + if result[i].String() != result[j].String() { + return result[i].String() < result[j].String() + } + return result[i].Pos() < result[j].Pos() + }) + return result +} +func (ix *index) allocationID(kind string, fn *ssa.Function, value ssa.Value) string { + evidence := ix.evidence(value.Pos()) + return fmt.Sprintf("%s:%s:%s:%d:%d:%s", kind, fn.String(), evidence.File, evidence.Line, evidence.Column, value.Name()) +} diff --git a/determinism_test.go b/determinism_test.go new file mode 100644 index 0000000..45c1a01 --- /dev/null +++ b/determinism_test.go @@ -0,0 +1,45 @@ +package contracttrace + +import ( + "context" + "encoding/json" + "fmt" + "reflect" + "testing" +) + +func TestBoundedFlowMergeIsDeterministic(t *testing.T) { + source := emptyFlow() + for i := 0; i < maxFlowValues+20; i++ { + source.strings[fmt.Sprintf("value-%03d", i)] = true + } + expected := map[string]bool{} + for i := 0; i < maxFlowValues; i++ { + expected[fmt.Sprintf("value-%03d", i)] = true + } + for run := 0; run < 20; run++ { + a := &flowAnalysis{} + dst := emptyFlow() + a.merge(&dst, source) + if !reflect.DeepEqual(dst.strings, expected) || !a.coverage.Widened { + t.Fatal("bounded merge chose arbitrary map iteration candidates or hid widening") + } + } +} + +func TestRepeatedTraceHasIdenticalEvidence(t *testing.T) { + options := Options{Root: "testdata/sample", Seeds: []string{"MapCallback", "ChannelEvent", "WrappedWrite"}, Depth: 4, MaxNodes: 250} + first, err := Trace(context.Background(), options) + if err != nil { + t.Fatal(err) + } + second, err := Trace(context.Background(), options) + if err != nil { + t.Fatal(err) + } + left, _ := json.Marshal(first) + right, _ := json.Marshal(second) + if string(left) != string(right) { + t.Fatal("unchanged source/build/options produced different reports") + } +} diff --git a/embedded.go b/embedded.go new file mode 100644 index 0000000..ade075b --- /dev/null +++ b/embedded.go @@ -0,0 +1,179 @@ +package contracttrace + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "go/ast" + "go/types" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "unicode" + + "golang.org/x/tools/go/packages" +) + +func embedPatterns(text string) ([]string, error) { + result := []string{} + for text = strings.TrimSpace(text); text != ""; text = strings.TrimSpace(text) { + if text[0] == '"' || text[0] == '`' { + quote := text[0] + end := 1 + for end < len(text) { + if quote == '"' && text[end] == '\\' { + end += 2 + continue + } + if text[end] == quote { + break + } + end++ + } + if end >= len(text) { + return nil, fmt.Errorf("unterminated embed pattern") + } + word, err := strconv.Unquote(text[:end+1]) + if err != nil { + return nil, err + } + result = append(result, word) + text = text[end+1:] + } else { + end := strings.IndexFunc(text, unicode.IsSpace) + if end < 0 { + end = len(text) + } + result = append(result, text[:end]) + text = text[end:] + } + } + return result, nil +} +func patternContains(pattern, file string) bool { + pattern = strings.TrimPrefix(pattern, "all:") + for candidate := file; candidate != "."; candidate = filepath.ToSlash(filepath.Dir(candidate)) { + if globMatch(pattern, candidate) { + return true + } + } + return false +} +func (ix *index) loadEmbedded(pkgs []*packages.Package) error { + ix.embedded = map[*types.Var]string{} + ix.assets = selectedEmbeddedAssetPaths(ix.root, pkgs) + bodies := map[string][]byte{} + h := sha256.New() + for _, file := range ix.assets { + body, err := os.ReadFile(filepath.Join(ix.root, filepath.FromSlash(file))) + if err != nil { + return err + } + bodies[file] = body + fmt.Fprintf(h, "%d:%s:%d:", len(file), file, len(body)) + h.Write(body) + } + ix.assetHash = hex.EncodeToString(h.Sum(nil)) + for _, p := range pkgs { + for _, file := range p.Syntax { + directory := filepath.Dir(ix.fset.Position(file.Pos()).Filename) + for _, decl := range file.Decls { + group, ok := decl.(*ast.GenDecl) + if !ok { + continue + } + for _, spec := range group.Specs { + value, ok := spec.(*ast.ValueSpec) + if !ok || len(value.Names) != 1 { + continue + } + patterns := []string{} + for _, comments := range []*ast.CommentGroup{group.Doc, value.Doc} { + if comments != nil { + for _, comment := range comments.List { + if strings.HasPrefix(comment.Text, "//go:embed ") { + parts, err := embedPatterns(strings.TrimPrefix(comment.Text, "//go:embed ")) + if err != nil { + return err + } + patterns = append(patterns, parts...) + } + } + } + } + if len(patterns) == 0 { + continue + } + variable, ok := p.TypesInfo.Defs[value.Names[0]].(*types.Var) + if !ok { + continue + } + candidates := []string{} + for _, file := range p.EmbedFiles { + rel, inside := relative(directory, file) + if inside { + for _, pattern := range patterns { + if patternContains(pattern, rel) { + candidates = append(candidates, file) + break + } + } + } + } + isString := false + switch t := variable.Type().Underlying().(type) { + case *types.Basic: + isString = t.Kind() == types.String + case *types.Slice: + if elem, ok := t.Elem().Underlying().(*types.Basic); ok { + isString = elem.Kind() == types.Byte + } + } + if isString && len(candidates) == 1 { + rel, inside := relative(ix.root, candidates[0]) + body, present := bodies[rel] + if !inside || !present { + return fmt.Errorf("embedded asset outside target snapshot: %s", candidates[0]) + } + ix.embedded[variable] = string(body) + } else { + ix.boundaries = append(ix.boundaries, Boundary{Kind: "embedded_filesystem", Reason: "embedded FS or ambiguous asset value is inventoried but not flattened into string flow", Evidence: ix.evidence(value.Pos())}) + } + } + } + } + } + return nil +} + +func selectedEmbeddedAssetPaths(root string, pkgs []*packages.Package) []string { + files := map[string]bool{} + for _, p := range pkgs { + for _, file := range p.EmbedFiles { + if rel, inside := relative(root, file); inside { + files[rel] = true + } + } + } + assets := make([]string, 0, len(files)) + for file := range files { + assets = append(assets, file) + } + sort.Strings(assets) + return assets +} + +func hashFiles(root string, files []string) (string, error) { + h := sha256.New() + for _, file := range files { + body, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(file))) + if err != nil { + return "", err + } + fmt.Fprintf(h, "%d:%s:%d:", len(file), file, len(body)) + h.Write(body) + } + return hex.EncodeToString(h.Sum(nil)), nil +} diff --git a/embedded_selection_test.go b/embedded_selection_test.go new file mode 100644 index 0000000..2f0c9cb --- /dev/null +++ b/embedded_selection_test.go @@ -0,0 +1,94 @@ +package contracttrace + +import ( + "bytes" + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestExploreRejectsNewEmbeddedFSMatch(t *testing.T) { + fixture := newEmbeddedSelectionFixture(t, `import "embed"`, "//go:embed assets/*.txt\nvar embedded embed.FS", "_ = embedded") + saved := traceGzipSnapshot(t, fixture.root) + + if err := os.WriteFile(filepath.Join(fixture.root, "assets", "unmatched.bin"), []byte("unmatched"), 0600); err != nil { + t.Fatal(err) + } + if _, err := Explore(context.Background(), saved, ExploreOptions{Seeds: []string{"Seed"}, Depth: 1, MaxNodes: 20}); err != nil { + t.Fatalf("unmatched non-Go file changed embedded selection: %v", err) + } + if err := os.WriteFile(filepath.Join(fixture.root, "assets", "two.txt"), []byte("two"), 0600); err != nil { + t.Fatal(err) + } + if _, err := Explore(context.Background(), saved, ExploreOptions{Seeds: []string{"Seed"}, Depth: 1, MaxNodes: 20}); err == nil || !strings.Contains(err.Error(), "embedded asset set changed") { + t.Fatalf("saved graph accepted a new embedded FS match: %v", err) + } +} + +func TestExploreRejectsNewScalarEmbedMatches(t *testing.T) { + for _, test := range []struct { + name string + typeName string + }{ + {name: "string", typeName: "string"}, + {name: "byte slice", typeName: "[]byte"}, + } { + t.Run(test.name, func(t *testing.T) { + fixture := newEmbeddedSelectionFixture(t, `import _ "embed"`, "//go:embed assets/single*.txt\nvar embedded "+test.typeName, "_ = embedded") + saved := traceGzipSnapshot(t, fixture.root) + if err := os.WriteFile(filepath.Join(fixture.root, "assets", "single-two.txt"), []byte("two"), 0600); err != nil { + t.Fatal(err) + } + if _, err := Explore(context.Background(), saved, ExploreOptions{Seeds: []string{"Seed"}, Depth: 1, MaxNodes: 20}); err == nil || !strings.Contains(err.Error(), "embedded asset set changed") { + t.Fatalf("saved graph accepted second %s embed match: %v", test.name, err) + } + }) + } +} + +type embeddedSelectionFixture struct { + root string +} + +func newEmbeddedSelectionFixture(t *testing.T, importLine, embedDecl, use string) embeddedSelectionFixture { + t.Helper() + root := t.TempDir() + if err := os.Mkdir(filepath.Join(root, "assets"), 0700); err != nil { + t.Fatal(err) + } + source := "package app\n\n" + importLine + "\n\n" + embedDecl + "\n\nfunc Seed() { " + use + " }\n" + if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example.com/embedselection\n\ngo 1.27.0\n"), 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "app.go"), []byte(source), 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "assets", "single-one.txt"), []byte("one"), 0600); err != nil { + t.Fatal(err) + } + t.Setenv("GOWORK", "off") + return embeddedSelectionFixture{root: root} +} + +func (f embeddedSelectionFixture) options() Options { + return Options{Root: f.root, Seeds: []string{"Seed"}, Depth: 1, MaxNodes: 20} +} + +func traceGzipSnapshot(t *testing.T, root string) Analysis { + t.Helper() + _, analysis, err := TraceWithAnalysis(context.Background(), Options{Root: root, Seeds: []string{"Seed"}, Depth: 1, MaxNodes: 20}) + if err != nil { + t.Fatalf("trace embedded fixture: %v", err) + } + var compressed bytes.Buffer + if err := WriteAnalysis(&compressed, analysis, true); err != nil { + t.Fatalf("write embedded snapshot: %v", err) + } + saved, err := ReadAnalysis(&compressed) + if err != nil { + t.Fatalf("read embedded snapshot: %v", err) + } + return saved +} diff --git a/error_aggregate_test.go b/error_aggregate_test.go new file mode 100644 index 0000000..f73b45b --- /dev/null +++ b/error_aggregate_test.go @@ -0,0 +1,85 @@ +package contracttrace + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestOutsideAggregateErrorsRetainOriginsAndPrivateControl(t *testing.T) { + root := t.TempDir() + files := map[string]string{ + "go.mod": "module example.com/aggregateerrors\n\ngo 1.27.0\n", + "input.go": `package aggregateerrors +import "errors" +type Record struct { E error; Next *Record; hidden error } +func source() error { return errors.New("local") } +func Public(r *Record) bool { return r.E != nil } +func Nested(r *Record) error { return r.Next.E } +func Slice(es []error) error { return es[0] } +func Map(es map[string]error) error { return es["key"] } +func Key(es map[error]int) error { for e := range es { return e }; return nil } +func Pointer(e **error) error { return **e } +func callback(r *Record) bool { return r.E != nil } +func Factory() func(*Record) bool { return callback } +func closed(r *Record) bool { return r.E != nil } +func Local() bool { r := &Record{E: source()}; return Public(r) || callback(r) || closed(r) } +func Hidden(r *Record) error { return r.hidden } +func Result(factory func() *Record) error { return factory().E } +func closedResult(factory func() *Record) error { return factory().E } +func localRecord() *Record { return &Record{E: source()} } +func Results() error { _ = Result(localRecord); return closedResult(localRecord) } +type Wrapper struct { E error } +func (Wrapper) Error() string { return "wrapper" } +func Concrete(w Wrapper) error { return w.E } +func ConcretePointer(w *Wrapper) error { return w.E } +`, + } + for name, contents := range files { + if err := os.WriteFile(filepath.Join(root, name), []byte(contents), 0644); err != nil { + t.Fatal(err) + } + } + seeds := []string{"Public", "Nested", "Slice", "Map", "Key", "Pointer", "callback", "closed", "Hidden", "Result", "closedResult", "Concrete", "ConcretePointer"} + fresh, saved, err := TraceWithAnalysis(context.Background(), Options{Root: root, Seeds: seeds, Depth: 4, MaxNodes: 200}) + if err != nil { + t.Fatal(err) + } + resumed, err := Explore(context.Background(), saved, ExploreOptions{Seeds: seeds, Depth: 4, MaxNodes: 200}) + if err != nil { + t.Fatal(err) + } + for _, report := range []Report{fresh, resumed} { + for _, owner := range seeds { + outside, local := false, false + for _, edge := range report.Relationships { + if !strings.HasSuffix(edge.From, "::"+owner) || (edge.Kind != "error_compare" && edge.Kind != "error_return") { + continue + } + outside = outside || strings.HasPrefix(edge.To, "error-input:") + for _, node := range report.Nodes { + if node.ID == edge.To && node.Kind == "error_result" && strings.Contains(node.Evidence.Snippet, `errors.New("local")`) { + local = true + } + } + } + if outside != (owner != "closed" && owner != "Hidden" && owner != "closedResult") { + t.Errorf("%s outside=%v", owner, outside) + } + if (owner == "Public" || owner == "callback" || owner == "closed" || owner == "Result" || owner == "closedResult") && !local { + t.Errorf("%s lost local origin", owner) + } + } + found := false + for _, boundary := range report.Boundaries { + if boundary.Kind == "outside_error_input" && boundary.Evidence.File == "input.go" && boundary.Evidence.Line > 0 { + found = true + } + } + if !found { + t.Error("missing source-backed aggregate error boundary") + } + } +} diff --git a/error_flow.go b/error_flow.go new file mode 100644 index 0000000..77a6516 --- /dev/null +++ b/error_flow.go @@ -0,0 +1,146 @@ +package contracttrace + +import ( + "fmt" + "go/token" + "go/types" + + "golang.org/x/tools/go/ssa" +) + +func isErrorType(t types.Type) bool { + return t != nil && types.Implements(t, types.Universe.Lookup("error").Type().Underlying().(*types.Interface)) +} + +func errorResultID(call ssa.CallInstruction, slot int, ix *index) string { + e := ix.callEvidence(call) + if call.Pos() == token.NoPos { + for index, instruction := range call.Block().Instrs { + if instruction == call { + return fmt.Sprintf("error-result:%s:%d:%d:%s:block%d:instruction%d:%d", e.File, e.Line, e.Column, call.Parent().String(), call.Block().Index, index, slot) + } + } + } + return fmt.Sprintf("error-result:%s:%d:%d:%s:%d", e.File, e.Line, e.Column, call.Parent().String(), slot) +} + +func (a *flowAnalysis) seedErrorResults(funcs []*ssa.Function, ix *index) { + for _, fn := range funcs { + for _, block := range fn.Blocks { + for _, instruction := range block.Instrs { + call, ok := instruction.(ssa.CallInstruction) + if !ok { + continue + } + results := call.Common().Signature().Results() + for slot := 0; slot < results.Len(); slot++ { + if !isErrorType(results.At(slot).Type()) { + continue + } + id := errorResultID(call, slot, ix) + e := ix.callEvidence(call) + ix.funcs[id] = &function{node: Node{ID: id, Name: fmt.Sprintf("error result %d at %s:%d:%d", slot, e.File, e.Line, e.Column), Kind: "error_result", Evidence: e}} + ix.edges = append(ix.edges, Relationship{From: ix.owner(fn), To: id, Kind: "error_result", Certainty: "fact", Evidence: e, Slot: fmt.Sprintf("result:%d", slot)}) + value := emptyFlow() + origin := id + if target := call.Common().StaticCallee(); target != nil && len(target.Blocks) != 0 { + if owner := ix.owner(target); owner != "" { + origin = fmt.Sprintf("error-return:%s:%d", owner, slot) + if ix.funcs[origin] == nil { + declaration := ix.funcs[owner].node.Evidence + ix.funcs[origin] = &function{node: Node{ID: origin, Name: fmt.Sprintf("error return slot %d of %s", slot, owner), Kind: "error_return_slot", Evidence: declaration}} + } + ix.edges = append(ix.edges, Relationship{From: id, To: origin, Kind: "error_result_contract", Certainty: "fact", Evidence: e, Slot: fmt.Sprintf("result:%d", slot)}) + } + } + value.errors = map[string]bool{origin: true} + if normal, ok := call.(*ssa.Call); ok { + if results.Len() == 1 { + a.put(normal, value) + } else if refs := normal.Referrers(); refs != nil { + for _, ref := range *refs { + if extract, ok := ref.(*ssa.Extract); ok && extract.Index == slot { + a.put(extract, value) + } + } + } + } + } + } + } + } + ix.boundaries = append(ix.boundaries, Boundary{Kind: "error_flow_model", Reason: "Error-typed call results retain bounded candidate origins through local parameters, returns and modeled memory/channels. Modeled static local calls share a callee return-slot origin while retaining every source call-result site; dependency and dynamic calls retain call-site origins. A result slot does not prove a non-nil error. Comparisons and unused SSA results identify investigation sites, not correct handling; invocation correlation, wrapping in dependencies, outside error inputs and runtime failure/cleanup ordering remain unresolved."}) + ix.boundaries = append(ix.boundaries, Boundary{Kind: "error_wrap_model", Reason: "Known errors.Join and fmt.Errorf targets retain candidate causes from modeled variadic slice elements. Closed literal argument arrays select fmt %w operands, including explicit indices and width/precision operands. Other storage shapes retain conservative candidates and pairing boundaries; successful formatting, nil filtering, outside/unmodeled slice contents, custom wrapping and runtime Unwrap/Is/As behavior remain unresolved. Candidate causes do not prove actual wrapping."}) +} + +func (a *flowAnalysis) errorUse(ins ssa.Instruction, ix *index) { + owner := ix.owner(ins.Parent()) + link := func(value ssa.Value, kind string, pos token.Pos, slot string) { + for _, origin := range sortedKeys(a.get(value).errors) { + ix.edges = append(ix.edges, Relationship{From: owner, To: origin, Kind: kind, Certainty: "possible", Evidence: ix.evidence(pos), Slot: slot}) + } + } + switch v := ins.(type) { + case *ssa.Return: + for slot, result := range v.Results { + link(result, "error_return", v.Pos(), fmt.Sprintf("result:%d", slot)) + } + case *ssa.BinOp: + if v.Op == token.EQL || v.Op == token.NEQ { + link(v.X, "error_compare", v.Pos(), "left") + link(v.Y, "error_compare", v.Pos(), "right") + } + case *ssa.Store: + link(v.Val, "error_store", v.Pos(), "value") + case *ssa.Send: + link(v.X, "error_send", v.Pos(), "value") + case *ssa.Panic: + link(v.X, "error_panic", v.Pos(), "value") + } + call, ok := ins.(ssa.CallInstruction) + if !ok { + return + } + for slot, argument := range flowArguments(call.Common()) { + link(argument, "error_argument", call.Pos(), flowArgumentSlot(call.Common(), slot)) + } + results := call.Common().Signature().Results() + for slot := 0; slot < results.Len(); slot++ { + if !isErrorType(results.At(slot).Type()) { + continue + } + kind := "" + switch v := call.(type) { + case *ssa.Defer: + kind = "deferred_error_result_discard" + case *ssa.Go: + kind = "goroutine_error_result_discard" + case *ssa.Call: + used := false + if refs := v.Referrers(); refs != nil { + for _, ref := range *refs { + if _, debug := ref.(*ssa.DebugRef); debug { + continue + } + if results.Len() == 1 { + used = true + } else if extract, ok := ref.(*ssa.Extract); ok && extract.Index == slot { + if consumers := extract.Referrers(); consumers != nil { + for _, consumer := range *consumers { + if _, debug := consumer.(*ssa.DebugRef); !debug { + used = true + } + } + } + } + } + } + if !used { + kind = "error_result_unused" + } + } + if kind != "" { + ix.edges = append(ix.edges, Relationship{From: owner, To: errorResultID(call, slot, ix), Kind: kind, Certainty: "fact", Evidence: ix.callEvidence(call), Slot: fmt.Sprintf("result:%d", slot)}) + } + } +} diff --git a/error_flow_test.go b/error_flow_test.go new file mode 100644 index 0000000..dfe2fc2 --- /dev/null +++ b/error_flow_test.go @@ -0,0 +1,143 @@ +package contracttrace + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestErrorResultInvestigationFlow(t *testing.T) { + root := t.TempDir() + files := map[string]string{ + "go.mod": "module example.com/errorflow\n\ngo 1.27.0\n", + "error.go": `package errorflow +import "errors" +func fail() error { return errors.New("failure") } +func Relay() error { return fail() } +func Check() bool { return Relay() != nil } +func Discard() { _ = Relay(); defer Relay(); go Relay() } +func Pair() (int, error) { return 1, Relay() } +func ReturnPair() error { _, err := Pair(); return err } +type record struct { E error } +func ThroughStorage() error { r := &record{E: Relay()}; return r.E } +func UseError(err error) bool { return err != nil } +func Pass() bool { return UseError(Relay()) } +func DiscardTuple() int { n, _ := Pair(); return n } +func UsedTuple() error { _, err := Pair(); return err } +func Channel() error { ch := make(chan error, 1); ch <- Relay(); return <-ch } +type provider interface { Failure() error } +type localProvider struct{} +func (localProvider) Failure() error { return Relay() } +func Interface(p provider) bool { return p.Failure() != nil } +func SeedInterface() bool { return Interface(localProvider{}) } +`, + } + for name, contents := range files { + if err := os.WriteFile(filepath.Join(root, name), []byte(contents), 0644); err != nil { + t.Fatal(err) + } + } + report, saved, err := TraceWithAnalysis(context.Background(), Options{Root: root, Seeds: []string{"fail"}, Depth: 8, MaxNodes: 150}) + if err != nil { + t.Fatal(err) + } + for _, document := range []Report{report} { + for _, expected := range []struct{ owner, kind string }{ + {"Check", "error_compare"}, {"ReturnPair", "error_return"}, + {"ThroughStorage", "error_return"}, {"Pass", "error_argument"}, + {"UseError", "error_compare"}, {"Discard", "error_result_unused"}, + {"Channel", "error_return"}, {"Interface", "error_compare"}, {"DiscardTuple", "error_result_unused"}, + {"Discard", "deferred_error_result_discard"}, {"Discard", "goroutine_error_result_discard"}, + } { + found := false + for _, edge := range document.Relationships { + if strings.HasSuffix(edge.From, "::"+expected.owner) && edge.Kind == expected.kind && strings.HasPrefix(edge.To, "error-result:") { + found = true + } + } + if !found { + t.Errorf("missing %s in %s", expected.kind, expected.owner) + } + } + } + for _, owner := range []string{"UseError", "ThroughStorage", "Channel", "Interface"} { + found := false + for _, edge := range report.Relationships { + if !strings.HasSuffix(edge.From, "::"+owner) { + continue + } + for _, node := range report.Nodes { + if node.ID == edge.To && node.Kind == "error_result" && node.Evidence.Line == 3 { + found = true + } + } + } + if !found { + t.Errorf("%s lost the original errors.New result", owner) + } + } + for _, edge := range report.Relationships { + if strings.HasSuffix(edge.From, "::UsedTuple") && edge.Kind == "error_result_unused" { + t.Error("used tuple error classified as unused") + } + if strings.HasSuffix(edge.From, "::DiscardTuple") && edge.Kind == "error_result_unused" && edge.Slot != "result:1" { + t.Error("integer tuple slot classified as an error") + } + } + resumed, err := Explore(context.Background(), saved, ExploreOptions{Seeds: []string{"Check"}, Depth: 8, MaxNodes: 150}) + if err != nil { + t.Fatal(err) + } + if !hasName(resumed, "ThroughStorage") { + t.Fatal("saved error investigation lost storage sibling") + } +} + +func TestRecursiveErrorForwardingDoesNotInventDistinctOrigins(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example.com/recursiveerror\n\ngo 1.27.0\n"), 0644); err != nil { + t.Fatal(err) + } + source := "package recursiveerror\nimport \"errors\"\nfunc Recurse(n int) error {\nif n == 0 { return errors.New(\"failure\") }\n" + for i := 1; i <= 80; i++ { + source += fmt.Sprintf("if n == %d { return Recurse(n-1) }\n", i) + } + source += "return nil\n}\n" + if err := os.WriteFile(filepath.Join(root, "error.go"), []byte(source), 0644); err != nil { + t.Fatal(err) + } + r, err := Trace(context.Background(), Options{Root: root, Seeds: []string{"Recurse"}, Depth: 3, MaxNodes: 200}) + if err != nil { + t.Fatal(err) + } + if r.Coverage.ValueFlow.Widened { + t.Fatal("forwarding one error through recursive call sites exhausted origin budget") + } + sites := 0 + for _, edge := range r.Relationships { + if edge.Kind == "error_result" { + sites++ + } + } + if sites != 81 { + t.Fatalf("lost source call result sites: %d", sites) + } + original := "" + for _, node := range r.Nodes { + if node.Kind == "error_result" && node.Evidence.Line == 4 { + original = node.ID + } + } + found := false + for _, edge := range r.Relationships { + if edge.Kind == "error_return" && edge.To == original { + found = true + } + } + if !found || original == "" { + t.Fatal("recursive forwarding lost original dependency error result") + } +} diff --git a/error_globals_test.go b/error_globals_test.go new file mode 100644 index 0000000..97b923f --- /dev/null +++ b/error_globals_test.go @@ -0,0 +1,83 @@ +package contracttrace + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestPublicErrorGlobalsKeepReplacementAndAliasedContents(t *testing.T) { + root := t.TempDir() + files := map[string]string{ + "go.mod": "module example.com/errorglobals\n\ngo 1.27.0\n", + "global.go": `package errorglobals +import "errors" +func source() error { return errors.New("local") } +var Public error = source() +var private error = source() +type Record struct { E error; hidden error } +var Shared = &Record{E: source(), hidden: source()} +var alias = Shared +var Empty *Record +var Slice = []error{source()} +var Map = map[string]error{"key": source()} +var EmptySlice []error +var EmptyMap map[string]error +func Read() error { return Public } +func Private() error { return private } +func Field() error { return Shared.E } +func Alias() error { return alias.E } +func Hidden() error { return Shared.hidden } +func EmptyField() error { return Empty.E } +func SliceRead() error { return Slice[0] } +func MapRead() error { return Map["key"] } +func EmptySliceRead() error { return EmptySlice[0] } +func EmptyMapRead() error { return EmptyMap["key"] } +`, + } + for name, contents := range files { + if err := os.WriteFile(filepath.Join(root, name), []byte(contents), 0644); err != nil { + t.Fatal(err) + } + } + seeds := []string{"Read", "Private", "Field", "Alias", "Hidden", "EmptyField", "SliceRead", "MapRead", "EmptySliceRead", "EmptyMapRead"} + fresh, saved, err := TraceWithAnalysis(context.Background(), Options{Root: root, Seeds: append([]string{"Public"}, seeds...), Depth: 4, MaxNodes: 250}) + if err != nil { + t.Fatal(err) + } + resumed, err := Explore(context.Background(), saved, ExploreOptions{Seeds: append([]string{"Public"}, seeds...), Depth: 4, MaxNodes: 250}) + if err != nil { + t.Fatal(err) + } + for _, report := range []Report{fresh, resumed} { + for _, owner := range seeds { + outside, local := false, false + for _, edge := range report.Relationships { + if !strings.HasSuffix(edge.From, "::"+owner) || edge.Kind != "error_return" { + continue + } + outside = outside || strings.HasPrefix(edge.To, "error-input:") + for _, node := range report.Nodes { + if node.ID == edge.To && node.Kind == "error_result" && strings.Contains(node.Evidence.Snippet, `errors.New("local")`) { + local = true + } + } + } + if outside != (owner != "Private" && owner != "Hidden") { + t.Errorf("%s outside=%v", owner, outside) + } + if owner != "EmptyField" && owner != "EmptySliceRead" && owner != "EmptyMapRead" && !local { + t.Errorf("%s lost local error", owner) + } + } + outside := false + for _, boundary := range report.Boundaries { + outside = outside || boundary.Kind == "outside_error_input" && boundary.Evidence.File == "global.go" && boundary.Evidence.Line > 0 + } + if !outside { + t.Error("missing source-backed global error boundary") + } + } +} diff --git a/error_inputs.go b/error_inputs.go new file mode 100644 index 0000000..20a9552 --- /dev/null +++ b/error_inputs.go @@ -0,0 +1,92 @@ +package contracttrace + +import ( + "fmt" + "go/token" + "go/types" + + "golang.org/x/tools/go/ssa" +) + +func isStringType(typ types.Type) bool { + basic, ok := typ.Underlying().(*types.Basic) + return ok && basic.Kind() == types.String +} + +func (a *flowAnalysis) seedFormatInput(parameter *ssa.Parameter) bool { + if !isStringType(parameter.Type()) { + return false + } + value := emptyFlow() + value.stringUnknown = true + return a.put(parameter, value) +} + +func (a *flowAnalysis) seedErrorInput(parameter *ssa.Parameter, ix *index, reason string) bool { + if !isErrorType(parameter.Type()) { + return false + } + fn := parameter.Parent() + owner := ix.owner(fn) + if owner == "" { + return false + } + ordinal := 0 + for i, candidate := range fn.Params { + if candidate == parameter { + ordinal = i + break + } + } + id := fmt.Sprintf("error-input:%s:%d", fn.String(), ordinal) + if ix.funcs[id] == nil { + e := ix.evidence(parameter.Pos()) + if parameter.Pos() == token.NoPos { + e = ix.funcs[owner].node.Evidence + e.Origin = "synthetic_declaration" + } + ix.funcs[id] = &function{node: Node{ID: id, Name: fmt.Sprintf("unresolved error input %d of %s", ordinal, owner), Kind: "error_input", Evidence: e}} + ix.edges = append(ix.edges, Relationship{From: owner, To: id, Kind: "external_input", Certainty: "possible", Evidence: e, Slot: fmt.Sprintf("parameter:%d", ordinal)}) + ix.boundaries = append(ix.boundaries, Boundary{Node: id, Kind: "outside_error_input", Reason: reason + " Known local error origins remain alongside this unresolved candidate; this is not proof of invocation or a non-nil error.", Evidence: e}) + } + value := emptyFlow() + value.errors = map[string]bool{id: true} + return a.put(parameter, value) +} + +func (a *flowAnalysis) seedErrorInputs(functions []*ssa.Function, ix *index, uncalled bool) bool { + called := map[*ssa.Function]bool{} + if uncalled { + for _, fn := range functions { + for _, block := range fn.Blocks { + for _, ins := range block.Instrs { + if call, ok := ins.(ssa.CallInstruction); ok { + for target := range a.targets(call.Common()) { + called[target] = true + } + } + } + } + } + } + changed := false + for _, fn := range functions { + public := fn.Object() != nil && fn.Object().Exported() + if !public && (!uncalled || called[fn]) { + continue + } + reason := "An exported function or method can receive error values from outside the analyzed callers." + if !public { + reason = "No modeled incoming call supplies this error parameter; missing or unmodeled invocation remains unresolved." + } + for _, parameter := range fn.Params { + if a.seedFormatInput(parameter) { + changed = true + } + if a.seedErrorInput(parameter, ix, reason) { + changed = true + } + } + } + return changed +} diff --git a/error_inputs_test.go b/error_inputs_test.go new file mode 100644 index 0000000..9a2b6a1 --- /dev/null +++ b/error_inputs_test.go @@ -0,0 +1,80 @@ +package contracttrace + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestOutsideErrorInputsKeepKnownOriginsAndPrivateControl(t *testing.T) { + root := t.TempDir() + files := map[string]string{ + "go.mod": "module example.com/errorinputs\n\ngo 1.27.0\n", + "input.go": `package errorinputs +import "errors" +func source() error { return errors.New("local") } +func Public(err error) bool { return err != nil } +func KnownPublic() bool { return Public(source()) } +func closed(err error) bool { return err != nil } +func Closed() bool { return closed(source()) } +func callback(err error) bool { return err != nil } +func ExportCallback() func(error) bool { return callback } +func MixedCallback() bool { return callback(source()) } +func unused(err error) bool { return err != nil } +`, + } + for name, contents := range files { + if err := os.WriteFile(filepath.Join(root, name), []byte(contents), 0644); err != nil { + t.Fatal(err) + } + } + r, saved, err := TraceWithAnalysis(context.Background(), Options{Root: root, Seeds: []string{"Public", "closed", "callback", "unused"}, Depth: 4, MaxNodes: 150}) + if err != nil { + t.Fatal(err) + } + for _, owner := range []string{"Public", "callback", "unused"} { + outside := false + for _, edge := range r.Relationships { + if strings.HasSuffix(edge.From, "::"+owner) && edge.Kind == "error_compare" && strings.HasPrefix(edge.To, "error-input:") { + outside = true + } + } + if !outside { + t.Errorf("%s missing unresolved outside error input", owner) + } + } + for _, owner := range []string{"Public", "closed", "callback"} { + known := false + for _, edge := range r.Relationships { + if !strings.HasSuffix(edge.From, "::"+owner) || edge.Kind != "error_compare" { + continue + } + if owner == "closed" && strings.HasPrefix(edge.To, "error-input:") { + t.Error("closed helper invented outside error input") + } + for _, node := range r.Nodes { + if edge.To == node.ID && node.Kind == "error_result" && node.Evidence.Line == 3 { + known = true + } + } + } + if !known { + t.Errorf("%s lost known local error", owner) + } + } + resumed, err := Explore(context.Background(), saved, ExploreOptions{Seeds: []string{"Public", "callback", "unused"}, Depth: 4, MaxNodes: 150}) + if err != nil { + t.Fatal(err) + } + boundary := false + for _, item := range resumed.Boundaries { + if item.Kind == "outside_error_input" && item.Evidence.File == "input.go" && item.Evidence.Line != 0 { + boundary = true + } + } + if !boundary { + t.Fatal("saved exploration lost source-backed outside error boundary") + } +} diff --git a/error_scope_test.go b/error_scope_test.go new file mode 100644 index 0000000..4ae7c9b --- /dev/null +++ b/error_scope_test.go @@ -0,0 +1,33 @@ +package contracttrace + +import "testing" + +func TestPossibleErrorOriginsDoNotCrowdCoreCallerPaths(t *testing.T) { + ix := &index{funcs: map[string]*function{ + "seed": {node: Node{ID: "seed", Name: "seed", Kind: "function"}}, + "caller": {node: Node{ID: "caller", Name: "caller", Kind: "function"}}, + "entry": {node: Node{ID: "entry", Name: "entry", Kind: "function"}}, + "error": {node: Node{ID: "error", Name: "error", Kind: "error_result"}}, + }, edges: []Relationship{ + {From: "caller", To: "seed", Kind: "call", Certainty: "fact"}, + {From: "entry", To: "caller", Kind: "call", Certainty: "fact"}, + {From: "seed", To: "error", Kind: "error_return", Certainty: "possible"}, + }} + r, err := ix.expand(Options{Seeds: []string{"seed"}, Depth: 2, MaxNodes: 3}, Coverage{}) + if err != nil { + t.Fatal(err) + } + if !hasName(r, "entry") { + t.Fatal("possible error origin displaced source-backed entry caller") + } + if !r.Coverage.Truncated { + t.Fatal("omitted error candidate was hidden") + } + r, err = ix.expand(Options{Seeds: []string{"seed"}, Depth: 2, MaxNodes: 4}, Coverage{}) + if err != nil { + t.Fatal(err) + } + if !hasName(r, "error") { + t.Fatal("error candidate was removed instead of deferred") + } +} diff --git a/error_wrap.go b/error_wrap.go new file mode 100644 index 0000000..fbd8200 --- /dev/null +++ b/error_wrap.go @@ -0,0 +1,132 @@ +package contracttrace + +import ( + "strings" + + "golang.org/x/tools/go/ssa" +) + +// Recognize wrapping verbs without treating escaped percent signs or a later +// literal 'w' as directives. Argument pairing is deliberately not inferred. +func wrappingFormat(format string) (wrap, unresolved bool) { + for i := 0; i < len(format); i++ { + if format[i] != '%' { + continue + } + i++ + if i == len(format) { + return wrap, true + } + if format[i] == '%' { + continue + } + for i < len(format) { + c := format[i] + if strings.ContainsRune("+-# 0.*", rune(c)) || c >= '1' && c <= '9' { + i++ + continue + } + if c == '[' { + i++ + start := i + for i < len(format) && format[i] >= '0' && format[i] <= '9' { + i++ + } + if i == start || i == len(format) || format[i] != ']' { + return wrap, true + } + i++ + continue + } + break + } + if i == len(format) { + return wrap, true + } + wrap = wrap || format[i] == 'w' + } + return wrap, false +} + +func errorWrapperName(target *ssa.Function) string { + if target == nil || target.Object() == nil || target.Object().Pkg() == nil || target.Signature.Recv() != nil { + return "" + } + name := target.Object().Pkg().Path() + "." + target.Object().Name() + if name == "errors.Join" || name == "fmt.Errorf" { + return name + } + return "" +} + +func (a *flowAnalysis) errorWrapCandidates(call *ssa.Call) (flowValue, bool) { + var result flowValue + unresolved := false + common := call.Common() + if common.IsInvoke() { + return result, false + } + if target := common.StaticCallee(); target != nil && errorWrapperName(target) == "" { + return result, false + } + for _, target := range sortedFunctions(a.targets(common)) { + name := errorWrapperName(target) + var elements flowValue + switch name { + case "errors.Join": + if len(common.Args) != 1 { + continue + } + elements = a.sequenceElements(common.Args[0]) + case "fmt.Errorf": + if len(common.Args) != 2 { + continue + } + formatValue := a.get(common.Args[0]) + formats := formatValue.strings + arguments, paired := literalFormatArguments(call) + if paired && len(formats) != 0 && !formatValue.stringUnknown { + for _, format := range sortedKeys(formats) { + positions, unknown := wrappingArguments(format, len(arguments)) + if unknown { + unresolved = true + a.merge(&elements, a.sequenceElements(common.Args[1])) + continue + } + for _, position := range positions { + a.merge(&elements, a.get(arguments[position])) + } + } + break + } + mayWrap := len(formats) == 0 || formatValue.stringUnknown + unresolved = unresolved || len(formats) == 0 || formatValue.stringUnknown + for _, format := range sortedKeys(formats) { + wrap, unknown := wrappingFormat(format) + mayWrap = mayWrap || wrap || unknown + unresolved = unresolved || unknown + } + if !mayWrap { + continue + } + unresolved = true + elements = a.sequenceElements(common.Args[1]) + default: + continue + } + origins := emptyFlow() + origins.errors = elements.errors + a.merge(&result, origins) + } + return result, unresolved +} + +func (a *flowAnalysis) errorWrapUse(call *ssa.Call, ix *index) { + value, unresolved := a.errorWrapCandidates(call) + for _, origin := range sortedKeys(value.errors) { + ix.edges = append(ix.edges, Relationship{From: errorResultID(call, 0, ix), To: origin, Kind: "error_wrap_cause", Certainty: "possible", Evidence: ix.callEvidence(call)}) + } + if unresolved { + ix.boundaries = append(ix.boundaries, Boundary{Node: ix.owner(call.Parent()), Kind: "error_wrap_format", Reason: "fmt.Errorf format or argument positions are unavailable or not fully recognized; candidate error arguments may be wrapped, and formatting-only behavior remains possible", Evidence: ix.callEvidence(call)}) + } +} diff --git a/error_wrap_test.go b/error_wrap_test.go new file mode 100644 index 0000000..8d3e02f --- /dev/null +++ b/error_wrap_test.go @@ -0,0 +1,115 @@ +package contracttrace + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestErrorWrappingRetainsCausesAndFormattingControls(t *testing.T) { + root := t.TempDir() + files := map[string]string{ + "go.mod": "module example.com/errorwrap\n\ngo 1.27.0\n", + "wrap.go": `package errorwrap +import ("errors"; "fmt") +func cause() error { return errors.New("cause") } +func Wrap() error { return fmt.Errorf("operation: %w", cause()) } +func Joined() error { return errors.Join(nil, Wrap(), cause()) } +func Spread() error { values := []error{cause()}; values = append(values, Wrap()); return errors.Join(values...) } +func Check() bool { return Joined() != nil } +func Display() error { return fmt.Errorf("display: %v", cause()) } +func Escaped() error { return fmt.Errorf("literal %%w: %v", cause()) } +func Indexed() error { return fmt.Errorf("%[2]w %[1]v", "message", cause()) } +func Dynamic(format string) error { return fmt.Errorf(format, cause()) } +var Join = errors.Join +func Alias() error { return Join(cause()) } +func PublicFormat(format string) error { return fmt.Errorf(format, cause()) } +func FormatCaller() error { return PublicFormat("%v") } +func closedFormat(format string) error { return fmt.Errorf(format, cause()) } +func ClosedFormatCaller() error { return closedFormat("%v") } +func ConcatFormat(format string) error { return fmt.Errorf("prefix: " + format, cause()) } +func ConcatCaller() error { return ConcatFormat("%v") } +func other() error { return errors.New("other") } +func Selected() error { return fmt.Errorf("%v %w", other(), cause()) } +func SelectedIndex() error { return fmt.Errorf("%[2]w %[1]v", other(), cause()) } +func Width() error { return fmt.Errorf("%*w %v", 8, cause(), other()) } +func Precision() error { return fmt.Errorf("%.*w %v", 3, cause(), other()) } +func Reordered() error { return fmt.Errorf("%[3]*.[2]*[1]w %[4]v", cause(), 3, 8, other()) } +func Mutated() error { values := []any{other(), cause()}; values[0] = cause(); return fmt.Errorf("%w %v", values...) } +`, + } + for name, contents := range files { + if err := os.WriteFile(filepath.Join(root, name), []byte(contents), 0644); err != nil { + t.Fatal(err) + } + } + r, saved, err := TraceWithAnalysis(context.Background(), Options{Root: root, Seeds: []string{"cause"}, Depth: 8, MaxNodes: 200}) + if err != nil { + t.Fatal(err) + } + original := "" + for _, node := range r.Nodes { + if node.Kind == "error_result" && node.Evidence.Line == 3 { + original = node.ID + } + } + if original == "" { + t.Fatal("missing cause result") + } + for _, owner := range []string{"Wrap", "Joined", "Spread", "Check", "Indexed", "Dynamic", "Alias", "PublicFormat", "ConcatFormat", "Selected", "SelectedIndex", "Width", "Precision", "Reordered", "Mutated"} { + found := false + for _, edge := range r.Relationships { + if strings.HasSuffix(edge.From, "::"+owner) && (edge.Kind == "error_return" || edge.Kind == "error_compare") && edge.To == original { + found = true + } + } + if !found { + t.Errorf("%s lost wrapped cause", owner) + } + } + for _, edge := range r.Relationships { + if (strings.HasSuffix(edge.From, "::Display") || strings.HasSuffix(edge.From, "::Escaped") || strings.HasSuffix(edge.From, "::closedFormat")) && edge.Kind == "error_return" && edge.To == original { + t.Errorf("formatting-only function retained wrapped cause: %s", edge.From) + } + } + other := "" + for _, node := range r.Nodes { + if node.Kind == "error_result" && strings.Contains(node.Evidence.Snippet, `errors.New("other")`) { + other = node.ID + } + } + if other == "" { + t.Fatal("missing formatting-only source error") + } + for _, edge := range r.Relationships { + for _, owner := range []string{"Selected", "SelectedIndex", "Width", "Precision", "Reordered"} { + if strings.HasSuffix(edge.From, "::"+owner) && edge.Kind == "error_return" && edge.To == other { + t.Errorf("%s wrapped the formatting-only argument", owner) + } + } + } + resumed, err := Explore(context.Background(), saved, ExploreOptions{Seeds: []string{"Check"}, Depth: 8, MaxNodes: 200}) + if err != nil { + t.Fatal(err) + } + unknown := false + for _, boundary := range resumed.Boundaries { + if boundary.Kind == "error_wrap_format" && boundary.Evidence.Line == 11 { + unknown = true + } + } + if !unknown { + t.Fatal("saved graph hid unresolved wrapping format") + } + mutation := false + for _, boundary := range resumed.Boundaries { + if boundary.Kind == "error_wrap_format" && strings.Contains(boundary.Evidence.Snippet, "values[0] = cause()") { + mutation = true + } + } + if !mutation { + t.Fatal("mutated argument storage had no pairing boundary") + } +} diff --git a/exploration.go b/exploration.go new file mode 100644 index 0000000..e2ca9a1 --- /dev/null +++ b/exploration.go @@ -0,0 +1,357 @@ +package contracttrace + +import ( + "bufio" + "compress/gzip" + "context" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "path" + "sort" + "strings" +) + +const analysisSchema = "contract-tracer.analysis.v0.1" + +// Analysis retains the discovered graph, including scope-excluded candidates. +// It is a source-bound snapshot, not a completeness certificate. +type Analysis struct { + Schema string `json:"schema"` + Root string `json:"root"` + Invariant string `json:"invariant"` + Coverage Coverage `json:"coverage"` + Config Config `json:"config"` + Nodes []Node `json:"nodes"` + Relationships []Relationship `json:"relationships"` + Boundaries []Boundary `json:"unresolved_boundaries"` + Ranges map[string]SourceRange `json:"function_ranges"` + DeclarationRanges map[string]SourceRange `json:"declaration_ranges,omitempty"` +} +type SourceRange struct { + First int `json:"first_line"` + Last int `json:"last_line"` +} +type ExploreOptions struct { + Seeds, Locations []string + Invariant string + Focus []string + Depth, MaxNodes int + ExpandCallbacks bool +} + +func TraceWithAnalysis(ctx context.Context, o Options) (Report, Analysis, error) { + var analysis Analysis + o.capture = &analysis + r, err := Trace(ctx, o) + return r, analysis, err +} +func ReadAnalysis(reader io.Reader) (Analysis, error) { + var a Analysis + buffered := bufio.NewReader(reader) + if magic, _ := buffered.Peek(2); len(magic) == 2 && magic[0] == 0x1f && magic[1] == 0x8b { + compressed, err := gzip.NewReader(buffered) + if err != nil { + return a, fmt.Errorf("compressed saved analysis: %w", err) + } + defer compressed.Close() + reader = compressed + } else { + reader = buffered + } + decoder := json.NewDecoder(reader) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&a); err != nil { + return a, err + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + return Analysis{}, fmt.Errorf("saved analysis must contain exactly one JSON object") + } + if err := a.validate(); err != nil { + return Analysis{}, err + } + return a, nil +} + +// WriteAnalysis streams the complete snapshot as JSON, optionally in gzip. +// The caller owns the writer. Compression changes representation, not scope. +func WriteAnalysis(writer io.Writer, a Analysis, gzipCompressed bool) error { + if err := a.validate(); err != nil { + return err + } + if !gzipCompressed { + return json.NewEncoder(writer).Encode(a) + } + compressed, err := gzip.NewWriterLevel(writer, gzip.BestSpeed) + if err != nil { + return err + } + if err := json.NewEncoder(compressed).Encode(a); err != nil { + compressed.Close() + return err + } + return compressed.Close() +} +func Explore(ctx context.Context, a Analysis, o ExploreOptions) (Report, error) { + if err := ctx.Err(); err != nil { + return Report{}, err + } + if err := a.validate(); err != nil { + return Report{}, err + } + if len(a.Coverage.LoadedSources) == 0 { + return Report{}, fmt.Errorf("saved analysis lacks the selected Go source inventory required for safe exploration; rerun analysis") + } + if len(o.Seeds) == 0 && len(o.Locations) == 0 { + return Report{}, fmt.Errorf("at least one symbol or source-location seed is required") + } + if o.Depth < 1 || o.MaxNodes < 1 { + return Report{}, fmt.Errorf("depth and max-nodes must be positive") + } + root, err := canonicalRoot(a.Root) + if err != nil { + return Report{}, fmt.Errorf("saved analysis root: %w", err) + } + before, _, err := fingerprint(root) + if err != nil { + return Report{}, err + } + assets, err := hashFiles(root, a.Coverage.EmbeddedFiles) + if err != nil { + return Report{}, err + } + identity, _ := json.Marshal(struct { + Build map[string]string + Config Config + Tests bool + Tags string + }{a.Coverage.Build, a.Config, a.Coverage.Tests, a.Coverage.Tags}) + if err := verifyLoadedSources(ctx, a.Coverage.LoadedSources); err != nil { + return Report{}, err + } + if hashText(before+string(identity)+assets+a.Coverage.LoadedSourceSHA256+a.Coverage.ResolutionSHA256) != a.Coverage.SourceSHA256 { + return Report{}, fmt.Errorf("source changed since saved analysis; rerun analysis before exploring") + } + if err := verifyLoadedSources(ctx, a.Coverage.ResolutionInputs); err != nil { + return Report{}, fmt.Errorf("resolution input check: %w", err) + } + if err := verifyBuildEnvironment(ctx, root, a.Coverage.Build); err != nil { + return Report{}, err + } + if a.Coverage.ResolutionScope == "workspace_modules.v1" { + inputs, err := captureResolutionInputs(a.Coverage.Build) + if err != nil { + return Report{}, err + } + if loadedSourceIdentity(inputs) != a.Coverage.ResolutionSHA256 { + return Report{}, fmt.Errorf("workspace module inventory changed; rerun analysis") + } + } + ix := &index{root: root, funcs: map[string]*function{}, edges: a.Relationships, boundaries: a.Boundaries, ranges: a.Ranges, declarationRanges: a.DeclarationRanges} + for _, n := range a.Nodes { + n.Distance = 0 + n.ReachedBy = nil + n.Relevance = nil + ix.funcs[n.ID] = &function{node: n} + } + coverage := a.Coverage + coverage.Depth = o.Depth + coverage.MaxNodes = o.MaxNodes + coverage.Truncated = false + coverage.ExpandCallbacks = o.ExpandCallbacks + report, err := ix.expand(Options{Seeds: o.Seeds, Locations: o.Locations, Focus: o.Focus, Depth: o.Depth, MaxNodes: o.MaxNodes, ExpandCallbacks: o.ExpandCallbacks}, coverage) + if err != nil { + return Report{}, err + } + if err := ctx.Err(); err != nil { + return Report{}, err + } + report.Schema = "contract-tracer.v0.1" + report.Root = root + report.Config = a.Config + report.Invariant = o.Invariant + if report.Invariant == "" { + report.Invariant = a.Invariant + } + report.Boundaries = append(report.Boundaries, Boundary{Kind: "saved_analysis", Reason: "Explored a saved graph after checking target Go/SQL/module inputs, selected embedded bytes, current selected package source paths against the captured parser inputs, and loaded Go source hashes. Dependency analysis and unmodeled relationships were not refreshed; original analysis boundaries remain."}) + if _, captured := a.Coverage.Build["GOWORK"]; !captured { + report.Boundaries = append(report.Boundaries, Boundary{Kind: "legacy_resolution_identity", Reason: "this snapshot predates workspace and GOFLAGS capture; workspace selection and those settings are not checked, so rerun analysis for their identity"}) + } + if a.Coverage.ResolutionScope == "" { + report.Boundaries = append(report.Boundaries, Boundary{Kind: "legacy_workspace_module_identity", Reason: "this snapshot predates workspace module manifest capture; sibling module dependency changes are not checked, so rerun analysis for their identity"}) + } + if a.Coverage.ResolutionScope != "loaded_modules.v1" { + report.Boundaries = append(report.Boundaries, Boundary{Kind: "legacy_dependency_module_identity", Reason: "this snapshot predates effective dependency module manifest capture; external dependency module directives are not checked, so rerun analysis for their identity"}) + } + groupBoundaries(&report) + if err := verifyAnalysisInputs(ctx, root, a.Coverage.Build, before, a.Coverage.EmbeddedFiles, assets, a.Coverage.LoadedSources, a.Coverage.ResolutionInputs, a.Coverage.Tests, a.Coverage.Tags); err != nil { + return Report{}, err + } + return report, nil +} + +func (ix *index) snapshot(r Report) Analysis { + full := Report{Nodes: []Node{}, Relationships: canonicalEdges(ix.edges), Boundaries: append([]Boundary(nil), ix.boundaries...)} + ranges := map[string]SourceRange{} + for id, f := range ix.funcs { + node := f.node + node.Distance = 0 + node.ReachedBy = nil + node.Relevance = nil + full.Nodes = append(full.Nodes, node) + if f.decl != nil { + ranges[id] = SourceRange{ix.fset.Position(f.decl.Pos()).Line, ix.fset.Position(f.decl.End()).Line} + } + } + for _, b := range r.Boundaries { + if b.Kind == "semantic_scope" || b.Kind == "analysis_model" || b.Kind == "lifecycle" || b.Kind == "loaded_source_identity" { + full.Boundaries = append(full.Boundaries, b) + } + } + groupBoundaries(&full) + coverage := r.Coverage + coverage.Depth = 0 + coverage.MaxNodes = 0 + coverage.Truncated = false + coverage.ExpandCallbacks = false + declarations := map[string]SourceRange{} + for id, span := range ix.declarationRanges { + declarations[id] = span + } + return Analysis{analysisSchema, r.Root, r.Invariant, coverage, r.Config, full.Nodes, full.Relationships, full.Boundaries, ranges, declarations} +} + +func (a Analysis) validate() error { + if a.Schema != analysisSchema { + return fmt.Errorf("unsupported saved analysis schema %q", a.Schema) + } + if a.Root == "" || len(a.Coverage.SourceSHA256) != 64 { + return fmt.Errorf("saved analysis has no source identity") + } + if _, err := hex.DecodeString(a.Coverage.SourceSHA256); err != nil { + return fmt.Errorf("invalid saved source fingerprint") + } + if err := validateSourceInventory(a.Coverage.LoadedSources, a.Coverage.LoadedSourceSHA256); err != nil { + return err + } + if err := validateSourceInventory(a.Coverage.ResolutionInputs, a.Coverage.ResolutionSHA256); err != nil { + return fmt.Errorf("resolution inventory: %w", err) + } + if a.Coverage.ResolutionScope != "" && a.Coverage.ResolutionScope != "workspace_modules.v1" && a.Coverage.ResolutionScope != "loaded_modules.v1" { + return fmt.Errorf("unsupported saved resolution scope") + } + if work, captured := a.Coverage.Build["GOWORK"]; captured && work != "" && work != "off" { + found := false + for _, input := range a.Coverage.ResolutionInputs { + found = found || input.Path == strings.ReplaceAll(work, "\\", "/") + } + if !found { + return fmt.Errorf("saved workspace has no matching resolution input") + } + } + for _, file := range a.Coverage.EmbeddedFiles { + if file == "" || file == ".." || strings.HasPrefix(file, "../") || strings.HasPrefix(file, "/") || strings.ContainsAny(file, "\\:") || path.Clean(file) != file { + return fmt.Errorf("saved embedded asset is not a relative target file: %q", file) + } + } + if err := validateConfig(a.Config); err != nil { + return err + } + nodes := map[string]Node{} + for _, n := range a.Nodes { + if n.Relevance != nil { + return fmt.Errorf("saved analysis contains query-specific contract relevance") + } + if n.ID == "" { + return fmt.Errorf("saved analysis contains an empty node ID") + } + if _, present := nodes[n.ID]; present { + return fmt.Errorf("saved analysis contains duplicate node %q", n.ID) + } + nodes[n.ID] = n + } + for _, e := range a.Relationships { + if _, ok := nodes[e.From]; !ok { + return fmt.Errorf("saved analysis edge has missing source %q", e.From) + } + if _, ok := nodes[e.To]; !ok { + return fmt.Errorf("saved analysis edge has missing target %q", e.To) + } + } + for id, span := range a.Ranges { + node, ok := nodes[id] + if !ok || node.Kind != "function" || span.First < 1 || span.Last < span.First || node.Evidence.Line != span.First { + return fmt.Errorf("invalid saved function range for %q", id) + } + } + for id, span := range a.DeclarationRanges { + node, ok := nodes[id] + if !ok || !declarationKind(node.Kind) || span.First < 1 || span.Last < span.First || node.Evidence.Line != span.First { + return fmt.Errorf("invalid saved declaration range for %q", id) + } + } + for _, b := range a.Boundaries { + if b.Node != "" { + if _, present := nodes[b.Node]; !present { + return fmt.Errorf("saved boundary has missing node %q", b.Node) + } + } + if b.CandidateCount < 0 || b.CandidateCount > 0 && b.CandidateCount < len(b.Examples) { + return fmt.Errorf("invalid saved boundary candidate count") + } + if len(b.Candidates) > 0 { + if len(b.Candidates) != b.CandidateCount { + return fmt.Errorf("saved boundary candidate inventory does not match count") + } + for i, target := range b.Candidates { + if target == "" || i > 0 && b.Candidates[i-1] >= target { + return fmt.Errorf("saved boundary candidates must be nonempty, distinct and sorted") + } + } + for _, example := range b.Examples { + at := sort.SearchStrings(b.Candidates, example) + if at == len(b.Candidates) || b.Candidates[at] != example { + return fmt.Errorf("saved boundary example is absent from candidate inventory") + } + } + } + } + return nil +} + +// Several SSA instances can project onto one named source function. Retain +// their summary union instead of whichever duplicate relationship arrived first. +func canonicalEdges(input []Relationship) []Relationship { + edges := map[string]Relationship{} + values := map[string]map[string]bool{} + for _, e := range input { + key := edgeKey(e) + if _, ok := edges[key]; !ok { + edges[key] = e + } + if len(e.Values) > 0 { + if values[key] == nil { + values[key] = map[string]bool{} + } + for _, v := range e.Values { + values[key][v] = true + } + } + } + keys := make([]string, 0, len(edges)) + for key := range edges { + keys = append(keys, key) + } + sort.Strings(keys) + output := make([]Relationship, 0, len(edges)) + for _, key := range keys { + e := edges[key] + if len(values[key]) > 0 { + e.Values = sortedKeys(values[key]) + } + output = append(output, e) + } + return output +} diff --git a/exploration_test.go b/exploration_test.go new file mode 100644 index 0000000..895a891 --- /dev/null +++ b/exploration_test.go @@ -0,0 +1,140 @@ +package contracttrace + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestSavedAnalysisExpandsBeyondFirstScope(t *testing.T) { + options := Options{Root: "testdata/sample", Seeds: []string{"Validate"}, Depth: 1, MaxNodes: 2} + first, analysis, err := TraceWithAnalysis(context.Background(), options) + if err != nil { + t.Fatal(err) + } + if !first.Coverage.Truncated { + t.Fatal("fixture must start with a truncated scope") + } + encoded, err := json.Marshal(analysis) + if err != nil { + t.Fatal(err) + } + saved, err := ReadAnalysis(strings.NewReader(string(encoded))) + if err != nil { + t.Fatal(err) + } + expanded, err := Explore(context.Background(), saved, ExploreOptions{Seeds: []string{"WrappedWrite"}, Depth: 4, MaxNodes: 200}) + if err != nil { + t.Fatal(err) + } + if !hasName(expanded, "AuditRead") || expanded.Coverage.SourceSHA256 != first.Coverage.SourceSHA256 || expanded.ContractComplete { + t.Fatal("saved graph did not preserve source identity and reveal previously excluded storage paths") + } + for _, boundary := range append(append([]Boundary(nil), saved.Boundaries...), expanded.Boundaries...) { + if boundary.CandidateCount > 0 && len(boundary.Candidates) != boundary.CandidateCount { + t.Fatalf("fresh saved/explored candidate inventory incomplete: %+v", boundary) + } + } + located, err := Explore(context.Background(), saved, ExploreOptions{Locations: []string{"sample.go:8"}, Depth: 2, MaxNodes: 100}) + if err != nil { + t.Fatal(err) + } + if !hasName(located, "Validate") { + t.Fatal("saved graph lost function-containing source locations") + } + // Caller-owned seed slices must not be mutated by exploration. + if len(first.Nodes) != 2 { + t.Fatal("exploration changed original scope") + } +} + +func TestSavedAnalysisRejectsChangedSource(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example.com/snapshot\n\ngo 1.27.0\n"), 0600); err != nil { + t.Fatal(err) + } + source := filepath.Join(root, "main.go") + if err := os.WriteFile(source, []byte("package snapshot\nfunc Seed() {}\n"), 0600); err != nil { + t.Fatal(err) + } + _, analysis, err := TraceWithAnalysis(context.Background(), Options{Root: root, Seeds: []string{"Seed"}, Depth: 1, MaxNodes: 10}) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(source, []byte("package snapshot\nfunc Seed() { panic(1) }\n"), 0600); err != nil { + t.Fatal(err) + } + if _, err := Explore(context.Background(), analysis, ExploreOptions{Seeds: []string{"Seed"}, Depth: 1, MaxNodes: 10}); err == nil || !strings.Contains(err.Error(), "changed") { + t.Fatalf("stale snapshot accepted: %v", err) + } +} + +func TestSavedAnalysisPreservesGroupedBoundaryCounts(t *testing.T) { + _, analysis, err := TraceWithAnalysis(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"Validate"}, Depth: 1, MaxNodes: 10}) + if err != nil { + t.Fatal(err) + } + analysis.Boundaries = append(analysis.Boundaries, Boundary{Node: "example.com/sample::Validate", Kind: "external_call", Reason: "summary from original analysis", CandidateCount: 17, Examples: []string{"external.a", "external.b", "external.c", "external.d", "external.e"}}) + report, err := Explore(context.Background(), analysis, ExploreOptions{Seeds: []string{"Validate"}, Depth: 1, MaxNodes: 10}) + if err != nil { + t.Fatal(err) + } + found := false + for _, b := range report.Boundaries { + if b.Reason == "summary from original analysis" { + found = true + if b.CandidateCount != 17 || len(b.Candidates) != 0 { + t.Fatalf("grouped count shrank from 17 to %d", b.CandidateCount) + } + } + } + if !found { + t.Fatal("original analysis boundary disappeared") + } +} + +func TestSavedAnalysisRejectsMalformedGraph(t *testing.T) { + _, original, err := TraceWithAnalysis(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"Validate"}, Depth: 1, MaxNodes: 10}) + if err != nil { + t.Fatal(err) + } + encoded, _ := json.Marshal(original) + cases := map[string]func(*Analysis){ + "duplicate node": func(a *Analysis) { a.Nodes = append(a.Nodes, a.Nodes[0]) }, + "query-specific relevance": func(a *Analysis) { a.Nodes[0].Relevance = &Relevance{Anchor: "fake", Distance: 0} }, + "missing edge target": func(a *Analysis) { a.Relationships[0].To = "missing" }, + "missing boundary owner": func(a *Analysis) { + a.Boundaries = append(a.Boundaries, Boundary{Node: "missing", Kind: "external_call"}) + }, + "negative candidate count": func(a *Analysis) { a.Boundaries = append(a.Boundaries, Boundary{CandidateCount: -1}) }, + "outside embedded asset": func(a *Analysis) { a.Coverage.EmbeddedFiles = []string{"../outside.txt"} }, + "partial candidate inventory": func(a *Analysis) { + a.Boundaries = append(a.Boundaries, Boundary{CandidateCount: 2, Candidates: []string{"a"}}) + }, + "duplicate candidates": func(a *Analysis) { + a.Boundaries = append(a.Boundaries, Boundary{CandidateCount: 2, Candidates: []string{"a", "a"}}) + }, + "unsorted candidates": func(a *Analysis) { + a.Boundaries = append(a.Boundaries, Boundary{CandidateCount: 2, Candidates: []string{"b", "a"}}) + }, + "missing example target": func(a *Analysis) { + a.Boundaries = append(a.Boundaries, Boundary{CandidateCount: 1, Candidates: []string{"a"}, Examples: []string{"b"}}) + }, + } + for name, change := range cases { + t.Run(name, func(t *testing.T) { + var a Analysis + if err := json.Unmarshal(encoded, &a); err != nil { + t.Fatal(err) + } + change(&a) + data, _ := json.Marshal(a) + if _, err := ReadAnalysis(strings.NewReader(string(data))); err == nil { + t.Fatal("malformed graph accepted") + } + }) + } +} diff --git a/flow.go b/flow.go new file mode 100644 index 0000000..6496dae --- /dev/null +++ b/flow.go @@ -0,0 +1,1412 @@ +package contracttrace + +import ( + "context" + "fmt" + "go/constant" + "go/token" + "go/types" + "sort" + "strings" + + "golang.org/x/tools/go/callgraph" + "golang.org/x/tools/go/packages" + "golang.org/x/tools/go/ssa" + "golang.org/x/tools/go/ssa/ssautil" +) + +// A bounded, monotone, context-insensitive value-flow model. Results are +// candidates: unsupported memory/dispatch and external callers remain boundaries. +const maxFlowValues = 64 + +type flowValue struct { + strings map[string]bool + functions map[*ssa.Function]bool + boundReceivers map[*ssa.Function]boundReceiverCandidates + boundReceiverUnknown bool + addresses map[string]bool + effects map[string]bool + errors map[string]bool + scalars map[string]bool + scalarUnknown bool + sqlUnknown bool + functionUnknown bool + functionNil bool + zeroAggregates map[types.Type]bool + zeroAggregateUnknown bool + mapNil bool + interfaceUnknown bool + stringUnknown bool + addressAlternatives map[string]typedAddressAlternative + addressAltUnknown bool +} + +type typedAddressAlternative struct { + typ types.Type + zero bool + unknown bool +} + +type boundReceiverCandidates struct { + addresses map[string]bool + typ types.Type + unknown bool +} + +func emptyFlow() flowValue { + return flowValue{strings: map[string]bool{}, functions: map[*ssa.Function]bool{}, addresses: map[string]bool{}, effects: map[string]bool{}} +} + +func captureBoundMethodReceiver(result *flowValue, target *ssa.Function, receiver flowValue, receiverType types.Type, enabled bool) { + if !enabled || target == nil || result == nil { + return + } + method, ok := target.Object().(*types.Func) + if !ok { + return + } + signature, ok := method.Type().(*types.Signature) + if !ok || signature.Recv() == nil { + return + } + result.boundReceivers = map[*ssa.Function]boundReceiverCandidates{ + target: {addresses: snapshotCandidates(receiver.addresses), typ: receiverType, unknown: receiver.sqlUnknown || receiver.boundReceiverUnknown || receiver.interfaceUnknown}, + } +} + +func isBoundMethodTarget(target *ssa.Function) bool { + if target == nil { + return false + } + method, ok := target.Object().(*types.Func) + if !ok { + return false + } + signature, ok := method.Type().(*types.Signature) + return ok && signature.Recv() != nil +} + +type flowAnalysis struct { + values map[ssa.Value]flowValue + memory map[string]flowValue + addressTypes map[string]types.Type + allocations map[string]*ssa.Alloc + zeroFunctionFields map[string]bool + zeroFunctionFieldCache map[string]uint8 + zeroFunctionElements map[string]bool + knownFunctionElements map[string]bool + zeroFunctionArrayElements map[string]bool + unknownFunctionElements map[string]bool + zeroFunctionArrayCache map[string]uint8 + globalFunctionStates map[string]globalFunctionState + channelClosures *channelClosureIndex + aliasVersions map[string]uint64 + recordCache map[ssa.Value]recordAddressCache + mapEntries map[string]map[string]bool + mapClosed map[string]bool + mapOpen map[string]bool + returns map[*ssa.Function][]flowValue + positions map[token.Pos]flowValue + callTargets map[token.Pos]flowValue + coverage FlowCoverage + special map[*ssa.Call][]flowValue + contexts map[ssa.CallInstruction]contextSite + contextKeys map[string]contextSite + doneChannels map[string]string + invokes map[*ssa.CallCommon]map[*ssa.Function]bool + sqlHandles map[string]sqlHandle + statementExecutions map[token.Pos]map[string]flowValue + sqlOperations map[token.Pos]map[string]bool + sqlReceivers map[token.Pos]flowValue + sqlCalls map[token.Pos]map[string]sqlSemanticCall + callbackEscapes map[ssa.CallInstruction]map[*ssa.Function]bool + callbackReturns map[*ssa.Return]map[*ssa.Function]bool + publicGlobals []*ssa.Global + localGlobals []*ssa.Global + globalAddressOwners map[string]map[string]bool + globalCallbacks map[*ssa.Global]map[*ssa.Function]bool + globalSQLFields map[*ssa.Global]bool + iterators map[string]iteratorSummary + inputRoots map[string]bool + interfaceAssertions map[ssa.Value]bool + memoryVersions map[string]uint64 + callbackCache map[ssa.Value]*callbackStorageCache + afterFuncs map[string]afterFuncSite + afterFuncSchedulers map[string]*ssa.Function + afterFuncTypeCache map[types.Type]string + scalarControls map[*ssa.BasicBlock][]*ssa.If + scalarControlCache map[*ssa.BasicBlock]flowValue + lifecycleMatched map[string]bool + lifecycleTypes []types.Type +} + +func (a *flowAnalysis) addGlobalFunctionState(result *flowValue, address string, ix *index) { + if result == nil || a == nil || !strings.HasPrefix(address, "global:") { + return + } + if a.globalFunctionStates == nil { + a.globalFunctionStates = map[string]globalFunctionState{} + } + state, cached := a.globalFunctionStates[address] + if !cached { + state = a.globalFunctionStateAtAddress(address, ix) + a.globalFunctionStates[address] = state + } + switch state { + case globalFunctionZero: + result.functionNil = true + case globalFunctionUnknown: + result.functionUnknown = true + } +} + +func aggregateSequenceStorageType(sequenceType types.Type, address string, addressTypes map[string]types.Type) types.Type { + if sequenceType == nil { + return nil + } + sequenceType = types.Unalias(sequenceType) + if pointer, ok := sequenceType.Underlying().(*types.Pointer); ok { + sequenceType = types.Unalias(pointer.Elem()) + } + if _, ok := sequenceType.Underlying().(*types.Array); ok { + return sequenceType + } + if _, ok := sequenceType.Underlying().(*types.Slice); !ok { + return nil + } + addressType := addressTypes[address] + if addressType == nil { + return nil + } + storage := types.Unalias(addressType) + pointer, ok := storage.Underlying().(*types.Pointer) + if !ok { + return nil + } + if _, ok := types.Unalias(pointer.Elem()).Underlying().(*types.Array); !ok { + return nil + } + return pointer.Elem() +} + +func aggregateSelectedElement(a *flowAnalysis, value flowValue, sequenceType types.Type, address string) flowValue { + result := emptyFlow() + if a == nil { + return result + } + storageType := aggregateSequenceStorageType(sequenceType, address, a.addressTypes) + if storageType == nil { + return result + } + a.merge(&result, a.zeroAggregateIndex(value, storageType)) + return result +} + +func (a *flowAnalysis) merge(dst *flowValue, src flowValue) bool { + changed := false + if dst.strings == nil { + unknown := dst.sqlUnknown + unknownFunction := dst.functionUnknown + nilFunction := dst.functionNil + unknownInterface := dst.interfaceUnknown + nilMap := dst.mapNil + unknownString := dst.stringUnknown + errorOrigins := dst.errors + scalarOrigins, unknownScalar := dst.scalars, dst.scalarUnknown + boundReceivers, unknownBoundReceiver := dst.boundReceivers, dst.boundReceiverUnknown + zeroAggregates, unknownZeroAggregate := dst.zeroAggregates, dst.zeroAggregateUnknown + addressAlternatives, unknownAddressAlternative := dst.addressAlternatives, dst.addressAltUnknown + *dst = emptyFlow() + dst.sqlUnknown = unknown + dst.functionUnknown = unknownFunction + dst.functionNil = nilFunction + dst.interfaceUnknown = unknownInterface + dst.mapNil = nilMap + dst.stringUnknown = unknownString + dst.errors = errorOrigins + dst.scalars, dst.scalarUnknown = scalarOrigins, unknownScalar + dst.boundReceivers, dst.boundReceiverUnknown = boundReceivers, unknownBoundReceiver + dst.zeroAggregates, dst.zeroAggregateUnknown = zeroAggregates, unknownZeroAggregate + dst.addressAlternatives, dst.addressAltUnknown = addressAlternatives, unknownAddressAlternative + } + if src.scalarUnknown && !dst.scalarUnknown { + dst.scalarUnknown = true + changed = true + } + for _, origin := range mergeKeys(src.scalars, dst.scalars) { + if dst.scalars[origin] { + continue + } + if len(dst.scalars) >= maxFlowValues { + a.coverage.Widened = true + if !dst.scalarUnknown { + dst.scalarUnknown = true + changed = true + } + continue + } + if dst.scalars == nil { + dst.scalars = map[string]bool{} + } + dst.scalars[origin] = true + changed = true + } + if src.sqlUnknown && !dst.sqlUnknown { + dst.sqlUnknown = true + changed = true + } + if src.functionUnknown && !dst.functionUnknown { + dst.functionUnknown = true + changed = true + } + if src.functionNil && !dst.functionNil { + dst.functionNil = true + changed = true + } + if src.zeroAggregateUnknown && !dst.zeroAggregateUnknown { + dst.zeroAggregateUnknown = true + changed = true + } + if src.mapNil && !dst.mapNil { + dst.mapNil = true + changed = true + } + if src.addressAltUnknown && !dst.addressAltUnknown { + dst.addressAltUnknown = true + changed = true + } + for _, address := range sortedKeys(src.addressAlternatives) { + incoming := src.addressAlternatives[address] + changed = a.addTypedAddressAlternative(dst, address, incoming.typ, incoming.zero, incoming.unknown) || changed + } + zeroTypes := make([]types.Type, 0, len(src.zeroAggregates)) + for typ := range src.zeroAggregates { + zeroTypes = append(zeroTypes, typ) + } + sort.Slice(zeroTypes, func(i, j int) bool { return types.TypeString(zeroTypes[i], nil) < types.TypeString(zeroTypes[j], nil) }) + for _, typ := range zeroTypes { + if dst.zeroAggregates[typ] { + continue + } + if len(dst.zeroAggregates) >= maxFlowValues { + a.coverage.Widened = true + if !dst.zeroAggregateUnknown { + dst.zeroAggregateUnknown = true + changed = true + } + continue + } + if dst.zeroAggregates == nil { + dst.zeroAggregates = map[types.Type]bool{} + } + dst.zeroAggregates[typ] = true + changed = true + } + if src.interfaceUnknown && !dst.interfaceUnknown { + dst.interfaceUnknown = true + changed = true + } + if src.stringUnknown && !dst.stringUnknown { + dst.stringUnknown = true + changed = true + } + for _, origin := range mergeKeys(src.errors, dst.errors) { + if !dst.errors[origin] { + if len(dst.errors) >= maxFlowValues { + a.coverage.Widened = true + continue + } + if dst.errors == nil { + dst.errors = map[string]bool{} + } + dst.errors[origin] = true + changed = true + } + } + for _, s := range mergeKeys(src.strings, dst.strings) { + if !dst.strings[s] { + if len(dst.strings) >= maxFlowValues { + a.coverage.Widened = true + if !dst.stringUnknown { + dst.stringUnknown = true + changed = true + } + continue + } + dst.strings[s] = true + changed = true + } + } + for _, f := range mergeFunctions(src.functions, dst.functions) { + if !dst.functions[f] { + if len(dst.functions) >= maxFlowValues { + a.coverage.Widened = true + continue + } + dst.functions[f] = true + changed = true + } + } + if len(src.boundReceivers) > 0 || src.boundReceiverUnknown { + boundFunctionSet := make(map[*ssa.Function]bool, len(src.boundReceivers)) + for fn := range src.boundReceivers { + boundFunctionSet[fn] = true + } + boundFunctions := sortedFunctions(boundFunctionSet) + if src.boundReceiverUnknown && !dst.boundReceiverUnknown { + dst.boundReceiverUnknown = true + changed = true + } + for _, fn := range boundFunctions { + receivers := src.boundReceivers[fn] + current, exists := dst.boundReceivers[fn] + if !exists && len(dst.boundReceivers) >= maxFlowValues { + a.coverage.Widened = true + if !dst.boundReceiverUnknown { + dst.boundReceiverUnknown = true + changed = true + } + continue + } + localChanged := false + if current.typ == nil && receivers.typ != nil { + current.typ = receivers.typ + localChanged = true + } else if current.typ != nil && receivers.typ != nil && !types.Identical(current.typ, receivers.typ) && !current.unknown { + current.unknown = true + localChanged = true + } + if current.addresses == nil { + current.addresses = map[string]bool{} + } + for _, address := range sortedKeys(receivers.addresses) { + if current.addresses[address] { + continue + } + if len(current.addresses) >= maxFlowValues { + a.coverage.Widened = true + if !current.unknown { + current.unknown = true + localChanged = true + } + continue + } + current.addresses[address] = true + localChanged = true + } + if receivers.unknown && !current.unknown { + current.unknown = true + localChanged = true + } + if dst.boundReceivers == nil { + dst.boundReceivers = map[*ssa.Function]boundReceiverCandidates{} + } + if !exists || localChanged { + dst.boundReceivers[fn] = current + changed = changed || !exists || localChanged + } + } + } + for _, p := range mergeKeys(src.addresses, dst.addresses) { + if !dst.addresses[p] { + if len(dst.addresses) >= maxFlowValues { + a.coverage.Widened = true + continue + } + dst.addresses[p] = true + changed = true + } + } + for _, effect := range mergeKeys(src.effects, dst.effects) { + if !dst.effects[effect] { + if len(dst.effects) >= maxFlowValues { + a.coverage.Widened = true + continue + } + dst.effects[effect] = true + changed = true + } + } + return changed +} + +func (a *flowAnalysis) get(v ssa.Value) flowValue { + if v != nil { + switch v.(type) { + case *ssa.Const, *ssa.Function, *ssa.Global: + // These values add intrinsic candidates before bounded merging. + default: + stored := a.values[v] + if len(stored.strings) <= maxFlowValues && len(stored.functions) <= maxFlowValues && len(stored.addresses) <= maxFlowValues && len(stored.boundReceivers) <= maxFlowValues && len(stored.effects) <= maxFlowValues && len(stored.errors) <= maxFlowValues && len(stored.scalars) <= maxFlowValues && len(stored.zeroAggregates) <= maxFlowValues && len(stored.addressAlternatives) <= maxFlowValues { + return snapshotFlow(stored) + } + } + } + result := emptyFlow() + if v == nil { + return result + } + switch x := v.(type) { + case *ssa.Const: + if x.IsNil() && x.Type() != nil { + if _, ok := types.Unalias(x.Type()).Underlying().(*types.Map); ok { + result.mapNil = true + } + } + if x.IsNil() && isSQLHandleType(x.Type()) { + result.sqlUnknown = true + } + if x.IsNil() && isFunctionType(x.Type()) { + result.functionNil = true + } + if x.Value != nil && x.Value.Kind() == constant.String { + result.strings[constant.StringVal(x.Value)] = true + } + case *ssa.Function: + result.functions[x] = true + case *ssa.Global: + result.addresses["global:"+x.String()] = true + } + a.merge(&result, a.values[v]) + return result +} +func (a *flowAnalysis) put(v ssa.Value, f flowValue) bool { + old := a.values[v] + changed := a.merge(&old, f) + a.values[v] = old + return changed +} +func (ix *index) registerInitializers(prog *ssa.Program, pkgs []*packages.Package) { + ix.ssaOwners = map[*ssa.Function]string{} + for _, p := range pkgs { + sp := prog.Package(p.Types) + if sp == nil || len(p.Syntax) == 0 { + continue + } + fn, ok := sp.Members["init"].(*ssa.Function) + if !ok { + continue + } + evidence := ix.evidence(p.Syntax[0].Pos()) + id := p.PkgPath + "::(package init)" + ix.ssaOwners[fn] = id + ix.funcs[id] = &function{node: Node{ID: id, Name: "(package init)", Kind: "initializer", Evidence: evidence}, pkg: p} + } +} +func (ix *index) analyzeFlow(ctx context.Context, prog *ssa.Program, graph *callgraph.Graph, config Config) (*flowAnalysis, error) { + a := &flowAnalysis{values: map[ssa.Value]flowValue{}, memory: map[string]flowValue{}, addressTypes: map[string]types.Type{}, returns: map[*ssa.Function][]flowValue{}, positions: map[token.Pos]flowValue{}, mapOpen: map[string]bool{}, coverage: FlowCoverage{MaxValues: maxFlowValues}} + if len(config.CallRules) > 0 { + a.callTargets = map[token.Pos]flowValue{} + } + a.invokes = map[*ssa.CallCommon]map[*ssa.Function]bool{} + for fn, node := range graph.Nodes { + if ix.owner(fn) == "" { + continue + } + for _, edge := range node.Out { + if edge.Site == nil || !edge.Site.Common().IsInvoke() { + continue + } + common := edge.Site.Common() + if a.invokes[common] == nil { + a.invokes[common] = map[*ssa.Function]bool{} + } + a.invokes[common][edge.Callee.Func] = true + } + } + for _, p := range prog.AllPackages() { + for _, member := range p.Members { + if global, ok := member.(*ssa.Global); ok { + a.addressTypes["global:"+global.String()] = global.Type() + if variable, ok := global.Object().(*types.Var); ok { + if _, inside := relative(ix.root, ix.fset.Position(global.Pos()).Filename); inside { + a.localGlobals = append(a.localGlobals, global) + } + if _, inside := relative(ix.root, ix.fset.Position(global.Pos()).Filename); inside && variable.Exported() { + a.publicGlobals = append(a.publicGlobals, global) + if sqlHandleGlobal(global) { + unknown := emptyFlow() + unknown.sqlUnknown = true + a.store("global:"+global.String(), unknown) + } + } + if text, present := ix.embedded[variable]; present { + value := emptyFlow() + value.strings[text] = true + a.memory["global:"+global.String()] = value + } + } + } + } + } + sort.Slice(a.publicGlobals, func(i, j int) bool { return a.publicGlobals[i].String() < a.publicGlobals[j].String() }) + funcs := []*ssa.Function{} + for fn := range ssautil.AllFunctions(prog) { + a.collectLifecycleTypes(fn, config) + if ix.owner(fn) != "" && len(fn.Blocks) > 0 { + funcs = append(funcs, fn) + } + } + sort.Slice(funcs, func(i, j int) bool { return funcs[i].String() < funcs[j].String() }) + a.channelClosures = newChannelClosureIndex(funcs) + a.seedErrorResults(funcs, ix) + a.seedScalarParameters(funcs, ix) + a.seedScalarBuiltinResults(funcs, ix) + a.seedScalarControl(funcs, ix) + a.afterFuncSchedulers = map[string]*ssa.Function{} + for _, fn := range funcs { + if cancellationSchedulerSignature(fn) { + id := ix.owner(fn) + if previous := a.afterFuncSchedulers[id]; previous == nil || previous.Synthetic != "" && fn.Synthetic == "" { + a.afterFuncSchedulers[id] = fn + } + } + } + a.seedSQLInputs(funcs, ix, false) + a.seedFunctionInputs(funcs, ix, false) + a.seedErrorInputs(funcs, ix, false) + for _, function := range funcs { + if function.Object() != nil && function.Object().Exported() { + for _, parameter := range function.Params { + a.seedAggregateInput(parameter, ix) + } + } + } + uncalledInputsSeeded := false + for round := 0; round < 100; round++ { + // Control predicates can acquire origins during this round. Recompute + // summaries next round, while sharing them across instructions here. + a.scalarControlCache = map[*ssa.BasicBlock]flowValue{} + if err := ctx.Err(); err != nil { + return nil, err + } + changed := false + for _, fn := range funcs { + for _, block := range fn.Blocks { + for _, instruction := range block.Instrs { + result := emptyFlow() + switch v := instruction.(type) { + case *ssa.MakeMap: + address := ix.allocationID("map", fn, v) + result.addresses[address] = true + a.addressTypes[address] = v.Type() + if a.mapClosed == nil { + a.mapClosed = map[string]bool{} + } + a.mapClosed[address] = true + case *ssa.MakeSlice: + address := ix.allocationID("slice", fn, v) + result.addresses[address] = true + a.addressTypes[address] = v.Type() + if slice, ok := types.Unalias(v.Type()).Underlying().(*types.Slice); ok && isFunctionType(slice.Elem()) { + elementAddress := address + ".element" + state := makeSliceFunctionElements(fn, v, ix) + cacheSequenceElement(a, elementAddress, state) + if state == functionElementUnsupported { + if a.unknownFunctionElements == nil { + a.unknownFunctionElements = map[string]bool{} + } + a.unknownFunctionElements[elementAddress] = true + } + } else if slice, ok := types.Unalias(v.Type()).Underlying().(*types.Slice); ok && inlineAggregate(slice.Elem()) { + state := makeSliceFunctionElements(fn, v, ix) + switch state { + case functionElementMayBeZero: + a.addZeroAggregate(&result, slice.Elem()) + case functionElementUnsupported: + a.markZeroAggregateUnknown(&result) + } + } + case *ssa.MakeChan: + result.addresses[ix.channelID(v.Pos())] = true + case *ssa.MapUpdate: + stored := a.get(v.Value) + if isScalarType(v.Value.Type()) { + a.mergeScalar(&stored, a.scalarControlValue(block)) + } + for _, p := range sortedKeys(a.get(v.Map).addresses) { + if a.mapUpdateOpens(fn, v, ix) && a.openMap(p) { + changed = true + } + if a.store(p+".keys", a.get(v.Key)) { + changed = true + } + for _, key := range a.mapKeys(v.Key) { + if a.storeMap(p, key, stored) { + changed = true + } + } + } + case *ssa.Lookup: + result = a.mapLookup(v.X, v.Index) + case *ssa.Range: + if _, ok := v.X.Type().Underlying().(*types.Map); ok { + a.merge(&result, a.get(v.X)) + } + case *ssa.Send: + if a.send(v.Chan, v.X, block) { + changed = true + } + case *ssa.Select: + for _, state := range v.States { + if state.Dir == types.SendOnly && a.send(state.Chan, state.Send, block) { + changed = true + } + } + case *ssa.Alloc: + address := ix.allocationID("alloc", fn, v) + result.addresses[address] = true + a.addressTypes[address] = v.Type() + if a.allocations == nil { + a.allocations = map[string]*ssa.Alloc{} + } + a.allocations[address] = v + if pointer, ok := v.Type().Underlying().(*types.Pointer); ok && inlineAggregate(pointer.Elem()) { + if owner := ix.funcs[ix.owner(fn)]; owner != nil && owner.pkg != nil { + initializer, found := allocationInitializer(fn, v, pointer.Elem(), owner.pkg, ix) + if found && initializer.zero { + a.addZeroAggregate(&result, pointer.Elem()) + } else if found && initializer.literal != nil { + if array, ok := types.Unalias(pointer.Elem()).Underlying().(*types.Array); ok && inlineAggregate(array.Elem()) && arrayLiteralHasOmittedElements(initializer.literal, array, owner.pkg) { + a.addZeroAggregate(&result, pointer.Elem()) + } + } else if state, recognized := sliceBackingAllocationState(fn, v, owner.pkg); recognized && state == functionElementMayBeZero { + a.addZeroAggregate(&result, pointer.Elem()) + } + } + } + if pointer, ok := v.Type().Underlying().(*types.Pointer); ok { + if array, ok := pointer.Elem().Underlying().(*types.Array); ok && isFunctionType(array.Elem()) { + elementAddress := address + ".element" + state := a.zeroFunctionArrayAtAddress(address, ix) + if state == functionElementUnsupported { + if a.unknownFunctionElements == nil { + a.unknownFunctionElements = map[string]bool{} + } + a.unknownFunctionElements[elementAddress] = true + } else { + cacheSequenceElement(a, elementAddress, state) + if state == functionElementMayBeZero { + if a.zeroFunctionArrayElements == nil { + a.zeroFunctionArrayElements = map[string]bool{} + } + a.zeroFunctionArrayElements[elementAddress] = true + } + } + } + } + case *ssa.Phi: + for _, edge := range v.Edges { + a.merge(&result, a.get(edge)) + } + case *ssa.ChangeType: + a.merge(&result, a.get(v.X)) + a.merge(&result, a.retypeZeroAggregate(a.get(v.X), v.X.Type(), v.Type())) + retypeAddressAlternatives(&result, v.X.Type(), v.Type()) + case *ssa.Convert: + a.merge(&result, a.get(v.X)) + a.merge(&result, a.retypeZeroAggregate(a.get(v.X), v.X.Type(), v.Type())) + retypeAddressAlternatives(&result, v.X.Type(), v.Type()) + case *ssa.ChangeInterface: + a.merge(&result, a.get(v.X)) + case *ssa.MakeInterface: + a.merge(&result, a.get(v.X)) + a.modelWaitGroupInterface(v.X, &result) + a.modelAfterFuncInterface(v.X, &result) + case *ssa.TypeAssert: + a.merge(&result, a.get(v.X)) + if !v.CommaOk && a.get(v.X).interfaceUnknown && a.seedInterfaceAssertion(v, v, ix) { + changed = true + } + case *ssa.FieldAddr: + parentType := types.Unalias(v.X.Type()) + if pointer, ok := parentType.Underlying().(*types.Pointer); ok { + parentType = types.Unalias(pointer.Elem()) + } + var childType types.Type + if structure, ok := types.Unalias(parentType).Underlying().(*types.Struct); ok && v.Field >= 0 && v.Field < structure.NumFields() { + childType = structure.Field(v.Field).Type() + } + legacyChildProjection := isFunctionType(childType) || inlineAggregate(childType) + addressPayload := carriesTypedAddressAlternative(childType) + parentValue := a.get(v.X) + if inlineAggregate(parentType) && legacyChildProjection { + a.merge(&result, a.zeroAggregateField(parentValue, parentType, v.Field)) + } + for _, p := range a.recordAddresses(v.X) { + address := fmt.Sprintf("%s.field:%d", p, v.Field) + if inlineAggregate(parentType) { + for _, source := range []flowValue{parentValue, a.memory[p]} { + if legacyChildProjection { + a.merge(&result, a.zeroAggregateField(source, parentType, v.Field)) + } + if addressPayload { + if pointer, ok := types.Unalias(v.Type()).Underlying().(*types.Pointer); ok { + a.addTypedAddressAlternative(&result, address, pointer.Elem(), exactZeroAggregateMatches(source, parentType), source.zeroAggregateUnknown) + } + } + } + } + result.addresses[address] = true + if pointer, ok := v.Type().Underlying().(*types.Pointer); ok && isFunctionType(pointer.Elem()) && a.zeroFunctionFieldAtAddress(address, ix) { + if a.zeroFunctionFields == nil { + a.zeroFunctionFields = map[string]bool{} + } + a.zeroFunctionFields[address] = true + } + if pointer, ok := v.Type().Underlying().(*types.Pointer); ok && isFunctionType(pointer.Elem()) { + a.addGlobalFunctionState(&result, address, ix) + } + } + case *ssa.IndexAddr: + _, parentArray := types.Unalias(v.X.Type()).Underlying().(*types.Array) + pointerArray := aggregateSequenceStorageType(v.X.Type(), "", nil) != nil && !parentArray + var childType types.Type + if pointer, ok := types.Unalias(v.Type()).Underlying().(*types.Pointer); ok { + childType = pointer.Elem() + } + legacyChildProjection := isFunctionType(childType) || inlineAggregate(childType) + addressPayload := carriesTypedAddressAlternative(childType) + sequenceValue := a.get(v.X) + for _, p := range a.recordAddresses(v.X) { + address := p + ".element" + result.addresses[address] = true + if pointerArray { + for _, source := range []flowValue{sequenceValue, a.memory[p]} { + projected := aggregateSelectedElement(a, source, v.X.Type(), p) + if legacyChildProjection { + a.merge(&result, projected) + } + storageType := aggregateSequenceStorageType(v.X.Type(), p, a.addressTypes) + if addressPayload && storageType != nil { + if pointer, ok := types.Unalias(v.Type()).Underlying().(*types.Pointer); ok { + a.addTypedAddressAlternative(&result, address, pointer.Elem(), exactZeroAggregateMatches(source, storageType), source.zeroAggregateUnknown) + } + } + } + } + if parentArray { + if pointer, ok := types.Unalias(v.Type()).Underlying().(*types.Pointer); ok { + storageType := types.Unalias(v.X.Type()) + for _, source := range []flowValue{sequenceValue, a.memory[p]} { + projected := aggregateSelectedElement(a, source, v.X.Type(), p) + if legacyChildProjection { + a.merge(&result, projected) + } + if addressPayload { + a.addTypedAddressAlternative(&result, address, pointer.Elem(), exactZeroAggregateMatches(source, storageType), source.zeroAggregateUnknown) + } + } + } + } + if pointer, ok := v.Type().Underlying().(*types.Pointer); ok && inlineAggregate(pointer.Elem()) && !parentArray { + a.merge(&result, aggregateSelectedElement(a, sequenceValue, v.X.Type(), p)) + } + if pointer, ok := v.Type().Underlying().(*types.Pointer); ok && isFunctionType(pointer.Elem()) { + a.merge(&result, a.functionElementValue(v.X, p, address, ix)) + } + } + if parentArray && legacyChildProjection { + projected := aggregateSelectedElement(a, sequenceValue, v.X.Type(), "") + a.merge(&result, projected) + } + if pointer, ok := v.Type().Underlying().(*types.Pointer); ok && inlineAggregate(pointer.Elem()) { + sequence, isSlice := types.Unalias(v.X.Type()).Underlying().(*types.Slice) + if isSlice { + marker := a.get(v.X) + if exactZeroAggregateMatches(marker, sequence.Elem()) { + a.addZeroAggregate(&result, sequence.Elem()) + } + if marker.zeroAggregateUnknown { + a.markZeroAggregateUnknown(&result) + } + } + } + case *ssa.Index: + _, parentArray := types.Unalias(v.X.Type()).Underlying().(*types.Array) + pointerArray := aggregateSequenceStorageType(v.X.Type(), "", nil) != nil && !parentArray + for _, p := range a.recordAddresses(v.X) { + address := p + ".element" + a.merge(&result, a.memory[address]) + if pointerArray { + a.merge(&result, aggregateSelectedElement(a, a.get(v.X), v.X.Type(), p)) + a.merge(&result, aggregateSelectedElement(a, a.memory[p], v.X.Type(), p)) + } + if isFunctionType(v.Type()) { + a.merge(&result, a.functionElementValue(v.X, p, address, ix)) + } + if inlineAggregate(v.Type()) { + result.addresses[address] = true + if !parentArray { + a.merge(&result, aggregateSelectedElement(a, a.get(v.X), v.X.Type(), p)) + } + } + } + if parentArray { + a.merge(&result, aggregateSelectedElement(a, a.get(v.X), v.X.Type(), "")) + } + if inlineAggregate(v.Type()) { + if sequence, ok := types.Unalias(v.X.Type()).Underlying().(*types.Slice); ok { + marker := a.get(v.X) + if exactZeroAggregateMatches(marker, sequence.Elem()) { + a.addZeroAggregate(&result, sequence.Elem()) + } + if marker.zeroAggregateUnknown { + a.markZeroAggregateUnknown(&result) + } + } + } + case *ssa.Slice: + a.merge(&result, a.get(v.X)) + case *ssa.Field: + parentType := types.Unalias(v.X.Type()) + if pointer, ok := parentType.Underlying().(*types.Pointer); ok { + parentType = types.Unalias(pointer.Elem()) + } + if inlineAggregate(parentType) { + a.merge(&result, a.zeroAggregateField(a.get(v.X), parentType, v.Field)) + } + for _, p := range a.recordAddresses(v.X) { + address := fmt.Sprintf("%s.field:%d", p, v.Field) + if inlineAggregate(parentType) { + a.merge(&result, a.zeroAggregateField(a.get(v.X), parentType, v.Field)) + a.merge(&result, a.zeroAggregateField(a.memory[p], parentType, v.Field)) + } + a.merge(&result, a.memory[address]) + if isFunctionType(v.Type()) && a.zeroFunctionFieldAtAddress(address, ix) { + result.functionNil = true + } + if isFunctionType(v.Type()) { + a.addGlobalFunctionState(&result, address, ix) + } + if inlineAggregate(v.Type()) { + result.addresses[address] = true + } + } + case *ssa.Store: + stored := a.get(v.Val) + if isScalarType(v.Val.Type()) { + a.mergeScalar(&stored, a.scalarControlValue(block)) + } + for _, p := range sortedKeys(a.get(v.Addr).addresses) { + if a.store(p, stored) { + changed = true + } + } + case *ssa.UnOp: + if v.Op == token.SUB || v.Op == token.NOT || v.Op == token.XOR { + a.mergeScalar(&result, a.get(v.X)) + } + if v.Op == token.ARROW { + result, _, _ = a.receiveWithClosedChannelValue(v.X, a.channelClosures) + } + if v.Op == token.MUL { + pointerValue := a.get(v.X) + for _, p := range sortedKeys(a.get(v.X).addresses) { + a.merge(&result, a.memory[p]) + a.projectTypedAddressAlternative(&result, pointerValue, p, v.Type()) + if isFunctionType(v.Type()) { + a.addGlobalFunctionState(&result, p, ix) + if a.zeroFunctionFields[p] || a.zeroFunctionArrayElements[p] || a.get(v.X).functionNil { + result.functionNil = true + } + if a.unknownFunctionElements[p] || a.get(v.X).functionUnknown { + result.functionUnknown = true + } + } + if inlineAggregate(v.Type()) { + result.addresses[p] = true + value := a.get(v.X) + if exactZeroAggregateMatches(value, v.Type()) { + a.addZeroAggregate(&result, v.Type()) + } + if value.zeroAggregateUnknown { + a.markZeroAggregateUnknown(&result) + } + } + } + propagateIndirectAlternative(&result, pointerValue, v.Type()) + } + case *ssa.BinOp: + a.mergeScalar(&result, a.get(v.X)) + a.mergeScalar(&result, a.get(v.Y)) + if v.Op == token.ADD { + left, right := a.get(v.X), a.get(v.Y) + result.stringUnknown = left.stringUnknown || right.stringUnknown + for _, x := range sortedKeys(left.strings) { + for _, y := range sortedKeys(right.strings) { + one := emptyFlow() + if len(x+y) <= 8192 { + one.strings[x+y] = true + } else { + a.coverage.Widened = true + } + a.merge(&result, one) + } + } + } + case *ssa.MakeClosure: + target := v.Fn.(*ssa.Function) + result.functions[target] = true + if len(v.Bindings) > 0 { + if method, ok := isSQLHandleMethodTarget(target); ok { + receiver := a.get(v.Bindings[0]) + projected, selected, status := a.projectMethodExpressionReceiver(receiver, v.Bindings[0].Type(), method) + if status == methodReceiverProjectionUnsupported { + projected = unresolvedProjectedReceiver() + } + receiverType := v.Bindings[0].Type() + if selected != nil && selected.Recv() != nil { + receiverType = selected.Recv().Type() + } + captureBoundMethodReceiver(&result, target, projected, receiverType, true) + } else if method, ok := target.Object().(*types.Func); ok && (contextAdapterName(method) == "Done" || contextAdapterName(method) == "Err") && contextSelectedMethod(v.Bindings[0].Type(), contextAdapterName(method)) != nil { + method = contextSelectedMethod(v.Bindings[0].Type(), contextAdapterName(method)) + receiver := a.get(v.Bindings[0]) + projected, selected, status := a.projectMethodExpressionReceiver(receiver, v.Bindings[0].Type(), method) + if status == methodReceiverProjectionUnsupported { + projected = unresolvedProjectedReceiver() + } + receiverType := v.Bindings[0].Type() + if selected != nil && selected.Recv() != nil { + receiverType = selected.Recv().Type() + } + captureBoundMethodReceiver(&result, target, projected, receiverType, true) + } else if len(config.CallRules) > 0 { + captureBoundMethodReceiver(&result, target, a.get(v.Bindings[0]), v.Bindings[0].Type(), true) + } else if isBoundMethodTarget(target) && callbackTargetOutside(target, ix) && callbackStorageType(v.Bindings[0].Type(), map[types.Type]bool{}) { + captureBoundMethodReceiver(&result, target, a.get(v.Bindings[0]), v.Bindings[0].Type(), true) + } + } + a.modelWaitGroupBinding(v, &result) + a.modelLifecycleBinding(v, &result, config) + for i, binding := range v.Bindings { + if i < len(target.FreeVars) && a.put(target.FreeVars[i], a.get(binding)) { + changed = true + } + } + case *ssa.Extract: + if assertion, ok := v.Tuple.(*ssa.TypeAssert); ok && v.Index == 0 && a.get(assertion.X).interfaceUnknown && a.seedInterfaceAssertion(v, assertion, ix) { + changed = true + } + if call, ok := v.Tuple.(*ssa.Call); ok && a.seedAggregateResult(v, call, ix, false) { + changed = true + } + if call, ok := v.Tuple.(*ssa.Call); ok && isFunctionType(v.Type()) && a.outsideFunctionResult(call, ix) { + result.functionUnknown = true + } + if call, ok := v.Tuple.(*ssa.Call); ok && isSQLHandleType(v.Type()) && a.special[call] == nil && a.sqlExternalResult(call.Common(), ix) { + result.sqlUnknown = true + } + switch tuple := v.Tuple.(type) { + case *ssa.Next: + if !tuple.IsString { + a.merge(&result, a.mapIterationValue(tuple.Iter, v.Index, v.Type())) + } + case *ssa.Lookup, *ssa.TypeAssert, *ssa.UnOp: + if v.Index == 0 { + a.merge(&result, a.get(v.Tuple)) + } + case *ssa.Select: + index := 2 + for _, state := range tuple.States { + if state.Dir == types.RecvOnly { + if index == v.Index { + value, _, _ := a.receiveWithClosedChannelValue(state.Chan, a.channelClosures) + a.merge(&result, value) + } + index++ + } + } + } + if call, ok := v.Tuple.(*ssa.Call); ok { + if v.Index < len(a.special[call]) { + a.merge(&result, a.special[call][v.Index]) + } + for _, target := range sortedFunctions(a.targets(call.Common())) { + if v.Index < len(a.returns[target]) { + a.merge(&result, a.returns[target][v.Index]) + } + } + } + case *ssa.Call: + if a.openMutatedMaps(v) { + changed = true + } + a.scalarBuiltinFlow(v, &result) + wrapped, _ := a.errorWrapCandidates(v) + a.merge(&result, wrapped) + a.modelIterator(v, ix, &result) + if a.sliceBuiltin(v, ix, &result) { + changed = true + } + if a.callbackEscape(v, ix) { + changed = true + } + if a.modelContext(v, ix) { + changed = true + } + a.modelSQLHandle(v, ix) + if a.seedAggregateResult(v, v, ix, false) { + changed = true + } + if isFunctionType(v.Type()) && a.outsideFunctionResult(v, ix) { + result.functionUnknown = true + } + if isSQLHandleType(v.Type()) && a.special[v] == nil && a.sqlExternalResult(v.Common(), ix) { + result.sqlUnknown = true + } + if len(a.special[v]) == 1 { + a.merge(&result, a.special[v][0]) + } + if a.call(v.Common(), ix) { + changed = true + } + for _, target := range sortedFunctions(a.targets(v.Common())) { + if len(a.returns[target]) == 1 { + a.merge(&result, a.returns[target][0]) + } + } + case *ssa.Defer: + if a.modelAfterFunc(v, ix) { + changed = true + } + if a.modelContext(v, ix) { + changed = true + } + a.modelSQLHandle(v, ix) + if a.sliceBuiltin(v, ix, &result) { + changed = true + } + if a.callbackEscape(v, ix) { + changed = true + } + if a.call(v.Common(), ix) { + changed = true + } + case *ssa.Go: + if a.modelAfterFunc(v, ix) { + changed = true + } + if a.modelContext(v, ix) { + changed = true + } + a.modelSQLHandle(v, ix) + if a.sliceBuiltin(v, ix, &result) { + changed = true + } + if a.callbackEscape(v, ix) { + changed = true + } + if a.call(v.Common(), ix) { + changed = true + } + case *ssa.Return: + if a.callbackReturnEscape(v, ix) { + changed = true + } + old := a.returns[fn] + if old == nil { + old = make([]flowValue, len(v.Results)) + } + for i, value := range v.Results { + returned := a.get(value) + if isScalarType(value.Type()) { + a.mergeScalar(&returned, a.scalarControlValue(block)) + } + if a.merge(&old[i], returned) { + changed = true + } + } + a.returns[fn] = old + } + if value, ok := instruction.(ssa.Value); ok { + a.configuredLifecycleResult(value, &result, ix, config) + if isScalarType(value.Type()) { + a.mergeScalar(&result, a.scalarControlValue(block)) + if _, phi := value.(*ssa.Phi); phi { + for _, pred := range block.Preds { + a.mergeScalar(&result, a.scalarControlValue(pred)) + } + } + } + if a.put(value, result) { + changed = true + } + } + } + } + } + if a.afterFuncSchedulerFlow() { + changed = true + } + if a.callbackGlobalEscapes(ix) { + changed = true + } + if a.refreshChannelClosures(a.channelClosures) { + changed = true + } + a.coverage.Iterations = round + 1 + if !changed { + if !uncalledInputsSeeded { + uncalledInputsSeeded = true + newInputs := a.seedSQLInputs(funcs, ix, true) + newFunctionInputs := a.seedFunctionInputs(funcs, ix, true) + newResults := a.seedUnresolvedSQLResults(funcs) + newFunctionResults := a.seedUnresolvedFunctionResults(funcs) + newErrorInputs := a.seedErrorInputs(funcs, ix, true) + newAggregateResults := a.seedUnresolvedAggregateResults(funcs, ix) + if newInputs || newFunctionInputs || newResults || newFunctionResults || newAggregateResults || newErrorInputs { + continue + } + } + a.coverage.Converged = true + break + } + } + // Source expression candidates are captured only after the fixed point. + a.scalarControlCache = map[*ssa.BasicBlock]flowValue{} + a.globalRelationships(ix) + a.iteratorRelationships(ix) + for _, fn := range funcs { + for _, block := range fn.Blocks { + for _, ins := range block.Instrs { + a.errorUse(ins, ix) + a.scalarUse(ins, ix) + if call, ok := ins.(*ssa.Call); ok { + a.errorWrapUse(call, ix) + } + a.globalUse(ins, ix) + if iterator, ok := ins.(*ssa.Range); ok { + if _, isMap := iterator.X.Type().Underlying().(*types.Map); isMap { + ix.boundaries = append(ix.boundaries, Boundary{Node: ix.owner(fn), Kind: "map_iteration_model", Reason: "map iteration retains separate unordered key and value candidates; key/value pairing, order, membership after delete/clear and runtime invocation are not proved; outside map inputs and unsupported aliases remain open", Evidence: ix.evidence(iterator.Pos())}) + concrete := false + for address := range a.get(iterator.X).addresses { + concrete = concrete || !strings.HasPrefix(address, "input:") + } + if !concrete { + ix.boundaries = append(ix.boundaries, Boundary{Node: ix.owner(fn), Kind: "unresolved_map_iteration", Reason: "map iteration has no concrete modeled storage origin; a synthetic outside input, nil or unsupported alias cannot establish an empty candidate set", Evidence: ix.evidence(iterator.Pos())}) + } + } + } + if returned, ok := ins.(*ssa.Return); ok { + a.callbackReturnRelationships(returned, ix) + a.callableSummaryReturnEscapes(returned, ix) + } + a.channelUse(ins, ix.owner(fn), ix) + ix.recoveryUse(ins) + if d, ok := ins.(*ssa.DebugRef); ok && d.Expr != nil { + old := a.positions[d.Expr.Pos()] + value := a.get(d.X) + if d.IsAddr { + for _, p := range sortedKeys(value.addresses) { + a.merge(&value, a.memory[p]) + } + } + a.merge(&old, value) + a.positions[d.Expr.Pos()] = old + } + if v, ok := ins.(ssa.Value); ok && v.Pos() != token.NoPos { + old := a.positions[v.Pos()] + a.merge(&old, a.get(v)) + a.positions[v.Pos()] = old + } + if call, ok := ins.(ssa.CallInstruction); ok { + if a.callTargets != nil { + callee := a.get(call.Common().Value) + oldCallee := a.callTargets[call.Common().Pos()] + a.merge(&oldCallee, callee) + a.callTargets[call.Common().Pos()] = oldCallee + } + a.callableSummaryCallEscapes(call, ix) + a.unresolvedFunctionUse(call, ix) + a.iteratorUse(call, ix) + a.callbackEscapeRelationships(call, ix) + from := ix.owner(fn) + a.configuredLifecycleUse(call, from, ix, config) + a.contextUse(call, from, ix) + a.sqlHandleUse(call, ix) + a.waitGroupUse(call, ix) + a.cleanupUse(call, ix) + if call.Common().IsInvoke() && !hasIndexedInvokeTarget(a.targets(call.Common()), ix) { + ix.boundaries = append(ix.boundaries, Boundary{Node: from, Kind: "unresolved_interface_flow", Reason: "no local implementation body modeled for interface method " + call.Common().Method.Name() + "; dependency bodies or runtime implementations can supply additional values", Evidence: ix.callEvidence(call)}) + } + for _, target := range sortedFunctions(a.targets(call.Common())) { + to := ix.owner(target) + if to == "" { + continue + } + if call.Common().StaticCallee() == nil && !call.Common().IsInvoke() { + kind := "resolved_callback_call" + switch call.(type) { + case *ssa.Go: + kind = "goroutine_" + kind + case *ssa.Defer: + kind = "deferred_" + kind + } + ix.callEdge(from, to, kind, "possible", call) + } + for i, arg := range flowArguments(call.Common()) { + value := a.get(arg) + summaries := flowSummary(value, ix) + if len(summaries) > 0 { + ix.edges = append(ix.edges, Relationship{From: from, To: to, Kind: "argument_flow", Certainty: "possible", Evidence: ix.callEvidence(call), Slot: flowArgumentSlot(call.Common(), i), Values: summaries}) + } + } + for i, value := range a.returns[target] { + summaries := flowSummary(value, ix) + if len(summaries) > 0 { + ix.edges = append(ix.edges, Relationship{From: to, To: from, Kind: "return_flow", Certainty: "possible", Evidence: ix.callEvidence(call), Slot: fmt.Sprintf("result:%d", i), Values: summaries}) + } + } + } + } + } + } + } + a.contextBoundaries(ix) + a.afterFuncRelationships(ix) + a.doneRelationships(ix) + for _, key := range sortedKeys(a.sqlHandles) { + site := a.sqlHandles[key] + ix.edge(site.owner, key, "sql_handle_create", site.certainty, site.position) + if site.discarded { + ix.boundaries = append(ix.boundaries, Boundary{ + Node: site.owner, Kind: "discarded_sql_handle", + Reason: "go or deferred database/sql constructor discards its returned handle; this allocation candidate does not prove execution or provide a local cleanup receiver", + Evidence: site.discardEvidence, + }) + } + if len(site.originals) > 0 { + linked := false + for _, candidate := range sortedSSAValues(site.originals) { + originalValue := a.get(candidate) + for _, original := range sortedKeys(originalValue.addresses) { + if strings.HasPrefix(original, "statement:") { + ix.edge(key, original, "statement_rebind", "possible", site.position) + linked = true + } + } + if originalValue.sqlUnknown || originalValue.interfaceUnknown { + site.originalUnknown = true + } + } + if !linked || site.originalUnknown { + ix.boundaries = append(ix.boundaries, Boundary{Node: key, Kind: "unresolved_statement_rebind", Reason: "transaction statement rebinding includes a candidate without a modeled original statement handle", Evidence: ix.evidence(site.position)}) + } + } + if site.hasParent { + linked := false + for _, parent := range sortedKeys(site.parent.addresses) { + parentKind := "" + for _, kind := range []string{"database", "connection", "transaction"} { + if strings.HasPrefix(parent, kind+":") { + parentKind = kind + } + } + if parentKind != "" { + ix.edge(key, parent, ix.funcs[key].node.Kind+"_"+parentKind, "possible", site.position) + linked = true + } + } + if !linked || site.parentUnknown || site.parent.sqlUnknown || site.parent.interfaceUnknown { + ix.boundaries = append(ix.boundaries, Boundary{Node: key, Kind: "unresolved_sql_parent", Reason: "SQL handle creation includes a candidate without a modeled parent receiver origin", Evidence: ix.evidence(site.position)}) + } + } + } + ix.boundaries = append(ix.boundaries, Boundary{Kind: "sql_handle_model", Reason: "Recognized direct, bound, method-expression and concrete-handle-backed interface calls to database/sql Open/OpenDB, Conn, prepare/begin and transaction statement APIs retain allocation-site handle candidates. SQL operation sites connect to receiver and table candidates; statement queries retain preparation origins. Flow is context-insensitive: receiver/query candidates are not correlated by invocation. Allocation sites do not establish physical database identity, successful creation, transaction ordering or cleanup. Unsupported wrappers, interface targets without compatible modeled database/sql handles, outside implementations and external origins remain unresolved."}) + ix.boundaries = append(ix.boundaries, Boundary{Kind: "recovery_model", Reason: "Explicit panic and recover sites are structural facts. Deferred local callees connect to recover calls directly in that SSA body; ordinary nested helper calls are not promoted to direct recovery. Actual panic ownership, registration/execution order, implicit panics, recovery success, repanics and cross-goroutine behavior remain unresolved."}) + ix.boundaries = append(ix.boundaries, Boundary{Kind: "waitgroup_model", Reason: "Static sync.WaitGroup methods, modeled bound closures and interfaces carrying typed WaitGroup receiver summaries retain operation/receiver candidates; Go tasks are function candidates. Direct constant Add deltas are reported, but runtime counts, ordering, reuse, copies, panic behavior and eventual task/join completion are not proved. Untagged external interface inputs, dependency bodies and unsupported wrapper shapes remain boundaries."}) + ix.boundaries = append(ix.boundaries, Boundary{Kind: "cleanup_model", Reason: "Defer sites identify registrations, resolved resources/callees and CFG-reachable explicit return/panic exits. Path feasibility, implicit panics, recovery effects, repeated registration, LIFO ordering, os.Exit/fatal termination and eventual cleanup execution are not proved."}) + ix.boundaries = append(ix.boundaries, Boundary{Kind: "callback_escape_model", Reason: "Function-value candidates, modeled public record fields, slice/array elements and map keys/values returned through exported APIs or passed to known dependencies/unmodeled bodies retain possible escapes. Pointer/container cycles and queued work are bounded; allocation/global types recover modeled containers through interfaces. Elements and map entries are unordered candidates, and deletion does not remove prior values. Append retains possible reused/new backing storage; copy retains possible source elements, including defer/go registrations, without proving lengths or execution order. Embedded records expose public descendant candidates without resolving selector shadowing or ambiguity. Private fields, unmodeled container flow, unresolved targets, external mutations, reflection and dependency bodies can add behavior; retention or invocation is not proved."}) + ix.boundaries = append(ix.boundaries, Boundary{Kind: "value_flow_model", Reason: "Context-insensitive propagation of string/function values through parameters, results, closures, modeled memory, map lookups and channels. Channel payloads are unordered unions; map deletion, map iteration, external inputs, reflection and unmodeled aliasing can add or retain values. Candidates do not establish exhaustive runtime behavior."}) + ix.boundaries = append(ix.boundaries, Boundary{Kind: "interface_flow_model", Reason: "Interface values propagate through local CHA implementation candidates, including receiver and result slots. Candidate implementations are not filtered by runtime receiver type; dependency bodies and implementations outside the loaded type universe can add behavior."}) + a.configuredLifecycleBoundaries(ix, config) + return a, nil +} +func hasIndexedInvokeTarget(targets map[*ssa.Function]bool, ix *index) bool { + for target := range targets { + if ix.owner(target) != "" { + return true + } + } + return false +} +func (a *flowAnalysis) targets(c *ssa.CallCommon) map[*ssa.Function]bool { + if target := c.StaticCallee(); target != nil { + return map[*ssa.Function]bool{target: true} + } + if c.IsInvoke() { + return a.invokes[c] + } + return a.get(c.Value).functions +} +func (a *flowAnalysis) call(c *ssa.CallCommon, ix *index) bool { + changed := a.invokeIterator(c, ix) + targets := a.targets(c) + external := len(targets) == 0 || a.get(c.Value).functionUnknown || c.IsInvoke() && a.get(c.Value).interfaceUnknown + for _, target := range sortedFunctions(targets) { + if ix.owner(target) == "" { + external = true + continue + } + for i, arg := range flowArguments(c) { + if i < len(target.Params) && a.put(target.Params[i], a.get(arg)) { + changed = true + } + } + } + if external { + for _, argument := range flowArguments(c) { + if isMapValue(argument) && a.openMapValue(argument) { + changed = true + } + } + } + return changed +} +func flowSummary(v flowValue, ix *index) []string { + result := []string{} + if v.scalarUnknown { + result = append(result, "unresolved:scalar_origins") + } + for origin := range v.scalars { + result = append(result, origin) + } + if v.stringUnknown { + result = append(result, "unresolved:string_value") + } + if v.interfaceUnknown { + result = append(result, "unresolved:interface_value") + } + if v.functionUnknown { + result = append(result, "unresolved:function_target") + } + if v.functionNil { + result = append(result, "unresolved:nil_function_value") + } + if v.sqlUnknown { + result = append(result, "unresolved:sql_handle") + } + for s := range v.strings { + result = append(result, "string:"+s) + } + for f := range v.functions { + if id := ix.owner(f); id != "" { + result = append(result, "function:"+id) + } + } + for p := range v.addresses { + if strings.HasPrefix(p, "global:") || strings.HasPrefix(p, "context:") || strings.HasPrefix(p, "channel:") || strings.HasPrefix(p, "map:") { + result = append(result, p) + } + } + for effect := range v.effects { + result = append(result, effect) + } + for origin := range v.errors { + result = append(result, origin) + } + sort.Strings(result) + return result +} diff --git a/flow_mixed_dependency_test.go b/flow_mixed_dependency_test.go new file mode 100644 index 0000000..187d9ea --- /dev/null +++ b/flow_mixed_dependency_test.go @@ -0,0 +1,57 @@ +package contracttrace + +import ( + "context" + "path/filepath" + "strings" + "testing" +) + +func TestMixedLocalAndDependencyInterfaceEffects(t *testing.T) { + root, err := filepath.Abs(filepath.Join("testdata", "mixed_dependency")) + if err != nil { + t.Fatal(err) + } + config := Config{StorageScopes: []StorageScope{{Namespace: "orders", DatabaseOrigins: []string{"example.com/mixed::localFactory.DB", "example.com/mixed::localFactory.Use"}}}} + r, err := Trace(context.Background(), Options{Root: root, Seeds: []string{"MixedEntry"}, Depth: 5, MaxNodes: 250, Config: config}) + if err != nil { + t.Fatal(err) + } + table, callbackTable := false, false + unresolvedSQL, escapedCallback, mapOpenFallback := false, false, false + for _, edge := range r.Relationships { + if strings.HasSuffix(edge.From, "::helper") && edge.To == "table:orders:mixed_records" && (edge.Kind == "sql_read" || edge.Kind == "sql_write") { + table = true + } + if edge.To == "table:orders:callback_records" && (edge.Kind == "sql_read" || edge.Kind == "sql_write") { + callbackTable = true + } + } + for _, boundary := range r.Boundaries { + unresolvedSQL = unresolvedSQL || boundary.Kind == "unresolved_sql_handle" && strings.Contains(boundary.Evidence.Snippet, "db.Query") + escapedCallback = escapedCallback || boundary.Kind == "escaped_sql_input" && strings.Contains(boundary.Evidence.Snippet, "factory.Use") + mapOpenFallback = mapOpenFallback || boundary.Kind == "unresolved_sql_handle" && strings.Contains(boundary.Evidence.Snippet, "map_records") + } + if !table || !callbackTable || !unresolvedSQL || !escapedCallback || !mapOpenFallback { + t.Fatalf("mixed interface effects missing: knownSQL=%t callbackSQL=%t unresolvedSQL=%t escapedCallback=%t mapOpenFallback=%t", table, callbackTable, unresolvedSQL, escapedCallback, mapOpenFallback) + } +} + +func TestExternalOnlyInterfaceRetainsBoundary(t *testing.T) { + root, err := filepath.Abs(filepath.Join("testdata", "mixed_dependency", "external_only")) + if err != nil { + t.Fatal(err) + } + r, err := Trace(context.Background(), Options{Root: root, Seeds: []string{"ExternalEntry"}, Depth: 4, MaxNodes: 100}) + if err != nil { + t.Fatal(err) + } + unresolvedInterface, unresolvedSQL := false, false + for _, boundary := range r.Boundaries { + unresolvedInterface = unresolvedInterface || boundary.Kind == "unresolved_interface_flow" && strings.Contains(boundary.Evidence.Snippet, "factory.DB") + unresolvedSQL = unresolvedSQL || boundary.Kind == "unresolved_sql_handle" && strings.Contains(boundary.Evidence.Snippet, "db.Query") + } + if !unresolvedInterface || !unresolvedSQL { + t.Fatalf("external-only interface boundary missing: unresolvedInterface=%t unresolvedSQL=%t", unresolvedInterface, unresolvedSQL) + } +} diff --git a/flow_snapshot.go b/flow_snapshot.go new file mode 100644 index 0000000..cce86e8 --- /dev/null +++ b/flow_snapshot.go @@ -0,0 +1,54 @@ +package contracttrace + +import "golang.org/x/tools/go/ssa" + +// Stored candidates have already passed bounded merging. A read still owns +// independent maps because callers may extend them before storing elsewhere. +// Avoid temporary key slices and repeated growth when making that snapshot. +func snapshotCandidates[K comparable](source map[K]bool) map[K]bool { + result := make(map[K]bool, len(source)) + for key := range source { + result[key] = true + } + return result +} + +func snapshotFlow(source flowValue) flowValue { + result := source + result.strings = snapshotCandidates(source.strings) + result.functions = snapshotCandidates(source.functions) + result.boundReceiverUnknown = source.boundReceiverUnknown + result.boundReceivers = nil + if source.boundReceivers != nil { + result.boundReceivers = make(map[*ssa.Function]boundReceiverCandidates, len(source.boundReceivers)) + for fn, receivers := range source.boundReceivers { + result.boundReceivers[fn] = boundReceiverCandidates{addresses: snapshotCandidates(receivers.addresses), typ: receivers.typ, unknown: receivers.unknown} + } + } + result.addresses = snapshotCandidates(source.addresses) + if len(source.zeroAggregates) != 0 { + result.zeroAggregates = snapshotCandidates(source.zeroAggregates) + } else { + result.zeroAggregates = nil + } + if len(source.addressAlternatives) != 0 { + result.addressAlternatives = make(map[string]typedAddressAlternative, len(source.addressAlternatives)) + for address, alternative := range source.addressAlternatives { + result.addressAlternatives[address] = alternative + } + } else { + result.addressAlternatives = nil + } + result.effects = snapshotCandidates(source.effects) + if len(source.errors) != 0 { + result.errors = snapshotCandidates(source.errors) + } else { + result.errors = nil + } + if len(source.scalars) != 0 { + result.scalars = snapshotCandidates(source.scalars) + } else { + result.scalars = nil + } + return result +} diff --git a/flow_snapshot_test.go b/flow_snapshot_test.go new file mode 100644 index 0000000..cf4a55b --- /dev/null +++ b/flow_snapshot_test.go @@ -0,0 +1,489 @@ +package contracttrace + +import ( + "fmt" + "go/ast" + "go/constant" + "go/parser" + "go/token" + "go/types" + "strings" + "testing" + + "golang.org/x/tools/go/ssa" +) + +var flowSnapshotSink flowValue + +func TestFlowReadSnapshotIsolationAndAllocationBound(t *testing.T) { + value := new(ssa.Alloc) + source := emptyFlow() + source.errors = map[string]bool{} + source.scalars = map[string]bool{} + source.sqlUnknown, source.functionUnknown, source.functionNil, source.interfaceUnknown, source.stringUnknown, source.scalarUnknown, source.mapNil = true, true, true, true, true, true, true + for i := 0; i < 64; i++ { + key := fmt.Sprintf("origin-%d", i) + source.strings[key], source.addresses[key], source.effects[key], source.errors[key], source.scalars[key] = true, true, true, true, true + source.functions[new(ssa.Function)] = true + } + a := &flowAnalysis{values: map[ssa.Value]flowValue{value: source}} + first := a.get(value) + if !first.sqlUnknown || !first.functionUnknown || !first.functionNil || !first.interfaceUnknown || !first.stringUnknown || !first.scalarUnknown || !first.mapNil { + t.Fatal("snapshot lost unresolved domains") + } + for name, candidates := range map[string]map[string]bool{"strings": first.strings, "addresses": first.addresses, "effects": first.effects, "errors": first.errors, "scalars": first.scalars} { + if len(candidates) != 64 { + t.Fatalf("%s snapshot lost candidates", name) + } + delete(candidates, "origin-0") + candidates["new-origin"] = true + } + for function := range first.functions { + delete(first.functions, function) + break + } + second := a.get(value) + for name, candidates := range map[string]map[string]bool{"strings": second.strings, "addresses": second.addresses, "effects": second.effects, "errors": second.errors, "scalars": second.scalars} { + if len(candidates) != 64 || !candidates["origin-0"] || candidates["new-origin"] { + t.Errorf("%s snapshot mutation changed stored origins", name) + } + } + if len(second.functions) != 64 { + t.Error("snapshot mutation changed stored callable origins") + } + allocations := testing.AllocsPerRun(100, func() { flowSnapshotSink = a.get(value) }) + t.Logf("64 candidates in all six domains: %.0f allocations per isolated read", allocations) + if allocations > 24 { + t.Errorf("bounded snapshot read rebuilds candidates excessively: %.0f allocations, budget 24", allocations) + } +} + +func TestMapNilFlowMergeAndSnapshot(t *testing.T) { + analysis := &flowAnalysis{} + var merged flowValue + nilMap := emptyFlow() + nilMap.mapNil = true + if !analysis.merge(&merged, nilMap) || !merged.mapNil { + t.Fatal("first nil-map candidate did not initialize and change the destination") + } + if analysis.merge(&merged, nilMap) { + t.Fatal("remerging the same nil-map candidate reported a change") + } + copy := snapshotFlow(merged) + if !copy.mapNil { + t.Fatal("flow snapshot lost the nil-map candidate") + } + known := emptyFlow() + known.addresses["map:known"] = true + analysis.merge(©, known) + if !copy.mapNil || !copy.addresses["map:known"] || merged.addresses["map:known"] { + t.Fatal("map candidates did not remain independent after snapshot") + } +} + +func TestTypedAddressAlternativeMergeAndSnapshot(t *testing.T) { + analysis := &flowAnalysis{} + loadedType := types.NewPointer(types.Typ[types.Int]) + var merged flowValue + unknown := emptyFlow() + unknown.addressAlternatives = map[string]typedAddressAlternative{ + "record.field:0": {typ: loadedType, unknown: true}, + } + if !analysis.merge(&merged, unknown) { + t.Fatal("initial typed address alternative did not change the destination") + } + zero := emptyFlow() + zero.addressAlternatives = map[string]typedAddressAlternative{ + "record.field:0": {typ: loadedType, zero: true}, + } + if !analysis.merge(&merged, zero) { + t.Fatal("known zero candidate did not join existing address uncertainty") + } + got := merged.addressAlternatives["record.field:0"] + if !got.zero || !got.unknown || !types.Identical(got.typ, loadedType) { + t.Fatalf("address candidates were not preserved monotonically: %+v", got) + } + + snapshot := snapshotFlow(merged) + delete(snapshot.addressAlternatives, "record.field:0") + if _, ok := merged.addressAlternatives["record.field:0"]; !ok { + t.Fatal("address alternative snapshot shared its map with storage") + } + empty := emptyFlow() + empty.addressAlternatives = map[string]typedAddressAlternative{} + emptySnapshot := snapshotFlow(empty) + if emptySnapshot.addressAlternatives != nil { + emptySnapshot.addressAlternatives["new"] = typedAddressAlternative{typ: loadedType, unknown: true} + } + if len(empty.addressAlternatives) != 0 { + t.Fatal("empty address alternative snapshot shared its map with storage") + } +} + +func TestTypedAddressAlternativeMixedStateIsAbsorbing(t *testing.T) { + intPointer := types.NewPointer(types.Typ[types.Int]) + stringPointer := types.NewPointer(types.Typ[types.String]) + for _, order := range [][]typedAddressAlternative{ + {{typ: intPointer, zero: true}, {typ: stringPointer, zero: true}}, + {{typ: stringPointer, zero: true}, {typ: intPointer, zero: true}}, + } { + analysis := &flowAnalysis{} + var merged flowValue + for _, incoming := range order { + value := emptyFlow() + value.addressAlternatives = map[string]typedAddressAlternative{"field": incoming} + analysis.merge(&merged, value) + } + mixed := merged.addressAlternatives["field"] + if mixed.typ != nil || mixed.zero || !mixed.unknown { + t.Fatalf("conflicting types did not become unknown-only: %+v", mixed) + } + if analysis.merge(&merged, merged) { + t.Fatal("remerging mixed address metadata changed the destination") + } + later := emptyFlow() + later.addressAlternatives = map[string]typedAddressAlternative{"field": {typ: intPointer, zero: true}} + if analysis.merge(&merged, later) { + t.Fatal("mixed address metadata was narrowed by a later concrete candidate") + } + mixed = merged.addressAlternatives["field"] + if mixed.typ != nil || mixed.zero || !mixed.unknown { + t.Fatalf("later candidate changed absorbing unknown-only state: %+v", mixed) + } + projected := emptyFlow() + analysis.projectTypedAddressAlternative(&projected, merged, "field", types.NewMap(types.Typ[types.String], types.Typ[types.Int])) + if projected.mapNil || !projected.interfaceUnknown { + t.Fatalf("conflicting loaded types fabricated a typed zero: %+v", projected) + } + } + + for _, currentZero := range []bool{false, true} { + analysis := &flowAnalysis{} + current := emptyFlow() + current.addressAlternatives = map[string]typedAddressAlternative{ + "slot": {typ: intPointer, zero: currentZero, unknown: true}, + } + incoming := emptyFlow() + incoming.addressAlternatives = map[string]typedAddressAlternative{ + "slot": {typ: nil, zero: true, unknown: true}, + } + if !analysis.merge(¤t, incoming) { + t.Fatalf("mixed incoming state did not change a concrete unknown destination (zero=%t)", currentZero) + } + got := current.addressAlternatives["slot"] + if got.typ != nil || got.zero || !got.unknown { + t.Fatalf("mixed incoming state was not stored as unknown-only (zero=%t): %+v", currentZero, got) + } + if analysis.merge(¤t, incoming) { + t.Fatalf("remerging mixed state changed the destination (zero=%t)", currentZero) + } + if analysis.addTypedAddressAlternative(¤t, "slot", intPointer, true, false) { + t.Fatalf("later concrete state narrowed mixed destination (zero=%t)", currentZero) + } + } + + malformed := emptyFlow() + malformed.addressAlternatives = map[string]typedAddressAlternative{ + "slot": {typ: nil, zero: true, unknown: true}, + } + malformedAnalysis := &flowAnalysis{} + for _, typ := range []types.Type{ + types.NewSignatureType(nil, nil, nil, nil, nil, false), + types.NewMap(types.Typ[types.String], types.Typ[types.Int]), + types.NewStruct(nil, nil), + } { + projected := emptyFlow() + malformedAnalysis.projectTypedAddressAlternative(&projected, malformed, "slot", typ) + if projected.functionNil || projected.mapNil || len(projected.zeroAggregates) != 0 || + !projected.functionUnknown && !projected.interfaceUnknown && !projected.zeroAggregateUnknown { + t.Fatalf("malformed nil-typed alternative projected a typed zero: type=%v value=%+v", typ, projected) + } + } + + analysis := &flowAnalysis{} + var bounded flowValue + for i := 0; i < maxFlowValues; i++ { + address := fmt.Sprintf("field:%02d", i) + if !analysis.addTypedAddressAlternative(&bounded, address, intPointer, true, false) { + t.Fatalf("candidate %d did not change bounded metadata", i) + } + } + if !analysis.addTypedAddressAlternative(&bounded, "field:overflow", intPointer, true, false) || !bounded.addressAltUnknown || !analysis.coverage.Widened { + t.Fatal("overflow did not retain a widening alternative") + } + if analysis.addTypedAddressAlternative(&bounded, "field:overflow-again", intPointer, true, false) { + t.Fatal("repeated overflow changed an already widened state") + } +} + +func TestTypedAddressAlternativeRetypesAcrossPointerConversion(t *testing.T) { + base := types.NewInterfaceType(nil, nil).Complete() + named := types.NewNamed(types.NewTypeName(token.NoPos, nil, "contextSlot", nil), base, nil) + from, to := types.NewPointer(base), types.NewPointer(named) + if !types.ConvertibleTo(from, to) { + t.Fatal("fixture pointer conversion is not valid Go") + } + value := emptyFlow() + value.addresses["record.field:0"] = true + value.addressAlternatives = map[string]typedAddressAlternative{"record.field:0": {typ: base, unknown: true}} + retypeAddressAlternatives(&value, from, to) + if alternative := value.addressAlternatives["record.field:0"]; !types.Identical(alternative.typ, named) || !alternative.unknown || alternative.zero { + t.Fatalf("address alternative did not follow the valid pointer conversion: %+v", alternative) + } + projected := emptyFlow() + (&flowAnalysis{}).projectTypedAddressAlternative(&projected, value, "record.field:0", named) + if !projected.interfaceUnknown { + t.Fatal("converted typed alternative was not projected on the matching dereference") + } +} + +func TestBoundReceiverSnapshotIsolation(t *testing.T) { + fn := new(ssa.Function) + receiverType := types.NewPointer(types.NewStruct(nil, nil)) + source := emptyFlow() + source.boundReceivers = map[*ssa.Function]boundReceiverCandidates{ + fn: {addresses: map[string]bool{"database:primary": true}, typ: receiverType}, + } + first := snapshotFlow(source) + receivers := first.boundReceivers[fn] + if !types.Identical(receivers.typ, receiverType) { + t.Fatal("snapshot lost the receiver type needed to walk exported callback fields") + } + delete(receivers.addresses, "database:primary") + receivers.addresses["database:mutated"] = true + first.boundReceivers[fn] = receivers + if !source.boundReceivers[fn].addresses["database:primary"] || source.boundReceivers[fn].addresses["database:mutated"] { + t.Fatal("nested receiver snapshot mutation changed stored candidates") + } + + empty := emptyFlow() + empty.boundReceivers = map[*ssa.Function]boundReceiverCandidates{} + emptySnapshot := snapshotFlow(empty) + emptySnapshot.boundReceivers[fn] = boundReceiverCandidates{addresses: map[string]bool{"new": true}} + if len(empty.boundReceivers) != 0 { + t.Fatal("empty bound receiver snapshot map shared with storage") + } + allocations := testing.AllocsPerRun(100, func() { flowSnapshotSink = snapshotFlow(source) }) + t.Logf("bound receiver snapshot: %.0f allocations for one target and one address", allocations) +} + +func TestZeroAggregateSnapshotIsolationAndEmptyAllocation(t *testing.T) { + typ := types.NewStruct([]*types.Var{types.NewVar(token.NoPos, nil, "callback", types.NewSignatureType(nil, nil, nil, nil, nil, false))}, nil) + source := emptyFlow() + source.zeroAggregates = map[types.Type]bool{typ: true} + snapshot := snapshotFlow(source) + delete(snapshot.zeroAggregates, typ) + snapshot.zeroAggregates[types.NewStruct(nil, nil)] = true + if !source.zeroAggregates[typ] || len(source.zeroAggregates) != 1 { + t.Fatal("zero aggregate snapshot mutation changed stored candidates") + } + if empty := snapshotFlow(emptyFlow()); empty.zeroAggregates != nil { + t.Fatal("empty snapshot should not allocate an empty zero aggregate map") + } +} + +func TestBoundReceiverCaptureHelperHonorsEnablement(t *testing.T) { + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, "bound.go", "package bound\ntype DB struct{}\nfunc (*DB) Exec(string) {}\nfunc use(db *DB) { db.Exec(\"query\") }\n", 0) + if err != nil { + t.Fatal(err) + } + info := &types.Info{Types: map[ast.Expr]types.TypeAndValue{}, Defs: map[*ast.Ident]types.Object{}, Uses: map[*ast.Ident]types.Object{}, Selections: map[*ast.SelectorExpr]*types.Selection{}} + typed, err := new(types.Config).Check("example.com/bound", fset, []*ast.File{file}, info) + if err != nil { + t.Fatal(err) + } + program := ssa.NewProgram(fset, 0) + ssaPackage := program.CreatePackage(typed, []*ast.File{file}, info, true) + ssaPackage.Build() + var methodValue *types.Selection + ast.Inspect(file, func(node ast.Node) bool { + if selector, ok := node.(*ast.SelectorExpr); ok && selector.Sel.Name == "Exec" { + methodValue = info.Selections[selector] + } + return true + }) + if methodValue == nil { + t.Fatal("fixture did not produce a bound method selection") + } + target := program.MethodValue(methodValue) + if target == nil { + t.Fatal("SSA did not create the bound method wrapper") + } + receiver := emptyFlow() + receiver.addresses["database:primary"] = true + receiver.interfaceUnknown = true + result := emptyFlow() + captureBoundMethodReceiver(&result, target, receiver, target.Signature.Recv().Type(), false) + if len(result.boundReceivers) != 0 { + t.Fatal("default configuration captured CallRules-only receiver metadata") + } + receiverType := target.Signature.Recv().Type() + captureBoundMethodReceiver(&result, target, receiver, receiverType, true) + if !result.boundReceivers[target].addresses["database:primary"] || !result.boundReceivers[target].unknown { + t.Fatal("enabled bound receiver capture omitted address or interface uncertainty") + } + if !types.Identical(result.boundReceivers[target].typ, receiverType) { + t.Fatal("bound receiver capture lost its declared type") + } +} + +func TestFlowReadSnapshotKeepsIntrinsicAndOversizedCandidates(t *testing.T) { + value := new(ssa.Alloc) + oversized := emptyFlow() + oversized.scalars = map[string]bool{} + for i := 0; i < 65; i++ { + key := fmt.Sprintf("origin-%02d", i) + oversized.strings[key] = true + oversized.scalars[key] = true + } + a := &flowAnalysis{values: map[ssa.Value]flowValue{value: oversized}} + read := a.get(value) + if len(read.strings) != 64 || len(read.scalars) != 64 || !read.stringUnknown || !read.scalarUnknown || !a.coverage.Widened { + t.Fatal("oversized source bypassed bounded merging or uncertainty") + } + if !read.strings["origin-00"] || read.strings["origin-64"] || !read.scalars["origin-00"] || read.scalars["origin-64"] { + t.Fatal("oversized source lost deterministic candidate selection") + } + literal := ssa.NewConst(constant.MakeString("literal"), types.Typ[types.String]) + stored := emptyFlow() + stored.strings["stored"] = true + a.values[literal] = stored + read = a.get(literal) + if !read.strings["literal"] || !read.strings["stored"] { + t.Fatal("intrinsic literal or stored candidates lost") + } + function := new(ssa.Function) + if !a.get(function).functions[function] { + t.Fatal("intrinsic callable origin lost") + } + empty := emptyFlow() + empty.errors = map[string]bool{} + empty.scalars = map[string]bool{} + empty.boundReceivers = map[*ssa.Function]boundReceiverCandidates{} + a.values[value] = empty + read = a.get(value) + read.strings["new"] = true + read.addresses["new"] = true + read.effects["new"] = true + read.functions[function] = true + read.boundReceivers[function] = boundReceiverCandidates{addresses: map[string]bool{"new": true}} + if len(a.values[value].strings) != 0 || len(a.values[value].addresses) != 0 || len(a.values[value].effects) != 0 || len(a.values[value].functions) != 0 { + t.Fatal("empty snapshot maps shared with storage") + } + if len(a.values[value].boundReceivers) != 0 { + t.Fatal("empty bound receiver snapshot map shared with storage") + } + if read.errors != nil || read.scalars != nil { + t.Fatal("empty lazy domains unexpectedly share maps") + } +} + +func TestBoundReceiverUnknownCanMergeBeforeKnownOrigin(t *testing.T) { + fn := new(ssa.Function) + a := &flowAnalysis{} + merged := emptyFlow() + unknownFirst := emptyFlow() + unknownFirst.boundReceivers = map[*ssa.Function]boundReceiverCandidates{fn: {unknown: true}} + a.merge(&merged, unknownFirst) + knownLater := emptyFlow() + knownLater.boundReceivers = map[*ssa.Function]boundReceiverCandidates{fn: {addresses: map[string]bool{"database:primary": true}}} + a.merge(&merged, knownLater) + got := merged.boundReceivers[fn] + if !got.unknown || !got.addresses["database:primary"] { + t.Fatalf("unknown-first merge lost known receiver or uncertainty: %+v", got) + } +} + +func TestBoundReceiverMetadataWideningIsBoundedAndDeterministic(t *testing.T) { + fset := token.NewFileSet() + var source strings.Builder + source.WriteString("package bounded\n") + for i := 0; i < maxFlowValues+1; i++ { + fmt.Fprintf(&source, "func Target%02d() {}\n", i) + } + file, err := parser.ParseFile(fset, "bounded.go", source.String(), 0) + if err != nil { + t.Fatal(err) + } + info := &types.Info{Types: map[ast.Expr]types.TypeAndValue{}, Defs: map[*ast.Ident]types.Object{}} + typed, err := new(types.Config).Check("example.com/bounded", fset, []*ast.File{file}, info) + if err != nil { + t.Fatal(err) + } + program := ssa.NewProgram(fset, 0) + ssaPackage := program.CreatePackage(typed, []*ast.File{file}, info, true) + ssaPackage.Build() + + functions := map[string]*ssa.Function{} + for i := 0; i < maxFlowValues+1; i++ { + name := fmt.Sprintf("Target%02d", i) + functions[name] = ssaPackage.Func(name) + } + mergeTargets := func(reverse bool) (*flowAnalysis, flowValue) { + analysis := &flowAnalysis{} + source := emptyFlow() + source.boundReceivers = map[*ssa.Function]boundReceiverCandidates{} + for i := 0; i < maxFlowValues+1; i++ { + index := i + if reverse { + index = maxFlowValues - i + } + name := fmt.Sprintf("Target%02d", index) + fn := functions[name] + source.boundReceivers[fn] = boundReceiverCandidates{addresses: map[string]bool{"database:" + name: true}} + } + value := emptyFlow() + analysis.merge(&value, source) + return analysis, value + } + firstAnalysis, first := mergeTargets(false) + secondAnalysis, second := mergeTargets(true) + if len(first.boundReceivers) != maxFlowValues || !first.boundReceiverUnknown || !firstAnalysis.coverage.Widened { + t.Fatalf("target metadata was not bounded with an explicit unknown: targets=%d unknown=%t widened=%t", len(first.boundReceivers), first.boundReceiverUnknown, firstAnalysis.coverage.Widened) + } + if len(second.boundReceivers) != maxFlowValues || !second.boundReceiverUnknown || !secondAnalysis.coverage.Widened { + t.Fatalf("reverse target order bypassed bound: targets=%d unknown=%t widened=%t", len(second.boundReceivers), second.boundReceiverUnknown, secondAnalysis.coverage.Widened) + } + for i := 0; i < maxFlowValues; i++ { + name := fmt.Sprintf("Target%02d", i) + fn := functions[name] + if !first.boundReceivers[fn].addresses["database:"+name] || !second.boundReceivers[fn].addresses["database:"+name] { + t.Fatalf("bounded target selection depends on insertion order at %s", name) + } + } + + fn := functions["Target00"] + mergeOrigins := func(reverse bool) (*flowAnalysis, boundReceiverCandidates) { + origins := emptyFlow() + one := boundReceiverCandidates{addresses: map[string]bool{}} + for i := 0; i < maxFlowValues+1; i++ { + index := i + if reverse { + index = maxFlowValues - i + } + one.addresses[fmt.Sprintf("database:origin-%02d", index)] = true + } + origins.boundReceivers = map[*ssa.Function]boundReceiverCandidates{fn: one} + analysis := &flowAnalysis{} + value := emptyFlow() + analysis.merge(&value, origins) + return analysis, value.boundReceivers[fn] + } + firstOriginsAnalysis, firstOrigins := mergeOrigins(false) + secondOriginsAnalysis, secondOrigins := mergeOrigins(true) + for name, result := range map[string]struct { + analysis *flowAnalysis + value boundReceiverCandidates + }{"forward": {firstOriginsAnalysis, firstOrigins}, "reverse": {secondOriginsAnalysis, secondOrigins}} { + if len(result.value.addresses) != maxFlowValues || !result.value.unknown || !result.analysis.coverage.Widened { + t.Fatalf("%s receiver origins were not bounded with explicit uncertainty: origins=%d unknown=%t widened=%t", name, len(result.value.addresses), result.value.unknown, result.analysis.coverage.Widened) + } + } + for i := 0; i < maxFlowValues; i++ { + key := fmt.Sprintf("database:origin-%02d", i) + if !firstOrigins.addresses[key] || !secondOrigins.addresses[key] { + t.Fatalf("bounded receiver-origin selection depends on insertion order at %s", key) + } + } +} diff --git a/flow_test.go b/flow_test.go new file mode 100644 index 0000000..bb70a17 --- /dev/null +++ b/flow_test.go @@ -0,0 +1,112 @@ +package contracttrace + +import ( + "context" + "strings" + "testing" +) + +func TestInterproceduralStrings(t *testing.T) { + r, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"WrappedWrite", "WrappedPublish"}, Depth: 4, MaxNodes: 150}) + if err != nil { + t.Fatal(err) + } + for _, name := range []string{"AuditRead", "WrappedConsume"} { + if !hasName(r, name) { + t.Errorf("missing value-flow sibling %s", name) + } + } + sql, event := false, false + for _, e := range r.Relationships { + if strings.HasSuffix(e.From, "::sqlWrapper") && e.To == "table:audit_log" { + sql = true + } + if strings.HasSuffix(e.From, "::publishKind") && e.To == "event:WRAPPED_COMPLETED" { + event = true + } + } + if !sql || !event { + t.Error("strings passed through parameters and returns were not resolved at storage/event sites") + } +} + +func TestMapAndChannelFlow(t *testing.T) { + r, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"MapCallback", "ChannelEvent", "SelectEvent"}, Depth: 4, MaxNodes: 250}) + if err != nil { + t.Fatal(err) + } + resolved, wrong, receive, close, selectReceive := false, false, false, false, false + for _, e := range r.Relationships { + if strings.HasSuffix(e.From, "::MapCallback") && e.Kind == "resolved_callback_call" { + if strings.HasSuffix(e.To, "::Validate") { + resolved = true + } + if strings.HasSuffix(e.To, "::OtherMapTarget") { + wrong = true + } + } + if strings.HasSuffix(e.From, "::receiveKind") && e.To == "event:CHANNEL_EVENT" { + receive = true + } + if strings.HasSuffix(e.From, "::selectKind") && e.To == "event:CHANNEL_EVENT" { + selectReceive = true + } + if strings.HasSuffix(e.From, "::ChannelEvent") && e.Kind == "channel_close" { + close = true + } + } + if !resolved || wrong || !receive || !selectReceive || !close || !hasName(r, "ChannelConsume") { + t.Errorf("container flow incomplete: resolved=%t wrong=%t receive=%t select=%t close=%t", resolved, wrong, receive, selectReceive, close) + } +} + +func TestContainerIdentityAndTupleSlots(t *testing.T) { + r, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"IntegerMapCallback", "SecondSelectEvent", "CommaReceiveEvent"}, Depth: 3, MaxNodes: 250}) + if err != nil { + t.Fatal(err) + } + callback, second, comma := false, false, false + for _, e := range r.Relationships { + if strings.HasSuffix(e.From, "::IntegerMapCallback") && e.Kind == "resolved_callback_call" { + if strings.HasSuffix(e.To, "::Validate") { + callback = true + } + if strings.HasSuffix(e.To, "::OtherMapTarget") { + t.Error("distinct integer map keys merged") + } + } + if strings.HasSuffix(e.From, "::SecondSelectEvent") && e.To == "event:SECOND_CHANNEL" { + second = true + } + if strings.HasSuffix(e.From, "::SecondSelectEvent") && e.To == "event:FIRST_CHANNEL" { + t.Error("distinct select receive slots merged") + } + if strings.HasSuffix(e.From, "::CommaReceiveEvent") && e.To == "event:COMMA_EVENT" { + comma = true + } + } + if !callback || !second || !comma { + t.Errorf("tuple flow missing: callback=%t second=%t comma=%t", callback, second, comma) + } +} +func TestCallbackValueFlow(t *testing.T) { + r, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"FlowEntry", "RegisteredEntry", "FieldEntry"}, Depth: 4, MaxNodes: 150}) + if err != nil { + t.Fatal(err) + } + expected := map[string]bool{"runCheck": false, "RegisteredEntry": false, "FieldEntry": false} + for _, e := range r.Relationships { + if e.Kind == "resolved_callback_call" && strings.HasSuffix(e.To, "::Validate") { + for name := range expected { + if strings.HasSuffix(e.From, "::"+name) { + expected[name] = true + } + } + } + } + for name, found := range expected { + if !found { + t.Errorf("callback flow through %s not resolved", name) + } + } +} diff --git a/focus.go b/focus.go new file mode 100644 index 0000000..00b3b87 --- /dev/null +++ b/focus.go @@ -0,0 +1,77 @@ +package contracttrace + +import ( + "fmt" + "sort" + "strings" +) + +// Focus explains paths in the selected graph; it never removes structural scope. +func (ix *index) applyFocus(r *Report, focus []string) error { + if len(focus) == 0 { + return nil + } + anchors := map[string]bool{} + for _, requested := range focus { + matches := []string{} + for id, f := range ix.funcs { + if id == requested || f.node.Name == requested { + matches = append(matches, id) + } + } + sort.Strings(matches) + if len(matches) == 0 { + return fmt.Errorf("contract focus %q not found", requested) + } + if len(matches) > 1 { + return fmt.Errorf("contract focus %q is ambiguous: %s", requested, strings.Join(matches, ", ")) + } + anchors[matches[0]] = true + } + r.Focus = sortedKeys(anchors) + nodes := map[string]int{} + for i := range r.Nodes { + nodes[r.Nodes[i].ID] = i + r.Nodes[i].Relevance = nil + } + adj := map[string][]Relationship{} + for _, e := range r.Relationships { + adj[e.From] = append(adj[e.From], e) + adj[e.To] = append(adj[e.To], e) + } + for id := range adj { + sortEdgesByPriorityAndKey(adj[id]) + } + queue := []string{} + for _, anchor := range r.Focus { + if index, ok := nodes[anchor]; ok { + r.Nodes[index].Relevance = &Relevance{Anchor: anchor, Distance: 0, PathCertainty: "fact"} + queue = append(queue, anchor) + } else { + r.Boundaries = append(r.Boundaries, Boundary{Kind: "focus_outside_scope", Reason: "contract focus " + anchor + " is discovered but outside selected scope; expand seeds/depth/budget to inspect paths", Evidence: ix.funcs[anchor].node.Evidence}) + } + } + for head := 0; head < len(queue); head++ { + id := queue[head] + current := r.Nodes[nodes[id]].Relevance + for _, edge := range adj[id] { + next := edge.To + if next == id { + next = edge.From + } + index, exists := nodes[next] + if !exists || r.Nodes[index].Relevance != nil { + continue + } + copy := edge + certainty := current.PathCertainty + if edge.Certainty != "fact" { + certainty = "possible" + } + r.Nodes[index].Relevance = &Relevance{Anchor: current.Anchor, Distance: current.Distance + 1, Via: ©, PathCertainty: certainty} + queue = append(queue, next) + } + } + r.Boundaries = append(r.Boundaries, Boundary{Kind: "contract_focus_model", Reason: "Explicit focus anchors explain shortest undirected paths in the selected graph. Possible relationships remain possible; graph proximity does not establish semantic relevance or an invariant. Missing paths can reflect scope limits or unsupported analysis, and do not establish irrelevance."}) + return nil +} diff --git a/focus_certainty_test.go b/focus_certainty_test.go new file mode 100644 index 0000000..344601d --- /dev/null +++ b/focus_certainty_test.go @@ -0,0 +1,87 @@ +package contracttrace + +import ( + "context" + "encoding/json" + "strings" + "testing" +) + +func TestFocusCertaintySurvivesSavedExplorationAndKeepsUnknownAnnotations(t *testing.T) { + options := Options{Root: "testdata/sample", Seeds: []string{"Entry"}, Focus: []string{"Validate"}, Depth: 2, MaxNodes: 100} + fresh, analysis, err := TraceWithAnalysis(context.Background(), options) + if err != nil { + t.Fatal(err) + } + encoded, err := json.Marshal(analysis) + if err != nil { + t.Fatal(err) + } + loaded, err := ReadAnalysis(strings.NewReader(string(encoded))) + if err != nil { + t.Fatal(err) + } + resumed, err := Explore(context.Background(), loaded, ExploreOptions{Seeds: options.Seeds, Focus: options.Focus, Depth: 2, MaxNodes: 100}) + if err != nil { + t.Fatal(err) + } + for _, report := range []Report{fresh, resumed} { + found := false + for _, node := range report.Nodes { + if node.Name == "Entry" { + found = node.Relevance != nil && node.Relevance.PathCertainty == "possible" + } + } + if !found { + t.Error("interface caller path lost cumulative uncertainty") + } + } + legacy := Report{Nodes: []Node{{ID: "a", Name: "A", Relevance: &Relevance{Anchor: "a"}}}} + if !strings.Contains(Markdown(legacy), "Whole focus path: unknown") { + t.Error("legacy annotation invented factual certainty") + } +} + +func TestFocusCertaintyIncludesEveryPathEdge(t *testing.T) { + ix := &index{funcs: map[string]*function{}} + report := Report{Nodes: []Node{{ID: "anchor", Name: "Anchor"}, {ID: "possible", Name: "Possible"}, {ID: "leaf", Name: "Leaf"}, {ID: "fact", Name: "Fact"}, {ID: "factleaf", Name: "FactLeaf"}}, Relationships: []Relationship{ + {From: "anchor", To: "possible", Kind: "possible_interface_call", Certainty: "possible"}, + {From: "possible", To: "leaf", Kind: "call", Certainty: "fact"}, + {From: "anchor", To: "fact", Kind: "call", Certainty: "fact"}, + {From: "fact", To: "factleaf", Kind: "call", Certainty: "fact"}, + }} + for _, node := range report.Nodes { + ix.funcs[node.ID] = &function{node: node} + } + if err := ix.applyFocus(&report, []string{"anchor"}); err != nil { + t.Fatal(err) + } + encoded, err := json.Marshal(report) + if err != nil { + t.Fatal(err) + } + var document struct { + Nodes []struct { + ID string `json:"id"` + Relevance struct { + Certainty string `json:"path_certainty"` + } `json:"contract_relevance"` + } `json:"nodes"` + } + if err := json.Unmarshal(encoded, &document); err != nil { + t.Fatal(err) + } + for _, node := range document.Nodes { + want := "fact" + if node.ID == "possible" || node.ID == "leaf" { + want = "possible" + } + if node.Relevance.Certainty != want { + t.Errorf("%s path certainty=%q want %s", node.ID, node.Relevance.Certainty, want) + } + } + text := Markdown(report) + if !strings.Contains(text, "Whole focus path: possible") || !strings.Contains(text, "Whole focus path: fact") { + t.Error("markdown omits cumulative path uncertainty") + } +} diff --git a/focus_test.go b/focus_test.go new file mode 100644 index 0000000..1e2ffde --- /dev/null +++ b/focus_test.go @@ -0,0 +1,103 @@ +package contracttrace + +import ( + "context" + "encoding/json" + "strings" + "testing" +) + +func TestContractFocusProvidesPathsWithoutChangingScope(t *testing.T) { + opts := Options{Root: "testdata/sample", Seeds: []string{"Validate"}, Depth: 3, MaxNodes: 200} + plain, err := Trace(context.Background(), opts) + if err != nil { + t.Fatal(err) + } + opts.Focus = []string{"Validate"} + focused, err := Trace(context.Background(), opts) + if err != nil { + t.Fatal(err) + } + if len(focused.Nodes) != len(plain.Nodes) || len(focused.Relationships) != len(plain.Relationships) { + t.Fatal("focus silently changed discovered scope") + } + if focused.Coverage.SourceSHA256 != plain.Coverage.SourceSHA256 { + t.Fatal("exploration focus changed analysis source identity") + } + foundAnchor, foundPath := false, false + for _, n := range focused.Nodes { + if n.Name == "Validate" { + foundAnchor = n.Relevance != nil && n.Relevance.Distance == 0 && n.Relevance.Anchor == n.ID + } + if n.Relevance != nil && n.Relevance.Distance > 0 { + foundPath = foundPath || n.Relevance.Via != nil && (n.Relevance.Via.From == n.ID || n.Relevance.Via.To == n.ID) + } + } + if !foundAnchor || !foundPath { + t.Fatalf("focus paths missing: anchor=%t path=%t", foundAnchor, foundPath) + } +} + +func TestContractFocusSavedExplorationAndOutsideScope(t *testing.T) { + _, analysis, err := TraceWithAnalysis(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"Validate"}, Focus: []string{"Validate"}, Depth: 1, MaxNodes: 2}) + if err != nil { + t.Fatal(err) + } + for _, n := range analysis.Nodes { + if n.Relevance != nil { + t.Fatal("query-specific relevance leaked into saved graph") + } + } + data, err := json.Marshal(analysis) + if err != nil { + t.Fatal(err) + } + saved, err := ReadAnalysis(strings.NewReader(string(data))) + if err != nil { + t.Fatal(err) + } + r, err := Explore(context.Background(), saved, ExploreOptions{Seeds: []string{"Validate"}, Focus: []string{"ManagedTask"}, Depth: 1, MaxNodes: 2}) + if err != nil { + t.Fatal(err) + } + outside := false + for _, b := range r.Boundaries { + outside = outside || b.Kind == "focus_outside_scope" + } + if !outside || len(r.Focus) != 1 { + t.Fatal("discovered focus outside scope was silently lost") + } + if _, err := Explore(context.Background(), saved, ExploreOptions{Seeds: []string{"Validate"}, Focus: []string{"missing-contract-anchor"}, Depth: 1, MaxNodes: 2}); err == nil { + t.Fatal("missing focus accepted") + } +} + +func TestContractFocusPathsRetainUncertaintyAndDisconnectedNodes(t *testing.T) { + ix := &index{funcs: map[string]*function{}} + r := Report{Nodes: []Node{{ID: "a", Name: "A"}, {ID: "b", Name: "B"}, {ID: "c", Name: "C"}, {ID: "z", Name: "Z"}}, Relationships: []Relationship{ + {From: "a", To: "b", Kind: "call", Certainty: "fact", Evidence: Evidence{File: "main.go", Line: 10}}, + {From: "b", To: "c", Kind: "possible_interface_call", Certainty: "possible", Evidence: Evidence{File: "main.go", Line: 20}}, + }} + for _, n := range r.Nodes { + ix.funcs[n.ID] = &function{node: n} + } + if err := ix.applyFocus(&r, []string{"C", "c"}); err != nil { + t.Fatal(err) + } + if len(r.Focus) != 1 { + t.Fatal("duplicate focus anchors were not canonicalized") + } + if r.Nodes[0].Relevance == nil || r.Nodes[0].Relevance.Distance != 2 || r.Nodes[0].Relevance.Via.To != "b" { + t.Fatal("two-hop focus path missing") + } + if r.Nodes[1].Relevance == nil || r.Nodes[1].Relevance.Distance != 1 || r.Nodes[1].Relevance.Via.Certainty != "possible" || r.Nodes[1].Relevance.Via.Evidence.Line != 20 { + t.Fatal("possible edge lost uncertainty or source evidence") + } + if r.Nodes[3].Relevance != nil { + t.Fatal("disconnected node received an invented path") + } + ix.funcs["other"] = &function{node: Node{ID: "other", Name: "C"}} + if err := ix.applyFocus(&r, []string{"C"}); err == nil { + t.Fatal("ambiguous focus name accepted") + } +} diff --git a/format_arguments.go b/format_arguments.go new file mode 100644 index 0000000..b3034e4 --- /dev/null +++ b/format_arguments.go @@ -0,0 +1,195 @@ +package contracttrace + +import ( + "go/constant" + "go/types" + "strconv" + "strings" + "unicode/utf8" + + "golang.org/x/tools/go/ssa" +) + +// Follow fmt's argument counter, including index resets and star operands. +// Malformed/missing operands fall back to conservative candidates. +func wrappingArguments(format string, count int) ([]int, bool) { + next := 0 + var selected []int + for i := 0; i < len(format); { + if format[i] != '%' { + i++ + continue + } + i++ + for i < len(format) && strings.ContainsRune("#0+- ", rune(format[i])) { + i++ + } + index := func() (bool, bool) { + if i == len(format) || format[i] != '[' { + return false, true + } + i++ + start := i + for i < len(format) && format[i] >= '0' && format[i] <= '9' { + i++ + } + if start == i || i == len(format) || format[i] != ']' { + return true, false + } + number, err := strconv.Atoi(format[start:i]) + i++ + if err != nil || number < 1 || number > count { + return true, false + } + next = number - 1 + return true, true + } + afterIndex, ok := index() + if !ok { + return nil, true + } + if i < len(format) && format[i] == '*' { + if next >= count { + return nil, true + } + next++ + i++ + afterIndex = false + } else { + start := i + for i < len(format) && format[i] >= '0' && format[i] <= '9' { + i++ + } + if afterIndex && i > start { + return nil, true + } + } + if i < len(format) && format[i] == '.' { + if afterIndex { + return nil, true + } + i++ + afterIndex, ok = index() + if !ok { + return nil, true + } + if i < len(format) && format[i] == '*' { + if next >= count { + return nil, true + } + next++ + i++ + afterIndex = false + } else { + for i < len(format) && format[i] >= '0' && format[i] <= '9' { + i++ + } + } + } + if !afterIndex { + _, ok = index() + if !ok { + return nil, true + } + } + if i == len(format) { + return nil, true + } + verb, size := utf8.DecodeRuneInString(format[i:]) + i += size + if verb == '%' { + continue + } + if next >= count { + return nil, true + } + if verb == 'w' { + selected = append(selected, next) + } + next++ + } + return selected, false +} + +// Only recover positions for a closed literal backing array with one slice +// passed to this call, constant indices and at most one write per element. +// Other aliases/writes keep the unordered slice model instead of guessed slots. +func literalFormatArguments(call *ssa.Call) ([]ssa.Value, bool) { + value := call.Common().Args[1] + if nilValue, ok := value.(*ssa.Const); ok && nilValue.IsNil() { + return nil, true + } + slice, ok := value.(*ssa.Slice) + if !ok || slice.Low != nil || slice.High != nil || slice.Max != nil { + return nil, false + } + array, ok := slice.X.(*ssa.Alloc) + if !ok { + return nil, false + } + pointer, ok := array.Type().Underlying().(*types.Pointer) + if !ok { + return nil, false + } + typ, ok := pointer.Elem().Underlying().(*types.Array) + if !ok || typ.Len() > 4096 { + return nil, false + } + values := make([]ssa.Value, typ.Len()) + written := make([]bool, typ.Len()) + // Scan the call block once rather than once for every literal operand. + beforeCall := make(map[ssa.Instruction]bool) + for _, instruction := range call.Block().Instrs { + if instruction == call { + break + } + beforeCall[instruction] = true + } + if refs := slice.Referrers(); refs != nil { + for _, ref := range *refs { + if _, debug := ref.(*ssa.DebugRef); !debug && ref != call { + return nil, false + } + } + } + if refs := array.Referrers(); refs != nil { + for _, ref := range *refs { + if _, debug := ref.(*ssa.DebugRef); debug || ref == slice { + continue + } + address, ok := ref.(*ssa.IndexAddr) + if !ok { + return nil, false + } + position, ok := address.Index.(*ssa.Const) + if !ok || position.Value == nil || position.Value.Kind() != constant.Int { + return nil, false + } + n, ok := constant.Int64Val(position.Value) + if !ok || n < 0 || n >= typ.Len() { + return nil, false + } + if uses := address.Referrers(); uses != nil { + for _, use := range *uses { + if _, debug := use.(*ssa.DebugRef); debug { + continue + } + store, ok := use.(*ssa.Store) + if !ok || store.Addr != address || written[n] { + return nil, false + } + if store.Block() != call.Block() { + if !store.Block().Dominates(call.Block()) { + return nil, false + } + } else if !beforeCall[store] { + return nil, false + } + written[n] = true + values[n] = store.Val + } + } + } + } + return values, true +} diff --git a/format_arguments_test.go b/format_arguments_test.go new file mode 100644 index 0000000..79a95c6 --- /dev/null +++ b/format_arguments_test.go @@ -0,0 +1,49 @@ +package contracttrace + +import ( + "errors" + "fmt" + "testing" +) + +// The runtime's Unwrap behavior is the oracle, independent of our parser. +func TestWrappingArgumentsMatchRuntime(t *testing.T) { + first, second := errors.New("first"), errors.New("second") + cases := []struct { + format string + args []any + }{ + {"%v %w", []any{first, second}}, + {"%[2]w %[1]v", []any{first, second}}, + {"%w %w", []any{first, second}}, + {"%[2]w %[2]w", []any{first, second}}, + {"%%w %v", []any{first}}, + {"%*w %v", []any{8, first, second}}, + {"%.*w %v", []any{3, first, second}}, + {"%[3]*.[2]*[1]w %[4]v", []any{first, 3, 8, second}}, + {"%[2]v %w", []any{first, second, first}}, + {"%+10.3w %v", []any{first, second}}, + {"%*% %w", []any{8, first}}, + {"%.[2]*[1]w", []any{first, 3}}, + {"%v", []any{first}}, + {"literal", nil}, + } + for _, test := range cases { + t.Run(test.format, func(t *testing.T) { + positions, unresolved := wrappingArguments(test.format, len(test.args)) + if unresolved { + t.Fatal("valid format was unresolved") + } + wrapped := fmt.Errorf(test.format, test.args...) + for _, candidate := range []error{first, second} { + selected := false + for _, position := range positions { + selected = selected || test.args[position] == candidate + } + if selected != errors.Is(wrapped, candidate) { + t.Errorf("%v: selected=%v, runtime=%v", candidate, selected, errors.Is(wrapped, candidate)) + } + } + }) + } +} diff --git a/function_iteration_test.go b/function_iteration_test.go new file mode 100644 index 0000000..1aca638 --- /dev/null +++ b/function_iteration_test.go @@ -0,0 +1,62 @@ +package contracttrace + +import ( + "context" + "strings" + "testing" +) + +func TestFunctionIterationRetainsYieldedCallbacks(t *testing.T) { + for _, example := range []struct{ caller, callback, table string }{ + {"FunctionIterationCaller", "functionIterationQuery", "function_iteration_records"}, + {"SliceFunctionIterationCaller", "sliceFunctionIterationQuery", "slice_function_iteration_records"}, + {"SliceAllIterationCaller", "sliceAllIterationQuery", "slice_all_iteration_records"}, + {"SliceBackwardIterationCaller", "sliceBackwardIterationQuery", "slice_backward_iteration_records"}, + {"MapValuesIterationCaller", "mapValuesIterationQuery", "map_values_iteration_records"}, + {"MapKeysIterationCaller", "mapKeysIterationQuery", "map_keys_iteration_records"}, + {"MapAllIterationCaller", "mapAllIterationQuery", "map_all_iteration_records"}, + } { + t.Run(example.caller, func(t *testing.T) { + r, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{example.caller}, Depth: 4, MaxNodes: 200, + Config: Config{StorageScopes: []StorageScope{{Namespace: "orders", DatabaseOrigins: []string{"example.com/sample::OpenOrdersDatabase"}}}}}) + if err != nil { + t.Fatal(err) + } + called := false + tables := map[string]bool{} + for _, edge := range r.Relationships { + called = called || strings.HasSuffix(edge.From, "::"+example.caller) && strings.HasSuffix(edge.To, "::"+example.callback) && edge.Kind == "resolved_callback_call" + if strings.HasSuffix(edge.From, "::"+example.callback) && edge.Kind == "sql_read" { + tables[edge.To] = true + } + } + if !called || len(tables) != 1 || !tables["table:orders:"+example.table] { + t.Fatalf("yielded callback lost: called=%t tables=%v", called, tables) + } + }) + } +} + +func TestReturnedIteratorRetainsCallbackEscape(t *testing.T) { + for _, example := range []struct{ caller, factory, callback, table string }{ + {"PublicIteratorCaller", "PublicSliceIterator", "publicIteratorQuery", "public_iterator_records"}, + {"RecordIteratorCaller", "PublicIteratorRecord", "recordIteratorQuery", "record_iterator_records"}, + } { + r, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{example.caller}, Depth: 4, MaxNodes: 200, + Config: Config{StorageScopes: []StorageScope{{Namespace: "orders", DatabaseOrigins: []string{"example.com/sample::OpenOrdersDatabase"}}}}}) + if err != nil { + t.Fatal(err) + } + tables := map[string]bool{} + escaped := false + for _, edge := range r.Relationships { + if strings.HasSuffix(edge.From, "::"+example.callback) && edge.Kind == "sql_read" { + tables[edge.To] = true + } + escaped = escaped || edge.Kind == "callback_return_escape" && strings.HasSuffix(edge.From, "::"+example.factory) && strings.HasSuffix(edge.To, "::"+example.callback) + } + if !escaped || len(tables) != 2 || !tables["table:orders:"+example.table] || !tables["table:"+example.table] { + t.Fatalf("returned iterator lost outside callback input: escaped=%t tables=%v", escaped, tables) + } + } +} diff --git a/function_nil_mixed_consumers_test.go b/function_nil_mixed_consumers_test.go new file mode 100644 index 0000000..64d09ae --- /dev/null +++ b/function_nil_mixed_consumers_test.go @@ -0,0 +1,166 @@ +package contracttrace + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestCallbackConsumersExplainNilAndOutsideAlternativesTogether(t *testing.T) { + root := t.TempDir() + files := map[string]string{ + "go.mod": "module example.com/mixedcallbacks\n\ngo 1.27.0\nrequire example.com/callbackapi v0.0.0\nreplace example.com/callbackapi => ./api\n", + "api/go.mod": "module example.com/callbackapi\n\ngo 1.27.0\n", + "api/api.go": `package callbackapi +func Subscribe(string, func()) {} +type Lease struct{} +func (*Lease) Close() {} +`, + "client.go": `package mixedcallbacks +import "example.com/callbackapi" + +func knownTask() {} +func invoke(fn func()) { fn() } + +func EventKnown() { callbackapi.Subscribe("event-known", knownTask) } +func EventNilOnly() { var callback func(); callbackapi.Subscribe("event-nil-only", callback) } +func EventKnownNil(flag bool) { + callback := knownTask + if flag { callback = nil } + callbackapi.Subscribe("event-known-nil", callback) +} +func EventKnownOutside(outside func(), flag bool) { + callback := knownTask + if flag { callback = outside } + callbackapi.Subscribe("event-known-outside", callback) +} +func EventKnownNilOutside(outside func(), nilFlag, outsideFlag bool) { + callback := knownTask + if nilFlag { callback = nil } + if outsideFlag { callback = outside } + callbackapi.Subscribe("event-known-nil-outside", callback) +} + +func BoundKnown() { callback := (&callbackapi.Lease{}).Close; callback() } +func BoundNilOnly() { var callback func(); callback() } +func BoundKnownNil(flag bool) { + callback := (&callbackapi.Lease{}).Close + if flag { callback = nil } + callback() +} +func BoundKnownOutside(outside func(), flag bool) { + callback := (&callbackapi.Lease{}).Close + if flag { callback = outside } + callback() +} +func BoundKnownNilOutside(outside func(), nilFlag, outsideFlag bool) { + callback := (&callbackapi.Lease{}).Close + if nilFlag { callback = nil } + if outsideFlag { callback = outside } + callback() +} +`, + } + for name, content := range files { + path := filepath.Join(root, name) + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0600); err != nil { + t.Fatal(err) + } + } + one := 1 + config := DefaultConfig() + config.CallRules = []CallRule{{Symbol: "example.com/callbackapi::Subscribe", Kind: "event_subscribe", Argument: 0, HandlerArgument: &one, Namespace: "notifications"}} + config.LifecycleRules = []LifecycleRule{{Symbol: "example.com/callbackapi::Lease.Close", Role: "release", Namespace: "leases", Identity: "origin", Receiver: true}} + functions := []string{ + "EventKnown", "EventNilOnly", "EventKnownNil", "EventKnownOutside", "EventKnownNilOutside", + "BoundKnown", "BoundNilOnly", "BoundKnownNil", "BoundKnownOutside", "BoundKnownNilOutside", + } + report, analysis, err := TraceWithAnalysis(context.Background(), Options{Root: root, Seeds: functions, Depth: 6, MaxNodes: 200, Config: config}) + if err != nil { + t.Fatal(err) + } + boundaries := func(owner, kind string) []Boundary { + var matches []Boundary + for _, boundary := range report.Boundaries { + if boundary.Node == "example.com/mixedcallbacks::"+owner && boundary.Kind == kind { + matches = append(matches, boundary) + } + } + return matches + } + assertBoundary := func(owner, kind string, wantNil, wantOutside bool) { + t.Helper() + matches := boundaries(owner, kind) + if len(matches) == 0 { + t.Fatalf("%s: missing %s boundary", owner, kind) + } + for _, boundary := range matches { + if strings.Contains(boundary.Reason, "typed nil") != wantNil { + t.Fatalf("%s: nil explanation mismatch: %+v", owner, boundary) + } + if strings.Contains(boundary.Reason, "outside") != wantOutside { + t.Fatalf("%s: outside explanation mismatch: %+v", owner, boundary) + } + } + } + assertNoBoundary := func(owner, kind string) { + t.Helper() + if found := boundaries(owner, kind); len(found) != 0 { + t.Fatalf("%s: unexpected %s boundary: %+v", owner, kind, found) + } + } + assertBoundary("EventNilOnly", "unresolved_handler", true, false) + assertBoundary("EventKnownNil", "unresolved_handler", true, false) + assertBoundary("EventKnownOutside", "unresolved_handler", false, true) + assertBoundary("EventKnownNilOutside", "unresolved_handler", true, true) + assertNoBoundary("EventKnown", "unresolved_handler") + + assertBoundary("BoundNilOnly", "unresolved_function_call", true, false) + assertBoundary("BoundKnownNil", "unresolved_lifecycle_dispatch", true, false) + assertBoundary("BoundKnownOutside", "unresolved_lifecycle_dispatch", false, true) + assertBoundary("BoundKnownNilOutside", "unresolved_lifecycle_dispatch", true, true) + assertNoBoundary("BoundKnown", "unresolved_lifecycle_dispatch") + + eventTopics := map[string]string{ + "EventNilOnly": "event-nil-only", + "EventKnown": "event-known", "EventKnownNil": "event-known-nil", + "EventKnownOutside": "event-known-outside", "EventKnownNilOutside": "event-known-nil-outside", + } + knownEvents := map[string]bool{} + knownReleases := map[string]bool{} + for owner := range eventTopics { + knownEvents[owner] = false + } + for _, owner := range []string{"BoundNilOnly", "BoundKnown", "BoundKnownNil", "BoundKnownOutside", "BoundKnownNilOutside"} { + knownReleases[owner] = false + } + for _, edge := range analysis.Relationships { + for owner, topic := range eventTopics { + knownEvents[owner] = knownEvents[owner] || edge.From == "example.com/mixedcallbacks::knownTask" && edge.To == "event:notifications:"+topic && edge.Kind == "event_handler" + } + for _, owner := range []string{"BoundKnown", "BoundKnownNil", "BoundKnownOutside", "BoundKnownNilOutside"} { + knownReleases[owner] = knownReleases[owner] || edge.From == "example.com/mixedcallbacks::"+owner && strings.HasPrefix(edge.To, "lifecycle:leases:") && edge.Kind == "lifecycle_release" && strings.Contains(edge.Evidence.Snippet, "callback()") + } + } + for _, owner := range []string{"EventKnown", "EventKnownNil", "EventKnownOutside", "EventKnownNilOutside"} { + if !knownEvents[owner] { + t.Fatalf("known callback edge was lost at %s", owner) + } + } + if knownEvents["EventNilOnly"] { + t.Fatal("nil-only event handler gained a known callback edge") + } + for _, owner := range []string{"BoundKnown", "BoundKnownNil", "BoundKnownOutside", "BoundKnownNilOutside"} { + if !knownReleases[owner] { + t.Fatalf("known bound Lease.Close edge was lost at %s", owner) + } + } + if knownReleases["BoundNilOnly"] { + t.Fatal("nil-only bound callback gained a lifecycle release edge") + } +} diff --git a/function_nil_test.go b/function_nil_test.go new file mode 100644 index 0000000..174bc96 --- /dev/null +++ b/function_nil_test.go @@ -0,0 +1,53 @@ +package contracttrace + +import ( + "go/types" + "testing" + + "golang.org/x/tools/go/ssa" +) + +func TestTypedFunctionNilFlowIsDistinctAndSurvivesMergeAndSnapshot(t *testing.T) { + functionType := types.NewSignatureType(nil, nil, nil, types.NewTuple(), types.NewTuple(), false) + nilFunction := ssa.NewConst(nil, functionType) + analysis := &flowAnalysis{} + read := analysis.get(nilFunction) + if !read.functionNil || read.functionUnknown || len(read.functions) != 0 { + t.Fatalf("typed nil was not kept as a distinct function alternative: %+v", read) + } + + known := new(ssa.Function) + combined := emptyFlow() + analysis.merge(&combined, read) + knownFlow := emptyFlow() + knownFlow.functions[known] = true + analysis.merge(&combined, knownFlow) + if !combined.functionNil || combined.functionUnknown || !combined.functions[known] { + t.Fatalf("known callback and nil alternatives were not preserved separately: %+v", combined) + } + + // A nil strings map triggers lazy initialization in merge. That path must + // preserve the preexisting nil metadata instead of silently clearing it. + preseeded := flowValue{functionNil: true} + analysis.merge(&preseeded, emptyFlow()) + if !preseeded.functionNil { + t.Fatal("lazy flow initialization erased preexisting typed nil metadata") + } + + snapshot := snapshotFlow(combined) + if !snapshot.functionNil || snapshot.functionUnknown || !snapshot.functions[known] { + t.Fatalf("flow snapshot lost distinct known/nil alternatives: %+v", snapshot) + } + summaries := flowSummary(combined, &index{}) + foundNil := false + for _, summary := range summaries { + foundNil = foundNil || summary == "unresolved:nil_function_value" + } + if !foundNil { + t.Fatalf("flow summary hid explicit nil function uncertainty: %v", summaries) + } + delete(snapshot.functions, known) + if !combined.functions[known] { + t.Fatal("mutating snapshot changed stored known callback candidates") + } +} diff --git a/generic_context_site_test.go b/generic_context_site_test.go new file mode 100644 index 0000000..edcbbd3 --- /dev/null +++ b/generic_context_site_test.go @@ -0,0 +1,278 @@ +package contracttrace + +import ( + "bytes" + "context" + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestGenericContextSiteKeepsEveryParentCandidate(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example.com/genericcontextsites\n\ngo 1.27.0\n"), 0600); err != nil { + t.Fatal(err) + } + source := `package app + +import "context" + +func withValue[T any](parent context.Context, value T) context.Context { + return context.WithValue(parent, "key", value) +} + +func withCancel[T any](parent context.Context, value T) (context.Context, context.CancelFunc) { + return context.WithCancel(parent) +} + +func withSchedulerCancel[T any](parent context.Context, value T) (context.Context, context.CancelFunc) { + return context.WithCancel(parent) +} + +type schedulingContext struct { + context.Context + ready, stopped chan struct{} + pending func() +} + +func (ctx *schedulingContext) Done() <-chan struct{} { return ctx.ready } +func (ctx *schedulingContext) AfterFunc(callback func()) func() bool { + ctx.pending = callback + return ctx.stop +} +func (ctx *schedulingContext) stop() bool { close(ctx.stopped); return true } +func (ctx *schedulingContext) fire() { ctx.pending() } + +func Observe(ctx context.Context) { + <-ctx.Done() +} + +func Scenario() { + background := context.Background() + cancelable, cancel := context.WithCancel(background) + defer cancel() + Observe(withValue(cancelable, 1)) + Observe(withValue(context.Background(), "value")) + parent := context.Background() + _, cancelOne := withCancel(cancelable, 1) + _, cancelTwo := withCancel(parent, "value") + cancelOne() + cancelTwo() + firstScheduler := &schedulingContext{Context: context.Background(), ready: make(chan struct{}), stopped: make(chan struct{})} + _, cancelFirstScheduler := withSchedulerCancel(firstScheduler, 1) + _, cancelSecondScheduler := withSchedulerCancel(context.Background(), "value") + firstScheduler.fire() + cancelFirstScheduler() + cancelSecondScheduler() +} +` + if err := os.WriteFile(filepath.Join(root, "app.go"), []byte(source), 0600); err != nil { + t.Fatal(err) + } + + options := Options{Root: root, Seeds: []string{"Scenario"}, Depth: 4, MaxNodes: 180} + report, analysis, err := TraceWithAnalysis(context.Background(), options) + if err != nil { + t.Fatal(err) + } + + lineOf := func(fragment string) int { + t.Helper() + position := strings.Index(source, fragment) + if position < 0 { + t.Fatalf("fixture does not contain %q", fragment) + } + return strings.Count(source[:position], "\n") + 1 + } + contextAt := func(fragment string) string { + t.Helper() + line := lineOf(fragment) + for _, node := range report.Nodes { + if node.Kind == "context" && node.Evidence.File == "app.go" && node.Evidence.Line == line { + return node.ID + } + } + t.Fatalf("no context node for %q at app.go:%d", fragment, line) + return "" + } + + withValueLine := lineOf("return context.WithValue") + observeLine := lineOf("<-ctx.Done()") + contextID := "" + for _, node := range report.Nodes { + if node.Kind == "context" && node.Evidence.File == "app.go" && node.Evidence.Line == withValueLine { + contextID = node.ID + break + } + } + if contextID == "" { + t.Fatalf("generic WithValue source site has no context node at app.go:%d", withValueLine) + } + outer := contextAt("cancelable, cancel := context.WithCancel(background)") + backgroundParent := contextAt("Observe(withValue(context.Background(), \"value\"))") + for _, parentID := range []string{outer, backgroundParent} { + if !hasRelationship(report, contextID, parentID, "context_cancellation_parent", "app.go", withValueLine) { + t.Errorf("generic WithValue source site lost parent context candidate %s", parentID) + } + } + signal := "" + for _, edge := range report.Relationships { + if edge.From == outer && edge.Kind == "context_done_signal" { + signal = edge.To + } + } + if signal == "" { + t.Errorf("generic WithValue source site lost its cancelable parent's Done signal candidate (%s)", outer) + } else if !hasRelationship(report, "example.com/genericcontextsites::Observe", signal, "channel_receive", "app.go", observeLine) { + t.Errorf("generic WithValue result was not connected to the cancelable parent's observed Done channel %s", signal) + } + if !hasBoundaryAt(report, "example.com/genericcontextsites::Observe", "nil_context_done_wait", "app.go", observeLine) { + t.Errorf("generic WithValue source site lost its Background parent's nil Done candidate at app.go:%d", observeLine) + } + + withCancelLine := lineOf("return context.WithCancel(parent)") + withCancelID := "" + for _, node := range report.Nodes { + if node.Kind == "context" && node.Evidence.File == "app.go" && node.Evidence.Line == withCancelLine { + withCancelID = node.ID + break + } + } + if withCancelID == "" { + t.Fatalf("generic WithCancel source site has no context node at app.go:%d", withCancelLine) + } + parent := contextAt("parent := context.Background()") + for _, parentID := range []string{outer, parent} { + if !hasRelationship(report, withCancelID, parentID, "context_cancellation_parent", "app.go", withCancelLine) { + t.Errorf("generic WithCancel source site lost parent candidate %s", parentID) + } + } + + // This generic constructor source site receives one scheduler-aware context + // and one Background context. The scheduler body's cancellation edge shows + // that the aggregated source-site reader retained the scheduler parent. + schedulerCancelLine := lineOf("func withSchedulerCancel") + 1 + var schedulerContextID string + for _, node := range report.Nodes { + if node.Kind == "context" && node.Evidence.File == "app.go" && node.Evidence.Line == schedulerCancelLine { + schedulerContextID = node.ID + } + } + if schedulerContextID == "" { + t.Fatalf("generic scheduler-aware cancelable site has no context node at app.go:%d", schedulerCancelLine) + } + fireLine := lineOf("func (ctx *schedulingContext) fire()") + if !hasRelationship(report, "example.com/genericcontextsites::schedulingContext.fire", schedulerContextID, "context_cancel", "app.go", fireLine) { + t.Errorf("aggregated generic cancelable parent lost the scheduler-body cancellation candidate %s", schedulerContextID) + } + schedulerBackgroundParent := contextAt("_, cancelSecondScheduler") + if !hasRelationship(report, schedulerContextID, schedulerBackgroundParent, "context_cancellation_parent", "app.go", schedulerCancelLine) { + t.Errorf("generic scheduler-aware source site lost its Background parent candidate %s", schedulerBackgroundParent) + } + if !hasBoundaryAt(report, "example.com/genericcontextsites::withSchedulerCancel", "context_parent", "app.go", schedulerCancelLine) { + t.Errorf("generic scheduler-aware source site lost the unresolved custom-parent identity boundary") + } + if !hasRelationship(report, schedulerContextID, "example.com/genericcontextsites::schedulingContext.stop", "context_scheduler_stop_target", "app.go", schedulerCancelLine) { + t.Errorf("generic cancelable source site lost the custom scheduler stop target") + } + + var compressed bytes.Buffer + if err := WriteAnalysis(&compressed, analysis, true); err != nil { + t.Fatal(err) + } + loaded, err := ReadAnalysis(bytes.NewReader(compressed.Bytes())) + if err != nil { + t.Fatal(err) + } + resumed, err := Explore(context.Background(), loaded, ExploreOptions{Seeds: options.Seeds, Depth: options.Depth, MaxNodes: options.MaxNodes}) + if err != nil { + t.Fatal(err) + } + if !hasRelationship(resumed, contextID, outer, "context_cancellation_parent", "app.go", withValueLine) || + !hasRelationship(resumed, contextID, backgroundParent, "context_cancellation_parent", "app.go", withValueLine) || + !hasBoundaryAt(resumed, "example.com/genericcontextsites::Observe", "nil_context_done_wait", "app.go", observeLine) || + !hasRelationship(resumed, "example.com/genericcontextsites::schedulingContext.fire", schedulerContextID, "context_cancel", "app.go", fireLine) { + t.Errorf("gzip-resumed analysis lost generic context parent, nil-Done, or scheduler candidates") + } +} + +func TestGenericWithoutCancelWideningKeepsKnownNilDone(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example.com/genericcontextwidening\n\ngo 1.27.0\n"), 0600); err != nil { + t.Fatal(err) + } + var source strings.Builder + source.WriteString("package app\n\nimport \"context\"\n\n") + for i := 0; i <= maxFlowValues; i++ { + fmt.Fprintf(&source, "type token%d struct{}\n", i) + } + source.WriteString(` +func detach[T any](parent context.Context, value T) { + detached := context.WithoutCancel(parent) + <-detached.Done() +} + +func inherit[T any](parent context.Context, value T) { + inherited := context.WithValue(parent, "key", value) + <-inherited.Done() +} + +func Scenario() { +`) + for i := 0; i <= maxFlowValues; i++ { + fmt.Fprintf(&source, "\tparent%d := context.Background()\n\tdetach(parent%d, token%d{})\n\tinherit(parent%d, token%d{})\n", i, i, i, i, i) + } + source.WriteString("}\n") + if err := os.WriteFile(filepath.Join(root, "app.go"), []byte(source.String()), 0600); err != nil { + t.Fatal(err) + } + + report, err := Trace(context.Background(), Options{Root: root, Seeds: []string{"Scenario"}, Depth: 4, MaxNodes: 600}) + if err != nil { + t.Fatal(err) + } + detachPos := strings.Index(source.String(), "detached := context.WithoutCancel") + detachLine := strings.Count(source.String()[:detachPos], "\n") + 1 + detachReceivePos := strings.Index(source.String(), "<-detached.Done()") + detachReceiveLine := strings.Count(source.String()[:detachReceivePos], "\n") + 1 + inheritPos := strings.Index(source.String(), "inherited := context.WithValue") + inheritLine := strings.Count(source.String()[:inheritPos], "\n") + 1 + inheritReceivePos := strings.Index(source.String(), "<-inherited.Done()") + inheritReceiveLine := strings.Count(source.String()[:inheritReceivePos], "\n") + 1 + parentWidenedAt := func(owner string, line int) bool { + for _, boundary := range report.Boundaries { + if boundary.Node == owner && boundary.Kind == "context_parent" && boundary.Evidence.File == "app.go" && boundary.Evidence.Line == line && strings.Contains(boundary.Reason, "widened") { + return true + } + } + return false + } + if !parentWidenedAt("example.com/genericcontextwidening::detach", detachLine) { + t.Errorf("bounded generic parent candidates were not reported at detach source line %d", detachLine) + } + if !hasBoundaryAt(report, "example.com/genericcontextwidening::detach", "nil_context_done_wait", "app.go", detachReceiveLine) { + t.Errorf("WithoutCancel lost its known nil Done candidate after parent widening at line %d", detachReceiveLine) + } + if !parentWidenedAt("example.com/genericcontextwidening::inherit", inheritLine) || + !hasBoundaryAt(report, "example.com/genericcontextwidening::inherit", "nil_context_done_wait", "app.go", inheritReceiveLine) { + t.Errorf("WithValue lost its bounded parent uncertainty or nil Done candidate at lines %d/%d", inheritLine, inheritReceiveLine) + } + if hasBoundaryAt(report, "example.com/genericcontextwidening::detach", "unresolved_channel", "app.go", detachReceiveLine) { + for _, boundary := range report.Boundaries { + if boundary.Node == "example.com/genericcontextwidening::detach" && boundary.Kind == "unresolved_channel" && boundary.Evidence.File == "app.go" && boundary.Evidence.Line == detachReceiveLine { + t.Logf("detached unresolved boundary: %+v", boundary) + } + } + t.Errorf("parent widening incorrectly made the known WithoutCancel Done result unresolved") + } + if !hasBoundaryAt(report, "example.com/genericcontextwidening::inherit", "unresolved_channel", "app.go", inheritReceiveLine) { + t.Errorf("WithValue parent widening did not retain unresolved Done alternatives") + } + for _, edge := range report.Relationships { + if edge.Kind == "context_done_signal" && edge.Evidence.File == "app.go" && edge.Evidence.Line == detachLine { + t.Errorf("WithoutCancel inherited a parent Done signal despite parent widening: %+v", edge) + } + } +} diff --git a/global_call_escape_test.go b/global_call_escape_test.go new file mode 100644 index 0000000..3c58204 --- /dev/null +++ b/global_call_escape_test.go @@ -0,0 +1,120 @@ +package contracttrace + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestGlobalAddressesPassedToExternalCallsRemainCandidates(t *testing.T) { + base := t.TempDir() + root := filepath.Join(base, "client") + if err := os.Mkdir(root, 0755); err != nil { + t.Fatal(err) + } + files := map[string]string{ + "go.mod": "module example.com/client\n\ngo 1.27.0\n\nrequire example.com/external v0.0.0\nreplace example.com/external => ../external\n", + "client.go": `package client +import "example.com/external" + +var record external.Record +var values [1]int +var unrelated int + +func Seed() { + external.TakeRecord(&record) + field := &record.Value + external.TakeInt(field) + external.TakeInt(&values[0]) + forward(&record.Value) + defer external.TakeInt(&record.Value) + go external.TakeInt(&values[0]) + record.Value = 1 + _ = record.Value +} + +func forward(value *int) { external.TakeInt(value) } +`, + "../external/go.mod": "module example.com/external\n\ngo 1.27.0\n", + "../external/api.go": `package external +type Record struct { Value int } +func TakeRecord(*Record) {} +func TakeInt(*int) {} +`, + } + for name, contents := range files { + path := filepath.Join(root, name) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(contents), 0644); err != nil { + t.Fatal(err) + } + } + report, err := Trace(context.Background(), Options{Root: root, Seeds: []string{"Seed"}, Depth: 4, MaxNodes: 100}) + if err != nil { + t.Fatal(err) + } + wants := []struct { + global, snippet string + }{ + {"record", "external.TakeRecord(&record)"}, + {"record", "external.TakeInt(field)"}, + {"values", "external.TakeInt(&values[0])"}, + {"record", "defer external.TakeInt(&record.Value)"}, + {"values", "go external.TakeInt(&values[0])"}, + {"record", "external.TakeInt(value)"}, + } + for _, want := range wants { + global := "global:example.com/client::" + want.global + found := false + for _, edge := range report.Relationships { + if edge.From == "example.com/client::Seed" || edge.From == "example.com/client::forward" { + found = found || edge.To == global && edge.Kind == "global_escape" && edge.Certainty == "possible" && strings.Contains(edge.Evidence.Snippet, want.snippet) + } + } + if !found { + t.Errorf("missing possible global escape to %s at %q", global, want.snippet) + } + } + helperCall, helperArgument := false, false + var helperEdges []Relationship + for _, edge := range report.Relationships { + if edge.From != "example.com/client::Seed" || edge.To != "example.com/client::forward" { + continue + } + helperEdges = append(helperEdges, edge) + helperCall = helperCall || edge.Kind == "call" && strings.Contains(edge.Evidence.Snippet, "forward(&record.Value)") + helperArgument = helperArgument || edge.Kind == "argument_flow" && strings.Contains(edge.Evidence.Snippet, "forward(&record.Value)") && contains(edge.Values, "global:example.com/client.record.field:0") + } + if !helperCall || !helperArgument { + t.Fatalf("local helper path lost the global-backed argument: call=%t argument=%t edges=%+v", helperCall, helperArgument, helperEdges) + } + read, write := false, false + for _, edge := range report.Relationships { + if edge.To != "global:example.com/client::record" || edge.From != "example.com/client::Seed" { + continue + } + read = read || edge.Kind == "global_read" && strings.Contains(edge.Evidence.Snippet, "_ = record.Value") + write = write || edge.Kind == "global_write" && strings.Contains(edge.Evidence.Snippet, "record.Value = 1") + } + if !read || !write { + t.Fatalf("direct global access controls missing: read=%t write=%t", read, write) + } + unrelated := false + for _, edge := range report.Relationships { + unrelated = unrelated || edge.From == "example.com/client::Seed" && edge.To == "global:example.com/client::unrelated" && edge.Kind == "global_escape" + } + if unrelated { + t.Fatal("unpassed global entered the external escape candidates") + } + unresolved := false + for _, boundary := range report.Boundaries { + unresolved = unresolved || (boundary.Kind == "unresolved_global_escape" && boundary.Node == "example.com/client::Seed" && strings.Contains(boundary.Evidence.Snippet, "external.TakeRecord(&record)") && strings.Contains(boundary.Reason, "no specific mutation is established")) + } + if !unresolved { + t.Fatal("possible external global mutation was not disclosed as unresolved") + } +} diff --git a/global_containers_test.go b/global_containers_test.go new file mode 100644 index 0000000..2d38149 --- /dev/null +++ b/global_containers_test.go @@ -0,0 +1,56 @@ +package contracttrace + +import ( + "context" + "encoding/json" + "strings" + "testing" +) + +func TestSharedGlobalContainerAccessSurvivesHelpersAndSavedAnalysis(t *testing.T) { + seeds := []string{"global:example.com/sample::privateMutationMap", "global:example.com/sample::privateMutationSlice"} + fresh, analysis, err := TraceWithAnalysis(context.Background(), Options{Root: "testdata/sample", Seeds: seeds, Depth: 1, MaxNodes: 100}) + if err != nil { + t.Fatal(err) + } + encoded, err := json.Marshal(analysis) + if err != nil { + t.Fatal(err) + } + loaded, err := ReadAnalysis(strings.NewReader(string(encoded))) + if err != nil { + t.Fatal(err) + } + resumed, err := Explore(context.Background(), loaded, ExploreOptions{Seeds: seeds, Depth: 1, MaxNodes: 100}) + if err != nil { + t.Fatal(err) + } + for _, report := range []Report{fresh, resumed} { + for _, want := range []struct{ owner, global, kind, snippet string }{ + {"mutateSharedMap", "privateMutationMap", "global_write", `state["new"] =`}, + {"mutateSharedMap", "privateMutationMap", "global_write", "delete(state"}, + {"mutateSharedMap", "privateMutationMap", "global_write", "clear(state"}, + {"readSharedMap", "privateMutationMap", "global_read", `state["new"]`}, + {"readSharedMap", "privateMutationMap", "global_read", "range state"}, + {"mutateSharedSlice", "privateMutationSlice", "global_write", "state[0] ="}, + {"mutateSharedSlice", "privateMutationSlice", "global_write", "copy(state"}, + {"mutateSharedSlice", "privateMutationSlice", "global_write", "append(state"}, + {"mutateSharedSlice", "privateMutationSlice", "global_write", "clear(state"}, + {"readSharedSlice", "privateMutationSlice", "global_read", "copy(make"}, + {"readSharedSlice", "privateMutationSlice", "global_read", "return state[0]"}, + {"GlobalContainerCaller", "privateMutationSlice", "global_write", "defer copy"}, + {"GlobalContainerCaller", "privateMutationSlice", "global_write", "go copy"}, + } { + found := false + for _, edge := range report.Relationships { + found = found || strings.HasSuffix(edge.From, "::"+want.owner) && edge.To == "global:example.com/sample::"+want.global && edge.Kind == want.kind && strings.Contains(edge.Evidence.Snippet, want.snippet) + } + if !found { + t.Errorf("missing %s %s at %s: %s", want.global, want.kind, want.owner, want.snippet) + } + } + if hasName(report, "mutateUnrelatedMap") { + t.Fatal("unrelated map mutation joined shared state") + } + } +} diff --git a/global_escape.go b/global_escape.go new file mode 100644 index 0000000..f3e90b0 --- /dev/null +++ b/global_escape.go @@ -0,0 +1,226 @@ +package contracttrace + +import ( + "fmt" + "go/token" + "go/types" + "strings" + + "golang.org/x/tools/go/ssa" +) + +func globalID(global *ssa.Global) string { + return "global:" + global.Pkg.Pkg.Path() + "::" + global.Name() +} + +func sqlHandleGlobal(global *ssa.Global) bool { + pointer, ok := global.Type().Underlying().(*types.Pointer) + return ok && isSQLHandleType(pointer.Elem()) +} + +func (a *flowAnalysis) callbackGlobalEscapes(ix *index) bool { + changed := false + for _, global := range a.publicGlobals { + if a.seedAggregateOrigin(global, "input:global:"+global.String(), false, ix.evidence(global.Pos()), ix) { + changed = true + } + cells := map[string]bool{} + functionCells := map[string]bool{} + interfaceCells := map[string]bool{} + outsideCells := map[string]types.Type{} + callbacks := a.accessibleStorageTracked(global, cells, functionCells, interfaceCells, outsideCells, nil, nil) + for _, cell := range sortedKeys(outsideCells) { + if isScalarType(outsideCells[cell]) { + if a.store(cell, a.outsideScalar("global:"+global.String()+":"+cell, globalID(global), ix.evidence(global.Pos()), ix)) { + changed = true + } + continue + } + if isStringType(outsideCells[cell]) { + unknown := emptyFlow() + unknown.stringUnknown = true + if a.store(cell, unknown) { + changed = true + ix.boundaries = append(ix.boundaries, Boundary{Node: globalID(global), Kind: "outside_string_input", Reason: "Outside callers can replace exported string storage or mutate its accessible contents, including through private aliases. Known local strings remain candidates; runtime mutation timing and arbitrary string values are unresolved.", Evidence: ix.evidence(global.Pos())}) + } + continue + } + id := "error-input:global:" + global.String() + ":" + cell + if ix.funcs[id] == nil { + evidence := ix.evidence(global.Pos()) + ix.funcs[id] = &function{node: Node{ID: id, Name: "outside error in " + global.Name(), Kind: "error_input", Evidence: evidence}} + ix.edges = append(ix.edges, Relationship{From: globalID(global), To: id, Kind: "external_input", Certainty: "possible", Evidence: evidence}) + ix.boundaries = append(ix.boundaries, Boundary{Node: id, Kind: "outside_error_input", Reason: "Outside callers can replace exported error storage or mutate accessible error contents. Known local origins remain alongside this candidate; private aliases of shared public objects do not close its origin set. Runtime identity, mutation timing and a non-nil error are not proved.", Evidence: evidence}) + } + unknown := emptyFlow() + unknown.errors = map[string]bool{id: true} + if a.store(cell, unknown) { + changed = true + } + } + for _, cell := range sortedKeys(interfaceCells) { + unknown := emptyFlow() + unknown.interfaceUnknown = true + if a.store(cell, unknown) { + changed = true + } + } + for _, cell := range sortedKeys(functionCells) { + unknown := emptyFlow() + unknown.functionUnknown = true + if a.store(cell, unknown) { + changed = true + } + } + for _, cell := range sortedKeys(cells) { + if a.globalSQLFields == nil { + a.globalSQLFields = map[*ssa.Global]bool{} + } + a.globalSQLFields[global] = true + unknown := emptyFlow() + unknown.sqlUnknown = true + if a.store(cell, unknown) { + changed = true + } + } + for _, callback := range sortedFunctions(callbacks) { + if ix.owner(callback) == "" { + continue + } + if a.globalCallbacks == nil { + a.globalCallbacks = map[*ssa.Global]map[*ssa.Function]bool{} + } + if a.globalCallbacks[global] == nil { + a.globalCallbacks[global] = map[*ssa.Function]bool{} + } + a.globalCallbacks[global][callback] = true + if a.markCallbackInputs(callback, ix) { + changed = true + } + } + } + return changed +} + +func (a *flowAnalysis) globalRelationships(ix *index) { + a.globalAddressOwners = map[string]map[string]bool{} + for _, global := range a.localGlobals { + id := globalID(global) + evidence := ix.evidence(global.Pos()) + ix.funcs[id] = &function{node: Node{ID: id, Name: global.Name(), Kind: "global", Evidence: evidence}} + if span, ok := ix.globalRanges[global.Pos()]; ok { + ix.declarationRanges[id] = span + } + for _, root := range a.recordAddresses(global) { + if a.globalAddressOwners[root] == nil { + a.globalAddressOwners[root] = map[string]bool{} + } + a.globalAddressOwners[root][id] = true + } + if !global.Object().Exported() { + continue + } + a.callableSummaryEscapes(global, id, "_global_escape", evidence, ix) + ix.boundaries = append(ix.boundaries, Boundary{Node: id, Kind: "external_global", Reason: "exported variable and accessible contents can be read or replaced by callers outside the selected build; modeled local values do not close its runtime origin set", Evidence: evidence}) + if sqlHandleGlobal(global) || a.globalSQLFields[global] { + ix.boundaries = append(ix.boundaries, Boundary{Node: id, Kind: "external_sql_global", Reason: "exported SQL handle or reachable public SQL-handle storage can be replaced outside the selected build; known local origins are retained alongside unknown input candidates", Evidence: evidence}) + } + for _, callback := range sortedFunctions(a.globalCallbacks[global]) { + owner := ix.owner(callback) + ix.edges = append(ix.edges, Relationship{From: id, To: owner, Kind: "callback_global_escape", Certainty: "possible", Evidence: evidence}) + ix.boundaries = append(ix.boundaries, Boundary{Node: owner, Kind: "external_callback", Reason: "callback candidate is reachable through an exported variable; outside retention, invocation, inputs and timing remain possible", Evidence: evidence}) + for _, parameter := range callback.Params { + if isSQLHandleType(parameter.Type()) { + ix.boundaries = append(ix.boundaries, Boundary{Node: owner, Kind: "escaped_sql_input", Reason: "SQL-handle callback inputs remain open through an exported variable", Evidence: evidence}) + break + } + } + } + } +} + +func (a *flowAnalysis) globalUse(instruction ssa.Instruction, ix *index) { + link := func(address ssa.Value, kind string) { a.globalAccess(instruction, address, kind, ix) } + switch use := instruction.(type) { + case *ssa.Store: + link(use.Addr, "global_write") + case *ssa.UnOp: + if use.Op == token.MUL { + link(use.X, "global_read") + } + case *ssa.MapUpdate: + link(use.Map, "global_write") + case *ssa.Lookup: + link(use.X, "global_read") + case *ssa.Range: + link(use.X, "global_read") + case *ssa.Index: + link(use.X, "global_read") + case ssa.CallInstruction: + common := use.Common() + if builtin, ok := common.Value.(*ssa.Builtin); ok { + if len(common.Args) == 0 { + return + } + switch builtin.Name() { + case "delete", "clear": + link(common.Args[0], "global_write") + case "copy": + if len(common.Args) == 2 { + link(common.Args[0], "global_write") + link(common.Args[1], "global_read") + } + case "append": + link(common.Args[0], "global_write") + for _, argument := range common.Args { + link(argument, "global_read") + } + case "len", "cap": + link(common.Args[0], "global_read") + } + return + } + if !a.callbackOutside(common, ix) { + return + } + for _, argument := range flowArguments(common) { + link(argument, "global_escape") + } + } +} + +func (a *flowAnalysis) globalAccess(instruction ssa.Instruction, address ssa.Value, kind string, ix *index) { + if address == nil { + return + } + matched := map[string]bool{} + for candidate := range a.get(address).addresses { + // Field/element addresses extend a storage root with dot-separated slots. + // Look up the root and its prefixes instead of rescanning every global. + for { + for id := range a.globalAddressOwners[candidate] { + matched[id] = true + } + separator := strings.LastIndex(candidate, ".") + if separator < 0 { + break + } + candidate = candidate[:separator] + } + } + owner := ix.owner(instruction.Parent()) + if owner == "" { + return + } + for _, id := range sortedKeys(matched) { + evidence := ix.evidence(instruction.Pos()) + if instruction.Pos() == token.NoPos { + evidence = ix.funcs[owner].node.Evidence + evidence.Origin = "synthetic_declaration" + } + ix.edges = append(ix.edges, Relationship{From: owner, To: id, Kind: kind, Certainty: "possible", Evidence: evidence}) + if kind == "global_escape" { + ix.boundaries = append(ix.boundaries, Boundary{Node: owner, Kind: "unresolved_global_escape", Reason: fmt.Sprintf("an outside, dependency or unresolved call receives an address rooted in %s and may retain or mutate it; no specific mutation is established", id), Evidence: evidence}) + } + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d925ce3 --- /dev/null +++ b/go.mod @@ -0,0 +1,13 @@ +module github.com/dominicnunez/contract-tracer + +go 1.27.0 + +require ( + github.com/rqlite/sql v0.0.0-20260224021119-1b2524a41372 + golang.org/x/mod v0.39.0 + golang.org/x/tools v0.49.0 +) + +require ( + golang.org/x/sync v0.22.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..9d7ea5d --- /dev/null +++ b/go.sum @@ -0,0 +1,12 @@ +github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= +github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/rqlite/sql v0.0.0-20260224021119-1b2524a41372 h1:2V0y6mzmPj7vQKad76nTL7sZ/lFLj5VKvNjpa6IRxYE= +github.com/rqlite/sql v0.0.0-20260224021119-1b2524a41372/go.mod h1:ib9zVtNgRKiGuoMyUqqL5aNpk+r+++YlyiVIkclVqPg= +golang.org/x/mod v0.39.0 h1:UF5zwQdCRRUpHfyPwr7d4UrGiVeldIsogtzWVnczL74= +golang.org/x/mod v0.39.0/go.mod h1:bvIbwjQ0HUFFf5AKukeeYQG4ZBUG9yxQbR9aEweIwYY= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= +golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= diff --git a/handler_selector_validation_test.go b/handler_selector_validation_test.go new file mode 100644 index 0000000..b7de275 --- /dev/null +++ b/handler_selector_validation_test.go @@ -0,0 +1,151 @@ +package contracttrace + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestSubscribeHandlerSelectorValidatesWithoutEventCandidate(t *testing.T) { + root := t.TempDir() + files := map[string]string{ + "go.mod": "module example.com/handlerselectors\n\ngo 1.27.0\nrequire example.com/handlerapi v0.0.0\nreplace example.com/handlerapi => ./api\n", + "api/go.mod": "module example.com/handlerapi\n\ngo 1.27.0\n", + "api/api.go": `package handlerapi + +type Bus struct{} + +func (*Bus) Subscribe(string, func(string)) {} +func (*Bus) SubscribeValid(string, func(string)) {} +func (*Bus) SubscribeBoth(string, func(string)) {} +`, + "app.go": `package app + +import "example.com/handlerapi" + +func Handler(string) {} + +func InvalidDynamic(bus *handlerapi.Bus, topic string) { + bus.Subscribe(topic, Handler) +} +func InvalidKnown(bus *handlerapi.Bus) { + bus.Subscribe("orders.known", Handler) +} +func InvalidMultiple(bus *handlerapi.Bus, choose bool) { + topic := "orders.first" + if choose { topic = "orders.second" } + bus.Subscribe(topic, Handler) +} +func InvalidMethodExpression(bus *handlerapi.Bus, topic string) { + (*handlerapi.Bus).Subscribe(bus, topic, Handler) +} +func ValidSubscriber(bus *handlerapi.Bus) { + bus.SubscribeValid("orders.valid", Handler) +} +func InvalidBoth(bus *handlerapi.Bus) { + bus.SubscribeBoth("orders.both", Handler) +} +func InvalidBound(bus *handlerapi.Bus, topic string) { + subscribe := bus.Subscribe + subscribe(topic, Handler) +} +`, + } + for name, contents := range files { + path := filepath.Join(root, name) + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(contents), 0600); err != nil { + t.Fatal(err) + } + } + invalidHandler := 2 + validHandler := 1 + invalidArgument := 2 + invalidBothHandler := 3 + config := DefaultConfig() + config.CallRules = []CallRule{ + {Symbol: "example.com/handlerapi::Bus.Subscribe", Kind: "event_subscribe", Argument: 0, HandlerArgument: &invalidHandler, Namespace: "orders"}, + {Symbol: "example.com/handlerapi::Bus.SubscribeValid", Kind: "event_subscribe", Argument: 0, HandlerArgument: &validHandler, Namespace: "orders"}, + {Symbol: "example.com/handlerapi::Bus.SubscribeBoth", Kind: "event_subscribe", Argument: invalidArgument, HandlerArgument: &invalidBothHandler, Namespace: "orders"}, + } + + report, err := Trace(context.Background(), Options{ + Root: root, + Seeds: []string{ + "InvalidDynamic", "InvalidKnown", "InvalidMultiple", "InvalidMethodExpression", "ValidSubscriber", "InvalidBoth", "InvalidBound", + }, + Depth: 4, MaxNodes: 180, Config: config, + }) + if err != nil { + t.Fatal(err) + } + for _, expected := range []struct { + owner string + line int + snippet string + }{ + {"InvalidDynamic", 8, "bus.Subscribe(topic"}, + {"InvalidKnown", 11, "bus.Subscribe(\"orders.known\""}, + {"InvalidMultiple", 16, "bus.Subscribe(topic"}, + {"InvalidMethodExpression", 19, "(*handlerapi.Bus).Subscribe"}, + {"InvalidBound", 29, "subscribe(topic, Handler)"}, + } { + count := 0 + for _, boundary := range report.Boundaries { + if boundary.Node == "example.com/handlerselectors::"+expected.owner && boundary.Kind == "invalid_rule_site" && + strings.Contains(boundary.Reason, "configured handler") && boundary.Evidence.File == "app.go" && + boundary.Evidence.Line == expected.line && strings.Contains(boundary.Evidence.Snippet, expected.snippet) { + count++ + } + } + if count != 1 { + t.Errorf("%s: invalid handler selector boundaries=%d, want one at app.go:%d", expected.owner, count, expected.line) + } + } + for _, reason := range []string{"configured argument", "configured handler"} { + count := 0 + for _, boundary := range report.Boundaries { + if boundary.Node == "example.com/handlerselectors::InvalidBoth" && boundary.Kind == "invalid_rule_site" && + strings.Contains(boundary.Reason, reason) && boundary.Evidence.File == "app.go" && boundary.Evidence.Line == 25 { + count++ + } + } + if count != 1 { + t.Errorf("combined-invalid site lost exactly one %s boundary: count=%d", reason, count) + } + } + for _, ownerAndLine := range []struct { + owner string + line int + }{ + {"InvalidDynamic", 8}, + {"InvalidMultiple", 16}, + {"InvalidMethodExpression", 19}, + {"InvalidBound", 29}, + } { + if !hasBoundaryAt(report, "example.com/handlerselectors::"+ownerAndLine.owner, "dynamic_api_value", "app.go", ownerAndLine.line) { + t.Errorf("%s: unresolved event input was not retained at the subscription site", ownerAndLine.owner) + } + } + if !hasRelationship(report, "example.com/handlerselectors::InvalidKnown", "event:orders:orders.known", "event_subscribe", "app.go", 11) { + t.Error("invalid handler selector suppressed the independent known subscription event candidate") + } + for _, value := range []string{"orders.first", "orders.second"} { + if !hasRelationship(report, "example.com/handlerselectors::InvalidMultiple", "event:orders:"+value, "event_subscribe", "app.go", 16) { + t.Errorf("multi-candidate subscription lost known event %q", value) + } + } + if !hasRelationship(report, "example.com/handlerselectors::ValidSubscriber", "event:orders:orders.valid", "event_subscribe", "app.go", 22) || + !hasRelationship(report, "example.com/handlerselectors::Handler", "event:orders:orders.valid", "event_handler", "app.go", 22) { + t.Error("valid subscriber rule lost its registration or handler target") + } + for _, boundary := range report.Boundaries { + if boundary.Node == "example.com/handlerselectors::ValidSubscriber" && boundary.Kind == "invalid_rule_site" { + t.Errorf("valid direct subscriber was incorrectly marked as an invalid rule site: %+v", boundary) + } + } +} diff --git a/init_same_line_test.go b/init_same_line_test.go new file mode 100644 index 0000000..401a842 --- /dev/null +++ b/init_same_line_test.go @@ -0,0 +1,127 @@ +package contracttrace + +import ( + "bytes" + "context" + "os" + "path/filepath" + "testing" +) + +func TestSameLineInitializersKeepDistinctOwnersInSnapshots(t *testing.T) { + root := t.TempDir() + write := func(name, contents string) { + t.Helper() + path := filepath.Join(root, name) + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(contents), 0600); err != nil { + t.Fatal(err) + } + } + write("go.mod", "module example.com/samelineinit\n\ngo 1.27.0\nrequire example.com/initapi v0.0.0\nreplace example.com/initapi => ./api\n") + write("api/go.mod", "module example.com/initapi\n\ngo 1.27.0\n") + write("api/api.go", `package initapi +func Publish(string) {} +func Query(string) {} +`) + write("app.go", `package samelineinit +import "example.com/initapi" +func firstBody() { initapi.Publish("first.event") } +func secondBody() { initapi.Publish("second.event") } +func init() { firstBody(); initapi.Publish("first.init"); initapi.Query("SELECT * FROM first_table") }; func init() { secondBody(); initapi.Publish("second.init"); initapi.Query("SELECT * FROM second_table") } +`) + write("ordinary.go", `package samelineinit +import "example.com/initapi" +func ordinaryBody() { initapi.Publish("ordinary.event") } +func init() { ordinaryBody() } +`) + config := DefaultConfig() + config.CallRules = []CallRule{ + {Symbol: "example.com/initapi::Publish", Kind: "event_publish", Argument: 0}, + {Symbol: "example.com/initapi::Query", Kind: "sql_query", Argument: 0, Namespace: "primary"}, + } + fresh, analysis, err := TraceWithAnalysis(context.Background(), Options{ + Root: root, Seeds: []string{"example.com/samelineinit::(package init)"}, Depth: 5, MaxNodes: 150, Config: config, + }) + if err != nil { + t.Fatal(err) + } + var snapshot bytes.Buffer + if err := WriteAnalysis(&snapshot, analysis, true); err != nil { + t.Fatal(err) + } + saved, err := ReadAnalysis(bytes.NewReader(snapshot.Bytes())) + if err != nil { + t.Fatal(err) + } + resumed, err := Explore(context.Background(), saved, ExploreOptions{ + Seeds: []string{"example.com/samelineinit::(package init)"}, Depth: 5, MaxNodes: 150, + }) + if err != nil { + t.Fatal(err) + } + for _, report := range []Report{fresh, resumed} { + owners := map[int]map[string]bool{} + for _, node := range report.Nodes { + if node.Kind == "function" && node.Name == "init" && node.Evidence.File == "app.go" { + owners[node.Evidence.Column] = map[string]bool{node.ID: true} + } + } + if len(owners) != 2 || len(owners[1]) != 1 || len(owners[105]) != 1 { + t.Errorf("same-line init declarations were merged or lost their source columns: owners=%v", owners) + } + callsByColumn := map[int]map[string]bool{} + for _, edge := range report.Relationships { + for column, ids := range owners { + if ids[edge.From] && edge.Kind == "call" && edge.Evidence.File == "app.go" { + if callsByColumn[column] == nil { + callsByColumn[column] = map[string]bool{} + } + callsByColumn[column][edge.To] = true + } + } + } + for _, tc := range []struct { + column int + helper string + event string + table string + other string + }{ + {column: 1, helper: "example.com/samelineinit::firstBody", event: "event:first.init", table: "table:primary:first_table", other: "secondBody"}, + {column: 105, helper: "example.com/samelineinit::secondBody", event: "event:second.init", table: "table:primary:second_table", other: "firstBody"}, + } { + ids := owners[tc.column] + var owner string + for id := range ids { + owner = id + } + if !callsByColumn[tc.column][tc.helper] || callsByColumn[tc.column]["example.com/samelineinit::"+tc.other] { + t.Errorf("initializer at app.go:%d has incorrect helper ownership: %v", tc.column, callsByColumn[tc.column]) + } + if !hasRelationship(report, owner, tc.event, "event_publish", "app.go", 5) || + !hasRelationship(report, owner, tc.table, "sql_read", "app.go", 5) { + t.Errorf("initializer at app.go:%d lost its own event or SQL evidence", tc.column) + } + otherEvent := "event:second.init" + otherTable := "table:primary:second_table" + if tc.other == "firstBody" { + otherEvent = "event:first.init" + otherTable = "table:primary:first_table" + } + if hasRelationship(report, owner, otherEvent, "event_publish", "app.go", 5) || + hasRelationship(report, owner, otherTable, "sql_read", "app.go", 5) { + t.Errorf("initializer at app.go:%d inherited the other initializer's evidence", tc.column) + } + } + } + located, err := Trace(context.Background(), Options{Root: root, Locations: []string{"ordinary.go:4"}, Depth: 3, MaxNodes: 100, Config: config}) + if err != nil { + t.Fatal(err) + } + if !hasName(located, "ordinary.event") { + t.Error("source-location seed no longer reaches a separate-line initializer") + } +} diff --git a/interface_flow.go b/interface_flow.go new file mode 100644 index 0000000..476aa3f --- /dev/null +++ b/interface_flow.go @@ -0,0 +1,111 @@ +package contracttrace + +import ( + "fmt" + "go/token" + + "golang.org/x/tools/go/ssa" +) + +// SSA stores an interface receiver in Value, whereas a direct method receiver +// is argument zero. Both must align with the concrete callee's Params. +func flowArguments(call *ssa.CallCommon) []ssa.Value { + if !call.IsInvoke() { + return call.Args + } + args := make([]ssa.Value, 0, len(call.Args)+1) + args = append(args, call.Value) + return append(args, call.Args...) +} + +// Generated wrappers have no source call expression. Attribute their structural +// relationship to the declaration and preserve that distinction in evidence. +func (ix *index) callEvidence(call ssa.CallInstruction) Evidence { + if call.Pos() != token.NoPos { + return ix.evidence(call.Pos()) + } + parent := call.Parent() + var evidence Evidence + if parent.Pos() != token.NoPos { + evidence = ix.evidence(parent.Pos()) + } else if owner := ix.funcs[ix.owner(parent)]; owner != nil { + evidence = owner.node.Evidence + } + evidence.Origin = "synthetic_declaration" + return evidence +} +func (ix *index) callEdge(from, to, kind, certainty string, call ssa.CallInstruction) { + ix.edges = append(ix.edges, Relationship{From: from, To: to, Kind: kind, Certainty: certainty, Evidence: ix.callEvidence(call)}) +} +func flowArgumentSlot(call *ssa.CallCommon, index int) string { + receiver := call.IsInvoke() + if target := call.StaticCallee(); target != nil && target.Signature.Recv() != nil { + receiver = true + } + if receiver { + if index == 0 { + return "receiver" + } + index-- + } + return fmt.Sprintf("argument:%d", index) +} + +// A struct copy can be stored at a new allocation before its fields are read. +// Retain source-record candidates through such copies. This is a conservative +// field summary: later writes may remain visible through copied candidates. +type recordAddressCache struct { + roots []string + reads map[string]uint64 + addresses []string +} + +func (a *flowAnalysis) recordAddresses(value ssa.Value) []string { + result := a.get(value) + roots := sortedKeys(result.addresses) + if cached, ok := a.recordCache[value]; ok && sameAddressRoots(roots, cached.roots) { + valid := true + for address, version := range cached.reads { + if a.aliasVersions[address] != version { + valid = false + break + } + } + if valid { + return cached.addresses + } + } + queue := append([]string(nil), roots...) + reads := map[string]uint64{} + for head := 0; head < len(queue); head++ { + reads[queue[head]] = a.aliasVersions[queue[head]] + for _, alias := range sortedKeys(a.memory[queue[head]].addresses) { + if result.addresses[alias] { + continue + } + candidate := emptyFlow() + candidate.addresses[alias] = true + if a.merge(&result, candidate) { + queue = append(queue, alias) + } + } + } + addresses := sortedKeys(result.addresses) + if a.recordCache == nil { + a.recordCache = map[ssa.Value]recordAddressCache{} + } + a.recordCache[value] = recordAddressCache{roots, reads, addresses} + return addresses +} + +func sameAddressRoots(left, right []string) bool { + if len(left) != len(right) { + return false + } + for i, address := range left { + if address != right[i] { + return false + } + } + return true +} diff --git a/interface_flow_test.go b/interface_flow_test.go new file mode 100644 index 0000000..d4e1def --- /dev/null +++ b/interface_flow_test.go @@ -0,0 +1,144 @@ +package contracttrace + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestInterfaceArgumentsResultsAndReceiverFlow(t *testing.T) { + r, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"InterfaceWrite", "InterfaceEvent", "PairEntry", "InterfaceCallback"}, Depth: 4, MaxNodes: 250}) + if err != nil { + t.Fatal(err) + } + sql, event, pair, callback := false, false, false, false + for _, e := range r.Relationships { + if strings.HasSuffix(e.From, "::SQLRunner.Run") && e.To == "table:interface_records" { + sql = true + } + if strings.HasSuffix(e.From, "::throughProvider") && e.To == "event:INTERFACE_EVENT" { + event = true + } + if strings.HasSuffix(e.From, "::InterfacePair") && e.To == "event:PAIR_SECOND" { + pair = true + } + if strings.HasSuffix(e.From, "::InterfacePair") && e.To == "event:PAIR_FIRST" { + t.Error("interface result slots were merged") + } + if strings.HasSuffix(e.From, "::CheckFunc.Check") && strings.HasSuffix(e.To, "::Validate") && e.Kind == "resolved_callback_call" { + callback = true + } + } + if !sql || !event || !pair || !callback || !hasName(r, "InterfaceRead") || !hasName(r, "InterfaceConsume") { + t.Errorf("interface flow missing: sql=%t event=%t pair=%t callback=%t", sql, event, pair, callback) + } +} + +func TestInterfaceCopiesPromotedMethodsAndAsyncCalls(t *testing.T) { + r, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"NestedInterfaceEvent", "PromotedInterfaceEvent", "AsyncEntry"}, Depth: 4, MaxNodes: 250}) + if err != nil { + t.Fatal(err) + } + nested, promoted, async, deferred, argument := false, false, false, false, false + for _, e := range r.Relationships { + if strings.HasSuffix(e.From, "::throughProvider") && e.To == "event:NESTED_INTERFACE" { + nested = true + } + if strings.HasSuffix(e.From, "::throughProvider") && e.To == "event:PROMOTED_INTERFACE" { + promoted = true + } + if strings.HasSuffix(e.From, "::SQLRunner.Run") && e.To == "table:async_records" { + async = true + } + if strings.HasSuffix(e.From, "::SQLRunner.Run") && e.To == "table:deferred_records" { + deferred = true + } + if strings.HasSuffix(e.From, "::InterfaceAsync") && e.Kind == "argument_flow" && e.Slot == "argument:0" { + for _, v := range e.Values { + if strings.HasPrefix(v, "string:INSERT INTO async_records") { + argument = true + } + } + } + } + if !nested || !promoted || !async || !deferred || !argument || !hasName(r, "AsyncRead") || !hasName(r, "DeferredRead") { + t.Errorf("interface sibling flow missing: nested=%t promoted=%t async=%t deferred=%t argument=%t", nested, promoted, async, deferred, argument) + } +} + +func TestSyntheticInterfaceCallsHaveAttributedSourceEvidence(t *testing.T) { + r, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"throughProvider"}, Depth: 3, MaxNodes: 250}) + if err != nil { + t.Fatal(err) + } + synthetic := false + for _, e := range r.Relationships { + if e.Evidence.File == "" || e.Evidence.Line < 1 { + t.Errorf("relationship has no source attribution: %s -> %s (%s)", e.From, e.To, e.Kind) + } + if e.Kind == "call" && strings.HasSuffix(e.From, "::topicValue.Topic") && e.From == e.To { + synthetic = true + if e.Evidence.Origin != "synthetic_declaration" { + t.Error("generated wrapper looked like a source call expression") + } + } + } + if !synthetic { + t.Fatal("fixture did not exercise the generated pointer receiver wrapper") + } +} + +func TestInterfaceWithoutLocalImplementationExposesBoundary(t *testing.T) { + r, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"ExternalInterface"}, Depth: 2, MaxNodes: 100}) + if err != nil { + t.Fatal(err) + } + found := false + for _, b := range r.Boundaries { + if b.Node == "example.com/sample::ExternalInterface" && b.Kind == "unresolved_interface_flow" && b.Evidence.Line > 0 { + found = true + } + } + if !found { + t.Fatal("interface with no loaded local implementation silently ended analysis") + } +} + +func TestInterfaceCandidateBudgetIsDisclosed(t *testing.T) { + root := t.TempDir() + module := "module example.com/interfacebudget\n\ngo 1.27.0\n" + if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte(module), 0600); err != nil { + t.Fatal(err) + } + var code strings.Builder + code.WriteString("package interfacebudget\ntype Event struct{EventType string}\ntype Sink interface{Send(string)}\ntype S struct{}\nfunc(S)Send(topic string){_=Event{EventType:topic}}\nfunc bridge(s Sink,topic string){s.Send(topic)}\nfunc Entry(){\n") + for i := 0; i < maxFlowValues+20; i++ { + fmt.Fprintf(&code, "bridge(S{},%q)\n", fmt.Sprintf("TOPIC_%03d", i)) + } + code.WriteString("}\n") + if err := os.WriteFile(filepath.Join(root, "source.go"), []byte(code.String()), 0600); err != nil { + t.Fatal(err) + } + r, err := Trace(context.Background(), Options{Root: root, Seeds: []string{"S.Send"}, Depth: 1, MaxNodes: 100}) + if err != nil { + t.Fatal(err) + } + events := 0 + limit := false + for _, n := range r.Nodes { + if n.Kind == "event" { + events++ + } + } + for _, b := range r.Boundaries { + if b.Kind == "value_flow_limit" { + limit = true + } + } + if events == 0 || events > maxFlowValues || !limit || !r.Coverage.ValueFlow.Widened || r.ContractComplete { + t.Errorf("interface budget looked exhaustive: events=%d limit=%t coverage=%+v", events, limit, r.Coverage.ValueFlow) + } +} diff --git a/iterators.go b/iterators.go new file mode 100644 index 0000000..6cb92a6 --- /dev/null +++ b/iterators.go @@ -0,0 +1,122 @@ +package contracttrace + +import ( + "go/types" + + "golang.org/x/tools/go/ssa" +) + +type iteratorSummary struct { + name string + source ssa.Value + call *ssa.Call +} +type iteratorYield struct { + value flowValue + typ types.Type +} + +func iteratorFactory(common *ssa.CallCommon) string { + target := common.StaticCallee() + if target == nil || target.Object() == nil || target.Object().Pkg() == nil || len(common.Args) != 1 { + return "" + } + name := target.Object().Pkg().Path() + "." + target.Object().Name() + switch name { + case "slices.Values", "slices.All", "slices.Backward", "maps.Keys", "maps.Values", "maps.All": + return name + } + return "" +} + +func (a *flowAnalysis) modelIterator(call *ssa.Call, ix *index, result *flowValue) { + name := iteratorFactory(call.Common()) + if name == "" { + return + } + if a.iterators == nil { + a.iterators = map[string]iteratorSummary{} + } + id := ix.allocationID("iterator", call.Parent(), call) + a.iterators[id] = iteratorSummary{name, call.Common().Args[0], call} + result.effects[id] = true +} + +func (a *flowAnalysis) iteratorValues(summary iteratorSummary) []iteratorYield { + switch source := summary.source.Type().Underlying().(type) { + case *types.Slice: + value := iteratorYield{a.sequenceElements(summary.source), source.Elem()} + if summary.name == "slices.Values" { + return []iteratorYield{value} + } + return []iteratorYield{{emptyFlow(), types.Typ[types.Int]}, value} + case *types.Map: + key := iteratorYield{a.mapIterationValue(summary.source, 1, source.Key()), source.Key()} + value := iteratorYield{a.mapIterationValue(summary.source, 2, source.Elem()), source.Elem()} + switch summary.name { + case "maps.Keys": + return []iteratorYield{key} + case "maps.Values": + return []iteratorYield{value} + case "maps.All": + return []iteratorYield{key, value} + } + } + return nil +} + +func (a *flowAnalysis) invokeIterator(common *ssa.CallCommon, ix *index) bool { + if len(common.Args) != 1 { + return false + } + changed := false + for _, effect := range sortedKeys(a.get(common.Value).effects) { + summary, ok := a.iterators[effect] + if !ok { + continue + } + for _, target := range sortedFunctions(a.get(common.Args[0]).functions) { + if ix.owner(target) == "" { + continue + } + for slot, candidate := range a.iteratorValues(summary) { + if slot < len(target.Params) && a.put(target.Params[slot], candidate.value) { + changed = true + } + } + } + } + return changed +} + +func (a *flowAnalysis) iteratorRelationships(ix *index) { + for _, effect := range sortedKeys(a.iterators) { + summary := a.iterators[effect] + evidence := ix.callEvidence(summary.call) + ix.funcs[effect] = &function{node: Node{ID: effect, Name: summary.name, Kind: "iterator", Evidence: evidence}} + ix.edges = append(ix.edges, Relationship{From: ix.owner(summary.call.Parent()), To: effect, Kind: "iterator_create", Certainty: "fact", Evidence: evidence}) + ix.boundaries = append(ix.boundaries, Boundary{Node: effect, Kind: "iterator_model", Reason: "standard slices/maps iterator retains yielded candidate slots through modeled local consumers; index numbers, key/value pairing, order, early termination, panic and cleanup timing are not proved", Evidence: evidence}) + } +} + +func (a *flowAnalysis) iteratorUse(call ssa.CallInstruction, ix *index) { + for _, effect := range sortedKeys(a.get(call.Common().Value).effects) { + _, ok := a.iterators[effect] + if !ok { + continue + } + ix.edges = append(ix.edges, Relationship{From: ix.owner(call.Parent()), To: effect, Kind: "iterator_invoke", Certainty: "possible", Evidence: ix.callEvidence(call)}) + found := false + if len(call.Common().Args) == 1 { + for _, target := range sortedFunctions(a.get(call.Common().Args[0]).functions) { + if owner := ix.owner(target); owner != "" { + found = true + ix.edges = append(ix.edges, Relationship{From: effect, To: owner, Kind: "iterator_yield", Certainty: "possible", Evidence: ix.callEvidence(call)}) + } + } + } + if !found { + ix.boundaries = append(ix.boundaries, Boundary{Node: effect, Kind: "unresolved_iterator_yield", Reason: "iterator invocation has no modeled local yield consumer; outside consumers or unsupported function flow remain open", Evidence: ix.callEvidence(call)}) + } + } +} diff --git a/lifecycle.go b/lifecycle.go new file mode 100644 index 0000000..eb34ba0 --- /dev/null +++ b/lifecycle.go @@ -0,0 +1,327 @@ +package contracttrace + +import ( + "fmt" + "go/token" + "go/types" + "strings" + + "golang.org/x/tools/go/ssa" +) + +type contextSite struct { + key, owner, name string + names []string + position token.Pos + certainty string + needsCancel bool + parent ssa.Value + parents map[string]map[ssa.Value]bool + parentUnknown bool + unknown bool +} + +func (a *flowAnalysis) modelContext(call ssa.CallInstruction, ix *index) bool { + changed := false + if direct, ok := call.(*ssa.Call); ok { + changed = a.modelAfterFunc(direct, ix) + a.modelDone(direct, ix) + } + common := call.Common() + targets := a.targets(common) + names := []string{} + knownTargets := 0 + for _, target := range sortedFunctions(targets) { + if candidate := contextConstructorName(target); candidate != "" { + seen := false + for _, current := range names { + seen = seen || current == candidate + } + if !seen { + names = append(names, candidate) + } + knownTargets++ + } + } + if len(names) == 0 { + return changed + } + name := names[0] + needsCancel := false + for _, candidate := range names { + switch candidate { + case "WithCancel", "WithCancelCause", "WithDeadline", "WithDeadlineCause", "WithTimeout", "WithTimeoutCause": + needsCancel = true + case "Background", "TODO", "WithValue", "WithoutCancel": + default: + return changed + } + } + if a.contexts == nil { + a.contexts = map[ssa.CallInstruction]contextSite{} + } + if a.contextKeys == nil { + a.contextKeys = map[string]contextSite{} + } + evidence := ix.evidence(call.Pos()) + key := resourceID("context", "", fmt.Sprintf("%s:%d:%d", evidence.File, evidence.Line, evidence.Column)) + contextValue := emptyFlow() + contextValue.addresses[key] = true + callee := a.get(common.Value) + if knownTargets != len(targets) || callee.functionUnknown || callee.functionNil || callee.boundReceiverUnknown { + contextValue.interfaceUnknown = true + } + for i := 1; i < len(names); i++ { + if contextConstructorClass(names[0]) != contextConstructorClass(names[i]) { + contextValue.interfaceUnknown = true + } + } + results := []flowValue{contextValue} + if needsCancel { + cancel := emptyFlow() + cancel.effects["cancel:"+key] = true + results = append(results, cancel) + } + certainty := "fact" + if _, ok := call.(*ssa.Call); !ok { + certainty = "possible" + } + site := contextSite{key: key, owner: ix.owner(call.Parent()), name: name, names: names, position: call.Pos(), certainty: certainty, needsCancel: needsCancel} + needsParent := false + for _, candidate := range names { + needsParent = needsParent || candidate != "Background" && candidate != "TODO" + } + if needsParent && len(call.Common().Args) > 0 { + site.parent = call.Common().Args[0] + } + site.unknown = contextValue.interfaceUnknown + a.contexts[call] = site + changed = a.mergeContextSite(site) || changed + if direct, ok := call.(*ssa.Call); ok { + if a.special == nil { + a.special = map[*ssa.Call][]flowValue{} + } + a.special[direct] = results + } + if ix.funcs[key] == nil { + ix.funcs[key] = &function{node: Node{ID: key, Name: "context at " + evidence.File + fmt.Sprintf(":%d:%d", evidence.Line, evidence.Column), Kind: "context", Evidence: evidence}} + } + return changed +} + +// mergeContextSite keeps the candidates for one source creation site when Go +// generic instantiations have separate SSA call instructions at the same +// position. Parent values stay as SSA identities and are resolved with get at +// each use, so later flow growth is visible. The per-call contexts map remains +// separate for source-owner and cancellation reporting. +func (a *flowAnalysis) mergeContextSite(site contextSite) bool { + current, exists := a.contextKeys[site.key] + changed := false + if !exists { + current = contextSite{ + key: site.key, owner: site.owner, position: site.position, + certainty: site.certainty, parents: map[string]map[ssa.Value]bool{}, + } + changed = true + } + if site.owner != "" && (current.owner == "" || site.owner < current.owner) { + current.owner = site.owner + changed = true + } + if current.name == "" || site.name != "" && site.name < current.name { + current.name = site.name + changed = true + } + nameSet := make(map[string]bool, len(current.names)+len(site.names)) + for _, name := range current.names { + nameSet[name] = true + } + for _, name := range site.names { + if !nameSet[name] { + nameSet[name] = true + changed = true + } + } + current.names = sortedKeys(nameSet) + if site.needsCancel && !current.needsCancel { + current.needsCancel = true + changed = true + } + if site.unknown && !current.unknown { + current.unknown = true + changed = true + } + if site.parentUnknown && !current.parentUnknown { + current.parentUnknown = true + changed = true + } + if site.parent != nil { + for _, name := range site.names { + if name == "Background" || name == "TODO" { + continue + } + parents := current.parents[name] + if parents[site.parent] { + continue + } + if len(parents) >= maxFlowValues { + if !current.parentUnknown { + current.parentUnknown = true + changed = true + } + a.coverage.Widened = true + continue + } + if parents == nil { + parents = map[ssa.Value]bool{} + current.parents[name] = parents + } + parents[site.parent] = true + changed = true + } + } + a.contextKeys[site.key] = current + return changed +} + +func contextHasName(site contextSite, name string) bool { + for _, candidate := range site.names { + if candidate == name { + return true + } + } + return site.name == name +} + +func contextConstructorName(target *ssa.Function) string { + if target == nil { + return "" + } + method, ok := target.Object().(*types.Func) + if !ok || method.Pkg() == nil || method.Pkg().Path() != "context" { + return "" + } + name := method.Name() + switch name { + case "WithCancel", "WithCancelCause", "WithDeadline", "WithDeadlineCause", "WithTimeout", "WithTimeoutCause", "Background", "TODO", "WithValue", "WithoutCancel": + default: + return "" + } + canonical, ok := method.Pkg().Scope().Lookup(name).(*types.Func) + if !ok || canonical.Origin() != method.Origin() { + return "" + } + return name +} + +func contextConstructorClass(name string) string { + switch name { + case "Background", "TODO", "WithoutCancel": + return "nil_done" + case "WithValue": + return "with_value" + case "WithCancel", "WithCancelCause", "WithDeadline", "WithDeadlineCause", "WithTimeout", "WithTimeoutCause": + return "cancelable" + default: + return "unknown" + } +} + +func (a *flowAnalysis) contextUse(call ssa.CallInstruction, from string, ix *index) { + a.afterFuncStopUse(call, from, ix) + common := call.Common() + for effect := range a.get(common.Value).effects { + if strings.HasPrefix(effect, "cancel:") { + ix.edge(from, strings.TrimPrefix(effect, "cancel:"), "context_cancel", "possible", call.Pos()) + } + } + methodNames := map[string]bool{} + if common.IsInvoke() { + if common.Method != nil && common.Method.Name() == "Done" { + methodNames["Done"] = true + } else if common.Method != nil && common.Method.Name() == "Err" { + methodNames["Err"] = true + } + } else { + for target := range a.targets(common) { + if method, ok := target.Object().(*types.Func); ok { + if method.Name() == "Done" || method.Name() == "Err" { + methodNames[method.Name()] = true + } + } + } + } + for _, methodName := range []string{"Done", "Err"} { + if !methodNames[methodName] { + continue + } + receiver, matched, unknown := a.contextCallReceiver(common, methodName) + if !matched { + continue + } + found := false + for key := range receiver.addresses { + if _, modeled := a.contextKeys[key]; modeled { + ix.edge(from, key, "context_observe", "possible", call.Pos()) + found = true + } + } + if !found || unknown || receiver.interfaceUnknown || receiver.boundReceiverUnknown { + ix.boundaries = append(ix.boundaries, Boundary{Node: from, Kind: "unresolved_context", Reason: "context observation has no modeled creation site; input or alias may be external", Evidence: ix.evidence(call.Pos())}) + } + } + for _, arg := range common.Args { + for key := range a.get(arg).addresses { + if _, modeled := a.contextKeys[key]; modeled { + ix.edge(from, key, "context_pass", "possible", call.Pos()) + } + } + } +} +func (a *flowAnalysis) contextBoundaries(ix *index) { + canceled := map[string]bool{} + for _, edge := range ix.edges { + if edge.Kind == "context_cancel" { + canceled[edge.To] = true + } + } + for _, site := range a.contexts { + summary := a.contextKeys[site.key] + if site.needsCancel && site.parent != nil { + parent := a.get(site.parent) + if summary.parentUnknown || summary.unknown { + parent.interfaceUnknown = true + } + a.cancellationSchedulerRelationships(site.key, parent, ix.evidence(site.position), "context_cancellation_scheduler", "context_scheduler_stop_target", ix) + } + ix.edge(site.owner, site.key, "context_create", site.certainty, site.position) + if site.name == "WithoutCancel" { + ix.edge(site.owner, site.key, "context_detach", site.certainty, site.position) + } + if site.needsCancel && !canceled[site.key] { + ix.boundaries = append(ix.boundaries, Boundary{Node: site.owner, Kind: "unobserved_cancel", Reason: "no modeled call to the cancel function for " + site.key + "; it may escape to external code or rely on deadline expiry, so this is an investigation candidate", Evidence: ix.evidence(site.position)}) + } + if site.parent != nil { + parentKnown := false + parentFlow := a.get(site.parent) + for parent := range parentFlow.addresses { + if _, modeled := a.contextKeys[parent]; modeled { + ix.edge(site.key, parent, "context_parent", "possible", site.position) + if site.name != "WithoutCancel" { + ix.edge(site.key, parent, "context_cancellation_parent", "possible", site.position) + } + parentKnown = true + } + } + if !parentKnown || parentFlow.interfaceUnknown || parentFlow.sqlUnknown || parentFlow.boundReceiverUnknown || summary.parentUnknown || summary.unknown { + reason := "parent context creation is outside the modeled flow" + if parentKnown { + reason = "known parent context candidates coexist with unresolved or widened parent alternatives" + } + ix.boundaries = append(ix.boundaries, Boundary{Node: site.owner, Kind: "context_parent", Reason: reason, Evidence: ix.evidence(site.position)}) + } + } else if summary.parentUnknown { + ix.boundaries = append(ix.boundaries, Boundary{Node: site.owner, Kind: "context_parent", Reason: "bounded parent candidates omit additional context origins", Evidence: ix.evidence(site.position)}) + } + } +} diff --git a/lifecycle_binding.go b/lifecycle_binding.go new file mode 100644 index 0000000..67d8e67 --- /dev/null +++ b/lifecycle_binding.go @@ -0,0 +1,283 @@ +package contracttrace + +import ( + "fmt" + "go/types" + "strconv" + "strings" + + "golang.org/x/tools/go/ssa" +) + +// Keep the receiver on each closure value. The generated wrapper's FreeVars +// are shared by distinct closures and cannot identify a captured handle. +func (a *flowAnalysis) modelLifecycleBinding(closure *ssa.MakeClosure, result *flowValue, config Config) { + if len(config.LifecycleRules) == 0 { + return + } + wrapper := closure.Fn.(*ssa.Function) + if !strings.HasPrefix(wrapper.Synthetic, "bound method wrapper") { + return + } + type apiTarget struct { + symbol string + signature *types.Signature + } + targets := []apiTarget{} + seenTargets := map[string]bool{} + addTarget := func(function *ssa.Function) { + symbol := lifecycleSymbol(function) + if symbol == "" { + return + } + signature := lifecycleDeclaredSignature(function) + if signature == nil { + return + } + if seenTargets[symbol] { + return + } + seenTargets[symbol] = true + targets = append(targets, apiTarget{symbol: symbol, signature: signature}) + } + // For interface selectors this is the source-declared API method. Its + // signature retains the receiver even when the generated wrapper signature + // does not. Concrete callgraph targets below remain separate candidates. + addTarget(wrapper) + for _, block := range wrapper.Blocks { + for _, instruction := range block.Instrs { + call, ok := instruction.(ssa.CallInstruction) + if !ok { + continue + } + for _, target := range sortedFunctions(a.targets(call.Common())) { + addTarget(target) + } + } + } + args := []ssa.Value{} + for _, block := range wrapper.Blocks { + for _, instruction := range block.Instrs { + call, ok := instruction.(ssa.CallInstruction) + if !ok { + continue + } + args = flowArguments(call.Common()) + if len(args) > 0 { + break + } + } + if len(args) > 0 { + break + } + } + for index, rule := range config.LifecycleRules { + id := strconv.Itoa(index) + for _, target := range targets { + if target.symbol != rule.Symbol { + continue + } + result.effects["lifecycle-bound-seen:"+id] = true + if reason := lifecycleBoundSignatureRuleError(rule, target.signature); reason != "" { + result.effects["lifecycle-bound-invalid:"+id+":"+reason] = true + continue + } + result.effects["lifecycle-bound-valid:"+id] = true + if !rule.Receiver || len(args) == 0 { + continue + } + for binding, variable := range wrapper.FreeVars { + if args[0] != variable || binding >= len(closure.Bindings) { + continue + } + value := a.get(closure.Bindings[binding]) + candidates := value.addresses + if rule.Identity == "value" { + candidates = value.strings + } + for _, candidate := range sortedKeys(candidates) { + result.effects["lifecycle-bound:"+id+":"+candidate] = true + } + if value.stringUnknown || value.interfaceUnknown || value.sqlUnknown || value.functionUnknown || value.mapNil { + result.effects["lifecycle-bound-unknown:"+id] = true + } + } + } + } +} + +func (a *flowAnalysis) configuredLifecycleBoundUse(call ssa.CallInstruction, from string, ix *index, config Config) map[int]bool { + consumed := map[int]bool{} + value := a.get(call.Common().Value) + for index, rule := range config.LifecycleRules { + id := strconv.Itoa(index) + if !value.effects["lifecycle-bound-seen:"+id] { + continue + } + consumed[index] = true + if a.lifecycleMatched == nil { + a.lifecycleMatched = map[string]bool{} + } + a.lifecycleMatched[rule.Symbol] = true + invalidPrefix := "lifecycle-bound-invalid:" + id + ":" + invalid := "" + for _, effect := range sortedKeys(value.effects) { + if strings.HasPrefix(effect, invalidPrefix) { + invalid = strings.TrimPrefix(effect, invalidPrefix) + break + } + } + if invalid != "" { + ix.boundaries = append(ix.boundaries, Boundary{Node: from, Kind: "invalid_lifecycle_rule_site", Reason: rule.Symbol + ": " + invalid, Evidence: ix.callEvidence(call)}) + if !value.effects["lifecycle-bound-valid:"+id] { + continue + } + } + if reason := lifecycleBoundInvocationError(rule, call); reason != "" { + ix.boundaries = append(ix.boundaries, Boundary{Node: from, Kind: "invalid_lifecycle_rule_site", Reason: rule.Symbol + ": " + reason, Evidence: ix.callEvidence(call)}) + continue + } + outsideAlternative := value.functionUnknown || value.effects["lifecycle-bound-unknown:"+id] + if outsideAlternative || value.functionNil { + reason := "Configured bound method target or receiver includes outside candidates for " + rule.Symbol + "; known candidates remain possible." + if value.functionNil && outsideAlternative { + reason = "Configured bound method target or receiver retains typed nil and outside alternatives for " + rule.Symbol + "; known candidates remain possible but execution is not established." + } else if value.functionNil { + reason = "Configured bound method target retains a typed nil callable alternative for " + rule.Symbol + "; known candidates remain possible but execution is not established." + } + ix.boundaries = append(ix.boundaries, Boundary{Node: from, Kind: "unresolved_lifecycle_dispatch", Reason: reason, Evidence: ix.callEvidence(call)}) + } + if rule.Result != nil { + valueResult := boundLifecycleResult(a, call, *rule.Result) + if rule.Identity == "origin" { + origin := configuredLifecycleOrigin(call, rule, rule.Symbol, ix) + if valueResult.addresses == nil { + valueResult.addresses = map[string]bool{} + } + valueResult.addresses[origin] = true + } + a.emitBoundLifecycleRole(call, from, rule, valueResult, ix) + if lifecycleResultDiscarded(call, *rule.Result) { + ix.boundaries = append(ix.boundaries, Boundary{Node: from, Kind: "discarded_lifecycle_result", Reason: fmt.Sprintf("%s configured result %d has no modeled consumer; deferred/goroutine returns and unused slots cannot transfer their handle to the caller", rule.Symbol, *rule.Result), Evidence: ix.callEvidence(call)}) + } + continue + } + if rule.Receiver { + prefix := "lifecycle-bound:" + id + ":" + selected := emptyFlow() + for _, effect := range sortedKeys(value.effects) { + if strings.HasPrefix(effect, prefix) { + selected.addresses[strings.TrimPrefix(effect, prefix)] = true + } + } + if value.effects["lifecycle-bound-unknown:"+id] { + selected.stringUnknown = true + selected.interfaceUnknown = true + } + if rule.Identity == "value" { + selected.strings = selected.addresses + selected.addresses = nil + } + a.emitBoundLifecycleRole(call, from, rule, selected, ix) + continue + } + args := flowArguments(call.Common()) + if rule.Argument == nil || *rule.Argument >= len(args) { + ix.boundaries = append(ix.boundaries, Boundary{Node: from, Kind: "invalid_lifecycle_rule_site", Reason: rule.Symbol + ": configured argument is outside the bound method invocation", Evidence: ix.callEvidence(call)}) + continue + } + selected := a.get(args[*rule.Argument]) + a.emitBoundLifecycleRole(call, from, rule, selected, ix) + } + return consumed +} + +func boundLifecycleResult(a *flowAnalysis, call ssa.CallInstruction, slot int) flowValue { + ordinary, ok := call.(*ssa.Call) + if !ok { + return emptyFlow() + } + if _, tuple := ordinary.Type().(*types.Tuple); !tuple { + return a.get(ordinary) + } + var selected flowValue + if refs := ordinary.Referrers(); refs != nil { + for _, instruction := range *refs { + if extract, ok := instruction.(*ssa.Extract); ok && extract.Index == slot { + a.merge(&selected, a.get(extract)) + } + } + } + return selected +} + +func (a *flowAnalysis) emitBoundLifecycleRole(call ssa.CallInstruction, from string, rule LifecycleRule, selected flowValue, ix *index) { + candidates := selected.addresses + if rule.Identity == "value" { + candidates = selected.strings + } + resolved := false + outside := selected.interfaceUnknown || selected.sqlUnknown || selected.functionUnknown || selected.mapNil + if rule.Identity == "value" { + outside = outside || selected.stringUnknown + } + for _, candidate := range sortedKeys(candidates) { + resolved = true + outside = outside || strings.HasPrefix(candidate, "input:") || a.inputRoots[candidate] + key := resourceID("lifecycle", rule.Namespace, candidate) + if ix.funcs[key] == nil { + ix.funcs[key] = &function{node: Node{ID: key, Name: "lifecycle " + rule.Namespace + " " + candidate, Kind: "lifecycle_resource", Evidence: ix.callEvidence(call)}} + } + ix.callEdge(from, key, "lifecycle_"+rule.Role, "possible", call) + } + if !resolved || outside { + ix.boundaries = append(ix.boundaries, Boundary{Node: from, Kind: "unresolved_lifecycle_resource", Reason: fmt.Sprintf("%s bound selector candidates are missing or include nil or outside values; ownership and dispatch remain unresolved", rule.Symbol), Evidence: ix.callEvidence(call)}) + } +} + +func lifecycleBoundSignatureRuleError(rule LifecycleRule, signature *types.Signature) string { + if signature == nil { + return "configured API declaration signature is unavailable" + } + method := signature.Recv() != nil + if rule.Result != nil { + results := signature.Results() + if *rule.Result >= results.Len() { + return "configured result index is outside the API signature" + } + return lifecycleIdentityTypeError(rule.Identity, results.At(*rule.Result).Type()) + } + if rule.Receiver { + if !method { + return "receiver selector used on a function without a receiver" + } + return lifecycleIdentityTypeError(rule.Identity, signature.Recv().Type()) + } + if !method { + return "bound selector target has no method receiver" + } + if rule.Argument == nil || *rule.Argument >= signature.Params().Len() { + return "configured argument index is outside the API signature" + } + return lifecycleIdentityTypeError(rule.Identity, signature.Params().At(*rule.Argument).Type()) +} + +func lifecycleBoundInvocationError(rule LifecycleRule, call ssa.CallInstruction) string { + signature, ok := call.Common().Value.Type().Underlying().(*types.Signature) + if !ok { + return "bound invocation has no function signature" + } + if rule.Result != nil { + if *rule.Result >= signature.Results().Len() { + return "configured result index is outside the bound method invocation" + } + return lifecycleIdentityTypeError(rule.Identity, signature.Results().At(*rule.Result).Type()) + } + if rule.Receiver { + return "" + } + if rule.Argument == nil || *rule.Argument >= signature.Params().Len() { + return "configured argument is outside the bound method invocation" + } + return lifecycleIdentityTypeError(rule.Identity, signature.Params().At(*rule.Argument).Type()) +} diff --git a/lifecycle_bound_validation_test.go b/lifecycle_bound_validation_test.go new file mode 100644 index 0000000..3e2f245 --- /dev/null +++ b/lifecycle_bound_validation_test.go @@ -0,0 +1,387 @@ +package contracttrace + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestLifecycleBoundSelectorsUseDeclaredMethodSignatures(t *testing.T) { + root := t.TempDir() + files := map[string]string{ + "go.mod": "module example.com/boundvalidation\n\ngo 1.27.0\nrequire example.com/leases v0.0.0\nreplace example.com/leases => ./api\n", + "api/go.mod": "module example.com/leases\n\ngo 1.27.0\n", + "api/api.go": `package leases +type Lease struct{} +func (*Lease) Close() {} +func (*Lease) CloseArg(string) {} +func (*Lease) Reserve(string) {} +func (*Lease) Acquire() *Lease { return &Lease{} } +func Acquire() *Lease { return &Lease{} } +func Drop(*Lease) {} +type Handle struct{} +type TypedLease[T any] struct{} +func (*TypedLease[T]) Close() {} +func (*TypedLease[T]) Set(T) {} +func (*TypedLease[T]) Take() T { var zero T; return zero } +func Reserve[T ~string](T) {} +func AcquireValue[T any]() T { var zero T; return zero } +type ResourceKey string +func (ResourceKey) ReleaseKey() {} +type Closer interface { Close() } +type TextLease int +func (TextLease) CloseText() string { return "" } +`, + "client.go": `package boundvalidation +import "example.com/leases" + +func Direct() { + lease := &leases.Lease{} + closeLease := lease.Close + alias := closeLease + invoke(alias) +} + +func InterfaceBound(lease leases.Closer) { + closeLease := lease.Close + alias := closeLease + invoke(alias) +} + +func InterfaceMixed(external leases.Closer) { + known := &leases.Lease{} + invoke(known.Close) + invoke(external.Close) +} + +func invoke(fn func()) { fn() } + +func InvalidScalarReceiver(lease *leases.TextLease) { + closeText := lease.CloseText + closeText() +} +func InvalidScalarDirect(lease leases.TextLease) { lease.CloseText() } + +func InvalidArgumentIndex(lease *leases.Lease) { + closeArg := lease.CloseArg + invokeArg(closeArg, "resource") +} +func InvalidArgumentIndexDirect(lease *leases.Lease) { lease.CloseArg("resource") } +func MaxIntDirect(lease *leases.Lease) { lease.CloseArg("resource") } +func MaxIntExpression(lease *leases.Lease) { (*leases.Lease).CloseArg(lease, "resource") } +func invokeArg(fn func(string), key string) { fn(key) } +func invokeExpression(fn func(*leases.Lease), receiver *leases.Lease) { fn(receiver) } + +func MethodExpressionReceiver() { (*leases.Lease).Close(&leases.Lease{}) } +func MethodExpressionHelper() { + expression := (*leases.Lease).Close + alias := expression + receiver := &leases.Lease{} + invokeExpression(alias, receiver) +} +func MethodExpressionArgument() { (*leases.Lease).Reserve(&leases.Lease{}, "orders") } +func MethodExpressionResult() { + lease := (*leases.Lease).Acquire(&leases.Lease{}) + leases.Drop(lease) +} +func DirectResult() { + lease := (&leases.Lease{}).Acquire() + leases.Drop(lease) +} +type promotedLease struct { *leases.Lease } +type deepPromotedLease struct { *promotedLease } +type keyEnvelope struct { leases.ResourceKey } +type genericStringEnvelope struct { *leases.TypedLease[string] } +type genericHandleEnvelope struct { *leases.TypedLease[*leases.Handle] } +func PromotedBound() { + receiver := &promotedLease{Lease: &leases.Lease{}} + closeLease := receiver.Close + invoke(closeLease) +} +func PromotedExpression() { + receiver := &promotedLease{Lease: &leases.Lease{}} + (*promotedLease).Close(receiver) +} +func PromotedExpressionAcquired() { + lease := leases.Acquire() + wrapped := &promotedLease{Lease: lease} + (*promotedLease).Close(wrapped) +} +func PromotedExpressionHelperAcquired() { + lease := leases.Acquire() + wrapped := &promotedLease{Lease: lease} + expression := (*promotedLease).Close + alias := expression + invokePromotedExpression(alias, wrapped) +} +func invokePromotedExpression(fn func(*promotedLease), wrapped *promotedLease) { fn(wrapped) } +func PromotedDeepExpressionAcquired() { + lease := leases.Acquire() + mid := &promotedLease{Lease: lease} + wrapped := &deepPromotedLease{promotedLease: mid} + (*deepPromotedLease).Close(wrapped) +} +func PromotedStringReceiver() { + wrapped := &keyEnvelope{ResourceKey: "tenant-key"} + (*keyEnvelope).ReleaseKey(wrapped) +} +func PromotedGenericStringArgument() { + wrapped := &genericStringEnvelope{TypedLease: &leases.TypedLease[string]{}} + (*genericStringEnvelope).Set(wrapped, "promoted-generic-key") +} +func PromotedGenericHandleArgument() { + wrapped := &genericHandleEnvelope{TypedLease: &leases.TypedLease[*leases.Handle]{}} + (*genericHandleEnvelope).Set(wrapped, &leases.Handle{}) +} + +func GenericStringLease() { + lease := &leases.TypedLease[string]{} + closeLease := lease.Close + closeLease() + set := lease.Set + set("generic-key") +} +func GenericHandleLease() { + lease := &leases.TypedLease[*leases.Handle]{} + closeLease := lease.Close + closeLease() + set := lease.Set + set(&leases.Handle{}) + take := lease.Take + handle := take() + _ = handle +} +func GenericStringResult() { + lease := &leases.TypedLease[string]{} + take := lease.Take + text := take() + _ = text +} +func GenericFunctions() { + leases.Reserve[string]("generic-function-key") + _ = leases.AcquireValue[*leases.Handle]() + _ = leases.AcquireValue[string]() +} + +func DeferredAndGoroutine() { + first := &leases.Lease{} + second := &leases.Lease{} + closeFirst := first.Close + closeSecond := second.Close + defer closeFirst() + go closeSecond() +} +`, + } + for name, contents := range files { + path := filepath.Join(root, name) + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(contents), 0600); err != nil { + t.Fatal(err) + } + } + one := 1 + zero := 0 + config := DefaultConfig() + config.LifecycleRules = []LifecycleRule{ + {Symbol: "example.com/leases::Lease.Close", Role: "release", Namespace: "handles", Identity: "origin", Receiver: true}, + {Symbol: "example.com/leases::Closer.Close", Role: "release", Namespace: "interfaces", Identity: "origin", Receiver: true}, + {Symbol: "example.com/leases::TextLease.CloseText", Role: "release", Namespace: "invalid", Identity: "origin", Receiver: true}, + {Symbol: "example.com/leases::Lease.CloseArg", Role: "release", Namespace: "invalid", Identity: "value", Argument: &one}, + {Symbol: "example.com/leases::Lease.Reserve", Role: "acquire", Namespace: "keys", Identity: "value", Argument: &zero}, + {Symbol: "example.com/leases::Lease.Acquire", Role: "acquire", Namespace: "results", Identity: "origin", Result: &zero}, + {Symbol: "example.com/leases::Drop", Role: "release", Namespace: "results", Identity: "origin", Argument: &zero}, + {Symbol: "example.com/leases::TypedLease.Close", Role: "release", Namespace: "generic-handles", Identity: "origin", Receiver: true}, + {Symbol: "example.com/leases::TypedLease.Set", Role: "acquire", Namespace: "generic-keys", Identity: "value", Argument: &zero}, + {Symbol: "example.com/leases::TypedLease.Take", Role: "acquire", Namespace: "generic-results", Identity: "origin", Result: &zero}, + {Symbol: "example.com/leases::Reserve", Role: "acquire", Namespace: "generic-function-keys", Identity: "value", Argument: &zero}, + {Symbol: "example.com/leases::AcquireValue", Role: "acquire", Namespace: "generic-function-results", Identity: "origin", Result: &zero}, + {Symbol: "example.com/leases::Acquire", Role: "acquire", Namespace: "handles", Identity: "origin", Result: &zero}, + {Symbol: "example.com/leases::ResourceKey.ReleaseKey", Role: "release", Namespace: "keys", Identity: "value", Receiver: true}, + } + report, analysis, err := TraceWithAnalysis(context.Background(), Options{ + Root: root, + Seeds: []string{"Direct", "InterfaceBound", "InterfaceMixed", "InvalidScalarReceiver", "InvalidScalarDirect", + "InvalidArgumentIndex", "InvalidArgumentIndexDirect", "MethodExpressionReceiver", "MethodExpressionArgument", + "MethodExpressionResult", "MethodExpressionHelper", "DirectResult", "PromotedBound", "PromotedExpression", + "PromotedExpressionAcquired", "PromotedExpressionHelperAcquired", + "PromotedDeepExpressionAcquired", "PromotedStringReceiver", "PromotedGenericStringArgument", "PromotedGenericHandleArgument", + "GenericStringLease", "GenericHandleLease", "GenericStringResult", "GenericFunctions", "DeferredAndGoroutine"}, + Depth: 6, MaxNodes: 200, Config: config, + }) + if err != nil { + t.Fatal(err) + } + role := func(owner, namespace, kind, snippet string) []Relationship { + var found []Relationship + for _, edge := range analysis.Relationships { + if edge.From == "example.com/boundvalidation::"+owner && edge.Kind == "lifecycle_"+kind && + strings.HasPrefix(edge.To, "lifecycle:"+namespace+":") && (snippet == "" || strings.Contains(edge.Evidence.Snippet, snippet)) { + found = append(found, edge) + } + } + return found + } + invalid := func(owner, snippet, reason string) bool { + for _, boundary := range report.Boundaries { + if boundary.Node == "example.com/boundvalidation::"+owner && boundary.Kind == "invalid_lifecycle_rule_site" && + strings.Contains(boundary.Evidence.Snippet, snippet) && strings.Contains(boundary.Reason, reason) { + return true + } + } + return false + } + + boundDirect := role("invoke", "handles", "release", "fn()") + if len(boundDirect) == 0 || !strings.Contains(boundDirect[0].To, "boundvalidation.Direct") { + t.Fatalf("bound pointer receiver did not preserve its captured allocation: %+v", boundDirect) + } + for _, boundary := range report.Boundaries { + if boundary.Node == "example.com/boundvalidation::invoke" && boundary.Kind == "invalid_lifecycle_rule_site" && + (boundary.Evidence.Snippet == "func invoke(fn func()) { fn() }") && + (strings.Contains(boundary.Reason, "Lease.Close") || strings.Contains(boundary.Reason, "Closer.Close")) { + t.Fatalf("valid captured method value produced a false invalid-site warning: %+v", boundary) + } + } + if interfaces := role("invoke", "interfaces", "release", "fn()"); len(interfaces) == 0 { + t.Fatal("declared interface method rule did not produce a bound receiver candidate") + } + if !invalid("InvalidScalarDirect", "lease.CloseText()", "scalar identity is unsupported") { + t.Fatal("direct scalar receiver control lost its specific invalid-site boundary") + } + if !invalid("InvalidArgumentIndexDirect", "lease.CloseArg(\"resource\")", "outside the API call") { + t.Fatal("direct out-of-range argument selector lost its invalid-site boundary") + } + if !invalid("InvalidScalarReceiver", "closeText()", "scalar identity is unsupported") { + t.Fatal("bound scalar receiver did not retain the declaration's scalar-type validation") + } + if invalid("InvalidScalarReceiver", "closeText()", "function without a receiver") { + t.Fatal("bound scalar control was misreported as missing a declared receiver") + } + if !invalid("invokeArg", "fn(key)", "outside the API signature") { + t.Fatal("bound out-of-range argument control lost its declaration-signature warning") + } + + if edges := role("MethodExpressionReceiver", "handles", "release", "(*leases.Lease).Close"); len(edges) == 0 { + t.Fatal("method-expression receiver selector did not select its explicit receiver argument") + } + if edges := role("invokeExpression", "handles", "release", "fn(receiver)"); len(edges) == 0 || !strings.Contains(edges[0].To, "MethodExpressionHelper") { + t.Fatalf("method-expression alias/helper did not preserve its explicit receiver argument: %+v", edges) + } + promotedBound := false + for _, edge := range role("invoke", "handles", "release", "fn()") { + promotedBound = promotedBound || strings.Contains(edge.To, "PromotedBound") + } + if !promotedBound { + t.Fatal("promoted bound method did not preserve receiver identity") + } + if edges := role("PromotedExpression", "handles", "release", "(*promotedLease).Close"); len(edges) == 0 { + t.Fatal("promoted method expression did not preserve its explicit receiver") + } + assertAcquiredLeaseRelease := func(acquireOwner, releaseOwner, snippet string) { + t.Helper() + var acquisition string + for _, edge := range analysis.Relationships { + if edge.From == "example.com/boundvalidation::"+acquireOwner && edge.Kind == "lifecycle_acquire" && + strings.HasPrefix(edge.To, "lifecycle:handles:configured%3Aexample.com%2Fleases%3A%3AAcquire%3A") { + acquisition = edge.To + break + } + } + if acquisition == "" { + t.Fatalf("%s did not produce configured Lease.Acquire origin", acquireOwner) + } + for _, edge := range analysis.Relationships { + if edge.From == "example.com/boundvalidation::"+releaseOwner && edge.Kind == "lifecycle_release" && + strings.Contains(edge.Evidence.Snippet, snippet) && edge.To == acquisition { + return + } + } + t.Fatalf("%s release at %q did not retain the acquired embedded Lease origin from %s: %q", releaseOwner, snippet, acquireOwner, acquisition) + } + assertAcquiredLeaseRelease("PromotedExpressionAcquired", "PromotedExpressionAcquired", "(*promotedLease).Close(wrapped)") + assertAcquiredLeaseRelease("PromotedExpressionHelperAcquired", "invokePromotedExpression", "fn(wrapped)") + assertAcquiredLeaseRelease("PromotedDeepExpressionAcquired", "PromotedDeepExpressionAcquired", "(*deepPromotedLease).Close(wrapped)") + if edges := role("PromotedStringReceiver", "keys", "release", "(*keyEnvelope).ReleaseKey(wrapped)"); len(edges) == 0 || edges[0].To != "lifecycle:keys:tenant-key" { + t.Fatalf("promoted named-string receiver did not retain its field string identity: %+v", edges) + } + if edges := role("PromotedGenericStringArgument", "generic-keys", "acquire", "promoted-generic-key"); len(edges) == 0 || edges[0].To != "lifecycle:generic-keys:promoted-generic-key" { + t.Fatalf("deep promoted generic selector lost instantiated string argument type: %+v", edges) + } + if !invalid("PromotedGenericHandleArgument", "&leases.Handle{}", "string resource key") { + t.Fatal("deep promoted generic T=*Handle argument incorrectly passed string selector validation") + } + if edges := role("MethodExpressionArgument", "keys", "acquire", "(*leases.Lease).Reserve"); len(edges) == 0 || edges[0].To != "lifecycle:keys:orders" { + t.Fatalf("method-expression argument selector did not skip the explicit receiver: %+v", edges) + } + acquired := role("MethodExpressionResult", "results", "acquire", "Lease).Acquire") + if len(acquired) == 0 { + t.Fatal("method-expression result selector did not retain the declared result slot") + } + joined := false + for _, release := range role("MethodExpressionResult", "results", "release", "leases.Drop") { + for _, acquire := range acquired { + joined = joined || release.To == acquire.To + } + } + if !joined { + t.Fatalf("method-expression result candidate did not join Drop: acquire=%+v", acquired) + } + if edges := role("GenericStringLease", "generic-keys", "acquire", "set(\"generic-key\")"); len(edges) == 0 || edges[0].To != "lifecycle:generic-keys:generic-key" { + t.Fatalf("generic T=string argument selector lost its concrete type/value: %+v", edges) + } + if edges := role("GenericHandleLease", "generic-handles", "release", "closeLease()"); len(edges) == 0 { + t.Fatal("generic pointer receiver did not retain lifecycle identity") + } + if !invalid("GenericHandleLease", "set(&leases.Handle{})", "string resource key") { + t.Fatal("generic T=*Handle argument incorrectly passed string selector validation") + } + genericHandleResult := role("GenericHandleLease", "generic-results", "acquire", "take()") + if len(genericHandleResult) == 0 { + t.Fatal("generic T=*Handle result incorrectly failed origin validation") + } + if !invalid("GenericStringResult", "take()", "scalar identity is unsupported") { + t.Fatal("generic T=string result incorrectly passed origin validation") + } + if edges := role("GenericFunctions", "generic-function-keys", "acquire", `Reserve[string]("generic-function-key")`); len(edges) == 0 || edges[0].To != "lifecycle:generic-function-keys:generic-function-key" { + t.Fatalf("generic function T=string argument lost its instantiated type: %+v", edges) + } + if edges := role("GenericFunctions", "generic-function-results", "acquire", "AcquireValue[*leases.Handle]()"); len(edges) == 0 { + t.Fatal("generic function T=*Handle result failed origin validation") + } + if !invalid("GenericFunctions", "AcquireValue[string]()", "scalar identity is unsupported") { + t.Fatal("generic function T=string result incorrectly passed origin validation") + } + + deferred := role("DeferredAndGoroutine", "handles", "release", "defer closeFirst()") + concurrent := role("DeferredAndGoroutine", "handles", "release", "go closeSecond()") + if len(deferred) == 0 || len(concurrent) == 0 || deferred[0].To == concurrent[0].To { + t.Fatalf("bound defer/go calls did not retain distinct captured receiver candidates: defer=%+v go=%+v", deferred, concurrent) + } + if report.ContractComplete { + t.Fatal("lifecycle candidates must not claim contract completeness") + } + maxInt := int(^uint(0) >> 1) + maxConfig := DefaultConfig() + maxConfig.LifecycleRules = []LifecycleRule{{Symbol: "example.com/leases::Lease.CloseArg", Role: "release", + Namespace: "max-index", Identity: "value", Argument: &maxInt}} + maxReport, _, err := TraceWithAnalysis(context.Background(), Options{ + Root: root, Seeds: []string{"MaxIntDirect", "MaxIntExpression"}, Depth: 3, MaxNodes: 100, Config: maxConfig, + }) + if err != nil { + t.Fatalf("MaxInt argument selectors panicked or failed analysis: %v", err) + } + maxWarnings := 0 + for _, boundary := range maxReport.Boundaries { + if boundary.Kind == "invalid_lifecycle_rule_site" && strings.Contains(boundary.Reason, "configured argument is outside the API call") { + maxWarnings++ + } + } + if maxWarnings != 2 { + t.Fatalf("expected safe source-site warnings for direct and method-expression MaxInt selectors, got %d: %+v", maxWarnings, maxReport.Boundaries) + } +} diff --git a/lifecycle_inputs.go b/lifecycle_inputs.go new file mode 100644 index 0000000..dcc0308 --- /dev/null +++ b/lifecycle_inputs.go @@ -0,0 +1,77 @@ +package contracttrace + +import ( + "go/types" + + "golang.org/x/tools/go/ssa" +) + +// Opaque lifecycle handles can have no public fields. Their type still needs +// outside-input summaries when it crosses a public or escaping boundary. +func (a *flowAnalysis) collectLifecycleTypes(fn *ssa.Function, config Config) { + if len(config.LifecycleRules) == 0 { + return + } + symbol := lifecycleSymbol(fn) + if symbol == "" { + return + } + signature := lifecycleDeclaredSignature(fn) + if signature == nil { + return + } + for _, rule := range config.LifecycleRules { + if rule.Symbol != symbol || rule.Identity != "origin" { + continue + } + var typ types.Type + switch { + case rule.Result != nil && *rule.Result < signature.Results().Len(): + typ = signature.Results().At(*rule.Result).Type() + case rule.Argument != nil && *rule.Argument < signature.Params().Len(): + typ = signature.Params().At(*rule.Argument).Type() + case rule.Receiver && signature.Recv() != nil: + typ = signature.Recv().Type() + } + if typ == nil || !lifecycleOriginType(typ) { + continue + } + found := false + for _, previous := range a.lifecycleTypes { + found = found || types.Identical(previous, typ) + } + if !found { + a.lifecycleTypes = append(a.lifecycleTypes, typ) + } + } +} + +func (a *flowAnalysis) lifecycleSensitiveType(typ types.Type, seen map[types.Type]bool) bool { + if len(a.lifecycleTypes) == 0 || seen[typ] { + return false + } + seen[typ] = true + for _, handle := range a.lifecycleTypes { + if types.Identical(handle, typ) { + return true + } + } + switch shape := typ.Underlying().(type) { + case *types.Pointer: + return a.lifecycleSensitiveType(shape.Elem(), seen) + case *types.Array: + return shape.Len() > 0 && a.lifecycleSensitiveType(shape.Elem(), seen) + case *types.Slice: + return a.lifecycleSensitiveType(shape.Elem(), seen) + case *types.Map: + return a.lifecycleSensitiveType(shape.Key(), seen) || a.lifecycleSensitiveType(shape.Elem(), seen) + case *types.Struct: + for i := 0; i < shape.NumFields(); i++ { + field := shape.Field(i) + if (field.Exported() || embeddedRecord(field)) && a.lifecycleSensitiveType(field.Type(), seen) { + return true + } + } + } + return false +} diff --git a/lifecycle_rules.go b/lifecycle_rules.go new file mode 100644 index 0000000..52e5139 --- /dev/null +++ b/lifecycle_rules.go @@ -0,0 +1,384 @@ +package contracttrace + +import ( + "errors" + "fmt" + "go/token" + "go/types" + "strconv" + "strings" + + "golang.org/x/tools/go/ssa" +) + +func lifecycleSymbol(target *ssa.Function) string { + obj := lifecycleDeclaredFunction(target) + if obj == nil || obj.Pkg() == nil { + return "" + } + return obj.Pkg().Path() + "::" + functionName(obj.Origin()) +} + +// SSA method expressions and bound-method wrappers retain the API Func object, +// while their executable signature can omit the declared receiver. Selectors +// are defined by the source declaration; invocation arguments are mapped using +// the executable call shape separately. +func lifecycleDeclaredFunction(target *ssa.Function) *types.Func { + if target == nil { + return nil + } + if obj, ok := target.Object().(*types.Func); ok && obj != nil { + return obj.Origin() + } + if origin := target.Origin(); origin != nil { + if obj, ok := origin.Object().(*types.Func); ok && obj != nil { + return obj.Origin() + } + } + return nil +} + +func lifecycleDeclaredSignature(target *ssa.Function) *types.Signature { + if target == nil { + return nil + } + actual := target.Signature + if actual == nil { + return nil + } + // Ordinary functions and methods carry the best instantiated executable + // signature. Use the declared object only for generated wrappers whose SSA + // signature deliberately changes the receiver/parameter shape. + if !strings.Contains(target.Synthetic, "bound method wrapper") && !strings.Contains(target.Synthetic, "thunk") { + return actual + } + obj := lifecycleDeclaredFunction(target) + if obj == nil { + return actual + } + declared, _ := obj.Type().(*types.Signature) + if declared == nil || declared.Recv() == nil { + return actual + } + var receiverType types.Type + params := actual.Params() + switch { + case strings.Contains(target.Synthetic, "bound method wrapper"): + receiverType = declared.Recv().Type() + if len(target.FreeVars) > 0 { + receiverType = target.FreeVars[0].Type() + } + case strings.Contains(target.Synthetic, "thunk") && params.Len() == declared.Params().Len()+1: + if apiMethod := lifecycleDeclaredFunction(target); apiMethod != nil { + if _, selectedSignature, _ := lifecycleMethodSetSelection(params.At(0).Type(), apiMethod); selectedSignature != nil { + return selectedSignature + } + } + // Keep the declaration's inner receiver if the adapter's receiver path + // cannot be selected. Never reinterpret its outer receiver as the API. + return declared + default: + return actual + } + receiver := types.NewVar(token.NoPos, obj.Pkg(), "", receiverType) + return types.NewSignatureType(receiver, nil, nil, params, actual.Results(), actual.Variadic()) +} + +func lifecycleOriginType(typ types.Type) bool { + switch typ.Underlying().(type) { + case *types.Pointer, *types.Struct, *types.Array, *types.Interface, *types.Map, *types.Slice, *types.Chan, *types.Signature: + return true + } + return false +} + +func lifecycleIdentityTypeError(identity string, typ types.Type) string { + if identity == "origin" && !lifecycleOriginType(typ) { + return "origin identity requires a reference or aggregate handle; scalar identity is unsupported" + } + if identity == "value" { + basic, ok := typ.Underlying().(*types.Basic) + if !ok || basic.Kind() != types.String { + return "value identity requires a string resource key" + } + } + return "" +} + +func configuredLifecycleOrigin(call ssa.CallInstruction, rule LifecycleRule, symbol string, ix *index) string { + evidence := ix.callEvidence(call) + origin := fmt.Sprintf("configured:%s:%s:%s:%d:%d:result:%d", symbol, ix.owner(call.Parent()), evidence.File, evidence.Line, evidence.Column, *rule.Result) + key := resourceID("lifecycle", rule.Namespace, origin) + if ix.funcs[key] == nil { + ix.funcs[key] = &function{node: Node{ID: key, Name: "lifecycle handle at " + evidence.File + fmt.Sprintf(":%d:%d result %d", evidence.Line, evidence.Column, *rule.Result), Kind: "lifecycle_resource", Evidence: evidence}} + } + return origin +} + +// Configured output origins augment ordinary flow without replacing builtin +// summaries or known local return candidates. Each site/slot/target has its own +// candidate: repeated invocations and successful acquisition are not proved. +func (a *flowAnalysis) configuredLifecycleResult(value ssa.Value, result *flowValue, ix *index, config Config) { + if len(config.LifecycleRules) == 0 { + return + } + var call *ssa.Call + slot := 0 + switch v := value.(type) { + case *ssa.Call: + if _, tuple := v.Type().(*types.Tuple); tuple { + return + } + call = v + case *ssa.Extract: + call, _ = v.Tuple.(*ssa.Call) + slot = v.Index + } + if call == nil || !lifecycleOriginType(value.Type()) { + return + } + bound := a.get(call.Common().Value) + for index, rule := range config.LifecycleRules { + id := strconv.Itoa(index) + if rule.Identity != "origin" || rule.Result == nil || *rule.Result != slot || !bound.effects["lifecycle-bound-seen:"+id] { + continue + } + if hasEffectPrefix(bound.effects, "lifecycle-bound-invalid:"+id+":") && !bound.effects["lifecycle-bound-valid:"+id] { + continue + } + if lifecycleBoundInvocationError(rule, call) != "" { + continue + } + origin := configuredLifecycleOrigin(call, rule, rule.Symbol, ix) + result.addresses[origin] = true + } + for _, target := range sortedFunctions(a.targets(call.Common())) { + if strings.HasPrefix(call.Parent().Synthetic, "bound method wrapper") { + continue + } + symbol := lifecycleSymbol(target) + for _, rule := range config.LifecycleRules { + if rule.Symbol != symbol || rule.Identity != "origin" || rule.Result == nil || *rule.Result != slot { + continue + } + origin := configuredLifecycleOrigin(call, rule, symbol, ix) + result.addresses[origin] = true + } + } +} + +func hasEffectPrefix(effects map[string]bool, prefix string) bool { + for effect := range effects { + if strings.HasPrefix(effect, prefix) { + return true + } + } + return false +} + +func lifecycleSelected(call ssa.CallInstruction, target *ssa.Function, rule LifecycleRule, a *flowAnalysis) (flowValue, types.Type, error) { + signature := lifecycleDeclaredSignature(target) + if signature == nil { + return flowValue{}, nil, fmt.Errorf("API declaration signature is unavailable") + } + if rule.Result != nil { + results := signature.Results() + if *rule.Result >= results.Len() { + return flowValue{}, nil, fmt.Errorf("configured result index is outside the API signature") + } + typ := results.At(*rule.Result).Type() + ordinary, ok := call.(*ssa.Call) + if !ok { + return emptyFlow(), typ, nil + } + if results.Len() == 1 { + return a.get(ordinary), typ, nil + } + var result flowValue + if refs := ordinary.Referrers(); refs != nil { + for _, instruction := range *refs { + if extract, ok := instruction.(*ssa.Extract); ok && extract.Index == *rule.Result { + a.merge(&result, a.get(extract)) + } + } + } + return result, typ, nil + } + method := signature.Recv() != nil + methodExpression := lifecycleMethodExpression(call, target, signature) + args := flowArguments(call.Common()) + var methodExpressionReceiver flowValue + if methodExpression { + // A method expression's receiver is an explicit first argument in SSA. + args = call.Common().Args + projected, selectedSignature, status := a.projectMethodExpressionReceiver( + a.get(args[0]), args[0].Type(), lifecycleDeclaredFunction(target)) + if status == methodReceiverProjectionUnsupported || selectedSignature == nil { + return projected, nil, errUnsupportedMethodReceiverProjection + } + signature = selectedSignature + method = signature.Recv() != nil + methodExpressionReceiver = projected + } + index := 0 + if rule.Receiver { + if !method { + return flowValue{}, nil, fmt.Errorf("receiver selector used on a function without a receiver") + } + if methodExpression { + index = 0 + } + } else { + if rule.Argument == nil || *rule.Argument < 0 || *rule.Argument >= signature.Params().Len() { + return flowValue{}, nil, fmt.Errorf("configured argument is outside the API call") + } + index = *rule.Argument + if method { + // The declared argument was range-checked before applying the + // explicit receiver offset, so even MaxInt selectors cannot overflow. + index++ + } + } + if index >= len(args) { + return flowValue{}, nil, fmt.Errorf("configured argument is outside the API call") + } + if rule.Receiver { + if methodExpression { + return methodExpressionReceiver, signature.Recv().Type(), nil + } + return a.get(args[index]), signature.Recv().Type(), nil + } + selectorIndex := index + if method { + selectorIndex-- + } + if selectorIndex < 0 || selectorIndex >= signature.Params().Len() { + return flowValue{}, nil, fmt.Errorf("configured argument is outside the API signature") + } + return a.get(args[index]), signature.Params().At(selectorIndex).Type(), nil +} + +func lifecycleMethodExpression(call ssa.CallInstruction, target *ssa.Function, declared *types.Signature) bool { + if call == nil || target == nil || declared == nil || declared.Recv() == nil || call.Common().IsInvoke() { + return false + } + callable, ok := call.Common().Value.Type().Underlying().(*types.Signature) + if !ok || callable.Params().Len() != declared.Params().Len()+1 { + return false + } + // Determine the receiver offset from the function value's callable + // signature. This also works when aliases or helper parameters make the + // call's StaticCallee unavailable; variadic arguments are packed into the + // final Common.Args entry after the explicit receiver. + return len(call.Common().Args) == callable.Params().Len() +} + +func lifecycleResultDiscarded(call ssa.CallInstruction, slot int) bool { + ordinary, ok := call.(*ssa.Call) + if !ok { + return true + } + refs := ordinary.Referrers() + if refs == nil { + return true + } + _, tuple := ordinary.Type().(*types.Tuple) + for _, instruction := range *refs { + if _, debug := instruction.(*ssa.DebugRef); debug { + continue + } + if !tuple { + return false + } + if extract, ok := instruction.(*ssa.Extract); ok && extract.Index == slot { + if uses := extract.Referrers(); uses != nil { + for _, use := range *uses { + if _, debug := use.(*ssa.DebugRef); !debug { + return false + } + } + } + } + } + return true +} + +func (a *flowAnalysis) configuredLifecycleUse(call ssa.CallInstruction, from string, ix *index, config Config) { + if len(config.LifecycleRules) == 0 { + return + } + boundRules := a.configuredLifecycleBoundUse(call, from, ix, config) + for _, target := range sortedFunctions(a.targets(call.Common())) { + symbol := lifecycleSymbol(target) + for index, rule := range config.LifecycleRules { + if symbol != rule.Symbol { + continue + } + // A captured wrapper invocation was already validated and selected + // against the declared method signature above. Its SSA wrapper target + // intentionally has no receiver, so treating it as an ordinary method + // call would emit a duplicate and false invalid-site warning. + if boundRules[index] { + continue + } + if a.lifecycleMatched == nil { + a.lifecycleMatched = map[string]bool{} + } + a.lifecycleMatched[symbol] = true + value, typ, err := lifecycleSelected(call, target, rule, a) + if err == nil { + if typeErr := lifecycleIdentityTypeError(rule.Identity, typ); typeErr != "" { + err = fmt.Errorf("%s", typeErr) + } + } + if err != nil { + if errors.Is(err, errUnsupportedMethodReceiverProjection) { + ix.boundaries = append(ix.boundaries, Boundary{Node: from, Kind: "unresolved_lifecycle_dispatch", Reason: symbol + ": " + err.Error() + "; no outer wrapper resource identity was substituted", Evidence: ix.callEvidence(call)}) + continue + } + ix.boundaries = append(ix.boundaries, Boundary{Node: from, Kind: "invalid_lifecycle_rule_site", Reason: symbol + ": " + err.Error(), Evidence: ix.callEvidence(call)}) + continue + } + if rule.Result != nil { + if rule.Identity == "origin" { + candidate := emptyFlow() + candidate.addresses[configuredLifecycleOrigin(call, rule, symbol, ix)] = true + a.merge(&value, candidate) + } + if lifecycleResultDiscarded(call, *rule.Result) { + ix.boundaries = append(ix.boundaries, Boundary{Node: from, Kind: "discarded_lifecycle_result", Reason: fmt.Sprintf("%s configured result %d has no modeled consumer; deferred/goroutine returns and unused slots cannot transfer their handle to the caller", symbol, *rule.Result), Evidence: ix.callEvidence(call)}) + } + } + candidates := value.addresses + if rule.Identity == "value" { + candidates = value.strings + } + resolved := false + outside := value.interfaceUnknown || value.sqlUnknown || value.functionUnknown || value.mapNil + for _, candidate := range sortedKeys(candidates) { + resolved = true + outside = outside || strings.HasPrefix(candidate, "input:") || a.inputRoots[candidate] + key := resourceID("lifecycle", rule.Namespace, candidate) + if ix.funcs[key] == nil { + ix.funcs[key] = &function{node: Node{ID: key, Name: "lifecycle " + rule.Namespace + " " + candidate, Kind: "lifecycle_resource", Evidence: ix.callEvidence(call)}} + } + ix.callEdge(from, key, "lifecycle_"+rule.Role, "possible", call) + } + if !resolved || outside || rule.Identity == "value" && value.stringUnknown { + ix.boundaries = append(ix.boundaries, Boundary{Node: from, Kind: "unresolved_lifecycle_resource", Reason: symbol + ": configured resource candidates are missing or include nil or outside values; ownership and runtime identity remain unresolved", Evidence: ix.callEvidence(call)}) + } + } + } +} + +func (a *flowAnalysis) configuredLifecycleBoundaries(ix *index, config Config) { + if len(config.LifecycleRules) == 0 { + return + } + for _, rule := range config.LifecycleRules { + if !a.lifecycleMatched[rule.Symbol] { + ix.boundaries = append(ix.boundaries, Boundary{Kind: "unmatched_lifecycle_rule", Reason: "No modeled API call matched lifecycle rule " + rule.Symbol + "; absent, unloaded or unsupported dispatch must be investigated."}) + } + } + ix.boundaries = append(ix.boundaries, Boundary{Kind: "lifecycle_rule_model", Reason: "Typed configured API roles connect string-key candidates or modeled handle origins through bounded value flow. Result origins identify a call site, slot and candidate target, not a successful acquisition or a unique runtime invocation. Outside inputs, aliasing, discarded results, unsupported dispatch and widening can add behavior. Roles, release success, ordering, ownership transfer and eventual cleanup are not proved."}) +} diff --git a/lifecycle_rules_test.go b/lifecycle_rules_test.go new file mode 100644 index 0000000..83d1512 --- /dev/null +++ b/lifecycle_rules_test.go @@ -0,0 +1,278 @@ +package contracttrace + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestConfiguredLifecycleKeysAndHandlesSurviveWrappersAndSnapshots(t *testing.T) { + root := t.TempDir() + files := map[string]string{ + "go.mod": "module example.com/lifeclient\n\ngo 1.27.0\nrequire example.com/lifeapi v0.0.0\nreplace example.com/lifeapi => ./api\n", + "api/go.mod": "module example.com/lifeapi\n\ngo 1.27.0\n", + "api/api.go": `package lifeapi +type Lease struct{} +func Acquire() *Lease {return &Lease{}} +func Pair() (*Lease,*Lease) {return &Lease{},&Lease{}} +func Drop(*Lease) {} +func (*Lease) Close() {} +func Reserve(string) {} +func ReleaseKey(string) {} +func RecoverKey(string) {} +`, + "client.go": `package lifeclient +import "example.com/lifeapi" +func Reserve() {lifeapi.Reserve("orders")} +func Cancel() {lifeapi.ReleaseKey("orders")} +func Recover() {lifeapi.RecoverKey("orders")} +func Other() {lifeapi.Reserve("billing")} +func Unknown(key string) {lifeapi.ReleaseKey(key)} +func External(h *lifeapi.Lease) {lifeapi.Drop(h)} +func KnownExternal() {h:=lifeapi.Acquire(); External(h)} +func private(h *lifeapi.Lease) {lifeapi.Drop(h)} +func Closed() {private(&lifeapi.Lease{})} +func StartHandle() {h:=lifeapi.Acquire(); defer lifeapi.Drop(h)} +func Wrap() *lifeapi.Lease {return lifeapi.Acquire()} +func UseWrap() {h:=Wrap(); go lifeapi.Drop(h)} +func Alias() {acquire:=lifeapi.Acquire; h:=acquire(); drop:=lifeapi.Drop; drop(h)} +func Receiver() {h:=lifeapi.Acquire(); h.Close()} +func Bound() {h:=lifeapi.Acquire(); close:=h.Close; close()} +func Tuple() {first,second:=lifeapi.Pair(); lifeapi.Drop(first); _=second} +func Discard() {lifeapi.Pair()} +func DeferredAcquire() {defer lifeapi.Acquire()} +func Channel() {h:=lifeapi.Acquire(); ch:=make(chan *lifeapi.Lease,1); ch<-h; lifeapi.Drop(<-ch)} +`, + } + for name, contents := range files { + path := filepath.Join(root, name) + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(contents), 0600); err != nil { + t.Fatal(err) + } + } + zero := 0 + one := 1 + config := DefaultConfig() + config.LifecycleRules = []LifecycleRule{ + {Symbol: "example.com/lifeapi::Reserve", Role: "acquire", Namespace: "keys", Identity: "value", Argument: &zero}, + {Symbol: "example.com/lifeapi::ReleaseKey", Role: "release", Namespace: "keys", Identity: "value", Argument: &zero}, + {Symbol: "example.com/lifeapi::RecoverKey", Role: "recover", Namespace: "keys", Identity: "value", Argument: &zero}, + {Symbol: "example.com/lifeapi::Acquire", Role: "acquire", Namespace: "handles", Identity: "origin", Result: &zero}, + {Symbol: "example.com/lifeapi::Drop", Role: "release", Namespace: "handles", Identity: "origin", Argument: &zero}, + {Symbol: "example.com/lifeapi::Lease.Close", Role: "release", Namespace: "handles", Identity: "origin", Receiver: true}, + {Symbol: "example.com/lifeapi::Pair", Role: "acquire", Namespace: "handles", Identity: "origin", Result: &zero}, + {Symbol: "example.com/lifeapi::Pair", Role: "acquire", Namespace: "handles", Identity: "origin", Result: &one}, + } + fresh, analysis, err := TraceWithAnalysis(context.Background(), Options{Root: root, Seeds: []string{"Reserve"}, Depth: 2, MaxNodes: 100, Config: config}) + if err != nil { + t.Fatal(err) + } + data, err := json.Marshal(analysis) + if err != nil { + t.Fatal(err) + } + saved, err := ReadAnalysis(strings.NewReader(string(data))) + if err != nil { + t.Fatal(err) + } + resumed, err := Explore(context.Background(), saved, ExploreOptions{Seeds: []string{"Reserve"}, Depth: 2, MaxNodes: 100}) + if err != nil { + t.Fatal(err) + } + for _, report := range []Report{fresh, resumed} { + for _, name := range []string{"Reserve", "Cancel", "Recover"} { + if !hasName(report, name) { + t.Errorf("configured lifecycle scope missed %s", name) + } + } + if hasName(report, "Other") { + t.Error("distinct resource value merged") + } + if report.ContractComplete { + t.Error("API mapping certified execution") + } + } + for _, pair := range [][2]string{{"StartHandle", "StartHandle"}, {"Wrap", "UseWrap"}, {"Alias", "Alias"}, {"Receiver", "Receiver"}, {"Bound", "Bound"}, {"Channel", "Channel"}} { + acquired := map[string]bool{} + for _, edge := range saved.Relationships { + if edge.From == "example.com/lifeclient::"+pair[0] && edge.Kind == "lifecycle_acquire" { + acquired[edge.To] = true + } + } + shared := false + for _, edge := range saved.Relationships { + if edge.From == "example.com/lifeclient::"+pair[1] && edge.Kind == "lifecycle_release" && acquired[edge.To] && edge.Certainty == "possible" && edge.Evidence.File == "client.go" { + shared = true + } + } + if !shared { + t.Errorf("lost acquire/release handle lineage %v", pair) + } + } + unknown := false + for _, boundary := range saved.Boundaries { + if boundary.Node == "example.com/lifeclient::Unknown" && boundary.Kind == "unresolved_lifecycle_resource" { + unknown = true + } + } + if !unknown { + t.Error("outside key input lacks unresolved lifecycle boundary") + } + external := false + for _, boundary := range saved.Boundaries { + if boundary.Kind == "unresolved_lifecycle_resource" && boundary.Node == "example.com/lifeclient::External" { + external = true + } + if boundary.Kind == "unresolved_lifecycle_resource" && boundary.Node == "example.com/lifeclient::private" { + t.Error("closed private handle input invented outside origin") + } + } + if !external { + t.Error("known caller concealed outside handles accepted by exported entry point") + } + for _, owner := range []string{"Discard", "DeferredAcquire"} { + acquired, discarded := false, false + for _, edge := range saved.Relationships { + if edge.From == "example.com/lifeclient::"+owner && edge.Kind == "lifecycle_acquire" { + acquired = true + } + } + for _, b := range saved.Boundaries { + if b.Node == "example.com/lifeclient::"+owner && b.Kind == "discarded_lifecycle_result" { + discarded = true + } + } + if !acquired || !discarded { + t.Errorf("discarded acquisition lost source role/boundary %s", owner) + } + } + first, second := "", "" + for _, node := range saved.Nodes { + if strings.Contains(node.ID, "configured") && strings.Contains(node.Name, "result") && strings.Contains(node.ID, "Tuple") { + if strings.HasSuffix(node.Name, "result 0") { + first = node.ID + } + if strings.HasSuffix(node.Name, "result 1") { + second = node.ID + } + } + } + firstReleased := false + for _, edge := range saved.Relationships { + if edge.From == "example.com/lifeclient::Tuple" && edge.Kind == "lifecycle_release" { + firstReleased = firstReleased || edge.To == first + if second != "" && edge.To == second { + t.Error("tuple result identities merged") + } + } + } + if first == "" || second == "" || !firstReleased { + t.Error("tuple acquisition/result-slot oracle missing") + } +} + +func TestLifecycleRuleConfigurationRejectsAmbiguousSelectors(t *testing.T) { + zero, negative := 0, -1 + base := LifecycleRule{Symbol: "example.com/api::Acquire", Role: "acquire", Namespace: "leases", Identity: "origin", Result: &zero} + for _, change := range []func(*LifecycleRule){ + func(r *LifecycleRule) { r.Argument = &zero }, func(r *LifecycleRule) { r.Receiver = true }, + func(r *LifecycleRule) { r.Result = nil }, func(r *LifecycleRule) { r.Result = &negative }, + func(r *LifecycleRule) { r.Namespace = "" }, func(r *LifecycleRule) { r.Identity = "guess" }, + func(r *LifecycleRule) { r.Role = "proven" }, func(r *LifecycleRule) { r.Symbol = "::Acquire" }, + } { + rule := base + change(&rule) + config := DefaultConfig() + config.LifecycleRules = []LifecycleRule{rule} + if err := validateConfig(config); err == nil { + t.Errorf("invalid lifecycle rule accepted: %+v", rule) + } + } +} + +func TestBoundLifecycleArgumentSelectorsPreserveDispatchAndOutsideInputs(t *testing.T) { + root := t.TempDir() + files := map[string]string{ + "go.mod": "module example.com/boundclient\n\ngo 1.27.0\nrequire example.com/boundapi v0.0.0\nreplace example.com/boundapi => ./api\n", + "api/go.mod": "module example.com/boundapi\n\ngo 1.27.0\n", + "api/api.go": `package boundapi +type Manager struct{} +func (*Manager) Reserve(string) {} +func (*Manager) Release(string) {} +`, + "client.go": `package boundclient +import "example.com/boundapi" +var manager boundapi.Manager +func BoundArgument() { + manager.Reserve("orders") + release := manager.Release + alias := release + invoke(alias, "orders") +} +func invoke(fn func(string), key string) { fn(key) } +func BoundPublic(key string) { + release := manager.Release + release(key) +} +`, + } + for name, contents := range files { + path := filepath.Join(root, name) + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(contents), 0600); err != nil { + t.Fatal(err) + } + } + zero := 0 + config := DefaultConfig() + config.LifecycleRules = []LifecycleRule{ + {Symbol: "example.com/boundapi::Manager.Reserve", Role: "acquire", Namespace: "keys", Identity: "value", Argument: &zero}, + {Symbol: "example.com/boundapi::Manager.Release", Role: "release", Namespace: "keys", Identity: "value", Argument: &zero}, + } + report, analysis, err := TraceWithAnalysis(context.Background(), Options{Root: root, Seeds: []string{"BoundArgument"}, Depth: 6, MaxNodes: 120, Config: config}) + if err != nil { + t.Fatal(err) + } + resource := "" + for _, edge := range analysis.Relationships { + if edge.From == "example.com/boundclient::BoundArgument" && edge.Kind == "lifecycle_acquire" && edge.Certainty == "possible" && edge.Evidence.File == "client.go" { + resource = edge.To + } + } + if resource == "" { + t.Fatal("direct key acquisition candidate missing from bound argument fixture") + } + joined := false + for _, edge := range analysis.Relationships { + if edge.From == "example.com/boundclient::invoke" && edge.To == resource && edge.Kind == "lifecycle_release" && edge.Certainty == "possible" && edge.Evidence.File == "client.go" { + joined = true + } + } + if !joined { + t.Fatalf("bound Release argument through alias/helper did not join direct acquisition candidate %q", resource) + } + for _, boundary := range report.Boundaries { + if boundary.Node == "example.com/boundclient::invoke" && boundary.Kind == "unresolved_lifecycle_dispatch" { + t.Fatalf("recognized configured bound argument selector remains explicitly unsupported at %s:%d: %s", boundary.Evidence.File, boundary.Evidence.Line, boundary.Reason) + } + } + public, _, err := TraceWithAnalysis(context.Background(), Options{Root: root, Seeds: []string{"BoundPublic"}, Depth: 6, MaxNodes: 120, Config: config}) + if err != nil { + t.Fatal(err) + } + outside := false + for _, boundary := range public.Boundaries { + outside = outside || boundary.Node == "example.com/boundclient::BoundPublic" && boundary.Kind == "unresolved_lifecycle_resource" && boundary.Evidence.File == "client.go" + } + if !outside { + t.Fatal("public outside bound key input lacks unresolved lifecycle-resource boundary") + } +} diff --git a/lifecycle_test.go b/lifecycle_test.go new file mode 100644 index 0000000..896036c --- /dev/null +++ b/lifecycle_test.go @@ -0,0 +1,207 @@ +package contracttrace + +import ( + "context" + "strings" + "testing" +) + +func TestContextLifecycleAcrossBoundaries(t *testing.T) { + r, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"Start", "WrapperStart", "Unreleased"}, Depth: 3, MaxNodes: 200}) + if err != nil { + t.Fatal(err) + } + cancel, observe, wrapped, missing := false, false, false, false + for _, e := range r.Relationships { + if e.Kind == "context_cancel" && strings.HasSuffix(e.From, "::Start") { + cancel = true + } + if e.Kind == "context_cancel" && strings.HasSuffix(e.From, "::WrapperStart") { + wrapped = true + } + if e.Kind == "context_observe" && strings.HasSuffix(e.From, "::Worker") { + observe = true + } + } + for _, b := range r.Boundaries { + if b.Kind == "unobserved_cancel" && strings.HasSuffix(b.Node, "::Unreleased") { + missing = true + } + } + if !cancel || !observe || !wrapped || !missing { + t.Errorf("lifecycle relationships missing: cancel=%t observe=%t wrapped=%t missing=%t", cancel, observe, wrapped, missing) + } +} + +func TestSeparateContextCreationSitesOnSameLine(t *testing.T) { + r, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"DualContexts"}, Depth: 2, MaxNodes: 100}) + if err != nil { + t.Fatal(err) + } + keys := map[string]bool{} + unreleased := false + for _, e := range r.Relationships { + if e.Kind == "context_create" && strings.HasSuffix(e.From, "::DualContexts") { + keys[e.To] = true + } + } + for _, b := range r.Boundaries { + if b.Kind == "unobserved_cancel" && strings.HasSuffix(b.Node, "::DualContexts") { + unreleased = true + } + } + if len(keys) != 2 || !unreleased { + t.Error("one canceled context concealed a separate uncanceled creation on the same line") + } +} + +func TestCancellationSignalConnectsWorkerAndCompletion(t *testing.T) { + r, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"CancellationStart"}, Depth: 4, MaxNodes: 250}) + if err != nil { + t.Fatal(err) + } + contextKey, completion := "", "" + for _, e := range r.Relationships { + if strings.HasSuffix(e.From, "::CancellationStart") && e.Kind == "context_cancel" { + contextKey = e.To + } + if strings.HasSuffix(e.From, "::CancellationStart") && e.Kind == "channel_create" { + completion = e.To + } + } + signal := "" + for _, e := range r.Relationships { + if e.From == contextKey && e.Kind == "context_done_signal" { + signal = e.To + } + } + wait, close, join := false, false, false + for _, e := range r.Relationships { + // SSA lowers a one-arm blocking select to an ordinary receive. + if strings.HasSuffix(e.From, "::CancellationWorker") && e.To == signal && (e.Kind == "channel_select_receive" || e.Kind == "channel_receive") { + wait = true + } + if strings.HasSuffix(e.From, "::CancellationWorker") && e.To == completion && e.Kind == "channel_close" { + close = true + } + if strings.HasSuffix(e.From, "::CancellationStart") && e.To == completion && e.Kind == "channel_receive" { + join = true + } + } + if contextKey == "" || signal == "" || !wait || !close || !join { + t.Errorf("shutdown investigation path missing: context=%s signal=%s wait=%t close=%t join=%t", contextKey, signal, wait, close, join) + } +} + +func TestDoneWrapperForwardsSignalAndNilDoneIsExplicit(t *testing.T) { + r, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"WrappedDoneStart", "NilDoneReceive", "DetachedDone"}, Depth: 4, MaxNodes: 250}) + if err != nil { + t.Fatal(err) + } + signal := "" + ctx := "" + for _, e := range r.Relationships { + if strings.HasSuffix(e.From, "::WrappedDoneStart") && e.Kind == "context_cancel" { + ctx = e.To + } + } + for _, e := range r.Relationships { + if e.From == ctx && e.Kind == "context_done_signal" { + signal = e.To + } + } + forwarded, nilBoundary, detached := false, false, false + for _, e := range r.Relationships { + if strings.HasSuffix(e.From, "::WaitSignal") && e.To == signal && e.Kind == "channel_receive" { + forwarded = true + } + if strings.HasSuffix(e.From, "::DetachedDone") && e.Kind == "context_detach" { + detached = true + } + } + for _, b := range r.Boundaries { + if strings.HasSuffix(b.Node, "::NilDoneReceive") && b.Kind == "nil_context_done_wait" { + nilBoundary = true + } + } + if signal == "" || !forwarded || !nilBoundary || !detached { + t.Errorf("Done semantics missing: signal=%s forward=%t nil=%t detached=%t", signal, forwarded, nilBoundary, detached) + } +} + +func TestDetachedContextDoesNotInheritCancellationSignal(t *testing.T) { + r, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"DetachStart", "NilDoneSelect"}, Depth: 4, MaxNodes: 250}) + if err != nil { + t.Fatal(err) + } + detached := "" + selectNil := false + for _, e := range r.Relationships { + if strings.HasSuffix(e.From, "::DetachedDone") && e.Kind == "context_detach" { + detached = e.To + } + } + for _, e := range r.Relationships { + if e.From == detached && (e.Kind == "context_cancellation_parent" || e.Kind == "context_done_signal") { + t.Errorf("WithoutCancel inherited a cancellation signal: %+v", e) + } + } + for _, b := range r.Boundaries { + if strings.HasSuffix(b.Node, "::NilDoneSelect") && b.Kind == "nil_context_done_select" { + selectNil = true + } + } + if detached == "" || !selectNil { + t.Errorf("detachment or disabled select arm missing: detached=%s select=%t", detached, selectNil) + } +} + +func TestDeadlineChildAndWrappedNilDone(t *testing.T) { + r, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"DeadlineChild", "WrappedNilDone"}, Depth: 4, MaxNodes: 250}) + if err != nil { + t.Fatal(err) + } + // Identify the independently expected constructor sites by source evidence, + // rather than accepting any context relationship in the report. + parent, child := "", "" + canceled := map[string]bool{} + for _, e := range r.Relationships { + if strings.HasSuffix(e.From, "::DeadlineChild") && e.Kind == "context_cancel" { + canceled[e.To] = true + } + } + for _, n := range r.Nodes { + if n.Kind != "context" || n.Evidence.File != "cancellation.go" || !canceled[n.ID] { + continue + } + if strings.Contains(n.Evidence.Snippet, "parent, cancelParent := context.WithCancel") { + parent = n.ID + } + if strings.Contains(n.Evidence.Snippet, "child, cancelChild := context.WithTimeout") { + child = n.ID + } + } + parentLink, signal := false, "" + for _, e := range r.Relationships { + if e.From == child && e.To == parent && e.Kind == "context_cancellation_parent" { + parentLink = true + } + if e.From == child && e.Kind == "context_done_signal" { + signal = e.To + } + } + wait, nilWait := false, false + for _, e := range r.Relationships { + if strings.HasSuffix(e.From, "::WaitSignal") && e.To == signal && e.Kind == "channel_receive" { + wait = true + } + } + for _, b := range r.Boundaries { + if strings.HasSuffix(b.Node, "::WrappedNilDone") && b.Kind == "nil_context_done_wait" { + nilWait = true + } + } + if parent == "" || child == "" || !parentLink || signal == "" || !wait || !nilWait { + t.Errorf("deadline/nil inheritance missing: parent=%s child=%s link=%t signal=%s wait=%t nil=%t", parent, child, parentLink, signal, wait, nilWait) + } +} diff --git a/loaded_sources.go b/loaded_sources.go new file mode 100644 index 0000000..fd7cd87 --- /dev/null +++ b/loaded_sources.go @@ -0,0 +1,115 @@ +package contracttrace + +import ( + "context" + "encoding/hex" + "encoding/json" + "fmt" + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "sort" + "strings" + "sync" +) + +type LoadedSource struct { + Path string `json:"path"` + SHA256 string `json:"sha256"` +} + +func validateSourceInventory(sources []LoadedSource, identity string) error { + if len(sources) == 0 && identity == "" { + return nil + } + if loadedSourceIdentity(sources) != identity { + return fmt.Errorf("saved loaded-source identity does not match its inventory") + } + previous := "" + for _, source := range sources { + absolute := strings.HasPrefix(source.Path, "/") || len(source.Path) >= 3 && source.Path[1] == ':' && source.Path[2] == '/' + if !absolute || strings.ContainsRune(source.Path, 0) || source.Path <= previous || len(source.SHA256) != 64 { + return fmt.Errorf("invalid saved loaded-source entry") + } + if _, err := hex.DecodeString(source.SHA256); err != nil { + return fmt.Errorf("invalid saved loaded-source digest") + } + previous = source.Path + } + return nil +} + +// packages.Load can parse files concurrently. Capture the exact bytes supplied +// to the parser, including dependency, replacement and generated Go sources. +type loadedSourceCapture struct { + mu sync.Mutex + files map[string]string +} + +func (c *loadedSourceCapture) parseFile(fset *token.FileSet, filename string, contents []byte) (*ast.File, error) { + if contents == nil { + var err error + contents, err = os.ReadFile(filename) + if err != nil { + return nil, err + } + } + abs, err := filepath.Abs(filename) + if err != nil { + return nil, err + } + path := filepath.ToSlash(abs) + digest := hashText(string(contents)) + c.mu.Lock() + if c.files == nil { + c.files = map[string]string{} + } + previous, found := c.files[path] + c.files[path] = digest + c.mu.Unlock() + if found && previous != digest { + return nil, fmt.Errorf("loaded source changed during package loading: %s", path) + } + return parser.ParseFile(fset, filename, contents, parser.AllErrors|parser.ParseComments|parser.SkipObjectResolution) +} + +func (c *loadedSourceCapture) sources() []LoadedSource { + c.mu.Lock() + defer c.mu.Unlock() + paths := make([]string, 0, len(c.files)) + for path := range c.files { + paths = append(paths, path) + } + sort.Strings(paths) + result := make([]LoadedSource, 0, len(paths)) + for _, path := range paths { + result = append(result, LoadedSource{path, c.files[path]}) + } + return result +} + +func loadedSourceIdentity(sources []LoadedSource) string { + if len(sources) == 0 { + return "" + } + contents, _ := json.Marshal(sources) + return hashText(string(contents)) +} + +func verifyLoadedSources(ctx context.Context, sources []LoadedSource) error { + for _, source := range sources { + if err := ctx.Err(); err != nil { + return err + } + contents, err := os.ReadFile(filepath.FromSlash(source.Path)) + if err != nil { + return fmt.Errorf("loaded source unavailable %s: %w", source.Path, err) + } + if hashText(string(contents)) != source.SHA256 { + return fmt.Errorf("loaded source changed: %s; rerun analysis on stable sources", source.Path) + } + } + return ctx.Err() +} diff --git a/loaded_sources_test.go b/loaded_sources_test.go new file mode 100644 index 0000000..2c08fc8 --- /dev/null +++ b/loaded_sources_test.go @@ -0,0 +1,195 @@ +package contracttrace + +import ( + "bytes" + "context" + "go/token" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestLoadedSourceCaptureRejectsMutationAndHonorsCancellation(t *testing.T) { + file := filepath.Join(t.TempDir(), "dependency.go") + contents := []byte("package dependency\nconst Marker = 1\n") + if err := os.WriteFile(file, contents, 0644); err != nil { + t.Fatal(err) + } + capture := &loadedSourceCapture{} + if _, err := capture.parseFile(token.NewFileSet(), file, nil); err != nil { + t.Fatal(err) + } + sources := capture.sources() + if err := verifyLoadedSources(context.Background(), sources); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if err := verifyLoadedSources(ctx, sources); err != context.Canceled { + t.Fatalf("canceled verification: %v", err) + } + changed := []byte("package dependency\nconst Marker = 2\n") + if err := os.WriteFile(file, changed, 0644); err != nil { + t.Fatal(err) + } + if err := verifyLoadedSources(context.Background(), sources); err == nil || !strings.Contains(err.Error(), "loaded source changed") { + t.Fatalf("mutation during analysis accepted: %v", err) + } + if _, err := capture.parseFile(token.NewFileSet(), file, changed); err == nil { + t.Fatal("two versions of one loaded file accepted") + } +} + +func TestSavedLoadedSourceInventoryRejectsInvalidEntries(t *testing.T) { + for _, sources := range [][]LoadedSource{ + {{Path: "relative.go", SHA256: strings.Repeat("0", 64)}}, + {{Path: "/dependency.go", SHA256: strings.Repeat("g", 64)}}, + {{Path: "/dependency.go", SHA256: strings.Repeat("0", 64)}, {Path: "/dependency.go", SHA256: strings.Repeat("0", 64)}}, + } { + analysis := Analysis{Schema: analysisSchema, Root: "/repo", Coverage: Coverage{SourceSHA256: strings.Repeat("0", 64), LoadedSources: sources, LoadedSourceSHA256: loadedSourceIdentity(sources)}} + if err := analysis.validate(); err == nil || !strings.Contains(err.Error(), "loaded-source") { + t.Fatalf("invalid loaded source entry accepted: %+v / %v", sources, err) + } + } +} + +func TestLocalDependencyEditChangesAnalysisIdentity(t *testing.T) { + base := t.TempDir() + root, dependency := filepath.Join(base, "app"), filepath.Join(base, "dependency") + for _, dir := range []string{root, dependency} { + if err := os.Mkdir(dir, 0755); err != nil { + t.Fatal(err) + } + } + files := map[string]string{ + filepath.Join(root, "go.mod"): "module example.com/app\n\ngo 1.27.0\n\nrequire example.com/dependency v0.0.0\nreplace example.com/dependency => ../dependency\n", + filepath.Join(root, "app.go"): "package app\nimport \"example.com/dependency\"\nfunc Seed() { _ = dependency.Marker() }\n", + filepath.Join(dependency, "go.mod"): "module example.com/dependency\n\ngo 1.27.0\n", + filepath.Join(dependency, "dependency.go"): "package dependency\nfunc Marker() int { return 1 }\n", + } + for path, contents := range files { + if err := os.WriteFile(path, []byte(contents), 0644); err != nil { + t.Fatal(err) + } + } + options := Options{Root: root, Seeds: []string{"Seed"}, Depth: 1, MaxNodes: 20} + first, saved, err := TraceWithAnalysis(context.Background(), options) + if err != nil { + t.Fatal(err) + } + if len(first.Coverage.LoadedSources) < 2 || first.Coverage.LoadedSourceSHA256 == "" { + t.Fatal("loaded source inventory missing") + } + if err := os.WriteFile(filepath.Join(dependency, "dependency.go"), []byte("package dependency\nfunc Marker() int { return 2 }\n"), 0644); err != nil { + t.Fatal(err) + } + second, err := Trace(context.Background(), options) + if err != nil { + t.Fatal(err) + } + if first.Coverage.SourceSHA256 == second.Coverage.SourceSHA256 { + t.Fatal("dependency source edit retained the old analysis identity") + } + _, err = Explore(context.Background(), saved, ExploreOptions{Seeds: []string{"Seed"}, Depth: 1, MaxNodes: 20}) + if err == nil || !strings.Contains(err.Error(), "loaded source changed") { + t.Fatalf("stale dependency snapshot accepted: %v", err) + } +} + +func TestExploreRejectsAddedLocalDependencySource(t *testing.T) { + base := t.TempDir() + root, dependency := filepath.Join(base, "app"), filepath.Join(base, "dependency") + for _, dir := range []string{root, dependency} { + if err := os.Mkdir(dir, 0755); err != nil { + t.Fatal(err) + } + } + files := map[string]string{ + filepath.Join(root, "go.mod"): "module example.com/app\n\ngo 1.27.0\n\nrequire example.com/dependency v0.0.0\nreplace example.com/dependency => ../dependency\n", + filepath.Join(root, "app.go"): "package app\nimport \"example.com/dependency\"\nfunc Seed() { _ = dependency.Marker() }\n", + filepath.Join(root, "app_test.go"): "package app\nimport \"testing\"\nfunc TestBuildSelection(t *testing.T) {}\n", + filepath.Join(dependency, "go.mod"): "module example.com/dependency\n\ngo 1.27.0\n", + filepath.Join(dependency, "dependency.go"): "package dependency\nfunc Marker() int { return 1 }\n", + filepath.Join(dependency, "tagged.go"): "//go:build selection_probe\n\npackage dependency\nconst Selected = true\n", + } + for path, contents := range files { + if err := os.WriteFile(path, []byte(contents), 0644); err != nil { + t.Fatal(err) + } + } + t.Setenv("GOWORK", "off") + _, saved, err := TraceWithAnalysis(context.Background(), Options{Root: root, Seeds: []string{"Seed"}, Depth: 1, MaxNodes: 20, Tests: true, Tags: "selection_probe"}) + if err != nil { + t.Fatalf("trace initial dependency selection: %v", err) + } + var compressed bytes.Buffer + if err := WriteAnalysis(&compressed, saved, true); err != nil { + t.Fatalf("write selected-source snapshot: %v", err) + } + saved, err = ReadAnalysis(&compressed) + if err != nil { + t.Fatalf("read selected-source snapshot: %v", err) + } + if _, err := Explore(context.Background(), saved, ExploreOptions{Seeds: []string{"Seed"}, Depth: 1, MaxNodes: 20}); err != nil { + t.Fatalf("stable gzip snapshot did not explore with saved Tests/tags: %v", err) + } + if err := os.WriteFile(filepath.Join(dependency, "unselected.go"), []byte("//go:build another_selection\n\npackage dependency\nconst Unselected = true\n"), 0644); err != nil { + t.Fatal(err) + } + if _, err := Explore(context.Background(), saved, ExploreOptions{Seeds: []string{"Seed"}, Depth: 1, MaxNodes: 20}); err != nil { + t.Fatalf("unselected build-tag file changed selected source identity: %v", err) + } + assertChanged := func(label string) { + t.Helper() + if _, err := Explore(context.Background(), saved, ExploreOptions{Seeds: []string{"Seed"}, Depth: 1, MaxNodes: 20}); err == nil || !strings.Contains(err.Error(), "selected Go source set changed") { + t.Fatalf("saved analysis accepted dependency source %s: %v", label, err) + } + } + assertMissing := func(label string) { + t.Helper() + if _, err := Explore(context.Background(), saved, ExploreOptions{Seeds: []string{"Seed"}, Depth: 1, MaxNodes: 20}); err == nil || !strings.Contains(err.Error(), "loaded source unavailable") { + t.Fatalf("saved analysis accepted dependency source %s: %v", label, err) + } + } + tagged, renamed := filepath.Join(dependency, "tagged.go"), filepath.Join(dependency, "renamed.go") + if err := os.Rename(tagged, renamed); err != nil { + t.Fatal(err) + } + assertMissing("rename") + if err := os.Rename(renamed, tagged); err != nil { + t.Fatal(err) + } + if err := os.Remove(tagged); err != nil { + t.Fatal(err) + } + assertMissing("removal") + if err := os.WriteFile(tagged, []byte("//go:build selection_probe\n\npackage dependency\nconst Selected = true\n"), 0644); err != nil { + t.Fatal(err) + } + added := filepath.Join(dependency, "added.go") + if err := os.WriteFile(added, []byte("//go:build selection_probe\n\npackage dependency\nfunc Added() int { return 2 }\n"), 0644); err != nil { + t.Fatal(err) + } + assertChanged("addition") +} + +func TestExploreRejectsLegacyAnalysisWithoutSourceInventory(t *testing.T) { + _, saved, err := TraceWithAnalysis(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"Validate"}, Depth: 1, MaxNodes: 20}) + if err != nil { + t.Fatal(err) + } + saved.Coverage.LoadedSources = nil + saved.Coverage.LoadedSourceSHA256 = "" + var compressed bytes.Buffer + if err := WriteAnalysis(&compressed, saved, true); err != nil { + t.Fatalf("write legacy snapshot: %v", err) + } + saved, err = ReadAnalysis(&compressed) + if err != nil { + t.Fatalf("read legacy snapshot: %v", err) + } + if _, err := Explore(context.Background(), saved, ExploreOptions{Seeds: []string{"Validate"}, Depth: 1, MaxNodes: 20}); err == nil || !strings.Contains(err.Error(), "lacks the selected Go source inventory") { + t.Fatalf("analysis without selected-source inventory was reused: %v", err) + } +} diff --git a/location.go b/location.go new file mode 100644 index 0000000..46ccb71 --- /dev/null +++ b/location.go @@ -0,0 +1,65 @@ +package contracttrace + +import ( + "fmt" + "path/filepath" + "sort" + "strconv" + "strings" +) + +func declarationKind(kind string) bool { + return kind == "global" || kind == "record_field" || kind == "event_discriminator_field" +} + +func (ix *index) locationSeeds(locations []string) ([]string, error) { + symbols := []string{} + for _, location := range locations { + colon := strings.LastIndex(location, ":") + if colon < 0 { + return nil, fmt.Errorf("location must be file:line: %q", location) + } + line, err := strconv.Atoi(location[colon+1:]) + if err != nil || line < 1 { + return nil, fmt.Errorf("invalid location line: %q", location) + } + path := filepath.FromSlash(location[:colon]) + if !filepath.IsAbs(path) { + path = filepath.Join(ix.root, path) + } + file, inside := relative(ix.root, filepath.Clean(path)) + if !inside { + return nil, fmt.Errorf("location is outside target root: %q", location) + } + matches := []string{} + for id, f := range ix.funcs { + if f.node.Evidence.File != file { + continue + } + first, last := 0, 0 + if f.decl != nil { + first, last = ix.fset.Position(f.decl.Pos()).Line, ix.fset.Position(f.decl.End()).Line + } else if span, ok := ix.ranges[id]; ok { + first, last = span.First, span.Last + } else if span, ok := ix.declarationRanges[id]; ok { + first, last = span.First, span.Last + } else if declarationKind(f.node.Kind) { + first, last = f.node.Evidence.Line, f.node.Evidence.Line + } else { + continue + } + if line >= first && line <= last { + matches = append(matches, id) + } + } + sort.Strings(matches) + if len(matches) == 0 { + return nil, fmt.Errorf("no indexed function, global or record-field declaration contains location %q; unindexed package-level initializers are not supported", location) + } + if len(matches) > 1 { + return nil, fmt.Errorf("ambiguous location %q: %s", location, strings.Join(matches, ", ")) + } + symbols = append(symbols, matches[0]) + } + return symbols, nil +} diff --git a/location_test.go b/location_test.go new file mode 100644 index 0000000..19caaed --- /dev/null +++ b/location_test.go @@ -0,0 +1,286 @@ +package contracttrace + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestRecordFieldLocationSurvivesSerializationAndRetainsUsers(t *testing.T) { + root := t.TempDir() + for name, contents := range map[string]string{ + "go.mod": "module example.com/fieldlocation\n\ngo 1.27.0\n", + "state.go": `package fieldlocation +type Counter struct { + remaining [ + 2]int + other int +} + +func Read(c *Counter) int { return c.remaining[0] } +func Write(c *Counter, n int) { c.remaining[0] = n } +func Other(c *Counter) int { return c.other } +`, + } { + if err := os.WriteFile(filepath.Join(root, name), []byte(contents), 0644); err != nil { + t.Fatal(err) + } + } + fresh, saved, err := TraceWithAnalysis(context.Background(), Options{Root: root, Locations: []string{"state.go:4"}, Depth: 1, MaxNodes: 100}) + if err != nil { + t.Fatal(err) + } + encoded, err := json.Marshal(saved) + if err != nil { + t.Fatal(err) + } + loaded, err := ReadAnalysis(strings.NewReader(string(encoded))) + if err != nil { + t.Fatal(err) + } + resumed, err := Explore(context.Background(), loaded, ExploreOptions{Locations: []string{"state.go:4"}, Depth: 1, MaxNodes: 100}) + if err != nil { + t.Fatal(err) + } + for _, report := range []Report{fresh, resumed} { + if len(report.Seeds) != 1 || report.Seeds[0] != "field:example.com/fieldlocation::Counter.remaining" { + t.Errorf("wrong declaration seed: %v", report.Seeds) + } + if !hasName(report, "Read") || !hasName(report, "Write") || hasName(report, "Other") { + t.Error("field location lost precise sibling scope") + } + } + id := "field:example.com/fieldlocation::Counter.remaining" + span, ok := loaded.DeclarationRanges[id] + if !ok || span.First != 3 || span.Last != 4 { + t.Fatalf("wrong saved field span: %+v", span) + } + loaded.DeclarationRanges[id] = SourceRange{4, 4} + if _, err := Explore(context.Background(), loaded, ExploreOptions{Seeds: []string{id}, Depth: 1, MaxNodes: 100}); err == nil { + t.Error("range inconsistent with declaration evidence accepted") + } + loaded.DeclarationRanges = nil + if _, err := Explore(context.Background(), loaded, ExploreOptions{Locations: []string{"state.go:3"}, Depth: 1, MaxNodes: 100}); err != nil { + t.Errorf("legacy declaration-line fallback: %v", err) + } + if _, err := Explore(context.Background(), loaded, ExploreOptions{Locations: []string{"state.go:4"}, Depth: 1, MaxNodes: 100}); err == nil { + t.Error("legacy snapshot invented continuation range") + } +} + +func TestSourceLocationSeed(t *testing.T) { + r, err := Trace(context.Background(), Options{Root: "testdata/sample", Locations: []string{"sample.go:8"}, Depth: 3, MaxNodes: 150}) + if err != nil { + t.Fatal(err) + } + if !hasName(r, "Validate") || !hasName(r, "Recovery") { + t.Error("source location did not start contract discovery at its containing function") + } + _, err = Trace(context.Background(), Options{Root: "testdata/sample", Locations: []string{"../outside.go:8"}, Depth: 3, MaxNodes: 150}) + if err == nil { + t.Error("outside-root location accepted") + } +} + +func TestGlobalInitializerLocationSeedSurvivesSavedExploration(t *testing.T) { + source, err := os.ReadFile("testdata/sample/databases.go") + if err != nil { + t.Fatal(err) + } + position := strings.Index(string(source), "privateTextConsumer, // location seed") + if position < 0 { + t.Fatal("missing independent source-location fixture") + } + location := fmt.Sprintf("databases.go:%d", strings.Count(string(source[:position]), "\n")+1) + fresh, analysis, err := TraceWithAnalysis(context.Background(), Options{Root: "testdata/sample", Locations: []string{location}, Depth: 2, MaxNodes: 100}) + if err != nil { + t.Fatal(err) + } + serialized, err := json.Marshal(analysis) + if err != nil { + t.Fatal(err) + } + loaded, err := ReadAnalysis(strings.NewReader(string(serialized))) + if err != nil { + t.Fatal(err) + } + resumed, err := Explore(context.Background(), loaded, ExploreOptions{Locations: []string{location}, Depth: 2, MaxNodes: 100}) + if err != nil { + t.Fatal(err) + } + for _, report := range []Report{fresh, resumed} { + seed, callback := false, false + for _, node := range report.Nodes { + seed = seed || node.Name == "PublicLocationCallbacks" && node.Distance == 0 + } + for _, edge := range report.Relationships { + callback = callback || edge.Kind == "callback_global_escape" && strings.HasSuffix(edge.To, "::privateTextConsumer") + } + if !seed || !callback { + t.Fatalf("global location lost scope: seed=%t callback=%t", seed, callback) + } + } + for id, span := range loaded.DeclarationRanges { + if !strings.HasSuffix(id, "::PublicLocationCallbacks") { + continue + } + loaded.DeclarationRanges[id] = SourceRange{span.First, span.First - 1} + if _, err := Explore(context.Background(), loaded, ExploreOptions{Locations: []string{location}, Depth: 2, MaxNodes: 100}); err == nil { + t.Fatal("malformed saved declaration range accepted") + } + loaded.DeclarationRanges = nil // Older analyses have only declaration evidence. + declaration := fmt.Sprintf("databases.go:%d", span.First) + if _, err := Explore(context.Background(), loaded, ExploreOptions{Locations: []string{declaration}, Depth: 2, MaxNodes: 100}); err != nil { + t.Fatalf("legacy declaration-line seed: %v", err) + } + if _, err := Explore(context.Background(), loaded, ExploreOptions{Locations: []string{location}, Depth: 2, MaxNodes: 100}); err == nil { + t.Fatal("legacy graph invented an initializer range") + } + return + } + t.Fatal("saved graph missing global declaration range") +} + +func TestGlobalLocationRejectsAmbiguousDeclarations(t *testing.T) { + ix := &index{root: t.TempDir(), funcs: map[string]*function{}, declarationRanges: map[string]SourceRange{}} + for _, id := range []string{"global:sample::First", "global:sample::Second"} { + ix.funcs[id] = &function{node: Node{ID: id, Kind: "global", Evidence: Evidence{File: "globals.go", Line: 3}}} + ix.declarationRanges[id] = SourceRange{3, 5} + } + if _, err := ix.locationSeeds([]string{"globals.go:4"}); err == nil || !strings.Contains(err.Error(), "ambiguous location") { + t.Fatalf("shared declaration must not guess an owner: %v", err) + } +} + +func TestPrivateGlobalLocationDiscoversReadersAndWriters(t *testing.T) { + source, err := os.ReadFile("testdata/sample/databases.go") + if err != nil { + t.Fatal(err) + } + position := strings.Index(string(source), "value: \"initial\", // private global location seed") + if position < 0 { + t.Fatal("missing private global source fixture") + } + location := fmt.Sprintf("databases.go:%d", strings.Count(string(source[:position]), "\n")+1) + fresh, analysis, err := TraceWithAnalysis(context.Background(), Options{Root: "testdata/sample", Locations: []string{location}, Depth: 1, MaxNodes: 100}) + if err != nil { + t.Fatal(err) + } + encoded, err := json.Marshal(analysis) + if err != nil { + t.Fatal(err) + } + loaded, err := ReadAnalysis(strings.NewReader(string(encoded))) + if err != nil { + t.Fatal(err) + } + resumed, err := Explore(context.Background(), loaded, ExploreOptions{Locations: []string{location}, Depth: 1, MaxNodes: 100}) + if err != nil { + t.Fatal(err) + } + for _, report := range []Report{fresh, resumed} { + expected := map[string]string{"readPrivateLocationState": "global_read", "siblingPrivateLocationState": "global_read", "setPrivateLocationState": "global_write"} + for _, edge := range report.Relationships { + for owner, kind := range expected { + if strings.HasSuffix(edge.From, "::"+owner) && edge.Kind == kind && edge.To == "global:example.com/sample::privateLocationState" { + delete(expected, owner) + } + } + } + if len(expected) != 0 { + t.Fatalf("missing private state paths: %v", expected) + } + if hasName(report, "writeUnrelatedPrivateState") { + t.Fatal("unrelated storage joined private global") + } + for _, boundary := range report.Boundaries { + if boundary.Node == "global:example.com/sample::privateLocationState" && strings.HasPrefix(boundary.Kind, "external_") { + t.Fatal("private storage classified as exported") + } + } + } +} + +func TestRecordFieldLocationRejectsGroupedDeclarationAmbiguity(t *testing.T) { + root := t.TempDir() + for name, contents := range map[string]string{ + "go.mod": "module example.com/groupedfields\n\ngo 1.27.0\n", + "fields.go": "package groupedfields\ntype Pair struct {\n first, second [\n 2]int\n}\nfunc Read(p *Pair) int { return p.first[0] + p.second[0] }\n", + } { + if err := os.WriteFile(filepath.Join(root, name), []byte(contents), 0644); err != nil { + t.Fatal(err) + } + } + _, saved, err := TraceWithAnalysis(context.Background(), Options{Root: root, Seeds: []string{"Read"}, Depth: 1, MaxNodes: 100}) + if err != nil { + t.Fatal(err) + } + encoded, err := json.Marshal(saved) + if err != nil { + t.Fatal(err) + } + loaded, err := ReadAnalysis(strings.NewReader(string(encoded))) + if err != nil { + t.Fatal(err) + } + for _, resume := range []bool{false, true} { + if resume { + _, err = Explore(context.Background(), loaded, ExploreOptions{Locations: []string{"fields.go:4"}, Depth: 1, MaxNodes: 100}) + } else { + _, err = Trace(context.Background(), Options{Root: root, Locations: []string{"fields.go:4"}, Depth: 1, MaxNodes: 100}) + } + if err == nil || !strings.Contains(err.Error(), "ambiguous location") || !strings.Contains(err.Error(), "Pair.first") || !strings.Contains(err.Error(), "Pair.second") { + t.Errorf("ambiguous declaration guessed an owner: %v", err) + } + } + for i := range loaded.Nodes { + if loaded.Nodes[i].ID == "field:example.com/groupedfields::Pair.first" { + loaded.Nodes[i].Kind = "table" + } + } + if _, err := Explore(context.Background(), loaded, ExploreOptions{Seeds: []string{"Read"}, Depth: 1, MaxNodes: 100}); err == nil { + t.Error("declaration range accepted for a non-declaration resource") + } +} + +func TestEventDiscriminatorDeclarationLineExplicitlySeedsAllUsers(t *testing.T) { + source, err := os.ReadFile("testdata/sample/sample.go") + if err != nil { + t.Fatal(err) + } + position := strings.Index(string(source), "type Event struct") + if position < 0 { + t.Fatal("event declaration fixture missing") + } + location := fmt.Sprintf("sample.go:%d", strings.Count(string(source[:position]), "\n")+1) + fresh, saved, err := TraceWithAnalysis(context.Background(), Options{Root: "testdata/sample", Locations: []string{location}, Depth: 1, MaxNodes: 100}) + if err != nil { + t.Fatal(err) + } + encoded, err := json.Marshal(saved) + if err != nil { + t.Fatal(err) + } + loaded, err := ReadAnalysis(strings.NewReader(string(encoded))) + if err != nil { + t.Fatal(err) + } + resumed, err := Explore(context.Background(), loaded, ExploreOptions{Locations: []string{location}, Depth: 1, MaxNodes: 100}) + if err != nil { + t.Fatal(err) + } + for _, report := range []Report{fresh, resumed} { + for _, name := range []string{"Publish", "Consume", "Another"} { + if !hasName(report, name) { + t.Errorf("field declaration location missing %s", name) + } + } + if len(report.Seeds) != 1 || report.Seeds[0] != "field:example.com/sample::Event.EventType" { + t.Errorf("wrong explicit field seed: %v", report.Seeds) + } + } +} diff --git a/map_iteration_test.go b/map_iteration_test.go new file mode 100644 index 0000000..dfd7922 --- /dev/null +++ b/map_iteration_test.go @@ -0,0 +1,84 @@ +package contracttrace + +import ( + "context" + "encoding/json" + "strings" + "testing" +) + +func TestMapIterationRetainsSeparateKeysValuesAndRecordCallbacks(t *testing.T) { + options := Options{Root: "testdata/sample", Seeds: []string{"MapIterationCaller"}, Depth: 4, MaxNodes: 250, + Config: Config{StorageScopes: []StorageScope{{Namespace: "orders", DatabaseOrigins: []string{"example.com/sample::OpenOrdersDatabase"}}}}} + fresh, analysis, err := TraceWithAnalysis(context.Background(), options) + if err != nil { + t.Fatal(err) + } + encoded, err := json.Marshal(analysis) + if err != nil { + t.Fatal(err) + } + loaded, err := ReadAnalysis(strings.NewReader(string(encoded))) + if err != nil { + t.Fatal(err) + } + resumed, err := Explore(context.Background(), loaded, ExploreOptions{Seeds: options.Seeds, Depth: options.Depth, MaxNodes: options.MaxNodes}) + if err != nil { + t.Fatal(err) + } + for _, report := range []Report{fresh, resumed} { + for _, callback := range []string{"iterationValueQuery", "iterationKeyQuery", "iterationRecordQuery"} { + found := false + for _, edge := range report.Relationships { + found = found || edge.Kind == "resolved_callback_call" && strings.HasSuffix(edge.From, "::MapIterationCaller") && strings.HasSuffix(edge.To, "::"+callback) + } + if !found { + t.Errorf("map iteration lost callback %s", callback) + } + } + for owner, table := range map[string]string{ + "iterationValueQuery": "iteration_callback_records", "iterationKeyQuery": "iteration_key_callback_records", "iterationRecordQuery": "iteration_record_callback_records", + "consumeIterationKey": "iteration_key_records", "consumeIterationValue": "iteration_value_records", + } { + tables := map[string]bool{} + for _, edge := range report.Relationships { + if edge.Kind == "sql_read" && strings.HasSuffix(edge.From, "::"+owner) { + tables[edge.To] = true + } + } + if len(tables) != 1 || !tables["table:orders:"+table] { + t.Errorf("%s iteration input: %v", owner, tables) + } + } + } +} + +func TestExportedMapIterationRetainsOutsideSQLCandidates(t *testing.T) { + r, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"PublicMapIterationCaller"}, Depth: 3, MaxNodes: 150, + Config: Config{StorageScopes: []StorageScope{{Namespace: "orders", DatabaseOrigins: []string{"example.com/sample::OpenOrdersDatabase"}}}}}) + if err != nil { + t.Fatal(err) + } + tables := map[string]bool{} + for _, edge := range r.Relationships { + if edge.Kind == "sql_read" && strings.HasSuffix(edge.From, "::publicIterationDatabaseQuery") { + tables[edge.To] = true + } + } + if len(tables) != 2 || !tables["table:orders:iteration_public_database_records"] || !tables["table:iteration_public_database_records"] { + t.Fatalf("iteration lost known or outside map inputs: %v", tables) + } +} + +func TestUnknownMapIterationExposesMissingStorageOrigin(t *testing.T) { + r, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"UnknownMapIteration"}, Depth: 1, MaxNodes: 50}) + if err != nil { + t.Fatal(err) + } + for _, boundary := range r.Boundaries { + if boundary.Kind == "unresolved_map_iteration" && strings.HasSuffix(boundary.Node, "::UnknownMapIteration") && strings.Contains(boundary.Evidence.Snippet, "range callbacks") { + return + } + } + t.Fatal("unknown incoming map silently looks empty") +} diff --git a/map_lookup_sql_unknown_test.go b/map_lookup_sql_unknown_test.go new file mode 100644 index 0000000..756f007 --- /dev/null +++ b/map_lookup_sql_unknown_test.go @@ -0,0 +1,945 @@ +package contracttrace + +import ( + "bytes" + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestMapLookupRetainsSQLHandleUncertainty(t *testing.T) { + root := t.TempDir() + write := func(name, source string) { + t.Helper() + if err := os.WriteFile(filepath.Join(root, name), []byte(source), 0644); err != nil { + t.Fatal(err) + } + } + write("go.mod", "module example.com/maplookup\n\ngo 1.27.0\n") + write("app.go", `package maplookup + +import ( + "database/sql" + "context" + "sync" +) + +type dbRecord struct { DB *sql.DB } +type innerRecord struct { DB *sql.DB } +type outerRecord struct { Inner innerRecord } +type namedHandles map[string]*sql.DB +type leaseMap map[string]int +type Lease struct{} +type leaseRecord struct { Lease *Lease } +type contextRecord struct { Ctx context.Context; Ready chan struct{} } +type contextSlot context.Context + +func knownTask() {} + +func AcquireLease() *Lease { return &Lease{} } +func (*Lease) Close() {} +func AcquireSlot() **Lease { + var record leaseRecord + return &record.Lease +} +func DropSlot(**Lease) {} +func AcquireMap() map[string]int { return make(map[string]int) } +func DropMap(map[string]int) {} +func AcquireLeaseMap() leaseMap { return make(leaseMap) } +func (leaseMap) Close() {} + +func OpenPrimary() *sql.DB { + db, _ := sql.Open("driver", "primary") + return db +} + +func lookupHandle[T any](handles map[string]T, key string) T { return handles[key] } + +func QueryAfterDelete(mutate bool) { + db := OpenPrimary() + handles := map[string]*sql.DB{"known": db} + if mutate { + delete(handles, "known") + } + handles["known"].Query("SELECT id FROM delete_records") +} + +func QueryAfterClear(mutate bool) { + db := OpenPrimary() + handles := map[string]*sql.DB{"known": db} + if mutate { + clear(handles) + } + handles["known"].Query("SELECT id FROM clear_records") +} + +func QueryWithMissingKeyAlternative(key string) { + db := OpenPrimary() + handles := map[string]*sql.DB{"known": db} + handles[key].Query("SELECT id FROM dynamic_records") +} + +func QueryMissingKey() { + db := OpenPrimary() + handles := map[string]*sql.DB{"known": db} + handles["absent"].Query("SELECT id FROM absent_records") +} + +func QueryFromEmptyMap(key string) { + handles := map[string]*sql.DB{} + handles[key].Query("SELECT id FROM empty_records") +} + +func QueryAfterWildcardStore(key string) { + db := OpenPrimary() + handles := map[string]*sql.DB{"known": db} + replacement := OpenPrimary() + handles[key] = replacement + handles["known"].Query("SELECT id FROM wildcard_records") +} + +func QueryKnownOnly() { + db := OpenPrimary() + handles := map[string]*sql.DB{"known": db} + handles["known"].Query("SELECT id FROM known_records") +} + +func QueryAfterLaterUpdate(update bool) { + db := OpenPrimary() + handles := map[string]*sql.DB{"known": db} + if update { + replacement := OpenPrimary() + handles["known"] = replacement + } + handles["known"].Query("SELECT id FROM updated_records") +} + +func QueryMapPhi(useKnown bool) { + var handles map[string]*sql.DB + if useKnown { + db := OpenPrimary() + handles = map[string]*sql.DB{"known": db} + } else { + handles = map[string]*sql.DB{} + } + handles["known"].Query("SELECT id FROM phi_records") +} + +func QueryNilMapPhi(useKnown bool) { + var handles map[string]*sql.DB + if useKnown { + db := OpenPrimary() + handles = map[string]*sql.DB{"known": db} + } + handles["known"].Query("SELECT id FROM nil_phi_records") +} + +func QueryRecordFieldAlternative(key string) { + db := OpenPrimary() + records := map[string]dbRecord{"known": {DB: db}} + records[key].DB.Query("SELECT id FROM record_alternatives") +} + +func QueryNestedRecordAlternative(key string) { + db := OpenPrimary() + records := map[string]outerRecord{"known": {Inner: innerRecord{DB: db}}} + records[key].Inner.DB.Query("SELECT id FROM nested_record_alternatives") +} + +func QueryArrayRecordAlternative(key string) { + db := OpenPrimary() + records := map[string][1]innerRecord{"known": {{DB: db}}} + records[key][0].DB.Query("SELECT id FROM array_record_alternatives") +} + +func QueryArrayDBAlternative(key string) { + db := OpenPrimary() + handles := map[string][1]*sql.DB{"known": {db}} + handles[key][0].Query("SELECT id FROM array_db_alternatives") +} + +func querySpilledArrayDynamic(array [1]*sql.DB) { + array[0].Query("SELECT id FROM spilled_array_alternatives") +} + +func querySpilledArrayKnown(array [1]*sql.DB) { + array[0].Query("SELECT id FROM spilled_array_known") +} + +func querySpilledArrayPointerDynamic(array *[1]*sql.DB) { + array[0].Query("SELECT id FROM spilled_array_pointer_alternatives") +} + +func querySpilledArrayPointerKnown(array *[1]*sql.DB) { + array[0].Query("SELECT id FROM spilled_array_pointer_known") +} + +func querySpilledRecordDynamic(record *dbRecord) { + record.DB.Query("SELECT id FROM spilled_record_alternatives") +} + +func querySpilledRecordKnown(record *dbRecord) { + record.DB.Query("SELECT id FROM spilled_record_known") +} + +func QuerySpilledArrayDBAlternative(key string) { + db := OpenPrimary() + handles := map[string][1]*sql.DB{"known": {db}} + array := handles[key] + querySpilledArrayDynamic(array) +} + +func QuerySpilledArrayDBKnown() { + db := OpenPrimary() + handles := map[string][1]*sql.DB{"known": {db}} + array := handles["known"] + querySpilledArrayKnown(array) +} + +func QuerySpilledArrayPointerAlternative(key string) { + db := OpenPrimary() + handles := map[string][1]*sql.DB{"known": {db}} + array := handles[key] + querySpilledArrayPointerDynamic(&array) +} + +func QuerySpilledArrayPointerKnown() { + db := OpenPrimary() + handles := map[string][1]*sql.DB{"known": {db}} + array := handles["known"] + querySpilledArrayPointerKnown(&array) +} + +func QuerySpilledRecordAlternative(key string) { + db := OpenPrimary() + records := map[string]dbRecord{"known": {DB: db}} + record := records[key] + querySpilledRecordDynamic(&record) +} + +func QuerySpilledRecordKnown() { + db := OpenPrimary() + records := map[string]dbRecord{"known": {DB: db}} + record := records["known"] + querySpilledRecordKnown(&record) +} + +func QueryNilParentEmptyMap(key string) { + records := map[string]*dbRecord{} + records[key].DB.Query("SELECT id FROM nil_parent_records") +} + +func QueryNamedMapAlternative(key string) { + db := OpenPrimary() + handles := namedHandles{"known": db} + handles[key].Query("SELECT id FROM named_map_alternatives") +} + +func QueryGenericMapAlternative(key string) { + db := OpenPrimary() + handles := map[string]*sql.DB{"known": db} + lookupHandle(handles, key).Query("SELECT id FROM generic_map_alternatives") +} + +func QueryNestedMapAlternative(key string) { + db := OpenPrimary() + inner := map[string]*sql.DB{"known": db} + outer := map[string]map[string]*sql.DB{"known": inner} + outer[key]["known"].Query("SELECT id FROM nested_map_alternatives") +} + +func QueryDirect() { + db := OpenPrimary() + db.Query("SELECT id FROM direct_records") +} + +func QueryTaskMapNilPhi(useKnown bool) { + var tasks map[string]func() + if useKnown { + tasks = map[string]func(){"known": knownTask} + } + var group sync.WaitGroup + group.Go(tasks["known"]) +} + +func QueryTaskMapKnownOnly() { + tasks := map[string]func(){"known": knownTask} + var group sync.WaitGroup + group.Go(tasks["known"]) +} + +func QueryNestedTaskMapPhi(useKnown bool) { + var tasks map[string]map[string]func() + if useKnown { + tasks = map[string]map[string]func(){"known": {"task": knownTask}} + } + var group sync.WaitGroup + group.Go(tasks["known"]["task"]) +} + +func QueryNestedTaskMapKnownOnly() { + tasks := map[string]map[string]func(){"known": {"task": knownTask}} + var group sync.WaitGroup + group.Go(tasks["known"]["task"]) +} + +func QueryArrayTaskMapNilPhi(useKnown bool) { + var tasks map[string][1]map[string]func() + if useKnown { + inner := map[string]func(){"task": knownTask} + tasks = map[string][1]map[string]func(){"known": {inner}} + } + var group sync.WaitGroup + group.Go(tasks["known"][0]["task"]) +} + +func QueryArrayTaskMapKnownOnly() { + inner := map[string]func(){"task": knownTask} + tasks := map[string][1]map[string]func(){"known": {inner}} + var group sync.WaitGroup + group.Go(tasks["known"][0]["task"]) +} + +func QueryPointerMapChildAlternative(key string) { + callbacks := map[string]func(){"known": knownTask} + maps := map[string]*map[string]func(){"known": &callbacks} + child := maps[key] + var group sync.WaitGroup + group.Go((*child)["known"]) +} + +func ObserveDynamicContextRecord(key string) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + ready := make(chan struct{}) + records := map[string]contextRecord{"known": {Ctx: ctx, Ready: ready}} + <-records[key].Ctx.Done() + <-records[key].Ready +} + +func ObserveKnownContextRecord() { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + ready := make(chan struct{}) + records := map[string]contextRecord{"known": {Ctx: ctx, Ready: ready}} + <-records["known"].Ctx.Done() + <-records["known"].Ready +} + +func ObserveDynamicConvertedContextRecord(key string) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + ready := make(chan struct{}) + records := map[string]contextRecord{"known": {Ctx: ctx, Ready: ready}} + record := records[key] + slot := (*contextSlot)(&record.Ctx) + <-(*slot).Done() + <-record.Ready +} + +func ObserveKnownConvertedContextRecord() { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + ready := make(chan struct{}) + records := map[string]contextRecord{"known": {Ctx: ctx, Ready: ready}} + record := records["known"] + slot := (*contextSlot)(&record.Ctx) + <-(*slot).Done() + <-record.Ready +} + +func QueryLeaseMapNilPhi(useKnown bool) { + var leases map[string]*Lease + if useKnown { + leases = map[string]*Lease{"known": AcquireLease()} + } + leases["known"].Close() +} + +func QueryLeaseMapKnownOnly() { + leases := map[string]*Lease{"known": AcquireLease()} + leases["known"].Close() +} + +func QueryLeaseRecordAlternative(key string) { + records := map[string]leaseRecord{"known": {Lease: AcquireLease()}} + records[key].Lease.Close() +} + +func QueryLeaseRecordKnownOnly() { + records := map[string]leaseRecord{"known": {Lease: AcquireLease()}} + records["known"].Lease.Close() +} + +func closeDynamicLeaseSlot(slot **Lease) { + lease := *slot + lease.Close() +} + +func closeKnownLeaseSlot(slot **Lease) { + lease := *slot + lease.Close() +} + +func UseKnownSlot() { + DropSlot(AcquireSlot()) +} + +func QueryLeaseRecordSlotAlternative(key string) { + records := map[string]leaseRecord{"known": {Lease: AcquireLease()}} + record := records[key] + closeDynamicLeaseSlot(&record.Lease) +} + +func QueryLeaseRecordSlotKnownOnly() { + records := map[string]leaseRecord{"known": {Lease: AcquireLease()}} + record := records["known"] + closeKnownLeaseSlot(&record.Lease) +} + +func ReleaseMapNilPhi(useNil bool) { + handle := AcquireMap() + if useNil { + handle = nil + } + DropMap(handle) +} + +func ReleaseMapKnownOnly() { + handle := AcquireMap() + DropMap(handle) +} + +func ReleaseBoundMapNilPhi(useNil bool) { + handle := AcquireLeaseMap() + if useNil { + handle = nil + } + closeHandle := handle.Close + closeHandle() +} + +func ReleaseBoundMapKnownOnly() { + handle := AcquireLeaseMap() + closeHandle := handle.Close + closeHandle() +} +`) + + config := DefaultConfig() + config.StorageScopes = []StorageScope{{Namespace: "primary", DatabaseOrigins: []string{"example.com/maplookup::OpenPrimary"}}} + zero := 0 + config.LifecycleRules = []LifecycleRule{ + {Symbol: "example.com/maplookup::AcquireLease", Role: "acquire", Namespace: "leases", Identity: "origin", Result: &zero}, + {Symbol: "example.com/maplookup::Lease.Close", Role: "release", Namespace: "leases", Identity: "origin", Receiver: true}, + {Symbol: "example.com/maplookup::AcquireMap", Role: "acquire", Namespace: "map-handles", Identity: "origin", Result: &zero}, + {Symbol: "example.com/maplookup::DropMap", Role: "release", Namespace: "map-handles", Identity: "origin", Argument: &zero}, + {Symbol: "example.com/maplookup::AcquireLeaseMap", Role: "acquire", Namespace: "bound-map-handles", Identity: "origin", Result: &zero}, + {Symbol: "example.com/maplookup::leaseMap.Close", Role: "release", Namespace: "bound-map-handles", Identity: "origin", Receiver: true}, + {Symbol: "example.com/maplookup::AcquireSlot", Role: "acquire", Namespace: "lease-slots", Identity: "origin", Result: &zero}, + {Symbol: "example.com/maplookup::DropSlot", Role: "release", Namespace: "lease-slots", Identity: "origin", Argument: &zero}, + } + seeds := []string{ + "QueryAfterDelete", "QueryAfterClear", "QueryWithMissingKeyAlternative", "QueryMissingKey", + "QueryFromEmptyMap", "QueryAfterWildcardStore", "QueryKnownOnly", "QueryAfterLaterUpdate", "QueryMapPhi", + "QueryNilMapPhi", "QueryRecordFieldAlternative", "QueryNestedRecordAlternative", + "QueryArrayRecordAlternative", "QueryArrayDBAlternative", "QueryNilParentEmptyMap", "QueryNamedMapAlternative", + "QuerySpilledArrayDBAlternative", "QuerySpilledArrayDBKnown", "QuerySpilledArrayPointerAlternative", "QuerySpilledArrayPointerKnown", "QuerySpilledRecordAlternative", "QuerySpilledRecordKnown", + "QueryGenericMapAlternative", "QueryDirect", + "QueryTaskMapNilPhi", "QueryTaskMapKnownOnly", + "QueryLeaseMapNilPhi", "QueryLeaseMapKnownOnly", + "QueryLeaseRecordAlternative", "QueryLeaseRecordKnownOnly", + "QueryLeaseRecordSlotAlternative", "QueryLeaseRecordSlotKnownOnly", + "UseKnownSlot", + "QueryNestedTaskMapPhi", "QueryNestedTaskMapKnownOnly", "QueryArrayTaskMapNilPhi", "QueryArrayTaskMapKnownOnly", + "QueryPointerMapChildAlternative", + "ObserveDynamicContextRecord", "ObserveKnownContextRecord", "ObserveDynamicConvertedContextRecord", "ObserveKnownConvertedContextRecord", + "ReleaseMapNilPhi", "ReleaseMapKnownOnly", + "ReleaseBoundMapNilPhi", "ReleaseBoundMapKnownOnly", + "QueryNestedMapAlternative", + } + report, analysis, err := TraceWithAnalysis(context.Background(), Options{Root: root, Seeds: seeds, Depth: 5, MaxNodes: 300, Config: config}) + if err != nil { + t.Fatal(err) + } + if report.Coverage.Truncated || report.ContractComplete { + t.Fatalf("map probe must be untruncated and leave analysis incomplete: truncated=%t complete=%t", report.Coverage.Truncated, report.ContractComplete) + } + var encoded bytes.Buffer + if err := WriteAnalysis(&encoded, analysis, true); err != nil { + t.Fatal(err) + } + saved, err := ReadAnalysis(bytes.NewReader(encoded.Bytes())) + if err != nil { + t.Fatal(err) + } + resumed, err := Explore(context.Background(), saved, ExploreOptions{Seeds: seeds, Depth: 5, MaxNodes: 300}) + if err != nil { + t.Fatal(err) + } + if resumed.Coverage.Truncated || resumed.ContractComplete { + t.Fatalf("resumed map probe must be untruncated and incomplete: truncated=%t complete=%t", resumed.Coverage.Truncated, resumed.ContractComplete) + } + + for _, current := range []Report{report, resumed} { + for _, tc := range []struct { + owner string + contextCall string + channelRead string + wantUncertain bool + }{ + {"ObserveDynamicContextRecord", "records[key].Ctx.Done()", "records[key].Ready", true}, + {"ObserveKnownContextRecord", "records[\"known\"].Ctx.Done()", "records[\"known\"].Ready", false}, + {"ObserveDynamicConvertedContextRecord", "(*slot).Done()", "record.Ready", true}, + {"ObserveKnownConvertedContextRecord", "(*slot).Done()", "record.Ready", false}, + } { + owner := "example.com/maplookup::" + tc.owner + contextSite := "" + channelSite := "" + for _, edge := range current.Relationships { + if edge.From == owner && edge.Kind == "context_observe" && edge.Evidence.File == "app.go" && strings.Contains(edge.Evidence.Snippet, tc.contextCall) { + contextSite = edge.To + } + if edge.From == owner && edge.Kind == "channel_receive" && edge.Evidence.File == "app.go" && strings.Contains(edge.Evidence.Snippet, tc.channelRead) { + channelSite = edge.To + } + } + if contextSite == "" || channelSite == "" { + t.Errorf("%s lost known context/channel map-record candidates: context=%q channel=%q", tc.owner, contextSite, channelSite) + } + contextUnknown, channelUnknown := false, false + for _, boundary := range current.Boundaries { + if boundary.Node == owner && boundary.Kind == "unresolved_context" && boundary.Evidence.File == "app.go" && strings.Contains(boundary.Evidence.Snippet, tc.contextCall) { + contextUnknown = true + } + if boundary.Node == owner && boundary.Kind == "unresolved_channel" && boundary.Evidence.File == "app.go" && strings.Contains(boundary.Evidence.Snippet, tc.channelRead) { + channelUnknown = true + } + } + if contextUnknown != tc.wantUncertain || channelUnknown != tc.wantUncertain { + t.Errorf("%s map-record zero alternative context/channel uncertainty=(%t,%t), want both %t", tc.owner, contextUnknown, channelUnknown, tc.wantUncertain) + } + } + for _, tc := range []struct { + owner string + table string + wantKnown bool + wantUnknown bool + }{ + {"QueryAfterDelete", "delete_records", true, true}, + {"QueryAfterClear", "clear_records", true, true}, + {"QueryWithMissingKeyAlternative", "dynamic_records", true, true}, + {"QueryMissingKey", "absent_records", false, true}, + {"QueryFromEmptyMap", "empty_records", false, true}, + {"QueryAfterWildcardStore", "wildcard_records", true, true}, + {"QueryKnownOnly", "known_records", true, false}, + {"QueryAfterLaterUpdate", "updated_records", true, true}, + {"QueryMapPhi", "phi_records", true, true}, + {"QueryNilMapPhi", "nil_phi_records", true, true}, + {"QueryRecordFieldAlternative", "record_alternatives", true, true}, + {"QueryNestedRecordAlternative", "nested_record_alternatives", true, true}, + {"QueryArrayRecordAlternative", "array_record_alternatives", true, true}, + {"QueryArrayDBAlternative", "array_db_alternatives", true, true}, + {"querySpilledArrayDynamic", "spilled_array_alternatives", true, true}, + {"querySpilledArrayKnown", "spilled_array_known", true, false}, + {"querySpilledArrayPointerDynamic", "spilled_array_pointer_alternatives", true, true}, + {"querySpilledArrayPointerKnown", "spilled_array_pointer_known", true, false}, + {"querySpilledRecordDynamic", "spilled_record_alternatives", true, true}, + {"querySpilledRecordKnown", "spilled_record_known", true, false}, + {"QueryNilParentEmptyMap", "nil_parent_records", false, true}, + {"QueryNamedMapAlternative", "named_map_alternatives", true, true}, + {"QueryGenericMapAlternative", "generic_map_alternatives", true, true}, + {"QueryNestedMapAlternative", "nested_map_alternatives", true, true}, + {"QueryDirect", "direct_records", true, false}, + } { + owner := "example.com/maplookup::" + tc.owner + known := hasRelationship(current, owner, "table:primary:"+tc.table, "sql_read", "app.go", 0) + if known != tc.wantKnown { + t.Errorf("%s known SQL edge to %s: got %t, want %t", tc.owner, tc.table, known, tc.wantKnown) + } + uncertain := false + operation := "" + for _, boundary := range current.Boundaries { + if boundary.Kind == "unresolved_sql_handle" && boundary.Evidence.File == "app.go" && strings.Contains(boundary.Evidence.Snippet, tc.table) { + uncertain = true + operation = boundary.Node + } + } + if tc.wantUnknown && !uncertain { + t.Errorf("%s lost its source-backed unknown map-handle alternative", tc.owner) + } else if !tc.wantUnknown && uncertain { + t.Errorf("closed known-only lookup acquired SQL handle uncertainty: %s", tc.owner) + } + if uncertain && !tc.wantKnown { + for _, edge := range current.Relationships { + if edge.Kind == "sql_receiver" && edge.From == operation && strings.HasPrefix(edge.To, "database:") { + t.Errorf("%s invented a database receiver origin from a missing map value: %+v", tc.owner, edge) + } + } + } + } + knownTaskBoundaryState(t, current, root) + nestedTaskMapBoundaryState(t, current, root) + leaseMapBoundaryState(t, current, root) + leaseFieldMapBoundaryState(t, current, root) + leaseSlotMapBoundaryState(t, current, root) + knownSlotState(t, current, root) + pointerMapChildState(t, current, root) + releaseMapBoundaryState(t, current, root) + boundMapBoundaryState(t, current, root) + } +} + +func leaseMapBoundaryState(t *testing.T, report Report, root string) { + t.Helper() + for _, test := range []struct { + name string + wantUncertain bool + }{ + {"QueryLeaseMapNilPhi", true}, + {"QueryLeaseMapKnownOnly", false}, + } { + acquired := map[string]bool{} + for _, edge := range report.Relationships { + if edge.From == "example.com/maplookup::"+test.name && edge.Kind == "lifecycle_acquire" && strings.Contains(edge.Evidence.Snippet, "AcquireLease()") { + acquired[edge.To] = true + } + } + line := sourceLineInFunction(t, root, "app.go", test.name, "leases[\"known\"].Close()") + released := false + for _, edge := range report.Relationships { + if edge.From == "example.com/maplookup::"+test.name && edge.Kind == "lifecycle_release" && acquired[edge.To] && edge.Evidence.File == "app.go" && edge.Evidence.Line == line && strings.Contains(edge.Evidence.Snippet, "leases[\"known\"].Close()") { + released = true + } + } + if len(acquired) == 0 || !released { + t.Errorf("%s lost configured lease acquire/release identity", test.name) + } + uncertain := false + for _, boundary := range report.Boundaries { + if boundary.Kind == "unresolved_lifecycle_resource" && boundary.Node == "example.com/maplookup::"+test.name && boundary.Evidence.File == "app.go" && boundary.Evidence.Line == line && strings.Contains(boundary.Evidence.Snippet, "leases[\"known\"].Close()") { + uncertain = true + } + } + if uncertain != test.wantUncertain { + t.Errorf("%s unresolved lease alternative=%t, want %t", test.name, uncertain, test.wantUncertain) + } + } +} + +func leaseFieldMapBoundaryState(t *testing.T, report Report, root string) { + t.Helper() + for _, test := range []struct { + name string + wantUncertain bool + }{ + {"QueryLeaseRecordAlternative", true}, + {"QueryLeaseRecordKnownOnly", false}, + } { + snippet := "records[\"known\"].Lease.Close()" + if test.wantUncertain { + snippet = "records[key].Lease.Close()" + } + line := sourceLineInFunction(t, root, "app.go", test.name, snippet) + acquired := map[string]bool{} + for _, edge := range report.Relationships { + if edge.From == "example.com/maplookup::"+test.name && edge.Kind == "lifecycle_acquire" && strings.Contains(edge.Evidence.Snippet, "AcquireLease()") { + acquired[edge.To] = true + } + } + released, uncertain := false, false + for _, edge := range report.Relationships { + if edge.From == "example.com/maplookup::"+test.name && edge.Kind == "lifecycle_release" && acquired[edge.To] && edge.Evidence.File == "app.go" && edge.Evidence.Line == line && strings.Contains(edge.Evidence.Snippet, snippet) { + released = true + } + } + for _, boundary := range report.Boundaries { + if boundary.Kind == "unresolved_lifecycle_resource" && boundary.Node == "example.com/maplookup::"+test.name && boundary.Evidence.File == "app.go" && boundary.Evidence.Line == line && strings.Contains(boundary.Evidence.Snippet, snippet) { + uncertain = strings.Contains(boundary.Reason, "nil") || strings.Contains(boundary.Reason, "unresolved resource") + } + } + if len(acquired) == 0 || !released { + t.Errorf("%s lost configured Lease acquire/release identity", test.name) + } + if uncertain != test.wantUncertain { + t.Errorf("%s unresolved nil Lease receiver=%t, want %t", test.name, uncertain, test.wantUncertain) + } + } +} + +func leaseSlotMapBoundaryState(t *testing.T, report Report, root string) { + t.Helper() + for _, test := range []struct { + caller string + helper string + wantUncertain bool + }{ + {"QueryLeaseRecordSlotAlternative", "closeDynamicLeaseSlot", true}, + {"QueryLeaseRecordSlotKnownOnly", "closeKnownLeaseSlot", false}, + } { + acquired := map[string]bool{} + for _, edge := range report.Relationships { + if edge.From == "example.com/maplookup::"+test.caller && edge.Kind == "lifecycle_acquire" && strings.Contains(edge.Evidence.Snippet, "AcquireLease()") { + acquired[edge.To] = true + } + } + line := sourceLineInFunction(t, root, "app.go", test.helper, "lease.Close()") + released, uncertain := false, false + for _, edge := range report.Relationships { + if edge.From == "example.com/maplookup::"+test.helper && edge.Kind == "lifecycle_release" && acquired[edge.To] && edge.Evidence.File == "app.go" && edge.Evidence.Line == line && strings.Contains(edge.Evidence.Snippet, "lease.Close()") { + released = true + } + } + for _, boundary := range report.Boundaries { + if boundary.Kind == "unresolved_lifecycle_resource" && boundary.Node == "example.com/maplookup::"+test.helper && boundary.Evidence.File == "app.go" && boundary.Evidence.Line == line && strings.Contains(boundary.Evidence.Snippet, "lease.Close()") { + uncertain = strings.Contains(boundary.Reason, "nil") || strings.Contains(boundary.Reason, "unresolved resource") + } + } + if len(acquired) == 0 || !released { + t.Errorf("%s lost configured Lease acquire/release identity through **Lease", test.caller) + } + if uncertain != test.wantUncertain { + t.Errorf("%s unresolved selected Lease receiver=%t, want %t", test.caller, uncertain, test.wantUncertain) + } + } +} + +func knownSlotState(t *testing.T, report Report, root string) { + t.Helper() + owner := "example.com/maplookup::UseKnownSlot" + line := sourceLineInFunction(t, root, "app.go", "UseKnownSlot", "DropSlot(AcquireSlot())") + acquired := map[string]bool{} + for _, edge := range report.Relationships { + if edge.From == owner && edge.Kind == "lifecycle_acquire" && strings.Contains(edge.Evidence.Snippet, "AcquireSlot()") { + acquired[edge.To] = true + } + } + released, unresolved := false, false + for _, edge := range report.Relationships { + if edge.From == owner && edge.Kind == "lifecycle_release" && acquired[edge.To] && edge.Evidence.File == "app.go" && edge.Evidence.Line == line && strings.Contains(edge.Evidence.Snippet, "DropSlot(AcquireSlot())") { + released = true + } + } + for _, boundary := range report.Boundaries { + if boundary.Kind == "unresolved_lifecycle_resource" && boundary.Node == owner && boundary.Evidence.File == "app.go" && boundary.Evidence.Line == line { + unresolved = true + } + } + if len(acquired) == 0 || !released { + t.Error("known **Lease slot lost its acquire/release identity") + } + if unresolved { + t.Error("known **Lease slot address was mistaken for its nil Lease payload") + } +} + +func releaseMapBoundaryState(t *testing.T, report Report, root string) { + t.Helper() + for _, test := range []struct { + name string + wantUncertain bool + }{ + {"ReleaseMapNilPhi", true}, + {"ReleaseMapKnownOnly", false}, + } { + snippet := "DropMap(handle)" + line := sourceLineInFunction(t, root, "app.go", test.name, snippet) + acquired := map[string]bool{} + for _, edge := range report.Relationships { + if edge.From == "example.com/maplookup::"+test.name && edge.Kind == "lifecycle_acquire" && strings.Contains(edge.Evidence.Snippet, "AcquireMap()") { + acquired[edge.To] = true + } + } + released, uncertain := false, false + for _, edge := range report.Relationships { + if edge.From == "example.com/maplookup::"+test.name && edge.Kind == "lifecycle_release" && acquired[edge.To] && edge.Evidence.File == "app.go" && edge.Evidence.Line == line && strings.Contains(edge.Evidence.Snippet, snippet) { + released = true + } + } + for _, boundary := range report.Boundaries { + if boundary.Kind == "unresolved_lifecycle_resource" && boundary.Node == "example.com/maplookup::"+test.name && boundary.Evidence.File == "app.go" && boundary.Evidence.Line == line && strings.Contains(boundary.Evidence.Snippet, snippet) { + uncertain = true + } + } + if len(acquired) == 0 || !released { + t.Errorf("%s lost configured map lease acquire/release identity", test.name) + } + if uncertain != test.wantUncertain { + t.Errorf("%s unresolved direct map-handle alternative=%t, want %t", test.name, uncertain, test.wantUncertain) + } + } +} + +func boundMapBoundaryState(t *testing.T, report Report, root string) { + t.Helper() + for _, test := range []struct { + name string + wantUncertain bool + }{ + {"ReleaseBoundMapNilPhi", true}, + {"ReleaseBoundMapKnownOnly", false}, + } { + snippet := "closeHandle()" + line := sourceLineInFunction(t, root, "app.go", test.name, snippet) + acquired := map[string]bool{} + for _, edge := range report.Relationships { + if edge.From == "example.com/maplookup::"+test.name && edge.Kind == "lifecycle_acquire" && strings.Contains(edge.Evidence.Snippet, "AcquireLeaseMap()") { + acquired[edge.To] = true + } + } + released, uncertain := false, false + for _, edge := range report.Relationships { + if edge.From == "example.com/maplookup::"+test.name && edge.Kind == "lifecycle_release" && acquired[edge.To] && edge.Evidence.File == "app.go" && edge.Evidence.Line == line && strings.Contains(edge.Evidence.Snippet, snippet) { + released = true + } + } + for _, boundary := range report.Boundaries { + if boundary.Kind == "unresolved_lifecycle_resource" && boundary.Node == "example.com/maplookup::"+test.name && boundary.Evidence.File == "app.go" && boundary.Evidence.Line == line && strings.Contains(boundary.Evidence.Snippet, snippet) { + uncertain = strings.Contains(boundary.Reason, "nil") || strings.Contains(boundary.Reason, "unresolved resource") + } + if test.wantUncertain && boundary.Node == "example.com/maplookup::"+test.name && boundary.Evidence.File == "app.go" && boundary.Evidence.Line == line && strings.Contains(boundary.Evidence.Snippet, snippet) && boundary.Kind == "unresolved_function_call" { + t.Errorf("%s fabricated a nil callable alternative for a map receiver", test.name) + } + } + if len(acquired) == 0 || !released { + t.Errorf("%s lost configured bound-map acquire/release identity", test.name) + } + if uncertain != test.wantUncertain { + t.Errorf("%s unresolved bound-map receiver=%t, want %t", test.name, uncertain, test.wantUncertain) + } + } +} + +func knownTaskBoundaryState(t *testing.T, report Report, root string) { + t.Helper() + for _, test := range []struct { + name string + wantUncertain bool + }{ + {"QueryTaskMapNilPhi", true}, + {"QueryTaskMapKnownOnly", false}, + } { + line := sourceLineInFunction(t, root, "app.go", test.name, "group.Go(tasks[\"known\"])") + var operation string + for _, edge := range report.Relationships { + if edge.Kind == "waitgroup_go" && strings.HasSuffix(edge.From, "::"+test.name) && strings.Contains(edge.Evidence.Snippet, "group.Go(tasks[\"known\"])") { + if edge.Evidence.File != "app.go" || edge.Evidence.Line != line { + t.Fatalf("%s WaitGroup.Go edge has wrong source evidence (want app.go:%d): %+v", test.name, line, edge.Evidence) + } + operation = edge.To + } + } + if operation == "" { + t.Fatalf("%s missing source-backed WaitGroup.Go edge", test.name) + } + known := false + uncertain := false + for _, edge := range report.Relationships { + if edge.Kind == "waitgroup_task" && edge.From == operation && strings.HasSuffix(edge.To, "::knownTask") { + known = true + } + } + for _, boundary := range report.Boundaries { + if boundary.Kind == "unresolved_waitgroup_task" && strings.HasSuffix(boundary.Node, "::"+test.name) && boundary.Evidence.File == "app.go" && boundary.Evidence.Line == line && strings.Contains(boundary.Evidence.Snippet, "group.Go(tasks[\"known\"])") { + uncertain = true + } + } + if !known { + t.Errorf("%s lost its knownTask alternative", test.name) + } + if uncertain != test.wantUncertain { + t.Errorf("%s unresolved WaitGroup task=%t, want %t", test.name, uncertain, test.wantUncertain) + } + } +} + +func nestedTaskMapBoundaryState(t *testing.T, report Report, root string) { + t.Helper() + for _, test := range []struct { + name string + snippet string + wantNil bool + }{ + {"QueryNestedTaskMapPhi", `group.Go(tasks["known"]["task"])`, true}, + {"QueryNestedTaskMapKnownOnly", `group.Go(tasks["known"]["task"])`, false}, + {"QueryArrayTaskMapNilPhi", `group.Go(tasks["known"][0]["task"])`, true}, + {"QueryArrayTaskMapKnownOnly", `group.Go(tasks["known"][0]["task"])`, false}, + } { + line := sourceLineInFunction(t, root, "app.go", test.name, test.snippet) + var operation string + for _, edge := range report.Relationships { + if edge.Kind == "waitgroup_go" && edge.From == "example.com/maplookup::"+test.name && edge.Evidence.File == "app.go" && edge.Evidence.Line == line && strings.Contains(edge.Evidence.Snippet, test.snippet) { + operation = edge.To + } + } + if operation == "" { + t.Fatalf("%s missing source-backed WaitGroup.Go edge at app.go:%d", test.name, line) + } + known, nilAlternative := false, false + for _, edge := range report.Relationships { + if edge.Kind == "waitgroup_task" && edge.From == operation && strings.HasSuffix(edge.To, "::knownTask") && edge.Evidence.File == "app.go" && edge.Evidence.Line == line && strings.Contains(edge.Evidence.Snippet, test.snippet) { + known = true + } + } + for _, boundary := range report.Boundaries { + nilReason := strings.Contains(boundary.Reason, "typed nil function value") || strings.Contains(boundary.Reason, "nil callback alternative") || strings.Contains(boundary.Reason, "possible nil") + if boundary.Kind == "unresolved_waitgroup_task" && boundary.Node == "example.com/maplookup::"+test.name && boundary.Evidence.File == "app.go" && boundary.Evidence.Line == line && strings.Contains(boundary.Evidence.Snippet, test.snippet) && nilReason { + nilAlternative = true + } + } + if !known { + t.Errorf("%s lost the knownTask alternative", test.name) + } + if nilAlternative != test.wantNil { + t.Errorf("%s typed-nil map alternative=%t, want %t", test.name, nilAlternative, test.wantNil) + } + } +} + +func pointerMapChildState(t *testing.T, report Report, root string) { + t.Helper() + owner := "example.com/maplookup::QueryPointerMapChildAlternative" + snippet := `group.Go((*child)["known"])` + line := sourceLineInFunction(t, root, "app.go", "QueryPointerMapChildAlternative", snippet) + var operation string + for _, edge := range report.Relationships { + if edge.Kind == "waitgroup_go" && edge.From == owner && edge.Evidence.File == "app.go" && edge.Evidence.Line == line && strings.Contains(edge.Evidence.Snippet, snippet) { + operation = edge.To + } + } + if operation == "" { + t.Fatalf("pointer-map child control lost its source-backed Go site at app.go:%d", line) + } + known := false + for _, edge := range report.Relationships { + known = known || edge.Kind == "waitgroup_task" && edge.From == operation && strings.HasSuffix(edge.To, "::knownTask") + } + if !known { + t.Errorf("pointer-map child control lost the valid known callback candidate") + } + for _, boundary := range report.Boundaries { + if boundary.Kind == "unresolved_waitgroup_task" && boundary.Node == owner && boundary.Evidence.File == "app.go" && boundary.Evidence.Line == line && strings.Contains(boundary.Evidence.Snippet, snippet) { + t.Errorf("nil pointer to a child map fabricated an invocation alternative before dereference: %+v", boundary) + } + } +} + +func sourceLineInFunction(t *testing.T, root, name, function, snippet string) int { + t.Helper() + data, err := os.ReadFile(filepath.Join(root, name)) + if err != nil { + t.Fatal(err) + } + inFunction := false + for lineNumber, line := range strings.Split(string(data), "\n") { + if strings.HasPrefix(line, "func ") { + inFunction = strings.HasPrefix(line, "func "+function+"(") + } + if inFunction && strings.Contains(line, snippet) { + return lineNumber + 1 + } + } + return 0 +} diff --git a/merge_keys_budget_test.go b/merge_keys_budget_test.go new file mode 100644 index 0000000..9fc27ff --- /dev/null +++ b/merge_keys_budget_test.go @@ -0,0 +1,324 @@ +package contracttrace + +import ( + "fmt" + "math/rand" + "sort" + "testing" +) + +type mergeBudgetDomain uint8 + +const ( + mergeBudgetScalar mergeBudgetDomain = iota + mergeBudgetError + mergeBudgetString + mergeBudgetAddress + mergeBudgetEffect +) + +type mergeBudgetState struct { + values map[string]bool + unknown bool + widened bool + changed bool +} + +// referenceMergeBudget models the current caller transition directly: gather +// and sort missing keys only when crossing the bound, then apply each +// domain's existing full-cap behavior. +func referenceMergeBudget(existing, incoming map[string]bool, unknown, incomingUnknown bool, domain mergeBudgetDomain) mergeBudgetState { + state := mergeBudgetState{values: cloneBoolMap(existing), unknown: unknown} + if incomingUnknown && (domain == mergeBudgetScalar || domain == mergeBudgetString) && !state.unknown { + state.unknown, state.changed = true, true + } + missing := make([]string, 0, len(incoming)) + for key := range incoming { + if !state.values[key] { + missing = append(missing, key) + } + } + if len(state.values)+len(missing) > maxFlowValues { + sort.Strings(missing) + } + for _, key := range missing { + if state.values[key] { + continue + } + if len(state.values) >= maxFlowValues { + state.widened = true + if (domain == mergeBudgetScalar || domain == mergeBudgetString) && !state.unknown { + state.unknown, state.changed = true, true + } + continue + } + state.values[key] = true + state.changed = true + } + return state +} + +func cloneBoolMap(source map[string]bool) map[string]bool { + if source == nil { + return nil + } + copy := make(map[string]bool, len(source)) + for key, value := range source { + copy[key] = value + } + return copy +} + +func TestMergeAtCapacityMatchesReferenceTransition(t *testing.T) { + rng := rand.New(rand.NewSource(21764)) + for _, domain := range []mergeBudgetDomain{mergeBudgetScalar, mergeBudgetError, mergeBudgetString, mergeBudgetAddress, mergeBudgetEffect} { + for _, size := range []int{0, 1, maxFlowValues - 1, maxFlowValues, maxFlowValues + 1} { + for _, incomingCount := range []int{0, 1, maxFlowValues, maxFlowValues + 1} { + for _, overlap := range []bool{false, true} { + existing := trueCandidates("e", size) + incoming := trueCandidates("k", incomingCount) + if overlap { + incoming = trueCandidates("e", incomingCount) + } + assertMergeTransition(t, domain, existing, incoming, false, false) + } + } + } + // Nil maps and false-valued keys exercise map-presence versus boolean + // membership semantics explicitly. + assertMergeTransition(t, domain, nil, nil, false, false) + assertMergeTransition(t, domain, trueCandidates("e", maxFlowValues), nil, false, false) + falseKeys := trueCandidates("e", maxFlowValues) + for key := range falseKeys { + falseKeys[key] = false + } + assertMergeTransition(t, domain, falseKeys, map[string]bool{"e000": true, "incoming": true}, false, false) + for caseIndex := 0; caseIndex < 500; caseIndex++ { + existing := map[string]bool{} + incoming := map[string]bool{} + population := rng.Intn(maxFlowValues + 3) // below, at, and above the bound + for i := 0; i < population; i++ { + existing[fmt.Sprintf("e%03d", i)] = rng.Intn(5) != 0 // false-valued keys are intentional + } + incomingCount := rng.Intn(maxFlowValues*2 + 1) + for i := 0; i < incomingCount; i++ { + key := fmt.Sprintf("k%03d", rng.Intn(maxFlowValues*3+1)) + incoming[key] = rng.Intn(4) != 0 + } + if caseIndex%4 == 0 && len(existing) >= maxFlowValues { + for key := range existing { + incoming[key] = true + } + } + unknown, incomingUnknown := rng.Intn(2) == 0, rng.Intn(2) == 0 + if domain != mergeBudgetScalar && domain != mergeBudgetString { + unknown, incomingUnknown = false, false + } + if caseIndex%7 == 0 { + incoming = nil + } + assertMergeTransition(t, domain, existing, incoming, unknown, incomingUnknown) + } + shared := map[string]bool{"alias-true": true, "alias-false": false} + destination, source := emptyFlow(), emptyFlow() + setBudgetValues(&destination, domain, shared) + setBudgetValues(&source, domain, shared) + if domain == mergeBudgetScalar { + destination.scalarUnknown = false + } + if domain == mergeBudgetString { + destination.stringUnknown = false + } + a := &flowAnalysis{} + expected := referenceMergeBudget(shared, shared, false, false, domain) + changed := a.merge(&destination, source) + actual, unknown := budgetValues(destination, domain) + if !equalBoolMaps(actual, expected.values) || unknown != expected.unknown || changed != expected.changed || a.coverage.Widened != expected.widened { + t.Fatalf("domain=%d aliased source/destination differs from reference transition", domain) + } + } +} + +func equalBoolMaps(left, right map[string]bool) bool { + if len(left) != len(right) { + return false + } + for key, value := range left { + other, ok := right[key] + if !ok || other != value { + return false + } + } + return true // nil and empty maps represent the same candidate set. +} + +func trueCandidates(prefix string, count int) map[string]bool { + result := make(map[string]bool, count) + for i := 0; i < count; i++ { + result[fmt.Sprintf("%s%03d", prefix, i)] = true + } + return result +} + +func assertMergeTransition(t *testing.T, domain mergeBudgetDomain, existing, incoming map[string]bool, unknown, incomingUnknown bool) { + t.Helper() + if domain != mergeBudgetScalar && domain != mergeBudgetString { + unknown, incomingUnknown = false, false + } + expected := referenceMergeBudget(existing, incoming, unknown, incomingUnknown, domain) + sourceBefore := cloneBoolMap(incoming) + a := &flowAnalysis{} + destination, source := emptyFlow(), emptyFlow() + setBudgetValues(&destination, domain, cloneBoolMap(existing)) + setBudgetValues(&source, domain, incoming) + if domain == mergeBudgetScalar { + destination.scalarUnknown, source.scalarUnknown = unknown, incomingUnknown + } + if domain == mergeBudgetString { + destination.stringUnknown, source.stringUnknown = unknown, incomingUnknown + } + changed := a.merge(&destination, source) + actualValues, actualUnknown := budgetValues(destination, domain) + if !equalBoolMaps(actualValues, expected.values) || actualUnknown != expected.unknown || a.coverage.Widened != expected.widened || changed != expected.changed { + t.Fatalf("domain=%d differs from legacy caller transition: got values=%v unknown=%v widened=%v changed=%v; want %+v", domain, actualValues, actualUnknown, a.coverage.Widened, changed, expected) + } + if !equalBoolMaps(incoming, sourceBefore) { + t.Fatal("merge mutated its incoming candidate map") + } + if onlyTrue(existing) && onlyTrue(incoming) { + assertMergeSetInvariants(t, domain, existing, incoming, unknown, incomingUnknown, destination, changed, a.coverage.Widened) + } +} + +func onlyTrue(values map[string]bool) bool { + for _, value := range values { + if !value { + return false + } + } + return true +} + +func assertMergeSetInvariants(t *testing.T, domain mergeBudgetDomain, existing, incoming map[string]bool, unknown, incomingUnknown bool, destination flowValue, changed, widened bool) { + t.Helper() + actual, actualUnknown := budgetValues(destination, domain) + missing := make([]string, 0, len(incoming)) + for key := range incoming { + if !existing[key] { + missing = append(missing, key) + } + } + capacity := maxFlowValues - len(existing) + if capacity < 0 { + capacity = 0 + } + if len(existing) >= maxFlowValues { + if !equalBoolMaps(actual, existing) { + t.Fatal("full/overfull merge inserted a candidate") + } + wantUnknown := unknown + if domain == mergeBudgetScalar || domain == mergeBudgetString { + wantUnknown = wantUnknown || incomingUnknown || len(missing) > 0 + } + if actualUnknown != wantUnknown || widened != (len(missing) > 0) { + t.Fatalf("full-cap flags differ: unknown=%v/%v widened=%v missing=%d", actualUnknown, wantUnknown, widened, len(missing)) + } + if changed != ((domain == mergeBudgetScalar || domain == mergeBudgetString) && !unknown && wantUnknown) { + t.Fatalf("full-cap changed=%v does not match newly-set uncertainty", changed) + } + return + } + if len(missing) <= capacity { + for _, key := range missing { + if !actual[key] { + t.Fatal("candidate fitting below cap was lost") + } + } + if widened { + t.Fatal("merge widened while all candidates fit") + } + } +} + +func setBudgetValues(value *flowValue, domain mergeBudgetDomain, candidates map[string]bool) { + switch domain { + case mergeBudgetScalar: + value.scalars = candidates + case mergeBudgetError: + value.errors = candidates + case mergeBudgetString: + value.strings = candidates + case mergeBudgetAddress: + value.addresses = candidates + case mergeBudgetEffect: + value.effects = candidates + } +} + +func budgetValues(value flowValue, domain mergeBudgetDomain) (map[string]bool, bool) { + switch domain { + case mergeBudgetScalar: + return value.scalars, value.scalarUnknown + case mergeBudgetError: + return value.errors, false + case mergeBudgetString: + return value.strings, value.stringUnknown + case mergeBudgetAddress: + return value.addresses, false + default: + return value.effects, false + } +} + +func legacyMergeKeys(values, existing map[string]bool) []string { + var result []string + for key := range values { + if !existing[key] { + result = append(result, key) + } + } + if len(existing)+len(result) > maxFlowValues { + sort.Strings(result) + } + return result +} + +var mergeKeysSink []string + +func BenchmarkMergeKeysAtCapacity(b *testing.B) { + existing := map[string]bool{} + for i := 0; i < maxFlowValues; i++ { + existing[fmt.Sprintf("existing-%03d", i)] = true + } + contained := cloneBoolMap(existing) + for _, count := range []int{maxFlowValues, 2048} { + disjoint := map[string]bool{} + for i := 0; i < count; i++ { + disjoint[fmt.Sprintf("incoming-%04d", i)] = true + } + b.Run(fmt.Sprintf("legacy/full-disjoint-%d", count), func(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + mergeKeysSink = legacyMergeKeys(disjoint, existing) + } + }) + b.Run(fmt.Sprintf("current/full-disjoint-%d", count), func(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + mergeKeysSink = mergeKeys(disjoint, existing) + } + }) + } + b.Run("legacy/full-contained", func(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + mergeKeysSink = legacyMergeKeys(contained, existing) + } + }) + b.Run("current/full-contained", func(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + mergeKeysSink = mergeKeys(contained, existing) + } + }) +} diff --git a/method_projection.go b/method_projection.go new file mode 100644 index 0000000..70f06c3 --- /dev/null +++ b/method_projection.go @@ -0,0 +1,341 @@ +package contracttrace + +import ( + "errors" + "fmt" + "go/types" +) + +var errUnsupportedMethodReceiverProjection = errors.New("method-expression receiver projection is unsupported") + +type methodReceiverProjection uint8 + +const ( + methodReceiverProjectionUnsupported methodReceiverProjection = iota + methodReceiverProjectionDirect + methodReceiverProjectionPromoted +) + +// projectMethodExpressionReceiver follows only the field path selected by the +// Go method set for this invocation's actual receiver type. It evaluates that +// path against the receiver value and modeled field memory; it never merges a +// generated thunk's parameters or interprets arbitrary method bodies. +func (a *flowAnalysis) projectMethodExpressionReceiver(value flowValue, receiverType types.Type, apiMethod *types.Func) (flowValue, *types.Signature, methodReceiverProjection) { + selectedMethod, selectedSignature, index := lifecycleMethodSetSelection(receiverType, apiMethod) + if selectedMethod == nil || selectedSignature == nil { + return unresolvedProjectedReceiver(), nil, methodReceiverProjectionUnsupported + } + fields := index[:len(index)-1] + if len(fields) == 0 { + return value, selectedSignature, methodReceiverProjectionDirect + } + apiReceiverType := selectedSignature.Recv().Type() + + // Keep uncertainty from the receiver, but never treat outer wrapper payloads + // or addresses as API resource candidates. + projected := emptyFlow() + projected.sqlUnknown = value.sqlUnknown + projected.functionUnknown = value.functionUnknown + projected.functionNil = value.functionNil + projected.interfaceUnknown = value.interfaceUnknown + projected.stringUnknown = value.stringUnknown + projected.scalarUnknown = value.scalarUnknown + projected.boundReceiverUnknown = value.boundReceiverUnknown + + for _, root := range sortedKeys(value.addresses) { + initial, initialUncertain := a.methodProjectionInitialAddresses(root, receiverType, fields[0]) + if initialUncertain { + projected.interfaceUnknown = true + } + addresses := map[string]bool{} + for _, address := range initial { + addresses[address] = true + } + currentType := receiverType + rootResolved := false + for pathIndex, fieldIndex := range fields { + structType := currentType + if pointer, ok := structType.Underlying().(*types.Pointer); ok { + structType = pointer.Elem() + } + record, ok := structType.Underlying().(*types.Struct) + if !ok || fieldIndex < 0 || fieldIndex >= record.NumFields() { + addresses = nil + break + } + fieldType := record.Field(fieldIndex).Type() + next := map[string]bool{} + for _, address := range sortedKeys(addresses) { + cell := fieldAddress(address, fieldIndex) + stored := a.memory[cell] + last := pathIndex == len(fields)-1 + if last { + _, fieldIsPointer := fieldType.Underlying().(*types.Pointer) + _, receiverIsPointer := apiReceiverType.Underlying().(*types.Pointer) + terminal := stored + branchResolved := false + if fieldIsPointer && !receiverIsPointer { + // A promoted value-receiver method through an embedded + // pointer implicitly dereferences that pointer. The API value + // is the pointee's modeled contents, not the pointer identity. + terminal = emptyFlow() + mergeProjectedUncertainty(&projected, stored) + targets := sortedKeys(stored.addresses) + if len(targets) == 0 { + projected.interfaceUnknown = true + } + for _, target := range targets { + loaded := a.memory[target] + a.merge(&terminal, loaded) + if !projectedReceiverHasCandidate(loaded, apiReceiverType) { + projected.interfaceUnknown = true + } else { + branchResolved = true + } + } + a.merge(&projected, terminal) + } else { + a.merge(&projected, stored) + } + // A promoted pointer-receiver method on an embedded scalar + // takes the address of the field cell itself. Value aggregates + // also keep their cell as an identity candidate. An embedded + // pointer instead requires the modeled pointee address below. + fieldCellIsReceiver := !fieldIsPointer && (receiverIsPointer || inlineAggregate(fieldType)) + if fieldCellIsReceiver { + candidate := emptyFlow() + candidate.addresses[cell] = true + a.merge(&projected, candidate) + branchResolved = true + } + if !fieldIsPointer || receiverIsPointer { + branchResolved = branchResolved || projectedReceiverHasCandidate(stored, apiReceiverType) + } + rootResolved = rootResolved || branchResolved + if !branchResolved { + projected.interfaceUnknown = true + } + continue + } + if _, ok := fieldType.Underlying().(*types.Pointer); ok { + knownTargets := sortedKeys(stored.addresses) + // An inline value-copy alias may hold this field's modeled + // contents at the aliased record address. Defer incompleteness + // to those paths instead of treating the empty copy cell as an + // independent missing pointer. + if len(knownTargets) == 0 && len(a.memory[address].addresses) == 0 { + projected.interfaceUnknown = true + } + for _, target := range knownTargets { + if !next[target] && len(next) >= maxFlowValues { + a.coverage.Widened = true + projected.interfaceUnknown = true + break + } + next[target] = true + } + // Unknown or non-address candidates at an intermediate pointer + // make the projected receiver incomplete, while known paths remain. + mergeProjectedUncertainty(&projected, stored) + } else { + fieldAddresses := []string{cell} + if inlineAggregate(fieldType) { + var complete bool + fieldAddresses, complete = a.methodProjectionRecordAddresses(cell) + mergeProjectedUncertainty(&projected, stored) + if !complete { + projected.interfaceUnknown = true + } + } + for _, fieldAddress := range fieldAddresses { + if !next[fieldAddress] && len(next) >= maxFlowValues { + a.coverage.Widened = true + projected.interfaceUnknown = true + continue + } + next[fieldAddress] = true + } + } + } + addresses = next + currentType = fieldType + if len(addresses) == 0 { + break + } + if pathIndex == len(fields)-1 { + break + } + } + if !rootResolved { + projected.interfaceUnknown = true + } + } + if len(projected.addresses) == 0 && len(projected.strings) == 0 && len(projected.errors) == 0 && + len(projected.functions) == 0 && len(projected.scalars) == 0 && !projected.sqlUnknown && !projected.interfaceUnknown && + !projected.stringUnknown && !projected.scalarUnknown { + projected.interfaceUnknown = true + } + return projected, selectedSignature, methodReceiverProjectionPromoted +} + +// methodProjectionInitialAddresses accounts for SSA's separate storage +// allocation for addressable value receivers. An expression such as +// (*Envelope).Done(&value) can pass a pointer cell whose memory contains the +// address of the aggregate copy; embedded fields then live under that copy. +// Preserve root-backed field storage when both representations are present. +func (a *flowAnalysis) methodProjectionInitialAddresses(root string, receiverType types.Type, firstField int) ([]string, bool) { + pointer, ok := receiverType.Underlying().(*types.Pointer) + if !ok { + return []string{root}, false + } + contents := a.memory[root] + uncertain := flowProjectionUncertain(contents) + if len(contents.addresses) == 0 { + return []string{root}, uncertain + } + result := map[string]bool{} + visited := map[string]bool{root: true} + queue := sortedKeys(contents.addresses) + if flowHasProjectionPayload(a.memory[fieldAddress(root, firstField)]) { + result[root] = true + } + for head := 0; head < len(queue); head++ { + if len(visited) >= maxFlowValues && !visited[queue[head]] { + a.coverage.Widened = true + uncertain = true + break + } + alias := queue[head] + if visited[alias] { + uncertain = true + continue + } + visited[alias] = true + aliasType := a.addressTypes[alias] + if aliasType == nil { + uncertain = true + continue + } + if !types.Identical(aliasType, receiverType) && !types.Identical(aliasType, pointer.Elem()) { + uncertain = true + continue + } + aliasContents := a.memory[alias] + uncertain = uncertain || flowProjectionUncertain(aliasContents) + if flowHasProjectionPayload(a.memory[fieldAddress(alias, firstField)]) { + result[alias] = true + } + children := sortedKeys(aliasContents.addresses) + if len(children) == 0 { + result[alias] = true + continue + } + for _, child := range children { + if !visited[child] { + if len(queue) >= maxFlowValues { + a.coverage.Widened = true + uncertain = true + continue + } + queue = append(queue, child) + } else { + uncertain = true + } + } + } + if len(result) == 0 { + // Keep the root path as an unresolved candidate when no typed alias + // establishes the aggregate storage location. + result[root] = true + uncertain = true + } + return sortedKeys(result), uncertain +} + +func flowProjectionUncertain(value flowValue) bool { + return value.interfaceUnknown || value.sqlUnknown || value.boundReceiverUnknown || value.functionUnknown || value.functionNil || value.stringUnknown || value.scalarUnknown +} + +func flowHasProjectionPayload(value flowValue) bool { + return len(value.addresses) > 0 || len(value.strings) > 0 || len(value.effects) > 0 || len(value.errors) > 0 || len(value.scalars) > 0 || len(value.functions) > 0 || len(value.boundReceivers) > 0 || value.interfaceUnknown || value.sqlUnknown || value.functionUnknown || value.functionNil || value.stringUnknown || value.scalarUnknown +} + +func lifecycleMethodSetSelection(receiverType types.Type, apiMethod *types.Func) (*types.Func, *types.Signature, []int) { + if receiverType == nil || apiMethod == nil { + return nil, nil, nil + } + selection := types.NewMethodSet(receiverType).Lookup(apiMethod.Pkg(), apiMethod.Name()) + if selection == nil { + return nil, nil, nil + } + selectedMethod, ok := selection.Obj().(*types.Func) + if !ok || selectedMethod.Origin() != apiMethod.Origin() { + return nil, nil, nil + } + selectedSignature, ok := selectedMethod.Type().(*types.Signature) + if !ok || selectedSignature.Recv() == nil { + return nil, nil, nil + } + return selectedMethod, selectedSignature, selection.Index() +} + +func projectedReceiverHasCandidate(value flowValue, receiverType types.Type) bool { + if receiverType == nil { + return false + } + if _, pointer := receiverType.Underlying().(*types.Pointer); pointer { + return len(value.addresses) > 0 + } + if basic, ok := receiverType.Underlying().(*types.Basic); ok && basic.Kind() == types.String { + return len(value.strings) > 0 + } + // Other resource receivers are represented by aggregate or interface + // addresses. Effects and uncertainty flags describe incomplete behavior, + // but do not establish a concrete receiver candidate. + return len(value.addresses) > 0 +} + +// methodProjectionRecordAddresses mirrors recordAddresses for one typed inline +// aggregate field. Memory may hold aliases created by value copies, so follow +// those aliases before selecting the next embedded field. Keep the root cell, +// cap fanout, and stop cycles without mutating modeled memory. +func (a *flowAnalysis) methodProjectionRecordAddresses(root string) ([]string, bool) { + addresses := map[string]bool{root: true} + queue := []string{root} + complete := true + for head := 0; head < len(queue); head++ { + for _, alias := range sortedKeys(a.memory[queue[head]].addresses) { + if addresses[alias] { + continue + } + if len(addresses) >= maxFlowValues { + a.coverage.Widened = true + complete = false + continue + } + addresses[alias] = true + queue = append(queue, alias) + } + } + return sortedKeys(addresses), complete +} + +func fieldAddress(address string, index int) string { + return fmt.Sprintf("%s.field:%d", address, index) +} + +func unresolvedProjectedReceiver() flowValue { + value := emptyFlow() + value.interfaceUnknown = true + return value +} + +func mergeProjectedUncertainty(destination *flowValue, source flowValue) { + destination.sqlUnknown = destination.sqlUnknown || source.sqlUnknown + destination.functionUnknown = destination.functionUnknown || source.functionUnknown + destination.functionNil = destination.functionNil || source.functionNil + destination.interfaceUnknown = destination.interfaceUnknown || source.interfaceUnknown + destination.stringUnknown = destination.stringUnknown || source.stringUnknown + destination.scalarUnknown = destination.scalarUnknown || source.scalarUnknown + destination.boundReceiverUnknown = destination.boundReceiverUnknown || source.boundReceiverUnknown +} diff --git a/method_projection_test.go b/method_projection_test.go new file mode 100644 index 0000000..b01db70 --- /dev/null +++ b/method_projection_test.go @@ -0,0 +1,346 @@ +package contracttrace + +import ( + "go/importer" + "go/token" + "go/types" + "testing" +) + +func TestPromotedMethodProjectionFollowsTypedInitialPointerStorageAlias(t *testing.T) { + contextPackage, err := importer.Default().Import("context") + if err != nil { + t.Fatal(err) + } + contextType := contextPackage.Scope().Lookup("Context").Type() + contextInterface := contextType.Underlying().(*types.Interface) + contextInterface.Complete() + var done *types.Func + for i := 0; i < contextInterface.NumMethods(); i++ { + if contextInterface.Method(i).Name() == "Done" { + done = contextInterface.Method(i) + } + } + if done == nil { + t.Fatal("context.Context.Done not found") + } + + pkg := types.NewPackage("example.com/contextprojection", "contextprojection") + field := types.NewField(token.NoPos, contextPackage, "Context", contextType, true) + wrapper := types.NewNamed(types.NewTypeName(token.NoPos, pkg, "Wrapper", nil), types.NewStruct([]*types.Var{field}, nil), nil) + wrapperPointer := types.NewPointer(wrapper) + root, copyAddress := "alloc:pointer-cell", "alloc:wrapper-copy" + contextOrigin := "context:known-context" + a := &flowAnalysis{ + memory: map[string]flowValue{ + root: {addresses: map[string]bool{copyAddress: true}}, + fieldAddress(copyAddress, 0): {addresses: map[string]bool{contextOrigin: true}}, + }, + addressTypes: map[string]types.Type{copyAddress: wrapperPointer}, + } + outer := emptyFlow() + outer.addresses[root] = true + outer.strings["outer-wrapper-payload"] = true + + projected, signature, status := a.projectMethodExpressionReceiver(outer, wrapperPointer, done) + if status != methodReceiverProjectionPromoted || signature == nil { + t.Fatalf("projection status/signature = %v/%v, want promoted Context.Done", status, signature) + } + if !projected.addresses[contextOrigin] || projected.addresses[root] || projected.addresses[copyAddress] || projected.strings["outer-wrapper-payload"] { + t.Fatalf("projection did not follow the typed initial pointer alias: %+v", projected) + } + if projected.interfaceUnknown { + t.Fatalf("complete typed alias path was marked uncertain: %+v", projected) + } +} + +func TestPromotedMethodProjectionKeepsRootFieldAndTypedAliasAlternatives(t *testing.T) { + pkg := types.NewPackage("example.com/contextprojectionbranches", "contextprojectionbranches") + lease, closeMethod := projectionLeaseType(pkg) + wrapper := projectionStructType(pkg, "Wrapper", types.NewPointer(lease), true) + wrapperPointer := types.NewPointer(wrapper) + root, copyAddress := "alloc:pointer-cell", "alloc:wrapper-copy" + rootOrigin, copyOrigin := "known:root-lease", "known:copy-lease" + a := &flowAnalysis{ + memory: map[string]flowValue{ + root: {addresses: map[string]bool{copyAddress: true}}, + fieldAddress(root, 0): {addresses: map[string]bool{rootOrigin: true}}, + fieldAddress(copyAddress, 0): {addresses: map[string]bool{copyOrigin: true}}, + }, + addressTypes: map[string]types.Type{copyAddress: wrapperPointer}, + } + outer := emptyFlow() + outer.addresses[root] = true + + projected, _, status := a.projectMethodExpressionReceiver(outer, wrapperPointer, closeMethod) + if status != methodReceiverProjectionPromoted || !projected.addresses[rootOrigin] || !projected.addresses[copyOrigin] { + t.Fatalf("projection dropped a root/alias receiver branch: status=%v flow=%+v", status, projected) + } +} + +func TestPromotedMethodProjectionFollowsBoundedInitialAliasChainAndCycle(t *testing.T) { + pkg := types.NewPackage("example.com/contextprojectionchain", "contextprojectionchain") + lease, closeMethod := projectionLeaseType(pkg) + wrapper := projectionStructType(pkg, "Wrapper", types.NewPointer(lease), true) + wrapperPointer := types.NewPointer(wrapper) + root, first, second := "alloc:pointer-cell", "alloc:wrapper-copy-one", "alloc:wrapper-copy-two" + origin := "known:terminal-lease" + a := &flowAnalysis{ + memory: map[string]flowValue{ + root: {addresses: map[string]bool{first: true}}, + first: {addresses: map[string]bool{second: true}}, + fieldAddress(second, 0): {addresses: map[string]bool{origin: true}}, + "alloc:cycle-a": {addresses: map[string]bool{"alloc:cycle-b": true}}, + "alloc:cycle-b": {addresses: map[string]bool{"alloc:cycle-a": true}}, + }, + addressTypes: map[string]types.Type{first: wrapperPointer, second: wrapperPointer, "alloc:cycle-a": wrapperPointer, "alloc:cycle-b": wrapperPointer}, + } + outer := emptyFlow() + outer.addresses[root] = true + projected, _, status := a.projectMethodExpressionReceiver(outer, wrapperPointer, closeMethod) + if status != methodReceiverProjectionPromoted || !projected.addresses[origin] || projected.interfaceUnknown { + t.Fatalf("typed two-hop alias chain did not resolve exactly: status=%v flow=%+v", status, projected) + } + + cycle := emptyFlow() + cycle.addresses["alloc:cycle-a"] = true + cyclic, _, _ := a.projectMethodExpressionReceiver(cycle, wrapperPointer, closeMethod) + if !cyclic.interfaceUnknown { + t.Fatalf("cyclic initial storage aliases were not retained as unresolved: %+v", cyclic) + } +} + +func TestPromotedMethodProjectionDoesNotFollowUntypedInitialAlias(t *testing.T) { + pkg := types.NewPackage("example.com/contextprojectionuntyped", "contextprojectionuntyped") + lease, closeMethod := projectionLeaseType(pkg) + wrapper := projectionStructType(pkg, "Wrapper", types.NewPointer(lease), true) + root, unrelated := "alloc:pointer-cell", "alloc:unrelated" + outer := emptyFlow() + outer.addresses[root] = true + a := &flowAnalysis{memory: map[string]flowValue{ + root: {addresses: map[string]bool{unrelated: true}}, + fieldAddress(unrelated, 0): {addresses: map[string]bool{"wrong:lease": true}}, + }} + projected, _, status := a.projectMethodExpressionReceiver(outer, types.NewPointer(wrapper), closeMethod) + if status != methodReceiverProjectionPromoted || projected.addresses["wrong:lease"] || !projected.interfaceUnknown { + t.Fatalf("untyped storage alias was followed as an API receiver: status=%v flow=%+v", status, projected) + } +} + +func TestPromotedMethodProjectionRetainsKnownAndMissingBranches(t *testing.T) { + pkg := types.NewPackage("example.com/projection", "projection") + lease, closeMethod := projectionLeaseType(pkg) + wrapper := projectionStructType(pkg, "Wrapper", types.NewPointer(lease), true) + wrapperPointer := types.NewPointer(wrapper) + root := "alloc:outer-wrapper" + missingRoot := "alloc:wrapper-with-missing-embedded-field" + leaseOrigin := "configured:Acquire:Scenario:result:0" + a := &flowAnalysis{memory: map[string]flowValue{}} + a.memory[fieldAddress(root, 0)] = flowValue{addresses: map[string]bool{leaseOrigin: true}} + outer := emptyFlow() + outer.addresses[root] = true + outer.addresses[missingRoot] = true + outer.strings["outer-wrapper-payload"] = true + + projected, signature, status := a.projectMethodExpressionReceiver(outer, wrapperPointer, closeMethod) + if status != methodReceiverProjectionPromoted { + t.Fatalf("status = %v, want promoted", status) + } + if signature == nil || !types.Identical(signature.Recv().Type(), types.NewPointer(lease)) { + t.Fatalf("selected receiver signature = %v, want *Lease", signature) + } + if !projected.addresses[leaseOrigin] { + t.Fatalf("supported embedding branch lost Lease origin: %+v", projected) + } + if projected.addresses[root] || projected.addresses[missingRoot] || projected.strings["outer-wrapper-payload"] { + t.Fatalf("outer wrapper identity or payload leaked into API receiver candidates: %+v", projected) + } + if !projected.interfaceUnknown { + t.Fatalf("missing embedding branch was hidden by the supported branch: %+v", projected) + } +} + +func TestPromotedMethodProjectionCarriesEmbeddedStringValue(t *testing.T) { + pkg := types.NewPackage("example.com/keyprojection", "keyprojection") + key := types.NewNamed(types.NewTypeName(token.NoPos, pkg, "ResourceKey", nil), types.Typ[types.String], nil) + methodType := types.NewSignatureType(types.NewVar(token.NoPos, pkg, "", key), nil, nil, + types.NewTuple(), types.NewTuple(), false) + method := types.NewFunc(token.NoPos, pkg, "ReleaseKey", methodType) + key.AddMethod(method) + wrapper := projectionStructType(pkg, "Envelope", key, true) + root := "alloc:envelope" + outer := emptyFlow() + outer.addresses[root] = true + outer.strings["not-the-resource-key"] = true + a := &flowAnalysis{memory: map[string]flowValue{fieldAddress(root, 0): {strings: map[string]bool{"tenant-key": true}}}} + + projected, signature, status := a.projectMethodExpressionReceiver(outer, types.NewPointer(wrapper), method) + if status != methodReceiverProjectionPromoted || signature == nil || !types.Identical(signature.Recv().Type(), key) { + t.Fatalf("projection status/signature = %v/%v, want promoted ResourceKey receiver", status, signature) + } + if !projected.strings["tenant-key"] || projected.strings["not-the-resource-key"] { + t.Fatalf("projection did not isolate the embedded string payload: %+v", projected) + } +} + +func TestPromotedPointerReceiverUsesEmbeddedScalarFieldCell(t *testing.T) { + pkg := types.NewPackage("example.com/keypointerprojection", "keypointerprojection") + key := types.NewNamed(types.NewTypeName(token.NoPos, pkg, "ResourceKey", nil), types.Typ[types.String], nil) + methodType := types.NewSignatureType(types.NewVar(token.NoPos, pkg, "", types.NewPointer(key)), nil, nil, + types.NewTuple(), types.NewTuple(), false) + method := types.NewFunc(token.NoPos, pkg, "ReleaseKey", methodType) + key.AddMethod(method) + wrapper := projectionStructType(pkg, "Envelope", key, true) + root := "alloc:envelope" + cell := fieldAddress(root, 0) + outer := emptyFlow() + outer.addresses[root] = true + outer.strings["outer-payload"] = true + a := &flowAnalysis{memory: map[string]flowValue{cell: {strings: map[string]bool{"tenant-key": true}}}} + + projected, signature, status := a.projectMethodExpressionReceiver(outer, types.NewPointer(wrapper), method) + if status != methodReceiverProjectionPromoted || signature == nil || !types.Identical(signature.Recv().Type(), types.NewPointer(key)) { + t.Fatalf("projection status/signature = %v/%v, want promoted *ResourceKey receiver", status, signature) + } + if !projected.addresses[cell] { + t.Fatalf("pointer receiver on embedded scalar lost the addressable field cell: %+v", projected) + } + if !projected.strings["tenant-key"] || projected.strings["outer-payload"] { + t.Fatalf("projection did not preserve only the embedded field payload: %+v", projected) + } +} + +func TestPromotedValueReceiverDereferencesEmbeddedPointer(t *testing.T) { + pkg := types.NewPackage("example.com/keypointervalueprojection", "keypointervalueprojection") + key := types.NewNamed(types.NewTypeName(token.NoPos, pkg, "ResourceKey", nil), types.Typ[types.String], nil) + methodType := types.NewSignatureType(types.NewVar(token.NoPos, pkg, "", key), nil, nil, + types.NewTuple(), types.NewTuple(), false) + method := types.NewFunc(token.NoPos, pkg, "ReleaseKey", methodType) + key.AddMethod(method) + wrapper := projectionStructType(pkg, "Envelope", types.NewPointer(key), true) + root := "alloc:envelope" + keyAddress := "alloc:key-value" + outer := emptyFlow() + outer.addresses[root] = true + a := &flowAnalysis{memory: map[string]flowValue{ + fieldAddress(root, 0): {addresses: map[string]bool{keyAddress: true}}, + keyAddress: {strings: map[string]bool{"pointer-key-value": true}}, + }} + + projected, signature, status := a.projectMethodExpressionReceiver(outer, types.NewPointer(wrapper), method) + if status != methodReceiverProjectionPromoted || signature == nil || !types.Identical(signature.Recv().Type(), key) { + t.Fatalf("projection status/signature = %v/%v, want promoted ResourceKey value receiver", status, signature) + } + if !projected.strings["pointer-key-value"] || len(projected.addresses) != 0 { + t.Fatalf("value receiver through embedded pointer did not load only the pointee string: %+v", projected) + } +} + +func TestPromotedReceiverEffectsDoNotResolveMissingResourceBranch(t *testing.T) { + pkg := types.NewPackage("example.com/effectprojection", "effectprojection") + lease, closeMethod := projectionLeaseType(pkg) + wrapper := projectionStructType(pkg, "Wrapper", types.NewPointer(lease), true) + root := "alloc:wrapper-with-resource" + missingRoot := "alloc:wrapper-with-effect-only-field" + leaseOrigin := "configured:Acquire:Scenario:result:0" + outer := emptyFlow() + outer.addresses[root] = true + outer.addresses[missingRoot] = true + a := &flowAnalysis{memory: map[string]flowValue{ + fieldAddress(root, 0): {addresses: map[string]bool{leaseOrigin: true}}, + fieldAddress(missingRoot, 0): {effects: map[string]bool{"nil_done:context:background": true}}, + }} + + projected, _, status := a.projectMethodExpressionReceiver(outer, types.NewPointer(wrapper), closeMethod) + if status != methodReceiverProjectionPromoted { + t.Fatalf("projection status = %v, want promoted", status) + } + if !projected.addresses[leaseOrigin] || !projected.interfaceUnknown { + t.Fatalf("effect-only missing branch was hidden by the supported branch: %+v", projected) + } +} + +func TestPromotedMethodProjectionWalksDeepPointerEmbedding(t *testing.T) { + pkg := types.NewPackage("example.com/deepprojection", "deepprojection") + lease, closeMethod := projectionLeaseType(pkg) + middle := projectionStructType(pkg, "Middle", types.NewPointer(lease), true) + outerType := projectionStructType(pkg, "Outer", types.NewPointer(middle), true) + root := "alloc:outer" + middleAddress := "alloc:middle" + leaseOrigin := "configured:Acquire:DeepScenario:result:0" + a := &flowAnalysis{memory: map[string]flowValue{ + fieldAddress(root, 0): {addresses: map[string]bool{middleAddress: true}}, + fieldAddress(middleAddress, 0): {addresses: map[string]bool{leaseOrigin: true}}, + }} + outer := emptyFlow() + outer.addresses[root] = true + + projected, signature, status := a.projectMethodExpressionReceiver(outer, types.NewPointer(outerType), closeMethod) + if status != methodReceiverProjectionPromoted || signature == nil || !types.Identical(signature.Recv().Type(), types.NewPointer(lease)) { + t.Fatalf("deep projection status/signature = %v/%v, want promoted *Lease receiver", status, signature) + } + if !projected.addresses[leaseOrigin] || projected.addresses[root] || projected.addresses[middleAddress] { + t.Fatalf("deep projection did not select only the embedded Lease origin: %+v", projected) + } +} + +func TestPromotedMethodProjectionFollowsInlineRecordAliases(t *testing.T) { + pkg := types.NewPackage("example.com/aliasprojection", "aliasprojection") + waitGroup := types.NewNamed(types.NewTypeName(token.NoPos, pkg, "WaitGroup", nil), types.NewStruct(nil, nil), nil) + methodType := types.NewSignatureType(types.NewVar(token.NoPos, pkg, "", types.NewPointer(waitGroup)), nil, nil, + types.NewTuple(), types.NewTuple(), false) + done := types.NewFunc(token.NoPos, pkg, "Done", methodType) + waitGroup.AddMethod(done) + valueGroup := projectionStructType(pkg, "ValueGroup", waitGroup, true) + pointerGroup := projectionStructType(pkg, "PointerGroup", types.NewPointer(valueGroup), true) + nestedGroup := projectionStructType(pkg, "NestedGroup", pointerGroup, true) + + root := "alloc:nested-group" + valueGroupAddress := "alloc:value-group-copy" + waitGroupAddress := "alloc:wait-group" + waitGroupOrigin := "known:wait-group-origin" + a := &flowAnalysis{memory: map[string]flowValue{ + fieldAddress(root, 0): {addresses: map[string]bool{valueGroupAddress: true}}, + fieldAddress(valueGroupAddress, 0): {addresses: map[string]bool{waitGroupAddress: true}}, + fieldAddress(waitGroupAddress, 0): {addresses: map[string]bool{waitGroupOrigin: true}}, + }} + outer := emptyFlow() + outer.addresses[root] = true + + projected, signature, status := a.projectMethodExpressionReceiver(outer, types.NewPointer(nestedGroup), done) + if status != methodReceiverProjectionPromoted || signature == nil || !types.Identical(signature.Recv().Type(), types.NewPointer(waitGroup)) { + t.Fatalf("nested alias projection status/signature = %v/%v, want promoted *WaitGroup receiver", status, signature) + } + if !projected.addresses[waitGroupAddress+".field:0"] { + t.Fatalf("nested inline-record alias did not retain the embedded WaitGroup cell: %+v", projected) + } + if projected.interfaceUnknown { + t.Fatalf("empty copied field cell incorrectly made a resolved alias path incomplete: %+v", projected) + } +} + +func projectionLeaseType(pkg *types.Package) (*types.Named, *types.Func) { + lease := types.NewNamed(types.NewTypeName(token.NoPos, pkg, "Lease", nil), types.NewStruct(nil, nil), nil) + signature := types.NewSignatureType(types.NewVar(token.NoPos, pkg, "", types.NewPointer(lease)), nil, nil, + types.NewTuple(), types.NewTuple(), false) + closeMethod := types.NewFunc(token.NoPos, pkg, "Close", signature) + lease.AddMethod(closeMethod) + return lease, closeMethod +} + +func projectionStructType(pkg *types.Package, name string, fieldType types.Type, embedded bool) *types.Named { + fieldName := name + if embedded { + switch typ := fieldType.(type) { + case *types.Pointer: + if named, ok := typ.Elem().(*types.Named); ok { + fieldName = named.Obj().Name() + } + case *types.Named: + fieldName = typ.Obj().Name() + } + } + field := types.NewField(token.NoPos, pkg, fieldName, fieldType, embedded) + typ := types.NewNamed(types.NewTypeName(token.NoPos, pkg, name, nil), types.NewStruct([]*types.Var{field}, []string{""}), nil) + return typ +} diff --git a/mixed_functions_test.go b/mixed_functions_test.go new file mode 100644 index 0000000..5e505ef --- /dev/null +++ b/mixed_functions_test.go @@ -0,0 +1,86 @@ +package contracttrace + +import ( + "context" + "strings" + "testing" +) + +func TestKnownFunctionTargetsRetainOutsideInputUncertainty(t *testing.T) { + r, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"MixedFunctionCaller", "readPrivateFunctionField"}, Depth: 3, MaxNodes: 200}) + if err != nil { + t.Fatal(err) + } + for kind, snippet := range map[string]string{"partial_function_call": "callback();", "deferred_partial_function_call": "defer callback()", "goroutine_partial_function_call": "go callback()"} { + found := false + for _, b := range r.Boundaries { + found = found || b.Kind == kind && strings.HasSuffix(b.Node, "::forwardOpenFunction") && strings.Contains(b.Evidence.Snippet, snippet) + } + if !found { + t.Errorf("known callback hid %s", kind) + } + } + for _, snippet := range []string{"PublicReplaceableFunction()", "PublicFunctionRecord.Call()", "PublicFunctionSlice[0]()", `PublicFunctionMap["local"]()`} { + found := false + for _, b := range r.Boundaries { + found = found || b.Kind == "partial_function_call" && strings.HasSuffix(b.Node, "::readPublicFunctions") && b.Evidence.Snippet == snippet + } + if !found { + t.Errorf("outside replacement hidden at %s", snippet) + } + } + for _, owner := range []string{"forwardOpenFunction", "readPublicFunctions", "closedInvokeFunction", "readPrivateFunctionField"} { + found := false + for _, e := range r.Relationships { + found = found || strings.HasSuffix(e.From, "::"+owner) && strings.HasSuffix(e.To, "::knownMixedFunction") && e.Kind == "resolved_callback_call" + } + if !found { + t.Errorf("known path lost at %s", owner) + } + } + for _, b := range r.Boundaries { + if strings.Contains(b.Kind, "partial_function_call") && (strings.HasSuffix(b.Node, "::closedInvokeFunction") || strings.HasSuffix(b.Node, "::readPrivateFunctionField")) { + t.Errorf("closed input incorrectly open: %+v", b) + } + } +} + +func TestFunctionResultsPreserveKnownAndOutsideFactoryTargets(t *testing.T) { + r, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"MixedDependencyFunction", "MixedFactoryCaller"}, Depth: 3, MaxNodes: 200}) + if err != nil { + t.Fatal(err) + } + for owner, callback := range map[string]string{"MixedDependencyFunction": "localFunctionStop", "consumeOpenFactory": "knownMixedFunction"} { + known, partial := false, false + for _, edge := range r.Relationships { + known = known || edge.Kind == "resolved_callback_call" && strings.HasSuffix(edge.From, "::"+owner) && strings.HasSuffix(edge.To, "::"+callback) + } + for _, boundary := range r.Boundaries { + for _, edge := range r.Relationships { + partial = partial || boundary.Kind == "partial_function_call" && strings.HasSuffix(boundary.Node, "::"+owner) && edge.Kind == "resolved_callback_call" && strings.HasSuffix(edge.To, "::"+callback) && boundary.Evidence.File == edge.Evidence.File && boundary.Evidence.Line == edge.Evidence.Line && boundary.Evidence.Column == edge.Evidence.Column + } + } + if !known || !partial { + t.Errorf("%s callable result: known=%t partial=%t", owner, known, partial) + } + } +} + +func TestOpenDispatcherRetainsCallbackEscapeAlongsideLocalConsumer(t *testing.T) { + r, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"MixedDispatchCaller"}, Depth: 4, MaxNodes: 200, + Config: Config{StorageScopes: []StorageScope{{Namespace: "orders", DatabaseOrigins: []string{"example.com/sample::OpenOrdersDatabase"}}}}}) + if err != nil { + t.Fatal(err) + } + tables := map[string]bool{} + escaped := false + for _, edge := range r.Relationships { + if edge.Kind == "sql_read" && strings.HasSuffix(edge.From, "::mixedDispatchQuery") { + tables[edge.To] = true + } + escaped = escaped || edge.Kind == "callback_escape" && strings.HasSuffix(edge.From, "::PublicDispatchFunction") && strings.HasSuffix(edge.To, "::mixedDispatchQuery") + } + if !escaped || len(tables) != 2 || !tables["table:orders:mixed_dispatch_records"] || !tables["table:mixed_dispatch_records"] { + t.Fatalf("known dispatcher hid outside retention/invocation: escaped=%t tables=%v", escaped, tables) + } +} diff --git a/model.go b/model.go new file mode 100644 index 0000000..a6c6221 --- /dev/null +++ b/model.go @@ -0,0 +1,157 @@ +// Package contracttrace discovers investigation scope; it does not prove invariants. +package contracttrace + +type Options struct { + Root string + Seeds []string + Locations []string + Invariant string + Focus []string + Depth int + MaxNodes int + Tests bool + Tags string + ExpandCallbacks bool + Config Config + capture *Analysis +} +type Config struct { + EventFields []string `json:"event_fields"` + LifecycleNames []string `json:"lifecycle_names"` + SQLMethods []string `json:"sql_methods"` + CallRules []CallRule `json:"call_rules,omitempty"` + SQLDialect string `json:"sql_dialect"` + SQLFiles []string `json:"sql_files"` + StorageScopes []StorageScope `json:"storage_scopes,omitempty"` + LifecycleRules []LifecycleRule `json:"lifecycle_rules,omitempty"` +} + +// LifecycleRule describes a configured API role, not a proof of its execution. +// Exactly one selector identifies a resource; argument indexes exclude receivers. +type LifecycleRule struct { + Symbol string `json:"symbol"` + Role string `json:"role"` + Namespace string `json:"namespace"` + Identity string `json:"identity"` + Argument *int `json:"argument,omitempty"` + Result *int `json:"result,omitempty"` + Receiver bool `json:"receiver,omitempty"` +} +type StorageScope struct { + Namespace string `json:"namespace"` + GoFiles []string `json:"go_files,omitempty"` + SQLFiles []string `json:"sql_files,omitempty"` + DatabaseOrigins []string `json:"database_origins,omitempty"` +} +type CallRule struct { + Symbol string `json:"symbol"` + Kind string `json:"kind"` + Argument int `json:"argument"` + HandlerArgument *int `json:"handler_argument,omitempty"` + Namespace string `json:"namespace,omitempty"` +} + +func DefaultConfig() Config { + return Config{ + EventFields: []string{"EventType"}, + LifecycleNames: []string{"recover", "rebuild", "shutdown", "close", "cancel", "stop", "rollback"}, + SQLMethods: []string{"Exec", "ExecContext", "Query", "QueryContext", "QueryRow", "QueryRowContext", "Prepare", "PrepareContext"}, + SQLDialect: "sqlite", SQLFiles: []string{"**/*.sql"}, + } +} + +type Evidence struct { + File string `json:"file"` + Line int `json:"line"` + Column int `json:"column,omitempty"` + Snippet string `json:"snippet"` + Origin string `json:"origin,omitempty"` +} +type Node struct { + ID string `json:"id"` + Name string `json:"name"` + Kind string `json:"kind"` + Evidence Evidence `json:"evidence"` + Distance int `json:"distance"` + ReachedBy *Relationship `json:"reached_by,omitempty"` + Relevance *Relevance `json:"contract_relevance,omitempty"` +} +type Relevance struct { + Anchor string `json:"anchor"` + Distance int `json:"distance"` + Via *Relationship `json:"via,omitempty"` + PathCertainty string `json:"path_certainty,omitempty"` +} +type Relationship struct { + From string `json:"from"` + To string `json:"to"` + Kind string `json:"kind"` + Certainty string `json:"certainty"` + Evidence Evidence `json:"evidence"` + Slot string `json:"slot,omitempty"` + Values []string `json:"values,omitempty"` +} + +type FlowCoverage struct { + Iterations int `json:"iterations"` + Converged bool `json:"converged"` + Widened bool `json:"widened"` + MaxValues int `json:"max_values"` +} +type Boundary struct { + Node string `json:"node,omitempty"` + Kind string `json:"kind"` + Reason string `json:"reason"` + Evidence Evidence `json:"evidence"` + CandidateCount int `json:"candidate_count,omitempty"` + Examples []string `json:"examples,omitempty"` + Candidates []string `json:"candidates,omitempty"` +} +type Coverage struct { + Packages []string `json:"packages"` + Files []string `json:"files"` + ExcludedGoFiles []string `json:"excluded_go_files"` + Build map[string]string `json:"build"` + Tests bool `json:"tests"` + Tags string `json:"tags"` + SourceSHA256 string `json:"source_sha256"` + LoadedSources []LoadedSource `json:"loaded_sources,omitempty"` + LoadedSourceSHA256 string `json:"loaded_source_sha256,omitempty"` + ResolutionInputs []LoadedSource `json:"resolution_inputs,omitempty"` + ResolutionSHA256 string `json:"resolution_sha256,omitempty"` + ResolutionScope string `json:"resolution_scope,omitempty"` + Depth int `json:"depth"` + MaxNodes int `json:"max_nodes"` + Truncated bool `json:"truncated"` + ExpandCallbacks bool `json:"expand_callbacks"` + ValueFlow FlowCoverage `json:"value_flow"` + Storage StorageCoverage `json:"storage"` + EmbeddedFiles []string `json:"embedded_files"` +} +type StorageCoverage struct { + Dialect string `json:"dialect"` + Files []string `json:"files"` + ExcludedFiles []string `json:"excluded_files"` + Queries int `json:"distinct_queries"` + ParsedStatements int `json:"parsed_statements"` + ParseFailures int `json:"parse_failures"` + Assignments []StorageAssignment `json:"namespace_assignments,omitempty"` +} +type StorageAssignment struct { + File string `json:"file"` + Namespace string `json:"namespace"` + Origin string `json:"origin"` +} +type Report struct { + Schema string `json:"schema"` + Root string `json:"root"` + Invariant string `json:"invariant"` + Focus []string `json:"contract_focus,omitempty"` + Seeds []string `json:"seeds"` + Nodes []Node `json:"nodes"` + Relationships []Relationship `json:"relationships"` + Boundaries []Boundary `json:"unresolved_boundaries"` + Coverage Coverage `json:"coverage"` + Config Config `json:"config"` + ContractComplete bool `json:"contract_complete"` +} diff --git a/nested_module_sql_test.go b/nested_module_sql_test.go new file mode 100644 index 0000000..4895a83 --- /dev/null +++ b/nested_module_sql_test.go @@ -0,0 +1,111 @@ +package contracttrace + +import ( + "context" + "os" + "path/filepath" + "testing" +) + +func TestSQLInventoryStopsAtNestedGoModuleBoundaries(t *testing.T) { + root := t.TempDir() + write := func(name, source string) { + t.Helper() + path := filepath.Join(root, name) + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(source), 0600); err != nil { + t.Fatal(err) + } + } + write("go.mod", "module example.com/rootmodule\n\ngo 1.27.0\n") + write("app.go", `package app + +import "example.com/rootmodule/api" + +func RootSeed() { api.Query("SELECT id FROM shared_records") } +`) + write("api/api.go", "package api\nfunc Query(string) {}\n") + write("schema.sql", "CREATE TABLE shared_records (id INTEGER PRIMARY KEY);\n") + write("nested/go.mod", "module example.com/nestedmodule\n\ngo 1.27.0\n") + write("nested/nested.go", "package nested\nfunc NestedSeed() {}\n") + write("nested/schema.sql", "CREATE TABLE shared_records (id INTEGER PRIMARY KEY);\nCREATE TABLE nested_only (id INTEGER);\n") + + rootConfig := Config{ + SQLFiles: []string{"**/*.sql"}, + CallRules: []CallRule{{Symbol: "example.com/rootmodule/api::Query", Kind: "sql_query", Argument: 0, Namespace: "root-store"}}, + StorageScopes: []StorageScope{{Namespace: "root-store", SQLFiles: []string{"**/*.sql"}}}, + } + rootReport, err := Trace(context.Background(), Options{Root: root, Seeds: []string{"RootSeed"}, Depth: 2, MaxNodes: 100, Config: rootConfig}) + if err != nil { + t.Fatal(err) + } + if len(rootReport.Coverage.Storage.Files) != 1 || rootReport.Coverage.Storage.Files[0] != "schema.sql" { + t.Errorf("root module SQL inventory crossed a nested go.mod boundary: %v", rootReport.Coverage.Storage.Files) + } + if !nestedModuleHasNode(rootReport, "table:root-store:shared_records", "table") { + t.Error("root module schema table is missing") + } + if !nestedModuleHasRelationship(rootReport, "example.com/rootmodule::RootSeed", "table:root-store:shared_records", "sql_read", "app.go") { + t.Error("parent module query did not join to its own shared_records schema") + } + if nestedModuleHasNode(rootReport, "table:root-store:nested_only", "table") { + t.Error("nested-only table entered the parent module graph") + } + for _, node := range rootReport.Nodes { + if node.Evidence.File == "nested/schema.sql" { + t.Errorf("parent module report retained nested SQL node evidence: %+v", node) + } + } + for _, edge := range rootReport.Relationships { + if edge.To == "table:root-store:nested_only" || edge.Evidence.File == "nested/schema.sql" { + t.Errorf("parent module report retained nested SQL relationship evidence: %+v", edge) + } + } + for _, assignment := range rootReport.Coverage.Storage.Assignments { + if assignment.File == "nested/schema.sql" { + t.Error("nested module SQL received a root storage namespace despite the broad explicit glob") + } + } + for _, file := range rootReport.Coverage.Files { + if filepath.ToSlash(file) == "nested/nested.go" { + t.Error("nested module Go source unexpectedly entered the parent module package inventory") + } + } + + nestedConfig := Config{ + SQLFiles: []string{"**/*.sql"}, + StorageScopes: []StorageScope{{Namespace: "nested-store", SQLFiles: []string{"**/*.sql"}}}, + } + nestedReport, err := Trace(context.Background(), Options{ + Root: filepath.Join(root, "nested"), Seeds: []string{"NestedSeed", "sql_file:nested-store:schema.sql"}, Depth: 2, MaxNodes: 100, Config: nestedConfig, + }) + if err != nil { + t.Fatal(err) + } + if len(nestedReport.Coverage.Storage.Files) != 1 || nestedReport.Coverage.Storage.Files[0] != "schema.sql" { + t.Errorf("nested module did not inventory its own SQL when selected as Root: %v", nestedReport.Coverage.Storage.Files) + } + if !nestedModuleHasNode(nestedReport, "table:nested-store:nested_only", "table") { + t.Error("nested module table is missing when that module is selected as Root") + } +} + +func nestedModuleHasNode(report Report, id, kind string) bool { + for _, node := range report.Nodes { + if node.ID == id && node.Kind == kind { + return true + } + } + return false +} + +func nestedModuleHasRelationship(report Report, from, to, kind, file string) bool { + for _, edge := range report.Relationships { + if edge.From == from && edge.To == to && edge.Kind == kind && edge.Evidence.File == file { + return true + } + } + return false +} diff --git a/ordering_test.go b/ordering_test.go new file mode 100644 index 0000000..91e1dad --- /dev/null +++ b/ordering_test.go @@ -0,0 +1,256 @@ +package contracttrace + +import ( + "math/rand" + "reflect" + "sort" + "testing" +) + +// This ceiling distinguishes one formatted key per relationship from the +// legacy comparator's formatted-key allocation on every sort comparison. +func TestCanonicalEdgesAvoidsPerComparisonFormatting(t *testing.T) { + input := orderingFixture(256) + allocations := testing.AllocsPerRun(3, func() { _ = canonicalEdges(input) }) + limit := float64(len(input)*12 + 256) + t.Logf("canonicalEdges allocations: %.0f for %d relationships (limit %.0f)", allocations, len(input), limit) + if allocations > limit { + t.Fatalf("canonical edge sorting allocated %.0f objects for %d relationships; want <= %.0f (one key per relationship, not one per comparator call)", allocations, len(input), limit) + } +} + +func orderingFixture(count int) []Relationship { + input := make([]Relationship, count) + for i := range input { + input[i] = Relationship{ + From: "source-" + string(rune('A'+i%26)) + "-" + itoaForOrderingTest(i), + To: "target-" + itoaForOrderingTest(count-i), + Kind: "call", + Certainty: "fact", + Evidence: Evidence{File: "fixture.go", Line: i + 1, Column: i%17 + 1}, + } + } + return input +} + +func TestPrioritySortAvoidsPerComparisonFormatting(t *testing.T) { + input := orderingFixture(256) + allocations := testing.AllocsPerRun(3, func() { + edges := append([]Relationship(nil), input...) + legacySortByPriorityAndKeyForTest(edges) + }) + optimized := testing.AllocsPerRun(3, func() { + edges := append([]Relationship(nil), input...) + sortEdgesByPriorityAndKey(edges) + }) + t.Logf("priority sort allocations: legacy %.0f, precomputed %.0f for %d relationships", allocations, optimized, len(input)) + if optimized > allocations*0.4 { + t.Fatalf("precomputed priority sort allocated %.0f objects; want at most 40%% of legacy comparator's %.0f", optimized, allocations) + } +} + +func itoaForOrderingTest(n int) string { + if n == 0 { + return "0" + } + var buf [20]byte + i := len(buf) + for n > 0 { + i-- + buf[i] = byte('0' + n%10) + n /= 10 + } + return string(buf[i:]) +} + +func legacyCanonicalEdgesForTest(input []Relationship) []Relationship { + edges := map[string]Relationship{} + values := map[string]map[string]bool{} + for _, edge := range input { + key := edgeKey(edge) + if _, ok := edges[key]; !ok { + edges[key] = edge + values[key] = map[string]bool{} + } + for _, value := range edge.Values { + values[key][value] = true + } + } + output := make([]Relationship, 0, len(edges)) + for key, edge := range edges { + if len(values[key]) > 0 { + edge.Values = sortedKeys(values[key]) + } + output = append(output, edge) + } + sort.Slice(output, func(i, j int) bool { return edgeKey(output[i]) < edgeKey(output[j]) }) + return output +} + +func legacySortByKeyForTest(edges []Relationship) { + sort.Slice(edges, func(i, j int) bool { return edgeKey(edges[i]) < edgeKey(edges[j]) }) +} + +func legacySortByPriorityAndKeyForTest(edges []Relationship) { + sort.Slice(edges, func(i, j int) bool { + if edgePriority(edges[i]) != edgePriority(edges[j]) { + return edgePriority(edges[i]) < edgePriority(edges[j]) + } + return edgeKey(edges[i]) < edgeKey(edges[j]) + }) +} + +func cloneRelationshipsForTest(input []Relationship) []Relationship { + copy := append([]Relationship(nil), input...) + for i := range copy { + if input[i].Values != nil { + copy[i].Values = append([]string{}, input[i].Values...) + } + } + return copy +} + +func TestCanonicalEdgesPreservesLegacyFormattedKeySemantics(t *testing.T) { + input := []Relationship{ + {From: "left|mid", To: "right", Kind: "call", Certainty: "fact", Slot: "slot", Evidence: Evidence{File: "f|g.go", Line: 1, Column: 2, Snippet: "first collision representative"}, Values: []string{"z", "a", "a"}}, + {From: "left", To: "mid|right", Kind: "call", Certainty: "fact", Slot: "slot", Evidence: Evidence{File: "f|g.go", Line: 1, Column: 2, Snippet: "different fields, same formatted key"}, Values: []string{"b"}}, + {From: "src|with|pipes", To: "dst", Kind: "field_read", Certainty: "possible", Slot: "a:b", Evidence: Evidence{File: "z.go", Line: 1234567890, Column: 12345678901, Snippet: "wide numeric fields"}}, + {From: "src", To: "dst", Kind: "event_publish", Certainty: "possible", Slot: "", Evidence: Evidence{File: "a.go", Line: -3, Column: 0, Snippet: "negative line formatting"}, Values: make([]string, 0)}, + {From: "src", To: "dst", Kind: "event_publish", Certainty: "possible", Slot: "", Evidence: Evidence{File: "a.go", Line: -3, Column: 0, Snippet: "later duplicate"}, Values: nil}, + {From: "src", To: "dst", Kind: "sql_read", Certainty: "fact", Slot: "", Evidence: Evidence{File: "b.go", Line: 8, Column: 1}, Values: []string{"same"}}, + {From: "src", To: "dst", Kind: "sql_read", Certainty: "fact", Slot: "", Evidence: Evidence{File: "b.go", Line: 8, Column: 1}, Values: []string{"same"}}, + } + original := cloneRelationshipsForTest(input) + want := legacyCanonicalEdgesForTest(input) + got := canonicalEdges(input) + if !reflect.DeepEqual(got, want) { + t.Fatalf("canonical edge output differs from legacy formatted-key behavior\ngot: %#v\nwant: %#v", got, want) + } + if !reflect.DeepEqual(input, original) { + t.Fatal("canonicalization mutated caller-owned relationships") + } + if got[0].From != "left|mid" || got[0].Evidence.Snippet != "first collision representative" { + t.Fatalf("equal formatted keys did not keep the first representative: %#v", got[0]) + } + if !reflect.DeepEqual(got[0].Values, []string{"a", "b", "z"}) { + t.Fatalf("duplicate value union is not sorted/deduplicated: %#v", got[0].Values) + } + got[0].Values[0] = "changed" + if input[0].Values[0] != "z" { + t.Fatal("merged output Values aliases caller-owned input storage") + } +} + +func TestPrecomputedOrderingMatchesLegacyComparators(t *testing.T) { + input := []Relationship{ + {From: "b|c", To: "d", Kind: "call", Certainty: "fact", Evidence: Evidence{File: "z.go", Line: 10, Column: 1, Snippet: "collision later"}}, + {From: "b", To: "c|d", Kind: "call", Certainty: "fact", Evidence: Evidence{File: "z.go", Line: 10, Column: 1, Snippet: "collision first"}}, + {From: "same", To: "node", Kind: "scalar_read", Certainty: "possible", Evidence: Evidence{File: "a.go", Line: 1234567890, Column: 12345678901}}, + {From: "same", To: "node", Kind: "call", Certainty: "possible", Evidence: Evidence{File: "a.go", Line: 2, Column: 1}}, + {From: "same", To: "node", Kind: "sql_read", Certainty: "fact", Evidence: Evidence{File: "z.go", Line: 3, Column: 1}}, + {From: "same", To: "node", Kind: "event_publish", Certainty: "fact", Evidence: Evidence{File: "a.go", Line: 3, Column: 1}}, + {From: "same", To: "node", Kind: "call", Certainty: "fact", Evidence: Evidence{File: "b.go", Line: 2, Column: 1}, Values: []string{"x"}}, + } + + wantKey := cloneRelationshipsForTest(input) + legacySortByKeyForTest(wantKey) + gotKey := cloneRelationshipsForTest(input) + sortEdgesByKey(gotKey) + if !reflect.DeepEqual(gotKey, wantKey) { + t.Fatalf("precomputed report ordering differs from legacy edgeKey order\ngot: %#v\nwant: %#v", gotKey, wantKey) + } + + wantPriority := cloneRelationshipsForTest(input) + legacySortByPriorityAndKeyForTest(wantPriority) + gotPriority := cloneRelationshipsForTest(input) + sortEdgesByPriorityAndKey(gotPriority) + if !reflect.DeepEqual(gotPriority, wantPriority) { + t.Fatalf("precomputed priority ordering differs from legacy priority/key order\ngot: %#v\nwant: %#v", gotPriority, wantPriority) + } +} + +func TestPrecomputedOrderingMatchesGeneratedLegacyCases(t *testing.T) { + rng := rand.New(rand.NewSource(0x5eed)) + pool := make([]Relationship, 180) + kinds := []string{"call", "sql_read", "event_publish", "scalar_read", "field_write"} + for i := range pool { + from := "source-" + itoaForOrderingTest(rng.Intn(19)) + to := "target-" + itoaForOrderingTest(rng.Intn(23)) + if i%17 == 0 { + from += "|segment" + } + pool[i] = Relationship{ + From: from, To: to, Kind: kinds[rng.Intn(len(kinds))], + Certainty: []string{"fact", "possible"}[rng.Intn(2)], + Slot: []string{"", "arg:0", "result:1", "x|y"}[rng.Intn(4)], + Evidence: Evidence{File: []string{"a.go", "b.go", "dir/c.go"}[rng.Intn(3)], + Line: rng.Intn(1_500_000_000), Column: rng.Intn(1_500_000_000), Snippet: "origin-" + itoaForOrderingTest(i)}, + Values: []string{"v-" + itoaForOrderingTest(rng.Intn(12)), "common"}, + } + } + input := make([]Relationship, 0, 280) + for i := 0; i < 220; i++ { + input = append(input, pool[rng.Intn(len(pool))]) + } + // Add equal formatted-key records with distinct snippets and value inputs. + for i := 0; i < 60; i++ { + base := pool[i] + base.Evidence.Snippet = "duplicate-origin-" + itoaForOrderingTest(i) + base.Values = []string{"extra-" + itoaForOrderingTest(i%9)} + input = append(input, base) + } + rng.Shuffle(len(input), func(i, j int) { input[i], input[j] = input[j], input[i] }) + + canonicalWant := legacyCanonicalEdgesForTest(input) + canonicalGot := canonicalEdges(input) + if !reflect.DeepEqual(canonicalGot, canonicalWant) { + t.Fatal("generated canonical output differs from the legacy formatted-key reference") + } + wantKey := cloneRelationshipsForTest(input) + legacySortByKeyForTest(wantKey) + gotKey := cloneRelationshipsForTest(input) + sortEdgesByKey(gotKey) + if !reflect.DeepEqual(gotKey, wantKey) { + t.Fatal("generated report ordering differs from the legacy formatted-key reference") + } + wantPriority := cloneRelationshipsForTest(input) + legacySortByPriorityAndKeyForTest(wantPriority) + gotPriority := cloneRelationshipsForTest(input) + sortEdgesByPriorityAndKey(gotPriority) + if !reflect.DeepEqual(gotPriority, wantPriority) { + t.Fatal("generated priority ordering differs from the legacy priority/key reference") + } + for i := 1; i < len(gotPriority); i++ { + previous, current := gotPriority[i-1], gotPriority[i] + if edgePriority(previous) > edgePriority(current) || edgePriority(previous) == edgePriority(current) && edgeKey(previous) > edgeKey(current) { + t.Fatalf("priority tiers or exact formatted-key order violated at positions %d/%d", i-1, i) + } + } +} + +func TestPrecomputedOrderingSkipsTrivialSlices(t *testing.T) { + edge := Relationship{From: "only", To: "edge", Kind: "call", Certainty: "fact"} + one := []Relationship{edge} + keyAllocs := testing.AllocsPerRun(20, func() { sortEdgesByKey(nil) }) + priorityAllocs := testing.AllocsPerRun(20, func() { sortEdgesByPriorityAndKey(nil) }) + oneKeyAllocs := testing.AllocsPerRun(20, func() { sortEdgesByKey(one) }) + onePriorityAllocs := testing.AllocsPerRun(20, func() { sortEdgesByPriorityAndKey(one) }) + if keyAllocs != 0 || priorityAllocs != 0 || oneKeyAllocs != 0 || onePriorityAllocs != 0 { + t.Fatalf("trivial edge sorting decorated without comparisons: nil key %.0f, nil priority %.0f, one key %.0f, one priority %.0f allocations", keyAllocs, priorityAllocs, oneKeyAllocs, onePriorityAllocs) + } +} + +func TestSortReportUsesPrecomputedFormattedKeys(t *testing.T) { + input := []Relationship{ + {From: "a|b", To: "c", Kind: "call", Certainty: "fact", Evidence: Evidence{File: "same.go", Line: 1, Column: 1, Snippet: "first"}}, + {From: "a", To: "b|c", Kind: "call", Certainty: "fact", Evidence: Evidence{File: "same.go", Line: 1, Column: 1, Snippet: "second"}}, + {From: "z", To: "x", Kind: "event_dispatch", Certainty: "possible", Evidence: Evidence{File: "other.go", Line: 2, Column: 10}}, + } + want := cloneRelationshipsForTest(input) + legacySortByKeyForTest(want) + report := Report{Relationships: cloneRelationshipsForTest(input)} + sortReport(&report) + if !reflect.DeepEqual(report.Relationships, want) { + t.Fatalf("sortReport changed legacy edge ordering: got %#v want %#v", report.Relationships, want) + } +} diff --git a/outside_channels_test.go b/outside_channels_test.go new file mode 100644 index 0000000..f81b709 --- /dev/null +++ b/outside_channels_test.go @@ -0,0 +1,112 @@ +package contracttrace + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestOutsideChannelsKeepContentsLifecycleAndPrivateControl(t *testing.T) { + root := t.TempDir() + files := map[string]string{ + "go.mod": "module example.com/channelinputs\n\ngo 1.27.0\n", + "channels.go": `package channelinputs +import ("errors"; "database/sql") +func source() error { return errors.New("local") } +func Public(ch <-chan error) error { return <-ch } +func closed(ch <-chan error) error { return <-ch } +func Local() error { ch := make(chan error, 1); ch <- source(); _ = Public(ch); return closed(ch) } +type Record struct { E error; Events <-chan error } +func Nested(r Record) error { return <-r.Events } +func Payload(ch <-chan Record) error { return (<-ch).E } +func Selected(ch <-chan error) error { select { case e := <-ch: return e; default: return nil } } +func Result(factory func() <-chan error) error { return <-factory() } +func callback() {} +func Callback(ch <-chan func()) { f := <-ch; f() } +func LocalCallback() { ch := make(chan func(), 1); ch <- callback; Callback(ch) } +func SQL(ch <-chan *sql.DB) { db := <-ch; db.Exec("select * from channel_records") } +func Signal(ch chan struct{}) { close(ch) } +type Loop chan Loop +func Cycle(ch Loop) { _ = <-ch } +func Pointer(ch *<-chan error) error { return <-*ch } +func LocalSQL() { db, _ := sql.Open("sqlite", "known"); ch := make(chan *sql.DB, 1); ch <- db; SQL(ch) } +func escaped(err error) bool { return err != nil } +func Export() <-chan func(error) bool { ch := make(chan func(error) bool, 1); ch <- escaped; return ch } +`, + } + for name, contents := range files { + if err := os.WriteFile(filepath.Join(root, name), []byte(contents), 0644); err != nil { + t.Fatal(err) + } + } + seeds := []string{"Public", "closed", "Nested", "Payload", "Selected", "Result", "Callback", "SQL", "Signal", "Cycle", "Pointer", "Export", "escaped"} + fresh, saved, err := TraceWithAnalysis(context.Background(), Options{Root: root, Seeds: seeds, Depth: 4, MaxNodes: 250, + Config: Config{StorageScopes: []StorageScope{{Namespace: "known", DatabaseOrigins: []string{"example.com/channelinputs::LocalSQL"}}}}}) + if err != nil { + t.Fatal(err) + } + resumed, err := Explore(context.Background(), saved, ExploreOptions{Seeds: seeds, Depth: 4, MaxNodes: 250}) + if err != nil { + t.Fatal(err) + } + for _, report := range []Report{fresh, resumed} { + for _, owner := range []string{"Public", "closed", "Nested", "Payload", "Selected", "Result", "Pointer"} { + outside, local := false, false + for _, edge := range report.Relationships { + if !strings.HasSuffix(edge.From, "::"+owner) || edge.Kind != "error_return" { + continue + } + outside = outside || strings.HasPrefix(edge.To, "error-input:") + for _, node := range report.Nodes { + if node.ID == edge.To && node.Kind == "error_result" && strings.Contains(node.Evidence.Snippet, `errors.New("local")`) { + local = true + } + } + } + if outside != (owner != "closed") { + t.Errorf("%s outside error=%v", owner, outside) + } + if (owner == "Public" || owner == "closed") && !local { + t.Errorf("%s lost local error", owner) + } + } + callback, unknownCallback, sql, signal := false, false, false, false + tables := map[string]bool{} + for _, edge := range report.Relationships { + if edge.Kind == "sql_read" && strings.HasSuffix(edge.From, "::SQL") { + tables[edge.To] = true + } + callback = callback || edge.Kind == "resolved_callback_call" && strings.HasSuffix(edge.From, "::Callback") && strings.HasSuffix(edge.To, "::callback") + signal = signal || edge.Kind == "channel_close" && strings.HasSuffix(edge.From, "::Signal") && strings.HasPrefix(edge.To, "channel:outside:") + } + for _, boundary := range report.Boundaries { + unknownCallback = unknownCallback || boundary.Kind == "partial_function_call" && strings.HasSuffix(boundary.Node, "::Callback") + sql = sql || boundary.Kind == "unresolved_sql_handle" && strings.Contains(boundary.Evidence.Snippet, "db.Exec") + } + if !callback || !unknownCallback || !sql || !signal { + t.Errorf("callback=%v outside callback=%v sql=%v signal=%v", callback, unknownCallback, sql, signal) + } + if !tables["table:known:channel_records"] || !tables["table:channel_records"] { + t.Errorf("channel SQL receiver candidates=%v", tables) + } + escaped, exported := false, false + for _, edge := range report.Relationships { + escaped = escaped || edge.Kind == "error_compare" && strings.HasSuffix(edge.From, "::escaped") && strings.HasPrefix(edge.To, "error-input:") + exported = exported || edge.Kind == "callback_return_escape" && strings.HasSuffix(edge.From, "::Export") && strings.HasSuffix(edge.To, "::escaped") + } + if !escaped || !exported { + t.Error("callback sent through exported channel lost outside inputs") + } + outsideChannel := false + for _, boundary := range report.Boundaries { + if boundary.Kind == "outside_channel" && boundary.Evidence.File == "channels.go" && boundary.Evidence.Line > 0 { + outsideChannel = true + } + } + if !outsideChannel { + t.Error("missing source-backed outside channel boundary") + } + } +} diff --git a/outside_interfaces.go b/outside_interfaces.go new file mode 100644 index 0000000..c9490e5 --- /dev/null +++ b/outside_interfaces.go @@ -0,0 +1,35 @@ +package contracttrace + +import ( + "fmt" + "go/types" + + "golang.org/x/tools/go/ssa" +) + +func isInterfaceType(typ types.Type) bool { + _, ok := typ.Underlying().(*types.Interface) + return ok +} + +// Materialize only the asserted shape. This does not enumerate all outside +// dynamic types or prove that an assertion succeeds. +func (a *flowAnalysis) seedInterfaceAssertion(value ssa.Value, assertion *ssa.TypeAssert, ix *index) bool { + if a.interfaceAssertions == nil { + a.interfaceAssertions = map[ssa.Value]bool{} + } + if !a.interfaceAssertions[value] { + a.interfaceAssertions[value] = true + ix.boundaries = append(ix.boundaries, Boundary{Node: ix.owner(value.Parent()), Kind: "outside_interface_assertion", Reason: "outside interface value retains possible asserted function/SQL/aggregate candidates; dynamic type compatibility, assertion success, nil safety and complete outside type enumeration are not proved", Evidence: ix.evidence(assertion.Pos())}) + } + unknown := emptyFlow() + unknown.interfaceUnknown = isInterfaceType(value.Type()) + unknown.functionUnknown = isFunctionType(value.Type()) + unknown.sqlUnknown = isSQLHandleType(value.Type()) + changed := a.put(value, unknown) + root := fmt.Sprintf("input:assertion:%s:%s", value.Parent().String(), value.Name()) + if a.seedAggregateOrigin(value, root, true, ix.evidence(assertion.Pos()), ix) { + changed = true + } + return changed +} diff --git a/outside_interfaces_test.go b/outside_interfaces_test.go new file mode 100644 index 0000000..b4254e3 --- /dev/null +++ b/outside_interfaces_test.go @@ -0,0 +1,53 @@ +package contracttrace + +import ( + "context" + "strings" + "testing" +) + +func TestOutsideInterfacesRetainAssertedCandidates(t *testing.T) { + r, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"OutsideInterfaceCaller"}, Depth: 4, MaxNodes: 400, + Config: Config{StorageScopes: []StorageScope{{Namespace: "aggregate", DatabaseOrigins: []string{"example.com/sample::knownAggregateDatabase"}}}}}) + if err != nil { + t.Fatal(err) + } + for _, c := range []struct { + owner, query, table string + outside bool + }{ + {"PublicAnyInput", "inputAnyQuery", "input_any_records", true}, + {"PublicAnyField", "fieldAnyQuery", "field_any_records", true}, + {"PublicAnyFactory", "factoryAnyQuery", "factory_any_records", true}, + {"escapedAnyCallback", "callbackAnyQuery", "callback_any_records", true}, + {"useAnyGlobal", "globalAnyQuery", "global_any_records", true}, + {"PublicAnyProvider", "providerAnyQuery", "provider_any_records", true}, + {"closedAnyInput", "closedAnyQuery", "closed_any_records", false}, + } { + known, outside := false, false + tables := map[string]bool{} + for _, e := range r.Relationships { + known = known || e.Kind == "resolved_callback_call" && strings.HasSuffix(e.From, "::"+c.owner) && strings.HasSuffix(e.To, "::"+c.query) + if e.Kind == "sql_read" && strings.HasSuffix(e.From, "::"+c.query) { + tables[e.To] = true + } + } + for _, b := range r.Boundaries { + outside = outside || b.Kind == "partial_function_call" && strings.HasSuffix(b.Node, "::"+c.owner) && strings.Contains(b.Evidence.Snippet, "record.Run(") + } + count := 1 + if c.outside { + count = 2 + } + if !known || outside != c.outside || len(tables) != count || !tables["table:aggregate:"+c.table] || tables["table:"+c.table] != c.outside { + t.Errorf("%s known=%t outside=%t tables=%v", c.owner, known, outside, tables) + } + } + function := false + for _, b := range r.Boundaries { + function = function || b.Kind == "partial_function_call" && strings.HasSuffix(b.Node, "::PublicAnyFunction") && strings.Contains(b.Evidence.Snippet, "callback()") + } + if !function { + t.Error("asserted function lost outside candidate") + } +} diff --git a/outside_strings_test.go b/outside_strings_test.go new file mode 100644 index 0000000..e1c35d1 --- /dev/null +++ b/outside_strings_test.go @@ -0,0 +1,123 @@ +package contracttrace + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestOutsideStringsKeepFormatUncertaintyAndPrivateControls(t *testing.T) { + root := t.TempDir() + files := map[string]string{ + "go.mod": "module example.com/stringinputs\n\ngo 1.27.0\n", + "strings.go": `package stringinputs +import ("errors"; "fmt"; "database/sql") +func source() error { return errors.New("local") } +type Record struct { Format string; hidden string } +var Format = "%v" +var privateFormat = "%v" +var Shared = &Record{Format: "%v", hidden: "%v"} +var alias = Shared +func Global() error { return fmt.Errorf(Format, source()) } +func Private() error { return fmt.Errorf(privateFormat, source()) } +func Alias() error { return fmt.Errorf(alias.Format, source()) } +func Hidden() error { return fmt.Errorf(Shared.hidden, source()) } +func Field(r *Record) error { return fmt.Errorf(r.Format, source()) } +func Slice(formats []string) error { return fmt.Errorf(formats[0], source()) } +func Map(formats map[string]string) error { return fmt.Errorf(formats["key"], source()) } +func Channel(formats <-chan string) error { return fmt.Errorf(<-formats, source()) } +func Pointer(format *string) error { return fmt.Errorf(*format, source()) } +func closed(r *Record) error { return fmt.Errorf(r.Format, source()) } +func Locals() { r := &Record{Format: "%v"}; _ = Field(r); _ = closed(r); _ = Slice([]string{"%v"}); _ = Map(map[string]string{"key":"%v"}); ch := make(chan string,1); ch <- "%v"; _ = Channel(ch); s := "%v"; _ = Pointer(&s) } +var Query = "select * from known_records" +var privateQuery = "select * from known_records" +func Prepared(db *sql.DB) { stmt, _ := db.Prepare(Query); stmt.Exec() } +func ClosedPrepared(db *sql.DB) { stmt, _ := db.Prepare(privateQuery); stmt.Exec() } +func other() error { return errors.New("other") } +func ZLookup(key string) error { m := map[string]func() error{"first": source, "second": other}; return m[key]() } +func closedLookup(key string) error { m := map[string]func() error{"first": source, "second": other}; return m[key]() } +func ALookupCaller() error { _ = ZLookup("first"); return closedLookup("first") } +func AFormat() string { return "%v" } +func AResults() { _ = Result(AFormat); _ = closedResult(AFormat) } +func Result(factory func() string) error { return fmt.Errorf(factory(), source()) } +func closedResult(factory func() string) error { return fmt.Errorf(factory(), source()) } +func ATupleFormat() (int, string) { return 1, "%v" } +func ATuples() { _ = Tuple(ATupleFormat); _ = closedTuple(ATupleFormat) } +func Tuple(factory func() (int, string)) error { _, format := factory(); return fmt.Errorf(format, source()) } +func closedTuple(factory func() (int, string)) error { _, format := factory(); return fmt.Errorf(format, source()) } +func AAssertions() { _ = Assert("%v"); _ = closedAssert("%v") } +func Assert(value any) error { return fmt.Errorf(value.(string), source()) } +func closedAssert(value any) error { return fmt.Errorf(value.(string), source()) } +`, + } + for name, contents := range files { + if err := os.WriteFile(filepath.Join(root, name), []byte(contents), 0644); err != nil { + t.Fatal(err) + } + } + seeds := []string{"source", "Prepared", "ClosedPrepared", "other"} + fresh, saved, err := TraceWithAnalysis(context.Background(), Options{Root: root, Seeds: seeds, Depth: 8, MaxNodes: 250}) + if err != nil { + t.Fatal(err) + } + resumed, err := Explore(context.Background(), saved, ExploreOptions{Seeds: seeds, Depth: 8, MaxNodes: 250}) + if err != nil { + t.Fatal(err) + } + for _, report := range []Report{fresh, resumed} { + origins := map[string]bool{} + for _, node := range report.Nodes { + if node.Kind == "error_result" && strings.Contains(node.Evidence.Snippet, `errors.New("local")`) { + origins[node.ID] = true + } + } + if len(origins) != 1 { + t.Fatal("missing source error") + } + for _, owner := range []string{"Global", "Private", "Alias", "Hidden", "Field", "Slice", "Map", "Channel", "Pointer", "closed", "Result", "closedResult", "Tuple", "closedTuple", "Assert", "closedAssert"} { + cause, boundary := false, false + for _, edge := range report.Relationships { + cause = cause || strings.HasSuffix(edge.From, "::"+owner) && edge.Kind == "error_return" && origins[edge.To] + } + for _, item := range report.Boundaries { + boundary = boundary || item.Kind == "error_wrap_format" && strings.HasSuffix(item.Node, "::"+owner) + } + outside := owner != "Private" && owner != "Hidden" && owner != "closed" && owner != "closedResult" && owner != "closedTuple" && owner != "closedAssert" + if cause != outside || boundary != outside { + t.Errorf("%s cause=%v boundary=%v expected outside=%v", owner, cause, boundary, outside) + } + } + for _, owner := range []string{"Prepared", "ClosedPrepared"} { + known, outside := false, false + for _, edge := range report.Relationships { + known = known || edge.Kind == "sql_read" && strings.HasSuffix(edge.From, "::"+owner) && edge.To == "table:known_records" + } + for _, boundary := range report.Boundaries { + outside = outside || boundary.Kind == "unresolved_prepared_sql" && strings.HasSuffix(boundary.Node, "::"+owner) + } + if !known || outside != (owner == "Prepared") { + t.Errorf("%s known query=%v outside query=%v", owner, known, outside) + } + } + other := map[string]bool{} + for _, node := range report.Nodes { + if node.Kind == "error_result" && strings.Contains(node.Evidence.Snippet, `errors.New("other")`) { + other[node.ID] = true + } + } + if len(other) != 1 { + t.Fatal("missing second map target's error") + } + for _, owner := range []string{"ZLookup", "closedLookup"} { + found := false + for _, edge := range report.Relationships { + found = found || edge.Kind == "error_return" && strings.HasSuffix(edge.From, "::"+owner) && other[edge.To] + } + if found != (owner == "ZLookup") { + t.Errorf("%s second map callback=%v", owner, found) + } + } + } +} diff --git a/parenthesized_event_fields_test.go b/parenthesized_event_fields_test.go new file mode 100644 index 0000000..c1da13d --- /dev/null +++ b/parenthesized_event_fields_test.go @@ -0,0 +1,121 @@ +package contracttrace + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestParenthesizedEventFieldsUseUnderlyingSelector(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example.com/parenthesizedevents\n\ngo 1.27.0\n"), 0600); err != nil { + t.Fatal(err) + } + source := `package app + +type Envelope struct { + EventType string + Version string +} + +func Assign() { + var envelope Envelope + (envelope.EventType) = "orders.assigned" +} + +func RangeValue(topics []string) { + var envelope Envelope + for _, ((envelope.EventType)) = range topics {} +} + +func RangeUnrelated(versions []string) { + var envelope Envelope + for _, (envelope.Version) = range versions {} +} + +func RangeBareField(topics []string) { + var EventType string + for _, (EventType) = range topics {} + _ = EventType +} + +func Compare() { + var envelope Envelope + if (envelope.EventType) == "orders.compared" {} +} + +func Switch() { + var envelope Envelope + switch (envelope.EventType) { + case "orders.switched": + } +} + +var packageEnvelope Envelope +var packageEvent = func() int { + (packageEnvelope.EventType) = "orders.initializer" + return 0 +}() +` + if err := os.WriteFile(filepath.Join(root, "app.go"), []byte(source), 0600); err != nil { + t.Fatal(err) + } + + report, err := Trace(context.Background(), Options{ + Root: root, + Seeds: []string{ + "Assign", "RangeValue", "RangeUnrelated", "RangeBareField", "Compare", "Switch", + "example.com/parenthesizedevents::(package init)", + }, + Depth: 2, + MaxNodes: 100, + Config: Config{EventFields: []string{"EventType"}}, + }) + if err != nil { + t.Fatal(err) + } + + line := func(fragment string) int { + t.Helper() + index := strings.Index(source, fragment) + if index < 0 { + t.Fatalf("fixture does not contain %q", fragment) + } + return strings.Count(source[:index], "\n") + 1 + } + owner := func(name string) string { return "example.com/parenthesizedevents::" + name } + expected := []struct { + from string + to string + kind string + line int + }{ + {owner("Assign"), "event:orders.assigned", "event_assign", line(`(envelope.EventType) = "orders.assigned"`)}, + {owner("Compare"), "event:orders.compared", "event_compare", line(`if (envelope.EventType) == "orders.compared"`)}, + {owner("Switch"), "event:orders.switched", "event_case", line(`case "orders.switched":`)}, + {owner("(package init)"), "event:orders.initializer", "event_assign", line(`(packageEnvelope.EventType) = "orders.initializer"`)}, + } + for _, want := range expected { + if !hasRelationship(report, want.from, want.to, want.kind, "app.go", want.line) { + t.Errorf("missing parenthesized event relationship from %s to %s (%s) at app.go:%d", want.from, want.to, want.kind, want.line) + } + } + rangeOwner := owner("RangeValue") + rangeLine := line("for _, ((envelope.EventType)) = range topics") + if !hasBoundaryAt(report, rangeOwner, "dynamic_event", "app.go", rangeLine) { + t.Errorf("parenthesized range target lacks dynamic_event at app.go:%d", rangeLine) + } + for _, edge := range report.Relationships { + if edge.From == rangeOwner && edge.Kind == "event_assign" { + t.Errorf("range iteration value was invented as a completed event: %+v", edge) + } + } + if hasBoundaryAt(report, owner("RangeUnrelated"), "dynamic_event", "app.go", line("for _, (envelope.Version) = range versions")) { + t.Error("parenthesized unrelated selector acquired a dynamic_event boundary") + } + if !hasBoundaryAt(report, owner("RangeBareField"), "dynamic_event", "app.go", line("for _, (EventType) = range topics")) { + t.Error("parenthesized configured bare field lacks dynamic_event") + } +} diff --git a/prepared_test.go b/prepared_test.go new file mode 100644 index 0000000..ae97430 --- /dev/null +++ b/prepared_test.go @@ -0,0 +1,125 @@ +package contracttrace + +import ( + "context" + "strings" + "testing" +) + +func TestPreparedSQLConnectsExecutionAndExcludesParameterText(t *testing.T) { + r, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"PreparedStart", "UnknownStatement"}, Depth: 4, MaxNodes: 250}) + if err != nil { + t.Fatal(err) + } + execution, close, unknown := false, false, false + for _, e := range r.Relationships { + if strings.HasSuffix(e.From, "::ExecuteRecords") && e.To == "table:prepared_records" && e.Kind == "sql_write" { + execution = true + } + close = close || e.Kind == "statement_close" && strings.HasSuffix(e.From, "::PreparedStart") + if strings.HasSuffix(e.From, "::ExecuteRecords") && e.To == "table:decoy_payload" { + t.Fatal("bound parameter was interpreted as a prepared query") + } + if strings.HasSuffix(e.From, "::UnknownStatement") && e.To == "table:unknown_payload" { + t.Fatal("unknown statement parameter invented a query") + } + } + for _, b := range r.Boundaries { + unknown = unknown || b.Kind == "unresolved_prepared_sql" && strings.HasSuffix(b.Node, "::UnknownStatement") + } + if !execution || !close || !unknown { + t.Fatalf("prepared contract path missing: execution=%t close=%t unknown=%t", execution, close, unknown) + } +} + +func TestTransactionAndPreparedLifecycleSites(t *testing.T) { + r, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"TransactionStatement"}, Depth: 4, MaxNodes: 250}) + if err != nil { + t.Fatal(err) + } + transaction := "" + commit, rollback, query, origin := false, false, false, false + parent, cleanupStatement, cleanupTransaction := false, false, false + for _, e := range r.Relationships { + if e.Kind == "transaction_commit" && strings.HasSuffix(e.From, "::TransactionStatement") { + transaction = e.To + commit = true + } + query = query || e.Kind == "sql_read" && strings.HasSuffix(e.From, "::TransactionStatement") && e.To == "table:transaction_records" + origin = origin || e.Kind == "prepared_sql_read" && e.To == "table:transaction_records" && strings.Contains(e.Evidence.Snippet, "transaction.Prepare") + } + for _, e := range r.Relationships { + rollback = rollback || e.Kind == "transaction_rollback" && e.To == transaction + parent = parent || e.Kind == "statement_transaction" && e.To == transaction + cleanupStatement = cleanupStatement || e.Kind == "cleanup_statement_close" + cleanupTransaction = cleanupTransaction || e.Kind == "cleanup_transaction_rollback" && e.To == transaction + } + if transaction == "" || !commit || !rollback || !query || !origin || !parent || !cleanupStatement || !cleanupTransaction { + t.Fatalf("transaction lifecycle scope missing: commit=%t rollback=%t query=%t origin=%t parent=%t cleanupStatement=%t cleanupTransaction=%t", commit, rollback, query, origin, parent, cleanupStatement, cleanupTransaction) + } +} + +func TestPreparedSQLRetainsPreparationNamespaceAcrossFiles(t *testing.T) { + config := DefaultConfig() + config.StorageScopes = []StorageScope{ + {Namespace: "origin", GoFiles: []string{"prepared.go"}}, + {Namespace: "execution", GoFiles: []string{"prepared_execution.go"}}, + } + r, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"PreparedStart"}, Depth: 4, MaxNodes: 250, Config: config}) + if err != nil { + t.Fatal(err) + } + found := false + for _, edge := range r.Relationships { + if !strings.HasSuffix(edge.From, "::ExecuteRecords") || edge.Kind != "sql_write" { + continue + } + if edge.To != "table:origin:prepared_records" { + t.Fatalf("execution changed the preparation database scope: %+v", edge) + } + found = true + } + if !found { + t.Fatal("cross-file prepared execution lost its preparation namespace") + } +} + +func TestTransactionRebindingRetainsQueryAndOwnership(t *testing.T) { + config := DefaultConfig() + config.StorageScopes = []StorageScope{{Namespace: "prepared", GoFiles: []string{"prepared.go"}}, {Namespace: "execution", GoFiles: []string{"prepared_execution.go"}}} + r, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"ReboundStatement", "ReboundUnknown"}, Depth: 5, MaxNodes: 250, Config: config}) + if err != nil { + t.Fatal(err) + } + rebindings := map[string]bool{} + parents := map[string]string{} + cleanup := "" + query, unknown := false, false + for _, edge := range r.Relationships { + if edge.Kind == "statement_rebind" && strings.Contains(edge.Evidence.Snippet, "transaction.Stmt") { + rebindings[edge.From] = true + } + if edge.Kind == "statement_transaction" { + parents[edge.From] = edge.To + } + if edge.Kind == "cleanup_statement_close" && strings.Contains(edge.Evidence.Snippet, "second.Close") { + cleanup = edge.To + } + query = query || edge.Kind == "sql_write" && strings.HasSuffix(edge.From, "::ExecuteRebound") && edge.To == "table:prepared:prepared_records" + if strings.Contains(edge.To, "decoy") && strings.HasPrefix(edge.To, "table:") { + t.Fatalf("rebound statement parameters became SQL: %+v", edge) + } + } + for _, boundary := range r.Boundaries { + unknown = unknown || boundary.Kind == "unresolved_prepared_sql" && strings.HasSuffix(boundary.Node, "::ReboundUnknown") + } + if len(rebindings) != 2 || cleanup == "" || !rebindings[cleanup] || !query || !unknown { + t.Fatalf("rebound path incomplete: rebindings=%d cleanup=%q query=%t unknown=%t", len(rebindings), cleanup, query, unknown) + } + transaction := parents[cleanup] + for rebound := range rebindings { + if transaction == "" || parents[rebound] != transaction { + t.Fatalf("rebound statement lost transaction identity: %+v", parents) + } + } +} diff --git a/profiles/go-core.json b/profiles/go-core.json new file mode 100644 index 0000000..fab8a51 --- /dev/null +++ b/profiles/go-core.json @@ -0,0 +1,6 @@ +{ + "event_fields": [], + "lifecycle_names": [], + "sql_methods": [], + "sql_files": [] +} diff --git a/promoted_sql_receiver_test.go b/promoted_sql_receiver_test.go new file mode 100644 index 0000000..c7b6e5c --- /dev/null +++ b/promoted_sql_receiver_test.go @@ -0,0 +1,112 @@ +package contracttrace + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestSQLCallRuleProjectsPromotedMethodExpressionReceiver(t *testing.T) { + root := t.TempDir() + files := map[string]string{ + "go.mod": `module example.com/promotedsqltest + +go 1.27.0 +`, + "client.go": `package promotedsqltest +import "database/sql" + +type Lease struct { *sql.DB } +type Wrapped struct { *Lease } + +func OpenPrimary() *sql.DB { db, _ := sql.Open("sqlite3", ":memory:"); return db } +func OpenArchive() *sql.DB { db, _ := sql.Open("sqlite3", ":memory:"); return db } +func PrimaryLease() *Lease { return &Lease{DB: OpenPrimary()} } +func ArchiveLease() *Lease { return &Lease{DB: OpenArchive()} } + +func Scenario() { + primary := &Wrapped{Lease: PrimaryLease()} + archive := &Wrapped{Lease: ArchiveLease()} + primary.Exec("SELECT id FROM records") + archive.Exec("SELECT id FROM records") + primaryAlias := primary.Exec + primaryAlias("SELECT id FROM records") + (*Wrapped).Exec(primary, "SELECT id FROM records") + go (*Wrapped).Exec(primary, "SELECT id FROM records") + defer (*Wrapped).Exec(archive, "SELECT id FROM records") + runArchiveExpression((*Wrapped).Exec, archive) + PublicExpression(archive) +} + +func runArchiveExpression(fn func(*Wrapped, string, ...any) (sql.Result, error), wrapped *Wrapped) { + fn(wrapped, "SELECT id FROM records") +} + +func PublicExpression(wrapped *Wrapped) { + (*Wrapped).Exec(wrapped, "SELECT id FROM records") +} +`, + "migrations/primary.sql": "CREATE TABLE records (id INTEGER PRIMARY KEY);\n", + "migrations/archive.sql": "CREATE TABLE records (id INTEGER PRIMARY KEY);\n", + } + for name, contents := range files { + path := filepath.Join(root, name) + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(contents), 0600); err != nil { + t.Fatal(err) + } + } + config := DefaultConfig() + config.SQLFiles = []string{"migrations/*.sql"} + config.StorageScopes = []StorageScope{ + {Namespace: "primary", SQLFiles: []string{"migrations/primary.sql"}, DatabaseOrigins: []string{"example.com/promotedsqltest::OpenPrimary"}}, + {Namespace: "archive", SQLFiles: []string{"migrations/archive.sql"}, DatabaseOrigins: []string{"example.com/promotedsqltest::OpenArchive"}}, + } + config.CallRules = []CallRule{{Symbol: "database/sql::DB.Exec", Kind: "sql_query", Argument: 0}} + report, analysis, err := TraceWithAnalysis(context.Background(), Options{Root: root, Seeds: []string{"Scenario", "PublicExpression"}, Depth: 8, MaxNodes: 300, Config: config}) + if err != nil { + t.Fatal(err) + } + want := []struct{ owner, target, evidence string }{ + {"example.com/promotedsqltest::Scenario", "table:primary:records", "primary.Exec("}, + {"example.com/promotedsqltest::Scenario", "table:archive:records", "archive.Exec("}, + {"example.com/promotedsqltest::Scenario", "table:primary:records", "primaryAlias("}, + {"example.com/promotedsqltest::Scenario", "table:primary:records", "(*Wrapped).Exec(primary"}, + {"example.com/promotedsqltest::Scenario", "table:primary:records", "go (*Wrapped).Exec(primary"}, + {"example.com/promotedsqltest::Scenario", "table:archive:records", "defer (*Wrapped).Exec(archive"}, + {"example.com/promotedsqltest::runArchiveExpression", "table:archive:records", "fn(wrapped,"}, + {"example.com/promotedsqltest::PublicExpression", "table:archive:records", "(*Wrapped).Exec(wrapped"}, + } + for _, expected := range want { + found, crossed := false, false + otherNamespace := "table:archive:records" + if strings.Contains(expected.target, ":archive:") { + otherNamespace = "table:primary:records" + } + for _, edge := range analysis.Relationships { + if edge.From != expected.owner || edge.Kind != "sql_read" || !strings.HasPrefix(strings.TrimSpace(edge.Evidence.Snippet), expected.evidence) { + continue + } + found = found || edge.To == expected.target + crossed = crossed || edge.To == otherNamespace + } + if !found || crossed { + t.Errorf("SQL call at %s lost its receiver namespace or crossed stores: want %s (found=%t crossed=%t)", expected.evidence, expected.target, found, crossed) + } + } + if report.ContractComplete { + t.Fatal("static SQL analysis must retain incomplete contract coverage") + } + unknownPublicReceiver := false + for _, boundary := range report.Boundaries { + unknownPublicReceiver = unknownPublicReceiver || boundary.Node == "example.com/promotedsqltest::PublicExpression" && + boundary.Kind == "unresolved_database_namespace" && strings.Contains(boundary.Evidence.Snippet, "(*Wrapped).Exec(wrapped") + } + if !unknownPublicReceiver { + t.Fatal("public promoted receiver lost its unresolved outside namespace boundary") + } +} diff --git a/range_event_fields_test.go b/range_event_fields_test.go new file mode 100644 index 0000000..711d0a0 --- /dev/null +++ b/range_event_fields_test.go @@ -0,0 +1,108 @@ +package contracttrace + +import ( + "context" + "os" + "path/filepath" + "testing" +) + +func TestRangeAssignmentsKeepConfiguredEventValuesDynamic(t *testing.T) { + root := t.TempDir() + write := func(name, source string) { + t.Helper() + if err := os.WriteFile(filepath.Join(root, name), []byte(source), 0600); err != nil { + t.Fatal(err) + } + } + write("go.mod", "module example.com/rangeevents\n\ngo 1.27.0\n") + write("app.go", `package app + +type Envelope struct { EventType string; Version int } + +func MapKey(topics map[string]bool) { + var envelope Envelope + for envelope.EventType = range topics {} +} +func SliceValue(topics []string) { + var envelope Envelope + for _, envelope.EventType = range topics {} +} +func ChannelValue(topics <-chan string) { + var envelope Envelope + for envelope.EventType = range topics {} +} +func IntegerAndStringRanges() { + var envelope Envelope + for envelope.Version = range 3 {} + for envelope.Version = range "orders" {} +} +func Direct() { + var envelope Envelope + envelope.EventType = "orders.direct" +} + +var packageTopics = map[string]bool{"orders.package": true} +var packageEnvelope = Envelope{} +var packageRange = func() int { + for packageEnvelope.EventType = range packageTopics {} + return 0 +}() + +func BareRangeField(values []string) { + var EventType string + for _, EventType = range values {} + _ = EventType +} +`) + + report, err := Trace(context.Background(), Options{ + Root: root, + Seeds: []string{ + "MapKey", "SliceValue", "ChannelValue", "IntegerAndStringRanges", "Direct", "BareRangeField", + "example.com/rangeevents::(package init)", + }, + Depth: 2, + MaxNodes: 100, + Config: Config{EventFields: []string{"EventType"}}, + }) + if err != nil { + t.Fatal(err) + } + for _, expected := range []struct { + owner string + line int + }{ + {"MapKey", 7}, + {"SliceValue", 11}, + {"ChannelValue", 15}, + {"(package init)", 30}, + {"BareRangeField", 36}, + } { + if !hasBoundaryAt(report, "example.com/rangeevents::"+expected.owner, "dynamic_event", "app.go", expected.line) { + t.Errorf("range assignment to configured event field lacks dynamic_event at %s:%d", expected.owner, expected.line) + } + } + if !hasRelationship(report, "example.com/rangeevents::Direct", "event:orders.direct", "event_assign", "app.go", 24) { + t.Error("ordinary event assignment stopped producing its known event") + } + for _, line := range []int{19, 20} { + if hasBoundaryAt(report, "example.com/rangeevents::IntegerAndStringRanges", "dynamic_event", "app.go", line) { + t.Errorf("range on unrelated Envelope.Version field acquired dynamic_event at app.go:%d", line) + } + } + for _, event := range []string{"orders.key", "orders.value", "orders.package"} { + for _, edge := range report.Relationships { + if edge.Kind == "event_assign" && edge.To == "event:"+event { + t.Errorf("range source value %q was incorrectly treated as a completed event assignment: %+v", event, edge) + } + } + } + for _, owner := range []string{"MapKey", "SliceValue", "ChannelValue", "IntegerAndStringRanges", "BareRangeField"} { + for _, edge := range report.Relationships { + if edge.Kind == "event_assign" && edge.From == "example.com/rangeevents::"+owner { + t.Errorf("range iteration was reported as a known event assignment: %+v", edge) + } + } + } +} diff --git a/record_cache_test.go b/record_cache_test.go new file mode 100644 index 0000000..9d432c7 --- /dev/null +++ b/record_cache_test.go @@ -0,0 +1,61 @@ +package contracttrace + +import ( + "fmt" + "go/types" + "reflect" + "testing" + + "golang.org/x/tools/go/ssa" +) + +func TestRepeatedRecordAddressExpansionHasBoundedAllocations(t *testing.T) { + measure := func(width int) float64 { + a := &flowAnalysis{values: map[ssa.Value]flowValue{}, memory: map[string]flowValue{}} + value := ssa.NewConst(nil, types.NewPointer(types.NewStruct(nil, nil))) + root := emptyFlow() + root.addresses["root"] = true + a.put(value, root) + aliases := emptyFlow() + for i := 0; i < width; i++ { + aliases.addresses[fmt.Sprintf("alias:%02d", i)] = true + } + a.store("root", aliases) + for alias := range aliases.addresses { + a.store(alias, aliases) + } + return testing.AllocsPerRun(10, func() { + if got := len(a.recordAddresses(value)); got != width+1 { + t.Fatalf("reachable addresses=%d, want %d", got, width+1) + } + }) + } + small, large := measure(3), measure(63) + t.Logf("allocations per unchanged expansion: small=%.0f large=%.0f", small, large) + if large > small*4+10 { + t.Fatalf("unchanged alias expansion allocates with closure width: small=%.0f large=%.0f", small, large) + } +} + +func TestRecordAddressExpansionObservesLaterMemoryAndRootAliases(t *testing.T) { + a := &flowAnalysis{values: map[ssa.Value]flowValue{}, memory: map[string]flowValue{}} + value := ssa.NewConst(nil, types.NewPointer(types.NewStruct(nil, nil))) + putRoot := func(name string) { flow := emptyFlow(); flow.addresses[name] = true; a.put(value, flow) } + storeAlias := func(from, to string) { flow := emptyFlow(); flow.addresses[to] = true; a.store(from, flow) } + check := func(expected []string) { + t.Helper() + if got := a.recordAddresses(value); !reflect.DeepEqual(got, expected) { + t.Fatalf("addresses=%v, want %v", got, expected) + } + } + putRoot("A") + storeAlias("A", "B") + check([]string{"A", "B"}) + storeAlias("B", "C") // B previously had no memory; that missing read matters. + check([]string{"A", "B", "C"}) + storeAlias("C", "A") // A pointer cycle must still terminate. + check([]string{"A", "B", "C"}) + putRoot("D") + storeAlias("D", "E") + check([]string{"A", "B", "C", "D", "E"}) +} diff --git a/record_fields.go b/record_fields.go new file mode 100644 index 0000000..2113707 --- /dev/null +++ b/record_fields.go @@ -0,0 +1,450 @@ +package contracttrace + +import ( + "fmt" + "go/ast" + "go/constant" + "go/token" + "go/types" + "sort" + + "golang.org/x/tools/go/packages" +) + +func inspectParents(root ast.Node, visit func(ast.Node, []ast.Node)) { + parents := []ast.Node{} + ast.Inspect(root, func(node ast.Node) bool { + if node == nil { + parents = parents[:len(parents)-1] + return true + } + visit(node, parents) + parents = append(parents, node) + return true + }) +} + +func (ix *index) recordFields(pkgs []*packages.Package, config Config) { + fields := map[token.Pos]string{} + if ix.declarationRanges == nil { + ix.declarationRanges = map[string]SourceRange{} + } + for _, pkg := range pkgs { + for _, file := range pkg.Syntax { + if _, inside := relative(ix.root, ix.fset.Position(file.Pos()).Filename); !inside { + continue + } + inspectParents(file, func(node ast.Node, parents []ast.Node) { + literal, ok := node.(*ast.StructType) + if !ok { + return + } + typ := pkg.TypesInfo.TypeOf(literal) + if typ == nil { + return + } + shape, ok := typ.Underlying().(*types.Struct) + if !ok { + return + } + e := ix.evidence(literal.Pos()) + label := fmt.Sprintf("struct@%s:%d:%d", e.File, e.Line, e.Column) + if len(parents) > 0 { + if spec, ok := parents[len(parents)-1].(*ast.TypeSpec); ok && spec.Type == literal { + label = spec.Name.Name + for _, parent := range parents { + if fn, ok := parent.(*ast.FuncDecl); ok { + label = fmt.Sprintf("%s.%s@%s:%d:%d", fn.Name.Name, label, e.File, e.Line, e.Column) + } + } + } + } + for i := 0; i < shape.NumFields(); i++ { + field := shape.Field(i) + if field.Name() == "_" { + continue + } + id := "field:" + pkg.PkgPath + "::" + label + "." + field.Name() + fields[field.Pos()] = id + if ix.funcs[id] == nil { + kind := "record_field" + if basic, ok := field.Type().Underlying().(*types.Basic); ok && basic.Kind() == types.String && contains(config.EventFields, field.Name()) { + kind = "event_discriminator_field" + } + ix.funcs[id] = &function{node: Node{ID: id, Name: label + "." + field.Name(), Kind: kind, Evidence: ix.evidence(field.Pos())}} + } + // Each named field starts at its own declaration evidence. A + // grouped declaration shares the type's end, so overlapping + // line seeds remain explicit ambiguities rather than guesses. + for _, group := range literal.Fields.List { + if field.Pos() >= group.Pos() && field.Pos() < group.End() { + ix.declarationRanges[id] = SourceRange{First: ix.fset.Position(field.Pos()).Line, Last: ix.fset.Position(group.End()).Line} + break + } + } + } + }) + } + } + link := func(owner string, field *types.Var, kind string, pos token.Pos) { + if target := fields[field.Pos()]; target != "" { + ix.edge(owner, target, kind, "fact", pos) + } else { + ix.boundaries = append(ix.boundaries, Boundary{Node: owner, Kind: "unindexed_record_field", Reason: "Typed field " + field.String() + " has no indexed declaration in the target; dependency or omitted type storage relationships remain unresolved.", Evidence: ix.evidence(pos)}) + } + } + // A Go value copy includes nested records and nonempty fixed arrays, but + // copying a reference/descriptor does not copy its referenced contents. + // Cache declaration inventories rather than enumerating array elements or + // repeating a shared nested type for every occurrence in a larger record. + inventories := map[types.Type][]*types.Var{} + valueFields := func(typ types.Type) []*types.Var { + if cached, ok := inventories[typ]; ok { + return cached + } + seen := map[types.Type]bool{} + var found []*types.Var + var visit func(types.Type) + visit = func(current types.Type) { + if current == nil || seen[current] { + return + } + seen[current] = true + switch shape := current.Underlying().(type) { + case *types.Struct: + for i := 0; i < shape.NumFields(); i++ { + field := shape.Field(i) + if field.Name() != "_" { + found = append(found, field) + visit(field.Type()) + } + } + case *types.Array: + if shape.Len() > 0 { + visit(shape.Elem()) + } + case *types.Tuple: + for i := 0; i < shape.Len(); i++ { + visit(shape.At(i).Type()) + } + } + } + visit(typ) + inventories[typ] = found + return found + } + scan := func(owner string, root ast.Node, pkg *packages.Package, results, receiver *ast.FieldList) { + record := func(typ types.Type, kind string, pos token.Pos) { + for _, field := range valueFields(typ) { + link(owner, field, kind, pos) + } + } + namedResults := func(list *ast.FieldList, kind string, pos token.Pos) { + if list == nil { + return + } + for _, result := range list.List { + for _, name := range result.Names { + if variable, ok := pkg.TypesInfo.Defs[name].(*types.Var); ok { + evidence := pos + if !evidence.IsValid() { + evidence = name.Pos() + } + record(variable.Type(), kind, evidence) + } + } + } + } + namedResults(results, "field_zero_initialize", token.NoPos) + if receiver != nil { + for _, field := range receiver.List { + record(pkg.TypesInfo.TypeOf(field.Type), "field_receiver_parameter", field.Type.Pos()) + } + } + embeddingAccess := func(selection *types.Selection, pos token.Pos) { + typ := selection.Recv() + for _, index := range selection.Index()[:len(selection.Index())-1] { + if pointer, ok := typ.Underlying().(*types.Pointer); ok { + typ = pointer.Elem() + } + shape, ok := typ.Underlying().(*types.Struct) + if !ok || index >= shape.NumFields() { + break + } + embedded := shape.Field(index) + link(owner, embedded, "field_embedding_access", pos) + typ = embedded.Type() + } + } + copyReceiver := func(selection *types.Selection, pos token.Pos) { + if signature, ok := selection.Obj().Type().(*types.Signature); ok && signature.Recv() != nil { + typ := signature.Recv().Type() + if _, ok := typ.Underlying().(*types.Interface); ok { + ix.boundaries = append(ix.boundaries, Boundary{Node: owner, Kind: "unresolved_record_receiver", Reason: "Interface method receiver implementation may use a value or pointer receiver; record copies and runtime dispatch identity are unresolved.", Evidence: ix.evidence(pos)}) + } else { + record(typ, "field_receiver_copy", pos) + } + } + } + whole := func(expression ast.Expr, kind string) { + if expression == nil { + return + } + if name, ok := expression.(*ast.Ident); ok && name.Name == "_" { + return + } + if tv, ok := pkg.TypesInfo.Types[expression]; ok && tv.IsType() { + return + } + record(pkg.TypesInfo.TypeOf(expression), kind, expression.Pos()) + } + inspectParents(root, func(node ast.Node, parents []ast.Node) { + switch expression := node.(type) { + case *ast.ReturnStmt: + if len(expression.Results) == 0 { + list := results + for i := len(parents) - 1; i >= 0; i-- { + if literal, ok := parents[i].(*ast.FuncLit); ok { + list = literal.Type.Results + break + } + } + namedResults(list, "field_record_read", expression.Pos()) + } + for _, value := range expression.Results { + whole(value, "field_record_read") + } + case *ast.AssignStmt: + for _, value := range expression.Lhs { + whole(value, "field_record_write") + } + for _, value := range expression.Rhs { + whole(value, "field_record_read") + } + case *ast.ValueSpec: + for _, name := range expression.Names { + if name.Name != "_" { + if variable, ok := pkg.TypesInfo.Defs[name].(*types.Var); ok { + kind := "field_record_write" + if len(expression.Values) == 0 { + kind = "field_zero_initialize" + } + record(variable.Type(), kind, name.Pos()) + } + } + } + for _, value := range expression.Values { + whole(value, "field_record_read") + } + case *ast.SendStmt: + whole(expression.Value, "field_record_read") + case *ast.BinaryExpr: + whole(expression.X, "field_record_read") + whole(expression.Y, "field_record_read") + case *ast.CallExpr: + callee := expression.Fun + for { + parenthesized, ok := callee.(*ast.ParenExpr) + if !ok { + break + } + callee = parenthesized.X + } + if selector, ok := callee.(*ast.SelectorExpr); ok { + if selection := pkg.TypesInfo.Selections[selector]; selection != nil && selection.Kind() == types.MethodExpr { + copyReceiver(selection, expression.Pos()) + embeddingAccess(selection, expression.Pos()) + } + } + for _, argument := range expression.Args { + whole(argument, "field_record_read") + } + if callee, ok := expression.Fun.(*ast.Ident); ok { + if builtin, ok := pkg.TypesInfo.Uses[callee].(*types.Builtin); ok && builtin.Name() == "new" { + if typ := pkg.TypesInfo.TypeOf(expression); typ != nil { + if pointer, ok := typ.Underlying().(*types.Pointer); ok { + record(pointer.Elem(), "field_zero_initialize", expression.Pos()) + } + } + } + } + case *ast.FuncLit: + namedResults(expression.Type.Results, "field_zero_initialize", token.NoPos) + case *ast.SelectorExpr: + selection := pkg.TypesInfo.Selections[expression] + if selection == nil { + return + } + if selection.Kind() == types.MethodVal { + copyReceiver(selection, expression.Sel.Pos()) + embeddingAccess(selection, expression.Sel.Pos()) + return + } + if selection.Kind() != types.FieldVal { + return + } + field, ok := selection.Obj().(*types.Var) + if !ok { + return + } + for _, kind := range fieldAccessRoles(expression, parents) { + link(owner, field, kind, expression.Sel.Pos()) + } + embeddingAccess(selection, expression.Sel.Pos()) + case *ast.CompositeLit: + typ := pkg.TypesInfo.TypeOf(expression) + if typ == nil { + return + } + if array, ok := typ.Underlying().(*types.Array); ok { + written := map[int64]bool{} + next := int64(0) + for _, element := range expression.Elts { + value := element + if pair, ok := element.(*ast.KeyValueExpr); ok { + value = pair.Value + index, exact := constant.Int64Val(pkg.TypesInfo.Types[pair.Key].Value) + if !exact { + continue + } + next = index + } + written[next] = true + next++ + record(array.Elem(), "field_write", element.Pos()) + whole(value, "field_record_read") + } + if int64(len(written)) < array.Len() { + record(array.Elem(), "field_zero_initialize", expression.Pos()) + } + return + } + shape, ok := typ.Underlying().(*types.Struct) + if !ok { + return + } + written := map[int]bool{} + for i, element := range expression.Elts { + index := i + if pair, ok := element.(*ast.KeyValueExpr); ok { + key, ok := pair.Key.(*ast.Ident) + if !ok { + continue + } + index = -1 + for j := 0; j < shape.NumFields(); j++ { + if shape.Field(j).Name() == key.Name { + index = j + break + } + } + } + if index >= 0 && index < shape.NumFields() { + written[index] = true + if shape.Field(index).Name() != "_" { + link(owner, shape.Field(index), "field_write", element.Pos()) + record(shape.Field(index).Type(), "field_write", element.Pos()) + } + } + } + for i := 0; i < shape.NumFields(); i++ { + if !written[i] && shape.Field(i).Name() != "_" { + link(owner, shape.Field(i), "field_zero_initialize", expression.Pos()) + record(shape.Field(i).Type(), "field_zero_initialize", expression.Pos()) + } + } + } + }) + } + ids := []string{} + for id, f := range ix.funcs { + if f.decl != nil { + ids = append(ids, id) + } + } + sort.Strings(ids) + for _, id := range ids { + f := ix.funcs[id] + scan(id, f.decl.Body, f.pkg, f.decl.Type.Results, f.decl.Recv) + } + for _, pkg := range pkgs { + owner := pkg.PkgPath + "::(package init)" + if ix.funcs[owner] == nil { + continue + } + for _, file := range pkg.Syntax { + if _, inside := relative(ix.root, ix.fset.Position(file.Pos()).Filename); !inside { + continue + } + for _, decl := range file.Decls { + if group, ok := decl.(*ast.GenDecl); ok && group.Tok == token.VAR { + for _, spec := range group.Specs { + scan(owner, spec, pkg, nil, nil) + } + } + } + } + } + ix.boundaries = append(ix.boundaries, Boundary{Kind: "record_field_model", Reason: "Typed source accesses, composite/new/variable/named-result initialization, record bindings, explicit and bare returns, tuple result values, arguments, sends, comparisons, formal value receivers and direct/bound receiver copies connect to indexed field declarations, including private fields, promoted selections and generic instances. Shared declaration identity connects sibling investigation paths, not proof of a shared runtime object. Conditional execution, pointer/container identity, field address escapes, reflection, dependency declarations and temporal ownership remain unresolved. Aliased method-expression invocation copies, dynamic interface receiver copies, pointer and descriptor contents are not implied by value copies; nested value records and nonempty fixed arrays are enumerated without runtime element correlation; field relationships do not correlate tuple slots or runtime instances."}) +} + +func fieldAccessRoles(expression ast.Expr, parents []ast.Node) []string { + current := ast.Node(expression) + contents := false + for i := len(parents) - 1; i >= 0; i-- { + switch parent := parents[i].(type) { + case *ast.ParenExpr: + if parent.X != current { + return []string{"field_read"} + } + current = parent + case *ast.IndexExpr: + if parent.X != current { + return []string{"field_read"} + } + contents = true + current = parent + case *ast.StarExpr: + if parent.X != current { + return []string{"field_read"} + } + contents = true + current = parent + case *ast.SelectorExpr: + if parent.X != current { + return []string{"field_read"} + } + contents = true + current = parent + case *ast.AssignStmt: + for _, left := range parent.Lhs { + if left == current { + if contents { + return []string{"field_read", "field_content_write"} + } + if parent.Tok != token.ASSIGN && parent.Tok != token.DEFINE { + return []string{"field_read", "field_write"} + } + return []string{"field_write"} + } + } + return []string{"field_read"} + case *ast.IncDecStmt: + if parent.X == current { + if contents { + return []string{"field_read", "field_content_write"} + } + return []string{"field_read", "field_write"} + } + return []string{"field_read"} + case *ast.UnaryExpr: + if parent.Op == token.AND && parent.X == current { + return []string{"field_address"} + } + return []string{"field_read"} + default: + return []string{"field_read"} + } + } + return []string{"field_read"} +} diff --git a/record_fields_test.go b/record_fields_test.go new file mode 100644 index 0000000..a8e827c --- /dev/null +++ b/record_fields_test.go @@ -0,0 +1,241 @@ +package contracttrace + +import ( + "context" + "os" + "path/filepath" + "testing" +) + +func TestPrivateFieldConnectsSiblingEntryPointsAndConstructors(t *testing.T) { + root := t.TempDir() + for name, contents := range map[string]string{ + "go.mod": "module example.com/fields\n\ngo 1.27.0\n", + "fields.go": `package fields +type Counter struct { remaining int; other int } +func New(n int) *Counter {return &Counter{remaining:n}} +func (c *Counter) Acquire(n int) bool { if c.remaining= 0x21 && r <= 0x7e && !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')) { + b.WriteByte('\\') + } + b.WriteRune(r) + } + return b.String() +} + +func markdownCode(value string) string { + value = visibleMarkdownControls(value) + if value == "" { + // Markdown has no empty code span; show the empty string explicitly. + value = `""` + } + longest, run := 0, 0 + for _, r := range value { + if r == '`' { + run++ + if run > longest { + longest = run + } + } else { + run = 0 + } + } + delimiter := strings.Repeat("`", longest+1) + content := value + if len(content) > 0 && (content[0] == ' ' || content[len(content)-1] == ' ' || content[0] == '`' || content[len(content)-1] == '`') && strings.Trim(content, " ") != "" { + content = " " + content + " " + } + return delimiter + content + delimiter +} + +// Markdown summarizes scope. JSON retains the complete reported relationships. +func Markdown(r Report) string { + var b strings.Builder + fmt.Fprint(&b, "# Contract investigation scope\n\n") + fmt.Fprintf(&b, "Invariant hypothesis: %s\n\n", markdownText(r.Invariant)) + if len(r.Focus) > 0 { + focus := make([]string, len(r.Focus)) + for i, value := range r.Focus { + focus[i] = markdownText(value) + } + fmt.Fprintf(&b, "Explicit contract focus: %s. Ordering uses graph distance to these anchors; it does not prove semantic relevance.\n\n", strings.Join(focus, ", ")) + } + fmt.Fprintf(&b, "Contract completeness is **not established**. %d nodes, %d relationships, %d grouped unresolved boundaries. Scope truncated: **%t**.\n\n", len(r.Nodes), len(r.Relationships), len(r.Boundaries), r.Coverage.Truncated) + fmt.Fprintf(&b, "Source/configuration fingerprint: %s. Build: %s/%s, %s; tests=%t, tags=%s.\n\n", markdownCode(r.Coverage.SourceSHA256), markdownText(r.Coverage.Build["GOOS"]), markdownText(r.Coverage.Build["GOARCH"]), markdownText(r.Coverage.Build["GOVERSION"]), r.Coverage.Tests, markdownCode(fmt.Sprintf("%q", r.Coverage.Tags))) + if len(r.Coverage.LoadedSources) > 0 { + fmt.Fprintf(&b, "Loaded Go source identity: %s across %d captured inputs, including dependency and generated sources. The JSON inventory retains their paths and hashes.\n\n", markdownCode(r.Coverage.LoadedSourceSHA256), len(r.Coverage.LoadedSources)) + } + fmt.Fprint(&b, "## Included scope\n\n") + nodes := append([]Node(nil), r.Nodes...) + sort.Slice(nodes, func(i, j int) bool { + if len(r.Focus) > 0 { + a, b := nodes[i].Relevance, nodes[j].Relevance + if (a != nil) != (b != nil) { + return a != nil + } + if a != nil && b != nil && a.Distance != b.Distance { + return a.Distance < b.Distance + } + } + if nodes[i].Distance != nodes[j].Distance { + return nodes[i].Distance < nodes[j].Distance + } + return nodes[i].ID < nodes[j].ID + }) + for _, n := range nodes { + fmt.Fprintf(&b, "- **%s** (%s, %d hops): %s\n", markdownText(n.Name), markdownText(n.Kind), n.Distance, markdownCode(fmt.Sprintf("%s:%d", n.Evidence.File, n.Evidence.Line))) + if relevance := n.Relevance; relevance != nil { + fmt.Fprintf(&b, " Contract anchor %s: %d graph hops.\n", markdownCode(relevance.Anchor), relevance.Distance) + certainty := relevance.PathCertainty + if certainty == "" { + certainty = "unknown (annotation has no cumulative certainty)" + } + fmt.Fprintf(&b, " Whole focus path: %s. This describes structural edges, not semantic relevance or an invariant proof.\n", markdownText(certainty)) + if e := relevance.Via; e != nil { + next := e.From + if next == n.ID { + next = e.To + } + fmt.Fprintf(&b, " Next focus-path node %s via %s (%s), at %s.\n", markdownCode(next), markdownCode(e.Kind), markdownText(e.Certainty), markdownCode(fmt.Sprintf("%s:%d", e.Evidence.File, e.Evidence.Line))) + } + } + if e := n.ReachedBy; e != nil { + other := e.From + if other == n.ID { + other = e.To + } + attribution := "evidenced" + if e.Evidence.Origin == "synthetic_declaration" { + attribution = "attributed to the generating declaration" + } + fmt.Fprintf(&b, " Connected to %s by %s (%s), %s at %s.\n", markdownCode(other), markdownCode(e.Kind), markdownText(e.Certainty), attribution, markdownCode(fmt.Sprintf("%s:%d", e.Evidence.File, e.Evidence.Line))) + } + } + counts := map[string]int{} + targets := map[string]int{} + missingInventories := 0 + for _, v := range r.Boundaries { + counts[v.Kind]++ + targets[v.Kind] += v.CandidateCount + if v.CandidateCount > 0 && len(v.Candidates) != v.CandidateCount { + missingInventories++ + } + } + kinds := []string{} + for k := range counts { + kinds = append(kinds, k) + } + sort.Strings(kinds) + fmt.Fprintln(&b, "\n## Unresolved boundary summary\n\n| Kind | Entries | Distinct targets counted per entry |\n| --- | ---: | ---: |") + for _, k := range kinds { + fmt.Fprintf(&b, "| %s | %d | %d |\n", markdownText(k), counts[k], targets[k]) + } + fmt.Fprintln(&b, "\nCounts are not globally distinct targets. Fresh JSON reports retain each grouped entry's discovered targets in `candidates`, alongside source evidence and up to five preview `examples`. These sets cover discovered candidates only; omitted bodies, unsupported analysis and widening remain boundaries. Use the JSON report to inspect targets; this summary does not discharge them.") + if missingInventories > 0 { + fmt.Fprintf(&b, "\n%d entries lack a full retained candidate inventory, as can occur with older snapshots. Their counts and examples cannot reconstruct the missing targets; rerun analysis to obtain current inventories.\n", missingInventories) + } + for _, v := range r.Boundaries { + if v.Node == "" { + fmt.Fprintf(&b, "\n- %s: %s\n", markdownText(v.Kind), markdownText(v.Reason)) + } + } + if len(r.Coverage.ExcludedGoFiles) > 0 { + fmt.Fprint(&b, "\n## Unselected Go files\n\n") + for _, f := range r.Coverage.ExcludedGoFiles { + fmt.Fprintf(&b, "- %s\n", markdownCode(f)) + } + } + return b.String() +} diff --git a/render_security_test.go b/render_security_test.go new file mode 100644 index 0000000..81f3a3d --- /dev/null +++ b/render_security_test.go @@ -0,0 +1,218 @@ +package contracttrace + +import ( + "bytes" + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestMarkdownKeepsReportValuesInsideTheirContexts(t *testing.T) { + plain := "label\n# forged heading" + path := "src/odd`path\n| forged row.md" + report := Report{ + Invariant: plain, + Focus: []string{"focus|name", "two`tick"}, + Coverage: Coverage{ + Build: map[string]string{"GOOS": "linux\n# build", "GOARCH": "amd64"}, + Tags: "tag|value", + SourceSHA256: "hash`with`ticks", + LoadedSourceSHA256: "loaded`hash", + LoadedSources: []LoadedSource{{Path: path}}, + ExcludedGoFiles: []string{path}, + }, + Nodes: []Node{{ + ID: "node|id", + Name: "name **outside** text", + Kind: "kind|value", + Evidence: Evidence{File: path, Snippet: "source\ntext"}, + ReachedBy: &Relationship{ + From: "from`id", To: "to|id", Kind: "edge|kind", Certainty: "possible\n# block", + Evidence: Evidence{File: path}, + }, + Relevance: &Relevance{ + Anchor: "anchor`id", Distance: 1, PathCertainty: "possible\n| row", + Via: &Relationship{From: "via`from", To: "via|to", Kind: "via|kind", Certainty: "possible", Evidence: Evidence{File: path}}, + }, + }}, + Boundaries: []Boundary{ + { + Node: "boundary|node", Kind: "bad | forged\n| injected | row |", Reason: "unused text", + Evidence: Evidence{File: path}, Candidates: []string{"candidate|one"}, Examples: []string{"example`one"}, + }, + {Kind: "plain-reason", Reason: "label\n# forged heading"}, + }, + } + + markdown := Markdown(report) + if strings.Contains(markdown, plain) || strings.Contains(markdown, "label") { + t.Error("report text was emitted as unescaped Markdown or active markup") + } + if strings.Contains(markdown, "\n# forged heading\n") || strings.Contains(markdown, "\n| injected | row |\n") { + t.Error("report text created a Markdown block or table row") + } + if !hasCommonMarkCodeContent(markdown, "src/odd`path\\n| forged row.md") { + t.Error("source path was not preserved in a valid code span") + } +} + +func TestMarkdownControlNormalizationHasIndependentExpectedText(t *testing.T) { + for _, test := range []struct { + name, input, want string + }{ + {"line and tab controls", "first\r\nsecond\t\x00", `first\r\nsecond\t\u{0000}`}, + {"bidi control", "left\u202e right", `left\u{202E} right`}, + {"invalid UTF-8 byte", string([]byte{0xff}), `\xFF`}, + {"Unicode text", "café 雪", "café 雪"}, + {"Unicode line separators", "first\u2028second\u2029", `first\u{2028}second\u{2029}`}, + } { + t.Run(test.name, func(t *testing.T) { + if got := visibleMarkdownControls(test.input); got != test.want { + t.Fatalf("visibleMarkdownControls(%q) = %q, want %q", test.input, got, test.want) + } + }) + } +} + +func TestMarkdownFromSavedExplorationKeepsSourceTextLiteral(t *testing.T) { + root := t.TempDir() + module := "module example.com/eventmarkdown\n\ngo 1.27.0\n" + if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte(module), 0600); err != nil { + t.Fatal(err) + } + filename := "event`source.go" + source := `package app + +type Envelope struct { EventType string } + +func Emit() { _ = Envelope{EventType: "label"} } +` + if err := os.WriteFile(filepath.Join(root, filename), []byte(source), 0600); err != nil { + t.Fatal(err) + } + options := Options{ + Root: root, Seeds: []string{"Emit"}, Invariant: "label", Depth: 2, MaxNodes: 50, + Config: Config{EventFields: []string{"EventType"}}, + } + fresh, analysis, err := TraceWithAnalysis(context.Background(), options) + if err != nil { + t.Fatal(err) + } + line := 5 + owner := "example.com/eventmarkdown::Emit" + resource := "event:%3Cem%3Elabel%3C%2Fem%3E" + if !hasRelationship(fresh, owner, resource, "event_construct", filename, line) { + t.Fatalf("Trace did not retain the source-backed event candidate: %+v", fresh.Relationships) + } + var encoded bytes.Buffer + if err := WriteAnalysis(&encoded, analysis, true); err != nil { + t.Fatal(err) + } + loaded, err := ReadAnalysis(bytes.NewReader(encoded.Bytes())) + if err != nil { + t.Fatal(err) + } + resumed, err := Explore(context.Background(), loaded, ExploreOptions{Seeds: options.Seeds, Depth: options.Depth, MaxNodes: options.MaxNodes}) + if err != nil { + t.Fatal(err) + } + for _, report := range []Report{fresh, resumed} { + if !hasRelationship(report, owner, resource, "event_construct", filename, line) { + t.Fatal("saved exploration lost the source-backed event candidate") + } + markdown := Markdown(report) + if strings.Contains(markdown, "label") || !strings.Contains(markdown, `\label\<\/em\>`) { + t.Fatalf("Markdown did not present the event value as literal text: %s", markdown) + } + if !hasCommonMarkCodeContent(markdown, filename+":5") { + t.Fatalf("Markdown did not preserve the backtick source filename in a code span: %s", markdown) + } + } +} + +func TestMarkdownCodeSpanBoundaryCases(t *testing.T) { + for _, value := range []string{"", "plain", "`", "```", "leading ", " trailing", " both ", " ", "\r\n", "`edge`", "spoof\u202e\ntext", string([]byte{0xff})} { + t.Run(value, func(t *testing.T) { + rendered := markdownCode(value) + if strings.ContainsAny(rendered, "\r\n") { + t.Fatalf("rendered code span contains a structural line break: %q", rendered) + } + if !hasCommonMarkCodeContent(rendered, expectedCodeText(value)) { + t.Fatalf("CommonMark code span changed %q: %q", value, rendered) + } + }) + } +} + +func TestMarkdownPlainTextEscapesCommonMarkPunctuation(t *testing.T) { + input := "x\r\n| row" + want := `\x\<\/b\>\\r\\n\| row` + if got := markdownText(input); got != want { + t.Fatalf("markdownText(%q) = %q, want %q", input, got, want) + } +} + +// This fuzz target checks code-span delimiters and whitespace preservation. +// Exact control-character normalization has independent examples above. +func FuzzMarkdownCodeSpanDelimiterRules(f *testing.F) { + for _, seed := range []string{"", "plain", "`", "`` and `", " leading", "trailing ", " ", "\r\n|#", "&entity;", "spoof\u202e\ntext", string([]byte{0xff})} { + f.Add(seed) + } + f.Fuzz(func(t *testing.T, value string) { + rendered := markdownCode(value) + if strings.ContainsAny(rendered, "\r\n") { + t.Fatalf("rendered code span contains a structural line break: %q", rendered) + } + if !hasCommonMarkCodeContent(rendered, expectedCodeText(value)) { + t.Fatalf("CommonMark code span changed %q: %q", value, rendered) + } + }) +} + +func expectedCodeText(value string) string { + if value == "" { + return `""` + } + return visibleMarkdownControls(value) +} + +// This small reader is an independent check of CommonMark code-span delimiters +// and the spec's single-space trimming rule; it does not call the renderer. +func hasCommonMarkCodeContent(document, want string) bool { + for i := 0; i < len(document); { + if document[i] != '`' { + i++ + continue + } + start := i + for i < len(document) && document[i] == '`' { + i++ + } + run := i - start + for search := i; search < len(document); { + if document[search] != '`' { + search++ + continue + } + end := search + for end < len(document) && document[end] == '`' { + end++ + } + if end-search == run { + content := document[i:search] + if len(content) >= 2 && content[0] == ' ' && content[len(content)-1] == ' ' && strings.Trim(content, " ") != "" { + content = content[1 : len(content)-1] + } + if content == want { + return true + } + search = end + break + } + search = end + } + } + return false +} diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..2370540 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1 @@ +pre-commit==4.6.2 diff --git a/resolution_inputs.go b/resolution_inputs.go new file mode 100644 index 0000000..10c30bd --- /dev/null +++ b/resolution_inputs.go @@ -0,0 +1,175 @@ +package contracttrace + +import ( + "context" + "fmt" + "os" + "path/filepath" + "runtime" + "sort" + "strings" + + "golang.org/x/mod/modfile" + "golang.org/x/tools/go/packages" +) + +var baseBuildEnvironmentKeys = []string{"GOOS", "GOARCH", "GOVERSION", "CGO_ENABLED", "GOFLAGS", "GOWORK"} + +var featureBuildEnvironmentKeys = []string{ + "GO386", "GOAMD64", "GOARM", "GOARM64", "GOMIPS", "GOMIPS64", + "GOPPC64", "GORISCV64", "GOWASM", "GOEXPERIMENT", "GOFIPS140", +} + +func buildEnvironmentKeys() []string { + keys := append([]string(nil), baseBuildEnvironmentKeys...) + return append(keys, featureBuildEnvironmentKeys...) +} + +// packageLoaderEnvironment keeps the workspace directory spelling in the +// same Windows path namespace as the canonical analysis root. The workfile +// itself is never resolved through a file symlink, so its relative module +// paths retain their original base. +func packageLoaderEnvironment(build map[string]string) []string { + if runtime.GOOS != "windows" { + return nil + } + work := build["GOWORK"] + if work == "" || work == "off" { + return nil + } + absolute, err := filepath.Abs(work) + if err != nil { + return nil + } + if _, err := os.Lstat(absolute); err != nil || hasPathReparsePoint(filepath.Dir(absolute)) { + return nil + } + parent, err := filepath.EvalSymlinks(filepath.Dir(absolute)) + if err != nil { + return nil + } + candidate := filepath.Join(parent, filepath.Base(absolute)) + originalInfo, originalErr := os.Stat(absolute) + candidateInfo, candidateErr := os.Stat(candidate) + if originalErr != nil || candidateErr != nil || !os.SameFile(originalInfo, candidateInfo) { + return nil + } + + env := os.Environ() + for i, entry := range env { + name, _, ok := strings.Cut(entry, "=") + if ok && strings.EqualFold(name, "GOWORK") { + env[i] = "GOWORK=" + candidate + return env + } + } + return append(env, "GOWORK="+candidate) +} + +func captureResolutionInputs(build map[string]string) ([]LoadedSource, error) { + work := build["GOWORK"] + if work == "" || work == "off" { + return nil, nil + } + abs, err := filepath.Abs(work) + if err != nil { + return nil, err + } + contents, err := os.ReadFile(abs) + if err != nil { + return nil, fmt.Errorf("workspace input unavailable: %w", err) + } + parsed, err := modfile.ParseWork(abs, contents, nil) + if err != nil { + return nil, fmt.Errorf("workspace input invalid: %w", err) + } + inputs := map[string]string{abs: hashText(string(contents))} + for _, use := range parsed.Use { + directory := use.Path + if !filepath.IsAbs(directory) { + directory = filepath.Join(filepath.Dir(abs), directory) + } + manifest := filepath.Join(directory, "go.mod") + data, err := os.ReadFile(manifest) + if err != nil { + return nil, fmt.Errorf("workspace module input unavailable: %w", err) + } + inputs[filepath.Clean(manifest)] = hashText(string(data)) + } + var sources []LoadedSource + for path, digest := range inputs { + sources = append(sources, LoadedSource{Path: filepath.ToSlash(path), SHA256: digest}) + } + sort.Slice(sources, func(i, j int) bool { return sources[i].Path < sources[j].Path }) + return sources, nil +} + +// Read effective manifests from the same loader metadata used by the syntax +// pass. Replacements may live outside both the root and the workspace. +func captureModuleInputs(pkgs []*packages.Package, workspace []LoadedSource) ([]LoadedSource, error) { + paths := map[string]bool{} + var diagnostics []string + packages.Visit(pkgs, nil, func(p *packages.Package) { + for _, err := range p.Errors { + diagnostics = append(diagnostics, err.Error()) + } + module := p.Module + for module != nil && module.Replace != nil { + module = module.Replace + } + if module != nil && module.GoMod != "" { + paths[module.GoMod] = true + } + }) + if len(diagnostics) != 0 { + sort.Strings(diagnostics) + return nil, fmt.Errorf("module discovery incomplete: %s", strings.Join(diagnostics, "\n")) + } + inputs := map[string]string{} + for _, input := range workspace { + inputs[input.Path] = input.SHA256 + } + for path := range paths { + abs, err := filepath.Abs(path) + if err != nil { + return nil, err + } + contents, err := os.ReadFile(abs) + if err != nil { + return nil, fmt.Errorf("module input unavailable: %w", err) + } + inputs[filepath.ToSlash(abs)] = hashText(string(contents)) + } + var sources []LoadedSource + for path, digest := range inputs { + sources = append(sources, LoadedSource{Path: path, SHA256: digest}) + } + sort.Slice(sources, func(i, j int) bool { return sources[i].Path < sources[j].Path }) + return sources, nil +} + +func verifyBuildEnvironment(ctx context.Context, root string, recorded map[string]string) error { + current, err := buildEnvironment(ctx, root) + if err != nil { + return err + } + return compareBuildEnvironment(current, recorded) +} + +func compareBuildEnvironment(current, recorded map[string]string) error { + for _, key := range baseBuildEnvironmentKeys { + if previous, captured := recorded[key]; captured && current[key] != previous { + return fmt.Errorf("build/resolution environment changed: %s; rerun analysis", key) + } + } + for _, key := range featureBuildEnvironmentKeys { + previous, captured := recorded[key] + if !captured { + return fmt.Errorf("saved analysis is missing required build feature setting %s; rerun analysis", key) + } + if current[key] != previous { + return fmt.Errorf("build/resolution environment changed: %s; rerun analysis", key) + } + } + return nil +} diff --git a/resolution_inputs_test.go b/resolution_inputs_test.go new file mode 100644 index 0000000..dc871fd --- /dev/null +++ b/resolution_inputs_test.go @@ -0,0 +1,365 @@ +package contracttrace + +import ( + "bytes" + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestGOAMD64ChangeRejectsSavedAnalysis(t *testing.T) { + if runtime.GOARCH != "amd64" { + t.Skip("amd64 feature build tags are only exercised on amd64 hosts") + } + root := t.TempDir() + files := map[string]string{ + "go.mod": "module example.com/featureidentity\n\ngo 1.27.0\n", + "common.go": "package app\nfunc Seed() {}\n", + "v1.go": "//go:build amd64.v1 && !amd64.v2\n\npackage app\nfunc SelectedV1() {}\n", + "v2.go": "//go:build amd64.v2\n\npackage app\nfunc SelectedV2() {}\n", + } + for name, contents := range files { + if err := os.WriteFile(filepath.Join(root, name), []byte(contents), 0600); err != nil { + t.Fatal(err) + } + } + t.Setenv("GOWORK", "off") + t.Setenv("GOFLAGS", "") + t.Setenv("GOEXPERIMENT", "") + t.Setenv("GOAMD64", "v1") + options := Options{Root: root, Seeds: []string{"Seed"}, Depth: 1, MaxNodes: 20} + v1Report, saved, err := TraceWithAnalysis(context.Background(), options) + if err != nil { + t.Fatal(err) + } + if !coverageHasFile(v1Report.Coverage.Files, "v1.go") || coverageHasFile(v1Report.Coverage.Files, "v2.go") { + t.Fatalf("GOAMD64=v1 selected unexpected source files: %v", v1Report.Coverage.Files) + } + var persisted bytes.Buffer + if err := WriteAnalysis(&persisted, saved, true); err != nil { + t.Fatal(err) + } + saved, err = ReadAnalysis(&persisted) + if err != nil { + t.Fatal(err) + } + if _, err := Explore(context.Background(), saved, ExploreOptions{Seeds: []string{"Seed"}, Depth: 1, MaxNodes: 20}); err != nil { + t.Fatalf("same-build saved analysis did not explore: %v", err) + } + legacy := saved + legacy.Coverage.Build = cloneBuildEnvironment(saved.Coverage.Build) + for _, key := range featureBuildEnvironmentKeys { + delete(legacy.Coverage.Build, key) + } + rebindAnalysisSourceIdentity(t, &legacy) + var oldSnapshot bytes.Buffer + if err := WriteAnalysis(&oldSnapshot, legacy, true); err != nil { + t.Fatal(err) + } + legacy, err = ReadAnalysis(&oldSnapshot) + if err != nil { + t.Fatalf("read legacy-shaped analysis: %v", err) + } + if _, err := Explore(context.Background(), legacy, ExploreOptions{Seeds: []string{"Seed"}, Depth: 1, MaxNodes: 20}); err == nil || !strings.Contains(err.Error(), "missing required build feature setting") { + t.Fatalf("legacy analysis missing feature settings was not rejected for regeneration: %v", err) + } + + t.Setenv("GOAMD64", "v2") + v2Report, _, err := TraceWithAnalysis(context.Background(), options) + if err != nil { + t.Fatal(err) + } + if !coverageHasFile(v2Report.Coverage.Files, "v2.go") || coverageHasFile(v2Report.Coverage.Files, "v1.go") { + t.Fatalf("GOAMD64=v2 selected unexpected source files: %v", v2Report.Coverage.Files) + } + if _, err := Explore(context.Background(), saved, ExploreOptions{Seeds: []string{"Seed"}, Depth: 1, MaxNodes: 20}); err == nil || !strings.Contains(err.Error(), "GOAMD64") { + t.Fatalf("saved v1 analysis was reused under GOAMD64=v2: %v", err) + } +} + +func TestGOEXPERIMENTChangeRejectsSavedAnalysis(t *testing.T) { + t.Setenv("GOWORK", "off") + t.Setenv("GOFLAGS", "") + t.Setenv("GOEXPERIMENT", "") + _, saved, err := TraceWithAnalysis(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"Validate"}, Depth: 1, MaxNodes: 20}) + if err != nil { + t.Fatal(err) + } + t.Setenv("GOEXPERIMENT", "none") + if _, err := Explore(context.Background(), saved, ExploreOptions{Seeds: []string{"Validate"}, Depth: 1, MaxNodes: 20}); err == nil || !strings.Contains(err.Error(), "GOEXPERIMENT") { + t.Fatalf("saved analysis was reused after GOEXPERIMENT changed: %v", err) + } +} + +func TestBuildFeatureEnvironmentFieldsAreCapturedAndCompared(t *testing.T) { + keys := []string{"GO386", "GOAMD64", "GOARM", "GOARM64", "GOMIPS", "GOMIPS64", "GOPPC64", "GORISCV64", "GOWASM", "GOEXPERIMENT", "GOFIPS140"} + current, err := buildEnvironment(context.Background(), "testdata/sample") + if err != nil { + t.Fatal(err) + } + for _, key := range keys { + if _, ok := current[key]; !ok { + t.Errorf("build identity omitted recognized Go feature setting %s", key) + continue + } + changed := cloneBuildEnvironment(current) + changed[key] = current[key] + "-different" + if err := compareBuildEnvironment(current, changed); err == nil || !strings.Contains(err.Error(), key) { + t.Errorf("changed feature setting %s was not identified: %v", key, err) + } + missing := cloneBuildEnvironment(current) + delete(missing, key) + if err := compareBuildEnvironment(current, missing); err == nil || !strings.Contains(err.Error(), key) { + t.Errorf("missing feature setting %s was not rejected: %v", key, err) + } + } +} + +func cloneBuildEnvironment(build map[string]string) map[string]string { + copy := make(map[string]string, len(build)) + for key, value := range build { + copy[key] = value + } + return copy +} + +func rebindAnalysisSourceIdentity(t *testing.T, analysis *Analysis) { + t.Helper() + rootFingerprint, _, err := fingerprint(analysis.Root) + if err != nil { + t.Fatal(err) + } + assets, err := hashFiles(analysis.Root, analysis.Coverage.EmbeddedFiles) + if err != nil { + t.Fatal(err) + } + identity, err := json.Marshal(struct { + Build map[string]string + Config Config + Tests bool + Tags string + }{analysis.Coverage.Build, analysis.Config, analysis.Coverage.Tests, analysis.Coverage.Tags}) + if err != nil { + t.Fatal(err) + } + analysis.Coverage.SourceSHA256 = hashText(rootFingerprint + string(identity) + assets + analysis.Coverage.LoadedSourceSHA256 + analysis.Coverage.ResolutionSHA256) +} + +func coverageHasFile(files []string, name string) bool { + for _, file := range files { + if filepath.Base(filepath.FromSlash(file)) == name { + return true + } + } + return false +} + +func TestWorkspaceDependencySwitchRejectsSavedAnalysis(t *testing.T) { + base := t.TempDir() + for _, dir := range []string{"app", "first", "second"} { + if err := os.Mkdir(filepath.Join(base, dir), 0755); err != nil { + t.Fatal(err) + } + } + files := map[string]string{ + "app/go.mod": "module example.com/app\n\ngo 1.27.0\n\nrequire example.com/dependency v0.0.0\n", + "app/app.go": "package app\nimport \"example.com/dependency\"\nfunc Seed() { _ = dependency.Marker() }\n", + "first/go.mod": "module example.com/dependency\n\ngo 1.27.0\n", + "first/dependency.go": "package dependency\nfunc Marker() int { return 1 }\n", + "second/go.mod": "module example.com/dependency\n\ngo 1.27.0\n", + "second/dependency.go": "package dependency\nfunc Marker() int { return 2 }\n", + "go.work": "go 1.27.0\nuse (\n ./app\n ./first\n)\n", + } + for file, contents := range files { + if err := os.WriteFile(filepath.Join(base, filepath.FromSlash(file)), []byte(contents), 0644); err != nil { + t.Fatal(err) + } + } + work := filepath.Join(base, "go.work") + t.Setenv("GOWORK", work) + t.Setenv("GOFLAGS", "") + options := Options{Root: filepath.Join(base, "app"), Seeds: []string{"Seed"}, Depth: 1, MaxNodes: 20} + _, saved, err := TraceWithAnalysis(context.Background(), options) + if err != nil { + t.Fatal(err) + } + if got := saved.Coverage.Build["GOWORK"]; got != work { + t.Fatalf("saved build identity rewrote supplied GOWORK: got %q, want %q", got, work) + } + if _, err := Explore(context.Background(), saved, ExploreOptions{Seeds: []string{"Seed"}, Depth: 1, MaxNodes: 20}); err != nil { + t.Fatalf("stable workspace analysis did not explore: %v", err) + } + if err := os.WriteFile(filepath.Join(base, "first", "added.go"), []byte("package dependency\nconst Added = 2\n"), 0644); err != nil { + t.Fatal(err) + } + if _, err := Explore(context.Background(), saved, ExploreOptions{Seeds: []string{"Seed"}, Depth: 1, MaxNodes: 20}); err == nil || !strings.Contains(err.Error(), "selected Go source set changed") { + t.Fatalf("saved workspace analysis accepted newly selected dependency source: %v", err) + } + for _, change := range []struct{ key, value string }{{"GOFLAGS", "-tags=contract_probe"}, {"GOWORK", "off"}} { + t.Run(change.key, func(t *testing.T) { + t.Setenv(change.key, change.value) + _, err := Explore(context.Background(), saved, ExploreOptions{Seeds: []string{"Seed"}, Depth: 1, MaxNodes: 20}) + if err == nil || !strings.Contains(err.Error(), change.key) { + t.Fatalf("changed selection setting accepted: %v", err) + } + }) + } + corrupt := saved + corrupt.Coverage.ResolutionInputs = nil + if err := corrupt.validate(); err == nil { + t.Fatal("workspace inventory removed without rejection") + } + if err := os.WriteFile(work, []byte("go 1.27.0\nuse (\n ./app\n ./second\n)\n"), 0644); err != nil { + t.Fatal(err) + } + _, err = Explore(context.Background(), saved, ExploreOptions{Seeds: []string{"Seed"}, Depth: 1, MaxNodes: 20}) + if err == nil || !strings.Contains(err.Error(), "changed") { + t.Fatalf("workspace dependency switch accepted: %v", err) + } +} + +func TestPackageLoaderSkipsWorkspacePathThroughWindowsJunction(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("Windows junction path behavior") + } + base := t.TempDir() + app := filepath.Join(base, "app") + other := filepath.Join(base, "other") + nested := filepath.Join(other, "nested") + for _, dir := range []string{app, nested} { + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatal(err) + } + } + appDependency := filepath.Join(app, "dependency") + otherDependency := filepath.Join(other, "dependency") + for _, dir := range []string{appDependency, otherDependency} { + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatal(err) + } + } + junction := filepath.Join(app, "alias") + output, err := exec.Command("cmd.exe", "/c", "mklink", "/J", junction, nested).CombinedOutput() + if err != nil { + t.Skipf("directory junction unavailable: %v: %s", err, output) + } + if !hasPathReparsePoint(junction) { + t.Fatal("Windows directory junction was not recognized as a reparse point") + } + work := filepath.Join(junction, "go.work") + if err := os.WriteFile(filepath.Join(nested, "go.work"), []byte("go 1.27.0\nuse ../dependency\n"), 0644); err != nil { + t.Fatal(err) + } + useThroughJunction := filepath.Dir(work) + string(os.PathSeparator) + ".." + string(os.PathSeparator) + "dependency" + useAfterParentResolution := filepath.Join(nested, "..", "dependency") + selected, err := os.Stat(useThroughJunction) + if err != nil { + t.Skipf("OS cannot resolve the workspace use path through the junction: %v", err) + } + redirected, err := os.Stat(useAfterParentResolution) + if err != nil { + t.Fatal(err) + } + if os.SameFile(selected, redirected) { + t.Skip("this Windows version resolves the relative workspace use identically after parent resolution") + } + t.Setenv("GOWORK", work) + loaderEnv := packageLoaderEnvironment(map[string]string{"GOWORK": work}) + if loaderEnv != nil { + t.Fatalf("loader environment rewrote GOWORK through a junction parent: %#v", loaderEnv) + } +} + +func TestWorkspaceModuleReplacementRejectsSavedAnalysis(t *testing.T) { + base := t.TempDir() + files := map[string]string{ + "app/go.mod": "module example.com/app\n\ngo 1.27.0\nrequire example.com/dependency v0.0.0\nreplace example.com/dependency => ../dependency\n", + "app/app.go": "package app\nimport \"example.com/dependency\"\nfunc Seed() { _ = dependency.Marker() }\n", + "dependency/go.mod": "module example.com/dependency\n\ngo 1.27.0\nrequire example.com/leaf v0.0.0\nreplace example.com/leaf => ../first\n", + "dependency/dependency.go": "package dependency\nimport \"example.com/leaf\"\nfunc Marker() int { return leaf.Value() }\n", + "first/go.mod": "module example.com/leaf\n\ngo 1.27.0\n", + "first/leaf.go": "package leaf\nfunc Value() int { return 1 }\n", + "second/go.mod": "module example.com/leaf\n\ngo 1.27.0\n", + "second/leaf.go": "package leaf\nfunc Value() int { return 2 }\n", + "go.work": "go 1.27.0\nuse (\n ./app\n ./dependency\n)\n", + } + for file, contents := range files { + path := filepath.Join(base, filepath.FromSlash(file)) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(contents), 0644); err != nil { + t.Fatal(err) + } + } + t.Setenv("GOWORK", filepath.Join(base, "go.work")) + t.Setenv("GOFLAGS", "") + options := Options{Root: filepath.Join(base, "app"), Seeds: []string{"Seed"}, Depth: 1, MaxNodes: 20} + _, saved, err := TraceWithAnalysis(context.Background(), options) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(base, "dependency/go.mod"), []byte(strings.ReplaceAll(files["dependency/go.mod"], "../first", "../second")), 0644); err != nil { + t.Fatal(err) + } + if _, err := Explore(context.Background(), saved, ExploreOptions{Seeds: []string{"Seed"}, Depth: 1, MaxNodes: 20}); err == nil { + t.Fatal("saved investigation accepted changed sibling module replacement") + } + _, fresh, err := TraceWithAnalysis(context.Background(), options) + if err != nil { + t.Fatal(err) + } + if saved.Coverage.ResolutionSHA256 == fresh.Coverage.ResolutionSHA256 { + t.Fatal("module replacement did not change resolution identity") + } +} + +func TestDependencyLanguageDirectiveRejectsSavedAnalysis(t *testing.T) { + base := t.TempDir() + files := map[string]string{ + "app/go.mod": "module example.com/app\n\ngo 1.27.0\nrequire example.com/dependency v0.0.0\nreplace example.com/dependency => ../dependency\n", + "app/app.go": "package app\nimport \"example.com/dependency\"\nfunc Seed() { _ = dependency.Marker[int](1) }\n", + "dependency/go.mod": "module example.com/dependency\n\ngo 1.27.0\n", + "dependency/dependency.go": "package dependency\nfunc Marker[T any](value T) T { return value }\n", + } + for file, contents := range files { + path := filepath.Join(base, filepath.FromSlash(file)) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(contents), 0644); err != nil { + t.Fatal(err) + } + } + t.Setenv("GOWORK", "off") + t.Setenv("GOFLAGS", "") + options := Options{Root: filepath.Join(base, "app"), Seeds: []string{"Seed"}, Depth: 1, MaxNodes: 20} + _, saved, err := TraceWithAnalysis(context.Background(), options) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(base, "dependency/go.mod"), []byte("module example.com/dependency\n\ngo 1.17\n"), 0644); err != nil { + t.Fatal(err) + } + if _, err := Explore(context.Background(), saved, ExploreOptions{Seeds: []string{"Seed"}, Depth: 1, MaxNodes: 20}); err == nil { + t.Fatal("saved investigation accepted dependency language version change") + } + if _, err := Trace(context.Background(), options); err == nil || !strings.Contains(err.Error(), "go1.18") { + t.Fatalf("lowered language version did not invalidate generic dependency: %v", err) + } + if err := os.WriteFile(filepath.Join(base, "dependency/go.mod"), []byte("module example.com/dependency\n\ngo 1.25.0\n"), 0644); err != nil { + t.Fatal(err) + } + _, fresh, err := TraceWithAnalysis(context.Background(), options) + if err != nil { + t.Fatal(err) + } + if saved.Coverage.LoadedSourceSHA256 != fresh.Coverage.LoadedSourceSHA256 || saved.Coverage.ResolutionSHA256 == fresh.Coverage.ResolutionSHA256 { + t.Fatal("module-only change did not retain Go byte identity and change resolution identity") + } +} diff --git a/root_path_test.go b/root_path_test.go new file mode 100644 index 0000000..b12be3c --- /dev/null +++ b/root_path_test.go @@ -0,0 +1,212 @@ +package contracttrace + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestTraceKeepsSymlinkParentRootSemantics(t *testing.T) { + base := t.TempDir() + app := filepath.Join(base, "app") + other := filepath.Join(base, "other") + if err := os.MkdirAll(app, 0755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(other, "nested"), 0755); err != nil { + t.Fatal(err) + } + writeRootFixture(t, app, "example.com/app", "package app\nfunc Validate() bool { return false }\n") + writeRootFixture(t, other, "example.com/other", "package other\nfunc Validate() bool { return true }\n") + if err := os.Symlink(filepath.Join(other, "nested"), filepath.Join(app, "alias")); err != nil { + t.Skipf("directory symlinks unavailable: %v", err) + } + rawRoot := app + string(os.PathSeparator) + "alias" + string(os.PathSeparator) + ".." + rawInfo, err := os.Stat(rawRoot) + if err != nil { + t.Skipf("platform cannot resolve the symlink/.. root path: %v", err) + } + otherInfo, err := os.Stat(other) + if err != nil { + t.Fatal(err) + } + if !os.SameFile(rawInfo, otherInfo) { + t.Skip("platform resolves symlink/.. differently from the POSIX root alias under test") + } + + wantRoot, err := filepath.Abs(other) + if err != nil { + t.Fatal(err) + } + report, err := Trace(context.Background(), Options{Root: rawRoot, Seeds: []string{"Validate"}, Depth: 2, MaxNodes: 50}) + if err != nil { + t.Fatal(err) + } + if report.Root != wantRoot { + t.Fatalf("Trace selected lexical root %q; OS-resolved root is %q", report.Root, wantRoot) + } + otherFound, appFound := false, false + for _, node := range report.Nodes { + otherFound = otherFound || node.ID == "example.com/other::Validate" + appFound = appFound || node.ID == "example.com/app::Validate" + } + if !otherFound || appFound { + t.Fatalf("Trace loaded the wrong module for the raw root path: nodes=%+v", report.Nodes) + } +} + +func TestCanonicalRootUsesPhysicalBaseForRelativeParent(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows resolves junction/.. roots using its native path rules") + } + base := t.TempDir() + actualCWD := filepath.Join(base, "other", "nested", "child") + logicalAlias := filepath.Join(base, "app", "alias") + if err := os.MkdirAll(actualCWD, 0755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(logicalAlias), 0755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join(base, "other", "nested"), logicalAlias); err != nil { + t.Skipf("directory symlinks unavailable: %v", err) + } + logicalCWD := filepath.Join(logicalAlias, "child") + originalCWD, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + originalPWD, hadPWD := os.LookupEnv("PWD") + t.Cleanup(func() { + if err := os.Chdir(originalCWD); err != nil { + t.Errorf("restore working directory: %v", err) + } + if hadPWD { + _ = os.Setenv("PWD", originalPWD) + } else { + _ = os.Unsetenv("PWD") + } + }) + if err := os.Chdir(logicalCWD); err != nil { + t.Skipf("cannot enter symlinked working directory: %v", err) + } + if err := os.Setenv("PWD", logicalCWD); err != nil { + t.Fatal(err) + } + reportedCWD, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if filepath.Clean(reportedCWD) != filepath.Clean(logicalCWD) { + t.Skipf("runtime does not preserve a logical PWD path: got %q, want %q", reportedCWD, logicalCWD) + } + + want, err := filepath.EvalSymlinks(filepath.Join(actualCWD, "..", "..")) + if err != nil { + t.Fatal(err) + } + got, err := canonicalRoot("../..") + if err != nil { + t.Fatal(err) + } + if got != want { + t.Fatalf("relative root resolved against logical PWD: got %q, want %q", got, want) + } +} + +func TestCanonicalRootAcceptsWindowsPathForms(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("Windows path forms") + } + root := t.TempDir() + t.Chdir(root) + want, err := filepath.EvalSymlinks(root) + if err != nil { + t.Fatal(err) + } + volume := filepath.VolumeName(root) + relativeToVolume := strings.TrimPrefix(root, volume) + for _, input := range []string{".", root, volume + ".", string(os.PathSeparator) + strings.TrimLeft(relativeToVolume, string(os.PathSeparator))} { + got, err := canonicalRoot(input) + if err != nil { + t.Errorf("canonicalRoot(%q): %v", input, err) + continue + } + if got != want { + t.Errorf("canonicalRoot(%q)=%q, want %q", input, got, want) + } + } +} + +func TestTraceUsesWindowsJunctionParentResolution(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("Windows junction path behavior") + } + base := t.TempDir() + app := filepath.Join(base, "app") + other := filepath.Join(base, "other") + nested := filepath.Join(other, "nested") + if err := os.MkdirAll(app, 0755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(nested, 0755); err != nil { + t.Fatal(err) + } + writeRootFixture(t, app, "example.com/app", "package app\nfunc Validate() bool { return false }\n") + writeRootFixture(t, other, "example.com/other", "package other\nfunc Validate() bool { return true }\n") + junction := filepath.Join(app, "alias") + output, err := exec.Command("cmd.exe", "/c", "mklink", "/J", junction, nested).CombinedOutput() + if err != nil { + t.Skipf("directory junction unavailable: %v: %s", err, output) + } + if !hasPathReparsePoint(junction) { + t.Fatal("Windows directory junction was not recognized as a reparse point") + } + rawRoot := app + string(os.PathSeparator) + "alias" + string(os.PathSeparator) + ".." + rawInfo, err := os.Stat(rawRoot) + if err != nil { + t.Skipf("OS cannot resolve the junction/.. root path: %v", err) + } + appInfo, err := os.Stat(app) + if err != nil { + t.Fatal(err) + } + if !os.SameFile(rawInfo, appInfo) { + t.Skip("this Windows version resolves junction/.. differently from the tested parent-directory behavior") + } + report, err := Trace(context.Background(), Options{Root: rawRoot, Seeds: []string{"Validate"}, Depth: 2, MaxNodes: 50}) + if err != nil { + t.Fatal(err) + } + reportRootInfo, err := os.Stat(report.Root) + if err != nil { + t.Fatalf("stat report root %q: %v", report.Root, err) + } + if !os.SameFile(rawInfo, reportRootInfo) { + t.Fatalf("Trace selected root %q; it is not the OS-resolved directory for %q", report.Root, rawRoot) + } + appFound, otherFound := false, false + for _, node := range report.Nodes { + appFound = appFound || node.ID == "example.com/app::Validate" + otherFound = otherFound || node.ID == "example.com/other::Validate" + } + if !appFound || otherFound { + t.Fatalf("Trace did not analyze the OS-selected app module: nodes=%+v", report.Nodes) + } +} + +func writeRootFixture(t *testing.T, root, module, source string) { + t.Helper() + for name, contents := range map[string]string{ + "go.mod": "module " + module + "\n\ngo 1.27.0\n", + "source.go": source, + } { + if err := os.WriteFile(filepath.Join(root, name), []byte(contents), 0644); err != nil { + t.Fatal(err) + } + } +} diff --git a/root_symlink_test.go b/root_symlink_test.go new file mode 100644 index 0000000..d0eada6 --- /dev/null +++ b/root_symlink_test.go @@ -0,0 +1,194 @@ +package contracttrace + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestTraceAndSavedAnalysisCanonicalizeSymlinkRoots(t *testing.T) { + base := t.TempDir() + parent := filepath.Join(base, "modules") + root := filepath.Join(parent, "app") + if err := os.MkdirAll(root, 0700); err != nil { + t.Fatal(err) + } + write := func(path, contents string) { + t.Helper() + if err := os.WriteFile(path, []byte(contents), 0600); err != nil { + t.Fatal(err) + } + } + write(filepath.Join(root, "go.mod"), "module example.com/rootlink\n\ngo 1.27.0\n") + write(filepath.Join(root, "app.go"), "package app\nfunc Seed() {}\n") + write(filepath.Join(root, "ignored.go"), "//go:build rootlink_disabled\n\npackage app\nconst ignored = 1\n") + schema := "CREATE TABLE root_link_records (id INTEGER PRIMARY KEY);\n" + write(filepath.Join(root, "schema.sql"), schema) + + canonical, err := filepath.EvalSymlinks(root) + if err != nil { + t.Fatal(err) + } + rootAlias := filepath.Join(base, "root-alias") + if err := os.Symlink(root, rootAlias); err != nil { + t.Skipf("platform does not permit creating a directory symlink: %v", err) + } + parentAlias := filepath.Join(base, "parent-alias") + if err := os.Symlink(parent, parentAlias); err != nil { + t.Skipf("platform does not permit creating an ancestor directory symlink: %v", err) + } + ancestorAlias := filepath.Join(parentAlias, "app") + + options := func(selectedRoot string) Options { + return Options{Root: selectedRoot, Seeds: []string{"Seed", "schema.sql"}, Depth: 2, MaxNodes: 100} + } + realReport, analysis, err := TraceWithAnalysis(context.Background(), options(root)) + if err != nil { + t.Fatalf("TraceWithAnalysis on real root: %v", err) + } + if analysis.Root != canonical { + t.Errorf("saved analysis root is not canonical: got %q, want %q", analysis.Root, canonical) + } + + var compressed bytes.Buffer + if err := WriteAnalysis(&compressed, analysis, true); err != nil { + t.Fatalf("write compressed analysis: %v", err) + } + saved, err := ReadAnalysis(&compressed) + if err != nil { + t.Fatalf("read compressed analysis: %v", err) + } + legacy := saved + legacy.Root = rootAlias + legacy.Coverage.Storage.Files = nil + legacy.Nodes = filterRootSymlinkLegacySQLNodes(legacy.Nodes) + legacy.Relationships = filterRootSymlinkLegacySQLRelationships(legacy.Relationships) + legacyFingerprint, _, err := fingerprint(rootAlias) + if err != nil { + t.Fatal(err) + } + identity, err := json.Marshal(struct { + Build map[string]string + Config Config + Tests bool + Tags string + }{legacy.Coverage.Build, legacy.Config, legacy.Coverage.Tests, legacy.Coverage.Tags}) + if err != nil { + t.Fatal(err) + } + assets, err := hashFiles(rootAlias, legacy.Coverage.EmbeddedFiles) + if err != nil { + t.Fatal(err) + } + legacy.Coverage.SourceSHA256 = hashText(legacyFingerprint + string(identity) + assets + legacy.Coverage.LoadedSourceSHA256 + legacy.Coverage.ResolutionSHA256) + if err := legacy.validate(); err != nil { + t.Fatalf("constructed legacy snapshot is not graph-valid: %v", err) + } + if _, err := Explore(context.Background(), legacy, ExploreOptions{Seeds: []string{"Seed"}, Depth: 1, MaxNodes: 50}); err == nil { + t.Error("saved analysis with legacy symlink-root fingerprint and missing SQL inventory was accepted") + } else if !strings.Contains(err.Error(), "source changed since saved analysis") { + t.Errorf("legacy snapshot was rejected for the wrong reason: %v", err) + } + + aliasReport, analysis, err := TraceWithAnalysis(context.Background(), options(rootAlias)) + if err != nil { + t.Fatalf("TraceWithAnalysis on symlink root: %v", err) + } + ancestorReport, err := Trace(context.Background(), options(ancestorAlias)) + if err != nil { + t.Fatalf("Trace on root reached through ancestor symlink: %v", err) + } + for label, report := range map[string]Report{"real": realReport, "direct symlink": aliasReport, "ancestor symlink": ancestorReport} { + if report.Root != canonical { + t.Errorf("%s root was not canonicalized: got %q, want %q", label, report.Root, canonical) + } + if len(report.Coverage.Storage.Files) != 1 || report.Coverage.Storage.Files[0] != "schema.sql" { + t.Errorf("%s root omitted its SQL inventory: %v", label, report.Coverage.Storage.Files) + } + if !rootSymlinkHasSchemaNode(report) { + t.Errorf("%s root omitted schema SQL evidence", label) + } + if !contains(report.Coverage.ExcludedGoFiles, "ignored.go") { + t.Errorf("%s root omitted excluded Go source from the descendant source inventory: %v", label, report.Coverage.ExcludedGoFiles) + } + } + if aliasReport.Coverage.SourceSHA256 != realReport.Coverage.SourceSHA256 || ancestorReport.Coverage.SourceSHA256 != realReport.Coverage.SourceSHA256 { + t.Error("real and symlink root selections produced different source identities") + } + if analysis.Root != canonical { + t.Errorf("saved analysis root is not canonical: got %q, want %q", analysis.Root, canonical) + } + compressed.Reset() + if err := WriteAnalysis(&compressed, analysis, true); err != nil { + t.Fatalf("write compressed symlink-root analysis: %v", err) + } + saved, err = ReadAnalysis(&compressed) + if err != nil { + t.Fatalf("read compressed symlink-root analysis: %v", err) + } + + decoy := filepath.Join(base, "decoy") + if err := os.MkdirAll(decoy, 0700); err != nil { + t.Fatal(err) + } + write(filepath.Join(decoy, "go.mod"), "module example.com/decoy\n\ngo 1.27.0\n") + write(filepath.Join(decoy, "app.go"), "package app\nfunc Seed() {}\n") + if err := os.Remove(rootAlias); err != nil { + t.Fatal(err) + } + if err := os.Symlink(decoy, rootAlias); err != nil { + t.Skipf("platform cannot retarget directory symlinks: %v", err) + } + explored, err := Explore(context.Background(), saved, ExploreOptions{Seeds: []string{"Seed", "schema.sql"}, Depth: 2, MaxNodes: 100}) + if err != nil { + t.Fatalf("saved canonical analysis did not survive retargeting the original alias: %v", err) + } + if explored.Root != canonical || !rootSymlinkHasSchemaNode(explored) { + t.Errorf("Explore followed the changed input alias instead of its recorded canonical root: root=%q", explored.Root) + } + + write(filepath.Join(root, "schema.sql"), schema+"-- changed\n") + if _, err := Explore(context.Background(), saved, ExploreOptions{Seeds: []string{"Seed"}, Depth: 1, MaxNodes: 50}); err == nil { + t.Error("compressed saved analysis accepted a changed SQL file") + } + write(filepath.Join(root, "schema.sql"), schema) + write(filepath.Join(root, "ignored.go"), "//go:build rootlink_disabled\n\npackage app\nconst ignored = 2\n") + if _, err := Explore(context.Background(), saved, ExploreOptions{Seeds: []string{"Seed"}, Depth: 1, MaxNodes: 50}); err == nil { + t.Error("compressed saved analysis accepted a changed excluded Go file") + } +} + +func rootSymlinkHasSchemaNode(report Report) bool { + for _, node := range report.Nodes { + if node.Kind == "sql_file" && node.Evidence.File == "schema.sql" && node.Evidence.Snippet != "" { + return true + } + } + return false +} + +func filterRootSymlinkLegacySQLNodes(nodes []Node) []Node { + filtered := make([]Node, 0, len(nodes)) + for _, node := range nodes { + if node.Kind == "sql_file" || node.ID == "table:root_link_records" { + continue + } + filtered = append(filtered, node) + } + return filtered +} + +func filterRootSymlinkLegacySQLRelationships(edges []Relationship) []Relationship { + filtered := make([]Relationship, 0, len(edges)) + for _, edge := range edges { + if edge.Evidence.File == "schema.sql" || edge.To == "table:root_link_records" { + continue + } + filtered = append(filtered, edge) + } + return filtered +} diff --git a/scalar_boundaries_test.go b/scalar_boundaries_test.go new file mode 100644 index 0000000..ad76cb2 --- /dev/null +++ b/scalar_boundaries_test.go @@ -0,0 +1,163 @@ +package contracttrace + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestScalarOutsideResultsAndAssertionsKeepOrigins(t *testing.T) { + root := t.TempDir() + for name, contents := range map[string]string{ + "go.mod": "module example.com/scalaroutside\n\ngo 1.27.0\n", + "outside.go": `package scalaroutside +import "strconv" +type Count int +func Factory(factory func() Count) bool { return factory() > 0 } +func Pair(factory func() (Count, bool)) bool { count, ok := factory(); return count > 0 && ok } +func KnownFactory() Count { return 3 } +func FactoryCaller() bool { return Factory(KnownFactory) } +func closedFactory(factory func() Count) bool { return factory() > 0 } +func ClosedFactoryCaller() bool { return closedFactory(KnownFactory) } +func Assertion(value any) bool { return value.(Count) > 0 } +func AssertPair(value any) bool { count, ok := value.(Count); return ok && count > 0 } +func closedAssertion(value any) bool { return value.(Count) > 0 } +func ClosedAssertionCaller() bool { return closedAssertion(Count(3)) } +func Dependency(text string) bool { count, _ := strconv.Atoi(text); return count > 0 } +`, + } { + if err := os.WriteFile(filepath.Join(root, name), []byte(contents), 0644); err != nil { + t.Fatal(err) + } + } + seeds := []string{"Factory", "Pair", "FactoryCaller", "closedFactory", "ClosedFactoryCaller", "Assertion", "AssertPair", "closedAssertion", "ClosedAssertionCaller", "Dependency"} + fresh, saved, err := TraceWithAnalysis(context.Background(), Options{Root: root, Seeds: seeds, Depth: 5, MaxNodes: 400}) + if err != nil { + t.Fatal(err) + } + resumed, err := Explore(context.Background(), saved, ExploreOptions{Seeds: seeds, Depth: 5, MaxNodes: 400}) + if err != nil { + t.Fatal(err) + } + for _, report := range []Report{fresh, resumed} { + for _, owner := range seeds { + found := false + for _, edge := range report.Relationships { + found = found || strings.HasSuffix(edge.From, "::"+owner) && edge.Kind == "scalar_return" && strings.HasPrefix(edge.To, "scalar-input:") + } + want := owner != "closedFactory" && owner != "ClosedFactoryCaller" && owner != "closedAssertion" && owner != "ClosedAssertionCaller" + if found != want { + t.Errorf("%s outside scalar result=%v, want %v", owner, found, want) + } + } + } +} + +func TestScalarOverflowDisclosesLostOriginsAtConsumer(t *testing.T) { + for _, count := range []int{64, 65} { + t.Run(fmt.Sprint(count), func(t *testing.T) { + root := t.TempDir() + parameters, terms, args := []string{}, []string{}, []string{} + for i := 0; i < count; i++ { + name := fmt.Sprintf("p%d", i) + parameters = append(parameters, name+" int") + terms = append(terms, name) + args = append(args, "1") + } + source := "package scalarcap\nfunc total(" + strings.Join(parameters, ",") + ") int { return " + strings.Join(terms, "+") + " }\nfunc Entry() int { return total(" + strings.Join(args, ",") + ") }\n" + if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example.com/scalarcap\n\ngo 1.27.0\n"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "cap.go"), []byte(source), 0644); err != nil { + t.Fatal(err) + } + fresh, saved, err := TraceWithAnalysis(context.Background(), Options{Root: root, Seeds: []string{"Entry", "total"}, Depth: 4, MaxNodes: 300}) + if err != nil { + t.Fatal(err) + } + resumed, err := Explore(context.Background(), saved, ExploreOptions{Seeds: []string{"Entry", "total"}, Depth: 4, MaxNodes: 300}) + if err != nil { + t.Fatal(err) + } + for _, report := range []Report{fresh, resumed} { + for _, owner := range []string{"total", "Entry"} { + unknown := false + for _, boundary := range report.Boundaries { + unknown = unknown || strings.HasSuffix(boundary.Node, "::"+owner) && boundary.Kind == "unresolved_scalar_origins" + } + if unknown != (count > 64) { + t.Errorf("%s count=%d truncation boundary=%v", owner, count, unknown) + } + } + origins := 0 + for _, node := range report.Nodes { + if node.Kind == "scalar_parameter" { + origins++ + } + } + if origins != count { + t.Errorf("source input inventory lost origins: got %d want %d", origins, count) + } + } + }) + } +} + +func TestScalarBuiltinResultsAndNumericArguments(t *testing.T) { + root := t.TempDir() + for name, contents := range map[string]string{ + "go.mod": "module example.com/scalarbuiltins\n\ngo 1.27.0\n", + "builtin.go": `package scalarbuiltins +func Length(values []int) int { return len(values) } +func Capacity(ch chan int) int { return cap(ch) } +func Copy(dst, src []int) int { return copy(dst,src) } +func Minimum(n int) int { return min(n,10) } +func Maximum(n float64) float64 { return max(n,10) } +func Complex(n float64) float64 { return real(complex(n,1)) } +func Imaginary(n float64) float64 { return imag(complex(1,n)) } +`, + } { + if err := os.WriteFile(filepath.Join(root, name), []byte(contents), 0644); err != nil { + t.Fatal(err) + } + } + seeds := []string{"Length", "Capacity", "Copy", "Minimum", "Maximum", "Complex", "Imaginary"} + fresh, saved, err := TraceWithAnalysis(context.Background(), Options{Root: root, Seeds: seeds, Depth: 5, MaxNodes: 300}) + if err != nil { + t.Fatal(err) + } + resumed, err := Explore(context.Background(), saved, ExploreOptions{Seeds: seeds, Depth: 5, MaxNodes: 300}) + if err != nil { + t.Fatal(err) + } + for _, report := range []Report{fresh, resumed} { + for _, owner := range seeds { + found := false + for _, edge := range report.Relationships { + found = found || strings.HasSuffix(edge.From, "::"+owner) && edge.Kind == "scalar_return" && strings.HasPrefix(edge.To, "scalar-builtin-result:") + } + if !found { + t.Errorf("%s lacks builtin scalar result origin", owner) + } + } + for _, owner := range []string{"Minimum", "Maximum", "Complex", "Imaginary"} { + found := false + for _, edge := range report.Relationships { + found = found || strings.HasSuffix(edge.From, "::"+owner) && edge.Kind == "scalar_return" && edge.To == "scalar-parameter:example.com/scalarbuiltins."+owner+":0" + } + if !found { + t.Errorf("%s lost numeric operand provenance", owner) + } + } + model := false + for _, boundary := range report.Boundaries { + model = model || boundary.Kind == "scalar_builtin_model" + } + if !model { + t.Error("missing builtin result limitations") + } + } +} diff --git a/scalar_builtins.go b/scalar_builtins.go new file mode 100644 index 0000000..1bbdc0b --- /dev/null +++ b/scalar_builtins.go @@ -0,0 +1,45 @@ +package contracttrace + +import ( + "strings" + + "golang.org/x/tools/go/ssa" +) + +// Each numeric builtin result is an operation source, independent of numeric +// truth. Reuse call-slot identity, including synthetic block/instruction sites. +func (a *flowAnalysis) seedScalarBuiltinResults(funcs []*ssa.Function, ix *index) { + for _, fn := range funcs { + for _, block := range fn.Blocks { + for _, instruction := range block.Instrs { + call, ok := instruction.(*ssa.Call) + if !ok || !isScalarType(call.Type()) { + continue + } + builtin, ok := call.Common().Value.(*ssa.Builtin) + if !ok { + continue + } + id := "scalar-builtin-result:" + strings.TrimPrefix(errorResultID(call, 0, ix), "error-result:") + e := ix.callEvidence(call) + ix.funcs[id] = &function{node: Node{ID: id, Name: builtin.Name() + " scalar result", Kind: "scalar_builtin_result", Evidence: e}} + ix.edges = append(ix.edges, Relationship{From: ix.owner(fn), To: id, Kind: "scalar_builtin_result", Certainty: "fact", Evidence: e, Slot: "result:0"}) + a.put(call, flowValue{scalars: map[string]bool{id: true}}) + } + } + } + ix.boundaries = append(ix.boundaries, Boundary{Kind: "scalar_builtin_model", Reason: "Numeric builtin calls retain source-backed result-operation origins. min, max, complex, real and imag also retain numeric argument provenance. len, cap and copy identify investigation operations without inferring container shape, length, capacity, copied count, successful execution or numeric correctness; storage and operation relationships must be inspected separately. Compile-time constants, implicit control and runtime timing are not established by these result origins."}) +} + +func (a *flowAnalysis) scalarBuiltinFlow(call *ssa.Call, result *flowValue) { + builtin, ok := call.Common().Value.(*ssa.Builtin) + if !ok || !isScalarType(call.Type()) { + return + } + switch builtin.Name() { + case "min", "max", "complex", "real", "imag": + for _, argument := range call.Common().Args { + a.mergeScalar(result, a.get(argument)) + } + } +} diff --git a/scalar_control.go b/scalar_control.go new file mode 100644 index 0000000..76e07ce --- /dev/null +++ b/scalar_control.go @@ -0,0 +1,145 @@ +package contracttrace + +import ( + "golang.org/x/tools/go/ssa" +) + +// A synthetic exit joins all modeled terminal blocks. Nonterminating paths are +// excluded from this postdominator model and disclosed, not assumed to finish. +// Bound quadratic storage and graph work independently of scalar candidate caps. +const maxControlBlocks = 256 + +func (a *flowAnalysis) seedScalarControl(funcs []*ssa.Function, ix *index) { + a.scalarControls = map[*ssa.BasicBlock][]*ssa.If{} + for _, fn := range funcs { + n := len(fn.Blocks) + if n == 0 { + continue + } + if n > maxControlBlocks { + ix.boundaries = append(ix.boundaries, Boundary{Node: ix.owner(fn), Kind: "scalar_control_bound", Reason: "Function exceeds the 256-block control analysis bound; implicit scalar influence remains unresolved.", Evidence: ix.funcs[ix.owner(fn)].node.Evidence}) + continue + } + canExit := make([]bool, n) + queue := []*ssa.BasicBlock{} + for _, block := range fn.Blocks { + if len(block.Succs) == 0 { + canExit[block.Index] = true + queue = append(queue, block) + } + } + for head := 0; head < len(queue); head++ { + for _, pred := range queue[head].Preds { + if !canExit[pred.Index] { + canExit[pred.Index] = true + queue = append(queue, pred) + } + } + } + incomplete := false + post := make([][]bool, n+1) + for i := 0; i <= n; i++ { + post[i] = make([]bool, n+1) + if i == n { + post[i][n] = true + continue + } + if !canExit[i] { + incomplete = true + continue + } + for j := 0; j < n; j++ { + post[i][j] = canExit[j] + } + post[i][n] = true + } + for changed := true; changed; { + changed = false + for i := n - 1; i >= 0; i-- { + if !canExit[i] { + continue + } + block := fn.Blocks[i] + for j := 0; j <= n; j++ { + keep := true + if len(block.Succs) == 0 { + keep = post[n][j] + } else { + for _, succ := range block.Succs { + if !canExit[succ.Index] { + incomplete = true + continue + } + keep = keep && post[succ.Index][j] + } + } + keep = keep || i == j + if post[i][j] != keep { + post[i][j] = keep + changed = true + } + } + } + } + if incomplete { + ix.boundaries = append(ix.boundaries, Boundary{Node: ix.owner(fn), Kind: "scalar_control_nonterminating", Reason: "Some control paths have no modeled terminal block. Control candidates use exit-reaching paths only; divergence, panic/recovery and termination-sensitive influence are unresolved.", Evidence: ix.funcs[ix.owner(fn)].node.Evidence}) + } + for _, block := range fn.Blocks { + if !canExit[block.Index] || len(block.Instrs) == 0 { + continue + } + branch, ok := block.Instrs[len(block.Instrs)-1].(*ssa.If) + if !ok { + continue + } + stop := n + for candidate := 0; candidate <= n; candidate++ { + if candidate == block.Index || !post[block.Index][candidate] { + continue + } + nearest := true + for other := 0; other <= n; other++ { + if other != block.Index && other != candidate && post[block.Index][other] && !post[candidate][other] { + nearest = false + break + } + } + if nearest { + stop = candidate + break + } + } + seen := make([]bool, n) + work := append([]*ssa.BasicBlock{}, block.Succs...) + for head := 0; head < len(work); head++ { + target := work[head] + if target.Index == stop || seen[target.Index] || !canExit[target.Index] { + continue + } + seen[target.Index] = true + a.scalarControls[target] = append(a.scalarControls[target], branch) + work = append(work, target.Succs...) + } + } + } + ix.boundaries = append(ix.boundaries, Boundary{Kind: "scalar_control_model", Reason: "Bounded exit-reaching CFG control dependencies retain candidate branch influence on scalar computations, selected returns, phi alternatives and conditional operations. Conditions are not proved feasible; equal branch outcomes may still retain candidates. Termination, panic/recovery, asynchronous ordering and numeric correctness are not established."}) +} + +func (a *flowAnalysis) scalarControlValue(block *ssa.BasicBlock) flowValue { + if len(a.scalarControls[block]) == 0 { + return flowValue{} + } + if result, ok := a.scalarControlCache[block]; ok { + return result + } + result := flowValue{} + for _, branch := range a.scalarControls[block] { + // Constants have no scalar origins. Other predicate origins already + // reside in values; copying the other flow domains is unnecessary. + a.mergeScalar(&result, a.values[branch.Cond]) + } + if a.scalarControlCache != nil { + a.scalarControlCache[block] = result + } + return result +} diff --git a/scalar_control_test.go b/scalar_control_test.go new file mode 100644 index 0000000..da35499 --- /dev/null +++ b/scalar_control_test.go @@ -0,0 +1,65 @@ +package contracttrace + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestScalarBranchSelectionReachesReturnsAndCallers(t *testing.T) { + root := t.TempDir() + for name, contents := range map[string]string{ + "go.mod": "module example.com/control\n\ngo 1.27.0\n", + "control.go": `package control +func choose(n int) bool { if n > 0 { return true }; return false } +func Caller(n int) bool { return choose(n) } +func Phi(n int) int { result := 1; if n > 0 { result = 2 }; return result } +func consume(n int) {} +func Conditional(n int) { if n > 0 { consume(1) } } +func Independent(n int) bool { if n > 0 { consume(1) }; return false } +func Loop(n int) int { result := 0; for n > 0 { result++; n-- }; return result } +func Memory(n int) int { value := new(int); if n > 0 { *value = 1 }; return *value } +func Map(n int) int { values := map[int]int{}; if n > 0 { values[0] = 1 }; return values[0] } +func Channel(n int) int { ch := make(chan int,1); if n > 0 { ch <- 1 }; return <-ch } +func Diverge(n int) int { if n > 0 { for {} }; return 1 } +`, + } { + if err := os.WriteFile(filepath.Join(root, name), []byte(contents), 0644); err != nil { + t.Fatal(err) + } + } + seeds := []string{"choose", "Caller", "Phi", "Conditional", "Independent", "Loop", "Memory", "Map", "Channel", "Diverge"} + fresh, saved, err := TraceWithAnalysis(context.Background(), Options{Root: root, Seeds: seeds, Depth: 5, MaxNodes: 300}) + if err != nil { + t.Fatal(err) + } + resumed, err := Explore(context.Background(), saved, ExploreOptions{Seeds: seeds, Depth: 5, MaxNodes: 300}) + if err != nil { + t.Fatal(err) + } + for _, report := range []Report{fresh, resumed} { + divergence := false + for _, boundary := range report.Boundaries { + divergence = divergence || strings.HasSuffix(boundary.Node, "::Diverge") && boundary.Kind == "scalar_control_nonterminating" + } + if !divergence { + t.Error("nonterminating control path was not disclosed") + } + for owner, kind := range map[string]string{"choose": "scalar_control_return", "Caller": "scalar_return", "Phi": "scalar_return", "Conditional": "scalar_control_call", "Loop": "scalar_return", "Memory": "scalar_return", "Map": "scalar_return", "Channel": "scalar_return"} { + found := false + for _, edge := range report.Relationships { + found = found || edge.Kind == kind && strings.HasSuffix(edge.From, "::"+owner) && edge.To == "scalar-parameter:example.com/control."+owner+":0" + } + if !found { + t.Errorf("%s missing %s input influence", owner, kind) + } + } + for _, edge := range report.Relationships { + if strings.HasSuffix(edge.From, "::Independent") && (edge.Kind == "scalar_return" || edge.Kind == "scalar_control_return") && edge.To == "scalar-parameter:example.com/control.Independent:0" { + t.Error("unrelated branch contaminated unconditional return") + } + } + } +} diff --git a/scalar_flow.go b/scalar_flow.go new file mode 100644 index 0000000..68297b6 --- /dev/null +++ b/scalar_flow.go @@ -0,0 +1,95 @@ +package contracttrace + +import ( + "fmt" + "go/types" + + "golang.org/x/tools/go/ssa" +) + +func isScalarType(t types.Type) bool { + if t == nil { + return false + } + basic, ok := t.Underlying().(*types.Basic) + return ok && basic.Info()&(types.IsNumeric|types.IsBoolean) != 0 +} + +// Only transfer this domain: a comparison must not inherit callable targets, +// string literals, error identities or pointer aliases from its operands. +func (a *flowAnalysis) mergeScalar(dst *flowValue, src flowValue) { + a.merge(dst, flowValue{scalars: src.scalars, scalarUnknown: src.scalarUnknown}) +} + +func (a *flowAnalysis) seedScalarParameters(funcs []*ssa.Function, ix *index) { + for _, fn := range funcs { + for slot, parameter := range fn.Params { + if !isScalarType(parameter.Type()) { + continue + } + id := fmt.Sprintf("scalar-parameter:%s:%d", fn.String(), slot) + e := ix.evidence(parameter.Pos()) + if e.Line < 1 { + e = ix.funcs[ix.owner(fn)].node.Evidence + e.Origin = "synthetic_declaration" + } + ix.funcs[id] = &function{node: Node{ID: id, Name: "scalar parameter " + parameter.Name() + " of " + fn.String(), Kind: "scalar_parameter", Evidence: e}} + ix.edges = append(ix.edges, Relationship{From: ix.owner(fn), To: id, Kind: "scalar_parameter", Certainty: "fact", Evidence: e, Slot: fmt.Sprintf("parameter:%d", slot)}) + a.put(parameter, flowValue{scalars: map[string]bool{id: true}}) + } + } + ix.boundaries = append(ix.boundaries, Boundary{Kind: "scalar_flow_model", Reason: "Numeric and boolean formal parameter origins propagate through arithmetic, comparisons, local calls, returns and modeled memory/channels as bounded context-insensitive candidates. Formal parameters do not prove outside invocation. Bounded branch influence is disclosed separately by the scalar control model. Numeric values, overflow, decision correctness, exhaustive implicit dependencies, invocation correlation, opaque operation results and runtime ownership or ordering are not proved."}) +} + +func (a *flowAnalysis) outsideScalar(address, root string, e Evidence, ix *index) flowValue { + id := "scalar-input:" + address + if ix.funcs[id] == nil { + ix.funcs[id] = &function{node: Node{ID: id, Name: "outside scalar in " + address, Kind: "scalar_input", Evidence: e}} + ix.edges = append(ix.edges, Relationship{From: root, To: id, Kind: "external_input", Certainty: "possible", Evidence: e}) + ix.boundaries = append(ix.boundaries, Boundary{Node: id, Kind: "outside_scalar_input", Reason: "Outside numeric or boolean storage may coexist with local values. Shared storage summaries do not prove runtime identity, mutation timing or numeric bounds.", Evidence: e}) + } + return flowValue{scalars: map[string]bool{id: true}} +} + +func (a *flowAnalysis) scalarUse(ins ssa.Instruction, ix *index) { + owner := ix.owner(ins.Parent()) + e := ix.evidence(ins.Pos()) + if e.Line < 1 { + e = ix.funcs[owner].node.Evidence + e.Origin = "synthetic_declaration" + } + linkFlow := func(v flowValue, kind, slot string) { + for _, origin := range sortedKeys(v.scalars) { + ix.edges = append(ix.edges, Relationship{From: owner, To: origin, Kind: kind, Certainty: "possible", Evidence: e, Slot: slot}) + } + if v.scalarUnknown { + ix.boundaries = append(ix.boundaries, Boundary{Node: owner, Kind: "unresolved_scalar_origins", Reason: "Additional scalar origins were omitted by bounded analysis at " + kind + " " + slot, Evidence: e}) + } + } + link := func(value ssa.Value, kind, slot string) { linkFlow(a.get(value), kind, slot) } + control := a.scalarControlValue(ins.Block()) + switch v := ins.(type) { + case *ssa.Return: + linkFlow(control, "scalar_control_return", "execution") + for slot, result := range v.Results { + link(result, "scalar_return", fmt.Sprintf("result:%d", slot)) + } + case *ssa.If: + link(v.Cond, "scalar_condition", "condition") + case *ssa.BinOp: + link(v.X, "scalar_operand", "left") + link(v.Y, "scalar_operand", "right") + case *ssa.Store: + linkFlow(control, "scalar_control_store", "execution") + link(v.Val, "scalar_store", "value") + case *ssa.Send: + linkFlow(control, "scalar_control_send", "execution") + link(v.X, "scalar_send", "value") + } + if call, ok := ins.(ssa.CallInstruction); ok { + linkFlow(control, "scalar_control_call", "execution") + for slot, argument := range flowArguments(call.Common()) { + link(argument, "scalar_argument", flowArgumentSlot(call.Common(), slot)) + } + } +} diff --git a/scalar_flow_test.go b/scalar_flow_test.go new file mode 100644 index 0000000..84bfc43 --- /dev/null +++ b/scalar_flow_test.go @@ -0,0 +1,84 @@ +package contracttrace + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestScalarOriginsCrossArithmeticStorageCallsAndDecisions(t *testing.T) { + root := t.TempDir() + files := map[string]string{ + "go.mod": "module example.com/scalars\n\ngo 1.27.0\n", + "flow.go": `package scalars +type Account struct { Count int } +func Adjust(n int) int { return n + 1 } +func Limit(n int) bool { return Adjust(n) <= 10 } +func Storage(n int) bool { r := &Account{Count: Adjust(n)}; return r.Count < 0 } +func Channel(n int) bool { ch := make(chan int, 1); ch <- Adjust(n); return <-ch > 0 } +func Negated(n int) bool { return !Limit(-n) } +func cleanup(n int) {} +func Conditional(n int) { if Limit(n) { cleanup(n) } } +func Defer(n int) { defer cleanup(n); go cleanup(n) } +func Independent(n int) bool { _ = Adjust(n); return false } +func PublicField(r *Account) bool { return r.Count > 0 } +var Shared int +func Global(n int) bool { Shared = Adjust(n); return Shared > 0 } +`, + } + for name, contents := range files { + if err := os.WriteFile(filepath.Join(root, name), []byte(contents), 0644); err != nil { + t.Fatal(err) + } + } + seeds := []string{"Adjust", "Limit", "Storage", "Channel", "Negated", "Conditional", "Defer", "Independent", "PublicField", "Global"} + fresh, saved, err := TraceWithAnalysis(context.Background(), Options{Root: root, Seeds: seeds, Depth: 5, MaxNodes: 300}) + if err != nil { + t.Fatal(err) + } + resumed, err := Explore(context.Background(), saved, ExploreOptions{Seeds: seeds, Depth: 5, MaxNodes: 300}) + if err != nil { + t.Fatal(err) + } + for _, report := range []Report{fresh, resumed} { + for _, owner := range []string{"Adjust", "Limit", "Storage", "Channel", "Negated", "Global", "Independent"} { + origin := "scalar-parameter:example.com/scalars." + owner + ":0" + found := false + for _, edge := range report.Relationships { + found = found || edge.Kind == "scalar_return" && strings.HasSuffix(edge.From, "::"+owner) && edge.To == origin + } + if found != (owner != "Independent") { + t.Errorf("%s retained own input count=%v", owner, found) + } + } + for _, owner := range []string{"Conditional", "Defer"} { + kind := "scalar_condition" + if owner == "Defer" { + kind = "scalar_argument" + } + found := false + for _, edge := range report.Relationships { + found = found || edge.Kind == kind && strings.HasSuffix(edge.From, "::"+owner) && edge.To == "scalar-parameter:example.com/scalars."+owner+":0" + } + if !found { + t.Errorf("%s missing %s input provenance", owner, kind) + } + } + outside := false + for _, edge := range report.Relationships { + outside = outside || edge.Kind == "scalar_return" && strings.HasSuffix(edge.From, "::PublicField") && strings.HasPrefix(edge.To, "scalar-input:") + } + if !outside { + t.Error("public count field lost outside origin") + } + model := false + for _, boundary := range report.Boundaries { + model = model || boundary.Kind == "scalar_flow_model" + } + if !model { + t.Error("missing scalar model limitations") + } + } +} diff --git a/scope.go b/scope.go new file mode 100644 index 0000000..1008ea9 --- /dev/null +++ b/scope.go @@ -0,0 +1,300 @@ +package contracttrace + +import ( + "fmt" + "sort" + "strings" +) + +func (ix *index) expand(o Options, c Coverage) (Report, error) { + r := Report{Nodes: []Node{}, Relationships: []Relationship{}, Boundaries: []Boundary{}, Coverage: c, Seeds: []string{}} + distances := map[string]int{} + reached := map[string]Relationship{} + queue := []string{} + locationSymbols, err := ix.locationSeeds(o.Locations) + if err != nil { + return r, err + } + o.Seeds = append(append([]string(nil), o.Seeds...), locationSymbols...) + for _, seed := range o.Seeds { + matches := []string{} + for id, f := range ix.funcs { + if id == seed || f.node.Name == seed { + matches = append(matches, id) + } + } + sort.Strings(matches) + if len(matches) == 0 { + return r, fmt.Errorf("seed %q not found", seed) + } + if len(matches) > 1 { + return r, fmt.Errorf("seed %q is ambiguous: %s", seed, strings.Join(matches, ", ")) + } + id := matches[0] + r.Seeds = append(r.Seeds, id) + if _, ok := distances[id]; !ok { + distances[id] = 0 + queue = append(queue, id) + } + } + if len(queue) > o.MaxNodes { + return r, fmt.Errorf("max-nodes is smaller than the seed count") + } + sort.Strings(queue) + adj := map[string][]Relationship{} + edges := canonicalEdges(ix.edges) + for _, e := range edges { + adj[e.From] = append(adj[e.From], e) + adj[e.To] = append(adj[e.To], e) + } + for id := range adj { + sortEdgesByPriorityAndKey(adj[id]) + } + seeds := append([]string(nil), queue...) + seeded := map[string]bool{} + for _, id := range seeds { + seeded[id] = true + } + // Configured event discriminators have value-specific resources. Crossing + // their declaration hub by default merges otherwise distinct event families. + // Keep all field edges in the inventory and expose the excluded users; an + // explicit field seed requests the broader declaration-based investigation. + blockedFieldHub := func(id string) bool { + return !seeded[id] && ix.funcs[id] != nil && ix.funcs[id].node.Kind == "event_discriminator_field" + } + // Apply priority to whole paths, not only each node's adjacency list. + // Revisit selected nodes at each tier so a shorter possible path can still + // enable expansion within Depth. Every admitted node has a selected parent. + for priority := 0; priority <= 1; priority++ { + queue = append([]string(nil), seeds...) + passDistances := map[string]int{} + for _, id := range seeds { + passDistances[id] = 0 + } + for head := 0; head < len(queue); head++ { + id := queue[head] + if blockedFieldHub(id) { + continue + } + for _, e := range adj[id] { + if scopePathPriority(e) > priority || strings.Contains(e.Kind, "possible_callback_call") && !o.ExpandCallbacks { + continue + } + next := e.To + if next == id { + next = e.From + } + if _, visited := passDistances[next]; visited || passDistances[id] >= o.Depth { + continue + } + oldDistance, selected := distances[next] + if !selected && len(distances) >= o.MaxNodes { + continue + } + distance := passDistances[id] + 1 + passDistances[next] = distance + if !selected || distance < oldDistance { + distances[next] = distance + reached[next] = e + } + queue = append(queue, next) + } + } + } + frontier := map[string]bool{} + for _, id := range sortedKeys(distances) { + for _, e := range adj[id] { + next := e.To + if next == id { + next = e.From + } + if strings.Contains(e.Kind, "possible_callback_call") && !o.ExpandCallbacks { + r.Boundaries = append(r.Boundaries, Boundary{Node: id, Kind: "callback_candidates", Reason: "same-signature CHA candidates were not used to expand scope; enable -expand-callbacks to investigate them", Evidence: e.Evidence, Examples: []string{next}}) + continue + } + if _, visited := distances[next]; visited { + continue + } + if blockedFieldHub(id) { + r.Boundaries = append(r.Boundaries, Boundary{Node: id, Kind: "field_scope_exclusion", Reason: "Configured string event discriminator users were not expanded through shared declaration identity; event value relationships retain their scope. Seed this field resource explicitly to inspect all users, including unresolved event values. This exclusion does not prove irrelevance.", Evidence: e.Evidence, Examples: []string{next}}) + continue + } + r.Coverage.Truncated = true + key := id + "|" + edgeKey(e) + if !frontier[key] { + frontier[key] = true + r.Boundaries = append(r.Boundaries, Boundary{Node: id, Kind: "scope_frontier", Reason: "unexpanded relationships at depth or node budget; examples are not an exhaustive target list", Evidence: e.Evidence, Examples: []string{next}}) + } + } + } + for id := range distances { + if f := ix.funcs[id]; f != nil { + n := f.node + n.Distance = distances[id] + if e, ok := reached[id]; ok { + copy := e + n.ReachedBy = © + } + r.Nodes = append(r.Nodes, n) + } + } + for _, e := range edges { + _, a := distances[e.From] + _, b := distances[e.To] + if a && b { + r.Relationships = append(r.Relationships, e) + } + } + for _, b := range ix.boundaries { + if b.Node == "" { + r.Boundaries = append(r.Boundaries, b) + continue + } + if _, ok := distances[b.Node]; ok { + r.Boundaries = append(r.Boundaries, b) + } + } + if err := ix.applyFocus(&r, o.Focus); err != nil { + return r, err + } + return r, nil +} + +func scopePathPriority(e Relationship) int { + if strings.HasPrefix(e.Kind, "field_") || strings.HasPrefix(e.Kind, "scalar_") || e.Kind == "external_input" || e.Kind == "external_result" || strings.Contains(e.Kind, "possible_callback_call") || strings.HasPrefix(e.Kind, "error_") && e.Certainty == "possible" { + return 1 + } + return 0 +} + +func edgePriority(e Relationship) int { + if e.Certainty == "fact" { + return 0 + } + if strings.HasPrefix(e.Kind, "sql_") || strings.HasPrefix(e.Kind, "event_") { + return 1 + } + return 2 +} + +// Group repeated candidate targets by source site, retaining a distinct target +// count, the full discovered target inventory and five deterministic examples. +func groupBoundaries(r *Report) { + grouped := map[string]*Boundary{} + targets := map[string]map[string]bool{} + result := []Boundary{} + seenFacts := map[string]bool{} + for _, b := range r.Boundaries { + // Older snapshots retained only examples. Preserve their count without + // inventing the targets that were discarded by the older analyzer. + if b.CandidateCount > 0 && len(b.Candidates) == 0 { + result = append(result, b) + continue + } + if len(b.Examples) == 0 && len(b.Candidates) == 0 { + key := fmt.Sprintf("%s|%s|%s:%d:%d|%s", b.Node, b.Kind, b.Evidence.File, b.Evidence.Line, b.Evidence.Column, b.Reason) + if !seenFacts[key] { + result = append(result, b) + seenFacts[key] = true + } + continue + } + key := fmt.Sprintf("%s|%s|%s:%d:%d|%s", b.Node, b.Kind, b.Evidence.File, b.Evidence.Line, b.Evidence.Column, b.Reason) + if grouped[key] == nil { + copy := b + copy.Examples = nil + copy.Candidates = nil + grouped[key] = © + targets[key] = map[string]bool{} + } + for _, target := range b.Examples { + targets[key][target] = true + } + for _, target := range b.Candidates { + targets[key][target] = true + } + } + for key, b := range grouped { + examples := []string{} + for target := range targets[key] { + examples = append(examples, target) + } + sort.Strings(examples) + b.CandidateCount = len(examples) + b.Candidates = examples + if len(examples) > 5 { + examples = examples[:5] + } + b.Examples = examples + result = append(result, *b) + } + r.Boundaries = result + sortReport(r) +} +func edgeKey(e Relationship) string { + return fmt.Sprintf("%s|%s|%s|%s|%s|%s:%09d:%09d", e.From, e.To, e.Kind, e.Certainty, e.Slot, e.Evidence.File, e.Evidence.Line, e.Evidence.Column) +} + +type keyedRelationship struct { + // Metadata stays parallel to edges so sorting does not allocate a second + // copy of each Relationship and its evidence/value slice headers. + edges []Relationship + keys []string + priorities []int +} + +func (s keyedRelationship) Len() int { return len(s.edges) } +func (s keyedRelationship) Less(i, j int) bool { + if s.priorities != nil && s.priorities[i] != s.priorities[j] { + return s.priorities[i] < s.priorities[j] + } + return s.keys[i] < s.keys[j] +} +func (s keyedRelationship) Swap(i, j int) { + s.edges[i], s.edges[j] = s.edges[j], s.edges[i] + s.keys[i], s.keys[j] = s.keys[j], s.keys[i] + if s.priorities != nil { + s.priorities[i], s.priorities[j] = s.priorities[j], s.priorities[i] + } +} + +// sortEdgesByPriorityAndKey decorates each edge once so comparator calls do +// not repeatedly format the same source identity. It keeps the existing +// priority tiers and exact edgeKey lexical order, including its delimiter and +// numeric-width behavior. +func sortEdgesByPriorityAndKey(edges []Relationship) { + if len(edges) < 2 { + return + } + keys := make([]string, len(edges)) + priorities := make([]int, len(edges)) + for i, edge := range edges { + keys[i] = edgeKey(edge) + priorities[i] = edgePriority(edge) + } + sort.Sort(keyedRelationship{edges: edges, keys: keys, priorities: priorities}) +} + +func sortEdgesByKey(edges []Relationship) { + if len(edges) < 2 { + return + } + keys := make([]string, len(edges)) + for i, edge := range edges { + keys[i] = edgeKey(edge) + } + sort.Sort(keyedRelationship{edges: edges, keys: keys}) +} + +func sortReport(r *Report) { + sort.Slice(r.Nodes, func(i, j int) bool { return r.Nodes[i].ID < r.Nodes[j].ID }) + sortEdgesByKey(r.Relationships) + sort.Strings(r.Seeds) + sort.Strings(r.Coverage.Packages) + sort.Strings(r.Coverage.Files) + sort.Strings(r.Coverage.ExcludedGoFiles) + sort.Slice(r.Boundaries, func(i, j int) bool { + a, b := r.Boundaries[i], r.Boundaries[j] + return fmt.Sprint(a.Node, a.Kind, a.Reason, a.Evidence) < fmt.Sprint(b.Node, b.Kind, b.Reason, b.Evidence) + }) +} diff --git a/scope_priority_test.go b/scope_priority_test.go new file mode 100644 index 0000000..7248c70 --- /dev/null +++ b/scope_priority_test.go @@ -0,0 +1,90 @@ +package contracttrace + +import "testing" + +func TestScopePriorityKeepsNearbyModeledStorage(t *testing.T) { + ix := &index{funcs: map[string]*function{}, edges: []Relationship{ + {From: "seed", To: "a", Kind: "call", Certainty: "fact"}, + {From: "a", To: "b", Kind: "call", Certainty: "fact"}, + {From: "b", To: "c", Kind: "call", Certainty: "fact"}, + {From: "seed", To: "table", Kind: "sql_read", Certainty: "possible"}, + }} + for _, id := range []string{"seed", "a", "b", "c", "table"} { + ix.funcs[id] = &function{node: Node{ID: id, Name: id, Kind: "function"}} + } + r, err := ix.expand(Options{Seeds: []string{"seed"}, Depth: 3, MaxNodes: 4}, Coverage{}) + if err != nil { + t.Fatal(err) + } + found := map[string]bool{} + for _, node := range r.Nodes { + found[node.ID] = true + } + if !found["table"] || found["c"] || !r.Coverage.Truncated { + t.Fatalf("nearby modeled storage displaced by distant calls: %v", found) + } +} + +func TestScopePriorityRevisitsShorterPathsWithinDepth(t *testing.T) { + ix := &index{funcs: map[string]*function{}, edges: []Relationship{ + {From: "seed", To: "a", Kind: "call", Certainty: "fact"}, + {From: "a", To: "b", Kind: "call", Certainty: "fact"}, + {From: "b", To: "c", Kind: "call", Certainty: "fact"}, + {From: "c", To: "d", Kind: "call", Certainty: "fact"}, + {From: "seed", To: "b", Kind: "resolved_callback_call", Certainty: "possible"}, + }} + for _, id := range []string{"seed", "a", "b", "c", "d"} { + ix.funcs[id] = &function{node: Node{ID: id, Name: id, Kind: "function"}} + } + r, err := ix.expand(Options{Seeds: []string{"seed"}, Depth: 3, MaxNodes: 5}, Coverage{}) + if err != nil { + t.Fatal(err) + } + want := map[string]int{"seed": 0, "a": 1, "b": 1, "c": 2, "d": 3} + if len(r.Nodes) != len(want) || r.Coverage.Truncated { + t.Fatalf("shorter path scope: %+v", r) + } + for _, node := range r.Nodes { + if node.Distance != want[node.ID] { + t.Errorf("%s distance=%d want=%d", node.ID, node.Distance, want[node.ID]) + } + if node.ReachedBy != nil { + parent := node.ReachedBy.From + if parent == node.ID { + parent = node.ReachedBy.To + } + if want[parent]+1 != node.Distance { + t.Errorf("%s has inconsistent parent %s", node.ID, parent) + } + } + } +} + +func TestScopeBudgetKeepsEstablishedPathsBeforePossibleExpansion(t *testing.T) { + ix := &index{funcs: map[string]*function{}, edges: []Relationship{ + {From: "seed", To: "caller", Kind: "call", Certainty: "fact"}, + {From: "caller", To: "entry", Kind: "call", Certainty: "fact"}, + {From: "entry", To: "outer", Kind: "call", Certainty: "fact"}, + {From: "seed", To: "outside", Kind: "external_input", Certainty: "possible"}, + {From: "outside", To: "speculative", Kind: "possible_callback_call", Certainty: "possible"}, + }} + for _, id := range []string{"seed", "caller", "entry", "outer", "outside", "speculative"} { + ix.funcs[id] = &function{node: Node{ID: id, Name: id, Kind: "function"}} + } + r, err := ix.expand(Options{Seeds: []string{"seed"}, Depth: 3, MaxNodes: 4, ExpandCallbacks: true}, Coverage{}) + if err != nil { + t.Fatal(err) + } + found := map[string]bool{} + for _, node := range r.Nodes { + found[node.ID] = true + } + for _, id := range []string{"seed", "caller", "entry", "outer"} { + if !found[id] { + t.Errorf("bounded scope lost established path node %s: %v", id, found) + } + } + if !r.Coverage.Truncated { + t.Fatal("possible expansion omission was not disclosed") + } +} diff --git a/semantic.go b/semantic.go new file mode 100644 index 0000000..ba224e0 --- /dev/null +++ b/semantic.go @@ -0,0 +1,575 @@ +package contracttrace + +import ( + "fmt" + "go/ast" + "go/constant" + "go/token" + "go/types" + "net/url" + "sort" + "strings" +) + +func normalizeConfig(c Config) Config { + d := DefaultConfig() + if c.EventFields == nil { + c.EventFields = d.EventFields + } + if c.LifecycleNames == nil { + c.LifecycleNames = d.LifecycleNames + } + if c.SQLMethods == nil { + c.SQLMethods = d.SQLMethods + } + if c.SQLDialect == "" { + c.SQLDialect = d.SQLDialect + } + if c.SQLFiles == nil { + c.SQLFiles = d.SQLFiles + } + return c +} +func contains(list []string, value string) bool { + for _, s := range list { + if s == value { + return true + } + } + return false +} +func field(expr ast.Expr) string { + if paren, ok := expr.(*ast.ParenExpr); ok { + return field(paren.X) + } + switch e := expr.(type) { + case *ast.SelectorExpr: + return e.Sel.Name + case *ast.Ident: + return e.Name + } + return "" +} + +type assignments struct { + values map[types.Object]ast.Expr + counts map[types.Object]int +} + +func semanticRoots(ix *index, f *function) []ast.Node { + if f.decl != nil && f.decl.Body != nil { + return []ast.Node{f.decl.Body} + } + if f.node.Kind != "initializer" || f.pkg == nil { + return nil + } + roots := []ast.Node{} + for _, file := range f.pkg.Syntax { + if _, inside := relative(ix.root, ix.fset.Position(file.Pos()).Filename); !inside { + continue + } + for _, declaration := range file.Decls { + group, ok := declaration.(*ast.GenDecl) + if !ok || group.Tok != token.VAR { + continue + } + for _, specification := range group.Specs { + value, ok := specification.(*ast.ValueSpec) + if ok && len(value.Values) > 0 { + roots = append(roots, value) + } + } + } + } + return roots +} + +func initializerFuncLiteral(expression ast.Expr) *ast.FuncLit { + switch value := expression.(type) { + case *ast.FuncLit: + return value + case *ast.ParenExpr: + return initializerFuncLiteral(value.X) + default: + return nil + } +} + +func inspectSemanticRoot(root ast.Node, f *function, visit func(ast.Node) bool) { + if f.node.Kind != "initializer" { + ast.Inspect(root, visit) + return + } + executed := map[*ast.FuncLit]bool{} + ast.Inspect(root, func(node ast.Node) bool { + call, ok := node.(*ast.CallExpr) + if ok { + if literal := initializerFuncLiteral(call.Fun); literal != nil { + executed[literal] = true + } + } + return true + }) + ast.Inspect(root, func(node ast.Node) bool { + if literal, ok := node.(*ast.FuncLit); ok && !executed[literal] { + return false + } + return visit(node) + }) +} + +func localAssignments(f *function, roots []ast.Node) assignments { + a := assignments{values: map[types.Object]ast.Expr{}, counts: map[types.Object]int{}} + set := func(lhs ast.Expr, rhs ast.Expr) { + id, ok := lhs.(*ast.Ident) + if !ok { + return + } + obj := f.pkg.TypesInfo.ObjectOf(id) + if obj != nil { + if f.node.Kind == "initializer" && obj.Parent() == f.pkg.Types.Scope() { + return + } + a.counts[obj]++ + a.values[obj] = rhs + } + } + for _, root := range roots { + inspectSemanticRoot(root, f, func(n ast.Node) bool { + switch v := n.(type) { + case *ast.AssignStmt: + if v.Tok == token.ASSIGN || v.Tok == token.DEFINE { + if len(v.Lhs) > len(v.Rhs) { + for _, lhs := range v.Lhs { + set(lhs, nil) + } + } else { + for i, lhs := range v.Lhs { + if i < len(v.Rhs) { + set(lhs, v.Rhs[i]) + } + } + } + } else { + for _, lhs := range v.Lhs { + if id, ok := lhs.(*ast.Ident); ok { + if obj := f.pkg.TypesInfo.ObjectOf(id); obj != nil { + a.counts[obj]++ + } + } + } + } + case *ast.ValueSpec: + if len(v.Values) > 0 && len(v.Names) > len(v.Values) { + for _, id := range v.Names { + set(id, nil) + } + } else { + for i, id := range v.Names { + if i < len(v.Values) { + set(id, v.Values[i]) + } + } + } + case *ast.IncDecStmt: + if id, ok := v.X.(*ast.Ident); ok { + a.counts[f.pkg.TypesInfo.ObjectOf(id)]++ + } + case *ast.RangeStmt: + for _, lhs := range []ast.Expr{v.Key, v.Value} { + if id, ok := lhs.(*ast.Ident); ok { + a.counts[f.pkg.TypesInfo.ObjectOf(id)]++ + } + } + } + return true + }) + } + return a +} + +type stringPart struct { + text string + hole bool +} + +type stringExpression struct { + parts []stringPart + complete bool +} + +type stringCandidate struct { + text string + holes []string +} + +func knownString(text string) stringExpression { + return stringExpression{parts: []stringPart{{text: text}}, complete: true} +} + +func unknownString() stringExpression { + return stringExpression{parts: []stringPart{{hole: true}}} +} + +func (value stringExpression) candidate() stringCandidate { + knownText := strings.Builder{} + for _, part := range value.parts { + if !part.hole { + knownText.WriteString(part.text) + } + } + known := knownText.String() + lowerKnown := strings.ToLower(known) + used := map[string]bool{} + text := strings.Builder{} + holes := []string{} + for _, part := range value.parts { + if !part.hole { + text.WriteString(part.text) + continue + } + for index := 0; ; index++ { + token := fmt.Sprintf("__ct_unknown_hole_%d__", index) + if strings.Contains(lowerKnown, token) || used[token] { + continue + } + used[token] = true + holes = append(holes, token) + text.WriteString(token) + break + } + } + return stringCandidate{text: text.String(), holes: holes} +} + +func stringValue(expr ast.Expr, f *function, a assignments, seen map[types.Object]bool) stringExpression { + if v, ok := f.pkg.TypesInfo.Types[expr]; ok && v.Value != nil && v.Value.Kind() == constant.String { + return knownString(constant.StringVal(v.Value)) + } + switch e := expr.(type) { + case *ast.ParenExpr: + return stringValue(e.X, f, a, seen) + case *ast.BinaryExpr: + if e.Op == token.ADD { + x := stringValue(e.X, f, a, seen) + y := stringValue(e.Y, f, a, seen) + parts := append([]stringPart{}, x.parts...) + parts = append(parts, y.parts...) + return stringExpression{parts: parts, complete: x.complete && y.complete} + } + case *ast.Ident: + obj := f.pkg.TypesInfo.ObjectOf(e) + if c, ok := obj.(*types.Const); ok && c.Val().Kind() == constant.String { + return knownString(constant.StringVal(c.Val())) + } + if obj != nil && !seen[obj] && a.counts[obj] == 1 { + seen[obj] = true + value := stringValue(a.values[obj], f, a, seen) + delete(seen, obj) + return value + } + } + return unknownString() +} +func (ix *index) resource(from, key, name, kind, relationship string, pos token.Pos) { + evidence := ix.evidence(pos) + if existing := ix.funcs[key]; existing == nil { + ix.funcs[key] = &function{node: Node{ID: key, Name: name, Kind: kind, Evidence: evidence}} + } + ix.edge(from, key, relationship, "possible", pos) +} + +func (ix *index) stringCandidates(expr ast.Expr, f *function, a assignments) ([]stringCandidate, bool) { + value := stringValue(expr, f, a, map[types.Object]bool{}) + if value.complete { + return []stringCandidate{value.candidate()}, true + } + if ix.flow != nil { + value := ix.flow.positions[expr.Pos()] + if call, ok := expr.(*ast.CallExpr); ok { + ix.flow.merge(&value, ix.flow.positions[call.Lparen]) + } + values := []stringCandidate{} + for s := range value.strings { + values = append(values, stringCandidate{text: s}) + } + sort.Slice(values, func(i, j int) bool { return values[i].text < values[j].text }) + if len(values) > 0 { + return values, false + } + } + return []stringCandidate{value.candidate()}, false +} + +func tableHasHole(table string, candidate stringCandidate) bool { + for _, hole := range candidate.holes { + if strings.Contains(table, hole) { + return true + } + } + return false +} + +func databaseSQLQueryArgument(name string) (int, bool) { + switch name { + case "Exec", "Query", "QueryRow", "Prepare": + return 0, true + case "ExecContext", "QueryContext", "QueryRowContext", "PrepareContext": + return 1, true + default: + return 0, false + } +} + +func (ix *index) defaultSQLArguments(call *ast.CallExpr, f *function, config Config) ([]int, bool) { + if ix.flow == nil { + return nil, false + } + calls, recognized := ix.flow.sqlCalls[call.Lparen] + if !recognized { + return nil, false + } + arguments := map[int]bool{} + for _, key := range sortedKeys(calls) { + candidate := calls[key] + if candidate.receiverKind == "Stmt" || !contains(config.SQLMethods, candidate.name) { + continue + } + argument, ok := databaseSQLQueryArgument(candidate.name) + if !ok { + continue + } + argument += callRuleReceiverOffset(call, f, candidate.method) + if argument < len(call.Args) { + arguments[argument] = true + } + } + result := make([]int, 0, len(arguments)) + for argument := range arguments { + result = append(result, argument) + } + sort.Ints(result) + return result, true +} + +func (ix *index) inventorySQLArgument(owner string, call *ast.CallExpr, f *function, assignments assignments, argument int, config Config) error { + if argument < 0 || argument >= len(call.Args) { + return nil + } + namespaces, err := ix.callNamespaces(owner, call, "", config) + if err != nil { + return err + } + texts, complete := ix.stringCandidates(call.Args[argument], f, assignments) + if !complete { + ix.boundaries = append(ix.boundaries, Boundary{Node: owner, Kind: "dynamic_sql", Reason: "query contains unresolved or multiply assigned values; only resolvable table fragments are inventoried", Evidence: ix.evidence(call.Args[argument].Pos())}) + } + tables := []sqlAccess{} + queries := make([]string, 0, len(texts)) + for _, text := range texts { + queries = append(queries, text.text) + for _, found := range ix.sqlAccesses(owner, text.text, ix.evidence(call.Pos())) { + if !tableHasHole(found.access.table, text) { + tables = append(tables, found.access) + } + } + } + if complete && len(tables) == 0 && !ix.parsedSQL(queries) { + ix.boundaries = append(ix.boundaries, Boundary{Node: owner, Kind: "unparsed_sql", Reason: "configured SQL method has no recognized table access; statement or wrapper may be unsupported", Evidence: ix.evidence(call.Pos())}) + } + for _, access := range tables { + for _, namespace := range namespaces { + ix.sqlTableAccess(owner, resourceID("table", namespace, access.table), access.table, access.role, call) + } + } + return nil +} + +func (ix *index) semantic(c Config) error { + ix.databaseOriginBoundaries(c) + ix.storage.Dialect = c.SQLDialect + matchedRules := map[string]bool{} + ids := []string{} + rootsByID := map[string][]ast.Node{} + for id, f := range ix.funcs { + if roots := semanticRoots(ix, f); len(roots) > 0 { + ids = append(ids, id) + rootsByID[id] = roots + } + } + sort.Strings(ids) + for _, id := range ids { + f := ix.funcs[id] + roots := rootsByID[id] + a := localAssignments(f, roots) + for _, hint := range c.LifecycleNames { + if strings.Contains(strings.ToLower(f.node.Name), strings.ToLower(hint)) { + ix.boundaries = append(ix.boundaries, Boundary{Node: id, Kind: "lifecycle_hint", Reason: "configured name hint " + hint + "; inspect lifecycle ownership, not an established invariant", Evidence: f.node.Evidence}) + break + } + } + event := func(expr ast.Expr, role string) { + values, complete := ix.stringCandidates(expr, f, a) + if !complete { + ix.boundaries = append(ix.boundaries, Boundary{Node: id, Kind: "dynamic_event", Reason: "configured event field uses a value that is not statically resolved", Evidence: ix.evidence(expr.Pos())}) + } + for _, value := range values { + if value.text != "" && len(value.holes) == 0 { + ix.resource(id, resourceID("event", "", value.text), value.text, "event", role, expr.Pos()) + } + } + } + var semanticErr error + for _, root := range roots { + inspectSemanticRoot(root, f, func(n ast.Node) bool { + if semanticErr != nil { + return false + } + switch v := n.(type) { + case *ast.CallExpr: + handled, err := ix.applyCallRules(id, f, v, a, c, matchedRules) + if err != nil { + semanticErr = err + return false + } + if handled { + break + } + prepared, err := ix.preparedSQL(id, v, c) + if err != nil { + semanticErr = err + return false + } + if prepared { + break + } + if arguments, recognized := ix.defaultSQLArguments(v, f, c); recognized { + for _, argument := range arguments { + if err := ix.inventorySQLArgument(id, v, f, a, argument, c); err != nil { + semanticErr = err + return false + } + } + break + } + sel, ok := v.Fun.(*ast.SelectorExpr) + if !ok || !contains(c.SQLMethods, sel.Sel.Name) { + break + } + arg := 0 + if strings.HasSuffix(sel.Sel.Name, "Context") { + arg = 1 + } + if err := ix.inventorySQLArgument(id, v, f, a, arg, c); err != nil { + semanticErr = err + return false + } + case *ast.CompositeLit: + typ := f.pkg.TypesInfo.TypeOf(v) + if typ == nil { + break + } + resolvedType := types.Unalias(typ) + if pointer, ok := resolvedType.Underlying().(*types.Pointer); ok { + resolvedType = types.Unalias(pointer.Elem()) + } + structType, ok := resolvedType.Underlying().(*types.Struct) + if !ok { + break + } + for i, element := range v.Elts { + if keyed, ok := element.(*ast.KeyValueExpr); ok { + if contains(c.EventFields, field(keyed.Key)) { + event(keyed.Value, "event_construct") + } + continue + } + if i < structType.NumFields() && contains(c.EventFields, structType.Field(i).Name()) { + event(element, "event_construct") + } + } + case *ast.AssignStmt: + if (v.Tok == token.ASSIGN || v.Tok == token.DEFINE) && len(v.Lhs) > len(v.Rhs) { + for _, lhs := range v.Lhs { + if contains(c.EventFields, field(lhs)) { + ix.boundaries = append(ix.boundaries, Boundary{ + Node: id, Kind: "dynamic_event", + Reason: "configured event field receives a selected result from a multi-result expression; its value is unresolved", + Evidence: ix.evidence(v.Pos()), + }) + } + } + break + } + for i, lhs := range v.Lhs { + if !contains(c.EventFields, field(lhs)) { + continue + } + if v.Tok == token.ASSIGN || v.Tok == token.DEFINE { + if i < len(v.Rhs) { + event(v.Rhs[i], "event_assign") + } + } else { + ix.boundaries = append(ix.boundaries, Boundary{ + Node: id, Kind: "dynamic_event", + Reason: "compound assignment combines an event value with another expression; the resulting value is unresolved", + Evidence: ix.evidence(v.Pos()), + }) + } + } + case *ast.RangeStmt: + for _, lhs := range []ast.Expr{v.Key, v.Value} { + if lhs != nil && contains(c.EventFields, field(lhs)) { + ix.boundaries = append(ix.boundaries, Boundary{ + Node: id, Kind: "dynamic_event", + Reason: "configured event field receives a range iteration key or value; the collection candidate is unresolved", + Evidence: ix.evidence(v.Pos()), + }) + } + } + case *ast.BinaryExpr: + if v.Op == token.EQL || v.Op == token.NEQ { + if contains(c.EventFields, field(v.X)) { + event(v.Y, "event_compare") + } + if contains(c.EventFields, field(v.Y)) { + event(v.X, "event_compare") + } + } + case *ast.SwitchStmt: + if contains(c.EventFields, field(v.Tag)) { + for _, item := range v.Body.List { + if clause, ok := item.(*ast.CaseClause); ok { + for _, expr := range clause.List { + event(expr, "event_case") + } + } + } + } + case *ast.GoStmt: + ix.boundaries = append(ix.boundaries, Boundary{Node: id, Kind: "concurrency", Reason: "goroutine ownership and termination need investigation", Evidence: ix.evidence(v.Pos())}) + case *ast.DeferStmt: + ix.boundaries = append(ix.boundaries, Boundary{Node: id, Kind: "cleanup", Reason: "deferred action is a cleanup candidate; adequacy and ordering are not established", Evidence: ix.evidence(v.Pos())}) + } + return true + }) + } + if semanticErr != nil { + return semanticErr + } + } + for _, rule := range c.CallRules { + if !matchedRules[rule.Symbol] { + ix.boundaries = append(ix.boundaries, Boundary{Kind: "unmatched_rule", Reason: "configured API symbol not encountered: " + rule.Symbol}) + } + } + ix.boundaries = append(ix.boundaries, Boundary{Kind: "storage_model", Reason: fmt.Sprintf("Table matches are candidates across potentially different databases. SQLite AST dependencies include schema and query sources; other dialects, unresolved construction, parser failures, trigger runtime conditions and ORM behavior require investigation. Recognized methods: %s", strings.Join(c.SQLMethods, ", "))}) + return nil +} + +func resourceID(kind, namespace, value string) string { + if namespace == "" { + return kind + ":" + url.QueryEscape(value) + } + return kind + ":" + url.QueryEscape(namespace) + ":" + url.QueryEscape(value) +} diff --git a/sentinel_collision_test.go b/sentinel_collision_test.go new file mode 100644 index 0000000..e528879 --- /dev/null +++ b/sentinel_collision_test.go @@ -0,0 +1,190 @@ +package contracttrace + +import ( + "bytes" + "context" + "os" + "path/filepath" + "testing" +) + +func TestSentinelTextRemainsARealCandidate(t *testing.T) { + root := t.TempDir() + files := map[string]string{ + "go.mod": "module example.com/sentinelclient\n\ngo 1.27.0\nrequire example.com/sentinelapi v0.0.0\nreplace example.com/sentinelapi => ./api\n", + "api/go.mod": "module example.com/sentinelapi\n\ngo 1.27.0\n", + "api/api.go": `package sentinelapi +type Manager struct{} +func Publish(string) {} +func Query(string) {} +func (*Manager) Reserve(string) {} +func (*Manager) Release(string) {} +`, + "app.go": `package sentinelclient +import ( + "database/sql" + "example.com/sentinelapi" +) +const marker = "__contract_unknown__" +const holeLooking = "__ct_unknown_hole_0__" +var manager sentinelapi.Manager +type Envelope struct { EventType string } +func EmitSentinel() { sentinelapi.Publish(marker) } +func EmitContainingText() { sentinelapi.Publish("prefix" + marker + "suffix") } +func EmitHoleLookingText() { sentinelapi.Publish(holeLooking) } +func EmitMixed(flag bool, outside string) { value := marker; if flag { value = outside }; sentinelapi.Publish(value) } +func EmitOutside(outside string) { sentinelapi.Publish(outside) } +func EmitPartial(outside string) { sentinelapi.Publish("orders." + outside) } +func ConstructSentinel() { _ = Envelope{EventType: marker} } +func ConfiguredQuerySentinel() { sentinelapi.Query("SELECT id FROM " + marker) } +func ConfiguredPartialQuery(suffix string) { sentinelapi.Query("SELECT id FROM configured_records JOIN " + suffix + " ON 1=1") } +func ConfiguredQueryOutside(query string) { sentinelapi.Query(query) } +func DefaultQuerySentinel(db *sql.DB) { db.Query("SELECT id FROM " + marker) } +func DefaultQueryOutside(db *sql.DB, query string) { db.Query(query) } +func PartialQuery(db *sql.DB, suffix string) { db.Query("SELECT id FROM known_records JOIN " + suffix + " ON 1=1") } +func PartialTableName(db *sql.DB, suffix string) { db.Query("SELECT id FROM known_" + suffix) } +func PartialHoleLookingTable(db *sql.DB, suffix string) { db.Query("SELECT id FROM __ct_unknown_hole_0__ JOIN " + suffix + " ON 1=1") } +func ReserveSentinel() { manager.Reserve(marker); manager.Release(marker) } +func ReserveContainingText() { reserve := manager.Reserve; release := manager.Release; reserve("prefix" + marker + "suffix"); release("prefix" + marker + "suffix") } +func ReserveMixed(flag bool, outside string) { value := marker; if flag { value = outside }; manager.Reserve(value); manager.Release(value) } +func ReserveOutside(outside string) { manager.Reserve(outside); manager.Release(outside) } +`, + } + for name, contents := range files { + path := filepath.Join(root, name) + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(contents), 0600); err != nil { + t.Fatal(err) + } + } + zero := 0 + config := DefaultConfig() + config.CallRules = []CallRule{ + {Symbol: "example.com/sentinelapi::Publish", Kind: "event_publish", Argument: 0, Namespace: "bus"}, + {Symbol: "example.com/sentinelapi::Query", Kind: "sql_query", Argument: 0, Namespace: "api"}, + } + config.LifecycleRules = []LifecycleRule{ + {Symbol: "example.com/sentinelapi::Manager.Reserve", Role: "acquire", Namespace: "keys", Identity: "value", Argument: &zero}, + {Symbol: "example.com/sentinelapi::Manager.Release", Role: "release", Namespace: "keys", Identity: "value", Argument: &zero}, + } + seeds := []string{"EmitSentinel", "EmitContainingText", "EmitHoleLookingText", "EmitMixed", "EmitOutside", "EmitPartial", "ConstructSentinel", "ConfiguredQuerySentinel", "ConfiguredPartialQuery", "ConfiguredQueryOutside", "DefaultQuerySentinel", "DefaultQueryOutside", "PartialQuery", "PartialTableName", "PartialHoleLookingTable", "ReserveSentinel", "ReserveContainingText", "ReserveMixed", "ReserveOutside"} + fresh, analysis, err := TraceWithAnalysis(context.Background(), Options{Root: root, Seeds: seeds, Depth: 5, MaxNodes: 200, Config: config}) + if err != nil { + t.Fatal(err) + } + var encoded bytes.Buffer + if err := WriteAnalysis(&encoded, analysis, true); err != nil { + t.Fatal(err) + } + saved, err := ReadAnalysis(bytes.NewReader(encoded.Bytes())) + if err != nil { + t.Fatal(err) + } + resumed, err := Explore(context.Background(), saved, ExploreOptions{Seeds: seeds, Depth: 5, MaxNodes: 200}) + if err != nil { + t.Fatal(err) + } + for _, report := range []Report{fresh, resumed} { + want := []struct{ owner, target, kind string }{ + {"EmitSentinel", "event:bus:__contract_unknown__", "event_publish"}, + {"EmitContainingText", "event:bus:prefix__contract_unknown__suffix", "event_publish"}, + {"EmitHoleLookingText", "event:bus:__ct_unknown_hole_0__", "event_publish"}, + {"EmitMixed", "event:bus:__contract_unknown__", "event_publish"}, + {"ConstructSentinel", "event:__contract_unknown__", "event_construct"}, + {"ConfiguredQuerySentinel", "table:api:__contract_unknown__", "sql_read"}, + {"ConfiguredPartialQuery", "table:api:configured_records", "sql_read"}, + {"DefaultQuerySentinel", "table:__contract_unknown__", "sql_read"}, + {"PartialQuery", "table:known_records", "sql_read"}, + {"PartialHoleLookingTable", "table:__ct_unknown_hole_0__", "sql_read"}, + {"ReserveSentinel", "lifecycle:keys:__contract_unknown__", "lifecycle_acquire"}, + {"ReserveSentinel", "lifecycle:keys:__contract_unknown__", "lifecycle_release"}, + {"ReserveContainingText", "lifecycle:keys:prefix__contract_unknown__suffix", "lifecycle_acquire"}, + {"ReserveContainingText", "lifecycle:keys:prefix__contract_unknown__suffix", "lifecycle_release"}, + {"ReserveMixed", "lifecycle:keys:__contract_unknown__", "lifecycle_acquire"}, + {"ReserveMixed", "lifecycle:keys:__contract_unknown__", "lifecycle_release"}, + } + for _, edge := range want { + if !hasRelationship(report, "example.com/sentinelclient::"+edge.owner, edge.target, edge.kind, "app.go", 0) { + t.Errorf("%s lost exact candidate %s (%s)", edge.owner, edge.target, edge.kind) + } + } + for _, owner := range []string{"EmitMixed", "EmitOutside", "EmitPartial"} { + if !hasBoundaryKind(report, "example.com/sentinelclient::"+owner, "dynamic_api_value") { + t.Errorf("%s lost its unresolved configured event input", owner) + } + } + for _, owner := range []string{"ReserveMixed", "ReserveOutside"} { + if !hasBoundaryKind(report, "example.com/sentinelclient::"+owner, "unresolved_lifecycle_resource") { + t.Errorf("%s lost its unresolved lifecycle input", owner) + } + } + for _, owner := range []string{"PartialQuery", "PartialTableName", "PartialHoleLookingTable"} { + if !hasBoundaryKind(report, "example.com/sentinelclient::"+owner, "dynamic_sql") { + t.Errorf("%s lost its unresolved suffix boundary", owner) + } + } + if !hasBoundaryKind(report, "example.com/sentinelclient::ConfiguredPartialQuery", "dynamic_api_value") { + t.Error("configured partial SQL lost its unresolved suffix boundary") + } + for _, owner := range []string{"ConfiguredQueryOutside", "DefaultQueryOutside"} { + if !hasBoundaryKind(report, "example.com/sentinelclient::"+owner, map[string]string{"ConfiguredQueryOutside": "dynamic_api_value", "DefaultQueryOutside": "dynamic_sql"}[owner]) { + t.Errorf("%s lost its unresolved SQL input boundary", owner) + } + for _, edge := range report.Relationships { + if edge.From == "example.com/sentinelclient::"+owner && edge.Kind == "sql_read" { + t.Errorf("%s fabricated a table from an unknown-only query: %+v", owner, edge) + } + } + } + if hasRelationship(report, "example.com/sentinelclient::PartialTableName", "table:known_", "sql_read", "app.go", 0) { + t.Error("incomplete table-name prefix was emitted as a complete table") + } + for _, owner := range []string{"EmitOutside", "EmitPartial"} { + if hasEdgeKind(report, "example.com/sentinelclient::"+owner, "event_publish") { + t.Errorf("%s emitted a complete event from an unknown value", owner) + } + } + if hasEdgeKind(report, "example.com/sentinelclient::PartialTableName", "sql_read") { + t.Error("unknown-only table name produced a complete SQL table candidate") + } + for owner, expected := range map[string]string{ + "PartialQuery": "table:known_records", + "ConfiguredPartialQuery": "table:api:configured_records", + } { + for _, edge := range report.Relationships { + if edge.From == "example.com/sentinelclient::"+owner && edge.Kind == "sql_read" && edge.To != expected { + t.Errorf("%s emitted a table beyond its statically known fragment: %+v", owner, edge) + } + } + } + if hasEdgeKind(report, "example.com/sentinelclient::ReserveOutside", "lifecycle_acquire") || + hasEdgeKind(report, "example.com/sentinelclient::ReserveOutside", "lifecycle_release") { + t.Error("unknown-only lifecycle key produced a concrete role") + } + if hasBoundaryKind(report, "example.com/sentinelclient::EmitSentinel", "dynamic_api_value") || + hasBoundaryKind(report, "example.com/sentinelclient::ConfiguredQuerySentinel", "dynamic_api_value") || + hasBoundaryKind(report, "example.com/sentinelclient::ReserveSentinel", "unresolved_lifecycle_resource") { + t.Error("known exact marker text was incorrectly treated as an unknown value") + } + } +} + +func hasEdgeKind(report Report, owner, kind string) bool { + for _, edge := range report.Relationships { + if edge.From == owner && edge.Kind == kind { + return true + } + } + return false +} + +func hasBoundaryKind(report Report, owner, kind string) bool { + for _, boundary := range report.Boundaries { + if boundary.Node == owner && boundary.Kind == kind { + return true + } + } + return false +} diff --git a/source_selection.go b/source_selection.go new file mode 100644 index 0000000..b33cf90 --- /dev/null +++ b/source_selection.go @@ -0,0 +1,131 @@ +package contracttrace + +import ( + "context" + "fmt" + "path/filepath" + "sort" + "strings" + + "golang.org/x/tools/go/packages" +) + +func packageLoaderConfig(ctx context.Context, root string, build map[string]string, tests bool, tags string) *packages.Config { + cfg := &packages.Config{Context: ctx, Dir: root, Env: packageLoaderEnvironment(build), Tests: tests} + if tags != "" { + cfg.BuildFlags = []string{"-tags=" + tags} + } + return cfg +} + +func selectedPackageSourcePaths(pkgs []*packages.Package) ([]string, error) { + if len(pkgs) == 0 { + return nil, fmt.Errorf("no Go packages loaded") + } + var diagnostics []string + paths := map[string]bool{} + packages.Visit(pkgs, nil, func(p *packages.Package) { + for _, err := range p.Errors { + diagnostics = append(diagnostics, err.Error()) + } + for _, filename := range p.CompiledGoFiles { + absolute, err := filepath.Abs(filename) + if err != nil { + diagnostics = append(diagnostics, fmt.Sprintf("source path %q: %v", filename, err)) + continue + } + paths[filepath.ToSlash(absolute)] = true + } + }) + if len(diagnostics) != 0 { + sort.Strings(diagnostics) + return nil, fmt.Errorf("package source selection incomplete: %s", strings.Join(diagnostics, "\n")) + } + selected := make([]string, 0, len(paths)) + for path := range paths { + selected = append(selected, path) + } + sort.Strings(selected) + return selected, nil +} + +func loadedSourcePaths(sources []LoadedSource) []string { + paths := make([]string, 0, len(sources)) + for _, source := range sources { + paths = append(paths, source.Path) + } + return paths +} + +func verifySelectedPackageSources(ctx context.Context, root string, build map[string]string, tests bool, tags string, loaded []LoadedSource, embedded []string) error { + if err := ctx.Err(); err != nil { + return err + } + cfg := packageLoaderConfig(ctx, root, build, tests, tags) + cfg.Mode = packages.NeedName | packages.NeedFiles | packages.NeedCompiledGoFiles | packages.NeedImports | packages.NeedDeps | packages.NeedModule | packages.NeedEmbedFiles | packages.NeedEmbedPatterns + pkgs, err := packages.Load(cfg, "./...") + if err != nil { + if ctx.Err() != nil { + return ctx.Err() + } + return fmt.Errorf("recheck selected Go source set: %w", err) + } + if !equalPathLists(selectedEmbeddedAssetPaths(root, pkgs), embedded) { + return fmt.Errorf("embedded asset set changed; rerun analysis") + } + selected, err := selectedPackageSourcePaths(pkgs) + if err != nil { + return fmt.Errorf("recheck selected Go source set: %w", err) + } + if err := ctx.Err(); err != nil { + return err + } + previous := loadedSourcePaths(loaded) + if len(selected) != len(previous) { + return fmt.Errorf("selected Go source set changed; rerun analysis") + } + for i := range selected { + if selected[i] != previous[i] { + return fmt.Errorf("selected Go source set changed; rerun analysis") + } + } + return ctx.Err() +} + +func equalPathLists(left, right []string) bool { + if len(left) != len(right) { + return false + } + for i := range left { + if left[i] != right[i] { + return false + } + } + return true +} + +func parserSourcePaths(sources []LoadedSource) []string { + paths := loadedSourcePaths(sources) + sort.Strings(paths) + return paths +} + +func verifyParserMatchesSelection(pkgs []*packages.Package, sources []LoadedSource) error { + selected, err := selectedPackageSourcePaths(pkgs) + if err != nil { + return err + } + parsed := parserSourcePaths(sources) + if len(parsed) == 0 { + return fmt.Errorf("package loader produced no selected Go source inputs") + } + if len(selected) != len(parsed) { + return fmt.Errorf("package source selection did not match parser inputs") + } + for i := range selected { + if selected[i] != parsed[i] { + return fmt.Errorf("package source selection did not match parser inputs") + } + } + return nil +} diff --git a/sql.go b/sql.go new file mode 100644 index 0000000..7e84830 --- /dev/null +++ b/sql.go @@ -0,0 +1,143 @@ +package contracttrace + +import ( + "strings" + "unicode" +) + +type sqlAccess struct{ table, role string } + +// sqlTokens omits comments and string literals. This inventories simple SQL, +// not a dialect-complete parser or a query validator. +func sqlTokens(q string) []string { + tokens := []string{} + for i := 0; i < len(q); { + if unicode.IsSpace(rune(q[i])) { + i++ + continue + } + if strings.HasPrefix(q[i:], "--") { + for i < len(q) && q[i] != '\n' { + i++ + } + continue + } + if strings.HasPrefix(q[i:], "/*") { + end := strings.Index(q[i+2:], "*/") + if end < 0 { + break + } + i += end + 4 + continue + } + if q[i] == '\'' { + i++ + for i < len(q) { + if q[i] == '\'' { + i++ + if i < len(q) && q[i] == '\'' { + i++ + continue + } + break + } + i++ + } + continue + } + if q[i] == '"' || q[i] == '`' || q[i] == '[' { + close := q[i] + if close == '[' { + close = ']' + } + i++ + start := i + var value strings.Builder + for i < len(q) { + if q[i] == close { + value.WriteString(q[start:i]) + i++ + if i < len(q) && q[i] == close { + value.WriteByte(close) + i++ + start = i + continue + } + break + } + i++ + } + tokens = append(tokens, value.String()) + continue + } + if unicode.IsLetter(rune(q[i])) || q[i] == '_' { + start := i + i++ + for i < len(q) && (unicode.IsLetter(rune(q[i])) || unicode.IsDigit(rune(q[i])) || q[i] == '_') { + i++ + } + tokens = append(tokens, q[start:i]) + continue + } + tokens = append(tokens, string(q[i])) + i++ + } + return tokens +} +func inventorySQLTables(q string) []sqlAccess { + t := sqlTokens(q) + result := []sqlAccess{} + cte := map[string]bool{} + if len(t) > 0 && strings.EqualFold(t[0], "WITH") { + for i := 1; i+1 < len(t); i++ { + if strings.EqualFold(t[i+1], "AS") { + cte[strings.ToLower(t[i])] = true + } + } + } + for i := 0; i+1 < len(t); i++ { + role := "" + switch strings.ToUpper(t[i]) { + case "FROM", "JOIN": + role = "read" + if i > 0 && strings.EqualFold(t[i-1], "DELETE") { + role = "write" + } + case "INTO", "UPDATE": + role = "write" + case "TABLE": + if i > 0 && (strings.EqualFold(t[i-1], "CREATE") || strings.EqualFold(t[i-1], "ALTER") || strings.EqualFold(t[i-1], "DROP")) { + role = "schema" + } + } + if role == "" { + continue + } + j := i + 1 + if strings.EqualFold(t[j], "IF") { + j++ + if j < len(t) && strings.EqualFold(t[j], "NOT") { + j++ + } + if j < len(t) && strings.EqualFold(t[j], "EXISTS") { + j++ + } + } + if j >= len(t) { + continue + } + table := t[j] + if table == "(" || table == "?" || table == "$" { + continue + } + if j+2 < len(t) && t[j+1] == "." { + table += "." + t[j+2] + } + table = strings.ToLower(table) + if cte[table] { + continue + } + result = append(result, sqlAccess{table: table, role: role}) + } + return result +} diff --git a/sql_ast.go b/sql_ast.go new file mode 100644 index 0000000..4ef7e39 --- /dev/null +++ b/sql_ast.go @@ -0,0 +1,157 @@ +package contracttrace + +import ( + "fmt" + "io" + "strings" + + sql "github.com/rqlite/sql" +) + +type sqlVisitor struct { + accesses []sqlAccess + positions []sql.Pos + statements int + scopes []map[string]bool + scopeNodes map[sql.Node]bool + writes map[*sql.QualifiedTableName]bool +} + +func (v *sqlVisitor) add(schema, name *sql.Ident, role string) { + if name == nil { + return + } + table := strings.ToLower(name.Name) + if schema != nil { + table = strings.ToLower(schema.Name) + "." + table + } else if role == "read" { + for i := len(v.scopes) - 1; i >= 0; i-- { + if v.scopes[i][table] { + return + } + } + } + v.accesses = append(v.accesses, sqlAccess{table, role}) + v.positions = append(v.positions, name.NamePos) +} +func (v *sqlVisitor) Visit(node sql.Node) (sql.Visitor, sql.Node, error) { + var with *sql.WithClause + switch n := node.(type) { + case sql.SelectExpr: + // The upstream visitor also treats scalar SELECT wrappers as leaves. + if _, err := sql.Walk(v, n.SelectStatement); err != nil { + return nil, node, err + } + case *sql.SelectExpr: + if _, err := sql.Walk(v, n.SelectStatement); err != nil { + return nil, node, err + } + case *sql.SelectStatement: + with = n.WithClause + case *sql.InsertStatement: + with = n.WithClause + case *sql.UpdateStatement: + with = n.WithClause + case *sql.DeleteStatement: + with = n.WithClause + } + if with != nil { + scope := map[string]bool{} + for _, cte := range with.CTEs { + scope[strings.ToLower(cte.TableName.Name)] = true + } + v.scopes = append(v.scopes, scope) + v.scopeNodes[node] = true + } + switch n := node.(type) { + case *sql.WithClause: + // The pinned upstream walker visits WithClause but not its CTE bodies. + for _, cte := range n.CTEs { + if cte.Select != nil { + if _, err := sql.Walk(v, cte.Select); err != nil { + return nil, node, err + } + } + } + case *sql.QualifiedTableName: + if !v.writes[n] { + v.add(n.Schema, n.Name, "read") + } + case *sql.InsertStatement: + v.add(n.Schema, n.Table, "write") + case *sql.UpdateStatement: + if n.Table != nil { + v.writes[n.Table] = true + v.add(n.Table.Schema, n.Table.Name, "write") + } + case *sql.DeleteStatement: + if n.Table != nil { + v.writes[n.Table] = true + v.add(n.Table.Schema, n.Table.Name, "write") + } + case *sql.CreateTableStatement: + v.add(n.Schema, n.Name, "schema") + case *sql.AlterTableStatement: + v.add(n.Schema, n.Name, "schema") + if n.NewName != nil { + v.add(n.Schema, n.NewName, "schema") + } + case *sql.DropTableStatement: + v.add(n.Schema, n.Name, "schema") + case *sql.CreateVirtualTableStatement: + v.add(n.Schema, n.Name, "schema") + case *sql.CreateViewStatement: + v.add(nil, n.Name, "schema") + case *sql.DropViewStatement: + v.add(nil, n.Name, "schema") + case *sql.CreateIndexStatement: + v.add(n.Schema, n.Table, "schema") + case *sql.CreateTriggerStatement: + v.add(nil, n.Table, "schema") + case *sql.ForeignKeyConstraint: + v.add(nil, n.ForeignTable, "schema_ref") + } + return v, node, nil +} +func (v *sqlVisitor) VisitEnd(node sql.Node) (sql.Node, error) { + if v.scopeNodes[node] { + v.scopes = v.scopes[:len(v.scopes)-1] + } + return node, nil +} + +func parseSQL(q string) sqlAnalysis { + parser := sql.NewParser(strings.NewReader(q)) + v := &sqlVisitor{scopeNodes: map[sql.Node]bool{}, writes: map[*sql.QualifiedTableName]bool{}} + for { + stmt, err := parser.ParseStatement() + if err == io.EOF { + return makeSQLAnalysis(v, nil) + } + if err != nil { + return makeSQLAnalysis(v, fmt.Errorf("SQLite parsing: %w", err)) + } + if _, err := sql.Walk(v, stmt); err != nil { + return makeSQLAnalysis(v, err) + } + v.statements++ + } +} +func parseSQLTables(q string) ([]sqlAccess, error) { + r := parseSQL(q) + result := []sqlAccess{} + for _, f := range r.found { + result = append(result, f.access) + } + return result, r.err +} +func sqlTables(q string) []sqlAccess { + accesses, err := parseSQLTables(q) + if err != nil { + return inventorySQLTables(q) + } + if accesses == nil { + return []sqlAccess{} + } + return accesses +} diff --git a/sql_bound_database_calls_test.go b/sql_bound_database_calls_test.go new file mode 100644 index 0000000..7236d52 --- /dev/null +++ b/sql_bound_database_calls_test.go @@ -0,0 +1,216 @@ +package contracttrace + +import ( + "bytes" + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestBoundDatabaseCallsUseModeledSQLReceivers(t *testing.T) { + root := t.TempDir() + write := func(name, source string) { + t.Helper() + file := filepath.Join(root, name) + if err := os.MkdirAll(filepath.Dir(file), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(file, []byte(source), 0600); err != nil { + t.Fatal(err) + } + } + write("go.mod", "module example.com/boundsql\n\ngo 1.27.0\n") + write("workflow.go", `package boundsql + +import ( + "context" + "database/sql" +) + +type Wrapped struct { *sql.DB } +type unrelated struct{} +func (unrelated) Query() {} +type Queryer interface { Query(string, ...any) (*sql.Rows, error) } + +func OpenPrimary() *sql.DB { + db, _ := sql.Open("driver", "primary") + return db +} +func OpenArchive() *sql.DB { + db, _ := sql.Open("driver", "archive") + return db +} + +func DirectQuery() { + db := OpenPrimary() + db.Query("SELECT id FROM shared_records") +} +func BoundQuery() { + db := OpenPrimary() + query := db.Query + query("SELECT id FROM shared_records") +} +func ConfiguredBoundQuery() { + db := OpenPrimary() + query := db.Query + query("SELECT id FROM configured_records") +} +func runBoundQuery(db *sql.DB, text string) { + query := db.Query + query(text) +} +func QueryThroughHelper() { + runBoundQuery(OpenPrimary(), "SELECT id FROM helper_records") +} +func QueryThroughEmbedding() { + db := OpenPrimary() + wrapped := &Wrapped{DB: db} + query := wrapped.Query + query("SELECT id FROM promoted_records") +} +func QueryMethodExpression() { + db := OpenPrimary() + (*sql.DB).Query(db, "SELECT id FROM expression_records") +} +func BoundExecContext() { + db := OpenPrimary() + exec := db.ExecContext + exec(context.Background(), "UPDATE exec_records SET id = 1") +} +func BoundConnectionQuery() { + db := OpenPrimary() + conn, _ := db.Conn(context.Background()) + query := conn.QueryContext + query(context.Background(), "SELECT id FROM connection_records") +} +func BoundTransactionQuery() { + db := OpenPrimary() + tx, _ := db.Begin() + query := tx.Query + query("SELECT id FROM transaction_records") +} +func ArchiveBoundQuery() { + db := OpenArchive() + query := db.Query + query("SELECT id FROM shared_records") +} +func UnrelatedBoundQuery() { + query := (unrelated{}).Query + query() +} +func UnknownBoundQuery(db *sql.DB) { + query := db.Query + query("SELECT id FROM unknown_records") +} +func queryThroughInterface(q Queryer, text string) { + q.Query(text) +} +func QueryKnownAndOutsideInterface(outside Queryer) { + db := OpenPrimary() + var query Queryer = db + if outside != nil { + query = outside + } + queryThroughInterface(query, "SELECT id FROM interface_records") +} +func PreparedQuery() { + db := OpenPrimary() + stmt, _ := db.Prepare("SELECT id FROM prepared_records") + query := stmt.Query + query("decoy_records") +} +`) + + config := DefaultConfig() + config.StorageScopes = []StorageScope{ + {Namespace: "primary", DatabaseOrigins: []string{"example.com/boundsql::OpenPrimary"}}, + {Namespace: "archive", DatabaseOrigins: []string{"example.com/boundsql::OpenArchive"}}, + } + seeds := []string{ + "DirectQuery", "BoundQuery", "ConfiguredBoundQuery", "QueryThroughHelper", "QueryThroughEmbedding", + "QueryMethodExpression", "BoundExecContext", "BoundConnectionQuery", "BoundTransactionQuery", + "ArchiveBoundQuery", "UnrelatedBoundQuery", "UnknownBoundQuery", "QueryKnownAndOutsideInterface", "PreparedQuery", + } + report, analysis, err := TraceWithAnalysis(context.Background(), Options{Root: root, Seeds: seeds, Depth: 4, MaxNodes: 200, Config: config}) + if err != nil { + t.Fatal(err) + } + var snapshot bytes.Buffer + if err := WriteAnalysis(&snapshot, analysis, true); err != nil { + t.Fatal(err) + } + saved, err := ReadAnalysis(bytes.NewReader(snapshot.Bytes())) + if err != nil { + t.Fatal(err) + } + resumed, err := Explore(context.Background(), saved, ExploreOptions{Seeds: seeds, Depth: 4, MaxNodes: 200}) + if err != nil { + t.Fatal(err) + } + for _, current := range []Report{report, resumed} { + for _, want := range []struct{ owner, table, namespace, kind string }{ + {"DirectQuery", "shared_records", "primary", "sql_read"}, + {"BoundQuery", "shared_records", "primary", "sql_read"}, + {"ConfiguredBoundQuery", "configured_records", "primary", "sql_read"}, + {"runBoundQuery", "helper_records", "primary", "sql_read"}, + {"QueryThroughEmbedding", "promoted_records", "primary", "sql_read"}, + {"QueryMethodExpression", "expression_records", "primary", "sql_read"}, + {"BoundExecContext", "exec_records", "primary", "sql_write"}, + {"BoundConnectionQuery", "connection_records", "primary", "sql_read"}, + {"BoundTransactionQuery", "transaction_records", "primary", "sql_read"}, + {"ArchiveBoundQuery", "shared_records", "archive", "sql_read"}, + {"PreparedQuery", "prepared_records", "primary", "sql_read"}, + } { + owner := "example.com/boundsql::" + want.owner + table := "table:" + want.namespace + ":" + want.table + if !hasRelationship(current, owner, table, want.kind, "workflow.go", 0) { + t.Errorf("%s did not inventory %s from its modeled SQL receiver", want.owner, table) + } + } + for _, edge := range current.Relationships { + if strings.HasSuffix(edge.From, "::UnrelatedBoundQuery") && edge.Kind == "sql_read" { + t.Errorf("unrelated Query method was treated as database/sql: %+v", edge) + } + if strings.HasSuffix(edge.From, "::PreparedQuery") && strings.Contains(edge.To, "decoy_records") && + (edge.Kind == "sql_read" || edge.Kind == "sql_write") { + t.Errorf("prepared statement execution argument was misread as default SQL: %+v", edge) + } + } + if hasRelationship(current, "example.com/boundsql::BoundQuery", "table:archive:shared_records", "sql_read", "workflow.go", 0) || + hasRelationship(current, "example.com/boundsql::ArchiveBoundQuery", "table:primary:shared_records", "sql_read", "workflow.go", 0) { + t.Error("a same-table query leaked across primary/archive receiver namespaces") + } + if !hasRelationship(current, "example.com/boundsql::UnknownBoundQuery", "table:unknown_records", "sql_read", "workflow.go", 0) || + !hasBoundaryKind(current, "example.com/boundsql::UnknownBoundQuery", "unresolved_database_namespace") { + t.Error("unknown bound receiver lost its possible query candidate or namespace uncertainty") + } + if !hasRelationship(current, "example.com/boundsql::queryThroughInterface", "table:primary:interface_records", "sql_read", "workflow.go", 0) || + !hasBoundaryKind(current, "example.com/boundsql::queryThroughInterface", "unresolved_database_namespace") { + t.Error("known interface receiver candidate or outside namespace uncertainty was lost") + } + } + withRule := config + withRule.CallRules = []CallRule{{Symbol: "database/sql::DB.Query", Kind: "sql_query", Argument: 0, Namespace: "rule"}} + configuredReport, err := Trace(context.Background(), Options{Root: root, Seeds: []string{"ConfiguredBoundQuery"}, Depth: 3, MaxNodes: 100, Config: withRule}) + if err != nil { + t.Fatal(err) + } + if !hasRelationship(configuredReport, "example.com/boundsql::ConfiguredBoundQuery", "table:rule:configured_records", "sql_read", "workflow.go", 0) || + hasRelationship(configuredReport, "example.com/boundsql::ConfiguredBoundQuery", "table:primary:configured_records", "sql_read", "workflow.go", 0) { + t.Error("a configured sql_query rule did not take precedence for a bound database method") + } + disabled := config + disabled.SQLMethods = []string{} + disabledReport, err := Trace(context.Background(), Options{Root: root, Seeds: []string{"BoundQuery"}, Depth: 3, MaxNodes: 100, Config: disabled}) + if err != nil { + t.Fatal(err) + } + for _, edge := range disabledReport.Relationships { + if edge.From == "example.com/boundsql::BoundQuery" && (edge.Kind == "sql_read" || edge.Kind == "sql_write") { + t.Error("canonical database/sql method metadata bypassed the configured SQLMethods filter") + break + } + } +} diff --git a/sql_bound_execution_test.go b/sql_bound_execution_test.go new file mode 100644 index 0000000..0df18f0 --- /dev/null +++ b/sql_bound_execution_test.go @@ -0,0 +1,109 @@ +package contracttrace + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestPreparedSQLFindsBoundAndPromotedStatementExecutions(t *testing.T) { + root := t.TempDir() + write := func(name, contents string) { + t.Helper() + if err := os.WriteFile(filepath.Join(root, name), []byte(contents), 0600); err != nil { + t.Fatal(err) + } + } + write("go.mod", "module example.com/boundstatement\n\ngo 1.27.0\n") + write("workflow.go", `package workflow + +import "database/sql" + +type promoted struct { *sql.Stmt } +type unrelatedQueryer struct{} +func (unrelatedQueryer) Query() {} + +func inspectStatements() { + db, _ := sql.Open("unused", "dsn") + defer db.Close() + rows, _ := db.Query("SELECT id FROM direct_records") + if rows != nil { defer rows.Close() } + stmt, _ := db.Prepare("SELECT id FROM bound_records") + defer stmt.Close() + + run := stmt.Query + run() + wrapped := promoted{Stmt: stmt} + wrapped.Query() + query := (*sql.Stmt).Query + query(stmt) + stmt.Query() +} + +func unrelatedQueryCall() { + var queryer unrelatedQueryer + queryer.Query() +} + +func unknownStatement(stmt *sql.Stmt) { + run := stmt.Query + run("SELECT id FROM argument_decoy") +} + +`) + write("schema.sql", "CREATE TABLE bound_records (id INTEGER);\nCREATE TABLE direct_records (id INTEGER);\nCREATE TABLE argument_decoy (id INTEGER);\n") + config := DefaultConfig() + config.StorageScopes = []StorageScope{{Namespace: "bound", GoFiles: []string{"workflow.go"}, SQLFiles: []string{"schema.sql"}, DatabaseOrigins: []string{"example.com/boundstatement::inspectStatements"}}} + report, err := Trace(context.Background(), Options{Root: root, Seeds: []string{"inspectStatements", "unknownStatement", "unrelatedQueryCall"}, Depth: 5, MaxNodes: 300, Config: config}) + if err != nil { + t.Fatal(err) + } + owners := []struct { + name, snippet string + }{ + {"inspectStatements", "run()"}, + {"inspectStatements", "wrapped.Query()"}, + {"inspectStatements", "query(stmt)"}, + {"inspectStatements", "stmt.Query()"}, + } + for _, want := range owners { + found := false + for _, edge := range report.Relationships { + if edge.Kind == "sql_read" && strings.HasSuffix(edge.From, "::"+want.name) && edge.To == "table:bound:bound_records" && strings.Contains(edge.Evidence.Snippet, want.snippet) { + found = true + } + } + if !found { + t.Errorf("prepared query was not connected to bound_records at %s: %s", want.name, want.snippet) + } + } + unknown := false + for _, edge := range report.Relationships { + if strings.HasSuffix(edge.From, "::unknownStatement") && strings.HasPrefix(edge.To, "table:") { + t.Errorf("bound Query argument was misread as SQL source: %+v", edge) + } + if strings.HasSuffix(edge.From, "::unrelatedQueryCall") && strings.HasPrefix(edge.To, "table:") { + t.Errorf("same-name non-SQL method was treated as a prepared query: %+v", edge) + } + } + direct := false + for _, edge := range report.Relationships { + direct = direct || edge.Kind == "sql_read" && edge.To == "table:bound:direct_records" && strings.Contains(edge.Evidence.Snippet, `db.Query("SELECT id FROM direct_records")`) + } + if !direct { + t.Error("direct database Query control was lost") + } + for _, boundary := range report.Boundaries { + if boundary.Kind == "unresolved_prepared_sql" && (strings.Contains(boundary.Evidence.Snippet, "db.Query(") || strings.Contains(boundary.Evidence.Snippet, "queryer.Query()")) { + t.Errorf("non-statement Query call was classified as prepared SQL: %+v", boundary) + } + } + for _, boundary := range report.Boundaries { + unknown = unknown || boundary.Kind == "unresolved_prepared_sql" && strings.HasSuffix(boundary.Node, "::unknownStatement") + } + if !unknown { + t.Error("opaque prepared statement execution did not retain unresolved query provenance") + } +} diff --git a/sql_factory_inputs_test.go b/sql_factory_inputs_test.go new file mode 100644 index 0000000..a51a831 --- /dev/null +++ b/sql_factory_inputs_test.go @@ -0,0 +1,68 @@ +package contracttrace + +import ( + "context" + "encoding/json" + "strings" + "testing" +) + +func TestOpenSQLFactoriesRetainKnownAndOutsideHandleResults(t *testing.T) { + options := Options{Root: "testdata/sample", Seeds: []string{"SQLFactoryCaller"}, Depth: 4, MaxNodes: 250, + Config: Config{StorageScopes: []StorageScope{{Namespace: "factory", DatabaseOrigins: []string{"example.com/sample::knownSQLFactoryDatabase"}}}}} + fresh, analysis, err := TraceWithAnalysis(context.Background(), options) + if err != nil { + t.Fatal(err) + } + data, err := json.Marshal(analysis) + if err != nil { + t.Fatal(err) + } + loaded, err := ReadAnalysis(strings.NewReader(string(data))) + if err != nil { + t.Fatal(err) + } + resumed, err := Explore(context.Background(), loaded, ExploreOptions{Seeds: options.Seeds, Depth: options.Depth, MaxNodes: options.MaxNodes}) + if err != nil { + t.Fatal(err) + } + for _, report := range []Report{fresh, resumed} { + for _, item := range []struct { + owner, table string + outside bool + }{ + {"openDatabaseFactoryQuery", "factory_database_records", true}, + {"openConnectionFactoryQuery", "factory_connection_records", true}, + {"openTransactionFactoryQuery", "factory_transaction_records", true}, + {"closedDatabaseFactoryQuery", "closed_factory_database_records", false}, + {"closedConnectionFactoryQuery", "closed_factory_connection_records", false}, + {"closedTransactionFactoryQuery", "closed_factory_transaction_records", false}, + } { + tables := map[string]bool{} + for _, edge := range report.Relationships { + if edge.Kind == "sql_read" && strings.HasSuffix(edge.From, "::"+item.owner) { + tables[edge.To] = true + } + } + want := 1 + if item.outside { + want = 2 + } + if len(tables) != want || !tables["table:factory:"+item.table] || tables["table:"+item.table] != item.outside { + t.Errorf("%s result origins: %v", item.owner, tables) + } + } + for _, owner := range []string{"openStatementFactoryQuery", "closedStatementFactoryQuery"} { + known, unknown := false, false + for _, edge := range report.Relationships { + known = known || edge.Kind == "sql_read" && strings.HasSuffix(edge.From, "::"+owner) && edge.To == "table:factory:factory_statement_records" + } + for _, b := range report.Boundaries { + unknown = unknown || b.Kind == "unresolved_prepared_sql" && strings.HasSuffix(b.Node, "::"+owner) + } + if !known || unknown != (owner == "openStatementFactoryQuery") { + t.Errorf("%s statement origins: known=%t unknown=%t", owner, known, unknown) + } + } + } +} diff --git a/sql_handles.go b/sql_handles.go new file mode 100644 index 0000000..4fe47b4 --- /dev/null +++ b/sql_handles.go @@ -0,0 +1,475 @@ +package contracttrace + +import ( + "fmt" + "go/ast" + "go/token" + "go/types" + "sort" + "strings" + + "golang.org/x/tools/go/ssa" +) + +type sqlHandle struct { + owner, symbol string + position token.Pos + certainty string + discarded bool + discardEvidence Evidence + typ types.Type + parent flowValue + hasParent bool + parentUnknown bool + queryUnknown bool + originalUnknown bool + queries map[sqlQueryBinding]bool + originals map[ssa.Value]bool + symbols map[string]bool +} + +type sqlQueryBinding struct { + query ssa.Value + symbol string +} + +type sqlQueryOrigin struct { + handle string + query ssa.Value + symbol string +} + +func isSQLHandleType(t types.Type) bool { + switch sqlReceiver(t) { + case "DB", "Conn", "Tx", "Stmt": + return true + } + return false +} + +func (a *flowAnalysis) sqlExternalResult(common *ssa.CallCommon, ix *index) bool { + if a.get(common.Value).functionUnknown || common.IsInvoke() && a.get(common.Value).interfaceUnknown { + return true + } + targets := a.targets(common) + if len(targets) == 0 { + return false + } + for target := range targets { + if ix.owner(target) == "" || len(target.Blocks) == 0 { + return true + } + } + return false +} + +func (a *flowAnalysis) seedUnresolvedSQLResults(funcs []*ssa.Function) bool { + changed := false + for _, fn := range funcs { + for _, block := range fn.Blocks { + for _, instruction := range block.Instrs { + var call *ssa.Call + var value ssa.Value + switch result := instruction.(type) { + case *ssa.Call: + call, value = result, result + case *ssa.Extract: + call, _ = result.Tuple.(*ssa.Call) + value = result + } + if call == nil || !isSQLHandleType(value.Type()) || a.special[call] != nil || len(a.targets(call.Common())) != 0 { + continue + } + unknown := emptyFlow() + unknown.sqlUnknown = true + if a.put(value, unknown) { + changed = true + } + } + } + } + return changed +} + +// Exported functions remain open to callers outside the selected build even +// when local calls are known. Uncalled local parameters also lack an origin. +func (a *flowAnalysis) seedSQLInputs(funcs []*ssa.Function, ix *index, uncalled bool) bool { + changed := false + called := map[*ssa.Function]bool{} + for _, fn := range funcs { + for _, block := range fn.Blocks { + for _, instruction := range block.Instrs { + if call, ok := instruction.(ssa.CallInstruction); ok { + for target := range a.targets(call.Common()) { + if ix.owner(target) != "" { + called[target] = true + } + } + } + } + } + } + for _, fn := range funcs { + exported := fn.Object() != nil && fn.Object().Exported() + if uncalled == exported { + continue + } + if called[fn] && !exported { + continue + } + for _, parameter := range fn.Params { + if isSQLHandleType(parameter.Type()) { + value := emptyFlow() + value.sqlUnknown = true + if a.put(parameter, value) { + changed = true + } + evidence := ix.evidence(parameter.Pos()) + if parameter.Pos() == token.NoPos { + evidence = ix.evidence(fn.Pos()) + if fn.Pos() == token.NoPos && ix.funcs[ix.owner(fn)] != nil { + evidence = ix.funcs[ix.owner(fn)].node.Evidence + } + evidence.Origin = "synthetic_declaration" + } + reason := "SQL-handle parameter has no modeled local caller and retains an unknown origin" + if exported { + reason = "exported SQL-handle parameter can receive values from outside the selected package/build; local callers do not close its origin set" + } + ix.boundaries = append(ix.boundaries, Boundary{Node: ix.owner(fn), Kind: "external_sql_input", Reason: reason + ": " + parameter.Name(), Evidence: evidence}) + } + } + } + return changed +} + +func sqlReceiver(t types.Type) string { + if pointer, ok := types.Unalias(t).(*types.Pointer); ok { + t = pointer.Elem() + } + named, ok := types.Unalias(t).(*types.Named) + if !ok || named.Obj().Pkg() == nil || named.Obj().Pkg().Path() != "database/sql" { + return "" + } + return named.Obj().Name() +} + +func (a *flowAnalysis) modelSQLHandle(call ssa.CallInstruction, ix *index) { + common := call.Common() + invocations, _ := a.sqlInvocations(common) + for _, invocation := range invocations { + kind := "" + queryArgument := -1 + originalArgument := -1 + switch { + case invocation.apiFunction && (invocation.name == "Open" || invocation.name == "OpenDB"): + kind = "database" + case invocation.receiverKind == "DB" && invocation.name == "Conn": + kind = "connection" + case strings.HasPrefix(invocation.name, "Prepare"): + kind = "statement" + queryArgument = 0 + if invocation.name == "PrepareContext" { + queryArgument = 1 + } + case invocation.name == "Begin" || invocation.name == "BeginTx": + kind = "transaction" + case invocation.receiverKind == "Tx" && (invocation.name == "Stmt" || invocation.name == "StmtContext"): + kind = "statement" + originalArgument = 0 + if invocation.name == "StmtContext" { + originalArgument = 1 + } + } + if kind == "" { + continue + } + id := ix.sqlHandleAllocationID(kind, call) + value := emptyFlow() + value.addresses[id] = true + direct, hasResult := call.(*ssa.Call) + if hasResult { + if a.special == nil { + a.special = map[*ssa.Call][]flowValue{} + } + resultCount := 1 + if tuple, ok := direct.Type().(*types.Tuple); ok { + resultCount = tuple.Len() + } + if resultCount < 1 { + resultCount = 1 + } + results := a.special[direct] + for len(results) < resultCount { + results = append(results, emptyFlow()) + } + a.merge(&results[0], value) + a.special[direct] = results + } + certainty := "fact" + if !hasResult { + certainty = "possible" + } + if ix.funcs[id] == nil { + ix.funcs[id] = &function{node: Node{ID: id, Name: kind + " creation", Kind: kind, Evidence: ix.callEvidence(call)}} + } + if a.sqlHandles == nil { + a.sqlHandles = map[string]sqlHandle{} + } + site, exists := a.sqlHandles[id] + if !exists { + var handleType types.Type + if signature, ok := invocation.method.Type().(*types.Signature); ok && signature.Results().Len() > 0 { + handleType = signature.Results().At(0).Type() + } + site = sqlHandle{owner: ix.owner(call.Parent()), position: common.Pos(), certainty: certainty, typ: handleType, parent: emptyFlow(), queries: map[sqlQueryBinding]bool{}, originals: map[ssa.Value]bool{}, symbols: map[string]bool{}} + } + if !hasResult { + site.discarded = true + site.discardEvidence = ix.callEvidence(call) + } + site.symbol = sqlInvocationSymbol(invocation) + site.symbols[site.symbol] = true + if invocation.receiverKind != "" { + site.hasParent = true + a.merge(&site.parent, invocation.receiver) + site.parentUnknown = site.parentUnknown || invocation.unknownReceiver + } + if queryArgument >= 0 { + if queryArgument < len(invocation.arguments) { + site.queries[sqlQueryBinding{query: invocation.arguments[queryArgument], symbol: site.symbol}] = true + } else { + site.queryUnknown = true + } + } + if originalArgument >= 0 { + if originalArgument < len(invocation.arguments) { + site.originals[invocation.arguments[originalArgument]] = true + } else { + site.originalUnknown = true + } + } + a.sqlHandles[id] = site + } +} + +func (ix *index) sqlHandleAllocationID(kind string, call ssa.CallInstruction) string { + if value, ok := call.(ssa.Value); ok { + return ix.allocationID(kind, call.Parent(), value) + } + evidence := ix.callEvidence(call) + return fmt.Sprintf("%s:%s:%s:%d:%d:call", kind, call.Parent().String(), evidence.File, evidence.Line, evidence.Column) +} + +func (a *flowAnalysis) sqlHandleUse(call ssa.CallInstruction, ix *index) { + invocations, unknown := a.sqlInvocations(call.Common()) + for _, invocation := range invocations { + receiver, name := invocation.receiverKind, invocation.name + value := invocation.receiver + position := call.Common().Pos() + query := strings.HasPrefix(name, "Exec") || strings.HasPrefix(name, "Query") || strings.HasPrefix(name, "Prepare") + if query { + a.recordSQLSemanticCall(position, invocation) + if a.sqlReceivers == nil { + a.sqlReceivers = map[token.Pos]flowValue{} + } + oldReceiver := a.sqlReceivers[position] + a.merge(&oldReceiver, value) + oldReceiver.sqlUnknown = oldReceiver.sqlUnknown || invocation.unknownReceiver || unknown + a.sqlReceivers[position] = oldReceiver + operation := ix.lifecycleSite(call, "sql_operation", receiver+"."+name+" SQL operation") + ix.callEdge(ix.owner(call.Parent()), operation, "sql_operation", "fact", call) + if a.sqlOperations == nil { + a.sqlOperations = map[token.Pos]map[string]bool{} + } + if a.sqlOperations[position] == nil { + a.sqlOperations[position] = map[string]bool{} + } + a.sqlOperations[position][operation] = true + resources := a.sqlResources(value, receiver) + for _, resource := range resources { + ix.callEdge(operation, resource, "sql_receiver", "possible", call) + } + if len(resources) == 0 || invocation.unknownReceiver || unknown { + ix.boundaries = append(ix.boundaries, Boundary{Node: operation, Kind: "unresolved_sql_handle", Reason: "SQL operation includes a candidate without a modeled receiver handle origin or an outside target", Evidence: ix.callEvidence(call)}) + } + } + if receiver == "Stmt" && (strings.HasPrefix(name, "Exec") || strings.HasPrefix(name, "Query")) { + if a.statementExecutions == nil { + a.statementExecutions = map[token.Pos]map[string]flowValue{} + } + if a.statementExecutions[position] == nil { + a.statementExecutions[position] = map[string]flowValue{} + } + old := a.statementExecutions[position][name] + a.merge(&old, value) + a.statementExecutions[position][name] = old + } + kind := "" + if receiver == "Stmt" && name == "Close" { + kind = "statement_close" + } + if receiver == "DB" && name == "Close" { + kind = "database_close" + } + if receiver == "Conn" && name == "Close" { + kind = "connection_close" + } + if receiver == "Stmt" && (strings.HasPrefix(name, "Exec") || strings.HasPrefix(name, "Query")) { + kind = "statement_execute" + } + if receiver == "Tx" && (name == "Commit" || name == "Rollback") { + kind = "transaction_" + strings.ToLower(name) + } + if kind == "" { + continue + } + resources := a.sqlResources(value, receiver) + for _, resource := range resources { + ix.callEdge(ix.owner(call.Parent()), resource, kind, "possible", call) + } + if len(resources) == 0 || invocation.unknownReceiver || unknown { + ix.boundaries = append(ix.boundaries, Boundary{Node: ix.owner(call.Parent()), Kind: "unresolved_sql_handle", Reason: "database/sql operation includes a candidate without a modeled receiver handle origin or an outside target", Evidence: ix.callEvidence(call)}) + } + } +} + +func (a *flowAnalysis) sqlResources(receiverValue flowValue, receiver string) []string { + prefix := "statement:" + if receiver == "Tx" { + prefix = "transaction:" + } else if receiver == "DB" { + prefix = "database:" + } else if receiver == "Conn" { + prefix = "connection:" + } + var resources []string + for _, alias := range sortedKeys(receiverValue.addresses) { + if strings.HasPrefix(alias, prefix) { + resources = append(resources, alias) + } + } + return resources +} + +func (ix *index) sqlTableAccess(owner, key, table, role string, call *ast.CallExpr) { + ix.resource(owner, key, table, "table", "sql_"+role, call.Pos()) + if ix.flow != nil { + for _, operation := range sortedKeys(ix.flow.sqlOperations[call.Lparen]) { + ix.resource(operation, key, table, "table", "sql_"+role, call.Pos()) + } + } +} + +// Follow recorded rebinding aliases after value-flow convergence. A visited set +// bounds cyclic source paths without dropping other reachable preparation sites. +func (a *flowAnalysis) sqlQueryOrigins(key string) ([]sqlQueryOrigin, bool) { + queue := []string{key} + visited := map[string]bool{} + origins := map[sqlQueryOrigin]bool{} + unresolved := false + for head := 0; head < len(queue); head++ { + current := queue[head] + if visited[current] { + continue + } + visited[current] = true + site, exists := a.sqlHandles[current] + if !exists { + unresolved = true + continue + } + for binding := range site.queries { + origins[sqlQueryOrigin{handle: current, query: binding.query, symbol: binding.symbol}] = true + } + unresolved = unresolved || site.queryUnknown || site.originalUnknown + if len(site.originals) == 0 { + if len(site.queries) == 0 { + unresolved = true + } + continue + } + for _, candidate := range sortedSSAValues(site.originals) { + original := a.get(candidate) + unresolved = unresolved || original.sqlUnknown || original.interfaceUnknown + aliases := sortedKeys(original.addresses) + if len(aliases) == 0 { + unresolved = true + } + queue = append(queue, aliases...) + } + } + result := make([]sqlQueryOrigin, 0, len(origins)) + for origin := range origins { + result = append(result, origin) + } + sort.Slice(result, func(i, j int) bool { + if result[i].handle != result[j].handle { + return result[i].handle < result[j].handle + } + if result[i].symbol != result[j].symbol { + return result[i].symbol < result[j].symbol + } + return result[i].query.String() < result[j].query.String() + }) + return result, unresolved || len(result) == 0 +} + +func (ix *index) preparedSQL(id string, call *ast.CallExpr, config Config) (bool, error) { + if ix.flow == nil { + return false, nil + } + executions := ix.flow.statementExecutions[call.Lparen] + var execution flowValue + configured := false + for _, name := range sortedKeys(executions) { + if !contains(config.SQLMethods, name) { + continue + } + configured = true + ix.flow.merge(&execution, executions[name]) + } + if !configured { + return false, nil + } + found := false + unresolved := false + keys := sortedKeys(execution.addresses) + unresolved = len(keys) == 0 || execution.sqlUnknown || execution.interfaceUnknown + for _, key := range keys { + origins, missing := ix.flow.sqlQueryOrigins(key) + unresolved = unresolved || missing + for _, origin := range origins { + site := ix.flow.sqlHandles[origin.handle] + namespaceOverride := "" + for _, rule := range config.CallRules { + if rule.Symbol == origin.symbol && rule.Kind == "sql_query" { + namespaceOverride = rule.Namespace + } + } + namespaces, err := ix.receiverNamespaces(site.owner, ix.evidence(site.position), []string{origin.handle}, execution.sqlUnknown || execution.interfaceUnknown, namespaceOverride, config) + if err != nil { + return true, err + } + queryValue := ix.flow.get(origin.query) + queries := sortedKeys(queryValue.strings) + unresolved = unresolved || queryValue.stringUnknown + if len(queries) == 0 { + unresolved = true + } + for _, query := range queries { + found = true + for _, access := range ix.sqlAccesses(key, query, ix.evidence(site.position)) { + for _, namespace := range namespaces { + ix.resource(origin.handle, resourceID("table", namespace, access.access.table), access.access.table, "table", "prepared_sql_"+access.access.role, site.position) + ix.sqlTableAccess(id, resourceID("table", namespace, access.access.table), access.access.table, access.access.role, call) + } + } + } + } + } + if !found || unresolved { + ix.boundaries = append(ix.boundaries, Boundary{Node: id, Kind: "unresolved_prepared_sql", Reason: "prepared execution includes an unresolved query origin or additional unknown query strings alongside known candidates; bound parameters are not SQL source", Evidence: ix.evidence(call.Pos())}) + } + return true, nil +} diff --git a/sql_invocations.go b/sql_invocations.go new file mode 100644 index 0000000..521a172 --- /dev/null +++ b/sql_invocations.go @@ -0,0 +1,320 @@ +package contracttrace + +import ( + "fmt" + "go/token" + "go/types" + "sort" + + "golang.org/x/tools/go/ssa" +) + +// sqlInvocation is the normalized call-site view shared by SQL handle +// construction, operations and cleanup. arguments contains only the declared +// API arguments; the method receiver is carried separately. +type sqlInvocation struct { + target *ssa.Function + method *types.Func + receiverKind string + name string + receiver flowValue + arguments []ssa.Value + unknownReceiver bool + apiFunction bool +} + +// sqlSemanticCall carries the canonical SQL method identity from the SSA call +// site to the source-oriented SQL inventory pass. The AST call may be a local +// method value rather than a selector expression. +type sqlSemanticCall struct { + method *types.Func + receiverKind string + name string +} + +func isSQLHandleMethodTarget(target *ssa.Function) (*types.Func, bool) { + if target == nil { + return nil, false + } + method, ok := target.Object().(*types.Func) + if !ok || method.Pkg() == nil || method.Pkg().Path() != "database/sql" { + return nil, false + } + signature, ok := method.Type().(*types.Signature) + if !ok || signature.Recv() == nil { + return nil, false + } + switch sqlReceiver(signature.Recv().Type()) { + case "DB", "Conn", "Stmt", "Tx": + return method.Origin(), true + default: + return nil, false + } +} + +func sqlInvocationSymbol(invocation sqlInvocation) string { + if invocation.method == nil || invocation.method.Pkg() == nil { + return "" + } + return invocation.method.Pkg().Path() + "::" + functionName(invocation.method) +} + +func (a *flowAnalysis) recordSQLSemanticCall(position token.Pos, invocation sqlInvocation) { + if a.sqlCalls == nil { + a.sqlCalls = map[token.Pos]map[string]sqlSemanticCall{} + } + if a.sqlCalls[position] == nil { + a.sqlCalls[position] = map[string]sqlSemanticCall{} + } + key := sqlInvocationSymbol(invocation) + if key == "" { + return + } + if _, ok := a.sqlCalls[position][key]; ok { + return + } + if len(a.sqlCalls[position]) >= maxFlowValues { + a.coverage.Widened = true + return + } + a.sqlCalls[position][key] = sqlSemanticCall{ + method: invocation.method, receiverKind: invocation.receiverKind, name: invocation.name, + } +} + +// sqlInvocations recognizes the declared database/sql API behind direct calls, +// method-expression thunks and target-keyed bound-method closures. Receiver +// candidates stay attached to their actual SSA target, so wrappers with the +// same declared method do not share arbitrary FreeVars. +func (a *flowAnalysis) sqlInvocations(common *ssa.CallCommon) ([]sqlInvocation, bool) { + callable := a.get(common.Value) + unknown := callable.functionUnknown || callable.functionNil || callable.interfaceUnknown || callable.boundReceiverUnknown + if target := common.StaticCallee(); target != nil { + if object, ok := target.Object().(*types.Func); ok && (object.Pkg() == nil || object.Pkg().Path() != "database/sql") { + return nil, false + } + } + var result []sqlInvocation + for _, target := range sortedFunctions(a.targets(common)) { + if target.Signature == nil { + unknown = true + continue + } + object, ok := target.Object().(*types.Func) + if !ok || object.Pkg() == nil || object.Pkg().Path() != "database/sql" { + continue + } + apiSignature, ok := object.Type().(*types.Signature) + if !ok { + continue + } + if apiSignature.Recv() == nil { + if object.Name() != "Open" && object.Name() != "OpenDB" { + continue + } + arguments := append([]ssa.Value(nil), common.Args...) + if !sqlArgumentsFit(apiSignature, arguments) { + unknown = true + } + result = append(result, sqlInvocation{target: target, method: object.Origin(), name: object.Name(), + arguments: arguments, unknownReceiver: unknown, apiFunction: true}) + continue + } + + receiverKind := sqlReceiver(apiSignature.Recv().Type()) + if receiverKind != "DB" && receiverKind != "Conn" && receiverKind != "Stmt" && receiverKind != "Tx" { + continue + } + invocation := sqlInvocation{target: target, method: object.Origin(), receiverKind: receiverKind, name: object.Name(), unknownReceiver: unknown} + argumentStart := -1 + switch { + case common.IsInvoke(): + // SSA interface invokes store the receiver in Value and only explicit + // API arguments in Args. Concrete API targets remain possible; outside + // dispatch is preserved separately through interfaceUnknown. + receiverType := apiSignature.Recv().Type() + if target.Signature.Recv() != nil { + receiverType = target.Signature.Recv().Type() + } + invocation.receiver, invocation.unknownReceiver = a.projectSQLReceiver(a.get(common.Value), receiverType, object.Origin(), invocation.unknownReceiver) + argumentStart = 0 + case target.Signature.Recv() != nil: + // A normal direct method call has the receiver as SSA argument zero. + if len(common.Args) == 0 { + invocation.unknownReceiver = true + break + } + invocation.receiver, invocation.unknownReceiver = a.projectSQLReceiver(a.get(common.Args[0]), common.Args[0].Type(), object.Origin(), invocation.unknownReceiver) + argumentStart = 1 + case target.Signature.Params().Len() == apiSignature.Params().Len()+1: + // A generated method-expression thunk has an explicit receiver + // parameter followed by the declared API parameters. + if len(common.Args) == 0 { + invocation.unknownReceiver = true + break + } + invocation.receiver, invocation.unknownReceiver = a.projectSQLReceiver(a.get(common.Args[0]), common.Args[0].Type(), object.Origin(), invocation.unknownReceiver) + argumentStart = 1 + case target.Signature.Params().Len() == apiSignature.Params().Len(): + // A bound method wrapper omits its receiver from invocation arguments. + argumentStart = 0 + if captured, exists := callable.boundReceivers[target]; exists { + invocation.receiver.addresses = snapshotCandidates(captured.addresses) + invocation.receiver.sqlUnknown = captured.unknown + invocation.unknownReceiver = invocation.unknownReceiver || captured.unknown || len(captured.addresses) == 0 + } else { + invocation.unknownReceiver = true + } + default: + invocation.unknownReceiver = true + } + if argumentStart >= 0 && argumentStart <= len(common.Args) { + invocation.arguments = append([]ssa.Value(nil), common.Args[argumentStart:]...) + if !sqlArgumentsFit(apiSignature, invocation.arguments) { + invocation.unknownReceiver = true + } + } + result = append(result, invocation) + } + if common.IsInvoke() && common.Method != nil { + inferred, hasUnknown := a.sqlInterfaceInvocations(common, callable) + result = append(result, inferred...) + unknown = unknown || hasUnknown + } + return result, unknown +} + +// A CHA graph may omit implementations supplied by a dependency when an +// interface is declared locally. Recover only candidates whose actual receiver +// already has a modeled database/sql handle origin and whose method-set +// signature exactly implements the invoked interface method. +func (a *flowAnalysis) sqlInterfaceInvocations(common *ssa.CallCommon, callable flowValue) ([]sqlInvocation, bool) { + interfaceMethod := common.Method + interfaceSignature, ok := interfaceMethod.Type().(*types.Signature) + if !ok || interfaceSignature.Recv() == nil { + return nil, false + } + interfaceType, ok := types.Unalias(interfaceSignature.Recv().Type()).Underlying().(*types.Interface) + if !ok { + return nil, false + } + interfaceType.Complete() + receiver := a.get(common.Value) + unknown := callable.interfaceUnknown || receiver.interfaceUnknown || receiver.sqlUnknown || receiver.boundReceiverUnknown + var result []sqlInvocation + for _, address := range sortedKeys(receiver.addresses) { + site, exists := a.sqlHandles[address] + if !exists || site.typ == nil || !types.Implements(site.typ, interfaceType) { + continue + } + selection := types.NewMethodSet(site.typ).Lookup(nil, interfaceMethod.Name()) + if selection == nil { + continue + } + method, ok := selection.Obj().(*types.Func) + if !ok || method.Pkg() == nil || method.Pkg().Path() != "database/sql" { + continue + } + apiSignature, ok := method.Type().(*types.Signature) + if !ok || apiSignature.Recv() == nil || !sqlMethodSignaturesMatch(apiSignature, interfaceSignature) { + continue + } + receiverKind := sqlReceiver(apiSignature.Recv().Type()) + if receiverKind != "DB" && receiverKind != "Conn" && receiverKind != "Stmt" && receiverKind != "Tx" { + continue + } + projected, projectionUnknown := a.projectSQLReceiver(flowValue{addresses: map[string]bool{address: true}}, site.typ, method.Origin(), false) + arguments := append([]ssa.Value(nil), common.Args...) + result = append(result, sqlInvocation{method: method.Origin(), receiverKind: receiverKind, name: method.Name(), receiver: projected, + arguments: arguments, unknownReceiver: unknown || projectionUnknown || !sqlArgumentsFit(apiSignature, arguments)}) + } + return result, unknown +} + +func sqlMethodSignaturesMatch(api, invoked *types.Signature) bool { + if api.Variadic() != invoked.Variadic() || api.Params().Len() != invoked.Params().Len() || api.Results().Len() != invoked.Results().Len() { + return false + } + for i := 0; i < api.Params().Len(); i++ { + if !types.Identical(api.Params().At(i).Type(), invoked.Params().At(i).Type()) { + return false + } + } + for i := 0; i < api.Results().Len(); i++ { + if !types.Identical(api.Results().At(i).Type(), invoked.Results().At(i).Type()) { + return false + } + } + return true +} + +func (a *flowAnalysis) projectSQLReceiver(receiver flowValue, receiverType types.Type, method *types.Func, unknown bool) (flowValue, bool) { + projected, _, status := a.projectMethodExpressionReceiver(receiver, receiverType, method) + if status == methodReceiverProjectionUnsupported { + // An unrecognized wrapper/type shape must not be substituted with the + // outer value as if it were the database/sql receiver. + unknown = true + } + unknown = unknown || projected.sqlUnknown || projected.interfaceUnknown || projected.boundReceiverUnknown + return projected, unknown +} + +func sqlArgumentsFit(signature *types.Signature, arguments []ssa.Value) bool { + if signature == nil { + return false + } + parameters := signature.Params() + fixed := parameters.Len() + if signature.Variadic() { + if fixed == 0 { + return false + } + fixed-- + } else if len(arguments) != fixed { + return false + } + if len(arguments) < fixed { + return false + } + for i, argument := range arguments { + if argument == nil { + return false + } + var formal types.Type + switch { + case i < fixed: + formal = parameters.At(i).Type() + case !signature.Variadic(): + return false + case i == len(arguments)-1 && types.AssignableTo(argument.Type(), parameters.At(parameters.Len()-1).Type()): + // A source-level ... call passes the variadic slice as one argument. + continue + default: + variadic, ok := types.Unalias(parameters.At(parameters.Len() - 1).Type()).Underlying().(*types.Slice) + if !ok { + return false + } + formal = variadic.Elem() + } + if !types.AssignableTo(argument.Type(), formal) { + return false + } + } + return true +} + +func sortedSSAValues(values map[ssa.Value]bool) []ssa.Value { + result := make([]ssa.Value, 0, len(values)) + for value := range values { + result = append(result, value) + } + key := func(value ssa.Value) string { + parent := "" + if function := value.Parent(); function != nil { + parent = function.String() + } + return fmt.Sprintf("%s\x00%010d\x00%s\x00%s", parent, value.Pos(), value.Type(), value.String()) + } + sort.Slice(result, func(i, j int) bool { return key(result[i]) < key(result[j]) }) + return result +} diff --git a/sql_invocations_test.go b/sql_invocations_test.go new file mode 100644 index 0000000..aa5d2a0 --- /dev/null +++ b/sql_invocations_test.go @@ -0,0 +1,209 @@ +package contracttrace + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestBuiltinSQLInvocationFamilyPreservesParentsQueriesAndCleanup(t *testing.T) { + root := t.TempDir() + source := `package sqlfamily +import ( + "context" + "database/sql" +) + +func open() (*sql.DB, error) { return sql.Open("unused", "dsn") } +func queryBound(fn func(string) (*sql.Stmt, error), query string) (*sql.Stmt, error) { return fn(query) } +func beginBound(fn func() (*sql.Tx, error)) (*sql.Tx, error) { return fn() } +type statementPreparer interface { Prepare(string) (*sql.Stmt, error) } +func PrepareThroughInterface(preparer statementPreparer, query string) (*sql.Stmt, error) { return preparer.Prepare(query) } +type fakePreparer struct{} +func (fakePreparer) Prepare(string) (*sql.Stmt, error) { return nil, nil } +func UnrelatedPrepareThroughInterface(preparer statementPreparer, query string) (*sql.Stmt, error) { return preparer.Prepare(query) } + +func SQLFamily() { + db, _ := open() + prepare := db.Prepare + statement, _ := queryBound(prepare, "SELECT body FROM prepared_records") + defer statement.Close() + statement.Query() + + begin := db.Begin + tx, _ := beginBound(begin) + defer tx.Rollback() + stmtMethod := (*sql.Tx).Stmt + rebound := stmtMethod(tx, statement) + defer rebound.Close() + rebound.Query() + stmtContextMethod := (*sql.Tx).StmtContext + reboundWithContext := stmtContextMethod(tx, context.Background(), statement) + defer reboundWithContext.Close() + reboundWithContext.Query() + txPrepare := tx.Prepare + txStatement, _ := queryBound(txPrepare, "SELECT body FROM transaction_records") + defer txStatement.Close() + txStatement.Query() + + ctx := context.Background() + conn, _ := db.Conn(ctx) + connectionTransaction, _ := conn.BeginTx(ctx, nil) + defer connectionTransaction.Rollback() + prepareContext := conn.PrepareContext + connStatement, _ := prepareContext(ctx, "SELECT body FROM connection_records") + defer connStatement.Close() + connStatement.Query() + go conn.Close() + db.Exec("INSERT INTO written_records (body) VALUES (?)", "body") + interfaceStatement, _ := PrepareThroughInterface(db, "SELECT body FROM interface_records") + interfaceStatement.Query() + unrelated, _ := UnrelatedPrepareThroughInterface(fakePreparer{}, "SELECT body FROM unrelated_records") + unrelated.Query() +} +` + if err := os.MkdirAll(root, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example.com/sqlfamily\n\ngo 1.27.0\n"), 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "family.go"), []byte(source), 0600); err != nil { + t.Fatal(err) + } + report, err := Trace(context.Background(), Options{Root: root, Seeds: []string{"SQLFamily"}, Depth: 8, MaxNodes: 500}) + if err != nil { + t.Fatal(err) + } + resources := map[string]string{} + for _, node := range report.Nodes { + if strings.HasPrefix(node.ID, "database:") || strings.HasPrefix(node.ID, "connection:") || strings.HasPrefix(node.ID, "statement:") || strings.HasPrefix(node.ID, "transaction:") { + resources[node.ID] = node.Evidence.Snippet + } + } + resourceFor := func(kind, snippet string) string { + for id, evidence := range resources { + if strings.HasPrefix(id, kind+":") && strings.Contains(evidence, snippet) { + return id + } + } + return "" + } + database := resourceFor("database", `sql.Open("unused", "dsn")`) + connection := resourceFor("connection", `db.Conn(ctx)`) + primaryStatement := resourceFor("statement", `fn(query)`) + connectionStatement := resourceFor("statement", `prepareContext(ctx`) + transaction := resourceFor("transaction", `fn()`) + rebound := resourceFor("statement", `stmtMethod(tx, statement)`) + txRebound := resourceFor("statement", `stmtContextMethod(tx, context.Background(), statement)`) + transactionStatement := resourceFor("statement", `fn(query)`) + connectionTransaction := resourceFor("transaction", `conn.BeginTx(ctx, nil)`) + if database == "" || connection == "" || primaryStatement == "" || connectionStatement == "" || transaction == "" || rebound == "" || txRebound == "" || transactionStatement == "" || connectionTransaction == "" { + t.Fatalf("SQL resource creation sites are missing: db=%q conn=%q stmt=%q connStmt=%q tx=%q rebound=%q txRebound=%q txStmt=%q connTx=%q resources=%v", database, connection, primaryStatement, connectionStatement, transaction, rebound, txRebound, transactionStatement, connectionTransaction, resources) + } + has := func(from, to, kind, snippet string) bool { + for _, edge := range report.Relationships { + if edge.From == from && edge.To == to && edge.Kind == kind && strings.Contains(edge.Evidence.Snippet, snippet) { + return true + } + } + return false + } + if !has(connection, database, "connection_database", "db.Conn(ctx)") { + t.Errorf("Conn result lost its DB parent: conn=%s db=%s", connection, database) + } + if !has(transaction, database, "transaction_database", "fn()") { + t.Errorf("Begin result lost its DB parent: tx=%s db=%s", transaction, database) + } + if !has(rebound, primaryStatement, "statement_rebind", "stmtMethod(tx, statement)") { + t.Errorf("Tx.Stmt result lost its original statement candidate: rebound=%s stmt=%s", rebound, primaryStatement) + } + if !has(txRebound, primaryStatement, "statement_rebind", "stmtContextMethod(tx, context.Background(), statement)") { + t.Errorf("Tx.StmtContext method expression lost its original statement candidate: rebound=%s stmt=%s", txRebound, primaryStatement) + } + if !has(transactionStatement, transaction, "statement_transaction", "fn(query)") { + t.Errorf("Tx.Prepare alias helper lost the transaction parent candidate: stmt=%s tx=%s", transactionStatement, transaction) + } + if !has(connectionTransaction, connection, "transaction_connection", "conn.BeginTx(ctx, nil)") { + t.Errorf("Conn.BeginTx result lost the connection parent: tx=%s conn=%s", connectionTransaction, connection) + } + queryEdge := func(owner, table, snippet string) bool { + for _, edge := range report.Relationships { + if strings.HasSuffix(edge.From, "::"+owner) && edge.Kind == "sql_read" && edge.To == "table:"+table && strings.Contains(edge.Evidence.Snippet, snippet) { + return true + } + } + return false + } + if !queryEdge("SQLFamily", "prepared_records", `statement.Query()`) || !queryEdge("SQLFamily", "connection_records", `connStatement.Query()`) || !queryEdge("SQLFamily", "transaction_records", `txStatement.Query()`) || !queryEdge("PrepareThroughInterface", "interface_records", `preparer.Prepare(query)`) { + var evidence []string + for _, edge := range report.Relationships { + if strings.Contains(edge.From, "PrepareThroughInterface") || strings.Contains(edge.To, "PrepareThroughInterface") || strings.Contains(edge.Evidence.Snippet, "interface") || strings.Contains(edge.To, "interface_records") { + evidence = append(evidence, edge.From+" -> "+edge.To+" ["+edge.Kind+"] "+edge.Evidence.Snippet) + } + } + for _, boundary := range report.Boundaries { + if strings.Contains(boundary.Evidence.Snippet, "interface") || strings.Contains(boundary.Evidence.Snippet, "preparer.Prepare") { + evidence = append(evidence, boundary.Node+" ["+boundary.Kind+"] "+boundary.Evidence.Snippet) + } + } + t.Errorf("prepared SQL query origins were lost: prepared=%t connection=%t transaction=%t interface=%t evidence=%v", queryEdge("SQLFamily", "prepared_records", `statement.Query()`), queryEdge("SQLFamily", "connection_records", `connStatement.Query()`), queryEdge("SQLFamily", "transaction_records", `txStatement.Query()`), queryEdge("PrepareThroughInterface", "interface_records", `preparer.Prepare(query)`), evidence) + } + write := false + for _, edge := range report.Relationships { + write = write || edge.Kind == "sql_write" && edge.To == "table:written_records" && strings.Contains(edge.Evidence.Snippet, `db.Exec("INSERT INTO written_records`) + } + if !write { + t.Error("variadic DB.Exec invocation lost its write-table candidate") + } + interfaceOperation := "" + interfaceBoundary := false + for _, edge := range report.Relationships { + if edge.Kind == "sql_operation" && strings.HasSuffix(edge.From, "::PrepareThroughInterface") && strings.Contains(edge.Evidence.Snippet, "preparer.Prepare(query)") { + interfaceOperation = edge.To + } + } + operationID := interfaceOperation + for _, edge := range report.Relationships { + if edge.From == operationID && edge.To == database && edge.Kind == "sql_receiver" { + interfaceOperation = "known" + } + } + for _, boundary := range report.Boundaries { + interfaceBoundary = interfaceBoundary || boundary.Kind == "unresolved_sql_handle" && boundary.Node == operationID && strings.Contains(boundary.Evidence.Snippet, "preparer.Prepare(query)") + } + if interfaceOperation != "known" || !interfaceBoundary { + t.Errorf("SQL interface invocation must preserve the modeled DB alongside outside receiver uncertainty: known receiver=%t unresolved boundary=%t", interfaceOperation == "known", interfaceBoundary) + } + for _, edge := range report.Relationships { + if edge.Kind == "sql_read" && edge.To == "table:alpha:unrelated_records" { + t.Errorf("same-signature user-defined preparer was incorrectly assigned the unrelated modeled DB namespace: %+v", edge) + } + } + cleanupHas := func(resource, kind, snippet string) bool { + for _, edge := range report.Relationships { + if edge.To == resource && edge.Kind == kind && strings.Contains(edge.Evidence.Snippet, snippet) { + return true + } + } + return false + } + for _, want := range []struct{ resource, kind, snippet string }{ + {primaryStatement, "cleanup_statement_close", `statement.Close()`}, + {rebound, "cleanup_statement_close", `rebound.Close()`}, + {txRebound, "cleanup_statement_close", `reboundWithContext.Close()`}, + {transactionStatement, "cleanup_statement_close", `txStatement.Close()`}, + {connectionStatement, "cleanup_statement_close", `connStatement.Close()`}, + {transaction, "cleanup_transaction_rollback", `tx.Rollback()`}, + {connectionTransaction, "cleanup_transaction_rollback", `connectionTransaction.Rollback()`}, + } { + if !cleanupHas(want.resource, want.kind, want.snippet) { + t.Errorf("missing source-site cleanup registration resource=%s kind=%s site=%s", want.resource, want.kind, want.snippet) + } + } + if !has("example.com/sqlfamily::SQLFamily", connection, "connection_close", "go conn.Close()") { + t.Errorf("goroutine Close site lost the exact connection resource: %s", connection) + } +} diff --git a/sql_test.go b/sql_test.go new file mode 100644 index 0000000..040f4d5 --- /dev/null +++ b/sql_test.go @@ -0,0 +1,35 @@ +package contracttrace + +import ( + "reflect" + "testing" +) + +func TestSQLInventory(t *testing.T) { + cases := []struct { + query string + want []sqlAccess + }{ + {`SELECT 'FROM unrelated' FROM "records" -- JOIN wrong + /* JOIN wrong */ JOIN tasks ON 1=1`, []sqlAccess{{"records", "read"}, {"tasks", "read"}}}, + {`INSERT INTO records SELECT body FROM events`, []sqlAccess{{"records", "write"}, {"events", "read"}}}, + {`WITH history AS (SELECT * FROM events) SELECT * FROM history`, []sqlAccess{{"events", "read"}}}, + {`DELETE FROM records`, []sqlAccess{{"records", "write"}}}, + {`UPDATE records SET body=?`, []sqlAccess{{"records", "write"}}}, + {`CREATE TABLE IF NOT EXISTS records (id TEXT)`, []sqlAccess{{"records", "schema"}}}, + {`DROP TABLE IF EXISTS records`, []sqlAccess{{"records", "schema"}}}, + {`SELECT * FROM main.records`, []sqlAccess{{"main.records", "read"}}}, + {`CREATE INDEX records_idx ON records(body)`, []sqlAccess{{"records", "schema"}}}, + {`CREATE TABLE child (parent_id REFERENCES parent(id))`, []sqlAccess{{"child", "schema"}, {"parent", "schema_ref"}}}, + {`CREATE TRIGGER record_audit AFTER INSERT ON records BEGIN INSERT INTO audit_log VALUES (NEW.body); END`, []sqlAccess{{"records", "schema"}, {"audit_log", "write"}}}, + {`WITH history(id) AS (SELECT id FROM events) SELECT * FROM history, records`, []sqlAccess{{"events", "read"}, {"records", "read"}}}, + {`SELECT (SELECT COUNT(*) FROM audit_log) FROM records`, []sqlAccess{{"audit_log", "read"}, {"records", "read"}}}, + {`SELECT * FROM (SELECT * FROM events) AS e JOIN tasks t ON e.id=t.id`, []sqlAccess{{"events", "read"}, {"tasks", "read"}}}, + {`WITH records AS (SELECT * FROM base) SELECT * FROM records`, []sqlAccess{{"base", "read"}}}, + } + for _, c := range cases { + if got := sqlTables(c.query); !reflect.DeepEqual(got, c.want) { + t.Errorf("%s: got %v want %v", c.query, got, c.want) + } + } +} diff --git a/storage.go b/storage.go new file mode 100644 index 0000000..15c2380 --- /dev/null +++ b/storage.go @@ -0,0 +1,178 @@ +package contracttrace + +import ( + "context" + "fmt" + "os" + "path" + "path/filepath" + "sort" + "strings" +) + +type sqlFound struct { + access sqlAccess + line, column int +} +type sqlAnalysis struct { + found []sqlFound + statements int + err error +} + +func (ix *index) parsedSQL(texts []string) bool { + for _, text := range texts { + if r, ok := ix.sqlCache[text]; !ok || r.err != nil { + return false + } + } + return len(texts) > 0 +} + +func makeSQLAnalysis(v *sqlVisitor, err error) sqlAnalysis { + result := sqlAnalysis{statements: v.statements, err: err} + for i, a := range v.accesses { + p := v.positions[i] + result.found = append(result.found, sqlFound{a, p.Line, p.Column}) + } + return result +} +func (ix *index) sqlAccesses(owner, query string, evidence Evidence) []sqlFound { + if ix.sqlCache == nil { + ix.sqlCache = map[string]sqlAnalysis{} + } + r, exists := ix.sqlCache[query] + if !exists { + ix.storage.Queries++ + if ix.storage.Dialect == "inventory" { + r.err = fmt.Errorf("inventory mode has no dialect parser") + } else { + r = parseSQL(query) + } + if r.err != nil { + ix.storage.ParseFailures++ + for _, access := range inventorySQLTables(query) { + r.found = append(r.found, sqlFound{access, 1, 1}) + } + } + ix.storage.ParsedStatements += r.statements + ix.sqlCache[query] = r + } + if r.err != nil { + ix.boundaries = append(ix.boundaries, Boundary{Node: owner, Kind: "sql_parse_failure", Reason: r.err.Error() + "; inventory fallback is provisional and may omit relationships", Evidence: evidence}) + } + return r.found +} +func (ix *index) sqlFiles(ctx context.Context, o Options) error { + rootFS, err := os.OpenRoot(ix.root) + if err != nil { + return err + } + defer rootFS.Close() + ix.storage.Files = []string{} + ix.storage.ExcludedFiles = []string{} + err = filepath.WalkDir(ix.root, func(file string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if err := ctx.Err(); err != nil { + return err + } + if d.IsDir() { + if d.Name() == ".git" || d.Name() == "vendor" || d.Name() == ".gograph" { + return filepath.SkipDir + } + // A selected Root follows one Go module tree; SQL selectors do not + // extend the inventory into a nested module. + if filepath.Clean(file) != filepath.Clean(ix.root) { + manifest := filepath.Join(file, "go.mod") + if info, err := os.Stat(manifest); err == nil && !info.IsDir() { + return filepath.SkipDir + } else if err != nil && !os.IsNotExist(err) { + return err + } + } + return nil + } + if !strings.HasSuffix(strings.ToLower(file), ".sql") { + return nil + } + rel, _ := relative(ix.root, file) + if d.Type()&os.ModeSymlink != 0 { + return fmt.Errorf("SQL file symlinks are not supported: %s", rel) + } + selected := false + for _, pattern := range o.Config.SQLFiles { + if globMatch(pattern, rel) { + selected = true + break + } + } + if !o.Tests { + for _, segment := range strings.Split(rel, "/") { + if segment == "testdata" { + selected = false + } + } + } + if !selected { + ix.storage.ExcludedFiles = append(ix.storage.ExcludedFiles, rel) + return nil + } + body, err := rootFS.ReadFile(filepath.FromSlash(rel)) + if err != nil { + return err + } + lines := strings.Split(string(body), "\n") + namespace, err := scopedStorageFile(rel, true, o.Config) + if err != nil { + return err + } + origin := "sql_file_rule" + if namespace == "" { + origin = "unscoped" + } + ix.assignStorage(rel, namespace, origin) + key := resourceID("sql_file", namespace, rel) + evidence := Evidence{File: rel, Line: 1, Column: 1, Snippet: strings.TrimSpace(lines[0])} + ix.funcs[key] = &function{node: Node{ID: key, Name: rel, Kind: "sql_file", Evidence: evidence}} + if namespace == "" && len(o.Config.StorageScopes) > 0 { + ix.boundaries = append(ix.boundaries, Boundary{Node: key, Kind: "unresolved_storage_namespace", Reason: "SQL file has no configured database namespace; unscoped inventory may alias configured databases", Evidence: evidence}) + } + ix.storage.Files = append(ix.storage.Files, rel) + for _, found := range ix.sqlAccesses(key, string(body), evidence) { + ev := Evidence{File: rel, Line: found.line, Column: found.column} + if ev.Line >= 1 && ev.Line <= len(lines) { + ev.Snippet = strings.TrimSpace(lines[ev.Line-1]) + } + table := resourceID("table", namespace, found.access.table) + if ix.funcs[table] == nil { + ix.funcs[table] = &function{node: Node{ID: table, Name: found.access.table, Kind: "table", Evidence: ev}} + } + ix.edges = append(ix.edges, Relationship{From: key, To: table, Kind: "sql_" + found.access.role, Certainty: "possible", Evidence: ev}) + } + return nil + }) + sort.Strings(ix.storage.Files) + sort.Strings(ix.storage.ExcludedFiles) + sortAssignments(&ix.storage) + return err +} +func globMatch(pattern, name string) bool { + p, n := strings.Split(filepath.ToSlash(pattern), "/"), strings.Split(name, "/") + var match func(int, int) bool + match = func(i, j int) bool { + if i == len(p) { + return j == len(n) + } + if p[i] == "**" { + return match(i+1, j) || j < len(n) && match(i, j+1) + } + if j >= len(n) { + return false + } + ok, err := path.Match(p[i], n[j]) + return err == nil && ok && match(i+1, j+1) + } + return match(0, 0) +} diff --git a/storage_scope.go b/storage_scope.go new file mode 100644 index 0000000..5c1e11d --- /dev/null +++ b/storage_scope.go @@ -0,0 +1,187 @@ +package contracttrace + +import ( + "fmt" + "go/ast" + "path" + "sort" + "strings" +) + +func (ix *index) callNamespaces(owner string, call *ast.CallExpr, explicit string, config Config) ([]string, error) { + var receivers []string + unknown := false + if ix.flow != nil { + receivers = sortedKeys(ix.flow.sqlReceivers[call.Lparen].addresses) + unknown = ix.flow.sqlReceivers[call.Lparen].sqlUnknown + } + return ix.receiverNamespaces(owner, ix.evidence(call.Pos()), receivers, unknown, explicit, config) +} + +func (ix *index) databaseOriginBoundaries(config Config) { + matched := map[string]bool{} + if ix.flow != nil { + for key, site := range ix.flow.sqlHandles { + if strings.HasPrefix(key, "database:") { + matched[site.owner] = true + } + } + } + declared := map[string]bool{} + for _, scope := range config.StorageScopes { + for _, origin := range scope.DatabaseOrigins { + declared[origin] = true + } + } + for _, origin := range sortedKeys(declared) { + if !matched[origin] { + ix.boundaries = append(ix.boundaries, Boundary{Kind: "unmatched_database_origin", Reason: "no modeled database constructor in configured owner: " + origin}) + } + } +} + +// Namespace declarations apply to constructor owners, never inferred DSNs. +// Possible receiver roots remain a set; this does not correlate invocations. +func (ix *index) receiverNamespaces(owner string, evidence Evidence, receivers []string, unknown bool, explicit string, config Config) ([]string, error) { + rules := map[string]string{} + for _, scope := range config.StorageScopes { + for _, origin := range scope.DatabaseOrigins { + rules[origin] = scope.Namespace + } + } + if explicit != "" || len(rules) == 0 { + namespace, err := ix.queryNamespace(owner, evidence, explicit, config) + return []string{namespace}, err + } + namespaces := map[string]bool{} + missing := unknown || len(receivers) == 0 || ix.flow == nil + queue := append([]string(nil), receivers...) + visited := map[string]bool{} + rootFound := false + for head := 0; ix.flow != nil && head < len(queue); head++ { + key := queue[head] + if visited[key] { + continue + } + visited[key] = true + site, exists := ix.flow.sqlHandles[key] + if !exists { + missing = true + continue + } + if strings.HasPrefix(key, "database:") { + rootFound = true + if namespace := rules[site.owner]; namespace != "" { + namespaces[namespace] = true + ix.assignStorage(ix.evidence(site.position).File, namespace, "database_origin:"+site.owner) + } else { + missing = true + } + continue + } + if !site.hasParent { + missing = true + continue + } + parent := site.parent + missing = missing || site.parentUnknown || parent.sqlUnknown || parent.interfaceUnknown || parent.boundReceiverUnknown + parents := sortedKeys(parent.addresses) + if len(parents) == 0 { + missing = true + } + queue = append(queue, parents...) + } + missing = missing || !rootFound + if missing { + ix.boundaries = append(ix.boundaries, Boundary{Node: owner, Kind: "unresolved_database_namespace", Reason: "receiver candidates include an unknown or unconfigured database origin; known origin namespaces are retained and remaining candidates use file/unscoped fallback", Evidence: evidence}) + namespace, err := ix.queryNamespace(owner, evidence, "", config) + if err != nil { + return nil, err + } + namespaces[namespace] = true + } + return sortedKeys(namespaces), nil +} + +func validateFileGlob(pattern string) error { + if strings.ContainsAny(pattern, "\\:") || strings.HasPrefix(pattern, "/") { + return fmt.Errorf("file pattern must be a relative slash-separated glob: %q", pattern) + } + for _, segment := range strings.Split(pattern, "/") { + if segment == "" || segment == "." || segment == ".." { + return fmt.Errorf("invalid file pattern %q", pattern) + } + if segment != "**" { + if _, err := path.Match(segment, ""); err != nil { + return fmt.Errorf("invalid file pattern %q: %w", pattern, err) + } + } + } + return nil +} + +func scopedStorageFile(file string, sqlFile bool, config Config) (string, error) { + namespaces := map[string]bool{} + for _, scope := range config.StorageScopes { + patterns := scope.GoFiles + if sqlFile { + patterns = scope.SQLFiles + } + for _, pattern := range patterns { + if globMatch(pattern, file) { + namespaces[scope.Namespace] = true + break + } + } + } + names := sortedKeys(namespaces) + if len(names) > 1 { + return "", fmt.Errorf("ambiguous storage namespace for %s: %s", file, strings.Join(names, ", ")) + } + if len(names) == 1 { + return names[0], nil + } + return "", nil +} + +func (ix *index) assignStorage(file, namespace, origin string) { + if ix.storageAssignments == nil { + ix.storageAssignments = map[string]bool{} + } + key := file + "\x00" + namespace + "\x00" + origin + if !ix.storageAssignments[key] { + ix.storageAssignments[key] = true + ix.storage.Assignments = append(ix.storage.Assignments, StorageAssignment{file, namespace, origin}) + } +} +func (ix *index) queryNamespace(owner string, evidence Evidence, explicit string, config Config) (string, error) { + namespace, origin := explicit, "api_rule" + if namespace == "" { + var err error + namespace, err = scopedStorageFile(evidence.File, false, config) + if err != nil { + return "", err + } + origin = "file_rule" + } + if namespace == "" { + origin = "unscoped" + if len(config.StorageScopes) > 0 { + ix.boundaries = append(ix.boundaries, Boundary{Node: owner, Kind: "unresolved_storage_namespace", Reason: "SQL call has no configured database namespace; its unscoped table inventory may alias configured databases, which are not joined automatically", Evidence: evidence}) + } + } + ix.assignStorage(evidence.File, namespace, origin) + return namespace, nil +} +func sortAssignments(storage *StorageCoverage) { + sort.Slice(storage.Assignments, func(i, j int) bool { + a, b := storage.Assignments[i], storage.Assignments[j] + if a.File != b.File { + return a.File < b.File + } + if a.Namespace != b.Namespace { + return a.Namespace < b.Namespace + } + return a.Origin < b.Origin + }) +} diff --git a/storage_scope_test.go b/storage_scope_test.go new file mode 100644 index 0000000..fabfbea --- /dev/null +++ b/storage_scope_test.go @@ -0,0 +1,166 @@ +package contracttrace + +import ( + "context" + "strings" + "testing" +) + +func multiStoreConfig() Config { + c := DefaultConfig() + c.StorageScopes = []StorageScope{ + {Namespace: "orders", GoFiles: []string{"orders/**/*.go"}, SQLFiles: []string{"migrations/orders/**/*.sql"}}, + {Namespace: "billing", GoFiles: []string{"billing/**/*.go"}, SQLFiles: []string{"migrations/billing/**/*.sql"}}, + } + return c +} + +func TestStorageNamespacesJoinSchemaWithoutCrossingDatabases(t *testing.T) { + r, err := Trace(context.Background(), Options{Root: "testdata/multistore", Seeds: []string{"example.com/multistore/orders::Write"}, Depth: 3, MaxNodes: 100, Config: multiStoreConfig()}) + if err != nil { + t.Fatal(err) + } + present := map[string]bool{} + for _, n := range r.Nodes { + present[n.ID] = true + if strings.Contains(n.ID, "/billing::") || n.ID == "table:billing:records" || strings.Contains(n.Evidence.File, "migrations/billing/") { + t.Errorf("unrelated database joined scope: %s", n.ID) + } + } + for _, id := range []string{"table:orders:records", "table:orders:audit_log", "example.com/multistore/orders::Read", "sql_file:orders:migrations%2Forders%2F001.sql"} { + if !present[id] { + t.Errorf("missing namespaced relationship path: %s", id) + } + } + trigger, fk := false, false + for _, e := range r.Relationships { + if strings.Contains(e.Evidence.File, "migrations/orders/") { + if e.To == "table:orders:audit_log" && e.Kind == "sql_write" { + trigger = true + } + if e.To == "table:orders:records" && e.Kind == "sql_schema_ref" { + fk = true + } + } + } + if !trigger || !fk { + t.Errorf("scoped schema lost trigger or foreign-key paths: trigger=%t fk=%t", trigger, fk) + } + assigned := false + for _, assignment := range r.Coverage.Storage.Assignments { + if assignment.File == "migrations/orders/001.sql" && assignment.Namespace == "orders" { + assigned = true + } + } + if !assigned { + t.Fatal("report omitted the schema namespace assignment") + } +} + +func TestSavedExplorationRetainsStorageIdentity(t *testing.T) { + _, analysis, err := TraceWithAnalysis(context.Background(), Options{Root: "testdata/multistore", Seeds: []string{"example.com/multistore/orders::Write"}, Depth: 1, MaxNodes: 2, Config: multiStoreConfig()}) + if err != nil { + t.Fatal(err) + } + r, err := Explore(context.Background(), analysis, ExploreOptions{Seeds: []string{"table:billing:records"}, Depth: 2, MaxNodes: 100}) + if err != nil { + t.Fatal(err) + } + read := false + for _, n := range r.Nodes { + if n.ID == "example.com/multistore/billing::Read" { + read = true + } + if strings.Contains(n.ID, "/orders::") || n.ID == "table:orders:records" { + t.Errorf("saved exploration crossed databases: %s", n.ID) + } + } + if !read { + t.Fatal("saved namespace resource could not seed its readers") + } +} + +func TestUnconfiguredSQLSitesRemainExplicitBoundaries(t *testing.T) { + c := DefaultConfig() + c.StorageScopes = []StorageScope{{Namespace: "billing", GoFiles: []string{"billing/**"}, SQLFiles: []string{"migrations/billing/**"}}} + r, err := Trace(context.Background(), Options{Root: "testdata/multistore", Seeds: []string{"example.com/multistore/orders::Write"}, Depth: 3, MaxNodes: 100, Config: c}) + if err != nil { + t.Fatal(err) + } + query, schema := false, false + for _, b := range r.Boundaries { + if b.Kind == "unresolved_storage_namespace" { + if b.Node == "example.com/multistore/orders::Write" { + query = true + } + if b.Evidence.File == "migrations/orders/001.sql" { + schema = true + } + } + } + if !query || !schema { + t.Errorf("unconfigured namespaces silently looked resolved: query=%t schema=%t", query, schema) + } +} + +func TestTypedStorageRuleOverridesFileScopeWithoutFallbackJoin(t *testing.T) { + c := DefaultConfig() + c.StorageScopes = []StorageScope{{Namespace: "file_default", GoFiles: []string{"sample.go"}}, {Namespace: "api_database", SQLFiles: []string{"migrations/*.sql"}}} + c.CallRules = []CallRule{{Symbol: "database/sql::DB.Exec", Kind: "sql_query", Argument: 0, Namespace: "api_database"}} + r, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"Write"}, Depth: 3, MaxNodes: 200, Config: c}) + if err != nil { + t.Fatal(err) + } + found := false + operationSites := map[string]bool{} + for _, e := range r.Relationships { + if e.From == "example.com/sample::Write" && e.Kind == "sql_operation" { + operationSites[e.To] = true + } + } + operationTable := false + for _, e := range r.Relationships { + if (e.From == "example.com/sample::Write" || operationSites[e.From]) && strings.HasPrefix(e.Kind, "sql_") && strings.HasPrefix(e.To, "table:") { + if e.To != "table:api_database:records" { + t.Errorf("typed SQL site also joined fallback namespace: %s", e.To) + } else { + found = true + operationTable = operationTable || operationSites[e.From] + } + } + } + if !found || !operationTable || !hasName(r, "migrations/001.sql") { + t.Fatal("explicit API namespace did not join its schema") + } +} + +func TestAmbiguousStorageFileRulesFailExplicitly(t *testing.T) { + for _, sqlFile := range []bool{false, true} { + c := multiStoreConfig() + conflicting := StorageScope{Namespace: "other"} + if sqlFile { + conflicting.SQLFiles = []string{"migrations/orders/**"} + } else { + conflicting.GoFiles = []string{"orders/**"} + } + c.StorageScopes = append(c.StorageScopes, conflicting) + _, err := Trace(context.Background(), Options{Root: "testdata/multistore", Seeds: []string{"example.com/multistore/orders::Write"}, Depth: 3, MaxNodes: 100, Config: c}) + if err == nil || !strings.Contains(err.Error(), "ambiguous storage namespace") { + t.Errorf("ambiguous SQL file=%t assignment was not rejected: %v", sqlFile, err) + } + } +} + +func TestStorageScopeConfigurationRejectsUnusableRules(t *testing.T) { + for _, input := range []string{ + `{"storage_scopes":[{"namespace":"","go_files":["orders/**"]}]}`, + `{"storage_scopes":[{"namespace":" ","sql_files":["schema.sql"]}]}`, + `{"storage_scopes":[{"namespace":"orders"}]}`, + `{"storage_scopes":[{"namespace":"orders","go_files":["["]}]}`, + `{"storage_scopes":[{"namespace":"orders","sql_files":["../schema.sql"]}]}`, + } { + if _, err := ReadConfig(strings.NewReader(input)); err == nil { + t.Errorf("invalid storage scope accepted: %s", input) + } + } +} diff --git a/storage_test.go b/storage_test.go new file mode 100644 index 0000000..14924e5 --- /dev/null +++ b/storage_test.go @@ -0,0 +1,185 @@ +package contracttrace + +import ( + "bytes" + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestExternalSchemaContract(t *testing.T) { + r, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"Write"}, Depth: 3, MaxNodes: 200}) + if err != nil { + t.Fatal(err) + } + if !hasName(r, "migrations/001.sql") { + t.Error("reader/writer contract omitted its migration file") + } + trigger, fk, index := false, false, false + for _, e := range r.Relationships { + if e.Evidence.File == "migrations/001.sql" { + if e.To == "table:audit_log" && e.Kind == "sql_write" && e.Evidence.Line == 6 { + trigger = true + } + if e.To == "table:records" && e.Kind == "sql_schema_ref" && e.Evidence.Line == 3 { + fk = true + } + if e.To == "table:records" && e.Kind == "sql_schema" && e.Evidence.Line == 4 { + index = true + } + } + } + if !trigger || !fk || !index { + t.Errorf("precise schema relationships missing: trigger=%t fk=%t index=%t", trigger, fk, index) + } +} + +func TestEmbeddedSQLValueFlow(t *testing.T) { + r, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"LoadEmbedded"}, Depth: 3, MaxNodes: 200}) + if err != nil { + t.Fatal(err) + } + found := false + for _, e := range r.Relationships { + if e.From == "example.com/sample::LoadEmbedded" && e.To == "table:embedded_records" && e.Kind == "sql_schema" { + found = true + } + } + if !found || !hasName(r, "EmbeddedRead") { + t.Error("embedded schema value did not connect the loader to its persisted-state readers") + } +} +func TestSQLSourceIdentityAndParserFailure(t *testing.T) { + root := t.TempDir() + file := filepath.Join(root, "schema.SQL") + if err := os.WriteFile(file, []byte("CREATE TABLE records(id);"), 0600); err != nil { + t.Fatal(err) + } + before, _, err := fingerprint(root) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(file, []byte("CREATE TABLE records(id, version);"), 0600); err != nil { + t.Fatal(err) + } + after, _, err := fingerprint(root) + if err != nil { + t.Fatal(err) + } + if before == after { + t.Error("SQL source changes did not revoke source identity") + } + if _, err := parseSQLTables("SELECT FROM records"); err == nil { + t.Error("invalid SQL looked fully parsed") + } +} + +func sqlSymlinkFixture(t *testing.T) (root, outsideSQL, symlinkPath, sentinel string) { + t.Helper() + base := t.TempDir() + root = filepath.Join(base, "analysis") + if err := os.MkdirAll(root, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example.com/symlinkfixture\n\ngo 1.27.0\n"), 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "app.go"), []byte("package app\nfunc Seed() {}\n"), 0600); err != nil { + t.Fatal(err) + } + sentinel = "outside-root-sql-sentinel-4fd829" + externalSQL := filepath.Join(base, "external.sql") + if err := os.WriteFile(externalSQL, []byte("-- "+sentinel+"\nCREATE TABLE external_inventory(id TEXT);\n"), 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "schema.sql"), []byte("-- "+sentinel+"\nCREATE TABLE external_inventory(id TEXT);\n"), 0600); err != nil { + t.Fatal(err) + } + return root, externalSQL, filepath.Join(root, "schema.sql"), sentinel +} + +func linkOutsideSQL(t *testing.T, target, link string) { + t.Helper() + if err := os.Symlink(target, link); err != nil { + t.Skipf("platform does not permit creating a symlink: %v", err) + } +} + +func assertSQLSymlinkRejected(t *testing.T, err error, operation, sentinel string) { + t.Helper() + if err == nil { + t.Fatalf("%s accepted SQL symlink to a file outside the analysis root", operation) + } + if !strings.Contains(strings.ToLower(err.Error()), "symlink") { + t.Errorf("%s did not report a clear SQL symlink rejection: %v", operation, err) + } + if strings.Contains(err.Error(), sentinel) { + t.Errorf("%s disclosed outside SQL content in its error", operation) + } +} + +func TestTraceRejectsSQLSymlinkToOutsideRoot(t *testing.T) { + root, externalSQL, link, sentinel := sqlSymlinkFixture(t) + if err := os.Remove(link); err != nil { + t.Fatal(err) + } + linkOutsideSQL(t, externalSQL, link) + report, err := Trace(context.Background(), Options{Root: root, Seeds: []string{"schema.sql"}, Depth: 1, MaxNodes: 20}) + if err == nil { + leaked := false + for _, node := range report.Nodes { + if node.Kind == "sql_file" && strings.Contains(node.Evidence.Snippet, sentinel) { + leaked = true + } + } + t.Errorf("Trace accepted outside SQL symlink (sentinel evidence disclosed=%t)", leaked) + } + assertSQLSymlinkRejected(t, err, "Trace", sentinel) +} + +func TestTraceRejectsSQLSymlinkEvenWhenExcludedByConfig(t *testing.T) { + root, externalSQL, link, sentinel := sqlSymlinkFixture(t) + if err := os.Remove(link); err != nil { + t.Fatal(err) + } + linkOutsideSQL(t, externalSQL, link) + config := DefaultConfig() + config.SQLFiles = []string{"migrations/**/*.sql"} + _, err := Trace(context.Background(), Options{Root: root, Seeds: []string{"Seed"}, Depth: 1, MaxNodes: 20, Config: config}) + assertSQLSymlinkRejected(t, err, "Trace", sentinel) +} + +func TestExploreRejectsSQLSymlinkAddedAfterSnapshot(t *testing.T) { + root, externalSQL, link, sentinel := sqlSymlinkFixture(t) + _, analysis, err := TraceWithAnalysis(context.Background(), Options{Root: root, Seeds: []string{"schema.sql"}, Depth: 1, MaxNodes: 20}) + if err != nil { + t.Fatalf("TraceWithAnalysis on the clean root: %v", err) + } + var snapshot bytes.Buffer + if err := WriteAnalysis(&snapshot, analysis, true); err != nil { + t.Fatalf("save clean analysis: %v", err) + } + analysis, err = ReadAnalysis(&snapshot) + if err != nil { + t.Fatalf("reload clean analysis: %v", err) + } + if err := os.Remove(link); err != nil { + t.Fatal(err) + } + linkOutsideSQL(t, externalSQL, link) + _, err = Explore(context.Background(), analysis, ExploreOptions{Seeds: []string{"schema.sql"}, Depth: 1, MaxNodes: 20}) + assertSQLSymlinkRejected(t, err, "Explore", sentinel) +} + +func TestTraceRejectsDanglingSQLSymlink(t *testing.T) { + root, _, link, sentinel := sqlSymlinkFixture(t) + if err := os.Remove(link); err != nil { + t.Fatal(err) + } + danglingTarget := filepath.Join(filepath.Dir(root), "missing-external.sql") + linkOutsideSQL(t, danglingTarget, link) + _, err := Trace(context.Background(), Options{Root: root, Seeds: []string{"schema.sql"}, Depth: 1, MaxNodes: 20}) + assertSQLSymlinkRejected(t, err, "Trace", sentinel) +} diff --git a/string_widening_test.go b/string_widening_test.go new file mode 100644 index 0000000..e7cd48f --- /dev/null +++ b/string_widening_test.go @@ -0,0 +1,67 @@ +package contracttrace + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestStringWideningKeepsPossibleWrappingCause(t *testing.T) { + for _, overflow := range []bool{false, true} { + t.Run(fmt.Sprint(overflow), func(t *testing.T) { + root := t.TempDir() + var source strings.Builder + source.WriteString("package widening\nimport (\"errors\"; \"fmt\")\nfunc cause() error { return errors.New(\"cause\") }\nfunc AFormats(n int) string { switch n {\n") + count := maxFlowValues - 1 + if overflow { + count++ + } + for i := 0; i < count; i++ { + fmt.Fprintf(&source, "case %d: return %q\n", i, fmt.Sprintf("display %02d: %%v", i)) + } + last := "display default: %v" + if overflow { + last = "z wrapping: %w" + } + fmt.Fprintf(&source, "default: return %q\n} }\nfunc Wrap(n int) error { return fmt.Errorf(AFormats(n), cause()) }\n", last) + if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example.com/widening\n\ngo 1.27.0\n"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "main.go"), []byte(source.String()), 0644); err != nil { + t.Fatal(err) + } + fresh, saved, err := TraceWithAnalysis(context.Background(), Options{Root: root, Seeds: []string{"cause"}, Depth: 4, MaxNodes: 250}) + if err != nil { + t.Fatal(err) + } + resumed, err := Explore(context.Background(), saved, ExploreOptions{Seeds: []string{"cause"}, Depth: 4, MaxNodes: 250}) + if err != nil { + t.Fatal(err) + } + for _, report := range []Report{fresh, resumed} { + origins := map[string]bool{} + for _, node := range report.Nodes { + if node.Kind == "error_result" && node.Evidence.Line == 3 { + origins[node.ID] = true + } + } + if len(origins) != 1 { + t.Fatal("missing cause origin") + } + wrapped, unknown := false, false + for _, edge := range report.Relationships { + wrapped = wrapped || edge.Kind == "error_return" && strings.HasSuffix(edge.From, "::Wrap") && origins[edge.To] + } + for _, boundary := range report.Boundaries { + unknown = unknown || boundary.Kind == "error_wrap_format" && strings.HasSuffix(boundary.Node, "::Wrap") + } + if wrapped != overflow || unknown != overflow || report.Coverage.ValueFlow.Widened != overflow { + t.Errorf("overflow=%v wrapped=%v unknown=%v widened=%v", overflow, wrapped, unknown, report.Coverage.ValueFlow.Widened) + } + } + }) + } +} diff --git a/summary_escapes.go b/summary_escapes.go new file mode 100644 index 0000000..1ad189b --- /dev/null +++ b/summary_escapes.go @@ -0,0 +1,68 @@ +package contracttrace + +import ( + "strings" + + "golang.org/x/tools/go/ssa" +) + +func (a *flowAnalysis) callableSummaryEscapes(value ssa.Value, from, suffix string, evidence Evidence, ix *index) { + if len(a.contextKeys) == 0 && len(a.afterFuncs) == 0 { + return + } + if cached := a.callbackCache[value]; cached != nil && cached.valid(a, a.get(value)) { + // A reusable callback walk saw no effects anywhere in the accessible + // storage. Effect-bearing walks are volatile and never enter this cache. + return + } + effects := map[string]bool{} + a.accessibleStorageTracked(value, nil, nil, nil, nil, nil, effects) + for _, effect := range sortedKeys(effects) { + key, kind := "", "" + switch { + case strings.HasPrefix(effect, "cancel:"): + key = strings.TrimPrefix(effect, "cancel:") + if _, modeled := a.contextKeys[key]; modeled { + kind = "context_cancel" + } + case strings.HasPrefix(effect, "afterfunc_stop:"): + key = strings.TrimPrefix(effect, "afterfunc_stop:") + if _, modeled := a.afterFuncs[key]; modeled { + kind = "cancellation_stop" + } + case strings.HasPrefix(effect, "afterfunc_dispatch:"): + key = strings.TrimPrefix(effect, "afterfunc_dispatch:") + if _, modeled := a.afterFuncs[key]; modeled { + kind = "cancellation_delivery" + } + } + if kind == "" { + continue + } + ix.edges = append(ix.edges, Relationship{From: from, To: key, Kind: kind + suffix, Certainty: "possible", Evidence: evidence}) + ix.boundaries = append(ix.boundaries, Boundary{Node: from, Kind: "callable_summary_escape", Reason: "a modeled cancellation, registration-stop or internal delivery summary crosses a dependency/outside call, exported result or public global; retention, replacement, invocation, success and timing remain unproved", Evidence: evidence, Examples: []string{kind + ":" + key}}) + } +} + +func (a *flowAnalysis) callableSummaryCallEscapes(call ssa.CallInstruction, ix *index) { + if !a.callbackOutside(call.Common(), ix) { + return + } + start := len(ix.edges) + for _, arg := range call.Common().Args { + a.callableSummaryEscapes(arg, ix.owner(call.Parent()), "_escape", ix.callEvidence(call), ix) + } + for i := start; i < len(ix.edges); i++ { + ix.edges[i].Kind = afterFuncKind(call, ix.edges[i].Kind) + } +} + +func (a *flowAnalysis) callableSummaryReturnEscapes(returned *ssa.Return, ix *index) { + fn := returned.Parent() + if fn.Object() == nil || !fn.Object().Exported() { + return + } + for _, result := range returned.Results { + a.callableSummaryEscapes(result, ix.owner(fn), "_return_escape", ix.evidence(returned.Pos()), ix) + } +} diff --git a/summary_escapes_test.go b/summary_escapes_test.go new file mode 100644 index 0000000..eb3b062 --- /dev/null +++ b/summary_escapes_test.go @@ -0,0 +1,63 @@ +package contracttrace + +import ( + "context" + "go/token" + "go/types" + "strings" + "testing" + + "golang.org/x/tools/go/ssa" +) + +func TestLifecycleEscapeSeesEffectOnlyMutationAfterCachedWalk(t *testing.T) { + signature := types.NewSignatureType(nil, nil, nil, nil, nil, false) + record := types.NewStruct([]*types.Var{types.NewVar(token.NoPos, nil, "Cancel", signature)}, nil) + value := ssa.NewConst(nil, types.NewPointer(record)) + a := &flowAnalysis{values: map[ssa.Value]flowValue{}, memory: map[string]flowValue{}, contextKeys: map[string]contextSite{"context:mutable": {}}} + root := emptyFlow() + root.addresses["record"] = true + a.put(value, root) + a.accessibleCallbacks(value) + ix := &index{} + a.callableSummaryEscapes(value, "owner", "_escape", Evidence{}, ix) + if len(ix.edges) != 0 { + t.Fatal("empty storage invented lifecycle escape") + } + updated := emptyFlow() + updated.effects["cancel:context:mutable"] = true + a.store("record.field:0", updated) + a.callableSummaryEscapes(value, "owner", "_escape", Evidence{}, ix) + if len(ix.edges) != 1 || ix.edges[0].Kind != "context_cancel_escape" || ix.edges[0].To != "context:mutable" { + t.Fatalf("cached absence hid effect-only mutation: %+v", ix.edges) + } +} + +func TestCallableLifecycleSummariesRetainDependencyReturnAndGlobalEscapes(t *testing.T) { + r, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"SummaryEscapeCaller", "PublicLifecycleSummaries", "PublicCancellationSummary", "EscapingSchedulingCaller"}, Depth: 5, MaxNodes: 250}) + if err != nil { + t.Fatal(err) + } + expected := map[string]bool{} + for _, kind := range []string{"context_cancel_escape", "deferred_context_cancel_escape", "goroutine_context_cancel_escape", "cancellation_stop_escape", "context_cancel_return_escape", "cancellation_stop_return_escape", "context_cancel_global_escape", "cancellation_delivery_escape"} { + expected[kind] = false + } + for _, edge := range r.Relationships { + if _, wanted := expected[edge.Kind]; wanted { + expected[edge.Kind] = true + } + } + for kind, found := range expected { + if !found { + t.Errorf("missing callable summary relationship %s", kind) + } + } + delivery, outsideStop := false, false + for _, boundary := range r.Boundaries { + delivery = delivery || boundary.Kind == "callable_summary_escape" && strings.Contains(boundary.Node, "escapingSchedulingContext.AfterFunc") + outsideStop = outsideStop || boundary.Kind == "unresolved_cancellation_scheduler_stop" + } + if !delivery || !outsideStop { + t.Errorf("external behavior limits: delivery=%t stop=%t", delivery, outsideStop) + } +} diff --git a/testdata/go-env-mutator/main.go b/testdata/go-env-mutator/main.go new file mode 100644 index 0000000..e893053 --- /dev/null +++ b/testdata/go-env-mutator/main.go @@ -0,0 +1,91 @@ +package main + +import ( + "errors" + "fmt" + "os" + "os/exec" + "strconv" +) + +func main() { + realGo := os.Getenv("CONTRACT_TRACE_REAL_GO") + if realGo == "" { + fatal("missing real go executable") + } + args := os.Args[1:] + if !isBuildEnvironmentQuery(args) { + command := exec.Command(realGo, args...) + command.Stdin = os.Stdin + command.Stdout = os.Stdout + command.Stderr = os.Stderr + if err := command.Run(); err != nil { + var exit *exec.ExitError + if errors.As(err, &exit) { + os.Exit(exit.ExitCode()) + } + fatal(err.Error()) + } + return + } + + output, err := exec.Command(realGo, args...).Output() + if err != nil { + fatal(err.Error()) + } + countPath := os.Getenv("CONTRACT_TRACE_ENV_COUNT") + count := 1 + if previous, err := os.ReadFile(countPath); err == nil { + count, err = strconv.Atoi(string(previous)) + if err != nil { + fatal("invalid invocation counter") + } + count++ + } else if !os.IsNotExist(err) { + fatal(err.Error()) + } + if err := os.WriteFile(countPath, []byte(strconv.Itoa(count)), 0600); err != nil { + fatal(err.Error()) + } + + if count == mustInt("CONTRACT_TRACE_MUTATE_ON") { + path := os.Getenv("CONTRACT_TRACE_MUTATE_PATH") + contents := os.Getenv("CONTRACT_TRACE_MUTATE_CONTENT") + if path == "" { + fatal("missing mutation path") + } + if err := os.WriteFile(path, []byte(contents), 0600); err != nil { + fatal(err.Error()) + } + if err := os.WriteFile(os.Getenv("CONTRACT_TRACE_MUTATION_MARKER"), []byte(strconv.Itoa(count)), 0600); err != nil { + fatal(err.Error()) + } + } + if _, err := os.Stdout.Write(output); err != nil { + fatal(err.Error()) + } +} + +func isBuildEnvironmentQuery(args []string) bool { + if len(args) < 4 || args[0] != "env" || args[1] != "-json" { + return false + } + seen := map[string]bool{} + for _, arg := range args[2:] { + seen[arg] = true + } + return seen["GOEXPERIMENT"] && seen["GOFIPS140"] +} + +func mustInt(name string) int { + value, err := strconv.Atoi(os.Getenv(name)) + if err != nil { + fatal(fmt.Sprintf("invalid %s: %v", name, err)) + } + return value +} + +func fatal(message string) { + fmt.Fprintln(os.Stderr, message) + os.Exit(1) +} diff --git a/testdata/mixed_dependency/external_only/external.go b/testdata/mixed_dependency/external_only/external.go new file mode 100644 index 0000000..7942ed1 --- /dev/null +++ b/testdata/mixed_dependency/external_only/external.go @@ -0,0 +1,16 @@ +package externalonly + +import ( + "database/sql" + "example.com/replacement" +) + +type Factory interface { + DB() *sql.DB +} + +func ExternalEntry() { + var factory Factory = replacement.Factory{} + db := factory.DB() + db.Query("SELECT * FROM outside_only_records") +} diff --git a/testdata/mixed_dependency/external_only/go.mod b/testdata/mixed_dependency/external_only/go.mod new file mode 100644 index 0000000..9c66370 --- /dev/null +++ b/testdata/mixed_dependency/external_only/go.mod @@ -0,0 +1,6 @@ +module example.com/externalonly + +go 1.27.0 + +require example.com/replacement v0.0.0 +replace example.com/replacement => ../replacement diff --git a/testdata/mixed_dependency/go.mod b/testdata/mixed_dependency/go.mod new file mode 100644 index 0000000..5c07ea0 --- /dev/null +++ b/testdata/mixed_dependency/go.mod @@ -0,0 +1,6 @@ +module example.com/mixed + +go 1.27.0 + +require example.com/replacement v0.0.0 +replace example.com/replacement => ./replacement diff --git a/testdata/mixed_dependency/mixed.go b/testdata/mixed_dependency/mixed.go new file mode 100644 index 0000000..e46408d --- /dev/null +++ b/testdata/mixed_dependency/mixed.go @@ -0,0 +1,41 @@ +package mixed + +import ( + "database/sql" + "example.com/replacement" +) + +type Factory interface { + DB() *sql.DB + Use(func(*sql.DB), map[string]*sql.DB) +} + +type localFactory struct{} + +func (localFactory) DB() *sql.DB { + db, _ := sql.Open("sqlite3", "local") + return db +} +func (localFactory) Use(callback func(*sql.DB), values map[string]*sql.DB) { + db, _ := sql.Open("sqlite3", "local-callback") + callback(db) +} + +var _ Factory = localFactory{} +var _ Factory = replacement.Factory{} + +func helper(factory Factory, callback func(*sql.DB), values map[string]*sql.DB) { + db := factory.DB() + db.Query("SELECT * FROM mixed_records") + factory.Use(callback, values) + values["known"].Query("SELECT * FROM map_records") +} + +func MixedEntry() { + var factory Factory = localFactory{} + db, _ := sql.Open("sqlite3", "entry") + values := map[string]*sql.DB{"known": db} + helper(factory, func(db *sql.DB) { db.Query("SELECT * FROM callback_records") }, values) + var dependencyFactory Factory = replacement.Factory{} + _ = dependencyFactory +} diff --git a/testdata/mixed_dependency/replacement/factory.go b/testdata/mixed_dependency/replacement/factory.go new file mode 100644 index 0000000..aa8e327 --- /dev/null +++ b/testdata/mixed_dependency/replacement/factory.go @@ -0,0 +1,11 @@ +package replacement + +import "database/sql" + +type Factory struct{} + +func (Factory) DB() *sql.DB { return nil } +func (Factory) Use(callback func(*sql.DB), values map[string]*sql.DB) { + callback(nil) + values["known"] = nil +} diff --git a/testdata/mixed_dependency/replacement/go.mod b/testdata/mixed_dependency/replacement/go.mod new file mode 100644 index 0000000..79345d4 --- /dev/null +++ b/testdata/mixed_dependency/replacement/go.mod @@ -0,0 +1,3 @@ +module example.com/replacement + +go 1.27.0 diff --git a/testdata/multistore/billing/read.go b/testdata/multistore/billing/read.go new file mode 100644 index 0000000..61fcf0e --- /dev/null +++ b/testdata/multistore/billing/read.go @@ -0,0 +1,5 @@ +package billing + +import "database/sql" + +func Read(db *sql.DB) { db.Query("SELECT id FROM records") } diff --git a/testdata/multistore/billing/write.go b/testdata/multistore/billing/write.go new file mode 100644 index 0000000..d302888 --- /dev/null +++ b/testdata/multistore/billing/write.go @@ -0,0 +1,5 @@ +package billing + +import "database/sql" + +func Write(db *sql.DB) { db.Exec("INSERT INTO records(id) VALUES (2)") } diff --git a/testdata/multistore/go.mod b/testdata/multistore/go.mod new file mode 100644 index 0000000..8ce1249 --- /dev/null +++ b/testdata/multistore/go.mod @@ -0,0 +1,3 @@ +module example.com/multistore + +go 1.27.0 diff --git a/testdata/multistore/migrations/billing/001.sql b/testdata/multistore/migrations/billing/001.sql new file mode 100644 index 0000000..7c455a3 --- /dev/null +++ b/testdata/multistore/migrations/billing/001.sql @@ -0,0 +1 @@ +CREATE TABLE records(id INTEGER); diff --git a/testdata/multistore/migrations/orders/001.sql b/testdata/multistore/migrations/orders/001.sql new file mode 100644 index 0000000..2ad534d --- /dev/null +++ b/testdata/multistore/migrations/orders/001.sql @@ -0,0 +1,5 @@ +CREATE TABLE records(id INTEGER); +CREATE TABLE audit_log(record_id INTEGER REFERENCES records(id)); +CREATE TRIGGER record_audit AFTER INSERT ON records BEGIN + INSERT INTO audit_log(record_id) VALUES (NEW.id); +END; diff --git a/testdata/multistore/orders/read.go b/testdata/multistore/orders/read.go new file mode 100644 index 0000000..230e7f7 --- /dev/null +++ b/testdata/multistore/orders/read.go @@ -0,0 +1,5 @@ +package orders + +import "database/sql" + +func Read(db *sql.DB) { db.Query("SELECT id FROM records") } diff --git a/testdata/multistore/orders/write.go b/testdata/multistore/orders/write.go new file mode 100644 index 0000000..90d74b3 --- /dev/null +++ b/testdata/multistore/orders/write.go @@ -0,0 +1,5 @@ +package orders + +import "database/sql" + +func Write(db *sql.DB) { db.Exec("INSERT INTO records(id) VALUES (1)") } diff --git a/testdata/sample/afterfunc.go b/testdata/sample/afterfunc.go new file mode 100644 index 0000000..458f25c --- /dev/null +++ b/testdata/sample/afterfunc.go @@ -0,0 +1,105 @@ +package sample + +import "context" + +type CallbackStop struct{ Stop func() bool } + +func registerCancellationCallback(ctx context.Context, callback func()) func() bool { + return context.AfterFunc(ctx, callback) +} +func stopCancellationCallback(record CallbackStop) { record.Stop() } +func firstCancellationCallback() {} +func secondCancellationCallback() {} +func detachedCancellationCallback() {} +func outsideCancellationCallback() {} +func OutsideCancellationCallback(ctx context.Context, callback func()) { + context.AfterFunc(ctx, callback) +} +func AfterFuncCaller() { + ctx, cancel := context.WithCancel(context.Background()) + first := context.AfterFunc(ctx, firstCancellationCallback) + second := context.AfterFunc(ctx, secondCancellationCallback) + stopCancellationCallback(CallbackStop{Stop: first}) + defer second() + third := registerCancellationCallback(context.WithoutCancel(ctx), detachedCancellationCallback) + go third() + OutsideCancellationCallback(ctx, outsideCancellationCallback) + defer context.AfterFunc(ctx, deferredRegistrationCallback) + go context.AfterFunc(ctx, goroutineRegistrationCallback) + cancel() +} + +func deferredRegistrationCallback() {} +func goroutineRegistrationCallback() {} +func AfterFuncCompletionCaller() { + ctx, cancel := context.WithCancel(context.Background()) + finished := make(chan struct{}) + stop := context.AfterFunc(ctx, func() { close(finished) }) + cancel() + if !stop() { + <-finished + } +} + +func MixedKnownStop(choose bool) { + stop := localFunctionStop + if choose { + stop = context.AfterFunc(context.Background(), func() {}) + } + stop() +} +func AfterFuncCancelBridge() { + lifetime, endLifetime := context.WithCancel(context.Background()) + child, cancelChild := context.WithCancel(context.Background()) + stop := context.AfterFunc(lifetime, cancelChild) + stop() + endLifetime() + _ = child +} + +type cancellationOwner struct{ finished chan struct{} } + +func (owner *cancellationOwner) finish() { close(owner.finished) } + +func registerAliasedCancellation(register func(context.Context, func()) func() bool, ctx context.Context, callback func()) func() bool { + return register(ctx, callback) +} + +func AliasedCancellationCaller() { + ctx, cancel := context.WithCancel(context.Background()) + owner := &cancellationOwner{finished: make(chan struct{})} + stop := registerAliasedCancellation(context.AfterFunc, ctx, owner.finish) + registerAliasedVoid(context.AfterFunc, ctx, owner.finish) + cancel() + stop() + <-owner.finished +} + +func registerAliasedVoid(register func(context.Context, func()) func() bool, ctx context.Context, callback func()) { + defer register(ctx, callback) + go register(ctx, callback) +} + +func localCancellationFactory(context.Context, func()) func() bool { return localAliasedStop } +func localAliasedStop() bool { return true } +func mixedAliasedCallback() {} + +func MixedAliasedCancellation(choose bool) { + register := context.AfterFunc + if choose { + register = localCancellationFactory + } + stop := register(context.Background(), mixedAliasedCallback) + stop() +} + +func OutsideAliasedCancellation(register func(context.Context, func()) func() bool) { + ctx, cancel := context.WithCancel(context.Background()) + stop := register(ctx, outsideAliasedCallback) + stop() + cancel() +} + +func outsideAliasedCallback() {} + +func KnownOutsideAliasedCancellationCaller() { OutsideAliasedCancellation(context.AfterFunc) } diff --git a/testdata/sample/aggregate_callback_inputs.go b/testdata/sample/aggregate_callback_inputs.go new file mode 100644 index 0000000..7ad23d1 --- /dev/null +++ b/testdata/sample/aggregate_callback_inputs.go @@ -0,0 +1,31 @@ +package sample + +import "database/sql" + +func PublicAggregateCallback() func(APIInputRecord) { return returnedAggregateCallback } +func returnedAggregateCallback(record APIInputRecord) { record.Run(record.Database) } + +var AggregateCallback = globalAggregateCallback + +func globalAggregateCallback(record APIInputRecord) { record.Run(record.Database) } +func DispatchAggregateCallback(dispatch func(func(APIInputRecord))) { + dispatch(dependencyAggregateCallback) +} +func localAggregateDispatch(callback func(APIInputRecord)) { + callback(APIInputRecord{Run: dependencyAggregateQuery, Database: knownAggregateDatabase()}) +} +func dependencyAggregateCallback(record APIInputRecord) { record.Run(record.Database) } +func privateAggregateCallback() func(APIInputRecord) { return closedAggregateCallback } +func closedAggregateCallback(record APIInputRecord) { record.Run(record.Database) } + +func EscapedAggregateInputCaller() { + db := knownAggregateDatabase() + PublicAggregateCallback()(APIInputRecord{Run: returnedAggregateQuery, Database: db}) + AggregateCallback(APIInputRecord{Run: globalAggregateQuery, Database: db}) + DispatchAggregateCallback(localAggregateDispatch) + privateAggregateCallback()(APIInputRecord{Run: privateAggregateQuery, Database: db}) +} +func returnedAggregateQuery(db *sql.DB) { db.Query("SELECT body FROM returned_aggregate_records") } +func globalAggregateQuery(db *sql.DB) { db.Query("SELECT body FROM global_aggregate_records") } +func dependencyAggregateQuery(db *sql.DB) { db.Query("SELECT body FROM dependency_aggregate_records") } +func privateAggregateQuery(db *sql.DB) { db.Query("SELECT body FROM private_aggregate_records") } diff --git a/testdata/sample/aggregate_inputs.go b/testdata/sample/aggregate_inputs.go new file mode 100644 index 0000000..89b4bc7 --- /dev/null +++ b/testdata/sample/aggregate_inputs.go @@ -0,0 +1,62 @@ +package sample + +import "database/sql" + +type APIInputRecord struct { + Run func(*sql.DB) + Database *sql.DB + Next *APIInputRecord +} + +func PublicValueRecord(record APIInputRecord) { consumeValueRecord(record) } +func consumeValueRecord(record APIInputRecord) { record.Run(record.Database) } +func PublicPointerRecordInput(record *APIInputRecord) { record.Next.Run(record.Next.Database) } +func PublicSliceInput(records []APIInputRecord) { records[0].Run(records[0].Database) } +func PublicMapInput(records map[string]APIInputRecord) { + record := records["local"] + record.Run(record.Database) +} +func closedRecordInput(record APIInputRecord) { record.Run(record.Database) } + +func AggregateInputCaller() { + db := knownAggregateDatabase() + record := APIInputRecord{Run: aggregateInputQuery, Database: db} + record.Next = &record + PublicValueRecord(record) + PublicPointerRecordInput(&record) + PublicSliceInput([]APIInputRecord{record}) + PublicMapInput(map[string]APIInputRecord{"local": record}) + closedRecordInput(APIInputRecord{Run: closedAggregateQuery, Database: db}) +} + +func knownAggregateDatabase() *sql.DB { db, _ := sql.Open("sqlite", "aggregate.db"); return db } +func aggregateInputQuery(db *sql.DB) { db.Query("SELECT body FROM aggregate_input_records") } +func closedAggregateQuery(db *sql.DB) { db.Query("SELECT body FROM closed_aggregate_input_records") } + +type APIRecordPair struct { + First, Second APIInputRecord + FunctionPointer *func(*sql.DB) +} + +func PublicRecordPair(pair APIRecordPair) { + pair.First.Run(pair.First.Database) + pair.Second.Run(pair.Second.Database) + (*pair.FunctionPointer)(pair.Second.Database) +} +func AggregatePairCaller() { + callback := aggregatePairQuery + record := APIInputRecord{Run: callback, Database: knownAggregateDatabase()} + PublicRecordPair(APIRecordPair{First: record, Second: record, FunctionPointer: &callback}) +} +func aggregatePairQuery(db *sql.DB) { db.Query("SELECT body FROM aggregate_pair_records") } + +type RecursiveAPIRecord struct { + Run func() + Children []RecursiveAPIRecord +} + +func PublicRecursiveContainer(record RecursiveAPIRecord) { record.Children[0].Run() } +func RecursiveContainerCaller() { + PublicRecursiveContainer(RecursiveAPIRecord{Children: []RecursiveAPIRecord{{Run: recursiveInputCallback}}}) +} +func recursiveInputCallback() {} diff --git a/testdata/sample/aggregate_pointers.go b/testdata/sample/aggregate_pointers.go new file mode 100644 index 0000000..04cdb62 --- /dev/null +++ b/testdata/sample/aggregate_pointers.go @@ -0,0 +1,44 @@ +package sample + +import "database/sql" + +type PointerInputRecord struct { + Record **APIInputRecord + Callback **func(*sql.DB) + Database ***sql.DB +} + +func PublicPointerChain(record **APIInputRecord) { + (*record).Run((*record).Database) +} +func PublicPointerFields(record PointerInputRecord) { + (*record.Record).Run((*record.Record).Database) + (**record.Callback)(**record.Database) +} +func OpenPointerChainResult(factory func() **APIInputRecord) { + record := factory() + (*record).Run((*record).Database) +} +func closedPointerChain(record **APIInputRecord) { + (*record).Run((*record).Database) +} +func localPointerChainResult() **APIInputRecord { + record := &APIInputRecord{Run: pointerChainResultQuery, Database: knownAggregateDatabase()} + return &record +} +func PointerChainCaller() { + db := knownAggregateDatabase() + dbPointer := &db + callback := pointerFieldQuery + callbackPointer := &callback + record := &APIInputRecord{Run: pointerChainInputQuery, Database: db} + PublicPointerChain(&record) + PublicPointerFields(PointerInputRecord{Record: &record, Callback: &callbackPointer, Database: &dbPointer}) + OpenPointerChainResult(localPointerChainResult) + closed := &APIInputRecord{Run: closedPointerChainQuery, Database: db} + closedPointerChain(&closed) +} +func pointerChainInputQuery(db *sql.DB) { db.Query("SELECT body FROM pointer_chain_input_records") } +func pointerFieldQuery(db *sql.DB) { db.Query("SELECT body FROM pointer_field_records") } +func pointerChainResultQuery(db *sql.DB) { db.Query("SELECT body FROM pointer_chain_result_records") } +func closedPointerChainQuery(db *sql.DB) { db.Query("SELECT body FROM closed_pointer_chain_records") } diff --git a/testdata/sample/aggregate_results.go b/testdata/sample/aggregate_results.go new file mode 100644 index 0000000..d21ef1f --- /dev/null +++ b/testdata/sample/aggregate_results.go @@ -0,0 +1,75 @@ +package sample + +import "database/sql" + +func OpenAggregateResult(factory func() APIInputRecord) { + record := factory() + record.Run(record.Database) +} +func OpenPointerAggregateResult(factory func() *APIInputRecord) { + record := factory() + record.Run(record.Database) +} +func OpenTupleAggregateResult(factory func() (APIInputRecord, error)) { + record, _ := factory() + record.Run(record.Database) +} +func OpenSliceAggregateResult(factory func() []APIInputRecord) { + records := factory() + records[0].Run(records[0].Database) +} +func OpenMapAggregateResult(factory func() map[string]APIInputRecord) { + record := factory()["local"] + record.Run(record.Database) +} +func closedAggregateResult(factory func() APIInputRecord) { + record := factory() + record.Run(record.Database) +} +func AggregateResultCaller() { + OpenArrayAggregateResult(localArrayResult) + OpenAggregateResult(localRecordResult) + OpenPointerAggregateResult(localPointerResult) + OpenTupleAggregateResult(localTupleResult) + OpenSliceAggregateResult(localSliceResult) + OpenMapAggregateResult(localMapResult) + closedAggregateResult(localClosedRecordResult) +} +func localRecordResult() APIInputRecord { + return APIInputRecord{Run: recordResultQuery, Database: knownAggregateDatabase()} +} +func localPointerResult() *APIInputRecord { + return &APIInputRecord{Run: pointerResultQuery, Database: knownAggregateDatabase()} +} +func localTupleResult() (APIInputRecord, error) { + return APIInputRecord{Run: tupleResultQuery, Database: knownAggregateDatabase()}, nil +} +func localSliceResult() []APIInputRecord { + return []APIInputRecord{{Run: sliceResultQuery, Database: knownAggregateDatabase()}} +} +func localMapResult() map[string]APIInputRecord { + return map[string]APIInputRecord{"local": {Run: mapResultQuery, Database: knownAggregateDatabase()}} +} +func localClosedRecordResult() APIInputRecord { + return APIInputRecord{Run: closedResultQuery, Database: knownAggregateDatabase()} +} +func recordResultQuery(db *sql.DB) { db.Query("SELECT body FROM record_result_records") } +func pointerResultQuery(db *sql.DB) { db.Query("SELECT body FROM pointer_result_records") } +func tupleResultQuery(db *sql.DB) { db.Query("SELECT body FROM tuple_result_records") } +func sliceResultQuery(db *sql.DB) { db.Query("SELECT body FROM slice_result_records") } +func mapResultQuery(db *sql.DB) { db.Query("SELECT body FROM map_result_records") } +func closedResultQuery(db *sql.DB) { db.Query("SELECT body FROM closed_result_records") } + +func OpenArrayAggregateResult(factory func() [1]APIInputRecord) { + records := factory() + records[0].Run(records[0].Database) +} +func unresolvedRecordResult(factory func() APIInputRecord) { + record := factory() + record.Run(record.Database) + record.Database.Query("SELECT body FROM unresolved_result_records") +} +func localArrayResult() [1]APIInputRecord { + return [1]APIInputRecord{{Run: arrayResultQuery, Database: knownAggregateDatabase()}} +} +func arrayResultQuery(db *sql.DB) { db.Query("SELECT body FROM array_result_records") } diff --git a/testdata/sample/apis.go b/testdata/sample/apis.go new file mode 100644 index 0000000..9a521f9 --- /dev/null +++ b/testdata/sample/apis.go @@ -0,0 +1,12 @@ +package sample + +import("context";"database/sql") + +func execute(ctx context.Context, db *sql.DB, query string) { _ = ctx; _ = db; _ = query } +func APIWrite(ctx context.Context,db *sql.DB) { execute(ctx,db,"INSERT INTO api_records (body) VALUES (?)") } +func APIRead(db *sql.DB) { db.Query("SELECT body FROM api_records") } +func sendAPI(ctx context.Context, topic string) { _ = ctx; _ = topic } +func subscribeAPI(topic string, handler func(Event)) { _ = topic; _ = handler } +func APIHandler(e Event) { _ = e } +func APISetup() { subscribeAPI("order.ready",APIHandler) } +func APIPublish(ctx context.Context) { sendAPI(ctx,identity("order.ready")) } diff --git a/testdata/sample/cancellation.go b/testdata/sample/cancellation.go new file mode 100644 index 0000000..7cf798a --- /dev/null +++ b/testdata/sample/cancellation.go @@ -0,0 +1,61 @@ +package sample + +import ( + "context" + "time" +) + +func CancellationWorker(ctx context.Context, finished chan<- struct{}) { + defer close(finished) + select { + case <-ctx.Done(): + return + } +} +func CancellationStart() { + ctx, cancel := context.WithCancel(context.Background()) + finished := make(chan struct{}) + go CancellationWorker(ctx, finished) + cancel() + <-finished +} +func WaitSignal(done <-chan struct{}) { <-done } +func WrappedDone(ctx context.Context) <-chan struct{} { + return context.WithValue(ctx, "key", "value").Done() +} +func WrappedDoneStart() { + ctx, cancel := context.WithCancel(context.Background()) + go WaitSignal(WrappedDone(ctx)) + cancel() +} +func NilDoneReceive() { <-context.Background().Done() } +func DetachedDone(parent context.Context) { + child := context.WithoutCancel(parent) + WaitSignal(child.Done()) +} + +func NilDoneSelect() { + select { + case <-context.TODO().Done(): + return + default: + return + } +} +func DetachStart() { + ctx, cancel := context.WithCancel(context.Background()) + DetachedDone(ctx) + cancel() +} + +func WrappedNilDone() { + <-context.WithValue(context.TODO(), "key", "value").Done() +} + +func DeadlineChild() { + parent, cancelParent := context.WithCancel(context.Background()) + defer cancelParent() + child, cancelChild := context.WithTimeout(parent, time.Second) + defer cancelChild() + WaitSignal(child.Done()) +} diff --git a/testdata/sample/cleanup.go b/testdata/sample/cleanup.go new file mode 100644 index 0000000..5897fa5 --- /dev/null +++ b/testdata/sample/cleanup.go @@ -0,0 +1,36 @@ +package sample + +import "context" + +func CleanupBranches(skip, early bool) { + if skip { + return + } + _, cancel := context.WithCancel(context.Background()) + defer cancel() + finished := make(chan struct{}) + defer close(finished) + ordinary := make(chan struct{}) + close(ordinary) + if early { + return + } + panic("cleanup path") +} + +func CleanupHelper(cancel context.CancelFunc, done chan struct{}) { + cancel() + close(done) +} + +func DeferredHelper() { + _, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + defer CleanupHelper(cancel, done) +} + +func DeferredLoop() { + defer func() {}() + for { + } +} diff --git a/testdata/sample/configured.go b/testdata/sample/configured.go new file mode 100644 index 0000000..717ad82 --- /dev/null +++ b/testdata/sample/configured.go @@ -0,0 +1,6 @@ +package sample + +type Message struct { Topic string } +func CustomPublish() Message { return Message{Topic: "order.finished"} } +func CustomConsume(m Message) bool { return m.Topic == "order.finished" } +// This function is deliberately excluded on normal builds. diff --git a/testdata/sample/containers.go b/testdata/sample/containers.go new file mode 100644 index 0000000..e7d328a --- /dev/null +++ b/testdata/sample/containers.go @@ -0,0 +1,52 @@ +package sample + +func OtherMapTarget(n int) bool { return n < -1 } +func MapCallback() bool { + checks := map[string]func(int) bool{"valid": Validate, "other": OtherMapTarget} + return checks["valid"](1) +} +func receiveKind(kinds <-chan string) Event { return Event{EventType: <-kinds} } +func sendKind(kinds chan<- string) { kinds <- "CHANNEL_EVENT" } +func ChannelEvent() Event { + kinds := make(chan string, 1) + sendKind(kinds) + result := receiveKind(kinds) + close(kinds) + return result +} +func ChannelConsume(e Event) bool { return e.EventType == "CHANNEL_EVENT" } +func selectKind(kinds <-chan string) Event { + select { + case kind := <-kinds: + return Event{EventType: kind} + default: + return Event{} + } +} +func SelectEvent() Event { kinds := make(chan string, 1); sendKind(kinds); return selectKind(kinds) } + +func IntegerMapCallback() bool { + checks := map[int]func(int) bool{1: Validate, 2: OtherMapTarget} + check, ok := checks[1] + return ok && check(1) +} +func SecondSelectEvent() Event { + first, second := make(chan string, 1), make(chan string, 1) + first <- "FIRST_CHANNEL" + second <- "SECOND_CHANNEL" + select { + case <-first: + return Event{} + case kind := <-second: + return Event{EventType: kind} + } +} +func CommaReceiveEvent() Event { + channel := make(chan string, 1) + channel <- "COMMA_EVENT" + kind, ok := <-channel + if !ok { + return Event{} + } + return Event{EventType: kind} +} diff --git a/testdata/sample/custom_scheduler.go b/testdata/sample/custom_scheduler.go new file mode 100644 index 0000000..a3c31b7 --- /dev/null +++ b/testdata/sample/custom_scheduler.go @@ -0,0 +1,138 @@ +package sample + +import ( + "context" + "time" +) + +type customSchedulingContext struct { + context.Context + ready, stopped chan struct{} + pending func() +} + +func (ctx *customSchedulingContext) Done() <-chan struct{} { return ctx.ready } +func (ctx *customSchedulingContext) AfterFunc(callback func()) func() bool { + ctx.pending = callback + return ctx.stop +} +func (ctx *customSchedulingContext) stop() bool { close(ctx.stopped); return true } +func (ctx *customSchedulingContext) fire() { + ctx.pending() + defer ctx.pending() + go ctx.pending() +} + +func CustomSchedulingCaller() { + base, cancel := context.WithCancel(context.Background()) + finished := make(chan struct{}) + stopped := make(chan struct{}) + ctx := &customSchedulingContext{Context: base, ready: make(chan struct{}), stopped: stopped} + stop := context.AfterFunc(ctx, func() { close(finished) }) + defer stop() + ctx.fire() + cancel() + <-finished +} + +func ChildCustomSchedulingCaller() { + parent := &customSchedulingContext{Context: context.Background(), ready: make(chan struct{})} + child, cancel := context.WithCancel(parent) + parent.fire() + cancel() + <-child.Done() +} + +func OtherChildCustomSchedulingCaller() { + parent := &customSchedulingContext{Context: context.Background(), ready: make(chan struct{})} + _, cause := context.WithCancelCause(parent) + _, deadline := context.WithDeadline(parent, time.Now()) + _, deadlineCause := context.WithDeadlineCause(parent, time.Now(), nil) + _, timeout := context.WithTimeout(parent, time.Hour) + _, timeoutCause := context.WithTimeoutCause(parent, time.Hour, nil) + parent.fire() + cause(nil) + deadline() + deadlineCause() + timeout() + timeoutCause() +} + +type namedCallback func() +type namedSchedulingContext struct{ context.Context } + +func (*namedSchedulingContext) AfterFunc(namedCallback) func() bool { + return func() bool { return true } +} +func namedSchedulingCallback() {} + +type promotedSchedulingContext struct{ *customSchedulingContext } + +type valuePromotedSchedulingContext struct{ customSchedulingContext } +type nestedPromotedSchedulingContext struct { + *valuePromotedSchedulingContext +} + +func valuePromotedSchedulingCallback() {} +func ValuePromotedSchedulingCaller() { + stopped := make(chan struct{}) + outer := &valuePromotedSchedulingContext{customSchedulingContext{Context: context.Background(), ready: make(chan struct{}), stopped: stopped}} + stop := context.AfterFunc(outer, valuePromotedSchedulingCallback) + stop() + outer.fire() +} + +func nestedPromotedSchedulingCallback() {} +func NestedPromotedSchedulingCaller() { + stopped := make(chan struct{}) + inner := &valuePromotedSchedulingContext{customSchedulingContext{Context: context.Background(), ready: make(chan struct{}), stopped: stopped}} + outer := &nestedPromotedSchedulingContext{inner} + stop := context.AfterFunc(outer, nestedPromotedSchedulingCallback) + stop() + outer.fire() +} + +type valueReceiverSchedulingContext struct { + context.Context + stopped chan struct{} +} + +func (ctx valueReceiverSchedulingContext) AfterFunc(func()) func() bool { + return func() bool { close(ctx.stopped); return true } +} +func valueReceiverSchedulingCallback() {} +func ValueReceiverSchedulingCaller() { + stopped := make(chan struct{}) + ctx := valueReceiverSchedulingContext{Context: context.Background(), stopped: stopped} + stop := context.AfterFunc(ctx, valueReceiverSchedulingCallback) + stop() +} + +func promotedSchedulingCallback() {} +func PromotedSchedulingCaller() { + stopped := make(chan struct{}) + inner := &customSchedulingContext{Context: context.Background(), ready: make(chan struct{}), stopped: stopped} + outer := &promotedSchedulingContext{inner} + stop := context.AfterFunc(outer, promotedSchedulingCallback) + stop() + outer.fire() +} +func NamedSchedulingCaller() { + stop := context.AfterFunc(&namedSchedulingContext{context.Background()}, namedSchedulingCallback) + stop() +} + +type wrongSchedulingContext struct{ context.Context } + +func (*wrongSchedulingContext) AfterFunc(func()) func() error { return func() error { return nil } } +func wrongSchedulingCallback() {} +func WrongSchedulingCaller() { + stop := context.AfterFunc(&wrongSchedulingContext{context.Background()}, wrongSchedulingCallback) + stop() +} + +func OutsideCustomScheduling(ctx context.Context) { context.AfterFunc(ctx, outsideSchedulingCallback) } +func outsideSchedulingCallback() {} +func KnownOutsideCustomSchedulingCaller() { + OutsideCustomScheduling(&customSchedulingContext{Context: context.Background(), ready: make(chan struct{})}) +} diff --git a/testdata/sample/databases.go b/testdata/sample/databases.go new file mode 100644 index 0000000..abf6015 --- /dev/null +++ b/testdata/sample/databases.go @@ -0,0 +1,437 @@ +package sample + +import ( + "context" + "database/sql" + "reflect" +) + +func DatabasePaths() { + first, _ := sql.Open("sqlite", "first.db") + second, _ := sql.Open("sqlite", "second.db") + defer first.Close() + defer second.Close() + SharedDatabaseQuery(first) + SharedDatabaseQuery(second) + connection, _ := first.Conn(context.Background()) + defer connection.Close() + transaction, _ := connection.BeginTx(context.Background(), nil) + defer transaction.Rollback() + statement, _ := transaction.Prepare("SELECT body FROM connection_records") + statement.Query() +} + +func SharedDatabaseQuery(db *sql.DB) { + db.Exec("INSERT INTO shared_records (body) VALUES ('value')") +} + +func UnknownDatabaseQuery(db *sql.DB) { + db.Query("SELECT body FROM external_records") +} + +func ConnectorDatabase() { + db := sql.OpenDB(nil) + db.Query("SELECT body FROM connector_records") +} + +func OpenOrdersDatabase() *sql.DB { + db, _ := sql.Open("sqlite", "orders.db") + return db +} + +func OpenAuditDatabase() *sql.DB { + db, _ := sql.Open("sqlite", "audit.db") + return db +} + +func NamespacedDatabasePaths() { + orders := OpenOrdersDatabase() + audit := OpenAuditDatabase() + SharedDatabaseQuery(orders) + SharedDatabaseQuery(audit) + transaction, _ := orders.Begin() + statement, _ := transaction.Prepare("SELECT body FROM scoped_records") + statement.Query() +} + +func MixedDatabaseInput(external *sql.DB) { + known := OpenOrdersDatabase() + mixedDatabaseQuery(known) + mixedDatabaseQuery(external) +} + +func mixedDatabaseQuery(db *sql.DB) { + defer db.Close() + db.Query("SELECT body FROM mixed_receiver_records") + statement, _ := db.Prepare("SELECT body FROM mixed_statement_records") + statement.Query() +} + +func MixedStatementInput(external *sql.Stmt) { + known, _ := OpenOrdersDatabase().Prepare("SELECT body FROM mixed_rebind_records") + transaction, _ := OpenOrdersDatabase().Begin() + mixedStatementRebind(transaction, known) + mixedStatementRebind(transaction, external) +} + +func mixedStatementRebind(transaction *sql.Tx, statement *sql.Stmt) { + rebound := transaction.Stmt(statement) + defer rebound.Close() + rebound.Query() +} + +func OpaqueStatementInput() { + db := OpenOrdersDatabase() + known, _ := db.Prepare("SELECT body FROM unknown_candidate_records") + prepare := db.Prepare // Bound database methods remain an unsupported origin. + opaque, _ := prepare("SELECT body FROM opaque_records") + opaqueStatementQuery(known) + opaqueStatementQuery(opaque) +} + +func opaqueStatementQuery(statement *sql.Stmt) { statement.Query() } + +func UnknownStatementInput() { + known, _ := OpenOrdersDatabase().Prepare("SELECT body FROM unknown_candidate_records") + unknownStatementQuery(known) + unknownStatementQuery(nil) +} + +func unknownStatementQuery(statement *sql.Stmt) { statement.Query() } + +func NilStatementInput(chooseNil bool) { + statement, _ := OpenOrdersDatabase().Prepare("SELECT body FROM unknown_candidate_records") + if chooseNil { + statement = nil + } + statement.Query() +} + +func PublicDatabaseCaller() { PublicDatabaseQuery(OpenOrdersDatabase()) } + +func PublicDatabaseQuery(db *sql.DB) { + db.Query("SELECT body FROM public_input_records") +} + +func PrivateDatabaseCaller() { privateDatabaseQuery(OpenOrdersDatabase()) } + +func privateDatabaseQuery(db *sql.DB) { + db.Query("SELECT body FROM private_input_records") +} + +func EscapingSQLCaller(external *sql.DB) { + escapingSQLQuery(OpenOrdersDatabase()) + callback := passSQLCallback(escapingSQLQuery) + reflect.ValueOf(callback).Call([]reflect.Value{reflect.ValueOf(external)}) +} + +func passSQLCallback(callback func(*sql.DB)) func(*sql.DB) { return callback } + +func escapingSQLQuery(db *sql.DB) { + db.Query("SELECT body FROM escaping_input_records") +} + +func GenericCallbackEscape() { + privateTextConsumer("local") + reflect.ValueOf(privateTextConsumer) +} + +func privateTextConsumer(text string) { _ = text } + +func ReturnedCallbackCaller() { + privateReturnedQuery(OpenOrdersDatabase()) + callback := PublicCallbackFactory() + callback(OpenOrdersDatabase()) +} + +func PublicCallbackFactory() func(*sql.DB) { return privateCallbackFactory() } + +func privateCallbackFactory() func(*sql.DB) { return privateReturnedQuery } + +func privateReturnedQuery(db *sql.DB) { + db.Query("SELECT body FROM returned_callback_records") +} + +func ClosedCallbackCaller() { closedCallbackFactory()(OpenOrdersDatabase()) } + +func closedCallbackFactory() func(*sql.DB) { return closedCallbackQuery } + +func closedCallbackQuery(db *sql.DB) { + db.Query("SELECT body FROM closed_callback_records") +} + +func ClosedDatabaseFactoryCaller() { closedDatabaseConsumer(closedDatabaseFactory()()) } + +func closedDatabaseFactory() func() *sql.DB { return OpenOrdersDatabase } + +func closedDatabaseConsumer(db *sql.DB) { + db.Query("SELECT body FROM closed_factory_records") +} + +type CallbackRecord struct { + Nested *CallbackInner + hidden func(*sql.DB) +} + +type CallbackInner struct { + Query func(*sql.DB) + Next *CallbackInner +} + +func PublicRecordFactory() CallbackRecord { return privateRecordFactory() } + +func privateRecordFactory() CallbackRecord { + inner := &CallbackInner{Query: recordCallbackQuery} + inner.Next = inner + return CallbackRecord{Nested: inner, hidden: hiddenRecordQuery} +} + +func RecordCallbackCaller() { + record := PublicRecordFactory() + record.Nested.Query(OpenOrdersDatabase()) + record.hidden(OpenOrdersDatabase()) +} + +func recordCallbackQuery(db *sql.DB) { db.Query("SELECT body FROM record_callback_records") } +func hiddenRecordQuery(db *sql.DB) { db.Query("SELECT body FROM hidden_record_records") } + +func RecordArgumentCaller() { + record := &CallbackInner{Query: argumentRecordQuery} + record.Query(OpenOrdersDatabase()) + privateRecordArgument(record) +} + +func privateRecordArgument(record any) { + _ = reflect.ValueOf(record) +} + +func argumentRecordQuery(db *sql.DB) { db.Query("SELECT body FROM argument_record_records") } + +type hiddenEmbeddedCallback struct { + Query func(*sql.DB) +} + +type PromotedCallbackRecord struct{ hiddenEmbeddedCallback } + +func PublicPromotedRecordFactory() PromotedCallbackRecord { + return PromotedCallbackRecord{hiddenEmbeddedCallback{Query: promotedRecordQuery}} +} + +func PromotedRecordCaller() { PublicPromotedRecordFactory().Query(OpenOrdersDatabase()) } + +func promotedRecordQuery(db *sql.DB) { db.Query("SELECT body FROM promoted_record_records") } + +func PublicSliceCallbacks() []func(*sql.DB) { return []func(*sql.DB){sliceCallbackQuery} } +func PublicArrayCallbacks() [1]func(*sql.DB) { return [1]func(*sql.DB){arrayCallbackQuery} } +func PublicMapCallbacks() map[string]func(*sql.DB) { + return map[string]func(*sql.DB){"query": mapCallbackQuery} +} + +func ContainerCallbackCaller() { + db := OpenOrdersDatabase() + sliceCallbackQuery(db) + arrayCallbackQuery(db) + mapCallbackQuery(db) + PublicSliceCallbacks()[0](db) + PublicArrayCallbacks()[0](db) + PublicMapCallbacks()["query"](db) +} + +func sliceCallbackQuery(db *sql.DB) { db.Query("SELECT body FROM slice_callback_records") } +func arrayCallbackQuery(db *sql.DB) { db.Query("SELECT body FROM array_callback_records") } +func mapCallbackQuery(db *sql.DB) { db.Query("SELECT body FROM map_callback_records") } + +func ContainerArgumentCaller() { + slice := make([]func(*sql.DB), 1) + slice[0] = madeSliceQuery + slice[0](OpenOrdersDatabase()) + privateRecordArgument(slice) + keys := map[*CallbackInner]bool{&CallbackInner{Query: mapKeyQuery}: true} + mapKeyQuery(OpenOrdersDatabase()) + privateRecordArgument(keys) +} + +func madeSliceQuery(db *sql.DB) { db.Query("SELECT body FROM made_slice_records") } +func mapKeyQuery(db *sql.DB) { db.Query("SELECT body FROM map_key_records") } + +func ClosedArrayCaller() { closedArrayFactory()[0](OpenOrdersDatabase()) } +func closedArrayFactory() [1]func(*sql.DB) { return [1]func(*sql.DB){closedArrayQuery} } +func closedArrayQuery(db *sql.DB) { db.Query("SELECT body FROM closed_array_records") } + +func PublicAppendedCallbacks() []func(*sql.DB) { + var callbacks []func(*sql.DB) + return append(callbacks, appendedQuery) +} + +func PublicCopiedCallbacks() []func(*sql.DB) { + source := []func(*sql.DB){copiedQuery} + destination := make([]func(*sql.DB), len(source)) + copy(destination, source) + return destination +} + +func SliceBuiltinCaller() { + db := OpenOrdersDatabase() + appendedQuery(db) + copiedQuery(db) + PublicAppendedCallbacks()[0](db) + PublicCopiedCallbacks()[0](db) +} + +func appendedQuery(db *sql.DB) { db.Query("SELECT body FROM appended_records") } +func copiedQuery(db *sql.DB) { db.Query("SELECT body FROM copied_records") } + +func PublicChainedCallbacks() []func(*sql.DB) { + base := []func(*sql.DB){chainFirstQuery} + extra := []func(*sql.DB){chainSecondQuery} + return append(base, extra...) +} + +func PublicCopiedRecordCallbacks() []CallbackInner { + source := []CallbackInner{{Query: copiedRecordQuery}} + destination := make([]CallbackInner, len(source)) + copy(destination, source) + return destination +} + +func PublicDeferredCopyCallbacks() []func(*sql.DB) { + source := []func(*sql.DB){deferredCopyQuery} + destination := make([]func(*sql.DB), len(source)) + defer copy(destination, source) + return destination +} + +func PublicGoCopyCallbacks() []func(*sql.DB) { + source := []func(*sql.DB){goCopyQuery} + destination := make([]func(*sql.DB), len(source)) + go copy(destination, source) + return destination +} + +func SliceBuiltinSiblingCaller() { + db := OpenOrdersDatabase() + chainFirstQuery(db) + chainSecondQuery(db) + copiedRecordQuery(db) + deferredCopyQuery(db) + goCopyQuery(db) + PublicChainedCallbacks()[0](db) + PublicChainedCallbacks()[1](db) + PublicCopiedRecordCallbacks()[0].Query(db) + PublicDeferredCopyCallbacks()[0](db) + PublicGoCopyCallbacks()[0](db) +} + +func chainFirstQuery(db *sql.DB) { db.Query("SELECT body FROM chain_first_records") } +func chainSecondQuery(db *sql.DB) { db.Query("SELECT body FROM chain_second_records") } +func copiedRecordQuery(db *sql.DB) { db.Query("SELECT body FROM copied_record_records") } +func deferredCopyQuery(db *sql.DB) { db.Query("SELECT body FROM deferred_copy_records") } +func goCopyQuery(db *sql.DB) { db.Query("SELECT body FROM go_copy_records") } + +func ClosedSliceBuiltinCaller() { closedSliceBuiltinFactory()[0](OpenOrdersDatabase()) } + +func closedSliceBuiltinFactory() []func(*sql.DB) { + var empty []func(*sql.DB) + source := append(empty, closedSliceBuiltinQuery) + destination := make([]func(*sql.DB), len(source)) + copy(destination, source) + return destination +} + +func closedSliceBuiltinQuery(db *sql.DB) { db.Query("SELECT body FROM closed_slice_builtin_records") } + +var PublicInstalledCallback func(*sql.DB) +var PublicLocationCallbacks = []func(string){ + privateTextConsumer, // location seed inside global initializer +} +var PublicInstalledRecord CallbackInner +var PublicPointerRecord *CallbackInner +var privateInstalledCallback func(*sql.DB) + +var privateLocationState = struct{ value string }{ + value: "initial", // private global location seed +} +var privateUnrelatedState string + +func writePrivateLocationState(value string) { + setPrivateLocationState(&privateLocationState.value, value) +} +func setPrivateLocationState(target *string, value string) { *target = value } +func readPrivateLocationState() string { return privateLocationState.value } +func siblingPrivateLocationState() string { return privateLocationState.value } +func writeUnrelatedPrivateState(value string) { privateUnrelatedState = value } + +func installGlobalCallbacks() { + PublicInstalledCallback = globalDirectQuery + PublicInstalledRecord.Query = globalFieldQuery + privateInstalledCallback = privateGlobalQuery + PublicPointerRecord = &CallbackInner{} + setGlobalPointer(PublicPointerRecord) +} + +func setGlobalPointer(record *CallbackInner) { record.Query = globalPointerQuery } + +func GlobalCallbackCaller() { + installGlobalCallbacks() + db := OpenOrdersDatabase() + PublicInstalledCallback(db) + PublicInstalledRecord.Query(db) + privateInstalledCallback(db) + PublicPointerRecord.Query(db) +} + +func globalDirectQuery(db *sql.DB) { db.Query("SELECT body FROM global_direct_records") } +func globalFieldQuery(db *sql.DB) { db.Query("SELECT body FROM global_field_records") } +func privateGlobalQuery(db *sql.DB) { db.Query("SELECT body FROM private_global_records") } +func globalPointerQuery(db *sql.DB) { db.Query("SELECT body FROM global_pointer_records") } + +var PublicGlobalDatabase *sql.DB +var PublicGlobalConnection *sql.Conn +var PublicGlobalTransaction *sql.Tx +var PublicGlobalStatement *sql.Stmt +var privateGlobalDatabase *sql.DB + +type GlobalSQLRecord struct { + Database *sql.DB + hidden *sql.DB +} + +var PublicGlobalSQLRecord GlobalSQLRecord +var PublicGlobalSQLSlice []*sql.DB +var PublicGlobalSQLArray [1]*sql.DB +var PublicGlobalSQLMap map[string]*sql.DB + +func publicGlobalSliceQuery(db *sql.DB) { db.Query("SELECT body FROM global_sql_slice_records") } +func publicGlobalArrayQuery(db *sql.DB) { db.Query("SELECT body FROM global_sql_array_records") } +func publicGlobalMapQuery(db *sql.DB) { db.Query("SELECT body FROM global_sql_map_records") } + +func GlobalDatabaseCaller() { + db := OpenOrdersDatabase() + PublicGlobalDatabase = db + privateGlobalDatabase = db + PublicGlobalConnection, _ = db.Conn(context.Background()) + PublicGlobalTransaction, _ = db.Begin() + PublicGlobalStatement, _ = db.Prepare("SELECT body FROM global_statement_records") + PublicGlobalSQLRecord = GlobalSQLRecord{Database: db, hidden: db} + PublicGlobalSQLSlice = []*sql.DB{db} + PublicGlobalSQLArray = [1]*sql.DB{db} + PublicGlobalSQLMap = map[string]*sql.DB{"db": db} + publicGlobalDatabaseQuery(PublicGlobalDatabase) + privateGlobalDatabaseQuery(privateGlobalDatabase) + publicGlobalRecordQuery(PublicGlobalSQLRecord.Database) + privateGlobalRecordQuery(PublicGlobalSQLRecord.hidden) + publicGlobalSliceQuery(PublicGlobalSQLSlice[0]) + publicGlobalArrayQuery(PublicGlobalSQLArray[0]) + publicGlobalMapQuery(PublicGlobalSQLMap["db"]) + PublicGlobalConnection.QueryContext(context.Background(), "SELECT body FROM global_connection_records") + PublicGlobalTransaction.Query("SELECT body FROM global_transaction_records") + PublicGlobalStatement.Query() +} + +func publicGlobalDatabaseQuery(db *sql.DB) { db.Query("SELECT body FROM global_database_records") } +func privateGlobalDatabaseQuery(db *sql.DB) { + db.Query("SELECT body FROM private_global_database_records") +} + +func publicGlobalRecordQuery(db *sql.DB) { db.Query("SELECT body FROM global_record_database_records") } +func privateGlobalRecordQuery(db *sql.DB) { db.Query("SELECT body FROM private_global_record_records") } diff --git a/testdata/sample/embedded.go b/testdata/sample/embedded.go new file mode 100644 index 0000000..ee5ffb6 --- /dev/null +++ b/testdata/sample/embedded.go @@ -0,0 +1,12 @@ +package sample + +import ( + _ "embed" + "database/sql" +) + +//go:embed embedded/schema.sql +var embeddedSchema string + +func LoadEmbedded(db *sql.DB) { db.Exec(embeddedSchema) } +func EmbeddedRead(db *sql.DB) { db.Query("SELECT body FROM embedded_records") } diff --git a/testdata/sample/embedded/schema.sql b/testdata/sample/embedded/schema.sql new file mode 100644 index 0000000..ee02314 --- /dev/null +++ b/testdata/sample/embedded/schema.sql @@ -0,0 +1 @@ +CREATE TABLE embedded_records (body TEXT); diff --git a/testdata/sample/flow.go b/testdata/sample/flow.go new file mode 100644 index 0000000..533913a --- /dev/null +++ b/testdata/sample/flow.go @@ -0,0 +1,20 @@ +package sample + +import "database/sql" + +func identity(s string) string { return s } +func sqlWrapper(db *sql.DB, q string) { db.Exec(identity(q)) } +func WrappedWrite(db *sql.DB) { sqlWrapper(db, "INSERT INTO audit_log (body) VALUES (?)") } +func AuditRead(db *sql.DB) { db.Query("SELECT body FROM audit_log") } +func makeKind() string { return "WRAPPED_COMPLETED" } +func publishKind(kind string) Event { return Event{EventType: kind} } +func WrappedPublish() Event { return publishKind(identity(makeKind())) } +func WrappedConsume(e Event) bool { return e.EventType == "WRAPPED_COMPLETED" } +func pass(check func(int) bool) func(int) bool { return check } +func runCheck(check func(int) bool) bool { return pass(check)(1) } +func FlowEntry() bool { return runCheck(Validate) } + +var registered = Validate +func RegisteredEntry() bool { return registered(1) } +type callbackSlot struct { check func(int) bool } +func FieldEntry() bool { slot := &callbackSlot{check: Validate}; return slot.check(1) } diff --git a/testdata/sample/function_iteration.go b/testdata/sample/function_iteration.go new file mode 100644 index 0000000..44d15aa --- /dev/null +++ b/testdata/sample/function_iteration.go @@ -0,0 +1,86 @@ +package sample + +import ( + "database/sql" + "iter" + "maps" + "slices" +) + +func localCallbackSequence() iter.Seq[func(*sql.DB)] { + return func(yield func(func(*sql.DB)) bool) { yield(functionIterationQuery) } +} + +func FunctionIterationCaller() { + db := OpenOrdersDatabase() + for callback := range localCallbackSequence() { + callback(db) + } +} + +func SliceFunctionIterationCaller() { + db := OpenOrdersDatabase() + for callback := range slices.Values([]func(*sql.DB){sliceFunctionIterationQuery}) { + callback(db) + } +} + +func functionIterationQuery(db *sql.DB) { db.Query("SELECT body FROM function_iteration_records") } +func sliceFunctionIterationQuery(db *sql.DB) { + db.Query("SELECT body FROM slice_function_iteration_records") +} + +func PublicSliceIterator() iter.Seq[func(*sql.DB)] { + return slices.Values([]func(*sql.DB){publicIteratorQuery}) +} + +func PublicIteratorCaller() { + for callback := range PublicSliceIterator() { + callback(OpenOrdersDatabase()) + } +} + +func publicIteratorQuery(db *sql.DB) { db.Query("SELECT body FROM public_iterator_records") } + +func SliceAllIterationCaller() { + for _, callback := range slices.All([]func(*sql.DB){sliceAllIterationQuery}) { + callback(OpenOrdersDatabase()) + } +} +func SliceBackwardIterationCaller() { + for _, callback := range slices.Backward([]func(*sql.DB){sliceBackwardIterationQuery}) { + callback(OpenOrdersDatabase()) + } +} +func MapValuesIterationCaller() { + for callback := range maps.Values(map[string]func(*sql.DB){"callback": mapValuesIterationQuery}) { + callback(OpenOrdersDatabase()) + } +} +func MapKeysIterationCaller() { + for record := range maps.Keys(map[*iterationRecord]string{&iterationRecord{Callback: mapKeysIterationQuery}: "payload"}) { + record.Callback(OpenOrdersDatabase()) + } +} +func MapAllIterationCaller() { + for _, callback := range maps.All(map[string]func(*sql.DB){"callback": mapAllIterationQuery}) { + callback(OpenOrdersDatabase()) + } +} +func sliceAllIterationQuery(db *sql.DB) { db.Query("SELECT body FROM slice_all_iteration_records") } +func sliceBackwardIterationQuery(db *sql.DB) { + db.Query("SELECT body FROM slice_backward_iteration_records") +} +func mapValuesIterationQuery(db *sql.DB) { db.Query("SELECT body FROM map_values_iteration_records") } +func mapKeysIterationQuery(db *sql.DB) { db.Query("SELECT body FROM map_keys_iteration_records") } +func mapAllIterationQuery(db *sql.DB) { db.Query("SELECT body FROM map_all_iteration_records") } + +func PublicIteratorRecord() struct{ Sequence iter.Seq[func(*sql.DB)] } { + return struct{ Sequence iter.Seq[func(*sql.DB)] }{Sequence: slices.Values([]func(*sql.DB){recordIteratorQuery})} +} +func RecordIteratorCaller() { + for callback := range PublicIteratorRecord().Sequence { + callback(OpenOrdersDatabase()) + } +} +func recordIteratorQuery(db *sql.DB) { db.Query("SELECT body FROM record_iterator_records") } diff --git a/testdata/sample/global_containers.go b/testdata/sample/global_containers.go new file mode 100644 index 0000000..54553d1 --- /dev/null +++ b/testdata/sample/global_containers.go @@ -0,0 +1,42 @@ +package sample + +var privateMutationMap = map[string]string{"initial": "value"} +var privateMutationSlice = []string{"initial"} +var unrelatedMutationMap = map[string]string{} + +func GlobalContainerCaller() { + mutateSharedMap(privateMutationMap) + readSharedMap(privateMutationMap) + mutateSharedSlice(privateMutationSlice) + readSharedSlice(privateMutationSlice) + defer copy(privateMutationSlice, []string{"deferred"}) + go copy(privateMutationSlice, []string{"asynchronous"}) +} + +func mutateSharedMap(state map[string]string) { + state["new"] = "value" + delete(state, "initial") + clear(state) +} + +func readSharedMap(state map[string]string) string { + value := state["new"] + for key := range state { + value += key + } + return value +} + +func mutateSharedSlice(state []string) { + state[0] = "indexed" + copy(state, []string{"copied"}) + _ = append(state, "appended") + clear(state) +} + +func readSharedSlice(state []string) string { + copy(make([]string, len(state)), state) + return state[0] +} + +func mutateUnrelatedMap() { unrelatedMutationMap["unrelated"] = "value" } diff --git a/testdata/sample/go.mod b/testdata/sample/go.mod new file mode 100644 index 0000000..4e49f99 --- /dev/null +++ b/testdata/sample/go.mod @@ -0,0 +1,3 @@ +module example.com/sample + +go 1.27.0 diff --git a/testdata/sample/interface_flow.go b/testdata/sample/interface_flow.go new file mode 100644 index 0000000..e25612a --- /dev/null +++ b/testdata/sample/interface_flow.go @@ -0,0 +1,59 @@ +package sample + +import "database/sql" + +type QueryRunner interface{ Run(string) } +type SQLRunner struct{ DB *sql.DB } + +func (r *SQLRunner) Run(query string) { r.DB.Exec(query) } +func throughRunner(r QueryRunner, query string) { r.Run(query) } +func InterfaceWrite(db *sql.DB) { + throughRunner(&SQLRunner{DB: db}, "INSERT INTO interface_records(id) VALUES (1)") +} +func InterfaceRead(db *sql.DB) { db.Query("SELECT id FROM interface_records") } + +type TopicProvider interface{ Topic() string } +type topicValue struct{ Kind string } + +func (v topicValue) Topic() string { return v.Kind } +func throughProvider(p TopicProvider) Event { return Event{EventType: p.Topic()} } +func InterfaceEvent() Event { return throughProvider(topicValue{Kind: "INTERFACE_EVENT"}) } +func InterfaceConsume(e Event) bool { return e.EventType == "INTERFACE_EVENT" } + +type PairProvider interface{ Pair() (string, string) } +type pairValue struct{} + +func (pairValue) Pair() (string, string) { return "PAIR_FIRST", "PAIR_SECOND" } +func InterfacePair(p PairProvider) Event { _, kind := p.Pair(); return Event{EventType: kind} } +func PairEntry() Event { return InterfacePair(pairValue{}) } + +type BoolCheck interface{ Check(int) bool } +type CheckFunc func(int) bool + +func (f CheckFunc) Check(n int) bool { return f(n) } +func throughCheck(check BoolCheck) bool { return check.Check(1) } +func InterfaceCallback() bool { return throughCheck(CheckFunc(Validate)) } + +type NestedTopic struct{ Value topicValue } + +func (v NestedTopic) Topic() string { return v.Value.Kind } +func NestedInterfaceEvent() Event { + return throughProvider(NestedTopic{Value: topicValue{Kind: "NESTED_INTERFACE"}}) +} + +type PromotedTopic struct{ topicValue } + +func PromotedInterfaceEvent() Event { + return throughProvider(&PromotedTopic{topicValue: topicValue{Kind: "PROMOTED_INTERFACE"}}) +} +func InterfaceAsync(r QueryRunner) { + go r.Run("INSERT INTO async_records(id) VALUES (1)") + defer r.Run("INSERT INTO deferred_records(id) VALUES (1)") +} +func AsyncEntry(db *sql.DB) { InterfaceAsync(&SQLRunner{DB: db}) } +func AsyncRead(db *sql.DB) { db.Query("SELECT id FROM async_records") } +func DeferredRead(db *sql.DB) { db.Query("SELECT id FROM deferred_records") } + +type MissingRunner interface{ Handle(string) } + +func ExternalInterface(r MissingRunner) { r.Handle("EXTERNAL_VALUE") } diff --git a/testdata/sample/lifecycle.go b/testdata/sample/lifecycle.go new file mode 100644 index 0000000..9301ff4 --- /dev/null +++ b/testdata/sample/lifecycle.go @@ -0,0 +1,10 @@ +package sample + +import "context" + +func Worker(ctx context.Context) { <-ctx.Done() } +func Start(ctx context.Context) { child, cancel := context.WithCancel(ctx); defer cancel(); go Worker(child) } +func newContext(ctx context.Context) (context.Context,context.CancelFunc) { return context.WithCancel(ctx) } +func WrapperStart(ctx context.Context) { child, stop := newContext(ctx); defer stop(); go Worker(child) } +func Unreleased(ctx context.Context) { child, _ := context.WithCancel(ctx); Worker(child) } +func DualContexts(ctx context.Context) { _, first := context.WithCancel(ctx); _, second := context.WithCancel(ctx); first(); _ = second } diff --git a/testdata/sample/map_iteration.go b/testdata/sample/map_iteration.go new file mode 100644 index 0000000..390d8be --- /dev/null +++ b/testdata/sample/map_iteration.go @@ -0,0 +1,53 @@ +package sample + +import "database/sql" + +func UnknownMapIteration(callbacks map[string]func()) { + for _, callback := range callbacks { + callback() + } +} + +var PublicIterationDatabases = map[string]*sql.DB{"local": OpenOrdersDatabase()} + +func PublicMapIterationCaller() { + for _, db := range PublicIterationDatabases { + publicIterationDatabaseQuery(db) + } +} + +func publicIterationDatabaseQuery(db *sql.DB) { + db.Query("SELECT body FROM iteration_public_database_records") +} + +type iterationRecord struct { + Callback func(*sql.DB) + Database *sql.DB +} + +func MapIterationCaller() { + db := OpenOrdersDatabase() + callbacks := map[string]func(*sql.DB){"callback": iterationValueQuery} + for _, callback := range callbacks { + callback(db) + } + keys := map[*iterationRecord]string{&iterationRecord{Callback: iterationKeyQuery}: "payload"} + for record := range keys { + record.Callback(db) + } + records := map[string]iterationRecord{"record": {Callback: iterationRecordQuery, Database: db}} + for _, record := range records { + record.Callback(record.Database) + } + queries := map[string]string{"SELECT body FROM iteration_key_records": "SELECT body FROM iteration_value_records"} + for key, value := range queries { + consumeIterationKey(db, key) + consumeIterationValue(db, value) + } +} + +func iterationValueQuery(db *sql.DB) { db.Query("SELECT body FROM iteration_callback_records") } +func iterationKeyQuery(db *sql.DB) { db.Query("SELECT body FROM iteration_key_callback_records") } +func iterationRecordQuery(db *sql.DB) { db.Query("SELECT body FROM iteration_record_callback_records") } +func consumeIterationKey(db *sql.DB, query string) { db.Query(query) } +func consumeIterationValue(db *sql.DB, query string) { db.Query(query) } diff --git a/testdata/sample/migrations/001.sql b/testdata/sample/migrations/001.sql new file mode 100644 index 0000000..6cd8c55 --- /dev/null +++ b/testdata/sample/migrations/001.sql @@ -0,0 +1,7 @@ +CREATE TABLE records (body TEXT); +CREATE TABLE audit_log (body TEXT); +CREATE TABLE relation (parent_id TEXT REFERENCES records(body)); +CREATE INDEX record_body ON records(body); +CREATE TRIGGER record_change AFTER INSERT ON records BEGIN + INSERT INTO audit_log VALUES (NEW.body); +END; diff --git a/testdata/sample/mixed_functions.go b/testdata/sample/mixed_functions.go new file mode 100644 index 0000000..5a1c847 --- /dev/null +++ b/testdata/sample/mixed_functions.go @@ -0,0 +1,60 @@ +package sample + +import ( + "database/sql" + "sync" +) + +var PublicReplaceableFunction func() + +// The private callback has a known package initializer so this fixture tests +// visibility, not the real zero-value-before-first-write behavior. +var PublicFunctionRecord = struct { + Call func() + hidden func() +}{hidden: knownMixedFunction} +var PublicFunctionSlice []func() +var PublicFunctionMap map[string]func() + +func PublicInvokeFunction(callback func()) { forwardOpenFunction(callback) } +func forwardOpenFunction(callback func()) { callback(); defer callback(); go callback() } +func closedInvokeFunction(callback func()) { callback() } +func knownMixedFunction() {} + +func MixedFunctionCaller() { + PublicInvokeFunction(knownMixedFunction) + closedInvokeFunction(knownMixedFunction) + PublicReplaceableFunction = knownMixedFunction + PublicFunctionRecord.Call = knownMixedFunction + PublicFunctionRecord.hidden = knownMixedFunction + PublicFunctionSlice = []func(){knownMixedFunction} + PublicFunctionMap = map[string]func(){"local": knownMixedFunction} + readPublicFunctions() +} + +func readPublicFunctions() { + PublicReplaceableFunction() + PublicFunctionRecord.Call() + PublicFunctionSlice[0]() + PublicFunctionMap["local"]() +} +func readPrivateFunctionField() { PublicFunctionRecord.hidden() } + +func MixedDependencyFunction(choose bool) { + stop := localFunctionStop + if choose { + stop = sync.OnceValue(localFunctionStop) + } + stop() +} +func localFunctionStop() bool { return true } + +func PublicCallFactory(factory func() func()) { consumeOpenFactory(factory) } +func consumeOpenFactory(factory func() func()) { factory()() } +func closedFunctionFactory() func() { return knownMixedFunction } +func MixedFactoryCaller() { PublicCallFactory(closedFunctionFactory) } + +func PublicDispatchFunction(dispatch func(func(*sql.DB))) { dispatch(mixedDispatchQuery) } +func localDispatchFunction(callback func(*sql.DB)) { callback(OpenOrdersDatabase()) } +func MixedDispatchCaller() { PublicDispatchFunction(localDispatchFunction) } +func mixedDispatchQuery(db *sql.DB) { db.Query("SELECT body FROM mixed_dispatch_records") } diff --git a/testdata/sample/outside_interfaces.go b/testdata/sample/outside_interfaces.go new file mode 100644 index 0000000..272522e --- /dev/null +++ b/testdata/sample/outside_interfaces.go @@ -0,0 +1,56 @@ +package sample + +import "database/sql" + +type AnyRecord struct{ Value any } + +func PublicAnyInput(value any) { record := value.(APIInputRecord); record.Run(record.Database) } +func PublicAnyField(input AnyRecord) { + record, _ := input.Value.(APIInputRecord) + record.Run(record.Database) +} +func PublicAnyFactory(factory func() any) { + record := factory().(APIInputRecord) + record.Run(record.Database) +} +func PublicAnyCallback() func(any) { return escapedAnyCallback } +func escapedAnyCallback(value any) { record := value.(APIInputRecord); record.Run(record.Database) } + +var AnyGlobal any = APIInputRecord{Run: globalAnyQuery, Database: knownAggregateDatabase()} + +func useAnyGlobal() { record := AnyGlobal.(APIInputRecord); record.Run(record.Database) } +func closedAnyInput(value any) { record := value.(APIInputRecord); record.Run(record.Database) } +func localAnyFactory() any { + return APIInputRecord{Run: factoryAnyQuery, Database: knownAggregateDatabase()} +} + +type AnyRecordProvider interface{ Record() APIInputRecord } +type localAnyProvider struct{} + +func (localAnyProvider) Record() APIInputRecord { + return APIInputRecord{Run: providerAnyQuery, Database: knownAggregateDatabase()} +} +func PublicAnyProvider(provider AnyRecordProvider) { + record := provider.Record() + record.Run(record.Database) +} +func PublicAnyFunction(value any) { callback := value.(func()); callback() } +func anyFunctionTarget() {} +func OutsideInterfaceCaller() { + db := knownAggregateDatabase() + PublicAnyInput(APIInputRecord{Run: inputAnyQuery, Database: db}) + PublicAnyField(AnyRecord{Value: APIInputRecord{Run: fieldAnyQuery, Database: db}}) + PublicAnyFactory(localAnyFactory) + PublicAnyCallback()(APIInputRecord{Run: callbackAnyQuery, Database: db}) + useAnyGlobal() + closedAnyInput(APIInputRecord{Run: closedAnyQuery, Database: db}) + PublicAnyProvider(localAnyProvider{}) + PublicAnyFunction(anyFunctionTarget) +} +func inputAnyQuery(db *sql.DB) { db.Query("SELECT body FROM input_any_records") } +func fieldAnyQuery(db *sql.DB) { db.Query("SELECT body FROM field_any_records") } +func factoryAnyQuery(db *sql.DB) { db.Query("SELECT body FROM factory_any_records") } +func callbackAnyQuery(db *sql.DB) { db.Query("SELECT body FROM callback_any_records") } +func globalAnyQuery(db *sql.DB) { db.Query("SELECT body FROM global_any_records") } +func closedAnyQuery(db *sql.DB) { db.Query("SELECT body FROM closed_any_records") } +func providerAnyQuery(db *sql.DB) { db.Query("SELECT body FROM provider_any_records") } diff --git a/testdata/sample/prepared.go b/testdata/sample/prepared.go new file mode 100644 index 0000000..6ce0c9b --- /dev/null +++ b/testdata/sample/prepared.go @@ -0,0 +1,42 @@ +package sample + +import ( + "context" + "database/sql" +) + +func PrepareRecords(db *sql.DB) *sql.Stmt { + statement, _ := db.Prepare("INSERT INTO prepared_records (body) VALUES (?)") + return statement +} +func PreparedStart(db *sql.DB) { + statement := PrepareRecords(db) + defer statement.Close() + ExecuteRecords(statement) +} +func UnknownStatement(statement *sql.Stmt) { statement.Exec("SELECT body FROM unknown_payload") } + +func TransactionStatement(db *sql.DB) { + transaction, _ := db.Begin() + defer transaction.Rollback() + statement, _ := transaction.Prepare("SELECT body FROM transaction_records") + defer statement.Close() + statement.Query() + transaction.Commit() +} + +func ReboundStatement(db *sql.DB) { + statement := PrepareRecords(db) + transaction, _ := db.Begin() + defer transaction.Rollback() + first := transaction.Stmt(statement) + second := transaction.StmtContext(context.Background(), first) + defer second.Close() + ExecuteRebound(second) +} + +func ReboundUnknown(db *sql.DB, statement *sql.Stmt) { + transaction, _ := db.Begin() + rebound := transaction.Stmt(statement) + rebound.Exec("SELECT body FROM rebound_decoy") +} diff --git a/testdata/sample/prepared_execution.go b/testdata/sample/prepared_execution.go new file mode 100644 index 0000000..5a19abf --- /dev/null +++ b/testdata/sample/prepared_execution.go @@ -0,0 +1,11 @@ +package sample + +import "database/sql" + +func ExecuteRecords(statement *sql.Stmt) { + statement.Exec("SELECT body FROM decoy_payload") +} + +func ExecuteRebound(statement *sql.Stmt) { + statement.Exec("SELECT body FROM rebound_parameter_decoy") +} diff --git a/testdata/sample/recovery.go b/testdata/sample/recovery.go new file mode 100644 index 0000000..81e1ea0 --- /dev/null +++ b/testdata/sample/recovery.go @@ -0,0 +1,18 @@ +package sample + +func RecoveryDirect() { _ = recover() } +func RecoveryIndirect() { RecoveryDirect() } +func PanicRecovered() { + defer RecoveryDirect() + panic("owned panic") +} +func PanicIndirectRecovery() { + defer RecoveryIndirect() + panic("indirect recovery") +} +func PanicBare() { panic("bare panic") } + +func PanicClosureRecovery() { + defer func() { _ = recover() }() + panic("closure recovery") +} diff --git a/testdata/sample/sample.go b/testdata/sample/sample.go new file mode 100644 index 0000000..df307e0 --- /dev/null +++ b/testdata/sample/sample.go @@ -0,0 +1,31 @@ +package sample + +import ( + "context" + "database/sql" +) + +func Validate(n int) bool { return n > 0 } +func Live() bool { return Validate(1) } +func apply(check func(int) bool) bool { return check(1) } +func Recovery() bool { return apply(Validate) } +type Reader interface { Read() bool } +type Store struct{} +func (Store) Read() bool { return Validate(1) } +func Entry(r Reader) bool { return r.Read() } +func unrelated() {} +func UnusedCheck(n int) bool { return n < 0 } + +const records = "records" +func Write(db *sql.DB) { db.Exec("INSERT INTO " + records + " (body) VALUES (?)", "value") } +func Read(db *sql.DB) { db.Query("SELECT body FROM records") } +func Rebuild(db *sql.DB) { db.Query("SELECT body FROM records ORDER BY version") } +func Other(db *sql.DB) { db.Query("SELECT body FROM unrelated") } +func Dynamic(db *sql.DB, table string) { db.Query("SELECT body FROM " + table) } + +const completed = "TASK_COMPLETED" +type Event struct { EventType string } +func Publish() Event { return Event{EventType: completed} } +func Consume(e Event) bool { return e.EventType == completed } +func Another() Event { return Event{EventType: "TASK_FAILED"} } +func Stop(ctx context.Context) { child, cancel := context.WithCancel(ctx); defer cancel(); _ = child } diff --git a/testdata/sample/sql_factory_inputs.go b/testdata/sample/sql_factory_inputs.go new file mode 100644 index 0000000..75fa4db --- /dev/null +++ b/testdata/sample/sql_factory_inputs.go @@ -0,0 +1,56 @@ +package sample + +import ( + "context" + "database/sql" +) + +func PublicSQLFactories(database func() *sql.DB, connection func() *sql.Conn, transaction func() *sql.Tx, statement func() (*sql.Stmt, error)) { + openDatabaseFactoryQuery(database()) + openConnectionFactoryQuery(connection()) + openTransactionFactoryQuery(transaction()) + prepared, _ := statement() + openStatementFactoryQuery(prepared) +} + +func SQLFactoryCaller() { + PublicSQLFactories(knownSQLFactoryDatabase, localConnectionFactory, localTransactionFactory, localStatementFactory) + closedSQLFactories(knownSQLFactoryDatabase, localConnectionFactory, localTransactionFactory, localStatementFactory) +} + +func localConnectionFactory() *sql.Conn { + value, _ := knownSQLFactoryDatabase().Conn(context.Background()) + return value +} +func localTransactionFactory() *sql.Tx { value, _ := knownSQLFactoryDatabase().Begin(); return value } +func localStatementFactory() (*sql.Stmt, error) { + return knownSQLFactoryDatabase().Prepare("SELECT body FROM factory_statement_records") +} +func openDatabaseFactoryQuery(db *sql.DB) { db.Query("SELECT body FROM factory_database_records") } +func openConnectionFactoryQuery(conn *sql.Conn) { + conn.QueryContext(context.Background(), "SELECT body FROM factory_connection_records") +} +func openTransactionFactoryQuery(tx *sql.Tx) { + tx.Query("SELECT body FROM factory_transaction_records") +} +func openStatementFactoryQuery(stmt *sql.Stmt) { stmt.Query() } + +func closedSQLFactories(database func() *sql.DB, connection func() *sql.Conn, transaction func() *sql.Tx, statement func() (*sql.Stmt, error)) { + closedDatabaseFactoryQuery(database()) + closedConnectionFactoryQuery(connection()) + closedTransactionFactoryQuery(transaction()) + prepared, _ := statement() + closedStatementFactoryQuery(prepared) +} +func closedDatabaseFactoryQuery(db *sql.DB) { + db.Query("SELECT body FROM closed_factory_database_records") +} +func closedConnectionFactoryQuery(conn *sql.Conn) { + conn.QueryContext(context.Background(), "SELECT body FROM closed_factory_connection_records") +} +func closedTransactionFactoryQuery(tx *sql.Tx) { + tx.Query("SELECT body FROM closed_factory_transaction_records") +} +func closedStatementFactoryQuery(stmt *sql.Stmt) { stmt.Query() } + +func knownSQLFactoryDatabase() *sql.DB { db, _ := sql.Open("sqlite", "factory.db"); return db } diff --git a/testdata/sample/summary_escapes.go b/testdata/sample/summary_escapes.go new file mode 100644 index 0000000..d1cd0c0 --- /dev/null +++ b/testdata/sample/summary_escapes.go @@ -0,0 +1,50 @@ +package sample + +import ( + "context" + "sync" + "time" +) + +func summaryStopTarget() {} +func SummaryEscapeCaller() { + ctx, cancel := context.WithCancel(context.Background()) + time.AfterFunc(time.Hour, cancel) + defer time.AfterFunc(time.Hour, cancel) + go time.AfterFunc(time.Hour, cancel) + stop := context.AfterFunc(ctx, summaryStopTarget) + sync.OnceValue(stop) +} + +type LifecycleSummaries struct { + Cancel context.CancelFunc + Stop func() bool +} + +func returnedSummaryTarget() {} +func PublicLifecycleSummaries() LifecycleSummaries { + ctx, cancel := context.WithCancel(context.Background()) + return LifecycleSummaries{Cancel: cancel, Stop: context.AfterFunc(ctx, returnedSummaryTarget)} +} + +func makeGlobalCancellationSummary() context.CancelFunc { + _, cancel := context.WithCancel(context.Background()) + return cancel +} + +var PublicCancellationSummary = makeGlobalCancellationSummary() + +type escapingSchedulingContext struct { + context.Context + ready chan struct{} +} + +func (ctx *escapingSchedulingContext) Done() <-chan struct{} { return ctx.ready } +func (*escapingSchedulingContext) AfterFunc(callback func()) func() bool { + return time.AfterFunc(time.Hour, callback).Stop +} +func escapingSchedulerTarget() {} +func EscapingSchedulingCaller() { + ctx := &escapingSchedulingContext{Context: context.Background(), ready: make(chan struct{})} + context.AfterFunc(ctx, escapingSchedulerTarget) +} diff --git a/testdata/sample/tagged.go b/testdata/sample/tagged.go new file mode 100644 index 0000000..4a32e4e --- /dev/null +++ b/testdata/sample/tagged.go @@ -0,0 +1,5 @@ +//go:build traceextra + +package sample + +func Tagged() bool { return Validate(1) } diff --git a/testdata/sample/unresolved_functions.go b/testdata/sample/unresolved_functions.go new file mode 100644 index 0000000..39ff79b --- /dev/null +++ b/testdata/sample/unresolved_functions.go @@ -0,0 +1,16 @@ +package sample + +func UnknownFunctionCalls(callback func()) { + callback() + defer callback() + go callback() + copy(make([]string, 1), []string{"builtin"}) +} + +func KnownFunctionCalls() { + callback := func() {} + callback() + defer callback() + go callback() + copy(make([]string, 1), []string{"builtin"}) +} diff --git a/testdata/sample/waitgroup.go b/testdata/sample/waitgroup.go new file mode 100644 index 0000000..f17d4c9 --- /dev/null +++ b/testdata/sample/waitgroup.go @@ -0,0 +1,198 @@ +package sample + +import "sync" + +func GroupWorker(group *sync.WaitGroup) { defer group.Done() } +func GroupWait(group *sync.WaitGroup) { group.Wait() } +func GroupStart() { + var group, unrelated sync.WaitGroup + group.Add(1) + go GroupWorker(&group) + GroupWait(&group) + unrelated.Add(2) + unrelated.Done() + unrelated.Wait() +} + +func ManagedTask() {} +func GroupGo() { + var group sync.WaitGroup + group.Go(ManagedTask) + group.Wait() +} + +var GlobalGroup sync.WaitGroup + +type GroupOwner struct{ workers, other sync.WaitGroup } + +func GroupFields() { + var owner GroupOwner + owner.workers.Add(1) + GroupWorker(&owner.workers) + owner.other.Add(-1) + GlobalGroup.Add(2) + GroupWait(&GlobalGroup) +} + +func UnknownGroup(group *sync.WaitGroup) { group.Wait() } + +func BoundCompletion(done func()) { defer done() } +func BoundJoin(wait func()) { wait() } +func BoundGroup() { + var group, unrelated sync.WaitGroup + group.Add(1) + unrelated.Add(2) + BoundCompletion(group.Done) + BoundJoin(group.Wait) + finishOther := unrelated.Done + finishOther() +} + +func BoundGroupGo() { + var group sync.WaitGroup + add := group.Add + add(0) + start := group.Go + start(ManagedTask) + group.Wait() +} + +func BoundGroupChoices(wait bool) { + var first, second sync.WaitGroup + first.Add(1) + second.Add(2) + selected := first.Done + if wait { + selected = second.Wait + } + defer selected() +} + +type GroupCompletion interface{ Done() } +type GroupJoin interface{ Wait() } + +func InterfaceCompletion(group GroupCompletion) { defer group.Done() } +func InterfaceJoin(group GroupJoin) { group.Wait() } +func InterfaceBound(group GroupCompletion, join GroupJoin) { + BoundCompletion(group.Done) + BoundJoin(join.Wait) +} + +type OtherCompletion struct{} + +func (*OtherCompletion) Done() {} +func InterfaceGroups() { + var group sync.WaitGroup + group.Add(1) + InterfaceCompletion(&group) + InterfaceJoin(&group) + InterfaceBound(&group, &group) + InterfaceCompletion(&OtherCompletion{}) +} + +type ValueGroup struct{ sync.WaitGroup } +type PointerGroup struct{ *ValueGroup } +type NestedGroup struct{ PointerGroup } + +type DirectPointerGroup struct{ *sync.WaitGroup } +type DirectNestedGroup struct{ DirectPointerGroup } + +type ClosedGroup struct{ sync.WaitGroup } + +func SimplePromotedMethodExpressions() { + group := &DirectNestedGroup{DirectPointerGroup: DirectPointerGroup{WaitGroup: &sync.WaitGroup{}}} + group.WaitGroup.Add(2) + (*DirectNestedGroup).Add(group, 1) +} + +func closedGroupWait(wait func(*ClosedGroup), group *ClosedGroup) { wait(group) } +func ClosedWaitGroupHelper() { + group := &ClosedGroup{} + closedGroupWait((*ClosedGroup).Wait, group) +} + +func MethodExpressionAdd(group *NestedGroup, delta int) { (*NestedGroup).Add(group, delta) } +func MethodExpressionDone(group *NestedGroup) { done := (*NestedGroup).Done; go done(group) } +func MethodExpressionWait(group *NestedGroup) { wait := (*NestedGroup).Wait; defer wait(group) } +func callGroupWait(wait func(*NestedGroup), group *NestedGroup) { wait(group) } +func MethodExpressionHelper(group *NestedGroup) { callGroupWait((*NestedGroup).Wait, group) } + +func callMixedGroupMethod(method func(*NestedGroup), group *NestedGroup) { method(group) } +func MixedMethodExpressionRoles() { + group := &NestedGroup{PointerGroup: PointerGroup{ValueGroup: &ValueGroup{}}} + group.WaitGroup.Add(1) + callMixedGroupMethod((*NestedGroup).Done, group) + callMixedGroupMethod((*NestedGroup).Wait, group) +} + +type UserDone struct{} + +func (*UserDone) Done() {} +func SameNameNonWaitGroupMethod(group *UserDone) { (*UserDone).Done(group) } + +func knownWaitGroupTask() {} +func PublicMethodExpressionGo(flag bool, outside func()) { + group := &sync.WaitGroup{} + task := knownWaitGroupTask + if flag { + task = outside + } + (*sync.WaitGroup).Go(group, task) +} + +func PublicMethodExpressionGoNil(flag bool) { + group := &sync.WaitGroup{} + task := knownWaitGroupTask + if flag { + task = nil + } + (*sync.WaitGroup).Go(group, task) +} + +func PromotedMethodExpressionSites(flag bool, outside *NestedGroup) { + known := &NestedGroup{PointerGroup: PointerGroup{ValueGroup: &ValueGroup{}}} + unrelated := &NestedGroup{PointerGroup: PointerGroup{ValueGroup: &ValueGroup{}}} + known.WaitGroup.Add(7) + add := (*NestedGroup).Add + add(known, 1) + done := (*NestedGroup).Done + go done(known) + wait := (*NestedGroup).Wait + defer wait(known) + callGroupWait((*NestedGroup).Wait, known) + unrelated.WaitGroup.Add(3) + selected := known + if flag { + selected = outside + } + (*NestedGroup).Done(selected) +} + +func PromotedMethodExpressions(flag bool, outside *NestedGroup) { + known := &NestedGroup{PointerGroup: PointerGroup{ValueGroup: &ValueGroup{}}} + known.WaitGroup.Add(1) + selected := known + if flag { + selected = outside + } + (*NestedGroup).Done(selected) +} + +func PublicWaitGroupInput(group *sync.WaitGroup) { + group.Add(1) + wait := group.Wait + wait() +} + +func uncalledWaitGroupInput(group *sync.WaitGroup) { group.Done() } + +func EscapingWaitGroupCallback() func(*sync.WaitGroup) { + return func(group *sync.WaitGroup) { (*sync.WaitGroup).Done(group) } +} + +var PublicGlobalWaitGroup sync.WaitGroup + +func PublicGlobalWaitGroupSites() { + PublicGlobalWaitGroup.Add(1) + (*sync.WaitGroup).Done(&PublicGlobalWaitGroup) +} diff --git a/tools/check_gofmt.py b/tools/check_gofmt.py new file mode 100644 index 0000000..93c8c02 --- /dev/null +++ b/tools/check_gofmt.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +"""Check that staged Go files are already gofmt-formatted.""" + +from __future__ import annotations + +import subprocess +import sys + + +def main_for_files(files: list[str]) -> int: + go_files = [name for name in files if name.endswith(".go")] + if not go_files: + return 0 + + try: + result = subprocess.run( + ["gofmt", "-l", *go_files], + check=False, + capture_output=True, + text=True, + ) + except OSError as exc: + print(f"cannot run gofmt: {exc}", file=sys.stderr) + return 2 + + if result.returncode != 0: + if result.stderr: + print(result.stderr, file=sys.stderr, end="") + return result.returncode + unformatted = [line for line in result.stdout.splitlines() if line] + if unformatted: + print("Go files need gofmt:", file=sys.stderr) + for name in unformatted: + print(f" {name}", file=sys.stderr) + return 1 + return 0 + + +def main() -> int: + return main_for_files(sys.argv[1:]) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/check_push_state.py b/tools/check_push_state.py new file mode 100644 index 0000000..908a24c --- /dev/null +++ b/tools/check_push_state.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +"""Require pre-push checks to run on the exact, clean revision being pushed.""" + +from __future__ import annotations + +import os +import re +import subprocess +import sys +from dataclasses import dataclass +from typing import Callable + + +def state_errors(to_ref: str, head: str, porcelain_status: str) -> list[str]: + errors: list[str] = [] + if to_ref and to_ref != head: + errors.append( + f"push source {to_ref} does not match checked-out HEAD {head}; " + "check out the revision being pushed and rerun the checks" + ) + if porcelain_status.strip(): + errors.append( + "the worktree has staged, unstaged, or untracked files; " + "commit or remove them before pushing" + ) + return errors + + +@dataclass(frozen=True) +class PushUpdate: + local_ref: str + local_oid: str + remote_ref: str + remote_oid: str + + @property + def is_deletion(self) -> bool: + return self.local_oid == "0" * len(self.local_oid) + + +def parse_push_updates(push_input: str, oid_length: int) -> tuple[list[PushUpdate], list[str]]: + if oid_length <= 0: + return [], ["cannot validate push updates without a valid object ID length"] + if push_input == "": + return [], [] + + zeros = "0" * oid_length + oid_pattern = re.compile(rf"[0-9a-fA-F]{{{oid_length}}}") + updates: list[PushUpdate] = [] + errors: list[str] = [] + for line_number, line in enumerate(push_input.splitlines(), 1): + fields = line.split() + if len(fields) != 4: + errors.append(f"malformed pre-push input on line {line_number}; expected four fields") + continue + local_ref, local_oid, remote_ref, remote_oid = fields + if not remote_ref.startswith("refs/"): + errors.append(f"malformed remote ref on pre-push input line {line_number}: {remote_ref!r}") + continue + if oid_pattern.fullmatch(local_oid) is None or oid_pattern.fullmatch(remote_oid) is None: + errors.append(f"malformed object ID on pre-push input line {line_number}") + continue + is_deletion = local_oid.casefold() == zeros + if (is_deletion and local_ref != "(delete)") or (not is_deletion and local_ref == "(delete)"): + errors.append(f"inconsistent deletion marker on pre-push input line {line_number}") + continue + updates.append(PushUpdate(local_ref, local_oid.casefold(), remote_ref, remote_oid.casefold())) + return updates, errors + + +def validate_push_updates( + updates: list[PushUpdate], + head: str, + resolve_commit: Callable[[str], str | None], +) -> tuple[list[str], bool]: + errors: list[str] = [] + has_updates = False + for update in updates: + if update.is_deletion: + continue + has_updates = True + commit = resolve_commit(update.local_oid) + if commit is None: + errors.append( + f"pushed object for {update.remote_ref} does not resolve to a commit; " + "push only commit refs or tags that point to a commit" + ) + elif commit.casefold() != head.casefold(): + errors.append( + f"pushed ref {update.local_ref} ({update.remote_ref}) resolves to {commit}, " + f"not checked-out HEAD {head}; check out the revision being pushed and rerun the checks" + ) + return errors, has_updates + + +def git_text(*args: str) -> str: + result = subprocess.run( + ["git", *args], + check=False, + capture_output=True, + text=True, + ) + if result.returncode: + raise RuntimeError(result.stderr.strip() or f"git {' '.join(args)} failed") + return result.stdout.strip() + + +def main() -> int: + try: + head = git_text("rev-parse", "HEAD") + status = git_text("status", "--porcelain", "--untracked-files=all") + except (OSError, RuntimeError) as exc: + print(f"cannot verify push state: {exc}", file=sys.stderr) + return 2 + + errors = state_errors(os.environ.get("PRE_COMMIT_TO_REF", ""), head, status) + if errors: + for error in errors: + print(error, file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/install_hooks.py b/tools/install_hooks.py new file mode 100644 index 0000000..feeff2c --- /dev/null +++ b/tools/install_hooks.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +"""Install the standard commit hook and Contract Tracer's multi-ref push hook.""" + +from __future__ import annotations + +import os +import shlex +import subprocess +import sys +from pathlib import Path + + +PRE_COMMIT_MARKER = b"File generated by pre-commit" +PRE_PUSH_MARKER = b"Managed by Contract Tracer: multi-ref pre-push validation" + + +def git_output(repo_root: Path, *args: str, allow_failure: bool = False) -> str: + result = subprocess.run( + ["git", *args], + cwd=repo_root, + check=False, + capture_output=True, + text=True, + ) + if result.returncode and not allow_failure: + raise RuntimeError(result.stderr.strip() or f"git {' '.join(args)} failed") + return result.stdout.strip() + + +def hooks_directory(repo_root: Path) -> Path: + configured = git_output(repo_root, "config", "--path", "--get", "core.hooksPath", allow_failure=True) + if configured: + path = Path(configured) + return (path if path.is_absolute() else repo_root / path).resolve() + path = Path(git_output(repo_root, "rev-parse", "--git-path", "hooks")) + return (path if path.is_absolute() else repo_root / path).resolve() + + +def _read_existing(path: Path) -> bytes | None: + try: + return path.read_bytes() + except FileNotFoundError: + return None + + +def _preflight_hooks(pre_commit_hook: Path, pre_push_hook: Path) -> None: + existing = _read_existing(pre_commit_hook) + if existing is not None and PRE_COMMIT_MARKER not in existing: + raise RuntimeError( + f"refusing to replace unrecognized hook {pre_commit_hook}; " + "move it or chain it explicitly before installing Contract Tracer hooks" + ) + + for legacy in (pre_commit_hook.with_name(pre_commit_hook.name + ".legacy"), pre_push_hook.with_name(pre_push_hook.name + ".legacy")): + if legacy.exists(): + raise RuntimeError( + f"refusing to install hooks while legacy hook {legacy} exists; " + "preserve or explicitly chain it before installing Contract Tracer hooks" + ) + + existing = _read_existing(pre_push_hook) + if ( + existing is not None + and PRE_COMMIT_MARKER not in existing + and PRE_PUSH_MARKER not in existing + ): + raise RuntimeError( + f"refusing to replace unrecognized hook {pre_push_hook}; " + "move it or chain it explicitly before installing Contract Tracer hooks" + ) + + +def _shell_path(path: Path, *, resolve: bool = True) -> str: + value = str(path.resolve()) if resolve else os.path.abspath(os.fspath(path)) + if os.name == "nt": + value = value.replace("\\", "/") + return shlex.quote(value) + + +def render_pre_push_hook(python_executable: str, script_path: Path) -> bytes: + python_path = _shell_path(Path(python_executable), resolve=False) + script = _shell_path(script_path) + content = f"""#!/bin/sh +# {PRE_PUSH_MARKER.decode()} +# Reinstall or update this hook with: python tools/install_hooks.py +CONTRACT_TRACER_PYTHON={python_path} +CONTRACT_TRACER_SCRIPT={script} +exec "$CONTRACT_TRACER_PYTHON" "$CONTRACT_TRACER_SCRIPT" "$@" +""" + return content.encode("utf-8") + + +def install_pre_push_hook( + repo_root: Path, + python_executable: str = sys.executable, + script_path: Path | None = None, +) -> Path: + repo_root = repo_root.resolve() + if script_path is None: + script_path = Path(__file__).with_name("pre_push.py") + script_path = script_path.resolve() + hook = hooks_directory(repo_root) / "pre-push" + legacy = hook.with_name(hook.name + ".legacy") + if legacy.exists(): + raise RuntimeError( + f"refusing to install while legacy hook {legacy} exists; " + "preserve or explicitly chain it before installing Contract Tracer hooks" + ) + current = _read_existing(hook) + if current is not None and PRE_COMMIT_MARKER not in current and PRE_PUSH_MARKER not in current: + raise RuntimeError( + f"refusing to replace unrecognized hook {hook}; " + "move it or chain it explicitly before installing Contract Tracer hooks" + ) + + hook.parent.mkdir(parents=True, exist_ok=True) + temporary = hook.with_name(hook.name + ".contract-tracer-tmp") + temporary.write_bytes(render_pre_push_hook(python_executable, script_path)) + if os.name != "nt": + temporary.chmod(0o755) + os.replace(temporary, hook) + return hook + + +def install_hooks(repo_root: Path, python_executable: str = sys.executable) -> None: + repo_root = repo_root.resolve() + hook_dir = hooks_directory(repo_root) + pre_commit_hook = hook_dir / "pre-commit" + pre_push_hook = hook_dir / "pre-push" + _preflight_hooks(pre_commit_hook, pre_push_hook) + + result = subprocess.run( + [python_executable, "-m", "pre_commit", "install", "--hook-type", "pre-commit"], + cwd=repo_root, + check=False, + ) + if result.returncode: + raise RuntimeError("pre-commit could not install the commit hook") + install_pre_push_hook(repo_root, python_executable) + + +def main() -> int: + repo_root = Path.cwd() + try: + git_output(repo_root, "rev-parse", "--show-toplevel") + install_hooks(repo_root) + except (OSError, RuntimeError) as exc: + print(f"cannot install Contract Tracer hooks: {exc}", file=sys.stderr) + return 1 + print("Installed pre-commit checks and the multi-ref pre-push hook.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/pre_push.py b/tools/pre_push.py new file mode 100644 index 0000000..f130ff1 --- /dev/null +++ b/tools/pre_push.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +"""Validate every ref in Git's pre-push input before running repository checks.""" + +from __future__ import annotations + +import os +import subprocess +import sys + +from check_push_state import parse_push_updates, state_errors, validate_push_updates + + +def git_text(*args: str) -> str: + result = subprocess.run( + ["git", *args], + check=False, + capture_output=True, + text=True, + ) + if result.returncode: + raise RuntimeError(result.stderr.strip() or f"git {' '.join(args)} failed") + return result.stdout.strip() + + +def resolve_commit(oid: str) -> str | None: + result = subprocess.run( + ["git", "rev-parse", "--verify", f"{oid}^{{commit}}"], + check=False, + capture_output=True, + text=True, + ) + if result.returncode: + return None + commit = result.stdout.strip() + if not commit or any(character not in "0123456789abcdefABCDEF" for character in commit): + return None + return commit.casefold() + + +def reject(errors: list[str]) -> int: + for error in errors: + print(error, file=sys.stderr) + return 1 + + +def main() -> int: + if len(sys.argv) != 3: + print("pre-push hook requires Git's remote name and URL arguments", file=sys.stderr) + return 2 + try: + push_input = sys.stdin.read() + object_format = git_text("rev-parse", "--show-object-format") + oid_length = {"sha1": 40, "sha256": 64}.get(object_format) + if oid_length is None: + raise RuntimeError(f"unsupported Git object format: {object_format}") + updates, errors = parse_push_updates(push_input, oid_length) + if errors: + return reject(errors) + if not any(not update.is_deletion for update in updates): + return 0 + head = git_text("rev-parse", "HEAD") + errors, has_updates = validate_push_updates(updates, head, resolve_commit) + if errors: + return reject(errors) + if not has_updates: + return 0 + + status = git_text("status", "--porcelain", "--untracked-files=all") + except (OSError, RuntimeError) as exc: + print(f"cannot verify push state: {exc}", file=sys.stderr) + return 2 + + errors = state_errors(head, head, status) + if errors: + return reject(errors) + + environment = os.environ.copy() + environment["PRE_COMMIT_TO_REF"] = head + try: + result = subprocess.run( + [sys.executable, "-m", "pre_commit", "run", "--all-files", "--hook-stage", "pre-push"], + check=False, + env=environment, + ) + except OSError as exc: + print(f"cannot run pre-push validation: {exc}", file=sys.stderr) + return 2 + if result.returncode: + return result.returncode + + try: + checked_head = git_text("rev-parse", "HEAD") + checked_status = git_text("status", "--porcelain", "--untracked-files=all") + except (OSError, RuntimeError) as exc: + print(f"cannot recheck push state after validation: {exc}", file=sys.stderr) + return 2 + errors = state_errors(head, checked_head, checked_status) + if errors: + return reject(errors) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/test_check_gofmt.py b/tools/test_check_gofmt.py new file mode 100644 index 0000000..2eee307 --- /dev/null +++ b/tools/test_check_gofmt.py @@ -0,0 +1,50 @@ +import unittest +from unittest.mock import patch + +import check_gofmt + + +class GoFormatTests(unittest.TestCase): + @patch("check_gofmt.subprocess.run") + def test_formatted_files_pass(self, run): + run.return_value.returncode = 0 + run.return_value.stdout = "" + run.return_value.stderr = "" + + self.assertEqual(check_gofmt.main_for_files(["main.go"]), 0) + run.assert_called_once_with( + ["gofmt", "-l", "main.go"], + check=False, + capture_output=True, + text=True, + ) + + @patch("check_gofmt.subprocess.run") + def test_unformatted_files_are_rejected_without_rewriting(self, run): + run.return_value.returncode = 0 + run.return_value.stdout = "main.go\n" + run.return_value.stderr = "" + + self.assertEqual(check_gofmt.main_for_files(["main.go"]), 1) + self.assertEqual(run.call_args.args[0], ["gofmt", "-l", "main.go"]) + + @patch("check_gofmt.subprocess.run") + def test_gofmt_failure_is_returned(self, run): + run.return_value.returncode = 2 + run.return_value.stdout = "" + run.return_value.stderr = "gofmt failed\n" + + self.assertEqual(check_gofmt.main_for_files(["main.go"]), 2) + + @patch("check_gofmt.subprocess.run", side_effect=FileNotFoundError("missing")) + def test_missing_gofmt_is_an_error(self, run): + self.assertEqual(check_gofmt.main_for_files(["main.go"]), 2) + + @patch("check_gofmt.subprocess.run") + def test_no_go_files_skips_tool(self, run): + self.assertEqual(check_gofmt.main_for_files(["fixture.txt"]), 0) + run.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/test_check_push_state.py b/tools/test_check_push_state.py new file mode 100644 index 0000000..185cd82 --- /dev/null +++ b/tools/test_check_push_state.py @@ -0,0 +1,116 @@ +import unittest + +from check_push_state import parse_push_updates, state_errors, validate_push_updates + + +class PushStateTests(unittest.TestCase): + def test_clean_matching_push_revision_passes(self): + self.assertEqual(state_errors("abc123", "abc123", ""), []) + + def test_wrong_push_revision_is_rejected(self): + errors = state_errors("abc123", "def456", "") + self.assertEqual(len(errors), 1) + self.assertIn("does not match checked-out HEAD", errors[0]) + self.assertIn("check out the revision being pushed", errors[0]) + + def test_dirty_tracked_or_untracked_worktree_is_rejected(self): + errors = state_errors("abc123", "abc123", " M source.go\n?? new.go\n") + self.assertEqual(len(errors), 1) + self.assertIn("staged, unstaged, or untracked files", errors[0]) + + def test_manual_pre_push_run_without_revision_still_checks_cleanliness(self): + self.assertEqual(state_errors("", "abc123", ""), []) + errors = state_errors("", "abc123", " M source.go") + self.assertEqual(len(errors), 1) + self.assertIn("worktree has", errors[0]) + + def test_wrong_revision_and_dirty_tree_both_reported(self): + errors = state_errors("abc123", "def456", "?? new.go") + self.assertEqual(len(errors), 2) + + +class PushInputTests(unittest.TestCase): + def setUp(self): + self.head = "a" * 40 + + def test_empty_and_deletion_only_inputs_skip_validation(self): + updates, errors = parse_push_updates("", len(self.head)) + self.assertEqual((updates, errors), ([], [])) + updates, errors = parse_push_updates( + f"(delete) {'0' * 40} refs/heads/remove {'b' * 40}\n", + len(self.head), + ) + self.assertEqual(errors, []) + self.assertEqual(len(updates), 1) + self.assertTrue(updates[0].is_deletion) + validation_errors, has_updates = validate_push_updates(updates, self.head, lambda _: None) + self.assertEqual(validation_errors, []) + self.assertFalse(has_updates) + + def test_malformed_and_wrong_width_input_fails_closed(self): + for text, oid_length in (("malformed input\n", 40), (f"refs/heads/main {self.head} refs/heads/main {'b' * 64}\n", 40)): + with self.subTest(text=text): + updates, errors = parse_push_updates(text, oid_length) + self.assertEqual(updates, []) + self.assertTrue(errors) + + def test_sha256_object_ids_use_their_native_width(self): + head = "a" * 64 + zeros = "0" * 64 + updates, errors = parse_push_updates( + f"HEAD~0 {head} refs/heads/sha256 {zeros}\n", + len(head), + ) + self.assertEqual(errors, []) + self.assertEqual(len(updates), 1) + validation_errors, has_updates = validate_push_updates(updates, head, lambda _: head) + self.assertEqual(validation_errors, []) + self.assertTrue(has_updates) + + def test_every_ref_must_resolve_to_head_independent_of_order(self): + old = "b" * 40 + updates, errors = parse_push_updates( + f"refs/heads/current {self.head} refs/heads/current {'0' * 40}\n" + f"refs/heads/older {old} refs/heads/older {'0' * 40}\n", + len(self.head), + ) + self.assertEqual(errors, []) + resolve = {self.head: self.head, old: old}.get + forward_errors, has_updates = validate_push_updates(updates, self.head, resolve) + reverse_errors, reverse_has_updates = validate_push_updates(list(reversed(updates)), self.head, resolve) + self.assertTrue(has_updates) + self.assertTrue(reverse_has_updates) + self.assertEqual(len(forward_errors), 1) + self.assertEqual(len(reverse_errors), 1) + self.assertIn("not checked-out HEAD", forward_errors[0]) + self.assertIn("not checked-out HEAD", reverse_errors[0]) + + def test_multiple_refs_to_head_and_tag_aliases_are_valid(self): + tag_object = "c" * 40 + updates, errors = parse_push_updates( + f"refs/heads/main {self.head} refs/heads/main {'0' * 40}\n" + f"refs/tags/release {tag_object} refs/tags/release {'0' * 40}\n", + len(self.head), + ) + self.assertEqual(errors, []) + resolve = {self.head: self.head, tag_object: self.head}.get + validation_errors, has_updates = validate_push_updates(updates, self.head, resolve) + self.assertEqual(validation_errors, []) + self.assertTrue(has_updates) + + def test_noncommit_and_old_commit_refs_are_rejected(self): + old = "b" * 40 + blob = "c" * 40 + for oid, resolved in ((old, old), (blob, None)): + updates, errors = parse_push_updates( + f"refs/heads/topic {oid} refs/heads/topic {'0' * 40}\n", + len(self.head), + ) + self.assertEqual(errors, []) + validation_errors, has_updates = validate_push_updates(updates, self.head, lambda _: resolved) + self.assertTrue(has_updates) + self.assertEqual(len(validation_errors), 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/test_pre_push.py b/tools/test_pre_push.py new file mode 100644 index 0000000..941d529 --- /dev/null +++ b/tools/test_pre_push.py @@ -0,0 +1,354 @@ +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +import venv + +from install_hooks import ( + PRE_COMMIT_MARKER, + PRE_PUSH_MARKER, + hooks_directory, + install_hooks, + install_pre_push_hook, + render_pre_push_hook, +) + + +def git(cwd, *args, input_text=None, check=True, env=None): + result = subprocess.run( + ["git", *args], + cwd=cwd, + input=input_text, + check=False, + capture_output=True, + text=True, + env=env, + ) + if check and result.returncode: + raise AssertionError(f"git {' '.join(args)} failed: {result.stderr}\n{result.stdout}") + return result + + +class PrePushHookTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(prefix="contract-tracer-push-test-") + self.addCleanup(self.temporary.cleanup) + root = Path(self.temporary.name) + self.work = root / "working copy" + self.remote = root / "remote.git" + self.stub = root / "stub" + self.log = root / "pre-commit.log" + git(root, "init", "--bare", "--initial-branch=main", str(self.remote)) + git(root, "--git-dir", str(self.remote), "config", "receive.denyDeleteCurrent", "ignore") + git(root, "init", "--initial-branch=main", str(self.work)) + git(self.work, "config", "user.name", "Hook Test") + git(self.work, "config", "user.email", "hook-test@example.invalid") + (self.work / "source.txt").write_text("first\n", encoding="utf-8") + git(self.work, "add", "source.txt") + git(self.work, "commit", "-m", "first") + self.first_commit = git(self.work, "rev-parse", "HEAD").stdout.strip() + git(self.work, "remote", "add", "origin", str(self.remote)) + git(self.work, "push", "origin", "main") + (self.work / "source.txt").write_text("second\n", encoding="utf-8") + git(self.work, "commit", "-am", "second") + self.head = git(self.work, "rev-parse", "HEAD").stdout.strip() + git(self.work, "branch", "older", self.first_commit) + git(self.work, "tag", "current-expression", "HEAD~0") + + pre_commit_stub = self.stub / "pre_commit" + pre_commit_stub.mkdir(parents=True) + (pre_commit_stub / "__init__.py").write_text("", encoding="utf-8") + (pre_commit_stub / "__main__.py").write_text( + "import json, os, pathlib, sys\n" + "record = {'args': sys.argv[1:], 'to_ref': os.environ.get('PRE_COMMIT_TO_REF'), 'cwd': os.getcwd()}\n" + "with pathlib.Path(os.environ['PRE_COMMIT_STUB_LOG']).open('a', encoding='utf-8') as stream:\n" + " stream.write(json.dumps(record) + '\\n')\n" + "target = os.environ.get('PRE_COMMIT_STUB_CHECKOUT')\n" + "if target:\n" + " import subprocess\n" + " subprocess.run(['git', 'checkout', '--detach', target], check=True)\n" + "late_file = os.environ.get('PRE_COMMIT_STUB_WRITE_FILE')\n" + "if late_file:\n" + " pathlib.Path(late_file).write_text('late change\\n', encoding='utf-8')\n", + encoding="utf-8", + ) + self.environment = os.environ.copy() + self.environment["PYTHONPATH"] = str(self.stub) + os.pathsep + self.environment.get("PYTHONPATH", "") + self.environment["PRE_COMMIT_STUB_LOG"] = str(self.log) + self.hook = install_pre_push_hook( + self.work, + sys.executable, + Path(__file__).with_name("pre_push.py"), + ) + + def push(self, *refspecs): + return git(self.work, "push", "origin", *refspecs, check=False, env=self.environment) + + def log_records(self): + if not self.log.exists(): + return [] + return [json.loads(line) for line in self.log.read_text(encoding="utf-8").splitlines()] + + def assert_remote_missing(self, ref): + result = git(self.remote.parent, "--git-dir", str(self.remote), "show-ref", "--verify", "--quiet", ref, check=False) + self.assertNotEqual(result.returncode, 0, f"unexpected remote ref {ref}") + + def test_installed_hook_validates_every_ref_before_running_precommit(self): + orders = ( + ("main:refs/heads/head-first", "older:refs/heads/old-second"), + ("older:refs/heads/old-first", "main:refs/heads/head-second"), + ) + for refspecs in orders: + result = self.push(*refspecs) + output = result.stdout + result.stderr + self.assertNotEqual(result.returncode, 0, output) + self.assertIn("not checked-out HEAD", output) + self.assertEqual(self.log_records(), []) + for ref in ( + "refs/heads/head-first", + "refs/heads/old-second", + "refs/heads/old-first", + "refs/heads/head-second", + ): + self.assert_remote_missing(ref) + + result = self.push("main:refs/heads/single-head") + self.assertEqual(result.returncode, 0, result.stderr) + records = self.log_records() + self.assertEqual(len(records), 1) + self.assertEqual(records[0]["args"], ["run", "--all-files", "--hook-stage", "pre-push"]) + self.assertEqual(records[0]["to_ref"], self.head) + + result = self.push("main:refs/heads/multiple-a", "main:refs/heads/multiple-b") + self.assertEqual(result.returncode, 0, result.stderr) + records = self.log_records() + self.assertEqual(len(records), 2, "multiple valid refs should run validation exactly once") + self.assertEqual(records[-1]["to_ref"], self.head) + + result = self.push( + f"{self.head}:refs/heads/by-sha", + "HEAD~0:refs/heads/by-expression", + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(len(self.log_records()), 3) + + def test_hook_resolves_lightweight_and_annotated_tags_to_commits(self): + git(self.work, "tag", "lightweight-head", self.head) + git(self.work, "tag", "--annotate", "annotated-head", self.head, "--message", "release") + result = self.push("refs/tags/lightweight-head", "refs/tags/annotated-head") + self.assertEqual(result.returncode, 0, result.stderr) + records = self.log_records() + self.assertEqual(len(records), 1) + + git(self.work, "tag", "old-commit", self.first_commit) + result = self.push("refs/tags/old-commit") + self.assertNotEqual(result.returncode, 0) + self.assertIn("not checked-out HEAD", result.stdout + result.stderr) + self.assertEqual(len(self.log_records()), 1) + self.assert_remote_missing("refs/tags/old-commit") + + blob = git(self.work, "hash-object", "-w", "--stdin", input_text="not a commit\n").stdout.strip() + git(self.work, "tag", "--annotate", "blob-object", blob, "--message", "invalid target") + result = self.push("refs/tags/blob-object") + self.assertNotEqual(result.returncode, 0) + self.assertIn("does not resolve to a commit", result.stdout + result.stderr) + self.assertEqual(len(self.log_records()), 1) + + def test_dirty_tree_blocks_updates_but_deletions_skip_checks(self): + result = self.push("main:refs/heads/dirty-control") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(len(self.log_records()), 1) + + (self.work / "untracked.txt").write_text("dirty\n", encoding="utf-8") + result = self.push("main:refs/heads/dirty-rejected") + self.assertNotEqual(result.returncode, 0) + self.assertIn("untracked files", result.stdout + result.stderr) + self.assertEqual(len(self.log_records()), 1) + + result = self.push(":refs/heads/dirty-control") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(len(self.log_records()), 1, "deletion-only push must skip validation") + self.assert_remote_missing("refs/heads/dirty-control") + self.assert_remote_missing("refs/heads/dirty-rejected") + + def test_deletion_from_unborn_worktree_skips_head_lookup_and_checks(self): + unborn = self.work.parent / "unborn" + git(unborn.parent, "init", "--initial-branch=main", str(unborn)) + git(unborn, "remote", "add", "origin", str(self.remote)) + install_pre_push_hook(unborn, sys.executable, Path(__file__).with_name("pre_push.py")) + + result = git(unborn, "push", "origin", ":refs/heads/main", check=False, env=self.environment) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.log_records(), []) + self.assert_remote_missing("refs/heads/main") + + def test_malformed_wrapper_input_fails_before_validation(self): + result = subprocess.run( + [sys.executable, str(Path(__file__).with_name("pre_push.py")), "origin", str(self.remote)], + cwd=self.work, + input="not four fields\n", + check=False, + capture_output=True, + text=True, + env=self.environment, + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("malformed pre-push input", result.stdout + result.stderr) + self.assertEqual(self.log_records(), []) + + def test_runner_cannot_change_checked_out_head_before_push_continues(self): + self.environment["PRE_COMMIT_STUB_CHECKOUT"] = self.first_commit + + result = self.push("main:refs/heads/runner-changed-head") + + self.assertNotEqual(result.returncode, 0) + self.assertIn("does not match checked-out HEAD", result.stdout + result.stderr) + self.assertEqual(len(self.log_records()), 1) + self.assert_remote_missing("refs/heads/runner-changed-head") + + def test_runner_cannot_leave_worktree_dirty_before_push_continues(self): + late_file = self.work / "late-untracked.txt" + self.environment["PRE_COMMIT_STUB_WRITE_FILE"] = str(late_file) + + result = self.push("main:refs/heads/runner-dirtied-tree") + + self.assertNotEqual(result.returncode, 0) + self.assertIn("worktree has staged, unstaged, or untracked files", result.stdout + result.stderr) + self.assertTrue(late_file.exists()) + self.assertEqual(len(self.log_records()), 1) + self.assert_remote_missing("refs/heads/runner-dirtied-tree") + + def test_installed_hook_runs_with_selected_pip_free_venv(self): + venv_root = self.work.parent / "isolated venv" + venv.EnvBuilder(with_pip=False).create(venv_root) + python = venv_root / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + purelib = subprocess.run( + [str(python), "-c", "import sysconfig; print(sysconfig.get_paths()['purelib'])"], + check=True, + capture_output=True, + text=True, + ).stdout.strip() + log = self.work.parent / "selected-venv.json" + Path(purelib, "pre_commit.py").write_text( + "import json, os, pathlib, sys\n" + "pathlib.Path(os.environ['SELECTED_VENV_LOG']).write_text(\n" + " json.dumps({'prefix': sys.prefix, 'args': sys.argv[1:]}), encoding='utf-8')\n", + encoding="utf-8", + ) + + work = self.work.parent / "venv work" + remote = self.work.parent / "venv remote.git" + git(self.work.parent, "init", "--bare", "--initial-branch=main", str(remote)) + git(work.parent, "init", "--initial-branch=main", str(work)) + git(work, "config", "user.name", "Hook Test") + git(work, "config", "user.email", "hook-test@example.invalid") + (work / "tracked.txt").write_text("tracked\n", encoding="utf-8") + git(work, "add", "tracked.txt") + git(work, "commit", "-m", "fixture") + git(work, "remote", "add", "origin", str(remote)) + git(work, "push", "origin", "main") + install_pre_push_hook(work, str(python), Path(__file__).with_name("pre_push.py")) + environment = os.environ.copy() + environment.pop("PYTHONPATH", None) + environment["SELECTED_VENV_LOG"] = str(log) + + result = git(work, "push", "origin", "main:refs/heads/venv-checked", check=False, env=environment) + + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + record = json.loads(log.read_text(encoding="utf-8")) + self.assertEqual(Path(record["prefix"]).resolve(), venv_root.resolve()) + self.assertEqual(record["args"], ["run", "--all-files", "--hook-stage", "pre-push"]) + + +class HookInstallerTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(prefix="contract-tracer-hook-install-") + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + git(self.root, "init") + self.hooks = hooks_directory(self.root) + + def test_installer_preserves_unknown_hook(self): + hook = self.hooks / "pre-push" + hook.parent.mkdir(parents=True, exist_ok=True) + custom = b"#!/bin/sh\n# user custom hook\nexit 42\n" + hook.write_bytes(custom) + with self.assertRaisesRegex(RuntimeError, "refusing to replace unrecognized hook"): + install_pre_push_hook(self.root, sys.executable, Path(__file__).with_name("pre_push.py")) + self.assertEqual(hook.read_bytes(), custom) + + def test_installer_replaces_a_precommit_managed_push_hook(self): + hook = self.hooks / "pre-push" + hook.parent.mkdir(parents=True, exist_ok=True) + hook.write_bytes(b"#!/bin/sh\n# File generated by pre-commit: https://pre-commit.com\nexit 0\n") + install_pre_push_hook(self.root, sys.executable, Path(__file__).with_name("pre_push.py")) + contents = hook.read_bytes() + self.assertIn(PRE_PUSH_MARKER, contents) + self.assertNotIn(PRE_COMMIT_MARKER, contents) + + def test_installer_refuses_legacy_hooks_before_mutating_either_hook(self): + self.hooks.mkdir(parents=True, exist_ok=True) + pre_commit_hook = self.hooks / "pre-commit" + pre_push_hook = self.hooks / "pre-push" + pre_commit_hook.write_bytes(b"#!/bin/sh\n# File generated by pre-commit\nexit 0\n") + pre_push_hook.write_bytes(b"#!/bin/sh\n# user hook\nexit 42\n") + legacy = self.hooks / "pre-commit.legacy" + legacy_contents = b"#!/bin/sh\n# preserved legacy hook\nexit 9\n" + legacy.write_bytes(legacy_contents) + before = (pre_commit_hook.read_bytes(), pre_push_hook.read_bytes(), legacy.read_bytes()) + + with self.assertRaisesRegex(RuntimeError, "legacy hook"): + install_hooks(self.root, sys.executable) + + self.assertEqual( + (pre_commit_hook.read_bytes(), pre_push_hook.read_bytes(), legacy.read_bytes()), + before, + ) + + def test_installer_preserves_unrecognized_commit_hook_and_existing_push_hook(self): + self.hooks.mkdir(parents=True, exist_ok=True) + pre_commit_hook = self.hooks / "pre-commit" + pre_push_hook = self.hooks / "pre-push" + custom_commit = b"#!/bin/sh\n# custom commit hook\nexit 31\n" + managed_push = b"#!/bin/sh\n# Managed by Contract Tracer: multi-ref pre-push validation\nexit 0\n" + pre_commit_hook.write_bytes(custom_commit) + pre_push_hook.write_bytes(managed_push) + + with self.assertRaisesRegex(RuntimeError, "unrecognized hook"): + install_hooks(self.root, sys.executable) + + self.assertEqual(pre_commit_hook.read_bytes(), custom_commit) + self.assertEqual(pre_push_hook.read_bytes(), managed_push) + + def test_push_legacy_hook_is_not_discarded(self): + hook = self.hooks / "pre-push" + hook.parent.mkdir(parents=True, exist_ok=True) + hook.write_bytes(b"#!/bin/sh\n# File generated by pre-commit\nexit 0\n") + legacy = self.hooks / "pre-push.legacy" + legacy_contents = b"#!/bin/sh\n# user legacy hook\nexit 17\n" + legacy.write_bytes(legacy_contents) + before = (hook.read_bytes(), legacy.read_bytes()) + + with self.assertRaisesRegex(RuntimeError, "legacy hook"): + install_pre_push_hook(self.root, sys.executable, Path(__file__).with_name("pre_push.py")) + + self.assertEqual((hook.read_bytes(), legacy.read_bytes()), before) + + @unittest.skipIf(os.name == "nt", "symlink executable paths are covered on POSIX") + def test_generated_hook_preserves_selected_python_symlink(self): + executable_link = self.root / "venv" / "bin" / "python" + executable_link.parent.mkdir(parents=True) + executable_link.symlink_to(sys.executable) + + hook = render_pre_push_hook(str(executable_link), Path(__file__).with_name("pre_push.py")) + + self.assertIn(str(executable_link).encode(), hook) + self.assertNotIn(str(Path(sys.executable).resolve()).encode(), hook) + + +if __name__ == "__main__": + unittest.main() diff --git a/trace.go b/trace.go new file mode 100644 index 0000000..494eab9 --- /dev/null +++ b/trace.go @@ -0,0 +1,581 @@ +package contracttrace + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "go/ast" + "go/token" + "go/types" + "os" + "os/exec" + "path/filepath" + "runtime" + "runtime/debug" + "sort" + "strings" + + "golang.org/x/tools/go/callgraph/cha" + "golang.org/x/tools/go/packages" + "golang.org/x/tools/go/ssa" + "golang.org/x/tools/go/ssa/ssautil" +) + +type function struct { + node Node + decl *ast.FuncDecl + pkg *packages.Package +} +type index struct { + root string + fset *token.FileSet + funcs map[string]*function + objects map[*types.Func]string + edges []Relationship + boundaries []Boundary + lines map[string][]string + flow *flowAnalysis + ssaOwners map[*ssa.Function]string + ownerCache map[*ssa.Function]string + ownerKnown map[*ssa.Function]bool + sqlCache map[string]sqlAnalysis + storage StorageCoverage + embedded map[*types.Var]string + assets []string + assetHash string + ranges map[string]SourceRange + declarationRanges map[string]SourceRange + globalRanges map[token.Pos]SourceRange + storageAssignments map[string]bool +} + +func Trace(ctx context.Context, o Options) (report Report, err error) { + defer func() { + if p := recover(); p != nil { + report = Report{} + err = fmt.Errorf("analysis failed: %v\n%s", p, debug.Stack()) + } + }() + if len(o.Seeds) == 0 && len(o.Locations) == 0 { + return report, fmt.Errorf("at least one symbol or source-location seed is required") + } + if o.Depth < 1 || o.MaxNodes < 1 { + return report, fmt.Errorf("depth and max-nodes must be positive") + } + o.Config = normalizeConfig(o.Config) + if err := validateConfig(o.Config); err != nil { + return Report{}, err + } + root, err := canonicalRoot(o.Root) + if err != nil { + return report, err + } + before, allFiles, err := fingerprint(root) + if err != nil { + return report, err + } + env, err := buildEnvironment(ctx, root) + if err != nil { + return report, err + } + resolution, err := captureResolutionInputs(env) + if err != nil { + return report, err + } + fset := token.NewFileSet() + sources := &loadedSourceCapture{} + cfg := packageLoaderConfig(ctx, root, env, o.Tests, o.Tags) + cfg.Fset = fset + cfg.Mode = packages.NeedName | packages.NeedFiles | packages.NeedCompiledGoFiles | packages.NeedImports | packages.NeedDeps | packages.NeedTypes | packages.NeedSyntax | packages.NeedTypesInfo | packages.NeedModule | packages.NeedEmbedFiles | packages.NeedEmbedPatterns + metadataConfig := *cfg + metadataConfig.Mode = packages.NeedName | packages.NeedImports | packages.NeedDeps | packages.NeedModule + metadata, err := packages.Load(&metadataConfig, "./...") + if err != nil { + return report, err + } + workspace := resolution + resolution, err = captureModuleInputs(metadata, workspace) + if err != nil { + return report, err + } + cfg.ParseFile = sources.parseFile + pkgs, err := packages.Load(cfg, "./...") + if err != nil { + return report, err + } + var diagnostics []string + packages.Visit(pkgs, nil, func(p *packages.Package) { + for _, e := range p.Errors { + diagnostics = append(diagnostics, e.Error()) + } + }) + if len(diagnostics) > 0 { + sort.Strings(diagnostics) + return report, fmt.Errorf("package loading incomplete: %s", strings.Join(diagnostics, "\n")) + } + if len(pkgs) == 0 { + return report, fmt.Errorf("no Go packages loaded") + } + loadedSources := sources.sources() + if err := verifyParserMatchesSelection(pkgs, loadedSources); err != nil { + return report, err + } + loadedResolution, err := captureModuleInputs(pkgs, workspace) + if err != nil { + return report, err + } + if loadedSourceIdentity(loadedResolution) != loadedSourceIdentity(resolution) { + return report, fmt.Errorf("module inputs changed during package loading; rerun analysis") + } + ix := &index{root: root, fset: fset, funcs: map[string]*function{}, objects: map[*types.Func]string{}, lines: map[string][]string{}} + ix.declarationRanges = map[string]SourceRange{} + ix.globalRanges = map[token.Pos]SourceRange{} + coverage := Coverage{Build: env, Tests: o.Tests, Tags: o.Tags, Depth: o.Depth, MaxNodes: o.MaxNodes, ExpandCallbacks: o.ExpandCallbacks, Packages: []string{}, Files: []string{}, ExcludedGoFiles: []string{}} + loaded := map[string]bool{} + for _, p := range pkgs { + coverage.Packages = append(coverage.Packages, p.ID) + for _, file := range p.CompiledGoFiles { + if rel, inside := relative(root, file); inside { + loaded[rel] = true + } + } + for _, file := range p.Syntax { + if _, inside := relative(root, fset.Position(file.Pos()).Filename); !inside { + continue + } + for _, d := range file.Decls { + if declaration, ok := d.(*ast.GenDecl); ok && declaration.Tok == token.VAR { + for _, spec := range declaration.Specs { + value := spec.(*ast.ValueSpec) + for _, name := range value.Names { + ix.globalRanges[name.Pos()] = SourceRange{fset.Position(name.Pos()).Line, fset.Position(value.End()).Line} + } + } + } + fd, ok := d.(*ast.FuncDecl) + if !ok || fd.Body == nil { + continue + } + obj, ok := p.TypesInfo.Defs[fd.Name].(*types.Func) + if !ok { + continue + } + e := ix.evidence(fd.Pos()) + name := functionName(obj) + id := p.PkgPath + "::" + name + if name == "init" { + id += fmt.Sprintf("@%s:%d:%d", e.File, e.Line, e.Column) + } + ix.objects[obj.Origin()] = id + ix.funcs[id] = &function{node: Node{ID: id, Name: name, Kind: "function", Evidence: e}, decl: fd, pkg: p} + } + } + } + for f := range loaded { + coverage.Files = append(coverage.Files, f) + } + for _, f := range allFiles { + if strings.HasSuffix(f, ".go") && !loaded[f] { + coverage.ExcludedGoFiles = append(coverage.ExcludedGoFiles, f) + } + } + for id, f := range ix.funcs { + ast.Inspect(f.decl.Body, func(n ast.Node) bool { + ident, ok := n.(*ast.Ident) + if !ok { + return true + } + obj, ok := f.pkg.TypesInfo.Uses[ident].(*types.Func) + if !ok { + return true + } + if target := ix.objects[obj.Origin()]; target != "" { + ix.edge(id, target, "function_reference", "fact", ident.Pos()) + } + return true + }) + } + if err := ctx.Err(); err != nil { + return report, err + } + if err := ix.loadEmbedded(pkgs); err != nil { + return Report{}, err + } + coverage.EmbeddedFiles = append([]string{}, ix.assets...) + prog, _ := ssautil.Packages(pkgs, ssa.InstantiateGenerics|ssa.GlobalDebug) + prog.Build() + ix.registerInitializers(prog, pkgs) + ix.recordFields(pkgs, o.Config) + cg := cha.CallGraph(prog) + ix.flow, err = ix.analyzeFlow(ctx, prog, cg, o.Config) + if err != nil { + return Report{}, err + } + for fn, n := range cg.Nodes { + from := ix.owner(fn) + if from == "" { + continue + } + for _, edge := range n.Out { + if edge.Site == nil { + continue + } + target := ix.owner(edge.Callee.Func) + kind, certainty := "possible_callback_call", "possible" + if edge.Site.Common().IsInvoke() { + kind = "possible_interface_call" + } + if edge.Site.Common().StaticCallee() != nil { + kind, certainty = "call", "fact" + } + switch edge.Site.(type) { + case *ssa.Go: + kind = "goroutine_" + kind + case *ssa.Defer: + kind = "deferred_" + kind + } + if target != "" { + ix.callEdge(from, target, kind, certainty, edge.Site) + } else { + ix.boundaries = append(ix.boundaries, Boundary{Node: from, Kind: "external_call", Reason: "callee bodies outside indexed repository; examples are not an exhaustive target list", Evidence: ix.callEvidence(edge.Site), Examples: []string{edge.Callee.Func.String()}}) + } + } + } + if err := ctx.Err(); err != nil { + return report, err + } + if err := ix.semantic(o.Config); err != nil { + return Report{}, err + } + if err := ix.sqlFiles(ctx, o); err != nil { + return Report{}, err + } + coverage.Storage = ix.storage + coverage.ValueFlow = ix.flow.coverage + if !ix.flow.coverage.Converged || ix.flow.coverage.Widened { + ix.boundaries = append(ix.boundaries, Boundary{Kind: "value_flow_limit", Reason: "value-flow fixed-point or candidate budget reached; candidates are incomplete"}) + } + report, err = ix.expand(o, coverage) + if err != nil { + return Report{}, err + } + report.Coverage.LoadedSources = loadedSources + report.Coverage.LoadedSourceSHA256 = loadedSourceIdentity(report.Coverage.LoadedSources) + report.Coverage.ResolutionInputs = resolution + report.Coverage.ResolutionSHA256 = loadedSourceIdentity(resolution) + report.Coverage.ResolutionScope = "loaded_modules.v1" + identity, _ := json.Marshal(struct { + Build map[string]string + Config Config + Tests bool + Tags string + }{env, o.Config, o.Tests, o.Tags}) + report.Coverage.SourceSHA256 = hashText(before + string(identity) + ix.assetHash + report.Coverage.LoadedSourceSHA256 + report.Coverage.ResolutionSHA256) + report.Schema = "contract-tracer.v0.1" + report.Root = root + report.Invariant = o.Invariant + report.Config = o.Config + report.Boundaries = append(report.Boundaries, + Boundary{Kind: "semantic_scope", Reason: "The supplied invariant is a hypothesis. This report does not prove it or discover every behavioral relationship."}, + Boundary{Kind: "analysis_model", Reason: "CHA targets are conservative candidates. References prove use, not invocation. Reflection, unsafe/cgo, runtime dispatch, dependency bodies and unselected build configurations are not fully modeled."}, + Boundary{Kind: "loaded_source_identity", Reason: "identity includes exact Go bytes and selected compiled-source paths presented to the package parser, plus root-local embedded asset matches in selected target packages; active workspace and its use-module manifests, effective loaded-module manifests checked around package loading, and recorded Go build/selection environment are also captured. Selected package source paths and target-package embedded asset matches are rechecked at completion and during saved exploration before final byte hashes. Unselected or non-inventoried dependency assets, unloaded module directives, checksum files, unrecorded settings and analyzer implementation remain outside this attestation; source identity does not imply dependency body analysis"}, + Boundary{Kind: "lifecycle", Reason: "Go/defer and configured name hints identify investigation sites; temporal ownership, cancellation propagation and eventual cleanup are not proven."}) + sortReport(&report) + groupBoundaries(&report) + var snapshot Analysis + if o.capture != nil { + snapshot = ix.snapshot(report) + } + if err := verifyAnalysisInputs(ctx, root, env, before, ix.assets, ix.assetHash, report.Coverage.LoadedSources, resolution, o.Tests, o.Tags); err != nil { + return Report{}, err + } + if o.capture != nil { + *o.capture = snapshot + } + return report, nil +} + +func canonicalRoot(root string) (string, error) { + if runtime.GOOS == "windows" { + requested := root + if requested == "" { + requested = "." + } + requestedInfo, err := os.Stat(requested) + if err != nil { + return "", err + } + absolute, err := filepath.Abs(requested) + if err != nil { + return "", err + } + resolved, err := filepath.EvalSymlinks(absolute) + if err != nil { + return "", err + } + absolute, err = filepath.Abs(resolved) + if err != nil { + return "", err + } + canonical := filepath.Clean(absolute) + canonicalInfo, err := os.Stat(canonical) + if err != nil { + return "", err + } + if !os.SameFile(requestedInfo, canonicalInfo) { + return "", fmt.Errorf("root %q does not resolve to the directory selected by the operating system", root) + } + return canonical, nil + } + + path := root + if !filepath.IsAbs(root) { + workingDirectory, err := os.Getwd() + if err != nil { + return "", err + } + workingDirectory, err = filepath.EvalSymlinks(workingDirectory) + if err != nil { + return "", err + } + if !strings.HasSuffix(workingDirectory, string(filepath.Separator)) { + workingDirectory += string(filepath.Separator) + } + path = workingDirectory + root + } + // Resolve raw path components before Abs cleans any symlink/.. sequence. + resolved, err := filepath.EvalSymlinks(path) + if err != nil { + return "", err + } + absolute, err := filepath.Abs(resolved) + if err != nil { + return "", err + } + return filepath.Clean(absolute), nil +} + +func functionName(obj *types.Func) string { + sig, _ := obj.Type().(*types.Signature) + if sig != nil && sig.Recv() != nil { + t := sig.Recv().Type() + if p, ok := t.(*types.Pointer); ok { + t = p.Elem() + } + if n, ok := t.(*types.Named); ok { + return n.Obj().Name() + "." + obj.Name() + } + } + return obj.Name() +} +func (ix *index) owner(fn *ssa.Function) (result string) { + if fn == nil { + return "" + } + if ix.ownerKnown[fn] { + return ix.ownerCache[fn] + } + if ix.ownerKnown == nil { + ix.ownerKnown = map[*ssa.Function]bool{} + ix.ownerCache = map[*ssa.Function]string{} + } + defer func() { ix.ownerKnown[fn] = true; ix.ownerCache[fn] = result }() + if id := ix.ssaOwners[fn]; id != "" { + return id + } + if obj, ok := fn.Object().(*types.Func); ok && obj != nil { + if id := ix.objects[obj.Origin()]; id != "" { + return id + } + } + if origin := fn.Origin(); origin != nil && origin != fn { + if obj, ok := origin.Object().(*types.Func); ok && obj != nil { + if id := ix.objects[obj.Origin()]; id != "" { + return id + } + } + } + if parent := fn.Parent(); parent != nil { + return ix.owner(parent) + } + for id, f := range ix.funcs { + if f.decl != nil && fn.Pos() >= f.decl.Pos() && fn.Pos() <= f.decl.End() { + return id + } + } + return "" +} +func (ix *index) evidence(pos token.Pos) Evidence { + p := ix.fset.Position(pos) + rel, inside := relative(ix.root, p.Filename) + if !inside { + rel = filepath.ToSlash(p.Filename) + } + lines, ok := ix.lines[p.Filename] + if !ok { + b, _ := os.ReadFile(p.Filename) + lines = strings.Split(string(b), "\n") + ix.lines[p.Filename] = lines + } + snippet := "" + if p.Line > 0 && p.Line <= len(lines) { + snippet = strings.TrimSpace(lines[p.Line-1]) + } + return Evidence{File: rel, Line: p.Line, Column: p.Column, Snippet: snippet} +} +func (ix *index) edge(from, to, kind, certainty string, pos token.Pos) { + ix.edges = append(ix.edges, Relationship{From: from, To: to, Kind: kind, Certainty: certainty, Evidence: ix.evidence(pos)}) +} +func relative(root, path string) (string, bool) { + rel, err := filepath.Rel(root, path) + return filepath.ToSlash(rel), err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && !filepath.IsAbs(rel) +} +func hashText(s string) string { sum := sha256.Sum256([]byte(s)); return hex.EncodeToString(sum[:]) } +func fingerprint(root string) (string, []string, error) { + rootFS, err := os.OpenRoot(root) + if err != nil { + return "", nil, err + } + defer rootFS.Close() + files := []string{} + err = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + if isFingerprintExcludedDirectory(d.Name()) { + return filepath.SkipDir + } + return nil + } + isSQL := strings.HasSuffix(strings.ToLower(d.Name()), ".sql") + if isSQL && d.Type()&os.ModeSymlink != 0 { + rel, _ := relative(root, path) + return fmt.Errorf("SQL file symlinks are not supported: %s", rel) + } + if isFingerprintInputName(d.Name()) { + rel, _ := relative(root, path) + files = append(files, rel) + } + return nil + }) + if err != nil { + return "", nil, err + } + sort.Strings(files) + h := sha256.New() + for _, f := range files { + data, err := rootFS.ReadFile(filepath.FromSlash(f)) + if err != nil { + return "", nil, err + } + fmt.Fprintf(h, "%d:%s:%d:", len(f), f, len(data)) + h.Write(data) + } + return hex.EncodeToString(h.Sum(nil)), files, nil +} + +func isFingerprintExcludedDirectory(name string) bool { + switch name { + case ".git", "vendor", ".gograph": + return true + default: + return false + } +} + +func isFingerprintInputName(name string) bool { + return strings.HasSuffix(name, ".go") || strings.HasSuffix(strings.ToLower(name), ".sql") || name == "go.mod" || name == "go.sum" +} + +// IsFingerprintInputPath reports whether writing path would create a file +// included in root's source fingerprint or replace a path alias to one. It +// compares filesystem identities rather than lexically cleaning paths. +func IsFingerprintInputPath(root, path string) (bool, error) { + root, err := canonicalRoot(root) + if err != nil { + return false, err + } + var destinationInfo os.FileInfo + if info, statErr := os.Stat(path); statErr == nil { + destinationInfo = info + } else if !os.IsNotExist(statErr) { + return false, fmt.Errorf("inspect destination %q: %w", path, statErr) + } else if info, lstatErr := os.Lstat(path); lstatErr == nil && info.Mode()&os.ModeSymlink != 0 { + return false, fmt.Errorf("destination %q is a dangling or unresolvable symbolic link", path) + } else if lstatErr != nil && !os.IsNotExist(lstatErr) { + return false, fmt.Errorf("inspect destination %q: %w", path, lstatErr) + } + prospectiveInput := false + var parentInfo os.FileInfo + if isFingerprintInputName(filepath.Base(path)) { + directory, _ := filepath.Split(path) + if directory == "" { + directory = "." + } + parentInfo, err = os.Stat(directory) + if err != nil { + return false, fmt.Errorf("inspect destination directory for %q: %w", path, err) + } + if !parentInfo.IsDir() { + return false, fmt.Errorf("destination parent for %q is not a directory", path) + } + } + aliasedInput := false + err = filepath.WalkDir(root, func(current string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.IsDir() { + if isFingerprintExcludedDirectory(entry.Name()) { + return filepath.SkipDir + } + if parentInfo != nil { + info, err := entry.Info() + if err != nil { + return err + } + if os.SameFile(parentInfo, info) { + prospectiveInput = true + return filepath.SkipAll + } + } + return nil + } + if destinationInfo != nil && isFingerprintInputName(entry.Name()) { + inputInfo, err := os.Stat(current) + if err != nil { + return err + } + if os.SameFile(destinationInfo, inputInfo) { + aliasedInput = true + return filepath.SkipAll + } + } + return nil + }) + return prospectiveInput || aliasedInput, err +} + +func buildEnvironment(ctx context.Context, root string) (map[string]string, error) { + keys := buildEnvironmentKeys() + args := append([]string{"env", "-json"}, keys...) + cmd := exec.CommandContext(ctx, "go", args...) + cmd.Dir = root + b, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("go env: %w", err) + } + var values map[string]string + if err := json.Unmarshal(b, &values); err != nil { + return nil, fmt.Errorf("unexpected go env output: %w", err) + } + for _, key := range keys { + if _, present := values[key]; !present { + return nil, fmt.Errorf("go env output missing %s", key) + } + } + return values, nil +} diff --git a/trace_test.go b/trace_test.go new file mode 100644 index 0000000..e713a50 --- /dev/null +++ b/trace_test.go @@ -0,0 +1,455 @@ +package contracttrace + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestCallbackAndInterfaceScope(t *testing.T) { + r, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"Validate"}, Invariant: "all accepted values are validated", Depth: 3, MaxNodes: 100}) + if err != nil { + t.Fatal(err) + } + for _, name := range []string{"Validate", "Live", "Recovery", "Store.Read", "Entry"} { + if !hasName(r, name) { + t.Errorf("missing independently expected path %s", name) + } + } + if hasName(r, "unrelated") { + t.Error("disconnected function included") + } + if hasName(r, "UnusedCheck") { + t.Error("same-signature callback candidate should remain an unresolved boundary by default") + } + possible := false + for _, b := range r.Boundaries { + if b.Kind == "callback_candidates" { + possible = true + } + } + if !possible { + t.Error("nonexpanded callback candidates must be disclosed") + } + found := false + for _, e := range r.Relationships { + if e.Kind == "function_reference" && e.Evidence.Snippet == "func Recovery() bool { return apply(Validate) }" { + found = true + } + } + if !found { + t.Error("callback use must be retained as source-backed reference") + } + if r.ContractComplete { + t.Error("structural discovery cannot certify the invariant") + } +} + +func TestFingerprintIncludesOrdinaryPackageDirectories(t *testing.T) { + root := t.TempDir() + dir := filepath.Join(root, "reports") + if err := os.Mkdir(dir, 0700); err != nil { + t.Fatal(err) + } + file := filepath.Join(dir, "source.go") + if err := os.WriteFile(file, []byte("package reports\nconst Value=1\n"), 0600); err != nil { + t.Fatal(err) + } + before, _, err := fingerprint(root) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(file, []byte("package reports\nconst Value=2\n"), 0600); err != nil { + t.Fatal(err) + } + after, _, err := fingerprint(root) + if err != nil { + t.Fatal(err) + } + if before == after { + t.Error("ordinary Go package named reports was omitted from source identity") + } +} + +func TestInvalidTargetAndCanceledAnalysis(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module example.com/broken\n\ngo 1.27.0\n"), 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "broken.go"), []byte("package broken\nfunc Seed() { missingSymbol() }\n"), 0600); err != nil { + t.Fatal(err) + } + r, err := Trace(context.Background(), Options{Root: dir, Seeds: []string{"Seed"}, Depth: 2, MaxNodes: 10}) + if err == nil || !strings.Contains(err.Error(), "package loading incomplete") || len(r.Nodes) != 0 { + t.Error("unloadable code must fail instead of emitting apparently successful scope") + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err = Trace(ctx, Options{Root: "testdata/sample", Seeds: []string{"Validate"}, Depth: 2, MaxNodes: 10}) + if err == nil { + t.Error("canceled analysis was allowed") + } +} + +func TestConfigurationAndScopeLimits(t *testing.T) { + r, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"CustomPublish"}, Depth: 2, MaxNodes: 100, Config: Config{EventFields: []string{"Topic"}}}) + if err != nil { + t.Fatal(err) + } + if !hasName(r, "CustomConsume") { + t.Error("configured field convention did not connect consumer") + } + r, err = Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"Validate"}, Depth: 1, MaxNodes: 2}) + if err != nil { + t.Fatal(err) + } + if len(r.Nodes) > 2 || !r.Coverage.Truncated { + t.Error("budget must be enforced and reported") + } + found := false + for _, b := range r.Boundaries { + if b.Kind == "scope_frontier" { + found = true + } + } + if !found { + t.Error("scope omission lacks an explicit boundary") + } +} + +func TestBuildCoverageAndUnknownSeed(t *testing.T) { + r, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"Validate"}, Depth: 3, MaxNodes: 100}) + if err != nil { + t.Fatal(err) + } + excluded := false + for _, f := range r.Coverage.ExcludedGoFiles { + if f == "tagged.go" { + excluded = true + } + } + if !excluded || hasName(r, "Tagged") { + t.Error("unselected build-tag path was not correctly reported") + } + r, err = Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"Validate"}, Depth: 3, MaxNodes: 100, Tags: "traceextra"}) + if err != nil { + t.Fatal(err) + } + if !hasName(r, "Tagged") { + t.Error("selected build-tag caller missing") + } + _, err = Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"DoesNotExist"}, Depth: 3, MaxNodes: 100}) + if err == nil || !strings.Contains(err.Error(), "not found") { + t.Error("unknown seed must fail explicitly") + } +} + +func TestStorageSiblingsAndDynamicBoundary(t *testing.T) { + r, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"Write", "Dynamic"}, Depth: 2, MaxNodes: 100}) + if err != nil { + t.Fatal(err) + } + for _, name := range []string{"Read", "Rebuild"} { + if !hasName(r, name) { + t.Errorf("missing state sibling %s", name) + } + } + if hasName(r, "Other") { + t.Error("unrelated table was joined") + } + found := false + for _, b := range r.Boundaries { + if b.Kind == "dynamic_sql" { + found = true + } + } + if !found { + t.Error("dynamic SQL was silently dropped") + } +} + +func TestEventProducerConsumer(t *testing.T) { + r, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"Publish"}, Depth: 2, MaxNodes: 100}) + if err != nil { + t.Fatal(err) + } + if !hasName(r, "Consume") { + t.Error("consumer of the same constant event missing") + } + if hasName(r, "Another") { + t.Error("different event joined") + } +} + +func TestPackageInitializersUseConfiguredSemanticAdapters(t *testing.T) { + configJSON := `{"storage_scopes":[{"namespace":"initializer-store","database_origins":["example.com/initfixture::(package init)"]}]}` + if _, err := ReadConfig(strings.NewReader(configJSON)); err != nil { + t.Fatalf("ReadConfig rejected the modeled package-initializer database owner: %v", err) + } + for _, invalid := range []string{ + "example.com/initfixture::(package init)::Open", + "example.com/initfixture:: (package init)", + } { + if _, err := ReadConfig(strings.NewReader(`{"storage_scopes":[{"namespace":"initializer-store","database_origins":["` + invalid + `"]}]}`)); err == nil { + t.Errorf("ReadConfig accepted malformed package-initializer database owner %q", invalid) + } + } + root := t.TempDir() + write := func(name, source string) { + t.Helper() + file := filepath.Join(root, name) + if err := os.MkdirAll(filepath.Dir(file), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(file, []byte(source), 0600); err != nil { + t.Fatal(err) + } + } + write("go.mod", "module example.com/initfixture\n\ngo 1.27.0\n") + write("api/api.go", `package api + +func Publish(string) bool { return true } +func Subscribe(string, func()) bool { return true } +func Query(string) bool { return true } +`) + write("events.go", `package app + +import "example.com/initfixture/api" + +type Envelope struct { EventType string } +const orderCreated = "orders.created" +var eventName = orderCreated +var eventNameMutation = ChangeEventName() +var publishResult = api.Publish(eventName) +var eventEnvelope = Envelope{EventType: orderCreated} +var immediateResult = func() bool { return api.Publish("iife.event") }() +`) + write("mutator.go", `package app + +func ChangeEventName() bool { + eventName = "orders.changed" + return true +} +`) + write("consumers.go", `package app + +import ( + "database/sql" + "example.com/initfixture/api" +) + +func HandleOrder() {} +var subscriptionResult = api.Subscribe(eventName, HandleOrder) +var queryResult = api.Query("SELECT id FROM records") +var openedDB, openErr = sql.Open("sqlite", ":memory:") +var openedRows, rowsErr = openedDB.Query("SELECT id FROM opened_records") +`) + write("dynamic.go", `package app + +import "example.com/initfixture/api" + +var dynamicEvent string +var dynamicResult = api.Publish(dynamicEvent) +`) + write("callbacks.go", `package app + +import "example.com/initfixture/api" + +var deferredPublisher = func() { api.Publish("closure.body") } +`) + write("functions.go", `package app + +import "example.com/initfixture/api" + +func OrdinaryFunction() bool { return api.Publish("body.event") } +func init() { api.Publish("runtime.init") } +`) + + r, err := Trace(context.Background(), Options{ + Root: root, Seeds: []string{"example.com/initfixture::(package init)"}, Depth: 3, MaxNodes: 100, + Config: Config{CallRules: []CallRule{ + {Symbol: "example.com/initfixture/api::Publish", Kind: "event_publish", Argument: 0}, + {Symbol: "example.com/initfixture/api::Subscribe", Kind: "event_subscribe", Argument: 0, HandlerArgument: intPointer(1)}, + {Symbol: "example.com/initfixture/api::Query", Kind: "sql_query", Argument: 0, Namespace: "primary"}, + }, StorageScopes: []StorageScope{{ + Namespace: "initializer-store", DatabaseOrigins: []string{"example.com/initfixture::(package init)"}, + }}}, + }) + if err != nil { + t.Fatal(err) + } + + initializer := "example.com/initfixture::(package init)" + requires := []struct { + to, kind string + line int + }{ + {"event:orders.changed", "event_publish", 9}, + {"event:orders.created", "event_construct", 10}, + {"event:iife.event", "event_publish", 11}, + {"event:orders.changed", "event_subscribe", 9}, + {"table:primary:records", "sql_read", 10}, + {"table:initializer-store:opened_records", "sql_read", 12}, + } + for _, expected := range requires { + found := false + for _, edge := range r.Relationships { + if edge.From == initializer && edge.To == expected.to && edge.Kind == expected.kind && + edge.Evidence.File == map[string]string{ + "event_publish": "events.go", "event_construct": "events.go", + "event_subscribe": "consumers.go", "sql_read": "consumers.go", + }[expected.kind] && edge.Evidence.Line == expected.line { + found = true + } + } + if !found { + t.Errorf("package initializer is missing %s from %s at line %d", expected.kind, expected.to, expected.line) + } + } + if !hasRelationship(r, "example.com/initfixture::HandleOrder", "event:orders.changed", "event_handler", "consumers.go", 9) { + t.Error("subscription handler declaration must connect through initializer registration evidence") + } + if hasRelationship(r, initializer, "event:body.event", "event_publish", "functions.go", 0) || + hasRelationship(r, initializer, "event:runtime.init", "event_publish", "functions.go", 0) || + hasRelationship(r, initializer, "event:closure.body", "event_publish", "callbacks.go", 0) { + t.Error("ordinary function bodies were incorrectly attributed to package variable initialization") + } + if !hasRelationship(r, "example.com/initfixture::OrdinaryFunction", "event:body.event", "event_publish", "functions.go", 5) { + t.Error("ordinary function call lost its semantic owner") + } + initOwner := "" + for _, node := range r.Nodes { + if node.Kind == "function" && node.Name == "init" && strings.HasPrefix(node.ID, "example.com/initfixture::init@functions.go:") { + initOwner = node.ID + } + } + if initOwner == "" || !hasRelationship(r, initOwner, "event:runtime.init", "event_publish", "functions.go", 6) { + t.Error("explicit init function call lost its source owner") + } + if !hasBoundaryAt(r, initializer, "dynamic_api_value", "dynamic.go", 6) || + !hasBoundaryAt(r, initializer, "dynamic_api_value", "events.go", 9) { + t.Error("dynamic package initializer API value needs its unresolved boundary") + } + for _, rule := range []string{ + "example.com/initfixture/api::Publish", "example.com/initfixture/api::Subscribe", "example.com/initfixture/api::Query", + } { + for _, boundary := range r.Boundaries { + if boundary.Kind == "unmatched_rule" && strings.Contains(boundary.Reason, rule) { + t.Errorf("configured API used only by package initialization was reported unmatched: %s", rule) + } + } + } + if r.ContractComplete { + t.Error("package initializer resource evidence must not certify the contract") + } +} + +func TestCompoundEventFieldAssignmentRemainsUnresolved(t *testing.T) { + root := t.TempDir() + files := map[string]string{ + "go.mod": "module example.com/compoundevent\n\ngo 1.27.0\n", + "api/api.go": `package api + +func Publish(string) bool { return true } +func Query(string) bool { return true } +`, + "app.go": `package app + +import "example.com/compoundevent/api" + +type Envelope struct { EventType string } + +func Send(e *Envelope) { + e.EventType = "orders.changed" + e.EventType += ".updated" +} + +func Define() { + EventType := "orders.local" + EventType = "orders.replaced" + _ = EventType +} + +func PublishPartial(EventType string) { + EventType += ".published" + api.Publish(EventType) +} + +func QueryPartial(query string) { + query += "SELECT id FROM records" + api.Query(query) +} +`, + } + for name, source := range files { + path := filepath.Join(root, name) + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(source), 0600); err != nil { + t.Fatal(err) + } + } + r, err := Trace(context.Background(), Options{ + Root: root, Seeds: []string{"Send", "Define", "PublishPartial", "QueryPartial"}, Depth: 2, MaxNodes: 50, + Config: Config{CallRules: []CallRule{ + {Symbol: "example.com/compoundevent/api::Publish", Kind: "event_publish", Argument: 0}, + {Symbol: "example.com/compoundevent/api::Query", Kind: "sql_query", Argument: 0, Namespace: "primary"}, + }}, + }) + if err != nil { + t.Fatal(err) + } + if !hasRelationship(r, "example.com/compoundevent::Send", "event:orders.changed", "event_assign", "app.go", 8) { + t.Error("plain assignment to a configured event field lost its complete event candidate") + } + if hasRelationship(r, "example.com/compoundevent::Send", "event:.updated", "event_assign", "app.go", 9) { + t.Error("compound assignment incorrectly treated only its suffix as the complete event") + } + if !hasBoundaryAt(r, "example.com/compoundevent::Send", "dynamic_event", "app.go", 9) { + t.Error("compound event assignment needs an unresolved-value boundary") + } + if !hasRelationship(r, "example.com/compoundevent::Define", "event:orders.local", "event_assign", "app.go", 13) || + !hasRelationship(r, "example.com/compoundevent::Define", "event:orders.replaced", "event_assign", "app.go", 14) { + t.Error("plain short and reassignment event values should remain recognized") + } + if !hasBoundaryAt(r, "example.com/compoundevent::PublishPartial", "dynamic_event", "app.go", 19) { + t.Error("compound assignment to an event-valued parameter needs an unresolved event boundary") + } + if !hasBoundaryAt(r, "example.com/compoundevent::PublishPartial", "dynamic_api_value", "app.go", 20) { + t.Error("configured API argument needs an unresolved-value boundary after a compound write") + } + if !hasBoundaryAt(r, "example.com/compoundevent::QueryPartial", "dynamic_api_value", "app.go", 25) { + t.Error("configured SQL API argument needs an unresolved-value boundary after a compound write") + } +} + +func hasRelationship(r Report, from, to, kind, file string, line int) bool { + for _, edge := range r.Relationships { + if edge.From == from && edge.To == to && edge.Kind == kind && edge.Evidence.File == file && + (line == 0 || edge.Evidence.Line == line) { + return true + } + } + return false +} + +func hasBoundaryAt(r Report, owner, kind, file string, line int) bool { + for _, boundary := range r.Boundaries { + if boundary.Node == owner && boundary.Kind == kind && boundary.Evidence.File == file && boundary.Evidence.Line == line { + return true + } + } + return false +} + +func hasName(r Report, name string) bool { + for _, n := range r.Nodes { + if n.Name == name { + return true + } + } + return false +} diff --git a/tuple_assignment_semantics_test.go b/tuple_assignment_semantics_test.go new file mode 100644 index 0000000..04f5295 --- /dev/null +++ b/tuple_assignment_semantics_test.go @@ -0,0 +1,148 @@ +package contracttrace + +import ( + "context" + "os" + "path/filepath" + "testing" +) + +func TestTupleAssignmentRetainsEventAndConfiguredValueUncertainty(t *testing.T) { + root := t.TempDir() + write := func(name, source string) { + t.Helper() + path := filepath.Join(root, name) + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(source), 0600); err != nil { + t.Fatal(err) + } + } + write("go.mod", "module example.com/tupleevent\n\ngo 1.27.0\n") + write("api/api.go", `package api + +func Publish(string) {} +func Query(string) bool { return true } +`) + write("app.go", `package app + +import "example.com/tupleevent/api" + +type Envelope struct { EventType string } + +func eventPair() (string, string) { return "orders.first", "orders.second" } +func queryPair() (string, bool) { return "SELECT id FROM tuple_records", true } + +func AssignEventFirst() { + var envelope Envelope + envelope.EventType, _ = eventPair() +} +func AssignEventSecond() { + var envelope Envelope + _, envelope.EventType = eventPair() +} +func PublishTupleResults() { + first, second := eventPair() + api.Publish(first) + api.Publish(second) +} +func QueryTupleResult() { + query, ok := queryPair() + _ = ok + api.Query(query) +} +func DirectEventAssignment() { + var envelope Envelope + envelope.EventType = "orders.direct" +} + +var packageQuery, packageQueryOK = queryPair() +var packageQueryResult = api.Query(packageQuery) + +func queryTail() (bool, string) { return true, "SELECT id FROM tuple_records" } +func PublishTupleValueSpec() { var first, second = eventPair(); _ = second; api.Publish(first) } +func PublishTupleSecondAssignment() { eventName := "orders.stale"; _, eventName = eventPair(); api.Publish(eventName) } +func QueryTupleValueSpec() { var query, ok = queryPair(); _ = ok; api.Query(query) } +func QueryTupleSecondAssignment() { query := "SELECT id FROM stale_records"; _, query = queryTail(); api.Query(query) } +func AssignEventCommaOK(values map[string]string) { var envelope Envelope; envelope.EventType, _ = values["event"] } +func AssignEventAssertion(value any) { var envelope Envelope; envelope.EventType, _ = value.(string) } +func PlainAfterDeclaration() { var name string; name = "orders.declared"; api.Publish(name) } +`) + + report, err := Trace(context.Background(), Options{ + Root: root, + Seeds: []string{ + "AssignEventFirst", "AssignEventSecond", "PublishTupleResults", "QueryTupleResult", + "DirectEventAssignment", "AssignEventCommaOK", "AssignEventAssertion", "PublishTupleValueSpec", "PublishTupleSecondAssignment", + "QueryTupleValueSpec", "QueryTupleSecondAssignment", "PlainAfterDeclaration", "example.com/tupleevent::(package init)", + }, + Depth: 3, MaxNodes: 100, + Config: Config{EventFields: []string{"EventType"}, CallRules: []CallRule{ + {Symbol: "example.com/tupleevent/api::Publish", Kind: "event_publish", Argument: 0}, + {Symbol: "example.com/tupleevent/api::Query", Kind: "sql_query", Argument: 0, Namespace: "primary"}, + }}, + }) + if err != nil { + t.Fatal(err) + } + + if !hasBoundaryAt(report, "example.com/tupleevent::AssignEventSecond", "dynamic_event", "app.go", 16) { + t.Error("event discriminator assigned from the second tuple slot lost unresolved event evidence") + } + if !hasBoundaryAt(report, "example.com/tupleevent::AssignEventFirst", "dynamic_event", "app.go", 12) { + t.Error("event discriminator assigned from a tuple call must remain explicitly unresolved") + } + if !hasBoundaryAt(report, "example.com/tupleevent::AssignEventCommaOK", "dynamic_event", "app.go", 41) || + !hasBoundaryAt(report, "example.com/tupleevent::AssignEventAssertion", "dynamic_event", "app.go", 42) { + t.Error("event discriminator from comma-ok map or type-assertion result lost unresolved evidence") + } + if !hasRelationship(report, "example.com/tupleevent::DirectEventAssignment", "event:orders.direct", "event_assign", "app.go", 30) { + t.Error("ordinary one-to-one event assignment lost its complete value") + } + + first := hasRelationship(report, "example.com/tupleevent::PublishTupleResults", "event:orders.first", "event_publish", "app.go", 20) + second := hasRelationship(report, "example.com/tupleevent::PublishTupleResults", "event:orders.second", "event_publish", "app.go", 21) + if !first || !second { + t.Errorf("tuple result candidates were not kept per slot: first=%t second=%t", first, second) + } + if hasRelationship(report, "example.com/tupleevent::PublishTupleResults", "event:orders.second", "event_publish", "app.go", 20) || + hasRelationship(report, "example.com/tupleevent::PublishTupleResults", "event:orders.first", "event_publish", "app.go", 21) { + t.Error("tuple result candidate from one slot leaked into the other slot") + } + if !hasBoundaryAt(report, "example.com/tupleevent::QueryTupleResult", "dynamic_api_value", "app.go", 26) { + t.Error("configured SQL argument derived from a tuple result lost unresolved-value evidence") + } + if !hasRelationship(report, "example.com/tupleevent::QueryTupleResult", "table:primary:tuple_records", "sql_read", "app.go", 26) { + t.Error("known SQL candidate from tuple result was not retained") + } + if !hasBoundaryAt(report, "example.com/tupleevent::(package init)", "dynamic_api_value", "app.go", 34) { + t.Error("package-initializer API argument derived from tuple ValueSpec lost unresolved-value evidence") + } + if !hasRelationship(report, "example.com/tupleevent::(package init)", "table:primary:tuple_records", "sql_read", "app.go", 34) { + t.Error("package-initializer SQL candidate from tuple ValueSpec was not retained") + } + if !hasBoundaryAt(report, "example.com/tupleevent::PublishTupleValueSpec", "dynamic_api_value", "app.go", 37) || + !hasRelationship(report, "example.com/tupleevent::PublishTupleValueSpec", "event:orders.first", "event_publish", "app.go", 37) || + hasRelationship(report, "example.com/tupleevent::PublishTupleValueSpec", "event:orders.second", "event_publish", "app.go", 37) { + t.Error("local tuple ValueSpec must retain the first event slot as a candidate with uncertainty, without importing the second slot") + } + if !hasBoundaryAt(report, "example.com/tupleevent::PublishTupleSecondAssignment", "dynamic_api_value", "app.go", 38) || + !hasRelationship(report, "example.com/tupleevent::PublishTupleSecondAssignment", "event:orders.second", "event_publish", "app.go", 38) || + hasRelationship(report, "example.com/tupleevent::PublishTupleSecondAssignment", "event:orders.stale", "event_publish", "app.go", 38) { + t.Error("later tuple assignment must invalidate the stale event fallback while retaining its actual result slot") + } + if !hasBoundaryAt(report, "example.com/tupleevent::QueryTupleValueSpec", "dynamic_api_value", "app.go", 39) || + !hasRelationship(report, "example.com/tupleevent::QueryTupleValueSpec", "table:primary:tuple_records", "sql_read", "app.go", 39) { + t.Error("local tuple ValueSpec SQL fallback lost its candidate or unresolved boundary") + } + if !hasBoundaryAt(report, "example.com/tupleevent::QueryTupleSecondAssignment", "dynamic_api_value", "app.go", 40) || + !hasRelationship(report, "example.com/tupleevent::QueryTupleSecondAssignment", "table:primary:tuple_records", "sql_read", "app.go", 40) || + hasRelationship(report, "example.com/tupleevent::QueryTupleSecondAssignment", "table:primary:stale_records", "sql_read", "app.go", 40) { + t.Error("later tuple SQL assignment must invalidate the stale query fallback while retaining its actual result slot") + } + if !hasRelationship(report, "example.com/tupleevent::PlainAfterDeclaration", "event:orders.declared", "event_publish", "app.go", 43) || + hasBoundaryAt(report, "example.com/tupleevent::PlainAfterDeclaration", "dynamic_api_value", "app.go", 43) { + t.Error("ordinary declaration followed by a plain assignment should retain its complete event argument") + } +} diff --git a/unkeyed_event_fields_test.go b/unkeyed_event_fields_test.go new file mode 100644 index 0000000..72d2751 --- /dev/null +++ b/unkeyed_event_fields_test.go @@ -0,0 +1,102 @@ +package contracttrace + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestUnkeyedStructLiteralsUseResolvedEventFieldPositions(t *testing.T) { + root := t.TempDir() + write := func(name, source string) { + t.Helper() + path := filepath.Join(root, name) + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(source), 0600); err != nil { + t.Fatal(err) + } + } + write("go.mod", "module example.com/unkeyedevent\n\ngo 1.27.0\n") + write("app.go", `package app + +type Envelope struct { EventType string; Version int } +type Alias = Envelope +type Generic[T any] struct { Version T; EventType string } + +func Positional() { + _ = Envelope{"orders.positional", 1} + _ = Alias{"orders.alias", 2} + _ = Generic[string]{"v1", "orders.generic"} +} + +func Keyed() { _ = Envelope{EventType: "orders.keyed", Version: 1} } +func Dynamic(name string) { _ = Envelope{name, 1} } +func NonStructLiterals() { + _ = []string{"orders.slice"} + _ = map[string]string{EventType: "orders.map"} + _ = [1]string{"orders.array"} +} + +var packageEnvelope = Envelope{"orders.package", 3} +var dormant = func() { _ = Envelope{"orders.dormant", 4} } + +func init() { _ = Envelope{"orders.explicit_init", 5} } +func Elided() { _ = []Envelope{{"orders.elided", 6}} } +func ElidedPointer() { _ = []*Envelope{{"orders.pointer", 7}} } +const EventType = "EventType" +`) + + report, err := Trace(context.Background(), Options{ + Root: root, + Seeds: []string{"Positional", "Keyed", "Dynamic", "NonStructLiterals", "Elided", "ElidedPointer", "example.com/unkeyedevent::(package init)"}, + Depth: 2, MaxNodes: 100, + Config: Config{EventFields: []string{"EventType"}}, + }) + if err != nil { + t.Fatal(err) + } + + for _, expected := range []struct { + owner, event string + line int + }{ + {"example.com/unkeyedevent::Positional", "orders.positional", 8}, + {"example.com/unkeyedevent::Positional", "orders.alias", 9}, + {"example.com/unkeyedevent::Positional", "orders.generic", 10}, + {"example.com/unkeyedevent::Keyed", "orders.keyed", 13}, + {"example.com/unkeyedevent::(package init)", "orders.package", 21}, + {"example.com/unkeyedevent::Elided", "orders.elided", 25}, + {"example.com/unkeyedevent::ElidedPointer", "orders.pointer", 26}, + } { + if !hasRelationship(report, expected.owner, "event:"+expected.event, "event_construct", "app.go", expected.line) { + t.Errorf("missing event construct %q from %s at app.go:%d", expected.event, expected.owner, expected.line) + } + } + if !hasBoundaryAt(report, "example.com/unkeyedevent::Dynamic", "dynamic_event", "app.go", 14) { + t.Error("unresolved unkeyed event discriminator should retain a dynamic_event boundary") + } + if hasRelationship(report, "example.com/unkeyedevent::NonStructLiterals", "event:orders.map", "event_construct", "app.go", 17) { + t.Error("a map key named EventType must not be interpreted as a struct event field") + } + explicitInitFound := false + for _, edge := range report.Relationships { + if strings.Contains(edge.To, "orders.dormant") { + t.Errorf("dormant initializer closure was attributed to package initialization: %+v", edge) + } + if strings.Contains(edge.To, "orders.explicit_init") { + if edge.Kind != "event_construct" || edge.Evidence.File != "app.go" || edge.Evidence.Line != 24 || + !strings.Contains(edge.From, "example.com/unkeyedevent::init@") { + t.Errorf("explicit init event has incorrect owner or source evidence: %+v", edge) + } else { + explicitInitFound = true + } + } + } + if !explicitInitFound { + t.Error("explicit init function's unkeyed event construct was not retained") + } +} diff --git a/unresolved_functions.go b/unresolved_functions.go new file mode 100644 index 0000000..844acfe --- /dev/null +++ b/unresolved_functions.go @@ -0,0 +1,144 @@ +package contracttrace + +import ( + "go/types" + "strings" + + "golang.org/x/tools/go/ssa" +) + +func isFunctionType(typ types.Type) bool { _, ok := typ.Underlying().(*types.Signature); return ok } + +func (a *flowAnalysis) seedFunctionInputs(functions []*ssa.Function, ix *index, uncalled bool) bool { + called := map[*ssa.Function]bool{} + if uncalled { + for _, function := range functions { + for _, block := range function.Blocks { + for _, instruction := range block.Instrs { + if call, ok := instruction.(ssa.CallInstruction); ok { + for target := range a.targets(call.Common()) { + if ix.owner(target) != "" { + called[target] = true + } + } + } + } + } + } + } + changed := false + for _, function := range functions { + exported := function.Object() != nil && function.Object().Exported() + if uncalled == exported || uncalled && called[function] { + continue + } + for _, parameter := range function.Params { + if isFunctionType(parameter.Type()) { + unknown := emptyFlow() + unknown.functionUnknown = true + if a.put(parameter, unknown) { + changed = true + } + } + } + } + return changed +} + +func (a *flowAnalysis) outsideFunctionResult(call *ssa.Call, ix *index) bool { + return a.special[call] == nil && iteratorFactory(call.Common()) == "" && (a.get(call.Common().Value).functionUnknown || a.sqlExternalResult(call.Common(), ix)) +} + +func (a *flowAnalysis) seedUnresolvedFunctionResults(functions []*ssa.Function) bool { + changed := false + for _, function := range functions { + for _, block := range function.Blocks { + for _, instruction := range block.Instrs { + var value ssa.Value + var call *ssa.Call + switch candidate := instruction.(type) { + case *ssa.Call: + value, call = candidate, candidate + case *ssa.Extract: + value = candidate + call, _ = candidate.Tuple.(*ssa.Call) + } + if value == nil || call == nil || !isFunctionType(value.Type()) || a.special[call] != nil || len(a.targets(call.Common())) != 0 { + continue + } + modeled := false + for effect := range a.get(call).effects { + if _, ok := a.iterators[effect]; ok { + modeled = true + } + } + if !modeled { + unknown := emptyFlow() + unknown.functionUnknown = true + if a.put(value, unknown) { + changed = true + } + } + } + } + } + return changed +} + +func (a *flowAnalysis) unresolvedFunctionUse(call ssa.CallInstruction, ix *index) { + common := call.Common() + if common.IsInvoke() || common.StaticCallee() != nil { + return + } + if _, builtin := common.Value.(*ssa.Builtin); builtin { + return + } + value := a.get(common.Value) + known := len(a.targets(common)) != 0 + for effect := range value.effects { + if strings.HasPrefix(effect, "afterfunc_dispatch:") { + if _, modeled := a.afterFuncs[strings.TrimPrefix(effect, "afterfunc_dispatch:")]; modeled { + known = true + } + } + if strings.HasPrefix(effect, "afterfunc_stop:") { + if _, modeled := a.afterFuncs[strings.TrimPrefix(effect, "afterfunc_stop:")]; modeled { + known = true + } + } + if _, modeled := a.iterators[effect]; modeled { + known = true + } + if strings.HasPrefix(effect, "cancel:") { + if _, modeled := a.contextKeys[strings.TrimPrefix(effect, "cancel:")]; modeled { + known = true + } + } + } + if len(a.waitGroupUses(common)) != 0 { + known = true + } + if known && !value.functionUnknown && !value.functionNil { + return + } + kind := "unresolved_function_call" + reason := "function call has no identified target in the bounded local flow model; nil, outside input or unsupported function flow cannot establish an absent caller/callee path or prove execution" + if known { + kind = "partial_function_call" + reason = "known function targets or callable summaries are retained alongside an unresolved alternative; additional runtime behavior remains unidentified" + if value.functionNil && value.functionUnknown { + reason = "known function targets are retained alongside possible nil and unresolved function-value alternatives; nil invocation would panic, and no successful invocation is inferred" + } else if value.functionNil { + reason = "known function targets are retained alongside a typed nil function alternative; invoking the nil candidate would panic, and no successful invocation is inferred" + } + } else if value.functionNil && !value.functionUnknown { + reason = "function call has only a typed nil function candidate; invoking it would panic, and no successful caller/callee path is inferred" + } + switch call.(type) { + case *ssa.Defer: + kind = "deferred_" + kind + case *ssa.Go: + kind = "goroutine_" + kind + } + ix.boundaries = append(ix.boundaries, Boundary{Node: ix.owner(call.Parent()), Kind: kind, Reason: reason, Evidence: ix.callEvidence(call)}) +} diff --git a/unresolved_functions_test.go b/unresolved_functions_test.go new file mode 100644 index 0000000..51ef300 --- /dev/null +++ b/unresolved_functions_test.go @@ -0,0 +1,44 @@ +package contracttrace + +import ( + "context" + "encoding/json" + "strings" + "testing" +) + +func TestUnresolvedFunctionCallsExposeEachExecutionForm(t *testing.T) { + seeds := []string{"UnknownFunctionCalls", "KnownFunctionCalls", "SliceFunctionIterationCaller", "Start", "DualContexts"} + fresh, analysis, err := TraceWithAnalysis(context.Background(), Options{Root: "testdata/sample", Seeds: seeds, Depth: 2, MaxNodes: 150}) + if err != nil { + t.Fatal(err) + } + data, err := json.Marshal(analysis) + if err != nil { + t.Fatal(err) + } + loaded, err := ReadAnalysis(strings.NewReader(string(data))) + if err != nil { + t.Fatal(err) + } + resumed, err := Explore(context.Background(), loaded, ExploreOptions{Seeds: seeds, Depth: 2, MaxNodes: 150}) + if err != nil { + t.Fatal(err) + } + for _, report := range []Report{fresh, resumed} { + for kind, snippet := range map[string]string{"unresolved_function_call": "callback()", "deferred_unresolved_function_call": "defer callback()", "goroutine_unresolved_function_call": "go callback()"} { + found := false + for _, boundary := range report.Boundaries { + found = found || boundary.Kind == kind && strings.HasSuffix(boundary.Node, "::UnknownFunctionCalls") && boundary.Evidence.Snippet == snippet + } + if !found { + t.Errorf("missing source-backed %s", kind) + } + } + for _, boundary := range report.Boundaries { + if strings.Contains(boundary.Kind, "unresolved_function_call") && (!strings.HasSuffix(boundary.Node, "::UnknownFunctionCalls") || strings.Contains(boundary.Evidence.Snippet, "copy(")) { + t.Errorf("resolved/builtin/modeled iterator falsely unresolved: %+v", boundary) + } + } + } +} diff --git a/unresolved_private_callbacks_test.go b/unresolved_private_callbacks_test.go new file mode 100644 index 0000000..0126e10 --- /dev/null +++ b/unresolved_private_callbacks_test.go @@ -0,0 +1,79 @@ +package contracttrace + +import ( + "bytes" + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestUncalledPrivateCallbackInputsStayUnresolved(t *testing.T) { + root := t.TempDir() + write := func(name, contents string) { + t.Helper() + if err := os.WriteFile(filepath.Join(root, name), []byte(contents), 0600); err != nil { + t.Fatal(err) + } + } + write("go.mod", "module example.com/privatecallbacks\n\ngo 1.27.0\n") + write("callbacks.go", `package callbacks + +func knownCallback() {} +func closedCallback() {} + +func uncalledPrivate(callback func(), chooseKnown bool) { + if chooseKnown { + callback = knownCallback + } + callback() +} + +func calledPrivate(callback func()) { callback() } +func closedCaller() { calledPrivate(closedCallback) } + +func ExportedCallback(callback func()) { callback() } +`) + options := Options{Root: root, Seeds: []string{"uncalledPrivate", "closedCaller", "ExportedCallback"}, Depth: 4, MaxNodes: 250} + fresh, analysis, err := TraceWithAnalysis(context.Background(), options) + if err != nil { + t.Fatal(err) + } + var compressed bytes.Buffer + if err := WriteAnalysis(&compressed, analysis, true); err != nil { + t.Fatal(err) + } + loaded, err := ReadAnalysis(bytes.NewReader(compressed.Bytes())) + if err != nil { + t.Fatal(err) + } + resumed, err := Explore(context.Background(), loaded, ExploreOptions{Seeds: options.Seeds, Depth: options.Depth, MaxNodes: options.MaxNodes}) + if err != nil { + t.Fatal(err) + } + + check := func(name string, report Report) { + t.Helper() + knownTarget, openPrivateBoundary, exportedBoundary := false, false, false + for _, edge := range report.Relationships { + knownTarget = knownTarget || edge.Kind == "resolved_callback_call" && strings.HasSuffix(edge.From, "::uncalledPrivate") && strings.HasSuffix(edge.To, "::knownCallback") && edge.Evidence.File == "callbacks.go" && edge.Evidence.Line == 10 && strings.Contains(edge.Evidence.Snippet, "callback()") + } + for _, boundary := range report.Boundaries { + openPrivateBoundary = openPrivateBoundary || boundary.Kind == "partial_function_call" && strings.HasSuffix(boundary.Node, "::uncalledPrivate") && boundary.Evidence.Line == 10 && strings.Contains(boundary.Evidence.Snippet, "callback()") + exportedBoundary = exportedBoundary || boundary.Kind == "unresolved_function_call" && strings.HasSuffix(boundary.Node, "::ExportedCallback") && boundary.Evidence.Line == 16 && strings.Contains(boundary.Evidence.Snippet, "callback()") + if (boundary.Kind == "partial_function_call" || strings.Contains(boundary.Kind, "unresolved_function_call")) && strings.HasSuffix(boundary.Node, "::calledPrivate") && boundary.Evidence.File == "callbacks.go" && boundary.Evidence.Line == 13 { + t.Errorf("%s: closed private caller acquired an outside callback alternative: %+v", name, boundary) + } + } + closedTarget := false + for _, edge := range report.Relationships { + closedTarget = closedTarget || edge.Kind == "resolved_callback_call" && strings.HasSuffix(edge.From, "::calledPrivate") && strings.HasSuffix(edge.To, "::closedCallback") && edge.Evidence.File == "callbacks.go" && edge.Evidence.Line == 13 && strings.Contains(edge.Evidence.Snippet, "callback()") + } + if !knownTarget || !openPrivateBoundary || !exportedBoundary || !closedTarget { + t.Errorf("%s: callback input contract incomplete: known=%t private-unknown=%t exported=%t closed=%t", name, knownTarget, openPrivateBoundary, exportedBoundary, closedTarget) + } + } + check("fresh", fresh) + check("resumed", resumed) +} diff --git a/waitgroup.go b/waitgroup.go new file mode 100644 index 0000000..2d3106f --- /dev/null +++ b/waitgroup.go @@ -0,0 +1,104 @@ +package contracttrace + +import ( + "go/types" + "strings" + + "golang.org/x/tools/go/ssa" +) + +func waitGroupOperation(common *ssa.CallCommon) (string, ssa.Value) { + target := common.StaticCallee() + if target == nil || target.Object() == nil || target.Object().Pkg() == nil || target.Object().Pkg().Path() != "sync" || target.Signature.Recv() == nil || len(common.Args) == 0 { + return "", nil + } + receiver := target.Signature.Recv().Type() + if pointer, ok := receiver.(*types.Pointer); ok { + receiver = pointer.Elem() + } + named, ok := receiver.(*types.Named) + if !ok || named.Obj().Name() != "WaitGroup" || named.Obj().Pkg() == nil || named.Obj().Pkg().Path() != "sync" { + return "", nil + } + switch target.Object().Name() { + case "Add", "Done", "Wait", "Go": + return target.Object().Name(), common.Args[0] + } + return "", nil +} + +func (a *flowAnalysis) waitGroupResources(call ssa.CallInstruction, ix *index) []waitGroupUse { + uses := a.waitGroupUses(call.Common()) + for _, use := range uses { + for _, id := range use.resources { + if ix.funcs[id] == nil { + ix.funcs[id] = &function{node: Node{ID: id, Name: "WaitGroup receiver", Kind: "waitgroup", Evidence: ix.callEvidence(call)}} + } + } + } + return uses +} + +func (a *flowAnalysis) waitGroupUse(call ssa.CallInstruction, ix *index) { + for _, use := range a.waitGroupResources(call, ix) { + name, resources := use.name, use.resources + from := ix.owner(call.Parent()) + evidence := ix.callEvidence(call) + if len(resources) == 0 || use.unknown { + reason := "WaitGroup receiver has no modeled allocation/global/field identity; input or alias may be external" + if len(resources) > 0 && use.outside { + reason = "WaitGroup receiver candidates include source-backed outside input values alongside local identities" + } else if len(resources) > 0 { + reason = "WaitGroup receiver projection retained known embedded candidates but other receiver branches are unresolved" + } + ix.boundaries = append(ix.boundaries, Boundary{Node: from, Kind: "unresolved_waitgroup", Reason: reason, Evidence: evidence}) + } + for _, id := range resources { + edge := Relationship{From: from, To: id, Kind: "waitgroup_" + strings.ToLower(name), Certainty: "possible", Evidence: evidence} + if name == "Add" && len(use.arguments) > 0 { + if constant, ok := use.arguments[0].(*ssa.Const); ok && constant.Value != nil { + edge.Slot = "delta" + edge.Values = []string{"integer:" + constant.Value.ExactString()} + } + } + ix.edges = append(ix.edges, edge) + } + if name == "Go" && len(use.arguments) > 0 { + found := false + taskValue := a.get(use.arguments[0]) + nilTask := taskValue.functionNil + uncertain := taskValue.functionUnknown || nilTask + for _, target := range sortedFunctions(a.get(use.arguments[0]).functions) { + to := ix.owner(target) + if to == "" { + ix.boundaries = append(ix.boundaries, Boundary{Node: from, Kind: "external_waitgroup_task", Reason: "WaitGroup.Go task body is outside the indexed repository", Evidence: evidence, Examples: []string{target.String()}}) + continue + } + found = true + for _, id := range resources { + ix.edges = append(ix.edges, Relationship{From: id, To: to, Kind: "waitgroup_task", Certainty: "possible", Evidence: evidence}) + } + } + if !found || uncertain { + reason := "WaitGroup.Go task has no modeled local function candidate; runtime callback selection or external bodies remain unresolved" + if found { + reason = "WaitGroup.Go has known local task candidates alongside an outside callback input" + if nilTask { + if taskValue.functionUnknown { + reason = "WaitGroup.Go has known local task candidates alongside a possible nil and unresolved callback-value alternative" + } else { + reason = "WaitGroup.Go has known local task candidates alongside a nil callback alternative" + } + } + } else if nilTask { + if taskValue.functionUnknown { + reason = "WaitGroup.Go has no known local task candidate and retains a possible nil and unresolved callback-value alternative" + } else { + reason = "WaitGroup.Go task is a typed nil function value; invoking it would panic, and no successful invocation is inferred" + } + } + ix.boundaries = append(ix.boundaries, Boundary{Node: from, Kind: "unresolved_waitgroup_task", Reason: reason, Evidence: evidence}) + } + } + } +} diff --git a/waitgroup_binding.go b/waitgroup_binding.go new file mode 100644 index 0000000..67b728d --- /dev/null +++ b/waitgroup_binding.go @@ -0,0 +1,185 @@ +package contracttrace + +import ( + "go/types" + "strings" + + "golang.org/x/tools/go/ssa" +) + +// Attach each captured receiver to its closure value, not the shared wrapper's +// FreeVar summary: different receivers can use the same generated wrapper. +func (a *flowAnalysis) modelWaitGroupBinding(closure *ssa.MakeClosure, result *flowValue) { + target := closure.Fn.(*ssa.Function) + if !strings.HasPrefix(target.Synthetic, "bound method wrapper") { + return + } + for _, block := range target.Blocks { + for _, ins := range block.Instrs { + call, ok := ins.(ssa.CallInstruction) + if !ok { + continue + } + name, receiver := waitGroupOperation(call.Common()) + interfaceReceiver := false + if name == "" && call.Common().IsInvoke() && waitGroupMethodName(call.Common().Method.Name()) { + name, receiver = call.Common().Method.Name(), call.Common().Value + interfaceReceiver = true + } + if name == "" { + continue + } + for index, variable := range target.FreeVars { + if receiver != variable || index >= len(closure.Bindings) { + continue + } + value := emptyFlow() + receiverFlow := a.get(closure.Bindings[index]) + aliases := receiverFlow.addresses + if interfaceReceiver { + known, tagged := a.waitGroupInterfaceAliases(closure.Bindings[index]) + if !known { + continue + } + aliases = tagged + value.interfaceUnknown = receiverFlow.interfaceUnknown || a.waitGroupInputCandidate(receiverFlow.addresses) + } + value.effects["waitgroup_bound:"+name] = true + for _, alias := range sortedKeys(aliases) { + value.effects["waitgroup_bound:"+name+":"+alias] = true + } + a.merge(result, value) + } + } + } +} + +type waitGroupUse struct { + name string + resources []string + arguments []ssa.Value + unknown bool + outside bool +} + +func (a *flowAnalysis) waitGroupUses(common *ssa.CallCommon) []waitGroupUse { + aliases := map[string]map[string]bool{} + partialOperations := map[string]bool{} + outsideOperations := map[string]bool{} + arguments := common.Args + if name, receiver := waitGroupOperation(common); name != "" { + aliases[name] = a.get(receiver).addresses + arguments = common.Args[1:] + } else if expressions := a.waitGroupMethodExpressions(common); len(expressions) > 0 { + for name, projected := range expressions { + aliases[name] = projected.addresses + partialOperations[name] = projected.interfaceUnknown || projected.boundReceiverUnknown || projected.functionUnknown + outsideOperations[name] = a.waitGroupInputCandidate(projected.addresses) + } + arguments = common.Args[1:] + } else if common.IsInvoke() && waitGroupMethodName(common.Method.Name()) { + receiver := a.get(common.Value) + if known, tagged := a.waitGroupInterfaceAliases(common.Value); known { + aliases[common.Method.Name()] = tagged + outside := receiver.interfaceUnknown || a.waitGroupInputCandidate(receiver.addresses) + partialOperations[common.Method.Name()] = outside + outsideOperations[common.Method.Name()] = outside + } + } else { + callable := a.get(common.Value) + for _, effect := range sortedKeys(callable.effects) { + if !strings.HasPrefix(effect, "waitgroup_bound:") { + continue + } + parts := strings.SplitN(strings.TrimPrefix(effect, "waitgroup_bound:"), ":", 2) + name := parts[0] + if aliases[name] == nil { + aliases[name] = map[string]bool{} + } + if len(parts) == 2 { + aliases[name][parts[1]] = true + } + outside := callable.interfaceUnknown || a.waitGroupInputCandidate(callable.addresses) + partialOperations[name] = partialOperations[name] || outside + outsideOperations[name] = outsideOperations[name] || outside + } + } + uses := []waitGroupUse{} + for _, name := range sortedKeys(aliases) { + outsideOperations[name] = outsideOperations[name] || a.waitGroupInputCandidate(aliases[name]) + partialOperations[name] = partialOperations[name] || outsideOperations[name] + resources := []string{} + for _, alias := range sortedKeys(aliases[name]) { + resources = append(resources, resourceID("waitgroup", "", alias)) + } + uses = append(uses, waitGroupUse{name, resources, arguments, partialOperations[name], outsideOperations[name]}) + } + return uses +} + +func (a *flowAnalysis) waitGroupInputCandidate(candidates map[string]bool) bool { + for candidate := range candidates { + if strings.HasPrefix(candidate, "input:") || a.inputRoots[candidate] { + return true + } + } + return false +} + +// Method expressions for promoted methods are generated SSA thunks. Their +// first call argument is the outer receiver, while the WaitGroup receiver is +// the embedded field selected by the Go method set. +func (a *flowAnalysis) waitGroupMethodExpressions(common *ssa.CallCommon) map[string]flowValue { + projectedByMethod := map[string]flowValue{} + if len(common.Args) == 0 { + return projectedByMethod + } + for _, target := range sortedFunctions(a.targets(common)) { + if target.Signature == nil || target.Signature.Recv() != nil { + continue + } + apiMethod, ok := target.Object().(*types.Func) + if !ok || apiMethod.Pkg() == nil || apiMethod.Pkg().Path() != "sync" || !waitGroupMethodName(apiMethod.Name()) { + continue + } + apiMethod = apiMethod.Origin() + if apiMethod.Pkg() == nil || apiMethod.Pkg().Path() != "sync" { + continue + } + receiver := apiMethod.Type().(*types.Signature).Recv().Type() + if pointer, ok := receiver.(*types.Pointer); ok { + receiver = pointer.Elem() + } + named, ok := receiver.(*types.Named) + if !ok || named.Obj().Name() != "WaitGroup" || named.Obj().Pkg() == nil || named.Obj().Pkg().Path() != "sync" { + continue + } + name := apiMethod.Name() + methodSet := types.NewMethodSet(common.Args[0].Type()) + for i := 0; i < methodSet.Len(); i++ { + method, ok := methodSet.At(i).Obj().(*types.Func) + if !ok || method.Name() != name { + continue + } + selected, ok := methodSet.At(i).Obj().(*types.Func) + if !ok || selected.Origin() != apiMethod { + continue + } + projected, _, status := a.projectMethodExpressionReceiver(a.get(common.Args[0]), common.Args[0].Type(), apiMethod) + if status == methodReceiverProjectionUnsupported { + projected.interfaceUnknown = true + } + for candidate := range projected.addresses { + if strings.HasPrefix(candidate, "input:") || a.inputRoots[candidate] { + projected.interfaceUnknown = true + } + } + combined := projectedByMethod[name] + a.merge(&combined, projected) + mergeProjectedUncertainty(&combined, a.get(common.Value)) + projectedByMethod[name] = combined + break + } + } + return projectedByMethod +} diff --git a/waitgroup_interface.go b/waitgroup_interface.go new file mode 100644 index 0000000..76e3f4f --- /dev/null +++ b/waitgroup_interface.go @@ -0,0 +1,48 @@ +package contracttrace + +import ( + "go/types" + "strings" + + "golang.org/x/tools/go/ssa" +) + +func isWaitGroupPointer(t types.Type) bool { + pointer, ok := types.Unalias(t).(*types.Pointer) + if !ok { + return false + } + named, ok := types.Unalias(pointer.Elem()).(*types.Named) + return ok && named.Obj().Name() == "WaitGroup" && named.Obj().Pkg() != nil && named.Obj().Pkg().Path() == "sync" +} + +func (a *flowAnalysis) modelWaitGroupInterface(value ssa.Value, result *flowValue) { + if !isWaitGroupPointer(value.Type()) { + return + } + tagged := emptyFlow() + tagged.effects["waitgroup_receiver"] = true + for _, alias := range sortedKeys(a.get(value).addresses) { + tagged.effects["waitgroup_receiver:"+alias] = true + } + a.merge(result, tagged) +} + +func (a *flowAnalysis) waitGroupInterfaceAliases(value ssa.Value) (bool, map[string]bool) { + known := false + aliases := map[string]bool{} + for effect := range a.get(value).effects { + if effect == "waitgroup_receiver" { + known = true + } + if strings.HasPrefix(effect, "waitgroup_receiver:") { + known = true + aliases[strings.TrimPrefix(effect, "waitgroup_receiver:")] = true + } + } + return known, aliases +} + +func waitGroupMethodName(name string) bool { + return name == "Add" || name == "Done" || name == "Wait" || name == "Go" +} diff --git a/waitgroup_interface_unknown_test.go b/waitgroup_interface_unknown_test.go new file mode 100644 index 0000000..1a922b8 --- /dev/null +++ b/waitgroup_interface_unknown_test.go @@ -0,0 +1,175 @@ +package contracttrace + +import ( + "bytes" + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestWaitGroupInterfaceCandidatesRetainOutsideAlternatives(t *testing.T) { + root := t.TempDir() + write := func(name, source string) { + t.Helper() + path := filepath.Join(root, name) + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(source), 0600); err != nil { + t.Fatal(err) + } + } + write("go.mod", "module example.com/waitgroup-interface\n\ngo 1.27.0\n") + write("waitgroup.go", `package app + +import "sync" + +type Group interface { + Add(int) + Done() + Wait() + Go(func()) +} + +func knownTask() {} + +func PublicMixed(input Group, chooseOutside bool, task func()) { + var local sync.WaitGroup + var selected Group = &local + if chooseOutside { + selected = input + } + selected.Add(1) + done := selected.Done + done() + selected.Wait() + selected.Go(knownTask) + selected.Go(task) +} + +func PublicOutsideOnly(input *sync.WaitGroup) { + input.Add(1) + done := input.Done + done() +} + +func UnmarkedInterfaceOnly(input Group) { + input.Add(1) + done := input.Done + done() + input.Wait() +} + +func closedKnownOnly() { + var local sync.WaitGroup + var selected Group = &local + selected.Add(1) + done := selected.Done + done() + selected.Wait() +} +`) + + fresh, analysis, err := TraceWithAnalysis(context.Background(), Options{ + Root: root, + Seeds: []string{"PublicMixed", "PublicOutsideOnly", "UnmarkedInterfaceOnly", "closedKnownOnly"}, + Depth: 4, + MaxNodes: 200, + }) + if err != nil { + t.Fatal(err) + } + var saved bytes.Buffer + if err := WriteAnalysis(&saved, analysis, true); err != nil { + t.Fatal(err) + } + loaded, err := ReadAnalysis(bytes.NewReader(saved.Bytes())) + if err != nil { + t.Fatal(err) + } + resumed, err := Explore(context.Background(), loaded, ExploreOptions{ + Seeds: []string{"PublicMixed", "PublicOutsideOnly", "UnmarkedInterfaceOnly", "closedKnownOnly"}, + Depth: 4, + MaxNodes: 200, + }) + if err != nil { + t.Fatal(err) + } + + for reportName, report := range map[string]Report{"fresh": fresh, "resumed": resumed} { + mixedGroup := "" + outsideGroup := "" + known := map[string]bool{"waitgroup_add": false, "waitgroup_done": false, "waitgroup_wait": false, "waitgroup_go": false} + for _, edge := range report.Relationships { + if strings.HasSuffix(edge.From, "::PublicMixed") && edge.Kind == "waitgroup_add" && strings.Contains(edge.Evidence.Snippet, "selected.Add") { + mixedGroup = edge.To + } + if strings.HasSuffix(edge.From, "::PublicOutsideOnly") && edge.Kind == "waitgroup_add" && strings.Contains(edge.Evidence.Snippet, "input.Add") { + outsideGroup = edge.To + } + } + if mixedGroup == "" || outsideGroup == "" { + t.Fatalf("%s report omitted source-backed known WaitGroup candidates: mixed=%q outside=%q", reportName, mixedGroup, outsideGroup) + } + for _, edge := range report.Relationships { + if strings.HasSuffix(edge.From, "::PublicMixed") && edge.To == mixedGroup { + if _, ok := known[edge.Kind]; ok { + known[edge.Kind] = true + } + } + } + knownTask := false + for _, edge := range report.Relationships { + knownTask = knownTask || edge.Kind == "waitgroup_task" && edge.From == mixedGroup && strings.HasSuffix(edge.To, "::knownTask") + } + if !knownTask { + t.Errorf("%s report lost the local Go task candidate for the known receiver", reportName) + } + for kind, found := range known { + if !found { + t.Errorf("%s report missing known interface-backed %s edge", reportName, kind) + } + } + for _, snippet := range []string{"selected.Add(1)", "done()", "selected.Wait()", "selected.Go(knownTask)"} { + found := false + for _, boundary := range report.Boundaries { + found = found || boundary.Kind == "unresolved_waitgroup" && strings.HasSuffix(boundary.Node, "::PublicMixed") && strings.Contains(boundary.Evidence.Snippet, snippet) + } + if !found { + t.Errorf("%s known-plus-outside operation %q lacks its unresolved boundary", reportName, snippet) + } + } + goBoundary, taskBoundary := false, false + for _, boundary := range report.Boundaries { + goBoundary = goBoundary || boundary.Kind == "unresolved_waitgroup" && strings.HasSuffix(boundary.Node, "::PublicMixed") && strings.Contains(boundary.Evidence.Snippet, "selected.Go(task)") + taskBoundary = taskBoundary || boundary.Kind == "unresolved_waitgroup_task" && strings.HasSuffix(boundary.Node, "::PublicMixed") && strings.Contains(boundary.Evidence.Snippet, "selected.Go(task)") + } + if !goBoundary || !taskBoundary { + t.Errorf("%s WaitGroup.Go lost receiver/task outside uncertainty: operation=%t task=%t", reportName, goBoundary, taskBoundary) + } + for _, snippet := range []string{"input.Add(1)", "done()"} { + found := false + for _, boundary := range report.Boundaries { + found = found || boundary.Kind == "unresolved_waitgroup" && strings.HasSuffix(boundary.Node, "::PublicOutsideOnly") && strings.Contains(boundary.Evidence.Snippet, snippet) + } + if !found { + t.Errorf("%s outside-only operation %q lacks its input boundary", reportName, snippet) + } + } + for _, boundary := range report.Boundaries { + if strings.HasSuffix(boundary.Node, "::closedKnownOnly") && strings.HasPrefix(boundary.Kind, "unresolved_waitgroup") { + t.Errorf("%s closed known-only interface flow acquired an outside boundary: %+v", reportName, boundary) + } + if strings.HasSuffix(boundary.Node, "::UnmarkedInterfaceOnly") && strings.HasPrefix(boundary.Kind, "unresolved_waitgroup") { + t.Errorf("%s untagged interface method names were inferred as WaitGroup: %+v", reportName, boundary) + } + } + for _, edge := range report.Relationships { + if strings.HasSuffix(edge.From, "::UnmarkedInterfaceOnly") && strings.HasPrefix(edge.Kind, "waitgroup_") { + t.Errorf("%s untagged interface method names produced a WaitGroup edge: %+v", reportName, edge) + } + } + } +} diff --git a/waitgroup_test.go b/waitgroup_test.go new file mode 100644 index 0000000..3e6d91d --- /dev/null +++ b/waitgroup_test.go @@ -0,0 +1,457 @@ +package contracttrace + +import ( + "context" + "encoding/json" + "strings" + "testing" +) + +func TestWaitGroupConnectsWorkerAndJoinWithoutMergingGroups(t *testing.T) { + r, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"GroupStart"}, Depth: 4, MaxNodes: 250}) + if err != nil { + t.Fatal(err) + } + group, unrelated := "", "" + for _, e := range r.Relationships { + if e.Kind != "waitgroup_add" || !strings.HasSuffix(e.From, "::GroupStart") { + continue + } + if strings.Contains(e.Evidence.Snippet, "group.Add(1)") { + group = e.To + } + if strings.Contains(e.Evidence.Snippet, "unrelated.Add(2)") { + unrelated = e.To + } + } + if group == "" || unrelated == "" || group == unrelated { + t.Fatalf("distinct coordination identities missing: group=%s unrelated=%s", group, unrelated) + } + done, wait, deferred := false, false, false + for _, e := range r.Relationships { + if e.To == group && e.Kind == "waitgroup_done" && strings.HasSuffix(e.From, "::GroupWorker") { + done = true + } + if e.To == group && e.Kind == "waitgroup_wait" && strings.HasSuffix(e.From, "::GroupWait") { + wait = true + } + if e.To == group && e.Kind == "cleanup_waitgroup_done" { + deferred = true + } + if e.To == unrelated && (strings.HasSuffix(e.From, "::GroupWorker") || strings.HasSuffix(e.From, "::GroupWait")) { + t.Fatal("unrelated WaitGroup acquired worker/join relationships") + } + } + if !done || !wait || !deferred { + t.Fatalf("coordination path missing: done=%t wait=%t deferred=%t", done, wait, deferred) + } +} + +func TestWaitGroupGoExposesManagedTask(t *testing.T) { + r, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"GroupGo"}, Depth: 3, MaxNodes: 250}) + if err != nil { + t.Fatal(err) + } + group := "" + for _, e := range r.Relationships { + if strings.HasSuffix(e.From, "::GroupGo") && e.Kind == "waitgroup_go" { + group = e.To + } + } + task, wait := false, false + for _, e := range r.Relationships { + if e.From == group && strings.HasSuffix(e.To, "::ManagedTask") && e.Kind == "waitgroup_task" { + task = true + } + if e.To == group && e.Kind == "waitgroup_wait" { + wait = true + } + } + if group == "" || !task || !wait { + t.Fatalf("managed coordination path missing: group=%s task=%t wait=%t", group, task, wait) + } +} + +func TestWaitGroupFieldsGlobalsAndUnknownReceiverSurviveExploration(t *testing.T) { + _, analysis, err := TraceWithAnalysis(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"GroupFields"}, Depth: 3, MaxNodes: 250}) + if err != nil { + t.Fatal(err) + } + bytes, err := json.Marshal(analysis) + if err != nil { + t.Fatal(err) + } + saved, err := ReadAnalysis(strings.NewReader(string(bytes))) + if err != nil { + t.Fatal(err) + } + r, err := Explore(context.Background(), saved, ExploreOptions{Seeds: []string{"GroupFields", "UnknownGroup"}, Depth: 4, MaxNodes: 250}) + if err != nil { + t.Fatal(err) + } + workers, other, global := "", "", "" + negative := false + for _, e := range r.Relationships { + if !strings.HasSuffix(e.From, "::GroupFields") || e.Kind != "waitgroup_add" { + continue + } + switch { + case strings.Contains(e.Evidence.Snippet, "workers.Add"): + workers = e.To + case strings.Contains(e.Evidence.Snippet, "other.Add"): + other = e.To + negative = len(e.Values) == 1 && e.Values[0] == "integer:-1" + case strings.Contains(e.Evidence.Snippet, "GlobalGroup.Add"): + global = e.To + } + } + if workers == "" || other == "" || global == "" || workers == other || workers == global || other == global || !negative { + t.Fatalf("field/global identity or delta lost: workers=%s other=%s global=%s negative=%t", workers, other, global, negative) + } + done, wait, unknown := false, false, false + for _, e := range r.Relationships { + if strings.HasSuffix(e.From, "::GroupWorker") && e.Kind == "waitgroup_done" { + done = done || e.To == workers + if e.To == other || e.To == global { + t.Fatal("separate field/global acquired worker completion") + } + } + if strings.HasSuffix(e.From, "::GroupWait") && e.Kind == "waitgroup_wait" && e.To == global { + wait = true + } + } + for _, b := range r.Boundaries { + unknown = unknown || b.Kind == "unresolved_waitgroup" && strings.HasSuffix(b.Node, "::UnknownGroup") + } + if !done || !wait || !unknown { + t.Fatalf("saved coordination scope missing: done=%t wait=%t unknown=%t", done, wait, unknown) + } +} + +func TestBoundWaitGroupMethodsRetainReceiverAndCleanup(t *testing.T) { + r, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"BoundGroup", "BoundGroupGo"}, Depth: 4, MaxNodes: 250}) + if err != nil { + t.Fatal(err) + } + group, managed, unrelated := "", "", "" + for _, e := range r.Relationships { + if strings.HasSuffix(e.From, "::BoundGroup") && e.Kind == "waitgroup_add" && strings.Contains(e.Evidence.Snippet, "group.Add(1)") { + group = e.To + } + if strings.HasSuffix(e.From, "::BoundGroupGo") && e.Kind == "waitgroup_wait" { + managed = e.To + } + if strings.HasSuffix(e.From, "::BoundGroup") && e.Kind == "waitgroup_add" && strings.Contains(e.Evidence.Snippet, "unrelated.Add(2)") { + unrelated = e.To + } + } + done, wait, cleanup, task, delta := false, false, false, false, false + for _, e := range r.Relationships { + if strings.HasSuffix(e.From, "::BoundCompletion") && e.Kind == "waitgroup_done" { + if e.To == unrelated { + t.Fatal("bound receiver was merged with unrelated group") + } + done = done || e.To == group + } + if strings.HasSuffix(e.From, "::BoundJoin") && e.Kind == "waitgroup_wait" && e.To == group { + wait = true + } + cleanup = cleanup || e.Kind == "cleanup_waitgroup_done" && e.To == group + task = task || e.Kind == "waitgroup_task" && e.From == managed && strings.HasSuffix(e.To, "::ManagedTask") + delta = delta || e.Kind == "waitgroup_add" && e.To == managed && len(e.Values) == 1 && e.Values[0] == "integer:0" + } + if group == "" || managed == "" || unrelated == "" || !done || !wait || !cleanup || !task || !delta { + t.Fatalf("bound lifecycle path missing: done=%t wait=%t cleanup=%t task=%t delta=%t", done, wait, cleanup, task, delta) + } +} + +func TestBoundWaitGroupChoicesKeepOperationAndReceiverTogether(t *testing.T) { + r, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"BoundGroupChoices"}, Depth: 3, MaxNodes: 250}) + if err != nil { + t.Fatal(err) + } + first, second := "", "" + for _, e := range r.Relationships { + if e.Kind != "waitgroup_add" || !strings.HasSuffix(e.From, "::BoundGroupChoices") { + continue + } + if strings.Contains(e.Evidence.Snippet, "first.Add") { + first = e.To + } else if strings.Contains(e.Evidence.Snippet, "second.Add") { + second = e.To + } + } + done, wait := false, false + for _, e := range r.Relationships { + if e.Kind == "cleanup_waitgroup_done" { + if e.To != first { + t.Fatal("Done candidate acquired Wait's receiver") + } + done = true + } + if e.Kind == "cleanup_waitgroup_wait" { + if e.To != second { + t.Fatal("Wait candidate acquired Done's receiver") + } + wait = true + } + } + if first == "" || second == "" || first == second || !done || !wait { + t.Fatalf("mixed closure candidates lost correlation: first=%s second=%s done=%t wait=%t", first, second, done, wait) + } +} + +func TestWaitGroupInterfacesRetainTypedReceiverIdentity(t *testing.T) { + r, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"InterfaceGroups"}, Depth: 4, MaxNodes: 250}) + if err != nil { + t.Fatal(err) + } + group := "" + for _, e := range r.Relationships { + if strings.HasSuffix(e.From, "::InterfaceGroups") && e.Kind == "waitgroup_add" { + group = e.To + } + } + done, wait, cleanup, boundDone, boundWait := false, false, false, false, false + for _, e := range r.Relationships { + if e.Kind == "waitgroup_done" && strings.HasSuffix(e.From, "::InterfaceCompletion") { + if e.To != group { + t.Fatal("non-WaitGroup implementation became a coordination resource") + } + done = true + } + wait = wait || e.Kind == "waitgroup_wait" && strings.HasSuffix(e.From, "::InterfaceJoin") && e.To == group + cleanup = cleanup || e.Kind == "cleanup_waitgroup_done" && e.To == group + boundDone = boundDone || e.Kind == "waitgroup_done" && e.To == group && strings.HasSuffix(e.From, "::BoundCompletion") + boundWait = boundWait || e.Kind == "waitgroup_wait" && e.To == group && strings.HasSuffix(e.From, "::BoundJoin") + } + if group == "" || !done || !wait || !cleanup || !boundDone || !boundWait { + t.Fatalf("interface path missing: done=%t wait=%t cleanup=%t boundDone=%t boundWait=%t", done, wait, cleanup, boundDone, boundWait) + } +} + +func TestPromotedWaitGroupMethodExpressionsPreserveEmbeddedIdentity(t *testing.T) { + fresh, analysis, err := TraceWithAnalysis(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"PromotedMethodExpressionSites", "SimplePromotedMethodExpressions"}, Depth: 5, MaxNodes: 300}) + if err != nil { + t.Fatal(err) + } + var saved strings.Builder + if err := WriteAnalysis(&saved, analysis, false); err != nil { + t.Fatal(err) + } + loaded, err := ReadAnalysis(strings.NewReader(saved.String())) + if err != nil { + t.Fatal(err) + } + r, err := Explore(context.Background(), loaded, ExploreOptions{Seeds: []string{"PromotedMethodExpressionSites", "SimplePromotedMethodExpressions"}, Depth: 5, MaxNodes: 300}) + if err != nil { + t.Fatal(err) + } + for _, report := range []Report{fresh, r} { + known, unrelated := "", "" + for _, edge := range report.Relationships { + if edge.Kind != "waitgroup_add" || !strings.HasSuffix(edge.From, "::PromotedMethodExpressionSites") { + continue + } + if strings.Contains(edge.Evidence.Snippet, "known.WaitGroup.Add(7)") { + known = edge.To + } + if strings.Contains(edge.Evidence.Snippet, "unrelated.WaitGroup.Add(3)") { + unrelated = edge.To + } + } + if known == "" || unrelated == "" || known == unrelated { + t.Fatalf("distinct direct-control resources missing in fresh/saved report: known=%q unrelated=%q", known, unrelated) + } + want := map[string]bool{"waitgroup_add": false, "waitgroup_done": false, "waitgroup_wait": false} + selectedReceivers := map[string]bool{} + for _, edge := range report.Relationships { + if !strings.HasSuffix(edge.From, "::PromotedMethodExpressionSites") && !strings.HasSuffix(edge.From, "::callGroupWait") { + continue + } + if edge.To == unrelated && (edge.Kind == "waitgroup_add" || edge.Kind == "waitgroup_done" || edge.Kind == "waitgroup_wait") && !strings.Contains(edge.Evidence.Snippet, "unrelated.WaitGroup.Add(3)") { + t.Fatalf("unrelated direct control acquired an expression edge: %+v", edge) + } + if edge.To != known { + if edge.Kind == "waitgroup_done" && strings.Contains(edge.Evidence.Snippet, "Done(selected)") { + selectedReceivers[edge.To] = true + } + continue + } + if edge.Kind == "waitgroup_done" && strings.Contains(edge.Evidence.Snippet, "Done(selected)") { + selectedReceivers[edge.To] = true + } + switch edge.Kind { + case "waitgroup_add": + if strings.Contains(edge.Evidence.Snippet, "add(known, 1)") && len(edge.Values) == 1 && edge.Values[0] == "integer:1" { + want[edge.Kind] = true + } + case "waitgroup_done": + if strings.Contains(edge.Evidence.Snippet, "done(known)") { + want[edge.Kind] = true + } + case "waitgroup_wait": + if strings.Contains(edge.Evidence.Snippet, "wait(known)") || strings.Contains(edge.Evidence.Snippet, "wait(group)") || strings.Contains(edge.Evidence.Snippet, "Wait, known") { + want[edge.Kind] = true + } + } + } + if !want["waitgroup_add"] || !want["waitgroup_done"] || !want["waitgroup_wait"] { + t.Fatalf("promoted method-expression edges lost embedded identity or argument offset: resource=%s operations=%v; edges=%+v", known, want, report.Relationships) + } + unresolved := false + unresolvedCandidates := []Boundary{} + for _, boundary := range report.Boundaries { + if boundary.Kind == "unresolved_waitgroup" && strings.HasSuffix(boundary.Node, "::PromotedMethodExpressionSites") { + unresolvedCandidates = append(unresolvedCandidates, boundary) + } + unresolved = unresolved || boundary.Kind == "unresolved_waitgroup" && strings.HasSuffix(boundary.Node, "::PromotedMethodExpressionSites") && strings.Contains(boundary.Evidence.Snippet, "Done(selected)") + } + if !unresolved { + t.Fatalf("known and outside receiver alternatives did not retain a source-backed unresolved boundary: %+v", unresolvedCandidates) + } + outsideCandidate := false + for receiver := range selectedReceivers { + outsideCandidate = outsideCandidate || strings.Contains(receiver, "input") + } + if !selectedReceivers[known] || !outsideCandidate { + t.Fatalf("known direct resource and outside input were not both retained at Done(selected): known=%s candidates=%v", known, selectedReceivers) + } + simpleDirect, simpleExpression := "", "" + for _, edge := range report.Relationships { + if edge.Kind != "waitgroup_add" || !strings.HasSuffix(edge.From, "::SimplePromotedMethodExpressions") { + continue + } + if strings.Contains(edge.Evidence.Snippet, "group.WaitGroup.Add(2)") { + simpleDirect = edge.To + } + if strings.Contains(edge.Evidence.Snippet, "(*DirectNestedGroup).Add(group, 1)") && len(edge.Values) == 1 && edge.Values[0] == "integer:1" { + simpleExpression = edge.To + } + } + if simpleDirect == "" || simpleExpression != simpleDirect { + t.Fatalf("nested pointer/value control lost direct-expression identity: direct=%q expression=%q", simpleDirect, simpleExpression) + } + } +} + +func TestWaitGroupExternalInputsAndGlobalControlsKeepBoundariesLocal(t *testing.T) { + r, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"PublicWaitGroupInput", "uncalledWaitGroupInput", "PublicGlobalWaitGroupSites", "EscapingWaitGroupCallback", "ClosedWaitGroupHelper"}, Depth: 4, MaxNodes: 350}) + if err != nil { + t.Fatal(err) + } + inputSites := map[string]bool{"group.Add(1)": false, "wait()": false} + inputCandidates := map[string]bool{} + for _, edge := range r.Relationships { + if strings.HasSuffix(edge.From, "::PublicWaitGroupInput") && strings.HasPrefix(edge.Kind, "waitgroup_") { + for source := range inputSites { + if strings.Contains(edge.Evidence.Snippet, source) { + inputSites[source] = true + inputCandidates[edge.To] = strings.Contains(edge.To, "input") + } + } + } + } + if !inputSites["group.Add(1)"] || !inputSites["wait()"] { + t.Fatalf("public direct/bound input operations missing: %+v", inputSites) + } + for _, site := range []string{"group.Add(1)", "wait()"} { + found := false + for _, boundary := range r.Boundaries { + found = found || boundary.Kind == "unresolved_waitgroup" && strings.HasSuffix(boundary.Node, "::PublicWaitGroupInput") && strings.Contains(boundary.Evidence.Snippet, site) + } + if !found { + t.Fatalf("public input operation %q lacks a source-backed outside boundary", site) + } + } + inputResource := false + for _, candidate := range inputCandidates { + inputResource = inputResource || candidate + } + if !inputResource { + t.Fatal("public direct/bound methods did not retain their aggregate input candidate") + } + uncalledBoundary := false + globalAdd, globalDone := "", "" + for _, boundary := range r.Boundaries { + uncalledBoundary = uncalledBoundary || boundary.Kind == "unresolved_waitgroup" && strings.HasSuffix(boundary.Node, "::uncalledWaitGroupInput") + } + for _, edge := range r.Relationships { + if strings.HasSuffix(edge.From, "::PublicGlobalWaitGroupSites") && strings.Contains(edge.Evidence.Snippet, "PublicGlobalWaitGroup.Add(1)") && edge.Kind == "waitgroup_add" { + globalAdd = edge.To + } + if strings.HasSuffix(edge.From, "::PublicGlobalWaitGroupSites") && strings.Contains(edge.Evidence.Snippet, "(*sync.WaitGroup).Done") && edge.Kind == "waitgroup_done" { + globalDone = edge.To + } + } + if !uncalledBoundary || globalAdd == "" || globalDone != globalAdd { + t.Fatalf("uncalled/global controls lost bounded behavior: uncalledBoundary=%t globalAdd=%q globalDone=%q", uncalledBoundary, globalAdd, globalDone) + } + globalOutside := false + for _, boundary := range r.Boundaries { + globalOutside = globalOutside || strings.HasSuffix(boundary.Node, "::PublicGlobalWaitGroupSites") && boundary.Kind == "unresolved_waitgroup" && strings.Contains(boundary.Evidence.Snippet, "PublicGlobalWaitGroup.Add(1)") + } + if !globalOutside { + t.Fatal("exported global operation did not disclose its outside mutable-input boundary") + } + escapedEdge, escapedBoundary := false, false + for _, edge := range r.Relationships { + escapedEdge = escapedEdge || edge.Kind == "waitgroup_done" && strings.Contains(edge.Evidence.Snippet, "(*sync.WaitGroup).Done(group)") && strings.Contains(edge.To, "input") + } + for _, boundary := range r.Boundaries { + escapedBoundary = escapedBoundary || boundary.Kind == "unresolved_waitgroup" && strings.Contains(boundary.Evidence.Snippet, "(*sync.WaitGroup).Done(group)") + } + if !escapedEdge || !escapedBoundary { + t.Fatalf("escaped callback receiver input was not retained with a per-site boundary: edge=%t boundary=%t", escapedEdge, escapedBoundary) + } + for _, boundary := range r.Boundaries { + if boundary.Kind == "unresolved_waitgroup" && strings.HasSuffix(boundary.Node, "::closedGroupWait") { + t.Fatalf("closed helper with a known local caller acquired an outside-input boundary: %+v", boundary) + } + } +} + +func TestWaitGroupMethodExpressionDispatchAndTaskAlternatives(t *testing.T) { + r, err := Trace(context.Background(), Options{Root: "testdata/sample", Seeds: []string{"MixedMethodExpressionRoles", "SameNameNonWaitGroupMethod", "PublicMethodExpressionGo", "PublicMethodExpressionGoNil"}, Depth: 5, MaxNodes: 350}) + if err != nil { + t.Fatal(err) + } + group := "" + for _, edge := range r.Relationships { + if edge.Kind == "waitgroup_add" && strings.HasSuffix(edge.From, "::MixedMethodExpressionRoles") && strings.Contains(edge.Evidence.Snippet, "group.WaitGroup.Add(1)") { + group = edge.To + } + } + roles := map[string]bool{"waitgroup_done": false, "waitgroup_wait": false} + var mixedEdges []Relationship + for _, edge := range r.Relationships { + if strings.Contains(edge.From, "callMixedGroupMethod") || strings.Contains(edge.Evidence.Snippet, "method(group)") { + mixedEdges = append(mixedEdges, edge) + if _, expectedRole := roles[edge.Kind]; edge.To == group && expectedRole { + roles[edge.Kind] = true + } + } + if strings.HasSuffix(edge.From, "::SameNameNonWaitGroupMethod") && strings.HasPrefix(edge.Kind, "waitgroup_") { + t.Fatalf("same-name user method was misclassified as sync.WaitGroup: %+v", edge) + } + } + if group == "" || !roles["waitgroup_done"] || !roles["waitgroup_wait"] { + t.Fatalf("shared helper did not retain both method-expression roles: group=%q roles=%v edges=%+v", group, roles, mixedEdges) + } + goSite, knownTask, nilKnownTask, nilTaskBoundary := false, false, false, false + for _, edge := range r.Relationships { + if edge.Kind == "waitgroup_go" && strings.HasSuffix(edge.From, "::PublicMethodExpressionGo") && strings.Contains(edge.Evidence.Snippet, "WaitGroup).Go") { + goSite = edge.To != "" + } + knownTask = knownTask || edge.Kind == "waitgroup_task" && edge.To == "example.com/sample::knownWaitGroupTask" + nilKnownTask = nilKnownTask || edge.Kind == "waitgroup_task" && edge.To == "example.com/sample::knownWaitGroupTask" && strings.HasSuffix(edge.Evidence.Snippet, "(*sync.WaitGroup).Go(group, task)") + } + unknownTask := false + for _, boundary := range r.Boundaries { + unknownTask = unknownTask || boundary.Kind == "unresolved_waitgroup_task" && strings.HasSuffix(boundary.Node, "::PublicMethodExpressionGo") && strings.Contains(boundary.Evidence.Snippet, "WaitGroup).Go") + nilTaskBoundary = nilTaskBoundary || boundary.Kind == "unresolved_waitgroup_task" && strings.HasSuffix(boundary.Node, "::PublicMethodExpressionGoNil") && strings.Contains(boundary.Evidence.Snippet, "WaitGroup).Go") && strings.Contains(boundary.Reason, "nil callback") + } + if !goSite || !knownTask || !unknownTask || !nilKnownTask || !nilTaskBoundary { + t.Fatalf("method-expression Go receiver/task alternatives incomplete: goSite=%t knownTask=%t unknownTask=%t nilKnownTask=%t nilTaskBoundary=%t", goSite, knownTask, unknownTask, nilKnownTask, nilTaskBoundary) + } +} diff --git a/workspace_reparse_other.go b/workspace_reparse_other.go new file mode 100644 index 0000000..bcdd4ec --- /dev/null +++ b/workspace_reparse_other.go @@ -0,0 +1,5 @@ +//go:build !windows + +package contracttrace + +func hasPathReparsePoint(string) bool { return false } diff --git a/workspace_reparse_windows.go b/workspace_reparse_windows.go new file mode 100644 index 0000000..b70fbd8 --- /dev/null +++ b/workspace_reparse_windows.go @@ -0,0 +1,43 @@ +//go:build windows + +package contracttrace + +import ( + "os" + "path/filepath" + "strings" + "syscall" +) + +func hasPathReparsePoint(path string) bool { + absolute, err := filepath.Abs(path) + if err != nil { + return true + } + volume := filepath.VolumeName(absolute) + if volume == "" { + return true + } + current := volume + string(filepath.Separator) + rootInfo, err := os.Lstat(current) + if err != nil || isReparsePoint(rootInfo) { + return true + } + remainder := strings.TrimLeft(absolute[len(volume):], string(filepath.Separator)) + for _, component := range strings.Split(remainder, string(filepath.Separator)) { + if component == "" { + continue + } + current = filepath.Join(current, component) + info, err := os.Lstat(current) + if err != nil || isReparsePoint(info) { + return true + } + } + return false +} + +func isReparsePoint(info os.FileInfo) bool { + data, ok := info.Sys().(*syscall.Win32FileAttributeData) + return !ok || data.FileAttributes&syscall.FILE_ATTRIBUTE_REPARSE_POINT != 0 +} diff --git a/zero_aggregate_callbacks_test.go b/zero_aggregate_callbacks_test.go new file mode 100644 index 0000000..58db5ab --- /dev/null +++ b/zero_aggregate_callbacks_test.go @@ -0,0 +1,143 @@ +package contracttrace + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestZeroRecordCallbacksSurviveContainerSelections(t *testing.T) { + root := t.TempDir() + source := `package recordnil +import "sync" +type holder struct { task func() } +type nestedHolder struct { inner holder } +func knownTask() {} +func ArrayRecordPartial(index int) { var values [2]holder; values[0].task = knownTask; var group sync.WaitGroup; group.Go(values[index].task) } +func ArrayRecordConditional(index int, flag bool) { var values [2]holder; if flag { values[0].task = knownTask }; var group sync.WaitGroup; group.Go(values[index].task) } +func SliceRecordPartial(index int) { values := make([]holder, 2); values[0].task = knownTask; var group sync.WaitGroup; group.Go(values[index].task) } +func SliceRecordDynamicPartial(length, index int) { if length <= 0 { return }; values := make([]holder, length); values[0].task = knownTask; var group sync.WaitGroup; group.Go(values[index].task) } +func SliceRecordDynamicConditional(length, index int, flag bool) { if length <= 0 { return }; values := make([]holder, length); if flag { values[0].task = knownTask }; var group sync.WaitGroup; group.Go(values[index].task) } +func SliceRecordDynamicEmpty(index int) { values := make([]holder, 0); var group sync.WaitGroup; group.Go(values[index].task) } +func MapRecordPartial(key string) { values := map[string]holder{"known": {task: knownTask}}; var group sync.WaitGroup; group.Go(values[key].task) } +func MapRecordMutated(key string, remove bool) { values := map[string]holder{"known": {task: knownTask}}; if remove { delete(values, "known") }; values[key] = holder{task: knownTask}; var group sync.WaitGroup; group.Go(values[key].task) } +func MapRecordOutside(values map[string]holder, key string) { var group sync.WaitGroup; group.Go(values[key].task) } +func NestedArrayRecordPartial(index int) { var values [2]nestedHolder; values[0].inner.task = knownTask; var group sync.WaitGroup; group.Go(values[index].inner.task) } +func ArrayRecordFull(index int) { values := [2]holder{{task: knownTask}, {task: knownTask}}; var group sync.WaitGroup; group.Go(values[index].task) } +func SliceRecordFull(index int) { values := []holder{{task: knownTask}, {task: knownTask}}; var group sync.WaitGroup; group.Go(values[index].task) } +func MapRecordKnownKey() { values := map[string]holder{"known": {task: knownTask}}; var group sync.WaitGroup; group.Go(values["known"].task) } +func ArrayRecordExplicitNil(index int) { values := [2]holder{{task: knownTask}, {task: nil}}; var group sync.WaitGroup; group.Go(values[index].task) } +func NilParentPointer() { var parent *holder; var group sync.WaitGroup; group.Go(parent.task) } +` + if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example.com/recordnil\n\ngo 1.27.0\n"), 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "records.go"), []byte(source), 0600); err != nil { + t.Fatal(err) + } + sites := map[string]string{ + "ArrayRecordPartial": "group.Go(values[index].task)", "ArrayRecordConditional": "group.Go(values[index].task)", + "SliceRecordPartial": "group.Go(values[index].task)", "SliceRecordDynamicPartial": "group.Go(values[index].task)", + "SliceRecordDynamicConditional": "group.Go(values[index].task)", "SliceRecordDynamicEmpty": "group.Go(values[index].task)", + "MapRecordPartial": "group.Go(values[key].task)", "MapRecordMutated": "group.Go(values[key].task)", + "MapRecordOutside": "group.Go(values[key].task)", "NestedArrayRecordPartial": "group.Go(values[index].inner.task)", + "ArrayRecordFull": "group.Go(values[index].task)", "SliceRecordFull": "group.Go(values[index].task)", + "MapRecordKnownKey": `group.Go(values["known"].task)`, "ArrayRecordExplicitNil": "group.Go(values[index].task)", + "NilParentPointer": "group.Go(parent.task)", + } + seeds := make([]string, 0, len(sites)) + for name := range sites { + seeds = append(seeds, name) + } + report, analysis, err := TraceWithAnalysis(context.Background(), Options{Root: root, Seeds: seeds, Depth: 5, MaxNodes: 250}) + if err != nil { + t.Fatal(err) + } + operations := map[string]string{} + knownTasks := map[string]bool{} + for _, edge := range analysis.Relationships { + for owner, snippet := range sites { + if edge.From == "example.com/recordnil::"+owner && strings.Contains(edge.Evidence.Snippet, snippet) && edge.Kind == "waitgroup_go" { + operations[owner] = edge.To + } + } + } + for _, edge := range analysis.Relationships { + if edge.Kind == "waitgroup_task" && edge.To == "example.com/recordnil::knownTask" { + for owner, operation := range operations { + if operation == edge.From { + knownTasks[owner] = true + } + } + } + } + boundaries := map[string][]Boundary{} + for _, boundary := range report.Boundaries { + if boundary.Kind != "unresolved_waitgroup_task" { + continue + } + owner := strings.TrimPrefix(boundary.Node, "example.com/recordnil::") + boundaries[owner] = append(boundaries[owner], boundary) + } + for owner, snippet := range sites { + if operations[owner] == "" { + t.Errorf("%s lost its source-backed operation edge at %s", owner, snippet) + } + for _, edge := range analysis.Relationships { + if (edge.Kind == "waitgroup_go" && edge.From == "example.com/recordnil::"+owner || edge.Kind == "waitgroup_task" && edge.From == operations[owner]) && + (edge.Evidence.File != "records.go" || edge.Evidence.Line == 0 || edge.Evidence.Snippet == "") { + t.Errorf("%s edge lacks source evidence: %+v", owner, edge) + } + } + } + for _, owner := range []string{"ArrayRecordPartial", "ArrayRecordConditional", "SliceRecordPartial", "SliceRecordDynamicPartial", "SliceRecordDynamicConditional", "MapRecordPartial", "MapRecordMutated", "NestedArrayRecordPartial"} { + if !knownTasks[owner] || !hasNilTaskBoundary(boundaries[owner]) { + t.Errorf("%s must retain its known task and expose the zero-record nil candidate: known=%t boundaries=%+v", owner, knownTasks[owner], boundaries[owner]) + } + if owner == "MapRecordMutated" && !hasNilAndUnknownTaskBoundary(boundaries[owner]) { + t.Errorf("%s must retain nil and unresolved callback alternatives: %+v", owner, boundaries[owner]) + } + } + for _, owner := range []string{"ArrayRecordFull", "SliceRecordFull", "MapRecordKnownKey"} { + if !knownTasks[owner] || len(boundaries[owner]) != 0 { + t.Errorf("%s full-initialization control changed: known=%t boundaries=%+v", owner, knownTasks[owner], boundaries[owner]) + } + } + if !knownTasks["ArrayRecordExplicitNil"] || !hasNilTaskBoundary(boundaries["ArrayRecordExplicitNil"]) { + t.Errorf("explicit nil field control lost its known task or nil boundary: known=%t boundaries=%+v", knownTasks["ArrayRecordExplicitNil"], boundaries["ArrayRecordExplicitNil"]) + } + if knownTasks["SliceRecordDynamicEmpty"] || hasNilTaskBoundary(boundaries["SliceRecordDynamicEmpty"]) { + t.Errorf("zero-length dynamic slice must not manufacture an element callback: known=%t boundaries=%+v", knownTasks["SliceRecordDynamicEmpty"], boundaries["SliceRecordDynamicEmpty"]) + } + if knownTasks["MapRecordOutside"] || len(boundaries["MapRecordOutside"]) == 0 || hasNilTaskBoundary(boundaries["MapRecordOutside"]) { + t.Errorf("outside map read must remain generic unresolved without typed nil: known=%t boundaries=%+v", knownTasks["MapRecordOutside"], boundaries["MapRecordOutside"]) + } + if knownTasks["NilParentPointer"] || len(boundaries["NilParentPointer"]) == 0 || hasNilTaskBoundary(boundaries["NilParentPointer"]) { + t.Errorf("nil-parent pointer must stay generic/unresolved without a fabricated nil callback: known=%t boundaries=%+v", knownTasks["NilParentPointer"], boundaries["NilParentPointer"]) + } + if report.Coverage.Truncated || report.ContractComplete { + t.Fatalf("expected untruncated but incomplete scope: coverage=%+v complete=%t", report.Coverage, report.ContractComplete) + } +} + +func hasNilTaskBoundary(boundaries []Boundary) bool { + for _, boundary := range boundaries { + reason := strings.ToLower(boundary.Reason) + if strings.Contains(reason, "nil callback alternative") || strings.Contains(reason, "typed nil") || strings.Contains(reason, "possible nil and unresolved callback-value alternative") { + return true + } + } + return false +} + +func hasNilAndUnknownTaskBoundary(boundaries []Boundary) bool { + for _, boundary := range boundaries { + reason := strings.ToLower(boundary.Reason) + if strings.Contains(reason, "possible nil and unresolved callback-value alternative") { + return true + } + } + return false +} diff --git a/zero_aggregate_flow.go b/zero_aggregate_flow.go new file mode 100644 index 0000000..1569052 --- /dev/null +++ b/zero_aggregate_flow.go @@ -0,0 +1,269 @@ +package contracttrace + +import ( + "go/types" +) + +func carriesTypedAddressAlternative(typ types.Type) bool { + if typ == nil { + return false + } + switch types.Unalias(typ).Underlying().(type) { + case *types.Pointer, *types.Map, *types.Interface, *types.Chan, *types.Slice: + return true + default: + return false + } +} + +func (a *flowAnalysis) addTypedAddressAlternative(value *flowValue, address string, typ types.Type, zero, unknown bool) bool { + if value == nil || address == "" { + return false + } + if typ == nil { + zero = false + unknown = true + } + if !zero && !unknown { + return false + } + current, exists := value.addressAlternatives[address] + if !exists { + if len(value.addressAlternatives) >= maxFlowValues { + changed := !value.addressAltUnknown + value.addressAltUnknown = true + if a != nil { + a.coverage.Widened = true + } + return changed + } + if value.addressAlternatives == nil { + value.addressAlternatives = map[string]typedAddressAlternative{} + } + value.addressAlternatives[address] = typedAddressAlternative{typ: typ, zero: zero, unknown: unknown} + return true + } + if current.typ == nil { + if current.zero || !current.unknown { + current.zero = false + current.unknown = true + value.addressAlternatives[address] = current + return true + } + return false + } + changed := false + if typ == nil { + current.typ = nil + current.zero = false + current.unknown = true + changed = true + } else if current.typ == nil { + current.typ = typ + changed = true + } else if !types.Identical(current.typ, typ) { + current.typ = nil + current.zero = false + current.unknown = true + changed = true + } + if current.typ == nil { + if changed { + value.addressAlternatives[address] = current + } + return changed + } + if zero && !current.zero { + current.zero = true + changed = true + } + if unknown && !current.unknown { + current.unknown = true + changed = true + } + if changed { + value.addressAlternatives[address] = current + } + return changed +} + +func (a *flowAnalysis) projectTypedAddressAlternative(result *flowValue, source flowValue, address string, typ types.Type) { + if result == nil || typ == nil { + return + } + if alternative, ok := source.addressAlternatives[address]; ok { + if alternative.typ != nil && types.Identical(alternative.typ, typ) { + addTypedAlternative(a, result, typ, alternative.zero, alternative.unknown) + } else if alternative.typ == nil { + addTypedAlternative(a, result, typ, false, true) + } + } + if source.addressAltUnknown { + addTypedAlternative(a, result, typ, false, true) + } +} + +func retypeAddressAlternatives(value *flowValue, from, to types.Type) { + if value == nil || from == nil || to == nil || !types.ConvertibleTo(from, to) { + return + } + fromPointer, fromOK := types.Unalias(from).Underlying().(*types.Pointer) + toPointer, toOK := types.Unalias(to).Underlying().(*types.Pointer) + if !fromOK || !toOK { + return + } + for address, alternative := range value.addressAlternatives { + if alternative.typ != nil && types.Identical(types.Unalias(alternative.typ), types.Unalias(fromPointer.Elem())) { + alternative.typ = toPointer.Elem() + } else if alternative.typ != nil { + alternative.typ = toPointer.Elem() + alternative.zero = false + alternative.unknown = true + } + value.addressAlternatives[address] = alternative + } +} + +// addZeroAggregate records a source-proven zero-value alternative for an +// inline aggregate. Later field or array reads project its modeled zero child. +func (a *flowAnalysis) addZeroAggregate(value *flowValue, typ types.Type) bool { + if value == nil || typ == nil || !inlineAggregate(typ) { + return false + } + typ = types.Unalias(typ) + if value.zeroAggregates[typ] { + return false + } + if len(value.zeroAggregates) >= maxFlowValues { + if value.zeroAggregateUnknown { + return false + } + value.zeroAggregateUnknown = true + if a != nil { + a.coverage.Widened = true + } + return true + } + if value.zeroAggregates == nil { + value.zeroAggregates = map[types.Type]bool{} + } + value.zeroAggregates[typ] = true + return true +} + +func (a *flowAnalysis) markZeroAggregateUnknown(value *flowValue) bool { + if value == nil || value.zeroAggregateUnknown { + return false + } + value.zeroAggregateUnknown = true + return true +} + +func exactZeroAggregateMatches(value flowValue, typ types.Type) bool { + if typ == nil || !inlineAggregate(typ) { + return false + } + typ = types.Unalias(typ) + for candidate := range value.zeroAggregates { + if types.Identical(types.Unalias(candidate), typ) { + return true + } + } + return false +} + +// propagateIndirectAlternative carries a typed child candidate through a +// pointer load. Generic pointer uncertainty is not projected into the pointee: +// a nil parent may panic before any child value is observed. +func propagateIndirectAlternative(result *flowValue, source flowValue, typ types.Type) { + if result == nil || typ == nil { + return + } + if pointer, ok := types.Unalias(typ).Underlying().(*types.Pointer); ok && isSQLHandleType(pointer.Elem()) { + result.sqlUnknown = result.sqlUnknown || source.sqlUnknown + return + } + if isFunctionType(typ) { + result.functionNil = result.functionNil || source.functionNil + result.functionUnknown = result.functionUnknown || source.functionUnknown + return + } + if isMapType(typ) { + result.mapNil = result.mapNil || source.mapNil + } +} + +// zeroAggregateField projects a typed zero aggregate through one inline +// struct field. Pointer fields retain their own zero alternative, but the +// marker never implies that the pointer's pointee is zero. +func (a *flowAnalysis) zeroAggregateField(value flowValue, parentType types.Type, field int) flowValue { + result := emptyFlow() + knownZero := exactZeroAggregateMatches(value, parentType) + if !knownZero && !value.zeroAggregateUnknown { + return result + } + structure, ok := types.Unalias(parentType).Underlying().(*types.Struct) + if !ok || field < 0 || field >= structure.NumFields() { + return result + } + child := structure.Field(field).Type() + switch { + case isSQLHandleType(child): + result.sqlUnknown = true + case isMapType(child): + result.mapNil = knownZero + result.interfaceUnknown = value.zeroAggregateUnknown + case isFunctionType(child): + if knownZero { + result.functionNil = true + } + if value.zeroAggregateUnknown { + result.functionUnknown = true + } + case inlineAggregate(child): + if knownZero { + a.addZeroAggregate(&result, child) + } + if value.zeroAggregateUnknown { + a.markZeroAggregateUnknown(&result) + } + default: + addTypedAlternative(a, &result, child, knownZero, value.zeroAggregateUnknown) + } + return result +} + +// zeroAggregateIndex projects a typed zero array through an element. Empty +// arrays and pointer elements do not produce a function-nil alternative. +func (a *flowAnalysis) zeroAggregateIndex(value flowValue, parentType types.Type) flowValue { + result := emptyFlow() + knownZero := exactZeroAggregateMatches(value, parentType) + if (!knownZero && !value.zeroAggregateUnknown) || parentType == nil { + return result + } + array, ok := types.Unalias(parentType).Underlying().(*types.Array) + if !ok || array.Len() == 0 { + return result + } + addTypedAlternative(a, &result, array.Elem(), knownZero, value.zeroAggregateUnknown) + return result +} + +// retypeZeroAggregate carries default-value evidence across SSA conversions +// only when the converted inline aggregate remains a compatible Go value. +func (a *flowAnalysis) retypeZeroAggregate(value flowValue, from, to types.Type) flowValue { + result := emptyFlow() + if from == nil || to == nil || !inlineAggregate(from) || !inlineAggregate(to) || !types.ConvertibleTo(from, to) { + return result + } + from = types.Unalias(from) + for candidate := range value.zeroAggregates { + if types.Identical(types.Unalias(candidate), from) { + a.addZeroAggregate(&result, to) + } + } + if value.zeroAggregateUnknown { + a.markZeroAggregateUnknown(&result) + } + return result +} diff --git a/zero_aggregate_flow_test.go b/zero_aggregate_flow_test.go new file mode 100644 index 0000000..4f92b07 --- /dev/null +++ b/zero_aggregate_flow_test.go @@ -0,0 +1,138 @@ +package contracttrace + +import ( + "fmt" + "go/ast" + "go/parser" + "go/token" + "go/types" + "testing" +) + +func zeroAggregateTestTypes(t *testing.T, extra int) *types.Package { + t.Helper() + source := `package zeroagg +type inner struct { task func() } +type outer struct { child inner; pointer *inner; callbacks [2]func(); items [2]inner } +type converted outer +type incompatible struct { text string } +type empty [0]inner +` + for index := 0; index < extra; index++ { + source += fmt.Sprintf("type aggregate%d struct { child inner }\n", index) + } + if extra > 0 { + source += "type convertedAggregate0 aggregate0\n" + } + fileset := token.NewFileSet() + file, err := parser.ParseFile(fileset, "zero.go", source, parser.AllErrors) + if err != nil { + t.Fatal(err) + } + pkg, err := new(types.Config).Check("example.com/zeroagg", fileset, []*ast.File{file}, nil) + if err != nil { + t.Fatal(err) + } + return pkg +} + +func TestZeroAggregateProjectionIsTypedAndStopsAtPointers(t *testing.T) { + pkg := zeroAggregateTestTypes(t, 0) + analysis := &flowAnalysis{} + inner := pkg.Scope().Lookup("inner").Type() + outer := pkg.Scope().Lookup("outer").Type() + value := emptyFlow() + if !analysis.addZeroAggregate(&value, outer) { + t.Fatal("failed to add typed zero outer aggregate") + } + child := analysis.zeroAggregateField(value, outer, 0) + if !child.zeroAggregates[types.Unalias(inner)] || child.functionNil || child.functionUnknown { + t.Fatalf("inline child did not retain its typed zero candidate: %+v", child) + } + task := analysis.zeroAggregateField(child, inner, 0) + if !task.functionNil || task.functionUnknown { + t.Fatalf("function field of a typed zero record was not projected to nil: %+v", task) + } + pointer := analysis.zeroAggregateField(value, outer, 1) + if pointer.functionNil || pointer.functionUnknown || len(pointer.zeroAggregates) != 0 || pointer.zeroAggregateUnknown { + t.Fatalf("zero pointer child was treated as a zero function-bearing record: %+v", pointer) + } + array := analysis.zeroAggregateField(value, outer, 2) + functionElement := analysis.zeroAggregateIndex(array, fieldType(outer, 2)) + if !functionElement.functionNil || functionElement.functionUnknown { + t.Fatalf("zero array element function was not projected to nil: %+v", functionElement) + } + items := analysis.zeroAggregateField(value, outer, 3) + item := analysis.zeroAggregateIndex(items, fieldType(outer, 3)) + if !item.zeroAggregates[types.Unalias(inner)] { + t.Fatalf("zero array element did not retain its inline record type: %+v", item) + } + zeroEmpty := emptyFlow() + analysis.addZeroAggregate(&zeroEmpty, pkg.Scope().Lookup("empty").Type()) + if projected := analysis.zeroAggregateIndex(zeroEmpty, pkg.Scope().Lookup("empty").Type()); projected.functionNil || len(projected.zeroAggregates) != 0 { + t.Fatalf("zero-length array manufactured an element alternative: %+v", projected) + } +} + +func TestZeroAggregateProjectionRetypesConversionsAndWidensConservatively(t *testing.T) { + pkg := zeroAggregateTestTypes(t, maxFlowValues+1) + analysis := &flowAnalysis{} + outer := pkg.Scope().Lookup("outer").Type() + convertedType := pkg.Scope().Lookup("converted").Type() + value := emptyFlow() + analysis.addZeroAggregate(&value, outer) + converted := analysis.retypeZeroAggregate(value, outer, convertedType) + if !converted.zeroAggregates[types.Unalias(convertedType)] { + t.Fatalf("compatible named aggregate conversion dropped the zero marker: %+v", converted) + } + convertedTask := analysis.zeroAggregateField(converted, convertedType, 0) + if !convertedTask.zeroAggregates[types.Unalias(fieldType(convertedType, 0))] { + t.Fatalf("converted aggregate lost its nested typed zero marker: %+v", convertedTask) + } + convertedTask = analysis.zeroAggregateField(convertedTask, fieldType(convertedType, 0), 0) + if !convertedTask.functionNil { + t.Fatalf("converted aggregate lost its function-nil alternative: %+v", convertedTask) + } + incompatible := analysis.retypeZeroAggregate(value, outer, pkg.Scope().Lookup("incompatible").Type()) + if len(incompatible.zeroAggregates) != 0 || incompatible.zeroAggregateUnknown { + t.Fatalf("incompatible conversion retained a zero marker: %+v", incompatible) + } + wide := emptyFlow() + for index := 0; index < maxFlowValues+1; index++ { + if !analysis.addZeroAggregate(&wide, pkg.Scope().Lookup(fmt.Sprintf("aggregate%d", index)).Type()) && index == maxFlowValues { + t.Fatal("zero aggregate cap failed to record widening") + } + } + if len(wide.zeroAggregates) > maxFlowValues || !wide.zeroAggregateUnknown || !analysis.coverage.Widened { + t.Fatalf("zero aggregate cap did not widen conservatively: count=%d unknown=%t coverage=%+v", len(wide.zeroAggregates), wide.zeroAggregateUnknown, analysis.coverage) + } + knownChild := analysis.zeroAggregateField(wide, pkg.Scope().Lookup("aggregate0").Type(), 0) + knownTask := analysis.zeroAggregateField(knownChild, fieldType(pkg.Scope().Lookup("aggregate0").Type(), 0), 0) + if !knownTask.functionUnknown || !knownTask.functionNil { + t.Fatalf("widening erased an established zero alternative or its uncertainty: %+v", knownTask) + } + droppedChild := analysis.zeroAggregateField(wide, pkg.Scope().Lookup(fmt.Sprintf("aggregate%d", maxFlowValues)).Type(), 0) + droppedTask := analysis.zeroAggregateField(droppedChild, fieldType(pkg.Scope().Lookup(fmt.Sprintf("aggregate%d", maxFlowValues)).Type(), 0), 0) + if !droppedTask.functionUnknown || droppedTask.functionNil { + t.Fatalf("widened-only alternative was misclassified as a known nil: %+v", droppedTask) + } + convertedWide := analysis.retypeZeroAggregate(wide, pkg.Scope().Lookup("aggregate0").Type(), pkg.Scope().Lookup("convertedAggregate0").Type()) + if !convertedWide.zeroAggregates[types.Unalias(pkg.Scope().Lookup("convertedAggregate0").Type())] || !convertedWide.zeroAggregateUnknown { + t.Fatalf("conversion erased a known candidate or its widened alternative: %+v", convertedWide) + } + convertedWideTask := analysis.zeroAggregateField(convertedWide, pkg.Scope().Lookup("convertedAggregate0").Type(), 0) + convertedWideTask = analysis.zeroAggregateField(convertedWideTask, fieldType(pkg.Scope().Lookup("convertedAggregate0").Type(), 0), 0) + if !convertedWideTask.functionNil || !convertedWideTask.functionUnknown { + t.Fatalf("converted known+unknown aggregate lost either alternative: %+v", convertedWideTask) + } + merged := emptyFlow() + analysis.merge(&merged, wide) + snapshot := snapshotFlow(merged) + if !snapshot.zeroAggregateUnknown || !snapshot.zeroAggregates[types.Unalias(pkg.Scope().Lookup("aggregate0").Type())] { + t.Fatalf("merge/snapshot lost known or widened aggregate metadata: merged=%+v snapshot=%+v", merged, snapshot) + } +} + +func fieldType(typ types.Type, index int) types.Type { + return types.Unalias(typ).Underlying().(*types.Struct).Field(index).Type() +} diff --git a/zero_function_elements.go b/zero_function_elements.go new file mode 100644 index 0000000..2b0a08b --- /dev/null +++ b/zero_function_elements.go @@ -0,0 +1,470 @@ +package contracttrace + +import ( + "go/ast" + "go/constant" + "go/types" + + "golang.org/x/tools/go/packages" + "golang.org/x/tools/go/ssa" +) + +type functionElementInitialization uint8 + +const ( + functionElementUnsupported functionElementInitialization = iota + functionElementFullyInitialized + functionElementMayBeZero +) + +// possiblePositiveLength reports whether a make-slice length can be positive. +func possiblePositiveLength(length ssa.Value) bool { + if c, ok := length.(*ssa.Const); ok && c.Value != nil { + return constant.Sign(c.Value) > 0 + } + return true +} + +// sequenceMayHaveElement distinguishes a zero-length slice from a view that +// can address at least one element. Unknown lengths remain possible. +func sequenceMayHaveElement(value ssa.Value) bool { + return sequenceMayHaveElementSeen(value, map[ssa.Value]bool{}, 0) +} + +func sequenceMayHaveElementSeen(value ssa.Value, seen map[ssa.Value]bool, depth int) bool { + if value == nil || depth >= maxFlowValues { + return true + } + if seen[value] { + return true + } + switch sequence := value.(type) { + case *ssa.MakeSlice: + return possiblePositiveLength(sequence.Len) + case *ssa.Slice: + low, lowOK := sequence.Low.(*ssa.Const) + high, highOK := sequence.High.(*ssa.Const) + lowValue, lowConst := int64(0), sequence.Low == nil + if lowOK && low.Value != nil { + lowValue, lowConst = constant.Int64Val(low.Value) + } + highValue, highConst := int64(0), false + if highOK && high.Value != nil { + highValue, highConst = constant.Int64Val(high.Value) + } + if lowConst && highConst { + return highValue > lowValue + } + return true + case *ssa.Phi: + seen[value] = true + defer delete(seen, value) + for _, edge := range sequence.Edges { + if sequenceMayHaveElementSeen(edge, seen, depth+1) { + return true + } + } + return false + case *ssa.ChangeType: + return sequenceMayHaveElementSeen(sequence.X, seen, depth+1) + case *ssa.Convert: + return sequenceMayHaveElementSeen(sequence.X, seen, depth+1) + default: + return true + } +} + +// zeroFunctionArrayAtAddress recognizes only a source-bound array allocation +// and a field path to its function-array value. Unsupported origins remain +// separate from proven zero initialization. +func (a *flowAnalysis) zeroFunctionArrayAtAddress(address string, ix *index) functionElementInitialization { + if state := a.zeroFunctionArrayCache[address]; state != 0 { + return functionElementInitialization(state) + } + root, found := longestAllocationRoot(address, a.allocations, false) + if !found { + return functionElementUnsupported + } + allocation := a.allocations[root] + if allocation == nil || allocation.Parent() == nil { + return functionElementUnsupported + } + pointer, ok := allocation.Type().Underlying().(*types.Pointer) + if !ok { + return functionElementUnsupported + } + indexes, ok := allocationFieldIndexes(root, address) + if !ok { + return functionElementUnsupported + } + arrayType, ok := functionArrayAtPath(pointer.Elem(), indexes) + if !ok || arrayType.Len() == 0 { + return functionElementUnsupported + } + owner := ix.funcs[ix.owner(allocation.Parent())] + if owner == nil || owner.pkg == nil || owner.pkg.TypesInfo == nil { + return functionElementUnsupported + } + initializer, found := allocationInitializer(allocation.Parent(), allocation, pointer.Elem(), owner.pkg, ix) + if !found { + if state, recognized := sliceBackingAllocationState(allocation.Parent(), allocation, owner.pkg); recognized { + a.cacheZeroFunctionArray(address, state) + return state + } + return functionElementUnsupported + } + if initializer.zero { + a.cacheZeroFunctionArray(address, functionElementMayBeZero) + return functionElementMayBeZero + } + state, found := arrayLiteralPathState(initializer.literal, pointer.Elem(), indexes, owner.pkg) + if !found { + return functionElementUnsupported + } + a.cacheZeroFunctionArray(address, state) + return state +} + +func sliceBackingAllocationState(function *ssa.Function, allocation *ssa.Alloc, pkg *packages.Package) (functionElementInitialization, bool) { + if function == nil || function.Syntax() == nil || allocation == nil || pkg == nil || pkg.TypesInfo == nil { + return functionElementUnsupported, false + } + var candidates []functionElementInitialization + ast.Inspect(function.Syntax(), func(node ast.Node) bool { + switch value := node.(type) { + case *ast.CallExpr: + if value.Pos() > allocation.Pos() || value.End() < allocation.Pos() || len(value.Args) < 2 { + return true + } + name, ok := value.Fun.(*ast.Ident) + if !ok || name.Name != "make" { + return true + } + typ := pkg.TypesInfo.TypeOf(value) + if typ == nil { + return true + } + slice, ok := types.Unalias(typ).Underlying().(*types.Slice) + if !ok || !inlineAggregate(slice.Elem()) && !isFunctionType(slice.Elem()) { + return true + } + positive := expressionMayBePositive(value.Args[1], pkg) + if len(value.Args) > 2 { + positive = positive || expressionMayBePositive(value.Args[2], pkg) + } + if positive { + candidates = append(candidates, functionElementMayBeZero) + } else { + candidates = append(candidates, functionElementFullyInitialized) + } + case *ast.CompositeLit: + if value.Pos() > allocation.Pos() || value.End() < allocation.Pos() { + return true + } + typ := pkg.TypesInfo.TypeOf(value) + slice, ok := types.Unalias(typ).Underlying().(*types.Slice) + if ok && (isFunctionType(slice.Elem()) || inlineAggregate(slice.Elem())) { + if sliceLiteralHasOmittedElements(value, pkg) { + candidates = append(candidates, functionElementMayBeZero) + } else { + candidates = append(candidates, functionElementFullyInitialized) + } + } + } + return true + }) + if len(candidates) != 1 { + return functionElementUnsupported, false + } + return candidates[0], true +} + +func expressionMayBePositive(expression ast.Expr, pkg *packages.Package) bool { + if pkg == nil || pkg.TypesInfo == nil { + return true + } + value := pkg.TypesInfo.Types[expression].Value + if value == nil || value.Kind() != constant.Int { + return true + } + return constant.Sign(value) > 0 +} + +func (a *flowAnalysis) cacheZeroFunctionArray(address string, state functionElementInitialization) { + if a.zeroFunctionArrayCache == nil { + a.zeroFunctionArrayCache = map[string]uint8{} + } + a.zeroFunctionArrayCache[address] = uint8(state) +} + +func (a *flowAnalysis) hasFunctionArrayAllocation(address string) bool { + _, found := longestAllocationRoot(address, a.allocations, true) + return found +} + +func functionArrayAtPath(typ types.Type, indexes []int) (*types.Array, bool) { + current := types.Unalias(typ) + for _, fieldIndex := range indexes { + structType, ok := current.Underlying().(*types.Struct) + if !ok || fieldIndex < 0 || fieldIndex >= structType.NumFields() { + return nil, false + } + current = types.Unalias(structType.Field(fieldIndex).Type()) + if _, pointer := current.Underlying().(*types.Pointer); pointer { + return nil, false + } + } + arrayType, ok := current.Underlying().(*types.Array) + if !ok || !isFunctionType(arrayType.Elem()) { + return nil, false + } + return arrayType, true +} + +func arrayLiteralPathState(literal *ast.CompositeLit, typ types.Type, indexes []int, pkg *packages.Package) (functionElementInitialization, bool) { + if len(indexes) == 0 { + arrayType, ok := types.Unalias(typ).Underlying().(*types.Array) + if !ok { + return functionElementUnsupported, false + } + if arrayLiteralHasOmittedElements(literal, arrayType, pkg) { + return functionElementMayBeZero, true + } + return functionElementFullyInitialized, true + } + current := types.Unalias(typ) + for depth, fieldIndex := range indexes { + structType, ok := current.Underlying().(*types.Struct) + if !ok || fieldIndex < 0 || fieldIndex >= structType.NumFields() { + return functionElementUnsupported, false + } + fieldType := types.Unalias(structType.Field(fieldIndex).Type()) + if _, pointer := fieldType.Underlying().(*types.Pointer); pointer { + return functionElementUnsupported, false + } + fieldArray, terminal := fieldType.Underlying().(*types.Array) + if depth == len(indexes)-1 { + if !terminal || !isFunctionType(fieldArray.Elem()) { + return functionElementUnsupported, false + } + value, present := compositeFieldValue(literal, fieldIndex, current, pkg) + if !present { + return functionElementMayBeZero, true + } + nested, ok := unparenExpr(value).(*ast.CompositeLit) + if !ok { + return functionElementUnsupported, false + } + return arrayLiteralPathState(nested, fieldType, nil, pkg) + } + if terminal { + return functionElementUnsupported, false + } + value, present := compositeFieldValue(literal, fieldIndex, current, pkg) + if !present { + if _, ok := functionArrayAtPath(fieldType, indexes[depth+1:]); ok { + return functionElementMayBeZero, true + } + return functionElementUnsupported, false + } + nested, ok := unparenExpr(value).(*ast.CompositeLit) + if !ok { + return functionElementUnsupported, false + } + literal, current = nested, fieldType + } + return functionElementUnsupported, false +} + +func arrayLiteralHasOmittedElements(literal *ast.CompositeLit, array *types.Array, pkg *packages.Package) bool { + if literal == nil || array == nil || pkg == nil || pkg.TypesInfo == nil { + return false + } + covered := map[int64]bool{} + nextIndex := int64(0) + for _, element := range literal.Elts { + if keyed, ok := element.(*ast.KeyValueExpr); ok { + value := pkg.TypesInfo.Types[keyed.Key].Value + if value == nil || value.Kind() != constant.Int { + return true + } + constantIndex, ok := constant.Int64Val(value) + if !ok || constantIndex < 0 || constantIndex >= array.Len() { + return true + } + covered[constantIndex] = true + nextIndex = constantIndex + 1 + continue + } + if nextIndex < array.Len() { + covered[nextIndex] = true + } + nextIndex++ + } + return int64(len(covered)) < array.Len() +} + +// makeSliceFunctionElements source-binds MakeSlice either to a slice literal, +// whose keyed holes can be proven, or a make call with its SSA length. All +// other construction paths stay unresolved. +func makeSliceFunctionElements(function *ssa.Function, makeSlice *ssa.MakeSlice, ix *index) functionElementInitialization { + if function == nil || function.Syntax() == nil || makeSlice == nil { + return functionElementUnsupported + } + owner := ix.funcs[ix.owner(function)] + if owner == nil || owner.pkg == nil || owner.pkg.TypesInfo == nil { + return functionElementUnsupported + } + var candidates []functionElementInitialization + ast.Inspect(function.Syntax(), func(node ast.Node) bool { + switch value := node.(type) { + case *ast.CompositeLit: + if value.Pos() > makeSlice.Pos() || value.End() < makeSlice.Pos() { + return true + } + typ := owner.pkg.TypesInfo.TypeOf(value) + if typ == nil { + return true + } + slice, ok := types.Unalias(typ).Underlying().(*types.Slice) + if !ok || !isFunctionType(slice.Elem()) && !inlineAggregate(slice.Elem()) || !types.Identical(types.Unalias(typ), types.Unalias(makeSlice.Type())) { + return true + } + if sliceLiteralHasOmittedElements(value, owner.pkg) { + candidates = append(candidates, functionElementMayBeZero) + } else { + candidates = append(candidates, functionElementFullyInitialized) + } + case *ast.CallExpr: + if value.Pos() > makeSlice.Pos() || value.End() < makeSlice.Pos() || len(value.Args) < 1 { + return true + } + name, ok := value.Fun.(*ast.Ident) + if !ok || name.Name != "make" { + return true + } + typ := owner.pkg.TypesInfo.TypeOf(value) + if typ == nil { + return true + } + slice, ok := types.Unalias(typ).Underlying().(*types.Slice) + if ok && (isFunctionType(slice.Elem()) || inlineAggregate(slice.Elem())) && types.Identical(types.Unalias(typ), types.Unalias(makeSlice.Type())) { + if possiblePositiveLength(makeSlice.Len) || possiblePositiveLength(makeSlice.Cap) { + candidates = append(candidates, functionElementMayBeZero) + } else { + candidates = append(candidates, functionElementFullyInitialized) + } + } + } + return true + }) + if len(candidates) != 1 { + return functionElementUnsupported + } + return candidates[0] +} + +func sliceLiteralHasOmittedElements(literal *ast.CompositeLit, pkg *packages.Package) bool { + if literal == nil || pkg == nil || pkg.TypesInfo == nil { + return false + } + covered := map[int64]bool{} + nextIndex, maxIndex := int64(0), int64(-1) + for _, element := range literal.Elts { + if keyed, ok := element.(*ast.KeyValueExpr); ok { + value := pkg.TypesInfo.Types[keyed.Key].Value + if value == nil || value.Kind() != constant.Int { + return true + } + index, ok := constant.Int64Val(value) + if !ok || index < 0 { + return true + } + covered[index] = true + nextIndex, maxIndex = index+1, max64(maxIndex, index) + continue + } + covered[nextIndex] = true + maxIndex, nextIndex = max64(maxIndex, nextIndex), nextIndex+1 + } + return int64(len(covered)) < maxIndex+1 +} + +func sliceElementsHaveZero(length, capacity ssa.Value) bool { + return possiblePositiveLength(length) || possiblePositiveLength(capacity) +} + +func max64(left, right int64) int64 { + if left > right { + return left + } + return right +} + +func cacheSequenceElement(a *flowAnalysis, address string, state functionElementInitialization) { + if state == functionElementUnsupported { + return + } + if a.knownFunctionElements == nil { + a.knownFunctionElements = map[string]bool{} + } + a.knownFunctionElements[address] = true + if state == functionElementMayBeZero { + if a.zeroFunctionElements == nil { + a.zeroFunctionElements = map[string]bool{} + } + a.zeroFunctionElements[address] = true + } +} + +// functionElementValue returns the zero/unknown alternative for one candidate +// sequence root. Callers merge one result per root so source branch ordering +// cannot erase another root's alternative. +func (a *flowAnalysis) functionElementValue(sequence ssa.Value, root, address string, ix *index) flowValue { + result := emptyFlow() + if _, isSlice := sequence.Type().Underlying().(*types.Slice); isSlice { + if !a.knownFunctionElements[address] && !a.unknownFunctionElements[address] { + state := a.zeroFunctionArrayAtAddress(root, ix) + if state != functionElementUnsupported || a.hasFunctionArrayAllocation(root) { + if state == functionElementUnsupported { + if a.unknownFunctionElements == nil { + a.unknownFunctionElements = map[string]bool{} + } + a.unknownFunctionElements[address] = true + } else { + cacheSequenceElement(a, address, state) + } + } + } + if sequenceMayHaveElement(sequence) { + result.functionNil = a.zeroFunctionElements[address] + result.functionUnknown = a.unknownFunctionElements[address] || a.get(sequence).functionUnknown + } + return result + } + if !a.knownFunctionElements[address] && !a.unknownFunctionElements[address] { + state := a.zeroFunctionArrayAtAddress(root, ix) + if state != functionElementUnsupported || a.hasFunctionArrayAllocation(root) { + if state == functionElementUnsupported { + if a.unknownFunctionElements == nil { + a.unknownFunctionElements = map[string]bool{} + } + a.unknownFunctionElements[address] = true + } else { + if a.knownFunctionElements == nil { + a.knownFunctionElements = map[string]bool{} + } + a.knownFunctionElements[address] = true + if state == functionElementMayBeZero { + if a.zeroFunctionArrayElements == nil { + a.zeroFunctionArrayElements = map[string]bool{} + } + a.zeroFunctionArrayElements[address] = true + } + } + } + } + result.functionNil = a.zeroFunctionArrayElements[address] + result.functionUnknown = a.unknownFunctionElements[address] || a.get(sequence).functionUnknown + return result +} diff --git a/zero_function_fields.go b/zero_function_fields.go new file mode 100644 index 0000000..de8887f --- /dev/null +++ b/zero_function_fields.go @@ -0,0 +1,287 @@ +package contracttrace + +import ( + "go/ast" + "go/token" + "go/types" + "strconv" + "strings" + + "golang.org/x/tools/go/packages" + "golang.org/x/tools/go/ssa" +) + +// zeroFunctionFieldAtAddress recognizes only a source-bound zero-initialized +// aggregate allocation and a field path omitted by its initializer. It never +// treats absent flow candidates as evidence of a nil value. +func (a *flowAnalysis) zeroFunctionFieldAtAddress(address string, ix *index) bool { + if a.zeroFunctionFieldCache != nil { + if result, exists := a.zeroFunctionFieldCache[address]; exists { + return result == 1 + } + } + if len(a.allocations) == 0 { + return false + } + root, found := longestAllocationRoot(address, a.allocations, false) + if !found { + return false + } + indexes, ok := allocationFieldIndexes(root, address) + if !ok || len(indexes) == 0 { + return false + } + allocation := a.allocations[root] + if allocation == nil { + return false + } + function := allocation.Parent() + owner := ix.funcs[ix.owner(function)] + if owner == nil || owner.pkg == nil || owner.pkg.TypesInfo == nil { + return false + } + typ := allocation.Type() + if pointer, ok := typ.Underlying().(*types.Pointer); ok { + typ = pointer.Elem() + } + if !functionFieldAtPath(typ, indexes) { + a.cacheZeroFunctionField(address, false) + return false + } + var result bool + if len(indexes) == 1 { + result = omittedFunctionFieldInitializer(function, allocation, indexes[0], typ, owner.pkg, ix) + } else { + result = omittedNestedFunctionField(function, allocation, indexes, typ, owner.pkg, ix) + } + a.cacheZeroFunctionField(address, result) + return result +} + +func (a *flowAnalysis) cacheZeroFunctionField(address string, result bool) { + if a.zeroFunctionFieldCache == nil { + a.zeroFunctionFieldCache = map[string]uint8{} + } + if result { + a.zeroFunctionFieldCache[address] = 1 + } else { + a.zeroFunctionFieldCache[address] = 2 + } +} + +func functionFieldAtPath(typ types.Type, indexes []int) bool { + current := typ + for depth, index := range indexes { + structType, ok := current.Underlying().(*types.Struct) + if !ok || index < 0 || index >= structType.NumFields() { + return false + } + fieldType := structType.Field(index).Type() + if depth == len(indexes)-1 { + return isFunctionType(fieldType) + } + if _, pointer := fieldType.Underlying().(*types.Pointer); pointer { + return false + } + current = fieldType + } + return false +} + +func allocationFieldIndexes(root, address string) ([]int, bool) { + suffix := strings.TrimPrefix(address, root) + indexes := []int{} + for len(suffix) > 0 { + if !strings.HasPrefix(suffix, ".field:") { + return nil, false + } + suffix = strings.TrimPrefix(suffix, ".field:") + end := strings.IndexByte(suffix, '.') + segment := suffix + if end >= 0 { + segment, suffix = suffix[:end], suffix[end:] + } else { + suffix = "" + } + index, err := strconv.Atoi(segment) + if err != nil { + return nil, false + } + indexes = append(indexes, index) + } + return indexes, true +} + +type aggregateInitializer struct { + literal *ast.CompositeLit + zero bool +} + +func omittedFunctionFieldInitializer(function *ssa.Function, allocation *ssa.Alloc, field int, typ types.Type, pkg *packages.Package, ix *index) bool { + initializer, found := allocationInitializer(function, allocation, typ, pkg, ix) + if !found { + return false + } + if initializer.zero { + return true + } + _, present := compositeFieldValue(initializer.literal, field, typ, pkg) + return !present +} + +func omittedNestedFunctionField(function *ssa.Function, allocation *ssa.Alloc, indexes []int, typ types.Type, pkg *packages.Package, ix *index) bool { + initializer, found := allocationInitializer(function, allocation, typ, pkg, ix) + if !found { + return false + } + if initializer.zero { + return true + } + literal := initializer.literal + current := typ + for depth, fieldIndex := range indexes { + structType, ok := current.Underlying().(*types.Struct) + if !ok || fieldIndex < 0 || fieldIndex >= structType.NumFields() { + return false + } + value, present := compositeFieldValue(literal, fieldIndex, current, pkg) + if !present { + return true + } + fieldType := structType.Field(fieldIndex).Type() + if depth == len(indexes)-1 { + return false + } + if _, ok := fieldType.Underlying().(*types.Pointer); ok { + return false + } + current = fieldType + nested, ok := unparenExpr(value).(*ast.CompositeLit) + if !ok { + return false + } + literal = nested + } + return false +} + +func allocationInitializer(function *ssa.Function, allocation *ssa.Alloc, typ types.Type, pkg *packages.Package, ix *index) (aggregateInitializer, bool) { + if function == nil || function.Syntax() == nil || allocation == nil || pkg == nil || pkg.TypesInfo == nil || ix == nil || ix.fset == nil { + return aggregateInitializer{}, false + } + position := ix.fset.Position(allocation.Pos()) + if position.Line == 0 { + return aggregateInitializer{}, false + } + var candidates []aggregateInitializer + ast.Inspect(function.Syntax(), func(node ast.Node) bool { + switch value := node.(type) { + case *ast.CompositeLit: + literalType := pkg.TypesInfo.TypeOf(value) + if literalType == nil || !types.Identical(types.Unalias(literalType), types.Unalias(typ)) { + return true + } + contains := value.Pos() <= allocation.Pos() && allocation.Pos() <= value.End() + if contains { + candidates = append(candidates, aggregateInitializer{literal: value}) + } + case *ast.ValueSpec: + for nameIndex, name := range value.Names { + if name.Pos() != allocation.Pos() { + continue + } + object := pkg.TypesInfo.Defs[name] + variable, ok := object.(*types.Var) + if !ok || !types.Identical(types.Unalias(variable.Type()), types.Unalias(typ)) { + continue + } + if len(value.Values) == 0 { + candidates = append(candidates, aggregateInitializer{zero: true}) + continue + } + if len(value.Values) != len(value.Names) || nameIndex >= len(value.Values) { + continue + } + if literal, ok := unparenExpr(value.Values[nameIndex]).(*ast.CompositeLit); ok { + literalType := pkg.TypesInfo.TypeOf(literal) + if literalType != nil && types.Identical(types.Unalias(literalType), types.Unalias(typ)) { + candidates = append(candidates, aggregateInitializer{literal: literal}) + } + } + } + case *ast.AssignStmt: + if value.Tok != token.DEFINE || len(value.Lhs) != len(value.Rhs) { + return true + } + for targetIndex, target := range value.Lhs { + identifier, ok := target.(*ast.Ident) + if !ok || identifier.Pos() != allocation.Pos() { + continue + } + variable, ok := pkg.TypesInfo.Defs[identifier].(*types.Var) + if !ok || !types.Identical(types.Unalias(variable.Type()), types.Unalias(typ)) { + continue + } + literal, ok := unparenExpr(value.Rhs[targetIndex]).(*ast.CompositeLit) + if !ok { + continue + } + literalType := pkg.TypesInfo.TypeOf(literal) + if literalType != nil && types.Identical(types.Unalias(literalType), types.Unalias(typ)) { + candidates = append(candidates, aggregateInitializer{literal: literal}) + } + } + case *ast.CallExpr: + if value.Pos() > allocation.Pos() || value.End() < allocation.Pos() || len(value.Args) != 1 { + return true + } + name, ok := value.Fun.(*ast.Ident) + if !ok || name.Name != "new" { + return true + } + allocatedType := pkg.TypesInfo.TypeOf(value.Args[0]) + if allocatedType != nil && types.Identical(types.Unalias(allocatedType), types.Unalias(typ)) { + candidates = append(candidates, aggregateInitializer{zero: true}) + } + } + return true + }) + if len(candidates) != 1 { + return aggregateInitializer{}, false + } + return candidates[0], true +} + +func compositeFieldValue(literal *ast.CompositeLit, fieldIndex int, typ types.Type, pkg *packages.Package) (ast.Expr, bool) { + structType, ok := typ.Underlying().(*types.Struct) + if !ok || fieldIndex < 0 || fieldIndex >= structType.NumFields() { + return nil, false + } + field := structType.Field(fieldIndex) + for index, element := range literal.Elts { + if keyed, ok := element.(*ast.KeyValueExpr); ok { + identifier, ok := keyed.Key.(*ast.Ident) + if ok { + object, _ := pkg.TypesInfo.Uses[identifier].(*types.Var) + if object == field || identifier.Name == field.Name() { + return keyed.Value, true + } + } + continue + } + if index == fieldIndex { + return element, true + } + } + return nil, false +} + +func unparenExpr(expression ast.Expr) ast.Expr { + for { + paren, ok := expression.(*ast.ParenExpr) + if !ok { + return expression + } + expression = paren.X + } +} diff --git a/zero_function_globals.go b/zero_function_globals.go new file mode 100644 index 0000000..0becdb5 --- /dev/null +++ b/zero_function_globals.go @@ -0,0 +1,236 @@ +package contracttrace + +import ( + "go/ast" + "go/types" + "sort" + "strconv" + "strings" + + "golang.org/x/tools/go/packages" + "golang.org/x/tools/go/ssa" +) + +// globalFunctionState distinguishes source-proven zero function values from +// definitely initialized callbacks and initializers the model cannot classify. +type globalFunctionState uint8 + +const ( + globalFunctionUnknown globalFunctionState = iota + globalFunctionZero + globalFunctionInitialized +) + +// globalFunctionStateAtAddress maps a modeled global or inline struct field +// address back to its source declaration. It deliberately does not interpret +// a zero pointer-to-struct as a zero function field: dereferencing that pointer +// can panic before the function-valued field is read. +func (a *flowAnalysis) globalFunctionStateAtAddress(address string, ix *index) globalFunctionState { + if a == nil || ix == nil || address == "" || len(a.localGlobals) == 0 { + return globalFunctionUnknown + } + + globals := append([]*ssa.Global(nil), a.localGlobals...) + sort.Slice(globals, func(i, j int) bool { return globals[i].String() < globals[j].String() }) + var selected *ssa.Global + selectedAddress := "" + for _, global := range globals { + if global == nil { + continue + } + candidate := "global:" + global.String() + if address != candidate && !strings.HasPrefix(address, candidate+".field:") { + continue + } + if len(candidate) > len(selectedAddress) { + selected, selectedAddress = global, candidate + } + } + if selected == nil { + return globalFunctionUnknown + } + + variable, ok := selected.Object().(*types.Var) + if !ok || variable.Type() == nil || selected.Pkg == nil || selected.Pkg.Pkg == nil { + return globalFunctionUnknown + } + fieldPath, ok := globalFieldPath(selectedAddress, address) + if !ok || !globalFunctionFieldType(variable.Type(), fieldPath) { + return globalFunctionUnknown + } + pkg := globalSourcePackage(ix, selected.Pkg.Pkg) + if pkg == nil || pkg.TypesInfo == nil { + return globalFunctionUnknown + } + return classifyGlobalFunctionSource(variable, fieldPath, pkg) +} + +func classifyGlobalFunctionSource(variable *types.Var, fieldPath []int, pkg *packages.Package) globalFunctionState { + if variable == nil || variable.Type() == nil || pkg == nil || pkg.TypesInfo == nil || !globalFunctionFieldType(variable.Type(), fieldPath) { + return globalFunctionUnknown + } + initializer, found := globalInitializer(variable, pkg) + if !found { + return globalFunctionUnknown + } + if initializer == nil { + return globalFunctionZero + } + return classifyGlobalFunctionInitializer(initializer, variable.Type(), fieldPath, pkg) +} + +func globalFieldPath(root, address string) ([]int, bool) { + suffix := strings.TrimPrefix(address, root) + if suffix == "" { + return nil, true + } + indexes := []int{} + for len(suffix) != 0 { + if !strings.HasPrefix(suffix, ".field:") { + return nil, false + } + suffix = strings.TrimPrefix(suffix, ".field:") + end := strings.IndexByte(suffix, '.') + segment := suffix + if end >= 0 { + segment, suffix = suffix[:end], suffix[end:] + } else { + suffix = "" + } + index, err := strconv.Atoi(segment) + if err != nil || index < 0 { + return nil, false + } + indexes = append(indexes, index) + } + return indexes, true +} + +func globalFunctionFieldType(typ types.Type, indexes []int) bool { + current := types.Unalias(typ) + if _, pointer := current.Underlying().(*types.Pointer); pointer { + return false + } + for depth, index := range indexes { + structure, ok := current.Underlying().(*types.Struct) + if !ok || index < 0 || index >= structure.NumFields() { + return false + } + fieldType := types.Unalias(structure.Field(index).Type()) + if depth == len(indexes)-1 { + return isFunctionType(fieldType) + } + if _, pointer := fieldType.Underlying().(*types.Pointer); pointer { + return false + } + current = fieldType + } + return len(indexes) == 0 && isFunctionType(current) +} + +func globalSourcePackage(ix *index, target *types.Package) *packages.Package { + ids := make([]string, 0, len(ix.funcs)) + for id := range ix.funcs { + ids = append(ids, id) + } + sort.Strings(ids) + for _, id := range ids { + fn := ix.funcs[id] + if fn != nil && fn.pkg != nil && fn.pkg.Types == target && fn.pkg.TypesInfo != nil { + return fn.pkg + } + } + return nil +} + +func globalInitializer(variable *types.Var, pkg *packages.Package) (ast.Expr, bool) { + if variable == nil || pkg == nil || pkg.TypesInfo == nil { + return nil, false + } + var initializer ast.Expr + found := false + for _, file := range pkg.Syntax { + for _, declaration := range file.Decls { + gen, ok := declaration.(*ast.GenDecl) + if !ok || gen.Tok.String() != "var" { + continue + } + for _, raw := range gen.Specs { + spec, ok := raw.(*ast.ValueSpec) + if !ok { + continue + } + for nameIndex, name := range spec.Names { + if pkg.TypesInfo.Defs[name] != variable { + continue + } + if found { + return nil, false + } + found = true + if len(spec.Values) == 0 { + initializer = nil + } else if len(spec.Values) == len(spec.Names) { + initializer = spec.Values[nameIndex] + } else if len(spec.Names) == 1 && len(spec.Values) == 1 { + initializer = spec.Values[0] + } else { + return nil, false + } + } + } + } + } + return initializer, found +} + +func classifyGlobalFunctionInitializer(expression ast.Expr, typ types.Type, indexes []int, pkg *packages.Package) globalFunctionState { + if expression == nil || typ == nil || pkg == nil || pkg.TypesInfo == nil { + return globalFunctionUnknown + } + expression = unparenExpr(expression) + typ = types.Unalias(typ) + if len(indexes) == 0 { + if !isFunctionType(typ) { + return globalFunctionUnknown + } + if value, ok := pkg.TypesInfo.Types[expression]; ok && value.IsNil() { + return globalFunctionZero + } + switch value := expression.(type) { + case *ast.FuncLit: + return globalFunctionInitialized + case *ast.Ident: + if _, ok := pkg.TypesInfo.Uses[value].(*types.Func); ok { + return globalFunctionInitialized + } + case *ast.SelectorExpr: + if _, ok := pkg.TypesInfo.Uses[value.Sel].(*types.Func); ok { + return globalFunctionInitialized + } + } + return globalFunctionUnknown + } + + structure, ok := typ.Underlying().(*types.Struct) + if !ok { + return globalFunctionUnknown + } + literal, ok := expression.(*ast.CompositeLit) + if !ok { + return globalFunctionUnknown + } + literalType := pkg.TypesInfo.TypeOf(literal) + if literalType == nil || !types.Identical(types.Unalias(literalType), typ) { + return globalFunctionUnknown + } + fieldIndex := indexes[0] + if fieldIndex < 0 || fieldIndex >= structure.NumFields() { + return globalFunctionUnknown + } + fieldValue, present := compositeFieldValue(literal, fieldIndex, typ, pkg) + if !present { + return globalFunctionZero + } + return classifyGlobalFunctionInitializer(fieldValue, structure.Field(fieldIndex).Type(), indexes[1:], pkg) +} diff --git a/zero_function_globals_test.go b/zero_function_globals_test.go new file mode 100644 index 0000000..7f3f50b --- /dev/null +++ b/zero_function_globals_test.go @@ -0,0 +1,124 @@ +package contracttrace + +import ( + "go/ast" + "go/parser" + "go/token" + "go/types" + "testing" + + "golang.org/x/tools/go/packages" +) + +func TestGlobalFunctionSourceClassificationDistinguishesZeroInitializedAndUnknown(t *testing.T) { + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, "globals.go", `package globals +type holder struct { + callback func() +} +type nestedHolder struct { inner struct { callback func() } } +var zero holder +var omitted = holder{} +var nested nestedHolder +var nestedOmitted = nestedHolder{} +var nestedInlineOmitted = nestedHolder{inner: struct { callback func() }{}} +var explicit = holder{callback: known} +var nilExplicit = holder{callback: nil} +var direct func() +var directNil func() = nil +var directSet = known +var dynamic = choose() +var pointer *holder +var sameA, sameB = holder{}, holder{callback: known} +func known() {} +func choose() holder { return holder{} } +`, parser.AllErrors) + if err != nil { + t.Fatal(err) + } + info := &types.Info{ + Types: make(map[ast.Expr]types.TypeAndValue), + Defs: make(map[*ast.Ident]types.Object), + Uses: make(map[*ast.Ident]types.Object), + } + checked, err := new(types.Config).Check("example.com/globals", fset, []*ast.File{file}, info) + if err != nil { + t.Fatal(err) + } + pkg := &packages.Package{PkgPath: checked.Path(), Types: checked, TypesInfo: info, Syntax: []*ast.File{file}} + state := func(name string, fields ...int) globalFunctionState { + t.Helper() + variable := sourceGlobalVariable(t, pkg, name) + return classifyGlobalFunctionSource(variable, fields, pkg) + } + + for _, name := range []string{"zero", "omitted"} { + if got := state(name, 0); got != globalFunctionZero { + t.Errorf("%s callback field state = %v, want source-proven zero", name, got) + } + } + if got := state("nested", 0, 0); got != globalFunctionZero { + t.Errorf("nested inline callback field state = %v, want source-proven zero", got) + } + if got := state("nestedOmitted", 0, 0); got != globalFunctionZero { + t.Errorf("omitted nested callback field state = %v, want source-proven zero", got) + } + if got := state("nestedInlineOmitted", 0, 0); got != globalFunctionZero { + t.Errorf("elided nested literal callback field state = %v, want source-proven zero", got) + } + if got := state("explicit", 0); got != globalFunctionInitialized { + t.Errorf("explicit known callback state = %v, want initialized", got) + } + if got := state("nilExplicit", 0); got != globalFunctionZero { + t.Errorf("explicit nil callback state = %v, want zero", got) + } + if got := state("direct"); got != globalFunctionZero { + t.Errorf("direct function global state = %v, want source-proven zero", got) + } + if got := state("directNil"); got != globalFunctionZero { + t.Errorf("explicit nil function global state = %v, want zero", got) + } + if got := state("directSet"); got != globalFunctionInitialized { + t.Errorf("direct initialized function global state = %v, want initialized", got) + } + if got := state("dynamic", 0); got != globalFunctionUnknown { + t.Errorf("call-initialized callback field state = %v, want unknown", got) + } + if got := state("pointer", 0); got != globalFunctionUnknown { + t.Errorf("field under zero pointer state = %v, want unknown (the parent pointer may panic)", got) + } + if got := state("sameA", 0); got != globalFunctionZero { + t.Errorf("same-line zero global state = %v, want source-proven zero", got) + } + if got := state("sameB", 0); got != globalFunctionInitialized { + t.Errorf("same-line initialized global state = %v, want initialized", got) + } +} + +func sourceGlobalVariable(t *testing.T, pkg *packages.Package, name string) *types.Var { + t.Helper() + for _, file := range pkg.Syntax { + for _, declaration := range file.Decls { + gen, ok := declaration.(*ast.GenDecl) + if !ok || gen.Tok != token.VAR { + continue + } + for _, raw := range gen.Specs { + spec, ok := raw.(*ast.ValueSpec) + if !ok { + continue + } + for _, identifier := range spec.Names { + if identifier.Name == name { + variable, _ := pkg.TypesInfo.Defs[identifier].(*types.Var) + if variable != nil { + return variable + } + } + } + } + } + } + t.Fatalf("package fixture has no variable %q", name) + return nil +}