Skip to content

Commit b57e858

Browse files
authored
Use swift-zip-archive (#4)
* Use `swift-zip-archive` * Use new URL APIs * Reduce file I/O
1 parent d1787da commit b57e858

File tree

5 files changed

+117
-143
lines changed

5 files changed

+117
-143
lines changed

Package.swift

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,22 +4,22 @@ import PackageDescription
44
let package = Package(
55
name: "swift-wallet",
66
platforms: [
7-
.macOS(.v12)
7+
.macOS(.v13)
88
],
99
products: [
1010
.library(name: "WalletPasses", targets: ["WalletPasses"]),
1111
.library(name: "WalletOrders", targets: ["WalletOrders"]),
1212
],
1313
dependencies: [
1414
.package(url: "https://github.com/apple/swift-certificates.git", from: "1.6.1"),
15-
.package(url: "https://github.com/vapor-community/Zip.git", from: "2.2.4"),
15+
.package(url: "https://github.com/adam-fowler/swift-zip-archive.git", from: "0.4.1"),
1616
],
1717
targets: [
1818
.target(
1919
name: "WalletPasses",
2020
dependencies: [
2121
.product(name: "X509", package: "swift-certificates"),
22-
.product(name: "Zip", package: "zip"),
22+
.product(name: "ZipArchive", package: "swift-zip-archive"),
2323
],
2424
swiftSettings: swiftSettings
2525
),
@@ -37,7 +37,7 @@ let package = Package(
3737
name: "WalletOrders",
3838
dependencies: [
3939
.product(name: "X509", package: "swift-certificates"),
40-
.product(name: "Zip", package: "zip"),
40+
.product(name: "ZipArchive", package: "swift-zip-archive"),
4141
],
4242
swiftSettings: swiftSettings
4343
),

Sources/WalletOrders/OrderBuilder.swift

Lines changed: 42 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import Crypto
22
import Foundation
33
@_spi(CMS) import X509
4-
import Zip
4+
import ZipArchive
55

6-
/// A tool that generates pass content bundles.
6+
/// A tool that generates order content bundles.
7+
///
8+
/// > Warning: You can only sign orders with the same order type identifier of the certificates used to initialize the ``OrderBuilder``.
79
public struct OrderBuilder: Sendable {
810
private let pemWWDRCertificate: String
911
private let pemCertificate: String
@@ -37,25 +39,25 @@ public struct OrderBuilder: Sendable {
3739
self.pemCertificate = pemCertificate
3840
self.pemPrivateKey = pemPrivateKey
3941
self.pemPrivateKeyPassword = pemPrivateKeyPassword
40-
self.openSSLURL = URL(fileURLWithPath: openSSLPath)
42+
self.openSSLURL = URL(filePath: openSSLPath)
4143
}
4244

4345
private func signature(for manifest: Data) throws -> Data {
4446
// Swift Crypto doesn't support encrypted PEM private keys, so we have to use OpenSSL for that.
4547
if let pemPrivateKeyPassword {
46-
guard FileManager.default.fileExists(atPath: self.openSSLURL.path) else {
48+
guard FileManager.default.fileExists(atPath: self.openSSLURL.path()) else {
4749
throw WalletOrdersError.noOpenSSLExecutable
4850
}
4951

50-
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
52+
let tempDir = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString, directoryHint: .isDirectory)
5153
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
5254
defer { try? FileManager.default.removeItem(at: tempDir) }
5355

54-
let manifestURL = tempDir.appendingPathComponent(Self.manifestFileName)
55-
let wwdrURL = tempDir.appendingPathComponent("wwdr.pem")
56-
let certificateURL = tempDir.appendingPathComponent("certificate.pem")
57-
let privateKeyURL = tempDir.appendingPathComponent("private.pem")
58-
let signatureURL = tempDir.appendingPathComponent(Self.signatureFileName)
56+
let manifestURL = tempDir.appending(path: Self.manifestFileName)
57+
let wwdrURL = tempDir.appending(path: "wwdr.pem")
58+
let certificateURL = tempDir.appending(path: "certificate.pem")
59+
let privateKeyURL = tempDir.appending(path: "private.pem")
60+
let signatureURL = tempDir.appending(path: Self.signatureFileName)
5961

6062
try manifest.write(to: manifestURL)
6163
try self.pemWWDRCertificate.write(to: wwdrURL, atomically: true, encoding: .utf8)
@@ -67,11 +69,11 @@ public struct OrderBuilder: Sendable {
6769
process.executableURL = self.openSSLURL
6870
process.arguments = [
6971
"smime", "-binary", "-sign",
70-
"-certfile", wwdrURL.path,
71-
"-signer", certificateURL.path,
72-
"-inkey", privateKeyURL.path,
73-
"-in", manifestURL.path,
74-
"-out", signatureURL.path,
72+
"-certfile", wwdrURL.path(),
73+
"-signer", certificateURL.path(),
74+
"-inkey", privateKeyURL.path(),
75+
"-in", manifestURL.path(),
76+
"-out", signatureURL.path(),
7577
"-outform", "DER",
7678
"-passin", "pass:\(pemPrivateKeyPassword)",
7779
]
@@ -105,51 +107,43 @@ public struct OrderBuilder: Sendable {
105107
order: some OrderJSON.Properties,
106108
sourceFilesDirectoryPath: String
107109
) throws -> Data {
108-
let filesDirectory = URL(fileURLWithPath: sourceFilesDirectoryPath, isDirectory: true)
109-
guard
110-
(try? filesDirectory.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false
111-
else {
110+
let filesDirectory = URL(filePath: sourceFilesDirectoryPath, directoryHint: .isDirectory)
111+
guard (try? filesDirectory.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false else {
112112
throw WalletOrdersError.noSourceFiles
113113
}
114114

115-
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
116-
try FileManager.default.copyItem(at: filesDirectory, to: tempDir)
117-
defer { try? FileManager.default.removeItem(at: tempDir) }
118-
119-
var archiveFiles: [ArchiveFile] = []
115+
var archiveFiles: [String: Data] = [:]
116+
var manifestJSON: [String: String] = [:]
120117

121118
let orderJSON = try self.encoder.encode(order)
122-
try orderJSON.write(to: tempDir.appendingPathComponent("order.json"))
123-
archiveFiles.append(ArchiveFile(filename: "order.json", data: orderJSON))
124-
125-
let sourceFilesPaths = try FileManager.default.subpathsOfDirectory(atPath: tempDir.path)
126-
127-
var manifestJSON: [String: String] = [:]
119+
archiveFiles["order.json"] = orderJSON
120+
manifestJSON["order.json"] = orderJSON.manifestHash
128121

122+
let sourceFilesPaths = try FileManager.default.subpathsOfDirectory(atPath: filesDirectory.path())
129123
for relativePath in sourceFilesPaths {
130-
let fileURL = URL(fileURLWithPath: relativePath, relativeTo: tempDir)
131-
132-
guard !fileURL.hasDirectoryPath else {
133-
continue
134-
}
135-
136-
guard !(fileURL.lastPathComponent == ".gitkeep" || fileURL.lastPathComponent == ".DS_Store") else {
137-
continue
138-
}
124+
let fileURL = URL(filePath: relativePath, directoryHint: .checkFileSystem, relativeTo: filesDirectory)
125+
guard !fileURL.hasDirectoryPath else { continue }
126+
if fileURL.lastPathComponent == ".gitkeep" || fileURL.lastPathComponent == ".DS_Store" { continue }
139127

140128
let fileData = try Data(contentsOf: fileURL)
141-
142-
archiveFiles.append(ArchiveFile(filename: relativePath, data: fileData))
143-
144-
manifestJSON[relativePath] = SHA256.hash(data: fileData).map { "0\(String($0, radix: 16))".suffix(2) }.joined()
129+
archiveFiles[relativePath] = fileData
130+
manifestJSON[relativePath] = fileData.manifestHash
145131
}
146132

147133
let manifestData = try self.encoder.encode(manifestJSON)
148-
archiveFiles.append(ArchiveFile(filename: Self.manifestFileName, data: manifestData))
149-
try archiveFiles.append(ArchiveFile(filename: Self.signatureFileName, data: self.signature(for: manifestData)))
134+
archiveFiles[Self.manifestFileName] = manifestData
135+
try archiveFiles[Self.signatureFileName] = self.signature(for: manifestData)
136+
137+
let writer = ZipArchiveWriter()
138+
for (filename, contents) in archiveFiles {
139+
try writer.writeFile(filename: filename, contents: Array(contents))
140+
}
141+
return try Data(writer.finalizeBuffer())
142+
}
143+
}
150144

151-
let zipFile = tempDir.appendingPathComponent("\(UUID().uuidString).order")
152-
try Zip.zipData(archiveFiles: archiveFiles, zipFilePath: zipFile)
153-
return try Data(contentsOf: zipFile)
145+
extension Data {
146+
fileprivate var manifestHash: String {
147+
SHA256.hash(data: self).map { "0\(String($0, radix: 16))".suffix(2) }.joined()
154148
}
155149
}

Sources/WalletPasses/PassBuilder.swift

Lines changed: 43 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import Crypto
22
import Foundation
33
@_spi(CMS) import X509
4-
import Zip
4+
import ZipArchive
55

66
/// A tool that generates pass content bundles.
7+
///
8+
/// > Warning: You can only sign passes with the same pass type identifier of the certificates used to initialize the ``PassBuilder``.
79
public struct PassBuilder: Sendable {
810
private let pemWWDRCertificate: String
911
private let pemCertificate: String
@@ -37,7 +39,7 @@ public struct PassBuilder: Sendable {
3739
self.pemCertificate = pemCertificate
3840
self.pemPrivateKey = pemPrivateKey
3941
self.pemPrivateKeyPassword = pemPrivateKeyPassword
40-
self.openSSLURL = URL(fileURLWithPath: openSSLPath)
42+
self.openSSLURL = URL(filePath: openSSLPath)
4143
}
4244

4345
/// Generates a signature for a given personalization token.
@@ -50,19 +52,19 @@ public struct PassBuilder: Sendable {
5052
public func signature(for data: Data) throws -> Data {
5153
// Swift Crypto doesn't support encrypted PEM private keys, so we have to use OpenSSL for that.
5254
if let pemPrivateKeyPassword {
53-
guard FileManager.default.fileExists(atPath: self.openSSLURL.path) else {
55+
guard FileManager.default.fileExists(atPath: self.openSSLURL.path()) else {
5456
throw WalletPassesError.noOpenSSLExecutable
5557
}
5658

57-
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
59+
let tempDir = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString, directoryHint: .isDirectory)
5860
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
5961
defer { try? FileManager.default.removeItem(at: tempDir) }
6062

61-
let manifestURL = tempDir.appendingPathComponent(Self.manifestFileName)
62-
let wwdrURL = tempDir.appendingPathComponent("wwdr.pem")
63-
let certificateURL = tempDir.appendingPathComponent("certificate.pem")
64-
let privateKeyURL = tempDir.appendingPathComponent("private.pem")
65-
let signatureURL = tempDir.appendingPathComponent(Self.signatureFileName)
63+
let manifestURL = tempDir.appending(path: Self.manifestFileName)
64+
let wwdrURL = tempDir.appending(path: "wwdr.pem")
65+
let certificateURL = tempDir.appending(path: "certificate.pem")
66+
let privateKeyURL = tempDir.appending(path: "private.pem")
67+
let signatureURL = tempDir.appending(path: Self.signatureFileName)
6668

6769
try data.write(to: manifestURL)
6870
try self.pemWWDRCertificate.write(to: wwdrURL, atomically: true, encoding: .utf8)
@@ -74,11 +76,11 @@ public struct PassBuilder: Sendable {
7476
process.executableURL = self.openSSLURL
7577
process.arguments = [
7678
"smime", "-binary", "-sign",
77-
"-certfile", wwdrURL.path,
78-
"-signer", certificateURL.path,
79-
"-inkey", privateKeyURL.path,
80-
"-in", manifestURL.path,
81-
"-out", signatureURL.path,
79+
"-certfile", wwdrURL.path(),
80+
"-signer", certificateURL.path(),
81+
"-inkey", privateKeyURL.path(),
82+
"-in", manifestURL.path(),
83+
"-out", signatureURL.path(),
8284
"-outform", "DER",
8385
"-passin", "pass:\(pemPrivateKeyPassword)",
8486
]
@@ -114,31 +116,25 @@ public struct PassBuilder: Sendable {
114116
sourceFilesDirectoryPath: String,
115117
personalization: PersonalizationJSON? = nil
116118
) throws -> Data {
117-
let filesDirectory = URL(fileURLWithPath: sourceFilesDirectoryPath, isDirectory: true)
118-
guard
119-
(try? filesDirectory.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false
120-
else {
119+
let filesDirectory = URL(filePath: sourceFilesDirectoryPath, directoryHint: .isDirectory)
120+
guard (try? filesDirectory.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false else {
121121
throw WalletPassesError.noSourceFiles
122122
}
123123

124-
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
125-
try FileManager.default.copyItem(at: filesDirectory, to: tempDir)
126-
defer { try? FileManager.default.removeItem(at: tempDir) }
127-
128-
var archiveFiles: [ArchiveFile] = []
124+
var archiveFiles: [String: Data] = [:]
125+
var manifestJSON: [String: String] = [:]
129126

130127
let passJSON = try self.encoder.encode(pass)
131-
try passJSON.write(to: tempDir.appendingPathComponent("pass.json"))
132-
archiveFiles.append(ArchiveFile(filename: "pass.json", data: passJSON))
128+
archiveFiles["pass.json"] = passJSON
129+
manifestJSON["pass.json"] = passJSON.manifestHash
133130

134-
// Pass Personalization
135131
if let personalization {
136132
let personalizationJSONData = try self.encoder.encode(personalization)
137-
try personalizationJSONData.write(to: tempDir.appendingPathComponent("personalization.json"))
138-
archiveFiles.append(ArchiveFile(filename: "personalization.json", data: personalizationJSONData))
133+
archiveFiles["personalization.json"] = personalizationJSONData
134+
manifestJSON["personalization.json"] = personalizationJSONData.manifestHash
139135
}
140136

141-
let sourceFilesPaths = try FileManager.default.subpathsOfDirectory(atPath: tempDir.path)
137+
let sourceFilesPaths = try FileManager.default.subpathsOfDirectory(atPath: filesDirectory.path())
142138

143139
if personalization != nil {
144140
guard
@@ -160,32 +156,30 @@ public struct PassBuilder: Sendable {
160156
throw WalletPassesError.noIcon
161157
}
162158

163-
var manifestJSON: [String: String] = [:]
164-
165159
for relativePath in sourceFilesPaths {
166-
let fileURL = URL(fileURLWithPath: relativePath, relativeTo: tempDir)
167-
168-
guard !fileURL.hasDirectoryPath else {
169-
continue
170-
}
171-
172-
guard !(fileURL.lastPathComponent == ".gitkeep" || fileURL.lastPathComponent == ".DS_Store") else {
173-
continue
174-
}
160+
let fileURL = URL(filePath: relativePath, directoryHint: .checkFileSystem, relativeTo: filesDirectory)
161+
guard !fileURL.hasDirectoryPath else { continue }
162+
if fileURL.lastPathComponent == ".gitkeep" || fileURL.lastPathComponent == ".DS_Store" { continue }
175163

176164
let fileData = try Data(contentsOf: fileURL)
177-
178-
archiveFiles.append(ArchiveFile(filename: relativePath, data: fileData))
179-
180-
manifestJSON[relativePath] = Insecure.SHA1.hash(data: fileData).map { "0\(String($0, radix: 16))".suffix(2) }.joined()
165+
archiveFiles[relativePath] = fileData
166+
manifestJSON[relativePath] = fileData.manifestHash
181167
}
182168

183169
let manifestData = try self.encoder.encode(manifestJSON)
184-
archiveFiles.append(ArchiveFile(filename: Self.manifestFileName, data: manifestData))
185-
try archiveFiles.append(ArchiveFile(filename: Self.signatureFileName, data: self.signature(for: manifestData)))
170+
archiveFiles[Self.manifestFileName] = manifestData
171+
try archiveFiles[Self.signatureFileName] = self.signature(for: manifestData)
172+
173+
let writer = ZipArchiveWriter()
174+
for (filename, contents) in archiveFiles {
175+
try writer.writeFile(filename: filename, contents: Array(contents))
176+
}
177+
return try Data(writer.finalizeBuffer())
178+
}
179+
}
186180

187-
let zipFile = tempDir.appendingPathComponent("\(UUID().uuidString).pkpass")
188-
try Zip.zipData(archiveFiles: archiveFiles, zipFilePath: zipFile)
189-
return try Data(contentsOf: zipFile)
181+
extension Data {
182+
fileprivate var manifestHash: String {
183+
Insecure.SHA1.hash(data: self).map { "0\(String($0, radix: 16))".suffix(2) }.joined()
190184
}
191185
}

Tests/WalletOrdersTests/WalletOrdersTests.swift

Lines changed: 12 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,13 @@ import Crypto
22
import Foundation
33
import Testing
44
import WalletOrders
5-
import Zip
5+
import ZipArchive
66

77
@Suite("WalletOrders Tests")
88
struct WalletOrdersTests {
99
let decoder = JSONDecoder()
1010
let order = TestOrder()
1111

12-
init() {
13-
Zip.addCustomFileExtension("order")
14-
}
15-
1612
@Test("Build Order")
1713
func build() throws {
1814
let builder = OrderBuilder(
@@ -81,26 +77,23 @@ struct WalletOrdersTests {
8177
}
8278

8379
private func testRoundTripped(_ bundle: Data) throws {
84-
let orderURL = FileManager.default.temporaryDirectory.appendingPathComponent("\(UUID().uuidString).order")
85-
try bundle.write(to: orderURL)
86-
let orderFolder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
87-
try Zip.unzipFile(orderURL, destination: orderFolder)
80+
let reader = try ZipArchiveReader(buffer: bundle)
81+
let directory = try reader.readDirectory()
8882

89-
#expect(FileManager.default.fileExists(atPath: orderFolder.path.appending("/signature")))
83+
#expect(directory.contains { $0.filename == "signature" })
9084

91-
#expect(FileManager.default.fileExists(atPath: orderFolder.path.appending("/pet_store_logo.png")))
92-
#expect(FileManager.default.fileExists(atPath: orderFolder.path.appending("/it-IT.lproj/pet_store_logo.png")))
85+
#expect(directory.contains { $0.filename == "pet_store_logo.png" })
86+
#expect(directory.contains { $0.filename == "it-IT.lproj/pet_store_logo.png" })
9387

94-
#expect(FileManager.default.fileExists(atPath: orderFolder.path.appending("/order.json")))
95-
let orderData = try String(contentsOfFile: orderFolder.path.appending("/order.json")).data(using: .utf8)
96-
let roundTrippedOrder = try decoder.decode(TestOrder.self, from: orderData!)
88+
let orderBytes = try reader.readFile(#require(directory.first { $0.filename == "order.json" }))
89+
let roundTrippedOrder = try decoder.decode(TestOrder.self, from: Data(orderBytes))
9790
#expect(roundTrippedOrder.authenticationToken == order.authenticationToken)
9891
#expect(roundTrippedOrder.orderIdentifier == order.orderIdentifier)
9992

100-
let manifestJSONData = try String(contentsOfFile: orderFolder.path.appending("/manifest.json")).data(using: .utf8)
101-
let manifestJSON = try decoder.decode([String: String].self, from: manifestJSONData!)
102-
let iconData = try Data(contentsOf: orderFolder.appendingPathComponent("/icon.png"))
103-
#expect(manifestJSON["icon.png"] == SHA256.hash(data: iconData).map { "0\(String($0, radix: 16))".suffix(2) }.joined())
93+
let manifestJSONBytes = try reader.readFile(#require(directory.first { $0.filename == "manifest.json" }))
94+
let manifestJSON = try decoder.decode([String: String].self, from: Data(manifestJSONBytes))
95+
let iconBytes = try reader.readFile(#require(directory.first { $0.filename == "icon.png" }))
96+
#expect(manifestJSON["icon.png"] == SHA256.hash(data: iconBytes).map { "0\(String($0, radix: 16))".suffix(2) }.joined())
10497
#expect(manifestJSON["pet_store_logo.png"] != nil)
10598
#expect(manifestJSON["it-IT.lproj/pet_store_logo.png"] != nil)
10699
}

0 commit comments

Comments
 (0)