diff options
| author | srdusr <[email protected]> | 2026-02-24 19:30:00 +0200 |
|---|---|---|
| committer | srdusr <[email protected]> | 2026-02-24 19:30:00 +0200 |
| commit | 66d00f513e30d0746c1d5b4cd3b0c4a34a243928 (patch) | |
| tree | 37d785dbe2b0541ae7215969584d9d582a96bf37 /cmd | |
| parent | 5200e412606c221fb618bd8e9a57a897553215d9 (diff) | |
| download | mitmux-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.go | 105 | ||||
| -rw-r--r-- | cmd/mitmux/main.go | 118 |
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() } |