srdusr
aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2026-05-22 21:47:00 +0200
committersrdusr <[email protected]>2026-05-22 21:47:00 +0200
commitd522b1177a9e1fdd04888121975f2b3509d19564 (patch)
tree8f4a8acb9ce73ba7d656cdb5c05c26002e3a028c
parent51811b67018515366bd56f3c3b21aed11d906db2 (diff)
downloadmitmux-d522b1177a9e1fdd04888121975f2b3509d19564.tar.gz
mitmux-d522b1177a9e1fdd04888121975f2b3509d19564.zip
CSV export and copy-as-curl (multiple export formats, like Burp)
Both extend the existing export system by dispatching on the file extension the user types, rather than adding a separate format- selection control - consistent with how any "save as" dialog already works, and requires no new UI beyond what export already has. Bulk export ('E' from the history list): .har (existing default) or .csv. CSV is a summary table (id/method/host/path/status/sizes/timing/ flag/source) built directly from the already-loaded Summary rows - deliberately lighter and faster than HAR, which needs a per-entry fetch from the daemon to get raw bytes. This mirrors Burp's own "export as CSV" being a listing for a report/spreadsheet, not a full-fidelity capture format - HAR already covers that need. Single-entry export ('e' from Detail view): .txt (existing default, unchanged) or .sh/.curl - the request re-serialized as a runnable curl command line (Burp/DevTools' "copy as curl"), for handing to someone else or re-running standalone without mitmux. Every value is shell- quoted (single-quote wrapping with '\'' escaping for embedded quotes) - a captured or edited request is exactly the kind of content that might contain shell metacharacters, so naive string concatenation would risk producing a command that does something other than what it appears to when pasted into a shell. Verified by actually executing a generated command against the real target and confirming the response matched the original request. CSV export needed one thing HAR didn't: a guard against CSV/formula injection. Method, host, path, and error all ultimately trace back to a request line or Host header - content this tool exists specifically to inspect from potentially hostile traffic - and a field starting with =, +, -, @, tab, or CR is a formula to Excel/LibreOffice/Sheets when the exported file is later opened, which can call out to other cells, external data, or worse depending on the app and its settings. csvSafe prefixes any such field with a single quote before writing - the standard mitigation per OWASP's own CSV injection guidance - so every affected spreadsheet application treats it as literal text instead. This is the same class of bug as the terminal-injection fix from the earlier robustness audit, just for a different output format: captured content controlling whatever later processes it, rather than the terminal that renders it. cmd/mitmux/csv.go: csvFromSummaries + csvSafe, unit tested including the formula-injection neutralization specifically. cmd/mitmux/export.go: curlCommand + shellQuote, plus singleEntryFormat/bulkExportFormat extension-dispatch helpers, all unit tested (curl generation, malformed- request handling, shell-quote escaping of embedded quotes). go build/vet/gofmt/test/mod tidy all clean.
-rw-r--r--PLAN.md35
-rw-r--r--README.md45
-rw-r--r--cmd/mitmux/csv.go73
-rw-r--r--cmd/mitmux/csv_test.go84
-rw-r--r--cmd/mitmux/export.go100
-rw-r--r--cmd/mitmux/export_test.go70
-rw-r--r--cmd/mitmux/main.go56
7 files changed, 432 insertions, 31 deletions
diff --git a/PLAN.md b/PLAN.md
index a3ca810..a070b20 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -306,11 +306,38 @@ recorded despite being out of scope, confirmed toggling the rule off
resumed recording everything, and confirmed both the substring and
regex pattern forms save and match correctly.
+Shipped since: CSV export and copy-as-curl, both extending the existing
+export system by dispatching on the file extension the user types
+rather than adding a separate format-selection control - ".har" (the
+existing default) or ".csv" for bulk export from the history list,
+".txt" (the existing default) or ".sh"/".curl" for single-entry export
+from Detail view. CSV is deliberately a lighter, faster path than HAR:
+a summary table (id/method/host/path/status/sizes/timing/flag/source)
+built straight from the already-loaded Summary rows, no per-entry fetch
+from the daemon needed, matching Burp's own "export as CSV" being a
+listing rather than a full-fidelity capture - HAR already covers that.
+Copy-as-curl parses the raw request (same net/http parsing used
+everywhere else in this codebase) and re-serializes it as a runnable
+curl command line rather than another copy of the raw bytes, which the
+plain-text format already gives you; verified by actually executing a
+generated command against the real target and confirming the response
+matched the original.
+
+CSV export required one more thing HAR didn't: guarding against CSV/
+formula injection. Method, host, path, and error all ultimately trace
+back to a request line or Host header - content this tool exists
+specifically to inspect from potentially hostile traffic - and a field
+starting with =, +, -, @, tab, or CR is a formula to Excel/LibreOffice/
+Sheets when the exported file is later opened. csvSafe prefixes any
+such field with a single quote, the standard mitigation (OWASP's own
+guidance), so every affected spreadsheet application treats it as
+literal text instead. This is the same class of bug as the terminal-
+injection fix from the robustness audit, just for a different output
+format - captured content controlling the tool that later processes it,
+rather than the terminal that renders it.
+
Still open from the expanded "worth considering" list: import (no path
-back in yet - HAR export was prioritized as the more common daily need,
-getting captured evidence OUT for a report or another tool, over
-bringing traffic IN) and copy-as-curl. Both requested explicitly; not
-started yet.
+back in yet). Requested explicitly; not 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 de8274e..7058180 100644
--- a/README.md
+++ b/README.md
@@ -158,7 +158,7 @@ below is enough to get going.
| `c` | mark for comparison - press `c` on another entry to diff |
| `x` | delete the selected entry (asks `y`/`n` to confirm) |
| `X` | clear ALL history, not just the current search filter (asks `y`/`n` to confirm) |
-| `E` | export the current view (respects an active search filter) as a HAR file |
+| `E` | export the current view (respects an active search filter) - `.har` or `.csv` |
| `d` | Decoder |
| `/` | search |
| `m` | match-and-replace rules |
@@ -196,20 +196,37 @@ only - not chained/pipelined the way Burp's Decoder supports.
### Export
Two independent export paths, both a modal path-prompt (`enter` writes
-and confirms, `esc` cancels):
-
-- `e` from Detail view exports the single selected entry - request and
- response raw bytes, plain text, exactly what Detail view already
- shows. Each side is annotated when it isn't wire-exact (truncated or
- reconstructed), matching Detail view's own labels.
+and confirms, `esc` cancels). Format is picked by the extension you
+type, the same convention any "save as" dialog uses - no separate
+format-selection control:
+
+- `e` from Detail view exports the single selected entry.
+ - `.txt` (default) - request and response raw bytes, plain text,
+ exactly what Detail view already shows. Each side is annotated when
+ it isn't wire-exact (truncated or reconstructed), matching Detail
+ view's own labels.
+ - `.sh` / `.curl` - the request as a runnable `curl` command line
+ (Burp/DevTools' own "copy as curl"), for handing to someone else or
+ re-running standalone without mitmux. Every value is shell-quoted
+ (a captured or edited request can contain arbitrary bytes).
- `E` from the history list exports the current view - the visible,
- filtered set if a search is active, everything otherwise - as one
- [HAR](https://en.wikipedia.org/wiki/HAR_(file_format)) file, for
- importing into Chrome/Firefox DevTools, Burp, Postman, or anything
- else that reads HAR 1.2. A binary body (an image, say) is base64-
- encoded in the HAR rather than corrupted as text. An entry that fails
- to fetch or parse is skipped rather than aborting the whole export;
- the status line reports how many, if any.
+ filtered set if a search is active, everything otherwise.
+ - `.har` (default) - one
+ [HAR](https://en.wikipedia.org/wiki/HAR_(file_format)) 1.2 file, for
+ importing into Chrome/Firefox DevTools, Burp, Postman, or anything
+ else that reads HAR. A binary body (an image, say) is base64-
+ encoded rather than corrupted as text. An entry that fails to fetch
+ or parse is skipped rather than aborting the whole export; the
+ status line reports how many, if any.
+ - `.csv` - a summary table (id, method, host, path, status, sizes,
+ timing, flag, source) for a report or spreadsheet - lighter and
+ faster than HAR since it needs no per-entry fetch from the daemon.
+ Any field that could be interpreted as a spreadsheet formula (starts
+ with `=`, `+`, `-`, `@`, tab, or CR - method/host/path/error all
+ ultimately trace back to a request line or Host header, exactly the
+ kind of content this tool exists to inspect from hostile traffic)
+ is neutralized with a leading quote before writing, the standard
+ CSV-injection mitigation.
There's no import yet (see `PLAN.md`).
diff --git a/cmd/mitmux/csv.go b/cmd/mitmux/csv.go
new file mode 100644
index 0000000..f64a071
--- /dev/null
+++ b/cmd/mitmux/csv.go
@@ -0,0 +1,73 @@
+package main
+
+import (
+ "encoding/csv"
+ "strconv"
+ "strings"
+ "time"
+
+ "mitmux/internal/store"
+)
+
+// csvFromSummaries renders entries as CSV - a summary table (ID,
+// method, host, path, status, sizes, timing, flag, source), not full
+// request/response bytes. This is deliberately the lighter, faster
+// bulk-export path: unlike HAR, it needs no per-entry fetch from the
+// daemon (Summary already has everything a spreadsheet/report table
+// needs), matching Burp's own "export as CSV" being a summary listing
+// rather than a full-fidelity capture format - that's what HAR is for.
+func csvFromSummaries(entries []store.Summary) (string, error) {
+ var b strings.Builder
+ w := csv.NewWriter(&b)
+
+ header := []string{"id", "method", "scheme", "host", "path", "status", "request_bytes", "response_bytes", "duration_ms", "flagged", "source", "started_at", "error"}
+ if err := w.Write(header); err != nil {
+ return "", err
+ }
+ for _, e := range entries {
+ row := []string{
+ strconv.FormatInt(e.ID, 10),
+ csvSafe(e.Method),
+ csvSafe(e.Scheme),
+ csvSafe(e.Host),
+ csvSafe(e.Path),
+ strconv.Itoa(e.StatusCode),
+ strconv.Itoa(e.ReqSize),
+ strconv.Itoa(e.RespSize),
+ strconv.FormatInt(e.Duration.Milliseconds(), 10),
+ strconv.FormatBool(e.Flagged),
+ e.Source,
+ e.StartedAt.Format(time.RFC3339),
+ csvSafe(e.Error),
+ }
+ if err := w.Write(row); err != nil {
+ return "", err
+ }
+ }
+ w.Flush()
+ if err := w.Error(); err != nil {
+ return "", err
+ }
+ return b.String(), nil
+}
+
+// csvSafe guards against CSV/formula injection: Method, Host, Path, and
+// Error all ultimately trace back to a request line or Host header -
+// content this tool exists specifically to inspect from potentially
+// hostile traffic. A value starting with =, +, -, @, tab, or CR is a
+// formula to Excel/LibreOffice/Sheets when the exported file is opened,
+// which (depending on the app and its settings) can call out to other
+// cells, external data, or worse. Prefixing such a value with a single
+// quote makes every affected spreadsheet application treat it as
+// literal text instead - the standard mitigation (OWASP's own CSV
+// injection guidance), not something specific to this tool.
+func csvSafe(s string) string {
+ if s == "" {
+ return s
+ }
+ switch s[0] {
+ case '=', '+', '-', '@', '\t', '\r':
+ return "'" + s
+ }
+ return s
+}
diff --git a/cmd/mitmux/csv_test.go b/cmd/mitmux/csv_test.go
new file mode 100644
index 0000000..b2f6074
--- /dev/null
+++ b/cmd/mitmux/csv_test.go
@@ -0,0 +1,84 @@
+package main
+
+import (
+ "encoding/csv"
+ "strings"
+ "testing"
+ "time"
+
+ "mitmux/internal/store"
+)
+
+func TestCsvFromSummariesBasic(t *testing.T) {
+ entries := []store.Summary{
+ {ID: 1, Method: "GET", Scheme: "https", Host: "example.com", Path: "/a", StatusCode: 200, ReqSize: 100, RespSize: 200, Duration: 150 * time.Millisecond, Source: "proxy"},
+ }
+ out, err := csvFromSummaries(entries)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ r := csv.NewReader(strings.NewReader(out))
+ rows, err := r.ReadAll()
+ if err != nil {
+ t.Fatalf("output is not valid CSV: %v", err)
+ }
+ if len(rows) != 2 { // header + 1 row
+ t.Fatalf("expected 2 rows (header+1), got %d: %v", len(rows), rows)
+ }
+ if rows[1][0] != "1" || rows[1][3] != "example.com" || rows[1][5] != "200" {
+ t.Errorf("unexpected row content: %v", rows[1])
+ }
+}
+
+func TestCsvFromSummariesEmpty(t *testing.T) {
+ out, err := csvFromSummaries(nil)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ r := csv.NewReader(strings.NewReader(out))
+ rows, err := r.ReadAll()
+ if err != nil {
+ t.Fatalf("output is not valid CSV: %v", err)
+ }
+ if len(rows) != 1 { // header only
+ t.Fatalf("expected header-only output, got %d rows", len(rows))
+ }
+}
+
+func TestCsvSafeNeutralizesFormulaPrefixes(t *testing.T) {
+ tests := []struct {
+ in string
+ want string
+ }{
+ {"=cmd|'/c calc'!A1", "'=cmd|'/c calc'!A1"},
+ {"+1+1", "'+1+1"},
+ {"-1", "'-1"},
+ {"@SUM(A1)", "'@SUM(A1)"},
+ {"example.com", "example.com"},
+ {"", ""},
+ }
+ for _, tt := range tests {
+ if got := csvSafe(tt.in); got != tt.want {
+ t.Errorf("csvSafe(%q) = %q, want %q", tt.in, got, tt.want)
+ }
+ }
+}
+
+func TestCsvFromSummariesNeutralizesFormulaInjection(t *testing.T) {
+ entries := []store.Summary{
+ {ID: 1, Method: "GET", Scheme: "http", Host: "=cmd|'/c calc'!A1", Path: "/", StatusCode: 200},
+ }
+ out, err := csvFromSummaries(entries)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ r := csv.NewReader(strings.NewReader(out))
+ rows, err := r.ReadAll()
+ if err != nil {
+ t.Fatalf("output is not valid CSV: %v", err)
+ }
+ host := rows[1][3]
+ if !strings.HasPrefix(host, "'") {
+ t.Errorf("expected malicious host to be neutralized with a leading quote, got %q", host)
+ }
+}
diff --git a/cmd/mitmux/export.go b/cmd/mitmux/export.go
index 5d11ca8..8b79226 100644
--- a/cmd/mitmux/export.go
+++ b/cmd/mitmux/export.go
@@ -1,8 +1,14 @@
package main
import (
+ "bufio"
+ "bytes"
"fmt"
+ "io"
+ "net/http"
"os"
+ "path/filepath"
+ "sort"
"strings"
"time"
@@ -38,6 +44,100 @@ func exportEntryText(d *ipc.EntryDetail) string {
return b.String()
}
+// singleEntryFormat picks the single-entry export format from path's
+// extension, the same "save as" convention bulkExportFormat uses for
+// the history list - no separate format-selection UI, just write the
+// extension you want.
+type singleEntryFormat int
+
+const (
+ formatPlainText singleEntryFormat = iota
+ formatCurl
+)
+
+func singleEntryFormatFor(path string) singleEntryFormat {
+ switch strings.ToLower(filepath.Ext(path)) {
+ case ".sh", ".curl":
+ return formatCurl
+ default:
+ return formatPlainText
+ }
+}
+
+// bulkExportFormat picks the history-list export format from path's
+// extension - same convention as singleEntryFormat, a separate type
+// because the two operate on entirely different data (Summary rows vs.
+// one EntryDetail) and don't share format options.
+type bulkExportFormat int
+
+const (
+ formatHAR bulkExportFormat = iota
+ formatCSV
+)
+
+func bulkExportFormatFor(path string) bulkExportFormat {
+ switch strings.ToLower(filepath.Ext(path)) {
+ case ".csv":
+ return formatCSV
+ default:
+ return formatHAR
+ }
+}
+
+// curlCommand renders one entry's request as a curl command line -
+// Burp/browser DevTools' own "copy as curl", so a request captured or
+// edited in mitmux can be handed to someone else, or re-run standalone,
+// without needing mitmux itself. Parses the raw request the same way
+// prettyResponse and the HAR conversion do (net/http, not reimplemented)
+// and re-serializes it as curl flags rather than emitting the raw bytes
+// directly - the point of this format specifically is a runnable shell
+// command, not another copy of the same raw text plain-text export
+// already gives you.
+func curlCommand(d *ipc.EntryDetail) (string, error) {
+ req, err := http.ReadRequest(bufio.NewReader(bytes.NewReader(d.RequestRaw)))
+ if err != nil {
+ return "", fmt.Errorf("parse request: %w", err)
+ }
+ body, _ := io.ReadAll(req.Body)
+ req.Body.Close()
+
+ var b strings.Builder
+ fmt.Fprintf(&b, "curl -X %s", shellQuote(req.Method))
+
+ names := make([]string, 0, len(req.Header))
+ for k := range req.Header {
+ if strings.EqualFold(k, "Host") || strings.EqualFold(k, "Content-Length") {
+ continue // -H Host is redundant with the URL; curl sets Content-Length itself from --data-raw
+ }
+ names = append(names, k)
+ }
+ sort.Strings(names)
+ for _, k := range names {
+ for _, v := range req.Header[k] {
+ fmt.Fprintf(&b, " \\\n -H %s", shellQuote(k+": "+v))
+ }
+ }
+ if len(body) > 0 {
+ fmt.Fprintf(&b, " \\\n --data-raw %s", shellQuote(string(body)))
+ }
+ fmt.Fprintf(&b, " \\\n %s\n", shellQuote(fmt.Sprintf("%s://%s%s", d.Scheme, d.Host, req.URL.RequestURI())))
+ return b.String(), nil
+}
+
+// shellQuote wraps s in single quotes for safe use as one POSIX shell
+// argument, escaping any embedded single quote by closing the quoted
+// string, inserting an escaped literal quote, then reopening it. Every
+// value here
+// (header values, body, URL) can be arbitrary attacker- or user-
+// supplied bytes - a captured/edited request is exactly the kind of
+// content that might contain shell metacharacters, so building the curl
+// command with naive string concatenation would risk producing a
+// command that does something other than what it appears to when
+// pasted into a shell.
+func shellQuote(s string) string {
+ return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
+}
+
func exportSuffix(exact, truncated bool) string {
switch {
case exact:
diff --git a/cmd/mitmux/export_test.go b/cmd/mitmux/export_test.go
index 03c38e0..84c93bb 100644
--- a/cmd/mitmux/export_test.go
+++ b/cmd/mitmux/export_test.go
@@ -77,3 +77,73 @@ func TestWriteExportFileEmptyPath(t *testing.T) {
t.Error("expected an error for an empty path, got nil")
}
}
+
+func TestSingleEntryFormatFor(t *testing.T) {
+ tests := []struct {
+ path string
+ want singleEntryFormat
+ }{
+ {"entry.txt", formatPlainText},
+ {"entry.sh", formatCurl},
+ {"entry.curl", formatCurl},
+ {"entry.CURL", formatCurl},
+ {"entry", formatPlainText},
+ {"", formatPlainText},
+ }
+ for _, tt := range tests {
+ if got := singleEntryFormatFor(tt.path); got != tt.want {
+ t.Errorf("singleEntryFormatFor(%q) = %v, want %v", tt.path, got, tt.want)
+ }
+ }
+}
+
+func TestCurlCommand(t *testing.T) {
+ d := &ipc.EntryDetail{
+ Summary: store.Summary{Method: "POST", Scheme: "https", Host: "example.com"},
+ RequestRaw: []byte("POST /a?x=1 HTTP/1.1\r\nHost: example.com\r\nContent-Type: application/json\r\nContent-Length: 13\r\n\r\n{\"key\":\"val\"}"),
+ }
+ got, err := curlCommand(d)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ for _, want := range []string{
+ "curl -X 'POST'",
+ "-H 'Content-Type: application/json'",
+ "--data-raw '{\"key\":\"val\"}'",
+ "'https://example.com/a?x=1'",
+ } {
+ if !strings.Contains(got, want) {
+ t.Errorf("curlCommand output missing %q, got:\n%s", want, got)
+ }
+ }
+ if strings.Contains(got, "-H 'Host:") {
+ t.Errorf("curlCommand should not include a redundant Host header, got:\n%s", got)
+ }
+ if strings.Contains(got, "Content-Length") {
+ t.Errorf("curlCommand should not include Content-Length (curl sets it itself), got:\n%s", got)
+ }
+}
+
+func TestCurlCommandMalformedRequest(t *testing.T) {
+ d := &ipc.EntryDetail{RequestRaw: []byte("not a request")}
+ if _, err := curlCommand(d); err == nil {
+ t.Error("expected an error for a malformed request, got nil")
+ }
+}
+
+func TestShellQuoteEscapesEmbeddedQuotes(t *testing.T) {
+ tests := []struct {
+ in string
+ want string
+ }{
+ {"simple", "'simple'"},
+ {"it's", `'it'\''s'`},
+ {"", "''"},
+ {"a; rm -rf /", "'a; rm -rf /'"},
+ }
+ for _, tt := range tests {
+ if got := shellQuote(tt.in); got != tt.want {
+ t.Errorf("shellQuote(%q) = %q, want %q", tt.in, got, tt.want)
+ }
+ }
+}
diff --git a/cmd/mitmux/main.go b/cmd/mitmux/main.go
index 21209bf..35cadaa 100644
--- a/cmd/mitmux/main.go
+++ b/cmd/mitmux/main.go
@@ -420,7 +420,7 @@ func (m *model) clearHistory() tea.Cmd {
}
}
-type harExportedMsg struct {
+type bulkExportedMsg struct {
path string
count int
skipped int
@@ -452,12 +452,30 @@ func (m *model) exportHAR(entries []store.Summary, path string) tea.Cmd {
doc, parseFailed := harDocFrom(details)
data, err := harMarshal(doc)
if err != nil {
- return harExportedMsg{err: err}
+ return bulkExportedMsg{err: err}
}
if err := writeExportFile(path, string(data)); err != nil {
- return harExportedMsg{err: err}
+ return bulkExportedMsg{err: err}
}
- return harExportedMsg{path: path, count: len(doc.Log.Entries), skipped: fetchFailed + parseFailed}
+ return bulkExportedMsg{path: path, count: len(doc.Log.Entries), skipped: fetchFailed + parseFailed}
+ }
+}
+
+// exportCSV writes entries as a CSV summary table. Unlike exportHAR,
+// this needs no per-entry fetch - Summary already carries everything a
+// CSV row needs - so it's synchronous work wrapped in a tea.Cmd only
+// for consistency with every other write-a-file action, not because it
+// actually blocks on anything slow.
+func (m *model) exportCSV(entries []store.Summary, path string) tea.Cmd {
+ return func() tea.Msg {
+ data, err := csvFromSummaries(entries)
+ if err != nil {
+ return bulkExportedMsg{err: err}
+ }
+ if err := writeExportFile(path, data); err != nil {
+ return bulkExportedMsg{err: err}
+ }
+ return bulkExportedMsg{path: path, count: len(entries)}
}
}
@@ -940,7 +958,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.statusMsg = "history cleared"
return m, tea.Batch(m.loadList, m.loadStatus)
- case harExportedMsg:
+ case bulkExportedMsg:
if msg.err != nil {
m.statusMsg = "export error: " + msg.err.Error()
return m, nil
@@ -1091,6 +1109,9 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
m.statusMsg = "exporting..."
+ if bulkExportFormatFor(path) == formatCSV {
+ return m, m.exportCSV(entries, path)
+ }
return m, m.exportHAR(entries, path)
case "esc":
m.exportEditing = false
@@ -1228,7 +1249,16 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.detail == nil {
return m, nil
}
- if err := writeExportFile(path, exportEntryText(m.detail)); err != nil {
+ content := exportEntryText(m.detail)
+ if singleEntryFormatFor(path) == formatCurl {
+ c, err := curlCommand(m.detail)
+ if err != nil {
+ m.statusMsg = "export error: " + err.Error()
+ return m, nil
+ }
+ content = c
+ }
+ if err := writeExportFile(path, content); err != nil {
m.statusMsg = "export error: " + err.Error()
} else {
m.statusMsg = "exported to " + path
@@ -1800,7 +1830,7 @@ func (m *model) helpView() string {
"c mark for comparison, then press c on another entry to diff",
"x delete the selected entry (asks to confirm)",
"X clear ALL history, not just the current filter (asks to confirm)",
- "E export the current view (respects an active search filter) as a HAR file",
+ "E export the current view (respects an active filter) - .har or .csv",
"d decoder (URL/Base64/Hex/HTML encode/decode)",
"/ search: plain text, host:value, AND/OR/NOT,",
" status:404 / status:4xx / status:>=400,",
@@ -1816,7 +1846,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)",
+ "e export this entry - .txt (raw request+response) or .sh/.curl (curl command)",
"esc / q back to history",
)
section("Comparer",
@@ -1895,10 +1925,10 @@ func (m *model) listView() string {
b.WriteString(m.table.View())
b.WriteString("\n")
if m.exportEditing {
- b.WriteString("export HAR (current view) to: ")
+ b.WriteString("export current view to: ")
b.WriteString(m.exportInput.View())
b.WriteString("\n")
- b.WriteString(helpStyle.Render("enter write · esc cancel · ctrl+c quit"))
+ b.WriteString(helpStyle.Render("enter write · esc cancel · ctrl+c quit - .har (full, default) or .csv (summary table)"))
return b.String()
}
if m.confirmPrompt != "" {
@@ -1908,9 +1938,9 @@ func (m *model) listView() string {
b.WriteString(statusStyle.Render(sanitizeLine(m.statusMsg)))
b.WriteString("\n")
}
- help := "enter view · r/i/c/d tools · f flag · x delete · X clear all · E export HAR · / search · m rules · s scope · ? help · q quit"
+ help := "enter view · r/i/c/d tools · f flag · x delete · X clear all · E export (HAR/CSV) · / search · m rules · s scope · ? help · q quit"
if m.query != "" {
- help = "enter view · r/i/c/d tools · f flag · x delete · X clear all · E export HAR (this filter) · / search · esc clear filter · s scope · ? help · q quit"
+ help = "enter view · r/i/c/d tools · f flag · x delete · X clear all · E export (this filter) · / search · esc clear filter · s scope · ? help · q quit"
}
b.WriteString(helpStyle.Render(help))
return b.String()
@@ -1949,7 +1979,7 @@ func (m *model) detailView() string {
b.WriteString("export to: ")
b.WriteString(m.exportInput.View())
b.WriteString("\n")
- b.WriteString(helpStyle.Render("enter write · esc cancel · ctrl+c quit"))
+ b.WriteString(helpStyle.Render("enter write · esc cancel · ctrl+c quit - .txt (raw, default) or .sh/.curl (curl command)"))
return b.String()
}
if m.statusMsg != "" {