Skip to content

Commit a4ed760

Browse files
authored
Change archive format for directories to .zip and add iOS/etc. support. (#826)
This PR switches from .tar.gz as the preferred archive format for compressed directories to .zip and uses `NSFileCoordinator` on Darwin to enable support for iOS, watchOS, tvOS, and visionOS. This feature remains experimental. ### Checklist: - [x] Code and documentation should follow the style of the [Style Guide](https://github.com/apple/swift-testing/blob/main/Documentation/StyleGuide.md). - [x] If public symbols are renamed or modified, DocC references should be updated.
1 parent 8fb3f68 commit a4ed760

File tree

2 files changed

+164
-61
lines changed

2 files changed

+164
-61
lines changed

Sources/Overlays/_Testing_Foundation/Attachments/Attachment+URL.swift

+157-60
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ public import Foundation
1616
private import UniformTypeIdentifiers
1717
#endif
1818

19+
#if !SWT_NO_PROCESS_SPAWNING && os(Windows)
20+
private import WinSDK
21+
#endif
22+
1923
#if !SWT_NO_FILE_IO
2024
extension URL {
2125
/// The file system path of the URL, equivalent to `path`.
@@ -32,17 +36,13 @@ extension URL {
3236
}
3337
}
3438

35-
#if SWT_TARGET_OS_APPLE && canImport(UniformTypeIdentifiers)
36-
@available(_uttypesAPI, *)
37-
extension UTType {
38-
/// A type that represents a `.tgz` archive, or `nil` if the system does not
39-
/// recognize that content type.
40-
fileprivate static let tgz = UTType("org.gnu.gnu-zip-tar-archive")
41-
}
42-
#endif
43-
4439
@_spi(Experimental)
4540
extension Attachment where AttachableValue == Data {
41+
#if SWT_TARGET_OS_APPLE
42+
/// An operation queue to use for asynchronously reading data from disk.
43+
private static let _operationQueue = OperationQueue()
44+
#endif
45+
4646
/// Initialize an instance of this type with the contents of the given URL.
4747
///
4848
/// - Parameters:
@@ -65,8 +65,6 @@ extension Attachment where AttachableValue == Data {
6565
throw CocoaError(.featureUnsupported, userInfo: [NSLocalizedDescriptionKey: "Attaching downloaded files is not supported"])
6666
}
6767

68-
// FIXME: use NSFileCoordinator on Darwin?
69-
7068
let url = url.resolvingSymlinksInPath()
7169
let isDirectory = try url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory!
7270

@@ -83,79 +81,178 @@ extension Attachment where AttachableValue == Data {
8381
// Ensure the preferred name of the archive has an appropriate extension.
8482
preferredName = {
8583
#if SWT_TARGET_OS_APPLE && canImport(UniformTypeIdentifiers)
86-
if #available(_uttypesAPI, *), let tgz = UTType.tgz {
87-
return (preferredName as NSString).appendingPathExtension(for: tgz)
84+
if #available(_uttypesAPI, *) {
85+
return (preferredName as NSString).appendingPathExtension(for: .zip)
8886
}
8987
#endif
90-
return (preferredName as NSString).appendingPathExtension("tgz") ?? preferredName
88+
return (preferredName as NSString).appendingPathExtension("zip") ?? preferredName
9189
}()
90+
}
9291

93-
try await self.init(Data(compressedContentsOfDirectoryAt: url), named: preferredName, sourceLocation: sourceLocation)
92+
#if SWT_TARGET_OS_APPLE
93+
let data: Data = try await withCheckedThrowingContinuation { continuation in
94+
let fileCoordinator = NSFileCoordinator()
95+
let fileAccessIntent = NSFileAccessIntent.readingIntent(with: url, options: [.forUploading])
96+
97+
fileCoordinator.coordinate(with: [fileAccessIntent], queue: Self._operationQueue) { error in
98+
let result = Result {
99+
if let error {
100+
throw error
101+
}
102+
return try Data(contentsOf: fileAccessIntent.url, options: [.mappedIfSafe])
103+
}
104+
continuation.resume(with: result)
105+
}
106+
}
107+
#else
108+
let data = if isDirectory {
109+
try await _compressContentsOfDirectory(at: url)
94110
} else {
95111
// Load the file.
96-
try self.init(Data(contentsOf: url, options: [.mappedIfSafe]), named: preferredName, sourceLocation: sourceLocation)
112+
try Data(contentsOf: url, options: [.mappedIfSafe])
97113
}
114+
#endif
115+
116+
self.init(data, named: preferredName, sourceLocation: sourceLocation)
98117
}
99118
}
100119

101-
// MARK: - Attaching directories
120+
#if !SWT_NO_PROCESS_SPAWNING && os(Windows)
121+
/// The filename of the archiver tool.
122+
private let _archiverName = "tar.exe"
102123

103-
extension Data {
104-
/// Initialize an instance of this type by compressing the contents of a
105-
/// directory.
106-
///
107-
/// - Parameters:
108-
/// - directoryURL: A URL referring to the directory to attach.
109-
///
110-
/// - Throws: Any error encountered trying to compress the directory, or if
111-
/// directories cannot be compressed on this platform.
112-
///
113-
/// This initializer asynchronously compresses the contents of `directoryURL`
114-
/// into an archive (currently of `.tgz` format, although this is subject to
115-
/// change) and stores a mapped copy of that archive.
116-
init(compressedContentsOfDirectoryAt directoryURL: URL) async throws {
117-
let temporaryName = "\(UUID().uuidString).tgz"
118-
let temporaryURL = FileManager.default.temporaryDirectory.appendingPathComponent(temporaryName)
124+
/// The path to the archiver tool.
125+
///
126+
/// This path refers to a file (named `_archiverName`) within the `"System32"`
127+
/// folder of the current system, which is not always located in `"C:\Windows."`
128+
///
129+
/// If the path cannot be determined, the value of this property is `nil`.
130+
private let _archiverPath: String? = {
131+
let bufferCount = GetSystemDirectoryW(nil, 0)
132+
guard bufferCount > 0 else {
133+
return nil
134+
}
135+
136+
return withUnsafeTemporaryAllocation(of: wchar_t.self, capacity: Int(bufferCount)) { buffer -> String? in
137+
let bufferCount = GetSystemDirectoryW(buffer.baseAddress!, UINT(buffer.count))
138+
guard bufferCount > 0 && bufferCount < buffer.count else {
139+
return nil
140+
}
119141

142+
return _archiverName.withCString(encodedAs: UTF16.self) { archiverName -> String? in
143+
var result: UnsafeMutablePointer<wchar_t>?
144+
145+
let flags = ULONG(PATHCCH_ALLOW_LONG_PATHS.rawValue)
146+
guard S_OK == PathAllocCombine(buffer.baseAddress!, archiverName, flags, &result) else {
147+
return nil
148+
}
149+
defer {
150+
LocalFree(result)
151+
}
152+
153+
return result.flatMap { String.decodeCString($0, as: UTF16.self)?.result }
154+
}
155+
}
156+
}()
157+
#endif
158+
159+
/// Compress the contents of a directory to an archive, then map that archive
160+
/// back into memory.
161+
///
162+
/// - Parameters:
163+
/// - directoryURL: A URL referring to the directory to attach.
164+
///
165+
/// - Returns: An instance of `Data` containing the compressed contents of the
166+
/// given directory.
167+
///
168+
/// - Throws: Any error encountered trying to compress the directory, or if
169+
/// directories cannot be compressed on this platform.
170+
///
171+
/// This function asynchronously compresses the contents of `directoryURL` into
172+
/// an archive (currently of `.zip` format, although this is subject to change.)
173+
private func _compressContentsOfDirectory(at directoryURL: URL) async throws -> Data {
120174
#if !SWT_NO_PROCESS_SPAWNING
121-
#if os(Windows)
122-
let tarPath = #"C:\Windows\System32\tar.exe"#
175+
let temporaryName = "\(UUID().uuidString).zip"
176+
let temporaryURL = FileManager.default.temporaryDirectory.appendingPathComponent(temporaryName)
177+
defer {
178+
try? FileManager().removeItem(at: temporaryURL)
179+
}
180+
181+
// The standard version of tar(1) does not (appear to) support writing PKZIP
182+
// archives. FreeBSD's (AKA bsdtar) was long ago rebased atop libarchive and
183+
// knows how to write PKZIP archives, while Windows inherited FreeBSD's tar
184+
// tool in Windows 10 Build 17063 (per https://techcommunity.microsoft.com/blog/containers/tar-and-curl-come-to-windows/382409).
185+
//
186+
// On Linux (which does not have FreeBSD's version of tar(1)), we can use
187+
// zip(1) instead.
188+
#if os(Linux)
189+
let archiverPath = "/usr/bin/zip"
190+
#elseif SWT_TARGET_OS_APPLE || os(FreeBSD)
191+
let archiverPath = "/usr/bin/tar"
192+
#elseif os(Windows)
193+
guard let archiverPath = _archiverPath else {
194+
throw CocoaError(.fileWriteUnknown, userInfo: [
195+
NSLocalizedDescriptionKey: "Could not determine the path to '\(_archiverName)'.",
196+
])
197+
}
123198
#else
124-
let tarPath = "/usr/bin/tar"
199+
#warning("Platform-specific implementation missing: tar or zip tool unavailable")
200+
let archiverPath = ""
201+
throw CocoaError(.featureUnsupported, userInfo: [NSLocalizedDescriptionKey: "This platform does not support attaching directories to tests."])
125202
#endif
203+
204+
try await withCheckedThrowingContinuation { continuation in
205+
let process = Process()
206+
207+
process.executableURL = URL(fileURLWithPath: archiverPath, isDirectory: false)
208+
126209
let sourcePath = directoryURL.fileSystemPath
127210
let destinationPath = temporaryURL.fileSystemPath
128-
defer {
129-
try? FileManager().removeItem(at: temporaryURL)
130-
}
211+
#if os(Linux)
212+
// The zip command constructs relative paths from the current working
213+
// directory rather than from command-line arguments.
214+
process.arguments = [destinationPath, "--recurse-paths", "."]
215+
process.currentDirectoryURL = directoryURL
216+
#elseif SWT_TARGET_OS_APPLE || os(FreeBSD)
217+
process.arguments = ["--create", "--auto-compress", "--directory", sourcePath, "--file", destinationPath, "."]
218+
#elseif os(Windows)
219+
// The Windows version of bsdtar can handle relative paths for other archive
220+
// formats, but produces empty archives when inferring the zip format with
221+
// --auto-compress, so archive with absolute paths here.
222+
//
223+
// An alternative may be to use PowerShell's Compress-Archive command,
224+
// however that comes with a security risk as we'd be responsible for two
225+
// levels of command-line argument escaping.
226+
process.arguments = ["--create", "--auto-compress", "--file", destinationPath, sourcePath]
227+
#endif
131228

132-
try await withCheckedThrowingContinuation { continuation in
133-
do {
134-
_ = try Process.run(
135-
URL(fileURLWithPath: tarPath, isDirectory: false),
136-
arguments: ["--create", "--gzip", "--directory", sourcePath, "--file", destinationPath, "."]
137-
) { process in
138-
let terminationReason = process.terminationReason
139-
let terminationStatus = process.terminationStatus
140-
if terminationReason == .exit && terminationStatus == EXIT_SUCCESS {
141-
continuation.resume()
142-
} else {
143-
let error = CocoaError(.fileWriteUnknown, userInfo: [
144-
NSLocalizedDescriptionKey: "The directory at '\(sourcePath)' could not be compressed.",
145-
])
146-
continuation.resume(throwing: error)
147-
}
148-
}
149-
} catch {
229+
process.standardOutput = nil
230+
process.standardError = nil
231+
232+
process.terminationHandler = { process in
233+
let terminationReason = process.terminationReason
234+
let terminationStatus = process.terminationStatus
235+
if terminationReason == .exit && terminationStatus == EXIT_SUCCESS {
236+
continuation.resume()
237+
} else {
238+
let error = CocoaError(.fileWriteUnknown, userInfo: [
239+
NSLocalizedDescriptionKey: "The directory at '\(sourcePath)' could not be compressed (\(terminationStatus)).",
240+
])
150241
continuation.resume(throwing: error)
151242
}
152243
}
153244

154-
try self.init(contentsOf: temporaryURL, options: [.mappedIfSafe])
245+
do {
246+
try process.run()
247+
} catch {
248+
continuation.resume(throwing: error)
249+
}
250+
}
251+
252+
return try Data(contentsOf: temporaryURL, options: [.mappedIfSafe])
155253
#else
156-
throw CocoaError(.featureUnsupported, userInfo: [NSLocalizedDescriptionKey: "This platform does not support attaching directories to tests."])
254+
throw CocoaError(.featureUnsupported, userInfo: [NSLocalizedDescriptionKey: "This platform does not support attaching directories to tests."])
157255
#endif
158-
}
159256
}
160257
#endif
161258
#endif

Tests/TestingTests/AttachmentTests.swift

+7-1
Original file line numberDiff line numberDiff line change
@@ -275,7 +275,13 @@ struct AttachmentTests {
275275
return
276276
}
277277

278-
#expect(attachment.preferredName == "\(temporaryDirectoryName).tgz")
278+
#expect(attachment.preferredName == "\(temporaryDirectoryName).zip")
279+
try! attachment.withUnsafeBufferPointer { buffer in
280+
#expect(buffer.count > 32)
281+
#expect(buffer[0] == UInt8(ascii: "P"))
282+
#expect(buffer[1] == UInt8(ascii: "K"))
283+
#expect(buffer.contains("loremipsum.txt".utf8))
284+
}
279285
valueAttached()
280286
}
281287

0 commit comments

Comments
 (0)