UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

350 lines • 15.2 kB
import { eloop, enoent, isDotWalkError, isEisdir, isEnoent, isFsError } from "../../../errors/fs.js"; import { fsErrorLine } from "../../../errors/render.js"; import { ByteSpool, spooled } from "../../../io/spool.js"; import { closeQuietly } from "../../../io/stream.js"; import { IOResult } from "../../../io/types.js"; import { encodeText } from "../../../shell/bytes.js"; import { PathSpec } from "../../../types.js"; import { gunzipStream, GzipDataError } from "../../../utils/compress.js"; import { respelled } from "../../../utils/key_prefix.js"; import { stripSlash } from "../../../utils/slash.js"; import { GZIP_KNOWN_SUFFIXES, GZIP_MAX_SUFFIX, GZIP_RETRY_SUFFIXES, GZIP_SUFFIX, GZIP_TAR_SUFFIXES, } from "../constants.js"; import { STDIN_OPERAND } from "../utils/constants.js"; import { pathExists } from "../utils/copy.js"; import { stdinStream } from "../utils/stream.js"; function asciiLower(text) { return text.replace(/[A-Z]/g, (c) => c.toLowerCase()); } /** * The compression suffix gzip reads off `name`, as spelled there. * * As gzip 1.13 reads it: the -S suffix and the ones gzip always knows, * compared without regard to ASCII case, and only where the name is longer * than the suffix with no slash right before it, so neither `.gz` nor * `d/.gz` has one. A -S suffix that ends one of the built-in ones is tried * after them, or `-S z` would take the `z` off `a.gz`. Mirrors Python's * gzip_suffix. */ export function gzipSuffix(name, suffix) { const inner = GZIP_KNOWN_SUFFIXES.some((k) => suffix.length < k.length && k.endsWith(suffix)); const own = asciiLower(suffix); const order = inner ? [...GZIP_KNOWN_SUFFIXES, own] : [own, ...GZIP_KNOWN_SUFFIXES]; const lowered = asciiLower(name); for (const known of order) { const cut = lowered.length - known.length; if (cut > 0 && lowered.endsWith(known) && lowered[cut - 1] !== '/') return name.slice(cut); } return null; } /** gzip's refusal of a -S suffix it cannot use, before any input. Mirrors * Python's suffix_refusal. */ export function suffixRefusal(suffix) { const bytes = encodeText(suffix).byteLength; if (bytes > 0 && bytes <= GZIP_MAX_SUFFIX) return null; return new IOResult({ exitCode: 1, stderr: encodeText(`gzip: invalid suffix '${suffix}'\n`) }); } /** * The output `gzip -d` names for `path`, typed and mounted; null for a name * with no suffix gzip knows. `.tgz` and `.taz`, in any case, become `.tar`; * any other suffix is dropped. */ function decompressed(path, suffix) { const found = gzipSuffix(path.rawPath, suffix); if (found === null) return null; const tar = GZIP_TAR_SUFFIXES.includes(asciiLower(found)) ? '.tar' : ''; const cut = found.length; const here = `/${stripSlash(path.virtual)}`; return [path.rawPath.slice(0, -cut) + tar, PathSpec.fromStrPath(here.slice(0, -cut) + tar)]; } /** * The names gzip -d opens in turn when `path` does not exist. * * Each is the name as typed with one suffix appended, in the same directory; * the empty name makes the suffix itself the name. A name ending in a slash * or a dot, or one whose walk failed before its last component, has no * directory to hold a suffixed twin, so every one of them misses too. A * retried name is read on the operand's own mount, where a namespace link is * not followed. */ function retries(path, missing, suffix) { const suffixes = suffix === GZIP_SUFFIX ? GZIP_RETRY_SUFFIXES : [suffix, ...GZIP_RETRY_SUFFIXES]; const typed = path.rawPath; const here = `/${stripSlash(path.virtual)}`; if (typed === '') { const base = here.replace(/\/+$/, '') + '/'; return suffixes.map((s) => respelled(PathSpec.fromStrPath(base + s), s)); } const last = typed.slice(typed.lastIndexOf('/') + 1); if (last === '' || last === '.' || last === '..' || isDotWalkError(missing)) return []; return suffixes.map((s) => respelled(PathSpec.fromStrPath(here + s), typed + s)); } async function* resumed(first, rest) { try { if (first.done === true) return; yield first.value; for (;;) { const next = await rest.next(); if (next.done === true) return; yield next.value; } } finally { await closeQuietly(rest); } } /** `source` read up to its first chunk, so a failed open throws here. */ async function opened(source) { const iterator = source[Symbol.asyncIterator](); return resumed(await iterator.next(), iterator); } /** * Open one operand the way gzip 1.13 opens it. * * A name typed with a trailing slash has to be a directory. Without -c, -t * or -f gzip opens with O_NOFOLLOW, so a link standing at the name, dangling * or not, is ELOOP, which -q never quiets; with them it is followed, a * retried name through the dispatcher, since what it leads to may live on any * mount. A directory is a warning. A missing name with no suffix gzip knows * is retried, when decompressing, with each suffix in turn and reported with * the -S one; any other failure ends the operand. Every failure is reported * in gzip's words and answers null. Mirrors Python's open_gzip_input. */ export async function openGzipInput(path, source, /** Takes a failure's line, exit code and whether it is a warning. */ report, options = {}) { const suffix = options.suffix ?? GZIP_SUFFIX; const resolver = options.resolver ?? null; const retry = options.decompress !== false && gzipSuffix(path.rawPath, suffix) === null; const names = [path]; for (const name of names) { const link = resolver !== null ? resolver.linkAt(name) : null; if (link !== null && options.follow !== true) { report(fsErrorLine('gzip', name, eloop(name)), 1, false); return null; } try { if (resolver !== null && name === path && resolver.vanished(name)) throw enoent(name.virtual); // The router followed the operand itself; a retried name it never saw // is followed through the dispatcher. const reads = resolver !== null && link !== null && name !== path ? resolver.read(link) : source(name); return { name, stream: await opened(reads), link }; } catch (err) { if (isEisdir(err)) { report(`gzip: ${name.rawPath} is a directory -- ignored\n`, 2, true); return null; } if (isEnoent(err)) { if (retry && name === path) for (const item of retries(path, err, suffix)) names.push(item); continue; } if (!isFsError(err)) throw err; report(fsErrorLine('gzip', name, err), 1, false); return null; } } const missing = retry ? path.rawPath + suffix : path.rawPath; report(fsErrorLine('gzip', missing, enoent(missing)), 1, false); return null; } /** * Whether anything stands where gzip creates an output. gzip creates with * O_EXCL, which a link standing there refuses, dangling or not, so the probe * is an lstat: through the dispatcher while the namespace holds links, the mount's * own stat otherwise. Mirrors Python's output_taken. */ export async function outputTaken(where, stat, resolver) { if (resolver !== null) return pathExists((p) => resolver.lstat(p), PathSpec.fromStrPath(where.virtual)); return stat !== undefined && (await pathExists(stat, where)); } /** An output gzip names from a typed name standing on a link: in the link's * own directory, whatever the link leads to. Mirrors Python's beside_link. */ export function besideLink(link, typed) { const dir = link.slice(0, link.lastIndexOf('/')); return PathSpec.fromStrPath(`${dir}/${typed.slice(typed.lastIndexOf('/') + 1)}`); } /** * Write an in-place output, replacing a link standing at its name. gzip -f * unlinks whatever holds the name before it creates the file, so a link there * is removed, never written through; an output beside a link goes through the * dispatcher, since the link may sit on any mount. Mirrors Python's replace_output. */ export async function replaceOutput(out, data, write, resolver, beside) { if (resolver !== null && resolver.links.statAt(out.virtual) !== null) await resolver.unlink(out.virtual); if (beside && resolver !== null) { await resolver.write(out.virtual, data); return; } await write(out, data); } /** * Remove an input once its in-place output is written: the link it was named * through, when there is one. Mirrors Python's remove_input. */ export async function removeInput(path, link, unlink, resolver) { if (link === null || resolver === null) await unlink(path); else await resolver.unlink(link); } /** gzip's refusal to replace an output already standing at its name. */ export function existsLine(name) { return `gzip: ${name} already exists;\tnot overwritten\n`; } /** * Decode operands in order the way gzip 1.13 does, in its voice. * * gunzip and zcat are gzip, so every line says `gzip:`. Each operand opens as * `openGzipInput` says. In place, a name with no known suffix and an output * already there are skipped with a warning (exit 2), a link standing there * counting as one; a warning under -q prints nothing and keeps its exit code, * except the unknown suffix, which -q drops whole. -f replaces an output, and * decodes a name standing on a link beside the link, removing the link rather * than what it leads to; with the output stdout it copies what is not gzip. * An input stdin cannot open as gzip ends the run, as gzip exits there. * * With -f, an output already there is replaced, and when the input then * turns out corrupt GNU has already unlinked it: mirage keeps it. Mirrors * Python's decompress_inputs. */ export async function decompressInputs(paths, read, options) { const suffix = options.suffix ?? GZIP_SUFFIX; const refused = suffixRefusal(suffix); if (refused !== null) return [null, refused]; const force = options.force === true; const quiet = options.quiet === true; const testOnly = options.testOnly === true; const resolver = options.resolver ?? null; const follow = options.toStdout === true || testOnly || force; const operands = paths.length > 0 ? paths : [STDIN_OPERAND]; const stream = stdinStream(read, options.stdin); const io = new IOResult(); let errors = ''; function report(line, code, warning = false) { if (!(warning && quiet)) { errors += line; io.stderr = encodeText(errors); } if (io.exitCode !== 1) io.exitCode = code; } function fail(err, shown) { report(err.render(shown), err.exitCode, err.exitCode === 2); } async function* run() { for (const operand of operands) { const onStdin = operand.rawPath === '-'; const inPlace = !(options.toStdout === true || testOnly || onStdin); const found = onStdin ? null : await openGzipInput(operand, inPlace ? read : stream, report, { suffix, follow, resolver, }); if (found === null && !onStdin) continue; const path = found === null ? operand : found.name; const source = found === null ? stream(operand) : found.stream; const link = found === null ? null : found.link; const shown = onStdin ? 'stdin' : path.rawPath; const output = inPlace ? decompressed(path, suffix) : null; if (inPlace && output === null) { if (!quiet) report(`gzip: ${shown}: unknown suffix -- ignored\n`, 2); await closeQuietly(source); continue; } const spool = new ByteSpool(); try { let failure = null; try { for await (const chunk of gunzipStream(source, testOnly, force && !inPlace)) { if (inPlace) await spool.append(chunk); else if (!testOnly) yield chunk; } } catch (err) { if (err instanceof GzipDataError) failure = err; else { if (!isFsError(err)) throw err; report('\n' + fsErrorLine('gzip', shown, err), 1); return; } } if (output === null) { if (failure !== null) { fail(failure, shown); if (failure.fatal || (onStdin && failure.firstHeader)) return; } continue; } if (failure?.firstHeader === true) { fail(failure, shown); if (failure.fatal) return; continue; } if (options.write === undefined || options.unlink === undefined) throw new Error('in-place decompression requires write and unlink'); const outName = output[0]; const out = link === null ? output[1] : besideLink(link, outName); const existed = await outputTaken(out, options.stat, resolver); if (existed && !force) { report(existsLine(outName), 2); continue; } if (failure !== null) { fail(failure, shown); if (failure.fatal) return; if (!failure.keepsOutput) continue; } const data = spool.stream(); try { await replaceOutput(out, data, options.write, resolver, link !== null); } catch (err) { if (!isFsError(err)) throw err; const line = fsErrorLine('gzip', outName, err); report(existed ? line : '\n' + line, 1); if (existed) continue; return; } if (options.keep !== true) await removeInput(path, link, options.unlink, resolver); } finally { await spool.close(); await closeQuietly(source); } } } const body = run(); if (testOnly || operands.some((p) => !(options.toStdout === true || p.rawPath === '-'))) { return [await spooled(body), io]; } return [body, io]; } //# sourceMappingURL=decompress.js.map