diff --git a/Sources/ZIPFoundation/Archive+Helpers.swift b/Sources/ZIPFoundation/Archive+Helpers.swift index faebd6bc..2e2cc8d8 100644 --- a/Sources/ZIPFoundation/Archive+Helpers.swift +++ b/Sources/ZIPFoundation/Archive+Helpers.swift @@ -180,24 +180,30 @@ extension Archive { return (sizeOfCD - UInt64(-cdDataLengthChange), numberOfTotalEntries - UInt64(-countChange)) } }() - let sizeOfCDForEOCD = updatedSizeOfCD >= maxSizeOfCentralDirectory - ? UInt32.max - : UInt32(updatedSizeOfCD) - let numberOfTotalEntriesForEOCD = updatedNumberOfEntries >= maxTotalNumberOfEntries - ? UInt16.max - : UInt16(updatedNumberOfEntries) - let offsetOfCDForEOCD = startOfCentralDirectory >= maxOffsetOfCentralDirectory - ? UInt32.max - : UInt32(startOfCentralDirectory) + return try writeEndOfCentralDirectory(totalNumberOfEntries: updatedNumberOfEntries, + sizeOfCentralDirectory: updatedSizeOfCD, + offsetOfCentralDirectory: startOfCentralDirectory, + offsetOfEndOfCentralDirectory: startOfEndOfCentralDirectory) + } + + func writeEndOfCentralDirectory(totalNumberOfEntries: UInt64, + sizeOfCentralDirectory: UInt64, + offsetOfCentralDirectory: UInt64, + offsetOfEndOfCentralDirectory: UInt64) throws -> EndOfCentralDirectoryStructure { + var record = self.endOfCentralDirectoryRecord + let sizeOfCDForEOCD = sizeOfCentralDirectory >= maxSizeOfCentralDirectory ? UInt32.max : UInt32(sizeOfCentralDirectory) + let numberOfTotalEntriesForEOCD = totalNumberOfEntries >= maxTotalNumberOfEntries ? UInt16.max : UInt16(totalNumberOfEntries) + let offsetOfCDForEOCD = offsetOfCentralDirectory >= maxOffsetOfCentralDirectory ? UInt32.max : UInt32(offsetOfCentralDirectory) // ZIP64 End of Central Directory var zip64EOCD: ZIP64EndOfCentralDirectory? if numberOfTotalEntriesForEOCD == .max || offsetOfCDForEOCD == .max || sizeOfCDForEOCD == .max { - zip64EOCD = try self.writeZIP64EOCD(totalNumberOfEntries: updatedNumberOfEntries, - sizeOfCentralDirectory: updatedSizeOfCD, - offsetOfCentralDirectory: startOfCentralDirectory, - offsetOfEndOfCentralDirectory: startOfEndOfCentralDirectory) + zip64EOCD = try self.writeZIP64EOCD(totalNumberOfEntries: totalNumberOfEntries, + sizeOfCentralDirectory: sizeOfCentralDirectory, + offsetOfCentralDirectory: offsetOfCentralDirectory, + offsetOfEndOfCentralDirectory: offsetOfEndOfCentralDirectory) } - record = EndOfCentralDirectoryRecord(record: record, numberOfEntriesOnDisk: numberOfTotalEntriesForEOCD, + record = EndOfCentralDirectoryRecord(record: record, + numberOfEntriesOnDisk: numberOfTotalEntriesForEOCD, numberOfEntriesInCentralDirectory: numberOfTotalEntriesForEOCD, updatedSizeOfCentralDirectory: sizeOfCDForEOCD, startOfCentralDirectory: offsetOfCDForEOCD) diff --git a/Sources/ZIPFoundation/Archive+Writing.swift b/Sources/ZIPFoundation/Archive+Writing.swift index d8e43b4c..84e392e8 100644 --- a/Sources/ZIPFoundation/Archive+Writing.swift +++ b/Sources/ZIPFoundation/Archive+Writing.swift @@ -15,9 +15,9 @@ extension Archive { case remove = -1 case add = 1 } - + typealias EndOfCentralDirectoryStructure = (EndOfCentralDirectoryRecord, ZIP64EndOfCentralDirectory?) - + /// Write files, directories or symlinks to the receiver. /// /// - Parameters: @@ -33,11 +33,11 @@ extension Archive { 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: @@ -96,7 +96,7 @@ extension Archive { progress: progress, provider: provider) } } - + /// Write files, directories or symlinks to the receiver. /// /// - Parameters: @@ -165,7 +165,26 @@ extension Archive { throw ArchiveError.cancelledOperation } } - + + /// Remove ZIP `Entry` objects from the receiver. + /// + /// - Parameters: + /// - entries: The `Entry` objects to remove. Can be a single entry or multiple entries. + /// - 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 any `Entry` is malformed or the receiver is not writable. + public func remove(_ entries: [Entry], bufferSize: Int = defaultReadChunkSize, progress: Progress? = nil) throws { + guard self.accessMode != .read else { throw ArchiveError.unwritableArchive } + guard !entries.isEmpty else { return } + + // Check if we can use the efficient truncation method + if canUseTruncationForRemoval(entries: entries) { + try removeEntriesUsingTruncation(entries: entries, progress: progress) + } else { + try removeEntriesUsingRewrite(entries: entries, bufferSize: bufferSize, progress: progress) + } + } + /// Remove a ZIP `Entry` from the receiver. /// /// - Parameters: @@ -174,132 +193,270 @@ extension Archive { /// - 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 } + try remove([entry], bufferSize: bufferSize, progress: progress) + } + + func error(fromPOSIXErrorCode code: Int32) -> Error { + guard let errorCode = POSIXErrorCode(rawValue: code) else { return ArchiveError.unknownError } + return POSIXError(errorCode) + } + + func entries(beforeEntry startEntry: Entry) -> [Entry] { + var result = [Entry]() + for entry in self { + if entry == startEntry { break } + result.append(entry) + } + return result + } + + 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) + } + + /// Determines if the removal can use the efficient truncation method. + /// + /// Truncation can be used when removing consecutive entries at the end of the archive. + /// + /// - Parameter entries: The entries to be removed. + /// - Returns: True if truncation can be used, false otherwise. + private func canUseTruncationForRemoval(entries: [Entry]) -> Bool { + guard !entries.isEmpty else { return false } + + // Sort entries by their offset in the archive + let sortedEntries = entries.sorted { $0.centralDirectoryStructure.effectiveRelativeOffsetOfLocalHeader < $1.centralDirectoryStructure.effectiveRelativeOffsetOfLocalHeader } + + // Get all entries in the archive as an array + let allEntries = Array(self) + + // Sort all entries by their offset + let sortedAllEntries = allEntries.sorted { $0.centralDirectoryStructure.effectiveRelativeOffsetOfLocalHeader < $1.centralDirectoryStructure.effectiveRelativeOffsetOfLocalHeader } + + // Find the first entry to remove in the sorted list + guard let firstEntryToRemove = sortedEntries.first, + let firstIndex = sortedAllEntries.firstIndex(where: { $0.centralDirectoryStructure.effectiveRelativeOffsetOfLocalHeader == firstEntryToRemove.centralDirectoryStructure.effectiveRelativeOffsetOfLocalHeader }) else { + return false + } + + // Check if the entries to remove are consecutive and at the end + let endIndex = sortedAllEntries.count - 1 + let expectedConsecutiveCount = endIndex - firstIndex + 1 + + // If we're removing exactly the consecutive entries at the end + if entries.count == expectedConsecutiveCount { + // Verify they are actually consecutive + for i in 0.. Data in return try Data.readChunk(of: chunkSize, from: self.archiveFile) } - let consumer: Consumer = { + let consumer: Consumer = { data in if progress?.isCancelled == true { throw ArchiveError.cancelledOperation } - _ = try Data.write(chunk: $0, to: tempArchive.archiveFile) - progress?.completedUnitCount += Int64($0.count) + _ = try Data.write(chunk: data, to: tempArchive.archiveFile) + progress?.completedUnitCount += Int64(data.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) + updatedOffset: entryStart - totalRemovedSize) centralDirectoryData.append(updatedCentralDirectory.data) - } else { offset = currentEntry.localSize } + } else { + totalRemovedSize += currentEntry.localSize + } } + let startOfCentralDirectory = UInt64(ftello(tempArchive.archiveFile)) _ = try Data.write(chunk: centralDirectoryData, to: tempArchive.archiveFile) let startOfEndOfCentralDirectory = UInt64(ftello(tempArchive.archiveFile)) tempArchive.endOfCentralDirectoryRecord = self.endOfCentralDirectoryRecord tempArchive.zip64EndOfCentralDirectory = self.zip64EndOfCentralDirectory + + // Use the first entry as a representative for the central directory structure + let representativeEntry = entries.first! let ecodStructure = try - tempArchive.writeEndOfCentralDirectory(centralDirectoryStructure: entry.centralDirectoryStructure, - startOfCentralDirectory: startOfCentralDirectory, - startOfEndOfCentralDirectory: startOfEndOfCentralDirectory, - operation: .remove) + tempArchive.writeEndOfCentralDirectory(centralDirectoryStructure: representativeEntry.centralDirectoryStructure, + startOfCentralDirectory: startOfCentralDirectory, + startOfEndOfCentralDirectory: startOfEndOfCentralDirectory, + operation: .remove) (tempArchive.endOfCentralDirectoryRecord, tempArchive.zip64EndOfCentralDirectory) = ecodStructure (self.endOfCentralDirectoryRecord, self.zip64EndOfCentralDirectory) = ecodStructure fflush(tempArchive.archiveFile) try self.replaceCurrentArchive(with: tempArchive) } - + + /// Replaces the current archive with the provided archive. + /// + /// - Parameter archive: The archive to replace the current one with. + /// - Throws: An error if the replacement fails. func replaceCurrentArchive(with archive: Archive) throws { if self.isMemoryArchive { - #if swift(>=5.0) +#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 +#endif } else { let fileManager = FileManager() - #if os(macOS) || os(iOS) || os(tvOS) || os(visionOS) || os(watchOS) +#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 +#else _ = try fileManager.removeItem(at: self.url) _ = try fileManager.moveItem(at: archive.url, to: self.url) - #endif +#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) - } -} diff --git a/Sources/ZIPFoundation/Archive.swift b/Sources/ZIPFoundation/Archive.swift index 23b47664..63e4f08d 100644 --- a/Sources/ZIPFoundation/Archive.swift +++ b/Sources/ZIPFoundation/Archive.swift @@ -92,6 +92,8 @@ public final class Archive: Sequence { case missingEndOfCentralDirectoryRecord /// Thrown when an entry contains a symlink pointing to a path outside the destination directory. case uncontainedSymlink + + case unknownError } /// The access mode for an `Archive`. @@ -250,8 +252,11 @@ public final class Archive: Sequence { directoryIndex += UInt64(centralDirStruct.fileCommentLength) index += 1 } - return Entry(centralDirectoryStructure: centralDirStruct, localFileHeader: localFileHeader, - dataDescriptor: dataDescriptor, zip64DataDescriptor: zip64DataDescriptor) + return Entry(centralDirectoryStructure: centralDirStruct, + directoryIndex: directoryIndex, + localFileHeader: localFileHeader, + dataDescriptor: dataDescriptor, + zip64DataDescriptor: zip64DataDescriptor) } } diff --git a/Sources/ZIPFoundation/Entry.swift b/Sources/ZIPFoundation/Entry.swift index 300ab802..a4352136 100644 --- a/Sources/ZIPFoundation/Entry.swift +++ b/Sources/ZIPFoundation/Entry.swift @@ -198,7 +198,7 @@ public struct Entry: Equatable { } return size } - var dataOffset: UInt64 { + public var dataOffset: UInt64 { var dataOffset = self.centralDirectoryStructure.effectiveRelativeOffsetOfLocalHeader dataOffset += UInt64(LocalFileHeader.size) dataOffset += UInt64(self.localFileHeader.fileNameLength) @@ -206,6 +206,7 @@ public struct Entry: Equatable { return dataOffset } let centralDirectoryStructure: CentralDirectoryStructure + let directoryIndex: UInt64 // Offset of the entry start in the central directory (from the start of the archive) let localFileHeader: LocalFileHeader let dataDescriptor: DefaultDataDescriptor? let zip64DataDescriptor: ZIP64DataDescriptor? @@ -218,12 +219,14 @@ public struct Entry: Equatable { } init?(centralDirectoryStructure: CentralDirectoryStructure, + directoryIndex: UInt64, localFileHeader: LocalFileHeader, dataDescriptor: DefaultDataDescriptor? = nil, zip64DataDescriptor: ZIP64DataDescriptor? = nil) { // We currently don't support encrypted archives guard !centralDirectoryStructure.isEncrypted else { return nil } self.centralDirectoryStructure = centralDirectoryStructure + self.directoryIndex = directoryIndex self.localFileHeader = localFileHeader self.dataDescriptor = dataDescriptor self.zip64DataDescriptor = zip64DataDescriptor diff --git a/Tests/ZIPFoundationTests/Resources/testRemoveEntriesFromArchiveWithZIP64EOCD.zip b/Tests/ZIPFoundationTests/Resources/testRemoveEntriesFromArchiveWithZIP64EOCD.zip new file mode 100644 index 00000000..3fc4feda Binary files /dev/null and b/Tests/ZIPFoundationTests/Resources/testRemoveEntriesFromArchiveWithZIP64EOCD.zip differ diff --git a/Tests/ZIPFoundationTests/Resources/testRemoveMultipleEntriesEmptyArray.zip b/Tests/ZIPFoundationTests/Resources/testRemoveMultipleEntriesEmptyArray.zip new file mode 100644 index 00000000..c59b3979 Binary files /dev/null and b/Tests/ZIPFoundationTests/Resources/testRemoveMultipleEntriesEmptyArray.zip differ diff --git a/Tests/ZIPFoundationTests/Resources/testRemoveMultipleEntriesErrorConditions.zip b/Tests/ZIPFoundationTests/Resources/testRemoveMultipleEntriesErrorConditions.zip new file mode 100644 index 00000000..c59b3979 Binary files /dev/null and b/Tests/ZIPFoundationTests/Resources/testRemoveMultipleEntriesErrorConditions.zip differ diff --git a/Tests/ZIPFoundationTests/Resources/testRemoveMultipleEntriesFromZIP64ArchiveWithRewrite.zip b/Tests/ZIPFoundationTests/Resources/testRemoveMultipleEntriesFromZIP64ArchiveWithRewrite.zip new file mode 100644 index 00000000..3fc4feda Binary files /dev/null and b/Tests/ZIPFoundationTests/Resources/testRemoveMultipleEntriesFromZIP64ArchiveWithRewrite.zip differ diff --git a/Tests/ZIPFoundationTests/Resources/testRemoveMultipleEntriesProgress.zip b/Tests/ZIPFoundationTests/Resources/testRemoveMultipleEntriesProgress.zip new file mode 100644 index 00000000..c59b3979 Binary files /dev/null and b/Tests/ZIPFoundationTests/Resources/testRemoveMultipleEntriesProgress.zip differ diff --git a/Tests/ZIPFoundationTests/Resources/testRemoveMultipleEntriesSingleEntry.zip b/Tests/ZIPFoundationTests/Resources/testRemoveMultipleEntriesSingleEntry.zip new file mode 100644 index 00000000..c59b3979 Binary files /dev/null and b/Tests/ZIPFoundationTests/Resources/testRemoveMultipleEntriesSingleEntry.zip differ diff --git a/Tests/ZIPFoundationTests/Resources/testRemoveMultipleEntriesWithRewrite.zip b/Tests/ZIPFoundationTests/Resources/testRemoveMultipleEntriesWithRewrite.zip new file mode 100644 index 00000000..c59b3979 Binary files /dev/null and b/Tests/ZIPFoundationTests/Resources/testRemoveMultipleEntriesWithRewrite.zip differ diff --git a/Tests/ZIPFoundationTests/Resources/testRemoveMultipleEntriesWithTruncation.zip b/Tests/ZIPFoundationTests/Resources/testRemoveMultipleEntriesWithTruncation.zip new file mode 100644 index 00000000..c59b3979 Binary files /dev/null and b/Tests/ZIPFoundationTests/Resources/testRemoveMultipleEntriesWithTruncation.zip differ diff --git a/Tests/ZIPFoundationTests/ZIPFoundationEntryTests+ZIP64.swift b/Tests/ZIPFoundationTests/ZIPFoundationEntryTests+ZIP64.swift index 1206e8c7..8d217859 100644 --- a/Tests/ZIPFoundationTests/ZIPFoundationEntryTests+ZIP64.swift +++ b/Tests/ZIPFoundationTests/ZIPFoundationEntryTests+ZIP64.swift @@ -106,7 +106,9 @@ extension ZIPFoundationTests { }) else { XCTFail("Failed to read local file header."); return } - guard let entry = Entry(centralDirectoryStructure: cds, localFileHeader: lfh) else { + guard let entry = Entry(centralDirectoryStructure: cds, + directoryIndex: 0, // not required for test + localFileHeader: lfh) else { XCTFail("Failed to create test entry."); return } XCTAssertNotNil(entry.zip64ExtendedInformation) diff --git a/Tests/ZIPFoundationTests/ZIPFoundationEntryTests.swift b/Tests/ZIPFoundationTests/ZIPFoundationEntryTests.swift index 64bc5d94..8f30c502 100644 --- a/Tests/ZIPFoundationTests/ZIPFoundationEntryTests.swift +++ b/Tests/ZIPFoundationTests/ZIPFoundationEntryTests.swift @@ -110,7 +110,9 @@ extension ZIPFoundationTests { XCTFail("Failed to read local file header.") return } - guard let entry = Entry(centralDirectoryStructure: central, localFileHeader: local) else { + guard let entry = Entry(centralDirectoryStructure: central, + directoryIndex: 0, // not required for test + localFileHeader: local) else { XCTFail("Failed to read entry.") return } @@ -142,7 +144,9 @@ extension ZIPFoundationTests { XCTFail("Failed to read local file header.") return } - guard let entry = Entry(centralDirectoryStructure: central, localFileHeader: local) else { + guard let entry = Entry(centralDirectoryStructure: central, + directoryIndex: 0, // not required for test + localFileHeader: local) else { XCTFail("Failed to read entry.") return } @@ -176,7 +180,9 @@ extension ZIPFoundationTests { XCTFail("Failed to read local file header.") return } - guard let entry = Entry(centralDirectoryStructure: central, localFileHeader: local) else { + guard let entry = Entry(centralDirectoryStructure: central, + directoryIndex: 0, // not required for test + localFileHeader: local) else { XCTFail("Failed to read entry.") return } diff --git a/Tests/ZIPFoundationTests/ZIPFoundationFileAttributeTests.swift b/Tests/ZIPFoundationTests/ZIPFoundationFileAttributeTests.swift index ebea946f..ddd7fef1 100644 --- a/Tests/ZIPFoundationTests/ZIPFoundationFileAttributeTests.swift +++ b/Tests/ZIPFoundationTests/ZIPFoundationFileAttributeTests.swift @@ -37,7 +37,9 @@ extension ZIPFoundationTests { else { XCTFail("Failed to read local file header."); return } - guard let entry = Entry(centralDirectoryStructure: cds, localFileHeader: lfh) else { + guard let entry = Entry(centralDirectoryStructure: cds, + directoryIndex: 0, // not required for test + localFileHeader: lfh) else { XCTFail("Failed to create test entry."); return } let attributes = FileManager.attributes(from: entry) diff --git a/Tests/ZIPFoundationTests/ZIPFoundationTests.swift b/Tests/ZIPFoundationTests/ZIPFoundationTests.swift index 4f9e07b7..6c9bdf16 100644 --- a/Tests/ZIPFoundationTests/ZIPFoundationTests.swift +++ b/Tests/ZIPFoundationTests/ZIPFoundationTests.swift @@ -242,6 +242,12 @@ extension ZIPFoundationTests { ("testRemoveCompressedEntry", testRemoveCompressedEntry), ("testRemoveDataDescriptorCompressedEntry", testRemoveDataDescriptorCompressedEntry), ("testRemoveEntryErrorConditions", testRemoveEntryErrorConditions), + ("testRemoveMultipleEntriesWithTruncation", testRemoveMultipleEntriesWithTruncation), + ("testRemoveMultipleEntriesWithRewrite", testRemoveMultipleEntriesWithRewrite), + ("testRemoveMultipleEntriesEmptyArray", testRemoveMultipleEntriesEmptyArray), + ("testRemoveMultipleEntriesSingleEntry", testRemoveMultipleEntriesSingleEntry), + ("testRemoveMultipleEntriesErrorConditions", testRemoveMultipleEntriesErrorConditions), + ("testRemoveMultipleEntriesProgress", testRemoveMultipleEntriesProgress), ("testRemoveUncompressedEntry", testRemoveUncompressedEntry), ("testTemporaryReplacementDirectoryURL", testTemporaryReplacementDirectoryURL), ("testSimpleTraversalAttack", testSimpleTraversalAttack), @@ -284,6 +290,7 @@ extension ZIPFoundationTests { ("testRemoveEntryFromArchiveWithZIP64EOCD", testRemoveEntryFromArchiveWithZIP64EOCD), ("testRemoveZIP64EntryFromArchiveWithZIP64EOCD", testRemoveZIP64EntryFromArchiveWithZIP64EOCD), ("testRemoveEntryWithZIP64ExtendedInformation", testRemoveEntryWithZIP64ExtendedInformation), + ("testRemoveMultipleEntriesFromZIP64ArchiveWithRewrite", testRemoveMultipleEntriesFromZIP64ArchiveWithRewrite), ("testWriteEOCDWithTooLargeSizeOfCentralDirectory", testWriteEOCDWithTooLargeSizeOfCentralDirectory), ("testWriteEOCDWithTooLargeCentralDirectoryOffset", testWriteEOCDWithTooLargeCentralDirectoryOffset), ("testWriteLargeChunk", testWriteLargeChunk), diff --git a/Tests/ZIPFoundationTests/ZIPFoundationWritingTests+ZIP64.swift b/Tests/ZIPFoundationTests/ZIPFoundationWritingTests+ZIP64.swift index e75f3e3c..3044844e 100644 --- a/Tests/ZIPFoundationTests/ZIPFoundationWritingTests+ZIP64.swift +++ b/Tests/ZIPFoundationTests/ZIPFoundationWritingTests+ZIP64.swift @@ -322,6 +322,48 @@ extension ZIPFoundationTests { } XCTAssertEqual(entry4.zip64ExtendedInformation?.relativeOffsetOfLocalHeader, entry3OriginalOffset) } + + func testRemoveMultipleEntriesFromZIP64ArchiveWithRewrite() { + // testRemoveEntryWithZIP64ExtendedInformation.zip/ + // ├─ data1.random (size: 64 * 32) + // ├─ data2.random (size: 64 * 32) + // ├─ data3.random (size: 64 * 32) [headerID: 1, dataSize: 8, ..0..0, relativeOffsetOfLocalHeader: 4180, ..0] + // ├─ data4.random (size: 64 * 32) [headerID: 1, dataSize: 8, ..0..0, relativeOffsetOfLocalHeader: 6270, ..0] + self.mockIntMaxValues() + defer { self.resetIntMaxValues() } + let archive = self.archive(for: #function, mode: .update) + + // Get non-consecutive entries for rewrite test + guard let entry1 = archive["data1.random"], + let entry3 = archive["data3.random"] else { + XCTFail("Failed to retrieve ZIP64 format entries from archive") + return + } + + let entriesToRemove = [entry1, entry3] + let initialEntryCount = Array(archive).count + let entryPaths = entriesToRemove.map { $0.path } + + do { + try archive.remove(entriesToRemove) + } catch { + XCTFail("Failed to remove multiple non-consecutive ZIP64 entries with error: \(error)") + } + + XCTAssert(archive.checkIntegrity()) + XCTAssertEqual(Array(archive).count, initialEntryCount - 2, "Should have removed exactly 2 entries") + XCTAssertNotNil(archive.zip64EndOfCentralDirectory, "Should still have ZIP64 EOCD") + + // Verify removed entries are no longer accessible + for path in entryPaths { + XCTAssertNil(archive[path], "Entry \(path) should be removed from archive") + } + + // Verify remaining entries are still accessible + XCTAssertNotNil(archive["data2.random"], "data2.random should still be accessible") + XCTAssertNotNil(archive["data4.random"], "data4.random should still be accessible") + } + } extension Archive { diff --git a/Tests/ZIPFoundationTests/ZIPFoundationWritingTests.swift b/Tests/ZIPFoundationTests/ZIPFoundationWritingTests.swift index 66b72d0d..c4e3ae7a 100755 --- a/Tests/ZIPFoundationTests/ZIPFoundationWritingTests.swift +++ b/Tests/ZIPFoundationTests/ZIPFoundationWritingTests.swift @@ -263,6 +263,133 @@ extension ZIPFoundationTests { XCTAssertSwiftError(try readonlyArchive.remove(entryToRemove), throws: Archive.ArchiveError.unwritableArchive) } + func testRemoveMultipleEntriesWithTruncation() { + let archive = self.archive(for: #function, mode: .update) + // Get entries that should be at the end of the archive for truncation optimization + let allEntries = Array(archive) + let sortedEntries = allEntries.sorted { $0.centralDirectoryStructure.effectiveRelativeOffsetOfLocalHeader < $1.centralDirectoryStructure.effectiveRelativeOffsetOfLocalHeader } + + // Take the last 2 entries for truncation test + let entriesToRemove = Array(sortedEntries.suffix(2)) + XCTAssertTrue(entriesToRemove.count == 2, "Should have 2 entries to remove for truncation test") + + let initialEntryCount = allEntries.count + let entryPaths = entriesToRemove.map { $0.path } + + do { + try archive.remove(entriesToRemove) + } catch { + XCTFail("Failed to remove multiple entries with truncation with error: \(error)") + } + + XCTAssert(archive.checkIntegrity()) + XCTAssertEqual(Array(archive).count, initialEntryCount - 2, "Should have removed exactly 2 entries") + + // Verify removed entries are no longer accessible + for path in entryPaths { + XCTAssertNil(archive[path], "Entry \(path) should be removed from archive") + } + } + + func testRemoveMultipleEntriesWithRewrite() { + let archive = self.archive(for: #function, mode: .update) + + // Get non-consecutive entries that will require the rewrite method + guard let firstEntry = archive["test/data.random"], + let secondEntry = archive["test/empty/"] else { + XCTFail("Failed to find test entries for non-consecutive removal") + return + } + + let entriesToRemove = [firstEntry, secondEntry] + let initialEntryCount = Array(archive).count + let entryPaths = entriesToRemove.map { $0.path } + + do { + try archive.remove(entriesToRemove) + } catch { + XCTFail("Failed to remove multiple non-consecutive entries with error: \(error)") + } + + XCTAssert(archive.checkIntegrity()) + XCTAssertEqual(Array(archive).count, initialEntryCount - 2, "Should have removed exactly 2 entries") + + // Verify removed entries are no longer accessible + for path in entryPaths { + XCTAssertNil(archive[path], "Entry \(path) should be removed from archive") + } + } + + func testRemoveMultipleEntriesEmptyArray() { + let archive = self.archive(for: #function, mode: .update) + let initialEntryCount = Array(archive).count + + do { + try archive.remove([]) + } catch { + XCTFail("Failed to handle empty array removal with error: \(error)") + } + + XCTAssert(archive.checkIntegrity()) + XCTAssertEqual(Array(archive).count, initialEntryCount, "Should not remove any entries when given empty array") + } + + func testRemoveMultipleEntriesSingleEntry() { + let archive = self.archive(for: #function, mode: .update) + guard let entryToRemove = archive["test/data.random"] else { + XCTFail("Failed to find entry to remove") + return + } + + let initialEntryCount = Array(archive).count + let entryPath = entryToRemove.path + + do { + try archive.remove([entryToRemove]) + } catch { + XCTFail("Failed to remove single entry via batch API with error: \(error)") + } + + XCTAssert(archive.checkIntegrity()) + XCTAssertEqual(Array(archive).count, initialEntryCount - 1, "Should have removed exactly 1 entry") + XCTAssertNil(archive[entryPath], "Entry should be removed from archive") + } + + func testRemoveMultipleEntriesErrorConditions() { + let readonlyArchive = self.archive(for: #function, mode: .read) + let allEntries = Array(readonlyArchive) + guard let entry = allEntries.first else { + XCTFail("Failed to find entry in readonly archive") + return + } + + XCTAssertSwiftError(try readonlyArchive.remove([entry]), + throws: Archive.ArchiveError.unwritableArchive) + } + + func testRemoveMultipleEntriesProgress() { + let archive = self.archive(for: #function, mode: .update) + let allEntries = Array(archive) + + // Take first 3 entries for progress testing + let entriesToRemove = Array(allEntries.prefix(3)) + XCTAssertTrue(entriesToRemove.count >= 2, "Should have at least 2 entries for progress test") + + let progress = Progress(totalUnitCount: 0) + var initialCompletedCount: Int64 = 0 + + do { + initialCompletedCount = progress.completedUnitCount + try archive.remove(entriesToRemove, progress: progress) + } catch { + XCTFail("Failed to remove entries with progress tracking with error: \(error)") + } + + XCTAssert(archive.checkIntegrity()) + XCTAssertGreaterThan(progress.totalUnitCount, 0, "Progress should have been updated with total work") + XCTAssertGreaterThan(progress.completedUnitCount, initialCompletedCount, "Progress should have been updated with completed work") + } + func testArchiveCreateErrorConditions() { let existantURL = ZIPFoundationTests.tempZipDirectoryURL XCTAssertCocoaError(try Archive(url: existantURL, accessMode: .create),