diff --git a/src/SharpCompress/Archives/AbstractWritableArchive.cs b/src/SharpCompress/Archives/AbstractWritableArchive.cs index b72c2dff..744d4ee2 100644 --- a/src/SharpCompress/Archives/AbstractWritableArchive.cs +++ b/src/SharpCompress/Archives/AbstractWritableArchive.cs @@ -96,6 +96,9 @@ public abstract class AbstractWritableArchive DateTime? modified ) => AddEntry(key, source, closeStream, size, modified); + IArchiveEntry IWritableArchive.AddDirectoryEntry(string key, DateTime? modified) => + AddDirectoryEntry(key, modified); + public TEntry AddEntry( string key, Stream source, @@ -136,6 +139,22 @@ public abstract class AbstractWritableArchive return false; } + public TEntry AddDirectoryEntry(string key, DateTime? modified = null) + { + if (key.Length > 0 && key[0] is '/' or '\\') + { + key = key.Substring(1); + } + if (DoesKeyMatchExisting(key)) + { + throw new ArchiveException("Cannot add entry with duplicate key: " + key); + } + var entry = CreateDirectoryEntry(key, modified); + newEntries.Add(entry); + RebuildModifiedCollection(); + return entry; + } + public void SaveTo(Stream stream, WriterOptions options) { //reset streams of new entries @@ -180,6 +199,8 @@ public abstract class AbstractWritableArchive bool closeStream ); + protected abstract TEntry CreateDirectoryEntry(string key, DateTime? modified); + protected abstract void SaveTo( Stream stream, WriterOptions options, diff --git a/src/SharpCompress/Archives/GZip/GZipArchive.cs b/src/SharpCompress/Archives/GZip/GZipArchive.cs index f4eb2805..34e4c648 100644 --- a/src/SharpCompress/Archives/GZip/GZipArchive.cs +++ b/src/SharpCompress/Archives/GZip/GZipArchive.cs @@ -185,6 +185,11 @@ public class GZipArchive : AbstractWritableArchive return new GZipWritableArchiveEntry(this, source, filePath, size, modified, closeStream); } + protected override GZipArchiveEntry CreateDirectoryEntry( + string directoryPath, + DateTime? modified + ) => throw new NotSupportedException("GZip archives do not support directory entries."); + protected override void SaveTo( Stream stream, WriterOptions options, diff --git a/src/SharpCompress/Archives/IWritableArchive.cs b/src/SharpCompress/Archives/IWritableArchive.cs index 5529ec3b..dde22a03 100644 --- a/src/SharpCompress/Archives/IWritableArchive.cs +++ b/src/SharpCompress/Archives/IWritableArchive.cs @@ -18,6 +18,8 @@ public interface IWritableArchive : IArchive DateTime? modified = null ); + IArchiveEntry AddDirectoryEntry(string key, DateTime? modified = null); + void SaveTo(Stream stream, WriterOptions options); Task SaveToAsync( diff --git a/src/SharpCompress/Archives/Tar/TarArchive.cs b/src/SharpCompress/Archives/Tar/TarArchive.cs index 05e74dbe..2754fd9b 100644 --- a/src/SharpCompress/Archives/Tar/TarArchive.cs +++ b/src/SharpCompress/Archives/Tar/TarArchive.cs @@ -224,6 +224,11 @@ public class TarArchive : AbstractWritableArchive closeStream ); + protected override TarArchiveEntry CreateDirectoryEntry( + string directoryPath, + DateTime? modified + ) => new TarWritableArchiveEntry(this, directoryPath, modified); + protected override void SaveTo( Stream stream, WriterOptions options, @@ -232,15 +237,25 @@ public class TarArchive : AbstractWritableArchive ) { using var writer = new TarWriter(stream, new TarWriterOptions(options)); - foreach (var entry in oldEntries.Concat(newEntries).Where(x => !x.IsDirectory)) + foreach (var entry in oldEntries.Concat(newEntries)) { - using var entryStream = entry.OpenEntryStream(); - writer.Write( - entry.Key.NotNull("Entry Key is null"), - entryStream, - entry.LastModifiedTime, - entry.Size - ); + if (entry.IsDirectory) + { + writer.WriteDirectory( + entry.Key.NotNull("Entry Key is null"), + entry.LastModifiedTime + ); + } + else + { + using var entryStream = entry.OpenEntryStream(); + writer.Write( + entry.Key.NotNull("Entry Key is null"), + entryStream, + entry.LastModifiedTime, + entry.Size + ); + } } } @@ -253,18 +268,31 @@ public class TarArchive : AbstractWritableArchive ) { using var writer = new TarWriter(stream, new TarWriterOptions(options)); - foreach (var entry in oldEntries.Concat(newEntries).Where(x => !x.IsDirectory)) + foreach (var entry in oldEntries.Concat(newEntries)) { - using var entryStream = entry.OpenEntryStream(); - await writer - .WriteAsync( - entry.Key.NotNull("Entry Key is null"), - entryStream, - entry.LastModifiedTime, - entry.Size, - cancellationToken - ) - .ConfigureAwait(false); + if (entry.IsDirectory) + { + await writer + .WriteDirectoryAsync( + entry.Key.NotNull("Entry Key is null"), + entry.LastModifiedTime, + cancellationToken + ) + .ConfigureAwait(false); + } + else + { + using var entryStream = entry.OpenEntryStream(); + await writer + .WriteAsync( + entry.Key.NotNull("Entry Key is null"), + entryStream, + entry.LastModifiedTime, + entry.Size, + cancellationToken + ) + .ConfigureAwait(false); + } } } diff --git a/src/SharpCompress/Archives/Tar/TarWritableArchiveEntry.cs b/src/SharpCompress/Archives/Tar/TarWritableArchiveEntry.cs index 147d187f..69da97c4 100644 --- a/src/SharpCompress/Archives/Tar/TarWritableArchiveEntry.cs +++ b/src/SharpCompress/Archives/Tar/TarWritableArchiveEntry.cs @@ -9,7 +9,8 @@ namespace SharpCompress.Archives.Tar; internal sealed class TarWritableArchiveEntry : TarArchiveEntry, IWritableArchiveEntry { private readonly bool closeStream; - private readonly Stream stream; + private readonly Stream? stream; + private readonly bool isDirectory; internal TarWritableArchiveEntry( TarArchive archive, @@ -27,6 +28,22 @@ internal sealed class TarWritableArchiveEntry : TarArchiveEntry, IWritableArchiv Size = size; LastModifiedTime = lastModified; this.closeStream = closeStream; + isDirectory = false; + } + + internal TarWritableArchiveEntry( + TarArchive archive, + string directoryPath, + DateTime? lastModified + ) + : base(archive, null, CompressionType.None) + { + stream = null; + Key = directoryPath; + Size = 0; + LastModifiedTime = lastModified; + closeStream = false; + isDirectory = true; } public override long Crc => 0; @@ -47,15 +64,19 @@ internal sealed class TarWritableArchiveEntry : TarArchiveEntry, IWritableArchiv public override bool IsEncrypted => false; - public override bool IsDirectory => false; + public override bool IsDirectory => isDirectory; public override bool IsSplitAfter => false; internal override IEnumerable Parts => throw new NotImplementedException(); - Stream IWritableArchiveEntry.Stream => stream; + Stream IWritableArchiveEntry.Stream => stream ?? Stream.Null; public override Stream OpenEntryStream() { + if (stream is null) + { + return Stream.Null; + } //ensure new stream is at the start, this could be reset stream.Seek(0, SeekOrigin.Begin); return SharpCompressStream.Create(stream, leaveOpen: true); @@ -63,7 +84,7 @@ internal sealed class TarWritableArchiveEntry : TarArchiveEntry, IWritableArchiv internal override void Close() { - if (closeStream) + if (closeStream && stream is not null) { stream.Dispose(); } diff --git a/src/SharpCompress/Archives/Zip/ZipArchive.cs b/src/SharpCompress/Archives/Zip/ZipArchive.cs index 955e9649..57db85c2 100644 --- a/src/SharpCompress/Archives/Zip/ZipArchive.cs +++ b/src/SharpCompress/Archives/Zip/ZipArchive.cs @@ -308,14 +308,24 @@ public class ZipArchive : AbstractWritableArchive ) { using var writer = new ZipWriter(stream, new ZipWriterOptions(options)); - foreach (var entry in oldEntries.Concat(newEntries).Where(x => !x.IsDirectory)) + foreach (var entry in oldEntries.Concat(newEntries)) { - using var entryStream = entry.OpenEntryStream(); - writer.Write( - entry.Key.NotNull("Entry Key is null"), - entryStream, - entry.LastModifiedTime - ); + if (entry.IsDirectory) + { + writer.WriteDirectory( + entry.Key.NotNull("Entry Key is null"), + entry.LastModifiedTime + ); + } + else + { + using var entryStream = entry.OpenEntryStream(); + writer.Write( + entry.Key.NotNull("Entry Key is null"), + entryStream, + entry.LastModifiedTime + ); + } } } @@ -328,12 +338,29 @@ public class ZipArchive : AbstractWritableArchive ) { using var writer = new ZipWriter(stream, new ZipWriterOptions(options)); - foreach (var entry in oldEntries.Concat(newEntries).Where(x => !x.IsDirectory)) + foreach (var entry in oldEntries.Concat(newEntries)) { - using var entryStream = entry.OpenEntryStream(); - await writer - .WriteAsync(entry.Key.NotNull("Entry Key is null"), entryStream, cancellationToken) - .ConfigureAwait(false); + if (entry.IsDirectory) + { + await writer + .WriteDirectoryAsync( + entry.Key.NotNull("Entry Key is null"), + entry.LastModifiedTime, + cancellationToken + ) + .ConfigureAwait(false); + } + else + { + using var entryStream = entry.OpenEntryStream(); + await writer + .WriteAsync( + entry.Key.NotNull("Entry Key is null"), + entryStream, + cancellationToken + ) + .ConfigureAwait(false); + } } } @@ -345,6 +372,11 @@ public class ZipArchive : AbstractWritableArchive bool closeStream ) => new ZipWritableArchiveEntry(this, source, filePath, size, modified, closeStream); + protected override ZipArchiveEntry CreateDirectoryEntry( + string directoryPath, + DateTime? modified + ) => new ZipWritableArchiveEntry(this, directoryPath, modified); + public static ZipArchive Create() => new(); protected override IReader CreateReaderForSolidExtraction() diff --git a/src/SharpCompress/Archives/Zip/ZipWritableArchiveEntry.cs b/src/SharpCompress/Archives/Zip/ZipWritableArchiveEntry.cs index c01776e6..f3feba19 100644 --- a/src/SharpCompress/Archives/Zip/ZipWritableArchiveEntry.cs +++ b/src/SharpCompress/Archives/Zip/ZipWritableArchiveEntry.cs @@ -9,7 +9,8 @@ namespace SharpCompress.Archives.Zip; internal class ZipWritableArchiveEntry : ZipArchiveEntry, IWritableArchiveEntry { private readonly bool closeStream; - private readonly Stream stream; + private readonly Stream? stream; + private readonly bool isDirectory; private bool isDisposed; internal ZipWritableArchiveEntry( @@ -27,6 +28,22 @@ internal class ZipWritableArchiveEntry : ZipArchiveEntry, IWritableArchiveEntry Size = size; LastModifiedTime = lastModified; this.closeStream = closeStream; + isDirectory = false; + } + + internal ZipWritableArchiveEntry( + ZipArchive archive, + string directoryPath, + DateTime? lastModified + ) + : base(archive, null) + { + stream = null; + Key = directoryPath; + Size = 0; + LastModifiedTime = lastModified; + closeStream = false; + isDirectory = true; } public override long Crc => 0; @@ -47,16 +64,20 @@ internal class ZipWritableArchiveEntry : ZipArchiveEntry, IWritableArchiveEntry public override bool IsEncrypted => false; - public override bool IsDirectory => false; + public override bool IsDirectory => isDirectory; public override bool IsSplitAfter => false; internal override IEnumerable Parts => throw new NotImplementedException(); - Stream IWritableArchiveEntry.Stream => stream; + Stream IWritableArchiveEntry.Stream => stream ?? Stream.Null; public override Stream OpenEntryStream() { + if (stream is null) + { + return Stream.Null; + } //ensure new stream is at the start, this could be reset stream.Seek(0, SeekOrigin.Begin); return SharpCompressStream.Create(stream, leaveOpen: true); @@ -64,7 +85,7 @@ internal class ZipWritableArchiveEntry : ZipArchiveEntry, IWritableArchiveEntry internal override void Close() { - if (closeStream && !isDisposed) + if (closeStream && !isDisposed && stream is not null) { stream.Dispose(); isDisposed = true; diff --git a/src/SharpCompress/Common/ExtractionMethods.cs b/src/SharpCompress/Common/ExtractionMethods.cs index f81f82d4..485fdf4d 100644 --- a/src/SharpCompress/Common/ExtractionMethods.cs +++ b/src/SharpCompress/Common/ExtractionMethods.cs @@ -1,5 +1,6 @@ using System; using System.IO; +using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; @@ -7,6 +8,15 @@ namespace SharpCompress.Common; internal static class ExtractionMethods { + /// + /// Gets the appropriate StringComparison for path checks based on the file system. + /// Windows uses case-insensitive file systems, while Unix-like systems use case-sensitive file systems. + /// + private static StringComparison PathComparison => + RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + /// /// Extract to specific directory, retaining filename /// @@ -48,7 +58,7 @@ internal static class ExtractionMethods if (!Directory.Exists(destdir)) { - if (!destdir.StartsWith(fullDestinationDirectoryPath, StringComparison.Ordinal)) + if (!destdir.StartsWith(fullDestinationDirectoryPath, PathComparison)) { throw new ExtractionException( "Entry is trying to create a directory outside of the destination directory." @@ -68,12 +78,7 @@ internal static class ExtractionMethods { destinationFileName = Path.GetFullPath(destinationFileName); - if ( - !destinationFileName.StartsWith( - fullDestinationDirectoryPath, - StringComparison.Ordinal - ) - ) + if (!destinationFileName.StartsWith(fullDestinationDirectoryPath, PathComparison)) { throw new ExtractionException( "Entry is trying to write a file outside of the destination directory." @@ -158,7 +163,7 @@ internal static class ExtractionMethods if (!Directory.Exists(destdir)) { - if (!destdir.StartsWith(fullDestinationDirectoryPath, StringComparison.Ordinal)) + if (!destdir.StartsWith(fullDestinationDirectoryPath, PathComparison)) { throw new ExtractionException( "Entry is trying to create a directory outside of the destination directory." @@ -178,12 +183,7 @@ internal static class ExtractionMethods { destinationFileName = Path.GetFullPath(destinationFileName); - if ( - !destinationFileName.StartsWith( - fullDestinationDirectoryPath, - StringComparison.Ordinal - ) - ) + if (!destinationFileName.StartsWith(fullDestinationDirectoryPath, PathComparison)) { throw new ExtractionException( "Entry is trying to write a file outside of the destination directory." diff --git a/src/SharpCompress/Compressors/LZMA/ICoder.cs b/src/SharpCompress/Compressors/LZMA/ICoder.cs index 95134979..22648533 100644 --- a/src/SharpCompress/Compressors/LZMA/ICoder.cs +++ b/src/SharpCompress/Compressors/LZMA/ICoder.cs @@ -1,12 +1,13 @@ using System; using System.IO; +using SharpCompress.Common; namespace SharpCompress.Compressors.LZMA; /// /// The exception that is thrown when an error in input stream occurs during decoding. /// -internal class DataErrorException : Exception +internal class DataErrorException : SharpCompressException { public DataErrorException() : base("Data Error") { } @@ -15,7 +16,7 @@ internal class DataErrorException : Exception /// /// The exception that is thrown when the value of an argument is outside the allowable range. /// -internal class InvalidParamException : Exception +internal class InvalidParamException : SharpCompressException { public InvalidParamException() : base("Invalid Parameter") { } diff --git a/src/SharpCompress/Compressors/Xz/XZIndexMarkerReachedException.cs b/src/SharpCompress/Compressors/Xz/XZIndexMarkerReachedException.cs index bb006a35..f7fe0428 100644 --- a/src/SharpCompress/Compressors/Xz/XZIndexMarkerReachedException.cs +++ b/src/SharpCompress/Compressors/Xz/XZIndexMarkerReachedException.cs @@ -1,5 +1,5 @@ -using System; +using SharpCompress.Common; namespace SharpCompress.Compressors.Xz; -public class XZIndexMarkerReachedException : Exception { } +public class XZIndexMarkerReachedException : SharpCompressException { } diff --git a/src/SharpCompress/Writers/AbstractWriter.cs b/src/SharpCompress/Writers/AbstractWriter.cs index 2f71c0cb..e75b93b6 100644 --- a/src/SharpCompress/Writers/AbstractWriter.cs +++ b/src/SharpCompress/Writers/AbstractWriter.cs @@ -37,6 +37,20 @@ public abstract class AbstractWriter(ArchiveType type, WriterOptions writerOptio await Task.CompletedTask.ConfigureAwait(false); } + public abstract void WriteDirectory(string directoryName, DateTime? modificationTime); + + public virtual async Task WriteDirectoryAsync( + string directoryName, + DateTime? modificationTime, + CancellationToken cancellationToken = default + ) + { + // Default implementation calls synchronous version + // Derived classes should override for true async behavior + WriteDirectory(directoryName, modificationTime); + await Task.CompletedTask.ConfigureAwait(false); + } + protected virtual void Dispose(bool isDisposing) { if (isDisposing) diff --git a/src/SharpCompress/Writers/GZip/GZipWriter.cs b/src/SharpCompress/Writers/GZip/GZipWriter.cs index 7dd4a7de..00100323 100644 --- a/src/SharpCompress/Writers/GZip/GZipWriter.cs +++ b/src/SharpCompress/Writers/GZip/GZipWriter.cs @@ -50,4 +50,7 @@ public sealed class GZipWriter : AbstractWriter source.CopyTo(stream); _wroteToStream = true; } + + public override void WriteDirectory(string directoryName, DateTime? modificationTime) => + throw new NotSupportedException("GZip archives do not support directory entries."); } diff --git a/src/SharpCompress/Writers/IWriter.cs b/src/SharpCompress/Writers/IWriter.cs index 3ca51bc3..d34d2398 100644 --- a/src/SharpCompress/Writers/IWriter.cs +++ b/src/SharpCompress/Writers/IWriter.cs @@ -16,4 +16,10 @@ public interface IWriter : IDisposable DateTime? modificationTime, CancellationToken cancellationToken = default ); + void WriteDirectory(string directoryName, DateTime? modificationTime); + Task WriteDirectoryAsync( + string directoryName, + DateTime? modificationTime, + CancellationToken cancellationToken = default + ); } diff --git a/src/SharpCompress/Writers/IWriterExtensions.cs b/src/SharpCompress/Writers/IWriterExtensions.cs index 080312c7..9b6a268f 100644 --- a/src/SharpCompress/Writers/IWriterExtensions.cs +++ b/src/SharpCompress/Writers/IWriterExtensions.cs @@ -55,6 +55,9 @@ public static class IWriterExtensions } } + public static void WriteDirectory(this IWriter writer, string directoryName) => + writer.WriteDirectory(directoryName, null); + // Async extensions public static Task WriteAsync( this IWriter writer, @@ -121,4 +124,10 @@ public static class IWriterExtensions .ConfigureAwait(false); } } + + public static Task WriteDirectoryAsync( + this IWriter writer, + string directoryName, + CancellationToken cancellationToken = default + ) => writer.WriteDirectoryAsync(directoryName, null, cancellationToken); } diff --git a/src/SharpCompress/Writers/Tar/TarWriter.cs b/src/SharpCompress/Writers/Tar/TarWriter.cs index 38565c57..96346e2b 100644 --- a/src/SharpCompress/Writers/Tar/TarWriter.cs +++ b/src/SharpCompress/Writers/Tar/TarWriter.cs @@ -74,6 +74,44 @@ public class TarWriter : AbstractWriter return filename.Trim('/'); } + private string NormalizeDirectoryName(string directoryName) + { + directoryName = NormalizeFilename(directoryName); + // Ensure directory name ends with '/' for tar format + if (!string.IsNullOrEmpty(directoryName) && !directoryName.EndsWith('/')) + { + directoryName += '/'; + } + return directoryName; + } + + public override void WriteDirectory(string directoryName, DateTime? modificationTime) + { + var normalizedName = NormalizeDirectoryName(directoryName); + if (string.IsNullOrEmpty(normalizedName)) + { + return; // Skip empty or root directory + } + + var header = new TarHeader(WriterOptions.ArchiveEncoding); + header.LastModifiedTime = modificationTime ?? TarHeader.EPOCH; + header.Name = normalizedName; + header.Size = 0; + header.EntryType = EntryType.Directory; + header.Write(OutputStream); + } + + public override async Task WriteDirectoryAsync( + string directoryName, + DateTime? modificationTime, + CancellationToken cancellationToken = default + ) + { + // Synchronous implementation is sufficient for header-only write + WriteDirectory(directoryName, modificationTime); + await Task.CompletedTask.ConfigureAwait(false); + } + public void Write(string filename, Stream source, DateTime? modificationTime, long? size) { if (!source.CanSeek && size is null) diff --git a/src/SharpCompress/Writers/Zip/ZipWriter.cs b/src/SharpCompress/Writers/Zip/ZipWriter.cs index f1572823..daee1ed6 100644 --- a/src/SharpCompress/Writers/Zip/ZipWriter.cs +++ b/src/SharpCompress/Writers/Zip/ZipWriter.cs @@ -3,6 +3,8 @@ using System.Buffers.Binary; using System.Collections.Generic; using System.IO; using System.Text; +using System.Threading; +using System.Threading.Tasks; using SharpCompress.Common; using SharpCompress.Common.Zip; using SharpCompress.Common.Zip.Headers; @@ -135,6 +137,73 @@ public class ZipWriter : AbstractWriter return filename.Trim('/'); } + private string NormalizeDirectoryName(string directoryName) + { + directoryName = NormalizeFilename(directoryName); + // Ensure directory name ends with '/' for zip format + if (!string.IsNullOrEmpty(directoryName) && !directoryName.EndsWith('/')) + { + directoryName += '/'; + } + return directoryName; + } + + public override void WriteDirectory(string directoryName, DateTime? modificationTime) + { + var normalizedName = NormalizeDirectoryName(directoryName); + if (string.IsNullOrEmpty(normalizedName)) + { + return; // Skip empty or root directory + } + + var options = new ZipWriterEntryOptions { ModificationDateTime = modificationTime }; + WriteDirectoryEntry(normalizedName, options); + } + + public override async Task WriteDirectoryAsync( + string directoryName, + DateTime? modificationTime, + CancellationToken cancellationToken = default + ) + { + // Synchronous implementation is sufficient for directory entries + WriteDirectory(directoryName, modificationTime); + await Task.CompletedTask.ConfigureAwait(false); + } + + private void WriteDirectoryEntry(string directoryPath, ZipWriterEntryOptions options) + { + var compression = ZipCompressionMethod.None; + + options.ModificationDateTime ??= DateTime.Now; + options.EntryComment ??= string.Empty; + + var entry = new ZipCentralDirectoryEntry( + compression, + directoryPath, + (ulong)streamPosition, + WriterOptions.ArchiveEncoding + ) + { + Comment = options.EntryComment, + ModificationTime = options.ModificationDateTime, + Crc = 0, + Compressed = 0, + Decompressed = 0, + }; + + // Use the archive default setting for zip64 and allow overrides + var useZip64 = isZip64; + if (options.EnableZip64.HasValue) + { + useZip64 = options.EnableZip64.Value; + } + + var headersize = (uint)WriteHeader(directoryPath, options, entry, useZip64); + streamPosition += headersize; + entries.Add(entry); + } + private int WriteHeader( string filename, ZipWriterEntryOptions zipWriterEntryOptions, diff --git a/tests/SharpCompress.Test/ExceptionHierarchyTests.cs b/tests/SharpCompress.Test/ExceptionHierarchyTests.cs new file mode 100644 index 00000000..b543d179 --- /dev/null +++ b/tests/SharpCompress.Test/ExceptionHierarchyTests.cs @@ -0,0 +1,116 @@ +using System; +using SharpCompress.Common; +using SharpCompress.Compressors.Deflate; +using SharpCompress.Compressors.LZMA; +using SharpCompress.Compressors.Xz; +using Xunit; + +namespace SharpCompress.Test; + +public class ExceptionHierarchyTests +{ + [Fact] + public void AllSharpCompressExceptions_InheritFromSharpCompressException() + { + // Verify that ArchiveException inherits from SharpCompressException + Assert.True(typeof(SharpCompressException).IsAssignableFrom(typeof(ArchiveException))); + + // Verify that ExtractionException inherits from SharpCompressException + Assert.True(typeof(SharpCompressException).IsAssignableFrom(typeof(ExtractionException))); + + // Verify that InvalidFormatException inherits from SharpCompressException (through ExtractionException) + Assert.True( + typeof(SharpCompressException).IsAssignableFrom(typeof(InvalidFormatException)) + ); + + // Verify that CryptographicException inherits from SharpCompressException + Assert.True( + typeof(SharpCompressException).IsAssignableFrom(typeof(CryptographicException)) + ); + + // Verify that IncompleteArchiveException inherits from SharpCompressException (through ArchiveException) + Assert.True( + typeof(SharpCompressException).IsAssignableFrom(typeof(IncompleteArchiveException)) + ); + + // Verify that ReaderCancelledException inherits from SharpCompressException + Assert.True( + typeof(SharpCompressException).IsAssignableFrom(typeof(ReaderCancelledException)) + ); + + // Verify that MultipartStreamRequiredException inherits from SharpCompressException (through ExtractionException) + Assert.True( + typeof(SharpCompressException).IsAssignableFrom( + typeof(MultipartStreamRequiredException) + ) + ); + + // Verify that MultiVolumeExtractionException inherits from SharpCompressException (through ExtractionException) + Assert.True( + typeof(SharpCompressException).IsAssignableFrom(typeof(MultiVolumeExtractionException)) + ); + + // Verify that ZlibException inherits from SharpCompressException + Assert.True(typeof(SharpCompressException).IsAssignableFrom(typeof(ZlibException))); + + // Verify that XZIndexMarkerReachedException inherits from SharpCompressException + Assert.True( + typeof(SharpCompressException).IsAssignableFrom(typeof(XZIndexMarkerReachedException)) + ); + } + + [Fact] + public void SharpCompressException_CanBeCaughtByBaseType() + { + // Test that a derived exception can be caught as SharpCompressException + var exception = new InvalidFormatException("Test message"); + var caughtException = false; + + try + { + throw exception; + } + catch (SharpCompressException ex) + { + caughtException = true; + Assert.Same(exception, ex); + } + + Assert.True(caughtException, "Exception should have been caught as SharpCompressException"); + } + + [Fact] + public void InternalLzmaExceptions_InheritFromSharpCompressException() + { + // Use reflection to verify internal exception types + var dataErrorExceptionType = Type.GetType( + "SharpCompress.Compressors.LZMA.DataErrorException, SharpCompress" + ); + Assert.NotNull(dataErrorExceptionType); + Assert.True(typeof(SharpCompressException).IsAssignableFrom(dataErrorExceptionType)); + + var invalidParamExceptionType = Type.GetType( + "SharpCompress.Compressors.LZMA.InvalidParamException, SharpCompress" + ); + Assert.NotNull(invalidParamExceptionType); + Assert.True(typeof(SharpCompressException).IsAssignableFrom(invalidParamExceptionType)); + } + + [Fact] + public void ExceptionConstructors_WorkCorrectly() + { + // Test parameterless constructor + var ex1 = new SharpCompressException(); + Assert.NotNull(ex1); + + // Test message constructor + var ex2 = new SharpCompressException("Test message"); + Assert.Equal("Test message", ex2.Message); + + // Test message and inner exception constructor + var inner = new InvalidOperationException("Inner"); + var ex3 = new SharpCompressException("Test message", inner); + Assert.Equal("Test message", ex3.Message); + Assert.Same(inner, ex3.InnerException); + } +} diff --git a/tests/SharpCompress.Test/ExtractionTests.cs b/tests/SharpCompress.Test/ExtractionTests.cs new file mode 100644 index 00000000..04b5b08f --- /dev/null +++ b/tests/SharpCompress.Test/ExtractionTests.cs @@ -0,0 +1,99 @@ +using System; +using System.IO; +using SharpCompress.Common; +using SharpCompress.Readers; +using SharpCompress.Writers; +using SharpCompress.Writers.Zip; +using Xunit; + +namespace SharpCompress.Test; + +public class ExtractionTests : TestBase +{ + [Fact] + public void Extraction_ShouldHandleCaseInsensitivePathsOnWindows() + { + // This test validates that extraction succeeds when Path.GetFullPath returns paths + // with casing that matches the platform's file system behavior. On Windows, + // Path.GetFullPath can return different casing than the actual directory on disk + // (e.g., "system32" vs "System32"), and the extraction should succeed because + // Windows file systems are case-insensitive. On Unix-like systems, this test + // verifies that the case-sensitive comparison is used correctly. + + var testArchive = Path.Combine(SCRATCH2_FILES_PATH, "test-extraction.zip"); + var extractPath = SCRATCH_FILES_PATH; + + // Create a simple test archive with a single file + using (var stream = File.Create(testArchive)) + { + using var writer = (ZipWriter) + WriterFactory.Open(stream, ArchiveType.Zip, CompressionType.Deflate); + + // Create a test file to add to the archive + var testFilePath = Path.Combine(SCRATCH2_FILES_PATH, "testfile.txt"); + File.WriteAllText(testFilePath, "Test content"); + + writer.Write("testfile.txt", testFilePath); + } + + // Extract the archive - this should succeed regardless of path casing + using (var stream = File.OpenRead(testArchive)) + { + using var reader = ReaderFactory.Open(stream); + + // This should not throw an exception even if Path.GetFullPath returns + // a path with different casing than the actual directory + var exception = Record.Exception(() => + reader.WriteAllToDirectory( + extractPath, + new ExtractionOptions { ExtractFullPath = false, Overwrite = true } + ) + ); + + Assert.Null(exception); + } + + // Verify the file was extracted successfully + var extractedFile = Path.Combine(extractPath, "testfile.txt"); + Assert.True(File.Exists(extractedFile)); + Assert.Equal("Test content", File.ReadAllText(extractedFile)); + } + + [Fact] + public void Extraction_ShouldPreventPathTraversalAttacks() + { + // This test ensures that the security check still works to prevent + // path traversal attacks (e.g., using "../" to escape the destination directory) + + var testArchive = Path.Combine(SCRATCH2_FILES_PATH, "test-traversal.zip"); + var extractPath = SCRATCH_FILES_PATH; + + // Create a test archive with a path traversal attempt + using (var stream = File.Create(testArchive)) + { + using var writer = (ZipWriter) + WriterFactory.Open(stream, ArchiveType.Zip, CompressionType.Deflate); + + var testFilePath = Path.Combine(SCRATCH2_FILES_PATH, "testfile2.txt"); + File.WriteAllText(testFilePath, "Test content"); + + // Try to write with a path that attempts to escape the destination directory + writer.Write("../../evil.txt", testFilePath); + } + + // Extract the archive - this should throw an exception for path traversal + using (var stream = File.OpenRead(testArchive)) + { + using var reader = ReaderFactory.Open(stream); + + var exception = Assert.Throws(() => + reader.WriteAllToDirectory( + extractPath, + new ExtractionOptions { ExtractFullPath = true, Overwrite = true } + ) + ); + + Assert.Contains("outside of the destination", exception.Message); + } + } +} diff --git a/tests/SharpCompress.Test/GZip/GZipArchiveDirectoryTests.cs b/tests/SharpCompress.Test/GZip/GZipArchiveDirectoryTests.cs new file mode 100644 index 00000000..201effcb --- /dev/null +++ b/tests/SharpCompress.Test/GZip/GZipArchiveDirectoryTests.cs @@ -0,0 +1,19 @@ +using System; +using System.IO; +using SharpCompress.Archives.GZip; +using Xunit; + +namespace SharpCompress.Test.GZip; + +public class GZipArchiveDirectoryTests : TestBase +{ + [Fact] + public void GZipArchive_AddDirectoryEntry_ThrowsNotSupportedException() + { + using var archive = GZipArchive.Create(); + + Assert.Throws(() => + archive.AddDirectoryEntry("test-dir", DateTime.Now) + ); + } +} diff --git a/tests/SharpCompress.Test/GZip/GZipWriterDirectoryTests.cs b/tests/SharpCompress.Test/GZip/GZipWriterDirectoryTests.cs new file mode 100644 index 00000000..5d51c441 --- /dev/null +++ b/tests/SharpCompress.Test/GZip/GZipWriterDirectoryTests.cs @@ -0,0 +1,19 @@ +using System; +using System.IO; +using SharpCompress.Common; +using SharpCompress.Writers.GZip; +using Xunit; + +namespace SharpCompress.Test.GZip; + +public class GZipWriterDirectoryTests : TestBase +{ + [Fact] + public void GZipWriter_WriteDirectory_ThrowsNotSupportedException() + { + using var memoryStream = new MemoryStream(); + using var writer = new GZipWriter(memoryStream, new GZipWriterOptions()); + + Assert.Throws(() => writer.WriteDirectory("test-dir", DateTime.Now)); + } +} diff --git a/tests/SharpCompress.Test/Tar/TarArchiveDirectoryTests.cs b/tests/SharpCompress.Test/Tar/TarArchiveDirectoryTests.cs new file mode 100644 index 00000000..3046246d --- /dev/null +++ b/tests/SharpCompress.Test/Tar/TarArchiveDirectoryTests.cs @@ -0,0 +1,112 @@ +using System; +using System.IO; +using System.Linq; +using SharpCompress.Archives.Tar; +using SharpCompress.Common; +using Xunit; + +namespace SharpCompress.Test.Tar; + +public class TarArchiveDirectoryTests : TestBase +{ + [Fact] + public void TarArchive_AddDirectoryEntry_CreatesDirectoryEntry() + { + using var archive = TarArchive.Create(); + + archive.AddDirectoryEntry("test-dir", DateTime.Now); + + var entries = archive.Entries.ToList(); + Assert.Single(entries); + Assert.Equal("test-dir", entries[0].Key); + Assert.True(entries[0].IsDirectory); + } + + [Fact] + public void TarArchive_AddDirectoryEntry_MultipleDirectories() + { + using var archive = TarArchive.Create(); + + archive.AddDirectoryEntry("dir1", DateTime.Now); + archive.AddDirectoryEntry("dir2", DateTime.Now); + archive.AddDirectoryEntry("dir1/subdir", DateTime.Now); + + var entries = archive.Entries.OrderBy(e => e.Key).ToList(); + Assert.Equal(3, entries.Count); + Assert.True(entries.All(e => e.IsDirectory)); + } + + [Fact] + public void TarArchive_AddDirectoryEntry_MixedWithFiles() + { + using var archive = TarArchive.Create(); + + archive.AddDirectoryEntry("dir1", DateTime.Now); + + using var contentStream = new MemoryStream( + System.Text.Encoding.UTF8.GetBytes("test content") + ); + archive.AddEntry("dir1/file.txt", contentStream, false, contentStream.Length, DateTime.Now); + + archive.AddDirectoryEntry("dir2", DateTime.Now); + + var entries = archive.Entries.OrderBy(e => e.Key).ToList(); + Assert.Equal(3, entries.Count); + Assert.True(entries[0].IsDirectory); + Assert.False(entries[1].IsDirectory); + Assert.True(entries[2].IsDirectory); + } + + [Fact] + public void TarArchive_AddDirectoryEntry_SaveAndReload() + { + var scratchPath = Path.Combine(SCRATCH_FILES_PATH, "tar-directory-test.tar"); + + using (var archive = TarArchive.Create()) + { + archive.AddDirectoryEntry("dir1", DateTime.Now); + archive.AddDirectoryEntry("dir2", DateTime.Now); + + using var contentStream = new MemoryStream( + System.Text.Encoding.UTF8.GetBytes("test content") + ); + archive.AddEntry( + "dir1/file.txt", + contentStream, + false, + contentStream.Length, + DateTime.Now + ); + + using (var fileStream = File.Create(scratchPath)) + { + archive.SaveTo(fileStream, CompressionType.None); + } + } + + using (var archive = TarArchive.Open(scratchPath)) + { + var entries = archive.Entries.OrderBy(e => e.Key).ToList(); + Assert.Equal(3, entries.Count); + + Assert.Equal("dir1/", entries[0].Key); + Assert.True(entries[0].IsDirectory); + + Assert.Equal("dir1/file.txt", entries[1].Key); + Assert.False(entries[1].IsDirectory); + + Assert.Equal("dir2/", entries[2].Key); + Assert.True(entries[2].IsDirectory); + } + } + + [Fact] + public void TarArchive_AddDirectoryEntry_DuplicateKey_ThrowsException() + { + using var archive = TarArchive.Create(); + + archive.AddDirectoryEntry("test-dir", DateTime.Now); + + Assert.Throws(() => archive.AddDirectoryEntry("test-dir", DateTime.Now)); + } +} diff --git a/tests/SharpCompress.Test/Tar/TarWriterDirectoryTests.cs b/tests/SharpCompress.Test/Tar/TarWriterDirectoryTests.cs new file mode 100644 index 00000000..e0b5d821 --- /dev/null +++ b/tests/SharpCompress.Test/Tar/TarWriterDirectoryTests.cs @@ -0,0 +1,164 @@ +using System; +using System.IO; +using System.Linq; +using SharpCompress.Archives.Tar; +using SharpCompress.Common; +using SharpCompress.Writers.Tar; +using Xunit; + +namespace SharpCompress.Test.Tar; + +public class TarWriterDirectoryTests : TestBase +{ + [Fact] + public void TarWriter_WriteDirectory_CreatesDirectoryEntry() + { + using var memoryStream = new MemoryStream(); + using ( + var writer = new TarWriter( + memoryStream, + new TarWriterOptions(CompressionType.None, true) + ) + ) + { + writer.WriteDirectory("test-dir", DateTime.Now); + } + + memoryStream.Position = 0; + using var archive = TarArchive.Open(memoryStream); + var entries = archive.Entries.ToList(); + + Assert.Single(entries); + Assert.Equal("test-dir/", entries[0].Key); + Assert.True(entries[0].IsDirectory); + } + + [Fact] + public void TarWriter_WriteDirectory_WithTrailingSlash() + { + using var memoryStream = new MemoryStream(); + using ( + var writer = new TarWriter( + memoryStream, + new TarWriterOptions(CompressionType.None, true) + ) + ) + { + writer.WriteDirectory("test-dir/", DateTime.Now); + } + + memoryStream.Position = 0; + using var archive = TarArchive.Open(memoryStream); + var entries = archive.Entries.ToList(); + + Assert.Single(entries); + Assert.Equal("test-dir/", entries[0].Key); + Assert.True(entries[0].IsDirectory); + } + + [Fact] + public void TarWriter_WriteDirectory_WithBackslash() + { + using var memoryStream = new MemoryStream(); + using ( + var writer = new TarWriter( + memoryStream, + new TarWriterOptions(CompressionType.None, true) + ) + ) + { + writer.WriteDirectory("test-dir\\subdir", DateTime.Now); + } + + memoryStream.Position = 0; + using var archive = TarArchive.Open(memoryStream); + var entries = archive.Entries.ToList(); + + Assert.Single(entries); + Assert.Equal("test-dir/subdir/", entries[0].Key); + Assert.True(entries[0].IsDirectory); + } + + [Fact] + public void TarWriter_WriteDirectory_EmptyString_IsSkipped() + { + using var memoryStream = new MemoryStream(); + using ( + var writer = new TarWriter( + memoryStream, + new TarWriterOptions(CompressionType.None, true) + ) + ) + { + writer.WriteDirectory("", DateTime.Now); + } + + memoryStream.Position = 0; + using var archive = TarArchive.Open(memoryStream); + + Assert.Empty(archive.Entries); + } + + [Fact] + public void TarWriter_WriteDirectory_MultipleDirectories() + { + using var memoryStream = new MemoryStream(); + using ( + var writer = new TarWriter( + memoryStream, + new TarWriterOptions(CompressionType.None, true) + ) + ) + { + writer.WriteDirectory("dir1", DateTime.Now); + writer.WriteDirectory("dir2", DateTime.Now); + writer.WriteDirectory("dir1/subdir", DateTime.Now); + } + + memoryStream.Position = 0; + using var archive = TarArchive.Open(memoryStream); + var entries = archive.Entries.OrderBy(e => e.Key).ToList(); + + Assert.Equal(3, entries.Count); + Assert.Equal("dir1/", entries[0].Key); + Assert.True(entries[0].IsDirectory); + Assert.Equal("dir1/subdir/", entries[1].Key); + Assert.True(entries[1].IsDirectory); + Assert.Equal("dir2/", entries[2].Key); + Assert.True(entries[2].IsDirectory); + } + + [Fact] + public void TarWriter_WriteDirectory_MixedWithFiles() + { + using var memoryStream = new MemoryStream(); + using ( + var writer = new TarWriter( + memoryStream, + new TarWriterOptions(CompressionType.None, true) + ) + ) + { + writer.WriteDirectory("dir1", DateTime.Now); + + using var contentStream = new MemoryStream( + System.Text.Encoding.UTF8.GetBytes("test content") + ); + writer.Write("dir1/file.txt", contentStream, DateTime.Now); + + writer.WriteDirectory("dir2", DateTime.Now); + } + + memoryStream.Position = 0; + using var archive = TarArchive.Open(memoryStream); + var entries = archive.Entries.OrderBy(e => e.Key).ToList(); + + Assert.Equal(3, entries.Count); + Assert.Equal("dir1/", entries[0].Key); + Assert.True(entries[0].IsDirectory); + Assert.Equal("dir1/file.txt", entries[1].Key); + Assert.False(entries[1].IsDirectory); + Assert.Equal("dir2/", entries[2].Key); + Assert.True(entries[2].IsDirectory); + } +} diff --git a/tests/SharpCompress.Test/Zip/ZipArchiveDirectoryTests.cs b/tests/SharpCompress.Test/Zip/ZipArchiveDirectoryTests.cs new file mode 100644 index 00000000..45bab5e3 --- /dev/null +++ b/tests/SharpCompress.Test/Zip/ZipArchiveDirectoryTests.cs @@ -0,0 +1,112 @@ +using System; +using System.IO; +using System.Linq; +using SharpCompress.Archives.Zip; +using SharpCompress.Common; +using Xunit; + +namespace SharpCompress.Test.Zip; + +public class ZipArchiveDirectoryTests : TestBase +{ + [Fact] + public void ZipArchive_AddDirectoryEntry_CreatesDirectoryEntry() + { + using var archive = ZipArchive.Create(); + + archive.AddDirectoryEntry("test-dir", DateTime.Now); + + var entries = archive.Entries.ToList(); + Assert.Single(entries); + Assert.Equal("test-dir", entries[0].Key); + Assert.True(entries[0].IsDirectory); + } + + [Fact] + public void ZipArchive_AddDirectoryEntry_MultipleDirectories() + { + using var archive = ZipArchive.Create(); + + archive.AddDirectoryEntry("dir1", DateTime.Now); + archive.AddDirectoryEntry("dir2", DateTime.Now); + archive.AddDirectoryEntry("dir1/subdir", DateTime.Now); + + var entries = archive.Entries.OrderBy(e => e.Key).ToList(); + Assert.Equal(3, entries.Count); + Assert.True(entries.All(e => e.IsDirectory)); + } + + [Fact] + public void ZipArchive_AddDirectoryEntry_MixedWithFiles() + { + using var archive = ZipArchive.Create(); + + archive.AddDirectoryEntry("dir1", DateTime.Now); + + using var contentStream = new MemoryStream( + System.Text.Encoding.UTF8.GetBytes("test content") + ); + archive.AddEntry("dir1/file.txt", contentStream, false, contentStream.Length, DateTime.Now); + + archive.AddDirectoryEntry("dir2", DateTime.Now); + + var entries = archive.Entries.OrderBy(e => e.Key).ToList(); + Assert.Equal(3, entries.Count); + Assert.True(entries[0].IsDirectory); + Assert.False(entries[1].IsDirectory); + Assert.True(entries[2].IsDirectory); + } + + [Fact] + public void ZipArchive_AddDirectoryEntry_SaveAndReload() + { + var scratchPath = Path.Combine(SCRATCH_FILES_PATH, "zip-directory-test.zip"); + + using (var archive = ZipArchive.Create()) + { + archive.AddDirectoryEntry("dir1", DateTime.Now); + archive.AddDirectoryEntry("dir2", DateTime.Now); + + using var contentStream = new MemoryStream( + System.Text.Encoding.UTF8.GetBytes("test content") + ); + archive.AddEntry( + "dir1/file.txt", + contentStream, + false, + contentStream.Length, + DateTime.Now + ); + + using (var fileStream = File.Create(scratchPath)) + { + archive.SaveTo(fileStream, CompressionType.Deflate); + } + } + + using (var archive = ZipArchive.Open(scratchPath)) + { + var entries = archive.Entries.OrderBy(e => e.Key).ToList(); + Assert.Equal(3, entries.Count); + + Assert.Equal("dir1/", entries[0].Key); + Assert.True(entries[0].IsDirectory); + + Assert.Equal("dir1/file.txt", entries[1].Key); + Assert.False(entries[1].IsDirectory); + + Assert.Equal("dir2/", entries[2].Key); + Assert.True(entries[2].IsDirectory); + } + } + + [Fact] + public void ZipArchive_AddDirectoryEntry_DuplicateKey_ThrowsException() + { + using var archive = ZipArchive.Create(); + + archive.AddDirectoryEntry("test-dir", DateTime.Now); + + Assert.Throws(() => archive.AddDirectoryEntry("test-dir", DateTime.Now)); + } +} diff --git a/tests/SharpCompress.Test/Zip/ZipWriterDirectoryTests.cs b/tests/SharpCompress.Test/Zip/ZipWriterDirectoryTests.cs new file mode 100644 index 00000000..a3b2788e --- /dev/null +++ b/tests/SharpCompress.Test/Zip/ZipWriterDirectoryTests.cs @@ -0,0 +1,146 @@ +using System; +using System.IO; +using System.Linq; +using SharpCompress.Archives.Zip; +using SharpCompress.Common; +using SharpCompress.Writers.Zip; +using Xunit; + +namespace SharpCompress.Test.Zip; + +public class ZipWriterDirectoryTests : TestBase +{ + [Fact] + public void ZipWriter_WriteDirectory_CreatesDirectoryEntry() + { + using var memoryStream = new MemoryStream(); + using ( + var writer = new ZipWriter(memoryStream, new ZipWriterOptions(CompressionType.Deflate)) + ) + { + writer.WriteDirectory("test-dir", DateTime.Now); + } + + memoryStream.Position = 0; + using var archive = ZipArchive.Open(memoryStream); + var entries = archive.Entries.ToList(); + + Assert.Single(entries); + Assert.Equal("test-dir/", entries[0].Key); + Assert.True(entries[0].IsDirectory); + } + + [Fact] + public void ZipWriter_WriteDirectory_WithTrailingSlash() + { + using var memoryStream = new MemoryStream(); + using ( + var writer = new ZipWriter(memoryStream, new ZipWriterOptions(CompressionType.Deflate)) + ) + { + writer.WriteDirectory("test-dir/", DateTime.Now); + } + + memoryStream.Position = 0; + using var archive = ZipArchive.Open(memoryStream); + var entries = archive.Entries.ToList(); + + Assert.Single(entries); + Assert.Equal("test-dir/", entries[0].Key); + Assert.True(entries[0].IsDirectory); + } + + [Fact] + public void ZipWriter_WriteDirectory_WithBackslash() + { + using var memoryStream = new MemoryStream(); + using ( + var writer = new ZipWriter(memoryStream, new ZipWriterOptions(CompressionType.Deflate)) + ) + { + writer.WriteDirectory("test-dir\\subdir", DateTime.Now); + } + + memoryStream.Position = 0; + using var archive = ZipArchive.Open(memoryStream); + var entries = archive.Entries.ToList(); + + Assert.Single(entries); + Assert.Equal("test-dir/subdir/", entries[0].Key); + Assert.True(entries[0].IsDirectory); + } + + [Fact] + public void ZipWriter_WriteDirectory_EmptyString_IsSkipped() + { + using var memoryStream = new MemoryStream(); + using ( + var writer = new ZipWriter(memoryStream, new ZipWriterOptions(CompressionType.Deflate)) + ) + { + writer.WriteDirectory("", DateTime.Now); + } + + memoryStream.Position = 0; + using var archive = ZipArchive.Open(memoryStream); + + Assert.Empty(archive.Entries); + } + + [Fact] + public void ZipWriter_WriteDirectory_MultipleDirectories() + { + using var memoryStream = new MemoryStream(); + using ( + var writer = new ZipWriter(memoryStream, new ZipWriterOptions(CompressionType.Deflate)) + ) + { + writer.WriteDirectory("dir1", DateTime.Now); + writer.WriteDirectory("dir2", DateTime.Now); + writer.WriteDirectory("dir1/subdir", DateTime.Now); + } + + memoryStream.Position = 0; + using var archive = ZipArchive.Open(memoryStream); + var entries = archive.Entries.OrderBy(e => e.Key).ToList(); + + Assert.Equal(3, entries.Count); + Assert.Equal("dir1/", entries[0].Key); + Assert.True(entries[0].IsDirectory); + Assert.Equal("dir1/subdir/", entries[1].Key); + Assert.True(entries[1].IsDirectory); + Assert.Equal("dir2/", entries[2].Key); + Assert.True(entries[2].IsDirectory); + } + + [Fact] + public void ZipWriter_WriteDirectory_MixedWithFiles() + { + using var memoryStream = new MemoryStream(); + using ( + var writer = new ZipWriter(memoryStream, new ZipWriterOptions(CompressionType.Deflate)) + ) + { + writer.WriteDirectory("dir1", DateTime.Now); + + using var contentStream = new MemoryStream( + System.Text.Encoding.UTF8.GetBytes("test content") + ); + writer.Write("dir1/file.txt", contentStream, DateTime.Now); + + writer.WriteDirectory("dir2", DateTime.Now); + } + + memoryStream.Position = 0; + using var archive = ZipArchive.Open(memoryStream); + var entries = archive.Entries.OrderBy(e => e.Key).ToList(); + + Assert.Equal(3, entries.Count); + Assert.Equal("dir1/", entries[0].Key); + Assert.True(entries[0].IsDirectory); + Assert.Equal("dir1/file.txt", entries[1].Key); + Assert.False(entries[1].IsDirectory); + Assert.Equal("dir2/", entries[2].Key); + Assert.True(entries[2].IsDirectory); + } +}