diff --git a/Sources/ZIPFoundation/Archive+Helpers.swift b/Sources/ZIPFoundation/Archive+Helpers.swift index e1ab5277..2ac8ee9b 100644 --- a/Sources/ZIPFoundation/Archive+Helpers.swift +++ b/Sources/ZIPFoundation/Archive+Helpers.swift @@ -42,6 +42,27 @@ extension Archive { }) } + func readSymbolicLink(entry: Entry, bufferSize: Int, skipCRC32: Bool, + progress: Progress? = nil, with consumer: Consumer) throws -> CRC32 { + var checksum = CRC32(0) + let localFileHeader = entry.localFileHeader + guard let compressionMethod = CompressionMethod(rawValue: localFileHeader.compressionMethod) else { + throw ArchiveError.invalidCompressionMethod + } + switch compressionMethod { + case .none: + let localFileHeader = entry.localFileHeader + let size = Int(localFileHeader.compressedSize) + let data = try Data.readChunk(of: size, from: self.archiveFile) + checksum = data.crc32(checksum: 0) + try consumer(data) + progress?.completedUnitCount = self.totalUnitCountForReading(entry) + case .deflate: checksum = try self.readCompressed(entry: entry, bufferSize: bufferSize, + skipCRC32: skipCRC32, progress: progress, with: consumer) + } + return checksum + } + // MARK: - Writing func writeEntry(uncompressedSize: Int64, type: Entry.EntryType, diff --git a/Sources/ZIPFoundation/Archive+Reading.swift b/Sources/ZIPFoundation/Archive+Reading.swift index 8423bd98..fb9d7b5d 100644 --- a/Sources/ZIPFoundation/Archive+Reading.swift +++ b/Sources/ZIPFoundation/Archive+Reading.swift @@ -18,12 +18,13 @@ extension Archive { /// - url: The destination file URL. /// - bufferSize: The maximum size of the read buffer and the decompression buffer (if needed). /// - skipCRC32: Optional flag to skip calculation of the CRC32 checksum to improve performance. - /// - allowUncontainedSymlinks: Optional flag to allow symlinks that point to paths outside the destination. + /// - symlinksValidWithin: Symbolic links are valid within the url /// - progress: A progress object that can be used to track or cancel the extract operation. /// - Returns: The checksum of the processed content or 0 if the `skipCRC32` flag was set to `true`. /// - Throws: An error if the destination file cannot be written or the entry contains malformed content. public func extract(_ entry: Entry, to url: URL, bufferSize: Int = defaultReadChunkSize, - skipCRC32: Bool = false, allowUncontainedSymlinks: Bool = false, + skipCRC32: Bool = false, + symlinksValidWithin: URL? = nil, progress: Progress? = nil) throws -> CRC32 { guard bufferSize > 0 else { throw ArchiveError.invalidBufferSize @@ -60,7 +61,7 @@ extension Archive { let parentURL = url.deletingLastPathComponent() let isAbsolutePath = (linkPath as NSString).isAbsolutePath let linkURL = URL(fileURLWithPath: linkPath, relativeTo: isAbsolutePath ? nil : parentURL) - let isContained = allowUncontainedSymlinks || linkURL.isContained(in: parentURL) + let isContained = linkURL.isContained(in: symlinksValidWithin ?? parentURL) guard isContained else { throw ArchiveError.uncontainedSymlink } try fileManager.createParentDirectoryStructure(for: url) @@ -108,12 +109,9 @@ extension Archive { try consumer(Data()) progress?.completedUnitCount = self.totalUnitCountForReading(entry) case .symlink: - let localFileHeader = entry.localFileHeader - let size = Int(localFileHeader.compressedSize) - let data = try Data.readChunk(of: size, from: self.archiveFile) - checksum = data.crc32(checksum: 0) - try consumer(data) - progress?.completedUnitCount = self.totalUnitCountForReading(entry) + checksum = try self.readSymbolicLink(entry: entry, bufferSize: bufferSize, + skipCRC32: skipCRC32, progress: progress, with: consumer) + } return checksum } diff --git a/Sources/ZIPFoundation/Archive+ReadingDeprecated.swift b/Sources/ZIPFoundation/Archive+ReadingDeprecated.swift index e0cd3636..133c5721 100644 --- a/Sources/ZIPFoundation/Archive+ReadingDeprecated.swift +++ b/Sources/ZIPFoundation/Archive+ReadingDeprecated.swift @@ -26,4 +26,18 @@ public extension Archive { try self.extract(entry, bufferSize: Int(bufferSize), skipCRC32: skipCRC32, progress: progress, consumer: consumer) } + + @available(*, deprecated, + message: "Please use `symlinksValidWithin` for validate symlinks") + func extract(_ entry: Entry, to url: URL, bufferSize: Int = defaultReadChunkSize, + skipCRC32: Bool = false, + allowUncontainedSymlinks: Bool, + progress: Progress? = nil) throws -> CRC32 { + var symlinksValidWithin: URL? + if allowUncontainedSymlinks { + symlinksValidWithin = URL.rootFS + } + return try self.extract(entry, to: url, skipCRC32: skipCRC32, + symlinksValidWithin: symlinksValidWithin, progress: progress) + } } diff --git a/Sources/ZIPFoundation/FileManager+ZIP.swift b/Sources/ZIPFoundation/FileManager+ZIP.swift index 6cd5571c..c2c04ef4 100644 --- a/Sources/ZIPFoundation/FileManager+ZIP.swift +++ b/Sources/ZIPFoundation/FileManager+ZIP.swift @@ -87,12 +87,12 @@ extension FileManager { /// - sourceURL: The file URL pointing to an existing ZIP file. /// - destinationURL: The file URL that identifies the destination directory of the unzip operation. /// - skipCRC32: Optional flag to skip calculation of the CRC32 checksum to improve performance. - /// - allowUncontainedSymlinks: Optional flag to allow symlinks that point to paths outside the destination. + /// - symlinksValidWithin: Symbolic links are valid within the url. Defalut: destinationURL. /// - progress: A progress object that can be used to track or cancel the unzip operation. /// - pathEncoding: Encoding for entry paths. Overrides the encoding specified in the archive. /// - Throws: Throws an error if the source item does not exist or the destination URL is not writable. public func unzipItem(at sourceURL: URL, to destinationURL: URL, - skipCRC32: Bool = false, allowUncontainedSymlinks: Bool = false, + skipCRC32: Bool = false, symlinksValidWithin: URL? = nil, progress: Progress? = nil, pathEncoding: String.Encoding? = nil) throws { let fileManager = FileManager() guard fileManager.itemExists(at: sourceURL) else { @@ -100,6 +100,7 @@ extension FileManager { } let archive = try Archive(url: sourceURL, accessMode: .read, pathEncoding: pathEncoding) var totalUnitCount = Int64(0) + let symlinksValidWithin = symlinksValidWithin ?? destinationURL if let progress = progress { totalUnitCount = archive.reduce(0, { $0 + archive.totalUnitCountForReading($1) }) progress.totalUnitCount = totalUnitCount @@ -117,11 +118,13 @@ extension FileManager { let entryProgress = archive.makeProgressForReading(entry) progress.addChild(entryProgress, withPendingUnitCount: entryProgress.totalUnitCount) crc32 = try archive.extract(entry, to: entryURL, - skipCRC32: skipCRC32, allowUncontainedSymlinks: allowUncontainedSymlinks, + skipCRC32: skipCRC32, + symlinksValidWithin: symlinksValidWithin, progress: entryProgress) } else { crc32 = try archive.extract(entry, to: entryURL, - skipCRC32: skipCRC32, allowUncontainedSymlinks: allowUncontainedSymlinks) + skipCRC32: skipCRC32, + symlinksValidWithin: symlinksValidWithin) } func verifyChecksumIfNecessary() throws { diff --git a/Sources/ZIPFoundation/FileManager+ZIPDeprecated.swift b/Sources/ZIPFoundation/FileManager+ZIPDeprecated.swift index 0b2ab5aa..e49950a3 100644 --- a/Sources/ZIPFoundation/FileManager+ZIPDeprecated.swift +++ b/Sources/ZIPFoundation/FileManager+ZIPDeprecated.swift @@ -15,4 +15,18 @@ public extension FileManager { try self.unzipItem(at: sourceURL, to: destinationURL, skipCRC32: skipCRC32, progress: progress, pathEncoding: preferredEncoding) } + + @available(*, deprecated, + message: "Please use `symlinksValidWithin` for validate symlinks") + func unzipItem(at sourceURL: URL, to destinationURL: URL, + skipCRC32: Bool = false, allowUncontainedSymlinks: Bool, + progress: Progress? = nil, pathEncoding: String.Encoding? = nil) throws { + var symlinksValidWithin: URL? + if allowUncontainedSymlinks { + symlinksValidWithin = URL.rootFS + } + try self.unzipItem(at: sourceURL, to: destinationURL, skipCRC32: skipCRC32, + symlinksValidWithin: symlinksValidWithin, + progress: progress, pathEncoding: pathEncoding) + } } diff --git a/Sources/ZIPFoundation/URL+ZIP.swift b/Sources/ZIPFoundation/URL+ZIP.swift index 49e841c2..a77035aa 100644 --- a/Sources/ZIPFoundation/URL+ZIP.swift +++ b/Sources/ZIPFoundation/URL+ZIP.swift @@ -24,4 +24,8 @@ extension URL { return URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent( ProcessInfo.processInfo.globallyUniqueString) } + + static var rootFS: URL { + URL(fileURLWithPath: "/") + } } diff --git a/Tests/ZIPFoundationTests/Resources/testInvalidSymlinkCompressionMethodErrorConditions.zip b/Tests/ZIPFoundationTests/Resources/testInvalidSymlinkCompressionMethodErrorConditions.zip new file mode 100644 index 00000000..fca5d29a Binary files /dev/null and b/Tests/ZIPFoundationTests/Resources/testInvalidSymlinkCompressionMethodErrorConditions.zip differ diff --git a/Tests/ZIPFoundationTests/Resources/testUnzipCompressedSymlink.zip b/Tests/ZIPFoundationTests/Resources/testUnzipCompressedSymlink.zip new file mode 100644 index 00000000..f71ff536 Binary files /dev/null and b/Tests/ZIPFoundationTests/Resources/testUnzipCompressedSymlink.zip differ diff --git a/Tests/ZIPFoundationTests/Resources/testUnzipSymlink.zip b/Tests/ZIPFoundationTests/Resources/testUnzipSymlink.zip new file mode 100644 index 00000000..a6aa6e4f Binary files /dev/null and b/Tests/ZIPFoundationTests/Resources/testUnzipSymlink.zip differ diff --git a/Tests/ZIPFoundationTests/ZIPFoundationFileManagerTests+ZIP64.swift b/Tests/ZIPFoundationTests/ZIPFoundationFileManagerTests+ZIP64.swift index 3e4b61a0..8c9478b5 100644 --- a/Tests/ZIPFoundationTests/ZIPFoundationFileManagerTests+ZIP64.swift +++ b/Tests/ZIPFoundationTests/ZIPFoundationFileManagerTests+ZIP64.swift @@ -100,6 +100,34 @@ extension ZIPFoundationTests { } } + func testUnzipSymlink() { + // stored by zip 3.0 via command line: zip -ry + // + // testUnzipSymlink.zip/ + // ├─ directory1 + // ├─ testUnzipSymlink + // ├─ directory2 + // ├─ testUnzipSymlink.png + do { + try unarchiveZIP64Item(for: #function) + } catch { + XCTFail("\(error)") + } + } + + func testUnzipCompressedSymlink() { + // testUnzipCompressedSymlink.zip/ + // ├─ directory1 + // ├─ testUnzipSymlink (compressed) + // ├─ directory2 + // ├─ testUnzipSymlink.png + do { + try unarchiveZIP64Item(for: #function) + } catch { + XCTFail("\(error)") + } + } + // MARK: - Helpers private func archiveZIP64Item(for testFunction: String, compressionMethod: CompressionMethod) throws { diff --git a/Tests/ZIPFoundationTests/ZIPFoundationFileManagerTests.swift b/Tests/ZIPFoundationTests/ZIPFoundationFileManagerTests.swift index bfc47413..12e1ca61 100755 --- a/Tests/ZIPFoundationTests/ZIPFoundationFileManagerTests.swift +++ b/Tests/ZIPFoundationTests/ZIPFoundationFileManagerTests.swift @@ -158,7 +158,7 @@ extension ZIPFoundationTests { provider: { (_, _) -> Data in return Data(linkTarget.utf8) }) - try? fileManager.unzipItem(at: linkArchiveURL, to: destinationURL, allowUncontainedSymlinks: true) + try? fileManager.unzipItem(at: linkArchiveURL, to: destinationURL, symlinksValidWithin: URL.rootFS) XCTAssert(fileManager.itemExists(at: destinationURL.appendingPathComponent("link"))) } diff --git a/Tests/ZIPFoundationTests/ZIPFoundationReadingTests.swift b/Tests/ZIPFoundationTests/ZIPFoundationReadingTests.swift index 2ffecd2a..472007f4 100644 --- a/Tests/ZIPFoundationTests/ZIPFoundationReadingTests.swift +++ b/Tests/ZIPFoundationTests/ZIPFoundationReadingTests.swift @@ -290,4 +290,15 @@ extension ZIPFoundationTests { XCTAssertCocoaError(try fileManager.unzipItem(at: archive.url, to: destinationURL), throwsErrorWithCode: .fileReadInvalidFileName) } + + func testInvalidSymlinkCompressionMethodErrorConditions() { + let archive = self.archive(for: #function, mode: .read) + guard let entry = archive["symlink"] else { + XCTFail("Missing entry in test archive") + return + } + + XCTAssertSwiftError(try archive.extract(entry, consumer: { (_) in }), + throws: Archive.ArchiveError.invalidCompressionMethod) + } } diff --git a/Tests/ZIPFoundationTests/ZIPFoundationTests.swift b/Tests/ZIPFoundationTests/ZIPFoundationTests.swift index a506bac1..fdc29b51 100644 --- a/Tests/ZIPFoundationTests/ZIPFoundationTests.swift +++ b/Tests/ZIPFoundationTests/ZIPFoundationTests.swift @@ -253,7 +253,8 @@ extension ZIPFoundationTests { ("testZipItem", testZipItem), ("testLinuxTestSuiteIncludesAllTests", testLinuxTestSuiteIncludesAllTests), ("testFileModificationDate", testFileModificationDate), - ("testFileModificationDateHelperMethods", testFileModificationDateHelperMethods) + ("testFileModificationDateHelperMethods", testFileModificationDateHelperMethods), + ("testInvalidSymlinkCompressionMethodErrorConditions", testInvalidSymlinkCompressionMethodErrorConditions) ] + zip64Tests + darwinOnlyTests + swift5OnlyTests } @@ -289,7 +290,9 @@ extension ZIPFoundationTests { ("testWriteLargeChunk", testWriteLargeChunk), ("testExtractUncompressedZIP64Entries", testExtractUncompressedZIP64Entries), ("testExtractCompressedZIP64Entries", testExtractCompressedZIP64Entries), - ("testExtractEntryWithZIP64DataDescriptor", testExtractEntryWithZIP64DataDescriptor) + ("testExtractEntryWithZIP64DataDescriptor", testExtractEntryWithZIP64DataDescriptor), + ("testUnzipSymlink", testUnzipSymlink), + ("testUnzipCompressedSymlink", testUnzipCompressedSymlink) ] }