@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
333 lines • 15.4 kB
JavaScript
// ========= 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