Skip to content

Commit ddaf2ca

Browse files
authored
Add --read-only-path and --masked-path option to container run / create (#2069)
1 parent cee0ba8 commit ddaf2ca

7 files changed

Lines changed: 438 additions & 0 deletions

File tree

Sources/Services/ContainerAPIService/Client/Flags.swift

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,7 @@ public struct Flags {
178178
kernel: String?,
179179
kernelArgs: [String],
180180
labels: [String],
181+
maskedPaths: [String],
181182
mounts: [String],
182183
name: String?,
183184
networks: [String],
@@ -186,6 +187,7 @@ public struct Flags {
186187
publishPorts: [String],
187188
publishSockets: [String],
188189
readOnly: Bool,
190+
readonlyPaths: [String],
189191
remove: Bool,
190192
rosetta: Bool,
191193
runtime: String?,
@@ -208,6 +210,7 @@ public struct Flags {
208210
self.kernel = kernel
209211
self.kernelArgs = kernelArgs
210212
self.labels = labels
213+
self.maskedPaths = maskedPaths
211214
self.mounts = mounts
212215
self.name = name
213216
self.networks = networks
@@ -216,6 +219,7 @@ public struct Flags {
216219
self.publishPorts = publishPorts
217220
self.publishSockets = publishSockets
218221
self.readOnly = readOnly
222+
self.readonlyPaths = readonlyPaths
219223
self.remove = remove
220224
self.rosetta = rosetta
221225
self.runtime = runtime
@@ -291,6 +295,16 @@ public struct Flags {
291295
@Option(name: [.short, .customLong("label")], help: "Add a key=value label to the container")
292296
public var labels: [String] = []
293297

298+
/// EXPERIMENTAL: The flag is subject to change.
299+
@Option(
300+
name: .customLong("masked-path"),
301+
help: .init(
302+
"[EXPERIMENTAL] Hide a path inside the container, in addition to the runtime defaults (or NONE to clear prior values and the defaults)",
303+
valueName: "path"
304+
)
305+
)
306+
public var maskedPaths: [String] = []
307+
294308
@Option(name: .customLong("mount"), help: "Add a mount to the container (format: type=<>,source=<>,target=<>,readonly)")
295309
public var mounts: [String] = []
296310

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

347+
/// EXPERIMENTAL: The flag is subject to change.
348+
@Option(
349+
name: .customLong("read-only-path"),
350+
help: .init(
351+
"[EXPERIMENTAL] Mark a path inside the container read-only, in addition to the runtime defaults (or NONE to clear prior values and the defaults)",
352+
valueName: "path"
353+
)
354+
)
355+
public var readonlyPaths: [String] = []
356+
333357
@Flag(name: [.customLong("rm"), .long], help: "Remove the container after it stops")
334358
public var remove = false
335359

Sources/Services/ContainerAPIService/Client/Parser.swift

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1056,6 +1056,60 @@ public struct Parser {
10561056
return (normalizedAdd, normalizedDrop)
10571057
}
10581058

1059+
// MARK: Security paths
1060+
1061+
/// Sentinel that clears all previously accumulated paths, including the runtime defaults.
1062+
private static let pathResetSentinel = "NONE"
1063+
1064+
/// Parse and validate --masked-path arguments.
1065+
///
1066+
/// Values are processed in order on top of the runtime default set, so
1067+
/// `--masked-path /foo` yields the defaults plus `/foo`. The `NONE` sentinel
1068+
/// clears everything accumulated so far, including the defaults. A nil result
1069+
/// means the flag was not supplied and the runtime defaults apply unchanged.
1070+
public static func maskedPaths(_ values: [String]) throws -> [String]? {
1071+
try pathOverrides(values, defaults: LinuxContainer.defaultMaskedPaths(), flagName: "masked-path")
1072+
}
1073+
1074+
/// Parse and validate --read-only-path arguments. Ordering, the `NONE`
1075+
/// sentinel, and the nil result carry the same meaning as ``maskedPaths(_:)``.
1076+
public static func readonlyPaths(_ values: [String]) throws -> [String]? {
1077+
try pathOverrides(values, defaults: LinuxContainer.defaultReadonlyPaths(), flagName: "read-only-path")
1078+
}
1079+
1080+
/// Accumulate absolute paths on top of `defaults`, honoring the `NONE` reset
1081+
/// sentinel and dropping duplicates while preserving first-occurrence order.
1082+
private static func pathOverrides(_ values: [String], defaults: [String], flagName: String) throws -> [String]? {
1083+
guard !values.isEmpty else {
1084+
return nil
1085+
}
1086+
var paths = defaults
1087+
var seen = Set(defaults)
1088+
for value in values {
1089+
let trimmed = value.trimmingCharacters(in: .whitespaces)
1090+
if trimmed.uppercased() == pathResetSentinel {
1091+
paths = []
1092+
seen = []
1093+
continue
1094+
}
1095+
guard trimmed.hasPrefix("/") else {
1096+
throw ContainerizationError(
1097+
.invalidArgument,
1098+
message: "invalid path '\(value)' for --\(flagName): path must be absolute, or the \(pathResetSentinel) sentinel"
1099+
)
1100+
}
1101+
// Strip trailing slashes, preserving the root path itself.
1102+
var normalized = trimmed
1103+
while normalized.count > 1 && normalized.hasSuffix("/") {
1104+
normalized.removeLast()
1105+
}
1106+
if seen.insert(normalized).inserted {
1107+
paths.append(normalized)
1108+
}
1109+
}
1110+
return paths
1111+
}
1112+
10591113
// MARK: Miscellaneous
10601114

10611115
public static func parseBool(string: String) -> Bool? {

Sources/Services/ContainerAPIService/Client/Utility.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,8 @@ public struct Utility {
255255
let caps = try Parser.capabilities(capAdd: management.capAdd, capDrop: management.capDrop)
256256
config.capAdd = caps.capAdd
257257
config.capDrop = caps.capDrop
258+
config.maskedPaths = try Parser.maskedPaths(management.maskedPaths)
259+
config.readonlyPaths = try Parser.readonlyPaths(management.readonlyPaths)
258260
config.stopSignal = imageConfig?.stopSignal
259261

260262
if let runtime = management.runtime {

Tests/ContainerAPIClientTests/ParserTest.swift

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
// limitations under the License.
1515
//===----------------------------------------------------------------------===//
1616

17+
import Containerization
1718
import ContainerizationError
1819
import ContainerizationExtras
1920
import Foundation
@@ -1196,6 +1197,152 @@ struct ParserTest {
11961197
}
11971198
}
11981199

1200+
// MARK: - Masked Paths Parser Tests
1201+
1202+
@Test
1203+
func testMaskedPathsParserEmpty() throws {
1204+
#expect(try Parser.maskedPaths([]) == nil)
1205+
}
1206+
1207+
@Test
1208+
func testMaskedPathsParserAppendsToDefaults() throws {
1209+
let result = try Parser.maskedPaths(["/run/secrets"])
1210+
#expect(result == LinuxContainer.defaultMaskedPaths() + ["/run/secrets"])
1211+
}
1212+
1213+
@Test
1214+
func testMaskedPathsParserResetSentinelOnly() throws {
1215+
#expect(try Parser.maskedPaths(["NONE"]) == [])
1216+
}
1217+
1218+
@Test
1219+
func testMaskedPathsParserResetSentinelThenPath() throws {
1220+
#expect(try Parser.maskedPaths(["NONE", "/run/secrets"]) == ["/run/secrets"])
1221+
}
1222+
1223+
@Test
1224+
func testMaskedPathsParserPathThenResetSentinel() throws {
1225+
#expect(try Parser.maskedPaths(["/run/secrets", "NONE"]) == [])
1226+
}
1227+
1228+
@Test
1229+
func testMaskedPathsParserResetSentinelCaseInsensitive() throws {
1230+
#expect(try Parser.maskedPaths(["none"]) == [])
1231+
#expect(try Parser.maskedPaths(["None"]) == [])
1232+
}
1233+
1234+
@Test
1235+
func testMaskedPathsParserOrderedResets() throws {
1236+
#expect(try Parser.maskedPaths(["/a", "NONE", "/b", "/c"]) == ["/b", "/c"])
1237+
}
1238+
1239+
@Test
1240+
func testMaskedPathsParserStripsTrailingSlash() throws {
1241+
#expect(try Parser.maskedPaths(["NONE", "/run/secrets/"]) == ["/run/secrets"])
1242+
#expect(try Parser.maskedPaths(["NONE", "/"]) == ["/"])
1243+
}
1244+
1245+
@Test
1246+
func testMaskedPathsParserTrimsWhitespace() throws {
1247+
#expect(try Parser.maskedPaths(["NONE", " /run/secrets "]) == ["/run/secrets"])
1248+
}
1249+
1250+
@Test
1251+
func testMaskedPathsParserDedupesRepeatedValues() throws {
1252+
#expect(try Parser.maskedPaths(["NONE", "/run/secrets", "/run/secrets/", "/run/secrets"]) == ["/run/secrets"])
1253+
}
1254+
1255+
@Test
1256+
func testMaskedPathsParserDedupesAgainstDefaults() throws {
1257+
let defaults = LinuxContainer.defaultMaskedPaths()
1258+
#expect(try Parser.maskedPaths([defaults[0]]) == defaults)
1259+
}
1260+
1261+
@Test
1262+
func testMaskedPathsParserRelativePath() throws {
1263+
#expect {
1264+
_ = try Parser.maskedPaths(["proc/kcore"])
1265+
} throws: { error in
1266+
"\(error)".contains("proc/kcore") && "\(error)".contains("masked-path")
1267+
}
1268+
}
1269+
1270+
@Test
1271+
func testMaskedPathsParserEmptyValue() throws {
1272+
#expect {
1273+
_ = try Parser.maskedPaths([""])
1274+
} throws: { _ in
1275+
true
1276+
}
1277+
}
1278+
1279+
// MARK: - Readonly Paths Parser Tests
1280+
1281+
@Test
1282+
func testReadonlyPathsParserEmpty() throws {
1283+
#expect(try Parser.readonlyPaths([]) == nil)
1284+
}
1285+
1286+
@Test
1287+
func testReadonlyPathsParserAppendsToDefaults() throws {
1288+
let result = try Parser.readonlyPaths(["/etc/config"])
1289+
#expect(result == LinuxContainer.defaultReadonlyPaths() + ["/etc/config"])
1290+
}
1291+
1292+
@Test
1293+
func testReadonlyPathsParserResetSentinelOnly() throws {
1294+
#expect(try Parser.readonlyPaths(["NONE"]) == [])
1295+
}
1296+
1297+
@Test
1298+
func testReadonlyPathsParserResetSentinelThenPath() throws {
1299+
#expect(try Parser.readonlyPaths(["NONE", "/etc/config"]) == ["/etc/config"])
1300+
}
1301+
1302+
@Test
1303+
func testReadonlyPathsParserPathThenResetSentinel() throws {
1304+
#expect(try Parser.readonlyPaths(["/etc/config", "NONE"]) == [])
1305+
}
1306+
1307+
@Test
1308+
func testReadonlyPathsParserResetSentinelCaseInsensitive() throws {
1309+
#expect(try Parser.readonlyPaths(["none"]) == [])
1310+
}
1311+
1312+
@Test
1313+
func testReadonlyPathsParserOrderedResets() throws {
1314+
#expect(try Parser.readonlyPaths(["/a", "NONE", "/b", "/c"]) == ["/b", "/c"])
1315+
}
1316+
1317+
@Test
1318+
func testReadonlyPathsParserStripsTrailingSlash() throws {
1319+
#expect(try Parser.readonlyPaths(["NONE", "/etc/config/"]) == ["/etc/config"])
1320+
}
1321+
1322+
@Test
1323+
func testReadonlyPathsParserDedupesAgainstDefaults() throws {
1324+
let defaults = LinuxContainer.defaultReadonlyPaths()
1325+
#expect(try Parser.readonlyPaths([defaults[0]]) == defaults)
1326+
}
1327+
1328+
@Test
1329+
func testReadonlyPathsParserRelativePath() throws {
1330+
#expect {
1331+
_ = try Parser.readonlyPaths(["proc/sys"])
1332+
} throws: { error in
1333+
"\(error)".contains("proc/sys") && "\(error)".contains("read-only-path")
1334+
}
1335+
}
1336+
1337+
@Test
1338+
func testReadonlyPathsParserDefaultsAreDistinctFromMaskedPaths() throws {
1339+
let masked = try Parser.maskedPaths(["/shared"])
1340+
let readonly = try Parser.readonlyPaths(["/shared"])
1341+
#expect(masked == LinuxContainer.defaultMaskedPaths() + ["/shared"])
1342+
#expect(readonly == LinuxContainer.defaultReadonlyPaths() + ["/shared"])
1343+
#expect(masked != readonly)
1344+
}
1345+
11991346
// MARK: - Parser.resources
12001347

12011348
@Test func testResourcesCustomDefaults() throws {

0 commit comments

Comments
 (0)