mirror of
https://github.com/apple/container.git
synced 2026-08-24 02:24:19 -05:00
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:
+9
-1
@@ -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: [],
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+18
-7
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user