diff --git a/src/commands/contacts/create.ts b/src/commands/contacts/create.ts index 3ba6d6a..a37f851 100644 --- a/src/commands/contacts/create.ts +++ b/src/commands/contacts/create.ts @@ -4,6 +4,7 @@ import { runCreate } from '../../lib/actions'; import type { GlobalOpts } from '../../lib/client'; import { buildHelpText } from '../../lib/help-text'; import { cancelAndExit, requireText } from '../../lib/prompts'; +import { safeTerminalText } from '../../lib/safe-terminal-text'; import { isInteractive } from '../../lib/tty'; import { parsePropertiesJson } from './utils'; @@ -105,7 +106,7 @@ Unsubscribed: setting --unsubscribed is a team-wide opt-out from all broadcasts, }), }), onInteractive: (data) => { - console.log(`Contact created: ${data.id}`); + console.log(`Contact created: ${safeTerminalText(data.id)}`); }, }, globalOpts, diff --git a/src/commands/contacts/get.ts b/src/commands/contacts/get.ts index a2c6830..9b082a9 100644 --- a/src/commands/contacts/get.ts +++ b/src/commands/contacts/get.ts @@ -3,6 +3,7 @@ import { runGet } from '../../lib/actions'; import type { GlobalOpts } from '../../lib/client'; import { buildHelpText } from '../../lib/help-text'; import { pickId } from '../../lib/prompts'; +import { safeTerminalText } from '../../lib/safe-terminal-text'; import { contactPickerConfig } from './utils'; export const getContactCommand = new Command('get') @@ -32,18 +33,23 @@ export const getContactCommand = new Command('get') sdkCall: (resend) => resend.contacts.get(id), onInteractive: (data) => { const name = [data.first_name, data.last_name] - .filter(Boolean) + .filter((s): s is string => Boolean(s)) + .map(safeTerminalText) .join(' '); - console.log(`${data.email}${name ? ` (${name})` : ''}`); - console.log(`ID: ${data.id}`); - console.log(`Created: ${data.created_at}`); + console.log( + `${safeTerminalText(data.email)}${name ? ` (${name})` : ''}`, + ); + console.log(`ID: ${safeTerminalText(data.id)}`); + console.log(`Created: ${safeTerminalText(data.created_at)}`); console.log(`Unsubscribed: ${data.unsubscribed ? 'yes' : 'no'}`); const propEntries = Object.entries(data.properties ?? {}); if (propEntries.length > 0) { console.log('Properties:'); - for (const [key, val] of propEntries) { - console.log(` ${key}: ${val.value}`); - } + propEntries.map(([key, val]) => + console.log( + ` ${safeTerminalText(key)}: ${safeTerminalText(String(val.value))}`, + ), + ); } }, }, diff --git a/src/commands/segments/create.ts b/src/commands/segments/create.ts index 0cb044c..5116496 100644 --- a/src/commands/segments/create.ts +++ b/src/commands/segments/create.ts @@ -3,6 +3,7 @@ import { runCreate } from '../../lib/actions'; import type { GlobalOpts } from '../../lib/client'; import { buildHelpText } from '../../lib/help-text'; import { requireText } from '../../lib/prompts'; +import { safeTerminalText } from '../../lib/safe-terminal-text'; export const createSegmentCommand = new Command('create') .description('Create a new segment') @@ -37,8 +38,8 @@ Non-interactive: --name is required.`, loading: 'Creating segment...', sdkCall: (resend) => resend.segments.create({ name }), onInteractive: (data) => { - console.log(`Segment created: ${data.id}`); - console.log(`Name: ${data.name}`); + console.log(`Segment created: ${safeTerminalText(data.id)}`); + console.log(`Name: ${safeTerminalText(data.name)}`); }, }, globalOpts, diff --git a/src/commands/segments/get.ts b/src/commands/segments/get.ts index f2a194d..386336f 100644 --- a/src/commands/segments/get.ts +++ b/src/commands/segments/get.ts @@ -3,6 +3,7 @@ import { runGet } from '../../lib/actions'; import type { GlobalOpts } from '../../lib/client'; import { buildHelpText } from '../../lib/help-text'; import { pickId } from '../../lib/prompts'; +import { safeTerminalText } from '../../lib/safe-terminal-text'; import { segmentPickerConfig } from './utils'; export const getSegmentCommand = new Command('get') @@ -27,9 +28,9 @@ export const getSegmentCommand = new Command('get') loading: 'Fetching segment...', sdkCall: (resend) => resend.segments.get(id), onInteractive: (data) => { - console.log(`${data.name}`); - console.log(`ID: ${data.id}`); - console.log(`Created: ${data.created_at}`); + console.log(safeTerminalText(data.name)); + console.log(`ID: ${safeTerminalText(data.id)}`); + console.log(`Created: ${safeTerminalText(data.created_at)}`); }, }, globalOpts, diff --git a/src/commands/topics/create.ts b/src/commands/topics/create.ts index eeb0841..dc306df 100644 --- a/src/commands/topics/create.ts +++ b/src/commands/topics/create.ts @@ -3,6 +3,7 @@ import { runCreate } from '../../lib/actions'; import type { GlobalOpts } from '../../lib/client'; import { buildHelpText } from '../../lib/help-text'; import { requireText } from '../../lib/prompts'; +import { safeTerminalText } from '../../lib/safe-terminal-text'; export const createTopicCommand = new Command('create') .description('Create a new topic for subscription management') @@ -61,7 +62,7 @@ Non-interactive: --name is required.`, ...(opts.description && { description: opts.description }), }), onInteractive: (data) => { - console.log(`Topic created: ${data.id}`); + console.log(`Topic created: ${safeTerminalText(data.id)}`); }, }, globalOpts, diff --git a/src/commands/topics/get.ts b/src/commands/topics/get.ts index c91e7cd..3c2672d 100644 --- a/src/commands/topics/get.ts +++ b/src/commands/topics/get.ts @@ -3,6 +3,7 @@ import { runGet } from '../../lib/actions'; import type { GlobalOpts } from '../../lib/client'; import { buildHelpText } from '../../lib/help-text'; import { pickId } from '../../lib/prompts'; +import { safeTerminalText } from '../../lib/safe-terminal-text'; import { topicPickerConfig } from './utils'; export const getTopicCommand = new Command('get') @@ -27,13 +28,15 @@ export const getTopicCommand = new Command('get') loading: 'Fetching topic...', sdkCall: (resend) => resend.topics.get(id), onInteractive: (data) => { - console.log(`${data.name}`); - console.log(`ID: ${data.id}`); + console.log(safeTerminalText(data.name)); + console.log(`ID: ${safeTerminalText(data.id)}`); if (data.description) { - console.log(`Description: ${data.description}`); + console.log(`Description: ${safeTerminalText(data.description)}`); } - console.log(`Default subscription: ${data.default_subscription}`); - console.log(`Created: ${data.created_at}`); + console.log( + `Default subscription: ${safeTerminalText(data.default_subscription)}`, + ); + console.log(`Created: ${safeTerminalText(data.created_at)}`); }, }, globalOpts, diff --git a/src/lib/output.ts b/src/lib/output.ts index 49de92a..9360596 100644 --- a/src/lib/output.ts +++ b/src/lib/output.ts @@ -1,4 +1,5 @@ import pc from 'picocolors'; +import { safeTerminalText } from './safe-terminal-text'; export function errorMessage(err: unknown, fallback: string): string { return err instanceof Error ? err.message : fallback; @@ -49,7 +50,7 @@ export function outputError( ), ); } else { - console.error(`${pc.red('Error:')} ${error.message}`); + console.error(`${pc.red('Error:')} ${safeTerminalText(error.message)}`); } process.exit(exitCode); diff --git a/src/lib/safe-terminal-text.ts b/src/lib/safe-terminal-text.ts new file mode 100644 index 0000000..a155f7a --- /dev/null +++ b/src/lib/safe-terminal-text.ts @@ -0,0 +1,11 @@ +// biome-ignore lint/complexity/useRegexLiterals: literal form triggers noControlCharactersInRegex +const CONTROL_CHARS = new RegExp( + '[\\u0000-\\u0008\\u000B\\u000C\\u000E-\\u001F\\u007F-\\u009F]', + 'g', +); + +export const safeTerminalText = (value: string): string => + value.replace( + CONTROL_CHARS, + (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`, + ); diff --git a/src/lib/table.ts b/src/lib/table.ts index f6f64af..2f079cb 100644 --- a/src/lib/table.ts +++ b/src/lib/table.ts @@ -1,3 +1,4 @@ +import { safeTerminalText } from './safe-terminal-text'; import { isUnicodeSupported } from './tty'; // All box-drawing characters generated via String.fromCodePoint() — never literal @@ -62,8 +63,9 @@ export function renderTable( if (rows.length === 0) { return emptyMessage; } + const sanitizedRows = rows.map((r) => r.map(safeTerminalText)); const widths = headers.map((h, i) => - Math.max(h.length, ...rows.map((r) => r[i].length)), + Math.max(h.length, ...sanitizedRows.map((r) => r[i].length)), ); const termWidth = getTerminalWidth(); @@ -71,7 +73,7 @@ export function renderTable( const totalWidth = widths.reduce((s, w) => s + w, 0) + 3 * widths.length + 1; if (totalWidth > termWidth) { - return renderCards(headers, rows, termWidth); + return renderCards(headers, sanitizedRows, termWidth); } } @@ -87,5 +89,5 @@ export function renderTable( cells.map((c, i) => c.padEnd(widths[i])).join(` ${BOX.v} `) + ' ' + BOX.v; - return [top, row(headers), mid, ...rows.map(row), bot].join('\n'); + return [top, row(headers), mid, ...sanitizedRows.map(row), bot].join('\n'); } diff --git a/tests/lib/safe-terminal-text.test.ts b/tests/lib/safe-terminal-text.test.ts new file mode 100644 index 0000000..7679954 --- /dev/null +++ b/tests/lib/safe-terminal-text.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; +import { safeTerminalText } from '../../src/lib/safe-terminal-text'; + +describe('safeTerminalText', () => { + it('returns plain text unchanged', () => { + expect(safeTerminalText('hello world')).toBe('hello world'); + }); + + it('preserves newlines and tabs', () => { + expect(safeTerminalText('line1\nline2\ttab')).toBe('line1\nline2\ttab'); + }); + + it('escapes ANSI escape sequences', () => { + expect(safeTerminalText('\u001b[2Jspoofed')).toBe('\\u001b[2Jspoofed'); + }); + + it('escapes CSI cursor movement sequences', () => { + expect(safeTerminalText('\u001b[Hspoofed')).toBe('\\u001b[Hspoofed'); + }); + + it('escapes null bytes', () => { + expect(safeTerminalText('before\u0000after')).toBe('before\\u0000after'); + }); + + it('escapes C1 control characters', () => { + expect(safeTerminalText('a\u008db')).toBe('a\\u008db'); + }); + + it('escapes DEL character', () => { + expect(safeTerminalText('a\u007fb')).toBe('a\\u007fb'); + }); + + it('escapes multiple control characters in one string', () => { + expect(safeTerminalText('\u001b[31mred\u001b[0m')).toBe( + '\\u001b[31mred\\u001b[0m', + ); + }); + + it('does not escape carriage return', () => { + expect(safeTerminalText('line\r\n')).toBe('line\r\n'); + }); + + it('handles empty strings', () => { + expect(safeTerminalText('')).toBe(''); + }); + + it('escapes OSC 52 clipboard payloads', () => { + const osc52 = '\u001b]52;c;SGVsbG8=\u0007'; + const result = safeTerminalText(osc52); + expect(result).not.toContain('\u001b'); + expect(result).not.toContain('\u0007'); + }); + + it('preserves unicode text beyond control range', () => { + expect(safeTerminalText('héllo wörld 日本語')).toBe('héllo wörld 日本語'); + }); +});