diff --git a/src/lib/utils/metadata-tagger.ts b/src/lib/utils/metadata-tagger.ts index ea04be1..1a7e3bb 100644 Binary files a/src/lib/utils/metadata-tagger.ts and b/src/lib/utils/metadata-tagger.ts differ diff --git a/tests/utils/metadata-tagger.integration.test.ts b/tests/utils/metadata-tagger.integration.test.ts new file mode 100644 index 0000000..6a5800a --- /dev/null +++ b/tests/utils/metadata-tagger.integration.test.ts @@ -0,0 +1,152 @@ +/** + * Component: Metadata Tagging Integration Tests (real ffmpeg) + * Documentation: documentation/phase3/file-organization.md + * + * Gated behind a runtime ffmpeg/ffprobe availability check. Skips cleanly + * if either binary is missing. Verifies that tag values written via the + * real argv-form spawn path are read back byte-for-byte by ffprobe — this + * is the integration-layer guarantee that the quote-bug (#171) cannot recur. + * + * Note: availability check uses spawnSync at module load so `it.skipIf` can + * see the result (test-registration is synchronous; beforeAll runs later). + */ + +import { execFile, spawnSync } from 'child_process'; +import { promisify } from 'util'; +import fs from 'fs/promises'; +import os from 'os'; +import path from 'path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { tagAudioFileMetadata } from '@/lib/utils/metadata-tagger'; + +const execFileP = promisify(execFile); + +function hasBinarySync(bin: string): boolean { + try { + const result = spawnSync(bin, ['-version'], { stdio: 'ignore' }); + return result.status === 0; + } catch { + return false; + } +} + +const ffmpegAvailable = hasBinarySync('ffmpeg'); +const ffprobeAvailable = hasBinarySync('ffprobe'); +const skipReal = !(ffmpegAvailable && ffprobeAvailable); + +let workDir = ''; + +beforeAll(async () => { + if (skipReal) { + // eslint-disable-next-line no-console + console.warn( + `[metadata-tagger.integration] Skipping real-ffmpeg tests: ffmpeg=${ffmpegAvailable}, ffprobe=${ffprobeAvailable}` + ); + return; + } + workDir = await fs.mkdtemp(path.join(os.tmpdir(), 'rmab-tagger-it-')); +}, 30_000); + +afterAll(async () => { + if (workDir) { + try { + await fs.rm(workDir, { recursive: true, force: true }); + } catch { + // best-effort cleanup + } + } +}); + +/** Create a small silent fixture audio file in the requested format. */ +async function makeFixture(ext: '.m4a' | '.mp3' | '.flac'): Promise { + const out = path.join(workDir, `fixture-${Date.now()}-${Math.random().toString(36).slice(2)}${ext}`); + const codecArgs = + ext === '.mp3' + ? ['-c:a', 'libmp3lame', '-q:a', '9'] + : ext === '.flac' + ? ['-c:a', 'flac'] + : ['-c:a', 'aac', '-b:a', '32k']; + await execFileP('ffmpeg', [ + '-y', + '-f', 'lavfi', + '-i', 'anullsrc=channel_layout=mono:sample_rate=22050', + '-t', '1', + ...codecArgs, + out, + ]); + return out; +} + +/** Read a single metadata tag back from a file via ffprobe. */ +async function readTag(filePath: string, key: string): Promise { + const { stdout } = await execFileP('ffprobe', [ + '-v', 'error', + '-show_entries', 'format_tags', + '-of', 'json', + filePath, + ]); + const parsed = JSON.parse(stdout) as { format?: { tags?: Record } }; + const tags = parsed.format?.tags ?? {}; + return tags[key] ?? tags[key.toLowerCase()] ?? tags[key.toUpperCase()]; +} + +describe('metadata-tagger integration (real ffmpeg)', () => { + // Torture value covers the bug-class surface: + // embedded `"`, `'`, `$`, `` ` ``, `\`, em-dash. + const TORTURE = `Test "Quoted" Book — Don't $hop \`now\` \\path`; + + it.skipIf(skipReal)( + 'm4a: round-trips a torture-value title byte-for-byte', + async () => { + const src = await makeFixture('.m4a'); + const result = await tagAudioFileMetadata(src, { title: TORTURE, author: 'Author' }); + expect(result.success).toBe(true); + expect(result.taggedFilePath).toBeDefined(); + const readBack = await readTag(result.taggedFilePath!, 'title'); + expect(readBack).toBe(TORTURE); + }, + 60_000 + ); + + it.skipIf(skipReal)( + 'mp3: round-trips a torture-value title byte-for-byte', + async () => { + const src = await makeFixture('.mp3'); + const result = await tagAudioFileMetadata(src, { title: TORTURE, author: 'Author' }); + expect(result.success).toBe(true); + const readBack = await readTag(result.taggedFilePath!, 'title'); + expect(readBack).toBe(TORTURE); + }, + 60_000 + ); + + it.skipIf(skipReal)( + 'flac: round-trips a torture-value title byte-for-byte', + async () => { + const src = await makeFixture('.flac'); + const result = await tagAudioFileMetadata(src, { title: TORTURE, author: 'Author' }); + expect(result.success).toBe(true); + const readBack = await readTag(result.taggedFilePath!, 'title'); + expect(readBack).toBe(TORTURE); + }, + 60_000 + ); + + it.skipIf(skipReal)( + 'm4a: author does NOT inherit a stray leading `"` when title contains `"`', + async () => { + // The exact #171 symptom: a `"` in one field used to leak as a leading `"` + // on an adjacent field after shell tokenization. Verify both are clean. + const src = await makeFixture('.m4a'); + const result = await tagAudioFileMetadata(src, { + title: 'Has "embedded" quotes', + author: 'Alexandre Dumas', + }); + expect(result.success).toBe(true); + const readAuthor = await readTag(result.taggedFilePath!, 'album_artist'); + expect(readAuthor).toBe('Alexandre Dumas'); + expect(readAuthor?.startsWith('"')).toBe(false); + }, + 60_000 + ); +}); diff --git a/tests/utils/metadata-tagger.test.ts b/tests/utils/metadata-tagger.test.ts index 9a03104..bb3e6ba 100644 --- a/tests/utils/metadata-tagger.test.ts +++ b/tests/utils/metadata-tagger.test.ts @@ -6,14 +6,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { checkFfmpegAvailable, tagAudioFileMetadata, tagMultipleFiles } from '@/lib/utils/metadata-tagger'; -const execMock = vi.hoisted(() => vi.fn()); +const execFileMock = vi.hoisted(() => vi.fn()); const fsMock = vi.hoisted(() => ({ access: vi.fn(), unlink: vi.fn(), })); vi.mock('child_process', () => ({ - exec: execMock, + execFile: execFileMock, })); vi.mock('fs/promises', () => ({ @@ -21,20 +21,49 @@ vi.mock('fs/promises', () => ({ ...fsMock, })); -function mockExecSuccess(stdout = 'ok') { - execMock.mockImplementation((command: string, options: any, callback?: any) => { +function mockExecFileSuccess(stdout = 'ok') { + execFileMock.mockImplementation((file: string, args: string[], options: any, callback?: any) => { const cb = typeof options === 'function' ? options : callback; cb(null, stdout, ''); }); } -function mockExecFailure(message = 'ffmpeg error') { - execMock.mockImplementation((command: string, options: any, callback?: any) => { +function mockExecFileFailure(message = 'ffmpeg error') { + execFileMock.mockImplementation((file: string, args: string[], options: any, callback?: any) => { const cb = typeof options === 'function' ? options : callback; cb(new Error(message), '', ''); }); } +function lastCallArgv(): string[] { + const call = execFileMock.mock.calls.at(-1); + if (!call) throw new Error('execFile was not called'); + return call[1] as string[]; +} + +function lastCallCommand(): string { + const call = execFileMock.mock.calls.at(-1); + if (!call) throw new Error('execFile was not called'); + return call[0] as string; +} + +/** + * Assert that argv contains the adjacent pair `['-metadata', '${key}=${value}']`. + * This is the regression-catching assertion — it verifies the metadata payload + * is a single argv element with no embedded shell quoting. + */ +function expectMetadataArg(argv: string[], key: string, value: string): void { + const expectedPayload = `${key}=${value}`; + for (let i = 0; i < argv.length - 1; i++) { + if (argv[i] === '-metadata' && argv[i + 1] === expectedPayload) { + return; + } + } + throw new Error( + `Expected argv to contain ['-metadata', '${expectedPayload}'] as adjacent elements.\nArgv: ${JSON.stringify(argv)}` + ); +} + describe('metadata tagger', () => { beforeEach(() => { vi.clearAllMocks(); @@ -50,12 +79,12 @@ describe('metadata tagger', () => { expect(result.success).toBe(false); expect(result.error).toContain('Unsupported file format'); - expect(execMock).not.toHaveBeenCalled(); + expect(execFileMock).not.toHaveBeenCalled(); }); - it('tags an m4b file with metadata', async () => { + it('tags an m4b file with metadata using argv form', async () => { fsMock.access.mockResolvedValue(undefined); - mockExecSuccess('done'); + mockExecFileSuccess('done'); const result = await tagAudioFileMetadata('/tmp/book.m4b', { title: 'Book', @@ -68,19 +97,62 @@ describe('metadata tagger', () => { expect(result.success).toBe(true); expect(result.taggedFilePath).toBe('/tmp/book.m4b.tmp'); - const command = execMock.mock.calls[0][0] as string; - expect(command).toContain('-metadata title="Book"'); - expect(command).toContain('-metadata album_artist="Author"'); - expect(command).toContain('-metadata composer="Narrator"'); - expect(command).toContain('-metadata date="2020"'); - expect(command).toContain('-metadata ----:com.apple.iTunes:ASIN="ASIN123"'); - expect(command).toContain('-f mp4'); + expect(lastCallCommand()).toBe('ffmpeg'); + + const argv = lastCallArgv(); + expectMetadataArg(argv, 'title', 'Book'); + expectMetadataArg(argv, 'album', 'Book'); + expectMetadataArg(argv, 'album_artist', 'Author'); + expectMetadataArg(argv, 'artist', 'Author'); + expectMetadataArg(argv, 'composer', 'Narrator'); + expectMetadataArg(argv, 'date', '2020'); + expectMetadataArg(argv, '----:com.apple.iTunes:ASIN', 'ASIN123'); + + expect(argv).toContain('/tmp/book.m4b'); + expect(argv).toContain('/tmp/book.m4b.tmp'); + + const fIdx = argv.indexOf('-f'); + expect(fIdx).toBeGreaterThanOrEqual(0); + expect(argv[fIdx + 1]).toBe('mp4'); + }); + + it('tags an mp3 file with mp3 output format', async () => { + fsMock.access.mockResolvedValue(undefined); + mockExecFileSuccess('done'); + + await tagAudioFileMetadata('/tmp/book.mp3', { + title: 'Book', + author: 'Author', + asin: 'ASIN999', + }); + + const argv = lastCallArgv(); + expectMetadataArg(argv, 'title', 'Book'); + expectMetadataArg(argv, 'ASIN', 'ASIN999'); + const fIdx = argv.indexOf('-f'); + expect(argv[fIdx + 1]).toBe('mp3'); + }); + + it('tags a flac file with flac output format', async () => { + fsMock.access.mockResolvedValue(undefined); + mockExecFileSuccess('done'); + + await tagAudioFileMetadata('/tmp/book.flac', { + title: 'Book', + author: 'Author', + }); + + const argv = lastCallArgv(); + expectMetadataArg(argv, 'title', 'Book'); + expectMetadataArg(argv, 'albumartist', 'Author'); + const fIdx = argv.indexOf('-f'); + expect(argv[fIdx + 1]).toBe('flac'); }); it('cleans up temp files and returns errors when ffmpeg fails', async () => { fsMock.access.mockResolvedValue(undefined); fsMock.unlink.mockResolvedValue(undefined); - mockExecFailure('exec failed'); + mockExecFileFailure('exec failed'); const result = await tagAudioFileMetadata('/tmp/book.mp3', { title: 'Book', @@ -95,7 +167,7 @@ describe('metadata tagger', () => { it('tags multiple files in sequence', async () => { fsMock.access.mockResolvedValue(undefined); - mockExecSuccess('done'); + mockExecFileSuccess('done'); const results = await tagMultipleFiles(['/tmp/one.m4a', '/tmp/two.m4a'], { title: 'Book', @@ -107,17 +179,17 @@ describe('metadata tagger', () => { }); it('checks ffmpeg availability', async () => { - mockExecSuccess('ffmpeg version'); + mockExecFileSuccess('ffmpeg version'); await expect(checkFfmpegAvailable()).resolves.toBe(true); - mockExecFailure('not installed'); + mockExecFileFailure('not installed'); await expect(checkFfmpegAvailable()).resolves.toBe(false); }); describe('series metadata', () => { it('writes show/episode_id for m4b when series/seriesPart provided', async () => { fsMock.access.mockResolvedValue(undefined); - mockExecSuccess('done'); + mockExecFileSuccess('done'); await tagAudioFileMetadata('/tmp/book.m4b', { title: 'Book', @@ -126,14 +198,14 @@ describe('metadata tagger', () => { seriesPart: '1', }); - const command = execMock.mock.calls[0][0] as string; - expect(command).toContain('-metadata show="The Mistborn Saga"'); - expect(command).toContain('-metadata episode_id="1"'); + const argv = lastCallArgv(); + expectMetadataArg(argv, 'show', 'The Mistborn Saga'); + expectMetadataArg(argv, 'episode_id', '1'); }); it('writes SERIES/SERIES-PART for mp3 when series/seriesPart provided', async () => { fsMock.access.mockResolvedValue(undefined); - mockExecSuccess('done'); + mockExecFileSuccess('done'); await tagAudioFileMetadata('/tmp/book.mp3', { title: 'Book', @@ -142,14 +214,14 @@ describe('metadata tagger', () => { seriesPart: '1.5', }); - const command = execMock.mock.calls[0][0] as string; - expect(command).toContain('-metadata SERIES="The Mistborn Saga"'); - expect(command).toContain('-metadata SERIES-PART="1.5"'); + const argv = lastCallArgv(); + expectMetadataArg(argv, 'SERIES', 'The Mistborn Saga'); + expectMetadataArg(argv, 'SERIES-PART', '1.5'); }); it('writes SERIES/SERIES-PART for flac when series/seriesPart provided', async () => { fsMock.access.mockResolvedValue(undefined); - mockExecSuccess('done'); + mockExecFileSuccess('done'); await tagAudioFileMetadata('/tmp/book.flac', { title: 'Book', @@ -158,110 +230,323 @@ describe('metadata tagger', () => { seriesPart: '2', }); - const command = execMock.mock.calls[0][0] as string; - expect(command).toContain('-metadata SERIES="The Mistborn Saga"'); - expect(command).toContain('-metadata SERIES-PART="2"'); + const argv = lastCallArgv(); + expectMetadataArg(argv, 'SERIES', 'The Mistborn Saga'); + expectMetadataArg(argv, 'SERIES-PART', '2'); }); it('omits series tags when fields are absent', async () => { fsMock.access.mockResolvedValue(undefined); - mockExecSuccess('done'); + mockExecFileSuccess('done'); await tagAudioFileMetadata('/tmp/book.m4b', { title: 'Book', author: 'Author', }); - const command = execMock.mock.calls[0][0] as string; - expect(command).not.toContain('show='); - expect(command).not.toContain('episode_id='); - expect(command).not.toContain('SERIES='); - expect(command).not.toContain('SERIES-PART='); + const argv = lastCallArgv(); + const payloads = argv.filter((_, i) => i > 0 && argv[i - 1] === '-metadata'); + expect(payloads.some((p) => p.startsWith('show='))).toBe(false); + expect(payloads.some((p) => p.startsWith('episode_id='))).toBe(false); + expect(payloads.some((p) => p.startsWith('SERIES='))).toBe(false); + expect(payloads.some((p) => p.startsWith('SERIES-PART='))).toBe(false); }); }); - describe('metadata escaping', () => { - it('does NOT escape single quotes (they are literal in double-quoted shell strings)', async () => { + describe('quote regression (#171) — embedded `"` flows through untouched', () => { + const tricky = 'Test "Quoted" Book'; + + it('m4b: embedded double-quote in title is a single clean argv element', async () => { fsMock.access.mockResolvedValue(undefined); - mockExecSuccess('done'); + mockExecFileSuccess('done'); + + await tagAudioFileMetadata('/tmp/book.m4b', { + title: tricky, + author: 'Author', + }); + + const argv = lastCallArgv(); + expectMetadataArg(argv, 'title', tricky); + expectMetadataArg(argv, 'album', tricky); + // Adjacent fields must not have inherited a stray leading `"`. + expectMetadataArg(argv, 'album_artist', 'Author'); + expectMetadataArg(argv, 'artist', 'Author'); + }); + + it('mp3: embedded double-quote in author is a single clean argv element', async () => { + fsMock.access.mockResolvedValue(undefined); + mockExecFileSuccess('done'); + + await tagAudioFileMetadata('/tmp/book.mp3', { + title: 'Book', + author: 'Alexandre "Dumas', + }); + + const argv = lastCallArgv(); + expectMetadataArg(argv, 'album_artist', 'Alexandre "Dumas'); + expectMetadataArg(argv, 'artist', 'Alexandre "Dumas'); + // Title must NOT have inherited a leading quote (the exact #171 symptom). + expectMetadataArg(argv, 'title', 'Book'); + }); + + it('flac: embedded double-quote in series flows through cleanly', async () => { + fsMock.access.mockResolvedValue(undefined); + mockExecFileSuccess('done'); + + await tagAudioFileMetadata('/tmp/book.flac', { + title: 'Book', + author: 'Author', + series: 'The "Final" Saga', + seriesPart: '1', + }); + + const argv = lastCallArgv(); + expectMetadataArg(argv, 'SERIES', 'The "Final" Saga'); + expectMetadataArg(argv, 'SERIES-PART', '1'); + }); + }); + + describe('special characters flow through as literals (no shell escaping)', () => { + it('single quotes are untouched', async () => { + fsMock.access.mockResolvedValue(undefined); + mockExecFileSuccess('done'); await tagAudioFileMetadata('/tmp/book.m4b', { title: "It's Not Her", - author: "Author's Name", + author: "O'Brien", }); - const command = execMock.mock.calls[0][0] as string; - // Single quotes should appear as-is, NOT escaped with backslash - expect(command).toContain('-metadata title="It\'s Not Her"'); - expect(command).not.toContain("It\\'s"); // No backslash-escaped single quotes - expect(command).toContain('-metadata album_artist="Author\'s Name"'); + const argv = lastCallArgv(); + expectMetadataArg(argv, 'title', "It's Not Her"); + expectMetadataArg(argv, 'album_artist', "O'Brien"); }); - it('escapes double quotes in metadata values', async () => { + it('dollar signs are untouched (no shell variable expansion)', async () => { fsMock.access.mockResolvedValue(undefined); - mockExecSuccess('done'); - - await tagAudioFileMetadata('/tmp/book.m4b', { - title: 'Book "Title"', - author: 'Author', - }); - - const command = execMock.mock.calls[0][0] as string; - expect(command).toContain('-metadata title="Book \\"Title\\""'); - }); - - it('escapes backticks to prevent command substitution', async () => { - fsMock.access.mockResolvedValue(undefined); - mockExecSuccess('done'); - - await tagAudioFileMetadata('/tmp/book.m4b', { - title: 'Book `test`', - author: 'Author', - }); - - const command = execMock.mock.calls[0][0] as string; - expect(command).toContain('-metadata title="Book \\`test\\`"'); - }); - - it('escapes dollar signs to prevent variable expansion', async () => { - fsMock.access.mockResolvedValue(undefined); - mockExecSuccess('done'); + mockExecFileSuccess('done'); await tagAudioFileMetadata('/tmp/book.m4b', { title: 'Book $100', author: 'Author', }); - const command = execMock.mock.calls[0][0] as string; - expect(command).toContain('-metadata title="Book \\$100"'); + const argv = lastCallArgv(); + expectMetadataArg(argv, 'title', 'Book $100'); }); - it('escapes backslashes before other characters', async () => { + it('backticks are untouched (no command substitution)', async () => { fsMock.access.mockResolvedValue(undefined); - mockExecSuccess('done'); + mockExecFileSuccess('done'); + + await tagAudioFileMetadata('/tmp/book.m4b', { + title: 'Book `test`', + author: 'Author', + }); + + const argv = lastCallArgv(); + expectMetadataArg(argv, 'title', 'Book `test`'); + }); + + it('backslashes are untouched', async () => { + fsMock.access.mockResolvedValue(undefined); + mockExecFileSuccess('done'); await tagAudioFileMetadata('/tmp/book.m4b', { title: 'Path\\to\\book', author: 'Author', }); - const command = execMock.mock.calls[0][0] as string; - expect(command).toContain('-metadata title="Path\\\\to\\\\book"'); + const argv = lastCallArgv(); + expectMetadataArg(argv, 'title', 'Path\\to\\book'); }); - it('handles complex titles with multiple special characters', async () => { + it('combined torture-test value flows through as one literal argv element', async () => { fsMock.access.mockResolvedValue(undefined); - mockExecSuccess('done'); + mockExecFileSuccess('done'); + + const tortureTitle = `Don't Say "Hello" for $5 \`backtick\` and \\back`; await tagAudioFileMetadata('/tmp/book.m4b', { - title: "Don't Say \"Hello\" for $5", + title: tortureTitle, author: "O'Brien", }); - const command = execMock.mock.calls[0][0] as string; - // Single quotes literal, double quotes escaped, dollar escaped - expect(command).toContain('-metadata title="Don\'t Say \\"Hello\\" for \\$5"'); - expect(command).toContain('-metadata album_artist="O\'Brien"'); + const argv = lastCallArgv(); + expectMetadataArg(argv, 'title', tortureTitle); + expectMetadataArg(argv, 'album_artist', "O'Brien"); + }); + }); + + describe('input sanitization (boundary defense for invisible chars)', () => { + it('strips leading BOM from title', async () => { + fsMock.access.mockResolvedValue(undefined); + mockExecFileSuccess('done'); + + await tagAudioFileMetadata('/tmp/book.m4b', { + title: 'Alexandre Dumas Title', + author: 'Author', + }); + + const argv = lastCallArgv(); + expectMetadataArg(argv, 'title', 'Alexandre Dumas Title'); + }); + + it('strips leading NBSP from author', async () => { + fsMock.access.mockResolvedValue(undefined); + mockExecFileSuccess('done'); + + await tagAudioFileMetadata('/tmp/book.m4b', { + title: 'Book', + author: ' Alexandre Dumas', + }); + + const argv = lastCallArgv(); + expectMetadataArg(argv, 'album_artist', 'Alexandre Dumas'); + expectMetadataArg(argv, 'artist', 'Alexandre Dumas'); + }); + + it('strips leading zero-width chars', async () => { + fsMock.access.mockResolvedValue(undefined); + mockExecFileSuccess('done'); + + await tagAudioFileMetadata('/tmp/book.m4b', { + title: '​‌‍Book Title', + author: 'Author', + }); + + const argv = lastCallArgv(); + expectMetadataArg(argv, 'title', 'Book Title'); + }); + + it('trims surrounding whitespace', async () => { + fsMock.access.mockResolvedValue(undefined); + mockExecFileSuccess('done'); + + await tagAudioFileMetadata('/tmp/book.m4b', { + title: ' Spaced Book ', + author: '\tAuthor Name\t', + }); + + const argv = lastCallArgv(); + expectMetadataArg(argv, 'title', 'Spaced Book'); + expectMetadataArg(argv, 'album_artist', 'Author Name'); + }); + + it('strips null bytes and line breaks anywhere in value', async () => { + fsMock.access.mockResolvedValue(undefined); + mockExecFileSuccess('done'); + + await tagAudioFileMetadata('/tmp/book.m4b', { + title: 'Book\x00With\nBreaks\r', + author: 'Author', + }); + + const argv = lastCallArgv(); + expectMetadataArg(argv, 'title', 'BookWithBreaks'); + }); + + it('leaves clean inputs untouched', async () => { + fsMock.access.mockResolvedValue(undefined); + mockExecFileSuccess('done'); + + await tagAudioFileMetadata('/tmp/book.m4b', { + title: 'Perfectly Clean Title', + author: 'Clean Author', + narrator: 'Clean Narrator', + asin: 'B0009JKV9W', + series: 'Clean Series', + seriesPart: '1', + }); + + const argv = lastCallArgv(); + expectMetadataArg(argv, 'title', 'Perfectly Clean Title'); + expectMetadataArg(argv, 'album_artist', 'Clean Author'); + expectMetadataArg(argv, 'composer', 'Clean Narrator'); + expectMetadataArg(argv, '----:com.apple.iTunes:ASIN', 'B0009JKV9W'); + expectMetadataArg(argv, 'show', 'Clean Series'); + expectMetadataArg(argv, 'episode_id', '1'); + }); + + it('sanitization applies across all three format branches', async () => { + fsMock.access.mockResolvedValue(undefined); + + for (const filePath of ['/tmp/book.m4b', '/tmp/book.mp3', '/tmp/book.flac']) { + mockExecFileSuccess('done'); + await tagAudioFileMetadata(filePath, { + title: ' Dirty Title ', + author: ' Dirty Author', + }); + const argv = lastCallArgv(); + expectMetadataArg(argv, 'title', 'Dirty Title'); + // m4b/mp3 use album_artist; flac uses albumartist (no underscore). + const authorKey = filePath.endsWith('.flac') ? 'albumartist' : 'album_artist'; + expectMetadataArg(argv, authorKey, 'Dirty Author'); + } + }); + }); + + describe('argv structural integrity', () => { + it('no -metadata payload starts or ends with `"` (the #171 bug class)', async () => { + fsMock.access.mockResolvedValue(undefined); + + const torture = { + title: 'Has "quotes" $dollar `tick` \\back', + author: 'A "B" C', + narrator: 'N "ar" tor', + asin: 'ASIN"123', + series: 'Ser "ies"', + seriesPart: '"1"', + year: 2020, + }; + + for (const filePath of ['/tmp/book.m4b', '/tmp/book.mp3', '/tmp/book.flac']) { + mockExecFileSuccess('done'); + await tagAudioFileMetadata(filePath, torture); + const argv = lastCallArgv(); + for (let i = 0; i < argv.length - 1; i++) { + if (argv[i] === '-metadata') { + const payload = argv[i + 1]; + // Split key=value; the VALUE part must not have leading/trailing wrapping quotes. + const eqIdx = payload.indexOf('='); + const value = eqIdx >= 0 ? payload.substring(eqIdx + 1) : payload; + // The seriesPart torture value is literally '"1"', so we check that the value + // matches one of the original torture values (no extra wrapping was added). + if (value.startsWith('"') && value.endsWith('"')) { + // Only acceptable when the original input was already wrapped (seriesPart='"1"'). + expect(value).toBe('"1"'); + } + } + } + } + }); + + it('input and output paths are passed as single argv elements (no quote wrapping)', async () => { + fsMock.access.mockResolvedValue(undefined); + mockExecFileSuccess('done'); + + await tagAudioFileMetadata('/path with spaces/book.m4b', { + title: 'Book', + author: 'Author', + }); + + const argv = lastCallArgv(); + const iIdx = argv.indexOf('-i'); + expect(iIdx).toBeGreaterThanOrEqual(0); + expect(argv[iIdx + 1]).toBe('/path with spaces/book.m4b'); + expect(argv[argv.length - 1]).toBe('/path with spaces/book.m4b.tmp'); + }); + + it('execFile is called with command "ffmpeg" (not a shell)', async () => { + fsMock.access.mockResolvedValue(undefined); + mockExecFileSuccess('done'); + + await tagAudioFileMetadata('/tmp/book.m4b', { + title: 'Book', + author: 'Author', + }); + + expect(execFileMock).toHaveBeenCalled(); + expect(lastCallCommand()).toBe('ffmpeg'); }); }); });