feat(core): virtual file system and Swift string semantics

The core gains Swift's virtual file system (paths, case-insensitive
lookup that keeps the spelling, quotas, atomic changes, the defaults of
a new device) and the string behaviour the ported code relies on without
saying so. Both are measured in the Swift oracle: 11 file system cases
(the three Swift tests among them) step by step, and trimming, lower
case, equality, ordering and Base64 case by case — whitespace and control
characters over all of Unicode.

Found by measuring:
- Swift trims NEL and ZERO WIDTH SPACE, not the BOM; JS trim() does the
  opposite. The .fls import now trims with swiftTrim.
- lowercased() maps scalar by scalar: a word-final sigma stays σ.
- Foundation decodes "====" and "Y===" as empty data.
- Foundation on Linux counts 24,735 plane-14 scalars as control
  characters, a table artefact; the port keeps Unicode Cc+Cf.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-29 21:23:45 +00:00
co-authored by Claude Opus 5.5
parent 51a83ea68d
commit a198d10b4a
15 changed files with 5247 additions and 18 deletions
+2
View File
@@ -4,6 +4,7 @@ export * from './model/documentation.js';
export * from './model/graph.js';
export * from './model/link.js';
export * from './model/node.js';
export * from './model/virtualFileSystem.js';
export * from './runtime/config.js';
export * from './runtime/engine.js';
export * from './runtime/ipv4.js';
@@ -14,3 +15,4 @@ export * from './runtime/snapshot.js';
export * from './runtime/speed.js';
export * from './runtime/trace.js';
export * from './util/equal.js';
export * from './util/swiftString.js';
+163
View File
@@ -0,0 +1,163 @@
// Ported from filius-ipad (GPLv2/GPLv3):
// State/TopologyVirtualFileSystem.swift (defaultImageFiles, legacyDefaultImageData)
// The PNG images a new PC or notebook finds in /images, as Base64. Copied
// verbatim, line breaks removed.
/** The 1×1 black placeholder earlier filius-ipad versions wrote instead. */
export const LEGACY_DEFAULT_IMAGE_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=';
export const DEFAULT_IMAGE_FILES: readonly { name: string; base64: string }[] = [
{
name: 'network-map.png',
base64:
'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',
},
{
name: 'traffic-heatmap.png',
base64:
'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' +
'QmCC',
},
{
name: 'switch-backplane.png',
base64:
'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',
},
];
@@ -0,0 +1,583 @@
// Ported from filius-ipad (GPLv2/GPLv3):
// State/TopologyVirtualFileSystem.swift
// The files of one PC or notebook: a flat map from normalized absolute paths
// to directories and files, case-insensitive lookup that keeps the spelling
// it was created with, and quotas. Every change works on a copy and replaces
// the state only when it succeeds, as Swift's value type does.
//
// Swift compares the path keys by canonical equivalence (a dictionary of
// Strings); the map here is keyed by the NFC form and keeps the spelling of
// the first insertion. Ordering and trimming follow Swift (swiftString.ts).
import {
containsSwiftControlCharacter,
swiftBase64Decode,
swiftCompare,
swiftEqual,
swiftHasPrefix,
swiftLowercased,
swiftTrim,
} from '../util/swiftString.js';
import { DEFAULT_IMAGE_FILES, LEGACY_DEFAULT_IMAGE_BASE64 } from './defaultImages.js';
export type VirtualFileContent =
| { readonly kind: 'directory' }
| { readonly kind: 'text'; readonly text: string }
| { readonly kind: 'binary'; readonly data: Uint8Array; readonly mediaType?: string }
| { readonly kind: 'image'; readonly data: Uint8Array; readonly mediaType: string };
export interface VirtualFileEntry {
readonly path: string;
readonly content: VirtualFileContent;
}
export type VirtualFileSystemErrorCode =
| 'pathMustBeAbsolute'
| 'pathEscapesRoot'
| 'invalidPathComponent'
| 'itemAlreadyExists'
| 'caseInsensitiveSiblingCollision'
| 'itemNotFound'
| 'parentDirectoryNotFound'
| 'expectedDirectory'
| 'expectedFile'
| 'expectedTextFile'
| 'expectedImageFile'
| 'directoryNotEmpty'
| 'cannotMutateRoot'
| 'cannotMoveDirectoryIntoItself'
| 'fileSizeQuotaExceeded'
| 'deviceSizeQuotaExceeded'
| 'deviceEntryQuotaExceeded'
| 'totalSizeQuotaExceeded'
| 'totalEntryQuotaExceeded';
/** Swift's TopologyVirtualFileSystemError: the case and its associated values. */
export class VirtualFileSystemError extends Error {
constructor(
readonly code: VirtualFileSystemErrorCode,
readonly values: readonly (string | number)[] = [],
) {
super(`${code}(${values.join(', ')})`);
this.name = 'VirtualFileSystemError';
}
}
function fail(code: VirtualFileSystemErrorCode, ...values: (string | number)[]): never {
throw new VirtualFileSystemError(code, values);
}
export function isDirectoryContent(content: VirtualFileContent): boolean {
return content.kind === 'directory';
}
const encoder = new TextEncoder();
export function contentByteCount(content: VirtualFileContent): number {
switch (content.kind) {
case 'directory':
return 0;
case 'text':
return encoder.encode(content.text).length;
case 'binary':
case 'image':
return content.data.length;
}
}
export function entryName(path: string): string {
if (path === '/') return '/';
return path.split('/').filter((part) => part !== '').at(-1) ?? '';
}
export function parentPath(normalizedPath: string): string {
if (normalizedPath === '/') return '/';
const separator = normalizedPath.lastIndexOf('/');
if (separator < 0) return '/';
return separator === 0 ? '/' : normalizedPath.slice(0, separator);
}
function depth(path: string): number {
return path.split('/').filter((part) => part !== '').length;
}
/** Shallow first, then by path — the order Swift applies a set of entries in. */
function byDepthThenPath(lhs: string, rhs: string): number {
return depth(lhs) - depth(rhs) || swiftCompare(lhs, rhs);
}
/**
* An absolute path with `\` as `/`, empty and `.` components dropped and `..`
* resolved; a component with surrounding whitespace or a control character
* is invalid.
*/
export function normalizedAbsolutePath(rawPath: string): string {
const path = rawPath.replaceAll('\\', '/');
if (!path.startsWith('/')) fail('pathMustBeAbsolute', rawPath);
const components: string[] = [];
for (const component of path.split('/')) {
if (component === '' || component === '.') continue;
if (component === '..') {
if (components.length === 0) fail('pathEscapesRoot', rawPath);
components.pop();
continue;
}
if (!swiftEqual(component, swiftTrim(component)) || containsSwiftControlCharacter(component)) {
fail('invalidPathComponent', component);
}
components.push(component);
}
return components.length === 0 ? '/' : `/${components.join('/')}`;
}
export const VIRTUAL_FILE_SYSTEM_LIMITS = {
// The 64 MiB project ceiling leaves headroom below the 128 MiB
// konfiguration.xml archive-entry limit for Base64 and XML structure.
maximumFileBytes: 8 * 1024 * 1024,
maximumDeviceBytes: 32 * 1024 * 1024,
maximumProjectBytes: 64 * 1024 * 1024,
maximumDeviceEntries: 4096,
maximumProjectEntries: 16_384,
} as const;
type Store = Map<string, { path: string; content: VirtualFileContent }>;
function keyOf(path: string): string {
return path.normalize('NFC');
}
export class VirtualFileSystem {
private store: Store;
constructor() {
this.store = new Map([[keyOf('/'), { path: '/', content: { kind: 'directory' } }]]);
}
/** `init(entries:)`: shallow entries first; each parent must exist. */
static fromEntries(entries: readonly VirtualFileEntry[]): VirtualFileSystem {
const fileSystem = new VirtualFileSystem();
const sorted = [...entries].sort((lhs, rhs) => byDepthThenPath(lhs.path, rhs.path));
for (const entry of sorted) {
const path = normalizedAbsolutePath(entry.path);
if (path === '/') fail('itemAlreadyExists', '/');
if (fileSystem.has(path)) fail('itemAlreadyExists', path);
if (fileSystem.get(parentPath(path))?.kind !== 'directory') fail('parentDirectoryNotFound', path);
fileSystem.ensureNoCaseInsensitiveSiblingCollision(path);
fileSystem.set(path, entry.content);
}
fileSystem.validateQuotas();
return fileSystem;
}
/** What a new PC or notebook starts with: notes, a log and three images. */
static defaultForDevice(): VirtualFileSystem {
const fileSystem = new VirtualFileSystem();
const attempt = (change: () => void) => {
try {
change();
} catch {
// `try?` in Swift: a default that cannot be written is skipped.
}
};
attempt(() => fileSystem.createDirectory('/home'));
attempt(() => fileSystem.createDirectory('/images'));
attempt(() => fileSystem.createDirectory('/var/log', true));
attempt(() => fileSystem.writeTextFile('/home/lab-notes.txt', 'Document deterministic runtime notes here.', false));
attempt(() => fileSystem.writeTextFile('/home/topology-exports.csv', 'timestamp,event,detail\n', false));
attempt(() => fileSystem.writeTextFile('/var/log/runtime-events.log', 'Virtual runtime initialized.\n', false));
for (const image of DEFAULT_IMAGE_FILES) {
const data = swiftBase64Decode(image.base64);
if (data === undefined) continue;
attempt(() => fileSystem.writeImageFile(`/images/${image.name}`, data, 'image/png', false));
}
return fileSystem;
}
/** Replaces the 1×1 black images of older versions by the current defaults; returns how many. */
upgradeLegacyDefaultImages(): number {
const legacy = swiftBase64Decode(LEGACY_DEFAULT_IMAGE_BASE64)!;
let replaced = 0;
for (const image of DEFAULT_IMAGE_FILES) {
const path = `/images/${image.name}`;
const existing = this.get(path);
const replacement = swiftBase64Decode(image.base64);
if (existing?.kind !== 'image' || !bytesEqual(existing.data, legacy) || replacement === undefined) continue;
this.set(path, { kind: 'image', data: replacement, mediaType: 'image/png' });
replaced += 1;
}
return replaced;
}
/** `validateProjectQuotas`: every device's quotas, then the project's totals. */
static validateProjectQuotas(fileSystems: Iterable<VirtualFileSystem>): void {
let totalBytes = 0;
let totalEntries = 0;
for (const fileSystem of fileSystems) {
fileSystem.validateQuotas();
totalBytes += fileSystem.totalFileBytes();
totalEntries += fileSystem.persistedEntryCount();
}
const limits = VIRTUAL_FILE_SYSTEM_LIMITS;
if (totalBytes > limits.maximumProjectBytes) fail('totalSizeQuotaExceeded', totalBytes, limits.maximumProjectBytes);
if (totalEntries > limits.maximumProjectEntries) {
fail('totalEntryQuotaExceeded', totalEntries, limits.maximumProjectEntries);
}
}
// ── Reading ──────────────────────────────────────────────────────────────
entry(rawPath: string): VirtualFileEntry {
const path = this.existingPath(rawPath);
const content = this.get(path);
if (content === undefined) fail('itemNotFound', path);
return { path, content };
}
contains(rawPath: string): boolean {
return this.existingPathIfPresent(rawPath) !== undefined;
}
allEntries(): VirtualFileEntry[] {
return [...this.store.values()]
.map(({ path, content }) => ({ path, content }))
.sort((lhs, rhs) => swiftCompare(lhs.path, rhs.path));
}
/** A directory's entries: directories first, then by name. */
entries(rawDirectoryPath: string): VirtualFileEntry[] {
const directoryPath = this.existingPath(rawDirectoryPath);
const content = this.get(directoryPath);
if (content === undefined) fail('itemNotFound', directoryPath);
if (content.kind !== 'directory') fail('expectedDirectory', directoryPath);
return [...this.store.values()]
.filter(({ path }) => !swiftEqual(path, directoryPath) && swiftEqual(parentPath(path), directoryPath))
.map(({ path, content: entryContent }) => ({ path, content: entryContent }))
.sort((lhs, rhs) => {
const lhsDirectory = lhs.content.kind === 'directory';
const rhsDirectory = rhs.content.kind === 'directory';
if (lhsDirectory !== rhsDirectory) return lhsDirectory ? -1 : 1;
const byName = swiftCompare(entryName(lhs.path), entryName(rhs.path));
return byName !== 0 ? byName : swiftCompare(lhs.path, rhs.path);
});
}
textFile(rawPath: string): string {
const entry = this.entry(rawPath);
if (entry.content.kind !== 'text') fail('expectedTextFile', entry.path);
return entry.content.text;
}
imageFile(rawPath: string): { data: Uint8Array; mediaType: string } {
const entry = this.entry(rawPath);
if (entry.content.kind !== 'image') fail('expectedImageFile', entry.path);
return { data: entry.content.data, mediaType: entry.content.mediaType };
}
/** Swift's `==`: the same paths (canonically) with equal contents. */
equals(other: VirtualFileSystem): boolean {
if (this.store.size !== other.store.size) return false;
for (const [key, { content }] of this.store) {
const theirs = other.store.get(key);
if (theirs === undefined || !contentEqual(content, theirs.content)) return false;
}
return true;
}
clone(): VirtualFileSystem {
const copy = new VirtualFileSystem();
copy.store = new Map(this.store);
return copy;
}
// ── Changing ─────────────────────────────────────────────────────────────
createDirectory(rawPath: string, recursive = false): void {
this.transaction((candidate) => candidate.createDirectoryUnchecked(rawPath, recursive));
}
writeTextFile(rawPath: string, text: string, overwrite = true): void {
this.writeFileContent({ kind: 'text', text }, rawPath, overwrite);
}
writeBinaryFile(rawPath: string, data: Uint8Array, mediaType?: string, overwrite = true): void {
this.writeFileContent(
mediaType === undefined ? { kind: 'binary', data } : { kind: 'binary', data, mediaType },
rawPath,
overwrite,
);
}
writeImageFile(rawPath: string, data: Uint8Array, mediaType: string, overwrite = true): void {
this.writeFileContent({ kind: 'image', data, mediaType }, rawPath, overwrite);
}
copyItem(rawSourcePath: string, rawDestinationPath: string, overwrite = false): void {
this.transaction((candidate) => candidate.copyItemUnchecked(rawSourcePath, rawDestinationPath, overwrite));
}
moveItem(rawSourcePath: string, rawDestinationPath: string, overwrite = false): void {
this.transaction((candidate) => candidate.moveItemUnchecked(rawSourcePath, rawDestinationPath, overwrite));
}
/** Renames in place; returns the new path. */
renameItem(rawPath: string, newName: string): string {
if (newName.includes('/') || newName.includes('\\')) fail('invalidPathComponent', newName);
const sourcePath = this.existingPath(rawPath);
if (sourcePath === '/') fail('cannotMutateRoot');
const parent = parentPath(sourcePath);
const destination = normalizedAbsolutePath(parent + (parent === '/' ? '' : '/') + newName);
this.moveItem(sourcePath, destination);
return this.existingPath(destination);
}
deleteItem(rawPath: string, recursive = false): void {
const path = this.existingPath(rawPath);
if (path === '/') fail('cannotMutateRoot');
const content = this.get(path);
if (content === undefined) fail('itemNotFound', path);
const descendants = [...this.store.values()].filter((entry) => swiftHasPrefix(entry.path, `${path}/`));
if (content.kind === 'directory' && !recursive && descendants.length > 0) fail('directoryNotEmpty', path);
for (const descendant of descendants) this.delete(descendant.path);
this.delete(path);
}
// ── Internals ────────────────────────────────────────────────────────────
private get(path: string): VirtualFileContent | undefined {
return this.store.get(keyOf(path))?.content;
}
private has(path: string): boolean {
return this.store.has(keyOf(path));
}
/** Assigning to an existing (canonically equal) key keeps its spelling, as a Swift dictionary does. */
private set(path: string, content: VirtualFileContent): void {
const key = keyOf(path);
const existing = this.store.get(key);
this.store.set(key, { path: existing?.path ?? path, content });
}
private delete(path: string): void {
this.store.delete(keyOf(path));
}
private transaction(change: (candidate: VirtualFileSystem) => void): void {
const candidate = this.clone();
change(candidate);
candidate.validateQuotas();
this.store = candidate.store;
}
private persistedEntryCount(): number {
return Math.max(0, this.store.size - 1);
}
private totalFileBytes(): number {
let total = 0;
for (const { content } of this.store.values()) total += contentByteCount(content);
return total;
}
private validateQuotas(): void {
const limits = VIRTUAL_FILE_SYSTEM_LIMITS;
for (const { path, content } of this.allEntries()) {
if (content.kind === 'directory') continue;
const bytes = contentByteCount(content);
if (bytes > limits.maximumFileBytes) fail('fileSizeQuotaExceeded', path, bytes, limits.maximumFileBytes);
}
const bytes = this.totalFileBytes();
if (bytes > limits.maximumDeviceBytes) fail('deviceSizeQuotaExceeded', bytes, limits.maximumDeviceBytes);
const entries = this.persistedEntryCount();
if (entries > limits.maximumDeviceEntries) fail('deviceEntryQuotaExceeded', entries, limits.maximumDeviceEntries);
}
private createDirectoryUnchecked(rawPath: string, recursive: boolean): void {
const normalized = normalizedAbsolutePath(rawPath);
if (normalized === '/') return;
const existing = this.existingPathIfPresent(normalized);
if (existing !== undefined) fail('itemAlreadyExists', existing);
const rawParent = parentPath(normalized);
let parent = this.existingPathIfPresent(rawParent);
if (parent === undefined) {
if (!recursive) fail('parentDirectoryNotFound', normalized);
this.createDirectoryUnchecked(rawParent, true);
parent = this.existingPath(rawParent);
}
if (this.get(parent)?.kind !== 'directory') fail('parentDirectoryNotFound', normalized);
const destination = join(parent, entryName(normalized));
this.ensureNoCaseInsensitiveSiblingCollision(destination);
this.set(destination, { kind: 'directory' });
}
private writeFileContent(content: VirtualFileContent, rawPath: string, overwrite: boolean): void {
const candidate = this.clone();
const normalized = normalizedAbsolutePath(rawPath);
if (normalized === '/') fail('expectedFile', normalized);
let destination: string;
const existing = candidate.existingPathIfPresent(normalized);
if (existing !== undefined) {
destination = existing;
if (!overwrite) {
if (swiftEqual(existing, normalized)) fail('itemAlreadyExists', existing);
fail('caseInsensitiveSiblingCollision', existing, normalized);
}
if (candidate.get(existing)?.kind === 'directory') fail('expectedFile', existing);
} else {
const parent = candidate.existingPath(parentPath(normalized));
if (candidate.get(parent)?.kind !== 'directory') fail('parentDirectoryNotFound', normalized);
destination = join(parent, entryName(normalized));
candidate.ensureNoCaseInsensitiveSiblingCollision(destination);
}
candidate.set(destination, content);
candidate.validateQuotas();
this.store = candidate.store;
}
/** The subtree at `sourcePath`, shallow first. */
private subtree(sourcePath: string): { path: string; content: VirtualFileContent }[] {
return [...this.store.values()]
.filter(({ path }) => swiftEqual(path, sourcePath) || swiftHasPrefix(path, `${sourcePath}/`))
.sort((lhs, rhs) => byDepthThenPath(lhs.path, rhs.path));
}
/** What copy and move check, and the destination they clear, before they write. */
private prepareTransfer(
sourcePath: string,
sourceContent: VirtualFileContent,
destinationPath: string,
overwrite: boolean,
skipSource: boolean,
): void {
if (
sourceContent.kind === 'directory' &&
(swiftHasPrefix(destinationPath, `${sourcePath}/`) || swiftHasPrefix(sourcePath, `${destinationPath}/`))
) {
fail('cannotMoveDirectoryIntoItself', sourcePath, destinationPath);
}
const destinationExisting = this.existingPathIfPresent(destinationPath);
if (destinationExisting !== undefined && !(skipSource && swiftEqual(destinationExisting, sourcePath))) {
if (!overwrite) fail('itemAlreadyExists', destinationExisting);
const destinationContent = this.get(destinationExisting);
if (destinationContent === undefined) fail('itemNotFound', destinationExisting);
if ((destinationContent.kind === 'directory') !== (sourceContent.kind === 'directory')) {
fail(sourceContent.kind === 'directory' ? 'expectedDirectory' : 'expectedFile', destinationExisting);
}
this.removeItemTree(destinationExisting);
}
if (this.get(parentPath(destinationPath))?.kind !== 'directory') fail('parentDirectoryNotFound', destinationPath);
this.ensureNoCaseInsensitiveSiblingCollision(destinationPath);
}
private copyItemUnchecked(rawSourcePath: string, rawDestinationPath: string, overwrite: boolean): void {
const sourcePath = this.existingPath(rawSourcePath);
const normalizedDestination = normalizedAbsolutePath(rawDestinationPath);
if (sourcePath === '/') fail('cannotMutateRoot');
const sourceContent = this.get(sourcePath);
if (sourceContent === undefined) fail('itemNotFound', sourcePath);
const destinationPath = this.canonicalDestinationPath(normalizedDestination);
if (swiftEqual(destinationPath, sourcePath)) {
if (overwrite) return;
fail('itemAlreadyExists', destinationPath);
}
this.prepareTransfer(sourcePath, sourceContent, destinationPath, overwrite, true);
for (const { path, content } of this.subtree(sourcePath)) {
this.set(destinationPath + path.slice(sourcePath.length), content);
}
}
private moveItemUnchecked(rawSourcePath: string, rawDestinationPath: string, overwrite: boolean): void {
const sourcePath = this.existingPath(rawSourcePath);
const normalizedDestination = normalizedAbsolutePath(rawDestinationPath);
if (sourcePath === '/') fail('cannotMutateRoot');
const sourceContent = this.get(sourcePath);
if (sourceContent === undefined) fail('itemNotFound', sourcePath);
const destinationPath = this.canonicalDestinationPath(normalizedDestination);
if (swiftEqual(destinationPath, sourcePath)) return;
this.prepareTransfer(sourcePath, sourceContent, destinationPath, overwrite, false);
const entries = this.subtree(sourcePath);
for (const { path } of entries) this.delete(path);
for (const { path, content } of entries) {
this.set(destinationPath + path.slice(sourcePath.length), content);
}
}
private canonicalDestinationPath(normalizedPath: string): string {
const existing = this.existingPathIfPresent(normalizedPath);
if (existing !== undefined) return existing;
const parent = this.existingPath(parentPath(normalizedPath));
return join(parent, entryName(normalizedPath));
}
private existingPathIfPresent(rawPath: string): string | undefined {
try {
return this.existingPath(rawPath);
} catch {
return undefined;
}
}
/** The stored spelling of a path, looked up component by component, case-insensitively. */
private existingPath(rawPath: string): string {
const normalized = normalizedAbsolutePath(rawPath);
if (normalized === '/') return '/';
let current = '/';
for (const component of normalized.split('/').filter((part) => part !== '')) {
const wanted = swiftLowercased(component);
const candidates = [...this.store.values()]
.filter(
({ path }) => swiftEqual(parentPath(path), current) && swiftEqual(swiftLowercased(entryName(path)), wanted),
)
.map(({ path }) => path);
if (candidates.length !== 1) fail('itemNotFound', normalized);
current = candidates[0]!;
}
return current;
}
private removeItemTree(path: string): void {
for (const entry of [...this.store.values()]) {
if (swiftEqual(entry.path, path) || swiftHasPrefix(entry.path, `${path}/`)) this.delete(entry.path);
}
}
private ensureNoCaseInsensitiveSiblingCollision(path: string): void {
const parent = parentPath(path);
const name = swiftLowercased(entryName(path));
const existing = [...this.store.values()]
.map((entry) => entry.path)
.filter(
(candidate) =>
!swiftEqual(candidate, path) &&
swiftEqual(parentPath(candidate), parent) &&
swiftEqual(swiftLowercased(entryName(candidate)), name),
)
.sort(swiftCompare)[0];
if (existing !== undefined) fail('caseInsensitiveSiblingCollision', existing, path);
}
}
function join(parent: string, name: string): string {
return parent === '/' ? `/${name}` : `${parent}/${name}`;
}
function bytesEqual(lhs: Uint8Array, rhs: Uint8Array): boolean {
return lhs.length === rhs.length && lhs.every((byte, index) => byte === rhs[index]);
}
function contentEqual(lhs: VirtualFileContent, rhs: VirtualFileContent): boolean {
if (lhs.kind !== rhs.kind) return false;
switch (lhs.kind) {
case 'directory':
return true;
case 'text':
return swiftEqual(lhs.text, (rhs as typeof lhs).text);
case 'binary':
case 'image': {
const other = rhs as typeof lhs;
return lhs.mediaType === other.mediaType && bytesEqual(lhs.data, other.data);
}
}
}
+121
View File
@@ -0,0 +1,121 @@
// Swift String behaviour the ported code relies on without saying so.
// JavaScript strings compare and trim differently; each function here says
// which Swift expression it stands for. Measured against the Swift core
// (test/strings.cases.json, tools/swift-oracle --strings).
/**
* `value.trimmingCharacters(in: .whitespacesAndNewlines)`, scalar by scalar:
* Unicode spaces (Zs), ZERO WIDTH SPACE (U+200B, a space in old Unicode and
* still in Foundation's table), TAB, and the newlines LF, VT, FF, CR, NEL,
* LS, PS. Unlike JS trim(), NEL and U+200B are trimmed and the BOM is not.
* The whole set is measured against Swift (strings.swift.json).
*/
export function swiftTrim(value: string): string {
const codePoints = Array.from(value);
let start = 0;
let end = codePoints.length;
while (start < end && isSwiftWhitespaceOrNewline(codePoints[start]!)) start += 1;
while (end > start && isSwiftWhitespaceOrNewline(codePoints[end - 1]!)) end -= 1;
return codePoints.slice(start, end).join('');
}
const WHITESPACE_OR_NEWLINE = /^[\p{Zs}\u200B\t\n\u000B\f\r\u0085\u2028\u2029]$/u;
function isSwiftWhitespaceOrNewline(codePoint: string): boolean {
return WHITESPACE_OR_NEWLINE.test(codePoint);
}
/**
* `CharacterSet.controlCharacters.contains(scalar)` for some scalar: Cc or
* Cf. Measured: identical below plane 14; Foundation on Linux also counts
* 24,735 variation selectors and unassigned scalars from U+E0101 up, in a
* pattern that repeats every 256 — a table artefact this port does not copy.
*/
export function containsSwiftControlCharacter(value: string): boolean {
return /[\p{Cc}\p{Cf}]/u.test(value);
}
/**
* `value.lowercased()`: the lower-case mapping scalar by scalar, without
* context — JS toLowerCase() turns a word-final Σ into ς, Swift into σ.
*/
export function swiftLowercased(value: string): string {
return Array.from(value, (codePoint) => codePoint.toLowerCase()).join('');
}
/** `lhs == rhs`: Swift strings are equal when they are canonically equivalent. */
export function swiftEqual(lhs: string, rhs: string): boolean {
return lhs === rhs || lhs.normalize('NFC') === rhs.normalize('NFC');
}
/**
* `lhs < rhs` as a comparator: canonically equivalent strings are equal, the
* rest ordered by Unicode scalar values (JS `<` orders UTF-16 units, which
* puts U+FF5E after U+1F600).
*/
export function swiftCompare(lhs: string, rhs: string): number {
if (lhs === rhs) return 0;
const left = Array.from(lhs.normalize('NFC'), (codePoint) => codePoint.codePointAt(0)!);
const right = Array.from(rhs.normalize('NFC'), (codePoint) => codePoint.codePointAt(0)!);
const length = Math.min(left.length, right.length);
for (let index = 0; index < length; index++) {
if (left[index] !== right[index]) return left[index]! - right[index]!;
}
return left.length - right.length;
}
/** `lhs.hasPrefix(prefix)` for the path prefixes the port uses (a parent path and "/"). */
export function swiftHasPrefix(value: string, prefix: string): boolean {
return value.startsWith(prefix) || value.normalize('NFC').startsWith(prefix.normalize('NFC'));
}
const BASE64_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
/**
* `Data(base64Encoded: text, options: .ignoreUnknownCharacters)` as
* Foundation does it (measured): characters outside the alphabet and `=` are
* skipped; after the first `=` only more `=` may follow; symbols and padding
* together must be a multiple of four — "====" and "Y===" are empty data.
* Else nil (undefined).
*/
export function swiftBase64Decode(text: string): Uint8Array | undefined {
const values: number[] = [];
let padding = 0;
for (const character of text) {
if (character === '=') {
padding += 1;
continue;
}
const value = BASE64_ALPHABET.indexOf(character);
if (value < 0) continue;
if (padding > 0) return undefined;
values.push(value);
}
if ((values.length + padding) % 4 !== 0) return undefined;
const bytes = new Uint8Array(Math.floor((values.length * 6) / 8));
let length = 0;
let buffer = 0;
let bits = 0;
for (const value of values) {
buffer = ((buffer << 6) | value) & 0xffffff;
bits += 6;
if (bits >= 8) {
bits -= 8;
bytes[length++] = (buffer >> bits) & 0xff;
}
}
return bytes;
}
/** Base64 with padding, no line breaks — `data.base64EncodedString()`. */
export function base64Encode(bytes: Uint8Array): string {
let text = '';
for (let index = 0; index < bytes.length; index += 3) {
const chunk = (bytes[index]! << 16) | ((bytes[index + 1] ?? 0) << 8) | (bytes[index + 2] ?? 0);
const remaining = bytes.length - index;
text += BASE64_ALPHABET[(chunk >> 18) & 63]! + BASE64_ALPHABET[(chunk >> 12) & 63]!;
text += remaining > 1 ? BASE64_ALPHABET[(chunk >> 6) & 63]! : '=';
text += remaining > 2 ? BASE64_ALPHABET[chunk & 63]! : '=';
}
return text;
}
+130
View File
@@ -0,0 +1,130 @@
{
"trim": [
" a ",
"\t\na\r",
"\u0085a\u0085",
"\ufeffa",
"\u00a0a\u00a0",
"\u2028a\u2029",
"\u3000a",
"\u200ba",
"\u001ca",
"\u000ba\f",
" \u0301a",
"a \u0301",
"\u180ea",
"\u1680a"
],
"lowercased": [
"ABC",
"\u03a3\u0391\u03a3",
"\u0391\u03a3 x",
"\u0130",
"\u1e9e",
"\u01c5",
"\u00c4\u00d6\u00dc",
"\u212a"
],
"equal": [
[
"\u00e9",
"e\u0301"
],
[
"\u00c5",
"A\u030a"
],
[
"\ufb01",
"fi"
],
[
"K",
"\u212a"
],
[
"\u03a9",
"\u2126"
],
[
"a",
"A"
],
[
"\u00e9",
"\u00e9"
]
],
"sort": [
[
"b",
"a",
"B"
],
[
"\uff5e",
"\ud83d\ude00"
],
[
"e\u0301x",
"\u00e9a"
],
[
"a",
"A",
"\u00e1",
"Z"
],
[
"file10",
"file2",
"File1"
]
],
"base64": [
"aGVsbG8=",
"aGVsbG8",
"aGVs bG8=",
"aGVsbG8=\n",
"aGVs=bG8=",
"====",
"",
"aGVsbG8==",
"YQ==",
"YQ",
"Y",
"!!!!YQ==",
"YQ==YQ==",
"YWJj",
"Y\u00e9Q==",
"YQ=\n=",
"Y===",
"YQ=",
"YWI=",
"YW==",
"=YQ=",
"A===",
"AA==",
"AAA=",
"AB==",
"ABC=",
"YQ===",
"YQ==\u0000",
"Y=Q=",
"YWJjZA",
"YWJjZA=",
"=",
"==",
"==="
],
"controlCharacters": [
"a",
"a\u200b",
"a\u007f",
"a\u00ad",
"a\u0085",
"\ufeff",
"a\ud83d\ude00",
"a\ue000"
]
}
File diff suppressed because it is too large Load Diff
+96
View File
@@ -0,0 +1,96 @@
// Swift String behaviour, measured in the Swift core (strings.swift.json,
// tools/swift-oracle --strings): every case must come out the same here.
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import {
base64Encode,
containsSwiftControlCharacter,
swiftBase64Decode,
swiftCompare,
swiftEqual,
swiftLowercased,
swiftTrim,
} from '../src/index.js';
const here = dirname(fileURLToPath(import.meta.url));
const cases = JSON.parse(readFileSync(join(here, 'strings.cases.json'), 'utf8')) as {
trim: string[];
lowercased: string[];
equal: [string, string][];
sort: string[][];
base64: string[];
controlCharacters: string[];
};
const swift = JSON.parse(readFileSync(join(here, 'strings.swift.json'), 'utf8')) as {
trim: string[];
lowercased: string[];
equal: boolean[];
sort: string[][];
base64: (number[] | null)[];
controlCharacters: boolean[];
whitespacesAndNewlinesSet: [number, number][];
controlCharactersSet: [number, number][];
};
/** The scalars for which `test` holds, as ranges [first, last] — the oracle's shape. */
function scalarRanges(test: (scalar: string) => boolean): [number, number][] {
const ranges: [number, number][] = [];
for (let value = 0; value <= 0x10ffff; value++) {
if (value >= 0xd800 && value <= 0xdfff) continue;
if (!test(String.fromCodePoint(value))) continue;
const last = ranges.at(-1);
if (last !== undefined && last[1] === value - 1) last[1] = value;
else ranges.push([value, value]);
}
return ranges;
}
describe('Swift string semantics', () => {
it('trims like trimmingCharacters(in: .whitespacesAndNewlines)', () => {
expect(cases.trim.map(swiftTrim)).toEqual(swift.trim);
});
it('lower-cases like lowercased()', () => {
expect(cases.lowercased.map(swiftLowercased)).toEqual(swift.lowercased);
});
it('compares like ==', () => {
expect(cases.equal.map(([lhs, rhs]) => swiftEqual(lhs, rhs))).toEqual(swift.equal);
});
it('sorts like sorted()', () => {
expect(cases.sort.map((list) => [...list].sort(swiftCompare))).toEqual(swift.sort);
});
it('decodes Base64 like Data(base64Encoded:options: .ignoreUnknownCharacters)', () => {
expect(cases.base64.map((text) => {
const bytes = swiftBase64Decode(text);
return bytes === undefined ? null : [...bytes];
})).toEqual(swift.base64);
});
it('finds control characters like CharacterSet.controlCharacters', () => {
expect(cases.controlCharacters.map(containsSwiftControlCharacter)).toEqual(swift.controlCharacters);
});
it('trims exactly the scalars Swift trims, all of Unicode', () => {
expect(scalarRanges((scalar) => swiftTrim(`${scalar}a`) === 'a')).toEqual(swift.whitespacesAndNewlinesSet);
});
it('counts the same scalars as control characters, except Foundation\'s plane-14 artefact', () => {
const below = (ranges: [number, number][]) => ranges.filter(([first]) => first < 0xe0100);
expect(scalarRanges(containsSwiftControlCharacter).filter(([first]) => first < 0xe0100)).toEqual(
below(swift.controlCharactersSet).map(([first, last]) => [first, Math.min(last, 0xe00ff)]),
);
});
it('encodes Base64 so that it decodes again', () => {
for (const length of [0, 1, 2, 3, 4, 5, 255]) {
const bytes = Uint8Array.from({ length }, (_, index) => (index * 37) & 0xff);
expect(swiftBase64Decode(base64Encode(bytes))).toEqual(bytes);
}
});
});
+569
View File
@@ -0,0 +1,569 @@
[
{
"name": "whitespace components and case-insensitive siblings (Swift test)",
"steps": [
{
"op": "createDirectory",
"path": "/docs"
},
{
"op": "writeText",
"path": "/docs/Readme.txt",
"text": "one"
},
{
"op": "writeText",
"path": "/docs/ note.txt",
"text": "two"
},
{
"op": "writeText",
"path": "/DOCS/readme.TXT",
"text": "two",
"overwrite": true
},
{
"op": "text",
"path": "/docs/Readme.txt"
},
{
"op": "entry",
"path": "/DOCS/README.TXT"
},
{
"op": "writeText",
"path": "/docs/README.txt",
"text": "three",
"overwrite": false
},
{
"op": "fromEntries",
"entries": [
{
"path": "/docs",
"kind": "directory"
},
{
"path": "/docs/Readme.txt",
"kind": "text",
"text": "one"
},
{
"path": "/docs/readme.TXT",
"kind": "text",
"text": "two"
}
]
}
]
},
{
"name": "quotas are explicit and atomic (Swift test)",
"steps": [
{
"op": "writeBinary",
"path": "/oversized.bin",
"size": 8388609,
"byte": 65
},
{
"op": "contains",
"path": "/oversized.bin"
},
{
"op": "writeBinary",
"path": "/file-0.bin",
"size": 8388608,
"byte": 66
},
{
"op": "writeBinary",
"path": "/file-1.bin",
"size": 8388608,
"byte": 66
},
{
"op": "writeBinary",
"path": "/file-2.bin",
"size": 8388608,
"byte": 66
},
{
"op": "writeBinary",
"path": "/file-3.bin",
"size": 8388608,
"byte": 66
},
{
"op": "writeBinary",
"path": "/file-4.bin",
"size": 8388608,
"byte": 66
},
{
"op": "contains",
"path": "/file-4.bin"
}
]
},
{
"name": "overwrite uses the final state's quota, move is atomic (Swift test)",
"steps": [
{
"op": "writeBinary",
"path": "/source.bin",
"size": 8388608,
"byte": 65,
"mediaType": "application/octet-stream"
},
{
"op": "writeBinary",
"path": "/destination.bin",
"size": 8388608,
"byte": 65,
"mediaType": "application/octet-stream"
},
{
"op": "writeBinary",
"path": "/third.bin",
"size": 8388608,
"byte": 65,
"mediaType": "application/octet-stream"
},
{
"op": "writeBinary",
"path": "/fourth.bin",
"size": 8388608,
"byte": 65,
"mediaType": "application/octet-stream"
},
{
"op": "copy",
"path": "/SOURCE.BIN",
"to": "/DESTINATION.BIN",
"overwrite": true
},
{
"op": "copy",
"path": "/source.bin",
"to": "/new.bin",
"overwrite": true
},
{
"op": "move",
"path": "/SOURCE.BIN",
"to": "/DESTINATION.BIN",
"overwrite": true
},
{
"op": "contains",
"path": "/source.bin"
},
{
"op": "contains",
"path": "/destination.bin"
}
]
},
{
"name": "path normalization",
"steps": [
{
"op": "normalize",
"path": "a"
},
{
"op": "normalize",
"path": "/"
},
{
"op": "normalize",
"path": "/a/./b/../c"
},
{
"op": "normalize",
"path": "/.."
},
{
"op": "normalize",
"path": "/a/../.."
},
{
"op": "normalize",
"path": "\\a\\b"
},
{
"op": "normalize",
"path": "/a/ b"
},
{
"op": "normalize",
"path": "/a/b "
},
{
"op": "normalize",
"path": "/a/b\u0007"
},
{
"op": "normalize",
"path": "/a/b\u200b"
},
{
"op": "normalize",
"path": "//a//b//"
},
{
"op": "normalize",
"path": "/a/..."
},
{
"op": "normalize",
"path": "/a/\u0085b"
},
{
"op": "normalize",
"path": "/a/\ufeffb"
},
{
"op": "normalize",
"path": "/a/\u00a0b\u00a0"
},
{
"op": "normalize",
"path": "/\u00e9/e\u0301"
}
]
},
{
"name": "canonical equivalence and scalar-wise lower case",
"steps": [
{
"op": "createDirectory",
"path": "/\u00e9"
},
{
"op": "entry",
"path": "/e\u0301"
},
{
"op": "writeText",
"path": "/E\u0301/x.txt",
"text": "1"
},
{
"op": "createDirectory",
"path": "/e\u0301"
},
{
"op": "createDirectory",
"path": "/\u039f\u0394\u039f\u03a3"
},
{
"op": "entry",
"path": "/\u03bf\u03b4\u03bf\u03c3"
},
{
"op": "entry",
"path": "/\u03bf\u03b4\u03bf\u03c2"
},
{
"op": "createDirectory",
"path": "/Stra\u00dfe"
},
{
"op": "createDirectory",
"path": "/STRASSE"
},
{
"op": "createDirectory",
"path": "/\u212aelvin"
},
{
"op": "entry",
"path": "/kelvin"
}
]
},
{
"name": "listing order",
"steps": [
{
"op": "writeText",
"path": "/b.txt",
"text": "b.txt"
},
{
"op": "writeText",
"path": "/a.txt",
"text": "a.txt"
},
{
"op": "writeText",
"path": "/Zeta",
"text": "Zeta"
},
{
"op": "writeText",
"path": "/\u00e9mile",
"text": "\u00e9mile"
},
{
"op": "writeText",
"path": "/file10",
"text": "file10"
},
{
"op": "writeText",
"path": "/file2",
"text": "file2"
},
{
"op": "writeText",
"path": "/\uff5e",
"text": "\uff5e"
},
{
"op": "writeText",
"path": "/\ud83d\ude00",
"text": "\ud83d\ude00"
},
{
"op": "createDirectory",
"path": "/zdir"
},
{
"op": "createDirectory",
"path": "/Adir"
},
{
"op": "entries",
"path": "/"
}
]
},
{
"name": "copy, move, rename and delete trees",
"steps": [
{
"op": "createDirectory",
"path": "/a/b/c",
"recursive": true
},
{
"op": "writeText",
"path": "/a/b/c/f.txt",
"text": "f"
},
{
"op": "writeText",
"path": "/a/g.txt",
"text": "g"
},
{
"op": "copy",
"path": "/a",
"to": "/a/b/x"
},
{
"op": "copy",
"path": "/a/b",
"to": "/"
},
{
"op": "copy",
"path": "/A/B",
"to": "/copy"
},
{
"op": "entries",
"path": "/copy/c"
},
{
"op": "move",
"path": "/copy",
"to": "/a/moved"
},
{
"op": "move",
"path": "/a",
"to": "/a/b"
},
{
"op": "rename",
"path": "/a/moved",
"name": "Renamed"
},
{
"op": "rename",
"path": "/a/g.txt",
"name": "x/y"
},
{
"op": "delete",
"path": "/a"
},
{
"op": "delete",
"path": "/",
"recursive": true
},
{
"op": "copy",
"path": "/a/g.txt",
"to": "/a/b",
"overwrite": true
},
{
"op": "copy",
"path": "/a/b",
"to": "/a/g.txt",
"overwrite": true
},
{
"op": "delete",
"path": "/A/b",
"recursive": true
},
{
"op": "writeText",
"path": "/a",
"text": "x"
},
{
"op": "text",
"path": "/a"
},
{
"op": "createDirectory",
"path": "/a/g.txt/x"
},
{
"op": "createDirectory",
"path": "/q/r"
}
]
},
{
"name": "file kinds",
"steps": [
{
"op": "writeImage",
"path": "/i.png",
"size": 10,
"byte": 1,
"mediaType": "image/png"
},
{
"op": "text",
"path": "/i.png"
},
{
"op": "writeText",
"path": "/",
"text": "x"
},
{
"op": "writeText",
"path": "/missing/x.txt",
"text": "x"
},
{
"op": "entry",
"path": "/i.png"
}
]
},
{
"name": "defaults of a new device",
"steps": [
{
"op": "default"
},
{
"op": "entries",
"path": "/images"
}
]
},
{
"name": "entries need their parents",
"steps": [
{
"op": "fromEntries",
"entries": [
{
"path": "/a/b",
"kind": "text",
"text": "x"
}
]
},
{
"op": "fromEntries",
"entries": [
{
"path": "/",
"kind": "directory"
}
]
},
{
"op": "fromEntries",
"entries": [
{
"path": "/b",
"kind": "directory"
},
{
"path": "/b/c",
"kind": "text",
"text": "c"
},
{
"path": "/a",
"kind": "text",
"text": "a"
}
]
}
]
},
{
"name": "canonically equal entries are one path",
"steps": [
{
"op": "fromEntries",
"entries": [
{
"path": "/\u00e9",
"kind": "text",
"text": "composed"
},
{
"path": "/e\u0301",
"kind": "text",
"text": "decomposed"
}
]
},
{
"op": "fromEntries",
"entries": [
{
"path": "/\u00e9",
"kind": "directory"
},
{
"path": "/e\u0301/x",
"kind": "text",
"text": "x"
}
]
},
{
"op": "entries",
"path": "/"
}
]
}
]
+863
View File
@@ -0,0 +1,863 @@
[
{
"entries" : [
{
"kind" : "directory",
"path" : "\/"
},
{
"kind" : "directory",
"path" : "\/docs"
},
{
"kind" : "text",
"path" : "\/docs\/Readme.txt",
"text" : "two"
}
],
"name" : "whitespace components and case-insensitive siblings (Swift test)",
"results" : [
{
"ok" : true
},
{
"ok" : true
},
{
"error" : "invalidPathComponent",
"values" : [
" note.txt"
]
},
{
"ok" : true
},
{
"ok" : "two"
},
{
"ok" : {
"kind" : "text",
"path" : "\/docs\/Readme.txt",
"text" : "two"
}
},
{
"error" : "caseInsensitiveSiblingCollision",
"values" : [
"\/docs\/Readme.txt",
"\/docs\/README.txt"
]
},
{
"error" : "caseInsensitiveSiblingCollision",
"values" : [
"\/docs\/Readme.txt",
"\/docs\/readme.TXT"
]
}
]
},
{
"entries" : [
{
"kind" : "directory",
"path" : "\/"
},
{
"bytes" : 8388608,
"kind" : "binary",
"mediaType" : null,
"path" : "\/file-0.bin",
"sha256" : "001224bdbc0a675a104bc57050e10365bce70ab7ca449685f8142460b0dd5ba5"
},
{
"bytes" : 8388608,
"kind" : "binary",
"mediaType" : null,
"path" : "\/file-1.bin",
"sha256" : "001224bdbc0a675a104bc57050e10365bce70ab7ca449685f8142460b0dd5ba5"
},
{
"bytes" : 8388608,
"kind" : "binary",
"mediaType" : null,
"path" : "\/file-2.bin",
"sha256" : "001224bdbc0a675a104bc57050e10365bce70ab7ca449685f8142460b0dd5ba5"
},
{
"bytes" : 8388608,
"kind" : "binary",
"mediaType" : null,
"path" : "\/file-3.bin",
"sha256" : "001224bdbc0a675a104bc57050e10365bce70ab7ca449685f8142460b0dd5ba5"
}
],
"name" : "quotas are explicit and atomic (Swift test)",
"results" : [
{
"error" : "fileSizeQuotaExceeded",
"values" : [
"\/oversized.bin",
8388609,
8388608
]
},
{
"ok" : false
},
{
"ok" : true
},
{
"ok" : true
},
{
"ok" : true
},
{
"ok" : true
},
{
"error" : "deviceSizeQuotaExceeded",
"values" : [
41943040,
33554432
]
},
{
"ok" : false
}
]
},
{
"entries" : [
{
"kind" : "directory",
"path" : "\/"
},
{
"bytes" : 8388608,
"kind" : "binary",
"mediaType" : "application\/octet-stream",
"path" : "\/destination.bin",
"sha256" : "b16bd32b101132fd0102461bc75ea65442c37293ac881ae953486c8ac26a7388"
},
{
"bytes" : 8388608,
"kind" : "binary",
"mediaType" : "application\/octet-stream",
"path" : "\/fourth.bin",
"sha256" : "b16bd32b101132fd0102461bc75ea65442c37293ac881ae953486c8ac26a7388"
},
{
"bytes" : 8388608,
"kind" : "binary",
"mediaType" : "application\/octet-stream",
"path" : "\/third.bin",
"sha256" : "b16bd32b101132fd0102461bc75ea65442c37293ac881ae953486c8ac26a7388"
}
],
"name" : "overwrite uses the final state's quota, move is atomic (Swift test)",
"results" : [
{
"ok" : true
},
{
"ok" : true
},
{
"ok" : true
},
{
"ok" : true
},
{
"ok" : true
},
{
"error" : "deviceSizeQuotaExceeded",
"values" : [
41943040,
33554432
]
},
{
"ok" : true
},
{
"ok" : false
},
{
"ok" : true
}
]
},
{
"entries" : [
{
"kind" : "directory",
"path" : "\/"
}
],
"name" : "path normalization",
"results" : [
{
"error" : "pathMustBeAbsolute",
"values" : [
"a"
]
},
{
"ok" : "\/"
},
{
"ok" : "\/a\/c"
},
{
"error" : "pathEscapesRoot",
"values" : [
"\/.."
]
},
{
"error" : "pathEscapesRoot",
"values" : [
"\/a\/..\/.."
]
},
{
"ok" : "\/a\/b"
},
{
"error" : "invalidPathComponent",
"values" : [
" b"
]
},
{
"error" : "invalidPathComponent",
"values" : [
"b "
]
},
{
"error" : "invalidPathComponent",
"values" : [
"b\u0007"
]
},
{
"error" : "invalidPathComponent",
"values" : [
"b​"
]
},
{
"ok" : "\/a\/b"
},
{
"ok" : "\/a\/..."
},
{
"error" : "invalidPathComponent",
"values" : [
"…b"
]
},
{
"error" : "invalidPathComponent",
"values" : [
"b"
]
},
{
"error" : "invalidPathComponent",
"values" : [
" b "
]
},
{
"ok" : "\/é\/é"
}
]
},
{
"entries" : [
{
"kind" : "directory",
"path" : "\/"
},
{
"kind" : "directory",
"path" : "\/Kelvin"
},
{
"kind" : "directory",
"path" : "\/STRASSE"
},
{
"kind" : "directory",
"path" : "\/Straße"
},
{
"kind" : "directory",
"path" : "\/é"
},
{
"kind" : "text",
"path" : "\/é\/x.txt",
"text" : "1"
},
{
"kind" : "directory",
"path" : "\/ΟΔΟΣ"
}
],
"name" : "canonical equivalence and scalar-wise lower case",
"results" : [
{
"ok" : true
},
{
"ok" : {
"kind" : "directory",
"path" : "\/é"
}
},
{
"ok" : true
},
{
"error" : "itemAlreadyExists",
"values" : [
"\/é"
]
},
{
"ok" : true
},
{
"ok" : {
"kind" : "directory",
"path" : "\/ΟΔΟΣ"
}
},
{
"error" : "itemNotFound",
"values" : [
"\/οδος"
]
},
{
"ok" : true
},
{
"ok" : true
},
{
"ok" : true
},
{
"ok" : {
"kind" : "directory",
"path" : "\/Kelvin"
}
}
]
},
{
"entries" : [
{
"kind" : "directory",
"path" : "\/"
},
{
"kind" : "directory",
"path" : "\/Adir"
},
{
"kind" : "text",
"path" : "\/Zeta",
"text" : "Zeta"
},
{
"kind" : "text",
"path" : "\/a.txt",
"text" : "a.txt"
},
{
"kind" : "text",
"path" : "\/b.txt",
"text" : "b.txt"
},
{
"kind" : "text",
"path" : "\/file10",
"text" : "file10"
},
{
"kind" : "text",
"path" : "\/file2",
"text" : "file2"
},
{
"kind" : "directory",
"path" : "\/zdir"
},
{
"kind" : "text",
"path" : "\/émile",
"text" : "émile"
},
{
"kind" : "text",
"path" : "\/~",
"text" : "~"
},
{
"kind" : "text",
"path" : "\/😀",
"text" : "😀"
}
],
"name" : "listing order",
"results" : [
{
"ok" : true
},
{
"ok" : true
},
{
"ok" : true
},
{
"ok" : true
},
{
"ok" : true
},
{
"ok" : true
},
{
"ok" : true
},
{
"ok" : true
},
{
"ok" : true
},
{
"ok" : true
},
{
"ok" : [
{
"kind" : "directory",
"path" : "\/Adir"
},
{
"kind" : "directory",
"path" : "\/zdir"
},
{
"kind" : "text",
"path" : "\/Zeta",
"text" : "Zeta"
},
{
"kind" : "text",
"path" : "\/a.txt",
"text" : "a.txt"
},
{
"kind" : "text",
"path" : "\/b.txt",
"text" : "b.txt"
},
{
"kind" : "text",
"path" : "\/file10",
"text" : "file10"
},
{
"kind" : "text",
"path" : "\/file2",
"text" : "file2"
},
{
"kind" : "text",
"path" : "\/émile",
"text" : "émile"
},
{
"kind" : "text",
"path" : "\/~",
"text" : "~"
},
{
"kind" : "text",
"path" : "\/😀",
"text" : "😀"
}
]
}
]
},
{
"entries" : [
{
"kind" : "directory",
"path" : "\/"
},
{
"kind" : "directory",
"path" : "\/a"
},
{
"kind" : "directory",
"path" : "\/a\/Renamed"
},
{
"kind" : "directory",
"path" : "\/a\/Renamed\/c"
},
{
"kind" : "text",
"path" : "\/a\/Renamed\/c\/f.txt",
"text" : "f"
},
{
"kind" : "text",
"path" : "\/a\/g.txt",
"text" : "g"
}
],
"name" : "copy, move, rename and delete trees",
"results" : [
{
"ok" : true
},
{
"ok" : true
},
{
"ok" : true
},
{
"error" : "cannotMoveDirectoryIntoItself",
"values" : [
"\/a",
"\/a\/b\/x"
]
},
{
"error" : "itemAlreadyExists",
"values" : [
"\/"
]
},
{
"ok" : true
},
{
"ok" : [
{
"kind" : "text",
"path" : "\/copy\/c\/f.txt",
"text" : "f"
}
]
},
{
"ok" : true
},
{
"error" : "cannotMoveDirectoryIntoItself",
"values" : [
"\/a",
"\/a\/b"
]
},
{
"ok" : "\/a\/Renamed"
},
{
"error" : "invalidPathComponent",
"values" : [
"x\/y"
]
},
{
"error" : "directoryNotEmpty",
"values" : [
"\/a"
]
},
{
"error" : "cannotMutateRoot",
"values" : [
]
},
{
"error" : "expectedFile",
"values" : [
"\/a\/b"
]
},
{
"error" : "expectedDirectory",
"values" : [
"\/a\/g.txt"
]
},
{
"ok" : true
},
{
"error" : "expectedFile",
"values" : [
"\/a"
]
},
{
"error" : "expectedTextFile",
"values" : [
"\/a"
]
},
{
"error" : "parentDirectoryNotFound",
"values" : [
"\/a\/g.txt\/x"
]
},
{
"error" : "parentDirectoryNotFound",
"values" : [
"\/q\/r"
]
}
]
},
{
"entries" : [
{
"kind" : "directory",
"path" : "\/"
},
{
"bytes" : 10,
"kind" : "image",
"mediaType" : "image\/png",
"path" : "\/i.png",
"sha256" : "ffadf8d89d37b3b55fe1847b513cf92e3be87e4c168708c7851845df96fb36be"
}
],
"name" : "file kinds",
"results" : [
{
"ok" : true
},
{
"error" : "expectedTextFile",
"values" : [
"\/i.png"
]
},
{
"error" : "expectedFile",
"values" : [
"\/"
]
},
{
"error" : "itemNotFound",
"values" : [
"\/missing"
]
},
{
"ok" : {
"bytes" : 10,
"kind" : "image",
"mediaType" : "image\/png",
"path" : "\/i.png",
"sha256" : "ffadf8d89d37b3b55fe1847b513cf92e3be87e4c168708c7851845df96fb36be"
}
}
]
},
{
"entries" : [
{
"kind" : "directory",
"path" : "\/"
},
{
"kind" : "directory",
"path" : "\/home"
},
{
"kind" : "text",
"path" : "\/home\/lab-notes.txt",
"text" : "Document deterministic runtime notes here."
},
{
"kind" : "text",
"path" : "\/home\/topology-exports.csv",
"text" : "timestamp,event,detail\n"
},
{
"kind" : "directory",
"path" : "\/images"
},
{
"bytes" : 2352,
"kind" : "image",
"mediaType" : "image\/png",
"path" : "\/images\/network-map.png",
"sha256" : "4105bdb0131a539791f3b0f00e43341de6a5e04ab21b0cda19ac49548cd0cdd1"
},
{
"bytes" : 1478,
"kind" : "image",
"mediaType" : "image\/png",
"path" : "\/images\/switch-backplane.png",
"sha256" : "8ae2492515a66cd69d729d70d449ece4e6c23ee151f98da44c6b7862058c463f"
},
{
"bytes" : 6195,
"kind" : "image",
"mediaType" : "image\/png",
"path" : "\/images\/traffic-heatmap.png",
"sha256" : "6b541170a55a139281170fcf1176ce0b962f1ab58b1f26b9d2cff7558711825b"
},
{
"kind" : "directory",
"path" : "\/var"
},
{
"kind" : "directory",
"path" : "\/var\/log"
},
{
"kind" : "text",
"path" : "\/var\/log\/runtime-events.log",
"text" : "Virtual runtime initialized.\n"
}
],
"name" : "defaults of a new device",
"results" : [
{
"ok" : true
},
{
"ok" : [
{
"bytes" : 2352,
"kind" : "image",
"mediaType" : "image\/png",
"path" : "\/images\/network-map.png",
"sha256" : "4105bdb0131a539791f3b0f00e43341de6a5e04ab21b0cda19ac49548cd0cdd1"
},
{
"bytes" : 1478,
"kind" : "image",
"mediaType" : "image\/png",
"path" : "\/images\/switch-backplane.png",
"sha256" : "8ae2492515a66cd69d729d70d449ece4e6c23ee151f98da44c6b7862058c463f"
},
{
"bytes" : 6195,
"kind" : "image",
"mediaType" : "image\/png",
"path" : "\/images\/traffic-heatmap.png",
"sha256" : "6b541170a55a139281170fcf1176ce0b962f1ab58b1f26b9d2cff7558711825b"
}
]
}
]
},
{
"entries" : [
{
"kind" : "directory",
"path" : "\/"
},
{
"kind" : "text",
"path" : "\/a",
"text" : "a"
},
{
"kind" : "directory",
"path" : "\/b"
},
{
"kind" : "text",
"path" : "\/b\/c",
"text" : "c"
}
],
"name" : "entries need their parents",
"results" : [
{
"error" : "parentDirectoryNotFound",
"values" : [
"\/a\/b"
]
},
{
"error" : "itemAlreadyExists",
"values" : [
"\/"
]
},
{
"ok" : true
}
]
},
{
"entries" : [
{
"kind" : "directory",
"path" : "\/"
},
{
"kind" : "directory",
"path" : "\/é"
},
{
"kind" : "text",
"path" : "\/é\/x",
"text" : "x"
}
],
"name" : "canonically equal entries are one path",
"results" : [
{
"error" : "itemAlreadyExists",
"values" : [
"\/é"
]
},
{
"ok" : true
},
{
"ok" : [
{
"kind" : "directory",
"path" : "\/é"
}
]
}
]
}
]
@@ -0,0 +1,166 @@
// The virtual file system against the Swift core: every case in
// vfs.cases.json runs in Swift (vfs.swift.json, tools/swift-oracle --vfs);
// each step's result or error and the final entries must match. The cases
// include the three Swift tests on the file system, ported as cases.
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import {
VirtualFileSystem,
VirtualFileSystemError,
normalizedAbsolutePath,
type VirtualFileContent,
type VirtualFileEntry,
} from '../src/index.js';
interface Step {
op: string;
path?: string;
to?: string;
name?: string;
text?: string;
size?: number;
byte?: number;
mediaType?: string;
recursive?: boolean;
overwrite?: boolean;
entries?: { path: string; kind: string; text?: string }[];
}
const here = dirname(fileURLToPath(import.meta.url));
const cases = JSON.parse(readFileSync(join(here, 'vfs.cases.json'), 'utf8')) as { name: string; steps: Step[] }[];
const swift = JSON.parse(readFileSync(join(here, 'vfs.swift.json'), 'utf8')) as {
name: string;
results: unknown[];
entries: unknown[];
}[];
function describeEntries(entries: VirtualFileEntry[]): unknown[] {
return entries.map(({ path, content }) => ({ path, ...describeContent(content) }));
}
function describeContent(content: VirtualFileContent): Record<string, unknown> {
switch (content.kind) {
case 'directory':
return { kind: 'directory' };
case 'text':
return { kind: 'text', text: content.text };
case 'binary':
case 'image':
return {
kind: content.kind,
bytes: content.data.length,
mediaType: content.mediaType ?? null,
sha256: createHash('sha256').update(content.data).digest('hex'),
};
}
}
function run(steps: Step[]) {
let fileSystem = new VirtualFileSystem();
const results: unknown[] = [];
for (const step of steps) {
const path = step.path ?? '/';
const bytes = () => new Uint8Array(step.size ?? 0).fill(step.byte ?? 0);
try {
switch (step.op) {
case 'createDirectory':
fileSystem.createDirectory(path, step.recursive ?? false);
break;
case 'writeText':
fileSystem.writeTextFile(path, step.text ?? '', step.overwrite ?? true);
break;
case 'writeBinary':
fileSystem.writeBinaryFile(path, bytes(), step.mediaType, step.overwrite ?? true);
break;
case 'writeImage':
fileSystem.writeImageFile(path, bytes(), step.mediaType ?? 'image/png', step.overwrite ?? true);
break;
case 'copy':
fileSystem.copyItem(path, step.to ?? '/', step.overwrite ?? false);
break;
case 'move':
fileSystem.moveItem(path, step.to ?? '/', step.overwrite ?? false);
break;
case 'rename':
results.push({ ok: fileSystem.renameItem(path, step.name ?? '') });
continue;
case 'delete':
fileSystem.deleteItem(path, step.recursive ?? false);
break;
case 'entry':
results.push({ ok: describeEntries([fileSystem.entry(path)])[0] });
continue;
case 'entries':
results.push({ ok: describeEntries(fileSystem.entries(path)) });
continue;
case 'text':
results.push({ ok: fileSystem.textFile(path) });
continue;
case 'contains':
results.push({ ok: fileSystem.contains(path) });
continue;
case 'normalize':
results.push({ ok: normalizedAbsolutePath(path) });
continue;
case 'default':
fileSystem = VirtualFileSystem.defaultForDevice();
break;
case 'fromEntries':
fileSystem = VirtualFileSystem.fromEntries(
(step.entries ?? []).map((entry) => ({
path: entry.path,
content: entry.kind === 'directory' ? { kind: 'directory' } : { kind: 'text', text: entry.text ?? '' },
})),
);
break;
default:
throw new Error(`unknown step ${step.op}`);
}
results.push({ ok: true });
} catch (error) {
if (!(error instanceof VirtualFileSystemError)) throw error;
results.push({ error: error.code, values: error.values });
}
}
return { results, entries: describeEntries(fileSystem.allEntries()) };
}
describe('virtual file system against Swift', () => {
it('covers every case', () => {
expect(swift.map((result) => result.name)).toEqual(cases.map((item) => item.name));
});
cases.forEach((item, index) => {
it(item.name, () => {
const { results, entries } = run(item.steps);
const expected = swift[index]!;
results.forEach((result, step) => {
expect(result, `step ${step + 1}: ${JSON.stringify(item.steps[step])}`).toEqual(expected.results[step]);
});
expect(entries).toEqual(expected.entries);
});
});
});
describe('virtual file system', () => {
it('leaves itself unchanged when a copy breaks a quota (Swift test)', () => {
const fileSystem = new VirtualFileSystem();
const maximum = new Uint8Array(8 * 1024 * 1024).fill(0x41);
for (const name of ['source', 'destination', 'third', 'fourth']) {
fileSystem.writeBinaryFile(`/${name}.bin`, maximum, 'application/octet-stream');
}
const before = fileSystem.clone();
expect(() => fileSystem.copyItem('/source.bin', '/new.bin', true)).toThrow(VirtualFileSystemError);
expect(fileSystem.equals(before)).toBe(true);
});
it('upgrades the 1×1 black images of older versions, and only those', () => {
const fileSystem = VirtualFileSystem.defaultForDevice();
expect(fileSystem.upgradeLegacyDefaultImages()).toBe(0);
});
});
+23 -17
View File
@@ -38,6 +38,7 @@ import {
inDeterministicRenderOrder,
interfaceKey,
isSafeFrame,
swiftTrim,
topologyNode,
DOCUMENTATION_BLACK,
DOCUMENTATION_PALE_YELLOW,
@@ -235,9 +236,14 @@ function importNode(source: FlsNode, newId: () => Id, warnings: string[]): Topol
});
}
/** Swift's `value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""`. */
function trimmedOrEmpty(value: string | null | undefined): string {
return value == null ? '' : swiftTrim(value);
}
/** Swift's normalizedOrDefault; Java's null (a rejected address) counts as empty. */
function orDefault(value: string | null, fallback: string): string {
const trimmed = value?.trim() ?? '';
const trimmed = trimmedOrEmpty(value);
return trimmed === '' ? fallback : trimmed;
}
@@ -253,8 +259,8 @@ function registerConfigurations(
deviceConfigurations.set(node.id, {
ipAddress: orDefault(nic?.ip ?? null, '192.168.0.10'),
subnetMask: orDefault(nic?.mask ?? null, '255.255.255.0'),
defaultGateway: nic?.gateway?.trim() ?? '',
dnsServer: nic?.dns?.trim() ?? '',
defaultGateway: trimmedOrEmpty(nic?.gateway),
dnsServer: trimmedOrEmpty(nic?.dns),
});
return;
}
@@ -272,14 +278,14 @@ function registerConfigurations(
// Swift's model has no router gateway at all; the core reads it from the
// router's device configuration, which is set only when there is one.
const first = source.interfaces[0];
const routerGateway = first?.gateway?.trim() ?? '';
const routerGateway = trimmedOrEmpty(first?.gateway);
if (node.kind === 'router' && first !== undefined && routerGateway !== '') {
const firstConfiguration = interfaceConfigurations.get(interfaceKey(node.id, node.ports[0]!.id));
deviceConfigurations.set(node.id, {
ipAddress: firstConfiguration?.ipAddress ?? '',
subnetMask: firstConfiguration?.subnetMask ?? '',
defaultGateway: routerGateway,
dnsServer: first.dns?.trim() ?? '',
dnsServer: trimmedOrEmpty(first.dns),
});
}
const wan = source.interfaces[0];
@@ -288,8 +294,8 @@ function registerConfigurations(
deviceConfigurations.set(node.id, {
ipAddress: wanConfiguration?.ipAddress ?? '',
subnetMask: wanConfiguration?.subnetMask ?? '',
defaultGateway: wan.gateway?.trim() ?? '',
dnsServer: wan.dns?.trim() ?? '',
defaultGateway: trimmedOrEmpty(wan.gateway),
dnsServer: trimmedOrEmpty(wan.dns),
});
}
}
@@ -310,7 +316,7 @@ type SystemConfigurations = Pick<
/** A row of text cells, trimmed, or undefined unless it has exactly four. */
function fourCells(row: (string | null)[]): [string, string, string, string] | undefined {
if (row.length !== 4 || row.some((cell) => cell === null)) return undefined;
return row.map((cell) => cell!.trim()) as [string, string, string, string];
return row.map((cell) => swiftTrim(cell!)) as [string, string, string, string];
}
/**
@@ -334,10 +340,10 @@ function registerServiceConfigurations(
if (server !== undefined) {
const configuration = dhcpServerConfiguration({
isActive: server.isActive,
lowerBoundIPAddress: server.lowerBound?.trim() ?? '',
upperBoundIPAddress: server.upperBound?.trim() ?? '',
gatewayIPAddress: server.gateway?.trim() ?? '',
dnsServerIPAddress: server.dnsServer?.trim() ?? '',
lowerBoundIPAddress: trimmedOrEmpty(server.lowerBound),
upperBoundIPAddress: trimmedOrEmpty(server.upperBound),
gatewayIPAddress: trimmedOrEmpty(server.gateway),
dnsServerIPAddress: trimmedOrEmpty(server.dnsServer),
useOwnSettings: server.useOwnSettings,
staticAssignments: server.staticAssignedAddresses.map((entry) => ({
id: newId(),
@@ -393,7 +399,7 @@ function registerSystemConfigurations(
warnings: string[],
): void {
const where = `node '${node.displayName}'`;
const ssid = source.system.ssid?.trim() ?? '';
const ssid = trimmedOrEmpty(source.system.ssid);
if (node.kind === 'networkSwitch') {
// Without an SSID in the file, Java rolls one; Swift derives it from the node id.
system.switchConfigurations.set(node.id, {
@@ -435,10 +441,10 @@ function firewallConfigurationOf(firewall: FlsFirewall, where: string, warnings:
const protocol = PROTOCOLS[rule.protocol];
if (protocol === undefined) warnings.push(`${where}: firewall protocol ${rule.protocol} unknown, used TCP`);
return {
sourceIPAddress: rule.srcIP?.trim() ?? '',
sourceSubnetMask: rule.srcMask?.trim() ?? '',
destinationIPAddress: rule.destIP?.trim() ?? '',
destinationSubnetMask: rule.destMask?.trim() ?? '',
sourceIPAddress: trimmedOrEmpty(rule.srcIP),
sourceSubnetMask: trimmedOrEmpty(rule.srcMask),
destinationIPAddress: trimmedOrEmpty(rule.destIP),
destinationSubnetMask: trimmedOrEmpty(rule.destMask),
port: rule.port,
protocol: protocol ?? 'tcp',
action: action(rule.action, 'action'),