Files
alexandrev-tibco e3cf1df73e Nested archives by signature, per-side sizes, applied-mode icon, manual pairing
Archives are detected by their signature, not just their extension, so a ZIP
renamed to anything still opens as a folder — and nesting keeps going, verified
on EAR -> JAR -> renamed ZIP. Entries with clearly non-container extensions are
skipped without inflating, since checking the signature inside an archive means
decompressing, and an EAR holds thousands of .class files.

The comparison now records which mode settled each file, so Automatic shows an
icon per row (byte / text / structural) instead of leaving you to guess when it
went semantic.

Sizes are shown on both sides and highlighted when they disagree, which is often
the fastest hint of what changed.

Two files whose names don't match can now be compared: mark one side, pick the
other, and it becomes the new base in this tab or a new one. Entries living
inside an archive are extracted to a temporary file first, since they have no
path of their own — that's what lets a file inside a JAR be paired with a loose
one.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UfhDYRLTpGJSKGP1m3LVJN
2026-07-30 15:53:33 +02:00

285 lines
12 KiB
Swift

import Foundation
import Compression
/// Reads ZIP containers (which includes JAR, EAR and WAR) without unpacking them.
///
/// The central directory already records every entry's CRC32 and sizes, so two
/// archives can be compared entry by entry *without decompressing anything*.
/// Bytes are only inflated when a specific entry is actually opened that's what
/// keeps comparing large EARs fast.
public struct ZipReader {
/// One entry in the archive's central directory.
public struct Entry {
public let path: String
public let crc32: UInt32
public let compressedSize: UInt64
public let uncompressedSize: UInt64
public let compressionMethod: UInt16
public let localHeaderOffset: UInt64
public let isDirectory: Bool
}
public enum ZipError: Error, LocalizedError {
case notAZip
case unsupported(String)
case corrupt(String)
public var errorDescription: String? {
switch self {
case .notAZip: return "Not a ZIP archive (no end-of-central-directory record)."
case .unsupported(let what): return "Unsupported ZIP feature: \(what)."
case .corrupt(let why): return "Corrupt ZIP: \(why)."
}
}
}
public let url: URL
public let entries: [Entry]
/// File extensions handled as archives, i.e. browsable like directories.
public static let archiveExtensions: Set<String> = ["zip", "jar", "ear", "war", "aar", "ipa"]
public static func isArchive(_ url: URL) -> Bool {
if archiveExtensions.contains(url.pathExtension.lowercased()) { return true }
// A ZIP renamed to anything else is still a ZIP, and deployment artefacts
// do this constantly, so fall back to the signature.
return looksLikeArchive(fileAt: url)
}
/// Extensions that are definitely not containers, used to skip the signature
/// check on the thousands of small entries inside a real EAR.
static let neverArchiveExtensions: Set<String> = [
"class", "java", "swift", "kt", "go", "c", "h", "m", "js", "ts", "css", "scss",
"html", "htm", "xml", "xsd", "xsl", "xslt", "wsdl", "json", "yml", "yaml",
"properties", "conf", "cfg", "ini", "toml", "md", "txt", "log", "csv", "sql",
"sh", "bat", "mf", "sf", "rsa", "dsa", "png", "jpg", "jpeg", "gif", "svg",
"ico", "pdf", "ttf", "otf", "woff", "woff2", "so", "dylib", "plist",
]
/// The local-file-header signature, plus the empty and spanned variants.
private static let signatures: [[UInt8]] = [
[0x50, 0x4B, 0x03, 0x04],
[0x50, 0x4B, 0x05, 0x06],
[0x50, 0x4B, 0x07, 0x08],
]
/// Cheap check: read only the first four bytes.
public static func looksLikeArchive(fileAt url: URL) -> Bool {
guard let handle = try? FileHandle(forReadingFrom: url) else { return false }
defer { try? handle.close() }
guard let head = try? handle.read(upToCount: 4) else { return false }
return looksLikeArchive(head)
}
public static func looksLikeArchive(_ head: Data?) -> Bool {
guard let head, head.count >= 4 else { return false }
let bytes = Array(head.prefix(4))
return signatures.contains(bytes)
}
/// Whether an entry inside an archive is worth opening as one. Known archive
/// extensions always are; anything with a clearly non-container extension
/// never is; the rest are only inflated when small enough that guessing wrong
/// is cheap, since checking the signature means decompressing it.
static func mayBeNestedArchive(name: String, uncompressedSize: UInt64,
inflateLimit: UInt64 = 64 * 1024 * 1024) -> Bool {
let ext = (name as NSString).pathExtension.lowercased()
if archiveExtensions.contains(ext) { return true }
if !ext.isEmpty && neverArchiveExtensions.contains(ext) { return false }
return uncompressedSize >= 22 && uncompressedSize <= inflateLimit
}
public init(url: URL) throws {
self.url = url
let data = try Data(contentsOf: url, options: .mappedIfSafe)
self.entries = try Self.readCentralDirectory(data)
}
// MARK: Central directory
private static let endOfCentralDirectorySignature: UInt32 = 0x06054b50
private static let zip64LocatorSignature: UInt32 = 0x07064b50
private static let zip64EndSignature: UInt32 = 0x06064b50
private static let centralFileHeaderSignature: UInt32 = 0x02014b50
private static func readCentralDirectory(_ data: Data) throws -> [Entry] {
guard let eocdOffset = findEndOfCentralDirectory(data) else { throw ZipError.notAZip }
var entryCount = Int(data.u16(eocdOffset + 10))
var directoryOffset = Int(data.u32(eocdOffset + 16))
let directorySize = Int(data.u32(eocdOffset + 12))
// ZIP64: the 32-bit fields saturate, and the real values live in the
// ZIP64 record that the locator points at.
if directoryOffset == 0xFFFF_FFFF || entryCount == 0xFFFF || directorySize == 0xFFFF_FFFF {
guard let locator = findZip64Locator(data, before: eocdOffset) else {
throw ZipError.unsupported("ZIP64 without locator")
}
let zip64End = Int(data.u64(locator + 8))
guard zip64End + 56 <= data.count, data.u32(zip64End) == zip64EndSignature else {
throw ZipError.corrupt("bad ZIP64 end record")
}
entryCount = Int(data.u64(zip64End + 32))
directoryOffset = Int(data.u64(zip64End + 48))
}
var entries: [Entry] = []
entries.reserveCapacity(entryCount)
var offset = directoryOffset
for _ in 0..<entryCount {
guard offset + 46 <= data.count, data.u32(offset) == centralFileHeaderSignature else {
throw ZipError.corrupt("bad central directory header")
}
let method = data.u16(offset + 10)
let crc = data.u32(offset + 16)
var compressed = UInt64(data.u32(offset + 20))
var uncompressed = UInt64(data.u32(offset + 24))
let nameLength = Int(data.u16(offset + 28))
let extraLength = Int(data.u16(offset + 30))
let commentLength = Int(data.u16(offset + 32))
var localOffset = UInt64(data.u32(offset + 42))
let nameStart = offset + 46
guard nameStart + nameLength <= data.count else { throw ZipError.corrupt("truncated name") }
let name = String(decoding: data[nameStart..<nameStart + nameLength], as: UTF8.self)
// Oversized values are carried in the ZIP64 extra field.
if uncompressed == 0xFFFF_FFFF || compressed == 0xFFFF_FFFF || localOffset == 0xFFFF_FFFF {
let extraStart = nameStart + nameLength
readZip64Extra(data, start: extraStart, length: extraLength,
uncompressed: &uncompressed, compressed: &compressed, localOffset: &localOffset)
}
entries.append(Entry(path: normalise(name),
crc32: crc,
compressedSize: compressed,
uncompressedSize: uncompressed,
compressionMethod: method,
localHeaderOffset: localOffset,
isDirectory: name.hasSuffix("/")))
offset = nameStart + nameLength + extraLength + commentLength
}
return entries
}
/// Entries may use either separator and some tools prefix "./".
private static func normalise(_ name: String) -> String {
var path = name.replacingOccurrences(of: "\\", with: "/")
while path.hasPrefix("./") { path.removeFirst(2) }
while path.hasSuffix("/") { path.removeLast() }
return path
}
private static func findEndOfCentralDirectory(_ data: Data) -> Int? {
// The record is at the end but may be followed by a comment (max 64 KiB).
let minimumSize = 22
guard data.count >= minimumSize else { return nil }
let searchLimit = max(0, data.count - minimumSize - 0xFFFF)
var offset = data.count - minimumSize
while offset >= searchLimit {
if data.u32(offset) == endOfCentralDirectorySignature { return offset }
offset -= 1
}
return nil
}
private static func findZip64Locator(_ data: Data, before eocd: Int) -> Int? {
let locator = eocd - 20
guard locator >= 0, data.u32(locator) == zip64LocatorSignature else { return nil }
return locator
}
private static func readZip64Extra(_ data: Data, start: Int, length: Int,
uncompressed: inout UInt64, compressed: inout UInt64,
localOffset: inout UInt64) {
var cursor = start
let end = min(start + length, data.count)
while cursor + 4 <= end {
let headerID = data.u16(cursor)
let size = Int(data.u16(cursor + 2))
let body = cursor + 4
if headerID == 0x0001 {
var field = body
if uncompressed == 0xFFFF_FFFF, field + 8 <= end { uncompressed = data.u64(field); field += 8 }
if compressed == 0xFFFF_FFFF, field + 8 <= end { compressed = data.u64(field); field += 8 }
if localOffset == 0xFFFF_FFFF, field + 8 <= end { localOffset = data.u64(field) }
return
}
cursor = body + size
}
}
// MARK: Reading entry bytes
/// Inflates one entry. Only called when the bytes are genuinely needed
/// comparisons normally stop at the CRC32 from the central directory.
public func data(for entry: Entry) throws -> Data {
let archive = try Data(contentsOf: url, options: .mappedIfSafe)
let headerOffset = Int(entry.localHeaderOffset)
guard headerOffset + 30 <= archive.count, archive.u32(headerOffset) == 0x04034b50 else {
throw ZipError.corrupt("bad local header for \(entry.path)")
}
let nameLength = Int(archive.u16(headerOffset + 26))
let extraLength = Int(archive.u16(headerOffset + 28))
let start = headerOffset + 30 + nameLength + extraLength
let end = start + Int(entry.compressedSize)
guard end <= archive.count else { throw ZipError.corrupt("truncated data for \(entry.path)") }
let payload = archive.subdata(in: start..<end)
switch entry.compressionMethod {
case 0:
return payload
case 8:
return try inflate(payload, expectedSize: Int(entry.uncompressedSize))
default:
throw ZipError.unsupported("compression method \(entry.compressionMethod)")
}
}
/// Raw DEFLATE via the system Compression framework (no third-party zlib).
private func inflate(_ payload: Data, expectedSize: Int) throws -> Data {
guard expectedSize > 0 else { return Data() }
var output = Data(count: expectedSize)
let written: Int = try output.withUnsafeMutableBytes { destination in
try payload.withUnsafeBytes { source in
guard let destinationBase = destination.bindMemory(to: UInt8.self).baseAddress,
let sourceBase = source.bindMemory(to: UInt8.self).baseAddress else {
throw ZipError.corrupt("empty buffer")
}
return compression_decode_buffer(destinationBase, expectedSize,
sourceBase, payload.count,
nil, COMPRESSION_ZLIB)
}
}
guard written == expectedSize else {
throw ZipError.corrupt("inflate produced \(written) of \(expectedSize) bytes")
}
return output
}
}
// MARK: - Little-endian reads
private extension Data {
func u16(_ offset: Int) -> UInt16 {
guard offset + 2 <= count else { return 0 }
return UInt16(self[startIndex + offset]) | UInt16(self[startIndex + offset + 1]) << 8
}
func u32(_ offset: Int) -> UInt32 {
guard offset + 4 <= count else { return 0 }
var value: UInt32 = 0
for i in (0..<4).reversed() { value = value << 8 | UInt32(self[startIndex + offset + i]) }
return value
}
func u64(_ offset: Int) -> UInt64 {
guard offset + 8 <= count else { return 0 }
var value: UInt64 = 0
for i in (0..<8).reversed() { value = value << 8 | UInt64(self[startIndex + offset + i]) }
return value
}
}