From 203d05816f536c81dae95bd9be1e96e1b60b200d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kov=C3=A1cs=20B=C3=A1lint=20Hunor?= Date: Tue, 6 Oct 2026 10:03:27 +0300 Subject: [PATCH] Follow up on grouped virtualization and service menu reviews Clamp the placeholder line index, reset anchor restore when the ScrollViewer is gone, reject control characters hidden by padded group headers, and update the launching threat model row. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01LeKXJc3DnK3PzAqwgA72NY --- docs/linux-port/threat-model-launching.md | 2 +- .../GroupedVirtualizingWrapGrid.cs | 7 ++++++- src/Files.Platform.Linux/Mime/DesktopEntryParser.cs | 6 ++++++ tests/Files.Platform.Tests/Launching/OpenDecisionTests.cs | 2 ++ 4 files changed, 15 insertions(+), 2 deletions(-) diff --git a/docs/linux-port/threat-model-launching.md b/docs/linux-port/threat-model-launching.md index cf4d4cc282f0..620702827c93 100644 --- a/docs/linux-port/threat-model-launching.md +++ b/docs/linux-port/threat-model-launching.md @@ -11,7 +11,7 @@ Attacker: controls a file's name, content, mode bits and symlinks (downloaded ar | 1 | Decision from file bytes (ELF / `#!` magic), not name or MIME glob | `OpenDecision.Sniff/Decide` | `OpenDecisionTests` | | 2 | Binaries (+x) always ask Run/Cancel with full path; scripts (+x) ask Run/Display/Cancel; ELF without +x is refused | `OpenDecision`, `ExecutePlanAsync` | `ContentDecidesNeverTheName` | | 3 | `.desktop`: silent only if under an XDG `applications` dir; otherwise always ask showing the exact argv; +x is not trust | `OpenDecision`, `ExecutePlanAsync` | `Desktop_Trusted...` | -| 4 | Strict `.desktop` parse: one `[Desktop Entry]` group, no duplicate keys, Type+Exec required, no NUL/control chars in file or Exec, no localized `Exec[..]/Type[..]/Terminal[..]/Path[..]/TryExec[..]`; invalid = refused (never given to a handler) | `DesktopEntryParser.ParseStrict` | `StrictParse_*` | +| 4 | Strict `.desktop` parse: NUL rejected anywhere in the file; control chars rejected inside `[Desktop Entry]` (including whitespace-padded group headers such as `\v[Foo]`) and in Exec; one `[Desktop Entry]` group, no duplicate keys, Type+Exec required, no localized `Exec[..]/Type[..]/Terminal[..]/Path[..]/TryExec[..]`; on the launcher path other groups and malformed lines are ignored, service menus stay strict everywhere; invalid = refused (never given to a handler) | `DesktopEntryParser.ParseStrict` | `StrictParse_*` | | 5 | Display equals exec: the argv is computed once (`DesktopExecExpander.Expand`), shown (control chars escaped), and passed unchanged to `ILauncherService.RunCommandAsync`; nothing is re-parsed or re-expanded after the dialog | `LinuxOpenPlan.Argv`, `RunCommandAsync` | `RunCommand_StartsExactlyTheDisplayedArgv...`, `Exec_ParsedOnce...` | | 6 | Dialog answer maps to exactly the described follow-up; Cancel/close/dialog failure never opens anything. "Display" opens the file in an explicitly chosen `text/plain` handler, never the file's own MIME default | `OpenDecision.Resolve`, `DisplayAsTextAsync` | `DialogAnswerMapsToExactlyTheDescribedFollowUp` | | 7 | TOCTOU: identity (dev, ino, mode, size, mtime; statx without following the final symlink) captured before the dialog and re-checked after it and before every start, including shared default-app launches | `FileIdentity`, `LinuxOpenPlan.StillValid` | `FileIdentity_DetectsChange` | diff --git a/src/Files.App.UnoVirtualization/GroupedVirtualizingWrapGrid.cs b/src/Files.App.UnoVirtualization/GroupedVirtualizingWrapGrid.cs index 353f98df9d41..fb950862e08f 100644 --- a/src/Files.App.UnoVirtualization/GroupedVirtualizingWrapGrid.cs +++ b/src/Files.App.UnoVirtualization/GroupedVirtualizingWrapGrid.cs @@ -273,7 +273,11 @@ private void ApplyAnchorOffset(double target, int request, int attempt) OwnerPanel.DispatcherQueue.TryEnqueue(DispatcherQueuePriority.Low, () => { if (request != offsetRequest || !IsGeometryCurrent || ScrollViewer is not { } viewer) + { + if (request == offsetRequest && ScrollViewer is null) + restoringAnchor = false; return; + } var extent = ScrollOrientation == Orientation.Vertical ? viewer.ExtentHeight : viewer.ExtentWidth; if (Math.Abs(extent - Geometry.Extent) > 1 && attempt < 8) { @@ -403,7 +407,8 @@ public override Line CreateLine(GeneratorDirection fillDirection, double extentO ? new Rect(0, extentOffset, availableBreadth, cellExtent) : new Rect(extentOffset, 0, cellExtent, availableBreadth)); OwnerPanel.InvalidateMeasure(); - return new Line(Math.Max(0, flat), (placeholder, nextVisibleItem)); + var clamped = Math.Clamp(flat, 0, Math.Max(0, Geometry.Count - 1)); + return new Line(clamped, (placeholder, Uno.UI.IndexPath.FromRowSection(clamped, 0))); } var views = new (FrameworkElement container, Uno.UI.IndexPath index)[row.Count]; for (var column = 0; column < row.Count; column++) diff --git a/src/Files.Platform.Linux/Mime/DesktopEntryParser.cs b/src/Files.Platform.Linux/Mime/DesktopEntryParser.cs index 6c58ef6566d3..2c464fd62a4f 100644 --- a/src/Files.Platform.Linux/Mime/DesktopEntryParser.cs +++ b/src/Files.Platform.Linux/Mime/DesktopEntryParser.cs @@ -124,6 +124,12 @@ public sealed record Entry(DesktopApplication Application, bool Hidden, string? var line = rawLine.Trim(); if (desktopEntryOnly) { + // Whitespace-padded group headers inside [Desktop Entry] must not hide control characters. + if (values is not null && line.StartsWith('[') && rawLine.TrimEnd('\r').Any(c => char.IsControl(c) && c != '\t')) + { + error = "control character"; + return null; + } if (line.StartsWith('[') && line != "[Desktop Entry]") { values = null; diff --git a/tests/Files.Platform.Tests/Launching/OpenDecisionTests.cs b/tests/Files.Platform.Tests/Launching/OpenDecisionTests.cs index 3c25c31d3fa5..1279e07e4c3c 100644 --- a/tests/Files.Platform.Tests/Launching/OpenDecisionTests.cs +++ b/tests/Files.Platform.Tests/Launching/OpenDecisionTests.cs @@ -123,6 +123,8 @@ public void StrictParse_IgnoresLinesWithoutKeys() [DataRow("[Desktop Entry]\nType=Application\nName=X\nExec=safe\n[Desktop Action a]\nName=unused\0name\n")] [DataRow("[Desktop Action a]\nName=unused\0name\n[Desktop Entry]\nType=Application\nName=X\nExec=safe\n")] [DataRow("[Desktop Entry]\nType=Application\nName=X\nExec=a\n[Desktop Action a]\nExec=unused\n[Desktop Entry]\nExec=b\n")] + [DataRow("[Desktop Entry]\nType=Application\nName=X\nExec=safe\n\v[Foo]\nName=y\n")] + [DataRow("[Desktop Entry]\nType=Application\nName=X\nExec=safe\n[Foo]\f\nName=y\n")] public void StrictParse_RejectsAmbiguousEntries(string text) { Assert.IsNull(Strict(text, out var error));