feat(fls): DNS records from /dns/hosts, as Java's server answers them

Java's DNS server reads its records from the text file /dns/hosts on every
query. dnsHosts.ts ports that reading (DNSServer.leseRecordListe,
ResourceRecord, Dateisystem.holeDatei) and keeps in the model the records
Java answers: type exactly A, MX or NS, and a name Java accepted. The file
stays in the file system as it is.

Both oracles read 76 hand-picked hosts files (dns-hosts.cases.txt): the
Java oracle through DNSServer.holeResourceRecords (DnsHostsCases), the
Swift oracle through TopologyDNSHostsFileCodec (--dns-hosts). The Java view
matches Java on every case; the model matches Swift except for 33 named
cases in twelve reasons: Java splits at single spaces and LF only, has no
comments or "IN", turns a TTL that is no int into 3600, keeps duplicates,
and empties names with a leading digit. The Swift dump now carries each
server's records; the corpus comparison covers them on 16 files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 06:38:01 +00:00
co-authored by Claude Opus 5.5
parent 1cc0dfd899
commit c80d212c90
17 changed files with 2518 additions and 11 deletions
+153
View File
@@ -0,0 +1,153 @@
// 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:
// - 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);
// - duplicates stay.
import type { DnsResourceRecord } from '@mdg-filius/core';
import { javaIpCheck, javaParseInt } from './javaIp.js';
import type { FlsFileTree } from './systemSoftware.js';
/** A record as Java's ResourceRecord holds it (the getters). */
export interface JavaResourceRecord {
readonly domainName: string;
readonly type: string;
readonly ttl: number;
readonly rdata: string;
}
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.
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])?)*)?\.?$/;
// 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]))$/;
/** `String.trim()`: every UTF-16 unit up to U+0020 off both ends. */
function javaTrim(text: string): string {
return text.replace(/^[\u0000-\u0020]+|[\u0000-\u0020]+$/g, '');
}
/**
* The content of /dns/hosts as `Dateisystem.holeDatei` finds it: the first
* node in preorder whose path equals it ignoring case, and only if that node
* is a file. Undefined when there is none; a file without content reads as
* empty (Java would fail on it).
*/
export function javaHostsFileContent(root: FlsFileTree | undefined): string | undefined {
if (root === undefined) return undefined;
const found = firstInPreorder(root, '', (path) => javaEqualsIgnoreCase(path, DNS_HOSTS_PATH));
return found?.kind === 'file' ? (found.content ?? '') : undefined;
}
/** Preorder below the root; a node's path is "/" plus each name after the root's. */
function firstInPreorder(node: FlsFileTree, path: string, matches: (path: string) => boolean): FlsFileTree | undefined {
for (const child of node.children) {
const childPath = `${path}/${child.kind === 'other' ? child.className : String(child.name)}`;
if (matches(childPath)) return child;
const found = firstInPreorder(child, childPath, matches);
if (found !== undefined) return found;
}
return undefined;
}
/**
* `String.equalsIgnoreCase` against an ASCII path, unit by unit: equal after
* Character.toUpperCase, so the long s (U+017F) is an "s". Java also
* compares the lower case of both; for the letters of /dns/hosts that adds
* no unit (measured over the BMP).
*/
function javaEqualsIgnoreCase(text: string, asciiPath: string): boolean {
if (text.length !== asciiPath.length) return false;
for (let index = 0; index < text.length; index++) {
if (text[index]!.toUpperCase() !== asciiPath[index]!.toUpperCase()) return false;
}
return true;
}
/** `DNSServer.leseRecordListe` on the content of /dns/hosts. */
export function readJavaDnsHosts(content: string): JavaResourceRecord[] {
const records: JavaResourceRecord[] = [];
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);
}
return records;
}
/** `new ResourceRecord(line)`: up to four space-separated fields. */
function javaResourceRecord(line: string): JavaResourceRecord {
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])) {
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[3] !== undefined) {
// Java tests ".*\." (no line terminator before the dot); a target with
// one fails the domain check anyway, so endsWith says the same.
const needsDot = (type === 'NS' || type === 'MX') && !fields[3].endsWith('.');
rdata = needsDot ? `${fields[3]}.` : fields[3];
}
return { domainName, type, ttl, rdata };
}
function isValidJavaRecord(record: JavaResourceRecord): boolean {
switch (record.type) {
case 'A':
return JAVA_DOMAIN.test(record.domainName) && JAVA_IP_ADDRESS.test(record.rdata);
case 'MX':
case 'NS':
return JAVA_DOMAIN.test(record.domainName) && JAVA_DOMAIN.test(record.rdata);
default:
return true;
}
}
/**
* 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.
*/
export function dnsRecordsOf(records: readonly JavaResourceRecord[], warn: (message: string) => void): DnsResourceRecord[] {
const answered: DnsResourceRecord[] = [];
for (const record of records) {
if (record.domainName === '') continue;
const name = record.domainName === '.' ? '.' : record.domainName.slice(0, -1).toLowerCase();
switch (record.type) {
case 'A': {
const address = javaIpCheck(record.rdata);
if (address === null) {
warn(`DNS record '${name} A ${record.rdata}' has no usable IPv4 address, left out (it stays in ${DNS_HOSTS_PATH})`);
continue;
}
answered.push({ name, type: 'A', ttlSeconds: record.ttl, target: address });
break;
}
case 'MX':
case 'NS':
answered.push({ name, type: record.type, ttlSeconds: record.ttl, target: record.rdata.toLowerCase() });
break;
default:
break;
}
}
return answered;
}
+11 -3
View File
@@ -33,7 +33,10 @@
// - a web server's files stay in /webserver (its document root), where Swift
// moves them to /www; and no program state is written into a host's files
// (Swift keeps Gnutella and e-mail state there as hidden JSON files).
// Not here yet: DNS records (from /dns/hosts) and e-mail accounts.
// - a DNS server's records are what Java's server answers from /dns/hosts
// (see dnsHosts.ts); Swift reads the file its own way — tabs, comments,
// "IN", names with a leading digit — and drops Java's duplicates.
// Not here yet: e-mail accounts.
import {
canonicalMacAddress,
@@ -88,6 +91,7 @@ import {
type TopologyNode,
type VirtualFileContent,
} from '@mdg-filius/core';
import { dnsRecordsOf, javaHostsFileContent, readJavaDnsHosts } from './dnsHosts.js';
import type { FlsApplicationClass, FlsFileTree, FlsFirewall } from './systemSoftware.js';
import type { FlsDocumentationItem, FlsInterface, FlsNode, FlsNodeClass, FlsTopology } from './topology.js';
@@ -415,11 +419,15 @@ function registerPrograms(source: FlsNode, node: TopologyNode, system: SystemCon
const forwarder = trimmedOrEmpty(source.interfaces[0]?.dns);
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) =>
warnings.push(`node '${node.displayName}': ${message}`),
);
system.dnsServerConfigurations.set(
node.id,
forwarding
? { recursiveResolutionEnabled: recursive, forwardingServerIPAddress: forwarder }
: { recursiveResolutionEnabled: recursive },
? { recursiveResolutionEnabled: recursive, forwardingServerIPAddress: forwarder, records }
: { recursiveResolutionEnabled: recursive, records },
);
break;
}
+1
View File
@@ -1,5 +1,6 @@
// Public surface of the .fls codec.
export * from './archive.js';
export * from './dnsHosts.js';
export * from './importProject.js';
export * from './javaIp.js';
export * from './javabeans.js';