560 lines
18 KiB
Go
560 lines
18 KiB
Go
package tui
|
||
|
||
import (
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"os"
|
||
"regexp"
|
||
"strings"
|
||
"unicode/utf8"
|
||
|
||
"github.com/charmbracelet/bubbles/viewport"
|
||
"github.com/charmbracelet/lipgloss"
|
||
|
||
"proxyctl/internal/app"
|
||
"proxyctl/internal/profile"
|
||
)
|
||
|
||
const (
|
||
minimumWidth = 60
|
||
minimumHeight = 16
|
||
wideWidth = 100
|
||
wideHeight = 28
|
||
)
|
||
|
||
type themeMode uint8
|
||
|
||
const (
|
||
themeModeAuto themeMode = iota
|
||
themeModeDark
|
||
themeModeLight
|
||
themeModeNoColor
|
||
)
|
||
|
||
type modelOptions struct {
|
||
theme themeMode
|
||
output io.Writer
|
||
}
|
||
|
||
// ModelOption configures terminal capabilities used by the complete TUI Model.
|
||
type ModelOption func(*modelOptions)
|
||
|
||
// WithOutput makes presentation capability detection use the same writer that
|
||
// Bubble Tea renders to. Production composition should pass tea.WithOutput's
|
||
// writer here as well.
|
||
func WithOutput(output io.Writer) ModelOption {
|
||
return func(options *modelOptions) { options.output = output }
|
||
}
|
||
|
||
func withTheme(mode themeMode) ModelOption {
|
||
return func(options *modelOptions) { options.theme = mode }
|
||
}
|
||
|
||
func defaultModelOptions() modelOptions {
|
||
return modelOptions{output: os.Stdout}
|
||
}
|
||
|
||
type presentationState struct {
|
||
snapshot app.Snapshot
|
||
page page
|
||
width int
|
||
height int
|
||
selected int
|
||
profileOffset int
|
||
profileDetails int
|
||
profileDelete *profileDeleteConfirmation
|
||
selectedTargetID string
|
||
dashboardFocus dashboardPane
|
||
showUnavailable bool
|
||
detailsOffset int
|
||
driftResolutionTargetID string
|
||
picker profilePickerState
|
||
form formState
|
||
help helpDialogState
|
||
confirmation *app.Confirmation
|
||
confirmationDialog *confirmationDialog
|
||
status string
|
||
operationStatus app.OperationStatus
|
||
currentError *operationError
|
||
errorExpanded bool
|
||
errorOffset int
|
||
exitDeferred bool
|
||
operationActive bool
|
||
operationProgress map[string]app.TargetProgressPhase
|
||
}
|
||
|
||
// terminalPresentation is the terminal-presentation Module. Application
|
||
// coordination supplies one immutable state and calls its single Render
|
||
// operation; responsive layout, theming, clipping, and final redaction stay
|
||
// inside the module.
|
||
type terminalPresentation struct {
|
||
theme semanticTheme
|
||
}
|
||
|
||
type terminalPage struct {
|
||
header string
|
||
primary string
|
||
context string
|
||
footer string
|
||
status string
|
||
statusExpanded bool
|
||
statusOffset int
|
||
focusLine int
|
||
primaryOffset int
|
||
contextOffset int
|
||
contextAnchor int
|
||
anchorContext bool
|
||
wrapPrimary bool
|
||
wrapContext bool
|
||
dashboard *dashboardRenderState
|
||
}
|
||
|
||
func (p terminalPresentation) Render(state presentationState) string {
|
||
// Application snapshots, adapter diagnostics, operation updates, and form
|
||
// input are all untrusted presentation data. Strip their controls before any
|
||
// semanticTheme style is applied; otherwise an attacker could inject one of
|
||
// the same allowlisted SGR sequences that the theme legitimately emits.
|
||
state = sanitizePresentationState(state)
|
||
width, height := state.width, state.height
|
||
if width <= 0 {
|
||
width = 80
|
||
}
|
||
if height <= 0 {
|
||
height = 24
|
||
}
|
||
|
||
if width < minimumWidth || height < minimumHeight {
|
||
content := profile.RedactText(p.minimumSizeView(width, height))
|
||
return strings.TrimRight(viewportContent(content, width, height, 0), " \n") + "\n"
|
||
}
|
||
|
||
var page terminalPage
|
||
switch state.page {
|
||
case profilesPage:
|
||
page = p.profilesView(state)
|
||
case formPage:
|
||
page = p.formView(state)
|
||
default:
|
||
page = p.mainView(state, width, height)
|
||
}
|
||
if state.help.top(state.confirmation) {
|
||
help := p.helpDialogView(state)
|
||
// Retain the underlying page header so Help is visibly contextual and
|
||
// modal rather than a replacement navigation destination.
|
||
page.primary = help.primary
|
||
page.context = ""
|
||
page.footer = help.footer
|
||
page.focusLine = help.focusLine
|
||
page.primaryOffset = help.primaryOffset
|
||
page.contextOffset = 0
|
||
page.dashboard = nil
|
||
}
|
||
page.status = renderStatus(state)
|
||
page.statusExpanded = state.errorExpanded && state.currentError != nil
|
||
page.statusOffset = state.errorOffset
|
||
page = redactTerminalPage(page)
|
||
// Untrusted values were redacted and stripped of controls before semantic
|
||
// styling. The final pass therefore removes unexpected terminal controls
|
||
// without reparsing clipped, composed prose as if it were a URL.
|
||
return sanitizeStyledText(p.renderShell(page, width, height))
|
||
}
|
||
|
||
func renderStatus(state presentationState) string {
|
||
var lines []string
|
||
if state.exitDeferred {
|
||
lines = append(lines, "Exit requested — waiting for the current mutation to finish safely")
|
||
} else if state.operationStatus.Summary != "" {
|
||
lines = append(lines, state.operationStatus.Summary)
|
||
} else if state.status != "" {
|
||
lines = append(lines, state.status)
|
||
}
|
||
if state.currentError != nil {
|
||
errorText := "Current error: " + state.currentError.status.Summary
|
||
if state.errorExpanded && state.currentError.detail != "" {
|
||
errorText += "\n" + state.currentError.detail
|
||
}
|
||
lines = append(lines, errorText)
|
||
}
|
||
return strings.Join(lines, "\n")
|
||
}
|
||
|
||
func redactTerminalPage(page terminalPage) terminalPage {
|
||
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(value)
|
||
}
|
||
|
||
func sanitizeUntrustedControls(value string) string {
|
||
return sanitizeTerminalText(value, false)
|
||
}
|
||
|
||
func sanitizeUntrustedText(value string) string {
|
||
return sanitizeUntrustedControls(profile.RedactText(value))
|
||
}
|
||
|
||
func sanitizeUntrustedUserinfoText(value string) string {
|
||
return sanitizeUntrustedControls(profile.RedactUserinfoText(value))
|
||
}
|
||
|
||
func sanitizePresentationState(state presentationState) presentationState {
|
||
state.snapshot.ActiveProfile = sanitizeUntrustedText(state.snapshot.ActiveProfile)
|
||
state.snapshot.Profiles = append([]profile.Profile(nil), state.snapshot.Profiles...)
|
||
for i := range state.snapshot.Profiles {
|
||
state.snapshot.Profiles[i] = sanitizePresentationProfile(state.snapshot.Profiles[i])
|
||
}
|
||
state.snapshot.Targets = append([]app.TargetSnapshot(nil), state.snapshot.Targets...)
|
||
for i := range state.snapshot.Targets {
|
||
item := &state.snapshot.Targets[i]
|
||
item.Description.Name = sanitizeUntrustedText(item.Description.Name)
|
||
item.Description.Summary = sanitizeUntrustedText(item.Description.Summary)
|
||
item.Description.Scope = sanitizeUntrustedText(item.Description.Scope)
|
||
item.Description.Permissions = sanitizeUntrustedText(item.Description.Permissions)
|
||
item.Description.Ownership = sanitizeUntrustedText(item.Description.Ownership)
|
||
item.Description.RestartRisk = sanitizeUntrustedText(item.Description.RestartRisk)
|
||
item.Description.Details = sanitizeUntrustedText(item.Description.Details)
|
||
item.AvailabilityEvidence = sanitizeUntrustedText(item.AvailabilityEvidence)
|
||
item.Evidence = sanitizeUntrustedText(item.Evidence)
|
||
item.DetectionErr = sanitizePresentationError(item.DetectionErr)
|
||
item.PrimaryAction.DisabledReason = sanitizeUntrustedText(item.PrimaryAction.DisabledReason)
|
||
item.DriftResolutionActions = append([]app.TargetAction(nil), item.DriftResolutionActions...)
|
||
for action := range item.DriftResolutionActions {
|
||
item.DriftResolutionActions[action].DisabledReason = sanitizeUntrustedText(item.DriftResolutionActions[action].DisabledReason)
|
||
}
|
||
}
|
||
|
||
sanitizeStatus := sanitizeUntrustedText
|
||
if state.page == formPage {
|
||
// Profile editing treats the identity as private as well as the password.
|
||
// Apply that stronger policy to every status/error ingress before styling.
|
||
sanitizeStatus = sanitizeUntrustedUserinfoText
|
||
}
|
||
state.status = sanitizeStatus(state.status)
|
||
state.operationStatus.Summary = sanitizeStatus(state.operationStatus.Summary)
|
||
if state.currentError != nil {
|
||
current := *state.currentError
|
||
current.status.Summary = sanitizeStatus(current.status.Summary)
|
||
current.detail = sanitizeStatus(current.detail)
|
||
state.currentError = ¤t
|
||
}
|
||
if state.confirmation != nil {
|
||
confirmation := sanitizePresentationConfirmation(*state.confirmation)
|
||
state.confirmation = &confirmation
|
||
}
|
||
if state.confirmationDialog != nil {
|
||
dialog := *state.confirmationDialog
|
||
dialog.request = sanitizePresentationConfirmation(dialog.request)
|
||
state.confirmationDialog = &dialog
|
||
}
|
||
if state.profileDelete != nil {
|
||
confirmation := *state.profileDelete
|
||
confirmation.name = sanitizeUntrustedText(confirmation.name)
|
||
confirmation.dialog.request = sanitizePresentationConfirmation(confirmation.dialog.request)
|
||
state.profileDelete = &confirmation
|
||
}
|
||
|
||
state.picker.selectedName = sanitizeUntrustedText(state.picker.selectedName)
|
||
searchPosition := state.picker.search.Position()
|
||
state.picker.search.SetValue(sanitizeUntrustedText(state.picker.search.Value()))
|
||
state.picker.search.SetCursor(searchPosition)
|
||
state.form = sanitizePresentationForm(state.form)
|
||
return state
|
||
}
|
||
|
||
func sanitizePresentationProfile(value profile.Profile) profile.Profile {
|
||
value.Name = sanitizeUntrustedText(value.Name)
|
||
value.SingleURL = sanitizeUntrustedText(value.SingleURL)
|
||
value.HTTPURL = sanitizeUntrustedText(value.HTTPURL)
|
||
value.HTTPSURL = sanitizeUntrustedText(value.HTTPSURL)
|
||
value.AllURL = sanitizeUntrustedText(value.AllURL)
|
||
value.Bypass = append([]string(nil), value.Bypass...)
|
||
for i := range value.Bypass {
|
||
value.Bypass[i] = sanitizeUntrustedText(value.Bypass[i])
|
||
}
|
||
return value
|
||
}
|
||
|
||
func sanitizePresentationForm(form formState) formState {
|
||
// Preserve credential byte/rune positions in the copied editor state so
|
||
// textinput cursor and horizontal viewport calculations stay truthful.
|
||
// renderedInput masks userinfo before clipping, and the final form boundary
|
||
// redacts it again; this pass only removes terminal controls before styling.
|
||
form.originalName = sanitizeUntrustedControls(form.originalName)
|
||
form.profile = sanitizePresentationFormProfile(form.profile)
|
||
form.inputs = append([]formInput(nil), form.inputs...)
|
||
for i := range form.inputs {
|
||
form.inputs[i].label = sanitizeUntrustedText(form.inputs[i].label)
|
||
position := form.inputs[i].input.Position()
|
||
form.inputs[i].input.SetValue(sanitizeUntrustedControls(form.inputs[i].input.Value()))
|
||
form.inputs[i].input.SetCursor(position)
|
||
}
|
||
form.probe.address = sanitizeUntrustedText(form.probe.address)
|
||
form.probe.feedback = sanitizeUntrustedText(form.probe.feedback)
|
||
form.probe.sourceValue = sanitizeUntrustedText(form.probe.sourceValue)
|
||
form.refreshValidation()
|
||
return form
|
||
}
|
||
|
||
func sanitizePresentationFormProfile(value profile.Profile) profile.Profile {
|
||
value.Name = sanitizeUntrustedControls(value.Name)
|
||
value.SingleURL = sanitizeUntrustedControls(value.SingleURL)
|
||
value.HTTPURL = sanitizeUntrustedControls(value.HTTPURL)
|
||
value.HTTPSURL = sanitizeUntrustedControls(value.HTTPSURL)
|
||
value.AllURL = sanitizeUntrustedControls(value.AllURL)
|
||
value.Bypass = append([]string(nil), value.Bypass...)
|
||
for i := range value.Bypass {
|
||
value.Bypass[i] = sanitizeUntrustedControls(value.Bypass[i])
|
||
}
|
||
return value
|
||
}
|
||
|
||
func sanitizePresentationConfirmation(value app.Confirmation) app.Confirmation {
|
||
value.Title = sanitizeUntrustedText(value.Title)
|
||
value.Summary = sanitizeUntrustedText(value.Summary)
|
||
value.ConfirmLabel = sanitizeUntrustedText(value.ConfirmLabel)
|
||
value.CancelLabel = sanitizeUntrustedText(value.CancelLabel)
|
||
value.Details = append([]string(nil), value.Details...)
|
||
for i := range value.Details {
|
||
value.Details[i] = sanitizeUntrustedText(value.Details[i])
|
||
}
|
||
return value
|
||
}
|
||
|
||
func sanitizePresentationError(err error) error {
|
||
if err == nil {
|
||
return nil
|
||
}
|
||
return errors.New(sanitizeUntrustedText(err.Error()))
|
||
}
|
||
|
||
var safeSGRPattern = regexp.MustCompile(`^(?:0|(?:(?:1;)?(?:4;)?38;5;(?:25|34|75|160|172|235|242|245|252)))$`)
|
||
|
||
// sanitizeStyledText is the final defense after semantic styling. Untrusted
|
||
// values have already passed sanitizeUntrustedText, so the only SGR retained
|
||
// here is output generated by semanticTheme.
|
||
func sanitizeStyledText(value string) string { return sanitizeTerminalText(value, true) }
|
||
|
||
func sanitizeTerminalText(value string, allowThemeSGR bool) 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 allowThemeSGR && 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 = skipTerminalString(value, i+2)
|
||
continue
|
||
}
|
||
i++
|
||
continue
|
||
}
|
||
if value[i] == 0x9b {
|
||
end := i + 1
|
||
for end < len(value) && (value[end] < 0x40 || value[end] > 0x7e) {
|
||
end++
|
||
}
|
||
if end < len(value) {
|
||
i = end + 1
|
||
} else {
|
||
i = len(value)
|
||
}
|
||
continue
|
||
}
|
||
if value[i] == 0x9d {
|
||
i = skipTerminalString(value, i+1)
|
||
continue
|
||
}
|
||
switch value[i] {
|
||
case '\n':
|
||
safe.WriteByte('\n')
|
||
case '\t':
|
||
safe.WriteString(" ")
|
||
default:
|
||
if value[i] >= utf8.RuneSelf {
|
||
r, size := utf8.DecodeRuneInString(value[i:])
|
||
if r != utf8.RuneError || size > 1 {
|
||
if r != '\ufeff' && !(r >= 0x80 && r <= 0x9f) && !(r >= 0x202a && r <= 0x202e) && !(r >= 0x2066 && r <= 0x2069) {
|
||
safe.WriteString(value[i : i+size])
|
||
}
|
||
i += size
|
||
continue
|
||
}
|
||
} else if value[i] >= 0x20 && value[i] != 0x7f {
|
||
safe.WriteByte(value[i])
|
||
}
|
||
}
|
||
i++
|
||
}
|
||
|
||
var visible strings.Builder
|
||
for _, r := range safe.String() {
|
||
if r == '\ufeff' || (r >= 0x80 && r <= 0x9f) || (r >= 0x202a && r <= 0x202e) || (r >= 0x2066 && r <= 0x2069) {
|
||
continue
|
||
}
|
||
visible.WriteRune(r)
|
||
}
|
||
return visible.String()
|
||
}
|
||
|
||
func skipTerminalString(value string, i int) int {
|
||
for i < len(value) {
|
||
if value[i] == '\a' || value[i] == 0x9c {
|
||
return i + 1
|
||
}
|
||
if value[i] == '\x1b' && i+1 < len(value) && value[i+1] == '\\' {
|
||
return i + 2
|
||
}
|
||
i++
|
||
}
|
||
return i
|
||
}
|
||
|
||
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)
|
||
|
||
headerHeight := lineCount(header)
|
||
footerHeight := lineCount(footer)
|
||
baseSeparators := 0
|
||
if header != "" {
|
||
baseSeparators++
|
||
}
|
||
if footer != "" || page.status != "" {
|
||
baseSeparators++
|
||
}
|
||
|
||
status := ""
|
||
if page.status != "" {
|
||
wrapped := lipgloss.NewStyle().Width(width).Render(p.theme.status.Render("Status: " + page.status))
|
||
statusHeight := min(2, lineCount(wrapped))
|
||
statusOffset := 0
|
||
if page.statusExpanded {
|
||
statusHeight = min(lineCount(wrapped), max(3, height/2))
|
||
// The compact dashboard needs ten rows to retain both panes. Bound
|
||
// expanded errors to the remaining terminal budget instead of
|
||
// allowing their viewport to push the frame beyond the terminal.
|
||
minimumBodyHeight := 1
|
||
if page.dashboard != nil {
|
||
minimumBodyHeight = 10
|
||
}
|
||
statusHeight = min(statusHeight, max(1, height-headerHeight-footerHeight-baseSeparators-minimumBodyHeight))
|
||
statusOffset = page.statusOffset
|
||
}
|
||
status = viewportContent(wrapped, width, statusHeight, statusOffset)
|
||
}
|
||
|
||
statusHeight := lineCount(status)
|
||
separators := baseSeparators
|
||
bodyHeight := height - headerHeight - footerHeight - statusHeight - separators
|
||
if bodyHeight < 1 {
|
||
bodyHeight = 1
|
||
}
|
||
body := renderPageBody(page, width, bodyHeight)
|
||
if page.dashboard != nil {
|
||
body = p.renderDashboard(*page.dashboard, width, bodyHeight)
|
||
}
|
||
|
||
sections := make([]string, 0, 3)
|
||
if header != "" {
|
||
sections = append(sections, header)
|
||
}
|
||
sections = append(sections, body)
|
||
bottom := joinNonEmpty(status, footer)
|
||
if bottom != "" {
|
||
sections = append(sections, bottom)
|
||
}
|
||
frame := strings.Join(sections, "\n\n")
|
||
return strings.TrimRight(frame, " \n") + "\n"
|
||
}
|
||
|
||
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(primaryContent, width, height, page.primaryOffset, page.focusLine)
|
||
}
|
||
|
||
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(primaryContent, width, primaryHeight, page.primaryOffset, page.focusLine)
|
||
contextFocus := -1
|
||
if page.anchorContext {
|
||
contextFocus = page.contextAnchor
|
||
}
|
||
context = viewportContentAt(context, width, contextHeight, page.contextOffset, contextFocus)
|
||
return primary + "\n\n" + context
|
||
}
|
||
|
||
func viewportContent(content string, width, height, focusLine int) string {
|
||
return viewportContentAt(content, width, height, 0, focusLine)
|
||
}
|
||
|
||
func viewportContentAt(content string, width, height, offset, focusLine int) string {
|
||
if content == "" || width <= 0 || height <= 0 {
|
||
return ""
|
||
}
|
||
view := viewport.New(width, height)
|
||
view.SetContent(strings.TrimRight(content, "\n"))
|
||
top := max(0, offset)
|
||
if focusLine >= 0 {
|
||
if focusLine < top {
|
||
top = focusLine
|
||
} else if focusLine >= top+height {
|
||
top = focusLine - height + 1
|
||
}
|
||
}
|
||
view.SetYOffset(top)
|
||
return view.View()
|
||
}
|
||
|
||
func lineCount(content string) int {
|
||
if content == "" {
|
||
return 0
|
||
}
|
||
return strings.Count(strings.TrimRight(content, "\n"), "\n") + 1
|
||
}
|
||
|
||
func joinNonEmpty(values ...string) string {
|
||
var present []string
|
||
for _, value := range values {
|
||
if value != "" {
|
||
present = append(present, value)
|
||
}
|
||
}
|
||
return strings.Join(present, "\n")
|
||
}
|
||
|
||
func (p terminalPresentation) minimumSizeView(width, height int) string {
|
||
return fmt.Sprintf("%s\n\nMinimum size: 60×16\nCurrent size: %d×%d\nResize the terminal to continue.",
|
||
p.theme.warning.Render("Terminal too small"), width, height)
|
||
}
|