FlsOracle now loads through SzenarioVerwaltung.laden, FILIUS's full loading path: the archive unpacked, the project's own applications copied where FILIUS's class loader finds them, then the configuration. --network keeps the configuration-only load (netzwerkLaden). On the corpus this changes two files: anwendung_komplett.fls and multicast_server.fls then have their own applications (Accumulator, Broadcast) installed. Java's views of the written and the exported files are byte-equal to before: Java-FILIUS 2.14 opens our exports, with the classes they carry, as it opens a file of its own, and sees what it sees in the original. Until own applications on hosts are written (B2), the reader, writer and export tests leave them out (test/ownApplications.ts); a test checks that only those two files are touched. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1029 lines
45 KiB
TypeScript
1029 lines
45 KiB
TypeScript
// Every corpus file, imported into the editor model and exported again:
|
|
// Java FILIUS loads the written file (tools/java-oracle, exported.java.json)
|
|
// to the topology of the original (corpus.java.json) — up to what the model
|
|
// decides differently, named below (DEPARTURES).
|
|
//
|
|
// UPDATE_EXPORTED=1 writes the files to test/exported/ (not checked in); the
|
|
// Java oracle then loads them and records each file's SHA-256, which must
|
|
// match what the export makes now.
|
|
|
|
import { createHash } from 'node:crypto';
|
|
import { mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
import { dirname, join, relative } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import {
|
|
defaultSwitchConfiguration,
|
|
dhcpServerConfiguration,
|
|
DOCUMENTATION_MINIMUM_SIZE,
|
|
firewallConfiguration,
|
|
firewallRule,
|
|
stableGeneratedMacAddress,
|
|
type EmailMessage,
|
|
type Id,
|
|
type InstallableProgram,
|
|
type TopologyNode,
|
|
VirtualFileSystem,
|
|
} from '@mdg-filius/core';
|
|
import { describe, expect, it } from 'vitest';
|
|
import {
|
|
decodeJavaBeans,
|
|
exportProject,
|
|
importProject,
|
|
javaBase64,
|
|
openProjectArchive,
|
|
readFlsArchive,
|
|
saveProjectArchive,
|
|
readTopology,
|
|
writeFlsArchive,
|
|
type FlsFileTree,
|
|
type ProjectToExport,
|
|
} from '../src/index.js';
|
|
import { writeTopologyXml } from '../src/topologyWriter.js';
|
|
import { withoutOwnApplications } from './ownApplications.js';
|
|
|
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
const corpusRoot = join(here, '../../../corpus');
|
|
const exportedRoot = join(here, 'exported');
|
|
|
|
function flsFiles(directory: string): string[] {
|
|
return readdirSync(directory).flatMap((name) => {
|
|
const path = join(directory, name);
|
|
if (statSync(path).isDirectory()) return flsFiles(path);
|
|
return name.endsWith('.fls') ? [path] : [];
|
|
});
|
|
}
|
|
|
|
/** Ids a test can read, in the order they are made. */
|
|
function counter(): () => Id {
|
|
let next = 0;
|
|
return () => `00000000-0000-0000-0000-${String(++next).padStart(12, '0')}`;
|
|
}
|
|
|
|
type JavaNode = {
|
|
class: string;
|
|
name: string | null;
|
|
bounds: number[];
|
|
useIpAsName?: boolean;
|
|
useMacAsName?: boolean;
|
|
ports: unknown;
|
|
system: Record<string, unknown>;
|
|
};
|
|
type JavaFile = { success: boolean; nodes: JavaNode[]; cables: unknown[]; documentation: unknown[]; sha256?: string };
|
|
|
|
const original = JSON.parse(readFileSync(join(corpusRoot, 'corpus.java.json'), 'utf8')) as Record<string, JavaFile>;
|
|
const exportedPath = join(here, 'exported.java.json');
|
|
const exported = JSON.parse(readFileSync(exportedPath, 'utf8')) as Record<string, JavaFile>;
|
|
|
|
/** The system software with its files in any order. */
|
|
function systemOf(settings: Record<string, unknown>) {
|
|
return settings.fileSystem === undefined || settings.fileSystem === null
|
|
? settings
|
|
: { ...settings, fileSystem: sortedTree(settings.fileSystem as JavaTree) };
|
|
}
|
|
|
|
/** The topology of Java's view: the label's position, not its size. */
|
|
function topologyOf(view: JavaFile) {
|
|
return {
|
|
success: view.success,
|
|
nodes: view.nodes.map((node) => ({
|
|
class: node.class,
|
|
name: node.name,
|
|
position: node.bounds.slice(0, 2),
|
|
useIpAsName: node.useIpAsName,
|
|
useMacAsName: node.useMacAsName,
|
|
ports: node.ports,
|
|
system: systemOf(node.system),
|
|
})),
|
|
cables: view.cables,
|
|
documentation: view.documentation,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* What the model decides differently from the Java file it was imported
|
|
* from, each applied to Java's view of the original:
|
|
* - generatedMac: a card whose MAC the file lacked (Java rolls a new one at
|
|
* each load) has the one the simulation uses, derived from its port id;
|
|
* - documentation: the items in the model's render order (rectangles below
|
|
* text), a missing text empty, a missing font Dialog 12, and at least the
|
|
* editor's smallest size (DOCUMENTATION_MINIMUM_SIZE);
|
|
* - switchSsid: a switch's SSID the file lacked (Java rolls one) is the one
|
|
* the model derives from the node's id;
|
|
* - modemRoles: the model keeps a pair of modems, not which one serves: the
|
|
* first of a pair in node order serves, the others connect. Both ways
|
|
* work within one project, where the client connects to localhost;
|
|
* - routerFiles: the model holds no files for a router or home router; what
|
|
* the file adds to the ones its constructor makes (a web server's
|
|
* www.conf) is not written;
|
|
* - fileTypes: a file's type follows from its kind in the model (fileType
|
|
* below), not the one the file named — Java names them unevenly itself
|
|
* (txt, text/txt, html, none);
|
|
* - fileOrder: directories first, then by name (compared regardless of
|
|
* order here);
|
|
* - recipientsAsStrings: a recipient an older file keeps as a string is the
|
|
* address Java's AddressEntry(String) reads from it (the oracle reports
|
|
* it next to the string), as the import reads it — Java puts the string
|
|
* into its typed list unchecked and its e-mail window fails on it;
|
|
* - sentFolder: the messages of the client's sent folder are sent ones;
|
|
* - accountFiles: /konten.txt and /mailserver/konten.txt are written anew
|
|
* from the model's accounts as FILIUS 2.14 writes them — with its types
|
|
* (text/txt, txt), the client's line without the line break older
|
|
* versions ended it with.
|
|
*/
|
|
const DEPARTURES = {
|
|
generatedMac(view: JavaFile, macs: (string | undefined)[][]): JavaFile {
|
|
return {
|
|
...view,
|
|
nodes: view.nodes.map((node, index) => ({
|
|
...node,
|
|
ports: (node.ports as Record<string, unknown>[]).map((port, portIndex) =>
|
|
'mac' in port && port.mac === null ? { ...port, mac: macs[index]![portIndex] } : port,
|
|
),
|
|
})),
|
|
};
|
|
},
|
|
switchSsid(view: JavaFile, nodeIds: Id[]): JavaFile {
|
|
return mapSystems(view, (node, index) =>
|
|
node.class === 'Switch' && node.system.ssid === null ? { ...node.system, ssid: defaultSwitchConfiguration(nodeIds[index]!).ssid } : node.system,
|
|
);
|
|
},
|
|
modemRoles(view: JavaFile, pairs: (string | undefined)[]): JavaFile {
|
|
const seen = new Set<string | undefined>();
|
|
return mapSystems(view, (node, index) => {
|
|
if (node.class !== 'Modem') return node.system;
|
|
const serves = !seen.has(pairs[index]);
|
|
seen.add(pairs[index]);
|
|
return { ...node.system, modem: { ...(node.system.modem as object), mode: serves ? 1 : 2 } };
|
|
});
|
|
},
|
|
routerFiles(view: JavaFile, added: number[]): JavaFile {
|
|
return mapSystems(view, (node, index) => {
|
|
if ((node.class !== 'Vermittlungsrechner' && node.class !== 'Gateway') || added[index] === 0) return node.system;
|
|
const tree = node.system.fileSystem as JavaTree;
|
|
return { ...node.system, fileSystem: { ...tree, children: tree.children.slice(0, tree.children.length - added[index]!) } };
|
|
});
|
|
},
|
|
fileTypes(view: JavaFile): JavaFile {
|
|
const retype = (tree: JavaTree): JavaTree => ({
|
|
...tree,
|
|
...('file' in tree ? { type: fileType(tree.file as string, tree.type as string | null) } : {}),
|
|
children: tree.children.map(retype),
|
|
});
|
|
return mapSystems(view, (node) =>
|
|
node.system.fileSystem === undefined || node.system.fileSystem === null
|
|
? node.system
|
|
: { ...node.system, fileSystem: retype(node.system.fileSystem as JavaTree) },
|
|
);
|
|
},
|
|
recipientsAsStrings(view: JavaFile): JavaFile {
|
|
return withRecipientsRead(view) as JavaFile;
|
|
},
|
|
sentFolder(view: JavaFile): JavaFile {
|
|
return mapSystems(view, (node) =>
|
|
node.system.applications === undefined
|
|
? node.system
|
|
: {
|
|
...node.system,
|
|
applications: (node.system.applications as Record<string, unknown>[]).map((application) =>
|
|
application.sent === undefined
|
|
? application
|
|
: { ...application, sent: (application.sent as Record<string, unknown>[]).map((message) => ({ ...message, versendet: true })) },
|
|
),
|
|
},
|
|
);
|
|
},
|
|
accountFiles(view: JavaFile, contents: (Map<string, string> | undefined)[]): JavaFile {
|
|
/** The tree at a path ("" for the root), with each account file as the export writes it. */
|
|
const rewrite = (tree: JavaTree, path: string, files: Map<string, string>): JavaTree => {
|
|
const children = tree.children.map((child) => rewrite(child, `${path}/${String(child.file ?? child.directory)}`, files));
|
|
const content = 'file' in tree ? files.get(path) : undefined;
|
|
if (content === undefined) return { ...tree, children };
|
|
const client = path.toLowerCase() === '/konten.txt';
|
|
return { ...tree, ...javaFile(tree.file as string, client ? 'text/txt' : 'txt', client ? content.replace(/\n$/, '') : content), children };
|
|
};
|
|
return mapSystems(view, (node, index) => {
|
|
const tree = node.system.fileSystem as JavaTree | null | undefined;
|
|
const files = contents[index];
|
|
return tree === undefined || tree === null || files === undefined ? node.system : { ...node.system, fileSystem: rewrite(tree, '', files) };
|
|
});
|
|
},
|
|
documentation(view: JavaFile): JavaFile {
|
|
const items = (view.documentation as Record<string, unknown>[]).map((item) => {
|
|
const [x, y, width, height] = item.bounds as number[];
|
|
return {
|
|
...item,
|
|
text: item.text ?? '',
|
|
bounds: [x, y, Math.max(width!, DOCUMENTATION_MINIMUM_SIZE.width), Math.max(height!, DOCUMENTATION_MINIMUM_SIZE.height)],
|
|
font: item.font ?? ['Dialog', 12, 0],
|
|
};
|
|
});
|
|
return { ...view, documentation: sortedDocumentation(items) };
|
|
},
|
|
};
|
|
|
|
type JavaTree = { file?: string; directory?: string | null; type?: string | null; children: JavaTree[] } & Record<string, unknown>;
|
|
|
|
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 TEXT_EXTENSIONS = new Set(['txt', 'html', 'htm', 'css', 'xml', 'csv', 'conf', 'log']);
|
|
|
|
/**
|
|
* The type of a file in the model's kind: an image (by its type or
|
|
* extension) by its media type, "jpg" for JPEG; text by a known extension,
|
|
* else "text"; other data by its type.
|
|
*/
|
|
function fileType(name: string, type: string | null): string {
|
|
const declared = (type ?? '').trim().toLowerCase();
|
|
const dot = name.lastIndexOf('.');
|
|
const extension = dot < 0 ? '' : name.slice(dot + 1).toLowerCase();
|
|
if (IMAGE_TYPES.has(declared) || IMAGE_TYPES.has(extension)) {
|
|
const subtype = declared === 'image' || declared === '' ? (extension === '' ? 'png' : extension) : declared;
|
|
return subtype === 'jpeg' ? 'jpg' : subtype;
|
|
}
|
|
if (TEXT_TYPES.has(declared)) return TEXT_EXTENSIONS.has(extension) ? extension : 'text';
|
|
return declared;
|
|
}
|
|
|
|
/** A file tree with every directory's children in an order that does not depend on the list's. */
|
|
function sortedTree(tree: JavaTree): JavaTree {
|
|
const children = tree.children.map(sortedTree);
|
|
return { ...tree, children: children.map((child) => JSON.stringify(child)).sort().map((text) => JSON.parse(text) as JavaTree) };
|
|
}
|
|
|
|
/** Every node's file tree as text, sorted or as it is. */
|
|
function fileTrees(view: JavaFile, sorted: boolean): string {
|
|
return JSON.stringify(
|
|
view.nodes.map((node) => {
|
|
const tree = node.system.fileSystem as JavaTree | null | undefined;
|
|
return tree === undefined || tree === null ? null : sorted ? sortedTree(tree) : tree;
|
|
}),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The contents of /konten.txt (with the client's account) and
|
|
* /mailserver/konten.txt (with the server) in a host's files, as the file
|
|
* has them: the ones the export writes anew.
|
|
*/
|
|
function accountFileContents(root: FlsFileTree | undefined, project: ReturnType<typeof importProject>, id: Id): Map<string, string> | undefined {
|
|
if (root === undefined) return undefined;
|
|
const result = new Map<string, string>();
|
|
const programs = project.installedPrograms.get(id);
|
|
const child = (tree: FlsFileTree, name: string) => tree.children.find((each) => each.kind !== 'other' && each.name?.toLowerCase() === name);
|
|
const client = child(root, 'konten.txt');
|
|
if (programs?.has('emailClient') && project.emailClientConfigurations.get(id)?.account != null && client?.kind === 'file') {
|
|
result.set(`/${client.name}`, client.content ?? '');
|
|
}
|
|
const directory = child(root, 'mailserver');
|
|
const server = directory?.kind === 'directory' ? child(directory, 'konten.txt') : undefined;
|
|
if (programs?.has('emailServer') && directory?.kind === 'directory' && server?.kind === 'file') {
|
|
result.set(`/${directory.name}/${server.name}`, server.content ?? '');
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/** Each recipient as Java reads its string, none null. */
|
|
function withRecipientsRead(value: unknown, key = ''): unknown {
|
|
if (Array.isArray(value)) {
|
|
if (key === 'empfaenger' || key === 'cc' || key === 'bcc') {
|
|
return value.filter((entry) => entry !== null).map((entry) => (entry[0] === 'string' ? entry[2] : entry));
|
|
}
|
|
return value.map((item) => withRecipientsRead(item));
|
|
}
|
|
if (value !== null && typeof value === 'object') {
|
|
return Object.fromEntries(Object.entries(value).map(([name, item]) => [name, withRecipientsRead(item, name)]));
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function mapSystems(view: JavaFile, system: (node: JavaNode, index: number) => Record<string, unknown>): JavaFile {
|
|
return { ...view, nodes: view.nodes.map((node, index) => ({ ...node, system: system(node, index) })) };
|
|
}
|
|
|
|
/** Documentation items in an order that does not depend on the list's. */
|
|
function sortedDocumentation(items: unknown[]): unknown[] {
|
|
return items.map((item) => JSON.stringify(item)).sort().map((text) => JSON.parse(text) as unknown);
|
|
}
|
|
|
|
/** Every port id each exported node's MAC could be derived from, by node and port. */
|
|
function portIds(project: ReturnType<typeof importProject>): Id[][] {
|
|
return project.graph.nodes.map((node) => node.ports.map((port) => port.id));
|
|
}
|
|
|
|
const departed = {
|
|
generatedMac: new Set<string>(),
|
|
documentation: new Set<string>(),
|
|
switchSsid: new Set<string>(),
|
|
modemRoles: new Set<string>(),
|
|
routerFiles: new Set<string>(),
|
|
fileTypes: new Set<string>(),
|
|
fileOrder: new Set<string>(),
|
|
recipientsAsStrings: new Set<string>(),
|
|
sentFolder: new Set<string>(),
|
|
accountFiles: new Set<string>(),
|
|
};
|
|
|
|
describe('the editor model exported, as Java loads it', () => {
|
|
for (const file of flsFiles(corpusRoot)) {
|
|
const name = relative(corpusRoot, file);
|
|
it(name, () => {
|
|
const bytes = new Uint8Array(readFileSync(file));
|
|
const topology = readTopology(decodeJavaBeans(readFlsArchive(bytes).configurationXml));
|
|
const project = openProjectArchive(bytes, counter());
|
|
const { topology: toWrite } = exportProject(project);
|
|
const { archive } = saveProjectArchive(project);
|
|
// The project's own applications, and every other entry, byte for byte.
|
|
expect(readFlsArchive(archive).supplementalEntries).toEqual(readFlsArchive(bytes).supplementalEntries);
|
|
if (process.env.UPDATE_EXPORTED === '1') {
|
|
mkdirSync(dirname(join(exportedRoot, name)), { recursive: true });
|
|
writeFileSync(join(exportedRoot, name), archive);
|
|
}
|
|
const java = exported[name];
|
|
expect(java?.sha256, 'exported.java.json is not about this output: UPDATE_EXPORTED=1, then npm run oracle:java').toBe(
|
|
createHash('sha256').update(archive).digest('hex'),
|
|
);
|
|
// A MAC the file lacked: the one derived from the port's id.
|
|
const ids = portIds(project);
|
|
const macs = toWrite.nodes.map((node, index) =>
|
|
node.interfaces.map((nic, portIndex) => {
|
|
const derived = stableGeneratedMacAddress(ids[index]![portIndex]!);
|
|
return nic.mac === derived ? derived : undefined;
|
|
}),
|
|
);
|
|
// The model does not install own applications yet (B2).
|
|
const source = withoutOwnApplications(original[name]!);
|
|
const nodeIds = project.graph.nodes.map((node) => node.id);
|
|
const pairs = nodeIds.map((id) => project.remoteLinkConfigurations.get(id)?.pairIdentifier);
|
|
// The account files of each host the export writes anew, by path, as the file has them.
|
|
const accountFiles = topology.nodes.map((node, index) => accountFileContents(node.system.fileSystem, project, nodeIds[index]!));
|
|
// What each router's file adds to its constructor's files.
|
|
const added = topology.nodes.map((node) => (node.system.templateFiles === true ? (node.system.fileSystem?.children.length ?? 0) : 0));
|
|
const steps: [keyof typeof departed, (view: JavaFile) => JavaFile][] = [
|
|
['generatedMac', (view) => DEPARTURES.generatedMac(view, macs)],
|
|
['switchSsid', (view) => DEPARTURES.switchSsid(view, nodeIds)],
|
|
['modemRoles', (view) => DEPARTURES.modemRoles(view, pairs)],
|
|
['routerFiles', (view) => DEPARTURES.routerFiles(view, added)],
|
|
['fileTypes', (view) => DEPARTURES.fileTypes(view)],
|
|
['recipientsAsStrings', (view) => DEPARTURES.recipientsAsStrings(view)],
|
|
['sentFolder', (view) => DEPARTURES.sentFolder(view)],
|
|
['accountFiles', (view) => DEPARTURES.accountFiles(view, accountFiles)],
|
|
['documentation', (view) => DEPARTURES.documentation(view)],
|
|
];
|
|
let expected = source;
|
|
for (const [departure, apply] of steps) {
|
|
const next = apply(expected);
|
|
if (departure === 'documentation') {
|
|
if (JSON.stringify(next.documentation) !== JSON.stringify(expected.documentation)) departed[departure].add(name);
|
|
} else if (JSON.stringify(next) !== JSON.stringify(expected)) {
|
|
departed[departure].add(name);
|
|
}
|
|
expected = next;
|
|
}
|
|
if (fileTrees(expected, true) === fileTrees(java!, true) && fileTrees(expected, false) !== fileTrees(java!, false)) {
|
|
departed.fileOrder.add(name);
|
|
}
|
|
expect(topologyOf({ ...java!, documentation: sortedDocumentation(java!.documentation) })).toEqual(topologyOf(expected));
|
|
});
|
|
}
|
|
|
|
it('departs from Java where the model decides differently', () => {
|
|
for (const [departure, files] of Object.entries(departed)) expect(files.size, departure).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
// ── A model beyond the corpus, loaded by Java ──────────────────────────────
|
|
|
|
/** A model without nodes, settings or documentation. */
|
|
function emptyProject(): ProjectToExport {
|
|
return {
|
|
graph: { nodes: [], links: [] },
|
|
deviceConfigurations: new Map(),
|
|
interfaceConfigurations: new Map(),
|
|
hostWirelessConfigurations: new Map(),
|
|
switchConfigurations: new Map(),
|
|
manualRoutesByNodeId: new Map(),
|
|
ripEnabledByNodeId: new Map(),
|
|
firewallConfigurationsByNodeId: new Map(),
|
|
dhcpClientConfigurations: new Map(),
|
|
dhcpServerConfigurations: new Map(),
|
|
portForwardingRowsByNodeId: new Map(),
|
|
remoteLinkConfigurations: new Map(),
|
|
virtualFileSystems: new Map(),
|
|
installedPrograms: new Map(),
|
|
activePrograms: new Map(),
|
|
webServerConfigurations: new Map(),
|
|
gnutellaConfigurations: new Map(),
|
|
dnsServerConfigurations: new Map(),
|
|
echoServerConfigurations: new Map(),
|
|
simpleClientConfigurations: new Map(),
|
|
emailClientConfigurations: new Map(),
|
|
emailServerConfigurations: new Map(),
|
|
documentationItems: [],
|
|
};
|
|
}
|
|
|
|
const port = (id: string) => ({ id, label: id, isOccupied: false });
|
|
|
|
/**
|
|
* A message with every character Java escapes in a text, a separator in its
|
|
* subject (a space in the account file), a sender with name and address,
|
|
* recipients without a name.
|
|
*/
|
|
const SYNTHETIC_MAIL: EmailMessage = {
|
|
id: 1,
|
|
from: { name: 'Anna', mailAddress: 'anna@schule.de' },
|
|
to: [
|
|
{ name: null, mailAddress: 'ben@schule.de' },
|
|
{ name: 'Carla', mailAddress: 'carla@schule.de' },
|
|
],
|
|
cc: [],
|
|
bcc: [{ name: null, mailAddress: 'eva@schule.de' }],
|
|
subject: 'Hallo; Welt',
|
|
body: 'a$b;c|d#e\nzweite Zeile',
|
|
receivedAtMilliseconds: 1_727_690_000_000,
|
|
isNew: false,
|
|
isMarkedForDeletion: true,
|
|
isSent: false,
|
|
};
|
|
|
|
/** SYNTHETIC_MAIL as the oracle describes it after Java loaded it. */
|
|
function javaMail(changes: Record<string, unknown> = {}) {
|
|
return {
|
|
absender: ['Anna', 'anna@schule.de'],
|
|
empfaenger: [[null, 'ben@schule.de'], ['Carla', 'carla@schule.de']],
|
|
cc: [],
|
|
bcc: [[null, 'eva@schule.de']],
|
|
betreff: 'Hallo; Welt',
|
|
text: 'a$b;c|d#e\nzweite Zeile',
|
|
dateReceived: '1727690000000',
|
|
neu: false,
|
|
delete: true,
|
|
versendet: false,
|
|
...changes,
|
|
};
|
|
}
|
|
|
|
const SERVER_ACCOUNTS_FILE =
|
|
'anna;schule.de;geheim;Muster;Anna;#Anna <anna@schule.de>$<ben@schule.de>,Carla <carla@schule.de>$$<eva@schule.de>$1727690000000$Hallo Welt$' +
|
|
'a&&00036&&b&&00059&&c&&00124&&d&&00035&&e|zweite Zeile\n';
|
|
const CLIENT_ACCOUNT_FILE = '10.0.0.1;10.0.0.1;110;25;anna;geheim;Muster;Anna;anna@schule.de';
|
|
|
|
/** Bytes of a length: (index * 37 + 11) mod 256. */
|
|
function bytesOf(length: number): Uint8Array {
|
|
return Uint8Array.from({ length }, (_, index) => (index * 37 + 11) % 256);
|
|
}
|
|
|
|
/**
|
|
* Nested directories, text with a known extension and without one, a PNG and
|
|
* a JPEG, binary data with and without a media type, 57 bytes — one line of
|
|
* Base64 exactly.
|
|
*/
|
|
function syntheticFiles(): VirtualFileSystem {
|
|
const files = new VirtualFileSystem();
|
|
files.createDirectory('/docs/deep', true);
|
|
files.writeTextFile('/docs/readme.txt', 'Zeile 1\r\nZeile 2');
|
|
files.writeTextFile('/docs/deep/notes', 'no extension');
|
|
files.writeTextFile('/index.HTML', '<p>upper case extension</p>');
|
|
files.writeImageFile('/bild.png', bytesOf(57), 'image/png');
|
|
files.writeImageFile('/foto.jpg', bytesOf(100), 'image/jpeg');
|
|
files.writeBinaryFile('/daten.bin', bytesOf(58), 'application/zip');
|
|
files.writeBinaryFile('/roh', bytesOf(3));
|
|
// The web server's and the peer-to-peer application's own: Java makes none.
|
|
files.createDirectory('/webserver');
|
|
files.writeTextFile('/webserver/index.html', '<p>own</p>');
|
|
files.createDirectory('/peer2peer');
|
|
return files;
|
|
}
|
|
|
|
/** The files a router's or home router's constructor makes, as Java loaded them from the corpus. */
|
|
function templateOf(javaClass: string): unknown[] {
|
|
const node = Object.values(original)
|
|
.flatMap((file) => file.nodes)
|
|
.find((each) => each.class === javaClass);
|
|
return (node!.system.fileSystem as JavaTree).children.slice(0, 1);
|
|
}
|
|
|
|
/** A file as the oracle describes it: its content's UTF-16 length and the SHA-256 of its UTF-8 bytes. */
|
|
function javaFile(name: string, type: string | null, content: string) {
|
|
return { file: name, type, length: content.length, sha256: createHash('sha256').update(content, 'utf8').digest('hex'), children: [] };
|
|
}
|
|
|
|
/**
|
|
* What the corpus lacks: a label with IP and MAC, a position between
|
|
* pixels, a switch with five ports, a wireless notebook, a router with three
|
|
* interfaces and a gateway, a half-transparent bold text and a rectangle.
|
|
*/
|
|
const SYNTHETIC: ProjectToExport = {
|
|
...emptyProject(),
|
|
graph: {
|
|
nodes: [
|
|
{ id: 'pc', kind: 'pc', displayName: 'PC', hostLabelPolicy: 'ipAndMAC', position: { x: 10.6, y: 20.2 }, ports: [port('p0')] },
|
|
{ id: 'sw', kind: 'networkSwitch', displayName: 'SW', hostLabelPolicy: 'manual', position: { x: 100, y: 20 }, ports: ['s0', 's1', 's2', 's3', 's4'].map(port) },
|
|
{ id: 'nb', kind: 'notebook', displayName: 'NB', hostLabelPolicy: 'macAddress', position: { x: 100, y: 120 }, ports: [port('n0')] },
|
|
{ id: 'rt', kind: 'router', displayName: 'RT', hostLabelPolicy: 'manual', position: { x: 200, y: 20 }, ports: ['r0', 'r1', 'r2'].map(port) },
|
|
{ id: 'gw', kind: 'gateway', displayName: 'GW', hostLabelPolicy: 'manual', position: { x: 300, y: 20 }, ports: ['g0', 'g1'].map(port) },
|
|
{ id: 'm1', kind: 'remoteLink', displayName: 'M1', hostLabelPolicy: 'manual', position: { x: 400, y: 20 }, ports: [port('m1p')] },
|
|
{ id: 'm2', kind: 'remoteLink', displayName: 'M2', hostLabelPolicy: 'manual', position: { x: 500, y: 20 }, ports: [port('m2p')] },
|
|
{ id: 'm3', kind: 'remoteLink', displayName: 'M3', hostLabelPolicy: 'manual', position: { x: 600, y: 20 }, ports: [port('m3p')] },
|
|
],
|
|
links: [
|
|
{ id: 'l1', sourceNodeId: 'pc', sourcePortId: 'p0', targetNodeId: 'sw', targetPortId: 's4' },
|
|
{ id: 'l2', sourceNodeId: 'nb', sourcePortId: 'n0', targetNodeId: 'sw', targetPortId: 's0' },
|
|
{ id: 'l3', sourceNodeId: 'sw', sourcePortId: 's2', targetNodeId: 'rt', targetPortId: 'r1' },
|
|
],
|
|
},
|
|
deviceConfigurations: new Map([
|
|
['pc', { ipAddress: '10.0.0.1', subnetMask: '255.255.255.0', defaultGateway: '10.0.0.254', dnsServer: '10.0.0.53' }],
|
|
['nb', { ipAddress: '10.0.0.2', subnetMask: '255.255.255.0', defaultGateway: '', dnsServer: '' }],
|
|
['rt', { ipAddress: '10.0.0.254', subnetMask: '255.255.255.0', defaultGateway: '10.9.9.9', dnsServer: '10.0.0.53' }],
|
|
]),
|
|
interfaceConfigurations: new Map([
|
|
['rt/r0', { ipAddress: '10.1.0.1', subnetMask: '255.255.0.0' }],
|
|
['rt/r1', { ipAddress: '10.0.0.254', subnetMask: '255.255.255.0' }],
|
|
['rt/r2', { ipAddress: '10.2.0.1', subnetMask: '255.255.0.0' }],
|
|
['gw/g0', { ipAddress: '42.0.0.7', subnetMask: '255.0.0.0' }],
|
|
['gw/g1', { ipAddress: '192.168.1.1', subnetMask: '255.255.255.0' }],
|
|
]),
|
|
switchConfigurations: new Map([['sw', { ssid: 'S-net', retentionTimeMilliseconds: 1234 }]]),
|
|
manualRoutesByNodeId: new Map([
|
|
['pc', [{ destinationNetwork: '10.5.0.0', subnetMask: '255.255.0.0', gateway: '10.0.0.254', interfaceIPAddress: '10.0.0.1' }]],
|
|
['rt', [{ destinationNetwork: '10.6.0.0', subnetMask: '255.255.0.0', gateway: '10.1.0.2', interfaceIPAddress: '10.1.0.1' }]],
|
|
]),
|
|
ripEnabledByNodeId: new Map([['rt', true]]),
|
|
firewallConfigurationsByNodeId: new Map([
|
|
[
|
|
'pc',
|
|
firewallConfiguration({
|
|
isActive: false,
|
|
defaultPolicy: 'accept',
|
|
rules: [firewallRule({ sourceIPAddress: '10.0.0.0', sourceSubnetMask: '255.0.0.0', port: 22, action: 'accept' })],
|
|
}),
|
|
],
|
|
[
|
|
'rt',
|
|
firewallConfiguration({
|
|
isActive: true,
|
|
defaultPolicy: 'accept',
|
|
dropIcmp: true,
|
|
filterSynSegmentsOnly: false,
|
|
filterUdp: false,
|
|
rules: [
|
|
firewallRule({ sourceIPAddress: '10.0.0.0', sourceSubnetMask: '255.0.0.0', protocol: 'all', action: 'accept' }),
|
|
firewallRule({ destinationIPAddress: '10.2.0.5', destinationSubnetMask: '255.255.255.255', port: 80, protocol: 'tcp' }),
|
|
firewallRule({ port: 53, protocol: 'udp', action: 'accept' }),
|
|
firewallRule({ protocol: 'icmp' }),
|
|
],
|
|
}),
|
|
],
|
|
]),
|
|
dhcpClientConfigurations: new Map([
|
|
['pc', { isEnabled: true }],
|
|
['gw', { isEnabled: true }],
|
|
]),
|
|
dhcpServerConfigurations: new Map([
|
|
[
|
|
'gw',
|
|
dhcpServerConfiguration({
|
|
isActive: true,
|
|
lowerBoundIPAddress: '192.168.1.100',
|
|
upperBoundIPAddress: '192.168.1.199',
|
|
gatewayIPAddress: '192.168.1.1',
|
|
dnsServerIPAddress: '8.8.8.8',
|
|
useOwnSettings: true,
|
|
staticAssignments: [{ id: 'a1', macAddress: 'AA:BB:CC:DD:EE:77', ipAddress: '192.168.1.77' }],
|
|
}),
|
|
],
|
|
]),
|
|
portForwardingRowsByNodeId: new Map([['gw', [{ protocolValue: 'TCP', publicPortValue: '8080', lanIPAddress: '192.168.1.10', lanPortValue: '80' }]]]),
|
|
virtualFileSystems: new Map([['pc', syntheticFiles()]]),
|
|
installedPrograms: new Map<Id, Set<InstallableProgram>>([
|
|
[
|
|
'pc',
|
|
new Set<InstallableProgram>([
|
|
'webServer',
|
|
'dnsServer',
|
|
'echoServer',
|
|
'simpleClient',
|
|
'gnutella',
|
|
'firewall',
|
|
'commandPrompt',
|
|
'textEditor',
|
|
'fileExplorer',
|
|
'imageViewer',
|
|
'webBrowser',
|
|
'emailServer',
|
|
'emailClient',
|
|
]),
|
|
],
|
|
['nb', new Set<InstallableProgram>(['gnutella', 'simpleClient', 'firewall'])],
|
|
]),
|
|
emailServerConfigurations: new Map([
|
|
[
|
|
'pc',
|
|
{
|
|
domain: 'schule.de',
|
|
pop3Port: 110,
|
|
accounts: [{ username: 'anna', password: 'geheim', firstName: 'Anna', lastName: 'Muster', mailbox: [SYNTHETIC_MAIL] }],
|
|
nextMessageID: 2,
|
|
},
|
|
],
|
|
]),
|
|
emailClientConfigurations: new Map([
|
|
[
|
|
'pc',
|
|
{
|
|
account: {
|
|
pop3Host: '10.0.0.1',
|
|
pop3Port: 110,
|
|
smtpHost: '10.0.0.1',
|
|
smtpPort: 25,
|
|
username: 'anna',
|
|
password: 'geheim',
|
|
firstName: 'Anna',
|
|
lastName: 'Muster',
|
|
email: 'anna@schule.de',
|
|
},
|
|
inbox: [SYNTHETIC_MAIL],
|
|
sent: [{ ...SYNTHETIC_MAIL, id: 2, isSent: true }],
|
|
drafts: [{ ...SYNTHETIC_MAIL, id: 3, subject: '', body: '', to: [] }],
|
|
addressBook: [
|
|
{ name: 'Muster', firstName: 'Anna', street: 'Weg 1', houseNumber: 12, postalCode: 20095, city: 'Hamburg', email: 'anna@schule.de', phone: '040' },
|
|
],
|
|
nextMessageID: 4,
|
|
},
|
|
],
|
|
]),
|
|
activePrograms: new Map([['pc', new Set<InstallableProgram>(['webServer', 'dnsServer', 'echoServer', 'emailServer'])]]),
|
|
webServerConfigurations: new Map([['pc', { port: 8080, documentRoot: '/webserver', virtualHosts: true }]]),
|
|
dnsServerConfigurations: new Map([['pc', { recursiveResolutionEnabled: true, records: [] }]]),
|
|
echoServerConfigurations: new Map([['pc', { port: 7777 }]]),
|
|
simpleClientConfigurations: new Map([
|
|
['pc', { targetAddress: '10.0.0.9', targetPort: 7777 }],
|
|
['nb', { targetAddress: '', targetPort: 55_555 }],
|
|
]),
|
|
gnutellaConfigurations: new Map([['pc', { maximumKnownPeers: 7 }]]),
|
|
remoteLinkConfigurations: new Map([
|
|
['m1', { pairIdentifier: 'java-modem-port-4242', latencyMilliseconds: 20, isEnabled: true }],
|
|
['m2', { pairIdentifier: 'java-modem-port-4242', latencyMilliseconds: 20, isEnabled: true }],
|
|
['m3', { pairIdentifier: 'our own pair', latencyMilliseconds: 20, isEnabled: true }],
|
|
]),
|
|
hostWirelessConfigurations: new Map([['nb', { isEnabled: true, ssid: 'net' }]]),
|
|
documentationItems: [
|
|
{
|
|
id: 'd1',
|
|
kind: 'rectangle',
|
|
frame: { x: 5, y: 6, width: 300, height: 200 },
|
|
text: '',
|
|
color: { red: 1, green: 0.5, blue: 0, alpha: 0.5 },
|
|
fontName: 'Dialog',
|
|
fontSize: 12,
|
|
isBold: false,
|
|
order: 0,
|
|
},
|
|
{
|
|
id: 'd2',
|
|
kind: 'text',
|
|
frame: { x: 40.4, y: 50.6, width: 120, height: 30 },
|
|
text: 'Netz A\nzweite Zeile',
|
|
color: { red: 0, green: 0, blue: 1, alpha: 1 },
|
|
fontName: 'SansSerif',
|
|
fontSize: 16,
|
|
isBold: true,
|
|
order: 1,
|
|
},
|
|
],
|
|
};
|
|
|
|
describe('a model beyond the corpus, as Java loads it', () => {
|
|
it('keeps what the corpus never has', () => {
|
|
const name = 'synthetic/model.fls';
|
|
const { topology: toWrite, warnings } = exportProject(SYNTHETIC);
|
|
const archive = writeFlsArchive(new TextEncoder().encode(writeTopologyXml(toWrite)));
|
|
if (process.env.UPDATE_EXPORTED === '1') {
|
|
mkdirSync(join(exportedRoot, 'synthetic'), { recursive: true });
|
|
writeFileSync(join(exportedRoot, name), archive);
|
|
}
|
|
const java = exported[name];
|
|
expect(java?.sha256, 'exported.java.json is not about this output: UPDATE_EXPORTED=1, then npm run oracle:java').toBe(
|
|
createHash('sha256').update(archive).digest('hex'),
|
|
);
|
|
expect(warnings).toEqual(["node 'PC': /mailserver/konten.txt: a subject holds a character Java's account file cannot carry, written there with a space"]);
|
|
const serverAccount = (nachrichten: unknown[]) => ({
|
|
benutzername: 'anna',
|
|
passwort: 'geheim',
|
|
nachname: 'Muster',
|
|
vorname: 'Anna',
|
|
emailAdresse: null,
|
|
pop3server: null,
|
|
pop3port: null,
|
|
smtpserver: null,
|
|
smtpport: null,
|
|
nachrichten,
|
|
});
|
|
const card = (ip: string, mask: string, mac: string, gateway: string, dns: string, wireless = false) => ({ ip, mask, mac, gateway, dns, wireless });
|
|
const modem = (name: string, x: number, mode: number, port: number) => ({
|
|
class: 'Modem',
|
|
name,
|
|
position: [x, 20],
|
|
useIpAsName: undefined,
|
|
useMacAsName: undefined,
|
|
ports: [{}],
|
|
system: { modem: { mode, port, ipAdresse: 'localhost' } },
|
|
});
|
|
expect(topologyOf(java!)).toEqual({
|
|
success: true,
|
|
nodes: [
|
|
{
|
|
class: 'Rechner',
|
|
name: 'PC',
|
|
position: [11, 20],
|
|
useIpAsName: true,
|
|
useMacAsName: true,
|
|
ports: [card('10.0.0.1', '255.255.255.0', stableGeneratedMacAddress('p0'), '10.0.0.254', '10.0.0.53')],
|
|
system: {
|
|
ssid: null,
|
|
manualRoutes: [['10.5.0.0', '255.255.0.0', '10.0.0.254', '10.0.0.1']],
|
|
dhcpClient: true,
|
|
dhcpServer: JAVA_DHCP_DEFAULTS,
|
|
fileSystem: sortedTree({
|
|
directory: 'root',
|
|
children: [
|
|
{
|
|
directory: 'docs',
|
|
children: [{ directory: 'deep', children: [javaFile('notes', 'text', 'no extension')] }, javaFile('readme.txt', 'txt', 'Zeile 1\r\nZeile 2')],
|
|
},
|
|
javaFile('bild.png', 'png', javaBase64(bytesOf(57))),
|
|
javaFile('daten.bin', 'zip', javaBase64(bytesOf(58))),
|
|
javaFile('foto.jpg', 'jpg', javaBase64(bytesOf(100))),
|
|
javaFile('index.HTML', 'html', '<p>upper case extension</p>'),
|
|
javaFile('roh', '', javaBase64(bytesOf(3))),
|
|
{ directory: 'webserver', children: [javaFile('index.html', 'html', '<p>own</p>')] },
|
|
{ directory: 'peer2peer', children: [] },
|
|
javaFile('konten.txt', 'text/txt', CLIENT_ACCOUNT_FILE),
|
|
{ directory: 'mailserver', children: [javaFile('konten.txt', 'txt', SERVER_ACCOUNTS_FILE)] },
|
|
],
|
|
}),
|
|
// In the order of Java's key.
|
|
applications: [
|
|
{ key: 'filius.software.clientserver.ClientBaustein', class: 'filius.software.clientserver.ClientBaustein', zielIPAdresse: '10.0.0.9', zielPort: 7777 },
|
|
{ key: 'filius.software.clientserver.ServerBaustein', class: 'filius.software.clientserver.ServerBaustein', aktiv: true, port: 7777 },
|
|
{ key: 'filius.software.dateiaustausch.PeerToPeerAnwendung', class: 'filius.software.dateiaustausch.PeerToPeerAnwendung', maxTeilnehmerZahl: 7 },
|
|
{ key: 'filius.software.dns.DNSServer', class: 'filius.software.dns.DNSServer', aktiv: true, port: 53, recursiveResolutionEnabled: true },
|
|
{
|
|
key: 'filius.software.email.EmailAnwendung',
|
|
class: 'filius.software.email.EmailAnwendung',
|
|
received: [javaMail()],
|
|
sent: [javaMail({ versendet: true })],
|
|
drafts: [javaMail({ betreff: '', text: '', empfaenger: [] })],
|
|
addressBook: [['Muster', 'Anna', 'Weg 1', 12, 20095, 'Hamburg', 'anna@schule.de', '040']],
|
|
// /konten.txt as Java's laden() reads it.
|
|
fileAccount: {
|
|
benutzername: 'anna',
|
|
passwort: 'geheim',
|
|
nachname: 'Muster',
|
|
vorname: 'Anna',
|
|
emailAdresse: 'anna@schule.de',
|
|
pop3server: '10.0.0.1',
|
|
pop3port: '110',
|
|
smtpserver: '10.0.0.1',
|
|
smtpport: '25',
|
|
nachrichten: [],
|
|
},
|
|
},
|
|
{
|
|
key: 'filius.software.email.EmailServer',
|
|
class: 'filius.software.email.EmailServer',
|
|
aktiv: true,
|
|
mailDomain: 'schule.de',
|
|
beanAccounts: [serverAccount([javaMail()])],
|
|
// /mailserver/konten.txt as Java's kontenLaden() reads it: the subject with a space, a message's flags as new.
|
|
fileAccounts: [serverAccount([javaMail({ betreff: 'Hallo Welt', neu: true, delete: false })])],
|
|
},
|
|
{
|
|
key: 'filius.software.firewall.Firewall',
|
|
class: 'filius.software.firewall.Firewall',
|
|
activated: false,
|
|
defaultPolicy: 1,
|
|
dropICMP: false,
|
|
filterSYNSegmentsOnly: true,
|
|
filterUdp: true,
|
|
rules: [['10.0.0.0', '255.0.0.0', '', '', 22, 6, 1]],
|
|
},
|
|
{ key: 'filius.software.lokal.FileExplorer', class: 'filius.software.lokal.FileExplorer' },
|
|
{ key: 'filius.software.lokal.ImageViewer', class: 'filius.software.lokal.ImageViewer' },
|
|
{ key: 'filius.software.lokal.Terminal', class: 'filius.software.lokal.Terminal' },
|
|
{ key: 'filius.software.lokal.TextEditor', class: 'filius.software.lokal.TextEditor' },
|
|
{ key: 'filius.software.www.WebBrowser', class: 'filius.software.www.WebBrowser' },
|
|
{ key: 'filius.software.www.WebServer', class: 'filius.software.www.WebServer', aktiv: true, port: 8080, useVHost: true },
|
|
],
|
|
},
|
|
},
|
|
{
|
|
class: 'Switch',
|
|
name: 'SW',
|
|
position: [100, 20],
|
|
useIpAsName: undefined,
|
|
useMacAsName: undefined,
|
|
ports: [{}, {}, {}, {}, {}],
|
|
system: { ssid: 'S-net', retentionTime: 1234 },
|
|
},
|
|
{
|
|
class: 'Notebook',
|
|
name: 'NB',
|
|
position: [100, 120],
|
|
useIpAsName: false,
|
|
useMacAsName: true,
|
|
ports: [card('10.0.0.2', '255.255.255.0', stableGeneratedMacAddress('n0'), '', '', true)],
|
|
system: {
|
|
ssid: 'net',
|
|
manualRoutes: [],
|
|
dhcpClient: false,
|
|
dhcpServer: JAVA_DHCP_DEFAULTS,
|
|
// Made by the peer-to-peer application.
|
|
fileSystem: { directory: 'root', children: [{ directory: 'peer2peer', children: [] }] },
|
|
applications: [
|
|
// Without a setting: no target (null), the model's 3 peers, Java's personal firewall.
|
|
{ key: 'filius.software.clientserver.ClientBaustein', class: 'filius.software.clientserver.ClientBaustein', zielIPAdresse: null, zielPort: 55_555 },
|
|
{ key: 'filius.software.dateiaustausch.PeerToPeerAnwendung', class: 'filius.software.dateiaustausch.PeerToPeerAnwendung', maxTeilnehmerZahl: 3 },
|
|
{
|
|
key: 'filius.software.firewall.Firewall',
|
|
class: 'filius.software.firewall.Firewall',
|
|
activated: true,
|
|
defaultPolicy: 0,
|
|
dropICMP: false,
|
|
filterSYNSegmentsOnly: true,
|
|
filterUdp: true,
|
|
rules: [],
|
|
},
|
|
],
|
|
},
|
|
},
|
|
{
|
|
class: 'Vermittlungsrechner',
|
|
name: 'RT',
|
|
position: [200, 20],
|
|
useIpAsName: undefined,
|
|
useMacAsName: undefined,
|
|
ports: [
|
|
card('10.1.0.1', '255.255.0.0', stableGeneratedMacAddress('r0'), '10.9.9.9', '10.0.0.53'),
|
|
card('10.0.0.254', '255.255.255.0', stableGeneratedMacAddress('r1'), '10.9.9.9', '10.0.0.53'),
|
|
card('10.2.0.1', '255.255.0.0', stableGeneratedMacAddress('r2'), '10.9.9.9', '10.0.0.53'),
|
|
],
|
|
system: {
|
|
ripEnabled: true,
|
|
fileSystem: sortedTree({ directory: 'root', children: templateOf('Vermittlungsrechner') as JavaTree[] }),
|
|
manualRoutes: [['10.6.0.0', '255.255.0.0', '10.1.0.2', '10.1.0.1']],
|
|
dhcpClient: false,
|
|
dhcpServer: JAVA_DHCP_DEFAULTS,
|
|
firewall: {
|
|
activated: true,
|
|
defaultPolicy: 1,
|
|
dropICMP: true,
|
|
filterSYNSegmentsOnly: false,
|
|
filterUdp: false,
|
|
rules: [
|
|
['10.0.0.0', '255.0.0.0', '', '', -1, -1, 1],
|
|
['', '', '10.2.0.5', '255.255.255.255', 80, 6, 0],
|
|
['', '', '', '', 53, 17, 1],
|
|
['', '', '', '', -1, 1, 0],
|
|
],
|
|
},
|
|
},
|
|
},
|
|
{
|
|
class: 'Gateway',
|
|
name: 'GW',
|
|
position: [300, 20],
|
|
useIpAsName: undefined,
|
|
useMacAsName: undefined,
|
|
ports: [
|
|
card('42.0.0.7', '255.0.0.0', stableGeneratedMacAddress('g0'), '', ''),
|
|
card('192.168.1.1', '255.255.255.0', stableGeneratedMacAddress('g1'), '', ''),
|
|
],
|
|
system: {
|
|
fileSystem: sortedTree({ directory: 'root', children: templateOf('Gateway') as JavaTree[] }),
|
|
manualRoutes: [],
|
|
dhcpClient: true,
|
|
dhcpServer: {
|
|
aktiv: true,
|
|
untergrenze: '192.168.1.100',
|
|
obergrenze: '192.168.1.199',
|
|
gatewayip: '192.168.1.1',
|
|
dnsserverip: '8.8.8.8',
|
|
ownSettings: true,
|
|
staticAssignedAddresses: ['AA:BB:CC:DD:EE:77 192.168.1.77'],
|
|
},
|
|
staticNAT: [['TCP', '8080', '192.168.1.10', '80']],
|
|
firewall: { activated: false, defaultPolicy: 0, dropICMP: false, filterSYNSegmentsOnly: true, filterUdp: true, rules: [] },
|
|
},
|
|
},
|
|
// A pair on Java's port: the first serves; a pair of our own: Java's default port.
|
|
modem('M1', 400, 1, 4242),
|
|
modem('M2', 500, 2, 4242),
|
|
modem('M3', 600, 1, 12345),
|
|
],
|
|
cables: [
|
|
{ ends: [[0, 0], [1, 4]], wireless: false, panel: [0, 1] },
|
|
{ ends: [[2, 0], [1, 0]], wireless: true, panel: [2, 1] },
|
|
{ ends: [[1, 2], [3, 1]], wireless: false, panel: [1, 3] },
|
|
],
|
|
documentation: [
|
|
{ type: 1, text: '', bounds: [5, 6, 300, 200], color: [255, 128, 0, 128], font: ['Dialog', 12, 0] },
|
|
{ type: 2, text: 'Netz A\nzweite Zeile', bounds: [40, 51, 120, 30], color: [0, 0, 255, 255], font: ['SansSerif', 16, 1] },
|
|
],
|
|
});
|
|
});
|
|
});
|
|
|
|
/** The DHCP server as every IP stack's constructor leaves it. */
|
|
const JAVA_DHCP_DEFAULTS = {
|
|
aktiv: false,
|
|
untergrenze: '0.0.0.0',
|
|
obergrenze: '0.0.0.0',
|
|
gatewayip: '0.0.0.0',
|
|
dnsserverip: '0.0.0.0',
|
|
ownSettings: false,
|
|
staticAssignedAddresses: [],
|
|
};
|
|
|
|
// ── Rules the corpus does not exercise ─────────────────────────────────────
|
|
|
|
describe('javaBase64', () => {
|
|
// FILIUS's Base64.encodeBytes around its line length (tools/java-oracle Base64Cases).
|
|
const cases = JSON.parse(readFileSync(join(here, 'base64.java.json'), 'utf8')) as [number, string][];
|
|
it.each(cases)('encodes %i bytes as Java does', (length, java) => {
|
|
const bytes = Uint8Array.from({ length }, (_, index) => (index * 37 + 11) % 256);
|
|
expect(javaBase64(bytes)).toBe(java);
|
|
});
|
|
});
|
|
|
|
describe('exportProject rules', () => {
|
|
const node = (id: string, kind: TopologyNode['kind'], ports: string[], extra: Partial<TopologyNode> = {}): TopologyNode => ({
|
|
id,
|
|
kind,
|
|
displayName: id,
|
|
hostLabelPolicy: 'manual',
|
|
position: { x: 0, y: 0 },
|
|
ports: ports.map(port),
|
|
...extra,
|
|
});
|
|
const link = (id: string, source: [string, string], target: [string, string]) => ({
|
|
id,
|
|
sourceNodeId: source[0],
|
|
sourcePortId: source[1],
|
|
targetNodeId: target[0],
|
|
targetPortId: target[1],
|
|
});
|
|
const project = (nodes: TopologyNode[], links: ReturnType<typeof link>[], wireless: [string, boolean][] = []): ProjectToExport => ({
|
|
...emptyProject(),
|
|
graph: { nodes, links },
|
|
hostWirelessConfigurations: new Map(wireless.map(([id, isEnabled]) => [id, { isEnabled, ssid: 'net' }])),
|
|
});
|
|
|
|
it('leaves out a node of a kind Java does not have, and the links to it', () => {
|
|
const { topology, warnings } = exportProject(
|
|
project(
|
|
[node('pc', 'pc', ['p0']), node('odd', 'unsupported', ['o0']), node('sw', 'networkSwitch', ['s0', 's1'])],
|
|
[link('l1', ['pc', 'p0'], ['odd', 'o0']), link('l2', ['pc', 'p0'], ['sw', 's1']), link('l3', ['pc', 'missing'], ['sw', 's0'])],
|
|
),
|
|
);
|
|
expect(topology.nodes.map((each) => each.javaClass)).toEqual(['Rechner', 'Switch']);
|
|
expect(topology.links).toEqual([{ ends: [{ node: 0, port: 0 }, { node: 1, port: 1 }], wireless: false, panel: [0, 1] }]);
|
|
expect(warnings).toEqual([
|
|
"node 'odd': a kind Java FILIUS does not have, left out",
|
|
'link 1: an end is not a port of an exported node, left out',
|
|
'link 3: an end is not a port of an exported node, left out',
|
|
]);
|
|
});
|
|
|
|
it('joins wirelessly only a host with a wireless card and a switch', () => {
|
|
const { topology } = exportProject(
|
|
project(
|
|
[
|
|
node('nb', 'notebook', ['n0']),
|
|
node('sw', 'networkSwitch', ['s0', 's1']),
|
|
node('nb2', 'notebook', ['m0']),
|
|
node('rt', 'router', ['r0']),
|
|
node('pc', 'pc', ['p0']),
|
|
],
|
|
[link('l1', ['sw', 's0'], ['nb', 'n0']), link('l2', ['nb2', 'm0'], ['rt', 'r0']), link('l3', ['pc', 'p0'], ['sw', 's1'])],
|
|
[
|
|
['nb', true],
|
|
['nb2', true],
|
|
['pc', false],
|
|
],
|
|
),
|
|
);
|
|
expect(topology.links.map((each) => each.wireless)).toEqual([true, false, false]);
|
|
expect(topology.nodes.map((each) => each.interfaces.map((nic) => nic.wireless))).toEqual([[true], [], [true], [false], [false]]);
|
|
});
|
|
|
|
it('rounds positions to Java ints and shows IP and MAC as the policy says', () => {
|
|
const { topology } = exportProject(
|
|
project([node('pc', 'pc', ['p0'], { position: { x: 10.5, y: -3.4 }, hostLabelPolicy: 'ipAndMAC' })], []),
|
|
);
|
|
expect(topology.nodes[0]).toMatchObject({ x: 11, y: -3, useIpAsName: true, useMacAsName: true, portCount: 1 });
|
|
expect(topology.nodes[0]!.interfaces[0]!.mac).toBe(stableGeneratedMacAddress('p0'));
|
|
});
|
|
});
|