update and test
This commit is contained in:
parent
33cdb9c8e7
commit
a526cb9c81
|
|
@ -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<ReturnType<typeof parseHeader>>;
|
||||
|
||||
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<void>((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!'));
|
||||
|
|
@ -1 +1 @@
|
|||
/Users/skn0tt/dev/microsoft/playwright/tar-example/foo
|
||||
./foo
|
||||
Binary file not shown.
35
tests/library/unit/tar.spec.ts
Normal file
35
tests/library/unit/tar.spec.ts
Normal file
|
|
@ -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');
|
||||
});
|
||||
Loading…
Reference in a new issue