diff options
| -rw-r--r-- | PLAN.md | 22 | ||||
| -rw-r--r-- | README.md | 4 | ||||
| -rw-r--r-- | cmd/mitmux/export.go | 64 | ||||
| -rw-r--r-- | cmd/mitmux/export_test.go | 79 | ||||
| -rw-r--r-- | cmd/mitmux/main.go | 61 |
5 files changed, 224 insertions, 6 deletions
@@ -240,10 +240,24 @@ TUI model, checked first in the history list's key handling, so any key other than y/Y safely cancels rather than falling through to whatever that key normally does. -Still open from the expanded "worth considering" list: export/import -(HAR, copy-as-curl), and scope/target filtering (to keep noise - -trackers, CDNs, unrelated third-party hosts - out of history and -search). Both requested explicitly; not started yet. +Shipped since: single-entry export. `e` from Detail view writes the +selected entry's raw request and response bytes to a plain-text file - +a modal path-prompt (same pattern as the Intruder grep-match/extract +edit buffers: enter writes and confirms, esc cancels), prefilled with a +sensible default filename. Deliberately plain text, not a structured +format: for "attach this to a report" or "grep it later," the raw bytes +as text are the point, matching this tool's own raw-bytes-first +philosophy rather than reformatting them into something else. Each +side is annotated when it isn't a wire-exact capture (reconstructed vs. +truncated, matching the Detail view's own labels) so the exported file +carries the same trust information the UI already shows, not a blanker +claim. + +Still open from the expanded "worth considering" list: bulk export +(HAR, for interop with other tools), copy-as-curl, and scope/target +filtering (to keep noise - trackers, CDNs, unrelated third-party +hosts - out of history and search). All requested explicitly; none +started yet. Skipped deliberately (from the research, matches this tool's stated scope): active/passive vulnerability scanning, plugin marketplace, @@ -168,7 +168,9 @@ below is enough to get going. `tab` switches request/response, `p` toggles pretty-printed JSON on the response (display-only - never touches the stored or resent bytes), `c` mark/compare (same as the history list), `r`/`i` jump straight to -Repeater/Intruder seeded from this entry, `esc` back. +Repeater/Intruder seeded from this entry, `e` exports the entry +(request and response, raw bytes, plain text - type a path and press +enter), `esc` back. ### Comparer diff --git a/cmd/mitmux/export.go b/cmd/mitmux/export.go new file mode 100644 index 0000000..5d11ca8 --- /dev/null +++ b/cmd/mitmux/export.go @@ -0,0 +1,64 @@ +package main + +import ( + "fmt" + "os" + "strings" + "time" + + "mitmux/internal/ipc" +) + +// exportEntryText renders one history entry as a plain-text file: a +// short header of metadata, then the raw request and response bytes +// verbatim - exactly what's already shown in Detail view, just written +// to disk instead of a viewport. Deliberately plain text, not a +// structured format: for the "attach this to a report" / "grep it +// later" use case, the raw bytes as text are the whole point, matching +// this tool's own "raw bytes are the source of truth" philosophy rather +// than reformatting them into something else first. +func exportEntryText(d *ipc.EntryDetail) string { + var b strings.Builder + fmt.Fprintf(&b, "# mitmux export - entry #%d\n", d.ID) + fmt.Fprintf(&b, "# %s %s://%s%s -> %d\n", d.Method, d.Scheme, d.Host, d.Path, d.StatusCode) + fmt.Fprintf(&b, "# captured %s, duration %s\n", d.StartedAt.Format(time.RFC3339), d.Duration) + if d.Error != "" { + fmt.Fprintf(&b, "# error: %s\n", d.Error) + } + b.WriteString("\n=== Request") + b.WriteString(exportSuffix(d.RequestExact, d.RequestTruncated)) + b.WriteString(" ===\n") + b.Write(d.RequestRaw) + + b.WriteString("\n\n=== Response") + b.WriteString(exportSuffix(d.ResponseExact, d.ResponseTruncated)) + b.WriteString(" ===\n") + b.Write(d.ResponseRaw) + b.WriteString("\n") + return b.String() +} + +func exportSuffix(exact, truncated bool) string { + switch { + case exact: + return "" + case truncated: + return " (truncated - hit capture size limit)" + default: + return " (reconstructed, not wire-exact)" + } +} + +// writeExportFile writes content to path, creating or overwriting it. +// path is used as given - os.WriteFile itself already resolves a +// relative path against the process's current working directory, and +// this deliberately doesn't second-guess an absolute path or expand a +// leading ~ (that's shell/readline behavior, not something a bare file +// write should reimplement); a user who wants their home directory can +// type it out or pass an absolute path. +func writeExportFile(path, content string) error { + if path == "" { + return fmt.Errorf("no path given") + } + return os.WriteFile(path, []byte(content), 0o644) +} diff --git a/cmd/mitmux/export_test.go b/cmd/mitmux/export_test.go new file mode 100644 index 0000000..03c38e0 --- /dev/null +++ b/cmd/mitmux/export_test.go @@ -0,0 +1,79 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "mitmux/internal/ipc" + "mitmux/internal/store" +) + +func TestExportEntryText(t *testing.T) { + d := &ipc.EntryDetail{ + Summary: store.Summary{ + ID: 42, Method: "GET", Scheme: "https", Host: "example.com", Path: "/a", + StatusCode: 200, StartedAt: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC), Duration: 150 * time.Millisecond, + }, + RequestRaw: []byte("GET /a HTTP/1.1\r\nHost: example.com\r\n\r\n"), + ResponseRaw: []byte("HTTP/1.1 200 OK\r\n\r\nhi"), + RequestExact: true, + ResponseExact: true, + } + got := exportEntryText(d) + + for _, want := range []string{ + "entry #42", + "GET https://example.com/a -> 200", + "=== Request ===", + "GET /a HTTP/1.1", + "=== Response ===", + "HTTP/1.1 200 OK", + "hi", + } { + if !strings.Contains(got, want) { + t.Errorf("exportEntryText output missing %q, got:\n%s", want, got) + } + } +} + +func TestExportEntryTextAnnotatesNonExact(t *testing.T) { + d := &ipc.EntryDetail{ + Summary: store.Summary{ID: 1, Method: "GET", Scheme: "http", Host: "x", Path: "/"}, + RequestRaw: []byte("req"), + ResponseRaw: []byte("resp"), + RequestExact: false, + ResponseExact: false, + ResponseTruncated: true, + } + got := exportEntryText(d) + if !strings.Contains(got, "=== Request (reconstructed, not wire-exact) ===") { + t.Errorf("expected request to be annotated as reconstructed, got:\n%s", got) + } + if !strings.Contains(got, "=== Response (truncated - hit capture size limit) ===") { + t.Errorf("expected response to be annotated as truncated, got:\n%s", got) + } +} + +func TestWriteExportFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "out.txt") + if err := writeExportFile(path, "hello"); err != nil { + t.Fatalf("writeExportFile: %v", err) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read back: %v", err) + } + if string(got) != "hello" { + t.Errorf("got %q, want %q", got, "hello") + } +} + +func TestWriteExportFileEmptyPath(t *testing.T) { + if err := writeExportFile("", "x"); err == nil { + t.Error("expected an error for an empty path, got nil") + } +} diff --git a/cmd/mitmux/main.go b/cmd/mitmux/main.go index dbc919d..d4811dc 100644 --- a/cmd/mitmux/main.go +++ b/cmd/mitmux/main.go @@ -211,6 +211,14 @@ type model struct { confirmPrompt string confirmYes func() tea.Cmd + // exportEditing mirrors the grep-match/extract edit-buffer pattern: + // a modal textinput overlay for the destination path, enter writes + // and confirms, esc cancels. Detail-view only (like pretty-print) - + // exporting needs the full EntryDetail with raw bytes, which is + // already loaded there. + exportEditing bool + exportInput textinput.Model + statusMsg string width int height int @@ -289,6 +297,9 @@ func newModel(client *ipc.Client, subCh <-chan store.Summary, socketPath string) geIn := textinput.New() geIn.Placeholder = "regexp - extracts first capture group (or whole match) from the response" + expIn := textinput.New() + expIn.Placeholder = "file path to write" + return &model{ client: client, subCh: subCh, @@ -308,6 +319,7 @@ func newModel(client *ipc.Client, subCh <-chan store.Summary, socketPath string) grepMatchInput: gmIn, grepExtractInput: geIn, decoderInput: din, + exportInput: expIn, } } @@ -736,6 +748,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.intruderResults.SetHeight(h - 9 - itmplHeight - ipayloadsHeight) m.grepMatchInput.Width = msg.Width - 2 m.grepExtractInput.Width = msg.Width - 2 + m.exportInput.Width = msg.Width - 2 return m, nil case listLoadedMsg: @@ -1011,6 +1024,32 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd case viewDetail: + if m.exportEditing { + switch msg.String() { + case "enter": + path := m.exportInput.Value() + m.exportEditing = false + m.exportInput.Blur() + if m.detail == nil { + return m, nil + } + if err := writeExportFile(path, exportEntryText(m.detail)); err != nil { + m.statusMsg = "export error: " + err.Error() + } else { + m.statusMsg = "exported to " + path + } + return m, nil + case "esc": + m.exportEditing = false + m.exportInput.Blur() + return m, nil + case "ctrl+c": + return m, tea.Quit + } + var cmd tea.Cmd + m.exportInput, cmd = m.exportInput.Update(msg) + return m, cmd + } switch msg.String() { case "q", "esc": m.mode = viewList @@ -1031,6 +1070,14 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.enterIntruder(m.detail) } return m, nil + case "e": + if m.detail != nil { + m.exportEditing = true + m.exportInput.SetValue(fmt.Sprintf("mitmux-entry-%d.txt", m.detail.ID)) + m.exportInput.CursorEnd() + return m, m.exportInput.Focus() + } + return m, nil case "p": m.prettyMode = !m.prettyMode if m.detail != nil { @@ -1521,6 +1568,7 @@ func (m *model) helpView() string { "p toggle pretty-printed JSON (response only, display-only)", "c mark/compare (same as history list)", "r / i open in Repeater / Intruder", + "e export this entry to a file (request + response, plain text)", "esc / q back to history", ) section("Comparer", @@ -1633,7 +1681,18 @@ func (m *model) detailView() string { b.WriteString("\n") b.WriteString(m.viewport.View()) b.WriteString("\n") - b.WriteString(helpStyle.Render("tab switch · p pretty-print · c compare · r repeater · i intruder · esc back · ? help · q quit")) + if m.exportEditing { + b.WriteString("export to: ") + b.WriteString(m.exportInput.View()) + b.WriteString("\n") + b.WriteString(helpStyle.Render("enter write · esc cancel · ctrl+c quit")) + return b.String() + } + if m.statusMsg != "" { + b.WriteString(statusStyle.Render(sanitizeLine(m.statusMsg))) + b.WriteString("\n") + } + b.WriteString(helpStyle.Render("tab switch · p pretty-print · c compare · r repeater · i intruder · e export · esc back · ? help · q quit")) return b.String() } |