From 577abc25eb41845668ca9828afc110c2fd62c71b Mon Sep 17 00:00:00 2001 From: Marten Svanfeldt Date: Thu, 16 Apr 2026 14:21:36 +0200 Subject: [PATCH 01/10] Restructure Python module for direct pip install Move the build configuration to the repository root so that 'pip install .' compiles all library C sources directly into the Python extension module, removing the need for a pre-built libbpak shared library. - Add root setup.py with all lib and wrapper sources - Add pyproject.toml with setuptools build-system declaration - Add MANIFEST.in for sdist packaging - Add python/bpak_user_settings.h using the existing BPAK_HAVE_USER_SETTINGS mechanism in bpak.h - Remove old python/setup.py that required libbpak --- MANIFEST.in | 9 +++++ pyproject.toml | 3 ++ python/bpak_user_settings.h | 8 ++++ python/setup.py | 31 --------------- setup.py | 76 +++++++++++++++++++++++++++++++++++++ 5 files changed, 96 insertions(+), 31 deletions(-) create mode 100644 MANIFEST.in create mode 100644 pyproject.toml create mode 100644 python/bpak_user_settings.h delete mode 100644 python/setup.py create mode 100644 setup.py diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..a0bc7cd --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,9 @@ +graft include +graft lib +graft python +graft ext/uuid +include README.rst +include LICENSE +include heatshrink.LICENSE +include setup.py +include pyproject.toml diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b61373e --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" diff --git a/python/bpak_user_settings.h b/python/bpak_user_settings.h new file mode 100644 index 0000000..b4d4b6a --- /dev/null +++ b/python/bpak_user_settings.h @@ -0,0 +1,8 @@ +#ifndef BPAK_USER_SETTINGS_H +#define BPAK_USER_SETTINGS_H + +#define BPAK_CONFIG_MERKLE 1 +#define BPAK_CONFIG_LZMA 1 +#define BPAK_CONFIG_MBEDTLS 1 + +#endif diff --git a/python/setup.py b/python/setup.py deleted file mode 100644 index f939a59..0000000 --- a/python/setup.py +++ /dev/null @@ -1,31 +0,0 @@ -#!/usr/bin/env python3 - -from setuptools import setup -from setuptools import Extension -import re - -def read_version(): - with open("../include/bpak/version.h", "r") as f: - return re.search(r"BPAK_VERSION_STRING \"(.*)\"$", - f.read(), - re.MULTILINE).group(1) - -setup(name='bpak', - version=read_version(), - description="BPAK python wrapper", - author="Jonas Blixt", - author_email="jonpe960@gmail.com", - license="BSD", - url="https://github.com/jonasblixt/bpak", - ext_modules=[ - Extension(name="bpak", - sources=[ - "meta.c", - "part.c", - "package.c", - "python_wrapper.c" - ], - libraries=["bpak"], - ) - ], -) diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..5bf78a5 --- /dev/null +++ b/setup.py @@ -0,0 +1,76 @@ +"""BPAK setuptools configuration.""" + +import re +from pathlib import Path + +from setuptools import Extension, setup + + +def read_version(): + text = Path("include/bpak/version.h").read_text() + return re.search(r'BPAK_VERSION_STRING "(.+)"', text).group(1) + + +# NOTE: Keep in sync with lib/CMakeLists.txt (authoritative source list) +_lib_sources = [ + "lib/bpak.c", + "lib/bpakcrc.c", + "lib/id.c", + "lib/keystore.c", + "lib/mem.c", + "lib/utils.c", + "lib/bsdiff.c", + "lib/bspatch.c", + "lib/merkle.c", + "lib/pkg.c", + "lib/pkg_create.c", + "lib/pkg_sign.c", + "lib/pkg_verify.c", + "lib/sais.c", + "lib/transport_decode.c", + "lib/transport_encode.c", + "lib/verify.c", + "lib/heatshrink/heatshrink_decoder.c", + "lib/heatshrink/heatshrink_encoder.c", + "lib/crypto.c", + "lib/mbedtls_wrapper.c", + "lib/keystore_load_from_file.c", + "ext/uuid/unpack.c", + "ext/uuid/unparse.c", +] + +_wrapper_sources = [ + "python/python_wrapper.c", + "python/package.c", + "python/meta.c", + "python/part.c", +] + +setup( + name="bpak", + version=read_version(), + description="BPAK - Bit Packer", + long_description=Path("README.rst").read_text(), + long_description_content_type="text/x-rst", + author="Jonas Blixt", + author_email="jonpe960@gmail.com", + license="BSD", + url="https://github.com/jonasblixt/bpak", + classifiers=[ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Topic :: Software Development :: Embedded Systems", + "License :: OSI Approved :: BSD License", + "Programming Language :: Python :: 3", + ], + ext_modules=[ + Extension( + name="bpak", + sources=_wrapper_sources + _lib_sources, + include_dirs=["include", "ext/uuid", "python"], + define_macros=[("BPAK_HAVE_USER_SETTINGS", "1")], + libraries=["mbedcrypto", "lzma"], + extra_compile_args=["-fvisibility=hidden"], + ) + ], +) From c1672f4c5fdff7fdf411af81e55a0fa85a80306e Mon Sep 17 00:00:00 2001 From: Marten Svanfeldt Date: Thu, 16 Apr 2026 14:21:46 +0200 Subject: [PATCH 02/10] Update docs and build config for pip install workflow - Add pip install instructions to README.rst and docs/build.rst - Fix stale cmake commands in docs to use out-of-tree builds - Remove non-existent BPAK_BUILD_PYTHON_WRAPPER from docs and cmake options table, add BPAK_BUILD_TOOL - Fix test/CMakeLists.txt to guard Python tests with BPAK_BUILD_TESTS instead of undefined BPAK_BUILD_PYTHON_WRAPPER - Update examples/python/Dockerfile from stale autotools to pip install - Add *.egg-info/ to .gitignore --- .gitignore | 1 + README.rst | 13 +++++++++++-- docs/build.rst | 33 ++++++++++++++++++++++----------- examples/python/Dockerfile | 19 ++++++------------- test/CMakeLists.txt | 2 +- 5 files changed, 41 insertions(+), 27 deletions(-) diff --git a/.gitignore b/.gitignore index 47f37ae..565c952 100644 --- a/.gitignore +++ b/.gitignore @@ -21,4 +21,5 @@ template* *.bak cov-int/ __pycache__ +*.egg-info/ tags diff --git a/README.rst b/README.rst index 18828cb..608ac20 100644 --- a/README.rst +++ b/README.rst @@ -32,9 +32,17 @@ Documentation is available here: `BPAK documentation`_ Building and installing ----------------------- -The library depends to mbedtls, liblzma, uuid +The library depends on mbedtls and liblzma. -Build library and tool:: +Install Python module:: + + $ pip install . + +This compiles the library and Python extension from source. Requires +``libmbedtls-dev`` and ``liblzma-dev`` (or equivalent) installed on the +system. + +Build C library and tool:: $ mkdir build && cd build $ cmake .. @@ -43,6 +51,7 @@ Build library and tool:: Running tests:: + $ mkdir build && cd build $ cmake .. -DBPAK_BUILD_TESTS=1 $ make && make test diff --git a/docs/build.rst b/docs/build.rst index 37d4235..629592e 100644 --- a/docs/build.rst +++ b/docs/build.rst @@ -4,21 +4,33 @@ Building and installing ----------------------- -The library depends on mbedtls and liblzma +The library depends on mbedtls and liblzma. -Build library and tool:: +Install Python module +--------------------- - $ cmake - $ make - $ sudo make install +:: + + $ pip install . + +This compiles the library and Python extension from source. Requires +``libmbedtls-dev`` and ``liblzma-dev`` (or equivalent) installed on the +system. -Optionally build with python support:: +Build C library and tool +------------------------ - $ cmake -DBPAK_BUILD_PYTHON_WRAPPER=1 +:: + + $ mkdir build && cd build + $ cmake .. + $ make + $ sudo make install Running tests:: - $ cmake -DBPAK_BUILD_TESTS=1 + $ mkdir build && cd build + $ cmake .. -DBPAK_BUILD_TESTS=1 $ make && make test @@ -29,12 +41,11 @@ cmake configure options Option Description =========================== ==================================================== BPAK_BUILD_MINIMAL Build a minimal version of the library -BPAK_BUILD_PYTHON_WRAPPER Build the python wrapper +BPAK_BUILD_TOOL Build the bpak command-line tool (default: ON) BPAK_BUILD_TESTS Build tests =========================== ==================================================== -The default setting is that everything is enabled except the python wrapper and -the tests. +The default setting builds the library and tool. Tests are disabled by default. Build settings diff --git a/examples/python/Dockerfile b/examples/python/Dockerfile index ec35b6d..3243a05 100644 --- a/examples/python/Dockerfile +++ b/examples/python/Dockerfile @@ -1,18 +1,11 @@ # build image from bpak root folder -from python:slim +FROM python:slim -RUN apt-get update -y - -RUN apt-get install -y gcc -RUN apt-get install -y pkgconf -RUN apt-get install -y autoconf-archive -RUN apt-get install -y libtool -RUN apt-get install -y make +RUN apt-get update -y && apt-get install -y \ + gcc \ + libmbedtls-dev \ + liblzma-dev COPY . . -RUN autoreconf -fi -RUN ./configure --enable-python-library --disable-dependency-tracking -RUN make -RUN make install -RUN ldconfig +RUN pip install . diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 5973a19..4dbc2dd 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -73,7 +73,7 @@ set(TEST_SCRIPT_PYTHON test_python_transport.py ) -if (BPAK_BUILD_PYTHON_WRAPPER) +if (BPAK_BUILD_TESTS) foreach(test_script IN LISTS TEST_SCRIPT_PYTHON) add_test(NAME ${test_script} COMMAND "${CMAKE_CURRENT_LIST_DIR}/${test_script}" "${CMAKE_SOURCE_DIR}" From d8ca3f5e68bccea7f3c7a53f9c7b76514f997424 Mon Sep 17 00:00:00 2001 From: Marten Svanfeldt Date: Mon, 20 Apr 2026 10:26:42 +0200 Subject: [PATCH 03/10] python: extend extension API for the new CLI Add wrapper bindings the Python bpak CLI relies on: - python_wrapper.c: hash_kind_str, signature_kind_str, id_to_string, meta_to_string, add_transport_meta, parse_public_key. - package.c: flags parameter on add_file/add_key, extract_file, delete_all_parts, part_sha256, and bounds checking on add_meta to refuse oversized payloads up front. - part.c: flags / transport_size / pad_bytes getters, and keep_meta keyword on Part.delete. --- python/package.c | 196 +++++++++++++++++++++++++++++++++--- python/part.c | 84 +++++++++++++++- python/python_wrapper.c | 213 +++++++++++++++++++++++++++++++++++++++- 3 files changed, 476 insertions(+), 17 deletions(-) diff --git a/python/package.c b/python/package.c index c30fbcd..33a2310 100644 --- a/python/package.c +++ b/python/package.c @@ -6,6 +6,7 @@ #include #include #include +#include #include "python_wrapper.h" @@ -173,7 +174,7 @@ static PyObject *package_sign(PyObject *self, PyObject *args, PyObject *kwds) static PyObject *package_add_file(PyObject *self, PyObject *args, PyObject *kwds) { - static char *kwlist[] = {"part_name", "filename", "with_merkle_tree", NULL}; + static char *kwlist[] = {"part_name", "filename", "with_merkle_tree", "flags", NULL}; BPAKPackage *package = (BPAKPackage *)self; struct bpak_header *h = bpak_pkg_header(&package->pkg); struct bpak_part_header *part; @@ -181,15 +182,17 @@ static PyObject *package_add_file(PyObject *self, PyObject *args, PyObject *kwds const char *part_name = NULL; PyObject *filename; int with_merkle_tree = 0; + unsigned int flags = 0; rc = PyArg_ParseTupleAndKeywords(args, kwds, - "sO&|p:add_file", + "sO&|pI:add_file", kwlist, &part_name, &PyUnicode_FSDecoder, &filename, - &with_merkle_tree); + &with_merkle_tree, + &flags); if (!rc) { return NULL; @@ -202,10 +205,10 @@ static PyObject *package_add_file(PyObject *self, PyObject *args, PyObject *kwds if (with_merkle_tree) { rc = bpak_pkg_add_file_with_merkle_tree(&package->pkg, - PyBytes_AsString(filename_ascii), part_name, 0); + PyBytes_AsString(filename_ascii), part_name, (uint8_t)flags); } else { rc = bpak_pkg_add_file(&package->pkg, PyBytes_AsString(filename_ascii), - part_name, 0); + part_name, (uint8_t)flags); } Py_DECREF(filename_ascii); @@ -226,21 +229,23 @@ static PyObject *package_add_file(PyObject *self, PyObject *args, PyObject *kwds static PyObject *package_add_key(PyObject *self, PyObject *args, PyObject *kwds) { - static char *kwlist[] = {"part_name", "filename", NULL}; + static char *kwlist[] = {"part_name", "filename", "flags", NULL}; BPAKPackage *package = (BPAKPackage *)self; struct bpak_header *h = bpak_pkg_header(&package->pkg); struct bpak_part_header *part; int rc; const char *part_name = NULL; PyObject *filename; + unsigned int flags = 0; rc = PyArg_ParseTupleAndKeywords(args, kwds, - "sO&:add_key", + "sO&|I:add_key", kwlist, &part_name, &PyUnicode_FSDecoder, - &filename); + &filename, + &flags); if (!rc) { return NULL; @@ -251,8 +256,8 @@ static PyObject *package_add_key(PyObject *self, PyObject *args, PyObject *kwds) return NULL; } - - rc = bpak_pkg_add_key(&package->pkg, PyBytes_AsString(filename_ascii), part_name, 0); + rc = bpak_pkg_add_key(&package->pkg, PyBytes_AsString(filename_ascii), + part_name, (uint8_t)flags); Py_DECREF(filename_ascii); @@ -331,7 +336,20 @@ static PyObject *package_add_meta(PyObject *self, PyObject *args, PyObject *kwds } } - rc = bpak_add_meta(h, meta_id, part_ref, size, &meta); + if (size > BPAK_METADATA_BYTES) { + PyErr_Format(PyExc_ValueError, + "metadata size %zd exceeds maximum %d bytes", + size, BPAK_METADATA_BYTES); + goto err_out; + } + + if (size > UINT16_MAX) { + PyErr_Format(PyExc_ValueError, + "metadata size %zd exceeds uint16 maximum", size); + goto err_out; + } + + rc = bpak_add_meta(h, meta_id, part_ref, (uint16_t)size, &meta); if (rc != BPAK_OK) { PyErr_Format(BPAKPackageError, "failed to add metadata: %s", bpak_error_string(rc)); @@ -370,6 +388,135 @@ static PyObject *package_add_meta(PyObject *self, PyObject *args, PyObject *kwds return NULL; } +static PyObject *package_extract_file(PyObject *self, PyObject *args, + PyObject *kwds) +{ + static char *kwlist[] = {"part_id", "filename", NULL}; + BPAKPackage *package = (BPAKPackage *)self; + int rc; + bpak_id_t part_id; + PyObject *filename; + + rc = PyArg_ParseTupleAndKeywords(args, kwds, "IO&:extract_file", kwlist, + &part_id, &PyUnicode_FSDecoder, &filename); + if (!rc) { + return NULL; + } + + PyObject *filename_ascii = PyUnicode_AsASCIIString(filename); + if (!filename_ascii) { + return NULL; + } + + rc = bpak_pkg_extract_file(&package->pkg, part_id, + PyBytes_AsString(filename_ascii)); + + Py_DECREF(filename_ascii); + + if (rc != BPAK_OK) { + return PyErr_Format(BPAKPackageError, "failed to extract file: %s", + bpak_error_string(rc)); + } + + Py_RETURN_NONE; +} + +static PyObject *package_delete_all_parts(PyObject *self, PyObject *args, + PyObject *kwds) +{ + static char *kwlist[] = {"keep_meta", NULL}; + BPAKPackage *package = (BPAKPackage *)self; + int rc; + int keep_meta = 0; + + rc = PyArg_ParseTupleAndKeywords(args, kwds, "|p:delete_all_parts", + kwlist, &keep_meta); + if (!rc) { + return NULL; + } + + rc = bpak_pkg_delete_all_parts(&package->pkg, !keep_meta); + if (rc != BPAK_OK) { + return PyErr_Format(BPAKPackageError, + "failed to delete all parts: %s", + bpak_error_string(rc)); + } + + Py_RETURN_NONE; +} + +static PyObject *package_part_sha256(PyObject *self, PyObject *args, + PyObject *kwds) +{ + static char *kwlist[] = {"part_id", NULL}; + BPAKPackage *package = (BPAKPackage *)self; + int rc; + bpak_id_t part_id; + uint8_t hash_buffer[32]; + + rc = PyArg_ParseTupleAndKeywords(args, kwds, "I:part_sha256", kwlist, + &part_id); + if (!rc) { + return NULL; + } + + rc = bpak_pkg_part_sha256(&package->pkg, hash_buffer, sizeof(hash_buffer), + part_id); + if (rc != BPAK_OK) { + return PyErr_Format(BPAKPackageError, + "failed to compute part sha256: %s", + bpak_error_string(rc)); + } + + return PyBytes_FromStringAndSize((char *)hash_buffer, sizeof(hash_buffer)); +} + +static PyObject *package_verify_with_keystore(PyObject *self, PyObject *args, + PyObject *kwds) +{ + static char *kwlist[] = {"keystore_path", NULL}; + BPAKPackage *package = (BPAKPackage *)self; + int rc; + PyObject *ks_filename; + struct bpak_key *key = NULL; + struct bpak_header *h = bpak_pkg_header(&package->pkg); + + rc = PyArg_ParseTupleAndKeywords(args, kwds, "O&:verify_with_keystore", + kwlist, &PyUnicode_FSDecoder, + &ks_filename); + if (!rc) { + return NULL; + } + + PyObject *filename_ascii = PyUnicode_AsASCIIString(ks_filename); + if (!filename_ascii) { + return NULL; + } + + rc = bpak_keystore_load_key_from_file(PyBytes_AsString(filename_ascii), + h->keystore_id, h->key_id, + NULL, NULL, &key); + + Py_DECREF(filename_ascii); + + if (rc != BPAK_OK) { + return PyErr_Format(BPAKPackageError, + "could not load key from keystore: %s", + bpak_error_string(rc)); + } + + rc = bpak_pkg_verify(&package->pkg, key); + + free(key); + + if (rc != BPAK_OK) { + return PyErr_Format(BPAKPackageError, "verification failed: %s", + bpak_error_string(rc)); + } + + Py_RETURN_TRUE; +} + static PyObject *package_get_part(PyObject *self, PyObject *args, PyObject *kwds) { static char *kwlist[] = {"id", NULL}; @@ -614,6 +761,13 @@ static int package_set_signature(PyObject *self, PyObject *value, return -1; } + if (signature_sz > BPAK_SIGNATURE_MAX_BYTES) { + PyErr_Format(PyExc_ValueError, + "signature size %zd exceeds maximum %d bytes", + signature_sz, BPAK_SIGNATURE_MAX_BYTES); + return -1; + } + rc = bpak_pkg_write_raw_signature(&package->pkg, signature_data, signature_sz); @@ -804,6 +958,26 @@ static PyMethodDef package_methods[] = { METH_VARARGS | METH_KEYWORDS, "Get a reference to a specific metadata object"}, + {"extract_file", + (PyCFunction)(void (*)(void))package_extract_file, + METH_VARARGS | METH_KEYWORDS, + "Extract a part to a file"}, + + {"delete_all_parts", + (PyCFunction)(void (*)(void))package_delete_all_parts, + METH_VARARGS | METH_KEYWORDS, + "Delete all parts from the package"}, + + {"part_sha256", + (PyCFunction)(void (*)(void))package_part_sha256, + METH_VARARGS | METH_KEYWORDS, + "Compute SHA256 hash of a part"}, + + {"verify_with_keystore", + (PyCFunction)(void (*)(void))package_verify_with_keystore, + METH_VARARGS | METH_KEYWORDS, + "Verify package using a keystore bpak file"}, + /* For context manager use */ {"__enter__", (PyCFunction)(void (*)(void))package_enter, diff --git a/python/part.c b/python/part.c index 0a30d97..f51afcd 100644 --- a/python/part.c +++ b/python/part.c @@ -191,12 +191,70 @@ static PyObject *part_read_data(PyObject *self, PyObject *Py_UNUSED(ignored)) return bytes; } -static PyObject *part_delete(PyObject *self, PyObject *Py_UNUSED(ignored)) +static PyObject *part_get_flags(PyObject *self, void *closure) +{ + (void)closure; + BPAKPart *part = (BPAKPart *)self; + struct bpak_header *h = bpak_pkg_header(&part->package->pkg); + struct bpak_part_header *p; + int rc = 0; + + rc = bpak_get_part(h, part->part_id, &p); + if (rc != BPAK_OK) { + return PyErr_Format(PyExc_KeyError, "failed to get partition: %s", + bpak_error_string(rc)); + } + + return PyLong_FromLong(p->flags); +} + +static PyObject *part_get_transport_size(PyObject *self, void *closure) +{ + (void)closure; + BPAKPart *part = (BPAKPart *)self; + struct bpak_header *h = bpak_pkg_header(&part->package->pkg); + struct bpak_part_header *p; + int rc = 0; + + rc = bpak_get_part(h, part->part_id, &p); + if (rc != BPAK_OK) { + return PyErr_Format(PyExc_KeyError, "failed to get partition: %s", + bpak_error_string(rc)); + } + + return PyLong_FromUnsignedLongLong(p->transport_size); +} + +static PyObject *part_get_pad_bytes(PyObject *self, void *closure) +{ + (void)closure; + BPAKPart *part = (BPAKPart *)self; + struct bpak_header *h = bpak_pkg_header(&part->package->pkg); + struct bpak_part_header *p; + int rc = 0; + + rc = bpak_get_part(h, part->part_id, &p); + if (rc != BPAK_OK) { + return PyErr_Format(PyExc_KeyError, "failed to get partition: %s", + bpak_error_string(rc)); + } + + return PyLong_FromLong(p->pad_bytes); +} + +static PyObject *part_delete(PyObject *self, PyObject *args, PyObject *kwds) { BPAKPart *part = (BPAKPart *)self; int rc; + int keep_meta = 0; + static char *kwlist[] = {"keep_meta", NULL}; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "|p:delete", kwlist, + &keep_meta)) { + return NULL; + } - rc = bpak_pkg_delete_part(&part->package->pkg, part->part_id, true); + rc = bpak_pkg_delete_part(&part->package->pkg, part->part_id, !keep_meta); if (rc != BPAK_OK) { return PyErr_Format(BPAKPackageError, "failed to delete part: %s", bpak_error_string(rc)); @@ -213,8 +271,8 @@ static PyMethodDef part_methods[] = { {"delete", (PyCFunction)(void (*)(void))part_delete, - METH_NOARGS, - "Delete part from package"}, + METH_VARARGS | METH_KEYWORDS, + "Delete part from package. Optional keep_meta=True to preserve metadata."}, {NULL} }; @@ -244,6 +302,24 @@ static PyGetSetDef part_getset[] = { "If part is transport encoded", NULL}, + {"flags", + (getter)part_get_flags, + (setter)NULL, + "Part flags byte", + NULL}, + + {"transport_size", + (getter)part_get_transport_size, + (setter)NULL, + "Part transport size in bytes", + NULL}, + + {"pad_bytes", + (getter)part_get_pad_bytes, + (setter)NULL, + "Part padding bytes", + NULL}, + {NULL} }; diff --git a/python/python_wrapper.c b/python/python_wrapper.c index 161b48e..f3013b0 100644 --- a/python/python_wrapper.c +++ b/python/python_wrapper.c @@ -7,6 +7,8 @@ #include #include #include +#include +#include #include "python_wrapper.h" @@ -132,6 +134,167 @@ static PyObject *m_transport_decode(PyObject *self, PyObject *args, } +static PyObject *m_hash_kind_str(PyObject *module, PyObject *args, + PyObject *kwds) +{ + (void)module; + int rc; + static char *kwlist[] = {"kind", NULL}; + unsigned int kind; + + rc = PyArg_ParseTupleAndKeywords(args, kwds, "I:hash_kind_str", kwlist, + &kind); + if (!rc) { + return NULL; + } + + const char *s = bpak_hash_kind((uint8_t)kind); + return PyUnicode_FromString(s); +} + +static PyObject *m_signature_kind_str(PyObject *module, PyObject *args, + PyObject *kwds) +{ + (void)module; + int rc; + static char *kwlist[] = {"kind", NULL}; + unsigned int kind; + + rc = PyArg_ParseTupleAndKeywords(args, kwds, "I:signature_kind_str", + kwlist, &kind); + if (!rc) { + return NULL; + } + + const char *s = bpak_signature_kind((uint8_t)kind); + return PyUnicode_FromString(s); +} + +static PyObject *m_id_to_string(PyObject *module, PyObject *args, + PyObject *kwds) +{ + (void)module; + int rc; + static char *kwlist[] = {"id", NULL}; + unsigned int id; + + rc = PyArg_ParseTupleAndKeywords(args, kwds, "I:id_to_string", kwlist, + &id); + if (!rc) { + return NULL; + } + + const char *s = bpak_id_to_string((bpak_id_t)id); + if (s == NULL) { + Py_RETURN_NONE; + } + + return PyUnicode_FromString(s); +} + +static PyObject *m_meta_to_string(PyObject *module, PyObject *args, + PyObject *kwds) +{ + (void)module; + int rc; + static char *kwlist[] = {"package", "meta", NULL}; + BPAKPackage *package = NULL; + BPAKMeta *meta_obj = NULL; + + rc = PyArg_ParseTupleAndKeywords(args, kwds, "O!O!:meta_to_string", + kwlist, + &BPAKPackageType, &package, + &BPAKMetaType, &meta_obj); + if (!rc) { + return NULL; + } + + struct bpak_header *h = bpak_pkg_header(&package->pkg); + struct bpak_meta_header *m; + + rc = bpak_get_meta(h, meta_obj->meta_id, meta_obj->part_ref, &m); + if (rc != BPAK_OK) { + return PyErr_Format(PyExc_KeyError, "failed to get meta: %s", + bpak_error_string(rc)); + } + + char buf[256]; + rc = bpak_meta_to_string(h, m, buf, sizeof(buf)); + if (rc != BPAK_OK) { + Py_RETURN_NONE; + } + + return PyUnicode_FromString(buf); +} + +static PyObject *m_add_transport_meta(PyObject *module, PyObject *args, + PyObject *kwds) +{ + (void)module; + int rc; + static char *kwlist[] = {"package", "part_id", "encoder_id", "decoder_id", + NULL}; + BPAKPackage *package = NULL; + unsigned int part_id; + unsigned int encoder_id; + unsigned int decoder_id; + + rc = PyArg_ParseTupleAndKeywords(args, kwds, "O!III:add_transport_meta", + kwlist, + &BPAKPackageType, &package, + &part_id, &encoder_id, &decoder_id); + if (!rc) { + return NULL; + } + + struct bpak_header *h = bpak_pkg_header(&package->pkg); + + rc = bpak_add_transport_meta(h, (bpak_id_t)part_id, + (uint32_t)encoder_id, (uint32_t)decoder_id); + if (rc != BPAK_OK) { + return PyErr_Format(BPAKPackageError, + "failed to add transport metadata: %s", + bpak_error_string(rc)); + } + + rc = package_write_header(package, false); + if (rc != BPAK_OK) { + return PyErr_Format(PyExc_IOError, "could not write header: %s", + bpak_error_string(rc)); + } + + Py_RETURN_NONE; +} + +static PyObject *m_parse_public_key(PyObject *module, PyObject *args, + PyObject *kwds) +{ + (void)module; + int rc; + static char *kwlist[] = {"data", NULL}; + const uint8_t *buffer; + Py_ssize_t length; + struct bpak_key *key = NULL; + + rc = PyArg_ParseTupleAndKeywords(args, kwds, "y#:parse_public_key", + kwlist, &buffer, &length); + if (!rc) { + return NULL; + } + + rc = bpak_crypto_parse_public_key(buffer, (size_t)length, &key); + if (rc != BPAK_OK) { + return PyErr_Format(BPAKPackageError, "failed to parse public key: %s", + bpak_error_string(rc)); + } + + PyObject *result = Py_BuildValue("(iy#)", key->kind, key->data, key->size); + + free(key); + + return result; +} + static PyMethodDef module_methods[] = { {"id", (PyCFunction)(void (*)(void))m_generate_id, @@ -157,19 +320,55 @@ static PyMethodDef module_methods[] = { "Transport decode from input to output, using origin for diff origin" }, + {"hash_kind_str", + (PyCFunction)(void (*)(void))m_hash_kind_str, + METH_VARARGS | METH_KEYWORDS, + "Convert hash kind constant to string" + }, + + {"signature_kind_str", + (PyCFunction)(void (*)(void))m_signature_kind_str, + METH_VARARGS | METH_KEYWORDS, + "Convert signature kind constant to string" + }, + + {"id_to_string", + (PyCFunction)(void (*)(void))m_id_to_string, + METH_VARARGS | METH_KEYWORDS, + "Convert bpak id to known string name, or None" + }, + + {"meta_to_string", + (PyCFunction)(void (*)(void))m_meta_to_string, + METH_VARARGS | METH_KEYWORDS, + "Convert metadata to string representation" + }, + + {"add_transport_meta", + (PyCFunction)(void (*)(void))m_add_transport_meta, + METH_VARARGS | METH_KEYWORDS, + "Add transport metadata to a package" + }, + + {"parse_public_key", + (PyCFunction)(void (*)(void))m_parse_public_key, + METH_VARARGS | METH_KEYWORDS, + "Parse a public key from bytes, returns (kind, data)" + }, + {NULL} }; static PyModuleDef module = { PyModuleDef_HEAD_INIT, - .m_name = "bpak", + .m_name = "bpak._bpak", .m_doc = NULL, .m_size = -1, .m_methods = module_methods }; -PyMODINIT_FUNC PyInit_bpak(void) +PyMODINIT_FUNC PyInit__bpak(void) { PyObject *m_p; @@ -225,5 +424,15 @@ PyMODINIT_FUNC PyInit_bpak(void) PyModule_AddIntConstant(m_p, "SIGN_PRIME256v1", BPAK_SIGN_PRIME256v1); PyModule_AddIntConstant(m_p, "SIGN_SECP384r1", BPAK_SIGN_SECP384r1); PyModule_AddIntConstant(m_p, "SIGN_SECP521r1", BPAK_SIGN_SECP521r1); + + PyModule_AddIntConstant(m_p, "KEY_PUB_RSA4096", BPAK_KEY_PUB_RSA4096); + PyModule_AddIntConstant(m_p, "KEY_PUB_PRIME256v1", BPAK_KEY_PUB_PRIME256v1); + PyModule_AddIntConstant(m_p, "KEY_PUB_SECP384r1", BPAK_KEY_PUB_SECP384r1); + PyModule_AddIntConstant(m_p, "KEY_PUB_SECP521r1", BPAK_KEY_PUB_SECP521r1); + + PyModule_AddIntConstant(m_p, "FLAG_EXCLUDE_FROM_HASH", + BPAK_FLAG_EXCLUDE_FROM_HASH); + PyModule_AddIntConstant(m_p, "FLAG_TRANSPORT", BPAK_FLAG_TRANSPORT); + return (m_p); } From 65e825838bbefea57ee8eda90053faecc6c6d6fd Mon Sep 17 00:00:00 2001 From: Marten Svanfeldt Date: Mon, 20 Apr 2026 10:26:58 +0200 Subject: [PATCH 04/10] python: add bpak CLI module mirroring the C bpak argv Add the bpak Python package and a Click-based CLI whose commands, flags, positional arguments, and mutually-exclusive groups match the C bpak binary in src/ exactly. Existing scripts and muscle memory keep working. Notable parity choices: - transport: single command with -a/-E/-D mode flags. --part is exposed as a long-only alias of --part-ref to match the long-option abbreviation behaviour of getopt_long in src/transport.c. - generate: positional dispatch (id, keystore). Unknown generators exit with an error instead of silently no-op'ing as in C. - show: -p reinterprets as the metadata part_id_ref filter when -m is given, matching src/show.c. - -v/--verbose lives on each subcommand (not a global flag), mirroring per-action getopt parsing in C. The new _apply_verbose helper resets the C extension's static log callback on level 0 so verbose state does not leak across calls in the same process. - Version banner is "BitPacker " to match src/misc.c. setup.py: declare the bpak package, rename the extension to bpak._bpak, register the bpak console_scripts entry point, and require Click >= 8.0. --- python/bpak/__init__.py | 31 ++ python/bpak/__main__.py | 825 ++++++++++++++++++++++++++++++++++++++++ python/bpak/_helpers.py | 50 +++ python/bpak/py.typed | 0 setup.py | 8 +- 5 files changed, 913 insertions(+), 1 deletion(-) create mode 100644 python/bpak/__init__.py create mode 100644 python/bpak/__main__.py create mode 100644 python/bpak/_helpers.py create mode 100644 python/bpak/py.typed diff --git a/python/bpak/__init__.py b/python/bpak/__init__.py new file mode 100644 index 0000000..7380004 --- /dev/null +++ b/python/bpak/__init__.py @@ -0,0 +1,31 @@ +"""BPAK - Bit Packer.""" + +from ._bpak import ( + FLAG_EXCLUDE_FROM_HASH, + FLAG_TRANSPORT, + HASH_SHA256, + HASH_SHA384, + HASH_SHA512, + KEY_PUB_PRIME256v1, + KEY_PUB_RSA4096, + KEY_PUB_SECP384r1, + KEY_PUB_SECP521r1, + SIGN_PRIME256v1, + SIGN_RSA4096, + SIGN_SECP384r1, + SIGN_SECP521r1, + Error, + Meta, + Package, + Part, + add_transport_meta, + hash_kind_str, + id, + id_to_string, + meta_to_string, + parse_public_key, + set_log_func, + signature_kind_str, + transport_decode, + transport_encode, +) diff --git a/python/bpak/__main__.py b/python/bpak/__main__.py new file mode 100644 index 0000000..1b41bb7 --- /dev/null +++ b/python/bpak/__main__.py @@ -0,0 +1,825 @@ +"""BPAK CLI - Bit Packer command line tool.""" + +from __future__ import annotations + +import functools +import os +import struct +import sys +from pathlib import Path + +import click + +from . import _bpak +from ._helpers import HASH_KIND_MAP, SIGN_KIND_MAP, encode_meta_value, resolve_id + +_verbose_level = 0 + + +def _log_callback(level: int, msg: str) -> None: + if level == 0: + click.echo(msg, nl=False, err=True) + elif level <= _verbose_level: + click.echo(msg, nl=False) + + +def _apply_verbose(level: int) -> None: + """Install or clear the bpak log callback for this command. + + Cleared on level==0 because _bpak stores the callback in a static + module-global; without clearing, a verbose call would leak logging + into later non-verbose calls in the same process. + """ + global _verbose_level + _verbose_level = level + if level > 0: + _bpak.set_log_func(_log_callback) + else: + _bpak.set_log_func(None) + + +def _print_version( + _ctx: click.Context, _param: click.Option, value: bool +) -> None: + if value: + try: + from importlib.metadata import version + + click.echo(f"BitPacker {version('bpak')}") + except Exception: + click.echo("BitPacker (unknown version)") + sys.exit(0) + + +def _handle_error(func): + """Decorator that catches bpak errors and converts to ClickException.""" + + @functools.wraps(func) + def wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except _bpak.Error as e: + raise click.ClickException(str(e)) from e + + return wrapper + + +def _verbose_option(f): + return click.option("-v", "--verbose", count=True, help="Verbose output")(f) + + +@click.group() +@click.option( + "-V", + "--version", + is_flag=True, + is_eager=True, + expose_value=False, + callback=_print_version, + help="Display version and exit", +) +def cli() -> None: + """BPAK - Bit Packer.""" + + +# --- create --- + + +@cli.command() +@click.argument("filename", type=click.Path()) +@click.option( + "-H", + "--hash-kind", + type=click.Choice(list(HASH_KIND_MAP.keys())), + default="sha256", + help="Hash algorithm", +) +@click.option( + "-S", + "--signature-kind", + type=click.Choice(list(SIGN_KIND_MAP.keys())), + default="prime256v1", + help="Signature algorithm", +) +@click.option("-Y", "--force", is_flag=True, help="Overwrite without asking") +@_verbose_option +@_handle_error +def create( + filename: str, + hash_kind: str, + signature_kind: str, + force: bool, + verbose: int, +) -> None: + """Create an empty bpak file.""" + _apply_verbose(verbose) + if os.path.exists(filename) and not force: + if not click.confirm(f"File '{filename}' exists. Overwrite?"): + return + + with _bpak.Package(filename, "wb") as pkg: + pkg.hash_kind = HASH_KIND_MAP[hash_kind] + pkg.signature_kind = SIGN_KIND_MAP[signature_kind] + + +# --- add --- + + +@cli.command() +@click.argument("filename", type=click.Path(exists=True)) +@click.option("-p", "--part", "part_name", help="Add part with given name") +@click.option("-m", "--meta", "meta_name", help="Add metadata with given name") +@click.option( + "-f", "--from-file", type=click.Path(exists=True), help="Load data from file" +) +@click.option("-s", "--from-string", help="Load data from string") +@click.option( + "-e", + "--encoder", + type=click.Choice(["uuid", "integer", "id", "key", "merkle"]), + help="Encoder for data", +) +@click.option( + "-F", + "--set-flag", + type=click.Choice(["dont-hash"]), + help="Set flag on part", +) +@click.option("-r", "--part-ref", help="Reference part") +@_verbose_option +@_handle_error +def add( + filename: str, + part_name: str | None, + meta_name: str | None, + from_file: str | None, + from_string: str | None, + encoder: str | None, + set_flag: str | None, + part_ref: str | None, + verbose: int, +) -> None: + """Add parts or metadata to a bpak file.""" + _apply_verbose(verbose) + if not part_name and not meta_name: + raise click.UsageError("Must specify --part or --meta") + + with _bpak.Package(filename, "r+") as pkg: + if meta_name: + meta_id = resolve_id(meta_name) + ref_id = resolve_id(part_ref) if part_ref else 0 + + if encoder and from_string: + data = encode_meta_value(from_string, encoder) + elif from_string: + data = from_string.encode("ascii") + b"\x00" + elif from_file: + data = Path(from_file).read_bytes() + else: + raise click.UsageError( + "Must specify --from-string or --from-file for metadata" + ) + + if len(data) > _bpak.BPAK_METADATA_BYTES if hasattr(_bpak, 'BPAK_METADATA_BYTES') else len(data) > 1920: + raise click.ClickException("Metadata too large") + + pkg.add_meta(meta_id, ref_id, data) + + elif part_name: + flags = 0 + if set_flag == "dont-hash": + flags = _bpak.FLAG_EXCLUDE_FROM_HASH + + if encoder == "key": + if not from_file: + raise click.UsageError("--encoder key requires --from-file") + pkg.add_key(part_name, from_file, flags=flags) + elif encoder == "merkle": + if not from_file: + raise click.UsageError( + "--encoder merkle requires --from-file" + ) + pkg.add_file( + part_name, from_file, with_merkle_tree=True, flags=flags + ) + else: + if not from_file: + raise click.UsageError("--from-file required for parts") + pkg.add_file(part_name, from_file, flags=flags) + + +# --- show --- + + +def _flag_str(flags: int) -> str: + chars = list("--------") + if flags & _bpak.FLAG_EXCLUDE_FROM_HASH: + chars[0] = "h" + if flags & _bpak.FLAG_TRANSPORT: + chars[1] = "T" + return "".join(chars) + + +def _bin2hex(data: bytes) -> str: + return "".join(f"{b:02x}" for b in data) + + +@cli.command() +@click.argument("filename", type=click.Path(exists=True)) +@click.option("-m", "--meta", "meta_name", help="Show specific metadata") +@click.option("-p", "--part", "part_name", help="Show specific part") +@click.option("-P", "--part-hash", help="Show SHA256 hash of a part") +@click.option("-H", "--hash", "show_hash", is_flag=True, help="Show package hash") +@click.option( + "-B", "--binary-hash", is_flag=True, help="Output hash in binary form" +) +@_verbose_option +@_handle_error +def show( + filename: str, + meta_name: str | None, + part_name: str | None, + part_hash: str | None, + show_hash: bool, + binary_hash: bool, + verbose: int, +) -> None: + """Show information about a bpak file.""" + _apply_verbose(verbose) + + with _bpak.Package(filename, "r+") as pkg: + h_kind = pkg.hash_kind + s_kind = pkg.signature_kind + + if meta_name: + meta_id = resolve_id(meta_name) + # In C show.c:126-128, when -m is given, -p reinterprets as + # the part_id_ref filter on the metadata lookup. + ref_id = resolve_id(part_name) if part_name else None + + for m in pkg.meta: + if m.id == meta_id: + if ref_id is not None and m.part_id_ref != ref_id: + continue + s = _bpak.meta_to_string(pkg, m) + if s: + click.echo(s) + return + raise click.ClickException(f"Could not find meta '{meta_name}'") + + if part_name: + part_id = resolve_id(part_name) + for p in pkg.parts: + if p.id == part_id: + click.echo(f"Found 0x{p.id:x}, {p.size} bytes") + return + raise click.ClickException(f"Could not find part '{part_name}'") + + if part_hash: + part_hash_id = resolve_id(part_hash) + h = pkg.part_sha256(part_hash_id) + click.echo(_bin2hex(h)) + return + + if binary_hash: + digest = pkg.digest + if digest: + sys.stdout.buffer.write(digest) + return + + if show_hash: + digest = pkg.digest + if digest: + click.echo(_bin2hex(digest)) + return + + # Full display + click.echo(f"BPAK File: {filename}") + click.echo("") + click.echo(f"Hash: {_bpak.hash_kind_str(h_kind)}") + click.echo(f"Signature: {_bpak.signature_kind_str(s_kind)}") + click.echo(f"Key ID: {pkg.key_id:08x}") + click.echo(f"Keystore ID: {pkg.keystore_id:08x}") + + click.echo("\nMetadata:") + click.echo(" ID Size Meta ID Part Ref Data") + + for m in pkg.meta: + s = _bpak.meta_to_string(pkg, m) + if s is None: + s = "" + id_name = _bpak.id_to_string(m.id) + if id_name is None: + id_name = "" + ref_str = f"{m.part_id_ref:08x}" if m.part_id_ref else " " + click.echo( + f" {m.id:08x} {m.size:<3} {id_name:<20s} {ref_str} {s}" + ) + + click.echo("\nParts:") + click.echo( + " ID Size Z-pad Flags Transport Size" + ) + + for p in pkg.parts: + flags = _flag_str(p.flags) + ts = p.transport_size if p.flags & _bpak.FLAG_TRANSPORT else p.size + click.echo( + f" {p.id:08x} {p.size:<12} {p.pad_bytes:<3} {flags}" + f" {ts:<12}" + ) + + # Hashes + digest = pkg.digest + if digest: + click.echo(f"\nHeader hash: {_bin2hex(digest)}") + payload_digest = pkg.digest + if payload_digest: + click.echo(f"Payload hash: {_bin2hex(payload_digest)}") + + if verbose: + meta_size = sum(m.size for m in pkg.meta) + click.echo(f"Metadata usage: {meta_size}/1920 bytes") + click.echo(f"Transport size: {pkg.size} bytes") + click.echo(f"Installed size: {pkg.installed_size} bytes") + + +# --- sign --- + + +@cli.command() +@click.argument("filename", type=click.Path(exists=True)) +@click.option("-k", "--key", type=click.Path(exists=True), help="Sign using key") +@click.option( + "-f", + "--signature", + type=click.Path(exists=True), + help="Write pre-computed signature from file", +) +@_verbose_option +@_handle_error +def sign( + filename: str, key: str | None, signature: str | None, verbose: int +) -> None: + """Sign a bpak file.""" + _apply_verbose(verbose) + if not key and not signature: + raise click.UsageError("Must specify --key or --signature") + + with _bpak.Package(filename, "r+") as pkg: + if signature: + sig_data = Path(signature).read_bytes() + if len(sig_data) > 512: + raise click.ClickException( + f"Signature file too large ({len(sig_data)} > 512 bytes)" + ) + pkg.signature = sig_data + elif key: + pkg.sign(key) + + +# --- verify --- + + +@cli.command() +@click.argument("filename", type=click.Path(exists=True)) +@click.option( + "-k", "--key", type=click.Path(exists=True), help="Verify using public key" +) +@click.option( + "-K", + "--keystore", + type=click.Path(exists=True), + help="Verify using keystore bpak file", +) +@_verbose_option +@_handle_error +def verify( + filename: str, key: str | None, keystore: str | None, verbose: int +) -> None: + """Verify a bpak file signature.""" + _apply_verbose(verbose) + if not key and not keystore: + raise click.UsageError("Must specify --key or --keystore") + + with _bpak.Package(filename, "r+") as pkg: + if keystore: + pkg.verify_with_keystore(keystore) + elif key: + pkg.verify(key) + + click.echo("Verification OK") + + +# --- generate --- + + +@cli.command() +@click.argument("generator") +@click.argument("rest", nargs=-1) +@click.option("-n", "--name", help="Name for generated keystore") +@click.option("-d", "--decorate", is_flag=True, help="Add section attributes") +@_verbose_option +@_handle_error +def generate( + generator: str, + rest: tuple[str, ...], + name: str | None, + decorate: bool, + verbose: int, +) -> None: + """Generate code or translations. + + Generators: + id Translate string to bpak id + keystore -n NAME Generate C keystore from bpak file + """ + _apply_verbose(verbose) + + if generator == "id": + if len(rest) != 1: + raise click.UsageError("generate id requires one positional argument") + id_string = rest[0] + click.echo(f'id("{id_string}") = 0x{_bpak.id(id_string):08x}') + return + + if generator == "keystore": + if len(rest) != 1: + raise click.UsageError("generate keystore requires a filename") + filename = rest[0] + if not os.path.exists(filename): + raise click.UsageError(f"File not found: {filename}") + if not name: + raise click.UsageError("generate keystore requires --name") + + from importlib.metadata import version as pkg_version + + try: + ver = pkg_version("bpak") + except Exception: + ver = "unknown" + + key_kind_names = { + _bpak.KEY_PUB_PRIME256v1: "BPAK_KEY_PUB_PRIME256v1", + _bpak.KEY_PUB_SECP384r1: "BPAK_KEY_PUB_SECP384r1", + _bpak.KEY_PUB_SECP521r1: "BPAK_KEY_PUB_SECP521r1", + _bpak.KEY_PUB_RSA4096: "BPAK_KEY_PUB_RSA4096", + } + + with _bpak.Package(filename, "rb") as pkg: + ks_meta = pkg.get_meta(_bpak.id("keystore-provider-id")) + ks_provider_id = struct.unpack("") + print("#include ") + print("\n") + + key_decorator = '__attribute__((section (".keystore_key"))) ' + header_decorator = '__attribute__((section (".keystore_header"))) ' + + key_index = 0 + for p in pkg.parts: + data = p.read_data() + + kind, key_data = _bpak.parse_public_key(data) + + if kind not in key_kind_names: + raise click.ClickException( + f"Unsupported key type ({kind}) for part 0x{p.id:x}" + ) + + print( + f"const struct bpak_key keystore_{safe_name}_key{key_index} " + f"{key_decorator if decorate else ''}=" + ) + print("{") + print(f" .id = 0x{p.id:x},") + print(f" .size = {len(key_data)},") + print(f" .kind = {key_kind_names[kind]},") + print(" .data =") + print(" {") + line = " " + for i, b in enumerate(key_data): + line += f"0x{b:02x}, " + if (i + 1) % 8 == 0: + print(line) + line = " " + if line.strip(): + print(line) + print(" },") + print("};\n") + key_index += 1 + + print( + f"const struct bpak_keystore keystore_{safe_name} " + f"{header_decorator if decorate else ''}=" + ) + print("{") + print(f" .id = 0x{ks_provider_id:x},") + print(f" .no_of_keys = {key_index},") + print(" .verified = true,") + print(" .keys =") + print(" {") + for i in range(key_index): + print( + f" (struct bpak_key *) &keystore_{safe_name}_key{i}," + ) + print(" },") + print("};") + return + + raise click.UsageError(f"Unknown generator: {generator}") + + +# --- transport --- + + +@cli.command() +@click.argument("filename", type=click.Path(exists=True)) +@click.option("-a", "--add", "add_mode", is_flag=True, help="Add transport meta") +@click.option( + "-E", "--encode", "encode_mode", is_flag=True, help="Encode for transport" +) +@click.option("-D", "--decode", "decode_mode", is_flag=True, help="Decode package") +@click.option( + "-r", + "--part-ref", + "--part", + "part_ref", + help="Part to add transport meta for (alias --part)", +) +@click.option("-e", "--encoder", help="Encoder algorithm name") +@click.option("-d", "--decoder", help="Decoder algorithm name") +@click.option("-o", "--output", type=click.Path(), help="Output file") +@click.option( + "-O", "--origin", type=click.Path(exists=True), help="Origin data file" +) +@_verbose_option +@_handle_error +def transport( + filename: str, + add_mode: bool, + encode_mode: bool, + decode_mode: bool, + part_ref: str | None, + encoder: str | None, + decoder: str | None, + output: str | None, + origin: str | None, + verbose: int, +) -> None: + """Transport encoding/decoding operations.""" + _apply_verbose(verbose) + + mode_count = sum([add_mode, encode_mode, decode_mode]) + if mode_count == 0: + raise click.UsageError("One of --add, --encode or --decode is required") + if mode_count > 1: + raise click.UsageError( + "Only one of --add, --encode or --decode is allowed" + ) + + if add_mode: + if not encoder or not decoder: + raise click.UsageError( + "--add requires --encoder and --decoder" + ) + with _bpak.Package(filename, "r+") as pkg: + ref_id = resolve_id(part_ref) if part_ref else 0 + encoder_id = resolve_id(encoder) + decoder_id = resolve_id(decoder) + _bpak.add_transport_meta(pkg, ref_id, encoder_id, decoder_id) + return + + # encode / decode share output requirement + if not output: + raise click.UsageError("--encode/--decode requires --output") + + op = _bpak.transport_encode if encode_mode else _bpak.transport_decode + + with _bpak.Package(filename, "rb") as pkg_in: + with _bpak.Package(output, "wb") as pkg_out: + if origin: + with _bpak.Package(origin, "rb") as pkg_origin: + op(pkg_in, pkg_out, pkg_origin) + else: + op(pkg_in, pkg_out) + + +# --- set --- + + +@cli.command("set") +@click.argument("filename", type=click.Path(exists=True)) +@click.option("-m", "--meta", "meta_name", help="Metadata to update") +@click.option("-s", "--from-string", help="String value") +@click.option( + "-e", + "--encoder", + type=click.Choice(["integer", "id"]), + help="Encoder for value", +) +@click.option("-k", "--key-id", help="Set key ID (name or 0x hex)") +@click.option("-i", "--keystore-id", help="Set keystore ID (name or 0x hex)") +@_verbose_option +@_handle_error +def set_cmd( + filename: str, + meta_name: str | None, + from_string: str | None, + encoder: str | None, + key_id: str | None, + keystore_id: str | None, + verbose: int, +) -> None: + """Update metadata or header fields in a bpak file.""" + _apply_verbose(verbose) + with _bpak.Package(filename, "r+") as pkg: + if key_id is not None: + pkg.key_id = resolve_id(key_id) + + if keystore_id is not None: + pkg.keystore_id = resolve_id(keystore_id) + + if meta_name and from_string: + meta_id = resolve_id(meta_name) + + if encoder: + new_data = encode_meta_value(from_string, encoder) + else: + new_data = from_string.encode("ascii") + b"\x00" + + try: + m = pkg.get_meta(meta_id) + if len(new_data) <= m.size: + m.raw_data = new_data + else: + part_ref = m.part_id_ref + m.delete() + pkg.add_meta(meta_id, part_ref, new_data) + except _bpak.Error: + pkg.add_meta(meta_id, 0, new_data) + + +# --- compare --- + +RED_CLR = "\033[31m" +YEL_CLR = "\033[33m" +GRN_CLR = "\033[32m" +NO_CLR = "\033[0m" + + +@cli.command() +@click.argument("file1", type=click.Path(exists=True)) +@click.argument("file2", type=click.Path(exists=True)) +@_verbose_option +@_handle_error +def compare(file1: str, file2: str, verbose: int) -> None: + """Compare two bpak files.""" + _apply_verbose(verbose) + with _bpak.Package(file1, "rb") as pkg1, _bpak.Package(file2, "rb") as pkg2: + click.echo("Metadata:") + meta1 = {(m.id, m.part_id_ref): m for m in pkg1.meta} + meta2 = {(m.id, m.part_id_ref): m for m in pkg2.meta} + + all_keys = sorted(set(meta1.keys()) | set(meta2.keys())) + for key in all_keys: + m1 = meta1.get(key) + m2 = meta2.get(key) + mid, mref = key + id_name = _bpak.id_to_string(mid) or "" + + if m1 and m2: + if m1.raw_data == m2.raw_data: + sym = "=" + clr = NO_CLR + else: + sym = "*" + clr = YEL_CLR + elif m1: + sym = "-" + clr = RED_CLR + else: + sym = "+" + clr = GRN_CLR + + click.echo( + f" {clr}{sym} {mid:08x} {id_name:<20s} " + f"ref={mref:08x}{NO_CLR}" + ) + + click.echo("\nParts:") + parts1 = {p.id: p for p in pkg1.parts} + parts2 = {p.id: p for p in pkg2.parts} + + all_ids = sorted(set(parts1.keys()) | set(parts2.keys())) + for pid in all_ids: + p1 = parts1.get(pid) + p2 = parts2.get(pid) + + if p1 and p2: + if p1.size == p2.size: + sym = "=" + clr = NO_CLR + else: + sym = "*" + clr = YEL_CLR + elif p1: + sym = "-" + clr = RED_CLR + else: + sym = "+" + clr = GRN_CLR + + size = (p1 or p2).size + click.echo(f" {clr}{sym} {pid:08x} size={size}{NO_CLR}") + + +# --- extract --- + + +@cli.command() +@click.argument("filename", type=click.Path(exists=True)) +@click.option("-p", "--part", "part_name", help="Extract part") +@click.option("-m", "--meta", "meta_name", help="Extract metadata") +@click.option("-o", "--output", type=click.Path(), help="Output file") +@click.option("-r", "--part-ref", help="Part reference for metadata") +@_verbose_option +@_handle_error +def extract( + filename: str, + part_name: str | None, + meta_name: str | None, + output: str | None, + part_ref: str | None, + verbose: int, +) -> None: + """Extract parts or metadata from a bpak file.""" + _apply_verbose(verbose) + if not part_name and not meta_name: + raise click.UsageError("Must specify --part or --meta") + if part_name and meta_name: + raise click.UsageError("Specify only one of --part or --meta") + + with _bpak.Package(filename, "rb") as pkg: + if part_name: + part_id = resolve_id(part_name) + if output: + pkg.extract_file(part_id, output) + else: + p = pkg.get_part(part_id) + data = p.read_data() + sys.stdout.buffer.write(data) + + elif meta_name: + meta_id = resolve_id(meta_name) + ref_id = resolve_id(part_ref) if part_ref else 0 + m = pkg.get_meta(meta_id, ref_id) + data = m.raw_data + + if output: + Path(output).write_bytes(data) + else: + sys.stdout.buffer.write(data) + + +# --- delete --- + + +@cli.command() +@click.argument("filename", type=click.Path(exists=True)) +@click.option("-p", "--part", "part_name", help="Part to delete") +@click.option("-a", "--all", "delete_all", is_flag=True, help="Delete all parts") +@click.option( + "-k", "--keep-meta", is_flag=True, help="Keep metadata when deleting parts" +) +@_verbose_option +@_handle_error +def delete( + filename: str, + part_name: str | None, + delete_all: bool, + keep_meta: bool, + verbose: int, +) -> None: + """Delete parts from a bpak file.""" + _apply_verbose(verbose) + if not part_name and not delete_all: + raise click.UsageError("Must specify --part or --all") + if part_name and delete_all: + raise click.UsageError("Specify only one of --part or --all") + + with _bpak.Package(filename, "r+") as pkg: + if delete_all: + pkg.delete_all_parts(keep_meta=keep_meta) + elif part_name: + part_id = resolve_id(part_name) + p = pkg.get_part(part_id) + p.delete(keep_meta=keep_meta) + + +if __name__ == "__main__": + cli() diff --git a/python/bpak/_helpers.py b/python/bpak/_helpers.py new file mode 100644 index 0000000..62bff72 --- /dev/null +++ b/python/bpak/_helpers.py @@ -0,0 +1,50 @@ +"""Helper utilities for the bpak CLI.""" + +from __future__ import annotations + +import struct +import uuid + +from . import ( + HASH_SHA256, + HASH_SHA384, + HASH_SHA512, + SIGN_PRIME256v1, + SIGN_RSA4096, + SIGN_SECP384r1, + SIGN_SECP521r1, + id as bpak_id, +) + +HASH_KIND_MAP: dict[str, int] = { + "sha256": HASH_SHA256, + "sha384": HASH_SHA384, + "sha512": HASH_SHA512, +} + +SIGN_KIND_MAP: dict[str, int] = { + "prime256v1": SIGN_PRIME256v1, + "secp384r1": SIGN_SECP384r1, + "secp521r1": SIGN_SECP521r1, + "rsa4096": SIGN_RSA4096, +} + + +def resolve_id(arg: str) -> int: + """Convert a name string or '0x...' hex literal to a bpak_id_t.""" + if arg.startswith("0x") or arg.startswith("0X"): + return int(arg, 16) + return bpak_id(arg) + + +def encode_meta_value(value: str, encoder: str) -> bytes: + """Encode a metadata value using the specified encoder.""" + if encoder == "integer": + return struct.pack("=8.0"], + entry_points={ + "console_scripts": ["bpak=bpak.__main__:cli"], + }, ) From 61f031b1542dee0016e678d6a77da1f0cff20986 Mon Sep 17 00:00:00 2001 From: Marten Svanfeldt Date: Tue, 21 Apr 2026 10:41:36 +0200 Subject: [PATCH 05/10] build: stage Python package into CMake build tree for ctest The Python package was restructured for pip install (commit 577abc2), but the CMake build kept producing a legacy monolithic `bpak.so`. That left `sys.path.insert("../python/"); import bpak` in the test_python_*.py harness unable to resolve the compiled module (`PyInit_bpak` was never exposed; the init function is `PyInit__bpak`), so ctest reported `ImportError: dynamic module does not define module export function` for every Python test on this branch. Stage the pure-Python package sources into ${CMAKE_CURRENT_BINARY_DIR}/bpak/ and build the CPython extension as `_bpak.so` alongside them, matching the `bpak._bpak` layout that setup.py already produces. Switches to Python_add_library(MODULE ...) for a proper Python extension target instead of a generic shared library. With this in place the existing Python tests run under ctest again, and the new test_python_cli.py picks up the same layout. --- python/CMakeLists.txt | 70 +++++++++++++++++++++++++++++++++++-------- 1 file changed, 58 insertions(+), 12 deletions(-) diff --git a/python/CMakeLists.txt b/python/CMakeLists.txt index 04ec206..aef985b 100644 --- a/python/CMakeLists.txt +++ b/python/CMakeLists.txt @@ -4,8 +4,55 @@ find_package(Python 3.6 COMPONENTS Interpreter Development ) -add_library( - ${PROJECT_NAME}-python-wrapper SHARED +set(_BPAK_PY_BUILD_DIR "${CMAKE_CURRENT_BINARY_DIR}/bpak") + +# Stage the pure-Python package layout inside the build tree so that +# `sys.path.insert(0, "../python/") ; import bpak` resolves to a complete +# package rooted at ${CMAKE_CURRENT_BINARY_DIR}/bpak/, with the compiled +# extension placed alongside __init__.py as bpak._bpak. +file(MAKE_DIRECTORY "${_BPAK_PY_BUILD_DIR}/_cli") + +set(_BPAK_PY_TOP_FILES + __init__.py + __main__.py + _helpers.py +) +set(_BPAK_PY_CLI_FILES + _cli/__init__.py + _cli/_common.py + _cli/add.py + _cli/compare.py + _cli/create.py + _cli/delete.py + _cli/extract.py + _cli/generate.py + _cli/set.py + _cli/show.py + _cli/sign.py + _cli/transport.py +) + +set(_BPAK_PY_STAGED_OUTPUTS) +foreach(_src IN LISTS _BPAK_PY_TOP_FILES _BPAK_PY_CLI_FILES) + set(_in "${CMAKE_CURRENT_SOURCE_DIR}/bpak/${_src}") + set(_out "${_BPAK_PY_BUILD_DIR}/${_src}") + add_custom_command( + OUTPUT "${_out}" + COMMAND ${CMAKE_COMMAND} -E copy "${_in}" "${_out}" + DEPENDS "${_in}" + COMMENT "Staging Python source: ${_src}" + ) + list(APPEND _BPAK_PY_STAGED_OUTPUTS "${_out}") +endforeach() + +add_custom_target(${PROJECT_NAME}-python-sources ALL + DEPENDS ${_BPAK_PY_STAGED_OUTPUTS}) + +# Build the CPython extension `bpak._bpak`. The init function in +# python_wrapper.c is PyInit__bpak, which Python expects from a module +# file named `_bpak`, so name the output accordingly. +Python_add_library( + ${PROJECT_NAME}-python-wrapper MODULE python_wrapper.c package.c meta.c @@ -17,21 +64,20 @@ target_include_directories(${PROJECT_NAME}-python-wrapper PRIVATE ${Python_INCLUDE_DIRS} ) -target_link_libraries(${PROJECT_NAME}-python-wrapper - ${Python_LIBRARIES} +target_link_libraries(${PROJECT_NAME}-python-wrapper PRIVATE ${PROJECT_NAME} ) -set_target_properties(${PROJECT_NAME}-python-wrapper - PROPERTIES VERSION ${PROJECT_VERSION}) -set_target_properties(${PROJECT_NAME}-python-wrapper - PROPERTIES SOVERSION ${PROJECT_VERSION_MAJOR}) - target_compile_options(${PROJECT_NAME}-python-wrapper PRIVATE -Wextra -pedantic -I${${PROJECT_NAME}_BINARY_DIR}/lib/ ) -set_target_properties(${PROJECT_NAME}-python-wrapper PROPERTIES PREFIX "") -set_target_properties(${PROJECT_NAME}-python-wrapper - PROPERTIES OUTPUT_NAME ${PROJECT_NAME}) +set_target_properties(${PROJECT_NAME}-python-wrapper PROPERTIES + OUTPUT_NAME "_bpak" + PREFIX "" + LIBRARY_OUTPUT_DIRECTORY "${_BPAK_PY_BUILD_DIR}" +) + +add_dependencies(${PROJECT_NAME}-python-wrapper + ${PROJECT_NAME}-python-sources) From c99065e6e0d4b00a55871813f05d6e15727e89d2 Mon Sep 17 00:00:00 2001 From: Marten Svanfeldt Date: Tue, 21 Apr 2026 10:42:07 +0200 Subject: [PATCH 06/10] python: rewrite CLI as idiomatic Click app Replace the 825-line argv-for-argv port of the C bpak binary with a split Click application under bpak._cli/. The Python CLI is no longer a mirror of the C argv; it is the best idiomatic shape for a Click tool, even at the cost of breaking compatibility with prior Python-bpak scripts. The C bpak binary is untouched. Shape (two-level command groups): bpak create FILE [--hash ...] [--signature ...] [--force] bpak compare FILE1 FILE2 bpak show FILE (full overview) bpak show meta FILE [ID] [--part-ref REF] bpak show part FILE ID [--hash] bpak show hash FILE [--binary] bpak add part FILE ID --from PATH [--no-hash] bpak add meta FILE ID (--from-string V | --from-file P) [--encoder ...] [--part-ref REF] bpak add key FILE ID --from PATH bpak add merkle FILE ID --from PATH bpak set meta FILE ID VALUE [--encoder ...] [--part-ref REF] bpak set header FILE [--key-id ID] [--keystore-id ID] bpak delete part FILE (ID | --all) [--keep-meta] bpak delete meta FILE ID [--part-ref REF] bpak extract part FILE ID [--output PATH] bpak extract meta FILE ID [--output PATH] [--part-ref REF] bpak sign FILE (--key PATH | --signature PATH) bpak verify FILE (--key PATH | --keystore PATH) bpak transport add FILE ID --encoder N --decoder N bpak transport encode FILE --output PATH [--origin PATH] bpak transport decode FILE --output PATH [--origin PATH] bpak generate id STRING bpak generate keystore FILE --name NAME [--decorate] Correctness fixes rolled in with the rewrite: - compare now diffs part contents via Package.part_sha256 on both sides (plus part-header fields), replacing the previous size-only check that reported same-size-different-content parts as equal. - show's full overview prints a single "Header hash" line; the old code mis-labelled the same Package.digest value as both header and payload. - sign / verify enforce exactly-one-of their key options; the previous CLI silently accepted neither being given. - verify opens the package read-only, so it works on read-only packages. - generate keystore rejects --name values that aren't valid C identifiers and validates the keystore-provider-id metadata length before unpacking. - Commands that write binary to stdout (extract part/meta, show hash --binary) refuse to run when stdout is a TTY, preventing terminal corruption. Shared plumbing in bpak/_cli/_common.py: - BpakId ParamType (replaces scattered resolve_id calls). - @open_package decorator centralises the Package open/close + error translation every command repeated by hand. - exactly_one_of / at_least_one_of / incompatible helpers use an is_set predicate so "--key-id 0" counts as provided, not missing. - install_verbose wires the _bpak log callback at the root group entry and tears it down via ctx.call_on_close; log output always goes to stderr so stdout stays clean for scripting. - binary_sink(path) centralises the TTY-refusal guard. - safe_c_identifier validates generate-keystore --name against [A-Za-z_][A-Za-z0-9_]*. _helpers.resolve_id now also parses bare decimal literals, so `--part-ref 0` means the integer 0 rather than CRC32("0"); this matches the C wrapper's part_id_ref default. __main__.py becomes a three-line shim. setup.py ships bpak._cli and repoints the console_scripts entry at bpak._cli:cli. --- python/bpak/__main__.py | 823 +--------------------------------- python/bpak/_cli/__init__.py | 69 +++ python/bpak/_cli/_common.py | 169 +++++++ python/bpak/_cli/add.py | 142 ++++++ python/bpak/_cli/compare.py | 73 +++ python/bpak/_cli/create.py | 42 ++ python/bpak/_cli/delete.py | 66 +++ python/bpak/_cli/extract.py | 73 +++ python/bpak/_cli/generate.py | 120 +++++ python/bpak/_cli/set.py | 87 ++++ python/bpak/_cli/show.py | 166 +++++++ python/bpak/_cli/sign.py | 73 +++ python/bpak/_cli/transport.py | 90 ++++ python/bpak/_helpers.py | 14 +- setup.py | 9 +- 15 files changed, 1190 insertions(+), 826 deletions(-) create mode 100644 python/bpak/_cli/__init__.py create mode 100644 python/bpak/_cli/_common.py create mode 100644 python/bpak/_cli/add.py create mode 100644 python/bpak/_cli/compare.py create mode 100644 python/bpak/_cli/create.py create mode 100644 python/bpak/_cli/delete.py create mode 100644 python/bpak/_cli/extract.py create mode 100644 python/bpak/_cli/generate.py create mode 100644 python/bpak/_cli/set.py create mode 100644 python/bpak/_cli/show.py create mode 100644 python/bpak/_cli/sign.py create mode 100644 python/bpak/_cli/transport.py diff --git a/python/bpak/__main__.py b/python/bpak/__main__.py index 1b41bb7..34e9aa6 100644 --- a/python/bpak/__main__.py +++ b/python/bpak/__main__.py @@ -1,825 +1,6 @@ -"""BPAK CLI - Bit Packer command line tool.""" - -from __future__ import annotations - -import functools -import os -import struct -import sys -from pathlib import Path - -import click - -from . import _bpak -from ._helpers import HASH_KIND_MAP, SIGN_KIND_MAP, encode_meta_value, resolve_id - -_verbose_level = 0 - - -def _log_callback(level: int, msg: str) -> None: - if level == 0: - click.echo(msg, nl=False, err=True) - elif level <= _verbose_level: - click.echo(msg, nl=False) - - -def _apply_verbose(level: int) -> None: - """Install or clear the bpak log callback for this command. - - Cleared on level==0 because _bpak stores the callback in a static - module-global; without clearing, a verbose call would leak logging - into later non-verbose calls in the same process. - """ - global _verbose_level - _verbose_level = level - if level > 0: - _bpak.set_log_func(_log_callback) - else: - _bpak.set_log_func(None) - - -def _print_version( - _ctx: click.Context, _param: click.Option, value: bool -) -> None: - if value: - try: - from importlib.metadata import version - - click.echo(f"BitPacker {version('bpak')}") - except Exception: - click.echo("BitPacker (unknown version)") - sys.exit(0) - - -def _handle_error(func): - """Decorator that catches bpak errors and converts to ClickException.""" - - @functools.wraps(func) - def wrapper(*args, **kwargs): - try: - return func(*args, **kwargs) - except _bpak.Error as e: - raise click.ClickException(str(e)) from e - - return wrapper - - -def _verbose_option(f): - return click.option("-v", "--verbose", count=True, help="Verbose output")(f) - - -@click.group() -@click.option( - "-V", - "--version", - is_flag=True, - is_eager=True, - expose_value=False, - callback=_print_version, - help="Display version and exit", -) -def cli() -> None: - """BPAK - Bit Packer.""" - - -# --- create --- - - -@cli.command() -@click.argument("filename", type=click.Path()) -@click.option( - "-H", - "--hash-kind", - type=click.Choice(list(HASH_KIND_MAP.keys())), - default="sha256", - help="Hash algorithm", -) -@click.option( - "-S", - "--signature-kind", - type=click.Choice(list(SIGN_KIND_MAP.keys())), - default="prime256v1", - help="Signature algorithm", -) -@click.option("-Y", "--force", is_flag=True, help="Overwrite without asking") -@_verbose_option -@_handle_error -def create( - filename: str, - hash_kind: str, - signature_kind: str, - force: bool, - verbose: int, -) -> None: - """Create an empty bpak file.""" - _apply_verbose(verbose) - if os.path.exists(filename) and not force: - if not click.confirm(f"File '{filename}' exists. Overwrite?"): - return - - with _bpak.Package(filename, "wb") as pkg: - pkg.hash_kind = HASH_KIND_MAP[hash_kind] - pkg.signature_kind = SIGN_KIND_MAP[signature_kind] - - -# --- add --- - - -@cli.command() -@click.argument("filename", type=click.Path(exists=True)) -@click.option("-p", "--part", "part_name", help="Add part with given name") -@click.option("-m", "--meta", "meta_name", help="Add metadata with given name") -@click.option( - "-f", "--from-file", type=click.Path(exists=True), help="Load data from file" -) -@click.option("-s", "--from-string", help="Load data from string") -@click.option( - "-e", - "--encoder", - type=click.Choice(["uuid", "integer", "id", "key", "merkle"]), - help="Encoder for data", -) -@click.option( - "-F", - "--set-flag", - type=click.Choice(["dont-hash"]), - help="Set flag on part", -) -@click.option("-r", "--part-ref", help="Reference part") -@_verbose_option -@_handle_error -def add( - filename: str, - part_name: str | None, - meta_name: str | None, - from_file: str | None, - from_string: str | None, - encoder: str | None, - set_flag: str | None, - part_ref: str | None, - verbose: int, -) -> None: - """Add parts or metadata to a bpak file.""" - _apply_verbose(verbose) - if not part_name and not meta_name: - raise click.UsageError("Must specify --part or --meta") - - with _bpak.Package(filename, "r+") as pkg: - if meta_name: - meta_id = resolve_id(meta_name) - ref_id = resolve_id(part_ref) if part_ref else 0 - - if encoder and from_string: - data = encode_meta_value(from_string, encoder) - elif from_string: - data = from_string.encode("ascii") + b"\x00" - elif from_file: - data = Path(from_file).read_bytes() - else: - raise click.UsageError( - "Must specify --from-string or --from-file for metadata" - ) - - if len(data) > _bpak.BPAK_METADATA_BYTES if hasattr(_bpak, 'BPAK_METADATA_BYTES') else len(data) > 1920: - raise click.ClickException("Metadata too large") - - pkg.add_meta(meta_id, ref_id, data) - - elif part_name: - flags = 0 - if set_flag == "dont-hash": - flags = _bpak.FLAG_EXCLUDE_FROM_HASH - - if encoder == "key": - if not from_file: - raise click.UsageError("--encoder key requires --from-file") - pkg.add_key(part_name, from_file, flags=flags) - elif encoder == "merkle": - if not from_file: - raise click.UsageError( - "--encoder merkle requires --from-file" - ) - pkg.add_file( - part_name, from_file, with_merkle_tree=True, flags=flags - ) - else: - if not from_file: - raise click.UsageError("--from-file required for parts") - pkg.add_file(part_name, from_file, flags=flags) - - -# --- show --- - - -def _flag_str(flags: int) -> str: - chars = list("--------") - if flags & _bpak.FLAG_EXCLUDE_FROM_HASH: - chars[0] = "h" - if flags & _bpak.FLAG_TRANSPORT: - chars[1] = "T" - return "".join(chars) - - -def _bin2hex(data: bytes) -> str: - return "".join(f"{b:02x}" for b in data) - - -@cli.command() -@click.argument("filename", type=click.Path(exists=True)) -@click.option("-m", "--meta", "meta_name", help="Show specific metadata") -@click.option("-p", "--part", "part_name", help="Show specific part") -@click.option("-P", "--part-hash", help="Show SHA256 hash of a part") -@click.option("-H", "--hash", "show_hash", is_flag=True, help="Show package hash") -@click.option( - "-B", "--binary-hash", is_flag=True, help="Output hash in binary form" -) -@_verbose_option -@_handle_error -def show( - filename: str, - meta_name: str | None, - part_name: str | None, - part_hash: str | None, - show_hash: bool, - binary_hash: bool, - verbose: int, -) -> None: - """Show information about a bpak file.""" - _apply_verbose(verbose) - - with _bpak.Package(filename, "r+") as pkg: - h_kind = pkg.hash_kind - s_kind = pkg.signature_kind - - if meta_name: - meta_id = resolve_id(meta_name) - # In C show.c:126-128, when -m is given, -p reinterprets as - # the part_id_ref filter on the metadata lookup. - ref_id = resolve_id(part_name) if part_name else None - - for m in pkg.meta: - if m.id == meta_id: - if ref_id is not None and m.part_id_ref != ref_id: - continue - s = _bpak.meta_to_string(pkg, m) - if s: - click.echo(s) - return - raise click.ClickException(f"Could not find meta '{meta_name}'") - - if part_name: - part_id = resolve_id(part_name) - for p in pkg.parts: - if p.id == part_id: - click.echo(f"Found 0x{p.id:x}, {p.size} bytes") - return - raise click.ClickException(f"Could not find part '{part_name}'") - - if part_hash: - part_hash_id = resolve_id(part_hash) - h = pkg.part_sha256(part_hash_id) - click.echo(_bin2hex(h)) - return - - if binary_hash: - digest = pkg.digest - if digest: - sys.stdout.buffer.write(digest) - return - - if show_hash: - digest = pkg.digest - if digest: - click.echo(_bin2hex(digest)) - return - - # Full display - click.echo(f"BPAK File: {filename}") - click.echo("") - click.echo(f"Hash: {_bpak.hash_kind_str(h_kind)}") - click.echo(f"Signature: {_bpak.signature_kind_str(s_kind)}") - click.echo(f"Key ID: {pkg.key_id:08x}") - click.echo(f"Keystore ID: {pkg.keystore_id:08x}") - - click.echo("\nMetadata:") - click.echo(" ID Size Meta ID Part Ref Data") - - for m in pkg.meta: - s = _bpak.meta_to_string(pkg, m) - if s is None: - s = "" - id_name = _bpak.id_to_string(m.id) - if id_name is None: - id_name = "" - ref_str = f"{m.part_id_ref:08x}" if m.part_id_ref else " " - click.echo( - f" {m.id:08x} {m.size:<3} {id_name:<20s} {ref_str} {s}" - ) - - click.echo("\nParts:") - click.echo( - " ID Size Z-pad Flags Transport Size" - ) - - for p in pkg.parts: - flags = _flag_str(p.flags) - ts = p.transport_size if p.flags & _bpak.FLAG_TRANSPORT else p.size - click.echo( - f" {p.id:08x} {p.size:<12} {p.pad_bytes:<3} {flags}" - f" {ts:<12}" - ) - - # Hashes - digest = pkg.digest - if digest: - click.echo(f"\nHeader hash: {_bin2hex(digest)}") - payload_digest = pkg.digest - if payload_digest: - click.echo(f"Payload hash: {_bin2hex(payload_digest)}") - - if verbose: - meta_size = sum(m.size for m in pkg.meta) - click.echo(f"Metadata usage: {meta_size}/1920 bytes") - click.echo(f"Transport size: {pkg.size} bytes") - click.echo(f"Installed size: {pkg.installed_size} bytes") - - -# --- sign --- - - -@cli.command() -@click.argument("filename", type=click.Path(exists=True)) -@click.option("-k", "--key", type=click.Path(exists=True), help="Sign using key") -@click.option( - "-f", - "--signature", - type=click.Path(exists=True), - help="Write pre-computed signature from file", -) -@_verbose_option -@_handle_error -def sign( - filename: str, key: str | None, signature: str | None, verbose: int -) -> None: - """Sign a bpak file.""" - _apply_verbose(verbose) - if not key and not signature: - raise click.UsageError("Must specify --key or --signature") - - with _bpak.Package(filename, "r+") as pkg: - if signature: - sig_data = Path(signature).read_bytes() - if len(sig_data) > 512: - raise click.ClickException( - f"Signature file too large ({len(sig_data)} > 512 bytes)" - ) - pkg.signature = sig_data - elif key: - pkg.sign(key) - - -# --- verify --- - - -@cli.command() -@click.argument("filename", type=click.Path(exists=True)) -@click.option( - "-k", "--key", type=click.Path(exists=True), help="Verify using public key" -) -@click.option( - "-K", - "--keystore", - type=click.Path(exists=True), - help="Verify using keystore bpak file", -) -@_verbose_option -@_handle_error -def verify( - filename: str, key: str | None, keystore: str | None, verbose: int -) -> None: - """Verify a bpak file signature.""" - _apply_verbose(verbose) - if not key and not keystore: - raise click.UsageError("Must specify --key or --keystore") - - with _bpak.Package(filename, "r+") as pkg: - if keystore: - pkg.verify_with_keystore(keystore) - elif key: - pkg.verify(key) - - click.echo("Verification OK") - - -# --- generate --- - - -@cli.command() -@click.argument("generator") -@click.argument("rest", nargs=-1) -@click.option("-n", "--name", help="Name for generated keystore") -@click.option("-d", "--decorate", is_flag=True, help="Add section attributes") -@_verbose_option -@_handle_error -def generate( - generator: str, - rest: tuple[str, ...], - name: str | None, - decorate: bool, - verbose: int, -) -> None: - """Generate code or translations. - - Generators: - id Translate string to bpak id - keystore -n NAME Generate C keystore from bpak file - """ - _apply_verbose(verbose) - - if generator == "id": - if len(rest) != 1: - raise click.UsageError("generate id requires one positional argument") - id_string = rest[0] - click.echo(f'id("{id_string}") = 0x{_bpak.id(id_string):08x}') - return - - if generator == "keystore": - if len(rest) != 1: - raise click.UsageError("generate keystore requires a filename") - filename = rest[0] - if not os.path.exists(filename): - raise click.UsageError(f"File not found: {filename}") - if not name: - raise click.UsageError("generate keystore requires --name") - - from importlib.metadata import version as pkg_version - - try: - ver = pkg_version("bpak") - except Exception: - ver = "unknown" - - key_kind_names = { - _bpak.KEY_PUB_PRIME256v1: "BPAK_KEY_PUB_PRIME256v1", - _bpak.KEY_PUB_SECP384r1: "BPAK_KEY_PUB_SECP384r1", - _bpak.KEY_PUB_SECP521r1: "BPAK_KEY_PUB_SECP521r1", - _bpak.KEY_PUB_RSA4096: "BPAK_KEY_PUB_RSA4096", - } - - with _bpak.Package(filename, "rb") as pkg: - ks_meta = pkg.get_meta(_bpak.id("keystore-provider-id")) - ks_provider_id = struct.unpack("") - print("#include ") - print("\n") - - key_decorator = '__attribute__((section (".keystore_key"))) ' - header_decorator = '__attribute__((section (".keystore_header"))) ' - - key_index = 0 - for p in pkg.parts: - data = p.read_data() - - kind, key_data = _bpak.parse_public_key(data) - - if kind not in key_kind_names: - raise click.ClickException( - f"Unsupported key type ({kind}) for part 0x{p.id:x}" - ) - - print( - f"const struct bpak_key keystore_{safe_name}_key{key_index} " - f"{key_decorator if decorate else ''}=" - ) - print("{") - print(f" .id = 0x{p.id:x},") - print(f" .size = {len(key_data)},") - print(f" .kind = {key_kind_names[kind]},") - print(" .data =") - print(" {") - line = " " - for i, b in enumerate(key_data): - line += f"0x{b:02x}, " - if (i + 1) % 8 == 0: - print(line) - line = " " - if line.strip(): - print(line) - print(" },") - print("};\n") - key_index += 1 - - print( - f"const struct bpak_keystore keystore_{safe_name} " - f"{header_decorator if decorate else ''}=" - ) - print("{") - print(f" .id = 0x{ks_provider_id:x},") - print(f" .no_of_keys = {key_index},") - print(" .verified = true,") - print(" .keys =") - print(" {") - for i in range(key_index): - print( - f" (struct bpak_key *) &keystore_{safe_name}_key{i}," - ) - print(" },") - print("};") - return - - raise click.UsageError(f"Unknown generator: {generator}") - - -# --- transport --- - - -@cli.command() -@click.argument("filename", type=click.Path(exists=True)) -@click.option("-a", "--add", "add_mode", is_flag=True, help="Add transport meta") -@click.option( - "-E", "--encode", "encode_mode", is_flag=True, help="Encode for transport" -) -@click.option("-D", "--decode", "decode_mode", is_flag=True, help="Decode package") -@click.option( - "-r", - "--part-ref", - "--part", - "part_ref", - help="Part to add transport meta for (alias --part)", -) -@click.option("-e", "--encoder", help="Encoder algorithm name") -@click.option("-d", "--decoder", help="Decoder algorithm name") -@click.option("-o", "--output", type=click.Path(), help="Output file") -@click.option( - "-O", "--origin", type=click.Path(exists=True), help="Origin data file" -) -@_verbose_option -@_handle_error -def transport( - filename: str, - add_mode: bool, - encode_mode: bool, - decode_mode: bool, - part_ref: str | None, - encoder: str | None, - decoder: str | None, - output: str | None, - origin: str | None, - verbose: int, -) -> None: - """Transport encoding/decoding operations.""" - _apply_verbose(verbose) - - mode_count = sum([add_mode, encode_mode, decode_mode]) - if mode_count == 0: - raise click.UsageError("One of --add, --encode or --decode is required") - if mode_count > 1: - raise click.UsageError( - "Only one of --add, --encode or --decode is allowed" - ) - - if add_mode: - if not encoder or not decoder: - raise click.UsageError( - "--add requires --encoder and --decoder" - ) - with _bpak.Package(filename, "r+") as pkg: - ref_id = resolve_id(part_ref) if part_ref else 0 - encoder_id = resolve_id(encoder) - decoder_id = resolve_id(decoder) - _bpak.add_transport_meta(pkg, ref_id, encoder_id, decoder_id) - return - - # encode / decode share output requirement - if not output: - raise click.UsageError("--encode/--decode requires --output") - - op = _bpak.transport_encode if encode_mode else _bpak.transport_decode - - with _bpak.Package(filename, "rb") as pkg_in: - with _bpak.Package(output, "wb") as pkg_out: - if origin: - with _bpak.Package(origin, "rb") as pkg_origin: - op(pkg_in, pkg_out, pkg_origin) - else: - op(pkg_in, pkg_out) - - -# --- set --- - - -@cli.command("set") -@click.argument("filename", type=click.Path(exists=True)) -@click.option("-m", "--meta", "meta_name", help="Metadata to update") -@click.option("-s", "--from-string", help="String value") -@click.option( - "-e", - "--encoder", - type=click.Choice(["integer", "id"]), - help="Encoder for value", -) -@click.option("-k", "--key-id", help="Set key ID (name or 0x hex)") -@click.option("-i", "--keystore-id", help="Set keystore ID (name or 0x hex)") -@_verbose_option -@_handle_error -def set_cmd( - filename: str, - meta_name: str | None, - from_string: str | None, - encoder: str | None, - key_id: str | None, - keystore_id: str | None, - verbose: int, -) -> None: - """Update metadata or header fields in a bpak file.""" - _apply_verbose(verbose) - with _bpak.Package(filename, "r+") as pkg: - if key_id is not None: - pkg.key_id = resolve_id(key_id) - - if keystore_id is not None: - pkg.keystore_id = resolve_id(keystore_id) - - if meta_name and from_string: - meta_id = resolve_id(meta_name) - - if encoder: - new_data = encode_meta_value(from_string, encoder) - else: - new_data = from_string.encode("ascii") + b"\x00" - - try: - m = pkg.get_meta(meta_id) - if len(new_data) <= m.size: - m.raw_data = new_data - else: - part_ref = m.part_id_ref - m.delete() - pkg.add_meta(meta_id, part_ref, new_data) - except _bpak.Error: - pkg.add_meta(meta_id, 0, new_data) - - -# --- compare --- - -RED_CLR = "\033[31m" -YEL_CLR = "\033[33m" -GRN_CLR = "\033[32m" -NO_CLR = "\033[0m" - - -@cli.command() -@click.argument("file1", type=click.Path(exists=True)) -@click.argument("file2", type=click.Path(exists=True)) -@_verbose_option -@_handle_error -def compare(file1: str, file2: str, verbose: int) -> None: - """Compare two bpak files.""" - _apply_verbose(verbose) - with _bpak.Package(file1, "rb") as pkg1, _bpak.Package(file2, "rb") as pkg2: - click.echo("Metadata:") - meta1 = {(m.id, m.part_id_ref): m for m in pkg1.meta} - meta2 = {(m.id, m.part_id_ref): m for m in pkg2.meta} - - all_keys = sorted(set(meta1.keys()) | set(meta2.keys())) - for key in all_keys: - m1 = meta1.get(key) - m2 = meta2.get(key) - mid, mref = key - id_name = _bpak.id_to_string(mid) or "" - - if m1 and m2: - if m1.raw_data == m2.raw_data: - sym = "=" - clr = NO_CLR - else: - sym = "*" - clr = YEL_CLR - elif m1: - sym = "-" - clr = RED_CLR - else: - sym = "+" - clr = GRN_CLR - - click.echo( - f" {clr}{sym} {mid:08x} {id_name:<20s} " - f"ref={mref:08x}{NO_CLR}" - ) - - click.echo("\nParts:") - parts1 = {p.id: p for p in pkg1.parts} - parts2 = {p.id: p for p in pkg2.parts} - - all_ids = sorted(set(parts1.keys()) | set(parts2.keys())) - for pid in all_ids: - p1 = parts1.get(pid) - p2 = parts2.get(pid) - - if p1 and p2: - if p1.size == p2.size: - sym = "=" - clr = NO_CLR - else: - sym = "*" - clr = YEL_CLR - elif p1: - sym = "-" - clr = RED_CLR - else: - sym = "+" - clr = GRN_CLR - - size = (p1 or p2).size - click.echo(f" {clr}{sym} {pid:08x} size={size}{NO_CLR}") - - -# --- extract --- - - -@cli.command() -@click.argument("filename", type=click.Path(exists=True)) -@click.option("-p", "--part", "part_name", help="Extract part") -@click.option("-m", "--meta", "meta_name", help="Extract metadata") -@click.option("-o", "--output", type=click.Path(), help="Output file") -@click.option("-r", "--part-ref", help="Part reference for metadata") -@_verbose_option -@_handle_error -def extract( - filename: str, - part_name: str | None, - meta_name: str | None, - output: str | None, - part_ref: str | None, - verbose: int, -) -> None: - """Extract parts or metadata from a bpak file.""" - _apply_verbose(verbose) - if not part_name and not meta_name: - raise click.UsageError("Must specify --part or --meta") - if part_name and meta_name: - raise click.UsageError("Specify only one of --part or --meta") - - with _bpak.Package(filename, "rb") as pkg: - if part_name: - part_id = resolve_id(part_name) - if output: - pkg.extract_file(part_id, output) - else: - p = pkg.get_part(part_id) - data = p.read_data() - sys.stdout.buffer.write(data) - - elif meta_name: - meta_id = resolve_id(meta_name) - ref_id = resolve_id(part_ref) if part_ref else 0 - m = pkg.get_meta(meta_id, ref_id) - data = m.raw_data - - if output: - Path(output).write_bytes(data) - else: - sys.stdout.buffer.write(data) - - -# --- delete --- - - -@cli.command() -@click.argument("filename", type=click.Path(exists=True)) -@click.option("-p", "--part", "part_name", help="Part to delete") -@click.option("-a", "--all", "delete_all", is_flag=True, help="Delete all parts") -@click.option( - "-k", "--keep-meta", is_flag=True, help="Keep metadata when deleting parts" -) -@_verbose_option -@_handle_error -def delete( - filename: str, - part_name: str | None, - delete_all: bool, - keep_meta: bool, - verbose: int, -) -> None: - """Delete parts from a bpak file.""" - _apply_verbose(verbose) - if not part_name and not delete_all: - raise click.UsageError("Must specify --part or --all") - if part_name and delete_all: - raise click.UsageError("Specify only one of --part or --all") - - with _bpak.Package(filename, "r+") as pkg: - if delete_all: - pkg.delete_all_parts(keep_meta=keep_meta) - elif part_name: - part_id = resolve_id(part_name) - p = pkg.get_part(part_id) - p.delete(keep_meta=keep_meta) +"""bpak CLI entry point.""" +from ._cli import cli if __name__ == "__main__": cli() diff --git a/python/bpak/_cli/__init__.py b/python/bpak/_cli/__init__.py new file mode 100644 index 0000000..1a90211 --- /dev/null +++ b/python/bpak/_cli/__init__.py @@ -0,0 +1,69 @@ +"""bpak CLI root group.""" + +from __future__ import annotations + +import sys + +import click + +from ._common import install_verbose + + +def _print_version(ctx: click.Context, _param: click.Option, value: bool) -> None: + if not value or ctx.resilient_parsing: + return + try: + from importlib.metadata import version + ver = version("bpak") + except Exception: + ver = "unknown" + click.echo(f"BitPacker {ver}") + ctx.exit(0) + + +@click.group(context_settings={"help_option_names": ["-h", "--help"]}) +@click.option( + "-V", + "--version", + is_flag=True, + is_eager=True, + expose_value=False, + callback=_print_version, + help="Display version and exit", +) +@click.option( + "-v", + "--verbose", + count=True, + help="Increase verbosity (repeatable)", +) +@click.pass_context +def cli(ctx: click.Context, verbose: int) -> None: + """BPAK - Bit Packer.""" + install_verbose(ctx, verbose) + + +# Wire subcommands. Imports are deferred here so a command file can import +# from ._common without a circular dependency on this module. +from . import create as _create # noqa: E402 +from . import compare as _compare # noqa: E402 +from . import show as _show # noqa: E402 +from . import add as _add # noqa: E402 +from . import set as _set # noqa: E402 +from . import delete as _delete # noqa: E402 +from . import extract as _extract # noqa: E402 +from . import sign as _sign # noqa: E402 +from . import transport as _transport # noqa: E402 +from . import generate as _generate # noqa: E402 + +cli.add_command(_create.create) +cli.add_command(_compare.compare) +cli.add_command(_show.show) +cli.add_command(_add.add) +cli.add_command(_set.set_) +cli.add_command(_delete.delete) +cli.add_command(_extract.extract) +cli.add_command(_sign.sign) +cli.add_command(_sign.verify) +cli.add_command(_transport.transport) +cli.add_command(_generate.generate) diff --git a/python/bpak/_cli/_common.py b/python/bpak/_cli/_common.py new file mode 100644 index 0000000..facc4cc --- /dev/null +++ b/python/bpak/_cli/_common.py @@ -0,0 +1,169 @@ +"""Shared plumbing for the bpak CLI.""" + +from __future__ import annotations + +import functools +import re +import sys +from typing import Any, Callable, Iterable + +import click + +from .. import _bpak +from .._helpers import resolve_id + +BPAK_METADATA_BYTES = getattr(_bpak, "BPAK_METADATA_BYTES", 1920) +BPAK_MAX_SIGNATURE_BYTES = getattr(_bpak, "BPAK_MAX_SIGNATURE_BYTES", 512) + +_C_IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") + + +class BpakId(click.ParamType): + name = "bpak_id" + + def convert(self, value, param, ctx): + if isinstance(value, int): + return value + try: + return resolve_id(value) + except Exception as exc: + self.fail(f"{value!r} is not a valid bpak id ({exc})", param, ctx) + + +BPAK_ID = BpakId() + + +def _log_callback_factory(ctx: click.Context) -> Callable[[int, str], None]: + def _cb(level: int, msg: str) -> None: + v = ctx.obj.get("verbose", 0) + if level == 0 or level <= v: + click.echo(msg, nl=False, err=True) + return _cb + + +def install_verbose(ctx: click.Context, verbose: int) -> None: + """Wire the _bpak log callback for this invocation and tear it down on exit.""" + ctx.ensure_object(dict) + ctx.obj["verbose"] = verbose + if verbose > 0: + _bpak.set_log_func(_log_callback_factory(ctx)) + else: + _bpak.set_log_func(None) + ctx.call_on_close(lambda: _bpak.set_log_func(None)) + + +def open_package(mode: str = "rb"): + """Decorator that opens the package file, passes pkg as first arg, closes it, + and translates _bpak.Error to ClickException. + + The decorated callback must have signature (pkg, ...); Click's own argument + injection keeps working for everything after pkg. + """ + + def decorator(func: Callable[..., Any]) -> Callable[..., Any]: + @functools.wraps(func) + def wrapper(filename: str, *args: Any, **kwargs: Any) -> Any: + try: + with _bpak.Package(filename, mode) as pkg: + return func(pkg, *args, **kwargs) + except _bpak.Error as exc: + raise click.ClickException(str(exc)) from exc + return wrapper + return decorator + + +def handle_bpak_errors(func: Callable[..., Any]) -> Callable[..., Any]: + """Translate _bpak.Error to ClickException for commands that manage their + own Package open/close (e.g. commands that open two packages).""" + @functools.wraps(func) + def wrapper(*args: Any, **kwargs: Any) -> Any: + try: + return func(*args, **kwargs) + except _bpak.Error as exc: + raise click.ClickException(str(exc)) from exc + return wrapper + + +def _is_set(value: Any) -> bool: + """Is this parsed option "set" for validation purposes? + + Accept 0 / "" / False-only-if-it-came-from-an-explicit-false as "set", + since Click only yields the default otherwise. A `None` means the option + was never provided; a `False` bool flag is likewise unset (the absence + of an `is_flag=True` flag). + """ + if value is None: + return False + if value is False: + return False + return True + + +def exactly_one_of(values: dict[str, Any]) -> str: + """Return the one option name whose value is set, or raise UsageError.""" + set_names = [name for name, v in values.items() if _is_set(v)] + if len(set_names) == 0: + raise click.UsageError( + f"one of {', '.join(values.keys())} is required" + ) + if len(set_names) > 1: + raise click.UsageError( + f"only one of {', '.join(values.keys())} is allowed " + f"(got: {', '.join(set_names)})" + ) + return set_names[0] + + +def at_least_one_of(values: dict[str, Any]) -> None: + if not any(_is_set(v) for v in values.values()): + raise click.UsageError( + f"at least one of {', '.join(values.keys())} is required" + ) + + +def incompatible(values: dict[str, Any]) -> None: + set_names = [k for k, v in values.items() if _is_set(v)] + if len(set_names) > 1: + raise click.UsageError( + f"{', '.join(values.keys())} are mutually exclusive " + f"(got: {', '.join(set_names)})" + ) + + +def binary_sink(output_path: str | None): + """Return a writable binary stream. + + - output_path given: file opened for writing (caller is responsible for closing). + - output_path None: sys.stdout.buffer, but only when stdout is non-TTY; + otherwise UsageError to prevent terminal corruption. + """ + if output_path is not None: + return open(output_path, "wb") + if sys.stdout.isatty(): + raise click.UsageError( + "refusing to write binary data to a terminal; " + "redirect stdout or pass --output PATH" + ) + return sys.stdout.buffer + + +def safe_c_identifier(name: str) -> str: + if not _C_IDENTIFIER_RE.match(name): + raise click.UsageError( + f"{name!r} is not a valid C identifier " + "(expected [A-Za-z_][A-Za-z0-9_]*)" + ) + return name + + +def bin2hex(data: bytes) -> str: + return data.hex() + + +def flag_str(flags: int) -> str: + chars = list("--------") + if flags & _bpak.FLAG_EXCLUDE_FROM_HASH: + chars[0] = "h" + if flags & _bpak.FLAG_TRANSPORT: + chars[1] = "T" + return "".join(chars) diff --git a/python/bpak/_cli/add.py b/python/bpak/_cli/add.py new file mode 100644 index 0000000..1936919 --- /dev/null +++ b/python/bpak/_cli/add.py @@ -0,0 +1,142 @@ +"""`bpak add` group — add part, meta, key, merkle to a package.""" + +from __future__ import annotations + +from pathlib import Path + +import click + +from .. import _bpak +from .._helpers import encode_meta_value +from ._common import ( + BPAK_ID, + BPAK_METADATA_BYTES, + exactly_one_of, + handle_bpak_errors, + open_package, +) + + +@click.group() +def add() -> None: + """Add content to a bpak file.""" + + +@add.command("part") +@click.argument("filename", type=click.Path(exists=True, dir_okay=False)) +@click.argument("id_", metavar="ID") +@click.option( + "--from", + "from_path", + type=click.Path(exists=True, dir_okay=False), + required=True, + help="Path to the source file whose bytes become the part contents", +) +@click.option( + "--no-hash", + is_flag=True, + help="Exclude this part from the package hash", +) +@handle_bpak_errors +@open_package("r+") +def add_part(pkg, id_: str, from_path: str, no_hash: bool) -> None: + """Add a raw part from a file. + + The ID is the part's symbolic name (e.g. "rootfs"); the underlying + library hashes it into a 32-bit ID. + """ + flags = _bpak.FLAG_EXCLUDE_FROM_HASH if no_hash else 0 + pkg.add_file(id_, from_path, flags=flags) + + +@add.command("meta") +@click.argument("filename", type=click.Path(exists=True, dir_okay=False)) +@click.argument("id_", metavar="ID", type=BPAK_ID) +@click.option( + "--from-string", + "from_string", + help="Value as a string (null-terminated by default, or passed through an encoder)", +) +@click.option( + "--from-file", + "from_file", + type=click.Path(exists=True, dir_okay=False), + help="Read the raw metadata bytes from this file", +) +@click.option( + "--encoder", + type=click.Choice(["uuid", "integer", "id"]), + help="Interpret --from-string as this type before storing", +) +@click.option( + "--part-ref", + "part_ref", + type=BPAK_ID, + default=0, + show_default=True, + help="Associate this metadata with the given part", +) +@handle_bpak_errors +@open_package("r+") +def add_meta( + pkg, + id_: int, + from_string: str | None, + from_file: str | None, + encoder: str | None, + part_ref: int, +) -> None: + """Add a metadata entry.""" + exactly_one_of({"--from-string": from_string, "--from-file": from_file}) + + if encoder and from_file: + raise click.UsageError("--encoder only applies with --from-string") + + if from_string is not None: + if encoder: + data = encode_meta_value(from_string, encoder) + else: + data = from_string.encode("ascii") + b"\x00" + else: + data = Path(from_file).read_bytes() + + if len(data) > BPAK_METADATA_BYTES: + raise click.ClickException( + f"metadata value too large ({len(data)} > {BPAK_METADATA_BYTES} bytes)" + ) + + pkg.add_meta(id_, part_ref, data) + + +@add.command("key") +@click.argument("filename", type=click.Path(exists=True, dir_okay=False)) +@click.argument("id_", metavar="ID") +@click.option( + "--from", + "from_path", + type=click.Path(exists=True, dir_okay=False), + required=True, + help="Path to the public key file to embed", +) +@handle_bpak_errors +@open_package("r+") +def add_key(pkg, id_: str, from_path: str) -> None: + """Embed a public key as a part (ID is the key's symbolic name).""" + pkg.add_key(id_, from_path) + + +@add.command("merkle") +@click.argument("filename", type=click.Path(exists=True, dir_okay=False)) +@click.argument("id_", metavar="ID") +@click.option( + "--from", + "from_path", + type=click.Path(exists=True, dir_okay=False), + required=True, + help="Source file to hash into a merkle tree part", +) +@handle_bpak_errors +@open_package("r+") +def add_merkle(pkg, id_: str, from_path: str) -> None: + """Add a part with an accompanying merkle tree (ID is the part's symbolic name).""" + pkg.add_file(id_, from_path, with_merkle_tree=True) diff --git a/python/bpak/_cli/compare.py b/python/bpak/_cli/compare.py new file mode 100644 index 0000000..b8cb59e --- /dev/null +++ b/python/bpak/_cli/compare.py @@ -0,0 +1,73 @@ +"""`bpak compare` — diff two bpak files.""" + +from __future__ import annotations + +import click + +from .. import _bpak +from ._common import handle_bpak_errors + + +@click.command() +@click.argument("file1", type=click.Path(exists=True, dir_okay=False)) +@click.argument("file2", type=click.Path(exists=True, dir_okay=False)) +@handle_bpak_errors +def compare(file1: str, file2: str) -> None: + """Compare two bpak files: metadata, part headers, and part contents.""" + with _bpak.Package(file1, "rb") as pkg1, _bpak.Package(file2, "rb") as pkg2: + click.echo("Metadata:") + meta1 = {(m.id, m.part_id_ref): m for m in pkg1.meta} + meta2 = {(m.id, m.part_id_ref): m for m in pkg2.meta} + for key in sorted(set(meta1) | set(meta2)): + mid, mref = key + m1 = meta1.get(key) + m2 = meta2.get(key) + id_name = _bpak.id_to_string(mid) or "" + if m1 and m2: + if m1.raw_data == m2.raw_data: + sym, color = "=", None + else: + sym, color = "*", "yellow" + elif m1: + sym, color = "-", "red" + else: + sym, color = "+", "green" + click.secho( + f" {sym} {mid:08x} {id_name:<20s} ref={mref:08x}", + fg=color, + ) + + click.echo("\nParts:") + parts1 = {p.id: p for p in pkg1.parts} + parts2 = {p.id: p for p in pkg2.parts} + for pid in sorted(set(parts1) | set(parts2)): + p1 = parts1.get(pid) + p2 = parts2.get(pid) + id_name = _bpak.id_to_string(pid) or "" + if p1 and p2: + same = _parts_equal(pkg1, pkg2, p1, p2) + sym, color = ("=", None) if same else ("*", "yellow") + elif p1: + sym, color = "-", "red" + else: + sym, color = "+", "green" + size = (p1 or p2).size + click.secho( + f" {sym} {pid:08x} {id_name:<20s} size={size}", + fg=color, + ) + + +def _parts_equal(pkg1, pkg2, p1, p2) -> bool: + """Compare part-header fields and content via SHA-256.""" + if ( + p1.size != p2.size + or p1.pad_bytes != p2.pad_bytes + or p1.flags != p2.flags + or p1.transport_size != p2.transport_size + ): + return False + try: + return pkg1.part_sha256(p1.id) == pkg2.part_sha256(p2.id) + except _bpak.Error: + return False diff --git a/python/bpak/_cli/create.py b/python/bpak/_cli/create.py new file mode 100644 index 0000000..523e2a4 --- /dev/null +++ b/python/bpak/_cli/create.py @@ -0,0 +1,42 @@ +"""`bpak create` — create a new empty package.""" + +from __future__ import annotations + +import os + +import click + +from .. import _bpak +from .._helpers import HASH_KIND_MAP, SIGN_KIND_MAP +from ._common import handle_bpak_errors + + +@click.command() +@click.argument("filename", type=click.Path()) +@click.option( + "--hash", + "hash_kind", + type=click.Choice(list(HASH_KIND_MAP.keys())), + default="sha256", + show_default=True, + help="Hash algorithm", +) +@click.option( + "--signature", + "signature_kind", + type=click.Choice(list(SIGN_KIND_MAP.keys())), + default="prime256v1", + show_default=True, + help="Signature algorithm", +) +@click.option("--force", is_flag=True, help="Overwrite without asking") +@handle_bpak_errors +def create(filename: str, hash_kind: str, signature_kind: str, force: bool) -> None: + """Create a new empty bpak file.""" + if os.path.exists(filename) and not force: + if not click.confirm(f"File '{filename}' exists. Overwrite?"): + return + + with _bpak.Package(filename, "wb") as pkg: + pkg.hash_kind = HASH_KIND_MAP[hash_kind] + pkg.signature_kind = SIGN_KIND_MAP[signature_kind] diff --git a/python/bpak/_cli/delete.py b/python/bpak/_cli/delete.py new file mode 100644 index 0000000..60e475a --- /dev/null +++ b/python/bpak/_cli/delete.py @@ -0,0 +1,66 @@ +"""`bpak delete` group — remove parts or metadata.""" + +from __future__ import annotations + +import click + +from ._common import ( + BPAK_ID, + exactly_one_of, + handle_bpak_errors, + open_package, +) + + +@click.group() +def delete() -> None: + """Delete parts or metadata from a bpak file.""" + + +@delete.command("part") +@click.argument("filename", type=click.Path(exists=True, dir_okay=False)) +@click.argument("id_", metavar="[ID]", type=BPAK_ID, required=False) +@click.option( + "--all", + "delete_all", + is_flag=True, + help="Delete every part in the package", +) +@click.option( + "--keep-meta", + is_flag=True, + help="Keep metadata entries that reference the deleted parts", +) +@handle_bpak_errors +@open_package("r+") +def delete_part( + pkg, id_: int | None, delete_all: bool, keep_meta: bool +) -> None: + """Delete a single part, or --all of them.""" + choice = exactly_one_of( + {"ID": id_ if id_ is not None else None, "--all": delete_all} + ) + if choice == "--all": + pkg.delete_all_parts(keep_meta=keep_meta) + else: + p = pkg.get_part(id_) + p.delete(keep_meta=keep_meta) + + +@delete.command("meta") +@click.argument("filename", type=click.Path(exists=True, dir_okay=False)) +@click.argument("id_", metavar="ID", type=BPAK_ID) +@click.option( + "--part-ref", + "part_ref", + type=BPAK_ID, + default=0, + show_default=True, + help="Pick the meta entry associated with this part", +) +@handle_bpak_errors +@open_package("r+") +def delete_meta(pkg, id_: int, part_ref: int) -> None: + """Delete a metadata entry.""" + m = pkg.get_meta(id_, part_ref) + m.delete() diff --git a/python/bpak/_cli/extract.py b/python/bpak/_cli/extract.py new file mode 100644 index 0000000..67fbf07 --- /dev/null +++ b/python/bpak/_cli/extract.py @@ -0,0 +1,73 @@ +"""`bpak extract` group — write parts or metadata out of a package.""" + +from __future__ import annotations + +import click + +from ._common import ( + BPAK_ID, + binary_sink, + handle_bpak_errors, + open_package, +) + + +@click.group() +def extract() -> None: + """Extract parts or metadata from a bpak file.""" + + +@extract.command("part") +@click.argument("filename", type=click.Path(exists=True, dir_okay=False)) +@click.argument("id_", metavar="ID", type=BPAK_ID) +@click.option( + "-o", + "--output", + type=click.Path(dir_okay=False), + help="Write to PATH; default is stdout (refused when stdout is a TTY)", +) +@handle_bpak_errors +@open_package("rb") +def extract_part(pkg, id_: int, output: str | None) -> None: + """Extract a part.""" + if output is not None: + pkg.extract_file(id_, output) + return + p = pkg.get_part(id_) + sink = binary_sink(None) + sink.write(p.read_data()) + sink.flush() + + +@extract.command("meta") +@click.argument("filename", type=click.Path(exists=True, dir_okay=False)) +@click.argument("id_", metavar="ID", type=BPAK_ID) +@click.option( + "-o", + "--output", + type=click.Path(dir_okay=False), + help="Write to PATH; default is stdout (refused when stdout is a TTY)", +) +@click.option( + "--part-ref", + "part_ref", + type=BPAK_ID, + default=0, + show_default=True, + help="Pick the meta entry associated with this part", +) +@handle_bpak_errors +@open_package("rb") +def extract_meta( + pkg, id_: int, output: str | None, part_ref: int +) -> None: + """Extract a metadata value.""" + m = pkg.get_meta(id_, part_ref) + data = m.raw_data + if output is not None: + with open(output, "wb") as f: + f.write(data) + else: + sink = binary_sink(None) + sink.write(data) + sink.flush() diff --git a/python/bpak/_cli/generate.py b/python/bpak/_cli/generate.py new file mode 100644 index 0000000..74dc1b7 --- /dev/null +++ b/python/bpak/_cli/generate.py @@ -0,0 +1,120 @@ +"""`bpak generate` group — id and keystore code generation.""" + +from __future__ import annotations + +import struct + +import click + +from .. import _bpak +from ._common import handle_bpak_errors, safe_c_identifier + + +@click.group() +def generate() -> None: + """Code and ID generation utilities.""" + + +@generate.command("id") +@click.argument("string") +def generate_id(string: str) -> None: + """Translate a string to its bpak id (32-bit CRC).""" + click.echo(f'id("{string}") = 0x{_bpak.id(string):08x}') + + +_KEY_KIND_NAMES = { + _bpak.KEY_PUB_PRIME256v1: "BPAK_KEY_PUB_PRIME256v1", + _bpak.KEY_PUB_SECP384r1: "BPAK_KEY_PUB_SECP384r1", + _bpak.KEY_PUB_SECP521r1: "BPAK_KEY_PUB_SECP521r1", + _bpak.KEY_PUB_RSA4096: "BPAK_KEY_PUB_RSA4096", +} + + +@generate.command("keystore") +@click.argument("filename", type=click.Path(exists=True, dir_okay=False)) +@click.option( + "--name", + required=True, + help="C identifier prefix for the generated keystore symbols", +) +@click.option( + "--decorate", + is_flag=True, + help="Add .keystore_key/.keystore_header section attributes", +) +@handle_bpak_errors +def generate_keystore(filename: str, name: str, decorate: bool) -> None: + """Emit a C keystore source file from a bpak keystore package.""" + safe = safe_c_identifier(name) + + try: + from importlib.metadata import version as pkg_version + ver = pkg_version("bpak") + except Exception: + ver = "unknown" + + with _bpak.Package(filename, "rb") as pkg: + ks_meta = pkg.get_meta(_bpak.id("keystore-provider-id")) + raw = ks_meta.raw_data + if len(raw) < 4: + raise click.ClickException( + "keystore-provider-id metadata is too short " + f"({len(raw)} bytes; need >= 4)" + ) + ks_provider_id = struct.unpack("") + out("#include ") + out("") + + key_decorator = '__attribute__((section (".keystore_key"))) ' + header_decorator = '__attribute__((section (".keystore_header"))) ' + + key_index = 0 + for p in pkg.parts: + data = p.read_data() + kind, key_data = _bpak.parse_public_key(data) + if kind not in _KEY_KIND_NAMES: + raise click.ClickException( + f"Unsupported key type ({kind}) for part 0x{p.id:x}" + ) + + out( + f"const struct bpak_key keystore_{safe}_key{key_index} " + f"{key_decorator if decorate else ''}=" + ) + out("{") + out(f" .id = 0x{p.id:x},") + out(f" .size = {len(key_data)},") + out(f" .kind = {_KEY_KIND_NAMES[kind]},") + out(" .data =") + out(" {") + line = " " + for i, b in enumerate(key_data): + line += f"0x{b:02x}, " + if (i + 1) % 8 == 0: + out(line) + line = " " + if line.strip(): + out(line) + out(" },") + out("};") + out("") + key_index += 1 + + out( + f"const struct bpak_keystore keystore_{safe} " + f"{header_decorator if decorate else ''}=" + ) + out("{") + out(f" .id = 0x{ks_provider_id:x},") + out(f" .no_of_keys = {key_index},") + out(" .verified = true,") + out(" .keys =") + out(" {") + for i in range(key_index): + out(f" (struct bpak_key *) &keystore_{safe}_key{i},") + out(" },") + out("};") diff --git a/python/bpak/_cli/set.py b/python/bpak/_cli/set.py new file mode 100644 index 0000000..705b9d7 --- /dev/null +++ b/python/bpak/_cli/set.py @@ -0,0 +1,87 @@ +"""`bpak set` group — update metadata or header fields.""" + +from __future__ import annotations + +import click + +from .. import _bpak +from .._helpers import encode_meta_value +from ._common import ( + BPAK_ID, + at_least_one_of, + handle_bpak_errors, + open_package, +) + + +@click.group("set") +def set_() -> None: + """Update a bpak file.""" + + +@set_.command("meta") +@click.argument("filename", type=click.Path(exists=True, dir_okay=False)) +@click.argument("id_", metavar="ID", type=BPAK_ID) +@click.argument("value") +@click.option( + "--encoder", + type=click.Choice(["integer", "id"]), + help="Interpret VALUE through this encoder before writing", +) +@click.option( + "--part-ref", + "part_ref", + type=BPAK_ID, + default=0, + show_default=True, + help="Pick the meta entry associated with this part", +) +@handle_bpak_errors +@open_package("r+") +def set_meta( + pkg, id_: int, value: str, encoder: str | None, part_ref: int +) -> None: + """Update an existing metadata entry, or create it if absent.""" + if encoder: + new_data = encode_meta_value(value, encoder) + else: + new_data = value.encode("ascii") + b"\x00" + + try: + m = pkg.get_meta(id_, part_ref) + except _bpak.Error: + m = None + + if m is not None: + if len(new_data) <= m.size: + m.raw_data = new_data + else: + m.delete() + pkg.add_meta(id_, part_ref, new_data) + else: + pkg.add_meta(id_, part_ref, new_data) + + +@set_.command("header") +@click.argument("filename", type=click.Path(exists=True, dir_okay=False)) +@click.option( + "--key-id", + "key_id", + type=BPAK_ID, + help="Set the package's key_id header field", +) +@click.option( + "--keystore-id", + "keystore_id", + type=BPAK_ID, + help="Set the package's keystore_id header field", +) +@handle_bpak_errors +@open_package("r+") +def set_header(pkg, key_id: int | None, keystore_id: int | None) -> None: + """Update header fields (key-id, keystore-id).""" + at_least_one_of({"--key-id": key_id, "--keystore-id": keystore_id}) + if key_id is not None: + pkg.key_id = key_id + if keystore_id is not None: + pkg.keystore_id = keystore_id diff --git a/python/bpak/_cli/show.py b/python/bpak/_cli/show.py new file mode 100644 index 0000000..3c93edf --- /dev/null +++ b/python/bpak/_cli/show.py @@ -0,0 +1,166 @@ +"""`bpak show` group — overview, meta, part, hash.""" + +from __future__ import annotations + +import click + +from .. import _bpak +from ._common import ( + BPAK_ID, + BPAK_METADATA_BYTES, + bin2hex, + binary_sink, + flag_str, + handle_bpak_errors, + open_package, +) + + +class _ShowGroup(click.Group): + """Dispatch ``bpak show FILE`` to the (hidden) summary subcommand so the + user doesn't have to type ``bpak show summary FILE`` explicitly.""" + + def resolve_command(self, ctx, args): + if args and args[0] not in self.commands and not args[0].startswith("-"): + args = ["summary", *args] + return super().resolve_command(ctx, args) + + +@click.group(cls=_ShowGroup) +def show() -> None: + """Show information about a bpak file.""" + + +@show.command("summary", hidden=True) +@click.argument("filename", type=click.Path(exists=True, dir_okay=False)) +@click.pass_context +@handle_bpak_errors +def show_summary(ctx: click.Context, filename: str) -> None: + """Print a full overview of a bpak file.""" + verbose = (ctx.obj or {}).get("verbose", 0) + with _bpak.Package(filename, "rb") as pkg: + click.echo(f"BPAK File: {filename}") + click.echo("") + click.echo(f"Hash: {_bpak.hash_kind_str(pkg.hash_kind)}") + click.echo(f"Signature: {_bpak.signature_kind_str(pkg.signature_kind)}") + click.echo(f"Key ID: {pkg.key_id:08x}") + click.echo(f"Keystore ID: {pkg.keystore_id:08x}") + + click.echo("\nMetadata:") + click.echo(" ID Size Meta ID Part Ref Data") + for m in pkg.meta: + s = _bpak.meta_to_string(pkg, m) or "" + id_name = _bpak.id_to_string(m.id) or "" + ref_str = f"{m.part_id_ref:08x}" if m.part_id_ref else " " + click.echo( + f" {m.id:08x} {m.size:<3} {id_name:<20s} {ref_str} {s}" + ) + + click.echo("\nParts:") + click.echo( + " ID Size Z-pad Flags Transport Size" + ) + for p in pkg.parts: + flags = flag_str(p.flags) + ts = p.transport_size if p.flags & _bpak.FLAG_TRANSPORT else p.size + click.echo( + f" {p.id:08x} {p.size:<12} {p.pad_bytes:<3} {flags}" + f" {ts:<12}" + ) + + digest = pkg.digest + if digest: + click.echo(f"\nHeader hash: {bin2hex(digest)}") + + if verbose: + meta_size = sum(m.size for m in pkg.meta) + click.echo( + f"Metadata usage: {meta_size}/{BPAK_METADATA_BYTES} bytes" + ) + click.echo(f"Transport size: {pkg.size} bytes") + click.echo(f"Installed size: {pkg.installed_size} bytes") + + +@show.command("meta") +@click.argument("filename", type=click.Path(exists=True, dir_okay=False)) +@click.argument("id_", metavar="[ID]", type=BPAK_ID, required=False) +@click.option( + "--part-ref", + "part_ref", + type=BPAK_ID, + default=None, + help="Filter by part_id_ref; omit to list every ref. " + "Use 0 to pick unassociated/global metadata entries.", +) +@handle_bpak_errors +@open_package("rb") +def show_meta(pkg, id_: int | None, part_ref: int | None) -> None: + """Show one metadata entry, or list all of them.""" + found = False + for m in pkg.meta: + if id_ is not None and m.id != id_: + continue + if part_ref is not None and m.part_id_ref != part_ref: + continue + found = True + s = _bpak.meta_to_string(pkg, m) or "" + id_name = _bpak.id_to_string(m.id) or "" + ref_str = f"{m.part_id_ref:08x}" if m.part_id_ref else " " + click.echo( + f"{m.id:08x} {id_name:<20s} ref={ref_str} size={m.size} {s}" + ) + if id_ is not None and not found: + if part_ref is not None: + raise click.ClickException( + f"no matching meta 0x{id_:08x} with part_ref 0x{part_ref:08x}" + ) + raise click.ClickException(f"no matching meta 0x{id_:08x}") + + +@show.command("part") +@click.argument("filename", type=click.Path(exists=True, dir_okay=False)) +@click.argument("id_", metavar="ID", type=BPAK_ID) +@click.option( + "--hash", + "show_part_hash", + is_flag=True, + help="Print the SHA-256 hash of the part instead of its summary", +) +@handle_bpak_errors +@open_package("rb") +def show_part(pkg, id_: int, show_part_hash: bool) -> None: + """Show a single part.""" + if show_part_hash: + click.echo(bin2hex(pkg.part_sha256(id_))) + return + p = pkg.get_part(id_) + id_name = _bpak.id_to_string(p.id) or "" + flags = flag_str(p.flags) + ts = p.transport_size if p.flags & _bpak.FLAG_TRANSPORT else p.size + click.echo( + f"{p.id:08x} {id_name:<20s} size={p.size} pad={p.pad_bytes} " + f"flags={flags} transport_size={ts}" + ) + + +@show.command("hash") +@click.argument("filename", type=click.Path(exists=True, dir_okay=False)) +@click.option( + "--binary", + "binary_mode", + is_flag=True, + help="Write the raw digest bytes to stdout", +) +@handle_bpak_errors +@open_package("rb") +def show_hash(pkg, binary_mode: bool) -> None: + """Print the package header hash (Package.digest).""" + digest = pkg.digest + if not digest: + raise click.ClickException("package has no digest") + if binary_mode: + sink = binary_sink(None) + sink.write(digest) + sink.flush() + else: + click.echo(bin2hex(digest)) diff --git a/python/bpak/_cli/sign.py b/python/bpak/_cli/sign.py new file mode 100644 index 0000000..b3aa3cc --- /dev/null +++ b/python/bpak/_cli/sign.py @@ -0,0 +1,73 @@ +"""`bpak sign` and `bpak verify`.""" + +from __future__ import annotations + +from pathlib import Path + +import click + +from ._common import ( + BPAK_MAX_SIGNATURE_BYTES, + exactly_one_of, + handle_bpak_errors, + open_package, +) + + +@click.command() +@click.argument("filename", type=click.Path(exists=True, dir_okay=False)) +@click.option( + "--key", + "key_path", + type=click.Path(exists=True, dir_okay=False), + help="Private key (PEM) used to sign the package", +) +@click.option( + "--signature", + "signature_path", + type=click.Path(exists=True, dir_okay=False), + help="Pre-computed signature file to install instead of signing", +) +@handle_bpak_errors +@open_package("r+") +def sign(pkg, key_path: str | None, signature_path: str | None) -> None: + """Sign a bpak file.""" + choice = exactly_one_of({"--key": key_path, "--signature": signature_path}) + if choice == "--signature": + sig_data = Path(signature_path).read_bytes() + if len(sig_data) > BPAK_MAX_SIGNATURE_BYTES: + raise click.ClickException( + f"Signature file too large " + f"({len(sig_data)} > {BPAK_MAX_SIGNATURE_BYTES} bytes)" + ) + pkg.signature = sig_data + else: + pkg.sign(key_path) + + +@click.command() +@click.argument("filename", type=click.Path(exists=True, dir_okay=False)) +@click.option( + "--key", + "key_path", + type=click.Path(exists=True, dir_okay=False), + help="Public key (PEM) to verify against", +) +@click.option( + "--keystore", + "keystore_path", + type=click.Path(exists=True, dir_okay=False), + help="Keystore bpak file to verify against", +) +@handle_bpak_errors +@open_package("rb") +def verify(pkg, key_path: str | None, keystore_path: str | None) -> None: + """Verify a bpak file signature.""" + choice = exactly_one_of( + {"--key": key_path, "--keystore": keystore_path} + ) + if choice == "--keystore": + pkg.verify_with_keystore(keystore_path) + else: + pkg.verify(key_path) + click.echo("Verification OK") diff --git a/python/bpak/_cli/transport.py b/python/bpak/_cli/transport.py new file mode 100644 index 0000000..7a1a713 --- /dev/null +++ b/python/bpak/_cli/transport.py @@ -0,0 +1,90 @@ +"""`bpak transport` group — transport metadata and encode/decode.""" + +from __future__ import annotations + +import click + +from .. import _bpak +from ._common import BPAK_ID, handle_bpak_errors, open_package + + +@click.group() +def transport() -> None: + """Transport encoding/decoding operations.""" + + +@transport.command("add") +@click.argument("filename", type=click.Path(exists=True, dir_okay=False)) +@click.argument("id_", metavar="ID", type=BPAK_ID) +@click.option( + "--encoder", + type=BPAK_ID, + required=True, + help="Transport encoder algorithm id (e.g. 'bsdiff' or '0x...')", +) +@click.option( + "--decoder", + type=BPAK_ID, + required=True, + help="Transport decoder algorithm id", +) +@handle_bpak_errors +@open_package("r+") +def transport_add(pkg, id_: int, encoder: int, decoder: int) -> None: + """Add transport metadata linking a part to an encoder/decoder pair.""" + _bpak.add_transport_meta(pkg, id_, encoder, decoder) + + +def _run_codec( + op, + filename: str, + output: str, + origin: str | None, +) -> None: + with _bpak.Package(filename, "rb") as pkg_in: + with _bpak.Package(output, "wb") as pkg_out: + if origin is not None: + with _bpak.Package(origin, "rb") as pkg_origin: + op(pkg_in, pkg_out, pkg_origin) + else: + op(pkg_in, pkg_out) + + +@transport.command("encode") +@click.argument("filename", type=click.Path(exists=True, dir_okay=False)) +@click.option( + "-o", + "--output", + type=click.Path(dir_okay=False), + required=True, + help="Path of the transport-encoded output package", +) +@click.option( + "--origin", + type=click.Path(exists=True, dir_okay=False), + help="Origin package to diff against (for bsdiff-style encoders)", +) +@handle_bpak_errors +def transport_encode(filename: str, output: str, origin: str | None) -> None: + """Transport-encode a package.""" + _run_codec(_bpak.transport_encode, filename, output, origin) + + +@transport.command("decode") +@click.argument("filename", type=click.Path(exists=True, dir_okay=False)) +@click.option( + "-o", + "--output", + type=click.Path(dir_okay=False), + required=True, + help="Path of the transport-decoded output package", +) +@click.option( + "--origin", + type=click.Path(exists=True, dir_okay=False), + help="Origin package used to reconstruct (for bsdiff-style decoders)", +) +@handle_bpak_errors +def transport_decode(filename: str, output: str, origin: str | None) -> None: + """Transport-decode a package.""" + _run_codec(_bpak.transport_decode, filename, output, origin) diff --git a/python/bpak/_helpers.py b/python/bpak/_helpers.py index 62bff72..d90efa9 100644 --- a/python/bpak/_helpers.py +++ b/python/bpak/_helpers.py @@ -31,9 +31,19 @@ def resolve_id(arg: str) -> int: - """Convert a name string or '0x...' hex literal to a bpak_id_t.""" - if arg.startswith("0x") or arg.startswith("0X"): + """Convert a name string or numeric literal to a bpak_id_t. + + Accepts: + - ``0x...`` / ``0X...`` hex literals (any width). + - Bare decimal literals (``0``, ``42``, ...). Note: this means a + part or metadata named exactly ``"123"`` must be written as + ``0x...`` to disambiguate from the decimal 123. + - Any other string, which is CRC32-hashed via ``bpak_id()``. + """ + if arg.startswith(("0x", "0X")): return int(arg, 16) + if arg.isdigit(): + return int(arg) return bpak_id(arg) diff --git a/setup.py b/setup.py index 71ae2c7..981d60a 100644 --- a/setup.py +++ b/setup.py @@ -63,8 +63,11 @@ def read_version(): "License :: OSI Approved :: BSD License", "Programming Language :: Python :: 3", ], - packages=["bpak"], - package_dir={"bpak": "python/bpak"}, + packages=["bpak", "bpak._cli"], + package_dir={ + "bpak": "python/bpak", + "bpak._cli": "python/bpak/_cli", + }, ext_modules=[ Extension( name="bpak._bpak", @@ -77,6 +80,6 @@ def read_version(): ], install_requires=["click>=8.0"], entry_points={ - "console_scripts": ["bpak=bpak.__main__:cli"], + "console_scripts": ["bpak=bpak._cli:cli"], }, ) From 0b6e33f4232e5660be5af3cac778fab889613eea Mon Sep 17 00:00:00 2001 From: Marten Svanfeldt Date: Tue, 21 Apr 2026 10:42:21 +0200 Subject: [PATCH 07/10] python: add CLI tests and migration doc test/test_python_cli.py drives every subcommand of the new bpak._cli through click.testing.CliRunner, including the happy path per subcommand, mutex errors (exactly_one_of / at_least_one_of), binary-to-TTY refusal, same-size-different-content part detection in compare, zero-value validation (--key-id 0), show meta --part-ref filtering, a sign/verify round trip on a read-only package, a transport add/encode/decode round trip, and generate keystore --name identifier rejection. Registered in test/CMakeLists.txt so it runs under ctest alongside the existing Python tests. docs/ug/99_python_cli.rst documents the new surface with a full command reference, notable differences from the C CLI, a migration table mapping the previous Python argv to the new shape, and a list of breaking changes (no compatibility shims are provided). A note at the top of 01_basics.rst points Python-installed users to the new page since the existing user-guide examples document the C bpak binary. --- docs/ug/01_basics.rst | 9 +- docs/ug/99_python_cli.rst | 161 ++++++++++++++++ test/CMakeLists.txt | 1 + test/test_python_cli.py | 382 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 552 insertions(+), 1 deletion(-) create mode 100644 docs/ug/99_python_cli.rst create mode 100755 test/test_python_cli.py diff --git a/docs/ug/01_basics.rst b/docs/ug/01_basics.rst index 2a5a924..3f3ac06 100644 --- a/docs/ug/01_basics.rst +++ b/docs/ug/01_basics.rst @@ -1,8 +1,15 @@ Basic example ============= +.. note:: + + The examples in this user guide use the C ``bpak`` command-line tool. The + ``bpak`` CLI installed from the Python package has a different, idiomatic + Click argv; see :doc:`99_python_cli` for its command reference and a + migration table. + In the simplest use-case for bitpacker the archive can be viewed as a container -format for other binaries with metadata on sizes and offsets of the parts it +format for other binaries with metadata on sizes and offsets of the parts it contains. Create an empty archive:: diff --git a/docs/ug/99_python_cli.rst b/docs/ug/99_python_cli.rst new file mode 100644 index 0000000..e409ec7 --- /dev/null +++ b/docs/ug/99_python_cli.rst @@ -0,0 +1,161 @@ +Python CLI +========== + +The ``bpak`` command shipped by the ``bpak`` Python package (``pip install bpak``) +is an idiomatic Click application. Its command-line interface is **not** the +same as the C ``bpak`` binary documented in the preceding chapters. The C +binary is unchanged; anyone needing the historical argv should continue to +install and run it directly. + +If you were using a previous version of the Python ``bpak`` script, see the +migration table below for the new shape of every subcommand. + +Overview +-------- + +- Two-level command groups: operations on parts and metadata are written as + ``noun verb``, e.g. ``bpak add part`` / ``bpak delete meta``. +- ``-v/--verbose`` is global on the root group: ``bpak -v add part ...`` rather + than per-command. +- The IDs of parts and metadata are positional arguments on every command that + addresses a single item. Where the C CLI accepted ``--part foo``, the Python + CLI takes ``foo`` positionally. +- Commands that produce binary data (``extract part``, ``extract meta``, + ``show hash --binary``) refuse to write to stdout when stdout is a terminal. + Redirect to a file or pass ``--output PATH``. +- ``generate keystore --name`` validates its argument as a C identifier + (``[A-Za-z_][A-Za-z0-9_]*``) before emitting any generated C source. + +Command reference +----------------- + +.. code-block:: text + + bpak [--version] [-v/--verbose...] + + Package lifecycle + bpak create FILE [--hash {sha256|sha384|sha512}] + [--signature {prime256v1|secp384r1|secp521r1|rsa4096}] + [--force] + bpak compare FILE1 FILE2 + + Inspection + bpak show FILE # full package overview + bpak show meta FILE [ID] [--part-ref REF] + bpak show part FILE ID [--hash] + bpak show hash FILE [--binary] # header hash (Package.digest) + + Content + bpak add part FILE ID --from PATH [--no-hash] + bpak add meta FILE ID (--from-string VAL | --from-file PATH) + [--encoder {uuid|integer|id}] [--part-ref REF] + bpak add key FILE ID --from PATH + bpak add merkle FILE ID --from PATH + + bpak set meta FILE ID VALUE [--encoder {integer|id}] [--part-ref REF] + bpak set header FILE [--key-id ID] [--keystore-id ID] + + bpak delete part FILE (ID | --all) [--keep-meta] + bpak delete meta FILE ID [--part-ref REF] + + bpak extract part FILE ID [--output PATH] + bpak extract meta FILE ID [--output PATH] [--part-ref REF] + + Signing + bpak sign FILE (--key PATH | --signature PATH) + bpak verify FILE (--key PATH | --keystore PATH) + + Transport + bpak transport add FILE ID --encoder NAME --decoder NAME + bpak transport encode FILE --output PATH [--origin PATH] + bpak transport decode FILE --output PATH [--origin PATH] + + Code generation + bpak generate id STRING + bpak generate keystore FILE --name NAME [--decorate] + +Notable differences from the C CLI +---------------------------------- + +``show`` prints a single ``Header hash`` line in its overview output. The C +CLI's full ``bpak show`` additionally prints a ``Payload hash`` value. The +Python wrapper only exposes the header hash today; use the C ``bpak show`` +binary when you need the payload hash. + +``show hash`` prints the header hash. It replaces the overloaded +C-style ``-H`` / ``-B`` / ``-P`` flag combinations. For a part-level hash, +use ``bpak show part FILE ID --hash``. + +``compare`` diffs part contents via ``Package.part_sha256`` on both sides +in addition to part-header fields. The previous Python CLI only compared +part *sizes*, so same-size-different-content parts were silently reported +as equal. + +Migration from the previous Python CLI +-------------------------------------- + ++----------------------------------------------------+----------------------------------------------------------+ +| Previous Python CLI | New Python CLI | ++====================================================+==========================================================+ +| ``bpak create FILE -H sha256 -S prime256v1 -Y`` | ``bpak create FILE --hash sha256 --signature prime256v1 | +| | --force`` | ++----------------------------------------------------+----------------------------------------------------------+ +| ``bpak add FILE -p fs -f rootfs.img`` | ``bpak add part FILE fs --from rootfs.img`` | ++----------------------------------------------------+----------------------------------------------------------+ +| ``bpak add FILE -m version -s 1.0.0`` | ``bpak add meta FILE version --from-string 1.0.0`` | ++----------------------------------------------------+----------------------------------------------------------+ +| ``bpak add FILE -p key --encoder key -f k.pem`` | ``bpak add key FILE key --from k.pem`` | ++----------------------------------------------------+----------------------------------------------------------+ +| ``bpak add FILE -p fs --encoder merkle -f r.img`` | ``bpak add merkle FILE fs --from r.img`` | ++----------------------------------------------------+----------------------------------------------------------+ +| ``bpak show FILE`` | ``bpak show FILE`` (unchanged shape; overview) | ++----------------------------------------------------+----------------------------------------------------------+ +| ``bpak show FILE -m version`` | ``bpak show meta FILE version`` | ++----------------------------------------------------+----------------------------------------------------------+ +| ``bpak show FILE -P fs`` | ``bpak show part FILE fs --hash`` | ++----------------------------------------------------+----------------------------------------------------------+ +| ``bpak show FILE -H`` | ``bpak show hash FILE`` | ++----------------------------------------------------+----------------------------------------------------------+ +| ``bpak show FILE -B`` | ``bpak show hash FILE --binary`` | ++----------------------------------------------------+----------------------------------------------------------+ +| ``bpak set FILE -m version -s 2.0.0`` | ``bpak set meta FILE version 2.0.0`` | ++----------------------------------------------------+----------------------------------------------------------+ +| ``bpak set FILE --key-id X --keystore-id Y`` | ``bpak set header FILE --key-id X --keystore-id Y`` | ++----------------------------------------------------+----------------------------------------------------------+ +| ``bpak delete FILE -p fs`` | ``bpak delete part FILE fs`` | ++----------------------------------------------------+----------------------------------------------------------+ +| ``bpak delete FILE -a`` | ``bpak delete part FILE --all`` | ++----------------------------------------------------+----------------------------------------------------------+ +| ``bpak extract FILE -p fs -o fs.img`` | ``bpak extract part FILE fs --output fs.img`` | ++----------------------------------------------------+----------------------------------------------------------+ +| ``bpak extract FILE -m version`` | ``bpak extract meta FILE version`` (stdout, non-TTY) | ++----------------------------------------------------+----------------------------------------------------------+ +| ``bpak transport FILE --add --part fs`` | ``bpak transport add FILE fs --encoder ENC --decoder D`` | +| ``--encoder E --decoder D`` | | ++----------------------------------------------------+----------------------------------------------------------+ +| ``bpak transport FILE --encode --output o.bpak`` | ``bpak transport encode FILE --output o.bpak`` | ++----------------------------------------------------+----------------------------------------------------------+ +| ``bpak transport FILE --decode --output o.bpak`` | ``bpak transport decode FILE --output o.bpak`` | ++----------------------------------------------------+----------------------------------------------------------+ +| ``bpak sign FILE -k priv.pem`` | ``bpak sign FILE --key priv.pem`` | ++----------------------------------------------------+----------------------------------------------------------+ +| ``bpak verify FILE -k pub.pem`` | ``bpak verify FILE --key pub.pem`` | ++----------------------------------------------------+----------------------------------------------------------+ +| ``bpak verify FILE -K keystore.bpak`` | ``bpak verify FILE --keystore keystore.bpak`` | ++----------------------------------------------------+----------------------------------------------------------+ +| ``bpak generate id STRING`` | ``bpak generate id STRING`` (unchanged) | ++----------------------------------------------------+----------------------------------------------------------+ +| ``bpak generate keystore FILE -n NAME`` | ``bpak generate keystore FILE --name NAME`` | ++----------------------------------------------------+----------------------------------------------------------+ + +Breaking changes (no compatibility shims) +----------------------------------------- + +- All short flags that differ from the common ``-o`` / ``-v`` set are removed. +- ``-Y`` is replaced by ``--force`` on ``create``. +- ``show`` no longer overloads ``-p`` to mean "filter by part reference" when + ``-m`` is given. Use ``--part-ref REF`` explicitly. +- ``add``, ``set``, ``delete``, and ``extract`` are split into two-level + subgroups (``part`` / ``meta`` / ``key`` / ``merkle`` / ``header``). +- ``transport`` subcommands replace the ``--add / --encode / --decode`` mode + flags. diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 4dbc2dd..db3fd65 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -71,6 +71,7 @@ set(TEST_SCRIPT_PYTHON test_python_create_package.py test_python_meta.py test_python_transport.py + test_python_cli.py ) if (BPAK_BUILD_TESTS) diff --git a/test/test_python_cli.py b/test/test_python_cli.py new file mode 100755 index 0000000..1764070 --- /dev/null +++ b/test/test_python_cli.py @@ -0,0 +1,382 @@ +#!/usr/bin/env python3 +"""Argv-level tests for the bpak Python CLI (``bpak._cli``). + +Run from ``${CMAKE_BINARY_DIR}/test`` with the source tree passed as argv[1] +(same convention as the other test_python_*.py scripts).""" + +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + +sys.path.insert(0, "../python/") + +from click.testing import CliRunner # noqa: E402 + +from bpak._cli import cli # noqa: E402 + + +# --- small helpers --- + + +def run(*args: str, input_: str | None = None): + runner = CliRunner() + return runner.invoke(cli, list(args), input=input_, catch_exceptions=False) + + +def ok(result, *, exit_code: int = 0) -> None: + if result.exit_code != exit_code: + raise AssertionError( + f"expected exit {exit_code}, got {result.exit_code}\n" + f"output:\n{result.output}" + ) + + +def fail(result, expected_fragment: str) -> None: + assert result.exit_code != 0, f"expected failure, got 0\n{result.output}" + if expected_fragment not in result.output: + raise AssertionError( + f"expected {expected_fragment!r} in output; got:\n{result.output}" + ) + + +def _make_keypair(tmp: Path) -> tuple[Path, Path]: + priv = tmp / "priv.pem" + pub = tmp / "pub.pem" + subprocess.run( + ["openssl", "ecparam", "-name", "prime256v1", "-genkey", "-noout", + "-out", str(priv)], + check=True, stderr=subprocess.DEVNULL, + ) + subprocess.run( + ["openssl", "ec", "-in", str(priv), "-pubout", "-out", str(pub)], + check=True, stderr=subprocess.DEVNULL, + ) + return priv, pub + + +# --- tests --- + + +def test_help_lists_all_top_level_commands(): + result = run("--help") + ok(result) + for cmd in ("create", "add", "show", "set", "delete", "extract", + "sign", "verify", "compare", "transport", "generate"): + assert cmd in result.output, f"missing top-level command: {cmd}" + + +def test_generate_id(): + result = run("generate", "id", "hello") + ok(result) + assert "0xf032519b" in result.output + + +def test_generate_keystore_rejects_bad_identifier(tmp_path: Path): + pkg = tmp_path / "ks.bpak" + ok(run("create", str(pkg), "--force")) + result = run("generate", "keystore", str(pkg), "--name", "bad name!") + fail(result, "not a valid C identifier") + + +def test_create_and_show_summary(tmp_path: Path): + pkg = tmp_path / "a.bpak" + ok(run("create", str(pkg), "--force")) + + # Bare `bpak show FILE` triggers the overview via the custom resolver. + result = run("show", str(pkg)) + ok(result) + assert "Hash:" in result.output + assert "Signature:" in result.output + assert "Payload hash" not in result.output # the old bogus label is gone + + # The explicit `summary` subcommand is hidden but still dispatchable. + result = run("show", "summary", str(pkg)) + ok(result) + assert "Hash:" in result.output + + +def test_add_and_show_part(tmp_path: Path): + pkg = tmp_path / "a.bpak" + payload = tmp_path / "data.bin" + payload.write_bytes(b"hello world") + ok(run("create", str(pkg), "--force")) + ok(run("add", "part", str(pkg), "fs", "--from", str(payload))) + + result = run("show", "part", str(pkg), "fs") + ok(result) + assert "size=" in result.output + assert "faabeca7" in result.output # id("fs") + + result = run("show", "part", str(pkg), "fs", "--hash") + ok(result) + assert len(result.output.strip()) == 64 # sha256 hex + + +def test_add_meta_variants(tmp_path: Path): + pkg = tmp_path / "a.bpak" + ok(run("create", str(pkg), "--force")) + + ok(run("add", "meta", str(pkg), "version", "--from-string", "1.0.0")) + ok(run("add", "meta", str(pkg), "build-id", "--from-string", "42", + "--encoder", "integer")) + + # mutual exclusion + fail(run("add", "meta", str(pkg), "x"), + "one of --from-string, --from-file is required") + + fail(run("add", "meta", str(pkg), "x", + "--from-string", "1", "--from-file", str(pkg)), + "only one of --from-string, --from-file is allowed") + + +def test_set_meta_updates_value(tmp_path: Path): + pkg = tmp_path / "a.bpak" + ok(run("create", str(pkg), "--force")) + ok(run("add", "meta", str(pkg), "version", "--from-string", "1.0.0")) + ok(run("set", "meta", str(pkg), "version", "2.0.0-longer")) + + result = run("extract", "meta", str(pkg), "version", "-o", str(tmp_path / "v.bin")) + ok(result) + data = (tmp_path / "v.bin").read_bytes() + assert data.rstrip(b"\x00").startswith(b"2.0.0-longer") + + +def test_set_header_requires_at_least_one(tmp_path: Path): + pkg = tmp_path / "a.bpak" + ok(run("create", str(pkg), "--force")) + fail(run("set", "header", str(pkg)), + "at least one of --key-id, --keystore-id is required") + ok(run("set", "header", str(pkg), "--key-id", "0xdeadbeef")) + + +def test_set_header_accepts_zero(tmp_path: Path): + """--key-id 0 is a valid explicit value, not 'missing'.""" + pkg = tmp_path / "a.bpak" + ok(run("create", str(pkg), "--force")) + ok(run("set", "header", str(pkg), "--key-id", "0")) + ok(run("set", "header", str(pkg), "--keystore-id", "0")) + + +def test_show_meta_part_ref_filter(tmp_path: Path): + pkg = tmp_path / "a.bpak" + payload = tmp_path / "data.bin" + payload.write_bytes(b"hello") + ok(run("create", str(pkg), "--force")) + ok(run("add", "part", str(pkg), "fs", "--from", str(payload))) + # A global meta (part_ref=0) and a part-scoped one with the same id-name. + ok(run("add", "meta", str(pkg), "version", + "--from-string", "global")) + ok(run("add", "meta", str(pkg), "version", + "--from-string", "fs-scope", "--part-ref", "fs")) + + all_result = run("show", "meta", str(pkg), "version") + ok(all_result) + assert all_result.output.count("\n") >= 2, \ + f"expected both entries listed, got:\n{all_result.output}" + + fs_only = run("show", "meta", str(pkg), "version", "--part-ref", "fs") + ok(fs_only) + # part_ref=id('fs')=0xfaabeca7 is printed in the 'ref=' column. + assert "faabeca7" in fs_only.output + assert fs_only.output.count("\n") == 1, \ + f"expected 1 entry, got:\n{fs_only.output}" + + global_only = run("show", "meta", str(pkg), "version", "--part-ref", "0") + ok(global_only) + assert "faabeca7" not in global_only.output, \ + f"part-scoped entry leaked into part_ref=0 filter:\n{global_only.output}" + + +def test_delete_part_mutex(tmp_path: Path): + pkg = tmp_path / "a.bpak" + payload = tmp_path / "data.bin" + payload.write_bytes(b"hello") + ok(run("create", str(pkg), "--force")) + ok(run("add", "part", str(pkg), "fs", "--from", str(payload))) + + fail(run("delete", "part", str(pkg)), + "one of ID, --all is required") + fail(run("delete", "part", str(pkg), "fs", "--all"), + "only one of ID, --all is allowed") + + ok(run("delete", "part", str(pkg), "fs")) + + +def test_delete_meta(tmp_path: Path): + pkg = tmp_path / "a.bpak" + ok(run("create", str(pkg), "--force")) + ok(run("add", "meta", str(pkg), "version", "--from-string", "1.0.0")) + ok(run("delete", "meta", str(pkg), "version")) + + +def test_extract_part_binary_tty_refusal(tmp_path: Path): + pkg = tmp_path / "a.bpak" + payload = tmp_path / "data.bin" + payload.write_bytes(b"hello") + ok(run("create", str(pkg), "--force")) + ok(run("add", "part", str(pkg), "fs", "--from", str(payload))) + + # CliRunner's stdout isn't a real TTY, so this should succeed. + result = run("extract", "part", str(pkg), "fs") + ok(result) + + # Simulate a TTY with a subprocess using `script` so isatty() is true. + env = os.environ.copy() + env["PYTHONPATH"] = "../python/" + proc = subprocess.run( + ["script", "-qc", + f"{sys.executable} -m bpak extract part {pkg} fs", "/dev/null"], + check=False, capture_output=True, env=env, + ) + combined = proc.stdout.decode("utf-8", errors="replace") + \ + proc.stderr.decode("utf-8", errors="replace") + assert "refusing to write binary data to a terminal" in combined, combined + + +def test_compare_flags_same_size_different_content(tmp_path: Path): + p1 = tmp_path / "a.bpak" + p2 = tmp_path / "b.bpak" + a = tmp_path / "a.bin" + b = tmp_path / "b.bin" + a.write_bytes(b"aaaaa\n") + b.write_bytes(b"bbbbb\n") # same length as a.bin + + ok(run("create", str(p1), "--force")) + ok(run("add", "part", str(p1), "fs", "--from", str(a))) + + ok(run("create", str(p2), "--force")) + ok(run("add", "part", str(p2), "fs", "--from", str(b))) + + result = run("compare", str(p1), str(p2)) + ok(result) + assert "* faabeca7" in result.output, \ + f"expected content-mismatch marker, got:\n{result.output}" + + +def test_sign_verify_round_trip(tmp_path: Path): + pkg = tmp_path / "a.bpak" + payload = tmp_path / "data.bin" + payload.write_bytes(b"hello") + priv, pub = _make_keypair(tmp_path) + + ok(run("create", str(pkg), "--force")) + ok(run("add", "part", str(pkg), "fs", "--from", str(payload))) + ok(run("add", "meta", str(pkg), "version", "--from-string", "1.0.0")) + + fail(run("sign", str(pkg)), "one of --key, --signature is required") + ok(run("sign", str(pkg), "--key", str(priv))) + + fail(run("verify", str(pkg)), + "one of --key, --keystore is required") + result = run("verify", str(pkg), "--key", str(pub)) + ok(result) + assert "Verification OK" in result.output + + +def test_verify_works_on_readonly_file(tmp_path: Path): + """`bpak verify` must not need write permission on the package.""" + pkg = tmp_path / "a.bpak" + payload = tmp_path / "data.bin" + payload.write_bytes(b"hello") + priv, pub = _make_keypair(tmp_path) + + ok(run("create", str(pkg), "--force")) + ok(run("add", "part", str(pkg), "fs", "--from", str(payload))) + ok(run("sign", str(pkg), "--key", str(priv))) + + os.chmod(pkg, 0o444) + try: + result = run("verify", str(pkg), "--key", str(pub)) + ok(result) + assert "Verification OK" in result.output + finally: + os.chmod(pkg, 0o644) + + +def test_transport_round_trip(tmp_path: Path): + """create -> add part -> transport add -> encode -> decode.""" + pkg = tmp_path / "a.bpak" + origin = tmp_path / "origin.bpak" + encoded = tmp_path / "encoded.bpak" + decoded = tmp_path / "decoded.bpak" + payload_a = tmp_path / "a.img" + payload_b = tmp_path / "b.img" + payload_a.write_bytes(b"A" * 4096) + payload_b.write_bytes(b"B" * 4096) + + # Build two packages sharing the same part id, used as origin / delta. + # Transport encoding requires the bpak-package uuid so the two sides + # can be matched up. + shared_uuid = "0888b0fa-9c48-4524-9845-06a641b61edd" + for p, src in ((origin, payload_a), (pkg, payload_b)): + ok(run("create", str(p), "--force")) + ok(run("add", "part", str(p), "fs", "--from", str(src))) + ok(run("add", "meta", str(p), "bpak-package", + "--from-string", shared_uuid, "--encoder", "uuid")) + ok(run("transport", "add", str(p), "fs", + "--encoder", "bsdiff", "--decoder", "bspatch")) + + ok(run("transport", "encode", str(pkg), + "--output", str(encoded), "--origin", str(origin))) + assert encoded.exists() and encoded.stat().st_size > 0 + + ok(run("transport", "decode", str(encoded), + "--output", str(decoded), "--origin", str(origin))) + assert decoded.exists() and decoded.stat().st_size > 0 + + +def test_show_hash_binary_tty_refusal_via_subprocess(tmp_path: Path): + pkg = tmp_path / "a.bpak" + ok(run("create", str(pkg), "--force")) + env = os.environ.copy() + env["PYTHONPATH"] = "../python/" + proc = subprocess.run( + ["script", "-qc", + f"{sys.executable} -m bpak show hash {pkg} --binary", "/dev/null"], + check=False, capture_output=True, env=env, + ) + combined = proc.stdout.decode("utf-8", errors="replace") + \ + proc.stderr.decode("utf-8", errors="replace") + assert "refusing to write binary data to a terminal" in combined, combined + + +# --- ad-hoc test runner --- + +def _main() -> int: + failures = [] + import inspect + import tempfile + + module_globals = globals() + test_names = sorted( + name for name, obj in module_globals.items() + if name.startswith("test_") and inspect.isfunction(obj) + ) + + for name in test_names: + fn = module_globals[name] + sig = inspect.signature(fn) + with tempfile.TemporaryDirectory(prefix="bpak-cli-") as td: + try: + if "tmp_path" in sig.parameters: + fn(Path(td)) + else: + fn() + except Exception as exc: # noqa: BLE001 + failures.append((name, exc)) + print(f"FAIL {name}: {exc}") + else: + print(f"ok {name}") + + if failures: + print(f"\n{len(failures)} / {len(test_names)} tests failed") + return 1 + print(f"\n{len(test_names)} tests passed") + return 0 + + +if __name__ == "__main__": + sys.exit(_main()) From 5b5b18588437b184f6ddf0e959bf8db5075f1e9b Mon Sep 17 00:00:00 2001 From: Marten Svanfeldt Date: Tue, 21 Apr 2026 16:23:55 +0200 Subject: [PATCH 08/10] python: add pyproject.toml with ruff/mypy config Project metadata, dependency-groups, and initial ruff/mypy config for the CLI package. Ruff runs with select = ["ALL"]; the ignore list silences high-noise/low-value rules for this codebase (TRY003/EM10x raise-message churn, D203/D213 pydocstyle conflicts, ERA001 commented-out-code false positives). Per-file ignores let the .pyi stub mirror the C API's `id`/`input` parameter names, the _cli __init__ keep its circular-import workaround, and the test/ scripts use assert/print/no-docstrings freely. --- pyproject.toml | 76 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index b61373e..d4211f2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,79 @@ [build-system] requires = ["setuptools>=61.0", "wheel"] build-backend = "setuptools.build_meta" + +[project] +name = "bpak" +requires-python = ">=3.10" +dynamic = [ + "version", + "description", + "readme", + "authors", + "license", + "classifiers", + "dependencies", + "scripts", + "urls", +] + +[dependency-groups] +dev = [ + "ruff>=0.6", + "mypy>=1.11", +] + +[tool.ruff] +line-length = 100 +extend-exclude = [ + "docs", + "build", +] +target-version = "py310" + +[tool.ruff.lint] +# See: https://docs.astral.sh/ruff/rules/ for details +select = ["ALL"] +ignore = [ + "COM812", # Conflicts with the formatter + "ISC001", # Conflicts with the formatter + "PLE0605", # ruff does not handle += in __all__ + "FBT", # don't care for these at the moment + "PT001", # https://github.com/astral-sh/ruff/issues/8796#issuecomment-1825907715 + "PT023", # https://github.com/astral-sh/ruff/issues/8796#issuecomment-1825907715 + "TRY003", # external message strings on ClickException raises + "EM101", # ditto: tolerate string-literal in exception constructors + "EM102", # ditto: tolerate f-string in exception constructors + "D203", # conflicts with D211 (pydocstyle pep257 convention) + "D213", # conflicts with D212 (pydocstyle pep257 convention) + "ERA001", # commented-out-code false positives +] + +pyupgrade.keep-runtime-typing = true +pylint.max-args = 8 + +[tool.ruff.lint.per-file-ignores] +"python/bpak/__init__.py" = ["F401", "A004"] +"python/bpak/_bpak.pyi" = ["A001", "A002"] +"python/bpak/_cli/__init__.py" = ["E402", "PLC0415"] +"python/bpak/_cli/*.py" = ["S101"] +"test/**/*.py" = [ + "S101", "T201", "ANN", "D", "INP001", "PLR2004", + "PLC0415", "E402", "I001", "S603", "S605", "S607", "S108", +] + +[tool.ruff.lint.pydocstyle] +convention = "pep257" + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" +skip-magic-trailing-comma = false +line-ending = "auto" + +[tool.mypy] +warn_return_any = true +exclude = [ + "^build.*", + "^docs.*", +] From 66697ddbd3c28fe624635e2ef9783560068b0b22 Mon Sep 17 00:00:00 2001 From: Marten Svanfeldt Date: Tue, 21 Apr 2026 16:24:42 +0200 Subject: [PATCH 09/10] python: add type stubs for the compiled _bpak extension Hand-written .pyi declaring Package (context-manager; sign/verify/ add_file/add_meta/get_part/get_meta/extract_file/delete_all_parts/ part_sha256/verify_with_keystore + getset attrs), Part, Meta, Error, module-level constants, and the module-level functions (id, id_to_string, hash_kind_str, signature_kind_str, meta_to_string, add_transport_meta, parse_public_key, transport_encode/decode, set_log_func). Signatures sourced from python/python_wrapper.c, package.c, part.c, and meta.c. Add __all__ to python/bpak/__init__.py so mypy and ruff see the re-exported surface explicitly rather than treating every import as unused. --- python/bpak/__init__.py | 42 ++++++++++++--- python/bpak/_bpak.pyi | 113 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 149 insertions(+), 6 deletions(-) create mode 100644 python/bpak/_bpak.pyi diff --git a/python/bpak/__init__.py b/python/bpak/__init__.py index 7380004..e6d5a22 100644 --- a/python/bpak/__init__.py +++ b/python/bpak/__init__.py @@ -6,18 +6,18 @@ HASH_SHA256, HASH_SHA384, HASH_SHA512, - KEY_PUB_PRIME256v1, KEY_PUB_RSA4096, - KEY_PUB_SECP384r1, - KEY_PUB_SECP521r1, - SIGN_PRIME256v1, SIGN_RSA4096, - SIGN_SECP384r1, - SIGN_SECP521r1, Error, + KEY_PUB_PRIME256v1, + KEY_PUB_SECP384r1, + KEY_PUB_SECP521r1, Meta, Package, Part, + SIGN_PRIME256v1, + SIGN_SECP384r1, + SIGN_SECP521r1, add_transport_meta, hash_kind_str, id, @@ -29,3 +29,33 @@ transport_decode, transport_encode, ) + +__all__ = [ + "FLAG_EXCLUDE_FROM_HASH", + "FLAG_TRANSPORT", + "HASH_SHA256", + "HASH_SHA384", + "HASH_SHA512", + "KEY_PUB_RSA4096", + "SIGN_RSA4096", + "Error", + "KEY_PUB_PRIME256v1", + "KEY_PUB_SECP384r1", + "KEY_PUB_SECP521r1", + "Meta", + "Package", + "Part", + "SIGN_PRIME256v1", + "SIGN_SECP384r1", + "SIGN_SECP521r1", + "add_transport_meta", + "hash_kind_str", + "id", + "id_to_string", + "meta_to_string", + "parse_public_key", + "set_log_func", + "signature_kind_str", + "transport_decode", + "transport_encode", +] diff --git a/python/bpak/_bpak.pyi b/python/bpak/_bpak.pyi new file mode 100644 index 0000000..d082923 --- /dev/null +++ b/python/bpak/_bpak.pyi @@ -0,0 +1,113 @@ +from collections.abc import Callable +from types import TracebackType +from typing import Final, Self + +FLAG_EXCLUDE_FROM_HASH: Final[int] +FLAG_TRANSPORT: Final[int] + +HASH_SHA256: Final[int] +HASH_SHA384: Final[int] +HASH_SHA512: Final[int] + +KEY_PUB_PRIME256v1: Final[int] +KEY_PUB_RSA4096: Final[int] +KEY_PUB_SECP384r1: Final[int] +KEY_PUB_SECP521r1: Final[int] + +SIGN_PRIME256v1: Final[int] +SIGN_RSA4096: Final[int] +SIGN_SECP384r1: Final[int] +SIGN_SECP521r1: Final[int] + +class Error(Exception): ... + +class Meta: + id: int + part_id_ref: int + size: int + raw_data: bytes + + def as_uuid(self) -> object: ... + def as_string(self) -> str: ... + def delete(self) -> None: ... + +class Part: + id: int + size: int + offset: int + is_transport_encoded: bool + flags: int + transport_size: int + pad_bytes: int + + def read_data(self) -> bytes: ... + def delete(self, keep_meta: bool = False) -> None: ... + +class Package: + digest: bytes + hash_kind: int + signature_kind: int + key_id: int + keystore_id: int + signature: bytes + size: int + installed_size: int + parts: list[Part] + meta: list[Meta] + + def __init__(self, filename: str, mode: str = "rb") -> None: ... + def __enter__(self) -> Self: ... + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: ... + def close(self) -> None: ... + def verify(self, verify_key_path: str) -> bool: ... + def sign(self, sign_key_path: str) -> bool: ... + def verify_with_keystore(self, keystore_path: str) -> bool: ... + def add_file( + self, + part_name: str, + filename: str, + with_merkle_tree: bool = False, + flags: int = 0, + ) -> Part: ... + def add_key(self, part_name: str, filename: str, flags: int = 0) -> Part: ... + def add_meta( + self, + id: int, + part_id_ref: int = 0, + data: bytes | str | None = None, + size: int = -1, + ) -> Meta: ... + def get_part(self, id: int) -> Part: ... + def get_meta(self, id: int, part_id_ref: int = 0) -> Meta: ... + def extract_file(self, part_id: int, filename: str) -> None: ... + def delete_all_parts(self, keep_meta: bool = False) -> None: ... + def part_sha256(self, part_id: int) -> bytes: ... + +def id(string: str) -> int: ... +def set_log_func(log_func: Callable[[int, str], None] | None) -> None: ... +def transport_encode( + input: Package, + output: Package, + origin: Package | None = None, +) -> None: ... +def transport_decode( + input: Package, + output: Package, + origin: Package | None = None, +) -> None: ... +def hash_kind_str(kind: int) -> str: ... +def signature_kind_str(kind: int) -> str: ... +def id_to_string(id: int) -> str | None: ... +def meta_to_string(package: Package, meta: Meta) -> str | None: ... +def add_transport_meta( + package: Package, + part_id: int, + encoder_id: int, + decoder_id: int, +) -> None: ... +def parse_public_key(data: bytes) -> tuple[int, bytes]: ... From 489067ef2d5a0b8339d76dd4853e5aa2df6d1471 Mon Sep 17 00:00:00 2001 From: Marten Svanfeldt Date: Tue, 21 Apr 2026 16:25:00 +0200 Subject: [PATCH 10/10] python: clean up lint and type warnings across CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Type the decorators in _cli/_common.py with ParamSpec/TypeVar/ Concatenate so open_package and handle_bpak_errors preserve their wrapped callback's signatures (filename -> pkg substitution for open_package). Annotate every @open_package-decorated callback as taking pkg: _bpak.Package, now possible with the new .pyi stub. Narrow str | None option values with assert after exactly_one_of resolves the choice in sign.py, add.py, compare.py, and delete.py; mypy can't see through the dict-based validator, and the asserts document the invariant. Absolute imports throughout: replace `from ..` / `from .._helpers` with `from bpak import _bpak` / `from bpak._helpers import …`. Move type-checking-only imports (collections.abc.Callable, _bpak) under TYPE_CHECKING where appropriate. Miscellaneous: drop unnecessary elif-after-return in _helpers.py; use Path.exists()/Path.open() in create.py/extract.py/_common.py; replace blind Exception catches with PackageNotFoundError in _cli/ __init__.py and generate.py; tidy docstrings; run ruff format across the package. After this change ruff check, ruff format --check, and mypy all pass on python/bpak with zero issues (from 134 ruff + 6 mypy errors). --- python/bpak/_cli/__init__.py | 25 +++++----- python/bpak/_cli/_common.py | 92 +++++++++++++++++++++-------------- python/bpak/_cli/add.py | 14 +++--- python/bpak/_cli/compare.py | 14 ++++-- python/bpak/_cli/create.py | 18 ++++--- python/bpak/_cli/delete.py | 17 +++++-- python/bpak/_cli/extract.py | 15 ++++-- python/bpak/_cli/generate.py | 25 +++++----- python/bpak/_cli/set.py | 18 ++++--- python/bpak/_cli/show.py | 45 ++++++++--------- python/bpak/_cli/sign.py | 25 ++++++++-- python/bpak/_cli/transport.py | 30 ++++++++---- python/bpak/_helpers.py | 15 +++--- 13 files changed, 213 insertions(+), 140 deletions(-) diff --git a/python/bpak/_cli/__init__.py b/python/bpak/_cli/__init__.py index 1a90211..e4ba003 100644 --- a/python/bpak/_cli/__init__.py +++ b/python/bpak/_cli/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations -import sys +from importlib.metadata import PackageNotFoundError, version import click @@ -13,9 +13,8 @@ def _print_version(ctx: click.Context, _param: click.Option, value: bool) -> Non if not value or ctx.resilient_parsing: return try: - from importlib.metadata import version ver = version("bpak") - except Exception: + except PackageNotFoundError: ver = "unknown" click.echo(f"BitPacker {ver}") ctx.exit(0) @@ -45,16 +44,16 @@ def cli(ctx: click.Context, verbose: int) -> None: # Wire subcommands. Imports are deferred here so a command file can import # from ._common without a circular dependency on this module. -from . import create as _create # noqa: E402 -from . import compare as _compare # noqa: E402 -from . import show as _show # noqa: E402 -from . import add as _add # noqa: E402 -from . import set as _set # noqa: E402 -from . import delete as _delete # noqa: E402 -from . import extract as _extract # noqa: E402 -from . import sign as _sign # noqa: E402 -from . import transport as _transport # noqa: E402 -from . import generate as _generate # noqa: E402 +from . import add as _add +from . import compare as _compare +from . import create as _create +from . import delete as _delete +from . import extract as _extract +from . import generate as _generate +from . import set as _set +from . import show as _show +from . import sign as _sign +from . import transport as _transport cli.add_command(_create.create) cli.add_command(_compare.compare) diff --git a/python/bpak/_cli/_common.py b/python/bpak/_cli/_common.py index facc4cc..6061bd4 100644 --- a/python/bpak/_cli/_common.py +++ b/python/bpak/_cli/_common.py @@ -5,28 +5,40 @@ import functools import re import sys -from typing import Any, Callable, Iterable +from pathlib import Path +from typing import IO, TYPE_CHECKING, Any, Concatenate, ParamSpec, TypeVar import click -from .. import _bpak -from .._helpers import resolve_id +from bpak import _bpak +from bpak._helpers import resolve_id + +if TYPE_CHECKING: + from collections.abc import Callable BPAK_METADATA_BYTES = getattr(_bpak, "BPAK_METADATA_BYTES", 1920) BPAK_MAX_SIGNATURE_BYTES = getattr(_bpak, "BPAK_MAX_SIGNATURE_BYTES", 512) _C_IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") +P = ParamSpec("P") +R = TypeVar("R") + class BpakId(click.ParamType): name = "bpak_id" - def convert(self, value, param, ctx): + def convert( + self, + value: Any, # noqa: ANN401 - Click ParamType receives Any + param: click.Parameter | None, + ctx: click.Context | None, + ) -> int: if isinstance(value, int): return value try: return resolve_id(value) - except Exception as exc: + except Exception as exc: # noqa: BLE001 - surface any parse error as UsageError self.fail(f"{value!r} is not a valid bpak id ({exc})", param, ctx) @@ -38,6 +50,7 @@ def _cb(level: int, msg: str) -> None: v = ctx.obj.get("verbose", 0) if level == 0 or level <= v: click.echo(msg, nl=False, err=True) + return _cb @@ -52,40 +65,55 @@ def install_verbose(ctx: click.Context, verbose: int) -> None: ctx.call_on_close(lambda: _bpak.set_log_func(None)) -def open_package(mode: str = "rb"): - """Decorator that opens the package file, passes pkg as first arg, closes it, - and translates _bpak.Error to ClickException. +def open_package( + mode: str = "rb", +) -> Callable[ + [Callable[Concatenate[_bpak.Package, P], R]], + Callable[Concatenate[str, P], R], +]: + """Open the package file and pass ``pkg`` to the callback. - The decorated callback must have signature (pkg, ...); Click's own argument - injection keeps working for everything after pkg. + Translates ``_bpak.Error`` raised by the callback into a + ``click.ClickException``. The decorated callback must have signature + ``(pkg, ...)``; Click's own argument injection keeps working for + everything after ``pkg``. """ - def decorator(func: Callable[..., Any]) -> Callable[..., Any]: + def decorator( + func: Callable[Concatenate[_bpak.Package, P], R], + ) -> Callable[Concatenate[str, P], R]: @functools.wraps(func) - def wrapper(filename: str, *args: Any, **kwargs: Any) -> Any: + def wrapper(filename: str, *args: P.args, **kwargs: P.kwargs) -> R: try: with _bpak.Package(filename, mode) as pkg: return func(pkg, *args, **kwargs) except _bpak.Error as exc: raise click.ClickException(str(exc)) from exc + return wrapper + return decorator -def handle_bpak_errors(func: Callable[..., Any]) -> Callable[..., Any]: - """Translate _bpak.Error to ClickException for commands that manage their - own Package open/close (e.g. commands that open two packages).""" +def handle_bpak_errors(func: Callable[P, R]) -> Callable[P, R]: + """Translate ``_bpak.Error`` to ``click.ClickException``. + + For commands that manage their own Package open/close (e.g. commands + that open two packages). + """ + @functools.wraps(func) - def wrapper(*args: Any, **kwargs: Any) -> Any: + def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: try: return func(*args, **kwargs) except _bpak.Error as exc: raise click.ClickException(str(exc)) from exc + return wrapper -def _is_set(value: Any) -> bool: - """Is this parsed option "set" for validation purposes? +def _is_set(value: object) -> bool: + """Report whether a parsed option counts as "set" for validation. Accept 0 / "" / False-only-if-it-came-from-an-explicit-false as "set", since Click only yields the default otherwise. A `None` means the option @@ -94,43 +122,35 @@ def _is_set(value: Any) -> bool: """ if value is None: return False - if value is False: - return False - return True + return value is not False def exactly_one_of(values: dict[str, Any]) -> str: """Return the one option name whose value is set, or raise UsageError.""" set_names = [name for name, v in values.items() if _is_set(v)] if len(set_names) == 0: - raise click.UsageError( - f"one of {', '.join(values.keys())} is required" - ) + raise click.UsageError(f"one of {', '.join(values.keys())} is required") if len(set_names) > 1: raise click.UsageError( - f"only one of {', '.join(values.keys())} is allowed " - f"(got: {', '.join(set_names)})" + f"only one of {', '.join(values.keys())} is allowed (got: {', '.join(set_names)})" ) return set_names[0] def at_least_one_of(values: dict[str, Any]) -> None: if not any(_is_set(v) for v in values.values()): - raise click.UsageError( - f"at least one of {', '.join(values.keys())} is required" - ) + raise click.UsageError(f"at least one of {', '.join(values.keys())} is required") def incompatible(values: dict[str, Any]) -> None: set_names = [k for k, v in values.items() if _is_set(v)] if len(set_names) > 1: raise click.UsageError( - f"{', '.join(values.keys())} are mutually exclusive " - f"(got: {', '.join(set_names)})" + f"{', '.join(values.keys())} are mutually exclusive (got: {', '.join(set_names)})" ) -def binary_sink(output_path: str | None): +def binary_sink(output_path: str | None) -> IO[bytes]: """Return a writable binary stream. - output_path given: file opened for writing (caller is responsible for closing). @@ -138,11 +158,10 @@ def binary_sink(output_path: str | None): otherwise UsageError to prevent terminal corruption. """ if output_path is not None: - return open(output_path, "wb") + return Path(output_path).open("wb") if sys.stdout.isatty(): raise click.UsageError( - "refusing to write binary data to a terminal; " - "redirect stdout or pass --output PATH" + "refusing to write binary data to a terminal; redirect stdout or pass --output PATH" ) return sys.stdout.buffer @@ -150,8 +169,7 @@ def binary_sink(output_path: str | None): def safe_c_identifier(name: str) -> str: if not _C_IDENTIFIER_RE.match(name): raise click.UsageError( - f"{name!r} is not a valid C identifier " - "(expected [A-Za-z_][A-Za-z0-9_]*)" + f"{name!r} is not a valid C identifier (expected [A-Za-z_][A-Za-z0-9_]*)" ) return name diff --git a/python/bpak/_cli/add.py b/python/bpak/_cli/add.py index 1936919..50d300b 100644 --- a/python/bpak/_cli/add.py +++ b/python/bpak/_cli/add.py @@ -6,8 +6,9 @@ import click -from .. import _bpak -from .._helpers import encode_meta_value +from bpak import _bpak +from bpak._helpers import encode_meta_value + from ._common import ( BPAK_ID, BPAK_METADATA_BYTES, @@ -39,7 +40,7 @@ def add() -> None: ) @handle_bpak_errors @open_package("r+") -def add_part(pkg, id_: str, from_path: str, no_hash: bool) -> None: +def add_part(pkg: _bpak.Package, id_: str, from_path: str, no_hash: bool) -> None: """Add a raw part from a file. The ID is the part's symbolic name (e.g. "rootfs"); the underlying @@ -79,7 +80,7 @@ def add_part(pkg, id_: str, from_path: str, no_hash: bool) -> None: @handle_bpak_errors @open_package("r+") def add_meta( - pkg, + pkg: _bpak.Package, id_: int, from_string: str | None, from_file: str | None, @@ -98,6 +99,7 @@ def add_meta( else: data = from_string.encode("ascii") + b"\x00" else: + assert from_file is not None data = Path(from_file).read_bytes() if len(data) > BPAK_METADATA_BYTES: @@ -120,7 +122,7 @@ def add_meta( ) @handle_bpak_errors @open_package("r+") -def add_key(pkg, id_: str, from_path: str) -> None: +def add_key(pkg: _bpak.Package, id_: str, from_path: str) -> None: """Embed a public key as a part (ID is the key's symbolic name).""" pkg.add_key(id_, from_path) @@ -137,6 +139,6 @@ def add_key(pkg, id_: str, from_path: str) -> None: ) @handle_bpak_errors @open_package("r+") -def add_merkle(pkg, id_: str, from_path: str) -> None: +def add_merkle(pkg: _bpak.Package, id_: str, from_path: str) -> None: """Add a part with an accompanying merkle tree (ID is the part's symbolic name).""" pkg.add_file(id_, from_path, with_merkle_tree=True) diff --git a/python/bpak/_cli/compare.py b/python/bpak/_cli/compare.py index b8cb59e..1fdbbc1 100644 --- a/python/bpak/_cli/compare.py +++ b/python/bpak/_cli/compare.py @@ -4,7 +4,8 @@ import click -from .. import _bpak +from bpak import _bpak + from ._common import handle_bpak_errors @@ -51,14 +52,21 @@ def compare(file1: str, file2: str) -> None: sym, color = "-", "red" else: sym, color = "+", "green" - size = (p1 or p2).size + present = p1 if p1 is not None else p2 + assert present is not None + size = present.size click.secho( f" {sym} {pid:08x} {id_name:<20s} size={size}", fg=color, ) -def _parts_equal(pkg1, pkg2, p1, p2) -> bool: +def _parts_equal( + pkg1: _bpak.Package, + pkg2: _bpak.Package, + p1: _bpak.Part, + p2: _bpak.Part, +) -> bool: """Compare part-header fields and content via SHA-256.""" if ( p1.size != p2.size diff --git a/python/bpak/_cli/create.py b/python/bpak/_cli/create.py index 523e2a4..4e2b87d 100644 --- a/python/bpak/_cli/create.py +++ b/python/bpak/_cli/create.py @@ -2,12 +2,13 @@ from __future__ import annotations -import os +from pathlib import Path import click -from .. import _bpak -from .._helpers import HASH_KIND_MAP, SIGN_KIND_MAP +from bpak import _bpak +from bpak._helpers import HASH_KIND_MAP, SIGN_KIND_MAP + from ._common import handle_bpak_errors @@ -33,9 +34,14 @@ @handle_bpak_errors def create(filename: str, hash_kind: str, signature_kind: str, force: bool) -> None: """Create a new empty bpak file.""" - if os.path.exists(filename) and not force: - if not click.confirm(f"File '{filename}' exists. Overwrite?"): - return + if ( + Path(filename).exists() + and not force + and not click.confirm( + f"File '{filename}' exists. Overwrite?", + ) + ): + return with _bpak.Package(filename, "wb") as pkg: pkg.hash_kind = HASH_KIND_MAP[hash_kind] diff --git a/python/bpak/_cli/delete.py b/python/bpak/_cli/delete.py index 60e475a..67ae94f 100644 --- a/python/bpak/_cli/delete.py +++ b/python/bpak/_cli/delete.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import TYPE_CHECKING + import click from ._common import ( @@ -11,6 +13,9 @@ open_package, ) +if TYPE_CHECKING: + from bpak import _bpak + @click.group() def delete() -> None: @@ -34,15 +39,17 @@ def delete() -> None: @handle_bpak_errors @open_package("r+") def delete_part( - pkg, id_: int | None, delete_all: bool, keep_meta: bool + pkg: _bpak.Package, + id_: int | None, + delete_all: bool, + keep_meta: bool, ) -> None: """Delete a single part, or --all of them.""" - choice = exactly_one_of( - {"ID": id_ if id_ is not None else None, "--all": delete_all} - ) + choice = exactly_one_of({"ID": id_ if id_ is not None else None, "--all": delete_all}) if choice == "--all": pkg.delete_all_parts(keep_meta=keep_meta) else: + assert id_ is not None p = pkg.get_part(id_) p.delete(keep_meta=keep_meta) @@ -60,7 +67,7 @@ def delete_part( ) @handle_bpak_errors @open_package("r+") -def delete_meta(pkg, id_: int, part_ref: int) -> None: +def delete_meta(pkg: _bpak.Package, id_: int, part_ref: int) -> None: """Delete a metadata entry.""" m = pkg.get_meta(id_, part_ref) m.delete() diff --git a/python/bpak/_cli/extract.py b/python/bpak/_cli/extract.py index 67fbf07..2cc9380 100644 --- a/python/bpak/_cli/extract.py +++ b/python/bpak/_cli/extract.py @@ -2,6 +2,9 @@ from __future__ import annotations +from pathlib import Path +from typing import TYPE_CHECKING + import click from ._common import ( @@ -11,6 +14,9 @@ open_package, ) +if TYPE_CHECKING: + from bpak import _bpak + @click.group() def extract() -> None: @@ -28,7 +34,7 @@ def extract() -> None: ) @handle_bpak_errors @open_package("rb") -def extract_part(pkg, id_: int, output: str | None) -> None: +def extract_part(pkg: _bpak.Package, id_: int, output: str | None) -> None: """Extract a part.""" if output is not None: pkg.extract_file(id_, output) @@ -59,13 +65,16 @@ def extract_part(pkg, id_: int, output: str | None) -> None: @handle_bpak_errors @open_package("rb") def extract_meta( - pkg, id_: int, output: str | None, part_ref: int + pkg: _bpak.Package, + id_: int, + output: str | None, + part_ref: int, ) -> None: """Extract a metadata value.""" m = pkg.get_meta(id_, part_ref) data = m.raw_data if output is not None: - with open(output, "wb") as f: + with Path(output).open("wb") as f: f.write(data) else: sink = binary_sink(None) diff --git a/python/bpak/_cli/generate.py b/python/bpak/_cli/generate.py index 74dc1b7..6ad5c41 100644 --- a/python/bpak/_cli/generate.py +++ b/python/bpak/_cli/generate.py @@ -3,12 +3,17 @@ from __future__ import annotations import struct +from importlib.metadata import PackageNotFoundError +from importlib.metadata import version as pkg_version import click -from .. import _bpak +from bpak import _bpak + from ._common import handle_bpak_errors, safe_c_identifier +_KEYSTORE_PROVIDER_ID_MIN_BYTES = 4 + @click.group() def generate() -> None: @@ -43,23 +48,22 @@ def generate_id(string: str) -> None: help="Add .keystore_key/.keystore_header section attributes", ) @handle_bpak_errors -def generate_keystore(filename: str, name: str, decorate: bool) -> None: +def generate_keystore(filename: str, name: str, decorate: bool) -> None: # noqa: PLR0915 """Emit a C keystore source file from a bpak keystore package.""" safe = safe_c_identifier(name) try: - from importlib.metadata import version as pkg_version ver = pkg_version("bpak") - except Exception: + except PackageNotFoundError: ver = "unknown" with _bpak.Package(filename, "rb") as pkg: ks_meta = pkg.get_meta(_bpak.id("keystore-provider-id")) raw = ks_meta.raw_data - if len(raw) < 4: + if len(raw) < _KEYSTORE_PROVIDER_ID_MIN_BYTES: raise click.ClickException( "keystore-provider-id metadata is too short " - f"({len(raw)} bytes; need >= 4)" + f"({len(raw)} bytes; need >= {_KEYSTORE_PROVIDER_ID_MIN_BYTES})" ) ks_provider_id = struct.unpack(" None: data = p.read_data() kind, key_data = _bpak.parse_public_key(data) if kind not in _KEY_KIND_NAMES: - raise click.ClickException( - f"Unsupported key type ({kind}) for part 0x{p.id:x}" - ) + raise click.ClickException(f"Unsupported key type ({kind}) for part 0x{p.id:x}") out( f"const struct bpak_key keystore_{safe}_key{key_index} " @@ -104,10 +106,7 @@ def generate_keystore(filename: str, name: str, decorate: bool) -> None: out("") key_index += 1 - out( - f"const struct bpak_keystore keystore_{safe} " - f"{header_decorator if decorate else ''}=" - ) + out(f"const struct bpak_keystore keystore_{safe} {header_decorator if decorate else ''}=") out("{") out(f" .id = 0x{ks_provider_id:x},") out(f" .no_of_keys = {key_index},") diff --git a/python/bpak/_cli/set.py b/python/bpak/_cli/set.py index 705b9d7..56952c3 100644 --- a/python/bpak/_cli/set.py +++ b/python/bpak/_cli/set.py @@ -4,8 +4,9 @@ import click -from .. import _bpak -from .._helpers import encode_meta_value +from bpak import _bpak +from bpak._helpers import encode_meta_value + from ._common import ( BPAK_ID, at_least_one_of, @@ -39,13 +40,14 @@ def set_() -> None: @handle_bpak_errors @open_package("r+") def set_meta( - pkg, id_: int, value: str, encoder: str | None, part_ref: int + pkg: _bpak.Package, + id_: int, + value: str, + encoder: str | None, + part_ref: int, ) -> None: """Update an existing metadata entry, or create it if absent.""" - if encoder: - new_data = encode_meta_value(value, encoder) - else: - new_data = value.encode("ascii") + b"\x00" + new_data = encode_meta_value(value, encoder) if encoder else value.encode("ascii") + b"\x00" try: m = pkg.get_meta(id_, part_ref) @@ -78,7 +80,7 @@ def set_meta( ) @handle_bpak_errors @open_package("r+") -def set_header(pkg, key_id: int | None, keystore_id: int | None) -> None: +def set_header(pkg: _bpak.Package, key_id: int | None, keystore_id: int | None) -> None: """Update header fields (key-id, keystore-id).""" at_least_one_of({"--key-id": key_id, "--keystore-id": keystore_id}) if key_id is not None: diff --git a/python/bpak/_cli/show.py b/python/bpak/_cli/show.py index 3c93edf..f00a6f7 100644 --- a/python/bpak/_cli/show.py +++ b/python/bpak/_cli/show.py @@ -4,7 +4,8 @@ import click -from .. import _bpak +from bpak import _bpak + from ._common import ( BPAK_ID, BPAK_METADATA_BYTES, @@ -17,10 +18,17 @@ class _ShowGroup(click.Group): - """Dispatch ``bpak show FILE`` to the (hidden) summary subcommand so the - user doesn't have to type ``bpak show summary FILE`` explicitly.""" + """Dispatch ``bpak show FILE`` to the (hidden) summary subcommand. + + Allows the user to write ``bpak show FILE`` instead of the explicit + ``bpak show summary FILE``. + """ - def resolve_command(self, ctx, args): + def resolve_command( + self, + ctx: click.Context, + args: list[str], + ) -> tuple[str | None, click.Command | None, list[str]]: if args and args[0] not in self.commands and not args[0].startswith("-"): args = ["summary", *args] return super().resolve_command(ctx, args) @@ -52,21 +60,14 @@ def show_summary(ctx: click.Context, filename: str) -> None: s = _bpak.meta_to_string(pkg, m) or "" id_name = _bpak.id_to_string(m.id) or "" ref_str = f"{m.part_id_ref:08x}" if m.part_id_ref else " " - click.echo( - f" {m.id:08x} {m.size:<3} {id_name:<20s} {ref_str} {s}" - ) + click.echo(f" {m.id:08x} {m.size:<3} {id_name:<20s} {ref_str} {s}") click.echo("\nParts:") - click.echo( - " ID Size Z-pad Flags Transport Size" - ) + click.echo(" ID Size Z-pad Flags Transport Size") for p in pkg.parts: flags = flag_str(p.flags) ts = p.transport_size if p.flags & _bpak.FLAG_TRANSPORT else p.size - click.echo( - f" {p.id:08x} {p.size:<12} {p.pad_bytes:<3} {flags}" - f" {ts:<12}" - ) + click.echo(f" {p.id:08x} {p.size:<12} {p.pad_bytes:<3} {flags} {ts:<12}") digest = pkg.digest if digest: @@ -74,9 +75,7 @@ def show_summary(ctx: click.Context, filename: str) -> None: if verbose: meta_size = sum(m.size for m in pkg.meta) - click.echo( - f"Metadata usage: {meta_size}/{BPAK_METADATA_BYTES} bytes" - ) + click.echo(f"Metadata usage: {meta_size}/{BPAK_METADATA_BYTES} bytes") click.echo(f"Transport size: {pkg.size} bytes") click.echo(f"Installed size: {pkg.installed_size} bytes") @@ -90,11 +89,11 @@ def show_summary(ctx: click.Context, filename: str) -> None: type=BPAK_ID, default=None, help="Filter by part_id_ref; omit to list every ref. " - "Use 0 to pick unassociated/global metadata entries.", + "Use 0 to pick unassociated/global metadata entries.", ) @handle_bpak_errors @open_package("rb") -def show_meta(pkg, id_: int | None, part_ref: int | None) -> None: +def show_meta(pkg: _bpak.Package, id_: int | None, part_ref: int | None) -> None: """Show one metadata entry, or list all of them.""" found = False for m in pkg.meta: @@ -106,9 +105,7 @@ def show_meta(pkg, id_: int | None, part_ref: int | None) -> None: s = _bpak.meta_to_string(pkg, m) or "" id_name = _bpak.id_to_string(m.id) or "" ref_str = f"{m.part_id_ref:08x}" if m.part_id_ref else " " - click.echo( - f"{m.id:08x} {id_name:<20s} ref={ref_str} size={m.size} {s}" - ) + click.echo(f"{m.id:08x} {id_name:<20s} ref={ref_str} size={m.size} {s}") if id_ is not None and not found: if part_ref is not None: raise click.ClickException( @@ -128,7 +125,7 @@ def show_meta(pkg, id_: int | None, part_ref: int | None) -> None: ) @handle_bpak_errors @open_package("rb") -def show_part(pkg, id_: int, show_part_hash: bool) -> None: +def show_part(pkg: _bpak.Package, id_: int, show_part_hash: bool) -> None: """Show a single part.""" if show_part_hash: click.echo(bin2hex(pkg.part_sha256(id_))) @@ -153,7 +150,7 @@ def show_part(pkg, id_: int, show_part_hash: bool) -> None: ) @handle_bpak_errors @open_package("rb") -def show_hash(pkg, binary_mode: bool) -> None: +def show_hash(pkg: _bpak.Package, binary_mode: bool) -> None: """Print the package header hash (Package.digest).""" digest = pkg.digest if not digest: diff --git a/python/bpak/_cli/sign.py b/python/bpak/_cli/sign.py index b3aa3cc..712dfea 100644 --- a/python/bpak/_cli/sign.py +++ b/python/bpak/_cli/sign.py @@ -3,6 +3,7 @@ from __future__ import annotations from pathlib import Path +from typing import TYPE_CHECKING import click @@ -13,6 +14,9 @@ open_package, ) +if TYPE_CHECKING: + from bpak import _bpak + @click.command() @click.argument("filename", type=click.Path(exists=True, dir_okay=False)) @@ -30,18 +34,23 @@ ) @handle_bpak_errors @open_package("r+") -def sign(pkg, key_path: str | None, signature_path: str | None) -> None: +def sign( + pkg: _bpak.Package, + key_path: str | None, + signature_path: str | None, +) -> None: """Sign a bpak file.""" choice = exactly_one_of({"--key": key_path, "--signature": signature_path}) if choice == "--signature": + assert signature_path is not None sig_data = Path(signature_path).read_bytes() if len(sig_data) > BPAK_MAX_SIGNATURE_BYTES: raise click.ClickException( - f"Signature file too large " - f"({len(sig_data)} > {BPAK_MAX_SIGNATURE_BYTES} bytes)" + f"Signature file too large ({len(sig_data)} > {BPAK_MAX_SIGNATURE_BYTES} bytes)" ) pkg.signature = sig_data else: + assert key_path is not None pkg.sign(key_path) @@ -61,13 +70,19 @@ def sign(pkg, key_path: str | None, signature_path: str | None) -> None: ) @handle_bpak_errors @open_package("rb") -def verify(pkg, key_path: str | None, keystore_path: str | None) -> None: +def verify( + pkg: _bpak.Package, + key_path: str | None, + keystore_path: str | None, +) -> None: """Verify a bpak file signature.""" choice = exactly_one_of( - {"--key": key_path, "--keystore": keystore_path} + {"--key": key_path, "--keystore": keystore_path}, ) if choice == "--keystore": + assert keystore_path is not None pkg.verify_with_keystore(keystore_path) else: + assert key_path is not None pkg.verify(key_path) click.echo("Verification OK") diff --git a/python/bpak/_cli/transport.py b/python/bpak/_cli/transport.py index 7a1a713..d88bb20 100644 --- a/python/bpak/_cli/transport.py +++ b/python/bpak/_cli/transport.py @@ -2,11 +2,17 @@ from __future__ import annotations +from typing import TYPE_CHECKING + import click -from .. import _bpak +from bpak import _bpak + from ._common import BPAK_ID, handle_bpak_errors, open_package +if TYPE_CHECKING: + from collections.abc import Callable + @click.group() def transport() -> None: @@ -30,24 +36,28 @@ def transport() -> None: ) @handle_bpak_errors @open_package("r+") -def transport_add(pkg, id_: int, encoder: int, decoder: int) -> None: +def transport_add( + pkg: _bpak.Package, + id_: int, + encoder: int, + decoder: int, +) -> None: """Add transport metadata linking a part to an encoder/decoder pair.""" _bpak.add_transport_meta(pkg, id_, encoder, decoder) def _run_codec( - op, + op: Callable[..., None], filename: str, output: str, origin: str | None, ) -> None: - with _bpak.Package(filename, "rb") as pkg_in: - with _bpak.Package(output, "wb") as pkg_out: - if origin is not None: - with _bpak.Package(origin, "rb") as pkg_origin: - op(pkg_in, pkg_out, pkg_origin) - else: - op(pkg_in, pkg_out) + with _bpak.Package(filename, "rb") as pkg_in, _bpak.Package(output, "wb") as pkg_out: + if origin is not None: + with _bpak.Package(origin, "rb") as pkg_origin: + op(pkg_in, pkg_out, pkg_origin) + else: + op(pkg_in, pkg_out) @transport.command("encode") diff --git a/python/bpak/_helpers.py b/python/bpak/_helpers.py index d90efa9..9114b0f 100644 --- a/python/bpak/_helpers.py +++ b/python/bpak/_helpers.py @@ -5,14 +5,16 @@ import struct import uuid -from . import ( +from bpak import ( HASH_SHA256, HASH_SHA384, HASH_SHA512, - SIGN_PRIME256v1, SIGN_RSA4096, + SIGN_PRIME256v1, SIGN_SECP384r1, SIGN_SECP521r1, +) +from bpak import ( id as bpak_id, ) @@ -51,10 +53,9 @@ def encode_meta_value(value: str, encoder: str) -> bytes: """Encode a metadata value using the specified encoder.""" if encoder == "integer": return struct.pack("