feat(fls): export the editor model's e-mail
exportProject.ts, step X4b, which completes the export: the e-mail beans from the model — the server's domain, accounts and mailboxes, the client's three folders and address book. The account files are written anew from the model's accounts, so that they say what the model says: /mailserver/konten.txt, which Java's server reads at every simulation start, and /konten.txt, the client's account. The format is FILIUS 2.14's (listeBenutzerkontenZuString, EmailKonto.toString) without its errors: empty fields where Java writes "null". Java's readers drop empty fields (StringTokenizer) and a trailing empty one (String.split); the files are written as a correct reader, this package's, reads them. Java escapes only a message's text; a character another field cannot carry (a separator, a line break) becomes a space there, with a warning, and the beans keep it. Checked by Java: every corpus file, imported, exported and written, loads with the e-mail programs of the original up to named departures: a string recipient read as an address (as the writer does), the sent folder's messages sent (the import's decision), the account files written anew with Java's types and without the line break older versions ended the client's with. In the synthetic model Java reads both account files back as the model means them, every escaped character of the text included. 17 mutation probes, each loaded by Java, were all caught. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,7 @@ import {
|
||||
firewallConfiguration,
|
||||
firewallRule,
|
||||
stableGeneratedMacAddress,
|
||||
type EmailMessage,
|
||||
type Id,
|
||||
type InstallableProgram,
|
||||
type TopologyNode,
|
||||
@@ -32,6 +33,7 @@ import {
|
||||
readFlsArchive,
|
||||
readTopology,
|
||||
writeFlsArchive,
|
||||
type FlsFileTree,
|
||||
type ProjectToExport,
|
||||
} from '../src/index.js';
|
||||
import { writeTopologyXml } from '../src/topologyWriter.js';
|
||||
@@ -69,21 +71,8 @@ const original = JSON.parse(readFileSync(join(corpusRoot, 'corpus.java.json'), '
|
||||
const exportedPath = join(here, 'exported.java.json');
|
||||
const exported = JSON.parse(readFileSync(exportedPath, 'utf8')) as Record<string, JavaFile>;
|
||||
|
||||
/**
|
||||
* The system software without what a later step exports — the e-mail
|
||||
* programs' domain, bean accounts, folders and address book; files in any
|
||||
* order.
|
||||
*/
|
||||
function systemOf(system: Record<string, unknown>) {
|
||||
const settings =
|
||||
system.applications === undefined
|
||||
? system
|
||||
: {
|
||||
...system,
|
||||
applications: (system.applications as Record<string, unknown>[]).map(
|
||||
({ mailDomain: _domain, beanAccounts: _accounts, received: _received, sent: _sent, drafts: _drafts, addressBook: _book, ...rest }) => rest,
|
||||
),
|
||||
};
|
||||
/** 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) };
|
||||
@@ -127,7 +116,16 @@ function topologyOf(view: JavaFile) {
|
||||
* 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).
|
||||
* 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 {
|
||||
@@ -174,6 +172,38 @@ const DEPARTURES = {
|
||||
: { ...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[];
|
||||
@@ -188,7 +218,7 @@ const DEPARTURES = {
|
||||
},
|
||||
};
|
||||
|
||||
type JavaTree = { file?: string; type?: string | null; children: JavaTree[] } & Record<string, unknown>;
|
||||
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']);
|
||||
@@ -227,6 +257,42 @@ function fileTrees(view: JavaFile, sorted: boolean): string {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) })) };
|
||||
}
|
||||
@@ -249,6 +315,9 @@ const departed = {
|
||||
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', () => {
|
||||
@@ -278,6 +347,8 @@ describe('the editor model exported, as Java loads it', () => {
|
||||
const source = 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][] = [
|
||||
@@ -286,6 +357,9 @@ describe('the editor model exported, as Java loads it', () => {
|
||||
['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;
|
||||
@@ -335,12 +409,58 @@ function emptyProject(): ProjectToExport {
|
||||
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);
|
||||
@@ -484,11 +604,49 @@ const SYNTHETIC: ProjectToExport = {
|
||||
'fileExplorer',
|
||||
'imageViewer',
|
||||
'webBrowser',
|
||||
'emailServer',
|
||||
'emailClient',
|
||||
]),
|
||||
],
|
||||
['nb', new Set<InstallableProgram>(['gnutella', 'simpleClient', 'firewall'])],
|
||||
]),
|
||||
activePrograms: new Map([['pc', new Set<InstallableProgram>(['webServer', 'dnsServer', 'echoServer'])]]),
|
||||
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 }]]),
|
||||
@@ -542,7 +700,19 @@ describe('a model beyond the corpus, as Java loads it', () => {
|
||||
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([]);
|
||||
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',
|
||||
@@ -582,6 +752,8 @@ describe('a model beyond the corpus, as Java loads it', () => {
|
||||
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.
|
||||
@@ -590,6 +762,36 @@ describe('a model beyond the corpus, as Java loads it', () => {
|
||||
{ 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',
|
||||
|
||||
Reference in New Issue
Block a user