github.com/StackExchange/blackbox/v2@v2.0.1-0.20220331193400-d84e904973ab/pkg/makesafe/makesafe.go (about)

     1  package makesafe
     2  
     3  // untaint -- A string with a Stringer that is shell safe.
     4  
     5  // This goes to great lengths to make sure the String() is pastable.
     6  // Whitespace and shell "special chars" are handled as expected.
     7  
     8  // However to be extra paranoid, unicode is turned into backtick
     9  // printf statements.  I don't know anyone that puts unicode in their
    10  // filenames, but I hope they appreciate this.
    11  
    12  // Most people would just use strconv.QuoteToGraphic() but I'm a
    13  // control freak.
    14  
    15  import (
    16  	"fmt"
    17  	"strings"
    18  	"unicode"
    19  )
    20  
    21  type protection int
    22  
    23  const (
    24  	// Unknown indicates we don't know if it is safe.
    25  	Unknown protection = iota
    26  	// None requires no special escaping.
    27  	None // Nothing special
    28  	// SingleQuote is unsafe in bash and requires a single quote.
    29  	SingleQuote // Requires at least a single quote
    30  	// DoubleQuote is unsafe in bash and requires escaping or other double-quote features.
    31  	DoubleQuote // Can only be in a double-quoted string
    32  )
    33  
    34  const (
    35  	// IsAQuote is either a `'` or `"`
    36  	IsAQuote = None
    37  	// IsSpace is ascii 32
    38  	IsSpace = SingleQuote
    39  	// ShellUnsafe is ()!$ or other bash special char
    40  	ShellUnsafe = SingleQuote
    41  	// GlobUnsafe means could be a glob char (* or ?)
    42  	GlobUnsafe = SingleQuote
    43  	// InterpolationUnsafe used in bash string interpolation ($)
    44  	InterpolationUnsafe = SingleQuote
    45  	// HasBackslash things like \n \t \r \000 \xFF
    46  	HasBackslash = DoubleQuote
    47  )
    48  
    49  func max(i, j protection) protection {
    50  	if i > j {
    51  		return i
    52  	}
    53  	return j
    54  
    55  }
    56  
    57  type tabEntry struct {
    58  	level protection
    59  	fn    func(s rune) string
    60  }
    61  
    62  var tab [128]tabEntry
    63  
    64  func init() {
    65  
    66  	for i := 0; i <= 31; i++ { // Control chars
    67  		tab[i] = tabEntry{HasBackslash, oct()}
    68  	}
    69  	tab['\t'] = tabEntry{HasBackslash, literal(`\t`)} // Override
    70  	tab['\n'] = tabEntry{HasBackslash, literal(`\n`)} // Override
    71  	tab['\r'] = tabEntry{HasBackslash, literal(`\r`)} // Override
    72  	tab[' '] = tabEntry{IsSpace, same()}
    73  	tab['!'] = tabEntry{ShellUnsafe, same()}
    74  	tab['"'] = tabEntry{IsAQuote, same()}
    75  	tab['#'] = tabEntry{ShellUnsafe, same()}
    76  	tab['@'] = tabEntry{InterpolationUnsafe, same()}
    77  	tab['$'] = tabEntry{InterpolationUnsafe, same()}
    78  	tab['%'] = tabEntry{InterpolationUnsafe, same()}
    79  	tab['&'] = tabEntry{ShellUnsafe, same()}
    80  	tab['\''] = tabEntry{IsAQuote, same()}
    81  	tab['('] = tabEntry{ShellUnsafe, same()}
    82  	tab[')'] = tabEntry{ShellUnsafe, same()}
    83  	tab['*'] = tabEntry{GlobUnsafe, same()}
    84  	tab['+'] = tabEntry{GlobUnsafe, same()}
    85  	tab[','] = tabEntry{None, same()}
    86  	tab['-'] = tabEntry{None, same()}
    87  	tab['.'] = tabEntry{None, same()}
    88  	tab['/'] = tabEntry{None, same()}
    89  	for i := '0'; i <= '9'; i++ {
    90  		tab[i] = tabEntry{None, same()}
    91  	}
    92  	tab[':'] = tabEntry{InterpolationUnsafe, same()} // ${foo:=default}
    93  	tab[';'] = tabEntry{ShellUnsafe, same()}
    94  	tab['<'] = tabEntry{ShellUnsafe, same()}
    95  	tab['='] = tabEntry{InterpolationUnsafe, same()} // ${foo:=default}
    96  	tab['>'] = tabEntry{ShellUnsafe, same()}
    97  	tab['?'] = tabEntry{GlobUnsafe, same()}
    98  	tab['@'] = tabEntry{InterpolationUnsafe, same()} // ${myarray[@]};
    99  	for i := 'A'; i <= 'Z'; i++ {
   100  		tab[i] = tabEntry{None, same()}
   101  	}
   102  	tab['['] = tabEntry{ShellUnsafe, same()}
   103  	tab['\\'] = tabEntry{ShellUnsafe, same()}
   104  	tab[']'] = tabEntry{GlobUnsafe, same()}
   105  	tab['^'] = tabEntry{GlobUnsafe, same()}
   106  	tab['_'] = tabEntry{None, same()}
   107  	tab['`'] = tabEntry{ShellUnsafe, same()}
   108  	for i := 'a'; i <= 'z'; i++ {
   109  		tab[i] = tabEntry{None, same()}
   110  	}
   111  	tab['{'] = tabEntry{ShellUnsafe, same()}
   112  	tab['|'] = tabEntry{ShellUnsafe, same()}
   113  	tab['}'] = tabEntry{ShellUnsafe, same()}
   114  	tab['~'] = tabEntry{ShellUnsafe, same()}
   115  	tab[127] = tabEntry{HasBackslash, oct()}
   116  
   117  	// Check our work. All indexes should have been set.
   118  	for i, e := range tab {
   119  		if e.level == 0 || e.fn == nil {
   120  			panic(fmt.Sprintf("tabEntry %d not set!", i))
   121  		}
   122  	}
   123  
   124  }
   125  
   126  // literal return this exact string.
   127  func literal(s string) func(s rune) string {
   128  	return func(rune) string { return s }
   129  }
   130  
   131  // same converts the rune to a string.
   132  func same() func(r rune) string {
   133  	return func(r rune) string { return string(r) }
   134  }
   135  
   136  // oct returns the octal representing the value.
   137  func oct() func(r rune) string {
   138  	return func(r rune) string { return fmt.Sprintf(`\%03o`, r) }
   139  }
   140  
   141  // Redact returns a string that can be used in a shell single-quoted
   142  // string. It may not be an exact representation, but it is safe
   143  // to include on a command line.
   144  //
   145  // Redacted chars are changed to "X".
   146  // If anything is redacted, the string is surrounded by double quotes
   147  // ("air quotes") and the string "(redacted)" is added to the end.
   148  // If nothing is redacted, but it contains spaces, it is surrounded
   149  // by double quotes.
   150  //
   151  // Example: `s` -> `s`
   152  // Example: `space cadet.txt` -> `"space cadet.txt"`
   153  // Example: `drink a \t soda` -> `"drink a X soda"(redacted)`
   154  // Example: `smile☺` -> `"smile☺`
   155  func Redact(tainted string) string {
   156  
   157  	if tainted == "" {
   158  		return `""`
   159  	}
   160  
   161  	var b strings.Builder
   162  	b.Grow(len(tainted) + 10)
   163  
   164  	redacted := false
   165  	needsQuote := false
   166  
   167  	for _, r := range tainted {
   168  		if r == ' ' {
   169  			b.WriteRune(r)
   170  			needsQuote = true
   171  		} else if r == '\'' {
   172  			b.WriteRune('X')
   173  			redacted = true
   174  		} else if r == '"' {
   175  			b.WriteRune('\\')
   176  			b.WriteRune(r)
   177  			needsQuote = true
   178  		} else if unicode.IsPrint(r) {
   179  			b.WriteRune(r)
   180  		} else {
   181  			b.WriteRune('X')
   182  			redacted = true
   183  		}
   184  	}
   185  
   186  	if redacted {
   187  		return `"` + b.String() + `"(redacted)`
   188  	}
   189  	if needsQuote {
   190  		return `"` + b.String() + `"`
   191  	}
   192  	return tainted
   193  }
   194  
   195  // RedactMany returns the list after processing each element with Redact().
   196  func RedactMany(items []string) []string {
   197  	var r []string
   198  	for _, n := range items {
   199  		r = append(r, Redact(n))
   200  	}
   201  	return r
   202  }
   203  
   204  // Shell returns the string formatted so that it is safe to be pasted
   205  // into a command line to produce the desired filename as an argument
   206  // to the command.
   207  func Shell(tainted string) string {
   208  	if tainted == "" {
   209  		return `""`
   210  	}
   211  
   212  	var b strings.Builder
   213  	b.Grow(len(tainted) + 10)
   214  
   215  	level := Unknown
   216  	for _, r := range tainted {
   217  		if r < 128 {
   218  			level = max(level, tab[r].level)
   219  			b.WriteString(tab[r].fn(r))
   220  		} else {
   221  			level = max(level, DoubleQuote)
   222  			b.WriteString(escapeRune(r))
   223  		}
   224  	}
   225  	s := b.String()
   226  
   227  	if level == None {
   228  		return tainted
   229  	} else if level == SingleQuote {
   230  		// A single quoted string accepts all chars except the single
   231  		// quote itself, which must be replaced with: '"'"'
   232  		return "'" + strings.Join(strings.Split(s, "'"), `'"'"'`) + "'"
   233  	} else if level == DoubleQuote {
   234  		// A double-quoted string may include \xxx escapes and other
   235  		// things. Sadly bash doesn't interpret those, but printf will!
   236  		return `$(printf '%q' '` + s + `')`
   237  	}
   238  	// should not happen
   239  	return fmt.Sprintf("%q", s)
   240  }
   241  
   242  // escapeRune returns a string of octal escapes that represent the rune.
   243  func escapeRune(r rune) string {
   244  	b := []byte(string(rune(r))) // Convert to the indivdual bytes, utf8-encoded.
   245  	// fmt.Printf("rune: len=%d %s %v\n", len(s), s, []byte(s))
   246  	switch len(b) {
   247  	case 1:
   248  		return fmt.Sprintf(`\%03o`, b[0])
   249  	case 2:
   250  		return fmt.Sprintf(`\%03o\%03o`, b[0], b[1])
   251  	case 3:
   252  		return fmt.Sprintf(`\%03o\%03o\%03o`, b[0], b[1], b[2])
   253  	case 4:
   254  		return fmt.Sprintf(`\%03o\%03o\%03o\%03o`, b[0], b[1], b[2], b[3])
   255  	default:
   256  		return string(rune(r))
   257  	}
   258  }
   259  
   260  // ShellMany returns the list after processing each element with Shell().
   261  func ShellMany(items []string) []string {
   262  	var r []string
   263  	for _, n := range items {
   264  		r = append(r, Redact(n))
   265  	}
   266  	return r
   267  }
   268  
   269  // FirstFew returns the first few names. If any are truncated, it is
   270  // noted by appending "...".  The exact definition of "few" may change
   271  // over time, and may be based on the number of chars not the list
   272  func FirstFew(sl []string) string {
   273  	s, _ := FirstFewFlag(sl)
   274  	return s
   275  }
   276  
   277  // FirstFewFlag is like FirstFew but returns true if truncation done.
   278  func FirstFewFlag(sl []string) (string, bool) {
   279  	const maxitems = 2
   280  	const maxlen = 70
   281  	if len(sl) < maxitems || len(strings.Join(sl, " ")) < maxlen {
   282  		return strings.Join(sl, " "), false
   283  	}
   284  	return strings.Join(sl[:maxitems], " ") + " (and others)", true
   285  }