diff --git a/Source/LibationFileManager/DiskSpaceHelper.cs b/Source/LibationFileManager/DiskSpaceHelper.cs index 264ff55c..9498080d 100644 --- a/Source/LibationFileManager/DiskSpaceHelper.cs +++ b/Source/LibationFileManager/DiskSpaceHelper.cs @@ -79,7 +79,10 @@ public static string NormalizePathForDriveQuery(string path) } /// - /// Returns the volume root used for free-space queries (e.g. C:\ or \\server\share\), or null if unknown. + /// Returns the volume root used for free-space queries and drive grouping + /// (e.g. C:\, \\server\share\, or a Unix mount such as /var/home), or null if unknown. + /// On Unix, is not used: it always returns / for absolute paths and + /// mis-attributes free space when Books/In progress live on another mount (e.g. Bazzite /var/home). /// public static string? GetPathRootForDiskSpaceCheck(string? path) { @@ -90,8 +93,14 @@ public static string NormalizePathForDriveQuery(string path) { var normalized = NormalizePathForDriveQuery(path); var fullPath = Path.GetFullPath(normalized); - var root = Path.GetPathRoot(fullPath); - return string.IsNullOrWhiteSpace(root) ? null : root; + + if (OperatingSystem.IsWindows()) + { + var root = Path.GetPathRoot(fullPath); + return string.IsNullOrWhiteSpace(root) ? null : root; + } + + return GetUnixMountPoint(fullPath); } catch { @@ -99,6 +108,81 @@ public static string NormalizePathForDriveQuery(string path) } } + /// + /// Resolves symlinks in component-by-component so that e.g. + /// /home/user/Books becomes /var/home/user/Books when /home/var/home. + /// Non-existent trailing segments are kept so a not-yet-created Books folder still resolves. + /// + public static string ResolvePathSymlinks(string path) + { + var fullPath = Path.GetFullPath(path); + if (OperatingSystem.IsWindows()) + return fullPath; + + var parts = fullPath.Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries); + var resolved = "/"; + + foreach (var part in parts) + { + var next = resolved == "/" + ? "/" + part + : resolved + Path.DirectorySeparatorChar + part; + + try + { + var dirInfo = new DirectoryInfo(next); + if (dirInfo.Exists) + { + var target = dirInfo.ResolveLinkTarget(returnFinalTarget: true); + resolved = target?.FullName ?? dirInfo.FullName; + continue; + } + + var fileInfo = new FileInfo(next); + if (fileInfo.Exists) + { + var target = fileInfo.ResolveLinkTarget(returnFinalTarget: true); + resolved = target?.FullName ?? fileInfo.FullName; + continue; + } + } + catch + { + // Keep syntactic path when link resolution fails for a segment. + } + + resolved = next; + } + + return resolved; + } + + /// + /// Pure helper: longest mount-point prefix of from . + /// Used for Unix volume identity and unit tests (injectable mount list). + /// + public static string? FindLongestMountPointPrefix(string fullPath, IEnumerable mountPoints) + { + if (string.IsNullOrWhiteSpace(fullPath)) + return null; + + string? best = null; + + foreach (var mount in mountPoints) + { + if (string.IsNullOrWhiteSpace(mount)) + continue; + + if (!IsPathOnMount(fullPath, mount)) + continue; + + if (best is null || mount.Length > best.Length) + best = mount; + } + + return best; + } + /// /// Returns free bytes for the volume containing , or null if unknown. /// Null means preflight cannot warn/block on that root (writable shares with no capacity API, offline drive, bad path). @@ -147,7 +231,10 @@ public static long GetCriticalFreeBytesForDriveUsage(BackupDriveUsage usage) public static IReadOnlyList GetBackupDriveSpaces(Configuration config, int bookCount) { - var pathsByRoot = new Dictionary paths, BackupDriveUsage usage)>(StringComparer.OrdinalIgnoreCase); + var pathComparer = OperatingSystem.IsWindows() + ? StringComparer.OrdinalIgnoreCase + : StringComparer.Ordinal; + var pathsByRoot = new Dictionary paths, BackupDriveUsage usage)>(pathComparer); void addPath(string? path, BackupDriveUsage usageFlag) { @@ -164,7 +251,7 @@ void addPath(string? path, BackupDriveUsage usageFlag) return; } - var root = Path.GetPathRoot(fullPath); + var root = GetPathRootForDiskSpaceCheck(fullPath); if (string.IsNullOrWhiteSpace(root)) return; @@ -173,7 +260,7 @@ void addPath(string? path, BackupDriveUsage usageFlag) else entry.usage |= usageFlag; - if (!entry.paths.Contains(fullPath, StringComparer.OrdinalIgnoreCase)) + if (!entry.paths.Contains(fullPath, pathComparer)) entry.paths.Add(fullPath); pathsByRoot[root] = entry; @@ -210,11 +297,46 @@ public static bool AnyDriveCriticallyLow(IReadOnlyList drives) => drives.Any(d => d.AvailableBytes is not null && d.AvailableBytes < GetCriticalFreeBytesForDriveUsage(d.Usage)); public readonly record struct BackupDriveSpace( - /// Volume root used for free-space display (e.g. C:\ or \\nas\library\). + /// Volume root used for free-space display (e.g. C:\ , \\nas\library\ , or /var/home). string DriveRoot, IReadOnlyList Paths, /// Null when could not query this root. long? AvailableBytes, long RequiredBytes, BackupDriveUsage Usage); + + private static string? GetUnixMountPoint(string fullPath) + { + var resolved = ResolvePathSymlinks(fullPath); + var mounts = DriveInfo.GetDrives() + .Where(static d => d.IsReady) + .Select(static d => d.Name); + + return FindLongestMountPointPrefix(resolved, mounts) + ?? Path.GetPathRoot(resolved); + } + + /// + /// Unix mount paths always use '/'. Do not use — + /// on Windows that is '\', which would break pure unit tests and any cross-OS path handling. + /// + private static bool IsPathOnMount(string fullPath, string mount) + { + const char unixSep = '/'; + + var mountTrimmed = mount.TrimEnd(unixSep); + if (mountTrimmed.Length == 0) + mountTrimmed = "/"; + + if (fullPath.Equals(mountTrimmed, StringComparison.Ordinal)) + return true; + + // Root mount "/" prefixes every absolute Unix path. + if (mountTrimmed == "/") + return fullPath.StartsWith("/", StringComparison.Ordinal); + + var prefix = mount.EndsWith(unixSep) ? mount : mount + unixSep; + + return fullPath.StartsWith(prefix, StringComparison.Ordinal); + } } diff --git a/Source/_Tests/LibationFileManager.Tests/DiskSpaceHelperTests.cs b/Source/_Tests/LibationFileManager.Tests/DiskSpaceHelperTests.cs index 8ca06593..a827533a 100644 --- a/Source/_Tests/LibationFileManager.Tests/DiskSpaceHelperTests.cs +++ b/Source/_Tests/LibationFileManager.Tests/DiskSpaceHelperTests.cs @@ -2,6 +2,7 @@ using Microsoft.VisualStudio.TestTools.UnitTesting; using System; using System.IO; +using System.Linq; namespace LibationFileManager.Tests; @@ -33,27 +34,121 @@ public void ErrorMessageIndicatesDiskFull_matches_common_phrases() } [TestMethod] - [OSCondition(OperatingSystems.Windows)] - public void Windows_NormalizePathForDriveQuery_strips_extended_prefix() + [OSCondition(OperatingSystems.Windows)] + public void Windows_NormalizePathForDriveQuery_strips_extended_prefix() { Assert.AreEqual(@"C:\Audiobooks\Books", DiskSpaceHelper.NormalizePathForDriveQuery(@"\\?\C:\Audiobooks\Books")); Assert.AreEqual(@"\\server\share\Books", DiskSpaceHelper.NormalizePathForDriveQuery(@"\\?\UNC\server\share\Books")); } [TestMethod] - [OSCondition(OperatingSystems.Windows)] - public void Windows_GetPathRootForDiskSpaceCheck_strips_extended_prefix() + [OSCondition(OperatingSystems.Windows)] + public void Windows_GetPathRootForDiskSpaceCheck_strips_extended_prefix() { Assert.AreEqual(@"C:\", DiskSpaceHelper.GetPathRootForDiskSpaceCheck(@"\\?\C:\Audiobooks\Books")); } [TestMethod] - public void GetPathRootForDiskSpaceCheck_unix_absolute_path() + public void FindLongestMountPointPrefix_prefers_var_home_over_root() + { + var mounts = new[] { "/", "/boot", "/var/home", "/tmp" }; + + Assert.AreEqual( + "/var/home", + DiskSpaceHelper.FindLongestMountPointPrefix("/var/home/user/Libation/Books", mounts)); + Assert.AreEqual( + "/var/home", + DiskSpaceHelper.FindLongestMountPointPrefix("/var/home", mounts)); + Assert.AreEqual( + "/tmp", + DiskSpaceHelper.FindLongestMountPointPrefix("/tmp/Libation-user", mounts)); + Assert.AreEqual( + "/", + DiskSpaceHelper.FindLongestMountPointPrefix("/usr/local/bin", mounts)); + } + + [TestMethod] + public void FindLongestMountPointPrefix_does_not_match_partial_segment() + { + // "/var/home2/..." must not match mount "/var/home" + var mounts = new[] { "/", "/var/home" }; + + Assert.AreEqual( + "/", + DiskSpaceHelper.FindLongestMountPointPrefix("/var/home2/books", mounts)); + } + + [TestMethod] + public void FindLongestMountPointPrefix_home_without_symlink_resolution_stays_on_root() + { + // Documents the Bazzite trap: /home/... does not prefix-match /var/home until symlinks are resolved. + var mounts = new[] { "/", "/var/home", "/tmp" }; + + Assert.AreEqual( + "/", + DiskSpaceHelper.FindLongestMountPointPrefix("/home/user/Libation/Books", mounts)); + } + + [TestMethod] + public void ResolvePathSymlinks_follows_home_to_var_home_style_link() + { + if (OperatingSystem.IsWindows()) + Assert.Inconclusive("Skipped because OS is Windows."); + + var root = Path.Combine(Path.GetTempPath(), "libation-diskspace-" + Guid.NewGuid().ToString("N")); + var varHome = Path.Combine(root, "var", "home"); + var homeLink = Path.Combine(root, "home"); + var booksUnderHome = Path.Combine(homeLink, "user", "Libation", "Books"); + + try + { + Directory.CreateDirectory(varHome); + Directory.CreateSymbolicLink(homeLink, varHome); + + var resolved = DiskSpaceHelper.ResolvePathSymlinks(booksUnderHome); + var expected = Path.Combine(varHome, "user", "Libation", "Books"); + + Assert.AreEqual(expected, resolved); + + var mounts = new[] { "/", varHome, Path.Combine(root, "tmp") }; + Assert.AreEqual( + varHome, + DiskSpaceHelper.FindLongestMountPointPrefix(resolved, mounts)); + } + finally + { + try + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + catch + { + // best-effort cleanup + } + } + } + + [TestMethod] + public void GetPathRootForDiskSpaceCheck_unix_uses_real_mount_not_always_slash() { if (OperatingSystem.IsWindows()) Assert.Inconclusive("Skipped because OS is Windows."); - Assert.AreEqual("/", DiskSpaceHelper.GetPathRootForDiskSpaceCheck("/home/user/Libation/Books")); + // Pick any ready non-root mount that exists on this machine (often /tmp or similar). + var nonRootMount = DriveInfo.GetDrives() + .Where(d => d.IsReady && d.Name is not "/" and not null) + .OrderByDescending(d => d.Name.Length) + .FirstOrDefault(); + + if (nonRootMount is null) + Assert.Inconclusive("No non-root mounts available to assert against."); + + var probePath = Path.Combine(nonRootMount.Name.TrimEnd(Path.DirectorySeparatorChar), "libation-probe"); + var root = DiskSpaceHelper.GetPathRootForDiskSpaceCheck(probePath); + + Assert.AreEqual(nonRootMount.Name, root); + Assert.AreNotEqual("/", root); } [TestMethod]