// Licensed to the Software Freedom Conservancy (SFC) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The SFC licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
'use strict'
const jszip = require('jszip')
const path = require('node:path')
const io = require('./index')
const { InvalidArgumentError } = require('../lib/error')
/**
* Manages a zip archive.
*/
class Zip {
constructor() {
/** @private @const */
this.z_ = new jszip()
/** @private @const {!Set<!Promise<?>>} */
this.pendingAdds_ = new Set()
}
/**
* Adds a file to this zip.
*
* @param {string} filePath path to the file to add.
* @param {string=} zipPath path to the file in the zip archive, defaults
* to the basename of `filePath`.
* @return {!Promise<?>} a promise that will resolve when added.
*/
addFile(filePath, zipPath = path.basename(filePath)) {
let add = io
.read(filePath)
.then((buffer) => this.z_.file(/** @type {string} */ (zipPath.replace(/\\/g, '/')), buffer))
this.pendingAdds_.add(add)
return add.then(
() => this.pendingAdds_.delete(add),
(e) => {
this.pendingAdds_.delete(add)
throw e
},
)
}
/**
* Recursively adds a directory and all of its contents to this archive.
*
* @param {string} dirPath path to the directory to add.
* @param {string=} zipPath path to the folder in the archive to add the
* directory contents to. Defaults to the root folder.
* @return {!Promise<?>} returns a promise that will resolve when
* the operation is complete.
*/
addDir(dirPath, zipPath = '') {
return io.walkDir(dirPath).then((entries) => {
let archive = this.z_
if (zipPath) {
archive = archive.folder(zipPath)
}
let files = []
entries.forEach((spec) => {
if (spec.dir) {
archive.folder(spec.path)
} else {
files.push(this.addFile(path.join(dirPath, spec.path), path.join(zipPath, spec.path)))
}
})
return Promise.all(files)
})
}
/**
* @param {string} path File path to test for within the archive.
* @return {boolean} Whether this zip archive contains an entry with the given
* path.
*/
has(path) {
return this.z_.file(path) !== null
}
/**
* Returns the contents of the file in this zip archive with the given `path`.
* The returned promise will be rejected with an {@link InvalidArgumentError}
* if either `path` does not exist within the archive, or if `path` refers
* to a directory.
*
* @param {string} path the path to the file whose contents to return.
* @return {!Promise<!Buffer>} a promise that will be resolved with the file's
* contents as a buffer.
*/
getFile(path) {
let file = this.z_.file(path)
if (!file) {
return Promise.reject(new InvalidArgumentError(`No such file in zip archive: ${path}`))
}
if (file.dir) {
return Promise.reject(new InvalidArgumentError(`The requested file is a directory: ${path}`))
}
return Promise.resolve(file.async('nodebuffer'))
}
/**
* Returns the compressed data for this archive in a buffer. _This method will
* not wait for any outstanding {@link #addFile add}
* {@link #addDir operations} before encoding the archive._
*
* @param {string} compression The desired compression.
* Must be `STORE` (the default) or `DEFLATE`.
* @return {!Promise<!Buffer>} a promise that will resolve with this archive
* as a buffer.
*/
toBuffer(compression = 'STORE') {
if (compression !== 'STORE' && compression !== 'DEFLATE') {
return Promise.reject(new InvalidArgumentError(`compression must be one of {STORE, DEFLATE}, got ${compression}`))
}
return Promise.resolve(this.z_.generateAsync({ compression, type: 'nodebuffer' }))
}
}
/**
* Asynchronously opens a zip archive.
*
* @param {string} path to the zip archive to load.
* @return {!Promise<!Zip>} a promise that will resolve with the opened
* archive.
*/
function load(path) {
return io.read(path).then((data) => {
let zip = new Zip()
return zip.z_.loadAsync(data).then(() => zip)
})
}
/**
* Asynchronously unzips an archive file.
*
* @param {string} src path to the source file to unzip.
* @param {string} dst path to the destination directory.
* @return {!Promise<string>} a promise that will resolve with `dst` once the
* archive has been unzipped.
*/
function unzip(src, dst) {
return load(src).then((zip) => {
const promisedDirs = new Map()
const promises = []
zip.z_.forEach((relPath, file) => {
let p
if (file.dir) {
p = createDir(relPath)
} else {
let dirname = path.dirname(relPath)
if (dirname === '.') {
p = writeFile(relPath, file)
} else {
p = createDir(dirname).then(() => writeFile(relPath, file))
}
}
promises.push(p)
})
return Promise.all(promises).then(() => dst)
function createDir(dir) {
let p = promisedDirs.get(dir)
if (!p) {
p = io.mkdirp(path.join(dst, dir))
promisedDirs.set(dir, p)
}
return p
}
function writeFile(relPath, file) {
return file.async('nodebuffer').then((buffer) => io.write(path.join(dst, relPath), buffer))
}
})
}
// PUBLIC API
module.exports = { Zip, load, unzip }