Separate out device extensions

This commit is contained in:
Matt Nadareski
2026-11-02 07:43:13 -05:00
parent 1ffc627d8a
commit 1b1bea3a17
4 changed files with 643 additions and 620 deletions

View File

@@ -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<string>();
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<string>();
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<string> expected = ["sg0", "sg10", "sg2"];
var actual = DeviceExtensions.EnumerateUnixOpticalGenericPaths(devRoot, sysfsScsiGenericRoot);
var actualNames = new List<string>();
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<string> expected = ["sdh", "sdi"];
var actual = DeviceExtensions.EnumerateUnixUsbFloppyBlockPaths(devRoot, sysBlockRoot);
var actualNames = new List<string>();
foreach (var p in actual)
{
actualNames.Add(Path.GetFileName(p));
}
actualNames.Sort(StringComparer.Ordinal);
Assert.Equal(expected, actualNames);
}
#endregion
}
}

View File

@@ -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<string>();
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<string>();
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<string> expected = ["sg0", "sg10", "sg2"];
var actual = IOExtensions.EnumerateUnixOpticalGenericPaths(devRoot, sysfsScsiGenericRoot);
var actualNames = new List<string>();
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<string> expected = ["sdh", "sdi"];
var actual = IOExtensions.EnumerateUnixUsbFloppyBlockPaths(devRoot, sysBlockRoot);
var actualNames = new List<string>();
foreach (var p in actual)
{
actualNames.Add(Path.GetFileName(p));
}
actualNames.Sort(StringComparer.Ordinal);
Assert.Equal(expected, actualNames);
}
#endregion
}
}

View File

@@ -0,0 +1,410 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text.RegularExpressions;
namespace SabreTools.IO.Extensions
{
/// <summary>
/// Methods around device access and identification
/// </summary>
public static class DeviceExtensions
{
#region Unix
/// <summary>
/// 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).
/// </summary>
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
];
/// <summary>
/// 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.
/// </summary>
#if NET20
private static readonly List<long> _unixFloppyMediaSizes =
#else
private static readonly HashSet<long> _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)
];
/// <summary>
/// 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.
/// </summary>
/// <param name="devRoot">Root directory to scan (typically "/dev")</param>
/// <returns>Device paths, or an empty list when the directory is unreadable</returns>
public static List<string> 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<string>();
foreach (var path in entries)
{
if (HasDeviceIndexSuffix(Path.GetFileName(path), "fd"))
result.Add(path);
}
return result;
}
/// <summary>
/// Enumerate Linux optical block devices under a directory by matching
/// the kernel convention "sr" followed by one or more digits (sr0, sr1, ...).
/// </summary>
/// <param name="devRoot">Root directory to scan (typically "/dev")</param>
/// <returns>Device paths, or an empty list when the directory is unreadable</returns>
public static List<string> 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<string>();
foreach (var path in entries)
{
if (HasDeviceIndexSuffix(Path.GetFileName(path), "sr"))
result.Add(path);
}
return result;
}
/// <summary>
/// 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.
/// </summary>
/// <param name="devRoot">Root directory the nodes live under (typically "/dev")</param>
/// <param name="sysfsScsiGenericRoot">sysfs class directory (typically "/sys/class/scsi_generic")</param>
/// <returns>Device paths, or an empty list when the sysfs directory is unreadable</returns>
public static List<string> 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<string>();
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;
}
/// <summary>
/// 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.
/// </summary>
/// <param name="devRoot">Root directory device nodes live under (typically "/dev")</param>
/// <param name="sysBlockRoot">sysfs block directory (typically "/sys/block")</param>
/// <returns>Device paths, or an empty list when the directory is unreadable</returns>
public static List<string> 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<string>();
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;
}
/// <summary>
/// Check whether a sysfs block-device name is a virtual or non-target device that
/// should not be surfaced as a dump target.
/// </summary>
/// <param name="name">Block-device name (e.g. "sda", "loop0")</param>
/// <returns>True when the device should be skipped</returns>
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;
}
/// <summary>
/// Get the mount point based on the block device mapping for generic SCSI
/// </summary>
/// <param name="devicePath">Device path to check</param>
/// <returns>Mount point for the generic device on success, null otherwise</returns>
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);
}
/// <summary>
/// Get the mount point based a mapping to /proc/mounts
/// </summary>
/// <param name="devicePath">Device path to check</param>
/// <returns>Mount point for the device on success, null otherwise</returns>
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;
}
}
/// <summary>
/// Read the device size from sysfs (reported in 512-byte sectors) and convert to bytes
/// </summary>
/// <param name="sysBlockEntry">Path to the device directory under the sysfs block root</param>
/// <returns>The device size in bytes, 0 on error</returns>
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;
}
/// <summary>
/// Read the device size from sysfs (reported in 512-byte sectors) and convert to bytes
/// </summary>
/// <param name="sysGenericEntry">Path to the device directory under the sysfs block root</param>
/// <returns>The device size in bytes, 0 on error</returns>
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;
}
/// <summary>
/// Read the sysfs "removable" flag for a block device
/// </summary>
/// <param name="sysBlockEntry">Path to the device directory under the sysfs block root</param>
/// <returns>True when the device reports itself as removable, false otherwise</returns>
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;
}
}
/// <summary>
/// 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.
/// </summary>
/// <param name="name">Device node name to check</param>
/// <param name="prefix">Required leading text (e.g. "sr", "sg")</param>
/// <returns>True when the name is the prefix followed only by digits</returns>
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
}
}

View File

@@ -945,402 +945,5 @@ namespace SabreTools.IO.Extensions
#endif
#endregion
#region Unix
/// <summary>
/// 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).
/// </summary>
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
];
/// <summary>
/// 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.
/// </summary>
#if NET20
private static readonly List<long> _unixFloppyMediaSizes =
#else
private static readonly HashSet<long> _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)
];
/// <summary>
/// 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.
/// </summary>
/// <param name="devRoot">Root directory to scan (typically "/dev")</param>
/// <returns>Device paths, or an empty list when the directory is unreadable</returns>
public static List<string> 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<string>();
foreach (var path in entries)
{
if (HasDeviceIndexSuffix(Path.GetFileName(path), "fd"))
result.Add(path);
}
return result;
}
/// <summary>
/// Enumerate Linux optical block devices under a directory by matching
/// the kernel convention "sr" followed by one or more digits (sr0, sr1, ...).
/// </summary>
/// <param name="devRoot">Root directory to scan (typically "/dev")</param>
/// <returns>Device paths, or an empty list when the directory is unreadable</returns>
public static List<string> 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<string>();
foreach (var path in entries)
{
if (HasDeviceIndexSuffix(Path.GetFileName(path), "sr"))
result.Add(path);
}
return result;
}
/// <summary>
/// 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.
/// </summary>
/// <param name="devRoot">Root directory the nodes live under (typically "/dev")</param>
/// <param name="sysfsScsiGenericRoot">sysfs class directory (typically "/sys/class/scsi_generic")</param>
/// <returns>Device paths, or an empty list when the sysfs directory is unreadable</returns>
public static List<string> 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<string>();
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;
}
/// <summary>
/// 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.
/// </summary>
/// <param name="devRoot">Root directory device nodes live under (typically "/dev")</param>
/// <param name="sysBlockRoot">sysfs block directory (typically "/sys/block")</param>
/// <returns>Device paths, or an empty list when the directory is unreadable</returns>
public static List<string> 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<string>();
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;
}
/// <summary>
/// Check whether a sysfs block-device name is a virtual or non-target device that
/// should not be surfaced as a dump target.
/// </summary>
/// <param name="name">Block-device name (e.g. "sda", "loop0")</param>
/// <returns>True when the device should be skipped</returns>
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;
}
/// <summary>
/// Get the mount point based on the block device mapping for generic SCSI
/// </summary>
/// <param name="devicePath">Device path to check</param>
/// <returns>Mount point for the generic device on success, null otherwise</returns>
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);
}
/// <summary>
/// Get the mount point based a mapping to /proc/mounts
/// </summary>
/// <param name="devicePath">Device path to check</param>
/// <returns>Mount point for the device on success, null otherwise</returns>
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;
}
}
/// <summary>
/// Read the device size from sysfs (reported in 512-byte sectors) and convert to bytes
/// </summary>
/// <param name="sysBlockEntry">Path to the device directory under the sysfs block root</param>
/// <returns>The device size in bytes, 0 on error</returns>
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;
}
/// <summary>
/// Read the device size from sysfs (reported in 512-byte sectors) and convert to bytes
/// </summary>
/// <param name="sysGenericEntry">Path to the device directory under the sysfs block root</param>
/// <returns>The device size in bytes, 0 on error</returns>
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;
}
/// <summary>
/// Read the sysfs "removable" flag for a block device
/// </summary>
/// <param name="sysBlockEntry">Path to the device directory under the sysfs block root</param>
/// <returns>True when the device reports itself as removable, false otherwise</returns>
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;
}
}
/// <summary>
/// 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.
/// </summary>
/// <param name="name">Device node name to check</param>
/// <param name="prefix">Required leading text (e.g. "sr", "sg")</param>
/// <returns>True when the name is the prefix followed only by digits</returns>
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
}
}