From 690943349250f9316e0e18f4fa7b5d7bfe925588 Mon Sep 17 00:00:00 2001 From: Mikimoto Date: Tue, 1 Sep 2026 07:49:47 +0000 Subject: [PATCH 1/6] feat(up): support cap_add and cap_drop --- .../Codable Structs/Service.swift | 14 ++++- .../Commands/ComposeUp.swift | 23 ++++++++ .../HardeningArgsTests.swift | 54 +++++++++++++++++++ 3 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 Tests/Container-Compose-StaticTests/HardeningArgsTests.swift diff --git a/Sources/Container-Compose/Codable Structs/Service.swift b/Sources/Container-Compose/Codable Structs/Service.swift index 78fcd73..2d55a05 100644 --- a/Sources/Container-Compose/Codable Structs/Service.swift +++ b/Sources/Container-Compose/Codable Structs/Service.swift @@ -92,6 +92,12 @@ public struct Service: Codable, Hashable { /// Mount container's root filesystem as read-only public let read_only: Bool? + /// Linux capabilities to add, e.g. `NET_BIND_SERVICE` + public let cap_add: [String]? + + /// Linux capabilities to drop, e.g. `ALL` + public let cap_drop: [String]? + /// Working directory inside the container public let working_dir: String? @@ -133,7 +139,7 @@ public struct Service: Codable, Hashable { enum CodingKeys: String, CodingKey { case image, build, deploy, restart, healthcheck, volumes, environment, env_file, ports, command, depends_on, user, container_name, labels, networks, hostname, entrypoint, privileged, read_only, working_dir, configs, secrets, stdin_open, tty, platform, - mem_limit, extra_hosts, profiles + mem_limit, extra_hosts, profiles, cap_add, cap_drop } /// Public memberwise initializer for testing @@ -159,6 +165,8 @@ public struct Service: Codable, Hashable { entrypoint: [String]? = nil, privileged: Bool? = nil, read_only: Bool? = nil, + cap_add: [String]? = nil, + cap_drop: [String]? = nil, working_dir: String? = nil, platform: String? = nil, configs: [ServiceConfig]? = nil, @@ -191,6 +199,8 @@ public struct Service: Codable, Hashable { self.entrypoint = entrypoint self.privileged = privileged self.read_only = read_only + self.cap_add = cap_add + self.cap_drop = cap_drop self.working_dir = working_dir self.platform = platform self.configs = configs @@ -315,6 +325,8 @@ public struct Service: Codable, Hashable { privileged = try container.decodeIfPresent(Bool.self, forKey: .privileged) read_only = try container.decodeIfPresent(Bool.self, forKey: .read_only) + cap_add = try container.decodeIfPresent([String].self, forKey: .cap_add) + cap_drop = try container.decodeIfPresent([String].self, forKey: .cap_drop) working_dir = try container.decodeIfPresent(String.self, forKey: .working_dir) configs = try container.decodeIfPresent([ServiceConfig].self, forKey: .configs) secrets = try container.decodeIfPresent([ServiceSecret].self, forKey: .secrets) diff --git a/Sources/Container-Compose/Commands/ComposeUp.swift b/Sources/Container-Compose/Commands/ComposeUp.swift index d42efd7..189de09 100644 --- a/Sources/Container-Compose/Commands/ComposeUp.swift +++ b/Sources/Container-Compose/Commands/ComposeUp.swift @@ -486,6 +486,27 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { return (value, false) } + /// Maps the container-hardening compose keys to `container run` flags. + /// + /// Extracted as a pure function so the mapping is directly testable: the + /// surrounding argument assembly has no test seam, which is how + /// `healthcheck.timeout` stayed parsed-but-unapplied. + /// + /// `cap_drop` is emitted before `cap_add` so that `cap_drop: [ALL]` followed + /// by a narrow `cap_add` behaves as Compose specifies. + static func hardeningRunArgs(for service: Service) -> [String] { + var args: [String] = [] + + for capability in service.cap_drop ?? [] { + args.append(contentsOf: ["--cap-drop", capability]) + } + for capability in service.cap_add ?? [] { + args.append(contentsOf: ["--cap-add", capability]) + } + + return args + } + static func validateStoppedServiceExitCode(_ exitCode: Int32, serviceName: String) throws { guard exitCode == 0 else { throw ComposeError.containerRunFailed(serviceName, exitCode) @@ -1070,6 +1091,8 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { runCommandArgs.append("--read-only") } + runCommandArgs.append(contentsOf: Self.hardeningRunArgs(for: service)) + // Add resource limits. // `mem_limit` is the top-level shorthand; `deploy.resources.limits.memory` is // the structured form. Both map to `container run --memory`. `mem_limit` takes diff --git a/Tests/Container-Compose-StaticTests/HardeningArgsTests.swift b/Tests/Container-Compose-StaticTests/HardeningArgsTests.swift new file mode 100644 index 0000000..4c6480c --- /dev/null +++ b/Tests/Container-Compose-StaticTests/HardeningArgsTests.swift @@ -0,0 +1,54 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Morris Richman and the Container-Compose project authors. All rights reserved. +// +// 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 Testing +import Foundation +@testable import Yams +@testable import ContainerComposeCore + +@Suite("Hardening Run Args") +struct HardeningArgsTests { + + private func service(_ yaml: String) throws -> Service { + try YAMLDecoder().decode(Service.self, from: yaml) + } + + @Test("cap_drop and cap_add parse") + func capabilitiesParse() throws { + let svc = try service(""" + image: alpine + cap_drop: + - ALL + cap_add: + - NET_BIND_SERVICE + """) + #expect(svc.cap_drop == ["ALL"]) + #expect(svc.cap_add == ["NET_BIND_SERVICE"]) + } + + @Test("cap_drop is emitted before cap_add") + func capabilityOrder() throws { + let svc = Service(image: "alpine", cap_add: ["NET_BIND_SERVICE"], cap_drop: ["ALL"]) + let args = ComposeUp.hardeningRunArgs(for: svc) + #expect(args == ["--cap-drop", "ALL", "--cap-add", "NET_BIND_SERVICE"]) + } + + @Test("no hardening keys yields no args") + func emptyYieldsNothing() throws { + let svc = Service(image: "alpine") + #expect(ComposeUp.hardeningRunArgs(for: svc).isEmpty) + } +} From d93d3146aecdbd20277a2ecb3054e28cffa82662 Mon Sep 17 00:00:00 2001 From: Mikimoto Date: Tue, 1 Sep 2026 07:58:04 +0000 Subject: [PATCH 2/6] feat(up): support shm_size, init and ulimits --- .../Codable Structs/Service.swift | 29 ++++++++++++++++++- .../Commands/ComposeUp.swift | 11 +++++++ .../HardeningArgsTests.swift | 29 +++++++++++++++++++ 3 files changed, 68 insertions(+), 1 deletion(-) diff --git a/Sources/Container-Compose/Codable Structs/Service.swift b/Sources/Container-Compose/Codable Structs/Service.swift index 2d55a05..f401db0 100644 --- a/Sources/Container-Compose/Codable Structs/Service.swift +++ b/Sources/Container-Compose/Codable Structs/Service.swift @@ -98,6 +98,17 @@ public struct Service: Codable, Hashable { /// Linux capabilities to drop, e.g. `ALL` public let cap_drop: [String]? + /// Size of `/dev/shm`, e.g. `256m` + public let shm_size: String? + + /// Compose `init:` — run an init process that reaps zombies. + /// Named `runInit` because `init` is a Swift keyword; the wire name is + /// restored by the `init` CodingKey below. + public let runInit: Bool? + + /// Resource limits, e.g. `["nofile": "65535"]` + public let ulimits: [String: String]? + /// Working directory inside the container public let working_dir: String? @@ -139,7 +150,8 @@ public struct Service: Codable, Hashable { enum CodingKeys: String, CodingKey { case image, build, deploy, restart, healthcheck, volumes, environment, env_file, ports, command, depends_on, user, container_name, labels, networks, hostname, entrypoint, privileged, read_only, working_dir, configs, secrets, stdin_open, tty, platform, - mem_limit, extra_hosts, profiles, cap_add, cap_drop + mem_limit, extra_hosts, profiles, cap_add, cap_drop, shm_size, ulimits + case runInit = "init" } /// Public memberwise initializer for testing @@ -167,6 +179,9 @@ public struct Service: Codable, Hashable { read_only: Bool? = nil, cap_add: [String]? = nil, cap_drop: [String]? = nil, + shm_size: String? = nil, + runInit: Bool? = nil, + ulimits: [String: String]? = nil, working_dir: String? = nil, platform: String? = nil, configs: [ServiceConfig]? = nil, @@ -201,6 +216,9 @@ public struct Service: Codable, Hashable { self.read_only = read_only self.cap_add = cap_add self.cap_drop = cap_drop + self.shm_size = shm_size + self.runInit = runInit + self.ulimits = ulimits self.working_dir = working_dir self.platform = platform self.configs = configs @@ -327,6 +345,15 @@ public struct Service: Codable, Hashable { read_only = try container.decodeIfPresent(Bool.self, forKey: .read_only) cap_add = try container.decodeIfPresent([String].self, forKey: .cap_add) cap_drop = try container.decodeIfPresent([String].self, forKey: .cap_drop) + shm_size = try container.decodeIfPresent(String.self, forKey: .shm_size) + runInit = try container.decodeIfPresent(Bool.self, forKey: .runInit) + if let stringForm = try? container.decodeIfPresent([String: String].self, forKey: .ulimits) { + ulimits = stringForm + } else if let intForm = try? container.decodeIfPresent([String: Int].self, forKey: .ulimits) { + ulimits = intForm.mapValues { "\($0)" } + } else { + ulimits = nil + } working_dir = try container.decodeIfPresent(String.self, forKey: .working_dir) configs = try container.decodeIfPresent([ServiceConfig].self, forKey: .configs) secrets = try container.decodeIfPresent([ServiceSecret].self, forKey: .secrets) diff --git a/Sources/Container-Compose/Commands/ComposeUp.swift b/Sources/Container-Compose/Commands/ComposeUp.swift index 189de09..6ac0099 100644 --- a/Sources/Container-Compose/Commands/ComposeUp.swift +++ b/Sources/Container-Compose/Commands/ComposeUp.swift @@ -504,6 +504,17 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { args.append(contentsOf: ["--cap-add", capability]) } + if let shmSize = service.shm_size { + args.append(contentsOf: ["--shm-size", shmSize]) + } + if service.runInit == true { + args.append("--init") + } + for name in (service.ulimits ?? [:]).keys.sorted() { + guard let value = service.ulimits?[name] else { continue } + args.append(contentsOf: ["--ulimit", "\(name)=\(value)"]) + } + return args } diff --git a/Tests/Container-Compose-StaticTests/HardeningArgsTests.swift b/Tests/Container-Compose-StaticTests/HardeningArgsTests.swift index 4c6480c..59c6d9d 100644 --- a/Tests/Container-Compose-StaticTests/HardeningArgsTests.swift +++ b/Tests/Container-Compose-StaticTests/HardeningArgsTests.swift @@ -46,6 +46,35 @@ struct HardeningArgsTests { #expect(args == ["--cap-drop", "ALL", "--cap-add", "NET_BIND_SERVICE"]) } + + @Test("shm_size, init and ulimits parse") + func scalarHardeningParse() throws { + let svc = try service(""" + image: alpine + shm_size: 256m + init: true + ulimits: + nofile: 65535 + """) + #expect(svc.shm_size == "256m") + #expect(svc.runInit == true) + #expect(svc.ulimits?["nofile"] == "65535") + } + + @Test("shm_size, init and ulimits emit flags") + func scalarHardeningArgs() throws { + let svc = Service(image: "alpine", shm_size: "256m", runInit: true, ulimits: ["nofile": "65535"]) + let args = ComposeUp.hardeningRunArgs(for: svc) + #expect(args.contains("--init")) + #expect(args.firstIndex(of: "--shm-size").map { args[$0 + 1] } == "256m") + #expect(args.firstIndex(of: "--ulimit").map { args[$0 + 1] } == "nofile=65535") + } + + @Test("init false emits no flag") + func initFalseEmitsNothing() throws { + let svc = Service(image: "alpine", runInit: false) + #expect(ComposeUp.hardeningRunArgs(for: svc).contains("--init") == false) + } @Test("no hardening keys yields no args") func emptyYieldsNothing() throws { let svc = Service(image: "alpine") From fc29ea6d960f529e0f62dee7a9808d9a9be09b3e Mon Sep 17 00:00:00 2001 From: Mikimoto Date: Tue, 1 Sep 2026 08:09:38 +0000 Subject: [PATCH 3/6] feat(up): support tmpfs and report options container run cannot express --- .../Codable Structs/Service.swift | 15 +++++- .../Commands/ComposeUp.swift | 47 +++++++++++++++++++ .../HardeningArgsTests.swift | 45 ++++++++++++++++++ 3 files changed, 106 insertions(+), 1 deletion(-) diff --git a/Sources/Container-Compose/Codable Structs/Service.swift b/Sources/Container-Compose/Codable Structs/Service.swift index f401db0..0c8aa43 100644 --- a/Sources/Container-Compose/Codable Structs/Service.swift +++ b/Sources/Container-Compose/Codable Structs/Service.swift @@ -109,6 +109,9 @@ public struct Service: Codable, Hashable { /// Resource limits, e.g. `["nofile": "65535"]` public let ulimits: [String: String]? + /// tmpfs mounts, Compose list form: `["/run:noexec,nosuid", "/tmp"]` + public let tmpfs: [String]? + /// Working directory inside the container public let working_dir: String? @@ -150,7 +153,7 @@ public struct Service: Codable, Hashable { enum CodingKeys: String, CodingKey { case image, build, deploy, restart, healthcheck, volumes, environment, env_file, ports, command, depends_on, user, container_name, labels, networks, hostname, entrypoint, privileged, read_only, working_dir, configs, secrets, stdin_open, tty, platform, - mem_limit, extra_hosts, profiles, cap_add, cap_drop, shm_size, ulimits + mem_limit, extra_hosts, profiles, cap_add, cap_drop, shm_size, ulimits, tmpfs case runInit = "init" } @@ -182,6 +185,7 @@ public struct Service: Codable, Hashable { shm_size: String? = nil, runInit: Bool? = nil, ulimits: [String: String]? = nil, + tmpfs: [String]? = nil, working_dir: String? = nil, platform: String? = nil, configs: [ServiceConfig]? = nil, @@ -219,6 +223,7 @@ public struct Service: Codable, Hashable { self.shm_size = shm_size self.runInit = runInit self.ulimits = ulimits + self.tmpfs = tmpfs self.working_dir = working_dir self.platform = platform self.configs = configs @@ -354,6 +359,14 @@ public struct Service: Codable, Hashable { } else { ulimits = nil } + + if let list = try? container.decodeIfPresent([String].self, forKey: .tmpfs) { + tmpfs = list + } else if let single = try? container.decodeIfPresent(String.self, forKey: .tmpfs) { + tmpfs = [single] + } else { + tmpfs = nil + } working_dir = try container.decodeIfPresent(String.self, forKey: .working_dir) configs = try container.decodeIfPresent([ServiceConfig].self, forKey: .configs) secrets = try container.decodeIfPresent([ServiceSecret].self, forKey: .secrets) diff --git a/Sources/Container-Compose/Commands/ComposeUp.swift b/Sources/Container-Compose/Commands/ComposeUp.swift index 6ac0099..06bc9e4 100644 --- a/Sources/Container-Compose/Commands/ComposeUp.swift +++ b/Sources/Container-Compose/Commands/ComposeUp.swift @@ -515,9 +515,53 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { args.append(contentsOf: ["--ulimit", "\(name)=\(value)"]) } + for entry in service.tmpfs ?? [] { + let (target, options) = Self.splitTmpfsEntry(entry) + var spec = "type=tmpfs,target=\(target)" + for option in options where option.hasPrefix("mode=") || option.hasPrefix("size=") { + spec += ",\(option)" + } + args.append(contentsOf: ["--mount", spec]) + } + return args } + /// Splits a Compose tmpfs entry into its target path and its option list. + static func splitTmpfsEntry(_ entry: String) -> (target: String, options: [String]) { + let parts = entry.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false) + let target = String(parts[0]) + guard parts.count == 2 else { return (target, []) } + return (target, parts[1].split(separator: ",").map(String.init)) + } + + /// Compose options this tool parses but `container run` cannot express. + /// + /// Returned rather than printed so the mapping stays testable, and reported + /// rather than dropped silently — a silent drop is the failure mode this + /// change set exists to remove. + static func unsupportedOptionWarnings(for service: Service, serviceName: String) -> [String] { + var warnings: [String] = [] + + for entry in service.tmpfs ?? [] { + let (target, options) = Self.splitTmpfsEntry(entry) + let dropped = options.filter { !$0.hasPrefix("mode=") && !$0.hasPrefix("size=") } + guard !dropped.isEmpty else { continue } + + warnings.append( + "Note: Service '\(serviceName)' tmpfs '\(target)': `container run` accepts only target, mode and size; dropped \(dropped.joined(separator: ","))." + ) + + if dropped.contains(where: { $0.hasPrefix("uid=") || $0.hasPrefix("gid=") }) { + warnings.append( + "Warning: Service '\(serviceName)' tmpfs '\(target)' requested uid/gid ownership, which `container run` cannot express. The mount will be owned by root, so a non-root container cannot write to it unless the mode is world-writable." + ) + } + } + + return warnings + } + static func validateStoppedServiceExitCode(_ exitCode: Int32, serviceName: String) throws { guard exitCode == 0 else { throw ComposeError.containerRunFailed(serviceName, exitCode) @@ -1103,6 +1147,9 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { } runCommandArgs.append(contentsOf: Self.hardeningRunArgs(for: service)) + for warning in Self.unsupportedOptionWarnings(for: service, serviceName: serviceName) { + print(warning) + } // Add resource limits. // `mem_limit` is the top-level shorthand; `deploy.resources.limits.memory` is diff --git a/Tests/Container-Compose-StaticTests/HardeningArgsTests.swift b/Tests/Container-Compose-StaticTests/HardeningArgsTests.swift index 59c6d9d..e9ca917 100644 --- a/Tests/Container-Compose-StaticTests/HardeningArgsTests.swift +++ b/Tests/Container-Compose-StaticTests/HardeningArgsTests.swift @@ -75,6 +75,51 @@ struct HardeningArgsTests { let svc = Service(image: "alpine", runInit: false) #expect(ComposeUp.hardeningRunArgs(for: svc).contains("--init") == false) } + + @Test("tmpfs list form parses") + func tmpfsParses() throws { + let svc = try service(""" + image: alpine + tmpfs: + - /run:noexec,nosuid + - /tmp + """) + #expect(svc.tmpfs == ["/run:noexec,nosuid", "/tmp"]) + } + + @Test("tmpfs maps to --mount type=tmpfs, never --tmpfs") + func tmpfsUsesMountFlag() throws { + let svc = Service(image: "alpine", tmpfs: ["/tmp"]) + let args = ComposeUp.hardeningRunArgs(for: svc) + #expect(args.contains("--tmpfs") == false) + #expect(args.firstIndex(of: "--mount").map { args[$0 + 1] } == "type=tmpfs,target=/tmp") + } + + @Test("tmpfs mode is carried through, unsupported options are not") + func tmpfsCarriesModeOnly() throws { + let svc = Service(image: "alpine", tmpfs: ["/run/postgresql:noexec,nosuid,uid=70,gid=70,mode=0755"]) + let args = ComposeUp.hardeningRunArgs(for: svc) + let spec = try #require(args.firstIndex(of: "--mount").map { args[$0 + 1] }) + #expect(spec.contains("target=/run/postgresql")) + #expect(spec.contains("mode=0755")) + #expect(spec.contains("uid=") == false) + #expect(spec.contains("noexec") == false) + } + + @Test("dropped tmpfs options are reported, and uid/gid gets its own warning") + func tmpfsDroppedOptionsAreReported() throws { + let svc = Service(image: "alpine", tmpfs: ["/run/postgresql:noexec,nosuid,uid=70,gid=70,mode=0755"]) + let warnings = ComposeUp.unsupportedOptionWarnings(for: svc, serviceName: "patroni1") + #expect(warnings.contains { $0.contains("noexec") && $0.contains("/run/postgresql") }) + #expect(warnings.contains { $0.contains("non-root") }) + } + + @Test("tmpfs with no options produces no warning") + func tmpfsNoOptionsNoWarning() throws { + let svc = Service(image: "alpine", tmpfs: ["/tmp"]) + #expect(ComposeUp.unsupportedOptionWarnings(for: svc, serviceName: "web").isEmpty) + } + @Test("no hardening keys yields no args") func emptyYieldsNothing() throws { let svc = Service(image: "alpine") From d6731770b4ba3d4d1e8b56146f66baaf96de5103 Mon Sep 17 00:00:00 2001 From: Mikimoto Date: Tue, 1 Sep 2026 08:20:44 +0000 Subject: [PATCH 4/6] feat(up): parse network_mode and report it as unsupported --- .../Codable Structs/Service.swift | 9 +++++- .../Commands/ComposeUp.swift | 6 ++++ .../HardeningArgsTests.swift | 28 +++++++++++++++++++ 3 files changed, 42 insertions(+), 1 deletion(-) diff --git a/Sources/Container-Compose/Codable Structs/Service.swift b/Sources/Container-Compose/Codable Structs/Service.swift index 0c8aa43..4098146 100644 --- a/Sources/Container-Compose/Codable Structs/Service.swift +++ b/Sources/Container-Compose/Codable Structs/Service.swift @@ -112,6 +112,10 @@ public struct Service: Codable, Hashable { /// tmpfs mounts, Compose list form: `["/run:noexec,nosuid", "/tmp"]` public let tmpfs: [String]? + /// Compose `network_mode`. Parsed so it can be reported; `container run` + /// has no equivalent, see `ComposeUp.unsupportedOptionWarnings`. + public let network_mode: String? + /// Working directory inside the container public let working_dir: String? @@ -153,7 +157,7 @@ public struct Service: Codable, Hashable { enum CodingKeys: String, CodingKey { case image, build, deploy, restart, healthcheck, volumes, environment, env_file, ports, command, depends_on, user, container_name, labels, networks, hostname, entrypoint, privileged, read_only, working_dir, configs, secrets, stdin_open, tty, platform, - mem_limit, extra_hosts, profiles, cap_add, cap_drop, shm_size, ulimits, tmpfs + mem_limit, extra_hosts, profiles, cap_add, cap_drop, shm_size, ulimits, tmpfs, network_mode case runInit = "init" } @@ -186,6 +190,7 @@ public struct Service: Codable, Hashable { runInit: Bool? = nil, ulimits: [String: String]? = nil, tmpfs: [String]? = nil, + network_mode: String? = nil, working_dir: String? = nil, platform: String? = nil, configs: [ServiceConfig]? = nil, @@ -224,6 +229,7 @@ public struct Service: Codable, Hashable { self.runInit = runInit self.ulimits = ulimits self.tmpfs = tmpfs + self.network_mode = network_mode self.working_dir = working_dir self.platform = platform self.configs = configs @@ -367,6 +373,7 @@ public struct Service: Codable, Hashable { } else { tmpfs = nil } + network_mode = try container.decodeIfPresent(String.self, forKey: .network_mode) working_dir = try container.decodeIfPresent(String.self, forKey: .working_dir) configs = try container.decodeIfPresent([ServiceConfig].self, forKey: .configs) secrets = try container.decodeIfPresent([ServiceSecret].self, forKey: .secrets) diff --git a/Sources/Container-Compose/Commands/ComposeUp.swift b/Sources/Container-Compose/Commands/ComposeUp.swift index 06bc9e4..1db95f1 100644 --- a/Sources/Container-Compose/Commands/ComposeUp.swift +++ b/Sources/Container-Compose/Commands/ComposeUp.swift @@ -559,6 +559,12 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { } } + if let mode = service.network_mode { + warnings.append( + "Note: Service '\(serviceName)' sets network_mode: \(mode). `container run` has no equivalent; the container will join the default network." + ) + } + return warnings } diff --git a/Tests/Container-Compose-StaticTests/HardeningArgsTests.swift b/Tests/Container-Compose-StaticTests/HardeningArgsTests.swift index e9ca917..c4da88b 100644 --- a/Tests/Container-Compose-StaticTests/HardeningArgsTests.swift +++ b/Tests/Container-Compose-StaticTests/HardeningArgsTests.swift @@ -120,6 +120,34 @@ struct HardeningArgsTests { #expect(ComposeUp.unsupportedOptionWarnings(for: svc, serviceName: "web").isEmpty) } + @Test("network_mode parses") + func networkModeParses() throws { + let svc = try service(""" + image: alpine + network_mode: none + """) + #expect(svc.network_mode == "none") + } + + @Test("network_mode emits no run args") + func networkModeEmitsNoArgs() throws { + let svc = Service(image: "alpine", network_mode: "none") + #expect(ComposeUp.hardeningRunArgs(for: svc).isEmpty) + } + + @Test("network_mode is reported as unsupported") + func networkModeIsReported() throws { + let svc = Service(image: "alpine", network_mode: "none") + let warnings = ComposeUp.unsupportedOptionWarnings(for: svc, serviceName: "etcd-init") + #expect(warnings.contains { $0.contains("network_mode") && $0.contains("etcd-init") }) + } + + @Test("absent network_mode produces no warning") + func absentNetworkModeNoWarning() throws { + let svc = Service(image: "alpine") + #expect(ComposeUp.unsupportedOptionWarnings(for: svc, serviceName: "web").isEmpty) + } + @Test("no hardening keys yields no args") func emptyYieldsNothing() throws { let svc = Service(image: "alpine") From c7d2d9158ddfd30392b326d003849f16a1bd3ad8 Mon Sep 17 00:00:00 2001 From: Mikimoto Date: Tue, 1 Sep 2026 08:25:41 +0000 Subject: [PATCH 5/6] test: cover hardening keys through a compose document with merge keys --- .../HardeningComposeIntegrationTests.swift | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 Tests/Container-Compose-StaticTests/HardeningComposeIntegrationTests.swift diff --git a/Tests/Container-Compose-StaticTests/HardeningComposeIntegrationTests.swift b/Tests/Container-Compose-StaticTests/HardeningComposeIntegrationTests.swift new file mode 100644 index 0000000..6d5e5a0 --- /dev/null +++ b/Tests/Container-Compose-StaticTests/HardeningComposeIntegrationTests.swift @@ -0,0 +1,117 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Morris Richman and the Container-Compose project authors. All rights reserved. +// +// 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 Testing +import Foundation +@testable import Yams +@testable import ContainerComposeCore + +/// The per-key tests in `HardeningArgsTests` build a `Service` directly. These +/// exercise the same keys through a whole compose document that uses a YAML +/// anchor and merge keys, which is how hardening settings are usually shared +/// across services in real files. A parser that silently failed to resolve +/// `<<:` would pass every per-key test and fail here. +@Suite("Hardening Keys Through A Compose Document") +struct HardeningComposeIntegrationTests { + + private static let yaml = """ + x-common: &common + cap_drop: + - ALL + init: true + read_only: true + + services: + web: + <<: *common + image: nginx:alpine + cap_add: + - NET_BIND_SERVICE + tmpfs: + - /run:noexec,nosuid,uid=70,gid=70,mode=0755 + db: + <<: *common + image: postgres:16 + shm_size: 256m + ulimits: + nofile: 65535 + fixer: + <<: *common + image: alpine:3 + network_mode: none + """ + + private func compose() throws -> DockerCompose { + try YAMLDecoder().decode(DockerCompose.self, from: Self.yaml) + } + + private func service(_ name: String) throws -> Service { + let svc = try compose().services[name] + return try #require(svc ?? nil) + } + + @Test("merge keys carry hardening settings to every service") + func mergeKeysResolve() throws { + let compose = try compose() + #expect(compose.services.count == 3) + for name in ["web", "db", "fixer"] { + let svc = try service(name) + #expect(svc.cap_drop == ["ALL"], "\(name) did not inherit cap_drop") + #expect(svc.runInit == true, "\(name) did not inherit init") + #expect(svc.read_only == true, "\(name) did not inherit read_only") + } + } + + @Test("inherited and per-service capabilities combine in Compose order") + func capabilitiesCombine() throws { + let args = ComposeUp.hardeningRunArgs(for: try service("web")) + let dropIndex = try #require(args.firstIndex(of: "--cap-drop")) + let addIndex = try #require(args.firstIndex(of: "--cap-add")) + #expect(dropIndex < addIndex) + #expect(args[dropIndex + 1] == "ALL") + #expect(args[addIndex + 1] == "NET_BIND_SERVICE") + } + + @Test("tmpfs keeps mode, drops what container run cannot express, and says so") + func tmpfsThroughDocument() throws { + let svc = try service("web") + let args = ComposeUp.hardeningRunArgs(for: svc) + let spec = try #require(args.firstIndex(of: "--mount").map { args[$0 + 1] }) + #expect(spec.contains("target=/run")) + #expect(spec.contains("mode=0755")) + #expect(spec.contains("uid=") == false) + + let warnings = ComposeUp.unsupportedOptionWarnings(for: svc, serviceName: "web") + #expect(warnings.contains { $0.contains("noexec") }) + #expect(warnings.contains { $0.contains("non-root") }) + } + + @Test("scalar hardening keys survive the document round trip") + func scalarsThroughDocument() throws { + let args = ComposeUp.hardeningRunArgs(for: try service("db")) + #expect(args.firstIndex(of: "--shm-size").map { args[$0 + 1] } == "256m") + #expect(args.firstIndex(of: "--ulimit").map { args[$0 + 1] } == "nofile=65535") + #expect(args.contains("--init")) + } + + @Test("network_mode produces a warning and no run args") + func networkModeThroughDocument() throws { + let svc = try service("fixer") + #expect(ComposeUp.hardeningRunArgs(for: svc).contains("--network") == false) + #expect(ComposeUp.unsupportedOptionWarnings(for: svc, serviceName: "fixer") + .contains { $0.contains("network_mode") }) + } +} From 9cf6c945c46af480956ca207cb6a43f8f737a651 Mon Sep 17 00:00:00 2001 From: Mikimoto Date: Tue, 1 Sep 2026 08:48:17 +0000 Subject: [PATCH 6/6] fix(up): map ulimits long form, interpolate variables, drop a false claim Three problems found by a fresh-context review of this branch, all verified against the installed apple/container sources rather than inferred: ulimits: the decoder tried [String: String] then [String: Int] and fell back to nil. Compose also allows a {soft, hard} pair, which matched neither, so a file using the long form silently lost its whole ulimits map including any sibling entries in short form. container run --ulimit takes =[:] (Parser.rlimit) and its type names match Compose's exactly, so the long form is directly expressible. Both forms now normalise through UlimitValue, and an entry that cannot be read throws instead of nilling the map. tmpfs: same silent-nil shape for a value that is neither a list nor a string; now throws. Options are also trimmed, so "/run:noexec, mode=0755" no longer drops mode by failing its prefix test. Capabilities: the comment claimed cap_drop was emitted before cap_add "so that cap_drop: [ALL] followed by a narrow cap_add behaves as Compose specifies". That is false. container collects the two flags into separate arrays (Flags.swift) and computes the effective set in RuntimeService.effectiveCapabilities - drop-ALL clears the base, adds are applied, individual drops removed - so the command-line order carries no meaning. Two tests asserted that ordering; they now assert that every declared capability reaches its flag, which is the real invariant. The six new keys also went through the run-args builder without variable interpolation while every neighbouring key resolved ${VAR}; they now take the environment and resolve it. --- .../Codable Structs/Service.swift | 55 +++++++++--- .../Commands/ComposeUp.swift | 47 +++++++--- .../HardeningArgsTests.swift | 87 ++++++++++++++++++- .../HardeningComposeIntegrationTests.swift | 9 +- 4 files changed, 163 insertions(+), 35 deletions(-) diff --git a/Sources/Container-Compose/Codable Structs/Service.swift b/Sources/Container-Compose/Codable Structs/Service.swift index 4098146..1664ea8 100644 --- a/Sources/Container-Compose/Codable Structs/Service.swift +++ b/Sources/Container-Compose/Codable Structs/Service.swift @@ -25,6 +25,41 @@ import Foundation /// Represents a single service definition within the `services` section. +/// One `ulimits:` entry. +/// +/// Compose accepts either a single value (`nofile: 65535`) or a soft/hard pair +/// (`nofile: {soft: 20000, hard: 40000}`). `container run --ulimit` takes +/// `=[:]`, so both forms are normalised to that string here. +/// +/// Decoding is deliberately throwing: an entry this cannot understand fails the +/// whole file rather than nilling the map, which would drop the sibling entries +/// with it and give no sign that anything was lost. +private struct UlimitValue: Decodable { + let flagValue: String + + private enum CodingKeys: String, CodingKey { + case soft, hard + } + + init(from decoder: any Decoder) throws { + if let single = try? decoder.singleValueContainer() { + if let intValue = try? single.decode(Int.self) { + flagValue = "\(intValue)" + return + } + if let stringValue = try? single.decode(String.self) { + flagValue = stringValue + return + } + } + + let keyed = try decoder.container(keyedBy: CodingKeys.self) + let soft = try keyed.decode(Int.self, forKey: .soft) + let hard = try keyed.decode(Int.self, forKey: .hard) + flagValue = "\(soft):\(hard)" + } +} + public struct Service: Codable, Hashable { /// Docker image name public let image: String? @@ -358,20 +393,18 @@ public struct Service: Codable, Hashable { cap_drop = try container.decodeIfPresent([String].self, forKey: .cap_drop) shm_size = try container.decodeIfPresent(String.self, forKey: .shm_size) runInit = try container.decodeIfPresent(Bool.self, forKey: .runInit) - if let stringForm = try? container.decodeIfPresent([String: String].self, forKey: .ulimits) { - ulimits = stringForm - } else if let intForm = try? container.decodeIfPresent([String: Int].self, forKey: .ulimits) { - ulimits = intForm.mapValues { "\($0)" } - } else { - ulimits = nil - } + ulimits = try container + .decodeIfPresent([String: UlimitValue].self, forKey: .ulimits)? + .mapValues(\.flagValue) - if let list = try? container.decodeIfPresent([String].self, forKey: .tmpfs) { + // List form is the common one; a bare string is also legal. Anything else + // throws rather than silently becoming nil. + if !container.contains(.tmpfs) { + tmpfs = nil + } else if let list = try? container.decode([String].self, forKey: .tmpfs) { tmpfs = list - } else if let single = try? container.decodeIfPresent(String.self, forKey: .tmpfs) { - tmpfs = [single] } else { - tmpfs = nil + tmpfs = [try container.decode(String.self, forKey: .tmpfs)] } network_mode = try container.decodeIfPresent(String.self, forKey: .network_mode) working_dir = try container.decodeIfPresent(String.self, forKey: .working_dir) diff --git a/Sources/Container-Compose/Commands/ComposeUp.swift b/Sources/Container-Compose/Commands/ComposeUp.swift index 1db95f1..983bc9a 100644 --- a/Sources/Container-Compose/Commands/ComposeUp.swift +++ b/Sources/Container-Compose/Commands/ComposeUp.swift @@ -492,31 +492,34 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { /// surrounding argument assembly has no test seam, which is how /// `healthcheck.timeout` stayed parsed-but-unapplied. /// - /// `cap_drop` is emitted before `cap_add` so that `cap_drop: [ALL]` followed - /// by a narrow `cap_add` behaves as Compose specifies. - static func hardeningRunArgs(for service: Service) -> [String] { + /// Capabilities are emitted drop-then-add for readability only. `container` + /// collects `--cap-add` and `--cap-drop` into two separate arrays and then + /// computes the effective set (drop-ALL clears the base, adds are applied, + /// individual drops are removed), so the order they appear in on the command + /// line carries no meaning. + static func hardeningRunArgs(for service: Service, environment: [String: String] = [:]) -> [String] { var args: [String] = [] for capability in service.cap_drop ?? [] { - args.append(contentsOf: ["--cap-drop", capability]) + args.append(contentsOf: ["--cap-drop", resolveVariable(capability, with: environment)]) } for capability in service.cap_add ?? [] { - args.append(contentsOf: ["--cap-add", capability]) + args.append(contentsOf: ["--cap-add", resolveVariable(capability, with: environment)]) } if let shmSize = service.shm_size { - args.append(contentsOf: ["--shm-size", shmSize]) + args.append(contentsOf: ["--shm-size", resolveVariable(shmSize, with: environment)]) } if service.runInit == true { args.append("--init") } for name in (service.ulimits ?? [:]).keys.sorted() { guard let value = service.ulimits?[name] else { continue } - args.append(contentsOf: ["--ulimit", "\(name)=\(value)"]) + args.append(contentsOf: ["--ulimit", "\(name)=\(resolveVariable(value, with: environment))"]) } for entry in service.tmpfs ?? [] { - let (target, options) = Self.splitTmpfsEntry(entry) + let (target, options) = Self.splitTmpfsEntry(resolveVariable(entry, with: environment)) var spec = "type=tmpfs,target=\(target)" for option in options where option.hasPrefix("mode=") || option.hasPrefix("size=") { spec += ",\(option)" @@ -530,9 +533,15 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { /// Splits a Compose tmpfs entry into its target path and its option list. static func splitTmpfsEntry(_ entry: String) -> (target: String, options: [String]) { let parts = entry.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false) - let target = String(parts[0]) + let target = String(parts[0]).trimmingCharacters(in: .whitespaces) guard parts.count == 2 else { return (target, []) } - return (target, parts[1].split(separator: ",").map(String.init)) + // `/run:noexec, mode=0755` is legal Compose; without the trim the second + // option would not match its prefix and would be dropped as unsupported. + let options = parts[1] + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + return (target, options) } /// Compose options this tool parses but `container run` cannot express. @@ -540,11 +549,15 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { /// Returned rather than printed so the mapping stays testable, and reported /// rather than dropped silently — a silent drop is the failure mode this /// change set exists to remove. - static func unsupportedOptionWarnings(for service: Service, serviceName: String) -> [String] { + static func unsupportedOptionWarnings( + for service: Service, + serviceName: String, + environment: [String: String] = [:] + ) -> [String] { var warnings: [String] = [] for entry in service.tmpfs ?? [] { - let (target, options) = Self.splitTmpfsEntry(entry) + let (target, options) = Self.splitTmpfsEntry(resolveVariable(entry, with: environment)) let dropped = options.filter { !$0.hasPrefix("mode=") && !$0.hasPrefix("size=") } guard !dropped.isEmpty else { continue } @@ -1152,8 +1165,14 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { runCommandArgs.append("--read-only") } - runCommandArgs.append(contentsOf: Self.hardeningRunArgs(for: service)) - for warning in Self.unsupportedOptionWarnings(for: service, serviceName: serviceName) { + runCommandArgs.append( + contentsOf: Self.hardeningRunArgs(for: service, environment: environmentVariables) + ) + for warning in Self.unsupportedOptionWarnings( + for: service, + serviceName: serviceName, + environment: environmentVariables + ) { print(warning) } diff --git a/Tests/Container-Compose-StaticTests/HardeningArgsTests.swift b/Tests/Container-Compose-StaticTests/HardeningArgsTests.swift index c4da88b..1783657 100644 --- a/Tests/Container-Compose-StaticTests/HardeningArgsTests.swift +++ b/Tests/Container-Compose-StaticTests/HardeningArgsTests.swift @@ -39,11 +39,22 @@ struct HardeningArgsTests { #expect(svc.cap_add == ["NET_BIND_SERVICE"]) } - @Test("cap_drop is emitted before cap_add") - func capabilityOrder() throws { - let svc = Service(image: "alpine", cap_add: ["NET_BIND_SERVICE"], cap_drop: ["ALL"]) + /// Order is deliberately not asserted: `container` collects the two flags + /// into separate arrays and computes the effective capability set, so the + /// command-line order carries no meaning. What matters is that every + /// declared capability reaches the right flag. + @Test("every declared capability reaches its flag") + func capabilitiesAreEmitted() throws { + let svc = Service( + image: "alpine", + cap_add: ["NET_BIND_SERVICE", "CHOWN"], + cap_drop: ["ALL"] + ) let args = ComposeUp.hardeningRunArgs(for: svc) - #expect(args == ["--cap-drop", "ALL", "--cap-add", "NET_BIND_SERVICE"]) + #expect(args.count == 6) + #expect(zip(args, args.dropFirst()).contains { $0 == "--cap-drop" && $1 == "ALL" }) + #expect(zip(args, args.dropFirst()).contains { $0 == "--cap-add" && $1 == "NET_BIND_SERVICE" }) + #expect(zip(args, args.dropFirst()).contains { $0 == "--cap-add" && $1 == "CHOWN" }) } @@ -148,6 +159,74 @@ struct HardeningArgsTests { #expect(ComposeUp.unsupportedOptionWarnings(for: svc, serviceName: "web").isEmpty) } + @Test("ulimits soft/hard long form maps to soft:hard") + func ulimitsLongForm() throws { + let svc = try service(""" + image: alpine + ulimits: + nproc: 65535 + nofile: + soft: 20000 + hard: 40000 + """) + #expect(svc.ulimits?["nproc"] == "65535") + #expect(svc.ulimits?["nofile"] == "20000:40000") + + let args = ComposeUp.hardeningRunArgs(for: svc) + #expect(zip(args, args.dropFirst()).contains { $0 == "--ulimit" && $1 == "nofile=20000:40000" }) + #expect(zip(args, args.dropFirst()).contains { $0 == "--ulimit" && $1 == "nproc=65535" }) + } + + @Test("an unreadable ulimits entry fails the file instead of nilling the map") + func ulimitsMalformedThrows() throws { + #expect(throws: (any Error).self) { + try YAMLDecoder().decode(Service.self, from: """ + image: alpine + ulimits: + nofile: + soft: 20000 + """) + } + } + + @Test("an unreadable tmpfs value fails the file instead of nilling it") + func tmpfsMalformedThrows() throws { + #expect(throws: (any Error).self) { + try YAMLDecoder().decode(Service.self, from: """ + image: alpine + tmpfs: + run: true + """) + } + } + + @Test("whitespace after a comma does not lose the option") + func tmpfsOptionWhitespace() throws { + let svc = Service(image: "alpine", tmpfs: ["/run:noexec, mode=0755"]) + let args = ComposeUp.hardeningRunArgs(for: svc) + let spec = try #require(args.firstIndex(of: "--mount").map { args[$0 + 1] }) + #expect(spec.contains("mode=0755")) + #expect(ComposeUp.unsupportedOptionWarnings(for: svc, serviceName: "web") + .contains { $0.contains("noexec") }) + } + + @Test("variables are interpolated in the hardening keys") + func hardeningKeysInterpolate() throws { + let env = ["RUNTIME_DIR": "/run/app", "SHM": "512m", "NOFILE": "65535"] + let svc = Service( + image: "alpine", + shm_size: "${SHM}", + ulimits: ["nofile": "${NOFILE}"], + tmpfs: ["${RUNTIME_DIR}:mode=0755"] + ) + let args = ComposeUp.hardeningRunArgs(for: svc, environment: env) + #expect(args.firstIndex(of: "--shm-size").map { args[$0 + 1] } == "512m") + #expect(zip(args, args.dropFirst()).contains { $0 == "--ulimit" && $1 == "nofile=65535" }) + let spec = try #require(args.firstIndex(of: "--mount").map { args[$0 + 1] }) + #expect(spec.contains("target=/run/app")) + #expect(spec.contains("${") == false) + } + @Test("no hardening keys yields no args") func emptyYieldsNothing() throws { let svc = Service(image: "alpine") diff --git a/Tests/Container-Compose-StaticTests/HardeningComposeIntegrationTests.swift b/Tests/Container-Compose-StaticTests/HardeningComposeIntegrationTests.swift index 6d5e5a0..0d3c3ac 100644 --- a/Tests/Container-Compose-StaticTests/HardeningComposeIntegrationTests.swift +++ b/Tests/Container-Compose-StaticTests/HardeningComposeIntegrationTests.swift @@ -75,14 +75,11 @@ struct HardeningComposeIntegrationTests { } } - @Test("inherited and per-service capabilities combine in Compose order") + @Test("inherited and per-service capabilities both reach the command line") func capabilitiesCombine() throws { let args = ComposeUp.hardeningRunArgs(for: try service("web")) - let dropIndex = try #require(args.firstIndex(of: "--cap-drop")) - let addIndex = try #require(args.firstIndex(of: "--cap-add")) - #expect(dropIndex < addIndex) - #expect(args[dropIndex + 1] == "ALL") - #expect(args[addIndex + 1] == "NET_BIND_SERVICE") + #expect(zip(args, args.dropFirst()).contains { $0 == "--cap-drop" && $1 == "ALL" }) + #expect(zip(args, args.dropFirst()).contains { $0 == "--cap-add" && $1 == "NET_BIND_SERVICE" }) } @Test("tmpfs keeps mode, drops what container run cannot express, and says so")