Compare commits

...
9 Commits
Author SHA1 Message Date
edi-oai 543453404c tart exec: use 64 KiB Tart Guest Agent receive windows (#1372) 2026-10-15 15:37:09 +01:00
RKS bb5cb01d31 Fix listing VMs deleted during metadata lookup (#1354)
* Fix listing VMs deleted during metadata lookup

* Isolate list deletion regressions by storage source
2026-10-15 00:12:32 -07:00
Cody Neiman 6c31a73166 Include rootfs in the OCI image config (#1360)
The OCI image spec marks `rootfs` as required in the image config, but
Tart's config only carried `architecture`, `os` and `config`. Registries
that validate image configs against the spec reject the manifest; Quay
3.17.4 fails the manifest PUT with HTTP 500 ("'rootfs' is a required
property"), so Tart images can't be pushed to Quay at all.

Add `rootfs: {"type": "layers", "diff_ids": []}`. Tart layers aren't
filesystem tarballs, so there are no DiffIDs to list. The field is
optional on decode so configs pushed by older Tart versions still parse.
2026-10-15 00:11:56 -07:00
Sylvester Kaczmarek e2042dedcd Treat an empty successful ARP cache as no address match (#1363)
Signed-off-by: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com>
2026-10-15 00:10:41 -07:00
Brendan Shanks 12501370cc Support getting the MAC address, and setting an arbitrary MAC address (#1327)
* tart get: Report MAC address

* tart set: Support setting an arbitrary MAC address
2026-10-13 16:57:49 +00:00
Sylvester Kaczmarek 7d39f4cf68 Parse fractional timestamps in registry token responses (#1361)
Signed-off-by: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com>
2026-10-13 16:54:22 +00:00
edi-oai 8364f4ccac tart run: introduce "--net-fd" for better network composability (#1371)
* tart run: introduce "--net-fd" for better network composability

* tart run: allow standard input/output/error FDs for --net-fd
2026-10-13 17:30:15 +01:00
12 ef925e433d Keep display refit setting when tart set changes other options (#1369) 2026-10-13 12:25:07 +00:00
12 37af26a810 Allow colons in Docker config auth passwords (#1370) 2026-10-13 12:18:52 +00:00
16 changed files with 375 additions and 47 deletions
+2
View File
@@ -9,6 +9,7 @@ fileprivate struct VMInfo: Encodable {
let DiskFormat: String
let Size: HumanReadableByteCount
let Display: String
let MACAddress: String
let Running: Bool
let State: String
}
@@ -38,6 +39,7 @@ struct Get: AsyncParsableCommand {
String(format: "%.3f", Float($0) / 1000 / 1000 / 1000)
},
Display: vmConfig.display.description,
MACAddress: vmConfig.macAddress.string,
Running: try vmDir.running(),
State: try vmDir.state().rawValue
)
+37 -21
View File
@@ -38,31 +38,47 @@ struct List: AsyncParsableCommand {
var infos: [VMInfo] = []
if source == nil || source == "local" {
infos += sortedInfos(try VMStorageLocal().list().map { (name, vmDir) in
try VMInfo(
Source: "local",
Name: name,
// ASIF capacity lookup can fail while a running VM holds the disk open.
Disk: HumanReadableByteCount(try? vmDir.diskSizeBytes()) { $0 / 1000 / 1000 / 1000 },
Size: HumanReadableByteCount(try vmDir.allocatedSizeBytes()) { $0 / 1000 / 1000 / 1000 },
Accessed: formatAccessDate(try vmDir.accessDate()),
Running: vmDir.running(),
State: vmDir.state().rawValue
)
infos += sortedInfos(try VMStorageLocal().list().compactMap { (name, vmDir) -> VMInfo? in
do {
return try VMInfo(
Source: "local",
Name: name,
// ASIF capacity lookup can fail while a running VM holds the disk open.
Disk: HumanReadableByteCount(try? vmDir.diskSizeBytes()) { $0 / 1000 / 1000 / 1000 },
Size: HumanReadableByteCount(try vmDir.allocatedSizeBytes()) { $0 / 1000 / 1000 / 1000 },
Accessed: formatAccessDate(try vmDir.accessDate()),
Running: vmDir.running(),
State: vmDir.state().rawValue
)
} catch {
// A VM can disappear after storage enumeration when another process deletes it.
if error.isFileNotFound() {
return nil
}
throw error
}
})
}
if source == nil || source == "oci" {
infos += sortedInfos(try VMStorageOCI().list().map { (name, vmDir, _) in
try VMInfo(
Source: "OCI",
Name: name,
Disk: HumanReadableByteCount(try? vmDir.diskSizeBytes()) { $0 / 1000 / 1000 / 1000 },
Size: HumanReadableByteCount(try vmDir.allocatedSizeBytes()) { $0 / 1000 / 1000 / 1000 },
Accessed: formatAccessDate(try vmDir.accessDate()),
Running: vmDir.running(),
State: vmDir.state().rawValue
)
infos += sortedInfos(try VMStorageOCI().list().compactMap { (name, vmDir, _) -> VMInfo? in
do {
return try VMInfo(
Source: "OCI",
Name: name,
Disk: HumanReadableByteCount(try? vmDir.diskSizeBytes()) { $0 / 1000 / 1000 / 1000 },
Size: HumanReadableByteCount(try vmDir.allocatedSizeBytes()) { $0 / 1000 / 1000 / 1000 },
Accessed: formatAccessDate(try vmDir.accessDate()),
Running: vmDir.running(),
State: vmDir.state().rawValue
)
} catch {
// A VM can disappear after storage enumeration when another process deletes it.
if error.isFileNotFound() {
return nil
}
throw error
}
})
}
+17 -1
View File
@@ -187,6 +187,17 @@ struct Run: AsyncParsableCommand {
""", valueName: "interface name"))
var netBridged: [String] = []
@Option(help: ArgumentHelp("Use a connected datagram Unix domain socket inherited from the parent process for networking (e.g. --net-fd=3)",
discussion: """
The socket carries raw Ethernet frames, one per datagram.
The simplest use-case is composing Tart and Softnet invocations that are orchestrated by another program, such as Orchard.
Another way to use this argument is to connect a VM to any other networking implementation such as gVisor or software-defined networking implementation.
""",
valueName: "file descriptor"))
var netFd: Int32?
@Flag(help: ArgumentHelp("Use software networking provided by Softnet instead of the default shared (NAT) networking",
discussion: """
Softnet provides better network isolation and alleviates DHCP shortage on production systems. Tart invokes Softnet when this option is specified as a sub-process and communicates with it over socketpair(2).
@@ -332,9 +343,10 @@ struct Run: AsyncParsableCommand {
if netBridged.count > 0 { netFlags += 1 }
if netSoftnet { netFlags += 1 }
if netHost { netFlags += 1 }
if netFd != nil { netFlags += 1 }
if netFlags > 1 {
throw ValidationError("--net-bridged, --net-softnet and --net-host are mutually exclusive")
throw ValidationError("--net-bridged, --net-softnet, --net-host and --net-fd are mutually exclusive")
}
if graphics && noGraphics {
@@ -725,6 +737,10 @@ struct Run: AsyncParsableCommand {
return NetworkBridged(interfaces: try netBridged.map { try findBridgedInterface($0) })
}
if let netFd = netFd {
return try NetworkFD(fd: netFd)
}
return nil
}
+18 -1
View File
@@ -20,6 +20,9 @@ struct Set: AsyncParsableCommand {
@Flag(inversion: .prefixedNo, help: ArgumentHelp("Whether to automatically reconfigure the VM's display to fit the window"))
var displayRefit: Bool? = nil
@Option(help: ArgumentHelp("MAC address to use."))
var mac: String?
@Flag(help: ArgumentHelp("Generate a new random MAC address for the VM."))
var randomMAC: Bool = false
@@ -47,6 +50,10 @@ struct Set: AsyncParsableCommand {
throw ValidationError("--disk is not supported for VMs with a stacked disk")
}
if randomMAC && mac != nil {
throw ValidationError("--random-mac and --mac are mutually exclusive")
}
var vmConfig = try VMConfig(fromURL: vmDir.configURL)
if let cpu = cpu {
@@ -67,7 +74,17 @@ struct Set: AsyncParsableCommand {
vmConfig.display.unit = display.unit
}
vmConfig.displayRefit = displayRefit
if let displayRefit = displayRefit {
vmConfig.displayRefit = displayRefit
}
if let mac = mac {
if let macAddress = VZMACAddress.init(string: mac), macAddress.isUnicastAddress {
vmConfig.macAddress = macAddress
} else {
throw ValidationError("'\(mac)' is not a valid unicast MAC address")
}
}
if randomMAC {
vmConfig.macAddress = VZMACAddress.randomLocallyAdministered()
@@ -104,13 +104,13 @@ struct DockerAuthConfig: Codable {
guard let data = Data(base64Encoded: authBase64) else {
return nil
}
guard let components = String(data: data, encoding: .utf8)?.components(separatedBy: ":") else {
guard let components = String(data: data, encoding: .utf8)?.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false) else {
return nil
}
if components.count != 2 {
return nil
}
return (components[0], components[1])
return (String(components[0]), String(components[1]))
}
}
+5 -2
View File
@@ -14,8 +14,11 @@ func withGuestAgentChannel<T>(
let channel = try GRPCChannelPool.with(
target: .unixDomainSocket(socketPath),
transportSecurity: .plaintext,
eventLoopGroup: .singletonMultiThreadedEventLoopGroup,
)
eventLoopGroup: .singletonMultiThreadedEventLoopGroup
) { configuration in
// Limit guest output in flight to work around VirtIO socket device implementation data loss
configuration.http2.targetWindowSize = 64 * 1024
}
do {
let result = try await body(channel)
@@ -41,9 +41,9 @@ struct ARPCacheInternalError: Error, CustomStringConvertible {
struct ARPCache {
let arpCommandOutput: Data
init() throws {
init(arpCommandURL: URL = URL(fileURLWithPath: "/usr/sbin/arp")) throws {
let process = Process.init()
process.executableURL = URL.init(fileURLWithPath: "/usr/sbin/arp")
process.executableURL = arpCommandURL
process.arguments = ["-an"]
let pipe = Pipe()
@@ -53,9 +53,7 @@ struct ARPCache {
try process.run()
guard let arpCommandOutput = try pipe.fileHandleForReading.readToEnd() else {
throw ARPCommandYieldedInvalidOutputError(explanation: "empty output")
}
let arpCommandOutput = try pipe.fileHandleForReading.readToEnd() ?? Data()
process.waitUntilExit()
@@ -69,9 +67,12 @@ struct ARPCache {
}
func ResolveMACAddress(macAddress: MACAddress) throws -> IPv4Address? {
let lines = String(decoding: arpCommandOutput, as: UTF8.self)
let output = String(decoding: arpCommandOutput, as: UTF8.self)
.trimmingCharacters(in: .whitespacesAndNewlines)
.components(separatedBy: "\n")
if output.isEmpty {
return nil
}
let lines = output.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>.*) .*$"#)
+37
View File
@@ -0,0 +1,37 @@
import Darwin
import Foundation
import Semaphore
import System
import Virtualization
class NetworkFD: Network {
private let attachment: VZFileHandleNetworkDeviceAttachment
init(fd: Int32) throws {
do {
try Softnet.validateControlFD(fd, type: SOCK_DGRAM)
} catch {
throw RuntimeError.Generic("--net-fd \(error)")
}
let flags = fcntl(fd, F_GETFD)
guard flags != -1 && fcntl(fd, F_SETFD, flags | FD_CLOEXEC) != -1 else {
throw RuntimeError.Generic("failed to set close-on-exec on --net-fd: \(Errno(rawValue: errno))")
}
let fileHandle = FileHandle(fileDescriptor: fd, closeOnDealloc: true)
attachment = VZFileHandleNetworkDeviceAttachment(fileHandle: fileHandle)
}
func attachments() -> [VZNetworkDeviceAttachment] {
[attachment]
}
func run(_ sema: AsyncSemaphore) throws {
// no-op, only used for Softnet
}
func stop() async throws {
// no-op, only used for Softnet
}
}
+11 -11
View File
@@ -24,7 +24,11 @@ class Softnet: Network {
}
controlFileHandle = FileHandle(fileDescriptor: controlFD, closeOnDealloc: true)
try Self.validateControlFD(controlFD)
do {
try Self.validateControlFD(controlFD)
} catch {
throw SoftnetError.InitializationFailed(why: "Softnet control file descriptor \(error)")
}
}
let fds = UnsafeMutablePointer<Int32>.allocate(capacity: MemoryLayout<Int>.stride * 2)
@@ -50,20 +54,16 @@ class Softnet: Network {
}
}
static func validateControlFD(_ fd: Int32) throws {
guard fd > STDERR_FILENO else {
throw SoftnetError.InitializationFailed(why: "Softnet control file descriptor must be greater than 2")
}
static func validateControlFD(_ fd: Int32, type: Int32 = SOCK_STREAM) throws {
var socketType: Int32 = 0
var socketTypeLength = socklen_t(MemoryLayout<Int32>.size)
guard getsockopt(fd, SOL_SOCKET, SO_TYPE, &socketType, &socketTypeLength) == 0 else {
let details = Errno(rawValue: CInt(errno))
throw SoftnetError.InitializationFailed(why: "Softnet control file descriptor is not a socket: \(details)")
throw RuntimeError.Generic("is not a socket: \(details)")
}
guard socketType == SOCK_STREAM else {
throw SoftnetError.InitializationFailed(why: "Softnet control file descriptor must be a Unix stream socket")
guard socketType == type else {
throw RuntimeError.Generic("must be a \(type == SOCK_STREAM ? "stream" : "datagram") socket")
}
var peerAddress = sockaddr_storage()
@@ -75,11 +75,11 @@ class Softnet: Network {
}
guard result == 0 else {
let details = Errno(rawValue: CInt(errno))
throw SoftnetError.InitializationFailed(why: "Softnet control file descriptor is not connected: \(details)")
throw RuntimeError.Generic("is not connected: \(details)")
}
guard peerAddress.ss_family == sa_family_t(AF_UNIX) else {
throw SoftnetError.InitializationFailed(why: "Softnet control file descriptor must be a Unix stream socket")
throw RuntimeError.Generic("must be a Unix socket")
}
}
+15
View File
@@ -226,11 +226,26 @@ struct OCIConfig: Codable {
var architecture: Architecture = .arm64
var os: OS = .darwin
var config: ConfigContainer?
// Required by the OCI image spec and enforced by registries that validate
// image configs (e.g. Quay). Optional so that configs pushed by older Tart
// versions, which lack it, can still be decoded.
var rootfs: RootFS? = RootFS()
struct ConfigContainer: Codable {
var Labels: [String: String]?
}
struct RootFS: Codable {
var type: String = "layers"
// Tart layers are not filesystem tarballs, so there are no DiffIDs to list
var diffIDs: [String] = []
enum CodingKeys: String, CodingKey {
case type
case diffIDs = "diff_ids"
}
}
func toJSON() throws -> Data {
try Config.jsonEncoder().encode(self)
}
+4 -1
View File
@@ -69,12 +69,15 @@ struct TokenResponse: Decodable, Authentication {
let dateFormatter = ISO8601DateFormatter()
dateFormatter.formatOptions = [.withInternetDateTime]
dateFormatter.timeZone = TimeZone(secondsFromGMT: 0)
let fractionalDateFormatter = ISO8601DateFormatter()
fractionalDateFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
fractionalDateFormatter.timeZone = TimeZone(secondsFromGMT: 0)
decoder.dateDecodingStrategy = .custom { decoder in
let container = try decoder.singleValueContainer()
let dateString = try container.decode(String.self)
return dateFormatter.date(from: dateString) ?? Date()
return dateFormatter.date(from: dateString) ?? fractionalDateFormatter.date(from: dateString) ?? Date()
}
var response = try decoder.decode(TokenResponse.self, from: fromData)
+71
View File
@@ -0,0 +1,71 @@
import XCTest
import Foundation
import Network
@testable import tart
final class ARPCacheTests: XCTestCase {
private let mac = MACAddress(fromString: "00:11:22:33:44:55")!
private func cache(_ output: String, exitCode: Int = 0) throws -> ARPCache {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: directory) }
let command = directory.appendingPathComponent("arp-fixture")
let quoted = output.replacingOccurrences(of: "'", with: "'\"'\"'")
try "#!/bin/sh\nprintf '%s' '\(quoted)'\nexit \(exitCode)\n".write(to: command, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: command.path)
return try ARPCache(arpCommandURL: command)
}
func testEmptySuccessfulOutputHasNoAddress() throws {
XCTAssertNil(try cache("").ResolveMACAddress(macAddress: mac))
}
func testWhitespaceSuccessfulOutputHasNoAddress() throws {
for output in ["\n", " \t\r\n", "\n\n"] {
XCTAssertNil(try cache(output).ResolveMACAddress(macAddress: mac))
}
}
func testEmptyOutputPreservesCommandFailure() throws {
XCTAssertThrowsError(try cache("", exitCode: 7)) { error in
XCTAssertEqual((error as? ARPCommandFailedError)?.terminationStatus, 7)
}
}
func testNonemptyOutputPreservesCommandFailure() throws {
XCTAssertThrowsError(try cache("synthetic failure", exitCode: 9)) { error in
XCTAssertEqual((error as? ARPCommandFailedError)?.terminationStatus, 9)
}
}
func testValidEntryResolvesAddress() throws {
let value = try cache("? (192.0.2.8) at 0:11:22:33:44:55 on en0 ifscope [ethernet]\n")
XCTAssertEqual(try value.ResolveMACAddress(macAddress: mac), IPv4Address("192.0.2.8"))
}
func testIncompleteAndOtherEntriesDoNotMatch() throws {
let value = try cache("""
? (192.0.2.9) at (incomplete) on en0 ifscope [ethernet]
? (192.0.2.10) at aa:bb:cc:dd:ee:ff on en0 ifscope [ethernet]
""")
XCTAssertNil(try value.ResolveMACAddress(macAddress: mac))
}
func testMalformedNonemptyOutputStillFails() throws {
XCTAssertThrowsError(try cache("synthetic invalid entry").ResolveMACAddress(macAddress: mac)) {
XCTAssertTrue($0 is ARPCommandYieldedInvalidOutputError)
}
}
func testInvalidAddressesStillFail() throws {
for line in [
"? (not-an-ip) at 0:11:22:33:44:55 on en0 ifscope [ethernet]",
"? (192.0.2.8) at not-a-mac on en0 ifscope [ethernet]",
] {
XCTAssertThrowsError(try cache(line).ResolveMACAddress(macAddress: mac)) {
XCTAssertTrue($0 is ARPCommandYieldedInvalidOutputError)
}
}
}
}
+97 -1
View File
@@ -55,6 +55,45 @@ final class CommandBehaviorTests: XCTestCase {
}
}
func testListSkipsLocalVMsDeletedDuringMetadataLookup() async throws {
try await assertListSurvivesDeletion(sourceArguments: ["--source", "local"])
}
func testListSkipsOCIVMsDeletedDuringMetadataLookup() async throws {
try await assertListSurvivesDeletion(sourceArguments: ["--source", "oci"])
}
func testListSkipsVMsDeletedDuringMetadataLookupAcrossSources() async throws {
try await assertListSurvivesDeletion(sourceArguments: [])
}
func testListPreservesMetadataPermissionErrors() async throws {
for source in ["local", "oci"] {
try await withTemporaryTartHome {
let vmDir = try source == "local" ? VMStorageLocal().create("inaccessible") :
VMStorageOCI().create(RemoteName("example.com/org/inaccessible:latest"))
var vmConfig = config()
vmConfig.diskFormat = .asif
try vmConfig.save(toURL: vmDir.configURL)
XCTAssertTrue(FileManager.default.createFile(atPath: vmDir.nvramURL.path, contents: Data()))
XCTAssertTrue(FileManager.default.createFile(atPath: vmDir.diskURL.path, contents: Data()))
defer {
try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: vmDir.baseURL.path)
}
let previousPath = try installUnavailableDiskutil(beforeFailure: "/bin/chmod 000 \"${4%/*}\"")
defer { restoreEnvironment("PATH", to: previousPath) }
do {
_ = try await commandOutput(List.self, ["--source", source, "--format", "json"])
XCTFail("list should report metadata permission errors")
} catch {
XCTAssertEqual((error as NSError).domain, NSCocoaErrorDomain)
XCTAssertEqual((error as NSError).code, NSFileReadNoPermissionError)
}
}
}
}
func testGetSurvivesUnavailableDiskCapacity() async throws {
try await withTemporaryTartHome {
let previousPath = try installUnavailableDiskutil()
@@ -206,6 +245,22 @@ final class CommandBehaviorTests: XCTestCase {
}
}
func testSetPreservesDisplayRefitWhenChangingOtherOptions() async throws {
try await withTemporaryTartHome {
let vmDir = try makeStandaloneVM(named: "display-refit", diskContents: "disk")
var vmConfig = try VMConfig(fromURL: vmDir.configURL)
vmConfig.displayRefit = false
try vmConfig.save(toURL: vmDir.configURL)
let command = try Set.parseAsRoot(["display-refit", "--cpu", "4"]) as! Set
try await command.run()
let updatedConfig = try VMConfig(fromURL: vmDir.configURL)
XCTAssertEqual(updatedConfig.displayRefit, false)
XCTAssertEqual(updatedConfig.cpuCount, 4)
}
}
func testRemoteAdditionalDiskRetainsTemporaryBackingFileLock() throws {
try withTemporaryTartHome {
let storage = try VMStorageOCI()
@@ -253,6 +308,46 @@ final class CommandBehaviorTests: XCTestCase {
}
}
private func assertListSurvivesDeletion(sourceArguments: [String]) async throws {
let digest = "sha256:" + String(repeating: "a", count: 64)
for outputArguments in [["--format", "json"], [], ["--quiet"]] {
try await withTemporaryTartHome {
// diskutil runs after enumeration, making the deletion race deterministic.
let previousPath = try installUnavailableDiskutil(beforeFailure: "/bin/rm -rf \"${4%/*}\"")
defer { restoreEnvironment("PATH", to: previousPath) }
let local = try VMStorageLocal()
let oci = try VMStorageOCI()
for (name, diskFormat) in [("disappearing", DiskImageFormat.asif), ("healthy", .raw)] {
let remoteName = try RemoteName("example.com/org/\(name)@\(digest)")
for vmDir in [try local.create(name), try oci.create(remoteName)] {
var vmConfig = config()
vmConfig.diskFormat = diskFormat
try vmConfig.save(toURL: vmDir.configURL)
XCTAssertTrue(FileManager.default.createFile(atPath: vmDir.nvramURL.path, contents: Data()))
XCTAssertTrue(FileManager.default.createFile(atPath: vmDir.diskURL.path, contents: Data()))
}
}
let arguments = sourceArguments + outputArguments
let output = try await commandOutput(List.self, arguments)
let expectedNames = sourceArguments.isEmpty ? ["healthy", "example.com/org/healthy@\(digest)"] :
[sourceArguments.last == "local" ? "healthy" : "example.com/org/healthy@\(digest)"]
if outputArguments == ["--format", "json"] {
let rows = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(output.utf8)) as? [[String: Any]])
XCTAssertEqual(rows.compactMap { $0["Name"] as? String }, expectedNames)
} else if outputArguments == ["--quiet"] {
XCTAssertEqual(output.split(separator: "\n").map(String.init), expectedNames)
} else {
for name in expectedNames {
XCTAssertTrue(output.contains(name))
}
XCTAssertFalse(output.contains("disappearing"))
}
}
}
}
private func config() -> VMConfig {
VMConfig(
platform: Linux(),
@@ -277,11 +372,12 @@ final class CommandBehaviorTests: XCTestCase {
)
}
private func installUnavailableDiskutil() throws -> String? {
private func installUnavailableDiskutil(beforeFailure: String = "") throws -> String? {
let binDirectory = try temporaryDirectory()
let diskutilURL = binDirectory.appendingPathComponent("diskutil")
let script = """
#!/bin/sh
\(beforeFailure)
echo 'Resource temporarily unavailable' >&2
exit 1
"""
+8
View File
@@ -13,4 +13,12 @@ final class DockerConfigTests: XCTestCase {
XCTAssertEqual(try config.findCredHelper(host: "456.dkr.ecr.us-east-1.amazonaws.com"), "ecr-login")
XCTAssertNil(try config.findCredHelper(host: "ghcr.io"))
}
func testAuthPasswordWithColons() throws {
let auth = Data("user:token:with:colons".utf8).base64EncodedString()
let credentials = try XCTUnwrap(DockerAuthConfig(auth: auth).decodeCredentials())
XCTAssertEqual(credentials.0, "user")
XCTAssertEqual(credentials.1, "token:with:colons")
}
}
+32
View File
@@ -154,6 +154,38 @@ final class OCIManifestTests: XCTestCase {
XCTAssertNil(manifest.diskBlockCount())
}
func testOCIConfigIncludesRootFS() throws {
let config = OCIConfig(architecture: .arm64, os: .darwin, config: .init(Labels: [diskFormatLabel: "raw"]))
let json = try JSONSerialization.jsonObject(with: config.toJSON()) as! [String: Any]
let rootfs = try XCTUnwrap(json["rootfs"] as? [String: Any])
XCTAssertEqual(rootfs["type"] as? String, "layers")
XCTAssertEqual(rootfs["diff_ids"] as? [String], [])
XCTAssertEqual(
String(decoding: try config.toJSON(), as: UTF8.self),
#"{"architecture":"arm64","config":{"Labels":{"org.cirruslabs.tart.disk.format":"raw"}},"os":"darwin","rootfs":{"diff_ids":[],"type":"layers"}}"#
)
}
func testOCIConfigWithoutRootFSDecodes() throws {
// OCI config as pushed by Tart 2.40.1 and earlier
let json = #"{"architecture":"arm64","config":{"Labels":{"org.cirruslabs.tart.disk.format":"raw"}},"os":"linux"}"#
let config = try Config.jsonDecoder().decode(OCIConfig.self, from: Data(json.utf8))
XCTAssertEqual(config.architecture, .arm64)
XCTAssertEqual(config.os, .linux)
XCTAssertEqual(config.config?.Labels, [diskFormatLabel: "raw"])
XCTAssertNil(config.rootfs)
}
func testOCIConfigRootFSRoundTrips() throws {
let config = try Config.jsonDecoder().decode(OCIConfig.self, from: OCIConfig().toJSON())
XCTAssertEqual(config.rootfs?.type, "layers")
XCTAssertEqual(config.rootfs?.diffIDs, [])
}
private func assertManifestError(_ expected: OCIManifestValidationError, diskDescriptors: [OCIManifestLayer]) {
XCTAssertThrowsError(try manifest(diskDescriptors: diskDescriptors).tartDiskRepresentation()) { error in
XCTAssertEqual(error as? OCIManifestValidationError, expected)
+11
View File
@@ -2,6 +2,17 @@ import XCTest
@testable import tart
final class TokenResponseTests: XCTestCase {
func testFractionalIssuedAt() throws {
for timestamp in ["1970-01-01T00:00:00.125Z", "1970-01-01T01:30:00.125+01:30", "1970-01-01T00:00:00.125000000Z"] {
let data = Data("{\"token\":\"test-token\",\"expires_in\":3600,\"issued_at\":\"\(timestamp)\"}".utf8)
let response = try TokenResponse.parse(fromData: data)
XCTAssertEqual(response.issuedAt!.timeIntervalSince1970, 0.125, accuracy: 0.0001)
XCTAssertEqual(response.tokenExpiresAt.timeIntervalSince1970, 3600.125, accuracy: 0.0001)
XCTAssertFalse(response.isValid())
}
}
func testBasic() throws {
var expectedTokenExpiresAtRange = DateInterval()
let tokenResponseRaw = Data("{\"token\":\"some token\"}".utf8)