chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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 FoundationEssentials
|
||||
import LCShim
|
||||
|
||||
#if canImport(Musl)
|
||||
import Musl
|
||||
private let _close = Musl.close
|
||||
#elseif canImport(Glibc)
|
||||
import Glibc
|
||||
private let _close = Glibc.close
|
||||
#endif
|
||||
|
||||
class Console {
|
||||
let master: Int32
|
||||
let slavePath: String
|
||||
|
||||
init() throws {
|
||||
let masterFD = open("/dev/ptmx", O_RDWR | O_NOCTTY | O_CLOEXEC)
|
||||
guard masterFD != -1 else {
|
||||
throw App.Errno(stage: "open_ptmx")
|
||||
}
|
||||
|
||||
guard unlockpt(masterFD) == 0 else {
|
||||
throw App.Errno(stage: "unlockpt")
|
||||
}
|
||||
|
||||
guard let slavePath = ptsname(masterFD) else {
|
||||
throw App.Errno(stage: "ptsname")
|
||||
}
|
||||
|
||||
self.master = masterFD
|
||||
self.slavePath = String(cString: slavePath)
|
||||
}
|
||||
|
||||
func configureStdIO() throws {
|
||||
let path = self.slavePath
|
||||
let slaveFD = open(path, O_RDWR)
|
||||
guard slaveFD != -1 else {
|
||||
throw App.Errno(stage: "open_pts")
|
||||
}
|
||||
defer { _ = _close(slaveFD) }
|
||||
|
||||
for fd: Int32 in 0...2 {
|
||||
guard dup3(slaveFD, fd, 0) != -1 else {
|
||||
throw App.Errno(stage: "dup3")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func close() throws {
|
||||
guard _close(self.master) == 0 else {
|
||||
throw App.Errno(stage: "close")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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 ArgumentParser
|
||||
import ContainerizationOCI
|
||||
import ContainerizationOS
|
||||
import FoundationEssentials
|
||||
import LCShim
|
||||
import Logging
|
||||
import SystemPackage
|
||||
|
||||
#if canImport(Musl)
|
||||
import Musl
|
||||
#elseif canImport(Glibc)
|
||||
import Glibc
|
||||
#endif
|
||||
|
||||
struct ExecCommand: ParsableCommand {
|
||||
static let configuration = CommandConfiguration(
|
||||
commandName: "exec",
|
||||
abstract: "Exec in a container"
|
||||
)
|
||||
|
||||
@Option(name: .long, help: "path to an OCI runtime spec process configuration")
|
||||
var processPath: String
|
||||
|
||||
@Option(name: .long, help: "pid of the init process for the container")
|
||||
var parentPid: Int
|
||||
|
||||
func run() throws {
|
||||
do {
|
||||
let src = URL(fileURLWithPath: processPath)
|
||||
let processBytes = try Data(contentsOf: src)
|
||||
let process = try JSONDecoder().decode(
|
||||
ContainerizationOCI.Process.self,
|
||||
from: processBytes
|
||||
)
|
||||
try execInNamespaces(process: process)
|
||||
} catch {
|
||||
App.writeError(error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
static func enterNS(pidFd: Int32, nsType: Int32) throws {
|
||||
guard setns(pidFd, nsType) == 0 else {
|
||||
throw App.Errno(stage: "setns(fd)")
|
||||
}
|
||||
}
|
||||
|
||||
private func execInNamespaces(process: ContainerizationOCI.Process) throws {
|
||||
let syncPipe = FileDescriptor(rawValue: 3)
|
||||
let ackPipe = FileDescriptor(rawValue: 4)
|
||||
|
||||
let pidFd = CZ_pidfd_open(Int32(parentPid), 0)
|
||||
guard pidFd > 0 else {
|
||||
throw App.Errno(stage: "pidfd_open(\(parentPid))")
|
||||
}
|
||||
try Self.enterNS(
|
||||
pidFd: pidFd,
|
||||
nsType: CLONE_NEWCGROUP | CLONE_NEWPID | CLONE_NEWUTS | CLONE_NEWNS
|
||||
)
|
||||
|
||||
let processID = fork()
|
||||
|
||||
guard processID != -1 else {
|
||||
try? syncPipe.close()
|
||||
try? ackPipe.close()
|
||||
|
||||
throw App.Errno(stage: "fork")
|
||||
}
|
||||
|
||||
if processID == 0 { // child
|
||||
// Wait for the grandparent to tell us that they acked our pid.
|
||||
var pidAckBuffer = [UInt8](repeating: 0, count: App.ackPid.count)
|
||||
let pidAckBytesRead = try pidAckBuffer.withUnsafeMutableBytes { buffer in
|
||||
try ackPipe.read(into: buffer)
|
||||
}
|
||||
guard pidAckBytesRead > 0 else {
|
||||
throw App.Failure(message: "read ack pipe")
|
||||
}
|
||||
let pidAckStr = String(decoding: pidAckBuffer[..<pidAckBytesRead], as: UTF8.self)
|
||||
|
||||
guard pidAckStr == App.ackPid else {
|
||||
throw App.Failure(message: "received invalid acknowledgement string: \(pidAckStr)")
|
||||
}
|
||||
|
||||
guard setsid() != -1 else {
|
||||
throw App.Errno(stage: "setsid()")
|
||||
}
|
||||
|
||||
if process.terminal {
|
||||
let pty = try Console()
|
||||
try pty.configureStdIO()
|
||||
var masterFD = pty.master
|
||||
|
||||
try withUnsafeBytes(of: &masterFD) { bytes in
|
||||
_ = try syncPipe.write(bytes)
|
||||
}
|
||||
|
||||
// Wait for the grandparent to tell us that they acked our console.
|
||||
var consoleAckBuffer = [UInt8](repeating: 0, count: App.ackConsole.count)
|
||||
let consoleAckBytesRead = try consoleAckBuffer.withUnsafeMutableBytes { buffer in
|
||||
try ackPipe.read(into: buffer)
|
||||
}
|
||||
guard consoleAckBytesRead > 0 else {
|
||||
throw App.Failure(message: "read ack pipe")
|
||||
}
|
||||
let consoleAckStr = String(decoding: consoleAckBuffer[..<consoleAckBytesRead], as: UTF8.self)
|
||||
|
||||
guard consoleAckStr == App.ackConsole else {
|
||||
throw App.Failure(message: "received invalid acknowledgement string: \(consoleAckStr)")
|
||||
}
|
||||
|
||||
guard ioctl(0, UInt(TIOCSCTTY), 0) != -1 else {
|
||||
throw App.Errno(stage: "setctty(0)")
|
||||
}
|
||||
try pty.close()
|
||||
}
|
||||
|
||||
// Apply O_CLOEXEC to all file descriptors except stdio.
|
||||
// This ensures that all unwanted fds we may have accidentally
|
||||
// inherited are marked close-on-exec so they stay out of the
|
||||
// container.
|
||||
try App.applyCloseExecOnFDs()
|
||||
try App.setRLimits(rlimits: process.rlimits)
|
||||
|
||||
// Prepare capabilities (before user change)
|
||||
let preparedCaps = try App.prepareCapabilities(capabilities: process.capabilities ?? ContainerizationOCI.LinuxCapabilities())
|
||||
|
||||
// Change stdio to be owned by the requested user.
|
||||
try App.fixStdioPerms(user: process.user)
|
||||
|
||||
// Set uid, gid, and supplementary groups
|
||||
try App.setPermissions(user: process.user)
|
||||
|
||||
// Finish capabilities (after user change)
|
||||
try App.finishCapabilities(preparedCaps)
|
||||
|
||||
// Set no_new_privs if requested by the OCI spec.
|
||||
try App.setNoNewPrivileges(process: process)
|
||||
|
||||
try App.exec(process: process, currentEnv: process.env)
|
||||
} else { // parent process
|
||||
// Send our child's pid to our parent before we exit.
|
||||
var childPid = processID
|
||||
try withUnsafeBytes(of: &childPid) { bytes in
|
||||
_ = try syncPipe.write(bytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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 ContainerizationOCI
|
||||
import ContainerizationOS
|
||||
import FoundationEssentials
|
||||
|
||||
#if canImport(Musl)
|
||||
import Musl
|
||||
#elseif canImport(Glibc)
|
||||
import Glibc
|
||||
#endif
|
||||
|
||||
struct ContainerMount {
|
||||
private let mounts: [ContainerizationOCI.Mount]
|
||||
private let rootfs: String
|
||||
|
||||
init(rootfs: String, mounts: [ContainerizationOCI.Mount]) {
|
||||
self.rootfs = rootfs
|
||||
self.mounts = mounts
|
||||
}
|
||||
|
||||
func mountToRootfs() throws {
|
||||
for m in self.mounts {
|
||||
let osMount = m.toOSMount()
|
||||
try osMount.mount(root: self.rootfs)
|
||||
}
|
||||
}
|
||||
|
||||
func configureConsole() throws {
|
||||
let ptmx = rootfs + "/dev/ptmx"
|
||||
guard remove(ptmx) == 0 else {
|
||||
throw App.Errno(stage: "remove(ptmx)")
|
||||
}
|
||||
guard symlink("pts/ptmx", ptmx) == 0 else {
|
||||
throw App.Errno(stage: "symlink(pts/ptmx)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ContainerizationOCI.Mount {
|
||||
func toOSMount() -> ContainerizationOS.Mount {
|
||||
ContainerizationOS.Mount(
|
||||
type: self.type,
|
||||
source: self.source,
|
||||
target: self.destination,
|
||||
options: self.options
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,502 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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 ArgumentParser
|
||||
import Cgroup
|
||||
import ContainerizationOCI
|
||||
import ContainerizationOS
|
||||
import FoundationEssentials
|
||||
import LCShim
|
||||
import SystemPackage
|
||||
|
||||
#if canImport(Musl)
|
||||
import Musl
|
||||
#elseif canImport(Glibc)
|
||||
import Glibc
|
||||
#endif
|
||||
|
||||
struct RunCommand: ParsableCommand {
|
||||
static let configuration = CommandConfiguration(
|
||||
commandName: "run",
|
||||
abstract: "Run a container"
|
||||
)
|
||||
|
||||
@Option(name: .long, help: "path to an OCI bundle")
|
||||
var bundlePath: String
|
||||
|
||||
mutating func run() throws {
|
||||
do {
|
||||
let spec: ContainerizationOCI.Spec
|
||||
do {
|
||||
let bundle = try ContainerizationOCI.Bundle.load(path: URL(filePath: bundlePath))
|
||||
spec = try bundle.loadConfig()
|
||||
} catch {
|
||||
throw App.Failure(message: "failed to load OCI bundle at \(bundlePath): \(error)")
|
||||
}
|
||||
try execInNamespace(spec: spec)
|
||||
} catch {
|
||||
App.writeError(error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private func childRootSetup(
|
||||
rootfs: ContainerizationOCI.Root,
|
||||
mounts: [ContainerizationOCI.Mount]
|
||||
) throws {
|
||||
// setup rootfs
|
||||
try prepareRoot(rootfs: rootfs.path)
|
||||
try mountRootfs(rootfs: rootfs.path, mounts: mounts)
|
||||
try setDevSymlinks(rootfs: rootfs.path)
|
||||
|
||||
try pivotRoot(rootfs: rootfs.path)
|
||||
|
||||
// Remount ro if requested.
|
||||
if rootfs.readonly {
|
||||
try self.remountRootfsReadOnly()
|
||||
}
|
||||
|
||||
try reOpenDevNull()
|
||||
}
|
||||
|
||||
/// Mask paths per OCI `linux.maskedPaths`. Files (and any non-directory)
|
||||
/// get `/dev/null` bind-mounted on top; directories get an empty read-only
|
||||
/// tmpfs. Missing paths are skipped silently — matches runc's `maskPath`.
|
||||
private func applyMaskedPaths(_ paths: [String]) throws {
|
||||
for path in paths {
|
||||
var st = stat()
|
||||
if stat(path, &st) != 0 {
|
||||
if errno == ENOENT {
|
||||
continue
|
||||
}
|
||||
throw App.Errno(stage: "stat(\(path)) for mask")
|
||||
}
|
||||
|
||||
if (st.st_mode & S_IFMT) == S_IFDIR {
|
||||
// Match runc: mask directories with a read-only tmpfs. MS_RDONLY
|
||||
// is what actually prevents writes into the masked dir; a
|
||||
// `size=0k` option would be a no-op (the kernel treats tmpfs
|
||||
// size=0 as "no limit", not an empty filesystem).
|
||||
guard mount("tmpfs", path, "tmpfs", UInt(MS_RDONLY | MS_NOSUID | MS_NODEV | MS_NOEXEC), nil) == 0 else {
|
||||
throw App.Errno(stage: "mount(tmpfs mask \(path))")
|
||||
}
|
||||
} else {
|
||||
guard mount("/dev/null", path, "bind", UInt(MS_BIND), nil) == 0 else {
|
||||
throw App.Errno(stage: "mount(bind /dev/null -> \(path))")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Make paths read-only per OCI `linux.readonlyPaths` by bind-mounting
|
||||
/// each onto itself and remounting with `MS_RDONLY`. Missing paths are
|
||||
/// skipped silently — matches runc's `readonlyPath`. The statfs fallback
|
||||
/// mirrors `remountRootfsReadOnly()` for filesystems whose existing flags
|
||||
/// (e.g. nosuid, nodev) must be preserved on the remount.
|
||||
private func applyReadonlyPaths(_ paths: [String]) throws {
|
||||
for path in paths {
|
||||
var st = stat()
|
||||
if stat(path, &st) != 0 {
|
||||
if errno == ENOENT {
|
||||
continue
|
||||
}
|
||||
throw App.Errno(stage: "stat(\(path)) for readonly")
|
||||
}
|
||||
|
||||
guard mount(path, path, "", UInt(MS_BIND | MS_REC), nil) == 0 else {
|
||||
throw App.Errno(stage: "mount(bind \(path))")
|
||||
}
|
||||
|
||||
var flags = UInt(MS_BIND | MS_REMOUNT | MS_RDONLY)
|
||||
if mount("", path, "", flags, "") == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
var s = statfs()
|
||||
guard statfs(path, &s) == 0 else {
|
||||
throw App.Errno(stage: "statfs(\(path))")
|
||||
}
|
||||
flags |= UInt(s.f_flags)
|
||||
|
||||
guard mount("", path, "", flags, "") == 0 else {
|
||||
throw App.Errno(stage: "mount remount-ro \(path)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func remountRootfsReadOnly() throws {
|
||||
var flags = UInt(MS_BIND | MS_REMOUNT | MS_RDONLY)
|
||||
|
||||
let ret = mount("", "/", "", flags, "")
|
||||
if ret == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
var s = statfs()
|
||||
guard statfs("/", &s) == 0 else {
|
||||
throw App.Errno(stage: "statfs(/)")
|
||||
}
|
||||
flags |= UInt(s.f_flags)
|
||||
|
||||
guard mount("", "/", "", flags, "") == 0 else {
|
||||
throw App.Errno(stage: "mount rootfs ro")
|
||||
}
|
||||
}
|
||||
|
||||
private func childSetup(
|
||||
spec: ContainerizationOCI.Spec,
|
||||
ackPipe: FileDescriptor,
|
||||
syncPipe: FileDescriptor
|
||||
) throws {
|
||||
guard let process = spec.process else {
|
||||
throw App.Failure(message: "no process configuration found in runtime spec")
|
||||
}
|
||||
guard let root = spec.root else {
|
||||
throw App.Failure(message: "no root found in runtime spec")
|
||||
}
|
||||
|
||||
// Wait for the grandparent to tell us that they acked our pid.
|
||||
var pidAckBuffer = [UInt8](repeating: 0, count: App.ackPid.count)
|
||||
let pidAckBytesRead = try pidAckBuffer.withUnsafeMutableBytes { buffer in
|
||||
try ackPipe.read(into: buffer)
|
||||
}
|
||||
guard pidAckBytesRead > 0 else {
|
||||
throw App.Failure(message: "read ack pipe")
|
||||
}
|
||||
let pidAckStr = String(decoding: pidAckBuffer[..<pidAckBytesRead], as: UTF8.self)
|
||||
|
||||
guard pidAckStr == App.ackPid else {
|
||||
throw App.Failure(message: "received invalid acknowledgement string: \(pidAckStr)")
|
||||
}
|
||||
|
||||
guard unshare(CLONE_NEWCGROUP) == 0 else {
|
||||
throw App.Errno(stage: "unshare(cgroup)")
|
||||
}
|
||||
|
||||
guard setsid() != -1 else {
|
||||
throw App.Errno(stage: "setsid()")
|
||||
}
|
||||
|
||||
try childRootSetup(rootfs: root, mounts: spec.mounts)
|
||||
|
||||
if process.terminal {
|
||||
let pty = try Console()
|
||||
try pty.configureStdIO()
|
||||
var masterFD = pty.master
|
||||
|
||||
try withUnsafeBytes(of: &masterFD) { bytes in
|
||||
_ = try syncPipe.write(bytes)
|
||||
}
|
||||
|
||||
// Wait for the grandparent to tell us that they acked our console.
|
||||
var consoleAckBuffer = [UInt8](repeating: 0, count: App.ackConsole.count)
|
||||
let consoleAckBytesRead = try consoleAckBuffer.withUnsafeMutableBytes { buffer in
|
||||
try ackPipe.read(into: buffer)
|
||||
}
|
||||
guard consoleAckBytesRead > 0 else {
|
||||
throw App.Failure(message: "read ack pipe")
|
||||
}
|
||||
let consoleAckStr = String(decoding: consoleAckBuffer[..<consoleAckBytesRead], as: UTF8.self)
|
||||
|
||||
guard consoleAckStr == App.ackConsole else {
|
||||
throw App.Failure(message: "received invalid acknowledgement string: \(consoleAckStr)")
|
||||
}
|
||||
|
||||
guard ioctl(0, UInt(TIOCSCTTY), 0) != -1 else {
|
||||
throw App.Errno(stage: "setctty(0)")
|
||||
}
|
||||
|
||||
try mountConsole(path: pty.slavePath)
|
||||
try pty.close()
|
||||
}
|
||||
|
||||
if !spec.hostname.isEmpty {
|
||||
let errCode = spec.hostname.withCString { ptr in
|
||||
sethostname(ptr, spec.hostname.count)
|
||||
}
|
||||
guard errCode == 0 else {
|
||||
throw App.Errno(stage: "sethostname()")
|
||||
}
|
||||
}
|
||||
|
||||
// Apply sysctls from the OCI spec.
|
||||
if let sysctls = spec.linux?.sysctl {
|
||||
for (key, value) in sysctls {
|
||||
let path = "/proc/sys/" + key.replacingOccurrences(of: ".", with: "/")
|
||||
let fd = open(path, O_WRONLY)
|
||||
guard fd >= 0 else {
|
||||
throw App.Errno(stage: "sysctl open(\(path))")
|
||||
}
|
||||
defer { close(fd) }
|
||||
let bytes = Array(value.utf8)
|
||||
let written = write(fd, bytes, bytes.count)
|
||||
guard written == bytes.count else {
|
||||
throw App.Errno(stage: "sysctl write(\(key)=\(value))")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply OCI maskedPaths/readonlyPaths AFTER sysctls (writes to
|
||||
// /proc/sys/* would otherwise fail once /proc/sys is remounted ro)
|
||||
// and BEFORE the user/capability change (mount() requires
|
||||
// CAP_SYS_ADMIN, which we still have here as root). Mask runs first
|
||||
// so a path appearing in both lists is hidden, not just locked.
|
||||
try self.applyMaskedPaths(spec.linux?.maskedPaths ?? [])
|
||||
try self.applyReadonlyPaths(spec.linux?.readonlyPaths ?? [])
|
||||
|
||||
// Apply O_CLOEXEC to all file descriptors except stdio.
|
||||
// This ensures that all unwanted fds we may have accidentally
|
||||
// inherited are marked close-on-exec so they stay out of the
|
||||
// container.
|
||||
try App.applyCloseExecOnFDs()
|
||||
|
||||
try App.setRLimits(rlimits: process.rlimits)
|
||||
|
||||
// Prepare capabilities (before user change)
|
||||
let preparedCaps = try App.prepareCapabilities(capabilities: process.capabilities ?? ContainerizationOCI.LinuxCapabilities())
|
||||
|
||||
// Change stdio to be owned by the requested user.
|
||||
try App.fixStdioPerms(user: process.user)
|
||||
|
||||
// Set uid, gid, and supplementary groups.
|
||||
try App.setPermissions(user: process.user)
|
||||
|
||||
// Finish capabilities (after user change)
|
||||
try App.finishCapabilities(preparedCaps)
|
||||
|
||||
// Set no_new_privs if requested by the OCI spec.
|
||||
try App.setNoNewPrivileges(process: process)
|
||||
|
||||
// Finally execve the container process.
|
||||
try App.exec(process: process, currentEnv: process.env)
|
||||
}
|
||||
|
||||
private func setupNamespaces(namespaces: [ContainerizationOCI.LinuxNamespace]?) throws -> Int32 {
|
||||
var unshareFlags: Int32 = 0
|
||||
|
||||
// Map namespace types to their corresponding CLONE flags
|
||||
let nsTypeToFlag: [ContainerizationOCI.LinuxNamespaceType: Int32] = [
|
||||
.pid: CLONE_NEWPID,
|
||||
.mount: CLONE_NEWNS,
|
||||
.uts: CLONE_NEWUTS,
|
||||
.ipc: CLONE_NEWIPC,
|
||||
.user: CLONE_NEWUSER,
|
||||
.cgroup: CLONE_NEWCGROUP,
|
||||
]
|
||||
|
||||
guard let namespaces = namespaces else {
|
||||
return CLONE_NEWPID | CLONE_NEWNS | CLONE_NEWUTS
|
||||
}
|
||||
|
||||
for ns in namespaces {
|
||||
guard let flag = nsTypeToFlag[ns.type] else {
|
||||
continue
|
||||
}
|
||||
|
||||
if ns.path.isEmpty {
|
||||
unshareFlags |= flag
|
||||
} else {
|
||||
let fd = open(ns.path, O_RDONLY | O_CLOEXEC)
|
||||
guard fd >= 0 else {
|
||||
throw App.Errno(stage: "open(\(ns.path))")
|
||||
}
|
||||
defer { close(fd) }
|
||||
|
||||
guard setns(fd, flag) == 0 else {
|
||||
throw App.Errno(stage: "setns(\(ns.path))")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return unshareFlags
|
||||
}
|
||||
|
||||
private func execInNamespace(spec: ContainerizationOCI.Spec) throws {
|
||||
let syncPipe = FileDescriptor(rawValue: 3)
|
||||
let ackPipe = FileDescriptor(rawValue: 4)
|
||||
|
||||
let unshareFlags = try setupNamespaces(namespaces: spec.linux?.namespaces)
|
||||
|
||||
guard unshare(unshareFlags) == 0 else {
|
||||
throw App.Errno(stage: "unshare(\(unshareFlags))")
|
||||
}
|
||||
|
||||
let processID = fork()
|
||||
guard processID != -1 else {
|
||||
try? syncPipe.close()
|
||||
try? ackPipe.close()
|
||||
throw App.Errno(stage: "fork")
|
||||
}
|
||||
|
||||
if processID == 0 { // child
|
||||
try childSetup(spec: spec, ackPipe: ackPipe, syncPipe: syncPipe)
|
||||
} else { // parent process
|
||||
// Setup cgroup before child enters cgroup namespace
|
||||
if let linux = spec.linux {
|
||||
let cgroupPath = linux.cgroupsPath
|
||||
if !cgroupPath.isEmpty {
|
||||
let cgroupManager = try Cgroup2Manager.load(group: URL(filePath: cgroupPath))
|
||||
|
||||
if let resources = linux.resources {
|
||||
try cgroupManager.applyResources(resources: resources)
|
||||
}
|
||||
|
||||
try cgroupManager.addProcess(pid: processID)
|
||||
}
|
||||
}
|
||||
|
||||
// Send our child's pid before we exit.
|
||||
var childPid = processID
|
||||
try withUnsafeBytes(of: &childPid) { bytes in
|
||||
_ = try syncPipe.write(bytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func mountRootfs(rootfs: String, mounts: [ContainerizationOCI.Mount]) throws {
|
||||
let containerMount = ContainerMount(rootfs: rootfs, mounts: mounts)
|
||||
try containerMount.mountToRootfs()
|
||||
try containerMount.configureConsole()
|
||||
}
|
||||
|
||||
private func prepareRoot(rootfs: String) throws {
|
||||
guard mount("", "/", "", UInt(MS_SLAVE | MS_REC), nil) == 0 else {
|
||||
throw App.Errno(stage: "mount(slave|rec)")
|
||||
}
|
||||
|
||||
guard mount(rootfs, rootfs, "bind", UInt(MS_BIND | MS_REC), nil) == 0 else {
|
||||
throw App.Errno(stage: "mount(bind|rec)")
|
||||
}
|
||||
}
|
||||
|
||||
private func setDevSymlinks(rootfs: String) throws {
|
||||
let links: [(src: String, dst: String)] = [
|
||||
("/proc/self/fd", "/dev/fd"),
|
||||
("/proc/self/fd/0", "/dev/stdin"),
|
||||
("/proc/self/fd/1", "/dev/stdout"),
|
||||
("/proc/self/fd/2", "/dev/stderr"),
|
||||
("/dev/rtc0", "/dev/rtc"),
|
||||
]
|
||||
|
||||
let rootfsURL = URL(fileURLWithPath: rootfs)
|
||||
for (src, dst) in links {
|
||||
let dest = rootfsURL.appendingPathComponent(dst)
|
||||
guard symlink(src, dest.path) == 0 else {
|
||||
if errno == EEXIST {
|
||||
continue
|
||||
}
|
||||
throw App.Errno(stage: "symlink(\(src) -> \(dest.path))")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func reOpenDevNull() throws {
|
||||
let file = open("/dev/null", O_RDWR)
|
||||
guard file != -1 else {
|
||||
throw App.Errno(stage: "open(/dev/null)")
|
||||
}
|
||||
defer { close(file) }
|
||||
|
||||
var devNullStat = stat()
|
||||
try withUnsafeMutablePointer(to: &devNullStat) { pointer in
|
||||
guard fstat(file, pointer) == 0 else {
|
||||
throw App.Errno(stage: "fstat(/dev/null)")
|
||||
}
|
||||
}
|
||||
|
||||
for fd: Int32 in 0...2 {
|
||||
var fdStat = stat()
|
||||
try withUnsafeMutablePointer(to: &fdStat) { pointer in
|
||||
guard fstat(fd, pointer) == 0 else {
|
||||
throw App.Errno(stage: "fstat(fd)")
|
||||
}
|
||||
}
|
||||
|
||||
if fdStat.st_rdev == devNullStat.st_rdev {
|
||||
guard dup3(file, fd, 0) != -1 else {
|
||||
throw App.Errno(stage: "dup3(null)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pivots the rootfs of the calling process in the namespace to the provided
|
||||
/// rootfs in the argument.
|
||||
///
|
||||
/// The pivot_root(".", ".") and unmount old root approach is exactly the same
|
||||
/// as runc's pivot root implementation in:
|
||||
/// https://github.com/opencontainers/runc/blob/main/libcontainer/rootfs_linux.go
|
||||
private func pivotRoot(rootfs: String) throws {
|
||||
let oldRoot = open("/", O_RDONLY | O_DIRECTORY)
|
||||
if oldRoot <= 0 {
|
||||
throw App.Errno(stage: "open(oldroot)")
|
||||
}
|
||||
defer { close(oldRoot) }
|
||||
|
||||
let newRoot = open(rootfs, O_RDONLY | O_DIRECTORY)
|
||||
if newRoot <= 0 {
|
||||
throw App.Errno(stage: "open(newroot)")
|
||||
}
|
||||
defer { close(newRoot) }
|
||||
|
||||
// change cwd to the new root
|
||||
guard fchdir(newRoot) == 0 else {
|
||||
throw App.Errno(stage: "fchdir(newroot)")
|
||||
}
|
||||
guard CZ_pivot_root(toCString("."), toCString(".")) == 0 else {
|
||||
throw App.Errno(stage: "pivot_root()")
|
||||
}
|
||||
// change cwd to the old root
|
||||
guard fchdir(oldRoot) == 0 else {
|
||||
throw App.Errno(stage: "fchdir(oldroot)")
|
||||
}
|
||||
// mount old root rslave so that unmount doesn't propagate back to outside
|
||||
// the namespace
|
||||
guard mount("", ".", "", UInt(MS_SLAVE | MS_REC), nil) == 0 else {
|
||||
throw App.Errno(stage: "mount(., slave|rec)")
|
||||
}
|
||||
// unmount old root
|
||||
guard umount2(".", Int32(MNT_DETACH)) == 0 else {
|
||||
throw App.Errno(stage: "umount(.)")
|
||||
}
|
||||
// switch cwd to the new root
|
||||
guard chdir("/") == 0 else {
|
||||
throw App.Errno(stage: "chdir(/)")
|
||||
}
|
||||
}
|
||||
|
||||
private func toCString(_ str: String) -> UnsafeMutablePointer<CChar>? {
|
||||
let cString = str.utf8CString
|
||||
let cStringCopy = UnsafeMutableBufferPointer<CChar>.allocate(capacity: cString.count)
|
||||
_ = cStringCopy.initialize(from: cString)
|
||||
return UnsafeMutablePointer(cStringCopy.baseAddress)
|
||||
}
|
||||
|
||||
private func mountConsole(path: String) throws {
|
||||
let console = "/dev/console"
|
||||
if access(console, F_OK) != 0 {
|
||||
let fd = open(console, O_RDWR | O_CREAT, mode_t(UInt16(0o600)))
|
||||
guard fd != -1 else {
|
||||
throw App.Errno(stage: "open(/dev/console)")
|
||||
}
|
||||
close(fd)
|
||||
}
|
||||
|
||||
guard mount(path, console, "bind", UInt(MS_BIND), nil) == 0 else {
|
||||
throw App.Errno(stage: "mount(console)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
/// NOTE: This binary implements a very small subset of the OCI runtime spec, mostly just
|
||||
/// the process configurations. Mounts, masked paths, and read-only paths are enforced.
|
||||
/// The `network` namespace is currently ignored and we always spawn a new pid and mount
|
||||
/// namespace.
|
||||
|
||||
import ArgumentParser
|
||||
import ContainerizationError
|
||||
import ContainerizationOCI
|
||||
import ContainerizationOS
|
||||
import FoundationEssentials
|
||||
import LCShim
|
||||
import Logging
|
||||
import SystemPackage
|
||||
|
||||
#if canImport(Musl)
|
||||
import Musl
|
||||
#elseif canImport(Glibc)
|
||||
import Glibc
|
||||
#endif
|
||||
|
||||
@main
|
||||
struct App: ParsableCommand {
|
||||
static let ackPid = "AckPid"
|
||||
static let ackConsole = "AckConsole"
|
||||
|
||||
static let configuration = CommandConfiguration(
|
||||
commandName: "vmexec",
|
||||
version: "0.1.0",
|
||||
subcommands: [
|
||||
ExecCommand.self,
|
||||
RunCommand.self,
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
extension App {
|
||||
/// Applies O_CLOEXEC to all file descriptors currently open for
|
||||
/// the process except the stdio fd values
|
||||
static func applyCloseExecOnFDs() throws {
|
||||
let minFD = 2 // stdin, stdout, stderr should be preserved
|
||||
|
||||
let fdList = try FileManager.default.contentsOfDirectory(atPath: "/proc/self/fd")
|
||||
|
||||
for fdStr in fdList {
|
||||
guard let fd = Int(fdStr) else {
|
||||
continue
|
||||
}
|
||||
if fd <= minFD {
|
||||
continue
|
||||
}
|
||||
|
||||
_ = fcntl(Int32(fd), F_SETFD, FD_CLOEXEC)
|
||||
}
|
||||
}
|
||||
|
||||
static func exec(process: ContainerizationOCI.Process, currentEnv: [String]? = nil) throws {
|
||||
guard !process.args.isEmpty else {
|
||||
throw App.Errno(stage: "exec", info: "process args cannot be empty")
|
||||
}
|
||||
|
||||
let executableArg = process.args[0]
|
||||
let resolvedExecutable: URL
|
||||
|
||||
if executableArg.contains("/") {
|
||||
if executableArg.hasPrefix("/") {
|
||||
resolvedExecutable = URL(fileURLWithPath: executableArg)
|
||||
} else {
|
||||
resolvedExecutable = URL(fileURLWithPath: process.cwd).appendingPathComponent(executableArg).standardized
|
||||
}
|
||||
|
||||
guard FileManager.default.fileExists(atPath: resolvedExecutable.path) else {
|
||||
throw App.Failure(message: "failed to find target executable \(executableArg)")
|
||||
}
|
||||
} else {
|
||||
let path = Path.findPath(currentEnv) ?? Path.getCurrentPath()
|
||||
guard let found = Path.lookPath(executableArg, path: path) else {
|
||||
throw App.Failure(message: "failed to find target executable \(executableArg)")
|
||||
}
|
||||
resolvedExecutable = found
|
||||
}
|
||||
|
||||
let executable = strdup(resolvedExecutable.path)
|
||||
var argv = process.args.map { strdup($0) }
|
||||
argv += [nil]
|
||||
let env = process.env.map { strdup($0) } + [nil]
|
||||
let cwd = process.cwd
|
||||
|
||||
// Create the working directory if it doesn't exist, this seems like the expected
|
||||
// OCI runtime spec behavior.
|
||||
if !FileManager.default.fileExists(atPath: cwd) {
|
||||
try FileManager.default.createDirectory(
|
||||
atPath: cwd,
|
||||
withIntermediateDirectories: true,
|
||||
attributes: [.posixPermissions: 0o755]
|
||||
)
|
||||
}
|
||||
|
||||
guard chdir(cwd) == 0 else {
|
||||
throw App.Errno(stage: "chdir(cwd)", info: "failed to change directory to '\(cwd)'")
|
||||
}
|
||||
|
||||
guard execvpe(executable, argv, env) != -1 else {
|
||||
throw App.Errno(stage: "execvpe(\(String(describing: executable)))", info: "failed to exec [\(process.args.joined(separator: " "))]")
|
||||
}
|
||||
fatalError("execvpe failed")
|
||||
}
|
||||
|
||||
static func setPermissions(user: ContainerizationOCI.User) throws {
|
||||
if user.additionalGids.count > 0 {
|
||||
guard setgroups(user.additionalGids.count, user.additionalGids) == 0 else {
|
||||
throw App.Errno(stage: "setgroups()")
|
||||
}
|
||||
}
|
||||
guard setgid(user.gid) == 0 else {
|
||||
throw App.Errno(stage: "setgid()")
|
||||
}
|
||||
// NOTE: setuid has to be done last because once the uid has been
|
||||
// changed, then the process will lose privilege to set the group
|
||||
// and supplementary groups
|
||||
guard setuid(user.uid) == 0 else {
|
||||
throw App.Errno(stage: "setuid()")
|
||||
}
|
||||
}
|
||||
|
||||
static func fixStdioPerms(user: ContainerizationOCI.User) throws {
|
||||
for i in 0...2 {
|
||||
var fdStat = stat()
|
||||
try withUnsafeMutablePointer(to: &fdStat) { pointer in
|
||||
guard fstat(Int32(i), pointer) == 0 else {
|
||||
throw App.Errno(stage: "fstat(fd)")
|
||||
}
|
||||
}
|
||||
|
||||
let desired = uid_t(user.uid)
|
||||
if fdStat.st_uid != desired {
|
||||
guard fchown(Int32(i), desired, fdStat.st_gid) != -1 else {
|
||||
throw App.Errno(stage: "fchown(\(i))")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func setRLimits(rlimits: [ContainerizationOCI.POSIXRlimit]) throws {
|
||||
for rl in rlimits {
|
||||
let resource: Int32
|
||||
switch rl.type {
|
||||
case "RLIMIT_AS":
|
||||
resource = CZ_RLIMIT_AS
|
||||
case "RLIMIT_CORE":
|
||||
resource = CZ_RLIMIT_CORE
|
||||
case "RLIMIT_CPU":
|
||||
resource = CZ_RLIMIT_CPU
|
||||
case "RLIMIT_DATA":
|
||||
resource = CZ_RLIMIT_DATA
|
||||
case "RLIMIT_FSIZE":
|
||||
resource = CZ_RLIMIT_FSIZE
|
||||
case "RLIMIT_LOCKS":
|
||||
resource = CZ_RLIMIT_LOCKS
|
||||
case "RLIMIT_MEMLOCK":
|
||||
resource = CZ_RLIMIT_MEMLOCK
|
||||
case "RLIMIT_MSGQUEUE":
|
||||
resource = CZ_RLIMIT_MSGQUEUE
|
||||
case "RLIMIT_NICE":
|
||||
resource = CZ_RLIMIT_NICE
|
||||
case "RLIMIT_NOFILE":
|
||||
resource = CZ_RLIMIT_NOFILE
|
||||
case "RLIMIT_NPROC":
|
||||
resource = CZ_RLIMIT_NPROC
|
||||
case "RLIMIT_RSS":
|
||||
resource = CZ_RLIMIT_RSS
|
||||
case "RLIMIT_RTPRIO":
|
||||
resource = CZ_RLIMIT_RTPRIO
|
||||
case "RLIMIT_RTTIME":
|
||||
resource = CZ_RLIMIT_RTTIME
|
||||
case "RLIMIT_SIGPENDING":
|
||||
resource = CZ_RLIMIT_SIGPENDING
|
||||
case "RLIMIT_STACK":
|
||||
resource = CZ_RLIMIT_STACK
|
||||
default:
|
||||
errno = EINVAL
|
||||
throw App.Errno(stage: "rlimit key unknown")
|
||||
}
|
||||
guard CZ_setrlimit(resource, rl.soft, rl.hard) == 0 else {
|
||||
throw App.Errno(stage: "setrlimit()")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func prepareCapabilities(capabilities: ContainerizationOCI.LinuxCapabilities) throws -> ContainerizationOS.LinuxCapabilities? {
|
||||
// Create capabilities instance from OCI config
|
||||
var caps = ContainerizationOS.LinuxCapabilities()
|
||||
|
||||
caps.set(which: [.effective], caps: (capabilities.effective ?? []).compactMap { try? CapabilityName(rawValue: $0) })
|
||||
caps.set(which: [.permitted], caps: (capabilities.permitted ?? []).compactMap { try? CapabilityName(rawValue: $0) })
|
||||
caps.set(which: [.inheritable], caps: (capabilities.inheritable ?? []).compactMap { try? CapabilityName(rawValue: $0) })
|
||||
caps.set(which: [.bounding], caps: (capabilities.bounding ?? []).compactMap { try? CapabilityName(rawValue: $0) })
|
||||
caps.set(which: [.ambient], caps: (capabilities.ambient ?? []).compactMap { try? CapabilityName(rawValue: $0) })
|
||||
|
||||
// Apply bounding set BEFORE user change (drop capabilities early)
|
||||
do {
|
||||
try caps.apply(kind: .bounds)
|
||||
} catch {
|
||||
throw App.Failure(message: "failed to apply bounding set capabilities: \(error)")
|
||||
}
|
||||
|
||||
// Set keep caps to preserve capabilities across setuid()
|
||||
do {
|
||||
try LinuxCapabilities.setKeepCaps()
|
||||
} catch {
|
||||
throw App.Failure(message: "failed to set keep caps: \(error)")
|
||||
}
|
||||
|
||||
return caps
|
||||
}
|
||||
|
||||
static func finishCapabilities(_ caps: ContainerizationOS.LinuxCapabilities?) throws {
|
||||
guard let caps = caps else { return }
|
||||
|
||||
do {
|
||||
try LinuxCapabilities.clearKeepCaps()
|
||||
} catch {
|
||||
throw App.Failure(message: "failed to clear keep caps: \(error)")
|
||||
}
|
||||
|
||||
do {
|
||||
try caps.apply(kind: [.caps])
|
||||
} catch {
|
||||
throw App.Failure(message: "failed to apply final capabilities: \(error)")
|
||||
}
|
||||
|
||||
try? caps.apply(kind: [.ambs])
|
||||
}
|
||||
|
||||
static func setNoNewPrivileges(process: ContainerizationOCI.Process) throws {
|
||||
guard process.noNewPrivileges else { return }
|
||||
guard CZ_prctl_set_no_new_privs() == 0 else {
|
||||
throw App.Errno(stage: "prctl(PR_SET_NO_NEW_PRIVS)")
|
||||
}
|
||||
}
|
||||
|
||||
static func Errno(stage: String, info: String = "") -> ContainerizationError {
|
||||
let posix = POSIXError(.init(rawValue: errno)!, userInfo: ["stage": stage])
|
||||
return ContainerizationError(.internalError, message: "\(info) \(String(describing: posix))")
|
||||
}
|
||||
|
||||
static func Failure(message: String) -> ContainerizationError {
|
||||
ContainerizationError(
|
||||
.internalError,
|
||||
message: message
|
||||
)
|
||||
}
|
||||
|
||||
static func writeError(_ error: Error) {
|
||||
let errorPipe = FileDescriptor(rawValue: 5)
|
||||
|
||||
let errorMessage: String
|
||||
if let czError = error as? ContainerizationError {
|
||||
errorMessage = czError.description
|
||||
} else {
|
||||
errorMessage = String(describing: error)
|
||||
}
|
||||
|
||||
let bytes = Array(errorMessage.utf8)
|
||||
_ = try? bytes.withUnsafeBytes { buffer in
|
||||
try errorPipe.write(buffer)
|
||||
}
|
||||
try? errorPipe.close()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user