diff --git a/Sources/ContainerizationEXT4/EXT4+Reader.swift b/Sources/ContainerizationEXT4/EXT4+Reader.swift index 5b2fa90c4..73e9a1119 100644 --- a/Sources/ContainerizationEXT4/EXT4+Reader.swift +++ b/Sources/ContainerizationEXT4/EXT4+Reader.swift @@ -190,11 +190,16 @@ extension EXT4 { } } + /// Physical block ranges of an inode, without their file offsets. Use `getFileExtents(inode:)` to read file contents. func getExtents(inode: InodeNumber) throws -> [(start: UInt32, end: UInt32)]? { + try getFileExtents(inode: inode)?.map { ($0.physicalBlock, $0.physicalBlock + $0.length) } + } + + func getFileExtents(inode: InodeNumber) throws -> [FileExtent]? { let inode = try self.getInode(number: inode) let inodeBlock = Data(tupleToArray(inode.block)) var offset = 0 - var extents: [(start: UInt32, end: UInt32)] = [] + var extents: [FileExtent] = [] let extentHeaderSize = MemoryLayout.size let extentIndexSize = MemoryLayout.size @@ -204,7 +209,10 @@ extension EXT4 { $0.loadLittleEndian(as: ExtentHeader.self) } guard header.magic == EXT4.ExtentHeaderMagic else { - return [] + return nil + } + guard extentHeaderSize + Int(header.entries) * extentLeafSize <= inodeBlock.count else { + throw Error.invalidExtents } offset += extentHeaderSize // Jump to entries switch header.depth { @@ -214,7 +222,7 @@ extension EXT4 { let leaf = inodeBlock.subdata(in: offset.. FileExtent { + // This reader only supports 32-bit block numbers. + guard leaf.startHigh == 0 else { + throw Error.invalidExtents + } + // A length above 32768 marks an unwritten extent of (length - 32768) blocks. + let initialized = UInt32(leaf.length) <= EXT4.MaxBlocksPerExtent + let length = initialized ? UInt32(leaf.length) : UInt32(leaf.length) - EXT4.MaxBlocksPerExtent + guard leaf.startLow <= UInt32.max - length, leaf.block <= UInt32.max - length else { + throw Error.invalidExtents + } + return FileExtent(logicalBlock: leaf.block, physicalBlock: leaf.startLow, length: length, initialized: initialized) + } + // MARK: Internal functions func getInode(number: UInt32) throws -> Inode { if let inode = self.inodes[number] { diff --git a/Sources/ContainerizationEXT4/EXT4+Types.swift b/Sources/ContainerizationEXT4/EXT4+Types.swift index 12eb7b68e..fc7dd6617 100644 --- a/Sources/ContainerizationEXT4/EXT4+Types.swift +++ b/Sources/ContainerizationEXT4/EXT4+Types.swift @@ -522,6 +522,13 @@ extension EXT4 { var leaves: [ExtentLeaf] } + struct FileExtent { + let logicalBlock: UInt32 + let physicalBlock: UInt32 + let length: UInt32 + let initialized: Bool + } + struct DirectoryEntry { let inode: InodeNumber let recordLength: UInt16 diff --git a/Sources/ContainerizationEXT4/EXT4.swift b/Sources/ContainerizationEXT4/EXT4.swift index d32bb8f10..4bc0ff7d7 100644 --- a/Sources/ContainerizationEXT4/EXT4.swift +++ b/Sources/ContainerizationEXT4/EXT4.swift @@ -326,6 +326,7 @@ extension EXT4 { case invalidPathEncoding(_ path: String) case couldNotReadInode(_ inode: UInt32) case couldNotReadGroup(_ group: UInt32) + case fileSizeMismatch(_ path: String, _ expected: UInt64, _ actual: UInt64) public var description: String { switch self { case .notFound(let path): @@ -348,6 +349,8 @@ extension EXT4 { return "could not read inode \(inode)" case .couldNotReadGroup(let group): return "could not read group descriptor \(group)" + case .fileSizeMismatch(let path, let expected, let actual): + return "read \(actual) bytes of \(path), expected \(expected)" } } } diff --git a/Sources/ContainerizationEXT4/EXT4Reader+Export.swift b/Sources/ContainerizationEXT4/EXT4Reader+Export.swift index 762a7af4b..5ff49234b 100644 --- a/Sources/ContainerizationEXT4/EXT4Reader+Export.swift +++ b/Sources/ContainerizationEXT4/EXT4Reader+Export.swift @@ -88,43 +88,10 @@ extension EXT4.EXT4Reader { try writer.writeEntry(entry: entry, data: nil) } else if mode.isReg() { entry.fileType = .regular - var data = Data() - var remaining: UInt64 = size - if let block = item.blocks { - for dataBlock in block.start.. self.blockSize { - count = self.blockSize - } else { - count = remaining - } - guard let dataBytes = try self.handle.read(upToCount: Int(count)) else { - throw EXT4.Error.couldNotReadBlock(dataBlock) - } - data.append(dataBytes) - remaining -= UInt64(dataBytes.count) - } - } - if let additionalBlocks = item.additionalBlocks { - for block in additionalBlocks { - for dataBlock in block.start.. self.blockSize { - count = self.blockSize - } else { - count = remaining - } - guard let dataBytes = try self.handle.read(upToCount: Int(count)) else { - throw EXT4.Error.couldNotReadBlock(dataBlock) - } - data.append(dataBytes) - remaining -= UInt64(dataBytes.count) - } - } - } - try writer.writeEntry(entry: entry, data: data) + let transaction = writer.makeTransactionWriter() + try transaction.writeHeader(entry: entry) + try self.writeContents(of: item.inode, size: size, path: pathStr, to: transaction) + try transaction.finish() } else if mode.isLink() { entry.fileType = .symbolicLink if size < 60 { @@ -163,6 +130,63 @@ extension EXT4.EXT4Reader { try writer.finishEncoding() } + private func writeContents(of inode: EXT4.InodeNumber, size: UInt64, path: String, to writer: ArchiveWriterTransaction) throws { + guard let extents = try self.getFileExtents(inode: inode) else { + // Inline-data and block-mapped files aren't supported. + guard size == 0 else { + throw EXT4.Error.invalidExtents + } + return + } + + let chunkSize = UInt64(1 << 20) + var zeros = Data() + var written: UInt64 = 0 + + func writeZeros(upTo end: UInt64) throws { + if written < end && zeros.isEmpty { + zeros = Data(count: Int(min(size, chunkSize))) + } + while written < end { + let count = Int(min(end - written, chunkSize)) + try zeros.withUnsafeBytes { try writer.writeChunk(data: UnsafeRawBufferPointer(rebasing: $0[0..= written else { + // Extents must be sorted and non-overlapping. + throw EXT4.Error.invalidExtents + } + if extentStart >= size { + break + } + let extentEnd = min(extentStart + UInt64(extent.length) * self.blockSize, size) + try writeZeros(upTo: extentStart) + guard extent.initialized else { + try writeZeros(upTo: extentEnd) + continue + } + try self.seek(block: extent.physicalBlock) + while written < extentEnd { + let count = Int(min(extentEnd - written, chunkSize)) + guard let data = try self.handle.read(upToCount: count), data.count == count else { + let block = UInt64(extent.physicalBlock) + (written - extentStart) / self.blockSize + throw EXT4.Error.couldNotReadBlock(UInt32(truncatingIfNeeded: block)) + } + try data.withUnsafeBytes { try writer.writeChunk(data: $0) } + written += UInt64(count) + } + } + try writeZeros(upTo: size) + + guard written == size else { + throw EXT4.Error.fileSizeMismatch(path, size, written) + } + } + @available(*, deprecated, renamed: "readInlineExtendedAttributes(from:)") public static func readInlineExtenedAttributes(from buffer: [UInt8]) throws -> [EXT4.ExtendedAttribute] { try readInlineExtendedAttributes(from: buffer) diff --git a/Sources/ContainerizationEXT4/EXT4Reader+IO.swift b/Sources/ContainerizationEXT4/EXT4Reader+IO.swift index c2c93641a..ffcaebf19 100644 --- a/Sources/ContainerizationEXT4/EXT4Reader+IO.swift +++ b/Sources/ContainerizationEXT4/EXT4Reader+IO.swift @@ -165,15 +165,13 @@ extension EXT4.EXT4Reader { return 0 } - guard let extents = try self.getExtents(inode: inodeNumber), !extents.isEmpty else { + guard let extents = try self.getFileExtents(inode: inodeNumber) else { return 0 } - for (physStartBlk, physEndBlk) in extents { - try validateBlockAddress(physStartBlk) - if physEndBlk > physStartBlk { - try validateBlockAddress(physEndBlk - 1) - } + for extent in extents where extent.initialized && extent.length > 0 { + try validateBlockAddress(extent.physicalBlock) + try validateBlockAddress(extent.physicalBlock + extent.length - 1) } guard let base = buffer.baseAddress else { @@ -188,15 +186,19 @@ extension EXT4.EXT4Reader { 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 + var previousEnd: UInt64 = 0 + for extent in extents { + let logicalOffset = UInt64(extent.logicalBlock) * blockSizeBytes + let logicalEnd = logicalOffset + UInt64(extent.length) * blockSizeBytes + guard logicalOffset >= previousEnd else { + // Extents must be sorted and non-overlapping. + throw EXT4.Error.invalidExtents + } + previousEnd = logicalEnd if logicalEnd <= reqStart { - logicalOffset = logicalEnd continue } if logicalOffset >= reqEnd { @@ -205,14 +207,18 @@ extension EXT4.EXT4Reader { let overlapStart = max(logicalOffset, reqStart) let overlapEnd = min(logicalEnd, reqEnd) + Self.zeroFill(base, written: &bytesWritten, upTo: Int(overlapStart - reqStart)) + if !extent.initialized { + Self.zeroFill(base, written: &bytesWritten, upTo: Int(overlapEnd - reqStart)) + continue + } var remaining = overlapEnd - overlapStart if remaining == 0 { - logicalOffset = logicalEnd continue } let offsetIntoExtent = overlapStart - logicalOffset - let absoluteByteOffset = (UInt64(physStartBlk) * blockSizeBytes) + offsetIntoExtent + let absoluteByteOffset = (UInt64(extent.physicalBlock) * blockSizeBytes) + offsetIntoExtent do { try self.handle.seek(toOffset: absoluteByteOffset) @@ -258,15 +264,24 @@ extension EXT4.EXT4Reader { } } - logicalOffset = logicalEnd if bytesWritten >= desiredBytes { break } } + Self.zeroFill(base, written: &bytesWritten, upTo: desiredBytes) return bytesWritten } + /// Zeroes `buffer[written.. written else { + return + } + buffer.advanced(by: written).initializeMemory(as: UInt8.self, repeating: 0, count: end - written) + written = end + } + // MARK: - Internals inside EXT4Reader public struct ResolvedPath { let inodeNum: EXT4.InodeNumber @@ -400,85 +415,11 @@ extension EXT4.EXT4Reader { /// 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 } + var out = Data(count: Int(count)) + let wrote = try out.withUnsafeMutableBytes { + try performRead(inodeNumber: inodeNum, start: offset, wantedBytes: count, into: $0) } - if out.count > Int(count) { out.removeSubrange(Int(count).. (url: URL, expected: [String: Data]) { + let bs = Int(Self.blockSize) + let url = FileManager.default.temporaryDirectory.appendingPathComponent("ext4-sparse-\(UUID().uuidString).img") + var expected = dense + + var sources: [String: Data] = [:] + let formatter = try EXT4.Formatter(FilePath(url.path), blockSize: UInt32(bs), minDiskSize: 8.mib()) + for (index, file) in sparseFiles.enumerated() { + let source = Data(Self.pattern(seed: UInt32(index + 1), count: file.sourceBlocks * bs)) + sources[file.path] = source + try create(formatter, file.path, source) + if file.indexed { + try create(formatter, file.path + ".leaf", Data(count: bs)) + } + } + for (path, data) in dense { + try create(formatter, path, data) + } + try formatter.close() + + // Find each file's inode and data blocks before patching the image. + var patches: [(file: SparseFile, inodeOffset: UInt64, physicalStart: UInt32, leafBlock: UInt32?)] = [] + do { + let reader = try EXT4.EXT4Reader(blockDevice: FilePath(url.path)) + for file in sparseFiles { + let physical = try #require(try reader.getExtents(inode: reader.stat(FilePath(file.path)).inodeNumber)) + try #require(physical.count == 1) + var leafBlock: UInt32? + if file.indexed { + let leaf = try #require(try reader.getExtents(inode: reader.stat(FilePath(file.path + ".leaf")).inodeNumber)) + leafBlock = try #require(leaf.first).start + } + let offset = try inodeOffset(reader, file.path) + patches.append((file, offset, physical[0].start, leafBlock)) + } + } + + let handle = try FileHandle(forUpdating: url) + defer { try? handle.close() } + let blockOffset = try #require(MemoryLayout.offset(of: \EXT4.Inode.block)) + let sizeLowOffset = try #require(MemoryLayout.offset(of: \EXT4.Inode.sizeLow)) + let sizeHighOffset = try #require(MemoryLayout.offset(of: \EXT4.Inode.sizeHigh)) + let iblockSize = MemoryLayout.size(ofValue: EXT4.Inode().block) + for (file, offset, physicalStart, leafBlock) in patches { + let leaves = file.extents.map { extent in + EXT4.ExtentLeaf( + block: extent.logical, + length: extent.length + (extent.unwritten ? UInt16(EXT4.MaxBlocksPerExtent) : 0), + startHigh: file.corruption == .highPhysicalBlock ? 1 : 0, + startLow: physicalStart + extent.sourceBlock) + } + var iblock: [UInt8] + if let leafBlock { + let leafHeader = Self.extentHeader( + entries: leaves.count, max: Self.nodeCapacity(bs), depth: file.corruption == .leafNotDepthZero ? 1 : 0, + magic: file.corruption == .badLeafMagic ? 0 : EXT4.ExtentHeaderMagic) + var node = Self.bytes(of: leafHeader) + for leaf in leaves { + node += Self.bytes(of: leaf) + } + node += [UInt8](repeating: 0, count: bs - node.count) + try handle.seek(toOffset: UInt64(leafBlock) * Self.blockSize) + try handle.write(contentsOf: node) + expected[file.path + ".leaf"] = Data(node) + + iblock = Self.bytes(of: Self.extentHeader(entries: 1, max: Self.nodeCapacity(iblockSize), depth: 1)) + let leafHigh: UInt16 = file.corruption == .highLeafBlock ? 1 : 0 + iblock += Self.bytes(of: EXT4.ExtentIndex(block: 0, leafLow: leafBlock, leafHigh: leafHigh, unused: 0)) + } else { + let capacity = Self.nodeCapacity(iblockSize) + let entries = file.corruption == .tooManyEntries ? Int(capacity) + 1 : leaves.count + iblock = Self.bytes(of: Self.extentHeader(entries: entries, max: capacity, depth: 0)) + for leaf in leaves { + iblock += Self.bytes(of: leaf) + } + } + if file.corruption == .noExtentTree { + iblock = [] + } + try #require(iblock.count <= iblockSize, "\(file.path): too many extents to fit in the inode") + iblock += [UInt8](repeating: 0, count: iblockSize - iblock.count) + try handle.seek(toOffset: offset + UInt64(blockOffset)) + try handle.write(contentsOf: iblock) + try handle.seek(toOffset: offset + UInt64(sizeLowOffset)) + try handle.write(contentsOf: Self.bytes(of: file.size.lo)) + try handle.seek(toOffset: offset + UInt64(sizeHighOffset)) + try handle.write(contentsOf: Self.bytes(of: file.size.hi)) + + // Expected: zeros, except where written extents map source blocks, cut to the file size. + if let source = sources[file.path] { + var content = Data(count: Int(file.size)) + for extent in file.extents where !extent.unwritten { + let start = Int(extent.logical) * bs + let end = min(start + Int(extent.length) * bs, content.count) + guard start < end else { continue } + let from = Int(extent.sourceBlock) * bs + if from + (end - start) <= source.count { + content.replaceSubrange(start.. UInt64 { + let number = try reader.stat(FilePath(path)).inodeNumber + let sb = reader.superBlock + let descriptor = try reader.getGroupDescriptor((number - 1) / sb.inodesPerGroup) + let index = UInt64((number - 1) % sb.inodesPerGroup) + return UInt64(descriptor.inodeTableLow) * Self.blockSize + index * UInt64(sb.inodeSize) + } + + // MARK: - Byte helpers + + private static func bytes(of value: T) -> [UInt8] { + withUnsafeLittleEndianBytes(of: value) { Array($0) } + } + + /// Number of extent entries that fit in a node of `bytes` bytes after its header. + private static func nodeCapacity(_ bytes: Int) -> UInt16 { + UInt16((bytes - MemoryLayout.size) / MemoryLayout.size) + } + + private static func extentHeader(entries: Int, max: UInt16, depth: UInt16, magic: UInt16 = EXT4.ExtentHeaderMagic) -> EXT4.ExtentHeader { + EXT4.ExtentHeader(magic: magic, entries: UInt16(entries), max: max, depth: depth, generation: 0) + } + + /// Repeatable pseudo-random bytes, so a misplaced or zeroed block shows up as a mismatch. + private static func pattern(seed: UInt32, count: Int) -> [UInt8] { + var state = seed &* 2_654_435_761 | 1 + return (0..> 17 + state ^= state << 5 + return UInt8(truncatingIfNeeded: state) + } + } + + private static func firstDifference(_ actual: Data, _ expected: Data) -> String? { + if let offset = zip(actual, expected).enumerated().first(where: { $0.element.0 != $0.element.1 })?.offset { + return String(format: "first mismatch at offset 0x%x", offset) + } + if actual.count != expected.count { + return "length \(actual.count), expected \(expected.count)" + } + return nil + } +}