diff --git a/Package.swift b/Package.swift
index 4323fcfef..86fb85c25 100644
--- a/Package.swift
+++ b/Package.swift
@@ -514,9 +514,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: [],
diff --git a/Sources/APIServer/APIServer+Start.swift b/Sources/APIServer/APIServer+Start.swift
index 3bdb7c8d1..5e01c41b4 100644
--- a/Sources/APIServer/APIServer+Start.swift
+++ b/Sources/APIServer/APIServer+Start.swift
@@ -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,
@@ -308,9 +318,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,
@@ -353,9 +361,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)
diff --git a/Sources/ContainerCommands/Application.swift b/Sources/ContainerCommands/Application.swift
index 3e43c05bc..f047d1f4b 100644
--- a/Sources/ContainerCommands/Application.swift
+++ b/Sources/ContainerCommands/Application.swift
@@ -23,6 +23,7 @@ import ContainerizationError
import ContainerizationOS
import Foundation
import Logging
+import SystemPackage
import TerminalProgress
// This logger is only used until `asyncCommand.run()`.
@@ -135,11 +136,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
@@ -148,13 +150,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,
diff --git a/Sources/ContainerCommands/DefaultCommand.swift b/Sources/ContainerCommands/DefaultCommand.swift
index d623c13de..3df8f570e 100644
--- a/Sources/ContainerCommands/DefaultCommand.swift
+++ b/Sources/ContainerCommands/DefaultCommand.swift
@@ -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 - ")
diff --git a/Sources/ContainerCommands/System/SystemStart.swift b/Sources/ContainerCommands/System/SystemStart.swift
index 4ddd9f239..1ee000cc3 100644
--- a/Sources/ContainerCommands/System/SystemStart.swift
+++ b/Sources/ContainerCommands/System/SystemStart.swift
@@ -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)
diff --git a/Sources/ContainerPersistence/FilePath+Symlinks.swift b/Sources/ContainerPersistence/FilePath+Symlinks.swift
new file mode 100644
index 000000000..fcf90d332
--- /dev/null
+++ b/Sources/ContainerPersistence/FilePath+Symlinks.swift
@@ -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)
+ }
+ }
+}
diff --git a/Sources/ContainerPersistence/PathUtils.swift b/Sources/ContainerPersistence/PathUtils.swift
index 925c4b08f..d91bb3e4e 100644
--- a/Sources/ContainerPersistence/PathUtils.swift
+++ b/Sources/ContainerPersistence/PathUtils.swift
@@ -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
}
}
}
diff --git a/Sources/ContainerPlugin/ApplicationRoot.swift b/Sources/ContainerPlugin/ApplicationRoot.swift
index 94db22c38..467732791 100644
--- a/Sources/ContainerPlugin/ApplicationRoot.swift
+++ b/Sources/ContainerPlugin/ApplicationRoot.swift
@@ -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
}
diff --git a/Sources/ContainerPlugin/FilePath+Resolve.swift b/Sources/ContainerPlugin/FilePath+Resolve.swift
new file mode 100644
index 000000000..54dd22c28
--- /dev/null
+++ b/Sources/ContainerPlugin/FilePath+Resolve.swift
@@ -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()
+ }
+}
diff --git a/Sources/ContainerPlugin/InstallRoot.swift b/Sources/ContainerPlugin/InstallRoot.swift
index 8f39c790b..01578b5de 100644
--- a/Sources/ContainerPlugin/InstallRoot.swift
+++ b/Sources/ContainerPlugin/InstallRoot.swift
@@ -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
}
diff --git a/Sources/ContainerPlugin/LogRoot.swift b/Sources/ContainerPlugin/LogRoot.swift
index 2550a887b..51630d249 100644
--- a/Sources/ContainerPlugin/LogRoot.swift
+++ b/Sources/ContainerPlugin/LogRoot.swift
@@ -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
}
diff --git a/Sources/ContainerVersion/Bundle+AppBundle.swift b/Sources/ContainerVersion/Bundle+AppBundle.swift
index 5028897e1..9c4941a41 100644
--- a/Sources/ContainerVersion/Bundle+AppBundle.swift
+++ b/Sources/ContainerVersion/Bundle+AppBundle.swift
@@ -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))
}
}
diff --git a/Sources/ContainerVersion/CommandLine+Executable.swift b/Sources/ContainerVersion/CommandLine+Executable.swift
index c48cbe573..67d854fe4 100644
--- a/Sources/ContainerVersion/CommandLine+Executable.swift
+++ b/Sources/ContainerVersion/CommandLine+Executable.swift
@@ -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)
}
}
diff --git a/Sources/ContainerVersion/ReleaseVersion.swift b/Sources/ContainerVersion/ReleaseVersion.swift
index 4ae98d6a4..99e9ad2b9 100644
--- a/Sources/ContainerVersion/ReleaseVersion.swift
+++ b/Sources/ContainerVersion/ReleaseVersion.swift
@@ -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"
}
diff --git a/Sources/Plugins/CoreImages/ImagesHelper.swift b/Sources/Plugins/CoreImages/ImagesHelper.swift
index 6ba3318b5..3eea9cc0d 100644
--- a/Sources/Plugins/CoreImages/ImagesHelper.swift
+++ b/Sources/Plugins/CoreImages/ImagesHelper.swift
@@ -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)
diff --git a/Tests/ContainerPersistenceTests/FilePathSymlinksTests.swift b/Tests/ContainerPersistenceTests/FilePathSymlinksTests.swift
new file mode 100644
index 000000000..26f307de4
--- /dev/null
+++ b/Tests/ContainerPersistenceTests/FilePathSymlinksTests.swift
@@ -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)
+ }
+}
diff --git a/Tests/ContainerPluginTests/FilePath+ResolveTests.swift b/Tests/ContainerPluginTests/FilePath+ResolveTests.swift
new file mode 100644
index 000000000..b9d933491
--- /dev/null
+++ b/Tests/ContainerPluginTests/FilePath+ResolveTests.swift
@@ -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"))
+ }
+}
diff --git a/Tests/ContainerPluginTests/RootPathTests.swift b/Tests/ContainerPluginTests/RootPathTests.swift
new file mode 100644
index 000000000..8efcae926
--- /dev/null
+++ b/Tests/ContainerPluginTests/RootPathTests.swift
@@ -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)
+ }
+}
diff --git a/Tests/ContainerVersionTests/Bundle+AppBundleTests.swift b/Tests/ContainerVersionTests/Bundle+AppBundleTests.swift
new file mode 100644
index 000000000..263789825
--- /dev/null
+++ b/Tests/ContainerVersionTests/Bundle+AppBundleTests.swift
@@ -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("".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("".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)
+ }
+}
diff --git a/Tests/ContainerPluginTests/CommandLine+ExecutableTest.swift b/Tests/ContainerVersionTests/CommandLine+ExecutableTests.swift
similarity index 53%
rename from Tests/ContainerPluginTests/CommandLine+ExecutableTest.swift
rename to Tests/ContainerVersionTests/CommandLine+ExecutableTests.swift
index d9e913c8d..cdda4fe89 100644
--- a/Tests/ContainerPluginTests/CommandLine+ExecutableTest.swift
+++ b/Tests/ContainerVersionTests/CommandLine+ExecutableTests.swift
@@ -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)
}
}