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
+8 -2
View File
@@ -158,8 +158,14 @@ func TestCompleteModelDefensivelyRedactsEveryRenderedPage(t *testing.T) {
if strings.Contains(frame, secret) {
t.Fatalf("frame %d exposed a credential:\n%s", i, frame)
}
if !strings.Contains(frame, redacted) {
t.Fatalf("frame %d did not apply the presentation redaction path:\n%s", i, frame)
if i < 3 {
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:
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 != "" {
body.WriteString("The Protocol Probe result remains attached to the generated Advanced Profile URL values.\n")
} else if model.form.hasProbeSourceAttachment() {
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:
body.WriteString(p.theme.section.Render("Dashboard"))
+33 -4
View File
@@ -1,6 +1,7 @@
package tui
import (
"context"
"fmt"
"strings"
"testing"
@@ -8,6 +9,7 @@ import (
tea "github.com/charmbracelet/bubbletea"
"proxyctl/internal/app"
"proxyctl/internal/probe"
"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") {
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")
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())
@@ -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) {
module := &profileManagementModule{}
model := openNewProfileEditor(t, module)
+9 -86
View File
@@ -2,9 +2,7 @@ package tui
import (
"context"
"errors"
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
@@ -47,14 +45,12 @@ type Model struct {
detect func(context.Context, string) (probe.Capabilities, error)
}
// probeDone carries one finished Protocol Probe back to the form. Endpoint and
// input text deliberately stay in formState: an asynchronous result cannot
// introduce userinfo into status, and formID plus generation reject stale work.
// probeDone carries only detector output and formState's opaque typed token.
// Source data and all validation/application rules remain owned by formState.
type probeDone struct {
caps probe.Capabilities
err error
formID uint64
generation uint64
caps probe.Capabilities
err error
token probeToken
}
type profileDeleteConfirmation struct {
@@ -177,57 +173,7 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
return m, waitForOperation(progress.updates)
}
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, 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
return m, m.form.applyProbeResult(done.token, done.caps, done.err)
}
key, ok := message.(tea.KeyMsg)
if !ok {
@@ -596,26 +542,14 @@ func (m Model) updateForm(key tea.KeyMsg) (tea.Model, tea.Cmd) {
m.form = formState{}
return m, nil
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
}
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
formID := m.form.id
generation := m.form.probe.generation
return m, func() tea.Msg {
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:
return m, m.form.toggleMode()
@@ -653,17 +587,6 @@ func (m *Model) applyProfileMutation(intent app.Intent, preferredName string) ap
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) {
if m.selectedProfileName != "" {
for i := range m.snapshot.Profiles {
-346
View File
@@ -1,15 +1,12 @@
package tui
import (
"context"
"errors"
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
"proxyctl/internal/app"
"proxyctl/internal/probe"
"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 {
msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(key)}
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)
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)
if item.field == profile.NameField {
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)
// Dashboard composition retains structured Target metadata until pane sizes
// are known. Redact the fully composed frame as the final presentation
// boundary as well, so no render path can bypass credential scrubbing.
return profile.RedactText(p.renderShell(page, width, height))
// boundary as well, so no render path can bypass credential scrubbing. Form
// 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 {
+15 -7
View File
@@ -55,26 +55,34 @@ func TestCompleteModelProfileEditorSupportsCursorEditingDeletionAndPaste(t *test
}
}
func TestCompleteModelProfileEditorHorizontallyScrollsLongSecretURLWithoutDisclosure(t *testing.T) {
func TestCompleteModelProfileEditorMasksEntireUserinfoWhilePreservingScrollAndSavedValue(t *testing.T) {
module := &profileManagementModule{}
model := openNewProfileEditor(t, module)
model = updateRunes(model, "office")
model = updateKey(model, "tab")
const username = "operator-private"
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)
end := model.View()
assertUsableFrame(t, end, 80, 24, "[focused]", "scroll.example:8443")
if strings.Contains(end, secret) || strings.Contains(end, "https://operator") {
t.Fatalf("end-scrolled input disclosed credentials or failed to scroll:\n%s", end)
for _, literal := range []string{username, secret} {
if strings.Contains(end, literal) {
t.Fatalf("end-scrolled input disclosed userinfo %q:\n%s", literal, end)
}
}
model = editorKey(model, tea.KeyHome)
start := model.View()
assertUsableFrame(t, start, 80, 24, "https://operator:")
if 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)
assertUsableFrame(t, start, 80, 24, "https://")
if strings.Contains(start, username) || strings.Contains(start, secret) || strings.Contains(start, "scroll.example:8443") {
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
import (
"errors"
"fmt"
"strings"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"proxyctl/internal/probe"
"proxyctl/internal/profile"
)
@@ -22,12 +24,19 @@ type formDefinition struct {
value string
}
type protocolProbeFieldState struct {
active bool
type probeToken struct {
formID uint64
generation uint64
address string
feedback string
field profile.Field
source profile.Field
}
type protocolProbeFieldState struct {
active bool
token probeToken
address string
feedback string
sourceValue string
sourceAttached bool
}
type formState struct {
@@ -39,6 +48,7 @@ type formState struct {
bypassPreviewLine int
validation profile.Validation
advancedInitialized bool
probeGeneration uint64
probe protocolProbeFieldState
}
@@ -136,7 +146,7 @@ func (f *formState) updateInput(key tea.KeyMsg) tea.Cmd {
f.bypassPreviewLine = 0
changedField := f.inputs[f.field].field
f.syncFocusedValue()
if changedField == profile.SingleURLField {
if isProxyURLField(changedField) {
f.invalidateProbe()
}
f.refreshValidation()
@@ -195,12 +205,106 @@ func (f formState) currentField() profile.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() {
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.address = ""
f.probe.feedback = ""
f.probe.field = ""
address, raw := f.probe.address, f.probe.sourceValue
switch {
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 {
@@ -210,12 +314,22 @@ func (f formState) probeText(field profile.Field) string {
}
return "Protocol Probe [Ctrl+D]: available"
}
if field == f.probe.field {
return f.probe.feedback
}
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) {
if f.currentField() != profile.BypassField {
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) {
tests := []struct {
name string
@@ -143,12 +186,12 @@ func TestCompleteModelProtocolProbePopulatesEstablishedCapabilityRepresentations
}
attachedField := "Single proxy URL"
if test.caps.HTTP && test.caps.SOCKS5 {
attachedField = "HTTP URL"
attachedField = "Single proxy URL probe [read-only]"
}
field := strings.Index(view, attachedField)
result := strings.Index(view, "Protocol Probe: detected")
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")
@@ -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) {
tests := []struct {
name string
@@ -338,33 +402,43 @@ func TestCompleteModelProtocolProbeSurvivesResizeWithoutRestartingOrDetaching(t
}
}
func TestCompleteModelProtocolProbeKeepsCredentialsOutOfDialStatusErrorsAndFrames(t *testing.T) {
func TestCompleteModelProtocolProbeKeepsAllUserinfoOutOfDialStatusErrorsAndEveryFormFrame(t *testing.T) {
const (
inputUser = "alice-private"
inputSecret = "input-secret"
errorUser = "mallory-private"
errorSecret = "error-secret"
)
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)
return probe.Capabilities{}, errors.New("failed https://mallory:" + errorSecret + "@evil.example:9999")
}, "alice:"+inputSecret+"@proxy.example:8080")
return probe.Capabilities{}, errors.New("failed https://" + errorUser + ":" + errorSecret + "@evil.example:9999")
}, inputUser+":"+inputSecret+"@proxy.example:8080")
frames := []string{model.View()}
model, command := startProtocolProbe(t, model)
progress := 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)
}
}
frames = append(frames, model.View())
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" {
t.Fatalf("probe dial evidence = %#v, want credential-free endpoint", dialed)
}
if strings.Contains(view, inputSecret) || strings.Contains(view, errorSecret) || strings.Contains(view, "mallory") {
t.Fatalf("probe frame exposed input or lower-layer credentials:\n%s", view)
for i, frame := range frames {
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")
}