diff --git a/Sources/ContainerizationEXT4/EXT4+Reader.swift b/Sources/ContainerizationEXT4/EXT4+Reader.swift index 5b2fa90c4..8c7315d99 100644 --- a/Sources/ContainerizationEXT4/EXT4+Reader.swift +++ b/Sources/ContainerizationEXT4/EXT4+Reader.swift @@ -193,6 +193,17 @@ extension EXT4 { func getExtents(inode: InodeNumber) throws -> [(start: UInt32, end: UInt32)]? { let inode = try self.getInode(number: inode) let inodeBlock = Data(tupleToArray(inode.block)) + return try self.decodeExtents(inodeBlock: inodeBlock) + } + + /// Decode the extent tree rooted in an inode's block region. + /// + /// `inodeBlock` is untrusted (it comes from an ext4 image), so the entry + /// count in each extent header is validated against the bytes actually + /// available before every fixed-size read. Without these checks a crafted + /// `entries` count drives `subdata` past the end of the buffer and traps + /// the process. + internal func decodeExtents(inodeBlock: Data) throws -> [(start: UInt32, end: UInt32)] { var offset = 0 var extents: [(start: UInt32, end: UInt32)] = [] @@ -200,6 +211,9 @@ extension EXT4 { let extentIndexSize = MemoryLayout.size let extentLeafSize = MemoryLayout.size // read extent header + guard offset + extentHeaderSize <= inodeBlock.count else { + return [] + } let header = inodeBlock.subdata(in: offset.. [UInt8] { + [UInt8(v & 0xff), UInt8((v >> 8) & 0xff)] + } + private func le32(_ v: UInt32) -> [UInt8] { + [UInt8(v & 0xff), UInt8((v >> 8) & 0xff), UInt8((v >> 16) & 0xff), UInt8((v >> 24) & 0xff)] + } + + /// Encode a 12-byte ExtentHeader (magic, entries, max, depth, generation). + private func extentHeader(entries: UInt16, max: UInt16 = 4, depth: UInt16, magic: UInt16 = 0xf30a) -> [UInt8] { + le16(magic) + le16(entries) + le16(max) + le16(depth) + le32(0) + } + + /// Encode a 12-byte ExtentLeaf (block, length, startHigh, startLow). + private func extentLeaf(block: UInt32 = 0, length: UInt16, startHigh: UInt16 = 0, startLow: UInt32) -> [UInt8] { + le32(block) + le16(length) + le16(startHigh) + le32(startLow) + } + + /// Build a throwaway valid ext4 image and return an open reader for it. + /// `decodeExtents` only reads its `inodeBlock` argument for the depth-0 path, + /// so any reader instance works. + private func makeReader() throws -> (EXT4.EXT4Reader, URL) { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("ext4-extent-\(UUID().uuidString).img") + let fmt = try EXT4.Formatter(FilePath(url.path), minDiskSize: 4 * 1024 * 1024) + try fmt.close() + let reader = try EXT4.EXT4Reader(blockDevice: FilePath(url.path)) + return (reader, url) + } + + // MARK: - Tests + + /// Well-formed depth-0 extents parse; the guards don't false-reject. + @Test + func wellFormedDepth0ExtentsParse() throws { + let (reader, url) = try makeReader() + defer { try? FileManager.default.removeItem(at: url) } + + var block = extentHeader(entries: 1, depth: 0) + block += extentLeaf(length: 2, startLow: 100) + block += [UInt8](repeating: 0, count: 60 - block.count) // real i_block is 60 bytes + #expect(block.count == 60) + + let extents = try reader.decodeExtents(inodeBlock: Data(block)) + #expect(extents.count == 1) + #expect(extents[0].start == 100) + #expect(extents[0].end == 102) + } + + /// `entries` claims more leaves than fit in the 60-byte inode block: must be + /// rejected rather than reading a leaf past the end of the buffer. + @Test + func tooManyDepth0EntriesDoesNotTrap() throws { + let (reader, url) = try makeReader() + defer { try? FileManager.default.removeItem(at: url) } + + // header (12) + 4 leaves (48) = 60 bytes, but entries claims 5. The 5th + // leaf read would span 60..<72 and trap without the bounds check. + var block = extentHeader(entries: 5, depth: 0) + for i in 0..<4 { block += extentLeaf(length: 1, startLow: UInt32(i + 1)) } + #expect(block.count == 60) + + #expect(throws: EXT4.Error.invalidExtents) { + _ = try reader.decodeExtents(inodeBlock: Data(block)) + } + } + + /// A buffer too small to even hold the extent header yields no extents + /// instead of trapping. + @Test + func shortInodeBlockReturnsEmpty() throws { + let (reader, url) = try makeReader() + defer { try? FileManager.default.removeItem(at: url) } + + let extents = try reader.decodeExtents(inodeBlock: Data([0x0a, 0xf3, 0x00, 0x00])) + #expect(extents.isEmpty) + } + + /// A non-extent inode (bad magic) yields no extents. + @Test + func nonExtentInodeReturnsEmpty() throws { + let (reader, url) = try makeReader() + defer { try? FileManager.default.removeItem(at: url) } + + var block = extentHeader(entries: 1, depth: 0, magic: 0x0000) + block += [UInt8](repeating: 0, count: 60 - block.count) + + let extents = try reader.decodeExtents(inodeBlock: Data(block)) + #expect(extents.isEmpty) + } +}