| // Copyright 2026 The Dawn & Tint Authors |
| // |
| // Redistribution and use in source and binary forms, with or without |
| // modification, are permitted provided that the following conditions are met: |
| // |
| // 1. Redistributions of source code must retain the above copyright notice, this |
| // list of conditions and the following disclaimer. |
| // |
| // 2. Redistributions in binary form must reproduce the above copyright notice, |
| // this list of conditions and the following disclaimer in the documentation |
| // and/or other materials provided with the distribution. |
| // |
| // 3. Neither the name of the copyright holder nor the names of its |
| // contributors may be used to endorse or promote products derived from |
| // this software without specific prior written permission. |
| // |
| // THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" |
| // AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE |
| // IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE |
| // DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE |
| // FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL |
| // DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR |
| // SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER |
| // CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, |
| // OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE |
| // OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. |
| |
| package main |
| |
| import ( |
| "fmt" |
| "path/filepath" |
| "strings" |
| |
| "dawn.googlesource.com/dawn/tools/src/fileutils" |
| ) |
| |
| // TODO(crbug.com/416755658): Add unittest coverage when exec calls are done |
| // via dependency injection. |
| |
| // reproStatus indicates the result of the reproduction attempt, failed reproduction is handled via Go errors |
| type reproStatus int |
| |
| const ( |
| ReproStatusIdentical reproStatus = iota |
| ReproStatusFixIdentifiers |
| ) |
| |
| // StatusString returns a human-readable description of the reproduction status. |
| func (r reproStatus) StatusString() string { |
| switch r { |
| case ReproStatusIdentical: |
| return "Initial crash reproduced" |
| case ReproStatusFixIdentifiers: |
| return "Reproduced with `--fix-identifiers=true`" |
| } |
| panic("unreachable") |
| } |
| |
| // NoteString returns a short parenthetical note for the reproduction status. |
| func (r reproStatus) NoteString() string { |
| switch r { |
| case ReproStatusIdentical: |
| return " (identical to original)" |
| case ReproStatusFixIdentifiers: |
| return "" |
| } |
| panic("unreachable") |
| } |
| |
| type triageConfig struct { |
| *taskConfig |
| inputBase string |
| reproFile string |
| logFile string |
| reportFile string |
| reproStatus reproStatus |
| inputsDisplay string |
| irInput string |
| failingPass string |
| verboseOut []byte |
| filterArg string |
| } |
| |
| // runCmd is a helper that executes a command for a triage subtask with standardized output capturing and logging |
| // behaviour |
| func (tc *triageConfig) runCmd(name string, args ...string) ([]byte, error) { |
| if tc.verbose { |
| fmt.Printf("executing: %s %s\n", name, strings.Join(args, " ")) |
| } |
| cmd := tc.execWrapper.Command(name, args...) |
| out, err := cmd.RunWithCombinedOutput() |
| if tc.verbose { |
| fmt.Printf("output:\n%s\n", string(out)) |
| } |
| return out, err |
| } |
| |
| // runTriage performs an automated triage of a fuzzer crash. |
| // It verifies the reproduction, extracts human-readable IR and WGSL, identifies the failing transformation pass, and |
| // generates a detailed Markdown report. Returns an error if a subtask fails unexpectedly. |
| func runTriage(t *taskConfig) error { |
| tc := &triageConfig{taskConfig: t} |
| tc.inputBase = filepath.Base(tc.triageFile) |
| tc.reproFile = tc.inputBase + ".repro" |
| tc.logFile = tc.inputBase + ".triage.log" |
| tc.reportFile = tc.inputBase + ".triage.md" |
| |
| if tc.out != "" && tc.out != "<tmp>" { |
| tc.reproFile = filepath.Join(tc.out, tc.reproFile) |
| tc.logFile = filepath.Join(tc.out, tc.logFile) |
| tc.reportFile = filepath.Join(tc.out, tc.reportFile) |
| } |
| |
| fmt.Println("verifying reproduction...") |
| fuzzerOutput, err := verifyReproduction(tc) |
| if err != nil { |
| return err |
| } |
| |
| fmt.Println("extracting human readable input...") |
| if err := extractHumanReadableInput(tc); err != nil { |
| return err |
| } |
| |
| fmt.Println("determining failing pass...") |
| failingPass, err := determineFailingPass(fuzzerOutput) |
| if err != nil { |
| return err |
| } |
| tc.failingPass = failingPass |
| |
| fmt.Println("running specific pass with more logging...") |
| if err := runSpecificPassAndLog(tc); err != nil { |
| return err |
| } |
| |
| fmt.Println("generating report...") |
| return generateTriageReport(tc) |
| } |
| |
| // verifyReproduction attempts to reproduce the crash using the fuzzer. |
| // If the initial run doesn't crash and execution in IR mode, it tries again with --fix-identifiers=true. |
| // It returns the fuzzer output string, and any error encountered. |
| func verifyReproduction(tc *triageConfig) (string, error) { |
| out, err := tc.runCmd(tc.fuzzer, "--verbose", tc.triageFile) |
| if err == nil { |
| if tc.fuzzMode == FuzzModeIr { |
| fmt.Println("initial run did not crash, attempting to fix identifiers...") |
| out, err = tc.runCmd(tc.fuzzer, "--verbose", "--fix-identifiers=true", tc.triageFile) |
| if err == nil { |
| return "", fmt.Errorf("issue did not reproduce even with --fix-identifiers=true") |
| } |
| fmt.Println("crash reproduced with --fix-identifiers=true. updating test case...") |
| tc.reproStatus = ReproStatusFixIdentifiers |
| _, err = tc.runCmd(tc.assembler, "--strip-invalid-identifiers", "-o", tc.reproFile, tc.triageFile) |
| if err != nil { |
| return "", fmt.Errorf("failed to strip invalid identifiers: %w", err) |
| } |
| return string(out), nil |
| } |
| return "", fmt.Errorf("issue did not reproduce") |
| } |
| |
| // Didn't need to modify, so Just copy original to reproFile |
| if err := fileutils.CopyFile(tc.reproFile, tc.triageFile, tc.osWrapper); err != nil { |
| return "", fmt.Errorf("failed to copy reproduction file: %w", err) |
| } |
| tc.reproStatus = ReproStatusIdentical |
| return string(out), nil |
| } |
| |
| // extractHumanReadableInput attempts to convert the reproduction file into human-readable IR and WGSL. |
| // Depending on the fuzzer mode, it uses the assembler or disassembler as needed. |
| // Returns formatted Markdown sections for the report and the raw IR input string. |
| func extractHumanReadableInput(tc *triageConfig) error { |
| var irInputStatus string |
| var wgslInput string |
| var wgslInputStatus string |
| |
| // Always try to get IR input |
| if tc.fuzzMode == FuzzModeIr { |
| irOut, err := tc.runCmd(tc.assembler, tc.reproFile) |
| if err != nil { |
| irInputStatus = fmt.Sprintf("Failed to extract IR: `%v`", err) |
| } else { |
| tc.irInput = string(irOut) |
| } |
| |
| wgslOut, err := tc.runCmd(tc.assembler, "--emit-wgsl", tc.reproFile) |
| if err != nil { |
| wgslInputStatus = fmt.Sprintf("ir_fuzz_dis could not produce WGSL: `%v`", err) |
| } else { |
| wgslInput = string(wgslOut) |
| } |
| } else { |
| irOut, err := tc.runCmd(tc.assembler, "--emit-ir", tc.reproFile) |
| if err != nil { |
| irInputStatus = fmt.Sprintf("ir_fuzz_as could not produce IR: `%v`", err) |
| } else { |
| tc.irInput = string(irOut) |
| } |
| |
| wgslOut, err := tc.osWrapper.ReadFile(tc.reproFile) |
| if err != nil { |
| wgslInputStatus = fmt.Sprintf("Failed to read WGSL: `%v`", err) |
| } else { |
| wgslInput = string(wgslOut) |
| } |
| } |
| |
| irInputDisplay := "" |
| if tc.irInput != "" { |
| irInputDisplay = "```\n" + tc.irInput + "```" |
| } |
| |
| wgslInputDisplay := "" |
| if wgslInput != "" { |
| wgslInputDisplay = "```\n" + wgslInput + "```" |
| } |
| |
| irSection := fmt.Sprintf("## IR Input\n%s\n%s\n", irInputStatus, irInputDisplay) |
| wgslSection := fmt.Sprintf("## WGSL Input\n%s\n%s\n", wgslInputStatus, wgslInputDisplay) |
| |
| if tc.fuzzMode == FuzzModeIr { |
| tc.inputsDisplay = irSection + "\n" + wgslSection |
| } else { |
| tc.inputsDisplay = wgslSection + "\n" + irSection |
| } |
| return nil |
| } |
| |
| // determineFailingPass parses the fuzzer output to identify the fuzzing pass that was being executed before the crash |
| // occurred. It searches backwards from the end of the output for known pass-execution markers. Returns the failing pass |
| // or an error if one cannot be identified |
| func determineFailingPass(fuzzerOutput string) (string, error) { |
| lines := strings.Split(fuzzerOutput, "\n") |
| for i := len(lines) - 1; i >= 0; i-- { |
| line := strings.TrimSpace(lines[i]) |
| if strings.HasPrefix(line, "• Running:") { |
| return strings.TrimSpace(strings.TrimPrefix(line, "• Running:")), nil |
| } |
| if strings.Contains(line, "Running pass:") { |
| parts := strings.Split(line, "Running pass:") |
| if len(parts) > 1 { |
| return strings.TrimSpace(parts[1]), nil |
| } |
| } |
| } |
| return "", fmt.Errorf("failed to identify failing pass from fuzzer output") |
| } |
| |
| // runSpecificPassAndLog re-runs the fuzzer on the reproduction file, specifically targeting the |
| // failing pass with verbose logging and IR dumping enabled. The output is captured and written |
| // to a log file. Returns the verbose output, the filter argument used, and any error. |
| func runSpecificPassAndLog(tc *triageConfig) error { |
| tc.filterArg = "--filter=" + tc.failingPass |
| |
| // Ignore command exit status issues (command is expected to crash) |
| tc.verboseOut, _ = tc.runCmd(tc.fuzzer, "--verbose", "--dump-ir=true", tc.filterArg, tc.reproFile) |
| |
| if err := tc.osWrapper.WriteFile(tc.logFile, tc.verboseOut, 0644); err != nil { |
| return fmt.Errorf("failed to write log file: %w", err) |
| } |
| return nil |
| } |
| |
| // generateTriageReport constructs a Markdown report summarizing the fuzzer crash triage results, |
| // including the reproduction steps, failing pass, IR dumps, and stack trace. |
| func generateTriageReport(tc *triageConfig) error { |
| // Parse verboseOut for IR dump and stack trace |
| irDump := "" |
| stackTrace := "" |
| var transformsRun []string |
| failingTransform := "" |
| vLines := strings.Split(string(tc.verboseOut), "\n") |
| |
| inDump := false |
| for _, line := range vLines { |
| if strings.HasPrefix(line, "== IR dump before") { |
| irDump = "" // Keep only the last one before crash |
| inDump = true |
| |
| transform := strings.TrimPrefix(line, "== IR dump before") |
| transform = strings.TrimSuffix(transform, ":") |
| transform = strings.TrimSpace(transform) |
| transformsRun = append(transformsRun, transform) |
| failingTransform = transform |
| continue |
| } |
| if inDump { |
| if strings.HasPrefix(line, "========") { |
| inDump = false |
| continue |
| } |
| irDump += line + "\n" |
| } |
| if strings.HasPrefix(line, "#0 ") || strings.HasPrefix(line, " #0 ") || strings.Contains(line, "stack trace:") || strings.Contains(line, "internal compiler error:") { |
| if stackTrace == "" { |
| idx := strings.Index(string(tc.verboseOut), line) |
| stackTrace = string(tc.verboseOut)[idx:] |
| } |
| } |
| } |
| |
| irDumpStatus := "" |
| irDumpDisplay := "" |
| if irDump == "" { |
| irDumpStatus = "crash on original input" |
| } else if strings.TrimSpace(irDump) == strings.TrimSpace(tc.irInput) { |
| irDumpStatus = "input not modified before crash" |
| } else { |
| irDumpDisplay = "```\n" + irDump + "```" |
| } |
| |
| report := fmt.Sprintf(`# Triage Report for %s |
| |
| ## Status |
| %s |
| |
| ### Fuzzer |
| `+"`"+`%s`+"`"+` |
| |
| ### Original File |
| `+"`"+`%s`+"`"+` |
| |
| ### Reproduction File |
| `+"`"+`%s`+"`"+`%s |
| |
| %s |
| ## Reproduction Instructions |
| `+"```"+` |
| %s --verbose --dump-ir=true %s %s |
| `+"```"+` |
| |
| ## Failing Pass |
| `+"`"+`%s`+"`"+` |
| |
| ## Transforms Run |
| `+"```"+` |
| %s |
| `+"```"+` |
| |
| ## Failing Transform |
| `+"`"+`%s`+"`"+` |
| |
| ## IR Dump before failure |
| %s |
| %s |
| |
| ## Crash Stack |
| `+"```"+` |
| %s |
| `+"```"+` |
| `, |
| tc.inputBase, |
| tc.reproStatus.StatusString(), |
| filepath.Base(tc.fuzzer), |
| tc.triageFile, |
| tc.reproFile, tc.reproStatus.NoteString(), |
| tc.inputsDisplay, |
| tc.fuzzer, tc.filterArg, tc.reproFile, |
| tc.failingPass, |
| strings.Join(transformsRun, "\n"), |
| failingTransform, |
| irDumpStatus, |
| irDumpDisplay, |
| stackTrace) |
| |
| fmt.Println("\n--- TRIAGE REPORT ---") |
| fmt.Println(report) |
| fmt.Println("----------------------") |
| |
| if err := tc.osWrapper.WriteFile(tc.reportFile, []byte(report), 0644); err != nil { |
| return fmt.Errorf("failed to write report file: %w", err) |
| } |
| |
| fmt.Printf("\nTriage complete.\nRepro: %s\nReport: %s\n", tc.reproFile, tc.reportFile) |
| return nil |
| } |