diff --git a/.gitattributes b/.gitattributes index 29ae0767..fc54bed6 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1 @@ -Sources/Minizip/* linguist-vendored +Sources/CMinizip/* linguist-vendored diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..2f6e4e0e --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @fpseverino \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3dbf46d0..424eda66 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,5 +9,49 @@ on: jobs: unit-tests: uses: vapor/ci/.github/workflows/run-unit-tests.yml@main + with: + with_linting: true + with_musl: true + ios_scheme_name: Zip secrets: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + + windows-unit: + if: ${{ !(github.event.pull_request.draft || false) }} + strategy: + fail-fast: false + matrix: + swift-version: + - 5.9 + - 5.10 + - 6.0 + include: + - { swift-version: 5.9, swift-branch: swift-5.9.2-release, swift-tag: 5.9.2-RELEASE } + - { swift-version: 5.10, swift-branch: swift-5.10.1-release, swift-tag: 5.10.1-RELEASE } + - { swift-version: 6.0, swift-branch: swift-6.0.1-release, swift-tag: 6.0.1-RELEASE } + runs-on: windows-latest + timeout-minutes: 60 + steps: + - name: Install Windows Swift toolchain + uses: compnerd/gha-setup-swift@main + with: + branch: ${{ matrix.swift-branch }} + tag: ${{ matrix.swift-tag }} + - name: Download zlib + run: | + curl -L -o zlib.zip https://www.zlib.net/zlib131.zip + mkdir zlib-131 + tar -xf zlib.zip -C zlib-131 --strip-components=1 + - name: Build and install zlib + run: | + cd zlib-131 + mkdir build + cd build + cmake .. + cmake --build . --config Release + cmake --install . --prefix ../install + - name: Check out code + uses: actions/checkout@v4 + - name: Run unit tests + run: | + swift test -Xcc -I'C:/Program Files (x86)/zlib/include' -Xlinker -L'C:/Program Files (x86)/zlib/lib' diff --git a/.swift-format b/.swift-format new file mode 100644 index 00000000..dea65687 --- /dev/null +++ b/.swift-format @@ -0,0 +1,70 @@ +{ + "fileScopedDeclarationPrivacy": { + "accessLevel": "private" + }, + "indentation": { + "spaces": 4 + }, + "indentConditionalCompilationBlocks": true, + "indentSwitchCaseLabels": false, + "lineBreakAroundMultilineExpressionChainComponents": false, + "lineBreakBeforeControlFlowKeywords": false, + "lineBreakBeforeEachArgument": false, + "lineBreakBeforeEachGenericRequirement": false, + "lineLength": 140, + "maximumBlankLines": 1, + "multiElementCollectionTrailingCommas": true, + "noAssignmentInExpressions": { + "allowedFunctions": [ + "XCTAssertNoThrow" + ] + }, + "prioritizeKeepingFunctionOutputTogether": false, + "respectsExistingLineBreaks": true, + "rules": { + "AllPublicDeclarationsHaveDocumentation": false, + "AlwaysUseLiteralForEmptyCollectionInit": false, + "AlwaysUseLowerCamelCase": false, + "AmbiguousTrailingClosureOverload": true, + "BeginDocumentationCommentWithOneLineSummary": false, + "DoNotUseSemicolons": true, + "DontRepeatTypeInStaticProperties": true, + "FileScopedDeclarationPrivacy": true, + "FullyIndirectEnum": true, + "GroupNumericLiterals": true, + "IdentifiersMustBeASCII": true, + "NeverForceUnwrap": false, + "NeverUseForceTry": false, + "NeverUseImplicitlyUnwrappedOptionals": false, + "NoAccessLevelOnExtensionDeclaration": true, + "NoAssignmentInExpressions": true, + "NoBlockComments": true, + "NoCasesWithOnlyFallthrough": true, + "NoEmptyTrailingClosureParentheses": true, + "NoLabelsInCasePatterns": true, + "NoLeadingUnderscores": false, + "NoParensAroundConditions": true, + "NoPlaygroundLiterals": true, + "NoVoidReturnOnFunctionSignature": true, + "OmitExplicitReturns": false, + "OneCasePerLine": true, + "OneVariableDeclarationPerLine": true, + "OnlyOneTrailingClosureArgument": true, + "OrderedImports": true, + "ReplaceForEachWithForLoop": true, + "ReturnVoidInsteadOfEmptyTuple": true, + "TypeNamesShouldBeCapitalized": true, + "UseEarlyExits": false, + "UseExplicitNilCheckInConditions": true, + "UseLetInEveryBoundCaseVariable": true, + "UseShorthandTypeNames": true, + "UseSingleLinePropertyGetter": true, + "UseSynthesizedInitializer": true, + "UseTripleSlashForDocumentationComments": true, + "UseWhereClausesInForLoops": false, + "ValidateDocumentationComments": false + }, + "spacesAroundRangeFormationOperators": false, + "tabWidth": 8, + "version": 1 +} \ No newline at end of file diff --git a/Package.swift b/Package.swift index db8f5915..54481327 100644 --- a/Package.swift +++ b/Package.swift @@ -1,6 +1,12 @@ -// swift-tools-version:5.8 +// swift-tools-version:5.9 import PackageDescription +#if canImport(Darwin) || compiler(<6.0) + import Foundation +#else + import FoundationEssentials +#endif + let package = Package( name: "Zip", products: [ @@ -8,35 +14,55 @@ let package = Package( ], targets: [ .target( - name: "Minizip", - exclude: ["module"], - swiftSettings: [ - .enableUpcomingFeature("ConciseMagicFile"), + name: "CMinizip", + cSettings: [ + .define("_CRT_SECURE_NO_WARNINGS", .when(platforms: [.windows])) ], - linkerSettings: [ - .linkedLibrary("z") - ] + swiftSettings: swiftSettings ), .target( name: "Zip", dependencies: [ - .target(name: "Minizip"), + .target(name: "CMinizip") + ], + cSettings: [ + .define("_CRT_SECURE_NO_WARNINGS", .when(platforms: [.windows])) ], - swiftSettings: [ - .enableUpcomingFeature("ConciseMagicFile"), - ] + swiftSettings: swiftSettings ), .testTarget( name: "ZipTests", dependencies: [ - .target(name: "Zip"), + .target(name: "Zip") ], resources: [ - .copy("Resources"), + .copy("TestResources") ], - swiftSettings: [ - .enableUpcomingFeature("ConciseMagicFile"), - ] + swiftSettings: swiftSettings ), ] ) + +var swiftSettings: [SwiftSetting] { + [ + .enableUpcomingFeature("ExistentialAny"), + .enableUpcomingFeature("ConciseMagicFile"), + .enableUpcomingFeature("ForwardTrailingClosures"), + .enableUpcomingFeature("DisableOutwardActorInference"), + .enableUpcomingFeature("StrictConcurrency"), + .enableExperimentalFeature("StrictConcurrency=complete"), + ] +} + +if let target = package.targets.filter({ $0.name == "CMinizip" }).first { + #if os(Windows) + if ProcessInfo.processInfo.environment["ZIP_USE_DYNAMIC_ZLIB"] == nil { + target.cSettings?.append(contentsOf: [.define("ZLIB_STATIC")]) + target.linkerSettings = [.linkedLibrary("zlibstatic")] + } else { + target.linkerSettings = [.linkedLibrary("zlib")] + } + #else + target.linkerSettings = [.linkedLibrary("z")] + #endif +} diff --git a/README.md b/README.md index 216b7802..086ca6d6 100644 --- a/README.md +++ b/README.md @@ -12,23 +12,41 @@ - Swift 5.8+ + Swift 5.9+
-A framework for zipping and unzipping files in Swift. +📂 A framework for zipping and unzipping files in Swift. Simple and quick to use. Built on top of [Minizip 1.2](https://github.com/zlib-ng/minizip-ng/tree/1.2). +## Overview + +### Getting Started + Use the SPM string to easily include the dependendency in your `Package.swift` file. ```swift .package(url: "https://github.com/vapor-community/Zip.git", from: "2.2.0") ``` -## Usage +and add it to your target's dependencies: + +```swift +.product(name: "Zip", package: "zip") +``` + +### Supported Platforms + +Zip supports all platforms supported by Swift 5.9 and later. + +To use Zip on Windows, you need to pass an available build of `zlib` to the build via extended flags. For example: + +```shell +swift build -Xcc -I'C:/pathTo/zlib/include' -Xlinker -L'C:/pathTo/zlib/lib' +``` ### Quick Functions @@ -56,7 +74,7 @@ import Zip do { let filePath = Bundle.main.url(forResource: "file", withExtension: "zip")! let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] - try Zip.unzipFile(filePath, destination: documentsDirectory, overwrite: true, password: "password") { progress in + try Zip.unzipFile(filePath, destination: documentsDirectory, password: "password") { progress in print(progress) } diff --git a/Sources/Minizip/crypt.c b/Sources/CMinizip/crypt.c similarity index 100% rename from Sources/Minizip/crypt.c rename to Sources/CMinizip/crypt.c diff --git a/Sources/CMinizip/include/CMinizip.h b/Sources/CMinizip/include/CMinizip.h new file mode 100644 index 00000000..f65ac0de --- /dev/null +++ b/Sources/CMinizip/include/CMinizip.h @@ -0,0 +1,17 @@ +// +// CMinizip.h +// Zip +// +// Created by Florian Friedrich on 3/27/19. +// Copyright © 2019 Roy Marmelstein. All rights reserved. +// + +#ifndef CMinizip_h +#define CMinizip_h + +#include "ioapi.h" +#include "crypt.h" +#include "unzip.h" +#include "zip.h" + +#endif /* CMinizip_h */ diff --git a/Sources/Minizip/include/crypt.h b/Sources/CMinizip/include/crypt.h similarity index 100% rename from Sources/Minizip/include/crypt.h rename to Sources/CMinizip/include/crypt.h diff --git a/Sources/Minizip/include/ioapi.h b/Sources/CMinizip/include/ioapi.h similarity index 100% rename from Sources/Minizip/include/ioapi.h rename to Sources/CMinizip/include/ioapi.h diff --git a/Sources/CMinizip/include/module.modulemap b/Sources/CMinizip/include/module.modulemap new file mode 100644 index 00000000..eb1878bf --- /dev/null +++ b/Sources/CMinizip/include/module.modulemap @@ -0,0 +1,4 @@ +module CMinizip [system][extern_c] { + header "CMinizip.h" + export * +} diff --git a/Sources/Minizip/include/unzip.h b/Sources/CMinizip/include/unzip.h similarity index 100% rename from Sources/Minizip/include/unzip.h rename to Sources/CMinizip/include/unzip.h diff --git a/Sources/Minizip/include/zip.h b/Sources/CMinizip/include/zip.h similarity index 100% rename from Sources/Minizip/include/zip.h rename to Sources/CMinizip/include/zip.h diff --git a/Sources/Minizip/ioapi.c b/Sources/CMinizip/ioapi.c similarity index 100% rename from Sources/Minizip/ioapi.c rename to Sources/CMinizip/ioapi.c diff --git a/Sources/Minizip/unzip.c b/Sources/CMinizip/unzip.c similarity index 100% rename from Sources/Minizip/unzip.c rename to Sources/CMinizip/unzip.c diff --git a/Sources/Minizip/zip.c b/Sources/CMinizip/zip.c similarity index 100% rename from Sources/Minizip/zip.c rename to Sources/CMinizip/zip.c diff --git a/Sources/Minizip/include/Minizip.h b/Sources/Minizip/include/Minizip.h deleted file mode 100644 index ef753eb9..00000000 --- a/Sources/Minizip/include/Minizip.h +++ /dev/null @@ -1,17 +0,0 @@ -// -// Minizip.h -// Zip -// -// Created by Florian Friedrich on 3/27/19. -// Copyright © 2019 Roy Marmelstein. All rights reserved. -// - -#ifndef Minizip_h -#define Minizip_h - -#import "ioapi.h" -#import "crypt.h" -#import "unzip.h" -#import "zip.h" - -#endif /* Minizip_h */ diff --git a/Sources/Minizip/module/module.modulemap b/Sources/Minizip/module/module.modulemap deleted file mode 100644 index 59eaacd7..00000000 --- a/Sources/Minizip/module/module.modulemap +++ /dev/null @@ -1,5 +0,0 @@ -module Minizip [system][extern_c] { - header "../include/Minizip.h" - link "z" - export * -} diff --git a/Sources/Zip/ArchiveFile.swift b/Sources/Zip/ArchiveFile.swift index 9b3335b3..2b032b5c 100644 --- a/Sources/Zip/ArchiveFile.swift +++ b/Sources/Zip/ArchiveFile.swift @@ -1,5 +1,5 @@ +@_implementationOnly import CMinizip import Foundation -@_implementationOnly import Minizip /// Defines data saved in memory that will be archived as a file. public struct ArchiveFile { @@ -34,29 +34,25 @@ public struct ArchiveFile { } extension Zip { - /** - Creates a zip file from an array of ``ArchiveFile``s - - - Parameters: - - archiveFiles: Array of ``ArchiveFile``. - - zipFilePath: Destination `URL`, should lead to a `.zip` filepath. - - password: The optional password string. - - compression: The compression strategy to use. - - progress: A progress closure called after unzipping each file in the archive. Double value betweem 0 and 1. - - - Throws: `ZipError.zipFail` if zipping fails. - - > Note: Supports implicit progress composition. - */ + /// Creates a zip file from an array of ``ArchiveFile``s. + /// + /// - Parameters: + /// - archiveFiles: Array of ``ArchiveFile``. + /// - zipFilePath: Destination `URL`, should lead to a `.zip` filepath. + /// - password: The optional password string. + /// - compression: The compression strategy to use. + /// - progress: A progress closure called after zipping each file in the archive. A `Double` value between 0 and 1. + /// + /// - Throws: ``ZipError/zipFail`` if zipping fails. + /// + /// > Note: Supports implicit progress composition. public class func zipData( archiveFiles: [ArchiveFile], zipFilePath: URL, password: String? = nil, compression: ZipCompression = .DefaultCompression, - progress: ((_ progress: Double) -> ())? = nil + progress: ((_ progress: Double) -> Void)? = nil ) throws { - let destinationPath = zipFilePath.path - // Progress handler set up var currentPosition: Int = 0 var totalSize: Int = 0 @@ -71,7 +67,7 @@ extension Zip { progressTracker.kind = ProgressKind.file // Begin Zipping - let zip = zipOpen(destinationPath, APPEND_STATUS_CREATE) + let zip = zipOpen(zipFilePath.nativePath, APPEND_STATUS_CREATE) for archiveFile in archiveFiles { // Skip empty data @@ -111,21 +107,18 @@ extension Zip { // Update progress handler currentPosition += archiveFile.data.count - - if let progressHandler = progress { - progressHandler((Double(currentPosition/totalSize))) + if let progress { + progress(Double(currentPosition / totalSize)) } - progressTracker.completedUnitCount = Int64(currentPosition) } zipClose(zip, nil) // Completed. Update progress handler. - if let progressHandler = progress { - progressHandler(1.0) + if let progress { + progress(1.0) } - progressTracker.completedUnitCount = Int64(totalSize) } -} \ No newline at end of file +} diff --git a/Sources/Zip/Date+dosDate.swift b/Sources/Zip/Date+dosDate.swift index ab5a467a..d4834282 100644 --- a/Sources/Zip/Date+dosDate.swift +++ b/Sources/Zip/Date+dosDate.swift @@ -1,8 +1,13 @@ -import Foundation +#if canImport(Darwin) || compiler(<6.0) + import Foundation +#else + import FoundationEssentials +#endif extension Date { var dosDate: UInt32 { let components = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute, .second], from: self) + let year = UInt32(components.year! - 1980) << 25 let month = UInt32(components.month!) << 21 let day = UInt32(components.day!) << 16 @@ -12,4 +17,4 @@ extension Date { return year | month | day | hour | minute | second } -} \ No newline at end of file +} diff --git a/Sources/Zip/FileManager+ProcessedFilePath.swift b/Sources/Zip/FileManager+ProcessedFilePath.swift new file mode 100644 index 00000000..0b399ec6 --- /dev/null +++ b/Sources/Zip/FileManager+ProcessedFilePath.swift @@ -0,0 +1,64 @@ +import Foundation + +extension FileManager { + struct ProcessedFilePath { + let filePathURL: URL + let fileName: String? + + var filePath: String { + filePathURL.nativePath + } + } + + /// Process zip paths. + /// + /// - Parameter roots: Paths as `URL`. + /// + /// - Returns: Array of ``ProcessedFilePath`` structs. + static func fileSubPaths(from roots: [URL]) -> [ProcessedFilePath] { + var processedFilePaths = [ProcessedFilePath]() + for pathURL in roots { + var isDirectory: ObjCBool = false + _ = FileManager.default.fileExists( + atPath: pathURL.nativePath, + isDirectory: &isDirectory + ) + + if !isDirectory.boolValue { + let processedPath = ProcessedFilePath(filePathURL: pathURL, fileName: pathURL.lastPathComponent) + processedFilePaths.append(processedPath) + } else { + let directoryContents = Self.expandDirectoryFilePath(pathURL) + processedFilePaths.append(contentsOf: directoryContents) + } + } + return processedFilePaths + } + + /// Expand directory contents and parse them into ``ProcessedFilePath`` structs. + /// + /// - Parameter directory: Path of folder as `URL`. + /// + /// - Returns: Array of ``ProcessedFilePath`` structs. + private static func expandDirectoryFilePath(_ directory: URL) -> [ProcessedFilePath] { + var processedFilePaths = [ProcessedFilePath]() + if let enumerator = FileManager.default.enumerator(atPath: directory.nativePath) { + while let filePathComponent = enumerator.nextObject() as? String { + let pathURL = directory.appendingPathComponent(filePathComponent) + + var isDirectory: ObjCBool = false + _ = FileManager.default.fileExists( + atPath: pathURL.nativePath, + isDirectory: &isDirectory + ) + + if !isDirectory.boolValue { + let fileName = (directory.lastPathComponent as NSString).appendingPathComponent(filePathComponent) + let processedPath = ProcessedFilePath(filePathURL: pathURL, fileName: fileName) + processedFilePaths.append(processedPath) + } + } + } + return processedFilePaths + } +} diff --git a/Sources/Zip/QuickZip.swift b/Sources/Zip/QuickZip.swift index 3870a327..5d7fdc00 100644 --- a/Sources/Zip/QuickZip.swift +++ b/Sources/Zip/QuickZip.swift @@ -6,85 +6,77 @@ // Copyright © 2016 Roy Marmelstein. All rights reserved. // -import Foundation +#if canImport(Darwin) || compiler(<6.0) + import Foundation +#else + import FoundationEssentials +#endif -extension Zip { - /** - Quickly unzips a file. - - Unzips to a new folder inside the app's documents folder with the zip file's name. - - - Parameter path: Path of zipped file. - - - Throws: `ZipError.unzipFail` if unzipping fails or `ZipError.fileNotFound` if file is not found. - - - Returns: `URL` of the destination folder. - */ +extension Zip { + /// Unzips a file with less configuration. + /// + /// Unzips to a new folder inside the temporary directory with the zip file's name. + /// + /// - Parameter path: Path of zipped file. + /// + /// - Throws: ``ZipError/unzipFail`` if unzipping fails or ``ZipError/fileNotFound`` if file is not found. + /// + /// - Returns: `URL` of the destination folder. public class func quickUnzipFile(_ path: URL) throws -> URL { return try quickUnzipFile(path, progress: nil) } - - /** - Quickly unzips a file. - - Unzips to a new folder inside the app's documents folder with the zip file's name. - - - Parameters: - - path: Path of zipped file. - - progress: An optional progress closure called after unzipping each file in the archive. A `Double` value between 0 and 1. - - - Throws: `ZipError.unzipFail` if unzipping fails or `ZipError.fileNotFound` if file is not found. - - > Note: Supports implicit progress composition. - - - Returns: `URL` of the destination folder. - */ - public class func quickUnzipFile(_ path: URL, progress: ((_ progress: Double) -> ())?) throws -> URL { - let destinationUrl = FileManager.default.temporaryDirectory - .appendingPathComponent(path.deletingPathExtension().lastPathComponent, isDirectory: true) + + /// Unzips a file with less configuration. + /// + /// Unzips to a new folder inside the temporary directory with the zip file's name. + /// + /// - Parameters: + /// - path: Path of zipped file. + /// - progress: An optional progress closure called after unzipping each file in the archive. A `Double` value between 0 and 1. + /// + /// - Throws: ``ZipError/unzipFail`` if unzipping fails or ``ZipError/fileNotFound`` if file is not found. + /// + /// > Note: Supports implicit progress composition. + /// + /// - Returns: `URL` of the destination folder. + public class func quickUnzipFile(_ path: URL, progress: ((_ progress: Double) -> Void)?) throws -> URL { + let destinationUrl = FileManager.default.temporaryDirectory.appendingPathComponent( + path.deletingPathExtension().lastPathComponent, isDirectory: true + ) try self.unzipFile(path, destination: destinationUrl, progress: progress) return destinationUrl } - /** - Quickly zips files. - - - Parameters: - - paths: Array of `URL` filepaths. - - fileName: File name for the resulting zip file. - - - Throws: `ZipError.zipFail` if zipping fails. - - > Note: Supports implicit progress composition. - - - Returns: `URL` of the destination folder. - */ + /// Zips files with less configuration. + /// + /// - Parameters: + /// - paths: Array of `URL` filepaths. + /// - fileName: File name for the resulting zip file. + /// + /// - Throws: ``ZipError/zipFail`` if zipping fails. + /// + /// - Returns: `URL` of the destination folder. public class func quickZipFiles(_ paths: [URL], fileName: String) throws -> URL { return try quickZipFiles(paths, fileName: fileName, progress: nil) } - - /** - Quickly zips files. - - - Parameters: - - paths: Array of `URL` filepaths. - - fileName: File name for the resulting zip file. - - progress: An optional progress closure called after unzipping each file in the archive. A `Double` value between 0 and 1. - - - Throws: `ZipError.zipFail` if zipping fails. - - > Note: Supports implicit progress composition. - - - Returns: `URL` of the destination folder. - */ - public class func quickZipFiles(_ paths: [URL], fileName: String, progress: ((_ progress: Double) -> ())?) throws -> URL { + + /// Zips files with less configuration. + /// + /// - Parameters: + /// - paths: Array of `URL` filepaths. + /// - fileName: File name for the resulting zip file. + /// - progress: An optional progress closure called after unzipping each file in the archive. A `Double` value between 0 and 1. + /// + /// - Throws: ``ZipError/zipFail`` if zipping fails. + /// + /// > Note: Supports implicit progress composition. + /// + /// - Returns: `URL` of the destination folder. + public class func quickZipFiles(_ paths: [URL], fileName: String, progress: ((_ progress: Double) -> Void)?) throws -> URL { var fileNameWithExtension = fileName if !fileName.hasSuffix(".zip") { fileNameWithExtension += ".zip" } - - print("fileNameWithExtension: \(fileNameWithExtension)") - let destinationUrl = FileManager.default.temporaryDirectory.appendingPathComponent(fileNameWithExtension) try self.zipFiles(paths: paths, zipFilePath: destinationUrl, progress: progress) return destinationUrl diff --git a/Sources/Zip/URL+nativePath.swift b/Sources/Zip/URL+nativePath.swift new file mode 100644 index 00000000..f15e2a5d --- /dev/null +++ b/Sources/Zip/URL+nativePath.swift @@ -0,0 +1,11 @@ +#if canImport(Darwin) || compiler(<6.0) + import Foundation +#else + import FoundationEssentials +#endif + +extension URL { + var nativePath: String { + return withUnsafeFileSystemRepresentation { String(cString: $0!) } + } +} diff --git a/Sources/Zip/Zip.docc/Advanced.md b/Sources/Zip/Zip.docc/Advanced.md index 59e77c04..6dbadce0 100644 --- a/Sources/Zip/Zip.docc/Advanced.md +++ b/Sources/Zip/Zip.docc/Advanced.md @@ -15,7 +15,7 @@ import Zip do { let filePath = Bundle.main.url(forResource: "file", withExtension: "zip")! let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] - try Zip.unzipFile(filePath, destination: documentsDirectory, overwrite: true, password: "password") { progress in + try Zip.unzipFile(filePath, destination: documentsDirectory, password: "password") { progress in print(progress) } diff --git a/Sources/Zip/Zip.docc/Documentation.md b/Sources/Zip/Zip.docc/Documentation.md index bc6b32e5..8a0f49dd 100644 --- a/Sources/Zip/Zip.docc/Documentation.md +++ b/Sources/Zip/Zip.docc/Documentation.md @@ -15,12 +15,30 @@ A framework for zipping and unzipping files in Swift. Simple and quick to use. Built on top of [Minizip 1.2](https://github.com/zlib-ng/minizip-ng/tree/1.2). +### Getting Started + Use the SPM string to easily include the dependendency in your `Package.swift` file. ```swift .package(url: "https://github.com/vapor-community/Zip.git", from: "2.2.0") ``` +and add it to your target's dependencies: + +```swift +.product(name: "Zip", package: "zip") +``` + +### Supported Platforms + +Zip supports all platforms supported by Swift 5.9 and later. + +To use Zip on Windows, you need to pass an available build of `zlib` to the build via extended flags. For example: + +```shell +swift build -Xcc -I'C:/pathTo/zlib/include' -Xlinker -L'C:/pathTo/zlib/lib' +``` + ## Topics ### Essentials diff --git a/Sources/Zip/Zip.swift b/Sources/Zip/Zip.swift index 16b90807..b6cf91ba 100644 --- a/Sources/Zip/Zip.swift +++ b/Sources/Zip/Zip.swift @@ -6,149 +6,180 @@ // Copyright © 2015 Roy Marmelstein. All rights reserved. // +@_implementationOnly import CMinizip import Foundation -@_implementationOnly import Minizip /// Main class that handles zipping and unzipping of files. public class Zip { // Set of vaild file extensions - internal static var customFileExtensions: Set = [] + #if compiler(>=5.10) + nonisolated(unsafe) private static var customFileExtensions: Set = [] + #else + private static var customFileExtensions: Set = [] + #endif + private static let lock = NSLock() @available(*, deprecated, message: "Do not use this initializer. Zip is a utility class and should not be instantiated.") - public init () {} - - /** - Unzips a file. - - - Parameters: - - zipFilePath: Local file path of zipped file. - - destination: Local file path to unzip to. - - overwrite: Indicates whether or not to overwrite files at the destination path. - - password: Optional password if file is protected. - - progress: A progress closure called after unzipping each file in the archive. A `Double` value between 0 and 1. - - fileOutputHandler: A closure called after each file is unzipped. A `URL` value of the unzipped file. - - - Throws: `ZipError.unzipFail` if unzipping fails or if fail is not found. - - > Note: Supports implicit progress composition - */ + public init() {} + + /// Unzips a file. + /// + /// - Parameters: + /// - zipFilePath: Local file path of zipped file. + /// - destination: Local file path to unzip to. + /// - overwrite: Indicates whether or not to overwrite files at the destination path. + /// - password: Optional password if file is protected. + /// - progress: A progress closure called after unzipping each file in the archive. A `Double` value between 0 and 1. + /// - fileOutputHandler: A closure called after each file is unzipped. A `URL` value of the unzipped file. + /// + /// - Throws: ``ZipError/unzipFail`` if unzipping fails or if fail is not found. + /// + /// > Note: Supports implicit progress composition. public class func unzipFile( _ zipFilePath: URL, destination: URL, overwrite: Bool = true, password: String? = nil, - progress: ((_ progress: Double) -> ())? = nil, + progress: ((_ progress: Double) -> Void)? = nil, fileOutputHandler: ((_ unzippedFile: URL) -> Void)? = nil ) throws { - let fileManager = FileManager.default - // Check whether a zip file exists at path. - let path = zipFilePath.path - if fileManager.fileExists(atPath: path) == false || !isValidFileExtension(zipFilePath.pathExtension) { + let path = zipFilePath.nativePath + if !FileManager.default.fileExists(atPath: path) || !isValidFileExtension(zipFilePath.pathExtension) { throw ZipError.fileNotFound } - - // Unzip set up - var ret: Int32 = 0 - var crc_ret: Int32 = 0 - let bufferSize: UInt32 = 4096 - var buffer = Array(repeating: 0, count: Int(bufferSize)) - + // Progress handler set up var totalSize: Double = 0.0 var currentPosition: Double = 0.0 - let fileAttributes = try fileManager.attributesOfItem(atPath: path) + let fileAttributes = try FileManager.default.attributesOfItem(atPath: path) if let attributeFileSize = fileAttributes[FileAttributeKey.size] as? Double { totalSize += attributeFileSize } - + let progressTracker = Progress(totalUnitCount: Int64(totalSize)) progressTracker.isCancellable = false progressTracker.isPausable = false progressTracker.kind = ProgressKind.file - + // Begin unzipping let zip = unzOpen64(path) defer { unzClose(zip) } if unzGoToFirstFile(zip) != UNZ_OK { throw ZipError.unzipFail } + + #if os(Windows) + var fileNames = Set() + #endif + + var buffer = [CUnsignedChar](repeating: 0, count: 4096) + var result: Int32 + repeat { if let cPassword = password?.cString(using: String.Encoding.ascii) { - ret = unzOpenCurrentFilePassword(zip, cPassword) + guard unzOpenCurrentFilePassword(zip, cPassword) == UNZ_OK else { + throw ZipError.unzipFail + } } else { - ret = unzOpenCurrentFile(zip); - } - if ret != UNZ_OK { - throw ZipError.unzipFail + guard unzOpenCurrentFile(zip) == UNZ_OK else { + throw ZipError.unzipFail + } } + var fileInfo = unz_file_info64() - memset(&fileInfo, 0, MemoryLayout.size) - ret = unzGetCurrentFileInfo64(zip, &fileInfo, nil, 0, nil, 0, nil, 0) - if ret != UNZ_OK { + guard unzGetCurrentFileInfo64(zip, &fileInfo, nil, 0, nil, 0, nil, 0) == UNZ_OK else { unzCloseCurrentFile(zip) throw ZipError.unzipFail } + currentPosition += Double(fileInfo.compressed_size) + let fileNameSize = Int(fileInfo.size_filename) + 1 let fileName = UnsafeMutablePointer.allocate(capacity: fileNameSize) + defer { fileName.deallocate() } unzGetCurrentFileInfo64(zip, &fileInfo, fileName, UInt16(fileNameSize), nil, 0, nil, 0) fileName[Int(fileInfo.size_filename)] = 0 var pathString = String(cString: fileName) - guard pathString.count > 0 else { + + #if os(Windows) + // Windows Reserved Characters + let reservedCharacters: CharacterSet = ["<", ">", ":", "\"", "|", "?", "*"] + + if pathString.rangeOfCharacter(from: reservedCharacters) != nil { + pathString = pathString.components(separatedBy: reservedCharacters).joined(separator: "_") + + let pathExtension = (pathString as NSString).pathExtension + let pathWithoutExtension = (pathString as NSString).deletingPathExtension + var counter = 1 + while fileNames.contains(pathString) { + let newFileName = "\(pathWithoutExtension) (\(counter))" + pathString = pathExtension.isEmpty ? newFileName : newFileName.appendingPathExtension(pathExtension) ?? newFileName + counter += 1 + } + } + + fileNames.insert(pathString) + #endif + + guard !pathString.isEmpty else { throw ZipError.unzipFail } - var isDirectory = false - let fileInfoSizeFileName = Int(fileInfo.size_filename-1) - if (fileName[fileInfoSizeFileName] == "/".cString(using: String.Encoding.utf8)?.first || fileName[fileInfoSizeFileName] == "\\".cString(using: String.Encoding.utf8)?.first) { - isDirectory = true; - } - free(fileName) if pathString.rangeOfCharacter(from: CharacterSet(charactersIn: "/\\")) != nil { pathString = pathString.replacingOccurrences(of: "\\", with: "/") } - let fullPath = destination.appendingPathComponent(pathString).standardized.path - // `.standardized` removes any `..` to move a level up. + let fullPath = destination.appendingPathComponent(pathString).standardizedFileURL.nativePath + + // `.standardizedFileURL` removes any `..` to move a level up. // If we then check that the `fullPath` starts with the destination directory we know we are not extracting "outside" the destination. - guard fullPath.starts(with: destination.standardized.path) else { + guard fullPath.starts(with: destination.standardizedFileURL.nativePath) else { throw ZipError.unzipFail } - let creationDate = Date() let directoryAttributes: [FileAttributeKey: Any]? - #if os(Linux) && swift(<6.0) - // On Linux, setting attributes is not yet really implemented. - // In Swift 4.2, the only settable attribute is `.posixPermissions`. - // See https://github.com/apple/swift-corelibs-foundation/blob/swift-4.2-branch/Foundation/FileManager.swift#L182-L196 + #if (os(Linux) || os(Windows)) && compiler(<6.0) directoryAttributes = nil #else + let creationDate = Date() directoryAttributes = [ .creationDate: creationDate, - .modificationDate: creationDate + .modificationDate: creationDate, ] #endif + let isDirectory = + fileName[Int(fileInfo.size_filename - 1)] == "/".cString(using: String.Encoding.utf8)?.first + || fileName[Int(fileInfo.size_filename - 1)] == "\\".cString(using: String.Encoding.utf8)?.first + do { + try FileManager.default.createDirectory( + atPath: (fullPath as NSString).deletingLastPathComponent, + withIntermediateDirectories: true, + attributes: directoryAttributes + ) + if isDirectory { - try fileManager.createDirectory(atPath: fullPath, withIntermediateDirectories: true, attributes: directoryAttributes) - } else { - let parentDirectory = (fullPath as NSString).deletingLastPathComponent - try fileManager.createDirectory(atPath: parentDirectory, withIntermediateDirectories: true, attributes: directoryAttributes) + try FileManager.default.createDirectory( + atPath: fullPath, + withIntermediateDirectories: false, + attributes: directoryAttributes + ) } } catch {} - if fileManager.fileExists(atPath: fullPath) && !isDirectory && !overwrite { + + if FileManager.default.fileExists(atPath: fullPath) && !isDirectory && !overwrite { unzCloseCurrentFile(zip) - ret = unzGoToNextFile(zip) + unzGoToNextFile(zip) } var writeBytes: UInt64 = 0 let filePointer: UnsafeMutablePointer? = fopen(fullPath, "wb") while let filePointer { - let readBytes = unzReadCurrentFile(zip, &buffer, bufferSize) + let readBytes = unzReadCurrentFile(zip, &buffer, UInt32(buffer.count)) guard readBytes > 0 else { break } guard fwrite(buffer, Int(readBytes), 1, filePointer) == 1 else { throw ZipError.unzipFail @@ -158,10 +189,10 @@ public class Zip { if let filePointer { fclose(filePointer) } - crc_ret = unzCloseCurrentFile(zip) - if crc_ret == UNZ_CRCERROR { + guard unzCloseCurrentFile(zip) != UNZ_CRCERROR else { throw ZipError.unzipFail } + guard writeBytes == fileInfo.uncompressed_size else { throw ZipError.unzipFail } @@ -169,108 +200,96 @@ public class Zip { // Set file permissions from current `fileInfo` if fileInfo.external_fa != 0 { let permissions = (fileInfo.external_fa >> 16) & 0x1FF - // We will devifne a valid permission range between Owner read only to full access + // We will define a valid permission range between Owner read only to full access if permissions >= 0o400 && permissions <= 0o777 { do { - try fileManager.setAttributes([.posixPermissions : permissions], ofItemAtPath: fullPath) + try FileManager.default.setAttributes([.posixPermissions: permissions], ofItemAtPath: fullPath) } catch { print("Failed to set permissions to file \(fullPath), error: \(error)") } } } - ret = unzGoToNextFile(zip) - + result = unzGoToNextFile(zip) + // Update progress handler - if let progressHandler = progress { - progressHandler((currentPosition / totalSize)) + if let progress { + progress(currentPosition / totalSize) } - - if let fileHandler = fileOutputHandler, - let encodedString = fullPath.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), - let fileUrl = URL(string: encodedString) { - fileHandler(fileUrl) + + if let fileOutputHandler { + fileOutputHandler(URL(fileURLWithPath: fullPath, isDirectory: false)) } - + progressTracker.completedUnitCount = Int64(currentPosition) - - } while (ret == UNZ_OK && ret != UNZ_END_OF_LIST_OF_FILE) - + } while result == UNZ_OK && result != UNZ_END_OF_LIST_OF_FILE + // Completed. Update progress handler. - if let progressHandler = progress { - progressHandler(1.0) + if let progress { + progress(1.0) } - progressTracker.completedUnitCount = Int64(totalSize) } - - /** - Zips a group of files. - - - Parameters: - - paths: Array of `URL` filepaths. - - zipFilePath: Destination `URL`, should lead to a `.zip` filepath. - - password: The optional password string. - - compression: The compression strategy to use. - - progress: A progress closure called after unzipping each file in the archive. A `Double` value between 0 and 1. - - - Throws: `ZipError.zipFail` if zipping fails. - - > Note: Supports implicit progress composition - */ + + /// Zips a group of files. + /// + /// - Parameters: + /// - paths: Array of `URL` filepaths. + /// - zipFilePath: Destination `URL`, should lead to a `.zip` filepath. + /// - password: The optional password string. + /// - compression: The compression strategy to use. + /// - progress: A progress closure called after unzipping each file in the archive. A `Double` value between 0 and 1. + /// + /// - Throws: ``ZipError/zipFail`` if zipping fails. + /// + /// > Note: Supports implicit progress composition. public class func zipFiles( paths: [URL], zipFilePath: URL, password: String? = nil, compression: ZipCompression = .DefaultCompression, - progress: ((_ progress: Double) -> ())? = nil + progress: ((_ progress: Double) -> Void)? = nil ) throws { - let fileManager = FileManager.default - - let processedPaths = ZipUtilities().processZipPaths(paths) - - // Zip set up - let chunkSize: Int = 16384 - + let processedPaths = FileManager.fileSubPaths(from: paths) + + let chunkSize = 16384 + // Progress handler set up - var currentPosition: Double = 0.0 - var totalSize: Double = 0.0 + var currentPosition = 0.0 + var totalSize = 0.0 // Get `totalSize` for progress handler for path in processedPaths { do { - let filePath = path.filePath() - let fileAttributes = try fileManager.attributesOfItem(atPath: filePath) - let fileSize = fileAttributes[FileAttributeKey.size] as? Double - if let fileSize { + let fileAttributes = try FileManager.default.attributesOfItem(atPath: path.filePath) + if let fileSize = fileAttributes[FileAttributeKey.size] as? Double { totalSize += fileSize } } catch {} } - + let progressTracker = Progress(totalUnitCount: Int64(totalSize)) progressTracker.isCancellable = false progressTracker.isPausable = false progressTracker.kind = ProgressKind.file - + // Begin Zipping - let zip = zipOpen(zipFilePath.path, APPEND_STATUS_CREATE) + let zip = zipOpen(zipFilePath.nativePath, APPEND_STATUS_CREATE) + for path in processedPaths { - let filePath = path.filePath() + let filePath = path.filePath + var isDirectory: ObjCBool = false - _ = fileManager.fileExists(atPath: filePath, isDirectory: &isDirectory) + _ = FileManager.default.fileExists(atPath: filePath, isDirectory: &isDirectory) if !isDirectory.boolValue { guard let input = fopen(filePath, "r") else { throw ZipError.zipFail } defer { fclose(input) } - let fileName = path.fileName - var zipInfo: zip_fileinfo = zip_fileinfo( - dos_date: 0, - internal_fa: 0, - external_fa: 0 - ) + + var zipInfo: zip_fileinfo = zip_fileinfo(dos_date: 0, internal_fa: 0, external_fa: 0) + do { - let fileAttributes = try fileManager.attributesOfItem(atPath: filePath) + let fileAttributes = try FileManager.default.attributesOfItem(atPath: filePath) if let fileDate = fileAttributes[FileAttributeKey.modificationDate] as? Date { zipInfo.dos_date = fileDate.dosDate } @@ -278,72 +297,77 @@ public class Zip { currentPosition += fileSize } } catch {} - guard let buffer = malloc(chunkSize) else { - throw ZipError.zipFail - } - if let password, let fileName { - zipOpenNewFileInZip3(zip, fileName, &zipInfo, nil, 0, nil, 0, nil, UInt16(Z_DEFLATED), compression.minizipCompression, 0, -MAX_WBITS, DEF_MEM_LEVEL, Z_DEFAULT_STRATEGY, password, 0) - } else if let fileName { - zipOpenNewFileInZip3(zip, fileName, &zipInfo, nil, 0, nil, 0, nil, UInt16(Z_DEFLATED), compression.minizipCompression, 0, -MAX_WBITS, DEF_MEM_LEVEL, Z_DEFAULT_STRATEGY, nil, 0) + + let buffer = UnsafeMutableRawPointer.allocate(byteCount: chunkSize, alignment: 1) + defer { buffer.deallocate() } + + if let fileName = path.fileName { + zipOpenNewFileInZip3( + zip, fileName, &zipInfo, + nil, 0, nil, 0, nil, + UInt16(Z_DEFLATED), compression.minizipCompression, 0, -MAX_WBITS, DEF_MEM_LEVEL, Z_DEFAULT_STRATEGY, + password, 0 + ) } else { throw ZipError.zipFail } - var length: Int = 0 + while feof(input) == 0 { - length = fread(buffer, 1, chunkSize, input) - zipWriteInFileInZip(zip, buffer, UInt32(length)) + zipWriteInFileInZip( + zip, + buffer, + UInt32(fread(buffer, 1, chunkSize, input)) + ) } - + // Update progress handler, only if progress is not 1, // because if we call it when progress == 1, // the user will receive a progress handler call with value 1.0 twice. - if let progressHandler = progress, currentPosition / totalSize != 1 { - progressHandler(currentPosition / totalSize) + if let progress, currentPosition / totalSize != 1 { + progress(currentPosition / totalSize) } - progressTracker.completedUnitCount = Int64(currentPosition) - + zipCloseFileInZip(zip) - free(buffer) } } + zipClose(zip, nil) - + // Completed. Update progress handler. - if let progressHandler = progress{ - progressHandler(1.0) + if let progress { + progress(1.0) } - progressTracker.completedUnitCount = Int64(totalSize) } - - /** - Adds a file extension to the set of custom file extensions. - - - Parameter fileExtension: A file extension. - */ + + /// Adds a file extension to the set of custom file extensions. + /// + /// - Parameter fileExtension: A file extension. public class func addCustomFileExtension(_ fileExtension: String) { + lock.lock() customFileExtensions.insert(fileExtension) + lock.unlock() } - - /** - Removes a file extension from the set of custom file extensions. - - - Parameter fileExtension: A file extension. - */ + + /// Removes a file extension from the set of custom file extensions. + /// + /// - Parameter fileExtension: A file extension. public class func removeCustomFileExtension(_ fileExtension: String) { + lock.lock() customFileExtensions.remove(fileExtension) + lock.unlock() } - - /** - Checks if a specific file extension is valid. - - - Parameter fileExtension: A file extension to check. - - - Returns: `true` if the extension is valid, otherwise `false`. - */ + + /// Checks if a specific file extension is valid. + /// + /// - Parameter fileExtension: A file extension to check. + /// + /// - Returns: `true` if the extension is valid, otherwise `false`. public class func isValidFileExtension(_ fileExtension: String) -> Bool { - let validFileExtensions: Set = customFileExtensions.union(["zip", "cbz"]) + lock.lock() + let validFileExtensions = customFileExtensions.union(["zip", "cbz"]) + lock.unlock() return validFileExtensions.contains(fileExtension) } } diff --git a/Sources/Zip/ZipCompression.swift b/Sources/Zip/ZipCompression.swift index bfa54a2f..24c09a6a 100644 --- a/Sources/Zip/ZipCompression.swift +++ b/Sources/Zip/ZipCompression.swift @@ -1,4 +1,4 @@ -@_implementationOnly import Minizip +@_implementationOnly import CMinizip /// Zip compression strategies. public enum ZipCompression: Int { @@ -19,4 +19,4 @@ public enum ZipCompression: Int { return Z_BEST_COMPRESSION } } -} \ No newline at end of file +} diff --git a/Sources/Zip/ZipUtilities.swift b/Sources/Zip/ZipUtilities.swift deleted file mode 100644 index 1763b4dc..00000000 --- a/Sources/Zip/ZipUtilities.swift +++ /dev/null @@ -1,104 +0,0 @@ -// -// ZipUtilities.swift -// Zip -// -// Created by Roy Marmelstein on 26/01/2016. -// Copyright © 2016 Roy Marmelstein. All rights reserved. -// - -import Foundation - -internal class ZipUtilities { - /* - Include root directory. - Default is true. - - e.g. The Test directory contains two files A.txt and B.txt. - - As true: - $ zip -r Test.zip Test/ - $ unzip -l Test.zip - Test/ - Test/A.txt - Test/B.txt - - As false: - $ zip -r Test.zip Test/ - $ unzip -l Test.zip - A.txt - B.txt - */ - let includeRootDirectory = true - - // File manager - let fileManager = FileManager.default - - /** - * ProcessedFilePath struct - */ - internal struct ProcessedFilePath { - let filePathURL: URL - let fileName: String? - - func filePath() -> String { - return filePathURL.path - } - } - - //MARK: Path processing - - /** - Process zip paths - - - parameter paths: Paths as `URL`. - - - returns: Array of `ProcessedFilePath` structs. - */ - internal func processZipPaths(_ paths: [URL]) -> [ProcessedFilePath] { - var processedFilePaths = [ProcessedFilePath]() - for path in paths { - let filePath = path.path - var isDirectory: ObjCBool = false - _ = fileManager.fileExists(atPath: filePath, isDirectory: &isDirectory) - if !isDirectory.boolValue { - let processedPath = ProcessedFilePath(filePathURL: path, fileName: path.lastPathComponent) - processedFilePaths.append(processedPath) - } else { - let directoryContents = expandDirectoryFilePath(path) - processedFilePaths.append(contentsOf: directoryContents) - } - } - return processedFilePaths - } - - /** - Expand directory contents and parse them into ProcessedFilePath structs. - - - parameter directory: Path of folder as `URL`. - - - returns: Array of `ProcessedFilePath` structs. - */ - internal func expandDirectoryFilePath(_ directory: URL) -> [ProcessedFilePath] { - var processedFilePaths = [ProcessedFilePath]() - let directoryPath = directory.path - if let enumerator = fileManager.enumerator(atPath: directoryPath) { - while let filePathComponent = enumerator.nextObject() as? String { - let path = directory.appendingPathComponent(filePathComponent) - let filePath = path.path - - var isDirectory: ObjCBool = false - _ = fileManager.fileExists(atPath: filePath, isDirectory: &isDirectory) - if !isDirectory.boolValue { - var fileName = filePathComponent - if includeRootDirectory { - let directoryName = directory.lastPathComponent - fileName = (directoryName as NSString).appendingPathComponent(filePathComponent) - } - let processedPath = ProcessedFilePath(filePathURL: path, fileName: fileName) - processedFilePaths.append(processedPath) - } - } - } - return processedFilePaths - } -} diff --git a/Tests/ZipTests/Resources/3crBXeO.gif b/Tests/ZipTests/TestResources/3crBXeO.gif similarity index 100% rename from Tests/ZipTests/Resources/3crBXeO.gif rename to Tests/ZipTests/TestResources/3crBXeO.gif diff --git a/Tests/ZipTests/TestResources/PassKitTest.order b/Tests/ZipTests/TestResources/PassKitTest.order new file mode 100644 index 00000000..184772b8 Binary files /dev/null and b/Tests/ZipTests/TestResources/PassKitTest.order differ diff --git a/Tests/ZipTests/TestResources/PassKitTest.pkpass b/Tests/ZipTests/TestResources/PassKitTest.pkpass new file mode 100644 index 00000000..91aadbfb Binary files /dev/null and b/Tests/ZipTests/TestResources/PassKitTest.pkpass differ diff --git a/Tests/ZipTests/Resources/bb8.zip b/Tests/ZipTests/TestResources/bb8.zip similarity index 100% rename from Tests/ZipTests/Resources/bb8.zip rename to Tests/ZipTests/TestResources/bb8.zip diff --git a/Tests/ZipTests/Resources/kYkLkPf.gif b/Tests/ZipTests/TestResources/kYkLkPf.gif similarity index 100% rename from Tests/ZipTests/Resources/kYkLkPf.gif rename to Tests/ZipTests/TestResources/kYkLkPf.gif diff --git a/Tests/ZipTests/Resources/pathTraversal.zip b/Tests/ZipTests/TestResources/pathTraversal.zip similarity index 100% rename from Tests/ZipTests/Resources/pathTraversal.zip rename to Tests/ZipTests/TestResources/pathTraversal.zip diff --git a/Tests/ZipTests/Resources/permissions.zip b/Tests/ZipTests/TestResources/permissions.zip similarity index 100% rename from Tests/ZipTests/Resources/permissions.zip rename to Tests/ZipTests/TestResources/permissions.zip diff --git a/Tests/ZipTests/Resources/prod-apple-swift-metrics-main-e6a00d36-finder.zip b/Tests/ZipTests/TestResources/prod-apple-swift-metrics-main-e6a00d36-finder.zip similarity index 100% rename from Tests/ZipTests/Resources/prod-apple-swift-metrics-main-e6a00d36-finder.zip rename to Tests/ZipTests/TestResources/prod-apple-swift-metrics-main-e6a00d36-finder.zip diff --git a/Tests/ZipTests/Resources/prod-apple-swift-metrics-main-e6a00d36-test.zip b/Tests/ZipTests/TestResources/prod-apple-swift-metrics-main-e6a00d36-test.zip similarity index 100% rename from Tests/ZipTests/Resources/prod-apple-swift-metrics-main-e6a00d36-test.zip rename to Tests/ZipTests/TestResources/prod-apple-swift-metrics-main-e6a00d36-test.zip diff --git a/Tests/ZipTests/Resources/prod-apple-swift-metrics-main-e6a00d36.zip b/Tests/ZipTests/TestResources/prod-apple-swift-metrics-main-e6a00d36.zip similarity index 100% rename from Tests/ZipTests/Resources/prod-apple-swift-metrics-main-e6a00d36.zip rename to Tests/ZipTests/TestResources/prod-apple-swift-metrics-main-e6a00d36.zip diff --git a/Tests/ZipTests/Resources/unsupported_permissions.zip b/Tests/ZipTests/TestResources/unsupported_permissions.zip similarity index 100% rename from Tests/ZipTests/Resources/unsupported_permissions.zip rename to Tests/ZipTests/TestResources/unsupported_permissions.zip diff --git a/Tests/ZipTests/ZipTests.swift b/Tests/ZipTests/ZipTests.swift index 60b4a90a..1e735f27 100644 --- a/Tests/ZipTests/ZipTests.swift +++ b/Tests/ZipTests/ZipTests.swift @@ -7,18 +7,14 @@ // import XCTest + @testable import Zip final class ZipTests: XCTestCase { private func url(forResource resource: String, withExtension ext: String? = nil) -> URL? { - #if swift(>=6.0) - let filePath = URL(fileURLWithPath: #file) - #else - let filePath = URL(fileURLWithPath: #filePath) - #endif - let resourcePath = filePath + let resourcePath = URL(fileURLWithPath: #filePath) .deletingLastPathComponent() - .appendingPathComponent("Resources") + .appendingPathComponent("TestResources") .appendingPathComponent(resource) return ext.map { resourcePath.appendingPathExtension($0) } ?? resourcePath } @@ -48,17 +44,17 @@ final class ZipTests: XCTestCase { try XCTAssertGreaterThan(Data(contentsOf: destinationURL.appendingPathComponent("3crBXeO.gif")).count, 0) try XCTAssertGreaterThan(Data(contentsOf: destinationURL.appendingPathComponent("kYkLkPf.gif")).count, 0) } - + func testQuickUnzipNonExistingPath() { let filePath = URL(fileURLWithPath: "/some/path/to/nowhere/bb9.zip") XCTAssertThrowsError(try Zip.quickUnzipFile(filePath)) } - + func testQuickUnzipNonZipPath() { let filePath = url(forResource: "3crBXeO", withExtension: "gif")! XCTAssertThrowsError(try Zip.quickUnzipFile(filePath)) } - + func testQuickUnzipProgress() throws { let filePath = url(forResource: "bb8", withExtension: "zip")! let destinationURL = try Zip.quickUnzipFile(filePath) { progress in @@ -71,12 +67,12 @@ final class ZipTests: XCTestCase { try XCTAssertGreaterThan(Data(contentsOf: destinationURL.appendingPathComponent("3crBXeO.gif")).count, 0) try XCTAssertGreaterThan(Data(contentsOf: destinationURL.appendingPathComponent("kYkLkPf.gif")).count, 0) } - + func testQuickUnzipOnlineURL() { let filePath = URL(string: "http://www.google.com/google.zip")! XCTAssertThrowsError(try Zip.quickUnzipFile(filePath)) } - + func testUnzip() throws { let filePath = url(forResource: "bb8", withExtension: "zip")! let destinationPath = try autoRemovingSandbox() @@ -87,7 +83,7 @@ final class ZipTests: XCTestCase { try XCTAssertGreaterThan(Data(contentsOf: destinationPath.appendingPathComponent("3crBXeO.gif")).count, 0) try XCTAssertGreaterThan(Data(contentsOf: destinationPath.appendingPathComponent("kYkLkPf.gif")).count, 0) } - + func testImplicitProgressUnzip() throws { let progress = Progress(totalUnitCount: 1) @@ -100,7 +96,7 @@ final class ZipTests: XCTestCase { XCTAssertTrue(progress.totalUnitCount == progress.completedUnitCount) } - + func testImplicitProgressZip() throws { let progress = Progress(totalUnitCount: 1) @@ -115,7 +111,7 @@ final class ZipTests: XCTestCase { XCTAssertTrue(progress.totalUnitCount == progress.completedUnitCount) } - + func testQuickZip() throws { let imageURL1 = url(forResource: "3crBXeO", withExtension: "gif")! let imageURL2 = url(forResource: "kYkLkPf", withExtension: "gif")! @@ -133,13 +129,13 @@ final class ZipTests: XCTestCase { let destinationURL = try Zip.quickZipFiles([imageURL1, imageURL2], fileName: "archive") { progress in XCTAssertFalse(progress.isNaN) } - XCTAssertTrue(FileManager.default.fileExists(atPath:destinationURL.path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: destinationURL.path)) try XCTAssertGreaterThan(Data(contentsOf: destinationURL).count, 0) addTeardownBlock { try? FileManager.default.removeItem(at: destinationURL) } } - + func testQuickZipFolder() throws { let fileManager = FileManager.default let imageURL1 = url(forResource: "3crBXeO", withExtension: "gif")! @@ -164,7 +160,7 @@ final class ZipTests: XCTestCase { XCTAssertNoThrow(try Zip.zipFiles(paths: [imageURL1, imageURL2], zipFilePath: zipFilePath, password: nil, progress: nil)) XCTAssertTrue(FileManager.default.fileExists(atPath: zipFilePath.path)) } - + func testZipUnzipPassword() throws { let imageURL1 = url(forResource: "3crBXeO", withExtension: "gif")! let imageURL2 = url(forResource: "kYkLkPf", withExtension: "gif")! @@ -183,7 +179,13 @@ final class ZipTests: XCTestCase { let unzipDestination = try Zip.quickUnzipFile(permissionsURL) let permission644 = unzipDestination.appendingPathComponent("unsupported_permission").appendingPathExtension("txt") let foundPermissions = try FileManager.default.attributesOfItem(atPath: permission644.path)[.posixPermissions] as? Int - let expectedPermissions = 0o644 + #if os(Windows) && compiler(<6.0) + let expectedPermissions = 0o700 + #elseif os(Windows) && compiler(>=6.0) + let expectedPermissions = 0o600 + #else + let expectedPermissions = 0o644 + #endif XCTAssertNotNil(foundPermissions) XCTAssertEqual( foundPermissions, @@ -206,9 +208,19 @@ final class ZipTests: XCTestCase { let attributes777 = try fileManager.attributesOfItem(atPath: permission777.path) let attributes600 = try fileManager.attributesOfItem(atPath: permission600.path) let attributes604 = try fileManager.attributesOfItem(atPath: permission604.path) - XCTAssertEqual(attributes777[.posixPermissions] as? Int, 0o777) - XCTAssertEqual(attributes600[.posixPermissions] as? Int, 0o600) - XCTAssertEqual(attributes604[.posixPermissions] as? Int, 0o604) + #if os(Windows) && compiler(<6.0) + XCTAssertEqual(attributes777[.posixPermissions] as? Int, 0o700) + XCTAssertEqual(attributes600[.posixPermissions] as? Int, 0o700) + XCTAssertEqual(attributes604[.posixPermissions] as? Int, 0o700) + #elseif os(Windows) && compiler(>=6.0) + XCTAssertEqual(attributes777[.posixPermissions] as? Int, 0o600) + XCTAssertEqual(attributes600[.posixPermissions] as? Int, 0o600) + XCTAssertEqual(attributes604[.posixPermissions] as? Int, 0o600) + #else + XCTAssertEqual(attributes777[.posixPermissions] as? Int, 0o777) + XCTAssertEqual(attributes600[.posixPermissions] as? Int, 0o600) + XCTAssertEqual(attributes604[.posixPermissions] as? Int, 0o604) + #endif } // Tests if https://github.com/marmelroy/Zip/issues/245 does not uccor anymore. @@ -220,9 +232,12 @@ final class ZipTests: XCTestCase { try Zip.unzipFile(filePath, destination: destinationPath, overwrite: true, password: "password", progress: nil) XCTFail("ZipError.unzipFail expected.") } catch {} - - let fileManager = FileManager.default - XCTAssertFalse(fileManager.fileExists(atPath: destinationPath.appendingPathComponent("../naughtyFile.txt").path)) + + XCTAssertFalse( + FileManager.default.fileExists( + atPath: destinationPath.appendingPathComponent("../naughtyFile.txt").path + ) + ) } func testQuickUnzipSubDir() throws { @@ -239,7 +254,7 @@ final class ZipTests: XCTestCase { XCTAssertTrue(fileManager.fileExists(atPath: subDir.path)) XCTAssertTrue(fileManager.fileExists(atPath: imageURL.path)) } - + func testAddedCustomFileExtensionIsValid() { let fileExtension = "cstm" Zip.addCustomFileExtension(fileExtension) @@ -247,7 +262,7 @@ final class ZipTests: XCTestCase { XCTAssertTrue(result) Zip.removeCustomFileExtension(fileExtension) } - + func testRemovedCustomFileExtensionIsInvalid() { let fileExtension = "cstm" Zip.addCustomFileExtension(fileExtension) @@ -255,12 +270,12 @@ final class ZipTests: XCTestCase { let result = Zip.isValidFileExtension(fileExtension) XCTAssertFalse(result) } - + func testDefaultFileExtensionsIsValid() { XCTAssertTrue(Zip.isValidFileExtension("zip")) XCTAssertTrue(Zip.isValidFileExtension("cbz")) } - + func testDefaultFileExtensionsIsNotRemoved() { Zip.removeCustomFileExtension("zip") Zip.removeCustomFileExtension("cbz") @@ -312,9 +327,10 @@ final class ZipTests: XCTestCase { } func testDosDate() { - XCTAssertEqual(0b10000011001100011000110000110001, Date(timeIntervalSince1970: 2389282415).dosDate) - XCTAssertEqual(0b00000001001100011000110000110001, Date(timeIntervalSince1970: 338060015).dosDate) - XCTAssertEqual(0b00000000001000010000000000000000, Date(timeIntervalSince1970: 315532800).dosDate) + NSTimeZone.default = NSTimeZone(forSecondsFromGMT: 0) as TimeZone + XCTAssertEqual(0b10000011_00110001_10001100_00110001, Date(timeIntervalSince1970: 2_389_282_415).dosDate) + XCTAssertEqual(0b00000001_00110001_10001100_00110001, Date(timeIntervalSince1970: 338_060_015).dosDate) + XCTAssertEqual(0b00000000_00100001_00000000_00000000, Date(timeIntervalSince1970: 315_532_800).dosDate) } func testInit() { @@ -324,6 +340,33 @@ final class ZipTests: XCTestCase { XCTAssertNil(zip) } + func testUnzipWithoutPassword() throws { + let imageURL1 = url(forResource: "3crBXeO", withExtension: "gif")! + let imageURL2 = url(forResource: "kYkLkPf", withExtension: "gif")! + let zipFilePath = try autoRemovingSandbox().appendingPathComponent("archive.zip") + try Zip.zipFiles(paths: [imageURL1, imageURL2], zipFilePath: zipFilePath, password: "password") + XCTAssertTrue(FileManager.default.fileExists(atPath: zipFilePath.path)) + let directoryName = zipFilePath.lastPathComponent.replacingOccurrences(of: ".\(zipFilePath.pathExtension)", with: "") + let destinationUrl = try autoRemovingSandbox().appendingPathComponent(directoryName, isDirectory: true) + XCTAssertThrowsError(try Zip.unzipFile(zipFilePath, destination: destinationUrl)) + } + + func testFileHandler() throws { + let filePath = url(forResource: "bb8", withExtension: "zip")! + let destinationPath = try autoRemovingSandbox() + XCTAssertNoThrow( + try Zip.unzipFile( + filePath, destination: destinationPath, password: "password", + fileOutputHandler: { fileURL in + XCTAssertTrue(FileManager.default.fileExists(atPath: fileURL.path)) + } + ) + ) + XCTAssertTrue(FileManager.default.fileExists(atPath: destinationPath.path)) + try XCTAssertGreaterThan(Data(contentsOf: destinationPath.appendingPathComponent("3crBXeO.gif")).count, 0) + try XCTAssertGreaterThan(Data(contentsOf: destinationPath.appendingPathComponent("kYkLkPf.gif")).count, 0) + } + // Tests if https://github.com/vapor-community/Zip/issues/4 does not occur anymore. func testRoundTripping() throws { // "prod-apple-swift-metrics-main-e6a00d36.zip" is the original zip file from the issue. @@ -365,4 +408,84 @@ final class ZipTests: XCTestCase { let newUnzippedFiles = try FileManager.default.contentsOfDirectory(atPath: newDestinationFolder.path) XCTAssertEqual(unzippedFiles, newUnzippedFiles) } + + #if os(Windows) + func testWindowsReservedChars() throws { + let txtFile = ArchiveFile(filename: "a_b.txt", data: "Hi Mom!".data(using: .utf8)!) + let txtFile1 = ArchiveFile(filename: "ab.txt", data: "Hello, Swift!".data(using: .utf8)!) + let txtFile3 = ArchiveFile(filename: "a:b.txt", data: "Hello, World!".data(using: .utf8)!) + let txtFile4 = ArchiveFile(filename: "a\"b.txt", data: "Hi Windows!".data(using: .utf8)!) + let txtFile5 = ArchiveFile(filename: "a|b.txt", data: "Hi Barbie!".data(using: .utf8)!) + let txtFile6 = ArchiveFile(filename: "a?b.txt", data: "Hi, Ken!".data(using: .utf8)!) + let txtFile7 = ArchiveFile(filename: "a*b.txt", data: "Hello Everyone!".data(using: .utf8)!) + + let file = ArchiveFile(filename: "a_b", data: "Hello, World!".data(using: .utf8)!) + let file1 = ArchiveFile(filename: "ab", data: "Hello, Swift!".data(using: .utf8)!) + let file3 = ArchiveFile(filename: "a:b", data: "Hello, World!".data(using: .utf8)!) + + let sandboxFolder = try autoRemovingSandbox() + let zipFilePath = sandboxFolder.appendingPathComponent("archive.zip") + try Zip.zipData( + archiveFiles: [ + txtFile, txtFile1, txtFile2, txtFile3, txtFile4, txtFile5, txtFile6, txtFile7, + file, file1, file2, file3, + ], + zipFilePath: zipFilePath + ) + + let destinationPath = try autoRemovingSandbox() + try Zip.unzipFile(zipFilePath, destination: destinationPath) + + let txtFileURL = destinationPath.appendingPathComponent("a_b.txt") + let txtFile1URL = destinationPath.appendingPathComponent("a_b (1).txt") + let txtFile2URL = destinationPath.appendingPathComponent("a_b (2).txt") + let txtFile3URL = destinationPath.appendingPathComponent("a_b (3).txt") + let txtFile4URL = destinationPath.appendingPathComponent("a_b (4).txt") + let txtFile5URL = destinationPath.appendingPathComponent("a_b (5).txt") + let txtFile6URL = destinationPath.appendingPathComponent("a_b (6).txt") + let txtFile7URL = destinationPath.appendingPathComponent("a_b (7).txt") + + let fileURL = destinationPath.appendingPathComponent("a_b") + let file1URL = destinationPath.appendingPathComponent("a_b (1)") + let file2URL = destinationPath.appendingPathComponent("a_b (2)") + let file3URL = destinationPath.appendingPathComponent("a_b (3)") + + XCTAssertTrue(FileManager.default.fileExists(atPath: txtFileURL.path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: txtFile1URL.path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: txtFile2URL.path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: txtFile3URL.path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: txtFile4URL.path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: txtFile5URL.path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: txtFile6URL.path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: txtFile7URL.path)) + + XCTAssertTrue(FileManager.default.fileExists(atPath: fileURL.path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: file1URL.path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: file2URL.path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: file3URL.path)) + } + #endif + + func testPassKitExtensions() throws { + let pkpassURL = url(forResource: "PassKitTest", withExtension: "pkpass")! + let pkpassDestination = try autoRemovingSandbox() + Zip.addCustomFileExtension("pkpass") + XCTAssertNoThrow(try Zip.unzipFile(pkpassURL, destination: pkpassDestination)) + XCTAssert(FileManager.default.fileExists(atPath: pkpassDestination.appendingPathComponent("pass.json").path)) + XCTAssert(FileManager.default.fileExists(atPath: pkpassDestination.appendingPathComponent("manifest.json").path)) + XCTAssert(FileManager.default.fileExists(atPath: pkpassDestination.appendingPathComponent("signature").path)) + XCTAssert(FileManager.default.fileExists(atPath: pkpassDestination.appendingPathComponent("icon.png").path)) + XCTAssert(FileManager.default.fileExists(atPath: pkpassDestination.appendingPathComponent("logo.png").path)) + + let orderURL = url(forResource: "PassKitTest", withExtension: "order")! + let orderDestination = try autoRemovingSandbox() + Zip.addCustomFileExtension("order") + XCTAssertNoThrow(try Zip.unzipFile(orderURL, destination: orderDestination)) + XCTAssert(FileManager.default.fileExists(atPath: orderDestination.appendingPathComponent("order.json").path)) + XCTAssert(FileManager.default.fileExists(atPath: orderDestination.appendingPathComponent("manifest.json").path)) + XCTAssert(FileManager.default.fileExists(atPath: orderDestination.appendingPathComponent("signature").path)) + XCTAssert(FileManager.default.fileExists(atPath: orderDestination.appendingPathComponent("icon.png").path)) + } }