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