feat(archive): add tar and tar.gz converters
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Generated
+68
@@ -37,6 +37,7 @@
|
|||||||
"react-i18next": "^17.0.11",
|
"react-i18next": "^17.0.11",
|
||||||
"react-router-dom": "^7.18.2",
|
"react-router-dom": "^7.18.2",
|
||||||
"sharp": "^0.35.3",
|
"sharp": "^0.35.3",
|
||||||
|
"tar": "^7.5.22",
|
||||||
"turndown": "^7.2.4",
|
"turndown": "^7.2.4",
|
||||||
"uuid": "^14.0.1"
|
"uuid": "^14.0.1"
|
||||||
},
|
},
|
||||||
@@ -737,6 +738,18 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"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": {
|
"node_modules/@jridgewell/sourcemap-codec": {
|
||||||
"version": "1.5.5",
|
"version": "1.5.5",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||||
@@ -2607,6 +2620,15 @@
|
|||||||
"fsevents": "~2.3.2"
|
"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": {
|
"node_modules/chromium-bidi": {
|
||||||
"version": "17.0.2",
|
"version": "17.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-17.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-17.0.2.tgz",
|
||||||
@@ -5110,6 +5132,27 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/mitt": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
|
||||||
@@ -6670,6 +6713,22 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/tar-stream": {
|
||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.0.tgz",
|
||||||
@@ -7316,6 +7375,15 @@
|
|||||||
"node": ">=10"
|
"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": {
|
"node_modules/yargs": {
|
||||||
"version": "18.1.0",
|
"version": "18.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-18.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/yargs/-/yargs-18.1.0.tgz",
|
||||||
|
|||||||
@@ -44,6 +44,7 @@
|
|||||||
"react-i18next": "^17.0.11",
|
"react-i18next": "^17.0.11",
|
||||||
"react-router-dom": "^7.18.2",
|
"react-router-dom": "^7.18.2",
|
||||||
"sharp": "^0.35.3",
|
"sharp": "^0.35.3",
|
||||||
|
"tar": "^7.5.22",
|
||||||
"turndown": "^7.2.4",
|
"turndown": "^7.2.4",
|
||||||
"uuid": "^14.0.1"
|
"uuid": "^14.0.1"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import path from 'node:path';
|
|||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import AdmZip from 'adm-zip';
|
import AdmZip from 'adm-zip';
|
||||||
import { ZipArchive } from 'archiver';
|
import { ZipArchive } from 'archiver';
|
||||||
|
import * as tar from 'tar';
|
||||||
import { register } from './registry.js';
|
import { register } from './registry.js';
|
||||||
|
|
||||||
const MAX_EXTRACTED_BYTES = 2 * 1024 * 1024 * 1024;
|
const MAX_EXTRACTED_BYTES = 2 * 1024 * 1024 * 1024;
|
||||||
@@ -53,8 +54,24 @@ function createZip(srcDir, outputPath, quality) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const EXTRACTORS = { zip: extractZip };
|
async function extractTarLike(inputPath, destDir) {
|
||||||
const CREATORS = { zip: createZip };
|
// 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) {
|
async function convert(inputPath, outputPath, options, sourceFormat, targetFormat) {
|
||||||
const { quality } = options ?? {};
|
const { quality } = options ?? {};
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import fs from 'node:fs/promises';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import AdmZip from 'adm-zip';
|
import AdmZip from 'adm-zip';
|
||||||
|
import * as tar from 'tar';
|
||||||
import { registerArchiveConverters, assertNoPathEscape } from '../../src/converters/archive.js';
|
import { registerArchiveConverters, assertNoPathEscape } from '../../src/converters/archive.js';
|
||||||
import { resolve, listTargetFormats } from '../../src/converters/registry.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/);
|
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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user