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 ` ? { ... }` 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, ",") }