From 3f2c38c4b62f3502dcb64e7b050f3de049518542 Mon Sep 17 00:00:00 2001 From: srdusr <99972264+srdusr@users.noreply.github.com> Date: Tue, 19 May 2026 22:15:00 +0200 Subject: Export a history entry to a plain-text file No way existed to get data out of mitmux at all short of querying the SQLite file directly. 'e' from Detail view exports the selected entry's raw request and response bytes to a file - a modal path-prompt (same pattern as Intruder's grep-match/extract edit buffers: enter writes and confirms, esc cancels), prefilled with a sensible default filename (mitmux-entry-.txt). Deliberately plain text, not a structured format: for "attach this to a report" or "grep it later" - the actual use case - the raw bytes as text are the whole point, matching this tool's own raw-bytes-first philosophy rather than reformatting them into something else first. Detail-view-only (like pretty-print), not also from the history list: exporting needs the full EntryDetail with raw bytes, which is already loaded there, so this avoids adding a second load-then-prompt path for one keystroke of convenience. Each side is annotated when it isn't a wire-exact capture - "(truncated - hit capture size limit)" or "(reconstructed, not wire-exact)", matching the same distinction Detail view's own labels already make - so the exported file carries the same trust information the UI shows, not a blanker claim that could mislead whoever reads the export later without the tool's own context. cmd/mitmux/export.go: exportEntryText (pure formatting, unit tested including the non-exact annotation paths) and writeExportFile (a thin os.WriteFile wrapper - a relative path resolves against the process's CWD, same as any other command-line tool; no ~ expansion, that's shell behavior, not something a bare file write should reimplement). Verified live in tmux: exported a real captured entry, confirmed the written file byte-for-byte via cat - correct header, exact request and response bytes including chunked body - and confirmed esc correctly cancels without writing anything. go build/vet/gofmt/test/mod tidy all clean. --- PLAN.md | 22 ++++++++++--- README.md | 4 ++- cmd/mitmux/export.go | 64 ++++++++++++++++++++++++++++++++++++++ cmd/mitmux/export_test.go | 79 +++++++++++++++++++++++++++++++++++++++++++++++ cmd/mitmux/main.go | 61 +++++++++++++++++++++++++++++++++++- 5 files changed, 224 insertions(+), 6 deletions(-) create mode 100644 cmd/mitmux/export.go create mode 100644 cmd/mitmux/export_test.go diff --git a/PLAN.md b/PLAN.md index 90a27fd..c1fc0b6 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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, diff --git a/README.md b/README.md index ccb770c..df744ba 100644 --- a/README.md +++ b/README.md @@ -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() } -- cgit v1.2.3