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.
+173 -7
View File
@@ -3,6 +3,7 @@
// file Swift reads completely. On the others Swift loses devices (plan §13,
// M2); there Java is the reference, checked in topology.test.ts.
import { createHash } from 'node:crypto';
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { dirname, join, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -17,14 +18,17 @@ import {
NetworkRuntimeEngine,
portSnapshot,
topologySnapshot,
VirtualFileSystem,
type Id,
type NodeKind,
type VirtualFileEntry,
} from '@mdg-filius/core';
import {
decodeJavaBeans,
importProject,
readFlsArchive,
readTopology,
type FlsFileTree,
type FlsNode,
type FlsTopology,
type ImportedProject,
@@ -58,6 +62,34 @@ function importFile(file: string): ImportedProject {
return importProject(readTopology(values), counter());
}
/** describeFileEntries' shape (tools/swift-oracle). */
function describeFileEntries(entries: VirtualFileEntry[]): unknown[] {
return entries.map(({ path, content }) => {
switch (content.kind) {
case 'directory':
return { path, kind: 'directory' };
case 'text':
return { path, kind: 'text', text: content.text };
case 'binary':
case 'image':
return {
path,
kind: content.kind,
bytes: content.data.length,
mediaType: content.mediaType ?? null,
sha256: createHash('sha256').update(content.data).digest('hex'),
};
}
});
}
function fileEntriesOf(fileSystem: VirtualFileSystem | undefined): unknown {
return fileSystem === undefined ? null : describeFileEntries(fileSystem.allEntries());
}
const DEFAULT_FILES = describeFileEntries(VirtualFileSystem.defaultForDevice().allEntries());
const EMPTY_FILES = describeFileEntries(new VirtualFileSystem().allEntries());
/**
* describeSystem's shape. A firewall as the core has it without one
* (inactive, drop, no rules) is left out: Swift keeps a configuration only
@@ -132,6 +164,7 @@ function asSwiftState(project: ImportedProject) {
device: project.deviceConfigurations.get(node.id) ?? null,
interfaces: node.ports.map((port) => project.interfaceConfigurations.get(interfaceKey(node.id, port.id)) ?? null),
system: systemOf(project, node.id),
fileSystem: fileEntriesOf(project.virtualFileSystems.get(node.id)),
})),
links: links.map((link) => [
nodeIndex.get(link.sourceNodeId),
@@ -166,14 +199,18 @@ const JAVA_DEFAULT_NAMES: Partial<Record<NodeKind, string>> = {
* pass unnoticed.
*/
function withJavaDefaultNames(state: unknown): unknown {
const { nodes, ...rest } = state as { nodes: { kind: NodeKind; displayName: string }[] };
const { nodes, ...rest } = state as { nodes: { kind: NodeKind; displayName: string; fileSystem: unknown }[] };
return {
...rest,
nodes: nodes.map((node) =>
node.displayName === defaultDisplayName(node.kind) && JAVA_DEFAULT_NAMES[node.kind] !== undefined
? { ...node, displayName: JAVA_DEFAULT_NAMES[node.kind] }
: node,
),
nodes: nodes.map((node) => {
const named =
node.displayName === defaultDisplayName(node.kind) && JAVA_DEFAULT_NAMES[node.kind] !== undefined
? { ...node, displayName: JAVA_DEFAULT_NAMES[node.kind] }
: node;
// A host the file gives no files: empty, as in Java — Swift fills in
// the demo files of a new device.
return deepEqual(named.fileSystem, DEFAULT_FILES) ? { ...named, fileSystem: EMPTY_FILES } : named;
}),
};
}
@@ -198,6 +235,41 @@ function withSwiftErrorsCorrected(name: string, state: unknown): unknown {
return { ...rest, nodes: nodes.map((node, index) => ({ ...node, ...corrections[index] })) };
}
/**
* What applications do to a file system, which the applications step ports:
* Swift keeps the e-mail and Gnutella state as files (left out on both
* sides here), and moves a web server's /webserver to /www (compared here
* by reading our /webserver as /www where Swift has one). Everything else
* in a file system must match.
*/
const APPLICATION_DIRECTORIES = ['/mail', '/mailserver', '/peer2peer'];
type FileEntries = { path: string }[] | null;
function withoutApplicationDirectories(state: unknown, webRootOf?: (index: number) => boolean): unknown {
const { nodes, ...rest } = state as { nodes: { fileSystem: FileEntries }[] };
const outside = (entry: { path: string }) =>
!APPLICATION_DIRECTORIES.some((directory) => entry.path === directory || entry.path.startsWith(`${directory}/`));
const asWebRoot = (entry: { path: string }) =>
entry.path === '/webserver' || entry.path.startsWith('/webserver/')
? { ...entry, path: `/www${entry.path.slice('/webserver'.length)}` }
: entry;
return {
...rest,
nodes: nodes.map((node, index) => {
if (node.fileSystem === null) return node;
const entries = node.fileSystem.filter(outside).map((entry) => (webRootOf?.(index) ? asWebRoot(entry) : entry));
return { ...node, fileSystem: entries.sort((lhs, rhs) => (lhs.path < rhs.path ? -1 : lhs.path > rhs.path ? 1 : 0)) };
}),
};
}
/** Whether Swift moved node `index`'s web files to /www. */
function swiftWebRoots(state: unknown): (index: number) => boolean {
const { nodes } = state as { nodes: { fileSystem: FileEntries }[] };
return (index) => nodes[index]?.fileSystem?.some((entry) => entry.path === '/www') ?? false;
}
const complete = (summary: SwiftSummary) =>
summary.skipped === 0 && !(summary.warnings ?? []).some((warning) => warning.startsWith('Skipped'));
@@ -213,7 +285,10 @@ describe('corpus: the imported model as Swift builds it', () => {
it(name, () => {
const project = importFile(file);
expect(asSwiftState(project)).toEqual(withSwiftErrorsCorrected(name, withJavaDefaultNames(swiftView[name]!.state)));
const expected = withSwiftErrorsCorrected(name, withJavaDefaultNames(swiftView[name]!.state));
expect(withoutApplicationDirectories(asSwiftState(project), swiftWebRoots(expected))).toEqual(
withoutApplicationDirectories(expected),
);
expect(project.warnings).toEqual([]);
});
}
@@ -549,6 +624,97 @@ describe('importProject rules', () => {
]);
});
it("maps a host's files by type and extension, as Swift does, and says what it had to guess", () => {
const file = (name: string | null, type: string | null, content: string | null): FlsFileTree => ({
kind: 'file',
name,
type,
content,
children: [],
});
const directory = (name: string | null, children: FlsFileTree[]): FlsFileTree => ({ kind: 'directory', name, children });
const project = importProject(
topology({
nodes: [
flsNode('Rechner', {
interfaces: [nic('10.0.0.1')],
system: {
fileSystem: directory('root', [
directory(' docs ', [
file('a.txt', 'text/txt', 'hallo'),
file('pic.JPG', 'txt', 'AAEC'),
file('bad.png', 'png', 'not base64!'),
file('blob.bin', 'Bin', 'AAEC'),
file('odd.xyz', 'xyz', 'plain words'),
file('empty.dat', 'dat', '!!!'),
file('icon', 'image', 'AAEC'),
file(' ', 'txt', 'x'),
file('A.TXT', 'txt', 'collides'),
]),
directory(null, [file('lost.txt', 'txt', 'x')]),
directory('docs', []),
]),
},
}),
],
}),
counter(),
);
const files = project.virtualFileSystems.get(project.graph.nodes[0]!.id)!;
expect(files.allEntries().map(({ path, content }) => [path, content.kind, 'mediaType' in content ? content.mediaType : undefined])).toEqual([
['/', 'directory', undefined],
['/docs', 'directory', undefined],
['/docs/a.txt', 'text', undefined],
['/docs/bad.png', 'binary', 'application/png'],
['/docs/blob.bin', 'binary', 'application/bin'],
['/docs/empty.dat', 'text', undefined],
['/docs/icon', 'image', 'image/png'],
['/docs/odd.xyz', 'text', undefined],
['/docs/pic.JPG', 'image', 'image/txt'],
]);
expect(project.warnings).toEqual([
"Java file '/docs/bad.png' declared image type 'png' but was not valid Base64; imported as UTF-8 binary data.",
"Java file '/docs/blob.bin' used unknown type 'bin'; preserved as binary data.",
"Java file '/docs/odd.xyz' used unknown type 'xyz'; preserved as UTF-8 text.",
"Java file '/docs/empty.dat' used unknown type 'dat'; preserved as UTF-8 text.",
"Ignored Java filesystem file with missing or unsafe name on 'Rechner'.",
"Ignored Java filesystem file '/docs/A.TXT' on 'Rechner': caseInsensitiveSiblingCollision(/docs/a.txt, /docs/A.TXT)",
"Ignored Java filesystem directory with missing or unsafe name on 'Rechner'.",
"Ignored Java filesystem directory '/docs' on 'Rechner': itemAlreadyExists(/docs)",
]);
});
it('leaves out a file over the per-file quota, and rejects an import over the project quota', () => {
const tooBig = 'A'.repeat(8 * 1024 * 1024 + 1);
const host = (files: FlsFileTree[]) =>
flsNode('Rechner', { interfaces: [nic('10.0.0.1')], system: { fileSystem: { kind: 'directory', name: 'root', children: files } } });
const single = importProject(topology({ nodes: [host([{ kind: 'file', name: 'big.txt', type: 'txt', content: tooBig, children: [] }])] }), counter());
expect(single.virtualFileSystems.get(single.graph.nodes[0]!.id)!.contains('/big.txt')).toBe(false);
expect(single.warnings).toEqual([
"Ignored Java filesystem file '/big.txt' on 'Rechner': fileSizeQuotaExceeded(/big.txt, 8388609, 8388608)",
]);
// Three hosts at their 32 MiB limit exceed the 64 MiB project limit:
// the import fails as a whole (one 8 MiB string, shared).
const limit = 'A'.repeat(8 * 1024 * 1024);
const fullHost = () =>
host([0, 1, 2, 3].map((index): FlsFileTree => ({ kind: 'file', name: `f${index}.txt`, type: 'txt', content: limit, children: [] })));
expect(() => importProject(topology({ nodes: [fullHost(), fullHost(), fullHost()] }), counter())).toThrow(
'totalSizeQuotaExceeded(100663296, 67108864)',
);
// Three devices at their 32 MiB limit exceed the 64 MiB project limit.
const full = new Uint8Array(8 * 1024 * 1024);
const devices = [0, 1, 2].map(() => {
const fileSystem = new VirtualFileSystem();
for (let index = 0; index < 4; index++) fileSystem.writeBinaryFile(`/f${index}`, full);
return fileSystem;
});
expect(() => VirtualFileSystem.validateProjectQuotas(devices)).toThrow('totalSizeQuotaExceeded(100663296, 67108864)');
});
it('leaves out a cable to itself and one to a port that already has a cable', () => {
const project = importProject(
topology({