Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 103 additions & 3 deletions src/Clet/Clets/Viewer/MarkdownClet.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@
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;
Expand Down Expand Up @@ -103,6 +106,15 @@
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",
Expand All @@ -129,6 +141,16 @@

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

Check warning on line 147 in src/Clet/Clets/Viewer/MarkdownClet.cs

View workflow job for this annotation

GitHub Actions / build-and-test

Possible null reference argument for parameter 'filePath' in 'void LoadFile(string filePath)'.

Check warning on line 147 in src/Clet/Clets/Viewer/MarkdownClet.cs

View workflow job for this annotation

GitHub Actions / build-and-test

Possible null reference argument for parameter 'filePath' in 'void LoadFile(string filePath)'.

Check warning on line 147 in src/Clet/Clets/Viewer/MarkdownClet.cs

View workflow job for this annotation

GitHub Actions / build-and-test

Possible null reference argument for parameter 'filePath' in 'void LoadFile(string filePath)'.

Check warning on line 147 in src/Clet/Clets/Viewer/MarkdownClet.cs

View workflow job for this annotation

GitHub Actions / build-and-test

Possible null reference argument for parameter 'filePath' in 'void LoadFile(string filePath)'.
e.Handled = true;

return;
}

// SurfaceOnly: show URL in status bar, don't open
statusShortcut.Title = e.Url;
e.Handled = true;
};
Expand Down Expand Up @@ -258,13 +280,91 @@

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

/// <summary>
/// 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.
/// </summary>
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<string> ExpandFiles (IReadOnlyList<string> patterns)
Expand Down
124 changes: 120 additions & 4 deletions tests/Clet.UnitTests/MarkdownCletTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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);
}
}
}
Loading