From a198d10b4a61880ceeccf59586d0b2060a2c1769 Mon Sep 17 00:00:00 2001 From: Julian Kiedaisch Date: Tue, 29 Sep 2026 21:23:45 +0000 Subject: [PATCH] feat(core): virtual file system and Swift string semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGES.md | 6 +- packages/core/src/index.ts | 2 + packages/core/src/model/defaultImages.ts | 163 ++ packages/core/src/model/virtualFileSystem.ts | 583 +++++ packages/core/src/util/swiftString.ts | 121 + packages/core/test/strings.cases.json | 130 + packages/core/test/strings.swift.json | 2340 ++++++++++++++++++ packages/core/test/strings.test.ts | 96 + packages/core/test/vfs.cases.json | 569 +++++ packages/core/test/vfs.swift.json | 863 +++++++ packages/core/test/virtualFileSystem.test.ts | 166 ++ packages/fls/src/importProject.ts | 40 +- tools/swift-oracle/README.md | 11 + tools/swift-oracle/Sources/Oracle/main.swift | 173 ++ tools/swift-oracle/run.sh | 2 + 15 files changed, 5247 insertions(+), 18 deletions(-) create mode 100644 packages/core/src/model/defaultImages.ts create mode 100644 packages/core/src/model/virtualFileSystem.ts create mode 100644 packages/core/src/util/swiftString.ts create mode 100644 packages/core/test/strings.cases.json create mode 100644 packages/core/test/strings.swift.json create mode 100644 packages/core/test/strings.test.ts create mode 100644 packages/core/test/vfs.cases.json create mode 100644 packages/core/test/vfs.swift.json create mode 100644 packages/core/test/virtualFileSystem.test.ts diff --git a/CHANGES.md b/CHANGES.md index 4e55863..039afce 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -22,6 +22,10 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand | `src/runtime/speed.ts` | Engine, `TopologyNetworkRuntimeSpeed` | übertragen | | `src/runtime/trace.ts` | Engine, Paketspur-Modell | übertragen | | `src/util/equal.ts` | — | neu (Ersatz für Swifts `Equatable`) | +| `src/util/swiftString.ts` | — | neu: Swift-String-Verhalten, das der übertragene Code stillschweigend voraussetzt (Trimmen, Kleinschreibung je Skalar, kanonische Gleichheit, Sortierung nach Skalarwerten, Base64 wie Foundation); am Swift-Orakel gemessen, Leerraum und Steuerzeichen über ganz Unicode | +| `src/model/virtualFileSystem.ts`, `src/model/defaultImages.ts` | `State/TopologyVirtualFileSystem.swift` | übertragen; Schlüssel in NFC statt Swifts kanonischer Wörterbuch-Gleichheit; Steuerzeichen nach Unicode Cc+Cf, ohne Foundations Tabellenfehler in Ebene 14 | +| `test/virtualFileSystem.test.ts`, `test/vfs.*.json` | `TopologyProjectPersistenceTests.swift`, `TopologyEditorReducerTests.swift` (drei Dateisystem-Tests, als Fälle) | übertragen und erweitert: 11 Fälle, Ergebnis aus dem Swift-Kern | +| `test/strings.test.ts`, `test/strings.*.json` | — | neu: Fälle und ganze Zeichenmengen, Ergebnis aus dem Swift-Kern | | `test/engine.test.ts` | `TopologyNetworkRuntimeEngineTests.swift` (21 von 72 Tests) | übertragen; `nextInt`-Referenzwerte an OpenJDK 21 gemessen | | `test/oracle.test.ts`, `test/scenarios/` | — | neu: 5 Szenarien, Ergebnis mit dem Swift-Kern abgeglichen (`tools/swift-oracle`) | | `test/routeTable.test.ts` | `TopologyJavaRouteTableTests.swift` (8 von 12 Tests) | übertragen; Geometrie- und Reducer-Tests folgen mit Editor und Dokument | @@ -33,7 +37,7 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand | `src/zip.ts` | `Persistence/TopologyFLSArchive.swift` (Fehler, Grenzen, Pfadregeln, ZIP-Leser/-Schreiber, CRC-32) | übertragen; DEFLATE über `fflate`, mit Apples Abschneide-Verhalten nachgebildet; Pfadfaltung (NFC, Breite, volles Case-Folding) an 41 Paaren gegen das Swift-Orakel gemessen | | `src/archive.ts` | ebenda, Archiv-Teil von Import und Export | übertragen, ohne die XML-Ebene | | `src/javabeans.ts`, `src/javaDefaults.json` | — | neu: XMLEncoder-Leser nach den Regeln von `java.beans.XMLDecoder` (Swift liest elementweise und verliert geteilte Werte); Getter-Standardwerte und statische Konstanten mit `tools/java-oracle` gemessen | -| `src/importProject.ts` | `Persistence/TopologyProjectStore.swift` (Knoten, Ports, Kabel, IP-Konfiguration, Switch/WLAN, Routen, RIP, Firewall, DHCP, Portweiterleitung, Modem, Doku-Elemente des Imports) | übertragen, Eingabe ist Javas Sicht: Name und Port-Anzahl wie Java, abgelehnte Adresse → Swift-Standard, Router-Standardgateway wie Java (Swift verliert es), jede Router-Firewall eingetragen, Konfigurationstexte wie in Swift getrimmt; gegen Swifts importiertes Modell auf 13 Dateien geprüft | +| `src/importProject.ts` | `Persistence/TopologyProjectStore.swift` (Knoten, Ports, Kabel, IP-Konfiguration, Switch/WLAN, Routen, RIP, Firewall, DHCP, Portweiterleitung, Modem, Doku-Elemente des Imports) | übertragen, Eingabe ist Javas Sicht: Name und Port-Anzahl wie Java, abgelehnte Adresse → Swift-Standard, Router-Standardgateway wie Java (Swift verliert es), jede Router-Firewall eingetragen, Konfigurationstexte wie in Swift getrimmt (`swiftTrim`); gegen Swifts importiertes Modell auf 13 Dateien geprüft | | `test/importProject.test.ts` | — | neu: Swifts importiertes Modell (13 Dateien, zwei begründete Abweichungen), Vollständigkeit aller 23, Pings im Kern (über einen Router; über drei Router mit manuellen Routen und Router-Gateways) | | `src/systemSoftware.ts` | Java `SwitchFirmware`, `Betriebssystem`, `VermittlungsrechnerBetriebssystem`, `GatewayFirmware`, `Weiterleitungstabelle`, `Firewall`, `FirewallRule`, `DHCPServer`, `ModemFirmware`, `Dateisystem`, `Datei` | neu nach Java: SSID, Lernzeit, RIP, manuelle Routen, Router-Firewall (an allen Stellen, an denen die Datei sie erreicht), DHCP, statische NAT, Modem, Dateisystem von Rechner und Notebook; `Character.isWhitespace` am JDK gemessen; gegen Java geprüft | | `src/javaIp.ts` | Java `IP.inetAton`/`ipCheck` | neu nach Java; an 46 Fällen mit Java gemessen | diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index efac237..9f8671a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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'; diff --git a/packages/core/src/model/defaultImages.ts b/packages/core/src/model/defaultImages.ts new file mode 100644 index 0000000..b68d692 --- /dev/null +++ b/packages/core/src/model/defaultImages.ts @@ -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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', + }, +]; diff --git a/packages/core/src/model/virtualFileSystem.ts b/packages/core/src/model/virtualFileSystem.ts new file mode 100644 index 0000000..9a1ec74 --- /dev/null +++ b/packages/core/src/model/virtualFileSystem.ts @@ -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; + +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): 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); + } + } +} diff --git a/packages/core/src/util/swiftString.ts b/packages/core/src/util/swiftString.ts new file mode 100644 index 0000000..8336669 --- /dev/null +++ b/packages/core/src/util/swiftString.ts @@ -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; +} diff --git a/packages/core/test/strings.cases.json b/packages/core/test/strings.cases.json new file mode 100644 index 0000000..d8d1afd --- /dev/null +++ b/packages/core/test/strings.cases.json @@ -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" + ] +} \ No newline at end of file diff --git a/packages/core/test/strings.swift.json b/packages/core/test/strings.swift.json new file mode 100644 index 0000000..8f0b2c0 --- /dev/null +++ b/packages/core/test/strings.swift.json @@ -0,0 +1,2340 @@ +{ + "base64" : [ + [ + 104, + 101, + 108, + 108, + 111 + ], + null, + [ + 104, + 101, + 108, + 108, + 111 + ], + [ + 104, + 101, + 108, + 108, + 111 + ], + null, + [ + + ], + [ + + ], + null, + [ + 97 + ], + null, + null, + [ + 97 + ], + null, + [ + 97, + 98, + 99 + ], + [ + 97 + ], + [ + 97 + ], + [ + + ], + null, + [ + 97, + 98 + ], + [ + 97 + ], + null, + [ + + ], + [ + 0 + ], + [ + 0, + 0 + ], + [ + 0 + ], + [ + 0, + 16 + ], + null, + [ + 97 + ], + null, + null, + null, + null, + null, + null + ], + "controlCharacters" : [ + false, + true, + true, + true, + true, + true, + false, + false + ], + "controlCharactersSet" : [ + [ + 0, + 31 + ], + [ + 127, + 159 + ], + [ + 173, + 173 + ], + [ + 1536, + 1541 + ], + [ + 1564, + 1564 + ], + [ + 1757, + 1757 + ], + [ + 1807, + 1807 + ], + [ + 2192, + 2193 + ], + [ + 2274, + 2274 + ], + [ + 6158, + 6158 + ], + [ + 8203, + 8207 + ], + [ + 8234, + 8238 + ], + [ + 8288, + 8292 + ], + [ + 8294, + 8303 + ], + [ + 65279, + 65279 + ], + [ + 65529, + 65531 + ], + [ + 69821, + 69821 + ], + [ + 69837, + 69837 + ], + [ + 78896, + 78911 + ], + [ + 113824, + 113827 + ], + [ + 119155, + 119162 + ], + [ + 917505, + 917505 + ], + [ + 917536, + 917631 + ], + [ + 917761, + 917761 + ], + [ + 917792, + 917887 + ], + [ + 918017, + 918017 + ], + [ + 918048, + 918143 + ], + [ + 918273, + 918273 + ], + [ + 918304, + 918399 + ], + [ + 918529, + 918529 + ], + [ + 918560, + 918655 + ], + [ + 918785, + 918785 + ], + [ + 918816, + 918911 + ], + [ + 919041, + 919041 + ], + [ + 919072, + 919167 + ], + [ + 919297, + 919297 + ], + [ + 919328, + 919423 + ], + [ + 919553, + 919553 + ], + [ + 919584, + 919679 + ], + [ + 919809, + 919809 + ], + [ + 919840, + 919935 + ], + [ + 920065, + 920065 + ], + [ + 920096, + 920191 + ], + [ + 920321, + 920321 + ], + [ + 920352, + 920447 + ], + [ + 920577, + 920577 + ], + [ + 920608, + 920703 + ], + [ + 920833, + 920833 + ], + [ + 920864, + 920959 + ], + [ + 921089, + 921089 + ], + [ + 921120, + 921215 + ], + [ + 921345, + 921345 + ], + [ + 921376, + 921471 + ], + [ + 921601, + 921601 + ], + [ + 921632, + 921727 + ], + [ + 921857, + 921857 + ], + [ + 921888, + 921983 + ], + [ + 922113, + 922113 + ], + [ + 922144, + 922239 + ], + [ + 922369, + 922369 + ], + [ + 922400, + 922495 + ], + [ + 922625, + 922625 + ], + [ + 922656, + 922751 + ], + [ + 922881, + 922881 + ], + [ + 922912, + 923007 + ], + [ + 923137, + 923137 + ], + [ + 923168, + 923263 + ], + [ + 923393, + 923393 + ], + [ + 923424, + 923519 + ], + [ + 923649, + 923649 + ], + [ + 923680, + 923775 + ], + [ + 923905, + 923905 + ], + [ + 923936, + 924031 + ], + [ + 924161, + 924161 + ], + [ + 924192, + 924287 + ], + [ + 924417, + 924417 + ], + [ + 924448, + 924543 + ], + [ + 924673, + 924673 + ], + [ + 924704, + 924799 + ], + [ + 924929, + 924929 + ], + [ + 924960, + 925055 + ], + [ + 925185, + 925185 + ], + [ + 925216, + 925311 + ], + [ + 925441, + 925441 + ], + [ + 925472, + 925567 + ], + [ + 925697, + 925697 + ], + [ + 925728, + 925823 + ], + [ + 925953, + 925953 + ], + [ + 925984, + 926079 + ], + [ + 926209, + 926209 + ], + [ + 926240, + 926335 + ], + [ + 926465, + 926465 + ], + [ + 926496, + 926591 + ], + [ + 926721, + 926721 + ], + [ + 926752, + 926847 + ], + [ + 926977, + 926977 + ], + [ + 927008, + 927103 + ], + [ + 927233, + 927233 + ], + [ + 927264, + 927359 + ], + [ + 927489, + 927489 + ], + [ + 927520, + 927615 + ], + [ + 927745, + 927745 + ], + [ + 927776, + 927871 + ], + [ + 928001, + 928001 + ], + [ + 928032, + 928127 + ], + [ + 928257, + 928257 + ], + [ + 928288, + 928383 + ], + [ + 928513, + 928513 + ], + [ + 928544, + 928639 + ], + [ + 928769, + 928769 + ], + [ + 928800, + 928895 + ], + [ + 929025, + 929025 + ], + [ + 929056, + 929151 + ], + [ + 929281, + 929281 + ], + [ + 929312, + 929407 + ], + [ + 929537, + 929537 + ], + [ + 929568, + 929663 + ], + [ + 929793, + 929793 + ], + [ + 929824, + 929919 + ], + [ + 930049, + 930049 + ], + [ + 930080, + 930175 + ], + [ + 930305, + 930305 + ], + [ + 930336, + 930431 + ], + [ + 930561, + 930561 + ], + [ + 930592, + 930687 + ], + [ + 930817, + 930817 + ], + [ + 930848, + 930943 + ], + [ + 931073, + 931073 + ], + [ + 931104, + 931199 + ], + [ + 931329, + 931329 + ], + [ + 931360, + 931455 + ], + [ + 931585, + 931585 + ], + [ + 931616, + 931711 + ], + [ + 931841, + 931841 + ], + [ + 931872, + 931967 + ], + [ + 932097, + 932097 + ], + [ + 932128, + 932223 + ], + [ + 932353, + 932353 + ], + [ + 932384, + 932479 + ], + [ + 932609, + 932609 + ], + [ + 932640, + 932735 + ], + [ + 932865, + 932865 + ], + [ + 932896, + 932991 + ], + [ + 933121, + 933121 + ], + [ + 933152, + 933247 + ], + [ + 933377, + 933377 + ], + [ + 933408, + 933503 + ], + [ + 933633, + 933633 + ], + [ + 933664, + 933759 + ], + [ + 933889, + 933889 + ], + [ + 933920, + 934015 + ], + [ + 934145, + 934145 + ], + [ + 934176, + 934271 + ], + [ + 934401, + 934401 + ], + [ + 934432, + 934527 + ], + [ + 934657, + 934657 + ], + [ + 934688, + 934783 + ], + [ + 934913, + 934913 + ], + [ + 934944, + 935039 + ], + [ + 935169, + 935169 + ], + [ + 935200, + 935295 + ], + [ + 935425, + 935425 + ], + [ + 935456, + 935551 + ], + [ + 935681, + 935681 + ], + [ + 935712, + 935807 + ], + [ + 935937, + 935937 + ], + [ + 935968, + 936063 + ], + [ + 936193, + 936193 + ], + [ + 936224, + 936319 + ], + [ + 936449, + 936449 + ], + [ + 936480, + 936575 + ], + [ + 936705, + 936705 + ], + [ + 936736, + 936831 + ], + [ + 936961, + 936961 + ], + [ + 936992, + 937087 + ], + [ + 937217, + 937217 + ], + [ + 937248, + 937343 + ], + [ + 937473, + 937473 + ], + [ + 937504, + 937599 + ], + [ + 937729, + 937729 + ], + [ + 937760, + 937855 + ], + [ + 937985, + 937985 + ], + [ + 938016, + 938111 + ], + [ + 938241, + 938241 + ], + [ + 938272, + 938367 + ], + [ + 938497, + 938497 + ], + [ + 938528, + 938623 + ], + [ + 938753, + 938753 + ], + [ + 938784, + 938879 + ], + [ + 939009, + 939009 + ], + [ + 939040, + 939135 + ], + [ + 939265, + 939265 + ], + [ + 939296, + 939391 + ], + [ + 939521, + 939521 + ], + [ + 939552, + 939647 + ], + [ + 939777, + 939777 + ], + [ + 939808, + 939903 + ], + [ + 940033, + 940033 + ], + [ + 940064, + 940159 + ], + [ + 940289, + 940289 + ], + [ + 940320, + 940415 + ], + [ + 940545, + 940545 + ], + [ + 940576, + 940671 + ], + [ + 940801, + 940801 + ], + [ + 940832, + 940927 + ], + [ + 941057, + 941057 + ], + [ + 941088, + 941183 + ], + [ + 941313, + 941313 + ], + [ + 941344, + 941439 + ], + [ + 941569, + 941569 + ], + [ + 941600, + 941695 + ], + [ + 941825, + 941825 + ], + [ + 941856, + 941951 + ], + [ + 942081, + 942081 + ], + [ + 942112, + 942207 + ], + [ + 942337, + 942337 + ], + [ + 942368, + 942463 + ], + [ + 942593, + 942593 + ], + [ + 942624, + 942719 + ], + [ + 942849, + 942849 + ], + [ + 942880, + 942975 + ], + [ + 943105, + 943105 + ], + [ + 943136, + 943231 + ], + [ + 943361, + 943361 + ], + [ + 943392, + 943487 + ], + [ + 943617, + 943617 + ], + [ + 943648, + 943743 + ], + [ + 943873, + 943873 + ], + [ + 943904, + 943999 + ], + [ + 944129, + 944129 + ], + [ + 944160, + 944255 + ], + [ + 944385, + 944385 + ], + [ + 944416, + 944511 + ], + [ + 944641, + 944641 + ], + [ + 944672, + 944767 + ], + [ + 944897, + 944897 + ], + [ + 944928, + 945023 + ], + [ + 945153, + 945153 + ], + [ + 945184, + 945279 + ], + [ + 945409, + 945409 + ], + [ + 945440, + 945535 + ], + [ + 945665, + 945665 + ], + [ + 945696, + 945791 + ], + [ + 945921, + 945921 + ], + [ + 945952, + 946047 + ], + [ + 946177, + 946177 + ], + [ + 946208, + 946303 + ], + [ + 946433, + 946433 + ], + [ + 946464, + 946559 + ], + [ + 946689, + 946689 + ], + [ + 946720, + 946815 + ], + [ + 946945, + 946945 + ], + [ + 946976, + 947071 + ], + [ + 947201, + 947201 + ], + [ + 947232, + 947327 + ], + [ + 947457, + 947457 + ], + [ + 947488, + 947583 + ], + [ + 947713, + 947713 + ], + [ + 947744, + 947839 + ], + [ + 947969, + 947969 + ], + [ + 948000, + 948095 + ], + [ + 948225, + 948225 + ], + [ + 948256, + 948351 + ], + [ + 948481, + 948481 + ], + [ + 948512, + 948607 + ], + [ + 948737, + 948737 + ], + [ + 948768, + 948863 + ], + [ + 948993, + 948993 + ], + [ + 949024, + 949119 + ], + [ + 949249, + 949249 + ], + [ + 949280, + 949375 + ], + [ + 949505, + 949505 + ], + [ + 949536, + 949631 + ], + [ + 949761, + 949761 + ], + [ + 949792, + 949887 + ], + [ + 950017, + 950017 + ], + [ + 950048, + 950143 + ], + [ + 950273, + 950273 + ], + [ + 950304, + 950399 + ], + [ + 950529, + 950529 + ], + [ + 950560, + 950655 + ], + [ + 950785, + 950785 + ], + [ + 950816, + 950911 + ], + [ + 951041, + 951041 + ], + [ + 951072, + 951167 + ], + [ + 951297, + 951297 + ], + [ + 951328, + 951423 + ], + [ + 951553, + 951553 + ], + [ + 951584, + 951679 + ], + [ + 951809, + 951809 + ], + [ + 951840, + 951935 + ], + [ + 952065, + 952065 + ], + [ + 952096, + 952191 + ], + [ + 952321, + 952321 + ], + [ + 952352, + 952447 + ], + [ + 952577, + 952577 + ], + [ + 952608, + 952703 + ], + [ + 952833, + 952833 + ], + [ + 952864, + 952959 + ], + [ + 953089, + 953089 + ], + [ + 953120, + 953215 + ], + [ + 953345, + 953345 + ], + [ + 953376, + 953471 + ], + [ + 953601, + 953601 + ], + [ + 953632, + 953727 + ], + [ + 953857, + 953857 + ], + [ + 953888, + 953983 + ], + [ + 954113, + 954113 + ], + [ + 954144, + 954239 + ], + [ + 954369, + 954369 + ], + [ + 954400, + 954495 + ], + [ + 954625, + 954625 + ], + [ + 954656, + 954751 + ], + [ + 954881, + 954881 + ], + [ + 954912, + 955007 + ], + [ + 955137, + 955137 + ], + [ + 955168, + 955263 + ], + [ + 955393, + 955393 + ], + [ + 955424, + 955519 + ], + [ + 955649, + 955649 + ], + [ + 955680, + 955775 + ], + [ + 955905, + 955905 + ], + [ + 955936, + 956031 + ], + [ + 956161, + 956161 + ], + [ + 956192, + 956287 + ], + [ + 956417, + 956417 + ], + [ + 956448, + 956543 + ], + [ + 956673, + 956673 + ], + [ + 956704, + 956799 + ], + [ + 956929, + 956929 + ], + [ + 956960, + 957055 + ], + [ + 957185, + 957185 + ], + [ + 957216, + 957311 + ], + [ + 957441, + 957441 + ], + [ + 957472, + 957567 + ], + [ + 957697, + 957697 + ], + [ + 957728, + 957823 + ], + [ + 957953, + 957953 + ], + [ + 957984, + 958079 + ], + [ + 958209, + 958209 + ], + [ + 958240, + 958335 + ], + [ + 958465, + 958465 + ], + [ + 958496, + 958591 + ], + [ + 958721, + 958721 + ], + [ + 958752, + 958847 + ], + [ + 958977, + 958977 + ], + [ + 959008, + 959103 + ], + [ + 959233, + 959233 + ], + [ + 959264, + 959359 + ], + [ + 959489, + 959489 + ], + [ + 959520, + 959615 + ], + [ + 959745, + 959745 + ], + [ + 959776, + 959871 + ], + [ + 960001, + 960001 + ], + [ + 960032, + 960127 + ], + [ + 960257, + 960257 + ], + [ + 960288, + 960383 + ], + [ + 960513, + 960513 + ], + [ + 960544, + 960639 + ], + [ + 960769, + 960769 + ], + [ + 960800, + 960895 + ], + [ + 961025, + 961025 + ], + [ + 961056, + 961151 + ], + [ + 961281, + 961281 + ], + [ + 961312, + 961407 + ], + [ + 961537, + 961537 + ], + [ + 961568, + 961663 + ], + [ + 961793, + 961793 + ], + [ + 961824, + 961919 + ], + [ + 962049, + 962049 + ], + [ + 962080, + 962175 + ], + [ + 962305, + 962305 + ], + [ + 962336, + 962431 + ], + [ + 962561, + 962561 + ], + [ + 962592, + 962687 + ], + [ + 962817, + 962817 + ], + [ + 962848, + 962943 + ], + [ + 963073, + 963073 + ], + [ + 963104, + 963199 + ], + [ + 963329, + 963329 + ], + [ + 963360, + 963455 + ], + [ + 963585, + 963585 + ], + [ + 963616, + 963711 + ], + [ + 963841, + 963841 + ], + [ + 963872, + 963967 + ], + [ + 964097, + 964097 + ], + [ + 964128, + 964223 + ], + [ + 964353, + 964353 + ], + [ + 964384, + 964479 + ], + [ + 964609, + 964609 + ], + [ + 964640, + 964735 + ], + [ + 964865, + 964865 + ], + [ + 964896, + 964991 + ], + [ + 965121, + 965121 + ], + [ + 965152, + 965247 + ], + [ + 965377, + 965377 + ], + [ + 965408, + 965503 + ], + [ + 965633, + 965633 + ], + [ + 965664, + 965759 + ], + [ + 965889, + 965889 + ], + [ + 965920, + 966015 + ], + [ + 966145, + 966145 + ], + [ + 966176, + 966271 + ], + [ + 966401, + 966401 + ], + [ + 966432, + 966527 + ], + [ + 966657, + 966657 + ], + [ + 966688, + 966783 + ], + [ + 966913, + 966913 + ], + [ + 966944, + 967039 + ], + [ + 967169, + 967169 + ], + [ + 967200, + 967295 + ], + [ + 967425, + 967425 + ], + [ + 967456, + 967551 + ], + [ + 967681, + 967681 + ], + [ + 967712, + 967807 + ], + [ + 967937, + 967937 + ], + [ + 967968, + 968063 + ], + [ + 968193, + 968193 + ], + [ + 968224, + 968319 + ], + [ + 968449, + 968449 + ], + [ + 968480, + 968575 + ], + [ + 968705, + 968705 + ], + [ + 968736, + 968831 + ], + [ + 968961, + 968961 + ], + [ + 968992, + 969087 + ], + [ + 969217, + 969217 + ], + [ + 969248, + 969343 + ], + [ + 969473, + 969473 + ], + [ + 969504, + 969599 + ], + [ + 969729, + 969729 + ], + [ + 969760, + 969855 + ], + [ + 969985, + 969985 + ], + [ + 970016, + 970111 + ], + [ + 970241, + 970241 + ], + [ + 970272, + 970367 + ], + [ + 970497, + 970497 + ], + [ + 970528, + 970623 + ], + [ + 970753, + 970753 + ], + [ + 970784, + 970879 + ], + [ + 971009, + 971009 + ], + [ + 971040, + 971135 + ], + [ + 971265, + 971265 + ], + [ + 971296, + 971391 + ], + [ + 971521, + 971521 + ], + [ + 971552, + 971647 + ], + [ + 971777, + 971777 + ], + [ + 971808, + 971903 + ], + [ + 972033, + 972033 + ], + [ + 972064, + 972159 + ], + [ + 972289, + 972289 + ], + [ + 972320, + 972415 + ], + [ + 972545, + 972545 + ], + [ + 972576, + 972671 + ], + [ + 972801, + 972801 + ], + [ + 972832, + 972927 + ], + [ + 973057, + 973057 + ], + [ + 973088, + 973183 + ], + [ + 973313, + 973313 + ], + [ + 973344, + 973439 + ], + [ + 973569, + 973569 + ], + [ + 973600, + 973695 + ], + [ + 973825, + 973825 + ], + [ + 973856, + 973951 + ], + [ + 974081, + 974081 + ], + [ + 974112, + 974207 + ], + [ + 974337, + 974337 + ], + [ + 974368, + 974463 + ], + [ + 974593, + 974593 + ], + [ + 974624, + 974719 + ], + [ + 974849, + 974849 + ], + [ + 974880, + 974975 + ], + [ + 975105, + 975105 + ], + [ + 975136, + 975231 + ], + [ + 975361, + 975361 + ], + [ + 975392, + 975487 + ], + [ + 975617, + 975617 + ], + [ + 975648, + 975743 + ], + [ + 975873, + 975873 + ], + [ + 975904, + 975999 + ], + [ + 976129, + 976129 + ], + [ + 976160, + 976255 + ], + [ + 976385, + 976385 + ], + [ + 976416, + 976511 + ], + [ + 976641, + 976641 + ], + [ + 976672, + 976767 + ], + [ + 976897, + 976897 + ], + [ + 976928, + 977023 + ], + [ + 977153, + 977153 + ], + [ + 977184, + 977279 + ], + [ + 977409, + 977409 + ], + [ + 977440, + 977535 + ], + [ + 977665, + 977665 + ], + [ + 977696, + 977791 + ], + [ + 977921, + 977921 + ], + [ + 977952, + 978047 + ], + [ + 978177, + 978177 + ], + [ + 978208, + 978303 + ], + [ + 978433, + 978433 + ], + [ + 978464, + 978559 + ], + [ + 978689, + 978689 + ], + [ + 978720, + 978815 + ], + [ + 978945, + 978945 + ], + [ + 978976, + 979071 + ], + [ + 979201, + 979201 + ], + [ + 979232, + 979327 + ], + [ + 979457, + 979457 + ], + [ + 979488, + 979583 + ], + [ + 979713, + 979713 + ], + [ + 979744, + 979839 + ], + [ + 979969, + 979969 + ], + [ + 980000, + 980095 + ], + [ + 980225, + 980225 + ], + [ + 980256, + 980351 + ], + [ + 980481, + 980481 + ], + [ + 980512, + 980607 + ], + [ + 980737, + 980737 + ], + [ + 980768, + 980863 + ], + [ + 980993, + 980993 + ], + [ + 981024, + 981119 + ], + [ + 981249, + 981249 + ], + [ + 981280, + 981375 + ], + [ + 981505, + 981505 + ], + [ + 981536, + 981631 + ], + [ + 981761, + 981761 + ], + [ + 981792, + 981887 + ], + [ + 982017, + 982017 + ], + [ + 982048, + 982143 + ], + [ + 982273, + 982273 + ], + [ + 982304, + 982399 + ], + [ + 982529, + 982529 + ], + [ + 982560, + 982655 + ], + [ + 982785, + 982785 + ], + [ + 982816, + 982911 + ] + ], + "equal" : [ + true, + true, + false, + true, + true, + false, + true + ], + "lowercased" : [ + "abc", + "σασ", + "ασ x", + "i̇", + "ß", + "dž", + "äöü", + "k" + ], + "sort" : [ + [ + "B", + "a", + "b" + ], + [ + "~", + "😀" + ], + [ + "éa", + "éx" + ], + [ + "A", + "Z", + "a", + "á" + ], + [ + "File1", + "file10", + "file2" + ] + ], + "trim" : [ + "a", + "a", + "a", + "a", + "a", + "a", + "a", + "a", + "\u001ca", + "a", + "́a", + "a ́", + "᠎a", + "a" + ], + "whitespacesAndNewlinesSet" : [ + [ + 9, + 13 + ], + [ + 32, + 32 + ], + [ + 133, + 133 + ], + [ + 160, + 160 + ], + [ + 5760, + 5760 + ], + [ + 8192, + 8203 + ], + [ + 8232, + 8233 + ], + [ + 8239, + 8239 + ], + [ + 8287, + 8287 + ], + [ + 12288, + 12288 + ] + ] +} diff --git a/packages/core/test/strings.test.ts b/packages/core/test/strings.test.ts new file mode 100644 index 0000000..2f4d1b2 --- /dev/null +++ b/packages/core/test/strings.test.ts @@ -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); + } + }); +}); diff --git a/packages/core/test/vfs.cases.json b/packages/core/test/vfs.cases.json new file mode 100644 index 0000000..307625c --- /dev/null +++ b/packages/core/test/vfs.cases.json @@ -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": "/" + } + ] + } +] \ No newline at end of file diff --git a/packages/core/test/vfs.swift.json b/packages/core/test/vfs.swift.json new file mode 100644 index 0000000..cb41da9 --- /dev/null +++ b/packages/core/test/vfs.swift.json @@ -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" : "\/é" + } + ] + } + ] + } +] diff --git a/packages/core/test/virtualFileSystem.test.ts b/packages/core/test/virtualFileSystem.test.ts new file mode 100644 index 0000000..850249b --- /dev/null +++ b/packages/core/test/virtualFileSystem.test.ts @@ -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 { + 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); + }); +}); diff --git a/packages/fls/src/importProject.ts b/packages/fls/src/importProject.ts index e82fe62..1c74ec4 100644 --- a/packages/fls/src/importProject.ts +++ b/packages/fls/src/importProject.ts @@ -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'), diff --git a/tools/swift-oracle/README.md b/tools/swift-oracle/README.md index 244076a..8088ea0 100644 --- a/tools/swift-oracle/README.md +++ b/tools/swift-oracle/README.md @@ -27,6 +27,17 @@ und unter `state` Swifts ganzes importiertes Modell ohne die zufälligen UUIDs ihren abgeleiteten IDs). Daran misst `packages/fls/test/importProject.test.ts` den Umbau — auf den 13 Dateien, die Swift vollständig liest. +## Dateisystem und Strings + +`--vfs` spielt die Fälle aus `packages/core/test/vfs.cases.json` im +virtuellen Dateisystem ab (jedes Ergebnis, jeder Fehler samt Werten, der +Endzustand mit SHA-256 der Binärdaten). `--strings` misst, was Swift aus +Strings macht, wo JavaScript abweicht: Trimmen, Kleinschreibung, Gleichheit, +Sortierung, Base64 — und die ganzen Mengen `whitespacesAndNewlines` und +`controlCharacters` über alle Unicode-Skalare. Grenze: Das ist Foundation +unter Linux; ihre Steuerzeichen-Tabelle hat in Ebene 14 einen Fehler, den +der Port nicht übernimmt. + ## Wie es gebaut wird `fetch-upstream.sh` holt `filius-ipad` am festen Stand `3b5ded0` und kopiert diff --git a/tools/swift-oracle/Sources/Oracle/main.swift b/tools/swift-oracle/Sources/Oracle/main.swift index 46d3d96..83c8df7 100644 --- a/tools/swift-oracle/Sources/Oracle/main.swift +++ b/tools/swift-oracle/Sources/Oracle/main.swift @@ -6,6 +6,7 @@ // Usage: Oracle — reads .scenario.json, writes .swift.json @testable import FiliusCore +import CryptoKit import Foundation // MARK: - Scenario format (shared with packages/core/test/oracle/scenario.ts) @@ -405,6 +406,161 @@ func describeSystem(_ state: TopologyEditorState, _ node: TopologyNode) -> JSON return json } +// MARK: - Virtual file system + +/// One step of a file system case (packages/core/test/vfs.cases.json). +struct FileSystemStep: Decodable { + let op: String + let path: String? + let to: String? + let name: String? + let text: String? + let size: Int? + let byte: UInt8? + let mediaType: String? + let recursive: Bool? + let overwrite: Bool? + let entries: [FileSystemCaseEntry]? +} + +struct FileSystemCaseEntry: Decodable { let path: String; let kind: String; let text: String? } +struct FileSystemCase: Decodable { let name: String; let steps: [FileSystemStep] } + +func describeFileSystemError(_ error: Error) -> JSON { + guard let error = error as? TopologyVirtualFileSystemError else { + return ["error": "other", "values": [String(describing: error)]] + } + func result(_ code: String, _ values: [Any] = []) -> JSON { ["error": code, "values": values] } + switch error { + case let .pathMustBeAbsolute(path): return result("pathMustBeAbsolute", [path]) + case let .pathEscapesRoot(path): return result("pathEscapesRoot", [path]) + case let .invalidPathComponent(component): return result("invalidPathComponent", [component]) + case let .itemAlreadyExists(path): return result("itemAlreadyExists", [path]) + case let .caseInsensitiveSiblingCollision(existing, attempted): + return result("caseInsensitiveSiblingCollision", [existing, attempted]) + case let .itemNotFound(path): return result("itemNotFound", [path]) + case let .parentDirectoryNotFound(path): return result("parentDirectoryNotFound", [path]) + case let .expectedDirectory(path): return result("expectedDirectory", [path]) + case let .expectedFile(path): return result("expectedFile", [path]) + case let .expectedTextFile(path): return result("expectedTextFile", [path]) + case let .expectedImageFile(path): return result("expectedImageFile", [path]) + case let .directoryNotEmpty(path): return result("directoryNotEmpty", [path]) + case .cannotMutateRoot: return result("cannotMutateRoot") + case let .cannotMoveDirectoryIntoItself(source, destination): + return result("cannotMoveDirectoryIntoItself", [source, destination]) + case let .fileSizeQuotaExceeded(path, actual, limit): return result("fileSizeQuotaExceeded", [path, actual, limit]) + case let .deviceSizeQuotaExceeded(actual, limit): return result("deviceSizeQuotaExceeded", [actual, limit]) + case let .deviceEntryQuotaExceeded(actual, limit): return result("deviceEntryQuotaExceeded", [actual, limit]) + case let .totalSizeQuotaExceeded(actual, limit): return result("totalSizeQuotaExceeded", [actual, limit]) + case let .totalEntryQuotaExceeded(actual, limit): return result("totalEntryQuotaExceeded", [actual, limit]) + } +} + +func describeFileEntries(_ entries: [TopologyVirtualFileEntry]) -> [JSON] { + entries.map { entry in + var json: JSON = ["path": entry.path] + switch entry.content { + case .directory: json["kind"] = "directory" + case let .text(value): json["kind"] = "text"; json["text"] = value + case let .binary(data, mediaType): + json["kind"] = "binary"; json["bytes"] = data.count; json["mediaType"] = mediaType ?? NSNull() + json["sha256"] = SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + case let .image(data, mediaType): + json["kind"] = "image"; json["bytes"] = data.count; json["mediaType"] = mediaType + json["sha256"] = SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } + return json + } +} + +func runFileSystemCases(_ cases: [FileSystemCase]) -> [JSON] { + cases.map { item in + var fileSystem = TopologyVirtualFileSystem() + var results: [JSON] = [] + for step in item.steps { + do { + let path = step.path ?? "/" + switch step.op { + case "createDirectory": try fileSystem.createDirectory(at: path, recursive: step.recursive ?? false) + case "writeText": try fileSystem.writeTextFile(at: path, text: step.text ?? "", overwrite: step.overwrite ?? true) + case "writeBinary": + try fileSystem.writeBinaryFile( + at: path, data: Data(repeating: step.byte ?? 0, count: step.size ?? 0), + mediaType: step.mediaType, overwrite: step.overwrite ?? true) + case "writeImage": + try fileSystem.writeImageFile( + at: path, data: Data(repeating: step.byte ?? 0, count: step.size ?? 0), + mediaType: step.mediaType ?? "image/png", overwrite: step.overwrite ?? true) + case "copy": try fileSystem.copyItem(at: path, to: step.to ?? "/", overwrite: step.overwrite ?? false) + case "move": try fileSystem.moveItem(at: path, to: step.to ?? "/", overwrite: step.overwrite ?? false) + case "rename": results.append(["ok": try fileSystem.renameItem(at: path, to: step.name ?? "")]); continue + case "delete": try fileSystem.deleteItem(at: path, recursive: step.recursive ?? false) + case "entry": results.append(["ok": describeFileEntries([try fileSystem.entry(at: path)])[0]]); continue + case "entries": results.append(["ok": describeFileEntries(try fileSystem.entries(in: path))]); continue + case "text": results.append(["ok": try fileSystem.textFile(at: path)]); continue + case "contains": results.append(["ok": fileSystem.contains(path)]); continue + case "normalize": results.append(["ok": try TopologyVirtualFileSystem.normalizedAbsolutePath(path)]); continue + case "default": fileSystem = .defaultForDevice() + case "fromEntries": + fileSystem = try TopologyVirtualFileSystem(entries: (step.entries ?? []).map { entry in + TopologyVirtualFileEntry( + path: entry.path, + content: entry.kind == "directory" ? .directory : .text(entry.text ?? "")) + }) + default: results.append(["error": "unknown step \(step.op)"]); continue + } + results.append(["ok": true]) + } catch { + results.append(describeFileSystemError(error)) + } + } + return ["name": item.name, "results": results, "entries": describeFileEntries(fileSystem.allEntries())] + } +} + +// MARK: - String semantics + +struct StringCases: Decodable { + let trim: [String] + let lowercased: [String] + let equal: [[String]] + let sort: [[String]] + let base64: [String] + let controlCharacters: [String] +} + +/// What Swift makes of strings where JavaScript differs (packages/core/test/strings.cases.json). +func measureStrings(_ cases: StringCases) -> JSON { + [ + "trim": cases.trim.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }, + "lowercased": cases.lowercased.map { $0.lowercased() }, + "equal": cases.equal.map { $0[0] == $0[1] }, + "sort": cases.sort.map { $0.sorted() }, + "base64": cases.base64.map { text -> Any in + Data(base64Encoded: text, options: .ignoreUnknownCharacters).map { Array($0) } ?? NSNull() + }, + "controlCharacters": cases.controlCharacters.map { text in + text.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) }) + }, + // The whole sets, scalar by scalar, as ranges [first, last]. + "whitespacesAndNewlinesSet": scalarRanges(in: .whitespacesAndNewlines), + "controlCharactersSet": scalarRanges(in: .controlCharacters), + ] +} + +func scalarRanges(in set: CharacterSet) -> [[Int]] { + var ranges: [[Int]] = [] + for value in 0...0x10FFFF { + guard let scalar = Unicode.Scalar(value), set.contains(scalar) else { continue } + if let last = ranges.last, last[1] == value - 1 { + ranges[ranges.count - 1][1] = value + } else { + ranges.append([value, value]) + } + } + return ranges +} + // MARK: - Archive path folding struct PathCase: Decodable { let name: String; let first: String; let second: String } @@ -446,6 +602,23 @@ if CommandLine.arguments.count == 4, CommandLine.arguments[1] == "--path-folding exit(0) } +if CommandLine.arguments.count == 4, CommandLine.arguments[1] == "--vfs" { + let cases = try JSONDecoder().decode([FileSystemCase].self, from: Data(contentsOf: URL(fileURLWithPath: CommandLine.arguments[2]))) + let output = runFileSystemCases(cases) + let data = try JSONSerialization.data(withJSONObject: output, options: [.prettyPrinted, .sortedKeys]) + try (data + Data("\n".utf8)).write(to: URL(fileURLWithPath: CommandLine.arguments[3])) + print("vfs: \(output.count) cases") + exit(0) +} + +if CommandLine.arguments.count == 4, CommandLine.arguments[1] == "--strings" { + let cases = try JSONDecoder().decode(StringCases.self, from: Data(contentsOf: URL(fileURLWithPath: CommandLine.arguments[2]))) + let data = try JSONSerialization.data(withJSONObject: measureStrings(cases), options: [.prettyPrinted, .sortedKeys]) + try (data + Data("\n".utf8)).write(to: URL(fileURLWithPath: CommandLine.arguments[3])) + print("strings: measured") + exit(0) +} + if CommandLine.arguments.count == 4, CommandLine.arguments[1] == "--corpus" { let summary = try readCorpus(root: URL(fileURLWithPath: CommandLine.arguments[2])) let data = try JSONSerialization.data(withJSONObject: summary, options: [.prettyPrinted, .sortedKeys]) diff --git a/tools/swift-oracle/run.sh b/tools/swift-oracle/run.sh index 92e15e0..b2d970a 100755 --- a/tools/swift-oracle/run.sh +++ b/tools/swift-oracle/run.sh @@ -12,4 +12,6 @@ docker run --rm -v "$root:/work" -w /work/tools/swift-oracle swift:6.1-jammy sh swift build -q .build/debug/Oracle /work/packages/core/test/scenarios .build/debug/Oracle --corpus /work/corpus /work/corpus/corpus.swift.json + .build/debug/Oracle --vfs /work/packages/core/test/vfs.cases.json /work/packages/core/test/vfs.swift.json + .build/debug/Oracle --strings /work/packages/core/test/strings.cases.json /work/packages/core/test/strings.swift.json .build/debug/Oracle --path-folding /work/packages/fls/test/path-folding.cases.json /work/packages/fls/test/path-folding.swift.json'