@@ -16,6 +16,10 @@ public import Foundation
1616private 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
2024extension 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)
4540extension 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
0 commit comments