package main
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"runtime"
"strings"
"github.com/mgurevin/recorder"
"github.com/mgurevin/recorder/hario"
)
const (
doctorPass = "pass"
doctorWarn = "warn"
doctorFail = "fail"
)
type doctorCheck struct {
Name string `json:"name"`
Status string `json:"status"`
Detail string `json:"detail"`
}
type doctorReport struct {
Healthy bool `json:"healthy"`
CLI string `json:"cli"`
Go string `json:"go"`
Platform string `json:"platform"`
SchemaVersion string `json:"schemaVersion"`
Capture string `json:"capture,omitempty"`
Checks []doctorCheck `json:"checks"`
}
func runDoctor(args []string, stdout, stderr io.Writer) error {
flags := newFlagSet("doctor", stderr)
inputFormat := flags.String("format", formatAuto, "input format: auto, har, or ndjson")
bodyStore := flags.String("body-store", "", "optional FileBodyStore root containing assets/")
jsonOutput := flags.Bool("json", false, "write machine-readable JSON")
if err := parseCommandFlags(flags, args); err != nil {
return usageError(err)
}
if flags.NArg() > 1 {
return fmt.Errorf("%w: doctor accepts at most one capture path", errUsage)
}
report := newDoctorReport()
if *bodyStore != "" {
report.add(checkBodyStore(*bodyStore))
}
if flags.NArg() == 1 {
path := flags.Arg(0)
if path == "-" {
return fmt.Errorf("%w: doctor requires a file path, not stdin", errUsage)
}
report.Capture = filepath.Base(path)
_, entries, format, err := readCapture(path, *inputFormat)
if err != nil {
report.add(doctorCheck{
Name: "capture",
Status: doctorFail,
Detail: safeCaptureError(err),
})
} else {
report.add(doctorCheck{
Name: "capture",
Status: doctorPass,
Detail: fmt.Sprintf("%s, %d entries", strings.ToUpper(format), len(entries)),
})
references := countBodyReferences(entries)
switch {
case references == 0:
report.add(doctorCheck{
Name: "body-assets",
Status: doctorPass,
Detail: "capture has no external body references",
})
case *bodyStore == "":
report.add(doctorCheck{
Name: "body-assets",
Status: doctorWarn,
Detail: fmt.Sprintf("%d external references not checked; provide -body-store", references),
})
default:
verification, verifyErr := verifyCapture(entries, format, *bodyStore)
if verifyErr != nil {
report.add(doctorCheck{
Name: "body-assets",
Status: doctorFail,
Detail: safeDoctorError(verifyErr),
})
} else if verification.Missing > 0 || verification.Modified > 0 {
report.add(doctorCheck{
Name: "body-assets",
Status: doctorFail,
Detail: fmt.Sprintf(
"%d verified checksums, %d missing, %d modified",
verification.ChecksumsVerified,
verification.Missing,
verification.Modified,
),
})
} else {
report.add(doctorCheck{
Name: "body-assets",
Status: doctorPass,
Detail: fmt.Sprintf(
"%d references, %d verified checksums, %d skipped checksums",
verification.BodyReferences,
verification.ChecksumsVerified,
verification.ChecksumsSkipped,
),
})
}
}
}
}
if *jsonOutput {
if err := writeJSON(stdout, report); err != nil {
return err
}
} else if err := writeDoctorReport(stdout, report); err != nil {
return err
}
if !report.Healthy {
return fmt.Errorf("doctor found incompatible or invalid state")
}
return nil
}
func newDoctorReport() *doctorReport {
report := &doctorReport{
Healthy: true,
CLI: commandVersion(),
Go: runtime.Version(),
Platform: runtime.GOOS + "/" + runtime.GOARCH,
SchemaVersion: recorder.RecorderExtensionVersion,
Checks: make([]doctorCheck, 0, 4),
}
report.add(doctorCheck{
Name: "runtime",
Status: doctorPass,
Detail: report.Go + " " + report.Platform,
})
report.add(doctorCheck{
Name: "schema",
Status: doctorPass,
Detail: "supports recorder extension version " + report.SchemaVersion,
})
return report
}
func (r *doctorReport) add(check doctorCheck) {
r.Checks = append(r.Checks, check)
if check.Status == doctorFail {
r.Healthy = false
}
}
func checkBodyStore(root string) doctorCheck {
assetsPath := filepath.Join(root, "assets")
info, err := os.Lstat(assetsPath)
if err != nil {
return doctorCheck{
Name: "body-store",
Status: doctorFail,
Detail: "assets directory is unavailable",
}
}
if info.Mode()&os.ModeSymlink != 0 {
return doctorCheck{
Name: "body-store",
Status: doctorFail,
Detail: "assets path must not be a symbolic link",
}
}
if !info.IsDir() {
return doctorCheck{
Name: "body-store",
Status: doctorFail,
Detail: "assets path is not a directory",
}
}
directory, err := os.Open(assetsPath)
if err != nil {
return doctorCheck{
Name: "body-store",
Status: doctorFail,
Detail: "assets directory is not readable",
}
}
closeErr := directory.Close()
if closeErr != nil {
return doctorCheck{
Name: "body-store",
Status: doctorFail,
Detail: "assets directory could not be closed safely",
}
}
return doctorCheck{
Name: "body-store",
Status: doctorPass,
Detail: "assets directory is readable",
}
}
func countBodyReferences(entries []*recorder.Entry) int {
count := 0
for _, entry := range entries {
for _, body := range entryBodies(entry) {
if body.info != nil && body.info.Store != "" {
count++
}
}
}
return count
}
func safeDoctorError(_ error) string {
return "body assets could not be verified safely"
}
func safeCaptureError(err error) string {
switch {
case errors.Is(err, os.ErrNotExist):
return "capture file is unavailable"
case errors.Is(err, hario.ErrLimitExceeded):
return "capture exceeds configured safety limits"
case errors.Is(err, hario.ErrInvalidHAR):
return "capture is structurally invalid or uses an unsupported schema"
default:
return "capture could not be read safely"
}
}
func writeDoctorReport(writer io.Writer, report *doctorReport) error {
var output strings.Builder
fmt.Fprintf(&output, "CLI: %s\n", report.CLI)
fmt.Fprintf(&output, "Runtime: %s %s\n", report.Go, report.Platform)
fmt.Fprintf(&output, "Recorder schema: %s\n", report.SchemaVersion)
if report.Capture != "" {
fmt.Fprintf(&output, "Capture: %s\n", report.Capture)
}
for _, check := range report.Checks {
fmt.Fprintf(&output, "[%s] %s: %s\n", strings.ToUpper(check.Status), check.Name, check.Detail)
}
_, err := io.WriteString(writer, output.String())
return err
}
package main
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/mgurevin/recorder"
)
type entryFilter struct {
method string
host string
statusMin int
statusMax int
}
func runFixture(args []string, stdout, stderr io.Writer) error {
flags := newFlagSet("fixture", stderr)
inputFormat := flags.String("format", formatAuto, "input format: auto, har, or ndjson")
outputFormat := flags.String("to", formatAuto, "output format: auto, har, or ndjson")
outputPath := flags.String("output", "-", `output path, or "-" for stdout`)
method := flags.String("method", "", "select one HTTP method")
host := flags.String("host", "", "select one request hostname")
statusMin := flags.Int("status-min", 0, "select status codes at or above this value")
statusMax := flags.Int("status-max", 0, "select status codes at or below this value")
if err := parseCommandFlags(flags, args); err != nil {
return usageError(err)
}
inputPath, err := onePath(flags)
if err != nil {
return err
}
targetFormat, err := resolveOutputFormat(*outputPath, *outputFormat)
if err != nil {
return err
}
if *statusMin < 0 || *statusMax < 0 || (*statusMax > 0 && *statusMin > *statusMax) {
return fmt.Errorf("%w: invalid status range", errUsage)
}
reader, closeReader, sourceFormat, err := openCapture(inputPath, *inputFormat)
if err != nil {
return err
}
defer closeReader()
stream, err := newEntryStream(reader, sourceFormat)
if err != nil {
return fmt.Errorf("read %s: %w", inputPath, err)
}
output, err := newAtomicOutput(*outputPath, stdout)
if err != nil {
return err
}
defer output.abort()
writer := newFixtureWriter(output.file, targetFormat)
if err := writer.begin(); err != nil {
return err
}
filter := entryFilter{
method: strings.ToUpper(strings.TrimSpace(*method)),
host: strings.ToLower(strings.TrimSpace(*host)),
statusMin: *statusMin,
statusMax: *statusMax,
}
for {
entry, nextErr := stream.Next()
if errors.Is(nextErr, io.EOF) {
break
}
if nextErr != nil {
return fmt.Errorf("read %s: %w", inputPath, nextErr)
}
if !filter.match(entry) {
continue
}
if err := writer.write(entry); err != nil {
return err
}
}
if err := writer.end(); err != nil {
return err
}
return output.commit()
}
func resolveOutputFormat(path, requested string) (string, error) {
switch strings.ToLower(requested) {
case formatHAR, formatNDJSON:
return strings.ToLower(requested), nil
case formatAuto:
default:
return "", fmt.Errorf("%w: output format must be auto, har, or ndjson", errUsage)
}
if path == "-" {
return formatNDJSON, nil
}
switch strings.ToLower(filepath.Ext(path)) {
case ".har":
return formatHAR, nil
case ".ndjson", ".jsonl":
return formatNDJSON, nil
default:
return "", fmt.Errorf("%w: cannot infer output format; use -to", errUsage)
}
}
func (f entryFilter) match(entry *recorder.Entry) bool {
if entry == nil || entry.Request == nil || entry.Response == nil {
return false
}
if f.method != "" && !strings.EqualFold(entry.Request.Method, f.method) {
return false
}
if f.host != "" {
parsed, err := url.Parse(entry.Request.URL)
if err != nil || !strings.EqualFold(parsed.Hostname(), f.host) {
return false
}
}
if f.statusMin > 0 && entry.Response.Status < f.statusMin {
return false
}
if f.statusMax > 0 && entry.Response.Status > f.statusMax {
return false
}
return true
}
type fixtureWriter struct {
writer io.Writer
format string
count int
}
func newFixtureWriter(writer io.Writer, format string) *fixtureWriter {
return &fixtureWriter{writer: writer, format: format}
}
func (w *fixtureWriter) begin() error {
if w.format != formatHAR {
return nil
}
version := strings.TrimPrefix(commandVersion(), "recorder ")
if version == "dev" {
version = "devel"
}
prefix := `{"log":{"version":"1.2","creator":{"name":"github.com/mgurevin/recorder/cmd/recorder","version":` +
strconv.Quote(version) + `},"entries":[`
_, err := io.WriteString(w.writer, prefix)
return err
}
func (w *fixtureWriter) write(entry *recorder.Entry) error {
encoded, err := json.Marshal(entry)
if err != nil {
return fmt.Errorf("encode fixture entry: %w", err)
}
if w.format == formatHAR && w.count > 0 {
if _, err := io.WriteString(w.writer, ","); err != nil {
return err
}
}
if _, err := w.writer.Write(encoded); err != nil {
return err
}
if w.format == formatNDJSON {
if _, err := io.WriteString(w.writer, "\n"); err != nil {
return err
}
}
w.count++
return nil
}
func (w *fixtureWriter) end() error {
if w.format != formatHAR {
return nil
}
_, err := io.WriteString(w.writer, "]}}\n")
return err
}
type atomicOutput struct {
file *os.File
finalPath string
stdout io.Writer
committed bool
temporary string
}
func newAtomicOutput(path string, stdout io.Writer) (*atomicOutput, error) {
directory := os.TempDir()
mode := os.FileMode(0o600)
if path != "-" {
directory = filepath.Dir(path)
if _, err := os.Stat(path); err == nil {
return nil, fmt.Errorf("output %s already exists", path)
} else if !errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("inspect output %s: %w", path, err)
}
}
file, err := os.CreateTemp(directory, ".recorder-fixture-*")
if err != nil {
return nil, fmt.Errorf("create temporary output: %w", err)
}
if err := file.Chmod(mode); err != nil {
_ = file.Close()
_ = os.Remove(file.Name())
return nil, fmt.Errorf("set output permissions: %w", err)
}
return &atomicOutput{
file: file,
finalPath: path,
stdout: stdout,
temporary: file.Name(),
}, nil
}
func (o *atomicOutput) commit() error {
if err := o.file.Sync(); err != nil {
return fmt.Errorf("sync temporary output: %w", err)
}
if _, err := o.file.Seek(0, io.SeekStart); err != nil {
return fmt.Errorf("rewind temporary output: %w", err)
}
if o.finalPath == "-" {
if _, err := io.Copy(o.stdout, o.file); err != nil {
return fmt.Errorf("write stdout: %w", err)
}
if err := o.file.Close(); err != nil {
return fmt.Errorf("close temporary output: %w", err)
}
if err := os.Remove(o.temporary); err != nil {
return fmt.Errorf("remove temporary output: %w", err)
}
} else {
if err := o.file.Close(); err != nil {
return fmt.Errorf("close temporary output: %w", err)
}
if err := os.Rename(o.temporary, o.finalPath); err != nil {
return fmt.Errorf("publish %s: %w", o.finalPath, err)
}
}
o.committed = true
return nil
}
func (o *atomicOutput) abort() {
if o == nil || o.committed {
return
}
_ = o.file.Close()
_ = os.Remove(o.temporary)
}
// Command recorder provides bounded offline validation, summaries, conversion,
// evidence verification, fixture preparation and serving, local Inspector
// handoff, compatibility diagnostics, and explicit FileBodyStore
// reconciliation for recorder HAR and NDJSON captures.
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"os/exec"
"os/signal"
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
"time"
"github.com/mgurevin/recorder"
"github.com/mgurevin/recorder/hario"
"github.com/mgurevin/recorder/internal/buildinfo"
)
const (
defaultInspectorURL = "https://mgurevin.github.io/recorder/"
formatAuto = "auto"
formatHAR = "har"
formatNDJSON = "ndjson"
)
var errUsage = errors.New("usage error")
func main() {
if err := run(context.Background(), os.Args[1:], os.Stdout, os.Stderr); err != nil {
fmt.Fprintln(os.Stderr, "recorder:", err)
if errors.Is(err, errUsage) {
os.Exit(2)
}
os.Exit(1)
}
}
func run(ctx context.Context, args []string, stdout, stderr io.Writer) error {
if len(args) == 0 {
if err := printUsage(stderr); err != nil {
return err
}
return errUsage
}
switch args[0] {
case "validate":
return runValidate(args[1:], stdout, stderr)
case "summarize":
return runSummarize(args[1:], stdout, stderr)
case "convert":
return runConvert(args[1:], stdout, stderr)
case "inspect":
return runInspect(ctx, args[1:], stdout, stderr)
case "verify":
return runVerify(args[1:], stdout, stderr)
case "fixture":
return runFixture(args[1:], stdout, stderr)
case "serve-fixture":
return runServeFixture(ctx, args[1:], stdout, stderr)
case "doctor":
return runDoctor(args[1:], stdout, stderr)
case "reconcile":
return runReconcile(args[1:], stdout, stderr)
case "version", "--version", "-version":
_, err := fmt.Fprintln(stdout, commandVersion())
return err
case "help", "--help", "-h":
return printUsage(stdout)
default:
if err := printUsage(stderr); err != nil {
return err
}
return fmt.Errorf("%w: unknown command %q", errUsage, args[0])
}
}
func printUsage(writer io.Writer) error {
_, err := fmt.Fprintln(writer, `Usage: recorder <command> [options]
Commands:
validate Validate every entry in a HAR or NDJSON capture
summarize Print a bounded metadata-only capture summary
convert Convert a validated HAR to NDJSON, or NDJSON to HAR
inspect Serve a validated capture on loopback and open the Inspector
verify Verify body assets and checksums without modifying them
fixture Select exchanges into a deterministic HAR or NDJSON fixture
serve-fixture
Serve a network-free hartest fixture on loopback
doctor Diagnose CLI, capture schema, and body-store compatibility
reconcile Compare authoritative captures with a FileBodyStore
version Print the CLI version
Run "recorder <command> -h" for command options.`)
return err
}
func runValidate(args []string, stdout, stderr io.Writer) error {
flags := newFlagSet("validate", stderr)
inputFormat := flags.String("format", formatAuto, "input format: auto, har, or ndjson")
jsonOutput := flags.Bool("json", false, "write machine-readable JSON")
if err := parseCommandFlags(flags, args); err != nil {
return usageError(err)
}
path, err := onePath(flags)
if err != nil {
return err
}
summary, err := scanCapture(path, *inputFormat)
if err != nil {
return err
}
if *jsonOutput {
return writeJSON(stdout, struct {
Valid bool `json:"valid"`
Format string `json:"format"`
Entries int `json:"entries"`
}{Valid: true, Format: summary.Format, Entries: summary.Entries})
}
_, err = fmt.Fprintf(
stdout,
"valid %s capture: %d entries\n",
strings.ToUpper(summary.Format),
summary.Entries,
)
return err
}
func runSummarize(args []string, stdout, stderr io.Writer) error {
flags := newFlagSet("summarize", stderr)
inputFormat := flags.String("format", formatAuto, "input format: auto, har, or ndjson")
jsonOutput := flags.Bool("json", false, "write machine-readable JSON")
if err := parseCommandFlags(flags, args); err != nil {
return usageError(err)
}
path, err := onePath(flags)
if err != nil {
return err
}
summary, err := scanCapture(path, *inputFormat)
if err != nil {
return err
}
if *jsonOutput {
return writeJSON(stdout, summary)
}
return writeSummary(stdout, summary)
}
func runConvert(args []string, stdout, stderr io.Writer) error {
flags := newFlagSet("convert", stderr)
inputFormat := flags.String("from", formatAuto, "input format: auto, har, or ndjson")
outputFormat := flags.String("to", "", "output format: har or ndjson")
outputPath := flags.String("output", "-", `output path, or "-" for stdout`)
if err := parseCommandFlags(flags, args); err != nil {
return usageError(err)
}
inputPath, err := onePath(flags)
if err != nil {
return err
}
if *outputFormat != formatHAR && *outputFormat != formatNDJSON {
return fmt.Errorf("%w: -to must be har or ndjson", errUsage)
}
reader, closeReader, detectedFormat, err := openCapture(inputPath, *inputFormat)
if err != nil {
return err
}
defer closeReader()
if detectedFormat == *outputFormat {
return fmt.Errorf("%w: input is already %s", errUsage, detectedFormat)
}
stream, err := newEntryStream(reader, detectedFormat)
if err != nil {
return fmt.Errorf("read %s: %w", inputPath, err)
}
output, err := newAtomicOutput(*outputPath, stdout)
if err != nil {
return err
}
defer output.abort()
writer := newFixtureWriter(output.file, *outputFormat)
if err := writer.begin(); err != nil {
return err
}
for {
entry, nextErr := stream.Next()
if errors.Is(nextErr, io.EOF) {
break
}
if nextErr != nil {
return fmt.Errorf("read %s: %w", inputPath, nextErr)
}
if err := writer.write(entry); err != nil {
return err
}
}
if err := writer.end(); err != nil {
return err
}
return output.commit()
}
func runInspect(ctx context.Context, args []string, stdout, stderr io.Writer) error {
flags := newFlagSet("inspect", stderr)
inputFormat := flags.String("format", formatAuto, "input format: auto, har, or ndjson")
inspectorURL := flags.String("inspector-url", defaultInspectorURL, "trusted Inspector base URL")
serverTLS := addServerTLSFlags(flags)
noOpen := flags.Bool("no-open", false, "print the URL without opening a browser")
if err := parseCommandFlags(flags, args); err != nil {
return usageError(err)
}
path, err := onePath(flags)
if err != nil {
return err
}
if path == "-" {
return fmt.Errorf("%w: inspect requires a file path", errUsage)
}
if _, err := scanCapture(path, *inputFormat); err != nil {
return err
}
absolutePath, err := filepath.Abs(path)
if err != nil {
return fmt.Errorf("resolve capture path: %w", err)
}
baseURL, origin, err := validateInspectorURL(*inspectorURL)
if err != nil {
return err
}
tlsConfig, serverScheme, err := serverTLS.load()
if err != nil {
return err
}
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return fmt.Errorf("listen on loopback: %w", err)
}
defer func() {
_ = listener.Close()
}()
captureURL := serverScheme + "://" + listener.Addr().String() + "/capture"
server := &http.Server{
Handler: captureHandler(absolutePath, origin),
ReadHeaderTimeout: 5 * time.Second,
}
defer func() {
_ = server.Close()
}()
go func() {
serveErr := serveHTTP(server, listener, tlsConfig)
if serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) {
_, _ = fmt.Fprintf(stderr, "recorder: Inspector server: %v\n", serveErr)
}
}()
query := baseURL.Query()
query.Set("har", captureURL)
baseURL.RawQuery = query.Encode()
if _, err := fmt.Fprintf(stdout, "Inspector: %s\n", baseURL.String()); err != nil {
return err
}
if _, err := fmt.Fprintln(stdout, "Serving the capture on loopback; press Ctrl-C to stop."); err != nil {
return err
}
if !*noOpen {
if err := openBrowser(baseURL.String()); err != nil {
_, _ = fmt.Fprintf(stderr, "recorder: open browser: %v\n", err)
}
}
signalContext, stop := signal.NotifyContext(ctx, os.Interrupt)
defer stop()
<-signalContext.Done()
return nil
}
func newFlagSet(name string, stderr io.Writer) *flag.FlagSet {
flags := flag.NewFlagSet(name, flag.ContinueOnError)
flags.SetOutput(stderr)
return flags
}
// parseCommandFlags preserves the standard flag package's parsing and error
// behavior while allowing options to appear before or after positional paths.
func parseCommandFlags(flags *flag.FlagSet, args []string) error {
options := make([]string, 0, len(args))
positionals := make([]string, 0, len(args))
for index := 0; index < len(args); index++ {
arg := args[index]
if arg == "--" {
positionals = append(positionals, args[index+1:]...)
break
}
if arg == "-" || !strings.HasPrefix(arg, "-") {
positionals = append(positionals, arg)
continue
}
options = append(options, arg)
name := strings.TrimLeft(arg, "-")
if separator := strings.IndexByte(name, '='); separator >= 0 {
name = name[:separator]
}
registered := flags.Lookup(name)
if registered == nil || strings.Contains(arg, "=") || isBooleanFlag(registered) {
continue
}
if index+1 < len(args) {
index++
options = append(options, args[index])
}
}
return flags.Parse(append(options, positionals...))
}
func isBooleanFlag(value *flag.Flag) bool {
boolean, ok := value.Value.(interface{ IsBoolFlag() bool })
return ok && boolean.IsBoolFlag()
}
func onePath(flags *flag.FlagSet) (string, error) {
if flags.NArg() != 1 {
return "", fmt.Errorf("%w: exactly one input path is required", errUsage)
}
return flags.Arg(0), nil
}
func usageError(err error) error {
if errors.Is(err, flag.ErrHelp) {
return nil
}
return fmt.Errorf("%w: %v", errUsage, err)
}
type captureSummary struct {
Format string `json:"format"`
Entries int `json:"entries"`
Methods map[string]int `json:"methods"`
StatusClasses map[string]int `json:"statusClasses"`
Hosts map[string]int `json:"hosts"`
Traces int `json:"traces"`
Failures int `json:"failures"`
TruncatedBodies int `json:"truncatedBodies"`
IncompleteBodies int `json:"incompleteBodies"`
ClosedEarlyBodies int `json:"closedEarlyBodies"`
CapturedBytes int64 `json:"capturedBytes"`
TotalDurationMS float64 `json:"totalDurationMs"`
}
func newCaptureSummary(format string) *captureSummary {
return &captureSummary{
Format: format,
Methods: make(map[string]int),
StatusClasses: make(map[string]int),
Hosts: make(map[string]int),
}
}
func (s *captureSummary) add(entry *recorder.Entry, traceIDs map[string]struct{}) {
s.Entries++
s.TotalDurationMS += entry.Time
if entry.Request != nil {
s.Methods[entry.Request.Method]++
if parsed, err := url.Parse(entry.Request.URL); err == nil && parsed.Hostname() != "" {
s.Hosts[parsed.Hostname()]++
}
}
if entry.Response != nil {
statusClass := "transport-error"
if entry.Response.Status > 0 {
statusClass = strconv.Itoa(entry.Response.Status/100) + "xx"
}
s.StatusClasses[statusClass]++
}
if entry.Recorder == nil {
return
}
if entry.Recorder.TraceID != "" {
traceIDs[entry.Recorder.TraceID] = struct{}{}
}
if entry.Recorder.Error != nil {
s.Failures++
}
s.addBody(entry.Recorder.RequestBody)
s.addBody(entry.Recorder.ResponseBody)
}
func (s *captureSummary) addBody(body *recorder.BodyInfo) {
if body == nil || !body.Present {
return
}
s.CapturedBytes += body.CapturedBytes
if body.Truncated {
s.TruncatedBodies++
}
if !body.Complete {
s.IncompleteBodies++
}
if body.ClosedEarly {
s.ClosedEarlyBodies++
}
}
func scanCapture(path, requestedFormat string) (*captureSummary, error) {
reader, closeReader, format, err := openCapture(path, requestedFormat)
if err != nil {
return nil, err
}
defer closeReader()
stream, err := newEntryStream(reader, format)
if err != nil {
return nil, fmt.Errorf("read %s: %w", path, err)
}
summary := newCaptureSummary(format)
traceIDs := make(map[string]struct{})
for {
entry, nextErr := stream.Next()
if errors.Is(nextErr, io.EOF) {
break
}
if nextErr != nil {
return nil, fmt.Errorf("read %s: %w", path, nextErr)
}
summary.add(entry, traceIDs)
}
summary.Traces = len(traceIDs)
return summary, nil
}
func readCapture(path, requestedFormat string) (*recorder.HAR, []*recorder.Entry, string, error) {
reader, closeReader, format, err := openCapture(path, requestedFormat)
if err != nil {
return nil, nil, "", err
}
defer closeReader()
switch format {
case formatHAR:
document, readErr := hario.ReadHAR(reader, hario.DefaultReadConfig())
if readErr != nil {
return nil, nil, "", fmt.Errorf("read %s: %w", path, readErr)
}
return document, document.Log.Entries, format, nil
case formatNDJSON:
entries, readErr := hario.ReadNDJSON(reader, hario.DefaultReadConfig())
if readErr != nil {
return nil, nil, "", fmt.Errorf("read %s: %w", path, readErr)
}
return nil, entries, format, nil
}
panic("unreachable capture format")
}
func openCapture(path, requestedFormat string) (io.Reader, func(), string, error) {
format, err := resolveFormat(path, requestedFormat)
if err != nil {
return nil, nil, "", err
}
if path == "-" {
return os.Stdin, func() {}, format, nil
}
file, err := os.Open(path)
if err != nil {
return nil, nil, "", fmt.Errorf("open %s: %w", path, err)
}
return file, func() { _ = file.Close() }, format, nil
}
func resolveFormat(path, requested string) (string, error) {
switch strings.ToLower(requested) {
case formatHAR, formatNDJSON:
return strings.ToLower(requested), nil
case formatAuto:
default:
return "", fmt.Errorf("%w: format must be auto, har, or ndjson", errUsage)
}
switch strings.ToLower(filepath.Ext(path)) {
case ".har":
return formatHAR, nil
case ".ndjson", ".jsonl":
return formatNDJSON, nil
default:
return "", fmt.Errorf("%w: cannot infer input format; use -format or -from", errUsage)
}
}
func newEntryStream(reader io.Reader, format string) (*hario.EntryStream, error) {
switch format {
case formatHAR:
return hario.NewHARStream(reader, hario.DefaultReadConfig())
case formatNDJSON:
return hario.NewNDJSONStream(reader, hario.DefaultReadConfig())
default:
return nil, fmt.Errorf("%w: unsupported format %q", errUsage, format)
}
}
func writeJSON(writer io.Writer, value any) error {
encoder := json.NewEncoder(writer)
encoder.SetIndent("", " ")
return encoder.Encode(value)
}
func writeSummary(writer io.Writer, summary *captureSummary) error {
var output strings.Builder
fmt.Fprintf(&output, "Format: %s\n", strings.ToUpper(summary.Format))
fmt.Fprintf(&output, "Entries: %d\n", summary.Entries)
fmt.Fprintf(&output, "Traces: %d\n", summary.Traces)
fmt.Fprintf(&output, "Failures: %d\n", summary.Failures)
fmt.Fprintf(&output, "Duration: %.3f ms\n", summary.TotalDurationMS)
fmt.Fprintf(&output, "Captured body bytes: %d\n", summary.CapturedBytes)
fmt.Fprintf(
&output,
"Bodies: %d truncated, %d incomplete, %d closed early\n",
summary.TruncatedBodies,
summary.IncompleteBodies,
summary.ClosedEarlyBodies,
)
writeCounts(&output, "Methods", summary.Methods)
writeCounts(&output, "Status classes", summary.StatusClasses)
writeCounts(&output, "Hosts", summary.Hosts)
_, err := io.WriteString(writer, output.String())
return err
}
func writeCounts(writer *strings.Builder, label string, counts map[string]int) {
keys := make([]string, 0, len(counts))
for key := range counts {
keys = append(keys, key)
}
sort.Strings(keys)
fmt.Fprintf(writer, "%s:", label)
for _, key := range keys {
fmt.Fprintf(writer, " %s=%d", key, counts[key])
}
fmt.Fprintln(writer)
}
func validateInspectorURL(value string) (*url.URL, string, error) {
parsed, err := url.Parse(value)
if err != nil {
return nil, "", fmt.Errorf("%w: invalid Inspector URL: %v", errUsage, err)
}
if parsed.Scheme != "https" && (parsed.Scheme != "http" || !isLoopbackHost(parsed.Hostname())) {
return nil, "", fmt.Errorf("%w: Inspector URL must use HTTPS or loopback HTTP", errUsage)
}
if parsed.Host == "" || parsed.User != nil {
return nil, "", fmt.Errorf("%w: Inspector URL must have a host and no credentials", errUsage)
}
origin := parsed.Scheme + "://" + parsed.Host
return parsed, origin, nil
}
func captureHandler(path, allowedOrigin string) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if request.URL.Path != "/capture" {
http.NotFound(writer, request)
return
}
if request.Method != http.MethodGet && request.Method != http.MethodHead {
writer.Header().Set("Allow", "GET, HEAD")
http.Error(writer, "method not allowed", http.StatusMethodNotAllowed)
return
}
origin := request.Header.Get("Origin")
if origin != "" && origin != allowedOrigin {
http.Error(writer, "origin forbidden", http.StatusForbidden)
return
}
if origin == allowedOrigin {
writer.Header().Set("Access-Control-Allow-Origin", allowedOrigin)
writer.Header().Set("Vary", "Origin")
}
writer.Header().Set("Cache-Control", "no-store")
writer.Header().Set("Content-Type", "application/octet-stream")
writer.Header().Set("X-Content-Type-Options", "nosniff")
http.ServeFile(writer, request, path)
})
}
func isLoopbackHost(host string) bool {
if strings.EqualFold(host, "localhost") {
return true
}
address := net.ParseIP(host)
return address != nil && address.IsLoopback()
}
func openBrowser(target string) error {
var command *exec.Cmd
switch runtime.GOOS {
case "darwin":
command = exec.Command("open", target)
case "windows":
command = exec.Command("rundll32", "url.dll,FileProtocolHandler", target)
default:
command = exec.Command("xdg-open", target)
}
return command.Start()
}
func commandVersion() string {
return "recorder " + buildinfo.Version()
}
package main
import (
"errors"
"fmt"
"io"
"math"
"sort"
"strings"
"time"
"github.com/mgurevin/recorder"
)
const defaultReconcileGrace = 24 * time.Hour
type reconcileReport struct {
Applied bool `json:"applied"`
Captures int `json:"captures"`
Entries int `json:"entries"`
LiveReferences int `json:"liveReferences"`
Scanned int `json:"scanned"`
Candidates int `json:"candidates"`
CandidateBytes int64 `json:"candidateBytes"`
Grace string `json:"grace"`
Warnings []string `json:"warnings"`
}
func runReconcile(args []string, stdout, stderr io.Writer) error {
flags := newFlagSet("reconcile", stderr)
inputFormat := flags.String("format", formatAuto, "input format for every capture: auto, har, or ndjson")
bodyStore := flags.String("body-store", "", "FileBodyStore root containing assets/")
grace := flags.Duration("grace", defaultReconcileGrace, "minimum orphan age eligible for cleanup")
apply := flags.Bool("apply", false, "delete eligible unreferenced committed assets")
authoritative := flags.Bool(
"authoritative",
false,
"confirm supplied captures are the complete live-reference set",
)
jsonOutput := flags.Bool("json", false, "write machine-readable JSON")
if err := parseCommandFlags(flags, args); err != nil {
return usageError(err)
}
if *bodyStore == "" {
return fmt.Errorf("%w: -body-store is required", errUsage)
}
if flags.NArg() == 0 {
return fmt.Errorf("%w: at least one authoritative capture path is required", errUsage)
}
if *grace < 0 {
return fmt.Errorf("%w: -grace must not be negative", errUsage)
}
if *apply && !*authoritative {
return fmt.Errorf("%w: -apply requires -authoritative", errUsage)
}
live, entries, err := collectLiveBodyReferences(flags.Args(), *inputFormat)
if err != nil {
return err
}
config := recorder.DefaultFileBodyStoreConfig()
config.MaxBytes = math.MaxInt64
config.MaxFiles = int(^uint(0) >> 1)
config.MaintenanceMode = true
store, err := recorder.NewFileBodyStore(*bodyStore, config)
if err != nil {
return fmt.Errorf("open body store for maintenance: %w", err)
}
if *apply && store.Stats().PartialFiles > 0 {
return errors.New("refusing cleanup while partial body files exist; stop writers and resolve partials first")
}
result, err := store.Reconcile(live, *grace, !*apply)
if err != nil {
return fmt.Errorf("reconcile body store: %w", err)
}
report := reconcileReport{
Applied: *apply,
Captures: flags.NArg(),
Entries: entries,
LiveReferences: len(live),
Scanned: result.Scanned,
Candidates: result.Released,
CandidateBytes: result.ReleasedBytes,
Grace: grace.String(),
Warnings: []string{},
}
if !*apply {
report.Warnings = append(
report.Warnings,
"dry-run only; no committed body assets were deleted",
)
}
if *jsonOutput {
return writeJSON(stdout, report)
}
return writeReconcileReport(stdout, report)
}
func collectLiveBodyReferences(paths []string, format string) ([]string, int, error) {
live := make(map[string]struct{})
entries := 0
for _, path := range paths {
if path == "-" {
return nil, 0, fmt.Errorf("%w: reconcile requires file paths, not stdin", errUsage)
}
reader, closeReader, detectedFormat, err := openCapture(path, format)
if err != nil {
return nil, 0, err
}
stream, err := newEntryStream(reader, detectedFormat)
if err != nil {
closeReader()
return nil, 0, fmt.Errorf("read %s: %w", path, err)
}
for {
entry, nextErr := stream.Next()
if errors.Is(nextErr, io.EOF) {
break
}
if nextErr != nil {
closeReader()
return nil, 0, fmt.Errorf("read %s: %w", path, nextErr)
}
entries++
for _, body := range entryBodies(entry) {
if body.info != nil && body.info.Store != "" {
live[body.info.Store] = struct{}{}
}
}
}
closeReader()
}
references := make([]string, 0, len(live))
for reference := range live {
references = append(references, reference)
}
sort.Strings(references)
return references, entries, nil
}
func writeReconcileReport(writer io.Writer, report reconcileReport) error {
var output strings.Builder
mode := "DRY-RUN"
if report.Applied {
mode = "APPLIED"
}
fmt.Fprintf(&output, "Mode: %s\n", mode)
fmt.Fprintf(&output, "Captures: %d\n", report.Captures)
fmt.Fprintf(&output, "Entries: %d\n", report.Entries)
fmt.Fprintf(&output, "Live body references: %d\n", report.LiveReferences)
fmt.Fprintf(&output, "Committed assets scanned: %d\n", report.Scanned)
fmt.Fprintf(&output, "Eligible unreferenced assets: %d (%d bytes)\n", report.Candidates, report.CandidateBytes)
fmt.Fprintf(&output, "Grace: %s\n", report.Grace)
for _, warning := range report.Warnings {
fmt.Fprintf(&output, "Warning: %s\n", warning)
}
_, err := io.WriteString(writer, output.String())
return err
}
package main
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"os/signal"
"sort"
"strings"
"time"
"github.com/mgurevin/recorder"
"github.com/mgurevin/recorder/hartest"
)
func runServeFixture(ctx context.Context, args []string, stdout, stderr io.Writer) error {
flags := newFlagSet("serve-fixture", stderr)
inputFormat := flags.String("format", formatAuto, "input format: auto, har, or ndjson")
listenAddress := flags.String("listen", "127.0.0.1:8080", "loopback listen address")
originValue := flags.String("origin", "", "recorded origin to map incoming paths onto")
bodyStore := flags.String("body-store", "", "FileBodyStore root containing assets/")
allowUnused := flags.Bool("allow-unused", false, "exit successfully when fixtures remain unused")
serverTLS := addServerTLSFlags(flags)
if err := parseCommandFlags(flags, args); err != nil {
return usageError(err)
}
path, err := onePath(flags)
if err != nil {
return err
}
_, entries, _, err := readCapture(path, *inputFormat)
if err != nil {
return err
}
origin, err := fixtureOrigin(entries, *originValue)
if err != nil {
return err
}
config := hartest.DefaultConfig()
if *bodyStore != "" {
config.Bodies = diskBodyOpener{root: *bodyStore}
}
fixture, err := hartest.NewTransport(entries, config)
if err != nil {
return fmt.Errorf("create fixture transport: %w", err)
}
tlsConfig, serverScheme, err := serverTLS.load()
if err != nil {
return err
}
listener, err := listenLoopback(*listenAddress)
if err != nil {
return err
}
defer func() {
_ = listener.Close()
}()
server := &http.Server{
Handler: fixtureHandler(fixture, origin, stderr),
ReadHeaderTimeout: 5 * time.Second,
}
serveErrors := make(chan error, 1)
go func() {
serveErr := serveHTTP(server, listener, tlsConfig)
if errors.Is(serveErr, http.ErrServerClosed) {
serveErr = nil
}
serveErrors <- serveErr
}()
if _, err := fmt.Fprintf(
stdout,
"Serving %d fixtures at %s://%s mapped to %s; press Ctrl-C to stop.\n",
len(entries),
serverScheme,
listener.Addr().String(),
origin.String(),
); err != nil {
return err
}
signalContext, stop := signal.NotifyContext(ctx, os.Interrupt)
defer stop()
select {
case serveErr := <-serveErrors:
if serveErr != nil {
return fmt.Errorf("serve fixture: %w", serveErr)
}
case <-signalContext.Done():
shutdownContext, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := server.Shutdown(shutdownContext); err != nil {
return fmt.Errorf("stop fixture server: %w", err)
}
if serveErr := <-serveErrors; serveErr != nil {
return fmt.Errorf("serve fixture: %w", serveErr)
}
}
if !*allowUnused {
if err := fixture.Verify(); err != nil {
return err
}
}
return nil
}
func fixtureOrigin(entries []*recorder.Entry, requested string) (*url.URL, error) {
if requested != "" {
parsed, err := url.Parse(requested)
if err != nil {
return nil, fmt.Errorf("%w: invalid fixture origin: %v", errUsage, err)
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return nil, fmt.Errorf("%w: fixture origin must use http or https", errUsage)
}
if parsed.Host == "" || parsed.User != nil || parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" {
return nil, fmt.Errorf("%w: fixture origin must contain only scheme and host", errUsage)
}
return parsed, nil
}
origins := make(map[string]*url.URL)
for _, entry := range entries {
if entry == nil || entry.Request == nil {
continue
}
parsed, err := url.Parse(entry.Request.URL)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
continue
}
key := strings.ToLower(parsed.Scheme + "://" + parsed.Host)
origins[key] = &url.URL{Scheme: parsed.Scheme, Host: parsed.Host}
}
if len(origins) != 1 {
keys := make([]string, 0, len(origins))
for key := range origins {
keys = append(keys, key)
}
sort.Strings(keys)
return nil, fmt.Errorf(
"%w: fixture contains %d origins (%s); select entries first or use -origin",
errUsage,
len(origins),
strings.Join(keys, ", "),
)
}
for _, origin := range origins {
return origin, nil
}
return nil, fmt.Errorf("%w: fixture contains no request origin", errUsage)
}
func listenLoopback(address string) (net.Listener, error) {
host, _, err := net.SplitHostPort(address)
if err != nil {
return nil, fmt.Errorf("%w: listen address must be host:port", errUsage)
}
if !isLoopbackHost(host) {
return nil, fmt.Errorf("%w: serve-fixture only listens on loopback", errUsage)
}
listener, err := net.Listen("tcp", address)
if err != nil {
return nil, fmt.Errorf("listen on %s: %w", address, err)
}
return listener, nil
}
func fixtureHandler(transport http.RoundTripper, origin *url.URL, stderr io.Writer) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
targetRequest := request.Clone(request.Context())
targetURL := *request.URL
targetURL.Scheme = origin.Scheme
targetURL.Host = origin.Host
targetRequest.URL = &targetURL
targetRequest.Host = origin.Host
targetRequest.RequestURI = ""
response, err := transport.RoundTrip(targetRequest)
if err != nil {
_, _ = fmt.Fprintf(stderr, "recorder: fixture mismatch: %v\n", err)
http.Error(writer, "fixture request did not match", http.StatusBadGateway)
return
}
defer func() {
_ = response.Body.Close()
}()
copyFixtureHeaders(writer.Header(), response.Header)
for name := range response.Trailer {
writer.Header().Add("Trailer", name)
}
writer.WriteHeader(response.StatusCode)
if _, err := io.Copy(writer, response.Body); err != nil {
_, _ = fmt.Fprintf(stderr, "recorder: fixture response body: %v\n", err)
return
}
for name, values := range response.Trailer {
writer.Header()[http.TrailerPrefix+name] = append([]string(nil), values...)
}
})
}
func copyFixtureHeaders(destination, source http.Header) {
for name, values := range source {
if isHopByHopHeader(name) {
continue
}
destination[name] = append([]string(nil), values...)
}
}
func isHopByHopHeader(name string) bool {
switch http.CanonicalHeaderKey(name) {
case "Connection", "Keep-Alive", "Proxy-Authenticate", "Proxy-Authorization",
"Te", "Trailer", "Transfer-Encoding", "Upgrade":
return true
default:
return false
}
}
type diskBodyOpener struct {
root string
}
func (o diskBodyOpener) Open(reference string) (io.ReadCloser, error) {
data, err := readBodyAsset(o.root, reference)
if err != nil {
return nil, err
}
return io.NopCloser(bytes.NewReader(data)), nil
}
package main
import (
"crypto/tls"
"flag"
"fmt"
"net"
"net/http"
)
type serverTLSFlags struct {
certificate *string
key *string
}
func addServerTLSFlags(flags *flag.FlagSet) serverTLSFlags {
return serverTLSFlags{
certificate: flags.String("tls-cert", "", "PEM server certificate for HTTPS"),
key: flags.String("tls-key", "", "PEM private key for HTTPS"),
}
}
func (f serverTLSFlags) load() (*tls.Config, string, error) {
certificateFile := *f.certificate
keyFile := *f.key
if certificateFile == "" && keyFile == "" {
return nil, "http", nil
}
if certificateFile == "" || keyFile == "" {
return nil, "", fmt.Errorf("%w: -tls-cert and -tls-key must be provided together", errUsage)
}
certificate, err := tls.LoadX509KeyPair(certificateFile, keyFile)
if err != nil {
return nil, "", fmt.Errorf("load TLS certificate and key: %w", err)
}
return &tls.Config{
MinVersion: tls.VersionTLS12,
Certificates: []tls.Certificate{certificate},
}, "https", nil
}
func serveHTTP(server *http.Server, listener net.Listener, config *tls.Config) error {
if config == nil {
return server.Serve(listener)
}
server.TLSConfig = config
return server.ServeTLS(listener, "", "")
}
package main
import (
"crypto/md5" //nolint:gosec // Verification must reproduce recorded legacy hashes.
"crypto/sha1" //nolint:gosec // Verification must reproduce recorded legacy hashes.
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"github.com/mgurevin/recorder"
)
const fileBodyReferencePrefix = "filebody:v1:"
var errVerificationFailed = errors.New("verification failed")
type verificationIssue struct {
Entry *int `json:"entry,omitempty"`
Direction string `json:"direction,omitempty"`
Kind string `json:"kind"`
Reference string `json:"reference,omitempty"`
Detail string `json:"detail,omitempty"`
}
type verificationReport struct {
Format string `json:"format"`
Entries int `json:"entries"`
BodyReferences int `json:"bodyReferences"`
ChecksumsVerified int `json:"checksumsVerified"`
ChecksumsSkipped int `json:"checksumsSkipped"`
Missing int `json:"missing"`
Modified int `json:"modified"`
Orphans int `json:"orphans"`
Issues []verificationIssue `json:"issues"`
}
func runVerify(args []string, stdout, stderr io.Writer) error {
flags := newFlagSet("verify", stderr)
inputFormat := flags.String("format", formatAuto, "input format: auto, har, or ndjson")
bodyStore := flags.String("body-store", "", "FileBodyStore root containing assets/")
jsonOutput := flags.Bool("json", false, "write machine-readable JSON")
if err := parseCommandFlags(flags, args); err != nil {
return usageError(err)
}
path, err := onePath(flags)
if err != nil {
return err
}
_, entries, format, err := readCapture(path, *inputFormat)
if err != nil {
return err
}
report, err := verifyCapture(entries, format, *bodyStore)
if err != nil {
return err
}
if *jsonOutput {
if err := writeJSON(stdout, report); err != nil {
return err
}
} else if err := writeVerificationReport(stdout, report); err != nil {
return err
}
if report.Missing > 0 || report.Modified > 0 {
return errVerificationFailed
}
return nil
}
func verifyCapture(entries []*recorder.Entry, format, bodyStore string) (*verificationReport, error) {
report := &verificationReport{Format: format, Entries: len(entries), Issues: []verificationIssue{}}
liveReferences := make(map[string]struct{})
if bodyStore == "" {
for _, entry := range entries {
for _, body := range entryBodies(entry) {
if body.info != nil && body.info.Store != "" {
return nil, fmt.Errorf("%w: -body-store is required for external body references", errUsage)
}
}
}
}
for index, entry := range entries {
for _, body := range entryBodies(entry) {
if body.info == nil || !body.info.Present {
continue
}
data, available, issue := bodyRepresentation(index, entry, body, bodyStore)
if issue != nil {
report.addIssue(*issue)
continue
}
if body.info.Store != "" {
report.BodyReferences++
liveReferences[body.info.Store] = struct{}{}
}
if body.info.Hash == "" {
continue
}
if !available || !bodyHashComparable(entry, body) {
report.ChecksumsSkipped++
continue
}
actual, hashErr := digest(body.info.HashAlgorithm, data)
if hashErr != nil {
report.addIssue(verificationIssue{
Entry: intPointer(index),
Direction: body.direction,
Kind: "unsupported-hash",
Detail: hashErr.Error(),
})
continue
}
if !strings.EqualFold(actual, body.info.Hash) {
report.addIssue(verificationIssue{
Entry: intPointer(index),
Direction: body.direction,
Kind: "checksum-mismatch",
Reference: body.info.Store,
})
continue
}
report.ChecksumsVerified++
}
}
if bodyStore != "" {
orphans, err := findOrphanAssets(bodyStore, liveReferences)
if err != nil {
return nil, err
}
for _, reference := range orphans {
report.Orphans++
report.Issues = append(report.Issues, verificationIssue{
Kind: "orphan",
Reference: reference,
})
}
}
return report, nil
}
type entryBody struct {
direction string
info *recorder.BodyInfo
}
func entryBodies(entry *recorder.Entry) []entryBody {
if entry == nil || entry.Recorder == nil {
return nil
}
return []entryBody{
{direction: "request", info: entry.Recorder.RequestBody},
{direction: "response", info: entry.Recorder.ResponseBody},
}
}
func bodyRepresentation(
entryIndex int,
entry *recorder.Entry,
body entryBody,
bodyStore string,
) ([]byte, bool, *verificationIssue) {
if body.info.Store != "" {
data, err := readBodyAsset(bodyStore, body.info.Store)
if err != nil {
kind := "missing"
if !errors.Is(err, os.ErrNotExist) {
kind = "modified"
}
return nil, false, &verificationIssue{
Entry: intPointer(entryIndex),
Direction: body.direction,
Kind: kind,
Reference: body.info.Store,
Detail: safeAssetError(err),
}
}
if int64(len(data)) != body.info.CapturedBytes {
return nil, false, &verificationIssue{
Entry: intPointer(entryIndex),
Direction: body.direction,
Kind: "size-mismatch",
Reference: body.info.Store,
Detail: fmt.Sprintf("capturedBytes=%d assetBytes=%d", body.info.CapturedBytes, len(data)),
}
}
return data, true, nil
}
data, ok := embeddedBody(entry, body.direction)
return data, ok, nil
}
func readBodyAsset(root, reference string) ([]byte, error) {
if root == "" {
return nil, errors.New("body store is required for external reference")
}
id, err := parseFileBodyReference(reference)
if err != nil {
return nil, err
}
path := filepath.Join(root, "assets", id+".body")
info, err := os.Lstat(path)
if err != nil {
return nil, err
}
if !info.Mode().IsRegular() {
return nil, errors.New("body asset is not a regular file")
}
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
return data, nil
}
func parseFileBodyReference(reference string) (string, error) {
if !strings.HasPrefix(reference, fileBodyReferencePrefix) {
return "", errors.New("unsupported body reference")
}
id := strings.TrimPrefix(reference, fileBodyReferencePrefix)
if len(id) != 32 {
return "", errors.New("invalid file body reference")
}
for _, char := range id {
if !strings.ContainsRune("0123456789abcdef", char) {
return "", errors.New("invalid file body reference")
}
}
return id, nil
}
func safeAssetError(err error) string {
if errors.Is(err, os.ErrNotExist) {
return "asset does not exist"
}
return "asset could not be read safely"
}
func intPointer(value int) *int {
return &value
}
func embeddedBody(entry *recorder.Entry, direction string) ([]byte, bool) {
if direction == "request" {
if entry.Request == nil || entry.Request.PostData == nil {
return nil, false
}
text := entry.Request.PostData.Text
if entry.Recorder != nil && entry.Recorder.RequestBodyEncoding == "base64" {
data, err := base64.StdEncoding.DecodeString(text)
return data, err == nil
}
return []byte(text), true
}
if entry.Response == nil || entry.Response.Content == nil {
return nil, false
}
if entry.Response.Content.Encoding == "base64" {
data, err := base64.StdEncoding.DecodeString(entry.Response.Content.Text)
return data, err == nil
}
return []byte(entry.Response.Content.Text), true
}
func bodyHashComparable(entry *recorder.Entry, body entryBody) bool {
if !body.info.Complete || body.info.Truncated || body.info.CapturedBytes != body.info.TotalBytes {
return false
}
if body.direction == "response" && entry.Recorder != nil && entry.Recorder.ResponseBodyDecoded {
return false
}
if entry.Recorder == nil || entry.Recorder.Redaction == nil {
return true
}
scope := entry.Recorder.Redaction.Request
if body.direction == "response" {
scope = entry.Recorder.Redaction.Response
}
if scope == nil || scope.Body == nil {
return true
}
return scope.Body.Outcome == recorder.BodyRedactionUnchanged
}
func digest(algorithm string, data []byte) (string, error) {
var sum []byte
switch strings.ToLower(algorithm) {
case "md5":
value := md5.Sum(data) //nolint:gosec // Reproduces recorded evidence.
sum = value[:]
case "sha1":
value := sha1.Sum(data) //nolint:gosec // Reproduces recorded evidence.
sum = value[:]
case "sha256":
value := sha256.Sum256(data)
sum = value[:]
default:
return "", fmt.Errorf("unsupported hash algorithm %q", algorithm)
}
return hex.EncodeToString(sum), nil
}
func findOrphanAssets(root string, live map[string]struct{}) ([]string, error) {
entries, err := os.ReadDir(filepath.Join(root, "assets"))
if err != nil {
return nil, fmt.Errorf("scan body store assets: %w", err)
}
orphans := make([]string, 0)
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".body") {
continue
}
if entry.Type()&os.ModeSymlink != 0 {
return nil, errors.New("symlink found in body store assets")
}
id := strings.TrimSuffix(entry.Name(), ".body")
reference := fileBodyReferencePrefix + id
if _, ok := live[reference]; !ok {
orphans = append(orphans, reference)
}
}
sort.Strings(orphans)
return orphans, nil
}
func (r *verificationReport) addIssue(issue verificationIssue) {
r.Issues = append(r.Issues, issue)
switch issue.Kind {
case "missing":
r.Missing++
case "orphan":
r.Orphans++
default:
r.Modified++
}
}
func writeVerificationReport(writer io.Writer, report *verificationReport) error {
var output strings.Builder
fmt.Fprintf(&output, "Format: %s\n", strings.ToUpper(report.Format))
fmt.Fprintf(&output, "Entries: %d\n", report.Entries)
fmt.Fprintf(&output, "Body references: %d\n", report.BodyReferences)
fmt.Fprintf(&output, "Checksums: %d verified, %d skipped\n", report.ChecksumsVerified, report.ChecksumsSkipped)
fmt.Fprintf(&output, "Assets: %d missing, %d modified, %d orphaned\n", report.Missing, report.Modified, report.Orphans)
for _, issue := range report.Issues {
entry := "-"
if issue.Entry != nil {
entry = fmt.Sprintf("%d", *issue.Entry)
}
fmt.Fprintf(
&output,
"- %s entry=%s direction=%s reference=%s\n",
issue.Kind,
entry,
issue.Direction,
issue.Reference,
)
}
_, err := io.WriteString(writer, output.String())
return err
}