aboutsummaryrefslogtreecommitdiff
path: root/src/main/java/eu
diff options
context:
space:
mode:
Diffstat (limited to 'src/main/java/eu')
-rw-r--r--src/main/java/eu/mulk/aendggner/DateiTyp.java21
-rw-r--r--src/main/java/eu/mulk/aendggner/Pipeline.java15
-rw-r--r--src/main/java/eu/mulk/aendggner/ZipAuspacker.java268
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/parse/PatchTextExtraktor.java2
-rw-r--r--src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtLoader.java2
5 files changed, 297 insertions, 11 deletions
diff --git a/src/main/java/eu/mulk/aendggner/DateiTyp.java b/src/main/java/eu/mulk/aendggner/DateiTyp.java
index 2157fc4..6e31dc1 100644
--- a/src/main/java/eu/mulk/aendggner/DateiTyp.java
+++ b/src/main/java/eu/mulk/aendggner/DateiTyp.java
@@ -3,22 +3,33 @@ package eu.mulk.aendggner;
import java.nio.charset.StandardCharsets;
/**
- * Die drei Eingabeformate, die ÄndGgner unterscheidet, erkannt an den ersten Bytes.
+ * Die Eingabeformate, die ÄndGgner unterscheidet, erkannt an den ersten Bytes.
*
- * <p>Ersetzt die frühere Tika-Erkennung: Unterschieden werden muss nur zwischen PDF, gii-XML und
- * Klartext, und dafür genügen die Signaturbytes. Das spart eine schwergewichtige Abhängigkeit samt
- * ServiceLoader- und XML-Konfiguration — was der Wasm-Übersetzung zugutekommt, für die jede
+ * <p>Ersetzt die frühere Tika-Erkennung: Unterschieden werden muss nur zwischen ZIP, PDF, gii-XML
+ * und Klartext, und dafür genügen die Signaturbytes. Das spart eine schwergewichtige Abhängigkeit
+ * samt ServiceLoader- und XML-Konfiguration — was der Wasm-Übersetzung zugutekommt, für die jede
* dynamisch aufgelöste Abhängigkeit Handarbeit bedeutet.
*/
public enum DateiTyp {
PDF,
XML,
+ ZIP,
KLARTEXT;
/** Wie weit hinein nach der Signatur gesucht wird (PDFs tragen gelegentlich Vorspann). */
private static final int VORSCHAU_BYTES = 1024;
public static DateiTyp erkenne(byte[] inhalt) {
+ // Die ZIP-Signatur steht an Byte 0 und wird deshalb zuerst und ohne Vorschaufenster geprüft:
+ // Ein Archiv, das zufällig „%PDF-“ in seinen komprimierten Daten führt, wäre sonst ein PDF.
+ if (inhalt.length >= 4
+ && inhalt[0] == 'P'
+ && inhalt[1] == 'K'
+ && inhalt[2] == 3
+ && inhalt[3] == 4) {
+ return ZIP;
+ }
+
// ISO-8859-1 bildet jedes Byte auf genau ein Zeichen ab — hier geht es um Signaturen, nicht
// um lesbaren Text, und die Zeichenzählung soll der Byteposition entsprechen.
var vorschau =
@@ -41,7 +52,7 @@ public enum DateiTyp {
return KLARTEXT;
}
- /** Für Fehlermeldungen: „PDF“, „XML“, „Klartext“. */
+ /** Für Fehlermeldungen: „PDF“, „XML“, „ZIP“, „Klartext“. */
public String anzeigeName() {
return this == KLARTEXT ? "Klartext" : name();
}
diff --git a/src/main/java/eu/mulk/aendggner/Pipeline.java b/src/main/java/eu/mulk/aendggner/Pipeline.java
index 4b2b7d9..16df275 100644
--- a/src/main/java/eu/mulk/aendggner/Pipeline.java
+++ b/src/main/java/eu/mulk/aendggner/Pipeline.java
@@ -278,11 +278,18 @@ public final class Pipeline {
return ladeStammgesetz(Quelle.lies(baseFile));
}
- /** Gii-XML → {@link GiiXmlLoader}; PDF/Klartext (Landesrecht) → {@link LandesRechtLoader}. */
+ /**
+ * Gii-XML → {@link GiiXmlLoader}; PDF/Klartext (Landesrecht) → {@link LandesRechtLoader}.
+ *
+ * <p>Vorweg wird ausgepackt: gesetze-im-internet.de gibt das Norm-XML nur als {@code xml.zip}
+ * aus, und das soll unentpackt taugen (siehe {@link ZipAuspacker}). Die Änderungsdokumente
+ * bleiben davon unberührt — Gesetzblätter und Drucksachen kommen nirgends als Archiv.
+ */
static Gesetz ladeStammgesetz(Quelle baseFile) throws Exception {
- return DateiTyp.erkenne(baseFile.inhalt()) == DateiTyp.XML
- ? new GiiXmlLoader().load(baseFile)
- : new LandesRechtLoader().load(baseFile);
+ var quelle = ZipAuspacker.auspacken(baseFile);
+ return DateiTyp.erkenne(quelle.inhalt()) == DateiTyp.XML
+ ? new GiiXmlLoader().load(quelle)
+ : new LandesRechtLoader().load(quelle);
}
/**
diff --git a/src/main/java/eu/mulk/aendggner/ZipAuspacker.java b/src/main/java/eu/mulk/aendggner/ZipAuspacker.java
new file mode 100644
index 0000000..0baeac2
--- /dev/null
+++ b/src/main/java/eu/mulk/aendggner/ZipAuspacker.java
@@ -0,0 +1,268 @@
+package eu.mulk.aendggner;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.zip.DataFormatException;
+import java.util.zip.Inflater;
+
+/**
+ * Holt das gii-Norm-XML aus einem Archiv heraus, wie gesetze-im-internet.de es ausliefert.
+ *
+ * <p>Das Portal gibt kein rohes XML aus, sondern je Gesetz nur {@code …/<kurz>/xml.zip}. Ohne
+ * diesen Schritt müsste jede:r die heruntergeladene Datei erst von Hand entpacken, bevor sie ins
+ * Formular passt — die Einführung der Browserfassung verweist deshalb unmittelbar auf das Archiv.
+ *
+ * <p>Das ZIP-Format wird hier von Hand gelesen statt mit {@link java.util.zip.ZipInputStream}: Von
+ * allen zip-Klassen des JDK trägt in der Wasm-Fassung allein {@link Inflater}, den {@code
+ * eu.mulk.aendggner.wasm.InflaterErsatz} durch die reine Java-Umsetzung von jzlib ersetzt. {@code
+ * ZipInputStream}, {@code ZipFile} und {@code CRC32} greifen auf native Bindungen zurück, die Web
+ * Image nicht kennt; die Prüfsumme rechnet deshalb {@link #crc32} selbst.
+ *
+ * <p>Gelesen wird über das Zentralverzeichnis am Dateiende, nicht über die Local Header: Bei
+ * Einträgen mit Data Descriptor stehen Größe und Prüfsumme im Local Header auf null und erst hinter
+ * den komprimierten Daten — das Zentralverzeichnis trägt sie immer.
+ */
+public final class ZipAuspacker {
+
+ /** „PK\05\06“ — Kennung des End-of-Central-Directory-Records. */
+ private static final int EOCD_SIGNATUR = 0x06054b50;
+
+ /** „PK\01\02“ — Kennung eines Eintrags im Zentralverzeichnis. */
+ private static final int ZENTRAL_SIGNATUR = 0x02014b50;
+
+ /** „PK\03\04“ — Kennung eines Local Headers. */
+ private static final int LOKAL_SIGNATUR = 0x04034b50;
+
+ /** Fester Teil des EOCD-Records; dahinter steht nur noch der Archivkommentar. */
+ private static final int EOCD_MINDESTLAENGE = 22;
+
+ /** Der Kommentar ist auf 64 KiB begrenzt; weiter zurück muss die Suche nach dem EOCD nicht. */
+ private static final int KOMMENTAR_MAX = 0xFFFF;
+
+ private static final int VERFAHREN_GESPEICHERT = 0;
+ private static final int VERFAHREN_DEFLATE = 8;
+
+ /** Bit 0 des Allgemeinen Kennzeichens: Der Eintrag ist verschlüsselt. */
+ private static final int KENNZEICHEN_VERSCHLUESSELT = 1;
+
+ private ZipAuspacker() {}
+
+ /**
+ * Liefert bei einem Archiv den enthaltenen XML-Eintrag, sonst die Quelle unverändert.
+ *
+ * <p>Die gelieferte Quelle trägt den Eintragsnamen, damit Protokoll und Ladefehler das Gesetz
+ * benennen statt seine Verpackung. Die Quellenzeile der Synopse bleibt davon unberührt: Sie nennt
+ * weiterhin die Datei, die angegeben wurde ({@code uwg.zip}), denn sie soll den Weg zur
+ * Fundstelle zurück beschreiben.
+ */
+ public static Quelle auspacken(Quelle quelle) throws IOException {
+ if (DateiTyp.erkenne(quelle.inhalt()) != DateiTyp.ZIP) {
+ return quelle;
+ }
+
+ var eintraege = zentralverzeichnis(quelle);
+ var xml = einzigesXml(eintraege, quelle.name());
+ return new Quelle(xml.name(), entpacke(quelle.inhalt(), xml, quelle.name()));
+ }
+
+ /** Ein Eintrag des Zentralverzeichnisses, soweit hier gebraucht. */
+ private record Eintrag(
+ String name,
+ int verfahren,
+ int kennzeichen,
+ long crc,
+ int gepackt,
+ int entpackt,
+ int offset) {
+
+ String dateiname() {
+ int schraeg = name.lastIndexOf('/');
+ return schraeg < 0 ? name : name.substring(schraeg + 1);
+ }
+ }
+
+ private static List<Eintrag> zentralverzeichnis(Quelle quelle) throws IOException {
+ var bytes = quelle.inhalt();
+ int eocd = sucheEocd(bytes, quelle.name());
+
+ int anzahl = u16(bytes, eocd + 10);
+ int anfang = (int) u32(bytes, eocd + 16);
+
+ var eintraege = new ArrayList<Eintrag>(anzahl);
+ int p = anfang;
+ for (int i = 0; i < anzahl; i++) {
+ if (p < 0 || p + 46 > bytes.length || (int) u32(bytes, p) != ZENTRAL_SIGNATUR) {
+ throw beschaedigt(quelle.name(), "das Zentralverzeichnis bricht ab");
+ }
+ int namensLaenge = u16(bytes, p + 28);
+ int zusatzLaenge = u16(bytes, p + 30);
+ int kommentarLaenge = u16(bytes, p + 32);
+ if (p + 46 + namensLaenge > bytes.length) {
+ throw beschaedigt(quelle.name(), "ein Eintragsname reicht über das Dateiende hinaus");
+ }
+ eintraege.add(
+ new Eintrag(
+ // Ohne gesetztes Bit 11 des Allgemeinen Kennzeichens schreibt das Format CP437;
+ // die hier vorkommenden Namen sind reines ASCII, für das beides zusammenfällt.
+ new String(bytes, p + 46, namensLaenge, StandardCharsets.UTF_8),
+ u16(bytes, p + 10),
+ u16(bytes, p + 8),
+ u32(bytes, p + 16),
+ (int) u32(bytes, p + 20),
+ (int) u32(bytes, p + 24),
+ (int) u32(bytes, p + 42)));
+ p += 46 + namensLaenge + zusatzLaenge + kommentarLaenge;
+ }
+ return eintraege;
+ }
+
+ /** Sucht den EOCD-Record von hinten: Vor ihm steht nur noch der Archivkommentar. */
+ private static int sucheEocd(byte[] bytes, String name) throws IOException {
+ int frueheste = Math.max(0, bytes.length - EOCD_MINDESTLAENGE - KOMMENTAR_MAX);
+ for (int p = bytes.length - EOCD_MINDESTLAENGE; p >= frueheste; p--) {
+ if ((int) u32(bytes, p) == EOCD_SIGNATUR) {
+ return p;
+ }
+ }
+ throw beschaedigt(name, "das Ende des Zentralverzeichnisses fehlt");
+ }
+
+ private static Eintrag einzigesXml(List<Eintrag> eintraege, String archiv) throws IOException {
+ var xml = new ArrayList<Eintrag>();
+ for (var eintrag : eintraege) {
+ // Verzeichniseinträge tragen keinen Inhalt; „__MACOSX/“ ist Beiwerk, das macOS beim Packen
+ // hinzufügt und dessen Namen die der echten Einträge spiegeln.
+ if (eintrag.name().endsWith("/") || eintrag.name().startsWith("__MACOSX/")) {
+ continue;
+ }
+ // gii legt Gesetzen mit Anlagen deren Bilddateien mit ins Archiv — es ist deshalb nicht
+ // einerlei, welcher Eintrag genommen wird.
+ if (eintrag.dateiname().toLowerCase().endsWith(".xml")) {
+ xml.add(eintrag);
+ }
+ }
+
+ if (xml.isEmpty()) {
+ throw new IOException(
+ "Das Archiv %s enthält keine XML-Datei; erwartet wird das gii-Norm-XML von"
+ .formatted(archiv)
+ + " gesetze-im-internet.de (xml.zip)");
+ }
+ if (xml.size() > 1) {
+ throw new IOException(
+ "Das Archiv %s enthält mehrere XML-Dateien (%s); bitte die gewünschte entpackt angeben"
+ .formatted(archiv, String.join(", ", xml.stream().map(Eintrag::name).toList())));
+ }
+ return xml.getFirst();
+ }
+
+ private static byte[] entpacke(byte[] bytes, Eintrag eintrag, String archiv) throws IOException {
+ if ((eintrag.kennzeichen() & KENNZEICHEN_VERSCHLUESSELT) != 0) {
+ throw new IOException(
+ "Der Eintrag %s im Archiv %s ist verschlüsselt".formatted(eintrag.name(), archiv));
+ }
+
+ // Der Local Header wiederholt Name und Zusatzfeld, und zwar mit eigenen Längen — die Daten
+ // beginnen erst dahinter.
+ int lokal = eintrag.offset();
+ if (lokal < 0 || lokal + 30 > bytes.length || (int) u32(bytes, lokal) != LOKAL_SIGNATUR) {
+ throw beschaedigt(
+ archiv,
+ "der Eintrag %s steht nicht dort, wo das Zentralverzeichnis ihn führt"
+ .formatted(eintrag.name()));
+ }
+ int daten = lokal + 30 + u16(bytes, lokal + 26) + u16(bytes, lokal + 28);
+ if (daten < 0 || eintrag.gepackt() < 0 || daten + eintrag.gepackt() > bytes.length) {
+ throw beschaedigt(
+ archiv, "der Eintrag %s reicht über das Dateiende hinaus".formatted(eintrag.name()));
+ }
+
+ var inhalt =
+ switch (eintrag.verfahren()) {
+ case VERFAHREN_GESPEICHERT -> {
+ var kopie = new byte[eintrag.gepackt()];
+ System.arraycopy(bytes, daten, kopie, 0, eintrag.gepackt());
+ yield kopie;
+ }
+ case VERFAHREN_DEFLATE ->
+ inflate(bytes, daten, eintrag.gepackt(), eintrag.entpackt(), eintrag.name(), archiv);
+ default ->
+ throw new IOException(
+ "Der Eintrag %s im Archiv %s ist mit dem unbekannten Verfahren %d gepackt"
+ .formatted(eintrag.name(), archiv, eintrag.verfahren()));
+ };
+
+ if (crc32(inhalt) != eintrag.crc()) {
+ throw beschaedigt(
+ archiv, "die Prüfsumme des Eintrags %s stimmt nicht".formatted(eintrag.name()));
+ }
+ return inhalt;
+ }
+
+ /**
+ * Rohes Deflate ohne zlib-Rahmen — daher {@code new Inflater(true)}. Die Ausgabegröße steht im
+ * Zentralverzeichnis, es wird also genau einmal ausgelesen und nicht nachgewachsen.
+ */
+ private static byte[] inflate(
+ byte[] bytes, int von, int gepackt, int entpackt, String eintrag, String archiv)
+ throws IOException {
+ if (entpackt < 0) {
+ throw beschaedigt(
+ archiv, "der Eintrag %s gibt eine unbrauchbare Größe an".formatted(eintrag));
+ }
+ var inflater = new Inflater(true);
+ try {
+ inflater.setInput(bytes, von, gepackt);
+ var aus = new byte[entpackt];
+ int gefuellt = 0;
+ while (gefuellt < entpackt) {
+ int geschrieben = inflater.inflate(aus, gefuellt, entpackt - gefuellt);
+ if (geschrieben == 0 && (inflater.finished() || inflater.needsInput())) {
+ throw beschaedigt(
+ archiv, "der Eintrag %s endet vor der angegebenen Größe".formatted(eintrag));
+ }
+ gefuellt += geschrieben;
+ }
+ return aus;
+ } catch (DataFormatException e) {
+ throw new IOException(
+ "Der Eintrag %s im Archiv %s lässt sich nicht entpacken: %s"
+ .formatted(eintrag, archiv, e.getMessage()),
+ e);
+ } finally {
+ inflater.end();
+ }
+ }
+
+ /**
+ * CRC-32 (IEEE 802.3) in reinem Java. {@code java.util.zip.CRC32} ruft eine native Routine des
+ * JDK auf, die Web Image nicht kennt; die Tabelle wird deshalb hier aufgebaut.
+ */
+ private static long crc32(byte[] daten) {
+ long crc = 0xFFFFFFFFL;
+ for (var b : daten) {
+ crc ^= b & 0xFF;
+ for (int i = 0; i < 8; i++) {
+ crc = (crc >>> 1) ^ (0xEDB88320L & -(crc & 1));
+ }
+ }
+ return crc ^ 0xFFFFFFFFL;
+ }
+
+ private static IOException beschaedigt(String archiv, String grund) {
+ return new IOException("Das Archiv %s ist beschädigt: %s".formatted(archiv, grund));
+ }
+
+ private static int u16(byte[] bytes, int p) {
+ return (bytes[p] & 0xFF) | ((bytes[p + 1] & 0xFF) << 8);
+ }
+
+ private static long u32(byte[] bytes, int p) {
+ return (bytes[p] & 0xFFL)
+ | ((bytes[p + 1] & 0xFFL) << 8)
+ | ((bytes[p + 2] & 0xFFL) << 16)
+ | ((bytes[p + 3] & 0xFFL) << 24);
+ }
+}
diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/PatchTextExtraktor.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/PatchTextExtraktor.java
index daa72c5..876f0b5 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/parse/PatchTextExtraktor.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/PatchTextExtraktor.java
@@ -44,7 +44,7 @@ public final class PatchTextExtraktor {
return switch (typ) {
case PDF -> extrahierePdf(quelle.inhalt());
case KLARTEXT -> new String(quelle.inhalt(), StandardCharsets.UTF_8);
- case XML ->
+ case XML, ZIP ->
throw new IOException(
"Nicht unterstützter Dateityp %s für %s (unterstützt: PDF, Klartext)"
.formatted(typ.anzeigeName(), quelle.name()));
diff --git a/src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtLoader.java b/src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtLoader.java
index 22c1e41..f5e777a 100644
--- a/src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtLoader.java
+++ b/src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtLoader.java
@@ -49,7 +49,7 @@ public final class LandesRechtLoader {
case KLARTEXT ->
Normalizer.normalize(
new String(quelle.inhalt(), StandardCharsets.UTF_8), Normalizer.Form.NFC);
- case XML ->
+ case XML, ZIP ->
throw new IOException(
"Nicht unterstützter Dateityp %s für Stammgesetz %s (unterstützt: PDF, Klartext)"
.formatted(typ.anzeigeName(), quelle.name()));