Files
summercms/modules/bonfire/errors.go
Jakub Zych a9025c0d12 fix(bonfire): strip control characters from error blocks, process views and tables
- bonfire.Sanitize (additive), moved from diary's console channel: tab
  kept, CR and LF to a space, every other control character dropped
- applied before styling to every error-block line (message,
  explanation, Try lines, -v chain), process-view item names and errors,
  the current item of spinner and progress, failure lines, and table
  headers and cells in both layouts
- diary's console handler calls bonfire.Sanitize; README and the
  writing-commands page say so (WR-05)
2026-10-22 02:31:55 +02:00

391 lines
11 KiB
Go

package bonfire
import (
"errors"
"io"
"os"
"path/filepath"
"strings"
"unicode"
"git.golem15.com/golem15/summercms/modules/sunscreen"
"github.com/charmbracelet/colorprofile"
)
// UsageError marks a mistake in how a command was called: an unknown
// command or flag, or a wrong number of arguments. Execute returns ExitUsage
// for it.
type UsageError struct {
Err error
// command is the command that was called wrongly, when known; the
// error block suggests its help.
command string
}
func (e *UsageError) Error() string { return e.Err.Error() }
func (e *UsageError) Unwrap() error { return e.Err }
// Usage wraps err as a UsageError; nil stays nil.
func Usage(err error) error {
if err == nil {
return nil
}
var ue *UsageError
if errors.As(err, &ue) {
return err
}
return &UsageError{Err: err}
}
// isUsage reports a UsageError in the chain or cobra's unknown command error.
func isUsage(err error) bool {
var ue *UsageError
if errors.As(err, &ue) {
return true
}
return strings.HasPrefix(err.Error(), "unknown command")
}
// HintError adds an explanation and a command to try to an error. The error
// block prints both under the error message; {bin} in Try becomes summer or
// bin/<name>.
type HintError struct {
Err error
Explanation string
Try string
}
func (e *HintError) Error() string { return e.Err.Error() }
func (e *HintError) Unwrap() error { return e.Err }
// WithHint returns err with an explanation and a Try line (several lines are
// allowed); "{bin}" in try is replaced by how the binary is called. A nil err
// stays nil.
func WithHint(err error, explanation, try string) error {
if err == nil {
return nil
}
return &HintError{Err: err, Explanation: explanation, Try: try}
}
// ErrReported marks an error whose details the command already printed:
// the error block prints nothing and the exit code is 1.
var ErrReported = errors.New("bonfire: error already reported")
type reportedError struct {
err error
}
func (e *reportedError) Error() string { return e.err.Error() }
func (e *reportedError) Unwrap() error { return e.err }
func (e *reportedError) Is(target error) bool { return target == ErrReported }
// Reported returns an error that matches both ErrReported and err, for a
// command that has already printed its problems. A nil err gives
// ErrReported.
func Reported(err error) error {
if err == nil {
return ErrReported
}
return &reportedError{err: err}
}
// Sanitize removes control characters other than tab from s; carriage
// returns and line feeds become spaces, so a multi-line value stays on one
// line. Text from outside the operator's control (database or remote error
// text, names an administrator typed) then cannot move the cursor, clear
// the screen or set the window title. Error blocks, process views, failure
// lines and tables pass their text through it, and so does diary's console
// channel. A clean string is returned unchanged.
func Sanitize(s string) string {
clean := true
for _, r := range s {
if r != '\t' && unicode.IsControl(r) {
clean = false
break
}
}
if clean {
return s
}
var b strings.Builder
for _, r := range s {
switch {
case r == '\n' || r == '\r':
b.WriteByte(' ')
case r != '\t' && unicode.IsControl(r):
default:
b.WriteRune(r)
}
}
return b.String()
}
// sanitizeLines applies Sanitize to every line of s, keeping the line
// breaks.
func sanitizeLines(s string) string {
lines := strings.Split(s, "\n")
for i, line := range lines {
lines[i] = Sanitize(line)
}
return strings.Join(lines, "\n")
}
// exitCodeError carries the exit code Execute returns for an error.
type exitCodeError struct {
err error
code int
}
func (e *exitCodeError) Error() string { return e.err.Error() }
func (e *exitCodeError) Unwrap() error { return e.err }
// WithExitCode returns err carrying code as the process exit code: Execute
// returns code instead of ExitFailure or ExitUsage. A code outside 1 to 255
// gives ExitFailure. A nil err stays nil. Wrap it with Reported when the
// error was already printed, as summer does with the exit code of an app
// binary it ran.
func WithExitCode(err error, code int) error {
if err == nil {
return nil
}
return &exitCodeError{err: err, code: code}
}
// RenderError writes err to w as the console error block: the ERROR badge
// and the message, the explanation and the Try lines of a HintError ({bin}
// replaced by summer or bin/<name>, for the root named name), then "Run with
// -v for details." at verbosity 0 or the wrapped error chain under "Caused
// by:" at -v and above. cobra's unknown command error becomes `Command "x"
// is not defined.` with its suggestions as Try lines, and other usage errors
// suggest the command's help. Every message passes through sunscreen.Scrub,
// and every line through Sanitize.
// Errors wrapped by Reported print nothing.
func RenderError(w io.Writer, name string, err error, g Globals) {
if err == nil || w == nil || errors.Is(err, ErrReported) {
return
}
if cw, ok := w.(*colorprofile.Writer); ok {
w = cw.Forward
}
bin := usageName(name)
p := DefaultPalette()
message := err.Error()
explanation, try := "", ""
var hint *HintError
if errors.As(err, &hint) {
explanation, try = hint.Explanation, hint.Try
}
if strings.HasPrefix(message, "unknown command") {
cmdName, suggestions := parseUnknownCommand(message)
message = `Command "` + cmdName + `" is not defined.`
if try == "" {
var lines []string
for _, s := range suggestions {
lines = append(lines, "{bin} "+s)
}
if len(lines) == 0 {
lines = []string{"{bin} list"}
}
try = strings.Join(lines, "\n")
}
} else if try == "" {
var ue *UsageError
if errors.As(err, &ue) {
if ue.command != "" {
try = "{bin} help " + ue.command
} else {
try = "{bin} list"
}
}
}
var b strings.Builder
b.WriteString("\n")
for i, line := range strings.Split(sanitizeLines(sunscreen.Scrub(strings.TrimRight(message, "\n"))), "\n") {
if i == 0 {
b.WriteString(" " + p.ErrorBold.Reverse(true).Render(" ERROR ") + " " + line + "\n")
continue
}
b.WriteString(" " + line + "\n")
}
if explanation != "" {
b.WriteString("\n")
for _, line := range strings.Split(sanitizeLines(sunscreen.Scrub(explanation)), "\n") {
b.WriteString(" " + line + "\n")
}
}
if try != "" {
b.WriteString("\n " + p.Header.Render("Try:") + "\n")
for _, line := range strings.Split(strings.ReplaceAll(try, "{bin}", bin), "\n") {
b.WriteString(" " + p.Success.Render(Sanitize(sunscreen.Scrub(line))) + "\n")
}
}
if g.Verbosity > 0 {
if chain := causes(err, err.Error()); len(chain) > 0 {
b.WriteString("\n " + p.Header.Render("Caused by:") + "\n")
for _, c := range chain {
b.WriteString(" " + p.Muted.Render("- "+sanitizeLines(sunscreen.Scrub(c))) + "\n")
}
}
} else {
b.WriteString("\n " + p.Muted.Render("Run with -v for details.") + "\n")
}
b.WriteString("\n")
_ = writeStyled(w, g, b.String())
}
// wrongDirectoryExplanation is the hint added when an application binary
// fails outside its application root.
const wrongDirectoryExplanation = "This command runs from the application root."
// wrongDirectory adds a hint to err when an application binary (any root
// name other than summer) failed while the working directory dir ("" means
// os.Getwd) has no config directory and a parent directory holds
// summer.yaml: compass then found no configuration, which is why the command
// failed (D-19). The Try line is "cd <relative path>; bin/<name> <args>",
// with the values of secret-looking flags masked (tryArgs).
// Usage errors, Reported errors and summer are left alone; nothing else
// about the failure changes.
func wrongDirectory(name string, err error, args []string, dir string) error {
if err == nil || name == "summer" || errors.Is(err, ErrReported) || isUsage(err) {
return err
}
if dir == "" {
wd, werr := os.Getwd()
if werr != nil {
return err
}
dir = wd
}
if info, serr := os.Stat(filepath.Join(dir, "config")); serr == nil && info.IsDir() {
return err
}
root := ""
for d := filepath.Dir(dir); ; d = filepath.Dir(d) {
if _, serr := os.Stat(filepath.Join(d, "summer.yaml")); serr == nil {
root = d
break
}
if filepath.Dir(d) == d {
break
}
}
if root == "" {
return err
}
rel, rerr := filepath.Rel(dir, root)
if rerr != nil {
return err
}
try := "cd " + shellQuote(rel) + "; {bin}" + tryArgs(args)
return &HintError{Err: err, Explanation: wrongDirectoryExplanation, Try: try}
}
// secretWords mark a long flag whose value the Try line masks.
var secretWords = []string{"password", "passwd", "secret", "token", "key"}
// secretFlag reports whether the long flag name (without "--") looks like it
// carries a secret.
func secretFlag(name string) bool {
name = strings.ToLower(name)
for _, w := range secretWords {
if strings.Contains(name, w) {
return true
}
}
return false
}
// tryArgs renders args for the wrong-directory Try line, each preceded by a
// space and quoted for a shell. The value of a long flag named like a
// secret becomes ***, in the "--flag=value" form and in the "--flag value"
// form (the next argument, when it does not start with "-"), so a password
// typed on the command line does not reach stderr and CI logs.
func tryArgs(args []string) string {
var b strings.Builder
for i := 0; i < len(args); i++ {
a := args[i]
b.WriteByte(' ')
name, long := strings.CutPrefix(a, "--")
flag, _, inline := strings.Cut(name, "=")
if !long || flag == "" || !secretFlag(flag) {
b.WriteString(shellQuote(a))
continue
}
if inline {
b.WriteString(shellQuote("--"+flag) + "=***")
continue
}
b.WriteString(shellQuote(a))
if i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") {
b.WriteString(" ***")
i++
}
}
return b.String()
}
// shellQuote single-quotes s when a shell would split or expand it.
func shellQuote(s string) string {
if s != "" && !strings.ContainsAny(s, " \t\n'\"\\$`*?[]{}()<>|&;#~!") {
return s
}
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}
// parseUnknownCommand reads the command name and cobra's "Did you mean
// this?" suggestions from an unknown command error.
func parseUnknownCommand(msg string) (string, []string) {
name := ""
if i := strings.Index(msg, `"`); i >= 0 {
if j := strings.Index(msg[i+1:], `"`); j >= 0 {
name = msg[i+1 : i+1+j]
}
}
var suggestions []string
if _, rest, ok := strings.Cut(msg, "Did you mean this?"); ok {
for _, line := range strings.Split(rest, "\n") {
if s := strings.TrimSpace(line); s != "" {
suggestions = append(suggestions, s)
}
}
}
return name, suggestions
}
// causes walks the wrapped errors below err (errors.Join members expanded)
// and returns each distinct message other than top.
func causes(err error, top string) []string {
var out []string
seen := map[string]bool{top: true}
var walk func(error)
walk = func(e error) {
for e != nil {
if msg := e.Error(); !seen[msg] {
seen[msg] = true
out = append(out, msg)
}
if joined, ok := e.(interface{ Unwrap() []error }); ok {
for _, m := range joined.Unwrap() {
walk(m)
}
return
}
e = errors.Unwrap(e)
}
}
walk(errors.Unwrap(err))
if joined, ok := err.(interface{ Unwrap() []error }); ok {
for _, m := range joined.Unwrap() {
walk(m)
}
}
return out
}