1'use strict'
2
3const parser = require('postcss-selector-parser')
4
5const arrayDelimiter = Symbol('arrayDelimiter')
6
7const escapeSlashes = str =>
8  str.replace(/\//g, '\\/')
9
10const unescapeSlashes = str =>
11  str.replace(/\\\//g, '/')
12
13// recursively fixes up any :attr pseudo-class found
14const fixupAttr = astNode => {
15  const properties = []
16  const matcher = {}
17  for (const selectorAstNode of astNode.nodes) {
18    const [firstAstNode] = selectorAstNode.nodes
19    if (firstAstNode.type === 'tag') {
20      properties.push(firstAstNode.value)
21    }
22  }
23
24  const lastSelectorAstNode = astNode.nodes.pop()
25  const [attributeAstNode] = lastSelectorAstNode.nodes
26
27  if (attributeAstNode.value === ':attr') {
28    const appendParts = fixupAttr(attributeAstNode)
29    properties.push(arrayDelimiter, ...appendParts.lookupProperties)
30    matcher.qualifiedAttribute = appendParts.attributeMatcher.qualifiedAttribute
31    matcher.operator = appendParts.attributeMatcher.operator
32    matcher.value = appendParts.attributeMatcher.value
33
34    // backwards compatibility
35    matcher.attribute = appendParts.attributeMatcher.attribute
36
37    if (appendParts.attributeMatcher.insensitive) {
38      matcher.insensitive = true
39    }
40  } else {
41    if (attributeAstNode.type !== 'attribute') {
42      throw Object.assign(
43        new Error('`:attr` pseudo-class expects an attribute matcher as the last value'),
44        { code: 'EQUERYATTR' }
45      )
46    }
47
48    matcher.qualifiedAttribute = unescapeSlashes(attributeAstNode.qualifiedAttribute)
49    matcher.operator = attributeAstNode.operator
50    matcher.value = attributeAstNode.value
51
52    // backwards compatibility
53    matcher.attribute = matcher.qualifiedAttribute
54
55    if (attributeAstNode.insensitive) {
56      matcher.insensitive = true
57    }
58  }
59
60  astNode.lookupProperties = properties
61  astNode.attributeMatcher = matcher
62  astNode.nodes.length = 0
63  return astNode
64}
65
66// fixed up nested pseudo nodes will have their internal selectors moved
67// to a new root node that will be referenced by the `nestedNode` property,
68// this tweak makes it simpler to reuse `retrieveNodesFromParsedAst` to
69// recursively parse and extract results from the internal selectors
70const fixupNestedPseudo = astNode => {
71  // create a new ast root node and relocate any children
72  // selectors of the current ast node to this new root
73  const newRootNode = parser.root()
74  astNode.nestedNode = newRootNode
75  newRootNode.nodes = [...astNode.nodes]
76
77  // clean up the ast by removing the children nodes from the
78  // current ast node while also cleaning up their `parent` refs
79  astNode.nodes.length = 0
80  for (const currAstNode of newRootNode.nodes) {
81    currAstNode.parent = newRootNode
82  }
83
84  // recursively fixup nodes of any nested selector
85  transformAst(newRootNode)
86}
87
88// :semver(<version|range|selector>, [version|range|selector], [function])
89// note: the first or second parameter must be a static version or range
90const fixupSemverSpecs = astNode => {
91  // if we have three nodes, the last is the semver function to use, pull that out first
92  if (astNode.nodes.length === 3) {
93    const funcNode = astNode.nodes.pop().nodes[0]
94    if (funcNode.type === 'tag') {
95      astNode.semverFunc = funcNode.value
96    } else if (funcNode.type === 'string') {
97      // a string is always in some type of quotes, we don't want those so slice them off
98      astNode.semverFunc = funcNode.value.slice(1, -1)
99    } else {
100      // anything that isn't a tag or a string isn't a function name
101      throw Object.assign(
102        new Error('`:semver` pseudo-class expects a function name as last value'),
103        { code: 'ESEMVERFUNC' }
104      )
105    }
106  }
107
108  // now if we have 1 node, it's a static value
109  // istanbul ignore else
110  if (astNode.nodes.length === 1) {
111    const semverNode = astNode.nodes.pop()
112    astNode.semverValue = semverNode.nodes.reduce((res, next) => `${res}${String(next)}`, '')
113  } else if (astNode.nodes.length === 2) {
114    // and if we have two nodes, one of them is a static value and we need to determine which it is
115    for (let i = 0; i < astNode.nodes.length; ++i) {
116      const type = astNode.nodes[i].nodes[0].type
117      // the type of the first child may be combinator for ranges, such as >14
118      if (type === 'tag' || type === 'combinator') {
119        const semverNode = astNode.nodes.splice(i, 1)[0]
120        astNode.semverValue = semverNode.nodes.reduce((res, next) => `${res}${String(next)}`, '')
121        astNode.semverPosition = i
122        break
123      }
124    }
125
126    if (typeof astNode.semverValue === 'undefined') {
127      throw Object.assign(
128        new Error('`:semver` pseudo-class expects a static value in the first or second position'),
129        { code: 'ESEMVERVALUE' }
130      )
131    }
132  }
133
134  // if we got here, the last remaining child should be attribute selector
135  if (astNode.nodes.length === 1) {
136    fixupAttr(astNode)
137  } else {
138    // if we don't have a selector, we default to `[version]`
139    astNode.attributeMatcher = {
140      insensitive: false,
141      attribute: 'version',
142      qualifiedAttribute: 'version',
143    }
144    astNode.lookupProperties = []
145  }
146
147  astNode.nodes.length = 0
148}
149
150const fixupTypes = astNode => {
151  const [valueAstNode] = astNode.nodes[0].nodes
152  const { value } = valueAstNode || {}
153  astNode.typeValue = value
154  astNode.nodes.length = 0
155}
156
157const fixupPaths = astNode => {
158  astNode.pathValue = unescapeSlashes(String(astNode.nodes[0]))
159  astNode.nodes.length = 0
160}
161
162const fixupOutdated = astNode => {
163  if (astNode.nodes.length) {
164    astNode.outdatedKind = String(astNode.nodes[0])
165    astNode.nodes.length = 0
166  }
167}
168
169const fixupVuln = astNode => {
170  const vulns = []
171  if (astNode.nodes.length) {
172    for (const selector of astNode.nodes) {
173      const vuln = {}
174      for (const node of selector.nodes) {
175        if (node.type !== 'attribute') {
176          throw Object.assign(
177            new Error(':vuln pseudo-class only accepts attribute matchers or "cwe" tag'),
178            { code: 'EQUERYATTR' }
179          )
180        }
181        if (!['severity', 'cwe'].includes(node._attribute)) {
182          throw Object.assign(
183            new Error(':vuln pseudo-class only matches "severity" and "cwe" attributes'),
184            { code: 'EQUERYATTR' }
185          )
186        }
187        if (!node.operator) {
188          node.operator = '='
189          node.value = '*'
190        }
191        if (node.operator !== '=') {
192          throw Object.assign(
193            new Error(':vuln pseudo-class attribute selector only accepts "=" operator', node),
194            { code: 'EQUERYATTR' }
195          )
196        }
197        if (!vuln[node._attribute]) {
198          vuln[node._attribute] = []
199        }
200        vuln[node._attribute].push(node._value)
201      }
202      vulns.push(vuln)
203    }
204    astNode.vulns = vulns
205    astNode.nodes.length = 0
206  }
207}
208
209// a few of the supported ast nodes need to be tweaked in order to properly be
210// interpreted as proper arborist query selectors, namely semver ranges from
211// both ids and :semver pseudo-class selectors need to be translated from what
212// are usually multiple ast nodes, such as: tag:1, class:.0, class:.0 to a
213// single `1.0.0` value, other pseudo-class selectors also get preprocessed in
214// order to make it simpler to execute later when traversing each ast node
215// using rootNode.walk(), such as :path, :type, etc. transformAst handles all
216// these modifications to the parsed ast by doing an extra, initial traversal
217// of the parsed ast from the query and modifying the parsed nodes accordingly
218const transformAst = selector => {
219  selector.walk((nextAstNode) => {
220    switch (nextAstNode.value) {
221      case ':attr':
222        return fixupAttr(nextAstNode)
223      case ':is':
224      case ':has':
225      case ':not':
226        return fixupNestedPseudo(nextAstNode)
227      case ':path':
228        return fixupPaths(nextAstNode)
229      case ':semver':
230        return fixupSemverSpecs(nextAstNode)
231      case ':type':
232        return fixupTypes(nextAstNode)
233      case ':outdated':
234        return fixupOutdated(nextAstNode)
235      case ':vuln':
236        return fixupVuln(nextAstNode)
237    }
238  })
239}
240
241const queryParser = (query) => {
242  // if query is an empty string or any falsy
243  // value, just returns an empty result
244  if (!query) {
245    return []
246  }
247
248  return parser(transformAst)
249    .astSync(escapeSlashes(query), { lossless: false })
250}
251
252module.exports = {
253  parser: queryParser,
254  arrayDelimiter,
255}
256