Files
Claude-Code-Monitor/server/lib/archive.js
T
nntrivi2001 57dc91585d feat: Claude Code Monitor — lanes, pipelines and a merged workspace
Internal SmartGift build of a Claude Code monitoring dashboard.

Lanes: a durable unit of parallel agent work, one per working directory,
tracked across session restarts. Managed lanes are git worktrees the
dashboard provisions and can reset or remove behind a three-check destroy
guard and a counted preflight; adopted lanes are directories you already
own and are never destroyable.

Pipelines: a lane moves through pipeline stages. A stage the agent declares
with evidence renders green; a stage inferred from the tool-event stream
renders dashed amber and never counts as done. Detection is forward-only
within a 30-minute window, and never writes the declared stage.

Workspace: one page at /run with a lane grid, the selected lane's pipeline,
and a full Claude console behind a disclosure.
2026-07-30 14:39:03 +07:00

271 lines
8.0 KiB
JavaScript

/**
* @file Safe archive extraction helpers for the history-import feature.
*
* Supports `.zip`, `.tar`, `.tar.gz`, `.tgz`, and plain `.gz` (single-file).
* Every entry is validated against path traversal (no absolute paths, no
* `..` segments) and resolved relative to the target directory. Non-regular
* entries (symlinks, devices, hardlinks) are skipped rather than extracted.
*
* All functions are async and never throw on unknown formats — they return
* `{ extracted: number, skipped: number }` so routes can surface counts.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("fs");
const path = require("path");
const os = require("os");
const zlib = require("zlib");
const { pipeline } = require("stream/promises");
const crypto = require("crypto");
/**
* Maximum total bytes any single archive is allowed to expand to during
* extraction. Tunable via env so deployments with huge legitimate archives
* can raise it; the default (4 GB) is generous for real-world transcript
* bundles but low enough to stop most zip-bomb attacks from filling disk.
*/
const MAX_EXTRACT_BYTES = parseInt(
process.env.CCAM_IMPORT_MAX_EXTRACT_BYTES || String(4 * 1024 * 1024 * 1024),
10
);
class ExtractionLimitError extends Error {
constructor(limit) {
super(`Archive exceeded the ${limit}-byte extraction limit (possible zip bomb).`);
this.code = "EXTRACTION_LIMIT_EXCEEDED";
}
}
/**
* True if `child` is contained within `parent` after normalization.
* Used to reject archive entries that would escape the extraction root.
*/
function isPathInside(parent, child) {
const p = path.resolve(parent) + path.sep;
const c = path.resolve(child);
return c === path.resolve(parent) || c.startsWith(p);
}
/**
* Normalize an archive entry name: strip leading slashes, collapse `..`,
* reject if it escapes the root.
*/
function safeJoin(root, entryName) {
const cleaned = String(entryName).replace(/^[/\\]+/, "");
if (!cleaned || cleaned === "." || cleaned === "..") return null;
const joined = path.join(root, cleaned);
if (!isPathInside(root, joined)) return null;
return joined;
}
/**
* Create a unique temp directory for extraction under the OS tmpdir.
* Caller is responsible for cleanup via `rmTempDir`.
*/
function mkTempDir(prefix = "ccam-import-") {
const dir = path.join(os.tmpdir(), prefix + crypto.randomBytes(6).toString("hex"));
fs.mkdirSync(dir, { recursive: true });
return dir;
}
function rmTempDir(dir) {
if (!dir) return;
try {
fs.rmSync(dir, { recursive: true, force: true });
} catch {
/* non-fatal */
}
}
/**
* Extract a `.zip` archive into `destDir` using adm-zip.
* Lazily required so the dependency is optional at install time for users
* who don't need archive upload.
*/
async function extractZip(zipPath, destDir) {
let AdmZip;
try {
AdmZip = require("adm-zip");
} catch (err) {
throw new Error(
"adm-zip is required to extract .zip archives. Run `npm install` to pick up new deps."
);
}
const zip = new AdmZip(zipPath);
const entries = zip.getEntries();
// Pre-check declared uncompressed sizes so we reject obvious zip bombs
// before materializing any bytes to disk.
let declared = 0;
for (const entry of entries) {
if (!entry.isDirectory) declared += entry.header?.size || 0;
}
if (declared > MAX_EXTRACT_BYTES) throw new ExtractionLimitError(MAX_EXTRACT_BYTES);
let extracted = 0;
let skipped = 0;
let writtenBytes = 0;
for (const entry of entries) {
if (entry.isDirectory) continue;
const target = safeJoin(destDir, entry.entryName);
if (!target) {
skipped++;
continue;
}
const data = entry.getData();
writtenBytes += data.length;
if (writtenBytes > MAX_EXTRACT_BYTES) throw new ExtractionLimitError(MAX_EXTRACT_BYTES);
fs.mkdirSync(path.dirname(target), { recursive: true });
try {
fs.writeFileSync(target, data);
extracted++;
} catch {
skipped++;
}
}
return { extracted, skipped };
}
/**
* Extract a `.tar`, `.tar.gz`, or `.tgz` archive into `destDir`.
* Uses the `tar` package in streaming mode with `onentry` filter so we can
* enforce path containment ourselves rather than relying on the lib's flags.
*/
async function extractTar(tarPath, destDir) {
let tar;
try {
tar = require("tar");
} catch {
throw new Error(
"tar is required to extract .tar/.tar.gz archives. Run `npm install` to pick up new deps."
);
}
let extracted = 0;
let skipped = 0;
let writtenBytes = 0;
await tar.x({
file: tarPath,
cwd: destDir,
strict: false,
preservePaths: false,
filter: (entryPath, entry) => {
if (entry.type && entry.type !== "File" && entry.type !== "Directory") {
skipped++;
return false;
}
const target = safeJoin(destDir, entryPath);
if (!target) {
skipped++;
return false;
}
if (entry.type === "File") {
writtenBytes += entry.size || 0;
if (writtenBytes > MAX_EXTRACT_BYTES) {
// Surfacing the limit as a throw aborts tar.x; callers will see
// ExtractionLimitError in the catch path.
throw new ExtractionLimitError(MAX_EXTRACT_BYTES);
}
extracted++;
}
return true;
},
});
return { extracted, skipped };
}
/**
* Decompress a plain `.gz` file (not a tar archive) into `destDir`, reusing
* the original filename with `.gz` stripped. Useful when a single JSONL was
* gzipped for transfer.
*/
async function extractGzSingle(gzPath, destDir) {
const base = path.basename(gzPath).replace(/\.gz$/i, "") || "decompressed.jsonl";
const target = safeJoin(destDir, base);
if (!target) return { extracted: 0, skipped: 1 };
fs.mkdirSync(path.dirname(target), { recursive: true });
// Count decompressed bytes as they flow through gunzip; abort if we blow
// past the extraction limit (defends against single-file gzip bombs).
let written = 0;
const { Transform } = require("stream");
const limiter = new Transform({
transform(chunk, _enc, cb) {
written += chunk.length;
if (written > MAX_EXTRACT_BYTES) {
cb(new ExtractionLimitError(MAX_EXTRACT_BYTES));
return;
}
cb(null, chunk);
},
});
await pipeline(
fs.createReadStream(gzPath),
zlib.createGunzip(),
limiter,
fs.createWriteStream(target)
);
return { extracted: 1, skipped: 0 };
}
/**
* Detect the archive kind from the filename. Returns one of:
* "zip" | "tar" | "tgz" | "gz" | "jsonl" | "meta" | "unknown"
*/
function detectKind(filename) {
const lower = filename.toLowerCase();
if (lower.endsWith(".zip")) return "zip";
if (lower.endsWith(".tar.gz") || lower.endsWith(".tgz")) return "tgz";
if (lower.endsWith(".tar")) return "tar";
if (lower.endsWith(".meta.json")) return "meta";
if (lower.endsWith(".jsonl")) return "jsonl";
if (lower.endsWith(".gz")) return "gz";
return "unknown";
}
/**
* Dispatch to the right extractor based on filename. For plain `.jsonl` and
* `.meta.json` files we copy them through into `destDir`. Unknown files are
* skipped so users can drop mixed content without failures.
*/
async function extractInto(srcPath, destDir, originalName) {
const name = originalName || path.basename(srcPath);
const kind = detectKind(name);
switch (kind) {
case "zip":
return extractZip(srcPath, destDir);
case "tar":
case "tgz":
return extractTar(srcPath, destDir);
case "gz":
return extractGzSingle(srcPath, destDir);
case "jsonl":
case "meta": {
const target = safeJoin(destDir, name);
if (!target) return { extracted: 0, skipped: 1 };
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.copyFileSync(srcPath, target);
return { extracted: 1, skipped: 0 };
}
default:
return { extracted: 0, skipped: 1 };
}
}
module.exports = {
mkTempDir,
rmTempDir,
extractInto,
extractZip,
extractTar,
extractGzSingle,
detectKind,
safeJoin,
isPathInside,
ExtractionLimitError,
MAX_EXTRACT_BYTES,
};