srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/cmd
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2026-07-31 16:07:00 +0200
committersrdusr <[email protected]>2026-07-31 16:07:00 +0200
commit9b630b2cbf9206855534668ebaaaee255cabedd4 (patch)
treeb4c45860ae3e52eb8909cb868649c07c6c93e1a6 /cmd
parent384573a2dc5e3b8e2a7bdfe2ce949f2c52ba2c52 (diff)
downloadmitmux-9b630b2cbf9206855534668ebaaaee255cabedd4.tar.gz
mitmux-9b630b2cbf9206855534668ebaaaee255cabedd4.zip
History sorting, status color-coding, and JSON syntax highlighting
Filtering already existed (FTS5 search plus status:/source:/flagged: and column filters). Sorting and highlighting didn't, at all. Sorting: o/O cycle and reverse the sort column (status, size, time taken, method, host, path) applied client-side on top of whatever order List/Search already returned. refreshTable() reorders m.entries itself, not just what's rendered - every "act on the selected row" key handler indexes m.table.Cursor() straight into m.entries with no indirection, so keeping the two in identical order sidesteps an entire class of "highlighted row and actual target silently disagree" bugs rather than updating every one of those call sites. JSON syntax highlighting (cmd/mitmux/jsoncolor.go): walks the token stream via json.Decoder.Token() with an explicit stack, not recursive calls, so depth is bounded by memory rather than Go's call stack for adversarial nesting. Every string re-escaped via json.Marshal before writing, which is also why the colorized output is deliberately never run through sanitizeControl afterward (unlike every other raw-text view here): JSON's own encoding rules already forbid a literal control character in a string, so re-marshaling neutralizes one as a side effect of producing valid JSON - running sanitizeControl on top would instead corrupt the ANSI codes this adds. Status-code color-coding (2xx green through 5xx red) does not live in the history/Intruder tables, despite an initial attempt to put it there. Confirmed live: bubbles/table v1.0.0 (the newest available) fits cell text to its column width via go-runewidth's Truncate, which has no ANSI awareness - it counts every character of a color escape sequence as real display width. Coloring the Status cell silently deleted the status text from the row; the width-fitting truncation cut into the escape sequence itself. styledStatus is used once instead, in detailView's title, a plain string rendered whole with no width constraint. Verified live in tmux against a running daemon: ascending/descending sort by status across six real entries: pretty-printed JSON confirmed correctly colored and indented via raw ANSI capture, not just eyeballed; the detail title's status color confirmed red for a 500 entry the same way; the request tab (never JSON) confirmed unaffected.
Diffstat (limited to 'cmd')
-rw-r--r--cmd/mitmux/jsoncolor.go134
-rw-r--r--cmd/mitmux/jsoncolor_test.go155
-rw-r--r--cmd/mitmux/main.go201
-rw-r--r--cmd/mitmux/pretty.go34
4 files changed, 504 insertions, 20 deletions
diff --git a/cmd/mitmux/jsoncolor.go b/cmd/mitmux/jsoncolor.go
new file mode 100644
index 0000000..9c224e3
--- /dev/null
+++ b/cmd/mitmux/jsoncolor.go
@@ -0,0 +1,134 @@
+package main
+
+import (
+ "bytes"
+ "encoding/json"
+ "io"
+)
+
+// colorizeJSON renders data (must already be json.Valid) as indented,
+// syntax-highlighted JSON - keys blue, strings green, numbers orange,
+// true/false/null magenta, punctuation gray - matching the convention
+// most editors and Burp/Caido's own JSON viewers use. Walks the token
+// stream via json.Decoder.Token() with an explicit stack rather than
+// recursive calls, so depth is bounded by available memory rather than
+// Go's call stack regardless of how deeply nested attacker-controlled
+// JSON is.
+//
+// Every string value and object key is re-escaped via json.Marshal
+// before being written out, which is what makes this safe to render
+// directly to the terminal without a separate sanitizeControl pass
+// afterward: JSON's own encoding rules forbid a raw control character
+// (ESC included) in a string literal, so re-marshaling one converts it
+// to a harmless \u-escape as a side effect of just producing valid JSON
+// text - the same safety property sanitizeControl exists to enforce
+// elsewhere, arrived at here for free instead. Running the already-
+// colored output through sanitizeControl afterward would instead
+// corrupt the ANSI escape codes this function adds - it treats ESC like
+// any other control byte, by design, for content that hasn't been
+// through this treatment.
+func colorizeJSON(data []byte) (string, bool) {
+ dec := json.NewDecoder(bytes.NewReader(data))
+ dec.UseNumber()
+
+ var out bytes.Buffer
+ type frame struct {
+ isObject bool
+ count int // values written so far at this nesting level
+ }
+ var stack []frame
+ // pendingKey is true right after writing `"key": ` - the next token
+ // is that key's value and stays on the same line rather than getting
+ // its own comma/newline/indent treatment.
+ pendingKey := false
+
+ indent := func(n int) {
+ for i := 0; i < n; i++ {
+ out.WriteString(" ")
+ }
+ }
+ beforeToken := func() {
+ if pendingKey {
+ pendingKey = false
+ return
+ }
+ if len(stack) == 0 {
+ return
+ }
+ top := &stack[len(stack)-1]
+ if top.count > 0 {
+ out.WriteString(jsonPunctStyle.Render(","))
+ }
+ top.count++
+ out.WriteByte('\n')
+ indent(len(stack))
+ }
+ writeKey := func(s string) {
+ beforeToken()
+ esc, _ := json.Marshal(s)
+ out.WriteString(jsonKeyStyle.Render(string(esc)))
+ out.WriteString(jsonPunctStyle.Render(": "))
+ pendingKey = true
+ }
+ writeStringValue := func(s string) {
+ beforeToken()
+ esc, _ := json.Marshal(s)
+ out.WriteString(jsonStringStyle.Render(string(esc)))
+ }
+
+ for {
+ tok, err := dec.Token()
+ if err != nil {
+ if err == io.EOF {
+ break
+ }
+ return "", false
+ }
+
+ if d, ok := tok.(json.Delim); ok {
+ switch d {
+ case '{', '[':
+ beforeToken()
+ out.WriteString(jsonPunctStyle.Render(string(d)))
+ stack = append(stack, frame{isObject: d == '{'})
+ case '}', ']':
+ closed := stack[len(stack)-1]
+ stack = stack[:len(stack)-1]
+ if closed.count > 0 {
+ out.WriteByte('\n')
+ indent(len(stack))
+ }
+ out.WriteString(jsonPunctStyle.Render(string(d)))
+ }
+ continue
+ }
+
+ // A string is a key only when directly inside an object and not
+ // immediately following a key we just wrote (pendingKey) - that
+ // next string is this key's *value*, not a new key.
+ isKeyPosition := len(stack) > 0 && stack[len(stack)-1].isObject && !pendingKey
+
+ switch t := tok.(type) {
+ case string:
+ if isKeyPosition {
+ writeKey(t)
+ } else {
+ writeStringValue(t)
+ }
+ case json.Number:
+ beforeToken()
+ out.WriteString(jsonNumberStyle.Render(t.String()))
+ case bool:
+ beforeToken()
+ s := "false"
+ if t {
+ s = "true"
+ }
+ out.WriteString(jsonBoolStyle.Render(s))
+ case nil:
+ beforeToken()
+ out.WriteString(jsonBoolStyle.Render("null"))
+ }
+ }
+ return out.String(), true
+}
diff --git a/cmd/mitmux/jsoncolor_test.go b/cmd/mitmux/jsoncolor_test.go
new file mode 100644
index 0000000..cccb9f8
--- /dev/null
+++ b/cmd/mitmux/jsoncolor_test.go
@@ -0,0 +1,155 @@
+package main
+
+import (
+ "os"
+ "regexp"
+ "strings"
+ "testing"
+
+ "github.com/charmbracelet/lipgloss"
+ "github.com/muesli/termenv"
+)
+
+// TestMain forces lipgloss to always emit color codes for this package's
+// tests. lipgloss auto-detects a color profile from the output stream by
+// default, which is Ascii (no color at all) under `go test` - stdout
+// isn't a real terminal there - so without this, every jsonXxxStyle.Render
+// call in the code under test would silently produce plain text and
+// TestColorizeJSONActuallyAddsColor would pass or fail based on how the
+// tests happen to be invoked rather than on the code's real behavior in
+// the TUI's actual terminal target.
+func TestMain(m *testing.M) {
+ lipgloss.SetColorProfile(termenv.TrueColor)
+ os.Exit(m.Run())
+}
+
+var ansiRE = regexp.MustCompile(`\x1b\[[0-9;]*m`)
+
+func stripANSI(s string) string { return ansiRE.ReplaceAllString(s, "") }
+
+func TestColorizeJSONPlainTextMatchesStandardIndent(t *testing.T) {
+ in := `{"b":2,"a":[1,2,3],"s":"hello","t":true,"f":false,"n":null,"nested":{"x":1}}`
+ got, ok := colorizeJSON([]byte(in))
+ if !ok {
+ t.Fatal("expected ok=true for valid JSON")
+ }
+ want := `{
+ "b": 2,
+ "a": [
+ 1,
+ 2,
+ 3
+ ],
+ "s": "hello",
+ "t": true,
+ "f": false,
+ "n": null,
+ "nested": {
+ "x": 1
+ }
+}`
+ if plain := stripANSI(got); plain != want {
+ t.Errorf("stripped output = %q, want %q", plain, want)
+ }
+}
+
+func TestColorizeJSONActuallyAddsColor(t *testing.T) {
+ got, ok := colorizeJSON([]byte(`{"key":"value"}`))
+ if !ok {
+ t.Fatal("expected ok=true")
+ }
+ if !strings.Contains(got, "\x1b[") {
+ t.Error("expected the output to contain ANSI escape codes")
+ }
+ if got == stripANSI(got) {
+ t.Error("output should differ from its ANSI-stripped form")
+ }
+}
+
+func TestColorizeJSONEmptyContainers(t *testing.T) {
+ got, ok := colorizeJSON([]byte(`{"a":{},"b":[]}`))
+ if !ok {
+ t.Fatal("expected ok=true")
+ }
+ want := `{
+ "a": {},
+ "b": []
+}`
+ if plain := stripANSI(got); plain != want {
+ t.Errorf("stripped output = %q, want %q", plain, want)
+ }
+}
+
+func TestColorizeJSONTopLevelScalar(t *testing.T) {
+ for _, tt := range []struct{ in, want string }{
+ {`"just a string"`, `"just a string"`},
+ {`42`, `42`},
+ {`true`, `true`},
+ {`null`, `null`},
+ } {
+ got, ok := colorizeJSON([]byte(tt.in))
+ if !ok {
+ t.Fatalf("colorizeJSON(%q): expected ok=true", tt.in)
+ }
+ if plain := stripANSI(got); plain != tt.want {
+ t.Errorf("colorizeJSON(%q) stripped = %q, want %q", tt.in, plain, tt.want)
+ }
+ }
+}
+
+func TestColorizeJSONInvalidReturnsFalse(t *testing.T) {
+ if _, ok := colorizeJSON([]byte(`{not valid json`)); ok {
+ t.Error("expected ok=false for invalid JSON")
+ }
+}
+
+// TestColorizeJSONNeutralizesControlCharactersInStrings pins down the
+// entire safety argument behind skipping a separate sanitizeControl pass
+// on colorizeJSON's output (see its doc comment). A raw, literal control
+// byte inside a JSON string makes the JSON syntactically invalid per RFC
+// 8259 - json.Valid (already checked by prettyResponse before this
+// function ever runs) rejects that case outright, so it's not the real
+// threat here. The real case is a *properly escaped* control character
+// () in otherwise-valid JSON: json.Decoder.Token() unescapes it
+// back into a literal ESC rune in the Go string it hands back, and that
+// must never reach the terminal as a real escape sequence when this
+// function writes it back out. encoding/json's own re-marshaling
+// escapes it again, which is what neutralizes it here.
+func TestColorizeJSONNeutralizesControlCharactersInStrings(t *testing.T) {
+ marker := "u00" + "1b" // built at runtime so this source file contains no literal backslash-u escape
+ in := []byte(`{"evil":"a\` + marker + `[31mFAKE\` + marker + `[0mb"}`)
+
+ got, ok := colorizeJSON(in)
+ if !ok {
+ t.Fatal("expected ok=true for valid JSON containing an escaped control character")
+ }
+ // The lipgloss color codes this function legitimately adds contain
+ // plenty of real ESC bytes of their own - strip those first so this
+ // check is only about whatever came from the JSON *content*.
+ plain := stripANSI(got)
+ if strings.ContainsRune(plain, 0x1b) {
+ t.Errorf("raw ESC byte from JSON content survived into output: %q", plain)
+ }
+ if !strings.Contains(plain, marker) {
+ t.Errorf("expected the ESC byte to still be escaped as a unicode sequence containing %q, got: %q", marker, plain)
+ }
+}
+
+func TestColorizeJSONArrayOfObjects(t *testing.T) {
+ in := `[{"id":1},{"id":2}]`
+ got, ok := colorizeJSON([]byte(in))
+ if !ok {
+ t.Fatal("expected ok=true")
+ }
+ want := `[
+ {
+ "id": 1
+ },
+ {
+ "id": 2
+ }
+]`
+ if plain := stripANSI(got); plain != want {
+ t.Errorf("stripped output = %q, want %q", plain, want)
+ }
+}
diff --git a/cmd/mitmux/main.go b/cmd/mitmux/main.go
index afb345b..30b71e7 100644
--- a/cmd/mitmux/main.go
+++ b/cmd/mitmux/main.go
@@ -9,6 +9,7 @@ import (
"os"
"path/filepath"
"regexp"
+ "sort"
"strings"
"time"
@@ -127,6 +128,42 @@ const (
tabResponse
)
+// sortColumn selects how the history list's currently loaded page is
+// ordered, client-side, on top of whatever order List/Search returned it
+// in (newest-first, or FTS5 relevance). sortByTime means "no override" -
+// the natural load order - rather than a real column to sort by, since
+// that order already comes sorted from the daemon.
+type sortColumn int
+
+const (
+ sortByTime sortColumn = iota
+ sortByStatus
+ sortBySize
+ sortByDuration
+ sortByMethod
+ sortByHost
+ sortByPath
+)
+
+func (c sortColumn) String() string {
+ switch c {
+ case sortByStatus:
+ return "status"
+ case sortBySize:
+ return "size"
+ case sortByDuration:
+ return "time taken"
+ case sortByMethod:
+ return "method"
+ case sortByHost:
+ return "host"
+ case sortByPath:
+ return "path"
+ default:
+ return "captured"
+ }
+}
+
type repeaterFocus int
const (
@@ -191,6 +228,11 @@ type model struct {
query string // applied filter, "" means unfiltered
pendingNew int // live entries captured while a filter hides them
+ // Client-side sort applied on top of whatever order List/Search
+ // already returned - see sortColumn.
+ sortColumn sortColumn
+ sortDesc bool
+
viewport viewport.Model
detail *ipc.EntryDetail
activeTab detailTab
@@ -1241,7 +1283,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
m.entries = msg.entries
- setTableRows(&m.table, rowsFor(m.entries))
+ m.refreshTable()
return m, nil
case newEntryMsg:
@@ -1261,7 +1303,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, m.waitForEntry
}
m.entries = append([]store.Summary{msg.entry}, m.entries...)
- setTableRows(&m.table, rowsFor(m.entries))
+ m.refreshTable()
return m, m.waitForEntry
case statusLoadedMsg:
@@ -1596,8 +1638,9 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case "f":
if row := m.table.Cursor(); row >= 0 && row < len(m.entries) {
m.entries[row].Flagged = !m.entries[row].Flagged
- setTableRows(&m.table, rowsFor(m.entries))
- return m, m.setFlagged(m.entries[row].ID, m.entries[row].Flagged)
+ id, flagged := m.entries[row].ID, m.entries[row].Flagged
+ m.refreshTable()
+ return m, m.setFlagged(id, flagged)
}
case "c":
if row := m.table.Cursor(); row >= 0 && row < len(m.entries) {
@@ -1662,6 +1705,27 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.mode = viewClientCerts
m.statusMsg = ""
return m, m.loadClientCerts
+ case "o":
+ m.sortColumn = (m.sortColumn + 1) % 7
+ m.sortDesc = false
+ m.refreshTable()
+ if m.sortColumn == sortByTime {
+ m.statusMsg = "sort: captured (load order)"
+ } else {
+ m.statusMsg = fmt.Sprintf("sort: %s ascending (O to reverse)", m.sortColumn)
+ }
+ return m, nil
+ case "O":
+ if m.sortColumn != sortByTime {
+ m.sortDesc = !m.sortDesc
+ m.refreshTable()
+ dir := "ascending"
+ if m.sortDesc {
+ dir = "descending"
+ }
+ m.statusMsg = fmt.Sprintf("sort: %s %s", m.sortColumn, dir)
+ }
+ return m, nil
case "esc":
if m.query != "" {
m.query = ""
@@ -2412,6 +2476,9 @@ func (m *model) helpView() string {
" status:404 / status:4xx / status:>=400,",
" source:repeater, flagged:true",
"esc clear active search filter",
+ "o cycle sort column (captured/status/size/time taken/",
+ " method/host/path) - sorts the currently loaded page",
+ "O reverse the current sort column's direction",
"m match-and-replace rules",
"s target scope (what gets recorded)",
"t client (mutual-TLS) certificates",
@@ -2501,6 +2568,18 @@ var (
diffDelStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("203"))
diffHunkStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("39"))
diffHeaderStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("240"))
+
+ status2xxStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")) // green
+ status3xxStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("39")) // blue
+ status4xxStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("208")) // orange
+ status5xxStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("203")) // red
+ statusErrStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("203")) // red
+
+ jsonKeyStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("39")) // blue
+ jsonStringStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")) // green
+ jsonNumberStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("208")) // orange
+ jsonBoolStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("170")) // magenta
+ jsonPunctStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) // gray
)
func (m *model) listView() string {
@@ -2538,9 +2617,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 · I import · / search · m rules · s scope · t client certs · ? help · q quit"
+ help := "enter view · r/i/c/d tools · f flag · x delete · X clear all · E export · I import · / search · o/O sort · m rules · s scope · t client certs · ? help · q quit"
if m.query != "" {
- help = "enter view · r/i/c/d tools · f flag · x delete · X clear all · E export (this filter) · I import · / search · esc clear filter · s scope · t client certs · ? help · q quit"
+ help = "enter view · r/i/c/d tools · f flag · x delete · X clear all · E export (this filter) · I import · / search · esc clear filter · o/O sort · s scope · t client certs · ? help · q quit"
}
b.WriteString(helpStyle.Render(help))
return b.String()
@@ -2556,8 +2635,21 @@ func (m *model) detailView() string {
if m.detail.Flagged {
flagMark = "★ "
}
- title := fmt.Sprintf(" %s#%d %s %s%s -> %d ", flagMark, m.detail.ID, sanitizeLine(m.detail.Method), sanitizeLine(m.detail.Host), sanitizeLine(m.detail.Path), m.detail.StatusCode)
- b.WriteString(titleStyle.Render(title))
+ statusText := fmt.Sprintf("%d", m.detail.StatusCode)
+ if m.detail.StatusCode == 0 {
+ statusText = "ERR"
+ }
+ title := fmt.Sprintf(" %s#%d %s %s%s -> ", flagMark, m.detail.ID, sanitizeLine(m.detail.Method), sanitizeLine(m.detail.Host), sanitizeLine(m.detail.Path))
+ // Status colored via a separate Render call nested inside titleStyle's
+ // own - placed last, right before titleStyle's own trailing space and
+ // reset, so the one cosmetic side effect of nesting styled text this
+ // way (the inner Render's reset code also ends titleStyle's bold/color
+ // for anything written after it) only costs a single space, not a
+ // visible chunk of the title going unstyled - bubbles/table's cell
+ // truncation would corrupt this the way it does in the history table
+ // (see styledStatus's doc comment), but this is a plain string built
+ // once and rendered whole, never routed through that.
+ b.WriteString(titleStyle.Render(title) + styledStatus(m.detail.StatusCode, statusText) + titleStyle.Render(" "))
b.WriteString("\n")
reqLabel := fmt.Sprintf("Request (%d bytes%s)", len(m.detail.RequestRaw), exactSuffix(m.detail.RequestExact, m.detail.RequestTruncated))
@@ -3094,6 +3186,99 @@ func detailBody(d *ipc.EntryDetail, tab detailTab) string {
return sanitizeBlock(string(d.ResponseRaw))
}
+// styledStatus renders text (an already-formatted status display value -
+// "200", "404", "ERR") colored by status class: 2xx green, 3xx blue, 4xx
+// orange, 5xx/ERR red - the same convention Burp and Caido both use.
+// statusCode is the raw numeric code (0 for ERR) so the class is derived
+// directly rather than re-parsed from text. Safe to color unconditionally
+// here: unlike a header value or body content, this text is always
+// something this file formatted itself ("%d" or the literal "ERR"), never
+// raw server-controlled bytes, so it needs no sanitizeControl pass first.
+//
+// Deliberately NOT used inside a table.Row cell: bubbles/table v1.0.0
+// (the newest available - confirmed no per-cell styling was ever added)
+// fits cell text to its column width via go-runewidth's Truncate, which
+// has no ANSI awareness at all - it counts every visible character of a
+// "\x1b[38;5;42m" escape sequence as real display width. Confirmed live:
+// coloring the Status cell this way didn't just fail to align, it
+// silently deleted the status text from the rendered row entirely
+// (truncation cut into the escape sequence itself). Safe anywhere a
+// plain string gets built once and lipgloss-rendered whole with no width
+// constraint - e.g. detailView's title - just not through table.Row.
+func styledStatus(statusCode int, text string) string {
+ switch {
+ case statusCode == 0:
+ return statusErrStyle.Render(text)
+ case statusCode < 300:
+ return status2xxStyle.Render(text)
+ case statusCode < 400:
+ return status3xxStyle.Render(text)
+ case statusCode < 500:
+ return status4xxStyle.Render(text)
+ default:
+ return status5xxStyle.Render(text)
+ }
+}
+
+// refreshTable reorders m.entries to match the current sort (a no-op
+// when sortByTime, the "no override" state) and re-renders the table
+// from it - call this instead of setTableRows(&m.table, rowsFor(...))
+// directly anywhere m.entries changes, so a sort stays applied across a
+// reload or a live-captured entry arriving.
+//
+// Reordering m.entries itself, not just what's rendered, matters beyond
+// display: every "row" key handler (enter/r/i/f/c/x and the mouse
+// equivalents) reads m.table.Cursor() and indexes straight into
+// m.entries at that position. If the table's rendered order and
+// m.entries's order ever diverged, the row highlighted on screen and
+// the entry an action actually targets would silently disagree -
+// pressing enter on what looks like entry #12 could open #47. Keeping
+// them identical sidesteps that whole class of bug rather than needing
+// every one of those call sites to go through a level of indirection.
+func (m *model) refreshTable() {
+ m.entries = m.sortedEntries()
+ setTableRows(&m.table, rowsFor(m.entries))
+}
+
+// sortedEntries returns m.entries in the order m.sortColumn/m.sortDesc
+// select, or m.entries unchanged for sortByTime (the natural load order
+// List/Search already returned, newest-first or FTS5-relevance - nothing
+// to re-sort). A stable sort so ties (e.g. several entries with the same
+// status) keep their relative load order rather than shuffling every
+// re-render.
+func (m *model) sortedEntries() []store.Summary {
+ if m.sortColumn == sortByTime {
+ return m.entries
+ }
+ sorted := append([]store.Summary(nil), m.entries...)
+ less := func(i, j int) bool {
+ a, b := sorted[i], sorted[j]
+ switch m.sortColumn {
+ case sortByStatus:
+ return a.StatusCode < b.StatusCode
+ case sortBySize:
+ return a.ReqSize+a.RespSize < b.ReqSize+b.RespSize
+ case sortByDuration:
+ return a.Duration < b.Duration
+ case sortByMethod:
+ return a.Method < b.Method
+ case sortByHost:
+ return a.Host < b.Host
+ case sortByPath:
+ return a.Path < b.Path
+ default:
+ return false
+ }
+ }
+ sort.SliceStable(sorted, func(i, j int) bool {
+ if m.sortDesc {
+ return less(j, i)
+ }
+ return less(i, j)
+ })
+ return sorted
+}
+
func rowsFor(entries []store.Summary) []table.Row {
rows := make([]table.Row, len(entries))
for i, e := range entries {
diff --git a/cmd/mitmux/pretty.go b/cmd/mitmux/pretty.go
index f6da6ad..1350623 100644
--- a/cmd/mitmux/pretty.go
+++ b/cmd/mitmux/pretty.go
@@ -47,20 +47,30 @@ func prettyResponse(raw []byte) (formatted string, ok bool) {
if err != nil || !json.Valid(body) {
return "", false
}
- var pretty bytes.Buffer
- if err := json.Indent(&pretty, body, "", " "); err != nil {
- return "", false
+
+ var headerBuf bytes.Buffer
+ headerBuf.WriteString(resp.Proto + " " + resp.Status + "\r\n")
+ writeHeadersSorted(&headerBuf, resp.Header, "Transfer-Encoding", "Content-Encoding", "Content-Length")
+ // Status and header values are server-controlled - sanitize before
+ // display, same as every other raw-text view. The colorized body
+ // below is handled separately and deliberately never passed through
+ // this: colorizeJSON's own re-marshaling of every string it prints
+ // already neutralizes control characters as a side effect of
+ // producing valid JSON, and sanitizeControl afterward would instead
+ // corrupt the ANSI color codes it adds (see colorizeJSON's doc
+ // comment).
+ headerText := sanitizeBlock(headerBuf.String())
+
+ bodyText, ok := colorizeJSON(body)
+ if !ok {
+ var plain bytes.Buffer
+ if err := json.Indent(&plain, body, "", " "); err != nil {
+ return "", false
+ }
+ bodyText = sanitizeBlock(plain.String())
}
- var out bytes.Buffer
- out.WriteString(resp.Proto + " " + resp.Status + "\r\n")
- writeHeadersSorted(&out, resp.Header, "Transfer-Encoding", "Content-Encoding", "Content-Length")
- out.WriteString("\r\n")
- out.Write(pretty.Bytes())
- // Status and header values are server-controlled and not JSON-escaped
- // like the body is - sanitize the whole rendering against control-
- // character/ANSI injection before it's ever displayed (see sanitize.go).
- return sanitizeBlock(out.String()), true
+ return headerText + "\r\n" + bodyText, true
}
func writeHeadersSorted(out *bytes.Buffer, h http.Header, omit ...string) {