Initial Commit
This commit is contained in:
92
packages/identity/auth/macos-keychain/src/constants/index.ts
Normal file
92
packages/identity/auth/macos-keychain/src/constants/index.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Constants for `@suaveplan/macos-keychain`.
|
||||
*
|
||||
* Exit codes match Apple's `errSec*` values from `<Security/SecBase.h>`.
|
||||
* Stderr substring patterns are sourced from the macOS `security` CLI source
|
||||
* tree and confirmed against live output captured in
|
||||
* `tests/integration.test.ts` on macOS 14.
|
||||
*/
|
||||
|
||||
/** Absolute path of the macOS `security` binary. */
|
||||
export const SECURITY_BINARY = "/usr/bin/security";
|
||||
|
||||
/** Default per-CLI-call timeout in milliseconds. */
|
||||
export const DEFAULT_TIMEOUT_MS = 10_000;
|
||||
|
||||
/** `errSecItemNotFound` — the keychain item could not be located. */
|
||||
export const EXIT_ITEM_NOT_FOUND = 44;
|
||||
|
||||
/** `errSecDuplicateItem` — an item with the same primary key already exists. */
|
||||
export const EXIT_DUPLICATE_ITEM = 45;
|
||||
|
||||
/** `errSecAuthFailed` — the user (or process) failed authentication. */
|
||||
export const EXIT_AUTH_FAILED = 36;
|
||||
|
||||
/** `errSecInteractionNotAllowed` — non-interactive context blocked the call. */
|
||||
export const EXIT_INTERACTION_NOT_ALLOWED = 25_308;
|
||||
|
||||
/** `errSecInvalidKeychain` — keychain reference is invalid or malformed. */
|
||||
export const EXIT_INVALID_KEYCHAIN = 25_295;
|
||||
|
||||
/**
|
||||
* Stderr substrings that indicate the keychain is locked. The classifier
|
||||
* checks each pattern (case-insensitive) before falling back to exit-code
|
||||
* mapping; a locked stderr beats an ambiguous exit code so callers see the
|
||||
* actionable `KEYCHAIN.LOCKED` rather than a generic access failure.
|
||||
*/
|
||||
export const EXIT_LOCKED_PATTERNS: readonly string[] = [
|
||||
"user interaction is not allowed",
|
||||
"the user name or passphrase you entered is not correct",
|
||||
"keychain is locked",
|
||||
"keychain could not be unlocked",
|
||||
];
|
||||
|
||||
/**
|
||||
* Stderr substrings that indicate a `not-found` outcome. Apple's CLI emits
|
||||
* different phrasings on different OS versions; the classifier accepts any
|
||||
* of these as evidence the lookup target does not exist.
|
||||
*/
|
||||
export const NOT_FOUND_STDERR_PATTERNS: readonly string[] = [
|
||||
"could not be found",
|
||||
"not be found in the user's default keychain",
|
||||
"no such keychain",
|
||||
"the specified item could not be found",
|
||||
];
|
||||
|
||||
/**
|
||||
* Stderr substrings that indicate a duplicate-item outcome.
|
||||
*/
|
||||
export const DUPLICATE_STDERR_PATTERNS: readonly string[] = [
|
||||
"duplicate",
|
||||
"already exists",
|
||||
"the specified item already exists",
|
||||
];
|
||||
|
||||
/**
|
||||
* Stderr substrings that indicate a permission / authorization failure.
|
||||
*/
|
||||
export const PERMISSION_STDERR_PATTERNS: readonly string[] = [
|
||||
"permission",
|
||||
"not authorized",
|
||||
"access denied",
|
||||
"user is not allowed",
|
||||
];
|
||||
|
||||
/**
|
||||
* Stderr substrings that indicate the keychain service itself is unavailable.
|
||||
*/
|
||||
export const UNAVAILABLE_STDERR_PATTERNS: readonly string[] = [
|
||||
"keychain service unavailable",
|
||||
"no such file or directory",
|
||||
"/usr/bin/security: command not found",
|
||||
];
|
||||
|
||||
/**
|
||||
* Stderr substrings that indicate the keychain reference or item type is
|
||||
* invalid (e.g. malformed primary key, non-string fields).
|
||||
*/
|
||||
export const INVALID_STDERR_PATTERNS: readonly string[] = [
|
||||
"seckeychainitemref",
|
||||
"invalid",
|
||||
"malformed",
|
||||
];
|
||||
@@ -0,0 +1,97 @@
|
||||
# error-classifier
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Translates the noisy `(exitCode, stderr)` tuples emitted by the macOS
|
||||
`security` binary into a single `KeychainError` with a stable code. Apple's
|
||||
CLI returns the same numeric exit code for several distinct failure modes,
|
||||
so the classifier looks at stderr substrings first; the exit code only
|
||||
matters when stderr is silent or generic.
|
||||
|
||||
This separation keeps the bridge implementation small — every CRUD method
|
||||
calls the classifier on a non-zero exit code and either rethrows the
|
||||
returned error or, when the classifier returns `null`, wraps the failure in
|
||||
`keychainAccessFailed` for the trace to retain the original stderr.
|
||||
|
||||
## Features
|
||||
|
||||
- Pure function — deterministic on its inputs, no globals, no allocations
|
||||
beyond the returned error.
|
||||
- Stderr substring matching (case-insensitive) takes precedence over numeric
|
||||
exit codes.
|
||||
- Five exit codes are mapped: `errSecItemNotFound`, `errSecDuplicateItem`,
|
||||
`errSecAuthFailed`, `errSecInteractionNotAllowed`, `errSecInvalidKeychain`.
|
||||
- Returns `null` for unrecognised pairs so the caller can decide between a
|
||||
generic fallback and a hard fail.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { classifyError } from "@suaveplan/macos-keychain";
|
||||
|
||||
const err = classifyError(44, "could not be found", "my-app", "alice");
|
||||
console.error(err?.code); // "KEYCHAIN.NOT_FOUND"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
classifyError,
|
||||
keychainAccessFailed,
|
||||
} from "@suaveplan/macos-keychain";
|
||||
|
||||
async function callSecurity(): Promise<void> {
|
||||
const result = await runner("/usr/bin/security", ["find-generic-password"]);
|
||||
if (result.exitCode === 0) return;
|
||||
const classified = classifyError(
|
||||
result.exitCode,
|
||||
result.stderr,
|
||||
"my-app",
|
||||
"alice"
|
||||
);
|
||||
throw classified ?? keychainAccessFailed("my-app", "alice", result.stderr);
|
||||
}
|
||||
|
||||
declare function runner(
|
||||
cmd: string,
|
||||
args: readonly string[]
|
||||
): Promise<{ stdout: string; stderr: string; exitCode: number }>;
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- **`classifyError(exitCode: number, stderr: string, service: string,
|
||||
account: string): KeychainError | null`** — returns the matching
|
||||
`KeychainError` or `null` for unknown combinations. Pure function.
|
||||
|
||||
Precedence:
|
||||
1. Stderr matches `EXIT_LOCKED_PATTERNS` → `KEYCHAIN.LOCKED`.
|
||||
2. Stderr matches `UNAVAILABLE_STDERR_PATTERNS` → `KEYCHAIN.UNAVAILABLE`.
|
||||
3. Stderr matches `PERMISSION_STDERR_PATTERNS` →
|
||||
`KEYCHAIN.PERMISSION_DENIED`.
|
||||
4. Stderr matches `NOT_FOUND_STDERR_PATTERNS` → `KEYCHAIN.NOT_FOUND`.
|
||||
5. Stderr matches `DUPLICATE_STDERR_PATTERNS` → `KEYCHAIN.ITEM_EXISTS`.
|
||||
6. Stderr matches `INVALID_STDERR_PATTERNS` → `KEYCHAIN.ITEM_INVALID`.
|
||||
7. Exit code `44` → `KEYCHAIN.NOT_FOUND`.
|
||||
8. Exit code `45` → `KEYCHAIN.ITEM_EXISTS`.
|
||||
9. Exit code `36` → `KEYCHAIN.ACCESS_FAILED`.
|
||||
10. Exit code `25308` → `KEYCHAIN.LOCKED`.
|
||||
11. Exit code `25295` → `KEYCHAIN.ITEM_INVALID`.
|
||||
12. Otherwise → `null`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Substring matching is case-insensitive; both the haystack and the needles
|
||||
are lower-cased before `String.prototype.includes`.
|
||||
- The locked-pattern check runs first because Apple frequently surfaces a
|
||||
locked-keychain failure with exit code `1`, which the numeric switch
|
||||
cannot disambiguate.
|
||||
- The classifier never throws; on a malformed input (e.g. NaN exit code) it
|
||||
returns `null` and lets the caller's fallback layer take over.
|
||||
- Adding a new `errSec*` constant requires three edits: a new entry in
|
||||
`constants/index.ts`, a new branch in this file's `switch`, and a new
|
||||
scenario in `error-classifier.test.ts`.
|
||||
@@ -0,0 +1,140 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
EXIT_AUTH_FAILED,
|
||||
EXIT_DUPLICATE_ITEM,
|
||||
EXIT_INTERACTION_NOT_ALLOWED,
|
||||
EXIT_INVALID_KEYCHAIN,
|
||||
EXIT_ITEM_NOT_FOUND,
|
||||
} from "../../constants/index.js";
|
||||
import { classifyError } from "./error-classifier.js";
|
||||
|
||||
describe("classifyError", () => {
|
||||
it("classifies errSecItemNotFound as KEYCHAIN.NOT_FOUND", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = classifyError(
|
||||
EXIT_ITEM_NOT_FOUND,
|
||||
"could not be found",
|
||||
"svc",
|
||||
"user"
|
||||
);
|
||||
expect(err?.code).toBe("KEYCHAIN.NOT_FOUND");
|
||||
});
|
||||
|
||||
it("classifies errSecDuplicateItem as KEYCHAIN.ITEM_EXISTS", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = classifyError(
|
||||
EXIT_DUPLICATE_ITEM,
|
||||
"duplicate item",
|
||||
"svc",
|
||||
"user"
|
||||
);
|
||||
expect(err?.code).toBe("KEYCHAIN.ITEM_EXISTS");
|
||||
});
|
||||
|
||||
it("classifies errSecAuthFailed as KEYCHAIN.ACCESS_FAILED via exit code", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = classifyError(EXIT_AUTH_FAILED, "", "svc", "user");
|
||||
expect(err?.code).toBe("KEYCHAIN.ACCESS_FAILED");
|
||||
});
|
||||
|
||||
it("classifies errSecInteractionNotAllowed as KEYCHAIN.LOCKED via exit code", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = classifyError(
|
||||
EXIT_INTERACTION_NOT_ALLOWED,
|
||||
"non-interactive",
|
||||
"svc",
|
||||
"user"
|
||||
);
|
||||
expect(err?.code).toBe("KEYCHAIN.LOCKED");
|
||||
});
|
||||
|
||||
it("classifies errSecInvalidKeychain as KEYCHAIN.ITEM_INVALID via exit code", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = classifyError(EXIT_INVALID_KEYCHAIN, "", "svc", "user");
|
||||
expect(err?.code).toBe("KEYCHAIN.ITEM_INVALID");
|
||||
});
|
||||
|
||||
it("prefers locked-stderr over ambiguous exit code", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = classifyError(
|
||||
1,
|
||||
"User interaction is not allowed; locked",
|
||||
"svc",
|
||||
"user"
|
||||
);
|
||||
expect(err?.code).toBe("KEYCHAIN.LOCKED");
|
||||
});
|
||||
|
||||
it("prefers unavailable-stderr over ambiguous exit code", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = classifyError(
|
||||
1,
|
||||
"no such file or directory",
|
||||
"svc",
|
||||
"user"
|
||||
);
|
||||
expect(err?.code).toBe("KEYCHAIN.UNAVAILABLE");
|
||||
});
|
||||
|
||||
it("prefers permission-stderr over ambiguous exit code", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = classifyError(1, "permission denied", "svc", "user");
|
||||
expect(err?.code).toBe("KEYCHAIN.PERMISSION_DENIED");
|
||||
});
|
||||
|
||||
it("prefers not-found-stderr over ambiguous exit code", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = classifyError(
|
||||
1,
|
||||
"the specified item could not be found",
|
||||
"svc",
|
||||
"user"
|
||||
);
|
||||
expect(err?.code).toBe("KEYCHAIN.NOT_FOUND");
|
||||
});
|
||||
|
||||
it("prefers duplicate-stderr over ambiguous exit code", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = classifyError(1, "already exists", "svc", "user");
|
||||
expect(err?.code).toBe("KEYCHAIN.ITEM_EXISTS");
|
||||
});
|
||||
|
||||
it("prefers invalid-stderr over ambiguous exit code", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = classifyError(
|
||||
1,
|
||||
"SecKeychainItemRef invalid",
|
||||
"svc",
|
||||
"user"
|
||||
);
|
||||
expect(err?.code).toBe("KEYCHAIN.ITEM_INVALID");
|
||||
});
|
||||
|
||||
it("returns null for unknown exit code and stderr combinations", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = classifyError(99999, "totally unknown", "svc", "user");
|
||||
expect(err).toBeNull();
|
||||
});
|
||||
|
||||
it("preserves service and account in the structured context", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = classifyError(EXIT_ITEM_NOT_FOUND, "", "svc-x", "user-y");
|
||||
expect(err?.context).toMatchObject({
|
||||
service: "svc-x",
|
||||
account: "user-y",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Maps `(exitCode, stderr)` tuples emitted by the macOS `security` CLI to the
|
||||
* matching `KeychainError` factory. Stderr substrings take precedence over
|
||||
* the exit code because Apple's CLI is more specific in stderr than in its
|
||||
* numeric return code (e.g. exit `1` covers many distinct failure modes).
|
||||
*/
|
||||
|
||||
import {
|
||||
DUPLICATE_STDERR_PATTERNS,
|
||||
EXIT_AUTH_FAILED,
|
||||
EXIT_DUPLICATE_ITEM,
|
||||
EXIT_INTERACTION_NOT_ALLOWED,
|
||||
EXIT_INVALID_KEYCHAIN,
|
||||
EXIT_ITEM_NOT_FOUND,
|
||||
EXIT_LOCKED_PATTERNS,
|
||||
INVALID_STDERR_PATTERNS,
|
||||
NOT_FOUND_STDERR_PATTERNS,
|
||||
PERMISSION_STDERR_PATTERNS,
|
||||
UNAVAILABLE_STDERR_PATTERNS,
|
||||
} from "../../constants/index.js";
|
||||
import type { KeychainError } from "../../error-codes.js";
|
||||
import {
|
||||
keychainAccessFailed,
|
||||
keychainItemExists,
|
||||
keychainItemInvalid,
|
||||
keychainLocked,
|
||||
keychainNotFound,
|
||||
keychainPermissionDenied,
|
||||
keychainUnavailable,
|
||||
} from "../../errors/keychain-errors.js";
|
||||
|
||||
function matchesAny(haystack: string, patterns: readonly string[]): boolean {
|
||||
const lower = haystack.toLowerCase();
|
||||
for (const pattern of patterns) {
|
||||
if (lower.includes(pattern.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a failed `security` CLI invocation.
|
||||
*
|
||||
* Order of precedence:
|
||||
* 1. Stderr substring patterns (locked → unavailable → permission →
|
||||
* invalid → not-found → duplicate).
|
||||
* 2. Numeric exit code (`errSecItemNotFound`, `errSecDuplicateItem`,
|
||||
* `errSecAuthFailed`, `errSecInteractionNotAllowed`,
|
||||
* `errSecInvalidKeychain`).
|
||||
* 3. Return `null` to signal "unknown" — the caller wraps in a generic
|
||||
* `keychainAccessFailed` so the trace still surfaces.
|
||||
*/
|
||||
export function classifyError(
|
||||
exitCode: number,
|
||||
stderr: string,
|
||||
service: string,
|
||||
account: string
|
||||
): KeychainError | null {
|
||||
if (matchesAny(stderr, EXIT_LOCKED_PATTERNS)) {
|
||||
return keychainLocked(service, account);
|
||||
}
|
||||
if (matchesAny(stderr, UNAVAILABLE_STDERR_PATTERNS)) {
|
||||
return keychainUnavailable(stderr);
|
||||
}
|
||||
if (matchesAny(stderr, PERMISSION_STDERR_PATTERNS)) {
|
||||
return keychainPermissionDenied(service, account);
|
||||
}
|
||||
if (matchesAny(stderr, NOT_FOUND_STDERR_PATTERNS)) {
|
||||
return keychainNotFound(service, account);
|
||||
}
|
||||
if (matchesAny(stderr, DUPLICATE_STDERR_PATTERNS)) {
|
||||
return keychainItemExists(service, account);
|
||||
}
|
||||
if (matchesAny(stderr, INVALID_STDERR_PATTERNS)) {
|
||||
return keychainItemInvalid(service, account, stderr);
|
||||
}
|
||||
switch (exitCode) {
|
||||
case EXIT_ITEM_NOT_FOUND:
|
||||
return keychainNotFound(service, account);
|
||||
case EXIT_DUPLICATE_ITEM:
|
||||
return keychainItemExists(service, account);
|
||||
case EXIT_AUTH_FAILED:
|
||||
return keychainAccessFailed(service, account, stderr);
|
||||
case EXIT_INTERACTION_NOT_ALLOWED:
|
||||
return keychainLocked(service, account);
|
||||
case EXIT_INVALID_KEYCHAIN:
|
||||
return keychainItemInvalid(service, account, stderr);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
# default-executor
|
||||
|
||||
## Purpose
|
||||
|
||||
Provides the runtime-aware default `CommandExecutor` used by the bridge when
|
||||
the consumer does not inject one. The executor encapsulates the only place
|
||||
in the package that reaches into `Bun.spawn` or `node:child_process.spawn`;
|
||||
every other module operates on the `CommandExecutor` abstraction so tests
|
||||
can substitute a deterministic stub.
|
||||
|
||||
The factory chooses Bun when `globalThis.Bun` is present and falls back to
|
||||
Node's `spawn` otherwise. Neither path goes through a shell — the binary
|
||||
path and arguments are passed as a structured array.
|
||||
|
||||
## Features
|
||||
|
||||
- Runtime detection per call (so a host that lazily polyfills `globalThis.Bun`
|
||||
still routes correctly).
|
||||
- AbortSignal propagation — `options.signal` is forwarded to the underlying
|
||||
spawn API so caller-side cancellation reaches the child.
|
||||
- UTF-8 stdout/stderr capture — both streams are decoded as text before being
|
||||
returned.
|
||||
- Graceful failure — `error` events on the Node path reject the promise; Bun
|
||||
errors propagate via `child.exited`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { createDefaultExecutor } from "@suaveplan/macos-keychain";
|
||||
|
||||
const runner = createDefaultExecutor();
|
||||
const result = await runner("/usr/bin/security", ["list-keychains"]);
|
||||
console.log(result.exitCode, result.stdout);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
MacOSKeychainBridge,
|
||||
createDefaultExecutor,
|
||||
} from "@suaveplan/macos-keychain";
|
||||
|
||||
const runner = createDefaultExecutor();
|
||||
const bridge = new MacOSKeychainBridge({ timeoutMs: 5_000 }, runner);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutHandle = setTimeout(() => controller.abort(), 1_000);
|
||||
try {
|
||||
await bridge.findItem("github", "alice", { signal: controller.signal });
|
||||
} finally {
|
||||
clearTimeout(timeoutHandle);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- **`createDefaultExecutor(): CommandExecutor`** — returns an async
|
||||
`(command, args, options) => Promise<CommandExecutorResult>`. The
|
||||
resolved value contains `stdout`, `stderr`, and `exitCode`. Rejects only
|
||||
when the runtime itself errors (binary missing, abort signal already
|
||||
aborted).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The Bun branch reads the `stdout` / `stderr` `ReadableStream<Uint8Array>`
|
||||
fully into memory before returning. For the `security` CLI this is fine
|
||||
because the worst-case payload (a single keychain item plus surrounding
|
||||
metadata) is a few kilobytes.
|
||||
- The Node branch uses `child.stdout.setEncoding("utf8")` so the `data`
|
||||
events deliver decoded strings; the executor concatenates them rather
|
||||
than buffering raw bytes.
|
||||
- Aborting the underlying child mid-call surfaces as either an `error`
|
||||
event (Node) or a non-zero exit code (Bun). The bridge layer translates
|
||||
both into `KEYCHAIN.TIMEOUT` when the combined signal indicates the
|
||||
timeout fired.
|
||||
- No globals are mutated; the factory returns a fresh closure per call so
|
||||
tests can construct multiple bridges with independent runtime detection.
|
||||
@@ -0,0 +1,174 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { createDefaultExecutor } from "./default-executor.js";
|
||||
|
||||
describe("createDefaultExecutor", () => {
|
||||
it("returns a callable async function", { timeout: 5000 }, () => {
|
||||
const runner = createDefaultExecutor();
|
||||
expect(typeof runner).toBe("function");
|
||||
});
|
||||
|
||||
it("invokes a real harmless binary on the host (Node spawn path)", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const runner = createDefaultExecutor();
|
||||
const result = await runner("/bin/echo", ["hello", "world"]);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout.trim()).toBe("hello world");
|
||||
});
|
||||
|
||||
it("captures stderr output", { timeout: 30000 }, async () => {
|
||||
const runner = createDefaultExecutor();
|
||||
// /bin/sh -c 'echo to-stderr 1>&2' deliberately uses a portable
|
||||
// single-call invocation; the runner still passes args as an array.
|
||||
const result = await runner("/bin/sh", ["-c", "echo error 1>&2"]);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stderr).toContain("error");
|
||||
});
|
||||
|
||||
it("returns the non-zero exit code when the binary fails", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const runner = createDefaultExecutor();
|
||||
const result = await runner("/bin/sh", ["-c", "exit 7"]);
|
||||
expect(result.exitCode).toBe(7);
|
||||
});
|
||||
|
||||
it("rejects when the binary does not exist", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const runner = createDefaultExecutor();
|
||||
await expect(
|
||||
runner("/this/path/does/not/exist/xyz", [])
|
||||
).rejects.toBeInstanceOf(Error);
|
||||
});
|
||||
|
||||
it("propagates AbortSignal cancellation on the Node spawn path", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
// Force the Node path so this test exercises the spawnViaNode
|
||||
// abort handling regardless of host runtime. Under Bun's native
|
||||
// spawn, abort surfaces as a non-rejected exit code; the Node
|
||||
// path consistently rejects, which is what callers depend on.
|
||||
const runner = createDefaultExecutor({ resolveBun: () => undefined });
|
||||
const ac = new AbortController();
|
||||
const promise = runner("/bin/sh", ["-c", "sleep 5"], {
|
||||
signal: ac.signal,
|
||||
});
|
||||
setTimeout(() => ac.abort(), 20);
|
||||
await expect(promise).rejects.toBeDefined();
|
||||
});
|
||||
|
||||
it("captures stdout and stderr on the Node spawn path", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
// Force the Node path and emit both stdout + stderr from the
|
||||
// child so the spawnViaNode "data" handlers (stdout += chunk /
|
||||
// stderr += chunk) execute. Under turbo's bun-node binary the
|
||||
// default executor takes the Bun fast-path and bypasses those
|
||||
// handlers, leaving them uncovered — this test pins them.
|
||||
const runner = createDefaultExecutor({ resolveBun: () => undefined });
|
||||
const result = await runner("/bin/sh", [
|
||||
"-c",
|
||||
"echo out && echo err 1>&2",
|
||||
]);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain("out");
|
||||
expect(result.stderr).toContain("err");
|
||||
});
|
||||
|
||||
it("uses the Bun fast-path when resolveBun returns a runtime", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fakeBun = {
|
||||
spawn() {
|
||||
return {
|
||||
stdout: new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(
|
||||
new TextEncoder().encode("ok\n")
|
||||
);
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
stderr: new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
exited: Promise.resolve(0),
|
||||
};
|
||||
},
|
||||
};
|
||||
const runner = createDefaultExecutor({ resolveBun: () => fakeBun });
|
||||
const result = await runner("/usr/bin/security", ["list-keychains"]);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toBe("ok\n");
|
||||
});
|
||||
|
||||
it("returns 130 when the Node child terminates via signal (code === null)", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
// Spawn a long-running child, then kill it; the Node `close` event
|
||||
// fires with `code === null` when termination is signal-driven, which
|
||||
// the executor maps to exit code 130.
|
||||
const cp = await import("node:child_process");
|
||||
const child = cp.spawn("/bin/sh", ["-c", "sleep 5"]);
|
||||
// Defer the kill so the child is fully spawned before we send SIGTERM.
|
||||
setTimeout(() => child.kill("SIGTERM"), 10);
|
||||
const result = await new Promise<number | null>((resolve) => {
|
||||
child.on("close", resolve);
|
||||
});
|
||||
// Verify the underlying Node behaviour the executor relies on.
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("maps a signal-killed child to exitCode 130 on the Node path", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
// Force the Node path — Bun's native spawn surfaces signalled
|
||||
// termination differently and is exercised separately.
|
||||
const runner = createDefaultExecutor({ resolveBun: () => undefined });
|
||||
const ac = new AbortController();
|
||||
const promise = runner("/bin/sh", ["-c", "sleep 5"], {
|
||||
signal: ac.signal,
|
||||
});
|
||||
// Trigger an abort that the Node spawn translates to a signal-driven
|
||||
// close. Some Node versions surface this as an `error` event instead
|
||||
// of a `close` with null code; tolerate both.
|
||||
setTimeout(() => ac.abort(), 20);
|
||||
try {
|
||||
const result = await promise;
|
||||
expect(result.exitCode).toBe(130);
|
||||
} catch (err) {
|
||||
expect(err).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("forwards signal option to the Bun spawn call", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let receivedSignal: AbortSignal | undefined;
|
||||
const fakeBun = {
|
||||
spawn(options: { signal?: AbortSignal | undefined }) {
|
||||
receivedSignal = options.signal;
|
||||
return {
|
||||
stdout: new ReadableStream<Uint8Array>({
|
||||
start(c) {
|
||||
c.close();
|
||||
},
|
||||
}),
|
||||
stderr: new ReadableStream<Uint8Array>({
|
||||
start(c) {
|
||||
c.close();
|
||||
},
|
||||
}),
|
||||
exited: Promise.resolve(0),
|
||||
};
|
||||
},
|
||||
};
|
||||
const runner = createDefaultExecutor({ resolveBun: () => fakeBun });
|
||||
const ac = new AbortController();
|
||||
await runner("/usr/bin/security", [], { signal: ac.signal });
|
||||
expect(receivedSignal).toBe(ac.signal);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Default `CommandExecutor` factory.
|
||||
*
|
||||
* On the Bun runtime we use `Bun.spawn` for low-overhead child process
|
||||
* launches; on Node.js we delegate to `node:child_process.spawn`. Neither
|
||||
* code path goes through a shell — arguments are passed as an array and
|
||||
* the macOS `security` binary is launched directly by file path.
|
||||
*
|
||||
* This file is the only place in the package that reaches into the runtime
|
||||
* for child-process APIs; everything else operates on the `CommandExecutor`
|
||||
* abstraction so tests can substitute a deterministic stub.
|
||||
*/
|
||||
|
||||
import type {
|
||||
CommandExecutor,
|
||||
CommandExecutorOptions,
|
||||
CommandExecutorResult,
|
||||
} from "../../types/macos-keychain.types.js";
|
||||
|
||||
interface BunGlobalShape {
|
||||
spawn: (options: BunSpawnOptions) => BunChild;
|
||||
}
|
||||
|
||||
interface BunSpawnOptions {
|
||||
cmd: readonly string[];
|
||||
stdout: "pipe";
|
||||
stderr: "pipe";
|
||||
signal?: AbortSignal | undefined;
|
||||
}
|
||||
|
||||
interface BunChild {
|
||||
exited: Promise<number>;
|
||||
stdout: ReadableStream<Uint8Array>;
|
||||
stderr: ReadableStream<Uint8Array>;
|
||||
}
|
||||
|
||||
declare const Bun: BunGlobalShape | undefined;
|
||||
|
||||
async function readStreamToString(
|
||||
stream: ReadableStream<Uint8Array>
|
||||
): Promise<string> {
|
||||
const decoder = new TextDecoder();
|
||||
const reader = stream.getReader();
|
||||
let out = "";
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
// ReadableStream<Uint8Array> never yields `undefined` chunks alongside
|
||||
// `done: false`, but the coercion keeps us safe under odd polyfills.
|
||||
out += decoder.decode(value as Uint8Array, { stream: true });
|
||||
}
|
||||
out += decoder.decode();
|
||||
return out;
|
||||
}
|
||||
|
||||
async function spawnViaBun(
|
||||
bun: BunGlobalShape,
|
||||
command: string,
|
||||
args: readonly string[],
|
||||
options?: CommandExecutorOptions
|
||||
): Promise<CommandExecutorResult> {
|
||||
const child = bun.spawn({
|
||||
cmd: [command, ...args],
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
...(options?.signal !== undefined ? { signal: options.signal } : {}),
|
||||
});
|
||||
const [stdout, stderr, exitCode] = await Promise.all([
|
||||
readStreamToString(child.stdout),
|
||||
readStreamToString(child.stderr),
|
||||
child.exited,
|
||||
]);
|
||||
return { stdout, stderr, exitCode };
|
||||
}
|
||||
|
||||
interface NodeChildProcessShape {
|
||||
spawn: (
|
||||
command: string,
|
||||
args: readonly string[],
|
||||
options: { stdio: "pipe"; signal?: AbortSignal | undefined }
|
||||
) => NodeChild;
|
||||
}
|
||||
|
||||
interface NodeChild {
|
||||
stdout: NodeReadable;
|
||||
stderr: NodeReadable;
|
||||
on(event: "close", cb: (code: number | null) => void): NodeChild;
|
||||
on(event: "error", cb: (err: Error) => void): NodeChild;
|
||||
}
|
||||
|
||||
interface NodeReadable {
|
||||
on(event: "data", cb: (chunk: Buffer | string) => void): NodeReadable;
|
||||
setEncoding(enc: "utf8"): void;
|
||||
}
|
||||
|
||||
async function spawnViaNode(
|
||||
command: string,
|
||||
args: readonly string[],
|
||||
options?: CommandExecutorOptions
|
||||
): Promise<CommandExecutorResult> {
|
||||
const cp = (await import(
|
||||
"node:child_process"
|
||||
)) as unknown as NodeChildProcessShape;
|
||||
return new Promise<CommandExecutorResult>((resolve, reject) => {
|
||||
const spawnOptions: {
|
||||
stdio: "pipe";
|
||||
signal?: AbortSignal | undefined;
|
||||
} =
|
||||
options?.signal !== undefined
|
||||
? { stdio: "pipe", signal: options.signal }
|
||||
: { stdio: "pipe" };
|
||||
const child = cp.spawn(command, args, spawnOptions);
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stderr.setEncoding("utf8");
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += String(chunk);
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += String(chunk);
|
||||
});
|
||||
child.on("error", reject);
|
||||
child.on("close", (code) => {
|
||||
// `code` is `null` only when the child was killed by signal; treat
|
||||
// signalled termination as a non-zero exit so the caller cannot
|
||||
// mistake a SIGTERM for success.
|
||||
const exitCode = code === null ? 130 : code;
|
||||
resolve({ stdout, stderr, exitCode });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Read the runtime `Bun` global without TypeScript type-narrowing churn. */
|
||||
function detectBun(): BunGlobalShape | undefined {
|
||||
return (globalThis as unknown as { Bun?: BunGlobalShape }).Bun;
|
||||
}
|
||||
|
||||
/** Options for `createDefaultExecutor`. */
|
||||
export interface CreateDefaultExecutorOptions {
|
||||
/**
|
||||
* Resolve the `Bun` runtime, or `undefined` to force the Node path.
|
||||
* Defaults to reading `globalThis.Bun`. Tests inject a fake to
|
||||
* exercise the Bun fast-path under any host.
|
||||
*/
|
||||
readonly resolveBun?: () => BunGlobalShape | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the default runtime executor. The decision between `Bun.spawn`
|
||||
* and `node:child_process` is made once per call so a host that polyfills
|
||||
* `globalThis.Bun` mid-run still routes correctly.
|
||||
*/
|
||||
export function createDefaultExecutor(
|
||||
options: CreateDefaultExecutorOptions = {}
|
||||
): CommandExecutor {
|
||||
const resolveBun = options.resolveBun ?? detectBun;
|
||||
return async (command, args, callOptions) => {
|
||||
const bun = resolveBun();
|
||||
if (bun !== undefined) {
|
||||
return spawnViaBun(bun, command, args, callOptions);
|
||||
}
|
||||
return spawnViaNode(command, args, callOptions);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
# keychain-bridge
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Hosts `MacOSKeychainBridge`, the single class consumers of the package
|
||||
construct. It composes the executor, parser, error classifier, and OTel
|
||||
meter into a strongly-typed CRUD surface for macOS generic-password items.
|
||||
Every method either resolves with a typed result or rejects with a
|
||||
`KeychainError` whose `code` is one of the eight registered literals.
|
||||
|
||||
The class is deliberately thin — most of the policy work (output parsing,
|
||||
error classification, runtime detection) lives in sibling modules so it can
|
||||
be tested in isolation. The bridge's job is to thread the
|
||||
configuration / abort signal / timeout / OTel concerns through every CLI
|
||||
invocation.
|
||||
|
||||
## Features
|
||||
|
||||
- CRUD methods: `addItem`, `findItem`, `updateItem`, `deleteItem`,
|
||||
`listItems`, `itemExists`.
|
||||
- Per-call `KeychainCallOptions.signal` cancellation, combined with the
|
||||
configured `timeoutMs` deadline.
|
||||
- Pluggable `CommandExecutor` constructor argument; defaults to
|
||||
`createDefaultExecutor()` for the running runtime.
|
||||
- Zod-validated config; an invalid `timeoutMs` throws a `ZodError` from the
|
||||
constructor before any CLI is invoked.
|
||||
- OTel emission on every call: `keychain.<op>` span (via the global
|
||||
provider), `keychain.ops_total` counter, `keychain.cli_duration_ms`
|
||||
histogram, and `keychain.items_returned` histogram (on `listItems`).
|
||||
- Logger pass-through — when `config.logger` is provided the bridge emits a
|
||||
`debug` record per invocation.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { MacOSKeychainBridge } from "@suaveplan/macos-keychain";
|
||||
|
||||
const bridge = new MacOSKeychainBridge();
|
||||
await bridge.addItem({ service: "my-app", account: "alice", password: "hunter2" });
|
||||
const item = await bridge.findItem("my-app", "alice");
|
||||
console.log(item.password);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
MacOSKeychainBridge,
|
||||
type CommandExecutor,
|
||||
} from "@suaveplan/macos-keychain";
|
||||
|
||||
// Deterministic stub for tests — no real keychain, no `vi.mock`.
|
||||
const stubRunner: CommandExecutor = async (cmd, args) => {
|
||||
if (args[0] === "find-generic-password") {
|
||||
return { stdout: "", stderr: 'password: "stored"', exitCode: 0 };
|
||||
}
|
||||
return { stdout: "", stderr: "", exitCode: 0 };
|
||||
};
|
||||
|
||||
const bridge = new MacOSKeychainBridge({ timeoutMs: 1_000 }, stubRunner);
|
||||
const controller = new AbortController();
|
||||
const item = await bridge.findItem("svc", "user", { signal: controller.signal });
|
||||
console.log(item.password); // "stored"
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- **`new MacOSKeychainBridge(config?: KeychainConfig, runner?:
|
||||
CommandExecutor)`** — validates `config` via `KeychainConfigSchema` and
|
||||
installs the runner. Calls `registerKeychainErrorCodes()` before
|
||||
returning.
|
||||
- **`addItem(item, options?): Promise<KeychainResult>`** — throws
|
||||
`KEYCHAIN.ITEM_EXISTS` on duplicate; otherwise routes through
|
||||
`classifyError` for failure mapping.
|
||||
- **`findItem(service, account, options?): Promise<KeychainItem>`** —
|
||||
parses `find-generic-password -g` output via `parseKeychainItem`. Throws
|
||||
`KEYCHAIN.NOT_FOUND` when the item is absent.
|
||||
- **`updateItem(service, account, newPassword, options?): Promise<
|
||||
KeychainResult>`** — performs delete-then-add. Tolerates a missing
|
||||
pre-existing item.
|
||||
- **`deleteItem(service, account, options?): Promise<KeychainResult>`** —
|
||||
throws `KEYCHAIN.NOT_FOUND` when the item does not exist.
|
||||
- **`listItems(service?, options?): Promise<readonly KeychainListEntry[]>`** —
|
||||
filters the dump output by service when provided. Records
|
||||
`keychain.items_returned` with the post-filter count.
|
||||
- **`itemExists(service, account, options?): Promise<boolean>`** — `true`
|
||||
on success, `false` on `KEYCHAIN.NOT_FOUND`, rethrows other errors.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- AbortSignal handling is centralised in `combineSignals`: a fresh
|
||||
`AbortController` is constructed per call and aborts on whichever fires
|
||||
first — the caller's signal or the configured `timeoutMs`. The `dispose`
|
||||
callback removes the listener and clears the timeout to avoid leaking
|
||||
handles.
|
||||
- Timeout detection inspects the combined signal's `aborted` flag; this
|
||||
ensures both caller-aborts and timer-aborts surface as
|
||||
`KEYCHAIN.TIMEOUT`.
|
||||
- Stderr placement of the password is a quirk of `find-generic-password
|
||||
-g`; the bridge concatenates stdout and stderr before handing the buffer
|
||||
to `parseKeychainItem` so the regex matches regardless of CLI version.
|
||||
- `listItems` post-filters in TypeScript rather than relying on the CLI's
|
||||
attribute filtering, which is unreliable across macOS versions.
|
||||
- The default runner deliberately lives in a separate file so the bridge
|
||||
itself stays under the file-size budget and so the test suite can
|
||||
construct bridges without touching child-process imports.
|
||||
@@ -0,0 +1,520 @@
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import { KeychainError } from "../../error-codes.js";
|
||||
import type {
|
||||
CommandExecutor,
|
||||
CommandExecutorResult,
|
||||
KeychainConfig,
|
||||
} from "../../types/macos-keychain.types.js";
|
||||
import { MacOSKeychainBridge } from "./keychain-bridge.js";
|
||||
|
||||
interface ExecutorCall {
|
||||
command: string;
|
||||
args: readonly string[];
|
||||
signal?: AbortSignal | undefined;
|
||||
}
|
||||
|
||||
interface StubBuilder {
|
||||
runner: CommandExecutor;
|
||||
calls: ExecutorCall[];
|
||||
queue: CommandExecutorResult[];
|
||||
}
|
||||
|
||||
function makeStub(...results: CommandExecutorResult[]): StubBuilder {
|
||||
const calls: ExecutorCall[] = [];
|
||||
const queue = [...results];
|
||||
const runner: CommandExecutor = async (command, args, options) => {
|
||||
calls.push({ command, args, signal: options?.signal });
|
||||
const next = queue.shift();
|
||||
if (next === undefined) {
|
||||
return { stdout: "", stderr: "", exitCode: 0 };
|
||||
}
|
||||
return next;
|
||||
};
|
||||
return { runner, calls, queue };
|
||||
}
|
||||
|
||||
describe("MacOSKeychainBridge constructor", () => {
|
||||
it("validates config via KeychainConfigSchema in the constructor", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => new MacOSKeychainBridge({ timeoutMs: -1 })).toThrow();
|
||||
});
|
||||
|
||||
it("accepts a valid config and a custom executor", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const stub = makeStub();
|
||||
const bridge = new MacOSKeychainBridge(
|
||||
{ timeoutMs: 1_000 },
|
||||
stub.runner
|
||||
);
|
||||
expect(bridge).toBeInstanceOf(MacOSKeychainBridge);
|
||||
});
|
||||
|
||||
it("constructs without arguments using runtime defaults", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const bridge = new MacOSKeychainBridge();
|
||||
expect(bridge).toBeInstanceOf(MacOSKeychainBridge);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MacOSKeychainBridge.addItem", () => {
|
||||
it("adds a new keychain item successfully", { timeout: 5000 }, async () => {
|
||||
const stub = makeStub({ stdout: "", stderr: "", exitCode: 0 });
|
||||
const bridge = new MacOSKeychainBridge({}, stub.runner);
|
||||
const result = await bridge.addItem({
|
||||
service: "svc",
|
||||
account: "user",
|
||||
password: "p",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
expect(stub.calls[0]?.args).toEqual([
|
||||
"add-generic-password",
|
||||
"-s",
|
||||
"svc",
|
||||
"-a",
|
||||
"user",
|
||||
"-w",
|
||||
"p",
|
||||
]);
|
||||
});
|
||||
|
||||
it("appends -l label and -j comment when supplied", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const stub = makeStub({ stdout: "", stderr: "", exitCode: 0 });
|
||||
const bridge = new MacOSKeychainBridge({}, stub.runner);
|
||||
await bridge.addItem({
|
||||
service: "svc",
|
||||
account: "user",
|
||||
password: "p",
|
||||
label: "L",
|
||||
comment: "C",
|
||||
});
|
||||
expect(stub.calls[0]?.args).toContain("-l");
|
||||
expect(stub.calls[0]?.args).toContain("L");
|
||||
expect(stub.calls[0]?.args).toContain("-j");
|
||||
expect(stub.calls[0]?.args).toContain("C");
|
||||
});
|
||||
|
||||
it("throws KEYCHAIN.ITEM_EXISTS on duplicate add", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const stub = makeStub({
|
||||
stdout: "",
|
||||
stderr: "duplicate item",
|
||||
exitCode: 45,
|
||||
});
|
||||
const bridge = new MacOSKeychainBridge({}, stub.runner);
|
||||
await expect(
|
||||
bridge.addItem({ service: "svc", account: "user", password: "p" })
|
||||
).rejects.toMatchObject({ code: "KEYCHAIN.ITEM_EXISTS" });
|
||||
});
|
||||
|
||||
it("throws keychainAccessFailed when classifier returns null", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const stub = makeStub({
|
||||
stdout: "",
|
||||
stderr: "weird",
|
||||
exitCode: 99999,
|
||||
});
|
||||
const bridge = new MacOSKeychainBridge({}, stub.runner);
|
||||
await expect(
|
||||
bridge.addItem({ service: "svc", account: "user", password: "p" })
|
||||
).rejects.toMatchObject({ code: "KEYCHAIN.ACCESS_FAILED" });
|
||||
});
|
||||
|
||||
it("forwards a parameterised argv to the executor (no shell interpolation)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const stub = makeStub({ stdout: "", stderr: "", exitCode: 0 });
|
||||
const bridge = new MacOSKeychainBridge({}, stub.runner);
|
||||
await bridge.addItem({
|
||||
service: "a; rm -rf /",
|
||||
account: "user",
|
||||
password: "p",
|
||||
});
|
||||
expect(stub.calls[0]?.command).toBe("/usr/bin/security");
|
||||
expect(stub.calls[0]?.args).toContain("a; rm -rf /");
|
||||
});
|
||||
|
||||
it("argv preserves shell metacharacters as literal payload", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const stub = makeStub({ stdout: "", stderr: "", exitCode: 0 });
|
||||
const bridge = new MacOSKeychainBridge({}, stub.runner);
|
||||
await bridge.addItem({
|
||||
service: "svc",
|
||||
account: "user",
|
||||
password: "$(whoami)",
|
||||
});
|
||||
expect(stub.calls[0]?.args).toContain("$(whoami)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("MacOSKeychainBridge.findItem", () => {
|
||||
it("finds an existing keychain item and returns parsed password", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const stub = makeStub({
|
||||
stdout: '"svce"<blob>="svc"\n"acct"<blob>="user"',
|
||||
stderr: 'password: "secret"',
|
||||
exitCode: 0,
|
||||
});
|
||||
const bridge = new MacOSKeychainBridge({}, stub.runner);
|
||||
const item = await bridge.findItem("svc", "user");
|
||||
expect(item.password).toBe("secret");
|
||||
});
|
||||
|
||||
it("rejects with KEYCHAIN.NOT_FOUND when missing", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const stub = makeStub({
|
||||
stdout: "",
|
||||
stderr: "could not be found",
|
||||
exitCode: 44,
|
||||
});
|
||||
const bridge = new MacOSKeychainBridge({}, stub.runner);
|
||||
await expect(bridge.findItem("svc", "user")).rejects.toMatchObject({
|
||||
code: "KEYCHAIN.NOT_FOUND",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to keychainAccessFailed when classifier returns null on find", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const stub = makeStub({
|
||||
stdout: "",
|
||||
stderr: "totally novel",
|
||||
exitCode: 12345,
|
||||
});
|
||||
const bridge = new MacOSKeychainBridge({}, stub.runner);
|
||||
await expect(bridge.findItem("svc", "user")).rejects.toMatchObject({
|
||||
code: "KEYCHAIN.ACCESS_FAILED",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("MacOSKeychainBridge.deleteItem", () => {
|
||||
it("returns success on a clean delete", { timeout: 5000 }, async () => {
|
||||
const stub = makeStub({ stdout: "", stderr: "", exitCode: 0 });
|
||||
const bridge = new MacOSKeychainBridge({}, stub.runner);
|
||||
const result = await bridge.deleteItem("svc", "user");
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("throws KEYCHAIN.NOT_FOUND when the item is missing", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const stub = makeStub({
|
||||
stdout: "",
|
||||
stderr: "could not be found",
|
||||
exitCode: 44,
|
||||
});
|
||||
const bridge = new MacOSKeychainBridge({}, stub.runner);
|
||||
await expect(bridge.deleteItem("svc", "user")).rejects.toMatchObject({
|
||||
code: "KEYCHAIN.NOT_FOUND",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to keychainAccessFailed when delete classifier returns null", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const stub = makeStub({
|
||||
stdout: "",
|
||||
stderr: "novel failure",
|
||||
exitCode: 99999,
|
||||
});
|
||||
const bridge = new MacOSKeychainBridge({}, stub.runner);
|
||||
await expect(bridge.deleteItem("svc", "user")).rejects.toMatchObject({
|
||||
code: "KEYCHAIN.ACCESS_FAILED",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("MacOSKeychainBridge.updateItem", () => {
|
||||
it("performs delete + add on update", { timeout: 5000 }, async () => {
|
||||
const stub = makeStub(
|
||||
{ stdout: "", stderr: "", exitCode: 0 },
|
||||
{ stdout: "", stderr: "", exitCode: 0 }
|
||||
);
|
||||
const bridge = new MacOSKeychainBridge({}, stub.runner);
|
||||
const result = await bridge.updateItem("svc", "user", "newp");
|
||||
expect(result.success).toBe(true);
|
||||
expect(stub.calls).toHaveLength(2);
|
||||
expect(stub.calls[0]?.args[0]).toBe("delete-generic-password");
|
||||
expect(stub.calls[1]?.args[0]).toBe("add-generic-password");
|
||||
});
|
||||
|
||||
it("tolerates a missing pre-existing item before re-adding", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const stub = makeStub(
|
||||
{
|
||||
stdout: "",
|
||||
stderr: "could not be found",
|
||||
exitCode: 44,
|
||||
},
|
||||
{ stdout: "", stderr: "", exitCode: 0 }
|
||||
);
|
||||
const bridge = new MacOSKeychainBridge({}, stub.runner);
|
||||
const result = await bridge.updateItem("svc", "user", "newp");
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("propagates non-not-found delete errors", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const stub = makeStub({
|
||||
stdout: "",
|
||||
stderr: "permission denied",
|
||||
exitCode: 1,
|
||||
});
|
||||
const bridge = new MacOSKeychainBridge({}, stub.runner);
|
||||
await expect(
|
||||
bridge.updateItem("svc", "user", "newp")
|
||||
).rejects.toMatchObject({ code: "KEYCHAIN.PERMISSION_DENIED" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("MacOSKeychainBridge.listItems", () => {
|
||||
it("returns parsed entries on success", { timeout: 5000 }, async () => {
|
||||
const dump = `keychain: "/x"
|
||||
"svce"<blob>="svc"
|
||||
"acct"<blob>="alice"
|
||||
keychain: "/x"
|
||||
"svce"<blob>="other"
|
||||
"acct"<blob>="bob"`;
|
||||
const stub = makeStub({ stdout: dump, stderr: "", exitCode: 0 });
|
||||
const bridge = new MacOSKeychainBridge({}, stub.runner);
|
||||
const entries = await bridge.listItems();
|
||||
expect(entries).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("filters by service when provided", { timeout: 5000 }, async () => {
|
||||
const dump = `keychain: "/x"
|
||||
"svce"<blob>="svc"
|
||||
"acct"<blob>="alice"
|
||||
keychain: "/x"
|
||||
"svce"<blob>="other"
|
||||
"acct"<blob>="bob"`;
|
||||
const stub = makeStub({ stdout: dump, stderr: "", exitCode: 0 });
|
||||
const bridge = new MacOSKeychainBridge({}, stub.runner);
|
||||
const entries = await bridge.listItems("svc");
|
||||
expect(entries).toEqual([{ service: "svc", account: "alice" }]);
|
||||
});
|
||||
|
||||
it("throws on non-zero exit", { timeout: 5000 }, async () => {
|
||||
const stub = makeStub({
|
||||
stdout: "",
|
||||
stderr: "permission denied",
|
||||
exitCode: 1,
|
||||
});
|
||||
const bridge = new MacOSKeychainBridge({}, stub.runner);
|
||||
await expect(bridge.listItems()).rejects.toMatchObject({
|
||||
code: "KEYCHAIN.PERMISSION_DENIED",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to keychainAccessFailed on unknown list error", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const stub = makeStub({
|
||||
stdout: "",
|
||||
stderr: "unknown",
|
||||
exitCode: 99999,
|
||||
});
|
||||
const bridge = new MacOSKeychainBridge({}, stub.runner);
|
||||
await expect(bridge.listItems()).rejects.toMatchObject({
|
||||
code: "KEYCHAIN.ACCESS_FAILED",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("MacOSKeychainBridge.itemExists", () => {
|
||||
it("returns true when findItem succeeds", { timeout: 5000 }, async () => {
|
||||
const stub = makeStub({
|
||||
stdout: "",
|
||||
stderr: 'password: "p"',
|
||||
exitCode: 0,
|
||||
});
|
||||
const bridge = new MacOSKeychainBridge({}, stub.runner);
|
||||
expect(await bridge.itemExists("svc", "user")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false on KEYCHAIN.NOT_FOUND", { timeout: 5000 }, async () => {
|
||||
const stub = makeStub({
|
||||
stdout: "",
|
||||
stderr: "could not be found",
|
||||
exitCode: 44,
|
||||
});
|
||||
const bridge = new MacOSKeychainBridge({}, stub.runner);
|
||||
expect(await bridge.itemExists("svc", "user")).toBe(false);
|
||||
});
|
||||
|
||||
it("rethrows other errors", { timeout: 5000 }, async () => {
|
||||
const stub = makeStub({
|
||||
stdout: "",
|
||||
stderr: "permission denied",
|
||||
exitCode: 1,
|
||||
});
|
||||
const bridge = new MacOSKeychainBridge({}, stub.runner);
|
||||
await expect(bridge.itemExists("svc", "user")).rejects.toBeInstanceOf(
|
||||
KeychainError
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AbortSignal and timeout handling", () => {
|
||||
it("rejects with KEYCHAIN.TIMEOUT when caller signal aborts", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const slowRunner: CommandExecutor = (_cmd, _args, options) =>
|
||||
new Promise<CommandExecutorResult>((_, reject) => {
|
||||
options?.signal?.addEventListener("abort", () =>
|
||||
reject(new Error("aborted"))
|
||||
);
|
||||
});
|
||||
const bridge = new MacOSKeychainBridge(
|
||||
{ timeoutMs: 1_000 },
|
||||
slowRunner
|
||||
);
|
||||
const ac = new AbortController();
|
||||
setTimeout(() => ac.abort(), 5);
|
||||
await expect(
|
||||
bridge.findItem("svc", "user", { signal: ac.signal })
|
||||
).rejects.toMatchObject({ code: "KEYCHAIN.TIMEOUT" });
|
||||
});
|
||||
|
||||
it("rejects with KEYCHAIN.TIMEOUT when timeoutMs deadline elapses", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const slowRunner: CommandExecutor = (_cmd, _args, options) =>
|
||||
new Promise<CommandExecutorResult>((_, reject) => {
|
||||
options?.signal?.addEventListener("abort", () =>
|
||||
reject(new Error("aborted"))
|
||||
);
|
||||
});
|
||||
const bridge = new MacOSKeychainBridge({ timeoutMs: 5 }, slowRunner);
|
||||
await expect(bridge.findItem("svc", "user")).rejects.toMatchObject({
|
||||
code: "KEYCHAIN.TIMEOUT",
|
||||
});
|
||||
});
|
||||
|
||||
it("supports a pre-aborted signal", { timeout: 5000 }, async () => {
|
||||
const slowRunner: CommandExecutor = (_cmd, _args, options) =>
|
||||
new Promise<CommandExecutorResult>((_, reject) => {
|
||||
if (options?.signal?.aborted) reject(new Error("pre-aborted"));
|
||||
options?.signal?.addEventListener("abort", () =>
|
||||
reject(new Error("aborted"))
|
||||
);
|
||||
});
|
||||
const bridge = new MacOSKeychainBridge(
|
||||
{ timeoutMs: 5_000 },
|
||||
slowRunner
|
||||
);
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
await expect(
|
||||
bridge.findItem("svc", "user", { signal: ac.signal })
|
||||
).rejects.toMatchObject({ code: "KEYCHAIN.TIMEOUT" });
|
||||
});
|
||||
|
||||
it("propagates KeychainError causes from the runner unchanged", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const failingRunner: CommandExecutor = async () => {
|
||||
throw new KeychainError(
|
||||
"KEYCHAIN.UNAVAILABLE",
|
||||
"keychain.error.unavailable"
|
||||
);
|
||||
};
|
||||
const bridge = new MacOSKeychainBridge({}, failingRunner);
|
||||
await expect(bridge.findItem("svc", "user")).rejects.toMatchObject({
|
||||
code: "KEYCHAIN.UNAVAILABLE",
|
||||
});
|
||||
});
|
||||
|
||||
it("wraps plain Error from the runner as KEYCHAIN.UNAVAILABLE", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const failingRunner: CommandExecutor = async () => {
|
||||
throw new Error("boom");
|
||||
};
|
||||
const bridge = new MacOSKeychainBridge({}, failingRunner);
|
||||
await expect(bridge.findItem("svc", "user")).rejects.toMatchObject({
|
||||
code: "KEYCHAIN.UNAVAILABLE",
|
||||
});
|
||||
});
|
||||
|
||||
it("wraps non-Error thrown values as KEYCHAIN.UNAVAILABLE", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const failingRunner: CommandExecutor = async () => {
|
||||
const failure: unknown = "literal";
|
||||
throw failure;
|
||||
};
|
||||
const bridge = new MacOSKeychainBridge({}, failingRunner);
|
||||
await expect(bridge.findItem("svc", "user")).rejects.toMatchObject({
|
||||
code: "KEYCHAIN.UNAVAILABLE",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Logger integration", () => {
|
||||
let captured: { level: string; msg: string }[];
|
||||
const logger: KeychainConfig["logger"] = {
|
||||
debug: (msg) => captured.push({ level: "debug", msg }),
|
||||
info: () => {},
|
||||
warn: () => {},
|
||||
error: () => {},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
captured = [];
|
||||
});
|
||||
afterEach(() => {
|
||||
captured = [];
|
||||
});
|
||||
|
||||
it("emits a debug log per invocation when a logger is provided", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const stub = makeStub({ stdout: "", stderr: "", exitCode: 0 });
|
||||
const bridge = new MacOSKeychainBridge({ logger }, stub.runner);
|
||||
await bridge.addItem({
|
||||
service: "svc",
|
||||
account: "user",
|
||||
password: "p",
|
||||
});
|
||||
expect(captured.some((c) => c.msg === "keychain.invoke")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Clock injection", () => {
|
||||
it("uses the injected clock for duration measurement (no Date.now in src)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let calls = 0;
|
||||
const clock = () => {
|
||||
calls += 1;
|
||||
return 100 * calls;
|
||||
};
|
||||
const stub = makeStub({ stdout: "", stderr: "", exitCode: 0 });
|
||||
const bridge = new MacOSKeychainBridge({ clock }, stub.runner);
|
||||
await bridge.addItem({
|
||||
service: "svc",
|
||||
account: "user",
|
||||
password: "p",
|
||||
});
|
||||
expect(calls).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,339 @@
|
||||
/**
|
||||
* `MacOSKeychainBridge` — the entry-point class for the package.
|
||||
*
|
||||
* Wraps the macOS `security` CLI behind a strongly-typed CRUD surface with
|
||||
* AbortSignal cancellation, configurable timeouts, OTel instrumentation,
|
||||
* and a pluggable `CommandExecutor` so tests can run without touching the
|
||||
* real keychain.
|
||||
*/
|
||||
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import type { Clock } from "@suaveplan/types";
|
||||
import { DEFAULT_TIMEOUT_MS, SECURITY_BINARY } from "../../constants/index.js";
|
||||
import {
|
||||
KEYCHAIN_CODES,
|
||||
KeychainError,
|
||||
registerKeychainErrorCodes,
|
||||
} from "../../error-codes.js";
|
||||
import {
|
||||
keychainAccessFailed,
|
||||
keychainItemExists,
|
||||
keychainTimeout,
|
||||
keychainUnavailable,
|
||||
} from "../../errors/keychain-errors.js";
|
||||
import { KeychainConfigSchema } from "../../schemas/macos-keychain.schemas.js";
|
||||
import type {
|
||||
CommandExecutor,
|
||||
CommandExecutorOptions,
|
||||
CommandExecutorResult,
|
||||
KeychainCallOptions,
|
||||
KeychainConfig,
|
||||
KeychainItem,
|
||||
KeychainResult,
|
||||
} from "../../types/macos-keychain.types.js";
|
||||
import { classifyError } from "../error-classifier/error-classifier.js";
|
||||
import {
|
||||
type KeychainListEntry,
|
||||
parseKeychainItem,
|
||||
parseKeychainList,
|
||||
} from "../output-parser/output-parser.js";
|
||||
import { createDefaultExecutor } from "./default-executor.js";
|
||||
|
||||
const meter = metrics.getMeter("@suaveplan/macos-keychain");
|
||||
const opsCounter = meter.createCounter("keychain.ops_total", {
|
||||
description: "Total keychain CRUD operations",
|
||||
});
|
||||
const cliDurationHist = meter.createHistogram("keychain.cli_duration_ms", {
|
||||
description: "Duration of the underlying `security` CLI invocation",
|
||||
unit: "ms",
|
||||
});
|
||||
const itemsReturnedHist = meter.createHistogram("keychain.items_returned", {
|
||||
description: "Number of items returned by listItems()",
|
||||
});
|
||||
|
||||
interface ResolvedConfig {
|
||||
readonly timeoutMs: number;
|
||||
readonly clock: Clock;
|
||||
}
|
||||
|
||||
/** Public API of the bridge. Every method may throw `KeychainError`. */
|
||||
export class MacOSKeychainBridge {
|
||||
private readonly resolved: ResolvedConfig;
|
||||
private readonly runner: CommandExecutor;
|
||||
private readonly logger: KeychainConfig["logger"];
|
||||
|
||||
public constructor(config: KeychainConfig = {}, runner?: CommandExecutor) {
|
||||
registerKeychainErrorCodes();
|
||||
const parsed = KeychainConfigSchema.parse(config);
|
||||
this.resolved = {
|
||||
timeoutMs: parsed.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
||||
clock: (parsed.clock as Clock | undefined) ?? (() => Date.now()),
|
||||
};
|
||||
this.runner = runner ?? createDefaultExecutor();
|
||||
this.logger = parsed.logger as KeychainConfig["logger"];
|
||||
}
|
||||
|
||||
public async addItem(
|
||||
item: Pick<KeychainItem, "service" | "account" | "password"> &
|
||||
Partial<Pick<KeychainItem, "label" | "comment">>,
|
||||
options: KeychainCallOptions = {}
|
||||
): Promise<KeychainResult> {
|
||||
const { service, account, password } = item;
|
||||
const args = [
|
||||
"add-generic-password",
|
||||
"-s",
|
||||
service,
|
||||
"-a",
|
||||
account,
|
||||
"-w",
|
||||
password,
|
||||
];
|
||||
if (item.label !== undefined) args.push("-l", item.label);
|
||||
if (item.comment !== undefined) args.push("-j", item.comment);
|
||||
const result = await this.invoke(
|
||||
"add",
|
||||
service,
|
||||
account,
|
||||
args,
|
||||
options
|
||||
);
|
||||
if (result.exitCode === 0) {
|
||||
return { success: true };
|
||||
}
|
||||
const classified = classifyError(
|
||||
result.exitCode,
|
||||
result.stderr,
|
||||
service,
|
||||
account
|
||||
);
|
||||
if (
|
||||
classified !== null &&
|
||||
classified.code === KEYCHAIN_CODES.ITEM_EXISTS
|
||||
) {
|
||||
throw keychainItemExists(service, account);
|
||||
}
|
||||
throw (
|
||||
classified ?? keychainAccessFailed(service, account, result.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
public async findItem(
|
||||
service: string,
|
||||
account: string,
|
||||
options: KeychainCallOptions = {}
|
||||
): Promise<KeychainItem> {
|
||||
const args = [
|
||||
"find-generic-password",
|
||||
"-s",
|
||||
service,
|
||||
"-a",
|
||||
account,
|
||||
"-g",
|
||||
];
|
||||
const result = await this.invoke(
|
||||
"find",
|
||||
service,
|
||||
account,
|
||||
args,
|
||||
options
|
||||
);
|
||||
if (result.exitCode !== 0) {
|
||||
const classified = classifyError(
|
||||
result.exitCode,
|
||||
result.stderr,
|
||||
service,
|
||||
account
|
||||
);
|
||||
throw (
|
||||
classified ??
|
||||
keychainAccessFailed(service, account, result.stderr)
|
||||
);
|
||||
}
|
||||
// `security` prints the password line on stderr, attributes on stdout.
|
||||
return parseKeychainItem(
|
||||
`${result.stdout}\n${result.stderr}`,
|
||||
service,
|
||||
account
|
||||
);
|
||||
}
|
||||
|
||||
public async updateItem(
|
||||
service: string,
|
||||
account: string,
|
||||
newPassword: string,
|
||||
options: KeychainCallOptions = {}
|
||||
): Promise<KeychainResult> {
|
||||
await this.deleteItem(service, account, options).catch((err) => {
|
||||
if (
|
||||
err instanceof KeychainError &&
|
||||
err.code === KEYCHAIN_CODES.NOT_FOUND
|
||||
) {
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
return this.addItem(
|
||||
{ service, account, password: newPassword },
|
||||
options
|
||||
);
|
||||
}
|
||||
|
||||
public async deleteItem(
|
||||
service: string,
|
||||
account: string,
|
||||
options: KeychainCallOptions = {}
|
||||
): Promise<KeychainResult> {
|
||||
const args = ["delete-generic-password", "-s", service, "-a", account];
|
||||
const result = await this.invoke(
|
||||
"delete",
|
||||
service,
|
||||
account,
|
||||
args,
|
||||
options
|
||||
);
|
||||
if (result.exitCode === 0) {
|
||||
return { success: true };
|
||||
}
|
||||
const classified = classifyError(
|
||||
result.exitCode,
|
||||
result.stderr,
|
||||
service,
|
||||
account
|
||||
);
|
||||
throw (
|
||||
classified ?? keychainAccessFailed(service, account, result.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
public async listItems(
|
||||
service?: string,
|
||||
options: KeychainCallOptions = {}
|
||||
): Promise<readonly KeychainListEntry[]> {
|
||||
const args =
|
||||
service !== undefined
|
||||
? ["dump-keychain", "-d"]
|
||||
: ["dump-keychain", "-d"];
|
||||
const result = await this.invoke(
|
||||
"list",
|
||||
service ?? "*",
|
||||
"*",
|
||||
args,
|
||||
options
|
||||
);
|
||||
if (result.exitCode !== 0) {
|
||||
const classified = classifyError(
|
||||
result.exitCode,
|
||||
result.stderr,
|
||||
service ?? "*",
|
||||
"*"
|
||||
);
|
||||
throw (
|
||||
classified ??
|
||||
keychainAccessFailed(service ?? "*", "*", result.stderr)
|
||||
);
|
||||
}
|
||||
const all = parseKeychainList(result.stdout);
|
||||
const filtered =
|
||||
service === undefined
|
||||
? all
|
||||
: all.filter((e) => e.service === service);
|
||||
itemsReturnedHist.record(filtered.length);
|
||||
return filtered;
|
||||
}
|
||||
|
||||
public async itemExists(
|
||||
service: string,
|
||||
account: string,
|
||||
options: KeychainCallOptions = {}
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await this.findItem(service, account, options);
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof KeychainError &&
|
||||
err.code === KEYCHAIN_CODES.NOT_FOUND
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private async invoke(
|
||||
op: "add" | "find" | "update" | "delete" | "list",
|
||||
service: string,
|
||||
account: string,
|
||||
args: readonly string[],
|
||||
options: KeychainCallOptions
|
||||
): Promise<CommandExecutorResult> {
|
||||
const start = this.resolved.clock();
|
||||
this.logger?.debug("keychain.invoke", { op, service, account });
|
||||
const combined = combineSignals(
|
||||
options.signal,
|
||||
this.resolved.timeoutMs
|
||||
);
|
||||
const runOptions: CommandExecutorOptions = { signal: combined.signal };
|
||||
try {
|
||||
const result = await this.runner(SECURITY_BINARY, args, runOptions);
|
||||
cliDurationHist.record(this.resolved.clock() - start, { op });
|
||||
opsCounter.add(1, { op, success: result.exitCode === 0 });
|
||||
return result;
|
||||
} catch (cause) {
|
||||
cliDurationHist.record(this.resolved.clock() - start, { op });
|
||||
opsCounter.add(1, { op, success: false });
|
||||
if (combined.aborted()) {
|
||||
throw keychainTimeout(
|
||||
service,
|
||||
account,
|
||||
this.resolved.timeoutMs
|
||||
);
|
||||
}
|
||||
if (cause instanceof KeychainError) {
|
||||
throw cause;
|
||||
}
|
||||
throw keychainUnavailable(
|
||||
cause instanceof Error ? cause.message : String(cause)
|
||||
);
|
||||
} finally {
|
||||
combined.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface CombinedSignal {
|
||||
readonly signal: AbortSignal;
|
||||
readonly aborted: () => boolean;
|
||||
readonly dispose: () => void;
|
||||
}
|
||||
|
||||
function combineSignals(
|
||||
user: AbortSignal | undefined,
|
||||
timeoutMs: number
|
||||
): CombinedSignal {
|
||||
const controller = new AbortController();
|
||||
let timeoutFired = false;
|
||||
const handle = setTimeout(() => {
|
||||
timeoutFired = true;
|
||||
controller.abort(new Error("keychain.timeout"));
|
||||
}, timeoutMs);
|
||||
let userListener: (() => void) | undefined;
|
||||
if (user !== undefined) {
|
||||
if (user.aborted) {
|
||||
controller.abort(user.reason);
|
||||
} else {
|
||||
userListener = () => controller.abort(user.reason);
|
||||
user.addEventListener("abort", userListener, { once: true });
|
||||
}
|
||||
}
|
||||
return {
|
||||
signal: controller.signal,
|
||||
aborted: () => controller.signal.aborted || timeoutFired,
|
||||
dispose: () => {
|
||||
clearTimeout(handle);
|
||||
if (userListener !== undefined && user !== undefined) {
|
||||
user.removeEventListener("abort", userListener);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
# output-parser
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Parses the macOS `security` CLI output formats consumed by the bridge into
|
||||
strongly-typed records. The CLI emits attribute-style output with quoted
|
||||
strings, hex blobs, and timestamps; this module is the only place that
|
||||
understands those wire formats so the rest of the package operates on
|
||||
`KeychainItem` instances.
|
||||
|
||||
The parser is also the package's only sensitive-data handler: it materialises
|
||||
the password bytes into a temporary `Uint8Array` long enough to decode UTF-8,
|
||||
zeroes the buffer immediately, and returns only a JS string view. Callers
|
||||
control how long the string lives.
|
||||
|
||||
## Features
|
||||
|
||||
- `parseKeychainItem` — parses combined stdout/stderr from `find-generic-
|
||||
password -g`, supporting both quoted-string (`password: "value"`) and
|
||||
hex-blob (`password: 0xDEADBEEF`) password formats.
|
||||
- Attribute extraction — recognises `svce`, `acct`, `labl`, `icmt`, `cdat`,
|
||||
`mdat` attribute lines and decodes Apple's date-time format.
|
||||
- Buffer zeroization — the working `Uint8Array` produced by `TextEncoder`
|
||||
and the hex-decode buffer are zeroed via `@suaveplan/crypto-utils`.
|
||||
- `parseKeychainList` — walks `dump-keychain` records and returns
|
||||
`{ service, account }` pairs, skipping entries that lack a `svce` or
|
||||
`acct` attribute.
|
||||
- Throws `KEYCHAIN.ITEM_INVALID` on malformed input rather than returning
|
||||
undefined or null.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { parseKeychainItem } from "@suaveplan/macos-keychain";
|
||||
|
||||
const item = parseKeychainItem(
|
||||
'password: "hunter2"\n"svce"<blob>="my-app"\n"acct"<blob>="alice"',
|
||||
"my-app",
|
||||
"alice"
|
||||
);
|
||||
console.log(item.password); // "hunter2"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
parseKeychainItem,
|
||||
parseKeychainList,
|
||||
} from "@suaveplan/macos-keychain";
|
||||
|
||||
const dump = `keychain: "/Users/alice/Library/Keychains/login.keychain-db"
|
||||
"svce"<blob>="github"
|
||||
"acct"<blob>="alice"
|
||||
keychain: "/Users/alice/Library/Keychains/login.keychain-db"
|
||||
"svce"<blob>="my-app"
|
||||
"acct"<blob>="alice"
|
||||
`;
|
||||
|
||||
const entries = parseKeychainList(dump);
|
||||
for (const e of entries) {
|
||||
const detail = parseKeychainItem(
|
||||
`password: "${"redacted"}"\n"svce"<blob>="${e.service}"\n"acct"<blob>="${e.account}"`,
|
||||
e.service,
|
||||
e.account
|
||||
);
|
||||
void detail;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- **`parseKeychainItem(output: string, service: string, account: string):
|
||||
KeychainItem`** — returns a fully-typed `KeychainItem`. Throws a
|
||||
`KeychainError` with `code === "KEYCHAIN.ITEM_INVALID"` if the output is
|
||||
missing a `password:` line or contains an undecodable hex blob.
|
||||
- **`parseKeychainList(stdout: string): readonly KeychainListEntry[]`** —
|
||||
returns a frozen-shape array. Records lacking `svce` or `acct` are
|
||||
silently skipped (this matches Apple's own behaviour when listing items
|
||||
that a process cannot read).
|
||||
- **`KeychainListEntry`** — `{ readonly service: string; readonly account:
|
||||
string }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The quoted-password regex tolerates backslash-escaped characters
|
||||
(`\\`, `\"`, `\n`, `\t`); `unescapeQuoted` reverses the escape sequences
|
||||
before the parsed value reaches the caller.
|
||||
- Hex decoding uses `TextDecoder("utf-8", { fatal: false })` so a
|
||||
truncated multi-byte sequence produces a `<EFBFBD>` replacement character
|
||||
rather than throwing — this matches the CLI's own lenient behaviour.
|
||||
- Dates use Apple's `YYYYMMDDHHMMSSZ` format with a trailing `\\000`
|
||||
null marker; the parser strips the marker and returns `undefined` for
|
||||
non-conformant strings rather than guessing.
|
||||
- `parseKeychainList` splits on `^keychain:` so multiple keychains in a
|
||||
single dump (login + iCloud + system) are walked independently.
|
||||
- Both parsers are pure on their inputs; instrumentation is the caller's
|
||||
responsibility.
|
||||
@@ -0,0 +1,197 @@
|
||||
import * as cryptoUtils from "@suaveplan/crypto-utils";
|
||||
import { describe, expect, it, vi } from "@suaveplan/testing/runner";
|
||||
import { KeychainError } from "../../error-codes.js";
|
||||
import { parseKeychainItem, parseKeychainList } from "./output-parser.js";
|
||||
|
||||
describe("parseKeychainItem", () => {
|
||||
it("parses a quoted-string password", { timeout: 5000 }, () => {
|
||||
const out =
|
||||
'"svce"<blob>="my-app"\n"acct"<blob>="alice"\npassword: "hunter2"';
|
||||
const item = parseKeychainItem(out, "my-app", "alice");
|
||||
expect(item.password).toBe("hunter2");
|
||||
expect(item.service).toBe("my-app");
|
||||
expect(item.account).toBe("alice");
|
||||
});
|
||||
|
||||
it("decodes a hex-encoded password", { timeout: 5000 }, () => {
|
||||
// 0x68756e74657232 = "hunter2" UTF-8
|
||||
const out = "password: 0x68756e74657232";
|
||||
const item = parseKeychainItem(out, "svc", "user");
|
||||
expect(item.password).toBe("hunter2");
|
||||
});
|
||||
|
||||
it("unescapes embedded backslash-quote sequences", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const out = String.raw`password: "she said \"hi\""`;
|
||||
const item = parseKeychainItem(out, "svc", "user");
|
||||
expect(item.password).toBe('she said "hi"');
|
||||
});
|
||||
|
||||
it("unescapes \\n and \\t inside the password", { timeout: 5000 }, () => {
|
||||
const out = String.raw`password: "line1\nline2\ttab"`;
|
||||
const item = parseKeychainItem(out, "svc", "user");
|
||||
expect(item.password).toBe("line1\nline2\ttab");
|
||||
});
|
||||
|
||||
it("populates label from labl attribute", { timeout: 5000 }, () => {
|
||||
const out =
|
||||
'"labl"<blob>="My Login"\n"svce"<blob>="my-app"\n"acct"<blob>="alice"\npassword: "p"';
|
||||
const item = parseKeychainItem(out, "my-app", "alice");
|
||||
expect(item.label).toBe("My Login");
|
||||
});
|
||||
|
||||
it("populates comment from icmt attribute", { timeout: 5000 }, () => {
|
||||
const out =
|
||||
'"icmt"<blob>="Created by tests"\n"svce"<blob>="my-app"\n"acct"<blob>="alice"\npassword: "p"';
|
||||
const item = parseKeychainItem(out, "my-app", "alice");
|
||||
expect(item.comment).toBe("Created by tests");
|
||||
});
|
||||
|
||||
it("parses cdat / mdat timestamps", { timeout: 5000 }, () => {
|
||||
const out =
|
||||
'"cdat"<timedate>=0x32303236303530333132333435365A00 "20260503123456Z\\000"\n"mdat"<timedate>=0x32303236303530333132333435365A00 "20260503123456Z\\000"\npassword: "p"';
|
||||
const item = parseKeychainItem(out, "svc", "user");
|
||||
expect(item.createdAt?.getUTCFullYear()).toBe(2026);
|
||||
expect(item.modifiedAt?.getUTCFullYear()).toBe(2026);
|
||||
});
|
||||
|
||||
it("ignores cdat with malformed timestamp", { timeout: 5000 }, () => {
|
||||
const out = '"cdat"<timedate>=0x00 "not a date"\npassword: "p"';
|
||||
const item = parseKeychainItem(out, "svc", "user");
|
||||
expect(item.createdAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores mdat with malformed timestamp", { timeout: 5000 }, () => {
|
||||
const out = '"mdat"<timedate>=0x00 "20990230999999Z"\npassword: "p"';
|
||||
const item = parseKeychainItem(out, "svc", "user");
|
||||
expect(item.modifiedAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores attribute lines with no quoted value", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// matches the ATTR_LINE_RE shape but with no quoted-value capture
|
||||
const out = '"labl"<blob>=0xDEADBEEF\npassword: "p"';
|
||||
const item = parseKeychainItem(out, "svc", "user");
|
||||
expect(item.label).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores attribute lines whose key is a hex blob", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// `0x00000007` is a valid attribute key per the ATTR_LINE_RE but the
|
||||
// parser ignores everything outside the well-known set.
|
||||
const out = '"0x00000007"<blob>="ignored"\npassword: "p"';
|
||||
const item = parseKeychainItem(out, "svc", "user");
|
||||
expect(item.password).toBe("p");
|
||||
});
|
||||
|
||||
it("ignores cdat that parses to NaN", { timeout: 5000 }, () => {
|
||||
// YYYYMMDDHHMMSSZ that passes the regex but is impossible (month 13)
|
||||
const out = '"cdat"<timedate>=0x00 "20261301000000Z"\npassword: "p"';
|
||||
const item = parseKeychainItem(out, "svc", "user");
|
||||
// Some JS engines still produce a valid Date for month 13 (rolls over);
|
||||
// we just assert the parser didn't throw.
|
||||
expect(item.password).toBe("p");
|
||||
});
|
||||
|
||||
it("throws KEYCHAIN.ITEM_INVALID when the password line is missing", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const out = '"svce"<blob>="my-app"\n"acct"<blob>="alice"';
|
||||
expect(() => parseKeychainItem(out, "my-app", "alice")).toThrow(
|
||||
KeychainError
|
||||
);
|
||||
try {
|
||||
parseKeychainItem(out, "my-app", "alice");
|
||||
} catch (err) {
|
||||
expect((err as KeychainError).code).toBe("KEYCHAIN.ITEM_INVALID");
|
||||
}
|
||||
});
|
||||
|
||||
it("throws KEYCHAIN.ITEM_INVALID for an odd-length hex password", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const out = "password: 0xABC";
|
||||
expect(() => parseKeychainItem(out, "svc", "user")).toThrow(
|
||||
KeychainError
|
||||
);
|
||||
});
|
||||
|
||||
it("zeroizes the working buffer after extracting the password", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const zeroizeSpy = vi.spyOn(cryptoUtils, "zeroize");
|
||||
zeroizeSpy.mockClear();
|
||||
const out = 'password: "hello"';
|
||||
parseKeychainItem(out, "svc", "user");
|
||||
expect(zeroizeSpy).toHaveBeenCalled();
|
||||
zeroizeSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("zeroizes the hex-decode buffer too", { timeout: 5000 }, () => {
|
||||
const zeroizeSpy = vi.spyOn(cryptoUtils, "zeroize");
|
||||
zeroizeSpy.mockClear();
|
||||
const out = "password: 0x6869";
|
||||
parseKeychainItem(out, "svc", "user");
|
||||
expect(zeroizeSpy).toHaveBeenCalledTimes(2);
|
||||
zeroizeSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("falls back to caller-supplied service/account when attributes are absent", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const item = parseKeychainItem('password: "p"', "fallback", "u");
|
||||
expect(item.service).toBe("fallback");
|
||||
expect(item.account).toBe("u");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseKeychainList", () => {
|
||||
it("returns an empty array on empty input", { timeout: 5000 }, () => {
|
||||
expect(parseKeychainList("")).toEqual([]);
|
||||
});
|
||||
|
||||
it("extracts service/account pairs from a multi-record dump", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const dump = `
|
||||
keychain: "/path/login.keychain-db"
|
||||
"svce"<blob>="github"
|
||||
"acct"<blob>="alice"
|
||||
keychain: "/path/login.keychain-db"
|
||||
"svce"<blob>="my-app"
|
||||
"acct"<blob>="bob"
|
||||
`;
|
||||
const entries = parseKeychainList(dump);
|
||||
expect(entries).toEqual([
|
||||
{ service: "github", account: "alice" },
|
||||
{ service: "my-app", account: "bob" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips records that lack svce or acct", { timeout: 5000 }, () => {
|
||||
const dump = `
|
||||
keychain: "/path/login.keychain-db"
|
||||
"svce"<blob>="github"
|
||||
keychain: "/path/login.keychain-db"
|
||||
"svce"<blob>="my-app"
|
||||
"acct"<blob>="bob"
|
||||
`;
|
||||
const entries = parseKeychainList(dump);
|
||||
expect(entries).toEqual([{ service: "my-app", account: "bob" }]);
|
||||
});
|
||||
|
||||
it("decodes escaped characters in service / account values", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const dump = String.raw`
|
||||
keychain: "/path/login.keychain-db"
|
||||
"svce"<blob>="my\"app"
|
||||
"acct"<blob>="al\\ice"
|
||||
`;
|
||||
const entries = parseKeychainList(dump);
|
||||
expect(entries).toEqual([{ service: 'my"app', account: "al\\ice" }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Parsers for the macOS `security` CLI output formats this package consumes.
|
||||
*
|
||||
* - `parseKeychainItem` — extracts a `KeychainItem` from
|
||||
* `find-generic-password -g` output. The CLI emits the password line on
|
||||
* stderr in the quoted format `password: "value"` or, when the password
|
||||
* contains non-printable bytes, in hex form `password: 0x68656c6c6f`. Both
|
||||
* variants are handled; malformed input throws `KEYCHAIN.ITEM_INVALID`.
|
||||
* - `parseKeychainList` — walks `dump-keychain` output for `0x00000007 …`
|
||||
* ASCII attribute blocks and returns the `service` / `account` pairs.
|
||||
*
|
||||
* Sensitive intermediate buffers are encoded into a `Uint8Array`, parsed,
|
||||
* and zeroed via `@suaveplan/crypto-utils` before the parser returns.
|
||||
*/
|
||||
|
||||
import { zeroize } from "@suaveplan/crypto-utils";
|
||||
import { keychainItemInvalid } from "../../errors/keychain-errors.js";
|
||||
import type { KeychainItem } from "../../types/macos-keychain.types.js";
|
||||
|
||||
const QUOTED_PASSWORD_RE = /password:\s*"((?:[^"\\]|\\.)*)"/;
|
||||
const HEX_PASSWORD_RE = /password:\s*0x([0-9a-fA-F]+)/;
|
||||
const ATTR_LINE_RE =
|
||||
/"(?<key>0x[0-9A-Fa-f]+|svce|acct|labl|icmt|cdat|mdat)"<(?:blob|timedate)>=(?:0x[0-9A-Fa-f]+\s*)?(?:"(?<val>(?:[^"\\]|\\.)*)")?/;
|
||||
|
||||
/** Decode the `\xNN` and `\\` escapes that the CLI emits in quoted strings. */
|
||||
function unescapeQuoted(s: string): string {
|
||||
return s
|
||||
.replace(/\\\\/g, "\\")
|
||||
.replace(/\\"/g, '"')
|
||||
.replace(/\\n/g, "\n")
|
||||
.replace(/\\t/g, "\t");
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a hex-encoded password into a UTF-8 string. The hex digits are
|
||||
* written into a `Uint8Array` that is zeroed before the function returns;
|
||||
* the caller receives only the string view.
|
||||
*/
|
||||
function decodeHexPassword(hex: string): string {
|
||||
if (hex.length % 2 !== 0) {
|
||||
throw new Error("hex string has odd length");
|
||||
}
|
||||
const buf = new Uint8Array(hex.length / 2);
|
||||
for (let i = 0; i < buf.length; i += 1) {
|
||||
// Caller-side regex `/0x([0-9a-fA-F]+)/` constrains `hex` to hex digits
|
||||
// only, so `Number.parseInt(_, 16)` cannot return `NaN` here.
|
||||
buf[i] = Number.parseInt(hex.substring(i * 2, i * 2 + 2), 16);
|
||||
}
|
||||
let result: string;
|
||||
try {
|
||||
result = new TextDecoder("utf-8", { fatal: false }).decode(buf);
|
||||
} finally {
|
||||
zeroize(buf);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Parse the `find-generic-password -g` combined output into a `KeychainItem`. */
|
||||
export function parseKeychainItem(
|
||||
output: string,
|
||||
service: string,
|
||||
account: string
|
||||
): KeychainItem {
|
||||
const buffer = new TextEncoder().encode(output);
|
||||
try {
|
||||
return parseKeychainItemInner(output, service, account);
|
||||
} finally {
|
||||
zeroize(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
function parseKeychainItemInner(
|
||||
output: string,
|
||||
service: string,
|
||||
account: string
|
||||
): KeychainItem {
|
||||
let password: string | undefined;
|
||||
const quoted = output.match(QUOTED_PASSWORD_RE);
|
||||
if (quoted !== null && quoted[1] !== undefined) {
|
||||
password = unescapeQuoted(quoted[1]);
|
||||
} else {
|
||||
const hex = output.match(HEX_PASSWORD_RE);
|
||||
if (hex !== null && hex[1] !== undefined) {
|
||||
try {
|
||||
password = decodeHexPassword(hex[1]);
|
||||
} catch (cause) {
|
||||
throw keychainItemInvalid(
|
||||
service,
|
||||
account,
|
||||
`hex decode failed: ${(cause as Error).message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (password === undefined) {
|
||||
throw keychainItemInvalid(
|
||||
service,
|
||||
account,
|
||||
"no password line in output"
|
||||
);
|
||||
}
|
||||
const attrs = parseAttributes(output);
|
||||
const item: KeychainItem = {
|
||||
service: attrs.service ?? service,
|
||||
account: attrs.account ?? account,
|
||||
password,
|
||||
...(attrs.label !== undefined ? { label: attrs.label } : {}),
|
||||
...(attrs.comment !== undefined ? { comment: attrs.comment } : {}),
|
||||
...(attrs.createdAt !== undefined
|
||||
? { createdAt: attrs.createdAt }
|
||||
: {}),
|
||||
...(attrs.modifiedAt !== undefined
|
||||
? { modifiedAt: attrs.modifiedAt }
|
||||
: {}),
|
||||
};
|
||||
return item;
|
||||
}
|
||||
|
||||
interface ParsedAttributes {
|
||||
service?: string;
|
||||
account?: string;
|
||||
label?: string;
|
||||
comment?: string;
|
||||
createdAt?: Date;
|
||||
modifiedAt?: Date;
|
||||
}
|
||||
|
||||
function parseAttributes(output: string): ParsedAttributes {
|
||||
const result: ParsedAttributes = {};
|
||||
for (const line of output.split("\n")) {
|
||||
const m = line.match(ATTR_LINE_RE);
|
||||
if (m === null || m.groups === undefined) continue;
|
||||
const key = m.groups["key"];
|
||||
const val = m.groups["val"];
|
||||
if (val === undefined) continue;
|
||||
const decoded = unescapeQuoted(val);
|
||||
if (key === "svce") result.service = decoded;
|
||||
else if (key === "acct") result.account = decoded;
|
||||
else if (key === "labl") result.label = decoded;
|
||||
else if (key === "icmt") result.comment = decoded;
|
||||
else if (key === "cdat") {
|
||||
const d = parseSecurityDate(decoded);
|
||||
if (d !== undefined) result.createdAt = d;
|
||||
} else if (key === "mdat") {
|
||||
const d = parseSecurityDate(decoded);
|
||||
if (d !== undefined) result.modifiedAt = d;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseSecurityDate(raw: string): Date | undefined {
|
||||
// Apple emits `20260503123456Z\000` in the cdat/mdat fields.
|
||||
const trimmed = raw.replace(/\\000$/, "").trim();
|
||||
const m = trimmed.match(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})Z?$/);
|
||||
if (m === null) return undefined;
|
||||
const [, y, mo, d, h, mi, s] = m;
|
||||
const iso = `${y}-${mo}-${d}T${h}:${mi}:${s}Z`;
|
||||
const parsed = new Date(iso);
|
||||
return Number.isNaN(parsed.getTime()) ? undefined : parsed;
|
||||
}
|
||||
|
||||
/** Parsed entry returned by `parseKeychainList`. */
|
||||
export interface KeychainListEntry {
|
||||
readonly service: string;
|
||||
readonly account: string;
|
||||
}
|
||||
|
||||
const SVCE_RE = /"svce"<blob>="((?:[^"\\]|\\.)*)"/;
|
||||
const ACCT_RE = /"acct"<blob>="((?:[^"\\]|\\.)*)"/;
|
||||
|
||||
/**
|
||||
* Parse `security dump-keychain` output for `service` / `account` pairs.
|
||||
*
|
||||
* The CLI emits one record per item separated by a `keychain: …` header;
|
||||
* we walk records, extract `svce` and `acct` blob attributes, and skip
|
||||
* anything that does not have both.
|
||||
*/
|
||||
export function parseKeychainList(
|
||||
stdout: string
|
||||
): readonly KeychainListEntry[] {
|
||||
const entries: KeychainListEntry[] = [];
|
||||
const records = stdout.split(/^keychain:/m);
|
||||
for (const record of records) {
|
||||
const svce = record.match(SVCE_RE);
|
||||
const acct = record.match(ACCT_RE);
|
||||
if (
|
||||
svce !== null &&
|
||||
acct !== null &&
|
||||
svce[1] !== undefined &&
|
||||
acct[1] !== undefined
|
||||
) {
|
||||
entries.push({
|
||||
service: unescapeQuoted(svce[1]),
|
||||
account: unescapeQuoted(acct[1]),
|
||||
});
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
91
packages/identity/auth/macos-keychain/src/error-codes.md
Normal file
91
packages/identity/auth/macos-keychain/src/error-codes.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# error-codes
|
||||
|
||||
## Purpose
|
||||
|
||||
Centralises the eight error codes emitted by `@suaveplan/macos-keychain` and
|
||||
the metadata table that downstream consumers (dashboards, retry policies,
|
||||
i18n catalogs) use to interpret them. Defining the registry in one file means
|
||||
factories, tests, and the bridge implementation never disagree on the literal
|
||||
values, and future codes can be added without coordinating across modules.
|
||||
|
||||
The module also exports the `KeychainError` class — a thin subclass of
|
||||
`SuaveplanError<KeychainErrorCode>` that narrows the code union and pre-sets
|
||||
`this.name`. It is the only error class the package ever throws.
|
||||
|
||||
## Features
|
||||
|
||||
- `KEYCHAIN_CODES` — frozen `Readonly<Record<reason, "KEYCHAIN.<reason>">>`
|
||||
produced by `defineCodes`.
|
||||
- `KeychainErrorCode` — discriminated union of every literal code value.
|
||||
- `KEYCHAIN_ERROR_METADATA` — frozen metadata table covering HTTP status,
|
||||
severity, retryable flag, user-message key, category, and description.
|
||||
- `registerKeychainErrorCodes()` — idempotent registration hook, called at
|
||||
module bottom.
|
||||
- `isKeychainErrorCodeRegistryRegistered()` — predicate exposed for tests.
|
||||
- `KeychainError` — concrete subclass of `SuaveplanError<KeychainErrorCode>`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
KEYCHAIN_CODES,
|
||||
KeychainError,
|
||||
} from "@suaveplan/macos-keychain";
|
||||
|
||||
throw new KeychainError(KEYCHAIN_CODES.NOT_FOUND, "keychain.error.not_found", {
|
||||
context: { service: "my-app", account: "alice" },
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
KEYCHAIN_CODES,
|
||||
KEYCHAIN_ERROR_METADATA,
|
||||
KeychainError,
|
||||
} from "@suaveplan/macos-keychain";
|
||||
|
||||
function isRetryable(err: unknown): boolean {
|
||||
if (!(err instanceof KeychainError)) return false;
|
||||
const meta = KEYCHAIN_ERROR_METADATA[err.code];
|
||||
return meta.retryable;
|
||||
}
|
||||
|
||||
if (isRetryable(thrown)) {
|
||||
await retryWithBackoff();
|
||||
}
|
||||
|
||||
declare const thrown: unknown;
|
||||
declare function retryWithBackoff(): Promise<void>;
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- **`KEYCHAIN_CODES`** — `Readonly<{ NOT_FOUND, ITEM_EXISTS, ACCESS_FAILED,
|
||||
LOCKED, UNAVAILABLE, PERMISSION_DENIED, ITEM_INVALID, TIMEOUT }>` mapping
|
||||
reason names to `"KEYCHAIN.<reason>"` literals.
|
||||
- **`KeychainErrorCode`** — derived union type.
|
||||
- **`KEYCHAIN_ERROR_METADATA`** — frozen `Record<KeychainErrorCode,
|
||||
KeychainCodeMetadata>` keyed by code value.
|
||||
- **`KeychainCodeMetadata`** — `{ httpStatus, severity, retryable,
|
||||
userMessageKey, category, description }`.
|
||||
- **`registerKeychainErrorCodes(): void`** — idempotent. The first call
|
||||
flips an internal flag; subsequent calls are no-ops.
|
||||
- **`isKeychainErrorCodeRegistryRegistered(): boolean`** — returns the flag
|
||||
state. Test-only; production code should not branch on this.
|
||||
- **`class KeychainError`** — `new KeychainError(code, messageKey, options?)`.
|
||||
`options` is `SuaveplanErrorOptions` from `@suaveplan/error` (cause,
|
||||
context, traceId, clock).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The metadata table is `Object.freeze`-d at module load to prevent runtime
|
||||
tampering.
|
||||
- `defineCodes` constructs the literal-typed code map without `as` assertions,
|
||||
so renaming a code triggers a typecheck error in every consumer.
|
||||
- `registerKeychainErrorCodes` does not currently push to a global registry —
|
||||
it exists as a stable hook so a future change can introduce a
|
||||
central code-discovery map without breaking the public surface.
|
||||
- `KeychainError` always sets `this.name = "KeychainError"` so `err.name`-based
|
||||
log filters work even after minification.
|
||||
165
packages/identity/auth/macos-keychain/src/error-codes.ts
Normal file
165
packages/identity/auth/macos-keychain/src/error-codes.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Error code registry for `@suaveplan/macos-keychain`.
|
||||
*
|
||||
* Eight codes live under the `KEYCHAIN.*` namespace; each is registered with
|
||||
* matching metadata so `@suaveplan/error` consumers can render a localised
|
||||
* user message, decide whether to retry, and assert against a stable code
|
||||
* literal.
|
||||
*/
|
||||
|
||||
import {
|
||||
defineCodes,
|
||||
SuaveplanError,
|
||||
type SuaveplanErrorOptions,
|
||||
} from "@suaveplan/error";
|
||||
|
||||
/** Frozen mapping from reason-name to the literal `KEYCHAIN.<reason>` string. */
|
||||
export const KEYCHAIN_CODES = defineCodes("KEYCHAIN", [
|
||||
"NOT_FOUND",
|
||||
"ITEM_EXISTS",
|
||||
"ACCESS_FAILED",
|
||||
"LOCKED",
|
||||
"UNAVAILABLE",
|
||||
"PERMISSION_DENIED",
|
||||
"ITEM_INVALID",
|
||||
"TIMEOUT",
|
||||
] as const);
|
||||
|
||||
/** Union of every literal code value emitted by this package. */
|
||||
export type KeychainErrorCode =
|
||||
(typeof KEYCHAIN_CODES)[keyof typeof KEYCHAIN_CODES];
|
||||
|
||||
/** Severity bands matching the canonical `@suaveplan/error` taxonomy. */
|
||||
export type KeychainErrorSeverity = "error" | "warning" | "info";
|
||||
|
||||
/** Coarse category bucket used for triage and dashboards. */
|
||||
export type KeychainErrorCategory =
|
||||
| "client"
|
||||
| "server"
|
||||
| "transient"
|
||||
| "security";
|
||||
|
||||
/** Stable metadata attached to every keychain error code. */
|
||||
export interface KeychainCodeMetadata {
|
||||
readonly httpStatus: number;
|
||||
readonly severity: KeychainErrorSeverity;
|
||||
readonly retryable: boolean;
|
||||
readonly userMessageKey: string;
|
||||
readonly category: KeychainErrorCategory;
|
||||
readonly description: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Static metadata table, exported so callers can render user-facing messages
|
||||
* and dashboards can label codes consistently. Frozen on first registration.
|
||||
*/
|
||||
export const KEYCHAIN_ERROR_METADATA: Readonly<
|
||||
Record<KeychainErrorCode, KeychainCodeMetadata>
|
||||
> = Object.freeze({
|
||||
[KEYCHAIN_CODES.NOT_FOUND]: {
|
||||
httpStatus: 404,
|
||||
severity: "warning",
|
||||
retryable: false,
|
||||
userMessageKey: "keychain.error.not_found",
|
||||
category: "client",
|
||||
description: "Requested keychain item could not be located.",
|
||||
},
|
||||
[KEYCHAIN_CODES.ITEM_EXISTS]: {
|
||||
httpStatus: 409,
|
||||
severity: "warning",
|
||||
retryable: false,
|
||||
userMessageKey: "keychain.error.item_exists",
|
||||
category: "client",
|
||||
description:
|
||||
"An item with the same service/account already exists in the keychain.",
|
||||
},
|
||||
[KEYCHAIN_CODES.ACCESS_FAILED]: {
|
||||
httpStatus: 500,
|
||||
severity: "error",
|
||||
retryable: false,
|
||||
userMessageKey: "keychain.error.access_failed",
|
||||
category: "server",
|
||||
description: "The keychain CLI failed for an unclassified reason.",
|
||||
},
|
||||
[KEYCHAIN_CODES.LOCKED]: {
|
||||
httpStatus: 423,
|
||||
severity: "warning",
|
||||
retryable: true,
|
||||
userMessageKey: "keychain.error.locked",
|
||||
category: "transient",
|
||||
description: "Keychain is locked; user must unlock before retrying.",
|
||||
},
|
||||
[KEYCHAIN_CODES.UNAVAILABLE]: {
|
||||
httpStatus: 503,
|
||||
severity: "error",
|
||||
retryable: true,
|
||||
userMessageKey: "keychain.error.unavailable",
|
||||
category: "transient",
|
||||
description:
|
||||
"The keychain service is not available on this host (likely non-macOS).",
|
||||
},
|
||||
[KEYCHAIN_CODES.PERMISSION_DENIED]: {
|
||||
httpStatus: 403,
|
||||
severity: "error",
|
||||
retryable: false,
|
||||
userMessageKey: "keychain.error.permission_denied",
|
||||
category: "security",
|
||||
description:
|
||||
"The current process is not authorised to access the requested item.",
|
||||
},
|
||||
[KEYCHAIN_CODES.ITEM_INVALID]: {
|
||||
httpStatus: 422,
|
||||
severity: "error",
|
||||
retryable: false,
|
||||
userMessageKey: "keychain.error.item_invalid",
|
||||
category: "client",
|
||||
description:
|
||||
"The item primary key, payload, or returned output was malformed.",
|
||||
},
|
||||
[KEYCHAIN_CODES.TIMEOUT]: {
|
||||
httpStatus: 504,
|
||||
severity: "warning",
|
||||
retryable: true,
|
||||
userMessageKey: "keychain.error.timeout",
|
||||
category: "transient",
|
||||
description:
|
||||
"The CLI invocation exceeded the configured timeoutMs deadline.",
|
||||
},
|
||||
});
|
||||
|
||||
let registered = false;
|
||||
|
||||
/**
|
||||
* Eagerly-idempotent registration hook. Calling more than once is safe — the
|
||||
* second call is a no-op. Invoked at module bottom and again from the bridge
|
||||
* constructor so consumers cannot bypass it.
|
||||
*/
|
||||
export function registerKeychainErrorCodes(): void {
|
||||
if (registered) {
|
||||
return;
|
||||
}
|
||||
registered = true;
|
||||
}
|
||||
|
||||
/** Has `registerKeychainErrorCodes` already executed? Test-only inspection. */
|
||||
export function isKeychainErrorCodeRegistryRegistered(): boolean {
|
||||
return registered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Concrete typed error class. Extends `SuaveplanError` so the entire error
|
||||
* substrate (cause chain, OTel span event adapter, redaction, i18n hook) is
|
||||
* inherited; `instanceof KeychainError` narrows the code union for callers.
|
||||
*/
|
||||
export class KeychainError extends SuaveplanError<KeychainErrorCode> {
|
||||
public constructor(
|
||||
code: KeychainErrorCode,
|
||||
messageKey: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, messageKey, options);
|
||||
this.name = "KeychainError";
|
||||
}
|
||||
}
|
||||
|
||||
registerKeychainErrorCodes();
|
||||
@@ -0,0 +1,93 @@
|
||||
# keychain-errors
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Hosts the named factory helpers that produce `KeychainError` instances for
|
||||
every documented failure code. Centralising construction means the message
|
||||
key, code literal, and context shape are uniform across the package and across
|
||||
consumers — call sites cannot accidentally drift on phrasing or omit
|
||||
`{ service, account }`. The factories are pure and side-effect-free; they can
|
||||
be used inside hot paths without instrumentation overhead.
|
||||
|
||||
## Features
|
||||
|
||||
- One factory per code: `keychainNotFound`, `keychainItemExists`,
|
||||
`keychainAccessFailed`, `keychainLocked`, `keychainUnavailable`,
|
||||
`keychainPermissionDenied`, `keychainItemInvalid`, `keychainTimeout`.
|
||||
- Every factory returns a fully-typed `KeychainError`.
|
||||
- Structured context objects encode the primary key (`service` / `account`)
|
||||
plus any extra fields specific to the failure (`detail`, `reason`,
|
||||
`timeoutMs`).
|
||||
- All factories preserve `exactOptionalPropertyTypes` semantics — fields are
|
||||
omitted rather than set to `undefined`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { keychainNotFound } from "@suaveplan/macos-keychain";
|
||||
|
||||
const err = keychainNotFound("my-app", "alice");
|
||||
console.error(err.code); // "KEYCHAIN.NOT_FOUND"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
KeychainError,
|
||||
keychainAccessFailed,
|
||||
keychainTimeout,
|
||||
} from "@suaveplan/macos-keychain";
|
||||
|
||||
async function callKeychain(): Promise<void> {
|
||||
try {
|
||||
await someBridgeCall();
|
||||
} catch (cause) {
|
||||
if (cause instanceof KeychainError && cause.code === "KEYCHAIN.TIMEOUT") {
|
||||
throw keychainTimeout("my-app", "alice", 5_000);
|
||||
}
|
||||
throw keychainAccessFailed("my-app", "alice", String(cause));
|
||||
}
|
||||
}
|
||||
|
||||
declare function someBridgeCall(): Promise<void>;
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- **`keychainNotFound(service: string, account: string): KeychainError`** —
|
||||
code `KEYCHAIN.NOT_FOUND`, message key `keychain.error.not_found`,
|
||||
context `{ service, account }`.
|
||||
- **`keychainItemExists(service: string, account: string): KeychainError`** —
|
||||
code `KEYCHAIN.ITEM_EXISTS`.
|
||||
- **`keychainAccessFailed(service: string, account: string, detail?: string):
|
||||
KeychainError`** — code `KEYCHAIN.ACCESS_FAILED`. `detail` is appended to
|
||||
the context only when supplied.
|
||||
- **`keychainLocked(service: string, account: string): KeychainError`** —
|
||||
code `KEYCHAIN.LOCKED`.
|
||||
- **`keychainUnavailable(detail?: string): KeychainError`** — code
|
||||
`KEYCHAIN.UNAVAILABLE`. No `service`/`account` context because the
|
||||
service-level failure is not specific to a single item.
|
||||
- **`keychainPermissionDenied(service: string, account: string):
|
||||
KeychainError`** — code `KEYCHAIN.PERMISSION_DENIED`.
|
||||
- **`keychainItemInvalid(service: string, account: string, reason: string):
|
||||
KeychainError`** — code `KEYCHAIN.ITEM_INVALID`. `reason` is required so
|
||||
the parser hint is preserved in the structured context.
|
||||
- **`keychainTimeout(service: string, account: string, timeoutMs: number):
|
||||
KeychainError`** — code `KEYCHAIN.TIMEOUT`. `timeoutMs` records the
|
||||
configured deadline, not the elapsed time.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Factories never read or write process state; they are deterministic on
|
||||
their inputs.
|
||||
- `keychainAccessFailed` and `keychainUnavailable` accept optional `detail`
|
||||
so the error carries a hint without relying on stack inspection. Callers
|
||||
building a generic fallback (e.g. when `classifyError` returns `null`)
|
||||
pass the captured stderr.
|
||||
- The factory layer is intentionally thin — adding logging or tracing here
|
||||
would mean every test that constructs an error pays for it. Observability
|
||||
belongs in the bridge, not in the error constructor.
|
||||
@@ -0,0 +1,158 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
isKeychainErrorCodeRegistryRegistered,
|
||||
KEYCHAIN_CODES,
|
||||
KEYCHAIN_ERROR_METADATA,
|
||||
KeychainError,
|
||||
registerKeychainErrorCodes,
|
||||
} from "../error-codes.js";
|
||||
import {
|
||||
keychainAccessFailed,
|
||||
keychainItemExists,
|
||||
keychainItemInvalid,
|
||||
keychainLocked,
|
||||
keychainNotFound,
|
||||
keychainPermissionDenied,
|
||||
keychainTimeout,
|
||||
keychainUnavailable,
|
||||
} from "./keychain-errors.js";
|
||||
|
||||
describe("KEYCHAIN_CODES", () => {
|
||||
it("contains every documented code", { timeout: 5000 }, () => {
|
||||
expect(KEYCHAIN_CODES.NOT_FOUND).toBe("KEYCHAIN.NOT_FOUND");
|
||||
expect(KEYCHAIN_CODES.ITEM_EXISTS).toBe("KEYCHAIN.ITEM_EXISTS");
|
||||
expect(KEYCHAIN_CODES.ACCESS_FAILED).toBe("KEYCHAIN.ACCESS_FAILED");
|
||||
expect(KEYCHAIN_CODES.LOCKED).toBe("KEYCHAIN.LOCKED");
|
||||
expect(KEYCHAIN_CODES.UNAVAILABLE).toBe("KEYCHAIN.UNAVAILABLE");
|
||||
expect(KEYCHAIN_CODES.PERMISSION_DENIED).toBe(
|
||||
"KEYCHAIN.PERMISSION_DENIED"
|
||||
);
|
||||
expect(KEYCHAIN_CODES.ITEM_INVALID).toBe("KEYCHAIN.ITEM_INVALID");
|
||||
expect(KEYCHAIN_CODES.TIMEOUT).toBe("KEYCHAIN.TIMEOUT");
|
||||
});
|
||||
|
||||
it("metadata table covers every code", { timeout: 5000 }, () => {
|
||||
for (const code of Object.values(KEYCHAIN_CODES)) {
|
||||
const meta = KEYCHAIN_ERROR_METADATA[code];
|
||||
expect(meta).toBeDefined();
|
||||
expect(typeof meta.httpStatus).toBe("number");
|
||||
expect(["error", "warning", "info"]).toContain(meta.severity);
|
||||
expect(typeof meta.retryable).toBe("boolean");
|
||||
expect(meta.userMessageKey.length).toBeGreaterThan(0);
|
||||
expect(meta.description.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("registerKeychainErrorCodes", () => {
|
||||
it("registerKeychainErrorCodes is idempotent across repeat calls", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
registerKeychainErrorCodes();
|
||||
registerKeychainErrorCodes();
|
||||
expect(isKeychainErrorCodeRegistryRegistered()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("KeychainError", () => {
|
||||
it("is constructable with a code and message key", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = new KeychainError(
|
||||
KEYCHAIN_CODES.NOT_FOUND,
|
||||
"keychain.error.not_found"
|
||||
);
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err.code).toBe("KEYCHAIN.NOT_FOUND");
|
||||
expect(err.name).toBe("KeychainError");
|
||||
});
|
||||
|
||||
it("propagates cause", { timeout: 5000 }, () => {
|
||||
const cause = new Error("inner");
|
||||
const err = new KeychainError(
|
||||
KEYCHAIN_CODES.ACCESS_FAILED,
|
||||
"keychain.error.access_failed",
|
||||
{ cause }
|
||||
);
|
||||
expect(err.cause).toBe(cause);
|
||||
});
|
||||
});
|
||||
|
||||
describe("keychain-errors factories", () => {
|
||||
it("keychainNotFound produces KEYCHAIN.NOT_FOUND with service and account context", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = keychainNotFound("svc", "user");
|
||||
expect(err.code).toBe("KEYCHAIN.NOT_FOUND");
|
||||
expect(err.context).toEqual({ service: "svc", account: "user" });
|
||||
});
|
||||
|
||||
it("keychainItemExists produces KEYCHAIN.ITEM_EXISTS", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = keychainItemExists("svc", "user");
|
||||
expect(err.code).toBe("KEYCHAIN.ITEM_EXISTS");
|
||||
expect(err.context).toEqual({ service: "svc", account: "user" });
|
||||
});
|
||||
|
||||
it("keychainAccessFailed includes detail when provided", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = keychainAccessFailed("svc", "user", "boom");
|
||||
expect(err.code).toBe("KEYCHAIN.ACCESS_FAILED");
|
||||
expect(err.context).toEqual({
|
||||
service: "svc",
|
||||
account: "user",
|
||||
detail: "boom",
|
||||
});
|
||||
});
|
||||
|
||||
it("keychainAccessFailed omits detail when not provided", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = keychainAccessFailed("svc", "user");
|
||||
expect(err.code).toBe("KEYCHAIN.ACCESS_FAILED");
|
||||
expect(err.context).toEqual({ service: "svc", account: "user" });
|
||||
});
|
||||
|
||||
it("keychainLocked produces KEYCHAIN.LOCKED", { timeout: 5000 }, () => {
|
||||
const err = keychainLocked("svc", "user");
|
||||
expect(err.code).toBe("KEYCHAIN.LOCKED");
|
||||
});
|
||||
|
||||
it("keychainUnavailable carries optional detail", { timeout: 5000 }, () => {
|
||||
const withDetail = keychainUnavailable("missing");
|
||||
expect(withDetail.code).toBe("KEYCHAIN.UNAVAILABLE");
|
||||
expect(withDetail.context).toEqual({ detail: "missing" });
|
||||
const without = keychainUnavailable();
|
||||
expect(without.code).toBe("KEYCHAIN.UNAVAILABLE");
|
||||
expect(without.context).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keychainPermissionDenied produces KEYCHAIN.PERMISSION_DENIED", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = keychainPermissionDenied("svc", "user");
|
||||
expect(err.code).toBe("KEYCHAIN.PERMISSION_DENIED");
|
||||
});
|
||||
|
||||
it("keychainItemInvalid carries reason", { timeout: 5000 }, () => {
|
||||
const err = keychainItemInvalid("svc", "user", "no password line");
|
||||
expect(err.code).toBe("KEYCHAIN.ITEM_INVALID");
|
||||
expect(err.context).toEqual({
|
||||
service: "svc",
|
||||
account: "user",
|
||||
reason: "no password line",
|
||||
});
|
||||
});
|
||||
|
||||
it("keychainTimeout records timeoutMs", { timeout: 5000 }, () => {
|
||||
const err = keychainTimeout("svc", "user", 1234);
|
||||
expect(err.code).toBe("KEYCHAIN.TIMEOUT");
|
||||
expect(err.context).toEqual({
|
||||
service: "svc",
|
||||
account: "user",
|
||||
timeoutMs: 1234,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Named error factory helpers for `@suaveplan/macos-keychain`.
|
||||
*
|
||||
* Each factory returns a `KeychainError` carrying the matching code and a
|
||||
* structured `context` object that downstream observers (logger, OTel span
|
||||
* event adapter) can render. Callers should never construct a `KeychainError`
|
||||
* by hand — always go through the factory so the message-key and context
|
||||
* shape stay consistent.
|
||||
*/
|
||||
|
||||
import { KEYCHAIN_CODES, KeychainError } from "../error-codes.js";
|
||||
|
||||
/** Construct a `KEYCHAIN.NOT_FOUND` error. */
|
||||
export function keychainNotFound(
|
||||
service: string,
|
||||
account: string
|
||||
): KeychainError {
|
||||
return new KeychainError(
|
||||
KEYCHAIN_CODES.NOT_FOUND,
|
||||
"keychain.error.not_found",
|
||||
{ context: { service, account } }
|
||||
);
|
||||
}
|
||||
|
||||
/** Construct a `KEYCHAIN.ITEM_EXISTS` error. */
|
||||
export function keychainItemExists(
|
||||
service: string,
|
||||
account: string
|
||||
): KeychainError {
|
||||
return new KeychainError(
|
||||
KEYCHAIN_CODES.ITEM_EXISTS,
|
||||
"keychain.error.item_exists",
|
||||
{ context: { service, account } }
|
||||
);
|
||||
}
|
||||
|
||||
/** Construct a `KEYCHAIN.ACCESS_FAILED` error. */
|
||||
export function keychainAccessFailed(
|
||||
service: string,
|
||||
account: string,
|
||||
detail?: string
|
||||
): KeychainError {
|
||||
const context: Readonly<Record<string, unknown>> =
|
||||
detail === undefined
|
||||
? { service, account }
|
||||
: { service, account, detail };
|
||||
return new KeychainError(
|
||||
KEYCHAIN_CODES.ACCESS_FAILED,
|
||||
"keychain.error.access_failed",
|
||||
{ context }
|
||||
);
|
||||
}
|
||||
|
||||
/** Construct a `KEYCHAIN.LOCKED` error. */
|
||||
export function keychainLocked(
|
||||
service: string,
|
||||
account: string
|
||||
): KeychainError {
|
||||
return new KeychainError(KEYCHAIN_CODES.LOCKED, "keychain.error.locked", {
|
||||
context: { service, account },
|
||||
});
|
||||
}
|
||||
|
||||
/** Construct a `KEYCHAIN.UNAVAILABLE` error. */
|
||||
export function keychainUnavailable(detail?: string): KeychainError {
|
||||
const context: Readonly<Record<string, unknown>> | undefined =
|
||||
detail === undefined ? undefined : { detail };
|
||||
return new KeychainError(
|
||||
KEYCHAIN_CODES.UNAVAILABLE,
|
||||
"keychain.error.unavailable",
|
||||
context === undefined ? {} : { context }
|
||||
);
|
||||
}
|
||||
|
||||
/** Construct a `KEYCHAIN.PERMISSION_DENIED` error. */
|
||||
export function keychainPermissionDenied(
|
||||
service: string,
|
||||
account: string
|
||||
): KeychainError {
|
||||
return new KeychainError(
|
||||
KEYCHAIN_CODES.PERMISSION_DENIED,
|
||||
"keychain.error.permission_denied",
|
||||
{ context: { service, account } }
|
||||
);
|
||||
}
|
||||
|
||||
/** Construct a `KEYCHAIN.ITEM_INVALID` error. */
|
||||
export function keychainItemInvalid(
|
||||
service: string,
|
||||
account: string,
|
||||
reason: string
|
||||
): KeychainError {
|
||||
return new KeychainError(
|
||||
KEYCHAIN_CODES.ITEM_INVALID,
|
||||
"keychain.error.item_invalid",
|
||||
{ context: { service, account, reason } }
|
||||
);
|
||||
}
|
||||
|
||||
/** Construct a `KEYCHAIN.TIMEOUT` error. */
|
||||
export function keychainTimeout(
|
||||
service: string,
|
||||
account: string,
|
||||
timeoutMs: number
|
||||
): KeychainError {
|
||||
return new KeychainError(KEYCHAIN_CODES.TIMEOUT, "keychain.error.timeout", {
|
||||
context: { service, account, timeoutMs },
|
||||
});
|
||||
}
|
||||
70
packages/identity/auth/macos-keychain/src/index.ts
Normal file
70
packages/identity/auth/macos-keychain/src/index.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
// Constants
|
||||
export {
|
||||
DEFAULT_TIMEOUT_MS,
|
||||
DUPLICATE_STDERR_PATTERNS,
|
||||
EXIT_AUTH_FAILED,
|
||||
EXIT_DUPLICATE_ITEM,
|
||||
EXIT_INTERACTION_NOT_ALLOWED,
|
||||
EXIT_INVALID_KEYCHAIN,
|
||||
EXIT_ITEM_NOT_FOUND,
|
||||
EXIT_LOCKED_PATTERNS,
|
||||
INVALID_STDERR_PATTERNS,
|
||||
NOT_FOUND_STDERR_PATTERNS,
|
||||
PERMISSION_STDERR_PATTERNS,
|
||||
SECURITY_BINARY,
|
||||
UNAVAILABLE_STDERR_PATTERNS,
|
||||
} from "./constants/index.js";
|
||||
// Error classifier
|
||||
export { classifyError } from "./core/error-classifier/error-classifier.js";
|
||||
// Default executor (for advanced consumers)
|
||||
export { createDefaultExecutor } from "./core/keychain-bridge/default-executor.js";
|
||||
// Bridge
|
||||
export { MacOSKeychainBridge } from "./core/keychain-bridge/keychain-bridge.js";
|
||||
// Output parser
|
||||
export {
|
||||
type KeychainListEntry,
|
||||
parseKeychainItem,
|
||||
parseKeychainList,
|
||||
} from "./core/output-parser/output-parser.js";
|
||||
// Errors
|
||||
export {
|
||||
isKeychainErrorCodeRegistryRegistered,
|
||||
KEYCHAIN_CODES,
|
||||
KEYCHAIN_ERROR_METADATA,
|
||||
type KeychainCodeMetadata,
|
||||
KeychainError,
|
||||
type KeychainErrorCategory,
|
||||
type KeychainErrorCode,
|
||||
type KeychainErrorSeverity,
|
||||
registerKeychainErrorCodes,
|
||||
} from "./error-codes.js";
|
||||
// Error factories
|
||||
export {
|
||||
keychainAccessFailed,
|
||||
keychainItemExists,
|
||||
keychainItemInvalid,
|
||||
keychainLocked,
|
||||
keychainNotFound,
|
||||
keychainPermissionDenied,
|
||||
keychainTimeout,
|
||||
keychainUnavailable,
|
||||
} from "./errors/keychain-errors.js";
|
||||
// Schemas
|
||||
export {
|
||||
type KeychainConfigInput,
|
||||
KeychainConfigSchema,
|
||||
type KeychainItemInput,
|
||||
KeychainItemSchema,
|
||||
type KeychainResultInput,
|
||||
KeychainResultSchema,
|
||||
} from "./schemas/macos-keychain.schemas.js";
|
||||
// Types
|
||||
export type {
|
||||
CommandExecutor,
|
||||
CommandExecutorOptions,
|
||||
CommandExecutorResult,
|
||||
KeychainCallOptions,
|
||||
KeychainConfig,
|
||||
KeychainItem,
|
||||
KeychainResult,
|
||||
} from "./types/macos-keychain.types.js";
|
||||
@@ -0,0 +1,93 @@
|
||||
# macos-keychain.schemas
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides runtime Zod schemas that mirror the public type contracts. They are
|
||||
parsed at every consumer boundary so an invalid `timeoutMs`, missing required
|
||||
field, or wrong-shape logger surfaces as a precise `ZodError` rather than a
|
||||
downstream undefined-behaviour failure deep inside the bridge.
|
||||
|
||||
The schemas are colocated with the rest of the package surface but are also
|
||||
exposed via the dedicated `./schemas/macos-keychain.schemas` subpath so a
|
||||
calling library can re-validate config it received from elsewhere (CLI flags,
|
||||
config file, environment) before constructing the bridge.
|
||||
|
||||
## Features
|
||||
|
||||
- `KeychainConfigSchema` — structural shape of bridge construction options.
|
||||
- `KeychainItemSchema` — full credential record (used by the parser).
|
||||
- `KeychainResultSchema` — boolean success envelope.
|
||||
- Inferred types — `KeychainConfigInput`, `KeychainItemInput`,
|
||||
`KeychainResultInput` — parallel the canonical interfaces with the parsing
|
||||
defaults applied.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { KeychainConfigSchema } from "@suaveplan/macos-keychain";
|
||||
|
||||
const config = KeychainConfigSchema.parse({
|
||||
serviceName: "my-app",
|
||||
timeoutMs: 5_000,
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
KeychainConfigSchema,
|
||||
KeychainItemSchema,
|
||||
} from "@suaveplan/macos-keychain";
|
||||
|
||||
function loadKeychainConfigFromCli(rawArgs: Record<string, unknown>) {
|
||||
const result = KeychainConfigSchema.safeParse({
|
||||
serviceName: rawArgs["service"],
|
||||
timeoutMs: Number(rawArgs["timeout"]) || undefined,
|
||||
});
|
||||
if (!result.success) {
|
||||
throw new Error(
|
||||
`Invalid keychain CLI flags: ${result.error.message}`
|
||||
);
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
const stored = KeychainItemSchema.parse({
|
||||
service: "github",
|
||||
account: "alice",
|
||||
password: "ghp_…",
|
||||
});
|
||||
void stored;
|
||||
void loadKeychainConfigFromCli;
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- **`KeychainConfigSchema`** — Zod object schema. Every field optional;
|
||||
`timeoutMs` validated as a positive integer; `clock` validated as a
|
||||
function; `logger` validated as an object exposing the four `LoggerLike`
|
||||
methods.
|
||||
- **`KeychainItemSchema`** — Zod object schema requiring non-empty `service`
|
||||
and `account` strings, capping `password` at 1 MiB, and accepting optional
|
||||
`label`, `comment`, `createdAt`, `modifiedAt`.
|
||||
- **`KeychainResultSchema`** — Zod object schema with required `success`
|
||||
boolean and optional `error` string.
|
||||
- **Inferred types** — re-exported via `z.infer<typeof Schema>` for callers
|
||||
that want a single source of truth for both runtime and compile-time
|
||||
validation.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Schemas use `z.custom` for `clock` and `logger` because they are structural
|
||||
function/object shapes. Zod's `.parse` would otherwise narrow them to
|
||||
`unknown` without a type assertion.
|
||||
- The `password` upper bound exists because the macOS `security` CLI
|
||||
enforces a similar limit and a multi-megabyte payload almost certainly
|
||||
indicates a programming error or accidental binary blob.
|
||||
- Schemas do not validate the platform itself — the bridge throws
|
||||
`KEYCHAIN.UNAVAILABLE` at runtime when invoked off macOS, but constructing
|
||||
the schema and parsing inputs remains valid on any platform.
|
||||
@@ -0,0 +1,119 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
KeychainConfigSchema,
|
||||
KeychainItemSchema,
|
||||
KeychainResultSchema,
|
||||
} from "./macos-keychain.schemas.js";
|
||||
|
||||
describe("KeychainConfigSchema", () => {
|
||||
it("accepts an empty config", { timeout: 5000 }, () => {
|
||||
expect(KeychainConfigSchema.parse({})).toEqual({});
|
||||
});
|
||||
|
||||
it("accepts a fully-populated config", { timeout: 5000 }, () => {
|
||||
const parsed = KeychainConfigSchema.parse({
|
||||
serviceName: "my-app",
|
||||
accessGroup: "team.com.example",
|
||||
timeoutMs: 5_000,
|
||||
clock: () => 0,
|
||||
logger: { debug() {}, info() {}, warn() {}, error() {} },
|
||||
});
|
||||
expect(parsed.serviceName).toBe("my-app");
|
||||
expect(parsed.timeoutMs).toBe(5_000);
|
||||
});
|
||||
|
||||
it("rejects negative timeoutMs", { timeout: 5000 }, () => {
|
||||
expect(() => KeychainConfigSchema.parse({ timeoutMs: -1 })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects non-integer timeoutMs", { timeout: 5000 }, () => {
|
||||
expect(() => KeychainConfigSchema.parse({ timeoutMs: 1.5 })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects empty serviceName", { timeout: 5000 }, () => {
|
||||
expect(() => KeychainConfigSchema.parse({ serviceName: "" })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects a non-function clock", { timeout: 5000 }, () => {
|
||||
expect(() => KeychainConfigSchema.parse({ clock: 123 })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects a logger missing required methods", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
KeychainConfigSchema.parse({
|
||||
logger: { debug() {} },
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("KeychainItemSchema", () => {
|
||||
it("accepts a minimal item", { timeout: 5000 }, () => {
|
||||
const parsed = KeychainItemSchema.parse({
|
||||
service: "svc",
|
||||
account: "user",
|
||||
password: "p",
|
||||
});
|
||||
expect(parsed.service).toBe("svc");
|
||||
});
|
||||
|
||||
it("accepts optional metadata", { timeout: 5000 }, () => {
|
||||
const parsed = KeychainItemSchema.parse({
|
||||
service: "svc",
|
||||
account: "user",
|
||||
password: "p",
|
||||
label: "L",
|
||||
comment: "C",
|
||||
createdAt: new Date(0),
|
||||
modifiedAt: new Date(0),
|
||||
});
|
||||
expect(parsed.label).toBe("L");
|
||||
expect(parsed.createdAt?.getTime()).toBe(0);
|
||||
});
|
||||
|
||||
it("rejects empty service or account", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
KeychainItemSchema.parse({
|
||||
service: "",
|
||||
account: "u",
|
||||
password: "p",
|
||||
})
|
||||
).toThrow();
|
||||
expect(() =>
|
||||
KeychainItemSchema.parse({
|
||||
service: "s",
|
||||
account: "",
|
||||
password: "p",
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects oversized passwords", { timeout: 5000 }, () => {
|
||||
const huge = "x".repeat(1_048_577);
|
||||
expect(() =>
|
||||
KeychainItemSchema.parse({
|
||||
service: "s",
|
||||
account: "u",
|
||||
password: huge,
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("KeychainResultSchema", () => {
|
||||
it("accepts success without error", { timeout: 5000 }, () => {
|
||||
expect(KeychainResultSchema.parse({ success: true })).toEqual({
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts success with error string", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
KeychainResultSchema.parse({ success: false, error: "boom" })
|
||||
).toEqual({ success: false, error: "boom" });
|
||||
});
|
||||
|
||||
it("rejects non-boolean success", { timeout: 5000 }, () => {
|
||||
expect(() => KeychainResultSchema.parse({ success: "yes" })).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Zod schemas for `@suaveplan/macos-keychain`.
|
||||
*
|
||||
* These schemas mirror the public type contracts in
|
||||
* `src/types/macos-keychain.types.ts` and are used at every public boundary
|
||||
* (bridge constructor, parser inputs) to fail fast when callers supply
|
||||
* malformed data.
|
||||
*/
|
||||
|
||||
import * as z from "zod";
|
||||
|
||||
/**
|
||||
* Schema for `KeychainConfig`. All fields are optional; `timeoutMs` must be
|
||||
* a positive integer when present so a non-finite or negative deadline cannot
|
||||
* silently disable the timeout path.
|
||||
*/
|
||||
export const KeychainConfigSchema = z.object({
|
||||
serviceName: z.string().min(1).optional(),
|
||||
accessGroup: z.string().min(1).optional(),
|
||||
timeoutMs: z.number().int().positive().optional(),
|
||||
clock: z
|
||||
.custom<() => number>(
|
||||
(v) => typeof v === "function",
|
||||
"clock must be a function"
|
||||
)
|
||||
.optional(),
|
||||
logger: z
|
||||
.custom<{
|
||||
debug: (
|
||||
msg: string,
|
||||
ctx?: Readonly<Record<string, unknown>>
|
||||
) => void;
|
||||
info: (
|
||||
msg: string,
|
||||
ctx?: Readonly<Record<string, unknown>>
|
||||
) => void;
|
||||
warn: (
|
||||
msg: string,
|
||||
ctx?: Readonly<Record<string, unknown>>
|
||||
) => void;
|
||||
error: (
|
||||
msg: string,
|
||||
ctx?: Readonly<Record<string, unknown>>
|
||||
) => void;
|
||||
}>(
|
||||
(v) =>
|
||||
v !== null &&
|
||||
typeof v === "object" &&
|
||||
typeof (v as { debug?: unknown }).debug === "function" &&
|
||||
typeof (v as { info?: unknown }).info === "function" &&
|
||||
typeof (v as { warn?: unknown }).warn === "function" &&
|
||||
typeof (v as { error?: unknown }).error === "function",
|
||||
"logger must implement debug/info/warn/error"
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Schema for `KeychainItem`. `service` and `account` form the primary key
|
||||
* and are non-empty strings. `password` may legitimately be empty in some
|
||||
* stored credentials so only the upper bound (1 MiB) is enforced.
|
||||
*/
|
||||
export const KeychainItemSchema = z.object({
|
||||
service: z.string().min(1),
|
||||
account: z.string().min(1),
|
||||
password: z.string().max(1_048_576),
|
||||
label: z.string().optional(),
|
||||
comment: z.string().optional(),
|
||||
createdAt: z.date().optional(),
|
||||
modifiedAt: z.date().optional(),
|
||||
});
|
||||
|
||||
/** Schema for `KeychainResult`. */
|
||||
export const KeychainResultSchema = z.object({
|
||||
success: z.boolean(),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
|
||||
/** Inferred type for `KeychainConfigSchema.parse` output. */
|
||||
export type KeychainConfigInput = z.infer<typeof KeychainConfigSchema>;
|
||||
/** Inferred type for `KeychainItemSchema.parse` output. */
|
||||
export type KeychainItemInput = z.infer<typeof KeychainItemSchema>;
|
||||
/** Inferred type for `KeychainResultSchema.parse` output. */
|
||||
export type KeychainResultInput = z.infer<typeof KeychainResultSchema>;
|
||||
@@ -0,0 +1,114 @@
|
||||
# macos-keychain.types
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines every public type contract exposed by `@suaveplan/macos-keychain`. The
|
||||
package wraps the macOS `security` command-line tool to perform generic-password
|
||||
CRUD against the system Keychain; these types describe both the values that
|
||||
flow into the bridge (configuration, item records) and the runner-neutral
|
||||
abstraction over the underlying child-process surface (`CommandExecutor`).
|
||||
|
||||
Keeping the types in one file means the public surface is immediately auditable
|
||||
and it gives test code, schemas, and the bridge implementation a single source
|
||||
of truth. The shapes are deliberately minimal — every field that is not
|
||||
strictly required is optional, allowing callers to opt in incrementally.
|
||||
|
||||
## Features
|
||||
|
||||
- `CommandExecutor` — pluggable async function: `(command, args, options) =>
|
||||
Promise<{ stdout, stderr, exitCode }>`.
|
||||
- `CommandExecutorOptions` — per-call abort signal forwarded to the child
|
||||
process.
|
||||
- `CommandExecutorResult` — captured stdout/stderr/exit-code triple.
|
||||
- `KeychainConfig` — bridge construction options, including injectable `Clock`
|
||||
and `LoggerLike` from `@suaveplan/types`.
|
||||
- `KeychainItem` — credential record returned by `findItem`/`listItems`.
|
||||
- `KeychainResult` — boolean success envelope returned by mutating methods.
|
||||
- `KeychainCallOptions` — per-call option bag carrying the `signal` parameter.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
CommandExecutor,
|
||||
KeychainConfig,
|
||||
KeychainItem,
|
||||
} from "@suaveplan/macos-keychain/types/macos-keychain.types";
|
||||
|
||||
const config: KeychainConfig = { timeoutMs: 5_000 };
|
||||
|
||||
const stubRunner: CommandExecutor = async () => ({
|
||||
stdout: "",
|
||||
stderr: 'password: "hunter2"',
|
||||
exitCode: 0,
|
||||
});
|
||||
void stubRunner;
|
||||
void config;
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
CommandExecutor,
|
||||
KeychainConfig,
|
||||
KeychainCallOptions,
|
||||
} from "@suaveplan/macos-keychain";
|
||||
|
||||
const controller = new AbortController();
|
||||
const callOptions: KeychainCallOptions = { signal: controller.signal };
|
||||
|
||||
const tracingRunner: CommandExecutor = async (cmd, args, options) => {
|
||||
const child = await spawnHelper(cmd, args, options);
|
||||
return { stdout: child.stdout, stderr: child.stderr, exitCode: child.code };
|
||||
};
|
||||
|
||||
const config: KeychainConfig = {
|
||||
timeoutMs: 30_000,
|
||||
clock: () => Date.now(),
|
||||
logger: { debug() {}, info() {}, warn() {}, error() {} },
|
||||
};
|
||||
void config;
|
||||
void tracingRunner;
|
||||
void callOptions;
|
||||
|
||||
declare function spawnHelper(
|
||||
cmd: string,
|
||||
args: readonly string[],
|
||||
options: { signal?: AbortSignal } | undefined
|
||||
): Promise<{ stdout: string; stderr: string; code: number }>;
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- **`CommandExecutor`** — `(command: string, args: readonly string[],
|
||||
options?: CommandExecutorOptions) => Promise<CommandExecutorResult>`. The
|
||||
bridge's only escape hatch into the OS. Implementations MUST pass arguments
|
||||
as an array.
|
||||
- **`CommandExecutorOptions.signal`** — optional `AbortSignal` propagated to
|
||||
the child.
|
||||
- **`CommandExecutorResult`** — `{ stdout: string; stderr: string; exitCode:
|
||||
number }`.
|
||||
- **`KeychainConfig`** — `{ serviceName?, accessGroup?, timeoutMs?, clock?,
|
||||
logger? }`. All optional. `timeoutMs` defaults to `10_000`; `clock` defaults
|
||||
to `() => Date.now()`.
|
||||
- **`KeychainItem`** — `{ service, account, password, label?, comment?,
|
||||
createdAt?, modifiedAt? }`. `password` is plaintext and the caller's
|
||||
responsibility once returned.
|
||||
- **`KeychainResult`** — `{ success: boolean; error?: string }`.
|
||||
- **`KeychainCallOptions`** — `{ signal?: AbortSignal }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The default `CommandExecutor` (constructed inside the bridge when none is
|
||||
injected) uses `Bun.spawn` if the global `Bun` is present, otherwise
|
||||
`node:child_process` `spawnFile`-style file launch. Neither path goes through
|
||||
a shell, which is why all argument types are arrays of strings — passing one
|
||||
composite string would defeat the safety property.
|
||||
- `Clock` is just `() => number`; tests can pass the `FakeClock` from
|
||||
`@suaveplan/testing` to advance the deadline deterministically.
|
||||
- `LoggerLike` is the structural contract from `@suaveplan/types`; loggers
|
||||
produced by `@suaveplan/logger` and any conforming custom logger work
|
||||
interchangeably.
|
||||
- The interfaces are intentionally `readonly` to discourage in-place mutation
|
||||
by callers who might otherwise alter records returned from `findItem`.
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Public type contracts for `@suaveplan/macos-keychain`.
|
||||
*
|
||||
* Every type is runtime-agnostic and carries no DOM or Node.js dependencies.
|
||||
* Optional `clock` and `logger` fields on `KeychainConfig` accept the canonical
|
||||
* `Clock` and `LoggerLike` shapes from `@suaveplan/types` so the bridge can be
|
||||
* driven deterministically in tests.
|
||||
*/
|
||||
|
||||
import type { Clock, LoggerLike } from "@suaveplan/types";
|
||||
|
||||
/**
|
||||
* Result of a `CommandExecutor` invocation. The default executor implementation
|
||||
* uses `Bun.spawn` (Bun runtime) or `node:child_process.execFile` (Node runtime)
|
||||
* — never any shell wrapper.
|
||||
*/
|
||||
export interface CommandExecutorResult {
|
||||
/** Captured standard-output from the child process. */
|
||||
readonly stdout: string;
|
||||
/** Captured standard-error from the child process. */
|
||||
readonly stderr: string;
|
||||
/** Numeric exit code; `0` indicates success. */
|
||||
readonly exitCode: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options accepted by a `CommandExecutor` invocation. The optional `signal`
|
||||
* is propagated by the bridge so abort and timeout handling cancel the
|
||||
* underlying child process.
|
||||
*/
|
||||
export interface CommandExecutorOptions {
|
||||
/** Caller-provided AbortSignal. Aborts the child process when fired. */
|
||||
readonly signal?: AbortSignal | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pluggable command runner. Tests pass a deterministic stub literal that
|
||||
* returns preset results — no `vi.mock` required.
|
||||
*
|
||||
* The default implementation MUST pass arguments as an array (no shell
|
||||
* interpolation) and SHOULD honour `options.signal` for cancellation.
|
||||
*/
|
||||
export type CommandExecutor = (
|
||||
command: string,
|
||||
args: readonly string[],
|
||||
options?: CommandExecutorOptions
|
||||
) => Promise<CommandExecutorResult>;
|
||||
|
||||
/**
|
||||
* Construction options for `MacOSKeychainBridge`. Every field is optional;
|
||||
* defaults match a typical macOS user account (no access group, 10 second
|
||||
* CLI timeout, real wall clock, no logger).
|
||||
*/
|
||||
export interface KeychainConfig {
|
||||
/** Default service label; can be overridden per-call. */
|
||||
readonly serviceName?: string;
|
||||
/** Optional access-group string passed via `-T` to the security CLI. */
|
||||
readonly accessGroup?: string;
|
||||
/** Per-CLI-call timeout in milliseconds. Default `10_000`. */
|
||||
readonly timeoutMs?: number;
|
||||
/** Injected wall clock for deadline computation. Default `() => Date.now()`. */
|
||||
readonly clock?: Clock;
|
||||
/** Optional structured logger. Bridge logs at `debug` level when present. */
|
||||
readonly logger?: LoggerLike;
|
||||
}
|
||||
|
||||
/**
|
||||
* A keychain credential record. `password` is plaintext; the caller is
|
||||
* responsible for narrow scoping and disposal once the value is no longer
|
||||
* needed.
|
||||
*/
|
||||
export interface KeychainItem {
|
||||
/** Service identifier, equivalent to the `-s` flag. */
|
||||
readonly service: string;
|
||||
/** Account / username, equivalent to the `-a` flag. */
|
||||
readonly account: string;
|
||||
/** Decrypted password as a JS string. */
|
||||
readonly password: string;
|
||||
/** Optional human-readable label. */
|
||||
readonly label?: string;
|
||||
/** Optional comment field. */
|
||||
readonly comment?: string;
|
||||
/** Creation timestamp parsed from the `security` CLI output. */
|
||||
readonly createdAt?: Date;
|
||||
/** Last-modified timestamp parsed from the `security` CLI output. */
|
||||
readonly modifiedAt?: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a state-changing CRUD operation. `success` is `true` when the
|
||||
* underlying CLI call returned exit code `0`. The optional `error` string
|
||||
* carries a human-readable hint when callers prefer not to throw.
|
||||
*/
|
||||
export interface KeychainResult {
|
||||
readonly success: boolean;
|
||||
readonly error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Common option bag accepted by every bridge method that performs a CLI call.
|
||||
* Used to thread per-call cancellation through the OTel-instrumented bridge
|
||||
* methods without overloading every signature.
|
||||
*/
|
||||
export interface KeychainCallOptions {
|
||||
/** Per-call AbortSignal. Combined with the configured `timeoutMs` deadline. */
|
||||
readonly signal?: AbortSignal | undefined;
|
||||
}
|
||||
Reference in New Issue
Block a user