srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/cmd
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2026-02-24 19:30:00 +0200
committersrdusr <[email protected]>2026-02-24 19:30:00 +0200
commit66d00f513e30d0746c1d5b4cd3b0c4a34a243928 (patch)
tree37d785dbe2b0541ae7215969584d9d582a96bf37 /cmd
parent5200e412606c221fb618bd8e9a57a897553215d9 (diff)
downloadmitmux-66d00f513e30d0746c1d5b4cd3b0c4a34a243928.tar.gz
mitmux-66d00f513e30d0746c1d5b4cd3b0c4a34a243928.zip
Comparer: unified diff between two history entries
Next item off the "worth considering" list from the Burp/ZAP/Caido gap research. Mark an entry with 'c' (from the history list or detail view - no fetch yet, just remembers the ID), then 'c' on a different entry fetches both and opens a colored unified diff of either side's request or response, tab to switch between them. Unified (git-diff style: +/- prefixed lines) rather than Burp's side-by-side two-pane layout - a two-column view fights terminal width for anything but a wide window, and unified reuses the same scrollable viewport pattern already used everywhere else in this TUI rather than needing new layout machinery. Uses github.com/pmezard/go-difflib (SequenceMatcher-based, a tested port of Python's difflib) rather than hand-rolling LCS/Myers diff, which has real edge cases worth not reinventing. CRLF is normalized to LF before diffing - display-only, same reasoning as the JSON pretty-printer - so an HTTP/1.1 exact capture doesn't show every single line as changed purely from an invisible trailing \r. Verified live against two real, distinctly different captured POST requests (different form bodies, different Content-Length): the request diff correctly isolated exactly the two changed lines with the unchanged headers shown as context, colors confirmed via raw ANSI codes in the captured pane output (red 203 for removed, green 42 for added) rather than assumed from the code, and the response tab showed a correct independent diff of the two responses (Date header, JSON body). Also confirmed the "same entry marked twice" path shows a hint rather than silently doing something confusing.
Diffstat (limited to 'cmd')
-rw-r--r--cmd/mitmux/compare.go105
-rw-r--r--cmd/mitmux/main.go118
2 files changed, 219 insertions, 4 deletions
diff --git a/cmd/mitmux/compare.go b/cmd/mitmux/compare.go
new file mode 100644
index 0000000..34bb684
--- /dev/null
+++ b/cmd/mitmux/compare.go
@@ -0,0 +1,105 @@
+package main
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/pmezard/go-difflib/difflib"
+)
+
+// unifiedDiff renders a colored unified diff (git/diff -u style) between
+// aText and bText, labeled aLabel/bLabel. CRLF is normalized to LF
+// first - this is a display transform only (mirrors prettyResponse's
+// approach), otherwise every line in an HTTP/1.1 exact capture would
+// show as changed purely from an invisible trailing \r, which would
+// bury the differences that actually matter under noise.
+func unifiedDiff(aLabel, bLabel, aText, bText string) string {
+ aText = strings.ReplaceAll(aText, "\r\n", "\n")
+ bText = strings.ReplaceAll(bText, "\r\n", "\n")
+
+ if aText == bText {
+ return helpStyle.Render("(identical)")
+ }
+
+ diff := difflib.UnifiedDiff{
+ A: difflib.SplitLines(aText),
+ B: difflib.SplitLines(bText),
+ FromFile: aLabel,
+ ToFile: bLabel,
+ Context: 3,
+ }
+ text, err := difflib.GetUnifiedDiffString(diff)
+ if err != nil {
+ return "(diff error: " + err.Error() + ")"
+ }
+ return colorizeDiff(text)
+}
+
+func colorizeDiff(text string) string {
+ var b strings.Builder
+ lines := strings.Split(strings.TrimSuffix(text, "\n"), "\n")
+ for i, line := range lines {
+ switch {
+ case strings.HasPrefix(line, "+++") || strings.HasPrefix(line, "---"):
+ b.WriteString(diffHeaderStyle.Render(line))
+ case strings.HasPrefix(line, "@@"):
+ b.WriteString(diffHunkStyle.Render(line))
+ case strings.HasPrefix(line, "+"):
+ b.WriteString(diffAddStyle.Render(line))
+ case strings.HasPrefix(line, "-"):
+ b.WriteString(diffDelStyle.Render(line))
+ default:
+ b.WriteString(line)
+ }
+ if i < len(lines)-1 {
+ b.WriteString("\n")
+ }
+ }
+ return b.String()
+}
+
+// compareContent is what's actually shown in the comparer viewport: a
+// unified diff of the request or response bodies of the two marked
+// entries, depending on compareTab.
+func (m *model) compareContent() string {
+ if m.compareA == nil || m.compareB == nil {
+ return ""
+ }
+ var aText, bText string
+ if m.compareTab == tabRequest {
+ aText, bText = string(m.compareA.RequestRaw), string(m.compareB.RequestRaw)
+ } else {
+ aText, bText = string(m.compareA.ResponseRaw), string(m.compareB.ResponseRaw)
+ }
+ return unifiedDiff(fmt.Sprintf("#%d", m.compareA.ID), fmt.Sprintf("#%d", m.compareB.ID), aText, bText)
+}
+
+func (m *model) compareView() string {
+ var b strings.Builder
+ if m.compareA == nil || m.compareB == nil {
+ b.WriteString("loading...\n")
+ return b.String()
+ }
+ title := fmt.Sprintf(" comparer - #%d (%s %s%s -> %d) vs #%d (%s %s%s -> %d) ",
+ m.compareA.ID, m.compareA.Method, m.compareA.Host, m.compareA.Path, m.compareA.StatusCode,
+ m.compareB.ID, m.compareB.Method, m.compareB.Host, m.compareB.Path, m.compareB.StatusCode)
+ b.WriteString(titleStyle.Render(title))
+ b.WriteString("\n")
+
+ if m.compareTab == tabRequest {
+ b.WriteString(tabActive.Render("Request"))
+ b.WriteString(tabInactive.Render("Response"))
+ } else {
+ b.WriteString(tabInactive.Render("Request"))
+ b.WriteString(tabActive.Render("Response"))
+ }
+ b.WriteString("\n")
+ b.WriteString(m.compareViewport.View())
+ b.WriteString("\n")
+ if m.statusMsg != "" {
+ b.WriteString(statusStyle.Render(m.statusMsg))
+ b.WriteString("\n")
+ }
+ b.WriteString(helpStyle.Render("tab switch request/response · ↑/↓ scroll · esc back · q quit"))
+ return b.String()
+}
diff --git a/cmd/mitmux/main.go b/cmd/mitmux/main.go
index 1f0177c..ebcd666 100644
--- a/cmd/mitmux/main.go
+++ b/cmd/mitmux/main.go
@@ -73,6 +73,7 @@ const (
viewRepeater
viewRules
viewIntruder
+ viewCompare
viewHelp
)
@@ -163,6 +164,12 @@ type model struct {
daemonStatus *ipc.StatusMsg
prevMode viewMode // for the ? help screen's "esc back" target
+ compareBaseID int64 // marked via 'c', 0 = none marked
+ compareA *ipc.EntryDetail
+ compareB *ipc.EntryDetail
+ compareTab detailTab
+ compareViewport viewport.Model
+
statusMsg string
width int
height int
@@ -324,6 +331,47 @@ func (m *model) loadDetail(id int64, dest string) tea.Cmd {
}
}
+// markOrCompare implements 'c': the first press on an entry marks it as
+// the comparison base (no fetch yet - cheap, no round trip until there's
+// actually something to compare). A second press on a *different* entry
+// fetches both and opens the comparer; pressing it again on the same
+// entry is a no-op with a hint, not a silent clear.
+func (m *model) markOrCompare(id int64) tea.Cmd {
+ switch {
+ case m.compareBaseID == 0:
+ m.compareBaseID = id
+ m.statusMsg = fmt.Sprintf("marked #%d for comparison - press c on another entry to diff", id)
+ return nil
+ case m.compareBaseID == id:
+ m.statusMsg = fmt.Sprintf("#%d is already marked - press c on a different entry to diff", id)
+ return nil
+ default:
+ baseID := m.compareBaseID
+ m.compareBaseID = 0
+ m.statusMsg = "loading comparison..."
+ return m.loadCompare(baseID, id)
+ }
+}
+
+type compareLoadedMsg struct {
+ a, b *ipc.EntryDetail
+ err error
+}
+
+func (m *model) loadCompare(idA, idB int64) tea.Cmd {
+ return func() tea.Msg {
+ a, err := m.client.Get(idA)
+ if err != nil {
+ return compareLoadedMsg{err: err}
+ }
+ b, err := m.client.Get(idB)
+ if err != nil {
+ return compareLoadedMsg{err: err}
+ }
+ return compareLoadedMsg{a: a, b: b}
+ }
+}
+
func (m *model) sendRepeat() tea.Cmd {
scheme, host := m.repeaterScheme, m.repeaterHost
// The textarea only understands LF; HTTP/1.1 requires CRLF. Restoring
@@ -520,6 +568,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.table.SetHeight(h - 5)
m.searchInput.Width = msg.Width - 2
m.viewport = viewport.New(msg.Width, h-5)
+ m.compareViewport = viewport.New(msg.Width, h-5)
reqHeight := (h - 6) / 2
m.reqArea.SetWidth(msg.Width)
@@ -603,6 +652,20 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.viewport.GotoTop()
return m, nil
+ case compareLoadedMsg:
+ if msg.err != nil {
+ m.statusMsg = "compare error: " + msg.err.Error()
+ return m, nil
+ }
+ m.compareA = msg.a
+ m.compareB = msg.b
+ m.compareTab = tabRequest
+ m.mode = viewCompare
+ m.statusMsg = ""
+ m.compareViewport.SetContent(m.compareContent())
+ m.compareViewport.GotoTop()
+ return m, nil
+
case repeatSentMsg:
m.sending = false
if msg.err != nil {
@@ -708,6 +771,10 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.table.SetRows(rowsFor(m.entries))
return m, m.setFlagged(m.entries[row].ID, m.entries[row].Flagged)
}
+ case "c":
+ if row := m.table.Cursor(); row >= 0 && row < len(m.entries) {
+ return m, m.markOrCompare(m.entries[row].ID)
+ }
case "/":
m.searching = true
m.searchInput.SetValue(m.query)
@@ -756,6 +823,10 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.viewport.SetContent(m.detailContent())
}
return m, nil
+ case "c":
+ if m.detail != nil {
+ return m, m.markOrCompare(m.detail.ID)
+ }
case "tab":
if m.activeTab == tabRequest {
m.activeTab = tabResponse
@@ -967,6 +1038,31 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m, cmd
+ case viewCompare:
+ switch msg.String() {
+ case "q", "esc":
+ m.mode = viewList
+ return m, nil
+ case "ctrl+c":
+ return m, tea.Quit
+ case "?":
+ m.prevMode = viewCompare
+ m.mode = viewHelp
+ return m, nil
+ case "tab":
+ if m.compareTab == tabRequest {
+ m.compareTab = tabResponse
+ } else {
+ m.compareTab = tabRequest
+ }
+ m.compareViewport.SetContent(m.compareContent())
+ m.compareViewport.GotoTop()
+ return m, nil
+ }
+ var cmd tea.Cmd
+ m.compareViewport, cmd = m.compareViewport.Update(msg)
+ return m, cmd
+
case viewHelp:
switch msg.String() {
case "ctrl+c":
@@ -1002,6 +1098,8 @@ func (m *model) View() string {
}
case viewIntruder:
body = m.intruderView()
+ case viewCompare:
+ body = m.compareView()
default:
body = m.listView()
}
@@ -1021,7 +1119,7 @@ func (m *model) statusBar() string {
}
view := map[viewMode]string{
viewList: "history", viewDetail: "detail", viewRepeater: "repeater",
- viewRules: "rules", viewIntruder: "intruder",
+ viewRules: "rules", viewIntruder: "intruder", viewCompare: "comparer",
}[m.mode]
return statusBarStyle.Render(fmt.Sprintf(" mitmux · proxy %s%s · %s ", proxy, count, view))
}
@@ -1050,6 +1148,7 @@ func (m *model) helpView() string {
"r open in Repeater",
"i open in Intruder",
"f toggle flag (★ mark this, revisit later)",
+ "c mark for comparison, then press c on another entry to diff",
"/ search: plain text, host:value, AND/OR/NOT,",
" status:404 / status:4xx / status:>=400,",
" source:repeater, flagged:true",
@@ -1061,9 +1160,15 @@ func (m *model) helpView() string {
"tab switch request/response",
"↑/↓ or j/k scroll (also g/G, ctrl+u/d - same as history list)",
"p toggle pretty-printed JSON (response only, display-only)",
+ "c mark/compare (same as history list)",
"r / i open in Repeater / Intruder",
"esc / q back to history",
)
+ section("Comparer",
+ "tab switch request/response diff",
+ "↑/↓ or j/k scroll (also g/G, ctrl+u/d - same as history list)",
+ "esc / q back to history",
+ )
section("Repeater / Intruder editors - vi-modal",
"Starts in NORMAL mode (not insert) - press i to type, esc to stop.",
"h j k l left/down/up/right 0 / $ line start/end",
@@ -1095,6 +1200,11 @@ var (
tabInactive = lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Padding(0, 1)
statusBarStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("0")).Background(lipgloss.Color("240"))
+
+ diffAddStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42"))
+ diffDelStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("203"))
+ diffHunkStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("39"))
+ diffHeaderStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("240"))
)
func (m *model) listView() string {
@@ -1115,9 +1225,9 @@ func (m *model) listView() string {
b.WriteString(statusStyle.Render(m.statusMsg))
b.WriteString("\n")
}
- help := "↑/↓ navigate · enter view · r repeater · i intruder · f flag · / search · m rules · q quit"
+ help := "enter view · r repeater · i intruder · f flag · c compare · / search · m rules · ? help · q quit"
if m.query != "" {
- help = "↑/↓ navigate · enter view · r repeater · i intruder · f flag · / search · m rules · esc clear filter · q quit"
+ help = "enter view · r repeater · i intruder · f flag · c compare · / search · esc clear filter · ? help · q quit"
}
b.WriteString(helpStyle.Render(help))
return b.String()
@@ -1152,7 +1262,7 @@ 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 JSON · ↑/↓ scroll · r repeater · i intruder · esc back · q quit"))
+ b.WriteString(helpStyle.Render("tab switch · p pretty-print · c compare · r repeater · i intruder · esc back · ? help · q quit"))
return b.String()
}