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