internal/source/file.go

package source

import (
	"context"
	"encoding/json"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"strings"
	"time"
)

// File replays a capture written to disk. Two formats are supported:
//
//   - *.har:  HTTP Archive (only the minimum fields needed for display)
//   - *.txt:  raw framed bytes, the same layout produced by unix.WriteFrame
type File struct {
	path    string
	format  string
	idx     int
	har     *harRoot
	rawFile *os.File
}

type harRoot struct {
	Log struct {
		Entries []harEntry `json:"entries"`
	} `json:"log"`
}

type harEntry struct {
	StartedDateTime time.Time `json:"startedDateTime"`
	Request         struct {
		Method      string         `json:"method"`
		URL         string         `json:"url"`
		HTTPVersion string         `json:"httpVersion"`
		Headers     []harHeader    `json:"headers"`
		PostData    harPostData    `json:"postData,omitempty"`
	} `json:"request"`
	Response struct {
		Status      int           `json:"status"`
		StatusText  string        `json:"statusText"`
		HTTPVersion string        `json:"httpVersion"`
		Headers     []harHeader   `json:"headers"`
		Content     harBodyFragment `json:"content"`
	} `json:"response"`
}

type harHeader struct{ Name, Value string }
type harPostData struct{ Text string `json:"text"` }
type harBodyFragment struct{ Text string `json:"text"` }

// OpenFile inspects the filename suffix to pick a decoder.
func OpenFile(path string) (*File, error) {
	f := &File{path: path, format: strings.ToLower(filepath.Ext(path))}
	switch f.format {
	case ".har", ".json":
		data, err := os.ReadFile(path)
		if err != nil {
			return nil, err
		}
		if err := json.Unmarshal(data, &f.har); err != nil {
			return nil, fmt.Errorf("file: parse har: %w", err)
		}
	case ".txt", ".bin":
		fh, err := os.Open(path)
		if err != nil {
			return nil, err
		}
		f.rawFile = fh
	default:
		return nil, fmt.Errorf("file: unsupported extension %q", f.format)
	}
	return f, nil
}

// Next yields one entry, rendered back to raw HTTP/1.1 wire bytes for
// the HAR backend, or one Event for the raw backend.
func (f *File) Next(ctx context.Context) (*Event, error) {
	if f.har != nil {
		if f.idx >= len(f.har.Log.Entries) {
			return nil, io.EOF
		}
		ent := f.har.Log.Entries[f.idx]
		f.idx++
		return &Event{
			ConnID:    uint64(f.idx),
			Direction: 0,
			Payload:   renderHARRequest(ent),
			At:        ent.StartedDateTime,
		}, nil
	}
	return nil, io.EOF
}

// Close releases the backing file if any.
func (f *File) Close() error {
	if f.rawFile != nil {
		return f.rawFile.Close()
	}
	return nil
}

func renderHARRequest(e harEntry) []byte {
	var b strings.Builder
	fmt.Fprintf(&b, "%s %s %s\r\n", e.Request.Method, e.Request.URL, e.Request.HTTPVersion)
	for _, h := range e.Request.Headers {
		fmt.Fprintf(&b, "%s: %s\r\n", h.Name, h.Value)
	}
	b.WriteString("\r\n")
	b.WriteString(e.Request.PostData.Text)
	return []byte(b.String())
}