feat(documents): open bounded local links in native readers

Require user-initiated current-namespace Markdown/text links, verify the opened target handle against the source physical folder, and retain that grant for all reloads. Preserve source readers and duplicate focus without arbitrary shell or protocol dispatch.

Validation: 662 core tests and 100% measured coverage, 10 actual document/image filesystem cases, zero-warning Release and formatting. Prepared physical link/duplicate/watcher/junction-reload acceptance; 109 native cases discovered. Prior exact4d full distributed CI is terminal success.

Co-Authored-By: Codex <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
This commit is contained in:
James MaesandCodex committed 2026-10-11 21:46:44 -05:00
1 parent 4d346565ea
commit b542dcd1f5
15 files changed
+353 -15

No files matched your search

+1 -1
View File
@@ -44,7 +44,7 @@ jobs:
shell: pwsh
run: >-
dotnet test tests/MarkdownViewr.Windows.UiTests --no-build
--filter 'FullyQualifiedName~SourceHighlighting|FullyQualifiedName~Settings_NewWindowPanelDefaults|FullyQualifiedName~Settings_ContentsPresentation|FullyQualifiedName~Settings_ContentsTooltip|FullyQualifiedName~Themes_PalettePreviewAndDefaultChoice|FullyQualifiedName~Settings_PrintingDefaults|FullyQualifiedName~Settings_PrintingDraft|FullyQualifiedName~ThemeTransfer_NativePickerCancels|FullyQualifiedName~Print_UserWidthAndBorders|FullyQualifiedName~ReadingKeys_ScrollCommands|FullyQualifiedName~Source_ShowReloadAndHide|FullyQualifiedName~ThemeEditor_AllTypography|FullyQualifiedName~ThemeEditor_NewCustomTheme|FullyQualifiedName~Appearance_WidthCssAndPrint|FullyQualifiedName~Toolbar_ReadingCommandsAndMenus|FullyQualifiedName~ThemeTransfer_BatchFutureInvalidAndDisabledExport|FullyQualifiedName~ReadingKeys_HeadingSequences|FullyQualifiedName~Source_BidirectionalScroll|FullyQualifiedName~Source_RawHtmlHeadingProvenance|FullyQualifiedName~Extensions_NativePreferences|FullyQualifiedName~ActivationInboxTests|FullyQualifiedName~Outline_NestedCollapse'
--filter 'FullyQualifiedName~SourceHighlighting|FullyQualifiedName~Settings_NewWindowPanelDefaults|FullyQualifiedName~Settings_ContentsPresentation|FullyQualifiedName~Settings_ContentsTooltip|FullyQualifiedName~Themes_PalettePreviewAndDefaultChoice|FullyQualifiedName~Settings_PrintingDefaults|FullyQualifiedName~Settings_PrintingDraft|FullyQualifiedName~ThemeTransfer_NativePickerCancels|FullyQualifiedName~Print_UserWidthAndBorders|FullyQualifiedName~ReadingKeys_ScrollCommands|FullyQualifiedName~Source_ShowReloadAndHide|FullyQualifiedName~ThemeEditor_AllTypography|FullyQualifiedName~ThemeEditor_NewCustomTheme|FullyQualifiedName~Appearance_WidthCssAndPrint|FullyQualifiedName~Toolbar_ReadingCommandsAndMenus|FullyQualifiedName~ThemeTransfer_BatchFutureInvalidAndDisabledExport|FullyQualifiedName~ReadingKeys_HeadingSequences|FullyQualifiedName~Source_BidirectionalScroll|FullyQualifiedName~Source_RawHtmlHeadingProvenance|FullyQualifiedName~Extensions_NativePreferences|FullyQualifiedName~Windows_LocalDocumentLinks|FullyQualifiedName~ActivationInboxTests|FullyQualifiedName~Outline_NestedCollapse'
--logger 'trx;LogFileName=reader-regressions.trx'
--results-directory artifacts/reader-regressions
- name: Retain native regression evidence
+26
View File
@@ -2,6 +2,32 @@
Updated: 2026-10-11
## Bounded native local links prepared; renderer checkpoint fully verified - 2026-10-11
Exact4d34656 / CI38193736344 is terminal success: focused27/27,
native/published106/106, installed stable/dev105/105 each; zero failures/skips.
Actual extended raw-heading/inline/block-code browser case and all registration/
coexistence/AppKit-update assertions pass. Retain current-native-4d34656.log and
actual TRXs/JSON at current-installed-evidence-38193736344. Fixtures are unsigned.
Local Markdown/text links previously remained blocked. They now require actual
user initiation, current namespace/no query/supported type and once-decoded safe
segments. Source physical root comes from its opened snapshot; the target handle
is checked before reading. Native child windows retain the scope on every reload,
focus only matching already-committed inside snapshots and preserve source/window
watching. No OS shell/arbitrary protocol/executable dispatch.662 core tests/100%
lines,10 real filesystem tests, zero-warning isolated Release/full format pass.
A diagnosed test junction teardown failure is corrected with explicit removal of
only the link before fixture cleanup; containment assertions remain. New physical
browser/duplicate/watcher/junction-reload case compiled;109 native cases discovered,
fresh hosted proof required. Original cases/deadlines retained; no local GUI input.
Prior CI is terminal, so the authorized next push can proceed after signed review.
Shared signing31 approval request unanswered; do not assume permission. Azure/
shipped signing, actual chooser/Explorer, owner design/scaling and genuine final-
head review remain required. No preview55472 change, login retry, shared merge,
release tag or publication. Original upstream/native compatibility limits recorded.
## Authored HTML fidelity corrected locally; preceding run fully green - 2026-10-11
Exactc3c735e / CI38190828814 is terminal success. Actual TRX native/published106/106,
+4
View File
@@ -2,6 +2,10 @@
## Current release checklist
- [x] Exact4d34656 / CI38193736344 terminal success: focused27/27, native/published106/106, installed stable/dev105/105 each; actual authored-code/raw-heading browser and channel lifecycle cases pass, zero skips.
- [x] Complete local-document link implementation through scoped native windows, with662 core/100% measured lines,10 actual filesystem tests and zero-warning Release/format.
- [ ] Verify the physical local-link/duplicate/watcher/junction-reload case and 109 native/published and108 per installed channel on the next pushed head; original cases/deadlines remain.
The detailed checkpoints below preserve historical tasks. This section supersedes
old "prepared/not run" statuses where the completed evidence now proves acceptance.
Full product scope remains in PRODUCT.md; unsigned fixtures are not release proof.
+5 -1
View File
@@ -26,7 +26,11 @@ Document-authored styles, IDs and classes are removed. Application theme CSS is
Disable scripts, web messages, host objects, frames, downloads, popups and permission
requests. Reject all browser resources except the exact document and allowlisted
local image requests. External HTTP(S) navigation requires a real user gesture
and opens in Windows' external handler. Other links remain blocked in this slice.
and opens in Windows' external handler. User-initiated Markdown/text file links
open native reader windows after the same namespace/path validation and physical
folder check. The new reader retains that required physical root for every
manual/automatic reload; it cannot follow a later junction outside the original
grant. Other URI schemes and executable/non-document targets remain blocked.
Decode resource paths once; reject traversal, alternate streams, devices and Windows
filename aliases. Before reading an image, resolve its opened handle with
+25
View File
@@ -156,3 +156,28 @@ The preceding exactc3c735e / CI38190828814 is terminal success: native/published
106/106, focused26/26, installed stable/dev105/105 each, zero skips. Actual
complete Settings screenshots and coexistence/registration/update evidence are
retained. Those results predate the new renderer correction and do not certify it.
## Local images and navigation audit
The pinned Mac WebView uses a file-directory base URI and delegates clicked links
to NSWorkspace. Windows deliberately retains its isolated HTTPS namespace and
bounded physical folder access. Same-document fragments jump/reveal native
headings; HTTP(S) links use Windows after an actual user gesture. Supported local
Markdown/text file links now open native windows, with duplicate focus, independent
watching and the inherited physical-folder check on every reload. They never
launch arbitrary files or protocols. Core URI tests and10 actual filesystem tests
pass;662 core cases retain100% measured lines. Full Release/format pass. The new
physical browser-link/duplicate/watcher/junction-reload case is compiled/discovered,
not executed;109 native cases now discovered.
Image requests support PNG, JPEG, GIF, BMP, WebP, SVG, AVIF and ICO through the
same checked file handle and16MiB guard. Remote/absolute/traversing/aliased-outside
requests are denied; browser paint/PDF fixtures prove representative local output.
The Mac TIFF MIME mapping is not advertised as Windows browser support. Other
protocol handlers and access outside the document folder remain intentional Windows
security boundaries, not claims of arbitrary NSWorkspace equivalence.
The preceding exact4d34656 / CI38193736344 is terminal success: focused27/27,
native/published106/106, installed stable/dev105/105 each, zero failures/skips.
Actual raw HTML/inline/block-code browser acceptance and channel lifecycle outputs
are retained; those results predate the new local-link implementation.
+18
View File
@@ -402,3 +402,21 @@ passive formatting tags provide no scripts, host messages or native capabilities
Core regressions exercise all16 preference combinations, protected inline/block
code, comments/attribute lookalikes, entities and exact CRLF/Unicode source ranges.
Fresh hosted browser/distributed acceptance is required for this correction.
## Native local document-link boundary
Only actual user-initiated browser navigation can request a local reader. Its URI
must belong to the current private namespace, have no query and use one of .md,
.markdown, .mdown, .mkd or .txt. Once-decoded segments reject traversal, devices,
alternate streams, controls and filename aliases. No document link is dispatched
as an executable, script, arbitrary protocol or OS shell command.
The source snapshot supplies its trusted physical directory. The target's open
handle is checked against that directory before reading, and that same handle
supplies the snapshot. A linked reader retains the required root for every reload,
including watcher/extension/frontmatter refresh. A changed junction cannot replace
its committed data with outside content. Duplicate focus requires an already
committed snapshot inside the source grant. Closed source windows cannot launch
new linked readers. Existing image, sanitizer, CSP and host-message rules remain.
Core URI and actual filesystem/junction tests pass; the prepared physical-link/
reload-scope desktop regression still requires hosted execution.
@@ -6,7 +6,7 @@
namespace MarkdownViewr.Windows.Core.Documents;
/// <summary>Limits browser resource requests to passive images belonging to one opened document.</summary>
/// <summary>Bounds passive image requests and user-initiated native document links to one opened document namespace.</summary>
public sealed class DocumentResourcePolicy
{
private static readonly HashSet<string> s_imageExtensions = new(StringComparer.OrdinalIgnoreCase)
@@ -14,6 +14,11 @@ public sealed class DocumentResourcePolicy
".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".svg", ".avif", ".ico",
};
private static readonly HashSet<string> s_documentExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".md", ".markdown", ".mdown", ".mkd", ".txt",
};
private static readonly HashSet<string> s_deviceNames = new(StringComparer.OrdinalIgnoreCase)
{
"CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
@@ -52,7 +57,21 @@ public sealed class DocumentResourcePolicy
/// <summary>Returns a decoded relative image path, or denies the request before filesystem access.</summary>
public string? GetImageRelativePath(Uri request)
public string? GetImageRelativePath(Uri request) => GetRelativePath(request, s_imageExtensions);
/// <summary>A deliberate local document link may read a supported type, never another scheme, query or native command.</summary>
public string? GetDocumentRelativePath(Uri request)
{
ArgumentNullException.ThrowIfNull(request);
return request.IsAbsoluteUri && request.Query.Length == 0 ? GetRelativePath(request, s_documentExtensions) : null;
}
/// <summary>Shares once-only decoding and segment containment between images and native document-link requests.</summary>
private string? GetRelativePath(Uri request, HashSet<string> extensions)
{
ArgumentNullException.ThrowIfNull(request);
if(!request.IsAbsoluteUri || !string.Equals(request.Scheme, "https", StringComparison.Ordinal) || !string.Equals(request.Host, DocumentUri.Host, StringComparison.Ordinal) || request.Port != 443 ||
@@ -61,7 +80,7 @@ public sealed class DocumentResourcePolicy
return null;
}
string relative = Uri.UnescapeDataString(request.AbsolutePath[_prefix.Length..]);
if(!s_imageExtensions.Contains(Path.GetExtension(relative)) || relative.Split('/').Any(segment => !IsSafeSegment(segment)))
if(!extensions.Contains(Path.GetExtension(relative)) || relative.Split('/').Any(segment => !IsSafeSegment(segment)))
{
return null;
}
+15 -3
View File
@@ -16,7 +16,7 @@ public partial class App
/// <summary>Creates an independent XAML window, retaining it until its native Closed event.</summary>
internal void NewDocumentWindow(string? path = null)
internal void NewDocumentWindow(string? path = null, string? requiredDocumentRoot = null)
{
if(path is not null)
{
@@ -28,14 +28,14 @@ public partial class App
{
Log.Warning("An invalid document launch path will be reported in its reader window.");
}
MainWindow? existing = _windows.FirstOrDefault(window => string.Equals(window.DocumentPath, path, StringComparison.OrdinalIgnoreCase));
MainWindow? existing = _windows.FirstOrDefault(window => string.Equals(window.DocumentPath, path, StringComparison.OrdinalIgnoreCase) && (requiredDocumentRoot is null || window.IsDocumentInside(requiredDocumentRoot)));
if(existing is not null)
{
existing.Activate();
return;
}
}
var window = new MainWindow(path, initialWindow: _windows.Count == 0);
var window = new MainWindow(path, initialWindow: _windows.Count == 0, requiredDocumentRoot);
_windows.Add(window);
window.Closed += (_, _) =>
{
@@ -84,6 +84,18 @@ public partial class App
/// <summary>Only a live native source window can open a folder-scoped document link; its new reader retains the scope on reload.</summary>
internal Task OpenLinkedDocumentAsync(MainWindow requester, string path, string physicalRoot)
{
if(_windows.Contains(requester))
{
NewDocumentWindow(path, physicalRoot);
}
return Task.CompletedTask;
}
/// <summary>File/Exit closes a snapshot of all document windows while native close/Ctrl+W affect only one.</summary>
internal void CloseDocumentWindows()
{
+16 -1
View File
@@ -17,6 +17,7 @@ internal sealed partial class DocumentBrowser
{
private const string SecurityHeaders = "Content-Security-Policy: default-src 'none'; img-src 'self'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'\r\nX-Content-Type-Options: nosniff\r\nReferrer-Policy: no-referrer\r\nCache-Control: no-store";
private readonly WebView2 _view;
private readonly Func<string, string, Task>? _openLinkedDocument;
private readonly Action<string> _status;
private CoreWebView2Environment? _environment;
private Core.Documents.DocumentResourcePolicy _policy = new();
@@ -40,9 +41,10 @@ internal sealed partial class DocumentBrowser
/// <summary>Attaches a native status callback; the document itself cannot invoke host commands.</summary>
public DocumentBrowser(WebView2 view, Action<string> status, Core.Themes.ReaderTheme theme, Core.Themes.ReadingAppearance? appearance = null)
public DocumentBrowser(WebView2 view, Action<string> status, Core.Themes.ReaderTheme theme, Core.Themes.ReadingAppearance? appearance = null, Func<string, string, Task>? openLinkedDocument = null)
{
_view = view;
_openLinkedDocument = openLinkedDocument;
_status = status;
_theme = theme;
_appearance = (appearance ?? new Core.Themes.ReadingAppearance()).Sanitize();
@@ -342,6 +344,19 @@ internal sealed partial class DocumentBrowser
return;
}
args.Cancel = true;
if(args.IsUserInitiated && uri is not null && _files is { } files && _openLinkedDocument is not null &&
_policy.GetDocumentRelativePath(uri) is { } relativePath)
{
try
{
await _openLinkedDocument(files.ResolveDocumentLink(relativePath), files.PhysicalRoot);
}
catch(Exception exception) when(exception is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException)
{
_status("This linked document could not be opened.");
}
return;
}
if(args.IsUserInitiated && uri is not null && uri.UserInfo.Length == 0 &&
!string.Equals(uri.Host, _policy.DocumentUri.Host, StringComparison.OrdinalIgnoreCase) && uri.Scheme is "https" or "http")
{
+15 -1
View File
@@ -46,12 +46,26 @@ internal sealed partial class DocumentFiles
/// <summary>The physical directory authorized by the selected document's actual open handle.</summary>
public string PhysicalRoot => _physicalRoot;
/// <summary>Resolves only the already-validated relative spelling; the eventual read must still enforce PhysicalRoot.</summary>
public string ResolveDocumentLink(string relativePath) => Path.GetFullPath(Path.Combine(_directory, relativePath));
/// <summary>Loads at most the parser limit plus one character, using the same handle for identity and contents.</summary>
public static DocumentFiles Open(string path)
public static DocumentFiles Open(string path, string? requiredPhysicalRoot = null)
{
string fullPath = Path.GetFullPath(path);
using SafeFileHandle handle = File.OpenHandle(fullPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
string physicalPath = GetPhysicalPath(handle);
if(requiredPhysicalRoot is not null && !physicalPath.StartsWith(requiredPhysicalRoot, StringComparison.Ordinal))
{
throw new IOException("This document resolves outside the permitted folder.");
}
using var stream = new FileStream(handle, FileAccess.Read);
using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
char[] buffer = new char[Core.Documents.MarkdownRenderer.MaximumCharacters + 1];
@@ -32,6 +32,11 @@ public sealed partial class MainWindow
/// <summary>A document-created link may focus only a reader whose committed physical snapshot belongs to its authorized folder.</summary>
internal bool IsDocumentInside(string physicalRoot) => _documentPhysicalRoot?.StartsWith(physicalRoot, StringComparison.Ordinal) == true;
/// <summary>Creates a genuinely blank window rather than re-reading the original process argument.</summary>
private void NewWindow_Click(object sender, RoutedEventArgs args) => ((App)Application.Current).NewDocumentWindow();
+9 -5
View File
@@ -24,6 +24,8 @@ public sealed partial class MainWindow : Window, IDisposable
private readonly DocumentBrowser _browser;
private Task? _browserInitialization;
private string? _documentPath;
private readonly string? _requiredDocumentRoot;
private string? _documentPhysicalRoot;
private bool _loaded;
private bool _closed;
private readonly Core.Documents.DocumentLoadScheduler _loads = new();
@@ -33,16 +35,17 @@ public sealed partial class MainWindow : Window, IDisposable
/// <summary>Sizes and brands the window, creates the document host, shows the splash, and schedules the quiet startup update check.</summary>
public MainWindow(string? documentPath = null, bool initialWindow = true)
public MainWindow(string? documentPath = null, bool initialWindow = true, string? requiredDocumentRoot = null)
{
_initialDocumentPath = documentPath;
_requiredDocumentRoot = requiredDocumentRoot;
_settings.ShowOutline = _settings.ShowOutlineByDefault;
_settings.ShowSource = _settings.ShowSourceByDefault;
_lastSavedSettings = Core.SettingsChanges.Copy(_settings);
InitializeComponent();
RefreshRecentDocuments();
_themes = Core.Themes.ThemeManagement.Enabled(_settings);
_browser = new DocumentBrowser(DocumentView, message => StatusText.Text = message, ActiveDocumentTheme(), _settings.ReadingAppearance);
_browser = new DocumentBrowser(DocumentView, message => StatusText.Text = message, ActiveDocumentTheme(), _settings.ReadingAppearance, (path, root) => ((App)Application.Current).OpenLinkedDocumentAsync(this, path, root));
CreateDocumentThemeMenu();
InitializeOutline();
InitializeSource();
@@ -211,7 +214,7 @@ public sealed partial class MainWindow : Window, IDisposable
_browserInitialization ??= _browser.InitializeAsync();
await _browserInitialization;
Core.Documents.DocumentViewport viewport = reload ? await _browser.CaptureViewportAsync() : default;
(DocumentFiles files, Core.Documents.DocumentOutline outline) = await ReadDocumentAsync(path, _settings.FrontmatterKind, _settings.MarkdownExtensions);
(DocumentFiles files, Core.Documents.DocumentOutline outline) = await ReadDocumentAsync(path, _settings.FrontmatterKind, _settings.MarkdownExtensions, _requiredDocumentRoot);
if(_closed)
{
return;
@@ -295,7 +298,7 @@ public sealed partial class MainWindow : Window, IDisposable
/// <summary>Retries only transient missing/sharing violations from an editor's replace/save operation.</summary>
private static async Task<(DocumentFiles Files, Core.Documents.DocumentOutline Outline)> ReadDocumentAsync(string path, Core.Documents.FrontmatterMode frontmatter, Core.Documents.MarkdownExtensions extensions)
private static async Task<(DocumentFiles Files, Core.Documents.DocumentOutline Outline)> ReadDocumentAsync(string path, Core.Documents.FrontmatterMode frontmatter, Core.Documents.MarkdownExtensions extensions, string? requiredDocumentRoot)
{
for(int attempt = 0; ; attempt++)
{
@@ -303,7 +306,7 @@ public sealed partial class MainWindow : Window, IDisposable
{
return await Task.Run(() =>
{
var document = DocumentFiles.Open(path);
var document = DocumentFiles.Open(path, requiredDocumentRoot);
return (document, new Core.Documents.DocumentOutline(document.Markdown, frontmatter, extensions));
});
}
@@ -329,6 +332,7 @@ public sealed partial class MainWindow : Window, IDisposable
RefreshSource();
RefreshOutline();
_documentPath = Path.GetFullPath(path);
_documentPhysicalRoot = files.PhysicalRoot;
CommitDocumentFooter(files);
RefreshEditorCommands();
Title = Path.GetFileName(path) + " — " + AppIdentity.Info.DisplayName;
@@ -92,6 +92,59 @@ public sealed class DocumentResourcePolicyTests
/// <summary>Supported local document types retain once-decoded literal paths, without providing shell commands.</summary>
[Theory]
[InlineData("child.md", "child.md")]
[InlineData("docs/child.MARKDOWN", "docs/child.MARKDOWN")]
[InlineData("child.mdown", "child.mdown")]
[InlineData("child.mkd", "child.mkd")]
[InlineData("child.txt", "child.txt")]
[InlineData("docs/caf%C3%A9%20%26%20%24HOME.md", "docs/café & $HOME.md")]
[InlineData("docs/%252e%252e.md", "docs/%2e%2e.md")]
public void GetDocumentRelativePath_SupportedType_DecodesOnlyOnce(string path, string expected)
{
var policy = new DocumentResourcePolicy();
Assert.Equal(expected, policy.GetDocumentRelativePath(new Uri(policy.DocumentUri, path)));
}
/// <summary>Documents reject queries, traversal, devices, alternate streams and executable/non-document targets before native access.</summary>
[Theory]
[InlineData("child.md?command=run")]
[InlineData("../private.md")]
[InlineData("docs/%2e%2e%2f%2e%2e%2fprivate.md")]
[InlineData("docs/CON.md")]
[InlineData("docs/child.md%3aprivate")]
[InlineData("docs/%5cprivate.md")]
[InlineData("docs/%00private.md")]
[InlineData("docs/folder./child.md")]
[InlineData("docs/folder%20/child.md")]
[InlineData("child.exe")]
[InlineData("child.lnk")]
[InlineData("child.png")]
public void GetDocumentRelativePath_UnsafeTarget_DeniesAccess(string path)
{
var policy = new DocumentResourcePolicy();
Assert.Null(policy.GetDocumentRelativePath(new Uri(policy.DocumentUri, path)));
}
/// <summary>Other namespaces, origins, schemes and relative requests never gain document-open authority.</summary>
[Fact]
public void GetDocumentRelativePath_ForeignContext_DeniesAccess()
{
var policy = new DocumentResourcePolicy();
Assert.Null(policy.GetDocumentRelativePath(new Uri("child.md", UriKind.Relative)));
Assert.Null(policy.GetDocumentRelativePath(new Uri(new DocumentResourcePolicy().DocumentUri, "child.md")));
Assert.Null(policy.GetDocumentRelativePath(new Uri("file:///C:/private.md")));
Assert.Null(policy.GetDocumentRelativePath(new Uri("https://example.com/child.md")));
Assert.Throws<ArgumentNullException>(() => policy.GetDocumentRelativePath(null!));
}
/// <summary>Requests cannot cross into another opened document.</summary>
[Fact]
public void GetImageRelativePath_OtherDocument_DeniesAccess()
@@ -3,6 +3,7 @@
* SPDX-License-Identifier: MIT
*/
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
@@ -54,6 +55,99 @@ public partial class AppTests
/// <summary>Physical local links open native readers, focus duplicates and retain inherited containment through a junction/reload attack.</summary>
[Fact]
public void Windows_LocalDocumentLinks_OpenOwnedReaderAndPreserveReloadScope()
{
const string Filename = "child & café $HOME.md";
string document = "# Link source\n\n[Open linked child](nested/" + Uri.EscapeDataString(Filename) + ")\n\n[Blocked program](blocked.exe)\n\n[Blocked parent](../private.md)";
using var session = new AppSession(document: document, documentFileName: "links.md");
string nested = Path.Combine(session.DataDirectory, "nested");
string saved = Path.Combine(session.DataDirectory, "saved-nested");
Directory.CreateDirectory(nested);
string path = Path.Combine(nested, Filename);
File.WriteAllText(path, "# Linked child");
File.WriteAllText(Path.Combine(session.DataDirectory, "blocked.exe"), "Not an executable; must never be dispatched.");
Window source = WaitForDocumentWindow(session, "links.md");
WaitForWindowHeading(session, source, "Link source");
InvokeDocumentMenu(session, source, "SourceToggle", menuName: "View");
string original = WaitForWindowElement(session, source, "SourceText").Patterns.Value.Pattern.Value.Value;
ClickLocalDocumentLink(session, source, "Open linked child");
Window child = WaitForDocumentWindow(session, Filename);
WaitForWindowHeading(session, child, "Linked child");
Assert.Equal(2, DocumentWindows(session).Length);
Assert.Equal(original, WaitForWindowElement(session, source, "SourceText").Patterns.Value.Pattern.Value.Value);
ClickLocalDocumentLink(session, source, "Open linked child");
session.WaitFor(() => GetForegroundWindow() == child.Properties.NativeWindowHandle.ValueOrDefault ? child : null, "the existing linked reader focused without duplication");
Assert.Equal(2, DocumentWindows(session).Length);
File.WriteAllText(path, "# Linked child refreshed");
WaitForWindowHeading(session, child, "Linked child refreshed");
foreach(string label in new[] { "Blocked program", "Blocked parent" })
{
ClickLocalDocumentLink(session, source, label);
session.WaitFor(() => WaitForWindowElement(session, source, "StatusText").Name.Contains("outside", StringComparison.Ordinal) ? source : null, "the rejected document link");
Assert.Equal(2, DocumentWindows(session).Length);
}
AssertLinkedReloadScope(session, child, nested, saved, Filename);
}
/// <summary>Replacing the linked directory with an outside junction must keep the old snapshot and enforce scope on manual reload.</summary>
private static void AssertLinkedReloadScope(AppSession session, Window child, string nested, string saved, string filename)
{
string outside = Path.Combine(Path.GetTempPath(), "markdownviewr-link-outside-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(outside);
File.WriteAllText(Path.Combine(outside, filename), "# Outside content must never be displayed");
Assert.StartsWith(Path.GetFullPath(session.DataDirectory) + Path.DirectorySeparatorChar, Path.GetFullPath(nested), StringComparison.Ordinal);
Assert.StartsWith(Path.GetFullPath(session.DataDirectory) + Path.DirectorySeparatorChar, Path.GetFullPath(saved), StringComparison.Ordinal);
try
{
Directory.Move(nested, saved);
var start = new ProcessStartInfo("cmd.exe") { UseShellExecute = false, CreateNoWindow = true };
start.ArgumentList.Add("/c");
start.ArgumentList.Add("mklink");
start.ArgumentList.Add("/J");
start.ArgumentList.Add(nested);
start.ArgumentList.Add(outside);
using Process process = Process.Start(start)!;
Assert.True(process.WaitForExit(10000), "Junction creation did not finish.");
Assert.Equal(0, process.ExitCode);
InvokeDocumentMenu(session, child, "ReloadMenuItem");
session.WaitFor(() => WaitForWindowElement(session, child, "StatusText").Name.Contains("previous version", StringComparison.Ordinal) ? child : null, "folder-scoped reload retaining the committed snapshot");
Assert.NotNull(WaitForWindowElement(session, child, "FileStaleButton"));
Assert.Null(child.FindFirstDescendant(cf => cf.ByName("Outside content must never be displayed")));
WaitForWindowHeading(session, child, "Linked child refreshed");
RetainDocumentWindow(session, child, "linked-document-scope-denial");
}
finally
{
if(Directory.Exists(nested) && new DirectoryInfo(nested).Attributes.HasFlag(FileAttributes.ReparsePoint))
{
Directory.Delete(nested);
}
if(Directory.Exists(saved) && !Directory.Exists(nested))
{
Directory.Move(saved, nested);
}
Assert.StartsWith(Path.Combine(Path.GetFullPath(Path.GetTempPath()), "markdownviewr-link-outside-"), Path.GetFullPath(outside), StringComparison.Ordinal);
Directory.Delete(outside, recursive: true);
}
}
/// <summary>Clicks the real browser hyperlink in its owning window so the host receives actual user-initiated navigation.</summary>
private static void ClickLocalDocumentLink(AppSession session, Window window, string label)
{
window.SetForeground();
Wait.UntilInputIsProcessed();
AutomationElement link = session.WaitFor(() => window.FindFirstDescendant(cf => cf.ByControlType(ControlType.Hyperlink).And(cf.ByName(label))), "the owned document hyperlink " + label);
Mouse.Click(link.GetClickablePoint());
}
/// <summary>New windows stay blank; recent activation focuses existing readers, failed opens preserve them and clearing cannot be undone by stale preferences.</summary>
[Fact]
public void Windows_NewRecentAndClear_PreservesDocumentsAndHistory()
@@ -74,6 +74,51 @@ public sealed class DocumentFilesTests : IDisposable
/// <summary>A linked document is read from the checked handle, and its inherited folder scope still applies to later reads.</summary>
[Fact]
public void Open_LinkedDocumentScope_RetainsInsideReadsAndRejectsOutside()
{
DocumentFiles source = CreateDocument();
string child = source.ResolveDocumentLink("child.md");
File.WriteAllText(child, "# Child snapshot");
Assert.Equal("# Child snapshot", DocumentFiles.Open(child, source.PhysicalRoot).Markdown);
File.WriteAllText(child, "# Child replacement");
Assert.Equal("# Child replacement", DocumentFiles.Open(child, source.PhysicalRoot).Markdown);
string outside = Path.Combine(_root, "outside", "private.md");
File.WriteAllText(outside, "# Outside content must not be read");
Assert.Throws<IOException>(() => DocumentFiles.Open(outside, source.PhysicalRoot));
}
/// <summary>A document link cannot use a directory junction to bypass its parent reader's physical folder grant.</summary>
[Fact]
public void Open_LinkedDocumentJunction_RejectsBeforeReading()
{
DocumentFiles source = CreateDocument();
string link = Path.Combine(_root, "document", "linked");
File.WriteAllText(Path.Combine(_root, "outside", "private.md"), "# Outside content must not be read");
var start = new ProcessStartInfo("cmd.exe") { UseShellExecute = false, CreateNoWindow = true };
start.ArgumentList.Add("/c");
start.ArgumentList.Add("mklink");
start.ArgumentList.Add("/J");
start.ArgumentList.Add(link);
start.ArgumentList.Add(Path.Combine(_root, "outside"));
using Process process = Process.Start(start)!;
Assert.True(process.WaitForExit(10000), "Junction creation did not finish.");
Assert.Equal(0, process.ExitCode);
try
{
Assert.Throws<IOException>(() => DocumentFiles.Open(source.ResolveDocumentLink("linked/private.md"), source.PhysicalRoot));
}
finally
{
Directory.Delete(link);
}
}
/// <summary>Ordinary relative images inside the selected folder retain their exact bytes.</summary>
[Fact]
public void ReadImage_InsideDocumentFolder_ReturnsExactBytes()