diff --git a/Sources/ContainerizationEXT4/EXT4+Formatter.swift b/Sources/ContainerizationEXT4/EXT4+Formatter.swift index 15957824..85126500 100644 --- a/Sources/ContainerizationEXT4/EXT4+Formatter.swift +++ b/Sources/ContainerizationEXT4/EXT4+Formatter.swift @@ -201,21 +201,42 @@ extension EXT4 { let pathNode = pathPtr.pointee let inodeNumber = Int(pathNode.inode) - 1 let pathInodePtr = self.inodes[inodeNumber] - var pathInode = pathInodePtr.pointee + let pathInode = pathInodePtr.pointee if directoryWhiteout && !pathInode.mode.isDir() { throw Error.notDirectory(path) } - for childPtr in pathNode.children { - try self.unlink(path: path.join(childPtr.pointee.name)) + // Iterative breath-first traversal of the FileTree to prevent recursion attacks + var queue: [(parent: Ptr?, entry: Ptr)] = pathNode.children.map { (pathPtr, $0) } + var head: Int = 0 + while head < queue.count { + let currNode = queue[head].entry + for childPtr in currNode.pointee.children { + queue.append((currNode, childPtr)) + } + head += 1 + } + + for (parent, entry) in queue.reversed() { + try _unlink(parentNodePtr: parent, pathNodePtr: entry) } guard !directoryWhiteout else { return } - if let parentNodePtr = self.tree.lookup(path: path.dir) { + try _unlink(parentNodePtr: self.tree.lookup(path: path.dir), pathNodePtr: pathPtr) + } + + private func _unlink(parentNodePtr: Ptr?, pathNodePtr: Ptr) throws { + let pathNode = pathNodePtr.pointee + let pathComponent = pathNode.name + let inodeNumber = Int(pathNode.inode) - 1 + let pathInodePtr = self.inodes[inodeNumber] + var pathInode = pathInodePtr.pointee + + if let parentNodePtr { let parentNode = parentNodePtr.pointee let parentInodePtr = self.inodes[Int(parentNode.inode) - 1] var parentInode = parentInodePtr.pointee @@ -226,7 +247,7 @@ extension EXT4 { } parentInodePtr.initialize(to: parentInode) parentNode.children.removeAll { childPtr in - childPtr.pointee.name == path.base + childPtr.pointee.name == pathComponent } parentNodePtr.initialize(to: parentNode) } @@ -347,6 +368,10 @@ extension EXT4 { guard mode.isLink() else { // unless it is a link, then it can be replaced by a dir throw Error.notFile(path) } + // root cannot be replaced with a link + if path.isRoot { + throw Error.unsupportedFiletype + } } try self.unlink(path: path) } @@ -953,7 +978,7 @@ extension EXT4 { contentsOf: Array.init(repeating: 0, count: Int(EXT4.InodeSize) - inodeSize)) } let tableSize: UInt64 = UInt64(EXT4.InodeSize) * blockGroups * inodesPerGroup - let rest = tableSize - uint32(self.inodes.count) * EXT4.InodeSize + let rest = tableSize - UInt32(self.inodes.count) * EXT4.InodeSize let zeroBlock = Array.init(repeating: 0, count: Int(self.blockSize)) for _ in 0..<(rest / self.blockSize) { try self.handle.write(contentsOf: zeroBlock) diff --git a/Sources/ContainerizationEXT4/FilePath+Extensions.swift b/Sources/ContainerizationEXT4/FilePath+Extensions.swift index d4004b73..ddd6f30a 100644 --- a/Sources/ContainerizationEXT4/FilePath+Extensions.swift +++ b/Sources/ContainerizationEXT4/FilePath+Extensions.swift @@ -49,6 +49,10 @@ extension FilePath { self.components.map { $0.string } } + public var isRoot: Bool { // platform agnostic + self.removingRoot().isEmpty + } + public init(_ url: URL) { self.init(url.path(percentEncoded: false)) } diff --git a/Sources/ContainerizationEXT4/UnsafeLittleEndianBytes.swift b/Sources/ContainerizationEXT4/UnsafeLittleEndianBytes.swift index 8f3852f0..299fdef0 100644 --- a/Sources/ContainerizationEXT4/UnsafeLittleEndianBytes.swift +++ b/Sources/ContainerizationEXT4/UnsafeLittleEndianBytes.swift @@ -71,12 +71,8 @@ public enum Endianness { // returns current endianness public var Endian: Endianness { - switch CFByteOrderGetCurrent() { - case CFByteOrder(CFByteOrderLittleEndian.rawValue): - return .little - case CFByteOrder(CFByteOrderBigEndian.rawValue): - return .big - default: - fatalError("impossible") + var value: UInt32 = 0x0102_0304 + return withUnsafeBytes(of: &value) { buffer in + buffer.first == 0x04 ? .little : .big } } diff --git a/Sources/ContainerizationOS/Socket/Socket.swift b/Sources/ContainerizationOS/Socket/Socket.swift index 33954516..758311e4 100644 --- a/Sources/ContainerizationOS/Socket/Socket.swift +++ b/Sources/ContainerizationOS/Socket/Socket.swift @@ -329,7 +329,7 @@ extension Socket { var cmsgBuf = [UInt8](repeating: 0, count: Int(CZ_CMSG_SPACE(Int(MemoryLayout.size)))) msg.msg_control = withUnsafeMutablePointer(to: &cmsgBuf[0]) { UnsafeMutableRawPointer($0) } - msg.msg_controllen = socklen_t(cmsgBuf.count) + msg.msg_controllen = numericCast(cmsgBuf.count) let recvResult = withUnsafeMutablePointer(to: &msg) { msgPtr in sysRecvmsg(handle.fileDescriptor, msgPtr, 0) diff --git a/Tests/ContainerizationEXT4Tests/TestEXT4Format+Unlink.swift b/Tests/ContainerizationEXT4Tests/TestEXT4Format+Unlink.swift new file mode 100644 index 00000000..a0fac05d --- /dev/null +++ b/Tests/ContainerizationEXT4Tests/TestEXT4Format+Unlink.swift @@ -0,0 +1,232 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 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) throws -> T + ) 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 body(formatter, imagePath) + return result + } + + @Test + func unpack_with_opaque_whiteout_path_does_not_stack_overflow_and_cleans_directory() throws { + let layerURL = try makeTempFileURL(prefix: "ext4-wh-layer") + defer { + try? FileManager.default.removeItem(at: layerURL) + } + + try writeLayerWithOpaqueWhiteout(to: layerURL) + + try withFormatter { formatter, imagePath in + try 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() throws { + try 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() throws { + #expect(throws: EXT4.Formatter.Error.unsupportedFiletype) { + try 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() throws { + try 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) + } + } +}