fix: address protocol probe review findings

This commit is contained in:
proxyctl coordinator
2026-07-18 00:44:10 +08:00
parent 02b49d5112
commit 954e7d977a
12 changed files with 315 additions and 489 deletions
+2 -2
View File
@@ -48,7 +48,7 @@ Start the Target panel with no arguments:
proxyctl proxyctl
``` ```
Press `m` for Proxy Profile management, then `n` to create a Profile, enter a name and proxy URL, and press Ctrl+S to save. The editor supports cursor movement, Home, End, Delete, Backspace, insertion, paste, and horizontal scrolling; Tab or the arrow keys move between fields, and Escape cancels without saving. Validation stays beside each field. The management page supports reviewing obscured connection details, editing, and safe deletion; Active Profile switching remains a separate dashboard picker workflow rather than a management action. Single mode applies one URL to HTTP, HTTPS, and ALL/SOCKS. Ctrl+T selects advanced mode for separate values without discarding values entered in either mode. A bare `host:port` is accepted and normalized to `http://`. The Single proxy URL field visibly offers Protocol Probe with Ctrl+D; it runs only on that explicit keypress, and its credential-safe progress and HTTP/SOCKS5 result stay with the affected URL field rather than global status. When one endpoint supports both protocols, the probe switches the form to advanced mode and fills HTTP/HTTPS with `http://` and ALL/SOCKS with `socks5://`. Bypass accepts comma- or newline-delimited hostnames, domain suffixes, IPv4, IPv6, and CIDR entries; the editor previews normalized, deduplicated values and identifies invalid items before save. Press `m` for Proxy Profile management, then `n` to create a Profile, enter a name and proxy URL, and press Ctrl+S to save. The editor supports cursor movement, Home, End, Delete, Backspace, insertion, paste, and horizontal scrolling; Tab or the arrow keys move between fields, and Escape cancels without saving. Validation stays beside each field. The management page supports reviewing obscured connection details, editing, and safe deletion; Active Profile switching remains a separate dashboard picker workflow rather than a management action. Single mode applies one URL to HTTP, HTTPS, and ALL/SOCKS. Ctrl+T selects advanced mode for separate values without discarding values entered in either mode. A bare `host:port` is accepted and normalized to `http://`. The Single proxy URL field visibly offers Protocol Probe with Ctrl+D; it runs only on that explicit keypress, and its credential-safe progress and HTTP/SOCKS5 result stay with the initiating URL context rather than global status. When one endpoint supports both protocols, the probe switches the form to advanced mode, retains a read-only Single URL probe source row, and fills HTTP/HTTPS with `http://` and ALL/SOCKS with `socks5://`. Bypass accepts comma- or newline-delimited hostnames, domain suffixes, IPv4, IPv6, and CIDR entries; the editor previews normalized, deduplicated values and identifies invalid items before save.
The dashboard shows the Active Profile. Press `p` to open the searchable Active Profile picker, type to filter, use arrows to select, and press Enter to activate; Escape cancels without changing the Active Profile. Use arrows or `j`/`k` to select an Available Target and Space to run its contextual action: Enable, Disable, Apply Profile, or Resolve. Drift Resolution explicitly offers Reapply Active Profile and Restore Original; restoration refuses safely if proxyctl no longer has sufficient ownership evidence. Press `r` to refresh detection, `u` to show unavailable Targets, and `?` on any page or dialog for contextual Help. Help scrolls with arrows, `j`/`k`, Page Up/Page Down, Home, and End; `q` or Escape closes it without changing the page, focus, selection, or viewport beneath it. Applying or reapplying requires an Active Profile, while Restore Original remains available without one. The dashboard shows the Active Profile. Press `p` to open the searchable Active Profile picker, type to filter, use arrows to select, and press Enter to activate; Escape cancels without changing the Active Profile. Use arrows or `j`/`k` to select an Available Target and Space to run its contextual action: Enable, Disable, Apply Profile, or Resolve. Drift Resolution explicitly offers Reapply Active Profile and Restore Original; restoration refuses safely if proxyctl no longer has sufficient ownership evidence. Press `r` to refresh detection, `u` to show unavailable Targets, and `?` on any page or dialog for contextual Help. Help scrolls with arrows, `j`/`k`, Page Up/Page Down, Home, and End; `q` or Escape closes it without changing the page, focus, selection, or viewport beneath it. Applying or reapplying requires an Active Profile, while Restore Original remains available without one.
@@ -116,7 +116,7 @@ Inspect failures and rerun until reset reports no failed managed Target. Then re
## Privacy and safety boundaries ## Privacy and safety boundaries
proxyctl has no telemetry, automatic update checks, or automatic proxy-connectivity tests. It does not contact the network on its own; the only network traffic it generates is the user-initiated Protocol Probe (Ctrl+D in the Profile form), a handshake check that never forwards traffic through the proxy and never contacts anything beyond the endpoint itself. Local diagnostic logs are credential-redacted and bounded in size. Proxy credentials remain present where a selected Target requires them, so normal local file security still matters. proxyctl has no telemetry, automatic update checks, or automatic proxy-connectivity tests. It does not contact the network on its own; the only network traffic it generates is the user-initiated Protocol Probe (Ctrl+D in the Profile form), a handshake check that never forwards traffic through the proxy and never contacts anything beyond the endpoint itself. Local diagnostic logs are credential-redacted and bounded in size. Profile form frames mask the entire URL userinfo while retaining the underlying value for saving. Proxy credentials remain present where a selected Target requires them, so normal local file security still matters.
proxyctl does **not** manage or supervise proxy processes such as Clash, sing-box, or Xray. It does not manage subscriptions, nodes, latency, routing rules, transparent proxying, TUN/iptables/nftables, arbitrary services or commands, plugins, or a complete headless Profile/Target CLI. proxyctl does **not** manage or supervise proxy processes such as Clash, sing-box, or Xray. It does not manage subscriptions, nodes, latency, routing rules, transparent proxying, TUN/iptables/nftables, arbitrary services or commands, plugins, or a complete headless Profile/Target CLI.
+13 -6
View File
@@ -271,6 +271,13 @@ func RedactText(value string) string {
return credentialPattern.ReplaceAllString(value, `${1}${2}:REDACTED@`) return credentialPattern.ReplaceAllString(value, `${1}${2}:REDACTED@`)
} }
// RedactUserinfoText removes usernames as well as passwords. Form frames use
// this stricter boundary because even the identity of the probe source is
// private while a Profile is being edited.
func RedactUserinfoText(value string) string {
return credentialPattern.ReplaceAllString(value, `${1}REDACTED@`)
}
func ObscureURL(value string) string { func ObscureURL(value string) string {
u, err := url.Parse(value) u, err := url.Parse(value)
if err != nil || u.User == nil { if err != nil || u.User == nil {
@@ -283,9 +290,9 @@ func ObscureURL(value string) string {
return u.String() return u.String()
} }
// ObscureURLForEditing masks only password runes while preserving the input's // ObscureURLForEditing masks every userinfo rune while preserving the input's
// rune count. A text input clone can therefore retain its real cursor and // rune count. A text input clone can therefore retain its real cursor and
// horizontal viewport positions without ever rendering the credential. // horizontal viewport positions without rendering either username or password.
func ObscureURLForEditing(value string) string { func ObscureURLForEditing(value string) string {
matches := credentialPattern.FindAllStringSubmatchIndex(value, -1) matches := credentialPattern.FindAllStringSubmatchIndex(value, -1)
if len(matches) == 0 { if len(matches) == 0 {
@@ -294,10 +301,10 @@ func ObscureURLForEditing(value string) string {
var masked strings.Builder var masked strings.Builder
last := 0 last := 0
for _, match := range matches { for _, match := range matches {
passwordStart, passwordEnd := match[6], match[7] userinfoStart, userinfoEnd := match[4], match[7]
masked.WriteString(value[last:passwordStart]) masked.WriteString(value[last:userinfoStart])
masked.WriteString(strings.Repeat("•", len([]rune(value[passwordStart:passwordEnd])))) masked.WriteString(strings.Repeat("•", len([]rune(value[userinfoStart:userinfoEnd]))))
last = passwordEnd last = userinfoEnd
} }
masked.WriteString(value[last:]) masked.WriteString(value[last:])
return masked.String() return masked.String()
+8 -2
View File
@@ -158,8 +158,14 @@ func TestCompleteModelDefensivelyRedactsEveryRenderedPage(t *testing.T) {
if strings.Contains(frame, secret) { if strings.Contains(frame, secret) {
t.Fatalf("frame %d exposed a credential:\n%s", i, frame) t.Fatalf("frame %d exposed a credential:\n%s", i, frame)
} }
if !strings.Contains(frame, redacted) { if i < 3 {
t.Fatalf("frame %d did not apply the presentation redaction path:\n%s", i, frame) if !strings.Contains(frame, redacted) {
t.Fatalf("frame %d did not apply the presentation redaction path:\n%s", i, frame)
}
continue
}
if strings.Contains(frame, "alice") || !strings.Contains(frame, "REDACTED@proxy.example:8443") {
t.Fatalf("form frame %d did not mask the entire userinfo:\n%s", i, frame)
} }
} }
} }
+2 -2
View File
@@ -149,8 +149,8 @@ func (p terminalPresentation) contextualHelp(model presentationState) string {
default: default:
body.WriteString("The Single proxy URL field visibly offers Protocol Probe; focus that field and press Ctrl+D to run it explicitly.\n") body.WriteString("The Single proxy URL field visibly offers Protocol Probe; focus that field and press Ctrl+D to run it explicitly.\n")
} }
} else if model.form.probe.feedback != "" { } else if model.form.hasProbeSourceAttachment() {
body.WriteString("The Protocol Probe result remains attached to the generated Advanced Profile URL values.\n") body.WriteString("The Protocol Probe result remains attached to its read-only initiating Single proxy URL context. The HTTP, HTTPS, and ALL/SOCKS values were generated from that probe.\n")
} }
default: default:
body.WriteString(p.theme.section.Render("Dashboard")) body.WriteString(p.theme.section.Render("Dashboard"))
+33 -4
View File
@@ -1,6 +1,7 @@
package tui package tui
import ( import (
"context"
"fmt" "fmt"
"strings" "strings"
"testing" "testing"
@@ -8,6 +9,7 @@ import (
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"proxyctl/internal/app" "proxyctl/internal/app"
"proxyctl/internal/probe"
"proxyctl/internal/profile" "proxyctl/internal/profile"
) )
@@ -304,10 +306,6 @@ func TestCompleteModelFooterHintsMatchContextAndAcceptedKeys(t *testing.T) {
if probeCommand == nil || !strings.Contains(model.View(), "Protocol Probe in progress") || strings.Contains(model.View(), "Ctrl+D: probe") { if probeCommand == nil || !strings.Contains(model.View(), "Protocol Probe in progress") || strings.Contains(model.View(), "Ctrl+D: probe") {
t.Fatalf("probe footer did not follow accepted in-field action and progress:\n%s", model.View()) t.Fatalf("probe footer did not follow accepted in-field action and progress:\n%s", model.View())
} }
model = updateKey(model, "?")
assertUsableFrame(t, model.View(), 80, 24, "Protocol Probe is in progress", "Ctrl+D does not start a duplicate probe")
model = updateKey(model, "esc")
model = updateKey(model, "ctrl+t") model = updateKey(model, "ctrl+t")
if strings.Contains(model.View(), "Ctrl+D: probe") || !strings.Contains(model.View(), "advanced mode") { if strings.Contains(model.View(), "Ctrl+D: probe") || !strings.Contains(model.View(), "advanced mode") {
t.Fatalf("Advanced form advertised a Single-field Protocol Probe:\n%s", model.View()) t.Fatalf("Advanced form advertised a Single-field Protocol Probe:\n%s", model.View())
@@ -335,6 +333,37 @@ func TestCompleteModelFooterHintsMatchContextAndAcceptedKeys(t *testing.T) {
}) })
} }
func TestCompleteModelHelpDocumentsProbeShortcutExplicitOnlyAttachmentAndDuplicateSuppression(t *testing.T) {
model := openProtocolProbeEditor(t, &recordingModule{}, func(context.Context, string) (probe.Capabilities, error) {
return probe.Capabilities{HTTP: true, SOCKS5: true}, nil
}, "proxy.example:7890")
model = updateKey(model, "?")
assertUsableFrame(t, model.View(), 80, 24,
"Ctrl+D explicitly runs Protocol Probe for the focused Single proxy URL field.",
"never runs on form entry, focus changes, or save.")
model = updateKey(model, "esc")
model, command := startProtocolProbe(t, model)
updated, duplicate := model.Update(tea.KeyMsg{Type: tea.KeyCtrlD})
model = updated.(Model)
if duplicate != nil {
t.Fatal("repeated Probe shortcut emitted duplicate work")
}
model = updateKey(model, "?")
assertUsableFrame(t, model.View(), 80, 24,
"Protocol Probe is in progress for the Single proxy URL field.",
"Ctrl+D does not start a duplicate probe")
model = updateKey(model, "esc")
model = finishProtocolProbe(t, model, command)
model = updateKey(model, "?")
assertUsableFrame(t, model.View(), 80, 24,
"The Protocol Probe result remains attached to its read-only initiating Single",
"proxy URL context. The HTTP, HTTPS, and ALL/SOCKS values were generated from",
"that probe.")
}
func TestCompleteModelHelpOverEditorPreservesFieldCursorPreviewAndResizeState(t *testing.T) { func TestCompleteModelHelpOverEditorPreservesFieldCursorPreviewAndResizeState(t *testing.T) {
module := &profileManagementModule{} module := &profileManagementModule{}
model := openNewProfileEditor(t, module) model := openNewProfileEditor(t, module)
+9 -86
View File
@@ -2,9 +2,7 @@ package tui
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"strings"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
@@ -47,14 +45,12 @@ type Model struct {
detect func(context.Context, string) (probe.Capabilities, error) detect func(context.Context, string) (probe.Capabilities, error)
} }
// probeDone carries one finished Protocol Probe back to the form. Endpoint and // probeDone carries only detector output and formState's opaque typed token.
// input text deliberately stay in formState: an asynchronous result cannot // Source data and all validation/application rules remain owned by formState.
// introduce userinfo into status, and formID plus generation reject stale work.
type probeDone struct { type probeDone struct {
caps probe.Capabilities caps probe.Capabilities
err error err error
formID uint64 token probeToken
generation uint64
} }
type profileDeleteConfirmation struct { type profileDeleteConfirmation struct {
@@ -177,57 +173,7 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
return m, waitForOperation(progress.updates) return m, waitForOperation(progress.updates)
} }
if done, ok := message.(probeDone); ok { if done, ok := message.(probeDone); ok {
if m.page != formPage || !m.form.probe.active || done.formID != m.form.id || done.generation != m.form.probe.generation { return m, m.form.applyProbeResult(done.token, done.caps, done.err)
return m, nil
}
m.form.probe.active = false
address := m.form.probe.address
switch {
case done.err != nil:
// Detector errors are intentionally not rendered. Production errors are
// based on the credential-free dial endpoint, but this boundary also
// protects frames if a lower layer returns hostile credential text.
m.form.probe.feedback = "Protocol Probe failed for " + address
case !done.caps.HTTP && !done.caps.SOCKS5:
m.form.probe.feedback = "Protocol Probe: unrecognized protocol at " + address
default:
raw := m.form.profile.SingleURL
if done.caps.HTTP && done.caps.SOCKS5 {
httpURL, err := probe.URL(raw, probe.Capabilities{HTTP: true})
if err != nil {
m.form.probe.feedback = "Protocol Probe could not apply the detected capabilities"
return m, nil
}
socksURL, err := probe.URL(raw, probe.Capabilities{SOCKS5: true})
if err != nil {
m.form.probe.feedback = "Protocol Probe could not apply the detected capabilities"
return m, nil
}
m.form.profile.Mode = profile.AdvancedMode
m.form.profile.HTTPURL = httpURL
m.form.profile.HTTPSURL = httpURL
m.form.profile.AllURL = strings.Replace(socksURL, "socks5h://", "socks5://", 1)
m.form.advancedInitialized = true
m.form.probe.field = profile.HTTPURLField
m.form.probe.feedback = "Protocol Probe: detected HTTP and SOCKS5 proxy at " + address
m.form.rebuildInputs(1)
return m, nil
}
rewritten, err := probe.URL(raw, done.caps)
if err != nil {
m.form.probe.feedback = "Protocol Probe could not apply the detected capabilities"
return m, nil
}
m.form.profile.SingleURL = rewritten
m.form.probe.field = profile.SingleURLField
kind := "SOCKS5"
if done.caps.HTTP {
kind = "HTTP"
}
m.form.probe.feedback = "Protocol Probe: detected " + kind + " proxy at " + address
m.form.rebuildInputs(1)
}
return m, nil
} }
key, ok := message.(tea.KeyMsg) key, ok := message.(tea.KeyMsg)
if !ok { if !ok {
@@ -596,26 +542,14 @@ func (m Model) updateForm(key tea.KeyMsg) (tea.Model, tea.Cmd) {
m.form = formState{} m.form = formState{}
return m, nil return m, nil
case tea.KeyCtrlD: case tea.KeyCtrlD:
if m.form.profile.Mode != profile.SingleMode || m.form.currentField() != profile.SingleURLField || m.form.probe.active { address, token, started := m.form.startProbe()
if !started {
return m, nil return m, nil
} }
m.form.syncFocusedValue()
m.form.invalidateProbe()
m.form.probe.field = profile.SingleURLField
address, err := probeAddress(m.form.profile.SingleURL)
if err != nil {
m.form.probe.feedback = "Protocol Probe: " + err.Error()
return m, nil
}
m.form.probe.active = true
m.form.probe.address = address
m.form.probe.feedback = "Protocol Probe: checking " + address + "…"
detect := m.detect detect := m.detect
formID := m.form.id
generation := m.form.probe.generation
return m, func() tea.Msg { return m, func() tea.Msg {
caps, err := detect(context.Background(), address) caps, err := detect(context.Background(), address)
return probeDone{caps: caps, err: err, formID: formID, generation: generation} return probeDone{caps: caps, err: err, token: token}
} }
case tea.KeyCtrlT: case tea.KeyCtrlT:
return m, m.form.toggleMode() return m, m.form.toggleMode()
@@ -653,17 +587,6 @@ func (m *Model) applyProfileMutation(intent app.Intent, preferredName string) ap
return result return result
} }
// probeAddress extracts a bare host:port from the typed endpoint for dialing
// and status text, dropping any userinfo so credentials are never displayed
// or sent anywhere.
func probeAddress(raw string) (string, error) {
u, err := profile.ParseProxyURL(raw)
if err != nil {
return "", errors.New("Single proxy URL must include host:port")
}
return u.Host, nil
}
func (m Model) selectedProfile() (profile.Profile, bool) { func (m Model) selectedProfile() (profile.Profile, bool) {
if m.selectedProfileName != "" { if m.selectedProfileName != "" {
for i := range m.snapshot.Profiles { for i := range m.snapshot.Profiles {
-346
View File
@@ -1,15 +1,12 @@
package tui package tui
import ( import (
"context"
"errors"
"strings" "strings"
"testing" "testing"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"proxyctl/internal/app" "proxyctl/internal/app"
"proxyctl/internal/probe"
"proxyctl/internal/profile" "proxyctl/internal/profile"
) )
@@ -138,349 +135,6 @@ func TestNoProfileStateStaysOnAccessibleMainPage(t *testing.T) {
} }
} }
func TestProtocolProbeDetectsHTTPAndRewritesSingleURL(t *testing.T) {
module := &recordingModule{}
model := NewModel(module)
var probed []string
model.detect = func(_ context.Context, address string) (probe.Capabilities, error) {
probed = append(probed, address)
return probe.Capabilities{HTTP: true}, nil
}
model = updateKey(model, "m")
model = updateKey(model, "n")
model = updateRunes(model, "home")
model = updateKey(model, "tab")
model = updateRunes(model, "alice:secret@localhost:8888")
model, cmd := pressCtrlD(model)
if cmd == nil {
t.Fatal("ctrl+d did not start a Protocol Probe")
}
updated, _ := model.Update(cmd())
model = updated.(Model)
if len(probed) != 1 || probed[0] != "localhost:8888" {
t.Fatalf("Protocol Probe dialed %#v, want exactly localhost:8888 without userinfo", probed)
}
if got := model.form.profile.SingleURL; got != "http://alice:secret@localhost:8888" {
t.Fatalf("Single URL after probe = %q, want detected scheme with preserved userinfo", got)
}
view := model.View()
if !strings.Contains(view, "Protocol Probe: detected HTTP proxy at localhost:8888") {
t.Fatalf("form view missing detection status:\n%s", view)
}
if strings.Contains(view, "secret") {
t.Fatalf("form view exposed proxy password:\n%s", view)
}
}
func pressCtrlD(model Model) (Model, tea.Cmd) {
updated, cmd := model.Update(tea.KeyMsg{Type: tea.KeyCtrlD})
return updated.(Model), cmd
}
func TestProtocolProbeFailureLeavesFieldUntouchedAndSavePossible(t *testing.T) {
module := &recordingModule{}
model := NewModel(module)
model.detect = func(_ context.Context, address string) (probe.Capabilities, error) {
return probe.Capabilities{}, errors.New("connection refused")
}
model = updateKey(model, "m")
model = updateKey(model, "n")
model = updateRunes(model, "home")
model = updateKey(model, "tab")
model = updateRunes(model, "localhost:8888")
model, cmd := pressCtrlD(model)
if cmd == nil {
t.Fatal("ctrl+d did not start a Protocol Probe")
}
updated, _ := model.Update(cmd())
model = updated.(Model)
if got := model.form.profile.SingleURL; got != "localhost:8888" {
t.Fatalf("failed probe rewrote Single URL to %q", got)
}
if view := model.View(); !strings.Contains(view, "Protocol Probe failed for localhost:8888") || strings.Contains(view, "connection refused") {
t.Fatalf("form view missing credential-safe field failure status:\n%s", view)
}
model = updateKey(model, "ctrl+s")
if len(module.intents) != 1 || module.intents[0].Kind != app.CreateProfile {
t.Fatalf("save after failed probe dispatched %#v", module.intents)
}
}
func TestProtocolProbeUnrecognizedProtocolLeavesFieldUntouched(t *testing.T) {
module := &recordingModule{}
model := NewModel(module)
model.detect = func(_ context.Context, address string) (probe.Capabilities, error) {
return probe.Capabilities{}, nil
}
model = updateKey(model, "m")
model = updateKey(model, "n")
model = updateRunes(model, "home")
model = updateKey(model, "tab")
model = updateRunes(model, "localhost:8888")
model, cmd := pressCtrlD(model)
if cmd == nil {
t.Fatal("ctrl+d did not start a Protocol Probe")
}
updated, _ := model.Update(cmd())
model = updated.(Model)
if got := model.form.profile.SingleURL; got != "localhost:8888" {
t.Fatalf("unrecognized probe rewrote Single URL to %q", got)
}
if view := model.View(); !strings.Contains(view, "Protocol Probe: unrecognized protocol at localhost:8888") {
t.Fatalf("form view missing unrecognized status:\n%s", view)
}
}
func TestProtocolProbeIgnoresRepeatedCtrlDWhileInFlight(t *testing.T) {
module := &recordingModule{}
model := NewModel(module)
calls := 0
model.detect = func(_ context.Context, address string) (probe.Capabilities, error) {
calls++
return probe.Capabilities{HTTP: true}, nil
}
model = updateKey(model, "m")
model = updateKey(model, "n")
model = updateRunes(model, "home")
model = updateKey(model, "tab")
model = updateRunes(model, "localhost:8888")
model, first := pressCtrlD(model)
if first == nil {
t.Fatal("ctrl+d did not start a Protocol Probe")
}
model, second := pressCtrlD(model)
if second != nil {
t.Fatal("ctrl+d during an in-flight probe started a second one")
}
updated, _ := model.Update(first())
model = updated.(Model)
if calls != 1 {
t.Fatalf("probe ran %d times, want 1", calls)
}
if got := model.form.profile.SingleURL; got != "http://localhost:8888" {
t.Fatalf("Single URL after probe = %q", got)
}
}
func TestProtocolProbeLateResultIgnoredAfterEscLeavesForm(t *testing.T) {
module := &recordingModule{}
model := NewModel(module)
model.detect = func(_ context.Context, address string) (probe.Capabilities, error) {
return probe.Capabilities{HTTP: true}, nil
}
model = updateKey(model, "m")
model = updateKey(model, "n")
model = updateRunes(model, "home")
model = updateKey(model, "tab")
model = updateRunes(model, "localhost:8888")
model, cmd := pressCtrlD(model)
if cmd == nil {
t.Fatal("ctrl+d did not start a Protocol Probe")
}
model = updateKey(model, "esc")
updated, _ := model.Update(cmd())
model = updated.(Model)
if model.page != profilesPage {
t.Fatalf("page = %d, want profilesPage", model.page)
}
if strings.Contains(model.status, "detected HTTP proxy") {
t.Fatalf("profiles page status mutated by late probe result: %s", model.status)
}
if model.form.profile.SingleURL != "" {
t.Fatalf("abandoned form SingleURL mutated to %q", model.form.profile.SingleURL)
}
}
func TestProtocolProbeLateResultIgnoredAfterSaveLeavesForm(t *testing.T) {
module := &recordingModule{}
model := NewModel(module)
model.detect = func(_ context.Context, address string) (probe.Capabilities, error) {
return probe.Capabilities{HTTP: true}, nil
}
model = updateKey(model, "m")
model = updateKey(model, "n")
model = updateRunes(model, "home")
model = updateKey(model, "tab")
model = updateRunes(model, "localhost:8888")
model, cmd := pressCtrlD(model)
if cmd == nil {
t.Fatal("ctrl+d did not start a Protocol Probe")
}
model = updateKey(model, "ctrl+s")
if len(module.intents) != 1 {
t.Fatalf("save dispatched %d intents, want 1", len(module.intents))
}
updated, _ := model.Update(cmd())
model = updated.(Model)
if model.page != profilesPage {
t.Fatalf("page = %d, want profilesPage", model.page)
}
if strings.Contains(model.status, "detected HTTP proxy") {
t.Fatalf("profiles page status mutated by late probe result: %s", model.status)
}
if model.form.profile.SingleURL != "" {
t.Fatalf("abandoned form SingleURL mutated to %q", model.form.profile.SingleURL)
}
}
func TestSaveFormDoesNotTriggerProtocolProbe(t *testing.T) {
module := &recordingModule{}
model := NewModel(module)
calls := 0
model.detect = func(_ context.Context, address string) (probe.Capabilities, error) {
calls++
return probe.Capabilities{HTTP: true}, nil
}
model = updateKey(model, "m")
model = updateKey(model, "n")
model = updateRunes(model, "home")
model = updateKey(model, "tab")
model = updateRunes(model, "localhost:8888")
model = updateKey(model, "ctrl+s")
if calls != 0 {
t.Fatalf("ctrl+s triggered %d probe call(s), want 0", calls)
}
if len(module.intents) != 1 || module.intents[0].Kind != app.CreateProfile {
t.Fatalf("ctrl+s dispatched %#v", module.intents)
}
}
func TestProtocolProbeOnlyTriggersOnSingleURLField(t *testing.T) {
module := &recordingModule{}
model := NewModel(module)
calls := 0
model.detect = func(_ context.Context, address string) (probe.Capabilities, error) {
calls++
return probe.Capabilities{HTTP: true}, nil
}
model = updateKey(model, "m")
model = updateKey(model, "n")
model = updateRunes(model, "home")
// Field 0 is Name.
model, cmd := pressCtrlD(model)
if cmd != nil {
t.Fatal("ctrl+d on Name field started a Protocol Probe")
}
// Move to Single URL and probe once so we can reach the Bypass field.
model = updateKey(model, "tab")
model = updateRunes(model, "localhost:8888")
model, cmd = pressCtrlD(model)
if cmd == nil {
t.Fatal("ctrl+d on Single URL field did not start a Protocol Probe")
}
updated, _ := model.Update(cmd())
model = updated.(Model)
// Move to Bypass (field 2) and press Ctrl+D again.
model = updateKey(model, "tab")
model, cmd = pressCtrlD(model)
if cmd != nil {
t.Fatal("ctrl+d on Bypass field started a Protocol Probe")
}
if calls != 1 {
t.Fatalf("Protocol Probe ran %d times, want 1", calls)
}
}
func TestProtocolProbeDetectsDualProtocolEndpointBuildsCompatibleAdvancedProfile(t *testing.T) {
module := &recordingModule{}
model := NewModel(module)
model.detect = func(_ context.Context, address string) (probe.Capabilities, error) {
return probe.Capabilities{HTTP: true, SOCKS5: true}, nil
}
model = updateKey(model, "m")
model = updateKey(model, "n")
model = updateRunes(model, "home")
model = updateKey(model, "tab")
model = updateRunes(model, "localhost:7890")
model, cmd := pressCtrlD(model)
if cmd == nil {
t.Fatal("ctrl+d did not start a Protocol Probe")
}
updated, _ := model.Update(cmd())
model = updated.(Model)
got := model.form.profile
if got.Mode != profile.AdvancedMode || got.HTTPURL != "http://localhost:7890" || got.HTTPSURL != "http://localhost:7890" || got.AllURL != "socks5://localhost:7890" {
t.Fatalf("dual-protocol Profile = %#v", got)
}
if view := model.View(); !strings.Contains(view, "Protocol Probe: detected HTTP and SOCKS5 proxy at localhost:7890") {
t.Fatalf("form view missing dual-protocol detection status:\n%s", view)
}
}
func TestProtocolProbeDetectsSOCKS5AndRewritesSingleURL(t *testing.T) {
module := &recordingModule{}
model := NewModel(module)
model.detect = func(_ context.Context, address string) (probe.Capabilities, error) {
return probe.Capabilities{SOCKS5: true}, nil
}
model = updateKey(model, "m")
model = updateKey(model, "n")
model = updateRunes(model, "home")
model = updateKey(model, "tab")
model = updateRunes(model, "localhost:8888")
model, cmd := pressCtrlD(model)
if cmd == nil {
t.Fatal("ctrl+d did not start a Protocol Probe")
}
updated, _ := model.Update(cmd())
model = updated.(Model)
if got := model.form.profile.SingleURL; got != "socks5h://localhost:8888" {
t.Fatalf("Single URL after SOCKS5 probe = %q, want socks5h://localhost:8888", got)
}
if view := model.View(); !strings.Contains(view, "Protocol Probe: detected SOCKS5 proxy at localhost:8888") {
t.Fatalf("form view missing SOCKS5 detection status:\n%s", view)
}
}
func TestProtocolProbeInAdvancedModeShowsHintWithoutProbing(t *testing.T) {
module := &recordingModule{}
model := NewModel(module)
calls := 0
model.detect = func(_ context.Context, address string) (probe.Capabilities, error) {
calls++
return probe.Capabilities{HTTP: true}, nil
}
model = updateKey(model, "m")
model = updateKey(model, "n")
model = updateRunes(model, "home")
model = updateKey(model, "ctrl+t")
model = updateKey(model, "tab")
model = updateRunes(model, "localhost:8888")
model, cmd := pressCtrlD(model)
if cmd != nil {
t.Fatal("ctrl+d in Advanced mode started a Protocol Probe")
}
if calls != 0 {
t.Fatalf("Advanced mode probe ran %d times, want 0", calls)
}
if view := model.View(); strings.Contains(view, "Protocol Probe") || strings.Contains(view, "Ctrl+D") {
t.Fatalf("Advanced mode advertised an inapplicable Protocol Probe action:\n%s", view)
}
}
func updateKey(model Model, key string) Model { func updateKey(model Model, key string) Model {
msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(key)} msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(key)}
special := map[string]tea.KeyType{ special := map[string]tea.KeyType{
+6
View File
@@ -238,6 +238,12 @@ func (p terminalPresentation) formView(model presentationState) terminalPage {
fmt.Fprintf(&fields, "\n %s", probeText) fmt.Fprintf(&fields, "\n %s", probeText)
currentLine++ currentLine++
} }
if item.field == profile.NameField && model.form.hasProbeSourceAttachment() {
fmt.Fprintf(&fields, "\n Single proxy URL probe [read-only]: %s", profile.ObscureURLForEditing(model.form.probe.sourceValue))
fmt.Fprintf(&fields, "\n %s", model.form.probe.feedback)
fields.WriteString("\n Generated Advanced URL fields from this Single proxy URL probe:")
currentLine += 3
}
validation := model.form.validationText(i) validation := model.form.validationText(i)
if item.field == profile.NameField { if item.field == profile.NameField {
if conflict := model.form.nameConflict(model.snapshot.Profiles); conflict != "" { if conflict := model.form.nameConflict(model.snapshot.Profiles); conflict != "" {
+7 -2
View File
@@ -146,8 +146,13 @@ func (p terminalPresentation) Render(state presentationState) string {
page = redactTerminalPage(page) page = redactTerminalPage(page)
// Dashboard composition retains structured Target metadata until pane sizes // Dashboard composition retains structured Target metadata until pane sizes
// are known. Redact the fully composed frame as the final presentation // are known. Redact the fully composed frame as the final presentation
// boundary as well, so no render path can bypass credential scrubbing. // boundary as well, so no render path can bypass credential scrubbing. Form
return profile.RedactText(p.renderShell(page, width, height)) // frames use the stricter boundary that removes the entire userinfo.
frame := p.renderShell(page, width, height)
if state.page == formPage {
return profile.RedactUserinfoText(frame)
}
return profile.RedactText(frame)
} }
func renderStatus(state presentationState) string { func renderStatus(state presentationState) string {
+15 -7
View File
@@ -55,26 +55,34 @@ func TestCompleteModelProfileEditorSupportsCursorEditingDeletionAndPaste(t *test
} }
} }
func TestCompleteModelProfileEditorHorizontallyScrollsLongSecretURLWithoutDisclosure(t *testing.T) { func TestCompleteModelProfileEditorMasksEntireUserinfoWhilePreservingScrollAndSavedValue(t *testing.T) {
module := &profileManagementModule{} module := &profileManagementModule{}
model := openNewProfileEditor(t, module) model := openNewProfileEditor(t, module)
model = updateRunes(model, "office") model = updateRunes(model, "office")
model = updateKey(model, "tab") model = updateKey(model, "tab")
const username = "operator-private"
const secret = "credential-never-rendered" const secret = "credential-never-rendered"
const raw = "https://operator:" + secret + "@this-is-a-very-long-proxy-hostname-for-horizontal-scroll.example:8443" const raw = "https://" + username + ":" + secret + "@this-is-a-very-long-proxy-hostname-for-horizontal-scroll.example:8443"
model = updateRunes(model, raw) model = updateRunes(model, raw)
end := model.View() end := model.View()
assertUsableFrame(t, end, 80, 24, "[focused]", "scroll.example:8443") assertUsableFrame(t, end, 80, 24, "[focused]", "scroll.example:8443")
if strings.Contains(end, secret) || strings.Contains(end, "https://operator") { for _, literal := range []string{username, secret} {
t.Fatalf("end-scrolled input disclosed credentials or failed to scroll:\n%s", end) if strings.Contains(end, literal) {
t.Fatalf("end-scrolled input disclosed userinfo %q:\n%s", literal, end)
}
} }
model = editorKey(model, tea.KeyHome) model = editorKey(model, tea.KeyHome)
start := model.View() start := model.View()
assertUsableFrame(t, start, 80, 24, "https://operator:") assertUsableFrame(t, start, 80, 24, "https://")
if strings.Contains(start, secret) || strings.Contains(start, "scroll.example:8443") { if strings.Contains(start, username) || strings.Contains(start, secret) || strings.Contains(start, "scroll.example:8443") {
t.Fatalf("home did not scroll to the safe beginning of the input:\n%s", start) t.Fatalf("home did not scroll to the masked beginning of the input:\n%s", start)
}
model = updateKey(model, "ctrl+s")
if len(module.intents) != 1 || module.intents[0].Profile.SingleURL != raw {
t.Fatalf("editable masking changed saved Profile semantics: %#v", module.intents)
} }
} }
+127 -13
View File
@@ -1,12 +1,14 @@
package tui package tui
import ( import (
"errors"
"fmt" "fmt"
"strings" "strings"
"github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"proxyctl/internal/probe"
"proxyctl/internal/profile" "proxyctl/internal/profile"
) )
@@ -22,12 +24,19 @@ type formDefinition struct {
value string value string
} }
type protocolProbeFieldState struct { type probeToken struct {
active bool formID uint64
generation uint64 generation uint64
address string source profile.Field
feedback string }
field profile.Field
type protocolProbeFieldState struct {
active bool
token probeToken
address string
feedback string
sourceValue string
sourceAttached bool
} }
type formState struct { type formState struct {
@@ -39,6 +48,7 @@ type formState struct {
bypassPreviewLine int bypassPreviewLine int
validation profile.Validation validation profile.Validation
advancedInitialized bool advancedInitialized bool
probeGeneration uint64
probe protocolProbeFieldState probe protocolProbeFieldState
} }
@@ -136,7 +146,7 @@ func (f *formState) updateInput(key tea.KeyMsg) tea.Cmd {
f.bypassPreviewLine = 0 f.bypassPreviewLine = 0
changedField := f.inputs[f.field].field changedField := f.inputs[f.field].field
f.syncFocusedValue() f.syncFocusedValue()
if changedField == profile.SingleURLField { if isProxyURLField(changedField) {
f.invalidateProbe() f.invalidateProbe()
} }
f.refreshValidation() f.refreshValidation()
@@ -195,12 +205,106 @@ func (f formState) currentField() profile.Field {
return f.inputs[f.field].field return f.inputs[f.field].field
} }
func isProxyURLField(field profile.Field) bool {
switch field {
case profile.SingleURLField, profile.HTTPURLField, profile.HTTPSURLField, profile.AllURLField:
return true
default:
return false
}
}
func (f *formState) invalidateProbe() { func (f *formState) invalidateProbe() {
f.probe.generation++ f.probeGeneration++
f.probe = protocolProbeFieldState{}
}
// startProbe owns applicability, duplicate suppression, source capture, and
// token issuance so callers cannot accidentally start work outside formState's
// stale-result rules.
func (f *formState) startProbe() (string, probeToken, bool) {
if f.profile.Mode != profile.SingleMode || f.currentField() != profile.SingleURLField || f.probe.active {
return "", probeToken{}, false
}
f.syncFocusedValue()
f.invalidateProbe()
token := probeToken{formID: f.id, generation: f.probeGeneration, source: profile.SingleURLField}
f.probe.token = token
f.probe.sourceValue = f.profile.SingleURL
address, err := probeAddress(f.probe.sourceValue)
if err != nil {
f.probe.feedback = "Protocol Probe: " + err.Error()
return "", probeToken{}, false
}
f.probe.active = true
f.probe.address = address
f.probe.feedback = "Protocol Probe: checking " + address + "…"
return address, token, true
}
// applyProbeResult validates and applies a completion as one form-owned
// operation. Every stale-result invariant stays here: form identity,
// generation, source field, active work, mode, and unchanged source value.
func (f *formState) applyProbeResult(token probeToken, caps probe.Capabilities, resultErr error) tea.Cmd {
if !f.probe.active || token != f.probe.token || token.formID != f.id ||
token.source != profile.SingleURLField || f.profile.Mode != profile.SingleMode ||
f.profile.SingleURL != f.probe.sourceValue {
return nil
}
f.probe.active = false f.probe.active = false
f.probe.address = "" address, raw := f.probe.address, f.probe.sourceValue
f.probe.feedback = "" switch {
f.probe.field = "" case resultErr != nil:
f.probe.feedback = "Protocol Probe failed for " + address
case !caps.HTTP && !caps.SOCKS5:
f.probe.feedback = "Protocol Probe: unrecognized protocol at " + address
case caps.HTTP && caps.SOCKS5:
httpURL, err := probe.URL(raw, probe.Capabilities{HTTP: true})
if err != nil {
f.probe.feedback = "Protocol Probe could not apply the detected capabilities"
return nil
}
socksURL, err := probe.URL(raw, probe.Capabilities{SOCKS5: true})
if err != nil {
f.probe.feedback = "Protocol Probe could not apply the detected capabilities"
return nil
}
f.profile.Mode = profile.AdvancedMode
f.profile.HTTPURL = httpURL
f.profile.HTTPSURL = httpURL
f.profile.AllURL = strings.Replace(socksURL, "socks5h://", "socks5://", 1)
f.advancedInitialized = true
f.probe.feedback = "Protocol Probe: detected HTTP and SOCKS5 proxy at " + address
f.probe.sourceAttached = true
return f.rebuildInputs(1)
default:
rewritten, err := probe.URL(raw, caps)
if err != nil {
f.probe.feedback = "Protocol Probe could not apply the detected capabilities"
return nil
}
f.profile.SingleURL = rewritten
f.setInputValue(profile.SingleURLField, rewritten)
kind := "SOCKS5"
if caps.HTTP {
kind = "HTTP"
}
f.probe.feedback = "Protocol Probe: detected " + kind + " proxy at " + address
}
f.refreshValidation()
return nil
}
func (f *formState) setInputValue(field profile.Field, value string) {
for i := range f.inputs {
if f.inputs[i].field != field {
continue
}
position := f.inputs[i].input.Position()
f.inputs[i].input.SetValue(value)
f.inputs[i].input.SetCursor(position)
return
}
} }
func (f formState) probeText(field profile.Field) string { func (f formState) probeText(field profile.Field) string {
@@ -210,12 +314,22 @@ func (f formState) probeText(field profile.Field) string {
} }
return "Protocol Probe [Ctrl+D]: available" return "Protocol Probe [Ctrl+D]: available"
} }
if field == f.probe.field {
return f.probe.feedback
}
return "" return ""
} }
func (f formState) hasProbeSourceAttachment() bool {
return f.profile.Mode == profile.AdvancedMode && f.probe.sourceAttached &&
f.probe.token.source == profile.SingleURLField && f.probe.feedback != ""
}
func probeAddress(raw string) (string, error) {
u, err := profile.ParseProxyURL(raw)
if err != nil {
return "", errors.New("Single proxy URL must include host:port")
}
return u.Host, nil
}
func (f *formState) scrollBypassPreview(delta int) { func (f *formState) scrollBypassPreview(delta int) {
if f.currentField() != profile.BypassField { if f.currentField() != profile.BypassField {
return return
@@ -105,6 +105,49 @@ func TestCompleteModelProtocolProbeNeverRunsWithoutExplicitInvocation(t *testing
} }
} }
func TestCompleteModelProtocolProbeCompletionPreservesAnotherEditedFieldsFocusCursorAndViewport(t *testing.T) {
module := &recordingModule{}
model := openProtocolProbeEditor(t, module, func(context.Context, string) (probe.Capabilities, error) {
return probe.Capabilities{HTTP: true}, nil
}, "proxy.example:8080")
model, command := startProtocolProbe(t, model)
model = updateKey(model, "shift+tab")
model = editorKey(model, tea.KeyHome)
model = updateRunes(model, "a-very-long-prefix-that-forces-the-name-field-to-scroll-horizontally-")
model = editorKey(model, tea.KeyLeft)
before := model.View()
assertUsableFrame(t, before, 80, 24, "> Name [focused]", "Protocol Probe: checking proxy.example:8080")
model = finishProtocolProbe(t, model, command)
after := model.View()
assertUsableFrame(t, after, 80, 24, "> Name [focused]", "Protocol Probe: detected HTTP proxy at proxy.example:8080")
if fieldLine(before, "> Name [focused]") != fieldLine(after, "> Name [focused]") {
t.Fatalf("non-mode-changing probe completion reset the edited field viewport or cursor:\nbefore:\n%s\nafter:\n%s", before, after)
}
model = updateRunes(model, "X")
model = updateKey(model, "ctrl+s")
if len(module.intents) != 1 {
t.Fatalf("save after asynchronous completion dispatched %#v", module.intents)
}
if got, want := module.intents[0].Profile.Name, "a-very-long-prefix-that-forces-the-name-field-to-scroll-horizontallyX-office"; got != want {
t.Fatalf("focus/cursor was not preserved: saved name = %q, want %q", got, want)
}
if got := module.intents[0].Profile.SingleURL; got != "http://proxy.example:8080" {
t.Fatalf("probe did not update its affected URL input: %q", got)
}
}
func fieldLine(frame, label string) string {
for _, line := range strings.Split(frame, "\n") {
if strings.Contains(line, label) {
return line
}
}
return ""
}
func TestCompleteModelProtocolProbePopulatesEstablishedCapabilityRepresentations(t *testing.T) { func TestCompleteModelProtocolProbePopulatesEstablishedCapabilityRepresentations(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -143,12 +186,12 @@ func TestCompleteModelProtocolProbePopulatesEstablishedCapabilityRepresentations
} }
attachedField := "Single proxy URL" attachedField := "Single proxy URL"
if test.caps.HTTP && test.caps.SOCKS5 { if test.caps.HTTP && test.caps.SOCKS5 {
attachedField = "HTTP URL" attachedField = "Single proxy URL probe [read-only]"
} }
field := strings.Index(view, attachedField) field := strings.Index(view, attachedField)
result := strings.Index(view, "Protocol Probe: detected") result := strings.Index(view, "Protocol Probe: detected")
if field < 0 || result < field || strings.Count(view[field:result], "\n") > 1 { if field < 0 || result < field || strings.Count(view[field:result], "\n") > 1 {
t.Fatalf("capability result was not attached to its populated URL field:\n%s", view) t.Fatalf("capability result was not attached to its initiating URL context:\n%s", view)
} }
model = updateKey(model, "ctrl+s") model = updateKey(model, "ctrl+s")
@@ -163,6 +206,27 @@ func TestCompleteModelProtocolProbePopulatesEstablishedCapabilityRepresentations
} }
} }
func TestCompleteModelProtocolProbeDualResultRetainsSingleSourceAndLinksGeneratedFields(t *testing.T) {
model := openProtocolProbeEditor(t, &recordingModule{}, func(context.Context, string) (probe.Capabilities, error) {
return probe.Capabilities{HTTP: true, SOCKS5: true}, nil
}, "proxy.example:7890")
model, command := startProtocolProbe(t, model)
model = finishProtocolProbe(t, model, command)
view := model.View()
assertUsableFrame(t, view, 80, 24,
"Create Proxy Profile — advanced mode",
"Single proxy URL probe [read-only]: proxy.example:7890",
"Protocol Probe: detected HTTP and SOCKS5 proxy at proxy.example:7890",
"Generated Advanced URL fields from this Single proxy URL probe:",
"HTTP URL", "HTTPS URL", "ALL/SOCKS URL")
if strings.Index(view, "Single proxy URL probe [read-only]") > strings.Index(view, "HTTP URL") {
t.Fatalf("initiating Single URL context was not visibly linked before generated fields:\n%s", view)
}
if strings.Contains(fieldLine(view, "HTTP URL"), "Protocol Probe") {
t.Fatalf("dual result was silently relabelled as an HTTP-field probe:\n%s", view)
}
}
func TestCompleteModelProtocolProbeFailureAndUnrecognizedResultsRemainEditableAndSavable(t *testing.T) { func TestCompleteModelProtocolProbeFailureAndUnrecognizedResultsRemainEditableAndSavable(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -338,33 +402,43 @@ func TestCompleteModelProtocolProbeSurvivesResizeWithoutRestartingOrDetaching(t
} }
} }
func TestCompleteModelProtocolProbeKeepsCredentialsOutOfDialStatusErrorsAndFrames(t *testing.T) { func TestCompleteModelProtocolProbeKeepsAllUserinfoOutOfDialStatusErrorsAndEveryFormFrame(t *testing.T) {
const ( const (
inputUser = "alice-private"
inputSecret = "input-secret" inputSecret = "input-secret"
errorUser = "mallory-private"
errorSecret = "error-secret" errorSecret = "error-secret"
) )
var dialed []string var dialed []string
model := openProtocolProbeEditor(t, &recordingModule{}, func(_ context.Context, address string) (probe.Capabilities, error) { module := &recordingModule{}
model := openProtocolProbeEditor(t, module, func(_ context.Context, address string) (probe.Capabilities, error) {
dialed = append(dialed, address) dialed = append(dialed, address)
return probe.Capabilities{}, errors.New("failed https://mallory:" + errorSecret + "@evil.example:9999") return probe.Capabilities{}, errors.New("failed https://" + errorUser + ":" + errorSecret + "@evil.example:9999")
}, "alice:"+inputSecret+"@proxy.example:8080") }, inputUser+":"+inputSecret+"@proxy.example:8080")
frames := []string{model.View()}
model, command := startProtocolProbe(t, model) model, command := startProtocolProbe(t, model)
progress := model.View() frames = append(frames, model.View())
if strings.Contains(progress, inputSecret) {
t.Fatalf("probe progress exposed credentials:\n%s", progress)
}
for _, line := range strings.Split(progress, "\n") {
if strings.Contains(line, "Protocol Probe") && strings.Contains(line, "alice") {
t.Fatalf("probe-generated progress exposed userinfo:\n%s", progress)
}
}
model = finishProtocolProbe(t, model, command) model = finishProtocolProbe(t, model, command)
view := model.View() frames = append(frames, model.View())
model = resize(model, 60, 16)
frames = append(frames, model.View())
model = resize(model, 80, 24)
frames = append(frames, model.View())
if len(dialed) != 1 || dialed[0] != "proxy.example:8080" { if len(dialed) != 1 || dialed[0] != "proxy.example:8080" {
t.Fatalf("probe dial evidence = %#v, want credential-free endpoint", dialed) t.Fatalf("probe dial evidence = %#v, want credential-free endpoint", dialed)
} }
if strings.Contains(view, inputSecret) || strings.Contains(view, errorSecret) || strings.Contains(view, "mallory") { for i, frame := range frames {
t.Fatalf("probe frame exposed input or lower-layer credentials:\n%s", view) for _, literal := range []string{inputUser, inputSecret, errorUser, errorSecret} {
if strings.Contains(frame, literal) {
t.Fatalf("form frame %d exposed userinfo literal %q:\n%s", i, literal, frame)
}
}
}
assertUsableFrame(t, frames[len(frames)-1], 80, 24, "Protocol Probe failed for proxy.example:8080")
model = updateKey(model, "ctrl+s")
if len(module.intents) != 1 || module.intents[0].Profile.SingleURL != "http://"+inputUser+":"+inputSecret+"@proxy.example:8080" {
t.Fatalf("userinfo masking weakened saved Profile semantics: %#v", module.intents)
} }
assertUsableFrame(t, view, 80, 24, "Protocol Probe failed for proxy.example:8080")
} }