Mit Roslyn umbenannt (Verweise folgen, Zeichenketten bleiben unberuehrt). Nicht umbenannt, weil gespeichert oder ueber die API gebunden (Stufe 4e): Properties, Record-Parameter, Enum-Member sowie die Parameter der Endpunkt-Handler (Routen-/Query-Bindung ueber den Namen). Die JSON-Felder anonymer Objekte bleiben ausdruecklich benannt (JsonFilter: pfad). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
164 lines
6.3 KiB
C#
164 lines
6.3 KiB
C#
using ClawdDotNet.Tools.FileRW;
|
|
using Shouldly;
|
|
|
|
namespace ClawdDotNet.Tools.Tests.FileRW;
|
|
|
|
/// <summary>
|
|
/// S6 from the inventory: the path check only compared string prefixes.
|
|
/// Without a trailing directory separator a root "…\Workspace" thus also allowed
|
|
/// "…\Workspace-Backup\…".
|
|
///
|
|
/// The file system is deliberately NOT abstracted here — the tests are supposed to check the real
|
|
/// path semantics of the respective platform (.., UNC, alternate data streams,
|
|
/// trailing dots, under Linux additionally upper/lower case and
|
|
/// links). An abstraction would hide exactly the class of errors this is
|
|
/// about.
|
|
///
|
|
/// The directory boundary itself is checked by <c>PathBoundaryTests</c> in Core.Tests; here it
|
|
/// is about what <see cref="WorkspacePath.Resolve"/> rejects beyond that.
|
|
/// </summary>
|
|
public sealed class WorkspacePathTests : IDisposable
|
|
{
|
|
private readonly string _root;
|
|
|
|
public WorkspacePathTests()
|
|
{
|
|
_root = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"), "Workspace");
|
|
Directory.CreateDirectory(_root);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
try { Directory.Delete(Path.GetDirectoryName(_root)!, recursive: true); }
|
|
catch { /* Cleaning up is secondary */ }
|
|
}
|
|
|
|
private string Resolve(string? relative) => WorkspacePath.Resolve(_root, relative, "personal");
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// Breakout attempts
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
[Theory]
|
|
[InlineData("../../../Windows/System32/drivers/etc/hosts")]
|
|
[InlineData(@"..\..\evil.txt")]
|
|
[InlineData("unterordner/../../../ausserhalb.txt")]
|
|
[InlineData("./../../evil.txt")]
|
|
[InlineData("..")]
|
|
public void Relative_escapes_are_rejected(string path)
|
|
{
|
|
Should.Throw<UnauthorizedAccessException>(() => Resolve(path));
|
|
}
|
|
|
|
/// <summary>
|
|
/// The Windows forms must be rejected under Linux too — but there a different
|
|
/// rule applies: <c>Path.IsPathRooted(@"C:\temp\x")</c> is <c>false</c> under Linux,
|
|
/// because that is simply a file name with a colon. These cases are caught
|
|
/// by the character rule: colon and backslash are not permitted in the
|
|
/// path, separation is done with <c>/</c>. The result is the same on both
|
|
/// platforms, the way there is not.
|
|
///
|
|
/// The same rule also catches <c>@"..\..\evil.txt"</c> from the test
|
|
/// above under Linux. That was never a breakout there — the backslash separates
|
|
/// nothing under Linux, the file would have landed in the workspace —, but the test expected a
|
|
/// rejection and got none.
|
|
/// </summary>
|
|
[Theory]
|
|
[InlineData(@"C:\Windows\System32\config\SAM")]
|
|
[InlineData(@"\\server\share\evil.txt")]
|
|
[InlineData("//server/share/evil.txt")]
|
|
[InlineData(@"C:\temp\datei.txt")]
|
|
public void Absolute_paths_and_UNC_shares_are_rejected(string path)
|
|
{
|
|
Should.Throw<UnauthorizedAccessException>(() => Resolve(path));
|
|
}
|
|
|
|
/// <summary>
|
|
/// The Linux equivalents. Under Windows these are ordinary relative paths
|
|
/// that either point nowhere or are caught by the <c>..</c> rule —
|
|
/// that's why they run along there instead of being skipped.
|
|
/// </summary>
|
|
[Theory]
|
|
[InlineData("/etc/passwd")]
|
|
[InlineData("/root/.ssh/id_rsa")]
|
|
[InlineData("../../../../etc/shadow")]
|
|
public void Unix_system_paths_are_rejected(string path)
|
|
{
|
|
Should.Throw<UnauthorizedAccessException>(() => Resolve(path));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("datei.txt:versteckt")]
|
|
[InlineData("datei.txt:$DATA")]
|
|
public void Alternate_data_streams_are_rejected(string path)
|
|
{
|
|
// An ADS would otherwise bypass the extension check: "x.txt:evil.exe".
|
|
Should.Throw<UnauthorizedAccessException>(() => Resolve(path));
|
|
}
|
|
|
|
/// <summary>
|
|
/// The actual core of S6: a neighboring directory with the same prefix.
|
|
/// </summary>
|
|
[Fact]
|
|
public void A_sibling_directory_with_the_same_prefix_counts_as_outside()
|
|
{
|
|
var backup = _root + "-Backup";
|
|
|
|
WorkspacePath.IsInside(Path.Combine(backup, "geheim.txt"), _root).ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public void Even_with_root_given_with_separator_the_sibling_directory_stays_outside()
|
|
{
|
|
var rootWithSeparator = _root + Path.DirectorySeparatorChar;
|
|
|
|
WorkspacePath.IsInside(_root + "-Backup" + Path.DirectorySeparatorChar + "x.txt", rootWithSeparator)
|
|
.ShouldBeFalse();
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// Counter-checks — normal use must work
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
[Theory]
|
|
[InlineData("bericht.md")]
|
|
[InlineData("unterordner/bericht.md")]
|
|
[InlineData("a/b/c/tief.json")]
|
|
[InlineData("./bericht.md")]
|
|
[InlineData("unterordner/../bericht.md")]
|
|
public void Paths_inside_the_workspace_are_resolved(string path)
|
|
{
|
|
var resolved = Resolve(path);
|
|
|
|
WorkspacePath.IsInside(resolved, _root).ShouldBeTrue();
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(null)]
|
|
[InlineData("")]
|
|
[InlineData(" ")]
|
|
[InlineData(".")]
|
|
public void Empty_values_give_the_root_directory(string? path)
|
|
{
|
|
var resolved = Resolve(path);
|
|
|
|
resolved.TrimEnd(Path.DirectorySeparatorChar)
|
|
.ShouldBe(Path.GetFullPath(_root).TrimEnd(Path.DirectorySeparatorChar));
|
|
}
|
|
|
|
[Fact]
|
|
public void The_root_directory_itself_counts_as_inside()
|
|
{
|
|
WorkspacePath.IsInside(_root, _root).ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public void A_subdirectory_with_a_similar_name_stays_inside()
|
|
{
|
|
// Counter-check to the prefix rule: within the root everything is allowed.
|
|
var inner = Path.Combine(_root, "Workspace-Backup", "x.txt");
|
|
|
|
WorkspacePath.IsInside(inner, _root).ShouldBeTrue();
|
|
}
|
|
}
|