Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions Sources/Services/ContainerAPIService/Client/Flags.swift
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ public struct Flags {
kernel: String?,
kernelArgs: [String],
labels: [String],
maskedPaths: [String],
mounts: [String],
name: String?,
networks: [String],
Expand All @@ -186,6 +187,7 @@ public struct Flags {
publishPorts: [String],
publishSockets: [String],
readOnly: Bool,
readonlyPaths: [String],
remove: Bool,
rosetta: Bool,
runtime: String?,
Expand All @@ -208,6 +210,7 @@ public struct Flags {
self.kernel = kernel
self.kernelArgs = kernelArgs
self.labels = labels
self.maskedPaths = maskedPaths
self.mounts = mounts
self.name = name
self.networks = networks
Expand All @@ -216,6 +219,7 @@ public struct Flags {
self.publishPorts = publishPorts
self.publishSockets = publishSockets
self.readOnly = readOnly
self.readonlyPaths = readonlyPaths
self.remove = remove
self.rosetta = rosetta
self.runtime = runtime
Expand Down Expand Up @@ -291,6 +295,16 @@ public struct Flags {
@Option(name: [.short, .customLong("label")], help: "Add a key=value label to the container")
public var labels: [String] = []

/// EXPERIMENTAL: The flag is subject to change.
@Option(
name: .customLong("masked-path"),
help: .init(
"[EXPERIMENTAL] Hide a path inside the container, in addition to the runtime defaults (or NONE to clear prior values and the defaults)",
valueName: "path"
)
)
public var maskedPaths: [String] = []

@Option(name: .customLong("mount"), help: "Add a mount to the container (format: type=<>,source=<>,target=<>,readonly)")
public var mounts: [String] = []

Expand Down Expand Up @@ -330,6 +344,16 @@ public struct Flags {
@Flag(name: .long, help: "Mount the container's root filesystem as read-only")
public var readOnly = false

/// EXPERIMENTAL: The flag is subject to change.
@Option(
name: .customLong("read-only-path"),
help: .init(
"[EXPERIMENTAL] Mark a path inside the container read-only, in addition to the runtime defaults (or NONE to clear prior values and the defaults)",
valueName: "path"
)
)
public var readonlyPaths: [String] = []

@Flag(name: [.customLong("rm"), .long], help: "Remove the container after it stops")
public var remove = false

Expand Down
54 changes: 54 additions & 0 deletions Sources/Services/ContainerAPIService/Client/Parser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1056,6 +1056,60 @@ public struct Parser {
return (normalizedAdd, normalizedDrop)
}

// MARK: Security paths

/// Sentinel that clears all previously accumulated paths, including the runtime defaults.
private static let pathResetSentinel = "NONE"

/// Parse and validate --masked-path arguments.
///
/// Values are processed in order on top of the runtime default set, so
/// `--masked-path /foo` yields the defaults plus `/foo`. The `NONE` sentinel
/// clears everything accumulated so far, including the defaults. A nil result
/// means the flag was not supplied and the runtime defaults apply unchanged.
public static func maskedPaths(_ values: [String]) throws -> [String]? {
try pathOverrides(values, defaults: LinuxContainer.defaultMaskedPaths(), flagName: "masked-path")
}

/// Parse and validate --read-only-path arguments. Ordering, the `NONE`
/// sentinel, and the nil result carry the same meaning as ``maskedPaths(_:)``.
public static func readonlyPaths(_ values: [String]) throws -> [String]? {
try pathOverrides(values, defaults: LinuxContainer.defaultReadonlyPaths(), flagName: "read-only-path")
}

/// Accumulate absolute paths on top of `defaults`, honoring the `NONE` reset
/// sentinel and dropping duplicates while preserving first-occurrence order.
private static func pathOverrides(_ values: [String], defaults: [String], flagName: String) throws -> [String]? {
guard !values.isEmpty else {
return nil
}
var paths = defaults
var seen = Set(defaults)
for value in values {
let trimmed = value.trimmingCharacters(in: .whitespaces)
if trimmed.uppercased() == pathResetSentinel {
paths = []
seen = []
continue
}
guard trimmed.hasPrefix("/") else {
throw ContainerizationError(
.invalidArgument,
message: "invalid path '\(value)' for --\(flagName): path must be absolute, or the \(pathResetSentinel) sentinel"
)
}
// Strip trailing slashes, preserving the root path itself.
var normalized = trimmed
while normalized.count > 1 && normalized.hasSuffix("/") {
normalized.removeLast()
}
if seen.insert(normalized).inserted {
paths.append(normalized)
}
}
return paths
}

// MARK: Miscellaneous

public static func parseBool(string: String) -> Bool? {
Expand Down
2 changes: 2 additions & 0 deletions Sources/Services/ContainerAPIService/Client/Utility.swift
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,8 @@ public struct Utility {
let caps = try Parser.capabilities(capAdd: management.capAdd, capDrop: management.capDrop)
config.capAdd = caps.capAdd
config.capDrop = caps.capDrop
config.maskedPaths = try Parser.maskedPaths(management.maskedPaths)
config.readonlyPaths = try Parser.readonlyPaths(management.readonlyPaths)
config.stopSignal = imageConfig?.stopSignal

if let runtime = management.runtime {
Expand Down
147 changes: 147 additions & 0 deletions Tests/ContainerAPIClientTests/ParserTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
// limitations under the License.
//===----------------------------------------------------------------------===//

import Containerization
import ContainerizationError
import ContainerizationExtras
import Foundation
Expand Down Expand Up @@ -1196,6 +1197,152 @@ struct ParserTest {
}
}

// MARK: - Masked Paths Parser Tests

@Test
func testMaskedPathsParserEmpty() throws {
#expect(try Parser.maskedPaths([]) == nil)
}

@Test
func testMaskedPathsParserAppendsToDefaults() throws {
let result = try Parser.maskedPaths(["/run/secrets"])
#expect(result == LinuxContainer.defaultMaskedPaths() + ["/run/secrets"])
}

@Test
func testMaskedPathsParserResetSentinelOnly() throws {
#expect(try Parser.maskedPaths(["NONE"]) == [])
}

@Test
func testMaskedPathsParserResetSentinelThenPath() throws {
#expect(try Parser.maskedPaths(["NONE", "/run/secrets"]) == ["/run/secrets"])
}

@Test
func testMaskedPathsParserPathThenResetSentinel() throws {
#expect(try Parser.maskedPaths(["/run/secrets", "NONE"]) == [])
}

@Test
func testMaskedPathsParserResetSentinelCaseInsensitive() throws {
#expect(try Parser.maskedPaths(["none"]) == [])
#expect(try Parser.maskedPaths(["None"]) == [])
}

@Test
func testMaskedPathsParserOrderedResets() throws {
#expect(try Parser.maskedPaths(["/a", "NONE", "/b", "/c"]) == ["/b", "/c"])
}

@Test
func testMaskedPathsParserStripsTrailingSlash() throws {
#expect(try Parser.maskedPaths(["NONE", "/run/secrets/"]) == ["/run/secrets"])
#expect(try Parser.maskedPaths(["NONE", "/"]) == ["/"])
}

@Test
func testMaskedPathsParserTrimsWhitespace() throws {
#expect(try Parser.maskedPaths(["NONE", " /run/secrets "]) == ["/run/secrets"])
}

@Test
func testMaskedPathsParserDedupesRepeatedValues() throws {
#expect(try Parser.maskedPaths(["NONE", "/run/secrets", "/run/secrets/", "/run/secrets"]) == ["/run/secrets"])
}

@Test
func testMaskedPathsParserDedupesAgainstDefaults() throws {
let defaults = LinuxContainer.defaultMaskedPaths()
#expect(try Parser.maskedPaths([defaults[0]]) == defaults)
}

@Test
func testMaskedPathsParserRelativePath() throws {
#expect {
_ = try Parser.maskedPaths(["proc/kcore"])
} throws: { error in
"\(error)".contains("proc/kcore") && "\(error)".contains("masked-path")
}
}

@Test
func testMaskedPathsParserEmptyValue() throws {
#expect {
_ = try Parser.maskedPaths([""])
} throws: { _ in
true
}
}

// MARK: - Readonly Paths Parser Tests

@Test
func testReadonlyPathsParserEmpty() throws {
#expect(try Parser.readonlyPaths([]) == nil)
}

@Test
func testReadonlyPathsParserAppendsToDefaults() throws {
let result = try Parser.readonlyPaths(["/etc/config"])
#expect(result == LinuxContainer.defaultReadonlyPaths() + ["/etc/config"])
}

@Test
func testReadonlyPathsParserResetSentinelOnly() throws {
#expect(try Parser.readonlyPaths(["NONE"]) == [])
}

@Test
func testReadonlyPathsParserResetSentinelThenPath() throws {
#expect(try Parser.readonlyPaths(["NONE", "/etc/config"]) == ["/etc/config"])
}

@Test
func testReadonlyPathsParserPathThenResetSentinel() throws {
#expect(try Parser.readonlyPaths(["/etc/config", "NONE"]) == [])
}

@Test
func testReadonlyPathsParserResetSentinelCaseInsensitive() throws {
#expect(try Parser.readonlyPaths(["none"]) == [])
}

@Test
func testReadonlyPathsParserOrderedResets() throws {
#expect(try Parser.readonlyPaths(["/a", "NONE", "/b", "/c"]) == ["/b", "/c"])
}

@Test
func testReadonlyPathsParserStripsTrailingSlash() throws {
#expect(try Parser.readonlyPaths(["NONE", "/etc/config/"]) == ["/etc/config"])
}

@Test
func testReadonlyPathsParserDedupesAgainstDefaults() throws {
let defaults = LinuxContainer.defaultReadonlyPaths()
#expect(try Parser.readonlyPaths([defaults[0]]) == defaults)
}

@Test
func testReadonlyPathsParserRelativePath() throws {
#expect {
_ = try Parser.readonlyPaths(["proc/sys"])
} throws: { error in
"\(error)".contains("proc/sys") && "\(error)".contains("read-only-path")
}
}

@Test
func testReadonlyPathsParserDefaultsAreDistinctFromMaskedPaths() throws {
let masked = try Parser.maskedPaths(["/shared"])
let readonly = try Parser.readonlyPaths(["/shared"])
#expect(masked == LinuxContainer.defaultMaskedPaths() + ["/shared"])
#expect(readonly == LinuxContainer.defaultReadonlyPaths() + ["/shared"])
#expect(masked != readonly)
}

// MARK: - Parser.resources

@Test func testResourcesCustomDefaults() throws {
Expand Down
Loading