fix(fls): follow Java's formats, not Java's errors

Java stays the reference for the .fls format, but its errors are no longer
copied. The Java oracle still measures Java as it is; each deliberate
departure is named in the tests with its reason (JAVA_BUGS) and must really
depart. The corpus - what Java itself writes - still reads exactly as in
Java.

- /dns/hosts: a label may start with a digit, holds no backslash, and the
  last one is not all digits (RFC 1123, RFC 3696); A, MX and NS are read in
  any case (RFC 4343); a negative TTL is invalid, so 3600 (RFC 2181). Also
  for the e-mail server's domain.
- /mailserver/konten.txt: empty fields count (Java's StringTokenizer skips
  them and shifts the rest, so an empty password breaks the account); a
  short line alone is left out, with a warning (Java throws for the whole
  file); a message keeps an empty text or subject (Java's split loses it);
  a ";" in an unescaped subject stays in the message; CRLF lines.
- /konten.txt: the last complete line is the account, also with an empty
  address (Java takes the last line and loses the account); CRLF lines.
- Static DHCP assignments: a broken entry alone is left out (Java loses
  every entry after it).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 07:20:52 +00:00
co-authored by Claude Opus 5.5
parent ec5523d7c7
commit 8f9930f1ff
10 changed files with 319 additions and 160 deletions
+50 -26
View File
@@ -1,14 +1,18 @@
// Java's DNS server keeps its records in the text file /dns/hosts and reads
// them on every query (DNSServer.leseRecordListe, ResourceRecord(String)).
// Measured on test/dns-hosts.cases.txt against Java (dns-hosts.java.json)
// and Swift (dns-hosts.swift.json); where the two differ, Java decides:
// and Swift (dns-hosts.swift.json). Java's format decides where the two
// differ:
// - fields are split at single spaces only: a tab or NBSP joins two fields;
// - there are no comments, no "IN" and no zone-file order;
// - a TTL that is not a Java int silently becomes 3600;
// - a name Java does not accept becomes the empty name, and the record
// still counts - it just never answers, like any record whose type is
// not exactly A, MX or NS (lower-case "a" included);
// - a TTL that is not a number silently becomes 3600;
// - a name that is not a domain name becomes the empty name, and the record
// still counts - it just never answers, like one of another type;
// - duplicates stay.
// Java's errors are not copied (each named in dnsHosts.test.ts): a label may
// start with a digit and holds no backslash (RFC 1123 - Java's pattern has
// both wrong; see isDomainName), the type A, MX or NS is read in any case
// (RFC 4343), and a negative TTL is invalid (RFC 2181).
import type { DnsResourceRecord } from '@mdg-filius/core';
import { javaFileContent } from './javaFileSystem.js';
@@ -16,8 +20,8 @@ import { javaIpCheck, javaParseInt } from './javaIp.js';
import { javaTrim } from './javaString.js';
import type { FlsFileTree } from './systemSoftware.js';
/** A record as Java's ResourceRecord holds it (the getters). */
export interface JavaResourceRecord {
/** A record of /dns/hosts, as Java's ResourceRecord holds it (the getters). */
export interface HostsRecord {
readonly domainName: string;
readonly type: string;
readonly ttl: number;
@@ -26,10 +30,22 @@ export interface JavaResourceRecord {
export const DNS_HOSTS_PATH = '/dns/hosts';
// EingabenUeberpruefung.musterDomain, including its quirks: labels start with
// a letter, labels after the first may hold a backslash, and the empty
// string is a valid domain.
export const JAVA_DOMAIN = /^([a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z]([a-zA-Z0-9\\-]{0,61}[a-zA-Z0-9])?)*)?\.?$/;
// Labels of letters, digits and inner hyphens, an optional final dot; the
// empty string counts too.
const DOMAIN_LABELS = /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*)?\.?$/;
/**
* A domain name as EingabenUeberpruefung.musterDomain means it (the empty
* string included). Java's pattern wants every label to start with a letter
* and lets a backslash into the labels after the first; instead a label may
* start with a digit (RFC 1123), but the last one must not be all digits
* (RFC 3696), so that no IPv4 address passes for a name.
*/
export function isDomainName(text: string): boolean {
if (!DOMAIN_LABELS.test(text)) return false;
const labels = text.split('.').filter((label) => label !== '');
return labels.length === 0 || !/^[0-9]+$/.test(labels[labels.length - 1]!);
}
// EingabenUeberpruefung.musterIpAdresse.
const JAVA_IP_ADDRESS =
/^(0*([1-9]?[0-9]?|1[0-9]{1,2}|2[0-4]?[0-9]|25[0-5]))\.((0*(1?[0-9]{1,2}|2[0-4]?[0-9]|25[0-5]))\.){2}(0*(1?[0-9]{1,2}|2[0-4]?[0-9]|25[0-5]))$/;
@@ -39,31 +55,39 @@ export function javaHostsFileContent(root: FlsFileTree | undefined): string | un
return javaFileContent(root, DNS_HOSTS_PATH);
}
const ANSWERED_TYPES = new Set(['A', 'MX', 'NS']);
/** `DNSServer.leseRecordListe` on the content of /dns/hosts. */
export function readJavaDnsHosts(content: string): JavaResourceRecord[] {
const records: JavaResourceRecord[] = [];
export function readDnsHosts(content: string): HostsRecord[] {
const records: HostsRecord[] = [];
for (const token of content.split('\n')) {
const line = javaTrim(token);
// line.split(" ", 5).length < 4: fewer than three single spaces.
if (line === '' || line.split(' ').length < 4) continue;
const record = javaResourceRecord(line);
if (isValidJavaRecord(record)) records.push(record);
const record = hostsRecord(line);
if (isValidRecord(record)) records.push(record);
}
return records;
}
/** `new ResourceRecord(line)`: up to four space-separated fields. */
function javaResourceRecord(line: string): JavaResourceRecord {
function hostsRecord(line: string): HostsRecord {
const fields = line.split(' ').filter((field) => field !== '').map(javaTrim);
let domainName = '';
let type = 'A';
let ttl = 3600;
let rdata = '';
if (fields[0] !== undefined && JAVA_DOMAIN.test(fields[0])) {
if (fields[0] !== undefined && isDomainName(fields[0])) {
domainName = fields[0].endsWith('.') ? fields[0] : `${fields[0]}.`;
}
if (fields[1] !== undefined) type = fields[1];
if (fields[2] !== undefined) ttl = javaParseInt(fields[2]) ?? ttl;
if (fields[1] !== undefined) {
const upper = fields[1].replace(/[a-z]/g, (letter) => letter.toUpperCase());
type = ANSWERED_TYPES.has(upper) ? upper : fields[1];
}
if (fields[2] !== undefined) {
const parsed = javaParseInt(fields[2]);
if (parsed !== undefined && parsed >= 0) ttl = parsed;
}
if (fields[3] !== undefined) {
// Java tests ".*\." (no line terminator before the dot); a target with
// one fails the domain check anyway, so endsWith says the same.
@@ -73,13 +97,13 @@ function javaResourceRecord(line: string): JavaResourceRecord {
return { domainName, type, ttl, rdata };
}
function isValidJavaRecord(record: JavaResourceRecord): boolean {
function isValidRecord(record: HostsRecord): boolean {
switch (record.type) {
case 'A':
return JAVA_DOMAIN.test(record.domainName) && JAVA_IP_ADDRESS.test(record.rdata);
return isDomainName(record.domainName) && JAVA_IP_ADDRESS.test(record.rdata);
case 'MX':
case 'NS':
return JAVA_DOMAIN.test(record.domainName) && JAVA_DOMAIN.test(record.rdata);
return isDomainName(record.domainName) && isDomainName(record.rdata);
default:
return true;
}
@@ -87,11 +111,11 @@ function isValidJavaRecord(record: JavaResourceRecord): boolean {
/**
* The records Java answers with, in the model's form: Java looks them up by
* type (exactly "A", "MX", "NS") and by name ignoring case, so a record with
* the empty name or another type never answers and is left out. The file
* itself stays as it is.
* type (A, MX, NS) and by name ignoring case, so a record with the empty
* name or another type never answers and is left out. The file itself stays
* as it is.
*/
export function dnsRecordsOf(records: readonly JavaResourceRecord[], warn: (message: string) => void): DnsResourceRecord[] {
export function dnsRecordsOf(records: readonly HostsRecord[], warn: (message: string) => void): DnsResourceRecord[] {
const answered: DnsResourceRecord[] = [];
for (const record of records) {
if (record.domainName === '') continue;
+2 -2
View File
@@ -91,7 +91,7 @@ import {
type TopologyNode,
type VirtualFileContent,
} from '@mdg-filius/core';
import { dnsRecordsOf, javaHostsFileContent, readJavaDnsHosts } from './dnsHosts.js';
import { dnsRecordsOf, javaHostsFileContent, readDnsHosts } from './dnsHosts.js';
import type { FlsApplicationClass, FlsFileTree, FlsFirewall } from './systemSoftware.js';
import type { FlsDocumentationItem, FlsInterface, FlsNode, FlsNodeClass, FlsTopology } from './topology.js';
@@ -420,7 +420,7 @@ function registerPrograms(source: FlsNode, node: TopologyNode, system: SystemCon
const primary = trimmedOrEmpty(source.interfaces[0]?.ip);
const forwarding = recursive && forwarder !== '' && forwarder !== primary;
const hosts = javaHostsFileContent(source.system.fileSystem);
const records = dnsRecordsOf(readJavaDnsHosts(hosts ?? ''), (message) =>
const records = dnsRecordsOf(readDnsHosts(hosts ?? ''), (message) =>
warnings.push(`node '${node.displayName}': ${message}`),
);
system.dnsServerConfigurations.set(
+73 -48
View File
@@ -7,10 +7,12 @@
// the bean list, and /konten.txt, the client's account, read (laden) when
// its window opens. The client's account is in no bean: its accessors
// (holeKontoListe, setzeKontoListe) are not bean properties.
// Checked against Java on the corpus (corpus.java.json, applications).
// Checked against Java on the corpus (corpus.java.json, applications) and on
// the account files of test/email-*.cases.txt, where Java's errors in reading
// them are not copied (see readEmailServerFile, readEmailClientFile).
import { isJavaObject, listItems, simpleClassName, type JavaObject, type JavaValue } from './javabeans.js';
import { javaSplit, javaTokens, javaTrim } from './javaString.js';
import { javaSplit, javaTrim } from './javaString.js';
/** AddressEntry: a display name, an address, or both. */
export interface JavaAddressEntry {
@@ -63,11 +65,6 @@ export interface JavaContact {
readonly phone: string | null;
}
/** An exception Java throws while reading, by its simple class name. */
export interface JavaFailure {
readonly exception: string;
}
export const EMAIL_SERVER_FILE = '/mailserver/konten.txt';
export const EMAIL_CLIENT_FILE = '/konten.txt';
@@ -213,17 +210,25 @@ function addressEntries(raw: string): JavaAddressEntry[] {
/**
* `EmailServer.stringZuListeBenutzerkonten`: one account a line —
* user;domain;password;last name;first name;messages — split by a
* StringTokenizer, which skips empty fields; an account with fewer than five
* fields makes Java throw. Messages are "#"-separated, seven "$"-separated
* fields each: sender, to, cc, bcc, date, subject, text. Java's split drops
* trailing empty fields, so a message with an empty text is lost.
* user;domain;password;last name;first name;messages. Messages are
* "#"-separated, seven "$"-separated fields each: sender, to, cc, bcc, date,
* subject, text. Java's errors are not copied (each named in
* javaEmail.test.ts): its StringTokenizer skips empty fields, so an empty
* password shifts the rest; one line with fewer than five fields makes it
* throw for the whole file; its split drops trailing empty fields, so a
* message with an empty text or subject is lost; a CR of a CRLF line stays.
* Here fields count also when empty, a short line alone is left out, and a
* message needs its seven fields, empty or not.
*/
export function readJavaEmailServerFile(content: string): JavaEmailAccount[] | JavaFailure {
export function readEmailServerFile(content: string, warn: Warn): JavaEmailAccount[] {
const accounts: JavaEmailAccount[] = [];
for (const line of javaTokens(content, '\n')) {
const fields = javaTokens(line, ';');
if (fields.length < 5) return { exception: 'NoSuchElementException' };
lines(content).forEach((line, index) => {
if (line === '') return;
const fields = splitAtMost(line, ';', 6);
if (fields.length < 5) {
warn(`${EMAIL_SERVER_FILE}: line ${index + 1} has fewer than five fields, left out`);
return;
}
const [username, , password, lastName, firstName, messages] = fields as [string, string, string, string, string, string?];
accounts.push({
username,
@@ -235,16 +240,20 @@ export function readJavaEmailServerFile(content: string): JavaEmailAccount[] | J
pop3Port: null,
smtpServer: null,
smtpPort: null,
messages: messages === undefined ? [] : fileMessages(messages),
messages: messages === undefined ? [] : fileMessages(messages, username, warn),
});
}
});
return accounts;
}
function fileMessages(messages: string): JavaEmail[] {
return javaSplit(messages, '#').flatMap((message) => {
const fields = javaSplit(message, '$');
if (fields.length !== 7) return [];
function fileMessages(messages: string, username: string, warn: Warn): JavaEmail[] {
return messages.split('#').flatMap((message) => {
if (message === '') return [];
const fields = message.split('$');
if (fields.length !== 7) {
warn(`${EMAIL_SERVER_FILE}: a message of '${username}' has ${fields.length} fields, not seven, left out`);
return [];
}
const [sender, to, cc, bcc, dateReceived, subject, body] = fields as [string, string, string, string, string, string, string];
return [
{
@@ -263,6 +272,17 @@ function fileMessages(messages: string): JavaEmail[] {
});
}
/** The lines of a text file, LF or CRLF. */
function lines(content: string): string[] {
return content.split('\n').map((line) => (line.endsWith('\r') ? line.slice(0, -1) : line));
}
/** Split at a separator into at most `limit` parts, empty ones included; the last keeps the rest. */
function splitAtMost(text: string, separator: string, limit: number): string[] {
const parts = text.split(separator);
return parts.length <= limit ? parts : [...parts.slice(0, limit - 1), parts.slice(limit - 1).join(separator)];
}
/** brtonl, then undoSpecialChar: "|" is a line break, "&&00124&&" a "|". */
function unescapedBody(body: string): string {
return body
@@ -274,31 +294,36 @@ function unescapedBody(body: string): string {
}
/**
* `EmailAnwendung.laden`: every line makes an account, the last one stays.
* `new EmailKonto(line)` takes nine ";"-separated fields — POP3 server, SMTP
* server, POP3 port, SMTP port, user, password, last name, first name,
* address — and with fewer keeps none (trailing empty fields do not count).
* Null when the file has no line at all.
* `EmailAnwendung.laden`: an account of nine ";"-separated fields — POP3
* server, SMTP server, POP3 port, SMTP port, user, password, last name,
* first name, address; further fields are ignored. Java takes the last line
* whatever it holds; its errors are not copied (each named in
* javaEmail.test.ts): a last line with fewer fields — also a complete one
* whose address is empty, since split drops trailing empty fields — leaves
* the account empty, and a CR of a CRLF line stays in the address. Here the
* last line with all nine fields is the account. Null when there is none.
*/
export function readJavaEmailClientFile(content: string): JavaEmailAccount | null {
const lines = javaSplit(content, '\n');
const line = lines[lines.length - 1];
if (line === undefined) return null;
const fields = javaSplit(line, ';');
const empty = { username: null, password: null, lastName: '', firstName: '', emailAddress: null };
const servers = { pop3Server: null, pop3Port: null, smtpServer: null, smtpPort: null };
if (fields.length <= 8) return { ...empty, ...servers, messages: [] };
const [pop3Server, smtpServer, pop3Port, smtpPort, username, password, lastName, firstName, emailAddress] = fields as string[];
return {
username: username!,
password: password!,
lastName: lastName!,
firstName: firstName!,
emailAddress: emailAddress!,
pop3Server: pop3Server!,
pop3Port: pop3Port!,
smtpServer: smtpServer!,
smtpPort: smtpPort!,
messages: [],
};
export function readEmailClientFile(content: string, warn: Warn): JavaEmailAccount | null {
let account: JavaEmailAccount | null = null;
lines(content).forEach((line, index) => {
const fields = line.split(';');
if (fields.length < 9) {
if (javaTrim(line) !== '') warn(`${EMAIL_CLIENT_FILE}: line ${index + 1} is no account of nine fields, ignored`);
return;
}
const [pop3Server, smtpServer, pop3Port, smtpPort, username, password, lastName, firstName, emailAddress] = fields as string[];
account = {
username: username!,
password: password!,
lastName: lastName!,
firstName: firstName!,
emailAddress: emailAddress!,
pop3Server: pop3Server!,
pop3Port: pop3Port!,
smtpServer: smtpServer!,
smtpPort: smtpPort!,
messages: [],
};
});
return account;
}
+2 -7
View File
@@ -1,5 +1,5 @@
// java.lang.String and java.util.StringTokenizer as FILIUS's text formats
// use them — each differs from its JavaScript namesake.
// java.lang.String as FILIUS's text formats use it - each differs from its
// JavaScript namesake.
/** `String.trim()`: every UTF-16 unit up to U+0020 off both ends. */
export function javaTrim(text: string): string {
@@ -17,8 +17,3 @@ export function javaSplit(text: string, separator: string): string[] {
while (parts.length > 0 && parts[parts.length - 1] === '') parts.pop();
return parts;
}
/** `new StringTokenizer(text, separator)`: the non-empty runs between separators. */
export function javaTokens(text: string, separator: string): string[] {
return text.split(separator).filter((token) => token !== '');
}
+14 -14
View File
@@ -27,19 +27,18 @@
// not know Java's constructors and sees a placeholder per path; they are
// merged here.
import { JAVA_DOMAIN } from './dnsHosts.js';
import { isDomainName } from './dnsHosts.js';
import {
EMAIL_CLIENT_FILE,
EMAIL_SERVER_FILE,
javaContacts,
javaEmailAccounts,
javaEmails,
readJavaEmailClientFile,
readJavaEmailServerFile,
readEmailClientFile,
readEmailServerFile,
type JavaContact,
type JavaEmail,
type JavaEmailAccount,
type JavaFailure,
} from './javaEmail.js';
import { javaFileContent } from './javaFileSystem.js';
import {
@@ -151,16 +150,16 @@ export interface FlsApplication {
maxPeers?: number | null;
/** Firewall: the personal firewall. */
firewall?: FlsFirewall;
/** EmailServer: its domain (Java keeps "filius.de" for one its pattern rejects). */
/** EmailServer: its domain ("filius.de" for one that is not a domain name, as Java's setter keeps it). */
mailDomain?: string;
/** EmailServer: the accounts of the bean list. */
beanAccounts?: JavaEmailAccount[];
/**
* EmailServer: the accounts of /mailserver/konten.txt, which Java reads at
* every simulation start — null without the file (Java then starts with
* none), a failure where Java throws.
* none).
*/
fileAccounts?: JavaEmailAccount[] | JavaFailure | null;
fileAccounts?: JavaEmailAccount[] | null;
/** EmailAnwendung: its folders (Java's empfangene, gesendete, erstellte Nachrichten). */
received?: JavaEmail[];
sent?: JavaEmail[];
@@ -313,10 +312,10 @@ function applications(map: JavaValue | undefined, root: FlsFileTree, warn: Warn)
case 'filius.software.email.EmailServer': {
entry.isActive = boolean(at('aktiv'), false, 'EmailServer aktiv', warn);
const domain = optionalString(at('mailDomain'), 'EmailServer mailDomain', warn);
entry.mailDomain = typeof domain === 'string' && JAVA_DOMAIN.test(domain) ? domain : 'filius.de';
entry.mailDomain = typeof domain === 'string' && isDomainName(domain) ? domain : 'filius.de';
entry.beanAccounts = javaEmailAccounts(at('listeBenutzerkonten'), warn);
const file = javaFileContent(root, EMAIL_SERVER_FILE);
entry.fileAccounts = file === undefined ? null : readJavaEmailServerFile(file);
entry.fileAccounts = file === undefined ? null : readEmailServerFile(file, warn);
break;
}
case 'filius.software.email.EmailAnwendung': {
@@ -325,7 +324,7 @@ function applications(map: JavaValue | undefined, root: FlsFileTree, warn: Warn)
entry.drafts = javaEmails(at('erstellteNachrichten'), warn);
entry.addressBook = javaContacts(at('adressbuch'), warn);
const file = javaFileContent(root, EMAIL_CLIENT_FILE);
entry.fileAccount = file === undefined ? null : readJavaEmailClientFile(file);
entry.fileAccount = file === undefined ? null : readEmailClientFile(file, warn);
break;
}
}
@@ -406,16 +405,17 @@ function dhcpServer(server: JavaValue | undefined, warn: Warn): FlsDhcpServer {
/**
* setStaticAssignedAddresses: each entry split at whitespace (StringUtils.split),
* MAC and IP from the first two parts. An entry with fewer throws inside the
* loop — XMLDecoder swallows it, and the entries after it are lost.
* MAC and IP from the first two parts. An entry with fewer makes Java throw
* inside the loop — XMLDecoder swallows it, and the entries after it are
* lost. That error is not copied: such an entry alone is left out.
*/
function staticAssignments(list: JavaValue | undefined, warn: Warn): { mac: string; ip: string }[] {
const result: { mac: string; ip: string }[] = [];
for (const entry of listItems(list)) {
const parts = typeof entry === 'string' ? javaWhitespaceSplit(entry) : [];
if (parts.length < 2) {
warn(`DHCPServer: static assignment ${describe(entry)} has no MAC and IP; Java stops reading here`);
break;
warn(`DHCPServer: static assignment ${describe(entry)} has no MAC and IP, left out`);
continue;
}
result.push({ mac: parts[0]!, ip: parts[1]! });
}