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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ public static TheoryData<string, int> ArchiveData
{ "TestData.a",3 },
{ "TestData.bsd.ar",3 },
{ "TestData.iso",3 },
{ "TestDataRockRidge.iso",2 },
{ "TestData.vhdx",3 },
{ "EmptyFile.txt", 1 },
{ "TestDataArchivesNested.zip", RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? 54 : 52 },
Expand Down Expand Up @@ -79,6 +80,7 @@ public static TheoryData<string, int> NoRecursionData
{ "TestData.a", 3 },
{ "TestData.bsd.ar", 3 },
{ "TestData.iso", 3 },
{ "TestDataRockRidge.iso", 2 },
{ "TestData.vhdx", 3 },
{ "EmptyFile.txt", 1 },
{ "TestDataArchivesNested.zip", 14 },
Expand Down
108 changes: 108 additions & 0 deletions RecursiveExtractor.Tests/ExtractorTests/FileMetadataTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ public void MetadataDefaults_AreNull()
Assert.Null(metadata.IsExecutable);
Assert.Null(metadata.IsSetUid);
Assert.Null(metadata.IsSetGid);
Assert.Null(metadata.FileAttributes);
Assert.Null(metadata.SecurityDescriptorSddl);
}

[Fact]
Expand Down Expand Up @@ -140,4 +142,110 @@ public void FileEntry_MetadataDefaultsToNull()
var entry = new FileEntry("test.txt", stream);
Assert.Null(entry.Metadata);
}

[Fact]
public async Task IsoEntries_MetadataIsNullWithoutRockRidge()
{
// TestData.iso does not have RockRidge extensions, so Unix metadata is not available
var extractor = new Extractor();
var path = Path.Combine(Directory.GetCurrentDirectory(), "TestData", "TestDataArchives", "TestData.iso");
var results = await extractor.ExtractAsync(path, new ExtractorOptions() { Recurse = false }).ToListAsync();

Assert.NotEmpty(results);
foreach (var entry in results)
{
// Without RockRidge extensions, metadata should be null
Assert.Null(entry.Metadata);
}
}

[Fact]
public void IsoEntries_MetadataIsNullWithoutRockRidge_Sync()
{
var extractor = new Extractor();
var path = Path.Combine(Directory.GetCurrentDirectory(), "TestData", "TestDataArchives", "TestData.iso");
var results = extractor.Extract(path, new ExtractorOptions() { Recurse = false }).ToList();

Assert.NotEmpty(results);
foreach (var entry in results)
{
Assert.Null(entry.Metadata);
}
}

[Fact]
public async Task IsoRockRidgeEntries_HaveMetadata()
{
// TestDataRockRidge.iso has RockRidge extensions with Unix permissions
var extractor = new Extractor();
var path = Path.Combine(Directory.GetCurrentDirectory(), "TestData", "TestDataArchives", "TestDataRockRidge.iso");
var results = await extractor.ExtractAsync(path, new ExtractorOptions() { Recurse = false }).ToListAsync();

Assert.NotEmpty(results);
foreach (var entry in results)
{
Assert.NotNull(entry.Metadata);
Assert.NotNull(entry.Metadata!.Mode);
Assert.NotNull(entry.Metadata.Uid);
Assert.NotNull(entry.Metadata.Gid);
}
}

[Fact]
public void IsoRockRidgeEntries_HaveMetadata_Sync()
{
var extractor = new Extractor();
var path = Path.Combine(Directory.GetCurrentDirectory(), "TestData", "TestDataArchives", "TestDataRockRidge.iso");
var results = extractor.Extract(path, new ExtractorOptions() { Recurse = false }).ToList();

Assert.NotEmpty(results);
foreach (var entry in results)
{
Assert.NotNull(entry.Metadata);
Assert.NotNull(entry.Metadata!.Mode);
Assert.NotNull(entry.Metadata.Uid);
Assert.NotNull(entry.Metadata.Gid);
}
}

[Fact]
public async Task VhdxNtfsEntries_HaveWindowsMetadata()
{
// TestData.vhdx contains an NTFS file system that implements IDosFileSystem and IWindowsFileSystem
var extractor = new Extractor();
var path = Path.Combine(Directory.GetCurrentDirectory(), "TestData", "TestDataArchives", "TestData.vhdx");
var results = await extractor.ExtractAsync(path, new ExtractorOptions() { Recurse = false }).ToListAsync();

Assert.NotEmpty(results);
foreach (var entry in results)
{
Assert.NotNull(entry.Metadata);
// NTFS provides Windows file attributes
Assert.NotNull(entry.Metadata!.FileAttributes);
// NTFS provides security descriptors
Assert.NotNull(entry.Metadata.SecurityDescriptorSddl);
Assert.Contains("D:", entry.Metadata.SecurityDescriptorSddl); // DACL present
// NTFS does not provide Unix metadata
Assert.Null(entry.Metadata.Mode);
Assert.Null(entry.Metadata.Uid);
Assert.Null(entry.Metadata.Gid);
}
}

[Fact]
public void VhdxNtfsEntries_HaveWindowsMetadata_Sync()
{
var extractor = new Extractor();
var path = Path.Combine(Directory.GetCurrentDirectory(), "TestData", "TestDataArchives", "TestData.vhdx");
var results = extractor.Extract(path, new ExtractorOptions() { Recurse = false }).ToList();

Assert.NotEmpty(results);
foreach (var entry in results)
{
Assert.NotNull(entry.Metadata);
Assert.NotNull(entry.Metadata!.FileAttributes);
Assert.NotNull(entry.Metadata.SecurityDescriptorSddl);
Assert.Null(entry.Metadata.Mode);
}
}
}
3 changes: 3 additions & 0 deletions RecursiveExtractor.Tests/RecursiveExtractor.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,9 @@
<None Update="TestData\TestDataArchives\TestData.iso">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="TestData\TestDataArchives\TestDataRockRidge.iso">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="TestData\TestDataArchives\TestData.rar">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
Expand Down
Binary file not shown.
98 changes: 98 additions & 0 deletions RecursiveExtractor/Extractors/DiscCommon.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,102 @@ public static class DiscCommon
{
private static readonly NLog.Logger Logger = NLog.LogManager.GetCurrentClassLogger();

/// <summary>
/// Tries to extract file metadata from a DiscUtils file system entry.
/// For file systems implementing <see cref="IUnixFileSystem"/> (Ext, Xfs, Btrfs, HfsPlus),
/// returns permissions, UID, and GID.
/// For file systems implementing <see cref="IDosFileSystem"/> (NTFS, FAT, WIM),
/// returns Windows file attributes.
/// For file systems implementing <see cref="IWindowsFileSystem"/> (NTFS, WIM),
Comment on lines +19 to +23
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The XML doc comment lists examples of IUnixFileSystem implementations (Ext/Xfs/Btrfs/HfsPlus) but this helper is also used for ISO via DiscUtils.Iso9660.CDReader, which implements IUnixFileSystem only when RockRidge metadata is present. Consider updating the comment to include CDReader/ISO and note the RockRidge requirement (or clarify that the list is non-exhaustive) to avoid misleading future readers.

Suggested change
/// For file systems implementing <see cref="IUnixFileSystem"/> (Ext, Xfs, Btrfs, HfsPlus),
/// returns permissions, UID, and GID.
/// For file systems implementing <see cref="IDosFileSystem"/> (NTFS, FAT, WIM),
/// returns Windows file attributes.
/// For file systems implementing <see cref="IWindowsFileSystem"/> (NTFS, WIM),
/// For file systems implementing <see cref="IUnixFileSystem"/> (such as Ext, Xfs, Btrfs, HfsPlus,
/// and ISO images via <c>DiscUtils.Iso9660.CDReader</c> when RockRidge extensions are present),
/// returns permissions, UID, and GID.
/// For file systems implementing <see cref="IDosFileSystem"/> (such as NTFS, FAT, WIM),
/// returns Windows file attributes.
/// For file systems implementing <see cref="IWindowsFileSystem"/> (such as NTFS, WIM),

Copilot uses AI. Check for mistakes.
/// also returns the security descriptor in SDDL format.
/// Returns null for file systems that support none of these interfaces.
/// </summary>
/// <param name="fs">The opened disc file system</param>
/// <param name="filePath">Path of the file within the file system</param>
/// <returns>Populated <see cref="FileEntryMetadata"/> or null when not available</returns>
internal static FileEntryMetadata? TryGetFileMetadata(DiscFileSystem fs, string filePath)
{
FileEntryMetadata? metadata = null;

if (fs is IUnixFileSystem unixFs)
{
try
{
var info = unixFs.GetUnixFileInfo(filePath);
metadata = new FileEntryMetadata
{
Mode = (long)info.Permissions,
Uid = info.UserId,
Gid = info.GroupId
};
}
catch (Exception e)
{
Logger.Debug(e, "Could not retrieve Unix metadata for {0}", filePath);
}
}

if (fs is IDosFileSystem dosFs)
{
try
{
var winInfo = dosFs.GetFileStandardInformation(filePath);
metadata ??= new FileEntryMetadata();
metadata.FileAttributes = winInfo.FileAttributes;
}
catch (Exception e)
{
Logger.Debug(e, "Could not retrieve DOS file attributes for {0}", filePath);
}
}

if (fs is IWindowsFileSystem windowsFs)
{
try
{
var securityDescriptor = windowsFs.GetSecurity(filePath);
if (securityDescriptor != null)
{
metadata ??= new FileEntryMetadata();
metadata.SecurityDescriptorSddl = securityDescriptor.GetSddlForm(
DiscUtils.Core.WindowsSecurity.AccessControl.AccessControlSections.All);
}
}
catch (Exception e)
{
Logger.Debug(e, "Could not retrieve security descriptor for {0}", filePath);
}
}

return metadata;
}

/// <summary>
/// Pre-collects metadata for all files while the file system is still open.
/// Used by extractors (e.g., ISO) where the file system is disposed before files are processed.
/// </summary>
/// <param name="fs">The opened disc file system</param>
/// <param name="fileInfos">The file entries to collect metadata for</param>
/// <returns>A dictionary mapping file paths to metadata, or null if the file system does not support metadata</returns>
internal static Dictionary<string, FileEntryMetadata>? CollectMetadata(DiscFileSystem fs, DiscFileInfo[] fileInfos)
{
if (fs is not IUnixFileSystem && fs is not IDosFileSystem && fs is not IWindowsFileSystem)
{
return null;
}

var result = new Dictionary<string, FileEntryMetadata>();
foreach (var fi in fileInfos)
{
var metadata = TryGetFileMetadata(fs, fi.FullName);
if (metadata != null)
{
result[fi.FullName] = metadata;
}
}
return result;
}

/// <summary>
/// Dump the FileEntries from a Logical Volume asynchronously
/// </summary>
Expand Down Expand Up @@ -59,6 +155,7 @@ public static async IAsyncEnumerable<FileEntry> DumpLogicalVolumeAsync(LogicalVo
if (fileStream != null && fi != null)
{
var newFileEntry = await FileEntry.FromStreamAsync($"{volume.Identity}{Path.DirectorySeparatorChar}{fi.FullName}", fileStream, parent, fi.CreationTime, fi.LastWriteTime, fi.LastAccessTime, memoryStreamCutoff: options.MemoryStreamCutoff).ConfigureAwait(false);
newFileEntry.Metadata = TryGetFileMetadata(fs, file);
if (options.Recurse || topLevel)
{
await foreach (var entry in Context.ExtractAsync(newFileEntry, options, governor, false))
Expand Down Expand Up @@ -124,6 +221,7 @@ public static IEnumerable<FileEntry> DumpLogicalVolume(LogicalVolumeInfo volume,
if (fileStream != null)
{
var newFileEntry = new FileEntry($"{volume.Identity}{Path.DirectorySeparatorChar}{file}", fileStream, parent, false, creation, modification, access, memoryStreamCutoff: options.MemoryStreamCutoff);
newFileEntry.Metadata = TryGetFileMetadata(fs, file);
if (options.Recurse || topLevel)
{
foreach (var extractedFile in Context.Extract(newFileEntry, options, governor, false))
Expand Down
12 changes: 12 additions & 0 deletions RecursiveExtractor/Extractors/IsoExtractor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,13 @@ public IsoExtractor(Extractor context)
public async IAsyncEnumerable<FileEntry> ExtractAsync(FileEntry fileEntry, ExtractorOptions options, ResourceGovernor governor, bool topLevel = true)
{
DiscUtils.DiscFileInfo[]? entries = null;
Dictionary<string, FileEntryMetadata>? metadataByPath = null;
var failed = false;
try
{
using var cd = new CDReader(fileEntry.Content, true);
entries = cd.Root.GetFiles("*.*", SearchOption.AllDirectories).ToArray();
metadataByPath = DiscCommon.CollectMetadata(cd, entries);
}
catch (Exception e)
{
Expand Down Expand Up @@ -69,6 +71,10 @@ public async IAsyncEnumerable<FileEntry> ExtractAsync(FileEntry fileEntry, Extra
{
var name = fileInfo.FullName.Replace('/', Path.DirectorySeparatorChar);
var newFileEntry = await FileEntry.FromStreamAsync(name, stream, fileEntry, fileInfo.CreationTime, fileInfo.LastWriteTime, fileInfo.LastAccessTime, memoryStreamCutoff: options.MemoryStreamCutoff).ConfigureAwait(false);
if (metadataByPath != null && metadataByPath.TryGetValue(fileInfo.FullName, out var entryMetadata))
{
newFileEntry.Metadata = entryMetadata;
}
if (options.Recurse || topLevel)
{
await foreach (var entry in Context.ExtractAsync(newFileEntry, options, governor, false))
Expand All @@ -92,11 +98,13 @@ public async IAsyncEnumerable<FileEntry> ExtractAsync(FileEntry fileEntry, Extra
public IEnumerable<FileEntry> Extract(FileEntry fileEntry, ExtractorOptions options, ResourceGovernor governor, bool topLevel = true)
{
DiscUtils.DiscFileInfo[]? entries = null;
Dictionary<string, FileEntryMetadata>? metadataByPath = null;
var failed = false;
try
{
using var cd = new CDReader(fileEntry.Content, true);
entries = cd.Root.GetFiles("*.*", SearchOption.AllDirectories).ToArray();
metadataByPath = DiscCommon.CollectMetadata(cd, entries);
}
catch(Exception e)
{
Expand Down Expand Up @@ -130,6 +138,10 @@ public IEnumerable<FileEntry> Extract(FileEntry fileEntry, ExtractorOptions opti
{
var name = fileInfo.FullName.Replace('/', Path.DirectorySeparatorChar);
var newFileEntry = new FileEntry(name, stream, fileEntry, createTime: file.CreationTime, modifyTime: file.LastWriteTime, accessTime: file.LastAccessTime, memoryStreamCutoff: options.MemoryStreamCutoff);
if (metadataByPath != null && metadataByPath.TryGetValue(fileInfo.FullName, out var entryMetadata))
{
newFileEntry.Metadata = entryMetadata;
}
if (options.Recurse || topLevel)
{
foreach (var entry in Context.Extract(newFileEntry, options, governor, false))
Expand Down
12 changes: 12 additions & 0 deletions RecursiveExtractor/Extractors/UdfExtractor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,13 @@ public UdfExtractor(Extractor context)
public async IAsyncEnumerable<FileEntry> ExtractAsync(FileEntry fileEntry, ExtractorOptions options, ResourceGovernor governor, bool topLevel = true)
{
DiscUtils.DiscFileInfo[]? entries = null;
Dictionary<string, FileEntryMetadata>? metadataByPath = null;
var failed = false;
try
{
using var cd = new UdfReader(fileEntry.Content);
entries = cd.Root.GetFiles("*.*", SearchOption.AllDirectories).ToArray();
metadataByPath = DiscCommon.CollectMetadata(cd, entries);
}
catch (Exception e)
{
Expand Down Expand Up @@ -69,6 +71,10 @@ public async IAsyncEnumerable<FileEntry> ExtractAsync(FileEntry fileEntry, Extra
{
var name = fileInfo.FullName.Replace('/', Path.DirectorySeparatorChar);
var newFileEntry = await FileEntry.FromStreamAsync(name, stream, fileEntry, fileInfo.CreationTime, fileInfo.LastWriteTime, fileInfo.LastAccessTime, memoryStreamCutoff: options.MemoryStreamCutoff).ConfigureAwait(false);
if (metadataByPath != null && metadataByPath.TryGetValue(fileInfo.FullName, out var entryMetadata))
{
newFileEntry.Metadata = entryMetadata;
}
if (options.Recurse || topLevel)
{
await foreach (var entry in Context.ExtractAsync(newFileEntry, options, governor, false))
Expand All @@ -92,11 +98,13 @@ public async IAsyncEnumerable<FileEntry> ExtractAsync(FileEntry fileEntry, Extra
public IEnumerable<FileEntry> Extract(FileEntry fileEntry, ExtractorOptions options, ResourceGovernor governor, bool topLevel = true)
{
DiscUtils.DiscFileInfo[]? entries = null;
Dictionary<string, FileEntryMetadata>? metadataByPath = null;
var failed = false;
try
{
using var cd = new UdfReader(fileEntry.Content);
entries = cd.Root.GetFiles("*.*", SearchOption.AllDirectories).ToArray();
metadataByPath = DiscCommon.CollectMetadata(cd, entries);
}
catch(Exception e)
{
Expand Down Expand Up @@ -130,6 +138,10 @@ public IEnumerable<FileEntry> Extract(FileEntry fileEntry, ExtractorOptions opti
{
var name = fileInfo.FullName.Replace('/', Path.DirectorySeparatorChar);
var newFileEntry = new FileEntry(name, stream, fileEntry, createTime: file.CreationTime, modifyTime: file.LastWriteTime, accessTime: file.LastAccessTime, memoryStreamCutoff: options.MemoryStreamCutoff);
if (metadataByPath != null && metadataByPath.TryGetValue(fileInfo.FullName, out var entryMetadata))
{
newFileEntry.Metadata = entryMetadata;
}
if (options.Recurse || topLevel)
{
foreach (var entry in Context.Extract(newFileEntry, options, governor, false))
Expand Down
2 changes: 2 additions & 0 deletions RecursiveExtractor/Extractors/WimExtractor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ public async IAsyncEnumerable<FileEntry> ExtractAsync(FileEntry fileEntry, Extra
{
var name = file.Replace('\\', Path.DirectorySeparatorChar);
var newFileEntry = await FileEntry.FromStreamAsync($"{image.FriendlyName}{Path.DirectorySeparatorChar}{name}", stream, fileEntry, memoryStreamCutoff: options.MemoryStreamCutoff).ConfigureAwait(false);
newFileEntry.Metadata = DiscCommon.TryGetFileMetadata(image, file);

if (options.Recurse || topLevel)
{
Expand Down Expand Up @@ -128,6 +129,7 @@ public IEnumerable<FileEntry> Extract(FileEntry fileEntry, ExtractorOptions opti
var name = file.Replace('\\', Path.DirectorySeparatorChar);

var newFileEntry = new FileEntry($"{image.FriendlyName}{Path.DirectorySeparatorChar}{name}", stream, fileEntry, memoryStreamCutoff: options.MemoryStreamCutoff);
newFileEntry.Metadata = DiscCommon.TryGetFileMetadata(image, file);
if (options.Recurse || topLevel)
{
foreach (var extractedFile in Context.Extract(newFileEntry, options, governor, false))
Expand Down
Loading