Files
convert/docs/superpowers/plans/2026-08-01-archive-conversion.md
T
2026-08-02 10:57:41 +02:00

48 KiB
Raw Blame History

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 09 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 09 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

// 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
// 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
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<void> — exported for direct testing, throws if any entry under extractDir resolves outside it.

  • Step 1: Add dependencies

npm install adm-zip archiver
  • Step 2: Write the failing test
// 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
// 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
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

npm install tar
  • Step 2: Write the failing test

Append to test/converters/archive.test.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:

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:

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
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

npm install 7zip-min
  • Step 2: Write the failing test

Append to test/converters/archive.test.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
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:

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
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:

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
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:

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
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

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):

curl -sL -o test/fixtures/sample.rar https://raw.githubusercontent.com/YuJianrong/node-unrar.js/master/testFiles/FolderTest.rar

Create the attribution file:

# 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:

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
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):

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
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:

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:

  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:

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
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:

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:

import { registerArchiveConverters } from './converters/archive.js';
import { extractExtension, stripExtension } from './archiveExtensions.js';

Update registerAllConverters:

function registerAllConverters() {
  if (convertersRegistered) return;
  registerImageConverters();
  registerImageToPdfConverter();
  registerDocumentConverters();
  registerIcoConverter();
  registerHeicConverter();
  registerFontConverter();
  registerDfontConverter();
  registerEbookConverter();
  registerArchiveConverters();
  convertersRegistered = true;
}

Update isValidQuality:

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:

    filename: (req, file, cb) => {
      const ext = extractExtension(file.originalname);
      cb(null, `${uuidv4()}.${ext}`);
    },

Replace the sourceFormat line in the POST /api/jobs handler:

      const sourceFormat = extractExtension(file.filename);

Replace the downloadFilename line in the download route:

    const downloadFilename = `${stripExtension(job.originalFilename)}.${job.targetFormat}`;

In src/worker.js, add the import near the other converter imports and the call in main():

import { registerArchiveConverters } from './converters/archive.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
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
// 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:

function extensionOf(fileName) {
  return fileName.split('.').pop().toLowerCase();
}

and add the import instead, near the other imports:

import { extensionOf } from '../utils/archiveExtensions.js';

In frontend/src/components/FileConfigCard.jsx, remove:

function extensionOf(fileName) {
  return fileName.split('.').pop();
}

and add:

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:

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:

  {
    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:

import { Image, FileText, TextAa, BookOpen, Archive, File as FileIcon } from '@phosphor-icons/react';
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):

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:

const ARCHIVE_COMPRESSION_FORMATS = ['zip', 'tar.gz', 'tar.bz2', '7z', 'tar.7z'];

Add a new conditional block alongside the existing PNG block:

          {ARCHIVE_COMPRESSION_FORMATS.includes(item.targetFormat) && (
            <RangeField
              label={t('quality.compression')}
              value={item.quality}
              min={0}
              max={9}
              onChange={(value) => onQualityChange(index, value)}
            />
          )}
  • Step 7: Add the formats.archives translation key

In frontend/src/locales/en.json, add to the formats object:

    "archives": "Archives"

In frontend/src/locales/fr.json, add to the formats object:

    "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:

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 (09) 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
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 26), compression level (Tasks 25, 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.