Skip to content

Commit 16b3021

Browse files
committed
LinuxContainer/LinuxPod: Sort/normalize
To avoid a user accidentally shadowing a mount, we can sort mounts by depth (but stable sort so mounts of the same depth stay in the order provided). We should normalize as well.
1 parent 2c286fd commit 16b3021

File tree

5 files changed

+127
-2
lines changed

5 files changed

+127
-2
lines changed

Sources/Containerization/LinuxContainer.swift

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import ContainerizationOCI
2222
import Foundation
2323
import Logging
2424
import Synchronization
25+
import SystemPackage
2526

2627
import struct ContainerizationOS.Terminal
2728

@@ -671,7 +672,7 @@ extension LinuxContainer {
671672
))
672673
}
673674

674-
spec.mounts = mounts
675+
spec.mounts = cleanAndSortMounts(mounts)
675676

676677
let stdio = IOUtil.setup(
677678
portAllocator: self.hostVsockPorts,
@@ -1292,6 +1293,26 @@ extension AttachedFilesystem {
12921293
}
12931294
}
12941295

1296+
/// Normalize mount destinations via ``FilePath/lexicallyNormalized()`` and
1297+
/// sort mounts by the depth of their destination path. This ensures that
1298+
/// higher level mounts don't shadow other mounts. For example, if a user
1299+
/// specifies mounts for `/tmp/foo/bar` and `/tmp`, sorting by depth ensures
1300+
/// `/tmp` is mounted first without shadowing `/tmp/foo/bar`.
1301+
func cleanAndSortMounts(_ mounts: [ContainerizationOCI.Mount]) -> [ContainerizationOCI.Mount] {
1302+
var mounts = mounts
1303+
for i in mounts.indices {
1304+
mounts[i].destination = FilePath(mounts[i].destination).lexicallyNormalized().string
1305+
}
1306+
return sortMountsByDestinationDepth(mounts)
1307+
}
1308+
1309+
/// Sort mounts by the depth of their destination path.
1310+
func sortMountsByDestinationDepth(_ mounts: [ContainerizationOCI.Mount]) -> [ContainerizationOCI.Mount] {
1311+
mounts.sorted { a, b in
1312+
a.destination.split(separator: "/").count < b.destination.split(separator: "/").count
1313+
}
1314+
}
1315+
12951316
struct IOUtil {
12961317
static func setup(
12971318
portAllocator: borrowing Atomic<UInt32>,

Sources/Containerization/LinuxPod.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -558,7 +558,7 @@ extension LinuxPod {
558558
))
559559
}
560560

561-
spec.mounts = mounts
561+
spec.mounts = cleanAndSortMounts(mounts)
562562

563563
// Configure namespaces for the container
564564
var namespaces: [LinuxNamespace] = [

Sources/Integration/ContainerTests.swift

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4344,4 +4344,49 @@ extension IntegrationSuite {
43444344
throw error
43454345
}
43464346
}
4347+
4348+
// Verify that mounts are sorted by destination path depth so that a
4349+
// higher-level mount (e.g. /mnt) doesn't shadow a deeper mount
4350+
// (e.g. /mnt/deep/nested). Both directories are separate virtiofs
4351+
// shares; the sort ensures /mnt is mounted first and /mnt/deep/nested
4352+
// on top of it.
4353+
func testMountsSortedByDepth() async throws {
4354+
let id = "test-mount-sort-depth"
4355+
4356+
let bs = try await bootstrap(id)
4357+
let buffer = BufferWriter()
4358+
4359+
// Create two separate mount directories with distinct files.
4360+
let deepDir = FileManager.default.uniqueTemporaryDirectory(create: true)
4361+
try "deep-content".write(to: deepDir.appendingPathComponent("deep.txt"), atomically: true, encoding: .utf8)
4362+
4363+
let shallowDir = FileManager.default.uniqueTemporaryDirectory(create: true)
4364+
try "shallow-content".write(to: shallowDir.appendingPathComponent("shallow.txt"), atomically: true, encoding: .utf8)
4365+
4366+
// Add deeper mount first, then shallower mount. Without sorting the
4367+
// shallower mount would shadow the deeper one.
4368+
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
4369+
config.process.arguments = ["/bin/cat", "/mnt/deep/nested/deep.txt"]
4370+
config.mounts.append(.share(source: deepDir.path, destination: "/mnt/deep/nested"))
4371+
config.mounts.append(.share(source: shallowDir.path, destination: "/mnt"))
4372+
config.process.stdout = buffer
4373+
config.bootLog = bs.bootLog
4374+
}
4375+
4376+
try await container.create()
4377+
try await container.start()
4378+
4379+
let status = try await container.wait()
4380+
try await container.stop()
4381+
4382+
guard status.exitCode == 0 else {
4383+
throw IntegrationError.assert(msg: "process status \(status) != 0")
4384+
}
4385+
4386+
let value = String(data: buffer.data, encoding: .utf8)
4387+
guard value == "deep-content" else {
4388+
throw IntegrationError.assert(
4389+
msg: "expected 'deep-content' but got '\(value ?? "<nil>")'")
4390+
}
4391+
}
43474392
}

Sources/Integration/Suite.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,7 @@ struct IntegrationSuite: AsyncParsableCommand {
376376
Test("container noNewPrivileges exec", testNoNewPrivilegesExec),
377377
Test("container workingDir created", testWorkingDirCreated),
378378
Test("container workingDir exec created", testWorkingDirExecCreated),
379+
Test("container mount sort by depth", testMountsSortedByDepth),
379380

380381
// Pods
381382
Test("pod single container", testPodSingleContainer),

Tests/ContainerizationTests/MountTests.swift

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

17+
import ContainerizationOCI
1718
import Foundation
1819
import Testing
1920

@@ -40,4 +41,61 @@ struct MountTests {
4041
#expect(Bool(false), "Expected virtiofs runtime options")
4142
}
4243
}
44+
45+
@Test func sortMountsByDestinationDepthPreventsParentShadowing() {
46+
let mounts: [ContainerizationOCI.Mount] = [
47+
.init(destination: "/tmp/foo/bar"),
48+
.init(destination: "/tmp"),
49+
.init(destination: "/var/log/app"),
50+
.init(destination: "/var"),
51+
]
52+
53+
let sorted = sortMountsByDestinationDepth(mounts)
54+
55+
#expect(
56+
sorted.map(\.destination) == [
57+
"/tmp",
58+
"/var",
59+
"/tmp/foo/bar",
60+
"/var/log/app",
61+
])
62+
}
63+
64+
@Test func sortMountsByDestinationDepthPreservesOrderForEqualDepth() {
65+
let mounts: [ContainerizationOCI.Mount] = [
66+
.init(destination: "/b"),
67+
.init(destination: "/a"),
68+
.init(destination: "/c"),
69+
]
70+
71+
let sorted = sortMountsByDestinationDepth(mounts)
72+
73+
// All same depth, order should be preserved (stable sort).
74+
#expect(sorted.map(\.destination) == ["/b", "/a", "/c"])
75+
}
76+
77+
@Test func sortMountsByDestinationDepthHandlesTrailingAndDoubleSlashes() {
78+
let mounts: [ContainerizationOCI.Mount] = [
79+
.init(destination: "/a//b/c"),
80+
.init(destination: "/a/"),
81+
]
82+
83+
let sorted = cleanAndSortMounts(mounts)
84+
85+
// Paths are cleaned: "/a/" -> "/a", "/a//b/c" -> "/a/b/c"
86+
#expect(sorted.map(\.destination) == ["/a", "/a/b/c"])
87+
}
88+
89+
@Test func sortMountsByDestinationDepthCleansDotAndDotDot() {
90+
let mounts: [ContainerizationOCI.Mount] = [
91+
.init(destination: "/tmp/../foo"),
92+
.init(destination: "/tmp/./bar/baz"),
93+
.init(destination: "/"),
94+
]
95+
96+
let sorted = cleanAndSortMounts(mounts)
97+
98+
// "/tmp/../foo" -> "/foo", "/tmp/./bar/baz" -> "/tmp/bar/baz"
99+
#expect(sorted.map(\.destination) == ["/", "/foo", "/tmp/bar/baz"])
100+
}
43101
}

0 commit comments

Comments
 (0)