using Microsoft.Extensions.Logging; using System.Collections.Concurrent; using System.Diagnostics; using System.Runtime.InteropServices; namespace LightlessSync.Utils { public static class FileSystemHelper { public enum FilesystemType { Unknown = 0, NTFS, // Compressable on file level Btrfs, // Compressable on file level Ext4, // Uncompressable Xfs, // Uncompressable Apfs, // Compressable on OS HfsPlus, // Compressable on OS Fat, // Uncompressable Exfat, // Uncompressable Zfs // Compressable, not on file level } private const string _mountPath = "/proc/mounts"; private const int _defaultBlockSize = 4096; private static readonly Dictionary _blockSizeCache = new(StringComparer.OrdinalIgnoreCase); private static readonly ConcurrentDictionary _filesystemTypeCache = new(StringComparer.OrdinalIgnoreCase); public static FilesystemType GetFilesystemType(string filePath, bool isWine = false) { try { string rootPath; if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && (!IsProbablyWine() || !isWine)) { var info = new FileInfo(filePath); var dir = info.Directory ?? new DirectoryInfo(filePath); rootPath = dir.Root.FullName; } else { rootPath = GetMountPoint(filePath); if (string.IsNullOrEmpty(rootPath)) rootPath = "/"; } if (_filesystemTypeCache.TryGetValue(rootPath, out var cachedType)) return cachedType; FilesystemType detected; if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && (!IsProbablyWine() || !isWine)) { var root = new DriveInfo(rootPath); var format = root.DriveFormat?.ToUpperInvariant() ?? string.Empty; detected = format switch { "NTFS" => FilesystemType.NTFS, "FAT32" => FilesystemType.Fat, "EXFAT" => FilesystemType.Exfat, _ => FilesystemType.Unknown }; } else { detected = GetLinuxFilesystemType(filePath); } if (isWine || IsProbablyWine()) { switch (detected) { case FilesystemType.NTFS: case FilesystemType.Unknown: { var linuxDetected = GetLinuxFilesystemType(filePath); if (linuxDetected != FilesystemType.Unknown) { detected = linuxDetected; } break; } } } _filesystemTypeCache[rootPath] = detected; return detected; } catch { return FilesystemType.Unknown; } } private static string GetMountPoint(string filePath) { try { var path = Path.GetFullPath(filePath); if (!File.Exists(_mountPath)) return "/"; var mounts = File.ReadAllLines(_mountPath); string bestMount = "/"; foreach (var line in mounts) { var parts = line.Split(' '); if (parts.Length < 3) continue; var mountPoint = parts[1].Replace("\\040", " ", StringComparison.Ordinal); string normalizedMount; try { normalizedMount = Path.GetFullPath(mountPoint); } catch { normalizedMount = mountPoint; } if (path.StartsWith(normalizedMount, StringComparison.Ordinal) && normalizedMount.Length > bestMount.Length) { bestMount = normalizedMount; } } return bestMount; } catch { return "/"; } } public static string GetMountOptionsForPath(string path) { try { var fullPath = Path.GetFullPath(path); var mounts = File.ReadAllLines("/proc/mounts"); string bestMount = string.Empty; string mountOptions = string.Empty; foreach (var line in mounts) { var parts = line.Split(' '); if (parts.Length < 4) continue; var mountPoint = parts[1].Replace("\\040", " ", StringComparison.Ordinal); string normalized; try { normalized = Path.GetFullPath(mountPoint); } catch { normalized = mountPoint; } if (fullPath.StartsWith(normalized, StringComparison.Ordinal) && normalized.Length > bestMount.Length) { bestMount = normalized; mountOptions = parts[3]; } } return mountOptions; } catch (Exception ex) { return string.Empty; } } private static FilesystemType GetLinuxFilesystemType(string filePath) { try { var mountPoint = GetMountPoint(filePath); var mounts = File.ReadAllLines(_mountPath); foreach (var line in mounts) { var parts = line.Split(' '); if (parts.Length < 3) continue; var mount = parts[1].Replace("\\040", " ", StringComparison.Ordinal); if (string.Equals(mount, mountPoint, StringComparison.Ordinal)) { var fstype = parts[2].ToLowerInvariant(); return fstype switch { "btrfs" => FilesystemType.Btrfs, "ext4" => FilesystemType.Ext4, "xfs" => FilesystemType.Xfs, "zfs" => FilesystemType.Zfs, "apfs" => FilesystemType.Apfs, "hfsplus" => FilesystemType.HfsPlus, _ => FilesystemType.Unknown }; } } return FilesystemType.Unknown; } catch { return FilesystemType.Unknown; } } public static int GetBlockSizeForPath(string path, ILogger? logger = null, bool isWine = false) { try { if (string.IsNullOrWhiteSpace(path)) return _defaultBlockSize; var fi = new FileInfo(path); if (!fi.Exists) return _defaultBlockSize; var root = fi.Directory?.Root.FullName.ToLowerInvariant() ?? "/"; if (_blockSizeCache.TryGetValue(root, out int cached)) return cached; if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && !isWine) { int result = GetDiskFreeSpaceW(root, out uint sectorsPerCluster, out uint bytesPerSector, out _, out _); if (result == 0) { logger?.LogWarning("Failed to determine block size for {root}", root); return _defaultBlockSize; } int clusterSize = (int)(sectorsPerCluster * bytesPerSector); _blockSizeCache[root] = clusterSize; logger?.LogTrace("NTFS cluster size for {root}: {cluster}", root, clusterSize); return clusterSize; } string realPath = fi.FullName; if (isWine && realPath.StartsWith("Z:\\", StringComparison.OrdinalIgnoreCase)) { realPath = "/" + realPath.Substring(3).Replace('\\', '/'); } var psi = new ProcessStartInfo { FileName = "/bin/bash", Arguments = $"-c \"stat -f -c %s '{realPath.Replace("'", "'\\''")}'\"", RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true, WorkingDirectory = "/" }; using var proc = Process.Start(psi); string stdout = proc?.StandardOutput.ReadToEnd().Trim() ?? ""; string _stderr = proc?.StandardError.ReadToEnd() ?? ""; try { proc?.WaitForExit(); } catch (Exception ex) { logger?.LogTrace(ex, "stat WaitForExit failed under Wine; ignoring"); } if (!(!int.TryParse(stdout, out int block) || block <= 0)) { _blockSizeCache[root] = block; logger?.LogTrace("Filesystem block size via stat for {root}: {block}", root, block); return block; } logger?.LogTrace("stat did not return valid block size for {file}, output: {out}", fi.FullName, stdout); _blockSizeCache[root] = _defaultBlockSize; return _defaultBlockSize; } catch (Exception ex) { logger?.LogTrace(ex, "Error determining block size for {path}", path); return _defaultBlockSize; } } [DllImport("kernel32.dll", SetLastError = true, PreserveSig = true)] private static extern int GetDiskFreeSpaceW([In, MarshalAs(UnmanagedType.LPWStr)] string lpRootPathName, out uint lpSectorsPerCluster, out uint lpBytesPerSector, out uint lpNumberOfFreeClusters, out uint lpTotalNumberOfClusters); //Extra check on public static bool IsProbablyWine() => Environment.GetEnvironmentVariable("WINELOADERNOEXEC") != null || Environment.GetEnvironmentVariable("WINEDLLPATH") != null || Directory.Exists("/proc/self") && File.Exists("/proc/mounts"); } }