From 8f75a09b55f13a390b7de354bdfd594c7787f446 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 13 Feb 2026 21:26:20 +0000
Subject: [PATCH 1/6] Initial plan
From 558034a966957f8751c5f706e581e1ee862dc04a Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 13 Feb 2026 21:31:40 +0000
Subject: [PATCH 2/6] Initial plan for disc image metadata extraction
Co-authored-by: gfs <98900+gfs@users.noreply.github.com>
---
nuget.config | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/nuget.config b/nuget.config
index 227ad0ce..248a5bb5 100644
--- a/nuget.config
+++ b/nuget.config
@@ -2,6 +2,6 @@
-
+
\ No newline at end of file
From f71dea8a9f999da32aa315f709a0739f7ca1d18d Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 13 Feb 2026 21:41:09 +0000
Subject: [PATCH 3/6] Add file metadata extraction for DiscUtils-based disc
image formats
- Add TryGetFileMetadata() and CollectMetadata() helpers to DiscCommon for
extracting Unix permissions, UID, and GID from IUnixFileSystem-compatible
file systems (Ext, Xfs, Btrfs, HfsPlus, ISO 9660 with RockRidge)
- Apply metadata extraction in DiscCommon (VHD/VHDX/VMDK), IsoExtractor,
UdfExtractor, and WimExtractor
- Add TestDataRockRidge.iso test fixture with RockRidge extensions
- Add tests for ISO metadata extraction (both with and without RockRidge)
- Restore nuget.config to original configuration
Co-authored-by: gfs <98900+gfs@users.noreply.github.com>
---
.../ExtractorTests/ExpectedNumFilesTests.cs | 2 +
.../ExtractorTests/FileMetadataTests.cs | 65 ++++++++++++++++++
.../RecursiveExtractor.Tests.csproj | 3 +
.../TestDataArchives/TestDataRockRidge.iso | Bin 0 -> 364544 bytes
RecursiveExtractor/Extractors/DiscCommon.cs | 60 ++++++++++++++++
RecursiveExtractor/Extractors/IsoExtractor.cs | 12 ++++
RecursiveExtractor/Extractors/UdfExtractor.cs | 12 ++++
RecursiveExtractor/Extractors/WimExtractor.cs | 2 +
nuget.config | 2 +-
9 files changed, 157 insertions(+), 1 deletion(-)
create mode 100644 RecursiveExtractor.Tests/TestData/TestDataArchives/TestDataRockRidge.iso
diff --git a/RecursiveExtractor.Tests/ExtractorTests/ExpectedNumFilesTests.cs b/RecursiveExtractor.Tests/ExtractorTests/ExpectedNumFilesTests.cs
index 92dd3c24..156fdbf1 100644
--- a/RecursiveExtractor.Tests/ExtractorTests/ExpectedNumFilesTests.cs
+++ b/RecursiveExtractor.Tests/ExtractorTests/ExpectedNumFilesTests.cs
@@ -38,6 +38,7 @@ public static TheoryData 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 },
@@ -79,6 +80,7 @@ public static TheoryData NoRecursionData
{ "TestData.a", 3 },
{ "TestData.bsd.ar", 3 },
{ "TestData.iso", 3 },
+ { "TestDataRockRidge.iso", 2 },
{ "TestData.vhdx", 3 },
{ "EmptyFile.txt", 1 },
{ "TestDataArchivesNested.zip", 14 },
diff --git a/RecursiveExtractor.Tests/ExtractorTests/FileMetadataTests.cs b/RecursiveExtractor.Tests/ExtractorTests/FileMetadataTests.cs
index 1700217f..8f5e2297 100644
--- a/RecursiveExtractor.Tests/ExtractorTests/FileMetadataTests.cs
+++ b/RecursiveExtractor.Tests/ExtractorTests/FileMetadataTests.cs
@@ -140,4 +140,69 @@ 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);
+ }
+ }
}
diff --git a/RecursiveExtractor.Tests/RecursiveExtractor.Tests.csproj b/RecursiveExtractor.Tests/RecursiveExtractor.Tests.csproj
index 248021b2..e529dedb 100644
--- a/RecursiveExtractor.Tests/RecursiveExtractor.Tests.csproj
+++ b/RecursiveExtractor.Tests/RecursiveExtractor.Tests.csproj
@@ -165,6 +165,9 @@
PreserveNewest
+
+ PreserveNewest
+
PreserveNewest
diff --git a/RecursiveExtractor.Tests/TestData/TestDataArchives/TestDataRockRidge.iso b/RecursiveExtractor.Tests/TestData/TestDataArchives/TestDataRockRidge.iso
new file mode 100644
index 0000000000000000000000000000000000000000..f7a9291b3d5b9c66eee576bedd1c50296a6b7414
GIT binary patch
literal 364544
zcmeI)QEwDQ902g$60t$Sh#?^OFfqo+gU7X)BJt_i+qMgLx0l^5DNjbFL=q_poW>_3
ziHZ6>_ym3czYh9y8qPY2u78j#a?denR>|J)EDZU?_Y>%9b7MsFi^uxl@6CW-_3r)qQevn?M
zz*;)W$~+tP*3wWH!o`)9?z#2VGOT8Uw7gbU=`bWk+N<&+e3YCGOBXMm4{7IGzA;+s
z_Xg?w;DwKuyWN#=xie0CMVXJzUG9|0dN!!vm21;$n$yiS7ef6;ypmO+N_+qGc03rB
z>&qh0{!jBr;6EVn=PdsJ2fCsQ1PBly
zK!5-N0t5&UAV7e?0SUy@Ire%sJvuy{hmWIj<5E8>4yZY|B|v}x0RjXF5FkK+009C7
zUVuRJ&A*-ksI$XO=~NW^{@*}I1PBlyK!5-N0t5&UAVA<%68PnrYwXJL>+$b@L`Csh
zd~dw@LHw}SjKBBrP|I-W!L)Gb!91^4Pshh*3-un&lJvd!-L}>&i=v(O`Ob|e_v7sD
zTEBR=1Gm}^+^SarHfdqr@I2q^z}a@e;BFRux4YKx$twyE&n7>bwtgKA--x5T-+p=h
z=AFNf``OJd%-1Q-hW}G^y6x)e<86L?cA|b(PrSs(s|
zfYDQ;``$|az3Be$KVC#XMrYbVojKa(M<0!|Z;t9XoAw`#vTw!Fb{%E6uFqra{Wiwl
z7i0a65FkK+z^f~e7CZ5g>gPKCX5-K6?OB$3)p{C=Jh>8ztlw&W
zJjn-5;cC`T%TR8N$9YkO)p~=Mab9Md)16Wj8f4{qT1+dK<21=uv!qvL`6!h6MvWtC^-Z^R}{4{cJeBZRgc}8I6np0RjXFJeNQ-!yK9^=J3_+?a9{m
z
+ /// Tries to extract file metadata from a DiscUtils file system entry.
+ /// For file systems implementing (Ext, Xfs, Btrfs, HfsPlus),
+ /// returns permissions, UID, and GID. Returns null for unsupported file systems.
+ ///
+ /// The opened disc file system
+ /// Path of the file within the file system
+ /// Populated or null when not available
+ internal static FileEntryMetadata? TryGetFileMetadata(DiscFileSystem fs, string filePath)
+ {
+ if (fs is not IUnixFileSystem unixFs)
+ {
+ return null;
+ }
+
+ try
+ {
+ var info = unixFs.GetUnixFileInfo(filePath);
+ return 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);
+ return null;
+ }
+ }
+
+ ///
+ /// 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.
+ ///
+ /// The opened disc file system
+ /// The file entries to collect metadata for
+ /// A dictionary mapping file paths to metadata, or null if the file system does not support metadata
+ internal static Dictionary? CollectMetadata(DiscFileSystem fs, DiscFileInfo[] fileInfos)
+ {
+ if (fs is not IUnixFileSystem)
+ {
+ return null;
+ }
+
+ var result = new Dictionary();
+ foreach (var fi in fileInfos)
+ {
+ var metadata = TryGetFileMetadata(fs, fi.FullName);
+ if (metadata != null)
+ {
+ result[fi.FullName] = metadata;
+ }
+ }
+ return result;
+ }
+
///
/// Dump the FileEntries from a Logical Volume asynchronously
///
@@ -59,6 +117,7 @@ public static async IAsyncEnumerable 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))
@@ -124,6 +183,7 @@ public static IEnumerable 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))
diff --git a/RecursiveExtractor/Extractors/IsoExtractor.cs b/RecursiveExtractor/Extractors/IsoExtractor.cs
index 3756b319..da94c098 100644
--- a/RecursiveExtractor/Extractors/IsoExtractor.cs
+++ b/RecursiveExtractor/Extractors/IsoExtractor.cs
@@ -31,11 +31,13 @@ public IsoExtractor(Extractor context)
public async IAsyncEnumerable ExtractAsync(FileEntry fileEntry, ExtractorOptions options, ResourceGovernor governor, bool topLevel = true)
{
DiscUtils.DiscFileInfo[]? entries = null;
+ Dictionary? 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)
{
@@ -69,6 +71,10 @@ public async IAsyncEnumerable 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))
@@ -92,11 +98,13 @@ public async IAsyncEnumerable ExtractAsync(FileEntry fileEntry, Extra
public IEnumerable Extract(FileEntry fileEntry, ExtractorOptions options, ResourceGovernor governor, bool topLevel = true)
{
DiscUtils.DiscFileInfo[]? entries = null;
+ Dictionary? 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)
{
@@ -130,6 +138,10 @@ public IEnumerable 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))
diff --git a/RecursiveExtractor/Extractors/UdfExtractor.cs b/RecursiveExtractor/Extractors/UdfExtractor.cs
index efc016c8..27d1bb53 100644
--- a/RecursiveExtractor/Extractors/UdfExtractor.cs
+++ b/RecursiveExtractor/Extractors/UdfExtractor.cs
@@ -31,11 +31,13 @@ public UdfExtractor(Extractor context)
public async IAsyncEnumerable ExtractAsync(FileEntry fileEntry, ExtractorOptions options, ResourceGovernor governor, bool topLevel = true)
{
DiscUtils.DiscFileInfo[]? entries = null;
+ Dictionary? 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)
{
@@ -69,6 +71,10 @@ public async IAsyncEnumerable 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))
@@ -92,11 +98,13 @@ public async IAsyncEnumerable ExtractAsync(FileEntry fileEntry, Extra
public IEnumerable Extract(FileEntry fileEntry, ExtractorOptions options, ResourceGovernor governor, bool topLevel = true)
{
DiscUtils.DiscFileInfo[]? entries = null;
+ Dictionary? 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)
{
@@ -130,6 +138,10 @@ public IEnumerable 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))
diff --git a/RecursiveExtractor/Extractors/WimExtractor.cs b/RecursiveExtractor/Extractors/WimExtractor.cs
index fe7fd3df..4cf9d516 100644
--- a/RecursiveExtractor/Extractors/WimExtractor.cs
+++ b/RecursiveExtractor/Extractors/WimExtractor.cs
@@ -58,6 +58,7 @@ public async IAsyncEnumerable 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)
{
@@ -128,6 +129,7 @@ public IEnumerable 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))
diff --git a/nuget.config b/nuget.config
index 248a5bb5..227ad0ce 100644
--- a/nuget.config
+++ b/nuget.config
@@ -2,6 +2,6 @@
-
+
\ No newline at end of file
From 649794aa708e00b2888582e26f4e42882e44e905 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sat, 14 Feb 2026 05:04:54 +0000
Subject: [PATCH 4/6] Plan: Add Windows filesystem metadata support for disc
image formats
Co-authored-by: gfs <98900+gfs@users.noreply.github.com>
---
nuget.config | 2 +-
nuget.config.original | 7 +++++++
2 files changed, 8 insertions(+), 1 deletion(-)
create mode 100644 nuget.config.original
diff --git a/nuget.config b/nuget.config
index 227ad0ce..c25d0d6c 100644
--- a/nuget.config
+++ b/nuget.config
@@ -2,6 +2,6 @@
-
+
\ No newline at end of file
diff --git a/nuget.config.original b/nuget.config.original
new file mode 100644
index 00000000..227ad0ce
--- /dev/null
+++ b/nuget.config.original
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
From 4cd536e8c691b22e8a11258eeb2b9a7db1d4b672 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sat, 14 Feb 2026 05:08:02 +0000
Subject: [PATCH 5/6] Add Windows filesystem metadata (NTFS/FAT/WIM) to disc
image extraction
- Add FileAttributes and SecurityDescriptorSddl properties to FileEntryMetadata
- Update DiscCommon.TryGetFileMetadata() to handle IDosFileSystem (file attributes)
and IWindowsFileSystem (SDDL security descriptors) in addition to IUnixFileSystem
- Update DiscCommon.CollectMetadata() to recognize Windows file systems
- Add VHDX/NTFS metadata tests verifying FileAttributes and SecurityDescriptorSddl
- Restore nuget.config to original configuration
Co-authored-by: gfs <98900+gfs@users.noreply.github.com>
---
.../ExtractorTests/FileMetadataTests.cs | 43 ++++++++++++
RecursiveExtractor/Extractors/DiscCommon.cs | 66 +++++++++++++++----
RecursiveExtractor/FileEntryMetadata.cs | 16 +++++
nuget.config | 6 +-
nuget.config.original | 7 --
5 files changed, 114 insertions(+), 24 deletions(-)
delete mode 100644 nuget.config.original
diff --git a/RecursiveExtractor.Tests/ExtractorTests/FileMetadataTests.cs b/RecursiveExtractor.Tests/ExtractorTests/FileMetadataTests.cs
index 8f5e2297..bef2a127 100644
--- a/RecursiveExtractor.Tests/ExtractorTests/FileMetadataTests.cs
+++ b/RecursiveExtractor.Tests/ExtractorTests/FileMetadataTests.cs
@@ -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]
@@ -205,4 +207,45 @@ public void IsoRockRidgeEntries_HaveMetadata_Sync()
Assert.NotNull(entry.Metadata.Gid);
}
}
+
+ [Fact]
+ public async Task VhdxNtfsEntries_HaveWindowsMetadata()
+ {
+ // TestData.vhdx contains an NTFS file system which 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);
+ }
+ }
}
diff --git a/RecursiveExtractor/Extractors/DiscCommon.cs b/RecursiveExtractor/Extractors/DiscCommon.cs
index 0b0269cd..7ffed751 100644
--- a/RecursiveExtractor/Extractors/DiscCommon.cs
+++ b/RecursiveExtractor/Extractors/DiscCommon.cs
@@ -17,33 +17,71 @@ public static class DiscCommon
///
/// Tries to extract file metadata from a DiscUtils file system entry.
/// For file systems implementing (Ext, Xfs, Btrfs, HfsPlus),
- /// returns permissions, UID, and GID. Returns null for unsupported file systems.
+ /// returns permissions, UID, and GID.
+ /// For file systems implementing (NTFS, FAT, WIM),
+ /// returns Windows file attributes.
+ /// For file systems implementing (NTFS, WIM),
+ /// also returns the security descriptor in SDDL format.
+ /// Returns null for file systems that support none of these interfaces.
///
/// The opened disc file system
/// Path of the file within the file system
/// Populated or null when not available
internal static FileEntryMetadata? TryGetFileMetadata(DiscFileSystem fs, string filePath)
{
- if (fs is not IUnixFileSystem unixFs)
+ FileEntryMetadata? metadata = null;
+
+ if (fs is IUnixFileSystem unixFs)
{
- return null;
+ 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);
+ }
}
- try
+ if (fs is IDosFileSystem dosFs)
{
- var info = unixFs.GetUnixFileInfo(filePath);
- return new FileEntryMetadata
+ try
{
- Mode = (long)info.Permissions,
- Uid = info.UserId,
- Gid = info.GroupId
- };
+ 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);
+ }
}
- catch (Exception e)
+
+ if (fs is IWindowsFileSystem windowsFs)
{
- Logger.Debug(e, "Could not retrieve Unix metadata for {0}", filePath);
- return null;
+ 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;
}
///
@@ -55,7 +93,7 @@ public static class DiscCommon
/// A dictionary mapping file paths to metadata, or null if the file system does not support metadata
internal static Dictionary? CollectMetadata(DiscFileSystem fs, DiscFileInfo[] fileInfos)
{
- if (fs is not IUnixFileSystem)
+ if (fs is not IUnixFileSystem && fs is not IDosFileSystem && fs is not IWindowsFileSystem)
{
return null;
}
diff --git a/RecursiveExtractor/FileEntryMetadata.cs b/RecursiveExtractor/FileEntryMetadata.cs
index 4254c442..f7ae5606 100644
--- a/RecursiveExtractor/FileEntryMetadata.cs
+++ b/RecursiveExtractor/FileEntryMetadata.cs
@@ -1,5 +1,7 @@
// Copyright (c) Microsoft Corporation. Licensed under the MIT License.
+using System.IO;
+
namespace Microsoft.CST.RecursiveExtractor
{
///
@@ -43,5 +45,19 @@ public class FileEntryMetadata
/// Null if not available from the archive format.
///
public long? Gid { get; set; }
+
+ ///
+ /// The Windows file attributes (e.g., ReadOnly, Hidden, System, Archive).
+ /// Available for NTFS, FAT, and WIM file systems.
+ /// Null if not available from the archive format.
+ ///
+ public FileAttributes? FileAttributes { get; set; }
+
+ ///
+ /// The NTFS security descriptor in SDDL (Security Descriptor Definition Language) format.
+ /// Available for NTFS and WIM file systems that implement IWindowsFileSystem.
+ /// Null if not available from the archive format.
+ ///
+ public string? SecurityDescriptorSddl { get; set; }
}
}
diff --git a/nuget.config b/nuget.config
index c25d0d6c..ba47b6aa 100644
--- a/nuget.config
+++ b/nuget.config
@@ -1,7 +1,7 @@
-
+
-
+
-
\ No newline at end of file
+
diff --git a/nuget.config.original b/nuget.config.original
deleted file mode 100644
index 227ad0ce..00000000
--- a/nuget.config.original
+++ /dev/null
@@ -1,7 +0,0 @@
-
-
-
-
-
-
-
\ No newline at end of file
From 7913f8dbd034703e9dac6654ac945aaaa54fa8cf Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sat, 14 Feb 2026 05:12:35 +0000
Subject: [PATCH 6/6] Address review feedback: improve test comment clarity
Co-authored-by: gfs <98900+gfs@users.noreply.github.com>
---
RecursiveExtractor.Tests/ExtractorTests/FileMetadataTests.cs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/RecursiveExtractor.Tests/ExtractorTests/FileMetadataTests.cs b/RecursiveExtractor.Tests/ExtractorTests/FileMetadataTests.cs
index bef2a127..5ffa462c 100644
--- a/RecursiveExtractor.Tests/ExtractorTests/FileMetadataTests.cs
+++ b/RecursiveExtractor.Tests/ExtractorTests/FileMetadataTests.cs
@@ -211,7 +211,7 @@ public void IsoRockRidgeEntries_HaveMetadata_Sync()
[Fact]
public async Task VhdxNtfsEntries_HaveWindowsMetadata()
{
- // TestData.vhdx contains an NTFS file system which implements IDosFileSystem and IWindowsFileSystem
+ // 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();