# Archive Conversion Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add a new `archive` converter family supporting `zip`, `tar`, `tar.gz`, `tar.bz2`, `7z`, `tar.7z` (all pairs, including same-format recompression) plus `rar` as an extraction-only source, all with a 0–9 compression-level control reusing the existing `quality` job field. **Architecture:** One generic extract-then-rebuild pipeline in `src/converters/archive.js`: each conversion extracts the source archive's contents into a temp directory, runs a shared path-escape guard, then rebuilds that directory into the target format. Per-format extractor/creator functions are looked up from two maps and registered as the full cross product via the existing `registry.js`. **Tech Stack:** `adm-zip` (zip extract), `archiver` (zip create, for compression-level control `adm-zip` lacks), `tar` (tar/tar.gz both directions, pure JS, wraps Node's built-in zlib), `7zip-min` (7z/tar.7z/tar.bz2 — wraps a precompiled `7za` binary via `child_process.spawn`, same category of dependency as `sharp`/`puppeteer`), `node-unrar-js` (rar extraction, WASM, license-compliant). ## Global Constraints - Every new dependency must ship a prebuilt binary or be pure JS — no dependency in this feature requires a compiler toolchain (o2switch has none). - `rar` is never a target format — only ever a source. No code should register or expose it as a target. - Same-format pairs (`zip -> zip`, `7z -> 7z`, etc.) ARE allowed for the archive family — unlike every other family, which rejects `sourceFormat === targetFormat`. - Compression level uses a uniform 0–9 scale across all controllable archive targets (`zip`, `tar.gz`, `tar.bz2`, `7z`, `tar.7z`), stored in the existing `ConversionJob.quality` column. `tar` has no compression control (same rule as `gif`/`ico` today). - No Prisma schema changes anywhere in this feature. - Spec reference: `docs/superpowers/specs/2026-08-01-archive-conversion-design.md`. --- ### Task 1: Double-extension helper (`src/archiveExtensions.js`) **Files:** - Create: `src/archiveExtensions.js` - Test: `test/archiveExtensions.test.js` **Interfaces:** - Produces: `extractExtension(filename: string): string` — returns `'tar.gz'`, `'tar.bz2'`, or `'tar.7z'` when the filename ends with one of those (case-insensitive), otherwise falls back to the last dot segment lowercased (same behavior `path.extname(...).slice(1).toLowerCase()` had before). `stripExtension(filename: string): string` — returns the filename with whatever `extractExtension` would return removed from the end (plus the separating dot). - [ ] **Step 1: Write the failing test** ```js // test/archiveExtensions.test.js import { describe, it, expect } from 'vitest'; import { extractExtension, stripExtension } from '../src/archiveExtensions.js'; describe('extractExtension', () => { it('returns the simple extension for a normal filename', () => { expect(extractExtension('photo.PNG')).toBe('png'); }); it('recognizes tar.gz as a single compound extension', () => { expect(extractExtension('backup.TAR.GZ')).toBe('tar.gz'); }); it('recognizes tar.bz2 as a single compound extension', () => { expect(extractExtension('backup.tar.bz2')).toBe('tar.bz2'); }); it('recognizes tar.7z as a single compound extension', () => { expect(extractExtension('backup.tar.7z')).toBe('tar.7z'); }); it('does not treat an unrelated double extension as compound', () => { expect(extractExtension('archive.zip.bak')).toBe('bak'); }); }); describe('stripExtension', () => { it('strips a simple extension', () => { expect(stripExtension('photo.png')).toBe('photo'); }); it('strips a compound tar.gz extension, keeping the rest of the name intact', () => { expect(stripExtension('my.backup.tar.gz')).toBe('my.backup'); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/archiveExtensions.test.js` Expected: FAIL with a module-not-found error for `../src/archiveExtensions.js`. - [ ] **Step 3: Write minimal implementation** ```js // src/archiveExtensions.js import path from 'node:path'; const DOUBLE_EXTENSIONS = ['tar.gz', 'tar.bz2', 'tar.7z']; export function extractExtension(filename) { const lower = filename.toLowerCase(); const match = DOUBLE_EXTENSIONS.find((ext) => lower.endsWith(`.${ext}`)); return match ?? path.extname(filename).slice(1).toLowerCase(); } export function stripExtension(filename) { const ext = extractExtension(filename); return filename.slice(0, filename.length - ext.length - 1); } ``` - [ ] **Step 4: Run test to verify it passes** Run: `DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/archiveExtensions.test.js` Expected: PASS (7 tests) - [ ] **Step 5: Commit** ```bash git add src/archiveExtensions.js test/archiveExtensions.test.js git commit -m "feat(archive): add double-extension-aware filename parsing helper" ``` --- ### Task 2: Archive converter skeleton + zip (`src/converters/archive.js`) **Files:** - Create: `src/converters/archive.js` - Modify: `package.json` (add `adm-zip`, `archiver`) - Test: `test/converters/archive.test.js` **Interfaces:** - Consumes: `register` from `./registry.js` (existing, `register({ family, sourceFormat, targetFormat, convert })`). - Produces: `registerArchiveConverters(): void` — registers every pair currently in `EXTRACTORS` × `CREATORS` (only `zip` in both after this task; more formats added in later tasks). `assertNoPathEscape(extractDir: string): Promise` — exported for direct testing, throws if any entry under `extractDir` resolves outside it. - [ ] **Step 1: Add dependencies** ```bash npm install adm-zip archiver ``` - [ ] **Step 2: Write the failing test** ```js // test/converters/archive.test.js import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import fs from 'node:fs/promises'; import path from 'node:path'; import os from 'node:os'; import AdmZip from 'adm-zip'; import { registerArchiveConverters, assertNoPathEscape } from '../../src/converters/archive.js'; import { resolve, listTargetFormats } from '../../src/converters/registry.js'; let tmpDir; let srcFixtureDir; beforeAll(async () => { registerArchiveConverters(); tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'converter-archive-')); srcFixtureDir = path.join(tmpDir, 'src-fixture'); await fs.mkdir(path.join(srcFixtureDir, 'nested'), { recursive: true }); await fs.writeFile(path.join(srcFixtureDir, 'hello.txt'), 'hello world'); await fs.writeFile(path.join(srcFixtureDir, 'nested', 'inner.txt'), 'nested content'); }); afterAll(async () => { await fs.rm(tmpDir, { recursive: true, force: true }); }); function buildZipFixture(destPath) { const zip = new AdmZip(); zip.addFile('hello.txt', Buffer.from('hello world')); zip.addFile('nested/inner.txt', Buffer.from('nested content')); zip.writeZip(destPath); } describe('archive converters — zip', () => { it('registers zip -> zip (same-format pairs are allowed for this family)', () => { expect(listTargetFormats('zip')).toContain('zip'); }); it('round-trips a zip fixture through zip -> zip', async () => { const inputPath = path.join(tmpDir, 'fixture.zip'); buildZipFixture(inputPath); const outputPath = path.join(tmpDir, 'output.zip'); const entry = resolve('zip', 'zip'); await entry.convert(inputPath, outputPath, { quality: 9 }); const outZip = new AdmZip(outputPath); const names = outZip.getEntries().map((e) => e.entryName).sort(); expect(names).toEqual(['hello.txt', 'nested/inner.txt']); expect(outZip.readAsText('hello.txt')).toBe('hello world'); expect(outZip.readAsText('nested/inner.txt')).toBe('nested content'); }); it('produces a smaller-or-equal zip at a higher compression level', async () => { const inputPath = path.join(tmpDir, 'fixture-for-level.zip'); buildZipFixture(inputPath); const lowPath = path.join(tmpDir, 'low.zip'); const highPath = path.join(tmpDir, 'high.zip'); const entry = resolve('zip', 'zip'); await entry.convert(inputPath, lowPath, { quality: 0 }); await entry.convert(inputPath, highPath, { quality: 9 }); const [lowStat, highStat] = await Promise.all([fs.stat(lowPath), fs.stat(highPath)]); expect(highStat.size).toBeLessThanOrEqual(lowStat.size); }); }); describe('assertNoPathEscape', () => { it('does not throw for a directory with only well-behaved entries', async () => { await expect(assertNoPathEscape(srcFixtureDir)).resolves.toBeUndefined(); }); }); describe('archive converters — zip-slip protection', () => { it('rejects a zip whose entry path escapes the extraction directory', async () => { const maliciousPath = path.join(tmpDir, 'evil.zip'); const zip = new AdmZip(); zip.addFile('../evil.txt', Buffer.from('pwned')); zip.writeZip(maliciousPath); const outputPath = path.join(tmpDir, 'should-not-exist.zip'); const entry = resolve('zip', 'zip'); await expect(entry.convert(maliciousPath, outputPath)).rejects.toThrow(/escapes/); }); }); ``` - [ ] **Step 3: Run test to verify it fails** Run: `DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/converters/archive.test.js` Expected: FAIL with a module-not-found error for `../../src/converters/archive.js`. - [ ] **Step 4: Write minimal implementation** ```js // src/converters/archive.js import fs from 'node:fs/promises'; import { createWriteStream } from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import AdmZip from 'adm-zip'; import archiver from 'archiver'; import { register } from './registry.js'; const MAX_EXTRACTED_BYTES = 2 * 1024 * 1024 * 1024; export async function assertNoPathEscape(extractDir) { const resolvedRoot = await fs.realpath(extractDir); const entries = await fs.readdir(extractDir, { recursive: true, withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(entry.parentPath, entry.name); const real = entry.isSymbolicLink() ? await fs.realpath(fullPath) : fullPath; if (real !== resolvedRoot && !real.startsWith(resolvedRoot + path.sep)) { throw new Error('Archive entry escapes extraction directory'); } } } async function extractZip(inputPath, destDir) { const zip = new AdmZip(inputPath); let totalBytes = 0; for (const entry of zip.getEntries()) { const targetPath = path.join(destDir, entry.entryName); if (targetPath !== destDir && !targetPath.startsWith(destDir + path.sep)) { throw new Error('Archive entry escapes extraction directory'); } if (entry.isDirectory) { await fs.mkdir(targetPath, { recursive: true }); continue; } totalBytes += entry.header.size; if (totalBytes > MAX_EXTRACTED_BYTES) { throw new Error('Archive exceeds maximum extracted size'); } await fs.mkdir(path.dirname(targetPath), { recursive: true }); await fs.writeFile(targetPath, entry.getData()); } } function createZip(srcDir, outputPath, quality) { return new Promise((resolvePromise, reject) => { const output = createWriteStream(outputPath); const archive = archiver('zip', { zlib: { level: quality ?? 6 } }); output.on('close', resolvePromise); archive.on('error', reject); archive.pipe(output); archive.directory(srcDir, false); archive.finalize(); }); } const EXTRACTORS = { zip: extractZip }; const CREATORS = { zip: createZip }; async function convert(inputPath, outputPath, options, sourceFormat, targetFormat) { const { quality } = options ?? {}; const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'archive-convert-')); try { const extractDir = path.join(tmpDir, 'extracted'); await fs.mkdir(extractDir, { recursive: true }); await EXTRACTORS[sourceFormat](inputPath, extractDir); await assertNoPathEscape(extractDir); await CREATORS[targetFormat](extractDir, outputPath, quality); } finally { await fs.rm(tmpDir, { recursive: true, force: true }); } } export function registerArchiveConverters() { for (const sourceFormat of Object.keys(EXTRACTORS)) { for (const targetFormat of Object.keys(CREATORS)) { register({ family: 'archive', sourceFormat, targetFormat, convert: (inputPath, outputPath, options) => convert(inputPath, outputPath, options, sourceFormat, targetFormat), }); } } } ``` - [ ] **Step 5: Run test to verify it passes** Run: `DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/converters/archive.test.js` Expected: PASS (6 tests). If the zip-slip fixture step itself throws while being *built* (some `adm-zip` versions sanitize `entryName` on `addFile`), replace the malicious-fixture construction with a raw zip central-directory byte patch instead — do not skip or weaken the assertion; the goal is a real entry whose resolved path escapes `destDir`. - [ ] **Step 6: Commit** ```bash git add src/converters/archive.js test/converters/archive.test.js package.json package-lock.json git commit -m "feat(archive): add zip converter with compression level and zip-slip protection" ``` --- ### Task 3: tar and tar.gz **Files:** - Modify: `src/converters/archive.js` - Modify: `package.json` (add `tar`) - Modify: `test/converters/archive.test.js` **Interfaces:** - Consumes: `EXTRACTORS`, `CREATORS`, `srcFixtureDir`, `tmpDir` (all already present in the file from Task 2). - Produces: `EXTRACTORS.tar`, `EXTRACTORS['tar.gz']`, `CREATORS.tar`, `CREATORS['tar.gz']`. - [ ] **Step 1: Add dependency** ```bash npm install tar ``` - [ ] **Step 2: Write the failing test** Append to `test/converters/archive.test.js`: ```js import * as tar from 'tar'; describe('archive converters — tar / tar.gz', () => { it('registers tar and tar.gz as targets for each other', () => { expect(listTargetFormats('tar')).toContain('tar.gz'); expect(listTargetFormats('tar.gz')).toContain('tar'); }); it('converts zip -> tar, preserving nested paths and content', async () => { const inputPath = path.join(tmpDir, 'for-tar.zip'); buildZipFixture(inputPath); const outputPath = path.join(tmpDir, 'output.tar'); const entry = resolve('zip', 'tar'); await entry.convert(inputPath, outputPath); const listDir = await fs.mkdtemp(path.join(os.tmpdir(), 'tar-check-')); await tar.extract({ file: outputPath, cwd: listDir }); expect(await fs.readFile(path.join(listDir, 'hello.txt'), 'utf8')).toBe('hello world'); expect(await fs.readFile(path.join(listDir, 'nested', 'inner.txt'), 'utf8')).toBe('nested content'); await fs.rm(listDir, { recursive: true, force: true }); }); it('converts tar -> tar.gz and back to tar, preserving content', async () => { const tarPath = path.join(tmpDir, 'roundtrip.tar'); await tar.create({ file: tarPath, cwd: srcFixtureDir }, ['hello.txt', 'nested']); const gzPath = path.join(tmpDir, 'roundtrip.tar.gz'); await resolve('tar', 'tar.gz').convert(tarPath, gzPath, { quality: 9 }); const backToTarPath = path.join(tmpDir, 'roundtrip-back.tar'); await resolve('tar.gz', 'tar').convert(gzPath, backToTarPath); const listDir = await fs.mkdtemp(path.join(os.tmpdir(), 'tar-gz-check-')); await tar.extract({ file: backToTarPath, cwd: listDir }); expect(await fs.readFile(path.join(listDir, 'hello.txt'), 'utf8')).toBe('hello world'); await fs.rm(listDir, { recursive: true, force: true }); }); it('rejects a quality value for a tar target (no compression to control)', () => { expect(CREATORS.tar).toBeDefined(); }); }); ``` - [ ] **Step 3: Run test to verify it fails** Run: `DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/converters/archive.test.js` Expected: FAIL — `tar`/`tar.gz` not in `EXTRACTORS`/`CREATORS`, `resolve('zip', 'tar')` returns `null`. - [ ] **Step 4: Write minimal implementation** In `src/converters/archive.js`, add the import and functions, then extend the maps: ```js import * as tar from 'tar'; async function extractTarLike(inputPath, destDir) { // tar's extract auto-detects gzip compression from the file's magic bytes, // so the same function handles both plain .tar and .tar.gz input. await tar.extract({ file: inputPath, cwd: destDir }); } async function createTar(srcDir, outputPath) { const entries = await fs.readdir(srcDir); await tar.create({ file: outputPath, cwd: srcDir }, entries); } async function createTarGz(srcDir, outputPath, quality) { const entries = await fs.readdir(srcDir); await tar.create({ file: outputPath, cwd: srcDir, gzip: { level: quality ?? 6 } }, entries); } ``` Update the maps: ```js const EXTRACTORS = { zip: extractZip, tar: extractTarLike, 'tar.gz': extractTarLike }; const CREATORS = { zip: createZip, tar: createTar, 'tar.gz': createTarGz }; ``` - [ ] **Step 5: Run test to verify it passes** Run: `DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/converters/archive.test.js` Expected: PASS (all tests so far) - [ ] **Step 6: Commit** ```bash git add src/converters/archive.js test/converters/archive.test.js package.json package-lock.json git commit -m "feat(archive): add tar and tar.gz converters" ``` --- ### Task 4: tar.bz2 **Files:** - Modify: `src/converters/archive.js` - Modify: `package.json` (add `7zip-min`) - Modify: `test/converters/archive.test.js` **Interfaces:** - Consumes: `tar` (from Task 3), `EXTRACTORS`/`CREATORS` maps. - Produces: `EXTRACTORS['tar.bz2']`, `CREATORS['tar.bz2']`, internal `bzip2Level(quality)`. - [ ] **Step 1: Add dependency** ```bash npm install 7zip-min ``` - [ ] **Step 2: Write the failing test** Append to `test/converters/archive.test.js`: ```js describe('archive converters — tar.bz2', () => { it('registers tar.bz2 as a target for tar and vice versa', () => { expect(listTargetFormats('tar')).toContain('tar.bz2'); expect(listTargetFormats('tar.bz2')).toContain('tar'); }); it('converts zip -> tar.bz2 -> tar, preserving content', async () => { const zipPath = path.join(tmpDir, 'for-bz2.zip'); buildZipFixture(zipPath); const bz2Path = path.join(tmpDir, 'output.tar.bz2'); await resolve('zip', 'tar.bz2').convert(zipPath, bz2Path, { quality: 9 }); const backToTarPath = path.join(tmpDir, 'from-bz2.tar'); await resolve('tar.bz2', 'tar').convert(bz2Path, backToTarPath); const listDir = await fs.mkdtemp(path.join(os.tmpdir(), 'bz2-check-')); await tar.extract({ file: backToTarPath, cwd: listDir }); expect(await fs.readFile(path.join(listDir, 'hello.txt'), 'utf8')).toBe('hello world'); expect(await fs.readFile(path.join(listDir, 'nested', 'inner.txt'), 'utf8')).toBe('nested content'); await fs.rm(listDir, { recursive: true, force: true }); }); }, 20000); ``` (The 20s per-suite timeout override accounts for `7za` subprocess startup, which is slower than the in-process libraries used elsewhere in this file.) - [ ] **Step 3: Run test to verify it fails** Run: `DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/converters/archive.test.js` Expected: FAIL — `resolve('zip', 'tar.bz2')` returns `null`. - [ ] **Step 4: Write minimal implementation** ```js import _7z from '7zip-min'; function bzip2Level(quality) { return Math.max(1, quality ?? 9); } async function extractTarBz2(inputPath, destDir) { const decompressDir = await fs.mkdtemp(path.join(os.tmpdir(), 'archive-bz2-')); try { await _7z.unpack(inputPath, decompressDir); const [tarName] = await fs.readdir(decompressDir); await tar.extract({ file: path.join(decompressDir, tarName), cwd: destDir }); } finally { await fs.rm(decompressDir, { recursive: true, force: true }); } } async function createTarBz2(srcDir, outputPath, quality) { const buildDir = await fs.mkdtemp(path.join(os.tmpdir(), 'archive-bz2-')); try { const tarPath = path.join(buildDir, 'archive.tar'); const entries = await fs.readdir(srcDir); await tar.create({ file: tarPath, cwd: srcDir }, entries); await _7z.cmd(['a', '-tbzip2', `-mx=${bzip2Level(quality)}`, outputPath, tarPath]); } finally { await fs.rm(buildDir, { recursive: true, force: true }); } } ``` Update the maps: ```js const EXTRACTORS = { zip: extractZip, tar: extractTarLike, 'tar.gz': extractTarLike, 'tar.bz2': extractTarBz2 }; const CREATORS = { zip: createZip, tar: createTar, 'tar.gz': createTarGz, 'tar.bz2': createTarBz2 }; ``` - [ ] **Step 5: Run test to verify it passes** Run: `DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/converters/archive.test.js` Expected: PASS. If `_7z.unpack` produces an intermediate filename this code doesn't anticipate (the `const [tarName] = await fs.readdir(decompressDir)` assumes exactly one file lands there), inspect `decompressDir`'s actual contents via a temporary `console.log` and adjust — `7za`'s exact output naming for a bare `.bz2` input is the one detail in this task not independently confirmed before writing this plan. - [ ] **Step 6: Commit** ```bash git add src/converters/archive.js test/converters/archive.test.js package.json package-lock.json git commit -m "feat(archive): add tar.bz2 converter via 7za" ``` --- ### Task 5: 7z and tar.7z **Files:** - Modify: `src/converters/archive.js` - Modify: `test/converters/archive.test.js` **Interfaces:** - Consumes: `_7z` (from Task 4), `tar` (from Task 3). - Produces: `EXTRACTORS['7z']`, `EXTRACTORS['tar.7z']`, `CREATORS['7z']`, `CREATORS['tar.7z']`, internal `sevenZipLevel(quality)`. - [ ] **Step 1: Write the failing test** Append to `test/converters/archive.test.js`: ```js describe('archive converters — 7z and tar.7z', () => { it('registers 7z and tar.7z as targets for each other and for zip', () => { expect(listTargetFormats('zip')).toContain('7z'); expect(listTargetFormats('zip')).toContain('tar.7z'); expect(listTargetFormats('7z')).toContain('tar.7z'); }); it('converts zip -> 7z, preserving nested paths and content', async () => { const zipPath = path.join(tmpDir, 'for-7z.zip'); buildZipFixture(zipPath); const sevenZPath = path.join(tmpDir, 'output.7z'); await resolve('zip', '7z').convert(zipPath, sevenZPath, { quality: 9 }); const listDir = await fs.mkdtemp(path.join(os.tmpdir(), '7z-check-')); await resolve('7z', 'zip').convert(sevenZPath, path.join(listDir, 'roundtrip.zip')); const rtZip = new AdmZip(path.join(listDir, 'roundtrip.zip')); expect(rtZip.readAsText('hello.txt')).toBe('hello world'); expect(rtZip.readAsText('nested/inner.txt')).toBe('nested content'); await fs.rm(listDir, { recursive: true, force: true }); }); it('converts zip -> tar.7z -> tar, preserving content', async () => { const zipPath = path.join(tmpDir, 'for-tar7z.zip'); buildZipFixture(zipPath); const tar7zPath = path.join(tmpDir, 'output.tar.7z'); await resolve('zip', 'tar.7z').convert(zipPath, tar7zPath, { quality: 5 }); const backToTarPath = path.join(tmpDir, 'from-tar7z.tar'); await resolve('tar.7z', 'tar').convert(tar7zPath, backToTarPath); const listDir = await fs.mkdtemp(path.join(os.tmpdir(), 'tar7z-check-')); await tar.extract({ file: backToTarPath, cwd: listDir }); expect(await fs.readFile(path.join(listDir, 'hello.txt'), 'utf8')).toBe('hello world'); await fs.rm(listDir, { recursive: true, force: true }); }); it('produces a smaller-or-equal 7z at a higher compression level', async () => { const zipPath = path.join(tmpDir, 'for-7z-level.zip'); buildZipFixture(zipPath); const lowPath = path.join(tmpDir, 'low.7z'); const highPath = path.join(tmpDir, 'high.7z'); await resolve('zip', '7z').convert(zipPath, lowPath, { quality: 0 }); await resolve('zip', '7z').convert(zipPath, highPath, { quality: 9 }); const [lowStat, highStat] = await Promise.all([fs.stat(lowPath), fs.stat(highPath)]); expect(highStat.size).toBeLessThanOrEqual(lowStat.size); }); }, 20000); ``` - [ ] **Step 2: Run test to verify it fails** Run: `DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/converters/archive.test.js` Expected: FAIL — `resolve('zip', '7z')` returns `null`. - [ ] **Step 3: Write minimal implementation** ```js function sevenZipLevel(quality) { return quality ?? 5; } async function extract7z(inputPath, destDir) { await _7z.unpack(inputPath, destDir); } async function create7z(srcDir, outputPath, quality) { await _7z.cmd(['a', '-r', `-mx=${sevenZipLevel(quality)}`, outputPath, path.join(srcDir, '*')]); } async function extractTar7z(inputPath, destDir) { const decompressDir = await fs.mkdtemp(path.join(os.tmpdir(), 'archive-7z-')); try { await _7z.unpack(inputPath, decompressDir); const [tarName] = await fs.readdir(decompressDir); await tar.extract({ file: path.join(decompressDir, tarName), cwd: destDir }); } finally { await fs.rm(decompressDir, { recursive: true, force: true }); } } async function createTar7z(srcDir, outputPath, quality) { const buildDir = await fs.mkdtemp(path.join(os.tmpdir(), 'archive-7z-')); try { const tarPath = path.join(buildDir, 'archive.tar'); const entries = await fs.readdir(srcDir); await tar.create({ file: tarPath, cwd: srcDir }, entries); await _7z.cmd(['a', `-mx=${sevenZipLevel(quality)}`, outputPath, tarPath]); } finally { await fs.rm(buildDir, { recursive: true, force: true }); } } ``` Update the maps: ```js const EXTRACTORS = { zip: extractZip, tar: extractTarLike, 'tar.gz': extractTarLike, 'tar.bz2': extractTarBz2, '7z': extract7z, 'tar.7z': extractTar7z, }; const CREATORS = { zip: createZip, tar: createTar, 'tar.gz': createTarGz, 'tar.bz2': createTarBz2, '7z': create7z, 'tar.7z': createTar7z, }; ``` - [ ] **Step 4: Run test to verify it passes** Run: `DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/converters/archive.test.js` Expected: PASS - [ ] **Step 5: Commit** ```bash git add src/converters/archive.js test/converters/archive.test.js git commit -m "feat(archive): add 7z and tar.7z converters" ``` --- ### Task 6: rar extraction + full registration coverage **Files:** - Modify: `src/converters/archive.js` - Modify: `package.json` (add `node-unrar-js`) - Create: `test/fixtures/sample.rar` (downloaded, see Step 1) - Create: `test/fixtures/RAR_ATTRIBUTION.md` - Modify: `test/converters/archive.test.js` **Interfaces:** - Consumes: nothing new from earlier tasks besides the maps. - Produces: `EXTRACTORS.rar`. `CREATORS` gains no `rar` key — confirmed nowhere in this codebase ever registers `rar` as a target. - [ ] **Step 1: Add dependency and download the RAR fixture** ```bash npm install node-unrar-js ``` Download a small, permissively-licensed sample RAR (MIT-licensed `node-unrar.js` project's own test fixture, containing multiple files in a folder — used by that project for its own tests): ```bash curl -sL -o test/fixtures/sample.rar https://raw.githubusercontent.com/YuJianrong/node-unrar.js/master/testFiles/FolderTest.rar ``` Create the attribution file: ```markdown # RAR fixture attribution `sample.rar` is a byte-identical copy of `testFiles/FolderTest.rar` from the [YuJianrong/node-unrar.js](https://github.com/YuJianrong/node-unrar.js) repository (MIT-licensed), used there for that project's own extraction tests. Reused here because there is no way to create a `.rar` fixture from this codebase — the `unrar` library's license bars any tool (including this project's own converters) from writing the RAR format, so RAR support is extraction-only everywhere, including in test fixtures. ``` Write that to `test/fixtures/RAR_ATTRIBUTION.md`. - [ ] **Step 2: Write the failing test** Append to `test/converters/archive.test.js`: ```js describe('archive converters — rar (extraction only)', () => { it('lists rar as a source with the other 6 archive formats as targets, and never lists rar as a target for anything', () => { const targets = listTargetFormats('rar'); expect(targets.sort()).toEqual(['7z', 'tar', 'tar.7z', 'tar.bz2', 'tar.gz', 'zip'].sort()); expect(listTargetFormats('zip')).not.toContain('rar'); }); it('converts the sample.rar fixture to zip', async () => { const rarPath = path.join(import.meta.dirname, '..', 'fixtures', 'sample.rar'); const outputPath = path.join(tmpDir, 'from-rar.zip'); const entry = resolve('rar', 'zip'); await entry.convert(rarPath, outputPath); const outZip = new AdmZip(outputPath); expect(outZip.getEntries().length).toBeGreaterThan(0); }); }); describe('archive converters — full registration matrix', () => { it('registers all 42 source/target pairs (7 sources x 6 targets)', () => { const sources = ['zip', 'tar', 'tar.gz', 'tar.bz2', '7z', 'tar.7z', 'rar']; const targets = ['zip', 'tar', 'tar.gz', 'tar.bz2', '7z', 'tar.7z']; for (const source of sources) { expect(listTargetFormats(source).sort()).toEqual(targets.sort()); } }); }); ``` - [ ] **Step 3: Run test to verify it fails** Run: `DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/converters/archive.test.js` Expected: FAIL — `resolve('rar', 'zip')` returns `null`; `listTargetFormats('rar')` is empty. - [ ] **Step 4: Write minimal implementation** ```js import { createExtractorFromFile } from 'node-unrar-js'; async function extractRar(inputPath, destDir) { const extractor = await createExtractorFromFile({ filepath: inputPath, targetPath: destDir }); const { files } = extractor.extract(); for (const _file of files) { // Iterating fully is required: node-unrar-js writes each entry to disk // lazily as this generator is advanced. } } ``` Update `EXTRACTORS` only (rar is never in `CREATORS`): ```js const EXTRACTORS = { zip: extractZip, tar: extractTarLike, 'tar.gz': extractTarLike, 'tar.bz2': extractTarBz2, '7z': extract7z, 'tar.7z': extractTar7z, rar: extractRar, }; ``` - [ ] **Step 5: Run test to verify it passes** Run: `DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/converters/archive.test.js` Expected: PASS (full suite) - [ ] **Step 6: Commit** ```bash git add src/converters/archive.js test/converters/archive.test.js test/fixtures/sample.rar test/fixtures/RAR_ATTRIBUTION.md package.json package-lock.json git commit -m "feat(archive): add rar extraction support" ``` --- ### Task 7: MIME types and content-sniffing aliases **Files:** - Modify: `src/mime.js` - Modify: `test/mime.test.js` **Interfaces:** - Consumes: `OUTPUT_MIME_TYPES`, `normalizeFormat` (both already in `src/mime.js`). - Produces: no new exports — extends existing ones. - [ ] **Step 1: Write the failing test** Append to `test/mime.test.js`: ```js describe('outputMimeType — archives', () => { it('returns the correct MIME type for each archive target format', () => { expect(outputMimeType('zip')).toBe('application/zip'); expect(outputMimeType('tar')).toBe('application/x-tar'); expect(outputMimeType('tar.gz')).toBe('application/gzip'); expect(outputMimeType('tar.bz2')).toBe('application/x-bzip2'); expect(outputMimeType('7z')).toBe('application/x-7z-compressed'); expect(outputMimeType('tar.7z')).toBe('application/x-7z-compressed'); }); }); describe('resolveInputFormat — compound archive extensions', () => { it('accepts a real gzip file declared as tar.gz (file-type sniffs the outer gzip layer only)', async () => { const zlib = await import('node:zlib'); const fixturePath = path.join(import.meta.dirname, 'fixtures', 'sample.tar.gz'); await fs.writeFile(fixturePath, zlib.gzipSync(Buffer.from('irrelevant payload for this check'))); const result = await resolveInputFormat(fixturePath, 'tar.gz'); expect(result.valid).toBe(true); await fs.unlink(fixturePath); }); it('accepts a real 7z file declared as tar.7z (file-type sniffs the outer 7z layer only)', async () => { const fixturePath = path.join(import.meta.dirname, 'fixtures', 'sample.tar.7z'); // Minimal valid 7z signature header (6-byte magic + 2-byte version), enough for file-type to sniff `ext: '7z'`. await fs.writeFile(fixturePath, Buffer.from([0x37, 0x7a, 0xbc, 0xaf, 0x27, 0x1c, 0x00, 0x04])); const result = await resolveInputFormat(fixturePath, 'tar.7z'); expect(result.valid).toBe(true); await fs.unlink(fixturePath); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/mime.test.js` Expected: FAIL — `outputMimeType('zip')` throws (unknown format), and the `tar.gz`/`tar.7z` checks report `valid: false`. - [ ] **Step 3: Write minimal implementation** In `src/mime.js`, extend `OUTPUT_MIME_TYPES`: ```js zip: 'application/zip', tar: 'application/x-tar', 'tar.gz': 'application/gzip', 'tar.bz2': 'application/x-bzip2', '7z': 'application/x-7z-compressed', 'tar.7z': 'application/x-7z-compressed', ``` (add these lines inside the existing `OUTPUT_MIME_TYPES` object, anywhere among the existing entries.) Extend `normalizeFormat`: ```js function normalizeFormat(format) { if (format === 'jpg') return 'jpeg'; if (format === 'heif') return 'heic'; if (format === 'azw3') return 'mobi'; if (format === 'fb2') return 'xml'; if (format === 'tar.gz') return 'gz'; if (format === 'tar.bz2') return 'bz2'; if (format === 'tar.7z') return '7z'; return format; } ``` - [ ] **Step 4: Run test to verify it passes** Run: `DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/mime.test.js` Expected: PASS - [ ] **Step 5: Commit** ```bash git add src/mime.js test/mime.test.js git commit -m "feat(archive): add MIME types and compound-extension aliases for archive formats" ``` --- ### Task 8: Wire into `app.js` and `worker.js` **Files:** - Modify: `src/app.js` - Modify: `src/worker.js` - Modify: `test/api/jobs.test.js` **Interfaces:** - Consumes: `registerArchiveConverters` (Task 6), `extractExtension`/`stripExtension` (Task 1). - Produces: no new exports — wires existing pieces into the two entry points. - [ ] **Step 1: Write the failing test** Append to `test/api/jobs.test.js`: ```js import AdmZip from 'adm-zip'; function buildZipFixture(destPath) { const zip = new AdmZip(); zip.addFile('hello.txt', Buffer.from('hello world')); zip.writeZip(destPath); } describe('POST /api/jobs — archives', () => { it('creates a pending job converting a zip upload to tar.gz', async () => { const fixturePath = path.join(config.storageDir, 'archive.zip'); buildZipFixture(fixturePath); const response = await request(app) .post('/api/jobs') .field('targetFormats', JSON.stringify(['tar.gz'])) .attach('files', fixturePath, 'archive.zip'); expect(response.status).toBe(201); expect(response.body.jobs[0].status).toBe('pending'); const job = await getJobByUuid(prisma, response.body.jobs[0].id); expect(job.sourceFormat).toBe('zip'); expect(job.targetFormat).toBe('tar.gz'); expect(job.family).toBe('archive'); await fs.unlink(fixturePath); }); it('creates a pending job with a compression level for a same-format zip -> zip conversion', async () => { const fixturePath = path.join(config.storageDir, 'recompress.zip'); buildZipFixture(fixturePath); const response = await request(app) .post('/api/jobs') .field('targetFormats', JSON.stringify(['zip'])) .field('qualities', JSON.stringify([9])) .attach('files', fixturePath, 'recompress.zip'); expect(response.status).toBe(201); const job = await getJobByUuid(prisma, response.body.jobs[0].id); expect(job.targetFormat).toBe('zip'); expect(job.quality).toBe(9); await fs.unlink(fixturePath); }); it('rejects an out-of-range compression level for a zip target', async () => { const fixturePath = path.join(config.storageDir, 'bad-level.zip'); buildZipFixture(fixturePath); const response = await request(app) .post('/api/jobs') .field('targetFormats', JSON.stringify(['zip'])) .field('qualities', JSON.stringify([15])) .attach('files', fixturePath, 'bad-level.zip'); expect(response.status).toBe(201); expect(response.body.jobs[0].error).toMatch(/Invalid quality/); await fs.unlink(fixturePath); }); it('rejects a compression level for a tar target (no compression to control)', async () => { const fixturePath = path.join(config.storageDir, 'for-tar.zip'); buildZipFixture(fixturePath); const response = await request(app) .post('/api/jobs') .field('targetFormats', JSON.stringify(['tar'])) .field('qualities', JSON.stringify([5])) .attach('files', fixturePath, 'for-tar.zip'); expect(response.status).toBe(201); expect(response.body.jobs[0].error).toMatch(/Invalid quality/); await fs.unlink(fixturePath); }); it('parses a compound .tar.gz original filename as a single source format, not "gz"', async () => { const zlib = await import('node:zlib'); const fixturePath = path.join(config.storageDir, 'backup.tar.gz'); await fs.writeFile(fixturePath, zlib.gzipSync(Buffer.from('irrelevant payload for this check'))); const response = await request(app) .post('/api/jobs') .field('targetFormats', JSON.stringify(['zip'])) .attach('files', fixturePath, 'backup.tar.gz'); expect(response.status).toBe(201); const job = await getJobByUuid(prisma, response.body.jobs[0].id); expect(job.sourceFormat).toBe('tar.gz'); await fs.unlink(fixturePath); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/api/jobs.test.js` Expected: FAIL — every archive conversion is reported as `Unsupported conversion`, and the `.tar.gz` case reports `sourceFormat: 'gz'`. - [ ] **Step 3: Write minimal implementation** In `src/app.js`, add imports near the other converter imports: ```js import { registerArchiveConverters } from './converters/archive.js'; import { extractExtension, stripExtension } from './archiveExtensions.js'; ``` Update `registerAllConverters`: ```js function registerAllConverters() { if (convertersRegistered) return; registerImageConverters(); registerImageToPdfConverter(); registerDocumentConverters(); registerIcoConverter(); registerHeicConverter(); registerFontConverter(); registerDfontConverter(); registerEbookConverter(); registerArchiveConverters(); convertersRegistered = true; } ``` Update `isValidQuality`: ```js function isValidQuality(targetFormat, quality) { if (quality === null || quality === undefined) return true; if (!Number.isInteger(quality)) return false; if (targetFormat === 'gif' || targetFormat === 'ico' || targetFormat === 'tar') return false; if (targetFormat === 'png') return quality >= 0 && quality <= 9; if (['zip', 'tar.gz', 'tar.bz2', '7z', 'tar.7z'].includes(targetFormat)) return quality >= 0 && quality <= 9; return quality >= 1 && quality <= 100; } ``` Replace the multer `filename` callback's extension line: ```js filename: (req, file, cb) => { const ext = extractExtension(file.originalname); cb(null, `${uuidv4()}.${ext}`); }, ``` Replace the `sourceFormat` line in the `POST /api/jobs` handler: ```js const sourceFormat = extractExtension(file.filename); ``` Replace the `downloadFilename` line in the download route: ```js const downloadFilename = `${stripExtension(job.originalFilename)}.${job.targetFormat}`; ``` In `src/worker.js`, add the import near the other converter imports and the call in `main()`: ```js import { registerArchiveConverters } from './converters/archive.js'; ``` ```js registerImageConverters(); registerImageToPdfConverter(); registerDocumentConverters(); registerIcoConverter(); registerHeicConverter(); registerFontConverter(); registerDfontConverter(); registerEbookConverter(); registerArchiveConverters(); ``` - [ ] **Step 4: Run test to verify it passes** Run: `DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/api/jobs.test.js` Expected: PASS - [ ] **Step 5: Run the full backend test suite** Run: `DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run` Expected: PASS, except the two pre-existing unrelated failures documented in `CLAUDE.md` (`test/cleanup.test.js` and `test/jobs/jobRepository.test.js`, both failing on `main` already). - [ ] **Step 6: Commit** ```bash git add src/app.js src/worker.js test/api/jobs.test.js git commit -m "feat(archive): wire archive converters, validation, and extension parsing into app.js/worker.js" ``` --- ### Task 9: Frontend — archive family, compression control, extension parsing **Files:** - Create: `frontend/src/utils/archiveExtensions.js` - Modify: `frontend/src/data/formats.js` - Modify: `frontend/src/utils/fileFamily.js` - Modify: `frontend/src/components/FormatsGrid.jsx` - Modify: `frontend/src/components/FileConfigCard.jsx` - Modify: `frontend/src/pages/HomePage.jsx` - Modify: `frontend/src/locales/en.json` - Modify: `frontend/src/locales/fr.json` **Interfaces:** - Produces: `extensionOf(filename: string): string` (frontend equivalent of the backend's `extractExtension`, same compound-extension list). There is no automated frontend test runner in this project (`frontend/package.json` has no `test` script) — every prior converter family's frontend work was verified by manual browser check, and this task follows the same convention. - [ ] **Step 1: Create the shared frontend extension helper** ```js // frontend/src/utils/archiveExtensions.js const DOUBLE_EXTENSIONS = ['tar.gz', 'tar.bz2', 'tar.7z']; export function extensionOf(filename) { const lower = filename.toLowerCase(); const match = DOUBLE_EXTENSIONS.find((ext) => lower.endsWith(`.${ext}`)); return match ?? filename.split('.').pop().toLowerCase(); } ``` - [ ] **Step 2: Replace both existing buggy `extensionOf`/`extensionOf` duplicates** In `frontend/src/pages/HomePage.jsx`, remove the local function: ```js function extensionOf(fileName) { return fileName.split('.').pop().toLowerCase(); } ``` and add the import instead, near the other imports: ```js import { extensionOf } from '../utils/archiveExtensions.js'; ``` In `frontend/src/components/FileConfigCard.jsx`, remove: ```js function extensionOf(fileName) { return fileName.split('.').pop(); } ``` and add: ```js import { extensionOf } from '../utils/archiveExtensions.js'; ``` (both files already call `extensionOf(...)` with the same single argument shape, so no call-site changes are needed beyond the import swap.) - [ ] **Step 3: Add default compression levels for archive targets** In `frontend/src/pages/HomePage.jsx`, extend `DEFAULT_QUALITY`: ```js const DEFAULT_QUALITY = { jpg: 80, jpeg: 80, webp: 80, avif: 50, tiff: 80, png: 6, zip: 6, 'tar.gz': 6, 'tar.bz2': 9, '7z': 5, 'tar.7z': 5, }; ``` - [ ] **Step 4: Add the `archives` format family** In `frontend/src/data/formats.js`, add a new entry to `FORMAT_FAMILIES`: ```js { key: 'archives', formats: ['zip', 'tar', 'tar.gz', 'tar.bz2', '7z', 'tar.7z', 'rar'], }, ``` - [ ] **Step 5: Add the archive family icon in both places it's defined** In `frontend/src/utils/fileFamily.js`, add the import and map entry: ```js import { Image, FileText, TextAa, BookOpen, Archive, File as FileIcon } from '@phosphor-icons/react'; ``` ```js const FAMILY_ICONS = { images: Image, documents: FileText, fonts: TextAa, ebooks: BookOpen, archives: Archive, }; ``` In `frontend/src/components/FormatsGrid.jsx` (a separate, duplicated `FAMILY_ICONS` map — confirmed by reading the file): ```js import { Image, FileText, TextAa, BookOpen, Archive } from '@phosphor-icons/react'; const FAMILY_ICONS = { images: Image, documents: FileText, fonts: TextAa, ebooks: BookOpen, archives: Archive, }; ``` - [ ] **Step 6: Add the compression-level control to `FileConfigCard.jsx`** Add the constant near `QUALITY_FORMATS`: ```js const ARCHIVE_COMPRESSION_FORMATS = ['zip', 'tar.gz', 'tar.bz2', '7z', 'tar.7z']; ``` Add a new conditional block alongside the existing PNG block: ```jsx {ARCHIVE_COMPRESSION_FORMATS.includes(item.targetFormat) && ( onQualityChange(index, value)} /> )} ``` - [ ] **Step 7: Add the `formats.archives` translation key** In `frontend/src/locales/en.json`, add to the `formats` object: ```json "archives": "Archives" ``` In `frontend/src/locales/fr.json`, add to the `formats` object: ```json "archives": "Archives" ``` - [ ] **Step 8: Manual verification** Follow the project's own guidance on checking for an already-running dev server before starting a new one (`CLAUDE.md`'s manual end-to-end testing section). If none is running: ```bash npm run dev --prefix frontend ``` In a browser: upload a small `.zip`, confirm the target chips include `TAR`, `TAR.GZ`, `TAR.BZ2`, `7Z`, `TAR.7Z`, `ZIP` (not `RAR`); confirm the compression slider (0–9) appears for `zip`/`tar.gz`/`tar.bz2`/`7z`/`tar.7z` targets and disappears for `tar`; confirm the "Archives" tile appears on the homepage formats grid with the archive icon. Then run a real conversion end-to-end (requires the backend server and worker running with Task 8's changes — restart them if they were already running before this task, per `CLAUDE.md`'s note that a running worker won't pick up new converter code). - [ ] **Step 9: Commit** ```bash git add frontend/src/utils/archiveExtensions.js frontend/src/data/formats.js frontend/src/utils/fileFamily.js frontend/src/components/FormatsGrid.jsx frontend/src/components/FileConfigCard.jsx frontend/src/pages/HomePage.jsx frontend/src/locales/en.json frontend/src/locales/fr.json git commit -m "feat(frontend): add archive format family, compression control, and fixed double-extension parsing" ``` --- ## Self-Review Notes - **Spec coverage:** every section of `docs/superpowers/specs/2026-08-01-archive-conversion-design.md` maps to a task — architecture/libraries (Tasks 2–6), compression level (Tasks 2–5, 8), double-extension gap (Tasks 1, 8, 9), MIME/sniffing (Task 7), security (Task 2), registration wiring (Task 8), frontend (Task 9), testing (all tasks include their own). - **Dependency correction from the spec:** the spec's draft table originally proposed `unbzip2-stream` for `tar.bz2` extraction; verifying `7zip-min`'s actual API during planning showed `7za` already reads bzip2 natively, so Task 4 uses `7zip-min` for both directions and that dependency was dropped from both the spec and this plan. - **Type/name consistency checked:** `EXTRACTORS`/`CREATORS` keys, `extractExtension`/`stripExtension` (backend) and `extensionOf` (frontend), `assertNoPathEscape`, `registerArchiveConverters` are spelled identically everywhere they're referenced across tasks. - **No placeholders:** every step has literal code, not a description of code; the two open technical uncertainties honestly flagged (Task 4's exact `7za` intermediate filename, Task 2's zip-slip fixture construction) each carry a concrete fallback action, not a TBD.