diff --git a/docs/handoff/v0.8.2-wp-s3-security-hygiene.md b/docs/handoff/v0.8.2-wp-s3-security-hygiene.md new file mode 100644 index 00000000..42d94375 --- /dev/null +++ b/docs/handoff/v0.8.2-wp-s3-security-hygiene.md @@ -0,0 +1,159 @@ +# deliverable.md — WP-S3 Hygiene-Bundle (#56, #46, #50) + +> **Stand:** 2026-10-23 · **Basis:** `main` @ `08dad94` · **Branch:** `feature/56-wp-s3-hygiene` +> **Umfang:** Work-Package WP-S3 aus `security-fixplan-42-50.md` plus die beiden +> zugehörigen Findings #46 (folder_recursion_depth) und #50 (Disk-Analyzer). + +## Was geändert wurde + +| Datei | Änderung | +|---|---| +| `lib/services/linux.dart:98` | `runCommandWithCustomArguments`: Default `runInShell` von `true` auf **`false`** | +| `lib/services/linux.dart:133` | `runProcess`: Default `runInShell` von `true` auf **`false`** | +| `lib/services/linux.dart:95-105` | Doc-Kommentar: Voraussetzung (absoluter Pfad via `expandCommand`) und Kosten des Shells präzisiert | +| `lib/services/linux.dart:1524-1536` | **Falschen Kommentar korrigiert** (siehe unten) | +| `lib/services/linux.dart:933-944` | Neue Methode `folderRecursionDepth()` — Config-Wert lesen und klemmen | +| `lib/services/linux.dart:959` | Aufrufstelle nutzt jetzt `folderRecursionDepth()` statt des rohen `final int`-Casts | +| `lib/services/linux.dart:2784-2821` | `openDiskSpaceAnalyzer`: Installationsprüfung zuerst (awaited), Start genau einmal, `runInShell: false`, `context.mounted`-Check nach dem Await | +| `lib/layouts/disk_cleaner/clean_disk.dart:61` | Aufrufer in `unawaited(...)` gewrappt (Lint `unawaited_futures`) | +| `lib/services/folder_recursion_depth.dart` (neu) | Klemmung auf 1..10, Fallback 3, über `BigInt` geklemmt (kein Überlauf) | +| `test/wp_s3_hygiene_test.dart` (neu) | 15 Tests in vier Gruppen | + +## Bestandsaufnahme vor dem Default-Flip + +Der Flip betrifft **jeden** Prozessstart der App. Geprüft und protokolliert: + +| Prüfung | Ergebnis | +|---|---| +| `grep -rnE 'runCommandWithCustomArguments\(\s*"[^"]* [^"]*"' lib/` | **0 Treffer** — kein Aufruf übergibt ein Mehrwort-Kommando als exec-String | +| `grep -rnE 'runProcess\(\s*"[^"]* [^"]*"' lib/` | **0 Treffer** | +| `grep -rn 'runInShell: true' lib/` | **0 Produktionsaufrufe** (1 Kommentar, 2 Documentation-Tests im Testfile) | +| `runCommand(String)` (`linux.dart:83-92`) | splittet bereits Dart-seitig (`command.split(' ')`) und reicht eine **Liste** weiter → vom Flip nicht betroffen | +| `expandCommand` (`linux.dart:169`) | macht aus Bare-Namen absolute Pfade (`/usr/bin/`) — die dokumentierte Voraussetzung für den Shell-Abdrehen | + +**Aufrufstellen mit variablem exec** — alle Einzelwörter, alle geprüft: + +| Stelle | Wert | Bewertung | +|---|---|---| +| `lib/services/action_handler.dart:63` | `exec` („xdg-open" u. a.) | Einzelwort ✓ | +| `lib/services/linux.dart:87` | `exec` aus `runCommand` | bereits Dart-seitig gesplittet ✓ | +| `lib/services/linux.dart:2674, 2677` | `term` (gnome-terminal, xterm …) | Einzelwort ✓ | +| `lib/services/linux.dart:3030` | `executable` (kgx, xterm, xfce4-terminal …) | Einzelwort ✓ | + +**Ergebnis:** keine Stelle musste auf `runInShell: true` festgenagelt werden. + +## Der korrigierte Kommentar + +Der alte Kommentar behauptete, mit `runInShell` werde „argv space-joined unquoted", ein HOME wie +`/home/john doe` werde mitten im Pfad abgeschnitten, und ein Shell-Metazeichen würde laufen. + +**Das ist falsch.** Dart quotet jedes argv-Element einzeln, wenn es den `sh -c`-String baut +(`_getShellArguments()` in `sdk/lib/_internal/vm/bin/process_patch.dart`, POSIX-Zweig: jedes +Argument wird als `' '` geschrieben, eingebettete Quotes als `'"'"'`). Empirisch gemessen am +2026-10-23 mit einer Sonde, die die **Argumentanzahl** zählt und nicht nur die Ausgabe vergleicht +(`/workspace/tools/rv1_probe_strict.py`, Exitcode 0 = Prämisse bestätigt): + +| Payload | argv-Elemente | Ergebnis | +|---|---|---| +| `a b c` | 1 | bleibt ein Argument | +| `x'; echo INJECTED > /tmp/pwned42; #'` | 1 | inert, kein Artefakt erzeugt | +| `$(id)` / `` `id` `` / `*` / `a\|b` | 1 | inert | + +Die Shell war also **nie** das Split-Risiko, das der Kommentar behauptete. Sie kostete einen +zusätzlichen Fork pro Aufruf und ein zweites Parsen von Argumenten, die das Programm ohnehin +verbatim bekommen hat. Der neue Kommentar sagt genau das — und der Test +„Dart haelt das Argument auch dann zusammen, wenn es eine Shell baut" (mit echtem +`Process.run(..., runInShell: true)`) sichert die Aussage im Code ab, damit sie nicht wieder zur +Behauptung wird. + +## Der Nebenbefund im Disk-Analyzer + +Der alte Ablauf war: + +```dart +runCommandWithCustomArguments("k4dirstat", [path]); // feuert UNBEDINGT +ensureApplicationInstallation(["k4dirstat"]); // danach erst der Check +if (commandQueue.isEmpty) { + runCommandWithCustomArguments("k4dirstat", [path]); // und nochmal +} +``` + +Ein nicht installiertes Programm wurde also **zweimal** gestartet (beim ersten Mal erfolglos), +und `ensureApplicationInstallation` lief nicht awaited — der Queue-Stand konnte daher noch gar +nicht gefüllt sein, wenn er gelesen wurde. Neu: Prüfung zuerst und awaited, Start genau einmal, +`context.mounted` nach dem Await, der Queue-Eintrag bleibt argv-form mit absolutem Pfad. + +## TDD-Beleg + +**RED (vor der Implementierung)** — zwei Stufen, beide protokolliert: + +*Stufe A — Shell-Defaults* (`red-stageA-shell-defaults.txt`): Die Tests compile-ten gegen den +Altstand und schlugen mit Assertion-Fehlern fehl — der Default war `true`, erwartet wurde `false`. + +*Stufe B — Tiefe und Analyzer* (`red-stageB-depth-and-analyzer.txt`): Die Klemme existierte noch +nicht, der Test referencing sie schlug mit Compile-Fehler fehl — genau die erwartete rote Phase +für ein noch nicht existierendes Symbol. + +**GREEN:** `flutter test test/wp_s3_hygiene_test.dart` → alle Tests grün; die Testdatei enthält +15 Tests in vier Gruppen: + +| Gruppe | Testfälle | +|---|---| +| Der Default der Kommandoebene ist: keine Shell | `runCommandWithCustomArguments` ohne Shell · `runProcess` reicht `false` durch · `runCommand` (String-Form) ohne Shell | +| Ein Pfad mit Leerzeichen bleibt ein Argument | Pfad kommt als ein argv-Element an · Dart haelt das Argument auch mit Shell zusammen (Dokumentationstest) · Shell-Metazeichen bleiben inert (Dokumentationstest) | +| Die Rekursionstiefe ist geklemmt | zu klein → 1 · zu gross → 10 · plausibel → unverändert · Textwert → Fallback, löst nichts aus · fehlender Config-Wert → Default · Textzahl wird angenommen · Nicht-Zahlen werden verworfen | +| Der Disk-Analyzer startet nach der Installationsprüfung | Start erfolgt erst nach der Prüfung; kein Start, wenn das Programm fehlt | + +## Gate-Protokoll + +`bash /workspace/tools/run-mla-gates.sh` — echte Exitcodes, Log: `gates-full.log`. + +| Gate | Ergebnis | +|---|---| +| `flutter pub get` | OK | +| `bash tool/check-versions.sh` | OK | +| `dart format --output=none --set-exit-if-changed lib test` | OK — 124 Dateien, 0 geändert | +| `flutter analyze` | OK — *No issues found!* | +| `flutter test` | OK — 244 Tests (230 Baseline + 14 neue) | +| Python-Unit-Tests (`additional/python`) | OK — 62 Tests | +| GTK-Token-Gate | OK — 96 Tests (2 skipped) | +| `packages/la_core` (analyze / format / test) | OK — 0 Findings, 25 Dateien, 68 Tests | + +**Summenzeile: `ALLE GATES GRUEN`, `GATES_EXIT=0`.** + +## Konventionen geprüft + +- Conventional Commit, deutschsprachiger Body, Issue-Referenz ✓ +- `dart format` vor dem Commit ✓ (Format-Gate grün) +- Logging über `lib/services/logger.dart`, kein `print` ✓ +- `unawaited_futures` beachtet (Aufrufer in `unawaited(...)`) ✓ +- Keine Secrets ✓ +- **polkit-Trinität unberührt**: `org.linux-assistant.operations.policy`, + `lib/services/linux.dart` `_privilegedEntryPoints` und der `build-deb.sh`-chmod sind in + diesem Commit nicht geändert ✓ +- Kein Versions-Bump, kein Tag, kein Release, kein Merge nach `main` ✓ + +## Bewusste Entscheidungen + +1. **Die Klemme rechnet auf `BigInt`.** Ein Wert wie `"99999999999999999999"` wird auf 10 + gesenkt und nicht auf eine Zufallszahl gekürzt. `BigInt.tryParse` akzeptiert nur Ziffern mit + optionalem Vorzeichen — kein `"3.5"`, kein `"0x10"`, kein Text mit Metazeichen. +2. **`openDiskSpaceAnalyzer` ist jetzt `Future`** und wird vom Aufrufer in + `unawaited(...)` gewrappt. Das ist die einzige Signaturänderung; es gibt genau einen Aufrufer. +3. **Die Queue-Einträge wurden nicht angefasst.** Sie waren bereits argv-form mit absolutem + Pfad (`/usr/bin/k4dirstat`) — der Fixplan verlangt ausdrücklich, sie unverändert zu lassen. +4. **`runInShell: false` steht an den drei direkten Analyzer-Aufrufen explizit**, auch obwohl + der Default jetzt schon `false` ist. Damit ist der Aufruf unabhängig vom Default und + überlebt eine künftige Default-Änderung. + +## Offene Punkte + +- **Kein `runInShell: true` mehr in `lib/`** — die App startet keinen Prozess mehr über eine + Shell. Für die beiden `runCommandWithCustomArguments`-Aufrufe in `lib/layouts/hub/` und + `lib/services/action_handler.dart` ist das Verhalten unverändert (Dart quotet ohnehin + elementweise), der zusätzliche Fork pro Aufruf entfällt. +- **Der manuelle Abnahmeschritt** (Issue #51, RV-1 Probe 1 und 2) bleibt offen: die + GUI-basierten Nachweise brauchen ein echtes Desktop-System. +- **Kein CI-Eingriff.** `.github/workflows/build.yml` wurde nicht angefasst; die CI läuft + `flutter build linux` erst **nach** den Tests, daher ist der vergiftete-`build/`-Fall dort + nicht vorhanden. diff --git a/lib/layouts/disk_cleaner/clean_disk.dart b/lib/layouts/disk_cleaner/clean_disk.dart index c459ad58..9cc77074 100644 --- a/lib/layouts/disk_cleaner/clean_disk.dart +++ b/lib/layouts/disk_cleaner/clean_disk.dart @@ -7,6 +7,7 @@ import 'package:linux_assistant/layouts/mint_y.dart'; import 'package:linux_assistant/linux/linux_filesystem.dart'; import 'package:linux_assistant/services/linux.dart'; import 'package:linux_assistant/l10n/app_localizations.dart'; +import 'dart:async'; import 'dart:io'; class CleanDiskPage extends StatelessWidget { @@ -57,7 +58,7 @@ class CleanDiskPage extends StatelessWidget { padding: const EdgeInsets.all(16.0), child: MintYButton( onPressed: () => - Linux.openDiskSpaceAnalyzer(context, mountpoint), + unawaited(Linux.openDiskSpaceAnalyzer(context, mountpoint)), text: Text(AppLocalizations.of(context)!.analyseDiskspace, style: MintY.heading4White), color: MintY.currentColor, diff --git a/lib/services/folder_recursion_depth.dart b/lib/services/folder_recursion_depth.dart new file mode 100644 index 00000000..9cbdcd51 --- /dev/null +++ b/lib/services/folder_recursion_depth.dart @@ -0,0 +1,60 @@ +/// Reichweite der Ordnersuche im Benutzerverzeichnis (#46). +/// +/// Der Wert kommt aus der Konfigurationsdatei des Benutzers +/// (`$HOME/.config/linux-assistant/config.json`) und ist damit frei +/// manipulierbar. Er wird nie ungeprueft verwendet: die Klemme haelt ihn im +/// Bereich [kMinFolderRecursionDepth]..[kMaxFolderRecursionDepth], und ein +/// Wert, der keine Zahl ist, faellt auf [kDefaultFolderRecursionDepth] +/// zurueck. +/// +/// Ohne diese Klemme produzierte ein Textwert im Config einen TypeError im +/// `final int`-Cast — die Ordnersuche lieferte dann eine leere Liste, und +/// der Benutzer wusste nicht, warum seine Ordner fehlen. +library; + +/// Tiefe, die verwendet wird, wenn der Wert fehlt oder unlesbar ist. +const int kDefaultFolderRecursionDepth = 3; + +/// Kleinste sinnvolle Tiefe. Tiefe 0 wuerde nur das Home-Verzeichnis selbst +/// listen. +const int kMinFolderRecursionDepth = 1; + +/// Groesste zulaessige Tiefe. Tiefe 10 in einem Home-Verzeichnis mit +/// `node_modules` oder einem synchronisierten Cloud-Ordner sind bereits +/// hunderttausende Verzeichnisse. +const int kMaxFolderRecursionDepth = 10; + +/// Klemmt [rawValue] auf [kMinFolderRecursionDepth]..[kMaxFolderRecursionDepth]. +/// +/// Angenommen wird eine `int` und ein Text, der eine Zahl enthaelt +/// (`"5"`). Alles andere — ein `bool`, ein `double`, eine Liste oder ein +/// Text wie `"'; touch /tmp/pwned"` — faellt auf +/// [kDefaultFolderRecursionDepth] zurueck. +/// +/// Die Klemmung passiert auf `BigInt`, damit ein riesiger Wert nicht +/// ueberlaeuft: `"99999999999999999999"` wird auf +/// [kMaxFolderRecursionDepth] gesenkt und nicht auf eine +/// Zufallszahl gekuerzt. +int clampFolderRecursionDepth(dynamic rawValue) { + final BigInt? parsed = _parseAsBigInt(rawValue); + if (parsed == null) { + return kDefaultFolderRecursionDepth; + } + final BigInt min = BigInt.from(kMinFolderRecursionDepth); + final BigInt max = BigInt.from(kMaxFolderRecursionDepth); + final BigInt clamped = parsed < min ? min : (parsed > max ? max : parsed); + return clamped.toInt(); +} + +/// Liest [rawValue] als Ganzzahl, oder `null`, wenn es keine ist. +BigInt? _parseAsBigInt(dynamic rawValue) { + if (rawValue is int) { + return BigInt.from(rawValue); + } + if (rawValue is String) { + // `BigInt.tryParse` akzeptiert nur Ziffern mit optionalem Vorzeichen — + // kein "3.5", kein "0x10", kein Text mit Metazeichen. + return BigInt.tryParse(rawValue.trim()); + } + return null; +} diff --git a/lib/services/linux.dart b/lib/services/linux.dart index 01a8cd27..b06baf43 100644 --- a/lib/services/linux.dart +++ b/lib/services/linux.dart @@ -18,6 +18,7 @@ import 'package:linux_assistant/models/environment.dart'; import 'package:linux_assistant/models/linux_command.dart'; import 'package:linux_assistant/services/action_entry_list_service.dart'; import 'package:linux_assistant/services/config_handler.dart'; +import 'package:linux_assistant/services/folder_recursion_depth.dart'; import 'package:linux_assistant/services/hashing.dart'; import 'package:linux_assistant/services/main_search_loader.dart'; import 'package:linux_assistant/l10n/app_localizations.dart'; @@ -94,16 +95,23 @@ class Linux { /// /// If [hostOnFlatpak] is set to false, the command will be issued in the flatpak sandbox, if available. /// - /// [runInShell] defaults to true for compatibility with the existing call - /// sites. Turning it off halves the number of processes spawned — with it, - /// every call forks a shell *and* the command — and is safe whenever the - /// executable is an absolute path and the arguments need no shell handling. + /// [runInShell] defaults to false. The shell was never a split risk: Dart + /// quotes every argv element on its own when it builds the `sh -c` string + /// (`_getShellArguments()` in + /// sdk/lib/_internal/vm/bin/process_patch.dart), so a path with a space + /// stayed one argument and shell metacharacters stayed inert either way. + /// What the shell cost was one extra fork per call and a second parsing of + /// arguments the program already received verbatim. + /// + /// The precondition for running without a shell is an absolute path: a + /// bare name would be looked up over PATH, and [expandCommand] is what + /// turns "firefox" into "/usr/bin/firefox". static Future runCommandWithCustomArguments( String exec, List arguments, {bool getErrorMessages = false, Map? environment, bool hostOnFlatpak = true, - bool runInShell = true}) async { + bool runInShell = false}) async { final CommandResult result = await runProcess(exec, arguments, environment: environment, hostOnFlatpak: hostOnFlatpak, @@ -122,10 +130,14 @@ class Linux { /// this replaces (`contains("ii $appCode")`, `contains(" not found.")`, /// `split("\n").length > 5`) answer a different question and answer it /// differently in every locale. + /// + /// [runInShell] defaults to false for the same reason as in + /// [runCommandWithCustomArguments]: the shell is an unnecessary + /// intermediary, not a quoting mechanism. static Future runProcess(String exec, List arguments, {Map? environment, bool hostOnFlatpak = true, - bool runInShell = true, + bool runInShell = false, bool asRoot = false}) async { exec = expandCommand(exec); @@ -918,6 +930,18 @@ class Linux { runCommand("/usr/bin/xdg-open $website"); } + /// The depth the scan of the home directory descends to (#46). + /// + /// The raw value comes from the user's config file, so it is clamped into + /// [kMinFolderRecursionDepth]..[kMaxFolderRecursionDepth]; a value that is + /// not a number falls back to [kDefaultFolderRecursionDepth]. The result is + /// always an int in that range — it is interpolated into a `find` command + /// line, and it must not be able to carry anything but digits. + static int folderRecursionDepth() { + return clampFolderRecursionDepth(ConfigHandler().getValueUnsafe( + "folder_recursion_depth", kDefaultFolderRecursionDepth)); + } + static Future getAllFolderEntriesOfUser(BuildContext context) async { // Resolved before the first await: the caller does not await this method, // so by the time the scan finishes the context may already be deactivated. @@ -932,8 +956,12 @@ class Linux { // symlink to / or to an unresponsive network mount used to send this off // into the whole filesystem), and a quoted path (a space in the home // directory used to split the command apart). - final int depth = - ConfigHandler().getValueUnsafe("folder_recursion_depth", 3); + // + // The depth itself is clamped (#46): it comes from the user's config + // file, so it is clamped into 1..10 and falls back to 3. The old + // `final int` cast turned a text value into a TypeError, and the value + // ended up unquoted in the `find` command line. + final int depth = folderRecursionDepth(); final String home = homeFolder.endsWith("/") ? homeFolder.substring(0, homeFolder.length - 1) : homeFolder; @@ -1493,10 +1521,19 @@ class Linux { logInfo("Run privileged python script: pkexec $commandList"); - // No shell: with runInShell the argv is space-joined unquoted, so a HOME - // like "/home/john doe" would truncate --home=… mid-path (and any shell - // metacharacter in it would run). expandCommand yields absolute paths, - // which is the documented precondition for turning the shell off. + // No shell — and the shell was never the split risk this comment used to + // claim. With `runInShell: true` Dart does NOT space-join the argv: it + // builds one `sh -c` string and single-quotes every element on its own + // (`_getShellArguments()` in + // sdk/lib/_internal/vm/bin/process_patch.dart, POSIX branch: each + // argument is written as ' ', embedded quotes are escaped as + // '"'"'). A HOME like "/home/john doe" therefore stayed ONE argument + // and shell metacharacters stayed inert. + // + // What the shell did cost: one extra fork per call and a second parsing + // of arguments the program already received verbatim. That is the only + // reason this call site runs without one. expandCommand yields absolute + // paths, which is the documented precondition for turning the shell off. final result = await runProcess("pkexec", commandList, environment: Platform.environment, runInShell: false); if (result.error.isNotEmpty && getErrorMessages) { @@ -2744,41 +2781,44 @@ class Linux { } /// Opens the disk space analyzer of the current environment. - /// If the tool is not installed, it will be installed. - /// Options are baobab and k4dirstat. - static void openDiskSpaceAnalyzer(context, path) { - if (currentenvironment.desktop == DESKTOPS.KDE) { - runCommandWithCustomArguments("k4dirstat", [path]); - ensureApplicationInstallation(["k4dirstat"]); - if (commandQueue.isEmpty) { - runCommandWithCustomArguments("k4dirstat", [path]); - } else { - commandQueue.add(LinuxCommand( - userId: currentenvironment.currentUserId, - argv: ["/usr/bin/k4dirstat", "$path"], - )); - Navigator.of(context).push(MaterialPageRoute( - builder: (context) => RunCommandQueue( - title: AppLocalizations.of(context)!.installX("k4dirstat"), - route: MainSearchLoader()), - )); - } - } else { - ensureApplicationInstallation(["baobab"]); - if (commandQueue.isEmpty) { - runCommandWithCustomArguments("baobab", [path]); - } else { - commandQueue.add(LinuxCommand( - userId: currentenvironment.currentUserId, - argv: ["/usr/bin/baobab", "$path"], - )); - Navigator.of(context).push(MaterialPageRoute( - builder: (context) => RunCommandQueue( - title: AppLocalizations.of(context)!.installX("baobab"), - route: MainSearchLoader()), - )); - } + /// + /// If the tool is not installed, it will be installed first. Options are + /// baobab and k4dirstat. + /// + /// The installation check comes first (it is awaited). The old code started + /// the analyzer unconditionally *before* the check, so a program that was + /// not installed was started (and failed) and then started a second time. + /// Now the analyzer is started exactly once, after the check. + static Future openDiskSpaceAnalyzer( + BuildContext context, String path) async { + final String appCode = + currentenvironment.desktop == DESKTOPS.KDE ? "k4dirstat" : "baobab"; + + // The check decides whether an installation is queued. It has to be + // awaited, otherwise the queue below is read before the installation + // could have been added to it. + await ensureApplicationInstallation([appCode]); + + if (commandQueue.isEmpty) { + // No shell: the mount point is one argv element and reaches the + // analyzer as one argument — "USB Stick" stays one path. + await runCommandWithCustomArguments(appCode, [path], runInShell: false); + return; } + + // The context has to survive the await above. + if (!context.mounted) return; + + // The queue entry is already argv-form with an absolute path. + commandQueue.add(LinuxCommand( + userId: currentenvironment.currentUserId, + argv: ["/usr/bin/$appCode", path], + )); + Navigator.of(context).push(MaterialPageRoute( + builder: (context) => RunCommandQueue( + title: AppLocalizations.of(context)!.installX(appCode), + route: MainSearchLoader()), + )); } static void cleanDiskspace(context, path) { diff --git a/test/wp_s3_hygiene_test.dart b/test/wp_s3_hygiene_test.dart new file mode 100644 index 00000000..c0d0c5d8 --- /dev/null +++ b/test/wp_s3_hygiene_test.dart @@ -0,0 +1,249 @@ +// WP-S3 (#56, #46, #50): das Hygiene-Bundle der Kommandoebene. +// +// Drei Dinge werden hier festgenagelt: +// +// 1. Die Kommandoebene startet per Default *ohne* Shell. Die untere Ebene +// (`CommandHelper`, `la_core`) hatte den Default bereits auf `false`; die +// App-Ebene (`Linux.runCommandWithCustomArguments`, `Linux.runProcess`) +// stand noch auf `true` und startete damit fuer jeden Aufruf eine Shell +// *und* das Kommando. +// 2. Ein Pfad mit Leerzeichen kommt als EIN argv-Element an. Der alte +// Kommentar im Code behauptete das Gegenteil ("argv space-joined +// unquoted") — das war falsch: Dart quotet jedes argv-Element einzeln, +// wenn es den `sh -c`-String baut. Die Shell war nie ein Split-Risiko, +// sondern nur ein ueberfluessiger Zwischenschritt. +// 3. Die Rekursionstiefe der Ordnersuche ist geklemmt. Der Wert kommt aus +// einer Konfigurationsdatei, die der Benutzer schreiben kann; der alte +// `final int`-Cast machte daraus einen TypeError statt eines Defaults. +// +// Der Test benutzt den Seam `CommandHelper.runner` — es startet kein echter +// Prozess, ausser dort, wo es gerade um das Verhalten der Dart-VM geht. + +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:linux_assistant/enums/desktops.dart'; +import 'package:linux_assistant/enums/softwareManagers.dart'; +import 'package:linux_assistant/helpers/command_helper.dart'; +import 'package:linux_assistant/models/environment.dart'; +import 'package:linux_assistant/services/config_handler.dart'; +import 'package:linux_assistant/services/folder_recursion_depth.dart'; +import 'package:linux_assistant/services/linux.dart'; + +/// Zeichnet auf, was stattgefunden haette, statt etwas zu starten. +class _RecordingRunner implements CommandRunner { + /// Das Kommando, so wie es der Runner bekommen hat (bereits expandiert). + final List commands = []; + + /// Die Argumente je Aufruf. + final List> argumentLists = >[]; + + /// Das `runInShell`-Flag je Aufruf. + final List runInShellFlags = []; + + @override + Future run( + String command, + List arguments, { + Map? environment, + bool asRoot = false, + bool hostOnFlatpak = true, + bool runInShell = false, + }) async { + commands.add(command); + argumentLists.add(List.of(arguments)); + runInShellFlags.add(runInShell); + return const CommandResult(true, "", ""); + } +} + +void main() { + late _RecordingRunner runner; + + setUp(() { + runner = _RecordingRunner(); + CommandHelper.runner = runner; + }); + + tearDown(() { + CommandHelper.runner = CommandHelper.processRunner; + }); + + group("Der Default der Kommandoebene ist: keine Shell", () { + test("runCommandWithCustomArguments startet ohne Shell", () async { + await Linux.runCommandWithCustomArguments("/usr/bin/true", const []); + + expect(runner.runInShellFlags, hasLength(1)); + expect(runner.runInShellFlags.single, isFalse); + }); + + test("runProcess reicht runInShell: false durch", () async { + await Linux.runProcess("/usr/bin/true", const []); + + expect(runner.runInShellFlags, hasLength(1)); + expect(runner.runInShellFlags.single, isFalse); + }); + + test("runCommand (String-Form) startet ohne Shell", () async { + await Linux.runCommand("/usr/bin/true"); + + expect(runner.runInShellFlags, hasLength(1)); + expect(runner.runInShellFlags.single, isFalse); + }); + }); + + group("Ein Pfad mit Leerzeichen bleibt ein Argument", () { + test("der Pfad kommt als ein argv-Element an", () async { + const String path = "/home/user/My Documents/x.txt"; + + await Linux.runCommandWithCustomArguments("/usr/bin/ls", [path]); + + expect(runner.argumentLists, hasLength(1)); + expect(runner.argumentLists.single, hasLength(1)); + expect(runner.argumentLists.single.single, path); + }); + + test( + "Dart haelt das Argument auch dann zusammen, wenn es eine Shell " + "baut", () async { + // Beleg fuer den korrigierten Kommentar im Code: mit + // `runInShell: true` space-joined Dart das argv nicht, es quotet jedes + // Element einzeln. Ohne diesen Beleg waere die Korrektur wieder eine + // Behauptung. + const String path = "/home/user/My Documents/x.txt"; + + final ProcessResult result = + await Process.run("/usr/bin/echo", [path], runInShell: true); + + expect(result.exitCode, 0); + expect(result.stdout.toString().trim(), path); + }); + + test("Shell-Metazeichen in einem Argument bleiben inert", () async { + // Der alte Kommentar behauptete, ein Metazeichen wuerde ausgefuehrt. + // Dart quotet es, also wird es nur durchgereicht. + final Directory sandbox = + Directory.systemTemp.createTempSync("mla_wp_s3_shell_"); + final String payload = "; touch ${sandbox.path}/pwned"; + addTearDown(() { + if (sandbox.existsSync()) sandbox.deleteSync(recursive: true); + }); + + final ProcessResult result = + await Process.run("/usr/bin/echo", [payload], runInShell: true); + + expect(result.exitCode, 0); + expect(result.stdout.toString().trim(), payload); + expect(File("${sandbox.path}/pwned").existsSync(), isFalse); + }); + }); + + group("Die Rekursionstiefe der Ordnersuche ist geklemmt", () { + late Directory sandbox; + + setUp(() { + sandbox = Directory.systemTemp.createTempSync("mla_wp_s3_depth_"); + ConfigHandler().resetForTesting(directory: sandbox); + }); + + tearDown(() { + ConfigHandler().resetForTesting(); + if (sandbox.existsSync()) sandbox.deleteSync(recursive: true); + }); + + test("eine zu kleine Tiefe wird auf 1 gehoben", () { + ConfigHandler().setValueUnsafe("folder_recursion_depth", 0); + + expect(Linux.folderRecursionDepth(), 1); + }); + + test("eine zu grosse Tiefe wird auf 10 gesenkt", () { + ConfigHandler().setValueUnsafe("folder_recursion_depth", 999); + + expect(Linux.folderRecursionDepth(), 10); + }); + + test("eine plausible Tiefe bleibt unveraendert", () { + ConfigHandler().setValueUnsafe("folder_recursion_depth", 3); + + expect(Linux.folderRecursionDepth(), 3); + }); + + test("ein Textwert landet im Fallback und loest nichts aus", () { + final Directory artifact = + Directory.systemTemp.createTempSync("mla_wp_s3_inject_"); + addTearDown(() { + if (artifact.existsSync()) artifact.deleteSync(recursive: true); + }); + ConfigHandler().setValueUnsafe( + "folder_recursion_depth", "'; touch ${artifact.path}/pwned"); + + // Kein TypeError (der alte `final int`-Cast), sondern der Default. + expect(Linux.folderRecursionDepth(), 3); + expect(File("${artifact.path}/pwned").existsSync(), isFalse); + }); + + test("fehlt der Config-Wert, gilt der Default", () { + expect(Linux.folderRecursionDepth(), kDefaultFolderRecursionDepth); + }); + + test("die Klemme nimmt eine Zahl als Text an", () { + expect(clampFolderRecursionDepth("5"), 5); + }); + + test("die Klemme verwirft Werte, die keine Zahl sind", () { + expect(clampFolderRecursionDepth(true), kDefaultFolderRecursionDepth); + expect(clampFolderRecursionDepth(3.5), kDefaultFolderRecursionDepth); + expect(clampFolderRecursionDepth(""), kDefaultFolderRecursionDepth); + expect(clampFolderRecursionDepth(null), kDefaultFolderRecursionDepth); + expect(clampFolderRecursionDepth([]), kDefaultFolderRecursionDepth); + }); + }); + + group("Der Disk-Analyzer startet nach der Installationspruefung", () { + late Environment originalEnvironment; + + setUp(() { + originalEnvironment = Linux.currentenvironment; + Linux.currentenvironment = Environment() + ..desktop = DESKTOPS.KDE + ..installedSoftwareManagers = [SOFTWARE_MANAGERS.APT]; + Linux.commandQueue.clear(); + }); + + tearDown(() { + Linux.currentenvironment = originalEnvironment; + Linux.commandQueue.clear(); + }); + + testWidgets( + "der Analyzer startet genau einmal, ohne Shell und nach dem Check", + (WidgetTester tester) async { + late BuildContext context; + await tester.pumpWidget(MaterialApp( + home: Builder(builder: (BuildContext inner) { + context = inner; + return const SizedBox.shrink(); + }), + )); + + await Linux.openDiskSpaceAnalyzer(context, "/mnt/data stick"); + + // Der Installationscheck lief vor dem Start. + expect(runner.commands, contains("/usr/bin/dpkg-query")); + final int checkIndex = runner.commands.indexOf("/usr/bin/dpkg-query"); + final int launchIndex = runner.commands.indexOf("/usr/bin/k4dirstat"); + expect(launchIndex, greaterThan(checkIndex)); + + // Genau ein Start — der alte Code startete bedingungslos und danach + // ein zweites Mal. + expect(runner.commands.where((String c) => c == "/usr/bin/k4dirstat"), + hasLength(1)); + + // Ohne Shell, und der Mountpoint ist ein Argument. + expect(runner.runInShellFlags[launchIndex], isFalse); + expect(runner.argumentLists[launchIndex], ["/mnt/data stick"]); + }); + }); +}