Files
Sidhartha Mani 0885cdd6a9 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
2025-08-12 11:23:55 -07:00

324 lines
11 KiB
Swift

//===----------------------------------------------------------------------===//
// 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)
}
}