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
+51 -33
View File
@@ -1,14 +1,15 @@
// Java's reading of /dns/hosts (DNSServer.leseRecordListe): every case in
// dns-hosts.cases.txt was run through Java (dns-hosts.java.json) and through
// Swift's hosts-file codec (dns-hosts.swift.json, tools/swift-oracle). The
// Java view must match Java exactly; the records the model keeps must match
// Swift except where Java answers differently — each case named below.
// reader must match Java except where Java errs (JAVA_BUGS); the records the
// model keeps must match Swift except where Java's format says otherwise —
// each case named below.
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import { dnsRecordsOf, readJavaDnsHosts } from '../src/dnsHosts.js';
import { dnsRecordsOf, readDnsHosts } from '../src/dnsHosts.js';
const here = dirname(fileURLToPath(import.meta.url));
type Row = [string, string, number, string];
@@ -21,7 +22,7 @@ function unescape(line: string): string {
}
function modelRows(line: string, warnings: string[] = []): Row[] {
return dnsRecordsOf(readJavaDnsHosts(unescape(line)), (message) => warnings.push(message)).map((record) => [
return dnsRecordsOf(readDnsHosts(unescape(line)), (message) => warnings.push(message)).map((record) => [
record.name,
record.type,
record.ttlSeconds,
@@ -31,13 +32,6 @@ function modelRows(line: string, warnings: string[] = []): Row[] {
/** Lines Swift reads as records that Java never answers. */
const SWIFT_READS_JAVA_NEVER_ANSWERS: { reason: string; lines: string[] }[] = [
{
reason: 'a type in lower case is another type in Java, which never answers',
lines: [
String.raw`www.filius.de a 3600 1.2.3.4\u000A`,
String.raw`filius.de mx 3600 mail.filius.de\u000A`,
],
},
{
reason: 'Java splits fields at single spaces only: a tab or NBSP is part of a field',
lines: [
@@ -65,12 +59,8 @@ const SWIFT_READS_JAVA_NEVER_ANSWERS: { reason: string; lines: string[] }[] = [
],
},
{
reason: 'a label that starts with a digit, or two final dots, make the name empty in Java',
lines: [
String.raw`1und1.de A 3600 1.2.3.4\u000A`,
String.raw`www.1und1.de A 3600 1.2.3.4\u000A`,
String.raw`www.filius.de.. A 3600 1.2.3.4\u000A`,
],
reason: 'two final dots make the name empty in Java',
lines: [String.raw`www.filius.de.. A 3600 1.2.3.4\u000A`],
},
{
reason: 'an MX or NS target must be a name Java accepts: no preference, no address, one final dot',
@@ -85,27 +75,19 @@ const SWIFT_READS_JAVA_NEVER_ANSWERS: { reason: string; lines: string[] }[] = [
/** Lines Java answers that Swift drops or reads otherwise. */
const JAVA_ANSWERS_SWIFT_DROPS: { reason: string; lines: string[] }[] = [
{
reason: 'a TTL that is no Java int is 3600',
reason: 'a TTL that is no number, or a negative one, is 3600',
lines: [
String.raw`www.filius.de A x 1.2.3.4\u000A`,
String.raw`www.filius.de A 4294967295 1.2.3.4\u000A`,
String.raw`www.filius.de A 99999999999 1.2.3.4\u000A`,
String.raw`www.filius.de A 2147483648 1.2.3.4\u000A`,
],
},
{
reason: 'a TTL is Java\'s Integer.parseInt: negative, and in any script\'s digits',
lines: [
String.raw`www.filius.de A -5 1.2.3.4\u000A`,
String.raw`www.filius.de A -2147483648 1.2.3.4\u000A`,
String.raw`www.filius.de A \u0661\u0660 1.2.3.4\u000A`,
],
},
{
reason: 'labels after the first may hold a backslash (Java\'s domain pattern)',
lines: [
String.raw`www.a\u005Cb.de A 3600 1.2.3.4\u000A`,
],
reason: "a TTL is read by Java's Integer.parseInt, in any script's digits",
lines: [String.raw`www.filius.de A \u0661\u0660 1.2.3.4\u000A`],
},
{
reason: 'Java trims control characters off a line (String.trim)',
@@ -130,23 +112,52 @@ const JAVA_ANSWERS_SWIFT_DROPS: { reason: string; lines: string[] }[] = [
},
];
/** Lines where Java errs and this reader does not (see dnsHosts.ts). */
const JAVA_BUGS: { reason: string; lines: string[] }[] = [
{
reason: 'a label may start with a digit (RFC 1123); Java empties the name',
lines: [String.raw`1und1.de A 3600 1.2.3.4\u000A`, String.raw`www.1und1.de A 3600 1.2.3.4\u000A`],
},
{
reason: "a label holds no backslash; Java's pattern lets one in after the first label",
lines: [String.raw`www.a\u005Cb.de A 3600 1.2.3.4\u000A`],
},
{
reason: 'the type is read in any case (RFC 4343); Java never answers "a" or "mx"',
lines: [String.raw`www.filius.de a 3600 1.2.3.4\u000A`, String.raw`filius.de mx 3600 mail.filius.de\u000A`],
},
{
reason: 'a negative TTL is invalid (RFC 2181) and so 3600; Java keeps it',
lines: [String.raw`www.filius.de A -5 1.2.3.4\u000A`, String.raw`www.filius.de A -2147483648 1.2.3.4\u000A`],
},
];
const javaBugs = new Map(JAVA_BUGS.flatMap(({ reason, lines }) => lines.map((line) => [line, reason] as const)));
const departures = new Map(
[...SWIFT_READS_JAVA_NEVER_ANSWERS, ...JAVA_ANSWERS_SWIFT_DROPS].flatMap(({ reason, lines }) =>
lines.map((line) => [line, reason] as const),
),
);
describe('readJavaDnsHosts against Java', () => {
describe('readDnsHosts against Java, but for its errors', () => {
it('covers every line of the cases file, in both oracles', () => {
const lines = readFileSync(join(here, 'dns-hosts.cases.txt'), 'ascii').split('\n').slice(0, -1);
expect(javaMeasured.map(([line]) => line)).toEqual(lines);
expect(swiftMeasured.map(([line]) => line)).toEqual(lines);
});
it('names only cases of the cases file as Java bugs', () => {
const lines = new Set(javaMeasured.map(([line]) => line));
expect([...javaBugs.keys()].filter((line) => !lines.has(line))).toEqual([]);
});
for (const [line, expected] of javaMeasured) {
it(JSON.stringify(line), () => {
const records = readJavaDnsHosts(unescape(line));
expect(records.map((record) => [record.domainName, record.type, record.ttl, record.rdata])).toEqual(expected);
const bug = javaBugs.get(line);
it(bug === undefined ? JSON.stringify(line) : `${JSON.stringify(line)} (Java errs: ${bug})`, () => {
const records = readDnsHosts(unescape(line)).map((record) => [record.domainName, record.type, record.ttl, record.rdata]);
if (bug === undefined) expect(records).toEqual(expected);
else expect(records).not.toEqual(expected);
});
}
});
@@ -168,7 +179,14 @@ describe('the records the model keeps, against Swift', () => {
describe('the records Java answers', () => {
it('leave out records Java never answers: an empty name, or a type other than A, MX, NS', () => {
expect(modelRows(String.raw`1und1.de A 3600 1.2.3.4\u000Afilius.de CNAME 3600 www.filius.de\u000Afilius.de a 3600 1.2.3.4`)).toEqual([]);
expect(modelRows(String.raw`a_b.de A 3600 1.2.3.4\u000Afilius.de CNAME 3600 www.filius.de\u000A1.2.3.4 A 3600 1.2.3.4`)).toEqual([]);
});
it('read a name with a digit first and a type in any case, but no numeric last label', () => {
expect(modelRows(String.raw`1und1.de a 60 1.2.3.4\u000A1und1.de Mx 60 mx.1und1.de\u000A1und1.de MX 60 10`)).toEqual([
['1und1.de', 'A', 60, '1.2.3.4'],
['1und1.de', 'MX', 60, 'mx.1und1.de.'],
]);
});
it('take names in lower case without the final dot, A targets as FILIUS normalises addresses', () => {
+106 -15
View File
@@ -1,13 +1,14 @@
// Java's readers of the e-mail account files: every case in
// The readers of the e-mail account files: every case in
// email-server.cases.txt and email-client.cases.txt was read by Java
// (EmailServer.kontenLaden, EmailAnwendung.laden; *.java.json,
// tools/java-oracle) and must come out the same here.
// tools/java-oracle) and must come out the same here — except where Java
// errs, each case named with its reason.
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import { addressEntry, readJavaEmailClientFile, readJavaEmailServerFile } from '../src/javaEmail.js';
import { addressEntry, readEmailClientFile, readEmailServerFile } from '../src/javaEmail.js';
import { javaEmailAccount } from './javaEmailNames.js';
const here = dirname(fileURLToPath(import.meta.url));
@@ -26,22 +27,112 @@ function measured(kind: 'server' | 'client'): [string, unknown][] {
return cases;
}
describe('readJavaEmailServerFile against Java', () => {
for (const [line, expected] of measured('server')) {
it(JSON.stringify(line), () => {
const accounts = readJavaEmailServerFile(unescape(line));
expect(Array.isArray(accounts) ? accounts.map(javaEmailAccount) : `!${accounts.exception}`).toEqual(expected);
/** Server files where Java errs and this reader does not (see readEmailServerFile). */
const SERVER_JAVA_BUGS: { reason: string; lines: string[] }[] = [
{
reason: "an empty field counts; Java's StringTokenizer skips it and shifts the rest",
lines: [String.raw`bob;;bob;Nachname;Vorname;\u000A`],
},
{
reason: 'a short line alone is left out; Java throws for the whole file',
lines: [String.raw`bob;filius.de;bob;Nachname\u000A`, String.raw`bob;filius.de;bob;Nachname;Vorname;\u000Abroken\u000A`],
},
{
reason: 'a message keeps an empty text or subject; Java loses it (split drops trailing empty fields)',
lines: [
String.raw`bob;filius.de;bob;Nachname;Vorname;#Alice <alice@senior.de>$bob@filius.de$$$$Hi$\u000A`,
String.raw`bob;filius.de;bob;Nachname;Vorname;#Alice <alice@senior.de>$bob@filius.de$$$$$\u000A`,
],
},
{
reason: "a \";\" in the messages belongs to them (Java writes subjects unescaped); Java's tokenizer cuts there",
lines: [String.raw`bob;filius.de;bob;Nachname;Vorname;#a@x$b@y$$$$Hi$Text;extra\u000A`],
},
{
reason: 'a CRLF line ends before its CR; Java keeps the CR in the last field',
lines: [
String.raw`bob;filius.de;bob;Nachname;Vorname\u000D\u000A`,
String.raw`bob;filius.de;bob;Nachname;Vorname;#a@x$b@y$$$$Hi$Text\u000D\u000A`,
],
},
];
/** Client files where Java errs and this reader does not (see readEmailClientFile). */
const CLIENT_JAVA_BUGS: { reason: string; lines: string[] }[] = [
{
reason: 'the last complete line is the account; Java takes the last line and so loses the account',
lines: [String.raw`a;a;110;25;first;p;L;F;first@x\u000Abroken\u000A`],
},
{
reason: 'an empty address is still an account; Java loses it (split drops trailing empty fields)',
lines: [String.raw`a;b;110;25;u;p;L;F;\u000A`, String.raw`;;;;;;;;`],
},
{
reason: 'an empty file holds no account; Java makes one without any values',
lines: [String.raw``],
},
{
reason: 'a CRLF line ends before its CR; Java keeps the CR in the address',
lines: [String.raw`a;b;110;25;u;p;L;F;u@x\u000D\u000A`],
},
];
function byLine(bugs: { reason: string; lines: string[] }[]): Map<string, string> {
return new Map(bugs.flatMap(({ reason, lines }) => lines.map((line) => [line, reason] as const)));
}
function compareWithJava(kind: 'server' | 'client', bugs: Map<string, string>, read: (content: string) => unknown) {
const cases = measured(kind);
it('names only cases of the cases file as Java bugs', () => {
const lines = new Set(cases.map(([line]) => line));
expect([...bugs.keys()].filter((line) => !lines.has(line))).toEqual([]);
});
for (const [line, expected] of cases) {
const bug = bugs.get(line);
it(bug === undefined ? JSON.stringify(line) : `${JSON.stringify(line)} (Java errs: ${bug})`, () => {
if (bug === undefined) expect(read(unescape(line))).toEqual(expected);
else expect(read(unescape(line))).not.toEqual(expected);
});
}
}
describe('readEmailServerFile against Java, but for its errors', () => {
compareWithJava('server', byLine(SERVER_JAVA_BUGS), (content) => readEmailServerFile(content, () => {}).map(javaEmailAccount));
});
describe('readJavaEmailClientFile against Java', () => {
for (const [line, expected] of measured('client')) {
it(JSON.stringify(line), () => {
const account = readJavaEmailClientFile(unescape(line));
expect(account === null ? null : javaEmailAccount(account)).toEqual(expected);
});
}
describe('readEmailClientFile against Java, but for its errors', () => {
compareWithJava('client', byLine(CLIENT_JAVA_BUGS), (content) => {
const account = readEmailClientFile(content, () => {});
return account === null ? null : javaEmailAccount(account);
});
});
describe('the account files, where Java errs', () => {
it('read what Java writes for an empty password, text and subject', () => {
const warnings: string[] = [];
const [account] = readEmailServerFile('bob;filius.de;;Nachname;Vorname;#<a@x>$<bob@filius.de>$$$$$\n', (message) => warnings.push(message));
expect(account).toMatchObject({ username: 'bob', password: '', lastName: 'Nachname', firstName: 'Vorname' });
expect(account!.messages).toMatchObject([{ sender: { mailAddress: 'a@x' }, subject: '', text: '' }]);
expect(warnings).toEqual([]);
});
it('leave out a short server line or a broken message, and say so', () => {
const warnings: string[] = [];
const accounts = readEmailServerFile('broken;line\nbob;filius.de;bob;N;V;#a@x$b@y$$$$Hi $5$Text\n', (message) => warnings.push(message));
expect(accounts.map((account) => [account.username, account.messages.length])).toEqual([['bob', 0]]);
expect(warnings).toEqual([
'/mailserver/konten.txt: line 1 has fewer than five fields, left out',
"/mailserver/konten.txt: a message of 'bob' has 8 fields, not seven, left out",
]);
});
it('take the last complete client line, and say which lines are not accounts', () => {
const warnings: string[] = [];
const account = readEmailClientFile('a;a;110;25;first;p;L;F;first@x\nbroken\n\n', (message) => warnings.push(message));
expect(account).toMatchObject({ username: 'first', emailAddress: 'first@x' });
expect(warnings).toEqual(['/konten.txt: line 2 is no account of nine fields, ignored']);
expect(readEmailClientFile('a;b;110;25;u;p;L;F;\n', () => {})).toMatchObject({ username: 'u', emailAddress: '' });
});
});
describe('addressEntry', () => {
+9 -10
View File
@@ -19,7 +19,6 @@ import {
type FlsNodeClass,
type FlsTopology,
type JavaEmailAccount,
type JavaFailure,
} from '../src/index.js';
import { EXPECTED_WARNINGS } from './expectedWarnings.js';
import { javaEmail, javaEmailAccount } from './javaEmailNames.js';
@@ -122,9 +121,8 @@ function javaApplications(applications: FlsApplication[]): unknown[] {
}));
}
function javaFileAccounts(accounts: JavaEmailAccount[] | JavaFailure | null): unknown {
if (accounts === null) return null;
return Array.isArray(accounts) ? accounts.map(javaEmailAccount) : `!${accounts.exception}`;
function javaFileAccounts(accounts: JavaEmailAccount[] | null): unknown {
return accounts === null ? null : accounts.map(javaEmailAccount);
}
/** Java's property names, as FlsOracle.java writes them. */
@@ -370,9 +368,9 @@ describe('readTopology rules', () => {
});
});
it('splits static DHCP assignments at Java whitespace and stops where Java throws', () => {
// U+3000 separates, U+00A0 does not; the third entry has no IP, so Java
// throws there and the fourth is never read.
it('splits static DHCP assignments at Java whitespace and leaves out only a broken one', () => {
// U+3000 separates, U+00A0 does not; the third entry has no IP. Java
// throws there and never reads the fourth — an error not copied.
const { nodes, warnings } = topologyOf(`<object class="java.util.LinkedList">${nodeItem(`<object class="filius.hardware.knoten.Rechner">
<void property="systemSoftware"><void property="DHCPServer">
<void property="staticAssignedAddresses"><object class="java.util.ArrayList">
@@ -393,10 +391,11 @@ describe('readTopology rules', () => {
staticAssignedAddresses: [
{ mac: '0A:00:00:00:00:01', ip: '10.0.0.1' },
{ mac: '0A:00:00:00:00:02', ip: '10.0.0.2\u00A0x' },
{ mac: '0A:00:00:00:00:04', ip: '10.0.0.4' },
],
});
expect(warnings).toEqual([
'node 1: DHCPServer: static assignment "0A:00:00:00:00:03" has no MAC and IP; Java stops reading here',
'node 1: DHCPServer: static assignment "0A:00:00:00:00:03" has no MAC and IP, left out',
]);
});
@@ -462,7 +461,7 @@ describe('readTopology rules', () => {
const { nodes } = topologyOf(`<object class="java.util.LinkedList">${nodeItem(`<object class="filius.hardware.knoten.Rechner">
<void property="systemSoftware"><void property="installierteAnwendungen">
${put('filius.software.email.EmailServer', `
<void property="mailDomain"><string>1und1.de</string></void>
<void property="mailDomain"><string>a_b.de</string></void>
<void property="listeBenutzerkonten"><void method="add"><object class="filius.software.email.EmailKonto">
<void property="benutzername"><string>bob</string></void>
<void property="nachname"><null/></void>
@@ -473,7 +472,7 @@ describe('readTopology rules', () => {
</void></void></object>`)}</object>`);
const server = nodes[0]!.system.applications![0]!;
// setMailDomain keeps "filius.de" for a domain Java's pattern rejects.
// setMailDomain keeps "filius.de" for a domain that is not a domain name.
expect(server).toMatchObject({ isActive: false, mailDomain: 'filius.de' });
expect(server.beanAccounts![0]).toMatchObject({ username: 'bob', lastName: '', firstName: '' });
expect(server.beanAccounts![0]!.messages[0]!.sender).toEqual({ name: 'Alice', mailAddress: 'alice@senior.de' });