diff --git a/Sources/ContainerizationEXT4/EXT4+Reader.swift b/Sources/ContainerizationEXT4/EXT4+Reader.swift index 20b2d2fb..9ab4ebf7 100644 --- a/Sources/ContainerizationEXT4/EXT4+Reader.swift +++ b/Sources/ContainerizationEXT4/EXT4+Reader.swift @@ -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 diff --git a/Sources/ContainerizationEXT4/EXT4+Types.swift b/Sources/ContainerizationEXT4/EXT4+Types.swift index 31f92257..0f306066 100644 --- a/Sources/ContainerizationEXT4/EXT4+Types.swift +++ b/Sources/ContainerizationEXT4/EXT4+Types.swift @@ -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 } + } diff --git a/Sources/ContainerizationEXT4/EXT4Reader+IO.swift b/Sources/ContainerizationEXT4/EXT4Reader+IO.swift new file mode 100644 index 00000000..f5b3e0e0 --- /dev/null +++ b/Sources/ContainerizationEXT4/EXT4Reader+IO.swift @@ -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.. (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() + + // 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.. (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).. 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) + } +} diff --git a/Tests/ContainerizationEXT4Tests/TestEXT4Reader+IO.swift b/Tests/ContainerizationEXT4Tests/TestEXT4Reader+IO.swift new file mode 100644 index 00000000..c3d6c972 --- /dev/null +++ b/Tests/ContainerizationEXT4Tests/TestEXT4Reader+IO.swift @@ -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.. /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) + } +}