Files
Laufzettel/src/lib/shell.ts
T
florian.wilde eec5de404a
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
feat(platzhalter): Aufloesung, Namensregeln und Shell-Maskierung (#26)
2026-09-25 13:30:31 +00:00

342 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { SHELLS } from "@/lib/vorlagen-format";
/**
* Maskieren von Werten fuer eine Shell und die Skript-Kopie (#012).
*
* Hier endet der Weg eines Werts aus dem Verzeichnis oder aus einem
* Eingabefeld: als Teil eines Befehls, den ein Techniker kopiert und auf einem
* Domaenencontroller ausfuehrt. Ein Wert, der die Anfuehrungszeichen des Befehls
* sprengt, fuehrt dort fremden Code aus — mit den Rechten, die der Techniker
* gerade hat, und das sind die hoechsten im Haus.
*
* **Die tragende Annahme, ausgesprochen:** `wertMaskieren` maskiert fuer die
* Einbettung **innerhalb** der Anfuehrungszeichen, die schon in der Vorlage
* stehen — bei PowerShell und Cmd doppelte, bei Bash einfache:
*
* ```
* New-ADUser -Name "{{vorname}} {{nachname}}" → ... -Name "Jörg Müller"
* useradd -c '{{mitarbeiter}}' → ... -c 'O'\''Brien'
* ```
*
* Diese Funktion setzt **keine** Anfuehrungszeichen dazu, weil sie sonst die
* schon vorhandenen verdoppeln wuerde. Steht der Platzhalter in der Vorlage
* nackt da (`-Name {{vorname}}`), kann keine Maskierung der Welt daraus ein
* einzelnes Argument machen; dann schuetzt nur, wer die Vorlage schreibt. Das ist
* eine Annahme und keine Zusage, und sie steht hier, damit niemand sie fuer eine
* Zusage haelt.
*/
export type Shell = (typeof SHELLS)[number];
export class ShellFehler extends Error {
constructor(nachricht: string) {
super(nachricht);
this.name = "ShellFehler";
}
}
/**
* Zeilenumbruch je Shell.
*
* PowerShell und Cmd leben in der CRLF-Welt: eine `.cmd`-Datei mit reinen
* Zeilenvorschueben bringt `cmd.exe` bei Sprungmarken zum Stolpern. Bash lebt in
* der LF-Welt, und zwar unversoehnlich: ein Wagenruecklauf am Zeilenende landet
* als Zeichen im letzten Argument und macht aus `fi` ein `fi\r`.
*/
export const UMBRUCH: Record<Shell, string> = {
powershell: "\r\n",
bash: "\n",
cmd: "\r\n",
};
/** Wie eine Kommentarzeile beginnt. Cmd kennt kein `#`. */
export const KOMMENTAR_ZEICHEN: Record<Shell, string> = {
powershell: "#",
bash: "#",
cmd: "REM",
};
/**
* Zeichen, die Cmd innerhalb doppelter Anfuehrungszeichen nicht tragen kann.
*
* Siehe `cmdMaskieren` — diese vier werden abgelehnt, statt sie unsicher
* durchzulassen.
*/
const CMD_UNMOEGLICH: readonly (readonly [RegExp, string])[] = [
[/"/, "ein doppeltes Anführungszeichen"],
[/\r/, "einen Wagenrücklauf"],
[/\n/, "einen Zeilenumbruch"],
[/\u0000/, "ein Null-Byte"],
];
/**
* Zeichen, die PowerShell als doppeltes Anfuehrungszeichen liest.
*
* Nicht nur `U+0022`: `about_Quoting_Rules` nennt ausdruecklich auch die
* typografischen Varianten. Gegen PowerShell 7 nachgemessen — `“`, `”` und `„`
* **beenden eine `"…"`-Zeichenkette**, alles danach ist Code.
*
* Das ist kein erfundener Angriff: `„` ist das deutsche Anfuehrungszeichen, und
* Word wie Outlook wandeln ein getipptes `"` von selbst in `“`/`”`. Ein
* Abteilungsname, den jemand aus Word in ein Formular kopiert, reicht.
*/
const PS_DOPPELTE_ANFUEHRUNG = /[\u201C\u201D\u201E]/gu;
/**
* Dieselbe Familie fuer das einfache Anfuehrungszeichen.
*
* Innerhalb von `"…"` sind sie harmlos und werden **nicht** verdoppelt — das
* verfaelschte jeden Namen wie „O’Neill“. Angeglichen werden sie trotzdem:
* benutzt eine Vorlage die andere Form (`'{{vorname}}'`), beenden sie dort die
* Zeichenkette (gemessen gegen PowerShell 7). Dass ein gewoehnliches `'` dieselbe
* Vorlage genauso bricht, faengt `platzhalterAusserhalbAnfuehrungszeichen` ab.
*/
const PS_EINFACHE_ANFUEHRUNG = /[\u2018\u2019\u201A\u201B]/gu;
/**
* Maskiert fuer eine PowerShell-Zeichenkette in doppelten Anfuehrungszeichen.
*
* Innerhalb von `"…"` sind genau drei Zeichen besonders, und alle drei werden
* hier entschaerft:
*
* - `` ` `` ist das Fluchtzeichen und wird verdoppelt. Zuerst, sonst wuerden die
* Fluchtzeichen der naechsten Schritte gleich wieder mitverdoppelt.
* - `"` beendet die Zeichenkette und wird verdoppelt (`""` ist innerhalb von
* `"…"` das literale Anfuehrungszeichen).
* - `$` fuehrt die Ersetzung ein. Das schliesst `$(…)` ein, also die Ausfuehrung:
* aus `$(rm -rf /)` wird `` `$(rm -rf /) `` und damit sichtbarer Text statt
* eines Unterausdrucks.
*
* **Typografische Anfuehrungszeichen werden vorher auf ihre ASCII-Form
* gebracht** und danach wie diese behandelt. Das aendert das Zeichen — aus `”`
* wird `"` —, und das ist Absicht: PowerShell liest es ohnehin als Begrenzer,
* es laesst sich also gar nicht als Text durchreichen. Lieber ein Zeichen, das
* anders aussieht als gedacht, als ein Befehl, der etwas anderes tut als er
* liest.
*
* Zeilenumbrueche werden zu `` `r `` und `` `n ``. Innerhalb von `"…"` waeren sie
* auch unmaskiert unschaedlich — die Zeichenkette laeuft ueber Zeilen weiter —,
* aber der Wert bleibt so in **einer** Zeile, und was in einer Zeile steht,
* ueberlebt jedes Kopieren, jedes Einfuegen in eine Konsole und jede
* Skript-Kopie, die Zeilen einzeln behandelt.
*/
function powershellMaskieren(wert: string): string {
return wert
.replace(PS_DOPPELTE_ANFUEHRUNG, '"')
.replace(PS_EINFACHE_ANFUEHRUNG, "'")
.replaceAll("`", "``")
.replaceAll('"', '""')
.replaceAll("$", "`$")
.replaceAll("\r", "`r")
.replaceAll("\n", "`n");
}
/**
* Maskiert fuer eine Bash-Zeichenkette in einfachen Anfuehrungszeichen.
*
* In `'…'` ist **kein** Zeichen besonders ausser dem einfachen
* Anfuehrungszeichen selbst — kein `$`, kein Backtick, kein `\`, kein
* Zeilenumbruch. Also genuegt der bekannte Kunstgriff: das Anfuehrungszeichen
* schliesst die Zeichenkette, setzt ein maskiertes `\'` daneben und oeffnet
* wieder. Fuer die Shell bleibt das ein zusammenhaengendes Wort.
*/
function bashMaskieren(wert: string): string {
/*
* Zeilenumbrueche bekommen eine eigene Behandlung: `'…'` traegt sie zwar
* woertlich, aber `skriptBauen` bringt danach alle Umbrueche des fertigen
* Befehls auf die Form der Shell — und kann dabei nicht wissen, welcher zum
* Befehl gehoert und welcher aus einem Wert stammt. Aus einem `\r` im Wert
* wurde so ein `\n`; bei CRLF-Werten aus CSV oder LDAP der Normalfall.
*
* `'…'$'\r''…'` ist Bashs eigene Schreibweise dafuer: die Zeichenkette wird
* geschlossen, das Zeichen als Fluchtfolge angehaengt und wieder geoeffnet.
* Fuer die Shell bleibt es ein Wort, und im fertigen Befehl steht kein
* wirklicher Umbruch mehr.
*/
return (
wert
.replaceAll("'", `'\\''`)
/*
* Ersatz**funktionen**, keine Zeichenketten: `$'` ist in `String.replace`
* ein Rueckverweis auf alles hinter dem Treffer. Aus `erste\nzweite` wurde
* damit `erste'zweite\n''zweite` — ein Befehl, der nicht mehr zugeht.
* Derselbe Fehler steckte in `mitBeispielwerten`, gefunden im Review zu #010.
*/
.replaceAll("\r", () => `'$'\\r''`)
.replaceAll("\n", () => `'$'\\n''`)
);
}
/**
* Maskiert fuer eine Cmd-Zeichenkette in doppelten Anfuehrungszeichen — soweit das geht.
*
* **Was hier nicht geht, und zwar grundsaetzlich:**
*
* - Ein `"` im Wert laesst sich nicht maskieren. `\"` ist keine Regel von
* `cmd.exe`, sondern eine Verabredung der C-Laufzeit des aufgerufenen
* Programms; `""` deuten verschiedene Programme verschieden, und `^"` wirkt
* innerhalb von Anfuehrungszeichen nicht. Ein `"` beendet das Argument, und
* alles danach liest `cmd.exe` als Befehl — inklusive `&`. Deshalb wird ein
* solcher Wert **abgelehnt**, mit CR, LF und dem Null-Byte zusammen, die eine
* Cmd-Befehlszeile ebenfalls nicht tragen kann.
* - Ein `%` bleibt stehen und wird von `cmd.exe` **ersetzt**, wenn er einen
* Variablennamen einschliesst: aus `%PATH%` wird der Suchpfad. Innerhalb von
* Anfuehrungszeichen aendert das nichts daran. Verdoppeln waere nur in einer
* Batch-Datei richtig und in einer interaktiven Eingabe falsch, und dieselbe
* Zeichenkette dient hier beidem — es gibt also keine Maskierung, die in
* beiden Faellen stimmt.
*
* **Das ist nicht harmlos**, auch wenn Cmd keine Befehlsersetzung kennt: in
* einer Batch-Datei wird `%VAR%` **vor** der Zerlegung in Befehl und
* Trennzeichen ersetzt. Enthaelt die Variable ein `&` oder ein `"`, wird das
* danach als Trennzeichen bzw. Anfuehrungszeichen gelesen — genau deshalb gibt
* es die verzoegerte Ersetzung `!VAR!` ueberhaupt. Wer einen Wert mit `%`
* erwartet, stellt den Schritt besser auf PowerShell um.
* - Ein `!` bleibt stehen. Er wird nur ersetzt, wenn die verzoegerte Ersetzung
* eingeschaltet ist (`setlocal enabledelayedexpansion`). Die Skript-Kopie
* dieses Moduls schaltet sie nicht ein; eine fremde Umgebung kann es.
*
* Harmlos sind dagegen `&`, `|`, `<`, `>` und `^`: `cmd.exe` behandelt
* Anfuehrungszeichen vor den Trennzeichen, innerhalb von `"…"` sind sie
* gewoehnliche Zeichen. Genau deshalb ist das `"` das einzige, was zaehlt — und
* genau deshalb wird es abgelehnt.
*/
function cmdMaskieren(wert: string): string {
for (const [muster, benennung] of CMD_UNMOEGLICH) {
if (muster.test(wert)) {
throw new ShellFehler(
`Der Wert enthält ${benennung}. In einem Cmd-Befehl lässt sich das nicht sicher maskieren; ` +
"bitte den Schritt auf PowerShell umstellen oder den Wert bereinigen.",
);
}
}
return wert;
}
/**
* Maskiert einen Wert fuer die angegebene Shell.
*
* Erwartet, dass der Platzhalter in der Vorlage in Anfuehrungszeichen steht —
* doppelte bei `powershell` und `cmd`, einfache bei `bash`. Siehe die Anmerkung
* am Kopf dieser Datei; sie ist die einzige Annahme, die dieses Modul nicht
* selbst pruefen kann.
*
* Wirft `ShellFehler`, wenn ein Wert in einem Cmd-Befehl nicht sicher
* darstellbar ist. Ein Fehler an der Stelle ist gewollt: der Techniker sieht
* dann, dass hier etwas nicht geht, statt einen Befehl zu kopieren, der etwas
* anderes tut als er liest.
*/
export function wertMaskieren(wert: string, shell: Shell): string {
switch (shell) {
case "powershell":
return powershellMaskieren(wert);
case "bash":
return bashMaskieren(wert);
case "cmd":
return cmdMaskieren(wert);
}
}
/**
* Platzhalter, die **nicht** zwischen den erwarteten Anfuehrungszeichen stehen.
*
* Die ganze Maskierung ruht auf einer Annahme: `{{name}}` steht in der Vorlage
* in Anfuehrungszeichen — doppelten bei `powershell` und `cmd`, einfachen bei
* `bash`. Steht er nackt da (`Write-Host {{vorname}}`), kann keine Maskierung der
* Welt daraus noch **ein** Argument machen: ein Leerzeichen im Wert zerlegt ihn,
* ein `;` beendet den Befehl. Bis zum Review von #012 hat das niemand geprueft —
* die Annahme stand als Kommentar da und wurde nirgends eingeloest.
*
* Geprueft wird durch schlichtes Durchzaehlen: ein Anfuehrungszeichen oeffnet,
* das naechste gleicher Art schliesst. Das ist grob — Fluchtfolgen innerhalb
* einer Zeichenkette kann es nicht auseinanderhalten — aber es faellt in die
* **sichere** Richtung: im Zweifel meldet es einen Platzhalter, der in Ordnung
* ist, statt einen durchzulassen, der es nicht ist.
*/
export function platzhalterAusserhalbAnfuehrungszeichen(befehl: string, shell: Shell): readonly string[] {
const zeichen = shell === "bash" ? "'" : '"';
const gefunden: string[] = [];
let offen = false;
for (let pos = 0; pos < befehl.length; pos += 1) {
if (befehl.charAt(pos) === zeichen) {
offen = !offen;
continue;
}
if (befehl.startsWith("{{", pos)) {
const ende = befehl.indexOf("}}", pos);
if (ende < 0) break;
const name = befehl.slice(pos + 2, ende).trim();
if (!offen && /^[a-z][a-z0-9_]*$/u.test(name)) gefunden.push(name);
pos = ende + 1;
}
}
return gefunden;
}
export type SkriptSchritt = {
readonly titel: string;
readonly shell: Shell;
readonly befehl: string;
};
/**
* Macht aus einem Titel eine Kommentarzeile, die nicht aufbrechen kann.
*
* Ein Titel mit Zeilenumbruch schiebt seine zweite Haelfte aus dem Kommentar
* heraus und laesst sie als Befehl stehen — aus einem Titel „Konto anlegen“ mit
* angehaengtem Umbruch und `format c:` wuerde eine ausfuehrbare Zeile. Alle
* Steuerzeichen werden deshalb zu Leerzeichen und mehrere Leerzeichen zu einem.
*/
function kommentarTitel(titel: string): string {
const eineZeile = titel
.replace(/[\u0000-\u001F\u007F]+/g, " ")
.replace(/\s{2,}/g, " ")
.trim();
return eineZeile || "(ohne Titel)";
}
/**
* Der Umbruch fuer ein ganzes Skript.
*
* Bei einheitlicher Shell der ihre. Eine gemischte Liste laesst sich ohnehin von
* keinem Interpreter als Ganzes ausfuehren — dort gibt es keine richtige Wahl,
* nur eine weniger schaedliche: LF, weil ein Wagenruecklauf jede Bash-Zeile
* sicher beschaedigt, waehrend PowerShell LF ohne weiteres liest. Fuer Cmd bleibt
* es ein Zugestaendnis.
*/
function umbruchFuer(schritte: readonly SkriptSchritt[]): string {
const ersteShell = schritte[0]?.shell;
if (ersteShell === undefined) return UMBRUCH.bash;
return schritte.every((schritt) => schritt.shell === ersteShell) ? UMBRUCH[ersteShell] : "\n";
}
/**
* Baut aus den Befehlsschritten eines Abschnitts ein Skript zum Kopieren.
*
* Alle Befehle in der uebergebenen Reihenfolge, je mit `# Schritt: <Titel>`
* davor (`REM Schritt: <Titel>` bei Cmd), getrennt durch eine Leerzeile. Die
* Umbrueche **innerhalb** eines mehrzeiligen Befehls werden auf denselben Umbruch
* gebracht: eine CRLF-Datei mit einzelnen Zeilenvorschuechen darin ist keine
* CRLF-Datei.
*
* Schritte ohne Befehl fallen heraus: eine Kommentarzeile mit einer Leerzeile
* darunter und nichts dazwischen hilft niemandem.
*
* Das Skript endet mit einem Umbruch, damit die letzte Zeile auch dann
* ausgefuehrt wird, wenn sie als Datei gespeichert wird.
*/
export function skriptBauen(schritte: readonly SkriptSchritt[]): string {
const mitBefehl = schritte.filter((schritt) => schritt.befehl.trim() !== "");
if (mitBefehl.length === 0) return "";
const umbruch = umbruchFuer(mitBefehl);
const bloecke = mitBefehl.map((schritt) => {
const kopf = `${KOMMENTAR_ZEICHEN[schritt.shell]} Schritt: ${kommentarTitel(schritt.titel)}`;
const befehl = schritt.befehl.trim().replace(/\r\n|\r|\n/g, umbruch);
return kopf + umbruch + befehl;
});
return bloecke.join(umbruch + umbruch) + umbruch;
}