diff --git a/Sources/ContainerCommands/System/SystemStart.swift b/Sources/ContainerCommands/System/SystemStart.swift index 16dadb555..236a8f254 100644 --- a/Sources/ContainerCommands/System/SystemStart.swift +++ b/Sources/ContainerCommands/System/SystemStart.swift @@ -73,7 +73,15 @@ extension Application { public init() {} public func run() async throws { - try ConfigurationLoader.copyConfigurationToReadOnly(to: appRoot) + do { + try ConfigurationLoader.copyConfigurationToReadOnly(to: appRoot) + } catch { + throw ContainerizationError( + .invalidArgument, + message: + "cannot prepare configuration under app-root \(appRoot): path must be writable (\(error.localizedDescription))" + ) + } // 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 @@ -103,8 +111,7 @@ extension Application { } let apiServerDataPath = appRoot.appending(FilePath.Component("apiserver")) - let apiServerDataURL = URL(fileURLWithPath: apiServerDataPath.string) - try FileManager.default.createDirectory(at: apiServerDataURL, withIntermediateDirectories: true) + try Self.ensureWritableDirectory(at: apiServerDataPath) var env = PluginLoader.filterEnvironment() env[ApplicationRoot.environmentName] = appRoot.string @@ -124,7 +131,15 @@ extension Application { let plistPath = apiServerDataPath.appending(FilePath.Component("apiserver.plist")) let plistURL = URL(fileURLWithPath: plistPath.string) let data = try plist.encode() - try data.write(to: plistURL) + do { + try data.write(to: plistURL) + } catch { + throw ContainerizationError( + .invalidArgument, + message: + "cannot write apiserver launchd plist at \(plistPath): app-root must be writable (\(error.localizedDescription))" + ) + } log.info("Launching container-apiserver...") try ServiceManager.register(plistPath: plistURL.path) @@ -222,5 +237,22 @@ extension Application { return false } } + + /// Create `path` if needed, surfacing a clear error when the app-root is not writable. + /// + /// Prefer this over a bare `FileManager` call so permission failures become a normal + /// CLI error instead of an opaque Cocoa error (see apple/container#1802). + static func ensureWritableDirectory(at path: FilePath) throws { + let url = URL(fileURLWithPath: path.string) + do { + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + } catch { + throw ContainerizationError( + .invalidArgument, + message: + "cannot create application data directory at \(path): app-root must be writable (\(error.localizedDescription))" + ) + } + } } } diff --git a/Tests/ContainerCommandsTests/SystemStartAppRootTests.swift b/Tests/ContainerCommandsTests/SystemStartAppRootTests.swift new file mode 100644 index 000000000..b1df3eaa7 --- /dev/null +++ b/Tests/ContainerCommandsTests/SystemStartAppRootTests.swift @@ -0,0 +1,60 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025-2026 Apple Inc. and the container project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import ContainerizationError +import Foundation +import SystemPackage +import Testing + +@testable import ContainerCommands + +struct SystemStartAppRootTests { + @Test + func ensureWritableDirectorySucceedsForWritableParent() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("container-approot-ok-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let child = FilePath(root.path).appending("apiserver") + try Application.SystemStart.ensureWritableDirectory(at: child) + #expect(FileManager.default.fileExists(atPath: child.string)) + } + + @Test + func ensureWritableDirectoryThrowsClearErrorForReadOnlyParent() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("container-approot-ro-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { + try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: root.path) + try? FileManager.default.removeItem(at: root) + } + try FileManager.default.setAttributes([.posixPermissions: 0o555], ofItemAtPath: root.path) + + let child = FilePath(root.path).appending("apiserver") + do { + try Application.SystemStart.ensureWritableDirectory(at: child) + Issue.record("expected ensureWritableDirectory to throw for a read-only app-root") + } catch let error as ContainerizationError { + let message = error.description + #expect(message.contains("app-root must be writable")) + #expect(message.contains(child.string) || message.contains("apiserver")) + } catch { + Issue.record("expected ContainerizationError, got \(error)") + } + } +}