Skip to content

Commit

Permalink
✨ feat(core): Add unzipArchive function
Browse files Browse the repository at this point in the history
  • Loading branch information
duckception committed Oct 3, 2023
1 parent b3877bd commit 2143fe8
Show file tree
Hide file tree
Showing 5 changed files with 543 additions and 13 deletions.
6 changes: 5 additions & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,16 @@
},
"dependencies": {
"axios": "^1.4.0",
"fs-extra": "^11.1.1"
"fs-extra": "^11.1.1",
"unzipper": "^0.10.14"
},
"devDependencies": {
"@types/archiver": "^5.3.3",
"@types/fs-extra": "^11.0.2",
"@types/node": "^20.8.0",
"@types/unzipper": "^0.10.7",
"@vitest/coverage-v8": "1.0.0-beta.0",
"archiver": "^6.0.1",
"memfs": "^4.5.0",
"msw": "^1.3.2",
"rimraf": "^5.0.1",
Expand Down
75 changes: 75 additions & 0 deletions packages/core/src/unzipArchive.ts
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}`)
})
}
155 changes: 155 additions & 0 deletions packages/core/test/unzipArchive.test.ts
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]
)
})
})
})
67 changes: 67 additions & 0 deletions packages/core/test/utils/createTestZipArchive.ts
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()
})
}
Loading

0 comments on commit 2143fe8

Please sign in to comment.