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:
+1
-1
@@ -37,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/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/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/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 (`swiftTrim`); 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, Dateisysteme, 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`), Rechner ohne Dateisystem leer wie in Java (Swift: Demo-Dateien); 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) |
|
| `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/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 |
|
| `src/javaIp.ts` | Java `IP.inetAton`/`ipCheck` | neu nach Java; an 46 Fällen mit Java gemessen |
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -69,6 +69,19 @@ export function swiftHasPrefix(value: string, prefix: string): boolean {
|
|||||||
return value.startsWith(prefix) || value.normalize('NFC').startsWith(prefix.normalize('NFC'));
|
return value.startsWith(prefix) || value.normalize('NFC').startsWith(prefix.normalize('NFC'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `(path as NSString).pathExtension` (measured): what follows the last dot
|
||||||
|
* of the last component, trailing slashes ignored — empty when the dot
|
||||||
|
* starts or ends the name (".bashrc", "a.").
|
||||||
|
*/
|
||||||
|
export function swiftPathExtension(path: string): string {
|
||||||
|
const trimmed = path.replace(/\/+$/, '');
|
||||||
|
const name = trimmed.slice(trimmed.lastIndexOf('/') + 1);
|
||||||
|
const dot = name.lastIndexOf('.');
|
||||||
|
if (dot <= 0) return '';
|
||||||
|
return name.slice(dot + 1);
|
||||||
|
}
|
||||||
|
|
||||||
const BASE64_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
const BASE64_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -126,5 +126,23 @@
|
|||||||
"\ufeff",
|
"\ufeff",
|
||||||
"a\ud83d\ude00",
|
"a\ud83d\ude00",
|
||||||
"a\ue000"
|
"a\ue000"
|
||||||
|
],
|
||||||
|
"pathExtension": [
|
||||||
|
"a.txt",
|
||||||
|
"a.tar.gz",
|
||||||
|
".bashrc",
|
||||||
|
"a.",
|
||||||
|
"a",
|
||||||
|
"/x/a.png",
|
||||||
|
"a.PNG",
|
||||||
|
"a..b",
|
||||||
|
"a b.c d",
|
||||||
|
"x.y/z",
|
||||||
|
"/x.y/",
|
||||||
|
"webserver/index.html",
|
||||||
|
"..",
|
||||||
|
"a.b.",
|
||||||
|
".a.b",
|
||||||
|
"\u00e9.PNG"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -2253,6 +2253,24 @@
|
|||||||
"äöü",
|
"äöü",
|
||||||
"k"
|
"k"
|
||||||
],
|
],
|
||||||
|
"pathExtension" : [
|
||||||
|
"txt",
|
||||||
|
"gz",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"png",
|
||||||
|
"PNG",
|
||||||
|
"b",
|
||||||
|
"c d",
|
||||||
|
"",
|
||||||
|
"y",
|
||||||
|
"html",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"b",
|
||||||
|
"PNG"
|
||||||
|
],
|
||||||
"sort" : [
|
"sort" : [
|
||||||
[
|
[
|
||||||
"B",
|
"B",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
swiftCompare,
|
swiftCompare,
|
||||||
swiftEqual,
|
swiftEqual,
|
||||||
swiftLowercased,
|
swiftLowercased,
|
||||||
|
swiftPathExtension,
|
||||||
swiftTrim,
|
swiftTrim,
|
||||||
} from '../src/index.js';
|
} from '../src/index.js';
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ const cases = JSON.parse(readFileSync(join(here, 'strings.cases.json'), 'utf8'))
|
|||||||
sort: string[][];
|
sort: string[][];
|
||||||
base64: string[];
|
base64: string[];
|
||||||
controlCharacters: string[];
|
controlCharacters: string[];
|
||||||
|
pathExtension: string[];
|
||||||
};
|
};
|
||||||
const swift = JSON.parse(readFileSync(join(here, 'strings.swift.json'), 'utf8')) as {
|
const swift = JSON.parse(readFileSync(join(here, 'strings.swift.json'), 'utf8')) as {
|
||||||
trim: string[];
|
trim: string[];
|
||||||
@@ -31,6 +33,7 @@ const swift = JSON.parse(readFileSync(join(here, 'strings.swift.json'), 'utf8'))
|
|||||||
sort: string[][];
|
sort: string[][];
|
||||||
base64: (number[] | null)[];
|
base64: (number[] | null)[];
|
||||||
controlCharacters: boolean[];
|
controlCharacters: boolean[];
|
||||||
|
pathExtension: string[];
|
||||||
whitespacesAndNewlinesSet: [number, number][];
|
whitespacesAndNewlinesSet: [number, number][];
|
||||||
controlCharactersSet: [number, number][];
|
controlCharactersSet: [number, number][];
|
||||||
};
|
};
|
||||||
@@ -76,6 +79,10 @@ describe('Swift string semantics', () => {
|
|||||||
expect(cases.controlCharacters.map(containsSwiftControlCharacter)).toEqual(swift.controlCharacters);
|
expect(cases.controlCharacters.map(containsSwiftControlCharacter)).toEqual(swift.controlCharacters);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('finds the extension like NSString.pathExtension', () => {
|
||||||
|
expect(cases.pathExtension.map(swiftPathExtension)).toEqual(swift.pathExtension);
|
||||||
|
});
|
||||||
|
|
||||||
it('trims exactly the scalars Swift trims, all of Unicode', () => {
|
it('trims exactly the scalars Swift trims, all of Unicode', () => {
|
||||||
expect(scalarRanges((scalar) => swiftTrim(`${scalar}a`) === 'a')).toEqual(swift.whitespacesAndNewlinesSet);
|
expect(scalarRanges((scalar) => swiftTrim(`${scalar}a`) === 'a')).toEqual(swift.whitespacesAndNewlinesSet);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,7 +24,10 @@
|
|||||||
// names stay as Java has them;
|
// names stay as Java has them;
|
||||||
// - a Java modem becomes an in-project remote link pair keyed by its port,
|
// - 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.
|
// 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 {
|
import {
|
||||||
canonicalMacAddress,
|
canonicalMacAddress,
|
||||||
@@ -38,7 +41,13 @@ import {
|
|||||||
inDeterministicRenderOrder,
|
inDeterministicRenderOrder,
|
||||||
interfaceKey,
|
interfaceKey,
|
||||||
isSafeFrame,
|
isSafeFrame,
|
||||||
|
swiftBase64Decode,
|
||||||
|
swiftLowercased,
|
||||||
|
swiftPathExtension,
|
||||||
swiftTrim,
|
swiftTrim,
|
||||||
|
normalizedAbsolutePath,
|
||||||
|
VirtualFileSystem,
|
||||||
|
VirtualFileSystemError,
|
||||||
topologyNode,
|
topologyNode,
|
||||||
DOCUMENTATION_BLACK,
|
DOCUMENTATION_BLACK,
|
||||||
DOCUMENTATION_PALE_YELLOW,
|
DOCUMENTATION_PALE_YELLOW,
|
||||||
@@ -64,8 +73,9 @@ import {
|
|||||||
type TopologyGraph,
|
type TopologyGraph,
|
||||||
type TopologyLink,
|
type TopologyLink,
|
||||||
type TopologyNode,
|
type TopologyNode,
|
||||||
|
type VirtualFileContent,
|
||||||
} from '@mdg-filius/core';
|
} 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';
|
import type { FlsDocumentationItem, FlsInterface, FlsNode, FlsNodeClass, FlsTopology } from './topology.js';
|
||||||
|
|
||||||
export interface ImportedProject {
|
export interface ImportedProject {
|
||||||
@@ -84,6 +94,8 @@ export interface ImportedProject {
|
|||||||
dhcpServerConfigurations: Map<Id, DhcpServerConfiguration>;
|
dhcpServerConfigurations: Map<Id, DhcpServerConfiguration>;
|
||||||
portForwardingRowsByNodeId: Map<Id, GatewayPortForwardingRow[]>;
|
portForwardingRowsByNodeId: Map<Id, GatewayPortForwardingRow[]>;
|
||||||
remoteLinkConfigurations: Map<Id, RemoteLinkConfiguration>;
|
remoteLinkConfigurations: Map<Id, RemoteLinkConfiguration>;
|
||||||
|
/** Every PC and notebook. */
|
||||||
|
virtualFileSystems: Map<Id, VirtualFileSystem>;
|
||||||
/** In render order: rectangles below text, then by order. */
|
/** In render order: rectangles below text, then by order. */
|
||||||
documentationItems: DocumentationItem[];
|
documentationItems: DocumentationItem[];
|
||||||
/** The topology's warnings, then this step's. */
|
/** The topology's warnings, then this step's. */
|
||||||
@@ -119,12 +131,16 @@ export function importProject(topology: FlsTopology, newId: () => Id): ImportedP
|
|||||||
dhcpServerConfigurations: new Map(),
|
dhcpServerConfigurations: new Map(),
|
||||||
portForwardingRowsByNodeId: new Map(),
|
portForwardingRowsByNodeId: new Map(),
|
||||||
remoteLinkConfigurations: new Map(),
|
remoteLinkConfigurations: new Map(),
|
||||||
|
virtualFileSystems: new Map(),
|
||||||
};
|
};
|
||||||
const nodes = topology.nodes.map((source) => {
|
const nodes = topology.nodes.map((source) => {
|
||||||
const node = importNode(source, newId, warnings);
|
const node = importNode(source, newId, warnings);
|
||||||
registerConfigurations(source, node, deviceConfigurations, interfaceConfigurations);
|
registerConfigurations(source, node, deviceConfigurations, interfaceConfigurations);
|
||||||
registerSystemConfigurations(source, node, system, warnings);
|
registerSystemConfigurations(source, node, system, warnings);
|
||||||
registerServiceConfigurations(source, node, system, newId, 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;
|
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[] = [];
|
const documentationItems: DocumentationItem[] = [];
|
||||||
for (const item of topology.documentation) {
|
for (const item of topology.documentation) {
|
||||||
const imported = importDocumentationItem(item, documentationItems.length, warnings);
|
const imported = importDocumentationItem(item, documentationItems.length, warnings);
|
||||||
@@ -311,6 +330,7 @@ type SystemConfigurations = Pick<
|
|||||||
| 'dhcpServerConfigurations'
|
| 'dhcpServerConfigurations'
|
||||||
| 'portForwardingRowsByNodeId'
|
| 'portForwardingRowsByNodeId'
|
||||||
| 'remoteLinkConfigurations'
|
| 'remoteLinkConfigurations'
|
||||||
|
| 'virtualFileSystems'
|
||||||
>;
|
>;
|
||||||
|
|
||||||
/** A row of text cells, trimmed, or undefined unless it has exactly four. */
|
/** 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
|
* Swift's finalizeCurrentDocumentationCandidate: RECT = 1, TEXT = 2; a frame
|
||||||
* inside the editor's world; the id from the item's values and its order.
|
* inside the editor's world; the id from the item's values and its order.
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
// file Swift reads completely. On the others Swift loses devices (plan §13,
|
// file Swift reads completely. On the others Swift loses devices (plan §13,
|
||||||
// M2); there Java is the reference, checked in topology.test.ts.
|
// M2); there Java is the reference, checked in topology.test.ts.
|
||||||
|
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
||||||
import { dirname, join, relative } from 'node:path';
|
import { dirname, join, relative } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
@@ -17,14 +18,17 @@ import {
|
|||||||
NetworkRuntimeEngine,
|
NetworkRuntimeEngine,
|
||||||
portSnapshot,
|
portSnapshot,
|
||||||
topologySnapshot,
|
topologySnapshot,
|
||||||
|
VirtualFileSystem,
|
||||||
type Id,
|
type Id,
|
||||||
type NodeKind,
|
type NodeKind,
|
||||||
|
type VirtualFileEntry,
|
||||||
} from '@mdg-filius/core';
|
} from '@mdg-filius/core';
|
||||||
import {
|
import {
|
||||||
decodeJavaBeans,
|
decodeJavaBeans,
|
||||||
importProject,
|
importProject,
|
||||||
readFlsArchive,
|
readFlsArchive,
|
||||||
readTopology,
|
readTopology,
|
||||||
|
type FlsFileTree,
|
||||||
type FlsNode,
|
type FlsNode,
|
||||||
type FlsTopology,
|
type FlsTopology,
|
||||||
type ImportedProject,
|
type ImportedProject,
|
||||||
@@ -58,6 +62,34 @@ function importFile(file: string): ImportedProject {
|
|||||||
return importProject(readTopology(values), counter());
|
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
|
* describeSystem's shape. A firewall as the core has it without one
|
||||||
* (inactive, drop, no rules) is left out: Swift keeps a configuration only
|
* (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,
|
device: project.deviceConfigurations.get(node.id) ?? null,
|
||||||
interfaces: node.ports.map((port) => project.interfaceConfigurations.get(interfaceKey(node.id, port.id)) ?? null),
|
interfaces: node.ports.map((port) => project.interfaceConfigurations.get(interfaceKey(node.id, port.id)) ?? null),
|
||||||
system: systemOf(project, node.id),
|
system: systemOf(project, node.id),
|
||||||
|
fileSystem: fileEntriesOf(project.virtualFileSystems.get(node.id)),
|
||||||
})),
|
})),
|
||||||
links: links.map((link) => [
|
links: links.map((link) => [
|
||||||
nodeIndex.get(link.sourceNodeId),
|
nodeIndex.get(link.sourceNodeId),
|
||||||
@@ -166,14 +199,18 @@ const JAVA_DEFAULT_NAMES: Partial<Record<NodeKind, string>> = {
|
|||||||
* pass unnoticed.
|
* pass unnoticed.
|
||||||
*/
|
*/
|
||||||
function withJavaDefaultNames(state: unknown): unknown {
|
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 {
|
return {
|
||||||
...rest,
|
...rest,
|
||||||
nodes: nodes.map((node) =>
|
nodes: nodes.map((node) => {
|
||||||
node.displayName === defaultDisplayName(node.kind) && JAVA_DEFAULT_NAMES[node.kind] !== undefined
|
const named =
|
||||||
? { ...node, displayName: JAVA_DEFAULT_NAMES[node.kind] }
|
node.displayName === defaultDisplayName(node.kind) && JAVA_DEFAULT_NAMES[node.kind] !== undefined
|
||||||
: node,
|
? { ...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] })) };
|
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) =>
|
const complete = (summary: SwiftSummary) =>
|
||||||
summary.skipped === 0 && !(summary.warnings ?? []).some((warning) => warning.startsWith('Skipped'));
|
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, () => {
|
it(name, () => {
|
||||||
const project = importFile(file);
|
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([]);
|
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', () => {
|
it('leaves out a cable to itself and one to a port that already has a cable', () => {
|
||||||
const project = importProject(
|
const project = importProject(
|
||||||
topology({
|
topology({
|
||||||
|
|||||||
@@ -325,6 +325,7 @@ func describeImportedState(_ state: TopologyEditorState) -> JSON {
|
|||||||
"device": device,
|
"device": device,
|
||||||
"interfaces": interfaces,
|
"interfaces": interfaces,
|
||||||
"system": describeSystem(state, node),
|
"system": describeSystem(state, node),
|
||||||
|
"fileSystem": state.virtualFileSystemsByNodeID[node.id].map { describeFileEntries($0.allEntries()) } ?? NSNull(),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
let links: [[Int]] = state.graph.links.map { link in
|
let links: [[Int]] = state.graph.links.map { link in
|
||||||
@@ -527,6 +528,7 @@ struct StringCases: Decodable {
|
|||||||
let sort: [[String]]
|
let sort: [[String]]
|
||||||
let base64: [String]
|
let base64: [String]
|
||||||
let controlCharacters: [String]
|
let controlCharacters: [String]
|
||||||
|
let pathExtension: [String]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What Swift makes of strings where JavaScript differs (packages/core/test/strings.cases.json).
|
/// What Swift makes of strings where JavaScript differs (packages/core/test/strings.cases.json).
|
||||||
@@ -542,6 +544,7 @@ func measureStrings(_ cases: StringCases) -> JSON {
|
|||||||
"controlCharacters": cases.controlCharacters.map { text in
|
"controlCharacters": cases.controlCharacters.map { text in
|
||||||
text.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) })
|
text.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) })
|
||||||
},
|
},
|
||||||
|
"pathExtension": cases.pathExtension.map { ($0 as NSString).pathExtension },
|
||||||
// The whole sets, scalar by scalar, as ranges [first, last].
|
// The whole sets, scalar by scalar, as ranges [first, last].
|
||||||
"whitespacesAndNewlinesSet": scalarRanges(in: .whitespacesAndNewlines),
|
"whitespacesAndNewlinesSet": scalarRanges(in: .whitespacesAndNewlines),
|
||||||
"controlCharactersSet": scalarRanges(in: .controlCharacters),
|
"controlCharactersSet": scalarRanges(in: .controlCharacters),
|
||||||
|
|||||||
Reference in New Issue
Block a user