317 lines
19 KiB
Swift
317 lines
19 KiB
Swift
//
|
|
// Archive+Writing.swift
|
|
// ZIPFoundation
|
|
//
|
|
// Copyright © 2017-2025 Thomas Zoechling, https://www.peakstep.com and the ZIP Foundation project authors.
|
|
// Released under the MIT License.
|
|
//
|
|
// See https://github.com/weichsel/ZIPFoundation/blob/master/LICENSE for license information.
|
|
//
|
|
|
|
import Foundation
|
|
|
|
extension Archive {
|
|
enum ModifyOperation: Int {
|
|
case remove = -1
|
|
case add = 1
|
|
}
|
|
|
|
typealias EndOfCentralDirectoryStructure = (EndOfCentralDirectoryRecord, ZIP64EndOfCentralDirectory?)
|
|
|
|
/// Write files, directories or symlinks to the receiver.
|
|
///
|
|
/// - Parameters:
|
|
/// - path: The path that is used to identify an `Entry` within the `Archive` file.
|
|
/// - baseURL: The base URL of the resource to add.
|
|
/// The `baseURL` combined with `path` must form a fully qualified file URL.
|
|
/// - compressionMethod: Indicates the `CompressionMethod` that should be applied to `Entry`.
|
|
/// By default, no compression will be applied.
|
|
/// - bufferSize: The maximum size of the write buffer and the compression buffer (if needed).
|
|
/// - progress: A progress object that can be used to track or cancel the add operation.
|
|
/// - Throws: An error if the source file cannot be read or the receiver is not writable.
|
|
public func addEntry(with path: String, relativeTo baseURL: URL,
|
|
compressionMethod: CompressionMethod = .none,
|
|
bufferSize: Int = defaultWriteChunkSize, progress: Progress? = nil) throws {
|
|
let fileURL = baseURL.appendingPathComponent(path)
|
|
|
|
try self.addEntry(with: path, fileURL: fileURL, compressionMethod: compressionMethod,
|
|
bufferSize: bufferSize, progress: progress)
|
|
}
|
|
|
|
/// Write files, directories or symlinks to the receiver.
|
|
///
|
|
/// - Parameters:
|
|
/// - path: The path that is used to identify an `Entry` within the `Archive` file.
|
|
/// - fileURL: An absolute file URL referring to the resource to add.
|
|
/// - compressionMethod: Indicates the `CompressionMethod` that should be applied to `Entry`.
|
|
/// By default, no compression will be applied.
|
|
/// - bufferSize: The maximum size of the write buffer and the compression buffer (if needed).
|
|
/// - progress: A progress object that can be used to track or cancel the add operation.
|
|
/// - Throws: An error if the source file cannot be read or the receiver is not writable.
|
|
public func addEntry(with path: String, fileURL: URL, compressionMethod: CompressionMethod = .none,
|
|
bufferSize: Int = defaultWriteChunkSize, progress: Progress? = nil) throws {
|
|
let fileManager = FileManager()
|
|
guard fileManager.itemExists(at: fileURL) else {
|
|
throw CocoaError(.fileReadNoSuchFile, userInfo: [NSFilePathErrorKey: fileURL.path])
|
|
}
|
|
let type = try FileManager.typeForItem(at: fileURL)
|
|
// symlinks do not need to be readable
|
|
guard type == .symlink || fileManager.isReadableFile(atPath: fileURL.path) else {
|
|
throw CocoaError(.fileReadNoPermission, userInfo: [NSFilePathErrorKey: fileURL.path])
|
|
}
|
|
let modDate = try FileManager.fileModificationDateTimeForItem(at: fileURL)
|
|
let uncompressedSize = type == .directory ? 0 : try FileManager.fileSizeForItem(at: fileURL)
|
|
let permissions = try FileManager.permissionsForItem(at: fileURL)
|
|
var provider: Provider
|
|
switch type {
|
|
case .file:
|
|
let entryFileSystemRepresentation = fileManager.fileSystemRepresentation(withPath: fileURL.path)
|
|
guard let entryFile: FILEPointer = fopen(entryFileSystemRepresentation, "rb") else {
|
|
throw POSIXError(errno, path: url.path)
|
|
}
|
|
defer { fclose(entryFile) }
|
|
provider = { _, _ in return try Data.readChunk(of: bufferSize, from: entryFile) }
|
|
try self.addEntry(with: path, type: type, uncompressedSize: uncompressedSize,
|
|
modificationDate: modDate, permissions: permissions,
|
|
compressionMethod: compressionMethod, bufferSize: bufferSize,
|
|
progress: progress, provider: provider)
|
|
case .directory:
|
|
provider = { _, _ in return Data() }
|
|
try self.addEntry(with: path.hasSuffix("/") ? path : path + "/",
|
|
type: type, uncompressedSize: uncompressedSize,
|
|
modificationDate: modDate, permissions: permissions,
|
|
compressionMethod: compressionMethod, bufferSize: bufferSize,
|
|
progress: progress, provider: provider)
|
|
case .symlink:
|
|
provider = { _, _ -> Data in
|
|
let linkDestination = try fileManager.destinationOfSymbolicLink(atPath: fileURL.path)
|
|
let linkFileSystemRepresentation = fileManager.fileSystemRepresentation(withPath: linkDestination)
|
|
let linkLength = Int(strlen(linkFileSystemRepresentation))
|
|
let linkBuffer = UnsafeBufferPointer(start: linkFileSystemRepresentation, count: linkLength)
|
|
return Data(buffer: linkBuffer)
|
|
}
|
|
try self.addEntry(with: path, type: type, uncompressedSize: uncompressedSize,
|
|
modificationDate: modDate, permissions: permissions,
|
|
compressionMethod: compressionMethod, bufferSize: bufferSize,
|
|
progress: progress, provider: provider)
|
|
}
|
|
}
|
|
|
|
/// Write files, directories or symlinks to the receiver.
|
|
///
|
|
/// - Parameters:
|
|
/// - path: The path that is used to identify an `Entry` within the `Archive` file.
|
|
/// - type: Indicates the `Entry.EntryType` of the added content.
|
|
/// - uncompressedSize: The uncompressed size of the data that is going to be added with `provider`.
|
|
/// - modificationDate: A `Date` describing the file modification date of the `Entry`.
|
|
/// Default is the current `Date`.
|
|
/// - permissions: POSIX file permissions for the `Entry`.
|
|
/// Default is `0`o`644` for files and symlinks and `0`o`755` for directories.
|
|
/// - compressionMethod: Indicates the `CompressionMethod` that should be applied to `Entry`.
|
|
/// By default, no compression will be applied.
|
|
/// - bufferSize: The maximum size of the write buffer and the compression buffer (if needed).
|
|
/// - progress: A progress object that can be used to track or cancel the add operation.
|
|
/// - provider: A closure that accepts a position and a chunk size. Returns a `Data` chunk.
|
|
/// - Throws: An error if the source data is invalid or the receiver is not writable.
|
|
public func addEntry(with path: String, type: Entry.EntryType, uncompressedSize: Int64,
|
|
modificationDate: Date = Date(), permissions: UInt16? = nil,
|
|
compressionMethod: CompressionMethod = .none, bufferSize: Int = defaultWriteChunkSize,
|
|
progress: Progress? = nil, provider: Provider) throws {
|
|
guard self.accessMode != .read else { throw ArchiveError.unwritableArchive }
|
|
// Directories and symlinks cannot be compressed
|
|
let compressionMethod = type == .file ? compressionMethod : .none
|
|
progress?.totalUnitCount = type == .directory ? defaultDirectoryUnitCount : uncompressedSize
|
|
let (eocdRecord, zip64EOCD) = (self.endOfCentralDirectoryRecord, self.zip64EndOfCentralDirectory)
|
|
guard self.offsetToStartOfCentralDirectory <= .max else { throw ArchiveError.invalidCentralDirectoryOffset }
|
|
var startOfCD = Int64(self.offsetToStartOfCentralDirectory)
|
|
fseeko(self.archiveFile, off_t(startOfCD), SEEK_SET)
|
|
let existingSize = self.sizeOfCentralDirectory
|
|
let existingData = try Data.readChunk(of: Int(existingSize), from: self.archiveFile)
|
|
fseeko(self.archiveFile, off_t(startOfCD), SEEK_SET)
|
|
let fileHeaderStart = Int64(ftello(self.archiveFile))
|
|
guard fileHeaderStart >= 0 else { throw ArchiveError.unwritableArchive }
|
|
|
|
let modDateTime = modificationDate.fileModificationDateTime
|
|
defer { fflush(self.archiveFile) }
|
|
do {
|
|
// Local File Header
|
|
var localFileHeader = try self.writeLocalFileHeader(path: path, compressionMethod: compressionMethod,
|
|
size: (UInt64(uncompressedSize), 0), checksum: 0,
|
|
modificationDateTime: modDateTime)
|
|
// File Data
|
|
let (written, checksum) = try self.writeEntry(uncompressedSize: uncompressedSize, type: type,
|
|
compressionMethod: compressionMethod, bufferSize: bufferSize,
|
|
progress: progress, provider: provider)
|
|
startOfCD = Int64(ftello(self.archiveFile))
|
|
guard startOfCD >= 0 else { throw ArchiveError.unwritableArchive }
|
|
|
|
// Write the local file header a second time. Now with compressedSize (if applicable) and a valid checksum.
|
|
fseeko(self.archiveFile, off_t(fileHeaderStart), SEEK_SET)
|
|
localFileHeader = try self.writeLocalFileHeader(path: path, compressionMethod: compressionMethod,
|
|
size: (UInt64(uncompressedSize), UInt64(written)),
|
|
checksum: checksum, modificationDateTime: modDateTime)
|
|
// Central Directory
|
|
fseeko(self.archiveFile, off_t(startOfCD), SEEK_SET)
|
|
_ = try Data.writeLargeChunk(existingData, size: existingSize, bufferSize: bufferSize, to: archiveFile)
|
|
let permissions = permissions ?? (type == .directory ? defaultDirectoryPermissions : defaultFilePermissions)
|
|
let externalAttributes = FileManager.externalFileAttributesForEntry(of: type, permissions: permissions)
|
|
let centralDir = try self.writeCentralDirectoryStructure(localFileHeader: localFileHeader,
|
|
relativeOffset: UInt64(fileHeaderStart),
|
|
externalFileAttributes: externalAttributes)
|
|
// End of Central Directory Record (including ZIP64 End of Central Directory Record/Locator)
|
|
let startOfEOCD = Int64(ftello(self.archiveFile))
|
|
guard startOfEOCD >= 0 else { throw ArchiveError.unwritableArchive }
|
|
|
|
let eocd = try
|
|
self.writeEndOfCentralDirectory(centralDirectoryStructure: centralDir,
|
|
startOfCentralDirectory: UInt64(startOfCD),
|
|
startOfEndOfCentralDirectory: UInt64(startOfEOCD), operation: .add)
|
|
(self.endOfCentralDirectoryRecord, self.zip64EndOfCentralDirectory) = eocd
|
|
} catch ArchiveError.cancelledOperation {
|
|
try rollback(UInt64(fileHeaderStart), (existingData, existingSize), bufferSize, eocdRecord, zip64EOCD)
|
|
throw ArchiveError.cancelledOperation
|
|
}
|
|
}
|
|
|
|
/// Remove a ZIP `Entry` from the receiver.
|
|
///
|
|
/// - Parameters:
|
|
/// - entry: The `Entry` to remove.
|
|
/// - bufferSize: The maximum size for the read and write buffers used during removal.
|
|
/// - progress: A progress object that can be used to track or cancel the remove operation.
|
|
/// - Throws: An error if the `Entry` is malformed or the receiver is not writable.
|
|
public func remove(_ entry: Entry, bufferSize: Int = defaultReadChunkSize, progress: Progress? = nil) throws {
|
|
guard self.accessMode != .read else { throw ArchiveError.unwritableArchive }
|
|
let (tempArchive, tempDir) = try self.makeTempArchive()
|
|
defer { tempDir.map { try? FileManager().removeItem(at: $0) } }
|
|
progress?.totalUnitCount = self.totalUnitCountForRemoving(entry)
|
|
var centralDirectoryData = Data()
|
|
var offset: UInt64 = 0
|
|
for currentEntry in self {
|
|
let cds = currentEntry.centralDirectoryStructure
|
|
if currentEntry != entry {
|
|
let entryStart = cds.effectiveRelativeOffsetOfLocalHeader
|
|
fseeko(self.archiveFile, off_t(entryStart), SEEK_SET)
|
|
let provider: Provider = { (_, chunkSize) -> Data in
|
|
return try Data.readChunk(of: chunkSize, from: self.archiveFile)
|
|
}
|
|
let consumer: Consumer = {
|
|
if progress?.isCancelled == true { throw ArchiveError.cancelledOperation }
|
|
_ = try Data.write(chunk: $0, to: tempArchive.archiveFile)
|
|
progress?.completedUnitCount += Int64($0.count)
|
|
}
|
|
guard currentEntry.localSize <= .max else { throw ArchiveError.invalidLocalHeaderSize }
|
|
_ = try Data.consumePart(of: Int64(currentEntry.localSize), chunkSize: bufferSize,
|
|
provider: provider, consumer: consumer)
|
|
let updatedCentralDirectory = updateOffsetInCentralDirectory(centralDirectoryStructure: cds,
|
|
updatedOffset: entryStart - offset)
|
|
centralDirectoryData.append(updatedCentralDirectory.data)
|
|
} else { offset = currentEntry.localSize }
|
|
}
|
|
let startOfCentralDirectory = Int64(ftello(tempArchive.archiveFile))
|
|
guard startOfCentralDirectory >= 0 else { throw ArchiveError.unwritableArchive }
|
|
|
|
_ = try Data.write(chunk: centralDirectoryData, to: tempArchive.archiveFile)
|
|
let startOfEndOfCentralDirectory = Int64(ftello(tempArchive.archiveFile))
|
|
guard startOfEndOfCentralDirectory >= 0 else { throw ArchiveError.unwritableArchive }
|
|
|
|
tempArchive.endOfCentralDirectoryRecord = self.endOfCentralDirectoryRecord
|
|
tempArchive.zip64EndOfCentralDirectory = self.zip64EndOfCentralDirectory
|
|
let ecodStructure = try
|
|
tempArchive.writeEndOfCentralDirectory(centralDirectoryStructure: entry.centralDirectoryStructure,
|
|
startOfCentralDirectory: UInt64(startOfCentralDirectory),
|
|
startOfEndOfCentralDirectory: UInt64(startOfEndOfCentralDirectory),
|
|
operation: .remove)
|
|
(tempArchive.endOfCentralDirectoryRecord, tempArchive.zip64EndOfCentralDirectory) = ecodStructure
|
|
(self.endOfCentralDirectoryRecord, self.zip64EndOfCentralDirectory) = ecodStructure
|
|
fflush(tempArchive.archiveFile)
|
|
try self.replaceCurrentArchive(with: tempArchive)
|
|
}
|
|
|
|
func replaceCurrentArchive(with archive: Archive) throws {
|
|
if self.isMemoryArchive {
|
|
#if swift(>=5.0)
|
|
guard let data = archive.data else {
|
|
throw ArchiveError.unwritableArchive
|
|
}
|
|
|
|
let config = try Archive.makeBackingConfiguration(for: data, mode: .update)
|
|
self.archiveFile = config.file
|
|
self.memoryFile = config.memoryFile
|
|
self.endOfCentralDirectoryRecord = config.endOfCentralDirectoryRecord
|
|
self.zip64EndOfCentralDirectory = config.zip64EndOfCentralDirectory
|
|
#endif
|
|
} else {
|
|
let fileManager = FileManager()
|
|
#if os(macOS) || os(iOS) || os(tvOS) || os(visionOS) || os(watchOS)
|
|
do {
|
|
_ = try fileManager.replaceItemAt(self.url, withItemAt: archive.url)
|
|
} catch {
|
|
_ = try fileManager.removeItem(at: self.url)
|
|
_ = try fileManager.moveItem(at: archive.url, to: self.url)
|
|
}
|
|
#else
|
|
_ = try fileManager.removeItem(at: self.url)
|
|
_ = try fileManager.moveItem(at: archive.url, to: self.url)
|
|
#endif
|
|
let fileSystemRepresentation = fileManager.fileSystemRepresentation(withPath: self.url.path)
|
|
guard let file = fopen(fileSystemRepresentation, "rb+") else { throw ArchiveError.unreadableArchive }
|
|
|
|
self.archiveFile = file
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Private
|
|
|
|
private extension Archive {
|
|
|
|
func updateOffsetInCentralDirectory(centralDirectoryStructure: CentralDirectoryStructure,
|
|
updatedOffset: UInt64) -> CentralDirectoryStructure {
|
|
let zip64ExtendedInformation = Entry.ZIP64ExtendedInformation(
|
|
zip64ExtendedInformation: centralDirectoryStructure.zip64ExtendedInformation, offset: updatedOffset)
|
|
let offsetInCD = updatedOffset < maxOffsetOfLocalFileHeader ? UInt32(updatedOffset) : UInt32.max
|
|
return CentralDirectoryStructure(centralDirectoryStructure: centralDirectoryStructure,
|
|
zip64ExtendedInformation: zip64ExtendedInformation,
|
|
relativeOffset: offsetInCD)
|
|
}
|
|
|
|
func rollback(_ localFileHeaderStart: UInt64, _ existingCentralDirectory: (data: Data, size: UInt64),
|
|
_ bufferSize: Int, _ endOfCentralDirRecord: EndOfCentralDirectoryRecord,
|
|
_ zip64EndOfCentralDirectory: ZIP64EndOfCentralDirectory?) throws {
|
|
fflush(self.archiveFile)
|
|
ftruncate(fileno(self.archiveFile), off_t(localFileHeaderStart))
|
|
fseeko(self.archiveFile, off_t(localFileHeaderStart), SEEK_SET)
|
|
_ = try Data.writeLargeChunk(existingCentralDirectory.data, size: existingCentralDirectory.size,
|
|
bufferSize: bufferSize, to: archiveFile)
|
|
_ = try Data.write(chunk: existingCentralDirectory.data, to: self.archiveFile)
|
|
if let zip64EOCD = zip64EndOfCentralDirectory {
|
|
_ = try Data.write(chunk: zip64EOCD.data, to: self.archiveFile)
|
|
}
|
|
_ = try Data.write(chunk: endOfCentralDirRecord.data, to: self.archiveFile)
|
|
}
|
|
|
|
func makeTempArchive() throws -> (Archive, URL?) {
|
|
var archive: Archive
|
|
var url: URL?
|
|
if self.isMemoryArchive {
|
|
#if swift(>=5.0)
|
|
archive = try Archive(data: Data(), accessMode: .create,
|
|
pathEncoding: self.pathEncoding)
|
|
#else
|
|
fatalError("Memory archives are unsupported.")
|
|
#endif
|
|
} else {
|
|
let manager = FileManager()
|
|
let tempDir = URL.temporaryReplacementDirectoryURL(for: self)
|
|
let uniqueString = ProcessInfo.processInfo.globallyUniqueString
|
|
let tempArchiveURL = tempDir.appendingPathComponent(uniqueString)
|
|
try manager.createParentDirectoryStructure(for: tempArchiveURL)
|
|
let tempArchive = try Archive(url: tempArchiveURL, accessMode: .create)
|
|
archive = tempArchive
|
|
url = tempDir
|
|
}
|
|
return (archive, url)
|
|
}
|
|
}
|