srdusr
aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2026-05-19 22:15:00 +0200
committersrdusr <[email protected]>2026-05-19 22:15:00 +0200
commit3f2c38c4b62f3502dcb64e7b050f3de049518542 (patch)
tree6d698821ae24a83bba2ec27396edecbac7b47eb7
parent4fa511ac5f2aa422456c49610a2cf29dfee46dfd (diff)
downloadmitmux-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.
-rw-r--r--PLAN.md22
-rw-r--r--README.md4
-rw-r--r--cmd/mitmux/export.go64
-rw-r--r--cmd/mitmux/export_test.go79
-rw-r--r--cmd/mitmux/main.go61
5 files changed, 224 insertions, 6 deletions
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()
}