diff --git a/Sources/ContainerizationEXT4/EXT4+Reader.swift b/Sources/ContainerizationEXT4/EXT4+Reader.swift index 5b2fa90c4..dea8451eb 100644 --- a/Sources/ContainerizationEXT4/EXT4+Reader.swift +++ b/Sources/ContainerizationEXT4/EXT4+Reader.swift @@ -165,11 +165,17 @@ extension EXT4 { } } - private func getDirEntries(dirTree: Data) throws -> [(String, InodeNumber)] { + internal func getDirEntries(dirTree: Data) throws -> [(String, InodeNumber)] { var children: [(String, InodeNumber)] = [] var offset = 0 let entrySize = MemoryLayout.size while offset < dirTree.count { + // A malformed image may leave fewer than `entrySize` bytes at the + // tail of the block. Stop before reading a fixed-size header that + // would run past the buffer and trap in `subdata`. + guard offset + entrySize <= dirTree.count else { + break + } let dirEntry = dirTree.subdata(in: offset..= entrySize + nameLength, + nameStart + nameLength <= dirTree.count + else { + break + } + let nameData = dirTree.subdata(in: nameStart.. [UInt8] { + [UInt8(v & 0xff), UInt8((v >> 8) & 0xff), UInt8((v >> 16) & 0xff), UInt8((v >> 24) & 0xff)] + } + private func le16(_ v: UInt16) -> [UInt8] { + [UInt8(v & 0xff), UInt8((v >> 8) & 0xff)] + } + + /// Encode the fixed 8-byte on-disk directory-entry header + /// (inode:4, recordLength:2, nameLength:1, fileType:1), little-endian. + private func header(inode: UInt32, recordLength: UInt16, nameLength: UInt8, fileType: UInt8 = 1) -> [UInt8] { + le32(inode) + le16(recordLength) + [nameLength, fileType] + } + + /// Build a throwaway valid ext4 image and return an open reader for it. + /// `getDirEntries` does not touch reader state, so any reader instance works. + private func makeReader() throws -> (EXT4.EXT4Reader, URL) { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("ext4-direntry-\(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 block: both entries parse, guards do not false-reject. + @Test + func wellFormedBlockParsesAllEntries() throws { + let (reader, url) = try makeReader() + defer { try? FileManager.default.removeItem(at: url) } + + var block = [UInt8]() + // entry "aa" (inode 11), record 16 = 8 header + 2 name + 6 pad + block += header(inode: 11, recordLength: 16, nameLength: 2) + block += Array("aa".utf8) + block += [UInt8](repeating: 0, count: 16 - 8 - 2) + // entry "bb" (inode 12), record 16 + block += header(inode: 12, recordLength: 16, nameLength: 2) + block += Array("bb".utf8) + block += [UInt8](repeating: 0, count: 16 - 8 - 2) + #expect(block.count == 32) + + let entries = try reader.getDirEntries(dirTree: Data(block)) + #expect(entries.count == 2) + #expect(entries[0].0 == "aa") + #expect(entries[0].1 == 11) + #expect(entries[1].0 == "bb") + #expect(entries[1].1 == 12) + } + + /// Fewer than a full header remain at the tail: must stop, not trap when + /// loading the fixed header past the end of the block. + @Test + func shortHeaderTailDoesNotTrap() throws { + let (reader, url) = try makeReader() + defer { try? FileManager.default.removeItem(at: url) } + + var block = [UInt8]() + // one valid entry occupying bytes 0..<12 + block += header(inode: 11, recordLength: 12, nameLength: 4) + block += Array("aaaa".utf8) + // 4 trailing bytes: offset lands at 12 with only 4 (< 8) bytes left + block += [UInt8](repeating: 0, count: 4) + #expect(block.count == 16) + + let entries = try reader.getDirEntries(dirTree: Data(block)) + #expect(entries.count == 1) + #expect(entries[0].0 == "aaaa") + #expect(entries[0].1 == 11) + } + + /// `nameLength` larger than the entry's own `recordLength`: must be rejected + /// rather than reading a name that runs past the record/block. + @Test + func nameLongerThanRecordDoesNotTrap() throws { + let (reader, url) = try makeReader() + defer { try? FileManager.default.removeItem(at: url) } + + // recordLength 16 (>= 8, passes the existing guard) but nameLength 200, + // so a name read would span 8..<208 and trap. + var block = header(inode: 12, recordLength: 16, nameLength: 200) + block += [UInt8](repeating: 0, count: 8) // pad block out to 16 bytes + #expect(block.count == 16) + + let entries = try reader.getDirEntries(dirTree: Data(block)) + #expect(entries.isEmpty) + } + + /// `recordLength` claims room for the name but the block itself is too small: + /// the name read would run off the end of the buffer and trap. + @Test + func nameRunningPastBlockDoesNotTrap() throws { + let (reader, url) = try makeReader() + defer { try? FileManager.default.removeItem(at: url) } + + // recordLength 512 admits nameLength 100 (512 >= 8 + 100), but the block + // is only 16 bytes, so name bytes 8..<108 lie outside the buffer. + var block = header(inode: 12, recordLength: 512, nameLength: 100) + block += [UInt8](repeating: 0, count: 8) // block is only 16 bytes total + #expect(block.count == 16) + + let entries = try reader.getDirEntries(dirTree: Data(block)) + #expect(entries.isEmpty) + } +}