pi-agent: Implement final TUI hardening
This commit is contained in:
@@ -60,7 +60,7 @@ func (p terminalPresentation) renderTargetListPane(state dashboardRenderState, w
|
||||
if selectedIndex >= bodyHeight {
|
||||
offset = selectedIndex - bodyHeight + 1
|
||||
}
|
||||
body := fixedViewport(strings.Join(lines, "\n"), width, bodyHeight, offset, false)
|
||||
body := fixedViewport(protectRenderedText(strings.Join(lines, "\n")), width, bodyHeight, offset, false)
|
||||
return fitPane(heading+"\n"+paneRule(width)+"\n"+body, width, height)
|
||||
}
|
||||
|
||||
@@ -175,7 +175,7 @@ func (p terminalPresentation) targetDetails(item app.TargetSnapshot) string {
|
||||
if item.Description.Details != "" {
|
||||
fmt.Fprintf(&details, "\n\n%s", item.Description.Details)
|
||||
}
|
||||
return details.String()
|
||||
return protectRenderedText(details.String())
|
||||
}
|
||||
|
||||
func (p terminalPresentation) stateCountSummary(snapshot app.Snapshot) string {
|
||||
|
||||
@@ -57,7 +57,7 @@ func (m Model) dashboardQuitAvailable() bool {
|
||||
}
|
||||
|
||||
func (p terminalPresentation) helpDialogView(model presentationState) terminalPage {
|
||||
content := lipgloss.NewStyle().Width(max(1, model.width)).Render(p.contextualHelp(model))
|
||||
content := lipgloss.NewStyle().Width(max(1, model.width)).Render(protectRenderedText(p.contextualHelp(model)))
|
||||
exitHint := "Ctrl+C: exit"
|
||||
if model.operationActive {
|
||||
exitHint = "Ctrl+C: defer safe exit"
|
||||
|
||||
+13
-4
@@ -51,6 +51,8 @@ func (p terminalPresentation) mainView(model presentationState, width, height in
|
||||
if selected, ok := newTargetCatalog(model.snapshot, true, model.driftResolutionTargetID).selected(model.driftResolutionTargetID); ok {
|
||||
page.dashboard = nil
|
||||
page.primary, page.context = p.driftResolutionView(selected)
|
||||
page.wrapPrimary = true
|
||||
page.wrapContext = true
|
||||
page.footer = "r: Reapply Active Profile o: Restore Original\nEnter/q/Esc: cancel ?: help"
|
||||
}
|
||||
}
|
||||
@@ -61,6 +63,7 @@ func (p terminalPresentation) mainView(model presentationState, width, height in
|
||||
}
|
||||
page.dashboard = nil
|
||||
page.primary = dialog.view()
|
||||
page.wrapPrimary = true
|
||||
page.context = "Cancellation is selected by default."
|
||||
page.footer = "←/→/Tab: choose y: confirm Enter: choose\nn/q/Esc: cancel ?: help"
|
||||
}
|
||||
@@ -174,10 +177,11 @@ func (p terminalPresentation) profilesView(model presentationState) terminalPage
|
||||
}
|
||||
if model.profileDelete != nil {
|
||||
return terminalPage{
|
||||
header: p.theme.title.Render("Proxy Profile management"),
|
||||
primary: model.profileDelete.dialog.view(),
|
||||
footer: "←/→/Tab: choose Enter: choose y: delete\nn/q/Esc: cancel ?: help",
|
||||
focusLine: -1,
|
||||
header: p.theme.title.Render("Proxy Profile management"),
|
||||
primary: model.profileDelete.dialog.view(),
|
||||
wrapPrimary: true,
|
||||
footer: "←/→/Tab: choose Enter: choose y: delete\nn/q/Esc: cancel ?: help",
|
||||
focusLine: -1,
|
||||
}
|
||||
}
|
||||
return terminalPage{
|
||||
@@ -188,6 +192,7 @@ func (p terminalPresentation) profilesView(model presentationState) terminalPage
|
||||
focusLine: model.selected,
|
||||
primaryOffset: model.profileOffset,
|
||||
contextOffset: model.profileDetails,
|
||||
wrapContext: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,6 +218,10 @@ func selectedProfileDetails(p terminalPresentation, model presentationState) str
|
||||
fmt.Fprintf(&details, "\n • %s", item)
|
||||
}
|
||||
}
|
||||
// The list gives short names the strongest scan position. Repeating the
|
||||
// complete value at the end of the scrollable details makes a clipped,
|
||||
// wide Unicode name fully reachable without displacing connection details.
|
||||
fmt.Fprintf(&details, "\nFull name: %s", selected.Name)
|
||||
return details.String()
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
@@ -102,6 +103,8 @@ type terminalPage struct {
|
||||
contextOffset int
|
||||
contextAnchor int
|
||||
anchorContext bool
|
||||
wrapPrimary bool
|
||||
wrapContext bool
|
||||
dashboard *dashboardRenderState
|
||||
}
|
||||
|
||||
@@ -150,9 +153,9 @@ func (p terminalPresentation) Render(state presentationState) string {
|
||||
// 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 sanitizeStyledText(profile.RedactUserinfoText(frame))
|
||||
}
|
||||
return profile.RedactText(frame)
|
||||
return sanitizeStyledText(profile.RedactText(frame))
|
||||
}
|
||||
|
||||
func renderStatus(state presentationState) string {
|
||||
@@ -175,14 +178,84 @@ func renderStatus(state presentationState) string {
|
||||
}
|
||||
|
||||
func redactTerminalPage(page terminalPage) terminalPage {
|
||||
page.header = profile.RedactText(page.header)
|
||||
page.primary = profile.RedactText(page.primary)
|
||||
page.context = profile.RedactText(page.context)
|
||||
page.footer = profile.RedactText(page.footer)
|
||||
page.status = profile.RedactText(page.status)
|
||||
page.header = protectRenderedText(page.header)
|
||||
page.primary = protectRenderedText(page.primary)
|
||||
page.context = protectRenderedText(page.context)
|
||||
page.footer = protectRenderedText(page.footer)
|
||||
page.status = protectRenderedText(page.status)
|
||||
return page
|
||||
}
|
||||
|
||||
func protectRenderedText(value string) string {
|
||||
return sanitizeStyledText(profile.RedactText(value))
|
||||
}
|
||||
|
||||
var safeSGRPattern = regexp.MustCompile(`^(?:0|(?:(?:1;)?(?:4;)?38;5;(?:25|34|75|160|172|235|242|245|252)))$`)
|
||||
|
||||
// sanitizeStyledText removes terminal-control injection while retaining only
|
||||
// the small SGR palette emitted by semanticTheme. OSC title/hyperlink commands,
|
||||
// cursor movement, concealment, carriage returns, and private C1 controls are
|
||||
// never meaningful application text and must not reach a rendered frame.
|
||||
func sanitizeStyledText(value string) string {
|
||||
var safe strings.Builder
|
||||
for i := 0; i < len(value); {
|
||||
if value[i] == '\x1b' {
|
||||
if i+1 < len(value) && value[i+1] == '[' {
|
||||
end := i + 2
|
||||
for end < len(value) && (value[end] < 0x40 || value[end] > 0x7e) {
|
||||
end++
|
||||
}
|
||||
if end < len(value) {
|
||||
if value[end] == 'm' && safeSGRPattern.MatchString(value[i+2:end]) {
|
||||
safe.WriteString(value[i : end+1])
|
||||
}
|
||||
i = end + 1
|
||||
continue
|
||||
}
|
||||
i = len(value)
|
||||
continue
|
||||
}
|
||||
if i+1 < len(value) && value[i+1] == ']' {
|
||||
i += 2
|
||||
for i < len(value) {
|
||||
if value[i] == '\a' {
|
||||
i++
|
||||
break
|
||||
}
|
||||
if value[i] == '\x1b' && i+1 < len(value) && value[i+1] == '\\' {
|
||||
i += 2
|
||||
break
|
||||
}
|
||||
i++
|
||||
}
|
||||
continue
|
||||
}
|
||||
i++
|
||||
continue
|
||||
}
|
||||
switch value[i] {
|
||||
case '\n':
|
||||
safe.WriteByte('\n')
|
||||
case '\t':
|
||||
safe.WriteString(" ")
|
||||
default:
|
||||
if value[i] >= 0x20 && value[i] != 0x7f {
|
||||
safe.WriteByte(value[i])
|
||||
}
|
||||
}
|
||||
i++
|
||||
}
|
||||
|
||||
var visible strings.Builder
|
||||
for _, r := range safe.String() {
|
||||
if (r >= 0x80 && r <= 0x9f) || (r >= 0x202a && r <= 0x202e) || (r >= 0x2066 && r <= 0x2069) {
|
||||
continue
|
||||
}
|
||||
visible.WriteRune(r)
|
||||
}
|
||||
return visible.String()
|
||||
}
|
||||
|
||||
func (p terminalPresentation) renderShell(page terminalPage, width, height int) string {
|
||||
header := viewportContent(page.header, width, lineCount(page.header), 0)
|
||||
footer := viewportContent(page.footer, width, lineCount(page.footer), 0)
|
||||
@@ -232,22 +305,30 @@ func (p terminalPresentation) renderShell(page terminalPage, width, height int)
|
||||
}
|
||||
|
||||
func renderPageBody(page terminalPage, width, height int) string {
|
||||
primaryContent := page.primary
|
||||
if page.wrapPrimary {
|
||||
primaryContent = lipgloss.NewStyle().Width(width).Render(primaryContent)
|
||||
}
|
||||
if page.context == "" || height < 5 {
|
||||
return viewportContentAt(page.primary, width, height, page.primaryOffset, page.focusLine)
|
||||
return viewportContentAt(primaryContent, width, height, page.primaryOffset, page.focusLine)
|
||||
}
|
||||
|
||||
contextHeight := min(lineCount(page.context), max(3, height/2))
|
||||
context := page.context
|
||||
if page.wrapContext {
|
||||
context = lipgloss.NewStyle().Width(width).Render(context)
|
||||
}
|
||||
contextHeight := min(lineCount(context), max(3, height/2))
|
||||
primaryHeight := height - contextHeight - 1
|
||||
if primaryHeight < 1 {
|
||||
primaryHeight = 1
|
||||
contextHeight = max(1, height-primaryHeight-1)
|
||||
}
|
||||
primary := viewportContentAt(page.primary, width, primaryHeight, page.primaryOffset, page.focusLine)
|
||||
primary := viewportContentAt(primaryContent, width, primaryHeight, page.primaryOffset, page.focusLine)
|
||||
contextFocus := -1
|
||||
if page.anchorContext {
|
||||
contextFocus = page.contextAnchor
|
||||
}
|
||||
context := viewportContentAt(page.context, width, contextHeight, page.contextOffset, contextFocus)
|
||||
context = viewportContentAt(context, width, contextHeight, page.contextOffset, contextFocus)
|
||||
return primary + "\n\n" + context
|
||||
}
|
||||
|
||||
|
||||
@@ -148,6 +148,9 @@ func profileIndex(items []profile.Profile, name string) int {
|
||||
func (p profilePickerState) view(snapshot app.Snapshot, width, height int) string {
|
||||
search := p.search
|
||||
search.Width = max(12, width-len(search.Prompt)-4)
|
||||
position := search.Position()
|
||||
search.SetValue(profile.ObscureURLForEditing(search.Value()))
|
||||
search.SetCursor(position)
|
||||
|
||||
items := p.filtered(snapshot)
|
||||
lines := make([]string, 0, len(items))
|
||||
@@ -178,7 +181,7 @@ func (p profilePickerState) view(snapshot app.Snapshot, width, height int) strin
|
||||
|
||||
listHeight := max(2, height-5)
|
||||
list := viewport.New(max(1, width), listHeight)
|
||||
list.SetContent(strings.Join(lines, "\n"))
|
||||
list.SetContent(protectRenderedText(strings.Join(lines, "\n")))
|
||||
if selectedIndex >= listHeight {
|
||||
list.SetYOffset(selectedIndex - listHeight + 1)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"proxyctl/internal/app"
|
||||
"proxyctl/internal/probe"
|
||||
"proxyctl/internal/profile"
|
||||
"proxyctl/internal/target"
|
||||
)
|
||||
|
||||
// These tests deliberately drive the complete production Model. They verify
|
||||
// user-visible rendering and accepted input, not page/component internals.
|
||||
func TestCompleteModelFixedSizeCoverageAcrossCompletedWorkflows(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(t *testing.T, width, height int) Model
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "startup",
|
||||
setup: func(t *testing.T, width, height int) Model {
|
||||
snapshot := hardeningSnapshot()
|
||||
for i := range snapshot.Targets {
|
||||
snapshot.Targets[i].DetectionPhase = app.TargetChecking
|
||||
}
|
||||
return resizedModel(t, &recordingModule{snapshot: snapshot}, themeModeNoColor, width, height)
|
||||
},
|
||||
want: []string{"Operations Console", "Checking", "Target availability"},
|
||||
},
|
||||
{
|
||||
name: "refresh",
|
||||
setup: func(t *testing.T, width, height int) Model {
|
||||
model := resizedModel(t, &recordingModule{snapshot: hardeningSnapshot()}, themeModeNoColor, width, height)
|
||||
updated, command := model.Update(keyMessage("r"))
|
||||
model = updated.(Model)
|
||||
if command == nil {
|
||||
t.Fatal("refresh did not start")
|
||||
}
|
||||
return model
|
||||
},
|
||||
want: []string{"Operations Console", "Checking Target availability and state"},
|
||||
},
|
||||
{
|
||||
name: "Target action",
|
||||
setup: func(t *testing.T, width, height int) Model {
|
||||
model := resizedModel(t, &recordingModule{snapshot: hardeningSnapshot()}, themeModeNoColor, width, height)
|
||||
updated, command := model.Update(tea.KeyMsg{Type: tea.KeySpace})
|
||||
model = updated.(Model)
|
||||
if command == nil {
|
||||
t.Fatal("Target action did not start")
|
||||
}
|
||||
return model
|
||||
},
|
||||
want: []string{"Applying", "Starting Target mutation"},
|
||||
},
|
||||
{
|
||||
name: "Active Profile switching",
|
||||
setup: func(t *testing.T, width, height int) Model {
|
||||
model := resizedModel(t, &recordingModule{snapshot: hardeningSnapshot()}, themeModeNoColor, width, height)
|
||||
return updateKey(model, "p")
|
||||
},
|
||||
want: []string{"Switch Active Profile", "Search:", "remote"},
|
||||
},
|
||||
{
|
||||
name: "management",
|
||||
setup: func(t *testing.T, width, height int) Model {
|
||||
model := resizedModel(t, &recordingModule{snapshot: hardeningSnapshot()}, themeModeNoColor, width, height)
|
||||
return updateKey(model, "m")
|
||||
},
|
||||
want: []string{"Proxy Profile management", "Selected Profile", "n: create"},
|
||||
},
|
||||
{
|
||||
name: "editor",
|
||||
setup: func(t *testing.T, width, height int) Model {
|
||||
model := resizedModel(t, &recordingModule{snapshot: hardeningSnapshot()}, themeModeNoColor, width, height)
|
||||
model = updateKey(model, "m")
|
||||
return updateKey(model, "e")
|
||||
},
|
||||
want: []string{"Edit Proxy Profile", "[focused]", "Ctrl+S: save"},
|
||||
},
|
||||
{
|
||||
name: "Protocol Probe",
|
||||
setup: func(t *testing.T, width, height int) Model {
|
||||
model := resizedModel(t, &recordingModule{snapshot: hardeningSnapshot()}, themeModeNoColor, width, height)
|
||||
model.detect = func(_ context.Context, _ string) (probe.Capabilities, error) {
|
||||
return probe.Capabilities{HTTP: true}, nil
|
||||
}
|
||||
model = updateKey(model, "m")
|
||||
model = updateKey(model, "e")
|
||||
model = updateKey(model, "tab")
|
||||
updated, command := model.Update(tea.KeyMsg{Type: tea.KeyCtrlD})
|
||||
model = updated.(Model)
|
||||
if command == nil {
|
||||
t.Fatal("Protocol Probe did not start")
|
||||
}
|
||||
return model
|
||||
},
|
||||
want: []string{"Protocol Probe", "checking proxy.example:8080", "in progress"},
|
||||
},
|
||||
{
|
||||
name: "Help",
|
||||
setup: func(t *testing.T, width, height int) Model {
|
||||
model := resizedModel(t, &recordingModule{snapshot: hardeningSnapshot()}, themeModeNoColor, width, height)
|
||||
return updateKey(model, "?")
|
||||
},
|
||||
want: []string{"Contextual Help", "q/Esc: close Help"},
|
||||
},
|
||||
{
|
||||
name: "Drift Resolution",
|
||||
setup: func(t *testing.T, width, height int) Model {
|
||||
model := resizedModel(t, &recordingModule{snapshot: hardeningSnapshot()}, themeModeNoColor, width, height)
|
||||
model = updateKey(model, "down")
|
||||
return updateKey(model, " ")
|
||||
},
|
||||
want: []string{"Drift Resolution", "Reapply Active Profile", "Restore Original"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
for _, size := range []struct{ width, height int }{{120, 36}, {80, 24}} {
|
||||
model := test.setup(t, size.width, size.height)
|
||||
assertCellBoundedFrame(t, model.View(), size.width, size.height, test.want...)
|
||||
}
|
||||
|
||||
model := test.setup(t, 80, 24)
|
||||
model = resize(model, 59, 15)
|
||||
assertCellBoundedFrame(t, model.View(), 59, 15, "Terminal too small", "59×15")
|
||||
model = resize(model, 80, 24)
|
||||
assertCellBoundedFrame(t, model.View(), 80, 24, test.want...)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteModelLightDarkAndNoColorRetainEquivalentSemantics(t *testing.T) {
|
||||
views := make(map[themeMode]string)
|
||||
for _, mode := range []themeMode{themeModeDark, themeModeLight, themeModeNoColor} {
|
||||
module := &targetModule{
|
||||
snapshot: hardeningSnapshot(),
|
||||
result: app.Result{Snapshot: hardeningSnapshot(), Err: errors.New("runner failed https://alice:theme-secret@proxy.example:8080")},
|
||||
}
|
||||
model := resizedModel(t, module, mode, 80, 24)
|
||||
updated, command := model.Update(tea.KeyMsg{Type: tea.KeySpace})
|
||||
model = updated.(Model)
|
||||
model = updateKey(model, "tab")
|
||||
progressFrame := model.View()
|
||||
updated, _ = model.Update(command())
|
||||
model = updated.(Model)
|
||||
model = updateKey(model, "e")
|
||||
views[mode] = progressFrame + "--- operation completed ---\n" + model.View()
|
||||
for _, semantic := range []string{"[focused]", "Disabled", "Drifted", "Applying", "mutations disabled", "Current error:", "REDACTED", "Action: Enable"} {
|
||||
if !strings.Contains(views[mode], semantic) {
|
||||
t.Fatalf("theme %d lost %q:\n%s", mode, semantic, views[mode])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
plain := views[themeModeNoColor]
|
||||
for _, symbol := range []string{"● Enabled", "○ Disabled", "! Drifted", "▶ Target details [focused]"} {
|
||||
if !strings.Contains(plain, symbol) {
|
||||
t.Fatalf("standard Unicode/ASCII status strategy lost %q:\n%s", symbol, plain)
|
||||
}
|
||||
}
|
||||
for _, r := range plain {
|
||||
if (r >= '\ue000' && r <= '\uf8ff') || (r >= '\U000f0000' && r <= '\U000ffffd') || (r >= '\U00100000' && r <= '\U0010fffd') {
|
||||
t.Fatalf("rendering depends on a private-use/Nerd Font glyph %U", r)
|
||||
}
|
||||
}
|
||||
for _, mode := range []themeMode{themeModeDark, themeModeLight} {
|
||||
if got := ansiPattern.ReplaceAllString(views[mode], ""); got != plain {
|
||||
t.Fatalf("theme %d changed semantic layout\ncolored stripped:\n%s\nNO_COLOR:\n%s", mode, got, plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteModelBoundsUnicodeAndKeepsLongContentReachable(t *testing.T) {
|
||||
longName := strings.Repeat("東京界", 50) + " TARGET-END"
|
||||
longProfile := strings.Repeat("支店", 70) + " PROFILE-END"
|
||||
snapshot := hardeningSnapshot()
|
||||
snapshot.ActiveProfile = longProfile
|
||||
snapshot.Profiles[0].Name = longProfile
|
||||
snapshot.Targets[0].Description.Name = longName
|
||||
snapshot.Targets[0].Evidence = strings.Repeat("証拠 and wrapped evidence ", 80) + " EVIDENCE-END"
|
||||
snapshot.Targets[0].Description.Details = strings.Repeat("詳細 line ", 100) + " DETAILS-END"
|
||||
model := resizedModel(t, &recordingModule{snapshot: snapshot}, themeModeNoColor, 80, 24)
|
||||
|
||||
assertCellBoundedFrame(t, model.View(), 80, 24, "Disabled")
|
||||
model = updateKey(model, "tab")
|
||||
for range 120 {
|
||||
model = updateKey(model, "pgdown")
|
||||
}
|
||||
assertCellBoundedFrame(t, model.View(), 80, 24, "DETAILS-END")
|
||||
|
||||
model = updateKey(model, "m")
|
||||
for range 60 {
|
||||
model = updateKey(model, "pgdown")
|
||||
}
|
||||
assertCellBoundedFrame(t, model.View(), 80, 24, "PROFILE-END")
|
||||
|
||||
model = updateKey(model, "d")
|
||||
for _, size := range []struct{ width, height int }{{80, 24}, {120, 36}, {59, 15}, {80, 24}} {
|
||||
model = resize(model, size.width, size.height)
|
||||
if size.width < minimumWidth {
|
||||
assertCellBoundedFrame(t, model.View(), size.width, size.height, "Terminal too small")
|
||||
continue
|
||||
}
|
||||
assertCellBoundedFrame(t, model.View(), size.width, size.height, "Delete Proxy Profile", "PROFILE-END", "> Cancel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteModelResizeStressPreservesDialogFocusSelectionViewportInFlightAndDeferredExit(t *testing.T) {
|
||||
snapshot := hardeningSnapshot()
|
||||
snapshot.Targets[1].Description.Details = strings.Repeat("APT scrollable detail. ", 90) + " APT-VIEWPORT-END"
|
||||
updates := make(chan app.OperationUpdate, 2)
|
||||
module := &interactiveScriptModule{snapshot: snapshot, streams: []chan app.OperationUpdate{updates}}
|
||||
model := resizedModel(t, module, themeModeNoColor, 120, 36)
|
||||
|
||||
updated, command := model.Update(tea.KeyMsg{Type: tea.KeySpace})
|
||||
model = updated.(Model)
|
||||
updates <- app.RunningOperationUpdate(snapshot,
|
||||
app.OperationStatus{Summary: "Writing Shell configuration", TargetID: "shell"},
|
||||
[]app.TargetProgress{{TargetID: "shell", Phase: app.TargetProgressRunning}})
|
||||
model, command = applyOperationCommand(t, model, command)
|
||||
model = updateKey(model, "down")
|
||||
model = updateKey(model, "tab")
|
||||
for range 120 {
|
||||
model = updateKey(model, "pgdown")
|
||||
}
|
||||
model = updateKey(model, "?")
|
||||
model = updateKey(model, "end")
|
||||
updated, quit := model.Update(tea.KeyMsg{Type: tea.KeyCtrlC})
|
||||
model = updated.(Model)
|
||||
if quit != nil {
|
||||
t.Fatal("exit was not deferred during the in-flight mutation")
|
||||
}
|
||||
|
||||
for _, size := range []struct{ width, height int }{{80, 24}, {59, 15}, {120, 36}, {80, 24}} {
|
||||
model = resize(model, size.width, size.height)
|
||||
if size.width < minimumWidth {
|
||||
assertCellBoundedFrame(t, model.View(), size.width, size.height, "Terminal too small")
|
||||
continue
|
||||
}
|
||||
assertCellBoundedFrame(t, model.View(), size.width, size.height,
|
||||
"Global exit safety", "q/Esc: close Help", "Exit pending until safe completion")
|
||||
}
|
||||
|
||||
model = updateKey(model, "q")
|
||||
assertCellBoundedFrame(t, model.View(), 80, 24,
|
||||
"> ! Drifted APT", "▶ Target details [focused]", "APT-VIEWPORT-", "END",
|
||||
"Applying", "Exit requested", "new work disabled")
|
||||
|
||||
updates <- app.SucceededOperationUpdate(snapshot,
|
||||
app.OperationStatus{Summary: "Shell Target is now Enabled", TargetID: "shell"},
|
||||
[]app.TargetProgress{{TargetID: "shell", Phase: app.TargetProgressSucceeded}})
|
||||
close(updates)
|
||||
model, quit = applyOperationCommand(t, model, command)
|
||||
if quit == nil {
|
||||
t.Fatal("safe completion did not retain deferred exit intent")
|
||||
}
|
||||
if _, ok := quit().(tea.QuitMsg); !ok {
|
||||
t.Fatal("safe completion did not emit Bubble Tea quit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteModelRedactsAdversarialSecretsFromEveryRenderedIngress(t *testing.T) {
|
||||
const secret = "ticket11 secret"
|
||||
credential := "https://alice:" + secret + "@proxy.example:8080"
|
||||
hostile := "\x1b]2;hostile-title\x07\x1b[8m" + credential + "\r"
|
||||
snapshot := hardeningSnapshot()
|
||||
snapshot.ActiveProfile = hostile
|
||||
snapshot.Profiles[0] = profile.Profile{Name: credential, Mode: profile.SingleMode, SingleURL: credential, Bypass: []string{credential}}
|
||||
snapshot.Targets[0].ID = credential
|
||||
snapshot.Targets[0].Description = target.Description{
|
||||
Name: credential, Summary: credential, Details: credential, Scope: credential,
|
||||
Permissions: credential, Ownership: credential, RestartRisk: credential,
|
||||
}
|
||||
snapshot.Targets[0].Evidence = hostile
|
||||
snapshot.Targets[0].AvailabilityEvidence = hostile
|
||||
snapshot.Targets[0].PrimaryAction.DisabledReason = hostile
|
||||
updates := make(chan app.OperationUpdate, 3)
|
||||
confirmation := make(chan app.OperationUpdate, 1)
|
||||
module := &interactiveScriptModule{snapshot: snapshot, streams: []chan app.OperationUpdate{updates, confirmation}}
|
||||
model := resizedModel(t, module, themeModeNoColor, 120, 36)
|
||||
assertSecretAbsent(t, model.View(), secret)
|
||||
|
||||
model = updateKey(model, "?")
|
||||
assertSecretAbsent(t, model.View(), secret)
|
||||
model = updateKey(model, "q")
|
||||
model = updateKey(model, "m")
|
||||
assertSecretAbsent(t, model.View(), secret)
|
||||
model = updateKey(model, "e")
|
||||
assertSecretAbsent(t, model.View(), secret)
|
||||
model = updateKey(model, "q")
|
||||
model = updateKey(model, "q")
|
||||
|
||||
updated, command := model.Update(keyMessage("r"))
|
||||
model = updated.(Model)
|
||||
updates <- app.RunningOperationUpdate(snapshot,
|
||||
app.OperationStatus{Summary: "notice " + credential, TargetID: credential},
|
||||
[]app.TargetProgress{{TargetID: credential, Phase: app.TargetProgressRunning, Evidence: credential, Err: errors.New("runner output " + credential)}})
|
||||
model, command = applyOperationCommand(t, model, command)
|
||||
assertSecretAbsent(t, model.View(), secret)
|
||||
updates <- app.FailedOperationUpdate(snapshot,
|
||||
app.OperationStatus{Summary: "error " + credential, TargetID: credential},
|
||||
[]app.TargetProgress{{TargetID: credential, Phase: app.TargetProgressFailed, Evidence: credential, Err: errors.New("progress output " + credential)}},
|
||||
credential, errors.New("runner output "+credential))
|
||||
close(updates)
|
||||
model, _ = applyOperationCommand(t, model, command)
|
||||
model = updateKey(model, "e")
|
||||
assertSecretAbsent(t, model.View(), secret)
|
||||
|
||||
updated, command = model.Update(keyMessage("r"))
|
||||
model = updated.(Model)
|
||||
confirmation <- app.ConfirmationOperationUpdate(snapshot, app.Confirmation{
|
||||
Title: "confirm " + credential, Summary: credential, Details: []string{credential},
|
||||
ConfirmLabel: "continue " + credential, CancelLabel: "cancel " + credential,
|
||||
})
|
||||
model, _ = applyOperationCommand(t, model, command)
|
||||
assertSecretAbsent(t, model.View(), secret)
|
||||
}
|
||||
|
||||
func hardeningSnapshot() app.Snapshot {
|
||||
return app.Snapshot{
|
||||
ActiveProfile: "office",
|
||||
Profiles: []profile.Profile{
|
||||
{Name: "office", Mode: profile.SingleMode, SingleURL: "http://proxy.example:8080", Bypass: []string{"localhost", ".example.com"}},
|
||||
{Name: "remote", Mode: profile.SingleMode, SingleURL: "socks5h://remote.example:1080"},
|
||||
},
|
||||
Targets: []app.TargetSnapshot{
|
||||
{ID: "shell", Description: target.Description{Name: "Shell", Summary: "Shell summary", Details: "Shell details"}, Available: true, State: target.Disabled, Evidence: "fragment absent", PrimaryAction: app.TargetAction{Kind: app.EnableTargetAction, Available: true}},
|
||||
{ID: "apt", Description: target.Description{Name: "APT", Summary: "APT summary", Details: "APT details"}, Available: true, State: target.Drifted, Evidence: "managed values differ", PrimaryAction: app.TargetAction{Kind: app.ResolveDriftTargetAction, Available: true}, DriftResolutionActions: []app.TargetAction{{Kind: app.ReapplyActiveProfileAction, Available: true}, {Kind: app.RestoreOriginalAction, Available: true}}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func assertCellBoundedFrame(t *testing.T, view string, width, height int, semantics ...string) {
|
||||
t.Helper()
|
||||
plain := ansiPattern.ReplaceAllString(view, "")
|
||||
lines := strings.Split(strings.TrimSuffix(plain, "\n"), "\n")
|
||||
if len(lines) > height {
|
||||
t.Fatalf("frame has %d lines, terminal height is %d:\n%s", len(lines), height, view)
|
||||
}
|
||||
for _, line := range lines {
|
||||
if got := lipgloss.Width(line); got > width {
|
||||
t.Fatalf("frame line is %d cells, terminal width is %d: %q", got, width, line)
|
||||
}
|
||||
}
|
||||
for _, semantic := range semantics {
|
||||
if !strings.Contains(plain, semantic) {
|
||||
t.Fatalf("frame missing %q:\n%s", semantic, view)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertSecretAbsent(t *testing.T, frame, secret string) {
|
||||
t.Helper()
|
||||
if strings.Contains(frame, secret) {
|
||||
t.Fatalf("rendered frame exposed secret %q:\n%s", secret, frame)
|
||||
}
|
||||
if strings.ContainsAny(frame, "\x1b\x07\r") {
|
||||
t.Fatalf("rendered frame retained hostile terminal control bytes: %q", frame)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user