1// parse a yarn lock file 2// basic format 3// 4// <request spec>[, <request spec> ...]: 5// <key> <value> 6// <subkey>: 7// <key> <value> 8// 9// Assume that any key or value might be quoted, though that's only done 10// in practice if certain chars are in the string. When writing back, we follow 11// Yarn's rules for quoting, to cause minimal friction. 12// 13// The data format would support nested objects, but at this time, it 14// appears that yarn does not use that for anything, so in the interest 15// of a simpler parser algorithm, this implementation only supports a 16// single layer of sub objects. 17// 18// This doesn't deterministically define the shape of the tree, and so 19// cannot be used (on its own) for Arborist.loadVirtual. 20// But it can give us resolved, integrity, and version, which is useful 21// for Arborist.loadActual and for building the ideal tree. 22// 23// At the very least, when a yarn.lock file is present, we update it 24// along the way, and save it back in Shrinkwrap.save() 25// 26// NIHing this rather than using @yarnpkg/lockfile because that module 27// is an impenetrable 10kloc of webpack flow output, which is overkill 28// for something relatively simple and tailored to Arborist's use case. 29 30const localeCompare = require('@isaacs/string-locale-compare')('en') 31const consistentResolve = require('./consistent-resolve.js') 32const { dirname } = require('path') 33const { breadth } = require('treeverse') 34 35// Sort Yarn entries respecting the yarn.lock sort order 36const yarnEntryPriorities = { 37 name: 1, 38 version: 2, 39 uid: 3, 40 resolved: 4, 41 integrity: 5, 42 registry: 6, 43 dependencies: 7, 44} 45 46const priorityThenLocaleCompare = (a, b) => { 47 if (!yarnEntryPriorities[a] && !yarnEntryPriorities[b]) { 48 return localeCompare(a, b) 49 } 50 /* istanbul ignore next */ 51 return (yarnEntryPriorities[a] || 100) > (yarnEntryPriorities[b] || 100) ? 1 : -1 52} 53 54const quoteIfNeeded = val => { 55 if ( 56 typeof val === 'boolean' || 57 typeof val === 'number' || 58 val.startsWith('true') || 59 val.startsWith('false') || 60 /[:\s\n\\",[\]]/g.test(val) || 61 !/^[a-zA-Z]/g.test(val) 62 ) { 63 return JSON.stringify(val) 64 } 65 66 return val 67} 68 69// sort a key/value object into a string of JSON stringified keys and vals 70const sortKV = obj => Object.keys(obj) 71 .sort(localeCompare) 72 .map(k => ` ${quoteIfNeeded(k)} ${quoteIfNeeded(obj[k])}`) 73 .join('\n') 74 75// for checking against previous entries 76const match = (p, n) => 77 p.integrity && n.integrity ? p.integrity === n.integrity 78 : p.resolved && n.resolved ? p.resolved === n.resolved 79 : p.version && n.version ? p.version === n.version 80 : true 81 82const prefix = 83`# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 84# yarn lockfile v1 85 86 87` 88 89const nullSymbol = Symbol('null') 90class YarnLock { 91 static parse (data) { 92 return new YarnLock().parse(data) 93 } 94 95 static fromTree (tree) { 96 return new YarnLock().fromTree(tree) 97 } 98 99 constructor () { 100 this.entries = null 101 this.endCurrent() 102 } 103 104 endCurrent () { 105 this.current = null 106 this.subkey = nullSymbol 107 } 108 109 parse (data) { 110 const ENTRY_START = /^[^\s].*:$/ 111 const SUBKEY = /^ {2}[^\s]+:$/ 112 const SUBVAL = /^ {4}[^\s]+ .+$/ 113 const METADATA = /^ {2}[^\s]+ .+$/ 114 this.entries = new Map() 115 this.current = null 116 const linere = /([^\r\n]*)\r?\n/gm 117 let match 118 let lineNum = 0 119 if (!/\n$/.test(data)) { 120 data += '\n' 121 } 122 while (match = linere.exec(data)) { 123 const line = match[1] 124 lineNum++ 125 if (line.charAt(0) === '#') { 126 continue 127 } 128 if (line === '') { 129 this.endCurrent() 130 continue 131 } 132 if (ENTRY_START.test(line)) { 133 this.endCurrent() 134 const specs = this.splitQuoted(line.slice(0, -1), /, */) 135 this.current = new YarnLockEntry(specs) 136 specs.forEach(spec => this.entries.set(spec, this.current)) 137 continue 138 } 139 if (SUBKEY.test(line)) { 140 this.subkey = line.slice(2, -1) 141 this.current[this.subkey] = {} 142 continue 143 } 144 if (SUBVAL.test(line) && this.current && this.current[this.subkey]) { 145 const subval = this.splitQuoted(line.trimLeft(), ' ') 146 if (subval.length === 2) { 147 this.current[this.subkey][subval[0]] = subval[1] 148 continue 149 } 150 } 151 // any other metadata 152 if (METADATA.test(line) && this.current) { 153 const metadata = this.splitQuoted(line.trimLeft(), ' ') 154 if (metadata.length === 2) { 155 // strip off the legacy shasum hashes 156 if (metadata[0] === 'resolved') { 157 metadata[1] = metadata[1].replace(/#.*/, '') 158 } 159 this.current[metadata[0]] = metadata[1] 160 continue 161 } 162 } 163 164 throw Object.assign(new Error('invalid or corrupted yarn.lock file'), { 165 position: match.index, 166 content: match[0], 167 line: lineNum, 168 }) 169 } 170 this.endCurrent() 171 return this 172 } 173 174 splitQuoted (str, delim) { 175 // a,"b,c",d"e,f => ['a','"b','c"','d"e','f'] => ['a','b,c','d"e','f'] 176 const split = str.split(delim) 177 const out = [] 178 let o = 0 179 for (let i = 0; i < split.length; i++) { 180 const chunk = split[i] 181 if (/^".*"$/.test(chunk)) { 182 out[o++] = chunk.trim().slice(1, -1) 183 } else if (/^"/.test(chunk)) { 184 let collect = chunk.trimLeft().slice(1) 185 while (++i < split.length) { 186 const n = split[i] 187 // something that is not a slash, followed by an even number 188 // of slashes then a " then end => ending on an unescaped " 189 if (/[^\\](\\\\)*"$/.test(n)) { 190 collect += n.trimRight().slice(0, -1) 191 break 192 } else { 193 collect += n 194 } 195 } 196 out[o++] = collect 197 } else { 198 out[o++] = chunk.trim() 199 } 200 } 201 return out 202 } 203 204 toString () { 205 return prefix + [...new Set([...this.entries.values()])] 206 .map(e => e.toString()) 207 .sort((a, b) => localeCompare(a.replace(/"/g, ''), b.replace(/"/g, ''))).join('\n\n') + '\n' 208 } 209 210 fromTree (tree) { 211 this.entries = new Map() 212 // walk the tree in a deterministic order, breadth-first, alphabetical 213 breadth({ 214 tree, 215 visit: node => this.addEntryFromNode(node), 216 getChildren: node => [...node.children.values(), ...node.fsChildren] 217 .sort((a, b) => a.depth - b.depth || localeCompare(a.name, b.name)), 218 }) 219 return this 220 } 221 222 addEntryFromNode (node) { 223 const specs = [...node.edgesIn] 224 .map(e => `${node.name}@${e.spec}`) 225 .sort(localeCompare) 226 227 // Note: 228 // yarn will do excessive duplication in a case like this: 229 // root -> (x@1.x, y@1.x, z@1.x) 230 // y@1.x -> (x@1.1, z@2.x) 231 // z@1.x -> () 232 // z@2.x -> (x@1.x) 233 // 234 // where x@1.2 exists, because the "x@1.x" spec will *always* resolve 235 // to x@1.2, which doesn't work for y's dep on x@1.1, so you'll get this: 236 // 237 // root 238 // +-- x@1.2.0 239 // +-- y 240 // | +-- x@1.1.0 241 // | +-- z@2 242 // | +-- x@1.2.0 243 // +-- z@1 244 // 245 // instead of this more deduped tree that arborist builds by default: 246 // 247 // root 248 // +-- x@1.2.0 (dep is x@1.x, from root) 249 // +-- y 250 // | +-- x@1.1.0 251 // | +-- z@2 (dep on x@1.x deduped to x@1.1.0 under y) 252 // +-- z@1 253 // 254 // In order to not create an invalid yarn.lock file with conflicting 255 // entries, AND not tell yarn to create an invalid tree, we need to 256 // ignore the x@1.x spec coming from z, since it's already in the entries. 257 // 258 // So, if the integrity and resolved don't match a previous entry, skip it. 259 // We call this method on shallower nodes first, so this is fine. 260 const n = this.entryDataFromNode(node) 261 let priorEntry = null 262 const newSpecs = [] 263 for (const s of specs) { 264 const prev = this.entries.get(s) 265 // no previous entry for this spec at all, so it's new 266 if (!prev) { 267 // if we saw a match already, then assign this spec to it as well 268 if (priorEntry) { 269 priorEntry.addSpec(s) 270 } else { 271 newSpecs.push(s) 272 } 273 continue 274 } 275 276 const m = match(prev, n) 277 // there was a prior entry, but a different thing. skip this one 278 if (!m) { 279 continue 280 } 281 282 // previous matches, but first time seeing it, so already has this spec. 283 // go ahead and add all the previously unseen specs, though 284 if (!priorEntry) { 285 priorEntry = prev 286 for (const s of newSpecs) { 287 priorEntry.addSpec(s) 288 this.entries.set(s, priorEntry) 289 } 290 newSpecs.length = 0 291 continue 292 } 293 294 // have a prior entry matching n, and matching the prev we just saw 295 // add the spec to it 296 priorEntry.addSpec(s) 297 this.entries.set(s, priorEntry) 298 } 299 300 // if we never found a matching prior, then this is a whole new thing 301 if (!priorEntry) { 302 const entry = Object.assign(new YarnLockEntry(newSpecs), n) 303 for (const s of newSpecs) { 304 this.entries.set(s, entry) 305 } 306 } else { 307 // pick up any new info that we got for this node, so that we can 308 // decorate with integrity/resolved/etc. 309 Object.assign(priorEntry, n) 310 } 311 } 312 313 entryDataFromNode (node) { 314 const n = {} 315 if (node.package.dependencies) { 316 n.dependencies = node.package.dependencies 317 } 318 if (node.package.optionalDependencies) { 319 n.optionalDependencies = node.package.optionalDependencies 320 } 321 if (node.version) { 322 n.version = node.version 323 } 324 if (node.resolved) { 325 n.resolved = consistentResolve( 326 node.resolved, 327 node.isLink ? dirname(node.path) : node.path, 328 node.root.path, 329 true 330 ) 331 } 332 if (node.integrity) { 333 n.integrity = node.integrity 334 } 335 336 return n 337 } 338 339 static get Entry () { 340 return YarnLockEntry 341 } 342} 343 344const _specs = Symbol('_specs') 345class YarnLockEntry { 346 constructor (specs) { 347 this[_specs] = new Set(specs) 348 this.resolved = null 349 this.version = null 350 this.integrity = null 351 this.dependencies = null 352 this.optionalDependencies = null 353 } 354 355 toString () { 356 // sort objects to the bottom, then alphabetical 357 return ([...this[_specs]] 358 .sort(localeCompare) 359 .map(quoteIfNeeded).join(', ') + 360 ':\n' + 361 Object.getOwnPropertyNames(this) 362 .filter(prop => this[prop] !== null) 363 .sort(priorityThenLocaleCompare) 364 .map(prop => 365 typeof this[prop] !== 'object' 366 ? ` ${prop} ${prop === 'integrity' ? this[prop] : JSON.stringify(this[prop])}\n` 367 : Object.keys(this[prop]).length === 0 ? '' 368 : ` ${prop}:\n` + sortKV(this[prop]) + '\n') 369 .join('')).trim() 370 } 371 372 addSpec (spec) { 373 this[_specs].add(spec) 374 } 375} 376 377module.exports = YarnLock 378