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