Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New function to efficiently remove the last entries from an archive #333

Open
wants to merge 2 commits into
base: development
Choose a base branch
from
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 @@
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)

Check failure on line 194 in Sources/ZIPFoundation/Archive+Helpers.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Line Length Violation: Line should be 120 characters or less; currently it has 127 characters (line_length)
let numberOfTotalEntriesForEOCD = totalNumberOfEntries >= maxTotalNumberOfEntries ? UInt16.max : UInt16(totalNumberOfEntries)

Check failure on line 195 in Sources/ZIPFoundation/Archive+Helpers.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Line Length Violation: Line should be 120 characters or less; currently it has 133 characters (line_length)
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
56 changes: 56 additions & 0 deletions Sources/ZIPFoundation/Archive+Writing.swift
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,62 @@ extension Archive {
self.archiveFile = file
}
}

/// Removes the given entry and all entries that follow it.
///
/// Note: In contrast to remove(), this function does not need to re-write the whole archive as it simply truncates the file before the
/// given entry and appends a new central directory.
///
/// - Parameters:
/// - entry: The `Entry` at which to start the remove.
/// - Throws: An error if the `Entry` is malformed or the receiver is not writable.
public func removeAllEntries(fromEntry entry: Entry) throws {
guard self.accessMode != .read else { throw ArchiveError.unwritableArchive }
let remainingEntries = entries(beforeEntry: entry)
guard self.offsetToStartOfCentralDirectory <= .max else { throw ArchiveError.invalidCentralDirectoryOffset }

// Rescue all precending entries in the central directory as a data copy
let startOfCD = self.offsetToStartOfCentralDirectory
let entryCDStartOffset = entry.directoryIndex
fseeko(self.archiveFile, off_t(startOfCD), SEEK_SET)
let remainingCDSize = entryCDStartOffset - startOfCD
let remainingCDData = try Data.readChunk(of: Int(remainingCDSize), from: self.archiveFile)

// Truncate everything from the local entry (including the central directory)
defer { fflush(self.archiveFile) }
let entryLocalStartOffset = entry.centralDirectoryStructure.effectiveRelativeOffsetOfLocalHeader
let archiveFD = fileno(self.archiveFile)
guard archiveFD != -1, ftruncate(archiveFD, off_t(entryLocalStartOffset)) != -1 else {
throw error(fromPOSIXErrorCode: errno)
}

// Re-append the central directory with the remaining entries
let newStartOfCD = entryLocalStartOffset
fseeko(self.archiveFile, off_t(0), SEEK_END)
_ = try Data.write(chunk: remainingCDData, to: self.archiveFile)

// Append the End of Central Directory Record (including ZIP64 End of Central Directory Record/Locator)
let startOfEOCD = UInt64(ftello(self.archiveFile))
let eocd = try self.writeEndOfCentralDirectory(totalNumberOfEntries: UInt64(remainingEntries.count),
sizeOfCentralDirectory: remainingCDSize,
offsetOfCentralDirectory: newStartOfCD,
offsetOfEndOfCentralDirectory: startOfEOCD)
(self.endOfCentralDirectoryRecord, self.zip64EndOfCentralDirectory) = eocd
}

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
}
}

// MARK: - Private
Expand Down
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 @@
case missingEndOfCentralDirectoryRecord
/// Thrown when an entry contains a symlink pointing to a path outside the destination directory.
case uncontainedSymlink

Check failure on line 95 in Sources/ZIPFoundation/Archive.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Trailing Whitespace Violation: Lines should not have trailing whitespace (trailing_whitespace)
case unknownError
}

/// The access mode for an `Archive`.
Expand Down Expand Up @@ -250,8 +252,11 @@
directoryIndex += UInt64(centralDirStruct.fileCommentLength)
index += 1
}
return Entry(centralDirectoryStructure: centralDirStruct, localFileHeader: localFileHeader,
dataDescriptor: dataDescriptor, zip64DataDescriptor: zip64DataDescriptor)
return Entry(centralDirectoryStructure: centralDirStruct,

Check failure on line 255 in Sources/ZIPFoundation/Archive.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Trailing Whitespace Violation: Lines should not have trailing whitespace (trailing_whitespace)
directoryIndex: directoryIndex,
localFileHeader: localFileHeader,
dataDescriptor: dataDescriptor,

Check failure on line 258 in Sources/ZIPFoundation/Archive.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Trailing Whitespace Violation: Lines should not have trailing whitespace (trailing_whitespace)
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.
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 @@
}) else {
XCTFail("Failed to read local file header."); return
}
guard let entry = Entry(centralDirectoryStructure: cds, localFileHeader: lfh) else {
guard let entry = Entry(centralDirectoryStructure: cds,

Check failure on line 109 in Tests/ZIPFoundationTests/ZIPFoundationEntryTests+ZIP64.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Trailing Whitespace Violation: Lines should not have trailing whitespace (trailing_whitespace)
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 @@
XCTFail("Failed to read local file header.")
return
}
guard let entry = Entry(centralDirectoryStructure: central, localFileHeader: local) else {
guard let entry = Entry(centralDirectoryStructure: central,

Check failure on line 113 in Tests/ZIPFoundationTests/ZIPFoundationEntryTests.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Trailing Whitespace Violation: Lines should not have trailing whitespace (trailing_whitespace)
directoryIndex: 0, // not required for test
localFileHeader: local) else {
XCTFail("Failed to read entry.")
return
}
Expand Down Expand Up @@ -142,7 +144,9 @@
XCTFail("Failed to read local file header.")
return
}
guard let entry = Entry(centralDirectoryStructure: central, localFileHeader: local) else {
guard let entry = Entry(centralDirectoryStructure: central,

Check failure on line 147 in Tests/ZIPFoundationTests/ZIPFoundationEntryTests.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Trailing Whitespace Violation: Lines should not have trailing whitespace (trailing_whitespace)
directoryIndex: 0, // not required for test
localFileHeader: local) else {
XCTFail("Failed to read entry.")
return
}
Expand Down Expand Up @@ -176,7 +180,9 @@
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 @@
else {
XCTFail("Failed to read local file header."); return
}
guard let entry = Entry(centralDirectoryStructure: cds, localFileHeader: lfh) else {
guard let entry = Entry(centralDirectoryStructure: cds,

Check failure on line 40 in Tests/ZIPFoundationTests/ZIPFoundationFileAttributeTests.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Trailing Whitespace Violation: Lines should not have trailing whitespace (trailing_whitespace)
directoryIndex: 0, // not required for test
localFileHeader: lfh) else {
XCTFail("Failed to create test entry."); return
}
let attributes = FileManager.attributes(from: entry)
Expand Down
25 changes: 25 additions & 0 deletions Tests/ZIPFoundationTests/ZIPFoundationWritingTests+ZIP64.swift
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,31 @@
}
XCTAssertEqual(entry4.zip64ExtendedInformation?.relativeOffsetOfLocalHeader, entry3OriginalOffset)
}

Check failure on line 325 in Tests/ZIPFoundationTests/ZIPFoundationWritingTests+ZIP64.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Trailing Whitespace Violation: Lines should not have trailing whitespace (trailing_whitespace)
func testRemoveEntriesFromArchiveWithZIP64EOCD() {
// testRemoveEntriesFromArchiveWithZIP64EOCD.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)
guard let entry = archive["data3.random"] else {
XCTFail("Failed to retrieve ZIP64 format entry from archive"); return
}
do {
try archive.removeAllEntries(fromEntry: entry)
} catch {
XCTFail("Failed to remove entry from archive with error : \(error)")
}
XCTAssert(archive.checkIntegrity())
XCTAssertNotNil(archive.zip64EndOfCentralDirectory)
XCTAssertNotNil(archive["data2.random"])
XCTAssertNil(archive["data3.random"])
XCTAssertNil(archive["data4.random"])
}

}

extension Archive {
Expand Down
32 changes: 32 additions & 0 deletions Tests/ZIPFoundationTests/ZIPFoundationWritingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,38 @@ extension ZIPFoundationTests {
XCTAssertSwiftError(try readonlyArchive.remove(entryToRemove), throws: Archive.ArchiveError.unwritableArchive)
}

func testRemoveFromEntryUncompressed() {
let archive = self.archive(for: #function, mode: .update)
guard let entryToRemove = archive["test/faust.txt"] else {
XCTFail("Failed to find entry to remove in uncompressed folder"); return
}
do {
try archive.removeAllEntries(fromEntry: entryToRemove)
} catch {
XCTFail("Failed to remove entries from uncompressed folder archive with error : \(error)")
}
XCTAssert(archive.checkIntegrity())
XCTAssertNotNil(archive["test/empty/"])
XCTAssertNil(archive["test/faust.txt"])
XCTAssertNil(archive["test/nested/deep/another.random"])
}

func testRemoveFromEntryCompressed() {
let archive = self.archive(for: #function, mode: .update)
guard let entryToRemove = archive["test/faust.txt"] else {
XCTFail("Failed to find entry to remove in uncompressed folder"); return
}
do {
try archive.removeAllEntries(fromEntry: entryToRemove)
} catch {
XCTFail("Failed to remove entries from uncompressed folder archive with error : \(error)")
}
XCTAssert(archive.checkIntegrity())
XCTAssertNotNil(archive["test/empty/"])
XCTAssertNil(archive["test/faust.txt"])
XCTAssertNil(archive["test/nested/deep/another.random"])
}

func testArchiveCreateErrorConditions() {
let existantURL = ZIPFoundationTests.tempZipDirectoryURL
XCTAssertCocoaError(try Archive(url: existantURL, accessMode: .create),
Expand Down
Loading