blob: 4612061e6bd7deda1fd671ca511d0126d0ed3983 [file] [log] [blame]
Matthias Andreas Benkard279fca62026-08-23 08:32:08 +02001// SPDX-FileCopyrightText: 2020 Matthias Andreas Benkard <code@mail.matthias.benkard.de>
2// SPDX-License-Identifier: AGPL-3.0-or-later
Matthias Andreas Benkard06da70e2026-08-16 07:34:12 +02003package eu.mulk.aendggner;
4
5import java.io.IOException;
6import java.nio.charset.StandardCharsets;
7import java.util.ArrayList;
8import java.util.List;
9import java.util.zip.DataFormatException;
10import 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 */
29public 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}