Skip to content

Add support for reading and writing zip files. - #1699

Open
al45tair wants to merge 4 commits into
swiftlang:mainfrom
al45tair:pr/zip-file-support
Open

al45tair wants to merge 4 commits into
swiftlang:mainfrom
al45tair:pr/zip-file-support

Conversation

@al45tair

Copy link
Copy Markdown
Contributor

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

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
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
@al45tair
al45tair requested a review from a team as a code owner October 16, 2026 16:29
@al45tair

Copy link
Copy Markdown
Contributor Author

@swift-ci Please test

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
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
@al45tair

Copy link
Copy Markdown
Contributor Author

@swift-ci Please test

@al45tair
al45tair requested a review from d-ronnqvist October 22, 2026 09:29

@d-ronnqvist d-ronnqvist left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a very large change and I've only started to look at it but here's some initial feedback from that first pass.

Comment on lines +17 to +22
do {
let data = try contents(of: URL(filePath: path))
return data
} catch {
return nil
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minor: I think this can be written more simply using try?

Suggested change
do {
let data = try contents(of: URL(filePath: path))
return data
} catch {
return nil
}
try? contents(of: URL(filePath: path))

Comment on lines +28 to +29
let buffer = UnsafeMutableRawBufferPointer.allocate(byteCount: 65536,
alignment: 16)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be good to have a comment that explains this byte count and alignment.

Comment on lines +39 to +47
internal(set) package var name: String
internal(set) package var date: Date
internal(set) package var comment: String?

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: What's modifying these properties that require them to be internal(set)? Would it be possible to restrict this further (private(set) or fileprivate(set))?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably. I'll try restricting them some more and see if anything breaks.

while pos >= 0 {
let ch = span.bytes.unsafeLoad(fromByteOffset: pos, as: UInt8.self)
switch ch {
case 0x50: // 'P'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI: you can initialize a UInt8 value for an ascii literal to make this more readable.

Suggested change
case 0x50: // 'P'
case .init(ascii: "P"):

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that's more typing and less readable, honestly. We're really trying to find a magic number here too — the fact the PK part is ASCII is largely irrelevant.

Comment on lines +230 to +245
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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rather than having to update the offset for each read like this, I wonder if it's be worth having a "cursor" that automatically updates by MemoryLayout<T>.size for each successful read... what do you think?

Comment on lines +163 to +166
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
{

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI: IIRC it's possible to do a 3 byte load using a tuple type

Suggested change
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
{
if span.bytes.unsafeLoad(fromByteOffset: pos + 1, as: (UInt8, UInt8, UInt8).self) == (0x4b, 0x05, 0x06) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a nice improvement. I hadn't considered that.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are these types surfaced in the Zip module's API or can they have an internal access level?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They are in the API; ZipFileReader's File type, for instance, has a read(into span: inout OutputRawSpan) method.

Comment on lines +418 to +429
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a way that the write operation could accumulate the number of bytes written to eliminate the risk that this manually tracked offset gets out of sync?

try sink.write(UInt16(0))
}

try sink.close()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it expected that the sink doesn't close if it encounters a write error along the way? In other words; should this close() call be in a defer statement earlier in this method body?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a good idea. Let's put it in a defer at the top.

Comment thread .swift-format

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would rather that we don't commit any formatting configuration files.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Drat. I hadn't intended to add that (again). I should add it to .gitignores or similar, maybe.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants