diff --git a/Sources/ContainerizationOCI/Spec.swift b/Sources/ContainerizationOCI/Spec.swift index cbad7bd9..3694b1e4 100644 --- a/Sources/ContainerizationOCI/Spec.swift +++ b/Sources/ContainerizationOCI/Spec.swift @@ -61,6 +61,27 @@ public struct Spec: Codable, Sendable { case root case linux } + + public init(from decoder: Decoder) throws { + self.init() + + let container = try decoder.container(keyedBy: CodingKeys.self) + self.version = try container.decode(String.self, forKey: .version) + self.hooks = try container.decodeIfPresent(Hook.self, forKey: .hooks) + self.process = try container.decodeIfPresent(Process.self, forKey: .process) + if let hostname = try container.decodeIfPresent(String.self, forKey: .hostname) { + self.hostname = hostname + } + if let domainname = try container.decodeIfPresent(String.self, forKey: .domainname) { + self.domainname = domainname + } + if let mounts = try container.decodeIfPresent([Mount].self, forKey: .mounts) { + self.mounts = mounts + } + self.annotations = try container.decodeIfPresent([String: String].self, forKey: .annotations) + self.root = try container.decodeIfPresent(Root.self, forKey: .root) + self.linux = try container.decodeIfPresent(Linux.self, forKey: .linux) + } } public struct Process: Codable, Sendable { @@ -78,6 +99,22 @@ public struct Process: Codable, Sendable { public var args: [String] public var terminal: Bool + public enum CodingKeys: String, CodingKey { + case cwd + case env + case consoleSize + case selinuxLabel + case noNewPrivileges + case commandLine + case oomScoreAdj + case capabilities + case apparmorProfile + case user + case rlimits + case args + case terminal + } + public init( args: [String] = [], cwd: String = "/", @@ -120,6 +157,40 @@ public struct Process: Codable, Sendable { }() self.init(args: args, cwd: cwd, env: env, user: user) } + public init(from decoder: Decoder) throws { + self.init() + + let container = try decoder.container(keyedBy: CodingKeys.self) + self.cwd = try container.decode(String.self, forKey: .cwd) + if let env = try container.decodeIfPresent([String].self, forKey: .env) { + self.env = env + } + self.consoleSize = try container.decodeIfPresent(Box.self, forKey: .consoleSize) + if let selinuxLabel = try container.decodeIfPresent(String.self, forKey: .selinuxLabel) { + self.selinuxLabel = selinuxLabel + } + if let noNewPrivileges = try container.decodeIfPresent(Bool.self, forKey: .noNewPrivileges) { + self.noNewPrivileges = noNewPrivileges + } + if let commandLine = try container.decodeIfPresent(String.self, forKey: .commandLine) { + self.commandLine = commandLine + } + self.oomScoreAdj = try container.decodeIfPresent(Int.self, forKey: .oomScoreAdj) + self.capabilities = try container.decodeIfPresent(LinuxCapabilities.self, forKey: .capabilities) + if let apparmorProfile = try container.decodeIfPresent(String.self, forKey: .apparmorProfile) { + self.apparmorProfile = apparmorProfile + } + self.user = try container.decode(User.self, forKey: .user) + if let rlimits = try container.decodeIfPresent([POSIXRlimit].self, forKey: .rlimits) { + self.rlimits = rlimits + } + if let args = try container.decodeIfPresent([String].self, forKey: .args) { + self.args = args + } + if let terminal = try container.decodeIfPresent(Bool.self, forKey: .terminal) { + self.terminal = terminal + } + } } public struct LinuxCapabilities: Codable, Sendable { @@ -160,6 +231,14 @@ public struct User: Codable, Sendable { public var additionalGids: [UInt32] public var username: String + public enum CodingKeys: String, CodingKey { + case uid + case gid + case umask + case additionalGids + case username + } + public init( uid: UInt32 = 0, gid: UInt32 = 0, @@ -173,16 +252,42 @@ public struct User: Codable, Sendable { self.additionalGids = additionalGids self.username = username } + + public init(from decoder: Decoder) throws { + self.init() + + let container = try decoder.container(keyedBy: CodingKeys.self) + self.uid = try container.decode(UInt32.self, forKey: .uid) + self.gid = try container.decode(UInt32.self, forKey: .gid) + self.umask = try container.decodeIfPresent(UInt32.self, forKey: .umask) + if let additionalGids = try container.decodeIfPresent([UInt32].self, forKey: .additionalGids) { + self.additionalGids = additionalGids + } + if let username = try container.decodeIfPresent(String.self, forKey: .username) { + self.username = username + } + } } public struct Root: Codable, Sendable { public var path: String public var readonly: Bool + public enum CodingKeys: String, CodingKey { + case path + case readonly + } + public init(path: String, readonly: Bool) { self.path = path self.readonly = readonly } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.path = try container.decode(String.self, forKey: .path) + self.readonly = try container.decodeIfPresent(Bool.self, forKey: .readonly) ?? false + } } public struct Mount: Codable, Sendable { @@ -194,9 +299,18 @@ public struct Mount: Codable, Sendable { public var uidMappings: [LinuxIDMapping] public var gidMappings: [LinuxIDMapping] + public enum CodingKeys: String, CodingKey { + case type + case source + case destination + case options + case uidMappings + case gidMappings + } + public init( - type: String, - source: String, + type: String = "", + source: String = "", destination: String, options: [String] = [], uidMappings: [LinuxIDMapping] = [], @@ -209,6 +323,16 @@ public struct Mount: Codable, Sendable { self.uidMappings = uidMappings self.gidMappings = gidMappings } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.type = try container.decodeIfPresent(String.self, forKey: .type) ?? "" + self.source = try container.decodeIfPresent(String.self, forKey: .source) ?? "" + self.destination = try container.decode(String.self, forKey: .destination) + self.options = try container.decodeIfPresent([String].self, forKey: .options) ?? [] + self.uidMappings = try container.decodeIfPresent([LinuxIDMapping].self, forKey: .uidMappings) ?? [] + self.gidMappings = try container.decodeIfPresent([LinuxIDMapping].self, forKey: .gidMappings) ?? [] + } } public struct Hook: Codable, Sendable { diff --git a/Tests/ContainerizationOCITests/OCISpecTests.swift b/Tests/ContainerizationOCITests/OCISpecTests.swift new file mode 100644 index 00000000..80145349 --- /dev/null +++ b/Tests/ContainerizationOCITests/OCISpecTests.swift @@ -0,0 +1,146 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the Containerization project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import Foundation +import Testing + +@testable import ContainerizationOCI + +struct OCISpecTests { + @Test func minimalSpecDecode() throws { + let version = "1.2.3" + let minSpec = + """ + { + "ociVersion": "\(version)" + } + """ + + guard let data = minSpec.data(using: .utf8) else { + Issue.record("test spec is not valid: \(minSpec)") + return + } + + let decodedSpec = try JSONDecoder().decode(ContainerizationOCI.Spec.self, from: data) + #expect(decodedSpec.version == version) + #expect(decodedSpec.hooks == nil) + #expect(decodedSpec.process == nil) + #expect(decodedSpec.hostname == "") + #expect(decodedSpec.domainname == "") + #expect(decodedSpec.mounts.isEmpty) + #expect(decodedSpec.annotations == nil) + #expect(decodedSpec.root == nil) + #expect(decodedSpec.linux == nil) + } + + @Test func minimalProcessSpecDecode() throws { + let cwd = "/test" + let minProcessSpec = + """ + { + "cwd": "\(cwd)", + "user": { + "uid": 10, + "gid": 11 + } + } + """ + + guard let data = minProcessSpec.data(using: .utf8) else { + Issue.record("test process spec is not valid: \(minProcessSpec)") + return + } + + let decodedSpec = try JSONDecoder().decode(ContainerizationOCI.Process.self, from: data) + #expect(decodedSpec.cwd == cwd) + #expect(decodedSpec.env.isEmpty) + #expect(decodedSpec.consoleSize == nil) + #expect(decodedSpec.selinuxLabel == "") + #expect(decodedSpec.noNewPrivileges == false) + #expect(decodedSpec.commandLine == "") + #expect(decodedSpec.oomScoreAdj == nil) + #expect(decodedSpec.capabilities == nil) + #expect(decodedSpec.apparmorProfile == "") + #expect(decodedSpec.user.uid == 10) + #expect(decodedSpec.user.gid == 11) + #expect(decodedSpec.rlimits.isEmpty) + #expect(decodedSpec.terminal == false) + } + + @Test func minimalUserSpecDecode() throws { + let minUserSpec = + """ + { + "uid": 10, + "gid": 11 + } + """ + + guard let data = minUserSpec.data(using: .utf8) else { + Issue.record("test user spec is not valid: \(minUserSpec)") + return + } + + let decodedSpec = try JSONDecoder().decode(ContainerizationOCI.User.self, from: data) + #expect(decodedSpec.uid == 10) + #expect(decodedSpec.gid == 11) + #expect(decodedSpec.umask == nil) + #expect(decodedSpec.additionalGids.isEmpty) + #expect(decodedSpec.username == "") + } + + @Test func minimalRootSpecDecode() throws { + let path = "/testpath" + let minRootSpec = + """ + { + "path": "\(path)" + } + """ + + guard let data = minRootSpec.data(using: .utf8) else { + Issue.record("test root spec is not valid: \(minRootSpec)") + return + } + + let decodedSpec = try JSONDecoder().decode(ContainerizationOCI.Root.self, from: data) + #expect(decodedSpec.path == path) + #expect(decodedSpec.readonly == false) + } + + @Test func minimalMountSpecDecode() throws { + let destination = "/testdest" + let minMountSpec = + """ + { + "destination": "\(destination)" + } + """ + + guard let data = minMountSpec.data(using: .utf8) else { + Issue.record("test mount spec is not valid: \(minMountSpec)") + return + } + + let decodedSpec = try JSONDecoder().decode(ContainerizationOCI.Mount.self, from: data) + #expect(decodedSpec.type == "") + #expect(decodedSpec.source == "") + #expect(decodedSpec.destination == destination) + #expect(decodedSpec.options.isEmpty) + #expect(decodedSpec.uidMappings.isEmpty) + #expect(decodedSpec.gidMappings.isEmpty) + } +}