blob: 4612061e6bd7deda1fd671ca511d0126d0ed3983 [file] [log] [blame]
// SPDX-FileCopyrightText: 2020 Matthias Andreas Benkard <code@mail.matthias.benkard.de>
// SPDX-License-Identifier: AGPL-3.0-or-later
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);
}
}