using ClawdDotNet.Core.Storage; using ClawdDotNet.Core.Tests.Infrastructure; using Shouldly; namespace ClawdDotNet.Core.Tests.Storage; /// /// Until the Linux port the containment check compared with /// OrdinalIgnoreCase — the Windows assumption that upper and lower case /// fold together. Under Linux Workspace and workspace are two /// directories, and a symbolic link can point out of the root directory /// without the plain path calculation noticing. /// /// The file system is deliberately not abstracted here: exactly the semantics this is /// about would be hidden by an abstraction. /// public sealed class PathBoundaryTests : IDisposable { private readonly string _base; private readonly string _root; public PathBoundaryTests() { _base = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N")); _root = Path.Combine(_base, "Workspace"); Directory.CreateDirectory(_root); } public void Dispose() { try { Directory.Delete(_base, recursive: true); } catch { /* Cleaning up is secondary */ } } // ═══════════════════════════════════════════════════════════ // Directory boundaries (the same on both platforms) // ═══════════════════════════════════════════════════════════ [Fact] public void A_sibling_directory_with_the_same_prefix_lies_outside() { PathBoundary.IsInside(Path.Combine(_root + "-Backup", "geheim.txt"), _root) .ShouldBeFalse(); } [Fact] public void The_root_directory_itself_lies_inside() { PathBoundary.IsInside(_root, _root).ShouldBeTrue(); PathBoundary.IsInside(_root + Path.DirectorySeparatorChar, _root).ShouldBeTrue(); } [Fact] public void A_subdirectory_lies_inside() { PathBoundary.IsInside(Path.Combine(_root, "a", "b", "c.txt"), _root).ShouldBeTrue(); } [Fact] public void Dot_dot_leads_outside() { PathBoundary.IsInside(Path.Combine(_root, "..", "evil.txt"), _root).ShouldBeFalse(); } // ═══════════════════════════════════════════════════════════ // Upper/lower case — here the platforms diverge // ═══════════════════════════════════════════════════════════ [LinuxFact] public void On_Linux_a_directory_with_different_case_is_a_different_one() { // "workspace" next to "Workspace": two real, separate directories. var lower = Path.Combine(_base, "workspace"); Directory.CreateDirectory(lower); PathBoundary.IsInside(Path.Combine(lower, "geheim.txt"), _root).ShouldBeFalse(); } [WindowsFact] public void On_Windows_a_different_case_means_the_same_directory() { var lower = Path.Combine(_base, "workspace"); PathBoundary.IsInside(Path.Combine(lower, "datei.txt"), _root).ShouldBeTrue(); } [Fact] public void The_key_for_locks_follows_the_platform() { var upper = Path.Combine(_base, "Config.json"); var lower = Path.Combine(_base, "config.json"); var same = PathBoundary.CanonicalKey(upper) == PathBoundary.CanonicalKey(lower); // Under Windows the same file — the same lock. Under Linux two files // that must not share one. same.ShouldBe(OperatingSystem.IsWindows() || OperatingSystem.IsMacOS()); } // ═══════════════════════════════════════════════════════════ // Symbolic links // ═══════════════════════════════════════════════════════════ [LinuxFact] public void A_link_out_of_the_root_directory_is_recognised() { // The case the plain path calculation does not see: "Workspace/raus" points // outside, but "Workspace/raus/beute.txt" lies lexically inside. var outside = Path.Combine(_base, "Draussen"); Directory.CreateDirectory(outside); Directory.CreateSymbolicLink(Path.Combine(_root, "raus"), outside); PathBoundary.IsInside(Path.Combine(_root, "raus", "beute.txt"), _root) .ShouldBeFalse(); } [LinuxFact] public void A_link_inside_the_root_directory_stays_allowed() { // Counter-check: whoever stays inside is not locked out. var ziel = Path.Combine(_root, "echte-daten"); Directory.CreateDirectory(ziel); Directory.CreateSymbolicLink(Path.Combine(_root, "abkuerzung"), ziel); PathBoundary.IsInside(Path.Combine(_root, "abkuerzung", "bericht.md"), _root) .ShouldBeTrue(); } [LinuxFact] public void A_root_directory_behind_a_link_does_not_lock_itself_out() { // If the workspace itself lies behind a link (common with /var → /private/var // or mounted data directories), the resolution must apply on both sides // — otherwise nothing matches any more after resolving. var real = Path.Combine(_base, "EchterOrt"); Directory.CreateDirectory(real); var over = Path.Combine(_base, "Verknuepft"); Directory.CreateSymbolicLink(over, real); PathBoundary.IsInside(Path.Combine(over, "datei.txt"), over).ShouldBeTrue(); PathBoundary.IsInside(Path.Combine(real, "datei.txt"), over).ShouldBeTrue(); } [Fact] public void Non_existent_paths_stay_checkable() { // When writing, the file does not exist yet. What does not exist cannot be a // link — the resolution must not fail on that. PathBoundary.IsInside(Path.Combine(_root, "gibt", "es", "noch", "nicht.txt"), _root) .ShouldBeTrue(); } }