Reformat code idents and introduce the SwiftFormat linter (#339)

* Package.swift: add SwiftFormat

Can be invoked with "swift package plugin swiftformat".

* $ swift package plugin swiftformat

* .cirrus.yml: run SwiftFormat

* SwiftFormat: exclude Sources/tart/OCI/Reference/Generated
This commit is contained in:
Nikolay Edigaryev 2022-11-29 19:56:13 +04:00 committed by GitHub
parent c27d4a089c
commit ad9c3c661e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
39 changed files with 685 additions and 657 deletions

View File

@ -22,6 +22,18 @@ task:
path: "integration-tests/pytest-junit.xml" path: "integration-tests/pytest-junit.xml"
format: junit format: junit
task:
name: Lint
alias: lint
macos_instance:
image: ghcr.io/cirruslabs/macos-ventura-xcode:latest
lint_script:
- swift package plugin --allow-writing-to-package-directory swiftformat --cache ignore --lint --report swiftformat.json .
always:
swiftformat_report_artifacts:
path: swiftformat.json
format: swiftformat
task: task:
name: Build name: Build
alias: build alias: build
@ -37,6 +49,7 @@ task:
name: Release name: Release
only_if: $CIRRUS_TAG != '' only_if: $CIRRUS_TAG != ''
depends_on: depends_on:
- lint
- test - test
- build - build
macos_instance: macos_instance:

5
.swiftformat Normal file
View File

@ -0,0 +1,5 @@
--disable all
--enable indent
--indent 2
--exclude Sources/tart/OCI/Reference/Generated
--swiftversion 5.7

View File

@ -98,6 +98,15 @@
"revision" : "6190d0cefff3013e77ed567e6b074f324e5c5bf5", "revision" : "6190d0cefff3013e77ed567e6b074f324e5c5bf5",
"version" : "6.3.1" "version" : "6.3.1"
} }
},
{
"identity" : "swiftformat",
"kind" : "remoteSourceControl",
"location" : "https://github.com/nicklockwood/SwiftFormat",
"state" : {
"revision" : "7c7dd06554a5fc3f452f1d16a780a81a3be04bce",
"version" : "0.50.4"
}
} }
], ],
"version" : 2 "version" : 2

View File

@ -18,6 +18,7 @@ let package = Package(
.package(url: "https://github.com/sushichop/Puppy", from: "0.5.1"), .package(url: "https://github.com/sushichop/Puppy", from: "0.5.1"),
.package(url: "https://github.com/antlr/antlr4", branch: "dev"), .package(url: "https://github.com/antlr/antlr4", branch: "dev"),
.package(url: "https://github.com/apple/swift-atomics.git", .upToNextMajor(from: "1.0.0")), .package(url: "https://github.com/apple/swift-atomics.git", .upToNextMajor(from: "1.0.0")),
.package(url: "https://github.com/nicklockwood/SwiftFormat", from: "0.50.4"),
], ],
targets: [ targets: [
.executableTarget(name: "tart", dependencies: [ .executableTarget(name: "tart", dependencies: [

View File

@ -45,7 +45,7 @@ struct Login: AsyncParsableCommand {
do { do {
let registry = try Registry(host: host, namespace: "", insecure: insecure, let registry = try Registry(host: host, namespace: "", insecure: insecure,
credentialsProviders: [credentialsProvider]) credentialsProviders: [credentialsProvider])
try await registry.ping() try await registry.ping()
} catch { } catch {
print("invalid credentials: \(error)") print("invalid credentials: \(error)")

View File

@ -7,13 +7,13 @@ struct Prune: AsyncParsableCommand {
static var configuration = CommandConfiguration(abstract: "Prune OCI and IPSW caches") static var configuration = CommandConfiguration(abstract: "Prune OCI and IPSW caches")
@Option(help: ArgumentHelp("Remove cache entries last accessed more than n days ago", @Option(help: ArgumentHelp("Remove cache entries last accessed more than n days ago",
discussion: "For example, --older-than=7 will remove entries that weren't accessed by Tart in the last 7 days.", discussion: "For example, --older-than=7 will remove entries that weren't accessed by Tart in the last 7 days.",
valueName: "n")) valueName: "n"))
var olderThan: UInt? var olderThan: UInt?
@Option(help: ArgumentHelp("Remove least recently used cache entries that do not fit the specified cache size budget n, expressed in gigabytes", @Option(help: ArgumentHelp("Remove least recently used cache entries that do not fit the specified cache size budget n, expressed in gigabytes",
discussion: "For example, --cache-budget=50 will effectively shrink all caches to a total size of 50 gigabytes.", discussion: "For example, --cache-budget=50 will effectively shrink all caches to a total size of 50 gigabytes.",
valueName: "n")) valueName: "n"))
var cacheBudget: UInt? var cacheBudget: UInt?
@Flag(help: .hidden) @Flag(help: .hidden)
@ -62,8 +62,8 @@ struct Prune: AsyncParsableCommand {
static func pruneCacheBudget(cacheBudgetBytes: UInt64) throws { static func pruneCacheBudget(cacheBudgetBytes: UInt64) throws {
let prunableStorages: [PrunableStorage] = [VMStorageOCI(), try IPSWCache()] let prunableStorages: [PrunableStorage] = [VMStorageOCI(), try IPSWCache()]
let prunables: [Prunable] = try prunableStorages let prunables: [Prunable] = try prunableStorages
.flatMap { try $0.prunables() } .flatMap { try $0.prunables() }
.sorted { try $0.accessDate() > $1.accessDate() } .sorted { try $0.accessDate() > $1.accessDate() }
var cacheBudgetBytes = cacheBudgetBytes var cacheBudgetBytes = cacheBudgetBytes
var prunablesToDelete: [Prunable] = [] var prunablesToDelete: [Prunable] = []
@ -87,8 +87,8 @@ struct Prune: AsyncParsableCommand {
static func pruneReclaim(reclaimBytes: UInt64) throws { static func pruneReclaim(reclaimBytes: UInt64) throws {
let prunableStorages: [PrunableStorage] = [VMStorageOCI(), try IPSWCache()] let prunableStorages: [PrunableStorage] = [VMStorageOCI(), try IPSWCache()]
let prunables: [Prunable] = try prunableStorages let prunables: [Prunable] = try prunableStorages
.flatMap { try $0.prunables() } .flatMap { try $0.prunables() }
.sorted { try $0.accessDate() < $1.accessDate() } .sorted { try $0.accessDate() < $1.accessDate() }
// Does it even make sense to start? // Does it even make sense to start?
let cacheUsedBytes = try prunables.map { try $0.sizeBytes() }.reduce(0, +) let cacheUsedBytes = try prunables.map { try $0.sizeBytes() }.reduce(0, +)

View File

@ -16,15 +16,15 @@ struct Push: AsyncParsableCommand {
var insecure: Bool = false var insecure: Bool = false
@Option(help: ArgumentHelp("chunk size in MB if registry supports chunked uploads", @Option(help: ArgumentHelp("chunk size in MB if registry supports chunked uploads",
discussion: """ discussion: """
By default monolithic method is used for uploading blobs to the registry but some registries support a more efficient chunked method. By default monolithic method is used for uploading blobs to the registry but some registries support a more efficient chunked method.
For example, AWS Elastic Container Registry supports only chunks larger than 5MB but GitHub Container Registry supports only chunks smaller than 4MB. Google Container Registry on the other hand doesn't support chunked uploads at all. For example, AWS Elastic Container Registry supports only chunks larger than 5MB but GitHub Container Registry supports only chunks smaller than 4MB. Google Container Registry on the other hand doesn't support chunked uploads at all.
Please refer to the documentation of your particular registry in order to see if this option is suitable for you and what's the recommended chunk size. Please refer to the documentation of your particular registry in order to see if this option is suitable for you and what's the recommended chunk size.
""")) """))
var chunkSize: Int = 0 var chunkSize: Int = 0
@Flag(help: ArgumentHelp("cache pushed images locally", @Flag(help: ArgumentHelp("cache pushed images locally",
discussion: "Increases disk usage, but saves time if you're going to pull the pushed images later.")) discussion: "Increases disk usage, but saves time if you're going to pull the pushed images later."))
var populateCache: Bool = false var populateCache: Bool = false
func run() async throws { func run() async throws {
@ -49,7 +49,7 @@ struct Push: AsyncParsableCommand {
// Push VM // Push VM
for (registryIdentifier, remoteNamesForRegistry) in registryGroups { for (registryIdentifier, remoteNamesForRegistry) in registryGroups {
let registry = try Registry(host: registryIdentifier.host, namespace: registryIdentifier.namespace, let registry = try Registry(host: registryIdentifier.host, namespace: registryIdentifier.namespace,
insecure: insecure) insecure: insecure)
defaultLogger.appendNewLine("pushing \(localName) to " defaultLogger.appendNewLine("pushing \(localName) to "
+ "\(registryIdentifier.host)/\(registryIdentifier.namespace)\(remoteNamesForRegistry.referenceNames())...") + "\(registryIdentifier.host)/\(registryIdentifier.namespace)\(remoteNamesForRegistry.referenceNames())...")

View File

@ -2,39 +2,39 @@ import ArgumentParser
import Foundation import Foundation
struct Rename: AsyncParsableCommand { struct Rename: AsyncParsableCommand {
static var configuration = CommandConfiguration(abstract: "Rename a VM") static var configuration = CommandConfiguration(abstract: "Rename a VM")
@Argument(help: "VM name") @Argument(help: "VM name")
var name: String var name: String
@Argument(help: "new VM name") @Argument(help: "new VM name")
var newName: String var newName: String
func validate() throws { func validate() throws {
if newName.contains("/") { if newName.contains("/") {
throw ValidationError("<new-name> should be a local name") throw ValidationError("<new-name> should be a local name")
}
} }
}
func run() async throws { func run() async throws {
do { do {
let localStorage = VMStorageLocal() let localStorage = VMStorageLocal()
if !localStorage.exists(name) { if !localStorage.exists(name) {
throw ValidationError("failed to rename a non-existent VM: \(name)") throw ValidationError("failed to rename a non-existent VM: \(name)")
} }
if localStorage.exists(newName) { if localStorage.exists(newName) {
throw ValidationError("failed to rename VM \(name), target VM \(name) already exists, delete it first!") throw ValidationError("failed to rename VM \(name), target VM \(name) already exists, delete it first!")
} }
try localStorage.rename(name, newName) try localStorage.rename(name, newName)
Foundation.exit(0) Foundation.exit(0)
} catch { } catch {
print(error) print(error)
Foundation.exit(1) Foundation.exit(1)
}
} }
}
} }

View File

@ -15,8 +15,8 @@ struct Run: AsyncParsableCommand {
var name: String var name: String
@Flag(help: ArgumentHelp( @Flag(help: ArgumentHelp(
"Don't open a UI window.", "Don't open a UI window.",
discussion: "Useful for integrating Tart VMs into other tools.\nUse `tart ip` in order to get an IP for SSHing or VNCing into the VM.")) discussion: "Useful for integrating Tart VMs into other tools.\nUse `tart ip` in order to get an IP for SSHing or VNCing into the VM."))
var noGraphics: Bool = false var noGraphics: Bool = false
@Flag(help: "Force open a UI window, even when VNC is enabled.") @Flag(help: "Force open a UI window, even when VNC is enabled.")
@ -24,11 +24,11 @@ struct Run: AsyncParsableCommand {
@Flag(help: "Boot into recovery mode") @Flag(help: "Boot into recovery mode")
var recovery: Bool = false var recovery: Bool = false
@Flag(help: ArgumentHelp( @Flag(help: ArgumentHelp(
"Use screen sharing instead of the built-in UI.", "Use screen sharing instead of the built-in UI.",
discussion: "Useful since Screen Sharing supports copy/paste, drag and drop, etc.\n" discussion: "Useful since Screen Sharing supports copy/paste, drag and drop, etc.\n"
+ "Note that Remote Login option should be enabled inside the VM.")) + "Note that Remote Login option should be enabled inside the VM."))
var vnc: Bool = false var vnc: Bool = false
@Flag(help: ArgumentHelp( @Flag(help: ArgumentHelp(
@ -41,46 +41,46 @@ struct Run: AsyncParsableCommand {
var withSoftnet: Bool = false var withSoftnet: Bool = false
@Option(help: ArgumentHelp(""" @Option(help: ArgumentHelp("""
Additional disk attachments with an optional read-only specifier\n(e.g. --disk=\"disk.bin\" --disk=\"ubuntu.iso:ro\") Additional disk attachments with an optional read-only specifier\n(e.g. --disk=\"disk.bin\" --disk=\"ubuntu.iso:ro\")
""", discussion: """ """, discussion: """
Learn how to create a disk image using Disk Utility here: Learn how to create a disk image using Disk Utility here:
https://support.apple.com/en-gb/guide/disk-utility/dskutl11888/mac https://support.apple.com/en-gb/guide/disk-utility/dskutl11888/mac
""", valueName: "path[:ro]")) """, valueName: "path[:ro]"))
var disk: [String] = [] var disk: [String] = []
@Option(name: [.customLong("rosetta")], help: ArgumentHelp( @Option(name: [.customLong("rosetta")], help: ArgumentHelp(
"Attaches a Rosetta share to the guest Linux VM with a specific tag (e.g. --rosetta=\"rosetta\")", "Attaches a Rosetta share to the guest Linux VM with a specific tag (e.g. --rosetta=\"rosetta\")",
discussion: """ discussion: """
Requires host to be macOS 13.0 (Ventura) with Rosetta installed. The latter can be done Requires host to be macOS 13.0 (Ventura) with Rosetta installed. The latter can be done
by running "softwareupdate --install-rosetta" (without quotes) in the Terminal.app. by running "softwareupdate --install-rosetta" (without quotes) in the Terminal.app.
Note that you also have to configure Rosetta in the guest Linux VM by following the Note that you also have to configure Rosetta in the guest Linux VM by following the
steps from "Mount the Shared Directory and Register Rosetta" section here: steps from "Mount the Shared Directory and Register Rosetta" section here:
https://developer.apple.com/documentation/virtualization/running_intel_binaries_in_linux_vms_with_rosetta#3978496 https://developer.apple.com/documentation/virtualization/running_intel_binaries_in_linux_vms_with_rosetta#3978496
""", """,
valueName: "tag" valueName: "tag"
)) ))
var rosettaTag: String? var rosettaTag: String?
@Option(help: ArgumentHelp(""" @Option(help: ArgumentHelp("""
Additional directory shares with an optional read-only specifier\n(e.g. --dir=\"build:~/src/build\" --dir=\"sources:~/src/sources:ro\") Additional directory shares with an optional read-only specifier\n(e.g. --dir=\"build:~/src/build\" --dir=\"sources:~/src/sources:ro\")
""", discussion: """ """, discussion: """
Requires host to be macOS 13.0 (Ventura) or newer. Requires host to be macOS 13.0 (Ventura) or newer.
All shared directories are automatically mounted to "/Volumes/My Shared Files" directory on macOS, All shared directories are automatically mounted to "/Volumes/My Shared Files" directory on macOS,
while on Linux you have to do it manually: "mount -t virtiofs com.apple.virtio-fs.automount /mount/point". while on Linux you have to do it manually: "mount -t virtiofs com.apple.virtio-fs.automount /mount/point".
For macOS guests, they must be running macOS 13.0 (Ventura) or newer. For macOS guests, they must be running macOS 13.0 (Ventura) or newer.
""", valueName: "name:path[:ro]")) """, valueName: "name:path[:ro]"))
var dir: [String] = [] var dir: [String] = []
@Option(help: ArgumentHelp(""" @Option(help: ArgumentHelp("""
Use bridged networking instead of the default shared (NAT) networking \n(e.g. --net-bridged=en0 or --net-bridged=\"Wi-Fi\") Use bridged networking instead of the default shared (NAT) networking \n(e.g. --net-bridged=en0 or --net-bridged=\"Wi-Fi\")
""", discussion: """ """, discussion: """
Specify "list" as an interface name (--net-bridged=list) to list the available bridged interfaces. Specify "list" as an interface name (--net-bridged=list) to list the available bridged interfaces.
""", valueName: "interface name")) """, valueName: "interface name"))
var netBridged: String? var netBridged: String?
@Flag(help: ArgumentHelp("Use software networking instead of the default shared (NAT) networking", @Flag(help: ArgumentHelp("Use software networking instead of the default shared (NAT) networking",
discussion: "Learn how to configure Softnet for use with Tart here: https://github.com/cirruslabs/softnet")) discussion: "Learn how to configure Softnet for use with Tart here: https://github.com/cirruslabs/softnet"))
var netSoftnet: Bool = false var netSoftnet: Bool = false
func validate() throws { func validate() throws {
@ -357,16 +357,16 @@ struct Run: AsyncParsableCommand {
} }
}.frame(width: CGFloat(vm!.config.display.width), height: CGFloat(vm!.config.display.height)) }.frame(width: CGFloat(vm!.config.display.width), height: CGFloat(vm!.config.display.height))
}.commands { }.commands {
// Remove some standard menu options // Remove some standard menu options
CommandGroup(replacing: .help, addition: {}) CommandGroup(replacing: .help, addition: {})
CommandGroup(replacing: .newItem, addition: {}) CommandGroup(replacing: .newItem, addition: {})
CommandGroup(replacing: .pasteboard, addition: {}) CommandGroup(replacing: .pasteboard, addition: {})
CommandGroup(replacing: .textEditing, addition: {}) CommandGroup(replacing: .textEditing, addition: {})
CommandGroup(replacing: .undoRedo, addition: {}) CommandGroup(replacing: .undoRedo, addition: {})
CommandGroup(replacing: .windowSize, addition: {}) CommandGroup(replacing: .windowSize, addition: {})
// Replace some standard menu options // Replace some standard menu options
CommandGroup(replacing: .appInfo) { AboutTart() } CommandGroup(replacing: .appInfo) { AboutTart() }
} }
} }
} }

View File

@ -12,8 +12,8 @@ struct Config {
tartHomeDir = URL(fileURLWithPath: customTartHome) tartHomeDir = URL(fileURLWithPath: customTartHome)
} else { } else {
tartHomeDir = FileManager.default tartHomeDir = FileManager.default
.homeDirectoryForCurrentUser .homeDirectoryForCurrentUser
.appendingPathComponent(".tart", isDirectory: true) .appendingPathComponent(".tart", isDirectory: true)
} }
self.tartHomeDir = tartHomeDir self.tartHomeDir = tartHomeDir
@ -26,7 +26,7 @@ struct Config {
func gc() throws { func gc() throws {
for entry in try FileManager.default.contentsOfDirectory(at: tartTmpDir, for entry in try FileManager.default.contentsOfDirectory(at: tartTmpDir,
includingPropertiesForKeys: [], options: []) { includingPropertiesForKeys: [], options: []) {
let lock = try FileLock(lockURL: entry) let lock = try FileLock(lockURL: entry)
if try !lock.trylock() { if try !lock.trylock() {
continue continue

View File

@ -1,10 +1,10 @@
import Foundation import Foundation
enum CredentialsProviderError: Error { enum CredentialsProviderError: Error {
case Failed(message: String) case Failed(message: String)
} }
protocol CredentialsProvider { protocol CredentialsProvider {
func retrieve(host: String) throws -> (String, String)? func retrieve(host: String) throws -> (String, String)?
func store(host: String, user: String, password: String) throws func store(host: String, user: String, password: String) throws
} }

View File

@ -22,14 +22,14 @@ class DockerConfigCredentialsProvider: CredentialsProvider {
guard let executableURL = resolveBinaryPath(binaryName) else { guard let executableURL = resolveBinaryPath(binaryName) else {
throw CredentialsProviderError.Failed(message: "\(binaryName) not found in PATH") throw CredentialsProviderError.Failed(message: "\(binaryName) not found in PATH")
} }
let process = Process.init() let process = Process.init()
process.executableURL = executableURL process.executableURL = executableURL
process.arguments = ["get"] process.arguments = ["get"]
let outPipe = Pipe() let outPipe = Pipe()
let inPipe = Pipe() let inPipe = Pipe()
process.standardOutput = outPipe process.standardOutput = outPipe
process.standardError = outPipe process.standardError = outPipe
process.standardInput = inPipe process.standardInput = inPipe
@ -38,7 +38,7 @@ class DockerConfigCredentialsProvider: CredentialsProvider {
inPipe.fileHandleForWriting.write("\(host)\n".data(using: .utf8)!) inPipe.fileHandleForWriting.write("\(host)\n".data(using: .utf8)!)
inPipe.fileHandleForWriting.closeFile() inPipe.fileHandleForWriting.closeFile()
process.waitUntilExit() process.waitUntilExit()
if !(process.terminationReason == .exit && process.terminationStatus == 0) { if !(process.terminationReason == .exit && process.terminationStatus == 0) {

View File

@ -1,66 +1,66 @@
import Foundation import Foundation
class KeychainCredentialsProvider: CredentialsProvider { class KeychainCredentialsProvider: CredentialsProvider {
func retrieve(host: String) throws -> (String, String)? { func retrieve(host: String) throws -> (String, String)? {
let query: [String: Any] = [kSecClass as String: kSecClassInternetPassword, let query: [String: Any] = [kSecClass as String: kSecClassInternetPassword,
kSecAttrProtocol as String: kSecAttrProtocolHTTPS, kSecAttrProtocol as String: kSecAttrProtocolHTTPS,
kSecAttrServer as String: host, kSecAttrServer as String: host,
kSecMatchLimit as String: kSecMatchLimitOne, kSecMatchLimit as String: kSecMatchLimitOne,
kSecReturnAttributes as String: true, kSecReturnAttributes as String: true,
kSecReturnData as String: true, kSecReturnData as String: true,
kSecAttrLabel as String: "Tart Credentials", kSecAttrLabel as String: "Tart Credentials",
] ]
var item: CFTypeRef? var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item) let status = SecItemCopyMatching(query as CFDictionary, &item)
if status != errSecSuccess { if status != errSecSuccess {
if status == errSecItemNotFound { if status == errSecItemNotFound {
return nil return nil
} }
throw CredentialsProviderError.Failed(message: "Keychain returned unsuccessful status \(status)") throw CredentialsProviderError.Failed(message: "Keychain returned unsuccessful status \(status)")
}
guard let item = item as? [String: Any],
let user = item[kSecAttrAccount as String] as? String,
let passwordData = item[kSecValueData as String] as? Data,
let password = String(data: passwordData, encoding: .utf8)
else {
throw CredentialsProviderError.Failed(message: "Keychain item has unexpected format")
}
return (user, password)
} }
func store(host: String, user: String, password: String) throws { guard let item = item as? [String: Any],
let passwordData = password.data(using: .utf8) let user = item[kSecAttrAccount as String] as? String,
let key: [String: Any] = [kSecClass as String: kSecClassInternetPassword, let passwordData = item[kSecValueData as String] as? Data,
kSecAttrProtocol as String: kSecAttrProtocolHTTPS, let password = String(data: passwordData, encoding: .utf8)
kSecAttrServer as String: host, else {
kSecAttrLabel as String: "Tart Credentials", throw CredentialsProviderError.Failed(message: "Keychain item has unexpected format")
]
let value: [String: Any] = [kSecAttrAccount as String: user,
kSecValueData as String: passwordData,
]
let status = SecItemCopyMatching(key as CFDictionary, nil)
switch status {
case errSecItemNotFound:
let status = SecItemAdd(key.merging(value) { (current, _) in current } as CFDictionary, nil)
if status != errSecSuccess {
throw CredentialsProviderError.Failed(message: "Keychain failed to add item: \(status.explanation())")
}
case errSecSuccess:
let status = SecItemUpdate(key as CFDictionary, value as CFDictionary)
if status != errSecSuccess {
throw CredentialsProviderError.Failed(message: "Keychain failed to update item: \(status.explanation())")
}
default:
throw CredentialsProviderError.Failed(message: "Keychain failed to find item: \(status.explanation())")
}
} }
return (user, password)
}
func store(host: String, user: String, password: String) throws {
let passwordData = password.data(using: .utf8)
let key: [String: Any] = [kSecClass as String: kSecClassInternetPassword,
kSecAttrProtocol as String: kSecAttrProtocolHTTPS,
kSecAttrServer as String: host,
kSecAttrLabel as String: "Tart Credentials",
]
let value: [String: Any] = [kSecAttrAccount as String: user,
kSecValueData as String: passwordData,
]
let status = SecItemCopyMatching(key as CFDictionary, nil)
switch status {
case errSecItemNotFound:
let status = SecItemAdd(key.merging(value) { (current, _) in current } as CFDictionary, nil)
if status != errSecSuccess {
throw CredentialsProviderError.Failed(message: "Keychain failed to add item: \(status.explanation())")
}
case errSecSuccess:
let status = SecItemUpdate(key as CFDictionary, value as CFDictionary)
if status != errSecSuccess {
throw CredentialsProviderError.Failed(message: "Keychain failed to update item: \(status.explanation())")
}
default:
throw CredentialsProviderError.Failed(message: "Keychain failed to find item: \(status.explanation())")
}
}
} }
extension OSStatus { extension OSStatus {

View File

@ -2,47 +2,47 @@ import Foundation
import System import System
enum FileLockError: Error, Equatable { enum FileLockError: Error, Equatable {
case Failed(_ message: String) case Failed(_ message: String)
case AlreadyLocked case AlreadyLocked
} }
class FileLock { class FileLock {
let url: URL let url: URL
let fd: Int32 let fd: Int32
init(lockURL: URL) throws { init(lockURL: URL) throws {
url = lockURL url = lockURL
fd = open(lockURL.path, 0) fd = open(lockURL.path, 0)
}
deinit {
close(fd)
}
func trylock() throws -> Bool {
try flockWrapper(LOCK_EX | LOCK_NB)
}
func lock() throws {
_ = try flockWrapper(LOCK_EX)
}
func unlock() throws {
_ = try flockWrapper(LOCK_UN)
}
func flockWrapper(_ operation: Int32) throws -> Bool {
let ret = flock(fd, operation)
if ret != 0 {
let details = Errno(rawValue: CInt(errno))
if (operation & LOCK_NB) != 0 && details == .wouldBlock {
return false
}
throw FileLockError.Failed("failed to lock \(url): \(details)")
} }
deinit { return true
close(fd) }
}
func trylock() throws -> Bool {
try flockWrapper(LOCK_EX | LOCK_NB)
}
func lock() throws {
_ = try flockWrapper(LOCK_EX)
}
func unlock() throws {
_ = try flockWrapper(LOCK_UN)
}
func flockWrapper(_ operation: Int32) throws -> Bool {
let ret = flock(fd, operation)
if ret != 0 {
let details = Errno(rawValue: CInt(errno))
if (operation & LOCK_NB) != 0 && details == .wouldBlock {
return false
}
throw FileLockError.Failed("failed to lock \(url): \(details)")
}
return true
}
} }

View File

@ -15,6 +15,6 @@ class IPSWCache: PrunableStorage {
func prunables() throws -> [Prunable] { func prunables() throws -> [Prunable] {
try FileManager.default.contentsOfDirectory(at: baseURL, includingPropertiesForKeys: nil) try FileManager.default.contentsOfDirectory(at: baseURL, includingPropertiesForKeys: nil)
.filter { $0.lastPathComponent.hasSuffix(".ipsw")} .filter { $0.lastPathComponent.hasSuffix(".ipsw")}
} }
} }

View File

@ -3,117 +3,117 @@ import Network
import Virtualization import Virtualization
struct ARPCommandFailedError: Error, CustomStringConvertible { struct ARPCommandFailedError: Error, CustomStringConvertible {
var terminationReason: Process.TerminationReason var terminationReason: Process.TerminationReason
var terminationStatus: Int32 var terminationStatus: Int32
var description: String { var description: String {
var reason: String var reason: String
switch terminationReason { switch terminationReason {
case .exit: case .exit:
reason = "exit code \(terminationStatus)" reason = "exit code \(terminationStatus)"
case .uncaughtSignal: case .uncaughtSignal:
reason = "uncaught signal" reason = "uncaught signal"
default: default:
reason = "unknown reason" reason = "unknown reason"
}
return "arp command failed: \(reason)"
} }
return "arp command failed: \(reason)"
}
} }
struct ARPCommandYieldedInvalidOutputError: Error, CustomStringConvertible { struct ARPCommandYieldedInvalidOutputError: Error, CustomStringConvertible {
var explanation: String var explanation: String
var description: String { var description: String {
"arp command yielded invalid output: \(explanation)" "arp command yielded invalid output: \(explanation)"
} }
} }
struct ARPCacheInternalError: Error, CustomStringConvertible { struct ARPCacheInternalError: Error, CustomStringConvertible {
var explanation: String var explanation: String
var description: String { var description: String {
"ARPCache internal error: \(explanation)" "ARPCache internal error: \(explanation)"
} }
} }
struct ARPCache { struct ARPCache {
static func ResolveMACAddress(macAddress: MACAddress, bridgeOnly: Bool = true) throws -> IPv4Address? { static func ResolveMACAddress(macAddress: MACAddress, bridgeOnly: Bool = true) throws -> IPv4Address? {
let process = Process.init() let process = Process.init()
process.executableURL = URL.init(fileURLWithPath: "/usr/sbin/arp") process.executableURL = URL.init(fileURLWithPath: "/usr/sbin/arp")
process.arguments = ["-an"] process.arguments = ["-an"]
let pipe = Pipe() let pipe = Pipe()
process.standardOutput = pipe process.standardOutput = pipe
process.standardError = pipe process.standardError = pipe
process.standardInput = FileHandle.nullDevice process.standardInput = FileHandle.nullDevice
try process.run() try process.run()
process.waitUntilExit() process.waitUntilExit()
if !(process.terminationReason == .exit && process.terminationStatus == 0) { if !(process.terminationReason == .exit && process.terminationStatus == 0) {
throw ARPCommandFailedError( throw ARPCommandFailedError(
terminationReason: process.terminationReason, terminationReason: process.terminationReason,
terminationStatus: process.terminationStatus) terminationStatus: process.terminationStatus)
}
guard let rawLines = try pipe.fileHandleForReading.readToEnd() else {
throw ARPCommandYieldedInvalidOutputError(explanation: "empty output")
}
let lines = String(decoding: rawLines, as: UTF8.self)
.trimmingCharacters(in: .whitespacesAndNewlines)
.components(separatedBy: "\n")
// Based on https://opensource.apple.com/source/network_cmds/network_cmds-606.40.2/arp.tproj/arp.c.auto.html
let regex = try NSRegularExpression(pattern: #"^.* \((?<ip>.*)\) at (?<mac>.*) on (?<interface>.*) .*$"#)
for line in lines {
let nsLineRange = NSRange(line.startIndex..<line.endIndex, in: line)
guard let match = regex.firstMatch(in: line, range: nsLineRange) else {
throw ARPCommandYieldedInvalidOutputError(explanation: "unparseable entry \"\(line)\"")
}
let rawIP = try match.getCaptureGroup(name: "ip", for: line)
guard let ip = IPv4Address(rawIP) else {
throw ARPCommandYieldedInvalidOutputError(explanation: "failed to parse IPv4 address \(rawIP)")
}
let rawMAC = try match.getCaptureGroup(name: "mac", for: line)
if rawMAC == "(incomplete)" {
continue
}
guard let mac = MACAddress(fromString: rawMAC) else {
throw ARPCommandYieldedInvalidOutputError(explanation: "failed to parse MAC address \(rawMAC)")
}
let interface = try match.getCaptureGroup(name: "interface", for: line)
if bridgeOnly && !interface.starts(with: "bridge") {
continue
}
if macAddress == mac {
return ip
}
}
return nil
} }
guard let rawLines = try pipe.fileHandleForReading.readToEnd() else {
throw ARPCommandYieldedInvalidOutputError(explanation: "empty output")
}
let lines = String(decoding: rawLines, as: UTF8.self)
.trimmingCharacters(in: .whitespacesAndNewlines)
.components(separatedBy: "\n")
// Based on https://opensource.apple.com/source/network_cmds/network_cmds-606.40.2/arp.tproj/arp.c.auto.html
let regex = try NSRegularExpression(pattern: #"^.* \((?<ip>.*)\) at (?<mac>.*) on (?<interface>.*) .*$"#)
for line in lines {
let nsLineRange = NSRange(line.startIndex..<line.endIndex, in: line)
guard let match = regex.firstMatch(in: line, range: nsLineRange) else {
throw ARPCommandYieldedInvalidOutputError(explanation: "unparseable entry \"\(line)\"")
}
let rawIP = try match.getCaptureGroup(name: "ip", for: line)
guard let ip = IPv4Address(rawIP) else {
throw ARPCommandYieldedInvalidOutputError(explanation: "failed to parse IPv4 address \(rawIP)")
}
let rawMAC = try match.getCaptureGroup(name: "mac", for: line)
if rawMAC == "(incomplete)" {
continue
}
guard let mac = MACAddress(fromString: rawMAC) else {
throw ARPCommandYieldedInvalidOutputError(explanation: "failed to parse MAC address \(rawMAC)")
}
let interface = try match.getCaptureGroup(name: "interface", for: line)
if bridgeOnly && !interface.starts(with: "bridge") {
continue
}
if macAddress == mac {
return ip
}
}
return nil
}
} }
extension NSTextCheckingResult { extension NSTextCheckingResult {
func getCaptureGroup(name: String, for string: String) throws -> String { func getCaptureGroup(name: String, for string: String) throws -> String {
let nsRange = self.range(withName: name) let nsRange = self.range(withName: name)
if nsRange.location == NSNotFound { if nsRange.location == NSNotFound {
throw ARPCacheInternalError(explanation: "attempted to retrieve non-existent named capture group \(name)") throw ARPCacheInternalError(explanation: "attempted to retrieve non-existent named capture group \(name)")
}
guard let range = Range.init(nsRange, in: string) else {
throw ARPCacheInternalError(explanation: "failed to convert NSRange to Range")
}
return String(string[range])
} }
guard let range = Range.init(nsRange, in: string) else {
throw ARPCacheInternalError(explanation: "failed to convert NSRange to Range")
}
return String(string[range])
}
} }

View File

@ -2,21 +2,21 @@ import Foundation
import Virtualization import Virtualization
class NetworkBridged: Network { class NetworkBridged: Network {
let interface: VZBridgedNetworkInterface let interface: VZBridgedNetworkInterface
init(interface: VZBridgedNetworkInterface) { init(interface: VZBridgedNetworkInterface) {
self.interface = interface self.interface = interface
} }
func attachment() -> VZNetworkDeviceAttachment { func attachment() -> VZNetworkDeviceAttachment {
VZBridgedNetworkDeviceAttachment(interface: interface) VZBridgedNetworkDeviceAttachment(interface: interface)
} }
func run(_ sema: DispatchSemaphore) throws { func run(_ sema: DispatchSemaphore) throws {
// no-op, only used for Softnet // no-op, only used for Softnet
} }
func stop() async throws { func stop() async throws {
// no-op, only used for Softnet // no-op, only used for Softnet
} }
} }

View File

@ -2,15 +2,15 @@ import Foundation
import Virtualization import Virtualization
class NetworkShared: Network { class NetworkShared: Network {
func attachment() -> VZNetworkDeviceAttachment { func attachment() -> VZNetworkDeviceAttachment {
VZNATNetworkDeviceAttachment() VZNATNetworkDeviceAttachment()
} }
func run(_ sema: DispatchSemaphore) throws { func run(_ sema: DispatchSemaphore) throws {
// no-op, only used for Softnet // no-op, only used for Softnet
} }
func stop() async throws { func stop() async throws {
// no-op, only used for Softnet // no-op, only used for Softnet
} }
} }

View File

@ -20,8 +20,8 @@ class Digest {
extension SHA256.Digest { extension SHA256.Digest {
func hexdigest() -> String { func hexdigest() -> String {
"sha256:" + self.map { "sha256:" + self.map {
String(format: "%02x", $0) String(format: "%02x", $0)
} }
.joined() .joined()
} }
} }

View File

@ -130,11 +130,11 @@ class Registry {
let manifestJSON = try manifest.toJSON() let manifestJSON = try manifest.toJSON()
let (data, response) = try await dataRequest(.PUT, endpointURL("\(namespace)/manifests/\(reference)"), let (data, response) = try await dataRequest(.PUT, endpointURL("\(namespace)/manifests/\(reference)"),
headers: ["Content-Type": manifest.mediaType], headers: ["Content-Type": manifest.mediaType],
body: manifestJSON) body: manifestJSON)
if response.statusCode != HTTPCode.Created.rawValue { if response.statusCode != HTTPCode.Created.rawValue {
throw RegistryError.UnexpectedHTTPStatusCode(when: "pushing manifest", code: response.statusCode, throw RegistryError.UnexpectedHTTPStatusCode(when: "pushing manifest", code: response.statusCode,
details: data.asText()) details: data.asText())
} }
return Digest.hash(manifestJSON) return Digest.hash(manifestJSON)
@ -142,10 +142,10 @@ class Registry {
public func pullManifest(reference: String) async throws -> (OCIManifest, Data) { public func pullManifest(reference: String) async throws -> (OCIManifest, Data) {
let (data, response) = try await dataRequest(.GET, endpointURL("\(namespace)/manifests/\(reference)"), let (data, response) = try await dataRequest(.GET, endpointURL("\(namespace)/manifests/\(reference)"),
headers: ["Accept": ociManifestMediaType]) headers: ["Accept": ociManifestMediaType])
if response.statusCode != HTTPCode.Ok.rawValue { if response.statusCode != HTTPCode.Ok.rawValue {
throw RegistryError.UnexpectedHTTPStatusCode(when: "pulling manifest", code: response.statusCode, throw RegistryError.UnexpectedHTTPStatusCode(when: "pulling manifest", code: response.statusCode,
details: data.asText()) details: data.asText())
} }
let manifest = try OCIManifest(fromJSON: data) let manifest = try OCIManifest(fromJSON: data)
@ -168,17 +168,17 @@ class Registry {
public func pushBlob(fromData: Data, chunkSizeMb: Int = 0) async throws -> String { public func pushBlob(fromData: Data, chunkSizeMb: Int = 0) async throws -> String {
// Initiate a blob upload // Initiate a blob upload
let (data, postResponse) = try await dataRequest(.POST, endpointURL("\(namespace)/blobs/uploads/"), let (data, postResponse) = try await dataRequest(.POST, endpointURL("\(namespace)/blobs/uploads/"),
headers: ["Content-Length": "0"]) headers: ["Content-Length": "0"])
if postResponse.statusCode != HTTPCode.Accepted.rawValue { if postResponse.statusCode != HTTPCode.Accepted.rawValue {
throw RegistryError.UnexpectedHTTPStatusCode(when: "pushing blob (POST)", code: postResponse.statusCode, throw RegistryError.UnexpectedHTTPStatusCode(when: "pushing blob (POST)", code: postResponse.statusCode,
details: data.asText()) details: data.asText())
} }
// Figure out where to upload the blob // Figure out where to upload the blob
var uploadLocation = try uploadLocationFromResponse(postResponse) var uploadLocation = try uploadLocationFromResponse(postResponse)
let digest = Digest.hash(fromData) let digest = Digest.hash(fromData)
if chunkSizeMb == 0 { if chunkSizeMb == 0 {
// monolithic upload // monolithic upload
let (data, response) = try await dataRequest( let (data, response) = try await dataRequest(
@ -192,7 +192,7 @@ class Registry {
) )
if response.statusCode != HTTPCode.Created.rawValue { if response.statusCode != HTTPCode.Created.rawValue {
throw RegistryError.UnexpectedHTTPStatusCode(when: "pushing blob (PUT) to \(uploadLocation)", throw RegistryError.UnexpectedHTTPStatusCode(when: "pushing blob (PUT) to \(uploadLocation)",
code: response.statusCode, details: data.asText()) code: response.statusCode, details: data.asText())
} }
return digest return digest
} }
@ -215,13 +215,13 @@ class Registry {
// always accept both statuses since AWS ECR is not following specification // always accept both statuses since AWS ECR is not following specification
if response.statusCode != HTTPCode.Created.rawValue && response.statusCode != HTTPCode.Accepted.rawValue { if response.statusCode != HTTPCode.Created.rawValue && response.statusCode != HTTPCode.Accepted.rawValue {
throw RegistryError.UnexpectedHTTPStatusCode(when: "streaming blob to \(uploadLocation)", throw RegistryError.UnexpectedHTTPStatusCode(when: "streaming blob to \(uploadLocation)",
code: response.statusCode, details: data.asText()) code: response.statusCode, details: data.asText())
} }
uploadedBytes += chunk.count uploadedBytes += chunk.count
// Update location for the next chunk // Update location for the next chunk
uploadLocation = try uploadLocationFromResponse(response) uploadLocation = try uploadLocationFromResponse(response)
} }
return digest return digest
} }
@ -230,7 +230,7 @@ class Registry {
if response.statusCode != HTTPCode.Ok.rawValue { if response.statusCode != HTTPCode.Ok.rawValue {
let body = try await channel.asData().asText() let body = try await channel.asData().asText()
throw RegistryError.UnexpectedHTTPStatusCode(when: "pulling blob", code: response.statusCode, throw RegistryError.UnexpectedHTTPStatusCode(when: "pulling blob", code: response.statusCode,
details: body) details: body)
} }
for try await part in channel { for try await part in channel {
@ -247,15 +247,15 @@ class Registry {
} }
private func dataRequest( private func dataRequest(
_ method: HTTPMethod, _ method: HTTPMethod,
_ urlComponents: URLComponents, _ urlComponents: URLComponents,
headers: Dictionary<String, String> = Dictionary(), headers: Dictionary<String, String> = Dictionary(),
parameters: Dictionary<String, String> = Dictionary(), parameters: Dictionary<String, String> = Dictionary(),
body: Data? = nil, body: Data? = nil,
doAuth: Bool = true doAuth: Bool = true
) async throws -> (Data, HTTPURLResponse) { ) async throws -> (Data, HTTPURLResponse) {
let (channel, response) = try await channelRequest(method, urlComponents, let (channel, response) = try await channelRequest(method, urlComponents,
headers: headers, parameters: parameters, body: body, doAuth: doAuth) headers: headers, parameters: parameters, body: body, doAuth: doAuth)
return (try await channel.asData(), response) return (try await channel.asData(), response)
} }

View File

@ -1,13 +1,13 @@
import Foundation import Foundation
struct PassphraseGenerator: Sequence { struct PassphraseGenerator: Sequence {
func makeIterator() -> PassphraseIterator { func makeIterator() -> PassphraseIterator {
PassphraseIterator() PassphraseIterator()
} }
} }
struct PassphraseIterator: IteratorProtocol { struct PassphraseIterator: IteratorProtocol {
mutating func next() -> String? { mutating func next() -> String? {
passphrases[Int(arc4random_uniform(UInt32(passphrases.count)))] passphrases[Int(arc4random_uniform(UInt32(passphrases.count)))]
} }
} }

View File

@ -1,14 +1,14 @@
import Foundation import Foundation
enum Architecture: String, Codable { enum Architecture: String, Codable {
case arm64 case arm64
case amd64 case amd64
} }
func CurrentArchitecture() -> Architecture { func CurrentArchitecture() -> Architecture {
#if arch(arm64) #if arch(arm64)
return .arm64 return .arm64
#elseif arch(x86_64) #elseif arch(x86_64)
return .amd64 return .amd64
#endif #endif
} }

View File

@ -1,97 +1,97 @@
import Virtualization import Virtualization
struct Darwin: Platform { struct Darwin: Platform {
var ecid: VZMacMachineIdentifier var ecid: VZMacMachineIdentifier
var hardwareModel: VZMacHardwareModel var hardwareModel: VZMacHardwareModel
init(ecid: VZMacMachineIdentifier, hardwareModel: VZMacHardwareModel) { init(ecid: VZMacMachineIdentifier, hardwareModel: VZMacHardwareModel) {
self.ecid = ecid self.ecid = ecid
self.hardwareModel = hardwareModel self.hardwareModel = hardwareModel
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let encodedECID = try container.decode(String.self, forKey: .ecid)
guard let data = Data.init(base64Encoded: encodedECID) else {
throw DecodingError.dataCorruptedError(forKey: .ecid,
in: container,
debugDescription: "failed to initialize Data using the provided value")
}
guard let ecid = VZMacMachineIdentifier.init(dataRepresentation: data) else {
throw DecodingError.dataCorruptedError(forKey: .ecid,
in: container,
debugDescription: "failed to initialize VZMacMachineIdentifier using the provided value")
}
self.ecid = ecid
let encodedHardwareModel = try container.decode(String.self, forKey: .hardwareModel)
guard let data = Data.init(base64Encoded: encodedHardwareModel) else {
throw DecodingError.dataCorruptedError(forKey: .hardwareModel, in: container, debugDescription: "")
}
guard let hardwareModel = VZMacHardwareModel.init(dataRepresentation: data) else {
throw DecodingError.dataCorruptedError(forKey: .hardwareModel, in: container, debugDescription: "")
}
self.hardwareModel = hardwareModel
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(ecid.dataRepresentation.base64EncodedString(), forKey: .ecid)
try container.encode(hardwareModel.dataRepresentation.base64EncodedString(), forKey: .hardwareModel)
}
func os() -> OS {
.darwin
}
func bootLoader(nvramURL: URL) throws -> VZBootLoader {
VZMacOSBootLoader()
}
func platform(nvramURL: URL) -> VZPlatformConfiguration {
let result = VZMacPlatformConfiguration()
result.machineIdentifier = ecid
result.auxiliaryStorage = VZMacAuxiliaryStorage(contentsOf: nvramURL)
result.hardwareModel = hardwareModel
return result
}
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration {
let result = VZMacGraphicsDeviceConfiguration()
if let hostMainScreen = NSScreen.main {
let vmScreenSize = NSSize(width: vmConfig.display.width, height: vmConfig.display.height)
result.displays = [
VZMacGraphicsDisplayConfiguration(for: hostMainScreen, sizeInPoints: vmScreenSize)
]
return result
} }
init(from decoder: Decoder) throws { result.displays = [
let container = try decoder.container(keyedBy: CodingKeys.self) VZMacGraphicsDisplayConfiguration(
widthInPixels: vmConfig.display.width,
heightInPixels: vmConfig.display.height,
// A reasonable guess according to Apple's documentation[1]
// [1]: https://developer.apple.com/documentation/coregraphics/1456599-cgdisplayscreensize
pixelsPerInch: 72
)
]
let encodedECID = try container.decode(String.self, forKey: .ecid) return result
guard let data = Data.init(base64Encoded: encodedECID) else { }
throw DecodingError.dataCorruptedError(forKey: .ecid,
in: container,
debugDescription: "failed to initialize Data using the provided value")
}
guard let ecid = VZMacMachineIdentifier.init(dataRepresentation: data) else {
throw DecodingError.dataCorruptedError(forKey: .ecid,
in: container,
debugDescription: "failed to initialize VZMacMachineIdentifier using the provided value")
}
self.ecid = ecid
let encodedHardwareModel = try container.decode(String.self, forKey: .hardwareModel) func pointingDevices() -> [VZPointingDeviceConfiguration] {
guard let data = Data.init(base64Encoded: encodedHardwareModel) else { if #available(macOS 13, *) {
throw DecodingError.dataCorruptedError(forKey: .hardwareModel, in: container, debugDescription: "") // Trackpad is only supported starting with macOS Ventura
} // macOS Monterey will continue using a USB device == .darwin
guard let hardwareModel = VZMacHardwareModel.init(dataRepresentation: data) else { return [VZMacTrackpadConfiguration(), VZUSBScreenCoordinatePointingDeviceConfiguration()]
throw DecodingError.dataCorruptedError(forKey: .hardwareModel, in: container, debugDescription: "") } else {
} return [VZUSBScreenCoordinatePointingDeviceConfiguration()]
self.hardwareModel = hardwareModel
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(ecid.dataRepresentation.base64EncodedString(), forKey: .ecid)
try container.encode(hardwareModel.dataRepresentation.base64EncodedString(), forKey: .hardwareModel)
}
func os() -> OS {
.darwin
}
func bootLoader(nvramURL: URL) throws -> VZBootLoader {
VZMacOSBootLoader()
}
func platform(nvramURL: URL) -> VZPlatformConfiguration {
let result = VZMacPlatformConfiguration()
result.machineIdentifier = ecid
result.auxiliaryStorage = VZMacAuxiliaryStorage(contentsOf: nvramURL)
result.hardwareModel = hardwareModel
return result
}
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration {
let result = VZMacGraphicsDeviceConfiguration()
if let hostMainScreen = NSScreen.main {
let vmScreenSize = NSSize(width: vmConfig.display.width, height: vmConfig.display.height)
result.displays = [
VZMacGraphicsDisplayConfiguration(for: hostMainScreen, sizeInPoints: vmScreenSize)
]
return result
}
result.displays = [
VZMacGraphicsDisplayConfiguration(
widthInPixels: vmConfig.display.width,
heightInPixels: vmConfig.display.height,
// A reasonable guess according to Apple's documentation[1]
// [1]: https://developer.apple.com/documentation/coregraphics/1456599-cgdisplayscreensize
pixelsPerInch: 72
)
]
return result
}
func pointingDevices() -> [VZPointingDeviceConfiguration] {
if #available(macOS 13, *) {
// Trackpad is only supported starting with macOS Ventura
// macOS Monterey will continue using a USB device == .darwin
return [VZMacTrackpadConfiguration(), VZUSBScreenCoordinatePointingDeviceConfiguration()]
} else {
return [VZUSBScreenCoordinatePointingDeviceConfiguration()]
}
} }
}
} }

View File

@ -2,36 +2,36 @@ import Virtualization
@available(macOS 13, *) @available(macOS 13, *)
struct Linux: Platform { struct Linux: Platform {
func os() -> OS { func os() -> OS {
.linux .linux
} }
func bootLoader(nvramURL: URL) throws -> VZBootLoader { func bootLoader(nvramURL: URL) throws -> VZBootLoader {
let result = VZEFIBootLoader() let result = VZEFIBootLoader()
result.variableStore = VZEFIVariableStore(url: nvramURL) result.variableStore = VZEFIVariableStore(url: nvramURL)
return result return result
} }
func platform(nvramURL: URL) -> VZPlatformConfiguration { func platform(nvramURL: URL) -> VZPlatformConfiguration {
VZGenericPlatformConfiguration() VZGenericPlatformConfiguration()
} }
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration { func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration {
let result = VZVirtioGraphicsDeviceConfiguration() let result = VZVirtioGraphicsDeviceConfiguration()
result.scanouts = [ result.scanouts = [
VZVirtioGraphicsScanoutConfiguration( VZVirtioGraphicsScanoutConfiguration(
widthInPixels: vmConfig.display.width, widthInPixels: vmConfig.display.width,
heightInPixels: vmConfig.display.height heightInPixels: vmConfig.display.height
) )
] ]
return result return result
} }
func pointingDevices() -> [VZPointingDeviceConfiguration] { func pointingDevices() -> [VZPointingDeviceConfiguration] {
[VZUSBScreenCoordinatePointingDeviceConfiguration()] [VZUSBScreenCoordinatePointingDeviceConfiguration()]
} }
} }

View File

@ -1,6 +1,6 @@
import Virtualization import Virtualization
enum OS: String, Codable { enum OS: String, Codable {
case darwin case darwin
case linux case linux
} }

View File

@ -1,9 +1,9 @@
import Virtualization import Virtualization
protocol Platform: Codable { protocol Platform: Codable {
func os() -> OS func os() -> OS
func bootLoader(nvramURL: URL) throws -> VZBootLoader func bootLoader(nvramURL: URL) throws -> VZBootLoader
func platform(nvramURL: URL) -> VZPlatformConfiguration func platform(nvramURL: URL) -> VZPlatformConfiguration
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration
func pointingDevices() -> [VZPointingDeviceConfiguration] func pointingDevices() -> [VZPointingDeviceConfiguration]
} }

View File

@ -5,9 +5,9 @@ import Puppy
var puppy = Puppy.default var puppy = Puppy.default
class LogFormatter: LogFormattable { class LogFormatter: LogFormattable {
func formatMessage(_ level: LogLevel, message: String, tag: String, function: String, file: String, line: UInt, swiftLogInfo: [String: String], label: String, date: Date, threadID: UInt64) -> String { func formatMessage(_ level: LogLevel, message: String, tag: String, function: String, file: String, line: UInt, swiftLogInfo: [String: String], label: String, date: Date, threadID: UInt64) -> String {
"\(date) \(level) \(message)" "\(date) \(level) \(message)"
} }
} }
@main @main

View File

@ -13,7 +13,7 @@ func resolveBinaryPath(_ name: String) -> URL? {
for pathComponent in path.split(separator: ":") { for pathComponent in path.split(separator: ":") {
let url = URL(fileURLWithPath: String(pathComponent)) let url = URL(fileURLWithPath: String(pathComponent))
.appendingPathComponent(name, isDirectory: false) .appendingPathComponent(name, isDirectory: false)
if FileManager.default.fileExists(atPath: url.path) { if FileManager.default.fileExists(atPath: url.path) {
return url return url

View File

@ -31,7 +31,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
// VM's config // VM's config
var name: String var name: String
// VM's config // VM's config
var config: VMConfig var config: VMConfig
@ -52,9 +52,9 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
// Initialize the virtual machine and its configuration // Initialize the virtual machine and its configuration
self.network = network self.network = network
let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL,
nvramURL: vmDir.nvramURL, vmConfig: config, nvramURL: vmDir.nvramURL, vmConfig: config,
network: network, additionalDiskAttachments: additionalDiskAttachments, network: network, additionalDiskAttachments: additionalDiskAttachments,
directorySharingDevices: directorySharingDevices directorySharingDevices: directorySharingDevices
) )
virtualMachine = VZVirtualMachine(configuration: configuration) virtualMachine = VZVirtualMachine(configuration: configuration)
@ -122,7 +122,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
virtualMachine.state == VZVirtualMachine.State.stopped || virtualMachine.state == VZVirtualMachine.State.stopped ||
virtualMachine.state == VZVirtualMachine.State.paused || virtualMachine.state == VZVirtualMachine.State.paused ||
virtualMachine.state == VZVirtualMachine.State.error virtualMachine.state == VZVirtualMachine.State.error
} }
} }
@ -177,9 +177,9 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
// Initialize the virtual machine and its configuration // Initialize the virtual machine and its configuration
self.network = network self.network = network
let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, nvramURL: vmDir.nvramURL, let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, nvramURL: vmDir.nvramURL,
vmConfig: config, network: network, vmConfig: config, network: network,
additionalDiskAttachments: additionalDiskAttachments, additionalDiskAttachments: additionalDiskAttachments,
directorySharingDevices: directorySharingDevices directorySharingDevices: directorySharingDevices
) )
virtualMachine = VZVirtualMachine(configuration: configuration) virtualMachine = VZVirtualMachine(configuration: configuration)

View File

@ -48,10 +48,10 @@ struct VMConfig: Codable {
var display: VMDisplayConfig = VMDisplayConfig() var display: VMDisplayConfig = VMDisplayConfig()
init( init(
platform: Platform, platform: Platform,
cpuCountMin: Int, cpuCountMin: Int,
memorySizeMin: UInt64, memorySizeMin: UInt64,
macAddress: VZMACAddress = VZMACAddress.randomLocallyAdministered() macAddress: VZMACAddress = VZMACAddress.randomLocallyAdministered()
) { ) {
self.os = platform.os() self.os = platform.os()
self.arch = CurrentArchitecture() self.arch = CurrentArchitecture()
@ -105,12 +105,12 @@ struct VMConfig: Codable {
let encodedMacAddress = try container.decode(String.self, forKey: .macAddress) let encodedMacAddress = try container.decode(String.self, forKey: .macAddress)
guard let macAddress = VZMACAddress.init(string: encodedMacAddress) else { guard let macAddress = VZMACAddress.init(string: encodedMacAddress) else {
throw DecodingError.dataCorruptedError( throw DecodingError.dataCorruptedError(
forKey: .hardwareModel, forKey: .hardwareModel,
in: container, in: container,
debugDescription: "failed to initialize VZMacAddress using the provided value") debugDescription: "failed to initialize VZMacAddress using the provided value")
} }
self.macAddress = macAddress self.macAddress = macAddress
display = try container.decodeIfPresent(VMDisplayConfig.self, forKey: .display) ?? VMDisplayConfig() display = try container.decodeIfPresent(VMDisplayConfig.self, forKey: .display) ?? VMDisplayConfig()
} }
@ -132,7 +132,7 @@ struct VMConfig: Codable {
mutating func setCPU(cpuCount: Int) throws { mutating func setCPU(cpuCount: Int) throws {
if cpuCount < cpuCountMin { if cpuCount < cpuCountMin {
throw LessThanMinimalResourcesError("VM should have \(cpuCountMin) CPU cores" throw LessThanMinimalResourcesError("VM should have \(cpuCountMin) CPU cores"
+ " at minimum (requested \(cpuCount))") + " at minimum (requested \(cpuCount))")
} }
self.cpuCount = cpuCount self.cpuCount = cpuCount
@ -141,7 +141,7 @@ struct VMConfig: Codable {
mutating func setMemory(memorySize: UInt64) throws { mutating func setMemory(memorySize: UInt64) throws {
if memorySize < memorySizeMin { if memorySize < memorySizeMin {
throw LessThanMinimalResourcesError("VM should have \(memorySizeMin) bytes" throw LessThanMinimalResourcesError("VM should have \(memorySizeMin) bytes"
+ " of memory at minimum (requested \(memorySizeMin))") + " of memory at minimum (requested \(memorySizeMin))")
} }
self.memorySize = memorySize self.memorySize = memorySize

View File

@ -59,11 +59,11 @@ extension VMDirectory {
// Progress // Progress
let diskCompressedSize: Int64 = Int64(diskLayers.map { let diskCompressedSize: Int64 = Int64(diskLayers.map {
$0.size $0.size
} }
.reduce(0) { .reduce(0) {
$0 + $1 $0 + $1
}) })
let prettyDiskSize = String(format: "%.1f", Double(diskCompressedSize) / 1_000_000_000.0) let prettyDiskSize = String(format: "%.1f", Double(diskCompressedSize) / 1_000_000_000.0)
defaultLogger.appendNewLine("pulling disk (\(prettyDiskSize) GB compressed)...") defaultLogger.appendNewLine("pulling disk (\(prettyDiskSize) GB compressed)...")
let progress = Progress(totalUnitCount: diskCompressedSize) let progress = Progress(totalUnitCount: diskCompressedSize)
@ -144,9 +144,9 @@ extension VMDirectory {
let ociConfigJSON = try OCIConfig(architecture: config.arch, os: config.os).toJSON() let ociConfigJSON = try OCIConfig(architecture: config.arch, os: config.os).toJSON()
let ociConfigDigest = try await registry.pushBlob(fromData: ociConfigJSON, chunkSizeMb: chunkSizeMb) let ociConfigDigest = try await registry.pushBlob(fromData: ociConfigJSON, chunkSizeMb: chunkSizeMb)
let manifest = OCIManifest( let manifest = OCIManifest(
config: OCIManifestConfig(size: ociConfigJSON.count, digest: ociConfigDigest), config: OCIManifestConfig(size: ociConfigJSON.count, digest: ociConfigDigest),
layers: layers, layers: layers,
uncompressedDiskSize: UInt64(mappedDiskReadOffset) uncompressedDiskSize: UInt64(mappedDiskReadOffset)
) )
// Manifest // Manifest

View File

@ -39,7 +39,7 @@ class VMStorageOCI: PrunableStorage {
// Pre-create intermediate directories (e.g. creates ~/.tart/cache/OCIs/github.com/org/repo/ // Pre-create intermediate directories (e.g. creates ~/.tart/cache/OCIs/github.com/org/repo/
// for github.com/org/repo:latest) // for github.com/org/repo:latest)
try FileManager.default.createDirectory(at: targetURL.deletingLastPathComponent(), try FileManager.default.createDirectory(at: targetURL.deletingLastPathComponent(),
withIntermediateDirectories: true) withIntermediateDirectories: true)
_ = try FileManager.default.replaceItemAt(targetURL, withItemAt: from.baseURL) _ = try FileManager.default.replaceItemAt(targetURL, withItemAt: from.baseURL)
} }
@ -53,7 +53,7 @@ class VMStorageOCI: PrunableStorage {
var refCounts = Dictionary<URL, UInt>() var refCounts = Dictionary<URL, UInt>()
guard let enumerator = FileManager.default.enumerator(at: baseURL, guard let enumerator = FileManager.default.enumerator(at: baseURL,
includingPropertiesForKeys: [.isSymbolicLinkKey]) else { includingPropertiesForKeys: [.isSymbolicLinkKey]) else {
return return
} }
@ -90,7 +90,7 @@ class VMStorageOCI: PrunableStorage {
var result: [(String, VMDirectory, Bool)] = Array() var result: [(String, VMDirectory, Bool)] = Array()
guard let enumerator = FileManager.default.enumerator(at: baseURL, guard let enumerator = FileManager.default.enumerator(at: baseURL,
includingPropertiesForKeys: [.isSymbolicLinkKey], options: [.producesRelativePathURLs]) else { includingPropertiesForKeys: [.isSymbolicLinkKey], options: [.producesRelativePathURLs]) else {
return [] return []
} }
@ -127,7 +127,7 @@ class VMStorageOCI: PrunableStorage {
let (manifest, manifestData) = try await registry.pullManifest(reference: name.reference.value) let (manifest, manifestData) = try await registry.pullManifest(reference: name.reference.value)
let digestName = RemoteName(host: name.host, namespace: name.namespace, let digestName = RemoteName(host: name.host, namespace: name.namespace,
reference: Reference(digest: Digest.hash(manifestData))) reference: Reference(digest: Digest.hash(manifestData)))
// Ensure that host directory for given RemoteName exists in OCI storage // Ensure that host directory for given RemoteName exists in OCI storage
let hostDirectoryURL = hostDirectoryURL(digestName) let hostDirectoryURL = hostDirectoryURL(digestName)
@ -165,15 +165,15 @@ class VMStorageOCI: PrunableStorage {
if capacityImportant == 0 || capacityAvailable == 0 { if capacityImportant == 0 || capacityAvailable == 0 {
puppy.warning("important capacity \(capacityImportant) bytes, " puppy.warning("important capacity \(capacityImportant) bytes, "
+ "available capacity is \(capacityAvailable) bytes") + "available capacity is \(capacityAvailable) bytes")
} }
// There is a suspicious that occasionally capacity is returned as zero which can't be true. // There is a suspicious that occasionally capacity is returned as zero which can't be true.
// Let's validate to avoid unnecessary pruning. // Let's validate to avoid unnecessary pruning.
if 0 < availableCapacityBytes && availableCapacityBytes < requiredCapacityBytes { if 0 < availableCapacityBytes && availableCapacityBytes < requiredCapacityBytes {
puppy.info("pruning cache to accommodate \(name) with a disk of size \(uncompressedDiskSize) bytes (" puppy.info("pruning cache to accommodate \(name) with a disk of size \(uncompressedDiskSize) bytes ("
+ "available capacity is \(availableCapacityBytes) bytes, required capacity " + "available capacity is \(availableCapacityBytes) bytes, required capacity "
+ "is \(requiredCapacityBytes) bytes)") + "is \(requiredCapacityBytes) bytes)")
try Prune.pruneReclaim(reclaimBytes: requiredCapacityBytes - availableCapacityBytes) try Prune.pruneReclaim(reclaimBytes: requiredCapacityBytes - availableCapacityBytes)
} }

View File

@ -3,36 +3,36 @@ import Dynamic
import Virtualization import Virtualization
class FullFledgedVNC: VNC { class FullFledgedVNC: VNC {
let password: String let password: String
private let vnc: Dynamic private let vnc: Dynamic
init(virtualMachine: VZVirtualMachine) { init(virtualMachine: VZVirtualMachine) {
password = Array(PassphraseGenerator().prefix(4)).joined(separator: "-") password = Array(PassphraseGenerator().prefix(4)).joined(separator: "-")
let securityConfiguration = Dynamic._VZVNCAuthenticationSecurityConfiguration(password: password) let securityConfiguration = Dynamic._VZVNCAuthenticationSecurityConfiguration(password: password)
vnc = Dynamic._VZVNCServer(port: 0, queue: DispatchQueue.global(), vnc = Dynamic._VZVNCServer(port: 0, queue: DispatchQueue.global(),
securityConfiguration: securityConfiguration) securityConfiguration: securityConfiguration)
vnc.virtualMachine = virtualMachine vnc.virtualMachine = virtualMachine
vnc.start() vnc.start()
} }
func waitForURL() async throws -> URL { func waitForURL() async throws -> URL {
while true { while true {
// Port is 0 shortly after start(), // Port is 0 shortly after start(),
// but will be initialized later // but will be initialized later
if let port = vnc.port.asUInt16, port != 0 { if let port = vnc.port.asUInt16, port != 0 {
return URL(string: "vnc://:\(password)@127.0.0.1:\(port)")! return URL(string: "vnc://:\(password)@127.0.0.1:\(port)")!
}
// Wait 50 ms.
try await Task.sleep(nanoseconds: 50_000_000)
} }
}
func stop() throws { // Wait 50 ms.
vnc.stop() try await Task.sleep(nanoseconds: 50_000_000)
} }
}
deinit { func stop() throws {
try? stop() vnc.stop()
} }
deinit {
try? stop()
}
} }

View File

@ -2,33 +2,33 @@ import XCTest
@testable import tart @testable import tart
final class FileLockTests: XCTestCase { final class FileLockTests: XCTestCase {
func testSimple() throws { func testSimple() throws {
// Create a temporary file that will be used as a lock // Create a temporary file that will be used as a lock
let url = temporaryFile() let url = temporaryFile()
// Make sure this file can be locked and unlocked // Make sure this file can be locked and unlocked
let lock = try FileLock(lockURL: url) let lock = try FileLock(lockURL: url)
try lock.lock() try lock.lock()
try lock.unlock() try lock.unlock()
} }
func testDoubleLockResultsInError() throws { func testDoubleLockResultsInError() throws {
// Create a temporary file that will be used as a lock // Create a temporary file that will be used as a lock
let url = temporaryFile() let url = temporaryFile()
// Create two locks on a same file and ensure one of them fails // Create two locks on a same file and ensure one of them fails
let firstLock = try FileLock(lockURL: url) let firstLock = try FileLock(lockURL: url)
try firstLock.lock() try firstLock.lock()
let secondLock = try! FileLock(lockURL: url) let secondLock = try! FileLock(lockURL: url)
XCTAssertFalse(try secondLock.trylock()) XCTAssertFalse(try secondLock.trylock())
} }
private func temporaryFile() -> URL { private func temporaryFile() -> URL {
let url = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString) let url = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString)
FileManager.default.createFile(atPath: url.path, contents: nil) FileManager.default.createFile(atPath: url.path, contents: nil)
return url return url
} }
} }

View File

@ -5,31 +5,31 @@ import Network
final class MACAddressResolverTests: XCTestCase { final class MACAddressResolverTests: XCTestCase {
func testSingleEntry() throws { func testSingleEntry() throws {
let leases = try Leases(""" let leases = try Leases("""
{ {
ip_address=1.2.3.4 ip_address=1.2.3.4
hw_address=1,00:11:22:33:44:55 hw_address=1,00:11:22:33:44:55
} }
""") """)
XCTAssertEqual(IPv4Address("1.2.3.4"), XCTAssertEqual(IPv4Address("1.2.3.4"),
try leases.resolveMACAddress(macAddress: MACAddress(fromString: "00:11:22:33:44:55")!)) try leases.resolveMACAddress(macAddress: MACAddress(fromString: "00:11:22:33:44:55")!))
} }
func testMultipleEntries() throws { func testMultipleEntries() throws {
let leases = try Leases(""" let leases = try Leases("""
{ {
ip_address=1.2.3.4 ip_address=1.2.3.4
hw_address=1,00:11:22:33:44:55 hw_address=1,00:11:22:33:44:55
} }
{ {
ip_address=5.6.7.8 ip_address=5.6.7.8
hw_address=1,AA:BB:CC:DD:EE:FF hw_address=1,AA:BB:CC:DD:EE:FF
} }
""") """)
XCTAssertEqual(IPv4Address("1.2.3.4"), XCTAssertEqual(IPv4Address("1.2.3.4"),
try leases.resolveMACAddress(macAddress: MACAddress(fromString: "00:11:22:33:44:55")!)) try leases.resolveMACAddress(macAddress: MACAddress(fromString: "00:11:22:33:44:55")!))
XCTAssertEqual(IPv4Address("5.6.7.8"), XCTAssertEqual(IPv4Address("5.6.7.8"),
try leases.resolveMACAddress(macAddress: MACAddress(fromString: "AA:BB:CC:DD:EE:FF")!)) try leases.resolveMACAddress(macAddress: MACAddress(fromString: "AA:BB:CC:DD:EE:FF")!))
} }
} }

View File

@ -2,88 +2,88 @@ import XCTest
@testable import tart @testable import tart
final class RegistryTests: XCTestCase { final class RegistryTests: XCTestCase {
var registryRunner: RegistryRunner? var registryRunner: RegistryRunner?
override func setUp() async throws { override func setUp() async throws {
try await super.setUp() try await super.setUp()
do { do {
registryRunner = try await RegistryRunner() registryRunner = try await RegistryRunner()
} catch { } catch {
try XCTSkipIf(ProcessInfo.processInfo.environment["CI"] == nil) try XCTSkipIf(ProcessInfo.processInfo.environment["CI"] == nil)
} }
}
override func tearDown() async throws {
try await super.tearDown()
registryRunner = nil
}
var registry: Registry {
registryRunner!.registry
}
func testPushPullBlobSmall() async throws {
// Generate a simple blob
let pushedBlob = Data("The quick brown fox jumps over the lazy dog".utf8)
// Push it
let pushedBlobDigest = try await registry.pushBlob(fromData: pushedBlob)
XCTAssertEqual("sha256:d7a8fbb307d7809469ca9abcb0082e4f8d5651e46d3cdb762d02d0bf37c9e592", pushedBlobDigest)
// Pull it
var pulledBlob = Data()
try await registry.pullBlob(pushedBlobDigest) { data in
pulledBlob.append(data)
} }
override func tearDown() async throws { // Ensure that both blobs are identical
try await super.tearDown() XCTAssertEqual(pushedBlob, pulledBlob)
}
registryRunner = nil func testPushPullBlobHugeInChunks() async throws {
// Generate a large enough blob
let fh = FileHandle(forReadingAtPath: "/dev/urandom")!
let largeBlobToPush = try fh.read(upToCount: 768 * 1024 * 1024)!
// Push it
let largeBlobDigest = try await registry.pushBlob(fromData: largeBlobToPush, chunkSizeMb: 10)
// Pull it
var pulledLargeBlob = Data()
try await registry.pullBlob(largeBlobDigest) { data in
pulledLargeBlob.append(data)
} }
var registry: Registry { // Ensure that both blobs are identical
registryRunner!.registry XCTAssertEqual(largeBlobToPush, pulledLargeBlob)
} }
func testPushPullBlobSmall() async throws { func testPushPullManifest() async throws {
// Generate a simple blob // Craft a basic config
let pushedBlob = Data("The quick brown fox jumps over the lazy dog".utf8) let configData = try OCIConfig().toJSON()
let configDigest = try await registry.pushBlob(fromData: configData)
// Push it // Craft a basic layer
let pushedBlobDigest = try await registry.pushBlob(fromData: pushedBlob) let layerData = Data("doesn't matter".utf8)
XCTAssertEqual("sha256:d7a8fbb307d7809469ca9abcb0082e4f8d5651e46d3cdb762d02d0bf37c9e592", pushedBlobDigest) let layerDigest = try await registry.pushBlob(fromData: layerData)
// Pull it // Craft a basic manifest and push it
var pulledBlob = Data() let manifest = OCIManifest(
try await registry.pullBlob(pushedBlobDigest) { data in config: OCIManifestConfig(size: configData.count, digest: configDigest),
pulledBlob.append(data) layers: [
} OCIManifestLayer(mediaType: "application/octet-stream", size: layerData.count, digest: layerDigest)
]
)
let pushedManifestDigest = try await registry.pushManifest(reference: "latest", manifest: manifest)
// Ensure that both blobs are identical // Ensure that the manifest pulled by tag matches with the one pushed above
XCTAssertEqual(pushedBlob, pulledBlob) let (pulledByTagManifest, _) = try await registry.pullManifest(reference: "latest")
} XCTAssertEqual(manifest, pulledByTagManifest)
func testPushPullBlobHugeInChunks() async throws { // Ensure that the manifest pulled by digest matches with the one pushed above
// Generate a large enough blob let (pulledByDigestManifest, _) = try await registry.pullManifest(reference: "\(pushedManifestDigest)")
let fh = FileHandle(forReadingAtPath: "/dev/urandom")! XCTAssertEqual(manifest, pulledByDigestManifest)
let largeBlobToPush = try fh.read(upToCount: 768 * 1024 * 1024)! }
// Push it
let largeBlobDigest = try await registry.pushBlob(fromData: largeBlobToPush, chunkSizeMb: 10)
// Pull it
var pulledLargeBlob = Data()
try await registry.pullBlob(largeBlobDigest) { data in
pulledLargeBlob.append(data)
}
// Ensure that both blobs are identical
XCTAssertEqual(largeBlobToPush, pulledLargeBlob)
}
func testPushPullManifest() async throws {
// Craft a basic config
let configData = try OCIConfig().toJSON()
let configDigest = try await registry.pushBlob(fromData: configData)
// Craft a basic layer
let layerData = Data("doesn't matter".utf8)
let layerDigest = try await registry.pushBlob(fromData: layerData)
// Craft a basic manifest and push it
let manifest = OCIManifest(
config: OCIManifestConfig(size: configData.count, digest: configDigest),
layers: [
OCIManifestLayer(mediaType: "application/octet-stream", size: layerData.count, digest: layerDigest)
]
)
let pushedManifestDigest = try await registry.pushManifest(reference: "latest", manifest: manifest)
// Ensure that the manifest pulled by tag matches with the one pushed above
let (pulledByTagManifest, _) = try await registry.pullManifest(reference: "latest")
XCTAssertEqual(manifest, pulledByTagManifest)
// Ensure that the manifest pulled by digest matches with the one pushed above
let (pulledByDigestManifest, _) = try await registry.pullManifest(reference: "\(pushedManifestDigest)")
XCTAssertEqual(manifest, pulledByDigestManifest)
}
} }

View File

@ -7,7 +7,7 @@ final class RemoteNameTests: XCTestCase {
XCTAssertEqual(expectedRemoteName, try RemoteName("ghcr.io/a/b:latest")) XCTAssertEqual(expectedRemoteName, try RemoteName("ghcr.io/a/b:latest"))
} }
func testComplexTag() throws { func testComplexTag() throws {
let expectedRemoteName = RemoteName(host: "ghcr.io", namespace: "a/b", reference: Reference(tag: "1.2.3-RC-1")) let expectedRemoteName = RemoteName(host: "ghcr.io", namespace: "a/b", reference: Reference(tag: "1.2.3-RC-1"))
@ -22,7 +22,7 @@ final class RemoteNameTests: XCTestCase {
) )
XCTAssertEqual(expectedRemoteName, XCTAssertEqual(expectedRemoteName,
try RemoteName("ghcr.io/a/b@sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")) try RemoteName("ghcr.io/a/b@sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"))
} }
func testASCIIOnly() throws { func testASCIIOnly() throws {

View File

@ -4,14 +4,14 @@ import XCTest
final class URLAbsolutizationTets: XCTestCase { final class URLAbsolutizationTets: XCTestCase {
func testNeedsAbsolutization() throws { func testNeedsAbsolutization() throws {
let url = URL(string: "/v2/some/path?some=query")! let url = URL(string: "/v2/some/path?some=query")!
.absolutize(URL(string: "https://example.com/v2/")!) .absolutize(URL(string: "https://example.com/v2/")!)
XCTAssertEqual(url.absoluteString, "https://example.com/v2/some/path?some=query") XCTAssertEqual(url.absoluteString, "https://example.com/v2/some/path?some=query")
} }
func testDoesntNeedAbsolutization() throws { func testDoesntNeedAbsolutization() throws {
let url = URL(string: "https://example.org/v2/some/path?some=query")! let url = URL(string: "https://example.org/v2/some/path?some=query")!
.absolutize(URL(string: "https://example.com/v2/")!) .absolutize(URL(string: "https://example.com/v2/")!)
XCTAssertEqual(url.absoluteString, "https://example.org/v2/some/path?some=query") XCTAssertEqual(url.absoluteString, "https://example.org/v2/some/path?some=query")
} }

View File

@ -2,53 +2,53 @@ import Foundation
@testable import tart @testable import tart
enum RegistryRunnerError: Error { enum RegistryRunnerError: Error {
case DockerFailed(exitCode: Int32) case DockerFailed(exitCode: Int32)
} }
class RegistryRunner { class RegistryRunner {
let containerID: String let containerID: String
let registry: Registry let registry: Registry
static func dockerCmd(_ arguments: String...) throws -> String { static func dockerCmd(_ arguments: String...) throws -> String {
let stdoutPipe = Pipe() let stdoutPipe = Pipe()
let proc = Process() let proc = Process()
proc.executableURL = URL(fileURLWithPath: "/usr/local/bin/docker") proc.executableURL = URL(fileURLWithPath: "/usr/local/bin/docker")
proc.arguments = arguments proc.arguments = arguments
proc.standardOutput = stdoutPipe proc.standardOutput = stdoutPipe
try proc.run() try proc.run()
let stdoutData = stdoutPipe.fileHandleForReading.readDataToEndOfFile() let stdoutData = stdoutPipe.fileHandleForReading.readDataToEndOfFile()
proc.waitUntilExit() proc.waitUntilExit()
if proc.terminationStatus != 0 { if proc.terminationStatus != 0 {
throw RegistryRunnerError.DockerFailed(exitCode: proc.terminationStatus) throw RegistryRunnerError.DockerFailed(exitCode: proc.terminationStatus)
}
return String(data: stdoutData, encoding: .utf8) ?? ""
} }
init() async throws { return String(data: stdoutData, encoding: .utf8) ?? ""
// Start container }
let container = try Self.dockerCmd("run", "-d", "--rm", "-p", "5000", "registry:2")
.trimmingCharacters(in: CharacterSet.newlines)
containerID = container
// Get forwarded port init() async throws {
let port = try Self.dockerCmd("inspect", containerID, "--format", "{{(index (index .NetworkSettings.Ports \"5000/tcp\") 0).HostPort}}") // Start container
.trimmingCharacters(in: CharacterSet.newlines) let container = try Self.dockerCmd("run", "-d", "--rm", "-p", "5000", "registry:2")
.trimmingCharacters(in: CharacterSet.newlines)
containerID = container
registry = try Registry(urlComponents: URLComponents(string: "http://127.0.0.1:\(port)/v2/")!, // Get forwarded port
namespace: "vm-image") let port = try Self.dockerCmd("inspect", containerID, "--format", "{{(index (index .NetworkSettings.Ports \"5000/tcp\") 0).HostPort}}")
.trimmingCharacters(in: CharacterSet.newlines)
// Wait for the Docker Registry to start registry = try Registry(urlComponents: URLComponents(string: "http://127.0.0.1:\(port)/v2/")!,
while ((try? await registry.ping()) == nil) { namespace: "vm-image")
try await Task.sleep(nanoseconds: 100_000_000)
} // Wait for the Docker Registry to start
while ((try? await registry.ping()) == nil) {
try await Task.sleep(nanoseconds: 100_000_000)
} }
}
deinit { deinit {
_ = try! Self.dockerCmd("kill", containerID) _ = try! Self.dockerCmd("kill", containerID)
} }
} }