Skip to content
Open
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
11 changes: 11 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
Sources/PreviewMD/Resources/Renderer/**/*.js text eol=lf
Sources/PreviewMD/Resources/Renderer/**/*.css text eol=lf
Sources/PreviewMD/Resources/Renderer/**/*.md text eol=lf
Sources/PreviewMD/Resources/Renderer/**/*.svg text eol=lf
Sources/PreviewMD/Resources/Renderer/**/*.html text eol=lf
Sources/PreviewMD/Resources/Renderer/**/*.ttf binary
Sources/PreviewMD/Resources/Renderer/**/*.woff binary
Sources/PreviewMD/Resources/Renderer/**/*.woff2 binary
ports/windows/renderer.sha256 text eol=lf
ports/windows/verify-renderer.ps1 text eol=lf
ports/windows/pin-renderer.py text eol=lf
4 changes: 4 additions & 0 deletions ports/windows/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
bin/
obj/
.vs/
*.user
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
using PreviewMD.Windows.Core;
using Xunit;

namespace PreviewMD.Windows.Core.Tests;

public class ExternalNavigationPolicyTests
{
[Fact]
public void HttpAndMailtoLaunchExternally()
{
Assert.True(ExternalNavigationPolicy.ShouldLaunchExternally("http://example.com/notes"));
Assert.True(ExternalNavigationPolicy.ShouldLaunchExternally("mailto:adam@jesion.pl"));
Assert.True(ExternalNavigationPolicy.ShouldLaunchExternally("https://example.com/image.png"));
}

[Fact]
public void AssetsShellFileAndLocalImageDoNotLaunchExternally()
{
Assert.False(ExternalNavigationPolicy.ShouldLaunchExternally("https://previewmd.assets/index.html"));
Assert.False(ExternalNavigationPolicy.ShouldLaunchExternally("https://previewmd.assets/index.html#section"));
Assert.False(ExternalNavigationPolicy.ShouldLaunchExternally("file:///C:/notes/other.md"));
Assert.False(ExternalNavigationPolicy.ShouldLaunchExternally("previewmd-local-image://resource?source=pic.png"));
Assert.False(ExternalNavigationPolicy.ShouldLaunchExternally("other.md"));
}

[Fact]
public void OnlyTheAssetsShellDocumentStays()
{
Assert.True(ExternalNavigationPolicy.IsAssetsShell("https://previewmd.assets/index.html"));
Assert.True(ExternalNavigationPolicy.IsAssetsShell("https://previewmd.assets/index.html#section"));
Assert.False(ExternalNavigationPolicy.IsAssetsShell("https://previewmd.assets/other.md"));
Assert.False(ExternalNavigationPolicy.IsAssetsShell("file:///C:/notes/other.md"));
}
}
249 changes: 249 additions & 0 deletions ports/windows/PreviewMD.Windows.Core.Tests/ImagePolicyTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
using System.Diagnostics;
using PreviewMD.Windows.Core;
using Xunit;

namespace PreviewMD.Windows.Core.Tests;

public class ImagePolicyTests
{
private const string Root = @"C:\previewmd-slice-root\notes";

[Fact]
public void ParentTraversalOutsideTheRootEscapesTheJail()
{
var decision = ImagePolicy.Decide("../pic.png", Root, _ => new FileFacts(true, 4, ".png"));

Assert.Equal(new ImageDecision.Refused(ImageRefusal.EscapesJail), decision);
}

[Fact]
public void RelativePngUnderTheRootIsAllowed()
{
var decision = ImagePolicy.Decide(
"pic.png",
Root,
path => path == @"C:\previewmd-slice-root\notes\pic.png"
? new FileFacts(true, 4, ".png")
: null);

var allowed = Assert.IsType<ImageDecision.Allowed>(decision);
Assert.Equal(@"C:\previewmd-slice-root\notes\pic.png", allowed.Path);
}

[Fact]
public void EncodedCustomSchemeSourceResolvesUnderTheRoot()
{
var decision = ImagePolicy.Decide(
"previewmd-local-image://resource?source=Renderer%2Fbadges%2Fmacos%2Fbadge.svg",
@"C:\previewmd-slice-root\WebAssets",
_ => new FileFacts(true, 80, ".svg"));

var allowed = Assert.IsType<ImageDecision.Allowed>(decision);
Assert.Equal(@"C:\previewmd-slice-root\WebAssets\Renderer\badges\macos\badge.svg", allowed.Path);
}

[Fact]
public void SvgBadgeUnderTheRootIsAllowed()
{
var decision = ImagePolicy.Decide(
"Renderer/badges/macos/badge.svg",
@"C:\previewmd-slice-root\WebAssets",
_ => new FileFacts(true, 80, ".svg"));

var allowed = Assert.IsType<ImageDecision.Allowed>(decision);
Assert.Equal(@"C:\previewmd-slice-root\WebAssets\Renderer\badges\macos\badge.svg", allowed.Path);
}

[Fact]
public void MarkdownExtensionIsUnsupported()
{
var decision = ImagePolicy.Decide("readme.md", Root, _ => new FileFacts(true, 20, ".md"));

Assert.Equal(new ImageDecision.Refused(ImageRefusal.UnsupportedType), decision);
}

[Fact]
public void DirectoryNotFoundFromLinkQueryStillAllowsRelativePng()
{
var notes = Directory.CreateTempSubdirectory("previewmd-link-miss-");
var picture = Path.Combine(notes.FullName, "pic.png");
var png = new byte[] { 137, 80, 78, 71, 13, 10, 26, 10 };
File.WriteAllBytes(picture, png);
var previousDirectory = ImagePolicy.DirectoryLinkTarget;

try
{
ImagePolicy.DirectoryLinkTarget = path =>
{
if (SamePath(path, notes.FullName))
throw new DirectoryNotFoundException(path);
return Directory.ResolveLinkTarget(path, returnFinalTarget: true);
};

var allowed = ImagePolicy.Decide("pic.png", notes.FullName, ImageInspection.Read);
var allowedPath = Assert.IsType<ImageDecision.Allowed>(allowed);
Assert.Equal(png, File.ReadAllBytes(allowedPath.Path));
}
finally
{
ImagePolicy.DirectoryLinkTarget = previousDirectory;
notes.Delete(recursive: true);
}
}

[Fact]
public void VolumeRootLinkFailureStillAllowsRelativePngAndRefusesJunctionEscape()
{
var parent = Directory.CreateTempSubdirectory("previewmd-volume-");
var notes = Directory.CreateDirectory(Path.Combine(parent.FullName, "notes"));
var picture = Path.Combine(notes.FullName, "pic.png");
var png = new byte[] { 137, 80, 78, 71, 13, 10, 26, 10 };
File.WriteAllBytes(picture, png);
var outside = Path.Combine(parent.FullName, "secret.png");
File.WriteAllBytes(outside, new byte[] { 1, 2, 3, 4 });
var directoryLink = Path.Combine(notes.FullName, "out");
var volumeRoot = Path.GetPathRoot(notes.FullName) ?? "";
var previousDirectory = ImagePolicy.DirectoryLinkTarget;

try
{
ImagePolicy.DirectoryLinkTarget = path =>
{
if (SamePath(path, volumeRoot))
throw new DirectoryNotFoundException(path);
return Directory.ResolveLinkTarget(path, returnFinalTarget: true);
};

var allowed = ImagePolicy.Decide("pic.png", notes.FullName, ImageInspection.Read);
var allowedPath = Assert.IsType<ImageDecision.Allowed>(allowed);
Assert.Equal(png, File.ReadAllBytes(allowedPath.Path));

CreateDirectoryLink(directoryLink, parent.FullName);
var escaped = ImagePolicy.Decide("out/secret.png", notes.FullName, ImageInspection.Read);
Assert.Equal(new ImageDecision.Refused(ImageRefusal.EscapesJail), escaped);
}
finally
{
ImagePolicy.DirectoryLinkTarget = previousDirectory;
RemoveLink(directoryLink);
parent.Delete(recursive: true);
}
}

private static bool SamePath(string left, string right)
{
var comparison = OperatingSystem.IsWindows()
? StringComparison.OrdinalIgnoreCase
: StringComparison.Ordinal;
return string.Equals(
Path.TrimEndingDirectorySeparator(Path.GetFullPath(left)),
Path.TrimEndingDirectorySeparator(Path.GetFullPath(right)),
comparison);
}

[Fact]
public void DocumentFolderAllowsARelativeImageAndRefusesEscape()
{
var parent = Directory.CreateTempSubdirectory("previewmd-jail-");
var notes = Directory.CreateDirectory(Path.Combine(parent.FullName, "notes"));
var img = Directory.CreateDirectory(Path.Combine(notes.FullName, "img"));
var picture = Path.Combine(img.FullName, "pic.png");
var png = new byte[] { 137, 80, 78, 71, 13, 10, 26, 10 };
File.WriteAllBytes(picture, png);
var outside = Path.Combine(parent.FullName, "secret.png");
File.WriteAllBytes(outside, new byte[] { 1, 2, 3, 4 });
var fileLink = Path.Combine(img.FullName, "escape.png");
var directoryLink = Path.Combine(notes.FullName, "out");

try
{
var allowed = ImagePolicy.Decide("img/pic.png", notes.FullName, ImageInspection.Read);
var allowedPath = Assert.IsType<ImageDecision.Allowed>(allowed);
Assert.Equal(png, File.ReadAllBytes(allowedPath.Path));

var parentEscape = ImagePolicy.Decide("../secret.png", notes.FullName, ImageInspection.Read);
Assert.Equal(new ImageDecision.Refused(ImageRefusal.EscapesJail), parentEscape);

var absoluteEscape = ImagePolicy.Decide(outside, notes.FullName, ImageInspection.Read);
Assert.Equal(new ImageDecision.Refused(ImageRefusal.EscapesJail), absoluteEscape);

CreateDirectoryLink(directoryLink, parent.FullName);
var directoryEscape = ImagePolicy.Decide("out/secret.png", notes.FullName, ImageInspection.Read);
Assert.Equal(new ImageDecision.Refused(ImageRefusal.EscapesJail), directoryEscape);

if (TryCreateFileLink(fileLink, outside))
{
var fileEscape = ImagePolicy.Decide("img/escape.png", notes.FullName, ImageInspection.Read);
Assert.Equal(new ImageDecision.Refused(ImageRefusal.EscapesJail), fileEscape);

var alias = Path.Combine(notes.FullName, "alias.png");
Assert.True(TryCreateFileLink(alias, picture));
var aliased = ImagePolicy.Decide("alias.png", notes.FullName, ImageInspection.Read);
var aliasPath = Assert.IsType<ImageDecision.Allowed>(aliased);
Assert.Equal(png, File.ReadAllBytes(aliasPath.Path));
RemoveLink(alias);
}
}
finally
{
RemoveLink(directoryLink);
RemoveLink(fileLink);
parent.Delete(recursive: true);
}
}

private static bool TryCreateFileLink(string linkPath, string targetPath)
{
try
{
File.CreateSymbolicLink(linkPath, targetPath);
return true;
}
catch (IOException) when (OperatingSystem.IsWindows())
{
return false;
}
}

private static void CreateDirectoryLink(string linkPath, string targetPath)
{
if (!OperatingSystem.IsWindows())
{
Directory.CreateSymbolicLink(linkPath, targetPath);
return;
}

using var process = new Process();
process.StartInfo.FileName = "cmd.exe";
process.StartInfo.Arguments = "/c mklink /J \"" + linkPath + "\" \"" + targetPath + "\"";
process.StartInfo.UseShellExecute = false;
process.StartInfo.CreateNoWindow = true;
process.Start();
process.WaitForExit();
if (process.ExitCode != 0)
throw new InvalidOperationException("The directory junction was not created.");
}

private static void RemoveLink(string linkPath)
{
if (Directory.Exists(linkPath))
{
Directory.Delete(linkPath, recursive: false);
return;
}

if (File.Exists(linkPath))
File.Delete(linkPath);
}

[Fact]
public void LengthAboveOneHundredMegabytesIsTooLarge()
{
var decision = ImagePolicy.Decide(
"big.png",
Root,
_ => new FileFacts(true, 100L * 1024 * 1024 + 1, ".png"));

Assert.Equal(new ImageDecision.Refused(ImageRefusal.FileTooLarge), decision);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using System.Text;
using PreviewMD.Windows.Core;
using Xunit;

namespace PreviewMD.Windows.Core.Tests;

public class MarkdownReaderTests
{
[Fact]
public void ReadStripsTheBomAndNormalizesNewlines()
{
var body = Encoding.UTF8.GetBytes("a\r\nb\rc");
var bytes = new byte[body.Length + 3];
bytes[0] = 0xEF;
bytes[1] = 0xBB;
bytes[2] = 0xBF;
body.CopyTo(bytes, 3);

Assert.Equal("a\nb\nc", MarkdownReader.Read(bytes));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<RootNamespace>PreviewMD.Windows.Core.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\PreviewMD.Windows.Core\PreviewMD.Windows.Core.csproj" />
</ItemGroup>
</Project>
Loading