feat(fls): import host file systems into the model

importProject turns a PC's or notebook's Java file tree into Swift's
virtual file system, as Swift's registerVirtualFileSystem does: images
by type or extension from Base64, text by type, unknown types as binary
data or text, unsafe names and collisions left out with a warning, and
the whole import rejected over the project quota. NSString.pathExtension
is measured in the oracle, which now writes every imported file system.

On the 13 files Swift reads completely every file matches, images
compared by hash. Intended: a host whose file has no file system stays
empty, as in Java, where Swift adds the demo files of a new device.
Pending the applications step, the comparison reads our /webserver as
Swift's /www and leaves out the e-mail and Gnutella state files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-29 21:28:55 +00:00
co-authored by Claude Opus 5.5
parent a198d10b4a
commit be3b332655
9 changed files with 4091 additions and 10 deletions
+124 -2
View File
@@ -24,7 +24,10 @@
// names stay as Java has them;
// - a Java modem becomes an in-project remote link pair keyed by its port,
// as in Swift — two FILIUS instances on different hosts are not joined.
// Not here yet: file systems and applications.
// - a PC or notebook whose file has no file system gets an empty one, as in
// Java — Swift gives it the demo files of a new device;
// - a file system's root is Java's root, whatever its user object says.
// Not here yet: applications (with them the web server's /webserver → /www).
import {
canonicalMacAddress,
@@ -38,7 +41,13 @@ import {
inDeterministicRenderOrder,
interfaceKey,
isSafeFrame,
swiftBase64Decode,
swiftLowercased,
swiftPathExtension,
swiftTrim,
normalizedAbsolutePath,
VirtualFileSystem,
VirtualFileSystemError,
topologyNode,
DOCUMENTATION_BLACK,
DOCUMENTATION_PALE_YELLOW,
@@ -64,8 +73,9 @@ import {
type TopologyGraph,
type TopologyLink,
type TopologyNode,
type VirtualFileContent,
} from '@mdg-filius/core';
import type { FlsFirewall } from './systemSoftware.js';
import type { FlsFileTree, FlsFirewall } from './systemSoftware.js';
import type { FlsDocumentationItem, FlsInterface, FlsNode, FlsNodeClass, FlsTopology } from './topology.js';
export interface ImportedProject {
@@ -84,6 +94,8 @@ export interface ImportedProject {
dhcpServerConfigurations: Map<Id, DhcpServerConfiguration>;
portForwardingRowsByNodeId: Map<Id, GatewayPortForwardingRow[]>;
remoteLinkConfigurations: Map<Id, RemoteLinkConfiguration>;
/** Every PC and notebook. */
virtualFileSystems: Map<Id, VirtualFileSystem>;
/** In render order: rectangles below text, then by order. */
documentationItems: DocumentationItem[];
/** The topology's warnings, then this step's. */
@@ -119,12 +131,16 @@ export function importProject(topology: FlsTopology, newId: () => Id): ImportedP
dhcpServerConfigurations: new Map(),
portForwardingRowsByNodeId: new Map(),
remoteLinkConfigurations: new Map(),
virtualFileSystems: new Map(),
};
const nodes = topology.nodes.map((source) => {
const node = importNode(source, newId, warnings);
registerConfigurations(source, node, deviceConfigurations, interfaceConfigurations);
registerSystemConfigurations(source, node, system, warnings);
registerServiceConfigurations(source, node, system, newId, warnings);
if (node.kind === 'pc' || node.kind === 'notebook') {
system.virtualFileSystems.set(node.id, importFileSystem(source.system.fileSystem, node.displayName, warnings));
}
return node;
});
@@ -160,6 +176,9 @@ export function importProject(topology: FlsTopology, newId: () => Id): ImportedP
});
});
// Swift rejects the whole import when the files exceed the quotas.
VirtualFileSystem.validateProjectQuotas(system.virtualFileSystems.values());
const documentationItems: DocumentationItem[] = [];
for (const item of topology.documentation) {
const imported = importDocumentationItem(item, documentationItems.length, warnings);
@@ -311,6 +330,7 @@ type SystemConfigurations = Pick<
| 'dhcpServerConfigurations'
| 'portForwardingRowsByNodeId'
| 'remoteLinkConfigurations'
| 'virtualFileSystems'
>;
/** A row of text cells, trimmed, or undefined unless it has exactly four. */
@@ -460,6 +480,108 @@ function firewallConfigurationOf(firewall: FlsFirewall, where: string, warnings:
};
}
const IMAGE_TYPES = new Set(['png', 'jpg', 'jpeg', 'gif', 'bmp', 'image']);
const TEXT_TYPES = new Set(['', 'text', 'txt', 'text/txt', 'html', 'htm', 'css', 'xml', 'csv', 'conf', 'log']);
const encoder = new TextEncoder();
/**
* Swift's importedContent: an image by type or extension (Base64), text by
* type, else Base64 as binary data, else text — each fallback with a warning.
*/
function importedContent(
rawType: string | null,
rawContent: string | null,
path: string,
warn: (message: string) => void,
): VirtualFileContent {
const type = swiftLowercased(trimmedOrEmpty(rawType));
const content = rawContent ?? '';
const extension = swiftLowercased(swiftPathExtension(path));
const binary = (data: Uint8Array): VirtualFileContent =>
type === '' ? { kind: 'binary', data } : { kind: 'binary', data, mediaType: `application/${type}` };
if (IMAGE_TYPES.has(type) || IMAGE_TYPES.has(extension)) {
const data = swiftBase64Decode(content);
if (data !== undefined) {
const subtype = type === 'image' || type === '' ? (extension === '' ? 'png' : extension) : type;
return { kind: 'image', data, mediaType: `image/${subtype === 'jpg' ? 'jpeg' : subtype}` };
}
warn(`Java file '${path}' declared image type '${type}' but was not valid Base64; imported as UTF-8 binary data.`);
return binary(encoder.encode(content));
}
if (TEXT_TYPES.has(type)) return { kind: 'text', text: content };
const data = swiftBase64Decode(content);
if (data !== undefined && (data.length > 0 || swiftTrim(content) === '')) {
warn(`Java file '${path}' used unknown type '${type}'; preserved as binary data.`);
return binary(data);
}
warn(`Java file '${path}' used unknown type '${type}'; preserved as UTF-8 text.`);
return { kind: 'text', text: content };
}
/** A usable name: not empty after trimming, no path separator. */
function safeName(name: string | null): string | undefined {
const trimmed = trimmedOrEmpty(name);
return trimmed === '' || trimmed.includes('/') || trimmed.includes('\\') ? undefined : trimmed;
}
/**
* Swift's registerVirtualFileSystem: the children of Java's root, depth
* first; an item that cannot be written is left out with a warning, the
* rest goes on.
*/
function importFileSystem(root: FlsFileTree | undefined, nodeName: string, warnings: string[]): VirtualFileSystem {
const fileSystem = new VirtualFileSystem();
const warn = (message: string) => warnings.push(message);
const describe = (error: unknown) => (error instanceof VirtualFileSystemError ? error.message : String(error));
const append = (tree: FlsFileTree, parent: string): void => {
if (tree.kind === 'file') {
const name = safeName(tree.name);
if (name === undefined) {
warn(`Ignored Java filesystem file with missing or unsafe name on '${nodeName}'.`);
return;
}
const rawPath = parent === '/' ? `/${name}` : `${parent}/${name}`;
try {
const path = normalizedAbsolutePath(rawPath);
const content = importedContent(tree.type, tree.content, path, warn);
switch (content.kind) {
case 'text':
fileSystem.writeTextFile(path, content.text, false);
break;
case 'binary':
fileSystem.writeBinaryFile(path, content.data, content.mediaType, false);
break;
case 'image':
fileSystem.writeImageFile(path, content.data, content.mediaType, false);
break;
case 'directory':
break;
}
} catch (error) {
warn(`Ignored Java filesystem file '${rawPath}' on '${nodeName}': ${describe(error)}`);
}
return;
}
const name = tree.kind === 'directory' ? safeName(tree.name) : undefined;
if (name === undefined) {
warn(`Ignored Java filesystem directory with missing or unsafe name on '${nodeName}'.`);
return;
}
const rawPath = parent === '/' ? `/${name}` : `${parent}/${name}`;
try {
const path = normalizedAbsolutePath(rawPath);
fileSystem.createDirectory(path, true);
for (const child of tree.children) append(child, path);
} catch (error) {
warn(`Ignored Java filesystem directory '${rawPath}' on '${nodeName}': ${describe(error)}`);
}
};
for (const child of root?.children ?? []) append(child, '/');
return fileSystem;
}
/**
* Swift's finalizeCurrentDocumentationCandidate: RECT = 1, TEXT = 2; a frame
* inside the editor's world; the id from the item's values and its order.