UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

333 lines • 15.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 { DEVICE_NUMBERS_KEY, FileType, LINK_TARGET_KEY, } from "../../../types.js"; import { systemNow } from "../../../utils/clock.js"; import { isoTimestamp } from "../../../utils/dates.js"; import { ALL_MODE_BITS, contentSize, posixMode, sizeUnknown } from "../../../utils/stat_view.js"; import { UTC_ZONE } from "../../../utils/timezone.js"; import { EPOCH_LS_TIME, FIND_FUTURE_SECONDS, FIND_LS_ESCAPES, FIND_OLD_SECONDS, LS_RECENT_SECONDS, MONTHS, TYPE_CHARS, } from "./constants.js"; import { groupName, ownerName, UNKNOWN_NAME } from "./identity.js"; import { gnuStrftime } from "./strftime.js"; // What a stat field a VFS cannot know renders as, in `stat -c` and in // the inode and block columns of `find -ls`. export const UNKNOWN_STAT_FIELD = '?'; // FileStat.extra key marking an `ls` row whose stat failed: the listing // named the entry, so GNU keeps its row, and every fact only a stat // supplies prints as UNKNOWN_STAT_FIELD. Its type is a directory's when // the listing slash-marked it, and unknown otherwise. export const STAT_FAILED_KEY = 'stat_failed'; const EPOCH = new Date(0); export const DEFAULT_COLUMNS = Object.freeze({ owner: true, group: true, inode: false, context: false, timeKind: 'mtime', timeStyle: 'locale', blockSize: null, }); // The size column under -h or --block-size, bytes otherwise. export function scaledSize(n, block, human) { if (block === null) return human ? humanSize(n) : String(n); if (block.humanBase === 1000) return humanScaled(n, 1000, ['', 'k', 'M', 'G', 'T', 'P', 'E']); if (block.humanBase !== null) return humanSize(n); return `${String(Math.ceil(n / block.divisor))}${block.suffix}`; } /** * The rounding GNU prints sizes with, shared by `-h` and `-H`. * * Three rules, none of which fall out of a plain divide-and-format. * Below one unit GNU prints the count alone -- `24`, never `24B`. Above * it the value is rounded *up* to the precision shown, so 1025 bytes is * `1.1K` rather than `1.0K`. And the decimal is dropped once the scaled * value reaches ten, giving `10K` rather than `10.0K`. Rounding up can * carry past the base (1048575 bytes ceils to 1024K, which GNU shows as * `1.0M`), so the unit is re-chosen after rounding instead of once up * front. * * @param n byte count * @param base 1024 for `-h`, 1000 for `-H` * @param units suffixes indexed by power; index 0 is unused because a * sub-unit count carries no suffix at all */ function ceilDiv(a, b) { return (a + b - 1n) / b; } export function humanScaled(n, base, units) { if (n < base) return String(n); // BigInt, not number: `n * 10` leaves the safe-integer range a little // under a petabyte, and the product silently rounds down before the // ceiling, which then lands on the wrong tenth -- 1914029841632461 // bytes read `1.7P` where GNU and Python say `1.8P`. Python does this // in arbitrary-precision ints, so BigInt is the faithful mirror rather // than a workaround. A count this large is integral; truncating guards // the BigInt conversion against a fractional caller. const value = BigInt(Math.trunc(n)); const big = BigInt(base); let i = 1; let divisor = big; for (;;) { const tenths = ceilDiv(value * 10n, divisor); if (tenths < 100n) { const unit = (tenths / 10n).toString(); const decimal = (tenths % 10n).toString(); return `${unit}.${decimal}${units[i] ?? ''}`; } const whole = ceilDiv(value, divisor); if (whole < big || i === units.length - 1) return `${whole.toString()}${units[i] ?? ''}`; i += 1; divisor *= big; } } export function humanSize(n) { return humanScaled(n, 1024, ['', 'K', 'M', 'G', 'T', 'P', 'E']); } function permTriplet(bits, special) { const execBit = special !== undefined ? bits & 1 ? special.toLowerCase() : special.toUpperCase() : bits & 1 ? 'x' : '-'; return (bits & 4 ? 'r' : '-') + (bits & 2 ? 'w' : '-') + execBit; } export function lsModeString(s) { const typeChar = TYPE_CHARS[s.type] ?? '-'; const mode = s.mode ?? posixMode(s) & ALL_MODE_BITS; return (typeChar + permTriplet(mode >> 6, mode & 0o4000 ? 's' : undefined) + permTriplet(mode >> 3, mode & 0o2000 ? 's' : undefined) + permTriplet(mode, mode & 0o1000 ? 't' : undefined)); } // The time column: `Mon DD HH:MM` for a recent time, `Mon DD YYYY` for an // old or future one, as GNU prints it. `findRule` uses findutils' window // (old past 180 days, future past an hour) rather than ls's (the last // half year, never the future). // A stat timestamp as a Date, null when unknown or unreadable. function parseWhen(modified) { if (modified === null || modified === undefined || modified === '') return null; const t = Date.parse(modified); return Number.isNaN(t) ? null : new Date(t); } // GNU's "recent" test: within the last half year and not in the future // for ls, findutils' wider window for `find -ls`. function isRecent(when, findRule) { const now = systemNow(); if (findRule) return !(now > when + FIND_OLD_SECONDS || when > now + FIND_FUTURE_SECONDS); return now - LS_RECENT_SECONDS < when && when < now; } function lsTimeString(modified, findRule = false) { const d = parseWhen(modified); if (d === null) return EPOCH_LS_TIME; const month = MONTHS[d.getUTCMonth()] ?? 'Jan'; const day = String(d.getUTCDate()).padStart(2); if (!isRecent(d.getTime() / 1000, findRule)) return `${month} ${day} ${String(d.getUTCFullYear())}`; const hh = String(d.getUTCHours()).padStart(2, '0'); const mm = String(d.getUTCMinutes()).padStart(2, '0'); return `${month} ${day} ${hh}:${mm}`; } // The time column under --time-style. full-iso, long-iso and iso are // GNU's three ISO shapes (iso pads its year form to the width of its // recent form, which is where its trailing space comes from); +FORMAT is // a date format, and one holding a newline names two, the first for a // time outside the recent window and the second for one inside it. An // unknown time renders the epoch, as the default style does. // The fraction of a second as the stamp spells it. const FRACTION = /\d\d:\d\d:\d\d\.(\d+)/; /** * A timestamp in GNU's full-iso layout, `%Y-%m-%d %H:%M:%S.%N %z`. The nine * fraction digits are the ones the stamp spells, so both hosts print what the * backend recorded rather than what their clock type keeps (a Python * `datetime` stops at microseconds, a `Date` at milliseconds). The time reads * in `zone`, UTC by default, and an unknown time is the epoch, as GNU renders * a missing file's. Mirrors Python's `full_iso_time`. */ export function fullIsoTime(modified, zone = UTC_ZONE) { const seconds = isoTimestamp(modified); const whole = new Date(Math.floor(seconds ?? 0) * 1000); const digits = seconds !== null && modified ? (FRACTION.exec(modified)?.[1] ?? '') : ''; const fraction = digits.padEnd(9, '0').slice(0, 9); return gnuStrftime(whole, `%Y-%m-%d %H:%M:%S.${fraction} %z`, zone); } export function styledTime(modified, style) { if (style === 'locale') return lsTimeString(modified); if (style === 'full-iso') return fullIsoTime(modified); const dt = parseWhen(modified) ?? EPOCH; if (style === 'long-iso') return gnuStrftime(dt, '%Y-%m-%d %H:%M', UTC_ZONE); const recent = isRecent(dt.getTime() / 1000, false); if (style === 'iso') return gnuStrftime(dt, recent ? '%m-%d %H:%M' : '%Y-%m-%d ', UTC_ZONE); const fmt = style.slice(1); const cut = fmt.indexOf('\n'); if (cut === -1) return gnuStrftime(dt, fmt, UTC_ZONE); return gnuStrftime(dt, recent ? fmt.slice(cut + 1) : fmt.slice(0, cut), UTC_ZONE); } // The timestamp an ls column shows for one row. A backend reports one // clock, so the access time falls back to the modification time and the // status-change time is the modification time (a plain write moves both // together on POSIX); a birth time is something no backend here reports, // so it stays unknown. export function timeOf(s, kind) { if (kind === 'atime') return s.atime ?? s.modified ?? null; if (kind === 'birth') return null; return s.modified ?? null; } // What -i and -Z put in front of a short row: `?` for each, since a VFS // has neither an inode nor a security context. export function lsPrefix(columns) { let out = ''; if (columns.inode) out += `${UNKNOWN_STAT_FIELD} `; if (columns.context) out += `${UNKNOWN_STAT_FIELD} `; return out; } // The name column: GNU appends `-> target` for a symlink row. export function lsName(s) { if (s.type !== FileType.SYMLINK) return s.name; const target = s.extra[LINK_TARGET_KEY]; return typeof target === 'string' && target !== '' ? `${s.name} -> ${target}` : s.name; } // The size and time columns of one `ls -l` row. A device row carries its // major and minor numbers where GNU puts them. An entry with neither a // size nor a time (a synthetic API-backend directory) shows `-` for the // time rather than inventing the epoch; its size is `-` too unless it is // a directory, whose size is always DIR_SIZE, or a FIFO, whose size is 0. // Mirrors the python formatter. function lsSizeAndTime(s, human, findRule = false, columns = DEFAULT_COLUMNS) { const whenIso = timeOf(s, columns.timeKind); const knownTime = columns.timeKind !== 'birth'; const renderTime = () => !knownTime ? UNKNOWN_NAME : findRule ? lsTimeString(whenIso, true) : styledTime(whenIso, columns.timeStyle); const device = s.extra[DEVICE_NUMBERS_KEY]; if (Array.isArray(device) && device.length === 2) { return [ `${String(device[0])}, ${String(device[1])}`, whenIso === null ? UNKNOWN_NAME : renderTime(), ]; } const size = sizeUnknown(s) ? UNKNOWN_NAME : scaledSize(contentSize(s), columns.blockSize, human); return [size, whenIso === null ? UNKNOWN_NAME : renderTime()]; } // `ls -l` rows: mode, links, owner, group, size, time, name. The owner is // the entry's uid when a backend or the attr overlay reports one, else // the workspace user; the group is the gid, else the session's profile; // `-` when nothing names one. A row whose stat failed is GNU's: the type // letter the listing gave, then `?` for every stat field, the time // right-aligned in its column. export function formatLsLong(stats, opts = {}) { const identity = opts.identity ?? null; const human = opts.human ?? false; const columns = opts.columns ?? DEFAULT_COLUMNS; const cells = stats.map((s) => s.extra[STAT_FAILED_KEY] === true ? [UNKNOWN_STAT_FIELD, UNKNOWN_STAT_FIELD] : lsSizeAndTime(s, human, false, columns)); const width = cells.reduce((m, [size]) => Math.max(m, size.length), 1); const timeWidth = cells.reduce((m, [, time]) => Math.max(m, time.length), 1); return stats.map((s, i) => { const [rawSize, time] = cells[i] ?? [UNKNOWN_NAME, UNKNOWN_NAME]; const failed = s.extra[STAT_FAILED_KEY] === true; const fields = failed ? [ (s.type === FileType.DIRECTORY ? 'd' : UNKNOWN_STAT_FIELD) + UNKNOWN_STAT_FIELD.repeat(9), UNKNOWN_STAT_FIELD, ] : [lsModeString(s), '1']; if (columns.owner) fields.push(failed ? UNKNOWN_STAT_FIELD : ownerName(s.uid, identity)); if (columns.group) fields.push(failed ? UNKNOWN_STAT_FIELD : groupName(s.gid, identity)); if (columns.context) fields.push(UNKNOWN_STAT_FIELD); fields.push(rawSize.padStart(width), failed ? time.padStart(timeWidth) : time, opts.names?.[i] ?? lsName(s)); const lead = columns.inode ? `${UNKNOWN_STAT_FIELD} ` : ''; return lead + fields.join(' '); }); } /** * One `find -ls` row in findutils' own layout. GNU's `find -ls` row is not * `ls -l`: it leads with the inode and the allocated 1K blocks, then * fixes every column's width (inode 9, blocks 6, links 3, owner and * group 8 left-aligned, size 8) instead of fitting them to the listing, * so a consumer can count fields. The inode and block columns carry * `?`, the answer `stat %i` and `%b` already give: a VFS has no inode * and no block allocation, and a number invented for either would read * as a fact. The remaining columns are the `ls -l` ones, from the same * helpers, so the two listings cannot disagree about a row; only the * name is spelled differently, escaped (`escapeFindName`) so the row * stays one line of fixed fields. `s` is the row named as find printed * it; `identity` is null outside a workspace, where both name columns * fall back to `-`. */ /** * Spell a name the way `find -ls` prints it. findutils escapes a name so * one row stays one line and its fields stay in place: a backslash, a * space and a double quote take a backslash, the C escapes stand for * their control characters, and every other control character and every * byte outside ASCII is an octal escape (`\303\274` for `ü`, as GNU * prints it in the C locale). `-print` is untouched; only the listing is * a table. */ export function escapeFindName(text) { let out = ''; for (const ch of text) { const escaped = FIND_LS_ESCAPES[ch]; if (escaped !== undefined) out += escaped; else if (ch > ' ' && ch < '\x7f') out += ch; else for (const byte of new TextEncoder().encode(ch)) out += `\\${byte.toString(8).padStart(3, '0')}`; } return out; } // The name column of a `find -ls` row, escaped, with the link target // escaped the same way. function findLsName(s) { const name = escapeFindName(s.name); if (s.type !== FileType.SYMLINK) return name; const target = s.extra[LINK_TARGET_KEY]; return typeof target === 'string' && target !== '' ? `${name} -> ${escapeFindName(target)}` : name; } export function formatFindLs(s, identity) { const [size, time] = lsSizeAndTime(s, false, true); const who = ownerName(s.uid, identity); const grp = groupName(s.gid, identity); return (`${UNKNOWN_STAT_FIELD.padStart(9)} ${UNKNOWN_STAT_FIELD.padStart(6)} ` + `${lsModeString(s)} ${'1'.padStart(3)} ${who.padEnd(8)} ${grp.padEnd(8)} ` + `${size.padStart(8)} ${time} ${findLsName(s)}`); } //# sourceMappingURL=formatting.js.map