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 }