feat(platzhalter): Aufloesung, Namensregeln und Shell-Maskierung (#26)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

This commit was merged in pull request #26.
This commit is contained in:
2026-09-25 13:30:31 +00:00
parent f85bc87324
commit eec5de404a
21 changed files with 4069 additions and 166 deletions
+550
View File
@@ -0,0 +1,550 @@
import { z } from "@/lib/validierung";
/**
* Namensregeln: aus einem Personennamen werden upn, sam, mail und hostname (#012).
*
* Aus diesen Werten entstehen die PowerShell-Befehle, die ein Techniker auf einem
* Domaenencontroller ausfuehrt. Ein Name, der hier krumm herauskommt, legt dort
* ein Konto mit krummem Namen an — und zwar unbemerkt, weil niemand mehr
* hinsieht, wenn der Befehl einmal kopiert ist. Deshalb gilt durchgehend:
*
* 1. **Fehlt etwas, kommt nichts heraus.** Keine Regel, ein unbekannter
* Platzhalter darin, ein leerer Vorname — jedes Mal `undefined`. Ein
* erfundener oder halber Anmeldename ist schlimmer als gar keiner, weil er
* ausgefuehrt wird.
* 2. **Die Grenzen des Zielsystems werden eingehalten, nicht gemeldet.** Das
* Active Directory kuerzt einen zu langen `sAMAccountName` nicht, es lehnt ihn
* ab; der NetBIOS-Name bricht bei 15 Zeichen.
* 3. **Framework- und datenbankfrei.** Ob ein Kandidat schon vergeben ist, weiss
* nur der Aufrufer; er reicht die Pruefung herein.
*/
export class NamensregelFehler extends Error {
constructor(nachricht: string) {
super(nachricht);
this.name = "NamensregelFehler";
}
}
/**
* Vorlagen fuer abgeleitete Werte, wie sie in `Einstellung.namensregeln` und
* `Kunde.namensregeln` liegen.
*
* Steht hier und nicht mehr im Einstellungs-Service: das Aufloesen gehoert in
* `src/lib`, und Schema und Aufloesung durften nicht in verschiedenen Schichten
* liegen — sonst haette der Service das Schema und die lib die Regeln, und
* niemand haette gemerkt, wenn beide unterschiedliche Platzhalter kennen.
*/
export const NamensregelnSchema = z.object({
upn: z.string().trim().max(200).optional(),
sam: z.string().trim().max(200).optional(),
mail: z.string().trim().max(200).optional(),
hostname: z.string().trim().max(200).optional(),
});
export type Namensregeln = z.infer<typeof NamensregelnSchema>;
export const ZIELFORMATE = ["upn", "sam", "mail", "hostname"] as const;
export type Zielformat = (typeof ZIELFORMATE)[number];
/** Grenze des Active Directory fuer `sAMAccountName`. Laenger wird nicht gekuerzt, sondern abgelehnt. */
export const SAM_HOECHSTLAENGE = 20;
/** Grenze des NetBIOS-Namens. Windows zeigt und registriert nur die ersten 15 Zeichen. */
export const HOSTNAME_HOECHSTLAENGE = 15;
/**
* Stellen, die eine Regel **ohne** eigenen Zaehler fuer die Unterscheidung frei haelt.
*
* Drei Stellen erlauben 999 Träger desselben Namensstamms. Der Vorrat wird immer
* zurueckgehalten, auch wenn der Name gerade noch frei ist: sonst hiesse
* derselbe Mensch `jmuellerluedenscheid`, solange er der erste ist, und
* `jmuellerluedensch2`, sobald ein zweiter dazukommt — und dann stimmt kein
* einmal notierter Name mehr mit dem ueberein, was die Regel heute liefert.
* Enthaelt die Regel einen Zaehler `{nnn}`, ist der die Unterscheidung und die
* volle Laenge steht zur Verfuegung.
*/
export const UNTERSCHEIDUNG_STELLEN = 3;
/** Hoechster Wert, der als Unterscheidung angehaengt wird — er passt in `UNTERSCHEIDUNG_STELLEN`. */
export const UNTERSCHEIDUNG_HOECHSTWERT = 10 ** UNTERSCHEIDUNG_STELLEN - 1;
/**
* Zeichen, die zwei Buchstaben ergeben oder nach Unicode keine Zerlegung haben.
*
* Alles andere erledigt die Zerlegung nach NFD: `é`, `ç`, `å` und ihre
* Verwandten verlieren dort einfach ihr Beizeichen. Hier stehen nur die beiden
* Sonderfaelle: die deutschen Umlaute und das scharfe S, die zu **zwei**
* Buchstaben werden statt einen zu verlieren, und die Buchstaben, die Unicode
* gar nicht zerlegt (`ø`, `ł`, `æ`, `þ`) und die deshalb ohne diese Tabelle als
* Nicht-ASCII ganz wegfielen — aus „Kowalski“ mit `ł` wuerde „Kowaski“.
*/
const FALTUNG: readonly (readonly [string, string])[] = [
["ä", "ae"],
["ö", "oe"],
["ü", "ue"],
["Ä", "Ae"],
["Ö", "Oe"],
["Ü", "Ue"],
["ß", "ss"],
["ẞ", "Ss"],
["æ", "ae"],
["Æ", "Ae"],
["œ", "oe"],
["Œ", "Oe"],
["ø", "o"],
["Ø", "O"],
["ł", "l"],
["Ł", "L"],
["đ", "d"],
["Đ", "D"],
["ð", "d"],
["Ð", "D"],
["þ", "th"],
["Þ", "Th"],
["ı", "i"],
];
/**
* Faltet einen Text auf druckbares ASCII.
*
* Drei Stufen, und die Reihenfolge ist der ganze Trick: erst die Zeichen, die
* **zwei** Buchstaben ergeben (`ä` → `ae`), dann die Zerlegung nach NFD samt
* Wegwerfen der Akzentzeichen (`é` → `e` + Akut → `e`), dann alles uebrige
* Nicht-ASCII. Umgekehrt waere `ä` schon in der zweiten Stufe zu `a` geworden,
* und „Jörg“ hiesse „Jorg“.
*
* Die Gross- und Kleinschreibung bleibt, wie sie ist — welche das Zielformat
* braucht, entscheidet das Zielformat. Aus „MÜLLER“ wird deshalb „MUeLLER“;
* sichtbar wird das nie, weil jedes der vier Formate die Schreibweise
* anschliessend vereinheitlicht.
*/
export function transliterieren(text: string): string {
// Erst zusammensetzen: „ü“ kann als ein Zeichen oder als `u` + Trema
// ankommen — aus einer CSV-Datei eines Mac regelmaessig als zweites. Ohne NFC
// griffe die Tabelle unten nicht, und aus „Müller“ wuerde „Muller“ statt
// „Mueller“: ein Anmeldename, der beinahe stimmt und deshalb erst im Betrieb
// auffaellt.
let gefaltet = text.normalize("NFC");
for (const [von, nach] of FALTUNG) {
gefaltet = gefaltet.replaceAll(von, nach);
}
return (
gefaltet
.normalize("NFD")
// Alle kombinierenden Zeichen: Akut, Gravis, Cedille, Ring, Haken.
.replace(/\p{M}+/gu, "")
// Nur druckbares ASCII bleibt — der Bereich vom Leerzeichen bis zur Tilde.
// Steuerzeichen und Zeilenumbrueche fallen weg, statt zu Leerzeichen zu
// werden: in einem Namen haben sie nichts zu suchen, und ein Umbruch
// mitten in einem Befehl waere gefaehrlich.
.replace(/[^ -~]/g, "")
);
}
/** Derselbe Text klein geschrieben — die Form, in der Namensteile in eine Regel gehen. */
export function kleinGefaltet(text: string): string {
return transliterieren(text).toLowerCase();
}
export type Namenswerte = {
readonly vorname?: string | null;
readonly nachname?: string | null;
/** Nur im Modus `DIENSTLEISTUNG` gesetzt; im Modus `INTERN` gibt es kein Kuerzel. */
readonly kundenkuerzel?: string | null;
};
/**
* Ein Namensteil fuer die Regel: gefaltet, klein, ohne Leerzeichen.
*
* „Anna Lena“ wird zu `annalena`. Der Bindestrich bleibt — er gehoert zum Namen
* und ist in Adressen wie in Anmeldenamen erlaubt.
*/
function namensteil(roh: string | null | undefined): string | undefined {
if (!roh) return undefined;
return kleinGefaltet(roh).replace(/\s+/g, "") || undefined;
}
/** Platzhalter einer Regel. `{nnn}` wird getrennt behandelt, weil die Zahl der `n` zaehlt. */
const REGEL_PLATZHALTER = /\{([a-z]+)\}/g;
type Regelteil =
{ readonly art: "text"; readonly text: string } | { readonly art: "zaehler"; readonly stellen: number };
function platzhalterWert(name: string, werte: Namenswerte): string | undefined {
switch (name) {
case "vorname":
return namensteil(werte.vorname);
case "nachname":
return namensteil(werte.nachname);
case "v": {
// Der erste **Buchstabe**, nicht das erste Zeichen: ein Vorname wie
// „'Abd al-Rahman“ faengt nach der Faltung mit einem Apostroph an, und ein
// Anmeldename `'abdalrahman` hilft niemandem.
const vorname = namensteil(werte.vorname);
return vorname?.match(/[a-z0-9]/)?.[0];
}
case "kundenkuerzel":
return namensteil(werte.kundenkuerzel);
default:
return undefined;
}
}
/**
* Zerlegt eine Regel in aufgeloeste Textstuecke und hoechstens einen Zaehler.
*
* `undefined` heisst „diese Regel ergibt hier keinen Namen“: ein Platzhalter, den
* es nicht gibt, eine uebrig gebliebene geschweifte Klammer, ein zweiter Zaehler
* oder ein Wert, der fehlt. Die ersten drei sind Fehler in der Einstellung, der
* letzte einer in den Stammdaten. Keiner davon laesst sich hier beheben, und
* geraten wird nicht.
*/
function regelZerlegen(regel: string, werte: Namenswerte): readonly Regelteil[] | undefined {
const teile: Regelteil[] = [];
let zaehlerGesehen = false;
let gelesenBis = 0;
/**
* Eine geschweifte Klammer im **festen** Teil der Regel ist ein verunglueckter
* Platzhalter: `{Nachname}`, `{NACHNAME}` oder ein fehlendes `}`. Unbemerkt
* wuerde daraus ein Anmeldename `nachname@firma.de` — die Klammern fallen beim
* Normalisieren weg, der Text bleibt. Also lieber kein Name.
*/
const festerText = (text: string): boolean => {
if (/[{}]/.test(text)) return false;
teile.push({ art: "text", text });
return true;
};
for (const treffer of regel.matchAll(REGEL_PLATZHALTER)) {
const name = treffer[1] ?? "";
const beginn = treffer.index;
if (beginn > gelesenBis && !festerText(regel.slice(gelesenBis, beginn))) {
return undefined;
}
gelesenBis = beginn + treffer[0].length;
if (/^n+$/.test(name)) {
if (zaehlerGesehen) return undefined;
zaehlerGesehen = true;
teile.push({ art: "zaehler", stellen: name.length });
continue;
}
const wert = platzhalterWert(name, werte);
if (wert === undefined) return undefined;
teile.push({ art: "text", text: wert });
}
if (gelesenBis < regel.length && !festerText(regel.slice(gelesenBis))) {
return undefined;
}
return teile;
}
function textVon(teil: Regelteil): string {
return teil.art === "text" ? teil.text : "";
}
/**
* Adressform fuer `upn` und `mail`.
*
* Klein geschrieben, genau ein `@`, beide Haelften gefuellt, keine doppelten
* Punkte und kein Punkt unmittelbar vor dem `@` — das lehnen Exchange und das
* Active Directory ab. Passt die Form nicht, kommt `undefined` heraus: eine
* Adresse ohne `@` ist keine, und sie in einen `New-ADUser`-Befehl zu setzen,
* hiesse dem Techniker eine Zusage zu machen, die nicht stimmt.
*/
function adresseNormalisieren(roh: string): string | undefined {
const teile = roh.toLowerCase().split("@");
if (teile.length !== 2) return undefined;
const lokal = (teile[0] ?? "")
.replace(/[^a-z0-9._+-]/g, "")
.replace(/\.{2,}/g, ".")
.replace(/^\.+/, "")
.replace(/\.+$/, "");
const bereich = (teile[1] ?? "")
.replace(/[^a-z0-9.-]/g, "")
.replace(/\.{2,}/g, ".")
.replace(/^[.-]+/, "")
.replace(/[.-]+$/, "");
if (!lokal || !bereich) return undefined;
// Am Rand raeumen die Schritte oben auf: aus `-firma.de-` wird `firma.de`.
// Innen geht das nicht, ohne den Namen zu erfinden — `firma.-de` bleibt also
// falsch und wird abgelehnt.
if (bereich.split(".").some((stufe) => !stufe || stufe.startsWith("-") || stufe.endsWith("-"))) {
return undefined;
}
return `${lokal}@${bereich}`;
}
/**
* Form des `sAMAccountName`.
*
* Erlaubt bleiben Buchstaben, Ziffern, Punkt und Unterstrich. Der Bindestrich
* faellt weg, obwohl das Active Directory ihn zuliesse: der Anmeldename wird
* abgetippt und am Telefon buchstabiert, und `mueller-luedenscheid` kostet dabei
* ein Zeichen von zwanzig fuer eine Information, die im Anzeigenamen ohnehin
* steht. Ein Punkt am Ende ist unzulaessig und fliegt auch nach dem Kuerzen
* wieder heraus, weil gekuerzt wird, bevor diese Form hergestellt ist.
*/
function samNormalisieren(roh: string): string | undefined {
return (
roh
.toLowerCase()
.replace(/[^a-z0-9._]/g, "")
.replace(/\.{2,}/g, ".")
.replace(/^\.+/, "")
.replace(/\.+$/, "") || undefined
);
}
/**
* Form des Hostnamens.
*
* **Grossbuchstaben**, weil Windows den NetBIOS-Namen gross fuehrt und gross
* anzeigt: steht auf dem Aufkleber `knd-pc-007` und meldet das Geraet
* `KND-PC-007`, sieht der naechste Techniker zwei Namen und sucht nach einem
* Fehler, den es nicht gibt. DNS unterscheidet die Schreibweise ohnehin nicht.
*
* Unterstrich und Punkt fallen weg — der Unterstrich ist in Hostnamen nach
* RFC 1123 nicht erlaubt und bringt Windows-DNS zum Stolpern, der Punkt trennt
* den Namen von der Domaene und hat im NetBIOS-Namen nichts zu suchen. Ein
* fuehrender oder abschliessender Bindestrich faellt ebenfalls weg; er entsteht
* beim Kuerzen von selbst.
*/
/**
* Namen, die Windows als Computernamen ablehnt.
*
* Es sind die alten Geraetenamen von MS-DOS. `CON` ist die Konsole, `PRN` der
* Drucker, `NUL` der Papierkorb — ein Rechner darf so nicht heissen, und ein
* Nachname „Con“ oder eine Abteilung „Aux“ reicht, damit die Regel es
* vorschlaegt. Gefunden im Review zu #012.
*/
const HOSTNAME_RESERVIERT: ReadonlySet<string> = new Set([
"CON",
"PRN",
"AUX",
"NUL",
...Array.from({ length: 9 }, (_, i) => `COM${i + 1}`),
...Array.from({ length: 9 }, (_, i) => `LPT${i + 1}`),
]);
function hostnameNormalisieren(roh: string): string | undefined {
const name =
roh
.toUpperCase()
.replace(/[^A-Z0-9-]/g, "")
.replace(/^-+/, "")
.replace(/-+$/, "") || undefined;
if (name === undefined) return undefined;
// Beides lehnt Windows ab: einen reservierten Geraetenamen und einen rein
// numerischen Namen (er waere von einer IP-Adresse nicht zu unterscheiden).
if (HOSTNAME_RESERVIERT.has(name)) return undefined;
if (/^[0-9]+$/.test(name)) return undefined;
return name;
}
function normalisieren(zielformat: Zielformat, roh: string): string | undefined {
switch (zielformat) {
case "upn":
case "mail":
return adresseNormalisieren(roh);
case "sam":
return samNormalisieren(roh);
case "hostname":
return hostnameNormalisieren(roh);
}
}
/**
* Wie lang darf das Ergebnis werden?
*
* `undefined` heisst „keine Grenze“: Adressen kuerzt niemand, eine gekuerzte
* Mail-Adresse waere schlicht falsch. `mitUnterscheidung` sagt, ob die
* Unterscheidung schon im Namen steckt — dann ist die volle Laenge nutzbar,
* sonst bleibt der Vorrat fuer die Unterscheidung frei.
*/
function grenzeFuer(zielformat: Zielformat, mitUnterscheidung: boolean): number | undefined {
const voll =
zielformat === "sam" ? SAM_HOECHSTLAENGE : zielformat === "hostname" ? HOSTNAME_HOECHSTLAENGE : undefined;
if (voll === undefined) return undefined;
return mitUnterscheidung ? voll : voll - UNTERSCHEIDUNG_STELLEN;
}
/**
* Setzt Text, Zahl und Text zusammen und haelt dabei die Grenze ein.
*
* Gekuerzt wird am Ende — aber **nie in der Zahl**. Genau daran waere sonst die
* Eindeutigkeit gestorben: `{nachname}-{nnn}` liefert fuer einen langen Namen
* zweimal denselben Hostnamen, wenn die 15 Zeichen mitten in der Nummer
* abschneiden. Deshalb wird Stueck fuer Stueck erst hinter, dann vor der Zahl
* weggenommen und jedes Mal neu normalisiert: Normalisieren kann die Laenge
* veraendern (ein Bindestrich am neuen Ende faellt weg), Kuerzen und Pruefen
* muessen also in derselben Schleife sitzen.
*/
function zusammensetzen(
zielformat: Zielformat,
vorText: string,
zahlText: string,
nachText: string,
grenze: number | undefined,
): string | undefined {
for (let weg = 0; weg <= vorText.length + nachText.length; weg += 1) {
const wegNach = Math.min(weg, nachText.length);
const wegVor = weg - wegNach;
const roh =
vorText.slice(0, vorText.length - wegVor) + zahlText + nachText.slice(0, nachText.length - wegNach);
const kandidat = normalisieren(zielformat, roh);
// Wird das Ergebnis beim Kuerzen unbrauchbar (leer, oder eine Adresse ohne
// `@`), hilft weiteres Kuerzen nicht mehr.
if (kandidat === undefined) return undefined;
if (grenze === undefined || kandidat.length <= grenze) return kandidat;
}
return undefined;
}
function zaehlerStellen(teile: readonly Regelteil[]): number | undefined {
for (const teil of teile) {
if (teil.art === "zaehler") return teil.stellen;
}
return undefined;
}
/** Enthaelt die Regel einen Zaehler `{n}`, `{nn}`, `{nnn}`, …? */
export function hatZaehler(regel: string): boolean {
for (const treffer of regel.matchAll(REGEL_PLATZHALTER)) {
if (/^n+$/.test(treffer[1] ?? "")) return true;
}
return false;
}
/**
* Bildet einen Namen aus einer Regel.
*
* `zaehler` fuellt ein `{nnn}` in der Regel; die Zahl der Stellen kommt aus der
* Zahl der `n`, also `{nn}` → `07`. Enthaelt die Regel einen Zaehler, aber es
* wird keiner mitgegeben, kommt `undefined` heraus — nicht `001`. Welche Nummer
* frei ist, weiss diese Schicht nicht, und die erste zu raten hiesse einen
* Hostnamen zu liefern, der schon an einem anderen Geraet haengt. Wer einen
* Zaehler braucht, ruft `namenEindeutigBilden`.
*/
export function namenBilden(
zielformat: Zielformat,
regel: string | undefined | null,
werte: Namenswerte,
zaehler?: number,
): string | undefined {
const bereinigt = regel?.trim();
if (!bereinigt) return undefined;
const teile = regelZerlegen(bereinigt, werte);
if (!teile) return undefined;
const stellen = zaehlerStellen(teile);
const index = teile.findIndex((teil) => teil.art === "zaehler");
const vorText = teile
.slice(0, index < 0 ? teile.length : index)
.map(textVon)
.join("");
const nachText =
index < 0
? ""
: teile
.slice(index + 1)
.map(textVon)
.join("");
if (stellen === undefined) {
return zusammensetzen(zielformat, vorText, "", "", grenzeFuer(zielformat, false));
}
if (zaehler === undefined || !Number.isInteger(zaehler) || zaehler < 0) return undefined;
const zahlText = String(zaehler).padStart(stellen, "0");
// Passt die Nummer nicht in ihre Stellen, waere das Ergebnis ein Zeichen zu
// lang und die Regel damit ueberdehnt — `{nn}` kann keine 100 tragen.
if (zahlText.length > stellen) return undefined;
return zusammensetzen(zielformat, vorText, zahlText, nachText, grenzeFuer(zielformat, true));
}
/**
* Haengt eine Unterscheidung an einen fertigen Namen.
*
* Bei Adressen steht sie vor dem `@` — `joerg.mueller2@firma.de`, nicht
* `joerg.mueller@firma.de2`. Bei `sam` und `hostname` steht sie am Ende und
* darf die Grenze ausschoepfen, weil sie selbst die Unterscheidung ist.
*/
function variante(zielformat: Zielformat, basis: string, nummer: number): string | undefined {
const nummerText = String(nummer);
if (zielformat === "upn" || zielformat === "mail") {
const trenner = basis.lastIndexOf("@");
if (trenner < 0) return undefined;
return zusammensetzen(zielformat, basis.slice(0, trenner), nummerText, basis.slice(trenner), undefined);
}
return zusammensetzen(zielformat, basis, nummerText, "", grenzeFuer(zielformat, true));
}
/**
* Bildet einen Namen, der noch frei ist.
*
* Die Vergabepruefung wird **hereingereicht**, nicht als Liste vergebener Werte
* entgegengenommen: schon fuer den Hostnamen hiesse eine Liste, alle Geraete
* eines Kunden zu laden, um eine einzige Nummer zu finden, und fuer `upn` und
* `mail` alle Mitarbeiter. Eine Funktion fragt genau einen Kandidaten und
* uebersetzt sich in ein `findFirst` mit Index; im Test ist sie ein `vi.fn`,
* dessen Aufrufe sich einzeln nachlesen lassen. Denselben Weg geht
* `tagCodeEindeutigErzeugen` in `src/lib/tagcode.ts`.
*
* **Was diese Schleife nicht leistet:** Die Datenbank sichert die Eindeutigkeit
* hier nicht ab — auf `Mitarbeiter.upn`, `.sam`, `.mail` und `Geraet.hostname`
* liegen Suchindizes, keine Unique-Indizes (anders als beim `tagCode`). Zwischen
* der Frage und dem Schreiben kann also ein zweiter Lauf denselben Namen
* belegen. Diese Schleife nimmt dem Techniker den absehbaren Fall ab, nicht das
* Wettrennen; die letzte Instanz ist das Zielsystem, das ein doppeltes
* `sAMAccountName` selbst ablehnt.
*/
export async function namenEindeutigBilden(
zielformat: Zielformat,
regel: string | undefined | null,
werte: Namenswerte,
istVergeben: (kandidat: string) => Promise<boolean>,
): Promise<string | undefined> {
const bereinigt = regel?.trim();
if (!bereinigt) return undefined;
const teile = regelZerlegen(bereinigt, werte);
if (!teile) return undefined;
const stellen = zaehlerStellen(teile);
if (stellen !== undefined) {
const hoechster = 10 ** stellen - 1;
for (let zaehler = 1; zaehler <= hoechster; zaehler += 1) {
const kandidat = namenBilden(zielformat, bereinigt, werte, zaehler);
if (kandidat === undefined) return undefined;
if (!(await istVergeben(kandidat))) return kandidat;
}
throw new NamensregelFehler(
`Die Regel „${bereinigt}“ gibt nur ${hoechster} Nummern her, und alle sind vergeben. ` +
"Die Regel braucht eine Stelle mehr.",
);
}
const basis = namenBilden(zielformat, bereinigt, werte);
if (basis === undefined) return undefined;
if (!(await istVergeben(basis))) return basis;
for (let nummer = 2; nummer <= UNTERSCHEIDUNG_HOECHSTWERT; nummer += 1) {
const kandidat = variante(zielformat, basis, nummer);
if (kandidat === undefined) return undefined;
if (!(await istVergeben(kandidat))) return kandidat;
}
throw new NamensregelFehler(
`„${basis}“ ist vergeben, und auch mit den Zusätzen 2 bis ${UNTERSCHEIDUNG_HOECHSTWERT} ` +
"war kein freier Name zu finden. Das deutet auf eine defekte Vergabeprüfung hin.",
);
}
+117
View File
@@ -0,0 +1,117 @@
import { randomInt } from "node:crypto";
/**
* Das generierte Passwort (#012).
*
* `{{generiertes_passwort}}` wird **bei jeder Anzeige neu erzeugt und nie
* gespeichert** — nicht im `Schrittergebnis`, nicht im `Kommentar`, nicht im
* `Protokoll` und nicht in der Druckansicht. Das ist keine Bequemlichkeit,
* sondern die Zusage: ein archivierter Lauf ist ein Dokument, das Jahre liegt
* und von vielen gelesen wird. Ein Anfangspasswort darin waere ein Fundstueck.
*
* Die Folge muss man aushalten: wer den Befehl ausfuehrt und das Passwort nicht
* sofort weiterreicht, hat es verloren. Genau deshalb steht der Hinweis
* „Passwort sofort in den Passwortmanager übernehmen“ daneben, und genau deshalb
* ist ein zweiter Aufruf ein **anderes** Passwort — nicht dasselbe noch einmal.
*/
/**
* Zeichen ohne Verwechslungsgefahr.
*
* Weggelassen sind `I l 1 |`, `O 0` und `B 8`: das Passwort wird von einem
* Bildschirm abgelesen und in ein anderes Fenster getippt, oft von der Person,
* der das Konto gehoert. Ein Zeichen, das man falsch liest, kostet einen
* Anruf — und der Sicherheitsgewinn dieser paar Zeichen ist dagegen nichts.
*/
export const PASSWORT_GROSS = "ACDEFGHJKLMNPQRSTUVWXYZ";
export const PASSWORT_KLEIN = "abcdefghijkmnopqrstuvwxyz";
export const PASSWORT_ZIFFERN = "23456789";
/**
* Sonderzeichen, die in keiner der drei Shells etwas anrichten.
*
* Bewusst ohne `" ' $ \` \\ ; & | < > %`: das Passwort landet in einem Befehl,
* den jemand kopiert. Die Maskierung in `src/lib/shell.ts` faengt das zwar ab —
* aber ein Passwort, das nur wegen korrekter Maskierung funktioniert, ist eine
* Wette auf jeden kuenftigen Aufrufer. Was gar nicht erst drin ist, kann auch
* nichts zerbrechen.
*
* Auch **ohne `!`**: bei eingeschalteter verzoegerter Ersetzung
* (`setlocal enabledelayedexpansion`) frisst `cmd.exe` es still, und das Konto
* bekaeme ein anderes Passwort als angezeigt. Gefunden im Review zu #012 — der
* Kommentar behauptete vorher Sicherheit in einer CSV-Zelle, und `= + - @`
* stehen dort am Zellenanfang fuer eine Formel. Deshalb faengt ein Passwort
* auch nie mit einem Sonderzeichen an (siehe `passwortErzeugen`).
*/
export const PASSWORT_SONDER = "#*+-=?@_~";
const ALPHABET = PASSWORT_GROSS + PASSWORT_KLEIN + PASSWORT_ZIFFERN + PASSWORT_SONDER;
export const PASSWORT_LAENGE = 20;
/** Steht in der Druckansicht statt des Passworts. */
export const PASSWORT_IM_DRUCK = "[Passwort – nicht gespeichert]";
/** Der Satz, der neben jedem angezeigten Passwort steht. */
export const PASSWORT_HINWEIS = "Passwort sofort in den Passwortmanager übernehmen";
function zeichenAus(vorrat: string): string {
// `randomInt` aus node:crypto statt Math.random: gleichverteilt und aus einer
// Quelle, die fuer Geheimnisse gedacht ist.
return vorrat.charAt(randomInt(vorrat.length));
}
/**
* Erzeugt ein Passwort mit 20 Zeichen.
*
* Aus jeder der vier Gruppen kommt mindestens ein Zeichen — nicht aus
* Aberglauben, sondern weil die Passwortrichtlinie eines Active Directory
* ueblicherweise drei von vier verlangt und ein abgewiesenes `New-ADUser` den
* Techniker vor dem Kunden stehen laesst. Danach wird gemischt, damit die
* Gruppen nicht an festen Stellen stehen.
*/
export function passwortErzeugen(laenge: number = PASSWORT_LAENGE): string {
if (laenge < 4) {
throw new Error("Ein Passwort unter vier Zeichen kann nicht aus vier Gruppen bestehen.");
}
const zeichen = [
zeichenAus(PASSWORT_GROSS),
zeichenAus(PASSWORT_KLEIN),
zeichenAus(PASSWORT_ZIFFERN),
zeichenAus(PASSWORT_SONDER),
];
while (zeichen.length < laenge) {
zeichen.push(zeichenAus(ALPHABET));
}
// Fisher-Yates mit `randomInt`: ein `sort(() => Math.random() - 0.5)` mischt
// nachweislich ungleich und wuerde die vier Pflichtzeichen bevorzugt vorn
// lassen.
for (let i = zeichen.length - 1; i > 0; i -= 1) {
const j = randomInt(i + 1);
[zeichen[i], zeichen[j]] = [zeichen[j] as string, zeichen[i] as string];
}
/*
* Das erste Zeichen ist nie ein Sonderzeichen.
*
* `= + - @` am Anfang einer Zelle liest eine Tabellenkalkulation als Formel,
* und ein Passwort geht seinen Weg oft ueber Zwischenablage und Tabelle. Statt
* die vier zu streichen — sie erhoehen den Vorrat spuerbar — wandert ein
* fuehrendes Sonderzeichen nach hinten. Die Laenge und die vier Gruppen
* bleiben unberuehrt.
*/
const erstes = zeichen[0] as string;
if (PASSWORT_SONDER.includes(erstes)) {
const tausch = zeichen.findIndex((zeichenAnStelle, index) => {
return index > 0 && !PASSWORT_SONDER.includes(zeichenAnStelle as string);
});
if (tausch > 0) {
zeichen[0] = zeichen[tausch] as string;
zeichen[tausch] = erstes;
}
}
return zeichen.join("");
}
+136
View File
@@ -0,0 +1,136 @@
import { istEingebaut } from "@/lib/platzhalter";
import { PASSWORT_IM_DRUCK, passwortErzeugen } from "@/lib/passwort";
/**
* Das Ersetzen der Platzhalter (#012).
*
* Framework- und datenbankfrei: was ein Platzhalter bedeutet, entscheidet der
* Aufrufer, indem er die Werte hereinreicht. Hier steht nur, **wie** ersetzt
* wird — und das ist die Stelle, an der ein Fehler einen PowerShell-Befehl
* verfaelscht, den jemand auf einem Domaenencontroller ausfuehrt.
*
* Drei Regeln tragen das:
*
* 1. **Ein unbekannter Platzhalter bleibt stehen.** `{{tipfehler}}` wird nicht
* zu einer leeren Stelle, sondern bleibt sichtbar — im Befehl faellt das auf,
* ein stillschweigend leerer Parameter nicht.
* 2. **Ein bekannter Platzhalter ohne Wert wird leer.** `{{kunde}}` im Modus
* Intern ist nicht unbekannt, er ist leer. Der Unterschied ist der zwischen
* „falsch geschrieben“ und „hier gibt es das nicht“.
* 3. **Das generierte Passwort entsteht erst beim Ersetzen** und wird nie
* zurueckgegeben. Es gibt keinen Weg, es nachtraeglich zu erfragen.
*/
/**
* Wie der Text weiterverwendet wird – das entscheidet ueber Maskierung und Passwort.
*
* **Warum `anzeige` nicht maskiert:** der Text landet als React-Kind im Baum,
* und React maskiert Textknoten selbst. Eine zweite Maskierung hier machte aus
* „Forschung & Entwicklung“ sichtbar „Forschung &amp;amp; Entwicklung“ — der
* Versuch, sicherer zu sein, waere ein Anzeigefehler in jedem zweiten
* Abteilungsnamen. `tests/platzhalter-anzeige.test.tsx` haelt fest, dass ein
* `<script>` im Wert wirklich maskiert ankommt; bewiesen wird das am gerenderten
* HTML und nicht an einer Behauptung.
*
* Wer den Text je **als rohes HTML** einsetzt, maskiert selbst — und begruendet
* im selben Atemzug, warum er rohes HTML einsetzt.
*/
export type Verwendung =
/** Anzeige im Browser. React maskiert selbst; hier geschieht nichts. */
| { readonly art: "anzeige" }
/** In einem Befehl. Werte werden fuer die Shell maskiert. */
| { readonly art: "befehl"; readonly maskieren: (wert: string) => string }
/**
* Druckansicht. Wie `anzeige`, aber das generierte Passwort erscheint als
* `[Passwort – nicht gespeichert]`: ein Ausdruck liegt herum.
*/
| { readonly art: "druck" };
export type ErsetzungsErgebnis = {
readonly text: string;
/** Platzhalter, die niemand kennt – die Oberflaeche macht daraus eine Warnung. */
readonly unbekannt: readonly string[];
/**
* Kam `{{generiertes_passwort}}` vor? Dann gehoert der Hinweis daneben.
* Das Passwort selbst steht **nicht** hier; es existiert nur im Text.
*/
readonly mitPasswort: boolean;
};
const PASSWORT_SCHLUESSEL = "generiertes_passwort";
/**
* Ersetzt die Platzhalter in einem Text.
*
* `werte` ist die Zuordnung Schluessel → Wert. Ein Schluessel, der dort fehlt,
* aber laut `istEingebaut` existiert, wird leer; alles andere bleibt stehen.
*
* Die Ersatzfunktion bekommt den Wert als **Funktion** und nicht als
* Zeichenkette: `String.replace` deutet `$&`, `` $` ``, `$'` und `$1` im
* Ersatztext als Rueckverweise. Ein Nachname `A$&B` haette sonst den gefundenen
* Platzhalter wieder eingesetzt.
*/
export function platzhalterErsetzen(
text: string,
werte: Readonly<Record<string, string>>,
verwendung: Verwendung = { art: "anzeige" },
): ErsetzungsErgebnis {
const unbekannt = new Set<string>();
let mitPasswort = false;
/*
* Erkannt wird **jede** doppelte Klammer, nicht nur die mit gueltigem
* Schluessel. `{{Hostname}}` blieb sonst stehen **und** wurde nicht gemeldet —
* der Redakteur bekam keine Warnung fuer einen Platzhalter, der nie ersetzt
* wird. Ein falsch geschriebener ist gefaehrlicher als ein unbekannter: er
* sieht richtig aus.
*/
const ergebnis = text.replace(/\{\{\s*([^}]*?)\s*\}\}/gu, (ganzes, roh: string) => {
const schluessel = roh;
if (!/^[a-z][a-z0-9_]*$/u.test(schluessel)) {
// Kein gueltiger Schluessel – stehen lassen und melden.
unbekannt.add(schluessel);
return ganzes as string;
}
if (schluessel === PASSWORT_SCHLUESSEL) {
mitPasswort = true;
// Im Druck steht ein Hinweis statt des Passworts: ein Ausdruck liegt
// herum, und das Passwort ist nirgends gespeichert, also auch nicht
// nachvollziehbar, wenn es dort stuende.
if (verwendung.art === "druck") return PASSWORT_IM_DRUCK;
return aufbereiten(passwortErzeugen(), verwendung);
}
const wert = werte[schluessel];
if (wert !== undefined) return aufbereiten(wert, verwendung);
// Bekannt, aber ohne Wert: leer. Unbekannt: stehen lassen.
if (istEingebaut(schluessel)) return "";
unbekannt.add(schluessel);
return ganzes as string;
});
return { text: ergebnis, unbekannt: [...unbekannt], mitPasswort };
}
function aufbereiten(wert: string, verwendung: Verwendung): string {
switch (verwendung.art) {
case "befehl":
return verwendung.maskieren(wert);
case "anzeige":
case "druck":
// Unveraendert: React maskiert Textknoten selbst, siehe `Verwendung`.
return wert;
}
}
/**
* Wie `platzhalterErsetzen` mit `anzeige` – als sprechender Name fuer Texte, die
* weder in HTML noch in einen Befehl gehen, etwa einen vorgeschlagenen Lauftitel.
*/
export function platzhalterErsetzenRoh(
text: string,
werte: Readonly<Record<string, string>>,
): ErsetzungsErgebnis {
return platzhalterErsetzen(text, werte, { art: "anzeige" });
}
+31
View File
@@ -89,6 +89,37 @@ export function istEingebaut(schluessel: string): boolean {
return EINGEBAUTE_SCHLUESSEL.has(schluessel);
}
/**
* Eingebaute Platzhalter, die eine Vorlagenvariable **nicht** belegen darf.
*
* Es sind die fuenf, die eine Aussage ueber **wer, wann und fuer wen** machen:
*
* - `techniker` und `datum` unterschreiben einen Befehl. Eine Variable dafuer
* hiesse, dass in einem fertigen Befehl ein fremder Name und ein falsches
* Datum stehen koennen, ohne dass es auffaellt.
* - `kunde` und `kundenkuerzel` sind im Modus INTERN ausdruecklich leer (#008).
* Eine Variable haette die Kundenebene in den Befehl zurueckgeholt, die es
* dort nicht gibt.
* - `generiertes_passwort` wird ohnehin vor jeder Zuordnung abgefangen; hier
* steht es, damit niemand eine Variable anlegt, die nie wirkt.
*
* Alles andere bleibt erlaubt, und das ist wichtig: beim Onboarding gibt es die
* Person im Verzeichnis noch nicht, die Vorlage fragt `upn`, `sam` und `mail`
* also selbst ab. Genau dafuer sind Variablen da.
*/
export const GESPERRTE_VARIABLENNAMEN: ReadonlySet<string> = new Set([
"techniker",
"datum",
"kunde",
"kundenkuerzel",
"generiertes_passwort",
]);
/** Darf eine Vorlagenvariable so heissen? */
export function istAlsVariableErlaubt(schluessel: string): boolean {
return !GESPERRTE_VARIABLENNAMEN.has(schluessel);
}
/**
* Die Platzhalter, die weder eingebaut noch als Variable definiert sind.
*
+341
View File
@@ -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;
}
+14 -1
View File
@@ -1,3 +1,4 @@
import { istAlsVariableErlaubt } from "@/lib/platzhalter";
import { z } from "@/lib/validierung";
/**
@@ -119,7 +120,19 @@ export const VariableSchema = z.object({
.regex(
/^[a-z][a-z0-9_]{0,39}$/,
"Ein Variablenschlüssel beginnt mit einem Kleinbuchstaben und besteht aus Kleinbuchstaben, Ziffern und Unterstrich.",
),
)
/*
* Ein eingebauter Name ist als Variable gesperrt – mit einer Ausnahme,
* `hostname` (siehe `UEBERSCHREIBBARE_PLATZHALTER`). Sonst hiesse eine
* Variable `techniker`, und der fertige Befehl unterschriebe mit einem
* Namen, der nicht stimmt. Die Aufloesung wehrt das noch einmal ab; hier
* faellt es dem Redakteur auf, **bevor** er die Vorlage veroeffentlicht.
*/
.refine(istAlsVariableErlaubt, {
message:
"Diesen Namen gibt es schon als eingebauten Platzhalter. Bitte einen anderen wählen — " +
"sonst stünde im fertigen Befehl etwas anderes, als der eingebaute Wert sagt.",
}),
beschriftung: z.string().trim().min(1, "Eine Variable braucht eine Beschriftung.").max(GRENZEN.text),
typ: z.enum(VARIABLEN_TYPEN).default("TEXT"),
optionen: z.array(z.string().trim().min(1).max(GRENZEN.text)).max(GRENZEN.optionen).default([]),