From ceddeddf3e6add3ef8d000fa399eadf36cee7d4e Mon Sep 17 00:00:00 2001 From: Anthony GAEREMYNCK <1@anthony.sh> Date: Sat, 1 Aug 2026 12:55:10 +0200 Subject: [PATCH] feat(archive): add rar extraction support Co-Authored-By: Claude Sonnet 5 --- package-lock.json | 10 ++++++++++ package.json | 1 + src/converters/archive.js | 11 +++++++++++ test/converters/archive.test.js | 29 +++++++++++++++++++++++++++++ test/fixtures/RAR_ATTRIBUTION.md | 9 +++++++++ test/fixtures/sample.rar | Bin 0 -> 5618 bytes 6 files changed, 60 insertions(+) create mode 100644 test/fixtures/RAR_ATTRIBUTION.md create mode 100644 test/fixtures/sample.rar diff --git a/package-lock.json b/package-lock.json index 94b01ee..540eff0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "mammoth": "^1.12.0", "markdown-it": "^15.0.0", "multer": "^2.2.0", + "node-unrar-js": "^2.0.2", "opentype.js": "^2.0.0", "pdf-lib": "^1.17.1", "pdfjs-dist": "^6.2.108", @@ -5307,6 +5308,15 @@ "devOptional": true, "license": "MIT" }, + "node_modules/node-unrar-js": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/node-unrar-js/-/node-unrar-js-2.0.2.tgz", + "integrity": "sha512-hLNmoJzqaKJnod8yiTVGe9hnlNRHotUi0CreSv/8HtfRi/3JnRC8DvsmKfeGGguRjTEulhZK6zXX5PXoVuDZ2w==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", diff --git a/package.json b/package.json index c6b30b7..32275bb 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "mammoth": "^1.12.0", "markdown-it": "^15.0.0", "multer": "^2.2.0", + "node-unrar-js": "^2.0.2", "opentype.js": "^2.0.0", "pdf-lib": "^1.17.1", "pdfjs-dist": "^6.2.108", diff --git a/src/converters/archive.js b/src/converters/archive.js index 4170267..8a6a0eb 100644 --- a/src/converters/archive.js +++ b/src/converters/archive.js @@ -6,6 +6,7 @@ import AdmZip from 'adm-zip'; import { ZipArchive } from 'archiver'; import * as tar from 'tar'; import _7z from '7zip-min'; +import { createExtractorFromFile } from 'node-unrar-js'; import { register } from './registry.js'; const MAX_EXTRACTED_BYTES = 2 * 1024 * 1024 * 1024; @@ -133,6 +134,15 @@ async function createTar7z(srcDir, outputPath, quality) { } } +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. + } +} + const EXTRACTORS = { zip: extractZip, tar: extractTarLike, @@ -140,6 +150,7 @@ const EXTRACTORS = { 'tar.bz2': extractTarBz2, '7z': extract7z, 'tar.7z': extractTar7z, + rar: extractRar, }; const CREATORS = { zip: createZip, diff --git a/test/converters/archive.test.js b/test/converters/archive.test.js index 72e3812..6401352 100644 --- a/test/converters/archive.test.js +++ b/test/converters/archive.test.js @@ -228,3 +228,32 @@ describe('archive converters — 7z and tar.7z', () => { 20000 ); }); + +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()); + } + }); +}); diff --git a/test/fixtures/RAR_ATTRIBUTION.md b/test/fixtures/RAR_ATTRIBUTION.md new file mode 100644 index 0000000..8f62039 --- /dev/null +++ b/test/fixtures/RAR_ATTRIBUTION.md @@ -0,0 +1,9 @@ +# 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. diff --git a/test/fixtures/sample.rar b/test/fixtures/sample.rar new file mode 100644 index 0000000000000000000000000000000000000000..c637668cbc0d10beba340607a2f256fb3bafd6e7 GIT binary patch literal 5618 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 literal 0 HcmV?d00001