diff --git a/src/cli/commands/self-update.test.ts b/src/cli/commands/self-update.test.ts index 1b55e59..0f50ab7 100644 --- a/src/cli/commands/self-update.test.ts +++ b/src/cli/commands/self-update.test.ts @@ -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, @@ -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[] = []; diff --git a/src/cli/commands/self-update.ts b/src/cli/commands/self-update.ts index a398a06..14cf6bb 100644 --- a/src/cli/commands/self-update.ts +++ b/src/cli/commands/self-update.ts @@ -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; +/** + * 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 { + 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))); }