feat(platzhalter): Aufloesung, Namensregeln und Shell-Maskierung (#26)
This commit was merged in pull request #26.
This commit is contained in:
@@ -0,0 +1,341 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user