diff --git a/README.md b/README.md index 6fbf4ae..e093312 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,12 @@ Linux and Windows users can pipe text on stdin. `--clipboard` uses `pbpaste`, `x - `clipcase search [--json]` searches entry text, tags, and source labels offline. - `clipcase export [--out ]` produces a single Markdown bundle. Without `--out` it writes the bundle to stdout; with `--out` it creates missing parent directories and writes the file. +Case identifiers are trimmed and lowercased; runs of characters outside +`a-z`, `0-9`, `.`, `_`, and `-` become `-`, and leading or trailing `-` +characters are removed. The result must contain at least one allowed character: +identifiers such as `Bug Login` become `bug-login`, while punctuation-only +identifiers such as `!!!` are rejected instead of being mapped to another case. + ## Storage format By default ClipCase writes to `.clipcase/`. `clipcase init --storage notes/cases` writes `.clipcase.json`. `CLIPCASE_HOME=/tmp/cases` overrides config. diff --git a/docs/STORAGE.md b/docs/STORAGE.md index 2807ca6..37dc22d 100644 --- a/docs/STORAGE.md +++ b/docs/STORAGE.md @@ -3,7 +3,10 @@ ClipCase stores plain files so users can inspect, diff, back up, or delete casefiles without the CLI. - `.clipcase.json` optionally points commands at a storage directory. -- Each case directory is named with the case slug. +- Each case directory is named with the case slug. Slugs are lowercase and may + contain ASCII letters, digits, `.`, `_`, and `-`; other character runs are + normalized to `-`. An identifier must produce a non-empty slug, so blank or + punctuation-only identifiers are rejected before case data is read or written. - `index.json` contains case metadata and entry metadata. - `entries/*.md` contains front matter, hash metadata, and fenced plaintext. diff --git a/src/storage.ts b/src/storage.ts index ccef28f..05f9c90 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -9,14 +9,14 @@ const LOCK_DIR = '.index.lock'; const LOCK_RETRY_MS = 25; 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 dir = path.resolve(root, slugify(caseName)); if (dir === root || path.dirname(dir) !== root) throw new ClipcaseError(`Invalid case name: ${caseName}`); return dir; } +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 { try { return JSON.parse(await fs.readFile(await indexPath(storageDir, caseName), '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); try { return JSON.parse(await fs.readFile(target, 'utf8')) as CaseMetadata; } catch { throw new ClipcaseError(`Case not found: ${caseName}`, 2); } } 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 { await ensureStore(storageDir); const slug = slugify(name); const dir = caseDir(storageDir, slug); 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 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 addEntry(storageDir: string, input: AddEntryInput): Promise { 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; } }); } +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 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); } diff --git a/src/util.ts b/src/util.ts index 0ea11e3..e6101b8 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,6 +1,6 @@ import { createHash } from 'node:crypto'; import path from 'node:path'; -export function slugify(input: string): string { return input.trim().toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'case'; } +export function slugify(input: string): string { return input.trim().toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, ''); } export function sha256(text: string): string { return createHash('sha256').update(text).digest('hex'); } export function shortHash(text: string): string { return sha256(text).slice(0, 12); } export function toPosix(p: string): string { return p.split(path.sep).join('/'); } diff --git a/test/cli.test.ts b/test/cli.test.ts index a0dc310..552c4af 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -126,6 +126,28 @@ describe('clipcase CLI', () => { await fs.access(path.join(cwd, 'store', 'normal-case', 'index.json')); }); + it('rejects punctuation-only case identifiers without changing the case slug', async () => { + const cwd = await tmp(); + + run(['init'], cwd); + run(['new', 'case', '--title', 'Existing Case'], cwd); + const index = path.join(cwd, '.clipcase', 'case', 'index.json'); + const before = await fs.readFile(index, 'utf8'); + + for (const [args, input] of [ + [['new', '!!!'], undefined], + [['add', '@@@'], 'must not be saved\n'], + [['show', '###'], undefined], + ] as Array<[string[], string | undefined]>) { + const result = runResult(args, cwd, input); + assert.notEqual(result.status, 0, args.join(' ')); + assert.match(result.stderr, /Invalid case name/); + } + + assert.equal(await fs.readFile(index, 'utf8'), before); + assert.deepEqual(await fs.readdir(path.join(cwd, '.clipcase', 'case', 'entries')), []); + }); + it('rejects unknown commands with usage', async () => { const cwd = await tmp(); diff --git a/test/clipcase.test.ts b/test/clipcase.test.ts index cb04013..b7cf8d1 100644 --- a/test/clipcase.test.ts +++ b/test/clipcase.test.ts @@ -8,6 +8,20 @@ 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-')); } 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(); + const existing = await createCase(dir, 'case', 'Existing Case', new Date('2026-01-01T00:00:00.000Z')); + const index = path.join(dir, 'case', 'index.json'); + const before = await fs.readFile(index, 'utf8'); + + await assert.rejects(() => createCase(dir, '!!!'), /Invalid case name: !!!/); + await assert.rejects(() => loadCase(dir, '@@@'), /Invalid case name: @@@/); + await assert.rejects(() => addEntry(dir, { caseName: '###', text: 'must not be saved' }), /Invalid case name: ###/); + + assert.equal(await fs.readFile(index, 'utf8'), before); + assert.deepEqual(await fs.readdir(path.join(dir, 'case', 'entries')), []); + assert.deepEqual(await loadCase(dir, 'case'), existing); +}); 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/); });