mirror of
https://github.com/apple/container.git
synced 2026-07-12 04:27:05 +00:00
@@ -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<CodingKeys>,
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
let absoluteHostPath = FilePathOps.absolutePath(FilePath(hostPath))
|
||||
|
||||
// Convert host path to absolute path for consistency
|
||||
let hostURL = URL(fileURLWithPath: hostPath)
|
||||
let absoluteHostPath = hostURL.absoluteURL.path
|
||||
|
||||
// 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
|
||||
)
|
||||
|
||||
|
||||
@@ -1000,9 +1000,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
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user