Der Einstieg, fuer den die Anwendung gebaut ist: scannen, sehen was zu tun ist,
loslegen. `/t/<tagCode>` kennt drei Faelle — registriertes Geraet, vorgedruckter
Tag, unbekannter Code —, die Geraeteseite zeigt Zuweisungen, Schnellstart und
Historie, und beides ist am Handy bedienbar.
Im QR steht ausschliesslich `{APP_URL}/t/{tagCode}`: kein Name, keine
Seriennummer, kein Besitzer. Ein Aufkleber haengt jahrelang sichtbar am Geraet
und wird von jedem Telefon gelesen; was darauf steht, ist veroeffentlicht. Die
Masse sind gemessen, nicht behauptet: ein echtes PDF liefert 62 x 29 mm und A4,
und ein bei 300 dpi gerastertes Etikett wird mit demselben Decoder
zurueckgelesen, den auch der Scanner benutzt.
Drei Defekte zeigte erst der gebaute Stand, alle an den Naehten zwischen den
parallel gebauten Teilen: „Starten“ setzte IN_ARBEIT und sprang nicht, „Vorlage
zuweisen“ bot Vorlagen an, die der Dienst danach ablehnte, und die Geraeteliste
verlinkte nichts. Der unabhaengige Review fand drei Blocker — darunter, dass der
Scanner nie die native BarcodeDetector-API benutzte und dass ein Unterpfad in
APP_URL aus dem gedruckten QR herausfiel.
Closes #14
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
403 lines
16 KiB
TypeScript
403 lines
16 KiB
TypeScript
import { execFileSync } from "node:child_process";
|
||
import { existsSync, mkdtempSync, rmSync } from "node:fs";
|
||
import { tmpdir } from "node:os";
|
||
import { join } from "node:path";
|
||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||
import {
|
||
KOMMENTAR_ZEICHEN,
|
||
ShellFehler,
|
||
type SkriptSchritt,
|
||
UMBRUCH,
|
||
skriptBauen,
|
||
wertMaskieren,
|
||
} from "@/lib/shell";
|
||
|
||
/**
|
||
* Shell-Maskierung und Skript-Kopie (#012).
|
||
*
|
||
* Die Zeichenketten-Vergleiche halten fest, **was** maskiert wird. Sie beweisen
|
||
* aber nicht, dass die Maskierung traegt — das tut nur die Shell selbst. Fuer
|
||
* Bash geschieht das hier wirklich: der maskierte Wert wird an `bash -c`
|
||
* uebergeben und es wird geprueft, dass genau **ein** Argument ankommt, dass es
|
||
* Zeichen fuer Zeichen dem Ausgangswert gleicht und dass nichts ausgefuehrt
|
||
* wurde. Fuer PowerShell steht dieselbe Probe bereit, laeuft aber nur, wo `pwsh`
|
||
* vorhanden ist.
|
||
*/
|
||
|
||
/** Ein Wert, der alles versammelt, was eine Shell gefaehrlich findet. */
|
||
const BOESARTIG = `O'Brien"; rm -rf / && echo $(whoami) \`id\` $HOME | tee %PATH% !x!\nzweite Zeile`;
|
||
|
||
/**
|
||
* Derselbe Angriff mit **typografischen** Anfuehrungszeichen.
|
||
*
|
||
* PowerShell liest `“`, `”` und `„` als doppeltes und `‘`, `’`, `‚`, `‛` als
|
||
* einfaches Anfuehrungszeichen (about_Quoting_Rules, gegen PowerShell 7
|
||
* nachgemessen). Bis zum Review von #012 gingen sie unmaskiert durch — ein
|
||
* Abteilungsname, den jemand aus Word kopiert, reichte fuer Codeausfuehrung auf
|
||
* dem Domaenencontroller.
|
||
*/
|
||
const TYPOGRAFISCH = "Mueller\u201D; New-Item -ItemType File -Path /tmp/marke > $null; \u201CX";
|
||
|
||
function vorhanden(programm: string): boolean {
|
||
try {
|
||
execFileSync(programm, ["--version"], { stdio: "ignore" });
|
||
return true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
const bashDa = vorhanden("bash");
|
||
|
||
/**
|
||
* Wie PowerShell hier erreichbar ist – und warum das nicht egal ist.
|
||
*
|
||
* Diese Maschine hat kein `pwsh`. Der Test dagegen hat sich deshalb bisher
|
||
* **still uebersprungen**, und ein stilles Ueberspringen sieht genauso gruen aus
|
||
* wie ein bestandener Lauf — genau die Falle, die `tests/helpers/db.ts` fuer die
|
||
* Datenbank bewusst zugemacht hat. Der Review von #012 hat den Blocker mit den
|
||
* typografischen Anfuehrungszeichen erst gefunden, nachdem er sich PowerShell
|
||
* aus einem Container geholt hat.
|
||
*
|
||
* Also: erst `pwsh`, sonst das Container-Abbild, und in CI gar kein
|
||
* Ueberspringen mehr.
|
||
*/
|
||
function pwshAufruf(): { readonly programm: string; readonly vorlauf: readonly string[] } | undefined {
|
||
if (vorhanden("pwsh")) return { programm: "pwsh", vorlauf: [] };
|
||
try {
|
||
execFileSync("docker", ["image", "inspect", PWSH_ABBILD], { stdio: "ignore" });
|
||
return { programm: "docker", vorlauf: ["run", "--rm", "-i", PWSH_ABBILD, "pwsh"] };
|
||
} catch {
|
||
return undefined;
|
||
}
|
||
}
|
||
|
||
const PWSH_ABBILD = "mcr.microsoft.com/powershell:latest";
|
||
const pwsh = pwshAufruf();
|
||
|
||
if (!pwsh && process.env["CI"]) {
|
||
throw new Error(
|
||
`Weder pwsh noch das Abbild ${PWSH_ABBILD} ist da. In CI darf die PowerShell-Maskierung ` +
|
||
"nicht ungeprueft bleiben: `docker pull " +
|
||
PWSH_ABBILD +
|
||
"`.",
|
||
);
|
||
}
|
||
|
||
describe("wertMaskieren fuer PowerShell", () => {
|
||
it("verdoppelt das doppelte Anfuehrungszeichen", () => {
|
||
expect(wertMaskieren('Jörg "der Chef" Müller', "powershell")).toBe('Jörg ""der Chef"" Müller');
|
||
});
|
||
|
||
it("verdoppelt den Backtick", () => {
|
||
expect(wertMaskieren("a`b", "powershell")).toBe("a``b");
|
||
});
|
||
|
||
it("maskiert das Dollarzeichen, damit $(…) nicht ausgefuehrt wird", () => {
|
||
// Genau hier entscheidet sich, ob ein Wert Text bleibt oder zu einem
|
||
// Unterausdruck wird, den PowerShell beim Aufbau der Zeichenkette ausfuehrt.
|
||
expect(wertMaskieren("$(rm -rf /)", "powershell")).toBe("`$(rm -rf /)");
|
||
expect(wertMaskieren("$env:PATH", "powershell")).toBe("`$env:PATH");
|
||
});
|
||
|
||
it("maskiert Backtick und Dollar in der richtigen Reihenfolge", () => {
|
||
// Zuerst der Backtick, dann das Dollarzeichen: umgekehrt wuerde die
|
||
// Maskierung `$ am Ende selbst wieder verdoppelt und das $ waere frei.
|
||
// "```$x" bedeutet in PowerShell: literaler Backtick, literales Dollar, x.
|
||
expect(wertMaskieren("`$x", "powershell")).toBe("```$x");
|
||
});
|
||
|
||
it("macht aus Zeilenumbruechen die Fluchtfolgen `r und `n", () => {
|
||
expect(wertMaskieren("erste\r\nzweite", "powershell")).toBe("erste`r`nzweite");
|
||
});
|
||
|
||
it("laesst einen harmlosen Wert unveraendert", () => {
|
||
expect(wertMaskieren("Jörg Müller-Lüdenscheid", "powershell")).toBe("Jörg Müller-Lüdenscheid");
|
||
expect(wertMaskieren("", "powershell")).toBe("");
|
||
});
|
||
|
||
it("maskiert jedes gefaehrliche Zeichen des Sammelwerts", () => {
|
||
const maskiert = wertMaskieren(BOESARTIG, "powershell");
|
||
|
||
// Kein unmaskiertes $ und kein einzelnes " bleibt uebrig.
|
||
expect(maskiert).not.toMatch(/(^|[^`])\$/);
|
||
expect(maskiert).not.toMatch(/(^|[^"])"([^"]|$)/);
|
||
expect(maskiert).not.toMatch(/[\r\n]/);
|
||
});
|
||
|
||
it("entschaerft typografische Anfuehrungszeichen", () => {
|
||
/*
|
||
* Der Blocker aus dem Review von #012: sie beenden eine
|
||
* PowerShell-Zeichenkette genauso wie `"`, gingen aber unmaskiert durch.
|
||
* Angeglichen wird auf die ASCII-Form und **dann** verdoppelt — als Text
|
||
* durchreichen laesst sich das Zeichen nicht, PowerShell liest es ohnehin
|
||
* als Begrenzer.
|
||
*/
|
||
expect(wertMaskieren("a\u201Db", "powershell")).toBe('a""b');
|
||
expect(wertMaskieren("a\u201Cb", "powershell")).toBe('a""b');
|
||
expect(wertMaskieren("a\u201Eb", "powershell")).toBe('a""b');
|
||
expect(wertMaskieren(TYPOGRAFISCH, "powershell")).not.toMatch(/[\u201C\u201D\u201E]/u);
|
||
// Das typografische Apostroph wird zum geraden – in `"…"` ist es harmlos
|
||
// und wird deshalb **nicht** verdoppelt.
|
||
expect(wertMaskieren("O\u2019Neill", "powershell")).toBe("O'Neill");
|
||
});
|
||
|
||
describe.skipIf(!pwsh)("gegen ein echtes PowerShell geprueft", () => {
|
||
/*
|
||
* Eine Minute statt der fuenf Sekunden, die Vitest sonst gibt: liegt kein
|
||
* `pwsh` auf der Maschine, laeuft dieser Test in einem Container, und der
|
||
* startet erst. Unter Last — ein Ende-zu-Ende-Lauf nebenher genuegt — reichte
|
||
* die Vorgabe nicht, und der Test kippte um, ohne dass an der Maskierung
|
||
* etwas falsch war. Ein Test, der gelegentlich grundlos rot ist, wird bald
|
||
* uebersehen.
|
||
*/
|
||
it("kommt als genau ein Argument an und fuehrt nichts aus", { timeout: 90_000 }, () => {
|
||
for (const wert of [BOESARTIG, TYPOGRAFISCH]) {
|
||
const maskiert = wertMaskieren(wert, "powershell");
|
||
const skript = `$werte = @("${maskiert}"); "$($werte.Count)|$($werte[0])"`;
|
||
const ausgabe = execFileSync(pwsh!.programm, [...pwsh!.vorlauf, "-NoProfile", "-Command", skript], {
|
||
encoding: "utf8",
|
||
timeout: 60_000,
|
||
});
|
||
const erwartet = wert
|
||
.replace(/[\u201C\u201D\u201E]/gu, '"')
|
||
.replace(/[\u2018\u2019\u201A\u201B]/gu, "'")
|
||
.replace(/\r?\n/g, "\n");
|
||
expect(ausgabe.trimEnd()).toBe(`1|${erwartet}`);
|
||
}
|
||
});
|
||
});
|
||
});
|
||
|
||
describe("wertMaskieren fuer Bash", () => {
|
||
it("bricht das einfache Anfuehrungszeichen heraus", () => {
|
||
expect(wertMaskieren("O'Brien", "bash")).toBe(`O'\\''Brien`);
|
||
});
|
||
|
||
it("laesst alles uebrige unveraendert – in '…' ist nichts davon besonders", () => {
|
||
expect(wertMaskieren("$HOME `id` \\ ; && | > <", "bash")).toBe("$HOME `id` \\ ; && | > <");
|
||
// Ein Umbruch bekommt Bashs eigene Schreibweise: die Zeichenkette wird
|
||
// geschlossen, das Zeichen als Fluchtfolge angehaengt, wieder geoeffnet.
|
||
// Sonst veraendert die Skript-Kopie ihn spaeter (sie normalisiert Umbrueche).
|
||
expect(wertMaskieren("erste\nzweite", "bash")).toBe(`erste'$'\\n''zweite`);
|
||
expect(wertMaskieren("", "bash")).toBe("");
|
||
});
|
||
|
||
describe.skipIf(!bashDa)("gegen ein echtes bash geprueft", () => {
|
||
let verzeichnis = "";
|
||
let marke = "";
|
||
|
||
beforeAll(() => {
|
||
verzeichnis = mkdtempSync(join(tmpdir(), "laufzettel-shell-"));
|
||
marke = join(verzeichnis, "ausgefuehrt");
|
||
});
|
||
|
||
afterAll(() => {
|
||
rmSync(verzeichnis, { recursive: true, force: true });
|
||
});
|
||
|
||
/** Gibt zurueck, wie viele Argumente ankamen und was im ersten stand. */
|
||
function alsArgument(wert: string): { anzahl: string; erstes: string } {
|
||
const skript = `set -- '${wertMaskieren(wert, "bash")}'\nprintf '%s\\x1f%s' "$#" "$1"`;
|
||
const ausgabe = execFileSync("bash", ["-c", skript], { encoding: "utf8" });
|
||
const [anzahl = "", erstes = ""] = ausgabe.split("\u001f");
|
||
return { anzahl, erstes };
|
||
}
|
||
|
||
it.each([
|
||
["einfaches Anfuehrungszeichen", "O'Brien"],
|
||
["Befehlsersetzung mit Dollar", "$(whoami)"],
|
||
["Befehlsersetzung mit Backtick", "`id`"],
|
||
["Trennzeichen", "a; b && c | d"],
|
||
["Zeilenumbruch", "erste\nzweite"],
|
||
["Rueckwaertsschraegstrich", "C:\\Users\\Jörg"],
|
||
["Sternchen und Tilde", "*.* ~ ?"],
|
||
["alles zusammen", BOESARTIG],
|
||
])("traegt %s als genau ein Argument", (_fall, wert) => {
|
||
const { anzahl, erstes } = alsArgument(wert);
|
||
|
||
expect(anzahl).toBe("1");
|
||
expect(erstes).toBe(wert);
|
||
});
|
||
|
||
it("fuehrt nichts aus, was im Wert steht", () => {
|
||
// Der Beweis ist die Datei, die nicht entsteht: schlaegt die Maskierung
|
||
// fehl, laeuft `touch` und die Marke liegt da.
|
||
const wert = `x'; touch ${marke}; echo 'y $(touch ${marke}) \`touch ${marke}\``;
|
||
const { anzahl, erstes } = alsArgument(wert);
|
||
|
||
expect(anzahl).toBe("1");
|
||
expect(erstes).toBe(wert);
|
||
expect(existsSync(marke)).toBe(false);
|
||
});
|
||
});
|
||
});
|
||
|
||
describe("wertMaskieren fuer Cmd", () => {
|
||
it('laesst die Trennzeichen stehen – in "…" sind sie gewoehnliche Zeichen', () => {
|
||
expect(wertMaskieren("a & b | c > d < e ^ f", "cmd")).toBe("a & b | c > d < e ^ f");
|
||
expect(wertMaskieren("Jörg Müller-Lüdenscheid", "cmd")).toBe("Jörg Müller-Lüdenscheid");
|
||
});
|
||
|
||
it("laesst Prozent und Ausrufezeichen stehen", () => {
|
||
// Bewusst und dokumentiert: `%%` waere nur in einer Batch-Datei richtig, in
|
||
// einer interaktiven Eingabe falsch. Die Folge ist im schlimmsten Fall ein
|
||
// falscher Wert, nicht fremder Code – Cmd kennt keine Befehlsersetzung.
|
||
expect(wertMaskieren("%PATH% !x!", "cmd")).toBe("%PATH% !x!");
|
||
});
|
||
|
||
it.each([
|
||
["ein doppeltes Anfuehrungszeichen", 'a" & del /q C:\\*'],
|
||
["einen Wagenruecklauf", "a\rb"],
|
||
["einen Zeilenumbruch", "a\nb"],
|
||
["ein Null-Byte", "a\u0000b"],
|
||
])("lehnt einen Wert mit %s ab", (_fall, wert) => {
|
||
// Ablehnen statt unsicher durchlassen: ein `"` beendet das Argument, und
|
||
// alles danach liest cmd.exe als Befehl.
|
||
expect(() => wertMaskieren(wert, "cmd")).toThrow(ShellFehler);
|
||
});
|
||
|
||
it("sagt in der Meldung, was im Weg ist und was zu tun ist", () => {
|
||
expect(() => wertMaskieren('a"b', "cmd")).toThrow(/Anführungszeichen/);
|
||
expect(() => wertMaskieren('a"b', "cmd")).toThrow(/PowerShell/);
|
||
});
|
||
});
|
||
|
||
describe("skriptBauen", () => {
|
||
const powershellSchritte: readonly SkriptSchritt[] = [
|
||
{ titel: "Konto anlegen", shell: "powershell", befehl: 'New-ADUser -Name "Jörg"' },
|
||
{ titel: "Gruppen setzen", shell: "powershell", befehl: "Add-ADGroupMember -Identity IT" },
|
||
];
|
||
|
||
it("reiht die Befehle in der uebergebenen Reihenfolge mit Kommentarzeilen", () => {
|
||
expect(skriptBauen(powershellSchritte)).toBe(
|
||
[
|
||
"# Schritt: Konto anlegen",
|
||
'New-ADUser -Name "Jörg"',
|
||
"",
|
||
"# Schritt: Gruppen setzen",
|
||
"Add-ADGroupMember -Identity IT",
|
||
"",
|
||
].join("\r\n"),
|
||
);
|
||
});
|
||
|
||
it("trennt die Bloecke durch genau eine Leerzeile", () => {
|
||
const skript = skriptBauen(powershellSchritte);
|
||
|
||
expect(skript).toContain(`New-ADUser -Name "Jörg"\r\n\r\n# Schritt: Gruppen setzen`);
|
||
expect(skript).not.toContain("\r\n\r\n\r\n");
|
||
});
|
||
|
||
it("endet mit einem Umbruch, damit die letzte Zeile ausgefuehrt wird", () => {
|
||
expect(skriptBauen(powershellSchritte).endsWith("\r\n")).toBe(true);
|
||
});
|
||
|
||
it("nimmt fuer bash den Zeilenvorschub", () => {
|
||
const skript = skriptBauen([{ titel: "Anlegen", shell: "bash", befehl: "useradd joerg" }]);
|
||
|
||
expect(skript).toBe("# Schritt: Anlegen\nuseradd joerg\n");
|
||
expect(skript).not.toContain("\r");
|
||
});
|
||
|
||
it("nimmt fuer cmd REM und den Wagenruecklauf", () => {
|
||
const skript = skriptBauen([{ titel: "Anlegen", shell: "cmd", befehl: "net user joerg /add" }]);
|
||
|
||
expect(skript).toBe("REM Schritt: Anlegen\r\nnet user joerg /add\r\n");
|
||
expect(KOMMENTAR_ZEICHEN.cmd).toBe("REM");
|
||
});
|
||
|
||
it("nimmt bei gemischten Shells den Zeilenvorschub", () => {
|
||
// Ein gemischtes Skript ist ohnehin von keinem Interpreter als Ganzes
|
||
// ausfuehrbar; ein Wagenruecklauf wuerde die bash-Zeilen zusaetzlich
|
||
// beschaedigen.
|
||
const skript = skriptBauen([
|
||
{ titel: "Erst PowerShell", shell: "powershell", befehl: "Get-ADUser joerg" },
|
||
{ titel: "Dann bash", shell: "bash", befehl: "id joerg" },
|
||
]);
|
||
|
||
expect(skript).not.toContain("\r");
|
||
expect(skript).toContain("# Schritt: Erst PowerShell");
|
||
expect(skript).toContain("# Schritt: Dann bash");
|
||
});
|
||
|
||
it("bringt die Umbrueche innerhalb eines mehrzeiligen Befehls auf denselben Stand", () => {
|
||
// Eine CRLF-Datei mit einzelnen Zeilenvorschuechen darin ist keine.
|
||
const skript = skriptBauen([
|
||
{ titel: "Mehrzeilig", shell: "powershell", befehl: "Zeile eins\nZeile zwei\r\nZeile drei" },
|
||
]);
|
||
|
||
expect(skript).toBe("# Schritt: Mehrzeilig\r\nZeile eins\r\nZeile zwei\r\nZeile drei\r\n");
|
||
expect(skript).not.toMatch(/[^\r]\n/);
|
||
});
|
||
|
||
it("laesst einen Titel mit Zeilenumbruch den Kommentar nicht sprengen", () => {
|
||
// Die zweite Haelfte des Titels wuerde sonst als Befehl in der Zeile
|
||
// darunter stehen – und `format c:` fuehrt niemand aus Versehen aus.
|
||
const skript = skriptBauen([
|
||
{ titel: "Konto anlegen\r\nformat c:", shell: "powershell", befehl: "Get-ADUser joerg" },
|
||
]);
|
||
|
||
expect(skript).toBe("# Schritt: Konto anlegen format c:\r\nGet-ADUser joerg\r\n");
|
||
expect(skript.split("\r\n").filter((zeile) => zeile !== "")).toHaveLength(2);
|
||
});
|
||
|
||
it("faengt auch Steuerzeichen und Leerzeichenketten im Titel", () => {
|
||
const skript = skriptBauen([{ titel: " Konto\t\tanlegen ", shell: "bash", befehl: "id joerg" }]);
|
||
|
||
expect(skript).toBe("# Schritt: Konto anlegen\nid joerg\n");
|
||
});
|
||
|
||
it("setzt fuer einen Titel, von dem nichts bleibt, einen Ersatz ein", () => {
|
||
const skript = skriptBauen([{ titel: "\n\t", shell: "bash", befehl: "id joerg" }]);
|
||
|
||
expect(skript).toBe("# Schritt: (ohne Titel)\nid joerg\n");
|
||
});
|
||
|
||
it("laesst Schritte ohne Befehl weg", () => {
|
||
const skript = skriptBauen([
|
||
{ titel: "Leer", shell: "bash", befehl: " " },
|
||
{ titel: "Voll", shell: "bash", befehl: "id joerg" },
|
||
]);
|
||
|
||
expect(skript).toBe("# Schritt: Voll\nid joerg\n");
|
||
});
|
||
|
||
it("liefert bei keinem Schritt und bei nur leeren Befehlen eine leere Zeichenkette", () => {
|
||
expect(skriptBauen([])).toBe("");
|
||
expect(skriptBauen([{ titel: "Leer", shell: "cmd", befehl: "" }])).toBe("");
|
||
});
|
||
|
||
it("haelt die Umbrueche je Shell fest", () => {
|
||
expect(UMBRUCH.powershell).toBe("\r\n");
|
||
expect(UMBRUCH.cmd).toBe("\r\n");
|
||
expect(UMBRUCH.bash).toBe("\n");
|
||
});
|
||
|
||
describe.skipIf(!bashDa)("das erzeugte bash-Skript laeuft", () => {
|
||
it("fuehrt beide Befehle in der Reihenfolge aus und haelt die Kommentare heraus", () => {
|
||
const skript = skriptBauen([
|
||
{ titel: "Erster # mit Rauten", shell: "bash", befehl: "printf 'eins'" },
|
||
{ titel: "Zweiter", shell: "bash", befehl: "printf 'zwei'" },
|
||
]);
|
||
|
||
expect(execFileSync("bash", ["-c", skript], { encoding: "utf8" })).toBe("einszwei");
|
||
});
|
||
|
||
it("fuehrt einen Titel nicht aus, der wie ein Befehl aussieht", () => {
|
||
const verzeichnis = mkdtempSync(join(tmpdir(), "laufzettel-skript-"));
|
||
const marke = join(verzeichnis, "ausgefuehrt");
|
||
try {
|
||
const skript = skriptBauen([
|
||
{ titel: `Anlegen\ntouch ${marke}`, shell: "bash", befehl: "printf 'eins'" },
|
||
]);
|
||
|
||
expect(execFileSync("bash", ["-c", skript], { encoding: "utf8" })).toBe("eins");
|
||
expect(existsSync(marke)).toBe(false);
|
||
} finally {
|
||
rmSync(verzeichnis, { recursive: true, force: true });
|
||
}
|
||
});
|
||
});
|
||
});
|