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>
143 lines
5.8 KiB
TypeScript
143 lines
5.8 KiB
TypeScript
// 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). 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 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';
|
|
import { javaIpCheck, javaParseInt } from './javaIp.js';
|
|
import { javaTrim } from './javaString.js';
|
|
import type { FlsFileTree } from './systemSoftware.js';
|
|
|
|
/** 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;
|
|
readonly rdata: string;
|
|
}
|
|
|
|
export const DNS_HOSTS_PATH = '/dns/hosts';
|
|
|
|
// 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]))$/;
|
|
|
|
/** The content of /dns/hosts as Java's DNS server finds it (see javaFileSystem.ts). */
|
|
export function javaHostsFileContent(root: FlsFileTree | undefined): string | undefined {
|
|
return javaFileContent(root, DNS_HOSTS_PATH);
|
|
}
|
|
|
|
const ANSWERED_TYPES = new Set(['A', 'MX', 'NS']);
|
|
|
|
/** `DNSServer.leseRecordListe` on the content of /dns/hosts. */
|
|
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 = hostsRecord(line);
|
|
if (isValidRecord(record)) records.push(record);
|
|
}
|
|
return records;
|
|
}
|
|
|
|
/** `new ResourceRecord(line)`: up to four space-separated fields. */
|
|
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 && isDomainName(fields[0])) {
|
|
domainName = fields[0].endsWith('.') ? fields[0] : `${fields[0]}.`;
|
|
}
|
|
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.
|
|
const needsDot = (type === 'NS' || type === 'MX') && !fields[3].endsWith('.');
|
|
rdata = needsDot ? `${fields[3]}.` : fields[3];
|
|
}
|
|
return { domainName, type, ttl, rdata };
|
|
}
|
|
|
|
function isValidRecord(record: HostsRecord): boolean {
|
|
switch (record.type) {
|
|
case 'A':
|
|
return isDomainName(record.domainName) && JAVA_IP_ADDRESS.test(record.rdata);
|
|
case 'MX':
|
|
case 'NS':
|
|
return isDomainName(record.domainName) && isDomainName(record.rdata);
|
|
default:
|
|
return true;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The records Java answers with, in the model's form: Java looks them up by
|
|
* 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 HostsRecord[], 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;
|
|
}
|