feat(core): virtual file system and Swift string semantics
The core gains Swift's virtual file system (paths, case-insensitive lookup that keeps the spelling, quotas, atomic changes, the defaults of a new device) and the string behaviour the ported code relies on without saying so. Both are measured in the Swift oracle: 11 file system cases (the three Swift tests among them) step by step, and trimming, lower case, equality, ordering and Base64 case by case — whitespace and control characters over all of Unicode. Found by measuring: - Swift trims NEL and ZERO WIDTH SPACE, not the BOM; JS trim() does the opposite. The .fls import now trims with swiftTrim. - lowercased() maps scalar by scalar: a word-final sigma stays σ. - Foundation decodes "====" and "Y===" as empty data. - Foundation on Linux counts 24,735 plane-14 scalars as control characters, a table artefact; the port keeps Unicode Cc+Cf. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
// Ported from filius-ipad (GPLv2/GPLv3):
|
||||
// State/TopologyVirtualFileSystem.swift (defaultImageFiles, legacyDefaultImageData)
|
||||
// The PNG images a new PC or notebook finds in /images, as Base64. Copied
|
||||
// verbatim, line breaks removed.
|
||||
|
||||
/** The 1×1 black placeholder earlier filius-ipad versions wrote instead. */
|
||||
export const LEGACY_DEFAULT_IMAGE_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=';
|
||||
|
||||
export const DEFAULT_IMAGE_FILES: readonly { name: string; base64: string }[] = [
|
||||
{
|
||||
name: 'network-map.png',
|
||||
base64:
|
||||
'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',
|
||||
},
|
||||
{
|
||||
name: 'traffic-heatmap.png',
|
||||
base64:
|
||||
'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' +
|
||||
'QmCC',
|
||||
},
|
||||
{
|
||||
name: 'switch-backplane.png',
|
||||
base64:
|
||||
'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',
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,583 @@
|
||||
// Ported from filius-ipad (GPLv2/GPLv3):
|
||||
// State/TopologyVirtualFileSystem.swift
|
||||
// The files of one PC or notebook: a flat map from normalized absolute paths
|
||||
// to directories and files, case-insensitive lookup that keeps the spelling
|
||||
// it was created with, and quotas. Every change works on a copy and replaces
|
||||
// the state only when it succeeds, as Swift's value type does.
|
||||
//
|
||||
// Swift compares the path keys by canonical equivalence (a dictionary of
|
||||
// Strings); the map here is keyed by the NFC form and keeps the spelling of
|
||||
// the first insertion. Ordering and trimming follow Swift (swiftString.ts).
|
||||
|
||||
import {
|
||||
containsSwiftControlCharacter,
|
||||
swiftBase64Decode,
|
||||
swiftCompare,
|
||||
swiftEqual,
|
||||
swiftHasPrefix,
|
||||
swiftLowercased,
|
||||
swiftTrim,
|
||||
} from '../util/swiftString.js';
|
||||
import { DEFAULT_IMAGE_FILES, LEGACY_DEFAULT_IMAGE_BASE64 } from './defaultImages.js';
|
||||
|
||||
export type VirtualFileContent =
|
||||
| { readonly kind: 'directory' }
|
||||
| { readonly kind: 'text'; readonly text: string }
|
||||
| { readonly kind: 'binary'; readonly data: Uint8Array; readonly mediaType?: string }
|
||||
| { readonly kind: 'image'; readonly data: Uint8Array; readonly mediaType: string };
|
||||
|
||||
export interface VirtualFileEntry {
|
||||
readonly path: string;
|
||||
readonly content: VirtualFileContent;
|
||||
}
|
||||
|
||||
export type VirtualFileSystemErrorCode =
|
||||
| 'pathMustBeAbsolute'
|
||||
| 'pathEscapesRoot'
|
||||
| 'invalidPathComponent'
|
||||
| 'itemAlreadyExists'
|
||||
| 'caseInsensitiveSiblingCollision'
|
||||
| 'itemNotFound'
|
||||
| 'parentDirectoryNotFound'
|
||||
| 'expectedDirectory'
|
||||
| 'expectedFile'
|
||||
| 'expectedTextFile'
|
||||
| 'expectedImageFile'
|
||||
| 'directoryNotEmpty'
|
||||
| 'cannotMutateRoot'
|
||||
| 'cannotMoveDirectoryIntoItself'
|
||||
| 'fileSizeQuotaExceeded'
|
||||
| 'deviceSizeQuotaExceeded'
|
||||
| 'deviceEntryQuotaExceeded'
|
||||
| 'totalSizeQuotaExceeded'
|
||||
| 'totalEntryQuotaExceeded';
|
||||
|
||||
/** Swift's TopologyVirtualFileSystemError: the case and its associated values. */
|
||||
export class VirtualFileSystemError extends Error {
|
||||
constructor(
|
||||
readonly code: VirtualFileSystemErrorCode,
|
||||
readonly values: readonly (string | number)[] = [],
|
||||
) {
|
||||
super(`${code}(${values.join(', ')})`);
|
||||
this.name = 'VirtualFileSystemError';
|
||||
}
|
||||
}
|
||||
|
||||
function fail(code: VirtualFileSystemErrorCode, ...values: (string | number)[]): never {
|
||||
throw new VirtualFileSystemError(code, values);
|
||||
}
|
||||
|
||||
export function isDirectoryContent(content: VirtualFileContent): boolean {
|
||||
return content.kind === 'directory';
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
export function contentByteCount(content: VirtualFileContent): number {
|
||||
switch (content.kind) {
|
||||
case 'directory':
|
||||
return 0;
|
||||
case 'text':
|
||||
return encoder.encode(content.text).length;
|
||||
case 'binary':
|
||||
case 'image':
|
||||
return content.data.length;
|
||||
}
|
||||
}
|
||||
|
||||
export function entryName(path: string): string {
|
||||
if (path === '/') return '/';
|
||||
return path.split('/').filter((part) => part !== '').at(-1) ?? '';
|
||||
}
|
||||
|
||||
export function parentPath(normalizedPath: string): string {
|
||||
if (normalizedPath === '/') return '/';
|
||||
const separator = normalizedPath.lastIndexOf('/');
|
||||
if (separator < 0) return '/';
|
||||
return separator === 0 ? '/' : normalizedPath.slice(0, separator);
|
||||
}
|
||||
|
||||
function depth(path: string): number {
|
||||
return path.split('/').filter((part) => part !== '').length;
|
||||
}
|
||||
|
||||
/** Shallow first, then by path — the order Swift applies a set of entries in. */
|
||||
function byDepthThenPath(lhs: string, rhs: string): number {
|
||||
return depth(lhs) - depth(rhs) || swiftCompare(lhs, rhs);
|
||||
}
|
||||
|
||||
/**
|
||||
* An absolute path with `\` as `/`, empty and `.` components dropped and `..`
|
||||
* resolved; a component with surrounding whitespace or a control character
|
||||
* is invalid.
|
||||
*/
|
||||
export function normalizedAbsolutePath(rawPath: string): string {
|
||||
const path = rawPath.replaceAll('\\', '/');
|
||||
if (!path.startsWith('/')) fail('pathMustBeAbsolute', rawPath);
|
||||
const components: string[] = [];
|
||||
for (const component of path.split('/')) {
|
||||
if (component === '' || component === '.') continue;
|
||||
if (component === '..') {
|
||||
if (components.length === 0) fail('pathEscapesRoot', rawPath);
|
||||
components.pop();
|
||||
continue;
|
||||
}
|
||||
if (!swiftEqual(component, swiftTrim(component)) || containsSwiftControlCharacter(component)) {
|
||||
fail('invalidPathComponent', component);
|
||||
}
|
||||
components.push(component);
|
||||
}
|
||||
return components.length === 0 ? '/' : `/${components.join('/')}`;
|
||||
}
|
||||
|
||||
export const VIRTUAL_FILE_SYSTEM_LIMITS = {
|
||||
// The 64 MiB project ceiling leaves headroom below the 128 MiB
|
||||
// konfiguration.xml archive-entry limit for Base64 and XML structure.
|
||||
maximumFileBytes: 8 * 1024 * 1024,
|
||||
maximumDeviceBytes: 32 * 1024 * 1024,
|
||||
maximumProjectBytes: 64 * 1024 * 1024,
|
||||
maximumDeviceEntries: 4096,
|
||||
maximumProjectEntries: 16_384,
|
||||
} as const;
|
||||
|
||||
type Store = Map<string, { path: string; content: VirtualFileContent }>;
|
||||
|
||||
function keyOf(path: string): string {
|
||||
return path.normalize('NFC');
|
||||
}
|
||||
|
||||
export class VirtualFileSystem {
|
||||
private store: Store;
|
||||
|
||||
constructor() {
|
||||
this.store = new Map([[keyOf('/'), { path: '/', content: { kind: 'directory' } }]]);
|
||||
}
|
||||
|
||||
/** `init(entries:)`: shallow entries first; each parent must exist. */
|
||||
static fromEntries(entries: readonly VirtualFileEntry[]): VirtualFileSystem {
|
||||
const fileSystem = new VirtualFileSystem();
|
||||
const sorted = [...entries].sort((lhs, rhs) => byDepthThenPath(lhs.path, rhs.path));
|
||||
for (const entry of sorted) {
|
||||
const path = normalizedAbsolutePath(entry.path);
|
||||
if (path === '/') fail('itemAlreadyExists', '/');
|
||||
if (fileSystem.has(path)) fail('itemAlreadyExists', path);
|
||||
if (fileSystem.get(parentPath(path))?.kind !== 'directory') fail('parentDirectoryNotFound', path);
|
||||
fileSystem.ensureNoCaseInsensitiveSiblingCollision(path);
|
||||
fileSystem.set(path, entry.content);
|
||||
}
|
||||
fileSystem.validateQuotas();
|
||||
return fileSystem;
|
||||
}
|
||||
|
||||
/** What a new PC or notebook starts with: notes, a log and three images. */
|
||||
static defaultForDevice(): VirtualFileSystem {
|
||||
const fileSystem = new VirtualFileSystem();
|
||||
const attempt = (change: () => void) => {
|
||||
try {
|
||||
change();
|
||||
} catch {
|
||||
// `try?` in Swift: a default that cannot be written is skipped.
|
||||
}
|
||||
};
|
||||
attempt(() => fileSystem.createDirectory('/home'));
|
||||
attempt(() => fileSystem.createDirectory('/images'));
|
||||
attempt(() => fileSystem.createDirectory('/var/log', true));
|
||||
attempt(() => fileSystem.writeTextFile('/home/lab-notes.txt', 'Document deterministic runtime notes here.', false));
|
||||
attempt(() => fileSystem.writeTextFile('/home/topology-exports.csv', 'timestamp,event,detail\n', false));
|
||||
attempt(() => fileSystem.writeTextFile('/var/log/runtime-events.log', 'Virtual runtime initialized.\n', false));
|
||||
for (const image of DEFAULT_IMAGE_FILES) {
|
||||
const data = swiftBase64Decode(image.base64);
|
||||
if (data === undefined) continue;
|
||||
attempt(() => fileSystem.writeImageFile(`/images/${image.name}`, data, 'image/png', false));
|
||||
}
|
||||
return fileSystem;
|
||||
}
|
||||
|
||||
/** Replaces the 1×1 black images of older versions by the current defaults; returns how many. */
|
||||
upgradeLegacyDefaultImages(): number {
|
||||
const legacy = swiftBase64Decode(LEGACY_DEFAULT_IMAGE_BASE64)!;
|
||||
let replaced = 0;
|
||||
for (const image of DEFAULT_IMAGE_FILES) {
|
||||
const path = `/images/${image.name}`;
|
||||
const existing = this.get(path);
|
||||
const replacement = swiftBase64Decode(image.base64);
|
||||
if (existing?.kind !== 'image' || !bytesEqual(existing.data, legacy) || replacement === undefined) continue;
|
||||
this.set(path, { kind: 'image', data: replacement, mediaType: 'image/png' });
|
||||
replaced += 1;
|
||||
}
|
||||
return replaced;
|
||||
}
|
||||
|
||||
/** `validateProjectQuotas`: every device's quotas, then the project's totals. */
|
||||
static validateProjectQuotas(fileSystems: Iterable<VirtualFileSystem>): void {
|
||||
let totalBytes = 0;
|
||||
let totalEntries = 0;
|
||||
for (const fileSystem of fileSystems) {
|
||||
fileSystem.validateQuotas();
|
||||
totalBytes += fileSystem.totalFileBytes();
|
||||
totalEntries += fileSystem.persistedEntryCount();
|
||||
}
|
||||
const limits = VIRTUAL_FILE_SYSTEM_LIMITS;
|
||||
if (totalBytes > limits.maximumProjectBytes) fail('totalSizeQuotaExceeded', totalBytes, limits.maximumProjectBytes);
|
||||
if (totalEntries > limits.maximumProjectEntries) {
|
||||
fail('totalEntryQuotaExceeded', totalEntries, limits.maximumProjectEntries);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Reading ──────────────────────────────────────────────────────────────
|
||||
|
||||
entry(rawPath: string): VirtualFileEntry {
|
||||
const path = this.existingPath(rawPath);
|
||||
const content = this.get(path);
|
||||
if (content === undefined) fail('itemNotFound', path);
|
||||
return { path, content };
|
||||
}
|
||||
|
||||
contains(rawPath: string): boolean {
|
||||
return this.existingPathIfPresent(rawPath) !== undefined;
|
||||
}
|
||||
|
||||
allEntries(): VirtualFileEntry[] {
|
||||
return [...this.store.values()]
|
||||
.map(({ path, content }) => ({ path, content }))
|
||||
.sort((lhs, rhs) => swiftCompare(lhs.path, rhs.path));
|
||||
}
|
||||
|
||||
/** A directory's entries: directories first, then by name. */
|
||||
entries(rawDirectoryPath: string): VirtualFileEntry[] {
|
||||
const directoryPath = this.existingPath(rawDirectoryPath);
|
||||
const content = this.get(directoryPath);
|
||||
if (content === undefined) fail('itemNotFound', directoryPath);
|
||||
if (content.kind !== 'directory') fail('expectedDirectory', directoryPath);
|
||||
return [...this.store.values()]
|
||||
.filter(({ path }) => !swiftEqual(path, directoryPath) && swiftEqual(parentPath(path), directoryPath))
|
||||
.map(({ path, content: entryContent }) => ({ path, content: entryContent }))
|
||||
.sort((lhs, rhs) => {
|
||||
const lhsDirectory = lhs.content.kind === 'directory';
|
||||
const rhsDirectory = rhs.content.kind === 'directory';
|
||||
if (lhsDirectory !== rhsDirectory) return lhsDirectory ? -1 : 1;
|
||||
const byName = swiftCompare(entryName(lhs.path), entryName(rhs.path));
|
||||
return byName !== 0 ? byName : swiftCompare(lhs.path, rhs.path);
|
||||
});
|
||||
}
|
||||
|
||||
textFile(rawPath: string): string {
|
||||
const entry = this.entry(rawPath);
|
||||
if (entry.content.kind !== 'text') fail('expectedTextFile', entry.path);
|
||||
return entry.content.text;
|
||||
}
|
||||
|
||||
imageFile(rawPath: string): { data: Uint8Array; mediaType: string } {
|
||||
const entry = this.entry(rawPath);
|
||||
if (entry.content.kind !== 'image') fail('expectedImageFile', entry.path);
|
||||
return { data: entry.content.data, mediaType: entry.content.mediaType };
|
||||
}
|
||||
|
||||
/** Swift's `==`: the same paths (canonically) with equal contents. */
|
||||
equals(other: VirtualFileSystem): boolean {
|
||||
if (this.store.size !== other.store.size) return false;
|
||||
for (const [key, { content }] of this.store) {
|
||||
const theirs = other.store.get(key);
|
||||
if (theirs === undefined || !contentEqual(content, theirs.content)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
clone(): VirtualFileSystem {
|
||||
const copy = new VirtualFileSystem();
|
||||
copy.store = new Map(this.store);
|
||||
return copy;
|
||||
}
|
||||
|
||||
// ── Changing ─────────────────────────────────────────────────────────────
|
||||
|
||||
createDirectory(rawPath: string, recursive = false): void {
|
||||
this.transaction((candidate) => candidate.createDirectoryUnchecked(rawPath, recursive));
|
||||
}
|
||||
|
||||
writeTextFile(rawPath: string, text: string, overwrite = true): void {
|
||||
this.writeFileContent({ kind: 'text', text }, rawPath, overwrite);
|
||||
}
|
||||
|
||||
writeBinaryFile(rawPath: string, data: Uint8Array, mediaType?: string, overwrite = true): void {
|
||||
this.writeFileContent(
|
||||
mediaType === undefined ? { kind: 'binary', data } : { kind: 'binary', data, mediaType },
|
||||
rawPath,
|
||||
overwrite,
|
||||
);
|
||||
}
|
||||
|
||||
writeImageFile(rawPath: string, data: Uint8Array, mediaType: string, overwrite = true): void {
|
||||
this.writeFileContent({ kind: 'image', data, mediaType }, rawPath, overwrite);
|
||||
}
|
||||
|
||||
copyItem(rawSourcePath: string, rawDestinationPath: string, overwrite = false): void {
|
||||
this.transaction((candidate) => candidate.copyItemUnchecked(rawSourcePath, rawDestinationPath, overwrite));
|
||||
}
|
||||
|
||||
moveItem(rawSourcePath: string, rawDestinationPath: string, overwrite = false): void {
|
||||
this.transaction((candidate) => candidate.moveItemUnchecked(rawSourcePath, rawDestinationPath, overwrite));
|
||||
}
|
||||
|
||||
/** Renames in place; returns the new path. */
|
||||
renameItem(rawPath: string, newName: string): string {
|
||||
if (newName.includes('/') || newName.includes('\\')) fail('invalidPathComponent', newName);
|
||||
const sourcePath = this.existingPath(rawPath);
|
||||
if (sourcePath === '/') fail('cannotMutateRoot');
|
||||
const parent = parentPath(sourcePath);
|
||||
const destination = normalizedAbsolutePath(parent + (parent === '/' ? '' : '/') + newName);
|
||||
this.moveItem(sourcePath, destination);
|
||||
return this.existingPath(destination);
|
||||
}
|
||||
|
||||
deleteItem(rawPath: string, recursive = false): void {
|
||||
const path = this.existingPath(rawPath);
|
||||
if (path === '/') fail('cannotMutateRoot');
|
||||
const content = this.get(path);
|
||||
if (content === undefined) fail('itemNotFound', path);
|
||||
const descendants = [...this.store.values()].filter((entry) => swiftHasPrefix(entry.path, `${path}/`));
|
||||
if (content.kind === 'directory' && !recursive && descendants.length > 0) fail('directoryNotEmpty', path);
|
||||
for (const descendant of descendants) this.delete(descendant.path);
|
||||
this.delete(path);
|
||||
}
|
||||
|
||||
// ── Internals ────────────────────────────────────────────────────────────
|
||||
|
||||
private get(path: string): VirtualFileContent | undefined {
|
||||
return this.store.get(keyOf(path))?.content;
|
||||
}
|
||||
|
||||
private has(path: string): boolean {
|
||||
return this.store.has(keyOf(path));
|
||||
}
|
||||
|
||||
/** Assigning to an existing (canonically equal) key keeps its spelling, as a Swift dictionary does. */
|
||||
private set(path: string, content: VirtualFileContent): void {
|
||||
const key = keyOf(path);
|
||||
const existing = this.store.get(key);
|
||||
this.store.set(key, { path: existing?.path ?? path, content });
|
||||
}
|
||||
|
||||
private delete(path: string): void {
|
||||
this.store.delete(keyOf(path));
|
||||
}
|
||||
|
||||
private transaction(change: (candidate: VirtualFileSystem) => void): void {
|
||||
const candidate = this.clone();
|
||||
change(candidate);
|
||||
candidate.validateQuotas();
|
||||
this.store = candidate.store;
|
||||
}
|
||||
|
||||
private persistedEntryCount(): number {
|
||||
return Math.max(0, this.store.size - 1);
|
||||
}
|
||||
|
||||
private totalFileBytes(): number {
|
||||
let total = 0;
|
||||
for (const { content } of this.store.values()) total += contentByteCount(content);
|
||||
return total;
|
||||
}
|
||||
|
||||
private validateQuotas(): void {
|
||||
const limits = VIRTUAL_FILE_SYSTEM_LIMITS;
|
||||
for (const { path, content } of this.allEntries()) {
|
||||
if (content.kind === 'directory') continue;
|
||||
const bytes = contentByteCount(content);
|
||||
if (bytes > limits.maximumFileBytes) fail('fileSizeQuotaExceeded', path, bytes, limits.maximumFileBytes);
|
||||
}
|
||||
const bytes = this.totalFileBytes();
|
||||
if (bytes > limits.maximumDeviceBytes) fail('deviceSizeQuotaExceeded', bytes, limits.maximumDeviceBytes);
|
||||
const entries = this.persistedEntryCount();
|
||||
if (entries > limits.maximumDeviceEntries) fail('deviceEntryQuotaExceeded', entries, limits.maximumDeviceEntries);
|
||||
}
|
||||
|
||||
private createDirectoryUnchecked(rawPath: string, recursive: boolean): void {
|
||||
const normalized = normalizedAbsolutePath(rawPath);
|
||||
if (normalized === '/') return;
|
||||
const existing = this.existingPathIfPresent(normalized);
|
||||
if (existing !== undefined) fail('itemAlreadyExists', existing);
|
||||
|
||||
const rawParent = parentPath(normalized);
|
||||
let parent = this.existingPathIfPresent(rawParent);
|
||||
if (parent === undefined) {
|
||||
if (!recursive) fail('parentDirectoryNotFound', normalized);
|
||||
this.createDirectoryUnchecked(rawParent, true);
|
||||
parent = this.existingPath(rawParent);
|
||||
}
|
||||
if (this.get(parent)?.kind !== 'directory') fail('parentDirectoryNotFound', normalized);
|
||||
const destination = join(parent, entryName(normalized));
|
||||
this.ensureNoCaseInsensitiveSiblingCollision(destination);
|
||||
this.set(destination, { kind: 'directory' });
|
||||
}
|
||||
|
||||
private writeFileContent(content: VirtualFileContent, rawPath: string, overwrite: boolean): void {
|
||||
const candidate = this.clone();
|
||||
const normalized = normalizedAbsolutePath(rawPath);
|
||||
if (normalized === '/') fail('expectedFile', normalized);
|
||||
|
||||
let destination: string;
|
||||
const existing = candidate.existingPathIfPresent(normalized);
|
||||
if (existing !== undefined) {
|
||||
destination = existing;
|
||||
if (!overwrite) {
|
||||
if (swiftEqual(existing, normalized)) fail('itemAlreadyExists', existing);
|
||||
fail('caseInsensitiveSiblingCollision', existing, normalized);
|
||||
}
|
||||
if (candidate.get(existing)?.kind === 'directory') fail('expectedFile', existing);
|
||||
} else {
|
||||
const parent = candidate.existingPath(parentPath(normalized));
|
||||
if (candidate.get(parent)?.kind !== 'directory') fail('parentDirectoryNotFound', normalized);
|
||||
destination = join(parent, entryName(normalized));
|
||||
candidate.ensureNoCaseInsensitiveSiblingCollision(destination);
|
||||
}
|
||||
candidate.set(destination, content);
|
||||
candidate.validateQuotas();
|
||||
this.store = candidate.store;
|
||||
}
|
||||
|
||||
/** The subtree at `sourcePath`, shallow first. */
|
||||
private subtree(sourcePath: string): { path: string; content: VirtualFileContent }[] {
|
||||
return [...this.store.values()]
|
||||
.filter(({ path }) => swiftEqual(path, sourcePath) || swiftHasPrefix(path, `${sourcePath}/`))
|
||||
.sort((lhs, rhs) => byDepthThenPath(lhs.path, rhs.path));
|
||||
}
|
||||
|
||||
/** What copy and move check, and the destination they clear, before they write. */
|
||||
private prepareTransfer(
|
||||
sourcePath: string,
|
||||
sourceContent: VirtualFileContent,
|
||||
destinationPath: string,
|
||||
overwrite: boolean,
|
||||
skipSource: boolean,
|
||||
): void {
|
||||
if (
|
||||
sourceContent.kind === 'directory' &&
|
||||
(swiftHasPrefix(destinationPath, `${sourcePath}/`) || swiftHasPrefix(sourcePath, `${destinationPath}/`))
|
||||
) {
|
||||
fail('cannotMoveDirectoryIntoItself', sourcePath, destinationPath);
|
||||
}
|
||||
const destinationExisting = this.existingPathIfPresent(destinationPath);
|
||||
if (destinationExisting !== undefined && !(skipSource && swiftEqual(destinationExisting, sourcePath))) {
|
||||
if (!overwrite) fail('itemAlreadyExists', destinationExisting);
|
||||
const destinationContent = this.get(destinationExisting);
|
||||
if (destinationContent === undefined) fail('itemNotFound', destinationExisting);
|
||||
if ((destinationContent.kind === 'directory') !== (sourceContent.kind === 'directory')) {
|
||||
fail(sourceContent.kind === 'directory' ? 'expectedDirectory' : 'expectedFile', destinationExisting);
|
||||
}
|
||||
this.removeItemTree(destinationExisting);
|
||||
}
|
||||
if (this.get(parentPath(destinationPath))?.kind !== 'directory') fail('parentDirectoryNotFound', destinationPath);
|
||||
this.ensureNoCaseInsensitiveSiblingCollision(destinationPath);
|
||||
}
|
||||
|
||||
private copyItemUnchecked(rawSourcePath: string, rawDestinationPath: string, overwrite: boolean): void {
|
||||
const sourcePath = this.existingPath(rawSourcePath);
|
||||
const normalizedDestination = normalizedAbsolutePath(rawDestinationPath);
|
||||
if (sourcePath === '/') fail('cannotMutateRoot');
|
||||
const sourceContent = this.get(sourcePath);
|
||||
if (sourceContent === undefined) fail('itemNotFound', sourcePath);
|
||||
const destinationPath = this.canonicalDestinationPath(normalizedDestination);
|
||||
if (swiftEqual(destinationPath, sourcePath)) {
|
||||
if (overwrite) return;
|
||||
fail('itemAlreadyExists', destinationPath);
|
||||
}
|
||||
this.prepareTransfer(sourcePath, sourceContent, destinationPath, overwrite, true);
|
||||
for (const { path, content } of this.subtree(sourcePath)) {
|
||||
this.set(destinationPath + path.slice(sourcePath.length), content);
|
||||
}
|
||||
}
|
||||
|
||||
private moveItemUnchecked(rawSourcePath: string, rawDestinationPath: string, overwrite: boolean): void {
|
||||
const sourcePath = this.existingPath(rawSourcePath);
|
||||
const normalizedDestination = normalizedAbsolutePath(rawDestinationPath);
|
||||
if (sourcePath === '/') fail('cannotMutateRoot');
|
||||
const sourceContent = this.get(sourcePath);
|
||||
if (sourceContent === undefined) fail('itemNotFound', sourcePath);
|
||||
const destinationPath = this.canonicalDestinationPath(normalizedDestination);
|
||||
if (swiftEqual(destinationPath, sourcePath)) return;
|
||||
this.prepareTransfer(sourcePath, sourceContent, destinationPath, overwrite, false);
|
||||
const entries = this.subtree(sourcePath);
|
||||
for (const { path } of entries) this.delete(path);
|
||||
for (const { path, content } of entries) {
|
||||
this.set(destinationPath + path.slice(sourcePath.length), content);
|
||||
}
|
||||
}
|
||||
|
||||
private canonicalDestinationPath(normalizedPath: string): string {
|
||||
const existing = this.existingPathIfPresent(normalizedPath);
|
||||
if (existing !== undefined) return existing;
|
||||
const parent = this.existingPath(parentPath(normalizedPath));
|
||||
return join(parent, entryName(normalizedPath));
|
||||
}
|
||||
|
||||
private existingPathIfPresent(rawPath: string): string | undefined {
|
||||
try {
|
||||
return this.existingPath(rawPath);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** The stored spelling of a path, looked up component by component, case-insensitively. */
|
||||
private existingPath(rawPath: string): string {
|
||||
const normalized = normalizedAbsolutePath(rawPath);
|
||||
if (normalized === '/') return '/';
|
||||
let current = '/';
|
||||
for (const component of normalized.split('/').filter((part) => part !== '')) {
|
||||
const wanted = swiftLowercased(component);
|
||||
const candidates = [...this.store.values()]
|
||||
.filter(
|
||||
({ path }) => swiftEqual(parentPath(path), current) && swiftEqual(swiftLowercased(entryName(path)), wanted),
|
||||
)
|
||||
.map(({ path }) => path);
|
||||
if (candidates.length !== 1) fail('itemNotFound', normalized);
|
||||
current = candidates[0]!;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
private removeItemTree(path: string): void {
|
||||
for (const entry of [...this.store.values()]) {
|
||||
if (swiftEqual(entry.path, path) || swiftHasPrefix(entry.path, `${path}/`)) this.delete(entry.path);
|
||||
}
|
||||
}
|
||||
|
||||
private ensureNoCaseInsensitiveSiblingCollision(path: string): void {
|
||||
const parent = parentPath(path);
|
||||
const name = swiftLowercased(entryName(path));
|
||||
const existing = [...this.store.values()]
|
||||
.map((entry) => entry.path)
|
||||
.filter(
|
||||
(candidate) =>
|
||||
!swiftEqual(candidate, path) &&
|
||||
swiftEqual(parentPath(candidate), parent) &&
|
||||
swiftEqual(swiftLowercased(entryName(candidate)), name),
|
||||
)
|
||||
.sort(swiftCompare)[0];
|
||||
if (existing !== undefined) fail('caseInsensitiveSiblingCollision', existing, path);
|
||||
}
|
||||
}
|
||||
|
||||
function join(parent: string, name: string): string {
|
||||
return parent === '/' ? `/${name}` : `${parent}/${name}`;
|
||||
}
|
||||
|
||||
function bytesEqual(lhs: Uint8Array, rhs: Uint8Array): boolean {
|
||||
return lhs.length === rhs.length && lhs.every((byte, index) => byte === rhs[index]);
|
||||
}
|
||||
|
||||
function contentEqual(lhs: VirtualFileContent, rhs: VirtualFileContent): boolean {
|
||||
if (lhs.kind !== rhs.kind) return false;
|
||||
switch (lhs.kind) {
|
||||
case 'directory':
|
||||
return true;
|
||||
case 'text':
|
||||
return swiftEqual(lhs.text, (rhs as typeof lhs).text);
|
||||
case 'binary':
|
||||
case 'image': {
|
||||
const other = rhs as typeof lhs;
|
||||
return lhs.mediaType === other.mediaType && bytesEqual(lhs.data, other.data);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user