From 96d6ca52c913cfde037d6473691ee1e679b889d9 Mon Sep 17 00:00:00 2001 From: Alastair Houghton Date: Thu, 15 Oct 2026 14:39:16 +0100 Subject: [PATCH 1/4] Add support for reading and writing zip files Add ZipFileReader, ZipFileDataSource and ZipFileWriter, along with the ZLib and CZLib modules they use for deflate compression. ZipFileReader also conforms to ReadOnlyFileManagerProtocol, so that the contents of a zip file can be used wherever a read-only file system is expected. This raises the minimum macOS deployment target to 15 for the span types the reader uses. rdar://190233135 --- .swift-format | 5 + CMakeLists.txt | 1 + Package.swift | 33 +- Sources/CMakeLists.txt | 2 + Sources/CZLib/CZLib.h | 11 + Sources/CZLib/module.modulemap | 4 + Sources/SwiftDocC/CMakeLists.txt | 7 +- ...leReader+ReadOnlyFileManagerProtocol.swift | 144 +++ Sources/ZLib/CMakeLists.txt | 30 + Sources/ZLib/ZLib.swift | 48 + Sources/Zip/CMakeLists.txt | 22 + Sources/Zip/Date+DosFileTime.swift | 65 ++ Sources/Zip/ZipFileDataSink.swift | 47 + Sources/Zip/ZipFileDataSource.swift | 41 + Sources/Zip/ZipFileError.swift | 59 ++ Sources/Zip/ZipFileReader.swift | 745 ++++++++++++++ Sources/Zip/ZipFileWriter.swift | 488 ++++++++++ Tests/ZipTests/ZipTests.swift | 911 ++++++++++++++++++ bin/benchmark/Package.swift | 2 +- 19 files changed, 2660 insertions(+), 5 deletions(-) create mode 100644 .swift-format create mode 100644 Sources/CZLib/CZLib.h create mode 100644 Sources/CZLib/module.modulemap create mode 100644 Sources/SwiftDocC/Utility/ZipFileReader+ReadOnlyFileManagerProtocol.swift create mode 100644 Sources/ZLib/CMakeLists.txt create mode 100644 Sources/ZLib/ZLib.swift create mode 100644 Sources/Zip/CMakeLists.txt create mode 100644 Sources/Zip/Date+DosFileTime.swift create mode 100644 Sources/Zip/ZipFileDataSink.swift create mode 100644 Sources/Zip/ZipFileDataSource.swift create mode 100644 Sources/Zip/ZipFileError.swift create mode 100644 Sources/Zip/ZipFileReader.swift create mode 100644 Sources/Zip/ZipFileWriter.swift create mode 100644 Tests/ZipTests/ZipTests.swift diff --git a/.swift-format b/.swift-format new file mode 100644 index 0000000000..705c4af755 --- /dev/null +++ b/.swift-format @@ -0,0 +1,5 @@ +{ + "indentation": { + "spaces": 4 + } +} diff --git a/CMakeLists.txt b/CMakeLists.txt index 72fc67b5db..2c7e06885f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -38,6 +38,7 @@ find_package(SwiftMarkdown) find_package(LMDB) find_package(SymbolKit) find_package(cmark-gfm) +find_package(ZLIB) add_compile_options("$<$:-package-name;SwiftDocC>") diff --git a/Package.swift b/Package.swift index 51a422688e..7f20235d40 100644 --- a/Package.swift +++ b/Package.swift @@ -65,7 +65,7 @@ let previewServerEnabledByDefault = false let package = Package( name: "SwiftDocC", platforms: [ - .macOS(.v13), + .macOS(.v15), .iOS(.v16) ], products: [ @@ -92,6 +92,7 @@ let package = Package( dependencies: [ .target(name: "DocCCommon"), .target(name: "DocCHTML"), + .target(name: "Zip"), .product(name: "Markdown", package: "swift-markdown"), .product(name: "SymbolKit", package: "swift-docc-symbolkit"), .product(name: "CLMDB", package: "swift-lmdb"), @@ -225,6 +226,36 @@ let package = Package( ], swiftSettings: swiftSettings(.v5) ), + + // Zip file handling + .target( + name: "Zip", + dependencies: [ + .target(name: "ZLib") + ], + exclude: ["CMakeLists.txt"], + swiftSettings: swiftSettings(.v6) + ), + .testTarget( + name: "ZipTests", + dependencies: [ + .target(name: "Zip") + ], + swiftSettings: swiftSettings(.v6) + ), + + .systemLibrary( + name: "CZLib", + pkgConfig: "zlib" + ), + .target( + name: "ZLib", + dependencies: [ + .target(name: "CZLib") + ], + exclude: ["CMakeLists.txt"], + swiftSettings: swiftSettings(.v6) + ) ] ) diff --git a/Sources/CMakeLists.txt b/Sources/CMakeLists.txt index 9470b09c8c..cab6943477 100644 --- a/Sources/CMakeLists.txt +++ b/Sources/CMakeLists.txt @@ -12,3 +12,5 @@ add_subdirectory(DocCHTML) add_subdirectory(SwiftDocC) add_subdirectory(DocCCommandLine) add_subdirectory(docc) +add_subdirectory(Zip) +add_subdirectory(ZLib) diff --git a/Sources/CZLib/CZLib.h b/Sources/CZLib/CZLib.h new file mode 100644 index 0000000000..5a407fa4ed --- /dev/null +++ b/Sources/CZLib/CZLib.h @@ -0,0 +1,11 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2026 Apple Inc. and the Swift project authors + Licensed under Apache License v2.0 with Runtime Library Exception + + See https://swift.org/LICENSE.txt for license information + See https://swift.org/CONTRIBUTORS.txt for Swift project authors +*/ + +#include diff --git a/Sources/CZLib/module.modulemap b/Sources/CZLib/module.modulemap new file mode 100644 index 0000000000..4432946a9d --- /dev/null +++ b/Sources/CZLib/module.modulemap @@ -0,0 +1,4 @@ +module CZLib { + header "CZLib.h" + export * +} \ No newline at end of file diff --git a/Sources/SwiftDocC/CMakeLists.txt b/Sources/SwiftDocC/CMakeLists.txt index c16fdf56e5..f507f5dadb 100644 --- a/Sources/SwiftDocC/CMakeLists.txt +++ b/Sources/SwiftDocC/CMakeLists.txt @@ -472,12 +472,13 @@ add_library(SwiftDocC Utility/SymbolGraphAvailability+Filter.swift Utility/Synchronization.swift Utility/ValidatedURL.swift - Utility/Version.swift) -target_link_libraries(SwiftDocC PRIVATE - DocCCommon) + Utility/Version.swift + Utility/ZipFileReader+ReadOnlyFileManagerProtocol.swift) target_link_libraries(SwiftDocC PRIVATE + DocCCommon DocCHTML) target_link_libraries(SwiftDocC PUBLIC + Zip SwiftMarkdown::Markdown DocC::SymbolKit LMDB::CLMDB diff --git a/Sources/SwiftDocC/Utility/ZipFileReader+ReadOnlyFileManagerProtocol.swift b/Sources/SwiftDocC/Utility/ZipFileReader+ReadOnlyFileManagerProtocol.swift new file mode 100644 index 0000000000..0575afa1a1 --- /dev/null +++ b/Sources/SwiftDocC/Utility/ZipFileReader+ReadOnlyFileManagerProtocol.swift @@ -0,0 +1,144 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2026 Apple Inc. and the Swift project authors + Licensed under Apache License v2.0 with Runtime Library Exception + + See https://swift.org/LICENSE.txt for license information + See https://swift.org/CONTRIBUTORS.txt for Swift project authors +*/ + +package import Foundation +package import Zip + +extension ZipFileReader: ReadOnlyFileManagerProtocol { + + package func contents(atPath path: String) -> Data? { + do { + let data = try contents(of: URL(filePath: path)) + return data + } catch { + return nil + } + } + + package func contents(of url: URL) throws -> Data { + let file = try open(path: url.path) + var data = Data() + let buffer = UnsafeMutableRawBufferPointer.allocate(byteCount: 65536, + alignment: 16) + defer { + buffer.deallocate() + } + + while true { + var span = OutputRawSpan(buffer: buffer, initializedCount: 0) + try file.read(into: &span) + if span.byteCount == 0 { + break + } + + data.append(buffer.baseAddress!.assumingMemoryBound(to: UInt8.self), + count: span.byteCount) + } + + return data + } + + package var currentDirectoryPath: String { "/" } + + package func contentsOfDirectory(atPath path: String) throws -> [String] { + guard let item = item(at: path) else { + throw ZipFileError.fileNotFound(path) + } + + switch item.kind { + case .file: + throw ZipFileError.itemIsNotADirectory(path) + case let .directory(contents): + return Array(contents.keys) + } + } + + package func contentsOfDirectory(at url: URL, includingPropertiesForKeys keys: [URLResourceKey]?, options mask: FileManager.DirectoryEnumerationOptions) throws -> [URL] { + let path = url.path + guard let item = item(at: path) else { + throw ZipFileError.fileNotFound(path) + } + + switch item.kind { + case .file: + throw ZipFileError.itemIsNotADirectory(path) + case let .directory(contents): + var result: [URL] = [] + for (name, child) in contents { + let itemURL = url.appending( + path: name, + directoryHint: child.isDirectory + ? .isDirectory : .notDirectory + ) + result.append(itemURL) + } + return result + } + } + + package func contentsOfDirectory(at url: URL, options mask: FileManager.DirectoryEnumerationOptions) throws -> (files: [URL], directories: [URL]) { + let path = url.path + guard let item = item(at: path) else { + throw ZipFileError.fileNotFound(path) + } + + switch item.kind { + case .file: + throw ZipFileError.itemIsNotADirectory(path) + case let .directory(contents): + var files: [URL] = [] + var directories: [URL] = [] + for (name, child) in contents { + let itemURL = url.appending( + path: name, + directoryHint: child.isDirectory + ? .isDirectory : .notDirectory + ) + if child.isDirectory { + directories.append(itemURL) + } else { + files.append(itemURL) + } + } + return (files: files, directories: directories) + } + } + + package func sizeOfDirectory(at url: URL, options mask: FileManager.DirectoryEnumerationOptions) throws -> Int64 { + guard let item = item(at: url.path) else { + throw ZipFileError.fileNotFound(url.path) + } + + if !item.isDirectory { + throw ZipFileError.itemIsNotADirectory(url.path) + } + + var totalSize = Int64(0) + var stack = [item] + + while let dir = stack.popLast() { + guard case let .directory(contents) = dir.kind else { + fatalError("Unexpected non-directory found in directory stack") + } + + for (_, child) in contents { + switch child.kind { + case .file: + totalSize += Int64(child.uncompressedSize) + case .directory: + stack.append(child) + } + } + } + + return totalSize + } + +} \ No newline at end of file diff --git a/Sources/ZLib/CMakeLists.txt b/Sources/ZLib/CMakeLists.txt new file mode 100644 index 0000000000..255105e360 --- /dev/null +++ b/Sources/ZLib/CMakeLists.txt @@ -0,0 +1,30 @@ +#[[ +This source file is part of the Swift open source project + +Copyright © 2014 - 2025 Apple Inc. and the Swift project authors +Licensed under Apache License v2.0 with Runtime Library Exception + +See https://swift.org/LICENSE.txt for license information +#]] + +add_library(ZLib + ZLib.swift +) + +set_target_properties(ZLib PROPERTIES + Swift_LANGUAGE_VERSION 6) + +target_compile_options(ZLib PUBLIC + "-I${CMAKE_CURRENT_SOURCE_DIR}/../CZLib" +) + +target_link_libraries(ZLib PUBLIC + ZLIB::ZLIB +) + +if(BUILD_SHARED_LIBS) + install(TARGETS ZLib + ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR} + LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR} + RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}) +endif() diff --git a/Sources/ZLib/ZLib.swift b/Sources/ZLib/ZLib.swift new file mode 100644 index 0000000000..8b66afcd8f --- /dev/null +++ b/Sources/ZLib/ZLib.swift @@ -0,0 +1,48 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2026 Apple Inc. and the Swift project authors + Licensed under Apache License v2.0 with Runtime Library Exception + + See https://swift.org/LICENSE.txt for license information + See https://swift.org/CONTRIBUTORS.txt for Swift project authors +*/ + +@_exported import CZLib + +// All of the below are macros in + +public func deflateInit( + _ stream: z_streamp, + _ level: CInt +) -> CInt { + return deflateInit_(stream, level, + ZLIB_VERSION, CInt(MemoryLayout.size)) +} + +public func deflateInit2( + _ stream: z_streamp, + _ level: CInt, + _ method: CInt, + _ windowBits: CInt, + _ memLevel: CInt, + _ strategy: CInt +) -> CInt { + return deflateInit2_(stream, level, method, windowBits, memLevel, strategy, + ZLIB_VERSION, CInt(MemoryLayout.size)) +} + +public func inflateInit( + _ stream: z_streamp +) -> CInt { + return inflateInit_(stream, + ZLIB_VERSION, CInt(MemoryLayout.size)) +} + +public func inflateInit2( + _ stream: z_streamp, + _ windowBits: CInt +) -> CInt { + return inflateInit2_(stream, windowBits, + ZLIB_VERSION, CInt(MemoryLayout.size)) +} diff --git a/Sources/Zip/CMakeLists.txt b/Sources/Zip/CMakeLists.txt new file mode 100644 index 0000000000..ec74bdc10d --- /dev/null +++ b/Sources/Zip/CMakeLists.txt @@ -0,0 +1,22 @@ +#[[ +This source file is part of the Swift open source project + +Copyright © 2014 - 2025 Apple Inc. and the Swift project authors +Licensed under Apache License v2.0 with Runtime Library Exception + +See https://swift.org/LICENSE.txt for license information +#]] + +add_library(Zip + Date+DosFileTime.swift + ZipFileDataSink.swift + ZipFileDataSource.swift + ZipFileError.swift + ZipFileReader.swift + ZipFileWriter.swift +) +target_link_libraries(Zip PUBLIC + ZLib +) +set_target_properties(Zip PROPERTIES + Swift_LANGUAGE_VERSION 6) diff --git a/Sources/Zip/Date+DosFileTime.swift b/Sources/Zip/Date+DosFileTime.swift new file mode 100644 index 0000000000..472964de44 --- /dev/null +++ b/Sources/Zip/Date+DosFileTime.swift @@ -0,0 +1,65 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2026 Apple Inc. and the Swift project authors + Licensed under Apache License v2.0 with Runtime Library Exception + + See https://swift.org/LICENSE.txt for license information + See https://swift.org/CONTRIBUTORS.txt for Swift project authors +*/ + +public import Foundation + +extension Date { + public init(dosFileTime: UInt32) { + let year = 1980 + Int((dosFileTime >> 25) & 0x7f) + let month = Int((dosFileTime >> 21) & 0xf) + let day = Int((dosFileTime >> 16) & 0x1f) + let hour = Int((dosFileTime >> 11) & 0x1f) + let minute = Int((dosFileTime >> 5) & 0x3f) + let second = Int((dosFileTime << 1) & 0x3f) + + let components = DateComponents( + calendar: Calendar(identifier: .gregorian), + year: year, + month: month, + day: day, + hour: hour, + minute: minute, + second: second + ) + + self = components.date! + } + + public var dosFileTime: UInt32 { + let dosMinTime: UInt32 = 0x0021_0000 + let dosMaxTime: UInt32 = 0xff9f_bf7d + let calendar = Calendar(identifier: .gregorian) + let components = calendar.dateComponents( + [.year, .month, .day, .hour, .minute, .second], + from: self + ) + let year = components.year! - 1980 + + if (year & ~0x7f) != 0 { + return year > 0 ? dosMaxTime : dosMinTime + } + + var dosTime = (year & 0x7f) << 25 + dosTime |= components.month! << 21 + dosTime |= components.day! << 16 + dosTime |= components.hour! << 11 + dosTime |= components.minute! << 5 + dosTime |= components.second! >> 1 + + if dosTime > dosMaxTime { + return dosMaxTime + } + if dosTime < dosMinTime { + return dosMinTime + } + + return UInt32(dosTime) + } +} diff --git a/Sources/Zip/ZipFileDataSink.swift b/Sources/Zip/ZipFileDataSink.swift new file mode 100644 index 0000000000..7af426bb02 --- /dev/null +++ b/Sources/Zip/ZipFileDataSink.swift @@ -0,0 +1,47 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2026 Apple Inc. and the Swift project authors + Licensed under Apache License v2.0 with Runtime Library Exception + + See https://swift.org/LICENSE.txt for license information + See https://swift.org/CONTRIBUTORS.txt for Swift project authors +*/ + +public import Foundation + +public enum ZipFileDataSinkError: Error { + case badOffset +} + +public class ZipFileDataSink: ZipFileSink { + public var data: Data + + public var canSeek: Bool { true } + + private var pos: Int + + public init() { + data = Data() + pos = 0 + } + + public func tell() throws -> Int? { + return pos + } + + public func seek(_ pos: Int) throws { + if pos < 0 || pos > data.count { + throw ZipFileDataSinkError.badOffset + } + self.pos = pos + } + + public func write(_ bytes: RawSpan) throws { + let rangeToReplace = pos.. data.count { + throw ZipFileDataSourceError.outOfRange + } + + span.withUnsafeMutableBytes { (buffer, count: inout Int) -> () in + count = min(buffer.count, data.count - offset) + data.copyBytes(to: buffer.baseAddress!.assumingMemoryBound(to: UInt8.self), + from: offset..(from offset: Int, as: T.Type) throws -> T + + /// Read a UTF-8 string + func read(from offset: Int, asStringOfLength: Int) throws -> String + + /// Read data from the source + func read(from offset: Int, into: inout OutputRawSpan) throws + + /// Close the source + func close() throws +} + +/// A ZipFileReader can be used to read data from a .zip file. +public class ZipFileReader: @unchecked Sendable { + + var source: S + + public class Item: CustomStringConvertible { + var headerOffset: Int? + + internal(set) public var name: String + internal(set) public var date: Date + internal(set) public var comment: String? + + internal(set) public var flags: UInt16 + internal(set) public var compression: UInt16 + internal(set) public var crc32: UInt32 + internal(set) public var compressedSize: Int + internal(set) public var uncompressedSize: Int + + public enum Kind { + case file + case directory([String: Item]) + } + + internal(set) public var kind: Kind + + init( + headerOffset: Int?, name: String, date: Date, comment: String?, + flags: UInt16, compression: UInt16, crc32: UInt32, + compressedSize: Int, uncompressedSize: Int, + kind: Kind + ) { + self.headerOffset = headerOffset + self.name = name + self.date = date + self.comment = comment + self.flags = flags + self.compression = compression + self.crc32 = crc32 + self.compressedSize = compressedSize + self.uncompressedSize = uncompressedSize + self.kind = kind + } + + public var description: String { + return description(indent: 0) + } + + func description(indent: Int) -> String { + let spaces = String(repeating: " ", count: indent * 2) + var result = "\(date)\t\(uncompressedSize)\t\(spaces)\(name)" + if case .directory(let children) = kind { + for (_, value) in children { + result.append("\n") + result.append(value.description(indent: indent + 1)) + } + } + return result + } + + public var isDirectory: Bool { + switch kind { + case .directory: return true + case .file: return false + } + } + + public var isFile: Bool { + switch kind { + case .directory: return false + case .file: return true + } + } + } + + internal(set) public var comment: String? = nil + var root = Item( + headerOffset: nil, + name: "/", + date: Date.now, + comment: nil, + flags: 0, + compression: 0, + crc32: 0, + compressedSize: 0, + uncompressedSize: 0, + kind: .directory([:]) + ) + + public init(source: S) throws { + self.source = source + try readDirectory() + } + + deinit { + do { + try source.close() + } catch { + print("error closing ZipFileSource: \(error)") + } + } + + // Locate the End of Central Directory record by scanning backwards + // from the end of the file. + func findEndOfCentralDirectory() throws -> Int? { + let toRead = min(source.length, 65536) + if toRead < 22 { + return nil + } + + let buffer = UnsafeMutableRawBufferPointer.allocate( + byteCount: toRead, + alignment: 16 + ) + defer { + buffer.deallocate() + } + + var span = OutputRawSpan(buffer: buffer, initializedCount: 0) + + // Read the last 64KB of the file, then search backwards for the + // central directory signature. + try source.read(from: source.length - toRead, into: &span) + + if span.byteCount != toRead { + throw ZipFileError.readOffEndOfSource + } + + var pos = toRead - 22 + while pos >= 0 { + let ch = span.bytes.unsafeLoad(fromByteOffset: pos, as: UInt8.self) + switch ch { + case 0x50: // 'P' + if span.bytes.unsafeLoad(fromByteOffset: pos + 1, as: UInt8.self) == 0x4b + && span.bytes.unsafeLoad(fromByteOffset: pos + 2, as: UInt8.self) == 0x05 + && span.bytes.unsafeLoad(fromByteOffset: pos + 3, as: UInt8.self) == 0x06 + { + return source.length - toRead + pos + } + pos -= 4 + case 0x4b: // 'K' + pos -= 1 + case 0x05: + pos -= 2 + case 0x06: + pos -= 3 + default: + pos -= 4 + } + } + + return nil + } + + // If a Zip64 End of Central Directory Locator immediately precedes the + // standard EOCD record, it points to a Zip64 End of Central Directory + // Record whose 64-bit central directory size/offset unconditionally + // replace the standard EOCD's own (possibly-sentineled) 32-bit values. + func resolveZip64EndOfCentralDirectory( + eocdOffset: Int, cdSize: Int, cdOffset: Int + ) throws -> (offset: Int, size: Int) { + guard eocdOffset >= 20 else { + return (cdOffset, cdSize) + } + + let locatorOffset = eocdOffset - 20 + let locatorSignature = try source.read(from: locatorOffset, as: UInt32.self) + if locatorSignature != 0x0706_4b50 /* PK<06><07> */ { + return (cdOffset, cdSize) + } + + let zip64EocdOffset = Int(try source.read(from: locatorOffset + 8, as: UInt64.self)) + + let zip64Signature = try source.read(from: zip64EocdOffset, as: UInt32.self) + if zip64Signature != 0x0606_4b50 /* PK<06><06> */ { + throw ZipFileError.badZip64Record(at: zip64EocdOffset) + } + + let sizeOfRemainingRecord = try source.read(from: zip64EocdOffset + 4, as: UInt64.self) + if sizeOfRemainingRecord < 44 { + throw ZipFileError.badZip64Record(at: zip64EocdOffset) + } + + let zip64CdSize = Int(try source.read(from: zip64EocdOffset + 40, as: UInt64.self)) + let zip64CdOffset = Int(try source.read(from: zip64EocdOffset + 48, as: UInt64.self)) + + return (zip64CdOffset, zip64CdSize) + } + + func readDirectory() throws { + guard let eocdOffset = try findEndOfCentralDirectory() else { + throw ZipFileError.noCentralDirectory + } + + // Read the end-of-central-directory block + let signature = try source.read(from: eocdOffset, as: UInt32.self) + if signature != 0x0605_4b50 /* PK<05><06> */ { + throw ZipFileError.noCentralDirectory + } + + let disk = try source.read(from: eocdOffset + 4, as: UInt16.self) + let directoryDisk = try source.read(from: eocdOffset + 6, as: UInt16.self) + let entriesOnDisk = try source.read(from: eocdOffset + 8, as: UInt16.self) + let totalEntries = try source.read(from: eocdOffset + 10, as: UInt16.self) + + if disk != directoryDisk || entriesOnDisk != totalEntries { + throw ZipFileError.spannedArchivesNotSupported + } + + let cdSize = Int(try source.read(from: eocdOffset + 12, as: UInt32.self)) + let cdOffset = Int(try source.read(from: eocdOffset + 16, as: UInt32.self)) + + let resolvedDirectory = try resolveZip64EndOfCentralDirectory( + eocdOffset: eocdOffset, cdSize: cdSize, cdOffset: cdOffset) + + let commentLength = try source.read(from: eocdOffset + 20, as: UInt16.self) + + if commentLength > 0 { + comment = try source.read( + from: eocdOffset + 22, + asStringOfLength: Int(commentLength)) + } + + // Now read the central directory + var pos = resolvedDirectory.offset + while pos < resolvedDirectory.offset + resolvedDirectory.size { + let entryPos = pos + let signature = try source.read(from: pos, as: UInt32.self) + if signature != 0x0201_4b50 /* PK<01><02> */ { + throw ZipFileError.badDirectoryEntry(at: pos) + } + + // let madeByVersion = try source.read(from: pos + 4, as: UInt16.self) + let requiredVersion = try source.read(from: pos + 6, as: UInt16.self) + + if requiredVersion > 45 { + throw ZipFileError.unsupportedVersion(requiredVersion) + } + + let flags = try source.read(from: pos + 8, as: UInt16.self) + let compression = try source.read(from: pos + 10, as: UInt16.self) + let dosFileTime = try source.read(from: pos + 12, as: UInt32.self) + let crc32 = try source.read(from: pos + 16, as: UInt32.self) + let compressedSize32 = try source.read(from: pos + 20, as: UInt32.self) + let uncompressedSize32 = try source.read(from: pos + 24, as: UInt32.self) + let nameLength = Int(try source.read(from: pos + 28, as: UInt16.self)) + let extraLength = Int(try source.read(from: pos + 30, as: UInt16.self)) + let commentLength = Int(try source.read(from: pos + 32, as: UInt16.self)) + let diskNumberStart = try source.read(from: pos + 34, as: UInt16.self) + //let internalAttrs = try source.read(from: pos + 36, as: UInt16.self) + //let externalAttrs = try source.read(from: pos + 38, as: UInt32.self) + let localHeaderOffset32 = try source.read(from: pos + 42, as: UInt32.self) + var timestamp: Date = Date(dosFileTime: dosFileTime) + + // Zip64: each of these fields can independently be sentineled to + // 0xFFFFFFFF/0xFFFF, with the real 64-bit value recovered below + // from a 0x0001 extra field. Disk start isn't otherwise used + // (spanned archives aren't supported), but must still be + // consulted to know whether the extra field's fixed-order + // subfields include it. + let needsZip64UncompressedSize = uncompressedSize32 == 0xFFFF_FFFF + let needsZip64CompressedSize = compressedSize32 == 0xFFFF_FFFF + let needsZip64HeaderOffset = localHeaderOffset32 == 0xFFFF_FFFF + let needsZip64DiskStart = diskNumberStart == 0xFFFF + + var resolvedUncompressedSize = Int(uncompressedSize32) + var resolvedCompressedSize = Int(compressedSize32) + var resolvedHeaderOffset = Int(localHeaderOffset32) + + var foundZip64UncompressedSize = false + var foundZip64CompressedSize = false + var foundZip64HeaderOffset = false + + pos += 46 + + let name = try source.read(from: pos, asStringOfLength: nameLength) + + pos += nameLength + + let extraEnd = pos + extraLength + while pos < extraEnd { + let tag = try source.read(from: pos, as: UInt16.self) + let size = Int(try source.read(from: pos + 2, as: UInt16.self)) + + // UT - Extended Timestamp + if tag == 0x5455 && size >= 5 { + let flags = try source.read(from: pos + 4, as: UInt8.self) + if (flags & 1) != 0 { + let unixTimestamp = try source.read(from: pos + 5, as: Int32.self) + timestamp = Date( + timeIntervalSince1970: + TimeInterval(unixTimestamp)) + } + } + + // Zip64 Extended Information Extra Field. Subfields appear, + // in this fixed order, only for the standard fields that + // were sentineled above. + if tag == 0x0001 { + var subPos = pos + 4 + let subEnd = pos + 4 + size + + if needsZip64UncompressedSize { + guard subPos + 8 <= subEnd else { + throw ZipFileError.badDirectoryEntry(at: entryPos) + } + resolvedUncompressedSize = Int(try source.read(from: subPos, as: UInt64.self)) + foundZip64UncompressedSize = true + subPos += 8 + } + if needsZip64CompressedSize { + guard subPos + 8 <= subEnd else { + throw ZipFileError.badDirectoryEntry(at: entryPos) + } + resolvedCompressedSize = Int(try source.read(from: subPos, as: UInt64.self)) + foundZip64CompressedSize = true + subPos += 8 + } + if needsZip64HeaderOffset { + guard subPos + 8 <= subEnd else { + throw ZipFileError.badDirectoryEntry(at: entryPos) + } + resolvedHeaderOffset = Int(try source.read(from: subPos, as: UInt64.self)) + foundZip64HeaderOffset = true + subPos += 8 + } + if needsZip64DiskStart { + guard subPos + 4 <= subEnd else { + throw ZipFileError.badDirectoryEntry(at: entryPos) + } + // Spanned archives aren't supported, so the value + // itself is unused, but it must still be consumed to + // keep the fixed-order block fully accounted for. + subPos += 4 + } + + if subPos != subEnd { + throw ZipFileError.badDirectoryEntry(at: entryPos) + } + } + + if extraEnd - pos < size { + throw ZipFileError.badDirectoryEntry(at: entryPos) + } + pos += size + 4 + } + + // Every sentineled field must have actually been resolved by a + // 0x0001 block; a sentinel with no matching Zip64 extra field is + // a malformed entry. + if (needsZip64UncompressedSize && !foundZip64UncompressedSize) + || (needsZip64CompressedSize && !foundZip64CompressedSize) + || (needsZip64HeaderOffset && !foundZip64HeaderOffset) + { + throw ZipFileError.badDirectoryEntry(at: entryPos) + } + + pos = extraEnd + + var comment: String? = nil + if commentLength > 0 { + comment = try source.read( + from: pos, + asStringOfLength: commentLength) + pos += commentLength + } + + let isDirectory = name.hasSuffix("/") + + var pieces = name.split(separator: "/") + let filename = String(pieces.removeLast()) + var parent = root + for piece in pieces.map({ String($0) }) { + guard case .directory(var contents) = parent.kind else { + throw ZipFileError.badPathInDirectory(name) + } + + if let child = contents[piece] { + switch child.kind { + case .file: + throw ZipFileError.badPathInDirectory(name) + case .directory: + break + } + + parent = child + } else { + // We create directories where they don't exist, since the order + // of items in the directory is undefined. + let child = Item( + headerOffset: nil, + name: piece, + date: Date.now, + comment: nil, + flags: 0, + compression: 0, + crc32: 0, + compressedSize: 0, + uncompressedSize: 0, + kind: .directory([:]) + ) + + contents[piece] = child + parent.kind = .directory(contents) + + parent = child + } + } + + let newItem: Item + if isDirectory { + newItem = Item( + headerOffset: resolvedHeaderOffset, + name: filename, + date: timestamp, + comment: comment, + flags: flags, + compression: compression, + crc32: crc32, + compressedSize: resolvedCompressedSize, + uncompressedSize: resolvedUncompressedSize, + kind: .directory([:]) + ) + } else { + newItem = Item( + headerOffset: resolvedHeaderOffset, + name: filename, + date: timestamp, + comment: comment, + flags: flags, + compression: compression, + crc32: crc32, + compressedSize: resolvedCompressedSize, + uncompressedSize: resolvedUncompressedSize, + kind: .file + ) + } + + if case .directory(var contents) = parent.kind { + if let existingItem = contents[filename] { + if !isDirectory { + throw ZipFileError.duplicateEntry(name) + } + if case .file = existingItem.kind { + throw ZipFileError.duplicateEntry(name) + } + + newItem.kind = existingItem.kind + } + contents[filename] = newItem + parent.kind = .directory(contents) + } + } + } + + func normalized(path: String) -> [String]? { + var normalized: [String] = [] + for piece in path.split(separator: "/") { + if piece == "." { + continue + } + if piece == ".." { + if normalized.count == 0 { + return nil + } + + normalized.removeLast() + continue + } + normalized.append(String(piece)) + } + return normalized + } + + public func item(at path: String) -> Item? { + guard let normalizedPath = normalized(path: path) else { + return nil + } + var item = root + for piece in normalizedPath { + switch item.kind { + case .directory(let contents): + guard let newItem = contents[piece] else { + return nil + } + item = newItem + case .file: + return nil + } + } + return item + } + + public func fileExists( + atPath path: String, + isDirectory: UnsafeMutablePointer? + ) -> Bool { + guard let item = item(at: path) else { + return false + } + + if let isDirectory { + isDirectory.pointee = ObjCBool(item.isDirectory) + } + + return true + } + + public func directoryExists(atPath path: String) -> Bool { + guard let item = item(at: path) else { + return false + } + + return item.isDirectory + } + + public func fileExists(atPath path: String) -> Bool { + guard let item = item(at: path) else { + return false + } + + return item.isFile + } + + public class File { + var reader: ZipFileReader + var stream: z_stream + var buffer: UnsafeMutableRawBufferPointer? + + var isCompressed: Bool + var pos: Int + var end: Int + + private(set) var name: String + private(set) var length: Int + private(set) var timestamp: Date + + init( + reader: ZipFileReader, + buffer: UnsafeMutableRawBufferPointer?, + isCompressed: Bool, + pos: Int, end: Int, + name: String, length: Int, + timestamp: Date + ) { + self.reader = reader + self.stream = z_stream() + self.buffer = buffer + self.isCompressed = isCompressed + self.pos = pos + self.end = end + self.name = name + self.length = length + self.timestamp = timestamp + } + + deinit { + if isCompressed { + inflateEnd(&stream) + } + if let buffer { + buffer.deallocate() + } + } + + public func read(into span: inout OutputRawSpan) throws { + if isCompressed { + guard let buffer else { + fatalError("buffer is somehow unset") + } + + // We're reading and decompressing compressed data + try span.withUnsafeMutableBytes { (output, count: inout Int) in + stream.next_out = output.baseAddress!.assumingMemoryBound(to: Bytef.self) + stream.avail_out = uInt(output.count) + + while stream.avail_out != 0 { + if stream.avail_in == 0 { + let toRead = min(end - pos, buffer.count) + let rebased = UnsafeMutableRawBufferPointer( + rebasing: + buffer[0.. File { + guard let item = item(at: path) else { + throw ZipFileError.fileNotFound(path) + } + + if item.isDirectory { + throw ZipFileError.itemIsADirectory(path) + } + + // We don't support encryption, or any compression other than DEFLATE + if (item.flags & 0xf7f1) != 0 + || (item.compression != 0 && item.compression != 8) + { + throw ZipFileError.unsupportedCompression(path) + } + + // Find the local header + let pos = item.headerOffset! + let signature = try source.read(from: pos, as: UInt32.self) + if signature != 0x0403_4b50 { + throw ZipFileError.badLocalHeader(for: path) + } + + let nameLength = try source.read(from: pos + 26, as: UInt16.self) + let extraFieldLength = try source.read(from: pos + 28, as: UInt16.self) + + // The item's data starts here + let dataStart = pos + 30 + Int(nameLength) + Int(extraFieldLength) + let dataEnd = dataStart + item.compressedSize + + if item.compression == 0 { + return File( + reader: self, buffer: nil, + isCompressed: false, pos: dataStart, end: dataEnd, + name: item.name, length: item.compressedSize, + timestamp: item.date) + } else { + let file = File( + reader: self, + buffer: UnsafeMutableRawBufferPointer.allocate( + byteCount: 65536, alignment: 16 + ), + isCompressed: true, pos: dataStart, end: dataEnd, + name: item.name, length: item.uncompressedSize, + timestamp: item.date) + + let ret = inflateInit2(&file.stream, -15) + if ret != Z_OK { + throw ZLibError(message: String(cString: file.stream.msg!)) + } + + return file + } + } +} + +extension ZipFileSource { + /// Read a fixed-width integer, in little-endian order + public func read(from offset: Int, as: T.Type) throws -> T { + return try withUnsafeTemporaryAllocation( + byteCount: MemoryLayout.size, + alignment: MemoryLayout.alignment + ) { + buffer -> T in + + var output = OutputRawSpan(buffer: buffer, initializedCount: 0) + + try self.read(from: offset, into: &output) + + if output.byteCount < MemoryLayout.size { + throw ZipFileError.readOffEndOfSource + } + + return output.bytes.unsafeLoad(fromByteOffset: 0, as: T.self).littleEndian + } + } + + /// Read a UTF-8 string + public func read(from offset: Int, asStringOfLength length: Int) throws -> String { + return try withUnsafeTemporaryAllocation(byteCount: length, alignment: 1) { + buffer in + + var output = OutputRawSpan(buffer: buffer, initializedCount: 0) + + try self.read(from: offset, into: &output) + + if output.byteCount != length { + throw ZipFileError.readOffEndOfSource + } + + return output.bytes.withUnsafeBytes { bytes in + return String(decoding: bytes, as: UTF8.self) + } + } + } +} diff --git a/Sources/Zip/ZipFileWriter.swift b/Sources/Zip/ZipFileWriter.swift new file mode 100644 index 0000000000..9623de995e --- /dev/null +++ b/Sources/Zip/ZipFileWriter.swift @@ -0,0 +1,488 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2026 Apple Inc. and the Swift project authors + Licensed under Apache License v2.0 with Runtime Library Exception + + See https://swift.org/LICENSE.txt for license information + See https://swift.org/CONTRIBUTORS.txt for Swift project authors +*/ + +public import Foundation +internal import ZLib + +public struct ZLibError: Error { + var message: String +} + +public protocol ZipFileSink { + var canSeek: Bool { get } + + /// Return the current position in the stream, or nil if not seekable + func tell() throws -> Int? + + /// Seek to a location in the stream + func seek(_ pos: Int) throws + + /// Write a fixed-width integer, in little-endian order + func write(_ x: T) throws + + /// Write a UTF-8 string + func write(_ s: String) throws + + /// Write a UTF-8 substring + func write(_ s: Substring) throws + + /// Write bytes + func write(_ bytes: RawSpan) throws + + /// Close (optional) + func close() throws +} + +/// A ZipFileWriter can be used to generate a .zip file. +/// +/// This generates UTF-8 zip files. By default (`allowZip64: false`), an +/// individual file whose compressed or uncompressed size doesn't fit in 32 +/// bits causes `withFile` to throw `ZipFileError.fileTooLarge` rather than +/// produce a corrupt archive. Pass `allowZip64: true` to support such +/// files: every entry then reserves a small (20 byte) Zip64 slot in its +/// local header up front, since the local header is committed to the +/// stream before an entry's final size is known. +/// +/// Central directory and end-of-central-directory records automatically +/// use Zip64 formatting whenever needed (more than 65535 entries, or an +/// archive whose central directory itself is larger than 4GB, or -- when +/// `allowZip64` is enabled -- an oversized individual file), regardless of +/// the `allowZip64` setting: that setting only concerns whether an +/// individual oversized file is accepted at all. +public class ZipFileWriter { + + var sink: S + var comment: String? + let allowZip64: Bool + + struct FileInfo { + var headerOffset: Int + + var name: Substring + var date: Date + var comment: String? + + var crc32: UInt32 + var compressedSize: Int + var uncompressedSize: Int + } + + var files: [FileInfo] = [] + var bytesWritten = 0 + private var buffer: UnsafeMutableRawBufferPointer + + public init(sink: consuming S, comment: String? = nil, allowZip64: Bool = false) { + self.sink = sink + self.comment = comment + self.allowZip64 = allowZip64 + buffer = UnsafeMutableRawBufferPointer.allocate( + byteCount: 65536, + alignment: 16 + ) + } + + deinit { + buffer.deallocate() + } + + public func addDirectory( + named name: String, + date: Date = .now, + comment: String? = nil + ) throws { + try withFile(named: name + "/", date: date, comment: comment) { _ in } + } + + public func withFile( + named name: String, + date: Date = .now, + comment: String? = nil, + generate: ((RawSpan) throws -> Void) throws -> Void + ) throws { + // A leading "/" just means "relative to the archive root", which + // is already true of every entry, so it's dropped rather than + // stored literally. + let name = name.dropFirst(name.hasPrefix("/") ? 1 : 0) + let headerOffset = bytesWritten + + // max compression, UTF-8 name, length at end if not seekable + let flags: UInt16 = sink.canSeek ? 0x802 : 0x80a + + // Write the local header + try sink.write(UInt32(0x0403_4b50)) // PK<03><04> + // Version (Windows Explorer doesn't like us using 4.5 unless we're using Zip64) + try sink.write(UInt16(allowZip64 ? 45 : 20)) + try sink.write(flags) // max compression, UTF-8 name + try sink.write(UInt16(8)) // DEFLATE compression + try sink.write(date.dosFileTime) // last modified time + + let crcPos = try sink.tell() + try sink.write(UInt32(0)) // CRC32 + try sink.write(UInt32(0)) // Compressed size + try sink.write(UInt32(0)) // Uncompressed size + + try sink.write(UInt16(name.utf8.count)) // Filename length + try sink.write(UInt16(allowZip64 ? 29 : 9)) // Extra field length + + bytesWritten += 30 + + // Filename + try sink.write(name) + + bytesWritten += name.utf8.count + + // Extra field + try sink.write(UInt16(0x5455)) // UT - Extended Timestamp + try sink.write(UInt16(5)) // Size + try sink.write(UInt8(1)) // Only modification time + try sink.write(Int32(date.timeIntervalSince1970)) + + bytesWritten += 9 + + // Extra field: reserved Zip64 slot. Always both sizes together -- + // the local-header form of the Zip64 extra field has no + // independent per-field presence, unlike the central directory's. + // For a seekable sink this is patched below with the real sizes + // once known. For a non-seekable sink it's never patched (and + // stays zeroed): its mere presence is itself the signal, for a + // sequential reader, that the trailing data descriptor uses + // 8-byte fields. + var zip64ExtraPos: Int? = nil + if allowZip64 { + zip64ExtraPos = try sink.tell() + try sink.write(UInt16(0x0001)) // Zip64 extended information + try sink.write(UInt16(16)) // Size: two 8-byte subfields + try sink.write(UInt64(0)) // Uncompressed size (placeholder) + try sink.write(UInt64(0)) // Compressed size (placeholder) + + bytesWritten += 20 + } + + // Compress the data and update the CRC + var crc = crc32_z(0, nil, 0) + var stream = z_stream() + + stream.zalloc = nil + stream.zfree = nil + stream.opaque = nil + + stream.next_out = buffer.baseAddress!.assumingMemoryBound(to: Bytef.self) + stream.avail_out = uInt(buffer.count) + if deflateInit2(&stream, 9, Z_DEFLATED, -15, 8, Z_DEFAULT_STRATEGY) != Z_OK { + throw ZLibError(message: String(cString: stream.msg!)) + } + + try generate { (data: RawSpan) throws -> Void in + if data.byteCount == 0 { + return + } + try data.withUnsafeBytes { inbuf in + // Update CRC + crc = crc32_z(crc, inbuf.baseAddress, inbuf.count) + + // Compress this chunk + stream.next_in = UnsafeMutablePointer( + mutating: + inbuf.baseAddress!.assumingMemoryBound(to: Bytef.self) + ) + stream.avail_in = uInt(inbuf.count) + + while stream.avail_in != 0 { + let ret = deflate(&stream, Z_NO_FLUSH) + + if ret == Z_STREAM_ERROR { + throw ZLibError(message: String(cString: stream.msg!)) + } + + if stream.avail_out == 0 { + try sink.write(buffer.bytes) + bytesWritten += buffer.count + stream.next_out = buffer.baseAddress!.assumingMemoryBound(to: Bytef.self) + stream.avail_out = uInt(buffer.count) + } + } + } + } + + while true { + let ret = deflate(&stream, Z_FINISH) + if ret != Z_OK && ret != Z_BUF_ERROR && ret != Z_STREAM_END { + throw ZLibError(message: String(cString: stream.msg!)) + } + + let toFlush = buffer.count - Int(stream.avail_out) + try sink.write(buffer.bytes.extracting(first: toFlush)) + bytesWritten += toFlush + stream.next_out = buffer.baseAddress!.assumingMemoryBound(to: Bytef.self) + stream.avail_out = uInt(buffer.count) + + if ret == Z_STREAM_END { + break + } + } + + let ret = deflateEnd(&stream) + if ret != Z_OK { + throw ZLibError(message: String(cString: stream.msg!)) + } + + // A size of exactly UInt32.max is itself the Zip64 sentinel value, + // so it must be treated as "doesn't fit" too -- hence >=, not >. + let zip64SizeThreshold = UInt(UInt32.max) + let needsZip64Sizes = + stream.total_out >= zip64SizeThreshold || stream.total_in >= zip64SizeThreshold + + if !allowZip64 && needsZip64Sizes { + throw ZipFileError.fileTooLarge(String(name)) + } + + // Now update the CRC, compressed and uncompressed sizes, *or* + // write the data descriptor if this stream is not seekable. + let currentPos = try sink.tell() + if let crcPos { + // Seekable: patch the fixed 4-byte local header fields in + // place, independently sentineling each if it doesn't fit. If + // a Zip64 slot was reserved, always mirror the true 64-bit + // sizes there too -- redundant when the standard fields + // already hold real values, but harmless, since a conformant + // reader only consults a Zip64 subfield when the corresponding + // standard field is sentineled. + try sink.seek(crcPos) + try sink.write(UInt32(crc)) + try sink.write( + stream.total_out >= zip64SizeThreshold + ? UInt32(0xFFFF_FFFF) : UInt32(stream.total_out)) + try sink.write( + stream.total_in >= zip64SizeThreshold + ? UInt32(0xFFFF_FFFF) : UInt32(stream.total_in)) + + if let zip64ExtraPos { + try sink.seek(zip64ExtraPos + 4) // skip past the tag(2)+size(2) prefix + try sink.write(UInt64(stream.total_in)) // Uncompressed size + try sink.write(UInt64(stream.total_out)) // Compressed size + } + + if allowZip64 && !needsZip64Sizes { + // We optimistically claimed version 4.5 above; this entry + // didn't actually need it, so patch it back down. + try sink.seek(crcPos - 10) + try sink.write(UInt16(20)) + } + + try sink.seek(currentPos!) + } else { + // Non-seekable: write the trailing data descriptor. Its size + // fields are 8 bytes wide whenever this writer allows Zip64, + // regardless of *this* entry's actual size -- every entry + // reserved a Zip64 marker in its local header in that case, + // and that marker's presence (not this entry's real size) is + // what a streaming reader uses to decide the descriptor's + // field width. + try sink.write(UInt32(0x0807_4b50)) // PK<07><08> + try sink.write(UInt32(crc)) + if allowZip64 { + try sink.write(UInt64(stream.total_out)) + try sink.write(UInt64(stream.total_in)) + bytesWritten += 24 // 4 sig + 4 crc + 8 + 8 + } else { + try sink.write(UInt32(stream.total_out)) + try sink.write(UInt32(stream.total_in)) + bytesWritten += 16 // 4 sig + 4 crc + 4 + 4 + } + } + + // Add all of this to the list of files + files.append( + FileInfo( + headerOffset: headerOffset, + name: name, + date: date, + comment: comment, + crc32: UInt32(crc), + compressedSize: Int(stream.total_out), + uncompressedSize: Int(stream.total_in) + )) + } + + public func close() throws { + // max compression, UTF-8 name, length at end if not seekable + let flags: UInt16 = sink.canSeek ? 0x802 : 0x80a + let centralDirectoryOffset = bytesWritten + let zip64SizeThreshold = Int(UInt32.max) + + // Write the central directory + for file in files { + // A size/offset of exactly UInt32.max is itself the Zip64 + // sentinel value, so it must be treated as "doesn't fit" too -- + // hence >=, not >. This is decided independently per field: a + // small file located past the 4GB mark in a huge archive can + // need only its header offset sentineled, for instance. + let needsZip64UncompressedSize = file.uncompressedSize >= zip64SizeThreshold + let needsZip64CompressedSize = file.compressedSize >= zip64SizeThreshold + let needsZip64HeaderOffset = file.headerOffset >= zip64SizeThreshold + let needsZip64 = + needsZip64UncompressedSize || needsZip64CompressedSize || needsZip64HeaderOffset + + try sink.write(UInt32(0x0201_4b50)) // PK<01><02> + try sink.write(needsZip64 ? UInt16(45) : UInt16(20)) // version made by + try sink.write(needsZip64 ? UInt16(45) : UInt16(20)) // version needed + try sink.write(flags) // max compression, UTF-8 name + try sink.write(UInt16(8)) // DEFLATE compression + try sink.write(file.date.dosFileTime) // last modified time + try sink.write(file.crc32) // CRC32 + try sink.write( + needsZip64CompressedSize ? UInt32(0xFFFF_FFFF) : UInt32(file.compressedSize)) + try sink.write( + needsZip64UncompressedSize ? UInt32(0xFFFF_FFFF) : UInt32(file.uncompressedSize)) + try sink.write(UInt16(file.name.utf8.count)) // Filename length + + // Zip64 extra field: only the subfields that are actually + // needed, in the reader's fixed order (uncompressed size, + // compressed size, header offset). Disk start is never needed + // since spanned archives are never produced. + let zip64PayloadSize = + (needsZip64UncompressedSize ? 8 : 0) + (needsZip64CompressedSize ? 8 : 0) + + (needsZip64HeaderOffset ? 8 : 0) + let extraLength = 9 + (needsZip64 ? 4 + zip64PayloadSize : 0) + try sink.write(UInt16(extraLength)) // Extra field length + + if let comment = file.comment { + try sink.write(UInt16(comment.utf8.count)) + } else { + try sink.write(UInt16(0)) + } + + try sink.write(UInt16(0)) // Disk number start + try sink.write(UInt16(0)) // Internal file attributes + try sink.write(UInt32(0)) // External file attributes + try sink.write( + needsZip64HeaderOffset ? UInt32(0xFFFF_FFFF) : UInt32(file.headerOffset)) + + bytesWritten += 46 + + // Filename + try sink.write(file.name) + + bytesWritten += file.name.utf8.count + + // Extra field: UT - Extended Timestamp + try sink.write(UInt16(0x5455)) + try sink.write(UInt16(5)) // Size + try sink.write(UInt8(1)) // Only modification time + try sink.write(Int32(file.date.timeIntervalSince1970)) + + bytesWritten += 9 + + // Extra field: Zip64 + if needsZip64 { + try sink.write(UInt16(0x0001)) + try sink.write(UInt16(zip64PayloadSize)) + if needsZip64UncompressedSize { + try sink.write(UInt64(file.uncompressedSize)) + } + if needsZip64CompressedSize { + try sink.write(UInt64(file.compressedSize)) + } + if needsZip64HeaderOffset { + try sink.write(UInt64(file.headerOffset)) + } + + bytesWritten += 4 + zip64PayloadSize + } + + // File comment + if let comment = file.comment { + try sink.write(comment) + + bytesWritten += comment.utf8.count + } + } + + let centralDirectorySize = bytesWritten - centralDirectoryOffset + + let needsZip64EntryCount = files.count >= 0xFFFF + let needsZip64CdSize = centralDirectorySize >= zip64SizeThreshold + let needsZip64CdOffset = centralDirectoryOffset >= zip64SizeThreshold + let needsZip64Eocd = needsZip64EntryCount || needsZip64CdSize || needsZip64CdOffset + + if needsZip64Eocd { + let zip64EocdOffset = bytesWritten + + try sink.write(UInt32(0x0606_4b50)) // Zip64 EOCD record signature + try sink.write(UInt64(44)) // size of remaining record (fixed part only) + try sink.write(UInt16(45)) // version made by + try sink.write(UInt16(45)) // version needed to extract + try sink.write(UInt32(0)) // number of this disk + try sink.write(UInt32(0)) // disk with the start of the central directory + try sink.write(UInt64(files.count)) // entries on this disk + try sink.write(UInt64(files.count)) // entries in total + try sink.write(UInt64(centralDirectorySize)) // size of central directory + try sink.write(UInt64(centralDirectoryOffset)) // offset of central directory + + bytesWritten += 56 + + try sink.write(UInt32(0x0706_4b50)) // Zip64 EOCD locator signature + try sink.write(UInt32(0)) // disk with the start of the Zip64 EOCD record + try sink.write(UInt64(zip64EocdOffset)) // offset of Zip64 EOCD record + try sink.write(UInt32(1)) // total number of disks + + bytesWritten += 20 + } + + // Write the end of central directory record + try sink.write(UInt32(0x0605_4b50)) // PK<05><06> + try sink.write(UInt16(0)) // Disk 0 + try sink.write(UInt16(0)) // Directory disk 0 + try sink.write(needsZip64EntryCount ? UInt16(0xFFFF) : UInt16(files.count)) + try sink.write(needsZip64EntryCount ? UInt16(0xFFFF) : UInt16(files.count)) + try sink.write(needsZip64CdSize ? UInt32(0xFFFF_FFFF) : UInt32(centralDirectorySize)) + try sink.write(needsZip64CdOffset ? UInt32(0xFFFF_FFFF) : UInt32(centralDirectoryOffset)) + + // Zip file comment + if let comment { + try sink.write(UInt16(comment.utf8.count)) + for elt in comment.utf8 { + try sink.write(elt) + } + } else { + try sink.write(UInt16(0)) + } + + try sink.close() + } +} + +// Provide default implementations of Sink methods +public extension ZipFileSink { + func write(_ x: T) throws { + var maybeSwapped = x.littleEndian + try withUnsafeBytes(of: &maybeSwapped) { + try self.write($0.bytes) + } + } + + func write(_ s: String) throws { + var sMutable = s + try sMutable.withUTF8 { + try self.write(UnsafeRawBufferPointer($0).bytes) + } + } + + func write(_ s: Substring) throws { + var sMutable = s + try sMutable.withUTF8 { + try self.write(UnsafeRawBufferPointer($0).bytes) + } + } + + func close() throws { + // Dummy implementation + } +} diff --git a/Tests/ZipTests/ZipTests.swift b/Tests/ZipTests/ZipTests.swift new file mode 100644 index 0000000000..cde323c537 --- /dev/null +++ b/Tests/ZipTests/ZipTests.swift @@ -0,0 +1,911 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2026 Apple Inc. and the Swift project authors + Licensed under Apache License v2.0 with Runtime Library Exception + + See https://swift.org/LICENSE.txt for license information + See https://swift.org/CONTRIBUTORS.txt for Swift project authors +*/ + +import Foundation +import Testing +@testable import Zip + +// MARK: - Test helpers + +/// Builds a zip archive in memory using `ZipFileWriter` and returns the resulting bytes. +private func makeZipData( + comment: String? = nil, + allowZip64: Bool = false, + build: (ZipFileWriter) throws -> Void +) throws -> Data { + let sink = ZipFileDataSink() + let writer = ZipFileWriter(sink: sink, comment: comment, allowZip64: allowZip64) + try build(writer) + try writer.close() + return sink.data +} + +/// Feeds `data` to a `withFile` generator closure, optionally split into multiple +/// chunks, to exercise both single-shot and multi-call writes. +private func writeChunks( + _ write: (RawSpan) throws -> Void, + _ data: Data, + chunkSize: Int? = nil +) throws { + if data.isEmpty { return } + let size = chunkSize ?? data.count + var index = data.startIndex + while index < data.endIndex { + let chunkEnd = data.index(index, offsetBy: size, limitedBy: data.endIndex) ?? data.endIndex + let chunk = data[index...File) throws -> Data { + var result = Data() + let buffer = UnsafeMutableRawBufferPointer.allocate(byteCount: 8192, alignment: 16) + defer { buffer.deallocate() } + while result.count < file.length { + var span = OutputRawSpan(buffer: buffer, initializedCount: 0) + try file.read(into: &span) + if span.byteCount == 0 { break } + result.append(contentsOf: buffer[0.. Data { + var state = seed + var bytes = [UInt8]() + bytes.reserveCapacity(count) + for _ in 0..> 33)) + } + return Data(bytes) +} + +/// A `ZipFileSink` that cannot seek, forcing `ZipFileWriter` down its +/// data-descriptor (streaming) code path instead of patching sizes in place. +private final class StreamingSink: ZipFileSink { + private(set) var data = Data() + var canSeek: Bool { false } + func tell() throws -> Int? { nil } + func seek(_ pos: Int) throws { fatalError("StreamingSink cannot seek") } + func write(_ bytes: RawSpan) throws { + bytes.withUnsafeBytes { buffer in + data.append(contentsOf: buffer) + } + } +} + +/// Hand-builds a minimal zip file consisting only of central directory entries +/// (with dummy sizes/offsets) followed by an end-of-central-directory record. +/// Useful for exercising `readDirectory`'s validation logic without needing +/// real compressed payloads or local headers. +private func buildCentralDirectoryOnlyZip( + entries: [(name: String, requiredVersion: UInt16, flags: UInt16, compression: UInt16)] +) -> Data { + var cd = Data() + func appendU16(_ v: UInt16, to data: inout Data) { + withUnsafeBytes(of: v.littleEndian) { data.append(contentsOf: $0) } + } + func appendU32(_ v: UInt32, to data: inout Data) { + withUnsafeBytes(of: v.littleEndian) { data.append(contentsOf: $0) } + } + + for entry in entries { + let nameBytes = Array(entry.name.utf8) + appendU32(0x0201_4b50, to: &cd) // central directory entry signature + appendU16(20, to: &cd) // version made by + appendU16(entry.requiredVersion, to: &cd) // version needed to extract + appendU16(entry.flags, to: &cd) + appendU16(entry.compression, to: &cd) + appendU32(0, to: &cd) // dos time + appendU32(0, to: &cd) // crc32 + appendU32(0, to: &cd) // compressed size + appendU32(0, to: &cd) // uncompressed size + appendU16(UInt16(nameBytes.count), to: &cd) + appendU16(0, to: &cd) // extra field length + appendU16(0, to: &cd) // comment length + appendU16(0, to: &cd) // disk number start + appendU16(0, to: &cd) // internal attributes + appendU32(0, to: &cd) // external attributes + appendU32(0, to: &cd) // local header offset + cd.append(contentsOf: nameBytes) + } + + var eocd = Data() + appendU32(0x0605_4b50, to: &eocd) // end-of-central-directory signature + appendU16(0, to: &eocd) // disk number + appendU16(0, to: &eocd) // directory disk + appendU16(UInt16(entries.count), to: &eocd) // entries on this disk + appendU16(UInt16(entries.count), to: &eocd) // entries in total + appendU32(UInt32(cd.count), to: &eocd) // central directory size + appendU32(0, to: &eocd) // central directory offset (it's right at the start) + appendU16(0, to: &eocd) // comment length + + return cd + eocd +} + +/// Finds the byte offset of the nth central directory entry signature in `data`. +private func offsetOfCentralDirectoryEntry(in data: Data, occurrence: Int = 0) -> Int { + let signature: [UInt8] = [0x50, 0x4b, 0x01, 0x02] + var found = 0 + var index = data.startIndex + while index <= data.endIndex - signature.count { + if data[index] == signature[0], data[index + 1] == signature[1], + data[index + 2] == signature[2], data[index + 3] == signature[3] + { + if found == occurrence { return index } + found += 1 + } + index += 1 + } + fatalError("no central directory entry found") +} + +// MARK: - Round-trip tests + +@Test func writeAndReadSingleFile() throws { + let content = Data("Hello, swift-zip!".utf8) + let date = Date(timeIntervalSince1970: 1_700_000_000) + + let zipData = try makeZipData { writer in + try writer.withFile(named: "hello.txt", date: date) { write in + try writeChunks(write, content) + } + } + + let reader = try ZipFileReader(source: ZipFileDataSource(data: zipData)) + + #expect(reader.fileExists(atPath: "hello.txt")) + #expect(!reader.directoryExists(atPath: "hello.txt")) + + let file = try reader.open(path: "hello.txt") + #expect(file.name == "hello.txt") + #expect(file.length == content.count) + #expect(try readAll(file) == content) + #expect(abs(file.timestamp.timeIntervalSince(date)) < 1) +} + +@Test func writeAndReadNestedDirectories() throws { + let zipData = try makeZipData { writer in + try writer.addDirectory(named: "docs") + try writer.withFile(named: "docs/readme.txt") { write in + try writeChunks(write, Data("readme".utf8)) + } + try writer.withFile(named: "docs/sub/deep.txt") { write in + try writeChunks(write, Data("deep".utf8)) + } + try writer.withFile(named: "top.txt") { write in + try writeChunks(write, Data("top".utf8)) + } + } + + let reader = try ZipFileReader(source: ZipFileDataSource(data: zipData)) + + #expect(reader.directoryExists(atPath: "docs")) + #expect(reader.directoryExists(atPath: "docs/sub")) // implicitly created, no addDirectory call + #expect(reader.fileExists(atPath: "docs/readme.txt")) + #expect(reader.fileExists(atPath: "docs/sub/deep.txt")) + #expect(reader.fileExists(atPath: "top.txt")) + + #expect(!reader.fileExists(atPath: "docs")) + #expect(!reader.directoryExists(atPath: "top.txt")) + #expect(!reader.fileExists(atPath: "nonexistent")) + #expect(!reader.directoryExists(atPath: "nonexistent")) + + #expect(try readAll(reader.open(path: "docs/readme.txt")) == Data("readme".utf8)) + #expect(try readAll(reader.open(path: "docs/sub/deep.txt")) == Data("deep".utf8)) + #expect(try readAll(reader.open(path: "top.txt")) == Data("top".utf8)) +} + +@Test func emptyFileRoundTrips() throws { + let zipData = try makeZipData { writer in + try writer.withFile(named: "empty.txt") { _ in } + } + + let reader = try ZipFileReader(source: ZipFileDataSource(data: zipData)) + let file = try reader.open(path: "empty.txt") + #expect(file.length == 0) + #expect(try readAll(file).isEmpty) +} + +@Test func largeCompressibleFileRoundTrips() throws { + // Bigger than the writer/reader's internal 64KB buffers, to exercise + // multiple compress/flush and refill/inflate cycles. + let content = Data(repeating: 0x41, count: 500_000) + + let zipData = try makeZipData { writer in + try writer.withFile(named: "big.txt") { write in + try writeChunks(write, content, chunkSize: 16384) + } + } + + let reader = try ZipFileReader(source: ZipFileDataSource(data: zipData)) + let file = try reader.open(path: "big.txt") + #expect(file.length == content.count) + #expect(try readAll(file) == content) +} + +@Test func largeIncompressibleFileRoundTrips() throws { + let content = pseudoRandomData(count: 300_000) + + let zipData = try makeZipData { writer in + try writer.withFile(named: "random.bin") { write in + try writeChunks(write, content, chunkSize: 4096) + } + } + + let reader = try ZipFileReader(source: ZipFileDataSource(data: zipData)) + let file = try reader.open(path: "random.bin") + #expect(try readAll(file) == content) +} + +@Test func multipleFilesInOneArchiveAreIndependentlyReadable() throws { + let files: [(name: String, content: String)] = [ + ("one.txt", "first file"), + ("two.txt", "second file, a bit longer than the first"), + ("three.txt", ""), + ] + + let zipData = try makeZipData { writer in + for entry in files { + try writer.withFile(named: entry.name) { write in + try writeChunks(write, Data(entry.content.utf8)) + } + } + } + + let reader = try ZipFileReader(source: ZipFileDataSource(data: zipData)) + for entry in files { + let file = try reader.open(path: entry.name) + #expect(try readAll(file) == Data(entry.content.utf8)) + } +} + +@Test func unicodeFileNamesRoundTrip() throws { + let name = "café/☃snowman.txt" + let content = Data("frozen".utf8) + + let zipData = try makeZipData { writer in + try writer.withFile(named: name) { write in + try writeChunks(write, content) + } + } + + let reader = try ZipFileReader(source: ZipFileDataSource(data: zipData)) + #expect(reader.directoryExists(atPath: "café")) + #expect(reader.fileExists(atPath: name)) + #expect(try readAll(reader.open(path: name)) == content) +} + +@Test func archiveAndFileCommentsRoundTrip() throws { + let zipData = try makeZipData(comment: "archive comment") { writer in + try writer.withFile(named: "a.txt", comment: "file comment") { write in + try writeChunks(write, Data("a".utf8)) + } + } + + let reader = try ZipFileReader(source: ZipFileDataSource(data: zipData)) + #expect(reader.comment == "archive comment") + #expect(reader.item(at: "a.txt")?.comment == "file comment") +} + +@Test func streamingNonSeekableWriterRoundTrips() throws { + let sink = StreamingSink() + let writer = ZipFileWriter(sink: sink) + let content = Data(repeating: 0x5a, count: 200_000) + + try writer.withFile(named: "stream.bin") { write in + try writeChunks(write, content, chunkSize: 8192) + } + try writer.addDirectory(named: "streamed-dir") + try writer.close() + + let reader = try ZipFileReader(source: ZipFileDataSource(data: sink.data)) + #expect(try readAll(reader.open(path: "stream.bin")) == content) + #expect(reader.directoryExists(atPath: "streamed-dir")) +} + +// MARK: - Reader error paths + +@Test func openingMissingFileThrowsFileNotFound() throws { + let zipData = try makeZipData { writer in + try writer.withFile(named: "a.txt") { write in + try writeChunks(write, Data("a".utf8)) + } + } + let reader = try ZipFileReader(source: ZipFileDataSource(data: zipData)) + + do { + _ = try reader.open(path: "missing.txt") + Issue.record("expected fileNotFound to be thrown") + } catch ZipFileError.fileNotFound(let path) { + #expect(path == "missing.txt") + } +} + +@Test func openingDirectoryAsFileThrows() throws { + let zipData = try makeZipData { writer in + try writer.addDirectory(named: "docs") + } + let reader = try ZipFileReader(source: ZipFileDataSource(data: zipData)) + + do { + _ = try reader.open(path: "docs") + Issue.record("expected itemIsADirectory to be thrown") + } catch ZipFileError.itemIsADirectory(let path) { + #expect(path == "docs") + } +} + +@Test func unsupportedCompressionMethodThrowsOnOpen() throws { + var zipData = try makeZipData { writer in + try writer.withFile(named: "a.bin") { write in + try writeChunks(write, Data(repeating: 1, count: 100)) + } + } + + // Corrupt the compression method recorded in the central directory entry + // (the local header is left untouched, since `open` trusts the central + // directory's copy). + let entryOffset = offsetOfCentralDirectoryEntry(in: zipData) + zipData[entryOffset + 10] = 99 + zipData[entryOffset + 11] = 0 + + let reader = try ZipFileReader(source: ZipFileDataSource(data: zipData)) + do { + _ = try reader.open(path: "a.bin") + Issue.record("expected unsupportedCompression to be thrown") + } catch ZipFileError.unsupportedCompression(let path) { + #expect(path == "a.bin") + } +} + +@Test func emptyDataThrowsNoCentralDirectory() throws { + do { + _ = try ZipFileReader(source: ZipFileDataSource(data: Data())) + Issue.record("expected noCentralDirectory to be thrown") + } catch ZipFileError.noCentralDirectory { + // expected + } +} + +@Test func minimalEmptyArchiveReadsWithNoEntries() throws { + let data = buildCentralDirectoryOnlyZip(entries: []) + #expect(data.count == 22) // a bare end-of-central-directory record + + let reader = try ZipFileReader(source: ZipFileDataSource(data: data)) + #expect(reader.comment == nil) + #expect(!reader.fileExists(atPath: "anything")) + #expect(!reader.directoryExists(atPath: "anything")) +} + +@Test func unsupportedVersionThrows() throws { + let data = buildCentralDirectoryOnlyZip( + entries: [(name: "a.txt", requiredVersion: 63, flags: 0, compression: 0)]) + + do { + _ = try ZipFileReader(source: ZipFileDataSource(data: data)) + Issue.record("expected unsupportedVersion to be thrown") + } catch ZipFileError.unsupportedVersion(let version) { + #expect(version == 63) + } +} + +@Test func duplicateEntryThrows() throws { + let data = buildCentralDirectoryOnlyZip(entries: [ + (name: "dup.txt", requiredVersion: 20, flags: 0, compression: 0), + (name: "dup.txt", requiredVersion: 20, flags: 0, compression: 0), + ]) + + do { + _ = try ZipFileReader(source: ZipFileDataSource(data: data)) + Issue.record("expected duplicateEntry to be thrown") + } catch ZipFileError.duplicateEntry(let name) { + #expect(name == "dup.txt") + } +} + +// MARK: - Zip64 support + +private struct ZipDirEntrySpec { + var name: String + var requiredVersion: UInt16 = 20 + var flags: UInt16 = 0 + var compression: UInt16 = 0 + var compressedSize: UInt32 = 0 + var uncompressedSize: UInt32 = 0 + var diskNumberStart: UInt16 = 0 + var localHeaderOffset: UInt32 = 0 + var extra: Data = Data() +} + +private func appendU16(_ v: UInt16, to data: inout Data) { + withUnsafeBytes(of: v.littleEndian) { data.append(contentsOf: $0) } +} +private func appendU32(_ v: UInt32, to data: inout Data) { + withUnsafeBytes(of: v.littleEndian) { data.append(contentsOf: $0) } +} +private func appendU64(_ v: UInt64, to data: inout Data) { + withUnsafeBytes(of: v.littleEndian) { data.append(contentsOf: $0) } +} + +/// Hand-builds a central directory (no EOCD) from entries that may carry +/// explicit sizes/offset/disk-start and arbitrary extra-field bytes, for +/// testing Zip64 extra-field parsing. +private func buildZip64CentralDirectory(entries: [ZipDirEntrySpec]) -> Data { + var cd = Data() + for entry in entries { + let nameBytes = Array(entry.name.utf8) + appendU32(0x0201_4b50, to: &cd) // central directory entry signature + appendU16(20, to: &cd) // version made by + appendU16(entry.requiredVersion, to: &cd) + appendU16(entry.flags, to: &cd) + appendU16(entry.compression, to: &cd) + appendU32(0, to: &cd) // dos time + appendU32(0, to: &cd) // crc32 + appendU32(entry.compressedSize, to: &cd) + appendU32(entry.uncompressedSize, to: &cd) + appendU16(UInt16(nameBytes.count), to: &cd) + appendU16(UInt16(entry.extra.count), to: &cd) + appendU16(0, to: &cd) // comment length + appendU16(entry.diskNumberStart, to: &cd) + appendU16(0, to: &cd) // internal attributes + appendU32(0, to: &cd) // external attributes + appendU32(entry.localHeaderOffset, to: &cd) + cd.append(contentsOf: nameBytes) + cd.append(entry.extra) + } + return cd +} + +/// Builds a 0x0001 Zip64 Extended Information extra field. Only the +/// supplied (non-nil) subfields are included, in the spec-mandated fixed +/// order: uncompressed size, compressed size, header offset, disk start. +private func zip64ExtraField( + uncompressedSize: UInt64? = nil, + compressedSize: UInt64? = nil, + headerOffset: UInt64? = nil, + diskStart: UInt32? = nil +) -> Data { + var payload = Data() + if let uncompressedSize { appendU64(uncompressedSize, to: &payload) } + if let compressedSize { appendU64(compressedSize, to: &payload) } + if let headerOffset { appendU64(headerOffset, to: &payload) } + if let diskStart { appendU32(diskStart, to: &payload) } + + var field = Data() + appendU16(0x0001, to: &field) + appendU16(UInt16(payload.count), to: &field) + field.append(payload) + return field +} + +/// Appends a standard EOCD record to `cd`, optionally preceded by a Zip64 +/// EOCD record + locator carrying the true cdSize/cdOffset, in which case +/// the standard EOCD's own corresponding fields are sentineled. +private func appendEndOfCentralDirectory(to cd: Data, entryCount: Int, zip64: Bool) -> Data { + var result = cd + if zip64 { + appendU32(0x0606_4b50, to: &result) // Zip64 EOCD record signature + appendU64(44, to: &result) // size of remaining record + appendU16(45, to: &result) // version made by + appendU16(45, to: &result) // version needed to extract + appendU32(0, to: &result) // number of this disk + appendU32(0, to: &result) // disk with start of CD + appendU64(UInt64(entryCount), to: &result) // entries on this disk + appendU64(UInt64(entryCount), to: &result) // entries in total + appendU64(UInt64(cd.count), to: &result) // size of CD + appendU64(0, to: &result) // offset of start of CD + + appendU32(0x0706_4b50, to: &result) // Zip64 EOCD locator signature + appendU32(0, to: &result) // disk with start of Zip64 EOCD + appendU64(UInt64(cd.count), to: &result) // offset of Zip64 EOCD record + appendU32(1, to: &result) // total number of disks + } + + appendU32(0x0605_4b50, to: &result) // EOCD signature + appendU16(0, to: &result) // disk number + appendU16(0, to: &result) // directory disk + appendU16(zip64 ? 0xFFFF : UInt16(entryCount), to: &result) + appendU16(zip64 ? 0xFFFF : UInt16(entryCount), to: &result) + appendU32(zip64 ? 0xFFFF_FFFF : UInt32(cd.count), to: &result) + appendU32(zip64 ? 0xFFFF_FFFF : 0, to: &result) // cd offset (always 0 here) + appendU16(0, to: &result) // comment length + return result +} + +@Test func zip64ExtraFieldResolvesLargeSizesAndOffset() throws { + let bigUncompressed: UInt64 = 5_000_000_000 + let bigCompressed: UInt64 = 4_000_000_000 + let bigOffset: UInt64 = 4_294_967_296 // one past UInt32.max + + let extra = zip64ExtraField( + uncompressedSize: bigUncompressed, compressedSize: bigCompressed, headerOffset: bigOffset) + + let cd = buildZip64CentralDirectory(entries: [ + ZipDirEntrySpec( + name: "big.bin", compressedSize: 0xFFFF_FFFF, uncompressedSize: 0xFFFF_FFFF, + localHeaderOffset: 0xFFFF_FFFF, extra: extra) + ]) + let data = appendEndOfCentralDirectory(to: cd, entryCount: 1, zip64: false) + + let reader = try ZipFileReader(source: ZipFileDataSource(data: data)) + #expect(reader.item(at: "big.bin")?.uncompressedSize == Int(bigUncompressed)) + #expect(reader.item(at: "big.bin")?.compressedSize == Int(bigCompressed)) + #expect(reader.item(at: "big.bin")?.headerOffset == Int(bigOffset)) +} + +@Test func zip64ArchiveLevelEocdIsUsedWhenStandardFieldsAreSentineled() throws { + let cd = buildZip64CentralDirectory(entries: [ + ZipDirEntrySpec(name: "a.txt"), ZipDirEntrySpec(name: "b.txt"), + ]) + let data = appendEndOfCentralDirectory(to: cd, entryCount: 2, zip64: true) + + let reader = try ZipFileReader(source: ZipFileDataSource(data: data)) + #expect(reader.fileExists(atPath: "a.txt")) + #expect(reader.fileExists(atPath: "b.txt")) +} + +@Test func zip64RecordBadSignatureThrows() throws { + let cd = buildZip64CentralDirectory(entries: [ZipDirEntrySpec(name: "a.txt")]) + var data = appendEndOfCentralDirectory(to: cd, entryCount: 1, zip64: true) + data[cd.count] = 0xFF // corrupt the Zip64 EOCD record signature + + do { + _ = try ZipFileReader(source: ZipFileDataSource(data: data)) + Issue.record("expected badZip64Record to be thrown") + } catch ZipFileError.badZip64Record(let offset) { + #expect(offset == cd.count) + } +} + +@Test func zip64ExtraFieldTooShortThrows() throws { + var badField = Data() + appendU16(0x0001, to: &badField) + appendU16(4, to: &badField) // claims 4 bytes, but a size subfield needs 8 + badField.append(contentsOf: [0, 0, 0, 0]) + + let cd = buildZip64CentralDirectory(entries: [ + ZipDirEntrySpec(name: "a.txt", uncompressedSize: 0xFFFF_FFFF, extra: badField) + ]) + let data = appendEndOfCentralDirectory(to: cd, entryCount: 1, zip64: false) + + do { + _ = try ZipFileReader(source: ZipFileDataSource(data: data)) + Issue.record("expected badDirectoryEntry to be thrown") + } catch ZipFileError.badDirectoryEntry { + // expected + } +} + +@Test func zip64SentinelWithoutExtraFieldThrows() throws { + let cd = buildZip64CentralDirectory(entries: [ + ZipDirEntrySpec(name: "a.txt", uncompressedSize: 0xFFFF_FFFF) // no extra field at all + ]) + let data = appendEndOfCentralDirectory(to: cd, entryCount: 1, zip64: false) + + do { + _ = try ZipFileReader(source: ZipFileDataSource(data: data)) + Issue.record("expected badDirectoryEntry to be thrown") + } catch ZipFileError.badDirectoryEntry { + // expected + } +} + +// MARK: - Zip64 writer support + +/// Reads a little-endian integer directly out of `data` at a given byte offset. +private func readLE(_ data: Data, at offset: Int, as: T.Type) -> T { + var value: T = 0 + withUnsafeMutableBytes(of: &value) { dest in + for i in 0...size { + dest[i] = data[data.startIndex + offset + i] + } + } + return T(littleEndian: value) +} + +/// Finds the first occurrence of `signature` in `data`, scanning forward from `start`. +private func firstOffset(of signature: [UInt8], in data: Data, from start: Int = 0) -> Int? { + var index = data.startIndex + start + while index <= data.endIndex - signature.count { + if (0.. Void, + byte: UInt8, + totalCount: Int, + chunkSize: Int = 1 << 20 +) throws { + guard totalCount > 0 else { return } + let allocated = min(chunkSize, totalCount) + let buffer = UnsafeMutableRawBufferPointer.allocate(byteCount: allocated, alignment: 16) + defer { buffer.deallocate() } + buffer.initializeMemory(as: UInt8.self, repeating: byte) + + var remaining = totalCount + while remaining > 0 { + let n = min(allocated, remaining) + if n == allocated { + try write(buffer.bytes) + } else { + let rebased = UnsafeMutableRawBufferPointer(rebasing: buffer[0...File, expectedLength: Int) throws { + #expect(file.length == expectedLength) + let chunkSize = 1 << 20 + let buffer = UnsafeMutableRawBufferPointer.allocate(byteCount: chunkSize, alignment: 16) + defer { buffer.deallocate() } + let zeroChunk = Data(count: chunkSize) + + var totalRead = 0 + while totalRead < file.length { + var span = OutputRawSpan(buffer: buffer, initializedCount: 0) + try file.read(into: &span) + if span.byteCount == 0 { break } + let chunk = Data(bytesNoCopy: buffer.baseAddress!, count: span.byteCount, deallocator: .none) + #expect(chunk == zeroChunk.prefix(span.byteCount)) + totalRead += span.byteCount + } + #expect(totalRead == expectedLength) +} + +@Test func zip64DisabledIsByteIdenticalToBaseline() throws { + let content = Data("some content".utf8) + let zipData = try makeZipData { writer in // allowZip64 defaults to false + try writer.withFile(named: "a.txt") { write in + try writeChunks(write, content) + } + } + + // Local header extra length must remain 9 (no reserved Zip64 slot). + #expect(readLE(zipData, at: 28, as: UInt16.self) == 9) + // No Zip64 EOCD locator anywhere (would only appear when needed). + #expect(firstOffset(of: [0x50, 0x4b, 0x06, 0x07], in: zipData) == nil) + + let reader = try ZipFileReader(source: ZipFileDataSource(data: zipData)) + #expect(try readAll(reader.open(path: "a.txt")) == content) +} + +@Test func zip64EnabledReservesTwentyByteLocalExtraField() throws { + let content = Data("hi".utf8) + let zipData = try makeZipData(allowZip64: true) { writer in + try writer.withFile(named: "a.txt") { write in + try writeChunks(write, content) + } + } + + let nameLength = Int(readLE(zipData, at: 26, as: UInt16.self)) + let extraLength = Int(readLE(zipData, at: 28, as: UInt16.self)) + #expect(extraLength == 29) + + // The UT timestamp block occupies the first 9 bytes of the extra field; + // the reserved Zip64 block follows. + let zip64TagOffset = 30 + nameLength + 9 + #expect(readLE(zipData, at: zip64TagOffset, as: UInt16.self) == 0x0001) + #expect(readLE(zipData, at: zip64TagOffset + 2, as: UInt16.self) == 16) + #expect(readLE(zipData, at: zip64TagOffset + 4, as: UInt64.self) == UInt64(content.count)) + #expect(readLE(zipData, at: zip64TagOffset + 12, as: UInt64.self) > 0) // compressed size + + // The standard fields still hold the real (small) values, not sentinels, + // since this file doesn't actually need Zip64. + #expect(readLE(zipData, at: 22, as: UInt32.self) == UInt32(content.count)) + #expect(readLE(zipData, at: 18, as: UInt32.self) != 0xFFFF_FFFF) + + let reader = try ZipFileReader(source: ZipFileDataSource(data: zipData)) + #expect(try readAll(reader.open(path: "a.txt")) == content) +} + +@Test func zip64EnabledNonSeekableUsesEightByteDescriptor() throws { + let content = Data("hello streaming".utf8) + let sink = StreamingSink() + let writer = ZipFileWriter(sink: sink, allowZip64: true) + try writer.withFile(named: "a.txt") { write in + try writeChunks(write, content) + } + try writer.close() + + let zipData = sink.data + let nameLength = Int(readLE(zipData, at: 26, as: UInt16.self)) + let extraLength = Int(readLE(zipData, at: 28, as: UInt16.self)) + #expect(extraLength == 29) + + // Standard local header size fields are always 0 for a data-descriptor entry. + #expect(readLE(zipData, at: 18, as: UInt32.self) == 0) + #expect(readLE(zipData, at: 22, as: UInt32.self) == 0) + + let dataStart = 30 + nameLength + extraLength + guard let descriptorOffset = firstOffset(of: [0x50, 0x4b, 0x07, 0x08], in: zipData, from: dataStart) + else { + Issue.record("data descriptor not found") + return + } + + // The rest of the file is: this file's central directory entry (no + // Zip64 needed, since it's small) + the standard EOCD (no comment, no + // Zip64 needed, since there's only one small entry). + let cdEntrySize = 46 + "a.txt".utf8.count + 9 + let eocdSize = 22 + let expectedDescriptorLength = zipData.count - (cdEntrySize + eocdSize) - descriptorOffset + #expect(expectedDescriptorLength == 24) // 4 sig + 4 crc + 8 + 8 + + let reader = try ZipFileReader(source: ZipFileDataSource(data: zipData)) + #expect(try readAll(reader.open(path: "a.txt")) == content) +} + +@Test func zip64EnabledRoundTripsFileAtUInt32MaxBoundarySeekable() throws { + let size = Int(UInt32.max) + let zipData = try makeZipData(allowZip64: true) { writer in + try writer.withFile(named: "big.bin") { write in + try writeRepeatingByte(write, byte: 0, totalCount: size) + } + } + + let reader = try ZipFileReader(source: ZipFileDataSource(data: zipData)) + try verifyAllZero(reader.open(path: "big.bin"), expectedLength: size) +} + +@Test func zip64EnabledRoundTripsFileAtUInt32MaxBoundaryNonSeekable() throws { + let size = Int(UInt32.max) + let sink = StreamingSink() + let writer = ZipFileWriter(sink: sink, allowZip64: true) + try writer.withFile(named: "big.bin") { write in + try writeRepeatingByte(write, byte: 0, totalCount: size) + } + try writer.close() + + let reader = try ZipFileReader(source: ZipFileDataSource(data: sink.data)) + try verifyAllZero(reader.open(path: "big.bin"), expectedLength: size) +} + +@Test func zip64DisabledThrowsForFileAtUInt32MaxBoundary() throws { + let size = Int(UInt32.max) + let sink = ZipFileDataSink() + let writer = ZipFileWriter(sink: sink) // allowZip64 defaults to false + + do { + try writer.withFile(named: "big.bin") { write in + try writeRepeatingByte(write, byte: 0, totalCount: size) + } + Issue.record("expected fileTooLarge to be thrown") + } catch ZipFileError.fileTooLarge(let name) { + #expect(name == "big.bin") + } +} + +@Test func zip64EocdEmittedWhenEntryCountOverflows() throws { + let sink = ZipFileDataSink() + let writer = ZipFileWriter(sink: sink) + + for i in 0..<0xFFFF { + writer.files.append( + ZipFileWriter.FileInfo( + headerOffset: 0, name: "f\(i)", date: .now, comment: nil, + crc32: 0, compressedSize: 0, uncompressedSize: 0)) + } + try writer.close() + + let data = sink.data + #expect(firstOffset(of: [0x50, 0x4b, 0x06, 0x06], in: data) != nil) + + guard let eocdOffset = firstOffset(of: [0x50, 0x4b, 0x05, 0x06], in: data) else { + Issue.record("standard EOCD not found") + return + } + #expect(readLE(data, at: eocdOffset + 8, as: UInt16.self) == 0xFFFF) // entries on disk + #expect(readLE(data, at: eocdOffset + 10, as: UInt16.self) == 0xFFFF) // entries in total + + let reader = try ZipFileReader(source: ZipFileDataSource(data: data)) + #expect(reader.fileExists(atPath: "f0")) + #expect(reader.fileExists(atPath: "f\(0xFFFF - 1)")) +} + +@Test func zip64EocdEmittedWhenCentralDirectoryOffsetOverflows() throws { + let sink = ZipFileDataSink() + let writer = ZipFileWriter(sink: sink) + + let fakeOffset = Int(UInt32.max) + 100 + writer.bytesWritten = fakeOffset + writer.files.append( + ZipFileWriter.FileInfo( + headerOffset: 0, name: "f", date: .now, comment: nil, + crc32: 0, compressedSize: 0, uncompressedSize: 0)) + try writer.close() + + let data = sink.data + guard let zip64RecordOffset = firstOffset(of: [0x50, 0x4b, 0x06, 0x06], in: data) else { + Issue.record("expected a Zip64 EOCD record") + return + } + #expect(readLE(data, at: zip64RecordOffset + 48, as: UInt64.self) == UInt64(fakeOffset)) + + guard let eocdOffset = firstOffset(of: [0x50, 0x4b, 0x05, 0x06], in: data) else { + Issue.record("standard EOCD not found") + return + } + #expect(readLE(data, at: eocdOffset + 16, as: UInt32.self) == 0xFFFF_FFFF) // cd offset sentinel +} + +// MARK: - Leading slash normalization + +@Test func writerStripsLeadingSlash() throws { + func storedName(_ zipData: Data, atLocalHeaderOffset offset: Int) -> Data { + let nameLength = Int(readLE(zipData, at: offset + 26, as: UInt16.self)) + return zipData.subdata(in: (offset + 30)..<(offset + 30 + nameLength)) + } + + let zipData = try makeZipData { writer in + try writer.withFile(named: "/foo.txt") { write in + try writeChunks(write, Data("hi".utf8)) + } + try writer.withFile(named: "bar.txt") { write in + try writeChunks(write, Data("hi".utf8)) + } + try writer.addDirectory(named: "/mydir") + } + + #expect(storedName(zipData, atLocalHeaderOffset: 0) == Data("foo.txt".utf8)) + + guard let secondOffset = firstOffset(of: [0x50, 0x4b, 0x03, 0x04], in: zipData, from: 4) else { + Issue.record("expected a second local header") + return + } + #expect(storedName(zipData, atLocalHeaderOffset: secondOffset) == Data("bar.txt".utf8)) + + guard + let thirdOffset = firstOffset( + of: [0x50, 0x4b, 0x03, 0x04], in: zipData, from: secondOffset + 4) + else { + Issue.record("expected a third local header") + return + } + // The trailing slash `addDirectory` adds is kept; only a leading one is stripped. + #expect(storedName(zipData, atLocalHeaderOffset: thirdOffset) == Data("mydir/".utf8)) + + let reader = try ZipFileReader(source: ZipFileDataSource(data: zipData)) + #expect(reader.fileExists(atPath: "foo.txt")) + #expect(reader.fileExists(atPath: "bar.txt")) + #expect(reader.directoryExists(atPath: "mydir")) +} + +@Test func readerIgnoresLeadingSlashInEntryNames() throws { + let data = buildCentralDirectoryOnlyZip(entries: [ + (name: "/foo.txt", requiredVersion: 20, flags: 0, compression: 0) + ]) + + let reader = try ZipFileReader(source: ZipFileDataSource(data: data)) + #expect(reader.fileExists(atPath: "foo.txt")) + #expect(reader.fileExists(atPath: "/foo.txt")) // leading slash in the query too +} + diff --git a/bin/benchmark/Package.swift b/bin/benchmark/Package.swift index 0317549b76..5b519f8bfc 100644 --- a/bin/benchmark/Package.swift +++ b/bin/benchmark/Package.swift @@ -14,7 +14,7 @@ import PackageDescription let package = Package( name: "benchmark", platforms: [ - .macOS(.v13) + .macOS(.v15) ], products: [ .executable( From de790044e02f46945d297d2bf901664b30ce7fc2 Mon Sep 17 00:00:00 2001 From: Alastair Houghton Date: Fri, 16 Oct 2026 10:11:57 +0100 Subject: [PATCH 2/4] [Build] Fix build scripts. On Windows, we need to fetch and build zlib, because SwiftPM can't do that on its own. Also, we no longer support Swift 6.1, so exclude that version. rdar://190233135 --- .github/scripts/buildzlib.ps1 | 155 +++++++++++++++++++++++++++++ .github/workflows/pull_request.yml | 20 +++- 2 files changed, 172 insertions(+), 3 deletions(-) create mode 100644 .github/scripts/buildzlib.ps1 diff --git a/.github/scripts/buildzlib.ps1 b/.github/scripts/buildzlib.ps1 new file mode 100644 index 0000000000..fd3a289a04 --- /dev/null +++ b/.github/scripts/buildzlib.ps1 @@ -0,0 +1,155 @@ +# +# This source file is part of the Swift.org open source project +# +# Copyright (c) 2026 Apple Inc. and the Swift project authors +# Licensed under Apache License v2.0 with Runtime Library Exception +# +# See https://swift.org/LICENSE.txt for license information +# See https://swift.org/CONTRIBUTORS.txt for Swift project authors +# + +<# +.SYNOPSIS + Fetches a pinned zlib release and builds it as a static library on Windows. + +.DESCRIPTION + Downloads the source tarball for a specific zlib release then configures + and builds with CMake (Visual Studio generator by default, or whatever + -Generator specifies). Only the static target is built. Output goes to + \include and \lib. + +.PARAMETER WorkDir + Scratch directory for the download, extracted source and build tree. + +.PARAMETER OutDir + Where the headers and zlib static library are installed. + +.PARAMETER Config + Release or Debug. + +.PARAMETER Arch + Target architecture for the Visual Studio generator: x64, Win32 or ARM64. + +.PARAMETER StaticRuntime + Link the static MSVC runtime (/MT) instead of the DLL runtime (/MD). + +.PARAMETER Generator + CMake generator. Defaults to the newest Visual Studio found by CMake. + +.PARAMETER Version + The zlib release to build, without the leading 'v'. Defaults to 1.3.2. + +.EXAMPLE + .\buildzlib.ps1 -OutDir C:\libs\zlib -StaticRuntime +#> +[CmdletBinding()] +param( + [string]$WorkDir = (Join-Path $PWD 'zlib-build'), + [string]$OutDir = (Join-Path $PWD 'zlib-install'), + [ValidateSet('Release', 'Debug')][string]$Config = 'Release', + [ValidateSet('x64', 'Win32', 'ARM64')][string]$Arch = 'x64', + [switch]$StaticRuntime, + [string]$Generator, + [string]$Version = '1.3.2' +) + +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' # Invoke-WebRequest is very slow otherwise +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 -bor [Net.SecurityProtocolType]::Tls13 + +function Invoke-Native { + & $args[0] $args[1..($args.Length - 1)] + if ($LASTEXITCODE -ne 0) { throw "'$($args -join ' ')' failed with exit code $LASTEXITCODE" } +} + +foreach ($tool in 'cmake', 'tar') { + if (-not (Get-Command $tool -ErrorAction SilentlyContinue)) { + throw "Required tool '$tool' not found on PATH. Install CMake and run from a VS Developer prompt if needed." + } +} + +# 1. Choose the version +$zlibVersion = $Version.TrimStart('v') +Write-Host "zlib version: $zlibVersion" + +# 2. Download and extract into fresh directories +$downloadDir = Join-Path $WorkDir "download-$zlibVersion" +$srcRoot = Join-Path $WorkDir "src-$zlibVersion" +$buildDir = Join-Path $WorkDir "build-$zlibVersion-$Arch" +foreach ($d in $downloadDir, $srcRoot, $buildDir) { + if (Test-Path $d) { Remove-Item $d -Recurse -Force } + New-Item -ItemType Directory -Path $d | Out-Null +} + +$url = "https://github.com/madler/zlib/releases/download/v$zlibVersion/zlib-$zlibVersion.tar.gz" +$archive = Join-Path $downloadDir "zlib-$zlibVersion.tar.gz" +Write-Host "Downloading $url" +Invoke-WebRequest $url -OutFile $archive + +# Paths are passed as arguments; nothing is run from inside the extracted tree. +Invoke-Native tar -xzf $archive -C $srcRoot +$srcDir = (Get-ChildItem $srcRoot -Directory | Select-Object -First 1).FullName +if (-not $srcDir) { throw 'Archive did not extract to a directory.' } + +# 3. Configure +$cmakeArgs = @('-S', $srcDir, '-B', $buildDir) +if ($Generator) { $cmakeArgs += @('-G', $Generator) } +if (-not $Generator -or $Generator -like 'Visual Studio*') { $cmakeArgs += @('-A', $Arch) } +$cmakeArgs += '-DZLIB_BUILD_SHARED=OFF' # ignored by older releases; we only build zlibstatic anyway +if ($StaticRuntime) { + $cmakeArgs += '-DCMAKE_POLICY_DEFAULT_CMP0091=NEW' + $cmakeArgs += '-DCMAKE_MSVC_RUNTIME_LIBRARY=MultiThreaded$<$:Debug>' +} +Invoke-Native cmake @cmakeArgs + +# 4. Build only the static library +Invoke-Native cmake --build $buildDir --config $Config --target zlibstatic --parallel + +# 5. Install headers and library +$incOut = Join-Path $OutDir 'include' +$libOut = Join-Path $OutDir 'lib' +New-Item -ItemType Directory -Force -Path $incOut, $libOut | Out-Null + +# zconf.h is generated into the build tree; the source copy is renamed zconf.h.included +Copy-Item (Join-Path $srcDir 'zlib.h') $incOut -Force +$zconf = Join-Path $buildDir 'zconf.h' +if (-not (Test-Path $zconf)) { $zconf = Join-Path $srcDir 'zconf.h' } +Copy-Item $zconf $incOut -Force + +# Multi-config generators put the lib under \; single-config in . +# Pre-1.3.2, the output is named zlibstatic or, for debug, zlibstaticd. +# As of zlib-1.3.2, the output is named zs or, for debug, zsd. +# Normalize to zlib[d] +$built = Get-ChildItem $buildDir -Recurse -Filter 'z*.lib' | + Where-Object { $_.Directory.Name -in $Config, (Split-Path $buildDir -Leaf) } | + Select-Object -First 1 +if (-not $built) { $built = Get-ChildItem $buildDir -Recurse -Filter 'z*.lib' | Select-Object -First 1 } +if (-not $built) { throw 'Could not find the built static library.' } + +$destName = $built.Name -replace '^(zlibstatic|zs)', 'zlib' +Copy-Item $built.FullName (Join-Path $libOut $destName) -Force + +# Write a pkgconfig file +$pcName = Join-Path $OutDir 'zlib.pc' + +# pkg-config treats backslash as an escape character, so use forward slashes +$pcLib = $libOut -replace '\\', '/' +$pcInc = $incOut -replace '\\', '/' +# Quote paths containing whitespace, so they stay a single flag argument +if ($pcLib -match '\s') { $pcLib = "`"$pcLib`"" } +if ($pcInc -match '\s') { $pcInc = "`"$pcInc`"" } +$pcLibName = [System.IO.Path]::GetFileNameWithoutExtension($destName) + +@" +Name: zlib +Version: $zlibVersion +Description: zlib compression library +Libs: -L$pcLib -l$pcLibName +Cflags: -I$pcInc +"@ | Out-File $pcName -Encoding ascii + +Write-Host '' +Write-Host "zlib $zlibVersion ($Config, $Arch) built successfully." +Write-Host " Headers: $incOut" +Write-Host " Library: $(Join-Path $libOut $destName)" +Write-Host " pkgconfig: $pcName" diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index b52f66f612..6e5e0c812a 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -24,7 +24,19 @@ jobs: # due to issues with filenames on Windows. For now, the Windows CI runs # build the package, and only build the tests without running them. # Filenames PR: https://github.com/swiftlang/swift-docc/pull/668 - windows_build_command: "Invoke-Program swift build --build-tests" + + # Windows needs to build a copy of zlib first, because SwiftPM isn't + # able to fetch a built copy there. The CMake build below needs it too, + # because nothing else provides ZLIB::ZLIB on Windows. + windows_pre_build_command: | + Invoke-Program .\.github\scripts\prebuild.ps1 -InstallCMake -SkipAndroid + Invoke-Program .\.github\scripts\buildzlib.ps1 -OutDir $Env:TEMP\zlib -WorkDir $Env:TEMP\zlib-build + windows_build_command: 'Invoke-Program swift build --pkg-config-path $Env:TEMP\zlib --build-tests' + + # We don't support Swift 6.1 any more + macos_exclude_xcode_versions: "[{\"xcode_version\": \"swift_6.1\"}]" + linux_exclude_swift_versions: "[{\"swift_version\": \"6.1\"}]" + windows_exclude_swift_versions: "[{\"swift_version\": \"6.1\"}]" cmake: name: CMake @@ -32,7 +44,9 @@ jobs: with: enable_linux_checks: false windows_swift_versions: '["nightly-6.4.x"]' - windows_pre_build_command: 'Invoke-Program .\.github\scripts\prebuild.ps1 -InstallCMake -SkipAndroid' + windows_pre_build_command: | + Invoke-Program .\.github\scripts\prebuild.ps1 -InstallCMake -SkipAndroid + Invoke-Program .\.github\scripts\buildzlib.ps1 -OutDir $Env:TEMP\zlib -WorkDir $Env:TEMP\zlib-build windows_build_command: | $Src = Join-Path $PWD ".build\checkouts" $Bin = Join-Path $PWD "BinaryCache" @@ -61,7 +75,7 @@ jobs: Build "$Bin\swift-lmdb" "$Src\swift-lmdb" @("-DBUILD_SHARED_LIBS=NO") Build "$Bin\swift-docc-symbolkit" "$Src\swift-docc-symbolkit" @("-DBUILD_SHARED_LIBS=NO") # Build DocC - Build "$Bin\swift-docc" "$PWD" @("-DBUILD_SHARED_LIBS=YES", "-DArgumentParser_DIR=$Bin\swift-argument-parser\cmake\modules", "-DSwiftASN1_DIR=$Bin\swift-asn1\cmake\modules", "-DSwiftCrypto_DIR=$Bin\swift-crypto\cmake\modules", "-DSwiftMarkdown_DIR=$Bin\swift-markdown\cmake\modules", "-DLMDB_DIR=$Bin\swift-lmdb\cmake\modules", "-DSymbolKit_DIR=$Bin\swift-docc-symbolkit\cmake\modules", "-Dcmark-gfm_DIR=$Install\swift-cmark\lib\cmake") + Build "$Bin\swift-docc" "$PWD" @("-DBUILD_SHARED_LIBS=YES", "-DArgumentParser_DIR=$Bin\swift-argument-parser\cmake\modules", "-DSwiftASN1_DIR=$Bin\swift-asn1\cmake\modules", "-DSwiftCrypto_DIR=$Bin\swift-crypto\cmake\modules", "-DSwiftMarkdown_DIR=$Bin\swift-markdown\cmake\modules", "-DLMDB_DIR=$Bin\swift-lmdb\cmake\modules", "-DSymbolKit_DIR=$Bin\swift-docc-symbolkit\cmake\modules", "-DZLIB_ROOT=$Env:TEMP\zlib", "-Dcmark-gfm_DIR=$Install\swift-cmark\lib\cmake") soundness: name: Soundness From 8f59c22ed36f8c10f8f758ed68eeb2f0ce2611d9 Mon Sep 17 00:00:00 2001 From: Alastair Houghton Date: Sat, 17 Oct 2026 15:08:50 +0100 Subject: [PATCH 3/4] Make the Zip code have `package` scope instead of being `public`. The Zip code was taken from a separate repo elsewhere, which is why it was all `public`. Make it `package` scope here as we don't need it outside of DocC (and we don't want the DocC repo to become a source for zip functionality). rdar://190233135 --- Sources/ZLib/ZLib.swift | 8 ++--- Sources/Zip/Date+DosFileTime.swift | 6 ++-- Sources/Zip/ZipFileDataSink.swift | 18 +++++----- Sources/Zip/ZipFileDataSource.swift | 12 +++---- Sources/Zip/ZipFileError.swift | 4 +-- Sources/Zip/ZipFileReader.swift | 56 ++++++++++++++--------------- Sources/Zip/ZipFileWriter.swift | 18 +++++----- bin/vendored-zip-package-access | 22 ++++++++++++ 8 files changed, 83 insertions(+), 61 deletions(-) create mode 100755 bin/vendored-zip-package-access diff --git a/Sources/ZLib/ZLib.swift b/Sources/ZLib/ZLib.swift index 8b66afcd8f..56cea68682 100644 --- a/Sources/ZLib/ZLib.swift +++ b/Sources/ZLib/ZLib.swift @@ -12,7 +12,7 @@ // All of the below are macros in -public func deflateInit( +package func deflateInit( _ stream: z_streamp, _ level: CInt ) -> CInt { @@ -20,7 +20,7 @@ public func deflateInit( ZLIB_VERSION, CInt(MemoryLayout.size)) } -public func deflateInit2( +package func deflateInit2( _ stream: z_streamp, _ level: CInt, _ method: CInt, @@ -32,14 +32,14 @@ public func deflateInit2( ZLIB_VERSION, CInt(MemoryLayout.size)) } -public func inflateInit( +package func inflateInit( _ stream: z_streamp ) -> CInt { return inflateInit_(stream, ZLIB_VERSION, CInt(MemoryLayout.size)) } -public func inflateInit2( +package func inflateInit2( _ stream: z_streamp, _ windowBits: CInt ) -> CInt { diff --git a/Sources/Zip/Date+DosFileTime.swift b/Sources/Zip/Date+DosFileTime.swift index 472964de44..8f06a7c27c 100644 --- a/Sources/Zip/Date+DosFileTime.swift +++ b/Sources/Zip/Date+DosFileTime.swift @@ -8,10 +8,10 @@ See https://swift.org/CONTRIBUTORS.txt for Swift project authors */ -public import Foundation +package import Foundation extension Date { - public init(dosFileTime: UInt32) { + package init(dosFileTime: UInt32) { let year = 1980 + Int((dosFileTime >> 25) & 0x7f) let month = Int((dosFileTime >> 21) & 0xf) let day = Int((dosFileTime >> 16) & 0x1f) @@ -32,7 +32,7 @@ extension Date { self = components.date! } - public var dosFileTime: UInt32 { + package var dosFileTime: UInt32 { let dosMinTime: UInt32 = 0x0021_0000 let dosMaxTime: UInt32 = 0xff9f_bf7d let calendar = Calendar(identifier: .gregorian) diff --git a/Sources/Zip/ZipFileDataSink.swift b/Sources/Zip/ZipFileDataSink.swift index 7af426bb02..2b8f4d02d4 100644 --- a/Sources/Zip/ZipFileDataSink.swift +++ b/Sources/Zip/ZipFileDataSink.swift @@ -8,36 +8,36 @@ See https://swift.org/CONTRIBUTORS.txt for Swift project authors */ -public import Foundation +package import Foundation -public enum ZipFileDataSinkError: Error { +package enum ZipFileDataSinkError: Error { case badOffset } -public class ZipFileDataSink: ZipFileSink { - public var data: Data +package class ZipFileDataSink: ZipFileSink { + package var data: Data - public var canSeek: Bool { true } + package var canSeek: Bool { true } private var pos: Int - public init() { + package init() { data = Data() pos = 0 } - public func tell() throws -> Int? { + package func tell() throws -> Int? { return pos } - public func seek(_ pos: Int) throws { + package func seek(_ pos: Int) throws { if pos < 0 || pos > data.count { throw ZipFileDataSinkError.badOffset } self.pos = pos } - public func write(_ bytes: RawSpan) throws { + package func write(_ bytes: RawSpan) throws { let rangeToReplace = pos.. data.count { throw ZipFileDataSourceError.outOfRange } @@ -35,7 +35,7 @@ public class ZipFileDataSource: ZipFileSource { } } - public func close() throws { + package func close() throws { // Nothing to do } } diff --git a/Sources/Zip/ZipFileError.swift b/Sources/Zip/ZipFileError.swift index 798fabb9e8..5bb1e80040 100644 --- a/Sources/Zip/ZipFileError.swift +++ b/Sources/Zip/ZipFileError.swift @@ -8,7 +8,7 @@ See https://swift.org/CONTRIBUTORS.txt for Swift project authors */ -public enum ZipFileError: Error, CustomStringConvertible { +package enum ZipFileError: Error, CustomStringConvertible { case noCentralDirectory case readOffEndOfSource case spannedArchivesNotSupported @@ -24,7 +24,7 @@ public enum ZipFileError: Error, CustomStringConvertible { case unsupportedVersion(UInt16) case fileTooLarge(String) - public var description: String { + package var description: String { switch self { case .noCentralDirectory: "Zip file central directory missing" diff --git a/Sources/Zip/ZipFileReader.swift b/Sources/Zip/ZipFileReader.swift index fe52d6c2ea..1897bcada2 100644 --- a/Sources/Zip/ZipFileReader.swift +++ b/Sources/Zip/ZipFileReader.swift @@ -8,10 +8,10 @@ See https://swift.org/CONTRIBUTORS.txt for Swift project authors */ -public import Foundation +package import Foundation internal import ZLib -public protocol ZipFileSource { +package protocol ZipFileSource { /// The length of the source var length: Int { get } @@ -29,29 +29,29 @@ public protocol ZipFileSource { } /// A ZipFileReader can be used to read data from a .zip file. -public class ZipFileReader: @unchecked Sendable { +package class ZipFileReader: @unchecked Sendable { var source: S - public class Item: CustomStringConvertible { + package class Item: CustomStringConvertible { var headerOffset: Int? - internal(set) public var name: String - internal(set) public var date: Date - internal(set) public var comment: String? + internal(set) package var name: String + internal(set) package var date: Date + internal(set) package var comment: String? - internal(set) public var flags: UInt16 - internal(set) public var compression: UInt16 - internal(set) public var crc32: UInt32 - internal(set) public var compressedSize: Int - internal(set) public var uncompressedSize: Int + internal(set) package var flags: UInt16 + internal(set) package var compression: UInt16 + internal(set) package var crc32: UInt32 + internal(set) package var compressedSize: Int + internal(set) package var uncompressedSize: Int - public enum Kind { + package enum Kind { case file case directory([String: Item]) } - internal(set) public var kind: Kind + internal(set) package var kind: Kind init( headerOffset: Int?, name: String, date: Date, comment: String?, @@ -71,7 +71,7 @@ public class ZipFileReader: @unchecked Sendable { self.kind = kind } - public var description: String { + package var description: String { return description(indent: 0) } @@ -87,14 +87,14 @@ public class ZipFileReader: @unchecked Sendable { return result } - public var isDirectory: Bool { + package var isDirectory: Bool { switch kind { case .directory: return true case .file: return false } } - public var isFile: Bool { + package var isFile: Bool { switch kind { case .directory: return false case .file: return true @@ -102,7 +102,7 @@ public class ZipFileReader: @unchecked Sendable { } } - internal(set) public var comment: String? = nil + internal(set) package var comment: String? = nil var root = Item( headerOffset: nil, name: "/", @@ -116,7 +116,7 @@ public class ZipFileReader: @unchecked Sendable { kind: .directory([:]) ) - public init(source: S) throws { + package init(source: S) throws { self.source = source try readDirectory() } @@ -501,7 +501,7 @@ public class ZipFileReader: @unchecked Sendable { return normalized } - public func item(at path: String) -> Item? { + package func item(at path: String) -> Item? { guard let normalizedPath = normalized(path: path) else { return nil } @@ -520,7 +520,7 @@ public class ZipFileReader: @unchecked Sendable { return item } - public func fileExists( + package func fileExists( atPath path: String, isDirectory: UnsafeMutablePointer? ) -> Bool { @@ -535,7 +535,7 @@ public class ZipFileReader: @unchecked Sendable { return true } - public func directoryExists(atPath path: String) -> Bool { + package func directoryExists(atPath path: String) -> Bool { guard let item = item(at: path) else { return false } @@ -543,7 +543,7 @@ public class ZipFileReader: @unchecked Sendable { return item.isDirectory } - public func fileExists(atPath path: String) -> Bool { + package func fileExists(atPath path: String) -> Bool { guard let item = item(at: path) else { return false } @@ -551,7 +551,7 @@ public class ZipFileReader: @unchecked Sendable { return item.isFile } - public class File { + package class File { var reader: ZipFileReader var stream: z_stream var buffer: UnsafeMutableRawBufferPointer? @@ -592,7 +592,7 @@ public class ZipFileReader: @unchecked Sendable { } } - public func read(into span: inout OutputRawSpan) throws { + package func read(into span: inout OutputRawSpan) throws { if isCompressed { guard let buffer else { fatalError("buffer is somehow unset") @@ -647,7 +647,7 @@ public class ZipFileReader: @unchecked Sendable { } } - public func open(path: String) throws -> File { + package func open(path: String) throws -> File { guard let item = item(at: path) else { throw ZipFileError.fileNotFound(path) } @@ -705,7 +705,7 @@ public class ZipFileReader: @unchecked Sendable { extension ZipFileSource { /// Read a fixed-width integer, in little-endian order - public func read(from offset: Int, as: T.Type) throws -> T { + package func read(from offset: Int, as: T.Type) throws -> T { return try withUnsafeTemporaryAllocation( byteCount: MemoryLayout.size, alignment: MemoryLayout.alignment @@ -725,7 +725,7 @@ extension ZipFileSource { } /// Read a UTF-8 string - public func read(from offset: Int, asStringOfLength length: Int) throws -> String { + package func read(from offset: Int, asStringOfLength length: Int) throws -> String { return try withUnsafeTemporaryAllocation(byteCount: length, alignment: 1) { buffer in diff --git a/Sources/Zip/ZipFileWriter.swift b/Sources/Zip/ZipFileWriter.swift index 9623de995e..34f12ee5d2 100644 --- a/Sources/Zip/ZipFileWriter.swift +++ b/Sources/Zip/ZipFileWriter.swift @@ -8,14 +8,14 @@ See https://swift.org/CONTRIBUTORS.txt for Swift project authors */ -public import Foundation +package import Foundation internal import ZLib -public struct ZLibError: Error { +package struct ZLibError: Error { var message: String } -public protocol ZipFileSink { +package protocol ZipFileSink { var canSeek: Bool { get } /// Return the current position in the stream, or nil if not seekable @@ -56,7 +56,7 @@ public protocol ZipFileSink { /// `allowZip64` is enabled -- an oversized individual file), regardless of /// the `allowZip64` setting: that setting only concerns whether an /// individual oversized file is accepted at all. -public class ZipFileWriter { +package class ZipFileWriter { var sink: S var comment: String? @@ -78,7 +78,7 @@ public class ZipFileWriter { var bytesWritten = 0 private var buffer: UnsafeMutableRawBufferPointer - public init(sink: consuming S, comment: String? = nil, allowZip64: Bool = false) { + package init(sink: consuming S, comment: String? = nil, allowZip64: Bool = false) { self.sink = sink self.comment = comment self.allowZip64 = allowZip64 @@ -92,7 +92,7 @@ public class ZipFileWriter { buffer.deallocate() } - public func addDirectory( + package func addDirectory( named name: String, date: Date = .now, comment: String? = nil @@ -100,7 +100,7 @@ public class ZipFileWriter { try withFile(named: name + "/", date: date, comment: comment) { _ in } } - public func withFile( + package func withFile( named name: String, date: Date = .now, comment: String? = nil, @@ -311,7 +311,7 @@ public class ZipFileWriter { )) } - public func close() throws { + package func close() throws { // max compression, UTF-8 name, length at end if not seekable let flags: UInt16 = sink.canSeek ? 0x802 : 0x80a let centralDirectoryOffset = bytesWritten @@ -460,7 +460,7 @@ public class ZipFileWriter { } // Provide default implementations of Sink methods -public extension ZipFileSink { +package extension ZipFileSink { func write(_ x: T) throws { var maybeSwapped = x.littleEndian try withUnsafeBytes(of: &maybeSwapped) { diff --git a/bin/vendored-zip-package-access b/bin/vendored-zip-package-access new file mode 100755 index 0000000000..f3636c5a9d --- /dev/null +++ b/bin/vendored-zip-package-access @@ -0,0 +1,22 @@ +#!/bin/bash +# +# This source file is part of the Swift.org open source project +# +# Copyright (c) 2026 Apple Inc. and the Swift project authors +# Licensed under Apache License v2.0 with Runtime Library Exception +# +# See https://swift.org/LICENSE.txt for license information +# See https://swift.org/CONTRIBUTORS.txt for Swift project authors + +# The Zip and ZLib sources are vendored from another repository, where their +# API is `public`. In this repository that API shouldn't be visible outside +# the package, so rewrite `public` to `package` (including `public import`). +# +# Re-run this script after updating the vendored sources. + +set -eu + +root="$(cd "$(dirname "$0")/.." && pwd)" + +fd --type f --extension swift . "$root/Sources/Zip" "$root/Sources/ZLib" \ + --exec perl -pi -e 's/\bpublic\b/package/g' From c055aee0c4d8bd8fbd69352d107fefc9a42a1542 Mon Sep 17 00:00:00 2001 From: Alastair Houghton Date: Sat, 17 Oct 2026 15:15:05 +0100 Subject: [PATCH 4/4] We can't use `Span` in this repo. Add shims. There is currently a problem preventing us from using `Span` types in this repository. Add some shims for now. We can remove these when the problem is resolved. rdar://190233135 --- Package.swift | 2 +- Sources/Zip/CMakeLists.txt | 1 + Sources/Zip/SpanShims.swift | 85 +++++++++++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 Sources/Zip/SpanShims.swift diff --git a/Package.swift b/Package.swift index 7f20235d40..ac62b3cb34 100644 --- a/Package.swift +++ b/Package.swift @@ -65,7 +65,7 @@ let previewServerEnabledByDefault = false let package = Package( name: "SwiftDocC", platforms: [ - .macOS(.v15), + .macOS(.v13), .iOS(.v16) ], products: [ diff --git a/Sources/Zip/CMakeLists.txt b/Sources/Zip/CMakeLists.txt index ec74bdc10d..61e1d6fb39 100644 --- a/Sources/Zip/CMakeLists.txt +++ b/Sources/Zip/CMakeLists.txt @@ -9,6 +9,7 @@ See https://swift.org/LICENSE.txt for license information add_library(Zip Date+DosFileTime.swift + SpanShims.swift ZipFileDataSink.swift ZipFileDataSource.swift ZipFileError.swift diff --git a/Sources/Zip/SpanShims.swift b/Sources/Zip/SpanShims.swift new file mode 100644 index 0000000000..9808e40e19 --- /dev/null +++ b/Sources/Zip/SpanShims.swift @@ -0,0 +1,85 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2026 Apple Inc. and the Swift project authors + Licensed under Apache License v2.0 with Runtime Library Exception + + See https://swift.org/LICENSE.txt for license information + See https://swift.org/CONTRIBUTORS.txt for Swift project authors +*/ + +// This repository can't use the standard library's `Span` types yet, but the +// vendored Zip sources are written in terms of `RawSpan` and `OutputRawSpan`. +// +// These minimal, pointer-backed stand-ins provide only the API surface the Zip +// code uses. Declarations in this module shadow the standard library's, so no +// other source file needs to change. +// +// To go back to the real types, delete this file (and its entry in +// Sources/Zip/CMakeLists.txt). + +/// A stand-in for the standard library's `RawSpan`. +package struct RawSpan { + let start: UnsafeRawPointer? + package let byteCount: Int + + init(start: UnsafeRawPointer?, byteCount: Int) { + self.start = start + self.byteCount = byteCount + } + + package func withUnsafeBytes( + _ body: (UnsafeRawBufferPointer) throws -> R + ) rethrows -> R { + try body(UnsafeRawBufferPointer(start: start, count: byteCount)) + } + + package func unsafeLoad(fromByteOffset offset: Int, as type: T.Type) -> T { + start!.loadUnaligned(fromByteOffset: offset, as: type) + } + + package func extracting(first maxLength: Int) -> RawSpan { + precondition(maxLength >= 0 && maxLength <= byteCount, "Length out of bounds") + return RawSpan(start: start, byteCount: maxLength) + } +} + +/// A stand-in for the standard library's `OutputRawSpan`. +package struct OutputRawSpan { + let buffer: UnsafeMutableRawBufferPointer + package private(set) var byteCount: Int + + package init(buffer: UnsafeMutableRawBufferPointer, initializedCount: Int) { + precondition(initializedCount >= 0 && initializedCount <= buffer.count, "Count out of bounds") + self.buffer = buffer + self.byteCount = initializedCount + } + + /// The initialized portion of the buffer. + package var bytes: RawSpan { + RawSpan(start: buffer.baseAddress, byteCount: byteCount) + } + + /// Calls `body` with the whole buffer and an in/out count of initialized bytes. + package mutating func withUnsafeMutableBytes( + _ body: (UnsafeMutableRawBufferPointer, inout Int) throws -> R + ) rethrows -> R { + var count = byteCount + defer { byteCount = count } + let result = try body(buffer, &count) + precondition(count >= 0 && count <= buffer.count, "Count out of bounds") + return result + } +} + +extension UnsafeRawBufferPointer { + var bytes: RawSpan { + RawSpan(start: baseAddress, byteCount: count) + } +} + +extension UnsafeMutableRawBufferPointer { + var bytes: RawSpan { + RawSpan(start: baseAddress.map { UnsafeRawPointer($0) }, byteCount: count) + } +}