From f096974632b483a0aa9ed807f5413fab06536e97 Mon Sep 17 00:00:00 2001 From: Mikimoto Date: Tue, 1 Sep 2026 07:49:47 +0000 Subject: [PATCH 1/9] 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 b0950ef..0f7d0fb 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 1231a28f3cdbc5eff060f7e39cf1f50be1c09ea8 Mon Sep 17 00:00:00 2001 From: Mikimoto Date: Tue, 1 Sep 2026 07:58:04 +0000 Subject: [PATCH 2/9] 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 0f7d0fb..8fa05cb 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 fac7dbf759ac7f946707db51b00f73c8a84f7021 Mon Sep 17 00:00:00 2001 From: Mikimoto Date: Tue, 1 Sep 2026 08:09:38 +0000 Subject: [PATCH 3/9] 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 8fa05cb..c9fefb2 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 91009c5942a171fcede125072b304617586c8975 Mon Sep 17 00:00:00 2001 From: Mikimoto Date: Tue, 1 Sep 2026 08:20:44 +0000 Subject: [PATCH 4/9] 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 c9fefb2..9bc1a96 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 1d675a70cab087b8c943d3b2c36a3fad9b82f368 Mon Sep 17 00:00:00 2001 From: Mikimoto Date: Tue, 1 Sep 2026 08:25:41 +0000 Subject: [PATCH 5/9] 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 16c2e97ff01a564d5b1defba0058ca3190efc26f Mon Sep 17 00:00:00 2001 From: Mikimoto Date: Tue, 1 Sep 2026 08:48:17 +0000 Subject: [PATCH 6/9] 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 9bc1a96..ce56a4a 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") From ae4178a0b98b34e60e1af85d0aaf6b8ae964f052 Mon Sep 17 00:00:00 2001 From: Mikimoto Date: Tue, 15 Sep 2026 05:50:45 +0000 Subject: [PATCH 7/9] feat(up): reject network_mode: none, and assert the hardening keys take effect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **`network_mode: none` now stops the run.** Every unsupported mode was reported and the container started anyway on the default network. For `host`, `service:` and `container:` that is wrong in the direction of "does not behave as configured". For `none` it is wrong in the other direction: the file asks for no networking and the container comes up connected, which is less isolation than was requested and is not something to find out from a note in the log. `rejectedNetworkMode` is the seam, normalising case and surrounding space so `None` and ` NONE ` cannot slip through; the other modes keep their note. Scoped to `none` deliberately. Widening it to every unsupported mode would change behaviour for existing compose files, and that is a call for the maintainer, not a side effect of this PR — the set is one function and one line away. **The hardening keys are now asserted from inside the container**, against what the kernel reports rather than against the argv that was assembled. Those are two different claims, and only the static suite covered the first: cap_drop: ALL + cap_add: NET_BIND_SERVICE CapBnd is exactly 1 << 10 — asserted as the whole mask, so anything `cap_drop: ALL` failed to remove shows up as an extra bit tmpfs: /scratch present in /proc/mounts as tmpfs shm_size: 64M /dev/shm carries size=65536k ulimits nofile 1234:5678 both columns in /proc/self/limits init: true PID 1 is not the service command Driven through compose rather than `container run`, so the mapping is what is under test. Six static tests for the rejection, one dynamic test for the rest. --- .../Commands/ComposeUp.swift | 23 +++- Sources/Container-Compose/Errors.swift | 5 + .../HardeningKeysTests.swift | 125 ++++++++++++++++++ .../HardeningArgsTests.swift | 51 +++++++ 4 files changed, 203 insertions(+), 1 deletion(-) create mode 100644 Tests/Container-Compose-DynamicTests/HardeningKeysTests.swift diff --git a/Sources/Container-Compose/Commands/ComposeUp.swift b/Sources/Container-Compose/Commands/ComposeUp.swift index 983bc9a..1462784 100644 --- a/Sources/Container-Compose/Commands/ComposeUp.swift +++ b/Sources/Container-Compose/Commands/ComposeUp.swift @@ -572,7 +572,9 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { } } - if let mode = service.network_mode { + // `none` is rejected outright rather than warned about, so it is absent + // here; every other value still only produces a note. + if let mode = service.network_mode, Self.rejectedNetworkMode(mode) == nil { warnings.append( "Note: Service '\(serviceName)' sets network_mode: \(mode). `container run` has no equivalent; the container will join the default network." ) @@ -581,6 +583,22 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { return warnings } + /// The `network_mode` values that must stop the run instead of being reported. + /// + /// Only `none` qualifies. Every unsupported mode is wrong, but the rest fail + /// as "does not behave as configured" — the container gets the default network + /// where the file asked for the host's, or for a peer's. `none` fails in the + /// other direction: the file asks for no networking at all and the container + /// would come up connected, which is less isolation than was requested and is + /// not something to discover from a note in the log. + /// + /// Returns the normalised mode when it must be rejected, `nil` otherwise. + static func rejectedNetworkMode(_ mode: String?) -> String? { + guard let normalised = mode?.trimmingCharacters(in: .whitespaces).lowercased(), + !normalised.isEmpty else { return nil } + return normalised == "none" ? normalised : nil + } + static func validateStoppedServiceExitCode(_ exitCode: Int32, serviceName: String) throws { guard exitCode == 0 else { throw ComposeError.containerRunFailed(serviceName, exitCode) @@ -1168,6 +1186,9 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { runCommandArgs.append( contentsOf: Self.hardeningRunArgs(for: service, environment: environmentVariables) ) + if let rejected = Self.rejectedNetworkMode(service.network_mode) { + throw ComposeError.unsupportedNetworkMode(serviceName, rejected) + } for warning in Self.unsupportedOptionWarnings( for: service, serviceName: serviceName, diff --git a/Sources/Container-Compose/Errors.swift b/Sources/Container-Compose/Errors.swift index 6e10116..592418c 100644 --- a/Sources/Container-Compose/Errors.swift +++ b/Sources/Container-Compose/Errors.swift @@ -46,6 +46,7 @@ public enum ComposeError: Error, LocalizedError { case healthcheckUnavailable(String) case healthcheckFailed(String) case noSuchService(String) + case unsupportedNetworkMode(String, String) public var errorDescription: String? { switch self { @@ -67,6 +68,10 @@ public enum ComposeError: Error, LocalizedError { return "Service '\(service)' failed its healthcheck." case .noSuchService(let name): return "no such service: \(name)" + case .unsupportedNetworkMode(let service, let mode): + return """ + Service '\(service)' sets network_mode: \(mode), which `container run` cannot express. Starting it anyway would attach the container to the default network — more connectivity than the compose file asks for, not less. Remove the key, or run this service under a runtime that supports it. + """ } } } diff --git a/Tests/Container-Compose-DynamicTests/HardeningKeysTests.swift b/Tests/Container-Compose-DynamicTests/HardeningKeysTests.swift new file mode 100644 index 0000000..0cc5abf --- /dev/null +++ b/Tests/Container-Compose-DynamicTests/HardeningKeysTests.swift @@ -0,0 +1,125 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerComposeCore + +/// The hardening keys, asserted against what the kernel reports inside the +/// container rather than against the argv that was assembled. +/// +/// Building the right `container run` flags and having them take effect are two +/// different claims; the static suite covers the first one. Everything here is +/// read from `/proc` inside a container that compose brought up. +@Suite("Hardening Keys - applied inside the container", .containerDependent, .serialized) +struct HardeningKeysTests { + + /// `CAP_NET_BIND_SERVICE` is capability 10, so a bounding set holding only it + /// is `1 << 10`. Asserting the whole mask rather than "contains" is what makes + /// `cap_drop: ALL` observable: anything left over shows up as extra bits. + private static let expectedCapabilityMask: UInt64 = 1 << 10 + + /// stdout arrives on an arbitrary queue, so it is accumulated behind a lock + /// rather than into a captured `var`. + private final class Output: @unchecked Sendable { + private let lock = NSLock() + private var text = "" + func append(_ s: String) { lock.lock(); text += s; lock.unlock() } + var value: String { lock.lock(); defer { lock.unlock() }; return text } + } + + private func exec(_ container: String, _ script: String) async throws -> String { + let output = Output() + let compose = try ComposeUp.parse(["-d", "--cwd", NSTemporaryDirectory()]) + _ = try await compose.streamCommand( + "container", + args: ["exec", container, "sh", "-c", script], + onStdout: { output.append($0) }, + onStderr: { _ in }) + return output.value + } + + @Test("cap_drop, cap_add, tmpfs, shm_size, ulimits and init all take effect") + func hardeningKeysAreApplied() async throws { + let project = "hardening-\(UUID().uuidString.prefix(8).lowercased())" + let dir = URL.temporaryDirectory.appending(path: project) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + let yaml = """ + name: \(project) + services: + box: + image: alpine:latest + command: ["sleep", "300"] + cap_drop: [ALL] + cap_add: [NET_BIND_SERVICE] + shm_size: 64M + init: true + ulimits: + nofile: + soft: 1234 + hard: 5678 + tmpfs: + - /scratch + """ + try yaml.write(to: dir.appending(path: "docker-compose.yaml"), atomically: false, encoding: .utf8) + let box = "\(project)-box" + + func tearDown() async { + var down = try? ComposeDown.parse(["--cwd", dir.path(percentEncoded: false)]) + try? await down?.run() + _ = try? await ComposeUp.parse(["-d", "--cwd", dir.path(percentEncoded: false)]) + .streamCommand("container", args: ["delete", "-f", box], + onStdout: { _ in }, onStderr: { _ in }) + try? FileManager.default.removeItem(at: dir) + } + + var composeUp = try ComposeUp.parse(["-d", "--cwd", dir.path(percentEncoded: false)]) + do { + try await composeUp.run() + + // cap_drop: ALL then cap_add: one — the bounding set is the whole claim. + let caps = try await exec(box, "grep '^CapBnd' /proc/self/status") + let mask = caps.split(separator: ":").last + .map { UInt64($0.trimmingCharacters(in: .whitespacesAndNewlines), radix: 16) } ?? nil + #expect(mask == Self.expectedCapabilityMask, + "CapBnd was \(caps.trimmingCharacters(in: .whitespacesAndNewlines))") + + // tmpfs: the target is mounted, and mounted as tmpfs. + let scratch = try await exec(box, "grep ' /scratch ' /proc/mounts") + #expect(scratch.contains("tmpfs"), "/scratch mount line: \(scratch)") + + // shm_size: 64M reaches /dev/shm as size=65536k. + let shm = try await exec(box, "grep ' /dev/shm ' /proc/mounts") + #expect(shm.contains("size=65536k"), "/dev/shm mount line: \(shm)") + + // ulimits long form: soft and hard land in the right columns. + let limits = try await exec(box, "grep -i 'open files' /proc/self/limits") + let fields = limits.split(whereSeparator: \.isWhitespace) + #expect(fields.contains("1234"), "limits line: \(limits)") + #expect(fields.contains("5678"), "limits line: \(limits)") + + // init: PID 1 is the init shim, not the service command. + let pid1 = try await exec(box, "cat /proc/1/comm") + let comm = pid1.trimmingCharacters(in: .whitespacesAndNewlines) + #expect(comm != "sleep", "PID 1 was \(comm); expected an init process") + #expect(!comm.isEmpty) + } catch { + await tearDown() + throw error + } + await tearDown() + } +} diff --git a/Tests/Container-Compose-StaticTests/HardeningArgsTests.swift b/Tests/Container-Compose-StaticTests/HardeningArgsTests.swift index 1783657..30d8741 100644 --- a/Tests/Container-Compose-StaticTests/HardeningArgsTests.swift +++ b/Tests/Container-Compose-StaticTests/HardeningArgsTests.swift @@ -233,3 +233,54 @@ struct HardeningArgsTests { #expect(ComposeUp.hardeningRunArgs(for: svc).isEmpty) } } + +/// `network_mode` is parsed so it can be acted on. `none` stops the run; every +/// other unsupported value is only reported, because those fail as "not what was +/// configured" while `none` fails as "less isolated than was configured". +@Suite("Network Mode Rejection") +struct NetworkModeRejectionTests { + + @Test("none is rejected") + func noneIsRejected() { + #expect(ComposeUp.rejectedNetworkMode("none") == "none") + } + + @Test("case and surrounding whitespace do not let none through") + func noneIsNormalised() { + #expect(ComposeUp.rejectedNetworkMode("None") == "none") + #expect(ComposeUp.rejectedNetworkMode(" NONE ") == "none") + } + + /// These are wrong too, but they are reported rather than fatal — widening + /// the rejection is a behaviour change for existing compose files. + @Test("other unsupported modes are not rejected") + func othersAreNotRejected() { + for mode in ["host", "bridge", "service:db", "container:abc123", "default"] { + #expect(ComposeUp.rejectedNetworkMode(mode) == nil, "\(mode) should not be fatal") + } + } + + @Test("an absent or empty network_mode is not rejected") + func absentIsNotRejected() { + #expect(ComposeUp.rejectedNetworkMode(nil) == nil) + #expect(ComposeUp.rejectedNetworkMode("") == nil) + #expect(ComposeUp.rejectedNetworkMode(" ") == nil) + } + + /// The note must not also fire for `none`; the throw is the whole report. + @Test("none produces no warning, since it is fatal instead") + func noneProducesNoWarning() { + let service = Service(image: "alpine:latest", network_mode: "none") + let warnings = ComposeUp.unsupportedOptionWarnings( + for: service, serviceName: "svc", environment: [:]) + #expect(!warnings.contains { $0.contains("network_mode") }) + } + + @Test("an unsupported but non-fatal mode still produces its note") + func hostStillWarns() { + let service = Service(image: "alpine:latest", network_mode: "host") + let warnings = ComposeUp.unsupportedOptionWarnings( + for: service, serviceName: "svc", environment: [:]) + #expect(warnings.contains { $0.contains("network_mode: host") }) + } +} From db6cf6de7d6c9c4718078d4b163ce87cb57c63c5 Mon Sep 17 00:00:00 2001 From: Mikimoto Date: Tue, 15 Sep 2026 05:58:17 +0000 Subject: [PATCH 8/9] fix(errors): render the unsupported network_mode message as one line The multi-line literal kept the continuation lines' indentation, so the error reached the terminal with runs of spaces inside it. Verified end to end: a compose file with network_mode: none now exits 1 with the message on one line and starts no container. --- Sources/Container-Compose/Errors.swift | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Sources/Container-Compose/Errors.swift b/Sources/Container-Compose/Errors.swift index 592418c..e8216c0 100644 --- a/Sources/Container-Compose/Errors.swift +++ b/Sources/Container-Compose/Errors.swift @@ -69,9 +69,10 @@ public enum ComposeError: Error, LocalizedError { case .noSuchService(let name): return "no such service: \(name)" case .unsupportedNetworkMode(let service, let mode): - return """ - Service '\(service)' sets network_mode: \(mode), which `container run` cannot express. Starting it anyway would attach the container to the default network — more connectivity than the compose file asks for, not less. Remove the key, or run this service under a runtime that supports it. - """ + return "Service '\(service)' sets network_mode: \(mode), which `container run` " + + "cannot express. Starting it anyway would attach the container to the default " + + "network — more connectivity than the compose file asks for, not less. Remove " + + "the key, or run this service under a runtime that supports it." } } } From a4847026f84004da8b7685220057aa1e6c99bbf4 Mon Sep 17 00:00:00 2001 From: Mikimoto Date: Tue, 15 Sep 2026 06:00:19 +0000 Subject: [PATCH 9/9] test: update the two network_mode assertions to the rejection behaviour MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both predate the change and assert that `network_mode: none` produces a note. It now throws instead, so the note is deliberately absent and those expectations were inverted. `networkModeIsReported` now checks both directions: `none` produces no note because the throw is the whole report, and `host` — unsupported but survivable — still does. The document-driven one asserts the rejection seam returns the value for the `fixer` service rather than looking for a warning that no longer exists. Static suite back to green: 273 tests in 25 suites. --- .../HardeningArgsTests.swift | 12 +++++++++--- .../HardeningComposeIntegrationTests.swift | 8 ++++++-- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/Tests/Container-Compose-StaticTests/HardeningArgsTests.swift b/Tests/Container-Compose-StaticTests/HardeningArgsTests.swift index 30d8741..243abdd 100644 --- a/Tests/Container-Compose-StaticTests/HardeningArgsTests.swift +++ b/Tests/Container-Compose-StaticTests/HardeningArgsTests.swift @@ -146,10 +146,16 @@ struct HardeningArgsTests { #expect(ComposeUp.hardeningRunArgs(for: svc).isEmpty) } - @Test("network_mode is reported as unsupported") + /// `none` is fatal rather than reported, so it must NOT also produce a note — + /// the throw is the whole report. An unsupported-but-survivable mode still does. + @Test("an unsupported network_mode is reported, but none is not") func networkModeIsReported() throws { - let svc = Service(image: "alpine", network_mode: "none") - let warnings = ComposeUp.unsupportedOptionWarnings(for: svc, serviceName: "etcd-init") + let fatal = Service(image: "alpine", network_mode: "none") + #expect(ComposeUp.unsupportedOptionWarnings(for: fatal, serviceName: "etcd-init") + .contains { $0.contains("network_mode") } == false) + + let reported = Service(image: "alpine", network_mode: "host") + let warnings = ComposeUp.unsupportedOptionWarnings(for: reported, serviceName: "etcd-init") #expect(warnings.contains { $0.contains("network_mode") && $0.contains("etcd-init") }) } diff --git a/Tests/Container-Compose-StaticTests/HardeningComposeIntegrationTests.swift b/Tests/Container-Compose-StaticTests/HardeningComposeIntegrationTests.swift index 0d3c3ac..bd5aa38 100644 --- a/Tests/Container-Compose-StaticTests/HardeningComposeIntegrationTests.swift +++ b/Tests/Container-Compose-StaticTests/HardeningComposeIntegrationTests.swift @@ -104,11 +104,15 @@ struct HardeningComposeIntegrationTests { #expect(args.contains("--init")) } - @Test("network_mode produces a warning and no run args") + /// The document's `fixer` service carries `network_mode: none`, which is + /// rejected rather than reported: no run args, no note, and the value comes + /// back from the rejection seam so the caller can refuse to start it. + @Test("network_mode: none through the document is rejected, not warned about") func networkModeThroughDocument() throws { let svc = try service("fixer") #expect(ComposeUp.hardeningRunArgs(for: svc).contains("--network") == false) + #expect(ComposeUp.rejectedNetworkMode(svc.network_mode) == "none") #expect(ComposeUp.unsupportedOptionWarnings(for: svc, serviceName: "fixer") - .contains { $0.contains("network_mode") }) + .contains { $0.contains("network_mode") } == false) } }