diff --git a/src/commands/decryptCommand.ts b/src/commands/decryptCommand.ts index 988ebfd..64a6025 100644 --- a/src/commands/decryptCommand.ts +++ b/src/commands/decryptCommand.ts @@ -2,6 +2,7 @@ import { writeFileSync } from 'fs'; import { join } from 'path'; import { ProjectManager } from '../core/projectManager'; import { FileManager } from '../files/fileManager'; +import { dotenvEscape } from './exportCommand'; import { validateSeedPhrase, seedPhraseToMasterKey, @@ -122,10 +123,12 @@ export class DecryptCommand { process.exit(0); } - // Write .env.{branch}.decrypted + // Write .env.{branch}.decrypted. Escape values so multi-line secrets + // (PEM keys, certs) are quoted/`\n`-escaped and survive being re-read by + // dotenv — a bare `KEY=value` line would truncate at the first newline. const outputFile = `.env.${branch}.decrypted`; const content = Object.entries(decrypted) - .map(([key, value]) => `${key}=${value}`) + .map(([key, value]) => `${key}=${dotenvEscape(value)}`) .join('\n'); writeFileSync(join(process.cwd(), outputFile), content + '\n', 'utf-8'); diff --git a/src/crypto/deployCrypto.ts b/src/crypto/deployCrypto.ts index f38bd28..fddd521 100644 --- a/src/crypto/deployCrypto.ts +++ b/src/crypto/deployCrypto.ts @@ -73,7 +73,13 @@ export function parseDeployCode(deployCode: string): { * service_key = HKDF-SHA256(innerBlob, salt=projectId+hex(deployId), info="capy:deploy:service-key", 32) * DECRYPT_KEY = HKDF-SHA256(projectKey || service_key, salt=deployId, info="capy:deploy:decrypt", 32) * - * Encrypted with AES-256-GCM(envBlob, DECRYPT_KEY) where envBlob is KEY=value\n lines. + * Encrypted with AES-256-GCM(envBlob, DECRYPT_KEY) where envBlob is a JSON + * object of { KEY: value } pairs. JSON is used (rather than KEY=value\n lines) + * so values may contain newlines, `=`, and `#` and still round-trip byte-for-byte + * — multi-line secrets like PEM private keys would otherwise be truncated at the + * first line and continuation lines containing `=` would mint phantom env vars. + * `decryptSecretsBlob` still accepts the legacy line format for blobs minted by + * older CLI versions. * * IMPORTANT: innerBlob MUST be the exact base64 string that was sent to the * service for KMS-wrapping. Do not recompute innerBlob here — deployInnerWrap @@ -105,10 +111,10 @@ export function encryptEnvBlob( hkdfSync('sha256', combined, deployId, 'capy:deploy:decrypt', 32), ); - const envBlob = Object.entries(envVars) - .map(([k, v]) => `${k}=${v}`) - .join('\n'); - const plaintext = Buffer.from(envBlob, 'utf-8'); + // JSON encoding round-trips multi-line values (PEM keys, certs, JSON blobs) + // and values containing `=`/`#` faithfully. The decrypt side detects this + // format vs the legacy `KEY=value\n` lines by the leading `{`. + const plaintext = Buffer.from(JSON.stringify(envVars), 'utf-8'); const iv = randomBytes(IV_LENGTH); const cipher = createCipheriv('aes-256-gcm', decryptKey, iv, { diff --git a/src/crypto/deployRuntime.ts b/src/crypto/deployRuntime.ts index e2580eb..da42c18 100644 --- a/src/crypto/deployRuntime.ts +++ b/src/crypto/deployRuntime.ts @@ -91,11 +91,52 @@ export async function fetchServiceKey( return body.service_key; } +/** + * Parses the decrypted env-var plaintext into a { KEY: value } record. + * + * Two formats are accepted: + * - JSON object (current): `{"KEY":"value",...}` — round-trips multi-line + * values and values containing `=`/`#` byte-for-byte. + * - Legacy `KEY=value\n` lines: emitted by CLI versions before multi-line + * support. Kept for backward compatibility so a newer `capy run` can still + * decrypt a SECRETS_BLOB minted by an older `capy deploy`. + * + * The leading `{` discriminates the two: env var names cannot begin with `{`, + * so a legacy blob (which starts with a key name) never collides with JSON. + */ +export function parseEnvPlaintext(text: string): Record { + if (text.trimStart().startsWith('{')) { + try { + const obj = JSON.parse(text); + if (obj && typeof obj === 'object' && !Array.isArray(obj)) { + const result: Record = {}; + for (const [k, v] of Object.entries(obj)) { + result[k] = typeof v === 'string' ? v : String(v); + } + return result; + } + } catch { + // Not valid JSON after all — fall through to legacy line parsing. + } + } + + const result: Record = {}; + for (const line of text.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eqIdx = trimmed.indexOf('='); + if (eqIdx === -1) continue; + result[trimmed.substring(0, eqIdx)] = trimmed.substring(eqIdx + 1); + } + return result; +} + /** * Derives the DECRYPT_KEY = HKDF(PROJECT_KEY || SERVICE_KEY, salt=deployId, * info="capy:deploy:decrypt") and decrypts the env var blob with AES-256-GCM. * - * Returns a record of KEY=value pairs parsed from the "KEY1=value1\n..." plaintext. + * Returns a record of KEY=value pairs parsed from the decrypted plaintext + * (JSON object, or legacy `KEY=value\n` lines — see {@link parseEnvPlaintext}). */ export function decryptSecretsBlob( encryptedVars: Buffer, @@ -124,13 +165,5 @@ export function decryptSecretsBlob( decipher.setAuthTag(authTag); const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]); - const result: Record = {}; - for (const line of plaintext.toString('utf-8').split('\n')) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith('#')) continue; - const eqIdx = trimmed.indexOf('='); - if (eqIdx === -1) continue; - result[trimmed.substring(0, eqIdx)] = trimmed.substring(eqIdx + 1); - } - return result; + return parseEnvPlaintext(plaintext.toString('utf-8')); } diff --git a/src/files/fileManager.ts b/src/files/fileManager.ts index a275b6d..30c0743 100644 --- a/src/files/fileManager.ts +++ b/src/files/fileManager.ts @@ -403,26 +403,33 @@ export class FileManager { * Creates a snippet-enhanced encrypted value for better usability */ private createSnippetWithEncryption(originalValue: string, encryptedValue: string): string { - const valueLength = originalValue.length; + // The snippet pieces are a cosmetic plaintext preview spliced around the + // ciphertext. They MUST stay newline-free: a newline in a leading snippet + // would land in the .env line before the ciphertext, and dotenv truncates a + // value at the first newline — corrupting decryption of any multi-line + // secret (e.g. a PEM key whose first bytes include a newline). Collapse all + // whitespace runs to a single space so the preview is always one line. + const snippetSource = originalValue.replace(/\s+/g, ' '); + const valueLength = snippetSource.length; if (valueLength <= 4) { - const snippet = originalValue.slice(-1); + const snippet = snippetSource.slice(-1); return `${encryptedValue}...${snippet}`; } else if (valueLength <= 8) { - const firstSnippet = originalValue.slice(0, 1); - const lastSnippet = originalValue.slice(-1); + const firstSnippet = snippetSource.slice(0, 1); + const lastSnippet = snippetSource.slice(-1); return `${firstSnippet}...${encryptedValue}...${lastSnippet}`; } else if (valueLength <= 16) { - const firstSnippet = originalValue.slice(0, 1); - const lastSnippet = originalValue.slice(-3); + const firstSnippet = snippetSource.slice(0, 1); + const lastSnippet = snippetSource.slice(-3); return `${firstSnippet}...${encryptedValue}...${lastSnippet}`; } else if (valueLength <= 24) { - const firstSnippet = originalValue.slice(0, 2); - const lastSnippet = originalValue.slice(-6); + const firstSnippet = snippetSource.slice(0, 2); + const lastSnippet = snippetSource.slice(-6); return `${firstSnippet}...${encryptedValue}...${lastSnippet}`; } else { - const firstSnippet = originalValue.slice(0, 4); - const lastSnippet = originalValue.slice(-6); + const firstSnippet = snippetSource.slice(0, 4); + const lastSnippet = snippetSource.slice(-6); return `${firstSnippet}...${encryptedValue}...${lastSnippet}`; } } diff --git a/src/index-dev.ts b/src/index-dev.ts index 2ff2e05..17d8b3f 100644 --- a/src/index-dev.ts +++ b/src/index-dev.ts @@ -424,8 +424,10 @@ program } const { writeFileSync } = await import('fs'); + const { dotenvEscape } = await import('./commands/exportCommand'); + // Escape so multi-line secrets survive being re-read by dotenv. const content = Object.entries(decrypted) - .map(([key, value]) => `${key}=${value}`) + .map(([key, value]) => `${key}=${dotenvEscape(value as string)}`) .join('\n'); writeFileSync(envPath, content + '\n', 'utf-8'); diff --git a/src/ui/editScreen.ts b/src/ui/editScreen.ts index 18cb10d..00d1010 100644 --- a/src/ui/editScreen.ts +++ b/src/ui/editScreen.ts @@ -11,6 +11,13 @@ const CLEAR_SCREEN = `${ESC}[2J`; const CLEAR_EOL = `${ESC}[K`; const ENTER_ALT_SCREEN = `${ESC}[?1049h`; const EXIT_ALT_SCREEN = `${ESC}[?1049l`; +// Bracketed paste: when enabled the terminal wraps pasted text in these +// markers, so we can append a multi-line paste (e.g. a PEM private key) +// verbatim instead of treating its embedded newlines as Enter/commit. +const ENABLE_BRACKETED_PASTE = `${ESC}[?2004h`; +const DISABLE_BRACKETED_PASTE = `${ESC}[?2004l`; +const PASTE_START = `${ESC}[200~`; +const PASTE_END = `${ESC}[201~`; const INVERSE = `${ESC}[7m`; const RESET = `${ESC}[0m`; const DIM = `${ESC}[90m`; @@ -63,6 +70,33 @@ export function classifyLocalRow( : { status: 'local', updatedLabel: 'uncommitted' }; } +/** + * Normalizes bracketed-paste content for storage in an edit buffer. Pasted + * line breaks are kept (so multi-line secrets like PEM keys survive), CRLF/CR + * are folded to LF, and other control characters (besides tab) are dropped so a + * stray escape sequence in the paste can't corrupt the value or the terminal. + */ +export function sanitizePastedText(raw: string): string { + let out = ''; + for (const ch of raw.replace(/\r\n?/g, '\n')) { + const code = ch.charCodeAt(0); + if (code === 0x0a || code === 0x09 || (code >= 0x20 && code !== 0x7f)) { + out += ch; + } + } + return out; +} + +/** + * Collapses a (possibly multi-line) value to a single visible line for the + * single-row TUI table: newlines render as a ↵ marker and tabs as a space. + * Length is preserved 1:1 so callers that pan/clip by character offset stay + * correct. + */ +export function renderInlineValue(value: string): string { + return value.replace(/\n/g, '↵').replace(/\t/g, ' '); +} + export class EditScreen { private state: EditState = { projectName: '', branch: '', rows: [], remoteAvailable: false }; private ctx: EditContext | null = null; @@ -78,6 +112,9 @@ export class EditScreen { private quitPrompt: 'commit' | null = null; private popupOpen = false; private popupPanOffset = 0; + // Accumulates raw stdin between PASTE_START and PASTE_END markers when a + // bracketed paste spans multiple `data` chunks. null when not mid-paste. + private pasteBuffer: string | null = null; run(state: EditState, ctx: EditContext): Promise { this.state = state; @@ -94,7 +131,7 @@ export class EditScreen { this.popupPanOffset = 0; return new Promise((resolve) => { - process.stdout.write(ENTER_ALT_SCREEN + HIDE_CURSOR); + process.stdout.write(ENTER_ALT_SCREEN + HIDE_CURSOR + ENABLE_BRACKETED_PASTE); if (process.stdin.isTTY) process.stdin.setRawMode(true); process.stdin.resume(); @@ -118,7 +155,7 @@ export class EditScreen { private cleanup(): void { if (this.cleanedUp) return; this.cleanedUp = true; - process.stdout.write(SHOW_CURSOR + EXIT_ALT_SCREEN); + process.stdout.write(DISABLE_BRACKETED_PASTE + SHOW_CURSOR + EXIT_ALT_SCREEN); if (process.stdin.isTTY) process.stdin.setRawMode(false); process.stdin.pause(); if (this.onDataHandler) process.stdin.removeListener('data', this.onDataHandler); @@ -128,6 +165,15 @@ export class EditScreen { private handleKeypress(data: Buffer, resolve: () => void): void { const key = data.toString(); + // Bracketed paste arrives as ESC[200~ ESC[201~, possibly split + // across data chunks. Intercept it before any key dispatch so pasted text + // (which may contain 'q', newlines, etc.) can never trigger quit/commit or + // navigation — its content is only ever appended to an active edit buffer. + if (this.pasteBuffer !== null || key.includes(PASTE_START)) { + this.consumePaste(key); + return; + } + // Ctrl-C exits unconditionally if (key === '\x03') { this.cleanup(); @@ -282,6 +328,30 @@ export class EditScreen { this.draw(); } + // Accumulates a bracketed paste across data chunks and, once the closing + // marker arrives, appends the pasted text to the active edit buffer verbatim + // (newlines preserved). Pasted outside of edit mode, the content is dropped. + private consumePaste(chunk: string): void { + this.pasteBuffer = (this.pasteBuffer ?? '') + chunk; + const start = this.pasteBuffer.indexOf(PASTE_START); + if (start === -1) { + this.pasteBuffer = null; + return; + } + const end = this.pasteBuffer.indexOf(PASTE_END, start + PASTE_START.length); + if (end === -1) return; // paste spans more chunks — wait for the rest + + const pasted = this.pasteBuffer.substring(start + PASTE_START.length, end); + this.pasteBuffer = null; + + if (!this.editing) return; + const appended = sanitizePastedText(pasted); + if (appended) { + this.editing.buffer += appended; + this.draw(); + } + } + private handleEditKey(key: string): void { if (!this.editing) return; @@ -615,7 +685,7 @@ export class EditScreen { // and revealed state with horizontal panning when the value overflows. private renderValueField(row: EditRow, width: number): string { if (this.editing && this.editing.key === row.key) { - const display = `> ${this.editing.buffer}_`; + const display = `> ${renderInlineValue(this.editing.buffer)}_`; if (display.length <= width) return display; // Keep the cursor (end of buffer) visible — clip from the left. return '…' + display.slice(display.length - width + 1); @@ -626,9 +696,13 @@ export class EditScreen { return this.maskedSnippet(row); } - const value = row.localValue ?? row.remoteValue; - if (value === undefined) return `${DIM}(no value)${RESET}`; - if (value === '') return `${DIM}(empty)${RESET}`; + const rawValue = row.localValue ?? row.remoteValue; + if (rawValue === undefined) return `${DIM}(no value)${RESET}`; + if (rawValue === '') return `${DIM}(empty)${RESET}`; + // Collapse newlines/tabs so a revealed multi-line value (e.g. a PEM key) + // stays on one row. renderInlineValue preserves length 1:1, so the panning + // math below remains correct. + const value = renderInlineValue(rawValue); if (value.length <= width) { this.popupPanOffset = 0; @@ -694,6 +768,6 @@ export class EditScreen { private maskedSnippet(row: EditRow): string { const value = row.localValue ?? row.remoteValue; if (value === undefined || value === '') return NO_VALUE; - return formatSnippet(value); + return renderInlineValue(formatSnippet(value)); } } diff --git a/tests/crypto/deployCrypto.test.ts b/tests/crypto/deployCrypto.test.ts index 37f5933..4e61bf6 100644 --- a/tests/crypto/deployCrypto.test.ts +++ b/tests/crypto/deployCrypto.test.ts @@ -1,4 +1,6 @@ -import { randomBytes, hkdfSync } from 'crypto'; +import { randomBytes, hkdfSync, createCipheriv } from 'crypto'; +import { readFileSync } from 'fs'; +import { join } from 'path'; import { generateDeployId, generateDerivationToken, @@ -12,9 +14,39 @@ import { import { parseSecretsBlob, decryptSecretsBlob, + parseEnvPlaintext, } from '../../src/crypto/deployRuntime'; import { deriveInnerKey } from '../../src/crypto/inviteCrypto'; +const PEM = readFileSync(join(__dirname, '../fixtures/rsa_test_key.pem'), 'utf-8'); + +// Derives the same DECRYPT_KEY the deploy/run pair uses, so a test can mint a +// blob in either the current (JSON) or legacy (KEY=value\n) plaintext format. +function deriveDecryptKey(pk: Buffer, innerBlob: string, projectId: string, deployId: Buffer): Buffer { + const innerBlobBytes = Buffer.from(innerBlob, 'base64'); + const serviceKey = Buffer.from( + hkdfSync('sha256', innerBlobBytes, projectId + deployId.toString('hex'), 'capy:deploy:service-key', 32), + ); + const combined = Buffer.concat([pk, serviceKey]); + return Buffer.from(hkdfSync('sha256', combined, deployId, 'capy:deploy:decrypt', 32)); +} + +function serviceKeyHexFor(innerBlob: string, projectId: string, deployId: Buffer): string { + const innerBlobBytes = Buffer.from(innerBlob, 'base64'); + return Buffer.from( + hkdfSync('sha256', innerBlobBytes, projectId + deployId.toString('hex'), 'capy:deploy:service-key', 32), + ).toString('hex'); +} + +// Encrypts an arbitrary plaintext under the deploy DECRYPT_KEY, reproducing the +// AES-256-GCM framing of encryptEnvBlob. Used to mint a *legacy*-format blob. +function encryptPlaintextBlob(plaintext: string, decryptKey: Buffer): Buffer { + const iv = randomBytes(12); + const cipher = createCipheriv('aes-256-gcm', decryptKey, iv, { authTagLength: 16 }); + const enc = Buffer.concat([cipher.update(Buffer.from(plaintext, 'utf-8')), cipher.final()]); + return Buffer.concat([iv, enc, cipher.getAuthTag()]); +} + describe('deployCrypto', () => { const projectKey = randomBytes(32); const projectId = 'test-project-456'; @@ -237,4 +269,80 @@ describe('deployCrypto', () => { expect(decrypted).toEqual(envVars); }); }); + + // CAP-55: multi-line secrets (PEM keys, certs) were truncated at the first + // line, and continuation lines containing `=` minted phantom env vars, + // because the deploy blob serialized vars as `KEY=value\n`. It now uses JSON. + describe('multi-line and special-character values (CAP-55)', () => { + function deployRoundtrip(envVars: Record): Record { + const pk = randomBytes(32); + const dt = generateDerivationToken(); + const deployId = generateDeployId(); + const innerBlob = deployInnerWrap(pk, dt, projectId); + const encryptedVars = encryptEnvBlob(envVars, pk, innerBlob, projectId, deployId); + return decryptSecretsBlob( + encryptedVars, + pk.toString('hex'), + serviceKeyHexFor(innerBlob, projectId, deployId), + deployId, + ); + } + + it('round-trips a real multi-line RSA PEM byte-for-byte', () => { + const decrypted = deployRoundtrip({ RSA_KEY: PEM, DB: 'postgres://u:p@h/d' }); + expect(decrypted.RSA_KEY).toBe(PEM); + expect(decrypted.DB).toBe('postgres://u:p@h/d'); + }); + + it('does not mint phantom keys from value lines containing "=" or "#"', () => { + const envVars = { + CERT: '-----BEGIN CERTIFICATE-----\nkey=val\n# not a comment\n-----END CERTIFICATE-----\n', + SVC_JSON: '{"type":"service_account","key":"a=b"}', + }; + const decrypted = deployRoundtrip(envVars); + // Exactly the two declared keys — no `key`, no orphaned lines. + expect(Object.keys(decrypted).sort()).toEqual(['CERT', 'SVC_JSON']); + expect(decrypted).toEqual(envVars); + }); + + it('decrypts a legacy KEY=value\\n blob minted by an older CLI (backward compat)', () => { + const pk = randomBytes(32); + const dt = generateDerivationToken(); + const deployId = generateDeployId(); + const innerBlob = deployInnerWrap(pk, dt, projectId); + const decryptKey = deriveDecryptKey(pk, innerBlob, projectId, deployId); + + // Mint with the OLD serialization the bug-era CLI produced. + const legacyPlaintext = 'API_KEY=sk_test_xxx\nDB=postgres://u:p@h/d'; + const encryptedVars = encryptPlaintextBlob(legacyPlaintext, decryptKey); + + const decrypted = decryptSecretsBlob( + encryptedVars, + pk.toString('hex'), + serviceKeyHexFor(innerBlob, projectId, deployId), + deployId, + ); + expect(decrypted).toEqual({ API_KEY: 'sk_test_xxx', DB: 'postgres://u:p@h/d' }); + }); + }); + + describe('parseEnvPlaintext', () => { + it('parses the current JSON format', () => { + expect(parseEnvPlaintext('{"A":"1","B":"two\\nlines"}')).toEqual({ A: '1', B: 'two\nlines' }); + }); + + it('parses the legacy KEY=value line format', () => { + expect(parseEnvPlaintext('A=1\nB=2\n# comment\n')).toEqual({ A: '1', B: '2' }); + }); + + it('keeps "=" inside a JSON value intact', () => { + expect(parseEnvPlaintext('{"TOKEN":"a=b=c"}')).toEqual({ TOKEN: 'a=b=c' }); + }); + + it('falls back to line parsing if a non-JSON value happens to start with {', () => { + // Legacy blobs always start with a key name, never `{`, so this is just a + // defensive check that malformed JSON does not throw. + expect(parseEnvPlaintext('{garbage not json')).toEqual({}); + }); + }); }); diff --git a/tests/files/multilineSecrets.test.ts b/tests/files/multilineSecrets.test.ts new file mode 100644 index 0000000..8e75e3c --- /dev/null +++ b/tests/files/multilineSecrets.test.ts @@ -0,0 +1,99 @@ +// CAP-55 regression: multi-line secret values (PEM private keys, certs, JSON +// blobs) must round-trip byte-for-byte through the encrypted .env that backs +// `capy run` local mode. The cosmetic value snippet spliced around the +// ciphertext used to be able to embed a raw newline into the .env line, which +// dotenv truncates at — corrupting decryption of values whose leading bytes +// include a newline. +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { mkdtempSync, rmSync, readFileSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { parse as parseDotenv } from 'dotenv'; +import { FileManager } from '../../src/files/fileManager'; +import { dotenvEscape } from '../../src/commands/exportCommand'; + +const PEM = readFileSync(join(__dirname, '../fixtures/rsa_test_key.pem'), 'utf-8'); +const KEY = 'a'.repeat(64); + +describe('multiline secrets round-trip through encrypted .env (CAP-55)', () => { + let dir: string; + let envPath: string; + let fm: FileManager; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'capy-multiline-')); + envPath = join(dir, '.env'); + fm = new FileManager(dir); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + function roundtrip(vars: Record): Record { + fm.writeEncryptedEnvFile(vars, KEY, envPath, null, 'main'); + return fm.readEncryptedEnvFile(KEY, envPath); + } + + it('round-trips a real multi-line RSA PEM byte-for-byte', () => { + const back = roundtrip({ RSA_KEY: PEM, OTHER: 'plainval' }); + expect(back.RSA_KEY).toBe(PEM); + expect(back.OTHER).toBe('plainval'); + }); + + it('keeps every encrypted variable on a single physical .env line', () => { + fm.writeEncryptedEnvFile({ RSA_KEY: PEM }, KEY, envPath, null, 'main'); + const content = readFileSync(envPath, 'utf-8'); + const valueLines = content.split('\n').filter((l) => l.startsWith('RSA_KEY=')); + expect(valueLines.length).toBe(1); + }); + + it('round-trips a value whose leading bytes are newlines', () => { + // The snippet preview takes the first chars of the value; a newline there + // is exactly what used to corrupt the line. + const val = '\n\n-----BEGIN-----\nbody\n-----END-----'; + const back = roundtrip({ LEADER: val }); + expect(back.LEADER).toBe(val); + }); + + it('round-trips a multi-line value containing "="', () => { + const val = 'line1=foo\nline2=bar\nline3'; + const back = roundtrip({ KV: val }); + expect(back.KV).toBe(val); + }); + + // Vince's proposed test on the CAP-55 call: "have a .env file with multiple + // lines". A multi-line secret authored in a *plaintext* .env must import + // (read → encrypt) and decrypt back byte-for-byte. dotenv only preserves a + // multi-line value when it is quoted, so the supported authoring form is a + // double-quoted value (raw unquoted multi-line is truncated by dotenv itself). + it('imports a quoted multi-line PEM from a plaintext .env and round-trips it', () => { + // Author a plaintext .env the way a user migrating a key would (quoted). + writeFileSync(envPath, `RSA_KEY=${dotenvEscape(PEM)}\nDB=postgres://u:p@h/d\n`, 'utf-8'); + + // Import path: this is exactly what `capy` reads on first sync. + const imported = fm.readEnvFile(envPath); + expect(imported.RSA_KEY).toBe(PEM); + + // Encrypt it, then read it back — full local pipeline. + fm.writeEncryptedEnvFile(imported, KEY, envPath, null, 'main'); + const back = fm.readEncryptedEnvFile(KEY, envPath); + expect(back.RSA_KEY).toBe(PEM); + expect(back.DB).toBe('postgres://u:p@h/d'); + }); + + // `capy decrypt` writes .env.{branch}.decrypted; that file must be re-readable + // as a faithful .env. Bare `KEY=value` lines truncated multi-line secrets. + it('writes a decrypted multi-line value that dotenv can re-read faithfully', () => { + const decrypted = { RSA_KEY: PEM, DB: 'postgres://u:p@h/d' }; + const content = Object.entries(decrypted) + .map(([k, v]) => `${k}=${dotenvEscape(v)}`) + .join('\n'); + const outPath = join(dir, '.env.main.decrypted'); + writeFileSync(outPath, content + '\n', 'utf-8'); + + const reread = parseDotenv(readFileSync(outPath, 'utf-8')); + expect(reread.RSA_KEY).toBe(PEM); + expect(reread.DB).toBe('postgres://u:p@h/d'); + }); +}); diff --git a/tests/fixtures/rsa_test_key.pem b/tests/fixtures/rsa_test_key.pem new file mode 100644 index 0000000..3a5baa2 --- /dev/null +++ b/tests/fixtures/rsa_test_key.pem @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEpQIBAAKCAQEAzr2/pQu558j6ONXBbpSPdVPTXIip1kL7P+UTU7Kiu/v43/02 +/NYv12G+EZMLyMgvsTCtwYkX/65xgqOtxlTzbY8sl4eXx3hKzGwoEOXTIrxRCVcN +B3CF58ETWG+PDL4yEkrnovFFedST2Pl3zXxsWXcdt+LWZ6IqxThkt17LmHyCR7gz +w9mcYwVQw3LG7Igqo1rLApe8i5gXv6GNzDyU35cvnwKYJSkB8W5jYvZdZ8GOZcCM +t5BD0yCGHL4bmcLni0LIn2sZheKwLW9AdkWeo413Hf4CK2LdrYKW7PPmDZahOyqL +SbZPlPhvT/h09JUX++JU0+HrublGF2bhQ8nOrwIDAQABAoIBAQCNn+DMEHde+Aep +rYzf0rdfuXfeOOSXmbUCNUwDFylxu4/m6VCk7ZlCY3vQEDqPZct+B4nQPbkJ9xdd +VgEyD3newKjceQ+Znqcm2KStxrLXZcfhrReI1CV2+IXnHC0Tnqswas25iqx8ZAqQ +JX1Tb/vNYdMi6CrWPXotNo+QKkkwgEePzKZye/zm2f6k+hUJmWjQ1exKszrd9zH7 +/1icmaEX6jSGxEZTHXvDft6whZf6DK2LjvSeDplDPEVLqN65TGZcnVSAuRTN+MDa +6zIx/Kfhl9SoOyRt9Bx0znuBXiB8N3FBk1uF6t4lA6jrAZ05z5pN56oVd0RfBxFF +XOEImeRxAoGBAOcuaBnVqUX3KOsTy71/X5i1Zi+RLn7yku9eUs3F49bSE5AZkajS +PvN5XRUERAgCl0TZ085wQgip9kwQgHILYmcEsijyEv5jgKtZfB52lMzCt20Sv7OB +ZbrcRXyTuIHDRRT2fZ7SDDxkU4mW2njQegg1WA4wx4lx5DUbKp3deP2HAoGBAOTv +psXyn2WivUtsCB9OuqEeZx+miRQUNcIAdyKgn7PGOrTnlqSYKyRdlT+GXXtp6sXa +YlU6GRuN8rxU+iP1X/8aVVZdHGgejD6dpzUbFbt5EbeLUk6LMaI+pxlnIV4nHRXF +V4zFiu3hmIHChdCaNG+L9Lrxq+15pOdnBnX4Pa+ZAoGBAMO/Y54ccEwxz4/dHzLB +W2yujGvSfpA3TXspXuulmBoZnz7wp4lPHMaECPD2v7QYnYVK/DFclE1JvKcDgf8O +7K9WJpTNBJAqKJTuHE6fEbefWDkfGvsfocfrI1ssqZoWgbQSUqPcL0gjmyhxd2O9 +AtdYc8rwOsSCjzz4V8l78iqZAoGBANbvbLshi6cnP+NEnOePycYkvhrIBqB0TPhD +6ZX4CZgFvu5DE3qaZr6wocPPSYrpqQJygqmTbykgfsl0WphR8fuWZJI9vsK+E1ti +Ni60rBWjmA+jXPXi1wmFGurNmVVFEZhz+ztt535os/73exya+inT00OES68b6sda +QYWWN4vZAoGAG3n5vhGHtrl8wsNsk2obDbii13e40Dg5T1nClyD+nvwfBkVglZlv +MpJ/sD6y65XTAnD8u1Vbm5uJyK8lgvfxLvcWw9CRXIe/PFGCIaz++DLR5ubjiE21 +1fgbjk95HbCtFI2ezGYSibAJwgWhY2ceq0SXn/YV+f/RAri27Th+AqQ= +-----END RSA PRIVATE KEY----- diff --git a/tests/ui/editScreen.test.ts b/tests/ui/editScreen.test.ts new file mode 100644 index 0000000..e6f431f --- /dev/null +++ b/tests/ui/editScreen.test.ts @@ -0,0 +1,41 @@ +// CAP-55 regression: pasting a multi-line secret into the `capy edit` TUI must +// preserve newlines (so a PEM key pastes whole) rather than dropping them, and +// a multi-line buffer must still render on a single table row. +import { describe, it, expect } from 'bun:test'; +import { sanitizePastedText, renderInlineValue } from '../../src/ui/editScreen'; + +describe('sanitizePastedText (CAP-55)', () => { + it('preserves newlines in a pasted multi-line value', () => { + const pem = '-----BEGIN-----\nabc\ndef\n-----END-----\n'; + expect(sanitizePastedText(pem)).toBe(pem); + }); + + it('folds CRLF and lone CR to LF', () => { + expect(sanitizePastedText('a\r\nb\rc')).toBe('a\nb\nc'); + }); + + it('keeps tabs but drops other control/escape characters', () => { + // ESC (0x1b) and a stray bell (0x07) are stripped; tab and newline survive. + expect(sanitizePastedText('a\tb\x1bc\x07\nd')).toBe('a\tbc\nd'); + }); + + it('preserves non-ASCII printable characters', () => { + expect(sanitizePastedText('café→π')).toBe('café→π'); + }); +}); + +describe('renderInlineValue (CAP-55)', () => { + it('collapses newlines to a single-line marker preserving length', () => { + const out = renderInlineValue('a\nb\nc'); + expect(out).toBe('a↵b↵c'); + expect(out.length).toBe('a\nb\nc'.length); + }); + + it('renders tabs as spaces', () => { + expect(renderInlineValue('a\tb')).toBe('a b'); + }); + + it('leaves a single-line value untouched', () => { + expect(renderInlineValue('sk_test_abc')).toBe('sk_test_abc'); + }); +});