Skip to content

Commit

Permalink
WIP
Browse files Browse the repository at this point in the history
  • Loading branch information
XhmikosR committed May 28, 2023
1 parent 7abeef9 commit a136be6
Show file tree
Hide file tree
Showing 5 changed files with 102 additions and 56 deletions.
7 changes: 6 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ on:
env:
FORCE_COLOR: 2

permissions:
contents: read

jobs:
test:
name: Node ${{ matrix.node }} on ${{ matrix.os }}
Expand All @@ -16,12 +19,14 @@ jobs:
strategy:
fail-fast: false
matrix:
node: [12, 14, 16, 18]
node: [14, 16, 18, 20]
os: [ubuntu-latest, windows-latest]

steps:
- name: Clone repository
uses: actions/checkout@v3
with:
persist-credentials: false

- name: Set up Node.js
uses: actions/setup-node@v3
Expand Down
43 changes: 43 additions & 0 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
name: "CodeQL"

on:
push:
branches:
- master
- "!dependabot/**"
pull_request:
branches:
- master
- "!dependabot/**"
schedule:
- cron: "0 0 * * 0"
workflow_dispatch:

jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write

steps:
- name: Clone repository
uses: actions/checkout@v3
with:
persist-credentials: false

- name: Initialize CodeQL
uses: github/codeql-action/init@v2
with:
languages: "javascript"
queries: +security-and-quality

- name: Autobuild
uses: github/codeql-action/autobuild@v2

- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2
with:
category: "/language:javascript"
52 changes: 27 additions & 25 deletions index.js
Original file line number Diff line number Diff line change
@@ -1,28 +1,34 @@
import {Buffer} from 'node:buffer';
import path from 'node:path';
import process from 'node:process';
import {promisify} from 'node:util';
import decompressTar from 'decompress-tar';
import decompressTarbz2 from 'decompress-tarbz2';
import decompressTargz from 'decompress-targz';
import decompressUnzip from 'decompress-unzip';
import fs from 'graceful-fs';
import makeDir from 'make-dir';
import pify from 'pify';
import stripDirs from 'strip-dirs';

const fsP = pify(fs);
const link = promisify(fs.link);
const readFile = promisify(fs.readFile);
const readlink = promisify(fs.readlink);
const realpath = promisify(fs.realpath);
const symlink = promisify(fs.symlink);
const utimes = promisify(fs.utimes);
const writeFile = promisify(fs.writeFile);

const runPlugins = (input, options) => {
if (options.plugins.length === 0) {
return Promise.resolve([]);
}

return Promise.all(options.plugins.map(x => x(input, options)))
// eslint-disable-next-line unicorn/no-array-reduce, unicorn/prefer-spread
.then(files => files.reduce((a, b) => a.concat(b)));
return Promise.all(options.plugins.map(plugin => plugin(input, options)))
// eslint-disable-next-line unicorn/no-array-reduce
.then(files => files.reduce((a, b) => [...a, ...b]));
};

const safeMakeDir = (dir, realOutputPath) => fsP.realpath(dir)
const safeMakeDir = (dir, realOutputPath) => realpath(dir)
.catch(_ => {
const parent = path.dirname(dir);
return safeMakeDir(parent, realOutputPath);
Expand All @@ -32,10 +38,10 @@ const safeMakeDir = (dir, realOutputPath) => fsP.realpath(dir)
throw new Error('Refusing to create a directory outside the output path.');
}

return makeDir(dir).then(fsP.realpath);
return makeDir(dir).then(realpath);
});

const preventWritingThroughSymlink = (destination, realOutputPath) => fsP.readlink(destination)
const preventWritingThroughSymlink = (destination, realOutputPath) => readlink(destination)
// Either no file exists, or it's not a symlink. In either case, this is
// not an escape we need to worry about in this phase.
.catch(_ => null)
Expand Down Expand Up @@ -79,47 +85,43 @@ const extractFile = (input, output, options) => runPlugins(input, options).then(

if (x.type === 'directory') {
return makeDir(output)
.then(outputPath => fsP.realpath(outputPath))
.then(outputPath => realpath(outputPath))
.then(realOutputPath => safeMakeDir(dest, realOutputPath))
.then(() => fsP.utimes(dest, now, x.mtime))
.then(() => utimes(dest, now, x.mtime))
.then(() => x);
}

return makeDir(output)
.then(outputPath => fsP.realpath(outputPath))
.then(outputPath => realpath(outputPath))
.then(realOutputPath =>
// Attempt to ensure parent directory exists (failing if it's outside the output dir)
safeMakeDir(path.dirname(dest), realOutputPath).then(() => realOutputPath),
)
.then(realOutputPath => {
if (x.type === 'file') {
return preventWritingThroughSymlink(dest, realOutputPath);
}

return realOutputPath;
})
.then(realOutputPath => fsP.realpath(path.dirname(dest))
.then(realOutputPath => x.type === 'file'
? preventWritingThroughSymlink(dest, realOutputPath)
: realOutputPath)
.then(realOutputPath => realpath(path.dirname(dest))
.then(realDestinationDir => {
if (realDestinationDir.indexOf(realOutputPath) !== 0) {
throw new Error(`Refusing to write outside output directory: ${realDestinationDir}`);
}
}))
.then(() => {
if (x.type === 'link') {
return fsP.link(x.linkname, dest);
return link(x.linkname, dest);
}

if (x.type === 'symlink' && process.platform === 'win32') {
return fsP.link(x.linkname, dest);
return link(x.linkname, dest);
}

if (x.type === 'symlink') {
return fsP.symlink(x.linkname, dest);
return symlink(x.linkname, dest);
}

return fsP.writeFile(dest, x.data, {mode});
return writeFile(dest, x.data, {mode});
})
.then(() => x.type === 'file' && fsP.utimes(dest, now, x.mtime))
.then(() => x.type === 'file' && utimes(dest, now, x.mtime))
.then(() => x);
}));
});
Expand All @@ -144,7 +146,7 @@ const decompress = (input, output, options) => {
...options,
};

const read = typeof input === 'string' ? fsP.readFile(input) : Promise.resolve(input);
const read = typeof input === 'string' ? readFile(input) : Promise.resolve(input);

return read.then(buf => extractFile(buf, output, options));
};
Expand Down
13 changes: 6 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,12 @@
"url": "github.com/kevva"
},
"engines": {
"node": "^12.20.0 || ^14.14.0 || >=16.0.0"
"node": "^14.14.0 || >=16.0.0"
},
"scripts": {
"ava": "ava",
"xo": "xo",
"fix": "xo --fix",
"test": "npm run xo && npm run ava",
"test-ci": "npm run xo && c8 ava"
},
Expand Down Expand Up @@ -45,17 +46,15 @@
"decompress-tarbz2": "^4.1.1",
"decompress-targz": "^4.1.1",
"decompress-unzip": "^4.0.1",
"graceful-fs": "^4.2.10",
"graceful-fs": "^4.2.11",
"make-dir": "^3.1.0",
"pify": "^5.0.0",
"strip-dirs": "^3.0.0"
},
"devDependencies": {
"ava": "^4.3.0",
"c8": "^7.11.3",
"ava": "^5.3.0",
"c8": "^7.13.0",
"is-jpg": "^3.0.0",
"path-exists": "^5.0.0",
"rimraf": "^3.0.2",
"xo": "^0.49.0"
"xo": "^0.54.2"
}
}
43 changes: 20 additions & 23 deletions test.js
Original file line number Diff line number Diff line change
@@ -1,26 +1,23 @@
import fs from 'node:fs';
import fs from 'node:fs/promises';
import path from 'node:path';
import process from 'node:process';
import {fileURLToPath} from 'node:url';
import {promisify} from 'node:util';
import isJpg from 'is-jpg';
import {pathExists} from 'path-exists';
import pify from 'pify';
import rimraf from 'rimraf';
import test from 'ava';
import decompress from './index.js';

const fsP = pify(fs);
const rimrafP = promisify(rimraf);
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const isWindows = process.platform === 'win32';

test.serial.afterEach('ensure decompressed files and directories are cleaned up', async () => {
await rimrafP(path.join(__dirname, 'directory'));
await rimrafP(path.join(__dirname, 'dist'));
await rimrafP(path.join(__dirname, 'example.txt'));
await rimrafP(path.join(__dirname, 'file.txt'));
await rimrafP(path.join(__dirname, 'edge_case_dots'));
await rimrafP(path.join(__dirname, 'symlink'));
await rimrafP(path.join(__dirname, 'test.jpg'));
await fs.rm(path.join(__dirname, 'directory'), {force: true, recursive: true});
await fs.rm(path.join(__dirname, 'dist'), {force: true, recursive: true});
await fs.rm(path.join(__dirname, 'example.txt'), {force: true, recursive: true});
await fs.rm(path.join(__dirname, 'file.txt'), {force: true, recursive: true});
await fs.rm(path.join(__dirname, 'edge_case_dots'), {force: true, recursive: true});
await fs.rm(path.join(__dirname, 'symlink'), {force: true, recursive: true});
await fs.rm(path.join(__dirname, 'test.jpg'), {force: true, recursive: true});
});

test('extract file', async t => {
Expand All @@ -40,13 +37,13 @@ test('extract file', async t => {
});

test('extract file using buffer', async t => {
const tarBuf = await fsP.readFile(path.join(__dirname, 'fixtures', 'file.tar'));
const tarBuf = await fs.readFile(path.join(__dirname, 'fixtures', 'file.tar'));
const tarFiles = await decompress(tarBuf);
const tarbzBuf = await fsP.readFile(path.join(__dirname, 'fixtures', 'file.tar.bz2'));
const tarbzBuf = await fs.readFile(path.join(__dirname, 'fixtures', 'file.tar.bz2'));
const tarbzFiles = await decompress(tarbzBuf);
const targzBuf = await fsP.readFile(path.join(__dirname, 'fixtures', 'file.tar.gz'));
const targzBuf = await fs.readFile(path.join(__dirname, 'fixtures', 'file.tar.gz'));
const targzFiles = await decompress(targzBuf);
const zipBuf = await fsP.readFile(path.join(__dirname, 'fixtures', 'file.zip'));
const zipBuf = await fs.readFile(path.join(__dirname, 'fixtures', 'file.zip'));
const zipFiles = await decompress(zipBuf);

t.is(tarFiles[0].path, 'test.jpg');
Expand All @@ -63,9 +60,9 @@ test.serial('extract file to directory', async t => {
t.true(await pathExists(path.join(__dirname, 'test.jpg')));
});

test.serial('extract symlink', async t => {
(isWindows ? test.skip : test.serial)('extract symlink', async t => {
await decompress(path.join(__dirname, 'fixtures', 'symlink.tar'), __dirname, {strip: 1});
t.is(await fsP.realpath(path.join(__dirname, 'symlink')), path.join(__dirname, 'file.txt'));
t.is(await fs.realpath(path.join(__dirname, 'symlink')), path.join(__dirname, 'file.txt'));
});

test.serial('extract directory', async t => {
Expand Down Expand Up @@ -104,7 +101,7 @@ test('map option', async t => {

test.serial('set mtime', async t => {
const files = await decompress(path.join(__dirname, 'fixtures', 'file.tar'), __dirname);
const stat = await fsP.stat(path.join(__dirname, 'test.jpg'));
const stat = await fs.stat(path.join(__dirname, 'test.jpg'));
t.deepEqual(files[0].mtime, stat.mtime);
});

Expand All @@ -119,13 +116,13 @@ test.serial('throw when a location outside the root is given', async t => {
}, {message: /Refusing/});
});

test.serial('throw when a location outside the root including symlinks is given', async t => {
(isWindows ? test.skip : test.serial)('throw when a location outside the root including symlinks is given', async t => {
await t.throwsAsync(async () => {
await decompress(path.join(__dirname, 'fixtures', 'slip.zip'), 'dist');
}, {message: /Refusing/});
});

test.serial('throw when a top-level symlink outside the root is given', async t => {
(isWindows ? test.skip : test.serial)('throw when a top-level symlink outside the root is given', async t => {
await t.throwsAsync(async () => {
await decompress(path.join(__dirname, 'fixtures', 'slip2.zip'), 'dist');
}, {message: /Refusing/});
Expand Down Expand Up @@ -156,7 +153,7 @@ test.serial('allows top-level file', async t => {
t.is(files[0].path, 'example.txt');
});

test.serial('throw when chained symlinks to /tmp/dist allow escape outside root directory', async t => {
(isWindows ? test.skip : test.serial)('throw when chained symlinks to /tmp/dist allow escape outside root directory', async t => {
await t.throwsAsync(async () => {
await decompress(path.join(__dirname, 'fixtures', 'slip3.zip'), '/tmp/dist');
}, {message: /Refusing/});
Expand Down

0 comments on commit a136be6

Please sign in to comment.