Skip to content

Change archive format for directories to .zip and add iOS/etc. support. #826

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Dec 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
217 changes: 157 additions & 60 deletions Sources/Overlays/_Testing_Foundation/Attachments/Attachment+URL.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ public import Foundation
private import UniformTypeIdentifiers
#endif

#if !SWT_NO_PROCESS_SPAWNING && os(Windows)
private import WinSDK
#endif

#if !SWT_NO_FILE_IO
extension URL {
/// The file system path of the URL, equivalent to `path`.
Expand All @@ -32,17 +36,13 @@ extension URL {
}
}

#if SWT_TARGET_OS_APPLE && canImport(UniformTypeIdentifiers)
@available(_uttypesAPI, *)
extension UTType {
/// A type that represents a `.tgz` archive, or `nil` if the system does not
/// recognize that content type.
fileprivate static let tgz = UTType("org.gnu.gnu-zip-tar-archive")
}
#endif

@_spi(Experimental)
extension Attachment where AttachableValue == Data {
#if SWT_TARGET_OS_APPLE
/// An operation queue to use for asynchronously reading data from disk.
private static let _operationQueue = OperationQueue()
#endif

/// Initialize an instance of this type with the contents of the given URL.
///
/// - Parameters:
Expand All @@ -65,8 +65,6 @@ extension Attachment where AttachableValue == Data {
throw CocoaError(.featureUnsupported, userInfo: [NSLocalizedDescriptionKey: "Attaching downloaded files is not supported"])
}

// FIXME: use NSFileCoordinator on Darwin?

let url = url.resolvingSymlinksInPath()
let isDirectory = try url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory!

Expand All @@ -83,79 +81,178 @@ extension Attachment where AttachableValue == Data {
// Ensure the preferred name of the archive has an appropriate extension.
preferredName = {
#if SWT_TARGET_OS_APPLE && canImport(UniformTypeIdentifiers)
if #available(_uttypesAPI, *), let tgz = UTType.tgz {
return (preferredName as NSString).appendingPathExtension(for: tgz)
if #available(_uttypesAPI, *) {
return (preferredName as NSString).appendingPathExtension(for: .zip)
}
#endif
return (preferredName as NSString).appendingPathExtension("tgz") ?? preferredName
return (preferredName as NSString).appendingPathExtension("zip") ?? preferredName
}()
}

try await self.init(Data(compressedContentsOfDirectoryAt: url), named: preferredName, sourceLocation: sourceLocation)
#if SWT_TARGET_OS_APPLE
let data: Data = try await withCheckedThrowingContinuation { continuation in
let fileCoordinator = NSFileCoordinator()
let fileAccessIntent = NSFileAccessIntent.readingIntent(with: url, options: [.forUploading])

fileCoordinator.coordinate(with: [fileAccessIntent], queue: Self._operationQueue) { error in
let result = Result {
if let error {
throw error
}
return try Data(contentsOf: fileAccessIntent.url, options: [.mappedIfSafe])
}
continuation.resume(with: result)
}
}
#else
let data = if isDirectory {
try await _compressContentsOfDirectory(at: url)
} else {
// Load the file.
try self.init(Data(contentsOf: url, options: [.mappedIfSafe]), named: preferredName, sourceLocation: sourceLocation)
try Data(contentsOf: url, options: [.mappedIfSafe])
}
#endif

self.init(data, named: preferredName, sourceLocation: sourceLocation)
}
}

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

extension Data {
/// Initialize an instance of this type by compressing the contents of a
/// directory.
///
/// - Parameters:
/// - directoryURL: A URL referring to the directory to attach.
///
/// - Throws: Any error encountered trying to compress the directory, or if
/// directories cannot be compressed on this platform.
///
/// This initializer asynchronously compresses the contents of `directoryURL`
/// into an archive (currently of `.tgz` format, although this is subject to
/// change) and stores a mapped copy of that archive.
init(compressedContentsOfDirectoryAt directoryURL: URL) async throws {
let temporaryName = "\(UUID().uuidString).tgz"
let temporaryURL = FileManager.default.temporaryDirectory.appendingPathComponent(temporaryName)
/// The path to the archiver tool.
///
/// This path refers to a file (named `_archiverName`) within the `"System32"`
/// folder of the current system, which is not always located in `"C:\Windows."`
///
/// If the path cannot be determined, the value of this property is `nil`.
private let _archiverPath: String? = {
let bufferCount = GetSystemDirectoryW(nil, 0)
guard bufferCount > 0 else {
return nil
}

return withUnsafeTemporaryAllocation(of: wchar_t.self, capacity: Int(bufferCount)) { buffer -> String? in
let bufferCount = GetSystemDirectoryW(buffer.baseAddress!, UINT(buffer.count))
guard bufferCount > 0 && bufferCount < buffer.count else {
return nil
}

return _archiverName.withCString(encodedAs: UTF16.self) { archiverName -> String? in
var result: UnsafeMutablePointer<wchar_t>?

let flags = ULONG(PATHCCH_ALLOW_LONG_PATHS.rawValue)
guard S_OK == PathAllocCombine(buffer.baseAddress!, archiverName, flags, &result) else {
return nil
}
defer {
LocalFree(result)
}

return result.flatMap { String.decodeCString($0, as: UTF16.self)?.result }
}
}
}()
#endif

/// Compress the contents of a directory to an archive, then map that archive
/// back into memory.
///
/// - Parameters:
/// - directoryURL: A URL referring to the directory to attach.
///
/// - Returns: An instance of `Data` containing the compressed contents of the
/// given directory.
///
/// - Throws: Any error encountered trying to compress the directory, or if
/// directories cannot be compressed on this platform.
///
/// This function asynchronously compresses the contents of `directoryURL` into
/// an archive (currently of `.zip` format, although this is subject to change.)
private func _compressContentsOfDirectory(at directoryURL: URL) async throws -> Data {
#if !SWT_NO_PROCESS_SPAWNING
#if os(Windows)
let tarPath = #"C:\Windows\System32\tar.exe"#
let temporaryName = "\(UUID().uuidString).zip"
let temporaryURL = FileManager.default.temporaryDirectory.appendingPathComponent(temporaryName)
defer {
try? FileManager().removeItem(at: temporaryURL)
}

// The standard version of tar(1) does not (appear to) support writing PKZIP
// archives. FreeBSD's (AKA bsdtar) was long ago rebased atop libarchive and
// knows how to write PKZIP archives, while Windows inherited FreeBSD's tar
// tool in Windows 10 Build 17063 (per https://techcommunity.microsoft.com/blog/containers/tar-and-curl-come-to-windows/382409).
//
// On Linux (which does not have FreeBSD's version of tar(1)), we can use
// zip(1) instead.
#if os(Linux)
let archiverPath = "/usr/bin/zip"
#elseif SWT_TARGET_OS_APPLE || os(FreeBSD)
let archiverPath = "/usr/bin/tar"
#elseif os(Windows)
guard let archiverPath = _archiverPath else {
throw CocoaError(.fileWriteUnknown, userInfo: [
NSLocalizedDescriptionKey: "Could not determine the path to '\(_archiverName)'.",
])
}
#else
let tarPath = "/usr/bin/tar"
#warning("Platform-specific implementation missing: tar or zip tool unavailable")
let archiverPath = ""
throw CocoaError(.featureUnsupported, userInfo: [NSLocalizedDescriptionKey: "This platform does not support attaching directories to tests."])
#endif

try await withCheckedThrowingContinuation { continuation in
let process = Process()

process.executableURL = URL(fileURLWithPath: archiverPath, isDirectory: false)

let sourcePath = directoryURL.fileSystemPath
let destinationPath = temporaryURL.fileSystemPath
defer {
try? FileManager().removeItem(at: temporaryURL)
}
#if os(Linux)
// The zip command constructs relative paths from the current working
// directory rather than from command-line arguments.
process.arguments = [destinationPath, "--recurse-paths", "."]
process.currentDirectoryURL = directoryURL
#elseif SWT_TARGET_OS_APPLE || os(FreeBSD)
process.arguments = ["--create", "--auto-compress", "--directory", sourcePath, "--file", destinationPath, "."]
#elseif os(Windows)
// The Windows version of bsdtar can handle relative paths for other archive
// formats, but produces empty archives when inferring the zip format with
// --auto-compress, so archive with absolute paths here.
//
// An alternative may be to use PowerShell's Compress-Archive command,
// however that comes with a security risk as we'd be responsible for two
// levels of command-line argument escaping.
process.arguments = ["--create", "--auto-compress", "--file", destinationPath, sourcePath]
#endif

try await withCheckedThrowingContinuation { continuation in
do {
_ = try Process.run(
URL(fileURLWithPath: tarPath, isDirectory: false),
arguments: ["--create", "--gzip", "--directory", sourcePath, "--file", destinationPath, "."]
) { process in
let terminationReason = process.terminationReason
let terminationStatus = process.terminationStatus
if terminationReason == .exit && terminationStatus == EXIT_SUCCESS {
continuation.resume()
} else {
let error = CocoaError(.fileWriteUnknown, userInfo: [
NSLocalizedDescriptionKey: "The directory at '\(sourcePath)' could not be compressed.",
])
continuation.resume(throwing: error)
}
}
} catch {
process.standardOutput = nil
process.standardError = nil

process.terminationHandler = { process in
let terminationReason = process.terminationReason
let terminationStatus = process.terminationStatus
if terminationReason == .exit && terminationStatus == EXIT_SUCCESS {
continuation.resume()
} else {
let error = CocoaError(.fileWriteUnknown, userInfo: [
NSLocalizedDescriptionKey: "The directory at '\(sourcePath)' could not be compressed (\(terminationStatus)).",
])
continuation.resume(throwing: error)
}
}

try self.init(contentsOf: temporaryURL, options: [.mappedIfSafe])
do {
try process.run()
} catch {
continuation.resume(throwing: error)
}
}

return try Data(contentsOf: temporaryURL, options: [.mappedIfSafe])
#else
throw CocoaError(.featureUnsupported, userInfo: [NSLocalizedDescriptionKey: "This platform does not support attaching directories to tests."])
throw CocoaError(.featureUnsupported, userInfo: [NSLocalizedDescriptionKey: "This platform does not support attaching directories to tests."])
#endif
}
}
#endif
#endif
8 changes: 7 additions & 1 deletion Tests/TestingTests/AttachmentTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,13 @@ struct AttachmentTests {
return
}

#expect(attachment.preferredName == "\(temporaryDirectoryName).tgz")
#expect(attachment.preferredName == "\(temporaryDirectoryName).zip")
try! attachment.withUnsafeBufferPointer { buffer in
#expect(buffer.count > 32)
#expect(buffer[0] == UInt8(ascii: "P"))
#expect(buffer[1] == UInt8(ascii: "K"))
#expect(buffer.contains("loremipsum.txt".utf8))
}
valueAttached()
}

Expand Down