Die Eingabe darf eine Anschrift sein

Das Handbuch verlangte, jede Eingabe als Datei bereitzuhalten. Für das Bundesrecht
war das eine Zumutung: Die Anschrift des Norm-XML folgt aus der Abkürzung des
Gesetzes, und wer sie von Hand herunterlädt, verrichtet eine Arbeit, die das
Erzeugnis selbst verrichten kann.

Der neue Bezug nimmt einen Dateipfad, eine Anschrift oder die Kurzform
„gii:<Kennung>“ und gibt eine Quelle zurück. Die Kennung ist die klein geschriebene
Abkürzung, in der jedes andere Zeichen als Buchstabe, Ziffer und Bindestrich zum
Unterstrich wird; führt sie ins Leere, so wird im Verzeichnis des Portals
nachgeschlagen, denn dass das UWG dort „uwg_2004“ heißt, kann niemand wissen. Bleibt
es mehrdeutig, so wird nicht geraten, sondern aufgezählt. Die vorhandene Datei behält
den Vorrang: Wer eine Datei „gii:etwas“ nennt, meint sie.

Zwei Kleinigkeiten, die das Werkzeug erst brauchbar machen: Der Zwischenspeicher ist
eine Bequemlichkeit und keine Bedingung — ist das Heimatverzeichnis schreibgeschützt,
so wird eben jedes Mal geladen. Und ein Vermittler wird aus HTTPS_PROXY samt Kennung
übernommen; die Laufzeit liest das nicht von sich aus, obgleich es auf der
Kommandozeile die gewohnte Angabe ist und in einer Behörde jeder Weg darüber führt.

Der Bezug gehört allein der Befehlszeile. Die Pipeline kennt weiterhin nur Name und
Bytes, und die Browserfassung rührt ihn nicht an.

Geprüft: mvnw verify, 415 Tests (zuvor 406), reuse lint 196/196. Von Hand belegt ist
der Lauf ganz ohne Datei — „gii:UWG 2004“ nebst der Drucksache 21/1855 von
dserver.bundestag.de, zwanzig Befehle angewandt; die Synopse aus „gii:UWG 2004“
gleicht bis auf den Quellennamen zeichengenau der aus der örtlichen XML-Datei.

Change-Id: I935efb19a5777fc8dd08c96ff44376b65fb7211b
diff --git a/FASSUNGEN.txt b/FASSUNGEN.txt
index f7a760e..a497e28 100644
--- a/FASSUNGEN.txt
+++ b/FASSUNGEN.txt
@@ -21,6 +21,83 @@
 
 
 ════════════════════════════════════════════════════════════════════════════════
+ Fassung vom 26. August 2026 (dritte Fassung des Tages),
+ zuletzt geändert durch die am 26. August 2026 vorgenommenen Änderungen
+════════════════════════════════════════════════════════════════════════════════
+
+Das Handbuch verlangte bislang, jede Eingabe als Datei bereitzuhalten. Für das
+Bundesrecht war das eine Zumutung: Die Anschrift des Norm-XML folgt aus der
+Abkürzung des Gesetzes, und wer sie von Hand herunterlädt, verrichtet eine Arbeit,
+die das Erzeugnis selbst verrichten kann. Dasselbe gilt für die Drucksachen, die
+der Bundestag frei ausgibt.
+
+
+Artikel 1
+Beschaffung der Eingaben
+
+(1) Es wird der Bezug (Bezug) eingeführt. Er nimmt eine Angabe entgegen und gibt
+    eine Quelle zurück. Angegeben werden können ein Dateipfad, eine Anschrift
+    („http://“, „https://“) sowie die Kurzform „gii:<Kennung>“ für das
+    Bundesrecht. Der vorhandene Dateipfad hat stets den Vorrang.
+
+(2) Die Kennung ergibt sich aus der Abkürzung des Gesetzes: klein geschrieben,
+    wobei jedes andere Zeichen als Buchstabe, Ziffer und Bindestrich zum
+    Unterstrich wird („UWG 2004“ wird „uwg_2004“). Führt sie ins Leere, so wird im
+    Verzeichnis des Portals (gii-toc.xml) nachgeschlagen; eine Kennung, die mit der
+    angegebenen beginnt, ist gemeint, sofern es genau eine gibt. Sonst wird nicht
+    geraten, sondern aufgezählt.
+
+(3) Geladenes wird unter „~/.cache/aendggner“ (oder „$XDG_CACHE_HOME“) abgelegt und
+    beim nächsten Lauf von dort genommen. Der Zwischenspeicher ist eine
+    Bequemlichkeit und keine Bedingung: Ist das Verzeichnis nicht beschreibbar, so
+    wird jedes Mal geladen.
+
+(4) Ein Vermittler wird aus „HTTPS_PROXY“ und Verwandten übernommen, samt Kennung
+    und Kennwort. Die Laufzeit liest diese Angaben nicht von sich aus, obgleich sie
+    auf der Kommandozeile die gewohnten sind; in einer Behörde führt jeder Weg über
+    einen solchen Vermittler.
+
+(5) Der Bezug gehört allein der Befehlszeile. Die Kernpipeline kennt weiterhin nur
+    die Quelle (Name und Bytes), und die Browserfassung rührt ihn nicht an: Dort
+    gibt es kein Dateisystem, und der Wagen des Benutzers verweigert einer fremden
+    Anschrift ohnehin die Auskunft.
+
+
+Artikel 2
+Änderung der Befehlszeile
+
+Die Befehlszeile (AendGgner) nimmt das Stammgesetz, die Änderungsdokumente und die
+Nachfassung fortan als Zeichenketten entgegen und beschafft sie über den Bezug. Die
+Meldungen nennen den Namen der Quelle und nicht mehr den Dateinamen; für eine Datei
+ist das dasselbe.
+
+
+Artikel 3
+Änderung des Handbuchs
+
+Das Handbuch (README.md) wird wie folgt geändert:
+
+1.  § 5 wird um den Absatz 4 ergänzt (Anschriften als Eingabe, Kennung des
+    Bundesrechts, Zwischenspeicher, Vermittler, Vorrang der Datei).
+
+2.  In § 6 Absatz 2 wird die Erläuterung zu „--nachfassung“ angepasst.
+
+
+Schlussbestimmung
+
+Die vorstehenden Änderungen sind durch die Prüfung sämtlicher Testfälle
+(vierhundertfünfzehn an der Zahl, zuvor vierhundertsechs) sowie durch die
+vollständige Erstellung (mvnw verify) bestätigt worden. Der Abruf selbst gehört
+nicht in eine Prüfung, die auf jedem Rechner laufen soll; geprüft werden die
+Ableitung der Kennung, die Ableitung des Namens und der Vorrang der vorhandenen
+Datei (BezugTest). Von Hand belegt ist der volle Lauf ohne jede Datei:
+„gii:UWG 2004“ nebst der Drucksache 21/1855 von dserver.bundestag.de ergibt zwanzig
+angewandte Befehle, und die Synopse aus „gii:UWG 2004“ gleicht bis auf den Namen der
+Quelle zeichengenau derjenigen aus der örtlichen XML-Datei. Die Auszeichnung nach
+REUSE ist vollständig (196 von 196 Dateien).
+
+
+════════════════════════════════════════════════════════════════════════════════
  Fassung vom 26. August 2026 (zweite Fassung des Tages),
  zuletzt geändert durch die am 26. August 2026 vorgenommenen Änderungen
 ════════════════════════════════════════════════════════════════════════════════
diff --git a/README.md b/README.md
index 6eaf452..14f9aae 100644
--- a/README.md
+++ b/README.md
@@ -166,7 +166,7 @@
 
 (1) Als Stammgesetz des Bundes wird XML im gii-norm-Format von
 [gesetze-im-internet.de](https://www.gesetze-im-internet.de/) angenommen. Das
-Portal gibt seine Werke nur als Archiv aus (`…/<kurz>/xml.zip`); das Archiv wird
+Portal gibt seine Werke nur als Archiv aus (`…/<kennung>/xml.zip`); das Archiv wird
 unmittelbar angenommen und der enthaltene XML-Eintrag daraus entpackt, ein
 Zwischenschritt von Hand entfällt.
 
@@ -193,6 +193,32 @@
 Text erschlossen, nicht aus dem Dateinamen (§ 10). Änderungsanträge dürfen
 zusammen mit demjenigen Entwurf angegeben werden, den sie ändern (§ 11).
 
+(4) Jede Eingabe — Stammgesetz, Änderungsdokument und Nachfassung — darf statt
+eines Dateipfades auch eine Anschrift sein:
+
+1. `http://…` oder `https://…` lädt das Werk unmittelbar, etwa eine Drucksache
+   von `dserver.bundestag.de`;
+2. `gii:<kennung>` steht für das Bundesrecht und wird zu
+   `https://www.gesetze-im-internet.de/<kennung>/xml.zip` aufgelöst. Die Kennung
+   ist der vorletzte Teil der Portaladresse; sie ergibt sich aus der Abkürzung des
+   Gesetzes, klein geschrieben, wobei jedes andere Zeichen als Buchstabe, Ziffer
+   und Bindestrich zum Unterstrich wird (`gii:"UWG 2004"` → `uwg_2004`). Führt sie
+   ins Leere, so wird im Verzeichnis des Portals nachgeschlagen: `gii:geg` findet
+   sich selbst, `gii:uwg` findet `uwg_2004`. Bleiben mehrere übrig, so wird nicht
+   geraten, sondern aufgezählt.
+
+Ein vorhandener Dateipfad hat stets den Vorrang. Geladenes wird unter
+`~/.cache/aendggner` (oder `$XDG_CACHE_HOME`) abgelegt und beim nächsten Lauf von
+dort genommen; ist das Verzeichnis nicht beschreibbar, wird eben jedes Mal geladen.
+Ein Vermittler wird aus `HTTPS_PROXY`/`HTTP_PROXY` übernommen. Die Browserfassung
+(§ 14) kennt diesen Weg nicht — dort verweigert der Wagen des Benutzers einer
+fremden Anschrift die Auskunft.
+
+```shell
+java -jar target/aendggner-0.1.0-SNAPSHOT.jar "gii:UWG 2004" \
+  https://dserver.bundestag.de/btd/21/018/2101855.pdf -o synopse.html
+```
+
 ## § 6 Betrieb der Befehlszeilenfassung
 
 (1) Nach der Herstellung nach § 4 wird das Erzeugnis wie folgt aufgerufen:
@@ -227,7 +253,8 @@
 
 **`--nachfassung <file>`**
 : Das Ergebnis Norm für Norm gegen die amtliche Nachfassung halten (§ 6b). Es
-  gelten dieselben Eingabeformate wie für das Stammgesetz.
+  gelten dieselben Eingaben wie für das Stammgesetz, auch die Anschriften des § 5
+  Absatz 4.
 
 **`--extract-only`**
 : Nur den bereinigten Lineartext des Änderungsdokuments ausgeben. Dies ist
diff --git a/src/main/java/eu/mulk/aendggner/AendGgner.java b/src/main/java/eu/mulk/aendggner/AendGgner.java
index 195a425..1544951 100644
--- a/src/main/java/eu/mulk/aendggner/AendGgner.java
+++ b/src/main/java/eu/mulk/aendggner/AendGgner.java
@@ -36,16 +36,20 @@
 
   @Parameters(
       index = "0",
+      paramLabel = "<base law>",
       description =
-          "The base law: gii-norm XML from gesetze-im-internet.de, or — for Bavarian state law —"
-              + " the consolidated version from gesetze-bayern.de (PDF or canonical plain text).")
-  private Path baseFile;
+          "The base law: gii-norm XML from gesetze-im-internet.de, or — for state law — the"
+              + " consolidated version from the state portal (PDF or canonical plain text). May be"
+              + " a file, an http(s) URL, or \"gii:<abbr>\" (e.g. gii:uwg) for federal law.")
+  private String baseFile;
 
   @Parameters(
       index = "1..*",
       arity = "*",
-      description = "The amendment act(s) to apply (BGBl PDF or plain text).")
-  private List<Path> patches;
+      paramLabel = "<amendment>",
+      description =
+          "The amendment act(s) to apply (BGBl PDF or plain text). May be files or http(s) URLs.")
+  private List<String> patches;
 
   @Option(
       names = {"-o", "--output"},
@@ -126,9 +130,18 @@
       paramLabel = "<file>",
       description =
           "Compare the result against the official amended version, norm by norm. Accepts the same "
-              + "formats as the base law. The report is added to the synopsis; a mismatch yields "
+              + "inputs as the base law. The report is added to the synopsis; a mismatch yields "
               + "exit code 3.")
-  private Path nachfassung;
+  private String nachfassung;
+
+  /** Die angegebenen Eingaben, jede über {@link Bezug} beschafft. */
+  private static List<Quelle> hole(List<String> angaben) throws IOException, InterruptedException {
+    var quellen = new ArrayList<Quelle>();
+    for (var angabe : angaben) {
+      quellen.add(Bezug.hole(angabe));
+    }
+    return quellen;
+  }
 
   public static void main(String... args) {
     int exitCode = new CommandLine(new AendGgner()).execute(args);
@@ -142,7 +155,7 @@
     log.debugf("Logging configured.");
 
     if (dumpGesetz) {
-      var gesetz = Pipeline.ladeStammgesetz(baseFile);
+      var gesetz = Pipeline.ladeStammgesetz(Bezug.hole(baseFile));
       System.out.printf(
           "%s — %s (%d Normen)%n", gesetz.jurabk(), gesetz.langue(), gesetz.normen().size());
       for (var norm : gesetz.normen()) {
@@ -157,11 +170,7 @@
     }
 
     if (extractOnly) {
-      var quellen = new ArrayList<Quelle>();
-      for (var file : patches) {
-        quellen.add(Quelle.lies(file));
-      }
-      System.out.print(Pipeline.extrahiereText(Quelle.lies(baseFile), quellen, raw));
+      System.out.print(Pipeline.extrahiereText(Bezug.hole(baseFile), hole(patches), raw));
       return 0;
     }
 
@@ -170,11 +179,11 @@
       // Ohne weitere Argumente wird die erste Datei selbst eingeordnet — zum Nachsehen, was
       // ÄndGgner in einem einzelnen Dokument erkennt, braucht es dann kein Stammgesetz.
       var zuPruefen = patches == null || patches.isEmpty() ? List.of(baseFile) : patches;
-      for (var file : zuPruefen) {
-        var kopf = DokumentErkenner.erkenne(extraktor.extrahiere(file));
+      for (var quelle : hole(zuPruefen)) {
+        var kopf = DokumentErkenner.erkenne(extraktor.extrahiere(quelle));
         System.out.printf(
             "%s: %s [eigene Drs. %s, Bezug %s] %s%n",
-            file.getFileName(),
+            quelle.name(),
             kopf.art(),
             kopf.eigeneDrucksache() == null ? "—" : kopf.eigeneDrucksache(),
             kopf.bezugsDrucksachen().isEmpty() ? "—" : String.join(", ", kopf.bezugsDrucksachen()),
@@ -184,15 +193,15 @@
     }
 
     if (dumpBefehle) {
-      var gesetz = Pipeline.ladeStammgesetz(baseFile);
+      var gesetz = Pipeline.ladeStammgesetz(Bezug.hole(baseFile));
       var extraktor = new PatchTextExtraktor(Pipeline.superskriptModus(gesetz));
       var parser = new AenderungsgesetzParser();
-      for (var file : patches) {
-        var text = TextBereiniger.bereinige(extraktor.extrahiere(file));
+      for (var quelle : hole(patches)) {
+        var text = TextBereiniger.bereinige(extraktor.extrahiere(quelle));
         var ergebnis = parser.parse(text, gesetz, artikel);
         System.out.printf(
             "%s: %d Befehle aus Artikel %s%n",
-            file.getFileName(), ergebnis.befehle().size(), ergebnis.artikel());
+            quelle.name(), ergebnis.befehle().size(), ergebnis.artikel());
         for (var warnung : ergebnis.warnungen()) {
           System.err.println("WARNUNG: " + warnung);
         }
@@ -238,11 +247,11 @@
     }
 
     var auftrag =
-        Pipeline.Auftrag.von(baseFile, patches)
+        Pipeline.Auftrag.von(Bezug.hole(baseFile), hole(patches))
             .mitArtikel(artikel)
             .mitVollstaendig(vollstaendig)
             .mitStichtag(tag)
-            .mitNachfassung(nachfassung == null ? null : Quelle.lies(nachfassung));
+            .mitNachfassung(nachfassung == null ? null : Bezug.hole(nachfassung));
 
     var ergebnis = Pipeline.erzeugeSynopse(auftrag);
 
diff --git a/src/main/java/eu/mulk/aendggner/Bezug.java b/src/main/java/eu/mulk/aendggner/Bezug.java
new file mode 100644
index 0000000..cded363
--- /dev/null
+++ b/src/main/java/eu/mulk/aendggner/Bezug.java
@@ -0,0 +1,257 @@
+// SPDX-FileCopyrightText: 2026 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.net.Authenticator;
+import java.net.InetSocketAddress;
+import java.net.PasswordAuthentication;
+import java.net.ProxySelector;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.MessageDigest;
+import java.time.Duration;
+import java.util.HexFormat;
+
+/**
+ * Woher eine Eingabe kommt: aus dem Dateisystem, aus dem Netz oder aus dem Bundesanzeiger-Portal.
+ *
+ * <p>Das Handbuch verlangte bislang, das Norm-XML des Bundes von Hand herunterzuladen, obgleich
+ * seine Anschrift aus der Abkürzung des Gesetzes folgt. Die Handarbeit ist damit keine Leistung des
+ * Benutzers, sondern eine Zumutung; sie entfällt.
+ *
+ * <p>Diese Klasse gehört allein der Befehlszeile. Die Browserfassung darf sie nicht anrühren: Dort
+ * gibt es kein Dateisystem, und der Wagen des Benutzers verweigert einer fremden Anschrift ohnehin
+ * die Auskunft. Die Kernpipeline kennt deshalb nur {@link Quelle} — Name und Bytes.
+ */
+public final class Bezug {
+
+  private static final org.jboss.logging.Logger log =
+      org.jboss.logging.Logger.getLogger(Bezug.class);
+
+  /** gesetze-im-internet.de gibt jedes Werk unter seiner Kennung als Archiv aus. */
+  private static final String GII = "https://www.gesetze-im-internet.de/%s/xml.zip";
+
+  /** Das Verzeichnis sämtlicher Werke des Portals; es nennt zu jedem Titel dessen Anschrift. */
+  private static final String GII_VERZEICHNIS = "https://www.gesetze-im-internet.de/gii-toc.xml";
+
+  private static final java.util.regex.Pattern GII_LINK =
+      java.util.regex.Pattern.compile(
+          "<link>https?://www\\.gesetze-im-internet\\.de/([^/]+)/xml\\.zip</link>");
+
+  private static final Duration FRIST = Duration.ofSeconds(60);
+
+  static {
+    // Ohne dies verweigert die Laufzeit das einfache Kennwort gegenüber einem Vermittler, sobald
+    // der Weg verschlüsselt ist. In einer Behörde führt jeder Weg über einen solchen Vermittler,
+    // und das Kennwort steht ohnehin in der Umgebung; die Sperre schützt hier niemanden.
+    if (System.getProperty("jdk.http.auth.tunneling.disabledSchemes") == null) {
+      System.setProperty("jdk.http.auth.tunneling.disabledSchemes", "");
+    }
+  }
+
+  private Bezug() {}
+
+  /**
+   * Die Eingabe, wie sie angegeben ist: ein Dateipfad, eine Netzanschrift {@code http(s)://…} oder
+   * die Kurzform {@code gii:<abkürzung>} für das Bundesrecht ({@code gii:uwg}).
+   *
+   * <p>Der Dateipfad hat den Vorrang: Wer eine Datei „gii:etwas“ nennt, meint sie. Geladenes wird
+   * unter {@code ~/.cache/aendggner} abgelegt und beim nächsten Lauf von dort genommen — ein
+   * zweiter Lauf mit denselben Eingaben soll das Netz nicht abermals behelligen.
+   */
+  public static Quelle hole(String angabe) throws IOException, InterruptedException {
+    var alsPfad = Path.of(angabe);
+    if (Files.exists(alsPfad)) {
+      return Quelle.lies(alsPfad);
+    }
+    if (angabe.startsWith("gii:")) {
+      var kurz = kennung(angabe.substring("gii:".length()));
+      if (kurz.isEmpty()) {
+        throw new IOException("„" + angabe + "“ nennt keine Kennung (etwa „gii:uwg_2004“).");
+      }
+      try {
+        return lade(GII.formatted(kurz), kurz + "-xml.zip");
+      } catch (NichtGefunden nichtGefunden) {
+        var gefunden = sucheImVerzeichnis(kurz);
+        return lade(GII.formatted(gefunden), gefunden + "-xml.zip");
+      }
+    }
+    if (angabe.startsWith("http://") || angabe.startsWith("https://")) {
+      return lade(angabe, dateiname(angabe));
+    }
+    // Kein Pfad, keine Anschrift: Quelle.lies meldet den fehlenden Pfad mit dem üblichen Wortlaut.
+    return Quelle.lies(alsPfad);
+  }
+
+  private static Quelle lade(String anschrift, String name)
+      throws IOException, InterruptedException {
+    var abgelegt = zwischenspeicher(anschrift, name);
+    if (abgelegt != null && Files.exists(abgelegt)) {
+      return new Quelle(name, Files.readAllBytes(abgelegt));
+    }
+
+    try (var client = baueClient()) {
+      var antwort =
+          client.send(
+              HttpRequest.newBuilder(URI.create(anschrift)).timeout(FRIST).GET().build(),
+              HttpResponse.BodyHandlers.ofByteArray());
+      if (antwort.statusCode() == 404) {
+        throw new NichtGefunden("„" + anschrift + "“ gibt es nicht (Status 404).");
+      }
+      if (antwort.statusCode() != 200) {
+        throw new IOException(
+            "„" + anschrift + "“ antwortet mit dem Status " + antwort.statusCode() + ".");
+      }
+      if (abgelegt != null) {
+        try {
+          Files.createDirectories(abgelegt.getParent());
+          Files.write(abgelegt, antwort.body());
+        } catch (IOException nichtSchreibbar) {
+          // Der Zwischenspeicher ist eine Bequemlichkeit, keine Bedingung: Ist das
+          // Heimatverzeichnis
+          // schreibgeschützt, so wird eben jedes Mal geladen.
+          log.debugf("Zwischenspeicher %s nicht beschreibbar: %s", abgelegt, nichtSchreibbar);
+        }
+      }
+      return new Quelle(name, antwort.body());
+    }
+  }
+
+  /** Die Anschrift gibt es nicht — für die Kennung des Bundesrechts ein Anlass nachzuschlagen. */
+  private static final class NichtGefunden extends IOException {
+    NichtGefunden(String meldung) {
+      super(meldung);
+    }
+  }
+
+  /**
+   * Die Kennung, wie das Portal sie führt: klein geschrieben, und was kein Buchstabe, keine Ziffer
+   * und kein Bindestrich ist, wird zum Unterstrich. So wird aus der Abkürzung „UWG 2004“ die
+   * Kennung „uwg_2004“ und aus „1-DM-GoldmünzG“ die Kennung „1-dm-goldm_nzg“.
+   */
+  static String kennung(String angabe) {
+    return angabe.strip().toLowerCase(java.util.Locale.ROOT).replaceAll("[^a-z0-9-]", "_");
+  }
+
+  /**
+   * Sucht die Kennung im Verzeichnis des Portals, wenn die angegebene ins Leere führt.
+   *
+   * <p>Das Portal hängt an manche Kennung das Jahr der Fassung („uwg_2004“), und wer „uwg“ eingibt,
+   * kann das nicht wissen. Eine Kennung, die mit der angegebenen beginnt, ist deshalb gemeint —
+   * aber nur, wenn es genau eine gibt. Sonst wird nicht geraten, sondern aufgezählt.
+   */
+  private static String sucheImVerzeichnis(String kurz) throws IOException, InterruptedException {
+    var verzeichnis =
+        new String(
+            lade(GII_VERZEICHNIS, "gii-toc.xml").inhalt(), java.nio.charset.StandardCharsets.UTF_8);
+    var treffer = new java.util.LinkedHashSet<String>();
+    var m = GII_LINK.matcher(verzeichnis);
+    while (m.find()) {
+      if (m.group(1).equals(kurz) || m.group(1).startsWith(kurz + "_")) {
+        treffer.add(m.group(1));
+      }
+    }
+    if (treffer.size() == 1) {
+      return treffer.iterator().next();
+    }
+    if (treffer.isEmpty()) {
+      throw new IOException(
+          "Das Portal führt kein Werk unter der Kennung „"
+              + kurz
+              + "“. Die Kennung ist der letzte Teil seiner Anschrift auf gesetze-im-internet.de.");
+    }
+    throw new IOException(
+        "Die Kennung „"
+            + kurz
+            + "“ ist mehrdeutig; das Portal führt "
+            + String.join(", ", treffer)
+            + ". Gemeint ist eine von ihnen.");
+  }
+
+  /**
+   * Der Wagen samt Vermittler, wie ihn die Umgebung vorschreibt.
+   *
+   * <p>Die Laufzeit liest {@code HTTPS_PROXY} und Verwandte nicht von sich aus; auf allen übrigen
+   * Werkzeugen der Kommandozeile ist das aber die gewohnte Angabe. Wer hinter einem Vermittler
+   * sitzt — in der Verwaltung ist das die Regel —, soll nichts eigens einstellen müssen.
+   */
+  private static HttpClient baueClient() {
+    var bauer = HttpClient.newBuilder().connectTimeout(FRIST);
+    var vermittler = umgebung("HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy");
+    if (vermittler != null) {
+      try {
+        var anschrift = URI.create(vermittler);
+        if (anschrift.getHost() != null) {
+          int hafen = anschrift.getPort() > 0 ? anschrift.getPort() : 8080;
+          bauer.proxy(ProxySelector.of(new InetSocketAddress(anschrift.getHost(), hafen)));
+          var kennung = anschrift.getUserInfo();
+          if (kennung != null && kennung.contains(":")) {
+            var teile = kennung.split(":", 2);
+            bauer.authenticator(
+                new Authenticator() {
+                  @Override
+                  protected PasswordAuthentication getPasswordAuthentication() {
+                    return new PasswordAuthentication(teile[0], teile[1].toCharArray());
+                  }
+                });
+          }
+        }
+      } catch (IllegalArgumentException unbrauchbar) {
+        // Eine unlesbare Angabe bleibt außer Betracht; der unmittelbare Weg wird versucht.
+      }
+    }
+    return bauer.build();
+  }
+
+  private static @org.jspecify.annotations.Nullable String umgebung(String... namen) {
+    for (var name : namen) {
+      var wert = System.getenv(name);
+      if (wert != null && !wert.isBlank()) {
+        return wert;
+      }
+    }
+    return null;
+  }
+
+  /**
+   * Der Ort im Zwischenspeicher; {@code null}, wenn keiner zu bestimmen ist. Der Name trägt einen
+   * Abdruck der Anschrift, damit zwei gleichnamige Werke verschiedener Herkunft sich nicht
+   * verdrängen.
+   */
+  private static Path zwischenspeicher(String anschrift, String name) {
+    var verzeichnis = System.getenv("XDG_CACHE_HOME");
+    var heim = System.getProperty("user.home");
+    if ((verzeichnis == null || verzeichnis.isBlank()) && (heim == null || heim.isBlank())) {
+      return null;
+    }
+    var wurzel =
+        verzeichnis != null && !verzeichnis.isBlank()
+            ? Path.of(verzeichnis)
+            : Path.of(heim, ".cache");
+    return wurzel.resolve("aendggner").resolve(abdruck(anschrift) + "-" + name);
+  }
+
+  private static String abdruck(String anschrift) {
+    try {
+      var summe =
+          MessageDigest.getInstance("SHA-256")
+              .digest(anschrift.getBytes(java.nio.charset.StandardCharsets.UTF_8));
+      return HexFormat.of().formatHex(summe, 0, 8);
+    } catch (java.security.NoSuchAlgorithmException unmoeglich) {
+      throw new IllegalStateException(unmoeglich);
+    }
+  }
+
+  /** Das letzte Glied der Anschrift, ohne Abfrageteil; sonst die Anschrift selbst. */
+  static String dateiname(String anschrift) {
+    var ohneAbfrage = anschrift.split("[?#]", 2)[0];
+    int schrägstrich = ohneAbfrage.lastIndexOf('/');
+    var letztes = schrägstrich >= 0 ? ohneAbfrage.substring(schrägstrich + 1) : ohneAbfrage;
+    return letztes.isBlank() ? anschrift : letztes;
+  }
+}
diff --git a/src/test/java/eu/mulk/aendggner/BezugTest.java b/src/test/java/eu/mulk/aendggner/BezugTest.java
new file mode 100644
index 0000000..6b54691
--- /dev/null
+++ b/src/test/java/eu/mulk/aendggner/BezugTest.java
@@ -0,0 +1,59 @@
+// SPDX-FileCopyrightText: 2026 Matthias Andreas Benkard <code@mail.matthias.benkard.de>
+// SPDX-License-Identifier: AGPL-3.0-or-later
+package eu.mulk.aendggner;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+
+/**
+ * Geprüft wird, was ohne Netz zu prüfen ist: die Ableitung der Kennung, die Ableitung des Namens
+ * und der Vorrang der vorhandenen Datei. Der Abruf selbst gehört nicht in eine Prüfung, die auf
+ * jedem Rechner laufen soll.
+ */
+class BezugTest {
+
+  /**
+   * Die Kennung des Portals: klein geschrieben, und was kein Buchstabe, keine Ziffer und kein
+   * Bindestrich ist, wird zum Unterstrich. So schreibt gesetze-im-internet.de seine Anschriften.
+   */
+  @ParameterizedTest
+  @CsvSource({
+    "UWG 2004,        uwg_2004",
+    "uwg_2004,        uwg_2004",
+    "GEG,             geg",
+    "1-DM-GoldmünzG,  1-dm-goldm_nzg",
+    "'  IfSG  ',      ifsg",
+  })
+  void leitetDieKennungAb(String angabe, String erwartet) {
+    assertThat(Bezug.kennung(angabe)).isEqualTo(erwartet);
+  }
+
+  @ParameterizedTest
+  @CsvSource({
+    "https://dserver.bundestag.de/btd/21/018/2101855.pdf, 2101855.pdf",
+    "https://www.gesetze-im-internet.de/uwg_2004/xml.zip, xml.zip",
+    "https://example.org/heft.pdf?fassung=2#seite3,      heft.pdf",
+  })
+  void leitetDenNamenAusDerAnschriftAb(String anschrift, String erwartet) {
+    assertThat(Bezug.dateiname(anschrift)).isEqualTo(erwartet);
+  }
+
+  /** Die vorhandene Datei hat den Vorrang: Wer eine Datei „gii:etwas“ nennt, meint sie. */
+  @Test
+  void nimmtDieVorhandeneDateiVorJederDeutung() throws IOException, InterruptedException {
+    var datei = Path.of("src/test/resources/sampledata/Brandenburg/FraktG-alt.txt");
+    assumeTrue(Files.exists(datei), "Beispieldaten fehlen");
+
+    var quelle = Bezug.hole(datei.toString());
+
+    assertThat(quelle.name()).isEqualTo("FraktG-alt.txt");
+    assertThat(quelle.inhalt()).isEqualTo(Files.readAllBytes(datei));
+  }
+}