Skip to content

Commit 1b02b47

Browse files
hnshahzkochan
andauthored
fix: remove macOS Gatekeeper quarantine xattr from native binaries (#11095)
Fixes #11056 ## Problem On macOS, pnpm imports files from its content-addressable store into `node_modules` via copy, reflink/clone, or hardlink. All three preserve extended attributes, including `com.apple.quarantine`. If a store blob carries that xattr — e.g. it was first written under a Gatekeeper-enabled app such as a Git client (`LSFileQuarantineEnabled=YES`) — the quarantine propagates into `node_modules`. Gatekeeper then blocks ad-hoc-signed native binaries (`.node`, `.dylib`, `.so`) from loading, even though pnpm has already verified each file's integrity against `pnpm-lock.yaml`. ## Solution After importing a package from the store, strip `com.apple.quarantine` from its native binaries — mirroring Homebrew's behaviour of dropping quarantine from downloads after checksum verification. --------- Co-authored-by: Zoltan Kochan <z@kochan.io>
1 parent 268c97d commit 1b02b47

9 files changed

Lines changed: 518 additions & 3 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@pnpm/fs.indexed-pkg-importer": patch
3+
"pnpm": patch
4+
---
5+
6+
Fix macOS Gatekeeper blocking native binaries (`.node`, `.dylib`, `.so`) by removing the `com.apple.quarantine` extended attribute after importing them from the store.
7+
8+
When pnpm imports files from its content-addressable store into `node_modules`, macOS preserves extended attributes, including `com.apple.quarantine`. If this xattr is present on a store blob (e.g. it was first written under a Gatekeeper-enabled app such as a Git client), it propagates to `node_modules`, and Gatekeeper blocks the native binary from loading even though pnpm already verified the file's integrity against the lockfile.
9+
10+
After importing a package, pnpm now strips `com.apple.quarantine` from its native binaries, matching Homebrew's behaviour of dropping quarantine from verified downloads. The cleanup is macOS-only, runs in a single batched `xattr` call per package, is restricted to native binaries (other files are untouched), and is non-fatal (it logs a warning on unexpected errors).
11+
12+
Fixes #11056

cspell.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,8 @@
417417
"worktree",
418418
"worktrees",
419419
"wrappy",
420+
"xattr",
421+
"xattrs",
420422
"xmarw",
421423
"yazl",
422424
"zkochan",

fs/indexed-pkg-importer/src/index.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { fastPathTemp as pathTemp } from 'path-temp'
1111
import { renameOverwriteSync } from 'rename-overwrite'
1212

1313
import { type Importer, type ImportFile, importIndexedDir } from './importIndexedDir.js'
14+
import { isNativeBinary, removeQuarantine } from './removeQuarantine.js'
1415

1516
export { type FilesMap, type ImportIndexedPackage, type ImportOptions }
1617

@@ -135,6 +136,7 @@ function tryClonePkg (
135136
if (opts.resolvedFrom !== 'store' || opts.force || !pkgExistsAtTargetDir(to, opts.filesMap)) {
136137
const clone = createCloneFunction()
137138
importIndexedDir({ importFile: clone, importFileAtomic: clone }, to, opts.filesMap, opts)
139+
removeQuarantineFromNativeBinaries(to, opts)
138140
return 'clone'
139141
}
140142
return undefined
@@ -168,6 +170,7 @@ function createClonePkg (): ImportIndexedPackage {
168170
return (to: string, opts: ImportOptions) => {
169171
if (opts.resolvedFrom !== 'store' || opts.force || !pkgExistsAtTargetDir(to, opts.filesMap)) {
170172
importIndexedDir(importer, to, opts.filesMap, opts)
173+
removeQuarantineFromNativeBinaries(to, opts)
171174
return 'clone'
172175
}
173176
return undefined
@@ -229,6 +232,7 @@ function hardlinkPkg (
229232
): 'hardlink' | undefined {
230233
if (opts.force || shouldRelinkPkg(to, opts)) {
231234
importIndexedDir({ importFile, importFileAtomic: importFile }, to, opts.filesMap, opts)
235+
removeQuarantineFromNativeBinaries(to, opts)
232236
return 'hardlink'
233237
}
234238
return undefined
@@ -303,6 +307,7 @@ export function copyPkg (
303307
// can leave a partially-written file. package.json is the completion
304308
// marker, so it must be written atomically via temp file + rename.
305309
importIndexedDir({ importFile: resilientCopyFileSync, importFileAtomic: atomicCopyFileSync }, to, opts.filesMap, opts)
310+
removeQuarantineFromNativeBinaries(to, opts)
306311
return 'copy'
307312
}
308313
return undefined
@@ -320,3 +325,21 @@ function atomicCopyFileSync (src: string, dest: string): void {
320325
}
321326
renameOverwriteSync(tmp, dest)
322327
}
328+
329+
// macOS preserves the com.apple.quarantine xattr when files are copied or
330+
// reflinked out of the store, and for hardlinks the imported file shares the
331+
// store blob's inode (and thus its xattrs). Either way Gatekeeper can block a
332+
// native binary from loading. Drop the quarantine once, in a single batched
333+
// `xattr` call per package, restricted to the few native binaries that
334+
// Gatekeeper actually guards. Only store imports are cleaned: that's where the
335+
// quarantine propagation happens and where pnpm has verified file integrity.
336+
function removeQuarantineFromNativeBinaries (to: string, opts: ImportOptions): void {
337+
if (process.platform !== 'darwin' || opts.resolvedFrom !== 'store') return
338+
const nativeBinaries: string[] = []
339+
for (const file of opts.filesMap.keys()) {
340+
if (isNativeBinary(file)) {
341+
nativeBinaries.push(path.join(to, file))
342+
}
343+
}
344+
removeQuarantine(nativeBinaries)
345+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { execFileSync } from 'node:child_process'
2+
import path from 'node:path'
3+
4+
import { globalWarn } from '@pnpm/logger'
5+
6+
const QUARANTINE_ATTR = 'com.apple.quarantine'
7+
// Quarantine removal only ever runs on macOS, so the set lists the binary
8+
// formats Gatekeeper guards there (.dll is Windows-only and never matches).
9+
const NATIVE_BINARY_EXTENSIONS = new Set(['.node', '.dylib', '.so'])
10+
// Cap the bytes of file-path arguments per `xattr` call so a package with many
11+
// (or very long) native-binary paths can't blow past the OS argv limit
12+
// (ARG_MAX, ~1 MB on macOS). Well under the limit to leave room for argv0 and
13+
// the environment block.
14+
const MAX_ARG_BYTES = 100_000
15+
16+
/**
17+
* Native binaries are the only files that macOS Gatekeeper blocks for carrying a
18+
* quarantine xattr, so removing it from anything else (JavaScript, text, etc.)
19+
* just wastes a syscall.
20+
*/
21+
export function isNativeBinary (filePath: string): boolean {
22+
return NATIVE_BINARY_EXTENSIONS.has(path.extname(filePath).toLowerCase())
23+
}
24+
25+
/**
26+
* Remove the macOS Gatekeeper quarantine xattr (com.apple.quarantine) from the
27+
* given files using a single `xattr` invocation.
28+
*
29+
* macOS preserves extended attributes when pnpm copies or reflinks files out of
30+
* its content-addressable store. If a store blob carries com.apple.quarantine
31+
* (e.g. it was first written under a Gatekeeper-enabled app such as a Git
32+
* client), the quarantine propagates to node_modules and Gatekeeper blocks the
33+
* native binary from loading, even though pnpm already verified the file's
34+
* integrity against the lockfile. This mirrors Homebrew's behaviour of dropping
35+
* quarantine from verified downloads.
36+
*
37+
* File paths are passed as separate arguments rather than interpolated into a
38+
* shell command, so package-controlled filenames cannot inject shell commands.
39+
* They are split into chunks that stay under the OS argv limit.
40+
*/
41+
export function removeQuarantine (filePaths: string[]): void {
42+
if (process.platform !== 'darwin') return
43+
for (const chunk of chunkByArgSize(filePaths)) {
44+
removeQuarantineFromChunk(chunk)
45+
}
46+
}
47+
48+
function removeQuarantineFromChunk (filePaths: string[]): void {
49+
try {
50+
execFileSync('/usr/bin/xattr', ['-d', QUARANTINE_ATTR, ...filePaths], {
51+
stdio: ['ignore', 'ignore', 'pipe'],
52+
})
53+
} catch (err: unknown) {
54+
// `xattr -d` exits non-zero when a file simply has no quarantine xattr to
55+
// remove ("No such xattr"), which is the common, expected case. It also
56+
// reports "No such file" for entries the importer legitimately dropped or
57+
// renamed (e.g. case-insensitive filename conflicts). Surface only errors
58+
// that are not of those kinds (e.g. permission denied).
59+
const realErrors = getStderr(err)
60+
.split('\n')
61+
.filter((line) => line.trim() !== '' && !line.includes('No such xattr') && !line.includes('No such file'))
62+
if (realErrors.length > 0) {
63+
globalWarn(`Failed to remove the macOS quarantine attribute:\n${realErrors.join('\n')}`)
64+
}
65+
}
66+
}
67+
68+
function chunkByArgSize (filePaths: string[]): string[][] {
69+
const chunks: string[][] = []
70+
let chunk: string[] = []
71+
let chunkBytes = 0
72+
for (const filePath of filePaths) {
73+
const bytes = Buffer.byteLength(filePath) + 1 // +1 for the argv null terminator
74+
if (chunk.length > 0 && chunkBytes + bytes > MAX_ARG_BYTES) {
75+
chunks.push(chunk)
76+
chunk = []
77+
chunkBytes = 0
78+
}
79+
chunk.push(filePath)
80+
chunkBytes += bytes
81+
}
82+
if (chunk.length > 0) chunks.push(chunk)
83+
return chunks
84+
}
85+
86+
function getStderr (err: unknown): string {
87+
if (typeof err === 'object' && err !== null && 'stderr' in err) {
88+
const stderr = (err as { stderr?: Buffer | string }).stderr
89+
if (stderr != null) return stderr.toString()
90+
}
91+
return err instanceof Error ? err.message : String(err)
92+
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { execFileSync } from 'node:child_process'
2+
import fs from 'node:fs'
3+
import os from 'node:os'
4+
import path from 'node:path'
5+
6+
import { afterEach, beforeEach, describe, expect, it, jest, test } from '@jest/globals'
7+
8+
const globalWarn = jest.fn()
9+
jest.unstable_mockModule('@pnpm/logger', () => ({ globalWarn }))
10+
11+
const { isNativeBinary, removeQuarantine } = await import('../src/removeQuarantine.js')
12+
13+
// Quarantine xattrs only exist on macOS, so these tests are scoped to it.
14+
const describeOnMacOS = process.platform === 'darwin' ? describe : describe.skip
15+
16+
const QUARANTINE_ATTR = 'com.apple.quarantine'
17+
18+
function setQuarantine (filePath: string): void {
19+
execFileSync('/usr/bin/xattr', ['-w', QUARANTINE_ATTR, '0083;00000000;TestApp;', filePath])
20+
}
21+
22+
function listXattrs (filePath: string): string {
23+
return execFileSync('/usr/bin/xattr', ['-l', filePath], { encoding: 'utf8' })
24+
}
25+
26+
function hasQuarantine (filePath: string): boolean {
27+
return listXattrs(filePath).includes(QUARANTINE_ATTR)
28+
}
29+
30+
test('isNativeBinary matches only native binary extensions handled on macOS', () => {
31+
expect(isNativeBinary('rollup.darwin-arm64.node')).toBe(true)
32+
expect(isNativeBinary('addon.DYLIB')).toBe(true)
33+
expect(isNativeBinary('addon.so')).toBe(true)
34+
expect(isNativeBinary('index.js')).toBe(false)
35+
expect(isNativeBinary('package.json')).toBe(false)
36+
expect(isNativeBinary('README')).toBe(false)
37+
// .dll is Windows-only and never relevant on macOS.
38+
expect(isNativeBinary('addon.dll')).toBe(false)
39+
})
40+
41+
describeOnMacOS('removeQuarantine', () => {
42+
let testDir: string
43+
44+
beforeEach(() => {
45+
globalWarn.mockClear()
46+
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'quarantine-test-'))
47+
})
48+
49+
afterEach(() => {
50+
fs.rmSync(testDir, { recursive: true, force: true })
51+
})
52+
53+
it('removes the quarantine xattr from a file', () => {
54+
const file = path.join(testDir, 'addon.node')
55+
fs.writeFileSync(file, 'test content')
56+
setQuarantine(file)
57+
expect(hasQuarantine(file)).toBe(true)
58+
59+
removeQuarantine([file])
60+
61+
expect(hasQuarantine(file)).toBe(false)
62+
expect(globalWarn).not.toHaveBeenCalled()
63+
})
64+
65+
it('does nothing when the quarantine xattr is absent', () => {
66+
const file = path.join(testDir, 'addon.node')
67+
fs.writeFileSync(file, 'test content')
68+
expect(hasQuarantine(file)).toBe(false)
69+
70+
expect(() => removeQuarantine([file])).not.toThrow()
71+
expect(globalWarn).not.toHaveBeenCalled()
72+
})
73+
74+
it('removes quarantine from a batch of files while preserving other xattrs', () => {
75+
const quarantined = path.join(testDir, 'a.node')
76+
const clean = path.join(testDir, 'b.node')
77+
fs.writeFileSync(quarantined, 'a')
78+
fs.writeFileSync(clean, 'b')
79+
setQuarantine(quarantined)
80+
execFileSync('/usr/bin/xattr', ['-w', 'com.example.custom', 'keep', quarantined])
81+
82+
removeQuarantine([quarantined, clean])
83+
84+
expect(hasQuarantine(quarantined)).toBe(false)
85+
expect(listXattrs(quarantined)).toContain('com.example.custom')
86+
expect(globalWarn).not.toHaveBeenCalled()
87+
})
88+
89+
it('does not warn for missing files (dropped/renamed by the importer)', () => {
90+
const quarantined = path.join(testDir, 'real.node')
91+
const missing = path.join(testDir, 'dropped.node')
92+
fs.writeFileSync(quarantined, 'a')
93+
setQuarantine(quarantined)
94+
95+
removeQuarantine([missing, quarantined])
96+
97+
expect(hasQuarantine(quarantined)).toBe(false)
98+
expect(globalWarn).not.toHaveBeenCalled()
99+
})
100+
101+
it('does not throw when given an empty list', () => {
102+
expect(() => removeQuarantine([])).not.toThrow()
103+
})
104+
})

pacquet/crates/package-manager/src/import_indexed_dir.rs

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
use crate::{LinkFileError, import_into_fresh_target};
1+
use crate::{
2+
LinkFileError, import_into_fresh_target,
3+
remove_quarantine::remove_quarantine_from_native_binaries,
4+
};
25
use derive_more::{Display, Error};
36
use miette::Diagnostic;
47
use pacquet_config::PackageImportMethod;
@@ -145,10 +148,17 @@ pub fn import_indexed_dir<Reporter: self::Reporter>(
145148
}
146149
};
147150

151+
// Drop the macOS quarantine xattr from the package's native binaries after
152+
// a populating import, matching pnpm's `removeQuarantineFromNativeBinaries`.
153+
// The marker-present short-circuit (and the non-directory dirent left as-is)
154+
// import nothing, so they skip the sweep, keeping warm installs free of the
155+
// per-install `xattr` cost — exactly pnpm's `!pkgExistsAtTargetDir` gate.
156+
let unquarantine = || remove_quarantine_from_native_binaries(dir_path, cas_paths);
148157
match (existing_kind, opts.force) {
149158
// Fresh target — populate it. Both linkers take this path on
150159
// first install.
151-
(None, _) => populate_dir::<Reporter>(logged_methods, import_method, dir_path, cas_paths),
160+
(None, _) => populate_dir::<Reporter>(logged_methods, import_method, dir_path, cas_paths)
161+
.inspect(|()| unquarantine()),
152162
// Short-circuit only when the completion marker is present
153163
// (pnpm's `pkgExistsAtTargetDir`, which checks `package.json`),
154164
// not on mere directory existence. A marker-less directory is a
@@ -159,6 +169,7 @@ pub fn import_indexed_dir<Reporter: self::Reporter>(
159169
Ok(())
160170
} else {
161171
populate_dir::<Reporter>(logged_methods, import_method, dir_path, cas_paths)
172+
.inspect(|()| unquarantine())
162173
}
163174
}
164175
// A non-directory dirent is left as-is; only force=true clobbers it.
@@ -171,6 +182,7 @@ pub fn import_indexed_dir<Reporter: self::Reporter>(
171182
ImportIndexedDirError::ClearNonDirEntry { path: dir_path.to_path_buf(), error }
172183
})?;
173184
populate_dir::<Reporter>(logged_methods, import_method, dir_path, cas_paths)
185+
.inspect(|()| unquarantine())
174186
}
175187
// Existing directory with force=true — stage and swap.
176188
(Some(_), true) => stage_and_swap::<Reporter>(
@@ -179,7 +191,8 @@ pub fn import_indexed_dir<Reporter: self::Reporter>(
179191
dir_path,
180192
cas_paths,
181193
opts.keep_modules_dir,
182-
),
194+
)
195+
.inspect(|()| unquarantine()),
183196
}
184197
}
185198

pacquet/crates/package-manager/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ mod package_extender;
3333
mod prefetching_resolver;
3434
mod prune_virtual_store;
3535
mod remove;
36+
mod remove_quarantine;
3637
mod resolution_observer;
3738
mod resolution_policy;
3839
mod retry_config;

0 commit comments

Comments
 (0)