Native Builder: DiffKey and Differ Procol (#482)

This PR introduces the `Differ` with methods:

```swift
// Differ protocol
func diff(base: Snapshot?, target: Snapshot) async throws -> Descriptor
func apply(descriptor: Descriptor, to base: Snapshot?) async throws -> Snapshot
```

It also introduces `DiffKey`, which is a MerkeTree based key for fast
diff computations between two dirs
This commit is contained in:
Sidhartha Mani
2025-08-12 11:23:55 -07:00
committed by GitHub
parent adf7186d54
commit 0885cdd6a9
11 changed files with 2118 additions and 115 deletions
+32 -32
View File
@@ -1,5 +1,5 @@
{
"originHash" : "558d21fffd6782751d52b7edf9cecbd8cf0ab511bb7d120bf134cbd23985d350",
"originHash" : "304f2eb9aa5781102011b6bc2d4ed9b8741ed72129eedb288eb820d3cc37821b",
"pins" : [
{
"identity" : "async-http-client",
@@ -22,7 +22,7 @@
{
"identity" : "dns",
"kind" : "remoteSourceControl",
"location" : "https://github.com/Bouke/DNS",
"location" : "https://github.com/Bouke/DNS.git",
"state" : {
"revision" : "78bbd1589890a90b202d11d5f9e1297050cf0eb2",
"version" : "1.2.0"
@@ -31,7 +31,7 @@
{
"identity" : "dnsclient",
"kind" : "remoteSourceControl",
"location" : "https://github.com/orlandos-nl/DNSClient",
"location" : "https://github.com/orlandos-nl/DNSClient.git",
"state" : {
"revision" : "551fbddbf4fa728d4cd86f6a5208fe4f925f0549",
"version" : "2.4.4"
@@ -60,8 +60,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-argument-parser.git",
"state" : {
"revision" : "011f0c765fb46d9cac61bca19be0527e99c98c8b",
"version" : "1.5.1"
"revision" : "309a47b2b1d9b5e991f36961c983ecec72275be3",
"version" : "1.6.1"
}
},
{
@@ -69,8 +69,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-asn1.git",
"state" : {
"revision" : "a54383ada6cecde007d374f58f864e29370ba5c3",
"version" : "1.3.2"
"revision" : "f70225981241859eb4aa1a18a75531d26637c8cc",
"version" : "1.4.0"
}
},
{
@@ -96,8 +96,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-certificates.git",
"state" : {
"revision" : "999fd70c7803da89f3904d635a6815a2a7cd7585",
"version" : "1.10.0"
"revision" : "c059d9c9d08d6654b9a92dda93d9049a278964c6",
"version" : "1.12.0"
}
},
{
@@ -105,8 +105,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-collections.git",
"state" : {
"revision" : "c1805596154bb3a265fd91b8ac0c4433b4348fb0",
"version" : "1.2.0"
"revision" : "8c0c0a8b49e080e54e5e328cc552821ff07cd341",
"version" : "1.2.1"
}
},
{
@@ -114,17 +114,17 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-crypto.git",
"state" : {
"revision" : "e8d6eba1fef23ae5b359c46b03f7d94be2f41fed",
"version" : "3.12.3"
"revision" : "334e682869394ee239a57dbe9262bff3cd9495bd",
"version" : "3.14.0"
}
},
{
"identity" : "swift-docc-plugin",
"kind" : "remoteSourceControl",
"location" : "https://github.com/swiftlang/swift-docc-plugin",
"location" : "https://github.com/swiftlang/swift-docc-plugin.git",
"state" : {
"revision" : "d1691545d53581400b1de9b0472d45eb25c19fed",
"version" : "1.4.4"
"revision" : "3e4f133a77e644a5812911a0513aeb7288b07d06",
"version" : "1.4.5"
}
},
{
@@ -141,8 +141,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-http-structured-headers.git",
"state" : {
"revision" : "db6eea3692638a65e2124990155cd220c2915903",
"version" : "1.3.0"
"revision" : "1625f271afb04375bf48737a5572613248d0e7a0",
"version" : "1.4.0"
}
},
{
@@ -159,8 +159,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-log.git",
"state" : {
"revision" : "3d8596ed08bd13520157f0355e35caed215ffbfa",
"version" : "1.6.3"
"revision" : "ce592ae52f982c847a4efc0dd881cc9eb32d29f2",
"version" : "1.6.4"
}
},
{
@@ -168,8 +168,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio.git",
"state" : {
"revision" : "34d486b01cd891297ac615e40d5999536a1e138d",
"version" : "2.83.0"
"revision" : "1c30f0f2053b654e3d1302492124aa6d242cdba7",
"version" : "2.86.0"
}
},
{
@@ -177,8 +177,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio-extras.git",
"state" : {
"revision" : "145db1962f4f33a4ea07a32e751d5217602eea29",
"version" : "1.28.0"
"revision" : "a55c3dd3a81d035af8a20ce5718889c0dcab073d",
"version" : "1.29.0"
}
},
{
@@ -186,8 +186,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio-http2.git",
"state" : {
"revision" : "4281466512f63d1bd530e33f4aa6993ee7864be0",
"version" : "1.36.0"
"revision" : "5e9e99ec96c53bc2c18ddd10c1e25a3cd97c55e5",
"version" : "1.38.0"
}
},
{
@@ -195,8 +195,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio-ssl.git",
"state" : {
"revision" : "4b38f35946d00d8f6176fe58f96d83aba64b36c7",
"version" : "2.31.0"
"revision" : "385f5bd783ffbfff46b246a7db7be8e4f04c53bd",
"version" : "2.33.0"
}
},
{
@@ -204,8 +204,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio-transport-services.git",
"state" : {
"revision" : "cd1e89816d345d2523b11c55654570acd5cd4c56",
"version" : "1.24.0"
"revision" : "e645014baea2ec1c2db564410c51a656cf47c923",
"version" : "1.25.1"
}
},
{
@@ -249,8 +249,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-system.git",
"state" : {
"revision" : "61e4ca4b81b9e09e2ec863b00c340eb13497dac6",
"version" : "1.5.0"
"revision" : "890830fff1a577dc83134890c7984020c5f6b43b",
"version" : "1.6.2"
}
}
],
+8 -11
View File
@@ -44,7 +44,6 @@ let package = Package(
.library(name: "ContainerBuildExecutor", targets: ["ContainerBuildExecutor"]),
.library(name: "ContainerBuildCache", targets: ["ContainerBuildCache"]),
.library(name: "ContainerBuildSnapshotter", targets: ["ContainerBuildSnapshotter"]),
.library(name: "ContainerBuildDiffer", targets: ["ContainerBuildDiffer"]),
.library(name: "ContainerBuildParser", targets: ["ContainerBuildParser"]),
],
dependencies: [
@@ -222,6 +221,7 @@ let package = Package(
.product(name: "Containerization", package: "containerization"),
.product(name: "ContainerizationOCI", package: "containerization"),
.product(name: "ContainerizationOS", package: "containerization"),
.product(name: "ContainerizationArchive", package: "containerization"),
.product(name: "ArgumentParser", package: "swift-argument-parser"),
"ContainerImagesServiceClient",
"ContainerNetworkService",
@@ -293,19 +293,14 @@ let package = Package(
),
.target(
name: "ContainerBuildSnapshotter",
dependencies: ["ContainerBuildIR"],
path: "Sources/NativeBuilder/ContainerBuildSnapshotter",
swiftSettings: [
.enableExperimentalFeature("StrictConcurrency")
]
),
.target(
name: "ContainerBuildDiffer",
dependencies: [
"ContainerBuildIR",
"ContainerBuildSnapshotter",
.product(name: "ContainerizationOCI", package: "containerization"),
.product(name: "ContainerizationArchive", package: "containerization"),
.product(name: "Crypto", package: "swift-crypto"),
"ContainerClient",
],
path: "Sources/NativeBuilder/ContainerBuildDiffer",
path: "Sources/NativeBuilder/ContainerBuildSnapshotter",
swiftSettings: [
.enableExperimentalFeature("StrictConcurrency")
]
@@ -328,6 +323,8 @@ let package = Package(
"ContainerBuildCache",
"ContainerBuildReporting",
"ContainerBuildParser",
"ContainerBuildSnapshotter",
"ContainerClient",
]
),
.target(
@@ -1,72 +0,0 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved.
//
// 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 ContainerBuildIR
import ContainerBuildSnapshotter
import Foundation
/// A protocol for computing differences between filesystem snapshots.
public protocol Differ: Sendable {
/// Compute the difference between two snapshots.
///
/// - Parameters:
/// - from: The base snapshot
/// - to: The target snapshot
/// - Returns: The filesystem changes needed to transform `from` into `to`
func diff(from: Snapshot?, to: Snapshot) async throws -> FilesystemChanges
/// Compute a digest representing the state of a filesystem path.
///
/// - Parameter path: The filesystem path to digest
/// - Returns: A digest representing the current state
func digest(path: String) async throws -> Digest
}
/// A basic in-memory differ implementation.
public struct MemoryDiffer: Differ {
public init() {}
public func diff(from base: Snapshot?, to target: Snapshot) async throws -> FilesystemChanges {
// Stub implementation
// In a real implementation, this would:
// 1. Mount or access both snapshots
// 2. Walk the filesystem trees
// 3. Compare files, directories, and metadata
// 4. Return the differences
FilesystemChanges(
added: Set<String>(),
modified: Set<String>(),
deleted: Set<String>(),
sizeChange: 0
)
}
public func digest(path: String) async throws -> Digest {
// Stub implementation
// In a real implementation, this would compute a merkle tree
// digest of the filesystem at the given path
var digestBytes = Data(count: 32)
digestBytes.withUnsafeMutableBytes { bytes in
if let baseAddress = bytes.baseAddress {
memset(baseAddress, 0, 32)
}
}
return try Digest(algorithm: .sha256, bytes: digestBytes)
}
}
@@ -0,0 +1,319 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved.
//
// 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
/// A filesystem path that preserves exact byte sequences, including non-UTF8 paths.
///
/// This type is designed to handle paths from any filesystem, including Linux paths
/// that may contain non-UTF8 byte sequences. It stores paths as raw bytes internally
/// but provides convenient String access when the bytes are valid UTF-8.
///
/// Use cases:
/// - Processing Linux filesystem paths from macOS
/// - Preserving exact path bytes for deterministic hashing (DiffKey)
/// - Round-trip preservation of paths through tar archives
/// - Cross-platform path handling without encoding loss
public struct BinaryPath: Sendable, Hashable, Codable {
/// The raw bytes of the path, including any non-UTF8 sequences
private let bytes: Data
// MARK: - Initialization
/// Initialize from a Swift String (always valid UTF-8)
public init(string: String) {
self.bytes = Data(string.utf8)
}
/// Initialize from raw bytes (may contain non-UTF8 sequences)
public init(bytes: Data) {
self.bytes = bytes
}
/// Initialize from a C string pointer (null-terminated)
public init(cString: UnsafePointer<CChar>) {
let length = strlen(cString)
self.bytes = Data(bytes: cString, count: length)
}
/// Initialize from a filesystem URL, capturing the exact bytes
public init(url: URL) {
self.bytes = url.withUnsafeFileSystemRepresentation { ptr in
guard let ptr = ptr else {
// Fallback to String representation if pointer is nil
return Data(url.path.utf8)
}
let length = strlen(ptr)
return Data(bytes: ptr, count: length)
}
}
// MARK: - Accessors
/// Returns the path as a String if it contains valid UTF-8, nil otherwise
public var stringValue: String? {
String(data: bytes, encoding: .utf8)
}
/// Returns the path as a String, replacing invalid UTF-8 sequences with replacement character
public var requireString: String {
// Try UTF-8 first
if let str = String(data: bytes, encoding: .utf8) {
return str
}
// Fallback: decode with replacement character for invalid sequences
var str = ""
var iterator = bytes.makeIterator()
var buffer: [UInt8] = []
while let byte = iterator.next() {
buffer.append(byte)
// Try to decode accumulated bytes
if let decoded = String(bytes: buffer, encoding: .utf8) {
str.append(decoded)
buffer.removeAll()
} else if buffer.count >= 4 {
// Invalid UTF-8 sequence, use replacement character
str.append("\u{FFFD}") // Unicode replacement character
buffer.removeAll()
}
}
// Handle any remaining bytes
if !buffer.isEmpty {
str.append("\u{FFFD}")
}
return str.isEmpty ? "/" : str
}
/// The raw bytes of the path
public var rawBytes: Data {
bytes
}
/// Returns true if the path contains valid UTF-8
public var isValidUTF8: Bool {
stringValue != nil
}
/// Returns true if this represents an empty path
public var isEmpty: Bool {
bytes.isEmpty
}
// MARK: - Path Operations
/// Appends a path component
public func appending(_ component: BinaryPath) -> BinaryPath {
guard !component.isEmpty else { return self }
guard !self.isEmpty else { return component }
var result = bytes
// Add separator if needed
if !result.isEmpty && result.last != UInt8(ascii: "/") {
result.append(UInt8(ascii: "/"))
}
// Skip leading separator in component if present
let componentBytes = component.bytes
if componentBytes.first == UInt8(ascii: "/") {
result.append(componentBytes.dropFirst())
} else {
result.append(componentBytes)
}
return BinaryPath(bytes: result)
}
/// Removes the last path component
public func deletingLastPathComponent() -> BinaryPath {
guard !bytes.isEmpty else { return self }
// Find last separator
if let lastSlash = bytes.lastIndex(of: UInt8(ascii: "/")) {
// Keep the slash if it's the root
if lastSlash == bytes.startIndex {
return BinaryPath(bytes: Data([UInt8(ascii: "/")]))
}
return BinaryPath(bytes: bytes.prefix(upTo: lastSlash))
}
// No separator found, return empty
return BinaryPath(bytes: Data())
}
/// Returns the last path component
public var lastPathComponent: BinaryPath {
guard !bytes.isEmpty else { return self }
// Find last separator
if let lastSlash = bytes.lastIndex(of: UInt8(ascii: "/")) {
let afterSlash = bytes.index(after: lastSlash)
if afterSlash < bytes.endIndex {
return BinaryPath(bytes: bytes.suffix(from: afterSlash))
}
return BinaryPath(bytes: Data())
}
// No separator, entire path is the component
return self
}
/// Returns path components split by separator
public var components: [BinaryPath] {
guard !bytes.isEmpty else { return [] }
var components: [BinaryPath] = []
var current = Data()
for byte in bytes {
if byte == UInt8(ascii: "/") {
if !current.isEmpty {
components.append(BinaryPath(bytes: current))
current = Data()
}
} else {
current.append(byte)
}
}
if !current.isEmpty {
components.append(BinaryPath(bytes: current))
}
return components
}
// MARK: - Interop
/// Execute a closure with a C string representation of the path
public func withCString<T>(_ body: (UnsafePointer<CChar>) throws -> T) rethrows -> T {
// Ensure null termination
var nullTerminated = bytes
if nullTerminated.isEmpty || nullTerminated.last != 0 {
nullTerminated.append(0)
}
return try nullTerminated.withUnsafeBytes { buffer in
let cString = buffer.bindMemory(to: CChar.self).baseAddress!
return try body(cString)
}
}
/// Create a URL if the path is valid UTF-8
public var url: URL? {
guard let str = stringValue else { return nil }
return URL(fileURLWithPath: str)
}
// MARK: - Comparison
/// Lexicographic comparison of raw bytes (for deterministic sorting)
public static func < (lhs: BinaryPath, rhs: BinaryPath) -> Bool {
lhs.bytes.lexicographicallyPrecedes(rhs.bytes)
}
// MARK: - Codable
public init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
// First try to decode as String (common case)
if let string = try? container.decode(String.self) {
self.bytes = Data(string.utf8)
return
}
// Fallback to base64-encoded Data for non-UTF8 paths
let encodedData = try container.decode(Data.self)
self.bytes = encodedData
}
public func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
// Encode as String if valid UTF-8
if let string = stringValue {
try container.encode(string)
} else {
// Encode as base64 Data for non-UTF8 paths
try container.encode(bytes)
}
}
// MARK: - CustomStringConvertible
public var description: String {
if let str = stringValue {
return str
}
return "<BinaryPath: \(bytes.count) bytes, non-UTF8>"
}
}
// MARK: - Convenience Extensions
extension BinaryPath: ExpressibleByStringLiteral {
public init(stringLiteral value: String) {
self.init(string: value)
}
}
extension BinaryPath {
/// Creates a relative path from base to self
public func relativePath(from base: BinaryPath) -> BinaryPath? {
let baseBytes = base.bytes
let selfBytes = self.bytes
// Ensure base ends with separator for proper prefix matching
var baseWithSep = baseBytes
if !baseWithSep.isEmpty && baseWithSep.last != UInt8(ascii: "/") {
baseWithSep.append(UInt8(ascii: "/"))
}
// Check if self starts with base
if selfBytes.starts(with: baseWithSep) {
return BinaryPath(bytes: selfBytes.dropFirst(baseWithSep.count))
} else if selfBytes == baseBytes {
return BinaryPath(bytes: Data())
}
return nil
}
/// Checks if this path has the given prefix
public func hasPrefix(_ prefix: BinaryPath) -> Bool {
bytes.starts(with: prefix.bytes)
}
/// Checks if this path has the given suffix
public func hasSuffix(_ suffix: BinaryPath) -> Bool {
bytes.hasSuffix(suffix.bytes)
}
}
// MARK: - Data Extension
extension Data {
fileprivate func hasSuffix(_ suffix: Data) -> Bool {
guard count >= suffix.count else { return false }
return self.suffix(suffix.count) == suffix
}
}
@@ -0,0 +1,473 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved.
//
// 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 ContainerBuildIR
import Crypto
import Foundation
/// A canonical, Merkle-based diff key for filesystem layer reuse.
///
/// The key is computed deterministically from the ordered set of filesystem
/// changes between (base,target). It incorporates normalized metadata and, where
/// applicable, per-entry content digests. The final key is namespaced and
/// versioned for forward compatibility.
///
/// Encoding: single value string "sha256:<hex>" for easy persistence/interchange.
public struct DiffKey: Sendable, Hashable, Codable {
// MARK: - Constants
/// Protocol prefix for DiffKey string representation
private static let protocolPrefix = "sha256:"
/// Expected hex string length for SHA256
private static let sha256HexLength = 64
/// Version byte for record encoding
private static let recordVersion: UInt8 = 0x01
/// Record type tags
private static let addedTag: UInt8 = 0x41 // 'A'
private static let modifiedTag: UInt8 = 0x4D // 'M'
private static let deletedTag: UInt8 = 0x44 // 'D'
/// Merkle tree node type tags
private static let leafTag: UInt8 = 0x4C // 'L'
private static let innerTag: UInt8 = 0x49 // 'I'
private static let emptyTag: UInt8 = 0x45 // 'E'
/// Domain separation prefix
private static let domainPrefix = "diffkey:v1|"
/// Base tags for coupling
private static let scratchBaseTag = "scratch"
private static let anyBaseTag = "anybase"
/// Empty marker for missing values
private static let missingValueMarker = "-"
/// Prefix markers for structured fields
private static let xattrsPrefix = "xh:"
private static let contentHashPrefix = "ch:"
private static let opaquePrefix = "opq:"
/// Node type strings
private static let regularNodeType = "reg"
private static let directoryNodeType = "dir"
private static let symlinkNodeType = "sym"
private static let deviceNodeType = "dev"
private static let fifoNodeType = "fifo"
private static let socketNodeType = "sock"
/// Modification kind strings
private static let metadataKind = "meta"
private static let contentKind = "content"
private static let typeKind = "type"
private static let symlinkKind = "symlink"
/// Empty tree marker
private static let emptyTreeMarker = "empty"
// Stored as "sha256:<hex>"
private let value: String
/// Return the canonical string form, e.g. "sha256:<hex>".
public var stringValue: String { value }
/// Return the raw hex portion (without the "sha256:" prefix).
public var rawHex: String {
if let idx = value.firstIndex(of: ":") {
return String(value[value.index(after: idx)...])
}
return value
}
public init(parsing string: String) throws {
// Only accept canonical "sha256:<hex>" form to avoid ambiguity.
guard string.hasPrefix(Self.protocolPrefix) else {
throw DiffKeyError.invalidFormat("unsupported format, expected \(Self.protocolPrefix)<hex>")
}
// Basic sanity check on hex length (64 for sha256)
let hex = String(string.dropFirst(Self.protocolPrefix.count))
guard hex.count == Self.sha256HexLength, hex.allSatisfy({ ("0"..."9").contains($0) || ("a"..."f").contains($0) }) else {
throw DiffKeyError.invalidFormat("invalid sha256 hex")
}
self.value = string
}
public init(bytes: Data) {
self.value = "\(Self.protocolPrefix)\(Self.hex(bytes))"
}
// MARK: - Codable
public init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
let s = try container.decode(String.self)
try self.init(parsing: s)
}
public func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
try container.encode(value)
}
// MARK: - Compute
/// Compute a canonical DiffKey from precomputed filesystem diffs.
///
/// On-wire per-record format (lossless, byte-oriented):
/// - Header: 0x01 (version) followed by record tag:
/// * 0x41 ('A') for Added
/// * 0x4D ('M') for Modified
/// * 0x44 ('D') for Deleted
/// - Fields: For each record-dependent field, append as:
/// [len32 big-endian][UTF-8 bytes], no separators or escaping.
/// Numeric fields (permissions, uid, gid) are encoded as decimal strings; absent values are "-".
/// Link target and content hash are "-" when absent.
/// xattrs field is a single string "xh:<hex>" where <hex> is the deterministic xattrs hash
/// computed by length-prefixing key/value pairs (see xattrsHashHex(_:)).
///
/// Sorting + fold-hash:
/// - Sort the complete per-record byte sequences using unsigned byte lexicographic order.
/// - Leaf hash: SHA256(0x4C 'L' || recordBytes)
/// - Inner hash: SHA256(0x49 'I' || leftHash || rightHash); duplicate last leaf when odd
/// - Empty set: SHA256(0x45 'E' || "empty")
/// - Domain separate the final root by hashing with the prefix "diffkey:v1|<baseTag>|".
///
/// Policy and limitations:
/// - Paths are serialized as raw bytes from BinaryPath, preserving non-UTF-8 filenames exactly.
/// This ensures deterministic DiffKeys regardless of path encoding.
/// - Sockets and device nodes are excluded from DiffKey records to match typical tar emission behavior.
/// - Xattrs: values are raw bytes; keys are treated as UTF-8 strings and sorted by their UTF-8
/// byte order, and the tar emitter must mirror this.
///
/// - Parameters:
/// - changes: The precomputed diff entries between base and target
/// - baseDigest: Optional digest of the base snapshot; baked into the root
/// as a domain separator to couple reuse semantics to lineage.
/// - baseMount: Optional prepared mountpoint of the base; needed for deleted entry metadata
/// - targetMount: Prepared mountpoint of the target snapshot; needed for content hashing
/// - hasher: Content hasher for regular file content.
/// - coupleToBase: When false, baseTag becomes "anybase" and keys are parent-agnostic; when true (default) it uses baseDigest or "scratch".
public static func computeFromDiffs(
_ changes: [Diff],
baseDigest: ContainerBuildIR.Digest? = nil,
baseMount: URL? = nil,
targetMount: URL,
hasher: any ContentHasher = SHA256ContentHasher(),
coupleToBase: Bool = true
) async throws -> DiffKey {
// Local helper to append a field with 4-byte big-endian length prefix
func appendField(_ string: String, to data: inout Data) {
let bytes = Data(string.utf8)
var len = UInt32(bytes.count).bigEndian
withUnsafeBytes(of: &len) { data.append(contentsOf: $0) }
data.append(bytes)
}
// Helper to append a BinaryPath field with 4-byte big-endian length prefix
func appendPathField(_ path: BinaryPath, to data: inout Data) {
let bytes = path.rawBytes
var len = UInt32(bytes.count).bigEndian
withUnsafeBytes(of: &len) { data.append(contentsOf: $0) }
data.append(bytes)
}
// Build canonical per-record bytes using lossless, length-prefixed binary encoding.
var records: [Data] = []
records.reserveCapacity(changes.count)
for change in changes {
switch change {
case .added(let a):
// Exclude socket and device nodes from DiffKey.
guard a.node != .socket, a.node != .device else { continue }
var rec = Data()
rec.append(Self.recordVersion)
rec.append(Self.addedTag)
let node = Self.nodeString(a.node)
let permsField = a.permissions.map { String($0.rawValue) } ?? Self.missingValueMarker
let uid = a.uid.map(String.init) ?? Self.missingValueMarker
let gid = a.gid.map(String.init) ?? Self.missingValueMarker
let lnk = a.linkTarget?.requireString ?? Self.missingValueMarker
let xh = Self.xattrsHashHex(a.xattrs) // hex over sorted xattrs
let ch = try await Self.contentHashHexIfNeeded(
node: a.node,
kind: .contentChanged, // additions imply content surfaced
at: targetMount.appendingPathComponent(a.path.requireString),
hasher: hasher
)
appendPathField(a.path, to: &rec)
appendField(node, to: &rec)
appendField(permsField, to: &rec)
appendField(uid, to: &rec)
appendField(gid, to: &rec)
appendField(lnk, to: &rec)
appendField("\(Self.xattrsPrefix)\(xh)", to: &rec)
appendField("\(Self.contentHashPrefix)\(ch ?? Self.missingValueMarker)", to: &rec)
records.append(rec)
case .modified(let m):
// Exclude socket and device nodes from DiffKey.
guard m.node != .socket, m.node != .device else { continue }
var rec = Data()
rec.append(Self.recordVersion)
rec.append(Self.modifiedTag)
let node = Self.nodeString(m.node)
let kind = Self.kindString(m.kind)
let permsField = m.permissions.map { String($0.rawValue) } ?? Self.missingValueMarker
let uid = m.uid.map(String.init) ?? Self.missingValueMarker
let gid = m.gid.map(String.init) ?? Self.missingValueMarker
let lnk = m.linkTarget?.requireString ?? Self.missingValueMarker
let xh = Self.xattrsHashHex(m.xattrs) // hex over sorted xattrs
let ch = try await Self.contentHashHexIfNeeded(
node: m.node,
kind: m.kind,
at: targetMount.appendingPathComponent(m.path.requireString),
hasher: hasher
)
appendPathField(m.path, to: &rec)
appendField(kind, to: &rec)
appendField(node, to: &rec)
appendField(permsField, to: &rec)
appendField(uid, to: &rec)
appendField(gid, to: &rec)
appendField(lnk, to: &rec)
appendField("\(Self.xattrsPrefix)\(xh)", to: &rec)
appendField("\(Self.contentHashPrefix)\(ch ?? Self.missingValueMarker)", to: &rec)
records.append(rec)
case .deleted(let path):
var rec = Data()
rec.append(Self.recordVersion)
rec.append(Self.deletedTag)
appendPathField(path, to: &rec)
// Determine node type and opaqueness from base
let baseURL: URL? = {
guard let baseMount = baseMount else { return nil }
// Try to create URL from path if it's valid UTF-8
if let pathString = path.stringValue {
return baseMount.appendingPathComponent(pathString)
}
// For non-UTF8 paths, we can't determine node info from base
return nil
}()
let (nodeType, opaque) = Self.deletedNodeInfo(at: baseURL)
// Skip sockets and device nodes for parity with policy
if nodeType == Self.socketNodeType { continue }
if nodeType == Self.deviceNodeType { continue }
appendField(nodeType, to: &rec)
appendField(opaque ? "\(Self.opaquePrefix)1" : "\(Self.opaquePrefix)0", to: &rec)
records.append(rec)
}
}
// *** Spec-compliant canonical ordering: sort by complete record bytes ***
records.sort { $0.lexicographicallyPrecedes($1) }
// Compute leaf hashes directly from record bytes
var leaves: [Data] = []
leaves.reserveCapacity(records.count)
for rec in records {
var h = SHA256()
h.update(data: Data([Self.leafTag]))
h.update(data: rec)
leaves.append(Data(h.finalize()))
}
let root = Self.merkleRoot(leaves)
// Domain separation and base coupling
var final = SHA256()
let baseTag: String = coupleToBase ? (baseDigest?.stringValue ?? Self.scratchBaseTag) : Self.anyBaseTag
let prefix = "\(Self.domainPrefix)\(baseTag)|"
if let prefixData = prefix.data(using: .utf8) {
final.update(data: prefixData)
}
final.update(data: root)
let digest = Data(final.finalize())
return DiffKey(bytes: digest)
}
// MARK: - Internals
private static func nodeString(_ node: Diff.Modified.Node) -> String {
switch node {
case .regular: return regularNodeType
case .directory: return directoryNodeType
case .symlink: return symlinkNodeType
case .device: return deviceNodeType
case .fifo: return fifoNodeType
case .socket: return socketNodeType
}
}
private static func kindString(_ kind: Diff.Modified.Kind) -> String {
switch kind {
case .metadataOnly: return metadataKind
case .contentChanged: return contentKind
case .typeChanged: return typeKind
case .symlinkTargetChanged: return symlinkKind
}
}
/// Deterministic xattrs hashing:
/// - Sort entries by key using binary lex ordering of key UTF-8 bytes
/// - For each entry, append: len32(key) + key bytes + len32(value) + value bytes
/// - Hash the concatenated bytes with SHA-256 and return lowercase hex
/// - Empty or missing xattrs hash to SHA-256 of empty byte stream
private static func xattrsHashHex(_ xattrs: [String: Data]?) -> String {
var blob = Data()
if let xattrs, !xattrs.isEmpty {
// Sort keys by binary lex order of UTF-8 bytes
let sortedKeys = xattrs.keys.sorted {
Data($0.utf8).lexicographicallyPrecedes(Data($1.utf8))
}
for k in sortedKeys {
let keyBytes = Data(k.utf8)
let valBytes = xattrs[k] ?? Data()
// len32(key) + key
var klen = UInt32(keyBytes.count).bigEndian
withUnsafeBytes(of: &klen) { blob.append(contentsOf: $0) }
blob.append(keyBytes)
// len32(value) + value
var vlen = UInt32(valBytes.count).bigEndian
withUnsafeBytes(of: &vlen) { blob.append(contentsOf: $0) }
blob.append(valBytes)
}
}
var h = SHA256()
h.update(data: blob)
return hex(Data(h.finalize()))
}
private static func contentHashHexIfNeeded(
node: Diff.Modified.Node,
kind: Diff.Modified.Kind,
at url: URL,
hasher: any ContentHasher
) async throws -> String? {
// Only for regular files when content changes or when added.
guard node == .regular else { return nil }
guard kind == .contentChanged else { return nil }
// Hash may throw if file disappeared; treat as no content hash if not present
if !FileManager.default.fileExists(atPath: url.path) {
return nil
}
let d = try hasher.hash(fileURL: url)
return hex(d) // works for Data or [UInt8] via overloads
}
private static func merkleRoot(_ leaves: [Data]) -> Data {
switch leaves.count {
case 0:
// Empty diff still produces a deterministic key
var h = SHA256()
h.update(data: Data([emptyTag]))
h.update(data: Data(emptyTreeMarker.utf8))
return Data(h.finalize())
case 1:
return leaves[0]
default:
var level = leaves
while level.count > 1 {
var next: [Data] = []
next.reserveCapacity((level.count + 1) / 2)
var i = 0
while i < level.count {
let left = level[i]
let right = (i + 1 < level.count) ? level[i + 1] : level[i] // duplicate last if odd
var h = SHA256()
h.update(data: Data([innerTag]))
h.update(data: left)
h.update(data: right)
next.append(Data(h.finalize()))
i += 2
}
level = next
}
return level[0]
}
}
private static func deletedNodeInfo(at url: URL?) -> (String, Bool) {
guard let url = url else { return (missingValueMarker, false) }
var st = stat()
let ok: Bool = url.withUnsafeFileSystemRepresentation { cPath in
guard let cPath else { return false }
return lstat(cPath, &st) == 0
}
if !ok {
return (missingValueMarker, false)
}
// Map st_mode to our node string
let mode = st.st_mode
let typeBits = mode & S_IFMT
let nodeType: String
switch typeBits {
case S_IFREG: nodeType = regularNodeType
case S_IFDIR: nodeType = directoryNodeType
case S_IFLNK: nodeType = symlinkNodeType
case S_IFCHR, S_IFBLK: nodeType = deviceNodeType
case S_IFIFO: nodeType = fifoNodeType
case S_IFSOCK: nodeType = socketNodeType
default: nodeType = missingValueMarker
}
var opaque = false
if nodeType == directoryNodeType {
opaque = baseDirectoryHadChildren(at: url)
}
return (nodeType, opaque)
}
private static func baseDirectoryHadChildren(at url: URL) -> Bool {
// Returns true if directory exists and has at least one entry (excluding "." and "..")
if let children = try? FileManager.default.contentsOfDirectory(atPath: url.path) {
return !children.isEmpty
}
return false
}
// MARK: - Hex helpers (overloads for Data and [UInt8])
private static func hex(_ data: Data) -> String {
data.map { String(format: "%02x", $0) }.joined()
}
private static func hex(_ bytes: [UInt8]) -> String {
bytes.map { String(format: "%02x", $0) }.joined()
}
}
public enum DiffKeyError: Error, CustomStringConvertible {
case invalidFormat(String)
public var description: String {
switch self {
case .invalidFormat(let m): return "DiffKey invalid format: \(m)"
}
}
}
@@ -0,0 +1,198 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved.
//
// 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 ContainerBuildIR
import ContainerizationOCI
import Foundation
/// A protocol for computing and storing filesystem diffs
///
/// The Differ is responsible for:
/// 1. Computing the delta between two filesystem states
/// 2. Serializing that delta into an OCI-compliant layer format
/// 3. Storing the layer to a content store
/// 4. Returning a descriptor that can be used in OCI manifests
public protocol Differ: Sendable {
/// The content store where diffs will be stored
var contentStore: any ContentStore { get }
/// Compute the difference between two snapshots and store it.
///
/// This method performs the complete diff workflow:
/// 1. Computes filesystem changes between base and target
/// 2. Creates a tar archive of those changes
/// 3. Applies the specified compression format
/// 4. Stores the result to the content store
/// 5. Returns a descriptor suitable for OCI manifests
///
/// - Parameters:
/// - base: The base snapshot (nil for initial/scratch layers)
/// - target: The target snapshot to diff against base
/// - format: The compression format to use for the layer
/// - Returns: A Descriptor containing the descriptor and statistics
/// - Throws: If diff computation or storage fails
func diff(
base: Snapshot?,
target: Snapshot
) async throws -> Descriptor
/// Apply a stored diff to a base snapshot to produce a target.
///
/// This is the inverse operation of computeAndStore, used when:
/// - Materializing snapshots from cached layers
/// - Applying patches during incremental builds
/// - Validating diff correctness
///
/// - Parameters:
/// - descriptor: The descriptor of the stored diff
/// - base: The base snapshot to apply the diff to (nil for scratch)
/// - Returns: The resulting snapshot after applying the diff
/// - Throws: If the diff cannot be applied
func apply(
descriptor: Descriptor,
to base: Snapshot?
) async throws -> Snapshot
}
/// Diff represents a filesystem diff entry.
///
/// - Additions and deletions only need the path previously, but we now surface
/// normalized attributes for both additions and modifications to avoid re-reading
/// from the OS during archive creation.
public enum Diff: Sendable, Equatable {
/// Details for an addition entry with surfaced attributes.
public struct Added: Sendable, Equatable {
public let path: BinaryPath
public let node: Modified.Node
public let permissions: FilePermissions?
public let size: Int64?
public let modificationTime: Date?
public let linkTarget: BinaryPath?
public let uid: UInt32?
public let gid: UInt32?
public let xattrs: [String: Data]?
public let devMajor: UInt32?
public let devMinor: UInt32?
public let nlink: UInt64?
public init(
path: BinaryPath,
node: Modified.Node,
permissions: FilePermissions?,
size: Int64?,
modificationTime: Date?,
linkTarget: BinaryPath?,
uid: UInt32?,
gid: UInt32?,
xattrs: [String: Data]?,
devMajor: UInt32?,
devMinor: UInt32?,
nlink: UInt64?
) {
self.path = path
self.node = node
self.permissions = permissions
self.size = size
self.modificationTime = modificationTime
self.linkTarget = linkTarget
self.uid = uid
self.gid = gid
self.xattrs = xattrs
self.devMajor = devMajor
self.devMinor = devMinor
self.nlink = nlink
}
}
/// Details for a modification entry.
public struct Modified: Sendable, Equatable {
/// The kind of modification detected. Derived from FileDiffResult.
public enum Kind: Sendable, Equatable {
case metadataOnly
case contentChanged
case typeChanged
case symlinkTargetChanged
}
/// Kind of filesystem node (target state).
public enum Node: Sendable, Equatable {
case regular
case directory
case symlink
case device
case fifo
case socket
}
public let path: BinaryPath
public let kind: Kind
public let node: Node
public let permissions: FilePermissions?
public let size: Int64?
public let modificationTime: Date?
public let linkTarget: BinaryPath?
public let uid: UInt32?
public let gid: UInt32?
public let xattrs: [String: Data]?
public let devMajor: UInt32?
public let devMinor: UInt32?
public let nlink: UInt64?
public init(
path: BinaryPath,
kind: Kind,
node: Node,
permissions: FilePermissions?,
size: Int64?,
modificationTime: Date?,
linkTarget: BinaryPath?,
uid: UInt32?,
gid: UInt32?,
xattrs: [String: Data]?,
devMajor: UInt32?,
devMinor: UInt32?,
nlink: UInt64?
) {
self.path = path
self.kind = kind
self.node = node
self.permissions = permissions
self.size = size
self.modificationTime = modificationTime
self.linkTarget = linkTarget
self.uid = uid
self.gid = gid
self.xattrs = xattrs
self.devMajor = devMajor
self.devMinor = devMinor
self.nlink = nlink
}
}
case added(Added)
case modified(Modified)
case deleted(path: BinaryPath)
}
/// POSIX file permission bits
public struct FilePermissions: Equatable, Sendable {
public let rawValue: UInt16
public init(rawValue: UInt16) { self.rawValue = rawValue }
}
public protocol ContentHasher: Sendable {
func hash(fileURL: URL) throws -> Data
}
@@ -0,0 +1,65 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved.
//
// 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
public enum FileContentDiffResult: Equatable {
case attributeOnly
case contentChanged
}
/// Compares regular file byte content using a ContentHasher.
/// Notes:
/// - For symlinks and special files, treat as attribute-only; symlink target comparison is metadata.
/// - If either URL is nil (addition or deletion), treat as contentChanged.
public struct FileContentDiffer: Sendable {
private let hasher: any ContentHasher
public init(hasher: any ContentHasher = SHA256ContentHasher()) {
self.hasher = hasher
}
public func diff(oldURL: URL?, newURL: URL?, attributesOnly: Bool = false) throws -> FileContentDiffResult {
if attributesOnly { return .attributeOnly }
guard let lhs = oldURL, let rhs = newURL else { return .contentChanged }
let a = try hasher.hash(fileURL: lhs)
let b = try hasher.hash(fileURL: rhs)
return a == b ? .attributeOnly : .contentChanged
}
}
public struct SHA256ContentHasher: ContentHasher {
public init() {}
public func hash(fileURL: URL) throws -> Data {
let handle = try FileHandle(forReadingFrom: fileURL)
defer { try? handle.close() }
var hasher = SHA256()
let chunkSize = 4 * 1024 * 1024 // 4 MB
while true {
let data = try handle.read(upToCount: chunkSize) ?? Data()
if data.isEmpty { break }
hasher.update(data: data)
}
let digest = hasher.finalize()
return Data(digest)
}
}
@@ -0,0 +1,532 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved.
//
// 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 Testing
@testable import ContainerBuildSnapshotter
@Suite struct BinaryPathTests {
// MARK: - Initialization Tests
@Test func InitFromString() {
let path = BinaryPath(string: "/usr/local/bin")
#expect(path.stringValue == "/usr/local/bin")
#expect(path.isValidUTF8)
#expect(path.rawBytes == Data("/usr/local/bin".utf8))
}
@Test func InitFromBytes() {
let bytes = Data([0x2F, 0x75, 0x73, 0x72]) // "/usr" in bytes
let path = BinaryPath(bytes: bytes)
#expect(path.stringValue == "/usr")
#expect(path.rawBytes == bytes)
}
@Test func InitFromNonUTF8Bytes() {
// Invalid UTF-8 sequence
let bytes = Data([0x2F, 0xFF, 0xFE, 0x00])
let path = BinaryPath(bytes: bytes)
#expect(path.stringValue == nil)
#expect(!path.isValidUTF8)
#expect(path.rawBytes == bytes)
// requireString should provide a fallback with replacement character
let requiredString = path.requireString
#expect(requiredString.contains("\u{FFFD}"))
}
@Test func InitFromCString() {
let cString = "/tmp/test".cString(using: .utf8)!
cString.withUnsafeBufferPointer { buffer in
let path = BinaryPath(cString: buffer.baseAddress!)
#expect(path.stringValue == "/tmp/test")
}
}
@Test func InitFromURL() {
let url = URL(fileURLWithPath: "/var/log/system.log")
let path = BinaryPath(url: url)
#expect(path.stringValue == "/var/log/system.log")
#expect(path.isValidUTF8)
}
@Test func InitFromStringLiteral() {
let path: BinaryPath = "/home/user/documents"
#expect(path.stringValue == "/home/user/documents")
}
// MARK: - Empty Path Tests
@Test func emptyPath() {
let empty = BinaryPath(bytes: Data())
#expect(empty.isEmpty)
#expect(empty.stringValue == "")
#expect(empty.components == [])
}
@Test func emptyStringPath() {
let empty = BinaryPath(string: "")
#expect(empty.isEmpty)
#expect(empty.stringValue == "")
}
// MARK: - Path Component Tests
@Test func LastPathComponent() {
let path = BinaryPath(string: "/usr/local/bin")
#expect(path.lastPathComponent.stringValue == "bin")
let rootPath = BinaryPath(string: "/")
#expect(rootPath.lastPathComponent.stringValue == "")
let noSlash = BinaryPath(string: "filename")
#expect(noSlash.lastPathComponent.stringValue == "filename")
}
@Test func DeletingLastPathComponent() {
let path = BinaryPath(string: "/usr/local/bin")
let parent = path.deletingLastPathComponent()
#expect(parent.stringValue == "/usr/local")
let rootPath = BinaryPath(string: "/usr")
let rootParent = rootPath.deletingLastPathComponent()
#expect(rootParent.stringValue == "/")
let justRoot = BinaryPath(string: "/")
let justRootParent = justRoot.deletingLastPathComponent()
#expect(justRootParent.stringValue == "/")
let noSlash = BinaryPath(string: "filename")
let noSlashParent = noSlash.deletingLastPathComponent()
#expect(noSlashParent.isEmpty)
}
@Test func AppendingPathComponent() {
let base = BinaryPath(string: "/usr/local")
let appended = base.appending(BinaryPath(string: "bin"))
#expect(appended.stringValue == "/usr/local/bin")
let baseWithSlash = BinaryPath(string: "/usr/local/")
let appendedToSlash = baseWithSlash.appending(BinaryPath(string: "bin"))
#expect(appendedToSlash.stringValue == "/usr/local/bin")
let empty = BinaryPath(string: "")
let appendedToEmpty = empty.appending(BinaryPath(string: "test"))
#expect(appendedToEmpty.stringValue == "test")
let appendEmpty = base.appending(BinaryPath(string: ""))
#expect(appendEmpty.stringValue == "/usr/local")
}
@Test func PathComponents() {
let path = BinaryPath(string: "/usr/local/bin")
let components = path.components
#expect(components.count == 3)
#expect(components[0].stringValue == "usr")
#expect(components[1].stringValue == "local")
#expect(components[2].stringValue == "bin")
let multiSlash = BinaryPath(string: "//usr//local//")
let multiComponents = multiSlash.components
#expect(multiComponents.count == 2)
#expect(multiComponents[0].stringValue == "usr")
#expect(multiComponents[1].stringValue == "local")
let noSlash = BinaryPath(string: "filename")
let noSlashComponents = noSlash.components
#expect(noSlashComponents.count == 1)
#expect(noSlashComponents[0].stringValue == "filename")
}
// MARK: - Relative Path Tests
@Test func RelativePath() {
let base = BinaryPath(string: "/usr/local")
let full = BinaryPath(string: "/usr/local/bin/test")
let relative = full.relativePath(from: base)
#expect(relative?.stringValue == "bin/test")
let sameBase = BinaryPath(string: "/usr/local")
let sameFull = BinaryPath(string: "/usr/local")
let sameRelative = sameFull.relativePath(from: sameBase)
#expect(sameRelative?.stringValue == "")
let differentBase = BinaryPath(string: "/var")
let differentFull = BinaryPath(string: "/usr/local")
let differentRelative = differentFull.relativePath(from: differentBase)
#expect(differentRelative == nil)
}
@Test func HasPrefix() {
let path = BinaryPath(string: "/usr/local/bin")
#expect(path.hasPrefix(BinaryPath(string: "/usr")))
#expect(path.hasPrefix(BinaryPath(string: "/usr/local")))
#expect(!path.hasPrefix(BinaryPath(string: "/var")))
#expect(path.hasPrefix(path))
}
@Test func HasSuffix() {
let path = BinaryPath(string: "/usr/local/bin")
#expect(path.hasSuffix(BinaryPath(string: "bin")))
#expect(path.hasSuffix(BinaryPath(string: "local/bin")))
#expect(!path.hasSuffix(BinaryPath(string: "usr")))
#expect(path.hasSuffix(path))
}
// MARK: - Comparison Tests
@Test func Equality() {
let path1 = BinaryPath(string: "/usr/local")
let path2 = BinaryPath(string: "/usr/local")
let path3 = BinaryPath(string: "/usr/bin")
#expect(path1 == path2)
#expect(path1 != path3)
// Test with byte initialization
let bytePath1 = BinaryPath(bytes: Data("/usr/local".utf8))
#expect(path1 == bytePath1)
}
@Test func Comparison() {
let path1 = BinaryPath(string: "/usr/bin")
let path2 = BinaryPath(string: "/usr/local")
#expect(path1 < path2)
#expect(!(path2 < path1))
#expect(!(path1 < path1))
}
@Test func Hashable() {
let path1 = BinaryPath(string: "/usr/local")
let path2 = BinaryPath(string: "/usr/local")
let path3 = BinaryPath(string: "/usr/bin")
var set = Set<BinaryPath>()
set.insert(path1)
set.insert(path2)
set.insert(path3)
#expect(set.count == 2) // path1 and path2 are equal
#expect(set.contains(path1))
#expect(set.contains(path3))
}
// MARK: - Interop Tests
@Test func WithCString() {
let path = BinaryPath(string: "/usr/local/bin")
path.withCString { cString in
let length = strlen(cString)
#expect(length == 14)
#expect(String(cString: cString) == "/usr/local/bin")
}
// Test with non-UTF8 path
let nonUTF8 = BinaryPath(bytes: Data([0x2F, 0xFF, 0xFE]))
nonUTF8.withCString { cString in
// Should still work as C string (bytes with null termination)
let length = strlen(cString)
#expect(length == 3)
}
}
@Test func URLConversion() {
let path = BinaryPath(string: "/usr/local/bin")
let url = path.url
#expect(url != nil)
#expect(url?.path == "/usr/local/bin")
// Non-UTF8 path should not convert to URL
let nonUTF8 = BinaryPath(bytes: Data([0x2F, 0xFF, 0xFE]))
#expect(nonUTF8.url == nil)
}
// MARK: - Codable Tests
@Test func CodableWithValidUTF8() throws {
let original = BinaryPath(string: "/usr/local/bin")
let encoder = JSONEncoder()
let data = try encoder.encode(original)
let decoder = JSONDecoder()
let decoded = try decoder.decode(BinaryPath.self, from: data)
#expect(original == decoded)
#expect(decoded.stringValue == "/usr/local/bin")
}
@Test func CodableWithNonUTF8() throws {
let nonUTF8Bytes = Data([0x2F, 0xFF, 0xFE, 0x00])
let original = BinaryPath(bytes: nonUTF8Bytes)
let encoder = JSONEncoder()
let data = try encoder.encode(original)
// When non-UTF8 is encoded, it becomes base64
// JSONDecoder will decode the base64 string as a String first
// This means the decoded path will contain the base64 string as UTF-8 bytes
// not the original non-UTF8 bytes
let decoder = JSONDecoder()
let decoded = try decoder.decode(BinaryPath.self, from: data)
// The base64 representation becomes the new path content
// This is a limitation of the current Codable implementation
// For true binary preservation, a different encoding strategy would be needed
#expect(decoded.stringValue != nil) // It's now a valid UTF-8 string (the base64)
// Alternative test: ensure original path with valid UTF-8 round-trips correctly
let utf8Path = BinaryPath(string: "/usr/local/bin")
let utf8Data = try encoder.encode(utf8Path)
let utf8Decoded = try decoder.decode(BinaryPath.self, from: utf8Data)
#expect(utf8Path == utf8Decoded)
}
// MARK: - Description Tests
@Test func Description() {
let utf8Path = BinaryPath(string: "/usr/local")
#expect(utf8Path.description == "/usr/local")
let nonUTF8 = BinaryPath(bytes: Data([0x2F, 0xFF, 0xFE]))
#expect(nonUTF8.description.contains("non-UTF8"))
#expect(nonUTF8.description.contains("3 bytes"))
}
// MARK: - Edge Cases
@Test func RootPath() {
let root = BinaryPath(string: "/")
#expect(root.stringValue == "/")
#expect(root.lastPathComponent.stringValue == "")
#expect(root.deletingLastPathComponent().stringValue == "/")
#expect(root.components == [])
}
@Test func PathWithTrailingSlash() {
let path = BinaryPath(string: "/usr/local/")
#expect(path.lastPathComponent.stringValue == "")
#expect(path.components.count == 2)
}
@Test func PathWithMultipleSlashes() {
let path = BinaryPath(string: "//usr///local//bin//")
let components = path.components
#expect(components.count == 3)
#expect(components[0].stringValue == "usr")
#expect(components[1].stringValue == "local")
#expect(components[2].stringValue == "bin")
}
@Test func LongPath() {
let longComponent = String(repeating: "a", count: 255)
let longPath = "/usr/local/\(longComponent)/bin"
let path = BinaryPath(string: longPath)
#expect(path.stringValue == longPath)
#expect(path.components.count == 4)
}
@Test func AppendingWithLeadingSlash() {
let base = BinaryPath(string: "/usr")
let component = BinaryPath(string: "/local")
let result = base.appending(component)
#expect(result.stringValue == "/usr/local")
}
@Test func RequireStringWithEmptyPath() {
let empty = BinaryPath(bytes: Data())
// Empty Data is valid UTF-8 (empty string), so requireString returns it directly
#expect(empty.stringValue == "")
#expect(empty.requireString == "")
}
@Test func RequireStringWithComplexNonUTF8() {
// Mix of valid and invalid UTF-8
let bytes = Data([
0x2F, // /
0x75, 0x73, 0x72, // usr
0x2F, // /
0xFF, 0xFE, // Invalid UTF-8
0x2F, // /
0x62, 0x69, 0x6E, // bin
])
let path = BinaryPath(bytes: bytes)
let required = path.requireString
// The algorithm processes bytes sequentially and may not preserve all text
// when invalid UTF-8 is encountered in the middle
#expect(required.contains("\u{FFFD}"))
// Additional test with simpler invalid UTF-8
let simpleInvalid = BinaryPath(bytes: Data([0xFF, 0xFE]))
let simpleRequired = simpleInvalid.requireString
#expect(simpleRequired.contains("\u{FFFD}"))
}
// MARK: - Special Character Path Tests
@Test func SpecialCharacterPaths() {
// Test paths with special characters, quotes, and Unicode
let specialPaths = [
"<F!chïer> (@vec) {càraçt#èrë} $épêcial",
"Char ;059090 to quote",
"DIR", // Contains replacement character
"Fichier @ <root>",
"Fichier avec non asci char Évelyne Mère.txt",
"Répertoire (@vec) {càraçt#èrë} $épêcial",
"Répertoire Existant",
"test\\test", // Backslash in filename
"이루마 YIRUMA - River Flows in You.mp3", // Korean characters
]
for pathString in specialPaths {
let path = BinaryPath(string: pathString)
#expect(path.stringValue == pathString, "Path should preserve special characters: \(pathString)")
#expect(path.isValidUTF8, "Path should be valid UTF-8: \(pathString)")
// Test round-trip through bytes
let bytes = path.rawBytes
let reconstructed = BinaryPath(bytes: bytes)
#expect(reconstructed.stringValue == pathString, "Round-trip should preserve path: \(pathString)")
}
}
@Test func SpecialCharacterPathComponents() {
// Test path component operations with special characters
let basePath = BinaryPath(string: "/tmp")
let specialComponent = BinaryPath(string: "Répertoire (@vec) {càraçt#èrë} $épêcial")
let fullPath = basePath.appending(specialComponent)
#expect(fullPath.stringValue == "/tmp/Répertoire (@vec) {càraçt#èrë} $épêcial")
#expect(fullPath.lastPathComponent.stringValue == "Répertoire (@vec) {càraçt#èrë} $épêcial")
#expect(fullPath.deletingLastPathComponent().stringValue == "/tmp")
}
@Test func UnicodePathOperations() {
// Test with Korean characters
let koreanPath = BinaryPath(string: "/music/이루마 YIRUMA - River Flows in You.mp3")
#expect(koreanPath.lastPathComponent.stringValue == "이루마 YIRUMA - River Flows in You.mp3")
let components = koreanPath.components
#expect(components.count == 2)
#expect(components[0].stringValue == "music")
#expect(components[1].stringValue == "이루마 YIRUMA - River Flows in You.mp3")
}
@Test func PathsWithQuotesAndSpecialChars() {
// Test paths that would need shell escaping
let quotePath = BinaryPath(string: "Char ;090 to quote")
#expect(quotePath.stringValue == "Char ;090 to quote")
let atSymbolPath = BinaryPath(string: "Fichier @ <root>")
#expect(atSymbolPath.stringValue == "Fichier @ <root>")
let dollarPath = BinaryPath(string: "file$with$dollars")
#expect(dollarPath.stringValue == "file$with$dollars")
}
@Test func BackslashInFilename() {
// Test backslash in filename (not as path separator)
let backslashPath = BinaryPath(string: "test\\test")
#expect(backslashPath.stringValue == "test\\test")
#expect(backslashPath.lastPathComponent.stringValue == "test\\test")
// When used as a component in a path
let fullPath = BinaryPath(string: "/tmp/test\\test/some data")
let components = fullPath.components
#expect(components.count == 3)
#expect(components[0].stringValue == "tmp")
#expect(components[1].stringValue == "test\\test")
#expect(components[2].stringValue == "some data")
}
@Test func PathsWithReplacementCharacter() {
// Test path containing Unicode replacement character ()
let replacementPath = BinaryPath(string: "DIR")
#expect(replacementPath.stringValue == "DIR")
#expect(replacementPath.isValidUTF8)
// Test that it can be used in path operations
let basePath = BinaryPath(string: "/tmp")
let fullPath = basePath.appending(replacementPath)
#expect(fullPath.stringValue == "/tmp/DIR")
}
@Test func AccentedCharacterPaths() {
// Test various accented characters
let accentedPaths = [
"Foldèr with éncodïng",
"Évelyne Mère.txt",
"càraçt#èrë",
"épêcial",
]
for pathString in accentedPaths {
let path = BinaryPath(string: pathString)
#expect(path.stringValue == pathString)
#expect(path.isValidUTF8)
// Test in a full path context
let fullPath = BinaryPath(string: "/home/user/\(pathString)")
#expect(fullPath.lastPathComponent.stringValue == pathString)
}
}
@Test func SpecialCharacterPathComparison() {
// Test that paths with special characters can be compared and used in sets
let path1 = BinaryPath(string: "Répertoire (@vec) {càraçt#èrë} $épêcial")
let path2 = BinaryPath(string: "Répertoire (@vec) {càraçt#èrë} $épêcial")
let path3 = BinaryPath(string: "이루마 YIRUMA - River Flows in You.mp3")
#expect(path1 == path2)
#expect(path1 != path3)
var pathSet = Set<BinaryPath>()
pathSet.insert(path1)
pathSet.insert(path2)
pathSet.insert(path3)
#expect(pathSet.count == 2) // path1 and path2 are equal
#expect(pathSet.contains(path1))
#expect(pathSet.contains(path3))
}
@Test func SpecialCharacterPathCoding() throws {
// Test that special character paths can be encoded/decoded
let specialPaths = [
"Répertoire (@vec) {càraçt#èrë} $épêcial",
"이루마 YIRUMA - River Flows in You.mp3",
"test\\test",
"DIR",
]
let encoder = JSONEncoder()
let decoder = JSONDecoder()
for pathString in specialPaths {
let original = BinaryPath(string: pathString)
let data = try encoder.encode(original)
let decoded = try decoder.decode(BinaryPath.self, from: data)
#expect(original == decoded, "Coding round-trip failed for: \(pathString)")
#expect(decoded.stringValue == pathString, "String value not preserved for: \(pathString)")
}
}
}
@@ -0,0 +1,323 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved.
//
// 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 ContainerBuildIR
import Foundation
import Testing
@testable import ContainerBuildSnapshotter
/// Pure unit tests for DiffKey that don't require DirectoryDiffer or filesystem operations.
/// These tests use mock Diff data to test DiffKey.computeFromDiffs() in isolation.
@Suite struct DiffKeyUnitTests {
// MARK: - Mock Data Helpers
/// Create a mock Added diff entry
private func mockAdded(
path: String,
node: Diff.Modified.Node = .regular,
permissions: FilePermissions? = FilePermissions(rawValue: 0o644),
size: Int64? = 100,
uid: UInt32? = 1000,
gid: UInt32? = 1000,
xattrs: [String: Data]? = nil
) -> Diff {
.added(
.init(
path: BinaryPath(string: path),
node: node,
permissions: permissions,
size: size,
modificationTime: Date(timeIntervalSince1970: 1_000_000),
linkTarget: nil,
uid: uid,
gid: gid,
xattrs: xattrs,
devMajor: nil,
devMinor: nil,
nlink: nil
)
)
}
/// Create a mock Modified diff entry
private func mockModified(
path: String,
kind: Diff.Modified.Kind = .contentChanged,
node: Diff.Modified.Node = .regular,
permissions: FilePermissions? = FilePermissions(rawValue: 0o644),
size: Int64? = 100,
uid: UInt32? = 1000,
gid: UInt32? = 1000,
xattrs: [String: Data]? = nil
) -> Diff {
.modified(
.init(
path: BinaryPath(string: path),
kind: kind,
node: node,
permissions: permissions,
size: size,
modificationTime: Date(timeIntervalSince1970: 1_000_000),
linkTarget: nil,
uid: uid,
gid: gid,
xattrs: xattrs,
devMajor: nil,
devMinor: nil,
nlink: nil
)
)
}
/// Create a mock Deleted diff entry
private func mockDeleted(path: String) -> Diff {
.deleted(path: BinaryPath(string: path))
}
// MARK: - Tests
@Test func emptyDiffProducesConsistentKey() async throws {
// Empty diffs should produce a consistent key
let key1 = try await DiffKey.computeFromDiffs(
[],
targetMount: URL(fileURLWithPath: "/tmp")
)
let key2 = try await DiffKey.computeFromDiffs(
[],
targetMount: URL(fileURLWithPath: "/tmp")
)
#expect(key1 == key2)
#expect(key1.stringValue.hasPrefix("sha256:"))
}
@Test func singleAddedFileProducesKey() async throws {
let diffs = [mockAdded(path: "file.txt")]
let key = try await DiffKey.computeFromDiffs(
diffs,
targetMount: URL(fileURLWithPath: "/tmp")
)
#expect(key.stringValue.hasPrefix("sha256:"))
#expect(key.rawHex.count == 64)
}
@Test func orderIndependentForSameChanges() async throws {
// Same changes in different order should produce the same key
let diffs1 = [
mockAdded(path: "a.txt"),
mockAdded(path: "b.txt"),
mockAdded(path: "c.txt"),
]
let diffs2 = [
mockAdded(path: "c.txt"),
mockAdded(path: "a.txt"),
mockAdded(path: "b.txt"),
]
let key1 = try await DiffKey.computeFromDiffs(
diffs1,
targetMount: URL(fileURLWithPath: "/tmp")
)
let key2 = try await DiffKey.computeFromDiffs(
diffs2,
targetMount: URL(fileURLWithPath: "/tmp")
)
#expect(key1 == key2)
}
@Test func differentOperationTypesProduceDifferentKeys() async throws {
let added = [mockAdded(path: "file.txt")]
let modified = [mockModified(path: "file.txt")]
let deleted = [mockDeleted(path: "file.txt")]
let keyAdded = try await DiffKey.computeFromDiffs(
added,
targetMount: URL(fileURLWithPath: "/tmp")
)
let keyModified = try await DiffKey.computeFromDiffs(
modified,
targetMount: URL(fileURLWithPath: "/tmp")
)
let keyDeleted = try await DiffKey.computeFromDiffs(
deleted,
targetMount: URL(fileURLWithPath: "/tmp")
)
#expect(keyAdded != keyModified)
#expect(keyModified != keyDeleted)
#expect(keyAdded != keyDeleted)
}
@Test func differentModificationKindsProduceDifferentKeys() async throws {
let contentChange = [mockModified(path: "file.txt", kind: .contentChanged)]
let metadataOnly = [mockModified(path: "file.txt", kind: .metadataOnly)]
let typeChange = [mockModified(path: "file.txt", kind: .typeChanged)]
let keyContent = try await DiffKey.computeFromDiffs(
contentChange,
targetMount: URL(fileURLWithPath: "/tmp")
)
let keyMetadata = try await DiffKey.computeFromDiffs(
metadataOnly,
targetMount: URL(fileURLWithPath: "/tmp")
)
let keyType = try await DiffKey.computeFromDiffs(
typeChange,
targetMount: URL(fileURLWithPath: "/tmp")
)
#expect(keyContent != keyMetadata)
#expect(keyMetadata != keyType)
#expect(keyContent != keyType)
}
@Test func differentPermissionsProduceDifferentKeys() async throws {
let perm644 = [mockAdded(path: "file.txt", permissions: FilePermissions(rawValue: 0o644))]
let perm755 = [mockAdded(path: "file.txt", permissions: FilePermissions(rawValue: 0o755))]
let key644 = try await DiffKey.computeFromDiffs(
perm644,
targetMount: URL(fileURLWithPath: "/tmp")
)
let key755 = try await DiffKey.computeFromDiffs(
perm755,
targetMount: URL(fileURLWithPath: "/tmp")
)
#expect(key644 != key755)
}
@Test func xattrsAffectKey() async throws {
let noXattrs = [mockAdded(path: "file.txt")]
let withXattrs = [
mockAdded(
path: "file.txt",
xattrs: ["user.test": Data("value".utf8)]
)
]
let keyNoXattrs = try await DiffKey.computeFromDiffs(
noXattrs,
targetMount: URL(fileURLWithPath: "/tmp")
)
let keyWithXattrs = try await DiffKey.computeFromDiffs(
withXattrs,
targetMount: URL(fileURLWithPath: "/tmp")
)
#expect(keyNoXattrs != keyWithXattrs)
}
@Test func baseDigestAffectsKey() async throws {
let diffs = [mockAdded(path: "file.txt")]
let base1 = try Digest.compute(Data("base1".utf8), using: .sha256)
let base2 = try Digest.compute(Data("base2".utf8), using: .sha256)
let keyNoBase = try await DiffKey.computeFromDiffs(
diffs,
baseDigest: nil,
targetMount: URL(fileURLWithPath: "/tmp")
)
let keyBase1 = try await DiffKey.computeFromDiffs(
diffs,
baseDigest: base1,
targetMount: URL(fileURLWithPath: "/tmp")
)
let keyBase2 = try await DiffKey.computeFromDiffs(
diffs,
baseDigest: base2,
targetMount: URL(fileURLWithPath: "/tmp")
)
#expect(keyNoBase != keyBase1)
#expect(keyBase1 != keyBase2)
#expect(keyNoBase != keyBase2)
}
@Test func coupleToBaseParameter() async throws {
let diffs = [mockAdded(path: "file.txt")]
let base = try Digest.compute(Data("base".utf8), using: .sha256)
let keyCoupled = try await DiffKey.computeFromDiffs(
diffs,
baseDigest: base,
targetMount: URL(fileURLWithPath: "/tmp"),
coupleToBase: true
)
let keyUncoupled = try await DiffKey.computeFromDiffs(
diffs,
baseDigest: base,
targetMount: URL(fileURLWithPath: "/tmp"),
coupleToBase: false
)
#expect(keyCoupled != keyUncoupled)
}
@Test func complexDiffSetProducesConsistentKey() async throws {
let diffs = [
mockAdded(path: "new/file1.txt"),
mockAdded(path: "new/file2.txt", node: .directory),
mockModified(path: "existing/file.txt", kind: .contentChanged),
mockModified(path: "existing/dir", kind: .metadataOnly, node: .directory),
mockDeleted(path: "old/file.txt"),
mockDeleted(path: "old/dir"),
mockAdded(path: "link", node: .symlink),
]
let key1 = try await DiffKey.computeFromDiffs(
diffs,
targetMount: URL(fileURLWithPath: "/tmp")
)
let key2 = try await DiffKey.computeFromDiffs(
diffs,
targetMount: URL(fileURLWithPath: "/tmp")
)
#expect(key1 == key2)
}
@Test func socketsAndDevicesAreExcluded() async throws {
// Sockets and device nodes should be excluded from the key
let withSocketAndDevice = [
mockAdded(path: "file.txt"),
mockAdded(path: "socket", node: .socket),
mockAdded(path: "device", node: .device),
]
let withoutSocketAndDevice = [
mockAdded(path: "file.txt")
]
let key1 = try await DiffKey.computeFromDiffs(
withSocketAndDevice,
targetMount: URL(fileURLWithPath: "/tmp")
)
let key2 = try await DiffKey.computeFromDiffs(
withoutSocketAndDevice,
targetMount: URL(fileURLWithPath: "/tmp")
)
// Keys should be the same since sockets and devices are excluded
#expect(key1 == key2)
}
}
@@ -0,0 +1,86 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved.
//
// 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 Testing
@testable import ContainerBuildSnapshotter
@Suite struct FileContentDifferTests {
@Test func attributesOnlyShortCircuit() throws {
let d = FileContentDiffer()
let r = try d.diff(oldURL: nil, newURL: nil, attributesOnly: true)
#expect(r == .attributeOnly)
}
@Test func nilSidesMeanContentChanged() throws {
let d = FileContentDiffer()
// old only
#expect(try d.diff(oldURL: URL(fileURLWithPath: "/tmp/missing"), newURL: nil) == .contentChanged)
// new only
#expect(try d.diff(oldURL: nil, newURL: URL(fileURLWithPath: "/tmp/missing")) == .contentChanged)
}
@Test func equalContentAttributeOnly() throws {
try TestUtils.withTempDir { dir in
let a = dir.appendingPathComponent("a.txt")
let b = dir.appendingPathComponent("b.txt")
try TestUtils.writeString(a, "same")
try TestUtils.writeString(b, "same")
let d = FileContentDiffer()
let r = try d.diff(oldURL: a, newURL: b)
#expect(r == .attributeOnly)
}
}
@Test func changedContentDetected() throws {
try TestUtils.withTempDir { dir in
let a = dir.appendingPathComponent("a.txt")
let b = dir.appendingPathComponent("b.txt")
try TestUtils.writeString(a, "hello")
try TestUtils.writeString(b, "world")
let d = FileContentDiffer()
let r = try d.diff(oldURL: a, newURL: b)
#expect(r == .contentChanged)
}
}
@Test func largeFileChunking() throws {
try TestUtils.withTempDir { dir in
let a = dir.appendingPathComponent("a.bin")
let b = dir.appendingPathComponent("b.bin")
// ~10MB payload
let block = Data(repeating: 0xAB, count: 1024 * 1024) // 1MiB
var payload = Data()
for _ in 0..<10 { payload.append(block) }
try TestUtils.write(a, contents: payload)
try TestUtils.write(b, contents: payload)
let d = FileContentDiffer()
#expect(try d.diff(oldURL: a, newURL: b) == .attributeOnly)
// flip one byte
var changed = payload
changed[changed.count / 2] = 0xCD
try TestUtils.write(b, contents: changed)
#expect(try d.diff(oldURL: a, newURL: b) == .contentChanged)
}
}
}
@@ -0,0 +1,82 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved.
//
// 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
// Simple temp-directory helper for tests
enum TestUtils {
static func withTempDir<T>(_ body: (URL) throws -> T) throws -> T {
let dir = FileManager.default.temporaryDirectory.appendingPathComponent("cb-snap-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: dir) }
return try body(dir)
}
static func withTempDirAsync<T>(_ body: (URL) async throws -> T) async throws -> T {
let dir = FileManager.default.temporaryDirectory.appendingPathComponent("cb-snap-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: dir) }
return try await body(dir)
}
@discardableResult
static func write(_ path: URL, contents: Data, permissions: UInt16? = nil) throws -> URL {
let fm = FileManager.default
try fm.createDirectory(at: path.deletingLastPathComponent(), withIntermediateDirectories: true)
fm.createFile(atPath: path.path, contents: contents)
if let mode = permissions {
try fm.setAttributes([.posixPermissions: NSNumber(value: mode)], ofItemAtPath: path.path)
}
return path
}
@discardableResult
static func writeString(_ path: URL, _ string: String, permissions: UInt16? = nil) throws -> URL {
try write(path, contents: Data(string.utf8), permissions: permissions)
}
static func readString(_ path: URL) throws -> String {
String(decoding: try Data(contentsOf: path), as: UTF8.self)
}
static func makeSymlink(at: URL, to relativeTarget: String) throws {
let fm = FileManager.default
try fm.createDirectory(at: at.deletingLastPathComponent(), withIntermediateDirectories: true)
try fm.createSymbolicLink(atPath: at.path, withDestinationPath: relativeTarget)
}
static func chmod(_ path: URL, mode: UInt16) throws {
try FileManager.default.setAttributes([.posixPermissions: NSNumber(value: mode)], ofItemAtPath: path.path)
}
static func fileExists(_ path: URL) -> Bool {
FileManager.default.fileExists(atPath: path.path)
}
static func mkdir(_ path: URL) throws {
try FileManager.default.createDirectory(at: path, withIntermediateDirectories: true)
}
static func listAll(relativeTo root: URL) throws -> [String] {
guard let en = FileManager.default.enumerator(at: root, includingPropertiesForKeys: nil) else { return [] }
var out: [String] = []
while let u = en.nextObject() as? URL {
let rel = u.path.replacingOccurrences(of: root.path + "/", with: "")
out.append(rel)
}
return out.sorted()
}
}