Skip to content

Commit 98555ce

Browse files
committed
fix: memory over allocation due to last partial block group
1 parent 5796abe commit 98555ce

2 files changed

Lines changed: 170 additions & 45 deletions

File tree

Sources/ContainerizationEXT4/EXT4+Formatter.swift

Lines changed: 63 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,11 @@ extension EXT4 {
5555
((groupCount - 1) / groupsPerDescriptorBlock + 1) * 32
5656
}
5757

58+
private var blocksInLastGroup: UInt32 {
59+
let remainder = blockCount % blocksPerGroup
60+
return remainder == 0 ? blocksPerGroup : remainder
61+
}
62+
5863
/// Initializes an ext4 filesystem formatter.
5964
///
6065
/// This constructor creates an instance of the ext4 formatter designed to format a block device
@@ -685,11 +690,6 @@ extension EXT4 {
685690
if newSize < contentRequiredSize {
686691
newSize = contentRequiredSize
687692
}
688-
// number of blocks needed for group descriptors
689-
let groupDescriptorBlockCount: UInt32 = (blockGroupSize.blockGroups - 1) / self.groupsPerDescriptorBlock + 1
690-
guard groupDescriptorBlockCount <= self.groupDescriptorBlocks else {
691-
throw Error.insufficientSpaceForGroupDescriptorBlocks
692-
}
693693

694694
var totalBlocks: UInt32 = 0
695695
var totalInodes: UInt32 = 0
@@ -700,20 +700,33 @@ extension EXT4 {
700700
if newSize < minGroups * blocksPerGroup * blockSize {
701701
newSize = UInt64(minGroups * blocksPerGroup * blockSize)
702702
}
703-
let totalGroups = (((newSize / UInt64(self.blockSize)) - 1) / UInt64(self.blocksPerGroup)) + 1
704703

705-
// If the provided disk size is not aligned to a blockgroup boundary, it needs to
706-
// be expanded to the next blockgroup boundary.
707-
// Example:
708-
// Provided disk size: 2 GB + 100MB: 2148 MB
709-
// BlockSize: 4096
710-
// Blockgroup size: 32768 blocks: 128MB
711-
// Number of blocks: 549888
712-
// Number of blockgroups = 549888 / 32768 = 16.78125
713-
// Aligned disk size = 557056 blocks = 17 blockgroups: 2176 MB
714-
if newSize < totalGroups * blocksPerGroup * blockSize {
715-
newSize = UInt64(totalGroups * blocksPerGroup * blockSize)
704+
// Preserve the requested filesystem size exactly when possible.
705+
// Any trailing partial group is kept as-is; we do not round up to a full
706+
// block-group boundary just to place that group's metadata.
707+
//
708+
// For groups beyond blockGroupSize.blockGroups, metadata is packed into a
709+
// reserved region starting at dataBlocks:
710+
// - inode table: inodeTableSizePerGroup blocks
711+
// - block bitmap: 1 block
712+
// - inode bitmap: 1 block
713+
//
714+
// This keeps descriptor pointers in-bounds even when the last group is tiny
715+
// (for example, 128 MiB + 4 KiB), while still preserving exact-size images
716+
// for larger partial tails (for example, 160 MiB).
717+
718+
let fsBlocks: UInt64 = (newSize + UInt64(self.blockSize) - 1) / UInt64(self.blockSize) // round up to block boundary
719+
let totalGroups = ((fsBlocks - 1) / UInt64(self.blocksPerGroup)) + 1 // round up to group boundary
720+
let groupDescriptorBlockCount: UInt32 = (UInt32(totalGroups) - 1) / self.groupsPerDescriptorBlock + 1 // round up to descriptor block boundary
721+
guard groupDescriptorBlockCount <= self.groupDescriptorBlocks else {
722+
throw Error.insufficientSpaceForGroupDescriptorBlocks
716723
}
724+
let extraGroupCount = UInt64(UInt32(totalGroups) - blockGroupSize.blockGroups) // count of groups beyond blockGroupSize.blockGroups that require packed metadata layout
725+
let packedMetadataStart = UInt64(dataBlocks) // start block (inclusive) of packed metadata region for extra groups
726+
let packedMetadataBlocks = extraGroupCount * UInt64(inodeTableSizePerGroup + 2) // each extra group has inodeTableSizePerGroup blocks for the inode table, plus 1 block for the block bitmap and 1 block for the inode bitmap
727+
let packedMetadataEnd = UInt32(packedMetadataStart + packedMetadataBlocks) // end block (exclusive) of packed metadata region for extra groups
728+
let reservedDataBlocks = max(dataBlocks, packedMetadataEnd) // exclusive upper bound of reserved blocks (data/metadata), used for bitmap marking
729+
717730
// Snapshot groupDescriptorBlocks before self.size potentially changes: the bitmap
718731
// loop uses this to identify which GDT slots were physically reserved at init time,
719732
// so it can mark any unused slots as free without accidentally freeing content blocks
@@ -739,13 +752,13 @@ extension EXT4 {
739752
var blocks: UInt32 = 0
740753
// blocks bitmap
741754
var bitmap: [UInt8] = .init(repeating: 0, count: self.blockSize * 2) // 1 for blocks, 1 for inodes
742-
if (group + 1) * UInt32(self.blocksPerGroup) <= dataBlocks { // fully allocated group
755+
if (group + 1) * UInt32(self.blocksPerGroup) <= reservedDataBlocks { // fully allocated group
743756
for i in 0..<(self.blockSize) {
744757
bitmap[Int(i)] = 0xff // mark as allocated
745758
}
746759
blocks = UInt32(self.blocksPerGroup)
747-
} else if group * UInt32(self.blocksPerGroup) < dataBlocks { // partially allocated group
748-
for i in 0..<dataBlocks - group * UInt32(self.blocksPerGroup) {
760+
} else if group * UInt32(self.blocksPerGroup) < reservedDataBlocks { // partially allocated group
761+
for i in 0..<reservedDataBlocks - group * UInt32(self.blocksPerGroup) {
749762
bitmap[Int(i / 8)] |= 1 << (i % 8)
750763
blocks += 1
751764
}
@@ -766,17 +779,13 @@ extension EXT4 {
766779
}
767780
}
768781

769-
// last blockGroup if not aligned with total size should be marked as allocated
770-
let remainingBlocks = diskBlocks % self.blocksPerGroup
771-
if group == totalGroups - 1 && remainingBlocks != 0 && self.size / self.blockSize < self.blocksPerGroup {
772-
for i in remainingBlocks..<self.blocksPerGroup {
782+
// The true last group may be smaller than blocksPerGroup; blocks beyond
783+
// groupBlockCount don't physically exist and must be marked used.
784+
let groupBlockCount = group == totalGroups.lo - 1 ? blocksInLastGroup : self.blocksPerGroup
785+
if groupBlockCount < self.blocksPerGroup {
786+
for i in groupBlockCount..<self.blocksPerGroup {
773787
bitmap[Int(i / 8)] |= 1 << (i % 8)
774788
}
775-
if remainingBlocks < self.size / self.blockSize {
776-
for i in remainingBlocks..<self.size / self.blockSize {
777-
bitmap[Int(i / 8)] &= ~(1 << (i % 8))
778-
}
779-
}
780789
}
781790

782791
// mark deleted blocks as free
@@ -819,7 +828,7 @@ extension EXT4 {
819828
let blockBitmap = UInt64(bitmapOffset + 2 * group)
820829
let inodeBitmap = UInt64(bitmapOffset + 2 * group + 1)
821830
let inodeTable = inodeTableOffset + UInt64(group * inodeTableSizePerGroup)
822-
let freeBlocksCount = UInt32(self.blocksPerGroup - blocks)
831+
let freeBlocksCount = UInt32(groupBlockCount - blocks)
823832
let freeInodesCount = UInt32(blockGroupSize.inodesPerGroup - inodes)
824833
groupDescriptors.append(
825834
// low bits
@@ -843,21 +852,29 @@ extension EXT4 {
843852

844853
// Since the bitmaps for unoccupied block groups are the same, there is no need
845854
// to allocate separate memory or storage for each individual bitmap.
846-
var blockBitmap: [UInt8] = .init(repeating: 0, count: Int(self.blocksPerGroup) / 8)
855+
let blockBitmap: [UInt8] = .init(repeating: 0, count: Int(self.blocksPerGroup) / 8)
847856
var inodeBitmap: [UInt8] = .init(repeating: 0xff, count: Int(self.blocksPerGroup) / 8)
848-
for i in 0..<inodeTableSizePerGroup + 2 {
849-
blockBitmap[Int(i) / 8] |= 1 << (i % 8)
850-
}
851857
for i in 0..<UInt16(blockGroupSize.inodesPerGroup) {
852858
inodeBitmap[Int(i) / 8] &= ~(1 << (i % 8))
853859
}
860+
var packedMetadataCursor = packedMetadataStart
854861
for group in blockGroupSize.blockGroups..<totalGroups.lo {
855-
let blocksInGroup = UInt32(self.blocksPerGroup)
856-
let blockBitmapOffset = UInt64(group * self.blocksPerGroup + inodeTableSizePerGroup)
857-
let inodeBitmapOffset = UInt64(group * self.blocksPerGroup + inodeTableSizePerGroup + 1)
858-
let inodeTableOffset = UInt64(self.blocksPerGroup) * group
859-
let freeBlocksCount = UInt32(blocksInGroup - inodeTableSizePerGroup - 2)
862+
let groupStart = UInt64(group) * UInt64(self.blocksPerGroup)
863+
let blocksInGroup = UInt32(min(UInt64(self.blocksPerGroup), fsBlocks - groupStart))
864+
let inodeTableOffset = packedMetadataCursor
865+
let blockBitmapOffset = inodeTableOffset + UInt64(inodeTableSizePerGroup)
866+
let inodeBitmapOffset = blockBitmapOffset + 1
867+
packedMetadataCursor += UInt64(inodeTableSizePerGroup + 2)
868+
let freeBlocksCount = blocksInGroup
860869
let freeInodesCount = UInt32(blockGroupSize.inodesPerGroup)
870+
let zeroInodeTable = [UInt8](repeating: 0, count: Int(inodeTableSizePerGroup) * Int(self.blockSize))
871+
872+
var groupBlockBitmap = blockBitmap
873+
if blocksInGroup < self.blocksPerGroup {
874+
for i in blocksInGroup..<self.blocksPerGroup {
875+
groupBlockBitmap[Int(i / 8)] |= 1 << (i % 8)
876+
}
877+
}
861878
groupDescriptors.append(
862879
// low bits
863880
GroupDescriptor(
@@ -874,9 +891,11 @@ extension EXT4 {
874891
itableUnusedLow: 0x0000,
875892
checksum: 0x0000
876893
))
877-
totalBlocks += (inodeTableSizePerGroup + 2)
878-
try self.seek(block: group * self.blocksPerGroup + inodeTableSizePerGroup)
879-
try self.handle.write(contentsOf: blockBitmap)
894+
try self.seek(block: UInt32(inodeTableOffset))
895+
try self.handle.write(contentsOf: zeroInodeTable)
896+
897+
try self.seek(block: UInt32(blockBitmapOffset))
898+
try self.handle.write(contentsOf: groupBlockBitmap)
880899
try self.handle.write(contentsOf: inodeBitmap)
881900
}
882901

@@ -890,10 +909,9 @@ extension EXT4 {
890909
// write superblock
891910
try self.seek(block: 0)
892911
try self.handle.write(contentsOf: Array<UInt8>.init(repeating: 0, count: 1024))
893-
894912
let computedInodes = totalGroups * blockGroupSize.inodesPerGroup
895-
var blocksCount = totalGroups * self.blocksPerGroup
896-
while blocksCount < totalBlocks {
913+
var blocksCount = (newSize + UInt64(self.blockSize) - 1) / UInt64(self.blockSize)
914+
if blocksCount < totalBlocks {
897915
blocksCount = UInt64(totalBlocks)
898916
}
899917
let totalFreeBlocks: UInt64

Tests/ContainerizationEXT4Tests/TestEXT4Format.swift

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,113 @@ struct Ext4FormatTests: ~Copyable {
218218
#expect(regFile.mode.isReg())
219219
#expect(regFile.sizeLow == 4)
220220
}
221+
222+
// This is a regression test for requested size = 160 MiB where the final group is only partially filled with metadata.
223+
@Test func partialFinalGroupPreservesExactRequestedSize() throws {
224+
let fsPath = FilePath(
225+
FileManager.default.temporaryDirectory
226+
.appendingPathComponent(UUID().uuidString, isDirectory: false)
227+
)
228+
defer { try? FileManager.default.removeItem(at: fsPath.url) }
229+
230+
let requested = 160.mib()
231+
let formatter = try EXT4.Formatter(fsPath, minDiskSize: requested)
232+
try formatter.close()
233+
234+
let file = try FileHandle(forReadingFrom: fsPath.url)
235+
let fileSize = try file.seekToEnd()
236+
#expect(fileSize == requested)
237+
238+
let ext4 = try EXT4.EXT4Reader(blockDevice: fsPath)
239+
let sb = ext4.superBlock
240+
let blocksCount = UInt64(sb.blocksCountLow) | (UInt64(sb.blocksCountHigh) << 32)
241+
let blockSize = UInt64(sb.blockSize)
242+
243+
#expect(blocksCount == 40_960) // 160 MiB / 4 KiB
244+
#expect(blockSize == 4.kib())
245+
#expect(fileSize == blocksCount * blockSize)
246+
247+
let freeBlocks = UInt64(sb.freeBlocksCountLow) | (UInt64(sb.freeBlocksCountHigh) << 32)
248+
#expect(freeBlocks <= blocksCount)
249+
250+
let gd1 = try ext4.getGroupDescriptor(1)
251+
#expect(UInt64(gd1.inodeTableLow) < blocksCount)
252+
#expect(UInt64(gd1.blockBitmapLow) < blocksCount)
253+
#expect(UInt64(gd1.inodeBitmapLow) < blocksCount)
254+
}
255+
256+
// This is a regression test for edge case of requested size = 128 MiB + 4 KiB
257+
@Test func packedMetadataBoundaryPreservesExactRequestedSizeForExtremeTinyTail() throws {
258+
let fsPath = FilePath(
259+
FileManager.default.temporaryDirectory
260+
.appendingPathComponent(UUID().uuidString, isDirectory: false)
261+
)
262+
defer { try? FileManager.default.removeItem(at: fsPath.url) }
263+
264+
let requested = 128.mib() + 4.kib()
265+
let formatter = try EXT4.Formatter(fsPath, minDiskSize: requested)
266+
try formatter.close()
267+
268+
let file = try FileHandle(forReadingFrom: fsPath.url)
269+
let fileSize = try file.seekToEnd()
270+
271+
let ext4 = try EXT4.EXT4Reader(blockDevice: fsPath)
272+
let sb = ext4.superBlock
273+
let blocksCount = UInt64(sb.blocksCountLow) | (UInt64(sb.blocksCountHigh) << 32)
274+
let blockSize = UInt64(sb.blockSize)
275+
276+
#expect(fileSize == requested)
277+
#expect(blocksCount == 32_769)
278+
#expect(fileSize == blocksCount * blockSize)
279+
280+
let gd1 = try ext4.getGroupDescriptor(1)
281+
282+
#expect(UInt64(gd1.inodeTableLow) < blocksCount)
283+
#expect(UInt64(gd1.blockBitmapLow) < blocksCount)
284+
#expect(UInt64(gd1.inodeBitmapLow) < blocksCount)
285+
286+
let group1Start = UInt64(sb.blocksPerGroup)
287+
#expect(UInt64(gd1.inodeTableLow) < group1Start)
288+
#expect(UInt64(gd1.blockBitmapLow) < group1Start)
289+
#expect(UInt64(gd1.inodeBitmapLow) < group1Start)
290+
291+
#expect(gd1.inodeTableLow < gd1.blockBitmapLow)
292+
#expect(gd1.blockBitmapLow < gd1.inodeBitmapLow)
293+
}
294+
295+
// Regression: exact-size image when trailing-group capacity exactly matches packed metadata footprint (inode table + 2 bitmaps)
296+
@Test func metadataPackingThresholdPreservesExactRequestedSize() throws {
297+
let fsPath = FilePath(
298+
FileManager.default.temporaryDirectory
299+
.appendingPathComponent(UUID().uuidString, isDirectory: false)
300+
)
301+
defer { try? FileManager.default.removeItem(at: fsPath.url) }
302+
303+
let inodeTableBlocks: UInt64 = 512
304+
let requested = 128.mib() + (inodeTableBlocks + 2) * 4.kib()
305+
306+
let formatter = try EXT4.Formatter(fsPath, minDiskSize: requested)
307+
try formatter.close()
308+
309+
let file = try FileHandle(forReadingFrom: fsPath.url)
310+
let fileSize = try file.seekToEnd()
311+
#expect(fileSize == requested)
312+
313+
let ext4 = try EXT4.EXT4Reader(blockDevice: fsPath)
314+
let sb = ext4.superBlock
315+
let blocksCount = UInt64(sb.blocksCountLow) | (UInt64(sb.blocksCountHigh) << 32)
316+
let blockSize = UInt64(sb.blockSize)
317+
#expect(fileSize == blocksCount * blockSize)
318+
319+
#expect(blocksCount == 33_282)
320+
321+
let gd1 = try ext4.getGroupDescriptor(1)
322+
#expect(UInt64(gd1.inodeTableLow) < blocksCount)
323+
#expect(UInt64(gd1.blockBitmapLow) < blocksCount)
324+
#expect(UInt64(gd1.inodeBitmapLow) < blocksCount)
325+
#expect(gd1.inodeTableLow < gd1.blockBitmapLow)
326+
#expect(gd1.blockBitmapLow < gd1.inodeBitmapLow)
327+
}
221328
}
222329

223330
@Suite(.serialized)

0 commit comments

Comments
 (0)