Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 20 additions & 14 deletions Sources/ZIPFoundation/Archive+Helpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
317 changes: 237 additions & 80 deletions Sources/ZIPFoundation/Archive+Writing.swift

Large diffs are not rendered by default.

9 changes: 7 additions & 2 deletions Sources/ZIPFoundation/Archive.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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)
}
}

Expand Down
5 changes: 4 additions & 1 deletion Sources/ZIPFoundation/Entry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -198,14 +198,15 @@ 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)
dataOffset += UInt64(self.localFileHeader.extraFieldLength)
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?
Expand All @@ -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
Expand Down
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
4 changes: 3 additions & 1 deletion Tests/ZIPFoundationTests/ZIPFoundationEntryTests+ZIP64.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
12 changes: 9 additions & 3 deletions Tests/ZIPFoundationTests/ZIPFoundationEntryTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
7 changes: 7 additions & 0 deletions Tests/ZIPFoundationTests/ZIPFoundationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -284,6 +290,7 @@ extension ZIPFoundationTests {
("testRemoveEntryFromArchiveWithZIP64EOCD", testRemoveEntryFromArchiveWithZIP64EOCD),
("testRemoveZIP64EntryFromArchiveWithZIP64EOCD", testRemoveZIP64EntryFromArchiveWithZIP64EOCD),
("testRemoveEntryWithZIP64ExtendedInformation", testRemoveEntryWithZIP64ExtendedInformation),
("testRemoveMultipleEntriesFromZIP64ArchiveWithRewrite", testRemoveMultipleEntriesFromZIP64ArchiveWithRewrite),
("testWriteEOCDWithTooLargeSizeOfCentralDirectory", testWriteEOCDWithTooLargeSizeOfCentralDirectory),
("testWriteEOCDWithTooLargeCentralDirectoryOffset", testWriteEOCDWithTooLargeCentralDirectoryOffset),
("testWriteLargeChunk", testWriteLargeChunk),
Expand Down
42 changes: 42 additions & 0 deletions Tests/ZIPFoundationTests/ZIPFoundationWritingTests+ZIP64.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
127 changes: 127 additions & 0 deletions Tests/ZIPFoundationTests/ZIPFoundationWritingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down