From: Ana Bobillo <[email protected]> Add tests that make a small ext4 image with mkfs.ext4 and check how U-Boot handles the on-disk indices that the previous patches check:
- test_fs_ext_oob_extent_header: an extent header with more entries than the inode can hold must give "invalid extent block" - test_fs_ext_oob_extent_loop: an extent index node pointing back at itself must give "invalid extent block" rather than loop for ever - test_fs_ext_oob_triple_indirect: a sparse file must read back correctly through the second and third entries of its triple indirect block - test_fs_ext_oob_group_layout: writing to a filesystem with s_blocks_per_group of zero must be refused - test_fs_ext_oob_delete_block: replacing a file whose indirect block lies past the end of the filesystem must fail with "Invalid block number" - test_fs_ext_oob_delete_inode: replacing a file whose directory entry names an inode past the last block group, or the journal's inode, must fail with "Invalid inode number" - test_fs_ext_oob_last_inode: replacing a file whose inode is the last of its block group must work and leave the filesystem clean for e2fsck Each image is removed and host device 0 unbound even when a test fails. Signed-off-by: Ana Bobillo <[email protected]> --- Changes in v2: - Say that some tests patch the image directly rather than with debugfs - Replace victim_field() with victim_has(), using DEVNULL - Remove the image and unbind host 0 even when a test fails - Use f-strings and record the new file's pylint score as 10.00 - Add tests for the extent depth check, the triple indirect lookup, the last inode of a group and a directory entry naming the journal's inode scripts/pylint.base | 1 + test/py/tests/test_fs/test_ext_oob.py | 310 ++++++++++++++++++++++++++ 2 files changed, 311 insertions(+) diff --git a/scripts/pylint.base b/scripts/pylint.base index bc39e2385a3..1905e32588f 100644 --- a/scripts/pylint.base +++ b/scripts/pylint.base @@ -50,6 +50,7 @@ test_tests_test_fs_fstest_helpers.py 4.29 test_tests_test_fs_test_basic.py 1.90 test_tests_test_fs_test_erofs.py 8.97 test_tests_test_fs_test_ext.py 1.48 +test_tests_test_fs_test_ext_oob.py 10.00 test_tests_test_fs_test_fs_cmd.py 8.00 test_tests_test_fs_test_fs_fat.py 2.50 test_tests_test_fs_test_mkdir.py 3.04 diff --git a/test/py/tests/test_fs/test_ext_oob.py b/test/py/tests/test_fs/test_ext_oob.py new file mode 100644 index 00000000000..12f4095c6e5 --- /dev/null +++ b/test/py/tests/test_fs/test_ext_oob.py @@ -0,0 +1,310 @@ +# SPDX-License-Identifier: GPL-2.0+ + +"""Test ext4 handling of corrupt on-disk indices + +Most tests make a small ext4 image with mkfs.ext4, corrupt one on-disk +field, with debugfs or by patching the image directly, and check that +U-Boot rejects the image and is still responsive afterwards. The others +check that U-Boot reads or replaces a valid file whose blocks or inode lie +at a boundary that it used to get wrong. +""" + +from contextlib import contextmanager +import os +import struct +from subprocess import run, check_output, DEVNULL +import zlib +import pytest +from fstest_defs import ADDR + +VICTIM = b'victim file\n' + + +@contextmanager +def ext4_image(ubman, prefix, opts=()): + """Make an 8 MiB ext4 image and remove it once the test is done + + mk_fs() takes no mkfs.ext4 options, which some tests need. When the with + block ends, whether or not the test passed, the image is removed and + host device 0, which the test may have bound to it, is unbound. + + Args: + ubman (ConsoleBase): U-Boot console + prefix (str): prefix of the image file name + opts (list of str): extra options for mkfs.ext4 + + Yields: + str: path of the image + """ + img = os.path.join(ubman.config.persistent_data_dir, f'{prefix}.ext4.img') + with open(img, 'wb') as fil: + fil.truncate(8 << 20) + try: + # U-Boot does not write to a filesystem with metadata checksums + run(['mkfs.ext4', '-q', '-F', '-O', '^metadata_csum', *opts, img], + check=True) + yield img + finally: + os.remove(img) + ubman.run_command('host unbind 0') + + +def debugfs(img, cmds): + """Run debugfs commands which write to an image + + Args: + img (str): path of the image + cmds (list of str): debugfs commands + """ + script = '\n'.join(cmds) + '\n' + run(['debugfs', '-w', '-f', '-', img], input=script.encode(), + capture_output=True, check=True) + + +def add_file(img, name, chunks): + """Write a file into the root directory of an image with debugfs + + Args: + img (str): path of the image + name (str): name of the file + chunks (dict): data of the file (bytes) by offset (int), the rest + of the file being left as holes + """ + src = f'{img}.{name}' + with open(src, 'wb') as fil: + for offset, data in chunks.items(): + fil.seek(offset) + fil.write(data) + try: + debugfs(img, [f'write {src} {name}']) + finally: + os.remove(src) + + +def read_at(img, offset, size): + """Read size bytes of the image at offset""" + with open(img, 'rb') as fil: + fil.seek(offset) + return fil.read(size) + + +def write_at(img, offset, data): + """Write data into the image at offset""" + with open(img, 'r+b') as fil: + fil.seek(offset) + fil.write(data) + + +def block_size(img): + """Get the block size of the filesystem in an image""" + return 1024 << struct.unpack('<I', read_at(img, 1024 + 24, 4))[0] + + +def file_block(img, path, blk): + """Get the filesystem block which holds a block of a file""" + return int(check_output(['debugfs', '-R', f'bmap {path} {blk}', img], + stderr=DEVNULL).decode()) + + +def victim_has(img, text): + """Check that debugfs shows some text in the inode of /victim""" + out = check_output(['debugfs', '-R', 'stat victim', img], + stderr=DEVNULL).decode() + return text in out + + +def load_crc(ubman, size, pos): + """Read part of /victim from host device 0 and get its CRC32 + + Args: + ubman (ConsoleBase): U-Boot console + size (int): number of bytes to read + pos (int): offset in the file to read from + + Returns: + str: output of the crc32 command + """ + ubman.run_command(f'ext4load host 0:0 {ADDR:x} /victim {size:x} {pos:x}') + return ubman.run_command(f'crc32 {ADDR:x} {size:x}') + + [email protected]('sandbox') [email protected]('cmd_ext4') [email protected]('debugfs') +def test_fs_ext_oob_extent_header(ubman): + """ext4fs_get_extent_block(): an extent header claiming more entries + than the inode's i_block area holds must be rejected""" + with ext4_image(ubman, 'ext_oob_eh') as img: + add_file(img, 'victim', {0: VICTIM}) + # i_block[0] = eh_magic | eh_entries << 16: 255 entries, eh_max stays 4 + debugfs(img, ['sif victim block[0] 0x00fff30a']) + assert victim_has(img, 'Flags: 0x80000') + + ubman.run_command(f'host bind 0 {img}') + out = ubman.run_command(f'ext4load host 0:0 {ADDR:x} /victim') + assert 'invalid extent block' in out + assert 'alive' in ubman.run_command('echo alive') + + [email protected]('sandbox') [email protected]('cmd_ext4') [email protected]('debugfs') +def test_fs_ext_oob_extent_loop(ubman): + """ext4fs_get_extent_block(): an index node pointing back at itself, so + that the depth does not drop, must be rejected rather than walked for + ever""" + with ext4_image(ubman, 'ext_oob_loop') as img: + add_file(img, 'victim', {0: VICTIM}) + blksz = block_size(img) + blk = file_block(img, 'victim', 0) + # Make the data block an index node of depth 1 with one entry, which + # points at the node itself: eh_magic, eh_entries, eh_max, eh_depth, + # eh_generation, then ei_block, ei_leaf_lo, ei_leaf_hi, ei_unused + write_at(img, blk * blksz, + struct.pack('<4HI2I2H', 0xf30a, 1, (blksz - 12) // 12, 1, 0, + 0, blk, 0, 0)) + # Make the root an index node of depth 1 pointing at that block: + # i_block[1] = eh_max | eh_depth << 16, i_block[4] = ei_leaf_lo + debugfs(img, ['sif victim block[1] 0x00010004', + f'sif victim block[4] {blk}', 'sif victim block[5] 0']) + + ubman.run_command(f'host bind 0 {img}') + out = ubman.run_command(f'ext4load host 0:0 {ADDR:x} /victim') + assert 'invalid extent block' in out + assert 'alive' in ubman.run_command('echo alive') + + [email protected]('sandbox') [email protected]('cmd_ext4') [email protected]('cmd_crc32') [email protected]('debugfs') +def test_fs_ext_oob_triple_indirect(ubman): + """read_allocated_block(): blocks reached through the second and third + entries of the triple indirect block must be read correctly""" + # With 1 KiB blocks an indirect block holds 256 block numbers. The file + # blocks after the 12 direct, 256 single and 256 * 256 double indirect + # ones are reached through the triple indirect block, each entry of which + # maps 256 * 256 blocks. + per = 256 + tind = 12 + per + per * per + # The first block of entry 1, the last block of its first indirect block + # and the first of its second, and the first block of entry 2 + blocks = [tind + per * per, tind + per * per + per - 1, + tind + per * per + per, tind + 2 * per * per] + chunks = {blk * 1024: f'{blk:08x}'.encode() * 128 for blk in blocks} + with ext4_image(ubman, 'ext_oob_tind', + ['-b', '1024', '-O', '^extent,^64bit']) as img: + add_file(img, 'victim', chunks) + assert victim_has(img, '(TIND)') + + ubman.run_command(f'host bind 0 {img}') + for first, count in ((blocks[0], 1), (blocks[1], 2), (blocks[3], 1)): + data = b''.join(chunks[(first + i) * 1024] for i in range(count)) + out = load_crc(ubman, len(data), first * 1024) + assert f'==> {zlib.crc32(data):08x}' in out + assert 'alive' in ubman.run_command('echo alive') + + [email protected]('sandbox') [email protected]('cmd_ext4_write') [email protected]('debugfs') +def test_fs_ext_oob_group_layout(ubman): + """ext4fs_init(): a superblock with zero blocks per group must make + writes fail rather than divide by zero""" + with ext4_image(ubman, 'ext_oob_geo') as img: + # s_blocks_per_group is at offset 32 of the superblock at byte 1024 + write_at(img, 1024 + 32, struct.pack('<I', 0)) + + ubman.run_command(f'host bind 0 {img}') + ubman.run_command(f'mw.b {ADDR:x} 41 1000') + out = ubman.run_command( + f'ext4write host 0:0 {ADDR:x} /newfile 0x1000') + assert 'Unsupported block group layout' in out + assert 'written' not in out + assert 'alive' in ubman.run_command('echo alive') + + [email protected]('sandbox') [email protected]('cmd_ext4_write') [email protected]('debugfs') +def test_fs_ext_oob_delete_block(ubman): + """delete_single_indirect_block(): replacing a file whose indirect block + lies past the end of the filesystem must fail before it is released""" + with ext4_image(ubman, 'ext_oob_del') as img: + add_file(img, 'victim', {0: VICTIM}) + # Make /victim a block-mapped file whose only bad field is block[IND] + cmds = ['sif victim flags 0', 'sif victim size 4096', + 'sif victim block[IND] 0x00ffff00'] + cmds += [f'sif victim block[{i}] 0' for i in range(12)] + debugfs(img, cmds) + assert victim_has(img, '(IND):16776960') + + ubman.run_command(f'host bind 0 {img}') + ubman.run_command(f'mw.b {ADDR:x} 41 1000') + out = ubman.run_command(f'ext4write host 0:0 {ADDR:x} /victim 0x1000') + assert 'Invalid block number' in out + assert 'written' not in out + assert 'alive' in ubman.run_command('echo alive') + + [email protected]('sandbox') [email protected]('cmd_ext4_write') [email protected]('debugfs') [email protected]('inum', [1000000, 8]) +def test_fs_ext_oob_delete_inode(ubman, inum): + """ext4fs_delete_file(): replacing a file whose directory entry names an + inode past the last block group, or a reserved inode such as the + journal's (8), must fail""" + with ext4_image(ubman, f'ext_oob_ino{inum}') as img: + add_file(img, 'victim', {0: VICTIM}) + # Point the /victim entry of the root directory at the inode; debugfs + # refuses to link a missing inode, so patch the entry in place + blksz = block_size(img) + blk = file_block(img, '/', 0) + data = bytearray(read_at(img, blk * blksz, blksz)) + off = 0 + while off < blksz: + rec_len, name_len = struct.unpack_from('<HB', data, off + 4) + if data[off + 8:off + 8 + name_len] == b'victim': + struct.pack_into('<I', data, off, inum) + break + off += rec_len + else: + pytest.fail('no /victim entry in the root directory') + write_at(img, blk * blksz, data) + + ubman.run_command(f'host bind 0 {img}') + ubman.run_command(f'mw.b {ADDR:x} 41 1000') + out = ubman.run_command(f'ext4write host 0:0 {ADDR:x} /victim 0x1000') + assert f'Invalid inode number {inum}' in out + assert 'written' not in out + assert 'alive' in ubman.run_command('echo alive') + + [email protected]('sandbox') [email protected]('cmd_ext4_write') [email protected]('cmd_crc32') [email protected]('debugfs') [email protected]('e2fsck') +def test_fs_ext_oob_last_inode(ubman): + """ext4fs_delete_file(), ext4fs_write(): replacing a file whose inode is + the last of its block group must use that group's inode bitmap and + inode table""" + # With 16 inodes per group, fill the free inodes of group 0 but the last + # one, inode 16, which /victim then takes + with ext4_image(ubman, 'ext_oob_last', ['-N', '16']) as img: + out = check_output(['debugfs', '-R', 'ffi', img], + stderr=DEVNULL).decode() + for num in range(int(out.split()[-1]), 16): + add_file(img, f'file{num}', {0: VICTIM}) + add_file(img, 'victim', {0: VICTIM}) + assert victim_has(img, 'Inode: 16 ') + + ubman.run_command(f'host bind 0 {img}') + ubman.run_command(f'mw.b {ADDR:x} 41 1000') + out = ubman.run_command(f'ext4write host 0:0 {ADDR:x} /victim 0x1000') + assert 'written' in out + out = load_crc(ubman, 0x1000, 0) + assert f'==> {zlib.crc32(b"A" * 0x1000):08x}' in out + assert 'alive' in ubman.run_command('echo alive') + run(['e2fsck', '-fn', img], capture_output=True, check=True)
