From 2b18ce79028f01727a38d175b248e166efb70afe Mon Sep 17 00:00:00 2001 From: Nathaniel van Diepen Date: Wed, 1 Apr 2026 12:43:41 -0600 Subject: [PATCH 01/31] First pass at fuzzing --- .gitignore | 2 + Makefile | 112 ++++++++++++++++------- ext4/__init__.py | 154 +++++++++++++++++--------------- ext4/_compat.py | 14 +-- ext4/block.py | 11 ++- ext4/blockdescriptor.py | 21 +++-- ext4/directory.py | 33 ++++--- ext4/enum.py | 23 ++--- ext4/extent.py | 30 ++++--- ext4/htree.py | 45 +++++----- ext4/inode.py | 104 ++++++++++++---------- ext4/struct.py | 26 +++--- ext4/superblock.py | 55 ++++++------ ext4/volume.py | 31 ++++--- ext4/xattr.py | 42 +++++---- fuzz.py | 191 ++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 49 ++++++++++- requirements.txt | 2 - test.py | 12 +-- test.sh | 6 +- 20 files changed, 655 insertions(+), 308 deletions(-) create mode 100644 fuzz.py delete mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore index ad32490..c27e4ef 100644 --- a/.gitignore +++ b/.gitignore @@ -161,3 +161,5 @@ cython_debug/ *.ext4 *.ext4.tmp +crash-* +timeout-* diff --git a/Makefile b/Makefile index 9f1295a..e534940 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,6 @@ VERSION := $(shell grep -m 1 version pyproject.toml | tr -s ' ' | tr -d "'\":" | PACKAGE := $(shell grep -m 1 name pyproject.toml | tr -s ' ' | tr -d "'\":" | cut -d' ' -f3) OBJ := $(wildcard ${PACKAGE}/**) -OBJ += requirements.txt OBJ += pyproject.toml OBJ += README.md OBJ += LICENSE @@ -19,19 +18,24 @@ else endif endif -ifeq ($(PYTHON),) -PYTHON := python +ifeq ($(FUZZ_TIMEOUT),) +FUZZ_TIMEOUT := 60 endif +.PHONY: clean clean: git clean --force -dX +.PHONY: build build: wheel +.PHONY: release release: wheel sdist +.PHONY: sdist sdist: dist/${PACKAGE}-${VERSION}.tar.gz +.PHONY: wheel wheel: dist/${PACKAGE}-${VERSION}-py3-none-any.whl dist: @@ -39,55 +43,99 @@ dist: dist/${PACKAGE}-${VERSION}.tar.gz: ${VENV_BIN_ACTIVATE} dist $(OBJ) . ${VENV_BIN_ACTIVATE}; \ - $(PYTHON) -m build --sdist + python -m build --sdist dist/${PACKAGE}-${VERSION}-py3-none-any.whl: ${VENV_BIN_ACTIVATE} dist $(OBJ) . ${VENV_BIN_ACTIVATE}; \ - $(PYTHON) -m build --wheel + python -m build --wheel -${VENV_BIN_ACTIVATE}: requirements.txt +${VENV_BIN_ACTIVATE}: pyproject.toml @echo "Setting up development virtual env in .venv" - $(PYTHON) -m venv .venv + python -m venv .venv . ${VENV_BIN_ACTIVATE}; \ - $(PYTHON) -m pip install \ - wheel \ - build \ - ruff \ - basedpyright; \ - $(PYTHON) -m pip install \ - -r requirements.txt + python -m pip install \ + --require-virtualenv \ + --editable \ + .; +.PHONY: test test: ${VENV_BIN_ACTIVATE} + @. ${VENV_BIN_ACTIVATE}; \ + python -m pip install \ + --require-virtualenv \ + --editable \ + .[test]; $(SHELL) test.sh +.PHONY: fuzz +fuzz: ${VENV_BIN_ACTIVATE} + @. ${VENV_BIN_ACTIVATE}; \ + python -m pip install \ + --require-virtualenv \ + --editable \ + .[fuzz] + . ${VENV_BIN_ACTIVATE};\ + python fuzz.py \ + -rss_limit_mb=2048 \ + -max_total_time=$(FUZZ_TIMEOUT) + +.PHONY: all all: release +.PHONY: lint lint: $(VENV_BIN_ACTIVATE) + @. ${VENV_BIN_ACTIVATE}; \ + python -m pip install \ + --require-virtualenv \ + --editable \ + .[dev]; \ + python -m pip install \ + --require-virtualenv \ + --editable \ + .[test]; \ + python -m pip install \ + --require-virtualenv \ + --editable \ + .[fuzz] . $(VENV_BIN_ACTIVATE); \ - $(PYTHON) -m ruff check; \ - $(PYTHON) -m basedpyright + python -m ruff check; \ + python -m basedpyright +.PHONY: lint-fix lint-fix: $(VENV_BIN_ACTIVATE) + @. ${VENV_BIN_ACTIVATE}; \ + python -m pip install \ + --require-virtualenv \ + --editable \ + .[dev]; \ + python -m pip install \ + --require-virtualenv \ + --editable \ + .[test]; \ + python -m pip install \ + --require-virtualenv \ + --editable \ + .[fuzz] . $(VENV_BIN_ACTIVATE); \ - $(PYTHON) -m ruff check --fix; \ - $(PYTHON) -m basedpyright + python -m ruff check --fix; \ + python -m basedpyright +.PHONY: format format: $(VENV_BIN_ACTIVATE) + @. ${VENV_BIN_ACTIVATE}; \ + python -m pip install \ + --require-virtualenv \ + --editable \ + .[dev] . $(VENV_BIN_ACTIVATE); \ - $(PYTHON) -m ruff format --diff + python -m ruff format --diff +.PHONY: format-fix format-fix: $(VENV_BIN_ACTIVATE) + @. ${VENV_BIN_ACTIVATE}; \ + python -m pip install \ + --require-virtualenv \ + --editable \ + .[dev] . $(VENV_BIN_ACTIVATE); \ - $(PYTHON) -m ruff format - -.PHONY: \ - all \ - build \ - clean \ - sdist \ - wheel \ - test \ - lint \ - lint-fix \ - format \ - format-fix + python -m ruff format diff --git a/ext4/__init__.py b/ext4/__init__.py index ecc30df..605479c 100644 --- a/ext4/__init__.py +++ b/ext4/__init__.py @@ -1,77 +1,85 @@ -from .enum import DX_HASH -from .enum import EXT2_FLAGS -from .enum import EXT4_BG -from .enum import EXT4_CHKSUM -from .enum import EXT4_DEFM -from .enum import EXT4_ERRORS -from .enum import EXT4_FEATURE_COMPAT -from .enum import EXT4_FEATURE_INCOMPAT -from .enum import EXT4_FEATURE_RO_COMPAT -from .enum import EXT4_FL -from .enum import EXT4_FS -from .enum import EXT4_FT -from .enum import EXT4_INO -from .enum import EXT4_MOUNT -from .enum import EXT4_MOUNT2 -from .enum import EXT4_OS -from .enum import EXT4_REV -from .enum import FS_ENCRYPTION_MODE -from .enum import MODE - -from .superblock import Superblock - +from .block import ( + BlockIO, + BlockIOBlocks, +) from .blockdescriptor import BlockDescriptor - -from .inode import BlockDevice -from .inode import CharacterDevice -from .inode import Directory -from .inode import Fifo -from .inode import File -from .inode import Hurd1 -from .inode import Hurd2 -from .inode import Inode -from .inode import Linux1 -from .inode import Linux2 -from .inode import Masix1 -from .inode import Masix2 -from .inode import Osd1 -from .inode import Osd2 -from .inode import Socket -from .inode import SymbolicLink - -from .volume import Volume -from .volume import InvalidStreamException - -from .extent import Extent -from .extent import ExtentBlocks -from .extent import ExtentHeader -from .extent import ExtentIndex -from .extent import ExtentTail - -from .struct import MagicError -from .struct import ChecksumError - -from .block import BlockIO -from .block import BlockIOBlocks - -from .directory import DirectoryEntry -from .directory import DirectoryEntry2 -from .directory import DirectoryEntryTail -from .directory import DirectoryEntryHash -from .directory import EXT4_NAME_LEN -from .directory import EXT4_DIR_PAD -from .directory import EXT4_DIR_ROUND -from .directory import EXT4_MAX_REC_LEN - -from .xattr import ExtendedAttributeError -from .xattr import ExtendedAttributeIBodyHeader -from .xattr import ExtendedAttributeHeader -from .xattr import ExtendedAttributeEntry - -from .htree import DXRoot -from .htree import DotDirectoryEntry2 -from .htree import DXEntry -from .htree import DXRootInfo +from .directory import ( + EXT4_DIR_PAD, + EXT4_DIR_ROUND, + EXT4_MAX_REC_LEN, + EXT4_NAME_LEN, + DirectoryEntry, + DirectoryEntry2, + DirectoryEntryHash, + DirectoryEntryTail, +) +from .enum import ( + DX_HASH, + EXT2_FLAGS, + EXT4_BG, + EXT4_CHKSUM, + EXT4_DEFM, + EXT4_ERRORS, + EXT4_FEATURE_COMPAT, + EXT4_FEATURE_INCOMPAT, + EXT4_FEATURE_RO_COMPAT, + EXT4_FL, + EXT4_FS, + EXT4_FT, + EXT4_INO, + EXT4_MOUNT, + EXT4_MOUNT2, + EXT4_OS, + EXT4_REV, + FS_ENCRYPTION_MODE, + MODE, +) +from .extent import ( + Extent, + ExtentBlocks, + ExtentHeader, + ExtentIndex, + ExtentTail, +) +from .htree import ( + DotDirectoryEntry2, + DXEntry, + DXRoot, + DXRootInfo, +) +from .inode import ( + BlockDevice, + CharacterDevice, + Directory, + Fifo, + File, + Hurd1, + Hurd2, + Inode, + Linux1, + Linux2, + Masix1, + Masix2, + Osd1, + Osd2, + Socket, + SymbolicLink, +) +from .struct import ( + ChecksumError, + MagicError, +) +from .superblock import Superblock +from .volume import ( + InvalidStreamException, + Volume, +) +from .xattr import ( + ExtendedAttributeEntry, + ExtendedAttributeError, + ExtendedAttributeHeader, + ExtendedAttributeIBodyHeader, +) __all__ = [ "DX_HASH", diff --git a/ext4/_compat.py b/ext4/_compat.py index 3d3c328..bfac5e2 100644 --- a/ext4/_compat.py +++ b/ext4/_compat.py @@ -1,17 +1,19 @@ import os -from typing import Protocol -from typing import runtime_checkable -from typing import TypeVar -from typing import Any +from typing import ( + Any, + Protocol, + TypeVar, + runtime_checkable, +) # Added in python 3.12 try: from typing import override # pyright: ignore[reportAssignmentType] except ImportError: - from typing import Callable + from collections.abc import Callable - def override(fn: Callable[..., Any]): # pyright: ignore[reportExplicitAny] + def override(fn: Callable[..., Any]): # pyright: ignore[reportExplicitAny] # noqa: ANN202 return fn diff --git a/ext4/block.py b/ext4/block.py index 9310937..a1aa9bd 100644 --- a/ext4/block.py +++ b/ext4/block.py @@ -1,16 +1,15 @@ # pyright: reportImportCycles=false -import io import errno - -from ._compat import override - +import io from typing import TYPE_CHECKING +from ._compat import override # pyright: ignore[reportAttributeAccessIssue] + if TYPE_CHECKING: from .inode import Inode -class BlockIOBlocks(object): +class BlockIOBlocks: def __init__(self, blockio: "BlockIO"): self.blockio: BlockIO = blockio self._null_block: bytearray = bytearray(self.block_size) @@ -43,7 +42,7 @@ def __getitem__(self, ee_block: int): class BlockIO(io.RawIOBase): def __init__(self, inode: "Inode"): super().__init__() - self.inode: "Inode" = inode + self.inode: Inode = inode self.cursor: int = 0 self.blocks: BlockIOBlocks = BlockIOBlocks(self) diff --git a/ext4/blockdescriptor.py b/ext4/blockdescriptor.py index e36eebc..93a0e21 100644 --- a/ext4/blockdescriptor.py +++ b/ext4/blockdescriptor.py @@ -1,14 +1,19 @@ # pyright: reportImportCycles=false -from ctypes import c_uint32 -from ctypes import c_uint16 +from ctypes import ( + c_uint16, + c_uint32, +) +from typing import ( + TYPE_CHECKING, + final, +) -from typing import final -from typing import TYPE_CHECKING - -from .enum import EXT4_BG -from .struct import Ext4Struct -from .struct import crc32c from ._compat import assert_cast +from .enum import EXT4_BG +from .struct import ( + Ext4Struct, + crc32c, +) if TYPE_CHECKING: from .volume import Volume diff --git a/ext4/directory.py b/ext4/directory.py index 9332dc3..5cea5fe 100644 --- a/ext4/directory.py +++ b/ext4/directory.py @@ -1,18 +1,23 @@ # pyright: reportImportCycles=false -from ctypes import c_uint32 -from ctypes import c_uint16 -from ctypes import c_uint8 -from ctypes import c_char -from ctypes import memmove -from ctypes import addressof - -from typing import final -from typing import TYPE_CHECKING - -from .struct import Ext4Struct +from ctypes import ( + addressof, + c_char, + c_uint8, + c_uint16, + c_uint32, + memmove, +) +from typing import ( + TYPE_CHECKING, + final, +) + +from ._compat import ( + assert_cast, + override, # pyright: ignore[reportAttributeAccessIssue] +) from .enum import EXT4_FT -from ._compat import override -from ._compat import assert_cast +from .struct import Ext4Struct if TYPE_CHECKING: from .inode import Directory @@ -25,7 +30,7 @@ class DirectoryEntryStruct(Ext4Struct): def __init__(self, directory: "Directory", offset: int): - self.directory: "Directory" = directory + self.directory: Directory = directory super().__init__(directory.volume, offset) @override diff --git a/ext4/enum.py b/ext4/enum.py index 77f5057..46d51a6 100644 --- a/ext4/enum.py +++ b/ext4/enum.py @@ -1,14 +1,17 @@ # pyright: reportImportCycles=false -from ctypes import c_uint8 -from ctypes import c_uint16 -from ctypes import c_uint32 - -from typing import cast -from typing import Any -from typing import final -from typing import TYPE_CHECKING - -from ._compat import override +from ctypes import ( + c_uint8, + c_uint16, + c_uint32, +) +from typing import ( + TYPE_CHECKING, + Any, + cast, + final, +) + +from ._compat import override # pyright: ignore[reportAttributeAccessIssue] if TYPE_CHECKING: from .struct import SimpleCData diff --git a/ext4/extent.py b/ext4/extent.py index 0accccf..612bd88 100644 --- a/ext4/extent.py +++ b/ext4/extent.py @@ -1,24 +1,28 @@ # pyright: reportImportCycles=false -from ctypes import c_uint32 -from ctypes import c_uint16 -from ctypes import sizeof - -from typing import final -from typing import TYPE_CHECKING - -from .struct import crc32c -from .struct import Ext4Struct +from ctypes import ( + c_uint16, + c_uint32, + sizeof, +) +from typing import ( + TYPE_CHECKING, + final, +) from ._compat import assert_cast +from .struct import ( + Ext4Struct, + crc32c, +) if TYPE_CHECKING: from .inode import Inode from .volume import Volume -class ExtentBlocks(object): +class ExtentBlocks: def __init__(self, extent: "Extent"): - self.extent: "Extent" = extent + self.extent: Extent = extent self._null_block: bytearray = bytearray(self.block_size) @property @@ -245,9 +249,9 @@ def inode(self) -> "Inode": return self.tree.inode -class ExtentTree(object): +class ExtentTree: def __init__(self, inode: "Inode"): - self.inode: "Inode" = inode + self.inode: Inode = inode self.headers: list[ExtentHeader] = [] if not self.has_extents: return diff --git a/ext4/htree.py b/ext4/htree.py index 92a1310..e6572c3 100644 --- a/ext4/htree.py +++ b/ext4/htree.py @@ -1,25 +1,30 @@ # pyright: reportImportCycles=false import warnings - -from ctypes import c_uint32 -from ctypes import c_uint16 -from ctypes import c_uint8 -from ctypes import c_char -from ctypes import sizeof -from ctypes import addressof -from ctypes import memmove -from ctypes import LittleEndianStructure - -from typing import final -from typing import TYPE_CHECKING - from collections.abc import Generator - -from .struct import Ext4Struct -from .struct import MagicError +from ctypes import ( + LittleEndianStructure, + addressof, + c_char, + c_uint8, + c_uint16, + c_uint32, + memmove, + sizeof, +) +from typing import ( + TYPE_CHECKING, + final, +) + +from ._compat import ( + assert_cast, + override, # pyright: ignore[reportAttributeAccessIssue] +) from .enum import DX_HASH -from ._compat import override -from ._compat import assert_cast +from .struct import ( + Ext4Struct, + MagicError, +) if TYPE_CHECKING: from .inode import Directory @@ -29,7 +34,7 @@ class LittleEndianStructureWithVolume(LittleEndianStructure): def __init__(self): super().__init__() - self._volume: "Volume | None" = None + self._volume: Volume | None = None @property def volume(self) -> "Volume": @@ -85,7 +90,7 @@ class DXRootInfo(LittleEndianStructure): class DXBase(Ext4Struct): def __init__(self, directory: "Directory", offset: int): - self.directory: "Directory" = directory + self.directory: Directory = directory super().__init__(directory.volume, offset) @override diff --git a/ext4/inode.py b/ext4/inode.py index 8f06217..5fa96b6 100644 --- a/ext4/inode.py +++ b/ext4/inode.py @@ -1,57 +1,65 @@ # pyright: reportImportCycles=false from __future__ import annotations +import errno import io import os -import errno import warnings - -from ctypes import LittleEndianStructure -from ctypes import Union -from ctypes import c_uint32 -from ctypes import c_uint16 -from ctypes import sizeof - -from typing import cast -from typing import final -from typing import Any -from typing import TYPE_CHECKING - -from cachetools import cachedmethod -from cachetools import LRUCache - -from ._compat import override -from ._compat import ReadableStream -from ._compat import assert_cast - from collections.abc import Generator - -from .struct import crc32c -from .struct import Ext4Struct -from .struct import MagicError - -from .enum import EXT4_OS -from .enum import EXT4_FL -from .enum import EXT4_FEATURE_INCOMPAT -from .enum import MODE -from .enum import EXT4_FT - -from .extent import Extent -from .extent import ExtentHeader -from .extent import ExtentIndex -from .extent import ExtentTree - +from ctypes import ( + LittleEndianStructure, + Union, + c_uint16, + c_uint32, + sizeof, +) +from typing import ( + TYPE_CHECKING, + Any, + cast, + final, +) + +from cachetools import ( + LRUCache, + cachedmethod, +) + +from ._compat import ( + ReadableStream, + assert_cast, + override, # pyright: ignore[reportAttributeAccessIssue] +) from .block import BlockIO - -from .directory import DirectoryEntry -from .directory import DirectoryEntry2 -from .directory import DirectoryEntryHash -from .directory import EXT4_DIR_ROUND - +from .directory import ( + EXT4_DIR_ROUND, + DirectoryEntry, + DirectoryEntry2, + DirectoryEntryHash, +) +from .enum import ( + EXT4_FEATURE_INCOMPAT, + EXT4_FL, + EXT4_FT, + EXT4_OS, + MODE, +) +from .extent import ( + Extent, + ExtentHeader, + ExtentIndex, + ExtentTree, +) from .htree import DXRoot - -from .xattr import ExtendedAttributeIBodyHeader -from .xattr import ExtendedAttributeHeader +from .struct import ( + Ext4Struct, + MagicError, + crc32c, +) +from .xattr import ( + ExtendedAttributeHeader, + ExtendedAttributeIBodyHeader, +) if TYPE_CHECKING: from .volume import Volume @@ -182,7 +190,7 @@ class Inode(Ext4Struct): ("i_projid", c_uint32), ] - def __new__(cls, volume: "Volume", offset: int, i_no: int): + def __new__(cls, volume: Volume, offset: int, i_no: int): if cls is not Inode: return super().__new__(cls) @@ -217,7 +225,7 @@ def __new__(cls, volume: "Volume", offset: int, i_no: int): raise InodeError(f"Unknown file type 0x{file_type:X}") - def __init__(self, volume: "Volume", offset: int, i_no: int): + def __init__(self, volume: Volume, offset: int, i_no: int): self.i_no: int = i_no super().__init__(volume, offset) self.tree: ExtentTree | None = ExtentTree(self) @@ -441,7 +449,7 @@ def readlink(self): class Directory(Inode): - def __init__(self, volume: "Volume", offset: int, i_no: int): + def __init__(self, volume: Volume, offset: int, i_no: int): super().__init__(volume, offset, i_no) self._inode_at_cache: LRUCache[str | bytes, Inode] = LRUCache(maxsize=32) self._dirents: None | list[DirectoryEntry | DirectoryEntry2] = None diff --git a/ext4/struct.py b/ext4/struct.py index 326a10b..de9e0c1 100644 --- a/ext4/struct.py +++ b/ext4/struct.py @@ -1,15 +1,21 @@ # pyright: reportImportCycles=false -import warnings import ctypes +import warnings +from collections.abc import Callable +from ctypes import ( + LittleEndianStructure, + addressof, + memmove, + sizeof, +) +from typing import ( + TYPE_CHECKING, + cast, +) -from ctypes import LittleEndianStructure -from ctypes import memmove -from ctypes import addressof -from ctypes import sizeof -from crcmod import mkCrcFun # pyright: ignore[reportMissingTypeStubs, reportUnknownVariableType] -from typing import cast -from typing import Callable -from typing import TYPE_CHECKING +from crcmod import ( + mkCrcFun, # pyright: ignore[reportMissingTypeStubs, reportUnknownVariableType] +) if TYPE_CHECKING: from .volume import Volume @@ -70,7 +76,7 @@ def to_hex(data: int | list[int] | bytes | None) -> str: class Ext4Struct(LittleEndianStructure): def __init__(self, volume: "Volume", offset: int): super().__init__() - self.volume: "Volume" = volume + self.volume: Volume = volume self.offset: int = offset self.read_from_volume() self.verify() diff --git a/ext4/superblock.py b/ext4/superblock.py index f90dbf0..4fd8cd5 100644 --- a/ext4/superblock.py +++ b/ext4/superblock.py @@ -1,31 +1,36 @@ # pyright: reportImportCycles=false -from ctypes import c_uint64 -from ctypes import c_uint32 -from ctypes import c_uint16 -from ctypes import c_uint8 -from ctypes import c_ubyte - -from typing import final -from typing import TYPE_CHECKING - -from .enum import EXT4_FS -from .enum import EXT4_ERRORS -from .enum import EXT4_OS -from .enum import EXT4_REV -from .enum import EXT4_FEATURE_COMPAT -from .enum import EXT4_FEATURE_INCOMPAT -from .enum import EXT4_FEATURE_RO_COMPAT -from .enum import DX_HASH -from .enum import EXT4_DEFM -from .enum import EXT2_FLAGS -from .enum import EXT4_CHKSUM -from .enum import EXT4_MOUNT -from .enum import FS_ENCRYPTION_MODE - -from .struct import Ext4Struct -from .struct import crc32c +from ctypes import ( + c_ubyte, + c_uint8, + c_uint16, + c_uint32, + c_uint64, +) +from typing import ( + TYPE_CHECKING, + final, +) from ._compat import assert_cast +from .enum import ( + DX_HASH, + EXT2_FLAGS, + EXT4_CHKSUM, + EXT4_DEFM, + EXT4_ERRORS, + EXT4_FEATURE_COMPAT, + EXT4_FEATURE_INCOMPAT, + EXT4_FEATURE_RO_COMPAT, + EXT4_FS, + EXT4_MOUNT, + EXT4_OS, + EXT4_REV, + FS_ENCRYPTION_MODE, +) +from .struct import ( + Ext4Struct, + crc32c, +) if TYPE_CHECKING: from .volume import Volume diff --git a/ext4/volume.py b/ext4/volume.py index 39dda12..065a0e1 100644 --- a/ext4/volume.py +++ b/ext4/volume.py @@ -1,30 +1,35 @@ from __future__ import annotations +import errno import io import os -import errno - -from uuid import UUID from pathlib import PurePosixPath +from uuid import UUID -from cachetools import cachedmethod -from cachetools import LRUCache +from cachetools import ( + LRUCache, + cachedmethod, +) -from ._compat import PeekableStream -from ._compat import assert_cast +from ._compat import ( + PeekableStream, + assert_cast, +) +from .blockdescriptor import BlockDescriptor from .enum import EXT4_INO +from .inode import ( + Directory, + Inode, +) from .superblock import Superblock -from .inode import Inode -from .inode import Directory -from .blockdescriptor import BlockDescriptor class InvalidStreamException(Exception): pass -class Inodes(object): - def __init__(self, volume: "Volume"): +class Inodes: + def __init__(self, volume: Volume): self.volume: Volume = volume self._group_cache: dict[int, tuple[int, int]] = {} self._offset_cache: LRUCache[int, int] = LRUCache(maxsize=32) @@ -60,7 +65,7 @@ def __getitem__(self, index: int): return Inode(self.volume, offset, index) -class Volume(object): +class Volume: def __init__( self, stream: PeekableStream, diff --git a/ext4/xattr.py b/ext4/xattr.py index 79c6ddd..6a6a12e 100644 --- a/ext4/xattr.py +++ b/ext4/xattr.py @@ -1,22 +1,28 @@ import warnings - -from ctypes import c_uint32 -from ctypes import c_uint16 -from ctypes import c_uint8 -from ctypes import sizeof - -from typing import final -from typing import TYPE_CHECKING - from collections.abc import Generator - -from .enum import EXT4_FL -from .enum import EXT4_FEATURE_INCOMPAT - -from .struct import Ext4Struct -from .struct import crc32c - -from ._compat import assert_cast, override +from ctypes import ( + c_uint8, + c_uint16, + c_uint32, + sizeof, +) +from typing import ( + TYPE_CHECKING, + final, +) + +from ._compat import ( + assert_cast, + override, # pyright: ignore[reportAttributeAccessIssue] +) +from .enum import ( + EXT4_FEATURE_INCOMPAT, + EXT4_FL, +) +from .struct import ( + Ext4Struct, + crc32c, +) if TYPE_CHECKING: from .inode import Inode @@ -28,7 +34,7 @@ class ExtendedAttributeError(Exception): class ExtendedAttributeBase(Ext4Struct): def __init__(self, inode: "Inode", offset: int, size: int): - self.inode: "Inode" = inode + self.inode: Inode = inode self.data_size: int = size super().__init__(inode.volume, offset) diff --git a/fuzz.py b/fuzz.py new file mode 100644 index 0000000..bf5384e --- /dev/null +++ b/fuzz.py @@ -0,0 +1,191 @@ +import os +import sys + +import atheris + +with atheris.instrument_imports(): # pyright: ignore[reportAttributeAccessIssue,reportUnknownMemberType] + from ext4 import Volume + + +MIN_DATA_SIZE = 128 * 1024 # 128KB + + +class FuzzableStream(atheris.FuzzedDataProvider): # pyright: ignore[reportAttributeAccessIssue,reportUnknownMemberType,reportUntypedBaseClass] + SUPERBLOCK_OFFSET: int = 0x400 + SUPERBLOCK_MAGIC_OFFSET: int = SUPERBLOCK_OFFSET + 0x38 # 0x438 + + def __init__(self, data: bytes) -> None: + # Pass to parent for fuzzing + super().__init__(data) # pyright: ignore[reportUnknownMemberType] + + # Pad data to minimum size for reading + if len(data) < MIN_DATA_SIZE: + data = data + b"\x00" * (MIN_DATA_SIZE - len(data)) + + self._data: bytearray = bytearray(data) + + self._view: memoryview[bytearray] = memoryview(self._data) + self._cursor: int = 0 + + def read(self, size: int) -> bytes: + result = self.peek(size) + self._cursor += len(result) + return result + + def peek(self, size: int) -> bytes: + offset = self._cursor + end = offset + size + + # Check if read overlaps with superblock region + sb_start = self.SUPERBLOCK_OFFSET + sb_end = sb_start + 256 # Superblock is 256 bytes + if offset < sb_end and end >= sb_start: + # Build superblock data from scratch + result = bytearray(256) + + # s_inodes_count at offset 0 + result[0x00:0x04] = b"\x20\x00\x00\x00" # 32 inodes + + # s_blocks_count_lo at offset 0x04 + result[0x04:0x08] = b"\x00\x80\x00\x00" # 128 blocks + + # s_free_blocks_count_lo at offset 0x0C + result[0x0C:0x10] = b"\x00\x80\x00\x00" + + # s_free_inodes_count at offset 0x10 + result[0x10:0x14] = b"\x20\x00\x00\x00" + + # s_first_data_block at offset 0x14 + result[0x14:0x18] = b"\x01\x00\x00\x00" + + # s_log_block_size at offset 0x18 + result[0x18:0x1C] = b"\x00\x00\x00\x00" + + # s_blocks_per_group at offset 0x20 + result[0x20:0x24] = b"\x08\x00\x00\x00" + + # s_inodes_per_group at offset 0x28 + result[0x28:0x2C] = b"\x10\x00\x00\x00" + + # s_magic at offset 0x38 + result[0x38:0x3A] = b"\x53\xef" # 0xEF53 + + # s_inode_size at offset 0x58 + result[0x58:0x5A] = b"\x80\x00" # 128 + + # s_desc_size at offset 0x60 + result[0x60:0x62] = b"\x20\x00" # 32 + + # s_feature_compat at offset 0x64 + result[0x64:0x68] = b"\x00\x00\x00\x00" + + # s_feature_incompat at offset 0x68 + result[0x68:0x6C] = b"\x40\x00\x00\x00" # IS64BIT + + # s_feature_ro_compat at offset 0x6C + result[0x6C:0x70] = b"\x00\x00\x00\x00" + + # Extract the requested portion + start = max(0, offset - sb_start) + result = result[start : end - sb_start] if end > sb_start else b"" + + # Add data before superblock if requested + if offset < sb_start: + before = self._view[offset : min(sb_start, end)].tobytes() + result = before + result + + # Add data after superblock if requested + if end > sb_end: + after = self._view[sb_end:end].tobytes() + result = bytes(result) + after + + return bytes(result) + + # Check if reading block group descriptor table (at block 3, offset 3072) + bgdt_start = 3072 # Block 3 * 1K block size + bgdt_size = 32 # 32 bytes per block descriptor + bgdt_end = bgdt_start + bgdt_size + + if offset < bgdt_end and end >= bgdt_start: + # Generate valid block descriptor + result = bytearray(bgdt_size) + # bg_inode_table at offset 8 (for the lo part) + result[8:12] = (2048).to_bytes(4, "little") # inode table at block 2 + return bytes(result) + + # Check if reading inode table (at block 2, offset 2048 for 1K blocks) + inode_table_start = 2048 # Block 2 * 1K block size + inode_size = 128 + num_inodes = 32 + inode_table_end = inode_table_start + ( + num_inodes * inode_size + ) # 32 inodes * 128 bytes + + # Check if we're reading within the inode table area OR might overlap with it + # Allow reading from offset 0 up to inode_table_end + if offset < inode_table_end and end > 0: + # Create valid inode data for inodes 1-32 + result = bytearray(num_inodes * inode_size) + + # Root inode (inode 2) - set as directory (IFDIR = 0x4000) + root_inode_offset = (2 - 1) * inode_size # inode 2 is at index 1 + result[root_inode_offset : root_inode_offset + 2] = ( + b"\x00\x40" # i_mode = IFDIR + ) + + # Bad blocks inode (inode 6) + bad_inode_offset = (6 - 1) * inode_size + result[bad_inode_offset : bad_inode_offset + 2] = b"\x00\x20" # IFBLK + + # Journal inode (inode 11) + journal_inode_offset = (11 - 1) * inode_size + result[journal_inode_offset : journal_inode_offset + 2] = ( + b"\x00\x40" # IFDIR + ) + + # Boot loader inode (inode 5) + boot_inode_offset = (5 - 1) * inode_size + result[boot_inode_offset : boot_inode_offset + 2] = b"\x00\x20" # IFBLK + + # Extract the portion requested + start = offset # Read starts at offset 128, so start at 128 in our generated table + return bytes(result[start : start + size]) + + return self._view[offset:end].tobytes() + + def seek(self, offset: int, mode: int | None = None) -> int: + if mode is None: + mode = os.SEEK_SET + if mode == os.SEEK_SET: + self._cursor = offset + elif mode == os.SEEK_CUR: + self._cursor += offset + elif mode == os.SEEK_END: + self._cursor = len(self._data) + offset + return self._cursor + + def tell(self) -> int: + return self._cursor + + +def TestOneInput(data: bytes) -> None: + stream = FuzzableStream(data) + + vol = Volume(stream) + _ = vol.superblock + for bd in vol.group_descriptors: + _ = bd.bg_block_bitmap + + root = vol.root + for dirent, _ in root.opendir(): + _ = dirent.name_bytes + + while next(vol.inodes[2].xattrs, None) is not None: + pass + + +argv = [sys.argv[0], "corpus", "-timeout=10", *sys.argv[1:]] +print("argv: ", end="") +print(argv) +_ = atheris.Setup(argv, TestOneInput) +atheris.Fuzz() diff --git a/pyproject.toml b/pyproject.toml index 4483948..ab8217e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,25 @@ classifiers = [ "Topic :: System :: Filesystems", "Topic :: Utilities", ] -dynamic = ["dependencies", "readme"] +dynamic = ["readme"] +dependencies = [ + "cachetools==6.0.0", + "crcmod==1.7", +] + +[project.optional-dependencies] +dev = [ + 'wheel', + 'build', + 'ruff', + 'basedpyright', +] +test = [ + "pytest", +] +fuzz = [ + "atheris", +] [project.urls] Homepage = "https://github.com/Eeems/python-ext4" @@ -43,3 +61,32 @@ readme = {file= ["README.md"], content-type = "text/markdown"} [build-system] requires = ["setuptools >= 61.0"] build-backend = "setuptools.build_meta" + +[tool.ruff] +exclude = [".venv", "build"] + +[tool.ruff.lint] +extend-select = [ + "UP", + "PL", + "ANN", + "S", +] +ignore = [ + "PLW0603", + "PLR2004", + "PLR0915", + "PLR0912", + "PLR0911", + "S101", + "S404", + "S603", + "S607", + "ANN401", + "ANN001", + "ANN003", +] + +[tool.pyright] +exclude = [".venv", "build"] +reportMissingTypeStubs = false diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 0e67294..0000000 --- a/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -cachetools==6.0.0 -crcmod==1.7 diff --git a/test.py b/test.py index 16e4cb1..455bc7f 100644 --- a/test.py +++ b/test.py @@ -3,12 +3,14 @@ import os import sys import traceback -import ext4 - +from collections.abc import Callable from io import BufferedReader -from typing import cast -from typing import Callable -from typing import Any +from typing import ( + Any, + cast, +) + +import ext4 FAILED = False diff --git a/test.sh b/test.sh index 1f75abf..036f36a 100755 --- a/test.sh +++ b/test.sh @@ -4,17 +4,15 @@ if ! [ -d .venv ]; then python -m venv .venv fi if [ -f .venv/Scripts/activate ]; then + make .venv/Scripts/activate source .venv/Scripts/activate elif [ -f .venv/bin/activate ]; then + make .venv/bin/activate source .venv/bin/activate else echo "venv missing" exit 1 fi -python -m pip install wheel -python -m pip install \ - --extra-index-url=https://wheels.eeems.codes/ \ - -r requirements.txt if [ ! -f test32.ext4 ] || [ ! -f test32.ext4.tmp ] || [ ! -f test64.ext4 ] || [ ! -f test64.ext4.tmp ] || [ ! -f test_htree.ext4 ]; then ./_test_image.sh trap "rm -f test{32,64,_htree}.ext4{,.tmp}" EXIT From 24e4e72497c7b45e192e5b23d78be920ee035009 Mon Sep 17 00:00:00 2001 From: Nathaniel van Diepen Date: Wed, 1 Apr 2026 13:24:41 -0600 Subject: [PATCH 02/31] Get fuzzer working --- ext4/volume.py | 3 ++ fuzz.py | 140 ++++++++++++++++++++++++++++++++++--------------- 2 files changed, 101 insertions(+), 42 deletions(-) diff --git a/ext4/volume.py b/ext4/volume.py index 065a0e1..76502bd 100644 --- a/ext4/volume.py +++ b/ext4/volume.py @@ -3,6 +3,7 @@ import errno import io import os +import sys from pathlib import PurePosixPath from uuid import UUID @@ -109,6 +110,8 @@ def __init__( table_offset + (index * self.superblock.desc_size), index, ) + print(f"DEBUG: Created BlockDescriptor {index} at offset {table_offset + (index * self.superblock.desc_size)}", file=sys.stderr) + print(f"DEBUG: bg_inode_table = {descriptor.bg_inode_table}", file=sys.stderr) descriptor.verify() self.group_descriptors.insert(index, descriptor) diff --git a/fuzz.py b/fuzz.py index bf5384e..2513469 100644 --- a/fuzz.py +++ b/fuzz.py @@ -4,39 +4,42 @@ import atheris with atheris.instrument_imports(): # pyright: ignore[reportAttributeAccessIssue,reportUnknownMemberType] - from ext4 import Volume + from ext4 import ( + File, + SymbolicLink, + Volume, + ) + from ext4._compat import ( + PeekableStream, + override, + ) MIN_DATA_SIZE = 128 * 1024 # 128KB -class FuzzableStream(atheris.FuzzedDataProvider): # pyright: ignore[reportAttributeAccessIssue,reportUnknownMemberType,reportUntypedBaseClass] +class FuzzableStream(PeekableStream): SUPERBLOCK_OFFSET: int = 0x400 SUPERBLOCK_MAGIC_OFFSET: int = SUPERBLOCK_OFFSET + 0x38 # 0x438 def __init__(self, data: bytes) -> None: - # Pass to parent for fuzzing - super().__init__(data) # pyright: ignore[reportUnknownMemberType] - - # Pad data to minimum size for reading - if len(data) < MIN_DATA_SIZE: - data = data + b"\x00" * (MIN_DATA_SIZE - len(data)) - - self._data: bytearray = bytearray(data) - - self._view: memoryview[bytearray] = memoryview(self._data) + self._view: memoryview[bytearray] = memoryview(data) self._cursor: int = 0 - def read(self, size: int) -> bytes: + @override + def read(self, size: int | None = None) -> bytes: result = self.peek(size) self._cursor += len(result) return result - def peek(self, size: int) -> bytes: + @override + def peek(self, size: int | None = None) -> bytes: # noqa: PLR0915 offset = self._cursor + if size is None: + size = len(self._view) - offset + end = offset + size - # Check if read overlaps with superblock region sb_start = self.SUPERBLOCK_OFFSET sb_end = sb_start + 256 # Superblock is 256 bytes if offset < sb_end and end >= sb_start: @@ -109,8 +112,9 @@ def peek(self, size: int) -> bytes: if offset < bgdt_end and end >= bgdt_start: # Generate valid block descriptor result = bytearray(bgdt_size) - # bg_inode_table at offset 8 (for the lo part) - result[8:12] = (2048).to_bytes(4, "little") # inode table at block 2 + # bg_inode_table_lo at offset 8 + # Set to block 2 = 2 + result[8:12] = (2).to_bytes(4, "little") return bytes(result) # Check if reading inode table (at block 2, offset 2048 for 1K blocks) @@ -121,34 +125,54 @@ def peek(self, size: int) -> bytes: num_inodes * inode_size ) # 32 inodes * 128 bytes - # Check if we're reading within the inode table area OR might overlap with it - # Allow reading from offset 0 up to inode_table_end - if offset < inode_table_end and end > 0: + # Only serve generated inode table for reads in the exact inode table region (2048-4096) + if offset >= inode_table_start and offset < inode_table_end: # Create valid inode data for inodes 1-32 result = bytearray(num_inodes * inode_size) + # Inode 1 - Regular file (IFREG = 0x8000) + result[0:2] = b"\x00\x80" + # Root inode (inode 2) - set as directory (IFDIR = 0x4000) - root_inode_offset = (2 - 1) * inode_size # inode 2 is at index 1 - result[root_inode_offset : root_inode_offset + 2] = ( - b"\x00\x40" # i_mode = IFDIR - ) - - # Bad blocks inode (inode 6) - bad_inode_offset = (6 - 1) * inode_size - result[bad_inode_offset : bad_inode_offset + 2] = b"\x00\x20" # IFBLK - - # Journal inode (inode 11) - journal_inode_offset = (11 - 1) * inode_size - result[journal_inode_offset : journal_inode_offset + 2] = ( - b"\x00\x40" # IFDIR - ) - - # Boot loader inode (inode 5) - boot_inode_offset = (5 - 1) * inode_size - result[boot_inode_offset : boot_inode_offset + 2] = b"\x00\x20" # IFBLK - - # Extract the portion requested - start = offset # Read starts at offset 128, so start at 128 in our generated table + result[128:130] = b"\x00\x40" # IFDIR + + # Inode 3 - Symbolic link (IFLNK = 0xA000) + result[256:258] = b"\x00\xa0" + + # Inode 4 - Socket (IFSOCK = 0xC000) + result[384:386] = b"\x00\xc0" + + # Boot loader inode (inode 5) - Block device (IFBLK = 0x6000) + result[512:514] = b"\x00\x60" # IFBLK + + # Bad blocks inode (inode 6) - Block device (IFBLK = 0x6000) + result[640:642] = b"\x00\x60" # IFBLK + + # Inode 7 - Character device (IFCHR = 0x2000) + result[768:770] = b"\x00\x20" + + # Inode 8 - FIFO (IFIFO = 0x1000) + result[896:898] = b"\x00\x10" + + # Inode 9 - Another directory + result[1024:1026] = b"\x00\x40" + + # Inode 10 - Another file + result[1152:1154] = b"\x00\x80" + + # Journal inode (inode 11) - set as directory (IFDIR = 0x4000) + result[1280:1282] = b"\x00\x40" + + # Inode 12-32 - Mix of files and directories + for i in range(12, 33): + inode_offset = (i - 1) * inode_size + file_type = 0x40 if i % 3 == 0 else 0x80 # Alternate IFDIR and IFREG + result[inode_offset : inode_offset + 2] = file_type.to_bytes( + 2, "little" + ) + + # Extract the portion requested relative to inode table start + start = offset - inode_table_start return bytes(result[start : start + size]) return self._view[offset:end].tobytes() @@ -156,12 +180,16 @@ def peek(self, size: int) -> bytes: def seek(self, offset: int, mode: int | None = None) -> int: if mode is None: mode = os.SEEK_SET + if mode == os.SEEK_SET: self._cursor = offset + elif mode == os.SEEK_CUR: self._cursor += offset + elif mode == os.SEEK_END: - self._cursor = len(self._data) + offset + self._cursor = len(self._view) + offset + return self._cursor def tell(self) -> int: @@ -169,6 +197,9 @@ def tell(self) -> int: def TestOneInput(data: bytes) -> None: + if len(data) < MIN_DATA_SIZE: + return + stream = FuzzableStream(data) vol = Volume(stream) @@ -180,9 +211,34 @@ def TestOneInput(data: bytes) -> None: for dirent, _ in root.opendir(): _ = dirent.name_bytes + for inode in [ + vol.inodes[1], # File + vol.inodes[2], # Directory (root) + vol.inodes[3], # SymbolicLink + vol.inodes[4], # Socket + vol.inodes[5], # BlockDevice + vol.inodes[6], # BlockDevice (bad blocks) + vol.inodes[7], # CharacterDevice + vol.inodes[8], # Fifo + vol.inodes[9], # Directory + vol.inodes[10], # File + vol.inodes[11], # Directory (journal) + ]: + _ = inode.extents + _ = inode.i_size + if isinstance(inode, File): + _ = inode.open() + + if isinstance(inode, SymbolicLink): + _ = inode.readlink() + while next(vol.inodes[2].xattrs, None) is not None: pass + _ = vol.bad_blocks + _ = vol.boot_loader + _ = vol.journal + argv = [sys.argv[0], "corpus", "-timeout=10", *sys.argv[1:]] print("argv: ", end="") From 2aa829b28b149074c071e5200c4ec6a7af9f6592 Mon Sep 17 00:00:00 2001 From: Nathaniel van Diepen Date: Wed, 1 Apr 2026 15:52:18 -0600 Subject: [PATCH 03/31] Fix linting --- ext4/_compat.py | 22 ++++++++++++---------- ext4/block.py | 20 +++++++++++--------- ext4/blockdescriptor.py | 7 ++++--- ext4/directory.py | 6 +++--- ext4/enum.py | 18 +++++++++--------- ext4/extent.py | 27 ++++++++++++++------------- ext4/htree.py | 20 ++++++++++---------- ext4/inode.py | 19 ++++++++++--------- ext4/struct.py | 20 ++++++++++---------- ext4/superblock.py | 2 +- ext4/volume.py | 36 +++++++++++++++++++++--------------- ext4/xattr.py | 16 ++++++++-------- fuzz.py | 2 ++ pyproject.toml | 3 +++ test.py | 20 ++++++++++---------- 15 files changed, 128 insertions(+), 110 deletions(-) diff --git a/ext4/_compat.py b/ext4/_compat.py index bfac5e2..8b9d711 100644 --- a/ext4/_compat.py +++ b/ext4/_compat.py @@ -1,4 +1,10 @@ +# pyright: reportUnnecessaryTypeIgnoreComment=false +# pyright: reportIgnoreCommentWithoutRule=false +# pyright: reportUnreachable=false +# pyright: reportExplicitAny=false +# pyright: reportAny=false import os +import sys from typing import ( Any, Protocol, @@ -6,15 +12,11 @@ runtime_checkable, ) -# Added in python 3.12 -try: - from typing import override # pyright: ignore[reportAssignmentType] +if sys.version_info < (3, 12): + from typing_extensions import override -except ImportError: - from collections.abc import Callable - - def override(fn: Callable[..., Any]): # pyright: ignore[reportExplicitAny] # noqa: ANN202 - return fn +else: + from typing import override @runtime_checkable @@ -34,8 +36,8 @@ def peek(self, size: int = 0, /) -> bytes: ... T = TypeVar("T") -def assert_cast(obj: Any, t: type[T], /) -> T: # pyright: ignore[reportExplicitAny, reportAny] - assert isinstance(obj, t), f"Object is: {type(obj)} not {t}" # pyright: ignore[reportAny] +def assert_cast(obj: Any, t: type[T], /) -> T: + assert isinstance(obj, t), f"Object is: {type(obj)} not {t}" return obj diff --git a/ext4/block.py b/ext4/block.py index a1aa9bd..c4c166b 100644 --- a/ext4/block.py +++ b/ext4/block.py @@ -3,33 +3,35 @@ import io from typing import TYPE_CHECKING -from ._compat import override # pyright: ignore[reportAttributeAccessIssue] +from ._compat import override if TYPE_CHECKING: + from .extent import Extent from .inode import Inode + from .volume import Volume class BlockIOBlocks: - def __init__(self, blockio: "BlockIO"): + def __init__(self, blockio: "BlockIO") -> None: self.blockio: BlockIO = blockio self._null_block: bytearray = bytearray(self.block_size) @property - def block_size(self): + def block_size(self) -> int: return self.blockio.block_size @property - def volume(self): + def volume(self) -> "Volume": return self.blockio.inode.volume - def __contains__(self, ee_block: int): + def __contains__(self, ee_block: int) -> bool: for extent in self.blockio.extents: if ee_block in extent.blocks: return True return False - def __getitem__(self, ee_block: int): + def __getitem__(self, ee_block: int) -> bytearray | bytes: for extent in self.blockio.extents: if ee_block not in extent.blocks: continue @@ -40,17 +42,17 @@ def __getitem__(self, ee_block: int): class BlockIO(io.RawIOBase): - def __init__(self, inode: "Inode"): + def __init__(self, inode: "Inode") -> None: super().__init__() self.inode: Inode = inode self.cursor: int = 0 self.blocks: BlockIOBlocks = BlockIOBlocks(self) - def __len__(self): + def __len__(self) -> int: return self.inode.i_size @property - def extents(self): + def extents(self) -> "list[Extent]": return self.inode.extents @property diff --git a/ext4/blockdescriptor.py b/ext4/blockdescriptor.py index 93a0e21..cf87af3 100644 --- a/ext4/blockdescriptor.py +++ b/ext4/blockdescriptor.py @@ -14,6 +14,7 @@ Ext4Struct, crc32c, ) +from .superblock import Superblock if TYPE_CHECKING: from .volume import Volume @@ -49,7 +50,7 @@ class BlockDescriptor(Ext4Struct): ("bg_reserved", c_uint32), ] - def __init__(self, volume: "Volume", offset: int, bg_no: int): + def __init__(self, volume: "Volume", offset: int, bg_no: int) -> None: super().__init__(volume, offset) self.bg_no: int = bg_no @@ -144,11 +145,11 @@ def bg_inode_table(self) -> int: return bg_inode_table_lo @property - def superblock(self): + def superblock(self) -> Superblock: return self.volume.superblock @Ext4Struct.checksum.getter - def checksum(self): + def checksum(self) -> int: csum = crc32c(self.bg_no.to_bytes(4, "little"), self.volume.seed) csum = crc32c(bytes(self)[: BlockDescriptor.bg_checksum.offset], csum) if self.volume.has_hi: diff --git a/ext4/directory.py b/ext4/directory.py index 5cea5fe..42b4782 100644 --- a/ext4/directory.py +++ b/ext4/directory.py @@ -14,7 +14,7 @@ from ._compat import ( assert_cast, - override, # pyright: ignore[reportAttributeAccessIssue] + override, ) from .enum import EXT4_FT from .struct import Ext4Struct @@ -29,12 +29,12 @@ class DirectoryEntryStruct(Ext4Struct): - def __init__(self, directory: "Directory", offset: int): + def __init__(self, directory: "Directory", offset: int) -> None: self.directory: Directory = directory super().__init__(directory.volume, offset) @override - def read_from_volume(self): + def read_from_volume(self) -> None: data = self.directory._open().read()[self.offset : self.offset + self.size] # pyright: ignore[reportPrivateUsage] _ = memmove(addressof(self), data, self.size) diff --git a/ext4/enum.py b/ext4/enum.py index 46d51a6..aa11e24 100644 --- a/ext4/enum.py +++ b/ext4/enum.py @@ -11,15 +11,15 @@ final, ) -from ._compat import override # pyright: ignore[reportAttributeAccessIssue] +from ._compat import override if TYPE_CHECKING: from .struct import SimpleCData -def TypedEnumerationType(_type: type["SimpleCData"]): - class EnumerationType(type(_type)): # type: ignore # pyright: ignore[reportGeneralTypeIssues, reportUntypedBaseClass] - def __new__(cls, name: str, bases: tuple[type, ...], data: dict[str, Any]): # pyright: ignore[reportExplicitAny, reportUnknownParameterType] +def TypedEnumerationType(_type: type["SimpleCData"]): # noqa: ANN201 + class EnumerationType(type(_type)): # type: ignore # pyright: ignore[reportGeneralTypeIssues, reportUntypedBaseClass] #noqa: ANN201 + def __new__(cls, name: str, bases: tuple[type, ...], data: dict[str, Any]): # pyright: ignore[reportExplicitAny, reportUnknownParameterType] # noqa: ANN204 _members_: dict[str, Any] # pyright: ignore[reportExplicitAny] if "_members_" not in data: _members_ = {} @@ -33,25 +33,25 @@ def __new__(cls, name: str, bases: tuple[type, ...], data: dict[str, Any]): # p _members_ = cast(dict[str, Any], data["_members_"]) # pyright: ignore[reportExplicitAny] data["_reverse_map_"] = {v: k for k, v in _members_.items()} # pyright: ignore[reportAny] - cls = type(_type).__new__(cls, name, bases, data) # pyright: ignore[reportCallIssue, reportUnknownVariableType] + cls = type(_type).__new__(cls, name, bases, data) # pyright: ignore[reportCallIssue, reportUnknownVariableType] # noqa: PLW0642 for key, value in cast(dict[str, Any], cls._members_).items(): # pyright: ignore[reportExplicitAny, reportAny] globals()[key] = value return cls # pyright: ignore[reportUnknownVariableType] @override - def __repr__(self): + def __repr__(self) -> str: return f"" # pyright: ignore[reportUnknownMemberType] return EnumerationType -def TypedCEnumeration(_type: type["SimpleCData"]): - class CEnumeration(_type, metaclass=TypedEnumerationType(_type)): # pyright: ignore[reportGeneralTypeIssues, reportUntypedBaseClass] +def TypedCEnumeration(_type: type["SimpleCData"]): # noqa: ANN201 + class CEnumeration(_type, metaclass=TypedEnumerationType(_type)): # pyright: ignore[reportGeneralTypeIssues, reportUntypedBaseClass] # noqa: ANN201,PLW1641,PLW1641 _members_: dict[str, Any] = {} # pyright: ignore[reportExplicitAny] @override - def __repr__(self): + def __repr__(self) -> str: value = self.value # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType] return f"<{self.__class__.__name__}.{self._reverse_map_.get(value, '(unknown)')}: {value}>" # pyright: ignore[reportUnknownMemberType] diff --git a/ext4/extent.py b/ext4/extent.py index 612bd88..397d5eb 100644 --- a/ext4/extent.py +++ b/ext4/extent.py @@ -1,4 +1,5 @@ # pyright: reportImportCycles=false +from collections.abc import Iterator from ctypes import ( c_uint16, c_uint32, @@ -21,7 +22,7 @@ class ExtentBlocks: - def __init__(self, extent: "Extent"): + def __init__(self, extent: "Extent") -> None: self.extent: Extent = extent self._null_block: bytearray = bytearray(self.block_size) @@ -55,7 +56,7 @@ def is_initialized(self) -> bool: def __contains__(self, ee_block: int) -> bool: return self.ee_block <= ee_block < self.ee_block + self.ee_len - def __getitem__(self, ee_block: int): + def __getitem__(self, ee_block: int) -> bytearray | bytes: block_size = self.block_size if not self.is_initialized or ee_block not in self: # Uninitialized @@ -65,10 +66,10 @@ def __getitem__(self, ee_block: int): _ = self.volume.seek(disk_block * block_size) return self.volume.read(block_size) - def __iter__(self): + def __iter__(self) -> Iterator[int]: return iter(range(self.ee_block, self.ee_block + self.ee_len)) - def __len__(self): + def __len__(self) -> int: return self.ee_len @@ -84,7 +85,7 @@ class ExtentHeader(Ext4Struct): ("eh_generation", c_uint32), ] - def __init__(self, tree: "ExtentTree", offset: int): + def __init__(self, tree: "ExtentTree", offset: int) -> None: self.tree: ExtentTree = tree super().__init__(self.inode.volume, offset) @@ -117,7 +118,7 @@ def inode(self) -> "Inode": return self.tree.inode @Ext4Struct.expected_magic.getter - def expected_magic(self): + def expected_magic(self) -> int: return 0xF30A @Ext4Struct.magic.getter @@ -137,7 +138,7 @@ def expected_checksum(self) -> int | None: return et_checksum @property - def seed(self): + def seed(self) -> int: return self.inode.seed @Ext4Struct.checksum.getter @@ -162,7 +163,7 @@ class ExtentIndex(Ext4Struct): ("ei_unused", c_uint16), ] - def __init__(self, header: ExtentHeader, offset: int, ei_no: int): + def __init__(self, header: ExtentHeader, offset: int, ei_no: int) -> None: self.ei_no: int = ei_no self.header: ExtentHeader = header super().__init__(self.inode.volume, offset) @@ -174,11 +175,11 @@ def ei_leaf(self) -> int: return ei_leaf_hi << 32 | ei_leaf_lo @property - def tree(self): + def tree(self) -> "ExtentTree": return self.header.tree @property - def inode(self): + def inode(self) -> "Inode": return self.tree.inode @@ -193,7 +194,7 @@ class Extent(Ext4Struct): ("ee_start_lo", c_uint32), ] - def __init__(self, header: ExtentHeader, offset: int, ee_no: int): + def __init__(self, header: ExtentHeader, offset: int, ee_no: int) -> None: super().__init__(header.inode.volume, offset) self.ee_no: int = ee_no self.header: ExtentHeader = header @@ -236,7 +237,7 @@ class ExtentTail(Ext4Struct): ("et_checksum", c_uint32), ] - def __init__(self, header: ExtentHeader, offset: int): + def __init__(self, header: ExtentHeader, offset: int) -> None: self.header: ExtentHeader = header super().__init__(self.inode.volume, offset) @@ -250,7 +251,7 @@ def inode(self) -> "Inode": class ExtentTree: - def __init__(self, inode: "Inode"): + def __init__(self, inode: "Inode") -> None: self.inode: Inode = inode self.headers: list[ExtentHeader] = [] if not self.has_extents: diff --git a/ext4/htree.py b/ext4/htree.py index e6572c3..d418d95 100644 --- a/ext4/htree.py +++ b/ext4/htree.py @@ -18,7 +18,7 @@ from ._compat import ( assert_cast, - override, # pyright: ignore[reportAttributeAccessIssue] + override, ) from .enum import DX_HASH from .struct import ( @@ -32,7 +32,7 @@ class LittleEndianStructureWithVolume(LittleEndianStructure): - def __init__(self): + def __init__(self) -> None: super().__init__() self._volume: Volume | None = None @@ -89,12 +89,12 @@ class DXRootInfo(LittleEndianStructure): class DXBase(Ext4Struct): - def __init__(self, directory: "Directory", offset: int): + def __init__(self, directory: "Directory", offset: int) -> None: self.directory: Directory = directory super().__init__(directory.volume, offset) @override - def read_from_volume(self): + def read_from_volume(self) -> None: reader = self.directory._open() # pyright: ignore[reportPrivateUsage] _ = reader.seek(self.offset) data = reader.read(sizeof(self)) @@ -110,7 +110,7 @@ class DXEntry(DXBase): ("block", c_uint32), ] - def __init__(self, parent: "DXEntriesBase", index: int): + def __init__(self, parent: "DXEntriesBase", index: int) -> None: self.index: int = index self.parent: DXEntriesBase = parent super().__init__( @@ -121,7 +121,7 @@ def __init__(self, parent: "DXEntriesBase", index: int): class DXEntriesBase(DXBase): @override - def read_from_volume(self): + def read_from_volume(self) -> None: super().read_from_volume() @property @@ -154,7 +154,7 @@ class DXRoot(DXEntriesBase): # ("entries", DXEntry * self.count), ] - def __init__(self, inode: "Directory"): + def __init__(self, inode: "Directory") -> None: super().__init__(inode, 0) @@ -168,7 +168,7 @@ class DXFake(LittleEndianStructure): ] @property - def expected_magic(self): + def expected_magic(self) -> int: return 0 @property @@ -191,7 +191,7 @@ class DXNode(DXEntriesBase): # ("entries", DXEntry * self.count), ] - def __init__(self, directory: "Directory", offset: int): + def __init__(self, directory: "Directory", offset: int) -> None: super().__init__(directory, offset) @@ -204,7 +204,7 @@ class DXTail(DXBase): ("dt_checksum", c_uint16), ] - def __init__(self, parent: DXNode): + def __init__(self, parent: DXNode) -> None: self.parent = parent count = assert_cast(parent.count, int) # pyright: ignore[reportAny] super().__init__( diff --git a/ext4/inode.py b/ext4/inode.py index 5fa96b6..8c3b2ed 100644 --- a/ext4/inode.py +++ b/ext4/inode.py @@ -28,7 +28,7 @@ from ._compat import ( ReadableStream, assert_cast, - override, # pyright: ignore[reportAttributeAccessIssue] + override, ) from .block import BlockIO from .directory import ( @@ -56,6 +56,7 @@ MagicError, crc32c, ) +from .superblock import Superblock from .xattr import ( ExtendedAttributeHeader, ExtendedAttributeIBodyHeader, @@ -190,7 +191,7 @@ class Inode(Ext4Struct): ("i_projid", c_uint32), ] - def __new__(cls, volume: Volume, offset: int, i_no: int): + def __new__(cls, volume: Volume, offset: int, i_no: int) -> Inode: if cls is not Inode: return super().__new__(cls) @@ -225,7 +226,7 @@ def __new__(cls, volume: Volume, offset: int, i_no: int): raise InodeError(f"Unknown file type 0x{file_type:X}") - def __init__(self, volume: Volume, offset: int, i_no: int): + def __init__(self, volume: Volume, offset: int, i_no: int) -> None: self.i_no: int = i_no super().__init__(volume, offset) self.tree: ExtentTree | None = ExtentTree(self) @@ -241,11 +242,11 @@ def extra_inode_data(self) -> bytes: return self.volume.read(self.superblock.s_inode_size - size) # pyright: ignore[reportAny] @property - def superblock(self): + def superblock(self) -> Superblock: return self.volume.superblock @property - def block_size(self): + def block_size(self) -> int: return self.volume.block_size @property @@ -444,12 +445,12 @@ def open( class SymbolicLink(Inode): - def readlink(self): + def readlink(self) -> bytes: return self._open().read() class Directory(Inode): - def __init__(self, volume: Volume, offset: int, i_no: int): + def __init__(self, volume: Volume, offset: int, i_no: int) -> None: super().__init__(volume, offset, i_no) self._inode_at_cache: LRUCache[str | bytes, Inode] = LRUCache(maxsize=32) self._dirents: None | list[DirectoryEntry | DirectoryEntry2] = None @@ -458,12 +459,12 @@ def __init__(self, volume: Volume, offset: int, i_no: int): self.htree = DXRoot(self) @override - def verify(self): + def verify(self) -> None: super().verify() # TODO verify DirectoryEntryHash? Or should this be in validate? @override - def validate(self): + def validate(self) -> None: super().validate() # TODO validate each directory entry block with DirectoryEntryTail diff --git a/ext4/struct.py b/ext4/struct.py index de9e0c1..82d32e3 100644 --- a/ext4/struct.py +++ b/ext4/struct.py @@ -14,7 +14,7 @@ ) from crcmod import ( - mkCrcFun, # pyright: ignore[reportMissingTypeStubs, reportUnknownVariableType] + mkCrcFun, # pyright: ignore[reportUnknownVariableType] ) if TYPE_CHECKING: @@ -74,7 +74,7 @@ def to_hex(data: int | list[int] | bytes | None) -> str: class Ext4Struct(LittleEndianStructure): - def __init__(self, volume: "Volume", offset: int): + def __init__(self, volume: "Volume", offset: int) -> None: super().__init__() self.volume: Volume = volume self.offset: int = offset @@ -91,7 +91,7 @@ def field_type(cls, name: str) -> SimpleCData | None: return None - def read_from_volume(self): + def read_from_volume(self) -> None: _ = self.volume.seek(self.offset) data = self.volume.read(sizeof(self)) if len(data) != sizeof(self): @@ -102,11 +102,11 @@ def read_from_volume(self): _ = memmove(addressof(self), data, sizeof(self)) @property - def size(self): + def size(self) -> int: return sizeof(self) @property - def magic(self): + def magic(self) -> None: return None @property @@ -114,18 +114,18 @@ def expected_magic(self) -> None: return None @property - def checksum(self): + def checksum(self) -> None: return None @property - def expected_checksum(self): + def expected_checksum(self) -> None: return None @property - def ignore_magic(self): + def ignore_magic(self) -> bool: return self.volume.ignore_magic - def verify(self): + def verify(self) -> None: """ Verify magic numbers """ @@ -142,7 +142,7 @@ def verify(self): warnings.warn(message, RuntimeWarning) - def validate(self): + def validate(self) -> None: """ Validate data checksums """ diff --git a/ext4/superblock.py b/ext4/superblock.py index 4fd8cd5..635c54e 100644 --- a/ext4/superblock.py +++ b/ext4/superblock.py @@ -137,7 +137,7 @@ class Superblock(Ext4Struct): ("s_checksum", c_uint32), ] - def __init__(self, volume: "Volume", _=None): + def __init__(self, volume: "Volume", _=None) -> None: super().__init__(volume, 0x400) @property diff --git a/ext4/volume.py b/ext4/volume.py index 76502bd..9a46a9d 100644 --- a/ext4/volume.py +++ b/ext4/volume.py @@ -30,7 +30,7 @@ class InvalidStreamException(Exception): class Inodes: - def __init__(self, volume: Volume): + def __init__(self, volume: Volume) -> None: self.volume: Volume = volume self._group_cache: dict[int, tuple[int, int]] = {} self._offset_cache: LRUCache[int, int] = LRUCache(maxsize=32) @@ -61,7 +61,7 @@ def offset(self, index: int) -> int: return table_offset + table_entry_index * s_inode_size @cachedmethod(lambda self: self._getitem_cache) # pyright: ignore[reportAny] - def __getitem__(self, index: int): + def __getitem__(self, index: int) -> Inode: offset = self.offset(index) return Inode(self.volume, offset, index) @@ -75,7 +75,7 @@ def __init__( ignore_magic: bool = False, ignore_checksum: bool = False, ignore_attr_name_index: bool = False, - ): + ) -> None: errors: list[str] = [] for name in ("read", "peek", "tell", "seek"): if not hasattr(stream, name): @@ -110,20 +110,26 @@ def __init__( table_offset + (index * self.superblock.desc_size), index, ) - print(f"DEBUG: Created BlockDescriptor {index} at offset {table_offset + (index * self.superblock.desc_size)}", file=sys.stderr) - print(f"DEBUG: bg_inode_table = {descriptor.bg_inode_table}", file=sys.stderr) + print( + f"DEBUG: Created BlockDescriptor {index} at offset {table_offset + (index * self.superblock.desc_size)}", + file=sys.stderr, + ) + print( + f"DEBUG: bg_inode_table = {descriptor.bg_inode_table}", + file=sys.stderr, + ) descriptor.verify() self.group_descriptors.insert(index, descriptor) self.inodes: Inodes = Inodes(self) self._inode_at_cache: LRUCache[str | bytes, Inode] = LRUCache(maxsize=32) - def __len__(self): + def __len__(self) -> int: _ = self.stream.seek(0, io.SEEK_END) return self.stream.tell() - self.offset @property - def bad_blocks(self): + def bad_blocks(self) -> Inode: return self.inodes[EXT4_INO.BAD] @property @@ -131,23 +137,23 @@ def root(self) -> Directory: return assert_cast(self.inodes[EXT4_INO.ROOT], Directory) @property - def user_quota(self): + def user_quota(self) -> Inode: return self.inodes[EXT4_INO.USR_QUOTA] @property - def group_quota(self): + def group_quota(self) -> Inode: return self.inodes[EXT4_INO.GRP_QUOTA] @property - def boot_loader(self): + def boot_loader(self) -> Inode: return self.inodes[EXT4_INO.BOOT_LOADER] @property - def undelete_directory(self): + def undelete_directory(self) -> Inode: return self.inodes[EXT4_INO.UNDEL_DIR] @property - def journal(self): + def journal(self) -> Inode: return self.inodes[EXT4_INO.JOURNAL] @property @@ -155,12 +161,12 @@ def has_hi(self) -> int: return self.superblock.has_hi @property - def uuid(self): + def uuid(self) -> UUID: s_uuid = assert_cast(bytes(self.superblock.s_uuid), bytes) # pyright: ignore[reportAny] return UUID(bytes=s_uuid) @property - def seed(self): + def seed(self) -> int: return self.superblock.seed @property @@ -205,7 +211,7 @@ def peek(self, size: int) -> bytes: def tell(self) -> int: return self.cursor - def block_read(self, index: int, count: int = 1): + def block_read(self, index: int, count: int = 1) -> bytes: assert index >= 0 assert count > 0 block_size = self.block_size # Only calculate once diff --git a/ext4/xattr.py b/ext4/xattr.py index 6a6a12e..e867484 100644 --- a/ext4/xattr.py +++ b/ext4/xattr.py @@ -13,7 +13,7 @@ from ._compat import ( assert_cast, - override, # pyright: ignore[reportAttributeAccessIssue] + override, ) from .enum import ( EXT4_FEATURE_INCOMPAT, @@ -33,7 +33,7 @@ class ExtendedAttributeError(Exception): class ExtendedAttributeBase(Ext4Struct): - def __init__(self, inode: "Inode", offset: int, size: int): + def __init__(self, inode: "Inode", offset: int, size: int) -> None: self.inode: Inode = inode self.data_size: int = size super().__init__(inode.volume, offset) @@ -47,7 +47,7 @@ class ExtendedAttributeIBodyHeader(ExtendedAttributeBase): ] @ExtendedAttributeBase.ignore_magic.getter - def ignore_magic(self): + def ignore_magic(self) -> bool: return False @ExtendedAttributeBase.magic.getter @@ -56,7 +56,7 @@ def magic(self) -> int: return h_magic @ExtendedAttributeBase.expected_magic.getter - def expected_magic(self): + def expected_magic(self) -> int: return 0xEA020000 def value_offset(self, entry: "ExtendedAttributeEntry") -> int: @@ -117,7 +117,7 @@ class ExtendedAttributeHeader(ExtendedAttributeIBodyHeader): ] @override - def verify(self): + def verify(self) -> None: super().verify() h_blocks = assert_cast(self.h_blocks, int) # pyright: ignore[reportAny] if h_blocks != 1: @@ -132,7 +132,7 @@ def value_offset(self, entry: "ExtendedAttributeEntry") -> int: return self.offset + e_value_offs @ExtendedAttributeIBodyHeader.expected_checksum.getter - def expected_checksum(self): + def expected_checksum(self) -> int | None: h_checksum = assert_cast(self.h_checksum, int) # pyright: ignore[reportAny] if not h_checksum: return None @@ -140,7 +140,7 @@ def expected_checksum(self): return h_checksum @ExtendedAttributeIBodyHeader.checksum.getter - def checksum(self): + def checksum(self) -> int | None: h_checksum = assert_cast(self.h_checksum, int) # pyright: ignore[reportAny] if not h_checksum: return None @@ -178,7 +178,7 @@ class ExtendedAttributeEntry(ExtendedAttributeBase): ] @override - def read_from_volume(self): + def read_from_volume(self) -> None: super().read_from_volume() e_name_len = assert_cast(self.e_name_len, int) # pyright: ignore[reportAny] self.e_name: bytes = self.volume.stream.read(e_name_len) # pyright: ignore[reportUninitializedInstanceVariable] diff --git a/fuzz.py b/fuzz.py index 2513469..2b64dd4 100644 --- a/fuzz.py +++ b/fuzz.py @@ -177,6 +177,7 @@ def peek(self, size: int | None = None) -> bytes: # noqa: PLR0915 return self._view[offset:end].tobytes() + @override def seek(self, offset: int, mode: int | None = None) -> int: if mode is None: mode = os.SEEK_SET @@ -192,6 +193,7 @@ def seek(self, offset: int, mode: int | None = None) -> int: return self._cursor + @override def tell(self) -> int: return self._cursor diff --git a/pyproject.toml b/pyproject.toml index ab8217e..2dbc010 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ dynamic = ["readme"] dependencies = [ "cachetools==6.0.0", "crcmod==1.7", + "typing-extensions==4.15.0; python_version<\"3.12\"", ] [project.optional-dependencies] @@ -78,6 +79,8 @@ ignore = [ "PLR0915", "PLR0912", "PLR0911", + "PLR6301", + "PLR0913", "S101", "S404", "S603", diff --git a/test.py b/test.py index 455bc7f..80a7fae 100644 --- a/test.py +++ b/test.py @@ -15,8 +15,8 @@ FAILED = False -def test_path_tuple(path: str | bytes, expected: tuple[bytes, ...]): - global FAILED +def test_path_tuple(path: str | bytes, expected: tuple[bytes, ...]) -> None: + global FAILED # noqa: PLW0603 print(f"check Volume.path_tuple({path}): ", end="") try: t = ext4.Volume.path_tuple(path) @@ -32,17 +32,17 @@ def test_path_tuple(path: str | bytes, expected: tuple[bytes, ...]): print(e) -def _eval_or_False(source: str) -> Any: # pyright: ignore[reportExplicitAny, reportAny] +def _eval_or_False(source: str) -> Any: # pyright: ignore[reportExplicitAny, reportAny] # noqa: ANN401 try: - return eval(source) # pyright: ignore[reportAny] + return eval(source) # pyright: ignore[reportAny] # noqa: S307 except Exception: traceback.print_exc() return False -def _assert(source: str, debug: Callable[[], Any] | None = None): # pyright: ignore[reportExplicitAny] - global FAILED +def _assert(source: str, debug: Callable[[], Any] | None = None) -> None: # pyright: ignore[reportExplicitAny] + global FAILED # noqa: PLW0603 print(f"check {source}: ", end="") if _eval_or_False(source): print("pass") @@ -54,8 +54,8 @@ def _assert(source: str, debug: Callable[[], Any] | None = None): # pyright: ig print(f" {debug()}") -def test_magic_error(f: BufferedReader): - global FAILED +def test_magic_error(f: BufferedReader) -> None: + global FAILED # noqa: PLW0603 try: print("check MagicError: ", end="") _ = ext4.Volume(f, offset=0) @@ -72,8 +72,8 @@ def test_magic_error(f: BufferedReader): print(e) -def test_root_inode(volume: ext4.Volume): - global FAILED +def test_root_inode(volume: ext4.Volume) -> None: + global FAILED # noqa: PLW0603 try: print("Validate root inode: ", end="") volume.root.validate() From dfe86d41c396d6e64a95eff85db18a48d7efba60 Mon Sep 17 00:00:00 2001 From: Nathaniel van Diepen Date: Wed, 1 Apr 2026 16:03:56 -0600 Subject: [PATCH 04/31] Add fuzzing to workflow --- .github/workflows/build.yaml | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index d3e9e8b..d7ea7f3 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -82,6 +82,34 @@ jobs: - name: Run test shell: bash run: make test + fuzz: + name: Fuzz + runs-on: ${{ matrix.os }} + needs: [test-image] + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + - windows-latest + - macos-latest + python: + - "3.10" + - "3.11" + - "3.12" + - "3.13" + #- "3.14" # not supported by atheris yet + steps: + - name: Checkout the Git repository + uses: actions/checkout@v4 + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + cache: "pip" + - name: Run test + shell: bash + run: make fuzz build: name: Build pip package runs-on: ubuntu-latest @@ -106,7 +134,7 @@ jobs: publish: name: Publish to PyPi if: github.repository == 'Eeems/python-ext4' && github.event_name == 'release' && startsWith(github.ref, 'refs/tags') - needs: [build] + needs: [build, fuzz] runs-on: ubuntu-latest permissions: id-token: write @@ -128,7 +156,7 @@ jobs: release: name: Add pip to release if: github.repository == 'Eeems/python-ext4' && github.event_name == 'release' && startsWith(github.ref, 'refs/tags') - needs: [build] + needs: [build, fuzz] runs-on: ubuntu-latest permissions: contents: write From fd7f269ffe12e3392e52756e5ba97ab8cd54d57b Mon Sep 17 00:00:00 2001 From: Nathaniel van Diepen Date: Wed, 1 Apr 2026 16:21:51 -0600 Subject: [PATCH 05/31] Fix fuzz --- .gitignore | 1 + ext4/__init__.py | 68 +++++++++++++++++++++++++----------------------- ext4/volume.py | 9 ------- fuzz.py | 26 +++++++++++++++--- 4 files changed, 58 insertions(+), 46 deletions(-) diff --git a/.gitignore b/.gitignore index c27e4ef..03b01bb 100644 --- a/.gitignore +++ b/.gitignore @@ -163,3 +163,4 @@ cython_debug/ *.ext4.tmp crash-* timeout-* +corpus/seed/ diff --git a/ext4/__init__.py b/ext4/__init__.py index 605479c..36e3d13 100644 --- a/ext4/__init__.py +++ b/ext4/__init__.py @@ -56,6 +56,7 @@ Hurd1, Hurd2, Inode, + InodeError, Linux1, Linux2, Masix1, @@ -82,11 +83,28 @@ ) __all__ = [ + "BlockDescriptor", + "BlockDevice", + "BlockIO", + "BlockIOBlocks", + "CharacterDevice", + "ChecksumError", + "Directory", + "DirectoryEntry", + "DirectoryEntry2", + "DirectoryEntryHash", + "DirectoryEntryTail", + "DotDirectoryEntry2", "DX_HASH", + "DXEntry", + "DXRoot", + "DXRootInfo", "EXT2_FLAGS", "EXT4_BG", "EXT4_CHKSUM", "EXT4_DEFM", + "EXT4_DIR_PAD", + "EXT4_DIR_ROUND", "EXT4_ERRORS", "EXT4_FEATURE_COMPAT", "EXT4_FEATURE_INCOMPAT", @@ -95,55 +113,39 @@ "EXT4_FS", "EXT4_FT", "EXT4_INO", + "EXT4_MAX_REC_LEN", "EXT4_MOUNT", "EXT4_MOUNT2", + "EXT4_NAME_LEN", "EXT4_OS", "EXT4_REV", - "FS_ENCRYPTION_MODE", - "MODE", - "Superblock", - "BlockDescriptor", - "BlockDevice", - "CharacterDevice", - "Directory", + "ExtendedAttributeEntry", + "ExtendedAttributeError", + "ExtendedAttributeHeader", + "ExtendedAttributeIBodyHeader", + "Extent", + "ExtentBlocks", + "ExtentHeader", + "ExtentIndex", + "ExtentTail", "Fifo", "File", + "FS_ENCRYPTION_MODE", "Hurd1", "Hurd2", "Inode", + "InodeError", + "InvalidStreamException", "Linux1", "Linux2", + "MagicError", "Masix1", "Masix2", + "MODE", "Osd1", "Osd2", "Socket", + "Superblock", "SymbolicLink", "Volume", - "InvalidStreamException", - "Extent", - "ExtentBlocks", - "ExtentHeader", - "ExtentIndex", - "ExtentTail", - "MagicError", - "ChecksumError", - "BlockIO", - "BlockIOBlocks", - "DirectoryEntry", - "DirectoryEntry2", - "DirectoryEntryTail", - "DirectoryEntryHash", - "EXT4_NAME_LEN", - "EXT4_DIR_PAD", - "EXT4_DIR_ROUND", - "EXT4_MAX_REC_LEN", - "ExtendedAttributeError", - "ExtendedAttributeIBodyHeader", - "ExtendedAttributeHeader", - "ExtendedAttributeEntry", - "DXRoot", - "DotDirectoryEntry2", - "DXEntry", - "DXRootInfo", ] diff --git a/ext4/volume.py b/ext4/volume.py index 9a46a9d..908958a 100644 --- a/ext4/volume.py +++ b/ext4/volume.py @@ -3,7 +3,6 @@ import errno import io import os -import sys from pathlib import PurePosixPath from uuid import UUID @@ -110,14 +109,6 @@ def __init__( table_offset + (index * self.superblock.desc_size), index, ) - print( - f"DEBUG: Created BlockDescriptor {index} at offset {table_offset + (index * self.superblock.desc_size)}", - file=sys.stderr, - ) - print( - f"DEBUG: bg_inode_table = {descriptor.bg_inode_table}", - file=sys.stderr, - ) descriptor.verify() self.group_descriptors.insert(index, descriptor) diff --git a/fuzz.py b/fuzz.py index 2b64dd4..5c5e89c 100644 --- a/fuzz.py +++ b/fuzz.py @@ -3,9 +3,18 @@ import atheris +MIN_DATA_SIZE = 128 * 1024 # 128KB + +seed_file = os.path.join("corpus", "seed", "seed.bin") +if not os.path.exists(seed_file) or os.path.getsize(seed_file) != MIN_DATA_SIZE: + os.makedirs(os.path.dirname(seed_file), exist_ok=True) + with open(seed_file, "wb") as f: + _ = f.write(b"\x00" * MIN_DATA_SIZE) + with atheris.instrument_imports(): # pyright: ignore[reportAttributeAccessIssue,reportUnknownMemberType] from ext4 import ( File, + InodeError, SymbolicLink, Volume, ) @@ -15,9 +24,6 @@ ) -MIN_DATA_SIZE = 128 * 1024 # 128KB - - class FuzzableStream(PeekableStream): SUPERBLOCK_OFFSET: int = 0x400 SUPERBLOCK_MAGIC_OFFSET: int = SUPERBLOCK_OFFSET + 0x38 # 0x438 @@ -209,10 +215,22 @@ def TestOneInput(data: bytes) -> None: for bd in vol.group_descriptors: _ = bd.bg_block_bitmap - root = vol.root + try: + root = vol.root + + except InodeError: + return + for dirent, _ in root.opendir(): _ = dirent.name_bytes + try: + for _ in vol.inodes: + pass + + except InodeError: + return + for inode in [ vol.inodes[1], # File vol.inodes[2], # Directory (root) From 14d970f9549da5a85df22bf0bed5afd57779356b Mon Sep 17 00:00:00 2001 From: Nathaniel van Diepen Date: Wed, 1 Apr 2026 16:26:08 -0600 Subject: [PATCH 06/31] Only fuzz on linux --- .github/workflows/build.yaml | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index d7ea7f3..64a25ba 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -84,15 +84,11 @@ jobs: run: make test fuzz: name: Fuzz - runs-on: ${{ matrix.os }} + runs-on: ubuntu-latest needs: [test-image] strategy: fail-fast: false matrix: - os: - - ubuntu-latest - - windows-latest - - macos-latest python: - "3.10" - "3.11" From 112dd8f1890c35e4a247d319229c7d7c70c9f55e Mon Sep 17 00:00:00 2001 From: Nathaniel van Diepen Date: Wed, 1 Apr 2026 16:33:01 -0600 Subject: [PATCH 07/31] Fixup makefile --- Makefile | 24 +++--------------------- 1 file changed, 3 insertions(+), 21 deletions(-) diff --git a/Makefile b/Makefile index e534940..b54986f 100644 --- a/Makefile +++ b/Makefile @@ -56,7 +56,7 @@ ${VENV_BIN_ACTIVATE}: pyproject.toml python -m pip install \ --require-virtualenv \ --editable \ - .; + .[dev]; .PHONY: test test: ${VENV_BIN_ACTIVATE} @@ -83,12 +83,8 @@ fuzz: ${VENV_BIN_ACTIVATE} all: release .PHONY: lint -lint: $(VENV_BIN_ACTIVATE) +lint: $(VENV_BIN_ACTIVATE); \ @. ${VENV_BIN_ACTIVATE}; \ - python -m pip install \ - --require-virtualenv \ - --editable \ - .[dev]; \ python -m pip install \ --require-virtualenv \ --editable \ @@ -102,12 +98,8 @@ lint: $(VENV_BIN_ACTIVATE) python -m basedpyright .PHONY: lint-fix -lint-fix: $(VENV_BIN_ACTIVATE) +lint-fix: $(VENV_BIN_ACTIVATE); \ @. ${VENV_BIN_ACTIVATE}; \ - python -m pip install \ - --require-virtualenv \ - --editable \ - .[dev]; \ python -m pip install \ --require-virtualenv \ --editable \ @@ -122,20 +114,10 @@ lint-fix: $(VENV_BIN_ACTIVATE) .PHONY: format format: $(VENV_BIN_ACTIVATE) - @. ${VENV_BIN_ACTIVATE}; \ - python -m pip install \ - --require-virtualenv \ - --editable \ - .[dev] . $(VENV_BIN_ACTIVATE); \ python -m ruff format --diff .PHONY: format-fix format-fix: $(VENV_BIN_ACTIVATE) - @. ${VENV_BIN_ACTIVATE}; \ - python -m pip install \ - --require-virtualenv \ - --editable \ - .[dev] . $(VENV_BIN_ACTIVATE); \ python -m ruff format From 1352748a6a4fcc4d0ac68fcaf7d19977622b252f Mon Sep 17 00:00:00 2001 From: Nathaniel van Diepen Date: Wed, 1 Apr 2026 16:34:04 -0600 Subject: [PATCH 08/31] Ignore certain checks for the fuzzing --- fuzz.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/fuzz.py b/fuzz.py index 5c5e89c..b1c57c0 100644 --- a/fuzz.py +++ b/fuzz.py @@ -210,7 +210,13 @@ def TestOneInput(data: bytes) -> None: stream = FuzzableStream(data) - vol = Volume(stream) + vol = Volume( + stream, + ignore_checksum=True, + ignore_flags=True, + ignore_magic=True, + ignore_attr_name_index=True, + ) _ = vol.superblock for bd in vol.group_descriptors: _ = bd.bg_block_bitmap From d80d4623bb1080512a76a7341f2801f3e76bdb53 Mon Sep 17 00:00:00 2001 From: Nathaniel van Diepen Date: Wed, 1 Apr 2026 16:54:03 -0600 Subject: [PATCH 09/31] Add htree and ignore data where Volume.root is not a directory --- fuzz.py | 263 +++++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 215 insertions(+), 48 deletions(-) diff --git a/fuzz.py b/fuzz.py index b1c57c0..b8ccdf8 100644 --- a/fuzz.py +++ b/fuzz.py @@ -13,6 +13,8 @@ with atheris.instrument_imports(): # pyright: ignore[reportAttributeAccessIssue,reportUnknownMemberType] from ext4 import ( + EXT4_INO, + Directory, File, InodeError, SymbolicLink, @@ -32,6 +34,24 @@ def __init__(self, data: bytes) -> None: self._view: memoryview[bytearray] = memoryview(data) self._cursor: int = 0 + # Decision bytes (contiguous) + self._enable_extents = data[0] & 0x80 + self._enable_htree = data[1] & 0x80 + self._num_groups = (data[2] % 8) + 1 + self._log_block_size = data[3] % 4 + self._feature_incompat = int.from_bytes(data[4:8], "little") + self._extent_depth = data[5] % 2 + self._htree_complexity = data[6] % 2 + + # Content bytes for structures (contiguous after decisions) + self._extent_num_extents = (data[7] % 5) + 1 + self._extent_block = int.from_bytes(data[8:12], "little") & 0xFFFF + self._htree_num_entries = (data[12] % 8) + 1 + self._htree_hash_version = data[13] % 4 + + # Block group content (16 bytes, 2 groups worth) + self._bg_data = data[14:30] + @override def read(self, size: int | None = None) -> bytes: result = self.peek(size) @@ -47,6 +67,7 @@ def peek(self, size: int | None = None) -> bytes: # noqa: PLR0915 end = offset + size sb_start = self.SUPERBLOCK_OFFSET + block_size = 1024 << self._log_block_size # 1K, 2K, 4K, or 8K sb_end = sb_start + 256 # Superblock is 256 bytes if offset < sb_end and end >= sb_start: # Build superblock data from scratch @@ -68,7 +89,7 @@ def peek(self, size: int | None = None) -> bytes: # noqa: PLR0915 result[0x14:0x18] = b"\x01\x00\x00\x00" # s_log_block_size at offset 0x18 - result[0x18:0x1C] = b"\x00\x00\x00\x00" + result[0x18:0x1C] = self._log_block_size.to_bytes(4, "little") # s_blocks_per_group at offset 0x20 result[0x20:0x24] = b"\x08\x00\x00\x00" @@ -89,7 +110,7 @@ def peek(self, size: int | None = None) -> bytes: # noqa: PLR0915 result[0x64:0x68] = b"\x00\x00\x00\x00" # s_feature_incompat at offset 0x68 - result[0x68:0x6C] = b"\x40\x00\x00\x00" # IS64BIT + result[0x68:0x6C] = self._feature_incompat.to_bytes(4, "little") # s_feature_ro_compat at offset 0x6C result[0x6C:0x70] = b"\x00\x00\x00\x00" @@ -110,77 +131,215 @@ def peek(self, size: int | None = None) -> bytes: # noqa: PLR0915 return bytes(result) - # Check if reading block group descriptor table (at block 3, offset 3072) - bgdt_start = 3072 # Block 3 * 1K block size + # Check if reading block group descriptor table + bgdt_start = block_size * 3 # Block 3 bgdt_size = 32 # 32 bytes per block descriptor - bgdt_end = bgdt_start + bgdt_size + bgdt_end = bgdt_start + (bgdt_size * self._num_groups) if offset < bgdt_end and end >= bgdt_start: - # Generate valid block descriptor - result = bytearray(bgdt_size) - # bg_inode_table_lo at offset 8 - # Set to block 2 = 2 - result[8:12] = (2).to_bytes(4, "little") - return bytes(result) + # Generate block descriptors based on data bytes + result = bytearray(bgdt_size * self._num_groups) + for i in range(self._num_groups): + base = i * bgdt_size + bb_offset = 2 + (i * 2) + ib_offset = 4 + (i * 2) + result[base + 0 : base + 2] = bytes( + [self._bg_data[bb_offset % 16], self._bg_data[(bb_offset + 1) % 16]] + ) + result[base + 4 : base + 6] = bytes( + [self._bg_data[ib_offset % 16], self._bg_data[(ib_offset + 1) % 16]] + ) + result[base + 8 : base + 12] = (2 + i * 4).to_bytes(4, "little") + + start = max(0, offset - bgdt_start) + end_pos = min(bgdt_end, end) + return bytes(result[start : end_pos - bgdt_start]) # Check if reading inode table (at block 2, offset 2048 for 1K blocks) - inode_table_start = 2048 # Block 2 * 1K block size + inode_table_start = block_size * 2 # Block 2 inode_size = 128 num_inodes = 32 inode_table_end = inode_table_start + ( num_inodes * inode_size ) # 32 inodes * 128 bytes - # Only serve generated inode table for reads in the exact inode table region (2048-4096) + # Only serve generated inode table for reads in the exact inode table region if offset >= inode_table_start and offset < inode_table_end: # Create valid inode data for inodes 1-32 result = bytearray(num_inodes * inode_size) - # Inode 1 - Regular file (IFREG = 0x8000) - result[0:2] = b"\x00\x80" - - # Root inode (inode 2) - set as directory (IFDIR = 0x4000) - result[128:130] = b"\x00\x40" # IFDIR - - # Inode 3 - Symbolic link (IFLNK = 0xA000) - result[256:258] = b"\x00\xa0" - - # Inode 4 - Socket (IFSOCK = 0xC000) - result[384:386] = b"\x00\xc0" - - # Boot loader inode (inode 5) - Block device (IFBLK = 0x6000) - result[512:514] = b"\x00\x60" # IFBLK - - # Bad blocks inode (inode 6) - Block device (IFBLK = 0x6000) - result[640:642] = b"\x00\x60" # IFBLK - - # Inode 7 - Character device (IFCHR = 0x2000) - result[768:770] = b"\x00\x20" + # Determine which inodes get special flags based on data bytes + extents_inodes = {1, 10} if self._enable_extents else set() + htree_inodes = {2, 9, 11} if self._enable_htree else set() + dir_inodes = {2, 9, 11} - # Inode 8 - FIFO (IFIFO = 0x1000) - result[896:898] = b"\x00\x10" - - # Inode 9 - Another directory - result[1024:1026] = b"\x00\x40" - - # Inode 10 - Another file - result[1152:1154] = b"\x00\x80" - - # Journal inode (inode 11) - set as directory (IFDIR = 0x4000) - result[1280:1282] = b"\x00\x40" - - # Inode 12-32 - Mix of files and directories - for i in range(12, 33): + for i in range(1, num_inodes + 1): inode_offset = (i - 1) * inode_size - file_type = 0x40 if i % 3 == 0 else 0x80 # Alternate IFDIR and IFREG + file_type = 0x40 if i in dir_inodes else 0x80 + if i in htree_inodes: + file_type = 0x40 # Directory + result[inode_offset : inode_offset + 2] = file_type.to_bytes( 2, "little" ) + # Set i_flags for EXTENTS and INDEX + flags = 0 + if i in extents_inodes: + flags |= 0x80000 # EXTENTS + if i in htree_inodes: + flags |= 0x1000 # INDEX + + if flags: + result[inode_offset + 0x14 : inode_offset + 0x18] = flags.to_bytes( + 4, "little" + ) + + # Generate extent tree in i_block[] for EXTENTS inodes + if i in extents_inodes: + extent_offset = inode_offset + 0x28 # i_block offset + + if self._extent_depth == 0: + # Depth 0: ExtentHeader + Extent entries + eh_magic = 0xF30A + result[extent_offset : extent_offset + 2] = eh_magic.to_bytes( + 2, "little" + ) + result[extent_offset + 2 : extent_offset + 4] = ( + self._extent_num_extents.to_bytes(2, "little") + ) + result[extent_offset + 4 : extent_offset + 6] = ( + self._extent_num_extents + ).to_bytes(2, "little") + result[extent_offset + 6 : extent_offset + 8] = ( + b"\x00\x00" # eh_depth = 0 + ) + result[extent_offset + 8 : extent_offset + 12] = ( + b"\x00\x00\x00\x00" + ) + + # Extent entries (ee_block, ee_len, ee_start_hi, ee_start_lo) + for j in range(self._extent_num_extents): + ee_offset = extent_offset + 12 + (j * 12) + block_num = (self._extent_block + j) & 0xFFFF + result[ee_offset : ee_offset + 4] = ( + j * block_size + ).to_bytes(4, "little") + result[ee_offset + 4 : ee_offset + 6] = ( + b"\x01\x00" # ee_len = 1 + ) + result[ee_offset + 6 : ee_offset + 8] = b"\x00\x00" + result[ee_offset + 8 : ee_offset + 12] = block_num.to_bytes( + 4, "little" + ) + else: + # Depth 1: ExtentHeader with index + ExtentIndex entries + result[extent_offset : extent_offset + 2] = ( + b"\x0a\xf3" # eh_magic + ) + result[extent_offset + 2 : extent_offset + 4] = ( + b"\x01\x00" # eh_entries = 1 + ) + result[extent_offset + 4 : extent_offset + 6] = ( + b"\x01\x00" # eh_max = 1 + ) + result[extent_offset + 6 : extent_offset + 8] = ( + b"\x01\x00" # eh_depth = 1 + ) + result[extent_offset + 8 : extent_offset + 12] = ( + b"\x00\x00\x00\x00" + ) + + # ExtentIndex entry pointing to leaf block + ei_offset = extent_offset + 12 + result[ei_offset : ei_offset + 4] = ( + b"\x00\x00\x00\x00" # ei_block = 0 + ) + leaf_block = (self._extent_block & 0xFF) + 10 + result[ei_offset + 4 : ei_offset + 8] = leaf_block.to_bytes( + 4, "little" + ) + result[ei_offset + 8 : ei_offset + 10] = b"\x00\x00" + result[ei_offset + 10 : ei_offset + 12] = b"\x00\x00" + + # For htree directories, set i_block[0] to point to htree data block + if i in htree_inodes: + htree_block = 20 + i + result[inode_offset + 0x28 : inode_offset + 0x2C] = ( + htree_block.to_bytes(4, "little") + ) + # Extract the portion requested relative to inode table start start = offset - inode_table_start return bytes(result[start : start + size]) + # Check for htree data blocks (block 20+ based on inode) + htree_start = block_size * 20 + htree_end = htree_start + (block_size * 4) + if offset < htree_end and end >= htree_start and self._enable_htree: + block_idx = (offset - htree_start) // block_size + if block_idx < 4: + result = bytearray(block_size) + if block_idx == 0: + result[0:4] = b"\x01\x00\x00\x00" # inode: 1 (.) + result[4:6] = b"\x10\x00" # rec_len: 16 + result[6:7] = b"\x01" # name_len: 1 + result[7:8] = bytes([0x02]) # file_type: DIR + result[8:12] = b".\x00\x00\x00" + result[12:16] = b"\x02\x00\x00\x00" # inode: 2 (..) + result[16:18] = b"\x10\x00" # rec_len: 16 + result[18:19] = b"\x02" # name_len: 2 + result[19:20] = bytes([0x02]) # file_type: DIR + result[20:24] = b"..\x00\x00" + result[24:28] = b"\x00\x00\x00\x00" # dx_root_info.reserved_zero + result[28:29] = bytes([self._htree_hash_version]) # hash_version + result[29:30] = b"\x08" # info_length: 8 + result[30:31] = bytes([self._htree_complexity]) # indirect_levels + result[31:32] = b"\x00" # unused_flags + result[32:34] = (12 + self._htree_num_entries * 8).to_bytes( + 2, "little" + ) # limit + result[34:36] = self._htree_num_entries.to_bytes( + 2, "little" + ) # count + result[36:40] = b"\x00\x00\x00\x00" # block + + dx_entry_offset = 40 + for j in range(self._htree_num_entries): + hash_val = (j * 0x1234567) & 0xFFFFFFFF + result[dx_entry_offset : dx_entry_offset + 4] = ( + hash_val.to_bytes(4, "little") + ) + result[dx_entry_offset + 4 : dx_entry_offset + 8] = ( + 20 + j + ).to_bytes(4, "little") + dx_entry_offset += 8 + + start = (offset - htree_start) % block_size + end_pos = min(block_size, start + size) + return bytes(result[start:end_pos]) + + # Check for extent leaf blocks (block 10+) + extent_leaf_start = block_size * 10 + extent_leaf_end = extent_leaf_start + (block_size * 4) + if ( + offset < extent_leaf_end + and end >= extent_leaf_start + and self._enable_extents + ): + block_idx = (offset - extent_leaf_start) // block_size + if block_idx < self._extent_num_extents: + result = bytearray(block_size) + result[0:4] = (block_idx * block_size).to_bytes(4, "little") + result[4:6] = b"\x01\x00" + result[6:8] = b"\x00\x00" + result[8:12] = ((self._extent_block + block_idx) & 0xFFFF).to_bytes( + 4, "little" + ) + start = (offset - extent_leaf_start) % block_size + end_pos = min(block_size, start + size) + return bytes(result[start:end_pos]) + return self._view[offset:end].tobytes() @override @@ -217,6 +376,9 @@ def TestOneInput(data: bytes) -> None: ignore_magic=True, ignore_attr_name_index=True, ) + if not isinstance(vol.inodes[EXT4_INO.ROOT], Directory): + return + _ = vol.superblock for bd in vol.group_descriptors: _ = bd.bg_block_bitmap @@ -227,6 +389,11 @@ def TestOneInput(data: bytes) -> None: except InodeError: return + htree = root.htree + if htree is not None: + for _ in htree.entries: + pass + for dirent, _ in root.opendir(): _ = dirent.name_bytes From 255450efa1c7b87769a5d72b36a825d90c235589 Mon Sep 17 00:00:00 2001 From: Nathaniel van Diepen Date: Wed, 1 Apr 2026 17:03:02 -0600 Subject: [PATCH 10/31] Better fuzz root handling --- fuzz.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/fuzz.py b/fuzz.py index b8ccdf8..bd6b8e2 100644 --- a/fuzz.py +++ b/fuzz.py @@ -169,8 +169,8 @@ def peek(self, size: int | None = None) -> bytes: # noqa: PLR0915 result = bytearray(num_inodes * inode_size) # Determine which inodes get special flags based on data bytes - extents_inodes = {1, 10} if self._enable_extents else set() - htree_inodes = {2, 9, 11} if self._enable_htree else set() + extents_inodes: set[int] = {1, 10} if self._enable_extents else set() + htree_inodes: set[int] = {2, 9, 11} if self._enable_htree else set() dir_inodes = {2, 9, 11} for i in range(1, num_inodes + 1): @@ -363,7 +363,7 @@ def tell(self) -> int: return self._cursor -def TestOneInput(data: bytes) -> None: +def TestOneInput(data: bytes) -> None: # noqa: PLR0912 if len(data) < MIN_DATA_SIZE: return @@ -376,19 +376,19 @@ def TestOneInput(data: bytes) -> None: ignore_magic=True, ignore_attr_name_index=True, ) - if not isinstance(vol.inodes[EXT4_INO.ROOT], Directory): - return - - _ = vol.superblock - for bd in vol.group_descriptors: - _ = bd.bg_block_bitmap try: - root = vol.root + if not isinstance(vol.inodes[EXT4_INO.ROOT], Directory): + return except InodeError: return + _ = vol.superblock + for bd in vol.group_descriptors: + _ = bd.bg_block_bitmap + + root = vol.root htree = root.htree if htree is not None: for _ in htree.entries: From d30b1316b126b8f7c731291c2633ae7ba5aa9e19 Mon Sep 17 00:00:00 2001 From: Nathaniel van Diepen Date: Wed, 1 Apr 2026 17:05:14 -0600 Subject: [PATCH 11/31] fix lint --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index b54986f..116f8d2 100644 --- a/Makefile +++ b/Makefile @@ -83,7 +83,7 @@ fuzz: ${VENV_BIN_ACTIVATE} all: release .PHONY: lint -lint: $(VENV_BIN_ACTIVATE); \ +lint: $(VENV_BIN_ACTIVATE); @. ${VENV_BIN_ACTIVATE}; \ python -m pip install \ --require-virtualenv \ From eea4111cde7fde1a25d10a86f1c6a0a317bcbb2b Mon Sep 17 00:00:00 2001 From: Nathaniel van Diepen Date: Wed, 1 Apr 2026 17:10:19 -0600 Subject: [PATCH 12/31] Stop fuzzing on 3.10 --- .github/workflows/build.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 64a25ba..457ce75 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -90,7 +90,7 @@ jobs: fail-fast: false matrix: python: - - "3.10" + #- "3.10" # atheris appears to break - "3.11" - "3.12" - "3.13" From 85ff1061916e952d4ac13f8ba322cbfd97c18251 Mon Sep 17 00:00:00 2001 From: Nathaniel van Diepen Date: Wed, 1 Apr 2026 17:47:00 -0600 Subject: [PATCH 13/31] Lint all versions --- .github/workflows/build.yaml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 457ce75..350d0fd 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -12,13 +12,22 @@ jobs: lint: name: Lint code runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python: + - "3.10" + - "3.11" + - "3.12" + - "3.13" + - "3.14" steps: - name: Checkout the Git repository uses: actions/checkout@v4 - name: Setup Python uses: actions/setup-python@v5 with: - python-version: 3.14 + python-version: ${{ matrix.python }} cache: "pip" - name: Lint code shell: bash From f0e6632cbaab689c76337df30c11c3ac40580056 Mon Sep 17 00:00:00 2001 From: Nathaniel van Diepen Date: Wed, 1 Apr 2026 17:49:27 -0600 Subject: [PATCH 14/31] Fix lint --- fuzz.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/fuzz.py b/fuzz.py index bd6b8e2..ea4120d 100644 --- a/fuzz.py +++ b/fuzz.py @@ -1,5 +1,6 @@ import os import sys +from typing import final import atheris @@ -26,6 +27,7 @@ ) +@final class FuzzableStream(PeekableStream): SUPERBLOCK_OFFSET: int = 0x400 SUPERBLOCK_MAGIC_OFFSET: int = SUPERBLOCK_OFFSET + 0x38 # 0x438 From 9d4c55353d7ccf72d33f0399dcb6580880133df1 Mon Sep 17 00:00:00 2001 From: Nathaniel van Diepen Date: Wed, 1 Apr 2026 17:56:45 -0600 Subject: [PATCH 15/31] Review fixes --- ext4/struct.py | 8 ++++---- pyproject.toml | 1 - 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/ext4/struct.py b/ext4/struct.py index 82d32e3..0b92017 100644 --- a/ext4/struct.py +++ b/ext4/struct.py @@ -106,19 +106,19 @@ def size(self) -> int: return sizeof(self) @property - def magic(self) -> None: + def magic(self) -> int | None: return None @property - def expected_magic(self) -> None: + def expected_magic(self) -> int | None: return None @property - def checksum(self) -> None: + def checksum(self) -> int | None: return None @property - def expected_checksum(self) -> None: + def expected_checksum(self) -> int | None: return None @property diff --git a/pyproject.toml b/pyproject.toml index 2dbc010..04fe251 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,7 +56,6 @@ packages = [ ] [tool.setuptools.dynamic] -dependencies = {file = ["requirements.txt"]} readme = {file= ["README.md"], content-type = "text/markdown"} [build-system] From e4a260f7475a67054bd722183dc387f8181f1e1f Mon Sep 17 00:00:00 2001 From: Nathaniel van Diepen Date: Wed, 1 Apr 2026 18:20:56 -0600 Subject: [PATCH 16/31] Filter more unusable data --- .../0183e12fe47d0e7625b09fd9b3b352fb8e7a0006 | Bin 0 -> 131072 bytes fuzz.py | 7 +++++++ 2 files changed, 7 insertions(+) create mode 100644 corpus/0183e12fe47d0e7625b09fd9b3b352fb8e7a0006 diff --git a/corpus/0183e12fe47d0e7625b09fd9b3b352fb8e7a0006 b/corpus/0183e12fe47d0e7625b09fd9b3b352fb8e7a0006 new file mode 100644 index 0000000000000000000000000000000000000000..ae3b0525d3f740920444fee8be9dc3d537d48346 GIT binary patch literal 131072 zcmeIup$z~q2t`50sqX%R;b=Ml7R(_(;w2Zcd}p~YPk;ac0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV8oI$f{~y zWV5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF%n97Nlo9~~1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N s0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0tCLm1A8|FPyhe` literal 0 HcmV?d00001 diff --git a/fuzz.py b/fuzz.py index ea4120d..4ad38b3 100644 --- a/fuzz.py +++ b/fuzz.py @@ -1,3 +1,4 @@ +import errno import os import sys from typing import final @@ -386,6 +387,12 @@ def TestOneInput(data: bytes) -> None: # noqa: PLR0912 except InodeError: return + except OSError as e: + if e.errno == errno.EINVAL: + return + + raise + _ = vol.superblock for bd in vol.group_descriptors: _ = bd.bg_block_bitmap From d961631e134f974c213feba7469905411b916744 Mon Sep 17 00:00:00 2001 From: Nathaniel van Diepen Date: Wed, 1 Apr 2026 18:28:20 -0600 Subject: [PATCH 17/31] Disable warnings in fuzzer, ignore short reads --- .../32c1133d349ff133b85c55ff7f6cdb0b8c5747ac | Bin 0 -> 131072 bytes fuzz.py | 6 ++++++ 2 files changed, 6 insertions(+) create mode 100644 corpus/32c1133d349ff133b85c55ff7f6cdb0b8c5747ac diff --git a/corpus/32c1133d349ff133b85c55ff7f6cdb0b8c5747ac b/corpus/32c1133d349ff133b85c55ff7f6cdb0b8c5747ac new file mode 100644 index 0000000000000000000000000000000000000000..51b239df816fcf216e5c4cee9b7f57645d9dc8cd GIT binary patch literal 131072 zcmeIuu?YYm3@1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!8At zz=?>KVxON?4Wv0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF x5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009E;0vBCR1d0Fv literal 0 HcmV?d00001 diff --git a/fuzz.py b/fuzz.py index 4ad38b3..e9c3474 100644 --- a/fuzz.py +++ b/fuzz.py @@ -1,10 +1,13 @@ import errno import os import sys +import warnings from typing import final import atheris +warnings.filterwarnings("ignore") + MIN_DATA_SIZE = 128 * 1024 # 128KB seed_file = os.path.join("corpus", "seed", "seed.bin") @@ -391,6 +394,9 @@ def TestOneInput(data: bytes) -> None: # noqa: PLR0912 if e.errno == errno.EINVAL: return + if "Short read for" in str(e): + return + raise _ = vol.superblock From c6fc3fdc9c1b93ce04acb7573193c1c18754296a Mon Sep 17 00:00:00 2001 From: Nathaniel van Diepen Date: Wed, 1 Apr 2026 18:34:45 -0600 Subject: [PATCH 18/31] Better handle OSError --- .../3296e61e301e0984140f170ff5ab8ac4499be67f | Bin 0 -> 131072 bytes .../492edd33a1a4061e9f893bf39a2e4cc13a9a2540 | Bin 0 -> 131072 bytes .../56aff556cc6970f095c56920f9d8d61ee8fe57c9 | Bin 0 -> 131072 bytes .../5d294ded54527b99dbd3697cc4a80d32e60ab2bb | Bin 0 -> 131072 bytes .../641f71118e439cf3c5adab60747042bdb601cf6b | Bin 0 -> 131072 bytes .../7ed9cd5e402079f0f97351888b486c9ac6550849 | Bin 0 -> 131072 bytes .../9caaba1fb571659ebf3c18d8e99154f1ac8e1a56 | Bin 0 -> 131072 bytes .../aa8423b1a8df2b9d7d017ed5d2bc71b210a95233 | Bin 0 -> 131072 bytes .../b241723689a8b999b3bab3bea5c67d0ff7fb353d | Bin 0 -> 131072 bytes .../b55366005e1f23d5bb6ce13eaf9af3b3d5e8552d | Bin 0 -> 131072 bytes .../e2c0097849a627e3cdda6e19ce819e96b06bec3b | Bin 0 -> 131072 bytes .../f7a7658aca261d9daabba67e3e3d3cd49e15a767 | Bin 0 -> 131072 bytes .../fd99672131d99a8989f997c6af548696b6dd4e7b | Bin 0 -> 131072 bytes ext4/block.py | 3 ++- ext4/struct.py | 4 +++- fuzz.py | 2 +- 16 files changed, 6 insertions(+), 3 deletions(-) create mode 100644 corpus/3296e61e301e0984140f170ff5ab8ac4499be67f create mode 100644 corpus/492edd33a1a4061e9f893bf39a2e4cc13a9a2540 create mode 100644 corpus/56aff556cc6970f095c56920f9d8d61ee8fe57c9 create mode 100644 corpus/5d294ded54527b99dbd3697cc4a80d32e60ab2bb create mode 100644 corpus/641f71118e439cf3c5adab60747042bdb601cf6b create mode 100644 corpus/7ed9cd5e402079f0f97351888b486c9ac6550849 create mode 100644 corpus/9caaba1fb571659ebf3c18d8e99154f1ac8e1a56 create mode 100644 corpus/aa8423b1a8df2b9d7d017ed5d2bc71b210a95233 create mode 100644 corpus/b241723689a8b999b3bab3bea5c67d0ff7fb353d create mode 100644 corpus/b55366005e1f23d5bb6ce13eaf9af3b3d5e8552d create mode 100644 corpus/e2c0097849a627e3cdda6e19ce819e96b06bec3b create mode 100644 corpus/f7a7658aca261d9daabba67e3e3d3cd49e15a767 create mode 100644 corpus/fd99672131d99a8989f997c6af548696b6dd4e7b diff --git a/corpus/3296e61e301e0984140f170ff5ab8ac4499be67f b/corpus/3296e61e301e0984140f170ff5ab8ac4499be67f new file mode 100644 index 0000000000000000000000000000000000000000..3eb0746a0044a10c5f09228ce439cf2c0c943586 GIT binary patch literal 131072 zcmeI*F%Ezr36dVb0Hx0gX8-^I literal 0 HcmV?d00001 diff --git a/corpus/56aff556cc6970f095c56920f9d8d61ee8fe57c9 b/corpus/56aff556cc6970f095c56920f9d8d61ee8fe57c9 new file mode 100644 index 0000000000000000000000000000000000000000..b5444494259642b10236213ec62a5b0e140e495b GIT binary patch literal 131072 zcmeI*s|~;)6hP63sOcp2b=`lX8dT|L0n&tzIT!@+ZgLhNB1MUbYpb=7^xZhucPpjj z+5_hV2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1eyrs=l858gA5@+fB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkLHzrfNzp+JBD0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5SS*gPb&)(AV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oM+{ zaE*v)5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 a2oNAZfB*pk1PBlyK!5-N0t5*B6Sx6|s0)Dr literal 0 HcmV?d00001 diff --git a/corpus/5d294ded54527b99dbd3697cc4a80d32e60ab2bb b/corpus/5d294ded54527b99dbd3697cc4a80d32e60ab2bb new file mode 100644 index 0000000000000000000000000000000000000000..bdd01163c762cd78cd872d7315bb55a4e4234818 GIT binary patch literal 131072 zcmeIuF$%yi2nEm_r+c@YruRQVrrITYKmtMfT=lEG)(+RTy5Hx(vl`SVK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAh1Px# literal 0 HcmV?d00001 diff --git a/corpus/641f71118e439cf3c5adab60747042bdb601cf6b b/corpus/641f71118e439cf3c5adab60747042bdb601cf6b new file mode 100644 index 0000000000000000000000000000000000000000..1cf58d8674592d91726a5ec5d36fe0243a86c21e GIT binary patch literal 131072 zcmeI*u?|2m5CG7M$)A{w{@5Ry6k!(;PI9kH2iLpa>ngcYni9JDtLoz!FztJ-b-p@q z?|XMITr>d!1PBlyK!5-N0t5&=7HA)5kbnRI0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk9|_Fc^sJ9cDU)3wc&uD@e*F_5K!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB=EF1dJqjOIBV2 z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfIvclAz{y2CP07y0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oT65uw-ghuLKAXAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXFL<-bn`HMO75!5C?fB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oU(QK=bFzxf=lj x1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!8AmzzTTo5-k7# literal 0 HcmV?d00001 diff --git a/corpus/7ed9cd5e402079f0f97351888b486c9ac6550849 b/corpus/7ed9cd5e402079f0f97351888b486c9ac6550849 new file mode 100644 index 0000000000000000000000000000000000000000..8e93fb8aea315427a56efbc16a86fbc9adcf76ad GIT binary patch literal 131072 zcmeI*F%Ezr5Cp)p^(FRpe%a?)Xr)4Y01b(`Fxvv*c6vg@Qxa4Bm8Es@_RV(8bz_!&-$Vlf1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0Rp26tfLm!n*ad< z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV6Rc zfqRgiY7!tofB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0x1Rdlv5j;009C72oNAZfB*pk1PBlyK!5-N e0t5&UAV7cs0RjXF5FkK+009C72oNC9L*NCtHWw5C literal 0 HcmV?d00001 diff --git a/corpus/9caaba1fb571659ebf3c18d8e99154f1ac8e1a56 b/corpus/9caaba1fb571659ebf3c18d8e99154f1ac8e1a56 new file mode 100644 index 0000000000000000000000000000000000000000..85351ce33c7d63cc122a7c5de93b38baf16adde4 GIT binary patch literal 131072 zcmeIuF$%yS3fYjnTnKGDi(P@ez+0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7e?Qh_HTmM*l6009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009Cs0{2Wvh5!Kq1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7e?HUjT9pL&!40RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk L1PBlyaFDxK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U WAV7cs0RjXF5FkK+009C7QVBdywH6cr literal 0 HcmV?d00001 diff --git a/corpus/f7a7658aca261d9daabba67e3e3d3cd49e15a767 b/corpus/f7a7658aca261d9daabba67e3e3d3cd49e15a767 new file mode 100644 index 0000000000000000000000000000000000000000..c0ba5bfd50c121b6ccdddff87b55cca87e73cb66 GIT binary patch literal 131072 zcmeIuF$%yi2nEm_r+c^DxHrsHyJQbYAV{C9ewEkS;ks7$`y6;ygZcyr5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C7whD}h*t*a%0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5(T1nx{oh5!Kq1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7dX8G*OVryeChfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF H{7B#fXJ!U6 literal 0 HcmV?d00001 diff --git a/corpus/fd99672131d99a8989f997c6af548696b6dd4e7b b/corpus/fd99672131d99a8989f997c6af548696b6dd4e7b new file mode 100644 index 0000000000000000000000000000000000000000..1149199a66f6b7e2743811196ada1eae5f9c155b GIT binary patch literal 131072 zcmeIuAr8PG3 int: raise NotImplementedError() if offset < 0: - raise OSError(errno.EINVAL, "Invalid argument") + raise OSError(errno.EINVAL, os.strerror(errno.EINVAL)) self.cursor = offset return offset diff --git a/ext4/struct.py b/ext4/struct.py index 0b92017..92e4883 100644 --- a/ext4/struct.py +++ b/ext4/struct.py @@ -1,5 +1,6 @@ # pyright: reportImportCycles=false import ctypes +import errno import warnings from collections.abc import Callable from ctypes import ( @@ -96,7 +97,8 @@ def read_from_volume(self) -> None: data = self.volume.read(sizeof(self)) if len(data) != sizeof(self): raise OSError( - f"Short read for {type(self).__name__} at offset {self.offset}" + errno.EIO, + f"Short read for {type(self).__name__} at offset {self.offset}", ) _ = memmove(addressof(self), data, sizeof(self)) diff --git a/fuzz.py b/fuzz.py index e9c3474..6cdd320 100644 --- a/fuzz.py +++ b/fuzz.py @@ -394,7 +394,7 @@ def TestOneInput(data: bytes) -> None: # noqa: PLR0912 if e.errno == errno.EINVAL: return - if "Short read for" in str(e): + if e.errno == errno.EIO and "Short read for" in str(e): return raise From 471deb36bbdb4e63f1a8d7fd40a3900da8877c3f Mon Sep 17 00:00:00 2001 From: Nathaniel van Diepen Date: Wed, 1 Apr 2026 20:35:48 -0600 Subject: [PATCH 19/31] Move to determensitic fs generation instead --- .../0183e12fe47d0e7625b09fd9b3b352fb8e7a0006 | Bin 131072 -> 0 bytes .../3296e61e301e0984140f170ff5ab8ac4499be67f | Bin 131072 -> 0 bytes .../32c1133d349ff133b85c55ff7f6cdb0b8c5747ac | Bin 131072 -> 0 bytes .../492edd33a1a4061e9f893bf39a2e4cc13a9a2540 | Bin 131072 -> 0 bytes .../56aff556cc6970f095c56920f9d8d61ee8fe57c9 | Bin 131072 -> 0 bytes .../5d294ded54527b99dbd3697cc4a80d32e60ab2bb | Bin 131072 -> 0 bytes .../641f71118e439cf3c5adab60747042bdb601cf6b | Bin 131072 -> 0 bytes .../7ed9cd5e402079f0f97351888b486c9ac6550849 | Bin 131072 -> 0 bytes .../9caaba1fb571659ebf3c18d8e99154f1ac8e1a56 | Bin 131072 -> 0 bytes .../aa8423b1a8df2b9d7d017ed5d2bc71b210a95233 | Bin 131072 -> 0 bytes .../b241723689a8b999b3bab3bea5c67d0ff7fb353d | Bin 131072 -> 0 bytes .../b55366005e1f23d5bb6ce13eaf9af3b3d5e8552d | Bin 131072 -> 0 bytes .../e2c0097849a627e3cdda6e19ce819e96b06bec3b | Bin 131072 -> 0 bytes .../f7a7658aca261d9daabba67e3e3d3cd49e15a767 | Bin 131072 -> 0 bytes .../fd99672131d99a8989f997c6af548696b6dd4e7b | Bin 131072 -> 0 bytes fuzz.py | 562 +++++------------- 16 files changed, 149 insertions(+), 413 deletions(-) delete mode 100644 corpus/0183e12fe47d0e7625b09fd9b3b352fb8e7a0006 delete mode 100644 corpus/3296e61e301e0984140f170ff5ab8ac4499be67f delete mode 100644 corpus/32c1133d349ff133b85c55ff7f6cdb0b8c5747ac delete mode 100644 corpus/492edd33a1a4061e9f893bf39a2e4cc13a9a2540 delete mode 100644 corpus/56aff556cc6970f095c56920f9d8d61ee8fe57c9 delete mode 100644 corpus/5d294ded54527b99dbd3697cc4a80d32e60ab2bb delete mode 100644 corpus/641f71118e439cf3c5adab60747042bdb601cf6b delete mode 100644 corpus/7ed9cd5e402079f0f97351888b486c9ac6550849 delete mode 100644 corpus/9caaba1fb571659ebf3c18d8e99154f1ac8e1a56 delete mode 100644 corpus/aa8423b1a8df2b9d7d017ed5d2bc71b210a95233 delete mode 100644 corpus/b241723689a8b999b3bab3bea5c67d0ff7fb353d delete mode 100644 corpus/b55366005e1f23d5bb6ce13eaf9af3b3d5e8552d delete mode 100644 corpus/e2c0097849a627e3cdda6e19ce819e96b06bec3b delete mode 100644 corpus/f7a7658aca261d9daabba67e3e3d3cd49e15a767 delete mode 100644 corpus/fd99672131d99a8989f997c6af548696b6dd4e7b diff --git a/corpus/0183e12fe47d0e7625b09fd9b3b352fb8e7a0006 b/corpus/0183e12fe47d0e7625b09fd9b3b352fb8e7a0006 deleted file mode 100644 index ae3b0525d3f740920444fee8be9dc3d537d48346..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 131072 zcmeIup$z~q2t`50sqX%R;b=Ml7R(_(;w2Zcd}p~YPk;ac0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV8oI$f{~y zWV5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF%n97Nlo9~~1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N s0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0tCLm1A8|FPyhe` diff --git a/corpus/3296e61e301e0984140f170ff5ab8ac4499be67f b/corpus/3296e61e301e0984140f170ff5ab8ac4499be67f deleted file mode 100644 index 3eb0746a0044a10c5f09228ce439cf2c0c943586..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 131072 zcmeI*F%Ezr3@1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!8At zz=?>KVxON?4Wv0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF x5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009E;0vBCR1d0Fv diff --git a/corpus/492edd33a1a4061e9f893bf39a2e4cc13a9a2540 b/corpus/492edd33a1a4061e9f893bf39a2e4cc13a9a2540 deleted file mode 100644 index 1c28e947752d197144848d8171dc7333d35a0aaf..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 131072 zcmeI*F%Ezr3;;l$6dVb0Hx0gX8-^I diff --git a/corpus/56aff556cc6970f095c56920f9d8d61ee8fe57c9 b/corpus/56aff556cc6970f095c56920f9d8d61ee8fe57c9 deleted file mode 100644 index b5444494259642b10236213ec62a5b0e140e495b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 131072 zcmeI*s|~;)6hP63sOcp2b=`lX8dT|L0n&tzIT!@+ZgLhNB1MUbYpb=7^xZhucPpjj z+5_hV2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1eyrs=l858gA5@+fB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkLHzrfNzp+JBD0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5SS*gPb&)(AV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oM+{ zaE*v)5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 a2oNAZfB*pk1PBlyK!5-N0t5*B6Sx6|s0)Dr diff --git a/corpus/5d294ded54527b99dbd3697cc4a80d32e60ab2bb b/corpus/5d294ded54527b99dbd3697cc4a80d32e60ab2bb deleted file mode 100644 index bdd01163c762cd78cd872d7315bb55a4e4234818..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 131072 zcmeIuF$%yi2nEm_r+c@YruRQVrrITYKmtMfT=lEG)(+RTy5Hx(vl`SVK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAh1Px# diff --git a/corpus/641f71118e439cf3c5adab60747042bdb601cf6b b/corpus/641f71118e439cf3c5adab60747042bdb601cf6b deleted file mode 100644 index 1cf58d8674592d91726a5ec5d36fe0243a86c21e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 131072 zcmeI*u?|2m5CG7M$)A{w{@5Ry6k!(;PI9kH2iLpa>ngcYni9JDtLoz!FztJ-b-p@q z?|XMITr>d!1PBlyK!5-N0t5&=7HA)5kbnRI0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk9|_Fc^sJ9cDU)3wc&uD@e*F_5K!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB=EF1dJqjOIBV2 z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfIvclAz{y2CP07y0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oT65uw-ghuLKAXAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXFL<-bn`HMO75!5C?fB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oU(QK=bFzxf=lj x1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!8AmzzTTo5-k7# diff --git a/corpus/7ed9cd5e402079f0f97351888b486c9ac6550849 b/corpus/7ed9cd5e402079f0f97351888b486c9ac6550849 deleted file mode 100644 index 8e93fb8aea315427a56efbc16a86fbc9adcf76ad..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 131072 zcmeI*F%Ezr5Cp)p^(FRpe%a?)Xr)4Y01b(`Fxvv*c6vg@Qxa4Bm8Es@_RV(8bz_!&-$Vlf1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0Rp26tfLm!n*ad< z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV6Rc zfqRgiY7!tofB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0x1Rdlv5j;009C72oNAZfB*pk1PBlyK!5-N e0t5&UAV7cs0RjXF5FkK+009C72oNC9L*NCtHWw5C diff --git a/corpus/9caaba1fb571659ebf3c18d8e99154f1ac8e1a56 b/corpus/9caaba1fb571659ebf3c18d8e99154f1ac8e1a56 deleted file mode 100644 index 85351ce33c7d63cc122a7c5de93b38baf16adde4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 131072 zcmeIuF$%yS3fYjnTnKGDi(P@ez+0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7e?Qh_HTmM*l6009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009Cs0{2Wvh5!Kq1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7e?HUjT9pL&!40RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk L1PBlyaFDxK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U WAV7cs0RjXF5FkK+009C7QVBdywH6cr diff --git a/corpus/f7a7658aca261d9daabba67e3e3d3cd49e15a767 b/corpus/f7a7658aca261d9daabba67e3e3d3cd49e15a767 deleted file mode 100644 index c0ba5bfd50c121b6ccdddff87b55cca87e73cb66..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 131072 zcmeIuF$%yi2nEm_r+c^DxHrsHyJQbYAV{C9ewEkS;ks7$`y6;ygZcyr5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C7whD}h*t*a%0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5(T1nx{oh5!Kq1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7dX8G*OVryeChfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF H{7B#fXJ!U6 diff --git a/corpus/fd99672131d99a8989f997c6af548696b6dd4e7b b/corpus/fd99672131d99a8989f997c6af548696b6dd4e7b deleted file mode 100644 index 1149199a66f6b7e2743811196ada1eae5f9c155b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 131072 zcmeIuAr8PG3 None: - self._view: memoryview[bytearray] = memoryview(data) - self._cursor: int = 0 - - # Decision bytes (contiguous) - self._enable_extents = data[0] & 0x80 - self._enable_htree = data[1] & 0x80 - self._num_groups = (data[2] % 8) + 1 - self._log_block_size = data[3] % 4 - self._feature_incompat = int.from_bytes(data[4:8], "little") - self._extent_depth = data[5] % 2 - self._htree_complexity = data[6] % 2 - - # Content bytes for structures (contiguous after decisions) - self._extent_num_extents = (data[7] % 5) + 1 - self._extent_block = int.from_bytes(data[8:12], "little") & 0xFFFF - self._htree_num_entries = (data[12] % 8) + 1 - self._htree_hash_version = data[13] % 4 - - # Block group content (16 bytes, 2 groups worth) - self._bg_data = data[14:30] - - @override - def read(self, size: int | None = None) -> bytes: - result = self.peek(size) - self._cursor += len(result) - return result - - @override - def peek(self, size: int | None = None) -> bytes: # noqa: PLR0915 - offset = self._cursor - if size is None: - size = len(self._view) - offset - - end = offset + size - - sb_start = self.SUPERBLOCK_OFFSET - block_size = 1024 << self._log_block_size # 1K, 2K, 4K, or 8K - sb_end = sb_start + 256 # Superblock is 256 bytes - if offset < sb_end and end >= sb_start: - # Build superblock data from scratch - result = bytearray(256) - - # s_inodes_count at offset 0 - result[0x00:0x04] = b"\x20\x00\x00\x00" # 32 inodes - - # s_blocks_count_lo at offset 0x04 - result[0x04:0x08] = b"\x00\x80\x00\x00" # 128 blocks - - # s_free_blocks_count_lo at offset 0x0C - result[0x0C:0x10] = b"\x00\x80\x00\x00" - - # s_free_inodes_count at offset 0x10 - result[0x10:0x14] = b"\x20\x00\x00\x00" - - # s_first_data_block at offset 0x14 - result[0x14:0x18] = b"\x01\x00\x00\x00" - - # s_log_block_size at offset 0x18 - result[0x18:0x1C] = self._log_block_size.to_bytes(4, "little") - - # s_blocks_per_group at offset 0x20 - result[0x20:0x24] = b"\x08\x00\x00\x00" - - # s_inodes_per_group at offset 0x28 - result[0x28:0x2C] = b"\x10\x00\x00\x00" - - # s_magic at offset 0x38 - result[0x38:0x3A] = b"\x53\xef" # 0xEF53 - - # s_inode_size at offset 0x58 - result[0x58:0x5A] = b"\x80\x00" # 128 - - # s_desc_size at offset 0x60 - result[0x60:0x62] = b"\x20\x00" # 32 - - # s_feature_compat at offset 0x64 - result[0x64:0x68] = b"\x00\x00\x00\x00" - - # s_feature_incompat at offset 0x68 - result[0x68:0x6C] = self._feature_incompat.to_bytes(4, "little") - - # s_feature_ro_compat at offset 0x6C - result[0x6C:0x70] = b"\x00\x00\x00\x00" - - # Extract the requested portion - start = max(0, offset - sb_start) - result = result[start : end - sb_start] if end > sb_start else b"" - - # Add data before superblock if requested - if offset < sb_start: - before = self._view[offset : min(sb_start, end)].tobytes() - result = before + result - - # Add data after superblock if requested - if end > sb_end: - after = self._view[sb_end:end].tobytes() - result = bytes(result) + after - - return bytes(result) - - # Check if reading block group descriptor table - bgdt_start = block_size * 3 # Block 3 - bgdt_size = 32 # 32 bytes per block descriptor - bgdt_end = bgdt_start + (bgdt_size * self._num_groups) - - if offset < bgdt_end and end >= bgdt_start: - # Generate block descriptors based on data bytes - result = bytearray(bgdt_size * self._num_groups) - for i in range(self._num_groups): - base = i * bgdt_size - bb_offset = 2 + (i * 2) - ib_offset = 4 + (i * 2) - result[base + 0 : base + 2] = bytes( - [self._bg_data[bb_offset % 16], self._bg_data[(bb_offset + 1) % 16]] - ) - result[base + 4 : base + 6] = bytes( - [self._bg_data[ib_offset % 16], self._bg_data[(ib_offset + 1) % 16]] - ) - result[base + 8 : base + 12] = (2 + i * 4).to_bytes(4, "little") - - start = max(0, offset - bgdt_start) - end_pos = min(bgdt_end, end) - return bytes(result[start : end_pos - bgdt_start]) - - # Check if reading inode table (at block 2, offset 2048 for 1K blocks) - inode_table_start = block_size * 2 # Block 2 - inode_size = 128 - num_inodes = 32 - inode_table_end = inode_table_start + ( - num_inodes * inode_size - ) # 32 inodes * 128 bytes - - # Only serve generated inode table for reads in the exact inode table region - if offset >= inode_table_start and offset < inode_table_end: - # Create valid inode data for inodes 1-32 - result = bytearray(num_inodes * inode_size) - # Determine which inodes get special flags based on data bytes - extents_inodes: set[int] = {1, 10} if self._enable_extents else set() - htree_inodes: set[int] = {2, 9, 11} if self._enable_htree else set() - dir_inodes = {2, 9, 11} - for i in range(1, num_inodes + 1): - inode_offset = (i - 1) * inode_size - file_type = 0x40 if i in dir_inodes else 0x80 - if i in htree_inodes: - file_type = 0x40 # Directory - - result[inode_offset : inode_offset + 2] = file_type.to_bytes( - 2, "little" - ) - - # Set i_flags for EXTENTS and INDEX - flags = 0 - if i in extents_inodes: - flags |= 0x80000 # EXTENTS - if i in htree_inodes: - flags |= 0x1000 # INDEX - - if flags: - result[inode_offset + 0x14 : inode_offset + 0x18] = flags.to_bytes( - 4, "little" - ) - - # Generate extent tree in i_block[] for EXTENTS inodes - if i in extents_inodes: - extent_offset = inode_offset + 0x28 # i_block offset - - if self._extent_depth == 0: - # Depth 0: ExtentHeader + Extent entries - eh_magic = 0xF30A - result[extent_offset : extent_offset + 2] = eh_magic.to_bytes( - 2, "little" - ) - result[extent_offset + 2 : extent_offset + 4] = ( - self._extent_num_extents.to_bytes(2, "little") - ) - result[extent_offset + 4 : extent_offset + 6] = ( - self._extent_num_extents - ).to_bytes(2, "little") - result[extent_offset + 6 : extent_offset + 8] = ( - b"\x00\x00" # eh_depth = 0 - ) - result[extent_offset + 8 : extent_offset + 12] = ( - b"\x00\x00\x00\x00" - ) - - # Extent entries (ee_block, ee_len, ee_start_hi, ee_start_lo) - for j in range(self._extent_num_extents): - ee_offset = extent_offset + 12 + (j * 12) - block_num = (self._extent_block + j) & 0xFFFF - result[ee_offset : ee_offset + 4] = ( - j * block_size - ).to_bytes(4, "little") - result[ee_offset + 4 : ee_offset + 6] = ( - b"\x01\x00" # ee_len = 1 - ) - result[ee_offset + 6 : ee_offset + 8] = b"\x00\x00" - result[ee_offset + 8 : ee_offset + 12] = block_num.to_bytes( - 4, "little" - ) - else: - # Depth 1: ExtentHeader with index + ExtentIndex entries - result[extent_offset : extent_offset + 2] = ( - b"\x0a\xf3" # eh_magic - ) - result[extent_offset + 2 : extent_offset + 4] = ( - b"\x01\x00" # eh_entries = 1 - ) - result[extent_offset + 4 : extent_offset + 6] = ( - b"\x01\x00" # eh_max = 1 - ) - result[extent_offset + 6 : extent_offset + 8] = ( - b"\x01\x00" # eh_depth = 1 - ) - result[extent_offset + 8 : extent_offset + 12] = ( - b"\x00\x00\x00\x00" - ) - - # ExtentIndex entry pointing to leaf block - ei_offset = extent_offset + 12 - result[ei_offset : ei_offset + 4] = ( - b"\x00\x00\x00\x00" # ei_block = 0 - ) - leaf_block = (self._extent_block & 0xFF) + 10 - result[ei_offset + 4 : ei_offset + 8] = leaf_block.to_bytes( - 4, "little" - ) - result[ei_offset + 8 : ei_offset + 10] = b"\x00\x00" - result[ei_offset + 10 : ei_offset + 12] = b"\x00\x00" - - # For htree directories, set i_block[0] to point to htree data block - if i in htree_inodes: - htree_block = 20 + i - result[inode_offset + 0x28 : inode_offset + 0x2C] = ( - htree_block.to_bytes(4, "little") - ) - - # Extract the portion requested relative to inode table start - start = offset - inode_table_start - return bytes(result[start : start + size]) - - # Check for htree data blocks (block 20+ based on inode) - htree_start = block_size * 20 - htree_end = htree_start + (block_size * 4) - if offset < htree_end and end >= htree_start and self._enable_htree: - block_idx = (offset - htree_start) // block_size - if block_idx < 4: - result = bytearray(block_size) - if block_idx == 0: - result[0:4] = b"\x01\x00\x00\x00" # inode: 1 (.) - result[4:6] = b"\x10\x00" # rec_len: 16 - result[6:7] = b"\x01" # name_len: 1 - result[7:8] = bytes([0x02]) # file_type: DIR - result[8:12] = b".\x00\x00\x00" - result[12:16] = b"\x02\x00\x00\x00" # inode: 2 (..) - result[16:18] = b"\x10\x00" # rec_len: 16 - result[18:19] = b"\x02" # name_len: 2 - result[19:20] = bytes([0x02]) # file_type: DIR - result[20:24] = b"..\x00\x00" - result[24:28] = b"\x00\x00\x00\x00" # dx_root_info.reserved_zero - result[28:29] = bytes([self._htree_hash_version]) # hash_version - result[29:30] = b"\x08" # info_length: 8 - result[30:31] = bytes([self._htree_complexity]) # indirect_levels - result[31:32] = b"\x00" # unused_flags - result[32:34] = (12 + self._htree_num_entries * 8).to_bytes( - 2, "little" - ) # limit - result[34:36] = self._htree_num_entries.to_bytes( - 2, "little" - ) # count - result[36:40] = b"\x00\x00\x00\x00" # block - - dx_entry_offset = 40 - for j in range(self._htree_num_entries): - hash_val = (j * 0x1234567) & 0xFFFFFFFF - result[dx_entry_offset : dx_entry_offset + 4] = ( - hash_val.to_bytes(4, "little") - ) - result[dx_entry_offset + 4 : dx_entry_offset + 8] = ( - 20 + j - ).to_bytes(4, "little") - dx_entry_offset += 8 - - start = (offset - htree_start) % block_size - end_pos = min(block_size, start + size) - return bytes(result[start:end_pos]) - - # Check for extent leaf blocks (block 10+) - extent_leaf_start = block_size * 10 - extent_leaf_end = extent_leaf_start + (block_size * 4) - if ( - offset < extent_leaf_end - and end >= extent_leaf_start - and self._enable_extents - ): - block_idx = (offset - extent_leaf_start) // block_size - if block_idx < self._extent_num_extents: - result = bytearray(block_size) - result[0:4] = (block_idx * block_size).to_bytes(4, "little") - result[4:6] = b"\x01\x00" - result[6:8] = b"\x00\x00" - result[8:12] = ((self._extent_block + block_idx) & 0xFFFF).to_bytes( - 4, "little" - ) - start = (offset - extent_leaf_start) % block_size - end_pos = min(block_size, start + size) - return bytes(result[start:end_pos]) - - return self._view[offset:end].tobytes() - - @override - def seek(self, offset: int, mode: int | None = None) -> int: - if mode is None: - mode = os.SEEK_SET - - if mode == os.SEEK_SET: - self._cursor = offset - - elif mode == os.SEEK_CUR: - self._cursor += offset - - elif mode == os.SEEK_END: - self._cursor = len(self._view) + offset - - return self._cursor - - @override - def tell(self) -> int: - return self._cursor - - -def TestOneInput(data: bytes) -> None: # noqa: PLR0912 +def TestOneInput(data: bytes) -> None: if len(data) < MIN_DATA_SIZE: return - stream = FuzzableStream(data) - - vol = Volume( - stream, - ignore_checksum=True, - ignore_flags=True, - ignore_magic=True, - ignore_attr_name_index=True, - ) - - try: - if not isinstance(vol.inodes[EXT4_INO.ROOT], Directory): - return - - except InodeError: - return - - except OSError as e: - if e.errno == errno.EINVAL: - return - - if e.errno == errno.EIO and "Short read for" in str(e): - return - - raise - - _ = vol.superblock - for bd in vol.group_descriptors: - _ = bd.bg_block_bitmap + fdp = atheris.FuzzedDataProvider(data) + + img_size: int = fdp.ConsumeIntInRange(32, 64) + block_size: int = [1024, 2048, 4096][fdp.ConsumeIntInRange(0, 2)] + inode_size: int = [128, 256][fdp.ConsumeIntInRange(0, 1)] + num_dirs: int = fdp.ConsumeIntInRange(2, 20) + num_files: int = fdp.ConsumeIntInRange(5, 50) + num_symlinks: int = fdp.ConsumeIntInRange(0, 10) + num_hardlinks: int = fdp.ConsumeIntInRange(0, 5) + num_xattr_files: int = fdp.ConsumeIntInRange(0, 10) + max_file_size: int = fdp.ConsumeIntInRange(1, 64) + + rng_seed: int = fdp.ConsumeInt(1024) + rng = random.Random(rng_seed) # noqa: S311 + + FEATURES = [ + "extent", + "dir_index", + "flex_bg", + "sparse_super", + "64bit", + "metadata_csum", + "huge_file", + "orphan_file", + ] + features = [f for f in FEATURES if fdp.PickValueInList([True, False])] + + with tempfile.TemporaryDirectory(prefix="ext4_fuzz_") as tmpdir: + rootdir = os.path.join(tmpdir, "root") + os.mkdir(rootdir) + dirs = [rootdir] + for _ in range(num_dirs): + parent = rng.choice(dirs) + name = "".join( + rng.choice(string.ascii_letters + string.digits) + for _ in range(rng.randint(1, 32)) + ) + path = os.path.join(parent, name) + os.mkdir(path) + dirs.append(path) + + files = [] + for _ in range(num_files): + parent = rng.choice(dirs) + name = "".join( + rng.choice(string.ascii_letters + string.digits) + for _ in range(rng.randint(1, 64)) + ) + path = os.path.join(parent, name) + size = rng.randint(1, max_file_size * 1024) + with open(path, "wb") as f: + _ = f.write(rng.randbytes(size)) + + files.append(path) + + targets = files + dirs + for _ in range(num_symlinks): + target = rng.choice(targets) + parent = rng.choice(dirs) + name = "".join( + rng.choice(string.ascii_letters + string.digits) + for _ in range(rng.randint(1, 32)) + ) + os.symlink(target, os.path.join(parent, name)) + + for _ in range(num_hardlinks): + if files: + target = rng.choice(files) + parent = rng.choice(dirs) + name = "".join( + rng.choice(string.ascii_letters + string.digits) + for _ in range(rng.randint(1, 32)) + ) + os.link(target, os.path.join(parent, name)) + + for _ in range(num_xattr_files): + if files: + path = rng.choice(files) + for _ in range(rng.randint(1, 5)): + key = f"user.xattr_{rng.randint(1, 10)}" + value = rng.randbytes(rng.randint(8, 64)) + os.setxattr(path, key, value) + + img_path = os.path.join(tmpdir, "image.img") + cmd = [ + "mkfs.ext4", + "-d", + rootdir, + "-I", + str(inode_size), + "-O", + ",".join(features) if features else "^none", + "-b", + str(block_size), + img_path, + f"{img_size}M", + ] + result = subprocess.run(cmd, check=False, capture_output=True) # noqa: S607,S603 + if result.returncode != 0: + raise subprocess.CalledProcessError( + result.returncode, cmd, result.stdout, result.stderr + ) + + try: + with open(img_path, "rb") as f: + vol = Volume( + f, + ignore_checksum=True, + ignore_flags=True, + ignore_magic=True, + ignore_attr_name_index=True, + ) + _ = vol.superblock + for bd in vol.group_descriptors: + _ = bd.bg_block_bitmap - root = vol.root - htree = root.htree - if htree is not None: - for _ in htree.entries: - pass + root = vol.root + htree = root.htree + if htree is not None: + for _ in htree.entries: + pass - for dirent, _ in root.opendir(): - _ = dirent.name_bytes + for dirent, _ in root.opendir(): + _ = dirent.name_bytes - try: - for _ in vol.inodes: - pass + for inode in vol.inodes: + _ = inode.extents + _ = inode.i_size + if isinstance(inode, File): + _ = inode.open().read() - except InodeError: - return + elif isinstance(inode, SymbolicLink): + _ = inode.readlink() - for inode in [ - vol.inodes[1], # File - vol.inodes[2], # Directory (root) - vol.inodes[3], # SymbolicLink - vol.inodes[4], # Socket - vol.inodes[5], # BlockDevice - vol.inodes[6], # BlockDevice (bad blocks) - vol.inodes[7], # CharacterDevice - vol.inodes[8], # Fifo - vol.inodes[9], # Directory - vol.inodes[10], # File - vol.inodes[11], # Directory (journal) - ]: - _ = inode.extents - _ = inode.i_size - if isinstance(inode, File): - _ = inode.open() + elif isinstance(inode, Directory): + for _ in inode.opendir(): + pass - if isinstance(inode, SymbolicLink): - _ = inode.readlink() + while next(inode.xattrs, None) is not None: + pass - while next(vol.inodes[2].xattrs, None) is not None: - pass + _ = vol.bad_blocks + _ = vol.boot_loader + _ = vol.journal - _ = vol.bad_blocks - _ = vol.boot_loader - _ = vol.journal + finally: + if os.path.exists(img_path): + os.remove(img_path) -argv = [sys.argv[0], "corpus", "-timeout=10", *sys.argv[1:]] +argv = [sys.argv[0], "corpus", "-timeout=30", *sys.argv[1:]] print("argv: ", end="") print(argv) _ = atheris.Setup(argv, TestOneInput) From 9ff590a6a291913d4bd7db597fd99b87e67c02cf Mon Sep 17 00:00:00 2001 From: Nathaniel van Diepen Date: Wed, 1 Apr 2026 20:45:32 -0600 Subject: [PATCH 20/31] Don't error when trying to get an unknown inode type, return UnknownInode --- .../fc8acf6226b84351b3cf161925dc8f9832004d3d | Bin 0 -> 145 bytes ext4/inode.py | 112 ++++++++++-------- ext4/volume.py | 26 ++-- fuzz.py | 10 +- 4 files changed, 87 insertions(+), 61 deletions(-) create mode 100644 corpus/fc8acf6226b84351b3cf161925dc8f9832004d3d diff --git a/corpus/fc8acf6226b84351b3cf161925dc8f9832004d3d b/corpus/fc8acf6226b84351b3cf161925dc8f9832004d3d new file mode 100644 index 0000000000000000000000000000000000000000..382697815aee062db9aab439bc0a7d53868faa0d GIT binary patch literal 145 OcmZQz7)W4*X*mD{M*uSb literal 0 HcmV?d00001 diff --git a/ext4/inode.py b/ext4/inode.py index 8c3b2ed..5fcae1b 100644 --- a/ext4/inode.py +++ b/ext4/inode.py @@ -191,40 +191,73 @@ class Inode(Ext4Struct): ("i_projid", c_uint32), ] - def __new__(cls, volume: Volume, offset: int, i_no: int) -> Inode: - if cls is not Inode: - return super().__new__(cls) - + @classmethod + def get_file_type(cls, volume: Volume, offset: int) -> EXT4_FT: _ = volume.seek(offset + Inode.i_mode.offset) - file_type: MODE = cast( + file_type = cast( MODE, Inode.field_type("i_mode").from_buffer_copy( # pyright: ignore[reportAttributeAccessIssue, reportUnknownMemberType, reportOptionalMemberAccess] volume.read(Inode.i_mode.size) ) & 0xF000, ) - if file_type == MODE.IFIFO: - return super().__new__(Fifo) # pyright: ignore[reportArgumentType] + match file_type: + case MODE.IFIFO: + return EXT4_FT.FIFO # pyright: ignore[reportReturnType] + + case MODE.IFCHR: + return EXT4_FT.CHRDEV # pyright: ignore[reportReturnType] + + case MODE.IFDIR: + return EXT4_FT.DIR # pyright: ignore[reportReturnType] + + case MODE.IFBLK: + return EXT4_FT.BLKDEV # pyright: ignore[reportReturnType] + + case MODE.IFREG: + return EXT4_FT.REG_FILE # pyright: ignore[reportReturnType] + + case MODE.IFLNK: + return EXT4_FT.SYMLINK # pyright: ignore[reportReturnType] + + case MODE.IFSOCK: + return EXT4_FT.SOCK # pyright: ignore[reportReturnType] + + case _: + return EXT4_FT.UNKNOWN # pyright: ignore[reportReturnType] + + def __new__(cls, volume: Volume, offset: int, i_no: int) -> Inode | None: + if cls is not Inode: + return super().__new__(cls) + + file_type = cls.get_file_type(volume, offset) + match file_type: + case EXT4_FT.FIFO: + return super().__new__(Fifo) # pyright: ignore[reportArgumentType] + + case EXT4_FT.DIR: + return super().__new__(Directory) # pyright: ignore[reportArgumentType] - if file_type == MODE.IFDIR: - return super().__new__(Directory) # pyright: ignore[reportArgumentType] + case EXT4_FT.REG_FILE: + return super().__new__(File) # pyright: ignore[reportArgumentType] - if file_type == MODE.IFREG: - return super().__new__(File) # pyright: ignore[reportArgumentType] + case EXT4_FT.SYMLINK: + return super().__new__(SymbolicLink) # pyright: ignore[reportArgumentType] - if file_type == MODE.IFLNK: - return super().__new__(SymbolicLink) # pyright: ignore[reportArgumentType] + case EXT4_FT.CHRDEV: + return super().__new__(CharacterDevice) # pyright: ignore[reportArgumentType] - if file_type == MODE.IFCHR: - return super().__new__(CharacterDevice) # pyright: ignore[reportArgumentType] + case EXT4_FT.BLKDEV: + return super().__new__(BlockDevice) # pyright: ignore[reportArgumentType] - if file_type == MODE.IFBLK: - return super().__new__(BlockDevice) # pyright: ignore[reportArgumentType] + case EXT4_FT.SOCK: + return super().__new__(Socket) # pyright: ignore[reportArgumentType] - if file_type == MODE.IFSOCK: - return super().__new__(Socket) # pyright: ignore[reportArgumentType] + case EXT4_FT.UNKNOWN: + return super().__new__(UnknownInode) # pyright: ignore[reportArgumentType] - raise InodeError(f"Unknown file type 0x{file_type:X}") + case _: + raise InodeError(f"Unknown file type 0x{file_type:X}") def __init__(self, volume: Volume, offset: int, i_no: int) -> None: self.i_no: int = i_no @@ -420,6 +453,10 @@ def xattrs( raise +class UnknownInode(Inode): + pass + + class Fifo(Inode): pass @@ -540,38 +577,13 @@ def _opendir( def _get_file_type(self, dirent: DirectoryEntry | DirectoryEntry2) -> EXT4_FT: dirent_inode = assert_cast(dirent.inode, int) # pyright: ignore[reportAny] offset = self.volume.inodes.offset(dirent_inode) - _ = self.volume.seek(offset + Inode.i_mode.offset) - file_type = cast( - MODE, - Inode.field_type("i_mode").from_buffer_copy( # pyright: ignore[reportAttributeAccessIssue, reportUnknownMemberType, reportOptionalMemberAccess] - self.volume.read(Inode.i_mode.size) + file_type = self.get_file_type(self.volume, offset) + if EXT4_FT.UNKNOWN >= file_type >= EXT4_FT.MAX: + raise OpenDirectoryError( + f"Unexpected file type {file_type} for inode {dirent_inode}" ) - & 0xF000, - ) - if file_type == MODE.IFIFO: - return EXT4_FT.FIFO # pyright: ignore[reportReturnType] - if file_type == MODE.IFCHR: - return EXT4_FT.CHRDEV # pyright: ignore[reportReturnType] - - if file_type == MODE.IFDIR: - return EXT4_FT.DIR # pyright: ignore[reportReturnType] - - if file_type == MODE.IFBLK: - return EXT4_FT.BLKDEV # pyright: ignore[reportReturnType] - - if file_type == MODE.IFREG: - return EXT4_FT.REG_FILE # pyright: ignore[reportReturnType] - - if file_type == MODE.IFLNK: - return EXT4_FT.SYMLINK # pyright: ignore[reportReturnType] - - if file_type == MODE.IFSOCK: - return EXT4_FT.SOCK # pyright: ignore[reportReturnType] - - raise OpenDirectoryError( - f"Unexpected file type {file_type} for inode {dirent_inode}" - ) + return file_type def opendir( self, diff --git a/ext4/volume.py b/ext4/volume.py index 908958a..a038522 100644 --- a/ext4/volume.py +++ b/ext4/volume.py @@ -60,7 +60,7 @@ def offset(self, index: int) -> int: return table_offset + table_entry_index * s_inode_size @cachedmethod(lambda self: self._getitem_cache) # pyright: ignore[reportAny] - def __getitem__(self, index: int) -> Inode: + def __getitem__(self, index: int) -> Inode | None: offset = self.offset(index) return Inode(self.volume, offset, index) @@ -121,7 +121,9 @@ def __len__(self) -> int: @property def bad_blocks(self) -> Inode: - return self.inodes[EXT4_INO.BAD] + inode = self.inodes[EXT4_INO.BAD] + assert inode is not None + return inode @property def root(self) -> Directory: @@ -129,23 +131,33 @@ def root(self) -> Directory: @property def user_quota(self) -> Inode: - return self.inodes[EXT4_INO.USR_QUOTA] + inode = self.inodes[EXT4_INO.USR_QUOTA] + assert inode is not None + return inode @property def group_quota(self) -> Inode: - return self.inodes[EXT4_INO.GRP_QUOTA] + inode = self.inodes[EXT4_INO.GRP_QUOTA] + assert inode is not None + return inode @property def boot_loader(self) -> Inode: - return self.inodes[EXT4_INO.BOOT_LOADER] + inode = self.inodes[EXT4_INO.BOOT_LOADER] + assert inode is not None + return inode @property def undelete_directory(self) -> Inode: - return self.inodes[EXT4_INO.UNDEL_DIR] + inode = self.inodes[EXT4_INO.UNDEL_DIR] + assert inode is not None + return inode @property def journal(self) -> Inode: - return self.inodes[EXT4_INO.JOURNAL] + inode = self.inodes[EXT4_INO.JOURNAL] + assert inode is not None + return inode @property def has_hi(self) -> int: diff --git a/fuzz.py b/fuzz.py index a55a75e..79df109 100644 --- a/fuzz.py +++ b/fuzz.py @@ -11,9 +11,9 @@ warnings.filterwarnings("ignore") -MIN_DATA_SIZE = 4 +MIN_DATA_SIZE = 145 -seed_file = os.path.join("corpus", "seed", "seed0") +seed_file = os.path.join("corpus", "seed", "seed.bin") if not os.path.exists(seed_file) or os.path.getsize(seed_file) != MIN_DATA_SIZE: os.makedirs(os.path.dirname(seed_file), exist_ok=True) with open(seed_file, "wb") as f: @@ -64,7 +64,7 @@ def TestOneInput(data: bytes) -> None: with tempfile.TemporaryDirectory(prefix="ext4_fuzz_") as tmpdir: rootdir = os.path.join(tmpdir, "root") os.mkdir(rootdir) - dirs = [rootdir] + dirs: list[str] = [rootdir] for _ in range(num_dirs): parent = rng.choice(dirs) name = "".join( @@ -75,7 +75,7 @@ def TestOneInput(data: bytes) -> None: os.mkdir(path) dirs.append(path) - files = [] + files: list[str] = [] for _ in range(num_files): parent = rng.choice(dirs) name = "".join( @@ -160,6 +160,8 @@ def TestOneInput(data: bytes) -> None: _ = dirent.name_bytes for inode in vol.inodes: + if inode is None: + continue _ = inode.extents _ = inode.i_size if isinstance(inode, File): From f7f96a8626b1c359f53617b31bcc44e5a235aac8 Mon Sep 17 00:00:00 2001 From: Nathaniel van Diepen Date: Wed, 1 Apr 2026 21:07:24 -0600 Subject: [PATCH 21/31] Ensure special inodes exist --- ...=> 40948b874c97af8e1529f8cf56a5f1e26e048aa2} | Bin 145 -> 145 bytes test.py | 8 ++++++++ 2 files changed, 8 insertions(+) rename corpus/{fc8acf6226b84351b3cf161925dc8f9832004d3d => 40948b874c97af8e1529f8cf56a5f1e26e048aa2} (55%) diff --git a/corpus/fc8acf6226b84351b3cf161925dc8f9832004d3d b/corpus/40948b874c97af8e1529f8cf56a5f1e26e048aa2 similarity index 55% rename from corpus/fc8acf6226b84351b3cf161925dc8f9832004d3d rename to corpus/40948b874c97af8e1529f8cf56a5f1e26e048aa2 index 382697815aee062db9aab439bc0a7d53868faa0d..2fe6610c4302281bf4207fe4b638ef8e2ca7c869 100644 GIT binary patch literal 145 LcmZQ%7-Rqd0U`hb literal 145 OcmZQz7)W4*X*mD{M*uSb diff --git a/test.py b/test.py index 80a7fae..7f9011a 100644 --- a/test.py +++ b/test.py @@ -127,6 +127,10 @@ def test_root_inode(volume: ext4.Volume) -> None: traceback.print_exc() continue + _assert("volume.superblock is not None") + _assert("volume.bad_blocks is not None") + _assert("volume.boot_loader is not None") + _assert("volume.journal is not None") test_root_inode(volume) _assert('volume.root.inode_at("test.txt") == volume.inode_at("/test.txt")') _assert('volume.root.inode_at("/test.txt") == volume.inode_at("/test.txt")') @@ -170,6 +174,10 @@ def test_root_inode(volume: ext4.Volume) -> None: traceback.print_exc() if volume is not None: + _assert("volume.superblock is not None") + _assert("volume.bad_blocks is not None") + _assert("volume.boot_loader is not None") + _assert("volume.journal is not None") test_root_inode(volume) _assert("volume.root.is_htree == True") _assert("volume.root.htree is not None") From 62c246a4b1ccd637cf33d80ee0efeda549cb9a76 Mon Sep 17 00:00:00 2001 From: Nathaniel van Diepen Date: Wed, 1 Apr 2026 21:18:39 -0600 Subject: [PATCH 22/31] Mutate data to always be needed size --- ... 1aecead5d62816aad8f6b936e3c3364e0155fcd3} | Bin 145 -> 145 bytes .../efb6064ecb642140b2a63c1fa90f5a32591e3857 | Bin 0 -> 145 bytes fuzz.py | 31 +++++++++++------- 3 files changed, 19 insertions(+), 12 deletions(-) rename corpus/{40948b874c97af8e1529f8cf56a5f1e26e048aa2 => 1aecead5d62816aad8f6b936e3c3364e0155fcd3} (55%) create mode 100644 corpus/efb6064ecb642140b2a63c1fa90f5a32591e3857 diff --git a/corpus/40948b874c97af8e1529f8cf56a5f1e26e048aa2 b/corpus/1aecead5d62816aad8f6b936e3c3364e0155fcd3 similarity index 55% rename from corpus/40948b874c97af8e1529f8cf56a5f1e26e048aa2 rename to corpus/1aecead5d62816aad8f6b936e3c3364e0155fcd3 index 2fe6610c4302281bf4207fe4b638ef8e2ca7c869..01525cfb3d90d1a1b436a77a39384e6be8fc917b 100644 GIT binary patch literal 145 QcmZQzAO}n&M-y>+05lu`k^lez literal 145 LcmZQ%7-Rqd0U`hb diff --git a/corpus/efb6064ecb642140b2a63c1fa90f5a32591e3857 b/corpus/efb6064ecb642140b2a63c1fa90f5a32591e3857 new file mode 100644 index 0000000000000000000000000000000000000000..6a3fd83ccfbb0ba4465c33017831fbc4d935fb60 GIT binary patch literal 145 McmZQz7+k;}007VcKmY&$ literal 0 HcmV?d00001 diff --git a/fuzz.py b/fuzz.py index 79df109..b0fce20 100644 --- a/fuzz.py +++ b/fuzz.py @@ -1,4 +1,3 @@ -import errno import os import random import string @@ -11,29 +10,25 @@ warnings.filterwarnings("ignore") -MIN_DATA_SIZE = 145 +EXPECTED_DATA_SIZE = 145 + seed_file = os.path.join("corpus", "seed", "seed.bin") -if not os.path.exists(seed_file) or os.path.getsize(seed_file) != MIN_DATA_SIZE: +if not os.path.exists(seed_file) or os.stat(seed_file).st_size != EXPECTED_DATA_SIZE: os.makedirs(os.path.dirname(seed_file), exist_ok=True) with open(seed_file, "wb") as f: - _ = f.write(b"\x00" * MIN_DATA_SIZE) + _ = f.write(b"\x00" * EXPECTED_DATA_SIZE) with atheris.instrument_imports(): from ext4 import ( - EXT4_INO, Directory, File, - InodeError, SymbolicLink, Volume, ) def TestOneInput(data: bytes) -> None: - if len(data) < MIN_DATA_SIZE: - return - fdp = atheris.FuzzedDataProvider(data) img_size: int = fdp.ConsumeIntInRange(32, 64) @@ -45,7 +40,6 @@ def TestOneInput(data: bytes) -> None: num_hardlinks: int = fdp.ConsumeIntInRange(0, 5) num_xattr_files: int = fdp.ConsumeIntInRange(0, 10) max_file_size: int = fdp.ConsumeIntInRange(1, 64) - rng_seed: int = fdp.ConsumeInt(1024) rng = random.Random(rng_seed) # noqa: S311 @@ -186,8 +180,21 @@ def TestOneInput(data: bytes) -> None: os.remove(img_path) -argv = [sys.argv[0], "corpus", "-timeout=30", *sys.argv[1:]] +def custom_mutator(data: bytes, _max_size: int, _seed: int) -> bytes: + if len(data) >= EXPECTED_DATA_SIZE: + return data[:EXPECTED_DATA_SIZE] + + return data + b"\x00" * (EXPECTED_DATA_SIZE - len(data)) + + +argv = [ + sys.argv[0], + "corpus", + "-timeout=30", + f"-max_len={EXPECTED_DATA_SIZE}", + *sys.argv[1:], +] print("argv: ", end="") print(argv) -_ = atheris.Setup(argv, TestOneInput) +_ = atheris.Setup(argv, TestOneInput, custom_mutator=custom_mutator) atheris.Fuzz() From 60b20aebb8e776807a16ab4908041bae3f6178c8 Mon Sep 17 00:00:00 2001 From: Nathaniel van Diepen Date: Wed, 1 Apr 2026 21:32:06 -0600 Subject: [PATCH 23/31] Make lint happy --- fuzz.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/fuzz.py b/fuzz.py index b0fce20..135c33b 100644 --- a/fuzz.py +++ b/fuzz.py @@ -5,6 +5,11 @@ import sys import tempfile import warnings +from collections.abc import Callable +from typing import ( + Any, + cast, +) import atheris @@ -19,7 +24,7 @@ with open(seed_file, "wb") as f: _ = f.write(b"\x00" * EXPECTED_DATA_SIZE) -with atheris.instrument_imports(): +with atheris.instrument_imports(): # pyright: ignore[reportUnknownMemberType, reportAttributeAccessIssue] from ext4 import ( Directory, File, @@ -29,7 +34,11 @@ def TestOneInput(data: bytes) -> None: - fdp = atheris.FuzzedDataProvider(data) + fdp = atheris.FuzzedDataProvider(data) # pyright: ignore[reportUnknownMemberType, reportAttributeAccessIssue, reportUnknownVariableType] + + fdp.ConsumeIntInRange = cast(Callable[[int, int], int], fdp.ConsumeIntInRange) + fdp.ConsumeInt = cast(Callable[[int], int], fdp.ConsumeInt) + fdp.PickValueInList = cast(Callable[[list[Any]], int], fdp.PickValueInList) # pyright: ignore[reportExplicitAny] img_size: int = fdp.ConsumeIntInRange(32, 64) block_size: int = [1024, 2048, 4096][fdp.ConsumeIntInRange(0, 2)] From 1a3f80dcf0e793ab5736c0e81330ef8d333ff662 Mon Sep 17 00:00:00 2001 From: Nathaniel van Diepen Date: Wed, 1 Apr 2026 21:34:52 -0600 Subject: [PATCH 24/31] Undo API change --- ext4/inode.py | 2 +- ext4/volume.py | 26 +++++++------------------- 2 files changed, 8 insertions(+), 20 deletions(-) diff --git a/ext4/inode.py b/ext4/inode.py index 5fcae1b..aadf601 100644 --- a/ext4/inode.py +++ b/ext4/inode.py @@ -226,7 +226,7 @@ def get_file_type(cls, volume: Volume, offset: int) -> EXT4_FT: case _: return EXT4_FT.UNKNOWN # pyright: ignore[reportReturnType] - def __new__(cls, volume: Volume, offset: int, i_no: int) -> Inode | None: + def __new__(cls, volume: Volume, offset: int, i_no: int) -> Inode: if cls is not Inode: return super().__new__(cls) diff --git a/ext4/volume.py b/ext4/volume.py index a038522..908958a 100644 --- a/ext4/volume.py +++ b/ext4/volume.py @@ -60,7 +60,7 @@ def offset(self, index: int) -> int: return table_offset + table_entry_index * s_inode_size @cachedmethod(lambda self: self._getitem_cache) # pyright: ignore[reportAny] - def __getitem__(self, index: int) -> Inode | None: + def __getitem__(self, index: int) -> Inode: offset = self.offset(index) return Inode(self.volume, offset, index) @@ -121,9 +121,7 @@ def __len__(self) -> int: @property def bad_blocks(self) -> Inode: - inode = self.inodes[EXT4_INO.BAD] - assert inode is not None - return inode + return self.inodes[EXT4_INO.BAD] @property def root(self) -> Directory: @@ -131,33 +129,23 @@ def root(self) -> Directory: @property def user_quota(self) -> Inode: - inode = self.inodes[EXT4_INO.USR_QUOTA] - assert inode is not None - return inode + return self.inodes[EXT4_INO.USR_QUOTA] @property def group_quota(self) -> Inode: - inode = self.inodes[EXT4_INO.GRP_QUOTA] - assert inode is not None - return inode + return self.inodes[EXT4_INO.GRP_QUOTA] @property def boot_loader(self) -> Inode: - inode = self.inodes[EXT4_INO.BOOT_LOADER] - assert inode is not None - return inode + return self.inodes[EXT4_INO.BOOT_LOADER] @property def undelete_directory(self) -> Inode: - inode = self.inodes[EXT4_INO.UNDEL_DIR] - assert inode is not None - return inode + return self.inodes[EXT4_INO.UNDEL_DIR] @property def journal(self) -> Inode: - inode = self.inodes[EXT4_INO.JOURNAL] - assert inode is not None - return inode + return self.inodes[EXT4_INO.JOURNAL] @property def has_hi(self) -> int: From a06309cf8bb5ffdbf2bb64b29ff6a282d1304227 Mon Sep 17 00:00:00 2001 From: Nathaniel van Diepen Date: Wed, 1 Apr 2026 21:45:17 -0600 Subject: [PATCH 25/31] Unify file type checking --- ext4/inode.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/ext4/inode.py b/ext4/inode.py index aadf601..ab2d52a 100644 --- a/ext4/inode.py +++ b/ext4/inode.py @@ -574,11 +574,14 @@ def _opendir( self._dirents = dirents + def _is_valid_file_type(self, file_type: EXT4_FT) -> bool: + return file_type != EXT4_FT.UNKNOWN and file_type < EXT4_FT.MAX + def _get_file_type(self, dirent: DirectoryEntry | DirectoryEntry2) -> EXT4_FT: dirent_inode = assert_cast(dirent.inode, int) # pyright: ignore[reportAny] offset = self.volume.inodes.offset(dirent_inode) file_type = self.get_file_type(self.volume, offset) - if EXT4_FT.UNKNOWN >= file_type >= EXT4_FT.MAX: + if not self._is_valid_file_type(file_type): raise OpenDirectoryError( f"Unexpected file type {file_type} for inode {dirent_inode}" ) @@ -594,7 +597,7 @@ def opendir( if file_type == EXT4_FT.DIR_CSUM: continue - if file_type == EXT4_FT.UNKNOWN or file_type > EXT4_FT.MAX: + if not self._is_valid_file_type(file_type): raise OpenDirectoryError(f"Unexpected file type: {file_type}") else: From 4d2ef7841602b993aaa0956e383a97eff8481429 Mon Sep 17 00:00:00 2001 From: Nathaniel van Diepen Date: Wed, 1 Apr 2026 21:46:26 -0600 Subject: [PATCH 26/31] Omit -O if features is empty --- fuzz.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/fuzz.py b/fuzz.py index 135c33b..7d663ef 100644 --- a/fuzz.py +++ b/fuzz.py @@ -127,8 +127,7 @@ def TestOneInput(data: bytes) -> None: rootdir, "-I", str(inode_size), - "-O", - ",".join(features) if features else "^none", + *(["-O", ",".join(features)] if features else []), "-b", str(block_size), img_path, From f8fd3cf997b975238e10888c9c38fe9c54a33ff7 Mon Sep 17 00:00:00 2001 From: Nathaniel van Diepen Date: Wed, 1 Apr 2026 21:47:23 -0600 Subject: [PATCH 27/31] Lint fix --- fuzz.py | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/fuzz.py b/fuzz.py index 7d663ef..e0207f7 100644 --- a/fuzz.py +++ b/fuzz.py @@ -141,18 +141,18 @@ def TestOneInput(data: bytes) -> None: try: with open(img_path, "rb") as f: - vol = Volume( + volume = Volume( f, ignore_checksum=True, ignore_flags=True, ignore_magic=True, ignore_attr_name_index=True, ) - _ = vol.superblock - for bd in vol.group_descriptors: - _ = bd.bg_block_bitmap + _ = volume.superblock + for group_descriptor in volume.group_descriptors: + _ = group_descriptor.bg_block_bitmap - root = vol.root + root = volume.root htree = root.htree if htree is not None: for _ in htree.entries: @@ -161,9 +161,7 @@ def TestOneInput(data: bytes) -> None: for dirent, _ in root.opendir(): _ = dirent.name_bytes - for inode in vol.inodes: - if inode is None: - continue + for inode in volume.inodes: _ = inode.extents _ = inode.i_size if isinstance(inode, File): @@ -179,9 +177,9 @@ def TestOneInput(data: bytes) -> None: while next(inode.xattrs, None) is not None: pass - _ = vol.bad_blocks - _ = vol.boot_loader - _ = vol.journal + _ = volume.bad_blocks + _ = volume.boot_loader + _ = volume.journal finally: if os.path.exists(img_path): From 134e63f7cd70242d64341f686c62539054de9059 Mon Sep 17 00:00:00 2001 From: Nathaniel van Diepen Date: Wed, 1 Apr 2026 21:50:08 -0600 Subject: [PATCH 28/31] Fix type checking --- fuzz.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/fuzz.py b/fuzz.py index e0207f7..37293f5 100644 --- a/fuzz.py +++ b/fuzz.py @@ -7,6 +7,7 @@ import warnings from collections.abc import Callable from typing import ( + TYPE_CHECKING, Any, cast, ) @@ -36,9 +37,10 @@ def TestOneInput(data: bytes) -> None: fdp = atheris.FuzzedDataProvider(data) # pyright: ignore[reportUnknownMemberType, reportAttributeAccessIssue, reportUnknownVariableType] - fdp.ConsumeIntInRange = cast(Callable[[int, int], int], fdp.ConsumeIntInRange) - fdp.ConsumeInt = cast(Callable[[int], int], fdp.ConsumeInt) - fdp.PickValueInList = cast(Callable[[list[Any]], int], fdp.PickValueInList) # pyright: ignore[reportExplicitAny] + if TYPE_CHECKING: + fdp.ConsumeIntInRange = cast(Callable[[int, int], int], fdp.ConsumeIntInRange) + fdp.ConsumeInt = cast(Callable[[int], int], fdp.ConsumeInt) + fdp.PickValueInList = cast(Callable[[list[Any]], int], fdp.PickValueInList) # pyright: ignore[reportExplicitAny] img_size: int = fdp.ConsumeIntInRange(32, 64) block_size: int = [1024, 2048, 4096][fdp.ConsumeIntInRange(0, 2)] From d016ef35e371d2ca0f5f072df2163ef1bc6150d1 Mon Sep 17 00:00:00 2001 From: Nathaniel van Diepen Date: Wed, 1 Apr 2026 22:07:28 -0600 Subject: [PATCH 29/31] Fix fuzzer infinite loop --- fuzz.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fuzz.py b/fuzz.py index 37293f5..aa7cf63 100644 --- a/fuzz.py +++ b/fuzz.py @@ -176,7 +176,7 @@ def TestOneInput(data: bytes) -> None: for _ in inode.opendir(): pass - while next(inode.xattrs, None) is not None: + for _ in inode.xattrs: pass _ = volume.bad_blocks From 06730f5adb54cc2a9575d4c3082dcbb97de7befd Mon Sep 17 00:00:00 2001 From: Nathaniel van Diepen Date: Wed, 1 Apr 2026 22:18:39 -0600 Subject: [PATCH 30/31] Add more fuzz coverage --- fuzz.py | 39 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/fuzz.py b/fuzz.py index aa7cf63..33cbd10 100644 --- a/fuzz.py +++ b/fuzz.py @@ -27,7 +27,9 @@ with atheris.instrument_imports(): # pyright: ignore[reportUnknownMemberType, reportAttributeAccessIssue] from ext4 import ( + ChecksumError, Directory, + ExtendedAttributeError, File, SymbolicLink, Volume, @@ -164,8 +166,15 @@ def TestOneInput(data: bytes) -> None: _ = dirent.name_bytes for inode in volume.inodes: - _ = inode.extents + try: + inode.validate() + + except ChecksumError: + pass + + _ = inode.extra_inode_data _ = inode.i_size + _ = inode.i_file_acl if isinstance(inode, File): _ = inode.open().read() @@ -176,12 +185,38 @@ def TestOneInput(data: bytes) -> None: for _ in inode.opendir(): pass - for _ in inode.xattrs: + _ = inode.has_filetype + _ = inode.is_htree + _ = inode.is_casefolded + _ = inode.is_encrypted + _ = inode.hash_in_dirent + _ = inode.inode_at("/") + try: + _ = inode.inode_at("/empty") + + except FileNotFoundError: + pass + + for _, _ in inode.xattrs: pass + for extent in inode.extents: + _ = extent.is_initialized + _ = extent.len + _ = extent.read() + + for index in inode.indices: + _ = index.ei_leaf + _ = volume.bad_blocks _ = volume.boot_loader _ = volume.journal + _ = volume.inode_at("/") + try: + _ = volume.inode_at("/empty") + + except FileNotFoundError: + pass finally: if os.path.exists(img_path): From fc815517249dbc5a5b73cf4db514d778509578f4 Mon Sep 17 00:00:00 2001 From: Nathaniel van Diepen Date: Wed, 1 Apr 2026 22:20:33 -0600 Subject: [PATCH 31/31] Fix lint --- fuzz.py | 1 - 1 file changed, 1 deletion(-) diff --git a/fuzz.py b/fuzz.py index 33cbd10..dd61da3 100644 --- a/fuzz.py +++ b/fuzz.py @@ -29,7 +29,6 @@ from ext4 import ( ChecksumError, Directory, - ExtendedAttributeError, File, SymbolicLink, Volume,