From e658880183a9da79309dd6fe910ffd1d2031dfb0 Mon Sep 17 00:00:00 2001 From: battuto Date: Thu, 12 Mar 2026 15:33:03 +0100 Subject: [PATCH 1/3] analyzer: report alternative transitive call paths in forEachPath When a function in the queried package has multiple outgoing call edges that each reach the same capability through different intermediate functions, only one path was reported by the BFS. This caused the JSON output to miss transitive capabilities reachable through alternative call paths. Add a second pass after the BFS that iterates over queried-package functions and checks for additional outgoing edges leading to visited nodes. For each such alternative edge, temporarily update the BFS state and report the path, then restore the original state. Fixes #153 --- analyzer/analyzer.go | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/analyzer/analyzer.go b/analyzer/analyzer.go index f065d4b..06b825c 100644 --- a/analyzer/analyzer.go +++ b/analyzer/analyzer.go @@ -787,6 +787,50 @@ func forEachPath(pkgs []*packages.Package, queriedPackages map[*types.Package]st } } } + // Second pass: find alternative call paths from functions in + // queriedPackages to the current capability. The BFS above only + // reports one path per function. If a function has multiple + // outgoing call edges that can each reach the capability through + // different intermediate functions, the additional paths are found + // here. + var queriedNodes []*callgraph.Node + for w := range visited { + if w.Func == nil || w.Func.Package() == nil { + continue + } + if _, ok := queriedPackages[w.Func.Package().Pkg]; !ok { + continue + } + if _, ok := nodes[w]; ok { + continue // w is an initial capability node, already reported + } + queriedNodes = append(queriedNodes, w) + } + sort.Sort(byFunction(queriedNodes)) + for _, w := range queriedNodes { + alreadyReportedCallee := visited[w].next() + var outEdges []*callgraph.Edge + for _, edge := range w.Out { + if !config.Classifier.IncludeCall(edge) { + continue + } + callee := edge.Callee + if callee == alreadyReportedCallee { + continue + } + if _, ok := visited[callee]; !ok { + continue + } + outEdges = append(outEdges, edge) + } + sort.Sort(byCallee(outEdges)) + for _, edge := range outEdges { + origState := visited[w] + visited[w] = bfsState{edge: edge} + fn(cap, visited, w) + visited[w] = origState + } + } } } From 3861fa1c666b170bc1eeee3e88345e6a91215c47 Mon Sep 17 00:00:00 2001 From: battuto Date: Thu, 30 Apr 2026 11:12:06 +0200 Subject: [PATCH 2/3] analyzer: report deep alternative call paths --- analyzer/analyzer.go | 137 ++++++++++++++++++++++++++++---------- analyzer/analyzer_test.go | 106 +++++++++++++++++++++++++++++ 2 files changed, 207 insertions(+), 36 deletions(-) diff --git a/analyzer/analyzer.go b/analyzer/analyzer.go index 06b825c..0bef6f0 100644 --- a/analyzer/analyzer.go +++ b/analyzer/analyzer.go @@ -72,11 +72,11 @@ func GetClassifier(excludeUnanalyzed bool) *interesting.Classifier { // those packages that have a path in the callgraph to a function with a // capability. // -// GetCapabilityInfo does not return every possible path (see the function -// CapabilityGraph for a way to get all paths). Which entries are returned -// depends on the value of Config.Granularity: +// Which entries are returned depends on the value of Config.Granularity: // - For "function" granularity (the default), one CapabilityInfo is returned -// for each combination of capability and function in pkgs. +// for each call path from a function in pkgs to a capability. If OmitPaths +// is set, paths cannot be distinguished in the output, so one entry is +// returned for each combination of capability and function in pkgs. // - For "package" granularity, one CapabilityInfo is returned for each // combination of capability and package in pkgs. // - For "intermediate" granularity, one CapabilityInfo is returned for each @@ -94,7 +94,11 @@ func GetCapabilityInfo(pkgs []*packages.Package, queriedPackages map[*types.Pack *ssa.Function // used for sorting } var caps []output - forEachPath(pkgs, queriedPackages, + pathFn := forEachPath + if config.Granularity == GranularityFunction && !config.OmitPaths { + pathFn = forEachSimplePath + } + pathFn(pkgs, queriedPackages, func(cap string, nodes bfsStateMap, v *callgraph.Node) { i := 0 c := cpb.CapabilityInfo{} @@ -137,7 +141,10 @@ func GetCapabilityInfo(pkgs []*packages.Package, queriedPackages map[*types.Pack if x, y := caps[i].CapabilityInfo.GetCapability(), caps[j].CapabilityInfo.GetCapability(); x != y { return x < y } - return funcCompare(caps[i].Function, caps[j].Function) < 0 + if c := funcCompare(caps[i].Function, caps[j].Function); c != 0 { + return c < 0 + } + return caps[i].CapabilityInfo.GetDepPath() < caps[j].CapabilityInfo.GetDepPath() }) if config.Granularity == GranularityPackage { // Keep only the first entry in the sorted list for each (capability, package) pair. @@ -787,12 +794,34 @@ func forEachPath(pkgs []*packages.Package, queriedPackages map[*types.Package]st } } } - // Second pass: find alternative call paths from functions in - // queriedPackages to the current capability. The BFS above only - // reports one path per function. If a function has multiple - // outgoing call edges that can each reach the capability through - // different intermediate functions, the additional paths are found - // here. + } +} + +// forEachSimplePath calls fn once for each simple call path from a function in +// queriedPackages to a function with the current capability. +// +// It first runs the same backward search as forEachPath to find nodes that can +// reach the capability, then walks forward through only those nodes. The +// per-callback bfsStateMap contains the edges for exactly one path. +func forEachSimplePath(pkgs []*packages.Package, queriedPackages map[*types.Package]struct{}, + fn func(cap string, nodes bfsStateMap, v *callgraph.Node), config *Config, +) { + safe, nodesByCapability, extraNodesByCapability := getPackageNodesWithCapability(pkgs, config) + nodesByCapability, allNodesWithExplicitCapability := mergeCapabilities(nodesByCapability, extraNodesByCapability) + extraNodesByCapability = nil // we don't use extraNodesByCapability again. + var caps []string + for cap := range nodesByCapability { + caps = append(caps, cap) + } + sort.Strings(caps) + for _, cap := range caps { + nodes := nodesByCapability[cap] + visited := searchBackwardsFromCapabilities( + nodesetPerCapability{cap: nodes}, + safe, + allNodesWithExplicitCapability, + config.Classifier) + var queriedNodes []*callgraph.Node for w := range visited { if w.Func == nil || w.Func.Package() == nil { @@ -801,37 +830,73 @@ func forEachPath(pkgs []*packages.Package, queriedPackages map[*types.Package]st if _, ok := queriedPackages[w.Func.Package().Pkg]; !ok { continue } - if _, ok := nodes[w]; ok { - continue // w is an initial capability node, already reported - } queriedNodes = append(queriedNodes, w) } sort.Sort(byFunction(queriedNodes)) for _, w := range queriedNodes { - alreadyReportedCallee := visited[w].next() - var outEdges []*callgraph.Edge - for _, edge := range w.Out { - if !config.Classifier.IncludeCall(edge) { - continue - } - callee := edge.Callee - if callee == alreadyReportedCallee { - continue - } - if _, ok := visited[callee]; !ok { - continue - } - outEdges = append(outEdges, edge) - } - sort.Sort(byCallee(outEdges)) - for _, edge := range outEdges { - origState := visited[w] - visited[w] = bfsState{edge: edge} - fn(cap, visited, w) - visited[w] = origState + forEachSimplePathFrom(cap, w, nodes, visited, config.Classifier, fn) + } + } +} + +func forEachSimplePathFrom(cap string, start *callgraph.Node, targetNodes nodeset, + canReachTarget bfsStateMap, classifier Classifier, + fn func(cap string, nodes bfsStateMap, v *callgraph.Node), +) { + var path []*callgraph.Edge + onPath := make(nodeset) + var walk func(*callgraph.Node) + walk = func(v *callgraph.Node) { + if _, ok := targetNodes[v]; ok { + pathState := make(bfsStateMap, len(path)+1) + for _, edge := range path { + pathState[edge.Caller] = bfsState{edge: edge} + } + pathState[v] = bfsState{} + fn(cap, pathState, start) + return + } + onPath[v] = struct{}{} + defer delete(onPath, v) + + for _, edge := range reachableOutgoingEdges(v, canReachTarget, classifier) { + if _, ok := onPath[edge.Callee]; ok { + continue } + path = append(path, edge) + walk(edge.Callee) + path = path[:len(path)-1] + } + } + walk(start) +} + +func reachableOutgoingEdges(v *callgraph.Node, canReachTarget bfsStateMap, classifier Classifier) []*callgraph.Edge { + var outEdges []*callgraph.Edge + for _, edge := range v.Out { + if !classifier.IncludeCall(edge) { + continue + } + if edge.Callee == nil || edge.Callee.Func == nil { + continue + } + if _, ok := canReachTarget[edge.Callee]; !ok { + continue + } + outEdges = append(outEdges, edge) + } + sort.Sort(byCallee(outEdges)) + // The JSON path uses function names, so multiple callsites to the same + // callee would produce duplicate-looking paths. Keep the first callsite in + // source order, matching CapabilityGraph's node-edge behavior. + deduped := outEdges[:0] + for _, edge := range outEdges { + if len(deduped) != 0 && edge.Callee == deduped[len(deduped)-1].Callee { + continue } + deduped = append(deduped, edge) } + return deduped } // intermediatePackages returns a CapabilityInfo for each unique (P, C) pair diff --git a/analyzer/analyzer_test.go b/analyzer/analyzer_test.go index bbb9185..dc070cf 100644 --- a/analyzer/analyzer_test.go +++ b/analyzer/analyzer_test.go @@ -285,6 +285,112 @@ func TestAnalysisWithClassifier(t *testing.T) { } } +func TestAnalysisReportsPathsThatDivergeAfterIntermediateCall(t *testing.T) { + filemap := map[string]string{ + "example.com/testlib/testlib.go": `package testlib + +import "example.com/dep" + +func A() { B() } +func B() { + dep.C() + dep.D() +} +`, + "example.com/dep/dep.go": `package dep + +func C() {} +func D() {} +`, + } + classifier := testClassifier{ + functions: map[[2]string]string{ + {"example.com/dep", "example.com/dep.C"}: "FILES", + {"example.com/dep", "example.com/dep.D"}: "FILES", + }, + ignoredEdges: nil, + } + pkgs, queriedPackages, cleanup, err := setup(filemap, "example.com/testlib") + if cleanup != nil { + defer cleanup() + } + if err != nil { + t.Fatalf("setup: %v", err) + } + cil := GetCapabilityInfo(pkgs, queriedPackages, &Config{ + Classifier: &classifier, + DisableBuiltin: true, + }) + expected := &cpb.CapabilityInfoList{ + CapabilityInfo: []*cpb.CapabilityInfo{{ + PackageName: proto.String("testlib"), + Capability: cpb.Capability_CAPABILITY_FILES.Enum(), + CapabilityName: proto.String("FILES"), + DepPath: proto.String("example.com/testlib.A example.com/testlib.B example.com/dep.C"), + Path: []*cpb.Function{ + &cpb.Function{Name: proto.String("example.com/testlib.A"), Package: proto.String("example.com/testlib")}, + &cpb.Function{Name: proto.String("example.com/testlib.B"), Package: proto.String("example.com/testlib")}, + &cpb.Function{Name: proto.String("example.com/dep.C"), Package: proto.String("example.com/dep")}, + }, + PackageDir: proto.String("example.com/testlib"), + CapabilityType: cpb.CapabilityType_CAPABILITY_TYPE_TRANSITIVE.Enum(), + }, { + PackageName: proto.String("testlib"), + Capability: cpb.Capability_CAPABILITY_FILES.Enum(), + CapabilityName: proto.String("FILES"), + DepPath: proto.String("example.com/testlib.A example.com/testlib.B example.com/dep.D"), + Path: []*cpb.Function{ + &cpb.Function{Name: proto.String("example.com/testlib.A"), Package: proto.String("example.com/testlib")}, + &cpb.Function{Name: proto.String("example.com/testlib.B"), Package: proto.String("example.com/testlib")}, + &cpb.Function{Name: proto.String("example.com/dep.D"), Package: proto.String("example.com/dep")}, + }, + PackageDir: proto.String("example.com/testlib"), + CapabilityType: cpb.CapabilityType_CAPABILITY_TYPE_TRANSITIVE.Enum(), + }, { + PackageName: proto.String("testlib"), + Capability: cpb.Capability_CAPABILITY_FILES.Enum(), + CapabilityName: proto.String("FILES"), + DepPath: proto.String("example.com/testlib.B example.com/dep.C"), + Path: []*cpb.Function{ + &cpb.Function{Name: proto.String("example.com/testlib.B"), Package: proto.String("example.com/testlib")}, + &cpb.Function{Name: proto.String("example.com/dep.C"), Package: proto.String("example.com/dep")}, + }, + PackageDir: proto.String("example.com/testlib"), + CapabilityType: cpb.CapabilityType_CAPABILITY_TYPE_TRANSITIVE.Enum(), + }, { + PackageName: proto.String("testlib"), + Capability: cpb.Capability_CAPABILITY_FILES.Enum(), + CapabilityName: proto.String("FILES"), + DepPath: proto.String("example.com/testlib.B example.com/dep.D"), + Path: []*cpb.Function{ + &cpb.Function{Name: proto.String("example.com/testlib.B"), Package: proto.String("example.com/testlib")}, + &cpb.Function{Name: proto.String("example.com/dep.D"), Package: proto.String("example.com/dep")}, + }, + PackageDir: proto.String("example.com/testlib"), + CapabilityType: cpb.CapabilityType_CAPABILITY_TYPE_TRANSITIVE.Enum(), + }}, + } + opts := []cmp.Option{ + protocmp.Transform(), + protocmp.SortRepeated(func(a, b *cpb.CapabilityInfo) bool { + return a.GetDepPath() < b.GetDepPath() + }), + protocmp.IgnoreFields(&cpb.CapabilityInfoList{}, "package_info"), + protocmp.IgnoreFields(&cpb.Function{}, "site"), + } + if diff := cmp.Diff(expected, cil, opts...); diff != "" { + t.Errorf("GetCapabilityInfo: got %v, want %v; diff %s", cil, expected, diff) + } + + counts := GetCapabilityCounts(pkgs, queriedPackages, &Config{ + Classifier: &classifier, + DisableBuiltin: true, + }) + if got := counts.CapabilityCounts["FILES"]; got != 2 { + t.Errorf("GetCapabilityCounts: FILES count = %d, want 2", got) + } +} + func TestGraphWithClassifier(t *testing.T) { pkgs, queriedPackages, cleanup, err := setup(filemap, "testlib") if cleanup != nil { From 1a63c44c0ebaa9737c7f63d2f9b7485eb326ca72 Mon Sep 17 00:00:00 2001 From: battuto Date: Thu, 30 Apr 2026 11:47:40 +0200 Subject: [PATCH 3/3] analyzer: add graph JSON output --- analyzer/analyzer.go | 6 + analyzer/analyzer_test.go | 99 +++++++++++++ analyzer/graph.go | 291 ++++++++++++++++++++++++++++++++++++++ analyzer/scan.go | 2 + cmd/capslock/capslock.go | 16 ++- 5 files changed, 408 insertions(+), 6 deletions(-) diff --git a/analyzer/analyzer.go b/analyzer/analyzer.go index 0bef6f0..1dc06c2 100644 --- a/analyzer/analyzer.go +++ b/analyzer/analyzer.go @@ -34,6 +34,12 @@ type Config struct { // CapabilitySet is the set of capabilities to use for graph output mode. // If CapabilitySet is nil, all capabilities are used. CapabilitySet *CapabilitySet + // GraphFunction limits graph-json output to paths starting at the named + // function. The name must match the call graph function name exactly. + GraphFunction string + // GraphPerFunction emits a separate graph-json graph for each reachable + // function in the queried packages. + GraphPerFunction bool // OmitPaths disables output of example call paths. OmitPaths bool } diff --git a/analyzer/analyzer_test.go b/analyzer/analyzer_test.go index dc070cf..ec1c421 100644 --- a/analyzer/analyzer_test.go +++ b/analyzer/analyzer_test.go @@ -391,6 +391,105 @@ func D() {} } } +func TestGraphJSONForSingleFunction(t *testing.T) { + filemap := map[string]string{ + "example.com/testlib/testlib.go": `package testlib + +import "example.com/dep" + +func A() { B() } +func B() { + dep.C() + dep.D() +} +`, + "example.com/dep/dep.go": `package dep + +func C() {} +func D() {} +`, + } + classifier := testClassifier{ + functions: map[[2]string]string{ + {"example.com/dep", "example.com/dep.C"}: "FILES", + {"example.com/dep", "example.com/dep.D"}: "FILES", + }, + ignoredEdges: nil, + } + pkgs, queriedPackages, cleanup, err := setup(filemap, "example.com/testlib") + if cleanup != nil { + defer cleanup() + } + if err != nil { + t.Fatalf("setup: %v", err) + } + + out, err := buildGraphJSON(pkgs, queriedPackages, &Config{ + Classifier: &classifier, + DisableBuiltin: true, + GraphFunction: "example.com/testlib.A", + }) + if err != nil { + t.Fatalf("buildGraphJSON: %v", err) + } + if got := len(out.Graphs); got != 1 { + t.Fatalf("buildGraphJSON returned %d graphs, want 1", got) + } + graph := out.Graphs[0] + if graph.Root != "example.com/testlib.A" { + t.Errorf("graph.Root = %q, want example.com/testlib.A", graph.Root) + } + if !reflect.DeepEqual(graph.Capabilities, []string{"FILES"}) { + t.Errorf("graph.Capabilities = %v, want [FILES]", graph.Capabilities) + } + + nodes := make(map[string]string) + for _, node := range graph.Nodes { + nodes[node.ID] = node.Kind + } + expectedNodes := map[string]string{ + "CAPABILITY_FILES": "capability", + "example.com/dep.C": "function", + "example.com/dep.D": "function", + "example.com/testlib.A": "function", + "example.com/testlib.B": "function", + } + if !reflect.DeepEqual(nodes, expectedNodes) { + t.Errorf("graph nodes = %v, want %v", nodes, expectedNodes) + } + + edges := make(map[[3]string]struct{}) + for _, edge := range graph.Edges { + edges[[3]string{edge.From, edge.To, edge.Kind}] = struct{}{} + } + expectedEdges := map[[3]string]struct{}{ + {"example.com/testlib.A", "example.com/testlib.B", "call"}: {}, + {"example.com/testlib.B", "example.com/dep.C", "call"}: {}, + {"example.com/testlib.B", "example.com/dep.D", "call"}: {}, + {"example.com/dep.C", "CAPABILITY_FILES", "capability"}: {}, + {"example.com/dep.D", "CAPABILITY_FILES", "capability"}: {}, + } + if !reflect.DeepEqual(edges, expectedEdges) { + t.Errorf("graph edges = %v, want %v", edges, expectedEdges) + } + + out, err = buildGraphJSON(pkgs, queriedPackages, &Config{ + Classifier: &classifier, + DisableBuiltin: true, + GraphPerFunction: true, + }) + if err != nil { + t.Fatalf("buildGraphJSON with GraphPerFunction: %v", err) + } + var roots []string + for _, graph := range out.Graphs { + roots = append(roots, graph.Root) + } + if !reflect.DeepEqual(roots, []string{"example.com/testlib.A", "example.com/testlib.B"}) { + t.Errorf("per-function graph roots = %v, want [example.com/testlib.A example.com/testlib.B]", roots) + } +} + func TestGraphWithClassifier(t *testing.T) { pkgs, queriedPackages, cleanup, err := setup(filemap, "testlib") if cleanup != nil { diff --git a/analyzer/graph.go b/analyzer/graph.go index ecb6a0f..0b00a47 100644 --- a/analyzer/graph.go +++ b/analyzer/graph.go @@ -8,10 +8,12 @@ package analyzer import ( "bufio" + "encoding/json" "fmt" "go/types" "io" "os" + "sort" "strconv" "strings" @@ -97,6 +99,295 @@ func graphOutput(pkgs []*packages.Package, queriedPackages map[*types.Package]st return w.Flush() } +type graphJSONOutputInfo struct { + Graphs []graphJSONGraph `json:"graphs"` +} + +type graphJSONGraph struct { + Root string `json:"root,omitempty"` + Roots []string `json:"roots,omitempty"` + Capabilities []string `json:"capabilities,omitempty"` + Nodes []graphJSONNode `json:"nodes"` + Edges []graphJSONEdge `json:"edges"` +} + +type graphJSONNode struct { + ID string `json:"id"` + Kind string `json:"kind"` + Package string `json:"package,omitempty"` +} + +type graphJSONEdge struct { + From string `json:"from"` + To string `json:"to"` + Kind string `json:"kind"` + Site *graphJSONSite `json:"site,omitempty"` +} + +type graphJSONSite struct { + Filename string `json:"filename"` + Line int64 `json:"line"` + Column int64 `json:"column"` +} + +func graphJSONOutput(pkgs []*packages.Package, queriedPackages map[*types.Package]struct{}, config *Config) error { + out, err := buildGraphJSON(pkgs, queriedPackages, config) + if err != nil { + return err + } + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + return encoder.Encode(out) +} + +func buildGraphJSON(pkgs []*packages.Package, queriedPackages map[*types.Package]struct{}, config *Config) (*graphJSONOutputInfo, error) { + if config.GraphFunction != "" && config.GraphPerFunction { + return nil, fmt.Errorf("-graph_function and -graph_per_function cannot be used together") + } + safe, nodesByCapability, extraNodesByCapability := getPackageNodesWithCapability(pkgs, config) + nodesByCapability, allNodesWithExplicitCapability := mergeCapabilities(nodesByCapability, extraNodesByCapability) + extraNodesByCapability = nil + + nodesByCapability = filterNodesByCapability(nodesByCapability, config.CapabilitySet) + bfsFromCapabilities := searchBackwardsFromCapabilities(nodesByCapability, safe, allNodesWithExplicitCapability, config.Classifier) + + roots := reachableQueriedNodes(bfsFromCapabilities, queriedPackages) + if config.GraphFunction != "" { + root, ok := findGraphRoot(roots, config.GraphFunction) + if !ok { + return nil, fmt.Errorf("function %q was not found among queried functions that reach the selected capabilities", config.GraphFunction) + } + return &graphJSONOutputInfo{ + Graphs: []graphJSONGraph{ + collectGraphJSON([]*callgraph.Node{root}, nodesByCapability, allNodesWithExplicitCapability, bfsFromCapabilities, config.Classifier), + }, + }, nil + } + + if config.GraphPerFunction { + out := &graphJSONOutputInfo{} + for _, root := range roots { + out.Graphs = append(out.Graphs, + collectGraphJSON([]*callgraph.Node{root}, nodesByCapability, allNodesWithExplicitCapability, bfsFromCapabilities, config.Classifier)) + } + return out, nil + } + + return &graphJSONOutputInfo{ + Graphs: []graphJSONGraph{ + collectGraphJSON(roots, nodesByCapability, allNodesWithExplicitCapability, bfsFromCapabilities, config.Classifier), + }, + }, nil +} + +func filterNodesByCapability(nodesByCapability nodesetPerCapability, capabilitySet *CapabilitySet) nodesetPerCapability { + if capabilitySet == nil { + return nodesByCapability + } + filtered := make(nodesetPerCapability) + for capability, nodes := range nodesByCapability { + if capabilitySet.Has(capability) { + filtered[capability] = nodes + } + } + return filtered +} + +func reachableQueriedNodes(bfsFromCapabilities bfsStateMap, queriedPackages map[*types.Package]struct{}) []*callgraph.Node { + var roots []*callgraph.Node + for v := range bfsFromCapabilities { + if v.Func == nil || v.Func.Package() == nil { + continue + } + if _, ok := queriedPackages[v.Func.Package().Pkg]; ok { + roots = append(roots, v) + } + } + sort.Sort(byFunction(roots)) + return roots +} + +func findGraphRoot(roots []*callgraph.Node, name string) (*callgraph.Node, bool) { + for _, root := range roots { + if root.Func != nil && root.Func.String() == name { + return root, true + } + } + return nil, false +} + +func collectGraphJSON(roots []*callgraph.Node, nodesByCapability nodesetPerCapability, + allNodesWithExplicitCapability nodeset, bfsFromCapabilities bfsStateMap, classifier Classifier, +) graphJSONGraph { + collector := newGraphJSONCollector(roots) + startNodes := make(nodeset) + for _, root := range roots { + startNodes[root] = struct{}{} + } + searchForwardsFromQueriedFunctions( + startNodes, + nodesByCapability, + allNodesWithExplicitCapability, + bfsFromCapabilities, + classifier, + func(_ bfsStateMap, node *callgraph.Node, _ bfsStateMap) { + collector.addFunctionNode(node) + }, + func(edge *callgraph.Edge) { + collector.addFunctionNode(edge.Caller) + collector.addFunctionNode(edge.Callee) + collector.addCallEdge(edge) + }, + func(fn *callgraph.Node, capability string) { + collector.addFunctionNode(fn) + collector.addCapabilityNode(capability) + collector.addCapabilityEdge(fn, capability) + }) + return collector.graph() +} + +type graphJSONCollector struct { + roots []string + nodes map[string]graphJSONNode + edges map[string]graphJSONEdge + capabilities map[string]struct{} +} + +func newGraphJSONCollector(roots []*callgraph.Node) *graphJSONCollector { + var rootNames []string + for _, root := range roots { + rootNames = append(rootNames, graphNodeID(root)) + } + sort.Strings(rootNames) + return &graphJSONCollector{ + roots: rootNames, + nodes: make(map[string]graphJSONNode), + edges: make(map[string]graphJSONEdge), + capabilities: make(map[string]struct{}), + } +} + +func (c *graphJSONCollector) addFunctionNode(node *callgraph.Node) { + if node == nil { + return + } + id := graphNodeID(node) + n := graphJSONNode{ + ID: id, + Kind: "function", + } + if pkg := nodeToPackage(node); pkg != nil { + n.Package = pkg.Path() + } + c.nodes[id] = n +} + +func (c *graphJSONCollector) addCapabilityNode(capability string) { + id := graphCapabilityID(capability) + c.nodes[id] = graphJSONNode{ + ID: id, + Kind: "capability", + } + c.capabilities[capability] = struct{}{} +} + +func (c *graphJSONCollector) addCallEdge(edge *callgraph.Edge) { + e := graphJSONEdge{ + From: graphNodeID(edge.Caller), + To: graphNodeID(edge.Callee), + Kind: "call", + Site: graphJSONSiteFromEdge(edge), + } + c.edges[graphJSONEdgeKey(e)] = e +} + +func (c *graphJSONCollector) addCapabilityEdge(fn *callgraph.Node, capability string) { + e := graphJSONEdge{ + From: graphNodeID(fn), + To: graphCapabilityID(capability), + Kind: "capability", + } + c.edges[graphJSONEdgeKey(e)] = e +} + +func (c *graphJSONCollector) graph() graphJSONGraph { + g := graphJSONGraph{ + Capabilities: sortedStrings(c.capabilities), + Nodes: sortedGraphJSONNodes(c.nodes), + Edges: sortedGraphJSONEdges(c.edges), + } + if len(c.roots) == 1 { + g.Root = c.roots[0] + } else { + g.Roots = c.roots + } + return g +} + +func graphNodeID(node *callgraph.Node) string { + if node == nil { + return "" + } + if node.Func != nil { + return node.Func.String() + } + return strconv.Itoa(node.ID) +} + +func graphCapabilityID(capability string) string { + return "CAPABILITY_" + capability +} + +func graphJSONSiteFromEdge(edge *callgraph.Edge) *graphJSONSite { + if position := callsitePosition(edge); position.IsValid() { + return &graphJSONSite{ + Filename: position.Filename, + Line: int64(position.Line), + Column: int64(position.Column), + } + } + return nil +} + +func graphJSONEdgeKey(e graphJSONEdge) string { + return e.Kind + "\x00" + e.From + "\x00" + e.To +} + +func sortedGraphJSONNodes(nodes map[string]graphJSONNode) []graphJSONNode { + ids := make([]string, 0, len(nodes)) + for id := range nodes { + ids = append(ids, id) + } + sort.Strings(ids) + out := make([]graphJSONNode, 0, len(ids)) + for _, id := range ids { + out = append(out, nodes[id]) + } + return out +} + +func sortedGraphJSONEdges(edges map[string]graphJSONEdge) []graphJSONEdge { + keys := make([]string, 0, len(edges)) + for key := range edges { + keys = append(keys, key) + } + sort.Strings(keys) + out := make([]graphJSONEdge, 0, len(keys)) + for _, key := range keys { + out = append(out, edges[key]) + } + return out +} + +func sortedStrings(values map[string]struct{}) []string { + out := make([]string, 0, len(values)) + for value := range values { + out = append(out, value) + } + sort.Strings(out) + return out +} + type graphBuilder struct { io.Writer nodeNamer func(any) string diff --git a/analyzer/scan.go b/analyzer/scan.go index f40f9d8..3e31483 100644 --- a/analyzer/scan.go +++ b/analyzer/scan.go @@ -109,6 +109,8 @@ func RunCapslock(args []string, output string, pkgs []*packages.Package, queried return ctm.Execute(os.Stdout, cil) } else if output == "g" || output == "graph" { return graphOutput(pkgs, queriedPackages, config) + } else if output == "graph-json" || output == "graph_json" { + return graphJSONOutput(pkgs, queriedPackages, config) } cil := GetCapabilityCounts(pkgs, queriedPackages, config) ctm := template.Must(template.New("default.tmpl").Funcs(templateFuncMap).ParseFS(staticContent, "static/default.tmpl")) diff --git a/cmd/capslock/capslock.go b/cmd/capslock/capslock.go index 53a9384..d485e2c 100644 --- a/cmd/capslock/capslock.go +++ b/cmd/capslock/capslock.go @@ -33,7 +33,7 @@ import ( var ( packageList = flag.String("packages", "", "target patterns to be analysed; allows wildcarding") - output = flag.String("output", "", "output mode to use; non-default options are json, m, package, v, graph, and compare") + output = flag.String("output", "", "output mode to use; non-default options are json, m, package, v, graph, graph-json, and compare") verbose = flag.Int("v", 0, "verbosity level") noiseFlag = flag.Bool("noisy", false, "include output on unanalyzed function calls (can be noisy)") customMap = flag.String("capability_map", "", "use a custom capability map file") @@ -46,6 +46,8 @@ var ( memprofile = flag.String("memprofile", "", "write memory profile to specified file") granularity = flag.String("granularity", "", `the granularity to use for comparisons, either "package" or "function".`) + graphFunction = flag.String("graph_function", "", "for -output=graph-json, only emit the graph reachable from this exact function name") + graphPerFunction = flag.Bool("graph_per_function", false, "for -output=graph-json, emit one graph for each reachable queried function") forceLocalModule = flag.Bool("force_local_module", false, "if the requested packages cannot be loaded in the current workspace, return an error immediately, instead of trying to load them in a temporary module") omitPaths = flag.Bool("omit_paths", false, "omit example call paths from output") version = flag.Bool("version", false, "report Capslock version and exit") @@ -210,11 +212,13 @@ func run() error { return fmt.Errorf("Some packages had errors. Aborting analysis.") } err = analyzer.RunCapslock(flag.Args(), *output, pkgs, queriedPackages, &analyzer.Config{ - Classifier: classifier, - DisableBuiltin: *disableBuiltin, - Granularity: g, - CapabilitySet: cs, - OmitPaths: *omitPaths, + Classifier: classifier, + DisableBuiltin: *disableBuiltin, + Granularity: g, + CapabilitySet: cs, + GraphFunction: *graphFunction, + GraphPerFunction: *graphPerFunction, + OmitPaths: *omitPaths, }) if *memprofile != "" {