chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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 Dispatch
|
||||
import Synchronization
|
||||
|
||||
#if canImport(Musl)
|
||||
import Musl
|
||||
#elseif canImport(Glibc)
|
||||
import Glibc
|
||||
#elseif canImport(Darwin)
|
||||
import Darwin
|
||||
#endif
|
||||
|
||||
/// Async friendly wrapper around `DispatchSourceSignal`. Provides an `AsyncStream`
|
||||
/// interface to get notified of received signals.
|
||||
public final class AsyncSignalHandler: Sendable {
|
||||
/// An async stream that returns the signal that was caught, if ever.
|
||||
public var signals: AsyncStream<Int32> {
|
||||
let (stream, cont) = AsyncStream.makeStream(of: Int32.self)
|
||||
self.state.withLock {
|
||||
$0.conts.append(cont)
|
||||
}
|
||||
cont.onTermination = { @Sendable _ in
|
||||
self.cancel()
|
||||
}
|
||||
return stream
|
||||
}
|
||||
|
||||
/// Cancel every AsyncStream of signals, as well as the underlying
|
||||
/// DispatchSignalSource's for each registered signal.
|
||||
public func cancel() {
|
||||
self.state.withLock {
|
||||
if $0.conts.isEmpty {
|
||||
return
|
||||
}
|
||||
|
||||
for cont in $0.conts {
|
||||
cont.finish()
|
||||
}
|
||||
for source in $0.sources {
|
||||
source.cancel()
|
||||
}
|
||||
$0.conts.removeAll()
|
||||
$0.sources.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
struct State: Sendable {
|
||||
var conts: [AsyncStream<Int32>.Continuation] = []
|
||||
// `sources` isn't used concurrently.
|
||||
nonisolated(unsafe) var sources: [any DispatchSourceSignal] = []
|
||||
}
|
||||
|
||||
// We keep a reference to the continuation object that is created for
|
||||
// our AsyncStream and tell our signal handler to yield a value to it
|
||||
// returning a value to the consumer
|
||||
private func handler(_ sig: Int32) {
|
||||
self.state.withLock {
|
||||
for cont in $0.conts {
|
||||
cont.yield(sig)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private let state: Mutex<State> = .init(State())
|
||||
|
||||
/// Create a new `AsyncSignalHandler` that catches all signals.
|
||||
public static func catchAll() -> AsyncSignalHandler {
|
||||
#if os(macOS)
|
||||
let range = 1...31
|
||||
#else
|
||||
let range = 1...64
|
||||
#endif
|
||||
return create(notify: range.map { Int32($0) })
|
||||
}
|
||||
|
||||
/// Create a new `AsyncSignalHandler` for the list of given signals `notify`.
|
||||
/// The default signal handlers for these signals are removed and async handlers
|
||||
/// added in their place. The async signal handlers that are installed simply
|
||||
/// yield to a stream if and when a signal is caught.
|
||||
public static func create(notify on: [Int32]) -> AsyncSignalHandler {
|
||||
let out = AsyncSignalHandler()
|
||||
var sources = [any DispatchSourceSignal]()
|
||||
for sig in on {
|
||||
signal(sig, SIG_IGN)
|
||||
let source = DispatchSource.makeSignalSource(signal: sig)
|
||||
source.setEventHandler {
|
||||
out.handler(sig)
|
||||
}
|
||||
source.resume()
|
||||
// Retain a reference to our signal sources so that they
|
||||
// do not go out of scope.
|
||||
sources.append(source)
|
||||
}
|
||||
out.state.withLock { $0.sources = sources }
|
||||
return out
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
extension BinaryInteger {
|
||||
private func toUnsignedMemoryAmount(_ amount: UInt64) -> UInt64 {
|
||||
guard self >= 0 else {
|
||||
fatalError("encountered negative number during conversion to memory amount")
|
||||
}
|
||||
let val = UInt64(self)
|
||||
let (newVal, overflow) = val.multipliedReportingOverflow(by: amount)
|
||||
guard !overflow else {
|
||||
fatalError("UInt64 overflow when converting to memory amount")
|
||||
}
|
||||
return newVal
|
||||
}
|
||||
|
||||
public func kib() -> UInt64 {
|
||||
self.toUnsignedMemoryAmount(1 << 10)
|
||||
}
|
||||
|
||||
public func mib() -> UInt64 {
|
||||
self.toUnsignedMemoryAmount(1 << 20)
|
||||
}
|
||||
|
||||
public func gib() -> UInt64 {
|
||||
self.toUnsignedMemoryAmount(1 << 30)
|
||||
}
|
||||
|
||||
public func tib() -> UInt64 {
|
||||
self.toUnsignedMemoryAmount(1 << 40)
|
||||
}
|
||||
|
||||
public func pib() -> UInt64 {
|
||||
self.toUnsignedMemoryAmount(1 << 50)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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
|
||||
import Foundation
|
||||
import Synchronization
|
||||
|
||||
#if canImport(Darwin)
|
||||
import Darwin
|
||||
private let _kill = Darwin.kill
|
||||
#elseif canImport(Musl)
|
||||
import Musl
|
||||
private let _kill = Musl.kill
|
||||
#elseif canImport(Glibc)
|
||||
import Glibc
|
||||
private let _kill = Glibc.kill
|
||||
#endif
|
||||
|
||||
/// Use a command to run an executable.
|
||||
public struct Command: Sendable {
|
||||
/// Path to the executable binary.
|
||||
public var executable: String
|
||||
/// Arguments provided to the binary.
|
||||
public var arguments: [String]
|
||||
/// Environment variables for the process.
|
||||
public var environment: [String]
|
||||
/// The directory where the process should execute.
|
||||
public var directory: String?
|
||||
/// Additional files to pass to the process.
|
||||
public var extraFiles: [FileHandle]
|
||||
/// The standard input.
|
||||
public var stdin: FileHandle?
|
||||
/// The standard output.
|
||||
public var stdout: FileHandle?
|
||||
/// The standard error.
|
||||
public var stderr: FileHandle?
|
||||
|
||||
private let state: State
|
||||
|
||||
/// System level attributes to set on the process.
|
||||
public struct Attrs: Sendable {
|
||||
/// Set pgroup for the new process.
|
||||
public var setPGroup: Bool
|
||||
/// Make the new process group the foreground process group (requires setPGroup).
|
||||
public var setForegroundPGroup: Bool
|
||||
/// Inherit the real uid/gid of the parent.
|
||||
public var resetIDs: Bool
|
||||
/// Reset the child's signal handlers to the default.
|
||||
public var setSignalDefault: Bool
|
||||
/// The initial signal mask for the process.
|
||||
public var signalMask: UInt32
|
||||
/// Create a new session for the process.
|
||||
public var setsid: Bool
|
||||
/// Set the controlling terminal for the process to fd 0.
|
||||
public var setctty: Bool
|
||||
/// Set the process user ID.
|
||||
public var uid: UInt32?
|
||||
/// Set the process group ID.
|
||||
public var gid: UInt32?
|
||||
/// Signal to send when parent process dies (Linux only).
|
||||
public var pdeathSignal: Int32?
|
||||
|
||||
public init(
|
||||
setPGroup: Bool = false,
|
||||
setForegroundPGroup: Bool = false,
|
||||
resetIDs: Bool = false,
|
||||
setSignalDefault: Bool = true,
|
||||
signalMask: UInt32 = 0,
|
||||
setsid: Bool = false,
|
||||
setctty: Bool = false,
|
||||
uid: UInt32? = nil,
|
||||
gid: UInt32? = nil,
|
||||
pdeathSignal: Int32? = nil
|
||||
) {
|
||||
self.setPGroup = setPGroup
|
||||
self.setForegroundPGroup = setForegroundPGroup
|
||||
self.resetIDs = resetIDs
|
||||
self.setSignalDefault = setSignalDefault
|
||||
self.signalMask = signalMask
|
||||
self.setsid = setsid
|
||||
self.setctty = setctty
|
||||
self.uid = uid
|
||||
self.gid = gid
|
||||
self.pdeathSignal = pdeathSignal
|
||||
}
|
||||
}
|
||||
|
||||
private final class State: Sendable {
|
||||
let pid: Atomic<pid_t> = Atomic(-1)
|
||||
}
|
||||
|
||||
/// Attributes to set on the process.
|
||||
public var attrs = Attrs()
|
||||
|
||||
/// System level process identifier.
|
||||
public var pid: Int32 { self.state.pid.load(ordering: .acquiring) }
|
||||
|
||||
public init(
|
||||
_ executable: String,
|
||||
arguments: [String] = [],
|
||||
environment: [String] = environment(),
|
||||
directory: String? = nil,
|
||||
extraFiles: [FileHandle] = []
|
||||
) {
|
||||
self.executable = executable
|
||||
self.arguments = arguments
|
||||
self.environment = environment
|
||||
self.extraFiles = extraFiles
|
||||
self.directory = directory
|
||||
self.state = State()
|
||||
}
|
||||
|
||||
public static func environment() -> [String] {
|
||||
ProcessInfo.processInfo.environment
|
||||
.map { "\($0)=\($1)" }
|
||||
}
|
||||
}
|
||||
|
||||
extension Command {
|
||||
public enum Error: Swift.Error, CustomStringConvertible {
|
||||
case processRunning
|
||||
|
||||
public var description: String {
|
||||
switch self {
|
||||
case .processRunning:
|
||||
return "the process is already running"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Command {
|
||||
@discardableResult
|
||||
public func kill(_ signal: Int32) -> Int32? {
|
||||
let pid = self.pid
|
||||
guard pid > 0 else {
|
||||
return nil
|
||||
}
|
||||
return _kill(pid, signal)
|
||||
}
|
||||
}
|
||||
|
||||
extension Command {
|
||||
/// Start the process.
|
||||
public func start() throws {
|
||||
guard self.pid == -1 else {
|
||||
throw Error.processRunning
|
||||
}
|
||||
let child = try execute()
|
||||
self.state.pid.store(child, ordering: .releasing)
|
||||
}
|
||||
|
||||
/// Wait for the process to exit and return the exit status.
|
||||
@discardableResult
|
||||
public func wait() throws -> Int32 {
|
||||
var rus = rusage()
|
||||
var ws = Int32()
|
||||
|
||||
let pid = self.pid
|
||||
guard pid > 0 else {
|
||||
return -1
|
||||
}
|
||||
|
||||
let result = wait4(pid, &ws, 0, &rus)
|
||||
guard result == pid else {
|
||||
throw POSIXError(.init(rawValue: errno)!)
|
||||
}
|
||||
return Self.toExitStatus(ws)
|
||||
}
|
||||
|
||||
private func execute() throws -> pid_t {
|
||||
var attrs = exec_command_attrs()
|
||||
exec_command_attrs_init(&attrs)
|
||||
|
||||
let set = try createFileset()
|
||||
defer {
|
||||
for nullHandle in set.nullHandles {
|
||||
try? nullHandle.close()
|
||||
}
|
||||
}
|
||||
var fds = [Int32](repeating: 0, count: set.handles.count)
|
||||
for (i, handle) in set.handles.enumerated() {
|
||||
fds[i] = handle.fileDescriptor
|
||||
}
|
||||
|
||||
attrs.setsid = self.attrs.setsid ? 1 : 0
|
||||
attrs.setctty = self.attrs.setctty ? 1 : 0
|
||||
attrs.setpgid = self.attrs.setPGroup ? 1 : 0
|
||||
attrs.setfgpgrp = self.attrs.setForegroundPGroup ? 1 : 0
|
||||
|
||||
var cwdPath: UnsafeMutablePointer<CChar>?
|
||||
if let chdir = self.directory {
|
||||
cwdPath = strdup(chdir)
|
||||
}
|
||||
defer {
|
||||
if let cwdPath {
|
||||
free(cwdPath)
|
||||
}
|
||||
}
|
||||
|
||||
if let uid = self.attrs.uid {
|
||||
attrs.uid = uid
|
||||
}
|
||||
if let gid = self.attrs.gid {
|
||||
attrs.gid = gid
|
||||
}
|
||||
|
||||
if let pdeathSignal = self.attrs.pdeathSignal {
|
||||
attrs.pdeathSignal = pdeathSignal
|
||||
}
|
||||
|
||||
var pid: pid_t = 0
|
||||
var argv = ([executable] + arguments).map { strdup($0) } + [nil]
|
||||
defer {
|
||||
for arg in argv where arg != nil {
|
||||
free(arg)
|
||||
}
|
||||
}
|
||||
|
||||
let env = environment.map { strdup($0) } + [nil]
|
||||
defer {
|
||||
for e in env where e != nil {
|
||||
free(e)
|
||||
}
|
||||
}
|
||||
|
||||
let result = fds.withUnsafeBufferPointer { file_handles in
|
||||
exec_command(
|
||||
&pid,
|
||||
argv[0],
|
||||
&argv,
|
||||
env,
|
||||
file_handles.baseAddress!, Int32(file_handles.count),
|
||||
cwdPath ?? nil,
|
||||
&attrs)
|
||||
}
|
||||
guard result == 0 else {
|
||||
throw POSIXError(.init(rawValue: errno)!)
|
||||
}
|
||||
|
||||
return pid
|
||||
}
|
||||
|
||||
/// Create a posix_spawn file actions set of fds to pass to the new process
|
||||
private func createFileset() throws -> (nullHandles: [FileHandle], handles: [FileHandle]) {
|
||||
// grab dev null handles for different purposes
|
||||
let nullRead = try openDevNull(flags: O_RDONLY)
|
||||
let nullWrite = try openDevNull(flags: O_WRONLY)
|
||||
var files = [FileHandle]()
|
||||
files.append(stdin ?? nullRead)
|
||||
files.append(stdout ?? nullWrite)
|
||||
files.append(stderr ?? nullWrite)
|
||||
files.append(contentsOf: extraFiles)
|
||||
return (nullHandles: [nullRead, nullWrite], handles: files)
|
||||
}
|
||||
|
||||
/// Returns a file handle to /dev/null with the specified flags.
|
||||
private func openDevNull(flags: Int32) throws -> FileHandle {
|
||||
let fd = open("/dev/null", flags, 0)
|
||||
guard fd >= 0 else {
|
||||
throw POSIXError(.init(rawValue: errno)!)
|
||||
}
|
||||
return FileHandle(fileDescriptor: fd, closeOnDealloc: false)
|
||||
}
|
||||
}
|
||||
|
||||
extension Command {
|
||||
private static let signalOffset: Int32 = 128
|
||||
|
||||
private static let shift: Int32 = 8
|
||||
private static let mask: Int32 = 0x7F
|
||||
private static let stopped: Int32 = 0x7F
|
||||
private static let exited: Int32 = 0x00
|
||||
|
||||
static func signaled(_ ws: Int32) -> Bool {
|
||||
ws & mask != stopped && ws & mask != exited
|
||||
}
|
||||
|
||||
static func exited(_ ws: Int32) -> Bool {
|
||||
ws & mask == exited
|
||||
}
|
||||
|
||||
static func exitStatus(_ ws: Int32) -> Int32 {
|
||||
let r: Int32
|
||||
#if os(Linux)
|
||||
r = ws >> shift & 0xFF
|
||||
#else
|
||||
r = ws >> shift
|
||||
#endif
|
||||
return r
|
||||
}
|
||||
|
||||
public static func toExitStatus(_ ws: Int32) -> Int32 {
|
||||
if signaled(ws) {
|
||||
// We use the offset as that is how existing container
|
||||
// runtimes minic bash for the status when signaled.
|
||||
return Int32(Self.signalOffset + ws & mask)
|
||||
}
|
||||
if exited(ws) {
|
||||
return exitStatus(ws)
|
||||
}
|
||||
return ws
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private func WIFEXITED(_ status: Int32) -> Bool {
|
||||
_WSTATUS(status) == 0
|
||||
}
|
||||
|
||||
private func _WSTATUS(_ status: Int32) -> Int32 {
|
||||
status & 0x7f
|
||||
}
|
||||
|
||||
private func WIFSIGNALED(_ status: Int32) -> Bool {
|
||||
(_WSTATUS(status) != 0) && (_WSTATUS(status) != 0x7f)
|
||||
}
|
||||
|
||||
private func WEXITSTATUS(_ status: Int32) -> Int32 {
|
||||
(status >> 8) & 0xff
|
||||
}
|
||||
|
||||
private func WTERMSIG(_ status: Int32) -> Int32 {
|
||||
status & 0x7f
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
#if canImport(Musl)
|
||||
import Musl
|
||||
#elseif canImport(Glibc)
|
||||
import Glibc
|
||||
#elseif canImport(Darwin)
|
||||
import Darwin
|
||||
#endif
|
||||
|
||||
#if canImport(FoundationEssentials)
|
||||
import struct FoundationEssentials.URL
|
||||
#else
|
||||
import struct Foundation.URL
|
||||
#endif
|
||||
|
||||
/// Trivial type to discover information about a given file (uid, gid, mode...).
|
||||
public struct File: Sendable {
|
||||
/// `File` errors.
|
||||
public enum Error: Swift.Error, CustomStringConvertible {
|
||||
case errno(_ e: Int32)
|
||||
|
||||
public var description: String {
|
||||
switch self {
|
||||
case .errno(let code):
|
||||
return "errno \(code)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a `FileInfo` struct with information about the file.
|
||||
/// - Parameters:
|
||||
/// - url: The path to the file.
|
||||
public static func info(_ url: URL) throws -> FileInfo {
|
||||
try info(url.path)
|
||||
}
|
||||
|
||||
/// Returns a `FileInfo` struct with information about the file.
|
||||
/// - Parameters:
|
||||
/// - path: The path to the file as a string.
|
||||
public static func info(_ path: String) throws -> FileInfo {
|
||||
var st = stat()
|
||||
guard lstat(path, &st) == 0 else {
|
||||
throw Error.errno(errno)
|
||||
}
|
||||
return FileInfo(path, stat: st)
|
||||
}
|
||||
}
|
||||
|
||||
/// `FileInfo` holds and provides easy access to stat(2) data
|
||||
/// for a file.
|
||||
public struct FileInfo: Sendable {
|
||||
private let _stat_t: stat
|
||||
private let _path: String
|
||||
|
||||
init(_ path: String, stat: stat) {
|
||||
self._path = path
|
||||
self._stat_t = stat
|
||||
}
|
||||
|
||||
/// mode_t for the file.
|
||||
public var mode: mode_t {
|
||||
self._stat_t.st_mode
|
||||
}
|
||||
|
||||
/// The files uid.
|
||||
public var uid: Int {
|
||||
Int(self._stat_t.st_uid)
|
||||
}
|
||||
|
||||
/// The files gid.
|
||||
public var gid: Int {
|
||||
Int(self._stat_t.st_gid)
|
||||
}
|
||||
|
||||
/// The filesystem ID the file belongs to.
|
||||
public var dev: Int {
|
||||
Int(self._stat_t.st_dev)
|
||||
}
|
||||
|
||||
/// The files inode number.
|
||||
public var ino: Int {
|
||||
Int(self._stat_t.st_ino)
|
||||
}
|
||||
|
||||
/// The size of the file.
|
||||
public var size: Int {
|
||||
Int(self._stat_t.st_size)
|
||||
}
|
||||
|
||||
/// The path to the file.
|
||||
public var path: String {
|
||||
self._path
|
||||
}
|
||||
|
||||
/// Returns if the file is a directory.
|
||||
public var isDirectory: Bool {
|
||||
mode & S_IFMT == S_IFDIR
|
||||
}
|
||||
|
||||
/// Returns if the file is a pipe.
|
||||
public var isPipe: Bool {
|
||||
mode & S_IFMT == S_IFIFO
|
||||
}
|
||||
|
||||
/// Returns if the file is a socket.
|
||||
public var isSocket: Bool {
|
||||
mode & S_IFMT == S_IFSOCK
|
||||
}
|
||||
|
||||
/// Returns if the file is a link.
|
||||
public var isLink: Bool {
|
||||
mode & S_IFMT == S_IFLNK
|
||||
}
|
||||
|
||||
/// Returns if the file is a regular file.
|
||||
public var isRegularFile: Bool {
|
||||
mode & S_IFMT == S_IFREG
|
||||
}
|
||||
|
||||
/// Returns if the file is a block device.
|
||||
public var isBlock: Bool {
|
||||
mode & S_IFMT == S_IFBLK
|
||||
}
|
||||
|
||||
/// Returns if the file is a character device.
|
||||
public var isChar: Bool {
|
||||
mode & S_IFMT == S_IFCHR
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import SystemPackage
|
||||
|
||||
#if canImport(Darwin)
|
||||
import Darwin
|
||||
private let os_dup = Darwin.dup
|
||||
private let os_S_IFMT = mode_t(Darwin.S_IFMT)
|
||||
private let os_S_IFREG = mode_t(Darwin.S_IFREG)
|
||||
private let os_S_IFDIR = mode_t(Darwin.S_IFDIR)
|
||||
private let os_S_IFLNK = mode_t(Darwin.S_IFLNK)
|
||||
#elseif canImport(Musl)
|
||||
import CSystem
|
||||
import Musl
|
||||
private let os_dup = Musl.dup
|
||||
private let os_S_IFMT = Musl.S_IFMT
|
||||
private let os_S_IFREG = Musl.S_IFREG
|
||||
private let os_S_IFDIR = Musl.S_IFDIR
|
||||
private let os_S_IFLNK = Musl.S_IFLNK
|
||||
#elseif canImport(Glibc)
|
||||
import Glibc
|
||||
private let os_dup = Glibc.dup
|
||||
private let os_S_IFMT = mode_t(Glibc.S_IFMT)
|
||||
private let os_S_IFREG = mode_t(Glibc.S_IFREG)
|
||||
private let os_S_IFDIR = mode_t(Glibc.S_IFDIR)
|
||||
private let os_S_IFLNK = mode_t(Glibc.S_IFLNK)
|
||||
#endif
|
||||
|
||||
/// Static utility functions for secure, symlink-safe filesystem operations
|
||||
/// anchored to a file descriptor.
|
||||
///
|
||||
/// All operations use `openat`/`mkdirat`/`unlinkat` anchored to the supplied
|
||||
/// file descriptor, preventing path traversal and TOCTOU races. The type is
|
||||
/// never instantiated; it exists solely as a namespace.
|
||||
public enum FileDescriptorOps {
|
||||
|
||||
// MARK: - Nested types
|
||||
|
||||
public enum Error: Swift.Error, CustomStringConvertible, Equatable {
|
||||
case invalidRelativePath
|
||||
case invalidPathComponent
|
||||
case cannotFollowSymlink
|
||||
case systemError(String, Int32)
|
||||
|
||||
public var description: String {
|
||||
switch self {
|
||||
case .invalidRelativePath:
|
||||
return "invalid relative path supplied to file descriptor operation"
|
||||
case .invalidPathComponent:
|
||||
return "an intermediate path component is missing or is not a directory"
|
||||
case .cannotFollowSymlink:
|
||||
return "cannot follow a symlink in a file descriptor operation"
|
||||
case .systemError(let operation, let err):
|
||||
return "\(operation) returned error: \(err)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The type of a directory entry yielded by ``enumerate(_:_:)``.
|
||||
public enum EntryType: Sendable, Equatable {
|
||||
/// A regular file.
|
||||
case regular
|
||||
/// A directory. The entry is recursed into; symlinks to directories are
|
||||
/// reported as `.symlink` and are never recursed.
|
||||
case directory
|
||||
/// A symbolic link (to a file or directory).
|
||||
case symlink
|
||||
/// Any other entry type (device node, named pipe, socket, etc.).
|
||||
case other
|
||||
}
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
/// Creates a directory relative to `fd`, rejecting paths that traverse symlinks.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - fd: An open file descriptor for the parent directory.
|
||||
/// - relativePath: The path to create, relative to `fd`.
|
||||
/// - permissions: The permissions to give the directory (default 0o755).
|
||||
/// - makeIntermediates: Create or replace intermediate components as needed.
|
||||
/// - completion: A function that operates on the new directory fd.
|
||||
/// - Throws: `FileDescriptorOps.Error` if path validation or system errors occur.
|
||||
public static func mkdir(
|
||||
_ fd: FileDescriptor,
|
||||
_ relativePath: FilePath,
|
||||
permissions: FilePermissions? = nil,
|
||||
makeIntermediates: Bool = false,
|
||||
completion: (FileDescriptor) throws -> Void = { _ in }
|
||||
) throws {
|
||||
try validateRelativePath(relativePath)
|
||||
try mkdir(
|
||||
fd,
|
||||
relativePath.components,
|
||||
permissions: permissions,
|
||||
makeIntermediates: makeIntermediates,
|
||||
completion: completion
|
||||
)
|
||||
}
|
||||
|
||||
/// Recursively removes a direct child of the directory at `fd`.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - fd: An open file descriptor for the parent directory.
|
||||
/// - filename: The name of the child to remove.
|
||||
/// - Throws: `FileDescriptorOps.Error` if system errors occur.
|
||||
public static func unlinkRecursive(_ fd: FileDescriptor, filename: FilePath.Component) throws {
|
||||
guard filename.string != "." && filename.string != ".." else {
|
||||
return
|
||||
}
|
||||
|
||||
guard unlinkat(fd.rawValue, filename.string, 0) != 0 else {
|
||||
return
|
||||
}
|
||||
|
||||
guard errno != ENOENT else {
|
||||
return
|
||||
}
|
||||
|
||||
guard errno == EPERM || errno == EISDIR else {
|
||||
throw Error.systemError("file removal during file descriptor unlink", errno)
|
||||
}
|
||||
|
||||
let componentFd = openat(fd.rawValue, filename.string, O_NOFOLLOW | O_RDONLY | O_DIRECTORY)
|
||||
guard componentFd >= 0 else {
|
||||
throw Error.systemError("directory open during file descriptor unlink", errno)
|
||||
}
|
||||
let componentFileDescriptor = FileDescriptor(rawValue: componentFd)
|
||||
defer { try? componentFileDescriptor.close() }
|
||||
|
||||
// Open the directory stream using a duplicate fd that closedir() will close.
|
||||
let ownedFd = os_dup(componentFd)
|
||||
guard let dir = fdopendir(ownedFd) else {
|
||||
throw Error.systemError("directory opendir during file descriptor unlink", errno)
|
||||
}
|
||||
defer { closedir(dir) }
|
||||
|
||||
while let entry = readdir(dir) {
|
||||
let childComponent = withUnsafePointer(to: entry.pointee.d_name) {
|
||||
$0.withMemoryRebound(to: UInt8.self, capacity: Int(NAME_MAX) + 1) {
|
||||
let name = String(decodingCString: $0, as: UTF8.self)
|
||||
return FilePath.Component(name)
|
||||
}
|
||||
}
|
||||
guard let childComponent else {
|
||||
throw Error.systemError("directory entry processing during file descriptor unlink", errno)
|
||||
}
|
||||
try unlinkRecursive(componentFileDescriptor, filename: childComponent)
|
||||
}
|
||||
|
||||
if unlinkat(fd.rawValue, filename.string, AT_REMOVEDIR) != 0 {
|
||||
throw Error.systemError("directory removal during file descriptor unlink", errno)
|
||||
}
|
||||
}
|
||||
|
||||
/// Recursively enumerates the contents of `fd` without following symbolic links.
|
||||
///
|
||||
/// Each entry — file, directory, symlink, or other type — is reported to
|
||||
/// `body` with a path relative to `fd`. Directories are reported before their
|
||||
/// contents (pre-order) and then recursed. A symlink whose target is a directory
|
||||
/// is reported as `.symlink` and is never followed, so traversal cannot escape
|
||||
/// the tree rooted at `fd` regardless of where symlinks point.
|
||||
///
|
||||
/// `fd` must be an open file descriptor for a directory.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - fd: An open file descriptor for the root directory to enumerate.
|
||||
/// - body: Called once per entry. `path` is relative to `fd`; `type`
|
||||
/// identifies the kind of entry; `parentFd` is the open file descriptor
|
||||
/// for the directory that contains the entry. The last component of `path`
|
||||
/// is the entry's filename; together with `parentFd` it allows the body to
|
||||
/// open the entry via
|
||||
/// `openat(parentFd.rawValue, path.lastComponent!.string, O_NOFOLLOW …)`
|
||||
/// without reconstructing an absolute path, preserving the TOCTOU safety
|
||||
/// of the traversal end-to-end. `parentFd` must not be closed within the
|
||||
/// body call, or used after the call returns. Throw to abort.
|
||||
/// - Throws: `FileDescriptorOps.Error` on system errors; any error thrown by
|
||||
/// `body` is propagated unchanged.
|
||||
public static func enumerate(
|
||||
_ fd: FileDescriptor,
|
||||
_ body: (_ path: FilePath, _ type: EntryType, _ parentFd: FileDescriptor) throws -> Void
|
||||
) throws {
|
||||
try enumerateHelper(fd, relativePath: FilePath(""), body: body)
|
||||
}
|
||||
|
||||
// MARK: - Canonical path
|
||||
|
||||
#if canImport(Darwin)
|
||||
/// Returns the canonical path for `fd` using `F_GETPATH`.
|
||||
public static func getCanonicalPath(_ fd: FileDescriptor) throws -> FilePath {
|
||||
var buffer = [CChar](repeating: 0, count: Int(PATH_MAX))
|
||||
guard fcntl(fd.rawValue, F_GETPATH, &buffer) != -1 else {
|
||||
throw Errno(rawValue: errno)
|
||||
}
|
||||
let bytes = buffer.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) }
|
||||
return FilePath(String(decoding: bytes, as: UTF8.self))
|
||||
}
|
||||
#elseif canImport(Glibc) || canImport(Musl)
|
||||
/// Returns the canonical path for `fd` via `/proc/self/fd`.
|
||||
public static func getCanonicalPath(_ fd: FileDescriptor) throws -> FilePath {
|
||||
let fdPath = "/proc/self/fd/\(fd.rawValue)"
|
||||
var buffer = [CChar](repeating: 0, count: 4096)
|
||||
let len = readlink(fdPath, &buffer, buffer.count - 1)
|
||||
guard len > 0 else {
|
||||
throw Error.systemError("readlink", errno)
|
||||
}
|
||||
let bytes = buffer.prefix(len).map { UInt8(bitPattern: $0) }
|
||||
return FilePath(String(decoding: bytes, as: UTF8.self))
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - Private helpers
|
||||
|
||||
private static func mkdir(
|
||||
_ fd: FileDescriptor,
|
||||
_ relativeComponents: FilePath.ComponentView,
|
||||
permissions: FilePermissions? = nil,
|
||||
makeIntermediates: Bool,
|
||||
completion: (FileDescriptor) throws -> Void
|
||||
) throws {
|
||||
guard let currentComponent = relativeComponents.first else {
|
||||
try completion(fd)
|
||||
return
|
||||
}
|
||||
let childComponents = FilePath.ComponentView(relativeComponents.dropFirst())
|
||||
|
||||
var componentFd = openat(fd.rawValue, currentComponent.string, O_NOFOLLOW | O_RDONLY | O_DIRECTORY)
|
||||
if componentFd < 0 {
|
||||
guard makeIntermediates || childComponents.isEmpty else {
|
||||
throw Error.invalidPathComponent
|
||||
}
|
||||
if errno != ENOENT {
|
||||
try unlinkRecursive(fd, filename: currentComponent)
|
||||
}
|
||||
|
||||
guard mkdirat(fd.rawValue, currentComponent.string, permissions?.rawValue ?? 0o755) == 0 else {
|
||||
throw Error.systemError("directory creation during file descriptor mkdir", errno)
|
||||
}
|
||||
|
||||
componentFd = openat(fd.rawValue, currentComponent.string, O_NOFOLLOW | O_RDONLY | O_DIRECTORY)
|
||||
guard componentFd >= 0 else {
|
||||
throw Error.systemError("directory open during file descriptor mkdir", errno)
|
||||
}
|
||||
}
|
||||
|
||||
let componentFileDescriptor = FileDescriptor(rawValue: componentFd)
|
||||
defer { try? componentFileDescriptor.close() }
|
||||
|
||||
guard !childComponents.isEmpty else {
|
||||
try completion(componentFileDescriptor)
|
||||
return
|
||||
}
|
||||
|
||||
try mkdir(
|
||||
componentFileDescriptor, childComponents,
|
||||
permissions: permissions, makeIntermediates: makeIntermediates, completion: completion)
|
||||
}
|
||||
|
||||
private static func enumerateHelper(
|
||||
_ fd: FileDescriptor,
|
||||
relativePath: FilePath,
|
||||
body: (_ path: FilePath, _ type: EntryType, _ parentFd: FileDescriptor) throws -> Void
|
||||
) throws {
|
||||
// fdopendir takes ownership of the fd passed to it and closes it via
|
||||
// closedir. Duplicate so the caller's fd remains open.
|
||||
let dupFd = os_dup(fd.rawValue)
|
||||
guard dupFd >= 0 else {
|
||||
throw Error.systemError("dup during file descriptor enumerate", errno)
|
||||
}
|
||||
guard let dir = fdopendir(dupFd) else {
|
||||
let savedErrno = errno
|
||||
try? FileDescriptor(rawValue: dupFd).close()
|
||||
throw Error.systemError("fdopendir during file descriptor enumerate", savedErrno)
|
||||
}
|
||||
defer { closedir(dir) }
|
||||
|
||||
while let entry = readdir(dir) {
|
||||
let name = withUnsafePointer(to: entry.pointee.d_name) {
|
||||
$0.withMemoryRebound(to: UInt8.self, capacity: Int(NAME_MAX) + 1) {
|
||||
String(decodingCString: $0, as: UTF8.self)
|
||||
}
|
||||
}
|
||||
guard name != "." && name != ".." else { continue }
|
||||
guard let component = FilePath.Component(name) else { continue }
|
||||
|
||||
let entryPath = relativePath.appending(component)
|
||||
let entryType = resolveEntryType(parentFd: fd.rawValue, name: name, dtype: entry.pointee.d_type)
|
||||
|
||||
// Pass fd (the parent directory) so the body can use
|
||||
// openat(parentFd.rawValue, path.lastComponent!.string, O_NOFOLLOW …)
|
||||
// rather than reconstructing an absolute path, keeping the fd chain unbroken.
|
||||
try body(entryPath, entryType, fd)
|
||||
|
||||
guard entryType == .directory else { continue }
|
||||
|
||||
// Open the child directory with O_NOFOLLOW to guarantee we are
|
||||
// entering a real directory and not a symlink that was swapped in
|
||||
// between readdir and here.
|
||||
let childFd = openat(fd.rawValue, name, O_NOFOLLOW | O_RDONLY | O_DIRECTORY)
|
||||
guard childFd >= 0 else {
|
||||
throw Error.systemError("openat during file descriptor enumerate", errno)
|
||||
}
|
||||
let childDescriptor = FileDescriptor(rawValue: childFd)
|
||||
defer { try? childDescriptor.close() }
|
||||
try enumerateHelper(childDescriptor, relativePath: entryPath, body: body)
|
||||
}
|
||||
}
|
||||
|
||||
private static func resolveEntryType(parentFd: Int32, name: String, dtype: UInt8) -> EntryType {
|
||||
switch dtype {
|
||||
case UInt8(DT_REG): return .regular
|
||||
case UInt8(DT_DIR): return .directory
|
||||
case UInt8(DT_LNK): return .symlink
|
||||
case UInt8(DT_UNKNOWN):
|
||||
// Some filesystems (NFS, ext2/3) report DT_UNKNOWN; fall back to fstatat.
|
||||
var stbuf = stat()
|
||||
guard fstatat(parentFd, name, &stbuf, AT_SYMLINK_NOFOLLOW) == 0 else { return .other }
|
||||
switch stbuf.st_mode & os_S_IFMT {
|
||||
case os_S_IFREG: return .regular
|
||||
case os_S_IFDIR: return .directory
|
||||
case os_S_IFLNK: return .symlink
|
||||
default: return .other
|
||||
}
|
||||
default: return .other
|
||||
}
|
||||
}
|
||||
|
||||
private static func validateRelativePath(_ path: FilePath) throws {
|
||||
guard !(path.components.contains { $0 == ".." }) else {
|
||||
throw Error.invalidRelativePath
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import Foundation
|
||||
import SystemPackage
|
||||
|
||||
/// Static utility functions for path operations.
|
||||
///
|
||||
/// The type is never instantiated; it exists solely as a namespace.
|
||||
public enum FilePathOps {
|
||||
/// Returns an absolute version of `path`.
|
||||
///
|
||||
/// This is a purely lexical operation: it does not resolve symlinks,
|
||||
/// perform tilde expansion, and does not access the file system. If `path`
|
||||
/// is already absolute, this returns `path` unchanged.
|
||||
public static func absolutePath(_ path: FilePath) -> FilePath {
|
||||
guard !path.isAbsolute else {
|
||||
return path
|
||||
}
|
||||
|
||||
return FilePath(FileManager.default.currentDirectoryPath)
|
||||
.appending(path.components)
|
||||
.lexicallyNormalized()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
#if os(macOS)
|
||||
#if canImport(FoundationEssentials)
|
||||
import FoundationEssentials
|
||||
#else
|
||||
import Foundation
|
||||
#endif
|
||||
|
||||
/// Holds the result of a query to the keychain.
|
||||
public struct KeychainQueryResult {
|
||||
public var username: String
|
||||
public var password: String
|
||||
public var modifiedDate: Date
|
||||
public var createdDate: Date
|
||||
}
|
||||
|
||||
/// Type that facilitates interacting with the macOS keychain.
|
||||
public struct KeychainQuery {
|
||||
public init() {}
|
||||
|
||||
/// Save a value to the keychain.
|
||||
/// - Parameters:
|
||||
/// - securityDomain: The security domain used to fetch keychain entries.
|
||||
/// - accessGroup: If present, the access group used to fetch keychain entries.
|
||||
/// - hostname: The hostname for the authenticating server.
|
||||
/// - username: The username to present to the server.
|
||||
/// - password: The password to present to the server.
|
||||
/// - Throws: An error if the keychain query fails or returns unexpected data.
|
||||
public func save(
|
||||
securityDomain: String,
|
||||
accessGroup: String? = nil,
|
||||
hostname: String,
|
||||
username: String,
|
||||
password: String
|
||||
) throws {
|
||||
if try exists(securityDomain: securityDomain, accessGroup: accessGroup, hostname: hostname) {
|
||||
try delete(securityDomain: securityDomain, accessGroup: accessGroup, hostname: hostname)
|
||||
}
|
||||
|
||||
guard let passwordEncoded = password.data(using: String.Encoding.utf8) else {
|
||||
throw Self.Error.invalidPasswordConversion
|
||||
}
|
||||
var query: [String: Any] = [
|
||||
kSecClass as String: kSecClassInternetPassword,
|
||||
kSecAttrSecurityDomain as String: securityDomain,
|
||||
kSecAttrServer as String: hostname,
|
||||
kSecAttrAccount as String: username,
|
||||
kSecValueData as String: passwordEncoded,
|
||||
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock,
|
||||
kSecAttrSynchronizable as String: false,
|
||||
]
|
||||
if let accessGroup {
|
||||
query[kSecAttrAccessGroup as String] = accessGroup
|
||||
}
|
||||
|
||||
let status = SecItemAdd(query as CFDictionary, nil)
|
||||
guard status == errSecSuccess else { throw Self.Error.unhandledError(status: status) }
|
||||
}
|
||||
|
||||
/// Delete a value from the keychain.
|
||||
/// - Parameters:
|
||||
/// - securityDomain: The security domain used to fetch keychain entries.
|
||||
/// - accessGroup: If present, the access group used to fetch keychain entries.
|
||||
/// - hostname: The hostname for the authenticating server.
|
||||
/// - Throws: An error if the keychain query fails or returns unexpected data.
|
||||
public func delete(securityDomain: String, accessGroup: String? = nil, hostname: String) throws {
|
||||
var query: [String: Any] = [
|
||||
kSecClass as String: kSecClassInternetPassword,
|
||||
kSecAttrSecurityDomain as String: securityDomain,
|
||||
kSecAttrServer as String: hostname,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||
]
|
||||
if let accessGroup {
|
||||
query[kSecAttrAccessGroup as String] = accessGroup
|
||||
}
|
||||
let status = SecItemDelete(query as CFDictionary)
|
||||
guard status == errSecSuccess || status == errSecItemNotFound else {
|
||||
throw Self.Error.unhandledError(status: status)
|
||||
}
|
||||
}
|
||||
|
||||
/// Retrieve a value from the keychain.
|
||||
/// - Parameters:
|
||||
/// - securityDomain: The security domain used to fetch keychain entries.
|
||||
/// - accessGroup: If present, the access group used to fetch keychain entries.
|
||||
/// - hostname: The hostname for the authenticating server.
|
||||
/// - Returns: The keychain entry.
|
||||
/// - Throws: An error if the keychain query fails or returns unexpected data.
|
||||
public func get(securityDomain: String, accessGroup: String? = nil, hostname: String) throws -> KeychainQueryResult? {
|
||||
var query: [String: Any] = [
|
||||
kSecClass as String: kSecClassInternetPassword,
|
||||
kSecAttrSecurityDomain as String: securityDomain,
|
||||
kSecAttrServer as String: hostname,
|
||||
kSecReturnAttributes as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||
kSecReturnData as String: true,
|
||||
]
|
||||
if let accessGroup {
|
||||
query[kSecAttrAccessGroup as String] = accessGroup
|
||||
}
|
||||
var item: CFTypeRef?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &item)
|
||||
let exists = try isQuerySuccessful(status)
|
||||
if !exists {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let fetched = item as? [String: Any] else {
|
||||
throw Self.Error.unexpectedDataFetched
|
||||
}
|
||||
guard let data = fetched[kSecValueData as String] as? Data else {
|
||||
throw Self.Error.keyNotPresent(key: kSecValueData as String)
|
||||
}
|
||||
guard let password = String(data: data, encoding: String.Encoding.utf8) else {
|
||||
throw Self.Error.unexpectedDataFetched
|
||||
}
|
||||
guard let username = fetched[kSecAttrAccount as String] as? String else {
|
||||
throw Self.Error.keyNotPresent(key: kSecAttrAccount as String)
|
||||
}
|
||||
guard let modifiedDate = fetched[kSecAttrModificationDate as String] as? Date else {
|
||||
throw Self.Error.keyNotPresent(key: kSecAttrModificationDate as String)
|
||||
}
|
||||
guard let createdDate = fetched[kSecAttrCreationDate as String] as? Date else {
|
||||
throw Self.Error.keyNotPresent(key: kSecAttrCreationDate as String)
|
||||
}
|
||||
return KeychainQueryResult(
|
||||
username: username,
|
||||
password: password,
|
||||
modifiedDate: modifiedDate,
|
||||
createdDate: createdDate
|
||||
)
|
||||
}
|
||||
|
||||
/// List all keychain entries for a domain.
|
||||
/// - Parameters:
|
||||
/// - securityDomain: The security domain used to fetch keychain entries.
|
||||
/// - accessGroup: If present, the access group used to fetch keychain entries.
|
||||
/// - Returns: An array of keychain metadata for each matching entry, or an empty array if none are found.
|
||||
/// - Throws: An error if the keychain query fails or returns unexpected data.
|
||||
public func list(securityDomain: String, accessGroup: String? = nil) throws -> [RegistryInfo] {
|
||||
var query: [String: Any] = [
|
||||
kSecClass as String: kSecClassInternetPassword,
|
||||
kSecAttrSecurityDomain as String: securityDomain,
|
||||
kSecReturnAttributes as String: true,
|
||||
kSecReturnData as String: false,
|
||||
kSecMatchLimit as String: kSecMatchLimitAll,
|
||||
]
|
||||
if let accessGroup {
|
||||
query[kSecAttrAccessGroup as String] = accessGroup
|
||||
}
|
||||
var item: CFTypeRef?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &item)
|
||||
let exists = try isQuerySuccessful(status)
|
||||
if !exists {
|
||||
return []
|
||||
}
|
||||
|
||||
guard let fetched = item as? [[String: Any]] else {
|
||||
throw Self.Error.unexpectedDataFetched
|
||||
}
|
||||
|
||||
return try fetched.map { registry in
|
||||
guard let hostname = registry[kSecAttrServer as String] as? String else {
|
||||
throw Self.Error.keyNotPresent(key: kSecAttrServer as String)
|
||||
}
|
||||
guard let username = registry[kSecAttrAccount as String] as? String else {
|
||||
throw Self.Error.keyNotPresent(key: kSecAttrAccount as String)
|
||||
}
|
||||
guard let modifiedDate = registry[kSecAttrModificationDate as String] as? Date else {
|
||||
throw Self.Error.keyNotPresent(key: kSecAttrModificationDate as String)
|
||||
}
|
||||
guard let createdDate = registry[kSecAttrCreationDate as String] as? Date else {
|
||||
throw Self.Error.keyNotPresent(key: kSecAttrCreationDate as String)
|
||||
}
|
||||
|
||||
return RegistryInfo(
|
||||
hostname: hostname,
|
||||
username: username,
|
||||
modifiedDate: modifiedDate,
|
||||
createdDate: createdDate
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a value exists in the keychain.
|
||||
/// - Parameters:
|
||||
/// - securityDomain: The security domain used to fetch keychain entries.
|
||||
/// - accessGroup: If present, the access group used to fetch keychain entries.
|
||||
/// - hostname: The hostname for the authenticating server.
|
||||
/// - Returns: `true` if the entry exists, `false` otherwise.
|
||||
/// - Throws: An error if the keychain query fails.
|
||||
public func exists(securityDomain: String, accessGroup: String? = nil, hostname: String) throws -> Bool {
|
||||
var query: [String: Any] = [
|
||||
kSecClass as String: kSecClassInternetPassword,
|
||||
kSecAttrSecurityDomain as String: securityDomain,
|
||||
kSecAttrServer as String: hostname,
|
||||
kSecReturnAttributes as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||
kSecReturnData as String: false,
|
||||
]
|
||||
if let accessGroup {
|
||||
query[kSecAttrAccessGroup as String] = accessGroup
|
||||
}
|
||||
|
||||
let status = SecItemCopyMatching(query as CFDictionary, nil)
|
||||
return try isQuerySuccessful(status)
|
||||
}
|
||||
|
||||
private func isQuerySuccessful(_ status: Int32) throws -> Bool {
|
||||
guard status != errSecItemNotFound else {
|
||||
return false
|
||||
}
|
||||
guard status == errSecSuccess else {
|
||||
throw Self.Error.unhandledError(status: status)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
extension KeychainQuery {
|
||||
public enum Error: Swift.Error {
|
||||
case unhandledError(status: Int32)
|
||||
case unexpectedDataFetched
|
||||
case keyNotPresent(key: String)
|
||||
case invalidPasswordConversion
|
||||
}
|
||||
}
|
||||
#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 canImport(FoundationEssentials)
|
||||
import FoundationEssentials
|
||||
#else
|
||||
import Foundation
|
||||
#endif
|
||||
|
||||
/// Holds the stored attributes for a registry.
|
||||
public struct RegistryInfo: Sendable {
|
||||
/// The registry host as a domain name with an optional port.
|
||||
public var hostname: String
|
||||
/// The username used to authenticate with the registry.
|
||||
public var username: String
|
||||
/// The date the registry was last modified.
|
||||
public let modifiedDate: Date
|
||||
/// The date the registry was created.
|
||||
public let createdDate: Date
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
#if canImport(FoundationEssentials)
|
||||
import FoundationEssentials
|
||||
#else
|
||||
import Foundation
|
||||
#endif
|
||||
|
||||
#if canImport(Musl)
|
||||
import Musl
|
||||
private let _mount = Musl.mount
|
||||
#elseif canImport(Glibc)
|
||||
import Glibc
|
||||
private let _mount = Glibc.mount
|
||||
#endif
|
||||
|
||||
/// `Binfmt` is a utility type that contains static helpers and types for
|
||||
/// mounting the Linux binfmt_misc filesystem, and creating new binfmt entries.
|
||||
public struct Binfmt: Sendable {
|
||||
/// Default mount path for binfmt_misc.
|
||||
public static let path = "/proc/sys/fs/binfmt_misc"
|
||||
|
||||
/// Entry models a binfmt_misc entry.
|
||||
/// https://docs.kernel.org/admin-guide/binfmt-misc.html
|
||||
public struct Entry {
|
||||
public var name: String
|
||||
public var type: String
|
||||
public var offset: String
|
||||
public var magic: String
|
||||
public var mask: String
|
||||
public var flags: String
|
||||
|
||||
public init(
|
||||
name: String,
|
||||
type: String = "M",
|
||||
offset: String = "",
|
||||
magic: String,
|
||||
mask: String,
|
||||
flags: String = "CF"
|
||||
) {
|
||||
self.name = name
|
||||
self.type = type
|
||||
self.offset = offset
|
||||
self.magic = magic
|
||||
self.mask = mask
|
||||
self.flags = flags
|
||||
}
|
||||
|
||||
/// Returns a binfmt `Entry` for amd64 ELF binaries.
|
||||
public static func amd64() -> Self {
|
||||
Binfmt.Entry(
|
||||
name: "x86_64",
|
||||
magic: #"\x7fELF\x02\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\x3e\x00"#,
|
||||
mask: #"\xff\xff\xff\xff\xff\xfe\xfe\x00\xff\xff\xff\xff\xff\xff\xff\xff\xfe\xff\xff\xff"#
|
||||
)
|
||||
}
|
||||
|
||||
#if os(Linux)
|
||||
/// Register the passed in `binaryPath` as the interpreter for a new binfmt_misc entry.
|
||||
public func register(binaryPath: String) throws {
|
||||
let registration = ":\(self.name):\(self.type):\(self.offset):\(self.magic):\(self.mask):\(binaryPath):\(self.flags)"
|
||||
|
||||
try registration.write(
|
||||
to: URL(fileURLWithPath: Binfmt.path).appendingPathComponent("register"),
|
||||
atomically: false,
|
||||
encoding: .ascii
|
||||
)
|
||||
}
|
||||
|
||||
/// Deregister the binfmt_misc entry described by the current object.
|
||||
public func deregister() throws {
|
||||
let data = "-1"
|
||||
try data.write(
|
||||
to: URL(fileURLWithPath: Binfmt.path).appendingPathComponent(self.name),
|
||||
atomically: false,
|
||||
encoding: .ascii
|
||||
)
|
||||
}
|
||||
#endif // os(Linux)
|
||||
}
|
||||
|
||||
#if os(Linux)
|
||||
/// Crude check to see if /proc/sys/fs/binfmt_misc/register exists.
|
||||
public static func mounted() -> Bool {
|
||||
FileManager.default.fileExists(atPath: "\(Self.path)/register")
|
||||
}
|
||||
|
||||
/// Mount the binfmt_misc filesystem.
|
||||
public static func mount() throws {
|
||||
guard _mount("binfmt_misc", Self.path, "binfmt_misc", 0, "") == 0 else {
|
||||
throw POSIXError.fromErrno()
|
||||
}
|
||||
}
|
||||
#endif // os(Linux)
|
||||
}
|
||||
@@ -0,0 +1,670 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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
|
||||
|
||||
// MARK: - Configuration Types
|
||||
|
||||
public enum CapabilityParsingError: Swift.Error, CustomStringConvertible {
|
||||
case invalidCapabilitySet(String)
|
||||
case invalidCapabilityName(String)
|
||||
|
||||
public var description: String {
|
||||
switch self {
|
||||
case .invalidCapabilitySet(let value):
|
||||
return "invalid CapabilitySet value '\(value)'"
|
||||
case .invalidCapabilityName(let value):
|
||||
return "invalid CapabilityName '\(value)'"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct CapabilitySet: Sendable, Hashable {
|
||||
private enum Value: Hashable, Sendable, CaseIterable {
|
||||
case bounding
|
||||
case effective
|
||||
case inheritable
|
||||
case permitted
|
||||
case ambient
|
||||
}
|
||||
|
||||
private var value: Value
|
||||
private init(_ value: Value) {
|
||||
self.value = value
|
||||
}
|
||||
|
||||
public init(rawValue: String) throws {
|
||||
let values = Value.allCases.reduce(into: [String: Value]()) {
|
||||
$0[String(describing: $1).lowercased()] = $1
|
||||
}
|
||||
|
||||
guard let match = values[rawValue.lowercased()] else {
|
||||
throw CapabilityParsingError.invalidCapabilitySet(rawValue)
|
||||
}
|
||||
self.value = match
|
||||
}
|
||||
|
||||
public static var bounding: Self { Self(.bounding) }
|
||||
public static var effective: Self { Self(.effective) }
|
||||
public static var inheritable: Self { Self(.inheritable) }
|
||||
public static var permitted: Self { Self(.permitted) }
|
||||
public static var ambient: Self { Self(.ambient) }
|
||||
}
|
||||
|
||||
extension CapabilitySet: CustomStringConvertible {
|
||||
public var description: String {
|
||||
String(describing: self.value)
|
||||
}
|
||||
}
|
||||
|
||||
public struct CapabilityName: Sendable, Hashable {
|
||||
private enum Value: Hashable, Sendable, CaseIterable {
|
||||
case chown
|
||||
case dacOverride
|
||||
case dacReadSearch
|
||||
case fowner
|
||||
case fsetid
|
||||
case kill
|
||||
case setgid
|
||||
case setuid
|
||||
case setpcap
|
||||
case linuxImmutable
|
||||
case netBindService
|
||||
case netBroadcast
|
||||
case netAdmin
|
||||
case netRaw
|
||||
case ipcLock
|
||||
case ipcOwner
|
||||
case sysModule
|
||||
case sysRawio
|
||||
case sysChroot
|
||||
case sysPtrace
|
||||
case sysPacct
|
||||
case sysAdmin
|
||||
case sysBoot
|
||||
case sysNice
|
||||
case sysResource
|
||||
case sysTime
|
||||
case sysTtyConfig
|
||||
case mknod
|
||||
case lease
|
||||
case auditWrite
|
||||
case auditControl
|
||||
case setfcap
|
||||
case macOverride
|
||||
case macAdmin
|
||||
case syslog
|
||||
case wakeAlarm
|
||||
case blockSuspend
|
||||
case auditRead
|
||||
case perfmon
|
||||
case bpf
|
||||
case checkpointRestore
|
||||
}
|
||||
|
||||
private var value: Value
|
||||
private init(_ value: Value) {
|
||||
self.value = value
|
||||
}
|
||||
|
||||
public init(rawValue: String) throws {
|
||||
let uppercased = rawValue.uppercased()
|
||||
let normalized = uppercased.hasPrefix("CAP_") ? uppercased : "CAP_\(uppercased)"
|
||||
|
||||
let capNameMap: [String: Value] = [
|
||||
"CAP_CHOWN": .chown,
|
||||
"CAP_DAC_OVERRIDE": .dacOverride,
|
||||
"CAP_DAC_READ_SEARCH": .dacReadSearch,
|
||||
"CAP_FOWNER": .fowner,
|
||||
"CAP_FSETID": .fsetid,
|
||||
"CAP_KILL": .kill,
|
||||
"CAP_SETGID": .setgid,
|
||||
"CAP_SETUID": .setuid,
|
||||
"CAP_SETPCAP": .setpcap,
|
||||
"CAP_LINUX_IMMUTABLE": .linuxImmutable,
|
||||
"CAP_NET_BIND_SERVICE": .netBindService,
|
||||
"CAP_NET_BROADCAST": .netBroadcast,
|
||||
"CAP_NET_ADMIN": .netAdmin,
|
||||
"CAP_NET_RAW": .netRaw,
|
||||
"CAP_IPC_LOCK": .ipcLock,
|
||||
"CAP_IPC_OWNER": .ipcOwner,
|
||||
"CAP_SYS_MODULE": .sysModule,
|
||||
"CAP_SYS_RAWIO": .sysRawio,
|
||||
"CAP_SYS_CHROOT": .sysChroot,
|
||||
"CAP_SYS_PTRACE": .sysPtrace,
|
||||
"CAP_SYS_PACCT": .sysPacct,
|
||||
"CAP_SYS_ADMIN": .sysAdmin,
|
||||
"CAP_SYS_BOOT": .sysBoot,
|
||||
"CAP_SYS_NICE": .sysNice,
|
||||
"CAP_SYS_RESOURCE": .sysResource,
|
||||
"CAP_SYS_TIME": .sysTime,
|
||||
"CAP_SYS_TTY_CONFIG": .sysTtyConfig,
|
||||
"CAP_MKNOD": .mknod,
|
||||
"CAP_LEASE": .lease,
|
||||
"CAP_AUDIT_WRITE": .auditWrite,
|
||||
"CAP_AUDIT_CONTROL": .auditControl,
|
||||
"CAP_SETFCAP": .setfcap,
|
||||
"CAP_MAC_OVERRIDE": .macOverride,
|
||||
"CAP_MAC_ADMIN": .macAdmin,
|
||||
"CAP_SYSLOG": .syslog,
|
||||
"CAP_WAKE_ALARM": .wakeAlarm,
|
||||
"CAP_BLOCK_SUSPEND": .blockSuspend,
|
||||
"CAP_AUDIT_READ": .auditRead,
|
||||
"CAP_PERFMON": .perfmon,
|
||||
"CAP_BPF": .bpf,
|
||||
"CAP_CHECKPOINT_RESTORE": .checkpointRestore,
|
||||
]
|
||||
|
||||
guard let match = capNameMap[normalized] else {
|
||||
throw CapabilityParsingError.invalidCapabilityName(rawValue)
|
||||
}
|
||||
self.value = match
|
||||
}
|
||||
|
||||
public var capValue: UInt32 {
|
||||
switch self.value {
|
||||
case .chown: return 0
|
||||
case .dacOverride: return 1
|
||||
case .dacReadSearch: return 2
|
||||
case .fowner: return 3
|
||||
case .fsetid: return 4
|
||||
case .kill: return 5
|
||||
case .setgid: return 6
|
||||
case .setuid: return 7
|
||||
case .setpcap: return 8
|
||||
case .linuxImmutable: return 9
|
||||
case .netBindService: return 10
|
||||
case .netBroadcast: return 11
|
||||
case .netAdmin: return 12
|
||||
case .netRaw: return 13
|
||||
case .ipcLock: return 14
|
||||
case .ipcOwner: return 15
|
||||
case .sysModule: return 16
|
||||
case .sysRawio: return 17
|
||||
case .sysChroot: return 18
|
||||
case .sysPtrace: return 19
|
||||
case .sysPacct: return 20
|
||||
case .sysAdmin: return 21
|
||||
case .sysBoot: return 22
|
||||
case .sysNice: return 23
|
||||
case .sysResource: return 24
|
||||
case .sysTime: return 25
|
||||
case .sysTtyConfig: return 26
|
||||
case .mknod: return 27
|
||||
case .lease: return 28
|
||||
case .auditWrite: return 29
|
||||
case .auditControl: return 30
|
||||
case .setfcap: return 31
|
||||
case .macOverride: return 32
|
||||
case .macAdmin: return 33
|
||||
case .syslog: return 34
|
||||
case .wakeAlarm: return 35
|
||||
case .blockSuspend: return 36
|
||||
case .auditRead: return 37
|
||||
case .perfmon: return 38
|
||||
case .bpf: return 39
|
||||
case .checkpointRestore: return 40
|
||||
}
|
||||
}
|
||||
|
||||
public static var chown: Self { Self(.chown) }
|
||||
public static var dacOverride: Self { Self(.dacOverride) }
|
||||
public static var dacReadSearch: Self { Self(.dacReadSearch) }
|
||||
public static var fowner: Self { Self(.fowner) }
|
||||
public static var fsetid: Self { Self(.fsetid) }
|
||||
public static var kill: Self { Self(.kill) }
|
||||
public static var setgid: Self { Self(.setgid) }
|
||||
public static var setuid: Self { Self(.setuid) }
|
||||
public static var setpcap: Self { Self(.setpcap) }
|
||||
public static var linuxImmutable: Self { Self(.linuxImmutable) }
|
||||
public static var netBindService: Self { Self(.netBindService) }
|
||||
public static var netBroadcast: Self { Self(.netBroadcast) }
|
||||
public static var netAdmin: Self { Self(.netAdmin) }
|
||||
public static var netRaw: Self { Self(.netRaw) }
|
||||
public static var ipcLock: Self { Self(.ipcLock) }
|
||||
public static var ipcOwner: Self { Self(.ipcOwner) }
|
||||
public static var sysModule: Self { Self(.sysModule) }
|
||||
public static var sysRawio: Self { Self(.sysRawio) }
|
||||
public static var sysChroot: Self { Self(.sysChroot) }
|
||||
public static var sysPtrace: Self { Self(.sysPtrace) }
|
||||
public static var sysPacct: Self { Self(.sysPacct) }
|
||||
public static var sysAdmin: Self { Self(.sysAdmin) }
|
||||
public static var sysBoot: Self { Self(.sysBoot) }
|
||||
public static var sysNice: Self { Self(.sysNice) }
|
||||
public static var sysResource: Self { Self(.sysResource) }
|
||||
public static var sysTime: Self { Self(.sysTime) }
|
||||
public static var sysTtyConfig: Self { Self(.sysTtyConfig) }
|
||||
public static var mknod: Self { Self(.mknod) }
|
||||
public static var lease: Self { Self(.lease) }
|
||||
public static var auditWrite: Self { Self(.auditWrite) }
|
||||
public static var auditControl: Self { Self(.auditControl) }
|
||||
public static var setfcap: Self { Self(.setfcap) }
|
||||
public static var macOverride: Self { Self(.macOverride) }
|
||||
public static var macAdmin: Self { Self(.macAdmin) }
|
||||
public static var syslog: Self { Self(.syslog) }
|
||||
public static var wakeAlarm: Self { Self(.wakeAlarm) }
|
||||
public static var blockSuspend: Self { Self(.blockSuspend) }
|
||||
public static var auditRead: Self { Self(.auditRead) }
|
||||
public static var perfmon: Self { Self(.perfmon) }
|
||||
public static var bpf: Self { Self(.bpf) }
|
||||
public static var checkpointRestore: Self { Self(.checkpointRestore) }
|
||||
|
||||
public static var allCases: [CapabilityName] {
|
||||
Value.allCases.map { CapabilityName($0) }
|
||||
}
|
||||
}
|
||||
|
||||
extension CapabilityName: CustomStringConvertible {
|
||||
public var description: String {
|
||||
switch self.value {
|
||||
case .chown: return "CAP_CHOWN"
|
||||
case .dacOverride: return "CAP_DAC_OVERRIDE"
|
||||
case .dacReadSearch: return "CAP_DAC_READ_SEARCH"
|
||||
case .fowner: return "CAP_FOWNER"
|
||||
case .fsetid: return "CAP_FSETID"
|
||||
case .kill: return "CAP_KILL"
|
||||
case .setgid: return "CAP_SETGID"
|
||||
case .setuid: return "CAP_SETUID"
|
||||
case .setpcap: return "CAP_SETPCAP"
|
||||
case .linuxImmutable: return "CAP_LINUX_IMMUTABLE"
|
||||
case .netBindService: return "CAP_NET_BIND_SERVICE"
|
||||
case .netBroadcast: return "CAP_NET_BROADCAST"
|
||||
case .netAdmin: return "CAP_NET_ADMIN"
|
||||
case .netRaw: return "CAP_NET_RAW"
|
||||
case .ipcLock: return "CAP_IPC_LOCK"
|
||||
case .ipcOwner: return "CAP_IPC_OWNER"
|
||||
case .sysModule: return "CAP_SYS_MODULE"
|
||||
case .sysRawio: return "CAP_SYS_RAWIO"
|
||||
case .sysChroot: return "CAP_SYS_CHROOT"
|
||||
case .sysPtrace: return "CAP_SYS_PTRACE"
|
||||
case .sysPacct: return "CAP_SYS_PACCT"
|
||||
case .sysAdmin: return "CAP_SYS_ADMIN"
|
||||
case .sysBoot: return "CAP_SYS_BOOT"
|
||||
case .sysNice: return "CAP_SYS_NICE"
|
||||
case .sysResource: return "CAP_SYS_RESOURCE"
|
||||
case .sysTime: return "CAP_SYS_TIME"
|
||||
case .sysTtyConfig: return "CAP_SYS_TTY_CONFIG"
|
||||
case .mknod: return "CAP_MKNOD"
|
||||
case .lease: return "CAP_LEASE"
|
||||
case .auditWrite: return "CAP_AUDIT_WRITE"
|
||||
case .auditControl: return "CAP_AUDIT_CONTROL"
|
||||
case .setfcap: return "CAP_SETFCAP"
|
||||
case .macOverride: return "CAP_MAC_OVERRIDE"
|
||||
case .macAdmin: return "CAP_MAC_ADMIN"
|
||||
case .syslog: return "CAP_SYSLOG"
|
||||
case .wakeAlarm: return "CAP_WAKE_ALARM"
|
||||
case .blockSuspend: return "CAP_BLOCK_SUSPEND"
|
||||
case .auditRead: return "CAP_AUDIT_READ"
|
||||
case .perfmon: return "CAP_PERFMON"
|
||||
case .bpf: return "CAP_BPF"
|
||||
case .checkpointRestore: return "CAP_CHECKPOINT_RESTORE"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Linux Implementation
|
||||
|
||||
#if os(Linux)
|
||||
|
||||
#if canImport(Musl)
|
||||
import Musl
|
||||
#elseif canImport(Glibc)
|
||||
import Glibc
|
||||
#endif
|
||||
|
||||
import CShim
|
||||
|
||||
/// Capability type flags
|
||||
public struct CapType: OptionSet, Sendable {
|
||||
public let rawValue: UInt32
|
||||
|
||||
public init(rawValue: UInt32) {
|
||||
self.rawValue = rawValue
|
||||
}
|
||||
|
||||
// Individual capability sets (for Get/Set/Unset/etc)
|
||||
public static let effective = CapType(rawValue: 1 << 0)
|
||||
public static let permitted = CapType(rawValue: 1 << 1)
|
||||
public static let inheritable = CapType(rawValue: 1 << 2)
|
||||
public static let bounding = CapType(rawValue: 1 << 3)
|
||||
public static let ambient = CapType(rawValue: 1 << 4)
|
||||
|
||||
// Bulk operation flags (for Apply/Fill/Clear)
|
||||
public static let caps = CapType(rawValue: 1 << 8) // CAPS - effective, permitted, inheritable
|
||||
public static let bounds = CapType(rawValue: 1 << 9) // BOUNDS - bounding set
|
||||
public static let ambs = CapType(rawValue: 1 << 10) // AMBS - ambient capabilities
|
||||
}
|
||||
|
||||
private struct CapabilityHeader {
|
||||
var version: UInt32
|
||||
var pid: Int32
|
||||
|
||||
init(pid: Int32 = 0) {
|
||||
self.version = 0x2008_0522
|
||||
self.pid = pid
|
||||
}
|
||||
}
|
||||
|
||||
private struct CapabilityData {
|
||||
var effective1: UInt32
|
||||
var permitted1: UInt32
|
||||
var inheritable1: UInt32
|
||||
var effective2: UInt32
|
||||
var permitted2: UInt32
|
||||
var inheritable2: UInt32
|
||||
|
||||
init(
|
||||
effective1: UInt32 = 0,
|
||||
permitted1: UInt32 = 0,
|
||||
inheritable1: UInt32 = 0,
|
||||
effective2: UInt32 = 0,
|
||||
permitted2: UInt32 = 0,
|
||||
inheritable2: UInt32 = 0
|
||||
) {
|
||||
self.effective1 = effective1
|
||||
self.permitted1 = permitted1
|
||||
self.inheritable1 = inheritable1
|
||||
self.effective2 = effective2
|
||||
self.permitted2 = permitted2
|
||||
self.inheritable2 = inheritable2
|
||||
}
|
||||
}
|
||||
|
||||
/// Interface with Linux capabilities
|
||||
/// https://linux.die.net/man/7/capabilities
|
||||
public struct LinuxCapabilities: Sendable {
|
||||
private var effectiveSet: UInt64 = 0
|
||||
private var permittedSet: UInt64 = 0
|
||||
private var inheritableSet: UInt64 = 0
|
||||
private var boundingSet: UInt64 = 0
|
||||
private var ambientSet: UInt64 = 0
|
||||
|
||||
public init() {}
|
||||
|
||||
/// Get the highest supported capability from the kernel
|
||||
public static func getLastSupported() throws -> CapabilityName {
|
||||
guard let data = try? String(contentsOfFile: "/proc/sys/kernel/cap_last_cap", encoding: .ascii),
|
||||
let lastCap = UInt32(data.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
else {
|
||||
throw LinuxCapabilities.Error.invalidCapabilitySet("failed to read /proc/sys/kernel/cap_last_cap")
|
||||
}
|
||||
|
||||
guard let capability = CapabilityName.allCases.first(where: { $0.capValue == lastCap }) else {
|
||||
throw LinuxCapabilities.Error.invalidCapabilitySet("no capability found for kernel max cap \(lastCap)")
|
||||
}
|
||||
|
||||
return capability
|
||||
}
|
||||
|
||||
/// Set keep caps
|
||||
public static func setKeepCaps() throws {
|
||||
let result = CZ_prctl_set_keepcaps()
|
||||
if result != 0 {
|
||||
throw LinuxCapabilities.Error.prctlFailed(errno: errno, operation: "PR_SET_KEEPCAPS")
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear keep caps
|
||||
public static func clearKeepCaps() throws {
|
||||
let result = CZ_prctl_clear_keepcaps()
|
||||
if result != 0 {
|
||||
throw LinuxCapabilities.Error.prctlFailed(errno: errno, operation: "PR_CLEAR_KEEPCAPS")
|
||||
}
|
||||
}
|
||||
|
||||
/// Load current process capabilities from kernel
|
||||
public mutating func load() throws {
|
||||
let data = try getCurrentCapabilities()
|
||||
self.effectiveSet = UInt64(data.effective1)
|
||||
self.permittedSet = UInt64(data.permitted1)
|
||||
self.inheritableSet = UInt64(data.inheritable1)
|
||||
}
|
||||
|
||||
/// Check if capability is present in the given set
|
||||
public func get(which: CapType, what: CapabilityName) -> Bool {
|
||||
let bit = UInt64(1) << what.capValue
|
||||
|
||||
if which.contains(.effective) {
|
||||
return (effectiveSet & bit) != 0
|
||||
} else if which.contains(.permitted) {
|
||||
return (permittedSet & bit) != 0
|
||||
} else if which.contains(.inheritable) {
|
||||
return (inheritableSet & bit) != 0
|
||||
} else if which.contains(.bounding) {
|
||||
return (boundingSet & bit) != 0
|
||||
} else if which.contains(.ambient) {
|
||||
return (ambientSet & bit) != 0
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/// Set capabilities in the given sets
|
||||
public mutating func set(which: CapType, caps: [CapabilityName]) {
|
||||
let mask = caps.reduce(UInt64(0)) { result, cap in
|
||||
result | (UInt64(1) << cap.capValue)
|
||||
}
|
||||
|
||||
if which.contains(.effective) {
|
||||
effectiveSet |= mask
|
||||
}
|
||||
if which.contains(.permitted) {
|
||||
permittedSet |= mask
|
||||
}
|
||||
if which.contains(.inheritable) {
|
||||
inheritableSet |= mask
|
||||
}
|
||||
if which.contains(.bounding) {
|
||||
boundingSet |= mask
|
||||
}
|
||||
if which.contains(.ambient) {
|
||||
ambientSet |= mask
|
||||
}
|
||||
}
|
||||
|
||||
/// Unset capabilities from the given sets
|
||||
public mutating func unset(which: CapType, caps: [CapabilityName]) {
|
||||
let mask = caps.reduce(UInt64(0)) { result, cap in
|
||||
result | (UInt64(1) << cap.capValue)
|
||||
}
|
||||
|
||||
if which.contains(.effective) {
|
||||
effectiveSet &= ~mask
|
||||
}
|
||||
if which.contains(.permitted) {
|
||||
permittedSet &= ~mask
|
||||
}
|
||||
if which.contains(.inheritable) {
|
||||
inheritableSet &= ~mask
|
||||
}
|
||||
if which.contains(.bounding) {
|
||||
boundingSet &= ~mask
|
||||
}
|
||||
if which.contains(.ambient) {
|
||||
ambientSet &= ~mask
|
||||
}
|
||||
}
|
||||
|
||||
/// Fill all bits of given capability types
|
||||
public mutating func fill(kind: CapType) {
|
||||
if kind.contains(.caps) {
|
||||
effectiveSet = 0xFFFF_FFFF_FFFF_FFFF
|
||||
permittedSet = 0xFFFF_FFFF_FFFF_FFFF
|
||||
inheritableSet = 0
|
||||
}
|
||||
if kind.contains(.bounds) {
|
||||
boundingSet = 0xFFFF_FFFF_FFFF_FFFF
|
||||
}
|
||||
if kind.contains(.ambs) {
|
||||
ambientSet = 0xFFFF_FFFF_FFFF_FFFF
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear all bits of given capability types
|
||||
public mutating func clear(kind: CapType) {
|
||||
if kind.contains(.caps) {
|
||||
effectiveSet = 0
|
||||
permittedSet = 0
|
||||
inheritableSet = 0
|
||||
}
|
||||
if kind.contains(.bounds) {
|
||||
boundingSet = 0
|
||||
}
|
||||
if kind.contains(.ambs) {
|
||||
ambientSet = 0
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply capabilities to current process
|
||||
public func apply(kind: CapType) throws {
|
||||
// Apply bounding set (requires CAP_SETPCAP)
|
||||
if kind.contains(.bounds) {
|
||||
try applyBoundingSet()
|
||||
}
|
||||
|
||||
// Apply main capabilities (effective, permitted, inheritable)
|
||||
if kind.contains(.caps) {
|
||||
try applyMainCapabilities()
|
||||
}
|
||||
|
||||
// Apply ambient capabilities
|
||||
if kind.contains(.ambs) {
|
||||
try applyAmbientCapabilities()
|
||||
}
|
||||
}
|
||||
|
||||
private func applyBoundingSet() throws {
|
||||
let currentData = try getCurrentCapabilities()
|
||||
let hasSetPCap = (currentData.effective1 & (1 << CapabilityName.setpcap.capValue)) != 0
|
||||
|
||||
if hasSetPCap {
|
||||
// Get the last supported capability to avoid trying to drop unsupported ones
|
||||
let lastSupported = try Self.getLastSupported()
|
||||
|
||||
for cap in CapabilityName.allCases {
|
||||
// Skip capabilities higher than what the kernel supports
|
||||
guard cap.capValue <= lastSupported.capValue else { continue }
|
||||
|
||||
let capBit = UInt64(1) << cap.capValue
|
||||
if (boundingSet & capBit) == 0 {
|
||||
let result = CZ_prctl_capbset_drop(cap.capValue)
|
||||
if result != 0 && errno != EINVAL {
|
||||
throw Error.prctlFailed(errno: errno, operation: "PR_CAPBSET_DROP")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func applyMainCapabilities() throws {
|
||||
let data = CapabilityData(
|
||||
effective1: UInt32(effectiveSet & 0xFFFF_FFFF),
|
||||
permitted1: UInt32(permittedSet & 0xFFFF_FFFF),
|
||||
inheritable1: UInt32(inheritableSet & 0xFFFF_FFFF)
|
||||
)
|
||||
|
||||
try setCapabilities(data: data)
|
||||
}
|
||||
|
||||
private func applyAmbientCapabilities() throws {
|
||||
// Clear all ambient capabilities first
|
||||
let clearResult = CZ_prctl_cap_ambient_clear_all()
|
||||
if clearResult != 0 && errno != EINVAL {
|
||||
throw Error.prctlFailed(errno: errno, operation: "PR_CAP_AMBIENT_CLEAR_ALL")
|
||||
}
|
||||
|
||||
// Get the last supported capability to avoid trying to set unsupported ones
|
||||
let lastSupported = try Self.getLastSupported()
|
||||
|
||||
// Set each ambient capability
|
||||
for cap in CapabilityName.allCases {
|
||||
// Skip capabilities higher than what the kernel supports
|
||||
guard cap.capValue <= lastSupported.capValue else { continue }
|
||||
|
||||
let capBit = UInt64(1) << cap.capValue
|
||||
if (ambientSet & capBit) != 0 {
|
||||
let result = CZ_prctl_cap_ambient_raise(cap.capValue)
|
||||
if result != 0 && errno != EINVAL {
|
||||
throw Error.prctlFailed(errno: errno, operation: "PR_CAP_AMBIENT_RAISE")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func getCurrentCapabilities() throws -> CapabilityData {
|
||||
var header = CapabilityHeader()
|
||||
var data = CapabilityData()
|
||||
|
||||
let result = withUnsafeMutablePointer(to: &header) { headerPtr in
|
||||
withUnsafeMutablePointer(to: &data) { dataPtr in
|
||||
CZ_capget(headerPtr, dataPtr)
|
||||
}
|
||||
}
|
||||
|
||||
if result != 0 {
|
||||
throw Error.capgetFailed(errno: errno)
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
private func setCapabilities(data: CapabilityData) throws {
|
||||
var header = CapabilityHeader()
|
||||
var mutableData = data
|
||||
|
||||
let result = withUnsafeMutablePointer(to: &header) { headerPtr in
|
||||
withUnsafeMutablePointer(to: &mutableData) { dataPtr in
|
||||
CZ_capset(headerPtr, dataPtr)
|
||||
}
|
||||
}
|
||||
|
||||
if result != 0 {
|
||||
throw Error.capsetFailed(errno: errno)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension LinuxCapabilities {
|
||||
public enum Error: Swift.Error, CustomStringConvertible {
|
||||
case unsupportedCapability(name: String)
|
||||
case capsetFailed(errno: Int32)
|
||||
case capgetFailed(errno: Int32)
|
||||
case prctlFailed(errno: Int32, operation: String)
|
||||
case invalidCapabilitySet(String)
|
||||
|
||||
public var description: String {
|
||||
switch self {
|
||||
case .unsupportedCapability(let name):
|
||||
return "unsupported capability: \(name)"
|
||||
case .capsetFailed(let errno):
|
||||
return "capset failed with errno \(errno): \(String(cString: strerror(errno)))"
|
||||
case .capgetFailed(let errno):
|
||||
return "capget failed with errno \(errno): \(String(cString: strerror(errno)))"
|
||||
case .prctlFailed(let errno, let operation):
|
||||
return "prctl(\(operation)) failed with errno \(errno): \(String(cString: strerror(errno)))"
|
||||
case .invalidCapabilitySet(let message):
|
||||
return "invalid capability set configuration: \(message)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,189 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
#if os(Linux)
|
||||
#if canImport(FoundationEssentials)
|
||||
import FoundationEssentials
|
||||
#else
|
||||
import Foundation
|
||||
#endif
|
||||
|
||||
#if canImport(Musl)
|
||||
import Musl
|
||||
private let _write = Musl.write
|
||||
#elseif canImport(Glibc)
|
||||
import Glibc
|
||||
private let _write = Glibc.write
|
||||
#endif
|
||||
|
||||
import CShim
|
||||
|
||||
// On glibc, epoll constants are EPOLL_EVENTS enum values. On musl they're
|
||||
// plain UInt32. These helpers normalize them to UInt32/Int32.
|
||||
private func epollMask(_ value: UInt32) -> UInt32 { value }
|
||||
private func epollMask(_ value: Int32) -> UInt32 { UInt32(bitPattern: value) }
|
||||
#if canImport(Glibc)
|
||||
private func epollMask(_ value: EPOLL_EVENTS) -> UInt32 { value.rawValue }
|
||||
private func epollFlag(_ value: EPOLL_EVENTS) -> Int32 { Int32(bitPattern: value.rawValue) }
|
||||
#endif
|
||||
|
||||
/// A thin wrapper around the Linux epoll syscall surface.
|
||||
public final class Epoll: Sendable {
|
||||
/// A set of epoll event flags.
|
||||
public struct Mask: OptionSet, Sendable {
|
||||
public let rawValue: UInt32
|
||||
|
||||
public init(rawValue: UInt32) {
|
||||
self.rawValue = rawValue
|
||||
}
|
||||
|
||||
public static let input = Mask(rawValue: epollMask(EPOLLIN))
|
||||
public static let output = Mask(rawValue: epollMask(EPOLLOUT))
|
||||
|
||||
public var isHangup: Bool {
|
||||
!self.isDisjoint(with: Mask(rawValue: epollMask(EPOLLHUP) | epollMask(EPOLLERR)))
|
||||
}
|
||||
|
||||
public var isRemoteHangup: Bool {
|
||||
!self.isDisjoint(with: Mask(rawValue: epollMask(EPOLLRDHUP)))
|
||||
}
|
||||
|
||||
public var readyToRead: Bool {
|
||||
self.contains(.input)
|
||||
}
|
||||
|
||||
public var readyToWrite: Bool {
|
||||
self.contains(.output)
|
||||
}
|
||||
}
|
||||
|
||||
/// An event returned by `wait()`.
|
||||
public struct Event: Sendable {
|
||||
public let fd: Int32
|
||||
public let mask: Mask
|
||||
}
|
||||
|
||||
private let epollFD: Int32
|
||||
private let eventFD: Int32
|
||||
|
||||
public init() throws {
|
||||
let efd = epoll_create1(Int32(EPOLL_CLOEXEC))
|
||||
guard efd >= 0 else {
|
||||
throw POSIXError.fromErrno()
|
||||
}
|
||||
|
||||
let evfd = eventfd(0, Int32(EFD_CLOEXEC | EFD_NONBLOCK))
|
||||
guard evfd >= 0 else {
|
||||
let evfdErrno = POSIXError.fromErrno()
|
||||
close(efd)
|
||||
throw evfdErrno
|
||||
}
|
||||
|
||||
self.epollFD = efd
|
||||
self.eventFD = evfd
|
||||
|
||||
// Register the eventfd with epoll for shutdown signaling.
|
||||
var event = epoll_event()
|
||||
event.events = epollMask(EPOLLIN)
|
||||
event.data.fd = self.eventFD
|
||||
let ctlResult = withUnsafeMutablePointer(to: &event) { ptr in
|
||||
epoll_ctl(efd, EPOLL_CTL_ADD, self.eventFD, ptr)
|
||||
}
|
||||
guard ctlResult == 0 else {
|
||||
let ctlErrno = POSIXError.fromErrno()
|
||||
close(evfd)
|
||||
close(efd)
|
||||
throw ctlErrno
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
close(epollFD)
|
||||
close(eventFD)
|
||||
}
|
||||
|
||||
/// Register a file descriptor for edge-triggered monitoring.
|
||||
public func add(_ fd: Int32, mask: Mask) throws {
|
||||
guard fcntl(fd, F_SETFL, O_NONBLOCK) == 0 else {
|
||||
throw POSIXError.fromErrno()
|
||||
}
|
||||
|
||||
let events = epollMask(EPOLLET) | mask.rawValue
|
||||
|
||||
var event = epoll_event()
|
||||
event.events = events
|
||||
event.data.fd = fd
|
||||
|
||||
try withUnsafeMutablePointer(to: &event) { ptr in
|
||||
if epoll_ctl(self.epollFD, EPOLL_CTL_ADD, fd, ptr) == -1 {
|
||||
throw POSIXError.fromErrno()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a file descriptor from the monitored collection.
|
||||
public func delete(_ fd: Int32) throws {
|
||||
var event = epoll_event()
|
||||
let result = withUnsafeMutablePointer(to: &event) { ptr in
|
||||
epoll_ctl(self.epollFD, EPOLL_CTL_DEL, fd, ptr) as Int32
|
||||
}
|
||||
if result != 0 {
|
||||
throw POSIXError.fromErrno()
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait for events.
|
||||
///
|
||||
/// Returns ready events, an empty array on timeout, or `nil` on shutdown.
|
||||
public func wait(maxEvents: Int = 128, timeout: Int32 = -1) -> [Event]? {
|
||||
var events: [epoll_event] = .init(repeating: epoll_event(), count: maxEvents)
|
||||
|
||||
while true {
|
||||
let n = epoll_wait(self.epollFD, &events, Int32(events.count), timeout)
|
||||
if n < 0 {
|
||||
if errno == EINTR || errno == EAGAIN {
|
||||
continue
|
||||
}
|
||||
preconditionFailure("epoll_wait failed unexpectedly: \(POSIXError.fromErrno())")
|
||||
}
|
||||
|
||||
if n == 0 {
|
||||
return []
|
||||
}
|
||||
|
||||
var result: [Event] = []
|
||||
result.reserveCapacity(Int(n))
|
||||
for i in 0..<Int(n) {
|
||||
let fd = events[i].data.fd
|
||||
if fd == self.eventFD {
|
||||
return nil
|
||||
}
|
||||
result.append(Event(fd: fd, mask: Mask(rawValue: events[i].events)))
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
/// Signal the epoll loop to stop waiting.
|
||||
public func shutdown() {
|
||||
var val: UInt64 = 1
|
||||
let n = _write(eventFD, &val, MemoryLayout<UInt64>.size)
|
||||
precondition(n == MemoryLayout<UInt64>.size, "eventfd write failed: \(POSIXError.fromErrno())")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#endif // os(Linux)
|
||||
@@ -0,0 +1,410 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
#if canImport(Musl)
|
||||
import Musl
|
||||
#elseif canImport(Glibc)
|
||||
import Glibc
|
||||
#elseif canImport(Darwin)
|
||||
import Darwin
|
||||
#endif
|
||||
|
||||
#if canImport(FoundationEssentials)
|
||||
import FoundationEssentials
|
||||
#else
|
||||
import Foundation
|
||||
#endif
|
||||
|
||||
extension POSIXError {
|
||||
public static func fromErrno() -> POSIXError {
|
||||
guard let errCode = POSIXErrorCode(rawValue: errno) else {
|
||||
fatalError("failed to convert errno to POSIXErrorCode")
|
||||
}
|
||||
return POSIXError(errCode)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
#if canImport(FoundationEssentials)
|
||||
import FoundationEssentials
|
||||
#else
|
||||
import Foundation
|
||||
#endif
|
||||
|
||||
/// `Path` provides utilities to look for binaries in the current PATH,
|
||||
/// or to return the current PATH.
|
||||
public struct Path {
|
||||
/// lookPath looks up an executable's path from $PATH
|
||||
public static func lookPath(_ name: String) -> URL? {
|
||||
lookup(name, path: getCurrentPath())
|
||||
}
|
||||
|
||||
public static func lookPath(_ name: String, path: String) -> URL? {
|
||||
lookup(name, path: path)
|
||||
}
|
||||
|
||||
// getEnv returns the default environment of the process
|
||||
// with the default $PATH added for the context of a macOS application bundle
|
||||
public static func getEnv() -> [String: String] {
|
||||
var env = ProcessInfo.processInfo.environment
|
||||
env["PATH"] = getCurrentPath()
|
||||
return env
|
||||
}
|
||||
|
||||
private static func lookup(_ name: String, path: String) -> URL? {
|
||||
// Return nil for empty names
|
||||
if name.isEmpty {
|
||||
return nil
|
||||
}
|
||||
|
||||
if name.contains("/") {
|
||||
if findExec(name) {
|
||||
return URL(fileURLWithPath: name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
for var lookdir in path.split(separator: ":") {
|
||||
if lookdir.isEmpty {
|
||||
lookdir = "."
|
||||
}
|
||||
let file = URL(fileURLWithPath: String(lookdir)).appendingPathComponent(name)
|
||||
if findExec(file.path) {
|
||||
return file
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// getPath returns $PATH for the current process
|
||||
public static func getCurrentPath() -> String {
|
||||
let env = ProcessInfo.processInfo.environment
|
||||
return env["PATH"] ?? "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
|
||||
}
|
||||
|
||||
// findPath returns a string containing the 'PATH' environment variable
|
||||
public static func findPath(_ env: [String]?) -> String? {
|
||||
guard let env = env else {
|
||||
return nil
|
||||
}
|
||||
return env.first(where: { $0.hasPrefix("PATH=") })
|
||||
.map { String($0.dropFirst(5)) }
|
||||
}
|
||||
|
||||
// findExec returns true if the provided path is an executable
|
||||
private static func findExec(_ path: String) -> Bool {
|
||||
let fm = FileManager.default
|
||||
return fm.isExecutableFile(atPath: path)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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 Foundation
|
||||
|
||||
extension Pipe {
|
||||
/// Close both sides of the pipe.
|
||||
public func close() throws {
|
||||
var err: Swift.Error?
|
||||
do {
|
||||
try self.fileHandleForReading.close()
|
||||
} catch {
|
||||
err = error
|
||||
}
|
||||
try self.fileHandleForWriting.close()
|
||||
if let err {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/// Ensure that both sides of the pipe are set with O_CLOEXEC.
|
||||
public func setCloexec() throws {
|
||||
if fcntl(self.fileHandleForWriting.fileDescriptor, F_SETFD, FD_CLOEXEC) == -1 {
|
||||
throw POSIXError(.init(rawValue: errno)!)
|
||||
}
|
||||
if fcntl(self.fileHandleForReading.fileDescriptor, F_SETFD, FD_CLOEXEC) == -1 {
|
||||
throw POSIXError(.init(rawValue: errno)!)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
## OS
|
||||
|
||||
This target contains general useful OS related definitions or wrappers.
|
||||
@@ -0,0 +1,60 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
#if canImport(Musl)
|
||||
import Musl
|
||||
#elseif canImport(Glibc)
|
||||
import Glibc
|
||||
#elseif canImport(Darwin)
|
||||
import Darwin
|
||||
#endif
|
||||
#if canImport(FoundationEssentials)
|
||||
import FoundationEssentials
|
||||
#else
|
||||
import Foundation
|
||||
#endif
|
||||
|
||||
/// A process reaper that returns exited processes along
|
||||
/// with their exit status.
|
||||
public struct Reaper {
|
||||
/// Process's pid and exit status.
|
||||
typealias Exit = (pid: Int32, status: Int32)
|
||||
|
||||
/// Reap all pending processes and return the pid and exit status.
|
||||
public static func reap() -> [Int32: Int32] {
|
||||
var reaped = [Int32: Int32]()
|
||||
while true {
|
||||
guard let exit = wait() else {
|
||||
return reaped
|
||||
}
|
||||
reaped[exit.pid] = exit.status
|
||||
}
|
||||
return reaped
|
||||
}
|
||||
|
||||
/// Returns the exit status of the last process that exited.
|
||||
/// nil is returned when no pending processes exist.
|
||||
private static func wait() -> Exit? {
|
||||
var rus = rusage()
|
||||
var ws = Int32()
|
||||
|
||||
let pid = wait4(-1, &ws, WNOHANG, &rus)
|
||||
if pid <= 0 {
|
||||
return nil
|
||||
}
|
||||
return (pid: pid, status: Command.toExitStatus(ws))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ContainerizationError
|
||||
import Dispatch
|
||||
import Logging
|
||||
import Synchronization
|
||||
|
||||
#if canImport(Musl)
|
||||
import Musl
|
||||
#elseif canImport(Glibc)
|
||||
import Glibc
|
||||
#elseif canImport(Darwin)
|
||||
import Darwin
|
||||
#endif
|
||||
|
||||
#if canImport(FoundationEssentials)
|
||||
import FoundationEssentials
|
||||
#else
|
||||
import Foundation
|
||||
#endif
|
||||
|
||||
/// Manages bidirectional data relay between two file descriptors using `DispatchSource`.
|
||||
///
|
||||
/// Uses non-blocking I/O with backpressure: when a destination fd's buffer is full,
|
||||
/// the relay suspends reading from the source and installs a `DispatchSourceWrite`
|
||||
/// to resume once the destination is writable again. This prevents blocking the
|
||||
/// dispatch queue and avoids head-of-line blocking across connections.
|
||||
///
|
||||
/// ## Concurrency model
|
||||
///
|
||||
/// The class has two distinct synchronization domains:
|
||||
///
|
||||
/// - **Serial dispatch queue** — owns all I/O state: the `Direction` objects (`d1`, `d2`)
|
||||
/// and their read buffers (`buf1`, `buf2`). Every event handler, cancel handler, and
|
||||
/// `stop()` call runs on this queue. No locks are needed for that state because the
|
||||
/// queue is the exclusive executor. Fields in this domain are marked `nonisolated(unsafe)`.
|
||||
///
|
||||
/// - **Mutexes** — protect the two pieces of state that cross the queue boundary:
|
||||
/// `activeDirections` (written by `start()`, which may run off-queue) and
|
||||
/// `completionState` (read by `waitForCompletion()` from any async context).
|
||||
public final class BidirectionalRelay: Sendable {
|
||||
private let fd1: Int32
|
||||
private let fd2: Int32
|
||||
private let log: Logger?
|
||||
private let queue: DispatchQueue
|
||||
private static let queueKey = DispatchSpecificKey<Void>()
|
||||
|
||||
/// Owns one direction of the relay: its read source, optional write source, and
|
||||
/// any data buffered due to backpressure.
|
||||
///
|
||||
/// All methods must be called only from the relay's serial dispatch queue.
|
||||
private final class Direction {
|
||||
var readSource: DispatchSourceRead?
|
||||
var writeSource: DispatchSourceWrite?
|
||||
var pendingData: [UInt8] = []
|
||||
var pendingOffset: Int = 0
|
||||
private var readSuspended = false
|
||||
|
||||
func suspendRead() {
|
||||
guard let src = readSource, !src.isCancelled, !readSuspended else { return }
|
||||
readSuspended = true
|
||||
src.suspend()
|
||||
}
|
||||
|
||||
func resumeRead() {
|
||||
guard let src = readSource, !src.isCancelled, readSuspended else { return }
|
||||
readSuspended = false
|
||||
src.resume()
|
||||
}
|
||||
|
||||
/// Resumes the read source before cancelling it if it is suspended.
|
||||
/// GCD does not deliver a cancel handler for a suspended source until it is resumed.
|
||||
func cancelRead() {
|
||||
guard let src = readSource, !src.isCancelled else { return }
|
||||
if readSuspended {
|
||||
readSuspended = false
|
||||
src.resume()
|
||||
}
|
||||
src.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
private enum CompletionState {
|
||||
case pending
|
||||
case waiting(CheckedContinuation<Void, Never>)
|
||||
case completed
|
||||
}
|
||||
|
||||
private enum CopyResult {
|
||||
case ok
|
||||
case blocked
|
||||
case eof
|
||||
}
|
||||
|
||||
// Queue-owned state. Written by start() before activate(), so all subsequent
|
||||
// accesses from event/cancel handlers observe the initialized values without
|
||||
// additional synchronization. nonisolated(unsafe) declares that we are taking
|
||||
// responsibility for this; the serial queue is the enforcing mechanism.
|
||||
private nonisolated(unsafe) let d1 = Direction() // fd1 → fd2
|
||||
private nonisolated(unsafe) let d2 = Direction() // fd2 → fd1
|
||||
private nonisolated(unsafe) let buf1: UnsafeMutableBufferPointer<UInt8>
|
||||
private nonisolated(unsafe) let buf2: UnsafeMutableBufferPointer<UInt8>
|
||||
|
||||
// Counts active read sources. Set to 2 in start() (possibly off-queue) and
|
||||
// decremented in cancel handlers (always on the queue). The Mutex provides the
|
||||
// cross-thread visibility guarantee for the initial write from start(). Whichever
|
||||
// cancel handler drives the count to zero calls closeBothFds() exactly once —
|
||||
// no cross-source isCancelled checks, no possibility of double-close.
|
||||
private let activeDirections: Mutex<Int>
|
||||
|
||||
// May be read from any async context (waitForCompletion) and written from the
|
||||
// queue (closeBothFds), so it needs a Mutex rather than queue-only protection.
|
||||
private let completionState: Mutex<CompletionState>
|
||||
|
||||
/// Creates a new bidirectional relay between two file descriptors.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - fd1: The first file descriptor.
|
||||
/// - fd2: The second file descriptor.
|
||||
/// - queue: The dispatch queue to use for I/O operations. If nil, a new queue is created.
|
||||
/// - log: The optional logger for debugging.
|
||||
public init(
|
||||
fd1: Int32,
|
||||
fd2: Int32,
|
||||
queue: DispatchQueue? = nil,
|
||||
log: Logger? = nil
|
||||
) {
|
||||
self.fd1 = fd1
|
||||
self.fd2 = fd2
|
||||
self.queue = queue ?? DispatchQueue(label: "com.apple.containerization.bidirectional-relay")
|
||||
self.queue.setSpecific(key: Self.queueKey, value: ())
|
||||
self.log = log
|
||||
self.activeDirections = Mutex(0)
|
||||
self.completionState = Mutex(.pending)
|
||||
|
||||
let pageSize = Int(getpagesize())
|
||||
self.buf1 = UnsafeMutableBufferPointer<UInt8>.allocate(capacity: pageSize)
|
||||
self.buf2 = UnsafeMutableBufferPointer<UInt8>.allocate(capacity: pageSize)
|
||||
}
|
||||
|
||||
deinit {
|
||||
buf1.deallocate()
|
||||
buf2.deallocate()
|
||||
}
|
||||
|
||||
private static func setNonBlocking(_ fd: Int32) throws {
|
||||
let flags = fcntl(fd, F_GETFL)
|
||||
guard flags != -1 else {
|
||||
throw ContainerizationError(
|
||||
.internalError,
|
||||
message: "fcntl F_GETFL failed on fd \(fd): errno \(errno)"
|
||||
)
|
||||
}
|
||||
guard fcntl(fd, F_SETFL, flags | O_NONBLOCK) != -1 else {
|
||||
throw ContainerizationError(
|
||||
.internalError,
|
||||
message: "fcntl F_SETFL O_NONBLOCK failed on fd \(fd): errno \(errno)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts the bidirectional relay to copy data between fd1 and fd2.
|
||||
public func start() throws {
|
||||
try Self.setNonBlocking(fd1)
|
||||
try Self.setNonBlocking(fd2)
|
||||
|
||||
let src1 = DispatchSource.makeReadSource(fileDescriptor: fd1, queue: queue)
|
||||
let src2 = DispatchSource.makeReadSource(fileDescriptor: fd2, queue: queue)
|
||||
d1.readSource = src1
|
||||
d2.readSource = src2
|
||||
activeDirections.withLock { $0 = 2 }
|
||||
|
||||
src1.setEventHandler { [self] in handleRead(d1, from: fd1, to: fd2, buffer: buf1) }
|
||||
src2.setEventHandler { [self] in handleRead(d2, from: fd2, to: fd1, buffer: buf2) }
|
||||
|
||||
src1.setCancelHandler { [self] in
|
||||
d1.writeSource?.cancel()
|
||||
d1.writeSource = nil
|
||||
directionFinished()
|
||||
}
|
||||
src2.setCancelHandler { [self] in
|
||||
d2.writeSource?.cancel()
|
||||
d2.writeSource = nil
|
||||
directionFinished()
|
||||
}
|
||||
|
||||
src1.activate()
|
||||
src2.activate()
|
||||
}
|
||||
|
||||
/// Stops the relay and closes both file descriptors.
|
||||
public func stop() {
|
||||
runOnQueue {
|
||||
d1.cancelRead()
|
||||
d2.cancelRead()
|
||||
}
|
||||
}
|
||||
|
||||
/// Waits for the relay to complete.
|
||||
public func waitForCompletion() async {
|
||||
await withCheckedContinuation { c in
|
||||
completionState.withLock { state in
|
||||
switch state {
|
||||
case .pending:
|
||||
state = .waiting(c)
|
||||
case .waiting:
|
||||
fatalError("waitForCompletion called multiple times")
|
||||
case .completed:
|
||||
c.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func runOnQueue(_ work: () -> Void) {
|
||||
if DispatchQueue.getSpecific(key: Self.queueKey) != nil {
|
||||
work()
|
||||
} else {
|
||||
queue.sync(execute: work)
|
||||
}
|
||||
}
|
||||
|
||||
private func directionFinished() {
|
||||
let remaining = activeDirections.withLock { count -> Int in
|
||||
count -= 1
|
||||
return count
|
||||
}
|
||||
if remaining == 0 {
|
||||
closeBothFds()
|
||||
}
|
||||
}
|
||||
|
||||
private func handleRead(
|
||||
_ dir: Direction,
|
||||
from srcFd: Int32,
|
||||
to dstFd: Int32,
|
||||
buffer: UnsafeMutableBufferPointer<UInt8>
|
||||
) {
|
||||
do {
|
||||
switch try Self.copy(buffer: buffer, from: srcFd, to: dstFd, direction: dir) {
|
||||
case .ok:
|
||||
break
|
||||
|
||||
case .eof:
|
||||
log?.debug(
|
||||
"source EOF",
|
||||
metadata: ["sourceFd": "\(srcFd)", "destinationFd": "\(dstFd)"]
|
||||
)
|
||||
dir.cancelRead()
|
||||
if shutdown(dstFd, Int32(SHUT_WR)) != 0 {
|
||||
log?.debug(
|
||||
"shutdown(SHUT_WR) failed",
|
||||
metadata: ["fd": "\(dstFd)", "errno": "\(errno)"]
|
||||
)
|
||||
}
|
||||
|
||||
case .blocked:
|
||||
log?.debug(
|
||||
"write blocked, applying backpressure",
|
||||
metadata: [
|
||||
"sourceFd": "\(srcFd)",
|
||||
"destinationFd": "\(dstFd)",
|
||||
"pendingBytes": "\(dir.pendingData.count)",
|
||||
]
|
||||
)
|
||||
dir.suspendRead()
|
||||
installWriteSource(for: dir, from: srcFd, to: dstFd)
|
||||
}
|
||||
} catch {
|
||||
log?.warning(
|
||||
"I/O error",
|
||||
metadata: [
|
||||
"sourceFd": "\(srcFd)",
|
||||
"destinationFd": "\(dstFd)",
|
||||
"error": "\(error)",
|
||||
]
|
||||
)
|
||||
dir.cancelRead()
|
||||
if shutdown(dstFd, Int32(SHUT_RDWR)) != 0 {
|
||||
log?.warning(
|
||||
"shutdown(SHUT_RDWR) failed",
|
||||
metadata: ["fd": "\(dstFd)", "errno": "\(errno)"]
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func installWriteSource(for dir: Direction, from srcFd: Int32, to dstFd: Int32) {
|
||||
let ws = DispatchSource.makeWriteSource(fileDescriptor: dstFd, queue: queue)
|
||||
dir.writeSource = ws
|
||||
ws.setEventHandler { [self] in drainPending(dir: dir, from: srcFd, to: dstFd) }
|
||||
// No cancel handler: clearing pendingData from a cancel handler would race with
|
||||
// a newly installed write source if drainPending completes and the read source
|
||||
// immediately produces another blocked write, installing a fresh write source
|
||||
// before the old cancel handler fires. pendingData is cleared explicitly by
|
||||
// drainPending on success, and freed with Direction when the relay is torn down.
|
||||
ws.activate()
|
||||
}
|
||||
|
||||
private func drainPending(dir: Direction, from srcFd: Int32, to dstFd: Int32) {
|
||||
let remaining = dir.pendingData.count - dir.pendingOffset
|
||||
guard remaining > 0 else { return }
|
||||
|
||||
let n = dir.pendingData.withUnsafeBufferPointer { buf in
|
||||
write(dstFd, buf.baseAddress!.advanced(by: dir.pendingOffset), remaining)
|
||||
}
|
||||
|
||||
if n > 0 {
|
||||
dir.pendingOffset += n
|
||||
if dir.pendingOffset >= dir.pendingData.count {
|
||||
dir.writeSource?.cancel()
|
||||
dir.writeSource = nil
|
||||
dir.pendingData = []
|
||||
dir.pendingOffset = 0
|
||||
log?.debug(
|
||||
"backpressure relieved, resuming reads",
|
||||
metadata: ["sourceFd": "\(srcFd)", "destinationFd": "\(dstFd)"]
|
||||
)
|
||||
dir.resumeRead()
|
||||
}
|
||||
} else if n == -1 && errno == EAGAIN {
|
||||
// Spurious write-ready notification; wait for the next one.
|
||||
} else {
|
||||
log?.warning(
|
||||
"write error during pending drain",
|
||||
metadata: ["destinationFd": "\(dstFd)", "errno": "\(errno)"]
|
||||
)
|
||||
dir.writeSource?.cancel()
|
||||
dir.writeSource = nil
|
||||
dir.cancelRead()
|
||||
if shutdown(dstFd, Int32(SHUT_RDWR)) != 0 {
|
||||
log?.warning(
|
||||
"shutdown(SHUT_RDWR) failed after drain error",
|
||||
metadata: ["fd": "\(dstFd)", "errno": "\(errno)"]
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drains srcFd into dstFd in a loop until EAGAIN/EWOULDBLOCK or EOF.
|
||||
///
|
||||
/// Looping until EAGAIN is required on Linux: libdispatch uses FIONREAD to decide
|
||||
/// whether to fire the read event, so when the only remaining readable condition is
|
||||
/// EOF (FIONREAD == 0), the event is suppressed. Reading in a loop here ensures we
|
||||
/// observe read() == 0 on the same handler invocation that drained the last bytes.
|
||||
private static func copy(
|
||||
buffer: UnsafeMutableBufferPointer<UInt8>,
|
||||
from srcFd: Int32,
|
||||
to dstFd: Int32,
|
||||
direction: Direction
|
||||
) throws -> CopyResult {
|
||||
guard let base = buffer.baseAddress else {
|
||||
throw ContainerizationError(.invalidState, message: "buffer has no base address")
|
||||
}
|
||||
|
||||
readLoop: while true {
|
||||
let nr = read(srcFd, base, buffer.count)
|
||||
if nr == 0 { return .eof }
|
||||
if nr < 0 {
|
||||
if errno == EAGAIN || errno == EWOULDBLOCK { return .ok }
|
||||
if errno == EINTR { continue readLoop }
|
||||
throw ContainerizationError(
|
||||
.internalError,
|
||||
message: "read failed: fd \(srcFd), errno \(errno)"
|
||||
)
|
||||
}
|
||||
|
||||
var offset = 0
|
||||
while offset < nr {
|
||||
let nw = write(dstFd, base.advanced(by: offset), nr - offset)
|
||||
if nw > 0 {
|
||||
offset += nw
|
||||
} else if nw < 0 {
|
||||
if errno == EINTR { continue }
|
||||
if errno == EAGAIN || errno == EWOULDBLOCK {
|
||||
direction.pendingData = Array(
|
||||
UnsafeBufferPointer(start: base.advanced(by: offset), count: nr - offset)
|
||||
)
|
||||
direction.pendingOffset = 0
|
||||
return .blocked
|
||||
}
|
||||
throw ContainerizationError(
|
||||
.internalError,
|
||||
message: "write failed: fd \(dstFd), errno \(errno)"
|
||||
)
|
||||
} else {
|
||||
throw ContainerizationError(
|
||||
.internalError,
|
||||
message: "zero-byte write on fd \(dstFd)"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func closeBothFds() {
|
||||
log?.debug("closing fds", metadata: ["fd1": "\(fd1)", "fd2": "\(fd2)"])
|
||||
close(fd1)
|
||||
close(fd2)
|
||||
completionState.withLock { state in
|
||||
if case .waiting(let c) = state { c.resume() }
|
||||
state = .completed
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,494 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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
|
||||
import Foundation
|
||||
import Synchronization
|
||||
|
||||
#if canImport(Musl)
|
||||
import Musl
|
||||
#elseif canImport(Glibc)
|
||||
import Glibc
|
||||
#elseif canImport(Darwin)
|
||||
import Darwin
|
||||
#else
|
||||
#error("Socket not supported on this platform.")
|
||||
#endif
|
||||
|
||||
#if !os(Windows)
|
||||
let sysFchmod = fchmod
|
||||
let sysRead = read
|
||||
let sysUnlink = unlink
|
||||
let sysSend = send
|
||||
let sysClose = close
|
||||
let sysShutdown = shutdown
|
||||
let sysBind = bind
|
||||
let sysSocket = socket
|
||||
let sysSetsockopt = setsockopt
|
||||
let sysGetsockopt = getsockopt
|
||||
let sysListen = listen
|
||||
let sysAccept = accept
|
||||
let sysConnect = connect
|
||||
let sysIoctl: @convention(c) (CInt, CUnsignedLong, UnsafeMutableRawPointer) -> CInt = ioctl
|
||||
let sysRecvmsg = recvmsg
|
||||
#endif
|
||||
|
||||
/// Thread-safe socket wrapper.
|
||||
public final class Socket: Sendable {
|
||||
public enum TimeoutOption {
|
||||
case send
|
||||
case receive
|
||||
}
|
||||
|
||||
public enum ShutdownOption {
|
||||
case read
|
||||
case write
|
||||
case readWrite
|
||||
}
|
||||
|
||||
private enum SocketState {
|
||||
case created
|
||||
case connected
|
||||
case listening
|
||||
}
|
||||
|
||||
private struct State {
|
||||
let socketState: SocketState
|
||||
let handle: FileHandle?
|
||||
let type: SocketType
|
||||
let acceptSource: DispatchSourceRead?
|
||||
}
|
||||
|
||||
private let _closeOnDeinit: Bool
|
||||
private let _queue: DispatchQueue
|
||||
|
||||
private let state: Mutex<State>
|
||||
|
||||
public var fileDescriptor: Int32 {
|
||||
guard let handle = state.withLock({ $0.handle }) else {
|
||||
return -1
|
||||
}
|
||||
return handle.fileDescriptor
|
||||
}
|
||||
|
||||
public convenience init(type: SocketType, closeOnDeinit: Bool = true) throws {
|
||||
let sockFD = sysSocket(type.domain, type.type, 0)
|
||||
if sockFD < 0 {
|
||||
throw SocketError.withErrno("failed to create socket: \(sockFD)", errno: errno)
|
||||
}
|
||||
self.init(fd: sockFD, type: type, closeOnDeinit: closeOnDeinit)
|
||||
}
|
||||
|
||||
init(fd: Int32, type: SocketType, closeOnDeinit: Bool) {
|
||||
_queue = DispatchQueue(label: "com.apple.containerization.socket")
|
||||
_closeOnDeinit = closeOnDeinit
|
||||
let state = State(
|
||||
socketState: .created,
|
||||
handle: FileHandle(fileDescriptor: fd, closeOnDealloc: false),
|
||||
type: type,
|
||||
acceptSource: nil
|
||||
)
|
||||
self.state = Mutex(state)
|
||||
}
|
||||
|
||||
/// Internal initializer for wrapping already-connected file descriptors (e.g., from socketpair)
|
||||
/// Ideally we just get rid of the state machine in this class. Not sure how much value it provides..
|
||||
init(fd: Int32, type: SocketType, closeOnDeinit: Bool, connected: Bool) {
|
||||
_queue = DispatchQueue(label: "com.apple.containerization.socket")
|
||||
_closeOnDeinit = closeOnDeinit
|
||||
let state = State(
|
||||
socketState: connected ? .connected : .created,
|
||||
handle: FileHandle(fileDescriptor: fd, closeOnDealloc: false),
|
||||
type: type,
|
||||
acceptSource: nil
|
||||
)
|
||||
self.state = Mutex(state)
|
||||
}
|
||||
|
||||
deinit {
|
||||
if _closeOnDeinit {
|
||||
try? close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Socket {
|
||||
static func errnoToError(msg: String) -> SocketError {
|
||||
SocketError.withErrno("\(msg) (\(_errnoString(errno)))", errno: errno)
|
||||
}
|
||||
|
||||
public func connect() throws {
|
||||
try state.withLock { currentState in
|
||||
guard currentState.socketState == .created else {
|
||||
throw SocketError.invalidOperationOnSocket("connect")
|
||||
}
|
||||
guard let handle = currentState.handle else {
|
||||
throw SocketError.closed
|
||||
}
|
||||
|
||||
var res: Int32 = 0
|
||||
try currentState.type.withSockAddr { (ptr, length) in
|
||||
res = Syscall.retrying {
|
||||
sysConnect(handle.fileDescriptor, ptr, length)
|
||||
}
|
||||
}
|
||||
|
||||
if res == -1 {
|
||||
throw Socket.errnoToError(msg: "could not connect to socket \(currentState.type)")
|
||||
}
|
||||
|
||||
currentState = State(
|
||||
socketState: .connected,
|
||||
handle: handle,
|
||||
type: currentState.type,
|
||||
acceptSource: currentState.acceptSource
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public func listen() throws {
|
||||
try state.withLock { currentState in
|
||||
guard currentState.socketState == .created else {
|
||||
throw SocketError.invalidOperationOnSocket("listen")
|
||||
}
|
||||
guard let handle = currentState.handle else {
|
||||
throw SocketError.closed
|
||||
}
|
||||
|
||||
try currentState.type.beforeBind(fd: handle.fileDescriptor)
|
||||
|
||||
var rc: Int32 = 0
|
||||
try currentState.type.withSockAddr { (ptr, length) in
|
||||
rc = sysBind(handle.fileDescriptor, ptr, length)
|
||||
}
|
||||
|
||||
if rc < 0 {
|
||||
throw Socket.errnoToError(msg: "could not bind to \(currentState.type)")
|
||||
}
|
||||
|
||||
try currentState.type.beforeListen(fd: handle.fileDescriptor)
|
||||
|
||||
if sysListen(handle.fileDescriptor, SOMAXCONN) < 0 {
|
||||
throw Socket.errnoToError(msg: "listen failed on \(currentState.type)")
|
||||
}
|
||||
|
||||
currentState = State(
|
||||
socketState: .listening,
|
||||
handle: handle,
|
||||
type: currentState.type,
|
||||
acceptSource: currentState.acceptSource
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public func close() throws {
|
||||
try state.withLock { currentState in
|
||||
guard let handle = currentState.handle else {
|
||||
// Already closed.
|
||||
return
|
||||
}
|
||||
|
||||
let acceptSource = currentState.acceptSource
|
||||
|
||||
acceptSource?.cancel()
|
||||
try handle.close()
|
||||
|
||||
currentState = State(
|
||||
socketState: currentState.socketState,
|
||||
handle: nil,
|
||||
type: currentState.type,
|
||||
acceptSource: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public func write(data: any DataProtocol) throws -> Int {
|
||||
let handle = try state.withLock { currentState in
|
||||
guard currentState.socketState == .connected else {
|
||||
throw SocketError.invalidOperationOnSocket("write")
|
||||
}
|
||||
guard let handle = currentState.handle else {
|
||||
throw SocketError.closed
|
||||
}
|
||||
return handle
|
||||
}
|
||||
|
||||
if data.isEmpty {
|
||||
return 0
|
||||
}
|
||||
|
||||
try handle.write(contentsOf: data)
|
||||
return data.count
|
||||
}
|
||||
|
||||
public func acceptStream(closeOnDeinit: Bool = true) throws -> AsyncThrowingStream<Socket, Swift.Error> {
|
||||
let source = try state.withLock { currentState -> DispatchSourceRead in
|
||||
guard currentState.socketState == .listening else {
|
||||
throw SocketError.invalidOperationOnSocket("accept")
|
||||
}
|
||||
guard let handle = currentState.handle else {
|
||||
throw SocketError.closed
|
||||
}
|
||||
guard currentState.acceptSource == nil else {
|
||||
throw SocketError.acceptStreamExists
|
||||
}
|
||||
|
||||
let source = DispatchSource.makeReadSource(
|
||||
fileDescriptor: handle.fileDescriptor,
|
||||
queue: _queue
|
||||
)
|
||||
|
||||
currentState = State(
|
||||
socketState: currentState.socketState,
|
||||
handle: handle,
|
||||
type: currentState.type,
|
||||
acceptSource: source
|
||||
)
|
||||
|
||||
return source
|
||||
}
|
||||
|
||||
return AsyncThrowingStream { cont in
|
||||
source.setCancelHandler {
|
||||
cont.finish()
|
||||
}
|
||||
source.setEventHandler(handler: {
|
||||
if source.data == 0 {
|
||||
source.cancel()
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let connection = try self.accept(closeOnDeinit: closeOnDeinit)
|
||||
cont.yield(connection)
|
||||
} catch SocketError.closed {
|
||||
source.cancel()
|
||||
} catch {
|
||||
cont.yield(with: .failure(error))
|
||||
source.cancel()
|
||||
}
|
||||
})
|
||||
source.activate()
|
||||
}
|
||||
}
|
||||
|
||||
public func accept(closeOnDeinit: Bool = true) throws -> Socket {
|
||||
let (handle, socketType) = try state.withLock { currentState in
|
||||
guard currentState.socketState == .listening else {
|
||||
throw SocketError.invalidOperationOnSocket("accept")
|
||||
}
|
||||
guard let handle = currentState.handle else {
|
||||
throw SocketError.closed
|
||||
}
|
||||
return (handle, currentState.type)
|
||||
}
|
||||
|
||||
let (clientFD, newSocketType) = try socketType.accept(fd: handle.fileDescriptor)
|
||||
return Socket(
|
||||
fd: clientFD,
|
||||
type: newSocketType,
|
||||
closeOnDeinit: closeOnDeinit,
|
||||
connected: true
|
||||
)
|
||||
}
|
||||
|
||||
/// Receive a file descriptor via SCM_RIGHTS control message.
|
||||
/// This is commonly used for passing file descriptors between processes via Unix domain sockets.
|
||||
public func receiveFileDescriptor() throws -> Int32 {
|
||||
let handle = try state.withLock { currentState in
|
||||
guard currentState.socketState == .connected else {
|
||||
throw SocketError.invalidOperationOnSocket("receiveFileDescriptor")
|
||||
}
|
||||
guard let handle = currentState.handle else {
|
||||
throw SocketError.closed
|
||||
}
|
||||
return handle
|
||||
}
|
||||
|
||||
var msg = msghdr()
|
||||
var iov = iovec()
|
||||
var buf: UInt8 = 0
|
||||
|
||||
iov.iov_base = withUnsafeMutablePointer(to: &buf) { UnsafeMutableRawPointer($0) }
|
||||
iov.iov_len = 1
|
||||
|
||||
msg.msg_iov = withUnsafeMutablePointer(to: &iov) { $0 }
|
||||
msg.msg_iovlen = 1
|
||||
|
||||
var cmsgBuf = [UInt8](repeating: 0, count: Int(CZ_CMSG_SPACE(Int(MemoryLayout<Int32>.size))))
|
||||
msg.msg_control = withUnsafeMutablePointer(to: &cmsgBuf[0]) { UnsafeMutableRawPointer($0) }
|
||||
msg.msg_controllen = numericCast(cmsgBuf.count)
|
||||
|
||||
let recvResult = withUnsafeMutablePointer(to: &msg) { msgPtr in
|
||||
sysRecvmsg(handle.fileDescriptor, msgPtr, 0)
|
||||
}
|
||||
|
||||
guard recvResult >= 0 else {
|
||||
throw Socket.errnoToError(msg: "recvmsg failed")
|
||||
}
|
||||
|
||||
// Extract file descriptor from control message
|
||||
let cmsgPtr = withUnsafeMutablePointer(to: &msg) { CZ_CMSG_FIRSTHDR($0) }
|
||||
guard let cmsg = cmsgPtr else {
|
||||
throw SocketError.invalidFileDescriptor
|
||||
}
|
||||
|
||||
guard cmsg.pointee.cmsg_level == SOL_SOCKET,
|
||||
cmsg.pointee.cmsg_type == SCM_RIGHTS
|
||||
else {
|
||||
throw SocketError.invalidFileDescriptor
|
||||
}
|
||||
|
||||
guard let dataPtr = CZ_CMSG_DATA(cmsg) else {
|
||||
throw SocketError.invalidFileDescriptor
|
||||
}
|
||||
|
||||
let fdPtr = dataPtr.assumingMemoryBound(to: Int32.self)
|
||||
let fd = fdPtr.pointee
|
||||
guard fd >= 0 else {
|
||||
throw SocketError.invalidFileDescriptor
|
||||
}
|
||||
|
||||
return fd
|
||||
}
|
||||
|
||||
public func read(buffer: inout Data) throws -> Int {
|
||||
let handle = try state.withLock { currentState in
|
||||
guard currentState.socketState == .connected else {
|
||||
throw SocketError.invalidOperationOnSocket("read")
|
||||
}
|
||||
guard let handle = currentState.handle else {
|
||||
throw SocketError.closed
|
||||
}
|
||||
return handle
|
||||
}
|
||||
|
||||
var bytesRead = 0
|
||||
let bufferSize = buffer.count
|
||||
try buffer.withUnsafeMutableBytes { pointer in
|
||||
guard let baseAddress = pointer.baseAddress else {
|
||||
throw SocketError.missingBaseAddress
|
||||
}
|
||||
|
||||
bytesRead = Syscall.retrying {
|
||||
sysRead(handle.fileDescriptor, baseAddress, bufferSize)
|
||||
}
|
||||
if bytesRead < 0 {
|
||||
throw Socket.errnoToError(msg: "error reading from connection")
|
||||
} else if bytesRead == 0 {
|
||||
throw SocketError.closed
|
||||
}
|
||||
}
|
||||
return bytesRead
|
||||
}
|
||||
|
||||
public func shutdown(how: ShutdownOption) throws {
|
||||
let handle = try state.withLock { currentState in
|
||||
guard let handle = currentState.handle else {
|
||||
throw SocketError.closed
|
||||
}
|
||||
return handle
|
||||
}
|
||||
|
||||
var howOpt: Int32 = 0
|
||||
switch how {
|
||||
case .read:
|
||||
howOpt = Int32(SHUT_RD)
|
||||
case .write:
|
||||
howOpt = Int32(SHUT_WR)
|
||||
case .readWrite:
|
||||
howOpt = Int32(SHUT_RDWR)
|
||||
}
|
||||
|
||||
if sysShutdown(handle.fileDescriptor, howOpt) < 0 {
|
||||
throw Socket.errnoToError(msg: "shutdown failed")
|
||||
}
|
||||
}
|
||||
|
||||
public func setSockOpt(sockOpt: Int32 = 0, ptr: UnsafeRawPointer, stride: UInt32) throws {
|
||||
let handle = try state.withLock { currentState in
|
||||
guard let handle = currentState.handle else {
|
||||
throw SocketError.closed
|
||||
}
|
||||
return handle
|
||||
}
|
||||
|
||||
if setsockopt(handle.fileDescriptor, SOL_SOCKET, sockOpt, ptr, stride) < 0 {
|
||||
throw Socket.errnoToError(msg: "failed to set sockopt")
|
||||
}
|
||||
}
|
||||
|
||||
public func setTimeout(option: TimeoutOption, seconds: Int) throws {
|
||||
let handle = try state.withLock { currentState in
|
||||
guard let handle = currentState.handle else {
|
||||
throw SocketError.closed
|
||||
}
|
||||
return handle
|
||||
}
|
||||
|
||||
var sockOpt: Int32 = 0
|
||||
switch option {
|
||||
case .receive:
|
||||
sockOpt = SO_RCVTIMEO
|
||||
case .send:
|
||||
sockOpt = SO_SNDTIMEO
|
||||
}
|
||||
|
||||
var timer = timeval()
|
||||
timer.tv_sec = seconds
|
||||
timer.tv_usec = 0
|
||||
|
||||
if setsockopt(
|
||||
handle.fileDescriptor,
|
||||
SOL_SOCKET,
|
||||
sockOpt,
|
||||
&timer,
|
||||
socklen_t(MemoryLayout<timeval>.size)
|
||||
) < 0 {
|
||||
throw Socket.errnoToError(msg: "failed to set read timeout")
|
||||
}
|
||||
}
|
||||
|
||||
static func _errnoString(_ err: Int32?) -> String {
|
||||
String(validatingCString: strerror(errno)) ?? "error: \(errno)"
|
||||
}
|
||||
}
|
||||
|
||||
public enum SocketError: Error, Equatable, CustomStringConvertible {
|
||||
case closed
|
||||
case acceptStreamExists
|
||||
case invalidOperationOnSocket(String)
|
||||
case missingBaseAddress
|
||||
case withErrno(_ msg: String, errno: Int32)
|
||||
case invalidFileDescriptor
|
||||
|
||||
public var description: String {
|
||||
switch self {
|
||||
case .closed:
|
||||
return "socket: closed"
|
||||
case .acceptStreamExists:
|
||||
return "accept stream already exists"
|
||||
case .invalidOperationOnSocket(let operation):
|
||||
return "socket: invalid operation on socket '\(operation)'"
|
||||
case .missingBaseAddress:
|
||||
return "socket: missing base address"
|
||||
case .withErrno(let msg, _):
|
||||
return "socket: error \(msg)"
|
||||
case .invalidFileDescriptor:
|
||||
return "socket: invalid file descriptor received"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
#if canImport(Musl)
|
||||
import Musl
|
||||
#elseif canImport(Glibc)
|
||||
import Glibc
|
||||
#elseif canImport(Darwin)
|
||||
import Darwin
|
||||
#else
|
||||
#error("SocketType not supported on this platform.")
|
||||
#endif
|
||||
|
||||
/// Protocol used to describe the family of socket to be created with `Socket`.
|
||||
public protocol SocketType: Sendable, CustomStringConvertible {
|
||||
/// The domain for the socket (AF_UNIX, AF_VSOCK etc.)
|
||||
var domain: Int32 { get }
|
||||
/// The type of socket (SOCK_STREAM).
|
||||
var type: Int32 { get }
|
||||
|
||||
/// Actions to perform before calling bind(2).
|
||||
func beforeBind(fd: Int32) throws
|
||||
/// Actions to perform before calling listen(2).
|
||||
func beforeListen(fd: Int32) throws
|
||||
|
||||
/// Handle accept(2) for an implementation of a socket type.
|
||||
func accept(fd: Int32) throws -> (Int32, SocketType)
|
||||
/// Provide a sockaddr pointer (by casting a socket specific type like sockaddr_un for example).
|
||||
func withSockAddr(_ closure: (_ ptr: UnsafePointer<sockaddr>, _ len: UInt32) throws -> Void) throws
|
||||
}
|
||||
|
||||
extension SocketType {
|
||||
public func beforeBind(fd: Int32) {}
|
||||
public func beforeListen(fd: Int32) {}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
#if canImport(Musl)
|
||||
import Musl
|
||||
let _SOCK_STREAM = SOCK_STREAM
|
||||
#elseif canImport(Glibc)
|
||||
import Glibc
|
||||
let _SOCK_STREAM = Int32(SOCK_STREAM.rawValue)
|
||||
#elseif canImport(Darwin)
|
||||
import Darwin
|
||||
let _SOCK_STREAM = SOCK_STREAM
|
||||
#else
|
||||
#error("UnixType not supported on this platform.")
|
||||
#endif
|
||||
|
||||
/// Unix domain socket variant of `SocketType`.
|
||||
public struct UnixType: SocketType, Sendable, CustomStringConvertible {
|
||||
public var domain: Int32 { AF_UNIX }
|
||||
public var type: Int32 { _SOCK_STREAM }
|
||||
public var description: String {
|
||||
path
|
||||
}
|
||||
|
||||
public let path: String
|
||||
public let perms: mode_t?
|
||||
private let _addr: sockaddr_un
|
||||
private let _unlinkExisting: Bool
|
||||
|
||||
private init(sockaddr: sockaddr_un) {
|
||||
let pathname: String = withUnsafePointer(to: sockaddr.sun_path) { ptr in
|
||||
let charPtr = UnsafeRawPointer(ptr).assumingMemoryBound(to: CChar.self)
|
||||
return String(cString: charPtr)
|
||||
}
|
||||
self._addr = sockaddr
|
||||
self.path = pathname
|
||||
self._unlinkExisting = false
|
||||
self.perms = nil
|
||||
}
|
||||
|
||||
/// Mode and unlinkExisting only used if the socket is going to be a listening socket.
|
||||
public init(
|
||||
path: String,
|
||||
perms: mode_t? = nil,
|
||||
unlinkExisting: Bool = false
|
||||
) throws {
|
||||
self.path = path
|
||||
self.perms = perms
|
||||
self._unlinkExisting = unlinkExisting
|
||||
var addr = sockaddr_un()
|
||||
addr.sun_family = sa_family_t(AF_UNIX)
|
||||
|
||||
let socketName = path
|
||||
let nameLength = socketName.utf8.count
|
||||
|
||||
#if os(macOS)
|
||||
// Funnily enough, this isn't limited by sun path on macOS even though
|
||||
// it's stated as so.
|
||||
let lengthLimit = 253
|
||||
#elseif os(Linux)
|
||||
let lengthLimit = MemoryLayout.size(ofValue: addr.sun_path)
|
||||
#endif
|
||||
|
||||
guard nameLength < lengthLimit else {
|
||||
throw Error.nameTooLong(path)
|
||||
}
|
||||
|
||||
_ = withUnsafeMutablePointer(to: &addr.sun_path.0) { ptr in
|
||||
socketName.withCString { strncpy(ptr, $0, nameLength) }
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
addr.sun_len = UInt8(MemoryLayout<UInt8>.size + MemoryLayout<sa_family_t>.size + socketName.utf8.count + 1)
|
||||
#endif
|
||||
self._addr = addr
|
||||
}
|
||||
|
||||
public func accept(fd: Int32) throws -> (Int32, SocketType) {
|
||||
var clientFD: Int32 = -1
|
||||
var addr = sockaddr_un()
|
||||
|
||||
clientFD = Syscall.retrying {
|
||||
var size = socklen_t(MemoryLayout<sockaddr_un>.stride)
|
||||
return withUnsafeMutablePointer(to: &addr) { pointer in
|
||||
pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { pointer in
|
||||
sysAccept(fd, pointer, &size)
|
||||
}
|
||||
}
|
||||
}
|
||||
if clientFD < 0 {
|
||||
throw Socket.errnoToError(msg: "accept failed")
|
||||
}
|
||||
|
||||
return (clientFD, UnixType(sockaddr: addr))
|
||||
}
|
||||
|
||||
public func beforeBind(fd: Int32) throws {
|
||||
#if os(Linux)
|
||||
// Only Linux supports setting the mode of a socket before binding.
|
||||
if let perms = self.perms {
|
||||
guard fchmod(fd, perms) == 0 else {
|
||||
throw Socket.errnoToError(msg: "fchmod failed")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
var rc: Int32 = 0
|
||||
if self._unlinkExisting {
|
||||
rc = sysUnlink(self.path)
|
||||
if rc != 0 && errno != ENOENT {
|
||||
throw Socket.errnoToError(msg: "failed to remove old socket at \(self.path)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func beforeListen(fd: Int32) throws {
|
||||
#if os(macOS)
|
||||
if let perms = self.perms {
|
||||
guard chmod(self.path, perms) == 0 else {
|
||||
throw Socket.errnoToError(msg: "chmod failed")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
public func withSockAddr(_ closure: (UnsafePointer<sockaddr>, UInt32) throws -> Void) throws {
|
||||
var addr = self._addr
|
||||
try withUnsafePointer(to: &addr) {
|
||||
let addrBytes = UnsafeRawPointer($0).assumingMemoryBound(to: sockaddr.self)
|
||||
try closure(addrBytes, UInt32(MemoryLayout<sockaddr_un>.stride))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension UnixType {
|
||||
/// `UnixType` errors.
|
||||
public enum Error: Swift.Error, CustomStringConvertible {
|
||||
case nameTooLong(_: String)
|
||||
|
||||
public var description: String {
|
||||
switch self {
|
||||
case .nameTooLong(let name):
|
||||
return "\(name) is too long for a Unix Domain Socket path"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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(Musl)
|
||||
import Musl
|
||||
#elseif canImport(Glibc)
|
||||
import Glibc
|
||||
#elseif canImport(Darwin)
|
||||
import Darwin
|
||||
#else
|
||||
#error("VsockType not supported on this platform.")
|
||||
#endif
|
||||
|
||||
/// Vsock variant of `SocketType`.
|
||||
public struct VsockType: SocketType, Sendable {
|
||||
public var domain: Int32 { AF_VSOCK }
|
||||
public var type: Int32 { _SOCK_STREAM }
|
||||
public var description: String {
|
||||
"\(cid):\(port)"
|
||||
}
|
||||
|
||||
public static let anyCID: UInt32 = UInt32(bitPattern: -1)
|
||||
public static let hypervisorCID: UInt32 = 0x0
|
||||
// Supported on Linux 5.6+; otherwise, will need to use getLocalCID().
|
||||
public static let localCID: UInt32 = 0x1
|
||||
public static let hostCID: UInt32 = 0x2
|
||||
|
||||
// socketFD is unused on Linux.
|
||||
public static func getLocalCID(socketFD: Int32) throws -> UInt32 {
|
||||
let request = VsockLocalCIDIoctl
|
||||
#if os(Linux)
|
||||
let fd = open("/dev/vsock", O_RDONLY | O_CLOEXEC)
|
||||
if fd == -1 {
|
||||
throw Socket.errnoToError(msg: "failed to open /dev/vsock")
|
||||
}
|
||||
defer { close(fd) }
|
||||
#else
|
||||
let fd = socketFD
|
||||
#endif
|
||||
var cid: UInt32 = 0
|
||||
guard sysIoctl(fd, numericCast(request), &cid) != -1 else {
|
||||
throw Socket.errnoToError(msg: "failed to get local cid")
|
||||
}
|
||||
return cid
|
||||
}
|
||||
|
||||
public let port: UInt32
|
||||
public let cid: UInt32
|
||||
|
||||
private let _addr: sockaddr_vm
|
||||
|
||||
public init(port: UInt32, cid: UInt32) {
|
||||
self.cid = cid
|
||||
self.port = port
|
||||
var sockaddr = sockaddr_vm()
|
||||
sockaddr.svm_family = sa_family_t(AF_VSOCK)
|
||||
sockaddr.svm_cid = cid
|
||||
sockaddr.svm_port = port
|
||||
self._addr = sockaddr
|
||||
}
|
||||
|
||||
private init(sockaddr: sockaddr_vm) {
|
||||
self._addr = sockaddr
|
||||
self.cid = sockaddr.svm_cid
|
||||
self.port = sockaddr.svm_port
|
||||
}
|
||||
|
||||
public func accept(fd: Int32) throws -> (Int32, SocketType) {
|
||||
var clientFD: Int32 = -1
|
||||
var addr = sockaddr_vm()
|
||||
|
||||
while clientFD < 0 {
|
||||
var size = socklen_t(MemoryLayout<sockaddr_vm>.stride)
|
||||
clientFD = withUnsafeMutablePointer(to: &addr) { pointer in
|
||||
pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { pointer in
|
||||
sysAccept(fd, pointer, &size)
|
||||
}
|
||||
}
|
||||
if clientFD < 0 && errno != EINTR {
|
||||
throw Socket.errnoToError(msg: "accept failed")
|
||||
}
|
||||
}
|
||||
return (clientFD, VsockType(sockaddr: addr))
|
||||
}
|
||||
|
||||
public func withSockAddr(_ closure: (UnsafePointer<sockaddr>, UInt32) throws -> Void) throws {
|
||||
var addr = self._addr
|
||||
try withUnsafePointer(to: &addr) {
|
||||
let addrBytes = UnsafeRawPointer($0).assumingMemoryBound(to: sockaddr.self)
|
||||
try closure(addrBytes, UInt32(MemoryLayout<sockaddr_vm>.stride))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
/// A timestamp with second and nanosecond precision.
|
||||
public struct TimeSpec: Sendable, Hashable {
|
||||
/// Seconds since the Unix epoch.
|
||||
public var seconds: Int64
|
||||
/// Nanoseconds past the second.
|
||||
public var nanoseconds: Int32
|
||||
|
||||
public init(seconds: Int64, nanoseconds: Int32) {
|
||||
self.seconds = seconds
|
||||
self.nanoseconds = nanoseconds
|
||||
}
|
||||
}
|
||||
|
||||
/// File metadata returned by a `stat` call.
|
||||
public struct Stat: Sendable, Hashable {
|
||||
/// ID of device containing file (`st_dev`).
|
||||
public var dev: UInt64
|
||||
/// Inode number (`st_ino`).
|
||||
public var ino: UInt64
|
||||
/// File type and mode (`st_mode`).
|
||||
public var mode: UInt32
|
||||
/// Number of hard links (`st_nlink`).
|
||||
public var nlink: UInt64
|
||||
/// User ID of owner (`st_uid`).
|
||||
public var uid: UInt32
|
||||
/// Group ID of owner (`st_gid`).
|
||||
public var gid: UInt32
|
||||
/// Device ID, if special file (`st_rdev`).
|
||||
public var rdev: UInt64
|
||||
/// Total size in bytes (`st_size`).
|
||||
public var size: Int64
|
||||
/// Preferred I/O block size (`st_blksize`).
|
||||
public var blksize: Int64
|
||||
/// Number of 512-byte blocks allocated (`st_blocks`).
|
||||
public var blocks: Int64
|
||||
/// Time of last access (`st_atim`).
|
||||
public var atime: TimeSpec
|
||||
/// Time of last modification (`st_mtim`).
|
||||
public var mtime: TimeSpec
|
||||
/// Time of last status change (`st_ctim`).
|
||||
public var ctime: TimeSpec
|
||||
|
||||
public init(
|
||||
dev: UInt64,
|
||||
ino: UInt64,
|
||||
mode: UInt32,
|
||||
nlink: UInt64,
|
||||
uid: UInt32,
|
||||
gid: UInt32,
|
||||
rdev: UInt64,
|
||||
size: Int64,
|
||||
blksize: Int64,
|
||||
blocks: Int64,
|
||||
atime: TimeSpec,
|
||||
mtime: TimeSpec,
|
||||
ctime: TimeSpec
|
||||
) {
|
||||
self.dev = dev
|
||||
self.ino = ino
|
||||
self.mode = mode
|
||||
self.nlink = nlink
|
||||
self.uid = uid
|
||||
self.gid = gid
|
||||
self.rdev = rdev
|
||||
self.size = size
|
||||
self.blksize = blksize
|
||||
self.blocks = blocks
|
||||
self.atime = atime
|
||||
self.mtime = mtime
|
||||
self.ctime = ctime
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
#if canImport(Musl)
|
||||
import Musl
|
||||
#elseif canImport(Glibc)
|
||||
import Glibc
|
||||
#elseif canImport(Darwin)
|
||||
import Darwin
|
||||
#else
|
||||
#error("retryingSyscall not supported on this platform.")
|
||||
#endif
|
||||
|
||||
/// Helper type to deal with running system calls.
|
||||
public struct Syscall {
|
||||
/// Retry a syscall on EINTR.
|
||||
public static func retrying<T: FixedWidthInteger>(_ closure: () -> T) -> T {
|
||||
while true {
|
||||
let res = closure()
|
||||
if res == -1 && errno == EINTR {
|
||||
continue
|
||||
}
|
||||
return res
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
#if canImport(Darwin)
|
||||
import Darwin
|
||||
#endif
|
||||
#if canImport(FoundationEssentials)
|
||||
import FoundationEssentials
|
||||
#else
|
||||
import Foundation
|
||||
#endif
|
||||
|
||||
/// Helper type to deal with system control functionalities.
|
||||
public struct Sysctl {
|
||||
#if os(macOS)
|
||||
/// Simple `sysctlbyname` wrapper.
|
||||
public static func byName(_ name: String) throws -> Int64 {
|
||||
var num: Int64 = 0
|
||||
var size = MemoryLayout<Int64>.size
|
||||
if sysctlbyname(name, &num, &size, nil, 0) != 0 {
|
||||
throw POSIXError.fromErrno()
|
||||
}
|
||||
return num
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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 Foundation
|
||||
|
||||
/// `Terminal` provides a clean interface to deal with terminal interactions on Unix platforms.
|
||||
public struct Terminal: Sendable {
|
||||
private let initState: termios?
|
||||
|
||||
private var descriptor: Int32 {
|
||||
handle.fileDescriptor
|
||||
}
|
||||
public let handle: FileHandle
|
||||
|
||||
public init(descriptor: Int32, setInitState: Bool = true) throws {
|
||||
if setInitState {
|
||||
self.initState = try Self.getattr(descriptor)
|
||||
} else {
|
||||
initState = nil
|
||||
}
|
||||
self.handle = .init(fileDescriptor: descriptor, closeOnDealloc: false)
|
||||
}
|
||||
|
||||
/// Write the provided data to the tty device.
|
||||
public func write(_ data: Data) throws {
|
||||
try handle.write(contentsOf: data)
|
||||
}
|
||||
|
||||
/// The winsize for a pty.
|
||||
public struct Size: Sendable {
|
||||
let size: winsize
|
||||
|
||||
/// The width or `col` of the pty.
|
||||
public var width: UInt16 {
|
||||
size.ws_col
|
||||
}
|
||||
/// The height or `rows` of the pty.
|
||||
public var height: UInt16 {
|
||||
size.ws_row
|
||||
}
|
||||
|
||||
init(_ size: winsize) {
|
||||
self.size = size
|
||||
}
|
||||
|
||||
/// Set the size for use with a pty.
|
||||
public init(width cols: UInt16, height rows: UInt16) {
|
||||
self.size = winsize(ws_row: rows, ws_col: cols, ws_xpixel: 0, ws_ypixel: 0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the current pty attached to any of the STDIO descriptors.
|
||||
public static var current: Terminal {
|
||||
get throws {
|
||||
for i in [STDERR_FILENO, STDOUT_FILENO, STDIN_FILENO] {
|
||||
do {
|
||||
return try Terminal(descriptor: i)
|
||||
} catch {}
|
||||
}
|
||||
throw Error.notAPty
|
||||
}
|
||||
}
|
||||
|
||||
/// The current window size for the pty.
|
||||
public var size: Size {
|
||||
get throws {
|
||||
var ws = winsize()
|
||||
try fromSyscall(ioctl(descriptor, UInt(TIOCGWINSZ), &ws))
|
||||
return Size(ws)
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new pty pair.
|
||||
/// - Parameter initialSize: An initial size of the child pty.
|
||||
public static func create(initialSize: Size? = nil) throws -> (parent: Terminal, child: Terminal) {
|
||||
var parent: Int32 = 0
|
||||
var child: Int32 = 0
|
||||
let size = initialSize ?? Size(width: 120, height: 40)
|
||||
var ws = size.size
|
||||
|
||||
try fromSyscall(openpty(&parent, &child, nil, nil, &ws))
|
||||
return (
|
||||
parent: try Terminal(descriptor: parent, setInitState: false),
|
||||
child: try Terminal(descriptor: child, setInitState: false)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Errors
|
||||
|
||||
extension Terminal {
|
||||
public enum Error: Swift.Error, CustomStringConvertible {
|
||||
case notAPty
|
||||
|
||||
public var description: String {
|
||||
switch self {
|
||||
case .notAPty:
|
||||
return "the provided fd is not a pty"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Terminal {
|
||||
/// Resize the current pty from the size of the provided pty.
|
||||
/// - Parameter pty: A pty to resize from.
|
||||
public func resize(from pty: Terminal) throws {
|
||||
var ws = try pty.size
|
||||
try fromSyscall(ioctl(descriptor, UInt(TIOCSWINSZ), &ws))
|
||||
}
|
||||
|
||||
/// Resize the pty to the provided window size.
|
||||
/// - Parameter size: A window size for a pty.
|
||||
public func resize(size: Size) throws {
|
||||
var ws = size.size
|
||||
try fromSyscall(ioctl(descriptor, UInt(TIOCSWINSZ), &ws))
|
||||
}
|
||||
|
||||
/// Resize the pty to the provided window size.
|
||||
/// - Parameter width: A width or cols of the terminal.
|
||||
/// - Parameter height: A height or rows of the terminal.
|
||||
public func resize(width: UInt16, height: UInt16) throws {
|
||||
var ws = Size(width: width, height: height)
|
||||
try fromSyscall(ioctl(descriptor, UInt(TIOCSWINSZ), &ws))
|
||||
}
|
||||
}
|
||||
|
||||
extension Terminal {
|
||||
/// Enable raw mode for the pty.
|
||||
public func setraw() throws {
|
||||
var attr = try Self.getattr(descriptor)
|
||||
cfmakeraw(&attr)
|
||||
attr.c_oflag = attr.c_oflag | tcflag_t(OPOST)
|
||||
try fromSyscall(tcsetattr(descriptor, TCSANOW, &attr))
|
||||
}
|
||||
|
||||
/// Enable echo support.
|
||||
/// Chars typed will be displayed to the terminal.
|
||||
public func enableEcho() throws {
|
||||
var attr = try Self.getattr(descriptor)
|
||||
attr.c_iflag &= ~tcflag_t(ICRNL)
|
||||
attr.c_lflag &= ~tcflag_t(ICANON | ECHO)
|
||||
try fromSyscall(tcsetattr(descriptor, TCSANOW, &attr))
|
||||
}
|
||||
|
||||
/// Disable echo support.
|
||||
/// Chars typed will not be displayed back to the terminal.
|
||||
public func disableEcho() throws {
|
||||
var attr = try Self.getattr(descriptor)
|
||||
attr.c_lflag &= ~tcflag_t(ECHO)
|
||||
try fromSyscall(tcsetattr(descriptor, TCSANOW, &attr))
|
||||
}
|
||||
|
||||
private static func getattr(_ fd: Int32) throws -> termios {
|
||||
var attr = termios()
|
||||
try fromSyscall(tcgetattr(fd, &attr))
|
||||
return attr
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Reset
|
||||
|
||||
extension Terminal {
|
||||
/// Close this pty's file descriptor.
|
||||
public func close() throws {
|
||||
do {
|
||||
// Use FileHandle's close directly as it sets the underlying fd in the object
|
||||
// to -1 for us.
|
||||
try self.handle.close()
|
||||
} catch {
|
||||
if let error = error as NSError?, error.domain == NSPOSIXErrorDomain {
|
||||
throw POSIXError(.init(rawValue: Int32(error.code))!)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset the pty to its initial state.
|
||||
public func reset() throws {
|
||||
if var attr = initState {
|
||||
try fromSyscall(tcsetattr(descriptor, TCSANOW, &attr))
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset the pty to its initial state masking any errors.
|
||||
/// This is commonly used in a `defer` body to reset the current pty where the error code is not generally useful.
|
||||
public func tryReset() {
|
||||
try? reset()
|
||||
}
|
||||
}
|
||||
|
||||
private func fromSyscall(_ status: Int32) throws {
|
||||
guard status == 0 else {
|
||||
throw POSIXError(.init(rawValue: errno)!)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
#if canImport(FoundationEssentials)
|
||||
import FoundationEssentials
|
||||
#else
|
||||
import Foundation
|
||||
#endif
|
||||
|
||||
#if canImport(Musl)
|
||||
import Musl
|
||||
#elseif canImport(Glibc)
|
||||
import Glibc
|
||||
#elseif canImport(Darwin)
|
||||
import Darwin
|
||||
#endif
|
||||
|
||||
/// The `resolvingSymlinksInPath` method of the `URL` struct does not resolve the symlinks
|
||||
/// for directories under `/private` which include`tmp`, `var` and `etc`
|
||||
/// hence adding a method to build up on the existing `resolvingSymlinksInPath` that prepends `/private` to those paths
|
||||
extension URL {
|
||||
/// returns the unescaped absolutePath of a URL joined by separator
|
||||
func absolutePath(_ separator: String = "/") -> String {
|
||||
self.pathComponents
|
||||
.joined(separator: separator)
|
||||
.dropFirst("/".count)
|
||||
.description
|
||||
}
|
||||
|
||||
public func resolvingSymlinksInPathWithPrivate() -> URL {
|
||||
let url = self.resolvingSymlinksInPath()
|
||||
#if os(macOS)
|
||||
let parts = url.pathComponents
|
||||
if parts.count > 1 {
|
||||
if (parts.first == "/") && ["tmp", "var", "etc"].contains(parts[1]) {
|
||||
var resolved = URL(filePath: "/private")
|
||||
for part in parts[1...] {
|
||||
resolved.append(path: part)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
}
|
||||
#endif
|
||||
return url
|
||||
}
|
||||
|
||||
public var isDirectory: Bool {
|
||||
var st = stat()
|
||||
guard stat(self.path, &st) == 0 else { return false }
|
||||
return (st.st_mode & S_IFMT) == S_IFDIR
|
||||
}
|
||||
|
||||
public var isSymlink: Bool {
|
||||
var st = stat()
|
||||
guard lstat(self.path, &st) == 0 else { return false }
|
||||
return (st.st_mode & S_IFMT) == S_IFLNK
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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 ContainerizationError
|
||||
|
||||
#if canImport(FoundationEssentials)
|
||||
import FoundationEssentials
|
||||
#else
|
||||
import Foundation
|
||||
#endif
|
||||
|
||||
/// `User` provides utilities to ensure that a given username exists in
|
||||
/// /etc/passwd (and /etc/group). Largely inspired by runc (and moby's)
|
||||
/// `user` packages.
|
||||
public enum User {
|
||||
public static let passwdFilePath = URL(filePath: "/etc/passwd")
|
||||
public static let groupFilePath = URL(filePath: "/etc/group")
|
||||
|
||||
private static let minID: UInt32 = 0
|
||||
private static let maxID: UInt32 = 2_147_483_647
|
||||
|
||||
public struct ExecUser: Sendable {
|
||||
public var uid: UInt32
|
||||
public var gid: UInt32
|
||||
public var sgids: [UInt32]
|
||||
public var home: String
|
||||
public var shell: String
|
||||
|
||||
public init(uid: UInt32, gid: UInt32, sgids: [UInt32], home: String, shell: String) {
|
||||
self.uid = uid
|
||||
self.gid = gid
|
||||
self.sgids = sgids
|
||||
self.home = home
|
||||
self.shell = shell
|
||||
}
|
||||
}
|
||||
|
||||
public struct User {
|
||||
public var name: String
|
||||
public var password: String
|
||||
public var uid: UInt32
|
||||
public var gid: UInt32
|
||||
public var gecos: String
|
||||
public var home: String
|
||||
public var shell: String
|
||||
|
||||
/// The argument `rawString` must follow the below format.
|
||||
/// Name:Password:Uid:Gid:Gecos:Home:Shell
|
||||
init(rawString: String) throws {
|
||||
let args = rawString.split(separator: ":", omittingEmptySubsequences: false)
|
||||
guard args.count == 7 else {
|
||||
throw Error.parseError("cannot parse User from '\(rawString)'")
|
||||
}
|
||||
guard let uid = UInt32(args[2]) else {
|
||||
throw Error.parseError("cannot parse uid from '\(args[2])'")
|
||||
}
|
||||
guard let gid = UInt32(args[3]) else {
|
||||
throw Error.parseError("cannot parse gid from '\(args[3])'")
|
||||
}
|
||||
self.name = String(args[0])
|
||||
self.password = String(args[1])
|
||||
self.uid = uid
|
||||
self.gid = gid
|
||||
self.gecos = String(args[4])
|
||||
self.home = String(args[5])
|
||||
self.shell = String(args[6])
|
||||
}
|
||||
}
|
||||
|
||||
struct Group {
|
||||
var name: String
|
||||
var password: String
|
||||
var gid: UInt32
|
||||
var users: [String]
|
||||
|
||||
/// The argument `rawString` must follow the below format.
|
||||
/// Name:Password:Gid:user1,user2
|
||||
init(rawString: String) throws {
|
||||
let args = rawString.split(separator: ":", omittingEmptySubsequences: false)
|
||||
guard args.count == 4 else {
|
||||
throw Error.parseError("cannot parse Group from '\(rawString)'")
|
||||
}
|
||||
guard let gid = UInt32(args[2]) else {
|
||||
throw Error.parseError("cannot parse gid from '\(args[2])'")
|
||||
}
|
||||
self.name = String(args[0])
|
||||
self.password = String(args[1])
|
||||
self.gid = gid
|
||||
self.users = args[3].split(separator: ",").map { String($0) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Private methods
|
||||
|
||||
extension User {
|
||||
private static func parse(file: URL, handler: (_ line: String) throws -> Void) throws {
|
||||
let fm = FileManager.default
|
||||
guard fm.fileExists(atPath: file.absolutePath()) else {
|
||||
throw Error.missingFile(file.absolutePath())
|
||||
}
|
||||
let content = try String(contentsOf: file, encoding: .ascii)
|
||||
let lines = content.components(separatedBy: .newlines)
|
||||
for line in lines {
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
guard !trimmed.isEmpty else {
|
||||
continue
|
||||
}
|
||||
guard !trimmed.hasPrefix("#") else {
|
||||
continue
|
||||
}
|
||||
try handler(trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse the contents of the passwd file with a provided filter function.
|
||||
static func parsePasswd(passwdFile: URL, filter: ((User) -> Bool)? = nil) throws -> [User] {
|
||||
var users: [User] = []
|
||||
try self.parse(file: passwdFile) { line in
|
||||
let user = try User(rawString: line)
|
||||
if let filter {
|
||||
guard filter(user) else {
|
||||
return
|
||||
}
|
||||
}
|
||||
users.append(user)
|
||||
}
|
||||
return users
|
||||
}
|
||||
|
||||
/// Parse the contents of the group file with a provided filter function.
|
||||
static func parseGroup(groupFile: URL, filter: ((Group) -> Bool)? = nil) throws -> [Group] {
|
||||
var groups: [Group] = []
|
||||
try self.parse(file: groupFile) { line in
|
||||
let group = try Group(rawString: line)
|
||||
if let filter {
|
||||
guard filter(group) else {
|
||||
return
|
||||
}
|
||||
}
|
||||
groups.append(group)
|
||||
}
|
||||
return groups
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Public methods
|
||||
|
||||
extension User {
|
||||
/// Looks up uid in the password file specified by `passwdPath`.
|
||||
public static func lookupUid(passwdPath: URL = Self.passwdFilePath, uid: UInt32) throws -> User {
|
||||
let users = try parsePasswd(
|
||||
passwdFile: passwdPath,
|
||||
filter: { u in
|
||||
u.uid == uid
|
||||
})
|
||||
if users.count == 0 {
|
||||
throw Error.noPasswdEntries
|
||||
}
|
||||
return users[0]
|
||||
}
|
||||
|
||||
/// Parses a user string in any of the following formats:
|
||||
/// "user, uid, user:group, uid:gid, uid:group, user:gid"
|
||||
/// and returns an ExecUser type from the information.
|
||||
public static func getExecUser(
|
||||
userString: String,
|
||||
defaults: ExecUser? = nil,
|
||||
passwdPath: URL = Self.passwdFilePath,
|
||||
groupPath: URL = Self.groupFilePath
|
||||
) throws -> ExecUser {
|
||||
let defaults = defaults ?? ExecUser(uid: 0, gid: 0, sgids: [], home: "/", shell: "")
|
||||
|
||||
var user = ExecUser(
|
||||
uid: defaults.uid,
|
||||
gid: defaults.gid,
|
||||
sgids: defaults.sgids,
|
||||
home: defaults.home,
|
||||
shell: defaults.shell
|
||||
)
|
||||
|
||||
let parts = userString.split(
|
||||
separator: ":",
|
||||
maxSplits: 1,
|
||||
omittingEmptySubsequences: false
|
||||
)
|
||||
let userArg = parts.isEmpty ? "" : String(parts[0])
|
||||
let groupArg = parts.count > 1 ? String(parts[1]) : ""
|
||||
|
||||
let uidArg = UInt32(userArg)
|
||||
let notUID = uidArg == nil
|
||||
let gidArg = UInt32(groupArg)
|
||||
let notGID = gidArg == nil
|
||||
|
||||
let users: [User]
|
||||
do {
|
||||
users = try parsePasswd(passwdFile: passwdPath) { u in
|
||||
if userArg.isEmpty {
|
||||
return u.uid == user.uid
|
||||
}
|
||||
if !notUID {
|
||||
return uidArg! == u.uid
|
||||
}
|
||||
return u.name == userArg
|
||||
}
|
||||
} catch Error.missingFile {
|
||||
users = []
|
||||
}
|
||||
|
||||
var matchedUserName = ""
|
||||
if !users.isEmpty {
|
||||
let matchedUser = users[0]
|
||||
matchedUserName = matchedUser.name
|
||||
user.uid = matchedUser.uid
|
||||
user.gid = matchedUser.gid
|
||||
user.home = matchedUser.home
|
||||
user.shell = matchedUser.shell
|
||||
} else if !userArg.isEmpty {
|
||||
if notUID {
|
||||
throw Error.noPasswdEntries
|
||||
}
|
||||
|
||||
user.uid = uidArg!
|
||||
if user.uid < minID || user.uid > maxID {
|
||||
throw Error.range
|
||||
}
|
||||
}
|
||||
|
||||
if !groupArg.isEmpty || !matchedUserName.isEmpty {
|
||||
let groups: [Group]
|
||||
do {
|
||||
groups = try parseGroup(groupFile: groupPath) { g in
|
||||
if groupArg.isEmpty {
|
||||
return g.users.contains(matchedUserName)
|
||||
}
|
||||
if !notGID {
|
||||
return gidArg! == g.gid
|
||||
}
|
||||
return g.name == groupArg
|
||||
}
|
||||
} catch Error.missingFile {
|
||||
groups = []
|
||||
}
|
||||
|
||||
if !groupArg.isEmpty {
|
||||
if !groups.isEmpty {
|
||||
user.gid = groups[0].gid
|
||||
} else {
|
||||
if notGID {
|
||||
throw Error.noGroupEntries
|
||||
}
|
||||
|
||||
user.gid = gidArg!
|
||||
if user.gid < minID || user.gid > maxID {
|
||||
throw Error.range
|
||||
}
|
||||
}
|
||||
}
|
||||
user.sgids = groups.map { $0.gid }
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
public enum Error: Swift.Error {
|
||||
case missingFile(String)
|
||||
case range
|
||||
case noPasswdEntries
|
||||
case noGroupEntries
|
||||
case parseError(String)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user