srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/cmd
diff options
context:
space:
mode:
Diffstat (limited to 'cmd')
-rw-r--r--cmd/mitmux/export.go64
-rw-r--r--cmd/mitmux/export_test.go79
-rw-r--r--cmd/mitmux/main.go61
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()
}