Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,30 @@ homebrew_casks:
license: 'MIT'
binaries:
- nodeup
# Strip the macOS Gatekeeper quarantine attribute after install.
#
# Homebrew quarantines every artifact it downloads (Cask::Quarantine
# .cask! in Homebrew's source) and propagates the attribute to the
# files it extracts. nodeup ships as an unsigned Go binary, so a
# quarantined copy cannot be executed at all: macOS's kernel kills
# the process with
#
# ASP: Security policy would not allow process: <pid>, <path>
#
# before main() ever runs — `nodeup version` just dies. Removing
# `com.apple.quarantine` is the remedy Homebrew's own cask guidance
# recommends (and what GoReleaser's cask docs point at).
#
# The hook runs `xattr -dr` on the staged path so it covers the
# extracted binary. `on_macos` keeps it a no-op on Linux. The `run`
# step is not `must_succeed`-critical: if xattr is unavailable the
# install should still complete rather than abort.
hooks:
post:
install_steps: |
on_macos do
run "/usr/bin/xattr", args: ["-dr", "com.apple.quarantine", "{{ .StagedPath }}"], must_succeed: false
end

# Scoop bucket — auto-pushed on every release. Requires SCOOP_BUCKET_TOKEN.
scoops:
Expand Down
41 changes: 39 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [1.2.2] - 2026-11-01

A patch release that **stops the snapshot leak** and gives the user
explicit control over whatever is left on disk.
A patch release that **stops the snapshot leak**, gives the user
explicit control over whatever is left on disk, and **unblocks macOS
Gatekeeper** so the CLI actually runs after installation.

Nothing in nodeup ever deleted a snapshot file. `nodeup upgrade`
writes one snapshot of the global npm packages per installed Node
Expand Down Expand Up @@ -47,6 +48,42 @@ random-suffixed filenames.
file that the snapshot lister silently skipped — an invisible
orphan that could never be cleaned up. Writes go through a `.tmp`
file plus a rename.
- **macOS Gatekeeper could block `nodeup` from running at all.**
macOS stamps files downloaded from the internet with the
`com.apple.quarantine` extended attribute, and Gatekeeper refuses
to execute an unsigned binary that still carries it — the kernel
kills the process *before* `main()` ever runs:

```
ASP: Security policy would not allow process: 82275, /opt/homebrew/Cellar/nodeup/1.2.1/bin/nodeup
```

`nodeup` ships as an unsigned Go binary (no Apple Developer ID
certificate), so this was fatal for it: the command produced no
output and no error, it just died. Homebrew quarantines every
artifact it downloads and propagates the attribute to the files it
extracts, so `brew install dipto0321/tap/nodeup` produced a
`nodeup` that could not be executed.

The fix is at install time, in both download channels:

- The Homebrew cask now carries a `postflight_steps` hook that runs
`xattr -dr com.apple.quarantine` on the staged path, so the
attribute is gone before the binary is linked into the prefix.
- The npm wrapper's `install.js` strips the attribute from the
extracted binary before it is moved into place.

Both paths are best-effort — an install never fails because the
attribute could not be removed — and both are no-ops on platforms
without the attribute. The recovery command for an already-broken
install is `xattr -dr com.apple.quarantine "$(command -v nodeup)"`;
see `docs/installation.md` for the details, including the
`chmod u+w` variant needed when the binary is not writable.

Note that the fix **cannot** live inside the Go binary: a
quarantined executable is killed before its first instruction, so
`main()` never runs and no amount of self-repair code can strip the
attribute. Closes #132.

### Added
- `nodeup packages cleanup` — delete the snapshot and migration-report
Expand Down
57 changes: 57 additions & 0 deletions docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,13 @@ reproducible across CI runs.
script it) when you want a new version. The binary is fully
self-contained — no runtime, no Node, no manager.

**On macOS:** if you downloaded the archive through a browser, the
extracted binary can carry the Gatekeeper quarantine attribute and
macOS will refuse to run it. Strip it with
`xattr -dr com.apple.quarantine nodeup` after extracting. See
[macOS Gatekeeper](#macos-gatekeeper-security-policy-would-not-allow-process)
for the full story.

### From source

```bash
Expand All @@ -165,6 +172,56 @@ nodeup version

Should print a version, git commit, build date, and Go runtime info.

## macOS Gatekeeper ("Security policy would not allow process")

macOS marks files that came from the internet with an extended
attribute called `com.apple.quarantine`. Gatekeeper then refuses to
execute an **unsigned** binary that still carries that attribute —
the kernel kills the process *before* `main()` ever runs, so the
command dies with no output at all:

```
ASP: Security policy would not allow process: 82275, /opt/homebrew/Cellar/nodeup/1.2.1/bin/nodeup
```

`nodeup` ships as an unsigned Go binary (no Apple Developer ID
certificate), so this used to hit anyone who installed it through a
channel that let the attribute survive. As of **v1.2.2** every
channel strips the attribute as part of the install:

| Channel | How the attribute is removed |
|---|---|
| Homebrew | The cask's `postflight_steps` hook runs `xattr -dr com.apple.quarantine` on the staged path after install. |
| npm wrapper | `nodeup-npm/scripts/install.js` strips the attribute from the extracted binary before it is moved into place. |
| Scoop | Windows only — the attribute does not exist. |
| From source | `go install` compiles locally — nothing is downloaded, so nothing is quarantined. |

### If you are already stuck

If `nodeup` dies silently with no output, macOS is blocking it. Strip
the attribute by hand:

```bash
xattr -dr com.apple.quarantine "$(command -v nodeup)"
```

If that reports `Permission denied`, the file is not writable — make
it writable first, then restore the mode:

```bash
BIN="$(command -v nodeup)"
chmod u+w "$BIN" && xattr -d com.apple.quarantine "$BIN" && chmod 555 "$BIN"
```

Reinstalling through Homebrew or npm also fixes it, because both
channels strip the attribute as part of the install.

> **Note:** `tar` and `zip` extraction both *propagate* the quarantine
> attribute to the files they unpack, so a browser-downloaded archive
> can leave the extracted binary quarantined too. That is why the
> direct-binary section above is followed by the `xattr` one-liner
> whenever the download came through a browser.

## Picking once and sticking

You can install via multiple channels at once if you want — they'll
Expand Down
62 changes: 62 additions & 0 deletions nodeup-npm/scripts/install.js
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,64 @@ function chmodExec(filePath) {
fs.chmodSync(filePath, 0o755);
}

// stripQuarantine removes the macOS Gatekeeper quarantine attribute
// (`com.apple.quarantine`) from a path, recursively.
//
// Why this matters: macOS marks files downloaded from the internet with
// that attribute, and refuses to execute an unsigned binary that still
// carries it — the kernel kills the process with
//
// ASP: Security policy would not allow process: <pid>, <path>
//
// before `main()` ever runs, so the CLI just dies silently. nodeup
// ships as an unsigned Go binary, so this is fatal for it.
//
// The npm path is normally safe (Node's `https` download does not set
// the attribute), but it is not guaranteed: a corporate proxy, a
// mirror, or a future change to how the archive is produced can leave
// the attribute set. Stripping it here makes the install robust
// regardless of how the archive arrived.
//
// Two macOS details:
//
// - `xattr -d` refuses to modify a file that is not writable by the
// caller. `chmodExec()` runs first and leaves the binary at 0755
// (owner-writable), so the removal succeeds.
// - Removing the attribute from a directory does not remove it from
// the files inside, hence the recursive `-r`.
//
// Best-effort by design: any failure is reported but never aborts the
// install. On a machine where the attribute was never set there is
// nothing to do, and the extra work is a no-op.
function stripQuarantine(targetPath) {
if (process.platform !== 'darwin') return false;
try {
// `-p` prints the value; a non-zero exit means "not present".
const probe = execFileSync('/usr/bin/xattr', ['-p', 'com.apple.quarantine', targetPath], {
stdio: ['ignore', 'pipe', 'ignore'],
});
if (!probe || probe.length === 0) return false;
} catch (_) {
// Attribute absent (or xattr unavailable) — nothing to strip.
return false;
}

try {
execFileSync('/usr/bin/xattr', ['-dr', 'com.apple.quarantine', targetPath], {
stdio: ['ignore', 'ignore', 'ignore'],
});
info(`stripped the macOS quarantine attribute from ${path.basename(targetPath)}`);
return true;
} catch (err) {
info(
`could not strip the macOS quarantine attribute from ${path.basename(targetPath)} ` +
`(${err.message}). If macOS blocks nodeup with "Security policy would not allow ` +
`process", run: xattr -dr com.apple.quarantine ${targetPath}`
);
return false;
}
}

// ---- main -----------------------------------------------------------------

(async function main() {
Expand Down Expand Up @@ -501,6 +559,10 @@ function chmodExec(filePath) {
}
fs.renameSync(extractedPath, binaryDest);
chmodExec(binaryDest);
// macOS Gatekeeper: a quarantined binary cannot be executed at all
// (the kernel kills it before main() runs). Remove the attribute so
// `nodeup` is always runnable after install.
stripQuarantine(binaryDest);

info(`installed nodeup v${version} (${os}/${goarch}) -> ${binaryDest}`);
} finally {
Expand Down
112 changes: 111 additions & 1 deletion nodeup-npm/scripts/install_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -451,5 +451,115 @@ test('extractTarGz_filterRejectsTraversalEntry', async function () {
fs.rmSync(tmp, { recursive: true, force: true });
});

// --- mirrored from install.js: stripQuarantine ----------------------------
//
// macOS Gatekeeper refuses to execute an unsigned binary that still
// carries the `com.apple.quarantine` attribute — the kernel kills the
// process before `main()` ever runs, so `nodeup` dies with no output.
// install.js strips the attribute after extraction. See #18.
//
// The real stripQuarantine() shells out to /usr/bin/xattr. We test the
// decision logic it implements (platform gate, probe-first, best-effort)
// with a fake exec so the test is hermetic and runs on every platform.

const QUARANTINE_ATTR = 'com.apple.quarantine';

function stripQuarantineTest({ platform, exec, targetPath }) {
// Not macOS: nothing to do, and we must not shell out at all.
if (platform !== 'darwin') return false;

// Probe first. A non-zero exit means "attribute not present", which
// is the common case and must be a silent no-op.
let probe;
try {
probe = exec(['-p', QUARANTINE_ATTR, targetPath]);
} catch (_) {
return false;
}
if (!probe || probe.length === 0) return false;

// Attribute present: strip it. A failure here must NOT throw — the
// install has to complete either way.
try {
exec(['-dr', QUARANTINE_ATTR, targetPath]);
} catch (_) {
return false;
}
return true;
}

// stripQuarantine: on a non-darwin platform the function is a no-op and
// never touches the filesystem.
test('stripQuarantine_nonDarwinIsANoOp', () => {
let called = false;
const exec = () => {
called = true;
return Buffer.from('0081;abc');
};
const result = stripQuarantineTest({
platform: 'linux',
exec,
targetPath: '/tmp/nodeup',
});
assert.strictEqual(result, false);
assert.strictEqual(called, false, 'must not shell out on non-darwin');
});

// stripQuarantine: the attribute is absent (xattr exits non-zero) —
// nothing happens and the function reports "did not strip".
test('stripQuarantine_absentAttributeIsANoOp', () => {
const calls = [];
const exec = (args) => {
calls.push(args);
throw new Error('xattr: No such xattr: com.apple.quarantine');
};
const result = stripQuarantineTest({
platform: 'darwin',
exec,
targetPath: '/tmp/nodeup',
});
assert.strictEqual(result, false);
assert.strictEqual(calls.length, 1, 'only the probe should run');
assert.deepStrictEqual(calls[0], ['-p', QUARANTINE_ATTR, '/tmp/nodeup']);
});

// stripQuarantine: the attribute is present — it is removed with the
// recursive form so nested files are covered too.
test('stripQuarantine_presentAttributeIsStrippedRecursively', () => {
const calls = [];
const exec = (args) => {
calls.push(args);
if (args[0] === '-p') return Buffer.from('0081;5f4a;0;abc');
return Buffer.from('');
};
const result = stripQuarantineTest({
platform: 'darwin',
exec,
targetPath: '/tmp/nodeup',
});
assert.strictEqual(result, true);
assert.strictEqual(calls.length, 2, 'probe then strip');
assert.deepStrictEqual(calls[0], ['-p', QUARANTINE_ATTR, '/tmp/nodeup']);
assert.deepStrictEqual(calls[1], ['-dr', QUARANTINE_ATTR, '/tmp/nodeup']);
});

// stripQuarantine: if the removal fails, the install must still
// complete — the function returns false instead of throwing.
test('stripQuarantine_removalFailureDoesNotAbortTheInstall', () => {
const exec = (args) => {
if (args[0] === '-p') return Buffer.from('0081;abc');
throw new Error('xattr: Permission denied');
};
let threw = false;
let result = null;
try {
result = stripQuarantineTest({ platform: 'darwin', exec, targetPath: '/tmp/nodeup' });
} catch (e) {
threw = true;
}
assert.strictEqual(threw, false, 'must never throw — an install must not fail on this');
assert.strictEqual(result, false);
});

// Done.
process.stdout.write(`0 issues. (${passed} tests)\n`);
process.stdout.write(`0 issues. (${passed} tests)\n`);
Loading