2026-04-16 15:21:24 +02:00
// Copyright (C) 2026, The Duplicati Team
2026-02-15 11:45:52 +01:00
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System ;
2026-03-01 11:37:25 +01:00
using System.Collections.Generic ;
using System.Diagnostics ;
using System.IO ;
using System.Linq ;
using System.Text.RegularExpressions ;
2026-03-02 15:17:24 +00:00
using System.Threading ;
2026-02-23 14:10:30 +01:00
using Duplicati.Proprietary.DiskImage ;
2026-03-01 11:37:25 +01:00
using NUnit.Framework ;
2026-03-05 15:30:36 +01:00
using Duplicati.Proprietary.DiskImage.General ;
2026-02-15 11:45:52 +01:00
#nullable enable
2026-03-16 08:57:30 +01:00
namespace Duplicati.UnitTest.DiskImage.Helpers ;
/// <summary>
/// Linux implementation of <see cref="IDiskImageHelper"/> using loop devices and standard Linux tools.
/// </summary>
internal class LinuxDiskImageHelper : IDiskImageHelper
2026-02-15 11:45:52 +01:00
{
/// <summary>
2026-03-16 08:57:30 +01:00
/// Tracks loop devices created by this helper for cleanup.
/// </summary>
private readonly List < string > _trackedLoopDevices = new ();
/// <summary>
/// Maps image paths to their associated loop devices.
/// </summary>
private readonly Dictionary < string , string > _imageToLoopDevice = new ();
/// <summary>
/// Tracks any mount directories that were automatically created for cleanup.
2026-02-15 11:45:52 +01:00
/// </summary>
2026-03-16 08:57:30 +01:00
private readonly List < string > _autoCreatedMntDirectories = new ();
/// <inheritdoc />
public string CreateDisk ( string imagePath , long sizeB )
2026-02-15 11:45:52 +01:00
{
2026-03-16 08:57:30 +01:00
// Ensure the directory exists
var directory = Path . GetDirectoryName ( imagePath );
if (! string . IsNullOrEmpty ( directory ) && ! Directory . Exists ( directory ))
Directory . CreateDirectory ( directory );
// Delete existing image if it exists
if ( File . Exists ( imagePath ))
{
try
2026-03-01 11:37:25 +01:00
{
2026-03-16 08:57:30 +01:00
CleanupDisk ( imagePath );
}
catch
{
// Ignore errors during cleanup
2026-03-01 11:37:25 +01:00
}
2026-03-16 08:57:30 +01:00
}
2026-03-01 11:37:25 +01:00
2026-03-16 08:57:30 +01:00
// Create a sparse disk image file using truncate
RunProcess ( "truncate" , $"-s {sizeB} \" { imagePath } \ "" );
2026-03-01 11:37:25 +01:00
2026-03-16 08:57:30 +01:00
// Attach it as a loop device
var loopDevice = RunProcess ( "losetup" , $"--find --show --partscan \" { imagePath } \ "" ). Trim ();
2026-03-01 11:37:25 +01:00
2026-03-16 08:57:30 +01:00
if ( string . IsNullOrEmpty ( loopDevice ) || ! loopDevice . StartsWith ( "/dev/loop" ))
throw new InvalidOperationException ( $"Failed to attach loop device for {imagePath}" );
2026-03-01 11:37:25 +01:00
2026-03-16 08:57:30 +01:00
// Track the loop device
_trackedLoopDevices . Add ( loopDevice );
_imageToLoopDevice [ imagePath ] = loopDevice ;
2026-03-01 11:37:25 +01:00
2026-03-16 08:57:30 +01:00
return loopDevice ;
}
2026-02-15 11:45:52 +01:00
2026-03-16 08:57:30 +01:00
/// <inheritdoc />
public string [] InitializeDisk ( string diskIdentifier , PartitionTableType tableType , ( FileSystemType , long )[] partitions )
{
if ( tableType == PartitionTableType . Unknown )
return []; // No partition table, so nothing to initialize
// Create partition table using parted
var label = tableType == PartitionTableType . GPT ? "gpt" : "msdos" ;
RunProcess ( "parted" , $"-s {diskIdentifier} mklabel {label}" );
// Get disk size for calculating partition sizes
var diskSize = GetDiskSize ( diskIdentifier );
var sectorSize = GetSectorSize ( diskIdentifier );
var mbrSize = 1 * sectorSize ; // MBR requires 1 sector for the partition table
var gptSize = mbrSize + 33 * sectorSize ; // GPT requires 34 sectors reserved at the beginning (1 for protective MBR + 33 for GPT header and partition entries)
long nextFreeSector = tableType == PartitionTableType . GPT ? gptSize : mbrSize ;
// Create partitions
for ( int i = 0 ; i < partitions . Length ; i ++)
2026-03-01 11:37:25 +01:00
{
2026-03-16 08:57:30 +01:00
var ( fsType , size ) = partitions [ i ];
var partitionNumber = i + 1 ;
2026-03-01 11:37:25 +01:00
2026-03-16 08:57:30 +01:00
// Calculate partition size
long endOffset ;
if ( size <= 0 )
2026-03-01 11:37:25 +01:00
{
2026-03-16 08:57:30 +01:00
if ( i != partitions . Length - 1 )
throw new InvalidOperationException ( "Only the last partition can be sized to fill the remaining space" );
endOffset = diskSize - sectorSize ;
}
else
endOffset = nextFreeSector + size - sectorSize ;
2026-03-01 11:37:25 +01:00
2026-03-16 08:57:30 +01:00
// Check if we should clamp
if ( tableType == PartitionTableType . GPT && endOffset > diskSize - gptSize )
2026-03-02 10:32:07 +00:00
if ( size <= 0 )
2026-03-16 08:57:30 +01:00
endOffset = diskSize - gptSize ;
2026-03-01 11:37:25 +01:00
else
2026-03-16 08:57:30 +01:00
throw new InvalidOperationException ( $"Partition {partitionNumber} exceeds available space. Max size for this partition is {diskSize - gptSize - nextFreeSector} bytes." );
2026-03-01 11:37:25 +01:00
2026-03-16 08:57:30 +01:00
// Create partition
RunProcess ( "parted" , $"-s {diskIdentifier} mkpart primary {nextFreeSector}B {endOffset}B" );
2026-03-01 11:37:25 +01:00
2026-03-16 08:57:30 +01:00
// For MBR, set the boot flag on the first partition (some filesystems need this)
if ( tableType == PartitionTableType . MBR && i == 0 )
{
try
2026-03-01 11:37:25 +01:00
{
2026-03-16 08:57:30 +01:00
RunProcess ( "parted" , $"-s {diskIdentifier} set {partitionNumber} boot on" );
}
catch
{
// Ignore errors setting boot flag
2026-03-01 11:37:25 +01:00
}
}
2026-03-16 08:57:30 +01:00
nextFreeSector = endOffset + sectorSize ;
}
2026-03-01 11:37:25 +01:00
2026-03-16 08:57:30 +01:00
// Re-read partition table
RunProcess ( "partprobe" , $"{diskIdentifier}" );
2026-03-01 11:37:25 +01:00
2026-03-16 08:57:30 +01:00
// Small delay to allow kernel to create partition devices
System . Threading . Thread . Sleep ( 200 );
2026-03-01 11:37:25 +01:00
2026-03-16 08:57:30 +01:00
// Format each partition
for ( int i = 0 ; i < partitions . Length ; i ++)
{
var ( fsType , _ ) = partitions [ i ];
var partitionNumber = i + 1 ;
var partitionDevice = GetPartitionDevice ( diskIdentifier , partitionNumber );
2026-03-01 11:37:25 +01:00
2026-03-16 08:57:30 +01:00
// Wait for partition device to exist
var retryCount = 0 ;
while (! File . Exists ( partitionDevice ) && retryCount < 10 )
{
System . Threading . Thread . Sleep ( 100 );
retryCount ++;
2026-03-01 11:37:25 +01:00
}
2026-03-16 08:57:30 +01:00
if (! File . Exists ( partitionDevice ))
throw new InvalidOperationException ( $"Partition device {partitionDevice} did not appear after creation" );
// Format the partition
FormatPartition ( partitionDevice , fsType );
2026-03-01 11:37:25 +01:00
}
2026-02-15 11:45:52 +01:00
2026-03-16 08:57:30 +01:00
return partitions . Length == 0 ? [] : Mount ( diskIdentifier , null , false , null );
}
2026-03-01 11:37:25 +01:00
2026-03-16 08:57:30 +01:00
/// <inheritdoc />
public string [] Mount ( string diskIdentifier , string? baseMountPath = null , bool readOnly = false , FileSystemType []? fileSystemTypes = null )
{
var mountPoints = new List < string >();
var partitions = GetPartitionNames ( diskIdentifier );
2026-03-02 12:24:34 +00:00
2026-03-16 08:57:30 +01:00
if ( partitions . Count == 0 )
throw new InvalidOperationException ( $"No partitions found for disk {diskIdentifier}" );
if ( fileSystemTypes is not null && fileSystemTypes . Length != partitions . Count )
throw new ArgumentException ( $"Length of fileSystemTypes array ({fileSystemTypes.Length}) must match the number of partitions ({partitions.Count})" );
2026-03-01 11:37:25 +01:00
2026-03-16 08:57:30 +01:00
for ( int i = 0 ; i < partitions . Count ; i ++)
{
var partitionDevice = partitions [ i ];
2026-03-01 11:37:25 +01:00
2026-03-16 08:57:30 +01:00
// Get filesystem type to handle special cases
FileSystemType ? fsType = null ;
if ( fileSystemTypes is not null )
fsType = fileSystemTypes [ i ];
2026-03-01 11:37:25 +01:00
2026-03-16 08:57:30 +01:00
// Create mount point
string mountPoint ;
if (! string . IsNullOrEmpty ( baseMountPath ))
{
var partitionName = Path . GetFileName ( partitionDevice );
mountPoint = Path . Combine ( baseMountPath , partitionName );
}
else
{
mountPoint = $"/mnt/duplicati_{Path.GetFileName(partitionDevice)}_{Guid.NewGuid():N}" ;
_autoCreatedMntDirectories . Add ( mountPoint );
}
2026-03-01 11:37:25 +01:00
2026-03-16 08:57:30 +01:00
Directory . CreateDirectory ( mountPoint );
2026-03-01 11:37:25 +01:00
2026-03-16 08:57:30 +01:00
// Mount the partition
List < string > options = [];
if ( readOnly )
options . Add ( "ro" );
if ( fsType == FileSystemType . XFS )
options . Add ( "nouuid" ); // XFS does not allow mounting with duplicate UUIDs, so we ignore UUIDs for testing
var optionsArg = options . Count > 0 ? $"-o {string.Join(" , ", options)}" : "" ;
var mountArgs = $"{optionsArg} {partitionDevice} \" { mountPoint } \ "" . Trim ();
RunProcess ( "mount" , mountArgs );
2026-03-01 11:37:25 +01:00
2026-03-16 08:57:30 +01:00
mountPoints . Add ( mountPoint );
2026-03-01 11:37:25 +01:00
}
2026-02-15 11:45:52 +01:00
2026-03-16 08:57:30 +01:00
return mountPoints . ToArray ();
}
2026-03-01 11:37:25 +01:00
2026-03-16 08:57:30 +01:00
/// <inheritdoc />
public void Unmount ( string diskIdentifier )
{
// Find all mounted partitions of this device
var partitions = GetPartitionNames ( diskIdentifier );
foreach ( var partitionDevice in partitions )
{
// Check if mounted
var mountInfo = GetMountPoint ( partitionDevice );
if (! string . IsNullOrEmpty ( mountInfo ))
2026-03-01 11:37:25 +01:00
{
2026-03-16 08:57:30 +01:00
RunProcess ( "umount" , partitionDevice );
2026-03-01 11:37:25 +01:00
}
}
2026-03-16 08:57:30 +01:00
}
2026-02-15 11:45:52 +01:00
2026-03-16 08:57:30 +01:00
/// <inheritdoc />
public void CleanupDisk ( string imagePath , string? diskIdentifier = null )
{
// Determine the loop device if not provided
if ( string . IsNullOrEmpty ( diskIdentifier ))
2026-03-01 11:37:25 +01:00
{
2026-03-16 08:57:30 +01:00
if ( _imageToLoopDevice . TryGetValue ( imagePath , out var trackedDevice ))
2026-03-01 11:37:25 +01:00
{
2026-03-16 08:57:30 +01:00
diskIdentifier = trackedDevice ;
2026-03-01 11:37:25 +01:00
}
2026-03-16 08:57:30 +01:00
else
2026-03-01 11:37:25 +01:00
{
2026-03-16 08:57:30 +01:00
// Try to find the loop device using losetup -j
try
2026-03-01 11:37:25 +01:00
{
2026-03-16 08:57:30 +01:00
var output = RunProcess ( "losetup" , $"-j \" { imagePath } \ "" );
if (! string . IsNullOrEmpty ( output ))
2026-03-02 15:17:24 +00:00
{
2026-03-16 08:57:30 +01:00
// Output format: /dev/loop0: [0005]:123456 (/path/to/image)
var match = Regex . Match ( output , @"^(\S+):" );
if ( match . Success )
diskIdentifier = match . Groups [ 1 ]. Value ;
2026-03-02 15:17:24 +00:00
}
2026-03-01 11:37:25 +01:00
}
2026-03-16 08:57:30 +01:00
catch
2026-03-01 11:37:25 +01:00
{
2026-03-16 08:57:30 +01:00
// Ignore errors
2026-03-01 11:37:25 +01:00
}
}
2026-03-16 08:57:30 +01:00
}
2026-03-01 11:37:25 +01:00
2026-03-16 08:57:30 +01:00
// Unmount partitions
if (! string . IsNullOrEmpty ( diskIdentifier ))
{
bool unmounted = false ;
for ( int i = 0 ; i < 5 ; i ++)
2026-03-01 11:37:25 +01:00
{
try
{
2026-03-16 08:57:30 +01:00
Unmount ( diskIdentifier );
unmounted = true ;
break ;
2026-03-01 11:37:25 +01:00
}
2026-03-16 08:57:30 +01:00
catch
2026-03-01 11:37:25 +01:00
{
2026-03-16 08:57:30 +01:00
// Try to wait
Thread . Sleep ( 1000 );
2026-03-01 11:37:25 +01:00
}
}
2026-03-16 08:57:30 +01:00
if (! unmounted )
{
TestContext . Progress . WriteLine ( $"Warning: Failed to unmount partitions for {diskIdentifier} after multiple attempts." );
}
2026-03-01 11:37:25 +01:00
2026-03-16 08:57:30 +01:00
// Detach loop device
try
2026-03-02 13:55:18 +00:00
{
2026-03-16 08:57:30 +01:00
RunProcess ( "losetup" , $"-d {diskIdentifier}" );
2026-03-02 13:55:18 +00:00
}
2026-03-16 08:57:30 +01:00
catch ( Exception ex )
{
TestContext . Progress . WriteLine ( $"Warning: Failed to detach loop device {diskIdentifier}: {ex.Message}" );
}
_trackedLoopDevices . Remove ( diskIdentifier );
2026-03-01 11:37:25 +01:00
}
2026-02-15 11:45:52 +01:00
2026-03-16 08:57:30 +01:00
// Delete the image file
if ( File . Exists ( imagePath ))
2026-03-01 11:37:25 +01:00
{
try
{
2026-03-16 08:57:30 +01:00
File . Delete ( imagePath );
2026-03-01 11:37:25 +01:00
}
2026-03-16 08:57:30 +01:00
catch ( Exception ex )
2026-03-01 11:37:25 +01:00
{
2026-03-16 08:57:30 +01:00
TestContext . Progress . WriteLine ( $"Warning: Failed to delete image file {imagePath}: {ex.Message}" );
2026-03-01 11:37:25 +01:00
}
}
2026-02-15 11:45:52 +01:00
2026-03-16 08:57:30 +01:00
_imageToLoopDevice . Remove ( imagePath );
2026-03-01 11:37:25 +01:00
2026-03-16 08:57:30 +01:00
// Clean up any auto-created mount directories
foreach ( var mntDir in _autoCreatedMntDirectories )
{
2026-03-01 11:37:25 +01:00
try
{
2026-03-16 08:57:30 +01:00
if ( Directory . Exists ( mntDir ))
Directory . Delete ( mntDir );
2026-03-01 11:37:25 +01:00
}
2026-03-16 08:57:30 +01:00
catch ( Exception ex )
2026-03-01 11:37:25 +01:00
{
2026-03-16 08:57:30 +01:00
TestContext . Progress . WriteLine ( $"Warning: Failed to delete mount directory {mntDir}: {ex.Message}" );
2026-03-01 11:37:25 +01:00
}
2026-03-16 08:57:30 +01:00
}
_autoCreatedMntDirectories . Clear ();
}
2026-03-01 11:37:25 +01:00
2026-03-16 08:57:30 +01:00
/// <inheritdoc />
public bool HasRequiredPrivileges ()
{
// Check if running as root (uid 0)
try
{
var uid = RunProcess ( "id" , "-u" ). Trim ();
return uid == "0" ;
}
catch
{
return false ;
}
}
2026-03-01 11:37:25 +01:00
2026-03-16 08:57:30 +01:00
/// <inheritdoc />
public PartitionTableGeometry GetPartitionTable ( string diskIdentifier )
{
var deviceName = Path . GetFileName ( diskIdentifier );
var tableType = PartitionTableType . Unknown ;
// Use blkid to get partition table type
try
{
var output = RunProcess ( "blkid" , $"-p -o value -s PTTYPE {diskIdentifier}" ). Trim ();
tableType = output . ToLower () switch
2026-03-01 11:37:25 +01:00
{
2026-03-16 08:57:30 +01:00
"gpt" => PartitionTableType . GPT ,
"dos" => PartitionTableType . MBR ,
"msdos" => PartitionTableType . MBR ,
_ => PartitionTableType . Unknown
2026-03-01 11:37:25 +01:00
};
}
2026-03-16 08:57:30 +01:00
catch
{
// Ignore errors
}
var size = GetDiskSize ( diskIdentifier );
var sectorSize = GetSectorSize ( diskIdentifier );
return new PartitionTableGeometry
{
Type = tableType ,
Size = size ,
SectorSize = sectorSize
};
}
2026-02-15 11:45:52 +01:00
2026-03-16 08:57:30 +01:00
/// <inheritdoc />
public PartitionGeometry [] GetPartitions ( string diskIdentifier )
{
var partitions = new List < PartitionGeometry >();
var partitionNames = GetPartitionNames ( diskIdentifier );
for ( int i = 0 ; i < partitionNames . Count ; i ++)
2026-03-01 11:37:25 +01:00
{
2026-03-16 08:57:30 +01:00
var partitionDevice = partitionNames [ i ];
var partitionNumber = i + 1 ;
2026-03-01 11:37:25 +01:00
2026-03-16 08:57:30 +01:00
try
2026-03-01 11:37:25 +01:00
{
2026-03-16 08:57:30 +01:00
// Get partition info using blockdev
var start = 0L ;
var size = 0L ;
2026-03-01 11:37:25 +01:00
try
{
2026-03-16 08:57:30 +01:00
var devName = Path . GetFileName ( partitionDevice );
var startFile = $"/sys/class/block/{devName}/start" ;
if ( File . Exists ( startFile ))
2026-03-01 11:37:25 +01:00
{
2026-03-16 08:57:30 +01:00
var startStr = File . ReadAllText ( startFile ). Trim ();
if ( long . TryParse ( startStr , out var startSector ))
2026-03-11 10:09:50 +00:00
{
2026-03-16 08:57:30 +01:00
start = startSector * 512 ;
2026-03-11 10:09:50 +00:00
}
2026-03-01 11:37:25 +01:00
}
2026-03-16 08:57:30 +01:00
else
2026-03-01 11:37:25 +01:00
{
2026-03-16 08:57:30 +01:00
var startStr = RunProcess ( "blockdev" , $"--getstart {partitionDevice}" ). Trim ();
if ( long . TryParse ( startStr , out var startSector ))
{
// blockdev --getstart always returns the start sector in 512-byte units
start = startSector * 512 ;
}
2026-03-01 11:37:25 +01:00
}
}
catch ( Exception ex )
{
2026-03-16 08:57:30 +01:00
TestContext . Progress . WriteLine ( $"Warning: Failed to get start for {partitionDevice}: {ex.Message}" );
2026-03-01 11:37:25 +01:00
}
2026-03-16 08:57:30 +01:00
try
{
size = GetDiskSize ( partitionDevice );
}
catch { }
2026-02-15 11:45:52 +01:00
2026-03-16 08:57:30 +01:00
// Get filesystem type
var fsType = FileSystemType . Unknown ;
try
{
var fsStr = RunProcess ( "blkid" , $"-o value -s TYPE {partitionDevice}" ). Trim ();
fsType = ParseFilesystemType ( fsStr );
}
catch { }
2026-03-01 11:37:25 +01:00
2026-03-16 08:57:30 +01:00
partitions . Add ( new PartitionGeometry
{
Number = partitionNumber ,
Type = PartitionType . Unknown ,
StartOffset = start ,
Size = size ,
FilesystemType = fsType ,
TableType = PartitionTableType . Unknown
});
2026-03-01 11:37:25 +01:00
}
2026-03-16 08:57:30 +01:00
catch ( Exception ex )
2026-03-01 11:37:25 +01:00
{
2026-03-16 08:57:30 +01:00
TestContext . Progress . WriteLine ( $"Warning: Failed to get info for partition {partitionDevice}: {ex.Message}" );
2026-03-01 11:37:25 +01:00
}
}
2026-02-15 11:45:52 +01:00
2026-03-16 08:57:30 +01:00
return partitions . ToArray ();
}
/// <inheritdoc />
public void FlushDisk ( string diskIdentifier )
{
// Sync all filesystems
RunProcess ( "sync" , "" );
// Flush buffers for the specific device
try
2026-03-01 11:37:25 +01:00
{
2026-03-16 08:57:30 +01:00
RunProcess ( "blockdev" , $"--flushbufs {diskIdentifier}" );
}
catch
{
// Ignore errors
}
}
2026-03-01 11:37:25 +01:00
2026-03-16 08:57:30 +01:00
/// <inheritdoc />
public string ReAttach ( string imagePath , string diskIdentifier , PartitionTableType tableType , bool readOnly = false )
{
// Unmount and detach
try
{
Unmount ( diskIdentifier );
}
catch { }
2026-03-01 11:37:25 +01:00
2026-03-16 08:57:30 +01:00
try
{
RunProcess ( "losetup" , $"-d {diskIdentifier}" );
}
catch { }
2026-03-01 11:37:25 +01:00
2026-03-16 08:57:30 +01:00
// Re-attach the loop device
var readOnlyArg = readOnly ? "--read-only" : "" ;
var newLoopDevice = RunProcess ( "losetup" , $"--find --show --partscan {readOnlyArg} \" { imagePath } \ "" ). Trim ();
2026-03-01 11:37:25 +01:00
2026-03-16 08:57:30 +01:00
if ( string . IsNullOrEmpty ( newLoopDevice ) || ! newLoopDevice . StartsWith ( "/dev/loop" ))
throw new InvalidOperationException ( $"Failed to re-attach loop device for {imagePath}" );
2026-03-01 11:37:25 +01:00
2026-03-16 08:57:30 +01:00
// Update tracking
_trackedLoopDevices . Remove ( diskIdentifier );
_trackedLoopDevices . Add ( newLoopDevice );
_imageToLoopDevice [ imagePath ] = newLoopDevice ;
2026-03-01 11:37:25 +01:00
2026-03-16 08:57:30 +01:00
// Allow time for partition discovery
System . Threading . Thread . Sleep ( 200 );
2026-02-27 07:06:41 +01:00
2026-03-16 08:57:30 +01:00
return newLoopDevice ;
}
2026-03-25 07:56:33 +01:00
/// <summary>
/// Set of file system types that are supported on Linux.
/// </summary>
private readonly HashSet < FileSystemType > _supportedFileSystems = [
FileSystemType . Btrfs ,
FileSystemType . Ext2 ,
FileSystemType . Ext3 ,
FileSystemType . Ext4 ,
FileSystemType . ExFAT ,
FileSystemType . FAT32 ,
FileSystemType . XFS ,
FileSystemType . Unknown
];
/// <inheritdoc />
public ( bool , string ) IsFileSystemTypeSupported ( FileSystemType fsType )
{
if ( _supportedFileSystems . Contains ( fsType ))
return ( true , $"{fsType} is supported on Linux" );
return ( false , $"{fsType} is not supported on Linux" );
}
2026-03-16 08:57:30 +01:00
/// <inheritdoc />
public void Dispose ()
{
// Clean up any remaining tracked loop devices
foreach ( var loopDevice in _trackedLoopDevices . ToList ())
2026-02-15 11:45:52 +01:00
{
2026-03-16 08:57:30 +01:00
try
2026-03-01 11:37:25 +01:00
{
2026-03-16 08:57:30 +01:00
// Find associated image
var imagePath = _imageToLoopDevice . FirstOrDefault ( x => x . Value == loopDevice ). Key ;
if (! string . IsNullOrEmpty ( imagePath ))
2026-03-01 11:37:25 +01:00
{
2026-03-16 08:57:30 +01:00
CleanupDisk ( imagePath , loopDevice );
2026-03-01 11:37:25 +01:00
}
2026-03-16 08:57:30 +01:00
else
2026-03-01 11:37:25 +01:00
{
2026-03-16 08:57:30 +01:00
// Just try to detach the loop device
RunProcess ( "losetup" , $"-d {loopDevice}" );
2026-03-01 11:37:25 +01:00
}
}
2026-03-16 08:57:30 +01:00
catch ( Exception ex )
{
TestContext . Progress . WriteLine ( $"Warning: Failed to dispose loop device {loopDevice}: {ex.Message}" );
}
2026-02-15 11:45:52 +01:00
}
2026-03-01 11:37:25 +01:00
2026-03-16 08:57:30 +01:00
_trackedLoopDevices . Clear ();
_imageToLoopDevice . Clear ();
GC . SuppressFinalize ( this );
}
2026-03-01 11:37:25 +01:00
2026-03-16 08:57:30 +01:00
#region Helper Methods
2026-03-01 11:37:25 +01:00
2026-03-16 08:57:30 +01:00
/// <summary>
/// Runs a process and returns the output.
/// </summary>
private static string RunProcess ( string fileName , string arguments )
{
var psi = new ProcessStartInfo
{
FileName = fileName ,
Arguments = arguments ,
RedirectStandardOutput = true ,
RedirectStandardError = true ,
UseShellExecute = false ,
CreateNoWindow = true
};
using var process = new Process { StartInfo = psi };
process . Start ();
string output = process . StandardOutput . ReadToEnd ();
string error = process . StandardError . ReadToEnd ();
process . WaitForExit ();
if ( process . ExitCode != 0 )
{
throw new InvalidOperationException ( $"Process {fileName} exited with code {process.ExitCode}: {error}" );
}
2026-03-01 11:37:25 +01:00
2026-03-16 08:57:30 +01:00
return output ;
}
2026-03-01 11:37:25 +01:00
2026-03-16 08:57:30 +01:00
/// <summary>
/// Gets the partition device path for a given disk and partition number.
/// </summary>
private static string GetPartitionDevice ( string diskIdentifier , int partitionNumber )
{
// For loop devices, partitions are named /dev/loop0p1, /dev/loop0p2, etc.
if ( diskIdentifier . StartsWith ( "/dev/loop" ))
{
return $"{diskIdentifier}p{partitionNumber}" ;
2026-03-01 11:37:25 +01:00
}
2026-03-16 08:57:30 +01:00
// For NVMe devices, partitions are named /dev/nvme0n1p1, etc.
if ( diskIdentifier . Contains ( "nvme" ))
2026-03-01 11:37:25 +01:00
{
2026-03-16 08:57:30 +01:00
return $"{diskIdentifier}p{partitionNumber}" ;
}
2026-03-01 11:37:25 +01:00
2026-03-16 08:57:30 +01:00
// For standard SCSI/SATA devices, partitions are named /dev/sda1, /dev/sda2, etc.
return $"{diskIdentifier}{partitionNumber}" ;
}
2026-03-01 11:37:25 +01:00
2026-03-16 08:57:30 +01:00
/// <summary>
/// Gets the list of partition device paths for a disk.
/// </summary>
private static List < string > GetPartitionNames ( string diskIdentifier )
{
var partitions = new List < string >();
var deviceName = Path . GetFileName ( diskIdentifier );
2026-03-01 11:37:25 +01:00
2026-03-16 08:57:30 +01:00
// Check for partition devices
if ( diskIdentifier . StartsWith ( "/dev/loop" ))
2026-03-01 11:37:25 +01:00
{
2026-03-16 08:57:30 +01:00
// Loop device partitions: /dev/loop0p1, /dev/loop0p2, etc.
for ( int i = 1 ; i <= 16 ; i ++)
{
var partitionDevice = $"{diskIdentifier}p{i}" ;
if ( File . Exists ( partitionDevice ))
partitions . Add ( partitionDevice );
else if ( i > 1 )
break ; // Stop after first missing partition
2026-03-01 11:37:25 +01:00
}
}
2026-03-16 08:57:30 +01:00
else if ( diskIdentifier . Contains ( "nvme" ))
2026-03-01 11:37:25 +01:00
{
2026-03-16 08:57:30 +01:00
// NVMe partitions: /dev/nvme0n1p1, etc.
for ( int i = 1 ; i <= 16 ; i ++)
{
var partitionDevice = $"{diskIdentifier}p{i}" ;
if ( File . Exists ( partitionDevice ))
partitions . Add ( partitionDevice );
else if ( i > 1 )
break ;
2026-03-01 11:37:25 +01:00
}
}
2026-03-16 08:57:30 +01:00
else
2026-03-01 11:37:25 +01:00
{
2026-03-16 08:57:30 +01:00
// Standard partitions: /dev/sda1, /dev/sda2, etc.
for ( int i = 1 ; i <= 16 ; i ++)
{
var partitionDevice = $"{diskIdentifier}{i}" ;
if ( File . Exists ( partitionDevice ))
partitions . Add ( partitionDevice );
else if ( i > 1 )
break ;
2026-03-01 11:37:25 +01:00
}
}
2026-03-16 08:57:30 +01:00
return partitions ;
}
2026-03-01 11:37:25 +01:00
2026-03-16 08:57:30 +01:00
/// <summary>
/// Gets the mount point for a partition device.
/// </summary>
private static string? GetMountPoint ( string partitionDevice )
{
try
{
var output = RunProcess ( "findmnt" , $"--raw --noheadings -o TARGET {partitionDevice}" ). Trim ();
return string . IsNullOrEmpty ( output ) ? null : output ;
2026-03-01 11:37:25 +01:00
}
2026-03-16 08:57:30 +01:00
catch
2026-03-01 11:37:25 +01:00
{
2026-03-16 08:57:30 +01:00
return null ;
2026-03-01 11:37:25 +01:00
}
2026-03-16 08:57:30 +01:00
}
2026-03-01 11:37:25 +01:00
2026-03-16 08:57:30 +01:00
/// <summary>
/// Gets the filesystem type of a partition.
/// </summary>
private static string GetFilesystemType ( string partitionDevice )
{
try
2026-03-01 11:37:25 +01:00
{
2026-03-16 08:57:30 +01:00
return RunProcess ( "blkid" , $"-o value -s TYPE {partitionDevice}" ). Trim ();
2026-03-01 11:37:25 +01:00
}
2026-03-16 08:57:30 +01:00
catch
2026-03-01 11:37:25 +01:00
{
2026-03-16 08:57:30 +01:00
return string . Empty ;
2026-03-01 11:37:25 +01:00
}
2026-03-16 08:57:30 +01:00
}
2026-03-01 11:37:25 +01:00
2026-03-16 08:57:30 +01:00
/// <summary>
/// Formats a partition with the specified filesystem type.
/// </summary>
private static void FormatPartition ( string partitionDevice , FileSystemType fsType )
{
var ( command , args ) = fsType switch
{
FileSystemType . FAT12 => ( "mkfs.vfat" , $"-F 12 {partitionDevice}" ),
FileSystemType . FAT16 => ( "mkfs.vfat" , $"-F 16 {partitionDevice}" ),
FileSystemType . FAT32 => ( "mkfs.vfat" , $"-F 32 {partitionDevice}" ),
FileSystemType . ExFAT => ( "mkfs.exfat" , $"{partitionDevice}" ),
FileSystemType . Ext2 => ( "mkfs.ext2" , $"-F {partitionDevice}" ),
FileSystemType . Ext3 => ( "mkfs.ext3" , $"-F {partitionDevice}" ),
FileSystemType . Ext4 => ( "mkfs.ext4" , $"-F {partitionDevice}" ),
FileSystemType . XFS => ( "mkfs.xfs" , $"-f {partitionDevice}" ),
FileSystemType . Btrfs => ( "mkfs.btrfs" , $"-f {partitionDevice}" ),
FileSystemType . NTFS => ( "mkfs.ntfs" , $"-F {partitionDevice}" ),
FileSystemType . ZFS => throw new NotSupportedException ( "ZFS is not supported for testing. ZFS requires pool creation which is incompatible with standard partition-based testing." ),
_ => throw new NotSupportedException ( $"Unsupported filesystem type on Linux: {fsType}" )
};
RunProcess ( command , args );
}
/// <summary>
/// Gets the size of a disk or partition in bytes.
/// </summary>
private static long GetDiskSize ( string devicePath )
{
var output = RunProcess ( "blockdev" , $"--getsize64 {devicePath}" ). Trim ();
return long . TryParse ( output , out var size ) ? size : 0 ;
}
/// <summary>
/// Gets the sector size of a disk.
/// </summary>
private static int GetSectorSize ( string devicePath )
{
var output = RunProcess ( "blockdev" , $"--getss {devicePath}" ). Trim ();
return int . TryParse ( output , out var size ) ? size : 512 ;
2026-02-15 11:45:52 +01:00
}
2026-03-16 08:57:30 +01:00
/// <summary>
/// Parses a filesystem type string to the FileSystemType enum.
/// </summary>
private static FileSystemType ParseFilesystemType ( string fsType )
{
return fsType . ToLower () switch
{
"ntfs" => FileSystemType . NTFS ,
"vfat" or "fat" => FileSystemType . FAT32 , // Could be FAT12/16/32
"exfat" => FileSystemType . ExFAT ,
"hfsplus" or "hfs+" => FileSystemType . HFSPlus ,
"apfs" => FileSystemType . APFS ,
"ext2" => FileSystemType . Ext2 ,
"ext3" => FileSystemType . Ext3 ,
"ext4" => FileSystemType . Ext4 ,
"xfs" => FileSystemType . XFS ,
"btrfs" => FileSystemType . Btrfs ,
"zfs" => FileSystemType . ZFS ,
"refs" => FileSystemType . ReFS ,
_ => FileSystemType . Unknown
};
}
#endregion
2026-02-15 11:45:52 +01:00
}