mirror of
https://github.com/apple/container.git
synced 2026-10-13 09:39:18 +00:00
Mount: Ensure symlinks stay scoped to rootfs (#559)
Today we don't resolve symlinks relative to the rootfs of the container as ContainerizationOS.Mount did not handle this. This means trying to mount something at /var/run (which typically is a symlink to /run in most cases) would resolve to /run in the root of the VM. This change adds some logic to handle this. ``` ➜ ./bin/cctl run --kernel bin/kernel.arm64 --mount /Users/dcantah/foo:/var/run/foo bash root@sandbox-vm:/# ls var/run/foo/ howdy ```
This commit is contained in:
1 parent
284db2ffea
commit
2bf2cedede
3 files changed
+259
-26
No files matched your search
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* Copyright © 2026 Apple Inc. and the Containerization project authors.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
#ifndef __OPENAT2_H
|
||||
#define __OPENAT2_H
|
||||
|
||||
#include <sys/types.h>
|
||||
|
||||
#ifndef RESOLVE_IN_ROOT
|
||||
#define RESOLVE_IN_ROOT 0x10
|
||||
#endif
|
||||
|
||||
struct cz_open_how {
|
||||
unsigned long long flags;
|
||||
unsigned long long mode;
|
||||
unsigned long long resolve;
|
||||
};
|
||||
|
||||
/// openat2(2) wrapper. Musl does not provide openat2 so we invoke the syscall
|
||||
/// directly. Requires Linux 5.6+.
|
||||
int CZ_openat2(int dirfd, const char *pathname, struct cz_open_how *how,
|
||||
size_t size);
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright © 2026 Apple Inc. and the Containerization project authors.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
#if defined(__linux__)
|
||||
|
||||
#include <sys/syscall.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include "openat2.h"
|
||||
|
||||
#ifndef SYS_openat2
|
||||
#define SYS_openat2 437
|
||||
#endif
|
||||
|
||||
int CZ_openat2(int dirfd, const char *pathname, struct cz_open_how *how,
|
||||
size_t size) {
|
||||
return syscall(SYS_openat2, dirfd, pathname, how, size);
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -14,6 +14,7 @@
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import CShim
|
||||
import Foundation
|
||||
|
||||
#if canImport(Musl)
|
||||
@@ -116,13 +117,170 @@ extension Mount {
|
||||
}
|
||||
|
||||
/// Mount the mount relative to `root` with the current set of data in the object.
|
||||
///
|
||||
/// Optionally provide `createWithPerms` to set the permissions for the directory that
|
||||
/// it will be mounted at.
|
||||
public func mount(root: String, createWithPerms: Int16? = nil) throws {
|
||||
var rootURL = URL(fileURLWithPath: root)
|
||||
rootURL = rootURL.resolvingSymlinksInPath()
|
||||
rootURL = rootURL.appendingPathComponent(self.target)
|
||||
try self.mountToTarget(target: rootURL.path, createWithPerms: createWithPerms)
|
||||
let fd = try secureResolveInRoot(root: root)
|
||||
defer { close(fd) }
|
||||
|
||||
let realPath = try readlinkProc(fd: fd)
|
||||
try self.mountToTarget(target: realPath, createWithPerms: createWithPerms, targetResolved: true)
|
||||
}
|
||||
|
||||
/// Open a path relative to `dirFd` using `openat2(2)` with `RESOLVE_IN_ROOT`.
|
||||
///
|
||||
/// All symlink resolution is confined to the directory tree beneath `dirFd`.
|
||||
/// Returns the file descriptor on success, or -1 on failure (with errno set).
|
||||
private func openInRoot(dirFd: Int32, path: String, flags: Int32, mode: UInt64 = 0) -> Int32 {
|
||||
path.withCString { cPath in
|
||||
var how = cz_open_how(
|
||||
flags: UInt64(flags),
|
||||
mode: mode,
|
||||
resolve: UInt64(RESOLVE_IN_ROOT)
|
||||
)
|
||||
return CZ_openat2(dirFd, cPath, &how, MemoryLayout<cz_open_how>.size)
|
||||
}
|
||||
}
|
||||
|
||||
private func secureResolveInRoot(root: String) throws -> Int32 {
|
||||
let rootFd = open(root, O_RDONLY | O_DIRECTORY | O_CLOEXEC)
|
||||
guard rootFd >= 0 else {
|
||||
throw Error.errno(errno, "failed to open rootfs '\(root)'")
|
||||
}
|
||||
|
||||
// Determine if the leaf mount point should be a file or directory.
|
||||
let opts = parseMountOptions()
|
||||
let isBindMount = (opts.flags & Int32(MS_BIND)) != 0
|
||||
var leafIsFile = false
|
||||
if isBindMount {
|
||||
var sourceStat = stat()
|
||||
if stat(self.source, &sourceStat) == 0 {
|
||||
leafIsFile = (sourceStat.st_mode & S_IFMT) == S_IFREG
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize target to a relative path for openat2.
|
||||
let relativePath = self.target
|
||||
.split(separator: "/", omittingEmptySubsequences: true)
|
||||
.joined(separator: "/")
|
||||
|
||||
guard !relativePath.isEmpty else {
|
||||
return rootFd
|
||||
}
|
||||
|
||||
// Fast path: try openat2 with RESOLVE_IN_ROOT for the full path.
|
||||
let openFlags: Int32 =
|
||||
leafIsFile
|
||||
? (O_RDONLY | O_CLOEXEC)
|
||||
: (O_RDONLY | O_DIRECTORY | O_CLOEXEC)
|
||||
let fd = openInRoot(dirFd: rootFd, path: relativePath, flags: openFlags)
|
||||
if fd >= 0 {
|
||||
close(rootFd)
|
||||
return fd
|
||||
}
|
||||
|
||||
guard errno == ENOENT else {
|
||||
let savedErrno = errno
|
||||
close(rootFd)
|
||||
throw Error.errno(savedErrno, "failed to resolve '\(self.target)' in rootfs")
|
||||
}
|
||||
|
||||
// Part of the path doesn't exist. Use openat2 to find the deepest
|
||||
// existing ancestor, then create the missing components.
|
||||
return try createMountTarget(
|
||||
rootFd: rootFd, relativePath: relativePath, leafIsFile: leafIsFile
|
||||
)
|
||||
}
|
||||
|
||||
private func createMountTarget(
|
||||
rootFd: Int32,
|
||||
relativePath: String,
|
||||
leafIsFile: Bool
|
||||
) throws -> Int32 {
|
||||
let components = relativePath.split(separator: "/").map(String.init)
|
||||
var currentFd = rootFd
|
||||
var resultFd: Int32 = -1
|
||||
|
||||
// Centralized cleanup. On success resultFd holds the fd we return,
|
||||
// so we avoid closing it. On error resultFd is -1 and we close
|
||||
// everything.
|
||||
defer {
|
||||
if currentFd != rootFd && currentFd != resultFd { close(currentFd) }
|
||||
if rootFd != resultFd { close(rootFd) }
|
||||
}
|
||||
|
||||
func fail(_ savedErrno: Int32, _ message: String) throws -> Never {
|
||||
throw Error.errno(savedErrno, message)
|
||||
}
|
||||
|
||||
// Find the deepest existing directory using openat2 with RESOLVE_IN_ROOT.
|
||||
var firstMissing = 0
|
||||
for i in 0..<components.count {
|
||||
let subpath = components[0...i].joined(separator: "/")
|
||||
let nextFd = openInRoot(dirFd: rootFd, path: subpath, flags: O_RDONLY | O_DIRECTORY | O_CLOEXEC)
|
||||
if nextFd < 0 {
|
||||
firstMissing = i
|
||||
break
|
||||
}
|
||||
if currentFd != rootFd { close(currentFd) }
|
||||
currentFd = nextFd
|
||||
firstMissing = i + 1
|
||||
}
|
||||
|
||||
// Create missing directories and the leaf mount point.
|
||||
for i in firstMissing..<components.count {
|
||||
let component = components[i]
|
||||
let isLast = (i == components.count - 1)
|
||||
|
||||
if isLast && leafIsFile {
|
||||
// Use mknodat to create a regular file without opening it.
|
||||
let rc = mknodat(currentFd, component, S_IFREG | 0o644, 0)
|
||||
if rc != 0 && errno != EEXIST {
|
||||
try fail(errno, "failed to create mount point file '\(component)'")
|
||||
}
|
||||
let pathFd = openat(currentFd, component, O_RDONLY | O_NOFOLLOW | O_CLOEXEC)
|
||||
guard pathFd >= 0 else {
|
||||
try fail(errno, "failed to re-open mount point file '\(component)'")
|
||||
}
|
||||
resultFd = pathFd
|
||||
return resultFd
|
||||
}
|
||||
|
||||
guard mkdirat(currentFd, component, 0o755) == 0 else {
|
||||
try fail(errno, "failed to create directory '\(component)'")
|
||||
}
|
||||
|
||||
let dirFd = openat(currentFd, component, O_RDONLY | O_NOFOLLOW | O_DIRECTORY | O_CLOEXEC)
|
||||
guard dirFd >= 0 else {
|
||||
try fail(errno, "failed to open created directory '\(component)'")
|
||||
}
|
||||
|
||||
if isLast {
|
||||
resultFd = dirFd
|
||||
return resultFd
|
||||
}
|
||||
|
||||
if currentFd != rootFd { close(currentFd) }
|
||||
currentFd = dirFd
|
||||
}
|
||||
|
||||
// All components already existed.
|
||||
resultFd = currentFd
|
||||
return resultFd
|
||||
}
|
||||
|
||||
/// Resolve the real filesystem path for an open fd via /proc/self/fd.
|
||||
private func readlinkProc(fd: Int32) throws -> String {
|
||||
let procPath = "/proc/self/fd/\(fd)"
|
||||
var buffer = [CChar](repeating: 0, count: Int(PATH_MAX) + 1)
|
||||
let len = readlink(procPath, &buffer, buffer.count - 1)
|
||||
guard len > 0 else {
|
||||
throw Error.errno(errno, "readlink failed for '\(procPath)'")
|
||||
}
|
||||
return buffer.prefix(len).withUnsafeBufferPointer { buf in
|
||||
String(decoding: buf.map { UInt8(bitPattern: $0) }, as: UTF8.self)
|
||||
}
|
||||
}
|
||||
|
||||
/// Mount the mount with the current set of data in the object. Optionally
|
||||
@@ -132,7 +290,7 @@ extension Mount {
|
||||
try self.mountToTarget(target: self.target, createWithPerms: createWithPerms)
|
||||
}
|
||||
|
||||
private func mountToTarget(target: String, createWithPerms: Int16?) throws {
|
||||
private func mountToTarget(target: String, createWithPerms: Int16?, targetResolved: Bool = false) throws {
|
||||
let pageSize = sysconf(Int32(_SC_PAGESIZE))
|
||||
|
||||
let opts = parseMountOptions()
|
||||
@@ -146,33 +304,38 @@ extension Mount {
|
||||
// Ensure propagation type change flags aren't included in other calls.
|
||||
let originalFlags = opts.flags & ~(propagationTypes)
|
||||
|
||||
let targetURL = URL(fileURLWithPath: target)
|
||||
let targetParent = targetURL.deletingLastPathComponent().path
|
||||
if let perms = createWithPerms {
|
||||
try mkdirAll(targetParent, perms)
|
||||
}
|
||||
|
||||
// For bind mounts, check if the source is a file and create the target accordingly.
|
||||
let isBindMount = (originalFlags & Int32(MS_BIND)) != 0
|
||||
if isBindMount {
|
||||
var sourceIsFile = false
|
||||
var sourceStat = stat()
|
||||
if stat(self.source, &sourceStat) == 0 {
|
||||
sourceIsFile = (sourceStat.st_mode & S_IFMT) == S_IFREG
|
||||
// When targetResolved is true, the target path has already been securely
|
||||
// resolved and the mount point created by secureResolveInRoot. Skip
|
||||
// directory/file creation to avoid following symlinks in the target path.
|
||||
if !targetResolved {
|
||||
let targetURL = URL(fileURLWithPath: target)
|
||||
let targetParent = targetURL.deletingLastPathComponent().path
|
||||
if let perms = createWithPerms {
|
||||
try mkdirAll(targetParent, perms)
|
||||
}
|
||||
|
||||
if sourceIsFile {
|
||||
// Create parent directories and touch the target file
|
||||
try mkdirAll(targetParent, 0o755)
|
||||
let fd = open(target, O_WRONLY | O_CREAT, 0o644)
|
||||
if fd >= 0 {
|
||||
close(fd)
|
||||
// For bind mounts, check if the source is a file and create the target accordingly.
|
||||
let isBindMount = (originalFlags & Int32(MS_BIND)) != 0
|
||||
if isBindMount {
|
||||
var sourceIsFile = false
|
||||
var sourceStat = stat()
|
||||
if stat(self.source, &sourceStat) == 0 {
|
||||
sourceIsFile = (sourceStat.st_mode & S_IFMT) == S_IFREG
|
||||
}
|
||||
|
||||
if sourceIsFile {
|
||||
// Create parent directories and touch the target file
|
||||
try mkdirAll(targetParent, 0o755)
|
||||
let fd = open(target, O_WRONLY | O_CREAT, 0o644)
|
||||
if fd >= 0 {
|
||||
close(fd)
|
||||
}
|
||||
} else {
|
||||
try mkdirAll(target, 0o755)
|
||||
}
|
||||
} else {
|
||||
try mkdirAll(target, 0o755)
|
||||
}
|
||||
} else {
|
||||
try mkdirAll(target, 0o755)
|
||||
}
|
||||
|
||||
if opts.flags & Int32(MS_REMOUNT) == 0 || !dataString.isEmpty {
|
||||
|
||||
Reference in new issue
Block a user