Skip to content

Commit b4552e8

Browse files
gfsCopilot
andcommitted
Complete disc image metadata support: fix ISO tests, avoid per-file exceptions
The two IsoEntries_MetadataIsNull* tests were written when only IUnixFileSystem was handled and were never updated when IDosFileSystem support was added, so they asserted null metadata for plain ISOs that now report FileAttributes. They failed and left the PR incomplete. - Fix the ISO tests to assert the real behavior and tighten the RockRidge and NTFS assertions to check concrete values instead of just non-null. - Add UDF and WIM metadata tests, covering the previously untested WimExtractor and UdfExtractor paths. - Skip the Unix branch for non-RockRidge ISO images. CDReader implements IUnixFileSystem unconditionally but throws for every file when the active variant is not RockRidge, which meant one thrown/caught exception per file. - Normalize an empty security descriptor to null so SecurityDescriptorSddl is either absent or meaningful (WIM images report empty SDDL). - Clarify the XML docs per review feedback and document FileEntryMetadata and the per-format support matrix in the README. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 64923784-69ff-4396-9d02-8320536ce3e2
1 parent 5225358 commit b4552e8

4 files changed

Lines changed: 182 additions & 34 deletions

File tree

README.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,47 @@ public string? ParentPath { get; }
127127
public DateTime CreateTime { get; }
128128
public DateTime ModifyTime { get; }
129129
public DateTime AccessTime { get; }
130+
public FileEntryMetadata? Metadata { get; set; }
131+
```
132+
</details>
133+
134+
<details>
135+
<summary>File Metadata</summary>
136+
<br/>
137+
When the source archive or disc image records it, `FileEntry.Metadata` holds additional attributes for the entry. Every property is nullable, and `Metadata` itself is `null` when the format carries no metadata at all, so you can distinguish "not recorded" from a real value.
138+
139+
```csharp
140+
public long? Mode { get; set; } // Unix permission bits
141+
public bool? IsExecutable { get; } // Derived from Mode
142+
public bool? IsSetUid { get; } // Derived from Mode
143+
public bool? IsSetGid { get; } // Derived from Mode
144+
public long? Uid { get; set; } // Unix owner id
145+
public long? Gid { get; set; } // Unix group id
146+
public FileAttributes? FileAttributes { get; set; } // Windows/DOS file attributes
147+
public string? SecurityDescriptorSddl { get; set; } // Windows security descriptor, SDDL form
148+
```
149+
150+
Which properties are populated depends on the format:
151+
152+
| Source | Populated |
153+
| --- | --- |
154+
| TAR, AR/DEB | `Mode`, `Uid`, `Gid` |
155+
| Ext, XFS, Btrfs, HFS+ (inside VHD/VHDX/VMDK/DMG) | `Mode`, `Uid`, `Gid` |
156+
| ISO 9660 with RockRidge extensions | `Mode`, `Uid`, `Gid`, `FileAttributes` |
157+
| ISO 9660 without RockRidge extensions | `FileAttributes` |
158+
| FAT (inside a disc image) | `FileAttributes` |
159+
| NTFS (inside a disc image) | `FileAttributes`, `SecurityDescriptorSddl` |
160+
| WIM | `FileAttributes`, and `SecurityDescriptorSddl` when the image records one |
161+
| UDF | None; `Metadata` is `null` |
162+
163+
```csharp
164+
foreach (var file in extractor.Extract("path/to/image.vhdx"))
165+
{
166+
if (file.Metadata?.SecurityDescriptorSddl is { } sddl)
167+
{
168+
Console.WriteLine($"{file.FullPath}: {sddl}");
169+
}
170+
}
130171
```
131172
</details>
132173

RecursiveExtractor.Tests/ExtractorTests/FileMetadataTests.cs

Lines changed: 116 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1-
// Copyright (c) Microsoft Corporation. Licensed under the MIT License.
1+
// Copyright (c) Microsoft Corporation. Licensed under the MIT License.
22

33
using Microsoft.CST.RecursiveExtractor;
4+
using System.Collections.Generic;
45
using System.IO;
56
using System.Linq;
7+
using System.Runtime.InteropServices;
68
using System.Threading.Tasks;
79
using Xunit;
810

@@ -144,23 +146,28 @@ public void FileEntry_MetadataDefaultsToNull()
144146
}
145147

146148
[Fact]
147-
public async Task IsoEntries_MetadataIsNullWithoutRockRidge()
149+
public async Task IsoEntries_HaveDosAttributesButNoUnixMetadataWithoutRockRidge()
148150
{
149-
// TestData.iso does not have RockRidge extensions, so Unix metadata is not available
151+
// TestData.iso has no RockRidge extensions, so Unix metadata is unavailable.
152+
// CDReader still implements IDosFileSystem, so file attributes are reported.
150153
var extractor = new Extractor();
151154
var path = Path.Combine(Directory.GetCurrentDirectory(), "TestData", "TestDataArchives", "TestData.iso");
152155
var results = await extractor.ExtractAsync(path, new ExtractorOptions() { Recurse = false }).ToListAsync();
153156

154157
Assert.NotEmpty(results);
155158
foreach (var entry in results)
156159
{
157-
// Without RockRidge extensions, metadata should be null
158-
Assert.Null(entry.Metadata);
160+
Assert.NotNull(entry.Metadata);
161+
Assert.Equal(FileAttributes.ReadOnly, entry.Metadata!.FileAttributes);
162+
Assert.Null(entry.Metadata.Mode);
163+
Assert.Null(entry.Metadata.Uid);
164+
Assert.Null(entry.Metadata.Gid);
165+
Assert.Null(entry.Metadata.SecurityDescriptorSddl);
159166
}
160167
}
161168

162169
[Fact]
163-
public void IsoEntries_MetadataIsNullWithoutRockRidge_Sync()
170+
public void IsoEntries_HaveDosAttributesButNoUnixMetadataWithoutRockRidge_Sync()
164171
{
165172
var extractor = new Extractor();
166173
var path = Path.Combine(Directory.GetCurrentDirectory(), "TestData", "TestDataArchives", "TestData.iso");
@@ -169,7 +176,12 @@ public void IsoEntries_MetadataIsNullWithoutRockRidge_Sync()
169176
Assert.NotEmpty(results);
170177
foreach (var entry in results)
171178
{
172-
Assert.Null(entry.Metadata);
179+
Assert.NotNull(entry.Metadata);
180+
Assert.Equal(FileAttributes.ReadOnly, entry.Metadata!.FileAttributes);
181+
Assert.Null(entry.Metadata.Mode);
182+
Assert.Null(entry.Metadata.Uid);
183+
Assert.Null(entry.Metadata.Gid);
184+
Assert.Null(entry.Metadata.SecurityDescriptorSddl);
173185
}
174186
}
175187

@@ -181,30 +193,67 @@ public async Task IsoRockRidgeEntries_HaveMetadata()
181193
var path = Path.Combine(Directory.GetCurrentDirectory(), "TestData", "TestDataArchives", "TestDataRockRidge.iso");
182194
var results = await extractor.ExtractAsync(path, new ExtractorOptions() { Recurse = false }).ToListAsync();
183195

184-
Assert.NotEmpty(results);
196+
AssertRockRidgeMetadata(results);
197+
}
198+
199+
[Fact]
200+
public void IsoRockRidgeEntries_HaveMetadata_Sync()
201+
{
202+
var extractor = new Extractor();
203+
var path = Path.Combine(Directory.GetCurrentDirectory(), "TestData", "TestDataArchives", "TestDataRockRidge.iso");
204+
var results = extractor.Extract(path, new ExtractorOptions() { Recurse = false }).ToList();
205+
206+
AssertRockRidgeMetadata(results);
207+
}
208+
209+
private static void AssertRockRidgeMetadata(IList<FileEntry> results)
210+
{
211+
Assert.Equal(2, results.Count);
185212
foreach (var entry in results)
186213
{
187214
Assert.NotNull(entry.Metadata);
188-
Assert.NotNull(entry.Metadata!.Mode);
189-
Assert.NotNull(entry.Metadata.Uid);
190-
Assert.NotNull(entry.Metadata.Gid);
215+
Assert.Equal(1001, entry.Metadata!.Uid);
216+
Assert.Equal(1001, entry.Metadata.Gid);
217+
Assert.NotNull(entry.Metadata.FileAttributes);
191218
}
219+
220+
// testfile.txt is 0755 (493 decimal), subdir/nested.txt is 0644 (420 decimal)
221+
var topLevel = results.Single(x => x.Name == "testfile.txt");
222+
Assert.Equal(493, topLevel.Metadata!.Mode);
223+
Assert.True(topLevel.Metadata.IsExecutable);
224+
225+
var nested = results.Single(x => x.Name == "nested.txt");
226+
Assert.Equal(420, nested.Metadata!.Mode);
227+
Assert.False(nested.Metadata.IsExecutable);
192228
}
193229

194230
[Fact]
195-
public void IsoRockRidgeEntries_HaveMetadata_Sync()
231+
public async Task UdfEntries_HaveNoMetadata()
196232
{
233+
// UdfReader implements none of the Unix/DOS/Windows file system interfaces,
234+
// so no metadata is available for pure UDF images.
197235
var extractor = new Extractor();
198-
var path = Path.Combine(Directory.GetCurrentDirectory(), "TestData", "TestDataArchives", "TestDataRockRidge.iso");
236+
var path = Path.Combine(Directory.GetCurrentDirectory(), "TestData", "TestDataArchives", "UdfTest.iso");
237+
var results = await extractor.ExtractAsync(path, new ExtractorOptions() { Recurse = false }).ToListAsync();
238+
239+
Assert.NotEmpty(results);
240+
foreach (var entry in results)
241+
{
242+
Assert.Null(entry.Metadata);
243+
}
244+
}
245+
246+
[Fact]
247+
public void UdfEntries_HaveNoMetadata_Sync()
248+
{
249+
var extractor = new Extractor();
250+
var path = Path.Combine(Directory.GetCurrentDirectory(), "TestData", "TestDataArchives", "UdfTest.iso");
199251
var results = extractor.Extract(path, new ExtractorOptions() { Recurse = false }).ToList();
200252

201253
Assert.NotEmpty(results);
202254
foreach (var entry in results)
203255
{
204-
Assert.NotNull(entry.Metadata);
205-
Assert.NotNull(entry.Metadata!.Mode);
206-
Assert.NotNull(entry.Metadata.Uid);
207-
Assert.NotNull(entry.Metadata.Gid);
256+
Assert.Null(entry.Metadata);
208257
}
209258
}
210259

@@ -216,14 +265,30 @@ public async Task VhdxNtfsEntries_HaveWindowsMetadata()
216265
var path = Path.Combine(Directory.GetCurrentDirectory(), "TestData", "TestDataArchives", "TestData.vhdx");
217266
var results = await extractor.ExtractAsync(path, new ExtractorOptions() { Recurse = false }).ToListAsync();
218267

268+
AssertNtfsMetadata(results);
269+
}
270+
271+
[Fact]
272+
public void VhdxNtfsEntries_HaveWindowsMetadata_Sync()
273+
{
274+
var extractor = new Extractor();
275+
var path = Path.Combine(Directory.GetCurrentDirectory(), "TestData", "TestDataArchives", "TestData.vhdx");
276+
var results = extractor.Extract(path, new ExtractorOptions() { Recurse = false }).ToList();
277+
278+
AssertNtfsMetadata(results);
279+
}
280+
281+
private static void AssertNtfsMetadata(IList<FileEntry> results)
282+
{
219283
Assert.NotEmpty(results);
220284
foreach (var entry in results)
221285
{
222286
Assert.NotNull(entry.Metadata);
223287
// NTFS provides Windows file attributes
224-
Assert.NotNull(entry.Metadata!.FileAttributes);
288+
Assert.Equal(FileAttributes.Archive, entry.Metadata!.FileAttributes);
225289
// NTFS provides security descriptors
226290
Assert.NotNull(entry.Metadata.SecurityDescriptorSddl);
291+
Assert.Contains("O:", entry.Metadata.SecurityDescriptorSddl); // Owner present
227292
Assert.Contains("D:", entry.Metadata.SecurityDescriptorSddl); // DACL present
228293
// NTFS does not provide Unix metadata
229294
Assert.Null(entry.Metadata.Mode);
@@ -233,19 +298,48 @@ public async Task VhdxNtfsEntries_HaveWindowsMetadata()
233298
}
234299

235300
[Fact]
236-
public void VhdxNtfsEntries_HaveWindowsMetadata_Sync()
301+
public async Task WimEntries_HaveWindowsFileAttributes()
237302
{
303+
// WIM extraction is only supported on Windows
304+
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
305+
{
306+
return;
307+
}
308+
238309
var extractor = new Extractor();
239-
var path = Path.Combine(Directory.GetCurrentDirectory(), "TestData", "TestDataArchives", "TestData.vhdx");
310+
var path = Path.Combine(Directory.GetCurrentDirectory(), "TestData", "TestDataArchives", "TestData.wim");
311+
var results = await extractor.ExtractAsync(path, new ExtractorOptions() { Recurse = false }).ToListAsync();
312+
313+
AssertWimMetadata(results);
314+
}
315+
316+
[Fact]
317+
public void WimEntries_HaveWindowsFileAttributes_Sync()
318+
{
319+
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
320+
{
321+
return;
322+
}
323+
324+
var extractor = new Extractor();
325+
var path = Path.Combine(Directory.GetCurrentDirectory(), "TestData", "TestDataArchives", "TestData.wim");
240326
var results = extractor.Extract(path, new ExtractorOptions() { Recurse = false }).ToList();
241327

328+
AssertWimMetadata(results);
329+
}
330+
331+
private static void AssertWimMetadata(IList<FileEntry> results)
332+
{
242333
Assert.NotEmpty(results);
243334
foreach (var entry in results)
244335
{
245336
Assert.NotNull(entry.Metadata);
246-
Assert.NotNull(entry.Metadata!.FileAttributes);
247-
Assert.NotNull(entry.Metadata.SecurityDescriptorSddl);
337+
Assert.Equal(FileAttributes.Archive, entry.Metadata!.FileAttributes);
248338
Assert.Null(entry.Metadata.Mode);
339+
Assert.Null(entry.Metadata.Uid);
340+
Assert.Null(entry.Metadata.Gid);
341+
// This WIM records no security descriptors, so an empty SDDL is normalized to null
342+
Assert.Null(entry.Metadata.SecurityDescriptorSddl);
249343
}
250344
}
251345
}

RecursiveExtractor/Extractors/DiscCommon.cs

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using DiscUtils;
2+
using DiscUtils.Iso9660;
23
using System;
34
using System.Collections.Generic;
45
using System.Collections.ObjectModel;
@@ -16,12 +17,14 @@ public static class DiscCommon
1617

1718
/// <summary>
1819
/// Tries to extract file metadata from a DiscUtils file system entry.
19-
/// For file systems implementing <see cref="IUnixFileSystem"/> (Ext, Xfs, Btrfs, HfsPlus),
20+
/// For file systems implementing <see cref="IUnixFileSystem"/> (such as Ext, Xfs, Btrfs, HfsPlus,
21+
/// and ISO 9660 images via <c>CDReader</c> when RockRidge extensions are present),
2022
/// returns permissions, UID, and GID.
21-
/// For file systems implementing <see cref="IDosFileSystem"/> (NTFS, FAT, WIM),
23+
/// For file systems implementing <see cref="IDosFileSystem"/> (such as NTFS, FAT, WIM, and ISO 9660),
2224
/// returns Windows file attributes.
23-
/// For file systems implementing <see cref="IWindowsFileSystem"/> (NTFS, WIM),
24-
/// also returns the security descriptor in SDDL format.
25+
/// For file systems implementing <see cref="IWindowsFileSystem"/> (such as NTFS and WIM),
26+
/// also returns the security descriptor in SDDL format when the file system provides one.
27+
/// The lists above are not exhaustive; support is determined by the interfaces the file system implements.
2528
/// Returns null for file systems that support none of these interfaces.
2629
/// </summary>
2730
/// <param name="fs">The opened disc file system</param>
@@ -31,7 +34,7 @@ public static class DiscCommon
3134
{
3235
FileEntryMetadata? metadata = null;
3336

34-
if (fs is IUnixFileSystem unixFs)
37+
if (fs is IUnixFileSystem unixFs && SupportsUnixMetadata(fs))
3538
{
3639
try
3740
{
@@ -68,11 +71,12 @@ public static class DiscCommon
6871
try
6972
{
7073
var securityDescriptor = windowsFs.GetSecurity(filePath);
71-
if (securityDescriptor != null)
74+
var sddl = securityDescriptor?.GetSddlForm(
75+
DiscUtils.Core.WindowsSecurity.AccessControl.AccessControlSections.All);
76+
if (!string.IsNullOrEmpty(sddl))
7277
{
7378
metadata ??= new FileEntryMetadata();
74-
metadata.SecurityDescriptorSddl = securityDescriptor.GetSddlForm(
75-
DiscUtils.Core.WindowsSecurity.AccessControl.AccessControlSections.All);
79+
metadata.SecurityDescriptorSddl = sddl;
7680
}
7781
}
7882
catch (Exception e)
@@ -84,6 +88,15 @@ public static class DiscCommon
8488
return metadata;
8589
}
8690

91+
/// <summary>
92+
/// Determines whether a file system that implements <see cref="IUnixFileSystem"/> can actually
93+
/// return Unix metadata. <c>CDReader</c> implements the interface unconditionally but only exposes
94+
/// Unix information when the active ISO 9660 variant is RockRidge, and throws for every other
95+
/// variant. Checking up front avoids throwing and catching an exception for every file in an image.
96+
/// </summary>
97+
private static bool SupportsUnixMetadata(DiscFileSystem fs)
98+
=> fs is not CDReader cdReader || cdReader.ActiveVariant == Iso9660Variant.RockRidge;
99+
87100
/// <summary>
88101
/// Pre-collects metadata for all files while the file system is still open.
89102
/// Used by extractors (e.g., ISO) where the file system is disposed before files are processed.

RecursiveExtractor/FileEntryMetadata.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,15 +48,15 @@ public class FileEntryMetadata
4848

4949
/// <summary>
5050
/// The Windows file attributes (e.g., ReadOnly, Hidden, System, Archive).
51-
/// Available for NTFS, FAT, and WIM file systems.
51+
/// Available for disc image file systems that expose DOS attributes, such as NTFS, FAT, WIM, and ISO 9660.
5252
/// Null if not available from the archive format.
5353
/// </summary>
5454
public FileAttributes? FileAttributes { get; set; }
5555

5656
/// <summary>
57-
/// The NTFS security descriptor in SDDL (Security Descriptor Definition Language) format.
58-
/// Available for NTFS and WIM file systems that implement <c>IWindowsFileSystem</c>.
59-
/// Null if not available from the archive format.
57+
/// The Windows security descriptor in SDDL (Security Descriptor Definition Language) format.
58+
/// Available for disc image file systems that expose Windows security descriptors, such as NTFS and WIM.
59+
/// Null if not available from the archive format, or if the file system did not record one for this file.
6060
/// </summary>
6161
public string? SecurityDescriptorSddl { get; set; }
6262
}

0 commit comments

Comments
 (0)