diff options
| author | Aleksandr Nogikh <nogikh@google.com> | 2024-10-03 17:43:17 +0200 |
|---|---|---|
| committer | Aleksandr Nogikh <nogikh@google.com> | 2024-10-11 11:58:06 +0000 |
| commit | d041766f6c33a373e0064b832cc4e7a4401b3659 (patch) | |
| tree | ba008da309760c5e88859ada245225d01769c15c | |
| parent | 5e7b4bcaa61e8bb9b1d1fbca21684fe490f69133 (diff) | |
pkg/manager: factor out the crash storage functionality
It reduces the size of the syz-manager/ code and makes it testable.
Use it in syz-testbed.
| -rw-r--r-- | pkg/manager/crash.go | 304 | ||||
| -rw-r--r-- | pkg/manager/crash_test.go | 106 | ||||
| -rw-r--r-- | tools/syz-reporter/reporter.go | 1 | ||||
| -rw-r--r-- | tools/syz-testbed/stats.go | 32 |
4 files changed, 418 insertions, 25 deletions
diff --git a/pkg/manager/crash.go b/pkg/manager/crash.go new file mode 100644 index 000000000..9be16e58e --- /dev/null +++ b/pkg/manager/crash.go @@ -0,0 +1,304 @@ +// Copyright 2024 syzkaller project authors. All rights reserved. +// Use of this source code is governed by Apache 2 LICENSE that can be found in the LICENSE file. + +package manager + +import ( + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "time" + + "github.com/google/syzkaller/pkg/hash" + "github.com/google/syzkaller/pkg/mgrconfig" + "github.com/google/syzkaller/pkg/osutil" + "github.com/google/syzkaller/prog" +) + +type CrashStore struct { + Tag string + BaseDir string + MaxCrashLogs int + MaxReproLogs int +} + +const reproFileName = "repro.prog" +const cReproFileName = "repro.cprog" +const straceFileName = "strace.log" + +const MaxReproAttempts = 3 + +func NewCrashStore(cfg *mgrconfig.Config) *CrashStore { + return &CrashStore{ + Tag: cfg.Tag, + BaseDir: cfg.Workdir, + MaxCrashLogs: cfg.MaxCrashLogs, + MaxReproLogs: MaxReproAttempts, + } +} + +func ReadCrashStore(workdir string) *CrashStore { + return &CrashStore{ + BaseDir: workdir, + } +} + +// Returns whether it was the first crash of a kind. +func (cs *CrashStore) SaveCrash(crash *Crash) (bool, error) { + dir := cs.path(crash.Title) + osutil.MkdirAll(dir) + + err := osutil.WriteFile(filepath.Join(dir, "description"), []byte(crash.Title+"\n")) + if err != nil { + return false, fmt.Errorf("failed to write crash: %w", err) + } + + // Save up to cs.cfg.MaxCrashLogs reports, overwrite the oldest once we've reached that number. + // Newer reports are generally more useful. Overwriting is also needed + // to be able to understand if a particular bug still happens or already fixed. + oldestI, first := 0, false + var oldestTime time.Time + for i := 0; i < cs.MaxCrashLogs; i++ { + info, err := os.Stat(filepath.Join(dir, fmt.Sprintf("log%v", i))) + if err != nil { + oldestI = i + if i == 0 { + first = true + } + break + } + if oldestTime.IsZero() || info.ModTime().Before(oldestTime) { + oldestI = i + oldestTime = info.ModTime() + } + } + writeOrRemove := func(name string, data []byte) { + filename := filepath.Join(dir, name+fmt.Sprint(oldestI)) + if len(data) == 0 { + os.Remove(filename) + return + } + osutil.WriteFile(filename, data) + } + writeOrRemove("log", crash.Output) + writeOrRemove("tag", []byte(cs.Tag)) + writeOrRemove("report", crash.Report.Report) + writeOrRemove("machineInfo", crash.MachineInfo) + + return first, nil +} + +func (cs *CrashStore) HasRepro(title string) bool { + return osutil.IsExist(filepath.Join(cs.path(title), reproFileName)) +} + +func (cs *CrashStore) MoreReproAttempts(title string) bool { + dir := cs.path(title) + for i := 0; i < cs.MaxReproLogs; i++ { + if !osutil.IsExist(filepath.Join(dir, fmt.Sprintf("repro%v", i))) { + return true + } + } + return false +} + +func (cs *CrashStore) SaveFailedRepro(title string, log []byte) error { + dir := cs.path(title) + osutil.MkdirAll(dir) + for i := 0; i < cs.MaxReproLogs; i++ { + name := filepath.Join(dir, fmt.Sprintf("repro%v", i)) + if !osutil.IsExist(name) && len(log) > 0 { + err := osutil.WriteFile(name, log) + if err != nil { + return err + } + break + } + } + return nil +} + +func (cs *CrashStore) SaveRepro(res *ReproResult, progText, cProgText []byte) error { + repro := res.Repro + rep := repro.Report + dir := cs.path(rep.Title) + osutil.MkdirAll(dir) + + err := osutil.WriteFile(filepath.Join(dir, "description"), []byte(rep.Title+"\n")) + if err != nil { + return fmt.Errorf("failed to write crash: %w", err) + } + // TODO: detect and handle errors below as well. + osutil.WriteFile(filepath.Join(dir, reproFileName), progText) + if cs.Tag != "" { + osutil.WriteFile(filepath.Join(dir, "repro.tag"), []byte(cs.Tag)) + } + if len(rep.Output) > 0 { + osutil.WriteFile(filepath.Join(dir, "repro.log"), rep.Output) + } + if len(rep.Report) > 0 { + osutil.WriteFile(filepath.Join(dir, "repro.report"), rep.Report) + } + if len(cProgText) > 0 { + osutil.WriteFile(filepath.Join(dir, cReproFileName), cProgText) + } + var assetErr error + repro.Prog.ForEachAsset(func(name string, typ prog.AssetType, r io.Reader) { + fileName := filepath.Join(dir, name+".gz") + if err := osutil.WriteGzipStream(fileName, r); err != nil { + assetErr = fmt.Errorf("failed to write crash asset: type %d, %w", typ, err) + } + }) + if assetErr != nil { + return assetErr + } + if res.Strace != nil { + // Unlike dashboard reporting, we save strace output separately from the original log. + if res.Strace.Error != nil { + osutil.WriteFile(filepath.Join(dir, "strace.error"), + []byte(fmt.Sprintf("%v", res.Strace.Error))) + } + if len(res.Strace.Output) > 0 { + osutil.WriteFile(filepath.Join(dir, straceFileName), res.Strace.Output) + } + } + if reproLog := res.Stats.FullLog(); len(reproLog) > 0 { + osutil.WriteFile(filepath.Join(dir, "repro.stats"), reproLog) + } + return nil +} + +type BugReport struct { + Title string + Tag string + Prog []byte + CProg []byte + Report []byte +} + +func (cs *CrashStore) Report(id string) (*BugReport, error) { + dir := filepath.Join(cs.BaseDir, "crashes", id) + desc, err := os.ReadFile(filepath.Join(dir, "description")) + if err != nil { + return nil, err + } + tag, _ := os.ReadFile(filepath.Join(dir, "repro.tag")) + ret := &BugReport{ + Title: strings.TrimSpace(string(desc)), + Tag: strings.TrimSpace(string(tag)), + } + ret.Prog, _ = os.ReadFile(filepath.Join(dir, reproFileName)) + ret.CProg, _ = os.ReadFile(filepath.Join(dir, cReproFileName)) + ret.Report, _ = os.ReadFile(filepath.Join(dir, "repro.report")) + return ret, nil +} + +type CrashInfo struct { + Index int + Log string // filename relative to the workdir + + // These fields are only set if full=true. + Tag string + Report string // filename relative to workdir + Time time.Time +} + +type BugInfo struct { + ID string + Title string + LastTime time.Time + HasRepro bool + HasCRepro bool + StraceFile string // relative to the workdir + ReproAttempts int + Crashes []*CrashInfo +} + +func (cs *CrashStore) BugInfo(id string, full bool) (*BugInfo, error) { + dir := filepath.Join(cs.BaseDir, "crashes", id) + + ret := &BugInfo{ID: id} + desc, err := os.ReadFile(filepath.Join(dir, "description")) + if err != nil { + return nil, err + } + stat, err := os.Stat(filepath.Join(dir, "description")) + if err != nil { + return nil, err + } + ret.Title = strings.TrimSpace(string(desc)) + ret.LastTime = stat.ModTime() + files, err := osutil.ListDir(dir) + if err != nil { + return nil, err + } + for _, f := range files { + if strings.HasPrefix(f, "log") { + index, err := strconv.ParseUint(f[3:], 10, 64) + if err == nil { + ret.Crashes = append(ret.Crashes, &CrashInfo{ + Index: int(index), + Log: filepath.Join("crashes", id, f), + }) + } + } else if f == reproFileName { + ret.HasRepro = true + } else if f == cReproFileName { + ret.HasCRepro = true + } else if f == straceFileName { + ret.StraceFile = filepath.Join(dir, f) + } else if strings.HasPrefix(f, "repro") { + ret.ReproAttempts++ + } + } + if !full { + return ret, nil + } + for _, crash := range ret.Crashes { + if stat, err := os.Stat(filepath.Join(cs.BaseDir, crash.Log)); err == nil { + crash.Time = stat.ModTime() + } + tag, _ := os.ReadFile(filepath.Join(dir, fmt.Sprintf("tag%d", crash.Index))) + crash.Tag = string(tag) + reportFile := filepath.Join("crashes", id, fmt.Sprintf("report%d", crash.Index)) + if osutil.IsExist(filepath.Join(cs.BaseDir, reportFile)) { + crash.Report = reportFile + } + } + sort.Slice(ret.Crashes, func(i, j int) bool { + return ret.Crashes[i].Time.After(ret.Crashes[j].Time) + }) + return ret, nil +} + +func (cs *CrashStore) BugList() ([]*BugInfo, error) { + dirs, err := osutil.ListDir(filepath.Join(cs.BaseDir, "crashes")) + if err != nil { + return nil, err + } + var ret []*BugInfo + for _, dir := range dirs { + info, err := cs.BugInfo(dir, false) + if err != nil { + return nil, fmt.Errorf("failed to process crashes/%s", dir) + } + ret = append(ret, info) + } + sort.Slice(ret, func(i, j int) bool { + return strings.ToLower(ret[i].Title) < strings.ToLower(ret[j].Title) + }) + return ret, nil +} + +func (cs *CrashStore) titleToID(title string) string { + sig := hash.Hash([]byte(title)) + return sig.String() +} + +func (cs *CrashStore) path(title string) string { + return filepath.Join(cs.BaseDir, "crashes", cs.titleToID(title)) +} diff --git a/pkg/manager/crash_test.go b/pkg/manager/crash_test.go new file mode 100644 index 000000000..cce74284a --- /dev/null +++ b/pkg/manager/crash_test.go @@ -0,0 +1,106 @@ +// Copyright 2024 syzkaller project authors. All rights reserved. +// Use of this source code is governed by Apache 2 LICENSE that can be found in the LICENSE file. + +package manager + +import ( + "testing" + + "github.com/google/syzkaller/pkg/report" + "github.com/google/syzkaller/pkg/repro" + "github.com/google/syzkaller/prog" + "github.com/stretchr/testify/assert" +) + +func TestCrashList(t *testing.T) { + crashStore := &CrashStore{ + BaseDir: t.TempDir(), + MaxCrashLogs: 10, + } + + first, err := crashStore.SaveCrash(&Crash{Report: &report.Report{ + Title: "Title A", + Output: []byte("ABCD"), + }}) + assert.NoError(t, err) + assert.True(t, first) + for i := 0; i < 2; i++ { + first, err := crashStore.SaveCrash(&Crash{Report: &report.Report{ + Title: "Title B", + Output: []byte("ABCD"), + }}) + assert.NoError(t, err) + assert.Equal(t, i == 0, first) + } + for i := 0; i < 3; i++ { + first, err := crashStore.SaveCrash(&Crash{Report: &report.Report{ + Title: "Title C", + Output: []byte("ABCD"), + }}) + assert.NoError(t, err) + assert.Equal(t, i == 0, first) + } + + list, err := crashStore.BugList() + assert.NoError(t, err) + assert.Len(t, list, 3) + + assert.Equal(t, "Title A", list[0].Title) + assert.Len(t, list[0].Crashes, 1) + assert.Equal(t, "Title B", list[1].Title) + assert.Len(t, list[1].Crashes, 2) + assert.Equal(t, "Title C", list[2].Title) + assert.Len(t, list[2].Crashes, 3) +} + +func TestMaxCrashLogs(t *testing.T) { + crashStore := &CrashStore{ + BaseDir: t.TempDir(), + MaxCrashLogs: 5, + } + + for i := 0; i < 20; i++ { + _, err := crashStore.SaveCrash(&Crash{Report: &report.Report{ + Title: "Title A", + Output: []byte("ABCD"), + }}) + assert.NoError(t, err) + } + + info, err := crashStore.BugInfo(crashStore.titleToID("Title A"), false) + assert.NoError(t, err) + assert.Len(t, info.Crashes, 5) +} + +func TestCrashRepro(t *testing.T) { + crashStore := &CrashStore{ + Tag: "abcd", + BaseDir: t.TempDir(), + MaxCrashLogs: 5, + } + + _, err := crashStore.SaveCrash(&Crash{Report: &report.Report{ + Title: "Some title", + Output: []byte("Some output"), + }}) + assert.NoError(t, err) + + err = crashStore.SaveRepro(&ReproResult{ + Repro: &repro.Result{ + Report: &report.Report{ + Title: "Some title", + Report: []byte("Some report"), + }, + Prog: &prog.Prog{}, + }, + }, []byte("prog text"), []byte("c prog text")) + assert.NoError(t, err) + + report, err := crashStore.Report(crashStore.titleToID("Some title")) + assert.NoError(t, err) + assert.Equal(t, "Some title", report.Title) + assert.Equal(t, "abcd", report.Tag) + assert.Equal(t, []byte("prog text"), report.Prog) + assert.Equal(t, []byte("c prog text"), report.CProg) + assert.Equal(t, []byte("Some report"), report.Report) +} diff --git a/tools/syz-reporter/reporter.go b/tools/syz-reporter/reporter.go index 6fa4adc47..53ccce4c3 100644 --- a/tools/syz-reporter/reporter.go +++ b/tools/syz-reporter/reporter.go @@ -129,6 +129,7 @@ func collectCrashes(workdir string) ([]*UICrashType, error) { return crashTypes, nil } +// TODO: reuse manager.CrashStore. func readCrash(workdir, dir string) *UICrashType { if len(dir) != 40 { return nil diff --git a/tools/syz-testbed/stats.go b/tools/syz-testbed/stats.go index 185156a5d..6044bfe3d 100644 --- a/tools/syz-testbed/stats.go +++ b/tools/syz-testbed/stats.go @@ -8,10 +8,9 @@ import ( "fmt" "os" "path/filepath" - "strings" "time" - "github.com/google/syzkaller/pkg/osutil" + "github.com/google/syzkaller/pkg/manager" "github.com/google/syzkaller/pkg/stat/sample" ) @@ -51,33 +50,16 @@ type StatView struct { Groups []RunResultGroup } -// TODO: we're implementing this functionaity at least the 3rd time (see syz-manager/html -// and tools/reporter). Create a more generic implementation and put it into a globally -// visible package. func collectBugs(workdir string) ([]BugInfo, error) { - crashdir := filepath.Join(workdir, "crashes") - dirs, err := osutil.ListDir(crashdir) + list, err := manager.ReadCrashStore(workdir).BugList() if err != nil { return nil, err } - bugs := []BugInfo{} - for _, dir := range dirs { - bugFolder := filepath.Join(crashdir, dir) - titleBytes, err := os.ReadFile(filepath.Join(bugFolder, "description")) - if err != nil { - return nil, err - } - bug := BugInfo{ - Title: strings.TrimSpace(string(titleBytes)), - } - files, err := os.ReadDir(bugFolder) - if err != nil { - return nil, err - } - for _, f := range files { - if strings.HasPrefix(f.Name(), "log") { - bug.Logs = append(bug.Logs, filepath.Join(bugFolder, f.Name())) - } + var bugs []BugInfo + for _, info := range list { + bug := BugInfo{Title: info.Title} + for _, crash := range info.Crashes { + bug.Logs = append(bug.Logs, filepath.Join(workdir, crash.Log)) } bugs = append(bugs, bug) } |
