From c7ba9a83e6f3d05a1dd366c4483edb6b29b1a6d1 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 12 Feb 2026 05:48:21 +0000
Subject: [PATCH 1/5] Initial plan
From ce9b952636346aa620136ae576c3358fb1c3c78a Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 12 Feb 2026 05:51:48 +0000
Subject: [PATCH 2/5] Initial plan for adding FileMetadata to FileEntry
Co-authored-by: gfs <98900+gfs@users.noreply.github.com>
---
nuget.config | 2 +-
nuget.config.bak | 7 +++++++
2 files changed, 8 insertions(+), 1 deletion(-)
create mode 100644 nuget.config.bak
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
diff --git a/nuget.config.bak b/nuget.config.bak
new file mode 100644
index 00000000..227ad0ce
--- /dev/null
+++ b/nuget.config.bak
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
From 0caf31e1f57f8a60407196c4fc9747a6d6a0cb21 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 12 Feb 2026 06:01:01 +0000
Subject: [PATCH 3/5] Add FileEntryMetadata class and populate metadata in Tar,
Zip, Rar, 7Zip, and Ar extractors
Co-authored-by: gfs <98900+gfs@users.noreply.github.com>
---
.../ExtractorTests/FileMetadataTests.cs | 143 ++++++++++++++++++
RecursiveExtractor/ArFile.cs | 90 +++++++++--
RecursiveExtractor/Extractors/RarExtractor.cs | 16 ++
.../Extractors/SevenZipExtractor.cs | 17 +++
RecursiveExtractor/Extractors/TarExtractor.cs | 10 +-
RecursiveExtractor/Extractors/ZipExtractor.cs | 28 ++++
RecursiveExtractor/FileEntry.cs | 6 +
RecursiveExtractor/FileEntryMetadata.cs | 47 ++++++
nuget.config | 2 +-
nuget.config.bak | 7 -
10 files changed, 346 insertions(+), 20 deletions(-)
create mode 100644 RecursiveExtractor.Tests/ExtractorTests/FileMetadataTests.cs
create mode 100644 RecursiveExtractor/FileEntryMetadata.cs
delete mode 100644 nuget.config.bak
diff --git a/RecursiveExtractor.Tests/ExtractorTests/FileMetadataTests.cs b/RecursiveExtractor.Tests/ExtractorTests/FileMetadataTests.cs
new file mode 100644
index 00000000..1700217f
--- /dev/null
+++ b/RecursiveExtractor.Tests/ExtractorTests/FileMetadataTests.cs
@@ -0,0 +1,143 @@
+// Copyright (c) Microsoft Corporation. Licensed under the MIT License.
+
+using Microsoft.CST.RecursiveExtractor;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using Xunit;
+
+namespace RecursiveExtractor.Tests.ExtractorTests;
+
+public class FileMetadataTests
+{
+ [Fact]
+ public async Task TarEntries_HaveMetadata()
+ {
+ var extractor = new Extractor();
+ var path = Path.Combine(Directory.GetCurrentDirectory(), "TestData", "TestDataArchives", "TestData.tar");
+ 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);
+ // Regular files in TestData.tar have mode 0644 (octal) = 420 (decimal)
+ Assert.Equal(420, entry.Metadata.Mode);
+ Assert.False(entry.Metadata.IsExecutable);
+ Assert.False(entry.Metadata.IsSetUid);
+ Assert.False(entry.Metadata.IsSetGid);
+ Assert.NotNull(entry.Metadata.Uid);
+ Assert.NotNull(entry.Metadata.Gid);
+ }
+ }
+
+ [Fact]
+ public void TarEntries_HaveMetadata_Sync()
+ {
+ var extractor = new Extractor();
+ var path = Path.Combine(Directory.GetCurrentDirectory(), "TestData", "TestDataArchives", "TestData.tar");
+ 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.Equal(420, entry.Metadata.Mode);
+ Assert.False(entry.Metadata.IsExecutable);
+ Assert.NotNull(entry.Metadata.Uid);
+ Assert.NotNull(entry.Metadata.Gid);
+ }
+ }
+
+ [Fact]
+ public async Task ArEntries_HaveMetadata()
+ {
+ var extractor = new Extractor();
+ var path = Path.Combine(Directory.GetCurrentDirectory(), "TestData", "TestDataArchives", "TestData.a");
+ 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);
+ // ar files in TestData.a have mode 0644 (octal) = 420 (decimal)
+ Assert.Equal(420, entry.Metadata.Mode);
+ Assert.False(entry.Metadata.IsExecutable);
+ Assert.NotNull(entry.Metadata.Uid);
+ Assert.Equal(0L, entry.Metadata.Uid);
+ Assert.NotNull(entry.Metadata.Gid);
+ Assert.Equal(0L, entry.Metadata.Gid);
+ }
+ }
+
+ [Fact]
+ public void ArEntries_HaveMetadata_Sync()
+ {
+ var extractor = new Extractor();
+ var path = Path.Combine(Directory.GetCurrentDirectory(), "TestData", "TestDataArchives", "TestData.a");
+ 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.Equal(420, entry.Metadata.Mode);
+ Assert.NotNull(entry.Metadata.Uid);
+ Assert.NotNull(entry.Metadata.Gid);
+ }
+ }
+
+ [Fact]
+ public void MetadataDefaults_AreNull()
+ {
+ var metadata = new FileEntryMetadata();
+ Assert.Null(metadata.Mode);
+ Assert.Null(metadata.Uid);
+ Assert.Null(metadata.Gid);
+ Assert.Null(metadata.IsExecutable);
+ Assert.Null(metadata.IsSetUid);
+ Assert.Null(metadata.IsSetGid);
+ }
+
+ [Fact]
+ public void IsExecutable_DerivedFromMode()
+ {
+ // 0755 (octal) = 493 (decimal)
+ var metadata = new FileEntryMetadata { Mode = 493 };
+ Assert.True(metadata.IsExecutable);
+ Assert.False(metadata.IsSetUid);
+ Assert.False(metadata.IsSetGid);
+
+ // 0644 (octal) = 420 (decimal)
+ metadata = new FileEntryMetadata { Mode = 420 };
+ Assert.False(metadata.IsExecutable);
+ }
+
+ [Fact]
+ public void SetUidSetGid_DerivedFromMode()
+ {
+ // 04755 (octal) = 2541 (decimal) — setuid + rwxr-xr-x
+ var metadata = new FileEntryMetadata { Mode = 2541 };
+ Assert.True(metadata.IsSetUid);
+ Assert.False(metadata.IsSetGid);
+ Assert.True(metadata.IsExecutable);
+
+ // 02755 (octal) = 1517 (decimal) — setgid + rwxr-xr-x
+ metadata = new FileEntryMetadata { Mode = 1517 };
+ Assert.False(metadata.IsSetUid);
+ Assert.True(metadata.IsSetGid);
+ Assert.True(metadata.IsExecutable);
+ }
+
+ [Fact]
+ public void FileEntry_MetadataDefaultsToNull()
+ {
+ using var stream = new MemoryStream(new byte[] { 0 });
+ var entry = new FileEntry("test.txt", stream);
+ Assert.Null(entry.Metadata);
+ }
+}
diff --git a/RecursiveExtractor/ArFile.cs b/RecursiveExtractor/ArFile.cs
index 66728061..45cc1fc6 100644
--- a/RecursiveExtractor/ArFile.cs
+++ b/RecursiveExtractor/ArFile.cs
@@ -83,7 +83,10 @@ public static IEnumerable GetFileEntries(FileEntry fileEntry, Extract
// The name length is included in the total size reported in the header
CopyStreamBytes(fileEntry.Content, entryStream, size - nameLength);
- yield return new FileEntry(Encoding.ASCII.GetString(nameSpan).TrimEnd('/'), entryStream, fileEntry, true, memoryStreamCutoff: options.MemoryStreamCutoff);
+ yield return new FileEntry(Encoding.ASCII.GetString(nameSpan).TrimEnd('/'), entryStream, fileEntry, true, memoryStreamCutoff: options.MemoryStreamCutoff)
+ {
+ Metadata = ParseArMetadata(headerBuffer)
+ };
}
}
else if (filename.Equals('/'))
@@ -149,7 +152,10 @@ public static IEnumerable GetFileEntries(FileEntry fileEntry, Extract
var entryStream = StreamFactory.GenerateAppropriateBackingStream(options, innerSize);
CopyStreamBytes(fileEntry.Content, entryStream, innerSize);
- yield return new FileEntry(filename.TrimEnd('/'), entryStream, fileEntry, true);
+ yield return new FileEntry(filename.TrimEnd('/'), entryStream, fileEntry, true)
+ {
+ Metadata = ParseArMetadata(headerBuffer)
+ };
}
}
fileEntry.Content.Position = fileEntry.Content.Length - 1;
@@ -220,7 +226,10 @@ public static IEnumerable GetFileEntries(FileEntry fileEntry, Extract
var entryStream = StreamFactory.GenerateAppropriateBackingStream(options, innerSize);
CopyStreamBytes(fileEntry.Content, entryStream, innerSize);
- yield return new FileEntry(filename.TrimEnd('/'), entryStream, fileEntry, true);
+ yield return new FileEntry(filename.TrimEnd('/'), entryStream, fileEntry, true)
+ {
+ Metadata = ParseArMetadata(headerBuffer)
+ };
}
}
fileEntry.Content.Position = fileEntry.Content.Length - 1;
@@ -241,14 +250,20 @@ public static IEnumerable GetFileEntries(FileEntry fileEntry, Extract
var entryStream = StreamFactory.GenerateAppropriateBackingStream(options, size);
CopyStreamBytes(fileEntry.Content, entryStream, size);
- yield return new FileEntry(filename.TrimEnd('/'), entryStream, fileEntry, true); ;
+ yield return new FileEntry(filename.TrimEnd('/'), entryStream, fileEntry, true)
+ {
+ Metadata = ParseArMetadata(headerBuffer)
+ };
}
else
{
var entryStream = StreamFactory.GenerateAppropriateBackingStream(options, size);
CopyStreamBytes(fileEntry.Content, entryStream, size);
- yield return new FileEntry(filename.TrimEnd('/'), entryStream, fileEntry, true);
+ yield return new FileEntry(filename.TrimEnd('/'), entryStream, fileEntry, true)
+ {
+ Metadata = ParseArMetadata(headerBuffer)
+ };
}
}
else
@@ -329,7 +344,10 @@ public static async IAsyncEnumerable GetFileEntriesAsync(FileEntry fi
// The name length is included in the total size reported in the header
await CopyStreamBytesAsync(fileEntry.Content, entryStream, size - nameLength).ConfigureAwait(false);
- yield return new FileEntry(Encoding.ASCII.GetString(nameSpan).TrimEnd('/'), entryStream, fileEntry, true);
+ yield return new FileEntry(Encoding.ASCII.GetString(nameSpan).TrimEnd('/'), entryStream, fileEntry, true)
+ {
+ Metadata = ParseArMetadata(headerBuffer)
+ };
}
}
else if (filename.Equals('/'))
@@ -394,7 +412,10 @@ public static async IAsyncEnumerable GetFileEntriesAsync(FileEntry fi
}
var entryStream = StreamFactory.GenerateAppropriateBackingStream(options, innerSize);
await CopyStreamBytesAsync(fileEntry.Content, entryStream, innerSize).ConfigureAwait(false);
- yield return new FileEntry(filename.TrimEnd('/'), entryStream, fileEntry, true);
+ yield return new FileEntry(filename.TrimEnd('/'), entryStream, fileEntry, true)
+ {
+ Metadata = ParseArMetadata(headerBuffer)
+ };
}
}
fileEntry.Content.Position = fileEntry.Content.Length - 1;
@@ -465,7 +486,10 @@ public static async IAsyncEnumerable GetFileEntriesAsync(FileEntry fi
var entryStream = StreamFactory.GenerateAppropriateBackingStream(options, innerSize);
await CopyStreamBytesAsync(fileEntry.Content, entryStream, innerSize).ConfigureAwait(false);
- yield return new FileEntry(filename.TrimEnd('/'), entryStream, fileEntry, true);
+ yield return new FileEntry(filename.TrimEnd('/'), entryStream, fileEntry, true)
+ {
+ Metadata = ParseArMetadata(headerBuffer)
+ };
}
}
fileEntry.Content.Position = fileEntry.Content.Length - 1;
@@ -485,13 +509,19 @@ public static async IAsyncEnumerable GetFileEntriesAsync(FileEntry fi
}
var entryStream = StreamFactory.GenerateAppropriateBackingStream(options, size);
CopyStreamBytes(fileEntry.Content, entryStream, size);
- yield return new FileEntry(filename.TrimEnd('/'), entryStream, fileEntry, true);
+ yield return new FileEntry(filename.TrimEnd('/'), entryStream, fileEntry, true)
+ {
+ Metadata = ParseArMetadata(headerBuffer)
+ };
}
else
{
var entryStream = StreamFactory.GenerateAppropriateBackingStream(options, size);
await CopyStreamBytesAsync(fileEntry.Content, entryStream, size).ConfigureAwait(false);
- yield return new FileEntry(filename.TrimEnd('/'), entryStream, fileEntry, true);
+ yield return new FileEntry(filename.TrimEnd('/'), entryStream, fileEntry, true)
+ {
+ Metadata = ParseArMetadata(headerBuffer)
+ };
}
}
else
@@ -570,6 +600,46 @@ internal static async Task CopyStreamBytesAsync(Stream input, Stream outpu
private const int bufferSize = 4096;
+ ///
+ /// Parse file metadata (UID, GID, mode) from an ar file header buffer.
+ ///
+ /// The 60-byte ar header
+ /// A with parsed values, or null if parsing fails.
+ internal static FileEntryMetadata? ParseArMetadata(byte[] headerBuffer)
+ {
+ var metadata = new FileEntryMetadata();
+ var hasData = false;
+
+ // ar_uid: bytes 28-33 (6 bytes), decimal
+ if (int.TryParse(Encoding.ASCII.GetString(headerBuffer[28..34]).Trim(), out var uid))
+ {
+ metadata.Uid = uid;
+ hasData = true;
+ }
+
+ // ar_gid: bytes 34-39 (6 bytes), decimal
+ if (int.TryParse(Encoding.ASCII.GetString(headerBuffer[34..40]).Trim(), out var gid))
+ {
+ metadata.Gid = gid;
+ hasData = true;
+ }
+
+ // ar_mode: bytes 40-47 (8 bytes), octal
+ var modeString = Encoding.ASCII.GetString(headerBuffer[40..48]).Trim();
+ try
+ {
+ if (!string.IsNullOrEmpty(modeString))
+ {
+ metadata.Mode = Convert.ToInt64(modeString, 8);
+ hasData = true;
+ }
+ }
+ catch (FormatException) { }
+ catch (OverflowException) { }
+
+ return hasData ? metadata : null;
+ }
+
private readonly static NLog.Logger Logger = NLog.LogManager.GetCurrentClassLogger();
}
}
\ No newline at end of file
diff --git a/RecursiveExtractor/Extractors/RarExtractor.cs b/RecursiveExtractor/Extractors/RarExtractor.cs
index e1096583..0e571672 100644
--- a/RecursiveExtractor/Extractors/RarExtractor.cs
+++ b/RecursiveExtractor/Extractors/RarExtractor.cs
@@ -108,6 +108,14 @@ public async IAsyncEnumerable ExtractAsync(FileEntry fileEntry, Extra
var newFileEntry = await FileEntry.FromStreamAsync(name, entry.OpenEntryStream(), fileEntry, entry.CreatedTime, entry.LastModifiedTime, entry.LastAccessedTime, memoryStreamCutoff: options.MemoryStreamCutoff).ConfigureAwait(false);
if (newFileEntry != null)
{
+ try
+ {
+ if (entry.Attrib.HasValue)
+ {
+ newFileEntry.Metadata = new FileEntryMetadata { Mode = entry.Attrib.Value };
+ }
+ }
+ catch (Exception) { }
if (options.Recurse || topLevel)
{
await foreach (var innerEntry in Context.ExtractAsync(newFileEntry, options, governor, false))
@@ -158,6 +166,14 @@ public IEnumerable Extract(FileEntry fileEntry, ExtractorOptions opti
}
if (newFileEntry != null)
{
+ try
+ {
+ if (entry.Attrib.HasValue)
+ {
+ newFileEntry.Metadata = new FileEntryMetadata { Mode = entry.Attrib.Value };
+ }
+ }
+ catch (Exception) { }
if (options.Recurse || topLevel)
{
foreach (var innerEntry in Context.Extract(newFileEntry, options, governor, false))
diff --git a/RecursiveExtractor/Extractors/SevenZipExtractor.cs b/RecursiveExtractor/Extractors/SevenZipExtractor.cs
index 2f1e3a53..43504800 100644
--- a/RecursiveExtractor/Extractors/SevenZipExtractor.cs
+++ b/RecursiveExtractor/Extractors/SevenZipExtractor.cs
@@ -46,6 +46,14 @@ public async IAsyncEnumerable ExtractAsync(FileEntry fileEntry, Extra
if (newFileEntry != null)
{
+ try
+ {
+ if (entry.Attrib.HasValue)
+ {
+ newFileEntry.Metadata = new FileEntryMetadata { Mode = entry.Attrib.Value };
+ }
+ }
+ catch (Exception) { }
if (options.Recurse || topLevel)
{
await foreach (var innerEntry in Context.ExtractAsync(newFileEntry, options, governor, false))
@@ -157,6 +165,15 @@ public IEnumerable Extract(FileEntry fileEntry, ExtractorOptions opti
var name = (entry.Key ?? string.Empty).Replace('/', Path.DirectorySeparatorChar);
var newFileEntry = new FileEntry(name, entry.OpenEntryStream(), fileEntry, createTime: entry.CreatedTime, modifyTime: entry.LastModifiedTime, accessTime: entry.LastAccessedTime, memoryStreamCutoff: options.MemoryStreamCutoff);
+ try
+ {
+ if (entry.Attrib.HasValue)
+ {
+ newFileEntry.Metadata = new FileEntryMetadata { Mode = entry.Attrib.Value };
+ }
+ }
+ catch (Exception) { }
+
if (options.Recurse || topLevel)
{
foreach (var innerEntry in Context.Extract(newFileEntry, options, governor, false))
diff --git a/RecursiveExtractor/Extractors/TarExtractor.cs b/RecursiveExtractor/Extractors/TarExtractor.cs
index 9de51798..4d5a2dc5 100644
--- a/RecursiveExtractor/Extractors/TarExtractor.cs
+++ b/RecursiveExtractor/Extractors/TarExtractor.cs
@@ -75,7 +75,10 @@ public async IAsyncEnumerable ExtractAsync(FileEntry fileEntry, Extra
name = name[2..];
}
- var newFileEntry = new FileEntry(name, fs, fileEntry, true, memoryStreamCutoff: options.MemoryStreamCutoff);
+ var newFileEntry = new FileEntry(name, fs, fileEntry, true, memoryStreamCutoff: options.MemoryStreamCutoff)
+ {
+ Metadata = new FileEntryMetadata { Mode = tarEntry.Mode, Uid = tarEntry.UserID, Gid = tarEntry.GroupId }
+ };
if (options.Recurse || topLevel)
{
@@ -144,7 +147,10 @@ public IEnumerable Extract(FileEntry fileEntry, ExtractorOptions opti
{
name = name[2..];
}
- var newFileEntry = new FileEntry(name, fs, fileEntry, true, memoryStreamCutoff: options.MemoryStreamCutoff);
+ var newFileEntry = new FileEntry(name, fs, fileEntry, true, memoryStreamCutoff: options.MemoryStreamCutoff)
+ {
+ Metadata = new FileEntryMetadata { Mode = tarEntry.Mode, Uid = tarEntry.UserID, Gid = tarEntry.GroupId }
+ };
if (options.Recurse || topLevel)
{
diff --git a/RecursiveExtractor/Extractors/ZipExtractor.cs b/RecursiveExtractor/Extractors/ZipExtractor.cs
index 5b706b44..9831a378 100644
--- a/RecursiveExtractor/Extractors/ZipExtractor.cs
+++ b/RecursiveExtractor/Extractors/ZipExtractor.cs
@@ -142,6 +142,20 @@ public async IAsyncEnumerable ExtractAsync(FileEntry fileEntry, Extra
var name = zipEntry.Key?.Replace('/', Path.DirectorySeparatorChar) ?? "";
var newFileEntry = new FileEntry(name, target, fileEntry, modifyTime: zipEntry.LastModifiedTime, memoryStreamCutoff: options.MemoryStreamCutoff);
+ try
+ {
+ if (zipEntry.Attrib.HasValue)
+ {
+ // For ZIP files, Unix permissions are stored in the upper 16 bits of the external attributes
+ var unixMode = (zipEntry.Attrib.Value >> 16) & 0xFFFF;
+ if (unixMode != 0)
+ {
+ newFileEntry.Metadata = new FileEntryMetadata { Mode = unixMode };
+ }
+ }
+ }
+ catch (Exception) { }
+
if (options.Recurse || topLevel)
{
await foreach (var innerEntry in Context.ExtractAsync(newFileEntry, options, governor, false))
@@ -237,6 +251,20 @@ public IEnumerable Extract(FileEntry fileEntry, ExtractorOptions opti
var name = zipEntry.Key?.Replace('/', Path.DirectorySeparatorChar) ?? "";
var newFileEntry = new FileEntry(name, fs, fileEntry, modifyTime: zipEntry.LastModifiedTime, memoryStreamCutoff: options.MemoryStreamCutoff);
+ try
+ {
+ if (zipEntry.Attrib.HasValue)
+ {
+ // For ZIP files, Unix permissions are stored in the upper 16 bits of the external attributes
+ var unixMode = (zipEntry.Attrib.Value >> 16) & 0xFFFF;
+ if (unixMode != 0)
+ {
+ newFileEntry.Metadata = new FileEntryMetadata { Mode = unixMode };
+ }
+ }
+ }
+ catch (Exception) { }
+
if (options.Recurse || topLevel)
{
foreach (var innerEntry in Context.Extract(newFileEntry, options, governor, false))
diff --git a/RecursiveExtractor/FileEntry.cs b/RecursiveExtractor/FileEntry.cs
index 921486fb..7e0fd55c 100644
--- a/RecursiveExtractor/FileEntry.cs
+++ b/RecursiveExtractor/FileEntry.cs
@@ -173,6 +173,12 @@ public FileEntry(string name, Stream inputStream, FileEntry? parent = null, bool
/// ExtractionStatus metadata.
///
public FileEntryStatus EntryStatus { get; set; }
+
+ ///
+ /// Optional metadata about the file such as permissions, ownership, and special bits.
+ /// Null when the archive format does not provide this information.
+ ///
+ public FileEntryMetadata? Metadata { get; set; }
///
/// Regular expression to find characters that are not valid in filenames/paths on this system.
diff --git a/RecursiveExtractor/FileEntryMetadata.cs b/RecursiveExtractor/FileEntryMetadata.cs
new file mode 100644
index 00000000..4254c442
--- /dev/null
+++ b/RecursiveExtractor/FileEntryMetadata.cs
@@ -0,0 +1,47 @@
+// Copyright (c) Microsoft Corporation. Licensed under the MIT License.
+
+namespace Microsoft.CST.RecursiveExtractor
+{
+ ///
+ /// Metadata about a file extracted from an archive, such as permissions and ownership.
+ /// Properties are nullable to indicate when the metadata was not available from the archive format.
+ ///
+ public class FileEntryMetadata
+ {
+ ///
+ /// The Unix file mode (permissions) as an integer (e.g., 0x1FF for 0777, 0x1ED for 0755).
+ /// Null if not available from the archive format.
+ ///
+ public long? Mode { get; set; }
+
+ ///
+ /// Whether the file has any executable permission bits set (owner, group, or other).
+ /// Derived from when available, otherwise null.
+ ///
+ public bool? IsExecutable => Mode.HasValue ? (Mode.Value & 0x49) != 0 : null; // 0x49 = 0111 in octal
+
+ ///
+ /// Whether the SetUID bit is set on this file.
+ /// Derived from when available, otherwise null.
+ ///
+ public bool? IsSetUid => Mode.HasValue ? (Mode.Value & 0x800) != 0 : null; // 04000 in octal
+
+ ///
+ /// Whether the SetGID bit is set on this file.
+ /// Derived from when available, otherwise null.
+ ///
+ public bool? IsSetGid => Mode.HasValue ? (Mode.Value & 0x400) != 0 : null; // 02000 in octal
+
+ ///
+ /// The User ID (UID) of the file owner.
+ /// Null if not available from the archive format.
+ ///
+ public long? Uid { get; set; }
+
+ ///
+ /// The Group ID (GID) of the file owner.
+ /// Null if not available from the archive format.
+ ///
+ public long? Gid { get; set; }
+ }
+}
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
diff --git a/nuget.config.bak b/nuget.config.bak
deleted file mode 100644
index 227ad0ce..00000000
--- a/nuget.config.bak
+++ /dev/null
@@ -1,7 +0,0 @@
-
-
-
-
-
-
-
\ No newline at end of file
From 6da62dc9c1ac4b454421459dfa4210293b666c1c Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 12 Feb 2026 06:02:11 +0000
Subject: [PATCH 4/5] Add logging to catch blocks for Attrib property access in
extractors
Co-authored-by: gfs <98900+gfs@users.noreply.github.com>
---
RecursiveExtractor/Extractors/RarExtractor.cs | 4 ++--
RecursiveExtractor/Extractors/SevenZipExtractor.cs | 4 ++--
RecursiveExtractor/Extractors/ZipExtractor.cs | 4 ++--
3 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/RecursiveExtractor/Extractors/RarExtractor.cs b/RecursiveExtractor/Extractors/RarExtractor.cs
index 0e571672..6335dfa0 100644
--- a/RecursiveExtractor/Extractors/RarExtractor.cs
+++ b/RecursiveExtractor/Extractors/RarExtractor.cs
@@ -115,7 +115,7 @@ public async IAsyncEnumerable ExtractAsync(FileEntry fileEntry, Extra
newFileEntry.Metadata = new FileEntryMetadata { Mode = entry.Attrib.Value };
}
}
- catch (Exception) { }
+ catch (Exception e) { Logger.Trace("Failed to read file attributes: {0}", e.Message); }
if (options.Recurse || topLevel)
{
await foreach (var innerEntry in Context.ExtractAsync(newFileEntry, options, governor, false))
@@ -173,7 +173,7 @@ public IEnumerable Extract(FileEntry fileEntry, ExtractorOptions opti
newFileEntry.Metadata = new FileEntryMetadata { Mode = entry.Attrib.Value };
}
}
- catch (Exception) { }
+ catch (Exception e) { Logger.Trace("Failed to read file attributes: {0}", e.Message); }
if (options.Recurse || topLevel)
{
foreach (var innerEntry in Context.Extract(newFileEntry, options, governor, false))
diff --git a/RecursiveExtractor/Extractors/SevenZipExtractor.cs b/RecursiveExtractor/Extractors/SevenZipExtractor.cs
index 43504800..8a2af1f3 100644
--- a/RecursiveExtractor/Extractors/SevenZipExtractor.cs
+++ b/RecursiveExtractor/Extractors/SevenZipExtractor.cs
@@ -53,7 +53,7 @@ public async IAsyncEnumerable ExtractAsync(FileEntry fileEntry, Extra
newFileEntry.Metadata = new FileEntryMetadata { Mode = entry.Attrib.Value };
}
}
- catch (Exception) { }
+ catch (Exception e) { Logger.Trace("Failed to read file attributes: {0}", e.Message); }
if (options.Recurse || topLevel)
{
await foreach (var innerEntry in Context.ExtractAsync(newFileEntry, options, governor, false))
@@ -172,7 +172,7 @@ public IEnumerable Extract(FileEntry fileEntry, ExtractorOptions opti
newFileEntry.Metadata = new FileEntryMetadata { Mode = entry.Attrib.Value };
}
}
- catch (Exception) { }
+ catch (Exception e) { Logger.Trace("Failed to read file attributes: {0}", e.Message); }
if (options.Recurse || topLevel)
{
diff --git a/RecursiveExtractor/Extractors/ZipExtractor.cs b/RecursiveExtractor/Extractors/ZipExtractor.cs
index 9831a378..1a0a0ce7 100644
--- a/RecursiveExtractor/Extractors/ZipExtractor.cs
+++ b/RecursiveExtractor/Extractors/ZipExtractor.cs
@@ -154,7 +154,7 @@ public async IAsyncEnumerable ExtractAsync(FileEntry fileEntry, Extra
}
}
}
- catch (Exception) { }
+ catch (Exception e) { Logger.Trace("Failed to read file attributes: {0}", e.Message); }
if (options.Recurse || topLevel)
{
@@ -263,7 +263,7 @@ public IEnumerable Extract(FileEntry fileEntry, ExtractorOptions opti
}
}
}
- catch (Exception) { }
+ catch (Exception e) { Logger.Trace("Failed to read file attributes: {0}", e.Message); }
if (options.Recurse || topLevel)
{
From 536c46a17d89c09887b4c9e6dfdeb62645c79e51 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 12 Feb 2026 06:17:12 +0000
Subject: [PATCH 5/5] Include archive type and file path in trace messages for
failed attribute reads
Co-authored-by: gfs <98900+gfs@users.noreply.github.com>
---
RecursiveExtractor/Extractors/RarExtractor.cs | 4 ++--
RecursiveExtractor/Extractors/SevenZipExtractor.cs | 4 ++--
RecursiveExtractor/Extractors/ZipExtractor.cs | 4 ++--
3 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/RecursiveExtractor/Extractors/RarExtractor.cs b/RecursiveExtractor/Extractors/RarExtractor.cs
index 6335dfa0..829b3d30 100644
--- a/RecursiveExtractor/Extractors/RarExtractor.cs
+++ b/RecursiveExtractor/Extractors/RarExtractor.cs
@@ -115,7 +115,7 @@ public async IAsyncEnumerable ExtractAsync(FileEntry fileEntry, Extra
newFileEntry.Metadata = new FileEntryMetadata { Mode = entry.Attrib.Value };
}
}
- catch (Exception e) { Logger.Trace("Failed to read file attributes: {0}", e.Message); }
+ catch (Exception e) { Logger.Trace("Failed to read file attributes for {0} in {1} archive {2}: {3}", entry.Key, ArchiveFileType.RAR, fileEntry.FullPath, e.Message); }
if (options.Recurse || topLevel)
{
await foreach (var innerEntry in Context.ExtractAsync(newFileEntry, options, governor, false))
@@ -173,7 +173,7 @@ public IEnumerable Extract(FileEntry fileEntry, ExtractorOptions opti
newFileEntry.Metadata = new FileEntryMetadata { Mode = entry.Attrib.Value };
}
}
- catch (Exception e) { Logger.Trace("Failed to read file attributes: {0}", e.Message); }
+ catch (Exception e) { Logger.Trace("Failed to read file attributes for {0} in {1} archive {2}: {3}", entry.Key, ArchiveFileType.RAR, fileEntry.FullPath, e.Message); }
if (options.Recurse || topLevel)
{
foreach (var innerEntry in Context.Extract(newFileEntry, options, governor, false))
diff --git a/RecursiveExtractor/Extractors/SevenZipExtractor.cs b/RecursiveExtractor/Extractors/SevenZipExtractor.cs
index 8a2af1f3..c3a7bf4c 100644
--- a/RecursiveExtractor/Extractors/SevenZipExtractor.cs
+++ b/RecursiveExtractor/Extractors/SevenZipExtractor.cs
@@ -53,7 +53,7 @@ public async IAsyncEnumerable ExtractAsync(FileEntry fileEntry, Extra
newFileEntry.Metadata = new FileEntryMetadata { Mode = entry.Attrib.Value };
}
}
- catch (Exception e) { Logger.Trace("Failed to read file attributes: {0}", e.Message); }
+ catch (Exception e) { Logger.Trace("Failed to read file attributes for {0} in {1} archive {2}: {3}", entry.Key, ArchiveFileType.P7ZIP, fileEntry.FullPath, e.Message); }
if (options.Recurse || topLevel)
{
await foreach (var innerEntry in Context.ExtractAsync(newFileEntry, options, governor, false))
@@ -172,7 +172,7 @@ public IEnumerable Extract(FileEntry fileEntry, ExtractorOptions opti
newFileEntry.Metadata = new FileEntryMetadata { Mode = entry.Attrib.Value };
}
}
- catch (Exception e) { Logger.Trace("Failed to read file attributes: {0}", e.Message); }
+ catch (Exception e) { Logger.Trace("Failed to read file attributes for {0} in {1} archive {2}: {3}", entry.Key, ArchiveFileType.P7ZIP, fileEntry.FullPath, e.Message); }
if (options.Recurse || topLevel)
{
diff --git a/RecursiveExtractor/Extractors/ZipExtractor.cs b/RecursiveExtractor/Extractors/ZipExtractor.cs
index 1a0a0ce7..d5795e18 100644
--- a/RecursiveExtractor/Extractors/ZipExtractor.cs
+++ b/RecursiveExtractor/Extractors/ZipExtractor.cs
@@ -154,7 +154,7 @@ public async IAsyncEnumerable ExtractAsync(FileEntry fileEntry, Extra
}
}
}
- catch (Exception e) { Logger.Trace("Failed to read file attributes: {0}", e.Message); }
+ catch (Exception e) { Logger.Trace("Failed to read file attributes for {0} in {1} archive {2}: {3}", zipEntry.Key, ArchiveFileType.ZIP, fileEntry.FullPath, e.Message); }
if (options.Recurse || topLevel)
{
@@ -263,7 +263,7 @@ public IEnumerable Extract(FileEntry fileEntry, ExtractorOptions opti
}
}
}
- catch (Exception e) { Logger.Trace("Failed to read file attributes: {0}", e.Message); }
+ catch (Exception e) { Logger.Trace("Failed to read file attributes for {0} in {1} archive {2}: {3}", zipEntry.Key, ArchiveFileType.ZIP, fileEntry.FullPath, e.Message); }
if (options.Recurse || topLevel)
{