From c9251e66e2371e889dd672171112cd06234aa1fa Mon Sep 17 00:00:00 2001 From: Anthony GAEREMYNCK <1@anthony.sh> Date: Sat, 1 Aug 2026 12:51:34 +0200 Subject: [PATCH] feat(archive): add tar and tar.gz converters Co-Authored-By: Claude Sonnet 5 --- package-lock.json | 68 +++++++++++++++++++++++++++++++++ package.json | 1 + src/converters/archive.js | 21 +++++++++- test/converters/archive.test.js | 39 +++++++++++++++++++ 4 files changed, 127 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 31aaf39..fb8aef6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,6 +37,7 @@ "react-i18next": "^17.0.11", "react-router-dom": "^7.18.2", "sharp": "^0.35.3", + "tar": "^7.5.22", "turndown": "^7.2.4", "uuid": "^14.0.1" }, @@ -737,6 +738,18 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -2607,6 +2620,15 @@ "fsevents": "~2.3.2" } }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/chromium-bidi": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-17.0.2.tgz", @@ -5110,6 +5132,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/mitt": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", @@ -6670,6 +6713,22 @@ "node": ">=8" } }, + "node_modules/tar": { + "version": "7.5.22", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/tar-stream": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.0.tgz", @@ -7316,6 +7375,15 @@ "node": ">=10" } }, + "node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/yargs": { "version": "18.1.0", "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.1.0.tgz", diff --git a/package.json b/package.json index 2dbef72..a541dff 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "react-i18next": "^17.0.11", "react-router-dom": "^7.18.2", "sharp": "^0.35.3", + "tar": "^7.5.22", "turndown": "^7.2.4", "uuid": "^14.0.1" }, diff --git a/src/converters/archive.js b/src/converters/archive.js index e5a3dcc..b14e261 100644 --- a/src/converters/archive.js +++ b/src/converters/archive.js @@ -4,6 +4,7 @@ import path from 'node:path'; import os from 'node:os'; import AdmZip from 'adm-zip'; import { ZipArchive } from 'archiver'; +import * as tar from 'tar'; import { register } from './registry.js'; const MAX_EXTRACTED_BYTES = 2 * 1024 * 1024 * 1024; @@ -53,8 +54,24 @@ function createZip(srcDir, outputPath, quality) { }); } -const EXTRACTORS = { zip: extractZip }; -const CREATORS = { zip: createZip }; +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); +} + +const EXTRACTORS = { zip: extractZip, tar: extractTarLike, 'tar.gz': extractTarLike }; +const CREATORS = { zip: createZip, tar: createTar, 'tar.gz': createTarGz }; async function convert(inputPath, outputPath, options, sourceFormat, targetFormat) { const { quality } = options ?? {}; diff --git a/test/converters/archive.test.js b/test/converters/archive.test.js index cb3b08c..ab3ec04 100644 --- a/test/converters/archive.test.js +++ b/test/converters/archive.test.js @@ -3,6 +3,7 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import os from 'node:os'; import AdmZip from 'adm-zip'; +import * as tar from 'tar'; import { registerArchiveConverters, assertNoPathEscape } from '../../src/converters/archive.js'; import { resolve, listTargetFormats } from '../../src/converters/registry.js'; @@ -94,3 +95,41 @@ describe('archive converters — zip-slip protection', () => { await expect(entry.convert(maliciousPath, outputPath)).rejects.toThrow(/escapes/); }); }); + +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 }); + }); +});