using ClawdDotNet.Tools.FileRW; using Shouldly; namespace ClawdDotNet.Tools.Tests.FileRW; /// /// 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 PathBoundaryTests in Core.Tests; here it /// is about what rejects beyond that. /// 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_Ausbrueche_werden_abgelehnt(string path) { Should.Throw(() => Resolve(path)); } /// /// The Windows forms must be rejected under Linux too — but there a different /// rule applies: Path.IsPathRooted(@"C:\temp\x") is false 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 /. The result is the same on both /// platforms, the way there is not. /// /// The same rule also catches @"..\..\evil.txt" 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. /// [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_Pfade_und_UNC_Freigaben_werden_abgelehnt(string path) { Should.Throw(() => Resolve(path)); } /// /// The Linux equivalents. Under Windows these are ordinary relative paths /// that either point nowhere or are caught by the .. rule — /// that's why they run along there instead of being skipped. /// [Theory] [InlineData("/etc/passwd")] [InlineData("/root/.ssh/id_rsa")] [InlineData("../../../../etc/shadow")] public void Unix_Systempfade_werden_abgelehnt(string path) { Should.Throw(() => Resolve(path)); } [Theory] [InlineData("datei.txt:versteckt")] [InlineData("datei.txt:$DATA")] public void Alternate_Data_Streams_werden_abgelehnt(string path) { // An ADS would otherwise bypass the extension check: "x.txt:evil.exe". Should.Throw(() => Resolve(path)); } /// /// The actual core of S6: a neighboring directory with the same prefix. /// [Fact] public void Ein_Nachbarverzeichnis_mit_gleichem_Praefix_gilt_als_ausserhalb() { var backup = _root + "-Backup"; WorkspacePath.IsInside(Path.Combine(backup, "geheim.txt"), _root).ShouldBeFalse(); } [Fact] public void Auch_bei_Rootangabe_mit_Trenner_bleibt_das_Nachbarverzeichnis_aussen() { 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 Pfade_innerhalb_des_Workspace_werden_aufgeloest(string path) { var resolved = Resolve(path); WorkspacePath.IsInside(resolved, _root).ShouldBeTrue(); } [Theory] [InlineData(null)] [InlineData("")] [InlineData(" ")] [InlineData(".")] public void Leere_Angaben_ergeben_das_Wurzelverzeichnis(string? path) { var resolved = Resolve(path); resolved.TrimEnd(Path.DirectorySeparatorChar) .ShouldBe(Path.GetFullPath(_root).TrimEnd(Path.DirectorySeparatorChar)); } [Fact] public void Das_Wurzelverzeichnis_selbst_gilt_als_innerhalb() { WorkspacePath.IsInside(_root, _root).ShouldBeTrue(); } [Fact] public void Ein_Unterverzeichnis_mit_aehnlichem_Namen_bleibt_innerhalb() { // 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(); } }