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));