diff options
| author | srdusr <[email protected]> | 2026-05-19 22:15:00 +0200 |
|---|---|---|
| committer | srdusr <[email protected]> | 2026-05-19 22:15:00 +0200 |
| commit | 3f2c38c4b62f3502dcb64e7b050f3de049518542 (patch) | |
| tree | 6d698821ae24a83bba2ec27396edecbac7b47eb7 /cmd | |
| parent | 4fa511ac5f2aa422456c49610a2cf29dfee46dfd (diff) | |
| download | mitmux-3f2c38c4b62f3502dcb64e7b050f3de049518542.tar.gz mitmux-3f2c38c4b62f3502dcb64e7b050f3de049518542.zip | |
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-<id>.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.
Diffstat (limited to 'cmd')
| -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 |
3 files changed, 203 insertions, 1 deletions
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() } |