diff --git a/__fixtures__/custom-deploy/invalid-file-chars.zip b/__fixtures__/custom-deploy/invalid-file-chars.zip new file mode 100644 index 000000000..fefd0f260 Binary files /dev/null and b/__fixtures__/custom-deploy/invalid-file-chars.zip differ diff --git a/__fixtures__/custom-deploy/no-root-folder.zip b/__fixtures__/custom-deploy/no-root-folder.zip new file mode 100644 index 000000000..06b016dca Binary files /dev/null and b/__fixtures__/custom-deploy/no-root-folder.zip differ diff --git a/__fixtures__/custom-deploy/no-themes-folder.zip b/__fixtures__/custom-deploy/no-themes-folder.zip new file mode 100644 index 000000000..c256bc7c0 Binary files /dev/null and b/__fixtures__/custom-deploy/no-themes-folder.zip differ diff --git a/__fixtures__/custom-deploy/valid-zip-posix.zip b/__fixtures__/custom-deploy/valid-zip-posix.zip new file mode 100644 index 000000000..67a1ab64f Binary files /dev/null and b/__fixtures__/custom-deploy/valid-zip-posix.zip differ diff --git a/__fixtures__/custom-deploy/valid-zip-win32.zip b/__fixtures__/custom-deploy/valid-zip-win32.zip new file mode 100644 index 000000000..39ddcbbe6 Binary files /dev/null and b/__fixtures__/custom-deploy/valid-zip-win32.zip differ diff --git a/__tests__/bin/vip-app-deploy-validate.e2e.js b/__tests__/bin/vip-app-deploy-validate.e2e.js new file mode 100644 index 000000000..10ae20646 --- /dev/null +++ b/__tests__/bin/vip-app-deploy-validate.e2e.js @@ -0,0 +1,65 @@ +import * as exit from '../../src/lib/cli/exit'; +import { validateZipFile } from '../../src/lib/validations/custom-deploy'; + +const exitSpy = jest.spyOn( exit, 'withError' ); +jest.spyOn( process, 'exit' ).mockImplementation( () => {} ); +console.error = jest.fn(); + +describe( 'vip-app-deploy-validate e2e', () => { + beforeEach( async () => { + jest.clearAllMocks(); + } ); + + describe( 'validateZipFile', () => { + it.each( [ + // Archive: __fixtures__/custom-deploy/valid-zip-posix.zip + // __MACOSX/ + // mysite/ + // mysite/.DS_Store + // mysite/__MACOSX + // mysite/themes + // mysite/themes/.DS_Store + // mysite/themes/__MACOSX + // mysite/themes/mytheme.php + '__fixtures__/custom-deploy/valid-zip-posix.zip', + + // Archive: __fixtures__/custom-deploy/valid-zip-win32.zip + // mysite/ + // mysite/themes + // mysite/themes/mytheme.php + '__fixtures__/custom-deploy/valid-zip-win32.zip', + ] )( 'should not throw error for valid zip file: %s', async file => { + await validateZipFile( file ); + + expect( exitSpy ).not.toHaveBeenCalled(); + } ); + + it.each( [ + { + // Archive: __fixtures__/custom-deploy/invalid-file-chars.zip + // mysite/ + // mysite/themes + // mysite/themes/invalid-file-name?.txt + file: '__fixtures__/custom-deploy/invalid-file-chars.zip', + error: `Filename invalid-file-name?.txt contains disallowed characters: [!/:*?"<>|'/^..]+`, + }, + { + // Archive: __fixtures__/custom-deploy/no-root-folder.zip + // no-root-folder.txt + file: '__fixtures__/custom-deploy/no-root-folder.zip', + error: `The compressed file must contain a single root directory.`, + }, + { + // Archive: __fixtures__/custom-deploy/no-themes-folder.zip + // mysite/ + // mysite/file + file: '__fixtures__/custom-deploy/no-themes-folder.zip', + error: `Missing \`themes\` directory from root folder.`, + }, + ] )( 'should throw an error for invalid zip file - $file', async ( { file, error } ) => { + await validateZipFile( file ); + + expect( exitSpy ).toHaveBeenCalledWith( error ); + } ); + } ); +} ); diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index a40c9b034..a89a575c2 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -14,7 +14,6 @@ "@automattic/vip-go-preflight-checks": "^2.0.16", "@automattic/vip-search-replace": "^1.1.1", "@json2csv/plainjs": "^7.0.3", - "adm-zip": "^0.5.14", "args": "5.0.3", "chalk": "4.1.2", "check-disk-space": "3.4.0", @@ -34,6 +33,7 @@ "jwt-decode": "4.0.0", "lando": "github:automattic/lando-cli.git#6ca2668", "node-fetch": "^2.6.1", + "node-stream-zip": "1.15.0", "open": "^10.0.0", "proxy-from-env": "^1.1.0", "semver": "7.6.3", @@ -115,7 +115,6 @@ "@babel/preset-typescript": "7.26.0", "@jest/globals": "^29.7.0", "@jest/test-sequencer": "^29.7.0", - "@types/adm-zip": "^0.5.5", "@types/args": "^5.0.3", "@types/cli-table": "^0.3.4", "@types/configstore": "5.0.1", @@ -3428,15 +3427,6 @@ "resolved": "https://registry.npmjs.org/@streamparser/json/-/json-0.0.20.tgz", "integrity": "sha512-VqAAkydywPpkw63WQhPVKCD3SdwXuihCUVZbbiY3SfSTGQyHmwRoq27y4dmJdZuJwd5JIlQoMPyGvMbUPY0RKQ==" }, - "node_modules/@types/adm-zip": { - "version": "0.5.7", - "resolved": "https://registry.npmjs.org/@types/adm-zip/-/adm-zip-0.5.7.tgz", - "integrity": "sha512-DNEs/QvmyRLurdQPChqq0Md4zGvPwHerAJYWk9l2jCbD1VPpnzRJorOdiq4zsw09NFbYnhfsoEhWtxIzXpn2yw==", - "dev": true, - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/args": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/@types/args/-/args-5.0.3.tgz", @@ -4024,14 +4014,6 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/adm-zip": { - "version": "0.5.16", - "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz", - "integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==", - "engines": { - "node": ">=12.0" - } - }, "node_modules/agent-base": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", @@ -10182,6 +10164,18 @@ "integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==", "dev": true }, + "node_modules/node-stream-zip": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/node-stream-zip/-/node-stream-zip-1.15.0.tgz", + "integrity": "sha512-LN4fydt9TqhZhThkZIVQnF9cwjU3qmUH9h78Mx/K7d3VvfRqqwthLwJEUOEL0QPZ0XQmNN7be5Ggit5+4dq3Bw==", + "engines": { + "node": ">=0.12.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/antelle" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -15688,15 +15682,6 @@ "resolved": "https://registry.npmjs.org/@streamparser/json/-/json-0.0.20.tgz", "integrity": "sha512-VqAAkydywPpkw63WQhPVKCD3SdwXuihCUVZbbiY3SfSTGQyHmwRoq27y4dmJdZuJwd5JIlQoMPyGvMbUPY0RKQ==" }, - "@types/adm-zip": { - "version": "0.5.7", - "resolved": "https://registry.npmjs.org/@types/adm-zip/-/adm-zip-0.5.7.tgz", - "integrity": "sha512-DNEs/QvmyRLurdQPChqq0Md4zGvPwHerAJYWk9l2jCbD1VPpnzRJorOdiq4zsw09NFbYnhfsoEhWtxIzXpn2yw==", - "dev": true, - "requires": { - "@types/node": "*" - } - }, "@types/args": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/@types/args/-/args-5.0.3.tgz", @@ -16179,11 +16164,6 @@ "dev": true, "requires": {} }, - "adm-zip": { - "version": "0.5.16", - "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz", - "integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==" - }, "agent-base": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", @@ -20661,6 +20641,11 @@ "integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==", "dev": true }, + "node-stream-zip": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/node-stream-zip/-/node-stream-zip-1.15.0.tgz", + "integrity": "sha512-LN4fydt9TqhZhThkZIVQnF9cwjU3qmUH9h78Mx/K7d3VvfRqqwthLwJEUOEL0QPZ0XQmNN7be5Ggit5+4dq3Bw==" + }, "normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", diff --git a/package.json b/package.json index f61c3361a..8e35a5451 100644 --- a/package.json +++ b/package.json @@ -114,7 +114,6 @@ "@babel/preset-typescript": "7.26.0", "@jest/globals": "^29.7.0", "@jest/test-sequencer": "^29.7.0", - "@types/adm-zip": "^0.5.5", "@types/args": "^5.0.3", "@types/cli-table": "^0.3.4", "@types/configstore": "5.0.1", @@ -145,7 +144,6 @@ "@automattic/vip-go-preflight-checks": "^2.0.16", "@automattic/vip-search-replace": "^1.1.1", "@json2csv/plainjs": "^7.0.3", - "adm-zip": "^0.5.14", "args": "5.0.3", "chalk": "4.1.2", "check-disk-space": "3.4.0", @@ -165,6 +163,7 @@ "jwt-decode": "4.0.0", "lando": "github:automattic/lando-cli.git#6ca2668", "node-fetch": "^2.6.1", + "node-stream-zip": "1.15.0", "open": "^10.0.0", "proxy-from-env": "^1.1.0", "semver": "7.6.3", diff --git a/src/bin/vip-app-deploy-validate.ts b/src/bin/vip-app-deploy-validate.ts index b1233aac8..51fe31638 100755 --- a/src/bin/vip-app-deploy-validate.ts +++ b/src/bin/vip-app-deploy-validate.ts @@ -41,7 +41,7 @@ export async function appDeployValidateCmd( const ext = extname( fileName ); if ( ext === '.zip' ) { - validateZipFile( fileName ); + await validateZipFile( fileName ); } else { await validateTarFile( fileName ); } diff --git a/src/lib/validations/custom-deploy.ts b/src/lib/validations/custom-deploy.ts index 8b3efe8b4..b65a64cc1 100644 --- a/src/lib/validations/custom-deploy.ts +++ b/src/lib/validations/custom-deploy.ts @@ -1,4 +1,4 @@ -import AdmZip from 'adm-zip'; +import StreamZip, { ZipEntry } from 'node-stream-zip'; import { constants } from 'node:fs'; import path from 'path'; import * as tar from 'tar'; @@ -77,14 +77,14 @@ export function validateName( name: string, isDirectory: boolean ) { /** * Validate the existence of a symlink in a zip file. Ignores symlinks in node_modules/.bin/ * - * @param {IZipEntry} entry The zip entry to validate + * @param {ZipEntry} entry The zip entry to validate */ -function validateZipSymlink( entry: AdmZip.IZipEntry ) { - if ( symlinkIgnorePattern.test( entry.entryName ) ) { +function validateZipSymlink( entry: ZipEntry ) { + if ( symlinkIgnorePattern.test( entry.name ) ) { return; } - const madeBy = entry.header.made >> 8; // eslint-disable-line no-bitwise + const madeBy = entry.verMade >> 8; // eslint-disable-line no-bitwise const errorMsg = errorMessages.symlink + entry.name; // DOS @@ -104,26 +104,31 @@ function validateZipSymlink( entry: AdmZip.IZipEntry ) { * Validate a zip entry for disallowed characters and symlinks. * Ignores __MACOSX directories. * - * @param {IZipEntry} entry The zip entry to validate + * @param {ZipEntry} entry The zip entry to validate */ -function validateZipEntry( entry: AdmZip.IZipEntry ) { - if ( entry.entryName.startsWith( macosxDir ) ) { +function validateZipEntry( entry: ZipEntry ) { + if ( entry.name.startsWith( macosxDir ) ) { return; } - validateName( entry.isDirectory ? entry.entryName : entry.name, entry.isDirectory ); + validateName( entry.isDirectory ? entry.name : path.basename( entry.name ), entry.isDirectory ); validateZipSymlink( entry ); } /** * Validate the existence of a themes directory in the root folder. * - * @param {IZipEntry[]} zipEntries The zip entries to validate + * @param rootFolder The root folder of the zip file + * @param {ZipEntry[]} zipEntries The zip entries to validate */ -function validateZipThemes( rootFolder: string, zipEntries: AdmZip.IZipEntry[] ) { - const hasThemesDir = zipEntries.some( - entry => entry.isDirectory && entry.entryName.startsWith( path.join( rootFolder, 'themes/' ) ) - ); +function validateZipThemes( rootFolder: string, zipEntries: ZipEntry[] ) { + const hasThemesDir = zipEntries.some( entry => { + // Convert win32 path separators to posix path separators + const posixPath = entry.name.replace( /\\/g, '/' ); + const requiredPosixPath = path.join( rootFolder, 'themes/' ).replace( /\\/g, '/' ); + + return entry.isDirectory && posixPath.startsWith( requiredPosixPath ); + } ); if ( ! hasThemesDir ) { exit.withError( errorMessages.missingThemes ); @@ -135,25 +140,26 @@ function validateZipThemes( rootFolder: string, zipEntries: AdmZip.IZipEntry[] ) * * @param {string} filePath The path to the zip file */ -export function validateZipFile( filePath: string ) { +export async function validateZipFile( filePath: string ) { try { - const zipFile = new AdmZip( filePath ); - const zipEntries = zipFile.getEntries(); + const zipFile = new StreamZip.async( { file: filePath } ); + + const zipEntries = await zipFile.entries(); - const rootDirs = zipEntries.filter( + const rootDirs = Object.values( zipEntries ).filter( entry => entry.isDirectory && - ! entry.entryName.startsWith( macosxDir ) && - ( entry.entryName.match( /\//g ) || [] ).length === 1 + ! entry.name.startsWith( macosxDir ) && + ( entry.name.match( /\//g ) || [] ).length === 1 ); if ( rootDirs.length !== 1 ) { exit.withError( errorMessages.singleRootDir ); } - const rootFolder = rootDirs[ 0 ].entryName; - validateZipThemes( rootFolder, zipEntries ); + const rootFolder = rootDirs[ 0 ].name; + validateZipThemes( rootFolder, Object.values( zipEntries ) ); - zipEntries.forEach( entry => validateZipEntry( entry ) ); + Object.values( zipEntries ).forEach( entry => validateZipEntry( entry ) ); } catch ( error ) { const err = error as Error; exit.withError( `Error reading file: ${ err.message }` );