diff --git a/.github/workflows/firmware.yml b/.github/workflows/firmware.yml index a187b3b..8730164 100644 --- a/.github/workflows/firmware.yml +++ b/.github/workflows/firmware.yml @@ -45,8 +45,9 @@ jobs: - name: Run Python tooling selftests run: | - python -m py_compile scripts/check_tdeck_budget.py scripts/fetch_tdeck_artifact.py scripts/serial_harness.py scripts/tdeck_smoke.py scripts/tdm_airtime_smoke.py + python -m py_compile scripts/build_app_package.py scripts/check_tdeck_budget.py scripts/fetch_tdeck_artifact.py scripts/serial_harness.py scripts/tdeck_smoke.py scripts/tdm_airtime_smoke.py python scripts/tdm_airtime_smoke.py --selftest + python scripts/build_app_package.py examples/local-apps/weather-mesh --out /tmp/weather.mesh.zip --device-path /sd/limitlezz/packages/weather.mesh.zip - name: Validate local app samples run: | python -m py_compile scripts/validate_local_app_samples.py diff --git a/docs/tdeck-app-catalog-schema.md b/docs/tdeck-app-catalog-schema.md index af15dd8..975d8df 100644 --- a/docs/tdeck-app-catalog-schema.md +++ b/docs/tdeck-app-catalog-schema.md @@ -1,8 +1,9 @@ # T-Deck App Catalog Schema This is the first Network App Store increment. It defines and validates the -future `index.json` shape, but it does not fetch, download, install, update, or -uninstall packages yet. +future `index.json` shape. Catalog URL refresh and App Store UI install buttons +remain separate work, but the lower-level package transaction can already +install a verified package file that is present on SD/appfs. Catalog indexes are expected at: @@ -52,14 +53,32 @@ Validation rules: - `hue`, if present, must be `-1` or `0..359`. - The catalog can list up to `24` apps. +Package archives: + +- The first firmware-native archive path is a `.zip` using ZIP method `0` + only, meaning stored/uncompressed entries. +- Whole-package `sha256` and exact byte size must match before extraction. +- The package must include root `manifest.json`; the embedded manifest `id` + must match the requested install id. +- File names must be relative, must not contain `..`, backslashes, colons, + absolute roots, hidden path segments, or a top-level `data/` tree. +- Each file is capped at `256 KB`, each package at `2 MB`, and each package at + `24` files. +- Extraction happens into a hidden staging directory, then promotion validates + the manifest and rolls back on failure. + Serial diagnostics: ```text app catalog status app catalog test +app package test +app package install ``` `app catalog status` validates a cached index if one exists and otherwise reports that no cached catalog is present. `app catalog test` runs a built-in valid/invalid schema selftest so hardware smoke can prove the parser without -requiring Wi-Fi or SD setup. +requiring Wi-Fi or SD setup. `app package test` creates and installs a small +stored-ZIP package on-device and proves hash mismatch, id mismatch, unsafe path, +unsupported compression, rollback, and update behavior. diff --git a/docs/tdeck-app-developer-guide.md b/docs/tdeck-app-developer-guide.md index c2ccc58..76b6264 100644 --- a/docs/tdeck-app-developer-guide.md +++ b/docs/tdeck-app-developer-guide.md @@ -30,7 +30,7 @@ Not implemented yet: - arbitrary Lua/script execution - background tasks - network catalog download/update -- app package signatures or SHA verification +- app package signatures - mesh send/receive APIs - notifications API behavior - raw hardware, radio, filesystem, or kernel access @@ -61,6 +61,23 @@ data/ optional; prepared automatically for storage apps assets/ optional; reserved for later richer runtimes ``` +Installable archive packages use a stored-only `.zip` subset for the first +firmware installer: + +```sh +python scripts/build_app_package.py examples/local-apps/weather-mesh \ + --out weather.mesh.zip --device-path /sd/limitlezz/packages/weather.mesh.zip +``` + +The script prints the exact package byte count and SHA256 digest for: + +```text +app package install +``` + +Archives must include root `manifest.json`, use relative file paths only, avoid +hidden path segments and top-level `data/`, and store entries uncompressed. + ## Minimal App Create a package directory: @@ -259,6 +276,7 @@ The native selftest creates sample local app packages and checks: - over-quota blocking - token permission gating - appfs-only discovery +- stored-ZIP package install/update rollback For release or PR validation, use the project workflow: diff --git a/docs/tdeck-feature-inventory.md b/docs/tdeck-feature-inventory.md index ad3fb6e..bb41b38 100644 --- a/docs/tdeck-feature-inventory.md +++ b/docs/tdeck-feature-inventory.md @@ -125,6 +125,7 @@ Status labels: | Network app catalog | Partial | `docs/tdeck-network-app-catalog.md`, `docs/examples/app-catalog-index.json`, and `scripts/validate_app_catalog.py` define and CI-validate the first HTTPS `index.json` contract with SDK compatibility, permissions, package hash/size, and screenshots | Fetch `index.json` over Wi-Fi, verify TLS/metadata on-device, and cache results. | | App download/install/update | Planned | App Store prototype only | SHA256 verify, extract, version updates, rollback failed installs. | | App download/install/update | Planned/Partial | App Store prototype plus bounded package-file SHA256 helper | Wire the verifier into download/staging, then extract, version updates, and rollback failed installs. | +| App download/install/update | Partial | Stored-only ZIP package installer verifies exact SHA256/byte count, extracts into hidden staging, validates embedded manifest id, promotes atomically, and exposes serial `app package test` / `app package install`; native selftest covers rollback for hash mismatch, id mismatch, unsafe path, unsupported compression, and update | Wire catalog download/cache entries and App Store GET/UPDATE actions to the package installer. | | Optional map app | Planned | Store data includes maps; maintainer notes prefer maps as optional | Keep maps out of the base firmware. | | APRS/weather/BBS/scope/game apps | Planned/Prototype catalog entries | Static `LZ_STORE` rows | Implement as sandboxed apps once runtime exists. | diff --git a/docs/tdeck-firmware-roadmap.md b/docs/tdeck-firmware-roadmap.md index e04f738..4ae4a1d 100644 --- a/docs/tdeck-firmware-roadmap.md +++ b/docs/tdeck-firmware-roadmap.md @@ -374,12 +374,19 @@ Deliverables: catalog fetch transport gated on connected Wi-Fi, with native simulator stub coverage for URL/buffer errors. - Cache catalog for offline browsing. -- Download app zip/package. +- Download app zip/package. Initial package transaction: firmware can install a + package file that is already present on SD/appfs; catalog-driven Wi-Fi + download and App Store button wiring remain TODO. - Verify SHA256 before install. Initial foundation: reusable package-file SHA256 hashing and expected-hash verification helpers with native simulator coverage. + Package install now requires exact expected hash and byte count before any + extraction. - Extract to app staging directory, then atomically promote to installed directory. Initial foundation: hidden staging directories, manifest-id validation, live - app backup/rollback promotion, and staging discard helpers. + app backup/rollback promotion, and staging discard helpers. Package install + now extracts stored-only ZIP entries into staging, rejects unsafe paths and + unsupported compression, validates the embedded manifest before promotion, and + selftests rollback/update behavior through serial `app package test`. - Show update badges on installed apps. - Show update badges on installed apps. Initial implementation: local App Store rows compare manifest versions with catalog metadata and display update chips diff --git a/docs/tdeck-network-app-catalog.md b/docs/tdeck-network-app-catalog.md index 73bd1c1..0ac1820 100644 --- a/docs/tdeck-network-app-catalog.md +++ b/docs/tdeck-network-app-catalog.md @@ -77,12 +77,20 @@ field, but when present it must be a compact version string such as `0.95.0`. ## Package Rules -The package URL must be HTTPS. The future installer should download to a staging -directory, verify `package_sha256` and `package_bytes`, extract the package, -validate its embedded `manifest.json` with the local manifest rules, and then -atomically promote the staged package into the app directory. Any failed -download, hash mismatch, manifest rejection, or extraction error must leave the -previous installed package intact. +The package URL must be HTTPS. The lower-level firmware installer accepts a +package file that has already been downloaded or copied to SD/appfs, verifies +`package_sha256` and `package_bytes`, extracts a stored-only `.zip` package into +a hidden staging directory, validates its embedded `manifest.json` with the +local manifest rules, and then atomically promotes the staged package into the +app directory. Any hash mismatch, manifest rejection, unsupported compression, +unsafe path, or extraction error leaves the previous installed package intact. + +The first firmware package subset deliberately avoids a decompressor: ZIP +method `0` is accepted, and compressed ZIP entries are rejected until flash/RAM +budget work says otherwise. Packages may contain up to 24 files, each file may +be up to 256 KB, and the complete archive must fit in the catalog's 2 MB +package limit. Packages must not ship a top-level `data/` tree; runtime data is +prepared by the OS for apps that request the `storage` permission. ## Validation diff --git a/scripts/build_app_package.py b/scripts/build_app_package.py new file mode 100644 index 0000000..96ecdb4 --- /dev/null +++ b/scripts/build_app_package.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +"""Build a LimitlezzOS app package as a deterministic ZIP_STORED archive.""" +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import sys +import zipfile +from pathlib import Path + + +PACKAGE_MAX_BYTES = 2 * 1024 * 1024 +FILE_MAX_BYTES = 256 * 1024 +SAFE_ID = re.compile(r"^[A-Za-z0-9_.-]{1,23}$") + + +def fail(message: str) -> None: + raise SystemExit(f"[package] {message}") + + +def safe_package_path(rel: str) -> bool: + if not rel or rel.startswith("/") or "\\" in rel or ":" in rel: + return False + parts = rel.split("/") + if any(part in {"", ".", ".."} or part.startswith(".") for part in parts): + return False + if parts[0] == "data": + return False + return True + + +def collect_files(root: Path) -> list[Path]: + files = sorted(path for path in root.rglob("*") if path.is_file()) + if not (root / "manifest.json").is_file(): + fail("manifest.json is required at the package root") + if len(files) > 24: + fail("too many files; firmware accepts at most 24") + for path in files: + rel = path.relative_to(root).as_posix() + if not safe_package_path(rel): + fail(f"unsafe package path: {rel}") + if path.stat().st_size > FILE_MAX_BYTES: + fail(f"file too large for firmware package: {rel}") + return files + + +def read_manifest(root: Path) -> dict: + try: + manifest = json.loads((root / "manifest.json").read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + fail(f"manifest.json is not valid JSON: {exc}") + app_id = manifest.get("id") + if not isinstance(app_id, str) or not SAFE_ID.fullmatch(app_id): + fail("manifest id is missing or unsafe") + return manifest + + +def build_package(root: Path, out: Path, files: list[Path]) -> None: + out.parent.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(out, "w", compression=zipfile.ZIP_STORED) as zf: + for path in files: + rel = path.relative_to(root).as_posix() + info = zipfile.ZipInfo(rel, date_time=(2026, 1, 1, 0, 0, 0)) + info.compress_type = zipfile.ZIP_STORED + info.external_attr = 0o100644 << 16 + zf.writestr(info, path.read_bytes()) + if out.stat().st_size > PACKAGE_MAX_BYTES: + out.unlink(missing_ok=True) + fail("package exceeds firmware 2 MB limit") + + +def sha256(path: Path) -> str: + h = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(65536), b""): + h.update(chunk) + return h.hexdigest() + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("app_dir", type=Path, help="directory containing manifest.json") + parser.add_argument("-o", "--out", type=Path, help="output .zip path") + parser.add_argument("--device-path", help="path the package will have on the T-Deck") + args = parser.parse_args() + + root = args.app_dir.resolve() + if not root.is_dir(): + fail(f"not a directory: {root}") + manifest = read_manifest(root) + app_id = manifest["id"] + out = args.out or (Path.cwd() / f"{app_id}.zip") + files = collect_files(root) + build_package(root, out, files) + + digest = sha256(out) + size = out.stat().st_size + install_path = args.device_path or out.as_posix() + print(f"package={out}") + print(f"id={app_id}") + print(f"bytes={size}") + print(f"sha256={digest}") + print(f"serial=app package install {app_id} {install_path} {digest} {size}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/tdeck_smoke.py b/scripts/tdeck_smoke.py index 06d493d..4bb5e35 100644 --- a/scripts/tdeck_smoke.py +++ b/scripts/tdeck_smoke.py @@ -115,16 +115,16 @@ def nostub_upload(project_dir: Path, env_name: str, port: str, baud: int, artifa "--baud", str(baud), "--before", - "default-reset", + "default_reset", "--after", - "hard-reset", + "hard_reset", "--no-stub", - "write-flash", - "--flash-mode", + "write_flash", + "--flash_mode", "dio", - "--flash-freq", + "--flash_freq", "80m", - "--flash-size", + "--flash_size", "16MB", "0x0", str(bootloader), diff --git a/sim/main_sim.c b/sim/main_sim.c index 3073a12..bfe78fb 100644 --- a/sim/main_sim.c +++ b/sim/main_sim.c @@ -1412,6 +1412,21 @@ static int codec_selftest(void) CHECK(lz_store_discard_app_install("weather.mesh", err, sizeof err), "app install staging discard succeeds"); } + /* 10b. app package installer: a stored-ZIP package verifies size/hash, + * extracts into staging, promotes atomically, and rolls back on bad + * packages without replacing the live app. */ + { + extern void lz_store_init(const char *datadir); + extern int lz_store_app_package_selftest(char *buf, int n); + sim_reset_dir("lzdata_apppackage"); + lz_store_init("lzdata_apppackage"); + char pkg[180]; + lz_store_app_package_selftest(pkg, sizeof pkg); + CHECK(strstr(pkg, "PASS") != NULL, + "app package stored-ZIP transaction selftest"); + lz_store_init(NULL); + sim_reset_dir("lzdata_apppackage"); + } /* 10. local app uninstall: users can delete a package while either * retaining scoped data for reinstall or deleting everything. */ { diff --git a/src/serial_cli.cpp b/src/serial_cli.cpp index 26510bc..78b1bb7 100644 --- a/src/serial_cli.cpp +++ b/src/serial_cli.cpp @@ -10,6 +10,7 @@ #ifdef LZ_TARGET_TDECK #include +#include #include "services/emergency_guard.h" #include "services/mesh.h" #include "services/feedback.h" @@ -77,6 +78,7 @@ static void cmd_help(void) " feedback status|test feedback/app-notification diagnostics\n" " app notify test request a test app notification\n" " app catalog status|test app catalog schema diagnostics\n" + " app package test|install \n" " touch [cal|debug|S X Y] touch: 'cal' runs on-screen calibration, 'debug' logs taps, 'S X Y' sets transform\n" " feedback show DND/priority feedback policy\n" " emergency [arm|confirm|cancel] diagnostic emergency trigger guard\n" @@ -464,6 +466,33 @@ static void cmd_app(char *args) Serial.print(b); return; } + if(args && strcmp(args, "package test") == 0) { + char b[180]; + lz_svc_app_package_selftest(b, sizeof b); + Serial.print(b); + return; + } + if(args && strncmp(args, "package install ", 16) == 0) { + char id[24] = {0}, path[80] = {0}, sha[65] = {0}; + unsigned long bytes = 0; + if(sscanf(args + 16, "%23s %79s %64s %lu", id, path, sha, &bytes) != 4 || + bytes == 0 || bytes > LZ_APP_PACKAGE_MAX_BYTES) { + Serial.println("usage: app package install "); + return; + } + lz_app_package_install_t r; + memset(&r, 0, sizeof r); + if(lz_svc_install_app_package(id, path, sha, (uint32_t)bytes, &r)) { + Serial.printf("[ok] app package install id=%s version=%s files=%u package=%lu extracted=%lu\n", + r.id, r.version, (unsigned)r.file_count, + (unsigned long)r.package_bytes, + (unsigned long)r.extracted_bytes); + } else { + Serial.printf("[err] app package install: %s\n", + r.error[0] ? r.error : "install failed"); + } + return; + } if(!args || !args[0] || strcmp(args, "catalog") == 0 || strcmp(args, "catalog status") == 0) { char b[180]; @@ -471,7 +500,7 @@ static void cmd_app(char *args) Serial.print(b); return; } - Serial.println("usage: app notify test | app catalog status | app catalog test"); + Serial.println("usage: app notify test | app catalog status | app catalog test | app package test"); } static void cmd_nodes(char *args) diff --git a/src/services/mesh.h b/src/services/mesh.h index 81cb0b1..49339bc 100644 --- a/src/services/mesh.h +++ b/src/services/mesh.h @@ -40,6 +40,7 @@ extern "C" { #define LZ_FEEDBACK_BODY_MAX 96 #define LZ_APP_CATALOG_JSON_MAX 4096u #define LZ_APP_CATALOG_MAX_APPS 24 +#define LZ_APP_PACKAGE_MAX_BYTES (2u * 1024u * 1024u) #define LZ_OTA_MANIFEST_SCHEMA "limitlezz.ota_manifest.v1" #define LZ_OTA_BOARD_TDECK "tdeck" #define LZ_OTA_SLOT_MAX_BYTES 0x500000u @@ -237,6 +238,16 @@ typedef struct { char first_error[64]; } lz_app_catalog_report_t; +typedef struct { + bool ok; + char id[24]; + char version[16]; + char error[48]; + uint32_t package_bytes; + uint32_t extracted_bytes; + uint16_t file_count; +} lz_app_package_install_t; + typedef struct { char label[24]; /* app-provided foreground control label */ char status[48]; /* bounded status shown after activation */ @@ -304,6 +315,10 @@ bool lz_svc_app_data_usage(const lz_local_app_t *app, uint32_t *used, uint32_t * bool lz_svc_clear_app_data(const lz_local_app_t *app, char *err, int err_cap); bool lz_svc_uninstall_local_app(const lz_local_app_t *app, bool keep_data, char *err, int err_cap); +bool lz_svc_install_app_package(const char *id, const char *package_path, + const char *sha256, uint32_t package_bytes, + lz_app_package_install_t *out); +int lz_svc_app_package_selftest(char *buf, int n); bool lz_svc_save_app_catalog_cache(const char *json, int len, char *err, int err_cap); bool lz_svc_load_app_catalog_cache(char *out, int cap, int *out_len, char *err, int err_cap); bool lz_svc_clear_app_catalog_cache(char *err, int err_cap); diff --git a/src/services/mesh_core.c b/src/services/mesh_core.c index afd12ea..495dc2c 100644 --- a/src/services/mesh_core.c +++ b/src/services/mesh_core.c @@ -35,6 +35,10 @@ bool lz_store_clear_app_catalog_cache(char *err, int err_cap); bool lz_store_start_local_app(const lz_local_app_t *app, lz_local_app_session_t *out); bool lz_store_local_app_action(lz_local_app_session_t *session, int idx); void lz_store_stop_local_app(lz_local_app_session_t *session); +bool lz_store_install_app_package(const char *id, const char *package_path, + const char *sha256, uint32_t package_bytes, + lz_app_package_install_t *out); +int lz_store_app_package_selftest(char *buf, int n); bool lz_store_validate_app_catalog_json(const char *json, lz_app_catalog_report_t *out); int lz_store_app_catalog_diag(char *buf, int n); int lz_store_app_catalog_selftest(char *buf, int n); @@ -492,6 +496,18 @@ int lz_svc_app_catalog_selftest(char *buf, int n) return lz_store_app_catalog_selftest(buf, n); } +bool lz_svc_install_app_package(const char *id, const char *package_path, + const char *sha256, uint32_t package_bytes, + lz_app_package_install_t *out) +{ + return lz_store_install_app_package(id, package_path, sha256, package_bytes, out); +} + +int lz_svc_app_package_selftest(char *buf, int n) +{ + return lz_store_app_package_selftest(buf, n); +} + bool lz_svc_ota_manifest_status(lz_ota_manifest_t *out) { return lz_store_ota_manifest_status(out); diff --git a/src/services/store.c b/src/services/store.c index adff88e..22e775c 100644 --- a/src/services/store.c +++ b/src/services/store.c @@ -706,7 +706,6 @@ static bool safe_entry(const char *s) return true; } -#define LZ_APP_CATALOG_PACKAGE_MAX_BYTES (2u * 1024u * 1024u) #define LZ_APP_CATALOG_SCREENSHOT_MAX 4 static bool catalog_fail(lz_app_catalog_report_t *r, const char *id, const char *msg) @@ -857,7 +856,7 @@ static bool catalog_validate_app(const char *obj, lz_app_catalog_report_t *r) if(!catalog_sha256_ok(sha)) return catalog_fail(r, id, "bad sha256"); if(!json_get_u32(obj, "size", &size)) return catalog_fail(r, id, "missing size"); - if(size == 0 || size > LZ_APP_CATALOG_PACKAGE_MAX_BYTES) + if(size == 0 || size > LZ_APP_PACKAGE_MAX_BYTES) return catalog_fail(r, id, "bad size"); if(json_get_int(obj, "hue", &hue) && (hue < -1 || hue > 359)) return catalog_fail(r, id, "bad hue"); @@ -1052,6 +1051,20 @@ static bool app_retained_name(const char *name) return name && strncmp(name, LZ_APP_RETAINED_PREFIX, strlen(LZ_APP_RETAINED_PREFIX)) == 0; } +static bool app_install_root(char *root, size_t root_cap, char *err, int err_cap) +{ + if(g_persist) { + snprintf(root, root_cap, "%s", g_dir); + return true; + } + if(g_appfs_dir[0]) { + snprintf(root, root_cap, "%s", g_appfs_dir); + return true; + } + set_err(err, err_cap, "storage unavailable"); + return false; +} + static bool app_retained_paths(const char *id, char *root, size_t root_cap, char *data, size_t data_cap, char *err, int err_cap) @@ -1431,10 +1444,6 @@ static bool app_install_paths(const char *id, char *apps, size_t apps_cap, char *backup, size_t backup_cap, char *err, int err_cap) { - if(!g_persist) { - set_err(err, err_cap, "storage unavailable"); - return false; - } if(!safe_id(id) || strlen(id) > LZ_APP_INSTALL_ID_MAX) { set_err(err, err_cap, "unsafe id"); return false; @@ -1449,7 +1458,9 @@ static bool app_install_paths(const char *id, char *apps, size_t apps_cap, return false; } - path_join(apps, apps_cap, g_dir, "apps"); + char root[128]; + if(!app_install_root(root, sizeof root, err, err_cap)) return false; + path_join(apps, apps_cap, root, "apps"); path_join(live, live_cap, apps, id); path_join(staging, staging_cap, apps, staging_name); path_join(backup, backup_cap, apps, backup_name); @@ -1557,6 +1568,533 @@ bool lz_store_promote_app_install(const char *id, char *err, int err_cap) return true; } +#define LZ_ZIP_LOCAL_SIG 0x04034b50u +#define LZ_ZIP_CENTRAL_SIG 0x02014b50u +#define LZ_ZIP_EOCD_SIG 0x06054b50u +#define LZ_ZIP_METHOD_STORED 0u +#define LZ_ZIP_FLAG_ENCRYPTED 0x0001u +#define LZ_ZIP_FLAG_DATA_DESCRIPTOR 0x0008u +#define LZ_APP_PACKAGE_MAX_FILES 24 +#define LZ_APP_PACKAGE_MAX_FILE_BYTES (256u * 1024u) + +static bool app_package_file_path_ok(const char *path) +{ + if(!safe_entry(path)) return false; + if(path[0] == '.' || path[0] == 0) return false; + if(strcmp(path, "data") == 0 || strncmp(path, "data/", 5) == 0) return false; + const char *seg = path; + while(*seg) { + if(*seg == '/' || *seg == '.') return false; + const char *slash = strchr(seg, '/'); + size_t len = slash ? (size_t)(slash - seg) : strlen(seg); + if(len == 0 || (len == 1 && seg[0] == '.')) return false; + seg = slash ? slash + 1 : seg + len; + } + return true; +} + +static bool app_package_dir_path_ok(const char *path) +{ + if(!path || !path[0]) return false; + char tmp[80]; + if(strlen(path) >= sizeof tmp) return false; + snprintf(tmp, sizeof tmp, "%s", path); + size_t len = strlen(tmp); + while(len > 0 && tmp[len - 1] == '/') tmp[--len] = 0; + return len > 0 && app_package_file_path_ok(tmp); +} + +static uint16_t zip_u16(const uint8_t *p) +{ + return (uint16_t)p[0] | ((uint16_t)p[1] << 8); +} + +static uint32_t zip_u32(const uint8_t *p) +{ + return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | + ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24); +} + +static bool app_package_read_exact(FILE *f, void *dst, size_t len, + char *err, int err_cap) +{ + if(len == 0) return true; + if(!f || !dst || fread(dst, 1, len, f) != len) { + set_err(err, err_cap, "package read failed"); + return false; + } + return true; +} + +static bool app_package_skip_bytes(FILE *f, uint32_t bytes, char *err, int err_cap) +{ + uint8_t buf[128]; + uint32_t left = bytes; + while(left > 0) { + size_t want = left < sizeof buf ? (size_t)left : sizeof buf; + if(fread(buf, 1, want, f) != want) { + set_err(err, err_cap, "package truncated"); + return false; + } + left -= (uint32_t)want; + } + return true; +} + +static bool app_package_mkdir_parents(const char *path, char *err, int err_cap) +{ + char tmp[180]; + snprintf(tmp, sizeof tmp, "%s", path); + for(char *p = tmp; *p; p++) { + if(*p != '/' && *p != '\\') continue; + char saved = *p; + *p = 0; + if(tmp[0] && !path_mkdir(tmp)) { + set_err(err, err_cap, "mkdir failed"); + return false; + } + *p = saved; + } + return true; +} + +static bool app_package_copy_bytes(FILE *in, const char *dest, uint32_t bytes, + uint32_t *total, char *err, int err_cap) +{ + if(!app_package_mkdir_parents(dest, err, err_cap)) return false; + FILE *out = fopen(dest, "wb"); + if(!out) { + set_err(err, err_cap, "file create failed"); + return false; + } + uint8_t buf[256]; + uint32_t left = bytes; + while(left > 0) { + size_t want = left < sizeof buf ? (size_t)left : sizeof buf; + size_t got = fread(buf, 1, want, in); + if(got != want) { + fclose(out); + set_err(err, err_cap, "package truncated"); + return false; + } + if(fwrite(buf, 1, got, out) != got) { + fclose(out); + set_err(err, err_cap, "file write failed"); + return false; + } + left -= (uint32_t)got; + if(total) *total += (uint32_t)got; + } + fclose(out); + return true; +} + +static bool lz_store_extract_app_package(const char *package_path, const char *staging, + lz_app_package_install_t *out, + char *err, int err_cap) +{ + FILE *f = fopen(package_path, "rb"); + if(!f) { + set_err(err, err_cap, "package missing"); + return false; + } + + int files = 0; + uint32_t total = 0; + bool saw_manifest = false; + for(;;) { + uint8_t sigb[4]; + size_t got = fread(sigb, 1, sizeof sigb, f); + if(got == 0) break; + if(got != sizeof sigb) { + fclose(f); + set_err(err, err_cap, "package truncated"); + return false; + } + uint32_t sig = zip_u32(sigb); + if(sig == LZ_ZIP_CENTRAL_SIG || sig == LZ_ZIP_EOCD_SIG) break; + if(sig != LZ_ZIP_LOCAL_SIG) { + fclose(f); + set_err(err, err_cap, "bad zip header"); + return false; + } + + uint8_t hdr[26]; + if(!app_package_read_exact(f, hdr, sizeof hdr, err, err_cap)) { + fclose(f); + return false; + } + uint16_t flags = zip_u16(hdr + 2); + uint16_t method = zip_u16(hdr + 4); + uint32_t comp_size = zip_u32(hdr + 14); + uint32_t uncomp_size = zip_u32(hdr + 18); + uint16_t name_len = zip_u16(hdr + 22); + uint16_t extra_len = zip_u16(hdr + 24); + + if((flags & (LZ_ZIP_FLAG_ENCRYPTED | LZ_ZIP_FLAG_DATA_DESCRIPTOR)) != 0) { + fclose(f); + set_err(err, err_cap, "unsupported zip flags"); + return false; + } + if(method != LZ_ZIP_METHOD_STORED) { + fclose(f); + set_err(err, err_cap, "unsupported zip method"); + return false; + } + if(comp_size != uncomp_size || uncomp_size > LZ_APP_PACKAGE_MAX_FILE_BYTES) { + fclose(f); + set_err(err, err_cap, "bad file size"); + return false; + } + if(name_len == 0 || name_len >= 80 || extra_len > 1024) { + fclose(f); + set_err(err, err_cap, "bad zip name"); + return false; + } + + char rel[80]; + if(!app_package_read_exact(f, rel, name_len, err, err_cap)) { + fclose(f); + return false; + } + rel[name_len] = 0; + if(!app_package_skip_bytes(f, extra_len, err, err_cap)) { + fclose(f); + return false; + } + + bool is_dir = rel[name_len - 1] == '/'; + if(is_dir) { + if(comp_size != 0 || !app_package_dir_path_ok(rel)) { + fclose(f); + set_err(err, err_cap, "bad file path"); + return false; + } + char clean[80]; + snprintf(clean, sizeof clean, "%s", rel); + size_t clen = strlen(clean); + while(clen > 0 && clean[clen - 1] == '/') clean[--clen] = 0; + char dir[180]; + path_join(dir, sizeof dir, staging, clean); + if(!app_package_mkdir_parents(dir, err, err_cap) || !path_mkdir(dir)) { + fclose(f); + set_err(err, err_cap, "mkdir failed"); + return false; + } + continue; + } + + if(!app_package_file_path_ok(rel)) { + fclose(f); + set_err(err, err_cap, "bad file path"); + return false; + } + if(++files > LZ_APP_PACKAGE_MAX_FILES) { + fclose(f); + set_err(err, err_cap, "too many files"); + return false; + } + if(total > LZ_APP_PACKAGE_MAX_BYTES || comp_size > LZ_APP_PACKAGE_MAX_BYTES - total) { + fclose(f); + set_err(err, err_cap, "package too large"); + return false; + } + char dest[180]; + path_join(dest, sizeof dest, staging, rel); + if(!app_package_copy_bytes(f, dest, comp_size, &total, err, err_cap)) { + fclose(f); + return false; + } + if(strcmp(rel, "manifest.json") == 0) saw_manifest = true; + } + fclose(f); + if(!saw_manifest) { + set_err(err, err_cap, "missing manifest"); + return false; + } + if(out) { + out->file_count = (uint16_t)files; + out->extracted_bytes = total; + } + return true; +} + +bool lz_store_install_app_package(const char *id, const char *package_path, + const char *sha256, uint32_t package_bytes, + lz_app_package_install_t *out) +{ + lz_app_package_install_t r; + memset(&r, 0, sizeof r); + if(id) snprintf(r.id, sizeof r.id, "%s", id); + r.package_bytes = package_bytes; + char err[48] = ""; + + if(!id || !package_path || !sha256 || package_bytes == 0) { + set_err(err, sizeof err, "missing package args"); + goto fail; + } + if(package_bytes > LZ_APP_PACKAGE_MAX_BYTES) { + set_err(err, sizeof err, "package too large"); + goto fail; + } + struct stat st; + if(stat(package_path, &st) != 0 || S_ISDIR(st.st_mode)) { + set_err(err, sizeof err, "package missing"); + goto fail; + } + if(st.st_size < 0 || (uint32_t)st.st_size != package_bytes) { + set_err(err, sizeof err, "size mismatch"); + goto fail; + } + if(!lz_store_verify_file_sha256(package_path, sha256, err, sizeof err)) + goto fail; + + char staging[160], live[160]; + if(!lz_store_prepare_app_install(id, staging, sizeof staging, + live, sizeof live, err, sizeof err)) + goto fail; + if(!lz_store_extract_app_package(package_path, staging, &r, err, sizeof err)) { + char discard[32]; + lz_store_discard_app_install(id, discard, sizeof discard); + goto fail; + } + if(!lz_store_promote_app_install(id, err, sizeof err)) { + char discard[32]; + lz_store_discard_app_install(id, discard, sizeof discard); + goto fail; + } + + lz_local_app_t app; + char reason[48] = ""; + if(load_app_manifest(live, &app, reason, sizeof reason)) { + snprintf(r.version, sizeof r.version, "%s", app.version); + } + r.ok = true; + if(out) *out = r; + return true; + +fail: + snprintf(r.error, sizeof r.error, "%s", err[0] ? err : "install failed"); + if(out) *out = r; + return false; +} + +static bool app_package_write_u16(FILE *f, uint16_t v) +{ + return fputc(v & 0xff, f) != EOF && fputc((v >> 8) & 0xff, f) != EOF; +} + +static bool app_package_write_u32(FILE *f, uint32_t v) +{ + return fputc(v & 0xff, f) != EOF && fputc((v >> 8) & 0xff, f) != EOF && + fputc((v >> 16) & 0xff, f) != EOF && fputc((v >> 24) & 0xff, f) != EOF; +} + +static bool app_package_write_local(FILE *f, const char *name, const char *body, + uint16_t method) +{ + size_t name_len = name ? strlen(name) : 0; + size_t body_len = body ? strlen(body) : 0; + if(!f || name_len == 0 || name_len > 0xffffu || body_len > 0xffffffffu) return false; + if(!app_package_write_u32(f, LZ_ZIP_LOCAL_SIG) || + !app_package_write_u16(f, 20) || !app_package_write_u16(f, 0) || + !app_package_write_u16(f, method) || + !app_package_write_u16(f, 0) || !app_package_write_u16(f, 0) || + !app_package_write_u32(f, 0) || + !app_package_write_u32(f, (uint32_t)body_len) || + !app_package_write_u32(f, (uint32_t)body_len) || + !app_package_write_u16(f, (uint16_t)name_len) || + !app_package_write_u16(f, 0)) + return false; + if(fwrite(name, 1, name_len, f) != name_len) return false; + if(body_len > 0 && fwrite(body, 1, body_len, f) != body_len) return false; + return true; +} + +static bool app_package_selftest_write_zip(const char *path, const char *id, + const char *version, + const char *extra_path, + uint16_t main_method) +{ + FILE *f = fopen(path, "wb"); + if(!f) return false; + char manifest[360]; + snprintf(manifest, sizeof manifest, + "{\"id\":\"%s\",\"name\":\"Package Selftest\",\"version\":\"%s\"," + "\"author\":\"Limitless\",\"entry\":\"main.lua\"," + "\"icon\":\"package\",\"hue\":84,\"api_version\":\"0.1\"," + "\"permissions\":[\"display\"],\"summary\":\"Installer selftest\"}", + id, version); + bool ok = app_package_write_local(f, "manifest.json", manifest, LZ_ZIP_METHOD_STORED) && + app_package_write_local(f, "main.lua", + "-- title: Package Selftest\n" + "-- body: Installed from package\n" + "return true\n", + main_method); + if(ok && extra_path) { + ok = app_package_write_local(f, extra_path, "extra package payload\n", + LZ_ZIP_METHOD_STORED); + } + ok = ok && app_package_write_u32(f, LZ_ZIP_EOCD_SIG) && + app_package_write_u16(f, 0) && app_package_write_u16(f, 0) && + app_package_write_u16(f, 0) && app_package_write_u16(f, 0) && + app_package_write_u32(f, 0) && app_package_write_u32(f, 0) && + app_package_write_u16(f, 0); + fclose(f); + if(!ok) remove(path); + return ok; +} + +static bool app_package_file_size_u32(const char *path, uint32_t *out) +{ + struct stat st; + if(stat(path, &st) != 0 || S_ISDIR(st.st_mode) || st.st_size < 0 || + st.st_size > (long)LZ_APP_PACKAGE_MAX_BYTES) + return false; + if(out) *out = (uint32_t)st.st_size; + return true; +} + +static bool app_package_hash_and_size(const char *path, char *sha, int sha_cap, + uint32_t *bytes, char *err, int err_cap) +{ + if(!app_package_file_size_u32(path, bytes)) { + set_err(err, err_cap, "size failed"); + return false; + } + return lz_store_file_sha256(path, sha, sha_cap, err, err_cap); +} + +static bool app_package_selftest_live_version(const char *id, const char *version) +{ + char apps[128], live[160], staging[160], backup[160], err[48]; + if(!app_install_paths(id, apps, sizeof apps, live, sizeof live, + staging, sizeof staging, backup, sizeof backup, + err, sizeof err)) + return false; + lz_local_app_t app; + char reason[48]; + return load_app_manifest(live, &app, reason, sizeof reason) && + strcmp(app.id, id) == 0 && strcmp(app.version, version) == 0; +} + +static void app_package_selftest_cleanup(const char *id, const char *package_path) +{ + if(package_path && package_path[0]) remove(package_path); + char apps[128], live[160], staging[160], backup[160], err[48]; + if(app_install_paths(id, apps, sizeof apps, live, sizeof live, + staging, sizeof staging, backup, sizeof backup, + err, sizeof err)) { + remove_tree(staging, err, sizeof err); + remove_tree(backup, err, sizeof err); + remove_tree(live, err, sizeof err); + } +} + +int lz_store_app_package_selftest(char *buf, int n) +{ + if(!buf || n <= 0) return 0; + const char *id = "lz.pkg.selftest"; + char root[128], err[48] = ""; + if(!app_install_root(root, sizeof root, err, sizeof err)) + return snprintf(buf, (size_t)n, "App package selftest: SKIP %s\n", + err[0] ? err : "storage unavailable"); + if(!path_mkdir(root)) + return snprintf(buf, (size_t)n, "App package selftest: SKIP root unavailable\n"); + + char package_path[160]; + path_join(package_path, sizeof package_path, root, ".lz-pkg-selftest.zip"); + app_package_selftest_cleanup(id, package_path); + + bool ok = true; + char detail[64] = ""; + char sha[65]; + uint32_t bytes = 0; + lz_app_package_install_t r; + + #define PKG_CHECK(cond, msg) do { \ + if(ok && !(cond)) { ok = false; snprintf(detail, sizeof detail, "%s", msg); } \ + } while(0) + + PKG_CHECK(app_package_selftest_write_zip(package_path, id, "1.0.0", NULL, + LZ_ZIP_METHOD_STORED), + "write v1"); + PKG_CHECK(app_package_hash_and_size(package_path, sha, sizeof sha, &bytes, + err, sizeof err), + "hash v1"); + PKG_CHECK(lz_store_install_app_package(id, package_path, sha, bytes, &r) && + r.ok && strcmp(r.version, "1.0.0") == 0 && r.file_count == 2, + "install v1"); + PKG_CHECK(app_package_selftest_live_version(id, "1.0.0"), + "live v1"); + + static const char zero_sha[] = + "0000000000000000000000000000000000000000000000000000000000000000"; + memset(&r, 0, sizeof r); + PKG_CHECK(!lz_store_install_app_package(id, package_path, zero_sha, bytes, &r) && + strcmp(r.error, "sha mismatch") == 0 && + app_package_selftest_live_version(id, "1.0.0"), + "bad hash rollback"); + + PKG_CHECK(app_package_selftest_write_zip(package_path, "lz.pkg.wrong", "9.9.9", + NULL, LZ_ZIP_METHOD_STORED), + "write wrong id"); + PKG_CHECK(app_package_hash_and_size(package_path, sha, sizeof sha, &bytes, + err, sizeof err), + "hash wrong id"); + memset(&r, 0, sizeof r); + PKG_CHECK(!lz_store_install_app_package(id, package_path, sha, bytes, &r) && + strcmp(r.error, "id mismatch") == 0 && + app_package_selftest_live_version(id, "1.0.0"), + "id mismatch rollback"); + + PKG_CHECK(app_package_selftest_write_zip(package_path, id, "1.1.0", + "data/cache.bin", LZ_ZIP_METHOD_STORED), + "write bad path"); + PKG_CHECK(app_package_hash_and_size(package_path, sha, sizeof sha, &bytes, + err, sizeof err), + "hash bad path"); + memset(&r, 0, sizeof r); + PKG_CHECK(!lz_store_install_app_package(id, package_path, sha, bytes, &r) && + strcmp(r.error, "bad file path") == 0 && + app_package_selftest_live_version(id, "1.0.0"), + "path rollback"); + + PKG_CHECK(app_package_selftest_write_zip(package_path, id, "1.2.0", NULL, 8), + "write deflated"); + PKG_CHECK(app_package_hash_and_size(package_path, sha, sizeof sha, &bytes, + err, sizeof err), + "hash deflated"); + memset(&r, 0, sizeof r); + PKG_CHECK(!lz_store_install_app_package(id, package_path, sha, bytes, &r) && + strcmp(r.error, "unsupported zip method") == 0 && + app_package_selftest_live_version(id, "1.0.0"), + "method rollback"); + + PKG_CHECK(app_package_selftest_write_zip(package_path, id, "2.0.0", + "assets/readme.txt", LZ_ZIP_METHOD_STORED), + "write v2"); + PKG_CHECK(app_package_hash_and_size(package_path, sha, sizeof sha, &bytes, + err, sizeof err), + "hash v2"); + memset(&r, 0, sizeof r); + PKG_CHECK(lz_store_install_app_package(id, package_path, sha, bytes, &r) && + r.ok && strcmp(r.version, "2.0.0") == 0 && r.file_count == 3 && + app_package_selftest_live_version(id, "2.0.0"), + "update v2"); + + #undef PKG_CHECK + + app_package_selftest_cleanup(id, package_path); + return snprintf(buf, (size_t)n, + "App package selftest: %s version=%s files=%u%s%s\n", + ok ? "PASS" : "FAIL", + ok ? "2.0.0" : "-", + ok ? (unsigned)r.file_count : 0u, + detail[0] ? " error=" : "", + detail); +} + static void scan_app_root(const char *apps_dir, lz_local_app_t *out, int cap, int *count) { if(!apps_dir || !out || !count || *count >= cap) return;