-
-
Notifications
You must be signed in to change notification settings - Fork 200
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
✨ feat(core): Add
unzipArchive
function
- Loading branch information
1 parent
b3877bd
commit 2143fe8
Showing
5 changed files
with
543 additions
and
13 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,75 @@ | ||
import path from 'node:path' | ||
import fs from 'fs-extra' | ||
import unzippper from 'unzipper' | ||
|
||
type UnzipArchiveOptions = { | ||
archivePath: string | ||
overrideFile?: boolean | ||
} | ||
|
||
type UnzipArchiveResult = { | ||
outputPath: string | ||
unzipSkipped: boolean | ||
} | ||
|
||
export async function unzipArchive(options: UnzipArchiveOptions) { | ||
const { archivePath, overrideFile } = options | ||
|
||
const resultPromise = new Promise<UnzipArchiveResult>((resolve, reject) => { | ||
const archiveFileExtension = archivePath.split('.').slice(-1) | ||
const outputPath = archivePath.replace(`.${archiveFileExtension}`, '') | ||
|
||
const fileExists = fs.existsSync(outputPath) | ||
if (fileExists && !overrideFile) { | ||
resolve({ | ||
outputPath, | ||
unzipSkipped: true | ||
}) | ||
|
||
return | ||
} | ||
|
||
// Creates the output directory | ||
fs.mkdirSync(outputPath, { recursive: true }) | ||
|
||
fs.createReadStream(archivePath) | ||
.pipe(unzippper.Parse()) | ||
.on('entry', function (entry) { | ||
const fileName = entry.path | ||
const type = entry.type as 'Directory' | 'File' | ||
|
||
if (type === 'Directory') { | ||
fs.mkdirSync(path.join(outputPath, fileName), { recursive: true }) | ||
return | ||
} | ||
|
||
if (type === 'File') { | ||
const outputFilePath = path.join(outputPath, fileName) | ||
const outputFilePathDir = path.dirname(outputFilePath) | ||
|
||
// Handles the rare case when a file is in a directory which has no entry ¯\_(ツ)_/¯ | ||
if (!fs.existsSync(outputFilePathDir)) { | ||
fs.mkdirSync(outputFilePathDir, { recursive: true }) | ||
} | ||
|
||
entry.pipe(fs.createWriteStream(outputFilePath)) | ||
} | ||
}) | ||
.promise() | ||
.then(() => { | ||
resolve({ | ||
outputPath, | ||
unzipSkipped: false | ||
}) | ||
}) | ||
.catch((error: Error) => { | ||
fs.unlinkSync(outputPath) | ||
reject(new Error(`[Pipe] ${error.message}`)) | ||
}) | ||
}) | ||
|
||
// TODO: Handle errors in a more sophisticated way | ||
return resultPromise.catch((error: Error) => { | ||
throw new Error(`[UnzipFile] Error unzipping the file - ${error.message}`) | ||
}) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,155 @@ | ||
import { fs, vol } from 'memfs' | ||
import { | ||
afterAll, | ||
afterEach, | ||
beforeEach, | ||
describe, | ||
expect, | ||
it, | ||
vi | ||
} from 'vitest' | ||
import { unzipArchive } from '../src/unzipArchive' | ||
import { | ||
ARCHIVE_CONTENTS, | ||
createTestZipArchive, | ||
createTestZipArchiveWithIncorrectEntries | ||
} from './utils/createTestZipArchive' | ||
|
||
const ROOT_DIR = '/tmp' | ||
const FILE_NAME = 'duck.txt' | ||
const NESTED_FILE_NAME = 'nested-duck.txt' | ||
const ARCHIVE_PATH = `${ROOT_DIR}/archive.zip` | ||
const OUTPUT_PATH = `${ROOT_DIR}/archive` | ||
const OUTPUT_NESTED_DIR_PATH = `${OUTPUT_PATH}/nested` | ||
const FILE_OUTPUT_PATH = `${OUTPUT_PATH}/${FILE_NAME}` | ||
const NESTED_FILE_OUTPUT_PATH = `${OUTPUT_NESTED_DIR_PATH}/${NESTED_FILE_NAME}` | ||
|
||
vi.mock('fs-extra', async () => { | ||
return { | ||
default: fs | ||
} | ||
}) | ||
|
||
describe('unzipArchive', () => { | ||
beforeEach(async () => { | ||
vol.mkdirSync(ROOT_DIR) | ||
}) | ||
|
||
afterEach(() => { | ||
// Using this instead of `vol.reset()` due to a bug with streams: | ||
// https://github.com/streamich/memfs/issues/550 | ||
vol.rmdirSync(ROOT_DIR, { recursive: true }) | ||
}) | ||
|
||
afterAll(() => { | ||
vi.resetAllMocks() | ||
}) | ||
|
||
describe('when the archive cannot be unzipped', () => { | ||
const incorrectArchivePath = `${ROOT_DIR}/incorrect-archive.zip` | ||
const outputDirPath = `${ROOT_DIR}/incorrect-archive` | ||
|
||
beforeEach(() => { | ||
fs.writeFileSync(incorrectArchivePath, 'Hello there! 👋') // Plain text file | ||
}) | ||
|
||
it('throws an error', async () => { | ||
const unzipFileFuncCall = unzipArchive({ | ||
archivePath: incorrectArchivePath | ||
}) | ||
|
||
await expect(unzipFileFuncCall).rejects.toThrowError( | ||
'[UnzipFile] Error unzipping the file - [Pipe] invalid signature' | ||
) | ||
}) | ||
|
||
it('removes the output directory if an error is thrown', async () => { | ||
const unzipFileFuncCall = unzipArchive({ | ||
archivePath: incorrectArchivePath | ||
}) | ||
|
||
await expect(unzipFileFuncCall).rejects.toThrow() | ||
expect(fs.existsSync(outputDirPath)).toBe(false) | ||
}) | ||
}) | ||
|
||
describe('when the output directory does not exist', () => { | ||
const runTestAndValidate = async () => { | ||
const result = await unzipArchive({ | ||
archivePath: ARCHIVE_PATH | ||
}) | ||
|
||
expect(result).toStrictEqual({ | ||
outputPath: OUTPUT_PATH, | ||
unzipSkipped: false | ||
}) | ||
expect(fs.existsSync(result.outputPath)).toBe(true) | ||
expect(fs.readFileSync(FILE_OUTPUT_PATH, 'utf8')).toBe( | ||
ARCHIVE_CONTENTS[FILE_NAME] | ||
) | ||
expect(fs.readFileSync(NESTED_FILE_OUTPUT_PATH, 'utf8')).toBe( | ||
ARCHIVE_CONTENTS[NESTED_FILE_NAME] | ||
) | ||
} | ||
|
||
it('unzips the archive', async () => { | ||
// Setup | ||
createTestZipArchive(ARCHIVE_PATH) | ||
|
||
await runTestAndValidate() | ||
}) | ||
|
||
it('unzips the archive if it contains incorrectly encoded directories', async () => { | ||
// Setup | ||
await createTestZipArchiveWithIncorrectEntries(ARCHIVE_PATH) | ||
|
||
await runTestAndValidate() | ||
}) | ||
}) | ||
|
||
describe('when the archive is already unzipped', () => { | ||
const unzippedFileContent = 'This is an unzipped duck file! 🦆' | ||
|
||
beforeEach(() => { | ||
fs.mkdirSync(OUTPUT_PATH) | ||
fs.writeFileSync(FILE_OUTPUT_PATH, unzippedFileContent) | ||
expect(fs.existsSync(FILE_OUTPUT_PATH)).toBe(true) | ||
}) | ||
|
||
it('skips unzipping', async () => { | ||
const result = await unzipArchive({ | ||
archivePath: ARCHIVE_PATH | ||
}) | ||
|
||
expect(result).toStrictEqual({ | ||
outputPath: OUTPUT_PATH, | ||
unzipSkipped: true | ||
}) | ||
expect(fs.existsSync(FILE_OUTPUT_PATH)).toBe(true) | ||
expect(fs.readFileSync(FILE_OUTPUT_PATH, 'utf8')).toBe( | ||
unzippedFileContent | ||
) | ||
}) | ||
|
||
it('overwrites the output directory if the `overrideFile` flag is present', async () => { | ||
createTestZipArchive(ARCHIVE_PATH) | ||
|
||
const result = await unzipArchive({ | ||
archivePath: ARCHIVE_PATH, | ||
overrideFile: true | ||
}) | ||
|
||
expect(result).toStrictEqual({ | ||
outputPath: OUTPUT_PATH, | ||
unzipSkipped: false | ||
}) | ||
expect(fs.existsSync(result.outputPath)).toBe(true) | ||
expect(fs.readFileSync(FILE_OUTPUT_PATH, 'utf8')).not.toBe( | ||
unzippedFileContent | ||
) | ||
expect(fs.readFileSync(NESTED_FILE_OUTPUT_PATH, 'utf8')).toBe( | ||
ARCHIVE_CONTENTS[NESTED_FILE_NAME] | ||
) | ||
}) | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,67 @@ | ||
import path from 'node:path' | ||
import archiver from 'archiver' | ||
import fs from 'fs-extra' | ||
|
||
/** | ||
* Zip archive with the following structure: | ||
* . | ||
* ├── duck.txt | ||
* └──> "Quack! 🐾" | ||
* └── nested | ||
* └── nested-duck.txt | ||
* └──> "Nested Quack! 🐾" | ||
*/ | ||
const ZIP_ARCHIVE_CONTENT_IN_BASE_64_URL = [ | ||
'UEsDBBQACAAIAOZ2Q1cAAAAAAAAAAAAAAAAIAAAAZHVjay50eHQLLE1MzlZU-DB_wj4AUEsHCBjTmn4N', | ||
'AAAACwAAAFBLAwQKAAAAAADmdkNXAAAAAAAAAAAAAAAABwAAAG5lc3RlZC9QSwMEFAAIAAgA5nZDVwAA', | ||
'AAAAAAAAAAAAABYAAABuZXN0ZWQvbmVzdGVkLWR1Y2sudHh080stLklNUQgsTUzOVlT4MH_CPgBQSwcI', | ||
'gkZm8hQAAAASAAAAUEsBAi0DFAAIAAgA5nZDVxjTmn4NAAAACwAAAAgAAAAAAAAAAAAgAKSBAAAAAGR1', | ||
'Y2sudHh0UEsBAi0DCgAAAAAA5nZDVwAAAAAAAAAAAAAAAAcAAAAAAAAAAAAQAO1BQwAAAG5lc3RlZC9Q', | ||
'SwECLQMUAAgACADmdkNXgkZm8hQAAAASAAAAFgAAAAAAAAAAACAApIFoAAAAbmVzdGVkL25lc3RlZC1k', | ||
'dWNrLnR4dFBLBQYAAAAAAwADAK8AAADAAAAAAAA' | ||
].join('') | ||
|
||
export const ARCHIVE_CONTENTS = { | ||
'duck.txt': 'Quack! 🐾', | ||
'nested-duck.txt': 'Nested Quack! 🐾' | ||
} as const | ||
|
||
export function createTestZipArchive(archivePath: string) { | ||
const targetDir = path.dirname(archivePath) | ||
fs.mkdirSync(targetDir, { recursive: true }) | ||
fs.writeFileSync( | ||
archivePath, | ||
Buffer.from(ZIP_ARCHIVE_CONTENT_IN_BASE_64_URL, 'base64url') | ||
) | ||
} | ||
|
||
export async function createTestZipArchiveWithIncorrectEntries( | ||
archivePath: string | ||
) { | ||
return new Promise<void>((resolve, reject) => { | ||
const archiveStream = fs.createWriteStream(archivePath) | ||
const archive = archiver('zip', { | ||
zlib: { level: 9 } // Compression level | ||
}) | ||
|
||
archiveStream.on('finish', () => { | ||
resolve() | ||
}) | ||
|
||
archive.on('error', (error: unknown) => { | ||
reject(error) | ||
}) | ||
|
||
archive.pipe(archiveStream) | ||
|
||
const duckFileName = 'duck.txt' | ||
archive.append(ARCHIVE_CONTENTS[duckFileName], { name: duckFileName }) | ||
|
||
const nestedDuckFileName = 'nested-duck.txt' | ||
archive.append(ARCHIVE_CONTENTS[nestedDuckFileName], { | ||
name: path.join('nested', nestedDuckFileName) | ||
}) | ||
|
||
archive.finalize() | ||
}) | ||
} |
Oops, something went wrong.