Vendor apple/containerization with a VM-extensions forwarding patch
Switch the containerization dependency from the github URL to a vendored copy (third_party/containerization, upstream commit 6b7b42ca) referenced by path, so we can carry a small local patch that upstream lacks: LinuxContainer.Configuration gains a `vmExtensions` field forwarded into VMConfiguration.extensions. Upstream already supports VMConfiguration.extensions + the VZInstanceExtension hook, but LinuxContainer — our only entry point — never forwarded them, so there was no way to attach a device (e.g. a memory balloon) to a container's VM. Tests/, docs/, examples/, images/ and the corresponding test targets are trimmed for footprint (we never build the dependency's tests). See PATCHES.md for the full diff vs. upstream and the re-vendoring procedure. Also adds the ContainerizationExtras product to NucleicCore (AddressAllocator, named in the configureVZ signature). Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
@@ -0,0 +1,495 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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
|
||||
|
||||
extension EXT4 {
|
||||
public enum PathIOError: Swift.Error, CustomStringConvertible {
|
||||
case notFound(String)
|
||||
case notAFile(String)
|
||||
case isDirectory(String)
|
||||
case notADirectory(String)
|
||||
case symlinkLoop(String)
|
||||
case invalidPath(String)
|
||||
|
||||
public var description: String {
|
||||
switch self {
|
||||
case .notFound(let p): return "no such file or directory: \(p)"
|
||||
case .notAFile(let p): return "not a regular file: \(p)"
|
||||
case .isDirectory(let p): return "is a directory: \(p)"
|
||||
case .notADirectory(let p): return "not a directory: \(p)"
|
||||
case .symlinkLoop(let p): return "symlink loop while resolving: \(p)"
|
||||
case .invalidPath(let p): return "invalid path: \(p)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
extension EXT4.EXT4Reader {
|
||||
/// Return true if a path exists (file or directory) in this ext4 device.
|
||||
public func exists(_ path: FilePath, followSymlinks: Bool = true) -> Bool {
|
||||
(try? resolvePath(path, followSymlinks: followSymlinks).inode) != nil
|
||||
}
|
||||
|
||||
/// Get the total number of blocks in the filesystem
|
||||
private var totalBlocks: UInt64 {
|
||||
let lo = UInt64(_superBlock.blocksCountLow)
|
||||
let hi = UInt64(_superBlock.blocksCountHigh)
|
||||
return lo | (hi << 32)
|
||||
}
|
||||
|
||||
/// Validate that a physical block address is within device bounds
|
||||
private func validateBlockAddress(_ block: UInt32) throws {
|
||||
guard UInt64(block) < totalBlocks else {
|
||||
throw EXT4.PathIOError.invalidPath("block address \(block) exceeds device bounds (\(totalBlocks) blocks)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Metadata (inode + inode number) for a path.
|
||||
public func stat(_ path: FilePath, followSymlinks: Bool = true) throws -> (inodeNumber: EXT4.InodeNumber, inode: EXT4.Inode) {
|
||||
let resolved = try resolvePath(path, followSymlinks: followSymlinks)
|
||||
return (resolved.inodeNum, try getInode(number: resolved.inodeNum))
|
||||
}
|
||||
|
||||
/// List a directory's entries (names only). Does not include "." or "..".
|
||||
public func listDirectory(_ path: FilePath) throws -> [String] {
|
||||
let (inoNum, ino) = try stat(path)
|
||||
guard ino.mode.isDir() else {
|
||||
throw EXT4.PathIOError.notADirectory(path.description)
|
||||
}
|
||||
let children = try children(of: inoNum)
|
||||
return
|
||||
children
|
||||
.map { $0.0 }
|
||||
.filter { $0 != "." && $0 != ".." }
|
||||
.sorted()
|
||||
}
|
||||
|
||||
/// Read bytes from a regular file at `path` into `buffer`, starting at `offset`.
|
||||
/// Returns the number of bytes written to `buffer` (may be less than `buffer.count` at EOF).
|
||||
/// - Note: Semantics mirror `read(2)`: partial reads are possible.
|
||||
@discardableResult
|
||||
public func readFile(
|
||||
at path: FilePath,
|
||||
into buffer: UnsafeMutableRawBufferPointer,
|
||||
offset: UInt64 = 0,
|
||||
followSymlinks: Bool = true
|
||||
) throws -> Int {
|
||||
let context = try prepareRead(path: path, offset: offset, followSymlinks: followSymlinks)
|
||||
if buffer.count == 0 || context.maxReadable == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
let want = min(UInt64(buffer.count), context.maxReadable)
|
||||
return try performRead(
|
||||
inodeNumber: context.inodeNumber,
|
||||
start: context.start,
|
||||
wantedBytes: want,
|
||||
into: buffer
|
||||
)
|
||||
}
|
||||
|
||||
/// Read bytes from a regular file at `path` starting at `offset`.
|
||||
/// If `count` is nil, reads to EOF. Returns exactly the requested bytes (or less at EOF).
|
||||
public func readFile(
|
||||
at path: FilePath,
|
||||
offset: UInt64 = 0,
|
||||
count: Int? = nil,
|
||||
followSymlinks: Bool = true
|
||||
) throws -> Data {
|
||||
let context = try prepareRead(path: path, offset: offset, followSymlinks: followSymlinks)
|
||||
let want = count.map { min(UInt64($0), context.maxReadable) } ?? context.maxReadable
|
||||
if want == 0 {
|
||||
return Data()
|
||||
}
|
||||
|
||||
var out = Data(count: Int(want))
|
||||
let wrote = try out.withUnsafeMutableBytes {
|
||||
try performRead(
|
||||
inodeNumber: context.inodeNumber,
|
||||
start: context.start,
|
||||
wantedBytes: want,
|
||||
into: $0
|
||||
)
|
||||
}
|
||||
if wrote < Int(want) {
|
||||
out.removeSubrange(wrote..<Int(want))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private func prepareRead(
|
||||
path: FilePath,
|
||||
offset: UInt64,
|
||||
followSymlinks: Bool
|
||||
) throws -> (inodeNumber: EXT4.InodeNumber, start: UInt64, maxReadable: UInt64) {
|
||||
let (inoNum, inode) = try stat(path, followSymlinks: followSymlinks)
|
||||
|
||||
if inode.mode.isDir() {
|
||||
throw EXT4.PathIOError.isDirectory(path.description)
|
||||
}
|
||||
|
||||
if !inode.mode.isReg() {
|
||||
throw EXT4.PathIOError.notAFile(path.description)
|
||||
}
|
||||
|
||||
let fileSize: UInt64 = inodeFileSize(inode)
|
||||
let start = min(offset, fileSize)
|
||||
let maxReadable = fileSize - start
|
||||
return (inodeNumber: inoNum, start: start, maxReadable: maxReadable)
|
||||
}
|
||||
|
||||
private func performRead(
|
||||
inodeNumber: EXT4.InodeNumber,
|
||||
start: UInt64,
|
||||
wantedBytes: UInt64,
|
||||
into buffer: UnsafeMutableRawBufferPointer
|
||||
) throws -> Int {
|
||||
if wantedBytes == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
guard let extents = try self.getExtents(inode: inodeNumber), !extents.isEmpty else {
|
||||
return 0
|
||||
}
|
||||
|
||||
for (physStartBlk, physEndBlk) in extents {
|
||||
try validateBlockAddress(physStartBlk)
|
||||
if physEndBlk > physStartBlk {
|
||||
try validateBlockAddress(physEndBlk - 1)
|
||||
}
|
||||
}
|
||||
|
||||
guard let base = buffer.baseAddress else {
|
||||
return 0
|
||||
}
|
||||
|
||||
let desiredBytes = Int(min(wantedBytes, UInt64(buffer.count)))
|
||||
if desiredBytes == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
let blockSizeBytes = self.blockSize
|
||||
let reqStart = start
|
||||
let reqEnd = start + UInt64(desiredBytes)
|
||||
var logicalOffset: UInt64 = 0
|
||||
var bytesWritten = 0
|
||||
|
||||
for (physStartBlk, physEndBlk) in extents {
|
||||
let extentBytes = UInt64(physEndBlk - physStartBlk) * blockSizeBytes
|
||||
let logicalEnd = logicalOffset + extentBytes
|
||||
|
||||
if logicalEnd <= reqStart {
|
||||
logicalOffset = logicalEnd
|
||||
continue
|
||||
}
|
||||
if logicalOffset >= reqEnd {
|
||||
break
|
||||
}
|
||||
|
||||
let overlapStart = max(logicalOffset, reqStart)
|
||||
let overlapEnd = min(logicalEnd, reqEnd)
|
||||
var remaining = overlapEnd - overlapStart
|
||||
if remaining == 0 {
|
||||
logicalOffset = logicalEnd
|
||||
continue
|
||||
}
|
||||
|
||||
let offsetIntoExtent = overlapStart - logicalOffset
|
||||
let absoluteByteOffset = (UInt64(physStartBlk) * blockSizeBytes) + offsetIntoExtent
|
||||
|
||||
do {
|
||||
try self.handle.seek(toOffset: absoluteByteOffset)
|
||||
} catch {
|
||||
if bytesWritten > 0 {
|
||||
return bytesWritten
|
||||
}
|
||||
throw EXT4.PathIOError.invalidPath("failed to seek to offset \(absoluteByteOffset): \(error)")
|
||||
}
|
||||
|
||||
while remaining > 0 && bytesWritten < desiredBytes {
|
||||
let chunk = min(desiredBytes - bytesWritten, Int(min(remaining, UInt64(1 << 20))))
|
||||
let dest = UnsafeMutableRawBufferPointer(
|
||||
start: base.advanced(by: bytesWritten),
|
||||
count: chunk
|
||||
)
|
||||
|
||||
do {
|
||||
guard let data = try self.handle.read(upToCount: chunk) else {
|
||||
return bytesWritten
|
||||
}
|
||||
|
||||
if data.count == 0 {
|
||||
return bytesWritten
|
||||
}
|
||||
|
||||
// Copy the data to the destination buffer
|
||||
data.withUnsafeBytes { sourceBytes in
|
||||
dest.copyMemory(from: UnsafeRawBufferPointer(sourceBytes))
|
||||
}
|
||||
|
||||
bytesWritten += data.count
|
||||
remaining -= UInt64(data.count)
|
||||
|
||||
if data.count < chunk && remaining > 0 {
|
||||
return bytesWritten
|
||||
}
|
||||
} catch {
|
||||
if bytesWritten > 0 {
|
||||
return bytesWritten
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
logicalOffset = logicalEnd
|
||||
if bytesWritten >= desiredBytes {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return bytesWritten
|
||||
}
|
||||
|
||||
// MARK: - Internals inside EXT4Reader
|
||||
public struct ResolvedPath {
|
||||
let inodeNum: EXT4.InodeNumber
|
||||
let inode: EXT4.Inode
|
||||
}
|
||||
|
||||
/// Resolve a path to an inode (optionally following symlinks).
|
||||
/// Paths may be absolute ("/...") or relative (from "/").
|
||||
public func resolvePath(_ path: FilePath, followSymlinks: Bool, maxSymlinks: Int = 40) throws -> ResolvedPath {
|
||||
var components: [String] = normalize(path: path)
|
||||
var current: EXT4.InodeNumber = EXT4.RootInode
|
||||
var parentStack: [EXT4.InodeNumber] = [] // Track parent chain for proper ".." handling
|
||||
|
||||
var symlinkHops = 0
|
||||
|
||||
// Process components one at a time to handle symlinks in the middle of paths
|
||||
var componentIndex = 0
|
||||
|
||||
while componentIndex < components.count {
|
||||
let name = components[componentIndex]
|
||||
|
||||
if name == "." {
|
||||
componentIndex += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if name == ".." {
|
||||
// Handle parent directory traversal
|
||||
if current == EXT4.RootInode {
|
||||
// At root, ".." points to itself
|
||||
componentIndex += 1
|
||||
continue
|
||||
}
|
||||
|
||||
// Use parent stack if available
|
||||
if !parentStack.isEmpty {
|
||||
current = parentStack.removeLast()
|
||||
} else {
|
||||
// Fallback: look up ".." entry in filesystem
|
||||
let entries = try children(of: current)
|
||||
if let parent = entries.first(where: { $0.0 == ".." })?.1 {
|
||||
current = parent
|
||||
}
|
||||
}
|
||||
componentIndex += 1
|
||||
continue
|
||||
}
|
||||
|
||||
// Regular component: verify current is a directory and look up child
|
||||
let currentInode = try getInode(number: current)
|
||||
guard currentInode.mode.isDir() else {
|
||||
throw EXT4.PathIOError.notADirectory(name)
|
||||
}
|
||||
|
||||
let entries = try children(of: current)
|
||||
guard let child = entries.first(where: { $0.0 == name }) else {
|
||||
throw EXT4.PathIOError.notFound(name)
|
||||
}
|
||||
|
||||
// Check if child is a symlink
|
||||
let childInode = try getInode(number: child.1)
|
||||
if childInode.mode.isLink() && followSymlinks {
|
||||
// Enforce max symlink depth
|
||||
symlinkHops += 1
|
||||
if symlinkHops > maxSymlinks {
|
||||
throw EXT4.PathIOError.symlinkLoop(FilePath(components.joined(separator: "/")).description)
|
||||
}
|
||||
|
||||
// Read symlink target
|
||||
let linkBytes = try readFileFromInode(inodeNum: child.1)
|
||||
guard let linkTarget = String(data: linkBytes, encoding: .utf8), !linkTarget.isEmpty else {
|
||||
throw EXT4.PathIOError.invalidPath("empty symlink target")
|
||||
}
|
||||
|
||||
// Parse symlink target into components
|
||||
let targetComponents = normalize(path: FilePath(linkTarget))
|
||||
|
||||
// Replace current component with symlink target components and continue
|
||||
if linkTarget.hasPrefix("/") {
|
||||
// Absolute symlink: reset to root
|
||||
current = EXT4.RootInode
|
||||
parentStack = []
|
||||
// Replace the symlink component with target components + remaining path
|
||||
components = targetComponents + Array(components[(componentIndex + 1)...])
|
||||
componentIndex = 0 // Start from beginning with new path
|
||||
} else {
|
||||
// Relative symlink: continue from current directory
|
||||
// Replace the symlink component with target components + remaining path
|
||||
components = Array(components[0..<componentIndex]) + targetComponents + Array(components[(componentIndex + 1)...])
|
||||
// Don't change componentIndex - continue from same position with expanded path
|
||||
}
|
||||
} else {
|
||||
// Not a symlink or not following symlinks - descend into directory
|
||||
parentStack.append(current)
|
||||
current = child.1
|
||||
componentIndex += 1
|
||||
}
|
||||
}
|
||||
|
||||
// All components processed - return final inode
|
||||
let finalInode = try getInode(number: current)
|
||||
return ResolvedPath(inodeNum: current, inode: finalInode)
|
||||
}
|
||||
|
||||
/// Normalize a path into components, handling absolute and relative paths.
|
||||
private func normalize(path: FilePath) -> [String] {
|
||||
let s = path.description
|
||||
let trimmed = s.hasPrefix("/") ? String(s.dropFirst()) : s
|
||||
if trimmed.isEmpty { return [] }
|
||||
return trimmed.split(separator: "/").map(String.init)
|
||||
}
|
||||
|
||||
/// Read entire file content of a regular file given an inode (used for symlink targets).
|
||||
private func readFileFromInode(inodeNum: EXT4.InodeNumber) throws -> Data {
|
||||
let ino = try getInode(number: inodeNum)
|
||||
guard ino.mode.isReg() || ino.mode.isLink() else {
|
||||
return Data()
|
||||
}
|
||||
let size = inodeFileSize(ino)
|
||||
if size == 0 { return Data() }
|
||||
|
||||
// Handle fast symlinks (target stored directly in inode block field)
|
||||
if ino.mode.isLink() && size < 60 {
|
||||
// Extract target from inode block field
|
||||
let blockData = withUnsafeBytes(of: ino.block) { Data($0) }
|
||||
return blockData.prefix(Int(size))
|
||||
}
|
||||
|
||||
return try readFileBytesFromExtents(inodeNum: inodeNum, offset: 0, count: size)
|
||||
}
|
||||
|
||||
/// Low-level read using extents, with explicit offset & length (in bytes).
|
||||
private func readFileBytesFromExtents(inodeNum: EXT4.InodeNumber, offset: UInt64, count: UInt64) throws -> Data {
|
||||
guard let extents = try self.getExtents(inode: inodeNum), !extents.isEmpty else {
|
||||
return Data()
|
||||
}
|
||||
|
||||
// Validate all extent blocks are within device bounds
|
||||
for (startBlk, endBlk) in extents {
|
||||
try validateBlockAddress(startBlk)
|
||||
if endBlk > startBlk {
|
||||
try validateBlockAddress(endBlk - 1)
|
||||
}
|
||||
}
|
||||
|
||||
var out = Data(capacity: Int(count))
|
||||
var logicalOffset: UInt64 = 0
|
||||
var bytesReadSuccessfully: Int = 0
|
||||
let reqStart = offset
|
||||
let reqEnd = offset + count
|
||||
let bs = self.blockSize
|
||||
|
||||
for (startBlk, endBlk) in extents {
|
||||
let extentBytes = UInt64(endBlk - startBlk) * bs
|
||||
let logicalEnd = logicalOffset + extentBytes
|
||||
if logicalEnd <= reqStart {
|
||||
logicalOffset = logicalEnd
|
||||
continue
|
||||
}
|
||||
if logicalOffset >= reqEnd { break }
|
||||
|
||||
let ovlStart = max(logicalOffset, reqStart)
|
||||
let ovlEnd = min(logicalEnd, reqEnd)
|
||||
let ovlLen = ovlEnd - ovlStart
|
||||
if ovlLen == 0 {
|
||||
logicalOffset = logicalEnd
|
||||
continue
|
||||
}
|
||||
|
||||
let offsetIntoExtent = ovlStart - logicalOffset
|
||||
let absByteOffset = UInt64(startBlk) * bs + offsetIntoExtent
|
||||
|
||||
do {
|
||||
try self.handle.seek(toOffset: absByteOffset)
|
||||
} catch {
|
||||
if bytesReadSuccessfully > 0 {
|
||||
// Return partial data that was successfully read
|
||||
return out
|
||||
}
|
||||
throw EXT4.PathIOError.invalidPath("failed to seek to offset \(absByteOffset): \(error)")
|
||||
}
|
||||
|
||||
var left = ovlLen
|
||||
while left > 0 {
|
||||
let chunk = Int(min(left, 1 << 20))
|
||||
|
||||
do {
|
||||
guard let data = try self.handle.read(upToCount: chunk) else {
|
||||
let blk = UInt32(absByteOffset / bs)
|
||||
throw EXT4.Error.couldNotReadBlock(blk)
|
||||
}
|
||||
|
||||
out.append(data)
|
||||
bytesReadSuccessfully += data.count
|
||||
left -= UInt64(data.count)
|
||||
|
||||
if data.count < chunk && left > 0 {
|
||||
// Partial read - return what we have
|
||||
return out
|
||||
}
|
||||
} catch {
|
||||
if bytesReadSuccessfully > 0 {
|
||||
// Return partial data on error
|
||||
return out
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
logicalOffset = logicalEnd
|
||||
if out.count >= Int(count) { break }
|
||||
}
|
||||
if out.count > Int(count) { out.removeSubrange(Int(count)..<out.count) }
|
||||
return out
|
||||
}
|
||||
|
||||
/// Compute 64-bit file size from the inode fields (i_size).
|
||||
/// ext4 stores low 32 bits in i_size_lo and the high 32 bits in i_size_high when 64-bit sizes are enabled.
|
||||
private func inodeFileSize(_ inode: EXT4.Inode) -> UInt64 {
|
||||
// The Containerization EXT4 Inode struct exposes mode and block fields; size fields
|
||||
// are commonly named sizeLo/sizeHigh in this codebase.
|
||||
// EXT4 supports 64-bit file sizes - always use both low and high parts.
|
||||
let lo = UInt64(inode.sizeLow)
|
||||
let hi = UInt64(inode.sizeHigh)
|
||||
return lo | (hi << 32)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user