diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index 214b6c2..ad4b978 100644 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -1,11 +1,13 @@ -name: CI +name: CI for 1.x on: push: - branches: [ master ] + branches: [ 1.x ] pull_request: - branches: [ master ] + branches: [ 1.x ] + + merge_group: jobs: Job: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1c6cbb1..ea554d7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,13 +1,19 @@ -name: Release +name: Release for 1.x on: push: - branches: [ master ] + branches: [ 1.x ] + +permissions: + contents: write + deployments: write + issues: write + pull-requests: write + id-token: write jobs: release: - name: Node.js - uses: node-modules/github-actions/.github/workflows/node-release.yml@master + name: NPM + uses: node-modules/github-actions/.github/workflows/npm-release.yml@master secrets: - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} GIT_TOKEN: ${{ secrets.GIT_TOKEN }} diff --git a/.gitignore b/.gitignore index 5eaf2d1..12a7c04 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ test/fixtures/chinese-path-test.zip .DS_Store yarn.lock !test/fixtures/symlink/node_modules +pnpm-lock.yaml diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b8bb67..0ff606f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## 1.10.5 (2026-04-13) + +* fix: prevent symlink path traversal via pre-existing symlinks during tar extraction ([18def23](https://github.com/node-modules/compressing/commit/18def23)) + +## 1.10.4 (2026-01-28) + +* fix: prevent arbitrary file write via symlink extraction (#133) ([8d16c19](https://github.com/node-modules/compressing/commit/8d16c19)), closes [#133](https://github.com/node-modules/compressing/issues/133) +* chore: add permissions to auto release ([01acc46](https://github.com/node-modules/compressing/commit/01acc46)) +* chore: add warnning message ([2368a03](https://github.com/node-modules/compressing/commit/2368a03)) +* chore: start 1.x branch ([6f936e0](https://github.com/node-modules/compressing/commit/6f936e0)) +* chore: support auto merge queue ([f2344e7](https://github.com/node-modules/compressing/commit/f2344e7)) +* chore: typo fix on branch ([41a5eae](https://github.com/node-modules/compressing/commit/41a5eae)) + ## [1.10.3](https://github.com/node-modules/compressing/compare/v1.10.2...v1.10.3) (2025-05-24) diff --git a/README.md b/README.md index e416e0c..c1a55d3 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# compressing +# compressing@1 [![NPM version][npm-image]][npm-url] [![Test coverage][codecov-image]][codecov-url] @@ -14,6 +14,10 @@ [download-image]: https://img.shields.io/npm/dm/compressing.svg?style=flat-square [download-url]: https://npmjs.org/package/compressing +## ⚠️ Warning + +**Version 1.x is no longer maintained. Please upgrade to version 2.x as soon as possible.** + The missing compressing and uncompressing lib for node. Currently supported: @@ -26,7 +30,7 @@ Currently supported: ## Install ```bash -npm install compressing +npm install compressing@1 ``` ## Usage diff --git a/lib/utils.js b/lib/utils.js index 3fe8df5..38974ae 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -5,6 +5,79 @@ const path = require('path'); const mkdirp = require('mkdirp'); const pump = require('pump'); +/** + * Check if childPath is within parentPath (prevents path traversal attacks) + * @param {string} childPath - The path to check + * @param {string} parentPath - The parent directory path + * @returns {boolean} - True if childPath is within parentPath + */ +function isPathWithinParent(childPath, parentPath) { + const normalizedChild = path.resolve(childPath); + const normalizedParent = path.resolve(parentPath); + const parentWithSep = normalizedParent.endsWith(path.sep) + ? normalizedParent + : normalizedParent + path.sep; + return normalizedChild === normalizedParent || + normalizedChild.startsWith(parentWithSep); +} + +/** + * Check if the real filesystem path stays within parentDir, + * accounting for pre-existing symlinks on disk. + * Walks each path segment from parentDir to targetPath using lstat. + * If any segment is a symlink, resolves it and verifies it stays within parentDir. + * @param {string} targetPath - Absolute path to validate + * @param {string} parentDir - Absolute path of the extraction root + * @param {string} realParentDir - Pre-resolved real path of parentDir (handles OS-level symlinks like /var -> /private/var on macOS) + * @param {function} callback - callback(err, safe) + */ +function isRealPathSafe(targetPath, parentDir, realParentDir, callback) { + function isWithinParent(p) { + return isPathWithinParent(p, parentDir) || isPathWithinParent(p, realParentDir); + } + + const relative = path.relative(parentDir, targetPath); + const segments = relative.split(path.sep); + let i = 0; + let current = parentDir; + + function checkNext() { + if (i >= segments.length) return callback(null, true); + const segment = segments[i++]; + if (!segment || segment === '.') return checkNext(); + + current = path.join(current, segment); + fs.lstat(current, function(err, stat) { + if (err) { + if (err.code === 'ENOENT') return callback(null, true); // doesn't exist yet, safe + // Fail closed: unexpected filesystem errors are unsafe + return callback(null, false); + } + if (!stat.isSymbolicLink()) return checkNext(); + + fs.realpath(current, function(err, resolved) { + if (err) { + if (err.code === 'ENOENT') { + // Dangling symlink - check textual target + return fs.readlink(current, function(err, linkTarget) { + if (err) return callback(null, false); + const absTarget = path.resolve(path.dirname(current), linkTarget); + callback(null, isWithinParent(absTarget)); + }); + } + // Fail closed: unexpected errors during symlink resolution are unsafe + return callback(null, false); + } + if (!isWithinParent(resolved)) return callback(null, false); + current = resolved; + checkNext(); + }); + }); + } + + checkNext(); +} + // file/fileBuffer/stream exports.sourceType = source => { if (!source) return undefined; @@ -93,58 +166,102 @@ exports.makeUncompressFn = StreamClass => { mkdirp(destDir, err => { if (err) return reject(err); - let entryCount = 0; - let successCount = 0; - let isFinish = false; - function done() { - // resolve when both stream finish and file write finish - if (isFinish && entryCount === successCount) resolve(); - } + // Resolve destDir to absolute path for security validation + const resolvedDestDir = path.resolve(destDir); + + // Resolve once for the entire extraction to handle OS-level symlinks + // (e.g. /var -> /private/var on macOS) + let realDestDir = resolvedDestDir; + fs.realpath(resolvedDestDir, (err, resolved) => { + if (!err) realDestDir = resolved; + + let entryCount = 0; + let successCount = 0; + let isFinish = false; + function done() { + // resolve when both stream finish and file write finish + if (isFinish && entryCount === successCount) resolve(); + } + + new StreamClass(opts) + .on('finish', () => { + isFinish = true; + done(); + }) + .on('error', reject) + .on('entry', (header, stream, next) => { + stream.on('end', next); + const destFilePath = path.join(resolvedDestDir, header.name); + const resolvedDestPath = path.resolve(destFilePath); + + // Security: Validate that the entry path doesn't escape the destination directory + if (!isPathWithinParent(resolvedDestPath, resolvedDestDir)) { + console.warn('[compressing] Skipping entry with path traversal: "' + header.name + '" -> "' + resolvedDestPath + '"'); + stream.resume(); + return; + } - new StreamClass(opts) - .on('finish', () => { - isFinish = true; - done(); - }) - .on('error', reject) - .on('entry', (header, stream, next) => { - stream.on('end', next); - const destFilePath = path.join(destDir, header.name); - - if (header.type === 'file') { - const dir = path.dirname(destFilePath); - mkdirp(dir, err => { - if (err) return reject(err); - - entryCount++; - pump(stream, fs.createWriteStream(destFilePath, { mode: opts.mode || header.mode }), err => { - if (err) return reject(err); - successCount++; - done(); - }); - }); - } else if (header.type === 'symlink') { - const dir = path.dirname(destFilePath); - const target = path.resolve(dir, header.linkname); - entryCount++; - - mkdirp(dir, err => { - if (err) return reject(err); - - const relativeTarget = path.relative(dir, target); - fs.symlink(relativeTarget, destFilePath, err => { - if (err) return reject(err); - successCount++; + // Security: Validate no pre-existing symlink in the path escapes the extraction directory + isRealPathSafe(resolvedDestPath, resolvedDestDir, realDestDir, (err, safe) => { + if (err || !safe) { + console.warn('[compressing] Skipping entry "' + header.name + '": a symlink in its path resolves outside the extraction directory'); stream.resume(); - }); - }); - } else { // directory - mkdirp(destFilePath, err => { - if (err) return reject(err); - stream.resume(); + return; + } + + if (header.type === 'file') { + const dir = path.dirname(destFilePath); + mkdirp(dir, err => { + if (err) return reject(err); + + entryCount++; + pump(stream, fs.createWriteStream(destFilePath, { mode: opts.mode || header.mode }), err => { + if (err) return reject(err); + successCount++; + done(); + }); + }); + } else if (header.type === 'symlink') { + const dir = path.dirname(destFilePath); + const target = path.resolve(dir, header.linkname); + + // Security: Validate that the symlink target doesn't escape the destination directory + if (!isPathWithinParent(target, resolvedDestDir)) { + console.warn('[compressing] Skipping symlink "' + header.name + '": target "' + target + '" escapes extraction directory'); + stream.resume(); + return; + } + + // Security: Validate no pre-existing symlink in the target path escapes the extraction directory + isRealPathSafe(target, resolvedDestDir, realDestDir, (err, targetSafe) => { + if (err || !targetSafe) { + console.warn('[compressing] Skipping symlink "' + header.name + '": target resolves outside extraction directory via existing symlink'); + stream.resume(); + return; + } + + entryCount++; + + mkdirp(dir, err => { + if (err) return reject(err); + + const relativeTarget = path.relative(dir, target); + fs.symlink(relativeTarget, destFilePath, err => { + if (err) return reject(err); + successCount++; + stream.resume(); + }); + }); + }); + } else { // directory + mkdirp(destFilePath, err => { + if (err) return reject(err); + stream.resume(); + }); + } }); - } - }); + }); + }); }); }); }; diff --git a/package.json b/package.json index 8e5a657..15996da 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "compressing", - "version": "1.10.3", + "version": "1.10.5", "description": "Everything you need for compressing and uncompressing", "main": "index.js", "scripts": { diff --git a/test/tar/security-GHSA-4c3q-x735-j3r5.test.js b/test/tar/security-GHSA-4c3q-x735-j3r5.test.js new file mode 100644 index 0000000..86b19f9 --- /dev/null +++ b/test/tar/security-GHSA-4c3q-x735-j3r5.test.js @@ -0,0 +1,209 @@ +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const zlib = require('zlib'); +const uuid = require('uuid'); +const assert = require('assert'); +const compressing = require('../..'); +const { createTarBuffer } = require('../util'); + +describe('test/tar/security-GHSA-4c3q-x735-j3r5.test.js', () => { + let tempDir; + + beforeEach(() => { + tempDir = path.join(os.tmpdir(), uuid.v4()); + fs.mkdirSync(tempDir, { recursive: true }); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + function gzipBuffer(buf) { + return new Promise((resolve, reject) => { + zlib.gzip(buf, (err, result) => { + if (err) return reject(err); + resolve(result); + }); + }); + } + + describe('pre-existing symlink file pointing outside destDir', () => { + it('should block file write through pre-existing symlink to external file', async () => { + const destDir = path.join(tempDir, 'dest'); + const outsideDir = path.join(tempDir, 'outside'); + const sensitiveFile = path.join(outsideDir, 'target.txt'); + + // Setup: create the sensitive file and a pre-existing symlink in destDir + fs.mkdirSync(outsideDir, { recursive: true }); + fs.writeFileSync(sensitiveFile, 'ORIGINAL_SAFE_CONTENT'); + fs.mkdirSync(destDir, { recursive: true }); + fs.symlinkSync(sensitiveFile, path.join(destDir, 'config_file')); + + // Create a tar with a regular file entry matching the symlink name + const tarBuffer = await createTarBuffer([ + { name: 'config_file', type: 'file', content: 'MALICIOUS_OVERWRITE' }, + ]); + + await compressing.tar.uncompress(tarBuffer, destDir); + + // The sensitive file should NOT have been overwritten + assert.strictEqual( + fs.readFileSync(sensitiveFile, 'utf8'), + 'ORIGINAL_SAFE_CONTENT', + 'Sensitive file should not be overwritten through pre-existing symlink' + ); + }); + }); + + describe('pre-existing symlink directory pointing outside destDir', () => { + it('should block file write through pre-existing symlink directory', async () => { + const destDir = path.join(tempDir, 'dest'); + const outsideDir = path.join(tempDir, 'outside'); + + // Setup: create outside dir and a symlink directory in destDir + fs.mkdirSync(outsideDir, { recursive: true }); + fs.mkdirSync(destDir, { recursive: true }); + fs.symlinkSync(outsideDir, path.join(destDir, 'subdir')); + + // Create a tar with a file inside the symlink directory + const tarBuffer = await createTarBuffer([ + { name: 'subdir/secret.txt', type: 'file', content: 'MALICIOUS_DATA' }, + ]); + + await compressing.tar.uncompress(tarBuffer, destDir); + + // The file should NOT exist in the outside directory + assert.strictEqual( + fs.existsSync(path.join(outsideDir, 'secret.txt')), + false, + 'File should not be written through pre-existing symlink directory' + ); + }); + }); + + describe('deeply nested pre-existing symlink', () => { + it('should block file write through nested symlink escape', async () => { + const destDir = path.join(tempDir, 'dest'); + const outsideDir = path.join(tempDir, 'outside'); + + // Setup: create real directories and a symlink deep in the tree + fs.mkdirSync(path.join(destDir, 'a', 'b'), { recursive: true }); + fs.mkdirSync(outsideDir, { recursive: true }); + fs.symlinkSync(outsideDir, path.join(destDir, 'a', 'b', 'c')); + + // Create a tar with a file through the deep symlink + const tarBuffer = await createTarBuffer([ + { name: 'a/b/c/file.txt', type: 'file', content: 'ESCAPED_DATA' }, + ]); + + await compressing.tar.uncompress(tarBuffer, destDir); + + // The file should NOT exist in the outside directory + assert.strictEqual( + fs.existsSync(path.join(outsideDir, 'file.txt')), + false, + 'File should not be written through deeply nested symlink' + ); + }); + }); + + describe('pre-existing symlink pointing within destDir (should be allowed)', () => { + it('should allow file write through symlink that stays within destDir', async () => { + const destDir = path.join(tempDir, 'dest'); + const realDir = path.join(destDir, 'real'); + + // Setup: create real directory and internal symlink + fs.mkdirSync(realDir, { recursive: true }); + fs.symlinkSync(realDir, path.join(destDir, 'link')); + + // Create a tar with a file through the internal symlink + const tarBuffer = await createTarBuffer([ + { name: 'link/newfile.txt', type: 'file', content: 'safe content' }, + ]); + + await compressing.tar.uncompress(tarBuffer, destDir); + + // The file SHOULD exist since the symlink points within destDir + assert.strictEqual( + fs.readFileSync(path.join(realDir, 'newfile.txt'), 'utf8'), + 'safe content', + 'File should be written through internal symlink' + ); + }); + }); + + describe('directory entry through pre-existing external symlink', () => { + it('should block directory creation through pre-existing symlink', async () => { + const destDir = path.join(tempDir, 'dest'); + const outsideDir = path.join(tempDir, 'outside'); + + // Setup + fs.mkdirSync(outsideDir, { recursive: true }); + fs.mkdirSync(destDir, { recursive: true }); + fs.symlinkSync(outsideDir, path.join(destDir, 'escape')); + + // Create a tar with a directory entry through the symlink + const tarBuffer = await createTarBuffer([ + { name: 'escape/newdir/', type: 'directory' }, + ]); + + await compressing.tar.uncompress(tarBuffer, destDir); + + // The directory should NOT exist in the outside directory + assert.strictEqual( + fs.existsSync(path.join(outsideDir, 'newdir')), + false, + 'Directory should not be created through pre-existing symlink' + ); + }); + }); + + describe('tgz format shares the same protection', () => { + it('should block file write through pre-existing symlink in tgz extraction', async () => { + const destDir = path.join(tempDir, 'dest'); + const outsideDir = path.join(tempDir, 'outside'); + const sensitiveFile = path.join(outsideDir, 'target.txt'); + + // Setup + fs.mkdirSync(outsideDir, { recursive: true }); + fs.writeFileSync(sensitiveFile, 'ORIGINAL_SAFE_CONTENT'); + fs.mkdirSync(destDir, { recursive: true }); + fs.symlinkSync(sensitiveFile, path.join(destDir, 'config_file')); + + // Create a tgz buffer + const tarBuffer = await createTarBuffer([ + { name: 'config_file', type: 'file', content: 'MALICIOUS_OVERWRITE' }, + ]); + const tgzBuffer = await gzipBuffer(tarBuffer); + + await compressing.tgz.uncompress(tgzBuffer, destDir); + + // The sensitive file should NOT have been overwritten + assert.strictEqual( + fs.readFileSync(sensitiveFile, 'utf8'), + 'ORIGINAL_SAFE_CONTENT', + 'TGZ: Sensitive file should not be overwritten through pre-existing symlink' + ); + }); + }); + + describe('normal extraction still works (regression)', () => { + it('should extract files normally when no pre-existing symlinks', async () => { + const destDir = path.join(tempDir, 'dest'); + + const tarBuffer = await createTarBuffer([ + { name: 'file1.txt', type: 'file', content: 'content1' }, + { name: 'subdir/', type: 'directory' }, + { name: 'subdir/file2.txt', type: 'file', content: 'content2' }, + ]); + + await compressing.tar.uncompress(tarBuffer, destDir); + + assert.strictEqual(fs.readFileSync(path.join(destDir, 'file1.txt'), 'utf8'), 'content1'); + assert.strictEqual(fs.readFileSync(path.join(destDir, 'subdir/file2.txt'), 'utf8'), 'content2'); + }); + }); +}); diff --git a/test/tar/security-GHSA-cc8f-xg8v-72m3.test.js b/test/tar/security-GHSA-cc8f-xg8v-72m3.test.js new file mode 100644 index 0000000..8c34347 --- /dev/null +++ b/test/tar/security-GHSA-cc8f-xg8v-72m3.test.js @@ -0,0 +1,203 @@ +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const uuid = require('uuid'); +const assert = require('assert'); +const compressing = require('../..'); +const { createTarBuffer } = require('../util'); + +describe('test/tar/security-GHSA-cc8f-xg8v-72m3.test.js', () => { + let tempDir; + + beforeEach(() => { + tempDir = path.join(os.tmpdir(), uuid.v4()); + fs.mkdirSync(tempDir, { recursive: true }); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + describe('symlink escape vulnerability (CVE-2021-32803 style)', () => { + it('should block symlink pointing outside extraction directory', async () => { + const destDir = path.join(tempDir, 'dest'); + const escapedFile = path.join(tempDir, 'escaped.txt'); + + // Create malicious TAR: + // 1. Symlink "escape" -> ".." (points to parent of dest) + // 2. File "escape/escaped.txt" (would write to tempDir/escaped.txt if symlink was followed) + const tarBuffer = await createTarBuffer([ + { name: 'escape', type: 'symlink', linkname: '..' }, + { name: 'escape/escaped.txt', type: 'file', content: 'malicious content' }, + ]); + + await compressing.tar.uncompress(tarBuffer, destDir); + + // The escaped file should NOT exist in the parent directory + assert.strictEqual(fs.existsSync(escapedFile), false, 'File should not be written outside destination'); + + // The symlink should NOT exist as a symlink (it may exist as a directory now) + const escapePath = path.join(destDir, 'escape'); + if (fs.existsSync(escapePath)) { + const stat = fs.lstatSync(escapePath); + assert.strictEqual(stat.isSymbolicLink(), false, 'Path should not be a symlink'); + } + }); + + it('should block symlink pointing to absolute path outside extraction directory', async () => { + const destDir = path.join(tempDir, 'dest'); + const escapedFile = path.join(tempDir, 'poc.txt'); + + // Create malicious TAR with symlink pointing to absolute path + const tarBuffer = await createTarBuffer([ + { name: 'myTmp', type: 'symlink', linkname: tempDir }, + { name: 'myTmp/poc.txt', type: 'file', content: 'malicious content' }, + ]); + + await compressing.tar.uncompress(tarBuffer, destDir); + + // The escaped file should NOT exist + assert.strictEqual(fs.existsSync(escapedFile), false, 'File should not be written via symlink escape'); + }); + + it('should block symlink with absolute path target like /etc/passwd', async () => { + const destDir = path.join(tempDir, 'dest'); + + // Create malicious TAR with symlink pointing to /etc/passwd + const tarBuffer = await createTarBuffer([ + { name: 'passwd', type: 'symlink', linkname: '/etc/passwd' }, + ]); + + await compressing.tar.uncompress(tarBuffer, destDir); + + // The symlink should NOT be created + assert.strictEqual(fs.existsSync(path.join(destDir, 'passwd')), false, 'Symlink to /etc/passwd should not be created'); + }); + }); + + describe('path traversal via file entries', () => { + it('should block file entry with ../ path traversal', async () => { + const destDir = path.join(tempDir, 'dest'); + const escapedFile = path.join(tempDir, 'traversed.txt'); + + // Create malicious TAR with path traversal + const tarBuffer = await createTarBuffer([ + { name: '../traversed.txt', type: 'file', content: 'malicious' }, + ]); + + await compressing.tar.uncompress(tarBuffer, destDir); + + // The file should NOT exist outside destination + assert.strictEqual(fs.existsSync(escapedFile), false, 'File should not be written via path traversal'); + }); + + it('should block file entry with nested ../ path traversal', async () => { + const destDir = path.join(tempDir, 'dest'); + const escapedFile = path.join(tempDir, 'nested-escape.txt'); + + // Create malicious TAR with nested path traversal + const tarBuffer = await createTarBuffer([ + { name: 'foo/bar/../../nested-escape.txt', type: 'file', content: 'malicious' }, + ]); + + await compressing.tar.uncompress(tarBuffer, destDir); + + // The file should NOT exist outside destination + // (Note: The file might be written as dest/nested-escape.txt after normalization, which is acceptable) + assert.strictEqual(fs.existsSync(escapedFile), false, 'File should not escape to parent via nested traversal'); + }); + + it('should block directory entry with ../ path traversal', async () => { + const destDir = path.join(tempDir, 'dest'); + const escapedDir = path.join(tempDir, 'escaped-dir'); + + // Create malicious TAR with directory path traversal + const tarBuffer = await createTarBuffer([ + { name: '../escaped-dir', type: 'directory' }, + ]); + + await compressing.tar.uncompress(tarBuffer, destDir); + + // The directory should NOT exist outside destination + assert.strictEqual(fs.existsSync(escapedDir), false, 'Directory should not be created via path traversal'); + }); + }); + + describe('backward compatibility - valid symlinks', () => { + it('should allow valid internal symlinks', async () => { + const destDir = path.join(tempDir, 'dest'); + + // Create TAR with valid internal symlink + const tarBuffer = await createTarBuffer([ + { name: 'real-file.txt', type: 'file', content: 'hello world' }, + { name: 'link-to-file.txt', type: 'symlink', linkname: 'real-file.txt' }, + ]); + + await compressing.tar.uncompress(tarBuffer, destDir); + + // Both files should exist + assert.strictEqual(fs.existsSync(path.join(destDir, 'real-file.txt')), true, 'Real file should exist'); + assert.strictEqual(fs.existsSync(path.join(destDir, 'link-to-file.txt')), true, 'Symlink should exist'); + + // Symlink should point to the real file + const linkTarget = fs.readlinkSync(path.join(destDir, 'link-to-file.txt')); + assert.strictEqual(linkTarget, 'real-file.txt', 'Symlink should point to real-file.txt'); + }); + + it('should allow symlinks within subdirectories', async () => { + const destDir = path.join(tempDir, 'dest'); + + // Create TAR with valid symlink in subdirectory + const tarBuffer = await createTarBuffer([ + { name: 'subdir/', type: 'directory' }, + { name: 'subdir/file.txt', type: 'file', content: 'content' }, + { name: 'subdir/link.txt', type: 'symlink', linkname: 'file.txt' }, + ]); + + await compressing.tar.uncompress(tarBuffer, destDir); + + // Both should exist + assert.strictEqual(fs.existsSync(path.join(destDir, 'subdir/file.txt')), true); + assert.strictEqual(fs.existsSync(path.join(destDir, 'subdir/link.txt')), true); + }); + + it('should extract symlink.tgz fixture correctly', async () => { + const sourceFile = path.join(__dirname, '..', 'fixtures', 'symlink.tgz'); + const destDir = path.join(tempDir, 'symlink-test'); + + // This should not throw + await compressing.tgz.uncompress(sourceFile, destDir); + + // Verify destination was created + assert.strictEqual(fs.existsSync(destDir), true, 'Destination directory should exist'); + }); + }); + + describe('edge cases', () => { + it('should handle empty TAR', async () => { + const destDir = path.join(tempDir, 'dest'); + const tarBuffer = await createTarBuffer([]); + + await compressing.tar.uncompress(tarBuffer, destDir); + + assert.strictEqual(fs.existsSync(destDir), true, 'Destination should be created'); + }); + + it('should handle normal files correctly', async () => { + const destDir = path.join(tempDir, 'dest'); + + const tarBuffer = await createTarBuffer([ + { name: 'file1.txt', type: 'file', content: 'content1' }, + { name: 'subdir/', type: 'directory' }, + { name: 'subdir/file2.txt', type: 'file', content: 'content2' }, + ]); + + await compressing.tar.uncompress(tarBuffer, destDir); + + assert.strictEqual(fs.readFileSync(path.join(destDir, 'file1.txt'), 'utf8'), 'content1'); + assert.strictEqual(fs.readFileSync(path.join(destDir, 'subdir/file2.txt'), 'utf8'), 'content2'); + }); + }); +}); diff --git a/test/util.js b/test/util.js index cdb84c6..2c26f10 100644 --- a/test/util.js +++ b/test/util.js @@ -1,5 +1,6 @@ const stream = require('stream'); const pump = require('pump'); +const tar = require('tar-stream'); // impl promise pipeline on Node.js 14 const pipelinePromise = stream.promises?.pipeline ?? function pipeline(...args) { @@ -11,4 +12,33 @@ const pipelinePromise = stream.promises?.pipeline ?? function pipeline(...args) }); }; +/** + * Create a TAR buffer with given entries + * @param {Array<{name: string, type?: string, linkname?: string, content?: string}>} entries + * @returns {Promise} + */ +function createTarBuffer(entries) { + return new Promise((resolve, reject) => { + const pack = tar.pack(); + const chunks = []; + + pack.on('data', chunk => chunks.push(chunk)); + pack.on('end', () => resolve(Buffer.concat(chunks))); + pack.on('error', reject); + + for (const entry of entries) { + if (entry.type === 'symlink') { + pack.entry({ name: entry.name, type: 'symlink', linkname: entry.linkname }); + } else if (entry.type === 'directory') { + pack.entry({ name: entry.name, type: 'directory' }); + } else { + pack.entry({ name: entry.name, type: 'file' }, entry.content || ''); + } + } + + pack.finalize(); + }); +} + exports.pipelinePromise = pipelinePromise; +exports.createTarBuffer = createTarBuffer;