ae948c7f04
A differently quoted substring inside a ${...} interpolation ended the
outer string early and truncated the argument list, dropping every name
after it. One shared walker now backs every source scan.
Lowercase the recovered prefix of an unresolved reference, and every
class name as it is added, so a mixed-case interpolated name still
widens.
Report fact records with no certname instead of dropping them, and
assert stripSource keeps newline positions rather than just length.
564 lines
15 KiB
Go
564 lines
15 KiB
Go
package impact
|
|
|
|
import (
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
// refKind separates a construct that applies a class from one that only names
|
|
// it for ordering or to read a value out of it.
|
|
type refKind int
|
|
|
|
const (
|
|
refOrdering refKind = iota
|
|
refDeclare
|
|
)
|
|
|
|
// UnresolvedRef is a class reference the parser saw but could not reduce to
|
|
// literal names. Dropping one silently loses hosts, so it is recorded against
|
|
// its file and reported instead.
|
|
type UnresolvedRef struct {
|
|
File string `json:"file"`
|
|
Form string `json:"form"`
|
|
Expr string `json:"expr"`
|
|
Prefix string `json:"prefix,omitempty"`
|
|
}
|
|
|
|
// reserved are bare words an argument list or selector arm can hold that are
|
|
// values rather than class names.
|
|
var reserved = map[string]bool{
|
|
"undef": true, "true": true, "false": true, "default": true,
|
|
"class": true, "define": true,
|
|
}
|
|
|
|
// maxVarDepth caps how far a bare variable is followed through same-file
|
|
// assignments before the reference is called unresolved.
|
|
const maxVarDepth = 4
|
|
|
|
// assignRe finds `$name = ` while skipping `==`, `=~` and `=>`.
|
|
var assignRe = regexp.MustCompile(`(?:^|[^\w$.])\$([a-z_][a-zA-Z0-9_]*)\s*=[^=~>]`)
|
|
|
|
type refScan struct {
|
|
refs map[string]refKind
|
|
unresolved []UnresolvedRef
|
|
vars map[string]string
|
|
depth int
|
|
}
|
|
|
|
// scanReferences extracts every class or defined-type name a manifest body
|
|
// names, plus every class reference it could not resolve to literal names.
|
|
func scanReferences(body string) (map[string]refKind, []UnresolvedRef) {
|
|
s := &refScan{refs: map[string]refKind{}, vars: assignments(body)}
|
|
|
|
// include/contain/require: every name in the argument list.
|
|
for _, loc := range declFuncRe.FindAllStringIndex(body, -1) {
|
|
args, ok := callArguments(body, loc[1])
|
|
if !ok {
|
|
continue
|
|
}
|
|
s.args(callName(body[loc[0]:loc[1]]), refDeclare, args)
|
|
}
|
|
// class { ['a', 'b']: } declares one resource per title.
|
|
for _, loc := range classParamRe.FindAllStringIndex(body, -1) {
|
|
s.args("class {}", refDeclare, resourceTitles(body, loc[1]))
|
|
}
|
|
// Class['a', 'b'] names classes that are declared elsewhere; it orders
|
|
// against them rather than applying them.
|
|
for _, loc := range classRefRe.FindAllStringIndex(body, -1) {
|
|
inner, _, ok := balanced(body, loc[1]-1, '[', ']')
|
|
if !ok {
|
|
continue
|
|
}
|
|
s.args("Class[]", refOrdering, inner)
|
|
}
|
|
for _, re := range []*regexp.Regexp{inheritsRe, defineUseRe, createResRe} {
|
|
for _, m := range re.FindAllStringSubmatch(body, -1) {
|
|
s.add(m[1], refDeclare)
|
|
}
|
|
}
|
|
// A $mod::params::thing read and a Mod::Type['x'] reference both depend on
|
|
// another class without declaring it.
|
|
for _, m := range scopeVarRe.FindAllStringSubmatch(body, -1) {
|
|
s.add(m[1], refOrdering)
|
|
}
|
|
for _, m := range resourceRefRe.FindAllStringSubmatch(body, -1) {
|
|
s.add(m[1], refOrdering)
|
|
}
|
|
|
|
return s.refs, dedupeUnresolved(s.unresolved)
|
|
}
|
|
|
|
// callName trims the separator declFuncRe matched ahead of the function name.
|
|
func callName(match string) string {
|
|
return strings.TrimLeftFunc(match, func(r rune) bool { return r < 'a' || r > 'z' })
|
|
}
|
|
|
|
func (s *refScan) add(name string, kind refKind) {
|
|
name = strings.ToLower(strings.TrimPrefix(name, "::"))
|
|
if name == "" || reserved[name] {
|
|
return
|
|
}
|
|
if old, ok := s.refs[name]; !ok || kind > old {
|
|
s.refs[name] = kind
|
|
}
|
|
}
|
|
|
|
func (s *refScan) args(form string, kind refKind, args string) {
|
|
for _, arg := range splitArgs(args) {
|
|
s.arg(form, kind, arg)
|
|
}
|
|
}
|
|
|
|
// arg reduces one argument expression to class names, descending into arrays,
|
|
// parentheses and selector arms. Anything it cannot reduce becomes an
|
|
// unresolved reference rather than nothing.
|
|
func (s *refScan) arg(form string, kind refKind, arg string) {
|
|
arg = strings.TrimSpace(arg)
|
|
if arg == "" {
|
|
return
|
|
}
|
|
switch arg[0] {
|
|
case '[':
|
|
s.nested(form, kind, arg, '[', ']')
|
|
case '(':
|
|
s.nested(form, kind, arg, '(', ')')
|
|
default:
|
|
if arms, ok := selectorArms(arg); ok {
|
|
s.selector(form, kind, arg, arms)
|
|
return
|
|
}
|
|
s.literal(form, kind, arg)
|
|
}
|
|
}
|
|
|
|
func (s *refScan) nested(form string, kind refKind, arg string, opener, closer byte) {
|
|
inner, _, ok := balanced(arg, 0, opener, closer)
|
|
if !ok {
|
|
s.unresolvedAdd(form, arg, "")
|
|
return
|
|
}
|
|
s.args(form, kind, inner)
|
|
}
|
|
|
|
// selector takes every literal name out of every arm, which is strictly better
|
|
// than widening: a selector over role names lists all the classes it can pick.
|
|
func (s *refScan) selector(form string, kind refKind, arg, arms string) {
|
|
for _, arm := range splitArgs(arms) {
|
|
_, value, ok := cutRocket(arm)
|
|
if !ok {
|
|
if strings.TrimSpace(arm) != "" {
|
|
s.unresolvedAdd(form, arg, "")
|
|
}
|
|
continue
|
|
}
|
|
s.arg(form, kind, value)
|
|
}
|
|
}
|
|
|
|
func (s *refScan) literal(form string, kind refKind, arg string) {
|
|
lit, quoted := unquote(arg)
|
|
switch {
|
|
case classNameRe.MatchString(lit):
|
|
s.add(lit, kind)
|
|
case anyCaseNameRe.MatchString(lit):
|
|
// Class[Apache::Ssl] names the class in type form.
|
|
s.add(lit, kind)
|
|
case quoted:
|
|
// "profile::role_${role}" is a name only the compiler can finish.
|
|
s.unresolvedAdd(form, arg, literalPrefix(lit))
|
|
case strings.HasPrefix(arg, "$"):
|
|
s.variable(form, kind, arg)
|
|
default:
|
|
s.unresolvedAdd(form, arg, "")
|
|
}
|
|
}
|
|
|
|
// variable follows a bare $name through the assignments in the same file, and
|
|
// gives up loudly when that leads nowhere.
|
|
func (s *refScan) variable(form string, kind refKind, arg string) {
|
|
expr, ok := s.vars[strings.TrimPrefix(strings.TrimPrefix(arg, "$"), "::")]
|
|
if !ok || s.depth >= maxVarDepth {
|
|
s.unresolvedAdd(form, arg, "")
|
|
return
|
|
}
|
|
before := len(s.refs) + len(s.unresolved)
|
|
s.depth++
|
|
s.arg(form, kind, expr)
|
|
s.depth--
|
|
if len(s.refs)+len(s.unresolved) == before {
|
|
s.unresolvedAdd(form, arg, "")
|
|
}
|
|
}
|
|
|
|
func (s *refScan) unresolvedAdd(form, expr, prefix string) {
|
|
s.unresolved = append(s.unresolved, UnresolvedRef{Form: form, Expr: collapse(expr), Prefix: prefix})
|
|
}
|
|
|
|
func dedupeUnresolved(in []UnresolvedRef) []UnresolvedRef {
|
|
if len(in) == 0 {
|
|
return nil
|
|
}
|
|
seen := map[UnresolvedRef]bool{}
|
|
out := in[:0]
|
|
for _, u := range in {
|
|
if seen[u] {
|
|
continue
|
|
}
|
|
seen[u] = true
|
|
out = append(out, u)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// literalPrefix returns the part of an interpolated name that is fixed text,
|
|
// so the report can say what family of classes was missed. Class names are
|
|
// case insensitive and every name the index holds is lowercase, so the prefix
|
|
// is lowered too or it matches nothing it is meant to widen to.
|
|
func literalPrefix(s string) string {
|
|
i := strings.Index(s, "$")
|
|
if i <= 0 {
|
|
return ""
|
|
}
|
|
return strings.ToLower(s[:i])
|
|
}
|
|
|
|
// collapse folds whitespace and clips an expression to a length that reads in
|
|
// a report.
|
|
func collapse(s string) string {
|
|
out := strings.Join(strings.Fields(s), " ")
|
|
if len(out) > 80 {
|
|
out = out[:80] + "..."
|
|
}
|
|
return out
|
|
}
|
|
|
|
func unquote(s string) (string, bool) {
|
|
if len(s) > 1 && (s[0] == '\'' || s[0] == '"') && s[len(s)-1] == s[0] {
|
|
return s[1 : len(s)-1], true
|
|
}
|
|
return s, false
|
|
}
|
|
|
|
// selectorArms returns the arm text of a `<expr> ? { ... }` selector.
|
|
func selectorArms(s string) (string, bool) {
|
|
var arms string
|
|
var ok bool
|
|
walkSource(s, 0, anyDelim, func(j int, c byte, depth int) bool {
|
|
if c != '?' || depth != 0 {
|
|
return false
|
|
}
|
|
k := j + 1
|
|
for k < len(s) && isSpace(s[k]) {
|
|
k++
|
|
}
|
|
if k >= len(s) || s[k] != '{' {
|
|
return false
|
|
}
|
|
arms, _, ok = balanced(s, k, '{', '}')
|
|
return true
|
|
})
|
|
return arms, ok
|
|
}
|
|
|
|
// cutRocket splits a selector arm on its top-level `=>`.
|
|
func cutRocket(s string) (string, string, bool) {
|
|
end := walkSource(s, 0, anyDelim, func(j int, c byte, depth int) bool {
|
|
return c == '=' && depth == 0 && j+1 < len(s) && s[j+1] == '>'
|
|
})
|
|
if end >= len(s) {
|
|
return "", "", false
|
|
}
|
|
return s[:end], s[end+2:], true
|
|
}
|
|
|
|
// assignments maps a variable assigned in this file to the expression it was
|
|
// assigned, so `$p = $x ? {...}; include $p` still yields names.
|
|
func assignments(body string) map[string]string {
|
|
out := map[string]string{}
|
|
for _, loc := range assignRe.FindAllStringSubmatchIndex(body, -1) {
|
|
name := body[loc[2]:loc[3]]
|
|
if _, seen := out[name]; seen {
|
|
continue
|
|
}
|
|
out[name] = statementExpr(body, loc[1]-1)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// statementExpr reads one expression starting at i, ending at the top-level
|
|
// semicolon, newline or closing delimiter that terminates the statement.
|
|
func statementExpr(s string, i int) string {
|
|
for i < len(s) && (s[i] == ' ' || s[i] == '\t') {
|
|
i++
|
|
}
|
|
end := walkSource(s, i, anyDelim, func(j int, c byte, depth int) bool {
|
|
if depth != 0 {
|
|
return false
|
|
}
|
|
switch c {
|
|
case ')', ']', '}', ';':
|
|
return true
|
|
case '\n':
|
|
return !continued(s[i:j])
|
|
}
|
|
return false
|
|
})
|
|
return s[i:end]
|
|
}
|
|
|
|
// stripSource blanks out # comments and heredoc bodies. Both are text the
|
|
// class scan must not read: a `#` inside a string is not a comment, and an
|
|
// `include` in heredoc documentation is not an edge. Blanking rather than
|
|
// deleting keeps the line structure the statement scan relies on.
|
|
func stripSource(src string) string {
|
|
out := []byte(src)
|
|
blank := func(from, to int) {
|
|
for k := from; k < to && k < len(out); k++ {
|
|
if out[k] != '\n' {
|
|
out[k] = ' '
|
|
}
|
|
}
|
|
}
|
|
|
|
var pending []string
|
|
for i := 0; i < len(src); {
|
|
switch c := src[i]; {
|
|
case c == '\'' || c == '"':
|
|
i = skipString(src, i)
|
|
case c == '#':
|
|
j := i
|
|
for j < len(src) && src[j] != '\n' {
|
|
j++
|
|
}
|
|
blank(i, j)
|
|
i = j
|
|
case c == '@' && i+1 < len(src) && src[i+1] == '(':
|
|
tag, next, ok := heredocTag(src, i+1)
|
|
if !ok {
|
|
i++
|
|
continue
|
|
}
|
|
pending = append(pending, tag)
|
|
i = next
|
|
case c == '\n':
|
|
i++
|
|
for _, tag := range pending {
|
|
i = blankHeredoc(src, blank, i, tag)
|
|
}
|
|
pending = pending[:0]
|
|
default:
|
|
i++
|
|
}
|
|
}
|
|
return string(out)
|
|
}
|
|
|
|
// heredocTag reads the tag out of @(EOT), @("EOT"), @(EOT/L) and @(EOT:json/nt).
|
|
func heredocTag(s string, i int) (string, int, bool) {
|
|
j := i + 1
|
|
for j < len(s) && s[j] != ')' && s[j] != '\n' {
|
|
j++
|
|
}
|
|
if j >= len(s) || s[j] != ')' {
|
|
return "", i + 1, false
|
|
}
|
|
spec := s[i+1 : j]
|
|
if k := strings.IndexAny(spec, ":/"); k >= 0 {
|
|
spec = spec[:k]
|
|
}
|
|
spec = strings.Trim(strings.TrimSpace(spec), `"'`)
|
|
if spec == "" {
|
|
return "", j + 1, false
|
|
}
|
|
return spec, j + 1, true
|
|
}
|
|
|
|
// blankHeredoc blanks the lines from i up to and including the line that ends
|
|
// the heredoc, and returns the index just past it.
|
|
func blankHeredoc(s string, blank func(from, to int), i int, tag string) int {
|
|
for i < len(s) {
|
|
end := strings.IndexByte(s[i:], '\n')
|
|
if end < 0 {
|
|
blank(i, len(s))
|
|
return len(s)
|
|
}
|
|
line := s[i : i+end]
|
|
blank(i, i+end)
|
|
i += end + 1
|
|
if isHeredocEnd(line, tag) {
|
|
return i
|
|
}
|
|
}
|
|
return i
|
|
}
|
|
|
|
// isHeredocEnd matches the terminator line, with its optional `|` indentation
|
|
// marker and `-` trailing-newline marker.
|
|
func isHeredocEnd(line, tag string) bool {
|
|
t := strings.TrimSpace(line)
|
|
t = strings.TrimSpace(strings.TrimPrefix(t, "|"))
|
|
t = strings.TrimSpace(strings.TrimPrefix(t, "-"))
|
|
return t == tag
|
|
}
|
|
|
|
func isSpace(c byte) bool { return c == ' ' || c == '\t' || c == '\n' || c == '\r' }
|
|
|
|
// callArguments returns the argument text of the function call whose name ends
|
|
// at i, in either the parenthesised or the bare statement form. It reports
|
|
// false when the keyword is not a call at all, such as a require => attribute.
|
|
func callArguments(s string, i int) (string, bool) {
|
|
if i >= len(s) {
|
|
return "", false
|
|
}
|
|
if s[i] != '(' && s[i] != ' ' && s[i] != '\t' {
|
|
return "", false
|
|
}
|
|
for i < len(s) && (s[i] == ' ' || s[i] == '\t') {
|
|
i++
|
|
}
|
|
if i >= len(s) {
|
|
return "", false
|
|
}
|
|
if s[i] == '(' {
|
|
inner, _, ok := balanced(s, i, '(', ')')
|
|
return inner, ok
|
|
}
|
|
if s[i] == '=' || s[i] == ',' || s[i] == ';' || s[i] == '\n' {
|
|
return "", false
|
|
}
|
|
return statementExpr(s, i), true
|
|
}
|
|
|
|
// splitArgs splits an argument list on its top-level commas.
|
|
func splitArgs(s string) []string {
|
|
var out []string
|
|
start := 0
|
|
walkSource(s, 0, anyDelim, func(j int, c byte, depth int) bool {
|
|
if c == ',' && depth == 0 {
|
|
out = append(out, s[start:j])
|
|
start = j + 1
|
|
}
|
|
return false
|
|
})
|
|
return append(out, s[start:])
|
|
}
|
|
|
|
// resourceTitles returns the title expression of a resource body whose opening
|
|
// brace ends at i, i.e. everything up to the colon that closes the title.
|
|
func resourceTitles(s string, i int) string {
|
|
var out string
|
|
walkSource(s, i, anyDelim, func(j int, c byte, depth int) bool {
|
|
if depth != 0 {
|
|
return false
|
|
}
|
|
switch c {
|
|
case ')', ']', '}':
|
|
return true
|
|
case ':':
|
|
out = s[i:j]
|
|
return true
|
|
}
|
|
return false
|
|
})
|
|
return out
|
|
}
|
|
|
|
// balanced returns the text between the delimiter at i and its match. Only the
|
|
// given pair is counted, so an unmatched brace inside a bracket list cannot
|
|
// unbalance it.
|
|
func balanced(s string, i int, opener, closer byte) (string, int, bool) {
|
|
end := walkSource(s, i, pairDelim(opener, closer), func(_ int, c byte, depth int) bool {
|
|
return c == closer && depth == 1
|
|
})
|
|
if end >= len(s) {
|
|
return "", len(s), false
|
|
}
|
|
return s[i+1 : end], end + 1, true
|
|
}
|
|
|
|
// delim reports how a byte changes nesting depth: +1 opening, -1 closing.
|
|
type delim func(byte) int
|
|
|
|
// anyDelim counts all three bracket pairs, which is what an expression scan
|
|
// wants: a comma is top level only if no bracket of any kind is open.
|
|
func anyDelim(c byte) int {
|
|
switch c {
|
|
case '(', '[', '{':
|
|
return 1
|
|
case ')', ']', '}':
|
|
return -1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func pairDelim(opener, closer byte) delim {
|
|
return func(c byte) int {
|
|
switch c {
|
|
case opener:
|
|
return 1
|
|
case closer:
|
|
return -1
|
|
}
|
|
return 0
|
|
}
|
|
}
|
|
|
|
// walkSource visits the bytes of s from i that lie outside string literals,
|
|
// passing each one the nesting depth that applies before it. String literals
|
|
// are skipped whole by the one shared scanner, so no caller can desync on a
|
|
// quote. It returns the index stop accepted, or len(s).
|
|
func walkSource(s string, i int, d delim, stop func(j int, c byte, depth int) bool) int {
|
|
depth := 0
|
|
for j := i; j < len(s); j++ {
|
|
c := s[j]
|
|
if c == '\'' || c == '"' {
|
|
j = skipString(s, j) - 1
|
|
continue
|
|
}
|
|
if stop(j, c, depth) {
|
|
return j
|
|
}
|
|
depth += d(c)
|
|
}
|
|
return len(s)
|
|
}
|
|
|
|
// skipString returns the index just past the quoted string starting at i. The
|
|
// opening delimiter is remembered and only the same delimiter closes the
|
|
// string, so a `"` inside a single-quoted substring cannot end a double-quoted
|
|
// one. Inside a double-quoted string a `${...}` interpolation is code rather
|
|
// than text, so it is scanned as such and may hold strings of either quote.
|
|
func skipString(s string, i int) int {
|
|
quote := s[i]
|
|
for j := i + 1; j < len(s); j++ {
|
|
switch c := s[j]; {
|
|
case c == '\\':
|
|
j++
|
|
case c == quote:
|
|
return j + 1
|
|
case quote == '"' && c == '$' && j+1 < len(s) && s[j+1] == '{':
|
|
j = skipInterpolation(s, j+1) - 1
|
|
}
|
|
}
|
|
return len(s)
|
|
}
|
|
|
|
// skipInterpolation returns the index just past the ${...} expression whose
|
|
// opening brace is at i.
|
|
func skipInterpolation(s string, i int) int {
|
|
end := walkSource(s, i, pairDelim('{', '}'), func(_ int, c byte, depth int) bool {
|
|
return c == '}' && depth == 1
|
|
})
|
|
if end >= len(s) {
|
|
return len(s)
|
|
}
|
|
return end + 1
|
|
}
|
|
|
|
// continued reports whether an argument list so far ends on a comma, meaning
|
|
// the list carries on over the newline.
|
|
func continued(s string) bool {
|
|
s = strings.TrimRight(s, " \t\r")
|
|
return strings.HasSuffix(s, ",")
|
|
}
|