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;