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]