Files
Tobias GesellchenandClaude Sonnet 4.6 0e9445af47 fix(security): sec7 — log-injection sweep, sanitizeErr helper
~30 remaining go/log-injection alerts share a common pattern: other
positional args in a log call are wrapped in sanitizeLog() but the
trailing 'err' value (via "%v") is not. CodeQL traces taint through
error chains back to the log.Printf call site itself.

Add sanitizeErr(err error) string to every affected package's
logutil.go (strips newlines from err.Error(), returns "<nil>" when
nil). Three packages had no logutil.go yet; new files added for
cmd/soundtouch-cli, cmd/websocket-demo, and examples.

Call-site changes (replace "%v, err" with "%s, sanitizeErr(err)" and
wrap any other unsanitised args in sanitizeLog):

pkg/client:
  - websocket.go:42   DefaultLogger.Printf now pre-formats and sanitises
                       the entire message (all variadic args sanitised)
  - websocket.go:445  err → sanitizeErr(err)

pkg/service/handlers:
  - handlers_account_mgmt.go:44   err
  - handlers_bmx_tunein.go:324,336 err (stationID already sanitised)
  - handlers_marge.go:288,510      err (deviceID/account already done)
  - handlers_mgmt.go:409,436,720  err
  - handlers_setup.go:1345        session + err
  - server.go:500                  bind
  - server.go:504,863,944,1029,   err (deviceIP/accountID already done)
    1164,1174

pkg/service/marge:
  - marge.go:1469,1923  saveErr / err

pkg/service/setup:
  - setup.go:1417,2316,2462  fmt.Printf — deviceIP / hostsContent / ip

pkg/service/stockholm:
  - proxy.go:117  effectiveTarget.String() + err

pkg/service/zeroconf:
  - zeroconf.go:312  err

pkg/service/proxy:
  - recorder.go:403  err (task.path already sanitised)

pkg/service/datastore:
  - datastore.go:940  werr (device already sanitised)

pkg/discovery:
  - dns.go:72   strings.Join(derived)
  - dns.go:503  d.upstreamDNS (fmt.Sprint of []string)

cmd/soundtouch-cli:
  - cmd_events.go:571  VerboseLogger.Printf — pre-format + sanitise
  - common.go:335      PrintError message

cmd/websocket-demo:
  - main.go:576   VerboseLogger.Printf — pre-format + sanitise

examples:
  - recording-filename-demo.go:79  err

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 11:28:26 +02:00

1087 lines
26 KiB
Go

package proxy
import (
"archive/tar"
"bytes"
"compress/gzip"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
)
// Recorder handles persisting HTTP interactions as .http files.
type Recorder struct {
BaseDir string
SessionID string
SessionDir string
Patterns PathPatterns
Redact bool
counter uint64
variables map[string]string
mu sync.Mutex
queue chan recordingTask
// rootMu guards lazy initialisation of root.
rootMu sync.Mutex
// root is an os.Root anchored at BaseDir; all filesystem operations
// that take a caller-derivable path go through it so the Go runtime
// guarantees containment regardless of what the path string contains.
root *os.Root
}
type recordingTask struct {
category string
req *http.Request
res *http.Response
replacements map[string]string
dir string
path string
}
// InteractionStats represents statistics for recorded interactions.
type InteractionStats struct {
TotalRequests int `json:"total_requests"`
ByService map[string]int `json:"by_service"`
BySession map[string]int `json:"by_session"`
}
// Interaction represents a single recorded HTTP interaction.
type Interaction struct {
ID string `json:"id"`
Session string `json:"session"`
Category string `json:"category"`
Method string `json:"method"`
Path string `json:"path"`
File string `json:"file"`
Counter int `json:"counter"`
Status int `json:"status"`
Timestamp string `json:"timestamp"`
SCMUDCData *EnrichedSCMUDCEvent `json:"scmudc_data,omitempty"`
}
// NewRecorder creates a new HTTP interaction recorder.
func NewRecorder(baseDir string) *Recorder {
sessionID := time.Now().Format("20060102-150405") + "-" + fmt.Sprintf("%d", os.Getpid())
r := &Recorder{
BaseDir: baseDir,
SessionID: sessionID,
Patterns: DefaultPatterns(),
variables: make(map[string]string),
}
// Use environment variable to control async recording, default to true for production
// but allow disabling it for tests if needed.
if os.Getenv("RECORDER_ASYNC") != "false" {
r.queue = make(chan recordingTask, 100)
go r.worker()
} else {
log.Println("Recorder starting in synchronous mode")
}
return r
}
// Close stops the recorder and waits for pending tasks to finish.
func (r *Recorder) Close() {
if r.queue != nil {
close(r.queue)
// We might want to wait here, but for now just closing is a start
}
r.rootMu.Lock()
defer r.rootMu.Unlock()
if r.root != nil {
_ = r.root.Close()
r.root = nil
}
}
// getRoot lazily opens the *os.Root anchored at r.BaseDir. The directory is
// MkdirAll-created on first call.
func (r *Recorder) getRoot() (*os.Root, error) {
r.rootMu.Lock()
defer r.rootMu.Unlock()
if r.root != nil {
return r.root, nil
}
if r.BaseDir == "" {
return nil, fmt.Errorf("recorder: BaseDir not configured")
}
if err := os.MkdirAll(r.BaseDir, 0755); err != nil {
return nil, fmt.Errorf("recorder: ensure BaseDir %s: %w", r.BaseDir, err)
}
root, err := os.OpenRoot(r.BaseDir)
if err != nil {
return nil, fmt.Errorf("recorder: open root at %s: %w", r.BaseDir, err)
}
r.root = root
return root, nil
}
// rootRel converts an absolute path under r.BaseDir to its root-relative form.
func (r *Recorder) rootRel(absPath string) (string, error) {
if !filepath.IsAbs(absPath) {
a, err := filepath.Abs(absPath)
if err != nil {
return "", err
}
absPath = a
}
if absPath == r.BaseDir {
return ".", nil
}
rel, err := filepath.Rel(r.BaseDir, absPath)
if err != nil {
return "", fmt.Errorf("recorder: %s outside BaseDir: %w", absPath, err)
}
if rel == "." || rel == "" {
return ".", nil
}
if strings.HasPrefix(rel, "..") {
return "", fmt.Errorf("recorder: %s outside BaseDir", absPath)
}
return rel, nil
}
func (r *Recorder) rootMkdirAll(absPath string, perm os.FileMode) error {
root, err := r.getRoot()
if err != nil {
return err
}
rel, err := r.rootRel(absPath)
if err != nil {
return err
}
if rel == "." {
return nil
}
return root.MkdirAll(rel, perm)
}
func (r *Recorder) rootWriteFile(absPath string, data []byte, perm os.FileMode) error {
root, err := r.getRoot()
if err != nil {
return err
}
rel, err := r.rootRel(absPath)
if err != nil {
return err
}
return root.WriteFile(rel, data, perm)
}
func (r *Recorder) rootReadFile(absPath string) ([]byte, error) {
root, err := r.getRoot()
if err != nil {
return nil, err
}
rel, err := r.rootRel(absPath)
if err != nil {
return nil, err
}
return root.ReadFile(rel)
}
func (r *Recorder) rootStat(absPath string) (os.FileInfo, error) {
root, err := r.getRoot()
if err != nil {
return nil, err
}
rel, err := r.rootRel(absPath)
if err != nil {
return nil, err
}
return root.Stat(rel)
}
func (r *Recorder) rootRemoveAll(absPath string) error {
root, err := r.getRoot()
if err != nil {
return err
}
rel, err := r.rootRel(absPath)
if err != nil {
return err
}
return root.RemoveAll(rel)
}
func (r *Recorder) rootReadDir(absPath string) ([]os.DirEntry, error) {
root, err := r.getRoot()
if err != nil {
return nil, err
}
rel, err := r.rootRel(absPath)
if err != nil {
return nil, err
}
d, err := root.Open(rel)
if err != nil {
return nil, err
}
defer func() { _ = d.Close() }()
// *os.File.ReadDir(-1) returns directory order; os.ReadDir sorts by
// name. Match the sorted contract so callers don't see a surprise.
entries, err := d.ReadDir(-1)
if err != nil {
return entries, err
}
sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() })
return entries, nil
}
func (r *Recorder) rootOpen(absPath string) (*os.File, error) {
root, err := r.getRoot()
if err != nil {
return nil, err
}
rel, err := r.rootRel(absPath)
if err != nil {
return nil, err
}
return root.Open(rel)
}
// Record logs an interaction to the configured category.
func (r *Recorder) Record(category string, req *http.Request, res *http.Response) error {
if r.BaseDir == "" {
return nil
}
sanitizedSegments, replacements := r.getSanitizedSegments(req.URL.Path)
dir, err := r.getRecordingDir(category, sanitizedSegments)
if err != nil {
return err
}
if err := r.rootMkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create directory %s: %w", dir, err)
}
path := r.getRecordingPath(dir, req.Method)
// If we are in async mode, we MUST copy the bodies now because the caller
// might close them as soon as Record() returns.
var (
clonedReq *http.Request
clonedRes *http.Response
)
if r.queue != nil {
// Clone request
clonedReq = req.Clone(req.Context())
if req.Body != nil {
bodyBytes, err := io.ReadAll(req.Body)
if err != nil {
log.Printf("failed to read request body for async recording: %v", err)
clonedReq.Body = http.NoBody
} else {
// Reset original body for subsequent consumers (though Record is usually called at the end)
req.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
// Set body for async task
clonedReq.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
clonedReq.ContentLength = int64(len(bodyBytes))
}
}
// Clone response if present
if res != nil {
clonedRes = &http.Response{
StatusCode: res.StatusCode,
Header: res.Header.Clone(),
Request: clonedReq,
}
if res.Body != nil {
bodyBytes, err := io.ReadAll(res.Body)
if err != nil {
log.Printf("failed to read response body for async recording: %v", err)
res.Body = http.NoBody
clonedRes.Body = http.NoBody
} else {
res.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
clonedRes.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
clonedRes.ContentLength = int64(len(bodyBytes))
}
}
}
} else {
clonedReq = req
clonedRes = res
}
task := recordingTask{
category: category,
req: clonedReq,
res: clonedRes,
replacements: replacements,
dir: dir,
path: path,
}
// For testing purposes or if queue is nil, fallback to synchronous
if r.queue == nil {
r.save(task)
return nil
}
select {
case r.queue <- task:
return nil
default:
return fmt.Errorf("recording queue full, dropping interaction for %s", req.URL.Path)
}
}
func (r *Recorder) save(task recordingTask) {
var (
buf bytes.Buffer
enriched *EnrichedSCMUDCEvent
)
// Check if this is a SCMUDC request and enrich it
if strings.Contains(task.req.URL.Path, "/v1/scmudc/") && task.req.Body != nil {
bodyBytes, err := io.ReadAll(task.req.Body)
if err == nil {
task.req.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
enriched = enrichSCMUDCRequest(bodyBytes)
}
}
r.writeRequestWithEnrichment(&buf, task.req, task.replacements, enriched)
if task.res != nil {
r.writeResponseWithEnrichment(&buf, task.res, enriched)
}
if err := r.rootWriteFile(task.path, buf.Bytes(), 0644); err != nil {
log.Printf("failed to write recording to %s: %s", sanitizeLog(task.path), sanitizeErr(err))
}
_ = r.updateEnvFile(task.replacements)
}
func (r *Recorder) worker() {
for task := range r.queue {
r.save(task)
}
}
func (r *Recorder) getSanitizedSegments(path string) ([]string, map[string]string) {
pathSegments := strings.Split(strings.Trim(path, "/"), "/")
sanitizedSegments := make([]string, 0, len(pathSegments))
replacements := make(map[string]string)
for _, segment := range pathSegments {
if segment == "" {
continue
}
sanitized, replacement := r.Patterns.Sanitize(segment)
sanitizedSegments = append(sanitizedSegments, sanitized)
if replacement != "" {
replacements[segment] = replacement
}
}
return sanitizedSegments, replacements
}
// safeJoin joins r.BaseDir with elem and refuses to construct paths that
// would escape BaseDir. Each element must satisfy filepath.IsLocal — i.e.
// it must not be absolute, must not contain ".." segments, and (on Windows)
// must not name a reserved device. CodeQL recognises filepath.IsLocal as
// a path-traversal sanitiser, so taint analysis at call sites that hand the
// result to os.* terminates here.
func (r *Recorder) safeJoin(elem ...string) (string, error) {
if r.BaseDir == "" {
return "", fmt.Errorf("recorder: BaseDir not configured")
}
for _, e := range elem {
if e == "" {
// filepath.Join silently skips empty components, but
// filepath.IsLocal returns false for "" — treat empties as
// no-ops to preserve the existing call shapes.
continue
}
if !filepath.IsLocal(e) {
return "", fmt.Errorf("recorder: path component %q escapes BaseDir", e)
}
}
return filepath.Join(append([]string{r.BaseDir}, elem...)...), nil
}
func (r *Recorder) getRecordingDir(category string, sanitizedSegments []string) (string, error) {
subDir := "root"
if len(sanitizedSegments) > 0 {
subDir = filepath.Join(sanitizedSegments...)
}
return r.safeJoin("interactions", r.SessionID, category, subDir)
}
func (r *Recorder) getRecordingPath(dir, method string) string {
timestamp := time.Now().Format("20060102-150405.000")
count := atomic.AddUint64(&r.counter, 1)
filename := fmt.Sprintf("%04d-%s-%s.http", count, timestamp, method)
return filepath.Join(dir, filename)
}
func (r *Recorder) writeRequestWithEnrichment(buf *bytes.Buffer, req *http.Request, replacements map[string]string, enriched *EnrichedSCMUDCEvent) {
displayURL := req.URL.String()
for orig, repl := range replacements {
displayURL = strings.ReplaceAll(displayURL, orig, "{{"+strings.Trim(repl, "{}")+"}}")
}
fmt.Fprintf(buf, "### %s %s\n", req.Method, displayURL)
for orig, repl := range replacements {
key := strings.Trim(repl, "{}")
fmt.Fprintf(buf, "// %s: %s\n", key, orig)
}
// Add SCMUDC enriched comments
if enriched != nil {
enrichedComments := generateSCMUDCComments(enriched)
for _, comment := range enrichedComments {
fmt.Fprintf(buf, "%s\n", comment)
}
}
fmt.Fprintf(buf, "%s %s\n", req.Method, displayURL)
fmt.Fprintf(buf, "Host: %s\n", req.Host)
for k, vv := range req.Header {
if r.Redact && isSensitive(k) {
fmt.Fprintf(buf, "%s: [REDACTED]\n", k)
continue
}
for _, v := range vv {
val := v
for orig, repl := range replacements {
val = strings.ReplaceAll(val, orig, "{{"+strings.Trim(repl, "{}")+"}}")
}
fmt.Fprintf(buf, "%s: %s\n", k, val)
}
}
buf.WriteString("\n")
if req.Body != nil {
bodyBytes, err := io.ReadAll(req.Body)
if err == nil {
req.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
buf.Write(bodyBytes)
buf.WriteString("\n")
}
}
}
func (r *Recorder) writeResponseWithEnrichment(buf *bytes.Buffer, res *http.Response, enriched *EnrichedSCMUDCEvent) {
buf.WriteString("\n")
buf.WriteString("> {% \n")
fmt.Fprintf(buf, " // Response: %d %s\n", res.StatusCode, http.StatusText(res.StatusCode))
// Add SCMUDC enrichment summary in response
if enriched != nil {
buf.WriteString(" //\n")
buf.WriteString(" // SCMUDC Event Analysis:\n")
fmt.Fprintf(buf, " // - Origin: %s (%s)\n", getOriginDescription(enriched.Origin), enriched.Origin)
fmt.Fprintf(buf, " // - Action: %s\n", enriched.Action)
fmt.Fprintf(buf, " // - Summary: %s\n", enriched.Summary)
if enriched.DecodedData != nil {
fmt.Fprintf(buf, " // - Content: %s\n", enriched.DecodedData.ItemName)
if enriched.DecodedData.SourceAccount != "" {
fmt.Fprintf(buf, " // - Account: %s\n", enriched.DecodedData.SourceAccount)
}
}
}
buf.WriteString(" //\n")
buf.WriteString(" // Headers:\n")
for k, vv := range res.Header {
if r.Redact && isSensitive(k) {
fmt.Fprintf(buf, " // %s: [REDACTED]\n", k)
continue
}
for _, v := range vv {
fmt.Fprintf(buf, " // %s: %s\n", k, v)
}
}
buf.WriteString("%}\n")
if res.Body != nil {
bodyBytes, err := io.ReadAll(res.Body)
if err == nil {
res.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
contentType := res.Header.Get("Content-Type")
if strings.Contains(contentType, "xml") || strings.Contains(contentType, "json") || strings.Contains(contentType, "text") {
buf.WriteString("\n/*\n")
buf.Write(bodyBytes)
buf.WriteString("\n*/\n")
} else if len(bodyBytes) > 0 {
fmt.Fprintf(buf, "\n[Binary response body: %d bytes]\n", len(bodyBytes))
}
}
}
}
func (r *Recorder) updateEnvFile(newVars map[string]string) error {
if len(newVars) == 0 {
return nil
}
r.mu.Lock()
defer r.mu.Unlock()
changed := false
for orig, repl := range newVars {
key := strings.Trim(repl, "{}")
if r.variables[key] != orig {
r.variables[key] = orig
changed = true
}
}
if !changed {
return nil
}
envFile := filepath.Join(r.BaseDir, "interactions", r.SessionID, "http-client.env.json")
// Create the structure: {"session": {"key": "val"}}
content := map[string]map[string]string{
"session": r.variables,
}
data, err := json.MarshalIndent(content, "", " ")
if err != nil {
return err
}
return r.rootWriteFile(envFile, data, 0644)
}
// GetInteractionStats returns statistics about recorded interactions.
func (r *Recorder) GetInteractionStats() (*InteractionStats, error) {
stats := &InteractionStats{
ByService: make(map[string]int),
BySession: make(map[string]int),
}
interactionsDir := filepath.Join(r.BaseDir, "interactions")
if _, err := r.rootStat(interactionsDir); os.IsNotExist(err) {
return stats, nil
}
err := filepath.Walk(interactionsDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() && strings.HasSuffix(info.Name(), ".http") {
stats.TotalRequests++
// Extract category (self/upstream) and session from path
// Path is like: .../interactions/<session>/<category>/...
rel, err := filepath.Rel(interactionsDir, path)
if err != nil {
return err
}
parts := strings.Split(rel, string(filepath.Separator))
if len(parts) >= 2 {
sessionID := parts[0]
category := parts[1]
stats.BySession[sessionID]++
stats.ByService[category]++
}
}
return nil
})
return stats, err
}
// ListInteractions returns a list of recorded interactions.
func (r *Recorder) ListInteractions(sessionFilter, categoryFilter, sinceFilter string) ([]Interaction, error) {
interactions := make([]Interaction, 0)
interactionsDir := filepath.Join(r.BaseDir, "interactions")
if _, err := r.rootStat(interactionsDir); os.IsNotExist(err) {
return interactions, nil
}
err := filepath.Walk(interactionsDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() || !strings.HasSuffix(info.Name(), ".http") {
return nil
}
rel, err := filepath.Rel(interactionsDir, path)
if err != nil {
return err
}
parts := strings.Split(rel, string(filepath.Separator))
if len(parts) < 3 {
return nil
}
sessionID, category := parts[0], parts[1]
if (sessionFilter != "" && sessionID != sessionFilter) || (categoryFilter != "" && category != categoryFilter) {
return nil
}
interaction, ok := r.parseInteractionFile(rel, path, parts)
if !ok {
return nil
}
if sinceFilter != "" && interaction.Timestamp != "" {
fullTS := r.getFullTimestamp(sessionID, interaction.ID)
normalizedSince := strings.ReplaceAll(strings.ReplaceAll(sinceFilter, ":", "-"), " ", "-")
if fullTS != "" && fullTS < normalizedSince {
return nil
}
}
interactions = append(interactions, interaction)
return nil
})
return interactions, err
}
func (r *Recorder) parseInteractionFile(rel, path string, parts []string) (Interaction, bool) {
sessionID, category := parts[0], parts[1]
filename := parts[len(parts)-1]
fnParts := strings.Split(strings.TrimSuffix(filename, ".http"), "-")
timestamp := ""
method, counter := "UNKNOWN", 0
if len(fnParts) >= 1 {
_, _ = fmt.Sscanf(fnParts[0], "%d", &counter)
}
// Check if this is the new format: count-yyyyMMdd-HHMMSS.sss-method.http
// New format has 4 parts and the second part is 8 digits (yyyyMMdd)
if len(fnParts) == 4 && len(fnParts[1]) == 8 {
dateStr := fnParts[1] // yyyyMMdd
timeStr := fnParts[2] // HHMMSS.sss
method = fnParts[3]
// Format date: yyyyMMdd -> yyyy-MM-dd
date := dateStr[0:4] + "-" + dateStr[4:6] + "-" + dateStr[6:8]
// Format time: HHMMSS.sss -> HH:MM:SS.sss
if len(timeStr) >= 6 {
time := timeStr[0:2] + ":" + timeStr[2:4] + ":" + timeStr[4:]
timestamp = date + " " + time
}
} else if len(fnParts) >= 5 {
// Legacy format: count-HH-MM-SS.sss-method.http
// Extract date from sessionID for backward compatibility
date := ""
if len(sessionID) >= 8 {
date = sessionID[0:4] + "-" + sessionID[4:6] + "-" + sessionID[6:8]
}
timeStr := fnParts[1] + ":" + fnParts[2] + ":" + fnParts[3]
timestamp = timeStr
if date != "" {
timestamp = date + " " + timeStr
}
method = fnParts[4]
}
requestPath := "/" + strings.Join(parts[2:len(parts)-1], "/")
if requestPath == "/root" {
requestPath = "/"
}
interaction := Interaction{
ID: filename,
Session: sessionID,
Category: category,
Method: method,
Path: requestPath,
File: rel,
Counter: counter,
Status: r.peekStatus(path),
Timestamp: timestamp,
}
// Extract SCMUDC enrichment data if this is a SCMUDC request
if strings.Contains(requestPath, "/v1/scmudc/") {
interaction.SCMUDCData = r.extractSCMUDCFromFile(path)
}
return interaction, true
}
// extractSCMUDCFromFile parses SCMUDC enrichment data from a .http file
func (r *Recorder) extractSCMUDCFromFile(path string) *EnrichedSCMUDCEvent {
content, err := r.rootReadFile(path)
if err != nil {
return nil
}
lines := strings.Split(string(content), "\n")
var (
enriched EnrichedSCMUDCEvent
foundSCMUDC bool
bodyStart int
)
// Look for SCMUDC enrichment comments
for i, line := range lines {
line = strings.TrimSpace(line)
switch {
case strings.HasPrefix(line, "// Origin: "):
parts := strings.Split(line, " (")
if len(parts) >= 2 {
enriched.Origin = strings.TrimSuffix(parts[1], ")")
foundSCMUDC = true
}
case strings.HasPrefix(line, "// Action: "):
enriched.Action = strings.TrimPrefix(line, "// Action: ")
case strings.HasPrefix(line, "// Command: "):
enriched.Command = strings.TrimPrefix(line, "// Command: ")
case strings.HasPrefix(line, "// Summary: "):
enriched.Summary = strings.TrimPrefix(line, "// Summary: ")
case strings.HasPrefix(line, "// - Source: "):
r.ensureDecodedData(&enriched)
enriched.DecodedData.ContentType = strings.TrimPrefix(line, "// - Source: ")
case strings.HasPrefix(line, "// - Item: "):
r.ensureDecodedData(&enriched)
enriched.DecodedData.ItemName = strings.TrimPrefix(line, "// - Item: ")
case strings.HasPrefix(line, "// - Account: "):
r.ensureDecodedData(&enriched)
enriched.DecodedData.SourceAccount = strings.TrimPrefix(line, "// - Account: ")
case strings.HasPrefix(line, "// - Artwork: "):
r.ensureDecodedData(&enriched)
enriched.DecodedData.ArtworkURL = strings.TrimPrefix(line, "// - Artwork: ")
case line == "// - Presetable: Yes":
r.ensureDecodedData(&enriched)
enriched.DecodedData.IsPresetable = true
case line == "{" && i > 0:
// Found start of JSON body
bodyStart = i
goto endLoop
}
}
endLoop:
// If we didn't find enrichment comments but this is a SCMUDC request,
// try to parse the JSON body directly
if !foundSCMUDC && bodyStart > 0 {
if parsed := r.parseSCMUDBody(lines, bodyStart); parsed != nil {
return parsed
}
}
if !foundSCMUDC {
return nil
}
return &enriched
}
func (r *Recorder) ensureDecodedData(enriched *EnrichedSCMUDCEvent) {
if enriched.DecodedData == nil {
enriched.DecodedData = &DecodedContent{}
}
}
func (r *Recorder) parseSCMUDBody(lines []string, bodyStart int) *EnrichedSCMUDCEvent {
var bodyLines []string
inBody := false
braceCount := 0
for i := bodyStart; i < len(lines); i++ {
line := lines[i]
if strings.TrimSpace(line) == "{" && !inBody {
inBody = true
bodyLines = append(bodyLines, line)
braceCount = 1
} else if inBody {
bodyLines = append(bodyLines, line)
braceCount += strings.Count(line, "{") - strings.Count(line, "}")
if braceCount == 0 {
break
}
}
}
if len(bodyLines) > 0 {
bodyJSON := strings.Join(bodyLines, "\n")
return enrichSCMUDCRequest([]byte(bodyJSON))
}
return nil
}
func (r *Recorder) getFullTimestamp(sessionID, filename string) string {
fnParts := strings.Split(strings.TrimSuffix(filename, ".http"), "-")
// Check if this is the new format: count-yyyyMMdd-HHMMSS.sss-method.http
// New format has 4 parts and the second part is 8 digits (yyyyMMdd)
if len(fnParts) == 4 && len(fnParts[1]) == 8 {
dateStr := fnParts[1] // yyyyMMdd
timeStr := fnParts[2] // HHMMSS.sss
if len(timeStr) >= 6 {
date := dateStr[0:4] + "-" + dateStr[4:6] + "-" + dateStr[6:8]
time := timeStr[0:2] + "-" + timeStr[2:4] + "-" + timeStr[4:]
return date + "-" + time
}
} else if len(fnParts) >= 5 {
// Legacy format: count-HH-MM-SS.sss-method.http
if len(sessionID) < 8 {
return ""
}
date := sessionID[0:4] + "-" + sessionID[4:6] + "-" + sessionID[6:8]
return date + "-" + fnParts[1] + "-" + fnParts[2] + "-" + fnParts[3]
}
return ""
}
func (r *Recorder) peekStatus(path string) int {
content, err := r.rootReadFile(path)
if err != nil {
return 0
}
lines := strings.Split(string(content), "\n")
for _, line := range lines {
if !strings.Contains(line, "// Response:") {
continue
}
trimmedLine := strings.TrimPrefix(strings.TrimSpace(line), "//")
trimmedLine = strings.TrimPrefix(strings.TrimSpace(trimmedLine), "Response:")
trimmedLine = strings.TrimSpace(trimmedLine)
status := 0
_, _ = fmt.Sscanf(trimmedLine, "%d", &status)
return status
}
return 0
}
// DeleteSession deletes a specific recording session.
func (r *Recorder) DeleteSession(sessionID string) error {
if sessionID == "" {
return fmt.Errorf("session ID is required")
}
sessionDir, err := r.safeJoin("interactions", sessionID)
if err != nil {
return err
}
return r.rootRemoveAll(sessionDir)
}
// CleanupSessions deletes all but the most recent keepCount sessions.
func (r *Recorder) CleanupSessions(keepCount int) error {
interactionsDir := filepath.Join(r.BaseDir, "interactions")
entries, err := r.rootReadDir(interactionsDir)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
var sessions []os.DirEntry
for _, entry := range entries {
if entry.IsDir() {
sessions = append(sessions, entry)
}
}
if len(sessions) <= keepCount {
return nil
}
// Sort sessions by name (timestamp) descending to keep the newest ones
// Session ID format: 20260102-150405-PID
sort.Slice(sessions, func(i, j int) bool {
return sessions[i].Name() > sessions[j].Name()
})
for i := keepCount; i < len(sessions); i++ {
sessionDir := filepath.Join(interactionsDir, sessions[i].Name())
if err := r.rootRemoveAll(sessionDir); err != nil {
return fmt.Errorf("failed to delete session %s: %w", sessions[i].Name(), err)
}
}
return nil
}
// GetInteractionContent returns the raw content of a recorded interaction.
func (r *Recorder) GetInteractionContent(relPath string) ([]byte, error) {
fullPath, err := r.safeJoin("interactions", relPath)
if err != nil {
return nil, err
}
return r.rootReadFile(fullPath)
}
// ArchiveSession creates a .tar.gz archive of the specified session and writes it to w.
func (r *Recorder) ArchiveSession(sessionID string, w io.Writer) (err error) {
sessionDir, err := r.safeJoin("interactions", sessionID)
if err != nil {
return err
}
info, statErr := r.rootStat(sessionDir)
if statErr != nil {
return statErr
}
if !info.IsDir() {
return fmt.Errorf("%s is not a directory", sessionID)
}
gw := gzip.NewWriter(w)
defer func() {
if closeErr := gw.Close(); closeErr != nil && err == nil {
err = closeErr
}
}()
tw := tar.NewWriter(gw)
defer func() {
if closeErr := tw.Close(); closeErr != nil && err == nil {
err = closeErr
}
}()
return filepath.Walk(sessionDir, func(path string, info os.FileInfo, walkErr error) error {
if walkErr != nil {
return walkErr
}
header, hErr := tar.FileInfoHeader(info, info.Name())
if hErr != nil {
return hErr
}
rel, rErr := filepath.Rel(sessionDir, path)
if rErr != nil {
return rErr
}
header.Name = rel
if whErr := tw.WriteHeader(header); whErr != nil {
return whErr
}
if !info.Mode().IsRegular() {
return nil
}
f, oErr := r.rootOpen(path)
if oErr != nil {
return oErr
}
defer func() { _ = f.Close() }()
_, cErr := io.Copy(tw, f)
return cErr
})
}