chore: import upstream snapshot with attribution
This commit is contained in:
+21
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
Binary file not shown.
+1
@@ -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"}
|
||||
+1
@@ -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"}}
|
||||
BIN
Binary file not shown.
@@ -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
|
||||
// Lower‑layer 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
|
||||
Reference in New Issue
Block a user