codekingpro/portable-devtools
115k
1// This is the base class that the other fetcher types in lib
2// all descend from.
3// It handles the unpacking and retry logic that is shared among
4// all of the other Fetcher types.
5
6const { basename, dirname } = require('node:path')
7const { rm, mkdir } = require('node:fs/promises')
8const PackageJson = require('@npmcli/package-json')
9const cacache = require('cacache')
10const fsm = require('fs-minipass')
11const getContents = require('@npmcli/installed-package-contents')
12const npa = require('npm-package-arg')
13const { promiseRetry } = require('@gar/promise-retry')
14const ssri = require('ssri')
15const tar = require('tar')
16const { Minipass } = require('minipass')
17const { log } = require('proc-log')
18const _ = require('./util/protected.js')
19const cacheDir = require('./util/cache-dir.js')
20const isPackageBin = require('./util/is-package-bin.js')
21const removeTrailingSlashes = require('./util/trailing-slashes.js')
22
23// Pacote is only concerned with the package.json contents
24const packageJsonPrepare = (p) => PackageJson.prepare(p).then(pkg => pkg.content)
25const packageJsonNormalize = (p) => PackageJson.normalize(p).then(pkg => pkg.content)
26
27class FetcherBase {
28 constructor (spec, opts) {
29 if (!opts || typeof opts !== 'object') {
30 throw new TypeError('options object is required')
31 }
32 this.spec = npa(spec, opts.where)
33
34 this.allowGitIgnore = !!opts.allowGitIgnore
35
36 // a bit redundant because presumably the caller already knows this,
37 // but it makes it easier to not have to keep track of the requested
38 // spec when we're dispatching thousands of these at once, and normalizing
39 // is nice. saveSpec is preferred if set, because it turns stuff like
40 // x/y#committish into github:x/y#committish. use name@rawSpec for
41 // registry deps so that we turn xyz and xyz@ -> xyz@
42 this.from = this.spec.registry
43 ? `${this.spec.name}@${this.spec.rawSpec}` : this.spec.saveSpec
44
45 this.#assertType()
46 // clone the opts object so that others aren't upset when we mutate it
47 // by adding/modifying the integrity value.
48 this.opts = { ...opts }
49
50 this.cache = opts.cache || cacheDir().cacache
51 this.tufCache = opts.tufCache || cacheDir().tufcache
52 this.resolved = opts.resolved || null
53
54 // default to caching/verifying with sha512, that's what we usually have
55 // need to change this default, or start overriding it, when sha512
56 // is no longer strong enough.
57 this.defaultIntegrityAlgorithm = opts.defaultIntegrityAlgorithm || 'sha512'
58
59 if (typeof opts.integrity === 'string') {
60 this.opts.integrity = ssri.parse(opts.integrity)
61 }
62
63 this.package = null
64 this.type = this.constructor.name
65 this.fmode = opts.fmode || 0o666
66 this.dmode = opts.dmode || 0o777
67 // we don't need a default umask, because we don't chmod files coming
68 // out of package tarballs. they're forced to have a mode that is
69 // valid, regardless of what's in the tarball entry, and then we let
70 // the process's umask setting do its job. but if configured, we do
71 // respect it.
72 this.umask = opts.umask || 0
73
74 this.preferOnline = !!opts.preferOnline
75 this.preferOffline = !!opts.preferOffline
76 this.offline = !!opts.offline
77
78 this.before = opts.before
79 this.fullMetadata = this.before ? true : !!opts.fullMetadata
80 this.fullReadJson = !!opts.fullReadJson
81 this[_.readPackageJson] = this.fullReadJson
82 ? packageJsonPrepare
83 : packageJsonNormalize
84
85 // rrh is a registry hostname or 'never' or 'always'
86 // defaults to registry.npmjs.org
87 this.replaceRegistryHost = (!opts.replaceRegistryHost || opts.replaceRegistryHost === 'npmjs') ?
88 'registry.npmjs.org' : opts.replaceRegistryHost
89
90 this.defaultTag = opts.defaultTag || 'latest'
91 this.registry = removeTrailingSlashes(opts.registry || 'https://registry.npmjs.org')
92
93 // command to run 'prepare' scripts on directories and git dirs
94 // To use pacote with yarn, for example, set npmBin to 'yarn'
95 // and npmCliConfig with yarn's equivalents.
96 this.npmBin = opts.npmBin || 'npm'
97
98 // command to install deps for preparing
99 this.npmInstallCmd = opts.npmInstallCmd || ['install', '--force']
100
101 // XXX fill more of this in based on what we know from this.opts
102 // we explicitly DO NOT fill in --tag, though, since we are often
103 // going to be packing in the context of a publish, which may set
104 // a dist-tag, but certainly wants to keep defaulting to latest.
105 this.npmCliConfig = opts.npmCliConfig || [
106 `--cache=${dirname(this.cache)}`,
107 `--prefer-offline=${!!this.preferOffline}`,
108 `--prefer-online=${!!this.preferOnline}`,
109 `--offline=${!!this.offline}`,
110 ...(this.before ? [`--before=${this.before.toISOString()}`] : []),
111 '--no-progress',
112 '--no-save',
113 '--no-audit',
114 // override any omit settings from the environment
115 '--include=dev',
116 '--include=peer',
117 '--include=optional',
118 // we need the actual things, not just the lockfile
119 '--no-package-lock-only',
120 '--no-dry-run',
121 ]
122 }
123
124 get integrity () {
125 return this.opts.integrity || null
126 }
127
128 set integrity (i) {
129 if (!i) {
130 return
131 }
132
133 i = ssri.parse(i)
134 const current = this.opts.integrity
135
136 // do not ever update an existing hash value, but do
137 // merge in NEW algos and hashes that we don't already have.
138 if (current) {
139 current.merge(i)
140 } else {
141 this.opts.integrity = i
142 }
143 }
144
145 get notImplementedError () {
146 return new Error('not implemented in this fetcher type: ' + this.type)
147 }
148
149 // override in child classes
150 // Returns a Promise that resolves to this.resolved string value
151 resolve () {
152 return this.resolved ? Promise.resolve(this.resolved)
153 : Promise.reject(this.notImplementedError)
154 }
155
156 packument () {
157 return Promise.reject(this.notImplementedError)
158 }
159
160 // override in child class
161 // returns a manifest containing:
162 // - name
163 // - version
164 // - _resolved
165 // - _integrity
166 // - plus whatever else was in there (corgi, full metadata, or pj file)
167 manifest () {
168 return Promise.reject(this.notImplementedError)
169 }
170
171 // private, should be overridden.
172 // Note that they should *not* calculate or check integrity or cache,
173 // but *just* return the raw tarball data stream.
174 [_.tarballFromResolved] () {
175 throw this.notImplementedError
176 }
177
178 // public, should not be overridden
179 tarball () {
180 return this.tarballStream(stream => stream.concat().then(data => {
181 data.integrity = this.integrity && String(this.integrity)
182 data.resolved = this.resolved
183 data.from = this.from
184 return data
185 }))
186 }
187
188 // private
189 // Note: cacache will raise a EINTEGRITY error if the integrity doesn't match
190 #tarballFromCache () {
191 const startTime = Date.now()
192 const stream = cacache.get.stream.byDigest(this.cache, this.integrity, this.opts)
193 const elapsedTime = Date.now() - startTime
194 // cache is good, so log it as a hit in particular since there was no fetch logged
195 log.http(
196 'cache',
197 `${this.spec} ${elapsedTime}ms (cache hit)`
198 )
199 return stream
200 }
201
202 get [_.cacheFetches] () {
203 return true
204 }
205
206 #istream (stream) {
207 // if not caching this, just return it
208 if (!this.opts.cache || !this[_.cacheFetches]) {
209 // instead of creating a new integrity stream, we only piggyback on the
210 // provided stream's events
211 if (stream.hasIntegrityEmitter) {
212 stream.on('integrity', i => this.integrity = i)
213 return stream
214 }
215
216 const istream = ssri.integrityStream(this.opts)
217 istream.on('integrity', i => this.integrity = i)
218 stream.on('error', err => istream.emit('error', err))
219 return stream.pipe(istream)
220 }
221
222 // we have to return a stream that gets ALL the data, and proxies errors,
223 // but then pipe from the original tarball stream into the cache as well.
224 // To do this without losing any data, and since the cacache put stream
225 // is not a passthrough, we have to pipe from the original stream into
226 // the cache AFTER we pipe into the middleStream. Since the cache stream
227 // has an asynchronous flush to write its contents to disk, we need to
228 // defer the middleStream end until the cache stream ends.
229 const middleStream = new Minipass()
230 stream.on('error', err => middleStream.emit('error', err))
231 stream.pipe(middleStream, { end: false })
232 const cstream = cacache.put.stream(
233 this.opts.cache,
234 `pacote:tarball:${this.from}`,
235 this.opts
236 )
237 cstream.on('integrity', i => this.integrity = i)
238 cstream.on('error', err => stream.emit('error', err))
239 stream.pipe(cstream)
240
241 // eslint-disable-next-line promise/catch-or-return
242 cstream.promise().catch(() => {}).then(() => middleStream.end())
243 return middleStream
244 }
245
246 pickIntegrityAlgorithm () {
247 return this.integrity ? this.integrity.pickAlgorithm(this.opts)
248 : this.defaultIntegrityAlgorithm
249 }
250
251 // TODO: check error class, once those are rolled out to our deps
252 isDataCorruptionError (er) {
253 return er.code === 'EINTEGRITY' || er.code === 'Z_DATA_ERROR'
254 }
255
256 // override the types getter
257 get types () {
258 return false
259 }
260
261 #assertType () {
262 if (this.types && !this.types.includes(this.spec.type)) {
263 throw new TypeError(`Wrong spec type (${
264 this.spec.type
265 }) for ${
266 this.constructor.name
267 }. Supported types: ${this.types.join(', ')}`)
268 }
269 }
270
271 // We allow ENOENTs from cacache, but not anywhere else.
272 // An ENOENT trying to read a tgz file, for example, is Right Out.
273 isRetriableError (er) {
274 // TODO: check error class, once those are rolled out to our deps
275 return this.isDataCorruptionError(er) ||
276 er.code === 'ENOENT' ||
277 er.code === 'EISDIR'
278 }
279
280 // Mostly internal, but has some uses
281 // Pass in a function which returns a promise
282 // Function will be called 1 or more times with streams that may fail.
283 // Retries:
284 // Function MUST handle errors on the stream by rejecting the promise,
285 // so that retry logic can pick it up and either retry or fail whatever
286 // promise it was making (ie, failing extraction, etc.)
287 //
288 // The return value of this method is a Promise that resolves the same
289 // as whatever the streamHandler resolves to.
290 //
291 // This should never be overridden by child classes, but it is public.
292 tarballStream (streamHandler) {
293 // Only short-circuit via cache if we have everything else we'll need,
294 // and the user has not expressed a preference for checking online.
295
296 const fromCache = (
297 !this.preferOnline &&
298 this.integrity &&
299 this.resolved
300 ) ? streamHandler(this.#tarballFromCache()).catch(er => {
301 if (this.isDataCorruptionError(er)) {
302 log.warn('tarball', `cached data for ${
303 this.spec
304 } (${this.integrity}) seems to be corrupted. Refreshing cache.`)
305 return this.cleanupCached().then(() => {
306 throw er
307 })
308 } else {
309 throw er
310 }
311 }) : null
312
313 const fromResolved = er => {
314 if (er) {
315 if (!this.isRetriableError(er)) {
316 throw er
317 }
318 log.silly('tarball', `no local data for ${
319 this.spec
320 }. Extracting by manifest.`)
321 }
322 return this.resolve().then(() => promiseRetry(tryAgain =>
323 streamHandler(this.#istream(this[_.tarballFromResolved]()))
324 .catch(streamErr => {
325 // Most likely data integrity. A cache ENOENT error is unlikely
326 // here, since we're definitely not reading from the cache, but it
327 // IS possible that the fetch subsystem accessed the cache, and the
328 // entry got blown away or something. Try one more time to be sure.
329 if (this.isRetriableError(streamErr)) {
330 log.warn('tarball', `tarball data for ${
331 this.spec
332 } (${this.integrity}) seems to be corrupted. Trying again.`)
333 return this.cleanupCached().then(() => tryAgain(streamErr))
334 }
335 throw streamErr
336 }), { retries: 1, minTimeout: 0, maxTimeout: 0 }))
337 }
338
339 return fromCache ? fromCache.catch(fromResolved) : fromResolved()
340 }
341
342 cleanupCached () {
343 return cacache.rm.content(this.cache, this.integrity, this.opts)
344 }
345
346 #empty (path) {
347 return getContents({ path, depth: 1 }).then(contents => Promise.all(
348 contents.map(entry => rm(entry, { recursive: true, force: true }))))
349 }
350
351 async #mkdir (dest) {
352 await this.#empty(dest)
353 return await mkdir(dest, { recursive: true })
354 }
355
356 // extraction is always the same. the only difference is where
357 // the tarball comes from.
358 async extract (dest) {
359 await this.#mkdir(dest)
360 return this.tarballStream((tarball) => this.#extract(dest, tarball))
361 }
362
363 #toFile (dest) {
364 return this.tarballStream(str => new Promise((res, rej) => {
365 const writer = new fsm.WriteStream(dest)
366 str.on('error', er => writer.emit('error', er))
367 writer.on('error', er => rej(er))
368 writer.on('close', () => res({
369 integrity: this.integrity && String(this.integrity),
370 resolved: this.resolved,
371 from: this.from,
372 }))
373 str.pipe(writer)
374 }))
375 }
376
377 // don't use this.#mkdir because we don't want to rimraf anything
378 async tarballFile (dest) {
379 const dir = dirname(dest)
380 await mkdir(dir, { recursive: true })
381 return this.#toFile(dest)
382 }
383
384 #extract (dest, tarball) {
385 const extractor = tar.x(this.#tarxOptions({ cwd: dest }))
386 const p = new Promise((resolve, reject) => {
387 extractor.on('end', () => {
388 resolve({
389 resolved: this.resolved,
390 integrity: this.integrity && String(this.integrity),
391 from: this.from,
392 })
393 })
394
395 extractor.on('error', er => {
396 log.warn('tar', er.message)
397 log.silly('tar', er)
398 reject(er)
399 })
400
401 tarball.on('error', er => reject(er))
402 })
403
404 tarball.pipe(extractor)
405 return p
406 }
407
408 // always ensure that entries are at least as permissive as our configured
409 // dmode/fmode, but never more permissive than the umask allows.
410 #entryMode (path, mode, type) {
411 const m = /Directory|GNUDumpDir/.test(type) ? this.dmode
412 : /File$/.test(type) ? this.fmode
413 : /* istanbul ignore next - should never happen in a pkg */ 0
414
415 // make sure package bins are executable
416 const exe = isPackageBin(this.package, path) ? 0o111 : 0
417 // always ensure that files are read/writable by the owner
418 return ((mode | m) & ~this.umask) | exe | 0o600
419 }
420
421 #tarxOptions ({ cwd }) {
422 const sawIgnores = new Set()
423 return {
424 cwd,
425 noChmod: true,
426 noMtime: true,
427 filter: (name, entry) => {
428 if (/Link$/.test(entry.type)) {
429 return false
430 }
431 entry.mode = this.#entryMode(entry.path, entry.mode, entry.type)
432 // this replicates the npm pack behavior where .gitignore files
433 // are treated like .npmignore files, but only if a .npmignore
434 // file is not present.
435 if (/File$/.test(entry.type)) {
436 const base = basename(entry.path)
437 if (base === '.npmignore') {
438 sawIgnores.add(entry.path)
439 } else if (base === '.gitignore' && !this.allowGitIgnore) {
440 // rename, but only if there's not already a .npmignore
441 const ni = entry.path.replace(/\.gitignore$/, '.npmignore')
442 if (sawIgnores.has(ni)) {
443 return false
444 }
445 entry.path = ni
446 }
447 return true
448 }
449 },
450 strip: 1,
451 onwarn: /* istanbul ignore next - we can trust that tar logs */
452 (code, msg, data) => {
453 log.warn('tar', code, msg)
454 log.silly('tar', code, msg, data)
455 },
456 umask: this.umask,
457 // always ignore ownership info from tarball metadata
458 preserveOwner: false,
459 }
460 }
461}
462
463module.exports = FetcherBase
464
465// Child classes
466const GitFetcher = require('./git.js')
467const RegistryFetcher = require('./registry.js')
468const FileFetcher = require('./file.js')
469const DirFetcher = require('./dir.js')
470const RemoteFetcher = require('./remote.js')
471
472// possible values for allow: 'all', 'root', 'none'
473const canUse = ({ allow = 'all', isRoot = false, allowType, spec }) => {
474 if (allow === 'all') {
475 return true
476 }
477 if (allow !== 'none' && isRoot) {
478 return true
479 }
480 throw Object.assign(
481 new Error(`Fetching${allow === 'root' ? ' non-root' : ''} packages of type "${allowType}" have been disabled`),
482 {
483 code: `EALLOW${allowType.toUpperCase()}`,
484 package: spec.toString(),
485 }
486 )
487}
488
489// Get an appropriate fetcher object from a spec and options
490FetcherBase.get = (rawSpec, opts = {}) => {
491 const spec = npa(rawSpec, opts.where)
492 switch (spec.type) {
493 case 'git':
494 canUse({ allow: opts.allowGit, isRoot: opts._isRoot, allowType: 'git', spec })
495 return new GitFetcher(spec, opts)
496
497 case 'remote':
498 canUse({ allow: opts.allowRemote, isRoot: opts._isRoot, allowType: 'remote', spec })
499 return new RemoteFetcher(spec, opts)
500
501 case 'version':
502 case 'range':
503 case 'tag':
504 case 'alias':
505 canUse({ allow: opts.allowRegistry, isRoot: opts._isRoot, allowType: 'registry', spec })
506 return new RegistryFetcher(spec.subSpec || spec, opts)
507
508 case 'file':
509 canUse({ allow: opts.allowFile, isRoot: opts._isRoot, allowType: 'file', spec })
510 return new FileFetcher(spec, opts)
511
512 case 'directory':
513 canUse({ allow: opts.allowDirectory, isRoot: opts._isRoot, allowType: 'directory', spec })
514 return new DirFetcher(spec, opts)
515
516 default:
517 throw new TypeError('Unknown spec type: ' + spec.type)
518 }
519}
520 