UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

166 lines • 8.16 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 { eexist, einval, eisdir, enoent, enotdir, enotempty } from "../../errors/fs.js"; import {} from "../../errors/types.js"; import { mountedPath } from "../../utils/key_prefix.js"; import { nowIso } from "../../utils/dates.js"; import { ancestors } from "../../utils/path.js"; import { rstripSlash } from "../../utils/slash.js"; import { compareCodePoints } from "../../utils/sort.js"; // Reject a destination whose parent chain is not all directories. Mirrors how // rename(2) resolves the destination: a component that does not exist is // ENOENT, a component that is a plain file is ENOTDIR (at any depth). Without // this the store grows a key under a directory it never recorded, and that // orphan makes both the phantom directory and its real parent unlistable. // Shared by every op that places a key at a caller-supplied path (rename, // copy, create, write, append, mkdir): none of them creates parents (that is // `mkdir -p`), so all owe the destination the same probe. The real-filesystem // backends get this from the kernel. // // Components are probed one at a time rather than by pulling the whole // directory set: this runs on every write, so a set membership test per // component (O(1), and a path is only a few components deep) beats // transferring every directory in the mount. export async function checkDestParents(store, dst, d) { const broken = await brokenParent(store, dst, d); if (broken !== null) throw broken; } // Refuse a rename that rename(2) refuses once both ends resolve. A file onto // a directory is EISDIR, a directory onto a file ENOTDIR, onto a directory // that holds entries ENOTEMPTY, and into its own subtree EINVAL. A keyed store // has no kernel to say so: without this a file and a directory end up under // one key, or two trees merge. A source the store does not hold is left to the // caller's lookup. Mirrors check_rename in dest.py. export async function checkRename(store, src, s, dst, d) { if (s === d) return; if (await store.hasFile(s)) { if (await store.hasDir(d)) throw eisdir(dst); return; } if (!(await store.hasDir(s))) return; if (d.startsWith(`${rstripSlash(s)}/`)) throw einval(src); if (await store.hasFile(d)) throw enotdir(dst); if (!(await store.hasDir(d))) return; const prefix = `${rstripSlash(d)}/`; if ((await store.listFiles(prefix)).length > 0) throw enotempty(dst); if ([...(await store.listDirs())].some((k) => k.startsWith(prefix))) throw enotempty(dst); } // The error a lookup of a key the store does not hold answers with. // open(2) and stat(2) resolve a path one component at a time and stop at the // first that is not a directory, so a plain file above the key is ENOTDIR // (`cat a.txt/x` is "Not a directory") and a missing component, or a key that // is simply absent, is ENOENT. It is the walk checkDestParents makes for a // destination, so a read, a stat and a write of one path agree on its errno; // it runs on a miss only, so a hit still costs one round trip. Mirrors // lookup_error in dest.py. export async function lookupError(store, spec, key) { return (await brokenParent(store, spec, key)) ?? enoent(spec); } async function brokenParent(store, spec, key) { for (const ancestor of ancestors(key)) { if (await store.hasDir(ancestor)) continue; if (await store.hasFile(ancestor)) return enotdir(spec); return enoent(spec); } return null; } // Reject a `mkdir` the store cannot satisfy. The companion of // checkDestParents for the one op that may create its own parents, and the two // flag modes fail differently because GNU implements them differently: // // * Plain mkdir issues one mkdir(2) on the whole path, so an existing target // is EEXIST whichever kind it is. Its parent chain stays checkDestParents' // job, which reports the operand because that is what the kernel resolves. // * mkdir -p walks the chain itself, creating as it goes, so it reports the // *component* it tripped on rather than the operand: `mkdir -p a.txt/sub` // is "cannot create directory 'a.txt': Not a directory". Reaching the // target itself as a plain file is EEXIST, not ENOTDIR. An existing // directory anywhere in the chain is success, which is what makes -p // idempotent. // // Without this a store has no kernel to refuse a directory key that collides // with a file key: -p added it anyway, the directory shadowed the file, and // reading it started reporting EISDIR while the bytes stayed orphaned in the // store. Pinned against GNU coreutils in docker. // open(2) for writing answers a directory with EISDIR whatever the caller // meant to do next, so bash prints `d: Is a directory` for `> d` and `>> d` // alike and tee and truncate say the same. A real filesystem gets this from // the kernel; a keyed store has to ask its own directory table, or the bytes // land on a key the directory shadows and are unreachable from then on. export async function checkWriteTarget(store, spec, key) { if (await store.hasDir(key)) throw eisdir(spec); } export async function checkMkdirTarget(store, spec, key, parents) { if (!parents) { if ((await store.hasDir(key)) || (await store.hasFile(key))) throw eexist(spec); return; } for (const component of [...ancestors(key), key]) { if (!(await store.hasFile(component))) continue; const named = mountedPath(spec, component); throw component === key ? eexist(named) : enotdir(named); } } /** * Keep what a directory op left under `p` inside its directories. The op * lists before it acts, so a key written under `p` meanwhile stays while the * op removes the directories above it, and a key under no recorded directory * is the orphan `checkDestParents` refuses. Each directory from `p` down to * such a key is recorded again, as `mkdir -p` records it, unless a file took * its name since: that file is not hidden behind a directory. The op drops a * removed directory's metadata only after this, so a kept one keeps its time * and attributes. Returns the names whose metadata stays: each directory kept * or still standing under `p`, and any file that took one's name; empty when * nothing was left. Mirrors Python's `keep_left`. */ export async function keepLeft(store, p) { const prefix = `${rstripSlash(p)}/`; const dirs = [...(await store.listDirs())].filter((k) => k.startsWith(prefix)); const files = (await store.listFiles(prefix)).filter((k) => k.startsWith(prefix)); const left = [...files, ...dirs]; if (left.length === 0) return new Set(); const needed = new Set([p]); for (const key of left) { for (const up of ancestors(key)) if (up.startsWith(prefix)) needed.add(up); } const now = nowIso(); for (const key of [...needed].sort(compareCodePoints)) { if ((await store.hasDir(key)) || (await store.hasFile(key))) continue; await store.addDir(key); if (!(await store.getModified(key))) await store.setModified(key, now); } // A standing directory keeps its metadata but is never made again: one // another client removed since the listing stays removed. return new Set([...needed, ...dirs]); } //# sourceMappingURL=dest.js.map