From 32709173dec9c36882e13f867a751f07bed44a02 Mon Sep 17 00:00:00 2001 From: Tig Date: Wed, 6 May 2026 15:10:01 -0600 Subject: [PATCH] Add local markdown link navigation to md clet Clicking a relative .md link in the viewer now navigates to that file in-place, provided it's within the CWD sandbox. Links outside CWD, to non-markdown files, or using http/https remain SurfaceOnly (shown in the status bar). New --allow-external-links option opts in to following markdown links outside the working directory. Prep for gui-cs/Terminal.Gui#5220 which adds Link.SafeSchemes and blocks file:// by default. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Clet/Clets/Viewer/MarkdownClet.cs | 106 +++++++++++++++++- tests/Clet.UnitTests/MarkdownCletTests.cs | 124 +++++++++++++++++++++- 2 files changed, 223 insertions(+), 7 deletions(-) diff --git a/src/Clet/Clets/Viewer/MarkdownClet.cs b/src/Clet/Clets/Viewer/MarkdownClet.cs index a6bfb1a..890e516 100644 --- a/src/Clet/Clets/Viewer/MarkdownClet.cs +++ b/src/Clet/Clets/Viewer/MarkdownClet.cs @@ -28,6 +28,9 @@ internal sealed class MarkdownClet : IViewerClet new ("cat", null, typeof (bool), "Render markdown to stdout without launching the TUI viewer.", false, "false"), + new ("allow-external-links", null, typeof (bool), + "Allow following markdown links outside the working directory.", + false, "false"), ]; public bool AcceptsPositionalArgs => true; @@ -103,6 +106,15 @@ public async Task RunAsync ( syntaxTheme = parsed; } + bool allowExternalLinks = options.CletOptions?.TryGetValue ("allow-external-links", out string? extVal) == true + && string.Equals (extVal, "true", StringComparison.OrdinalIgnoreCase); + + // Sandbox root for local link navigation — CWD is the ceiling + string sandboxRoot = Path.GetFullPath (Environment.CurrentDirectory); + + // Track the directory of the currently viewed file for resolving relative links + string? currentFileDir = files.Count > 0 ? Path.GetDirectoryName (Path.GetFullPath (files [0])) : null; + Runnable window = new () { Title = options.Title ?? "Markdown Viewer", @@ -129,6 +141,16 @@ public async Task RunAsync ( markdownView.LinkClicked += (_, e) => { + // Try to navigate to local .md files within the sandbox + if (currentFileDir is not null && TryResolveLocalMarkdownLink (e.Url, currentFileDir, sandboxRoot, allowExternalLinks, out string? resolvedPath)) + { + LoadFile (resolvedPath); + e.Handled = true; + + return; + } + + // SurfaceOnly: show URL in status bar, don't open statusShortcut.Title = e.Url; e.Handled = true; }; @@ -258,13 +280,91 @@ public async Task RunAsync ( void LoadFile (string filePath) { - string fileContent = TerminalEscapeSanitizer.Sanitize (File.ReadAllText (filePath))!; + string fullPath = Path.GetFullPath (filePath); + string fileContent = TerminalEscapeSanitizer.Sanitize (File.ReadAllText (fullPath))!; markdownView.Text = fileContent; - FileInfo fileInfo = new (filePath); + currentFileDir = Path.GetDirectoryName (fullPath); + + FileInfo fileInfo = new (fullPath); fileSizeShortcut.Title = FormatFileSize (fileInfo.Length); - statusShortcut.Title = Path.GetFileName (filePath); + statusShortcut.Title = Path.GetFileName (fullPath); + } + } + + /// + /// Resolves a link URL to a local markdown file path if it's a relative path + /// or file:// URI pointing to a .md file within the sandbox. + /// + internal static bool TryResolveLocalMarkdownLink ( + string url, + string currentDir, + string sandboxRoot, + bool allowExternal, + out string? resolvedPath) + { + resolvedPath = null; + + // Strip fragment (e.g. #section) from the URL + int fragmentIndex = url.IndexOf ('#'); + string pathPart = fragmentIndex >= 0 ? url [..fragmentIndex] : url; + + if (string.IsNullOrWhiteSpace (pathPart)) + { + return false; } + + // Handle file:// URIs + if (pathPart.StartsWith ("file://", StringComparison.OrdinalIgnoreCase)) + { + if (!Uri.TryCreate (pathPart, UriKind.Absolute, out Uri? fileUri) || !fileUri.IsFile) + { + return false; + } + + pathPart = fileUri.LocalPath; + } + // Reject non-local schemes (http://, https://, mailto:, etc.) + else if (pathPart.Contains ("://", StringComparison.Ordinal)) + { + return false; + } + + // Resolve relative to the current file's directory + string fullPath; + + try + { + fullPath = Path.IsPathRooted (pathPart) + ? Path.GetFullPath (pathPart) + : Path.GetFullPath (Path.Combine (currentDir, pathPart)); + } + catch + { + return false; + } + + // Must be a markdown file + if (!fullPath.EndsWith (".md", StringComparison.OrdinalIgnoreCase) + && !fullPath.EndsWith (".markdown", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + // Sandbox check: must be under CWD unless --allow-external-links + if (!allowExternal && !fullPath.StartsWith (sandboxRoot, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (!File.Exists (fullPath)) + { + return false; + } + + resolvedPath = fullPath; + + return true; } private static List ExpandFiles (IReadOnlyList patterns) diff --git a/tests/Clet.UnitTests/MarkdownCletTests.cs b/tests/Clet.UnitTests/MarkdownCletTests.cs index d39d7bd..3fe3fc3 100644 --- a/tests/Clet.UnitTests/MarkdownCletTests.cs +++ b/tests/Clet.UnitTests/MarkdownCletTests.cs @@ -53,15 +53,14 @@ public void Aliases_ContainsMarkdown () } [Fact] - public void Options_ContainsThemeAndCat () + public void Options_ContainsThemeCatAndAllowExternalLinks () { MarkdownClet clet = new (); - Assert.Equal (2, clet.Options.Count); + Assert.Equal (3, clet.Options.Count); Assert.Equal ("theme", clet.Options [0].Name); - Assert.False (clet.Options [0].Required); Assert.Equal ("cat", clet.Options [1].Name); - Assert.False (clet.Options [1].Required); + Assert.Equal ("allow-external-links", clet.Options [2].Name); } [Fact] @@ -71,4 +70,121 @@ public void AcceptsPositionalArgs_IsTrue () Assert.True (clet.AcceptsPositionalArgs); } + + [Fact] + public void TryResolveLocalMarkdownLink_RelativeMdFile_Resolves () + { + // Create a temp .md file within a sandbox + string sandbox = Path.GetFullPath (Path.GetTempPath ()); + string tempFile = Path.Combine (sandbox, $"test-{Guid.NewGuid ()}.md"); + File.WriteAllText (tempFile, "# Test"); + + try + { + bool result = MarkdownClet.TryResolveLocalMarkdownLink ( + Path.GetFileName (tempFile), sandbox, sandbox, allowExternal: false, out string? resolved); + + Assert.True (result); + Assert.Equal (tempFile, resolved); + } + finally + { + File.Delete (tempFile); + } + } + + [Fact] + public void TryResolveLocalMarkdownLink_OutsideSandbox_Blocked () + { + string sandbox = Path.GetFullPath (Path.Combine (Path.GetTempPath (), "sandbox-test")); + string outsideFile = Path.GetFullPath (Path.Combine (Path.GetTempPath (), "outside.md")); + Directory.CreateDirectory (sandbox); + File.WriteAllText (outsideFile, "# Outside"); + + try + { + bool result = MarkdownClet.TryResolveLocalMarkdownLink ( + outsideFile, sandbox, sandbox, allowExternal: false, out _); + + Assert.False (result); + } + finally + { + File.Delete (outsideFile); + Directory.Delete (sandbox); + } + } + + [Fact] + public void TryResolveLocalMarkdownLink_OutsideSandbox_AllowedWithFlag () + { + string sandbox = Path.GetFullPath (Path.Combine (Path.GetTempPath (), "sandbox-test2")); + string outsideFile = Path.GetFullPath (Path.Combine (Path.GetTempPath (), "outside2.md")); + Directory.CreateDirectory (sandbox); + File.WriteAllText (outsideFile, "# Outside"); + + try + { + bool result = MarkdownClet.TryResolveLocalMarkdownLink ( + outsideFile, sandbox, sandbox, allowExternal: true, out string? resolved); + + Assert.True (result); + Assert.Equal (outsideFile, resolved); + } + finally + { + File.Delete (outsideFile); + Directory.Delete (sandbox); + } + } + + [Fact] + public void TryResolveLocalMarkdownLink_HttpUrl_Rejected () + { + bool result = MarkdownClet.TryResolveLocalMarkdownLink ( + "https://example.com/README.md", "/tmp", "/tmp", allowExternal: false, out _); + + Assert.False (result); + } + + [Fact] + public void TryResolveLocalMarkdownLink_NonMdFile_Rejected () + { + string sandbox = Path.GetFullPath (Path.GetTempPath ()); + string tempFile = Path.Combine (sandbox, $"test-{Guid.NewGuid ()}.txt"); + File.WriteAllText (tempFile, "not markdown"); + + try + { + bool result = MarkdownClet.TryResolveLocalMarkdownLink ( + Path.GetFileName (tempFile), sandbox, sandbox, allowExternal: false, out _); + + Assert.False (result); + } + finally + { + File.Delete (tempFile); + } + } + + [Fact] + public void TryResolveLocalMarkdownLink_FragmentStripped () + { + string sandbox = Path.GetFullPath (Path.GetTempPath ()); + string tempFile = Path.Combine (sandbox, $"test-{Guid.NewGuid ()}.md"); + File.WriteAllText (tempFile, "# Test"); + + try + { + bool result = MarkdownClet.TryResolveLocalMarkdownLink ( + Path.GetFileName (tempFile) + "#section", sandbox, sandbox, allowExternal: false, out string? resolved); + + Assert.True (result); + Assert.Equal (tempFile, resolved); + } + finally + { + File.Delete (tempFile); + } + } }