Files
container/Sources/ContainerPlugin/ServiceManager.swift
T
Renlord Yang 175e493a6f Fix container system status false negative under a Seatbelt sandbox. (#2316)
- `launchctl list` does not work as expected in the sandbox.
- Probe with `launchctl print <domain>/<label>` instead,
  which launchd does allow a sandboxed process, and key
  off exit codes: 0 means registered, 113 (ENOSERVICE)
  means not registered, and anything else is an error rather
  than being mistranslated into a negative.
- The probe is advisory in `system status`, so an indeterminate
  answer falls through to the XPC health check, whose success 
  is authoritative.

Signed-off-by: Renlord Yang <renlord@apple.com>
2026-10-05 15:52:37 -07:00

174 lines
7.1 KiB
Swift

//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the container 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 Foundation
public struct ServiceManager {
enum LaunchctlStatus {
static let noSuchService: Int32 = 113
}
private static func runLaunchctlCommand(args: [String]) throws -> Int32 {
let launchctl = Foundation.Process()
launchctl.executableURL = URL(fileURLWithPath: "/bin/launchctl")
launchctl.arguments = args
let null = FileHandle.nullDevice
launchctl.standardOutput = null
launchctl.standardError = null
try launchctl.run()
launchctl.waitUntilExit()
return launchctl.terminationStatus
}
/// Register a service by providing the path to a plist.
public static func register(plistPath: String) throws {
let domain = try Self.getDomainString()
_ = try runLaunchctlCommand(args: ["bootstrap", domain, plistPath])
}
/// Deregister a service by a launchd label.
public static func deregister(fullServiceLabel label: String) throws {
_ = try runLaunchctlCommand(args: ["bootout", label])
}
/// Deregister a service and pass return status
public static func deregister(fullServiceLabel label: String, status: inout Int32) throws {
status = try runLaunchctlCommand(args: ["bootout", label])
}
/// Restart a service by a launchd label.
public static func kickstart(fullServiceLabel label: String) throws {
_ = try runLaunchctlCommand(args: ["kickstart", "-k", label])
}
/// Send a signal to a service by a launchd label.
public static func kill(fullServiceLabel label: String, signal: Int32 = 15) throws {
_ = try runLaunchctlCommand(args: ["kill", "\(signal)", label])
}
/// Retrieve labels for all loaded launch units.
public static func enumerate() throws -> [String] {
let launchctl = Foundation.Process()
launchctl.executableURL = URL(fileURLWithPath: "/bin/launchctl")
launchctl.arguments = ["list"]
let stdoutPipe = Pipe()
let stderrPipe = Pipe()
launchctl.standardOutput = stdoutPipe
launchctl.standardError = stderrPipe
try launchctl.run()
let outputData = stdoutPipe.fileHandleForReading.readDataToEndOfFile()
let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile()
launchctl.waitUntilExit()
let status = launchctl.terminationStatus
guard status == 0 else {
throw ContainerizationError(
.internalError, message: "command `launchctl list` failed with status \(status), message: \(String(data: stderrData, encoding: .utf8) ?? "no error message")")
}
guard let outputText = String(data: outputData, encoding: .utf8) else {
throw ContainerizationError(
.internalError, message: "could not decode output of command `launchctl list`, message: \(String(data: stderrData, encoding: .utf8) ?? "no error message")")
}
// The third field of each line of launchctl list output is the label
return outputText.split { $0.isNewline }
.map { String($0).split { $0.isWhitespace } }
.filter { $0.count >= 3 }
.map { String($0[2]) }
}
/// Check if a service has been registered or not.
public static func isRegistered(fullServiceLabel label: String) throws -> Bool {
let result = try runLaunchctlPrint(target: label)
return try Self.interpretPrintStatus(result.status, target: label, standardError: result.standardError)
}
private static func runLaunchctlPrint(target: String) throws -> (status: Int32, standardError: String) {
let launchctl = Foundation.Process()
launchctl.executableURL = URL(fileURLWithPath: "/bin/launchctl")
launchctl.arguments = ["print", target]
let stderrPipe = Pipe()
launchctl.standardOutput = FileHandle.nullDevice
launchctl.standardError = stderrPipe
try launchctl.run()
let errorData = stderrPipe.fileHandleForReading.readDataToEndOfFile()
launchctl.waitUntilExit()
return (launchctl.terminationStatus, String(decoding: errorData, as: UTF8.self))
}
static func interpretPrintStatus(_ status: Int32, target: String = "", standardError: String = "") throws -> Bool {
switch status {
case 0:
return true
case LaunchctlStatus.noSuchService:
return false
default:
var message = "command `launchctl print \(target)` failed with status \(status)"
let details = standardError.trimmingCharacters(in: .whitespacesAndNewlines)
if !details.isEmpty {
message += ", message: \(details)"
}
throw ContainerizationError(.internalError, message: message)
}
}
private static func getLaunchdSessionType() throws -> String {
let launchctl = Foundation.Process()
launchctl.executableURL = URL(fileURLWithPath: "/bin/launchctl")
launchctl.arguments = ["managername"]
let null = FileHandle.nullDevice
let stdoutPipe = Pipe()
launchctl.standardOutput = stdoutPipe
launchctl.standardError = null
try launchctl.run()
let outputData = stdoutPipe.fileHandleForReading.readDataToEndOfFile()
launchctl.waitUntilExit()
let status = launchctl.terminationStatus
guard status == 0 else {
throw ContainerizationError(.internalError, message: "command `launchctl managername` failed with status \(status)")
}
guard let outputText = String(data: outputData, encoding: .utf8) else {
throw ContainerizationError(.internalError, message: "could not decode output of command `launchctl managername`")
}
return outputText.trimmingCharacters(in: .whitespacesAndNewlines)
}
public static func getDomainString() throws -> String {
let currentSessionType = try getLaunchdSessionType()
switch currentSessionType {
case LaunchPlist.Domain.System.rawValue:
return LaunchPlist.Domain.System.rawValue.lowercased()
case LaunchPlist.Domain.Background.rawValue:
return "user/\(getuid())"
case LaunchPlist.Domain.Aqua.rawValue:
return "gui/\(getuid())"
default:
throw ContainerizationError(.internalError, message: "unsupported session type \(currentSessionType)")
}
}
}