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:
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user