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:
@@ -30,8 +30,20 @@
|
||||
// model keeps it), the echo server's port, the generic client's target, the
|
||||
// peer count, the personal firewall. A DNS server's records are in
|
||||
// /dns/hosts, one of the files, and it forwards to the host's DNS server as
|
||||
// Java's does. E-mail accounts, messages and address books are a later
|
||||
// step. System software, files and programs
|
||||
// Java's does.
|
||||
//
|
||||
// E-mail: the server's domain and accounts with their mailboxes, the client's
|
||||
// folders and address book, as Java's beans. The account files are written
|
||||
// anew from the model's accounts, in the format Java writes them
|
||||
// (EmailServer.listeBenutzerkontenZuString, EmailKonto.toString), 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.
|
||||
// Java's errors are not copied: a field is written empty where Java writes
|
||||
// "null", and the format is written as Java's own readers should read it —
|
||||
// they drop empty fields (StringTokenizer) and a trailing empty one
|
||||
// (String.split), the readers of this package do not. A character a field
|
||||
// cannot carry in the format (a separator, a line break) becomes a space,
|
||||
// with a warning; the beans keep it. System software, files and programs
|
||||
// follow in later steps.
|
||||
//
|
||||
// What the model keeps is written as it keeps it, and so differs from the
|
||||
@@ -52,7 +64,10 @@
|
||||
// type their kind says, not the one the file named; a router's own files
|
||||
// (a web server's www.conf) are not in the model;
|
||||
// - a peer-to-peer application's peer count the file lacked (Java rolls
|
||||
// 3, 4 or 5) is the model's 3.
|
||||
// 3, 4 or 5) is the model's 3;
|
||||
// - e-mail as the model keeps it (importEmail.ts): an older file's client
|
||||
// account in /konten.txt, where FILIUS 2.14 no longer reads it; account
|
||||
// files written from the model's accounts.
|
||||
//
|
||||
// Checked by Java: every corpus file, imported, exported and written, loads in
|
||||
// Java FILIUS to the topology of the original up to those departures.
|
||||
@@ -71,6 +86,11 @@ import {
|
||||
type DhcpServerConfiguration,
|
||||
type DnsServerConfiguration,
|
||||
type EchoServerConfiguration,
|
||||
type EmailAddress,
|
||||
type EmailClientConfiguration,
|
||||
type EmailContact,
|
||||
type EmailMessage,
|
||||
type EmailServerConfiguration,
|
||||
type GnutellaConfiguration,
|
||||
type InstallableProgram,
|
||||
type SimpleClientConfiguration,
|
||||
@@ -91,6 +111,7 @@ import {
|
||||
type TopologyNode,
|
||||
type VirtualFileSystem,
|
||||
} from '@mdg-filius/core';
|
||||
import { EMAIL_CLIENT_FILE, EMAIL_SERVER_FILE, type JavaAddressEntry, type JavaContact, type JavaEmail } from './javaEmail.js';
|
||||
import { PROGRAM_OF_CLASS } from './importProject.js';
|
||||
import type { FlsApplicationClass, FlsDhcpServer, FlsFileTree, FlsFirewall } from './systemSoftware.js';
|
||||
import type { FlsDocumentationItem, FlsInterface, FlsLink, FlsNodeClass } from './topology.js';
|
||||
@@ -118,6 +139,8 @@ export interface ProjectToExport {
|
||||
readonly dnsServerConfigurations: ReadonlyMap<Id, DnsServerConfiguration>;
|
||||
readonly echoServerConfigurations: ReadonlyMap<Id, EchoServerConfiguration>;
|
||||
readonly simpleClientConfigurations: ReadonlyMap<Id, SimpleClientConfiguration>;
|
||||
readonly emailClientConfigurations: ReadonlyMap<Id, EmailClientConfiguration>;
|
||||
readonly emailServerConfigurations: ReadonlyMap<Id, EmailServerConfiguration>;
|
||||
readonly documentationItems: readonly DocumentationItem[];
|
||||
}
|
||||
|
||||
@@ -144,7 +167,7 @@ export function exportProject(project: ProjectToExport): ExportedTopology {
|
||||
});
|
||||
const indexOfNode = new Map(exported.map((node, index) => [node.id, index]));
|
||||
const servingModems = firstOfEachPair(exported, project);
|
||||
const nodes = exported.map((node) => exportNode(node, project, servingModems));
|
||||
const nodes = exported.map((node) => exportNode(node, project, servingModems, warnings));
|
||||
|
||||
const links: FlsLink[] = [];
|
||||
project.graph.links.forEach((link, index) => {
|
||||
@@ -172,7 +195,7 @@ export function exportProject(project: ProjectToExport): ExportedTopology {
|
||||
};
|
||||
}
|
||||
|
||||
function exportNode(node: TopologyNode, project: ProjectToExport, servingModems: ReadonlySet<Id>): NodeToWrite {
|
||||
function exportNode(node: TopologyNode, project: ProjectToExport, servingModems: ReadonlySet<Id>, warnings: string[]): NodeToWrite {
|
||||
const javaClass = CLASS_OF_KIND[node.kind as Exclude<NodeKind, 'unsupported'>];
|
||||
const interfaces = exportInterfaces(node, project);
|
||||
return {
|
||||
@@ -184,7 +207,7 @@ function exportNode(node: TopologyNode, project: ProjectToExport, servingModems:
|
||||
useMacAsName: showsMac(node.hostLabelPolicy),
|
||||
interfaces,
|
||||
portCount: javaClass === 'Switch' || javaClass === 'Modem' ? node.ports.length : interfaces.length,
|
||||
system: exportSystem(node, project, servingModems),
|
||||
system: exportSystem(node, project, servingModems, (message) => warnings.push(`node '${node.displayName}': ${message}`)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -211,7 +234,9 @@ function modemPort(pairIdentifier: string | undefined): number {
|
||||
return port >= 1 && port <= 65_535 ? port : DEFAULT_MODEM_PORT;
|
||||
}
|
||||
|
||||
function exportSystem(node: TopologyNode, project: ProjectToExport, servingModems: ReadonlySet<Id>): SystemToWrite {
|
||||
type Warn = (message: string) => void;
|
||||
|
||||
function exportSystem(node: TopologyNode, project: ProjectToExport, servingModems: ReadonlySet<Id>, warn: Warn): SystemToWrite {
|
||||
const id = node.id;
|
||||
switch (node.kind) {
|
||||
case 'networkSwitch': {
|
||||
@@ -246,12 +271,12 @@ function exportSystem(node: TopologyNode, project: ProjectToExport, servingModem
|
||||
case 'notebook': {
|
||||
const ssid = project.hostWirelessConfigurations.get(id)?.ssid ?? '';
|
||||
const files = project.virtualFileSystems.get(id);
|
||||
return {
|
||||
...system,
|
||||
ssid: ssid === '' ? null : ssid,
|
||||
fileSystem: { kind: 'directory', name: 'root', children: files === undefined ? [] : fileTrees(files, '/') },
|
||||
applications: exportPrograms(id, project),
|
||||
};
|
||||
const root: FlsFileTree = { kind: 'directory', name: 'root', children: files === undefined ? [] : fileTrees(files, '/') };
|
||||
const client = project.installedPrograms.get(id)?.has('emailClient') === true ? project.emailClientConfigurations.get(id) : undefined;
|
||||
const server = project.installedPrograms.get(id)?.has('emailServer') === true ? project.emailServerConfigurations.get(id) : undefined;
|
||||
if (client?.account != null) putFile(root, EMAIL_CLIENT_FILE, 'text/txt', clientAccountFile(client, warn));
|
||||
if (server !== undefined) putFile(root, EMAIL_SERVER_FILE, 'txt', serverAccountsFile(server, warn));
|
||||
return { ...system, ssid: ssid === '' ? null : ssid, fileSystem: root, applications: exportPrograms(id, project) };
|
||||
}
|
||||
case 'router':
|
||||
return {
|
||||
@@ -310,14 +335,159 @@ function exportPrograms(id: Id, project: ProjectToExport): ApplicationToWrite[]
|
||||
return { ...application, maxPeers: project.gnutellaConfigurations.get(id)?.maximumKnownPeers ?? 3 };
|
||||
case 'firewall':
|
||||
return { ...application, firewall: exportFirewall(project.firewallConfigurationsByNodeId.get(id) ?? JAVA_PERSONAL_FIREWALL_DEFAULTS) };
|
||||
case 'emailServer':
|
||||
return { ...application, isActive };
|
||||
case 'emailServer': {
|
||||
const server = project.emailServerConfigurations.get(id);
|
||||
return {
|
||||
...application,
|
||||
isActive,
|
||||
mailDomain: server?.domain ?? 'filius.de',
|
||||
beanAccounts: (server?.accounts ?? []).map((account) => ({
|
||||
username: account.username,
|
||||
password: account.password,
|
||||
lastName: account.lastName,
|
||||
firstName: account.firstName,
|
||||
emailAddress: null,
|
||||
pop3Server: null,
|
||||
pop3Port: null,
|
||||
smtpServer: null,
|
||||
smtpPort: null,
|
||||
messages: account.mailbox.map(javaEmail),
|
||||
})),
|
||||
};
|
||||
}
|
||||
case 'emailClient': {
|
||||
const client = project.emailClientConfigurations.get(id);
|
||||
return {
|
||||
...application,
|
||||
received: (client?.inbox ?? []).map(javaEmail),
|
||||
sent: (client?.sent ?? []).map(javaEmail),
|
||||
drafts: (client?.drafts ?? []).map(javaEmail),
|
||||
addressBook: (client?.addressBook ?? []).map(javaContact),
|
||||
};
|
||||
}
|
||||
default:
|
||||
return application;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function javaAddress(address: EmailAddress): JavaAddressEntry {
|
||||
return { name: address.name, mailAddress: address.mailAddress };
|
||||
}
|
||||
|
||||
/** A message as Java's Email; its received time as the digits Java keeps, none as Java's "". */
|
||||
function javaEmail(message: EmailMessage): JavaEmail {
|
||||
return {
|
||||
sender: javaAddress(message.from),
|
||||
to: message.to.map(javaAddress),
|
||||
cc: message.cc.map(javaAddress),
|
||||
bcc: message.bcc.map(javaAddress),
|
||||
subject: message.subject,
|
||||
text: message.body,
|
||||
dateReceived: message.receivedAtMilliseconds === undefined ? '' : String(message.receivedAtMilliseconds),
|
||||
isNew: message.isNew,
|
||||
isDeleted: message.isMarkedForDeletion,
|
||||
isSent: message.isSent,
|
||||
};
|
||||
}
|
||||
|
||||
function javaContact(contact: EmailContact): JavaContact {
|
||||
return { ...contact };
|
||||
}
|
||||
|
||||
/** A field of an account file: each character it cannot carry becomes a space, with a warning. */
|
||||
function field(value: string, forbidden: RegExp, what: string, warn: Warn): string {
|
||||
if (!forbidden.test(value)) return value;
|
||||
warn(`${what} holds a character Java's account file cannot carry, written there with a space`);
|
||||
return value.replace(new RegExp(forbidden.source, 'g'), ' ');
|
||||
}
|
||||
|
||||
/** EmailKonto.toString: nine fields; a field may hold no ';' and no line break. */
|
||||
function clientAccountFile(client: EmailClientConfiguration, warn: Warn): string {
|
||||
const account = client.account!;
|
||||
const text = (value: string, what: string) => field(value, /[;\r\n]/, `${EMAIL_CLIENT_FILE}: ${what}`, warn);
|
||||
return [
|
||||
text(account.pop3Host, 'POP3 server'),
|
||||
text(account.smtpHost, 'SMTP server'),
|
||||
String(account.pop3Port),
|
||||
String(account.smtpPort),
|
||||
text(account.username, 'user name'),
|
||||
text(account.password, 'password'),
|
||||
text(account.lastName, 'last name'),
|
||||
text(account.firstName, 'first name'),
|
||||
text(account.email, 'address'),
|
||||
].join(';');
|
||||
}
|
||||
|
||||
/**
|
||||
* EmailServer.listeBenutzerkontenZuString: per account a line of user,
|
||||
* domain, password, last and first name, then its messages, each "#" and
|
||||
* seven fields joined by "$" — the text with Java's escapes
|
||||
* (replaceSpecialChar, then line breaks as "|"), the others unescaped.
|
||||
*/
|
||||
function serverAccountsFile(server: EmailServerConfiguration, warn: Warn): string {
|
||||
const where = (what: string) => `${EMAIL_SERVER_FILE}: ${what}`;
|
||||
const plain = (value: string, what: string) => field(value, /[;#$\r\n]/, where(what), warn);
|
||||
const address = (entry: EmailAddress, what: string) => {
|
||||
const name = entry.name === null ? null : field(entry.name, /[;#$,<>\r\n]/, where(what), warn);
|
||||
const mail = entry.mailAddress === null ? null : field(entry.mailAddress, /[;#$,<>\r\n]/, where(what), warn);
|
||||
if (name === null) return mail === null ? '' : `<${mail}>`;
|
||||
return mail === null ? name : `${name} <${mail}>`;
|
||||
};
|
||||
const addresses = (entries: readonly EmailAddress[], what: string) => entries.map((entry) => address(entry, what)).join(',');
|
||||
const escapedText = (text: string) =>
|
||||
text.replace(/\$/g, '&&00036&&').replace(/;/g, '&&00059&&').replace(/\|/g, '&&00124&&').replace(/#/g, '&&00035&&').replace(/\n/g, '|');
|
||||
return server.accounts
|
||||
.map((account) => {
|
||||
const messages = account.mailbox
|
||||
.map(
|
||||
(message) =>
|
||||
'#' +
|
||||
[
|
||||
address(message.from, 'a sender'),
|
||||
addresses(message.to, 'a recipient'),
|
||||
addresses(message.cc, 'a recipient'),
|
||||
addresses(message.bcc, 'a recipient'),
|
||||
message.receivedAtMilliseconds === undefined ? '' : String(message.receivedAtMilliseconds),
|
||||
plain(message.subject, 'a subject'),
|
||||
field(escapedText(message.body), /\r$/, where('a text'), warn),
|
||||
].join('$'),
|
||||
)
|
||||
.join('');
|
||||
return [
|
||||
plain(account.username, 'a user name'),
|
||||
server.domain,
|
||||
plain(account.password, 'a password'),
|
||||
plain(account.lastName, 'a last name'),
|
||||
plain(account.firstName, 'a first name'),
|
||||
messages,
|
||||
].join(';') + '\n';
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* The file at an absolute path of the root's tree, replacing one of the same
|
||||
* name in any case (Java's holeDatei finds it so); directories are made.
|
||||
*/
|
||||
function putFile(root: FlsFileTree, path: string, type: string, content: string): void {
|
||||
const names = path.split('/').filter((name) => name !== '');
|
||||
let directory = root;
|
||||
for (const name of names.slice(0, -1)) {
|
||||
let next = directory.children.find((child) => child.kind === 'directory' && child.name?.toLowerCase() === name);
|
||||
if (next === undefined) {
|
||||
next = { kind: 'directory', name, children: [] };
|
||||
directory.children.push(next);
|
||||
}
|
||||
directory = next;
|
||||
}
|
||||
const name = names.at(-1)!;
|
||||
const file: FlsFileTree = { kind: 'file', name, type, content, children: [] };
|
||||
const index = directory.children.findIndex((child) => child.kind === 'file' && child.name?.toLowerCase() === name);
|
||||
if (index < 0) directory.children.push(file);
|
||||
else directory.children[index] = file;
|
||||
}
|
||||
|
||||
/** The text types Java's importer reads as text, named by a file's extension. */
|
||||
const TEXT_EXTENSIONS = new Set(['txt', 'html', 'htm', 'css', 'xml', 'csv', 'conf', 'log']);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user