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 Ein_Nachbarverzeichnis_mit_gleichem_Praefix_liegt_ausserhalb() { PathBoundary.IsInside(Path.Combine(_root + "-Backup", "geheim.txt"), _root) .ShouldBeFalse(); } [Fact] public void Das_Wurzelverzeichnis_selbst_liegt_innerhalb() { PathBoundary.IsInside(_root, _root).ShouldBeTrue(); PathBoundary.IsInside(_root + Path.DirectorySeparatorChar, _root).ShouldBeTrue(); } [Fact] public void Ein_Unterverzeichnis_liegt_innerhalb() { PathBoundary.IsInside(Path.Combine(_root, "a", "b", "c.txt"), _root).ShouldBeTrue(); } [Fact] public void Punkt_Punkt_fuehrt_hinaus() { PathBoundary.IsInside(Path.Combine(_root, "..", "evil.txt"), _root).ShouldBeFalse(); } // ═══════════════════════════════════════════════════════════ // Upper/lower case — here the platforms diverge // ═══════════════════════════════════════════════════════════ [LinuxFact] public void Unter_Linux_ist_ein_Verzeichnis_mit_anderer_Schreibweise_ein_anderes() { // "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 Unter_Windows_meint_eine_andere_Schreibweise_dasselbe_Verzeichnis() { var lower = Path.Combine(_base, "workspace"); PathBoundary.IsInside(Path.Combine(lower, "datei.txt"), _root).ShouldBeTrue(); } [Fact] public void Der_Schluessel_fuer_Sperren_folgt_der_Plattform() { var upper = Path.Combine(_base, "Config.json"); var lower = Path.Combine(_base, "config.json"); var gleich = PathBoundary.CanonicalKey(upper) == PathBoundary.CanonicalKey(lower); // Under Windows the same file — the same lock. Under Linux two files // that must not share one. gleich.ShouldBe(OperatingSystem.IsWindows() || OperatingSystem.IsMacOS()); } // ═══════════════════════════════════════════════════════════ // Symbolic links // ═══════════════════════════════════════════════════════════ [LinuxFact] public void Eine_Verknuepfung_aus_dem_Wurzelverzeichnis_heraus_wird_erkannt() { // The case the plain path calculation does not see: "Workspace/raus" points // outside, but "Workspace/raus/beute.txt" lies lexically inside. var draussen = Path.Combine(_base, "Draussen"); Directory.CreateDirectory(draussen); Directory.CreateSymbolicLink(Path.Combine(_root, "raus"), draussen); PathBoundary.IsInside(Path.Combine(_root, "raus", "beute.txt"), _root) .ShouldBeFalse(); } [LinuxFact] public void Eine_Verknuepfung_innerhalb_des_Wurzelverzeichnisses_bleibt_erlaubt() { // 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 Ein_Wurzelverzeichnis_hinter_einer_Verknuepfung_sperrt_sich_nicht_selbst_aus() { // 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 echt = Path.Combine(_base, "EchterOrt"); Directory.CreateDirectory(echt); var ueber = Path.Combine(_base, "Verknuepft"); Directory.CreateSymbolicLink(ueber, echt); PathBoundary.IsInside(Path.Combine(ueber, "datei.txt"), ueber).ShouldBeTrue(); PathBoundary.IsInside(Path.Combine(echt, "datei.txt"), ueber).ShouldBeTrue(); } [Fact] public void Nicht_existierende_Pfade_bleiben_pruefbar() { // 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(); } }