Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 129 additions & 7 deletions Source/LibationFileManager/DiskSpaceHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,10 @@ public static string NormalizePathForDriveQuery(string path)
}

/// <summary>
/// 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>C:\</c>, <c>\\server\share\</c>, or a Unix mount such as <c>/var/home</c>), or null if unknown.
/// On Unix, <see cref="Path.GetPathRoot"/> is not used: it always returns <c>/</c> for absolute paths and
/// mis-attributes free space when Books/In progress live on another mount (e.g. Bazzite <c>/var/home</c>).
/// </summary>
public static string? GetPathRootForDiskSpaceCheck(string? path)
{
Expand All @@ -90,15 +93,96 @@ 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
{
return null;
}
}

/// <summary>
/// Resolves symlinks in <paramref name="path"/> component-by-component so that e.g.
/// <c>/home/user/Books</c> becomes <c>/var/home/user/Books</c> when <c>/home</c> → <c>/var/home</c>.
/// Non-existent trailing segments are kept so a not-yet-created Books folder still resolves.
/// </summary>
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;
}

/// <summary>
/// Pure helper: longest mount-point prefix of <paramref name="fullPath"/> from <paramref name="mountPoints"/>.
/// Used for Unix volume identity and unit tests (injectable mount list).
/// </summary>
public static string? FindLongestMountPointPrefix(string fullPath, IEnumerable<string> 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;
}

/// <summary>
/// Returns free bytes for the volume containing <paramref name="path"/>, or null if unknown.
/// Null means preflight cannot warn/block on that root (writable shares with no capacity API, offline drive, bad path).
Expand Down Expand Up @@ -147,7 +231,10 @@ public static long GetCriticalFreeBytesForDriveUsage(BackupDriveUsage usage)

public static IReadOnlyList<BackupDriveSpace> GetBackupDriveSpaces(Configuration config, int bookCount)
{
var pathsByRoot = new Dictionary<string, (List<string> paths, BackupDriveUsage usage)>(StringComparer.OrdinalIgnoreCase);
var pathComparer = OperatingSystem.IsWindows()
? StringComparer.OrdinalIgnoreCase
: StringComparer.Ordinal;
var pathsByRoot = new Dictionary<string, (List<string> paths, BackupDriveUsage usage)>(pathComparer);

void addPath(string? path, BackupDriveUsage usageFlag)
{
Expand All @@ -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;

Expand All @@ -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;
Expand Down Expand Up @@ -210,11 +297,46 @@ public static bool AnyDriveCriticallyLow(IReadOnlyList<BackupDriveSpace> drives)
=> drives.Any(d => d.AvailableBytes is not null && d.AvailableBytes < GetCriticalFreeBytesForDriveUsage(d.Usage));

public readonly record struct BackupDriveSpace(
/// <summary>Volume root used for free-space display (e.g. C:\ or \\nas\library\).</summary>
/// <summary>Volume root used for free-space display (e.g. C:\ , \\nas\library\ , or /var/home).</summary>
string DriveRoot,
IReadOnlyList<string> Paths,
/// <summary>Null when <see cref="TryGetAvailableFreeBytes"/> could not query this root.</summary>
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);
}

/// <summary>
/// Unix mount paths always use '/'. Do not use <see cref="Path.DirectorySeparatorChar"/> —
/// on Windows that is '\', which would break pure unit tests and any cross-OS path handling.
/// </summary>
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);
}
}
107 changes: 101 additions & 6 deletions Source/_Tests/LibationFileManager.Tests/DiskSpaceHelperTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using Microsoft.VisualStudio.TestTools.UnitTesting;
using System;
using System.IO;
using System.Linq;

namespace LibationFileManager.Tests;

Expand Down Expand Up @@ -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]
Expand Down
Loading