Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions Sources/Containerization/DNSConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@
// limitations under the License.
//===----------------------------------------------------------------------===//

import ContainerizationError
import ContainerizationExtras

/// DNS configuration for a container. The values will be used to
/// construct /etc/resolv.conf for a given container.
public struct DNS: Sendable {
Expand Down Expand Up @@ -41,6 +44,26 @@ public struct DNS: Sendable {
self.searchDomains = searchDomains
self.options = options
}

/// Validates the DNS configuration.
///
/// Ensures that all nameserver entries are valid IPv4 or IPv6 addresses.
/// Arbitrary hostnames are not permitted as nameservers.
///
/// - Throws: ``ContainerizationError`` with code `.invalidArgument` if
/// any nameserver is not a valid IP address.
public func validate() throws {
for nameserver in nameservers {
let isValidIPv4 = (try? IPv4Address(nameserver)) != nil
let isValidIPv6 = (try? IPv6Address(nameserver)) != nil
if !isValidIPv4 && !isValidIPv6 {
throw ContainerizationError(
.invalidArgument,
message: "nameserver '\(nameserver)' is not a valid IPv4 or IPv6 address"
)
}
}
}
}

extension DNS {
Expand Down
6 changes: 2 additions & 4 deletions Sources/Containerization/Image/InitImage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,8 @@ extension InitImage {
var result = try writer.create(from: rootfs)
let layerDescriptor = Descriptor(mediaType: ContainerizationOCI.MediaTypes.imageLayerGzip, digest: result.digest.digestString, size: result.size)

// TODO: compute and fill in the correct diffID for the above layer
// We currently put in the sha of the fully compressed layer, this needs to be replaced with
// the sha of the uncompressed layer.
let rootfsConfig = ContainerizationOCI.Rootfs(type: "layers", diffIDs: [result.digest.digestString])
let diffID = try ContentWriter.diffID(of: rootfs)
let rootfsConfig = ContainerizationOCI.Rootfs(type: "layers", diffIDs: [diffID.digestString])
let runtimeConfig = ContainerizationOCI.ImageConfig(labels: labels)
let imageConfig = ContainerizationOCI.Image(architecture: platform.architecture, os: platform.os, config: runtimeConfig, rootfs: rootfsConfig)
result = try writer.create(from: imageConfig)
Expand Down
1 change: 1 addition & 0 deletions Sources/Containerization/Vminitd.swift
Original file line number Diff line number Diff line change
Expand Up @@ -487,6 +487,7 @@ extension Vminitd {

/// Configure DNS within the sandbox's environment.
public func configureDNS(config: DNS, location: String) async throws {
try config.validate()
_ = try await client.configureDns(
.with {
$0.location = location
Expand Down
110 changes: 110 additions & 0 deletions Sources/ContainerizationOCI/Content/ContentWriter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
// limitations under the License.
//===----------------------------------------------------------------------===//

import Compression
import ContainerizationError
import Crypto
import Foundation
Expand Down Expand Up @@ -60,6 +61,110 @@ public class ContentWriter {
return try self.write(data)
}

/// Computes the SHA256 digest of the uncompressed content of a gzip file.
///
/// Per the OCI Image Specification, a DiffID is the SHA256 digest of the
/// uncompressed layer content. This method decompresses the gzip data and
/// hashes the result using a streaming approach for memory efficiency.
///
/// - Parameter url: The URL of the gzip-compressed file.
/// - Returns: The SHA256 digest of the uncompressed content.
public static func diffID(of url: URL) throws -> SHA256.Digest {
let compressedData = try Data(contentsOf: url)
let decompressed = try Self.decompressGzip(compressedData)
return SHA256.hash(data: decompressed)
}

/// Decompresses gzip data by stripping the gzip header and feeding the raw
/// deflate stream to Apple's Compression framework.
private static func decompressGzip(_ data: Data) throws -> Data {
let headerSize = try Self.gzipHeaderSize(data)

var output = Data()
let bufferSize = 65_536
let destinationBuffer = UnsafeMutablePointer<UInt8>.allocate(capacity: bufferSize)
defer { destinationBuffer.deallocate() }

try data.withUnsafeBytes { rawBuffer in
guard let sourcePointer = rawBuffer.baseAddress?.assumingMemoryBound(to: UInt8.self) else {
throw ContentWriterError.decompressionFailed
}

let stream = UnsafeMutablePointer<compression_stream>.allocate(capacity: 1)
defer { stream.deallocate() }

var status = compression_stream_init(stream, COMPRESSION_STREAM_DECODE, COMPRESSION_ZLIB)
guard status != COMPRESSION_STATUS_ERROR else {
throw ContentWriterError.decompressionFailed
}
defer { compression_stream_destroy(stream) }

stream.pointee.src_ptr = sourcePointer.advanced(by: headerSize)
stream.pointee.src_size = data.count - headerSize
stream.pointee.dst_ptr = destinationBuffer
stream.pointee.dst_size = bufferSize

repeat {
status = compression_stream_process(stream, 0)

switch status {
case COMPRESSION_STATUS_OK:
let produced = bufferSize - stream.pointee.dst_size
output.append(destinationBuffer, count: produced)
stream.pointee.dst_ptr = destinationBuffer
stream.pointee.dst_size = bufferSize

case COMPRESSION_STATUS_END:
let produced = bufferSize - stream.pointee.dst_size
if produced > 0 {
output.append(destinationBuffer, count: produced)
}

default:
throw ContentWriterError.decompressionFailed
}
} while status == COMPRESSION_STATUS_OK
}

return output
}

/// Parses the gzip header to determine where the raw deflate stream begins.
private static func gzipHeaderSize(_ data: Data) throws -> Int {
guard data.count >= 10,
data[data.startIndex] == 0x1f,
data[data.startIndex + 1] == 0x8b
else {
throw ContentWriterError.invalidGzip
}

let start = data.startIndex
let flags = data[start + 3]
var offset = 10

// FEXTRA
if flags & 0x04 != 0 {
guard data.count >= offset + 2 else { throw ContentWriterError.invalidGzip }
let extraLen = Int(data[start + offset]) | (Int(data[start + offset + 1]) << 8)
offset += 2 + extraLen
}
// FNAME
if flags & 0x08 != 0 {
while offset < data.count && data[start + offset] != 0 { offset += 1 }
offset += 1
}
// FCOMMENT
if flags & 0x10 != 0 {
while offset < data.count && data[start + offset] != 0 { offset += 1 }
offset += 1
}
// FHCRC
if flags & 0x02 != 0 { offset += 2 }

guard offset < data.count else { throw ContentWriterError.invalidGzip }
return offset
}

/// Encodes the passed in type as a JSON blob and writes it to the base path.
/// - Parameters:
/// - content: The type to convert to JSON.
Expand All @@ -69,3 +174,8 @@ public class ContentWriter {
return try self.write(data)
}
}

enum ContentWriterError: Error {
case invalidGzip
case decompressionFailed
}
115 changes: 115 additions & 0 deletions Tests/ContainerizationOCITests/DiffIDTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the Containerization 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 Crypto
import Foundation
import Testing

@testable import ContainerizationOCI

struct DiffIDTests {
/// Helper to create a gzip-compressed temporary file from raw data.
private func createGzipFile(content: Data) throws -> URL {
let tempDir = FileManager.default.temporaryDirectory
let rawFile = tempDir.appendingPathComponent(UUID().uuidString)
let gzFile = tempDir.appendingPathComponent(UUID().uuidString + ".gz")
try content.write(to: rawFile)
defer { try? FileManager.default.removeItem(at: rawFile) }

let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/gzip")
process.arguments = ["-k", "-f", rawFile.path]
try process.run()
process.waitUntilExit()

let gzPath = URL(fileURLWithPath: rawFile.path + ".gz")
if FileManager.default.fileExists(atPath: gzPath.path) {
try FileManager.default.moveItem(at: gzPath, to: gzFile)
}
return gzFile
}

@Test func diffIDMatchesUncompressedSHA256() throws {
let content = Data("hello, oci layer content for diffid test".utf8)
let gzFile = try createGzipFile(content: content)
defer { try? FileManager.default.removeItem(at: gzFile) }

let diffID = try ContentWriter.diffID(of: gzFile)
let expected = SHA256.hash(data: content)

#expect(diffID.digestString == expected.digestString)
}

@Test func diffIDIsDeterministic() throws {
let content = Data("deterministic diffid check".utf8)
let gzFile = try createGzipFile(content: content)
defer { try? FileManager.default.removeItem(at: gzFile) }

let first = try ContentWriter.diffID(of: gzFile)
let second = try ContentWriter.diffID(of: gzFile)

#expect(first.digestString == second.digestString)
}

@Test func diffIDRejectsNonGzipData() throws {
let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try Data("this is not gzip".utf8).write(to: tempFile)
defer { try? FileManager.default.removeItem(at: tempFile) }

#expect(throws: ContentWriterError.self) {
try ContentWriter.diffID(of: tempFile)
}
}

@Test func diffIDRejectsEmptyFile() throws {
let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try Data().write(to: tempFile)
defer { try? FileManager.default.removeItem(at: tempFile) }

#expect(throws: ContentWriterError.self) {
try ContentWriter.diffID(of: tempFile)
}
}

@Test func diffIDHandlesLargeContent() throws {
// 1MB of repeating data
let pattern = Data("ABCDEFGHIJKLMNOPQRSTUVWXYZ012345".utf8)
var large = Data()
for _ in 0..<(1_048_576 / pattern.count) {
large.append(pattern)
}
let gzFile = try createGzipFile(content: large)
defer { try? FileManager.default.removeItem(at: gzFile) }

let diffID = try ContentWriter.diffID(of: gzFile)
let expected = SHA256.hash(data: large)

#expect(diffID.digestString == expected.digestString)
}

@Test func diffIDDigestStringFormat() throws {
let content = Data("format test".utf8)
let gzFile = try createGzipFile(content: content)
defer { try? FileManager.default.removeItem(at: gzFile) }

let diffID = try ContentWriter.diffID(of: gzFile)
let digestString = diffID.digestString

#expect(digestString.hasPrefix("sha256:"))
// sha256: prefix + 64 hex chars
#expect(digestString.count == 7 + 64)
}
}
30 changes: 30 additions & 0 deletions Tests/ContainerizationTests/DNSTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,34 @@ struct DNSTests {
let expected = "nameserver 8.8.8.8\n"
#expect(dns.resolvConf == expected)
}

@Test func dnsValidateAcceptsValidIPv4Nameservers() throws {
let dns = DNS(nameservers: ["8.8.8.8", "1.1.1.1"])
#expect(throws: Never.self) { try dns.validate() }
}

@Test func dnsValidateAcceptsValidIPv6Nameservers() throws {
let dns = DNS(nameservers: ["2001:4860:4860::8888", "::1"])
#expect(throws: Never.self) { try dns.validate() }
}

@Test func dnsValidateAcceptsMixedIPv4AndIPv6Nameservers() throws {
let dns = DNS(nameservers: ["8.8.8.8", "2001:4860:4860::8844"])
#expect(throws: Never.self) { try dns.validate() }
}

@Test func dnsValidateAcceptsEmptyNameservers() throws {
let dns = DNS(nameservers: [])
#expect(throws: Never.self) { try dns.validate() }
}

@Test func dnsValidateRejectsHostname() {
let dns = DNS(nameservers: ["dns.example.com"])
#expect(throws: (any Error).self) { try dns.validate() }
}

@Test func dnsValidateRejectsInvalidAddress() {
let dns = DNS(nameservers: ["not-an-ip"])
#expect(throws: (any Error).self) { try dns.validate() }
}
}