diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..82f9275 --- /dev/null +++ b/.gitignore @@ -0,0 +1,162 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ diff --git a/igelfs/__init__.py b/igelfs/__init__.py new file mode 100644 index 0000000..fbd9080 --- /dev/null +++ b/igelfs/__init__.py @@ -0,0 +1,36 @@ +""" +Python interface for the IGEL filesystem. + +For a standard IGEL OS disk image, the layout is similar to the below: +- Partition 1 + - IGEL FS +- Partition 2 + - FAT32, ESP #1 +- Partition 3 + - FAT32, ESP #2 + +IGEL FS has the following layout: +- Section #0 + - Boot Registry + - Boot Registry Entries + - Directory + - Partition Descriptors + - Fragment Descriptors +- Section #1... + - Section Header + - Partition + - Partition Header + - Partition Payload +""" + +from igelfs.filesystem import Filesystem, Section +from igelfs.models import BootRegistryHeader, Directory, PartitionHeader, SectionHeader + +__all__ = [ + "BootRegistryHeader", + "Directory", + "Filesystem", + "PartitionHeader", + "Section", + "SectionHeader", +] diff --git a/igelfs/base.py b/igelfs/base.py new file mode 100644 index 0000000..42d2cc3 --- /dev/null +++ b/igelfs/base.py @@ -0,0 +1,98 @@ +"""Abstract base classes for various data models.""" + +import io +from abc import ABC +from dataclasses import dataclass, fields +from typing import ClassVar, get_args, get_origin + + +@dataclass +class BaseDataModel(ABC): + """Abstract base class for data model.""" + + MODEL_ATTRIBUTE_SIZES: ClassVar[dict[str, int]] + + def __len__(self) -> int: + """Implement __len__ data model method.""" + return self.size + + def to_bytes(self) -> bytes: + """Return bytes of all data.""" + with io.BytesIO() as fd: + for attribute, size in self.MODEL_ATTRIBUTE_SIZES.items(): + data = getattr(self, attribute) + match data: + case bytes(): + fd.write(data) + case int(): + fd.write(data.to_bytes(size)) + case str(): + fd.write(data.encode()) + case BaseDataModel() | DataModelCollection(): + fd.write(data.to_bytes()) + fd.seek(0) + return fd.read() + + @property + def size(self) -> int: + """Return actual size of all data.""" + return len(self.to_bytes()) + + @classmethod + def get_model_size(cls) -> int: + """Return expected total size of data for model.""" + return sum(cls.MODEL_ATTRIBUTE_SIZES.values()) + + def verify(self) -> bool: + """Verify data model integrity.""" + return self.size == self.get_model_size() + + @classmethod + def from_bytes_to_dict(cls, data: bytes) -> dict[str, bytes]: + """Return dictionary from bytes.""" + model = {} + with io.BytesIO(data) as fd: + for attribute, size in cls.MODEL_ATTRIBUTE_SIZES.items(): + model[attribute] = fd.read(size) + return model + + @classmethod + def from_bytes(cls, data: bytes) -> "BaseDataModel": + """Return data model instance from bytes.""" + model = cls.from_bytes_to_dict(data) + for field in fields(cls): + if get_origin(field.type) == DataModelCollection: + inner = get_args(field.type)[0] + model[field.name] = DataModelCollection( + inner.from_bytes(chunk) + for chunk in [ + model[field.name][i : i + inner.get_model_size()] + for i in range( + 0, len(model[field.name]), inner.get_model_size() + ) + ] + ) + elif field.type == str: + model[field.name] = model[field.name].decode() + elif field.type == int: + model[field.name] = int.from_bytes(model[field.name]) + else: + model[field.name] = field.type(model[field.name]) + return cls(**model) + + +class DataModelCollection(list): + """List subclass to provide additional helper methods.""" + + def to_bytes(self) -> bytes: + """Return bytes of all models.""" + with io.BytesIO() as fd: + for model in self: + fd.write(model.to_bytes()) + fd.seek(0) + return fd.read() + + @property + def size(self) -> int: + """Return actual size of all data.""" + return len(self.to_bytes()) diff --git a/igelfs/constants.py b/igelfs/constants.py new file mode 100644 index 0000000..dece065 --- /dev/null +++ b/igelfs/constants.py @@ -0,0 +1,108 @@ +"""Python implementation of igelsdk.h sourced from igel-flash-driver.""" + +import math +from enum import IntEnum, IntFlag + + +class SectionSize(IntEnum): + """Enumeration for section sizes.""" + + SECT_SIZE_64K = 0 + SECT_SIZE_128K = 1 + SECT_SIZE_256K = 2 + SECT_SIZE_512K = 3 + SECT_SIZE_1M = 4 + SECT_SIZE_2M = 5 + SECT_SIZE_4M = 6 + SECT_SIZE_8M = 7 + SECT_SIZE_16M = 8 + + @classmethod + def get(cls, size: int) -> "SectionSize": + """Get SectionSize for specified size.""" + section_size = int(math.log2(size / 65536)) + return cls(section_size) + + +class PartitionType(IntEnum): + """Enumeration for partition types.""" + + EMPTY = 0 # partition descriptor is free + IGEL_RAW = 1 # an uncompressed an writable partition + IGEL_COMPRESSED = 2 # a compressed read-only partition + IGEL_FREELIST = 3 # only used by the partition directory + IGEL_RAW_RO = ( + 4 # an uncompressed read-only partition (so CRC is valid and should be checked) + ) + IGEL_RAW_4K_ALIGNED = ( + 5 # an uncompressed an writable partition which is aligned to 4k sectors + ) + + +class PartitionFlag(IntFlag): + """Enumeration for partition flags.""" + + UPDATE_IN_PROGRESS = 0x100 # flag indicating a not yet to use partition + HAS_IGEL_HASH = ( + 0x200 # flag indicating the presence of a igel hash block after the header + ) + HAS_CRYPT = 0x400 # flag indicating the presence of a encryption + + +class ExtentType(IntEnum): + """Enumeration for extent types.""" + + KERNEL = 1 + RAMDISK = 2 + SPLASH = 3 + CHECKSUMS = 4 + SQUASHFS = 5 + WRITEABLE = 6 + LOGIN = 7 + + +LOG2_SECT_SIZE = SectionSize.SECT_SIZE_256K +IGF_SECTION_SIZE = 0x10000 << (LOG2_SECT_SIZE & 0xF) +IGF_SECTION_SHIFT = 16 + (LOG2_SECT_SIZE & 0xF) + + +def get_start_of_section(n: int) -> int: + """Return start of section.""" + return n << IGF_SECTION_SHIFT + + +def get_section_of(x: int) -> int: + """Return section for specified number.""" + return x >> IGF_SECTION_SHIFT + + +def get_offset_of(x: int) -> int: + """Return offset for specified number.""" + return x & (IGF_SECTION_SIZE - 1) + + +IGF_SECT_HDR_LEN = 32 +IGF_SECT_DATA_LEN = IGF_SECTION_SIZE - IGF_SECT_HDR_LEN + +IGF_MAX_MINORS = 256 + +DIRECTORY_MAGIC = 0x52494450 # PDIR +CRC_DUMMY = 0x55555555 + +IGEL_BOOTREG_OFFSET = 0x00000000 +IGEL_BOOTREG_SIZE = 0x00008000 # 32K size +IGEL_BOOTREG_MAGIC = 0x4F4F42204C454749 # IGEL BOO + +DIR_OFFSET = IGEL_BOOTREG_OFFSET + IGEL_BOOTREG_SIZE # Starts after the boot registry +DIR_SIZE = ( + IGF_SECTION_SIZE - DIR_OFFSET +) # Reserve the rest of section #0 for the directory + +DIR_MAX_MINORS = 512 +MAX_FRAGMENTS = 1404 + +BOOTSPLASH_MAGIC = "IGELBootSplash" + +BOOTREG_MAGIC = "163L" +BOOTREG_IDENT = "IGEL BOOTREGISTRY" +BOOTREG_FLAG_LOCK = 0x0001 diff --git a/igelfs/filesystem.py b/igelfs/filesystem.py new file mode 100644 index 0000000..c54be4e --- /dev/null +++ b/igelfs/filesystem.py @@ -0,0 +1,106 @@ +"""Python implementation to handle IGEL filesystems.""" + +from dataclasses import dataclass +from pathlib import Path + +from igelfs.constants import ( + DIR_OFFSET, + DIR_SIZE, + IGEL_BOOTREG_OFFSET, + IGEL_BOOTREG_SIZE, + IGF_SECT_DATA_LEN, + IGF_SECT_HDR_LEN, + IGF_SECTION_SIZE, + SectionSize, + get_section_of, + get_start_of_section, +) +from igelfs.models import BootRegistryHeader, Directory, SectionHeader + + +@dataclass +class Section: + """Dataclass to handle section of an image.""" + + path: str | Path + offset: int + data: bytes + index: int | None = None + + def __post_init__(self) -> None: + """Handle post-initialisation of dataclass instance.""" + if self.index is None: + self.index = get_section_of(self.offset) + + @property + def size(self) -> int: + """Return size of data.""" + return len(self.data) + + @property + def header(self) -> bytes: + """Return header of data.""" + return SectionHeader.from_bytes(self.data[:IGF_SECT_HDR_LEN]) + + @property + def payload(self) -> bytes: + """Return header of data.""" + return self.data[IGF_SECT_HDR_LEN:IGF_SECT_DATA_LEN] + + def write(self, path: str | Path) -> Path: + """Write data of section to specified path and return Path.""" + path = Path(path).absolute() + with open(path, "wb") as fd: + fd.write(self.data) + return path + + +class Filesystem: + """IGEL filesystem class to handle properties and methods.""" + + def __init__(self, path: str | Path) -> None: + """Initialise instance.""" + self.path = Path(path).absolute() + + def __getitem__(self, index: int) -> Section: + """Implement getitem method.""" + return self.get_section_by_index(index) + + @property + def size(self) -> int: + """Return size of image.""" + return self.path.stat().st_size + + @property + def section_size(self) -> SectionSize: + """Return SectionSize for image.""" + return SectionSize.get(self.size) + + @property + def bootreg(self) -> BootRegistryHeader: + """Return bootreg Section for image.""" + data = self.get_data(IGEL_BOOTREG_OFFSET, IGEL_BOOTREG_SIZE) + return BootRegistryHeader.from_bytes(data) + + @property + def directory(self) -> Directory: + """Return directory Section for image.""" + data = self.get_data(DIR_OFFSET, DIR_SIZE) + return Directory.from_bytes(data) + + def get_data(self, offset: int = 0, size: int = -1): + """Return data for specified offset and size.""" + with open(self.path, "rb") as fd: + fd.seek(offset) + return fd.read(size) + + def get_section_by_offset(self, offset: int, size: int) -> Section: + """Return section of image by offset and size.""" + data = self.get_data(offset, size) + return Section(path=self.path, offset=offset, data=data) + + def get_section_by_index(self, index: int) -> Section: + """Return section of image by index.""" + offset = get_start_of_section(index) + data = self.get_data(offset, IGF_SECTION_SIZE) + return Section(path=self.path, index=index, offset=offset, data=data) diff --git a/igelfs/models.py b/igelfs/models.py new file mode 100644 index 0000000..8ef51be --- /dev/null +++ b/igelfs/models.py @@ -0,0 +1,190 @@ +"""Data models for the IGEL filesystem.""" + +from dataclasses import dataclass +from typing import ClassVar + +from igelfs.base import BaseDataModel, DataModelCollection +from igelfs.constants import DIR_MAX_MINORS, MAX_FRAGMENTS + + +@dataclass +class BootRegistryEntry(BaseDataModel): + """Dataclass to describe each entry of boot registry.""" + + MODEL_ATTRIBUTE_SIZES: ClassVar[dict[str, int]] = {"flag": 2, "data": 62} + + flag: int # first 9 bits next, 1 bit next present, 6 bit len key + data: bytes + + +@dataclass +class BootRegistryHeader(BaseDataModel): + """Dataclass to handle boot registry header data.""" + + MODEL_ATTRIBUTE_SIZES: ClassVar[dict[str, int]] = { + "ident_legacy": 17, + "magic": 4, + "hdr_version": 1, + "boot_id": 21, + "enc_alg": 1, + "flags": 2, + "empty": 82, + "free": 64, + "used": 64, + "dir": 252, + "reserve": 4, + "entry": 504 * BootRegistryEntry.get_model_size(), + } + + ident_legacy: str # "IGEL BOOTREGISTRY" + magic: str # BOOTREG_MAGIC + hdr_version: int # 0x01 for the first + boot_id: str # boot_id + enc_alg: int # encryption algorithm + flags: int # flags + empty: bytes # placeholder + free: bytes # bitmap with free 64 byte blocks + used: bytes # bitmap with used 64 byte blocks + dir: bytes # directory bitmap (4 bits for each block -> key len) + reserve: bytes # placeholder + entry: DataModelCollection[BootRegistryEntry] # real data + + +@dataclass +class SectionHeader(BaseDataModel): + """Dataclass to handle section header data.""" + + MODEL_ATTRIBUTE_SIZES: ClassVar[dict[str, int]] = { + "crc": 4, + "magic": 4, + "section_type": 2, + "section_size": 2, + "partition_minor": 4, + "generation": 2, + "section_in_minor": 4, + "next_section": 4, + } + + crc: int # crc of the rest of the section */ + magic: int # magic number (erase count long ago) + section_type: int + section_size: int # log2((section size in bytes) / 65536) + partition_minor: int # partition number (driver minor number) + generation: int # update generation count + section_in_minor: int # n = 0,...,(number of sect.-1) + next_section: int # index of the next section or 0xffffffff = end of chain + + +@dataclass +class PartitionHeader(BaseDataModel): + """Dataclass to handle partition header data.""" + + MODEL_ATTRIBUTE_SIZES: ClassVar[dict[str, int]] = { + "type": 2, + "hdrlen": 2, + "partlen": 8, + "n_blocks": 8, + "offset_blocktable": 8, + "offset_blocks": 8, + "n_clusters": 4, + "cluster_shift": 2, + "n_extents": 2, + "name": 16, + "update_hash": 64, + } + + type: int # partition type + hdrlen: int # length of the complete partition header + partlen: int # length of this partition (incl. header) + n_blocks: int # number of uncompressed 1k blocks + offset_blocktable: int # needed for compressed partitions + offset_blocks: int # start of the compressed block clusters + n_clusters: int # number of clusters + cluster_shift: int # 2^x blocks make up a cluster + n_extents: int # number of extents, if any + name: bytes # optional character code (for pdir) + # A high level hash over almost all files, used to determine if an update is needed + update_hash: bytes + + +@dataclass +class FragmentDescriptor(BaseDataModel): + """Dataclass to handle fragment descriptors.""" + + MODEL_ATTRIBUTE_SIZES: ClassVar[dict[str, int]] = {"first_section": 4, "length": 4} + + first_section: int + length: int # number of sections + + +@dataclass +class PartitionDescriptor(BaseDataModel): + """Dataclass to handle partition descriptors.""" + + MODEL_ATTRIBUTE_SIZES: ClassVar[dict[str, int]] = { + "minor": 4, + "type": 2, + "first_fragment": 2, + "n_fragments": 2, + } + + minor: int # a replication of igf_sect_hdr.partition + type: int # partition type, a replication of igf_part_hdr.type + first_fragment: int # index of the first fragment + n_fragments: int # number of additional fragments + + +@dataclass +class Directory(BaseDataModel): + """Dataclass to handle directory header data.""" + + MODEL_ATTRIBUTE_SIZES: ClassVar[dict[str, int]] = { + "magic": 4, + "crc": 4, + "dir_type": 2, + "max_minors": 2, + "version": 2, + "dummy": 2, + "n_fragments": 4, + "max_fragments": 4, + "extension": 8, + "partition": DIR_MAX_MINORS * PartitionDescriptor.get_model_size(), + "fragment": MAX_FRAGMENTS * FragmentDescriptor.get_model_size(), + } + + magic: str # DIRECTORY_MAGIC + crc: int + dir_type: int # allows for future extensions + max_minors: int # redundant, allows for dynamic part table + version: int # update count, never used so far + dummy: int # for future extensions + n_fragments: int # total number of fragments + max_fragments: int # redundant, allows for dynamic frag table + extension: bytes # unspecified, for future extensions + partition: DataModelCollection[PartitionDescriptor] + fragment: DataModelCollection[FragmentDescriptor] + + +@dataclass +class BootsplashHeader(BaseDataModel): + """Dataclass to handle bootsplash header data.""" + + MODEL_ATTRIBUTE_SIZES: ClassVar[dict[str, int]] = {"magic": 14, "num_splashs": 1} + + magic: str # BOOTSPLASH_MAGIC + num_splashs: int + + +@dataclass +class Bootsplash(BaseDataModel): + """Dataclass to handle bootsplash data.""" + + MODEL_ATTRIBUTE_SIZES: ClassVar[dict[str, int]] = { + "offset": 8, + "length": 8, + "ident": 8, + } + + offset: int + length: int + ident: bytes diff --git a/igelfs/tests/conftest.py b/igelfs/tests/conftest.py new file mode 100644 index 0000000..16bb573 --- /dev/null +++ b/igelfs/tests/conftest.py @@ -0,0 +1,23 @@ +"""Testing configuration.""" + +import pytest + +from igelfs.filesystem import Filesystem +from igelfs.models import BootRegistryHeader + + +def pytest_addoption(parser): + """Parse command-line arguments.""" + parser.addoption("--path", action="store", default="default image path") + + +@pytest.fixture(scope="session") +def fs(pytestconfig) -> Filesystem: + """Return Filesystem instance for image.""" + return Filesystem(pytestconfig.getoption("path")) + + +@pytest.fixture(scope="session") +def bootreg(fs: Filesystem) -> BootRegistryHeader: + """Return BootRegistryHeader instance.""" + return fs.bootreg diff --git a/igelfs/tests/test_bootreg.py b/igelfs/tests/test_bootreg.py new file mode 100644 index 0000000..b858ffe --- /dev/null +++ b/igelfs/tests/test_bootreg.py @@ -0,0 +1,23 @@ +"""Unit tests for the boot registry.""" + +from igelfs.constants import BOOTREG_IDENT, BOOTREG_MAGIC, IGEL_BOOTREG_SIZE + + +def test_bootreg_size(bootreg) -> None: + """Test size of boot registry.""" + assert bootreg.size == IGEL_BOOTREG_SIZE + + +def test_bootreg_verify(bootreg) -> None: + """Test verification of boot registry.""" + assert bootreg.verify() + + +def test_bootreg_ident_legacy(bootreg) -> None: + """Test ident_legacy attribute of boot registry.""" + assert bootreg.ident_legacy == BOOTREG_IDENT + + +def test_bootreg_magic(bootreg) -> None: + """Test magic attribute of boot registry.""" + assert bootreg.magic == BOOTREG_MAGIC