diff --git a/docs/STORAGE.md b/docs/STORAGE.md index 37dc22d..158e1a4 100644 --- a/docs/STORAGE.md +++ b/docs/STORAGE.md @@ -20,6 +20,13 @@ than the longest run found in the captured text. Readers use the indexed byte length to recover the exact capture, including whether it ended with a newline. Older entries that use the original fixed triple-backtick fence remain readable. +If a case directory has no `index.json`, commands treat it as a missing case and +`list` ignores that directory. If an index exists but is malformed or cannot be +read (including when `index.json` is a directory), `list` and case-specific +commands stop with a diagnostic that names the case and index path. Repair or +restore that plain JSON file before retrying; ClipCase does not silently discard +or replace corrupt metadata. + Entry IDs are timestamp plus content hash prefix: `YYYYMMDDTHHMMSSZ-<12 hex>`. If that ID already exists, ClipCase appends a zero-padded collision counter, starting at `-000001`. This preserves both identical captures made within the same second diff --git a/src/storage.ts b/src/storage.ts index 05f9c90..cb0ec1b 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -11,13 +11,15 @@ const LOCK_TIMEOUT_MS = 10_000; export async function ensureStore(storageDir: string): Promise { await fs.mkdir(storageDir, { recursive: true }); } export function caseDir(storageDir: string, caseName: string): string { const root = path.resolve(storageDir); const slug = slugify(caseName); if (!slug) throw new ClipcaseError(`Invalid case name: ${caseName}`); const dir = path.resolve(root, slug); if (dir === root || path.dirname(dir) !== root) throw new ClipcaseError(`Invalid case name: ${caseName}`); return dir; } async function indexPath(storageDir: string, caseName: string): Promise { return path.join(caseDir(storageDir, caseName), INDEX_FILE); } -export async function loadCase(storageDir: string, caseName: string): Promise { const target = await indexPath(storageDir, caseName); try { return JSON.parse(await fs.readFile(target, 'utf8')) as CaseMetadata; } catch { throw new ClipcaseError(`Case not found: ${caseName}`, 2); } } +export async function loadCase(storageDir: string, caseName: string): Promise { const target = await indexPath(storageDir, caseName); let contents: string; try { contents = await fs.readFile(target, 'utf8'); } catch (error) { if (hasCode(error, 'ENOENT')) throw new ClipcaseError(`Case not found: ${caseName}`, 2); throw new ClipcaseError(`Cannot read case metadata for ${slugify(caseName)} at ${target}: ${errorMessage(error)}`, 4); } try { return JSON.parse(contents) as CaseMetadata; } catch (error) { throw new ClipcaseError(`Invalid case metadata for ${slugify(caseName)} at ${target}: ${errorMessage(error)}`, 4); } } async function saveCase(storageDir: string, meta: CaseMetadata): Promise { meta.entries.sort((a, b) => a.id.localeCompare(b.id)); const target = await indexPath(storageDir, meta.name); const temporary = `${target}.${process.pid}.${Date.now()}.tmp`; try { await fs.writeFile(temporary, JSON.stringify(meta, null, 2) + '\n', { flag: 'wx' }); await fs.rename(temporary, target); } finally { await fs.rm(temporary, { force: true }); } } async function withCaseLock(storageDir: string, caseName: string, operation: () => Promise): Promise { const lock = path.join(caseDir(storageDir, caseName), LOCK_DIR); const deadline = Date.now() + LOCK_TIMEOUT_MS; while (true) { try { await fs.mkdir(lock); break; } catch (error) { if (!isAlreadyExists(error)) throw error; if (Date.now() >= deadline) throw new ClipcaseError(`Timed out waiting to update case: ${slugify(caseName)}`, 4); await new Promise((resolve) => setTimeout(resolve, LOCK_RETRY_MS)); } } try { return await operation(); } finally { await fs.rmdir(lock).catch(() => undefined); } } export async function createCase(storageDir: string, name: string, title?: string, now = new Date()): Promise { const slug = slugify(name); const dir = caseDir(storageDir, name); await ensureStore(storageDir); await fs.mkdir(path.join(dir, 'entries'), { recursive: true }); const createdAt = now.toISOString(); const meta: CaseMetadata = { name: slug, title: title ?? slug, createdAt, updatedAt: createdAt, entries: [] }; await fs.writeFile(path.join(dir, INDEX_FILE), JSON.stringify(meta, null, 2) + '\n', { flag: 'wx' }); return meta; } -export async function listCases(storageDir: string): Promise { await ensureStore(storageDir); const names = await fs.readdir(storageDir).catch(() => [] as string[]); const cases: CaseMetadata[] = []; for (const name of names.sort()) { try { cases.push(await loadCase(storageDir, name)); } catch {} } return cases.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt) || a.name.localeCompare(b.name)); } +export async function listCases(storageDir: string): Promise { await ensureStore(storageDir); const names = await fs.readdir(storageDir); const cases: CaseMetadata[] = []; for (const name of names.sort()) { try { cases.push(await loadCase(storageDir, name)); } catch (error) { if (error instanceof ClipcaseError && error.exitCode === 2) continue; throw error; } } return cases.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt) || a.name.localeCompare(b.name)); } export async function addEntry(storageDir: string, input: AddEntryInput): Promise { caseDir(storageDir, input.caseName); const findings = findSecrets(input.text); if (findings.length && !input.allowSecret) throw new ClipcaseError(`Refusing to save likely secret(s): ${findings.map((f) => f.label).join(', ')}. Re-run with --allow-secret if this is intentional.`, 3); return withCaseLock(storageDir, input.caseName, async () => { const meta = await loadCase(storageDir, input.caseName); const now = input.now ?? new Date(); const createdAt = now.toISOString(); const hash = sha256(input.text); const stamp = createdAt.replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z'); const baseId = `${stamp}-${shortHash(input.text)}`; let entry: EntryMetadata | undefined; for (let collision = 0; !entry; collision += 1) { const id = collision === 0 ? baseId : `${baseId}-${String(collision).padStart(6, '0')}`; const relPath = toPosix(path.join('entries', `${id}.md`)); const candidate: EntryMetadata = { id, caseName: meta.name, createdAt, source: input.source ?? 'stdin', tags: [...new Set(input.tags ?? [])].sort(), hash, bytes: Buffer.byteLength(input.text), path: relPath }; try { await fs.writeFile(path.join(caseDir(storageDir, meta.name), relPath), renderEntry(candidate, input.text), { flag: 'wx' }); entry = candidate; } catch (error) { if (!isAlreadyExists(error)) throw error; } } try { meta.entries.push(entry); meta.updatedAt = createdAt; await saveCase(storageDir, meta); return entry; } catch (error) { await fs.rm(path.join(caseDir(storageDir, meta.name), entry.path), { force: true }); throw error; } }); } function isAlreadyExists(error: unknown): boolean { return error instanceof Error && 'code' in error && error.code === 'EEXIST'; } +function hasCode(error: unknown, code: string): boolean { return error instanceof Error && 'code' in error && error.code === code; } +function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } function fenceFor(text: string): string { const longest = Math.max(0, ...Array.from(text.matchAll(/`+/g), (match) => match[0].length)); return '`'.repeat(Math.max(3, longest + 1)); } function serializedLabel(value: string): string { return JSON.stringify(value).slice(1, -1); } function codeSpan(value: string): string { const serialized = serializedLabel(value); const longest = Math.max(0, ...Array.from(serialized.matchAll(/`+/g), (match) => match[0].length)); const delimiter = '`'.repeat(longest + 1); return `${delimiter}${serialized}${delimiter}`; } diff --git a/test/clipcase.test.ts b/test/clipcase.test.ts index b7cf8d1..99b6536 100644 --- a/test/clipcase.test.ts +++ b/test/clipcase.test.ts @@ -2,11 +2,13 @@ import assert from 'node:assert/strict'; import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; +import { spawnSync } from 'node:child_process'; import { test } from 'node:test'; import { addEntry, createCase, exportCase, listCases, loadCase, readEntryText, searchCases } from '../src/index.js'; import { findSecrets } from '../src/secrets.js'; import { loadConfig, writeConfig } from '../src/config.js'; async function tmp(): Promise { return fs.mkdtemp(path.join(os.tmpdir(), 'clipcase-test-')); } +function cli(cwd: string, ...args: string[]) { return spawnSync(process.execPath, [path.resolve('dist/src/cli.js'), ...args], { cwd, encoding: 'utf8' }); } test('creates cases and captures deterministic entry metadata', async () => { const dir = await tmp(); await createCase(dir, 'Bug Login', 'Bug Login', new Date('2026-01-01T00:00:00.000Z')); const entry = await addEntry(dir, { caseName: 'bug-login', text: 'hello repro\n', source: 'terminal', tags: ['repro'], now: new Date('2026-01-01T00:01:00.000Z') }); assert.equal(entry.id, '20260101T000100Z-4e17aeaa9041'); assert.equal(entry.source, 'terminal'); assert.deepEqual(entry.tags, ['repro']); }); test('rejects case identifiers that do not produce a meaningful slug', async () => { const dir = await tmp(); @@ -25,6 +27,32 @@ test('rejects case identifiers that do not produce a meaningful slug', async () test('keeps identical same-second captures as distinct, ordered entries', async () => { const dir = await tmp(); await createCase(dir, 'collision'); const now = new Date('2026-01-01T00:00:01.100Z'); const first = await addEntry(dir, { caseName: 'collision', text: 'same content', now }); const second = await addEntry(dir, { caseName: 'collision', text: 'same content', now: new Date('2026-01-01T00:00:01.900Z') }); assert.equal(first.id, '20260101T000001Z-a636bd7cd420'); assert.equal(second.id, `${first.id}-000001`); assert.notEqual(first.path, second.path); const meta = await loadCase(dir, 'collision'); assert.deepEqual(meta.entries.map((entry) => entry.id), [first.id, second.id]); assert.deepEqual(meta.entries.map((entry) => entry.hash), [first.hash, first.hash]); assert.deepEqual(meta.entries.map((entry) => entry.bytes), [12, 12]); assert.equal((await searchCases(dir, 'same content')).length, 2); const exported = await exportCase(dir, 'collision'); assert.ok(exported.indexOf(`## ${first.id}\n`) < exported.indexOf(`## ${second.id}\n`)); }); test('blocks likely secrets unless explicitly allowed', async () => { const dir = await tmp(); await createCase(dir, 'secret-case'); await assert.rejects(() => addEntry(dir, { caseName: 'secret-case', text: 'token=abcdefghijklmnopqrstuvwxyz123456' }), /Refusing to save/); const entry = await addEntry(dir, { caseName: 'secret-case', text: 'token=abcdefghijklmnopqrstuvwxyz123456', allowSecret: true }); assert.ok(entry.id); assert.equal(findSecrets('AKIAABCDEFGHIJKLMNOP').length, 1); assert.equal(findSecrets('npm_abcdefghijklmnopqrstuvwxyzABCDEFGHIJKL').length, 1); }); test('lists, searches, and exports case content', async () => { const dir = await tmp(); await createCase(dir, 'bug-login', 'Login Bug'); await addEntry(dir, { caseName: 'bug-login', text: 'expired cookie causes failure', source: 'terminal', tags: ['auth'] }); assert.equal((await listCases(dir)).length, 1); const results = await searchCases(dir, 'cookie'); assert.equal(results.length, 1); const exported = await exportCase(dir, 'bug-login'); assert.match(exported, /# Login Bug/); assert.match(exported, /expired cookie/); }); +test('distinguishes missing, malformed, and unreadable case indexes', async () => { + const dir = await tmp(); + await assert.rejects(() => loadCase(dir, 'missing'), (error: unknown) => error instanceof Error && error.message === 'Case not found: missing'); + await createCase(dir, 'malformed'); + await fs.writeFile(path.join(dir, 'malformed', 'index.json'), '{broken'); + await assert.rejects(() => loadCase(dir, 'malformed'), /Invalid case metadata for malformed .*index\.json/); + await fs.mkdir(path.join(dir, 'unreadable', 'index.json'), { recursive: true }); + await assert.rejects(() => loadCase(dir, 'unreadable'), /Cannot read case metadata for unreadable .*index\.json/); + await assert.rejects(() => listCases(dir), /Invalid case metadata for malformed/); +}); +test('CLI list and show report corrupt metadata while missing show keeps exit 2', async () => { + const cwd = await tmp(); + await writeConfig('.clipcase', cwd); + const storage = path.join(cwd, '.clipcase'); + await createCase(storage, 'broken'); + await fs.writeFile(path.join(storage, 'broken', 'index.json'), '{broken'); + const list = cli(cwd, 'list'); + assert.equal(list.status, 4); + assert.match(list.stderr, /Invalid case metadata for broken .*index\.json/); + const show = cli(cwd, 'show', 'broken'); + assert.equal(show.status, 4); + assert.match(show.stderr, /Invalid case metadata for broken .*index\.json/); + const missing = cli(cwd, 'show', 'missing'); + assert.equal(missing.status, 2); + assert.equal(missing.stderr, 'Case not found: missing\n'); +}); test('round-trips arbitrary entry text and safely serializes metadata', async () => { const dir = await tmp(); await createCase(dir, 'hostile-markdown');