mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-16 00:14:37 +00:00
fix(persist): harden persist restore safety and edge cases
This commit is contained in:
1 parent
61bcd4df5e
commit
29cceb3a88
3 files changed
+807
-150
No files matched your search
@@ -0,0 +1,273 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import * as lockfile from 'proper-lockfile';
|
||||
import { handlePersistCommand } from '../../../src/commands/persist-command';
|
||||
|
||||
interface RestoreFixture {
|
||||
claudeDir: string;
|
||||
settingsPath: string;
|
||||
backupPath: string;
|
||||
timestamp: string;
|
||||
originalSettings: Record<string, unknown>;
|
||||
backupSettings: Record<string, unknown>;
|
||||
}
|
||||
|
||||
let tempRoot: string;
|
||||
let originalClaudeConfigDir: string | undefined;
|
||||
let originalProcessExit: typeof process.exit;
|
||||
let originalFsOpen: typeof fs.promises.open;
|
||||
let originalFsRename: typeof fs.promises.rename;
|
||||
|
||||
async function pathExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.promises.access(filePath, fs.constants.F_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createRestoreFixture(
|
||||
options: {
|
||||
timestamp?: string;
|
||||
originalSettings?: Record<string, unknown>;
|
||||
backupSettings?: Record<string, unknown>;
|
||||
} = {}
|
||||
): Promise<RestoreFixture> {
|
||||
const timestamp = options.timestamp ?? '20260110_205324';
|
||||
const claudeDir = path.join(tempRoot, '.claude');
|
||||
const settingsPath = path.join(claudeDir, 'settings.json');
|
||||
const backupPath = `${settingsPath}.backup.${timestamp}`;
|
||||
|
||||
const originalSettings = options.originalSettings ?? {
|
||||
env: { ORIGINAL_TOKEN: 'original-value' },
|
||||
permissions: { defaultMode: 'plan' },
|
||||
};
|
||||
const backupSettings = options.backupSettings ?? {
|
||||
env: { NEW_TOKEN: 'new-value' },
|
||||
permissions: { defaultMode: 'acceptEdits' },
|
||||
};
|
||||
|
||||
await fs.promises.mkdir(claudeDir, { recursive: true });
|
||||
await fs.promises.writeFile(settingsPath, JSON.stringify(originalSettings, null, 2) + '\n', 'utf8');
|
||||
await fs.promises.writeFile(backupPath, JSON.stringify(backupSettings, null, 2) + '\n', 'utf8');
|
||||
|
||||
return { claudeDir, settingsPath, backupPath, timestamp, originalSettings, backupSettings };
|
||||
}
|
||||
|
||||
function stubProcessExit(): void {
|
||||
process.exit = ((code?: number) => {
|
||||
throw new Error(`process.exit(${code ?? 0})`);
|
||||
}) as typeof process.exit;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'ccs-persist-handler-test-'));
|
||||
originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
||||
originalProcessExit = process.exit;
|
||||
originalFsOpen = fs.promises.open;
|
||||
originalFsRename = fs.promises.rename;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
process.exit = originalProcessExit;
|
||||
fs.promises.open = originalFsOpen;
|
||||
fs.promises.rename = originalFsRename;
|
||||
|
||||
if (originalClaudeConfigDir === undefined) {
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
} else {
|
||||
process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir;
|
||||
}
|
||||
|
||||
if (tempRoot) {
|
||||
await fs.promises.rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe('persist command real handler paths', () => {
|
||||
it('throws parseError for missing --permission-mode before profile detection', async () => {
|
||||
await expect(handlePersistCommand(['glm', '--permission-mode'])).rejects.toThrow(
|
||||
'Missing value for --permission-mode'
|
||||
);
|
||||
});
|
||||
|
||||
it('throws parseError for empty inline --permission-mode before profile detection', async () => {
|
||||
await expect(handlePersistCommand(['glm', '--permission-mode='])).rejects.toThrow(
|
||||
'Missing value for --permission-mode'
|
||||
);
|
||||
});
|
||||
|
||||
it('throws parseError for invalid --permission-mode before profile detection', async () => {
|
||||
await expect(handlePersistCommand(['glm', '--permission-mode', 'invalid-mode'])).rejects.toThrow(
|
||||
/Invalid --permission-mode/
|
||||
);
|
||||
});
|
||||
|
||||
it('throws parseError for unknown flags on real handler path', async () => {
|
||||
await expect(handlePersistCommand(['glm', '--unknown-flag'])).rejects.toThrow(
|
||||
/Unknown option\(s\)/
|
||||
);
|
||||
});
|
||||
|
||||
it('throws parseError for list/restore conflict on real handler path', async () => {
|
||||
await expect(handlePersistCommand(['--list-backups', '--restore'])).rejects.toThrow(
|
||||
'--list-backups cannot be used with --restore'
|
||||
);
|
||||
});
|
||||
|
||||
it('throws parseError for permission flags with --restore on real handler path', async () => {
|
||||
await expect(handlePersistCommand(['--restore', '--auto-approve'])).rejects.toThrow(
|
||||
/Permission flags are not valid with backup operations/
|
||||
);
|
||||
});
|
||||
|
||||
it('shows help when --help is present even with other invalid args', async () => {
|
||||
await expect(handlePersistCommand(['--help', '--permission-mode'])).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not create CLAUDE_CONFIG_DIR on parseError path', async () => {
|
||||
const isolatedClaudeDir = path.join(tempRoot, '.claude-parse-early');
|
||||
process.env.CLAUDE_CONFIG_DIR = isolatedClaudeDir;
|
||||
|
||||
await expect(handlePersistCommand(['glm', '--permission-mode='])).rejects.toThrow(
|
||||
'Missing value for --permission-mode'
|
||||
);
|
||||
expect(await pathExists(isolatedClaudeDir)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('persist command restore failure handling', () => {
|
||||
it('exits when lock cannot be acquired (concurrency protection)', async () => {
|
||||
const fixture = await createRestoreFixture();
|
||||
process.env.CLAUDE_CONFIG_DIR = fixture.claudeDir;
|
||||
|
||||
const release = await lockfile.lock(fixture.claudeDir, {
|
||||
stale: 60000,
|
||||
retries: { retries: 0 },
|
||||
realpath: false,
|
||||
});
|
||||
|
||||
stubProcessExit();
|
||||
try {
|
||||
await expect(handlePersistCommand(['--restore', fixture.timestamp, '--yes'])).rejects.toThrow(
|
||||
'process.exit(1)'
|
||||
);
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
});
|
||||
|
||||
it('exits when backup read fails with ENOENT after selection', async () => {
|
||||
const fixture = await createRestoreFixture();
|
||||
process.env.CLAUDE_CONFIG_DIR = fixture.claudeDir;
|
||||
|
||||
fs.promises.open = (async (...args: Parameters<typeof fs.promises.open>) => {
|
||||
const target = String(args[0]);
|
||||
if (target === fixture.backupPath) {
|
||||
const error = new Error('forced missing backup') as NodeJS.ErrnoException;
|
||||
error.code = 'ENOENT';
|
||||
throw error;
|
||||
}
|
||||
return originalFsOpen(...args);
|
||||
}) as typeof fs.promises.open;
|
||||
|
||||
stubProcessExit();
|
||||
await expect(handlePersistCommand(['--restore', fixture.timestamp, '--yes'])).rejects.toThrow(
|
||||
'process.exit(1)'
|
||||
);
|
||||
});
|
||||
|
||||
it('exits when backup read fails with ELOOP (symlink rejection)', async () => {
|
||||
const fixture = await createRestoreFixture();
|
||||
process.env.CLAUDE_CONFIG_DIR = fixture.claudeDir;
|
||||
|
||||
fs.promises.open = (async (...args: Parameters<typeof fs.promises.open>) => {
|
||||
const target = String(args[0]);
|
||||
if (target === fixture.backupPath) {
|
||||
const error = new Error('forced symlink rejection') as NodeJS.ErrnoException;
|
||||
error.code = 'ELOOP';
|
||||
throw error;
|
||||
}
|
||||
return originalFsOpen(...args);
|
||||
}) as typeof fs.promises.open;
|
||||
|
||||
stubProcessExit();
|
||||
await expect(handlePersistCommand(['--restore', fixture.timestamp, '--yes'])).rejects.toThrow(
|
||||
'process.exit(1)'
|
||||
);
|
||||
});
|
||||
|
||||
it('exits when backup path resolves to a non-regular file', async () => {
|
||||
const fixture = await createRestoreFixture();
|
||||
process.env.CLAUDE_CONFIG_DIR = fixture.claudeDir;
|
||||
|
||||
fs.promises.open = (async (...args: Parameters<typeof fs.promises.open>) => {
|
||||
const target = String(args[0]);
|
||||
if (target === fixture.backupPath) {
|
||||
const fakeHandle = {
|
||||
stat: async () => ({ isFile: () => false }),
|
||||
readFile: async () => '',
|
||||
close: async () => undefined,
|
||||
} as unknown as fs.promises.FileHandle;
|
||||
return fakeHandle;
|
||||
}
|
||||
return originalFsOpen(...args);
|
||||
}) as typeof fs.promises.open;
|
||||
|
||||
stubProcessExit();
|
||||
await expect(handlePersistCommand(['--restore', fixture.timestamp, '--yes'])).rejects.toThrow(
|
||||
'process.exit(1)'
|
||||
);
|
||||
});
|
||||
|
||||
it('rolls back settings when restore write fails mid-flight', async () => {
|
||||
const fixture = await createRestoreFixture();
|
||||
process.env.CLAUDE_CONFIG_DIR = fixture.claudeDir;
|
||||
|
||||
let renameCalls = 0;
|
||||
fs.promises.rename = (async (...args: Parameters<typeof fs.promises.rename>) => {
|
||||
renameCalls += 1;
|
||||
if (renameCalls === 1) {
|
||||
throw new Error('forced rename failure');
|
||||
}
|
||||
return originalFsRename(...args);
|
||||
}) as typeof fs.promises.rename;
|
||||
|
||||
stubProcessExit();
|
||||
await expect(handlePersistCommand(['--restore', fixture.timestamp, '--yes'])).rejects.toThrow(
|
||||
'process.exit(1)'
|
||||
);
|
||||
|
||||
const finalContent = await fs.promises.readFile(fixture.settingsPath, 'utf8');
|
||||
const finalSettings = JSON.parse(finalContent);
|
||||
expect(finalSettings).toEqual(fixture.originalSettings);
|
||||
});
|
||||
|
||||
it('includes dual failure context when restore write and rollback both fail', async () => {
|
||||
const fixture = await createRestoreFixture();
|
||||
process.env.CLAUDE_CONFIG_DIR = fixture.claudeDir;
|
||||
|
||||
fs.promises.rename = (async () => {
|
||||
throw new Error('forced rename failure');
|
||||
}) as typeof fs.promises.rename;
|
||||
|
||||
const originalConsoleLog = console.log;
|
||||
const capturedLogs: string[] = [];
|
||||
console.log = (...args: unknown[]) => {
|
||||
capturedLogs.push(args.map((arg) => String(arg)).join(' '));
|
||||
};
|
||||
|
||||
stubProcessExit();
|
||||
try {
|
||||
await expect(handlePersistCommand(['--restore', fixture.timestamp, '--yes'])).rejects.toThrow(
|
||||
'process.exit(1)'
|
||||
);
|
||||
expect(capturedLogs.some((line) => line.includes('Rollback also failed'))).toBe(true);
|
||||
} finally {
|
||||
console.log = originalConsoleLog;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,17 @@ describe('Persist Command', () => {
|
||||
*/
|
||||
function parseArgs(args) {
|
||||
const validPermissionModes = ['default', 'plan', 'acceptEdits', 'bypassPermissions'];
|
||||
const knownFlags = [
|
||||
'--yes',
|
||||
'-y',
|
||||
'--list-backups',
|
||||
'--restore',
|
||||
'--permission-mode',
|
||||
'--dangerously-skip-permissions',
|
||||
'--auto-approve',
|
||||
'--help',
|
||||
'-h',
|
||||
];
|
||||
const result = {
|
||||
profile: undefined,
|
||||
yes: false,
|
||||
@@ -32,6 +43,7 @@ describe('Persist Command', () => {
|
||||
dangerouslySkipPermissions: false,
|
||||
parseError: undefined,
|
||||
};
|
||||
const unknownFlags = [];
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
if (arg === '--yes' || arg === '-y') {
|
||||
@@ -72,8 +84,32 @@ describe('Persist Command', () => {
|
||||
result.dangerouslySkipPermissions = true;
|
||||
} else if (!arg.startsWith('-') && !result.profile) {
|
||||
result.profile = arg;
|
||||
} else if (arg.startsWith('-')) {
|
||||
const known = knownFlags.some((flag) => arg === flag || arg.startsWith(`${flag}=`));
|
||||
if (!known) {
|
||||
unknownFlags.push(arg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!result.parseError && unknownFlags.length > 0) {
|
||||
const unknownList = unknownFlags.map((flag) => `"${flag}"`).join(', ');
|
||||
result.parseError = `Unknown option(s): ${unknownList}. Run 'ccs persist --help' for usage.`;
|
||||
}
|
||||
|
||||
if (!result.parseError && result.listBackups && result.restore) {
|
||||
result.parseError = '--list-backups cannot be used with --restore';
|
||||
}
|
||||
|
||||
if (
|
||||
!result.parseError &&
|
||||
(result.listBackups || result.restore) &&
|
||||
(result.permissionMode || result.dangerouslySkipPermissions)
|
||||
) {
|
||||
result.parseError =
|
||||
'Permission flags are not valid with backup operations. Use them only with ccs persist <profile>.';
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -120,10 +156,9 @@ describe('Persist Command', () => {
|
||||
assert.strictEqual(result.yes, false);
|
||||
});
|
||||
|
||||
it('ignores unknown flags', () => {
|
||||
it('rejects unknown flags with a clear parseError', () => {
|
||||
const result = parseArgs(['glm', '--unknown', '--yes']);
|
||||
assert.strictEqual(result.profile, 'glm');
|
||||
assert.strictEqual(result.yes, true);
|
||||
assert.match(result.parseError, /Unknown option\(s\)/);
|
||||
});
|
||||
|
||||
it('takes only first positional as profile', () => {
|
||||
@@ -177,6 +212,16 @@ describe('Persist Command', () => {
|
||||
assert.match(result.parseError, /Invalid --permission-mode/);
|
||||
});
|
||||
|
||||
it('sets parseError for missing --permission-mode value', () => {
|
||||
const result = parseArgs(['glm', '--permission-mode']);
|
||||
assert.strictEqual(result.parseError, 'Missing value for --permission-mode');
|
||||
});
|
||||
|
||||
it('sets parseError for empty inline --permission-mode=', () => {
|
||||
const result = parseArgs(['glm', '--permission-mode=']);
|
||||
assert.strictEqual(result.parseError, 'Missing value for --permission-mode');
|
||||
});
|
||||
|
||||
it('parses --dangerously-skip-permissions flag', () => {
|
||||
const result = parseArgs(['glm', '--dangerously-skip-permissions']);
|
||||
assert.strictEqual(result.dangerouslySkipPermissions, true);
|
||||
@@ -193,25 +238,68 @@ describe('Persist Command', () => {
|
||||
assert.strictEqual(resolvePermissionMode(result), 'bypassPermissions');
|
||||
});
|
||||
|
||||
it('throws on conflict between dangerous mode and non-bypass permission mode', () => {
|
||||
const parsed = parseArgs(['glm', '--permission-mode', 'acceptEdits', '--auto-approve']);
|
||||
['acceptEdits', 'plan', 'default'].forEach((mode) => {
|
||||
it(`throws on conflict between dangerous mode and --permission-mode ${mode}`, () => {
|
||||
const parsed = parseArgs(['glm', '--permission-mode', mode, '--auto-approve']);
|
||||
|
||||
assert.throws(
|
||||
() => resolvePermissionMode(parsed),
|
||||
/--dangerously-skip-permissions conflicts with --permission-mode/
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps test permission mode list aligned with source constant', () => {
|
||||
const sourcePath = path.join(__dirname, '../../../src/commands/persist-command.ts');
|
||||
const sourceContent = fs.readFileSync(sourcePath, 'utf8');
|
||||
['default', 'plan', 'acceptEdits', 'bypassPermissions'].forEach((mode) => {
|
||||
assert(
|
||||
sourceContent.includes(`'${mode}'`),
|
||||
`Expected mode '${mode}' to exist in source constant`
|
||||
assert.throws(
|
||||
() => resolvePermissionMode(parsed),
|
||||
/--dangerously-skip-permissions conflicts with --permission-mode/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('allows dangerous mode with --permission-mode bypassPermissions', () => {
|
||||
const parsed = parseArgs([
|
||||
'glm',
|
||||
'--permission-mode',
|
||||
'bypassPermissions',
|
||||
'--dangerously-skip-permissions',
|
||||
]);
|
||||
assert.strictEqual(resolvePermissionMode(parsed), 'bypassPermissions');
|
||||
});
|
||||
|
||||
it('sets parseError when --list-backups and --restore are both provided', () => {
|
||||
const parsed = parseArgs(['--list-backups', '--restore']);
|
||||
assert.strictEqual(parsed.parseError, '--list-backups cannot be used with --restore');
|
||||
});
|
||||
|
||||
it('sets parseError when permission flags are used with --restore', () => {
|
||||
const parsed = parseArgs(['--restore', '--auto-approve']);
|
||||
assert.match(parsed.parseError, /Permission flags are not valid with backup operations/);
|
||||
});
|
||||
|
||||
it('sets parseError when permission flags are used with --list-backups', () => {
|
||||
const parsed = parseArgs(['--list-backups', '--permission-mode', 'plan']);
|
||||
assert.match(parsed.parseError, /Permission flags are not valid with backup operations/);
|
||||
});
|
||||
|
||||
it('keeps test permission mode list exactly aligned with source constant', () => {
|
||||
const sourcePath = path.join(__dirname, '../../../src/commands/persist-command.ts');
|
||||
const sourceContent = fs.readFileSync(sourcePath, 'utf8');
|
||||
const match = sourceContent.match(/const VALID_PERMISSION_MODES = \[(.*?)\] as const/s);
|
||||
assert(match, 'Expected VALID_PERMISSION_MODES constant to exist');
|
||||
const sourceModes = [...match[1].matchAll(/'([^']+)'/g)].map((entry) => entry[1]).sort();
|
||||
const testModes = ['default', 'plan', 'acceptEdits', 'bypassPermissions'].sort();
|
||||
assert.deepStrictEqual(sourceModes, testModes);
|
||||
});
|
||||
|
||||
it('accepts common option ordering permutations', () => {
|
||||
const cases = [
|
||||
['glm', '--yes', '--permission-mode', 'plan'],
|
||||
['--yes', 'glm', '--permission-mode=plan'],
|
||||
['--permission-mode', 'plan', 'glm', '--yes'],
|
||||
['--yes', '--permission-mode', 'plan', 'glm'],
|
||||
];
|
||||
|
||||
for (const input of cases) {
|
||||
const parsed = parseArgs(input);
|
||||
assert.strictEqual(parsed.parseError, undefined, `Expected no parseError for: ${input.join(' ')}`);
|
||||
assert.strictEqual(parsed.profile, 'glm');
|
||||
assert.strictEqual(parsed.yes, true);
|
||||
assert.strictEqual(parsed.permissionMode, 'plan');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
@@ -469,7 +557,34 @@ describe('Persist Command', () => {
|
||||
* Simulates the logic to detect sensitive keys for masking
|
||||
*/
|
||||
function isSensitiveKey(key) {
|
||||
return key.includes('TOKEN') || key.includes('KEY') || key.includes('SECRET');
|
||||
const sensitiveParts = new Set([
|
||||
'TOKEN',
|
||||
'KEY',
|
||||
'SECRET',
|
||||
'PASSWORD',
|
||||
'PASS',
|
||||
'AUTH',
|
||||
'CREDENTIAL',
|
||||
'PRIVATE',
|
||||
'ACCESS',
|
||||
'REFRESH',
|
||||
'APIKEY',
|
||||
]);
|
||||
const withCamelCaseBoundaries = key.replace(/([a-z0-9])([A-Z])/g, '$1_$2');
|
||||
const parts = withCamelCaseBoundaries.toUpperCase().split(/[^A-Z0-9]+/).filter(Boolean);
|
||||
if (parts.some((part) => sensitiveParts.has(part))) {
|
||||
return true;
|
||||
}
|
||||
const compact = parts.join('');
|
||||
return (
|
||||
compact.includes('TOKEN') ||
|
||||
compact.includes('APIKEY') ||
|
||||
compact.includes('ACCESSKEY') ||
|
||||
compact.includes('AUTHKEY') ||
|
||||
compact.includes('SECRET') ||
|
||||
compact.includes('PASSWORD') ||
|
||||
compact.includes('CREDENTIAL')
|
||||
);
|
||||
}
|
||||
|
||||
it('detects TOKEN in key name', () => {
|
||||
@@ -492,6 +607,24 @@ describe('Persist Command', () => {
|
||||
assert.strictEqual(isSensitiveKey('ANTHROPIC_MODEL'), false);
|
||||
assert.strictEqual(isSensitiveKey('DISABLE_TELEMETRY'), false);
|
||||
});
|
||||
|
||||
it('detects lowercase and mixed-case sensitive keys', () => {
|
||||
assert.strictEqual(isSensitiveKey('anthropic_auth_token'), true);
|
||||
assert.strictEqual(isSensitiveKey('Api_Key'), true);
|
||||
assert.strictEqual(isSensitiveKey('clientSecret'), true);
|
||||
});
|
||||
|
||||
it('detects additional secret families', () => {
|
||||
assert.strictEqual(isSensitiveKey('DB_PASSWORD'), true);
|
||||
assert.strictEqual(isSensitiveKey('refresh_token_value'), true);
|
||||
assert.strictEqual(isSensitiveKey('private_credential_blob'), true);
|
||||
});
|
||||
|
||||
it('detects camelCase sensitive key variants', () => {
|
||||
assert.strictEqual(isSensitiveKey('accessKeyId'), true);
|
||||
assert.strictEqual(isSensitiveKey('authTokenValue'), true);
|
||||
assert.strictEqual(isSensitiveKey('apiKeyValue'), true);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
|
||||
Reference in new issue
Block a user