Skip to content

Commit 5b27501

Browse files
committed
Basics: support multiple formats with a single archiver
Added `MultiFormatArchiver` that can handle multiple formats by delegating to other archivers it was initialized with. Since most uses of `Archiver` are either generic or take an existential, this allows us to pass `MultiFormatArchiver` that previously supported only a single archive format. rdar://107485048
1 parent c6809a3 commit 5b27501

File tree

9 files changed

+344
-1
lines changed

9 files changed

+344
-1
lines changed

Sources/Basics/Archiver+Zip.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import TSCBasic
1414
import Dispatch
1515

1616
/// An `Archiver` that handles ZIP archives using the command-line `zip` and `unzip` tools.
17-
public struct ZipArchiver: Archiver, Cancellable {
17+
public final class ZipArchiver: Archiver, Cancellable {
1818
public var supportedExtensions: Set<String> { ["zip"] }
1919

2020
/// The file-system implementation used for various file-system operations and checks.
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift open source project
4+
//
5+
// Copyright (c) 2014-2023 Apple Inc. and the Swift project authors
6+
// Licensed under Apache License v2.0 with Runtime Library Exception
7+
//
8+
// See http://swift.org/LICENSE.txt for license information
9+
// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
10+
//
11+
//===----------------------------------------------------------------------===//
12+
13+
import struct TSCBasic.AbsolutePath
14+
15+
/// An `Archiver` that handles multiple formats by delegating to other archivers it was initialized with.
16+
public final class MultiFormatArchiver: Archiver {
17+
public var supportedExtensions: Set<String>
18+
19+
enum Error: Swift.Error {
20+
case unknownFormat(String, AbsolutePath)
21+
case noFileNameExtension(AbsolutePath)
22+
23+
var description: String {
24+
switch self {
25+
case .unknownFormat(let ext, let path):
26+
return "unknown format with extension \(ext) at path `\(path)`"
27+
case .noFileNameExtension(let path):
28+
return "file at path `\(path)` has no extension to detect archival format from"
29+
}
30+
}
31+
}
32+
33+
private let formatMapping: [String: any Archiver]
34+
35+
public init(_ archivers: [any Archiver]) {
36+
var formatMapping = [String: any Archiver]()
37+
var supportedExtensions = Set<String>()
38+
39+
for archiver in archivers {
40+
supportedExtensions.formUnion(archiver.supportedExtensions)
41+
for ext in archiver.supportedExtensions {
42+
formatMapping[ext] = archiver
43+
}
44+
}
45+
46+
self.formatMapping = formatMapping
47+
self.supportedExtensions = supportedExtensions
48+
}
49+
50+
private func archiver(for archivePath: AbsolutePath) throws -> any Archiver {
51+
let filename = archivePath.basename
52+
53+
// Calculating extension manually, since ``AbsolutePath//extension`` doesn't support multiple extensions,
54+
// like `.tar.gz`.
55+
guard let firstDot = filename.firstIndex(of: ".") else {
56+
throw Error.noFileNameExtension(archivePath)
57+
}
58+
59+
var extensions = String(filename[firstDot ..< filename.endIndex])
60+
61+
guard extensions.count > 1 else {
62+
throw Error.noFileNameExtension(archivePath)
63+
}
64+
65+
extensions.removeFirst()
66+
67+
guard let archiver = self.formatMapping[extensions] else {
68+
throw Error.unknownFormat(extensions, archivePath)
69+
}
70+
71+
return archiver
72+
}
73+
74+
public func extract(
75+
from archivePath: AbsolutePath,
76+
to destinationPath: AbsolutePath,
77+
completion: @escaping (Result<Void, Swift.Error>) -> Void
78+
) {
79+
do {
80+
let archiver = try archiver(for: archivePath)
81+
archiver.extract(from: archivePath, to: destinationPath, completion: completion)
82+
} catch {
83+
completion(.failure(error))
84+
}
85+
}
86+
87+
public func compress(
88+
directory: AbsolutePath,
89+
to destinationPath: AbsolutePath,
90+
completion: @escaping (Result<Void, Swift.Error>) -> Void
91+
) {
92+
do {
93+
let archiver = try archiver(for: destinationPath)
94+
archiver.compress(directory: directory, to: destinationPath, completion: completion)
95+
} catch {
96+
completion(.failure(error))
97+
}
98+
}
99+
100+
public func validate(
101+
path: AbsolutePath,
102+
completion: @escaping (Result<Bool, Swift.Error>) -> Void
103+
) {
104+
do {
105+
let archiver = try archiver(for: path)
106+
archiver.validate(path: path, completion: completion)
107+
} catch {
108+
completion(.failure(error))
109+
}
110+
}
111+
}
Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift open source project
4+
//
5+
// Copyright (c) 2014-2023 Apple Inc. and the Swift project authors
6+
// Licensed under Apache License v2.0 with Runtime Library Exception
7+
//
8+
// See http://swift.org/LICENSE.txt for license information
9+
// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
10+
//
11+
//===----------------------------------------------------------------------===//
12+
13+
import Basics
14+
import TSCBasic
15+
import TSCclibc // for SPM_posix_spawn_file_actions_addchdir_np_supported
16+
import TSCTestSupport
17+
import XCTest
18+
19+
final class MultiFormatArchiverTests: XCTestCase {
20+
func testSuccess() throws {
21+
try testWithTemporaryDirectory { tmpdir in
22+
let archiver = MultiFormatArchiver(
23+
[TarArchiver(fileSystem: localFileSystem), ZipArchiver(fileSystem: localFileSystem)]
24+
)
25+
let inputArchivePath = AbsolutePath(path: #file).parentDirectory
26+
.appending(components: "Inputs", "archive.tar.gz")
27+
let tarDestination = tmpdir.appending("tar")
28+
try localFileSystem.createDirectory(tarDestination)
29+
try archiver.extract(from: inputArchivePath, to: tarDestination)
30+
let tarContent = tarDestination.appending("file")
31+
XCTAssert(localFileSystem.exists(tarContent))
32+
XCTAssertEqual((try? localFileSystem.readFileContents(tarContent))?.cString, "Hello World!")
33+
34+
let zipDestination = tmpdir.appending("zip")
35+
try localFileSystem.createDirectory(zipDestination)
36+
try archiver.extract(from: inputArchivePath, to: zipDestination)
37+
let zipContent = zipDestination.appending("file")
38+
XCTAssert(localFileSystem.exists(zipContent))
39+
XCTAssertEqual((try? localFileSystem.readFileContents(zipContent))?.cString, "Hello World!")
40+
}
41+
}
42+
43+
func testArchiveDoesntExist() {
44+
let fileSystem = InMemoryFileSystem()
45+
let archiver = MultiFormatArchiver(
46+
[TarArchiver(fileSystem: fileSystem), ZipArchiver(fileSystem: fileSystem)]
47+
)
48+
let archive = AbsolutePath("/archive.tar.gz")
49+
XCTAssertThrowsError(try archiver.extract(from: archive, to: "/")) { error in
50+
XCTAssertEqual(error as? FileSystemError, FileSystemError(.noEntry, archive))
51+
}
52+
}
53+
54+
func testDestinationDoesntExist() throws {
55+
let fileSystem = InMemoryFileSystem(emptyFiles: "/archive.tar.gz")
56+
let archiver = MultiFormatArchiver(
57+
[TarArchiver(fileSystem: fileSystem), ZipArchiver(fileSystem: fileSystem)]
58+
)
59+
let destination = AbsolutePath("/destination")
60+
XCTAssertThrowsError(try archiver.extract(from: "/archive.tar.gz", to: destination)) { error in
61+
XCTAssertEqual(error as? FileSystemError, FileSystemError(.notDirectory, destination))
62+
}
63+
}
64+
65+
func testDestinationIsFile() throws {
66+
let fileSystem = InMemoryFileSystem(emptyFiles: "/archive.tar.gz", "/destination")
67+
let archiver = MultiFormatArchiver(
68+
[TarArchiver(fileSystem: fileSystem), ZipArchiver(fileSystem: fileSystem)]
69+
)
70+
let destination = AbsolutePath("/destination")
71+
XCTAssertThrowsError(try archiver.extract(from: "/archive.tar.gz", to: destination)) { error in
72+
XCTAssertEqual(error as? FileSystemError, FileSystemError(.notDirectory, destination))
73+
}
74+
}
75+
76+
func testInvalidArchive() throws {
77+
try testWithTemporaryDirectory { tmpdir in
78+
let archiver = MultiFormatArchiver(
79+
[TarArchiver(fileSystem: localFileSystem), ZipArchiver(fileSystem: localFileSystem)]
80+
)
81+
var inputArchivePath = AbsolutePath(path: #file).parentDirectory
82+
.appending(components: "Inputs", "invalid_archive.tar.gz")
83+
XCTAssertThrowsError(try archiver.extract(from: inputArchivePath, to: tmpdir)) { error in
84+
#if os(Linux)
85+
XCTAssertMatch((error as? StringError)?.description, .contains("not in gzip format"))
86+
#else
87+
XCTAssertMatch((error as? StringError)?.description, .contains("Unrecognized archive format"))
88+
#endif
89+
}
90+
91+
inputArchivePath = AbsolutePath(path: #file).parentDirectory
92+
.appending(components: "Inputs", "invalid_archive.zip")
93+
XCTAssertThrowsError(try archiver.extract(from: inputArchivePath, to: tmpdir)) { error in
94+
#if os(Windows)
95+
XCTAssertMatch((error as? StringError)?.description, .contains("Unrecognized archive format"))
96+
#else
97+
XCTAssertMatch((error as? StringError)?.description, .contains("End-of-central-directory signature not found"))
98+
#endif
99+
}
100+
}
101+
}
102+
103+
func testValidation() throws {
104+
// valid
105+
try testWithTemporaryDirectory { _ in
106+
let archiver = MultiFormatArchiver(
107+
[TarArchiver(fileSystem: localFileSystem), ZipArchiver(fileSystem: localFileSystem)]
108+
)
109+
let path = AbsolutePath(path: #file).parentDirectory
110+
.appending(components: "Inputs", "archive.tar.gz")
111+
XCTAssertTrue(try archiver.validate(path: path))
112+
}
113+
// invalid
114+
try testWithTemporaryDirectory { _ in
115+
let archiver = MultiFormatArchiver(
116+
[TarArchiver(fileSystem: localFileSystem), ZipArchiver(fileSystem: localFileSystem)]
117+
)
118+
let path = AbsolutePath(path: #file).parentDirectory
119+
.appending(components: "Inputs", "invalid_archive.tar.gz")
120+
XCTAssertFalse(try archiver.validate(path: path))
121+
}
122+
// error
123+
try testWithTemporaryDirectory { _ in
124+
let archiver = MultiFormatArchiver(
125+
[TarArchiver(fileSystem: localFileSystem), ZipArchiver(fileSystem: localFileSystem)]
126+
)
127+
let path = AbsolutePath.root.appending("does_not_exist.tar.gz")
128+
XCTAssertThrowsError(try archiver.validate(path: path)) { error in
129+
XCTAssertEqual(error as? FileSystemError, FileSystemError(.noEntry, path))
130+
}
131+
}
132+
}
133+
134+
func testCompress() throws {
135+
#if os(Linux)
136+
guard SPM_posix_spawn_file_actions_addchdir_np_supported() else {
137+
throw XCTSkip("working directory not supported on this platform")
138+
}
139+
#endif
140+
141+
try testWithTemporaryDirectory { tmpdir in
142+
let archiver = MultiFormatArchiver(
143+
[TarArchiver(fileSystem: localFileSystem), ZipArchiver(fileSystem: localFileSystem)]
144+
)
145+
146+
let rootDir = tmpdir.appending(component: UUID().uuidString)
147+
try localFileSystem.createDirectory(rootDir)
148+
try localFileSystem.writeFileContents(rootDir.appending("file1.txt"), string: "Hello World!")
149+
150+
let dir1 = rootDir.appending("dir1")
151+
try localFileSystem.createDirectory(dir1)
152+
try localFileSystem.writeFileContents(dir1.appending("file2.txt"), string: "Hello World 2!")
153+
154+
let dir2 = dir1.appending("dir2")
155+
try localFileSystem.createDirectory(dir2)
156+
try localFileSystem.writeFileContents(dir2.appending("file3.txt"), string: "Hello World 3!")
157+
try localFileSystem.writeFileContents(dir2.appending("file4.txt"), string: "Hello World 4!")
158+
159+
var archivePath = tmpdir.appending(component: UUID().uuidString + ".tar.gz")
160+
try archiver.compress(directory: rootDir, to: archivePath)
161+
XCTAssertFileExists(archivePath)
162+
163+
var extractRootDir = tmpdir.appending(component: UUID().uuidString)
164+
try localFileSystem.createDirectory(extractRootDir)
165+
try archiver.extract(from: archivePath, to: extractRootDir)
166+
try localFileSystem.stripFirstLevel(of: extractRootDir)
167+
168+
XCTAssertFileExists(extractRootDir.appending("file1.txt"))
169+
XCTAssertEqual(
170+
try? localFileSystem.readFileContents(extractRootDir.appending("file1.txt")),
171+
"Hello World!"
172+
)
173+
174+
var extractedDir1 = extractRootDir.appending("dir1")
175+
XCTAssertDirectoryExists(extractedDir1)
176+
XCTAssertFileExists(extractedDir1.appending("file2.txt"))
177+
XCTAssertEqual(
178+
try? localFileSystem.readFileContents(extractedDir1.appending("file2.txt")),
179+
"Hello World 2!"
180+
)
181+
182+
var extractedDir2 = extractedDir1.appending("dir2")
183+
XCTAssertDirectoryExists(extractedDir2)
184+
XCTAssertFileExists(extractedDir2.appending("file3.txt"))
185+
XCTAssertEqual(
186+
try? localFileSystem.readFileContents(extractedDir2.appending("file3.txt")),
187+
"Hello World 3!"
188+
)
189+
XCTAssertFileExists(extractedDir2.appending("file4.txt"))
190+
XCTAssertEqual(
191+
try? localFileSystem.readFileContents(extractedDir2.appending("file4.txt")),
192+
"Hello World 4!"
193+
)
194+
195+
archivePath = tmpdir.appending(component: UUID().uuidString + ".zip")
196+
try archiver.compress(directory: rootDir, to: archivePath)
197+
XCTAssertFileExists(archivePath)
198+
199+
extractRootDir = tmpdir.appending(component: UUID().uuidString)
200+
try localFileSystem.createDirectory(extractRootDir)
201+
try archiver.extract(from: archivePath, to: extractRootDir)
202+
try localFileSystem.stripFirstLevel(of: extractRootDir)
203+
204+
XCTAssertFileExists(extractRootDir.appending("file1.txt"))
205+
XCTAssertEqual(
206+
try? localFileSystem.readFileContents(extractRootDir.appending("file1.txt")),
207+
"Hello World!"
208+
)
209+
210+
extractedDir1 = extractRootDir.appending("dir1")
211+
XCTAssertDirectoryExists(extractedDir1)
212+
XCTAssertFileExists(extractedDir1.appending("file2.txt"))
213+
XCTAssertEqual(
214+
try? localFileSystem.readFileContents(extractedDir1.appending("file2.txt")),
215+
"Hello World 2!"
216+
)
217+
218+
extractedDir2 = extractedDir1.appending("dir2")
219+
XCTAssertDirectoryExists(extractedDir2)
220+
XCTAssertFileExists(extractedDir2.appending("file3.txt"))
221+
XCTAssertEqual(
222+
try? localFileSystem.readFileContents(extractedDir2.appending("file3.txt")),
223+
"Hello World 3!"
224+
)
225+
XCTAssertFileExists(extractedDir2.appending("file4.txt"))
226+
XCTAssertEqual(
227+
try? localFileSystem.readFileContents(extractedDir2.appending("file4.txt")),
228+
"Hello World 4!"
229+
)
230+
}
231+
}
232+
}

0 commit comments

Comments
 (0)