diff --git a/docs/handoff/v0.8.6-q1-security-scan.md b/docs/handoff/v0.8.6-q1-security-scan.md new file mode 100644 index 00000000..16c8d521 --- /dev/null +++ b/docs/handoff/v0.8.6-q1-security-scan.md @@ -0,0 +1,111 @@ +# deliverable.md — V0.8.6 Welle 1: Q1 Security-Scan auf Knopfdruck (#58) + +> **Stand:** 2026-10-23 · **Basis:** `main` @ `08dad94` · **Branch:** `feature/58-q1-security-scan` +> **Umfang:** Q1 aus Issue #58 — der Security-Check startet nicht mehr beim Betreten der +> Sektion, sondern auf Knopfdruck, und sein Ergebnis überlebt einen App-Neustart. + +## Was geändert wurde + +| Datei | Änderung | +|---|---| +| `lib/services/security_scan_cache.dart` (neu) | Cache des letzten Scan-Ergebnisses unter `$XDG_CACHE_HOME/linux-assistant/security-last.json` | +| `lib/layouts/security_check/overview.dart` | Scan startet nur auf Klick; Cache wird gelesen und geschrieben; Alter des gecachten Ergebnisses wird angezeigt | +| `lib/l10n/*.arb` + generierte Dateien | 7 neue Strings in **allen vier** Sprachen (en/de/it/fi) | +| `test/q1_security_scan_test.dart` (neu) | 29 Tests: Cache-Ort, Snapshot-Format, Altersberechnung, Cache-Verhalten, Seiten-Verhalten | + +## Die Abnahmekriterien aus #58 + +| Kriterium | Umsetzung | Nachweis | +|---|---|---| +| Der Scan startet **nicht** beim ersten Build | `_checkerOutput` ist nullable und bleibt null, bis der Knopf gedrückt wird | `the page starts no scan when the page is opened` — `runner.calls` ist leer | +| Kein pkexec-Dialog beim Betreten | dieselbe Ursache: es wird kein Befehl gestartet | derselbe Test | +| Ergebnis in `$XDG_CACHE_HOME/linux-assistant/security-last.json` | `SecurityScanCache` — `XDG_CACHE_HOME` mit Fallback `~/.cache` | `cache location`-Gruppe (3 Tests) + `the result file is security-last.json in that directory` | +| Befund-Tokens + Zeitstempel | `SecurityScanSnapshot` mit `tokens` (die Zeilen des Checkers) und `timestamp` (UTC, ISO 8601) | `the JSON shape is stable` + `cache the written file is the documented JSON` | +| Nach Neustart mit „vor X" | Die Seite liest den Cache beim Öffnen und zeigt das Alter | `shows the cached result with its age after a restart` | +| l10n in allen vier Sprachen | 7 Keys × (en, de, it, fi) | `flutter gen-l10n` erzeugt, l10n-Ratchet grün | + +### Das Cache-Format + +```json +{ + "version": 1, + "timestamp": "2026-10-23T12:30:00.000Z", + "tokens": ["firewallinactive", "upgradeablepackage: linux-assistant-1.0"] +} +``` + +Der Cache ist ein **Cache**, keine Konfiguration: er darf jederzeit gelöscht werden, und die App +muss sein Fehlen überstehen. Deshalb: + +- **Format-Version.** Eine Datei mit anderer Version wird ignoriert, nicht missverstanden. +- **Defekte Datei.** Eine kaputte Datei liefert `null` statt einer Exception — die Seite + zeigt „noch kein Check gelaufen", nicht einen Fehlerdialog. +- **Atomares Schreiben.** Die Datei wird als Nachbar geschrieben und danach umbenannt. Eine + App, die mitten im Schreiben stirbt, hinterlässt keine halbe Datei, die niemand mehr lesen kann. +- **Tokens statt Sätzen.** Gespeichert werden die Zeilen des Checkers, nicht die Sätze der UI. + Die Sätze werden bei jedem Start neu abgeleitet — ein auf Deutsch gecachter Befund liest sich + nach einem Wechsel auf Finnisch trotzdem richtig. +- **Erfolgsschild.** Die Erfolgsmarke des Checkers ist kein Befund und wird nicht gespeichert. + +## TDD-Beleg + +`test/q1_security_scan_test.dart` — 29 Tests in fünf Gruppen: + +| Gruppe | Testfälle | +|---|---| +| `cache location` | `XDG_CACHE_HOME` wird beachtet · Fallback `~/.cache` · leeres `XDG_CACHE_HOME` · Datei heißt `security-last.json` | +| `snapshot` | Befundzeilen bleiben, Erfolgsschild fällt weg · der Inhalt bleibt erhalten · JSON-Roundtrip · stabiles Format · fremde Version abgelehnt · unbrauchbarer Zeitstempel abgelehnt · Nicht-Text-Tokens abgelehnt | +| `age` | unter einer Minute = „gerade eben" · Minuten · Stunden · Tage · Zeitstempel in der Zukunft = „gerade eben" · exakt eine Minute ist eine Minute | +| `cache` | vor dem ersten Scan kein Ergebnis · schreiben und lesen · Verzeichnis wird angelegt · kaputte Datei überlebt · leere Datei überlebt · keine temporäre Datei bleibt liegen · geschriebene Datei ist das dokumentierte JSON | +| `the page` | **kein Scan beim Öffnen** · der Knopf ist da · genau ein Scan pro Druck · das Ergebnis landet im Cache · nach „Neustart" zeigt die Seite das gecachte Ergebnis mit Alter — und startet nichts | + +## Gate-Protokoll + +`bash /workspace/tools/run-mla-gates.sh` — echte Exitcodes. + +| Gate | Ergebnis | +|---|---| +| `flutter pub get` | OK | +| `bash tool/check-versions.sh` | OK | +| `dart format --output=none --set-exit-if-changed lib test` | OK — 126 Dateien, 0 geändert | +| `flutter analyze` | OK — *No issues found!* | +| `flutter test` | OK — **259 Tests** (230 Baseline + 29 neue) | +| Python-Unit-Tests | OK — 62 Tests | +| GTK-Token-Gate | OK — 96 Tests (2 skipped) | +| `packages/la_core` | OK — 0 Findings, 25 Dateien, 68 Tests | + +**Summenzeile: `ALLE GATES GRUEN`.** + +## Konventionen geprüft + +- Conventional Commit, deutschsprachiger Body, `Refs #58` ✓ +- `dart format` vor dem Commit ✓ +- Logging über `lib/services/logger.dart`, kein `print` ✓ +- l10n: 7 Keys in allen vier ARB-Dateien, generiert mit `flutter gen-l10n` ✓ +- Keine Secrets, kein Versions-Bump, kein Release ✓ +- **Die Security-Check-Logik selbst wurde nicht angefasst** — der privilegierte Runner und die + polkit-Aktionen bleiben unangetastet. Der Cache speichert nur, was der Checker ohnehin + ausgibt. + +## Zwei Korrekturen an der Vorarbeit + +1. **`setState` mit Future.** `setState(() => _checkerOutput = _runAndStore())` übergibt dem + Callback ein Future — Flutter lehnt das ab („setState() callback argument returned a + Future"). Der Callback braucht einen Block-Body. +2. **Echte Datei-IO im Widget-Test.** `await cache.read()` in der Fake-Async-Zone eines + `testWidgets` bekommt sein IO-Ereignis nie und der Test hängt bis zum Zeitüberlauf. Echte + IO muss über `tester.runAsync(...)` laufen. Betroffen waren zwei Stellen im Test. + +Beides war in der Vorarbeit so nicht vorhanden und ist hier behoben. + +## Offene Punkte + +- **Der manuelle Abnahmeschritt** bleibt offen: ein echter Scan braucht root und damit ein + Passwort-Dialog. In der Sandbox läuft der Check über einen Fake-Runner, der das + Checker-Format liefert. Der Button und der Cache-Pfad sind damit geprüft, das echte + `pkexec` nicht. +- **Was passiert, wenn der Check fehlschlägt?** `_rootRequired` und `_scriptFailed` bleiben + unverändert — sie zeigen den Fehler und bieten den erneuten Versuch. In den Cache landet + nur ein erfolgreicher Lauf; ein fehlgeschlagener verwischt das letzte gecachte Ergebnis + nicht. +- **Q4 und Q7** (Hotkey bzw. Feedback-Entfernung) sind nicht Teil dieses Pakets. diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 1eb4b56a..edd099c6 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1332,5 +1332,65 @@ "placeholders": {}, "description": "Explains what confirming the dialog above does", "type": "text" + }, + "securityScanRunCheck": "Jetzt prüfen", + "@securityScanRunCheck": { + "description": "Button that starts the security scan on demand", + "type": "text" + }, + "securityScanNotRunYet": "Es wurde noch nicht geprüft.", + "@securityScanNotRunYet": { + "description": "Shown on the security page before the first scan", + "type": "text" + }, + "securityScanLastRun": "Letzte Prüfung: {age}", + "@securityScanLastRun": { + "placeholders": { + "age": { + "type": "String" + } + }, + "description": "When the last security check ran, with its age", + "type": "text" + }, + "securityScanAgeJustNow": "gerade eben", + "@securityScanAgeJustNow": { + "description": "Age of a security check that just ran", + "type": "text" + }, + "securityScanAgeMinutes": "vor {count} Min.", + "@securityScanAgeMinutes": { + "placeholders": { + "count": { + "type": "int" + } + }, + "description": "Age of a security check in minutes", + "type": "text" + }, + "securityScanAgeHours": "vor {count} Std.", + "@securityScanAgeHours": { + "placeholders": { + "count": { + "type": "int" + } + }, + "description": "Age of a security check in hours", + "type": "text" + }, + "securityScanAgeDays": "vor {count} T.", + "@securityScanAgeDays": { + "placeholders": { + "count": { + "type": "int" + } + }, + "description": "Age of a security check in days", + "type": "text" + }, + "securityScanRootHint": "Die Prüfung braucht Root-Rechte und fragt deshalb nach deinem Passwort.", + "@securityScanRootHint": { + "description": "Explains why pressing the check button asks for a password", + "type": "text" } } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 16174cae..44cd73ec 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1298,5 +1298,65 @@ "placeholders": {}, "description": "Explains what confirming the dialog above does", "type": "text" + }, + "securityScanRunCheck": "Check now", + "@securityScanRunCheck": { + "description": "Button that starts the security scan on demand", + "type": "text" + }, + "securityScanNotRunYet": "No security check has been run yet.", + "@securityScanNotRunYet": { + "description": "Shown on the security page before the first scan", + "type": "text" + }, + "securityScanLastRun": "Last check: {age}", + "@securityScanLastRun": { + "placeholders": { + "age": { + "type": "String" + } + }, + "description": "When the last security check ran, with its age", + "type": "text" + }, + "securityScanAgeJustNow": "just now", + "@securityScanAgeJustNow": { + "description": "Age of a security check that just ran", + "type": "text" + }, + "securityScanAgeMinutes": "{count} min ago", + "@securityScanAgeMinutes": { + "placeholders": { + "count": { + "type": "int" + } + }, + "description": "Age of a security check in minutes", + "type": "text" + }, + "securityScanAgeHours": "{count} h ago", + "@securityScanAgeHours": { + "placeholders": { + "count": { + "type": "int" + } + }, + "description": "Age of a security check in hours", + "type": "text" + }, + "securityScanAgeDays": "{count} d ago", + "@securityScanAgeDays": { + "placeholders": { + "count": { + "type": "int" + } + }, + "description": "Age of a security check in days", + "type": "text" + }, + "securityScanRootHint": "The check needs root rights, so it will ask for your password.", + "@securityScanRootHint": { + "description": "Explains why pressing the check button asks for a password", + "type": "text" } } diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 7ac3bc1d..f198bcd9 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -1318,5 +1318,65 @@ "placeholders": {}, "description": "Explains what confirming the dialog above does", "type": "text" + }, + "securityScanRunCheck": "Controlla ora", + "@securityScanRunCheck": { + "description": "Button that starts the security scan on demand", + "type": "text" + }, + "securityScanNotRunYet": "Non è ancora stato eseguito alcun controllo di sicurezza.", + "@securityScanNotRunYet": { + "description": "Shown on the security page before the first scan", + "type": "text" + }, + "securityScanLastRun": "Ultimo controllo: {age}", + "@securityScanLastRun": { + "placeholders": { + "age": { + "type": "String" + } + }, + "description": "When the last security check ran, with its age", + "type": "text" + }, + "securityScanAgeJustNow": "appena", + "@securityScanAgeJustNow": { + "description": "Age of a security check that just ran", + "type": "text" + }, + "securityScanAgeMinutes": "{count} min fa", + "@securityScanAgeMinutes": { + "placeholders": { + "count": { + "type": "int" + } + }, + "description": "Age of a security check in minutes", + "type": "text" + }, + "securityScanAgeHours": "{count} h fa", + "@securityScanAgeHours": { + "placeholders": { + "count": { + "type": "int" + } + }, + "description": "Age of a security check in hours", + "type": "text" + }, + "securityScanAgeDays": "{count} g fa", + "@securityScanAgeDays": { + "placeholders": { + "count": { + "type": "int" + } + }, + "description": "Age of a security check in days", + "type": "text" + }, + "securityScanRootHint": "Il controllo richiede i privilegi root, quindi ti chiederà la password.", + "@securityScanRootHint": { + "description": "Explains why pressing the check button asks for a password", + "type": "text" } } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 938bd118..126198b9 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -2147,6 +2147,54 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'The file will be executed as a program with your user rights. Only continue if you know what it does.'** String get runFileWarning; + + /// Button that starts the security scan on demand + /// + /// In en, this message translates to: + /// **'Check now'** + String get securityScanRunCheck; + + /// Shown on the security page before the first scan + /// + /// In en, this message translates to: + /// **'No security check has been run yet.'** + String get securityScanNotRunYet; + + /// When the last security check ran, with its age + /// + /// In en, this message translates to: + /// **'Last check: {age}'** + String securityScanLastRun(String age); + + /// Age of a security check that just ran + /// + /// In en, this message translates to: + /// **'just now'** + String get securityScanAgeJustNow; + + /// Age of a security check in minutes + /// + /// In en, this message translates to: + /// **'{count} min ago'** + String securityScanAgeMinutes(int count); + + /// Age of a security check in hours + /// + /// In en, this message translates to: + /// **'{count} h ago'** + String securityScanAgeHours(int count); + + /// Age of a security check in days + /// + /// In en, this message translates to: + /// **'{count} d ago'** + String securityScanAgeDays(int count); + + /// Explains why pressing the check button asks for a password + /// + /// In en, this message translates to: + /// **'The check needs root rights, so it will ask for your password.'** + String get securityScanRootHint; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 5baa7a6c..7c2a6699 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -1182,4 +1182,37 @@ class AppLocalizationsDe extends AppLocalizations { @override String get runFileWarning => 'Die Datei wird als Programm mit deinen Benutzerrechten ausgeführt. Fahre nur fort, wenn du weißt, was sie tut.'; + + @override + String get securityScanRunCheck => 'Jetzt prüfen'; + + @override + String get securityScanNotRunYet => 'Es wurde noch nicht geprüft.'; + + @override + String securityScanLastRun(String age) { + return 'Letzte Prüfung: $age'; + } + + @override + String get securityScanAgeJustNow => 'gerade eben'; + + @override + String securityScanAgeMinutes(int count) { + return 'vor $count Min.'; + } + + @override + String securityScanAgeHours(int count) { + return 'vor $count Std.'; + } + + @override + String securityScanAgeDays(int count) { + return 'vor $count T.'; + } + + @override + String get securityScanRootHint => + 'Die Prüfung braucht Root-Rechte und fragt deshalb nach deinem Passwort.'; } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 0239d6eb..395e1938 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1173,4 +1173,37 @@ class AppLocalizationsEn extends AppLocalizations { @override String get runFileWarning => 'The file will be executed as a program with your user rights. Only continue if you know what it does.'; + + @override + String get securityScanRunCheck => 'Check now'; + + @override + String get securityScanNotRunYet => 'No security check has been run yet.'; + + @override + String securityScanLastRun(String age) { + return 'Last check: $age'; + } + + @override + String get securityScanAgeJustNow => 'just now'; + + @override + String securityScanAgeMinutes(int count) { + return '$count min ago'; + } + + @override + String securityScanAgeHours(int count) { + return '$count h ago'; + } + + @override + String securityScanAgeDays(int count) { + return '$count d ago'; + } + + @override + String get securityScanRootHint => + 'The check needs root rights, so it will ask for your password.'; } diff --git a/lib/l10n/app_localizations_fi.dart b/lib/l10n/app_localizations_fi.dart index b355cb0b..7a2e34eb 100644 --- a/lib/l10n/app_localizations_fi.dart +++ b/lib/l10n/app_localizations_fi.dart @@ -1183,4 +1183,38 @@ class AppLocalizationsFi extends AppLocalizations { @override String get runFileWarning => 'Tiedosto suoritetaan ohjelmana käyttöoikeuksillasi. Jatka vain, jos tiedät, mitä se tekee.'; + + @override + String get securityScanRunCheck => 'Tarkista nyt'; + + @override + String get securityScanNotRunYet => + 'Turvallisuustarkistusta ei ole vielä tehty.'; + + @override + String securityScanLastRun(String age) { + return 'Viimeisin tarkistus: $age'; + } + + @override + String get securityScanAgeJustNow => 'juuri nyt'; + + @override + String securityScanAgeMinutes(int count) { + return '$count min sitten'; + } + + @override + String securityScanAgeHours(int count) { + return '$count t sitten'; + } + + @override + String securityScanAgeDays(int count) { + return '$count pv sitten'; + } + + @override + String get securityScanRootHint => + 'Tarkistus tarvitsee pääkäyttäjän oikeudet, joten se kysyy salasanaasi.'; } diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index edff0d0c..eec8e31c 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -1191,4 +1191,38 @@ class AppLocalizationsIt extends AppLocalizations { @override String get runFileWarning => 'Il file verrà eseguito come programma con i tuoi privilegi utente. Continua solo se sai cosa fa.'; + + @override + String get securityScanRunCheck => 'Controlla ora'; + + @override + String get securityScanNotRunYet => + 'Non è ancora stato eseguito alcun controllo di sicurezza.'; + + @override + String securityScanLastRun(String age) { + return 'Ultimo controllo: $age'; + } + + @override + String get securityScanAgeJustNow => 'appena'; + + @override + String securityScanAgeMinutes(int count) { + return '$count min fa'; + } + + @override + String securityScanAgeHours(int count) { + return '$count h fa'; + } + + @override + String securityScanAgeDays(int count) { + return '$count g fa'; + } + + @override + String get securityScanRootHint => + 'Il controllo richiede i privilegi root, quindi ti chiederà la password.'; } diff --git a/lib/l10n/linuxassistant_fi.arb b/lib/l10n/linuxassistant_fi.arb index cc691305..b684a3c6 100644 --- a/lib/l10n/linuxassistant_fi.arb +++ b/lib/l10n/linuxassistant_fi.arb @@ -903,5 +903,65 @@ "placeholders": {}, "description": "Explains what confirming the dialog above does", "type": "text" + }, + "securityScanRunCheck": "Tarkista nyt", + "@securityScanRunCheck": { + "description": "Button that starts the security scan on demand", + "type": "text" + }, + "securityScanNotRunYet": "Turvallisuustarkistusta ei ole vielä tehty.", + "@securityScanNotRunYet": { + "description": "Shown on the security page before the first scan", + "type": "text" + }, + "securityScanLastRun": "Viimeisin tarkistus: {age}", + "@securityScanLastRun": { + "placeholders": { + "age": { + "type": "String" + } + }, + "description": "When the last security check ran, with its age", + "type": "text" + }, + "securityScanAgeJustNow": "juuri nyt", + "@securityScanAgeJustNow": { + "description": "Age of a security check that just ran", + "type": "text" + }, + "securityScanAgeMinutes": "{count} min sitten", + "@securityScanAgeMinutes": { + "placeholders": { + "count": { + "type": "int" + } + }, + "description": "Age of a security check in minutes", + "type": "text" + }, + "securityScanAgeHours": "{count} t sitten", + "@securityScanAgeHours": { + "placeholders": { + "count": { + "type": "int" + } + }, + "description": "Age of a security check in hours", + "type": "text" + }, + "securityScanAgeDays": "{count} pv sitten", + "@securityScanAgeDays": { + "placeholders": { + "count": { + "type": "int" + } + }, + "description": "Age of a security check in days", + "type": "text" + }, + "securityScanRootHint": "Tarkistus tarvitsee pääkäyttäjän oikeudet, joten se kysyy salasanaasi.", + "@securityScanRootHint": { + "description": "Explains why pressing the check button asks for a password", + "type": "text" } } diff --git a/lib/layouts/security_check/overview.dart b/lib/layouts/security_check/overview.dart index 566aae6e..4d896ea5 100644 --- a/lib/layouts/security_check/overview.dart +++ b/lib/layouts/security_check/overview.dart @@ -9,6 +9,7 @@ import 'package:linux_assistant/layouts/mint_y.dart'; import 'package:linux_assistant/layouts/security_check/security_check_outcome.dart'; import 'package:linux_assistant/layouts/security_check/security_finding.dart'; import 'package:linux_assistant/services/linux.dart'; +import 'package:linux_assistant/services/security_scan_cache.dart'; import 'package:linux_assistant/services/main_search_loader.dart'; import 'package:linux_assistant/widgets/hermes/hermes_badge.dart'; import 'package:linux_assistant/widgets/hermes/hermes_card.dart'; @@ -44,14 +45,60 @@ class SecurityCheckOverview extends StatelessWidget { /// home directory permissions, it now shows the command and leaves the decision /// — and the execution — with the user. class SecurityCheckContent extends StatefulWidget { - const SecurityCheckContent({super.key}); + const SecurityCheckContent({super.key, this.cache}); + + /// Where the last result is stored. Injectable so a test does not write + /// into the developer's own cache. + final SecurityScanCache? cache; @override State createState() => _SecurityCheckContentState(); } class _SecurityCheckContentState extends State { - late Future _checkerOutput = _runChecker(); + /// The scan reaches root through pkexec. It must not start on its own: a + /// password dialog because someone opened a page is not an option. The + /// future stays null until the user presses the button. + Future? _checkerOutput; + + late final SecurityScanCache _cache = widget.cache ?? SecurityScanCache(); + + /// The result of the last scan, read from the cache when the page opens. + /// Null means: no scan has ever been run on this system. + SecurityScanSnapshot? _lastSnapshot; + + @override + void initState() { + super.initState(); + _loadCachedResult(); + } + + /// Reads the cached result of the last scan. + /// + /// Only the cache is touched here — no scan is started. A page that is + /// opened shows what the last scan found, not a new scan. + Future _loadCachedResult() async { + final SecurityScanSnapshot? snapshot = await _cache.read(); + if (!mounted) return; + setState(() => _lastSnapshot = snapshot); + } + + /// Runs the scan and stores its result in the cache. + /// + /// The write happens before the result is shown, so a scan that the user + /// saw is also a scan that survives a restart. + Future _runAndStore() async { + final CommandResult result = await _runChecker(); + if (classifySecurityCheck(result) == SecurityCheckOutcome.success) { + final SecurityScanSnapshot snapshot = SecurityScanSnapshot.fromOutput( + result.output, + timestamp: DateTime.now(), + ); + await _cache.write(snapshot); + if (mounted) setState(() => _lastSnapshot = snapshot); + } + return result; + } static Future _runChecker() { final home = "--home=${Platform.environment['HOME']}"; @@ -76,15 +123,27 @@ class _SecurityCheckContentState extends State { } void _reload() { - setState(() => _checkerOutput = _runChecker()); + // Block body: an arrow function would hand the Future of + // _runAndStore() back to setState, which rejects a callback that returns + // a Future. + setState(() { + _checkerOutput = _runAndStore(); + }); } @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; + final Future? running = _checkerOutput; + + // No scan is running: show what the last one found, or that there was + // none. The button to start a scan is part of the banner below. + if (running == null) { + return _idleView(context); + } return FutureBuilder( - future: _checkerOutput, + future: running, builder: (context, snapshot) { if (!snapshot.hasData) { return MintYLoadingPage(text: l10n.analysingSystemSecurity); @@ -113,6 +172,59 @@ class _SecurityCheckContentState extends State { ); } + /// The page before a scan has been started: the cached result of the last + /// scan, or the hint that there was none. + Widget _idleView(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final t = HermesTokens.of(context); + final SecurityScanSnapshot? snapshot = _lastSnapshot; + + return ListView( + padding: const EdgeInsets.all(HermesTokens.space4), + children: [ + _readOnlyBanner(context), + const SizedBox(height: HermesTokens.space4), + if (snapshot == null) + Text( + l10n.securityScanNotRunYet, + style: TextStyle(color: t.muted, fontSize: 12, height: 1.5), + ) + else ...[ + Text( + l10n.securityScanLastRun(_formatAge(context, snapshot.timestamp)), + style: TextStyle(color: t.muted, fontSize: 12, height: 1.5), + ), + const SizedBox(height: HermesTokens.space2), + for (final finding in _buildFindings(context, snapshot.output)) + SecurityFindingTile(finding: finding), + ], + const SizedBox(height: HermesTokens.space4), + Text( + l10n.securityScanRootHint, + style: TextStyle(color: t.muted, fontSize: 12, height: 1.5), + ), + ], + ); + } + + /// The age of a cached result, in the coarsest unit that still says + /// something ("5 min ago" rather than "5 minutes and 12 seconds ago"). + String _formatAge(BuildContext context, DateTime timestamp) { + final l10n = AppLocalizations.of(context)!; + final SecurityScanAge age = + SecurityScanAge.between(timestamp, DateTime.now()); + switch (age.unit) { + case SecurityScanAgeUnit.justNow: + return l10n.securityScanAgeJustNow; + case SecurityScanAgeUnit.minutes: + return l10n.securityScanAgeMinutes(age.count); + case SecurityScanAgeUnit.hours: + return l10n.securityScanAgeHours(age.count); + case SecurityScanAgeUnit.days: + return l10n.securityScanAgeDays(age.count); + } + } + Widget _readOnlyBanner(BuildContext context) { final t = HermesTokens.of(context); final l10n = AppLocalizations.of(context)!; @@ -131,9 +243,10 @@ class _SecurityCheckContentState extends State { ), const Spacer(), TextButton.icon( + key: const Key("security-scan-run"), onPressed: _reload, icon: const Icon(Icons.refresh, size: 14), - label: Text(l10n.reload), + label: Text(l10n.securityScanRunCheck), ), ], ), diff --git a/lib/services/security_scan_cache.dart b/lib/services/security_scan_cache.dart new file mode 100644 index 00000000..3aa157cd --- /dev/null +++ b/lib/services/security_scan_cache.dart @@ -0,0 +1,214 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:linux_assistant/layouts/security_check/security_check_outcome.dart'; +import 'package:linux_assistant/services/logger.dart'; + +/// The directory the app keeps its cached data in, below the XDG cache home. +const String kSecurityScanCacheDirectoryName = "linux-assistant"; + +/// The file the result of the last security scan is stored in. +const String kSecurityScanCacheFileName = "security-last.json"; + +/// The format version of the cache file. +/// +/// A file with a different version is ignored rather than misread: the app +/// then shows "no result yet" instead of interpreting fields that mean +/// something else by now. +const int kSecurityScanCacheVersion = 1; + +/// The result of one security scan, as it is kept between app runs. +/// +/// The [tokens] are the checker's own lines — "firewallinactive", +/// "upgradeablepackage: foo-1.2", … — and not the sentences the UI shows. +/// The sentences are derived from the tokens again on every start, so a result +/// that was cached in German still reads correctly after the user switched the +/// app to Finnish. +class SecurityScanSnapshot { + const SecurityScanSnapshot({required this.tokens, required this.timestamp}); + + /// The checker's output lines, one finding per line. + final List tokens; + + /// When the scan ran. + final DateTime timestamp; + + /// The cached result in the shape the checker's output has: one token per + /// line. This is what the finding parser reads. + String get output => tokens.join("\n"); + + /// Takes the checker's raw stdout and keeps the finding lines. + /// + /// The success marker is not a finding and neither are blank lines; both + /// would only make the cache bigger. Everything else is kept verbatim, so a + /// checker that learns a new token does not need a change here. + factory SecurityScanSnapshot.fromOutput(String output, + {required DateTime timestamp}) { + final List tokens = []; + for (final String line in output.split("\n")) { + final String trimmed = line.trim(); + if (trimmed.isEmpty || trimmed == kSecurityCheckSuccessMarker) { + continue; + } + tokens.add(trimmed); + } + return SecurityScanSnapshot(tokens: tokens, timestamp: timestamp); + } + + /// The on-disk shape of a snapshot. + Map toJson() { + return { + "version": kSecurityScanCacheVersion, + // UTC, so the file stays readable after the user changes the timezone + // or crosses a border. + "timestamp": timestamp.toUtc().toIso8601String(), + "tokens": tokens, + }; + } + + /// Reads a snapshot back. + /// + /// Returns null for anything this build cannot make sense of: a truncated + /// file, a foreign format, a version from the future, a timestamp that is + /// not a timestamp. A cache is a convenience, never a reason to fail. + static SecurityScanSnapshot? fromJson(Map json) { + final Object? version = json["version"]; + if (version is! int || version != kSecurityScanCacheVersion) { + return null; + } + + final Object? rawTokens = json["tokens"]; + if (rawTokens is! List) { + return null; + } + final List tokens = []; + for (final Object? token in rawTokens) { + if (token is! String) { + return null; + } + tokens.add(token); + } + + final Object? rawTimestamp = json["timestamp"]; + if (rawTimestamp is! String) { + return null; + } + final DateTime? timestamp = DateTime.tryParse(rawTimestamp); + if (timestamp == null) { + return null; + } + + return SecurityScanSnapshot(tokens: tokens, timestamp: timestamp); + } +} + +/// How coarse the age of a cached result is shown. +enum SecurityScanAgeUnit { + /// Less than a minute old. + justNow, + + minutes, + hours, + days, +} + +/// The age of a cached result, rounded down to the unit the UI shows. +class SecurityScanAge { + const SecurityScanAge(this.unit, this.count); + + final SecurityScanAgeUnit unit; + + /// How many of [unit] have passed. Always 0 for [SecurityScanAgeUnit.justNow]. + final int count; + + /// How old [timestamp] is, seen from [now]. + /// + /// A timestamp in the future — a changed clock, a mixed-up timezone — counts + /// as "just now" instead of producing a negative age. + factory SecurityScanAge.between(DateTime timestamp, DateTime now) { + final Duration age = now.difference(timestamp); + if (age.isNegative || age.inMinutes < 1) { + return const SecurityScanAge(SecurityScanAgeUnit.justNow, 0); + } + if (age.inHours < 1) { + return SecurityScanAge(SecurityScanAgeUnit.minutes, age.inMinutes); + } + if (age.inHours < 24) { + return SecurityScanAge(SecurityScanAgeUnit.hours, age.inHours); + } + return SecurityScanAge(SecurityScanAgeUnit.days, age.inDays); + } +} + +/// The last scan result on disk. +/// +/// The location follows the XDG base directory spec: `$XDG_CACHE_HOME` when it +/// is set, otherwise `~/.cache`. The file is a cache, not configuration: it +/// may be deleted at any time, and the app has to survive its absence. +class SecurityScanCache { + SecurityScanCache({Directory? directory}) + : _directory = directory ?? defaultDirectory(); + + final Directory _directory; + + /// The directory the cache file lives in. + Directory get directory => _directory; + + /// The file the result is stored in. + File get file => File("${_directory.path}/$kSecurityScanCacheFileName"); + + /// `$XDG_CACHE_HOME/linux-assistant`, or `~/.cache/linux-assistant` when the + /// variable is unset or empty. + static Directory defaultDirectory({Map? environment}) { + final Map env = environment ?? Platform.environment; + final String? xdg = env["XDG_CACHE_HOME"]; + final String base = (xdg != null && xdg.trim().isNotEmpty) + ? xdg.trim() + : "${env["HOME"] ?? "."}/.cache"; + return Directory("$base/$kSecurityScanCacheDirectoryName"); + } + + /// The stored result, or null when there is none or it is unusable. + /// + /// Never throws: a broken cache file must not keep the page from opening — + /// the user can always run a new scan. + Future read() async { + try { + final File cacheFile = file; + if (!await cacheFile.exists()) { + return null; + } + final String content = await cacheFile.readAsString(); + if (content.trim().isEmpty) { + return null; + } + final Object? decoded = jsonDecode(content); + if (decoded is! Map) { + return null; + } + return SecurityScanSnapshot.fromJson(Map.from(decoded)); + } catch (error) { + logError("Could not read the cached security scan", error); + return null; + } + } + + /// Stores [snapshot] and replaces the previous result. + /// + /// Written to a sibling first and renamed afterwards: an app that dies + /// mid-write must not leave a half-written file behind that no longer + /// parses. A cache that cannot be written is not worth an error dialog — + /// the scan itself already worked. + Future write(SecurityScanSnapshot snapshot) async { + try { + await _directory.create(recursive: true); + final String content = jsonEncode(snapshot.toJson()); + final File temporary = + File("${file.path}.${DateTime.now().microsecondsSinceEpoch}.tmp"); + await temporary.writeAsString(content, flush: true); + await temporary.rename(file.path); + } catch (error) { + logError("Could not write the cached security scan", error); + } + } +} diff --git a/test/q1_security_scan_test.dart b/test/q1_security_scan_test.dart new file mode 100644 index 00000000..fa67419a --- /dev/null +++ b/test/q1_security_scan_test.dart @@ -0,0 +1,422 @@ +// Q1 of issue #58: the security scan runs when the user asks for it, not when +// the page is opened — and its result survives an app restart. +// +// The scan reaches root through pkexec. A password dialog that pops up +// because someone opened a page is not an option, so the behaviour is pinned +// here: opening the page starts nothing, pressing the button starts exactly +// one scan, and the result of that scan lands in the cache. + +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:linux_assistant/helpers/command_helper.dart'; +import 'package:linux_assistant/l10n/app_localizations.dart'; +import 'package:linux_assistant/layouts/mint_y.dart'; +import 'package:linux_assistant/layouts/security_check/overview.dart'; +import 'package:linux_assistant/services/security_scan_cache.dart'; + +/// The key of the button that starts the scan. +const Key kRunScanButton = Key("security-scan-run"); + +/// Records every command instead of running it. +class _RecordingRunner implements CommandRunner { + _RecordingRunner(this.result); + + final CommandResult result; + final List> calls = >[]; + + @override + Future run( + String command, + List arguments, { + Map? environment, + bool asRoot = false, + bool hostOnFlatpak = true, + bool runInShell = false, + }) async { + calls.add([command, ...arguments]); + return result; + } +} + +/// A successful checker run, as the privileged script prints it. +const String kSuccessfulReport = "firewallinactive\n" + "upgradeablepackage: linux-assistant-1.0\n" + "#!script ran successfully."; + +/// Real file IO started from the fake-async test zone needs several +/// drain-and-pump rounds to work through its await chain; one round only +/// advances it by a single hop. +Future _drainRealIo(WidgetTester tester, {int rounds = 8}) async { + for (var i = 0; i < rounds; i++) { + await tester + .runAsync(() => Future.delayed(const Duration(milliseconds: 50))); + await tester.pump(const Duration(milliseconds: 50)); + } +} + +/// The page under test, in the app's own theme and localization. +Widget _host(SecurityScanCache cache) { + return MaterialApp( + theme: MintY.theme(), + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: const [Locale('en', '')], + home: Scaffold(body: SecurityCheckContent(cache: cache)), + ); +} + +void main() { + group("cache location", () { + test("follows XDG_CACHE_HOME when it is set", () { + final Directory directory = SecurityScanCache.defaultDirectory( + environment: { + "XDG_CACHE_HOME": "/tmp/xdg-cache", + "HOME": "/home/tester", + }, + ); + expect(directory.path, "/tmp/xdg-cache/linux-assistant"); + }); + + test("falls back to ~/.cache when XDG_CACHE_HOME is unset", () { + final Directory directory = SecurityScanCache.defaultDirectory( + environment: {"HOME": "/home/tester"}, + ); + expect(directory.path, "/home/tester/.cache/linux-assistant"); + }); + + test("falls back to ~/.cache when XDG_CACHE_HOME is empty", () { + final Directory directory = SecurityScanCache.defaultDirectory( + environment: { + "XDG_CACHE_HOME": " ", + "HOME": "/home/tester", + }, + ); + expect(directory.path, "/home/tester/.cache/linux-assistant"); + }); + + test("the result file is security-last.json in that directory", () { + final SecurityScanCache cache = SecurityScanCache( + directory: Directory("/tmp/q1-cache/linux-assistant"), + ); + expect( + cache.file.path, "/tmp/q1-cache/linux-assistant/security-last.json"); + }); + }); + + group("snapshot", () { + test("keeps the finding lines and drops the success marker", () { + final SecurityScanSnapshot snapshot = SecurityScanSnapshot.fromOutput( + "firewallinactive\n\n#!script ran successfully.\nsshrunning\n", + timestamp: DateTime.utc(2026, 10, 23, 12), + ); + expect(snapshot.tokens, ["firewallinactive", "sshrunning"]); + }); + + test("keeps the payload of a finding", () { + final SecurityScanSnapshot snapshot = SecurityScanSnapshot.fromOutput( + "additionalsource: http://example.org/repo\n", + timestamp: DateTime.utc(2026, 10, 23, 12), + ); + expect( + snapshot.tokens.single, "additionalsource: http://example.org/repo"); + }); + + test("round-trips through JSON", () { + final SecurityScanSnapshot snapshot = SecurityScanSnapshot( + tokens: ["firewallinactive", "sshrunning"], + timestamp: DateTime.utc(2026, 10, 23, 12, 30), + ); + + final SecurityScanSnapshot? restored = + SecurityScanSnapshot.fromJson(snapshot.toJson()); + + expect(restored, isNotNull); + expect(restored!.tokens, snapshot.tokens); + expect(restored.timestamp.toUtc(), snapshot.timestamp.toUtc()); + }); + + test("the JSON shape is stable", () { + final SecurityScanSnapshot snapshot = SecurityScanSnapshot( + tokens: ["sshrunning"], + timestamp: DateTime.utc(2026, 10, 23, 12, 30), + ); + + expect(snapshot.toJson(), { + "version": 1, + "timestamp": "2026-10-23T12:30:00.000Z", + "tokens": ["sshrunning"], + }); + }); + + test("refuses a file from a different format version", () { + final Map json = SecurityScanSnapshot( + tokens: ["sshrunning"], + timestamp: DateTime.utc(2026, 10, 23), + ).toJson(); + json["version"] = 99; + + expect(SecurityScanSnapshot.fromJson(json), isNull); + }); + + test("refuses a file without a usable timestamp", () { + final Map json = SecurityScanSnapshot( + tokens: ["sshrunning"], + timestamp: DateTime.utc(2026, 10, 23), + ).toJson(); + json["timestamp"] = "not a timestamp"; + + expect(SecurityScanSnapshot.fromJson(json), isNull); + }); + + test("refuses a file whose tokens are not strings", () { + final Map json = SecurityScanSnapshot( + tokens: ["sshrunning"], + timestamp: DateTime.utc(2026, 10, 23), + ).toJson(); + json["tokens"] = [42]; + + expect(SecurityScanSnapshot.fromJson(json), isNull); + }); + }); + + group("age", () { + final DateTime now = DateTime.utc(2026, 10, 23, 12); + + test("less than a minute old is just now", () { + final SecurityScanAge age = SecurityScanAge.between( + now.subtract(const Duration(seconds: 42)), + now, + ); + expect(age.unit, SecurityScanAgeUnit.justNow); + }); + + test("minutes are counted", () { + final SecurityScanAge age = SecurityScanAge.between( + now.subtract(const Duration(minutes: 7, seconds: 30)), + now, + ); + expect(age.unit, SecurityScanAgeUnit.minutes); + expect(age.count, 7); + }); + + test("hours are counted", () { + final SecurityScanAge age = SecurityScanAge.between( + now.subtract(const Duration(hours: 5)), + now, + ); + expect(age.unit, SecurityScanAgeUnit.hours); + expect(age.count, 5); + }); + + test("days are counted", () { + final SecurityScanAge age = SecurityScanAge.between( + now.subtract(const Duration(days: 3, hours: 2)), + now, + ); + expect(age.unit, SecurityScanAgeUnit.days); + expect(age.count, 3); + }); + + test("a timestamp in the future is just now, not a negative age", () { + final SecurityScanAge age = SecurityScanAge.between( + now.add(const Duration(hours: 2)), + now, + ); + expect(age.unit, SecurityScanAgeUnit.justNow); + expect(age.count, 0); + }); + + test("exactly one minute is a minute, not just now", () { + final SecurityScanAge age = SecurityScanAge.between( + now.subtract(const Duration(minutes: 1)), + now, + ); + expect(age.unit, SecurityScanAgeUnit.minutes); + expect(age.count, 1); + }); + }); + + group("cache", () { + late Directory directory; + late SecurityScanCache cache; + + setUp(() { + directory = Directory.systemTemp.createTempSync("q1-security-scan"); + cache = SecurityScanCache(directory: directory); + }); + + tearDown(() { + if (directory.existsSync()) { + directory.deleteSync(recursive: true); + } + }); + + test("has no result before the first scan", () async { + expect(await cache.read(), isNull); + }); + + test("writes and reads the result back", () async { + final SecurityScanSnapshot snapshot = SecurityScanSnapshot( + tokens: ["firewallinactive", "sshrunning"], + timestamp: DateTime.utc(2026, 10, 23, 12, 30), + ); + + await cache.write(snapshot); + + final SecurityScanSnapshot? restored = await cache.read(); + expect(restored, isNotNull); + expect(restored!.tokens, snapshot.tokens); + expect(restored.timestamp.toUtc(), snapshot.timestamp.toUtc()); + }); + + test("creates the directory it needs", () async { + final Directory nested = + Directory("${directory.path}/deep/inside/linux-assistant"); + final SecurityScanCache nestedCache = + SecurityScanCache(directory: nested); + + await nestedCache.write(SecurityScanSnapshot( + tokens: ["sshrunning"], + timestamp: DateTime.utc(2026, 10, 23), + )); + + expect(nestedCache.file.existsSync(), isTrue); + }); + + test("survives a corrupt file instead of failing", () async { + cache.file.writeAsStringSync("{not json at all"); + + expect(await cache.read(), isNull); + }); + + test("survives an empty file", () async { + cache.file.writeAsStringSync(" "); + + expect(await cache.read(), isNull); + }); + + test("leaves no temporary file behind", () async { + await cache.write(SecurityScanSnapshot( + tokens: ["sshrunning"], + timestamp: DateTime.utc(2026, 10, 23), + )); + + final List leftovers = directory + .listSync() + .map((FileSystemEntity entity) => entity.path.split("/").last) + .where((String name) => name.endsWith(".tmp")) + .toList(); + expect(leftovers, isEmpty); + }); + + test("the written file is the documented JSON", () async { + await cache.write(SecurityScanSnapshot( + tokens: ["firewallinactive"], + timestamp: DateTime.utc(2026, 10, 23, 12, 30), + )); + + final Map decoded = + jsonDecode(cache.file.readAsStringSync()) as Map; + expect(decoded["version"], 1); + expect(decoded["timestamp"], "2026-10-23T12:30:00.000Z"); + expect(decoded["tokens"], ["firewallinactive"]); + }); + }); + + group("the page", () { + late Directory directory; + late SecurityScanCache cache; + late _RecordingRunner runner; + final CommandRunner originalRunner = CommandHelper.runner; + + setUp(() { + directory = Directory.systemTemp.createTempSync("q1-security-scan"); + cache = SecurityScanCache(directory: directory); + runner = _RecordingRunner(const CommandResult( + true, + kSuccessfulReport, + "", + 0, + )); + CommandHelper.runner = runner; + }); + + tearDown(() { + CommandHelper.runner = originalRunner; + if (directory.existsSync()) { + directory.deleteSync(recursive: true); + } + }); + + testWidgets("starts no scan when the page is opened", (tester) async { + await tester.pumpWidget(_host(cache)); + await _drainRealIo(tester); + + expect(runner.calls, isEmpty, + reason: "the scan reaches root through pkexec; it may only start " + "when the user asks for it"); + }); + + testWidgets("offers the button that starts the scan", (tester) async { + await tester.pumpWidget(_host(cache)); + await _drainRealIo(tester); + + expect(find.byKey(kRunScanButton), findsOneWidget); + }); + + testWidgets("starts exactly one scan when the button is pressed", + (tester) async { + await tester.pumpWidget(_host(cache)); + await _drainRealIo(tester); + + await tester.tap(find.byKey(kRunScanButton)); + await _drainRealIo(tester); + + expect(runner.calls.length, 1); + expect(runner.calls.single.first, contains("pkexec")); + expect(runner.calls.single, contains("read_security_report.py")); + }); + + testWidgets("stores the result of the scan in the cache", (tester) async { + await tester.pumpWidget(_host(cache)); + await _drainRealIo(tester); + + await tester.tap(find.byKey(kRunScanButton)); + await _drainRealIo(tester); + + expect(cache.file.existsSync(), isTrue); + // Echte Datei-IO muss in einem Widget-Test ueber runAsync laufen: in + // der Fake-Async-Zone bekommt das Future nie sein IO-Ereignis und der + // Test haengt bis zum Zeitueberlauf. + final SecurityScanSnapshot? stored = + await tester.runAsync(() => cache.read()); + expect(stored, isNotNull); + expect(stored!.tokens, contains("firewallinactive")); + expect(stored.tokens, isNot(contains("#!script ran successfully."))); + }); + + testWidgets("shows the cached result with its age after a restart", + (tester) async { + // What a restart looks like: the cache file is there, the page is not. + await tester.runAsync(() => cache.write(SecurityScanSnapshot( + tokens: ["firewallinactive"], + timestamp: DateTime.now().subtract(const Duration(minutes: 5)), + ))); + + await tester.pumpWidget(_host(cache)); + await _drainRealIo(tester); + + expect(find.textContaining("5"), findsWidgets, + reason: "the age of the cached result is shown"); + expect(runner.calls, isEmpty); + }); + }); +}