srdusr
aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-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()
}