Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 31 additions & 6 deletions Sources/ContainerizationEXT4/EXT4+Formatter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<FileTree.FileTreeNode>?, entry: Ptr<FileTree.FileTreeNode>)] = 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<FileTree.FileTreeNode>?, pathNodePtr: Ptr<FileTree.FileTreeNode>) 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
Expand All @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -953,7 +978,7 @@ extension EXT4 {
contentsOf: Array<UInt8>.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<UInt8>.init(repeating: 0, count: Int(self.blockSize))
for _ in 0..<(rest / self.blockSize) {
try self.handle.write(contentsOf: zeroBlock)
Expand Down
4 changes: 4 additions & 0 deletions Sources/ContainerizationEXT4/FilePath+Extensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand Down
10 changes: 3 additions & 7 deletions Sources/ContainerizationEXT4/UnsafeLittleEndianBytes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
2 changes: 1 addition & 1 deletion Sources/ContainerizationOS/Socket/Socket.swift
Original file line number Diff line number Diff line change
Expand Up @@ -329,7 +329,7 @@ extension Socket {

var cmsgBuf = [UInt8](repeating: 0, count: Int(CZ_CMSG_SPACE(Int(MemoryLayout<Int32>.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)
Expand Down
232 changes: 232 additions & 0 deletions Tests/ContainerizationEXT4Tests/TestEXT4Format+Unlink.swift
Original file line number Diff line number Diff line change
@@ -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<T>(
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)
}
}
}