diff --git a/Makefile b/Makefile index 4598663..9c4251f 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,8 @@ GOLANGCI := $(GOPATH)/bin/golangci-lint -.PHONY: analyser -analyser: - go build -o ./bin/analyser ./cmd/analyser/main.go +.PHONY: analyzer +analyzer: + go build -o ./bin/analyzer ./main.go .PHONY: get get: diff --git a/README.md b/README.md index cc64e62..ac4545f 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,26 @@ Even if `condition2` is never met during runtime, VM Compat will still detect `d This ensures that all possible execution paths are analyzed, making the tool effective in identifying compatibility concerns proactively. +## Prerequisites + +VM Compat requires `llvm-objdump` to be installed for disassembly. Install it using the following commands: + +### Linux (Ubuntu/Debian) +```sh +sudo apt update && sudo apt install llvm +``` + +### macOS +```sh +brew install llvm +``` + +Ensure that `llvm-objdump` is accessible in your system's `PATH`. If necessary, update the `PATH` variable: + +```sh +export PATH="$(brew --prefix llvm)/bin:$PATH" +``` + ## Installation To install VM Compat, clone the repository and build the binary: @@ -54,25 +74,70 @@ cd vm-compat make analyser ``` -## CLI Flags +## Usage -VM Compat provides several command-line flags to control its behavior: +### General CLI Usage -| Flag | Description | Default | -| -------------------------- | ---------------------------------------------------------------------- |-----------------------------------| -| `-vm-profile` | Path to the VM profile config file. | `./profile/cannon/cannon-64.yaml` | -| `-analyzer` | Type of analysis to perform. Options: `opcode`, `syscall` | analyzes both by default | -| `-disassembly-output-path` | File path to store the disassembled opcode assembly code. | None | -| `-format` | Output format. Options: `json`, `text` | `text` | -| `-report-output-path` | Path to store the analysis report. If not provided, outputs to stdout. | Stdout | +```sh +./bin/analyzer [global options] command [command options] +``` -## Usage +### Commands + +- `analyze`: Checks the program compatibility against the VM profile. +- `trace`: Generates a stack trace for a given function. +- `help, h`: Shows a list of commands or help for one command. -Run VM Compat with the Go source file you want to analyze: +### Command-Specific Usage + +#### Analyze Command ```sh -./bin/analyser -analyzer=opcode -format=text -disassembly-output-path=sample.asm -vm-profile=./profile/cannon/cannon-64.yaml ./examples/sample.go +./bin/analyzer analyze [command options] ``` + +#### Analyze Options + +| Option | Description | Default | +|---------------------------------|--------------------------------------------------------------------|---------| +| `--vm-profile value` | Path to the VM profile config file (required). | None | +| `--analysis-type value` | Type of analysis to perform. Options: `opcode`, `syscall`. | All | +| `--disassembly-output-path` | File path to store the disassembled assembly code. | None | +| `--format value` | Output format. Options: `json`, `text`. | `text` | +| `--report-output-path value` | Output file path for report. Default: stdout. | None | +| `--with-trace` | Enable full stack trace output. | `false` | +| `--help, -h` | Show help. | None | + +#### Trace Command + +```sh +./bin/analyzer trace [command options] +``` + +#### Trace Options + +| Option | Description | Default | +|-----------------------|----------------------------------------------------------------------------------------|---------| +| `--vm-profile value` | Path to the VM profile config file (required). | None | +| `--function value` | Name of the function to trace. Include package name (e.g., `syscall.read`). (required) | None | +| `--help, -h` | Show help. | None | + +## Example Usage + +### Running an Analysis + +```sh +./bin/analyzer analyze --with-trace=true --format=text --analysis-type=syscall --disassembly-output-path=sample.asm --vm-profile ./profile/cannon/cannon-64.yaml ./examples/sample.go + +``` + +### Running a Trace + +```sh +./bin/analyzer trace --vm-profile=./profile/cannon/cannon-64.yaml --function=syscall.read + +```` + To create vm specific profile, follow [this](./profile/readme.md) ## Example Output @@ -102,6 +167,5 @@ To create vm specific profile, follow [this](./profile/readme.md) 1. [CRITICAL] Incompatible Syscall Detected: 5006 - Sources: -> zsyscall_linux_mips64.go:1677 : (syscall.lstat) - -> syscall_linux_mips64x.go:154 : (syscall.Lstat) ... ``` diff --git a/analyser/analyser.go b/analyzer/analyzer.go similarity index 80% rename from analyser/analyser.go rename to analyzer/analyzer.go index 39c36ac..cc40ca8 100644 --- a/analyser/analyser.go +++ b/analyzer/analyzer.go @@ -1,11 +1,14 @@ -// Package analyser provides an interface for analyzing source code for compatibility issues. -package analyser +// Package analyzer provides an interface for analyzing source code for compatibility issues. +package analyzer // Analyzer represents the interface for the analyzer. type Analyzer interface { // Analyze analyzes the provided source code and returns any issues found. // TODO: better to update the code to take a reader interface instead of path - Analyze(path string) ([]*Issue, error) + Analyze(path string, withTrace bool) ([]*Issue, error) + + // TraceStack generates callstack for a function to debug + TraceStack(path string, function string) (*IssueSource, error) } // IssueSeverity represents the severity level of an issue. @@ -18,9 +21,9 @@ const ( // Issue represents a single issue found by the analyzer. type Issue struct { - Sources []*IssueSource `json:"sources"` - Message string `json:"message"` // A description of the issue. - Severity IssueSeverity `json:"severity"` + Sources *IssueSource `json:"sources"` + Message string `json:"message"` // A description of the issue. + Severity IssueSeverity `json:"severity"` } // IssueSource represents a location in the code where the issue originates. diff --git a/analyser/opcode/opcode.go b/analyzer/opcode/opcode.go similarity index 63% rename from analyser/opcode/opcode.go rename to analyzer/opcode/opcode.go index 161ecae..061c7d0 100644 --- a/analyser/opcode/opcode.go +++ b/analyzer/opcode/opcode.go @@ -1,4 +1,4 @@ -// Package opcode implements analyser.Analyzer for detecting incompatible opcodes. +// Package opcode implements analyzer.Analyzer for detecting incompatible opcodes. package opcode import ( @@ -7,7 +7,7 @@ import ( "slices" "strings" - "github.com/ChainSafe/vm-compat/analyser" + "github.com/ChainSafe/vm-compat/analyzer" "github.com/ChainSafe/vm-compat/asmparser" "github.com/ChainSafe/vm-compat/asmparser/mips" "github.com/ChainSafe/vm-compat/common" @@ -18,11 +18,11 @@ type opcode struct { profile *profile.VMProfile } -func NewAnalyser(profile *profile.VMProfile) analyser.Analyzer { +func NewAnalyser(profile *profile.VMProfile) analyzer.Analyzer { return &opcode{profile: profile} } -func (op *opcode) Analyze(path string) ([]*analyser.Issue, error) { +func (op *opcode) Analyze(path string, withTrace bool) ([]*analyzer.Issue, error) { var err error var callGraph asmparser.CallGraph @@ -39,13 +39,20 @@ func (op *opcode) Analyze(path string) ([]*analyser.Issue, error) { if err != nil { return nil, err } - issues := make([]*analyser.Issue, 0) + issues := make([]*analyzer.Issue, 0) for _, segment := range callGraph.Segments() { for _, instruction := range segment.Instructions() { if !op.isAllowedOpcode(instruction.OpcodeHex(), instruction.Funct()) { - issues = append(issues, &analyser.Issue{ - Severity: analyser.IssueSeverityCritical, - Sources: common.TraceAsmCaller(absPath, instruction, segment, callGraph, make([]*analyser.IssueSource, 0), 0), + source, err := common.TraceAsmCaller(absPath, callGraph, segment.Label()) + if err != nil { // non-reachable portion ignored + continue + } + if !withTrace { + source.CallStack = nil + } + issues = append(issues, &analyzer.Issue{ + Severity: analyzer.IssueSeverityCritical, + Sources: source, Message: fmt.Sprintf("Incompatible Opcode Detected: Opcode: %s, Funct: %s", instruction.OpcodeHex(), instruction.Funct()), }) @@ -55,6 +62,11 @@ func (op *opcode) Analyze(path string) ([]*analyser.Issue, error) { return issues, nil } +// TraceStack generates callstack for a function to debug +func (op *opcode) TraceStack(path string, function string) (*analyzer.IssueSource, error) { + return nil, fmt.Errorf("stack trace is not supported for assembly code") +} + func (op *opcode) isAllowedOpcode(opcode, funct string) bool { return slices.ContainsFunc(op.profile.AllowedOpcodes, func(instr profile.OpcodeInstruction) bool { if !strings.EqualFold(instr.Opcode, opcode) { diff --git a/analyser/syscall/asm_syscall.go b/analyzer/syscall/asm_syscall.go similarity index 66% rename from analyser/syscall/asm_syscall.go rename to analyzer/syscall/asm_syscall.go index 766d2ac..53e2be7 100644 --- a/analyser/syscall/asm_syscall.go +++ b/analyzer/syscall/asm_syscall.go @@ -6,7 +6,7 @@ import ( "path/filepath" "slices" - "github.com/ChainSafe/vm-compat/analyser" + "github.com/ChainSafe/vm-compat/analyzer" "github.com/ChainSafe/vm-compat/asmparser" "github.com/ChainSafe/vm-compat/asmparser/mips" "github.com/ChainSafe/vm-compat/common" @@ -21,14 +21,14 @@ type asmSyscallAnalyser struct { } // NewAssemblySyscallAnalyser initializes an analyser for assembly syscalls. -func NewAssemblySyscallAnalyser(profile *profile.VMProfile) analyser.Analyzer { +func NewAssemblySyscallAnalyser(profile *profile.VMProfile) analyzer.Analyzer { return &asmSyscallAnalyser{profile: profile} } // Analyze scans an assembly file for syscalls and detects compatibility issues. // //nolint:cyclop -func (a *asmSyscallAnalyser) Analyze(path string) ([]*analyser.Issue, error) { +func (a *asmSyscallAnalyser) Analyze(path string, withTrace bool) ([]*analyzer.Issue, error) { var ( err error callGraph asmparser.CallGraph @@ -45,7 +45,7 @@ func (a *asmSyscallAnalyser) Analyze(path string) ([]*analyser.Issue, error) { return nil, fmt.Errorf("error parsing assembly file: %w", err) } - issues := make([]*analyser.Issue, 0) + issues := make([]*analyzer.Issue, 0) absPath, err := filepath.Abs(path) if err != nil { @@ -72,20 +72,33 @@ func (a *asmSyscallAnalyser) Analyze(path string) ([]*analyser.Issue, error) { if slices.Contains(a.profile.AllowedSycalls, syscallNum) { continue } + // Better to develop a new algo to check all segments at once like go_syscall + source, err := common.TraceAsmCaller(absPath, callGraph, segment.Label()) + if err != nil { // non-reachable portion ignored + continue + } + if !withTrace { + source.CallStack = nil + } - severity := analyser.IssueSeverityCritical - message := fmt.Sprintf("Incompatible Syscall Detected: %d", syscallNum) + severity := analyzer.IssueSeverityCritical + message := fmt.Sprintf("Potential Incompatible Syscall Detected: %d", syscallNum) if slices.Contains(a.profile.NOOPSyscalls, syscallNum) { - message = fmt.Sprintf("NOOP Syscall Detected: %d", syscallNum) - severity = analyser.IssueSeverityWarning + message = fmt.Sprintf("Potential NOOP Syscall Detected: %d", syscallNum) + severity = analyzer.IssueSeverityWarning } - issues = append(issues, &analyser.Issue{ + issues = append(issues, &analyzer.Issue{ Severity: severity, Message: message, - Sources: common.TraceAsmCaller(absPath, instruction, segment, callGraph, make([]*analyser.IssueSource, 0), 0), + Sources: source, }) } } return issues, nil } + +// TraceStack generates callstack for a function to debug +func (a *asmSyscallAnalyser) TraceStack(path string, function string) (*analyzer.IssueSource, error) { + return nil, fmt.Errorf("stack trace is not supported for assembly code") +} diff --git a/analyser/syscall/go_syscall.go b/analyzer/syscall/go_syscall.go similarity index 59% rename from analyser/syscall/go_syscall.go rename to analyzer/syscall/go_syscall.go index c179007..1eeab8c 100644 --- a/analyser/syscall/go_syscall.go +++ b/analyzer/syscall/go_syscall.go @@ -9,7 +9,7 @@ import ( "strconv" "strings" - "github.com/ChainSafe/vm-compat/analyser" + "github.com/ChainSafe/vm-compat/analyzer" "github.com/ChainSafe/vm-compat/common" "github.com/ChainSafe/vm-compat/profile" "golang.org/x/tools/go/callgraph" @@ -32,18 +32,131 @@ type goSyscallAnalyser struct { } // NewGOSyscallAnalyser initializes an analyser for Go syscalls. -func NewGOSyscallAnalyser(profile *profile.VMProfile) analyser.Analyzer { +func NewGOSyscallAnalyser(profile *profile.VMProfile) analyzer.Analyzer { return &goSyscallAnalyser{profile: profile} } // Analyze scans a Go binary for syscalls and detects compatibility issues. // //nolint:cyclop -func (a *goSyscallAnalyser) Analyze(path string) ([]*analyser.Issue, error) { +func (a *goSyscallAnalyser) Analyze(path string, withTrace bool) ([]*analyzer.Issue, error) { + cg, fset, err := a.buildCallGraph(path) + if err != nil { + return nil, err + } + syscalls := make([]syscallSource, 0) + err = callgraph.GraphVisitEdges(cg, func(edge *callgraph.Edge) error { + callee := edge.Callee.Func + if callee != nil && callee.Pkg != nil && callee.Pkg.Pkg != nil { + if slices.Contains(syscallAPIs, callee.String()) { + calls := traceSyscalls(edge.Site.Common().Args[0], edge, fset) + syscalls = append(syscalls, calls...) + } + } + return nil + }) + if err != nil { + return nil, err + } + + // Check against allowed syscalls. + issues := make([]*analyzer.Issue, 0) + functions := make([]string, 0) + for _, syscall := range syscalls { + functions = append(functions, syscall.source.Function) + } + tracker := a.reachableFunctions(cg, functions) + stackTraceMap := make(map[string]*analyzer.IssueSource) + for i := range syscalls { + syscll := syscalls[i] + if slices.Contains(a.profile.AllowedSycalls, syscll.num) || !tracker[syscll.source.Function] { + continue + } + stackTrace := syscll.source + if withTrace { + stackTrace = stackTraceMap[syscll.source.Function] + if stackTrace == nil { + stackTrace, _ = a.trackStack(cg, fset, syscll.source.Function) + stackTraceMap[syscll.source.Function] = stackTrace + } + } + + severity := analyzer.IssueSeverityCritical + message := fmt.Sprintf("Potential Incompatible Syscall Detected: %d", syscll.num) + if slices.Contains(a.profile.NOOPSyscalls, syscll.num) { + severity = analyzer.IssueSeverityWarning + message = fmt.Sprintf("Potential NOOP Syscall Detected: %d", syscll.num) + } + + issues = append(issues, &analyzer.Issue{ + Severity: severity, + Sources: stackTrace, + Message: message, + }) + } + + return issues, nil +} + +func (a *goSyscallAnalyser) TraceStack(path string, function string) (*analyzer.IssueSource, error) { + cg, fset, err := a.buildCallGraph(path) + if err != nil { + return nil, err + } + return a.trackStack(cg, fset, function) +} + +func (a *goSyscallAnalyser) trackStack(cg *callgraph.Graph, fset *token.FileSet, function string) (*analyzer.IssueSource, error) { + seen := make(map[*callgraph.Node]bool) + var visit func(n *callgraph.Node, edge *callgraph.Edge) *analyzer.IssueSource + visit = func(n *callgraph.Node, edge *callgraph.Edge) *analyzer.IssueSource { + var src *analyzer.IssueSource + if edge != nil && edge.Caller != nil && edge.Site != nil { + position := fset.Position(edge.Site.Pos()) + fn := edge.Caller.Func.String() + src = &analyzer.IssueSource{ + File: position.Filename, + Line: position.Line, + Function: fn, + AbsPath: filepath.Clean(position.Filename), + } + if fn == function { + return src + } + } + // as we are checking edge.Caller we need to get 1 step deeper everytime, that requires to re-visit the node + if seen[n] { + return nil + } + seen[n] = true + + for _, e := range n.Out { + ch := visit(e.Callee, e) + if ch != nil { + if src != nil { + ch.AddCallStack(src) + } + return ch + } + } + return nil + } + + for _, n := range cg.Nodes { + if n.Func.String() == "command-line-arguments.main" || n.Func.String() == "command-line-arguments.init" { + if source := visit(n, nil); source != nil { + return source, nil + } + } + } + return nil, fmt.Errorf("no trace found to root for the given function") +} + +func (a *goSyscallAnalyser) buildCallGraph(path string) (*callgraph.Graph, *token.FileSet, error) { // Find the Go module root for correct context modRoot, err := common.FindGoModuleRoot(path) if err != nil { - return nil, fmt.Errorf("failed to find Go module root: %w", err) + return nil, nil, fmt.Errorf("failed to find Go module root: %w", err) } cfg := &packages.Config{ Mode: packages.LoadAllSyntax, @@ -58,10 +171,10 @@ func (a *goSyscallAnalyser) Analyze(path string) ([]*analyser.Issue, error) { initial, err := packages.Load(cfg, path) if err != nil { - return nil, err + return nil, nil, err } if packages.PrintErrors(initial) > 0 { - return nil, fmt.Errorf("packages contain errors") + return nil, nil, fmt.Errorf("packages contain errors") } // Create and build SSA-form program representation. @@ -72,7 +185,7 @@ func (a *goSyscallAnalyser) Analyze(path string) ([]*analyser.Issue, error) { // Construct call graph using RTA analysis. mains, err := mainPackages(prog.AllPackages()) if err != nil { - return nil, err + return nil, nil, err } roots := make([]*ssa.Function, 0) for _, main := range mains { @@ -83,51 +196,40 @@ func (a *goSyscallAnalyser) Analyze(path string) ([]*analyser.Issue, error) { cg := rta.Analyze(roots, true).CallGraph cg.DeleteSyntheticNodes() - // Analyze call graph for syscalls. - fset := initial[0].Fset - syscalls := make([]syscallSource, 0) - err = callgraph.GraphVisitEdges(cg, func(edge *callgraph.Edge) error { - callee := edge.Callee.Func - if callee != nil && callee.Pkg != nil && callee.Pkg.Pkg != nil { - if slices.Contains(syscallAPIs, callee.String()) { - calls := traceSyscalls(edge.Site.Common().Args[0], edge, fset) - syscalls = append(syscalls, calls...) - } - } - return nil - }) - if err != nil { - return nil, err - } + return cg, initial[0].Fset, nil +} - // Check against allowed syscalls. - issues := []*analyser.Issue{} - for i := range syscalls { - syscll := syscalls[i] - if slices.Contains(a.profile.AllowedSycalls, syscll.num) { - continue +func (a *goSyscallAnalyser) reachableFunctions(cg *callgraph.Graph, functions []string) map[string]bool { + seen := make(map[*callgraph.Node]bool) + tracker := make(map[string]bool) + + var visit func(n *callgraph.Node) + visit = func(n *callgraph.Node) { + if seen[n] { + return } + seen[n] = true - severity := analyser.IssueSeverityCritical - message := fmt.Sprintf("Incompatible Syscall Detected: %d", syscll.num) - if slices.Contains(a.profile.NOOPSyscalls, syscll.num) { - severity = analyser.IssueSeverityWarning - message = fmt.Sprintf("NOOP Syscall Detected: %d", syscll.num) + if slices.Contains(functions, n.Func.String()) { + tracker[n.Func.String()] = true } - issues = append(issues, &analyser.Issue{ - Severity: severity, - Sources: syscll.source, - Message: message, - }) + for _, e := range n.Out { + visit(e.Callee) + } } - return issues, nil + for _, n := range cg.Nodes { + if n.Func.String() == "command-line-arguments.main" || n.Func.String() == "command-line-arguments.init" { + visit(n) + } + } + return tracker } type syscallSource struct { num int - source []*analyser.IssueSource + source *analyzer.IssueSource } func traceSyscalls(value ssa.Value, edge *callgraph.Edge, fset *token.FileSet) []syscallSource { @@ -136,7 +238,15 @@ func traceSyscalls(value ssa.Value, edge *callgraph.Edge, fset *token.FileSet) [ case *ssa.Const: valInt, err := strconv.Atoi(v.Value.String()) if err == nil { - return []syscallSource{{num: valInt, source: traceCaller(edge, make([]*analyser.IssueSource, 0), 0, fset)}} + position := fset.Position(edge.Site.Pos()) + return []syscallSource{{num: valInt, + source: &analyzer.IssueSource{ + File: position.Filename, + Line: position.Line, + Function: edge.Caller.Func.String(), + AbsPath: filepath.Clean(position.Filename), + }, + }} } case *ssa.Global: result = append(result, traceInit(v, v.Pkg.Members, edge, fset)...) @@ -240,102 +350,3 @@ func initFuncs(pkgs []*ssa.Package) []*ssa.Function { } return inits } - -// traceCaller correctly tracks function calls in the execution stack. -func traceCaller(edge *callgraph.Edge, paths []*analyser.IssueSource, depth int, fset *token.FileSet) []*analyser.IssueSource { - if edge == nil || edge.Caller == nil { - return paths // Prevent nil pointer dereference - } - - // Create a new IssueSource entry for this function call - source := &analyser.IssueSource{ - File: "undefined", - Line: 0, - AbsPath: "undefined", - Function: edge.Caller.Func.String(), - } - - // Get file name, absolute path, and line number safely - if edge.Site != nil { - pos := edge.Site.Pos() - position := fset.Position(pos) - source.File = filepath.Base(position.Filename) - source.Line = position.Line - if position.Filename != "" { - source.AbsPath = filepath.Clean(position.Filename) - } - } - - // If this is the first function call in the trace, initialize the stack - newPaths := make([]*analyser.IssueSource, 0) - if len(paths) == 0 { - newPaths = []*analyser.IssueSource{source} - } else { - if len(paths) > 1 { - panic("multiple paths not possible") - } - newPath := paths[0].Copy() - newPath.AddCallStack(source) - newPaths = append(newPaths, newPath) - } - - // Stop recursion at desired depth to prevent infinite loops - if depth >= 1 || len(edge.Caller.In) == 0 { - return newPaths - } - - // Recurse for previous function calls (callers) - result := make([]*analyser.IssueSource, 0) - for _, e := range edge.Caller.In { - result = append(result, traceCaller(e, newPaths, depth+1, fset)...) - } - - return result -} - -//nolint:unused -func traceCallerToRoot(edge *callgraph.Edge, paths []*analyser.IssueSource, depth int, fset *token.FileSet) []*analyser.IssueSource { - if edge == nil || edge.Caller == nil { - return paths // Prevent nil pointer dereference - } - // Create a new IssueSource entry for this function call - source := &analyser.IssueSource{ - File: "undefined", - Line: 0, - AbsPath: "undefined", - Function: edge.Caller.Func.String(), - } - // Get file name, absolute path, and line number safely - if edge.Site != nil { - pos := edge.Site.Pos() - position := fset.Position(pos) - source.File = filepath.Base(position.Filename) - source.Line = position.Line - if position.Filename != "" { - source.AbsPath = filepath.Clean(position.Filename) - } - } - - if len(edge.Caller.In) == 0 && (source.Function == "command-line-arguments.main" || - source.Function == "command-line-arguments.init") { - return []*analyser.IssueSource{source} - } - if len(edge.Caller.In) == 0 || depth == 10 { - return nil - } - results := make([]*analyser.IssueSource, 0) - for _, e := range edge.Caller.In { - res := traceCaller(e, paths, depth+1, fset) - if len(res) > 0 { - results = append(results, res...) - break - } - } - finalResults := make([]*analyser.IssueSource, 0) - for _, result := range results { - src := source.Copy() - src.CallStack = result - finalResults = append(finalResults, src) - } - return finalResults -} diff --git a/cmd/analyser/main.go b/cmd/analyser/main.go deleted file mode 100644 index 5e1251c..0000000 --- a/cmd/analyser/main.go +++ /dev/null @@ -1,142 +0,0 @@ -package main - -import ( - "flag" - "fmt" - "log" - "os" - "path/filepath" - - "github.com/ChainSafe/vm-compat/analyser" - "github.com/ChainSafe/vm-compat/analyser/opcode" - "github.com/ChainSafe/vm-compat/analyser/syscall" - "github.com/ChainSafe/vm-compat/disassembler" - "github.com/ChainSafe/vm-compat/disassembler/manager" - "github.com/ChainSafe/vm-compat/profile" - "github.com/ChainSafe/vm-compat/renderer" -) - -var ( - vmProfile = flag.String("vm-profile", "./profile/cannon/cannon-64.yaml", "vm profile config") - analyzer = flag.String("analyzer", "", "analyzer to run. Options: opcode, syscall") - disassemblyOutput = flag.String("disassembly-output-path", "", "output file path for opcode assembly code.") - format = flag.String("format", "text", "format of the output. Options: json, text") - reportOutputPath = flag.String("report-output-path", "", "output file path for report. Default: stdout") -) - -const usage = ` -analyser: checks the program compatibility against the vm profile - -Usage: - - callgraph [-analyzer=opcode|syscall] [-vm-profile=path_to_config] package... -` - -func main() { - flag.Parse() - args := flag.Args() - - if len(args) != 1 { - fmt.Fprint(os.Stderr, usage) - return - } - - prof, err := profile.LoadProfile(*vmProfile) - if err != nil { - log.Fatalf("Error loading profile: %v", err) - } - - disassemblyPath, err := disassemble(prof, args[0], *disassemblyOutput) - if err != nil { - log.Fatalf("Error disassembling the file: %v", err) - } - - issues, err := analyze(prof, args[0], disassemblyPath, *analyzer) - if err != nil { - log.Fatalf("Analysis failed: %v", err) - } - - if err := writeReport(issues, *format, *reportOutputPath, prof); err != nil { - log.Fatalf("Unable to write report: %v", err) - } -} - -// disassemble extracts assembly output for analysis. -func disassemble(prof *profile.VMProfile, path, outputPath string) (string, error) { - dis, err := manager.NewDisassembler(disassembler.TypeObjdump, prof.GOOS, prof.GOARCH) - if err != nil { - return "", err - } - - if outputPath == "" { - outputPath = filepath.Join(os.TempDir(), "temp_assembly_output") - } - - _, err = dis.Disassemble(disassembler.SourceFile, path, outputPath) - return outputPath, err -} - -// analyze runs the selected analyzer(s). -func analyze(prof *profile.VMProfile, path, disassemblyPath, mode string) ([]*analyser.Issue, error) { - if mode == "opcode" { - return opcode.NewAnalyser(prof).Analyze(disassemblyPath) - } - if mode == "syscall" { - return analyzeSyscalls(prof, path, disassemblyPath) - } - // by default analyze both - opIssues, err := opcode.NewAnalyser(prof).Analyze(disassemblyPath) - if err != nil { - return nil, err - } - sysIssues, err := analyzeSyscalls(prof, path, disassemblyPath) - if err != nil { - return nil, err - } - - return append(opIssues, sysIssues...), nil -} - -// writeReport outputs the results in the specified format. -func writeReport(issues []*analyser.Issue, format, outputPath string, prof *profile.VMProfile) error { - var output *os.File - if outputPath == "" { - output = os.Stdout - } else { - absPath, err := filepath.Abs(outputPath) - if err != nil { - return fmt.Errorf("unable to determine absolute path: %w", err) - } - output, err = os.OpenFile(absPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) - if err != nil { - return fmt.Errorf("unable to open output file: %w", err) - } - defer func() { - _ = output.Close() - }() - } - - var rendererInstance renderer.Renderer - switch format { - case "text": - rendererInstance = renderer.NewTextRenderer(prof) - case "json": - rendererInstance = renderer.NewJSONRenderer() - default: - return fmt.Errorf("invalid format: %s", format) - } - - return rendererInstance.Render(issues, output) -} - -func analyzeSyscalls(profile *profile.VMProfile, source string, disassemblyPath string) ([]*analyser.Issue, error) { - issues, err := syscall.NewGOSyscallAnalyser(profile).Analyze(source) - if err != nil { - return nil, err - } - issues2, err := syscall.NewAssemblySyscallAnalyser(profile).Analyze(disassemblyPath) - if err != nil { - return nil, err - } - return append(issues, issues2...), nil -} diff --git a/cmd/analyze.go b/cmd/analyze.go new file mode 100644 index 0000000..3c71980 --- /dev/null +++ b/cmd/analyze.go @@ -0,0 +1,182 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/ChainSafe/vm-compat/analyzer" + "github.com/ChainSafe/vm-compat/analyzer/opcode" + "github.com/ChainSafe/vm-compat/analyzer/syscall" + "github.com/ChainSafe/vm-compat/disassembler" + "github.com/ChainSafe/vm-compat/disassembler/manager" + "github.com/ChainSafe/vm-compat/profile" + "github.com/ChainSafe/vm-compat/renderer" + "github.com/urfave/cli/v2" +) + +// TODO: update flag type + +var ( + VMProfileFlag = &cli.StringFlag{ + Name: "vm-profile", + Usage: "Path to the VM profile config file", + Required: true, + } + AnalysisTypeFlag = &cli.StringFlag{ + Name: "analysis-type", + Usage: "Type of analysis to perform. Options: opcode, syscall", + Required: false, + } + DisassemblyOutputFlag = &cli.PathFlag{ + Name: "disassembly-output-path", + Usage: "File path to store the disassembled assembly code", + Required: false, + } + FormatFlag = &cli.StringFlag{ + Name: "format", + Usage: "format of the output. Options: json, text", + Required: false, + DefaultText: "text", + } + ReportOutputPathFlag = &cli.PathFlag{ + Name: "report-output-path", + Usage: "output file path for report. Default: stdout", + Required: false, + } + TraceFlag = &cli.BoolFlag{ + Name: "with-trace", + Usage: "enable full stack trace output", + Required: false, + Value: false, + } +) + +func CreateAnalyzeCommand(action cli.ActionFunc) *cli.Command { + return &cli.Command{ + Name: "analyze", + Usage: "Checks the program compatibility against the VM profile", + Description: "Checks the program compatibility against the VM profile", + Action: action, + Flags: []cli.Flag{ + VMProfileFlag, + AnalysisTypeFlag, + DisassemblyOutputFlag, + FormatFlag, + ReportOutputPathFlag, + TraceFlag, + }, + } +} + +var AnalyzeCommand = CreateAnalyzeCommand(AnalyzeCompatibility) + +func AnalyzeCompatibility(ctx *cli.Context) error { + vmProfile := ctx.Path(VMProfileFlag.Name) + prof, err := profile.LoadProfile(vmProfile) + if err != nil { + return fmt.Errorf("error loading profile: %w", err) + } + + source := ctx.Args().First() + disassemblyPath := ctx.Path(DisassemblyOutputFlag.Name) + format := ctx.String(FormatFlag.Name) + reportOutputPath := ctx.Path(ReportOutputPathFlag.Name) + analysisType := ctx.String(AnalysisTypeFlag.Name) + withTrace := ctx.Bool(TraceFlag.Name) + + disassemblyPath, err = disassemble(prof, source, disassemblyPath) + if err != nil { + return fmt.Errorf("error disassembling the file: %w", err) + } + + issues, err := analyze(prof, source, disassemblyPath, analysisType, withTrace) + if err != nil { + return fmt.Errorf("analysis failed: %w", err) + } + + if err := writeReport(issues, format, reportOutputPath, prof); err != nil { + return fmt.Errorf("unable to write report: %w", err) + } + return nil +} + +// disassemble extracts assembly output for analysis. +func disassemble(prof *profile.VMProfile, path, outputPath string) (string, error) { + dis, err := manager.NewDisassembler(disassembler.TypeObjdump, prof.GOOS, prof.GOARCH) + if err != nil { + return "", err + } + + if outputPath == "" { + outputPath = filepath.Join(os.TempDir(), "temp_assembly_output") + } + + _, err = dis.Disassemble(disassembler.SourceFile, path, outputPath) + return outputPath, err +} + +// analyze runs the selected analyzer(s). +func analyze(prof *profile.VMProfile, path, disassemblyPath, mode string, withTrace bool) ([]*analyzer.Issue, error) { + if mode == "opcode" { + return opcode.NewAnalyser(prof).Analyze(disassemblyPath, withTrace) + } + if mode == "syscall" { + return analyzeSyscalls(prof, path, disassemblyPath, withTrace) + } + // by default analyze both + opIssues, err := opcode.NewAnalyser(prof).Analyze(disassemblyPath, withTrace) + if err != nil { + return nil, err + } + sysIssues, err := analyzeSyscalls(prof, path, disassemblyPath, withTrace) + if err != nil { + return nil, err + } + + return append(opIssues, sysIssues...), nil +} + +// writeReport outputs the results in the specified format. +func writeReport(issues []*analyzer.Issue, format, outputPath string, prof *profile.VMProfile) error { + var output *os.File + if outputPath == "" { + output = os.Stdout + } else { + absPath, err := filepath.Abs(outputPath) + if err != nil { + return fmt.Errorf("unable to determine absolute path: %w", err) + } + output, err = os.OpenFile(absPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) + if err != nil { + return fmt.Errorf("unable to open output file: %w", err) + } + defer func() { + _ = output.Close() + }() + } + + var rendererInstance renderer.Renderer + switch format { + case "text": + rendererInstance = renderer.NewTextRenderer(prof) + case "json": + rendererInstance = renderer.NewJSONRenderer() + default: + return fmt.Errorf("invalid format: %s", format) + } + + return rendererInstance.Render(issues, output) +} + +func analyzeSyscalls(profile *profile.VMProfile, source string, disassemblyPath string, withTrace bool) ([]*analyzer.Issue, error) { + issues, err := syscall.NewGOSyscallAnalyser(profile).Analyze(source, withTrace) + if err != nil { + return nil, err + } + issues2, err := syscall.NewAssemblySyscallAnalyser(profile).Analyze(disassemblyPath, withTrace) + if err != nil { + return nil, err + } + return append(issues, issues2...), nil +} diff --git a/cmd/trace.go b/cmd/trace.go new file mode 100644 index 0000000..d2ddc51 --- /dev/null +++ b/cmd/trace.go @@ -0,0 +1,73 @@ +// Package cmd defines all the commands for the cli +package cmd + +import ( + "fmt" + "os" + "strings" + + "github.com/ChainSafe/vm-compat/analyzer" + + "github.com/ChainSafe/vm-compat/analyzer/syscall" + "github.com/ChainSafe/vm-compat/profile" + "github.com/urfave/cli/v2" +) + +var ( + FunctionNameFlag = &cli.StringFlag{ + Name: "function", + Usage: "Name of the function to trace. Name should include with package name. Ex: syscall.read", + Required: true, + } +) + +func CreateTraceCommand(action cli.ActionFunc) *cli.Command { + return &cli.Command{ + Name: "trace", + Usage: "Generates stack trace for a given function", + Description: "Generates stack trace for a given function", + Action: action, + Flags: []cli.Flag{ + VMProfileFlag, + FunctionNameFlag, + }, + } +} + +var TraceCommand = CreateTraceCommand(TraceCaller) + +func TraceCaller(ctx *cli.Context) error { + vmProfile := ctx.Path(VMProfileFlag.Name) + prof, err := profile.LoadProfile(vmProfile) + if err != nil { + return fmt.Errorf("error loading profile: %w", err) + } + + function := ctx.Path(FunctionNameFlag.Name) + source := ctx.Args().First() + + analyzer := syscall.NewGOSyscallAnalyser(prof) + callStack, err := analyzer.TraceStack(source, function) + if err != nil { + return err + } + str := printCallStack(callStack, "") + _, err = os.Stdout.WriteString(str) + if err != nil { + return err + } + return nil +} + +func printCallStack(source *analyzer.IssueSource, str string) string { + fileInfo := fmt.Sprintf( + " \033[94m\033]8;;file://%s:%d\033\\%s:%d\033]8;;\033\\\033[0m", + source.AbsPath, source.Line, source.File, source.Line, + ) + str = strings.Join( + []string{str, fmt.Sprintf("-> %s : (%s)", fileInfo, source.Function)}, "\n") + if source.CallStack != nil { + return printCallStack(source.CallStack, str) + } + return str +} diff --git a/common/stack_tracer.go b/common/stack_tracer.go index 4ff8e4e..6d905e2 100644 --- a/common/stack_tracer.go +++ b/common/stack_tracer.go @@ -1,55 +1,60 @@ package common import ( + "fmt" "path/filepath" + "strings" - "github.com/ChainSafe/vm-compat/analyser" + "github.com/ChainSafe/vm-compat/analyzer" "github.com/ChainSafe/vm-compat/asmparser" ) // TraceAsmCaller correctly tracks function calls in the execution stack. func TraceAsmCaller( filePath string, - instruction asmparser.Instruction, - segment asmparser.Segment, graph asmparser.CallGraph, - paths []*analyser.IssueSource, - depth int) []*analyser.IssueSource { - if instruction == nil || segment == nil { - return paths // Prevent nil pointer dereference + function string, +) (*analyzer.IssueSource, error) { + var segment asmparser.Segment + for _, seg := range graph.Segments() { + if seg.Label() == function { + segment = seg + break + } } - - // Create a new IssueSource entry for this function call - source := &analyser.IssueSource{ - File: filepath.Base(filePath), - Line: instruction.Line(), - AbsPath: filePath, - Function: segment.Label(), + if segment == nil { + return nil, fmt.Errorf("could not find %s in %s", function, filePath) } - // If this is the first function call in the trace, initialize the stack - newPaths := make([]*analyser.IssueSource, 0) - if len(paths) == 0 { - newPaths = []*analyser.IssueSource{source} - } else { - if len(paths) > 1 { - panic("multiple paths not possible") + seen := make(map[asmparser.Segment]bool) + var visit func(graph asmparser.CallGraph, segment asmparser.Segment) *analyzer.IssueSource + + visit = func(graph asmparser.CallGraph, segment asmparser.Segment) *analyzer.IssueSource { + if seen[segment] { + return nil } - newPath := paths[0].Copy() - newPath.AddCallStack(source) - newPaths = append(newPaths, newPath) - } + seen[segment] = true - parents := graph.ParentsOf(segment) - // Stop recursion at desired depth to prevent infinite loops - if depth >= 1 || len(parents) == 0 { - return newPaths + source := &analyzer.IssueSource{ + File: filepath.Base(filePath), + Line: segment.Instructions()[0].Line() - 1, // function start line + AbsPath: filePath, + Function: segment.Label(), + } + if strings.Contains(source.Function, ".init") { // where to end + return source + } + for _, seg := range graph.ParentsOf(segment) { + ch := visit(graph, seg) + if ch != nil { + source.AddCallStack(ch) + return source + } + } + return nil } - - // Recurse for previous function calls (callers) - result := make([]*analyser.IssueSource, 0) - for _, seg := range parents { - result = append(result, TraceAsmCaller(filePath, seg.Instructions()[0], seg, graph, newPaths, depth+1)...) + src := visit(graph, segment) + if src == nil { + return nil, fmt.Errorf("no trace found to root for the given function") } - - return result + return src, nil } diff --git a/examples/sample.go b/examples/sample.go index 6ffde47..61d0037 100644 --- a/examples/sample.go +++ b/examples/sample.go @@ -1,9 +1,9 @@ package main import ( - "fmt" + "os" ) func main() { - fmt.Println("Hello World") + os.Stdout.Write([]byte("Hello World!")) } diff --git a/go.mod b/go.mod index 430161e..fcda532 100644 --- a/go.mod +++ b/go.mod @@ -6,15 +6,19 @@ toolchain go1.22.2 require ( github.com/stretchr/testify v1.10.0 + github.com/urfave/cli/v2 v2.27.5 golang.org/x/tools v0.29.0 gopkg.in/yaml.v3 v3.0.1 ) require ( + github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/kr/pretty v0.3.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rogpeppe/go-internal v1.11.0 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect golang.org/x/mod v0.22.0 // indirect golang.org/x/sync v0.10.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect diff --git a/go.sum b/go.sum index be5acc8..a59949e 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc= +github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -16,8 +18,14 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w= +github.com/urfave/cli/v2 v2.27.5/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ= +github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= +github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4= golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= diff --git a/main.go b/main.go new file mode 100644 index 0000000..69c2657 --- /dev/null +++ b/main.go @@ -0,0 +1,25 @@ +package main + +import ( + "context" + "log" + "os" + + "github.com/ChainSafe/vm-compat/cmd" + "github.com/urfave/cli/v2" +) + +func main() { + app := cli.NewApp() + app.Name = os.Args[0] + app.Usage = "VM Compatibility Analyzer" + app.Description = "VM Compatibility Analyzer" + app.Commands = []*cli.Command{ + cmd.AnalyzeCommand, + cmd.TraceCommand, + } + err := app.RunContext(context.Background(), os.Args) + if err != nil { + log.Fatal(err) + } +} diff --git a/renderer/json.go b/renderer/json.go index 26642f9..66e17e3 100644 --- a/renderer/json.go +++ b/renderer/json.go @@ -5,7 +5,7 @@ import ( "encoding/json" "io" - "github.com/ChainSafe/vm-compat/analyser" + "github.com/ChainSafe/vm-compat/analyzer" ) // JSONRenderer renders issues in JSON format. @@ -15,7 +15,7 @@ func NewJSONRenderer() Renderer { return &JSONRenderer{} } -func (r *JSONRenderer) Render(issues []*analyser.Issue, output io.Writer) error { +func (r *JSONRenderer) Render(issues []*analyzer.Issue, output io.Writer) error { return json.NewEncoder(output).Encode(issues) } diff --git a/renderer/renderer.go b/renderer/renderer.go index 467a74f..89b6014 100644 --- a/renderer/renderer.go +++ b/renderer/renderer.go @@ -4,13 +4,13 @@ package renderer import ( "io" - "github.com/ChainSafe/vm-compat/analyser" + "github.com/ChainSafe/vm-compat/analyzer" ) // Renderer defines the interface for rendering lint results in different formats. type Renderer interface { // Render takes a list of issues and outputs them in the desired format to the provided writer. - Render(issues []*analyser.Issue, output io.Writer) error + Render(issues []*analyzer.Issue, output io.Writer) error // Format returns the name of the output format (e.g., "json", "text", "html"). Format() string diff --git a/renderer/text.go b/renderer/text.go index ff42317..00f0c3f 100644 --- a/renderer/text.go +++ b/renderer/text.go @@ -9,7 +9,7 @@ import ( "strings" "time" - "github.com/ChainSafe/vm-compat/analyser" + "github.com/ChainSafe/vm-compat/analyzer" "github.com/ChainSafe/vm-compat/profile" ) @@ -24,7 +24,7 @@ func NewTextRenderer(profile *profile.VMProfile) Renderer { } // Render formats and writes the analysis report to the command line. -func (r *TextRenderer) Render(issues []*analyser.Issue, output io.Writer) error { +func (r *TextRenderer) Render(issues []*analyzer.Issue, output io.Writer) error { if len(issues) == 0 { return nil } @@ -32,7 +32,7 @@ func (r *TextRenderer) Render(issues []*analyser.Issue, output io.Writer) error timestamp := time.Now().Format("2006-01-02 15:04:05 UTC") // Group issues by message - groupedIssues := make(map[string][]*analyser.Issue) + groupedIssues := make(map[string][]*analyzer.Issue) for _, issue := range issues { groupedIssues[issue.Message] = append(groupedIssues[issue.Message], issue) } @@ -42,7 +42,7 @@ func (r *TextRenderer) Render(issues []*analyser.Issue, output io.Writer) error numOfCriticalIssues := 0 var sortedMessages = make([]string, 0, len(groupedIssues)) for msg, val := range groupedIssues { - if val[0].Severity == analyser.IssueSeverityCritical { + if val[0].Severity == analyzer.IssueSeverityCritical { numOfCriticalIssues++ } sortedMessages = append(sortedMessages, msg) @@ -78,18 +78,8 @@ func (r *TextRenderer) Render(issues []*analyser.Issue, output io.Writer) error report.WriteString(fmt.Sprintf("%d. [%s] %s\n", issueCounter, groupedIssue[0].Severity, msg)) report.WriteString(" - Sources:") - count := 0 for _, issue := range groupedIssue { - for _, source := range issue.Sources { - count++ - report.WriteString(fmt.Sprintf("%s\n", buildCallStack(output, source, "", 5))) - if count == 2 { - break - } - } - if count == 2 { - break - } + report.WriteString(fmt.Sprintf("%s\n", buildCallStack(output, issue.Sources, ""))) } issueCounter++ } @@ -106,7 +96,7 @@ func (r *TextRenderer) Render(issues []*analyser.Issue, output io.Writer) error return err } -func buildCallStack(output io.Writer, source *analyser.IssueSource, str string, depth int) string { +func buildCallStack(output io.Writer, source *analyzer.IssueSource, str string) string { var fileInfo string if output == os.Stdout { fileInfo = fmt.Sprintf( @@ -121,9 +111,9 @@ func buildCallStack(output io.Writer, source *analyser.IssueSource, str string, []string{ str, fmt.Sprintf("-> %s : (%s)", fileInfo, source.Function)}, - fmt.Sprintf("\n%s", strings.Repeat(" ", depth))) + "\n ") if source.CallStack != nil { - return buildCallStack(output, source.CallStack, str, depth+1) + return buildCallStack(output, source.CallStack, str) } return str }