diff --git a/packages/playwright-core/src/utils/tar.ts b/packages/playwright-core/src/utils/tar.ts index 6108e962b6..da03a016ec 100644 --- a/packages/playwright-core/src/utils/tar.ts +++ b/packages/playwright-core/src/utils/tar.ts @@ -14,65 +14,60 @@ * limitations under the License. */ import fs from 'fs'; -import zlib from 'zlib'; -import { Transform } from 'stream'; +import { Writable } from 'stream'; import path from 'path'; -import { execSync } from 'child_process'; -class TarHeader { - static parseHeader(buffer) { - if (buffer.length < 512) - return null; +function parseHeader(buffer: Buffer) { + if (buffer.length < 512) + return null; - let name = buffer.toString('utf8', 0, 100).replace(/\0/g, ''); - const prefixField = buffer.toString('utf8', 345, 500).replace(/\0/g, ''); - if (prefixField) - name = path.join(prefixField, name); + let name = buffer.toString('utf8', 0, 100).replace(/\0/g, ''); + const prefixField = buffer.toString('utf8', 345, 500).replace(/\0/g, ''); + if (prefixField) + name = path.join(prefixField, name); - const size = parseInt(buffer.toString('utf8', 124, 136).trim(), 8); - const typeFlag = buffer[156]; - const mode = parseInt(buffer.toString('utf8', 100, 108).trim(), 8); - const linkname = buffer.toString('utf8', 157, 257).replace(/\0/g, ''); + const size = parseInt(buffer.toString('utf8', 124, 136).trim(), 8); + const typeFlag = buffer[156]; + const mode = parseInt(buffer.toString('utf8', 100, 108).trim(), 8); + const linkname = buffer.toString('utf8', 157, 257).replace(/\0/g, ''); - // Parse user and group IDs - const uid = parseInt(buffer.toString('utf8', 108, 116).trim(), 8); - const gid = parseInt(buffer.toString('utf8', 116, 124).trim(), 8); + // Parse user and group IDs + const uid = parseInt(buffer.toString('utf8', 108, 116).trim(), 8); + const gid = parseInt(buffer.toString('utf8', 116, 124).trim(), 8); - let type = 'file'; - if (typeFlag === 53) // ASCII '5' - type = 'directory'; - else if (typeFlag === 50) // ASCII '2' - type = 'symlink'; - else if (typeFlag === 0 || typeFlag === 48) // ASCII '0' - type = 'file'; + let type = 'file'; + if (typeFlag === 53) // ASCII '5' + type = 'directory'; + else if (typeFlag === 50) // ASCII '2' + type = 'symlink'; + else if (typeFlag === 0 || typeFlag === 48) // ASCII '0' + type = 'file'; - return { - name: name.replace(/^\/+/, ''), - size, - type, - mode: mode || 0o644, - linkname, - uid, - gid - }; - } + return { + name: name.replace(/^\/+/, ''), + size, + type, + mode: mode || 0o644, + linkname, + uid, + gid + }; } -class TarTransformer extends Transform { - constructor(outputPath, prefix = 'chrome-mac') { +type TarHeader = NonNullable>; + +export class TarExtractor extends Writable { + private buffer = Buffer.alloc(0); + private currentHeader: TarHeader | null = null; + private remainingBytes = 0; + private currentFileStream: fs.WriteStream | null = null; + + constructor(private outputPath: (path: string) => string) { super(); - this.outputPath = outputPath; - this.prefix = prefix; - this.buffer = Buffer.alloc(0); - this.currentHeader = null; - this.remainingBytes = 0; - this.symlinksToCreate = new Map(); - this.currentFileStream = null; - this.filesToChmod = new Set(); } - async mkdir(dir) { + async mkdir(dir: string) { try { await fs.promises.mkdir(dir, { recursive: true }); // Set proper permissions for directories @@ -83,16 +78,8 @@ class TarTransformer extends Transform { } } - normalizePath(headerName) { - if (!headerName.startsWith(this.prefix)) - return path.join(this.prefix, headerName); - - return headerName; - } - - async processHeader(header) { - const normalizedPath = this.normalizePath(header.name); - const fullPath = path.join(this.outputPath, normalizedPath); + async processHeader(header: TarHeader) { + const fullPath = this.outputPath(header.name); await this.mkdir(path.dirname(fullPath)); if (header.type === 'directory') { @@ -101,118 +88,45 @@ class TarTransformer extends Transform { } if (header.type === 'symlink') { - this.symlinksToCreate.set(fullPath, header.linkname); + await this.createSymlink(fullPath, header.linkname); return null; } - // Track files that need chmod - this.filesToChmod.add({ path: fullPath, mode: header.mode }); + // TODO: track chmod maybe return fs.createWriteStream(fullPath, { mode: header.mode }); } - async createSymlinks() { - for (const [symlinkPath, targetPath] of this.symlinksToCreate) { - try { - const linkDir = path.dirname(symlinkPath); - - try { - await fs.promises.unlink(symlinkPath); - } catch (err) { - if (err.code !== 'ENOENT') - throw err; - } - - await fs.promises.symlink(targetPath, symlinkPath); - } catch (err) { - console.error(`Failed to create symlink ${symlinkPath} -> ${targetPath}:`, err); - } - } - } - - async fixMacOSApp() { + async createSymlink(symlinkPath: string, targetPath: string) { try { - const appPath = path.join(this.outputPath, this.prefix, 'Chromium.app'); - - // Remove quarantine attribute - execSync(`xattr -r -d com.apple.quarantine "${appPath}"`, { stdio: 'ignore' }); - - // Set proper permissions recursively - execSync(`chmod -R u+x "${appPath}"`, { stdio: 'ignore' }); - - // Mark as executable - execSync(`chmod +x "${appPath}/Contents/MacOS/Chromium"`, { stdio: 'ignore' }); - - // Fix permissions for all binary files - for (const file of this.filesToChmod) { - if ((file.mode & 0o111) !== 0) { // If file has any execute bits - await fs.promises.chmod(file.path, file.mode); - } - } + await fs.promises.unlink(symlinkPath); } catch (err) { - console.error('Error fixing macOS app:', err); + if (err.code !== 'ENOENT') + throw err; } + + await fs.promises.symlink(targetPath, symlinkPath); } - async processHeader(header) { - const normalizedPath = this.normalizePath(header.name); - const fullPath = path.join(this.outputPath, normalizedPath); - await this.mkdir(path.dirname(fullPath)); - - if (header.type === 'directory') { - await this.mkdir(fullPath); - return null; - } - - if (header.type === 'symlink') { - this.symlinksToCreate.set(fullPath, header.linkname); - return null; - } - - return fs.createWriteStream(fullPath, { mode: header.mode }); - } - - async createSymlinks() { - for (const [symlinkPath, targetPath] of this.symlinksToCreate) { - try { - const linkDir = path.dirname(symlinkPath); - const resolvedTarget = path.resolve(linkDir, targetPath); - - try { - await fs.promises.unlink(symlinkPath); - } catch (err) { - if (err.code !== 'ENOENT') - throw err; - } - - await fs.promises.symlink(targetPath, symlinkPath); - } catch (err) { - console.error(`Failed to create symlink ${symlinkPath} -> ${targetPath}:`, err); - } - } - } - - // ... rest of the implementation (same _transform and other methods) ... - - async _transform(chunk, encoding, callback) { + override async _write(chunk: Buffer, _encoding: string, callback: (err?: Error) => void) { try { this.buffer = Buffer.concat([this.buffer, chunk]); while (this.buffer.length >= 512) { if (!this.currentHeader) { // Check for end of archive (two consecutive zero blocks) - if (this.buffer.slice(0, 512).every(byte => byte === 0)) { - this.buffer = this.buffer.slice(512); + if (this.buffer.subarray(0, 512).every(byte => byte === 0)) { + this.buffer = this.buffer.subarray(512); continue; } - const header = TarHeader.parseHeader(this.buffer); + const header = parseHeader(this.buffer); if (!header) break; this.currentHeader = header; this.remainingBytes = header.size; - this.buffer = this.buffer.slice(512); + this.buffer = this.buffer.subarray(512); if (header.size > 0) this.currentFileStream = await this.processHeader(header); @@ -229,30 +143,22 @@ class TarTransformer extends Transform { continue; } - const dataChunk = this.buffer.slice(0, blockSize); - this.buffer = this.buffer.slice(blockSize); + const dataChunk = this.buffer.subarray(0, blockSize); + this.buffer = this.buffer.subarray(blockSize); this.remainingBytes -= blockSize; - if (this.currentFileStream) { - await new Promise((resolve, reject) => { - this.currentFileStream.write(dataChunk, err => { - if (err) - reject(err); - else - resolve(); - }); - }); - } + if (this.currentFileStream) + await new Promise((resolve, reject) => this.currentFileStream!.write(dataChunk, err => err ? reject(err) : resolve())); // Handle padding if (this.remainingBytes === 0) { const padding = 512 - (this.currentHeader.size % 512); if (padding < 512) - this.buffer = this.buffer.slice(padding); + this.buffer = this.buffer.subarray(padding); this.currentHeader = null; if (this.currentFileStream) { - await new Promise(resolve => this.currentFileStream.end(resolve)); + await new Promise(resolve => this.currentFileStream!.end(resolve)); this.currentFileStream = null; } } @@ -262,38 +168,4 @@ class TarTransformer extends Transform { callback(err); } } - - async _flush(callback) { - try { - if (this.currentFileStream) - await new Promise(resolve => this.currentFileStream.end(resolve)); - - await this.createSymlinks(); - await this.fixMacOSApp(); - callback(); - } catch (err) { - callback(err); - } - } } - -const inputPath = '/Users/maxschmitt/Downloads/chromium-mac.tar.br'; -const outputPath = '/Users/maxschmitt/Downloads'; - -// Clean up previous extraction -fs.rmSync(path.join(outputPath, 'chrome-mac'), { recursive: true, force: true }); - -const input = fs.createReadStream(inputPath); -const brotli = zlib.createBrotliDecompress(); -const tarTransformer = new TarTransformer(outputPath); - -// Add error handlers -input.on('error', err => console.error('Input stream error:', err)); -brotli.on('error', err => console.error('Brotli decompression error:', err)); -tarTransformer.on('error', err => console.error('Tar extraction error:', err)); - -// Pipe everything together -input - .pipe(brotli) - .pipe(tarTransformer) - .on('finish', () => console.log('Extraction complete!')); \ No newline at end of file diff --git a/tar-example/symlink b/tar-example/symlink index d2c2f1ba69..a9c3f30e00 120000 --- a/tar-example/symlink +++ b/tar-example/symlink @@ -1 +1 @@ -/Users/skn0tt/dev/microsoft/playwright/tar-example/foo \ No newline at end of file +./foo \ No newline at end of file diff --git a/tests/assets/archive.tar b/tests/assets/archive.tar index e919955438..ee95a26b9c 100644 Binary files a/tests/assets/archive.tar and b/tests/assets/archive.tar differ diff --git a/tests/library/unit/tar.spec.ts b/tests/library/unit/tar.spec.ts new file mode 100644 index 0000000000..e6f31d5b6b --- /dev/null +++ b/tests/library/unit/tar.spec.ts @@ -0,0 +1,35 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed 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. + */ + + +import { expect, test } from '@playwright/test'; +import { createReadStream, constants } from 'fs'; +import { access, readFile, readlink } from 'fs/promises'; +import { TarExtractor } from '../../../packages/playwright-core/src/utils/tar'; +import { finished } from 'stream/promises'; + +test('tar extractor', async () => { + const input = createReadStream('tests/assets/archive.tar'); + const extractor = new TarExtractor(name => test.info().outputPath(name)); + await finished(input.pipe(extractor)); + + expect(await readFile(test.info().outputPath('tar-example/foo'), { encoding: 'utf-8' })).toEqual('foo-content'); + expect(await readFile(test.info().outputPath('tar-example/bar/baz'), { encoding: 'utf-8' })).toEqual('baz-content'); + expect(await readFile(test.info().outputPath('tar-example/build.sh'), { encoding: 'utf-8' })).toEqual('echo "hello world"'); + await access(test.info().outputPath('tar-example/build.sh'), constants.X_OK); + expect(await readlink(test.info().outputPath('tar-example/symlink'))).toEqual('./foo'); + expect(await readFile(test.info().outputPath('tar-example/symlink'), { encoding: 'utf-8' })).toEqual('foo-content'); +});