diff --git a/goosebit/api/v1/software/routes.py b/goosebit/api/v1/software/routes.py index 10dd3462..5fa9a36a 100644 --- a/goosebit/api/v1/software/routes.py +++ b/goosebit/api/v1/software/routes.py @@ -70,7 +70,7 @@ async def post_update(_: Request, file: UploadFile | None = File(None), url: str artifacts_dir = Path(config.artifacts_dir) file_path = artifacts_dir.joinpath(file.filename) - async with aiofiles.tempfile.NamedTemporaryFile("w+b") as f: + async with aiofiles.tempfile.NamedTemporaryFile("w+b", delete_on_close=True) as f: await f.write(await file.read()) absolute = await file_path.absolute() software = await create_software_update(absolute.as_uri(), Path(f.name)) diff --git a/goosebit/db/migrations/models/1_20240911081506_add_image_format.py b/goosebit/db/migrations/models/1_20240911081506_add_image_format.py new file mode 100644 index 00000000..731e341c --- /dev/null +++ b/goosebit/db/migrations/models/1_20240911081506_add_image_format.py @@ -0,0 +1,11 @@ +from tortoise import BaseDBAsyncClient + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "software" ADD "image_format" SMALLINT NOT NULL DEFAULT 0 /* SWU: 0\nRAUC: 1 */;""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "software" DROP COLUMN "image_format";""" diff --git a/goosebit/db/models.py b/goosebit/db/models.py index 979f800a..8d9d363e 100644 --- a/goosebit/db/models.py +++ b/goosebit/db/models.py @@ -108,12 +108,28 @@ class Hardware(Model): revision = fields.CharField(max_length=255) +class SoftwareImageFormat(IntEnum): + SWU = 0 + RAUC = 1 + + def __str__(self): + return self.name.upper() + + @classmethod + def from_str(cls, name): + try: + return cls[name.upper()] + except KeyError: + return cls.SWU + + class Software(Model): id = fields.IntField(primary_key=True) uri = fields.CharField(max_length=255) size = fields.BigIntField() hash = fields.CharField(max_length=255) version = fields.CharField(max_length=255) + image_format = fields.IntEnumField(SoftwareImageFormat, default=SoftwareImageFormat.SWU) compatibility = fields.ManyToManyField( "models.Hardware", related_name="softwares", diff --git a/goosebit/ui/templates/software.html.jinja b/goosebit/ui/templates/software.html.jinja index e664527f..9b813a05 100644 --- a/goosebit/ui/templates/software.html.jinja +++ b/goosebit/ui/templates/software.html.jinja @@ -53,7 +53,7 @@
diff --git a/goosebit/updates/__init__.py b/goosebit/updates/__init__.py index 35c35dbf..ab2c6626 100644 --- a/goosebit/updates/__init__.py +++ b/goosebit/updates/__init__.py @@ -3,7 +3,7 @@ from urllib.parse import unquote, urlparse from urllib.request import url2pathname -from anyio import Path +from anyio import Path, open_file from fastapi import HTTPException from fastapi.requests import Request from tortoise.expressions import Q @@ -47,7 +47,16 @@ async def create_software_update(uri: str, temp_file: Path | None) -> Software: filename = Path(url2pathname(unquote(parsed_uri.path))).name path = Path(config.artifacts_dir).joinpath(update_info["hash"], filename) await path.parent.mkdir(parents=True, exist_ok=True) - await temp_file.rename(path) + + # basically os.rename, but sync and async versions of that break across filesystems + async with await open_file(temp_file, "rb") as temp_file: + async with await open_file(path, "wb") as file: + while True: + chunk = await temp_file.read(1024 * 1024) + if not chunk: + break + await file.write(chunk) + absolute = await path.absolute() uri = absolute.as_uri() @@ -57,6 +66,7 @@ async def create_software_update(uri: str, temp_file: Path | None) -> Software: version=str(update_info["version"]), size=update_info["size"], hash=update_info["hash"], + image_format=update_info["image_format"], ) # create compatibility information diff --git a/goosebit/updates/swdesc/__init__.py b/goosebit/updates/swdesc/__init__.py new file mode 100644 index 00000000..ce2b1af5 --- /dev/null +++ b/goosebit/updates/swdesc/__init__.py @@ -0,0 +1,34 @@ +import logging + +import aiofiles +import httpx +from anyio import Path, open_file + +from ...db.models import SoftwareImageFormat +from . import rauc, swu + +logger = logging.getLogger(__name__) + + +async def parse_remote(url: str): + async with httpx.AsyncClient() as c: + file = await c.get(url) + async with aiofiles.tempfile.NamedTemporaryFile("w+b") as f: + await f.write(file.content) + return await parse_file(Path(str(f.name))) + + +async def parse_file(file: Path): + async with await open_file(file, "r+b") as f: + magic = await f.read(4) + if magic == swu.MAGIC: + image_format = SoftwareImageFormat.SWU + attributes = await swu.parse_file(file) + elif magic == rauc.MAGIC: + image_format = SoftwareImageFormat.RAUC + attributes = await rauc.parse_file(file) + else: + logger.warning(f"Unknown file format, magic={magic}") + raise ValueError(f"Unknown file format, magic={magic}") + attributes["image_format"] = image_format + return attributes diff --git a/goosebit/updates/swdesc/func.py b/goosebit/updates/swdesc/func.py new file mode 100644 index 00000000..779ea468 --- /dev/null +++ b/goosebit/updates/swdesc/func.py @@ -0,0 +1,19 @@ +import hashlib + +from anyio import AsyncFile + + +async def sha1_hash_file(fileobj: AsyncFile): + last = await fileobj.tell() + await fileobj.seek(0) + sha1_hash = hashlib.sha1() + buf = bytearray(2**18) + view = memoryview(buf) + while True: + size = await fileobj.readinto(buf) + if size == 0: + break + sha1_hash.update(view[:size]) + + await fileobj.seek(last) + return sha1_hash.hexdigest() diff --git a/goosebit/updates/swdesc/rauc.py b/goosebit/updates/swdesc/rauc.py new file mode 100644 index 00000000..de0eff19 --- /dev/null +++ b/goosebit/updates/swdesc/rauc.py @@ -0,0 +1,45 @@ +import configparser +import logging + +import semver +from anyio import Path, open_file +from PySquashfsImage import SquashFsImage + +from .func import sha1_hash_file + +MAGIC = b"hsqs" + +logger = logging.getLogger(__name__) + + +async def parse_file(file: Path): + async with await open_file(file, "r+b") as f: + image_data = await f.read() + + image = SquashFsImage.from_bytes(image_data) + manifest = image.select("manifest.raucm") + manifest_str = manifest.read_bytes().decode("utf-8") + config = configparser.ConfigParser() + config.read_string(manifest_str) + swdesc_attrs = parse_descriptor(config) + + stat = await file.stat() + swdesc_attrs["size"] = stat.st_size + swdesc_attrs["hash"] = await sha1_hash_file(f) + return swdesc_attrs + + +def parse_descriptor(manifest: configparser.ConfigParser): + swdesc_attrs = {} + try: + swdesc_attrs["version"] = semver.Version.parse(manifest["system"].get("version")) + swdesc_attrs["compatibility"] = [{"hw_model": "default", "hw_revision": manifest["system"]["compatible"]}] + except KeyError: + try: + swdesc_attrs["version"] = semver.Version.parse(manifest["update"].get("version")) + swdesc_attrs["compatibility"] = [{"hw_model": "default", "hw_revision": manifest["update"]["compatible"]}] + except KeyError as e: + logger.warning(f"Parsing RAUC descriptor failed, error={e}") + raise ValueError("Parsing RAUC descriptor failed", e) + + return swdesc_attrs diff --git a/goosebit/updates/swdesc.py b/goosebit/updates/swdesc/swu.py similarity index 67% rename from goosebit/updates/swdesc.py rename to goosebit/updates/swdesc/swu.py index c6e5685c..3caa728d 100644 --- a/goosebit/updates/swdesc.py +++ b/goosebit/updates/swdesc/swu.py @@ -1,15 +1,16 @@ -import hashlib import logging from typing import Any -import aiofiles -import httpx import libconf import semver -from anyio import AsyncFile, Path, open_file +from anyio import Path, open_file + +from .func import sha1_hash_file logger = logging.getLogger(__name__) +MAGIC = b"0707" + def _append_compatibility(boardname, value, compatibility): if "hardware-compatibility" in value: @@ -34,7 +35,7 @@ def parse_descriptor(swdesc: libconf.AttrDict[Any, Any | None]): swdesc_attrs["compatibility"] = compatibility except KeyError as e: - logging.warning(f"Parsing swu descriptor failed, error={e}") + logger.warning(f"Parsing swu descriptor failed, error={e}") raise ValueError("Parsing swu descriptor failed", e) return swdesc_attrs @@ -61,29 +62,6 @@ async def parse_file(file: Path): swdesc_attrs = parse_descriptor(swdesc) stat = await file.stat() swdesc_attrs["size"] = stat.st_size - swdesc_attrs["hash"] = await _sha1_hash_file(f) - return swdesc_attrs + swdesc_attrs["hash"] = await sha1_hash_file(f) - -async def parse_remote(url: str): - async with httpx.AsyncClient() as c: - file = await c.get(url) - async with aiofiles.tempfile.NamedTemporaryFile("w+b") as f: - await f.write(file.content) - return await parse_file(Path(str(f.name))) - - -async def _sha1_hash_file(fileobj: AsyncFile): - last = await fileobj.tell() - await fileobj.seek(0) - sha1_hash = hashlib.sha1() - buf = bytearray(2**18) - view = memoryview(buf) - while True: - size = await fileobj.readinto(buf) - if size == 0: - break - sha1_hash.update(view[:size]) - - await fileobj.seek(last) - return sha1_hash.hexdigest() + return swdesc_attrs diff --git a/pyproject.toml b/pyproject.toml index d6632b96..a2145ae4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ httpx = "^0.27.0" pydantic-settings = "^2.4.0" asyncpg = { version = "^0.29.0", optional = true } +pysquashfsimage = "^0.9.0" [tool.poetry.extras] postgresql = ["asyncpg"] diff --git a/tests/updates/rauc/__init__.py b/tests/updates/rauc/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/updates/software-header.swu b/tests/updates/rauc/software-header.swu similarity index 100% rename from tests/updates/software-header.swu rename to tests/updates/rauc/software-header.swu diff --git a/tests/updates/test_swdesc.py b/tests/updates/rauc/test_swdesc.py similarity index 98% rename from tests/updates/test_swdesc.py rename to tests/updates/rauc/test_swdesc.py index 3649cdb5..013801c4 100644 --- a/tests/updates/test_swdesc.py +++ b/tests/updates/rauc/test_swdesc.py @@ -2,7 +2,7 @@ from anyio import Path from libconf import AttrDict -from goosebit.updates.swdesc import parse_descriptor, parse_file +from goosebit.updates.swdesc.swu import parse_descriptor, parse_file def test_parse_descriptor_simple(): diff --git a/tests/updates/swu/__init__.py b/tests/updates/swu/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/updates/swu/software-header.raucb b/tests/updates/swu/software-header.raucb new file mode 100644 index 00000000..25d6188b Binary files /dev/null and b/tests/updates/swu/software-header.raucb differ diff --git a/tests/updates/swu/test_swdesc.py b/tests/updates/swu/test_swdesc.py new file mode 100644 index 00000000..c59eaea4 --- /dev/null +++ b/tests/updates/swu/test_swdesc.py @@ -0,0 +1,14 @@ +import pytest +from anyio import Path + +from goosebit.updates.swdesc.rauc import parse_file + + +@pytest.mark.asyncio +async def test_parse_software_header(): + resolved = await Path(__file__).resolve() + swdesc_attrs = await parse_file(resolved.parent / "software-header.raucb") + assert str(swdesc_attrs["version"]) == "8.8.1-11-g8c926e5+188370" + assert swdesc_attrs["compatibility"] == [ + {"hw_model": "default", "hw_revision": "rauc-test-goosebit"}, + ]