From 9cf0ad9fe8207cd106626a635730215d269e9c0f Mon Sep 17 00:00:00 2001 From: Natalia Portillo Date: Wed, 15 Apr 2026 17:44:07 +0100 Subject: [PATCH] Add ZIP archive support with entry handling, decompression, and metadata features --- .../Localization/Localization.Designer.cs | 42 + .../Localization/Localization.es.resx | 21 + Aaru.Archives/Localization/Localization.resx | 21 + Aaru.Archives/Zip/Consts.cs | 54 ++ Aaru.Archives/Zip/Enums.cs | 70 ++ Aaru.Archives/Zip/Files.cs | 275 +++++++ Aaru.Archives/Zip/Info.cs | 83 ++ Aaru.Archives/Zip/Open.cs | 719 ++++++++++++++++++ Aaru.Archives/Zip/Structs.cs | 32 + Aaru.Archives/Zip/Xattrs.cs | 44 ++ Aaru.Archives/Zip/Zip.cs | 37 + README.md | 1 + 12 files changed, 1399 insertions(+) create mode 100644 Aaru.Archives/Zip/Consts.cs create mode 100644 Aaru.Archives/Zip/Enums.cs create mode 100644 Aaru.Archives/Zip/Files.cs create mode 100644 Aaru.Archives/Zip/Info.cs create mode 100644 Aaru.Archives/Zip/Open.cs create mode 100644 Aaru.Archives/Zip/Structs.cs create mode 100644 Aaru.Archives/Zip/Xattrs.cs create mode 100644 Aaru.Archives/Zip/Zip.cs diff --git a/Aaru.Archives/Localization/Localization.Designer.cs b/Aaru.Archives/Localization/Localization.Designer.cs index ad1da29b2..ddc44da22 100644 --- a/Aaru.Archives/Localization/Localization.Designer.cs +++ b/Aaru.Archives/Localization/Localization.Designer.cs @@ -836,5 +836,47 @@ namespace Aaru.Archives { return ResourceManager.GetString("RAR_has_recovery_record", resourceCulture); } } + + internal static string Zip_Name { + get { + return ResourceManager.GetString("Zip_Name", resourceCulture); + } + } + + internal static string ZIP_archive { + get { + return ResourceManager.GetString("ZIP_archive", resourceCulture); + } + } + + internal static string ZIP_entries_0 { + get { + return ResourceManager.GetString("ZIP_entries_0", resourceCulture); + } + } + + internal static string ZIP_files_0 { + get { + return ResourceManager.GetString("ZIP_files_0", resourceCulture); + } + } + + internal static string ZIP_directories_0 { + get { + return ResourceManager.GetString("ZIP_directories_0", resourceCulture); + } + } + + internal static string ZIP_encrypted_entries_0 { + get { + return ResourceManager.GetString("ZIP_encrypted_entries_0", resourceCulture); + } + } + + internal static string ZIP_comment_0 { + get { + return ResourceManager.GetString("ZIP_comment_0", resourceCulture); + } + } } } diff --git a/Aaru.Archives/Localization/Localization.es.resx b/Aaru.Archives/Localization/Localization.es.resx index acc8442d6..8766033be 100644 --- a/Aaru.Archives/Localization/Localization.es.resx +++ b/Aaru.Archives/Localization/Localization.es.resx @@ -411,4 +411,25 @@ [slateblue1]El archivo tiene [green]registro de recuperación[/][/] + + ZIP + + + [bold][blue]Archivo ZIP[/][/] + + + [slateblue1]Entradas: [green]{0}[/][/] + + + [slateblue1]Archivos: [green]{0}[/][/] + + + [slateblue1]Directorios: [green]{0}[/][/] + + + [slateblue1]Entradas cifradas: [green]{0}[/][/] + + + [slateblue1]Comentario del archivo: [green]{0}[/][/] + \ No newline at end of file diff --git a/Aaru.Archives/Localization/Localization.resx b/Aaru.Archives/Localization/Localization.resx index b2ba25aeb..43138538b 100644 --- a/Aaru.Archives/Localization/Localization.resx +++ b/Aaru.Archives/Localization/Localization.resx @@ -419,4 +419,25 @@ [slateblue1]Archive has [green]recovery record[/][/] + + ZIP + + + [bold][blue]ZIP archive[/][/] + + + [slateblue1]Entries: [green]{0}[/][/] + + + [slateblue1]Files: [green]{0}[/][/] + + + [slateblue1]Directories: [green]{0}[/][/] + + + [slateblue1]Encrypted entries: [green]{0}[/][/] + + + [slateblue1]Archive comment: [green]{0}[/][/] + \ No newline at end of file diff --git a/Aaru.Archives/Zip/Consts.cs b/Aaru.Archives/Zip/Consts.cs new file mode 100644 index 000000000..571e6323d --- /dev/null +++ b/Aaru.Archives/Zip/Consts.cs @@ -0,0 +1,54 @@ +namespace Aaru.Archives; + +public sealed partial class Zip +{ + /// Local file header signature: PK\x03\x04 + const uint LOCAL_HEADER_SIG = 0x04034B50; + /// Central directory file header signature: PK\x01\x02 + const uint CENTRAL_DIR_SIG = 0x02014B50; + /// End of central directory record signature: PK\x05\x06 + const uint EOCD_SIG = 0x06054B50; + /// ZIP64 end of central directory record signature: PK\x06\x06 + const uint ZIP64_EOCD_SIG = 0x06064B50; + /// ZIP64 end of central directory locator signature: PK\x06\x07 + const uint ZIP64_LOCATOR_SIG = 0x07064B50; + /// Data descriptor signature: PK\x07\x08 + const uint DATA_DESCRIPTOR_SIG = 0x08074B50; + + // Extra field header IDs + const ushort EXTRA_ZIP64 = 0x0001; + const ushort EXTRA_EXT_TIMESTAMP = 0x5455; + const ushort EXTRA_UNIX1 = 0x5855; + const ushort EXTRA_UNIX2 = 0x7855; + const ushort EXTRA_UNIX3 = 0x7875; + const ushort EXTRA_UNICODE_PATH = 0x7075; + const ushort EXTRA_WINZIP_AES = 0x9901; + + // General purpose bit flags + const ushort FLAG_ENCRYPTED = 0x0001; + const ushort FLAG_IMPLODE_8K = 0x0002; + const ushort FLAG_IMPLODE_LITERALS = 0x0004; + const ushort FLAG_DATA_DESCRIPTOR = 0x0008; + const ushort FLAG_UTF8 = 0x0800; + + /// Minimum ZIP file size: empty EOCD record (22 bytes) + const int MIN_FILE_SIZE = 22; + + /// Size of a fixed central directory entry (without variable-length fields) + const int CENTRAL_DIR_ENTRY_SIZE = 46; + + /// Size of a fixed local file header (without variable-length fields) + const int LOCAL_HEADER_SIZE = 30; + + /// Size of a fixed End of Central Directory record (without comment) + const int EOCD_SIZE = 22; + + /// Chunk size for backward EOCD search + const int EOCD_SEARCH_CHUNK = 0x10000; + + /// Directory attribute in DOS external file attributes + const uint ATTR_DIRECTORY = 0x10; + + /// CRC32 polynomial used in ZIP format + const uint CRC_POLY = 0xEDB88320; +} \ No newline at end of file diff --git a/Aaru.Archives/Zip/Enums.cs b/Aaru.Archives/Zip/Enums.cs new file mode 100644 index 000000000..5fd747ff0 --- /dev/null +++ b/Aaru.Archives/Zip/Enums.cs @@ -0,0 +1,70 @@ +namespace Aaru.Archives; + +public sealed partial class Zip +{ +#region CompressionMethod enum + + enum CompressionMethod : ushort + { + /// No compression + Stored = 0, + /// LZW-based shrink + Shrink = 1, + /// Reduce with compression factor 1 + Reduce1 = 2, + /// Reduce with compression factor 2 + Reduce2 = 3, + /// Reduce with compression factor 3 + Reduce3 = 4, + /// Reduce with compression factor 4 + Reduce4 = 5, + /// Shannon-Fano + LZSS imploding + Implode = 6, + /// Deflate (RFC 1951) + Deflate = 8, + /// Deflate64 / Enhanced Deflate (64KB window) + Deflate64 = 9, + /// BZip2 + BZip2 = 12, + /// LZMA + Lzma = 14, + /// WinZip JPEG compressed data + WinZipJpeg = 96, + /// WinZip WavPack compressed data + WavPack = 97, + /// PPMd version I, Rev 1 + PPMd = 98, + /// WinZip AES encryption marker (actual method in extra field) + WinZipAes = 99 + } + +#endregion + +#region HostOs enum + + enum HostOs : byte + { + MsDos = 0, + Amiga = 1, + OpenVms = 2, + Unix = 3, + VmCms = 4, + AtariSt = 5, + Os2Hpfs = 6, + Macintosh = 7, + ZSystem = 8, + CpM = 9, + WindowsNtfs = 10, + Mvs = 11, + Vse = 12, + AcornRisc = 13, + Vfat = 14, + AltMvs = 15, + BeOs = 16, + Tandem = 17, + Os400 = 18, + OsX = 19 + } + +#endregion +} \ No newline at end of file diff --git a/Aaru.Archives/Zip/Files.cs b/Aaru.Archives/Zip/Files.cs new file mode 100644 index 000000000..df67e28d5 --- /dev/null +++ b/Aaru.Archives/Zip/Files.cs @@ -0,0 +1,275 @@ +using System; +using System.IO; +using System.IO.Compression; +using Aaru.CommonTypes.Enums; +using Aaru.CommonTypes.Interfaces; +using Aaru.CommonTypes.Structs; +using Aaru.Compression; +using Aaru.Compression.Zip; +using Aaru.Filters; +using Aaru.Helpers.IO; +using BZip2 = Aaru.Compression.BZip2; +using FileAttributes = Aaru.CommonTypes.Structs.FileAttributes; + +namespace Aaru.Archives; + +public sealed partial class Zip +{ +#region IArchive Members + + /// + public ErrorNumber GetFilename(int entryNumber, out string fileName) + { + fileName = null; + + if(!Opened) return ErrorNumber.NotOpened; + + if(entryNumber < 0 || entryNumber >= _entries.Count) return ErrorNumber.OutOfRange; + + fileName = _entries[entryNumber].Filename ?? ""; + + return ErrorNumber.NoError; + } + + /// + public ErrorNumber GetEntryNumber(string fileName, bool caseInsensitiveMatch, out int entryNumber) + { + entryNumber = -1; + + if(!Opened) return ErrorNumber.NotOpened; + + StringComparison comparison = caseInsensitiveMatch + ? StringComparison.CurrentCultureIgnoreCase + : StringComparison.CurrentCulture; + + for(int i = 0, count = _entries.Count; i < count; i++) + { + GetFilename(i, out string name); + + if(name is null) continue; + + if(!name.Equals(fileName, comparison)) continue; + + entryNumber = i; + + return ErrorNumber.NoError; + } + + return ErrorNumber.NoSuchFile; + } + + /// + public ErrorNumber GetCompressedSize(int entryNumber, out long length) + { + length = -1; + + if(!Opened) return ErrorNumber.NotOpened; + + if(entryNumber < 0 || entryNumber >= _entries.Count) return ErrorNumber.OutOfRange; + + length = _entries[entryNumber].CompressedSize; + + return ErrorNumber.NoError; + } + + /// + public ErrorNumber GetUncompressedSize(int entryNumber, out long length) + { + length = -1; + + if(!Opened) return ErrorNumber.NotOpened; + + if(entryNumber < 0 || entryNumber >= _entries.Count) return ErrorNumber.OutOfRange; + + length = _entries[entryNumber].UncompressedSize; + + return ErrorNumber.NoError; + } + + /// + public ErrorNumber Stat(int entryNumber, out FileEntryInfo stat) + { + stat = null; + + if(!Opened) return ErrorNumber.NotOpened; + + if(entryNumber < 0 || entryNumber >= _entries.Count) return ErrorNumber.OutOfRange; + + Entry entry = _entries[entryNumber]; + + stat = new FileEntryInfo + { + Attributes = entry.IsDirectory ? FileAttributes.Directory : FileAttributes.File, + Blocks = entry.UncompressedSize / 512, + BlockSize = 512, + Length = entry.UncompressedSize, + LastWriteTimeUtc = entry.LastWriteTime != default(DateTime) ? entry.LastWriteTime.ToUniversalTime() : null + }; + + if(entry.LastAccessTime != default(DateTime)) stat.AccessTimeUtc = entry.LastAccessTime.ToUniversalTime(); + + if(entry.CreationTime != default(DateTime)) stat.CreationTimeUtc = entry.CreationTime.ToUniversalTime(); + + if(entry.UncompressedSize % 512 != 0) stat.Blocks++; + + return ErrorNumber.NoError; + } + + /// + public ErrorNumber GetEntry(int entryNumber, out IFilter filter) + { + filter = null; + + if(!Opened) return ErrorNumber.NotOpened; + + if(entryNumber < 0 || entryNumber >= _entries.Count) return ErrorNumber.OutOfRange; + + Entry entry = _entries[entryNumber]; + + if(entry.IsDirectory) return ErrorNumber.InvalidArgument; + + if(entry.IsEncrypted) return ErrorNumber.NotSupported; + + Stream stream; + + if(entry.UncompressedSize == 0) + stream = new MemoryStream([]); + else + { + Stream compressedStream = new OffsetStream(new NonClosableStream(_stream), + entry.DataOffset, + entry.DataOffset + entry.CompressedSize - 1); + + switch(entry.Method) + { + case CompressionMethod.Stored: + stream = compressedStream; + + break; + + case CompressionMethod.Shrink: + stream = new ShrinkStream(compressedStream, entry.UncompressedSize); + + break; + + case CompressionMethod.Reduce1: + case CompressionMethod.Reduce2: + case CompressionMethod.Reduce3: + case CompressionMethod.Reduce4: + stream = new ReduceStream(compressedStream, entry.UncompressedSize, (int)entry.Method - 1); + + break; + + case CompressionMethod.Implode: + stream = new ImplodeStream(compressedStream, + entry.UncompressedSize, + (entry.Flags & FLAG_IMPLODE_8K) != 0, + (entry.Flags & FLAG_IMPLODE_LITERALS) != 0); + + break; + + case CompressionMethod.Deflate: + { + // DeflateStream decompresses and we wrap in a MemoryStream for seekability + var decompressed = new byte[entry.UncompressedSize]; + + using(var deflate = new DeflateStream(compressedStream, CompressionMode.Decompress, true)) + { + var totalRead = 0; + + while(totalRead < decompressed.Length) + { + int bytesRead = deflate.Read(decompressed, totalRead, decompressed.Length - totalRead); + + if(bytesRead == 0) break; + + totalRead += bytesRead; + } + } + + stream = new MemoryStream(decompressed); + + break; + } + + case CompressionMethod.Deflate64: + stream = new Deflate64Stream(compressedStream, entry.UncompressedSize); + + break; + + case CompressionMethod.BZip2: + { + var compressedData = new byte[entry.CompressedSize]; + int compRead = compressedStream.Read(compressedData, 0, compressedData.Length); + var decompressedBuf = new byte[entry.UncompressedSize]; + + BZip2.DecodeBuffer(compressedData, decompressedBuf); + + stream = new MemoryStream(decompressedBuf); + + break; + } + + case CompressionMethod.Lzma: + { + // ZIP LZMA header: 2 bytes version, 2 bytes properties size, N bytes properties + var lzmaHeader = new byte[4]; + compressedStream.Read(lzmaHeader, 0, 4); + var propsSize = BitConverter.ToUInt16(lzmaHeader, 2); + + var properties = new byte[propsSize]; + compressedStream.Read(properties, 0, propsSize); + + long remainingCompressed = entry.CompressedSize - 4 - propsSize; + var compressedData = new byte[remainingCompressed]; + compressedStream.Read(compressedData, 0, compressedData.Length); + + var decompressedBuf = new byte[entry.UncompressedSize]; + + LZMA.DecodeBuffer(compressedData, decompressedBuf, properties); + + stream = new MemoryStream(decompressedBuf); + + break; + } + + case CompressionMethod.PPMd: + { + // PPMd info word: 2 bytes + var infoBytes = new byte[2]; + compressedStream.Read(infoBytes, 0, 2); + var info = BitConverter.ToUInt16(infoBytes, 0); + + int maxOrder = (info & 0x0F) + 1; + int subAllocSize = (info >> 4 & 0xFF) + 1 << 20; + bool restoration = (info >> 12 & 0x0F) != 0; + + // Remaining data is the PPMd stream + long remaining = entry.CompressedSize - 2; + + Stream ppmdInput = new OffsetStream(new NonClosableStream(_stream), + entry.DataOffset + 2, + entry.DataOffset + entry.CompressedSize - 1); + + stream = new PpmdStream(ppmdInput, entry.UncompressedSize, maxOrder, subAllocSize, restoration); + + break; + } + + default: + return ErrorNumber.NotSupported; + } + } + + filter = new ZZZNoFilter(); + ErrorNumber errno = filter.Open(stream); + + if(errno == ErrorNumber.NoError) return ErrorNumber.NoError; + + stream.Close(); + + return errno; + } + +#endregion +} \ No newline at end of file diff --git a/Aaru.Archives/Zip/Info.cs b/Aaru.Archives/Zip/Info.cs new file mode 100644 index 000000000..02eb1eb9f --- /dev/null +++ b/Aaru.Archives/Zip/Info.cs @@ -0,0 +1,83 @@ +using System.IO; +using System.Text; +using Aaru.CommonTypes.Enums; +using Aaru.CommonTypes.Interfaces; + +namespace Aaru.Archives; + +public sealed partial class Zip +{ +#region IArchive Members + + /// + public bool Identify(IFilter filter) + { + if(filter.DataForkLength < MIN_FILE_SIZE) return false; + + Stream stream = filter.GetDataForkStream(); + + if(stream.Length < 4) return false; + + stream.Position = 0; + + var sig = new byte[8]; + int read = stream.Read(sig, 0, sig.Length < stream.Length ? sig.Length : (int)stream.Length); + + if(read < 4) return false; + + // PK\x03\x04 at offset 0 + if(sig[0] == 0x50 && sig[1] == 0x4B && sig[2] == 0x03 && sig[3] == 0x04) return true; + + // PK\x05\x06 at offset 0 (empty archive) + if(sig[0] == 0x50 && sig[1] == 0x4B && sig[2] == 0x05 && sig[3] == 0x06) return true; + + // PK\x03\x04 at offset 4 (some SFX variants prepend 4 bytes) + if(read >= 8 && sig[4] == 0x50 && sig[5] == 0x4B && sig[6] == 0x03 && sig[7] == 0x04) return true; + + return false; + } + + /// + public void GetInformation(IFilter filter, Encoding encoding, out string information) + { + information = ""; + + if(filter.DataForkLength < MIN_FILE_SIZE) return; + + ErrorNumber errno = Open(filter, encoding); + + if(errno != ErrorNumber.NoError) return; + + var sb = new StringBuilder(); + sb.AppendLine(Localization.ZIP_archive); + sb.AppendFormat(Localization.ZIP_entries_0, _entries.Count).AppendLine(); + + var directories = 0; + var files = 0; + var encrypted = 0; + + foreach(Entry entry in _entries) + { + if(entry.IsDirectory) + directories++; + else + files++; + + if(entry.IsEncrypted) encrypted++; + } + + sb.AppendFormat(Localization.ZIP_files_0, files).AppendLine(); + + if(directories > 0) sb.AppendFormat(Localization.ZIP_directories_0, directories).AppendLine(); + + if(encrypted > 0) sb.AppendFormat(Localization.ZIP_encrypted_entries_0, encrypted).AppendLine(); + + if(_archiveComment is not null) sb.AppendFormat(Localization.ZIP_comment_0, _archiveComment).AppendLine(); + + information = sb.ToString(); + + Close(); + } + +#endregion +} \ No newline at end of file diff --git a/Aaru.Archives/Zip/Open.cs b/Aaru.Archives/Zip/Open.cs new file mode 100644 index 000000000..b98d2584b --- /dev/null +++ b/Aaru.Archives/Zip/Open.cs @@ -0,0 +1,719 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using Aaru.CommonTypes.Enums; +using Aaru.CommonTypes.Interfaces; + +namespace Aaru.Archives; + +public sealed partial class Zip +{ + List _entries; + + void FindEndOfCentralDirectory(out long eocdOffset, out long zip64LocOffset) + { + eocdOffset = -1; + zip64LocOffset = -1; + + long end = _stream.Length; + + // Search backward in chunks for the EOCD signature + long chunkEnd = end; + + while(chunkEnd > 0) + { + int numBytes = EOCD_SEARCH_CHUNK; + + if(chunkEnd - numBytes < 0) numBytes = (int)chunkEnd; + + long chunkStart = chunkEnd - numBytes; + + // Overlap by 20 bytes to catch ZIP64 locator that might span chunk boundaries + int preservedBytes = chunkStart >= 20 ? 20 : 0; + + var buf = new byte[numBytes]; + _stream.Position = chunkStart; + + int bytesRead = _stream.Read(buf, 0, numBytes); + + if(bytesRead < 4) break; + + // Search for PK\x05\x06 (EOCD signature) + for(int pos = bytesRead - 4; pos >= preservedBytes; pos--) + { + if(buf[pos] != 0x50 || buf[pos + 1] != 0x4B || buf[pos + 2] != 0x05 || buf[pos + 3] != 0x06) continue; + + eocdOffset = chunkStart + pos; + + // Check for ZIP64 end of central directory locator 20 bytes before EOCD + if(pos >= 20 && + buf[pos - 20] == 0x50 && + buf[pos - 19] == 0x4B && + buf[pos - 18] == 0x06 && + buf[pos - 17] == 0x07) + zip64LocOffset = chunkStart + pos - 20; + + return; + } + + chunkEnd -= numBytes - preservedBytes * 2; + } + } + + void ParseCentralDirectory(long eocdOffset, long zip64LocOffset) + { + var reader = new BinaryReader(_stream, Encoding.UTF8, true); + + // Read EOCD record + _stream.Position = eocdOffset + 4; // skip signature + + ushort diskNumber = reader.ReadUInt16(); + ushort centralDirDisk = reader.ReadUInt16(); + ushort entriesOnDisk = reader.ReadUInt16(); + long totalEntries = reader.ReadUInt16(); + long centralDirSize = reader.ReadUInt32(); + long centralDirOffset = reader.ReadUInt32(); + ushort commentLength = reader.ReadUInt16(); + + if(commentLength > 0) + { + byte[] commentBytes = reader.ReadBytes(commentLength); + _archiveComment = Encoding.UTF8.GetString(commentBytes); + } + + // If we have a ZIP64 locator, read the ZIP64 EOCD record + if(zip64LocOffset >= 0) + { + _stream.Position = zip64LocOffset + 4; // skip signature + + uint zip64EocdDisk = reader.ReadUInt32(); + long zip64EocdOffset = reader.ReadInt64(); + + _stream.Position = zip64EocdOffset; + uint zip64Sig = reader.ReadUInt32(); + + if(zip64Sig == ZIP64_EOCD_SIG) + { + reader.ReadInt64(); // size of ZIP64 EOCD record + reader.ReadUInt16(); // version made by + reader.ReadUInt16(); // version needed + reader.ReadUInt32(); // disk number + centralDirDisk = (ushort)reader.ReadUInt32(); + reader.ReadInt64(); // entries on disk + totalEntries = reader.ReadInt64(); + centralDirSize = reader.ReadInt64(); + centralDirOffset = reader.ReadInt64(); + } + } + + // Seek to the start of the central directory + _stream.Position = centralDirOffset; + + for(long i = 0; i < totalEntries; i++) + { + if(_stream.Position >= _stream.Length) break; + + Entry entry = ReadCentralDirectoryRecord(reader); + + if(entry.Filename is null) break; + + long savedPos = _stream.Position; + + // Read local header to get exact data offset + _stream.Position = entry.DataOffset; // locheaderoffset stored temporarily in DataOffset + uint localSig = reader.ReadUInt32(); + + if(localSig == LOCAL_HEADER_SIG || localSig == EOCD_SIG) + { + reader.ReadUInt16(); // version needed + reader.ReadUInt16(); // flags + reader.ReadUInt16(); // compression method + uint localDate = reader.ReadUInt32(); + reader.ReadUInt32(); // crc32 + reader.ReadUInt32(); // compressed size + reader.ReadUInt32(); // uncompressed size + ushort localNameLen = reader.ReadUInt16(); + ushort localExtraLen = reader.ReadUInt16(); + + long dataOffset = _stream.Position + localNameLen + localExtraLen; + + // Parse local extra fields for additional metadata (timestamps, unicode paths, etc.) + if(localNameLen > 0) reader.ReadBytes(localNameLen); + + if(localExtraLen > 0) + { + long extraStart = _stream.Position; + ParseExtraFields(reader, localExtraLen, ref entry); + _stream.Position = extraStart + localExtraLen; + } + + entry.DataOffset = dataOffset; + } + + // Handle overflow for archives with >65535 entries without ZIP64 + if(i == totalEntries - 1 && zip64LocOffset < 0) + { + long remaining = centralDirOffset + centralDirSize - savedPos; + + if(remaining > 65536 * CENTRAL_DIR_ENTRY_SIZE) totalEntries += 65536; + } + + _entries.Add(entry); + + _stream.Position = savedPos; + } + } + + Entry ReadCentralDirectoryRecord(BinaryReader reader) + { + Entry entry = new(); + + uint sig = reader.ReadUInt32(); + + if(sig != CENTRAL_DIR_SIG) return entry; + + byte creatorVersion = reader.ReadByte(); + byte system = reader.ReadByte(); + ushort extractVersion = reader.ReadUInt16(); + ushort flags = reader.ReadUInt16(); + ushort method = reader.ReadUInt16(); + uint dosDateTime = reader.ReadUInt32(); + uint crc32 = reader.ReadUInt32(); + long compSize = reader.ReadUInt32(); + long uncompSize = reader.ReadUInt32(); + ushort nameLength = reader.ReadUInt16(); + ushort extraLength = reader.ReadUInt16(); + ushort commentLength = reader.ReadUInt16(); + ushort startDisk = reader.ReadUInt16(); + ushort intFileAttrib = reader.ReadUInt16(); + uint extFileAttrib = reader.ReadUInt32(); + long locHeaderOff = reader.ReadUInt32(); + + // Read filename + byte[] nameBytes = nameLength > 0 ? reader.ReadBytes(nameLength) : null; + + // Parse extra fields in central directory (primarily for ZIP64 overrides) + int extraRemaining = extraLength; + + while(extraRemaining >= 4) + { + ushort extId = reader.ReadUInt16(); + ushort extSize = reader.ReadUInt16(); + extraRemaining -= 4; + + if(extSize > extraRemaining) break; + + extraRemaining -= extSize; + long nextExtra = _stream.Position + extSize; + + if(extId == EXTRA_ZIP64) + { + if(uncompSize == 0xFFFFFFFF && _stream.Position + 8 <= nextExtra) uncompSize = reader.ReadInt64(); + + if(compSize == 0xFFFFFFFF && _stream.Position + 8 <= nextExtra) compSize = reader.ReadInt64(); + + if(locHeaderOff == 0xFFFFFFFF && _stream.Position + 8 <= nextExtra) locHeaderOff = reader.ReadInt64(); + + if(startDisk == 0xFFFF && _stream.Position + 4 <= nextExtra) startDisk = (ushort)reader.ReadUInt32(); + } + + _stream.Position = nextExtra; + } + + if(extraRemaining > 0) _stream.Position += extraRemaining; + + // Read comment + string comment = null; + + if(commentLength > 0) + { + byte[] commentBytes = reader.ReadBytes(commentLength); + + comment = (flags & FLAG_UTF8) != 0 + ? Encoding.UTF8.GetString(commentBytes) + : _encoding.GetString(commentBytes); + } + + // Decode filename + string filename = null; + + if(nameBytes is not null) + { + filename = (flags & FLAG_UTF8) != 0 ? Encoding.UTF8.GetString(nameBytes) : _encoding.GetString(nameBytes); + + // Normalize path separators + filename = filename.Replace('\\', '/'); + } + + // Detect directory + var isDirectory = false; + + if(filename is not null && filename.EndsWith('/') && uncompSize == 0) isDirectory = true; + + if(system == (byte)HostOs.MsDos && (extFileAttrib & ATTR_DIRECTORY) != 0 && compSize == 0 && uncompSize == 0) + isDirectory = true; + + // Extract Unix permissions from external attributes for Unix-created archives + ushort unixPerms = 0; + + if(system == (byte)HostOs.Unix) + { + var perm = (int)(extFileAttrib >> 16); + + if(perm != 0) unixPerms = (ushort)perm; + } + + entry.Method = (CompressionMethod)method; + entry.Filename = filename; + entry.CompressedSize = compSize; + entry.UncompressedSize = uncompSize; + entry.DataOffset = locHeaderOff; // temporary, resolved to actual data offset later + entry.LastWriteTime = DosToDateTime(dosDateTime); + entry.Crc32 = crc32; + entry.System = (HostOs)system; + entry.ExternalAttributes = extFileAttrib; + entry.UnixPermissions = unixPerms; + entry.Flags = flags; + entry.Comment = comment; + entry.IsDirectory = isDirectory; + entry.IsEncrypted = (flags & FLAG_ENCRYPTED) != 0; + + return entry; + } + + void ParseWithoutCentralDirectory() + { + var reader = new BinaryReader(_stream, Encoding.UTF8, true); + + _stream.Position = 0; + + while(_stream.Position < _stream.Length - 4) + { + uint sig; + + try + { + sig = reader.ReadUInt32(); + } + catch(EndOfStreamException) + { + break; + } + + switch(sig) + { + case LOCAL_HEADER_SIG: + case EOCD_SIG: // kludge for strange archives + { + ushort extractVersion = reader.ReadUInt16(); + ushort flags = reader.ReadUInt16(); + ushort method = reader.ReadUInt16(); + uint dosDateTime = reader.ReadUInt32(); + uint crc32 = reader.ReadUInt32(); + long compSize = reader.ReadUInt32(); + long uncompSize = reader.ReadUInt32(); + ushort nameLength = reader.ReadUInt16(); + ushort extraLength = reader.ReadUInt16(); + + long dataOffset = _stream.Position + nameLength + extraLength; + + byte[] nameBytes = nameLength > 0 ? reader.ReadBytes(nameLength) : null; + + var entry = new Entry + { + Method = (CompressionMethod)method, + Flags = flags, + Crc32 = crc32, + CompressedSize = compSize, + UncompressedSize = uncompSize, + LastWriteTime = DosToDateTime(dosDateTime), + IsEncrypted = (flags & FLAG_ENCRYPTED) != 0 + }; + + // Parse extra fields + if(extraLength > 0) + { + long extraStart = _stream.Position; + + // ZIP64 fields may override sizes + ParseExtraFields(reader, extraLength, ref entry); + + _stream.Position = extraStart + extraLength; + } + + // If data descriptor flag is set, we need to find the sizes after the data + if((flags & FLAG_DATA_DESCRIPTOR) != 0) + { + // Skip to the data descriptor after compressed data + // For streaming archives, scan for the next signature + FindDataDescriptor(reader, ref entry); + } + + // Decode filename + if(nameBytes is not null) + { + entry.Filename = (flags & FLAG_UTF8) != 0 + ? Encoding.UTF8.GetString(nameBytes) + : _encoding.GetString(nameBytes); + + entry.Filename = entry.Filename.Replace('\\', '/'); + + if(entry.Filename.EndsWith('/') && entry.UncompressedSize == 0) entry.IsDirectory = true; + } + + entry.DataOffset = dataOffset; + + long nextPos = (flags & FLAG_DATA_DESCRIPTOR) != 0 + ? _stream.Position + : dataOffset + entry.CompressedSize; + + _entries.Add(entry); + + _stream.Position = nextPos; + + break; + } + + case CENTRAL_DIR_SIG: + // Hit central directory — stop scanning + return; + + default: + // Try to find the next valid entry + if(!FindNextLocalHeader(reader)) return; + + break; + } + } + } + + void ParseExtraFields(BinaryReader reader, int length, ref Entry entry) + { + long end = _stream.Position + length; + + while(_stream.Position + 4 <= end) + { + ushort extId = reader.ReadUInt16(); + ushort extSize = reader.ReadUInt16(); + + if(_stream.Position + extSize > end) break; + + long nextExtra = _stream.Position + extSize; + + switch(extId) + { + case EXTRA_ZIP64: + if(entry.UncompressedSize == 0xFFFFFFFF && _stream.Position + 8 <= nextExtra) + entry.UncompressedSize = reader.ReadInt64(); + + if(entry.CompressedSize == 0xFFFFFFFF && _stream.Position + 8 <= nextExtra) + entry.CompressedSize = reader.ReadInt64(); + + break; + + case EXTRA_EXT_TIMESTAMP when extSize >= 1: + { + byte tsFlags = reader.ReadByte(); + + if((tsFlags & 1) != 0 && _stream.Position + 4 <= nextExtra) + entry.LastWriteTime = DateTimeOffset.FromUnixTimeSeconds(reader.ReadInt32()).DateTime; + + if((tsFlags & 2) != 0 && _stream.Position + 4 <= nextExtra) + entry.LastAccessTime = DateTimeOffset.FromUnixTimeSeconds(reader.ReadInt32()).DateTime; + + if((tsFlags & 4) != 0 && _stream.Position + 4 <= nextExtra) + entry.CreationTime = DateTimeOffset.FromUnixTimeSeconds(reader.ReadInt32()).DateTime; + + break; + } + + case EXTRA_UNIX1 when extSize >= 8: + { + entry.LastAccessTime = DateTimeOffset.FromUnixTimeSeconds(reader.ReadInt32()).DateTime; + entry.LastWriteTime = DateTimeOffset.FromUnixTimeSeconds(reader.ReadInt32()).DateTime; + + if(extSize >= 10) entry.Uid = reader.ReadUInt16(); + + if(extSize >= 12) entry.Gid = reader.ReadUInt16(); + + break; + } + + case EXTRA_UNIX2 when extSize >= 4: + { + entry.Uid = reader.ReadUInt16(); + entry.Gid = reader.ReadUInt16(); + + break; + } + + case EXTRA_UNIX3 when extSize >= 4: + { + byte version = reader.ReadByte(); + + if(version == 1) + { + byte uidSize = reader.ReadByte(); + + entry.Uid = uidSize switch + { + 2 => reader.ReadUInt16(), + 4 => reader.ReadUInt32(), + _ => entry.Uid + }; + + if(uidSize != 2 && uidSize != 4) _stream.Position += uidSize; + + byte gidSize = reader.ReadByte(); + + entry.Gid = gidSize switch + { + 2 => reader.ReadUInt16(), + 4 => reader.ReadUInt32(), + _ => entry.Gid + }; + + if(gidSize != 2 && gidSize != 4) _stream.Position += gidSize; + } + + break; + } + + case EXTRA_UNICODE_PATH when extSize >= 6: + { + byte unicodeVersion = reader.ReadByte(); + + if(unicodeVersion == 1) + { + uint nameCrc = reader.ReadUInt32(); + + int unicodeLen = extSize - 5; + + if(unicodeLen > 0) + { + byte[] unicodeBytes = reader.ReadBytes(unicodeLen); + + // Strip trailing null bytes + int trimLen = unicodeLen; + + while(trimLen > 0 && unicodeBytes[trimLen - 1] == 0) trimLen--; + + if(trimLen > 0) + { + string unicodeName = Encoding.UTF8.GetString(unicodeBytes, 0, trimLen); + + // Normalize path separators + unicodeName = unicodeName.Replace('\\', '/'); + entry.Filename = unicodeName; + } + } + } + + break; + } + + case EXTRA_WINZIP_AES when extSize >= 7: + { + ushort aesVersion = reader.ReadUInt16(); + ushort aesVendor = reader.ReadUInt16(); + byte aesKeySize = reader.ReadByte(); + ushort aesMethod = reader.ReadUInt16(); + + // The actual compression method is stored in the AES extra field + if(entry.Method == CompressionMethod.WinZipAes) entry.Method = (CompressionMethod)aesMethod; + + entry.IsEncrypted = true; + + break; + } + } + + _stream.Position = nextExtra; + } + + _stream.Position = end; + } + + void FindDataDescriptor(BinaryReader reader, ref Entry entry) + { + // When the data descriptor flag is set, the CRC and sizes follow the compressed data. + // We scan for the data descriptor signature or the next local header/central directory. + long startPos = _stream.Position + entry.CompressedSize; + + if(startPos >= _stream.Length) return; + + _stream.Position = startPos; + + // Try to read a data descriptor at the expected position + if(_stream.Position + 16 <= _stream.Length) + { + uint possibleSig = reader.ReadUInt32(); + + if(possibleSig == DATA_DESCRIPTOR_SIG) + { + // Data descriptor with signature + entry.Crc32 = reader.ReadUInt32(); + entry.CompressedSize = reader.ReadUInt32(); + entry.UncompressedSize = reader.ReadUInt32(); + + // Check for ZIP64 data descriptor (8-byte sizes) + if(entry.CompressedSize == 0xFFFFFFFF || entry.UncompressedSize == 0xFFFFFFFF) + { + _stream.Position -= 8; + entry.CompressedSize = reader.ReadInt64(); + entry.UncompressedSize = reader.ReadInt64(); + } + } + else + { + // Data descriptor without signature (CRC directly) + entry.Crc32 = possibleSig; + entry.CompressedSize = reader.ReadUInt32(); + entry.UncompressedSize = reader.ReadUInt32(); + } + } + } + + bool FindNextLocalHeader(BinaryReader reader) + { + // Scan forward for the next PK signature + while(_stream.Position < _stream.Length - 4) + { + byte b = reader.ReadByte(); + + if(b != 0x50) continue; + + if(_stream.Position >= _stream.Length - 3) return false; + + byte b2 = reader.ReadByte(); + + if(b2 != 0x4B) + { + _stream.Position--; + + continue; + } + + byte b3 = reader.ReadByte(); + byte b4 = reader.ReadByte(); + + // Local header, central directory, or EOCD + if(b3 == 0x03 && b4 == 0x04 || b3 == 0x01 && b4 == 0x02 || b3 == 0x05 && b4 == 0x06) + { + _stream.Position -= 4; + + return true; + } + } + + return false; + } + + static DateTime DosToDateTime(uint dosDateTime) + { + var time = (int)(dosDateTime & 0xFFFF); + var date = (int)(dosDateTime >> 16); + + int second = (time & 0x1F) * 2; + int minute = time >> 5 & 0x3F; + int hour = time >> 11 & 0x1F; + + int day = date & 0x1F; + int month = date >> 5 & 0x0F; + int year = (date >> 9 & 0x7F) + 1980; + + // Validate ranges + if(month < 1) month = 1; + if(month > 12) month = 12; + if(day < 1) day = 1; + + if(day > DateTime.DaysInMonth(year, month)) day = DateTime.DaysInMonth(year, month); + + if(hour > 23) hour = 23; + if(minute > 59) minute = 59; + if(second > 59) second = 59; + + try + { + return new DateTime(year, month, day, hour, minute, second, DateTimeKind.Local); + } + catch(ArgumentOutOfRangeException) + { + return default(DateTime); + } + } + + static uint ComputeCrc32(byte[] data) + { + var crc = 0xFFFFFFFF; + + foreach(byte b in data) + { + crc ^= b; + + for(var j = 0; j < 8; j++) + { + if((crc & 1) != 0) + crc = crc >> 1 ^ CRC_POLY; + else + crc >>= 1; + } + } + + return crc ^ 0xFFFFFFFF; + } + +#region IArchive Members + + /// + public ErrorNumber Open(IFilter filter, Encoding encoding) + { + if(filter is null || filter.DataForkLength < MIN_FILE_SIZE) return ErrorNumber.InvalidArgument; + + _stream = filter.GetDataForkStream(); + _encoding = encoding ?? Encoding.GetEncoding("ibm437"); + _entries = []; + + _features = ArchiveSupportedFeature.SupportsFilenames | ArchiveSupportedFeature.SupportsCompression; + + long eocdOffset = -1; + long zip64LocOffset = -1; + + FindEndOfCentralDirectory(out eocdOffset, out zip64LocOffset); + + if(eocdOffset >= 0) + ParseCentralDirectory(eocdOffset, zip64LocOffset); + else + ParseWithoutCentralDirectory(); + + // Detect features from parsed entries + foreach(Entry entry in _entries) + { + if(entry.IsDirectory) + { + _features |= ArchiveSupportedFeature.SupportsSubdirectories | + ArchiveSupportedFeature.HasExplicitDirectories; + } + + if(entry.LastWriteTime != default(DateTime)) _features |= ArchiveSupportedFeature.HasEntryTimestamp; + + if(entry.Comment is not null) _features |= ArchiveSupportedFeature.SupportsXAttrs; + + if(entry.IsEncrypted) _features |= ArchiveSupportedFeature.SupportsProtection; + } + + Opened = true; + + return ErrorNumber.NoError; + } + + /// + public void Close() + { + _entries = null; + Opened = false; + } + +#endregion +} \ No newline at end of file diff --git a/Aaru.Archives/Zip/Structs.cs b/Aaru.Archives/Zip/Structs.cs new file mode 100644 index 000000000..0f1492fae --- /dev/null +++ b/Aaru.Archives/Zip/Structs.cs @@ -0,0 +1,32 @@ +using System; + +namespace Aaru.Archives; + +public sealed partial class Zip +{ +#region Nested type: Entry + + struct Entry + { + public CompressionMethod Method; + public string Filename; + public long CompressedSize; + public long UncompressedSize; + public long DataOffset; + public DateTime LastWriteTime; + public DateTime LastAccessTime; + public DateTime CreationTime; + public uint Crc32; + public HostOs System; + public uint ExternalAttributes; + public ushort UnixPermissions; + public uint Uid; + public uint Gid; + public string Comment; + public ushort Flags; + public bool IsDirectory; + public bool IsEncrypted; + } + +#endregion +} \ No newline at end of file diff --git a/Aaru.Archives/Zip/Xattrs.cs b/Aaru.Archives/Zip/Xattrs.cs new file mode 100644 index 000000000..db359b528 --- /dev/null +++ b/Aaru.Archives/Zip/Xattrs.cs @@ -0,0 +1,44 @@ +using System.Collections.Generic; +using System.Text; +using Aaru.CommonTypes.Enums; + +namespace Aaru.Archives; + +public sealed partial class Zip +{ +#region IArchive Members + + /// + public ErrorNumber ListXAttr(int entryNumber, out List xattrs) + { + xattrs = null; + + if(!Opened) return ErrorNumber.NotOpened; + + if(entryNumber < 0 || entryNumber >= _entries.Count) return ErrorNumber.OutOfRange; + + xattrs = []; + + if(_entries[entryNumber].Comment is not null) xattrs.Add("comment"); + + return ErrorNumber.NoError; + } + + /// + public ErrorNumber GetXattr(int entryNumber, string xattr, ref byte[] buffer) + { + buffer = null; + + if(!Opened) return ErrorNumber.NotOpened; + + if(entryNumber < 0 || entryNumber >= _entries.Count) return ErrorNumber.OutOfRange; + + if(xattr != "comment" || _entries[entryNumber].Comment is null) return ErrorNumber.NoSuchExtendedAttribute; + + buffer = Encoding.UTF8.GetBytes(_entries[entryNumber].Comment); + + return ErrorNumber.NoError; + } + +#endregion +} \ No newline at end of file diff --git a/Aaru.Archives/Zip/Zip.cs b/Aaru.Archives/Zip/Zip.cs new file mode 100644 index 000000000..28ff6365c --- /dev/null +++ b/Aaru.Archives/Zip/Zip.cs @@ -0,0 +1,37 @@ +using System; +using System.IO; +using System.Text; +using Aaru.CommonTypes.Interfaces; + +namespace Aaru.Archives; + +public sealed partial class Zip : IArchive +{ + const string MODULE_NAME = "ZIP Archive Plugin"; + string _archiveComment; + Encoding _encoding; + ArchiveSupportedFeature _features; + Stream _stream; + +#region IArchive Members + + /// + public string Name => Localization.Zip_Name; + /// + public Guid Id => new("A3E2F8C1-6D4B-4E7A-B9C5-3F1D8A2E6B47"); + /// + public string Author => Authors.NataliaPortillo; + /// + public bool Opened { get; private set; } + /// + public ArchiveSupportedFeature ArchiveFeatures => !Opened + ? ArchiveSupportedFeature.SupportsCompression | + ArchiveSupportedFeature.SupportsFilenames | + ArchiveSupportedFeature.SupportsSubdirectories | + ArchiveSupportedFeature.HasEntryTimestamp + : _features; + /// + public int NumberOfEntries => Opened ? _entries.Count : -1; + +#endregion +} \ No newline at end of file diff --git a/README.md b/README.md index aef6a3e0f..0c05bce59 100644 --- a/README.md +++ b/README.md @@ -172,6 +172,7 @@ Supported archives * RAR (.RAR) * Symbian Installation File (.SIS) * Xbox 360 Secure Transacted File System (STFS) +* ZIP (.ZIP) * ZOO (.ZOO) Supported partitioning schemes