Use FilePath for app, install, and log roots. (#1558)

- Closes #1557.
- Replaces `executableURL` utility function for getting app executable
path with `executablePath`.
- Adds `FilePath.resolvingSymlinks()` extension.
- Also converts for FilePath for types in `ContainerVersion` target.
This commit is contained in:
J Logan
2026-05-26 13:58:55 -07:00
committed by GitHub
parent 277c96cbea
commit ca3a4d6670
20 changed files with 549 additions and 128 deletions
+9 -1
View File
@@ -520,9 +520,17 @@ let package = Package(
.target(
name: "ContainerVersion",
dependencies: [
"CVersion"
.product(name: "SystemPackage", package: "swift-system"),
"CVersion",
],
),
.testTarget(
name: "ContainerVersionTests",
dependencies: [
.product(name: "SystemPackage", package: "swift-system"),
"ContainerVersion",
]
),
.target(
name: "CVersion",
dependencies: [],
+28 -22
View File
@@ -43,16 +43,16 @@ extension APIServer {
@Flag(name: .long, help: "Enable debug logging")
var debug = false
var appRoot = ApplicationRoot.url
var appRoot = ApplicationRoot.path
var installRoot = InstallRoot.url
var installRoot = InstallRoot.path
var logRoot = LogRoot.path
func run() async throws {
let containerSystemConfig: ContainerSystemConfig = try await ConfigurationLoader.load()
let commandName = APIServer._commandName
let logPath = logRoot.map { $0.appending("\(commandName).log") }
let logPath = logRoot.map { $0.appending(FilePath.Component("\(commandName).log") ?? "unknown") }
let log = ServiceLogger.bootstrap(category: "APIServer", debug: debug, logPath: logPath)
log.info("starting helper", metadata: ["name": "\(commandName)"])
defer {
@@ -179,22 +179,24 @@ extension APIServer {
log.info(
"initializing plugin loader",
metadata: [
"installRoot": "\(installRoot.path(percentEncoded: false))"
"installRoot": "\(installRoot.string)"
])
let pluginsURL = PluginLoader.userPluginsDir(installRoot: installRoot)
// TODO: Remove when we convert PluginLoader to FilePath
let installRootURL = URL(fileURLWithPath: installRoot.string)
let pluginsURL = PluginLoader.userPluginsDir(installRoot: installRootURL)
log.info("detecting user plugins directory", metadata: ["path": "\(pluginsURL.path(percentEncoded: false))"])
var directoryExists: ObjCBool = false
_ = FileManager.default.fileExists(atPath: pluginsURL.path, isDirectory: &directoryExists)
let userPluginsURL = directoryExists.boolValue ? pluginsURL : nil
// plugins built into the application installed as a Unix-like application
let installRootPluginsURL =
let installRootPluginsPath =
installRoot
.appendingPathComponent("libexec")
.appendingPathComponent("container")
.appendingPathComponent("plugins")
.standardized
.appending(FilePath.Component("libexec"))
.appending(FilePath.Component("container"))
.appending(FilePath.Component("plugins"))
let installRootPluginsURL = URL(fileURLWithPath: installRootPluginsPath.string)
let pluginDirectories = [
userPluginsURL,
@@ -210,9 +212,10 @@ extension APIServer {
log.info("discovered plugin directory", metadata: ["path": "\(pluginDirectory.path(percentEncoded: false))"])
}
let appRootURL = URL(fileURLWithPath: appRoot.string)
return try PluginLoader(
appRoot: appRoot,
installRoot: installRoot,
appRoot: appRootURL,
installRoot: installRootURL,
logRoot: logRoot,
pluginDirectories: pluginDirectories,
pluginFactories: pluginFactories,
@@ -245,9 +248,12 @@ extension APIServer {
private func initializeHealthCheckService(log: Logger, routes: inout [XPCRoute: XPCServer.RouteHandler]) {
log.info("initializing health check service")
// TODO: Remove when we convert HealthCheckHarness to FilePath
let installRootURL = URL(fileURLWithPath: installRoot.string)
let appRootURL = URL(fileURLWithPath: appRoot.string)
let svc = HealthCheckHarness(
appRoot: appRoot,
installRoot: installRoot,
appRoot: appRootURL,
installRoot: installRootURL,
logRoot: logRoot,
log: log
)
@@ -257,7 +263,9 @@ extension APIServer {
private func initializeKernelService(log: Logger, routes: inout [XPCRoute: XPCServer.RouteHandler]) throws {
log.info("initializing kernel service")
let svc = try KernelService(log: log, appRoot: appRoot)
// TODO: Remove when we convert KernelService to FilePath
let appRootURL = URL(fileURLWithPath: appRoot.string)
let svc = try KernelService(log: log, appRoot: appRootURL)
let harness = KernelHarness(service: svc, log: log)
routes[XPCRoute.installKernel] = XPCServer.route(harness.install)
routes[XPCRoute.getDefaultKernel] = XPCServer.route(harness.getDefaultKernel)
@@ -271,8 +279,10 @@ extension APIServer {
) throws -> ContainersService {
log.info("initializing containers service")
// TODO: Remove when we convert ContainersService to FilePath
let appRootURL = URL(fileURLWithPath: appRoot.string)
let service = try ContainersService(
appRoot: appRoot,
appRoot: appRootURL,
pluginLoader: pluginLoader,
containerSystemConfig: containerSystemConfig,
log: log,
@@ -310,9 +320,7 @@ extension APIServer {
) async throws -> NetworksService {
log.info("initializing networks service")
// TODO: This goes away when we convert our roots to FilePath
let appPath = FilePath(appRoot.absolutePath())
let resourceRoot = appPath.appending("networks")
let resourceRoot = appRoot.appending(FilePath.Component("networks"))
let service = try await NetworksService(
pluginLoader: pluginLoader,
resourceRoot: resourceRoot,
@@ -355,9 +363,7 @@ extension APIServer {
) throws -> VolumesService {
log.info("initializing volume service")
// TODO: This goes away when we convert our roots to FilePath
let appPath = FilePath(appRoot.absolutePath())
let resourceRoot = appPath.appending("volumes")
let resourceRoot = appRoot.appending(FilePath.Component("volumes"))
let service = try VolumesService(resourceRoot: resourceRoot, containersService: containersService, log: log)
let harness = VolumesHarness(service: service, log: log)
+13 -12
View File
@@ -23,6 +23,7 @@ import ContainerizationError
import ContainerizationOS
import Foundation
import Logging
import SystemPackage
import TerminalProgress
// This logger is only used until `asyncCommand.run()`.
@@ -136,11 +137,12 @@ public struct Application: AsyncLoggableCommand {
}
public static func createPluginLoader() async throws -> PluginLoader {
let installRoot = CommandLine.executablePathUrl
.deletingLastPathComponent()
.appendingPathComponent("..")
.standardized
let pluginsURL = PluginLoader.userPluginsDir(installRoot: installRoot)
let installRootPath = CommandLine.executablePath
.removingLastComponent()
.removingLastComponent()
// TODO: Remove when we convert PluginLoader to FilePath.
let installRootURL = URL(fileURLWithPath: installRootPath.string)
let pluginsURL = PluginLoader.userPluginsDir(installRoot: installRootURL)
var directoryExists: ObjCBool = false
_ = FileManager.default.fileExists(atPath: pluginsURL.path, isDirectory: &directoryExists)
let userPluginsURL = directoryExists.boolValue ? pluginsURL : nil
@@ -149,13 +151,12 @@ public struct Application: AsyncLoggableCommand {
let appBundlePluginsURL = Bundle.main.resourceURL?.appending(path: "plugins")
// plugins built into the application installed as a Unix-like application
let installRootPluginsURL =
installRoot
.appendingPathComponent("libexec")
.appendingPathComponent("container")
.appendingPathComponent("plugins")
.standardized
let installRootPluginsPath =
installRootPath
.appending(FilePath.Component("libexec"))
.appending(FilePath.Component("container"))
.appending(FilePath.Component("plugins"))
let installRootPluginsURL = URL(fileURLWithPath: installRootPluginsPath.string)
let pluginDirectories = [
userPluginsURL,
appBundlePluginsURL,
+13 -10
View File
@@ -19,6 +19,7 @@ import ContainerAPIClient
import ContainerPlugin
import Darwin
import Foundation
import SystemPackage
struct DefaultCommand: AsyncLoggableCommand {
public static let configuration = CommandConfiguration(
@@ -48,17 +49,19 @@ struct DefaultCommand: AsyncLoggableCommand {
}
// Compute canonical plugin directories to show in helpful errors (avoid hard-coded paths)
let installRoot = CommandLine.executablePathUrl
.deletingLastPathComponent()
.appendingPathComponent("..")
.standardized
let userPluginsURL = PluginLoader.userPluginsDir(installRoot: installRoot)
let installRootPluginsURL =
let installRoot = CommandLine.executablePath
.removingLastComponent()
.removingLastComponent()
// TODO: Remove when we convert PluginLoader to FilePath
let installRootURL = URL(fileURLWithPath: installRoot.string)
let userPluginsURL = PluginLoader.userPluginsDir(installRoot: installRootURL)
let installRootPluginsPath =
installRoot
.appendingPathComponent("libexec")
.appendingPathComponent("container")
.appendingPathComponent("plugins")
.standardized
.appending(FilePath.Component("libexec"))
.appending(FilePath.Component("container"))
.appending(FilePath.Component("plugins"))
let installRootPluginsURL = URL(fileURLWithPath: installRootPluginsPath.string)
let hintPaths = [userPluginsURL, installRootPluginsURL]
.map { $0.appendingPathComponent(command).path(percentEncoded: false) }
.joined(separator: "\n - ")
@@ -34,19 +34,19 @@ extension Application {
@Option(
name: .shortAndLong,
help: "Path to the root directory for application data",
transform: { URL(filePath: $0) })
var appRoot = ApplicationRoot.defaultURL
transform: { FilePath(FileManager.default.currentDirectoryPath).resolve($0, defaultPath: FilePath($0)) })
var appRoot = ApplicationRoot.defaultPath
@Option(
name: .long,
help: "Path to the root directory for application executables and plugins",
transform: { URL(filePath: $0) })
var installRoot = InstallRoot.defaultURL
transform: { FilePath(FileManager.default.currentDirectoryPath).resolve($0, defaultPath: FilePath($0)) })
var installRoot = InstallRoot.defaultPath
@Option(
name: .long,
help: "Path to the root directory for log data, using macOS log facility if not set",
transform: { FilePath($0) })
transform: { FilePath(FileManager.default.currentDirectoryPath).resolve($0, defaultPath: FilePath($0)) })
var logRoot: FilePath? = nil
@Flag(
@@ -72,9 +72,7 @@ extension Application {
public init() {}
public func run() async throws {
let appRootPath = FilePath(appRoot.path(percentEncoded: false))
let installRootPath = FilePath(installRoot.path(percentEncoded: false))
try ConfigurationLoader.copyConfigurationToReadOnly(to: appRootPath)
try ConfigurationLoader.copyConfigurationToReadOnly(to: appRoot)
// Pass appRoot before installRoot: ConfigurationLoader uses first-match-wins
// precedence, so user-provided config in appRoot overrides the defaults
// shipped under installRoot. Both layers are passed explicitly because
@@ -82,8 +80,8 @@ extension Application {
// loader's default search would otherwise ignore those overrides.
let containerSystemConfig: ContainerSystemConfig = try await ConfigurationLoader.load(
configurationFiles: [
ConfigurationLoader.configurationFile(in: appRootPath, of: .appRoot),
ConfigurationLoader.configurationFile(in: installRootPath, of: .installRoot),
ConfigurationLoader.configurationFile(in: appRoot, of: .appRoot),
ConfigurationLoader.configurationFile(in: installRoot, of: .installRoot),
])
// Without the true path to the binary in the plist, `container-apiserver` won't launch properly.
@@ -91,29 +89,27 @@ extension Application {
// Gatekeeper / amfid validates code signatures relative to the enclosing .app bundle
// hierarchy; launching via a symlink outside the bundle fails that check.
// TODO: Can we use the plugin loader to bootstrap the API server?
let executableUrl = CommandLine.executablePathUrl
.deletingLastPathComponent()
.appendingPathComponent("container-apiserver")
.resolvingSymlinksInPath()
let executablePath = try CommandLine.executablePath
.removingLastComponent()
.appending(FilePath.Component("container-apiserver"))
.resolvingSymlinks()
var args = [executableUrl.absolutePath()]
var args = [executablePath.string]
args.append("start")
if logOptions.debug {
args.append("--debug")
}
let apiServerDataUrl = appRoot.appending(path: "apiserver")
try! FileManager.default.createDirectory(at: apiServerDataUrl, withIntermediateDirectories: true)
let apiServerDataPath = appRoot.appending(FilePath.Component("apiserver"))
let apiServerDataURL = URL(fileURLWithPath: apiServerDataPath.string)
try! FileManager.default.createDirectory(at: apiServerDataURL, withIntermediateDirectories: true)
var env = PluginLoader.filterEnvironment()
env[ApplicationRoot.environmentName] = appRoot.path(percentEncoded: false)
env[InstallRoot.environmentName] = installRoot.path(percentEncoded: false)
env[ApplicationRoot.environmentName] = appRoot.string
env[InstallRoot.environmentName] = installRoot.string
if let logRoot {
env[LogRoot.environmentName] =
logRoot.isAbsolute
? logRoot.string
: FilePath(FileManager.default.currentDirectoryPath).appending(logRoot.components).string
env[LogRoot.environmentName] = logRoot.string
}
let plist = LaunchPlist(
label: "com.apple.container.apiserver",
@@ -124,7 +120,8 @@ extension Application {
machServices: ["com.apple.container.apiserver"]
)
let plistURL = apiServerDataUrl.appending(path: "apiserver.plist")
let plistPath = apiServerDataPath.appending(FilePath.Component("apiserver.plist"))
let plistURL = URL(fileURLWithPath: plistPath.string)
let data = try plist.encode()
try data.write(to: plistURL)
@@ -0,0 +1,39 @@
//===----------------------------------------------------------------------===//
// Copyright © 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 Darwin
import SystemPackage
extension FilePath {
/// Returns a new `FilePath` with all symlinks resolved and `.`/`..`
/// components normalized, by calling `realpath(3)`.
///
/// Unlike ``lexicallyNormalized()``, this method accesses the file system.
/// It throws ``Errno/noSuchFileOrDirectory`` if any component of the path
/// does not exist.
///
/// The returned path is always absolute. If the receiver is a relative path,
/// it is resolved against the process's current working directory.
public func resolvingSymlinks() throws -> FilePath {
try withPlatformString { cPath in
guard let resolved = Darwin.realpath(cPath, nil) else {
throw Errno(rawValue: Darwin.errno)
}
defer { free(resolved) }
return FilePath(platformString: resolved)
}
}
}
+4 -5
View File
@@ -51,11 +51,10 @@ public enum PathUtils {
// rather than argv[0]: when the binary is invoked through PATH (e.g.
// `container ...`), argv[0] is just the basename and resolves to an
// empty FilePath, which FileManager treats as CWD-relative.
let installRootURL = CommandLine.executablePathUrl
.deletingLastPathComponent()
.appendingPathComponent("..")
.standardized
return FilePath(installRootURL.path(percentEncoded: false))
let installRootPath = CommandLine.executablePath
.removingLastComponent()
.removingLastComponent()
return installRootPath
}
}
}
+23 -8
View File
@@ -15,19 +15,34 @@
//===----------------------------------------------------------------------===//
import Foundation
import SystemPackage
/// Provides the application data root path.
public struct ApplicationRoot {
/// The environment variable that if set, determines the root directory for the application data store.
/// Otherwise, the system uses the default "~/Library/Application Support/com.apple.container".
public static let environmentName = "CONTAINER_APP_ROOT"
public static let defaultURL = FileManager.default.urls(
for: .applicationSupportDirectory,
in: .userDomainMask
).first!.appendingPathComponent("com.apple.container")
/// The default root directory used when ``environmentName`` is not set:
/// `~/Library/Application Support/com.apple.container`.
public static let defaultPath = FilePath(
FileManager.default.urls(
for: .applicationSupportDirectory,
in: .userDomainMask
).first!.path(percentEncoded: false)
)
.appending(FilePath.Component("com.apple.container"))
private static let envPath = ProcessInfo.processInfo.environment[Self.environmentName]
/// The resolved root directory path, always lexically normalized.
///
/// If the environment variable is set to an absolute path, that path is used directly.
/// If it is set to a relative path, the path is resolved against the working directory.
/// Otherwise, ``defaultPath`` is used.
public static let path = FilePath(FileManager.default.currentDirectoryPath).resolve(
ProcessInfo.processInfo.environment[environmentName],
defaultPath: defaultPath
)
public static let url = envPath.map { URL(fileURLWithPath: $0) } ?? defaultURL
public static let path = url.path(percentEncoded: false)
/// The pathname to the root directory
public static let pathname = path.string
}
@@ -0,0 +1,48 @@
//===----------------------------------------------------------------------===//
// Copyright © 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 SystemPackage
extension FilePath {
/// Resolves a pathname string relative to this path.
///
/// The result is lexically normalized `.` components are removed and `..` components
/// collapse the preceding component. Absolute pathnames are returned normalized as-is;
/// relative pathnames are appended to `self` before normalizing.
///
/// - Parameter pathname: The pathname to resolve.
/// - Returns: The resolved ``FilePath``, or `nil` if `pathname` is `nil` or empty.
package func resolve(_ pathname: String?) -> FilePath? {
guard let pathname, !pathname.isEmpty else { return nil }
let path = FilePath(pathname)
guard !path.isAbsolute else { return path.lexicallyNormalized() }
return self.appending(path.components).lexicallyNormalized()
}
/// Resolves a pathname string relative to this path, falling back to a default.
///
/// The result is lexically normalized `.` components are removed and `..` components
/// collapse the preceding component. Absolute pathnames are returned normalized as-is;
/// relative pathnames are appended to `self` before normalizing.
///
/// - Parameters:
/// - pathname: The pathname to resolve.
/// - defaultPath: The path returned when `pathname` is `nil` or empty.
/// - Returns: The resolved ``FilePath``, or `defaultPath` lexically normalized if `pathname` is `nil` or empty.
package func resolve(_ pathname: String?, defaultPath: FilePath) -> FilePath {
resolve(pathname) ?? defaultPath.lexicallyNormalized()
}
}
+23 -8
View File
@@ -16,19 +16,34 @@
import ContainerVersion
import Foundation
import SystemPackage
/// Provides the application installation root path.
public struct InstallRoot {
/// The environment variable that if set, determines the root directory for installed application.
/// Otherwise, the system computes the install path as the parent of the directory containing the
/// application binary (for example, "/usr/local/bin/container" -> "/usr/local").
public static let environmentName = "CONTAINER_INSTALL_ROOT"
public static let defaultURL = CommandLine.executablePathUrl
.deletingLastPathComponent()
.appendingPathComponent("..")
.standardized
/// The default root directory used when the environment variable is not set.
///
/// Computed as the grandparent of ``CommandLine/executablePath``
/// (for example, `/usr/local/bin/container` `/usr/local`).
/// Lexically normalized but not canonical, as symlinks in the executable path are not resolved.
public static let defaultPath = CommandLine.executablePath
.removingLastComponent()
.removingLastComponent()
private static let envPath = ProcessInfo.processInfo.environment[Self.environmentName]
/// The resolved root directory path, always lexically normalized.
///
/// If the environment variable is set to an absolute path, that path is used directly.
/// If it is set to a relative path, the path is resolved against the working directory.
/// Otherwise, ``defaultPath`` is used.
public static let path = FilePath(FileManager.default.currentDirectoryPath).resolve(
ProcessInfo.processInfo.environment[environmentName],
defaultPath: defaultPath
)
public static let url = envPath.map { URL(fileURLWithPath: $0) } ?? defaultURL
public static let path = url.path(percentEncoded: false)
/// The pathname to the root directory
public static let pathname = path.string
}
+9 -11
View File
@@ -19,21 +19,19 @@ import SystemPackage
/// Provides the application data root path.
public struct LogRoot {
private static let envPath = ProcessInfo.processInfo.environment[Self.environmentName].flatMap {
$0.isEmpty ? nil : FilePath($0)
}
/// The environment variable that if set, determines the root directory for log files.
/// Otherwise, the application uses the macOS log facility.
public static let environmentName = "CONTAINER_LOG_ROOT"
/// The path object for the log file root directory
public static let path = envPath.map {
guard !$0.isAbsolute else { return $0 }
return FilePath(FileManager.default.currentDirectoryPath).appending($0.components)
}
/// The resolved root directory for log files, or `nil` if the environment variable is not set.
///
/// When non-nil, the path is always lexically normalized.
/// If the environment variable is set to an absolute path, that path is used directly.
/// If it is set to a relative path, the path is resolved against the working directory.
public static let path = FilePath(FileManager.default.currentDirectoryPath).resolve(
ProcessInfo.processInfo.environment[environmentName]
)
/// The pathname to the log file root directory
/// The pathname to the root directory
public static let pathname = path?.string
}
+25 -10
View File
@@ -14,18 +14,33 @@
// limitations under the License.
//===----------------------------------------------------------------------===//
import Darwin
import Foundation
import SystemPackage
/// Retrieve the application bundle for a path that refers to a macOS executable.
extension Bundle {
public static func appBundle(executableURL: URL) -> Bundle? {
let resolvedURL = executableURL.resolvingSymlinksInPath()
let macOSURL = resolvedURL.deletingLastPathComponent()
let contentsURL = macOSURL.deletingLastPathComponent()
let bundleURL = contentsURL.deletingLastPathComponent()
if bundleURL.pathExtension == "app" {
return Bundle(url: bundleURL)
}
return nil
/// Retrieves the application bundle for a path that refers to a macOS executable.
///
/// Resolves symlinks in `executablePath`, then walks up the standard macOS bundle layout
/// (`MacOS/` `Contents/` `Foo.app/`) and verifies the `.app` extension.
///
/// - Parameter executablePath: The path to a macOS executable inside a bundle.
/// - Returns: The ``Bundle`` at the resolved `.app` directory, or `nil` if the executable
/// is not inside a valid macOS application bundle.
public static func appBundle(executablePath: FilePath) -> Bundle? {
let resolvedPath =
executablePath.withPlatformString { cPath in
Darwin.realpath(cPath, nil).map { ptr -> FilePath in
defer { free(ptr) }
return FilePath(platformString: ptr)
}
} ?? executablePath
let bundlePath =
resolvedPath
.removingLastComponent() // MacOS/
.removingLastComponent() // Contents/
.removingLastComponent() // Foo.app/
guard bundlePath.lastComponent?.extension == "app" else { return nil }
return Bundle(url: URL(fileURLWithPath: bundlePath.string))
}
}
@@ -15,9 +15,14 @@
//===----------------------------------------------------------------------===//
import Foundation
import SystemPackage
extension CommandLine {
public static var executablePathUrl: URL {
/// The path of the running executable.
///
/// Obtained via `_NSGetExecutablePath`, which returns an absolute, lexically normalized path
/// (no `.` or `..` components). Symlinks are not resolved, so the path is not canonical.
public static var executablePath: FilePath {
/// _NSGetExecutablePath with a zero-length buffer returns the needed buffer length
var bufferSize: Int32 = 0
var buffer = [CChar](repeating: 0, count: Int(bufferSize))
@@ -31,6 +36,6 @@ extension CommandLine {
/// Return the path with the executable file component removed the last component and
let executablePath = String(cString: &buffer)
return URL(filePath: executablePath)
return FilePath(executablePath)
}
}
@@ -35,7 +35,7 @@ public struct ReleaseVersion {
}
public static func version() -> String {
let appBundle = Bundle.appBundle(executableURL: CommandLine.executablePathUrl)
let appBundle = Bundle.appBundle(executablePath: CommandLine.executablePath)
let bundleVersion = appBundle?.infoDictionary?["CFBundleShortVersionString"] as? String
return bundleVersion ?? get_release_version().map { String(cString: $0) } ?? "0.0.0"
}
+12 -7
View File
@@ -25,6 +25,7 @@ import ContainerXPC
import Containerization
import Foundation
import Logging
import SystemPackage
@main
struct ImagesHelper: AsyncParsableCommand {
@@ -51,9 +52,9 @@ extension ImagesHelper {
@Option(name: .long, help: "XPC service prefix")
var serviceIdentifier: String = "com.apple.container.core.container-core-images"
var appRoot = ApplicationRoot.url
var appRoot = ApplicationRoot.path
var installRoot = InstallRoot.url
var installRoot = InstallRoot.path
var logRoot = LogRoot.path
@@ -90,11 +91,13 @@ extension ImagesHelper {
}
}
private func initializeImagesService(root: URL, containerSystemConfig: ContainerSystemConfig, log: Logger, routes: inout [String: XPCServer.RouteHandler]) throws {
private func initializeImagesService(root: FilePath, containerSystemConfig: ContainerSystemConfig, log: Logger, routes: inout [String: XPCServer.RouteHandler]) throws {
// TODO: remove as part of ImageStore URL removal PR
let rootURL = URL(fileURLWithPath: root.string)
let contentStore = RemoteContentStoreClient()
let imageStore = try ImageStore(path: root, contentStore: contentStore)
let imageStore = try ImageStore(path: rootURL, contentStore: contentStore)
let unpackStrategy = SnapshotStore.defaultUnpackStrategy(initImage: containerSystemConfig.vminit.image)
let snapshotStore = try SnapshotStore(path: root, unpackStrategy: unpackStrategy, log: log)
let snapshotStore = try SnapshotStore(path: rootURL, unpackStrategy: unpackStrategy, log: log)
let service = try ImagesService(contentStore: contentStore, imageStore: imageStore, snapshotStore: snapshotStore, log: log)
let harness = ImagesServiceHarness(service: service, log: log)
@@ -112,8 +115,10 @@ extension ImagesHelper {
routes[ImagesServiceXPCRoute.snapshotGet.rawValue] = XPCServer.route(harness.getSnapshot)
}
private func initializeContentService(root: URL, log: Logger, routes: inout [String: XPCServer.RouteHandler]) throws {
let service = try ContentStoreService(root: root, log: log)
private func initializeContentService(root: FilePath, log: Logger, routes: inout [String: XPCServer.RouteHandler]) throws {
// TODO: remove as part of ImageStore URL removal PR
let rootURL = URL(fileURLWithPath: root.string)
let service = try ContentStoreService(root: rootURL, log: log)
let harness = ContentServiceHarness(service: service, log: log)
routes[ImagesServiceXPCRoute.contentClean.rawValue] = XPCServer.route(harness.clean)
@@ -0,0 +1,51 @@
//===----------------------------------------------------------------------===//
// Copyright © 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 ContainerTestSupport
import Foundation
import SystemPackage
import Testing
@testable import ContainerPersistence
struct FilePathSymlinksTests {
@Test func realPathReturnsAbsolutePath() throws {
let resolved = try FilePath("/tmp").resolvingSymlinks()
#expect(resolved.isAbsolute)
}
@Test func symlinkResolvesToTarget() async throws {
try await TemporaryStorage.withTempDir { dir in
let target = dir.appending(FilePath.Component("target"))
let link = dir.appending(FilePath.Component("link"))
try "content".write(toFile: target.string, atomically: true, encoding: .utf8)
try FileManager.default.createSymbolicLink(atPath: link.string, withDestinationPath: target.string)
#expect(try link.resolvingSymlinks() == target.resolvingSymlinks())
}
}
@Test func nonExistentPathThrows() {
#expect(throws: Errno.noSuchFileOrDirectory) {
try FilePath("/nonexistent/path/that/does/not/exist").resolvingSymlinks()
}
}
@Test func relativePathResolvesToAbsolute() throws {
let resolved = try FilePath(".").resolvingSymlinks()
#expect(resolved.isAbsolute)
}
}
@@ -0,0 +1,74 @@
//===----------------------------------------------------------------------===//
// Copyright © 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 SystemPackage
import Testing
@testable import ContainerPlugin
private let cwd = FilePath("/current/dir")
struct FilePathResolveTests {
@Test func nilWhenPathnameIsNil() {
#expect(cwd.resolve(nil) == nil)
}
@Test func nilWhenPathnameIsEmpty() {
#expect(cwd.resolve("") == nil)
}
@Test func absolutePathnameReturnedAsIs() {
#expect(cwd.resolve("/custom/root") == FilePath("/custom/root"))
}
@Test func relativePathnamePrependsCurrentDirectory() {
#expect(cwd.resolve("data") == FilePath("/current/dir/data"))
}
@Test func relativePathnameWithDotDotIsLexicallyNormalized() {
#expect(cwd.resolve("../sibling") == FilePath("/current/sibling"))
}
@Test func relativePathnameWithDotIsLexicallyNormalized() {
#expect(cwd.resolve("./data") == FilePath("/current/dir/data"))
}
@Test func absolutePathnameWithDotDotIsLexicallyNormalized() {
#expect(cwd.resolve("/custom/../root") == FilePath("/root"))
}
@Test func absolutePathnameWithDotIsLexicallyNormalized() {
#expect(cwd.resolve("/custom/./root") == FilePath("/custom/root"))
}
@Test func defaultPathUsedWhenPathnameIsNil() {
let fallback = FilePath("/fallback")
#expect(cwd.resolve(nil, defaultPath: fallback) == fallback)
}
@Test func defaultPathUsedWhenPathnameIsEmpty() {
let fallback = FilePath("/fallback")
#expect(cwd.resolve("", defaultPath: fallback) == fallback)
}
@Test func defaultPathIsLexicallyNormalized() {
#expect(cwd.resolve(nil, defaultPath: FilePath("/fallback/../normalized")) == FilePath("/normalized"))
}
@Test func absolutePathnameOverridesDefaultPath() {
#expect(cwd.resolve("/custom", defaultPath: FilePath("/fallback")) == FilePath("/custom"))
}
}
@@ -0,0 +1,47 @@
//===----------------------------------------------------------------------===//
// Copyright © 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 SystemPackage
import Testing
@testable import ContainerPlugin
struct ApplicationRootTests {
@Test func defaultPathIsAbsolute() {
#expect(ApplicationRoot.defaultPath.isAbsolute)
}
@Test func defaultPathEndsWithContainerComponent() {
#expect(ApplicationRoot.defaultPath.lastComponent?.string == "com.apple.container")
}
}
struct InstallRootTests {
@Test func defaultPathIsAbsolute() {
#expect(InstallRoot.defaultPath.isAbsolute)
}
@Test func defaultPathIsGrandparentOfExecutable() {
#expect(InstallRoot.defaultPath == CommandLine.executablePath.removingLastComponent().removingLastComponent())
}
}
struct LogRootTests {
@Test func pathIsNilWhenEnvUnset() {
// CONTAINER_LOG_ROOT is not set in the unit test environment
#expect(LogRoot.path == nil)
}
}
@@ -0,0 +1,84 @@
//===----------------------------------------------------------------------===//
// Copyright © 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 Foundation
import SystemPackage
import Testing
@testable import ContainerVersion
struct BundleAppBundleTests {
@Test func returnsNilForUnixInstallPath() {
// /usr/local/bin/container no .app bundle in the hierarchy
let path = FilePath("/usr/local/bin/container")
#expect(Bundle.appBundle(executablePath: path) == nil)
}
@Test func returnsBundleForAppBundleExecutable() throws {
// Build a minimal Foo.app bundle on disk Bundle(url:) requires the directory to exist.
let tmp = FileManager.default.temporaryDirectory
.appendingPathComponent("BundleTest-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: tmp) }
let bundleURL = tmp.appendingPathComponent("Foo.app", isDirectory: true)
let contentsURL = bundleURL.appendingPathComponent("Contents", isDirectory: true)
let macOSURL = contentsURL.appendingPathComponent("MacOS", isDirectory: true)
try FileManager.default.createDirectory(at: macOSURL, withIntermediateDirectories: true)
try Data("<plist version=\"1.0\"><dict/></plist>".utf8)
.write(to: contentsURL.appendingPathComponent("Info.plist"))
let executablePath = FilePath(macOSURL.path(percentEncoded: false))
.appending(FilePath.Component("Foo"))
let bundle = Bundle.appBundle(executablePath: executablePath)
#expect(bundle != nil)
#expect(bundle?.bundleURL.lastPathComponent == "Foo.app")
}
@Test func returnsBundleForSymlinkedExecutable() throws {
let tmp = FileManager.default.temporaryDirectory
.appendingPathComponent("BundleTest-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: tmp) }
let bundleURL = tmp.appendingPathComponent("Foo.app", isDirectory: true)
let contentsURL = bundleURL.appendingPathComponent("Contents", isDirectory: true)
let macOSURL = contentsURL.appendingPathComponent("MacOS", isDirectory: true)
try FileManager.default.createDirectory(at: macOSURL, withIntermediateDirectories: true)
try Data("<plist version=\"1.0\"><dict/></plist>".utf8)
.write(to: contentsURL.appendingPathComponent("Info.plist"))
let executableURL = macOSURL.appendingPathComponent("Foo")
try Data().write(to: executableURL)
// Symlink outside the bundle pointing at the real executable
let symlinkURL = tmp.appendingPathComponent("foo-link")
try FileManager.default.createSymbolicLink(at: symlinkURL, withDestinationURL: executableURL)
let bundle = Bundle.appBundle(executablePath: FilePath(symlinkURL.path(percentEncoded: false)))
#expect(bundle != nil)
#expect(bundle?.bundleURL.lastPathComponent == "Foo.app")
}
@Test func returnsNilWhenTooShallow() {
// Only one component above executable can't be a bundle
let path = FilePath("/Foo.app/binary")
#expect(Bundle.appBundle(executablePath: path) == nil)
}
@Test func returnsNilWhenThirdAncestorLacksAppExtension() {
// Parent hierarchy exists but doesn't end in .app
let path = FilePath("/opt/tools/bin/helper")
#expect(Bundle.appBundle(executablePath: path) == nil)
}
}
@@ -1,5 +1,5 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the container project authors.
// Copyright © 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.
@@ -14,14 +14,25 @@
// limitations under the License.
//===----------------------------------------------------------------------===//
import Foundation
import SystemPackage
import Testing
@testable import ContainerPlugin
@testable import ContainerVersion
struct CommandLineExecutableTest {
@Test
func testCLIPluginConfigLoad() async throws {
#expect(CommandLine.executablePathUrl.lastPathComponent == "swiftpm-testing-helper")
struct CommandLineExecutableTests {
@Test func lastComponentIsTestBinary() {
#expect(CommandLine.executablePath.lastComponent?.string == "swiftpm-testing-helper")
}
@Test func pathIsAbsolute() {
#expect(CommandLine.executablePath.isAbsolute)
}
@Test func pathIsNonEmpty() {
#expect(!CommandLine.executablePath.string.isEmpty)
}
@Test func removingLastComponentTwiceIsAbsolute() {
#expect(CommandLine.executablePath.removingLastComponent().removingLastComponent().isAbsolute)
}
}