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) } }