Add IO utilities for traversing dirs and reading files from EXT4 devices (#285)

This commit is contained in:
Sidhartha Mani authored and GitHub committed 2025-09-18 09:03:26 -07:00
1 parent d7613c4830
commit fe227c805e
4 files changed
+1156 -2

No files matched your search

@@ -189,7 +189,7 @@ extension EXT4 {
}
}
private func getExtents(inode: InodeNumber) throws -> [(start: UInt32, end: UInt32)]? {
func getExtents(inode: InodeNumber) throws -> [(start: UInt32, end: UInt32)]? {
let inode = try self.getInode(number: inode)
let inodeBlock = Data(tupleToArray(inode.block))
var offset = 0
@@ -362,7 +362,7 @@ extension EXT4 {
public static let TypeMask = FileModeFlag(rawValue: 0xF000)
}
typealias InodeNumber = UInt32
public typealias InodeNumber = UInt32
public struct Inode {
var mode: UInt16 = 0
@@ -618,4 +618,5 @@ extension EXT4.Inode {
inode.extraIsize = UInt16(EXT4.ExtraIsize)
return inode
}
}
@@ -0,0 +1,566 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025 Apple Inc. and the Containerization project authors. All rights reserved.
//
// 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
var visitedInodes = Set<EXT4.InodeNumber>()
// 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 {
// Check for symlink loop
if visitedInodes.contains(child.1) {
throw EXT4.PathIOError.symlinkLoop(FilePath(components.joined(separator: "/")).description)
}
visitedInodes.insert(child.1)
// 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)
}
/// Walk a sequence of path components from a starting inode with parent tracking.
/// Returns the final inode and updated parent stack.
private func walkWithParents(
current start: EXT4.InodeNumber,
components: [String],
parentStack initialStack: [EXT4.InodeNumber]
) throws -> (EXT4.InodeNumber, [EXT4.InodeNumber]) {
var current = start
var parentStack = initialStack
if components.isEmpty { return (current, parentStack) }
for name in components {
if name == "." {
continue
}
if name == ".." {
// Handle parent directory traversal with proper tracking
if current == EXT4.RootInode {
// At root, ".." points to itself (POSIX behavior)
continue
}
// Use parent stack if available for accurate traversal
if !parentStack.isEmpty {
current = parentStack.removeLast()
} else {
// No parent tracking available - look up ".." entry in filesystem
// This happens when we start traversal from a non-root inode
let entries = try children(of: current)
if let parent = entries.first(where: { $0.0 == ".." })?.1 {
current = parent
}
}
continue
}
// Regular component: verify current is a directory before traversing
let currentInode = try getInode(number: current)
guard currentInode.mode.isDir() else {
throw EXT4.PathIOError.notADirectory(name)
}
// Look up child in current directory
let entries = try children(of: current)
guard let child = entries.first(where: { $0.0 == name }) else {
throw EXT4.PathIOError.notFound(name)
}
// Push current to parent stack before descending
parentStack.append(current)
current = child.1
}
return (current, parentStack)
}
/// Walk a sequence of path components from a starting inode.
private func walk(current start: EXT4.InodeNumber, components: [String]) throws -> EXT4.InodeNumber {
let (result, _) = try walkWithParents(current: start, components: components, parentStack: [])
return result
}
/// 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)
}
}
@@ -0,0 +1,587 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025 Apple Inc. and the Containerization project authors. All rights reserved.
//
// 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
blockSize: UInt32 = 4096,
populate: (EXT4.Formatter) throws -> Void
) throws -> URL {
let url = makeTempImageURL()
let path = FilePath(url.path)
// 1) Format image
let formatter = try EXT4.Formatter(path, blockSize: blockSize, 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 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)
}
}