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
26 changes: 24 additions & 2 deletions packages/cli/src/credentials.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { homedir } from 'node:os';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { homedir, tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { configDir } from './credentials.js';
import { configDir, credentialsPath, writeCredentials } from './credentials.js';

const ORIGINAL_XDG_CONFIG_HOME = process.env.XDG_CONFIG_HOME;
const ORIGINAL_HOME = process.env.HOME;
Expand Down Expand Up @@ -29,3 +30,24 @@ describe('configDir', () => {
expect(configDir()).toBe(join(homedir() || '.', '.config', 'sh1pt'));
});
});

describe('writeCredentials', () => {
it('supports concurrent atomic writes without sharing a temporary file', async () => {
const root = await mkdtemp(join(tmpdir(), 'sh1pt-credentials-'));
process.env.XDG_CONFIG_HOME = root;

try {
const credentials = Array.from({ length: 20 }, (_, index) => ({
access_token: `access-${index}`,
refresh_token: `refresh-${index}`,
}));

await expect(Promise.all(credentials.map(writeCredentials))).resolves.toHaveLength(20);

const saved = JSON.parse(await readFile(credentialsPath(), 'utf8'));
expect(credentials).toContainEqual(saved);
} finally {
await rm(root, { recursive: true, force: true });
}
});
});
11 changes: 8 additions & 3 deletions packages/cli/src/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,14 @@ export async function writeCredentials(creds: Credentials): Promise<void> {
// Atomic write: write to a tmp file then rename, so a crash mid-write
// never leaves credentials.json truncated/corrupt. Mirrors writeVault() in
// local-vault.ts which holds equally sensitive data.
const tmp = `${path}.tmp`;
await fs.writeFile(tmp, JSON.stringify(creds, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
await fs.rename(tmp, path);
const tmp = `${path}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
try {
await fs.writeFile(tmp, JSON.stringify(creds, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
await fs.rename(tmp, path);
} catch (error) {
await fs.unlink(tmp).catch(() => {});
throw error;
}
// rename(2) preserves the source mode, but if the destination pre-existed
// at a looser mode (e.g. 0644 from an older sh1pt build), the resulting
// file keeps that loose mode. Explicitly tighten after rename.
Expand Down
Loading