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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions src/lib/credentials.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,19 @@ describe('writeProfile', () => {
expect(file.dev).toEqual({ apiKey: 'sk-dev', apiUrl: 'https://dev' });
});

it('reclaims stale mutation locks and removes the lock after writing', () => {
const lockPath = `${credentialsPath}.lock`;
writeFileSync(
lockPath,
`${JSON.stringify({ pid: process.pid, createdAt: Date.now() - 60_000, token: 'stale' })}\n`,
);

writeProfile('default', { apiKey: 'sk-new' }, { path: credentialsPath });

expect(readProfile('default', { path: credentialsPath })).toEqual({ apiKey: 'sk-new' });
expect(existsSync(lockPath)).toBe(false);
});

it('does not leak the api key into the on-disk file format aside from the value itself', () => {
writeProfile('default', { apiKey: 'sk-secret-12345' }, { path: credentialsPath });
const onDisk = readFileSync(credentialsPath, 'utf-8');
Expand All @@ -168,6 +181,7 @@ describe('writeProfile', () => {
describe('deleteProfile', () => {
it('returns false when the profile is missing', () => {
expect(deleteProfile('nope', { path: credentialsPath })).toBe(false);
expect(existsSync(credentialsPath)).toBe(false);
});

it('removes the named profile and leaves others intact', () => {
Expand Down
174 changes: 161 additions & 13 deletions src/lib/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ import {
readFileSync,
renameSync,
statSync,
unlinkSync,
writeFileSync,
} from 'node:fs';
import { spawnSync, type SpawnSyncReturns } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { homedir } from 'node:os';
import { dirname, join } from 'node:path';
import { localValidationError } from './errors.js';
Expand Down Expand Up @@ -62,6 +64,17 @@ export interface CredentialsOptions {
path?: string;
}

interface CredentialsLockInfo {
pid?: number;
createdAt?: number;
token?: string;
}

interface CredentialsLock {
assertHeld: () => void;
release: () => void;
}

interface RestrictiveModeOptions {
platform?: NodeJS.Platform;
env?: NodeJS.ProcessEnv;
Expand All @@ -83,6 +96,10 @@ const FIELD_TO_FILE_KEY: Record<keyof ProfileEntry, string> = {
apiUrl: 'api_url',
};

const CREDENTIALS_LOCK_RETRY_MS = 25;
const CREDENTIALS_LOCK_WAIT_MS = 5_000;
const CREDENTIALS_LOCK_STALE_MS = 30_000;

export function parseCredentials(content: string): CredentialsFile {
const result: CredentialsFile = {};
let currentEntry: ProfileEntry | null = null;
Expand Down Expand Up @@ -165,23 +182,24 @@ export function writeProfile(
): void {
assertValidProfileName(profile);
const path = resolvePath(options);
const file = readCredentialsFile(options);
file[profile] = { ...file[profile], ...entry };
writeCredentialsAtomic(path, file);
mutateCredentialsFile(path, file => {
file[profile] = { ...file[profile], ...entry };
return file;
});
}

export function deleteProfile(profile: string, options: CredentialsOptions = {}): boolean {
assertValidProfileName(profile);
const path = resolvePath(options);
const file = readCredentialsFile(options);
if (!(profile in file)) return false;
delete file[profile];
if (Object.keys(file).length === 0) {
writeCredentialsAtomic(path, {});
} else {
writeCredentialsAtomic(path, file);
}
return true;
if (!existsSync(path)) return false;
let removed = false;
mutateCredentialsFile(path, file => {
if (!(profile in file)) return undefined;
removed = true;
delete file[profile];
return file;
});
return removed;
}

/**
Expand Down Expand Up @@ -244,10 +262,140 @@ function resolvePath(options: CredentialsOptions): string {
return options.path ?? defaultCredentialsPath();
}

function writeCredentialsAtomic(path: string, file: CredentialsFile): void {
function mutateCredentialsFile(
path: string,
mutate: (file: CredentialsFile) => CredentialsFile | undefined,
): void {
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
const lock = acquireCredentialsLock(path);
try {
const file = readCredentialsFile({ path });
const nextFile = mutate(file);
if (nextFile === undefined) return;
lock.assertHeld();
writeCredentialsAtomic(path, nextFile);
} finally {
lock.release();
}
}

function writeCredentialsAtomic(path: string, file: CredentialsFile): void {
const tmp = `${path}.tmp.${process.pid}`;
writeFileSync(tmp, serializeCredentials(file), { mode: 0o600, encoding: 'utf8' });
renameSync(tmp, path);
ensureRestrictiveMode(path);
}

function acquireCredentialsLock(path: string): CredentialsLock {
const lockPath = `${path}.lock`;
const deadline = Date.now() + CREDENTIALS_LOCK_WAIT_MS;
const token = `${process.pid}:${Date.now()}:${randomUUID()}`;
const lockInfo: Required<CredentialsLockInfo> = {
pid: process.pid,
createdAt: Date.now(),
token,
};

while (true) {
try {
writeFileSync(lockPath, `${JSON.stringify(lockInfo)}\n`, {
encoding: 'utf8',
flag: 'wx',
mode: 0o600,
});
return {
assertHeld: () => assertCredentialsLockHeld(lockPath, token),
release: () => releaseCredentialsLock(lockPath, token),
};
} catch (error) {
if (!isErrnoException(error) || error.code !== 'EEXIST') {
throw error;
}

reclaimStaleCredentialsLock(lockPath);
if (Date.now() >= deadline) {
throw localValidationError(
'credentialsLock',
'timed out waiting for another credential update to finish; retry the command',
undefined,
'field',
);
}
sleepSync(CREDENTIALS_LOCK_RETRY_MS);
}
}
}

function reclaimStaleCredentialsLock(lockPath: string): void {
let lockInfo: CredentialsLockInfo | undefined;
try {
lockInfo = JSON.parse(readFileSync(lockPath, 'utf-8')) as CredentialsLockInfo;
} catch {
lockInfo = undefined;
}

const createdAt = typeof lockInfo?.createdAt === 'number' ? lockInfo.createdAt : undefined;
const ageMs = createdAt === undefined ? Number.POSITIVE_INFINITY : Date.now() - createdAt;
const pid = typeof lockInfo?.pid === 'number' ? lockInfo.pid : undefined;
if (ageMs <= CREDENTIALS_LOCK_STALE_MS && (pid === undefined || isProcessAlive(pid))) {
return;
}

try {
unlinkSync(lockPath);
} catch (error) {
if (!isErrnoException(error) || error.code !== 'ENOENT') {
throw error;
}
}
}

function assertCredentialsLockHeld(lockPath: string, token: string): void {
const lockInfo = readCredentialsLockInfo(lockPath);
if (lockInfo?.token === token) return;
throw localValidationError(
'credentialsLock',
'lost ownership of the credential update lock; retry the command',
undefined,
'field',
);
}

function releaseCredentialsLock(lockPath: string, token: string): void {
const lockInfo = readCredentialsLockInfo(lockPath);
if (lockInfo?.token !== token) return;
try {
unlinkSync(lockPath);
} catch (error) {
if (!isErrnoException(error) || error.code !== 'ENOENT') {
throw error;
}
}
}

function readCredentialsLockInfo(lockPath: string): CredentialsLockInfo | undefined {
try {
return JSON.parse(readFileSync(lockPath, 'utf-8')) as CredentialsLockInfo;
} catch {
return undefined;
}
}

function isProcessAlive(pid: number): boolean {
if (pid === process.pid) return true;
try {
process.kill(pid, 0);
return true;
} catch (error) {
if (isErrnoException(error) && error.code === 'ESRCH') return false;
return true;
}
}

function isErrnoException(error: unknown): error is NodeJS.ErrnoException {
return error instanceof Error && 'code' in error;
}

function sleepSync(ms: number): void {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
}
Loading