411 lines
15 KiB
Swift
411 lines
15 KiB
Swift
//===----------------------------------------------------------------------===//
|
|
// Copyright © 2025-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.
|
|
//===----------------------------------------------------------------------===//
|
|
|
|
import CShim
|
|
|
|
#if canImport(FoundationEssentials)
|
|
import FoundationEssentials
|
|
#else
|
|
import Foundation
|
|
#endif
|
|
|
|
#if canImport(Musl)
|
|
import Musl
|
|
private let _mount = Musl.mount
|
|
private let _umount = Musl.umount2
|
|
#elseif canImport(Glibc)
|
|
import Glibc
|
|
private let _mount = Glibc.mount
|
|
private let _umount = Glibc.umount2
|
|
#endif
|
|
|
|
// Mount package modeled closely from containerd's: https://github.com/containerd/containerd/tree/main/core/mount
|
|
|
|
/// `Mount` models a Linux mount (although potentially could be used on other unix platforms), and
|
|
/// provides a simple interface to mount what the type describes.
|
|
public struct Mount: Sendable {
|
|
// Type specifies the host-specific of the mount.
|
|
public var type: String
|
|
// Source specifies where to mount from. Depending on the host system, this
|
|
// can be a source path or device.
|
|
public var source: String
|
|
// Target specifies an optional subdirectory as a mountpoint.
|
|
public var target: String
|
|
// Options contains zero or more fstab-style mount options.
|
|
public var options: [String]
|
|
|
|
public init(type: String, source: String, target: String, options: [String]) {
|
|
self.type = type
|
|
self.source = source
|
|
self.target = target
|
|
self.options = options
|
|
}
|
|
}
|
|
|
|
extension Mount {
|
|
#if canImport(Glibc)
|
|
internal typealias Flag = Int
|
|
#else
|
|
internal typealias Flag = Int32
|
|
#endif
|
|
|
|
internal struct FlagBehavior {
|
|
let clear: Bool
|
|
let flag: Flag
|
|
|
|
public init(_ clear: Bool, _ flag: Flag) {
|
|
self.clear = clear
|
|
self.flag = flag
|
|
}
|
|
}
|
|
|
|
#if os(Linux)
|
|
internal static let flagsDictionary: [String: FlagBehavior] = [
|
|
"async": .init(true, MS_SYNCHRONOUS),
|
|
"atime": .init(true, MS_NOATIME),
|
|
"bind": .init(false, MS_BIND),
|
|
"defaults": .init(false, 0),
|
|
"dev": .init(true, MS_NODEV),
|
|
"diratime": .init(true, MS_NODIRATIME),
|
|
"dirsync": .init(false, MS_DIRSYNC),
|
|
"exec": .init(true, MS_NOEXEC),
|
|
"mand": .init(false, MS_MANDLOCK),
|
|
"noatime": .init(false, MS_NOATIME),
|
|
"nodev": .init(false, MS_NODEV),
|
|
"nodiratime": .init(false, MS_NODIRATIME),
|
|
"noexec": .init(false, MS_NOEXEC),
|
|
"nomand": .init(true, MS_MANDLOCK),
|
|
"norelatime": .init(true, MS_RELATIME),
|
|
"nostrictatime": .init(true, MS_STRICTATIME),
|
|
"nosuid": .init(false, MS_NOSUID),
|
|
"rbind": .init(false, MS_BIND | MS_REC),
|
|
"relatime": .init(false, MS_RELATIME),
|
|
"remount": .init(false, MS_REMOUNT),
|
|
"ro": .init(false, MS_RDONLY),
|
|
"rw": .init(true, MS_RDONLY),
|
|
"strictatime": .init(false, MS_STRICTATIME),
|
|
"suid": .init(true, MS_NOSUID),
|
|
"sync": .init(false, MS_SYNCHRONOUS),
|
|
]
|
|
|
|
internal struct MountOptions {
|
|
var flags: Int32
|
|
var data: [String]
|
|
|
|
public init(_ flags: Int32 = 0, data: [String] = []) {
|
|
self.flags = flags
|
|
self.data = data
|
|
}
|
|
}
|
|
|
|
/// Whether the mount is read only.
|
|
public var readOnly: Bool {
|
|
for option in self.options {
|
|
if option == "ro" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
/// 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 {
|
|
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_IFDIR
|
|
}
|
|
}
|
|
|
|
// 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
|
|
/// provide `createWithPerms` to set the permissions for the directory that
|
|
/// it will be mounted at.
|
|
public func mount(createWithPerms: Int16? = nil) throws {
|
|
try self.mountToTarget(target: self.target, createWithPerms: createWithPerms)
|
|
}
|
|
|
|
private func mountToTarget(target: String, createWithPerms: Int16?, targetResolved: Bool = false) throws {
|
|
let pageSize = sysconf(Int32(_SC_PAGESIZE))
|
|
|
|
let opts = parseMountOptions()
|
|
let dataString = opts.data.joined(separator: ",")
|
|
if dataString.count > pageSize {
|
|
throw Error.validation("data string exceeds page size (\(dataString.count) > \(pageSize))")
|
|
}
|
|
|
|
let propagationTypes: Int32 = Int32(MS_SHARED) | Int32(MS_PRIVATE) | Int32(MS_SLAVE) | Int32(MS_UNBINDABLE)
|
|
|
|
// Ensure propagation type change flags aren't included in other calls.
|
|
let originalFlags = opts.flags & ~(propagationTypes)
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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 sourceIsNonDir = false
|
|
var sourceStat = stat()
|
|
if stat(self.source, &sourceStat) == 0 {
|
|
sourceIsNonDir = (sourceStat.st_mode & S_IFMT) != S_IFDIR
|
|
}
|
|
|
|
if sourceIsNonDir {
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
if opts.flags & Int32(MS_REMOUNT) == 0 || !dataString.isEmpty {
|
|
guard _mount(self.source, target, self.type, UInt(originalFlags), dataString) == 0 else {
|
|
throw Error.errno(
|
|
errno,
|
|
"failed initial mount source=\(self.source) target=\(target) type=\(self.type) data=\(dataString)"
|
|
)
|
|
}
|
|
}
|
|
|
|
if opts.flags & propagationTypes != 0 {
|
|
// Change the propagation type.
|
|
let pflags = propagationTypes | Int32(MS_REC) | Int32(MS_SILENT)
|
|
guard _mount("", target, "", UInt(opts.flags & pflags), "") == 0 else {
|
|
throw Error.errno(errno, "failed propagation change mount")
|
|
}
|
|
}
|
|
|
|
let bindReadOnlyFlags = Int32(MS_BIND) | Int32(MS_RDONLY)
|
|
if originalFlags & bindReadOnlyFlags == bindReadOnlyFlags {
|
|
guard _mount("", target, "", UInt(originalFlags | Int32(MS_REMOUNT)), "") == 0 else {
|
|
throw Error.errno(errno, "failed bind mount")
|
|
}
|
|
}
|
|
}
|
|
|
|
private func mkdirAll(_ name: String, _ perm: Int16) throws {
|
|
try FileManager.default.createDirectory(
|
|
atPath: name,
|
|
withIntermediateDirectories: true,
|
|
attributes: [.posixPermissions: perm]
|
|
)
|
|
}
|
|
|
|
private func parseMountOptions() -> MountOptions {
|
|
var mountOpts = MountOptions()
|
|
for option in self.options {
|
|
if let entry = Self.flagsDictionary[option], entry.flag != 0 {
|
|
if entry.clear {
|
|
mountOpts.flags &= ~Int32(entry.flag)
|
|
} else {
|
|
mountOpts.flags |= Int32(entry.flag)
|
|
}
|
|
} else {
|
|
mountOpts.data.append(option)
|
|
}
|
|
}
|
|
return mountOpts
|
|
}
|
|
|
|
/// `Mount` errors
|
|
public enum Error: Swift.Error, CustomStringConvertible {
|
|
case errno(Int32, String)
|
|
case validation(String)
|
|
|
|
public var description: String {
|
|
switch self {
|
|
case .errno(let errno, let message):
|
|
return "mount failed with errno \(errno): \(message)"
|
|
case .validation(let message):
|
|
return "failed during validation: \(message)"
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
}
|