github.com/google/syzkaller@v0.0.0-20251211124644-a066d2bc4b02/pkg/cover/report_test.go (about)

     1  // Copyright 2020 syzkaller project authors. All rights reserved.
     2  // Use of this source code is governed by Apache 2 LICENSE that can be found in the LICENSE file.
     3  
     4  // It may or may not work on other OSes.
     5  // If you test on another OS and it works, enable it.
     6  //go:build linux
     7  
     8  package cover
     9  
    10  import (
    11  	"bytes"
    12  	"encoding/csv"
    13  	"encoding/json"
    14  	"errors"
    15  	"fmt"
    16  	"os"
    17  	"path/filepath"
    18  	"reflect"
    19  	"regexp"
    20  	"runtime"
    21  	"slices"
    22  	"strconv"
    23  	"strings"
    24  	"testing"
    25  	"time"
    26  
    27  	"github.com/google/syzkaller/pkg/cover/backend"
    28  	"github.com/google/syzkaller/pkg/mgrconfig"
    29  	"github.com/google/syzkaller/pkg/osutil"
    30  	"github.com/google/syzkaller/pkg/symbolizer"
    31  	_ "github.com/google/syzkaller/sys"
    32  	"github.com/google/syzkaller/sys/targets"
    33  	"github.com/stretchr/testify/assert"
    34  )
    35  
    36  type Test struct {
    37  	Name      string
    38  	CFlags    []string
    39  	LDFlags   []string
    40  	Progs     []Prog
    41  	DebugInfo bool
    42  	AddCover  bool
    43  	AddBadPc  bool
    44  	// Set to true if the test should be skipped under broken kcov.
    45  	SkipIfKcovIsBroken bool
    46  	// Inexact coverage generated by AddCover=true may override empty Result.
    47  	Result   string
    48  	Supports func(target *targets.Target) bool
    49  }
    50  
    51  func TestReportGenerator(t *testing.T) {
    52  	tests := []Test{
    53  		{
    54  			Name:      "no-coverage",
    55  			DebugInfo: true,
    56  			AddCover:  true,
    57  			Result:    `.* doesn't contain coverage callbacks \(set CONFIG_KCOV=y on linux\)`,
    58  		},
    59  		{
    60  			Name:     "no-debug-info",
    61  			CFlags:   []string{"-fsanitize-coverage=trace-pc"},
    62  			AddCover: true,
    63  			Result:   `failed to parse DWARF.*\(set CONFIG_DEBUG_INFO=y on linux\)`,
    64  		},
    65  		{
    66  			Name:      "no-pcs",
    67  			CFlags:    []string{"-fsanitize-coverage=trace-pc"},
    68  			DebugInfo: true,
    69  			Result:    `no coverage collected so far`,
    70  		},
    71  		{
    72  			Name:      "bad-pcs",
    73  			CFlags:    []string{"-fsanitize-coverage=trace-pc"},
    74  			DebugInfo: true,
    75  			Progs:     []Prog{{Data: "data", PCs: []uint64{0x1, 0x2}}},
    76  			Result:    `coverage doesn't match any coverage callbacks`,
    77  		},
    78  		{
    79  			Name:      "good",
    80  			AddCover:  true,
    81  			CFlags:    []string{"-fsanitize-coverage=trace-pc"},
    82  			DebugInfo: true,
    83  		},
    84  		{
    85  			Name:               "mismatch-pcs",
    86  			AddCover:           true,
    87  			AddBadPc:           true,
    88  			CFlags:             []string{"-fsanitize-coverage=trace-pc"},
    89  			DebugInfo:          true,
    90  			SkipIfKcovIsBroken: true,
    91  			Result:             `.* do not have matching coverage callbacks`,
    92  		},
    93  		{
    94  			Name:      "good-pie",
    95  			AddCover:  true,
    96  			CFlags:    []string{"-fsanitize-coverage=trace-pc", "-fpie"},
    97  			LDFlags:   []string{"-pie", "-Wl,--section-start=.text=0x33300000"},
    98  			DebugInfo: true,
    99  			Supports: func(target *targets.Target) bool {
   100  				return target.OS == targets.Fuchsia ||
   101  					// Fails with "relocation truncated to fit: R_AARCH64_CALL26 against symbol `memcpy'".
   102  					target.OS == targets.Linux && target.Arch != targets.ARM64
   103  			},
   104  		},
   105  		{
   106  			Name:     "good-pie-relocs",
   107  			AddCover: true,
   108  			// This produces a binary that resembles CONFIG_RANDOMIZE_BASE=y.
   109  			// Symbols and .text section has addresses around 0x33300000,
   110  			// but debug info has all PC ranges around 0 address.
   111  			CFlags:    []string{"-fsanitize-coverage=trace-pc", "-fpie"},
   112  			LDFlags:   []string{"-pie", "-Wl,--section-start=.text=0x33300000,--emit-relocs"},
   113  			DebugInfo: true,
   114  			Supports: func(target *targets.Target) bool {
   115  				if target.OS == targets.Fuchsia {
   116  					return true
   117  				}
   118  				if target.OS == targets.Linux {
   119  					if target.Arch == targets.RiscV64 {
   120  						// When the binary is compiled with gcc and parsed with
   121  						// llvm-addr2line, we get an invalid "func_name", which
   122  						// breaks our tests.
   123  						fmt.Printf("target.CCompiler=%s", target.CCompiler)
   124  						return target.CCompiler == "clang"
   125  					}
   126  					if target.Arch == targets.ARM64 || target.Arch == targets.ARM ||
   127  						target.Arch == targets.I386 {
   128  						return false
   129  					}
   130  					return true
   131  				}
   132  				return false
   133  			},
   134  		},
   135  	}
   136  	t.Parallel()
   137  	for os, arches := range targets.List {
   138  		if os == targets.TestOS {
   139  			continue
   140  		}
   141  		for _, target := range arches {
   142  			target := targets.Get(target.OS, target.Arch)
   143  			if target.BuildOS != runtime.GOOS {
   144  				continue
   145  			}
   146  			t.Run(target.OS+"-"+target.Arch, func(t *testing.T) {
   147  				t.Parallel()
   148  				if target.BrokenCompiler != "" {
   149  					t.Skip("skipping the test due to broken cross-compiler:\n" + target.BrokenCompiler)
   150  				}
   151  				for _, test := range tests {
   152  					t.Run(test.Name, func(t *testing.T) {
   153  						if test.Supports != nil && !test.Supports(target) {
   154  							t.Skip("unsupported target")
   155  						}
   156  						t.Parallel()
   157  						testReportGenerator(t, target, test)
   158  					})
   159  				}
   160  			})
   161  		}
   162  	}
   163  }
   164  
   165  func testReportGenerator(t *testing.T, target *targets.Target, test Test) {
   166  	reps, err := generateReport(t, target, &test)
   167  	if err != nil {
   168  		if test.Result == "" {
   169  			t.Fatalf("expected no error, but got:\n%v", err)
   170  		}
   171  		if !regexp.MustCompile(test.Result).MatchString(err.Error()) {
   172  			t.Fatalf("expected error %q, but got:\n%v", test.Result, err)
   173  		}
   174  		return
   175  	}
   176  	if test.Result != "" {
   177  		t.Fatalf("got no error, but expected %q", test.Result)
   178  	}
   179  	checkCSVReport(t, reps.csv.Bytes())
   180  	checkJSONLReport(t, reps.jsonl.Bytes(), sampleCoverJSON)
   181  	checkJSONLReport(t, reps.jsonlPrograms.Bytes(), sampleJSONLlProgs)
   182  }
   183  
   184  const kcovCode = `
   185  #ifdef ASLR_BASE
   186  #define _GNU_SOURCE
   187  #endif
   188  
   189  #include <stdio.h>
   190  
   191  #ifdef ASLR_BASE
   192  #include <dlfcn.h>
   193  #include <link.h>
   194  #include <stddef.h>
   195  
   196  void* aslr_base() {
   197         struct link_map* map = NULL;
   198         void* handle = dlopen(NULL, RTLD_LAZY | RTLD_NOLOAD);
   199         if (handle != NULL) {
   200                dlinfo(handle, RTLD_DI_LINKMAP, &map);
   201                dlclose(handle);
   202         }
   203         return map ? (void *)map->l_addr : NULL;
   204  }
   205  #else
   206  void* aslr_base() { return NULL; }
   207  #endif
   208  
   209  void __sanitizer_cov_trace_pc() { printf("%llu", (long long)(__builtin_return_address(0) - aslr_base())); }
   210  `
   211  
   212  func buildTestBinary(t *testing.T, target *targets.Target, test *Test, dir string) string {
   213  	kcovSrc := filepath.Join(dir, "kcov.c")
   214  	kcovObj := filepath.Join(dir, "kcov.o")
   215  	if err := osutil.WriteFile(kcovSrc, []byte(kcovCode)); err != nil {
   216  		t.Fatal(err)
   217  	}
   218  
   219  	aslrDefine := "-DNO_ASLR_BASE"
   220  	if target.OS == targets.Linux || target.OS == targets.OpenBSD ||
   221  		target.OS == targets.FreeBSD || target.OS == targets.NetBSD {
   222  		aslrDefine = "-DASLR_BASE"
   223  	}
   224  	aslrExtraLibs := []string{}
   225  	if target.OS == targets.Linux {
   226  		aslrExtraLibs = []string{"-ldl"}
   227  	}
   228  
   229  	targetCFlags := slices.DeleteFunc(slices.Clone(target.CFlags), func(flag string) bool {
   230  		return strings.HasPrefix(flag, "-std=c++")
   231  	})
   232  	kcovFlags := append([]string{"-c", "-fpie", "-w", "-x", "c", "-o", kcovObj, kcovSrc, aslrDefine}, targetCFlags...)
   233  	src := filepath.Join(dir, "main.c")
   234  	obj := filepath.Join(dir, "main.o")
   235  	bin := filepath.Join(dir, target.KernelObject)
   236  	if err := osutil.WriteFile(src, []byte(`int main() {}`)); err != nil {
   237  		t.Fatal(err)
   238  	}
   239  	if _, err := osutil.RunCmd(time.Hour, "", target.CCompiler, kcovFlags...); err != nil {
   240  		t.Fatal(err)
   241  	}
   242  
   243  	// We used to compile and link with a single compiler invocation,
   244  	// but clang has a bug that it tries to link in ubsan runtime when
   245  	// -fsanitize-coverage=trace-pc is provided during linking and
   246  	// ubsan runtime is missing for arm/arm64/riscv arches in the llvm packages.
   247  	// So we first compile with -fsanitize-coverage and then link w/o it.
   248  	cflags := append(append([]string{"-w", "-c", "-o", obj, src}, targetCFlags...), test.CFlags...)
   249  	if test.DebugInfo {
   250  		// TODO: pkg/cover doesn't support DWARF5 yet, which is the default in Clang.
   251  		cflags = append([]string{"-g", "-gdwarf-4"}, cflags...)
   252  	}
   253  	if _, err := osutil.RunCmd(time.Hour, "", target.CCompiler, cflags...); err != nil {
   254  		errText := err.Error()
   255  		errText = strings.ReplaceAll(errText, "‘", "'")
   256  		errText = strings.ReplaceAll(errText, "’", "'")
   257  		if strings.Contains(errText, "error: unrecognized command line option '-fsanitize-coverage=trace-pc'") &&
   258  			os.Getenv("SYZ_ENV") == "" {
   259  			t.Skip("skipping test, -fsanitize-coverage=trace-pc is not supported")
   260  		}
   261  		t.Fatal(err)
   262  	}
   263  
   264  	ldflags := append(append(append([]string{"-o", bin, obj, kcovObj}, aslrExtraLibs...),
   265  		targetCFlags...), test.LDFlags...)
   266  	staticIdx, pieIdx := -1, -1
   267  	for i, arg := range ldflags {
   268  		switch arg {
   269  		case "-static":
   270  			staticIdx = i
   271  		case "-pie":
   272  			pieIdx = i
   273  		}
   274  	}
   275  	if target.OS == targets.Fuchsia && pieIdx != -1 {
   276  		// Fuchsia toolchain fails when given -pie:
   277  		// clang-12: error: argument unused during compilation: '-pie'
   278  		ldflags[pieIdx] = ldflags[len(ldflags)-1]
   279  		ldflags = ldflags[:len(ldflags)-1]
   280  	} else if pieIdx != -1 && staticIdx != -1 {
   281  		// -static and -pie are incompatible during linking.
   282  		ldflags[staticIdx] = ldflags[len(ldflags)-1]
   283  		ldflags = ldflags[:len(ldflags)-1]
   284  	}
   285  	if _, err := osutil.RunCmd(time.Hour, "", target.CCompiler, ldflags...); err != nil {
   286  		// Arm linker in the env image has a bug when linking a clang-produced files.
   287  		var vErr *osutil.VerboseError
   288  		if errors.As(err, &vErr) && regexp.MustCompile(`arm-linux-gnueabi.* assertion fail`).Match(vErr.Output) {
   289  			t.Skipf("skipping test, broken arm linker (%v)", err)
   290  		}
   291  		t.Fatal(err)
   292  	}
   293  	return bin
   294  }
   295  
   296  type reports struct {
   297  	csv           *bytes.Buffer
   298  	jsonl         *bytes.Buffer
   299  	jsonlPrograms *bytes.Buffer
   300  }
   301  
   302  func generateReport(t *testing.T, target *targets.Target, test *Test) (*reports, error) {
   303  	dir := t.TempDir()
   304  	bin := buildTestBinary(t, target, test, dir)
   305  	cfg := &mgrconfig.Config{
   306  		Derived: mgrconfig.Derived{
   307  			SysTarget: target,
   308  		},
   309  		KernelObj:      dir,
   310  		KernelSrc:      dir,
   311  		KernelBuildSrc: dir,
   312  		Type:           "",
   313  	}
   314  	cfg.KernelSubsystem = []mgrconfig.Subsystem{
   315  		{
   316  			Name: "sound",
   317  			Paths: []string{
   318  				"sound",
   319  				"techpack/audio",
   320  			},
   321  		},
   322  	}
   323  	modules, err := backend.DiscoverModules(cfg.SysTarget, cfg.KernelObj, cfg.ModuleObj)
   324  	if err != nil {
   325  		return nil, err
   326  	}
   327  
   328  	// Deep copy, as we are going to modify progs. Our test generate multiple reports from the same
   329  	// test object in parallel. Without copying we have a datarace here.
   330  	progs := []Prog{}
   331  	for _, p := range test.Progs {
   332  		progs = append(progs, Prog{Sig: p.Sig, Data: p.Data, PCs: append([]uint64{}, p.PCs...)})
   333  	}
   334  
   335  	rg, err := MakeReportGenerator(cfg, modules)
   336  	if err != nil {
   337  		return nil, err
   338  	}
   339  	if !rg.PreciseCoverage && test.SkipIfKcovIsBroken {
   340  		t.Skip("coverage testing requested, but kcov is broken")
   341  	}
   342  	if test.AddCover {
   343  		var pcs []uint64
   344  		Inexact := false
   345  		// Sanitizers crash when installing signal handlers with static libc.
   346  		const sanitizerOptions = "handle_segv=0:handle_sigbus=0:handle_sigfpe=0"
   347  		cmd := osutil.Command(bin)
   348  		cmd.Env = append([]string{
   349  			"UBSAN_OPTIONS=" + sanitizerOptions,
   350  			"ASAN_OPTIONS=" + sanitizerOptions,
   351  		}, os.Environ()...)
   352  		if output, err := osutil.Run(time.Minute, cmd); err == nil {
   353  			pc, err := strconv.ParseUint(string(output), 10, 64)
   354  			if err != nil {
   355  				t.Fatal(err)
   356  			}
   357  			pcs = append(pcs, backend.PreviousInstructionPC(target, "", pc))
   358  			t.Logf("using exact coverage PC 0x%x", pcs[0])
   359  		} else if target.OS == runtime.GOOS && (target.Arch == runtime.GOARCH || target.VMArch == runtime.GOARCH) {
   360  			t.Fatal(err)
   361  		} else {
   362  			text, err := symbolizer.ReadTextSymbols(bin)
   363  			if err != nil {
   364  				t.Fatal(err)
   365  			}
   366  			if nmain := len(text["main"]); nmain != 1 {
   367  				t.Fatalf("got %v main symbols", nmain)
   368  			}
   369  			main := text["main"][0]
   370  			for off := 0; off < main.Size; off++ {
   371  				pcs = append(pcs, main.Addr+uint64(off))
   372  			}
   373  			t.Logf("using inexact coverage range 0x%x-0x%x", main.Addr, main.Addr+uint64(main.Size))
   374  			Inexact = true
   375  		}
   376  		if Inexact && test.Result == "" && rg.PreciseCoverage {
   377  			test.Result = fmt.Sprintf("%d out of %d PCs returned by kcov do not have matching coverage callbacks",
   378  				len(pcs)-1, len(pcs))
   379  		}
   380  		if test.AddBadPc {
   381  			pcs = append(pcs, 0xdeadbeef)
   382  		}
   383  		progs = append(progs, Prog{Data: "main", PCs: pcs})
   384  	}
   385  	params := HandlerParams{
   386  		Progs: progs,
   387  	}
   388  	if err := rg.DoHTML(new(bytes.Buffer), params); err != nil {
   389  		return nil, err
   390  	}
   391  	assert.NoError(t, rg.DoSubsystemCover(new(bytes.Buffer), params))
   392  	assert.NoError(t, rg.DoFileCover(new(bytes.Buffer), params))
   393  	res := &reports{
   394  		csv:           new(bytes.Buffer),
   395  		jsonl:         new(bytes.Buffer),
   396  		jsonlPrograms: new(bytes.Buffer),
   397  	}
   398  	assert.NoError(t, rg.DoFuncCover(res.csv, params))
   399  	assert.NoError(t, rg.DoCoverJSONL(res.jsonl, params))
   400  	assert.NoError(t, rg.DoCoverPrograms(res.jsonlPrograms, params))
   401  	return res, nil
   402  }
   403  
   404  func checkCSVReport(t *testing.T, CSVReport []byte) {
   405  	csvReader := csv.NewReader(bytes.NewBuffer(CSVReport))
   406  	lines, err := csvReader.ReadAll()
   407  	if err != nil {
   408  		t.Fatal(err)
   409  	}
   410  
   411  	if !reflect.DeepEqual(lines[0], csvHeader) {
   412  		t.Fatalf("heading line in CSV doesn't match %v", lines[0])
   413  	}
   414  
   415  	foundMain := false
   416  	for _, line := range lines {
   417  		if line[2] == "main" {
   418  			foundMain = true
   419  			if line[3] != "1" && line[4] != "1" {
   420  				t.Fatalf("function coverage percentage doesn't match %v vs. %v", line[3], "100")
   421  			}
   422  		}
   423  	}
   424  	if !foundMain {
   425  		t.Fatalf("no main in the CSV report")
   426  	}
   427  }
   428  
   429  func checkJSONLReport(t *testing.T, gotBytes, wantBytes []byte) {
   430  	compacted := new(bytes.Buffer)
   431  	if err := json.Compact(compacted, wantBytes); err != nil {
   432  		t.Errorf("failed to prepare compacted json: %v", err)
   433  	}
   434  	compacted.Write([]byte("\n"))
   435  
   436  	// PC is hard to predict here. Let's fix it.
   437  	actualString := regexp.MustCompile(`"pc":[0-9]*`).ReplaceAllString(
   438  		string(gotBytes), `"pc":12345`)
   439  	assert.Equal(t, compacted.String(), actualString)
   440  }
   441  
   442  var sampleJSONLlProgs = []byte(`{
   443  	"program": "main",
   444  	"coverage": [
   445  		{
   446  			"file_path": "main.c",
   447  			"functions": [
   448  				{
   449  					"func_name": "main",
   450  					"blocks": [
   451  						{
   452  							"hit_count": 1,
   453  							"from_line": 1,
   454  							"from_column": 0,
   455  							"to_line": 1,
   456  							"to_column": -1
   457  						}
   458  					]
   459  				}
   460  			]
   461  		}
   462  	]
   463  }`)
   464  
   465  func makeFileStat(name string) fileStats {
   466  	return fileStats{
   467  		Name:                       name,
   468  		CoveredLines:               1,
   469  		TotalLines:                 8,
   470  		CoveredPCs:                 1,
   471  		TotalPCs:                   4,
   472  		TotalFunctions:             2,
   473  		CoveredFunctions:           1,
   474  		CoveredPCsInFunctions:      1,
   475  		TotalPCsInCoveredFunctions: 2,
   476  		TotalPCsInFunctions:        2,
   477  	}
   478  }
   479  
   480  func TestCoverByFilePrefixes(t *testing.T) {
   481  	datas := []fileStats{
   482  		makeFileStat("a"),
   483  		makeFileStat("a/1"),
   484  		makeFileStat("a/2"),
   485  		makeFileStat("a/2/A"),
   486  		makeFileStat("a/3"),
   487  	}
   488  	subsystems := []mgrconfig.Subsystem{
   489  		{
   490  			Name: "test",
   491  			Paths: []string{
   492  				"a",
   493  				"-a/2",
   494  			},
   495  		},
   496  	}
   497  	d := groupCoverByFilePrefixes(datas, subsystems)
   498  	assert.Equal(t, d["test"], map[string]string{
   499  		"name":              "test",
   500  		"lines":             "3 / 24 / 12.50%",
   501  		"PCsInFiles":        "3 / 12 / 25.00%",
   502  		"Funcs":             "3 / 6 / 50.00%",
   503  		"PCsInFuncs":        "3 / 6 / 50.00%",
   504  		"PCsInCoveredFuncs": "3 / 6 / 50.00%",
   505  	})
   506  }