UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

256 lines • 11.4 kB
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. ========= // 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. // ========= Copyright 2026 @ Strukto.AI All Rights Reserved. ========= import { fsStrerror, isFsError, walkRefusal } from "../../../../errors/fs.js"; import { posixPhrase } from "../../../../errors/posix.js"; import { fnmatch } from "../../../../utils/fnmatch.js"; import { respellOne } from "../../../../utils/path.js"; import { lstripSlash, rstripSlash } from "../../../../utils/slash.js"; import { OTHER_FILESYSTEM, scanOperand } from "../archive/walk.js"; import { CREATE_ERROR_EXIT, EMPTY_ARCHIVE, EMPTY_MEMBER, FATAL_TRAILER, SELF_DUMP, USAGE_HINT, } from "./constants.js"; function refusal(notices) { return { members: [], notices, exitCode: CREATE_ERROR_EXIT, write: false }; } // Whether GNU's `--exclude` pattern matches this member name. GNU's // exclusion is unanchored: the pattern is tried against the whole name // and against every suffix that starts at a path component, so `a.txt`, // `d/a.txt` and `sub/b.txt` all match entries under `d`. Wildcards cross // slashes (`*/b.txt` matches `d/sub/b.txt`), which is tar's default for // exclusion patterns. A directory's trailing slash is not part of what // the pattern sees. Info-ZIP's `-x` is the anchored counterpart, which // is why the two are not shared. function excluded(name, pattern) { const bare = rstripSlash(name); if (fnmatch(bare, pattern)) return true; let cut = bare.indexOf('/'); while (cut !== -1) { if (fnmatch(bare.slice(cut + 1), pattern)) return true; cut = bare.indexOf('/', cut + 1); } return false; } // Drop excluded names and everything beneath an excluded directory. GNU // does not walk into a directory it excluded, so `--exclude sub` takes // `d/sub/` and `d/sub/b.txt` together; matching each name in isolation // would keep the children of a pruned directory. function pruned(names, pattern) { if (pattern === null) return [...names]; const kept = []; const cutDirs = []; for (const name of names) { if (cutDirs.some((cut) => name.startsWith(cut))) continue; if (excluded(name, pattern)) { if (name.endsWith('/')) cutDirs.push(name); continue; } kept.push(name); } return kept; } // Split a spelled path into the name tar stores and what it drops. tar // stores no name that could climb out of the directory it is extracted // into, so it removes everything through the *last* `..` segment: // `x/../y/f3` is stored as `y/f3` and `/data/sub/../file` as `file`. Only // when the path has no `..` does the leading slash become the thing // removed. A `.` segment escapes nothing and survives, so `./file` is // stored verbatim. Info-ZIP makes the opposite choice and keeps `..` in // the member name, which is why zip_cmd does not share this. Returns the // name and the prefix removed to get it ('' when nothing was), and each // distinct prefix earns one notice naming it. export function stripPrefix(spelled) { const segments = spelled.split('/'); let last = -1; for (let i = 0; i < segments.length; i++) { if (segments[i] === '..') last = i; } if (last >= 0) { const rest = segments.slice(last + 1).join('/'); const prefix = segments.slice(0, last + 1).join('/'); return [rest, rest !== '' ? `${prefix}/` : prefix]; } if (spelled.startsWith('/')) return [lstripSlash(spelled), '/']; return [spelled, '']; } // GNU's notice for a prefix it refused to store. function removingLeading(prefix) { return `tar: Removing leading \`${prefix}' from member names`; } // Announce a removed prefix the first time this run drops it. Emitted in // place rather than collected and prepended, because GNU interleaves // these with the per-operand errors in operand order. function announcePrefix(prefix, dropped, notices) { if (prefix === '' || dropped.includes(prefix)) return; dropped.push(prefix); notices.push(removingLeading(prefix)); } // The name tar records for a path spelled as the operand was typed. The // traversal prefix is dropped (see stripPrefix) and a directory carries // the trailing slash that tells an extractor it holds no content. An // operand that is all traversal — `tar -cf a.tar ..` — leaves nothing to // name, and GNU stores that directory as `./`. function memberName(spelled, kind) { const [name] = stripPrefix(spelled); if (kind === 'dir') { if (name === '') return './'; if (!name.endsWith('/')) return `${name}/`; } return name; } /** * GNU tar's `tar: NAME: Cannot VERB: REASON` diagnostic; `who` is the * program signing it, tar or the compressor child it runs. Mirrors Python's * cannot. */ export function cannot(name, verb, reason, who = 'tar') { return `${who}: ${name}: Cannot ${verb}: ${reason}`; } export async function checkDirectories(directories, isDir, stat) { for (const directory of directories) { let reason; try { if (directory.walkError !== null) throw walkRefusal(directory); if (await isDir(directory)) continue; await stat(directory); reason = posixPhrase('ENOTDIR'); } catch (err) { if (!isFsError(err)) throw err; reason = fsStrerror(err) ?? String(err); } return [cannot(directory.rawPath, 'open', reason), FATAL_TRAILER]; } return []; } /** * Decide every member of a new archive, before writing any of it. * * One pass per operand, in the order they were typed, each contributing * itself and then its subtree. GNU walks a directory operand rather than * refusing it, and mirage now does too; the one deliberate divergence is * ordering, since GNU emits siblings in readdir order (filesystem-dependent) * and this sorts them, the same choice `du` already documents. */ export async function planCreate(paths, deps) { if (paths.length === 0) return refusal([EMPTY_ARCHIVE, USAGE_HINT]); const directoryErrors = await checkDirectories(deps.directories ?? [], deps.isDir, deps.stat); if (directoryErrors.length > 0) return refusal(directoryErrors); const members = []; const notices = []; const dropped = []; let exitCode = 0; for (const path of paths) { if (path.walkError !== null) { // The walk refused the operand before tar ran (the empty name, a // link loop), so nothing is there to scan; the prefix it would strip // is still announced first, as for any operand it cannot stat. if (path.rawPath === '') notices.push(EMPTY_MEMBER); const trimmed = rstripSlash(path.rawPath); announcePrefix(stripPrefix(trimmed === '' ? path.rawPath : trimmed)[1], dropped, notices); notices.push(cannot(path.rawPath, 'stat', String(fsStrerror(walkRefusal(path))))); exitCode = CREATE_ERROR_EXIT; continue; } // GNU strips a trailing slash off the operand before naming the // member, and re-adds one only for a member that really is a // directory: `tar -cf a.tar dlink/` stores `dlink`, the symlink, // exactly as `tar -cf a.tar dlink` does. const trimmedRaw = rstripSlash(path.rawPath); const raw = trimmedRaw === '' ? path.rawPath : trimmedRaw; const base = rstripSlash(path.virtual) || '/'; const scan = await scanOperand(path, { stat: deps.stat, walk: deps.walk, links: deps.links ?? null, mounts: deps.mounts ?? null, dereference: deps.dereference, recurse: true, }); // GNU announces the prefix it refuses to store before it reports what // it could not read, and keeps both in operand order — a later // operand's notice must not jump ahead of an earlier operand's error. // The operand's own spelling carries the prefix even when nothing // under it can be archived, which is why `tar -cf a.tar sub/../missing` // still announces `sub/../`. announcePrefix(stripPrefix(raw)[1], dropped, notices); // Each name is then stripped on its own, so one operand can owe two // notices: `tar -cf a.tar ..` drops `..` from the directory and `../` // from everything under it. const named = scan.entries.map((entry) => { const spelled = respellOne(entry.namePath, base, raw); announcePrefix(stripPrefix(spelled)[1], dropped, notices); return [memberName(spelled, entry.kind), spelled, entry]; }); for (const problem of scan.problems) { const shown = respellOne(problem.path, base, raw); // A link followed onto another mount is a crossing too, which // --one-file-system leaves unreported, as in GNU. if (deps.oneFileSystem === true && problem.reason === OTHER_FILESYSTEM) continue; if (problem.unreadable === true) { // A directory the walk could not open: GNU names it, keeps its // entry, and fails the run. notices.push(cannot(shown, 'open', problem.reason ?? '')); exitCode = CREATE_ERROR_EXIT; continue; } if (problem.fatal !== true) { notices.push(`tar: ${shown}: ${problem.reason ?? ''}`); continue; } notices.push(cannot(shown, 'stat', problem.reason ?? '')); exitCode = CREATE_ERROR_EXIT; } if (scan.missing) continue; for (const crossing of deps.oneFileSystem === true ? [] : scan.crossings) { const shown = memberName(respellOne(crossing, base, raw), 'dir'); notices.push(`tar: ${shown}: ${OTHER_FILESYSTEM}`); } const keep = new Set(pruned(named.map(([name]) => name), deps.exclude)); for (const [name, spelled, entry] of named) { if (!keep.has(name)) continue; const read = entry.read ?? null; if (deps.archive.rawPath !== '-' && read !== null && read.virtual === deps.archive.virtual) { notices.push(`tar: ${name}: ${SELF_DUMP}`); continue; } members.push({ name, kind: entry.kind, path: read, target: entry.target ?? '', spelled, size: entry.size ?? null, }); } } return { members, notices, exitCode, write: true }; } //# sourceMappingURL=create.js.map