src / tools / archiveTools.ts
src / tools / archiveTools.ts
/**
* Archive Tools: Create and extract archives (ZIP/TAR) with validation.
*/
import archiver from 'archiver';
import fs, { createWriteStream } from 'fs';
import fsPromises from 'fs/promises';
import path from 'path';
import unzipper from 'unzipper';
import { execFile } from 'child_process';
import util from 'util';
import { z } from 'zod';
import { validatePath as validateWorkspacePath } from '../pathSecurity';
const execFilePromise = util.promisify(execFile);
// --- Helpers ---
async function sanitizePath(requestedPath: string, baseDir?: string): Promise<string> {
const resolvedBase = path.resolve(baseDir || process.cwd());
return validateWorkspacePath(resolvedBase, resolvedBase, requestedPath);
}
function validateArchiveEntry(entryPath: string, destination: string): void {
if (path.isAbsolute(entryPath) || /^[A-Za-z]:[\\/]/.test(entryPath)) {
throw new Error(`Archive entry uses an absolute path: ${entryPath}`);
}
validateWorkspacePath(destination, destination, entryPath);
}
// --- Schemas ---
const ZipCreateSchema = z.object({
files: z.array(z.string()).min(1),
output_path: z.string(),
compression_level: z.number().optional().default(9)
});
const TarCreateSchemaSpecific = z.object({
files: z.array(z.string()),
output_path: z.string(),
format: z.enum(['tar', 'tgz']).optional().default('tar')
});
// --- Functions ---
export async function zip_create(input: any, workspaceRoot: string = process.cwd()) {
const args = ZipCreateSchema.parse(input);
// Validate source files exist and sanitize paths
const resolvedFiles = await Promise.all(
args.files.map(f => sanitizePath(f, workspaceRoot))
);
const outputPath = await sanitizePath(args.output_path, workspaceRoot);
return new Promise<{ archive_path: string, total_size: number }>((resolve, reject) => {
const output = createWriteStream(outputPath);
const archive = (archiver as any)('zip', { zlib: { level: args.compression_level as number } });
let totalSize = 0;
archive.on('entry', (entry: any) => {
totalSize += entry.stats.size || 0;
});
output.on('close', () => {
resolve({
archive_path: outputPath,
total_size: totalSize + archive.pointer() // Include headers/footers roughly
});
});
archive.on('error', (err: any) => reject(err));
output.on('error', (err: any) => reject(err));
archive.pipe(output);
resolvedFiles.forEach(file => {
if (fs.statSync(file).isDirectory()) {
archive.directory(file, path.basename(file));
} else {
archive.file(file, { name: path.basename(file) });
}
});
archive.finalize();
});
}
const ZipExtractSchema = z.object({
archive_path: z.string(),
dest_dir: z.string()
});
export async function zip_extract(input: any, workspaceRoot: string = process.cwd()) {
const args = ZipExtractSchema.parse(input);
const archivePath = await sanitizePath(args.archive_path, workspaceRoot);
const destDir = await sanitizePath(args.dest_dir, workspaceRoot);
// Ensure destination exists
await fsPromises.mkdir(destDir, { recursive: true });
try {
const dir = (await (unzipper as any).Open.file(archivePath));
for (const entry of dir.files) {
validateArchiveEntry(entry.path, destDir);
}
// Extract all files
await dir.extract({ path: destDir });
// List extracted files for return value
const entries = await fsPromises.readdir(destDir, { recursive: true });
const extractedFiles = (entries as any[]).map(f => path.join(args.dest_dir, f));
return { extracted_files: extractedFiles };
} catch (error) {
throw new Error(`Failed to extract zip: ${(error as Error).message}`);
}
}
export async function tar_create(input: any, workspaceRoot: string = process.cwd()) {
const args = TarCreateSchemaSpecific.parse(input);
const resolvedFiles = await Promise.all(
args.files.map(f => sanitizePath(f, workspaceRoot))
);
const outputPath = await sanitizePath(args.output_path, workspaceRoot);
return new Promise<{ archive_path: string, format_used: string }>((resolve, reject) => {
const output = createWriteStream(outputPath);
const archiveType = args.format === 'tgz' ? 'gzip' : 'tar';
const archive = (archiver as any)(archiveType, { gzip: args.format === 'tgz' });
output.on('close', () => {
resolve({
archive_path: outputPath,
format_used: args.format
});
});
archive.on('error', (err: any) => reject(err));
output.on('error', (err: any) => reject(err));
archive.pipe(output);
resolvedFiles.forEach(file => {
if (fs.statSync(file).isDirectory()) {
archive.directory(file, path.basename(file));
} else {
archive.file(file, { name: path.basename(file) });
}
});
archive.finalize();
});
}
const TarExtractSchema = z.object({
archive_path: z.string(),
dest_dir: z.string()
});
export async function tar_extract(input: any, workspaceRoot: string = process.cwd()) {
const args = TarExtractSchema.parse(input);
const archivePath = await sanitizePath(args.archive_path, workspaceRoot);
const destDir = await sanitizePath(args.dest_dir, workspaceRoot);
// Use system 'tar' command for extraction as no pure JS tar-extractor is listed in deps
try {
await fsPromises.mkdir(destDir, { recursive: true });
// Determine command based on extension or format if known. Default to tar.
const ext = path.extname(args.archive_path).toLowerCase();
let cmd = 'tar';
let flags = ['-xf'];
if (ext === '.tgz' || ext === '.gz') {
flags.push('-z');
}
const listFlags = ['-tf'];
if (ext === '.tgz') listFlags.push('-z');
const { stdout: listing } = await execFilePromise(cmd, [...listFlags, archivePath]);
for (const entry of listing.split(/\r?\n/).filter(Boolean)) {
validateArchiveEntry(entry, destDir);
}
await execFilePromise(cmd, [...flags, archivePath], { cwd: destDir });
const extractedFiles = await fsPromises.readdir(destDir, { recursive: true });
return {
extracted_files: (extractedFiles as any[]).map(f => path.join(args.dest_dir, f))
};
} catch (error) {
throw new Error(`Failed to extract tar: ${(error as Error).message}`);
}
}
const ListContentsSchema = z.object({
path: z.string()
});
export async function archive_list_contents(input: any, workspaceRoot: string = process.cwd()) {
const args = ListContentsSchema.parse(input);
const resolvedPath = await sanitizePath(args.path, workspaceRoot);
const stats = fs.statSync(resolvedPath);
if (!stats.isFile()) {
throw new Error(`Path ${resolvedPath} is not a file.`);
}
const ext = path.extname(resolvedPath).toLowerCase();
let files: { name: string, size: number, compressed_size?: number }[] = [];
try {
if (ext === '.zip') {
const dir = (await (unzipper as any).Open.file(resolvedPath));
files = dir.entries.map((entry: any) => ({
name: entry.path,
size: entry.uncompressedSize || 0,
compressed_size: entry.compressedSize
}));
} else if (ext === '.tar' || ext === '.tgz') {
// Use system command to list contents
const cmd = 'tar';
const flags = ['-tf'];
if (ext === '.tgz') flags.push('-z');
const { stdout } = await execFilePromise(cmd, [...flags, resolvedPath]);
files = stdout.split('\n').filter(Boolean).map(name => ({
name,
size: 0, // tar tf doesn't always give sizes in list mode easily without -v
compressed_size: ext === '.tgz' ? 0 : undefined
}));
} else {
throw new Error(`Unsupported archive format: ${ext}`);
}
} catch (error) {
throw new Error(`Failed to list contents: ${(error as Error).message}`);
}
return { files };
}/**
* Archive Tools: Create and extract archives (ZIP/TAR) with validation.
*/
import archiver from 'archiver';
import fs, { createWriteStream } from 'fs';
import fsPromises from 'fs/promises';
import path from 'path';
import unzipper from 'unzipper';
import { execFile } from 'child_process';
import util from 'util';
import { z } from 'zod';
import { validatePath as validateWorkspacePath } from '../pathSecurity';
const execFilePromise = util.promisify(execFile);
// --- Helpers ---
async function sanitizePath(requestedPath: string, baseDir?: string): Promise<string> {
const resolvedBase = path.resolve(baseDir || process.cwd());
return validateWorkspacePath(resolvedBase, resolvedBase, requestedPath);
}
function validateArchiveEntry(entryPath: string, destination: string): void {
if (path.isAbsolute(entryPath) || /^[A-Za-z]:[\\/]/.test(entryPath)) {
throw new Error(`Archive entry uses an absolute path: ${entryPath}`);
}
validateWorkspacePath(destination, destination, entryPath);
}
// --- Schemas ---
const ZipCreateSchema = z.object({
files: z.array(z.string()).min(1),
output_path: z.string(),
compression_level: z.number().optional().default(9)
});
const TarCreateSchemaSpecific = z.object({
files: z.array(z.string()),
output_path: z.string(),
format: z.enum(['tar', 'tgz']).optional().default('tar')
});
// --- Functions ---
export async function zip_create(input: any, workspaceRoot: string = process.cwd()) {
const args = ZipCreateSchema.parse(input);
// Validate source files exist and sanitize paths
const resolvedFiles = await Promise.all(
args.files.map(f => sanitizePath(f, workspaceRoot))
);
const outputPath = await sanitizePath(args.output_path, workspaceRoot);
return new Promise<{ archive_path: string, total_size: number }>((resolve, reject) => {
const output = createWriteStream(outputPath);
const archive = (archiver as any)('zip', { zlib: { level: args.compression_level as number } });
let totalSize = 0;
archive.on('entry', (entry: any) => {
totalSize += entry.stats.size || 0;
});
output.on('close', () => {
resolve({
archive_path: outputPath,
total_size: totalSize + archive.pointer() // Include headers/footers roughly
});
});
archive.on('error', (err: any) => reject(err));
output.on('error', (err: any) => reject(err));
archive.pipe(output);
resolvedFiles.forEach(file => {
if (fs.statSync(file).isDirectory()) {
archive.directory(file, path.basename(file));
} else {
archive.file(file, { name: path.basename(file) });
}
});
archive.finalize();
});
}
const ZipExtractSchema = z.object({
archive_path: z.string(),
dest_dir: z.string()
});
export async function zip_extract(input: any, workspaceRoot: string = process.cwd()) {
const args = ZipExtractSchema.parse(input);
const archivePath = await sanitizePath(args.archive_path, workspaceRoot);
const destDir = await sanitizePath(args.dest_dir, workspaceRoot);
// Ensure destination exists
await fsPromises.mkdir(destDir, { recursive: true });
try {
const dir = (await (unzipper as any).Open.file(archivePath));
for (const entry of dir.files) {
validateArchiveEntry(entry.path, destDir);
}
// Extract all files
await dir.extract({ path: destDir });
// List extracted files for return value
const entries = await fsPromises.readdir(destDir, { recursive: true });
const extractedFiles = (entries as any[]).map(f => path.join(args.dest_dir, f));
return { extracted_files: extractedFiles };
} catch (error) {
throw new Error(`Failed to extract zip: ${(error as Error).message}`);
}
}
export async function tar_create(input: any, workspaceRoot: string = process.cwd()) {
const args = TarCreateSchemaSpecific.parse(input);
const resolvedFiles = await Promise.all(
args.files.map(f => sanitizePath(f, workspaceRoot))
);
const outputPath = await sanitizePath(args.output_path, workspaceRoot);
return new Promise<{ archive_path: string, format_used: string }>((resolve, reject) => {
const output = createWriteStream(outputPath);
const archiveType = args.format === 'tgz' ? 'gzip' : 'tar';
const archive = (archiver as any)(archiveType, { gzip: args.format === 'tgz' });
output.on('close', () => {
resolve({
archive_path: outputPath,
format_used: args.format
});
});
archive.on('error', (err: any) => reject(err));
output.on('error', (err: any) => reject(err));
archive.pipe(output);
resolvedFiles.forEach(file => {
if (fs.statSync(file).isDirectory()) {
archive.directory(file, path.basename(file));
} else {
archive.file(file, { name: path.basename(file) });
}
});
archive.finalize();
});
}
const TarExtractSchema = z.object({
archive_path: z.string(),
dest_dir: z.string()
});
export async function tar_extract(input: any, workspaceRoot: string = process.cwd()) {
const args = TarExtractSchema.parse(input);
const archivePath = await sanitizePath(args.archive_path, workspaceRoot);
const destDir = await sanitizePath(args.dest_dir, workspaceRoot);
// Use system 'tar' command for extraction as no pure JS tar-extractor is listed in deps
try {
await fsPromises.mkdir(destDir, { recursive: true });
// Determine command based on extension or format if known. Default to tar.
const ext = path.extname(args.archive_path).toLowerCase();
let cmd = 'tar';
let flags = ['-xf'];
if (ext === '.tgz' || ext === '.gz') {
flags.push('-z');
}
const listFlags = ['-tf'];
if (ext === '.tgz') listFlags.push('-z');
const { stdout: listing } = await execFilePromise(cmd, [...listFlags, archivePath]);
for (const entry of listing.split(/\r?\n/).filter(Boolean)) {
validateArchiveEntry(entry, destDir);
}
await execFilePromise(cmd, [...flags, archivePath], { cwd: destDir });
const extractedFiles = await fsPromises.readdir(destDir, { recursive: true });
return {
extracted_files: (extractedFiles as any[]).map(f => path.join(args.dest_dir, f))
};
} catch (error) {
throw new Error(`Failed to extract tar: ${(error as Error).message}`);
}
}
const ListContentsSchema = z.object({
path: z.string()
});
export async function archive_list_contents(input: any, workspaceRoot: string = process.cwd()) {
const args = ListContentsSchema.parse(input);
const resolvedPath = await sanitizePath(args.path, workspaceRoot);
const stats = fs.statSync(resolvedPath);
if (!stats.isFile()) {
throw new Error(`Path ${resolvedPath} is not a file.`);
}
const ext = path.extname(resolvedPath).toLowerCase();
let files: { name: string, size: number, compressed_size?: number }[] = [];
try {
if (ext === '.zip') {
const dir = (await (unzipper as any).Open.file(resolvedPath));
files = dir.entries.map((entry: any) => ({
name: entry.path,
size: entry.uncompressedSize || 0,
compressed_size: entry.compressedSize
}));
} else if (ext === '.tar' || ext === '.tgz') {
// Use system command to list contents
const cmd = 'tar';
const flags = ['-tf'];
if (ext === '.tgz') flags.push('-z');
const { stdout } = await execFilePromise(cmd, [...flags, resolvedPath]);
files = stdout.split('\n').filter(Boolean).map(name => ({
name,
size: 0, // tar tf doesn't always give sizes in list mode easily without -v
compressed_size: ext === '.tgz' ? 0 : undefined
}));
} else {
throw new Error(`Unsupported archive format: ${ext}`);
}
} catch (error) {
throw new Error(`Failed to list contents: ${(error as Error).message}`);
}
return { files };
}