Skip to content

Commit 1f4473a

Browse files
committed
Pack and unpack preserving exec perms on all package bins
This addresses the problem brought up by @boneskull on isaacs/node-tar#210 (comment) If a package is created on a Windows system, the file will not have the executable bit set, as Windows determines executable status by the filename extension. Thus, installing with '--no-bin-links' produces a package that can't be used as expected. This change does the following: - While extracting, if the manifest has been loaded, then the mode of any bin targets is made executable. - If the manifest is not loaded, then AFTER extraction, the mode of all bin targets is set appropriately. (Only relevant for the FileFetcher class, as the others will all have access to the manifest earlier in the process.) - When packing, all bin targets are given an executable mode in the archive. Thus, newer npm will properly handle archives created without proper modes, and will always produce proper modes for the benefit of other/older clients regardless of what fs.stat says. Strict in what we publish, liberal in what we install.
1 parent 347c563 commit 1f4473a

23 files changed

+228
-18
lines changed

lib/dir.js

+27-10
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@ const Minipass = require('minipass')
55
const { promisify } = require('util')
66
const readPackageJson = promisify(require('read-package-json'))
77
const npm = require('./util/npm.js')
8+
const isPackageBin = require('./util/is-package-bin.js')
89
const packlist = require('npm-packlist')
910
const tar = require('tar')
1011
const _prepareDir = Symbol('_prepareDir')
12+
const _tarcOpts = Symbol('_tarcOpts')
1113

1214
const _tarballFromResolved = Symbol.for('pacote.Fetcher._tarballFromResolved')
1315
class DirFetcher extends Fetcher {
@@ -47,20 +49,35 @@ class DirFetcher extends Fetcher {
4749
// pipe to the stream, and proxy errors the chain.
4850
this[_prepareDir]()
4951
.then(() => packlist({ path: this.resolved }))
50-
.then(files => tar.c({
51-
cwd: this.resolved,
52-
prefix: 'package/',
53-
portable: true,
54-
gzip: true,
55-
56-
// Provide a specific date in the 1980s for the benefit of zip,
57-
// which is confounded by files dated at the Unix epoch 0.
58-
mtime: new Date('1985-10-26T08:15:00.000Z'),
59-
}, files).on('error', er => stream.emit('error', er)).pipe(stream))
52+
.then(files => tar.c(this[_tarcOpts](), files)
53+
.on('error', er => stream.emit('error', er)).pipe(stream))
6054
.catch(er => stream.emit('error', er))
6155
return stream
6256
}
6357

58+
[_tarcOpts] () {
59+
return {
60+
cwd: this.resolved,
61+
prefix: 'package/',
62+
portable: true,
63+
gzip: true,
64+
65+
// ensure that package bins are always executable
66+
// Note that npm-packlist is already filtering out
67+
// anything that is not a regular file, ignored by
68+
// .npmignore or package.json "files", etc.
69+
filter: (path, stat) => {
70+
if (isPackageBin(this.package, path))
71+
stat.mode |= 0o111
72+
return true
73+
},
74+
75+
// Provide a specific date in the 1980s for the benefit of zip,
76+
// which is confounded by files dated at the Unix epoch 0.
77+
mtime: new Date('1985-10-26T08:15:00.000Z'),
78+
}
79+
}
80+
6481
manifest () {
6582
if (this.package)
6683
return Promise.resolve(this.package)

lib/fetcher.js

+11-7
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ const retry = require('promise-retry')
1414
const fsm = require('fs-minipass')
1515
const cacache = require('cacache')
1616
const osenv = require('osenv')
17+
const isPackageBin = require('./util/is-package-bin.js')
1718

1819
// we only change ownership on unix platforms, and only if uid is 0
1920
const selfOwner = process.getuid && process.getuid() === 0 ? {
@@ -333,25 +334,28 @@ class FetcherBase {
333334

334335
// always ensure that entries are at least as permissive as our configured
335336
// dmode/fmode, but never more permissive than the umask allows.
336-
[_entryMode] (mode, type) {
337-
const m = type === 'Directory' ? this.dmode
338-
: type === 'File' ? this.fmode
337+
[_entryMode] (path, mode, type) {
338+
const m = /Directory|GNUDumpDir/.test(type) ? this.dmode
339+
: /File$/.test(type) ? this.fmode
339340
: /* istanbul ignore next - should never happen in a pkg */ 0
340-
return (mode | m) & ~this.umask
341+
342+
// make sure package bins are executable
343+
const exe = isPackageBin(this.package, path) ? 0o111 : 0
344+
return ((mode | m) & ~this.umask) | exe
341345
}
342346

343347
[_tarxOptions] ({ cwd, uid, gid }) {
344348
const sawIgnores = new Set()
345349
return {
346350
cwd,
347351
filter: (name, entry) => {
348-
if (/^.*link$/i.test(entry.type))
352+
if (/Link$/.test(entry.type))
349353
return false
350-
entry.mode = this[_entryMode](entry.mode, entry.type)
354+
entry.mode = this[_entryMode](entry.path, entry.mode, entry.type)
351355
// this replicates the npm pack behavior where .gitignore files
352356
// are treated like .npmignore files, but only if a .npmignore
353357
// file is not present.
354-
if (entry.type === 'File') {
358+
if (/File$/.test(entry.type)) {
355359
const base = basename(entry.path)
356360
if (base === '.npmignore')
357361
sawIgnores.add(entry.path)

lib/file.js

+33
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ const cacache = require('cacache')
44
const { promisify } = require('util')
55
const readPackageJson = promisify(require('read-package-json'))
66
const _tarballFromResolved = Symbol.for('pacote.Fetcher._tarballFromResolved')
7+
const _exeBins = Symbol('_exeBins')
8+
const { resolve } = require('path')
9+
const fs = require('fs')
710

811
class FileFetcher extends Fetcher {
912
constructor (spec, opts) {
@@ -31,6 +34,36 @@ class FileFetcher extends Fetcher {
3134
}))
3235
}
3336

37+
[_exeBins] (pkg, dest) {
38+
if (!pkg.bin)
39+
return Promise.resolve()
40+
41+
return Promise.all(Object.keys(pkg.bin).map(k => new Promise(res => {
42+
const script = resolve(dest, pkg.bin[k])
43+
// Best effort. Ignore errors here, the only result is that
44+
// a bin script is not executable. But if it's missing or
45+
// something, we just leave it for a later stage to trip over
46+
// when we can provide a more useful contextual error.
47+
fs.stat(script, (er, st) => {
48+
if (er)
49+
return res()
50+
const mode = st.mode | 0o111
51+
if (mode === st.mode)
52+
return res()
53+
fs.chmod(script, mode, res)
54+
})
55+
})))
56+
}
57+
58+
extract (dest) {
59+
// if we've already loaded the manifest, then the super got it.
60+
// but if not, read the unpacked manifest and chmod properly.
61+
return super.extract(dest)
62+
.then(result => this.package ? result
63+
: readPackageJson(dest + '/package.json').then(pkg =>
64+
this[_exeBins](pkg, dest)).then(() => result))
65+
}
66+
3467
[_tarballFromResolved] () {
3568
// create a read stream and return it
3669
return new fsm.ReadStream(this.resolved)

lib/util/is-package-bin.js

+24
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
// Function to determine whether a path is in the package.bin set.
2+
// Used to prevent issues when people publish a package from a
3+
// windows machine, and then install with --no-bin-links.
4+
//
5+
// Note: this is not possible in remote or file fetchers, since
6+
// we don't have the manifest until AFTER we've unpacked. But the
7+
// main use case is registry fetching with git a distant second,
8+
// so that's an acceptable edge case to not handle.
9+
10+
const binObj = (name, bin) =>
11+
typeof bin === 'string' ? { [name]: bin } : bin
12+
13+
const hasBin = (pkg, path) => {
14+
const bin = binObj(pkg.name, pkg.bin)
15+
const p = path.replace(/^[^\\\/]*\//, '')
16+
for (const [k, v] of Object.entries(bin)) {
17+
if (v === p)
18+
return true
19+
}
20+
return false
21+
}
22+
23+
module.exports = (pkg, path) =>
24+
pkg && pkg.bin ? hasBin(pkg, path) : false

tap-snapshots/test-dir.js-TAP.test.js

+7
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,13 @@ Object {
165165
}
166166
`
167167

168+
exports[`test/dir.js TAP make bins executable > results of unpack 1`] = `
169+
Object {
170+
"integrity": "sha512-rlE32nBV7XgKCm0I7YqAewyVPbaRJWUQMZUFLlngGK3imG+som3Hin7d/zPTikWg64tHIxb8VXeeq6u0IRRfmQ==",
171+
"resolved": "\${CWD}/test/fixtures/bin-object",
172+
}
173+
`
174+
168175
exports[`test/dir.js TAP with prepare script > extract 1`] = `
169176
Object {
170177
"integrity": "sha512-HTzPAt8wmXNchUdisnGDSCuUgrFee5v8F6GsLc5mQd29VXiNzv4PGz71jjLSIF1wWQSB+UjLTmSJSGznF/s/Lw==",
+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
/* IMPORTANT
2+
* This snapshot file is auto-generated, but designed for humans.
3+
* It should be checked into source control and tracked carefully.
4+
* Re-generate by setting TAP_SNAPSHOT=1 and running tests.
5+
* Make sure to inspect the output below. Do not ignore changes!
6+
*/
7+
'use strict'
8+
exports[`test/fetcher.js TAP make bins executable > results of unpack 1`] = `
9+
Object {
10+
"integrity": "sha512-TqzCjecWyQe8vqLbT0nv/OaWf0ptRZ2DnPmiuGUYJJb70shp02+/uu37IJSkM2ZEP1SAOeKrYrWPVIIYW+d//g==",
11+
"resolved": "{CWD}/test/fixtures/bin-object.tgz",
12+
}
13+
`
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
/* IMPORTANT
2+
* This snapshot file is auto-generated, but designed for humans.
3+
* It should be checked into source control and tracked carefully.
4+
* Re-generate by setting TAP_SNAPSHOT=1 and running tests.
5+
* Make sure to inspect the output below. Do not ignore changes!
6+
*/
7+
'use strict'
8+
exports[`test/fetcher.js fake-sudo TAP make bins executable > results of unpack 1`] = `
9+
Object {
10+
"integrity": "sha512-TqzCjecWyQe8vqLbT0nv/OaWf0ptRZ2DnPmiuGUYJJb70shp02+/uu37IJSkM2ZEP1SAOeKrYrWPVIIYW+d//g==",
11+
"resolved": "{CWD}/test/fixtures/bin-object.tgz",
12+
}
13+
`

tap-snapshots/test-file.js-TAP.test.js

+21
Original file line numberDiff line numberDiff line change
@@ -125,3 +125,24 @@ Object {
125125
},
126126
}
127127
`
128+
129+
exports[`test/file.js TAP make bins executable bin-good > results of unpack 1`] = `
130+
Object {
131+
"integrity": "sha512-Fx11OiHxV82CztnPk+k0S6H/66J4/eUzZEMGX2dJjP+Mxfrm8fSzE4SQG604zWk17ELZsOGENCdWSkvj4cpjUw==",
132+
"resolved": "\${CWD}/test/fixtures/bin-good.tgz",
133+
}
134+
`
135+
136+
exports[`test/file.js TAP make bins executable bin-object > results of unpack 1`] = `
137+
Object {
138+
"integrity": "sha512-TqzCjecWyQe8vqLbT0nv/OaWf0ptRZ2DnPmiuGUYJJb70shp02+/uu37IJSkM2ZEP1SAOeKrYrWPVIIYW+d//g==",
139+
"resolved": "\${CWD}/test/fixtures/bin-object.tgz",
140+
}
141+
`
142+
143+
exports[`test/file.js TAP make bins executable bin-string > results of unpack 1`] = `
144+
Object {
145+
"integrity": "sha512-iCc87DMYVMofO221ksAlMD88Zgsr4OIvqeX73KxTPikWaQPvBFZpzI9FGWnD4PTLTyJzOSETQh86+IwEidJRZg==",
146+
"resolved": "\${CWD}/test/fixtures/bin-string.tgz",
147+
}
148+
`

test/dir.js

+10
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,13 @@ t.test('when read fails', t => {
5959
const f = new DirFetcher(preparespec, {})
6060
return t.rejects(f.extract(me + '/nope'), poop)
6161
})
62+
63+
t.test('make bins executable', async t => {
64+
const file = resolve(__dirname, 'fixtures/bin-object')
65+
const spec = `file:${relative(process.cwd(), file)}`
66+
const f = new DirFetcher(spec, {})
67+
const target = resolve(me, basename(file))
68+
const res = await f.extract(target)
69+
t.matchSnapshot(res, 'results of unpack')
70+
t.equal(fs.statSync(target + '/script.js').mode & 0o111, 0o111)
71+
})

test/fetcher.js

+14-1
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ const { relative, resolve, basename } = require('path')
1818
const me = resolve(__dirname, basename(__filename, '.js'))
1919
const Fetcher = require('../lib/fetcher.js')
2020
const t = require('tap')
21+
t.cleanSnapshot = s => s.split(process.cwd()).join('{CWD}')
2122
if (!fakeSudo)
2223
t.teardown(() => require('rimraf').sync(me))
2324

@@ -378,5 +379,17 @@ if (!fakeSudo) {
378379

379380
t.end()
380381
})
381-
382382
}
383+
384+
t.test('make bins executable', async t => {
385+
const file = resolve(__dirname, 'fixtures/bin-object.tgz')
386+
const spec = `file:${relative(process.cwd(), file)}`
387+
const f = new FileFetcher(spec, {})
388+
// simulate a fetcher that already has a manifest
389+
const manifest = require('./fixtures/bin-object/package.json')
390+
f.package = manifest
391+
const target = resolve(me, basename(file, '.tgz'))
392+
const res = await f.extract(target)
393+
t.matchSnapshot(res, 'results of unpack')
394+
t.equal(fs.statSync(target + '/script.js').mode & 0o111, 0o111)
395+
})

test/file.js

+28
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,31 @@ t.test('basic', async t => {
2626
return t.resolveMatchSnapshot(f.extract(me + '/extract'), 'extract')
2727
.then(() => t.matchSnapshot(require(pj), 'package.json extracted'))
2828
})
29+
30+
const binString = resolve(__dirname, 'fixtures/bin-string.tgz')
31+
const binObject = resolve(__dirname, 'fixtures/bin-object.tgz')
32+
// this one actually doesn't need any help
33+
const binGood = resolve(__dirname, 'fixtures/bin-good.tgz')
34+
35+
t.test('make bins executable', t => {
36+
const fs = require('fs')
37+
const files = [binString, binObject, binGood]
38+
t.plan(files.length)
39+
files.forEach(file => t.test(basename(file, '.tgz'), async t => {
40+
const spec = `file:${relative(process.cwd(), file)}`
41+
const f = new FileFetcher(spec, {})
42+
const target = resolve(me, basename(file, '.tgz'))
43+
const res = await f.extract(target)
44+
t.matchSnapshot(res, 'results of unpack')
45+
t.equal(fs.statSync(target + '/script.js').mode & 0o111, 0o111)
46+
}))
47+
})
48+
49+
t.test('dont bork on missing script', async t => {
50+
const file = resolve(__dirname, 'fixtures/bin-missing.tgz')
51+
const spec = `file:${relative(process.cwd(), file)}`
52+
const f = new FileFetcher(spec, {})
53+
const target = resolve(me, basename(file, '.tgz'))
54+
const res = await f.extract(target)
55+
t.throws(() => fs.statSync(target + '/script.js'), 'should be missing')
56+
})

test/fixtures/bin-good.tgz

354 Bytes
Binary file not shown.

test/fixtures/bin-good/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"name":"bin-object","version":"1.2.3","bin":{"bin-object":"script.js"}}

test/fixtures/bin-good/script.js

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
#!/usr/bin/env node
2+
const fs = require('fs')
3+
const assert = require('assert')
4+
assert.equal(fs.statSync(__filename).mode & 0o111, 0o111)
5+
console.log('ok')

test/fixtures/bin-missing.tgz

224 Bytes
Binary file not shown.
+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"name":"bin-string","version":"1.2.3","bin":"script.js"}

test/fixtures/bin-object.tgz

354 Bytes
Binary file not shown.

test/fixtures/bin-object/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"name":"bin-object","version":"1.2.3","bin":{"bin-object":"script.js"}}

test/fixtures/bin-object/script.js

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
#!/usr/bin/env node
2+
const fs = require('fs')
3+
const assert = require('assert')
4+
assert.equal(fs.statSync(__filename).mode & 0o111, 0o111)
5+
console.log('ok')

test/fixtures/bin-string.tgz

350 Bytes
Binary file not shown.

test/fixtures/bin-string/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"name":"bin-string","version":"1.2.3","bin":"script.js"}

test/fixtures/bin-string/script.js

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
#!/usr/bin/env node
2+
const fs = require('fs')
3+
const assert = require('assert')
4+
assert.equal(fs.statSync(__filename).mode & 0o111, 0o111)
5+
console.log('ok')

test/util/is-package-bin.js

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
const isPackageBin = require('../../lib/util/is-package-bin.js')
2+
const t = require('tap')
3+
4+
t.ok(isPackageBin({bin:'foo'}, 'package/foo'), 'finds string')
5+
t.ok(isPackageBin({bin:{bar:'foo'}}, 'package/foo'), 'finds in obj')
6+
t.notOk(isPackageBin(null, 'anything'), 'return false if pkg is not')
7+
t.notOk(isPackageBin({bin:'foo'}, 'package/bar'), 'not the bin string')
8+
t.notOk(isPackageBin({bin:{bar:'foo'}}, 'package/bar'), 'not in obj')

0 commit comments

Comments
 (0)