diff --git a/SabreTools.IO.Extensions.Test/DeviceExtensionsTests.cs b/SabreTools.IO.Extensions.Test/DeviceExtensionsTests.cs new file mode 100644 index 0000000..d9f0eb7 --- /dev/null +++ b/SabreTools.IO.Extensions.Test/DeviceExtensionsTests.cs @@ -0,0 +1,233 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Xunit; + +namespace SabreTools.IO.Extensions.Test +{ + public class DeviceExtensionsTests + { + #region EnumerateUnixFloppyBlockPaths + + [Fact] + public void EnumerateUnixFloppyBlockPaths_Null_ReturnsEmpty() + { + var actual = DeviceExtensions.EnumerateUnixFloppyBlockPaths(null); + Assert.Empty(actual); + } + + [Fact] + public void EnumerateUnixFloppyBlockPaths_Empty_ReturnsEmpty() + { + var actual = DeviceExtensions.EnumerateUnixFloppyBlockPaths(string.Empty); + Assert.Empty(actual); + } + + [Fact] + public void EnumerateUnixFloppyBlockPaths_MissingDirectory_ReturnsEmpty() + { + string devRoot = "INVALID"; + var actual = DeviceExtensions.EnumerateUnixFloppyBlockPaths(devRoot); + Assert.Empty(actual); + } + + [Fact] + public void EnumerateUnixFloppyBlockPaths_OnlyMatchesFdFollowedByDigits() + { + string devRoot = Path.Combine(Environment.CurrentDirectory, "TestData", "dev"); + string[] expected = ["fd0", "fd1", "fd7"]; + + var actual = DeviceExtensions.EnumerateUnixFloppyBlockPaths(devRoot); + + var actualNames = new List(); + foreach (var p in actual) + { + actualNames.Add(Path.GetFileName(p)); + } + + actualNames.Sort(StringComparer.Ordinal); + Assert.Equal(expected, actualNames); + } + + #endregion + + #region EnumerateUnixOpticalBlockPaths + + [Fact] + public void EnumerateUnixOpticalBlockPaths_Null_ReturnsEmpty() + { + var actual = DeviceExtensions.EnumerateUnixOpticalBlockPaths(null); + Assert.Empty(actual); + } + + [Fact] + public void EnumerateUnixOpticalBlockPaths_Empty_ReturnsEmpty() + { + var actual = DeviceExtensions.EnumerateUnixOpticalBlockPaths(string.Empty); + Assert.Empty(actual); + } + + [Fact] + public void EnumerateUnixOpticalBlockPaths_MissingDirectory_ReturnsEmpty() + { + string devRoot = "INVALID"; + var actual = DeviceExtensions.EnumerateUnixOpticalBlockPaths(devRoot); + Assert.Empty(actual); + } + + [Fact] + public void EnumerateUnixOpticalBlockPaths_OnlyMatchesSrFollowedByDigits() + { + string devRoot = Path.Combine(Environment.CurrentDirectory, "TestData", "dev"); + string[] expected = ["sr0", "sr1", "sr15"]; + + var actual = DeviceExtensions.EnumerateUnixOpticalBlockPaths(devRoot); + + var actualNames = new List(); + foreach (var p in actual) + { + actualNames.Add(Path.GetFileName(p)); + } + + actualNames.Sort(StringComparer.Ordinal); + Assert.Equal(expected, actualNames); + } + + #endregion + + #region EnumerateUnixOpticalGenericPaths + + [Fact] + public void EnumerateUnixOpticalGenericPaths_DevRootNull_ReturnsEmpty() + { + var actual = DeviceExtensions.EnumerateUnixOpticalGenericPaths(null, "/sys/class/scsi_generic"); + Assert.Empty(actual); + } + + [Fact] + public void EnumerateUnixOpticalGenericPaths_DevRootEmpty_ReturnsEmpty() + { + var actual = DeviceExtensions.EnumerateUnixOpticalGenericPaths(string.Empty, "/sys/class/scsi_generic"); + Assert.Empty(actual); + } + + [Fact] + public void EnumerateUnixOpticalGenericPaths_SysBlockRootNull_ReturnsEmpty() + { + var actual = DeviceExtensions.EnumerateUnixOpticalGenericPaths("/dev", null); + Assert.Empty(actual); + } + + [Fact] + public void EnumerateUnixOpticalGenericPaths_SysBlockRootEmpty_ReturnsEmpty() + { + var actual = DeviceExtensions.EnumerateUnixOpticalGenericPaths("/dev", string.Empty); + Assert.Empty(actual); + } + + [Fact] + public void EnumerateUnixOpticalGenericPaths_MissingDirectory_ReturnsEmpty() + { + string sysfsScsiGenericRoot = "INVALID"; + var actual = DeviceExtensions.EnumerateUnixOpticalGenericPaths("/dev/", sysfsScsiGenericRoot); + Assert.Empty(actual); + } + + [Fact] + public void EnumerateUnixOpticalGenericPaths_OnlyMatchesOpticalSgNodes() + { + string devRoot = "/dev"; + string sysfsScsiGenericRoot = Path.Combine(Environment.CurrentDirectory, "TestData", "sys", "class", "scsi_generic"); + + // sg0 - Optical, kept + // sg1 - Direct-access disk, skipped + // sg2 - Optical (trailing newline), kept + // sg3 - Sequential tape, skipped + // sg10 - Optical (multi-digit), kept + // sga - Name not all-digits, skipped + // sgX - Name not all-digits, skipped + List expected = ["sg0", "sg10", "sg2"]; + + var actual = DeviceExtensions.EnumerateUnixOpticalGenericPaths(devRoot, sysfsScsiGenericRoot); + + var actualNames = new List(); + foreach (var p in actual) + { + actualNames.Add(Path.GetFileName(p)); + } + + actualNames.Sort(StringComparer.Ordinal); + + Assert.Equal(expected, actualNames); + } + + #endregion + + #region EnumerateUnixUsbFloppyBlockPaths + + [Fact] + public void EnumerateUnixUsbFloppyBlockPaths_DevRootNull_ReturnsEmpty() + { + var actual = DeviceExtensions.EnumerateUnixUsbFloppyBlockPaths(null, "/sys/block"); + Assert.Empty(actual); + } + + [Fact] + public void EnumerateUnixUsbFloppyBlockPaths_DevRootEmpty_ReturnsEmpty() + { + var actual = DeviceExtensions.EnumerateUnixUsbFloppyBlockPaths(string.Empty, "/sys/block"); + Assert.Empty(actual); + } + + [Fact] + public void EnumerateUnixUsbFloppyBlockPaths_SysBlockRootNull_ReturnsEmpty() + { + var actual = DeviceExtensions.EnumerateUnixUsbFloppyBlockPaths("/dev", null); + Assert.Empty(actual); + } + + [Fact] + public void EnumerateUnixUsbFloppyBlockPaths_SysBlockRootEmpty_ReturnsEmpty() + { + var actual = DeviceExtensions.EnumerateUnixUsbFloppyBlockPaths("/dev", string.Empty); + Assert.Empty(actual); + } + + [Fact] + public void EnumerateUnixUsbFloppyBlockPaths_MissingDirectory_ReturnsEmpty() + { + string sysBlockRoot = "INVALID"; + var actual = DeviceExtensions.EnumerateUnixUsbFloppyBlockPaths("/dev/", sysBlockRoot); + Assert.Empty(actual); + } + + [Fact] + public void EnumerateUnixUsbFloppyBlockPaths_OnlyMatchesRemovableFloppySizedDisks() + { + string devRoot = "/dev"; + string sysBlockRoot = Path.Combine(Environment.CurrentDirectory, "TestData", "sys", "block"); + + // nvme0n1 - Not an sd* device, not scanned + // sda - Fixed disk of floppy size, not removable, skipped + // sdb - USB flash drive, not a floppy size, skipped + // sdc - Empty floppy drive (no media), skipped + // sdh - USB floppy, 1.44 MB, matched + // sdi - USB floppy, 720 KB (trailing newline), matched + List expected = ["sdh", "sdi"]; + + var actual = DeviceExtensions.EnumerateUnixUsbFloppyBlockPaths(devRoot, sysBlockRoot); + + var actualNames = new List(); + foreach (var p in actual) + { + actualNames.Add(Path.GetFileName(p)); + } + + actualNames.Sort(StringComparer.Ordinal); + + Assert.Equal(expected, actualNames); + } + + #endregion + } +} diff --git a/SabreTools.IO.Extensions.Test/IOExtensionsTests.cs b/SabreTools.IO.Extensions.Test/IOExtensionsTests.cs index 2a661fb..d27de7e 100644 --- a/SabreTools.IO.Extensions.Test/IOExtensionsTests.cs +++ b/SabreTools.IO.Extensions.Test/IOExtensionsTests.cs @@ -651,228 +651,5 @@ namespace SabreTools.IO.Extensions.Test } #endregion - - #region EnumerateUnixFloppyBlockPaths - - [Fact] - public void EnumerateUnixFloppyBlockPaths_Null_ReturnsEmpty() - { - var actual = IOExtensions.EnumerateUnixFloppyBlockPaths(null); - Assert.Empty(actual); - } - - [Fact] - public void EnumerateUnixFloppyBlockPaths_Empty_ReturnsEmpty() - { - var actual = IOExtensions.EnumerateUnixFloppyBlockPaths(string.Empty); - Assert.Empty(actual); - } - - [Fact] - public void EnumerateUnixFloppyBlockPaths_MissingDirectory_ReturnsEmpty() - { - string devRoot = "INVALID"; - var actual = IOExtensions.EnumerateUnixFloppyBlockPaths(devRoot); - Assert.Empty(actual); - } - - [Fact] - public void EnumerateUnixFloppyBlockPaths_OnlyMatchesFdFollowedByDigits() - { - string devRoot = Path.Combine(Environment.CurrentDirectory, "TestData", "dev"); - string[] expected = ["fd0", "fd1", "fd7"]; - - var actual = IOExtensions.EnumerateUnixFloppyBlockPaths(devRoot); - - var actualNames = new List(); - foreach (var p in actual) - { - actualNames.Add(Path.GetFileName(p)); - } - - actualNames.Sort(StringComparer.Ordinal); - Assert.Equal(expected, actualNames); - } - - #endregion - - #region EnumerateUnixOpticalBlockPaths - - [Fact] - public void EnumerateUnixOpticalBlockPaths_Null_ReturnsEmpty() - { - var actual = IOExtensions.EnumerateUnixOpticalBlockPaths(null); - Assert.Empty(actual); - } - - [Fact] - public void EnumerateUnixOpticalBlockPaths_Empty_ReturnsEmpty() - { - var actual = IOExtensions.EnumerateUnixOpticalBlockPaths(string.Empty); - Assert.Empty(actual); - } - - [Fact] - public void EnumerateUnixOpticalBlockPaths_MissingDirectory_ReturnsEmpty() - { - string devRoot = "INVALID"; - var actual = IOExtensions.EnumerateUnixOpticalBlockPaths(devRoot); - Assert.Empty(actual); - } - - [Fact] - public void EnumerateUnixOpticalBlockPaths_OnlyMatchesSrFollowedByDigits() - { - string devRoot = Path.Combine(Environment.CurrentDirectory, "TestData", "dev"); - string[] expected = ["sr0", "sr1", "sr15"]; - - var actual = IOExtensions.EnumerateUnixOpticalBlockPaths(devRoot); - - var actualNames = new List(); - foreach (var p in actual) - { - actualNames.Add(Path.GetFileName(p)); - } - - actualNames.Sort(StringComparer.Ordinal); - Assert.Equal(expected, actualNames); - } - - #endregion - - #region EnumerateUnixOpticalGenericPaths - - [Fact] - public void EnumerateUnixOpticalGenericPaths_DevRootNull_ReturnsEmpty() - { - var actual = IOExtensions.EnumerateUnixOpticalGenericPaths(null, "/sys/class/scsi_generic"); - Assert.Empty(actual); - } - - [Fact] - public void EnumerateUnixOpticalGenericPaths_DevRootEmpty_ReturnsEmpty() - { - var actual = IOExtensions.EnumerateUnixOpticalGenericPaths(string.Empty, "/sys/class/scsi_generic"); - Assert.Empty(actual); - } - - [Fact] - public void EnumerateUnixOpticalGenericPaths_SysBlockRootNull_ReturnsEmpty() - { - var actual = IOExtensions.EnumerateUnixOpticalGenericPaths("/dev", null); - Assert.Empty(actual); - } - - [Fact] - public void EnumerateUnixOpticalGenericPaths_SysBlockRootEmpty_ReturnsEmpty() - { - var actual = IOExtensions.EnumerateUnixOpticalGenericPaths("/dev", string.Empty); - Assert.Empty(actual); - } - - [Fact] - public void EnumerateUnixOpticalGenericPaths_MissingDirectory_ReturnsEmpty() - { - string sysfsScsiGenericRoot = "INVALID"; - var actual = IOExtensions.EnumerateUnixOpticalGenericPaths("/dev/", sysfsScsiGenericRoot); - Assert.Empty(actual); - } - - [Fact] - public void EnumerateUnixOpticalGenericPaths_OnlyMatchesOpticalSgNodes() - { - string devRoot = "/dev"; - string sysfsScsiGenericRoot = Path.Combine(Environment.CurrentDirectory, "TestData", "sys", "class", "scsi_generic"); - - // sg0 - Optical, kept - // sg1 - Direct-access disk, skipped - // sg2 - Optical (trailing newline), kept - // sg3 - Sequential tape, skipped - // sg10 - Optical (multi-digit), kept - // sga - Name not all-digits, skipped - // sgX - Name not all-digits, skipped - List expected = ["sg0", "sg10", "sg2"]; - - var actual = IOExtensions.EnumerateUnixOpticalGenericPaths(devRoot, sysfsScsiGenericRoot); - - var actualNames = new List(); - foreach (var p in actual) - { - actualNames.Add(Path.GetFileName(p)); - } - - actualNames.Sort(StringComparer.Ordinal); - - Assert.Equal(expected, actualNames); - } - - #endregion - - #region EnumerateUnixUsbFloppyBlockPaths - - [Fact] - public void EnumerateUnixUsbFloppyBlockPaths_DevRootNull_ReturnsEmpty() - { - var actual = IOExtensions.EnumerateUnixUsbFloppyBlockPaths(null, "/sys/block"); - Assert.Empty(actual); - } - - [Fact] - public void EnumerateUnixUsbFloppyBlockPaths_DevRootEmpty_ReturnsEmpty() - { - var actual = IOExtensions.EnumerateUnixUsbFloppyBlockPaths(string.Empty, "/sys/block"); - Assert.Empty(actual); - } - - [Fact] - public void EnumerateUnixUsbFloppyBlockPaths_SysBlockRootNull_ReturnsEmpty() - { - var actual = IOExtensions.EnumerateUnixUsbFloppyBlockPaths("/dev", null); - Assert.Empty(actual); - } - - [Fact] - public void EnumerateUnixUsbFloppyBlockPaths_SysBlockRootEmpty_ReturnsEmpty() - { - var actual = IOExtensions.EnumerateUnixUsbFloppyBlockPaths("/dev", string.Empty); - Assert.Empty(actual); - } - - [Fact] - public void EnumerateUnixUsbFloppyBlockPaths_MissingDirectory_ReturnsEmpty() - { - string sysBlockRoot = "INVALID"; - var actual = IOExtensions.EnumerateUnixUsbFloppyBlockPaths("/dev/", sysBlockRoot); - Assert.Empty(actual); - } - - [Fact] - public void EnumerateUnixUsbFloppyBlockPaths_OnlyMatchesRemovableFloppySizedDisks() - { - string devRoot = "/dev"; - string sysBlockRoot = Path.Combine(Environment.CurrentDirectory, "TestData", "sys", "block"); - - // nvme0n1 - Not an sd* device, not scanned - // sda - Fixed disk of floppy size, not removable, skipped - // sdb - USB flash drive, not a floppy size, skipped - // sdc - Empty floppy drive (no media), skipped - // sdh - USB floppy, 1.44 MB, matched - // sdi - USB floppy, 720 KB (trailing newline), matched - List expected = ["sdh", "sdi"]; - - var actual = IOExtensions.EnumerateUnixUsbFloppyBlockPaths(devRoot, sysBlockRoot); - - var actualNames = new List(); - foreach (var p in actual) - { - actualNames.Add(Path.GetFileName(p)); - } - - actualNames.Sort(StringComparer.Ordinal); - - Assert.Equal(expected, actualNames); - } - - #endregion } } diff --git a/SabreTools.IO.Extensions/DeviceExtensions.cs b/SabreTools.IO.Extensions/DeviceExtensions.cs new file mode 100644 index 0000000..c13b815 --- /dev/null +++ b/SabreTools.IO.Extensions/DeviceExtensions.cs @@ -0,0 +1,410 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.RegularExpressions; + +namespace SabreTools.IO.Extensions +{ + /// + /// Methods around device access and identification + /// + public static class DeviceExtensions + { + #region Unix + + /// + /// sysfs block-device name prefixes that the fixed-drive enumerator does not surface: + /// loopback images, ramdisks, device-mapper and software RAID virtual devices, network + /// block devices, ZFS volumes, and the floppy and optical drives (each surfaced + /// separately by its own enumerator rather than as a fixed or removable disk). + /// + private static readonly string[] _excludedUnixBlockPrefixes = + [ + "dm-", // device-mapper (LVM/LUKS, etc.) virtual devices + "fd", // floppy drive (surfaced separately by the floppy enumerator) + "loop", // loopback-mounted image + "md", // software RAID (mdadm) + "nbd", // network block device + "ram", // ramdisk + "sr", // optical drive (surfaced separately by the optical enumerator) + "zd", // ZFS volume (zvol) + "zram", // compressed ramdisk + ]; + + /// + /// Floppy media sizes in bytes for the standard 5.25" and 3.5" PC formats + /// (360 KB, 720 KB, 1.2 MB, 1.44 MB, 2.88 MB). A removable SCSI disk reporting one of + /// these exact capacities is a USB floppy with media inserted; no other removable + /// storage uses these sizes, so this doubles as the floppy identity check. + /// +#if NET20 + private static readonly List _unixFloppyMediaSizes = +#else + private static readonly HashSet _unixFloppyMediaSizes = +#endif + [ + 368640, // 360 KB (5.25" DD) + 737280, // 720 KB (3.5" DD) + 1228800, // 1.2 MB (5.25" HD) + 1474560, // 1.44 MB (3.5" HD) + 2949120, // 2.88 MB (3.5" ED) + ]; + + /// + /// Enumerate Linux floppy block devices under a directory by matching the kernel + /// convention "fd" followed by one or more digits (fd0, fd1, ...). This deliberately + /// rejects the "/dev/fd" symlink (no trailing digits) and format-specific nodes such + /// as "fd0u1440" (embedded non-digit characters), which are not whole-device targets. + /// + /// Root directory to scan (typically "/dev") + /// Device paths, or an empty list when the directory is unreadable + public static List EnumerateUnixFloppyBlockPaths(string? devRoot = "/dev") + { + // If the device root is invalid + if (string.IsNullOrEmpty(devRoot) || !Directory.Exists(devRoot)) + return []; + + // Attempt to retrieve all matching devices + string[] entries = IOExtensions.SafeGetFiles(devRoot!, "fd*"); + + // Return only valid devices + var result = new List(); + foreach (var path in entries) + { + if (HasDeviceIndexSuffix(Path.GetFileName(path), "fd")) + result.Add(path); + } + + return result; + } + + /// + /// Enumerate Linux optical block devices under a directory by matching + /// the kernel convention "sr" followed by one or more digits (sr0, sr1, ...). + /// + /// Root directory to scan (typically "/dev") + /// Device paths, or an empty list when the directory is unreadable + public static List EnumerateUnixOpticalBlockPaths(string? devRoot = "/dev") + { + // If the device root is invalid + if (string.IsNullOrEmpty(devRoot) || !Directory.Exists(devRoot)) + return []; + + // Attempt to retrieve all matching devices + string[] entries = IOExtensions.SafeGetFiles(devRoot!, "sr*"); + + // Return only valid devices + var result = new List(); + foreach (var path in entries) + { + if (HasDeviceIndexSuffix(Path.GetFileName(path), "sr")) + result.Add(path); + } + + return result; + } + + /// + /// Enumerate Linux optical drives via their generic SCSI nodes (/dev/sg*). + /// Unlike the block nodes, "sg" is not optical-specific, so each candidate is + /// confirmed against sysfs: an optical drive reports SCSI peripheral device type 5. + /// + /// Root directory the nodes live under (typically "/dev") + /// sysfs class directory (typically "/sys/class/scsi_generic") + /// Device paths, or an empty list when the sysfs directory is unreadable + public static List EnumerateUnixOpticalGenericPaths(string? devRoot = "/dev", string? sysfsScsiGenericRoot = "/sys/class/scsi_generic") + { + // If the device root is invalid + if (string.IsNullOrEmpty(devRoot) || !Directory.Exists(devRoot)) + return []; + + // If the system generic root is invalid + if (string.IsNullOrEmpty(sysfsScsiGenericRoot) || !Directory.Exists(sysfsScsiGenericRoot)) + return []; + + // Attempt to retrieve all matching devices + string[] entries = IOExtensions.SafeGetFileSystemEntries(sysfsScsiGenericRoot!, "sg*"); + + // Return only valid devices + var result = new List(); + foreach (var entry in entries) + { + string name = Path.GetFileName(entry); + if (!HasDeviceIndexSuffix(name, "sg")) + continue; + + // "sg" is generic SCSI; keep only optical drives (peripheral device type 5) + int scsiType = ReadUnixBlockDeviceType(entry); + if (scsiType != 5) + continue; + + result.Add(Path.Combine(devRoot, name)); + } + + return result; + } + + /// + /// Enumerate USB floppy drives, which the kernel exposes as ordinary SCSI disks + /// (/dev/sd*) rather than /dev/fd* nodes. A disk is treated as a floppy when it reports + /// itself removable and its media is one of the standard floppy sizes. This mirrors how + /// Windows identifies a floppy from the inserted medium (Win32_LogicalDisk.MediaType) + /// and, like that path, only recognizes the drive while floppy media is present. + /// + /// Root directory device nodes live under (typically "/dev") + /// sysfs block directory (typically "/sys/block") + /// Device paths, or an empty list when the directory is unreadable + public static List EnumerateUnixUsbFloppyBlockPaths(string? devRoot = "/dev", string? sysBlockRoot = "/sys/block") + { + // If the device root is invalid + if (string.IsNullOrEmpty(devRoot) || !Directory.Exists(devRoot)) + return []; + + // If the system block root is invalid + if (string.IsNullOrEmpty(sysBlockRoot) || !Directory.Exists(sysBlockRoot)) + return []; + + // Attempt to retrieve all matching devices + string[] entries = IOExtensions.SafeGetFileSystemEntries(sysBlockRoot!, "sd*"); + + // Return only valid devices + var result = new List(); + foreach (var entry in entries) + { + // Ensure the drive has the removable flag set + if (!ReadUnixRemovableFlag(entry)) + continue; + + // Filter out non-standard floppy sizes for now + if (!_unixFloppyMediaSizes.Contains(ReadUnixBlockDeviceSize(entry))) + continue; + + string devicePath = Path.Combine(devRoot, Path.GetFileName(entry)); + result.Add(devicePath); + } + + return result; + } + + /// + /// Check whether a sysfs block-device name is a virtual or non-target device that + /// should not be surfaced as a dump target. + /// + /// Block-device name (e.g. "sda", "loop0") + /// True when the device should be skipped + public static bool IsExcludedUnixBlockDevice(string name) + { + if (string.IsNullOrEmpty(name)) + return true; + + foreach (var prefix in _excludedUnixBlockPrefixes) + { + if (name.StartsWith(prefix, StringComparison.Ordinal)) + return true; + } + + return false; + } + + /// + /// Get the mount point based on the block device mapping for generic SCSI + /// + /// Device path to check + /// Mount point for the generic device on success, null otherwise + public static string? GetMountPointGeneric(string? devicePath) + { + // If the device path is invalid + if (devicePath is null) + return null; + +#pragma warning disable SYSLIB1045 // Convert to 'GeneratedRegexAttribute'. + // If the device does not match the generic pattern + if (!Regex.IsMatch(devicePath, @"/dev/sg.+")) + return null; +#pragma warning restore SYSLIB1045 // Convert to 'GeneratedRegexAttribute'. + + // Get the SCSI name from the path by trimming `/dev/` +#if NETFRAMEWORK || NETSTANDARD + string scsiName = devicePath.Substring(5); +#else + string scsiName = devicePath[5..]; +#endif + + // Build the path to the block directory + string blockDirectory = $"/sys/class/scsi_generic/{scsiName}/device/block/"; + if (!Directory.Exists(blockDirectory)) + return null; + + // If there are no block mappings + var blockDevices = IOExtensions.SafeGetDirectories(blockDirectory); + if (blockDevices.Length == 0) + return null; + + // Take the first found block mapping and reformat to `/dev/sr*` + string mappedDevice = blockDevices[0]; +#if NETFRAMEWORK || NETSTANDARD + mappedDevice = mappedDevice.Substring($"/sys/class/scsi_generic/{scsiName}/device/block/".Length); +#else + mappedDevice = mappedDevice[$"/sys/class/scsi_generic/{scsiName}/device/block/".Length..]; +#endif + mappedDevice = $"/dev/{mappedDevice}"; + + // Return the mount point for that device + return GetMountPointMounts(mappedDevice); + } + + /// + /// Get the mount point based a mapping to /proc/mounts + /// + /// Device path to check + /// Mount point for the device on success, null otherwise + public static string? GetMountPointMounts(string? devicePath) + { + // If the device path is invalid + if (devicePath is null) + return null; + + // Try reading the mounts file for information + string mountsPath = "/proc/mounts"; + try + { + if (!File.Exists(mountsPath)) + return null; + + // Read all lines from the mounts file + string[] mountsLines = File.ReadAllLines(mountsPath); + foreach (string mount in mountsLines) + { + // Split the whitespace-separated mount entry line +#if NETFRAMEWORK || NETSTANDARD + string[] entry = mount.Split(' '); +#else + string[] entry = mount.Split(' ', StringSplitOptions.RemoveEmptyEntries); +#endif + if (entry.Length < 2) + continue; + + // Extract the two required pieces + string entryDevicePath = entry[0]; + string entryMountPoint = entry[1]; + + // If the drive does not match + if (!string.Equals(devicePath, entryDevicePath)) + continue; + + // Return the mount point + return entryMountPoint; + } + + // No matches were found + return null; + } + catch + { + // Absorb errors for now + return null; + } + } + + /// + /// Read the device size from sysfs (reported in 512-byte sectors) and convert to bytes + /// + /// Path to the device directory under the sysfs block root + /// The device size in bytes, 0 on error + public static long ReadUnixBlockDeviceSize(string sysBlockEntry) + { + string sizePath = Path.Combine(sysBlockEntry, "size"); + try + { + if (!File.Exists(sizePath)) + return 0; + + string sizeString = File.ReadAllText(sizePath).Trim(); + if (long.TryParse(sizeString, out long sectors) && sectors > 0) + return sectors * 512; + } + catch + { + // Unreadable size; report it as unknown + } + + return 0; + } + + /// + /// Read the device size from sysfs (reported in 512-byte sectors) and convert to bytes + /// + /// Path to the device directory under the sysfs block root + /// The device size in bytes, 0 on error + public static int ReadUnixBlockDeviceType(string sysGenericEntry) + { + string typePath = Path.Combine(Path.Combine(sysGenericEntry, "device"), "type"); + try + { + if (!File.Exists(typePath)) + return 0; + + string typeString = File.ReadAllText(typePath).Trim(); + if (int.TryParse(typeString, out int scsiType)) + return scsiType; + } + catch + { + // Unreadable type; report it as unknown + } + + return 0; + } + + /// + /// Read the sysfs "removable" flag for a block device + /// + /// Path to the device directory under the sysfs block root + /// True when the device reports itself as removable, false otherwise + public static bool ReadUnixRemovableFlag(string sysBlockEntry) + { + string removablePath = Path.Combine(sysBlockEntry, "removable"); + try + { + if (!File.Exists(removablePath)) + return false; + + string removableString = File.ReadAllText(removablePath).Trim(); + return removableString == "1"; + } + catch + { + return false; + } + } + + /// + /// Check that a device node name is the given prefix followed by one or more digits + /// (e.g. "sr0", "sg12"), rejecting names with trailing or embedded non-digit characters. + /// + /// Device node name to check + /// Required leading text (e.g. "sr", "sg") + /// True when the name is the prefix followed only by digits + private static bool HasDeviceIndexSuffix(string name, string prefix) + { + // If the prefix is longer than the name + if (name.Length <= prefix.Length) + return false; + + // If the prefix is invalid for the provided name + if (!name.StartsWith(prefix, StringComparison.Ordinal)) + return false; + + // Check that all following characters are numeric + for (int i = prefix.Length; i < name.Length; i++) + { + if (!char.IsDigit(name[i])) + return false; + } + + return true; + } + + #endregion + } +} diff --git a/SabreTools.IO.Extensions/IOExtensions.cs b/SabreTools.IO.Extensions/IOExtensions.cs index 66420df..e508f43 100644 --- a/SabreTools.IO.Extensions/IOExtensions.cs +++ b/SabreTools.IO.Extensions/IOExtensions.cs @@ -945,402 +945,5 @@ namespace SabreTools.IO.Extensions #endif #endregion - - #region Unix - - /// - /// sysfs block-device name prefixes that the fixed-drive enumerator does not surface: - /// loopback images, ramdisks, device-mapper and software RAID virtual devices, network - /// block devices, ZFS volumes, and the floppy and optical drives (each surfaced - /// separately by its own enumerator rather than as a fixed or removable disk). - /// - private static readonly string[] _excludedUnixBlockPrefixes = - [ - "dm-", // device-mapper (LVM/LUKS, etc.) virtual devices - "fd", // floppy drive (surfaced separately by the floppy enumerator) - "loop", // loopback-mounted image - "md", // software RAID (mdadm) - "nbd", // network block device - "ram", // ramdisk - "sr", // optical drive (surfaced separately by the optical enumerator) - "zd", // ZFS volume (zvol) - "zram", // compressed ramdisk - ]; - - /// - /// Floppy media sizes in bytes for the standard 5.25" and 3.5" PC formats - /// (360 KB, 720 KB, 1.2 MB, 1.44 MB, 2.88 MB). A removable SCSI disk reporting one of - /// these exact capacities is a USB floppy with media inserted; no other removable - /// storage uses these sizes, so this doubles as the floppy identity check. - /// -#if NET20 - private static readonly List _unixFloppyMediaSizes = -#else - private static readonly HashSet _unixFloppyMediaSizes = -#endif - [ - 368640, // 360 KB (5.25" DD) - 737280, // 720 KB (3.5" DD) - 1228800, // 1.2 MB (5.25" HD) - 1474560, // 1.44 MB (3.5" HD) - 2949120, // 2.88 MB (3.5" ED) - ]; - - /// - /// Enumerate Linux floppy block devices under a directory by matching the kernel - /// convention "fd" followed by one or more digits (fd0, fd1, ...). This deliberately - /// rejects the "/dev/fd" symlink (no trailing digits) and format-specific nodes such - /// as "fd0u1440" (embedded non-digit characters), which are not whole-device targets. - /// - /// Root directory to scan (typically "/dev") - /// Device paths, or an empty list when the directory is unreadable - public static List EnumerateUnixFloppyBlockPaths(string? devRoot = "/dev") - { - // If the device root is invalid - if (string.IsNullOrEmpty(devRoot) || !Directory.Exists(devRoot)) - return []; - - // Attempt to retrieve all matching devices - string[] entries = SafeGetFiles(devRoot!, "fd*"); - - // Return only valid devices - var result = new List(); - foreach (var path in entries) - { - if (HasDeviceIndexSuffix(Path.GetFileName(path), "fd")) - result.Add(path); - } - - return result; - } - - /// - /// Enumerate Linux optical block devices under a directory by matching - /// the kernel convention "sr" followed by one or more digits (sr0, sr1, ...). - /// - /// Root directory to scan (typically "/dev") - /// Device paths, or an empty list when the directory is unreadable - public static List EnumerateUnixOpticalBlockPaths(string? devRoot = "/dev") - { - // If the device root is invalid - if (string.IsNullOrEmpty(devRoot) || !Directory.Exists(devRoot)) - return []; - - // Attempt to retrieve all matching devices - string[] entries = SafeGetFiles(devRoot!, "sr*"); - - // Return only valid devices - var result = new List(); - foreach (var path in entries) - { - if (HasDeviceIndexSuffix(Path.GetFileName(path), "sr")) - result.Add(path); - } - - return result; - } - - /// - /// Enumerate Linux optical drives via their generic SCSI nodes (/dev/sg*). - /// Unlike the block nodes, "sg" is not optical-specific, so each candidate is - /// confirmed against sysfs: an optical drive reports SCSI peripheral device type 5. - /// - /// Root directory the nodes live under (typically "/dev") - /// sysfs class directory (typically "/sys/class/scsi_generic") - /// Device paths, or an empty list when the sysfs directory is unreadable - public static List EnumerateUnixOpticalGenericPaths(string? devRoot = "/dev", string? sysfsScsiGenericRoot = "/sys/class/scsi_generic") - { - // If the device root is invalid - if (string.IsNullOrEmpty(devRoot) || !Directory.Exists(devRoot)) - return []; - - // If the system generic root is invalid - if (string.IsNullOrEmpty(sysfsScsiGenericRoot) || !Directory.Exists(sysfsScsiGenericRoot)) - return []; - - // Attempt to retrieve all matching devices - string[] entries = SafeGetFileSystemEntries(sysfsScsiGenericRoot!, "sg*"); - - // Return only valid devices - var result = new List(); - foreach (var entry in entries) - { - string name = Path.GetFileName(entry); - if (!HasDeviceIndexSuffix(name, "sg")) - continue; - - // "sg" is generic SCSI; keep only optical drives (peripheral device type 5) - int scsiType = ReadUnixBlockDeviceType(entry); - if (scsiType != 5) - continue; - - result.Add(Path.Combine(devRoot, name)); - } - - return result; - } - - /// - /// Enumerate USB floppy drives, which the kernel exposes as ordinary SCSI disks - /// (/dev/sd*) rather than /dev/fd* nodes. A disk is treated as a floppy when it reports - /// itself removable and its media is one of the standard floppy sizes. This mirrors how - /// Windows identifies a floppy from the inserted medium (Win32_LogicalDisk.MediaType) - /// and, like that path, only recognizes the drive while floppy media is present. - /// - /// Root directory device nodes live under (typically "/dev") - /// sysfs block directory (typically "/sys/block") - /// Device paths, or an empty list when the directory is unreadable - public static List EnumerateUnixUsbFloppyBlockPaths(string? devRoot = "/dev", string? sysBlockRoot = "/sys/block") - { - // If the device root is invalid - if (string.IsNullOrEmpty(devRoot) || !Directory.Exists(devRoot)) - return []; - - // If the system block root is invalid - if (string.IsNullOrEmpty(sysBlockRoot) || !Directory.Exists(sysBlockRoot)) - return []; - - // Attempt to retrieve all matching devices - string[] entries = SafeGetFileSystemEntries(sysBlockRoot!, "sd*"); - - // Return only valid devices - var result = new List(); - foreach (var entry in entries) - { - // Ensure the drive has the removable flag set - if (!ReadUnixRemovableFlag(entry)) - continue; - - // Filter out non-standard floppy sizes for now - if (!_unixFloppyMediaSizes.Contains(ReadUnixBlockDeviceSize(entry))) - continue; - - string devicePath = Path.Combine(devRoot, Path.GetFileName(entry)); - result.Add(devicePath); - } - - return result; - } - - /// - /// Check whether a sysfs block-device name is a virtual or non-target device that - /// should not be surfaced as a dump target. - /// - /// Block-device name (e.g. "sda", "loop0") - /// True when the device should be skipped - public static bool IsExcludedUnixBlockDevice(string name) - { - if (string.IsNullOrEmpty(name)) - return true; - - foreach (var prefix in _excludedUnixBlockPrefixes) - { - if (name.StartsWith(prefix, StringComparison.Ordinal)) - return true; - } - - return false; - } - - /// - /// Get the mount point based on the block device mapping for generic SCSI - /// - /// Device path to check - /// Mount point for the generic device on success, null otherwise - public static string? GetMountPointGeneric(string? devicePath) - { - // If the device path is invalid - if (devicePath is null) - return null; - -#pragma warning disable SYSLIB1045 // Convert to 'GeneratedRegexAttribute'. - // If the device does not match the generic pattern - if (!Regex.IsMatch(devicePath, @"/dev/sg.+")) - return null; -#pragma warning restore SYSLIB1045 // Convert to 'GeneratedRegexAttribute'. - - // Get the SCSI name from the path by trimming `/dev/` -#if NETFRAMEWORK || NETSTANDARD - string scsiName = devicePath.Substring(5); -#else - string scsiName = devicePath[5..]; -#endif - - // Build the path to the block directory - string blockDirectory = $"/sys/class/scsi_generic/{scsiName}/device/block/"; - if (!Directory.Exists(blockDirectory)) - return null; - - // If there are no block mappings - var blockDevices = SafeGetDirectories(blockDirectory); - if (blockDevices.Length == 0) - return null; - - // Take the first found block mapping and reformat to `/dev/sr*` - string mappedDevice = blockDevices[0]; -#if NETFRAMEWORK || NETSTANDARD - mappedDevice = mappedDevice.Substring($"/sys/class/scsi_generic/{scsiName}/device/block/".Length); -#else - mappedDevice = mappedDevice[$"/sys/class/scsi_generic/{scsiName}/device/block/".Length..]; -#endif - mappedDevice = $"/dev/{mappedDevice}"; - - // Return the mount point for that device - return GetMountPointMounts(mappedDevice); - } - - /// - /// Get the mount point based a mapping to /proc/mounts - /// - /// Device path to check - /// Mount point for the device on success, null otherwise - public static string? GetMountPointMounts(string? devicePath) - { - // If the device path is invalid - if (devicePath is null) - return null; - - // Try reading the mounts file for information - string mountsPath = "/proc/mounts"; - try - { - if (!File.Exists(mountsPath)) - return null; - - // Read all lines from the mounts file - string[] mountsLines = File.ReadAllLines(mountsPath); - foreach (string mount in mountsLines) - { - // Split the whitespace-separated mount entry line -#if NETFRAMEWORK || NETSTANDARD - string[] entry = mount.Split(' '); -#else - string[] entry = mount.Split(' ', StringSplitOptions.RemoveEmptyEntries); -#endif - if (entry.Length < 2) - continue; - - // Extract the two required pieces - string entryDevicePath = entry[0]; - string entryMountPoint = entry[1]; - - // If the drive does not match - if (!string.Equals(devicePath, entryDevicePath)) - continue; - - // Return the mount point - return entryMountPoint; - } - - // No matches were found - return null; - } - catch - { - // Absorb errors for now - return null; - } - } - - /// - /// Read the device size from sysfs (reported in 512-byte sectors) and convert to bytes - /// - /// Path to the device directory under the sysfs block root - /// The device size in bytes, 0 on error - public static long ReadUnixBlockDeviceSize(string sysBlockEntry) - { - string sizePath = Path.Combine(sysBlockEntry, "size"); - try - { - if (!File.Exists(sizePath)) - return 0; - - string sizeString = File.ReadAllText(sizePath).Trim(); - if (long.TryParse(sizeString, out long sectors) && sectors > 0) - return sectors * 512; - } - catch - { - // Unreadable size; report it as unknown - } - - return 0; - } - - /// - /// Read the device size from sysfs (reported in 512-byte sectors) and convert to bytes - /// - /// Path to the device directory under the sysfs block root - /// The device size in bytes, 0 on error - public static int ReadUnixBlockDeviceType(string sysGenericEntry) - { - string typePath = Path.Combine(Path.Combine(sysGenericEntry, "device"), "type"); - try - { - if (!File.Exists(typePath)) - return 0; - - string typeString = File.ReadAllText(typePath).Trim(); - if (int.TryParse(typeString, out int scsiType)) - return scsiType; - } - catch - { - // Unreadable type; report it as unknown - } - - return 0; - } - - /// - /// Read the sysfs "removable" flag for a block device - /// - /// Path to the device directory under the sysfs block root - /// True when the device reports itself as removable, false otherwise - public static bool ReadUnixRemovableFlag(string sysBlockEntry) - { - string removablePath = Path.Combine(sysBlockEntry, "removable"); - try - { - if (!File.Exists(removablePath)) - return false; - - string removableString = File.ReadAllText(removablePath).Trim(); - return removableString == "1"; - } - catch - { - return false; - } - } - - /// - /// Check that a device node name is the given prefix followed by one or more digits - /// (e.g. "sr0", "sg12"), rejecting names with trailing or embedded non-digit characters. - /// - /// Device node name to check - /// Required leading text (e.g. "sr", "sg") - /// True when the name is the prefix followed only by digits - private static bool HasDeviceIndexSuffix(string name, string prefix) - { - // If the prefix is longer than the name - if (name.Length <= prefix.Length) - return false; - - // If the prefix is invalid for the provided name - if (!name.StartsWith(prefix, StringComparison.Ordinal)) - return false; - - // Check that all following characters are numeric - for (int i = prefix.Length; i < name.Length; i++) - { - if (!char.IsDigit(name[i])) - return false; - } - - return true; - } - - #endregion } }