diff --git a/Package.resolved b/Package.resolved index 6d19fb55d..3fdbb381c 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "d9d032f8b1ad94cf9006bd4b441ce1dad52802b04b1d7f7b30baedd32cee0e8b", + "originHash" : "92e9d30c1bb3bc52ba8c855f83a17db4752940b9438a9f5d20fe5b7c1328aae0", "pins" : [ { "identity" : "async-http-client", @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/containerization.git", "state" : { - "revision" : "9205a766302a18e06a0ce43f8b7e483625e3e50f", - "version" : "0.33.1" + "revision" : "2550dd49f1890702f6fe0171212050bbce9d3825", + "version" : "0.33.2" } }, { diff --git a/Package.swift b/Package.swift index 2cc2e7092..f6dda67b9 100644 --- a/Package.swift +++ b/Package.swift @@ -23,7 +23,7 @@ import PackageDescription let releaseVersion = ProcessInfo.processInfo.environment["RELEASE_VERSION"] ?? "0.0.0" let gitCommit = ProcessInfo.processInfo.environment["GIT_COMMIT"] ?? "unspecified" let builderShimVersion = "0.12.0" -let scVersion = "0.33.1" +let scVersion = "0.33.2" let package = Package( name: "container", diff --git a/Sources/ContainerResource/Container/PublishSocket.swift b/Sources/ContainerResource/Container/PublishSocket.swift index 08ea752bf..3d53cd475 100644 --- a/Sources/ContainerResource/Container/PublishSocket.swift +++ b/Sources/ContainerResource/Container/PublishSocket.swift @@ -14,27 +14,148 @@ // limitations under the License. //===----------------------------------------------------------------------===// +import ContainerizationError import Foundation import SystemPackage /// Represents a socket that should be published from container to host. +/// +/// - Deprecated: New for 1.0.0, path types changed from `URL` to `FilePath`. +/// - Note: Decoder handles `FilePath` and `URL` for persistent data compatibility; +/// this compatibility will be removed in a later release. public struct PublishSocket: Sendable, Codable { - /// The path to the socket in the container. - public var containerPath: URL + /// Absolute path to the socket inside the container. + public var containerPath: FilePath - /// The path where the socket should appear on the host. - public var hostPath: URL + /// Absolute path where the socket appears on the host. + public var hostPath: FilePath /// File permissions for the socket on the host. public var permissions: FilePermissions? + /// Creates a `PublishSocket` with validated absolute paths. + /// + /// - Parameters: + /// - containerPath: Absolute path to the socket inside the container. + /// Must begin with `/`. + /// - hostPath: Absolute path where the socket appears on the host. + /// Must begin with `/`. + /// - permissions: File permissions applied to the socket on the host. + /// - Throws: `ContainerizationError` with code `.invalidArgument` if + /// either path is not absolute. public init( - containerPath: URL, - hostPath: URL, + containerPath: FilePath, + hostPath: FilePath, permissions: FilePermissions? = nil - ) { + ) throws { + guard containerPath.isAbsolute else { + throw ContainerizationError( + .invalidArgument, + message: "containerPath must be absolute: \(containerPath)" + ) + } + guard hostPath.isAbsolute else { + throw ContainerizationError( + .invalidArgument, + message: "hostPath must be absolute: \(hostPath)" + ) + } self.containerPath = containerPath self.hostPath = hostPath self.permissions = permissions } + + private enum CodingKeys: String, CodingKey { + case containerPath + case hostPath + case permissions + } + + /// Encodes each path as its plain absolute string (e.g. `"/var/run/docker.sock"`). + /// + /// Pre-1.0 wire-format change from the prior `URL`-typed encoding which + /// emitted `URL.absoluteString` (`"file:///var/run/docker.sock"`). The + /// decoder accepts both forms for compatibility with persisted bundles + /// from earlier releases; that compatibility will be removed in a later + /// release. + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(containerPath.string, forKey: .containerPath) + try container.encode(hostPath.string, forKey: .hostPath) + try container.encodeIfPresent(permissions, forKey: .permissions) + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let containerPath = try Self.decodePath(from: container, forKey: .containerPath) + let hostPath = try Self.decodePath(from: container, forKey: .hostPath) + let permissions = try container.decodeIfPresent(FilePermissions.self, forKey: .permissions) + do { + try self.init( + containerPath: containerPath, + hostPath: hostPath, + permissions: permissions + ) + } catch let error as ContainerizationError { + throw DecodingError.dataCorruptedError( + forKey: .containerPath, + in: container, + debugDescription: String(describing: error) + ) + } + } + + /// Decodes a `FilePath` accepting either the new plain-path form + /// (`"/var/run/docker.sock"`) or the legacy file-URL form emitted by + /// older releases (`"file:///var/run/docker.sock"`). Throws + /// `DecodingError.dataCorrupted` on a malformed file URL, empty input, or a + /// non-absolute path — validating decoded paths here guards against + /// manually edited or corrupt persisted configs, complementing the + /// by-construction check in `init(containerPath:hostPath:permissions:)`. + private static func decodePath( + from container: KeyedDecodingContainer, + forKey key: CodingKeys + ) throws -> FilePath { + let raw = try container.decode(String.self, forKey: key) + + let path: String + if raw.hasPrefix("file:") { + guard let url = URL(string: raw), url.isFileURL else { + throw DecodingError.dataCorruptedError( + forKey: key, + in: container, + debugDescription: "malformed file URL: \(raw)" + ) + } + if let host = url.host(), !host.isEmpty, host != "localhost" { + throw DecodingError.dataCorruptedError( + forKey: key, + in: container, + debugDescription: "file URL host must be empty or 'localhost': \(raw)" + ) + } + path = url.path(percentEncoded: false) + } else { + path = raw + } + + guard !path.isEmpty else { + throw DecodingError.dataCorruptedError( + forKey: key, + in: container, + debugDescription: "decoded socket path is empty: \(raw)" + ) + } + + let filePath = FilePath(path) + guard filePath.isAbsolute else { + throw DecodingError.dataCorruptedError( + forKey: key, + in: container, + debugDescription: "decoded socket path must be absolute: \(raw)" + ) + } + + return filePath + } } diff --git a/Sources/Services/ContainerAPIService/Client/Parser.swift b/Sources/Services/ContainerAPIService/Client/Parser.swift index 7cbe7e0b0..61779498e 100644 --- a/Sources/Services/ContainerAPIService/Client/Parser.swift +++ b/Sources/Services/ContainerAPIService/Client/Parser.swift @@ -22,6 +22,7 @@ import ContainerizationExtras import ContainerizationOCI import ContainerizationOS import Foundation +import SystemPackage /// A parsed volume specification from user input public struct ParsedVolume { @@ -739,7 +740,6 @@ public struct Parser { let hostPath = String(parts[0]) let containerPath = String(parts[1]) - // Validate paths are not empty if hostPath.isEmpty { throw ContainerizationError( .invalidArgument, message: "host socket path cannot be empty") @@ -749,28 +749,18 @@ public struct Parser { .invalidArgument, message: "container socket path cannot be empty") } - // Ensure container path must start with / - if !containerPath.hasPrefix("/") { - throw ContainerizationError( - .invalidArgument, - message: "container socket path must be absolute: \(containerPath)") - } - - // Convert host path to absolute path for consistency - let hostURL = URL(fileURLWithPath: hostPath) - let absoluteHostPath = hostURL.absoluteURL.path + let absoluteHostPath = FilePathOps.absolutePath(FilePath(hostPath)) - // Check if host socket already exists and might be in use - if FileManager.default.fileExists(atPath: absoluteHostPath) { + if FileManager.default.fileExists(atPath: absoluteHostPath.string) { do { - let attrs = try FileManager.default.attributesOfItem(atPath: absoluteHostPath) + let attrs = try FileManager.default.attributesOfItem(atPath: absoluteHostPath.string) if let fileType = attrs[.type] as? FileAttributeType, fileType == .typeSocket { throw ContainerizationError( .invalidArgument, message: "host socket \(absoluteHostPath) already exists and may be in use") } // If it exists but is not a socket, we can remove it and create socket - try FileManager.default.removeItem(atPath: absoluteHostPath) + try FileManager.default.removeItem(atPath: absoluteHostPath.string) } catch let error as ContainerizationError { throw error } catch { @@ -778,17 +768,15 @@ public struct Parser { } } - // Create host directory if it doesn't exist - let hostDir = hostURL.deletingLastPathComponent() - if !FileManager.default.fileExists(atPath: hostDir.path) { + let hostDir = absoluteHostPath.removingLastComponent() + if !FileManager.default.fileExists(atPath: hostDir.string) { try FileManager.default.createDirectory( - at: hostDir, withIntermediateDirectories: true) + atPath: hostDir.string, withIntermediateDirectories: true) } - // Create and return PublishSocket object with validated paths - return PublishSocket( - containerPath: URL(fileURLWithPath: containerPath), - hostPath: URL(fileURLWithPath: absoluteHostPath), + return try PublishSocket( + containerPath: FilePath(containerPath), + hostPath: absoluteHostPath, permissions: nil ) diff --git a/Sources/Services/RuntimeLinux/Server/RuntimeService.swift b/Sources/Services/RuntimeLinux/Server/RuntimeService.swift index 187de7af6..d4d468108 100644 --- a/Sources/Services/RuntimeLinux/Server/RuntimeService.swift +++ b/Sources/Services/RuntimeLinux/Server/RuntimeService.swift @@ -999,9 +999,10 @@ public actor RuntimeService { } for publishedSocket in config.publishedSockets { + // UnixSocketConfiguration (Containerization) takes URL; convert from FilePath at the boundary. let socketConfig = UnixSocketConfiguration( - source: publishedSocket.containerPath, - destination: publishedSocket.hostPath, + source: URL(filePath: publishedSocket.containerPath.string), + destination: URL(filePath: publishedSocket.hostPath.string), permissions: publishedSocket.permissions, direction: .outOf ) diff --git a/Tests/ContainerResourceTests/PublishSocketTests.swift b/Tests/ContainerResourceTests/PublishSocketTests.swift new file mode 100644 index 000000000..6d40d2fdb --- /dev/null +++ b/Tests/ContainerResourceTests/PublishSocketTests.swift @@ -0,0 +1,241 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationError +import Foundation +import SystemPackage +import Testing + +@testable import ContainerResource + +/// Tests covering the custom `Codable` implementation and validating +/// initializer on ``PublishSocket``. +/// +/// `containerPath` and `hostPath` were migrated from `URL` to `FilePath`. The +/// wire format was simultaneously changed from `URL.absoluteString` +/// (e.g. `"file:///var/run/docker.sock"`) to the plain absolute path string +/// (e.g. `"/var/run/docker.sock"`). The decoder retains compatibility with +/// the legacy file-URL form so persisted bundles from earlier releases +/// continue to load. +struct PublishSocketTests { + // MARK: - init validation + + @Test + func testInitAcceptsAbsolutePaths() throws { + let socket = try PublishSocket( + containerPath: FilePath("/var/run/docker.sock"), + hostPath: FilePath("/Users/me/docker.sock") + ) + #expect(socket.containerPath == FilePath("/var/run/docker.sock")) + #expect(socket.hostPath == FilePath("/Users/me/docker.sock")) + #expect(socket.permissions == nil) + } + + @Test + func testInitRejectsRelativeContainerPath() { + #expect(throws: ContainerizationError.self) { + try PublishSocket( + containerPath: FilePath("relative/path.sock"), + hostPath: FilePath("/host.sock") + ) + } + } + + @Test + func testInitRejectsRelativeHostPath() { + #expect(throws: ContainerizationError.self) { + try PublishSocket( + containerPath: FilePath("/var/run/docker.sock"), + hostPath: FilePath("relative/host.sock") + ) + } + } + + // MARK: - Encoding (plain absolute path) + + @Test + func testEncodeProducesPlainAbsolutePath() throws { + let socket = try PublishSocket( + containerPath: FilePath("/var/run/docker.sock"), + hostPath: FilePath("/Users/me/docker.sock") + ) + let json = try JSONEncoder().encode(socket) + let decoded = try #require(try JSONSerialization.jsonObject(with: json) as? [String: Any]) + #expect(decoded["containerPath"] as? String == "/var/run/docker.sock") + #expect(decoded["hostPath"] as? String == "/Users/me/docker.sock") + #expect(decoded["permissions"] == nil) + } + + @Test + func testEncodeDoesNotPercentEncode() throws { + // Plain-path encoding preserves spaces and special characters verbatim + // (no URL percent-encoding layer). + let socket = try PublishSocket( + containerPath: FilePath("/tmp/a b.sock"), + hostPath: FilePath("/tmp/dir with spaces/sock") + ) + let json = try JSONEncoder().encode(socket) + let decoded = try #require(try JSONSerialization.jsonObject(with: json) as? [String: Any]) + #expect(decoded["containerPath"] as? String == "/tmp/a b.sock") + #expect(decoded["hostPath"] as? String == "/tmp/dir with spaces/sock") + } + + // MARK: - Decoding (canonical plain-path form) + + @Test + func testDecodePlainAbsolutePath() throws { + let json = """ + {"containerPath":"/var/run/docker.sock","hostPath":"/Users/me/docker.sock"} + """.data(using: .utf8)! + let socket = try JSONDecoder().decode(PublishSocket.self, from: json) + #expect(socket.containerPath == FilePath("/var/run/docker.sock")) + #expect(socket.hostPath == FilePath("/Users/me/docker.sock")) + } + + // MARK: - Decoding (legacy file-URL form, compat) + + @Test + func testDecodeLegacyFileURLForm() throws { + let json = """ + {"containerPath":"file:///var/run/docker.sock","hostPath":"file:///Users/me/docker.sock"} + """.data(using: .utf8)! + let socket = try JSONDecoder().decode(PublishSocket.self, from: json) + #expect(socket.containerPath == FilePath("/var/run/docker.sock")) + #expect(socket.hostPath == FilePath("/Users/me/docker.sock")) + #expect(socket.permissions == nil) + } + + @Test + func testDecodeLegacyFileURLResolvesPercentEncoding() throws { + // Persisted bundles created via `URL(fileURLWithPath:)` percent-encode + // spaces; decoding must yield the original literal path. + let json = """ + {"containerPath":"file:///tmp/a%20b.sock","hostPath":"file:///tmp/x%2Fy.sock"} + """.data(using: .utf8)! + let socket = try JSONDecoder().decode(PublishSocket.self, from: json) + #expect(socket.containerPath == FilePath("/tmp/a b.sock")) + // `%2F` decodes to a literal `/` inside the path component. + #expect(socket.hostPath == FilePath("/tmp/x/y.sock")) + } + + @Test + func testDecodeLegacyFileURLWithLocalhostHost() throws { + let json = """ + {"containerPath":"file://localhost/var/run/docker.sock","hostPath":"file:///host.sock"} + """.data(using: .utf8)! + let socket = try JSONDecoder().decode(PublishSocket.self, from: json) + #expect(socket.containerPath == FilePath("/var/run/docker.sock")) + #expect(socket.hostPath == FilePath("/host.sock")) + } + + // MARK: - Round-trip + + @Test + func testRoundTrip() throws { + let original = try PublishSocket( + containerPath: FilePath("/var/run/docker.sock"), + hostPath: FilePath("/tmp/socket with spaces.sock"), + permissions: FilePermissions(rawValue: 0o660) + ) + let encoder = JSONEncoder() + let decoder = JSONDecoder() + let data = try encoder.encode(original) + let decoded = try decoder.decode(PublishSocket.self, from: data) + #expect(decoded.containerPath == original.containerPath) + #expect(decoded.hostPath == original.hostPath) + #expect(decoded.permissions == original.permissions) + } + + // MARK: - Decoding errors + + @Test + func testDecodeEmptyStringThrows() { + let json = """ + {"containerPath":"","hostPath":"/host.sock"} + """.data(using: .utf8)! + #expect(throws: DecodingError.self) { + try JSONDecoder().decode(PublishSocket.self, from: json) + } + } + + @Test + func testDecodeFileColonOnlyThrows() { + // `"file:"` parses as a URL but yields an empty path; reject loudly + // rather than silently producing `FilePath("")`. + let json = """ + {"containerPath":"file:","hostPath":"/host.sock"} + """.data(using: .utf8)! + #expect(throws: DecodingError.self) { + try JSONDecoder().decode(PublishSocket.self, from: json) + } + } + + @Test + func testDecodeFileSchemeNoPathThrows() { + let json = """ + {"containerPath":"file://","hostPath":"/host.sock"} + """.data(using: .utf8)! + #expect(throws: DecodingError.self) { + try JSONDecoder().decode(PublishSocket.self, from: json) + } + } + + @Test + func testDecodeRelativePathThrows() { + // Reject non-absolute paths. `decodePath` validates absoluteness at the + // decode layer (and `init` enforces it by construction), surfacing the + // failure as a `DecodingError`. + let json = """ + {"containerPath":"relative/path.sock","hostPath":"/host.sock"} + """.data(using: .utf8)! + #expect(throws: DecodingError.self) { + try JSONDecoder().decode(PublishSocket.self, from: json) + } + } + + @Test + func testDecodeRelativeHostPathThrows() { + // A relative `hostPath` is likewise rejected at the decode layer. + let json = """ + {"containerPath":"/var/run/docker.sock","hostPath":"relative/host.sock"} + """.data(using: .utf8)! + #expect(throws: DecodingError.self) { + try JSONDecoder().decode(PublishSocket.self, from: json) + } + } + + @Test + func testDecodeNonLocalHostFileURLThrows() { + // file URLs with a non-empty / non-localhost host are unsafe to + // interpret as a local path. + let json = """ + {"containerPath":"file://example.com/etc/passwd","hostPath":"/host.sock"} + """.data(using: .utf8)! + #expect(throws: DecodingError.self) { + try JSONDecoder().decode(PublishSocket.self, from: json) + } + } + + @Test + func testDecodeMissingRequiredKeyThrows() { + let json = """ + {"hostPath":"/host.sock"} + """.data(using: .utf8)! + #expect(throws: DecodingError.self) { + try JSONDecoder().decode(PublishSocket.self, from: json) + } + } +}