| Matthias Andreas Benkard | 279fca6 | 2026-08-23 08:32:08 +0200 | [diff] [blame] | 1 | // SPDX-FileCopyrightText: 2020 Matthias Andreas Benkard <code@mail.matthias.benkard.de> |
| 2 | // SPDX-License-Identifier: AGPL-3.0-or-later |
| Matthias Andreas Benkard | 06da70e | 2026-08-16 07:34:12 +0200 | [diff] [blame] | 3 | package eu.mulk.aendggner; |
| 4 | |
| 5 | import java.io.IOException; |
| 6 | import java.nio.charset.StandardCharsets; |
| 7 | import java.util.ArrayList; |
| 8 | import java.util.List; |
| 9 | import java.util.zip.DataFormatException; |
| 10 | import java.util.zip.Inflater; |
| 11 | |
| 12 | /** |
| 13 | * Holt das gii-Norm-XML aus einem Archiv heraus, wie gesetze-im-internet.de es ausliefert. |
| 14 | * |
| 15 | * <p>Das Portal gibt kein rohes XML aus, sondern je Gesetz nur {@code …/<kurz>/xml.zip}. Ohne |
| 16 | * diesen Schritt müsste jede:r die heruntergeladene Datei erst von Hand entpacken, bevor sie ins |
| 17 | * Formular passt — die Einführung der Browserfassung verweist deshalb unmittelbar auf das Archiv. |
| 18 | * |
| 19 | * <p>Das ZIP-Format wird hier von Hand gelesen statt mit {@link java.util.zip.ZipInputStream}: Von |
| 20 | * allen zip-Klassen des JDK trägt in der Wasm-Fassung allein {@link Inflater}, den {@code |
| 21 | * eu.mulk.aendggner.wasm.InflaterErsatz} durch die reine Java-Umsetzung von jzlib ersetzt. {@code |
| 22 | * ZipInputStream}, {@code ZipFile} und {@code CRC32} greifen auf native Bindungen zurück, die Web |
| 23 | * Image nicht kennt; die Prüfsumme rechnet deshalb {@link #crc32} selbst. |
| 24 | * |
| 25 | * <p>Gelesen wird über das Zentralverzeichnis am Dateiende, nicht über die Local Header: Bei |
| 26 | * Einträgen mit Data Descriptor stehen Größe und Prüfsumme im Local Header auf null und erst hinter |
| 27 | * den komprimierten Daten — das Zentralverzeichnis trägt sie immer. |
| 28 | */ |
| 29 | public final class ZipAuspacker { |
| 30 | |
| 31 | /** „PK\05\06“ — Kennung des End-of-Central-Directory-Records. */ |
| 32 | private static final int EOCD_SIGNATUR = 0x06054b50; |
| 33 | |
| 34 | /** „PK\01\02“ — Kennung eines Eintrags im Zentralverzeichnis. */ |
| 35 | private static final int ZENTRAL_SIGNATUR = 0x02014b50; |
| 36 | |
| 37 | /** „PK\03\04“ — Kennung eines Local Headers. */ |
| 38 | private static final int LOKAL_SIGNATUR = 0x04034b50; |
| 39 | |
| 40 | /** Fester Teil des EOCD-Records; dahinter steht nur noch der Archivkommentar. */ |
| 41 | private static final int EOCD_MINDESTLAENGE = 22; |
| 42 | |
| 43 | /** Der Kommentar ist auf 64 KiB begrenzt; weiter zurück muss die Suche nach dem EOCD nicht. */ |
| 44 | private static final int KOMMENTAR_MAX = 0xFFFF; |
| 45 | |
| 46 | private static final int VERFAHREN_GESPEICHERT = 0; |
| 47 | private static final int VERFAHREN_DEFLATE = 8; |
| 48 | |
| 49 | /** Bit 0 des Allgemeinen Kennzeichens: Der Eintrag ist verschlüsselt. */ |
| 50 | private static final int KENNZEICHEN_VERSCHLUESSELT = 1; |
| 51 | |
| 52 | private ZipAuspacker() {} |
| 53 | |
| 54 | /** |
| 55 | * Liefert bei einem Archiv den enthaltenen XML-Eintrag, sonst die Quelle unverändert. |
| 56 | * |
| 57 | * <p>Die gelieferte Quelle trägt den Eintragsnamen, damit Protokoll und Ladefehler das Gesetz |
| 58 | * benennen statt seine Verpackung. Die Quellenzeile der Synopse bleibt davon unberührt: Sie nennt |
| 59 | * weiterhin die Datei, die angegeben wurde ({@code uwg.zip}), denn sie soll den Weg zur |
| 60 | * Fundstelle zurück beschreiben. |
| 61 | */ |
| 62 | public static Quelle auspacken(Quelle quelle) throws IOException { |
| 63 | if (DateiTyp.erkenne(quelle.inhalt()) != DateiTyp.ZIP) { |
| 64 | return quelle; |
| 65 | } |
| 66 | |
| 67 | var eintraege = zentralverzeichnis(quelle); |
| 68 | var xml = einzigesXml(eintraege, quelle.name()); |
| 69 | return new Quelle(xml.name(), entpacke(quelle.inhalt(), xml, quelle.name())); |
| 70 | } |
| 71 | |
| 72 | /** Ein Eintrag des Zentralverzeichnisses, soweit hier gebraucht. */ |
| 73 | private record Eintrag( |
| 74 | String name, |
| 75 | int verfahren, |
| 76 | int kennzeichen, |
| 77 | long crc, |
| 78 | int gepackt, |
| 79 | int entpackt, |
| 80 | int offset) { |
| 81 | |
| 82 | String dateiname() { |
| 83 | int schraeg = name.lastIndexOf('/'); |
| 84 | return schraeg < 0 ? name : name.substring(schraeg + 1); |
| 85 | } |
| 86 | } |
| 87 | |
| 88 | private static List<Eintrag> zentralverzeichnis(Quelle quelle) throws IOException { |
| 89 | var bytes = quelle.inhalt(); |
| 90 | int eocd = sucheEocd(bytes, quelle.name()); |
| 91 | |
| 92 | int anzahl = u16(bytes, eocd + 10); |
| 93 | int anfang = (int) u32(bytes, eocd + 16); |
| 94 | |
| 95 | var eintraege = new ArrayList<Eintrag>(anzahl); |
| 96 | int p = anfang; |
| 97 | for (int i = 0; i < anzahl; i++) { |
| 98 | if (p < 0 || p + 46 > bytes.length || (int) u32(bytes, p) != ZENTRAL_SIGNATUR) { |
| 99 | throw beschaedigt(quelle.name(), "das Zentralverzeichnis bricht ab"); |
| 100 | } |
| 101 | int namensLaenge = u16(bytes, p + 28); |
| 102 | int zusatzLaenge = u16(bytes, p + 30); |
| 103 | int kommentarLaenge = u16(bytes, p + 32); |
| 104 | if (p + 46 + namensLaenge > bytes.length) { |
| 105 | throw beschaedigt(quelle.name(), "ein Eintragsname reicht über das Dateiende hinaus"); |
| 106 | } |
| 107 | eintraege.add( |
| 108 | new Eintrag( |
| 109 | // Ohne gesetztes Bit 11 des Allgemeinen Kennzeichens schreibt das Format CP437; |
| 110 | // die hier vorkommenden Namen sind reines ASCII, für das beides zusammenfällt. |
| 111 | new String(bytes, p + 46, namensLaenge, StandardCharsets.UTF_8), |
| 112 | u16(bytes, p + 10), |
| 113 | u16(bytes, p + 8), |
| 114 | u32(bytes, p + 16), |
| 115 | (int) u32(bytes, p + 20), |
| 116 | (int) u32(bytes, p + 24), |
| 117 | (int) u32(bytes, p + 42))); |
| 118 | p += 46 + namensLaenge + zusatzLaenge + kommentarLaenge; |
| 119 | } |
| 120 | return eintraege; |
| 121 | } |
| 122 | |
| 123 | /** Sucht den EOCD-Record von hinten: Vor ihm steht nur noch der Archivkommentar. */ |
| 124 | private static int sucheEocd(byte[] bytes, String name) throws IOException { |
| 125 | int frueheste = Math.max(0, bytes.length - EOCD_MINDESTLAENGE - KOMMENTAR_MAX); |
| 126 | for (int p = bytes.length - EOCD_MINDESTLAENGE; p >= frueheste; p--) { |
| 127 | if ((int) u32(bytes, p) == EOCD_SIGNATUR) { |
| 128 | return p; |
| 129 | } |
| 130 | } |
| 131 | throw beschaedigt(name, "das Ende des Zentralverzeichnisses fehlt"); |
| 132 | } |
| 133 | |
| 134 | private static Eintrag einzigesXml(List<Eintrag> eintraege, String archiv) throws IOException { |
| 135 | var xml = new ArrayList<Eintrag>(); |
| 136 | for (var eintrag : eintraege) { |
| 137 | // Verzeichniseinträge tragen keinen Inhalt; „__MACOSX/“ ist Beiwerk, das macOS beim Packen |
| 138 | // hinzufügt und dessen Namen die der echten Einträge spiegeln. |
| 139 | if (eintrag.name().endsWith("/") || eintrag.name().startsWith("__MACOSX/")) { |
| 140 | continue; |
| 141 | } |
| 142 | // gii legt Gesetzen mit Anlagen deren Bilddateien mit ins Archiv — es ist deshalb nicht |
| 143 | // einerlei, welcher Eintrag genommen wird. |
| 144 | if (eintrag.dateiname().toLowerCase().endsWith(".xml")) { |
| 145 | xml.add(eintrag); |
| 146 | } |
| 147 | } |
| 148 | |
| 149 | if (xml.isEmpty()) { |
| 150 | throw new IOException( |
| 151 | "Das Archiv %s enthält keine XML-Datei; erwartet wird das gii-Norm-XML von" |
| 152 | .formatted(archiv) |
| 153 | + " gesetze-im-internet.de (xml.zip)"); |
| 154 | } |
| 155 | if (xml.size() > 1) { |
| 156 | throw new IOException( |
| 157 | "Das Archiv %s enthält mehrere XML-Dateien (%s); bitte die gewünschte entpackt angeben" |
| 158 | .formatted(archiv, String.join(", ", xml.stream().map(Eintrag::name).toList()))); |
| 159 | } |
| 160 | return xml.getFirst(); |
| 161 | } |
| 162 | |
| 163 | private static byte[] entpacke(byte[] bytes, Eintrag eintrag, String archiv) throws IOException { |
| 164 | if ((eintrag.kennzeichen() & KENNZEICHEN_VERSCHLUESSELT) != 0) { |
| 165 | throw new IOException( |
| 166 | "Der Eintrag %s im Archiv %s ist verschlüsselt".formatted(eintrag.name(), archiv)); |
| 167 | } |
| 168 | |
| 169 | // Der Local Header wiederholt Name und Zusatzfeld, und zwar mit eigenen Längen — die Daten |
| 170 | // beginnen erst dahinter. |
| 171 | int lokal = eintrag.offset(); |
| 172 | if (lokal < 0 || lokal + 30 > bytes.length || (int) u32(bytes, lokal) != LOKAL_SIGNATUR) { |
| 173 | throw beschaedigt( |
| 174 | archiv, |
| 175 | "der Eintrag %s steht nicht dort, wo das Zentralverzeichnis ihn führt" |
| 176 | .formatted(eintrag.name())); |
| 177 | } |
| 178 | int daten = lokal + 30 + u16(bytes, lokal + 26) + u16(bytes, lokal + 28); |
| 179 | if (daten < 0 || eintrag.gepackt() < 0 || daten + eintrag.gepackt() > bytes.length) { |
| 180 | throw beschaedigt( |
| 181 | archiv, "der Eintrag %s reicht über das Dateiende hinaus".formatted(eintrag.name())); |
| 182 | } |
| 183 | |
| 184 | var inhalt = |
| 185 | switch (eintrag.verfahren()) { |
| 186 | case VERFAHREN_GESPEICHERT -> { |
| 187 | var kopie = new byte[eintrag.gepackt()]; |
| 188 | System.arraycopy(bytes, daten, kopie, 0, eintrag.gepackt()); |
| 189 | yield kopie; |
| 190 | } |
| 191 | case VERFAHREN_DEFLATE -> |
| 192 | inflate(bytes, daten, eintrag.gepackt(), eintrag.entpackt(), eintrag.name(), archiv); |
| 193 | default -> |
| 194 | throw new IOException( |
| 195 | "Der Eintrag %s im Archiv %s ist mit dem unbekannten Verfahren %d gepackt" |
| 196 | .formatted(eintrag.name(), archiv, eintrag.verfahren())); |
| 197 | }; |
| 198 | |
| 199 | if (crc32(inhalt) != eintrag.crc()) { |
| 200 | throw beschaedigt( |
| 201 | archiv, "die Prüfsumme des Eintrags %s stimmt nicht".formatted(eintrag.name())); |
| 202 | } |
| 203 | return inhalt; |
| 204 | } |
| 205 | |
| 206 | /** |
| 207 | * Rohes Deflate ohne zlib-Rahmen — daher {@code new Inflater(true)}. Die Ausgabegröße steht im |
| 208 | * Zentralverzeichnis, es wird also genau einmal ausgelesen und nicht nachgewachsen. |
| 209 | */ |
| 210 | private static byte[] inflate( |
| 211 | byte[] bytes, int von, int gepackt, int entpackt, String eintrag, String archiv) |
| 212 | throws IOException { |
| 213 | if (entpackt < 0) { |
| 214 | throw beschaedigt( |
| 215 | archiv, "der Eintrag %s gibt eine unbrauchbare Größe an".formatted(eintrag)); |
| 216 | } |
| 217 | var inflater = new Inflater(true); |
| 218 | try { |
| 219 | inflater.setInput(bytes, von, gepackt); |
| 220 | var aus = new byte[entpackt]; |
| 221 | int gefuellt = 0; |
| 222 | while (gefuellt < entpackt) { |
| 223 | int geschrieben = inflater.inflate(aus, gefuellt, entpackt - gefuellt); |
| 224 | if (geschrieben == 0 && (inflater.finished() || inflater.needsInput())) { |
| 225 | throw beschaedigt( |
| 226 | archiv, "der Eintrag %s endet vor der angegebenen Größe".formatted(eintrag)); |
| 227 | } |
| 228 | gefuellt += geschrieben; |
| 229 | } |
| 230 | return aus; |
| 231 | } catch (DataFormatException e) { |
| 232 | throw new IOException( |
| 233 | "Der Eintrag %s im Archiv %s lässt sich nicht entpacken: %s" |
| 234 | .formatted(eintrag, archiv, e.getMessage()), |
| 235 | e); |
| 236 | } finally { |
| 237 | inflater.end(); |
| 238 | } |
| 239 | } |
| 240 | |
| 241 | /** |
| 242 | * CRC-32 (IEEE 802.3) in reinem Java. {@code java.util.zip.CRC32} ruft eine native Routine des |
| 243 | * JDK auf, die Web Image nicht kennt; die Tabelle wird deshalb hier aufgebaut. |
| 244 | */ |
| 245 | private static long crc32(byte[] daten) { |
| 246 | long crc = 0xFFFFFFFFL; |
| 247 | for (var b : daten) { |
| 248 | crc ^= b & 0xFF; |
| 249 | for (int i = 0; i < 8; i++) { |
| 250 | crc = (crc >>> 1) ^ (0xEDB88320L & -(crc & 1)); |
| 251 | } |
| 252 | } |
| 253 | return crc ^ 0xFFFFFFFFL; |
| 254 | } |
| 255 | |
| 256 | private static IOException beschaedigt(String archiv, String grund) { |
| 257 | return new IOException("Das Archiv %s ist beschädigt: %s".formatted(archiv, grund)); |
| 258 | } |
| 259 | |
| 260 | private static int u16(byte[] bytes, int p) { |
| 261 | return (bytes[p] & 0xFF) | ((bytes[p + 1] & 0xFF) << 8); |
| 262 | } |
| 263 | |
| 264 | private static long u32(byte[] bytes, int p) { |
| 265 | return (bytes[p] & 0xFFL) |
| 266 | | ((bytes[p + 1] & 0xFFL) << 8) |
| 267 | | ((bytes[p + 2] & 0xFFL) << 16) |
| 268 | | ((bytes[p + 3] & 0xFFL) << 24); |
| 269 | } |
| 270 | } |