342 lines
14 KiB
TypeScript
342 lines
14 KiB
TypeScript
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;
|
||
}
|