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
39 changes: 39 additions & 0 deletions src/cli/commands/self-update.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
import { Result as R } from "../../shared/result.ts";
import {
detectBinaryName,
interpretXattrRemoval,
type QuarantineRemover,
tryRemoveMacosQuarantine,
WINDOWS_UNSUPPORTED_MESSAGE,
Expand Down Expand Up @@ -68,6 +69,44 @@ describe("detectBinaryName", () => {
});
});

describe("interpretXattrRemoval", () => {
test("exit 0 → ok", () => {
expect(R.isOk(interpretXattrRemoval(0, ""))).toBe(true);
});

test("missing attribute (No such xattr) → ok, not a failure", () => {
// The binary is fetched over HTTP, so macOS never stamps it with the
// quarantine attribute. `xattr -d` then exits non-zero with this message,
// which must NOT surface as a warning.
const stderr = "xattr: /Users/me/.local/bin/wt: No such xattr: com.apple.quarantine";
expect(R.isOk(interpretXattrRemoval(1, stderr))).toBe(true);
});

test("missing attribute (ENOATTR) → ok", () => {
expect(R.isOk(interpretXattrRemoval(1, "xattr: [Errno 93] ENOATTR"))).toBe(true);
});

test("missing attribute (Attribute not found) → ok", () => {
expect(R.isOk(interpretXattrRemoval(1, "Attribute not found"))).toBe(true);
});

test("genuine failure → err with stderr message", () => {
const result = interpretXattrRemoval(1, "xattr: command not found");
expect(R.isErr(result)).toBe(true);
if (R.isErr(result)) {
expect(result.error.message).toBe("xattr: command not found");
}
});

test("non-zero exit with empty stderr → err mentions the exit code", () => {
const result = interpretXattrRemoval(2, "");
expect(R.isErr(result)).toBe(true);
if (R.isErr(result)) {
expect(result.error.message).toBe("xattr exited with code 2");
}
});
});

describe("tryRemoveMacosQuarantine", () => {
test("remover ok → no warning", () => {
const warnings: string[] = [];
Expand Down
37 changes: 29 additions & 8 deletions src/cli/commands/self-update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,20 +32,41 @@ export function detectBinaryName(platform: NodeJS.Platform, arch: string): Resul

/**
* Best-effort removal of the macOS quarantine attribute from a freshly
* downloaded binary. Failures (missing `xattr`, non-zero exit, or no
* attribute present) must NOT fail the self-update — they only surface
* as a warning so the user knows why the binary might be Gatekeeper-blocked.
* downloaded binary. Real failures (missing `xattr`, unexpected non-zero exit)
* must NOT fail the self-update — they only surface as a warning so the user
* knows why the binary might be Gatekeeper-blocked.
*/
export type QuarantineRemover = (targetPath: string) => Result<void>;

/**
* Interpret the outcome of `xattr -d com.apple.quarantine`.
*
* A missing attribute is NOT a failure: the binary is fetched over HTTP, so
* macOS never stamps it with `com.apple.quarantine` in the first place and
* Gatekeeper has nothing to block. `xattr` reports this with a non-zero exit
* and a "No such xattr" message, which we treat as success to avoid a
* misleading warning that tells the user to re-run a command that would fail
* the same way.
*/
export function interpretXattrRemoval(exitCode: number, stderr: string): Result<void> {
if (exitCode === 0) {
return R.ok(undefined);
}
const normalized = stderr.toLowerCase();
if (
normalized.includes("no such xattr") ||
normalized.includes("attribute not found") ||
normalized.includes("enoattr")
) {
return R.ok(undefined);
}
return R.err(new Error(stderr.trim() || `xattr exited with code ${exitCode}`));
}

export const defaultQuarantineRemover: QuarantineRemover = (targetPath) => {
try {
const proc = Bun.spawnSync(["xattr", "-d", "com.apple.quarantine", targetPath]);
if (proc.exitCode !== 0) {
const stderr = proc.stderr?.toString().trim();
return R.err(new Error(stderr || `xattr exited with code ${proc.exitCode}`));
}
return R.ok(undefined);
return interpretXattrRemoval(proc.exitCode, proc.stderr?.toString() ?? "");
} catch (err) {
return R.err(err instanceof Error ? err : new Error(String(err)));
}
Expand Down