import { createHash } from "node:crypto"; import type { StateArchiveEntry, StateFileDetection, StateFindEntry, StateFindOptions, StateHashOptions, StateJsonUpdateOperation, StateJsonUpdateResult, StateTreeNode, StateTreeOptions, StateTreeSummary } from "./backend"; import { decodeText, diffContent, encodeText } from "./helpers"; type PathToken = string | number; type TreeOps = { lstat(path: string): Promise<{ type: "file" | "directory" | "symlink"; size: number; mtime: Date; } | null>; readdirWithFileTypes(path: string): Promise< Array<{ name: string; type: "file" | "directory" | "symlink"; }> >; resolvePath(base: string, path: string): Promise; }; export function queryJsonValue(value: unknown, query: string): unknown { const tokens = parseJsonPath(query); let current = value; for (const token of tokens) { if (typeof token === "number") { if (!Array.isArray(current)) { throw new Error(`JSON query expected array access at [${token}]`); } current = current[token]; } else { if (current === null || typeof current !== "object") { throw new Error(`JSON query expected object access at "${token}"`); } current = (current as Record)[token]; } } return current; } export function updateJsonValue( input: unknown, operations: StateJsonUpdateOperation[], filePath: string ): StateJsonUpdateResult { const clone = structuredClone(input) as unknown; for (const operation of operations) { const tokens = parseJsonPath(operation.path); if (operation.op === "set") { setJsonPathValue(clone, tokens, operation.value); } else { deleteJsonPathValue(clone, tokens); } } const content = JSON.stringify(clone, null, 2) + "\n"; return { value: clone, content, diff: JSON.stringify(input) === JSON.stringify(clone) ? "" : diffContent( JSON.stringify(input, null, 2) + "\n", content, filePath, filePath ), operationsApplied: operations.length }; } export async function buildTree( root: string, ops: TreeOps, options: StateTreeOptions = {} ): Promise { const seenDepth = options.maxDepth ?? Number.POSITIVE_INFINITY; return buildTreeNode(root, 0, seenDepth, ops); } export async function summarizeTree( root: string, ops: TreeOps, options: StateTreeOptions = {} ): Promise { const tree = await buildTree(root, ops, options); const summary: StateTreeSummary = { files: 0, directories: 0, symlinks: 0, totalBytes: 0, maxDepth: 0 }; summarizeNode(tree, 0, summary); return summary; } export async function findInTree( root: string, ops: TreeOps, options: StateFindOptions = {} ): Promise { const results: StateFindEntry[] = []; const matcher = options.pathPattern !== undefined ? globToRegex(options.pathPattern) : null; const nameMatcher = options.name !== undefined ? globToRegex(options.name) : null; const types = Array.isArray(options.type) ? new Set(options.type) : options.type ? new Set([options.type]) : null; await visitFind( root, root, 0, ops, options, matcher, nameMatcher, types, results ); return results; } export function detectFile( path: string, bytes: Uint8Array ): StateFileDetection { const extension = getExtension(path); const text = isLikelyText(bytes); const mime = MIME_BY_EXTENSION[extension ?? ""] ?? (text ? "text/plain" : "application/octet-stream"); return { mime, extension: extension ?? undefined, binary: !text, description: `${mime}${extension ? ` (${extension})` : ""}` }; } export function hashBytes( bytes: Uint8Array, options: StateHashOptions = {} ): string { const algorithm = options.algorithm ?? "sha256"; return createHash(algorithm).update(bytes).digest("hex"); } export async function gzipBytes(bytes: Uint8Array): Promise { return transformBytes(bytes, new CompressionStream("gzip")); } export async function gunzipBytes(bytes: Uint8Array): Promise { return transformBytes(bytes, new DecompressionStream("gzip")); } export function buildTar(entries: Array): Uint8Array { const chunks: Uint8Array[] = []; for (const entry of entries) { const header = createTarHeader(entry); chunks.push(header); if (entry.type === "file") { chunks.push(entry.bytes); const remainder = entry.bytes.byteLength % 512; if (remainder !== 0) { chunks.push(new Uint8Array(512 - remainder)); } } } chunks.push(new Uint8Array(1024)); return concatBytes(chunks); } export function listTar(bytes: Uint8Array): StateArchiveEntry[] { return parseTar(bytes) .map((entry) => ({ path: entry.path, type: entry.type, size: entry.size })) .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)); } export function extractTar( bytes: Uint8Array ): Array<{ path: string; type: "file" | "directory"; bytes?: Uint8Array }> { return parseTar(bytes).map((entry) => entry.type === "file" ? { path: entry.path, type: entry.type, bytes: entry.bytes } : { path: entry.path, type: entry.type } ); } export type TarInputEntry = | { path: string; type: "directory"; } | { path: string; type: "file"; bytes: Uint8Array; }; function parseJsonPath(query: string): PathToken[] { const trimmed = query.trim(); if (!trimmed || trimmed === ".") { return []; } let cursor = trimmed.startsWith(".") ? trimmed.slice(1) : trimmed; const tokens: PathToken[] = []; while (cursor.length > 0) { if (cursor[0] === "[") { const close = cursor.indexOf("]"); if (close === -1) { throw new Error(`Invalid JSON path: ${query}`); } tokens.push(Number(cursor.slice(1, close))); cursor = cursor.slice(close + 1); if (cursor.startsWith(".")) cursor = cursor.slice(1); continue; } const dot = cursor.search(/[.[\]]/); if (dot === -1) { tokens.push(cursor); break; } tokens.push(cursor.slice(0, dot)); cursor = cursor.slice(dot); if (cursor.startsWith(".")) cursor = cursor.slice(1); } return tokens.filter((token) => token !== ""); } function setJsonPathValue( root: unknown, tokens: PathToken[], value: unknown ): void { if (tokens.length === 0) { throw new Error("JSON update path must not be empty"); } let current = root as Record | unknown[]; for (let index = 0; index < tokens.length - 1; index++) { const token = tokens[index]; const nextToken = tokens[index + 1]; if (typeof token === "number") { if (!Array.isArray(current)) { throw new Error(`JSON update expected array at [${token}]`); } if (current[token] === undefined) { current[token] = typeof nextToken === "number" ? [] : {}; } current = current[token] as Record | unknown[]; } else { if ( current === null || typeof current !== "object" || Array.isArray(current) ) { throw new Error(`JSON update expected object at "${token}"`); } if ((current as Record)[token] === undefined) { (current as Record)[token] = typeof nextToken === "number" ? [] : {}; } current = (current as Record)[token] as | Record | unknown[]; } } const finalToken = tokens[tokens.length - 1]; if (typeof finalToken === "number") { if (!Array.isArray(current)) { throw new Error(`JSON update expected array at [${finalToken}]`); } current[finalToken] = value; } else { if (current === null || typeof current !== "object") { throw new Error(`JSON update expected object at "${finalToken}"`); } (current as Record)[finalToken] = value; } } function deleteJsonPathValue(root: unknown, tokens: PathToken[]): void { if (tokens.length === 0) { throw new Error("JSON delete path must not be empty"); } let current = root as Record | unknown[]; for (let index = 0; index < tokens.length - 1; index++) { const token = tokens[index]; const next = typeof token === "number" ? (current as unknown[])[token] : (current as Record)[token]; current = next as Record | unknown[]; if (current === undefined) { return; } } const finalToken = tokens[tokens.length - 1]; if (typeof finalToken === "number") { if (Array.isArray(current) && finalToken < current.length) { current.splice(finalToken, 1); } } else if (current && typeof current === "object") { delete (current as Record)[finalToken]; } } async function buildTreeNode( path: string, depth: number, maxDepth: number, ops: TreeOps ): Promise { const stat = await ops.lstat(path); if (!stat) { throw new Error(`ENOENT: no such file or directory: ${path}`); } const node: StateTreeNode = { path, name: path === "/" ? "/" : path.slice(path.lastIndexOf("/") + 1), type: stat.type, size: stat.size }; if (stat.type === "directory" && depth < maxDepth) { const entries = await ops.readdirWithFileTypes(path); node.children = []; for (const entry of entries) { const childPath = await ops.resolvePath(path, entry.name); node.children.push( await buildTreeNode(childPath, depth + 1, maxDepth, ops) ); } } return node; } function summarizeNode( node: StateTreeNode, depth: number, summary: StateTreeSummary ): void { summary.maxDepth = Math.max(summary.maxDepth, depth); if (node.type === "file") { summary.files++; summary.totalBytes += node.size; } else if (node.type === "directory") { summary.directories++; } else { summary.symlinks++; } for (const child of node.children ?? []) { summarizeNode(child, depth + 1, summary); } } async function visitFind( path: string, root: string, depth: number, ops: TreeOps, options: StateFindOptions, pathMatcher: RegExp | null, nameMatcher: RegExp | null, types: Set | null, results: StateFindEntry[] ): Promise { const stat = await ops.lstat(path); if (!stat) return; const name = path === "/" ? "/" : path.slice(path.lastIndexOf("/") + 1); if ( matchesFind( path, name, depth, stat, options, pathMatcher, nameMatcher, types ) ) { results.push({ path, name, type: stat.type, depth, size: stat.size, mtime: stat.mtime }); } if (stat.type === "directory") { const maxDepth = options.maxDepth ?? Number.POSITIVE_INFINITY; if (depth >= maxDepth) return; const entries = await ops.readdirWithFileTypes(path); for (const entry of entries) { const child = await ops.resolvePath(path, entry.name); await visitFind( child, root, depth + 1, ops, options, pathMatcher, nameMatcher, types, results ); } } } function matchesFind( path: string, name: string, depth: number, stat: { type: string; size: number; mtime: Date }, options: StateFindOptions, pathMatcher: RegExp | null, nameMatcher: RegExp | null, types: Set | null ): boolean { if (depth < (options.minDepth ?? 0)) return false; if (types && !types.has(stat.type)) return false; if (pathMatcher && !pathMatcher.test(path)) return false; if (nameMatcher && !nameMatcher.test(name)) return false; if (options.sizeMin !== undefined && stat.size < options.sizeMin) return false; if (options.sizeMax !== undefined && stat.size > options.sizeMax) return false; if (options.mtimeAfter && stat.mtime <= new Date(options.mtimeAfter)) return false; if (options.mtimeBefore && stat.mtime >= new Date(options.mtimeBefore)) return false; if (options.empty === true && !(stat.type === "directory" || stat.size === 0)) return false; return true; } async function transformBytes( bytes: Uint8Array, stream: CompressionStream | DecompressionStream ): Promise { const input = new Blob([new Uint8Array(bytes)]).stream(); const transformed = input.pipeThrough(stream); const chunks: Uint8Array[] = []; const reader = transformed.getReader(); for (;;) { const { done, value } = await reader.read(); if (done) break; chunks.push(value); } return concatBytes(chunks); } function concatBytes(chunks: Uint8Array[]): Uint8Array { const total = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0); const out = new Uint8Array(total); let offset = 0; for (const chunk of chunks) { out.set(chunk, offset); offset += chunk.byteLength; } return out; } function createTarHeader(entry: TarInputEntry): Uint8Array { const header = new Uint8Array(512); writeAscii(header, 0, 100, entry.path); writeOctal(header, 100, 8, entry.type === "directory" ? 0o755 : 0o644); writeOctal(header, 108, 8, 0); writeOctal(header, 116, 8, 0); writeOctal( header, 124, 12, entry.type === "file" ? entry.bytes.byteLength : 0 ); writeOctal(header, 136, 12, Math.floor(Date.now() / 1000)); for (let i = 148; i < 156; i++) header[i] = 32; header[156] = entry.type === "directory" ? "5".charCodeAt(0) : "0".charCodeAt(0); writeAscii(header, 257, 6, "ustar"); writeAscii(header, 263, 2, "00"); const checksum = header.reduce((sum, value) => sum + value, 0); writeOctal(header, 148, 8, checksum); return header; } function parseTar(bytes: Uint8Array): Array<{ path: string; type: "file" | "directory"; size: number; bytes: Uint8Array; }> { const entries: Array<{ path: string; type: "file" | "directory"; size: number; bytes: Uint8Array; }> = []; let offset = 0; while (offset + 512 <= bytes.byteLength) { const header = bytes.subarray(offset, offset + 512); if (header.every((byte) => byte === 0)) break; const path = readAscii(header, 0, 100); const size = readOctal(header, 124, 12); const typeFlag = String.fromCharCode(header[156] || 48); const type = typeFlag === "5" ? "directory" : "file"; offset += 512; const body = bytes.subarray(offset, offset + size); entries.push({ path, type, size, bytes: new Uint8Array(body) }); offset += Math.ceil(size / 512) * 512; } return entries; } function writeAscii( buffer: Uint8Array, offset: number, length: number, value: string ): void { const bytes = encodeText(value); buffer.set(bytes.subarray(0, length), offset); } function writeOctal( buffer: Uint8Array, offset: number, length: number, value: number ): void { const str = value.toString(8).padStart(length - 2, "0") + "\0 "; writeAscii(buffer, offset, length, str); } function readAscii(buffer: Uint8Array, offset: number, length: number): string { const value = decodeText(buffer.subarray(offset, offset + length)); return value.split("\u0000", 1)[0].trim(); } function readOctal(buffer: Uint8Array, offset: number, length: number): number { const text = readAscii(buffer, offset, length).trim(); return text ? Number.parseInt(text, 8) : 0; } function getExtension(path: string): string | null { const idx = path.lastIndexOf("."); return idx === -1 ? null : path.slice(idx + 1).toLowerCase(); } function isLikelyText(bytes: Uint8Array): boolean { for (const byte of bytes.subarray(0, Math.min(bytes.length, 512))) { if (byte === 0) return false; if (byte < 9) return false; } return true; } function globToRegex(pattern: string): RegExp { let i = 0; let re = "^"; while (i < pattern.length) { const ch = pattern[i]; if (ch === "*") { if (pattern[i + 1] === "*") { i += 2; if (pattern[i] === "/") { re += "(?:.+/)?"; i++; } else { re += ".*"; } } else { re += "[^/]*"; i++; } } else if (ch === "?") { re += "[^/]"; i++; } else { re += ch.replace(/[.+^$|\\()]/g, "\\$&"); i++; } } re += "$"; return new RegExp(re); } const MIME_BY_EXTENSION: Record = { js: "application/javascript", ts: "application/typescript", json: "application/json", html: "text/html", css: "text/css", md: "text/markdown", txt: "text/plain", png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", svg: "image/svg+xml", tar: "application/x-tar", gz: "application/gzip" };