chore: import upstream snapshot with attribution
Build containerization / Verify commit signatures (push) Has been skipped
Build containerization / containerization (push) Successful in 0s
Linux build / Linux compile check (push) Has been cancelled
Linux build / Determine Swift version (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:25:30 +08:00
commit 680845cb1c
445 changed files with 103779 additions and 0 deletions
@@ -0,0 +1,21 @@
{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"config": {
"mediaType": "application/vnd.oci.image.config.v1+json",
"digest": "sha256:8e2eb240a6cd7be1a0d308125afe0060b020e89275ced2e729eda7d4eeff62a2",
"size": 824
},
"layers": [
{
"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
"digest": "sha256:c6b39de5b33961661dc939b997cc1d30cda01e38005a6c6625fd9c7e748bab44",
"size": 3333361
},
{
"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
"digest": "sha256:4f4fb700ef54461cfa02571ae0db9a0dc1e0cdb5577484a6d75e68dc38e8acc1",
"size": 32
}
]
}
@@ -0,0 +1 @@
{"architecture":"arm64","config":{"Env":["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"],"Cmd":["/bin/sh"],"OnBuild":null},"created":"2024-03-16T00:09:03.929767682Z","history":[{"created":"2024-01-26T23:44:55.650290626Z","created_by":"/bin/sh -c #(nop) ADD file:6dc287a22d6cc7723b0576dd3a9a644468d133c54d42c8a8eda403e3117648f7 in / "},{"created":"2024-01-26T23:44:55.750082605Z","created_by":"/bin/sh -c #(nop) CMD [\"/bin/sh\"]","empty_layer":true},{"created":"2024-03-16T00:09:03.929767682Z","created_by":"RUN /bin/sh -c echo \"test\" # buildkit","comment":"buildkit.dockerfile.v0"}],"os":"linux","rootfs":{"type":"layers","diff_ids":["sha256:7c504f21be85c8ade51b7ade32a39a4269bcbcf0e593352923f1b8ea6278e5ef","sha256:5f70bf18a086007016e948b04aed3b82103a36bea41755b6cddfaf10ace3c6ef"]},"variant":"v8"}
@@ -0,0 +1 @@
{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json","manifests":[{"mediaType":"application/vnd.oci.image.manifest.v1+json","digest":"sha256:48a06049d3738991b011ca8b12473d712b7c40666a1462118dae3c403676afc2","size":667,"annotations":{"com.apple.container.sign.v1.certificate/ptr":"mac-Q5W6919KP6:41FB3AB2-E9B9-45CE-8252-8C17C8038670:wlan0","com.apple.container.sign.v1.signature":"MEUCIEX3psgFczBpby6sMdzBk5FF5ID5UbqM4nOpqfiVbkseAiEAlDLBr9ajHiswl8/rOyVmYdN98lakuK+dKyABEBXRXeQ="},"platform":{"architecture":"arm64","os":"linux"}}],"annotations":{"com.apple.container.info.v1.dockerfile-sha256sum":"d95983c2a8acbd4cf861c7d3b9117d3e722aebc3768ca682ddf2a427e2fd6583","com.apple.container.sign.v1.certificate/mac-Q5W6919KP6:41FB3AB2-E9B9-45CE-8252-8C17C8038670:wlan0":"LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSURjekNDQXhpZ0F3SUJBZ0lJRW9tR0duRkFGTnd3Q2dZSUtvWkl6ajBFQXdJd2FqRWtNQ0lHQTFVRUF3d2INClFYQndiR1VnUTI5eWNHOXlZWFJsSUZCTFNVNUpWQ0JEUVNBeU1TQXdIZ1lEVlFRTERCZERaWEowYVdacFkyRjANCmFXOXVJRUYxZEdodmNtbDBlVEVUTUJFR0ExVUVDZ3dLUVhCd2JHVWdTVzVqTGpFTE1Ba0dBMVVFQmhNQ1ZWTXcNCkhoY05Nakl4TWpJd01Ua3hOREl3V2hjTk1qVXdNVEU0TVRreE5ERTVXakNCcGpFYU1CZ0dDZ21TSm9tVDhpeGsNCkFRRU1DakkzTURFd09UVTRPVFV4UWpCQUJnTlZCQU1NT1cxaFl5MVJOVmMyT1RFNVMxQTJPalF4UmtJelFVSXkNCkxVVTVRamt0TkRWRFJTMDRNalV5TFRoRE1UZERPREF6T0RZM01EcDNiR0Z1TURFVk1CTUdBMVVFQ3d3TVFYQncNCmJHVkRiMjV1WldOME1STXdFUVlEVlFRS0RBcEJjSEJzWlNCSmJtTXVNUmd3RmdZS0NaSW1pWlB5TEdRQkdSWUkNClNVUk5VeTFUVTA4d1dUQVRCZ2NxaGtqT1BRSUJCZ2dxaGtqT1BRTUJCd05DQUFTcW5tSjZ3cFluWWlKRkdRaDENCjlOcGkyVnp3M1I3UFlMOXY3MnNiUDZsNy83VUt3YXV4aVk1ZkdEL29yTnhwbWNScFdPRk5mNW1JUWpFcHByMDMNCkpYUXpvNElCYVRDQ0FXVXdEQVlEVlIwVEFRSC9CQUl3QURBZkJnTlZIU01FR0RBV2dCVEx3K0x0QUcxai8rV1QNCkpsLzJJVDVVMEJ6WEZUQkVCZ2dyQmdFRkJRY0JBUVE0TURZd05BWUlLd1lCQlFVSE1BR0dLR2gwZEhBNkx5OXYNClkzTndMbUZ3Y0d4bExtTnZiUzl2WTNOd01ETXRjR3RwYm1sMFkyRXlNREV3VGdZRFZSMFJCRWN3UmFCREJnWXINCkJnRUZBZ0tnT1RBM29CZ2JGa0ZRVUV4RlEwOU9Ua1ZEVkM1QlVGQk1SUzVEVDAyaEd6QVpvQU1DQVFDaEVqQVENCkd3NXphV1JvWVhKMGFHRmZiV0Z1YVRBb0JnTlZIU1VFSVRBZkJnZ3JCZ0VGQlFjREFnWUtLd1lCQkFHQ054UUMNCkFnWUhLd1lCQlFJREJEQXpCZ05WSFI4RUxEQXFNQ2lnSnFBa2hpSm9kSFJ3T2k4dlkzSnNMbUZ3Y0d4bExtTnYNCmJTOXdhMmx1YVhSallUSXVZM0pzTUIwR0ExVWREZ1FXQkJTRlIxOExjWkgxY1laNHlEajVyWXkwMmZNeTREQU8NCkJnTlZIUThCQWY4RUJBTUNCNEF3RUFZSktvWklodmRqWkFZcEJBTU1BVEl3Q2dZSUtvWkl6ajBFQXdJRFNRQXcNClJnSWhBSk9YTnBEWE43QjhHZFZIVE1WdmEzSForeXlCTDlMcm1hZTJkeFpUUUVoekFpRUF4b25CRjhnMTNQeXYNCkhCRFVSY0p0ZURUYVdQdnJyUW9HUi9KZXQzb3B5R1U9Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K"}}
@@ -0,0 +1,114 @@
//===----------------------------------------------------------------------===//
// 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.
//===----------------------------------------------------------------------===//
// swiftlint:disable force_try
import Foundation
import Testing
@testable import ContainerizationEXT4
struct TestEXT4ExtendedAttribute {
@Test func compressName() {
struct TestCase {
let input: String
let expectedId: UInt8
let expectedStr: String
init(_ input: String, _ expectedId: UInt8, _ expectedStr: String) {
self.input = input
self.expectedId = expectedId
self.expectedStr = expectedStr
}
}
let tests: [TestCase] = [
.init("my.test.xattr", 0, "my.test.xattr"),
.init("user.fubar", 1, "fubar"),
.init("system.posix_acl_access.denied_su", 2, ".denied_su"),
.init("system.posix_acl_default_failed", 3, "_failed"),
.init("trusted.user", 4, "user"),
.init("trusted_user", 0, "trusted_user"),
.init("security.auth", 6, "auth"),
.init("system.admin", 7, "admin"),
.init("system.richacl.denied", 8, ".denied"),
]
for test in tests {
let ret = EXT4.ExtendedAttribute.compressName(test.input)
#expect(ret.0 == test.expectedId)
#expect(ret.1 == test.expectedStr)
}
}
@Test func lastXattrNotDroppedAtBufferBoundary() throws {
let buffer: [UInt8] = [
0, // nameLength
8, // nameIndex
0, 0, // valueOffset
0, 0, 0, 0, // valueInum
0, 0, 0, 0, // valueSize
0, 0, 0, 0, // hash
]
let attrs = try EXT4.FileXattrsState.read(buffer: buffer, start: 0, offset: 0)
try #require(attrs.count == 1)
#expect(attrs[0].fullName == "system.richacl")
}
@Test func xattrOutOfBoundsValueDoesNotCrash() throws {
let buffer: [UInt8] = [
1, // nameLength
1, // nameIndex
17, 0, // valueOffset
0, 0, 0, 0, // valueInum
4, 0, 0, 0, // valueSize
0, 0, 0, 0, // hash
UInt8(ascii: "a"), 0, 0, 0, // name
]
let attrs = try EXT4.FileXattrsState.read(buffer: buffer, start: 0, offset: 0)
#expect(attrs.isEmpty)
}
@Test func encodeDecodeAttributes() {
let xattrs: [String: Data] = [
"foo.bar": Data([1, 2, 3]),
"bar": Data([0, 0, 0]),
"system.richacl.bar": Data([99, 1, 9, 1]),
"foobar.user": Data([71, 2, 45]),
"test.xattr.cap": Data([1, 32, 3]),
"testing123": Data([12, 24, 45]),
"sys.admin": Data([16, 23, 13]),
"test.123": Data([15, 26, 54]),
"extendedattribute.test": Data([15, 26, 54, 1, 2, 4, 6, 7, 7]),
]
let blockSize = 4096
var state = EXT4.FileXattrsState(
inode: 1, inodeXattrCapacity: EXT4.InodeExtraSize, blockCapacity: UInt32(blockSize))
for (s, d) in xattrs {
let attribute = EXT4.ExtendedAttribute(name: s, value: [UInt8](d))
try! state.add(attribute)
}
var inlineAttrBuffer: [UInt8] = .init(repeating: 0, count: Int(EXT4.InodeExtraSize))
var blockAttrBuffer: [UInt8] = .init(repeating: 0, count: blockSize)
try! state.writeInlineAttributes(buffer: &inlineAttrBuffer)
try! state.writeBlockAttributes(buffer: &blockAttrBuffer)
let gotInlineXattrs = try! EXT4.EXT4Reader.readInlineExtendedAttributes(from: inlineAttrBuffer)
let gotBlockXattrs = try! EXT4.EXT4Reader.readBlockExtendedAttributes(from: blockAttrBuffer)
var gotXattrs: [String: Data] = [:]
for attr in gotBlockXattrs + gotInlineXattrs {
gotXattrs[attr.fullName] = Data(attr.value)
}
#expect(gotXattrs == xattrs)
}
}
@@ -0,0 +1,80 @@
//===----------------------------------------------------------------------===//
// 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 Foundation
import SystemPackage
import Testing
@testable import ContainerizationEXT4
struct Ext4FormatCreateTests {
@Test func fileReplace() throws {
let fsPath = FilePath(
FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: false))
defer { try? FileManager.default.removeItem(at: fsPath.url) }
let formatter = try EXT4.Formatter(fsPath, minDiskSize: 32.kib())
defer { try? formatter.close() }
try formatter.create(path: FilePath("/file"), mode: EXT4.Inode.Mode(.S_IFREG, 0o755), buf: nil) // create a regular file
#expect(throws: Never.self) {
try formatter.create(path: FilePath("/file"), mode: EXT4.Inode.Mode(.S_IFREG, 0o755), buf: nil)
} // overwrite it with a regular file
#expect(throws: Error.self) { try formatter.create(path: FilePath("/file"), mode: EXT4.Inode.Mode(.S_IFDIR, 0o700)) } // overwrite it with a directory
}
@Test func dirReplace() throws {
let fsPath = FilePath(
FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: false))
defer { try? FileManager.default.removeItem(at: fsPath.url) }
let formatter = try EXT4.Formatter(fsPath, minDiskSize: 32.kib())
defer { try? formatter.close() }
try formatter.create(path: FilePath("/dir"), mode: EXT4.Inode.Mode(.S_IFDIR, 0o700)) // create a directory
#expect(throws: Never.self) {
try formatter.create(path: FilePath("/dir"), mode: EXT4.Inode.Mode(.S_IFDIR, 0o700))
} // overwrite it with a directory
#expect(throws: Error.self) { try formatter.create(path: FilePath("/dir"), mode: EXT4.Inode.Mode(.S_IFREG, 0o755)) } // overwrite it with a file
}
@Test func fileParentFails() throws {
let fsPath = FilePath(
FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: false))
defer { try? FileManager.default.removeItem(at: fsPath.url) }
let formatter = try EXT4.Formatter(fsPath, minDiskSize: 32.kib())
defer { try? formatter.close() }
try formatter.create(path: FilePath("/file"), mode: EXT4.Inode.Mode(.S_IFREG, 0o755), buf: nil) // create a regular file
#expect(throws: Error.self) { try formatter.create(path: FilePath("/file/dir"), mode: EXT4.Inode.Mode(.S_IFDIR, 0o700)) } // create a subdir in a file?
}
@Test func createParentAutomatically() throws {
let fsPath = FilePath(
FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: false))
defer { try? FileManager.default.removeItem(at: fsPath.url) }
let formatter = try EXT4.Formatter(fsPath, minDiskSize: 32.kib())
defer { try? formatter.close() }
#expect(throws: Never.self) {
try formatter.create(path: FilePath("/parent/file"), mode: EXT4.Inode.Mode(.S_IFREG, 0o755), buf: nil)
} // should create /parent automatically
}
}
@@ -0,0 +1,79 @@
//===----------------------------------------------------------------------===//
// Copyright © 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 Foundation
import SystemPackage
import Testing
@testable import ContainerizationEXT4
struct Ext4FormatLinkTests {
@Test func hardlinkLinksCount() throws {
func makeFile(unlink: Bool) throws -> FilePath {
let path = FilePath(
FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: false))
let fmt = try EXT4.Formatter(path, minDiskSize: 32.kib())
try fmt.create(path: "/original", mode: EXT4.Inode.Mode(.S_IFREG, 0o755), buf: nil)
try fmt.link(link: "/hardlink", target: "/original")
if unlink {
try fmt.unlink(path: "/hardlink")
}
try fmt.close()
return path
}
let afterLink = try makeFile(unlink: false)
#expect(try EXT4.EXT4Reader(blockDevice: afterLink).stat("/original").inode.linksCount == 2)
let afterUnlink = try makeFile(unlink: true)
#expect(try EXT4.EXT4Reader(blockDevice: afterUnlink).stat("/original").inode.linksCount == 1)
}
@Test func hardlinkCreatesMissingParents() throws {
let path = FilePath(
FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: false))
defer { try? FileManager.default.removeItem(at: path.url) }
let fmt = try EXT4.Formatter(path, minDiskSize: 32.kib())
try fmt.create(path: "/original", mode: EXT4.Inode.Mode(.S_IFREG, 0o755), buf: nil)
// Parent dirs /a and /a/b do not exist yet; link must create them implicitly.
try fmt.link(link: "/a/b/hardlink", target: "/original")
try fmt.close()
let reader = try EXT4.EXT4Reader(blockDevice: path)
#expect(try reader.stat("/a").inode.mode.isDir())
#expect(try reader.stat("/a/b").inode.mode.isDir())
let target = try reader.stat("/original")
#expect(try reader.stat("/a/b/hardlink").inodeNumber == target.inodeNumber)
#expect(target.inode.linksCount == 2)
}
@Test func unlinkFirstInodeFreesInode() throws {
let emptyPath = FilePath(FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: false))
defer { try? FileManager.default.removeItem(at: emptyPath.url) }
try EXT4.Formatter(emptyPath, minDiskSize: 32.kib()).close()
let path = FilePath(FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: false))
defer { try? FileManager.default.removeItem(at: path.url) }
let fmt = try EXT4.Formatter(path, minDiskSize: 32.kib())
try fmt.create(path: FilePath("/file"), mode: EXT4.Inode.Mode(.S_IFREG, 0o755), buf: nil)
try fmt.unlink(path: FilePath("/file"))
try fmt.close()
#expect(try EXT4.EXT4Reader(blockDevice: path).superBlock.freeInodesCount == EXT4.EXT4Reader(blockDevice: emptyPath).superBlock.freeInodesCount)
}
}
@@ -0,0 +1,232 @@
//===----------------------------------------------------------------------===//
// Copyright © 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 ContainerizationArchive
import ContainerizationEXT4
import Foundation
import SystemPackage
import Testing
struct EXT4WhiteoutTests {
private func makeTempFileURL(prefix: String) throws -> URL {
let base = FileManager.default.temporaryDirectory
let url = base.appendingPathComponent("\(prefix)-\(UUID().uuidString)")
return url
}
private func writeLayerWithOpaqueWhiteout(to url: URL) throws {
let writer = try ArchiveWriter(
format: .pax,
filter: .gzip,
file: url
)
let ts = Date()
let entry = WriteEntry()
entry.modificationDate = ts
entry.creationDate = ts
entry.owner = 0
entry.group = 0
entry.fileType = .directory
entry.permissions = 0o755
entry.path = "usr"
try writer.writeEntry(entry: entry, data: nil)
entry.path = "usr/local"
try writer.writeEntry(entry: entry, data: nil)
entry.path = "usr/local/bin"
try writer.writeEntry(entry: entry, data: nil)
entry.fileType = .regular
entry.permissions = 0o644
let fooData = Data("hello\n".utf8)
entry.path = "usr/local/bin/foo"
entry.size = Int64(fooData.count)
try writer.writeEntry(entry: entry, data: fooData)
entry.fileType = .regular
entry.permissions = 0o000
entry.size = 0
entry.path = "usr//.wh..wh..opq"
try writer.writeEntry(entry: entry, data: nil)
try writer.finishEncoding()
}
private func withFormatter<T>(
prefix: String = "ext4-whiteout",
blockSize: UInt32 = 4096,
minDiskSize: UInt64 = 16.mib(),
_ body: (EXT4.Formatter, FilePath) async throws -> T
) async throws -> T {
let imageURL = try makeTempFileURL(prefix: prefix)
let imagePath = FilePath(imageURL.path)
defer {
try? FileManager.default.removeItem(at: imageURL)
}
let formatter = try EXT4.Formatter(
imagePath,
blockSize: blockSize,
minDiskSize: minDiskSize
)
let result = try await body(formatter, imagePath)
return result
}
@Test
func unpack_with_opaque_whiteout_path_does_not_stack_overflow_and_cleans_directory() async throws {
let layerURL = try makeTempFileURL(prefix: "ext4-wh-layer")
defer {
try? FileManager.default.removeItem(at: layerURL)
}
try writeLayerWithOpaqueWhiteout(to: layerURL)
try await withFormatter { formatter, imagePath in
try await formatter.unpack(
source: FilePath(layerURL.path).url,
format: .pax,
compression: .gzip,
progress: nil
)
try formatter.close()
let reader = try EXT4.EXT4Reader(blockDevice: FilePath(imagePath.description))
#expect(reader.exists(FilePath("/usr/local/bin")) == false)
#expect(reader.exists(FilePath("/usr/local/bin/foo")) == false)
}
}
@Test
func directoryWhiteout_from_wh_opq_path_with_repeated_slashes_terminates() async throws {
try await withFormatter { formatter, _ in
try formatter.create(
path: FilePath("/usr"),
mode: EXT4.Inode.Mode(.S_IFDIR, 0o755)
)
try formatter.create(
path: FilePath("/usr/local"),
mode: EXT4.Inode.Mode(.S_IFDIR, 0o755)
)
try formatter.create(
path: FilePath("/usr/local/bin"),
mode: EXT4.Inode.Mode(.S_IFDIR, 0o755)
)
try formatter.create(
path: FilePath("/usr/local/bin/foo"),
mode: EXT4.Inode.Mode(.S_IFREG, 0o644)
)
try formatter.create(
path: FilePath("/usr/local/bin/bar"),
mode: EXT4.Inode.Mode(.S_IFREG, 0o644)
)
let whiteoutEntry = FilePath("//usr//.wh..wh..opq")
let directoryToWhiteout = whiteoutEntry.dir
let normalized = directoryToWhiteout.lexicallyNormalized()
#expect(normalized == FilePath("/usr"))
try formatter.unlink(path: directoryToWhiteout, directoryWhiteout: true)
}
}
/// Test the exact recursion attack sequence:
/// create /_d
/// create symlink / -> /_
/// create /_
/// create symlink / -> /_
///
/// This creates a recursive symlink structure that can cause infinite recursion
/// during directory traversal operations.
@Test
func recursion_attack_sequence_does_not_cause_infinite_recursion() async throws {
await #expect(throws: EXT4.Formatter.Error.unsupportedFiletype) {
try await withFormatter { formatter, _ in
// Step 1: create /_d
try formatter.create(
path: FilePath("/_d"),
mode: EXT4.Inode.Mode(.S_IFDIR, 0o755)
)
// Step 2: create symlink / -> /_
try formatter.create(
path: FilePath("/"),
link: FilePath("/_"),
mode: EXT4.Inode.Mode(.S_IFLNK, 0o777)
)
try formatter.create(
path: FilePath("/_"),
mode: EXT4.Inode.Mode(.S_IFDIR, 0o755)
)
try formatter.create(
path: FilePath("/"),
link: FilePath("/_"),
mode: EXT4.Inode.Mode(.S_IFLNK, 0o777)
)
}
}
}
@Test
func file_whiteouts_and_directory_whiteouts_interact_correctly() async throws {
try await withFormatter { formatter, imagePath in
// Lowerlayer content
try formatter.create(
path: FilePath("/opt"),
mode: EXT4.Inode.Mode(.S_IFDIR, 0o755)
)
try formatter.create(
path: FilePath("/opt/app"),
mode: EXT4.Inode.Mode(.S_IFDIR, 0o755)
)
try formatter.create(
path: FilePath("/opt/app/cache"),
mode: EXT4.Inode.Mode(.S_IFDIR, 0o755)
)
try formatter.create(
path: FilePath("/opt/app/cache/file"),
mode: EXT4.Inode.Mode(.S_IFREG, 0o644)
)
try formatter.unlink(path: FilePath("/opt/app/cache/file"))
try formatter.unlink(
path: FilePath("/opt/app/cache"),
directoryWhiteout: true
)
try formatter.close()
let reader = try EXT4.EXT4Reader(blockDevice: FilePath(imagePath.description))
#expect(reader.exists(FilePath("/opt")))
#expect(reader.exists(FilePath("/opt/app")))
#expect(reader.exists(FilePath("/opt/app/cache")))
#expect(reader.exists(FilePath("/opt/app/cache/file")) == false)
}
}
}
@@ -0,0 +1,262 @@
//===----------------------------------------------------------------------===//
// 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.
//===----------------------------------------------------------------------===//
// swiftlint: disable force_try shorthand_operator static_over_final_class
import Foundation
import SystemPackage
import Testing
@testable import ContainerizationEXT4
struct Ext4FormatTests: ~Copyable {
let fsPath = FilePath(
FileManager.default.uniqueTemporaryDirectory()
.appendingPathComponent("ext4.img.delme.format", isDirectory: false))
// This test creates a file named "ext4.img.delme"
// Since there are no tools yet in osx/swift to test the created filesystem,
// the tests below perform the same checks as the following manual commands
//
// From project root
// $> backpack run -it -v SwiftExt4/Tests/SwiftExt4Tests/:/test -w test ubuntu:latest
// $> ls -lrth ext4.img.delme # should be only 44K
// $> e2fsck ext4.img.delme # should return 0
// $> dumpe2fs ext4.img.delme # should print info and return 0
// $> debugfs ext4.img.delme # should open the fs
// debugfs 1.46.5 (30-Dec-2021)
// debugfs: ls
// 2 (12) . 2 (12) .. 15 (12) x 11 (20) lost+found 12 (12) ase
// 16 (12) y 0 (4016)
//
// # check directory
//
// debugfs: stat /test
// Inode: 12 Type: directory Mode: 01274 Flags: 0xc0000
// Generation: 0 Version: 0x00000000:00000000
// User: 0 Group: 0 Size: 4096
// File ACL: 0
// Links: 3 Blockcount: 1
// Fragment: Address: 0 Number: 0 Size: 0
// ctime: 0x6614b59f:8cdf6a34 -- Tue Apr 9 03:27:27 2024
// atime: 0x6614b59f:8cdf6a34 -- Tue Apr 9 03:27:27 2024
// mtime: 0x6614b59f:8cdf6a34 -- Tue Apr 9 03:27:27 2024
// crtime: 0x6614b59f:8cdf6a34 -- Tue Apr 9 03:27:27 2024
// Size of extra inode fields: 24
// EXTENTS:
// (0):5
//
// # check regular file
//
// debugfs: stat /test/foo/bar/x
// Inode: 15 Type: regular Mode: 01363 Flags: 0xc0000
// Generation: 0 Version: 0x00000000:00000000
// User: 0 Group: 0 Size: 4
// File ACL: 0
// Links: 2 Blockcount: 1
// Fragment: Address: 0 Number: 0 Size: 0
// ctime: 0x6614b59f:8ce91ef8 -- Tue Apr 9 03:27:27 2024
// atime: 0x6614b59f:8ce91ef8 -- Tue Apr 9 03:27:27 2024
// mtime: 0x6614b59f:8ce91ef8 -- Tue Apr 9 03:27:27 2024
// crtime: 0x6614b59f:8ce91ef8 -- Tue Apr 9 03:27:27 2024
// Size of extra inode fields: 24
// EXTENTS:
// (0):2
//
// # check symlink
//
// debugfs: stat /y
// Inode: 16 Type: symlink Mode: 01675 Flags: 0x0
// Generation: 0 Version: 0x00000000:00000000
// User: 0 Group: 0 Size: 19
// File ACL: 0
// Links: 1 Blockcount: 0
// Fragment: Address: 0 Number: 0 Size: 0
// ctime: 0x6614b59f:8cf052fc -- Tue Apr 9 03:27:27 2024
// atime: 0x6614b59f:8cf052fc -- Tue Apr 9 03:27:27 2024
// mtime: 0x6614b59f:8cf052fc -- Tue Apr 9 03:27:27 2024
// crtime: 0x6614b59f:8cf052fc -- Tue Apr 9 03:27:27 2024
// Size of extra inode fields: 24
// Fast link dest: "test/foo"
//
// # check hard link
//
// debugfs: stat x
// Inode: 15 Type: regular Mode: 01363 Flags: 0xc0000
// Generation: 0 Version: 0x00000000:00000000
// User: 0 Group: 0 Size: 4
// File ACL: 0
// Links: 2 Blockcount: 1
// Fragment: Address: 0 Number: 0 Size: 0
// ctime: 0x6614b59f:8ce91ef8 -- Tue Apr 9 03:27:27 2024
// atime: 0x6614b59f:8ce91ef8 -- Tue Apr 9 03:27:27 2024
// mtime: 0x6614b59f:8ce91ef8 -- Tue Apr 9 03:27:27 2024
// crtime: 0x6614b59f:8ce91ef8 -- Tue Apr 9 03:27:27 2024
// Size of extra inode fields: 24
// EXTENTS:
// (0):2
//
// Mount and check
//
// $> mkdir -p mntpnt
// $> mount -t ext4 ext4.img.delme mntpnt
// $> # explore file tree
init() throws {
let formatter = try EXT4.Formatter(fsPath, minDiskSize: 32.kib())
try formatter.create(path: FilePath("/test"), mode: EXT4.Inode.Mode(.S_IFDIR, 0o700))
try formatter.create(path: FilePath("/test/foo"), mode: EXT4.Inode.Mode(.S_IFDIR, 0o700))
try formatter.create(path: FilePath("/test/foo/bar"), mode: EXT4.Inode.Mode(.S_IFDIR, 0o700))
let inputStream = InputStream(data: "test".data(using: .utf8)!)
inputStream.open()
try formatter.create(
path: FilePath("/test/foo/bar/x"), mode: EXT4.Inode.Mode(.S_IFREG, 0o755),
buf: inputStream) // create a regular file
inputStream.close()
try formatter.link(link: FilePath("/x"), target: FilePath("/test/foo/bar/x"))
try formatter.create(
path: FilePath("/y"), link: FilePath("test/foo"), mode: EXT4.Inode.Mode(.S_IFLNK, 0o700)) // create a symlink
try formatter.close()
}
deinit {
try? FileManager.default.removeItem(at: fsPath.url)
}
/// This test checks that the size of the FS at fsPath is the minimum possible
/// for its data + metadata. It should be 44 kib or 11 blocks, expanded to accommodate
/// data requiring > 32KiB of space
@Test func fileSize() throws {
let f = try FileHandle(forReadingFrom: fsPath.url)
let size = try f.seekToEnd()
#expect(size == 128.mib())
}
/// This test checks that the superblock was created correctly
@Test func superblock() throws {
let f = try EXT4.EXT4Reader(blockDevice: fsPath)
#expect(f.superBlock.blocksCountLow == 32768)
#expect(f.superBlock.freeBlocksCountLow == 32246) // total - 512 inode blocks
}
/// This test checks that the group descriptor has been set correctly
@Test func groupDescriptors() throws {
let f = try EXT4.EXT4Reader(blockDevice: fsPath)
let gd = try f.getGroupDescriptor(0)
#expect(gd.blockBitmapLow == 551) // move over by 512 blocks (for inodes)
#expect(gd.inodeBitmapLow == 552) // move over by 512 blocks (for inodes)
#expect(gd.inodeTableLow == 39)
#expect(gd.freeBlocksCountLow == 32246) // 512 block used by larger inode table per block group
#expect(gd.freeInodesCountLow == 8176) // 512 times the inodes
#expect(gd.usedDirsCountLow == 5)
}
/// This test checks that the block bitmap has been set correctly
@Test func blockBitmap() throws {
let ext4 = try EXT4.EXT4Reader(blockDevice: fsPath)
let gd = try ext4.getGroupDescriptor(1)
let blockBitmapOffset = gd.blockBitmapLow
let f = try #require(FileHandle(forReadingFrom: fsPath))
try f.seek(toOffset: ext4.blockSize * blockBitmapOffset)
let bitmapSize = ext4.superBlock.blocksPerGroup / 8
#expect(bitmapSize == 4096)
let _ = try f.read(
upToCount: Int(ext4.superBlock.blocksCountLow - ext4.superBlock.freeBlocksCountLow - 1) / 8 + 1)
}
/// This test checks that the inode bitmap has been set correctly
@Test func inodeBitmap() throws {
let ext4 = try EXT4.EXT4Reader(blockDevice: fsPath)
let gd = try ext4.getGroupDescriptor(1)
let inodeBitmapOffset = gd.inodeBitmapLow
let f = try #require(FileHandle(forReadingFrom: fsPath))
try f.seek(toOffset: ext4.blockSize * inodeBitmapOffset)
let bitmapSize = ext4.superBlock.inodesPerGroup / 8
#expect(bitmapSize == 1024)
}
/// This test checks that the inode table has been set correctly
@Test func inodeTable() throws {
let ext4 = try EXT4.EXT4Reader(blockDevice: fsPath)
let gd = try ext4.getGroupDescriptor(0)
let inodeTableOffset = gd.inodeTableLow
let f = try #require(FileHandle(forReadingFrom: fsPath))
try f.seek(toOffset: ext4.blockSize * inodeTableOffset)
let inodeTableSize = ext4.superBlock.inodesPerGroup * UInt32(ext4.superBlock.inodeSize)
#expect(inodeTableSize == 2_097_152)
let inodeTableData = try #require(try f.read(upToCount: Int(inodeTableSize)))
let inodeAt: (Int) -> EXT4.Inode = { inodeNum in
var inodeBytes: [UInt8] = .init(repeating: 0, count: Int(ext4.superBlock.inodeSize))
let inodeStart = Int(ext4.superBlock.inodeSize) * (inodeNum - 1)
var j: Int = 0
for i in inodeStart..<inodeStart + Int(ext4.superBlock.inodeSize) {
inodeBytes[j] = inodeTableData[i]
j = j + 1
}
return inodeBytes.withUnsafeBytes { ptr in
ptr.loadLittleEndian(as: EXT4.Inode.self)
}
}
let root = inodeAt(2)
#expect(root.mode.isDir())
#expect(root.linksCount == 4)
let regFile = inodeAt(15)
#expect(regFile.mode.isReg())
#expect(regFile.sizeLow == 4)
}
}
@Suite(.serialized)
struct NegativeTimestampRoundtripTests {
private let fsPath = FilePath(
FileManager.default.temporaryDirectory
.appendingPathComponent("ext4-pre1970-roundtrip.img", isDirectory: false))
private let apollo11MoonLanding: Date = {
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return f.date(from: "1969-07-20T20:17:39.9Z")!
}()
@Test func encodeNegativeTimestamp() throws {
let formatter = try EXT4.Formatter(fsPath, minDiskSize: 32.kib())
defer { try? formatter.close() }
let ts = FileTimestamps(access: apollo11MoonLanding, modification: apollo11MoonLanding, creation: apollo11MoonLanding)
try formatter.create(path: FilePath("/file"), mode: EXT4.Inode.Mode(.S_IFREG, 0o755), ts: ts, buf: nil)
}
@Test func decodeNegativeTimestamp() throws {
let reader = try EXT4.EXT4Reader(blockDevice: fsPath)
let (_, inode) = try reader.stat(FilePath("/file"))
let mtime = Date(fsTimestamp: UInt64(inode.mtime) | (UInt64(inode.mtimeExtra) << 32))
let atime = Date(fsTimestamp: UInt64(inode.atime) | (UInt64(inode.atimeExtra) << 32))
let crtime = Date(fsTimestamp: UInt64(inode.crtime) | (UInt64(inode.crtimeExtra) << 32))
#expect(mtime == apollo11MoonLanding)
#expect(atime == apollo11MoonLanding)
#expect(crtime == apollo11MoonLanding)
}
}
struct Ext4FormatEmptyRangeTests {
@Test func closeEmptyFilesystem() throws {
let fsPath = FilePath(
FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: false))
defer { try? FileManager.default.removeItem(at: fsPath.url) }
let formatter = try EXT4.Formatter(fsPath, minDiskSize: 32.kib())
try formatter.close()
}
}
@@ -0,0 +1,166 @@
//===----------------------------------------------------------------------===//
// Copyright © 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 Foundation
import SystemPackage
import Testing
@testable import ContainerizationEXT4
/// Structural validation tests for journaled EXT4 filesystems.
///
/// Each test creates a minimal image with a specific journal mode, then reads the
/// superblock back via `EXT4.EXT4Reader` and asserts the invariants that the Linux
/// kernel checks before mounting:
///
/// - **Geometry**: `fileSize == blocksCount * blockSize`
/// (the exact invariant behind "bad geometry: block count N exceeds size of device")
/// - **Additive sizing**: `fileSize > minDiskSize`
/// (the journal grows the image rather than carving into the usable capacity)
/// - **Feature flag**: `featureCompat` has `hasJournal` set
/// - **Journal inode**: `journalInum == EXT4.JournalInode` (inode 8)
/// - **Mount options**: `defaultMountOpts` matches the requested mode
@Suite(.serialized)
struct JournalFormatTests {
let minDiskSize: UInt64 = 128.mib()
// MARK: - Per-mode tests
@Test func writeback() throws {
let path = tmpPath("ext4-journal-writeback")
defer { cleanup(path) }
let formatter = try EXT4.Formatter(path, minDiskSize: minDiskSize, journal: .init(defaultMode: .writeback))
try writeHello(formatter, content: "writeback")
try formatter.close()
// Writeback mode: only journal metadata is committed to disk before the data.
// defaultMountOpts encodes this as 0x0060 (data=writeback | barrier).
try verifyStructure(at: path, expectedMountOpts: EXT4.DefaultMountOpts.journalWriteback)
}
@Test func ordered() throws {
let path = tmpPath("ext4-journal-ordered")
defer { cleanup(path) }
let formatter = try EXT4.Formatter(path, minDiskSize: minDiskSize, journal: .init(defaultMode: .ordered))
try writeHello(formatter, content: "ordered")
try formatter.close()
// Ordered mode (the Linux default): data blocks are written before the journal commit.
// defaultMountOpts encodes this as 0x0040 (data=ordered | barrier).
try verifyStructure(at: path, expectedMountOpts: EXT4.DefaultMountOpts.journalOrdered)
}
@Test func journalData() throws {
let path = tmpPath("ext4-journal-data")
defer { cleanup(path) }
let formatter = try EXT4.Formatter(path, minDiskSize: minDiskSize, journal: .init(defaultMode: .journal))
try writeHello(formatter, content: "journal")
try formatter.close()
// Data journaling mode: data and metadata are both journaled.
// defaultMountOpts encodes this as 0x0020 (data=journal | barrier).
try verifyStructure(at: path, expectedMountOpts: EXT4.DefaultMountOpts.journalData)
}
// MARK: - Shared structural helper
private func verifyStructure(at path: FilePath, expectedMountOpts: UInt32) throws {
let fileSize = try fileByteCount(at: path)
let reader = try EXT4.EXT4Reader(blockDevice: path)
let sb = reader.superBlock
let blocksCount = UInt64(sb.blocksCountLow) | (UInt64(sb.blocksCountHigh) << 32)
let blockSize = UInt64(sb.blockSize)
// The Linux kernel rejects mounts where the superblock's block count implies
// a larger device than the actual file backing the filesystem.
#expect(fileSize == blocksCount * blockSize, "geometry mismatch: fileSize=\(fileSize), blocksCount=\(blocksCount), blockSize=\(blockSize)")
// The journal is additive overhead; the image must be larger than minDiskSize.
#expect(fileSize > minDiskSize, "journal did not grow the image: fileSize=\(fileSize), minDiskSize=\(minDiskSize)")
// The COMPAT_HAS_JOURNAL flag must be set for the kernel to look for inode 8.
#expect(sb.featureCompat & EXT4.CompatFeature.hasJournal.rawValue != 0, "COMPAT_HAS_JOURNAL not set in featureCompat (0x\(String(sb.featureCompat, radix: 16)))")
// The journal inode number recorded in the superblock must point at inode 8.
#expect(sb.journalInum == EXT4.JournalInode, "journalInum=\(sb.journalInum), expected \(EXT4.JournalInode)")
// The default mount options must reflect the requested journaling mode.
#expect(sb.defaultMountOpts == expectedMountOpts, "defaultMountOpts=0x\(String(sb.defaultMountOpts, radix: 16)), expected 0x\(String(expectedMountOpts, radix: 16))")
}
// MARK: - Filesystem population helpers
private func writeHello(_ formatter: EXT4.Formatter, content: String) throws {
try formatter.create(path: FilePath("/data"), mode: EXT4.Inode.Mode(.S_IFDIR, 0o755))
let stream = InputStream(data: Data(content.utf8))
stream.open()
defer { stream.close() }
try formatter.create(
path: FilePath("/data/hello.txt"),
mode: EXT4.Inode.Mode(.S_IFREG, 0o644),
buf: stream)
}
// MARK: - File helpers
private func tmpPath(_ name: String) -> FilePath {
FilePath(
FileManager.default.temporaryDirectory
.appendingPathComponent("\(name)-\(UUID().uuidString).img", isDirectory: false)
.path
)
}
private func cleanup(_ path: FilePath) {
try? FileManager.default.removeItem(atPath: path.string)
}
private func fileByteCount(at path: FilePath) throws -> UInt64 {
let attrs = try FileManager.default.attributesOfItem(atPath: path.string)
guard let size = attrs[.size] as? UInt64 else {
throw CocoaError(.fileReadUnknown)
}
return size
}
}
/// Tests that the UInt32 block-count overflow guards fire at the right points.
struct JournalOverflowTests {
// (UInt32.max + 1) blocks at the minimum block size triggers the guard in init()
// before any file is created, so no cleanup is needed.
@Test func initRejectsBlockCountOverflowingUInt32() {
let path = FilePath(
FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: false).path)
#expect(throws: EXT4.Formatter.Error.self) {
try EXT4.Formatter(path, blockSize: 1024, minDiskSize: (UInt64(UInt32.max) + 1) * 1024)
}
}
// minDiskSize is exactly at the UInt32 block count limit; adding any journal pushes
// newSize / blockSize above UInt32.max. The guard in close() fires before the bitmap
// loop, so this test completes quickly despite the large sparse file.
@Test func closeRejectsJournalExpansionOverflowingUInt32() throws {
let path = FilePath(
FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: false).path)
defer { try? FileManager.default.removeItem(atPath: path.string) }
let formatter = try EXT4.Formatter(
path, blockSize: 1024, minDiskSize: UInt64(UInt32.max) * 1024,
journal: .init(defaultMode: .writeback))
#expect(throws: EXT4.Formatter.Error.self) {
try formatter.close()
}
}
}
@@ -0,0 +1,652 @@
//===----------------------------------------------------------------------===//
// 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 Foundation
import SystemPackage
import Testing
@testable import ContainerizationEXT4
@Suite
struct EXT4PathIOTests {
// MARK: - Helpers
private func makeTempImageURL(name: String = UUID().uuidString) -> URL {
FileManager.default.temporaryDirectory.appendingPathComponent("ext4-\(name).img")
}
/// Build a fresh ext4 image, populate content, close, and return its URL.
/// Usage:
/// let url = try buildFS { fmt in
/// try createFile(fmt, "/etc/hostname", "myhost\n")
/// try createSymlink(fmt, "/bin/sh", "/usr/bin/busybox") // fast link
/// }
private func buildFS(
minDiskSize: UInt64 = 4 * 1024 * 1024, // 4 MiB is enough for these tests
populate: (EXT4.Formatter) throws -> Void
) throws -> URL {
let url = makeTempImageURL()
let path = FilePath(url.path)
// 1) Format image
let formatter = try EXT4.Formatter(path, minDiskSize: minDiskSize)
// 2) Populate contents
try populate(formatter)
// 3) Finalize filesystem
try formatter.close()
return url
}
/// Convenience to create a directory (recursively).
private func createDir(_ fmt: EXT4.Formatter, _ path: String, mode: UInt16 = EXT4.Inode.Mode(.S_IFDIR, 0o755)) throws {
try fmt.create(path: FilePath(path), mode: mode)
}
/// Convenience to create a regular file with UTF-8 content.
private func createFile(_ fmt: EXT4.Formatter, _ path: String, _ contents: String, mode: UInt16 = EXT4.Inode.Mode(.S_IFREG, 0o644)) throws {
let data = Data(contents.utf8)
let stream = InputStream(data: data)
stream.open()
defer { stream.close() }
try fmt.create(path: FilePath(path), mode: mode, buf: stream)
}
/// Convenience to create a (fast or long) symlink. Pass absolute target.
private func createSymlink(_ fmt: EXT4.Formatter, _ linkPath: String, _ target: String, mode: UInt16 = EXT4.Inode.Mode(.S_IFLNK, 0o777)) throws {
try fmt.create(path: FilePath(linkPath), link: FilePath(target), mode: mode)
}
/// Open reader for a given image URL.
private func openReader(_ url: URL) throws -> EXT4.EXT4Reader {
try EXT4.EXT4Reader(blockDevice: FilePath(url.path))
}
// MARK: - Tests
@Test
func existsAndStatRootAndLostFound() throws {
let url = try buildFS { _ in /* nothing extra */ }
defer { try? FileManager.default.removeItem(at: url) }
let r = try openReader(url)
#expect(r.exists(FilePath("/")))
let (inoNum, ino) = try r.stat(FilePath("/"))
#expect(inoNum == 2)
#expect(ino.mode.isDir())
// lost+found is created by the formatter
let names = try r.listDirectory(FilePath("/"))
#expect(names.contains("lost+found"))
}
@Test
func createAndReadRegularFilesWithOffsetsAndEOF() throws {
let url = try buildFS { fmt in
try self.createDir(fmt, "/etc")
try self.createFile(fmt, "/etc/hostname", "myhost\n")
try self.createDir(fmt, "/usr/bin")
try self.createFile(fmt, "/usr/bin/hello", "Hello EXT4!\n")
}
defer { try? FileManager.default.removeItem(at: url) }
let r = try openReader(url)
// exists/stat
#expect(r.exists(FilePath("/etc/hostname")))
let (_, ino) = try r.stat(FilePath("/usr/bin/hello"))
#expect(ino.mode.isReg())
// listDirectory excludes "." and ".." and is sorted
let usrChildren = try r.listDirectory(FilePath("/usr"))
#expect(usrChildren == ["bin"])
// full read to EOF
let hello = try r.readFile(at: FilePath("/usr/bin/hello"))
#expect(String(decoding: hello, as: UTF8.self) == "Hello EXT4!\n")
// offset + count semantics
let data = try r.readFile(at: FilePath("/usr/bin/hello"), offset: 6, count: 5)
#expect(String(decoding: data, as: UTF8.self) == "EXT4!")
// offset == size => empty; offset > size => empty
let hostname = try r.readFile(at: FilePath("/etc/hostname"))
let size = hostname.count
#expect(try r.readFile(at: FilePath("/etc/hostname"), offset: UInt64(size)).count == 0)
#expect(try r.readFile(at: FilePath("/etc/hostname"), offset: UInt64(size + 100)).count == 0)
}
@Test
func readFileIntoBufferMatchesData() throws {
let url = try buildFS { fmt in
try self.createDir(fmt, "/etc")
try self.createFile(fmt, "/etc/hostname", "myhost\n")
}
defer { try? FileManager.default.removeItem(at: url) }
let reader = try openReader(url)
let path = FilePath("/etc/hostname")
let data = try reader.readFile(at: path, offset: 0, count: 4096)
var buffer = [UInt8](repeating: 0, count: data.count)
let wrote = try buffer.withUnsafeMutableBytes { ptr in
try reader.readFile(at: path, into: ptr, offset: 0)
}
#expect(wrote == data.count)
if wrote < buffer.count {
buffer.removeSubrange(wrote..<buffer.count)
}
#expect(Data(buffer) == data)
}
@Test
func listDirectoryOnFileThrowsNotAFile() throws {
let url = try buildFS { fmt in
try self.createDir(fmt, "/etc")
try self.createFile(fmt, "/etc/hostname", "host\n")
}
defer { try? FileManager.default.removeItem(at: url) }
let r = try openReader(url)
do {
_ = try r.listDirectory(FilePath("/etc/hostname"))
Issue.record("Expected notADirectory error")
} catch let error as EXT4.PathIOError {
guard case .notADirectory = error else {
Issue.record("Expected notADirectory, got \(error)")
return
}
} catch {
Issue.record("Expected EXT4.PathIOError, got \(error)")
}
}
@Test
func fastSymlinkResolutionAndRead() throws {
// Fast symlink: target stored in inode "block" area if len < 60 (Formatter behavior).
// Create /usr/bin/busybox and /bin/sh -> /usr/bin/busybox
let url = try buildFS { fmt in
try self.createDir(fmt, "/usr/bin")
try self.createFile(fmt, "/usr/bin/busybox", "BB\n")
try self.createDir(fmt, "/bin")
try self.createSymlink(fmt, "/bin/sh", "/usr/bin/busybox")
}
defer { try? FileManager.default.removeItem(at: url) }
let r = try openReader(url)
// Reading through the link should hit the target bytes.
let sh = try r.readFile(at: FilePath("/bin/sh"))
#expect(String(decoding: sh, as: UTF8.self) == "BB\n")
// stat(followSymlinks: false) should show symlink inode
let (_, lnkIno) = try r.stat(FilePath("/bin/sh"), followSymlinks: false)
#expect(lnkIno.mode.isLink())
// stat(default) follows
let (_, tgtIno) = try r.stat(FilePath("/bin/sh"))
#expect(tgtIno.mode.isReg())
}
@Test
func longSymlinkResolutionAndRead() throws {
// Long symlink: target > 60 bytes triggers extent-backed storage (Formatter behavior).
// Build a very long absolute path to exceed 60 chars.
let deepDir = "/a/very/long/path/that/exceeds/sixty/bytes/for/symlink/target"
#expect(deepDir.utf8.count > 60)
let url = try buildFS { fmt in
// Create deep directory structure and file
try self.createDir(fmt, deepDir)
try self.createFile(fmt, "\(deepDir)/payload.txt", "LONGLINK\n")
// Link at a short path -> long absolute target
try self.createSymlink(fmt, "/ll", "\(deepDir)/payload.txt")
}
defer { try? FileManager.default.removeItem(at: url) }
let r = try openReader(url)
let bytes = try r.readFile(at: FilePath("/ll"))
#expect(String(decoding: bytes, as: UTF8.self) == "LONGLINK\n")
}
@Test
func symlinkLoopDetection() throws {
let url = try buildFS { fmt in
// /a -> /b and /b -> /a
try self.createSymlink(fmt, "/a", "/b")
try self.createSymlink(fmt, "/b", "/a")
}
defer { try? FileManager.default.removeItem(at: url) }
let r = try openReader(url)
do {
_ = try r.stat(FilePath("/a"))
Issue.record("Expected symlinkLoop error")
} catch let error as EXT4.PathIOError {
guard case .symlinkLoop = error else {
Issue.record("Expected symlinkLoop, got \(error)")
return
}
} catch {
Issue.record("Expected EXT4.PathIOError, got \(error)")
}
}
@Test
func complexSymlinkLoopDetection() throws {
let url = try buildFS { fmt in
// Create a longer chain that eventually loops: /a -> /b -> /c -> /d -> /b
try self.createSymlink(fmt, "/a", "/b")
try self.createSymlink(fmt, "/b", "/c")
try self.createSymlink(fmt, "/c", "/d")
try self.createSymlink(fmt, "/d", "/b") // Loop back to /b
}
defer { try? FileManager.default.removeItem(at: url) }
let r = try openReader(url)
do {
_ = try r.stat(FilePath("/a"))
Issue.record("Expected symlinkLoop error")
} catch let error as EXT4.PathIOError {
guard case .symlinkLoop = error else {
Issue.record("Expected symlinkLoop for complex loop, got \(error)")
return
}
} catch {
Issue.record("Expected EXT4.PathIOError, got \(error)")
}
}
@Test
func selfReferencingSymlink() throws {
let url = try buildFS { fmt in
// Self-referencing symlink: /self -> /self
try self.createSymlink(fmt, "/self", "/self")
}
defer { try? FileManager.default.removeItem(at: url) }
let r = try openReader(url)
do {
_ = try r.stat(FilePath("/self"))
Issue.record("Expected symlinkLoop error")
} catch let error as EXT4.PathIOError {
guard case .symlinkLoop = error else {
Issue.record("Expected symlinkLoop for self-reference, got \(error)")
return
}
} catch {
Issue.record("Expected EXT4.PathIOError, got \(error)")
}
}
@Test
func longSymlinkChainWithoutLoop() throws {
let url = try buildFS { fmt in
// Create a long chain without loops (should succeed)
try self.createDir(fmt, "/target")
try self.createFile(fmt, "/target/file.txt", "SUCCESS\n")
// Create chain: /link1 -> /link2 -> /link3 -> /link4 -> /target/file.txt
try self.createSymlink(fmt, "/link4", "/target/file.txt")
try self.createSymlink(fmt, "/link3", "/link4")
try self.createSymlink(fmt, "/link2", "/link3")
try self.createSymlink(fmt, "/link1", "/link2")
}
defer { try? FileManager.default.removeItem(at: url) }
let r = try openReader(url)
// Should successfully resolve without hitting loop detection
let data = try r.readFile(at: FilePath("/link1"))
#expect(String(decoding: data, as: UTF8.self) == "SUCCESS\n")
}
@Test
func symlinkLoopThroughDirectory() throws {
let url = try buildFS { fmt in
// Create directory structure with symlink loop through paths
try self.createDir(fmt, "/dir1")
try self.createDir(fmt, "/dir2")
try self.createSymlink(fmt, "/dir1/link", "/dir2/link")
try self.createSymlink(fmt, "/dir2/link", "/dir1/link")
}
defer { try? FileManager.default.removeItem(at: url) }
let r = try openReader(url)
do {
_ = try r.stat(FilePath("/dir1/link"))
Issue.record("Expected symlinkLoop error")
} catch let error as EXT4.PathIOError {
guard case .symlinkLoop = error else {
Issue.record("Expected symlinkLoop for directory loop, got \(error)")
return
}
} catch {
Issue.record("Expected EXT4.PathIOError, got \(error)")
}
}
@Test
func pathWalkWithDotAndDotDot() throws {
let url = try buildFS { fmt in
try self.createDir(fmt, "/a/b")
try self.createFile(fmt, "/a/b/c.txt", "OK\n")
}
defer { try? FileManager.default.removeItem(at: url) }
let r = try openReader(url)
// /a/./b/../b/c.txt should resolve to /a/b/c.txt
let p = FilePath("/a/./b/../b/c.txt")
let data = try r.readFile(at: p)
#expect(String(decoding: data, as: UTF8.self) == "OK\n")
}
@Test
func parentDirectoryTraversal() throws {
let url = try buildFS { fmt in
try self.createDir(fmt, "/a/b/c/d")
try self.createFile(fmt, "/a/file1.txt", "A\n")
try self.createFile(fmt, "/a/b/file2.txt", "B\n")
try self.createFile(fmt, "/a/b/c/file3.txt", "C\n")
try self.createFile(fmt, "/a/b/c/d/file4.txt", "D\n")
}
defer { try? FileManager.default.removeItem(at: url) }
let r = try openReader(url)
// Test multiple levels of parent traversal
// /a/b/c/d/../../../../a/file1.txt should resolve to /a/file1.txt
let p1 = FilePath("/a/b/c/d/../../../../a/file1.txt")
let data1 = try r.readFile(at: p1)
#expect(String(decoding: data1, as: UTF8.self) == "A\n")
// /a/b/c/../file2.txt should resolve to /a/b/file2.txt
let p2 = FilePath("/a/b/c/../file2.txt")
let data2 = try r.readFile(at: p2)
#expect(String(decoding: data2, as: UTF8.self) == "B\n")
// /a/b/c/d/../file3.txt should resolve to /a/b/c/file3.txt
let p3 = FilePath("/a/b/c/d/../file3.txt")
let data3 = try r.readFile(at: p3)
#expect(String(decoding: data3, as: UTF8.self) == "C\n")
}
@Test
func parentDirectoryAtRoot() throws {
let url = try buildFS { fmt in
try self.createFile(fmt, "/root.txt", "ROOT\n")
}
defer { try? FileManager.default.removeItem(at: url) }
let r = try openReader(url)
// Test that ".." at root stays at root
// /../root.txt should resolve to /root.txt
let p1 = FilePath("/../root.txt")
let data1 = try r.readFile(at: p1)
#expect(String(decoding: data1, as: UTF8.self) == "ROOT\n")
// /../../root.txt should also resolve to /root.txt
let p2 = FilePath("/../../root.txt")
let data2 = try r.readFile(at: p2)
#expect(String(decoding: data2, as: UTF8.self) == "ROOT\n")
}
@Test
func complexParentWithSymlinks() throws {
let url = try buildFS { fmt in
try self.createDir(fmt, "/real/path")
try self.createFile(fmt, "/real/path/target.txt", "TARGET\n")
try self.createFile(fmt, "/real/other.txt", "OTHER\n")
try self.createDir(fmt, "/links")
// Create symlink: /links/link -> /real/path
try self.createSymlink(fmt, "/links/link", "/real/path")
}
defer { try? FileManager.default.removeItem(at: url) }
let r = try openReader(url)
// Test parent directory traversal through symlinks
// When we follow /links/link (which points to /real/path), we're now at /real/path
// Then ".." takes us to /real, and "other.txt" gives us /real/other.txt
let p = FilePath("/links/link/../other.txt")
let data = try r.readFile(at: p)
#expect(String(decoding: data, as: UTF8.self) == "OTHER\n")
// Also test direct access through the symlink
let p2 = FilePath("/links/link/target.txt")
let data2 = try r.readFile(at: p2)
#expect(String(decoding: data2, as: UTF8.self) == "TARGET\n")
}
@Test
func relativeSymlinkWithParentTraversal() throws {
let url = try buildFS { fmt in
try self.createDir(fmt, "/a/b")
try self.createFile(fmt, "/a/target.txt", "REL_TARGET\n")
// Create relative symlink: /a/b/link -> ../target.txt
try self.createSymlink(fmt, "/a/b/link", "../target.txt")
}
defer { try? FileManager.default.removeItem(at: url) }
let r = try openReader(url)
// The relative symlink should properly resolve through parent
let data = try r.readFile(at: FilePath("/a/b/link"))
#expect(String(decoding: data, as: UTF8.self) == "REL_TARGET\n")
}
@Test
func readOnSymlinkWithFollowFalseThrows() throws {
let url = try buildFS { fmt in
try self.createFile(fmt, "/tgt", "X\n")
try self.createSymlink(fmt, "/lnk", "/tgt")
}
defer { try? FileManager.default.removeItem(at: url) }
let r = try openReader(url)
do {
_ = try r.readFile(at: FilePath("/lnk"), followSymlinks: false)
Issue.record("Expected notAFile error")
} catch let error as EXT4.PathIOError {
guard case .notAFile = error else {
Issue.record("Expected notAFile for symlink read without following, got \(error)")
return
}
} catch {
Issue.record("Expected EXT4.PathIOError, got \(error)")
}
}
@Test
func nonExistentPathExistsAndReadErrors() throws {
let url = try buildFS { _ in }
defer { try? FileManager.default.removeItem(at: url) }
let r = try openReader(url)
#expect(!r.exists(FilePath("/nope")))
do {
_ = try r.stat(FilePath("/nope"))
Issue.record("Expected notFound error")
} catch let error as EXT4.PathIOError {
guard case .notFound = error else {
Issue.record("Expected notFound, got \(error)")
return
}
} catch {
Issue.record("Expected EXT4.PathIOError, got \(error)")
}
#expect(throws: (any Error).self) {
try r.readFile(at: FilePath("/nope"))
}
}
@Test
func sameAbsoluteSymlinkFollowedTwice() throws {
let url = try buildFS { fmt in
try self.createDir(fmt, "/target")
try self.createFile(fmt, "/target/file.txt", "OK")
try self.createSymlink(fmt, "/symlink", "/target")
}
defer { try? FileManager.default.removeItem(at: url) }
let r = try openReader(url)
let data = try r.readFile(at: FilePath("/symlink/../symlink/file.txt"))
#expect(String(decoding: data, as: UTF8.self) == "OK")
}
@Test
func sameRelativeSymlinkFollowedTwice() throws {
let url = try buildFS { fmt in
try self.createDir(fmt, "/target")
try self.createFile(fmt, "/target/file.txt", "OK")
try self.createSymlink(fmt, "/symlink", "../target")
}
defer { try? FileManager.default.removeItem(at: url) }
let r = try openReader(url)
let data = try r.readFile(at: FilePath("/symlink/../symlink/file.txt"))
#expect(String(decoding: data, as: UTF8.self) == "OK")
}
@Test
func boundsCheckingForInvalidExtents() throws {
// This test verifies that the reader properly validates extent addresses
// Note: We can't easily create an image with invalid extents using the Formatter,
// so this test documents the expected behavior rather than testing it directly.
// The bounds checking is tested implicitly by all other tests that read files.
let url = try buildFS { fmt in
try self.createFile(fmt, "/test.txt", "Valid file\n")
}
defer { try? FileManager.default.removeItem(at: url) }
let r = try openReader(url)
// Reading a valid file should work without bounds errors
let data = try r.readFile(at: FilePath("/test.txt"))
#expect(String(decoding: data, as: UTF8.self) == "Valid file\n")
// The bounds checking happens internally when reading extents
// If an extent pointed outside device bounds, it would throw an error
}
@Test
func partialReadRecovery() throws {
// Test that partial reads return successfully read data
// even if later parts fail
let url = try buildFS(minDiskSize: 8 * 1024 * 1024) { fmt in
// Create a moderately sized file
let content = String(repeating: "A", count: 100_000)
try self.createFile(fmt, "/partial.txt", content)
}
defer { try? FileManager.default.removeItem(at: url) }
let r = try openReader(url)
// Read the full file to ensure it works
let fullData = try r.readFile(at: FilePath("/partial.txt"))
#expect(fullData.count == 100_000)
// Read with offset and count
let partialData = try r.readFile(at: FilePath("/partial.txt"), offset: 1000, count: 5000)
#expect(partialData.count == 5000)
// Verify content is correct
let expectedContent = String(repeating: "A", count: 5000)
#expect(String(decoding: partialData, as: UTF8.self) == expectedContent)
}
@Test
func largeFileReadAcrossBlocks() throws {
// Keep this modest to avoid slow CI while still crossing multiple blocks.
let bigSize = 2 * 1024 * 1024 + 123 // ~2 MiB + tail
let url = try buildFS(minDiskSize: 16 * 1024 * 1024) { fmt in
try self.createDir(fmt, "/big")
// Generate deterministic content without holding huge Data in memory at once
// by assembling in chunks.
let chunk = Data("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ\n".utf8)
var buf = Data(capacity: bigSize)
while buf.count + chunk.count <= bigSize {
buf.append(chunk)
}
if buf.count < bigSize {
buf.append(contentsOf: [UInt8](repeating: 0x5A, count: bigSize - buf.count)) // 'Z'
}
let stream = InputStream(data: buf)
stream.open()
defer { stream.close() }
try fmt.create(path: FilePath("/big/file.bin"), mode: EXT4.Inode.Mode(.S_IFREG, 0o644), buf: stream)
}
defer { try? FileManager.default.removeItem(at: url) }
let r = try openReader(url)
// Sample reads near the end and across likely block boundaries.
let tail = try r.readFile(at: FilePath("/big/file.bin"), offset: UInt64(bigSize - 64), count: 64)
#expect(tail.count == 64)
let middle = try r.readFile(at: FilePath("/big/file.bin"), offset: 64, count: 128)
#expect(middle.count == 128)
// Read to EOF without count
let all = try r.readFile(at: FilePath("/big/file.bin"))
#expect(all.count == bigSize)
}
@Test
func fileTreeNodePathWithAbsoluteRoot() {
let tree = EXT4.FileTree(EXT4.RootInode, "/")
let dirPtr = EXT4.Ptr(EXT4.FileTree.FileTreeNode(inode: 3, name: "dir", parent: tree.root))
tree.root.pointee.children.append(dirPtr)
let filePtr = EXT4.Ptr(EXT4.FileTree.FileTreeNode(inode: 4, name: "file", parent: dirPtr))
dirPtr.pointee.children.append(filePtr)
#expect(dirPtr.pointee.path == FilePath("/dir"))
#expect(filePtr.pointee.path == FilePath("/dir/file"))
}
@Test
func fileTreeNodePathWithRelativeRoot() {
let tree = EXT4.FileTree(EXT4.RootInode, ".")
let dirPtr = EXT4.Ptr(EXT4.FileTree.FileTreeNode(inode: 3, name: "dir", parent: tree.root))
tree.root.pointee.children.append(dirPtr)
let filePtr = EXT4.Ptr(EXT4.FileTree.FileTreeNode(inode: 4, name: "file", parent: dirPtr))
dirPtr.pointee.children.append(filePtr)
#expect(dirPtr.pointee.path == FilePath("dir"))
#expect(filePtr.pointee.path == FilePath("dir/file"))
}
@Test
func fileTreeNodePathWithNamedRoot() {
let tree = EXT4.FileTree(EXT4.RootInode, "dir")
let filePtr = EXT4.Ptr(EXT4.FileTree.FileTreeNode(inode: 3, name: "file", parent: tree.root))
tree.root.pointee.children.append(filePtr)
#expect(filePtr.pointee.path == FilePath("dir/file"))
}
}
@@ -0,0 +1,105 @@
//===----------------------------------------------------------------------===//
// 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 Foundation
import SystemPackage
import Testing
@testable import ContainerizationEXT4
struct Ext4UnpackerTests {
// alpine image
let indexSHA: String = "ad59e9f71edceca7b1ac7c642410858489b743c97233b0a26a5e2098b1443762"
let fsPath = FilePath(
FileManager.default.uniqueTemporaryDirectory()
.appendingPathComponent("ext4.unpacked.oci.img.delme", isDirectory: false))
final class MockEXT4Unpacker {
static func Unpack(index: String, fsPath: FilePath) async throws {
let fs = try EXT4.Formatter(fsPath)
let bundle = Bundle.module
guard let indexPath = bundle.url(forResource: index, withExtension: nil) else {
throw NSError(domain: "indexPath not found", code: 1)
}
let indexData = try Data(contentsOf: indexPath)
guard let indexDict = try JSONSerialization.jsonObject(with: indexData, options: []) as? [String: Any]
else {
throw NSError(domain: "indexDict could not be loaded as json", code: 1)
}
guard let manifests = indexDict["manifests"] as? [[String: Any]] else {
throw NSError(domain: "manifests field in index not found", code: 1)
}
guard let digest = manifests[0]["digest"] as? String else {
throw NSError(domain: "digest field not found in index", code: 1)
}
guard
let manifestPath = bundle.url(
forResource: String(digest.dropFirst("sha256:".count)), withExtension: nil)
else {
throw NSError(domain: "manifestPath not found", code: 1)
}
let manifestData = try Data(contentsOf: manifestPath)
guard let manifestDict = try JSONSerialization.jsonObject(with: manifestData, options: []) as? [String: Any]
else {
throw NSError(domain: "manifestDict could not be loaded as json", code: 1)
}
guard let layers = manifestDict["layers"] as? [[String: Any]] else {
throw NSError(domain: "layers field in manifests not found", code: 1)
}
for layer in layers {
guard let layerDigestWithSHA = layer["digest"] as? String else {
throw NSError(domain: "digest field not found in layer", code: 1)
}
let layerDigest = String(layerDigestWithSHA.dropFirst("sha256:".count))
guard let layerPath = bundle.url(forResource: layerDigest, withExtension: nil) else {
throw NSError(domain: "layer \(layerDigest) not found", code: 1)
}
try await fs.unpack(source: layerPath)
}
try fs.close()
}
}
@Test func eXT4Unpacker() async throws {
try await MockEXT4Unpacker.Unpack(index: self.indexSHA, fsPath: self.fsPath)
let ext4 = try EXT4.EXT4Reader(blockDevice: self.fsPath)
let children = try ext4.children(of: EXT4.RootInode)
#expect(
Set(children.map { $0.0 })
== Set([
".",
"..",
"media",
"var",
"opt",
"lost+found",
"tmp",
"mnt",
"sys",
"usr",
"srv",
"root",
"etc",
"dev",
"proc",
"run",
"home",
"bin",
"lib",
"sbin",
]))
}
}
@@ -0,0 +1,514 @@
//===----------------------------------------------------------------------===//
// 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.
//===----------------------------------------------------------------------===//
// swiftlint:disable force_try static_over_final_class
#if os(macOS)
import ContainerizationArchive
import ContainerizationExtras
import Foundation
import Testing
import SystemPackage
@testable import ContainerizationEXT4
struct Tar2EXT4Test: ~Copyable {
let fsPath = FilePath(
FileManager.default.uniqueTemporaryDirectory()
.appendingPathComponent("ext4.tar.img.delme", isDirectory: false))
let xattrs: [String: Data] = [
"foo.bar": Data([1, 2, 3]),
"bar": Data([0, 0, 0]),
"system.richacl.bar": Data([99, 1, 9, 1]),
"foobar.user": Data([71, 2, 45]),
"test.xattr.cap": Data([1, 32, 3]),
"testing123": Data([12, 24, 45]),
"sys.admin": Data([16, 23, 13]),
"test.123": Data([15, 26, 54]),
"extendedattribute.test": Data([15, 26, 54, 1, 2, 4, 6, 7, 7]),
]
let layerDir: URL
let layer1Path: URL
let layer2Path: URL
init() throws {
// Compute paths before any throwing code to satisfy ~Copyable initialization rules.
let layerDir = FileManager.default.uniqueTemporaryDirectory()
let layer1Path = layerDir.appendingPathComponent("layer1.tar.gz", isDirectory: false)
let layer2Path = layerDir.appendingPathComponent("layer2.tar.gz", isDirectory: false)
self.layerDir = layerDir
self.layer1Path = layer1Path
self.layer2Path = layer2Path
// create layer1
let layer1Archiver = try ArchiveWriter(
configuration: ArchiveWriterConfiguration(format: .paxRestricted, filter: .gzip))
try layer1Archiver.open(file: layer1Path)
// create 2 directories and fill them with files
try layer1Archiver.writeEntry(entry: WriteEntry.dir(path: "/dir1", permissions: 0o755), data: nil)
try layer1Archiver.writeEntry(entry: WriteEntry.file(path: "/dir1/file1", permissions: 0o644), data: nil)
try layer1Archiver.writeEntry(entry: WriteEntry.dir(path: "/dir2", permissions: 0o755), data: nil)
try layer1Archiver.writeEntry(entry: WriteEntry.file(path: "/dir2/file1", permissions: 0o644), data: nil)
try layer1Archiver.finishEncoding()
// create layer2
let layer2Archiver = try ArchiveWriter(
configuration: ArchiveWriterConfiguration(format: .paxRestricted, filter: .gzip))
try layer2Archiver.open(file: layer2Path)
// create 3 directories and fill them with files and whiteouts
try layer2Archiver.writeEntry(entry: WriteEntry.dir(path: "/dir1", permissions: 0o755), data: nil)
try layer2Archiver.writeEntry(
entry: WriteEntry.file(path: "/dir1/.wh.file1", permissions: 0o644), data: nil)
try layer2Archiver.writeEntry(entry: WriteEntry.dir(path: "/dir2", permissions: 0o755), data: nil)
try layer2Archiver.writeEntry(
entry: WriteEntry.file(path: "/dir2/.wh..wh..opq", permissions: 0o644), data: nil)
try layer2Archiver.writeEntry(entry: WriteEntry.dir(path: "/dir3", permissions: 0o755), data: nil)
try layer2Archiver.writeEntry(
entry: WriteEntry.file(path: "/dir3/file1", permissions: 0o644, xattrs: xattrs), data: nil)
try layer2Archiver.writeEntry(entry: WriteEntry.dir(path: "/dir4", permissions: 0o755), data: nil)
try layer2Archiver.writeEntry(
entry: WriteEntry.file(path: "/dir4/special_ĩ", permissions: 0o644), data: nil)
try layer2Archiver.writeEntry(
entry: WriteEntry.link(path: "/dir4/specialcharacters", permissions: 0o644, target: "special_ĩ"),
data: nil)
// a new layer overwriting over an existing layer
try layer2Archiver.writeEntry(entry: WriteEntry.file(path: "/dir2/file1", permissions: 0o644), data: nil)
try layer2Archiver.finishEncoding()
}
private func unpackLayers() async throws {
let formatter = try EXT4.Formatter(fsPath)
try await formatter.unpack(source: layer1Path)
try await formatter.unpack(source: layer2Path)
try formatter.close()
}
deinit {
try? FileManager.default.removeItem(at: fsPath.url)
try? FileManager.default.removeItem(at: layerDir)
}
@Test func testUnpackBasic() async throws {
try await unpackLayers()
let ext4 = try EXT4.EXT4Reader(blockDevice: fsPath)
// just a directory
let dir1Inode = try ext4.getInode(number: 12)
#expect(dir1Inode.mode.isDir())
// white out file /dir1/file1
let dir1File1Inode = try ext4.getInode(number: 13)
#expect(dir1File1Inode.dtime != 0)
#expect(dir1File1Inode.linksCount == 0) // deleted
// white out dir /dir2
let dir2Inode = try ext4.getInode(number: 14)
#expect(dir2Inode.dtime == 0)
#expect(dir2Inode.linksCount == 2) // children deleted
// new dir /dir3
let dir3Inode = try ext4.getInode(number: 16)
#expect(dir3Inode.mode.isDir())
#expect(dir3Inode.linksCount == 2)
// new file /dir3/file1
let dir3File1Inode = try ext4.getInode(number: 17)
#expect(dir3File1Inode.mode.isReg())
#expect(dir3File1Inode.linksCount == 1)
#expect(try ext4.getXattrsForInode(inode: dir3File1Inode) == xattrs)
// overwritten dir /dir2
let dir2OverwriteInode = try ext4.getInode(number: 18)
#expect(dir2OverwriteInode.mode.isDir())
#expect(dir2OverwriteInode.linksCount == 2)
// /dir4/special_ĩ
let dir2File1OverwriteInode = try ext4.getInode(number: 19)
#expect(dir2File1OverwriteInode.mode.isReg())
#expect(dir2File1OverwriteInode.linksCount == 1)
let specialFileInode = try ext4.getInode(number: 20)
let bytes = Data(Mirror(reflecting: specialFileInode.block).children.compactMap { $0.value as? UInt8 })
let targetString = try #require(String(data: bytes, encoding: .utf8), "Could not parse special file path")
let specialFileTarget = FilePath(targetString)
#expect(specialFileTarget.description.hasPrefix("special_ĩ"))
}
}
/// Collects progress events in a thread-safe manner.
private actor ProgressCollector {
var events: [ProgressEvent] = []
func append(_ newEvents: [ProgressEvent]) {
events.append(contentsOf: newEvents)
}
func allEvents() -> [ProgressEvent] {
events
}
}
struct UnpackProgressTest {
@Test func progressReportsAccurateSizes() async throws {
// Create an archive with files of known sizes
let tempDir = FileManager.default.uniqueTemporaryDirectory()
let archivePath = tempDir.appendingPathComponent("test.tar.gz", isDirectory: false)
let fsPath = FilePath(tempDir.appendingPathComponent("test.ext4.img", isDirectory: false))
defer {
try? FileManager.default.removeItem(at: tempDir)
}
// Create test data with specific sizes
let file1Data = Data(repeating: 0xAA, count: 1024) // 1 KiB
let file2Data = Data(repeating: 0xBB, count: 4096) // 4 KiB
let file3Data = Data(repeating: 0xCC, count: 512) // 512 bytes
let expectedTotalSize: Int64 = 1024 + 4096 + 512 // 5632 bytes
// Build the archive
let archiver = try ArchiveWriter(
configuration: ArchiveWriterConfiguration(format: .paxRestricted, filter: .gzip))
try archiver.open(file: archivePath)
try archiver.writeEntry(entry: WriteEntry.dir(path: "/data", permissions: 0o755), data: nil)
try archiver.writeEntry(
entry: WriteEntry.file(path: "/data/file1.bin", permissions: 0o644, size: Int64(file1Data.count)),
data: file1Data)
try archiver.writeEntry(
entry: WriteEntry.file(path: "/data/file2.bin", permissions: 0o644, size: Int64(file2Data.count)),
data: file2Data)
try archiver.writeEntry(
entry: WriteEntry.file(path: "/data/file3.bin", permissions: 0o644, size: Int64(file3Data.count)),
data: file3Data)
// Include an empty file to verify it doesn't break size calculations
try archiver.writeEntry(
entry: WriteEntry.file(path: "/data/empty.bin", permissions: 0o644, size: 0),
data: Data())
try archiver.finishEncoding()
// Set up progress collection
let collector = ProgressCollector()
let shouldPrintProgress = ProcessInfo.processInfo.environment["PRINT_UNPACK_PROGRESS"] == "1"
let progressHandler: ProgressHandler = { events in
if shouldPrintProgress {
for event in events {
print("unpack-progress \(event.event): \(event.value)")
}
}
await collector.append(events)
}
// Unpack with progress tracking
let formatter = try EXT4.Formatter(fsPath)
try await formatter.unpack(source: archivePath, progress: progressHandler)
try formatter.close()
// Analyze collected events
let allEvents = await collector.allEvents()
var reportedTotalSize: Int64 = 0
var reportedTotalItems: Int = 0
var cumulativeSize: Int64 = 0
var itemCount: Int = 0
for event in allEvents {
switch event {
case .addTotalSize(let value):
reportedTotalSize += value
case .addTotalItems(let value):
reportedTotalItems += value
case .addSize(let value):
cumulativeSize += value
case .addItems(let value):
itemCount += value
}
}
// Verify the progress contract
let expectedTotalItems: Int = 5 // 1 dir + 4 files
#expect(
reportedTotalSize == expectedTotalSize,
"Total size should be \(expectedTotalSize) bytes, got \(reportedTotalSize)")
#expect(
reportedTotalItems == expectedTotalItems,
"Total items should be \(expectedTotalItems), got \(reportedTotalItems)")
#expect(
cumulativeSize == expectedTotalSize,
"Cumulative size should equal total size (\(expectedTotalSize)), got \(cumulativeSize)")
#expect(
itemCount == expectedTotalItems,
"Should have processed \(expectedTotalItems) entries (1 dir + 4 files), got \(itemCount)")
// Verify incremental progress: we should get separate add-size events for each file
let addSizeEvents = allEvents.compactMap { event -> Int64? in
guard case .addSize(let value) = event else { return nil }
return value
}
#expect(
addSizeEvents.count == 4,
"Should have 4 add-size events (one per file, including empty), got \(addSizeEvents.count)")
// Verify individual file sizes were reported correctly
let reportedSizes = addSizeEvents.sorted()
#expect(
reportedSizes == [0, 512, 1024, 4096],
"Individual file sizes should be [0, 512, 1024, 4096], got \(reportedSizes)")
// Verify event-by-event behavior expected by clients:
// total remains stable and written bytes are monotonic as progress updates arrive.
var runningTotal: Int64?
var runningWritten: Int64 = 0
var previousSnapshot: (written: Int64, total: Int64?)?
var progressSnapshotCount = 0
for event in allEvents {
switch event {
case .addTotalSize(let value):
runningTotal = (runningTotal ?? 0) + value
case .addSize(let value):
runningWritten += value
let currentSnapshot = (written: runningWritten, total: runningTotal)
if let previousSnapshot {
#expect(
currentSnapshot.written >= previousSnapshot.written,
"Written bytes should be monotonic: \(currentSnapshot.written) < \(previousSnapshot.written)")
#expect(
currentSnapshot.total == previousSnapshot.total,
"Total bytes should remain stable across progress updates")
}
previousSnapshot = currentSnapshot
progressSnapshotCount += 1
default:
break
}
}
#expect(
progressSnapshotCount == addSizeEvents.count,
"Should produce one monotonic snapshot per add-size update")
// Verify totals come before incremental events (first pass before second pass)
let totalSizeIndex = try #require(
allEvents.firstIndex(where: {
guard case .addTotalSize = $0 else { return false }
return true
}),
"add-total-size event should be present")
let firstAddSizeIndex = try #require(
allEvents.firstIndex(where: {
guard case .addSize = $0 else { return false }
return true
}),
"add-size event should be present")
#expect(
totalSizeIndex < firstAddSizeIndex,
"add-total-size should be reported before add-size events")
let totalItemsIndex = try #require(
allEvents.firstIndex(where: {
guard case .addTotalItems = $0 else { return false }
return true
}),
"add-total-items event should be present")
let firstAddItemsIndex = try #require(
allEvents.firstIndex(where: {
guard case .addItems = $0 else { return false }
return true
}),
"add-items event should be present")
#expect(
totalItemsIndex < firstAddItemsIndex,
"add-total-items should be reported before add-items events")
}
@Test func progressHandlerIsOptional() async throws {
// Verify that unpacking works without a progress handler (existing behavior)
let tempDir = FileManager.default.uniqueTemporaryDirectory()
let archivePath = tempDir.appendingPathComponent("test.tar.gz", isDirectory: false)
let fsPath = FilePath(tempDir.appendingPathComponent("test.ext4.img", isDirectory: false))
defer {
try? FileManager.default.removeItem(at: tempDir)
}
let archiver = try ArchiveWriter(
configuration: ArchiveWriterConfiguration(format: .paxRestricted, filter: .gzip))
try archiver.open(file: archivePath)
try archiver.writeEntry(entry: WriteEntry.dir(path: "/test", permissions: 0o755), data: nil)
let data = Data(repeating: 0x42, count: 100)
try archiver.writeEntry(
entry: WriteEntry.file(path: "/test/file.bin", permissions: 0o644, size: Int64(data.count)),
data: data)
try archiver.finishEncoding()
// Unpack without progress handler - should not throw
let formatter = try EXT4.Formatter(fsPath)
try await formatter.unpack(source: archivePath)
try formatter.close()
// Verify the file was unpacked correctly
let reader = try EXT4.EXT4Reader(blockDevice: fsPath)
let children = try reader.children(of: EXT4.RootInode)
let childNames = Set(children.map { $0.0 })
#expect(childNames.contains("test"), "Directory 'test' should exist in unpacked filesystem")
}
@Test func unpackCreatesImplicitParentsForHardlink() async throws {
// A hardlink whose parent dir has no explicit archive entry must unpack: the
// missing parents are created implicitly instead of failing with "... not found".
// This is the exact repro shape (e.g. Bazel rules_img runfiles trees).
let tempDir = FileManager.default.uniqueTemporaryDirectory()
let archivePath = tempDir.appendingPathComponent("hardlink.tar.gz", isDirectory: false)
let fsPath = FilePath(tempDir.appendingPathComponent("hardlink.ext4.img", isDirectory: false))
defer { try? FileManager.default.removeItem(at: tempDir) }
let archiver = try ArchiveWriter(
configuration: ArchiveWriterConfiguration(format: .paxRestricted, filter: .gzip))
try archiver.open(file: archivePath)
// The hardlink target. /bin itself has no explicit dir entry either.
let payload = Data("hello".utf8)
try archiver.writeEntry(
entry: WriteEntry.file(path: "/bin/app", permissions: 0o755, size: Int64(payload.count)),
data: payload)
// The parent dir /bin/app.runfiles/_main/app_ has no explicit archive entry.
try archiver.writeEntry(
entry: WriteEntry.hardlink(path: "/bin/app.runfiles/_main/app_/app", target: "/bin/app"),
data: nil)
try archiver.finishEncoding()
let formatter = try EXT4.Formatter(fsPath)
try await formatter.unpack(source: archivePath) // must not throw notFound
try formatter.close()
let reader = try EXT4.EXT4Reader(blockDevice: fsPath)
// Implicitly-created parent directories exist and are directories.
#expect(try reader.stat("/bin/app.runfiles").inode.mode.isDir())
#expect(try reader.stat("/bin/app.runfiles/_main").inode.mode.isDir())
#expect(try reader.stat("/bin/app.runfiles/_main/app_").inode.mode.isDir())
// Hardlink resolves to the target inode and bumps the link count to 2.
let target = try reader.stat("/bin/app")
let hardlink = try reader.stat("/bin/app.runfiles/_main/app_/app")
#expect(hardlink.inodeNumber == target.inodeNumber)
#expect(target.inode.linksCount == 2)
}
@Test func unpackCreatesImplicitParentsForSymlink() async throws {
// A symlink whose parent dir has no explicit archive entry must unpack.
let tempDir = FileManager.default.uniqueTemporaryDirectory()
let archivePath = tempDir.appendingPathComponent("symlink.tar.gz", isDirectory: false)
let fsPath = FilePath(tempDir.appendingPathComponent("symlink.ext4.img", isDirectory: false))
defer { try? FileManager.default.removeItem(at: tempDir) }
let archiver = try ArchiveWriter(
configuration: ArchiveWriterConfiguration(format: .paxRestricted, filter: .gzip))
try archiver.open(file: archivePath)
// The parent dir /etc/links has no explicit archive entry.
try archiver.writeEntry(
entry: WriteEntry.link(path: "/etc/links/cur", permissions: 0o777, target: "/bin/app"),
data: nil)
try archiver.finishEncoding()
let formatter = try EXT4.Formatter(fsPath)
try await formatter.unpack(source: archivePath) // must not throw notFound
try formatter.close()
let reader = try EXT4.EXT4Reader(blockDevice: fsPath)
#expect(try reader.stat("/etc/links").inode.mode.isDir())
#expect(try reader.stat("/etc/links/cur", followSymlinks: false).inode.mode.isLink())
}
@Test func unpackCreatesImplicitParentsForRegularFile() async throws {
// A regular file whose parent dir has no explicit archive entry must unpack.
let tempDir = FileManager.default.uniqueTemporaryDirectory()
let archivePath = tempDir.appendingPathComponent("file.tar.gz", isDirectory: false)
let fsPath = FilePath(tempDir.appendingPathComponent("file.ext4.img", isDirectory: false))
defer { try? FileManager.default.removeItem(at: tempDir) }
let archiver = try ArchiveWriter(
configuration: ArchiveWriterConfiguration(format: .paxRestricted, filter: .gzip))
try archiver.open(file: archivePath)
// The parent dir /var/lib/data has no explicit archive entry.
let payload = Data("world".utf8)
try archiver.writeEntry(
entry: WriteEntry.file(path: "/var/lib/data/file.txt", permissions: 0o644, size: Int64(payload.count)),
data: payload)
try archiver.finishEncoding()
let formatter = try EXT4.Formatter(fsPath)
try await formatter.unpack(source: archivePath) // must not throw notFound
try formatter.close()
let reader = try EXT4.EXT4Reader(blockDevice: fsPath)
#expect(try reader.stat("/var/lib/data").inode.mode.isDir())
#expect(try reader.stat("/var/lib/data/file.txt").inode.mode.isReg())
#expect(try reader.readFile(at: "/var/lib/data/file.txt") == payload)
}
}
extension ContainerizationArchive.WriteEntry {
static func dir(path: String, permissions: UInt16) -> WriteEntry {
let entry = WriteEntry()
entry.path = path
entry.fileType = .directory
entry.permissions = permissions
return entry
}
static func file(path: String, permissions: UInt16, size: Int64? = nil, xattrs: [String: Data]? = nil) -> WriteEntry {
let entry = WriteEntry()
entry.path = path
entry.fileType = .regular
entry.permissions = permissions
entry.size = size
if let xattrs {
entry.xattrs = xattrs
}
return entry
}
static func link(path: String, permissions: UInt16, target: String) -> WriteEntry {
let entry = WriteEntry()
entry.path = path
entry.fileType = .symbolicLink
entry.symlinkTarget = target
return entry
}
static func hardlink(path: String, target: String) -> WriteEntry {
let entry = WriteEntry()
entry.path = path
entry.fileType = .regular
entry.hardlink = target
return entry
}
}
extension EXT4.EXT4Reader {
fileprivate func getXattrsForInode(inode: EXT4.Inode) throws -> [String: Data] {
var attributes: [EXT4.ExtendedAttribute] = []
let buffer: [UInt8] = EXT4.tupleToArray(inode.inlineXattrs)
try attributes.append(contentsOf: Self.readInlineExtendedAttributes(from: buffer))
let block = inode.xattrBlockLow
try self.seek(block: block)
let buf = try self.handle.read(upToCount: Int(self.blockSize))!
try attributes.append(contentsOf: Self.readBlockExtendedAttributes(from: [UInt8](buf)))
var xattrs: [String: Data] = [:]
for attribute in attributes {
guard attribute.fullName != "system.data" else {
continue
}
xattrs[attribute.fullName] = Data(attribute.value)
}
return xattrs
}
}
#endif