diff --git a/README.md b/README.md index 954c83b..d42521b 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,9 @@ XTEINK-MANIFEST|1 || ``` -App IDs and file paths cannot contain `|`. App IDs use only letters, numbers, `_`, and `-`. Descriptions come from the `-- DESCRIPTION:` line in each `main.lua`. +App IDs use only letters, numbers, `_`, and `-`. Payload paths use ASCII letters, numbers, `.`, `_`, `/`, and `-`; empty files and traversal components are rejected. Descriptions come from the `-- DESCRIPTION:` line in each `main.lua`. + +Compatible catalogs contain up to 64 apps, with at most 64 files, 8 MB per file, and 16 MB total per app. Catalogs are limited to 32 KB and manifests to 16 KB. Downloads are size- and SHA-256-checked. HTTPS is encrypted but the firmware does not authenticate peer certificates, so repository metadata is not protected against an active network attacker. ## Adding or changing an app @@ -51,9 +53,4 @@ App IDs and file paths cannot contain `|`. App IDs use only letters, numbers, `_ scripts/update-manifests.py --check ``` -Commit app payloads, manifests, and `catalog.txt` together. - -## Included apps - -- **HomeAssistant** — Edit its installed `config.txt` with your URL, long-lived access token, and entity IDs. The repository contains placeholders only. -- **Wordle** — Includes its five-letter candidate list in `words.txt`. +Commit app payloads, manifests, and `catalog.txt` together. An app's root `README.md` is repository documentation and is intentionally excluded from its install manifest. diff --git a/apps/AppStore/README.md b/apps/AppStore/README.md new file mode 100644 index 0000000..32a698d --- /dev/null +++ b/apps/AppStore/README.md @@ -0,0 +1,18 @@ +# AppStore + +Installs new Lua apps from one or more public Xteink repositories. + +## Bootstrap + +Copy this directory to `/plugins/AppStore` on the SD card. On first launch, AppStore creates `repositories.txt` from `repositories.default.txt`. + +Repository entries use this format: + +```text +XTEINK-REPOSITORIES|1 +Name|https://host/path/catalog.txt +``` + +AppStore downloads each package into `/plugins/.install-`, verifies declared sizes and SHA-256 hashes, requires `main.lua`, then renames the completed directory into place. Existing apps are left unchanged; updates, including self-update, are not supported yet. + +HTTPS is encrypted without peer-certificate authentication. An active network attacker can replace both metadata and payload hashes. diff --git a/apps/AppStore/main.lua b/apps/AppStore/main.lua new file mode 100644 index 0000000..5628a20 --- /dev/null +++ b/apps/AppStore/main.lua @@ -0,0 +1,348 @@ +-- DESCRIPTION: Install apps from public Xteink repositories + +local ROOT = "/plugins/AppStore" +local REPOSITORIES_PATH = ROOT .. "/repositories.txt" +local DEFAULT_REPOSITORIES_PATH = ROOT .. "/repositories.default.txt" +local CATALOG_PATH = ROOT .. "/.catalog.txt" +local MAX_CATALOG_BYTES = 32768 +local MAX_MANIFEST_BYTES = 16384 +local MAX_APP_BYTES = 16 * 1024 * 1024 +local MAX_FILE_BYTES = 8 * 1024 * 1024 +local MAX_FILES = 64 + +local screen = "repositories" +local repositories = {} +local apps = {} +local selected = 1 +local message = "" +local pendingRepository = nil +local needsDraw = true + +local function removeFile(path) + if fs.exists(path) then fs.remove(path) end +end + +local function clearTree(path) + if not fs.exists(path) then return true end + local ok, err = fs.removeTree(path) + if not ok or fs.exists(path) then return nil, err or "Could not remove " .. path end + return true +end + +local function validRelativePath(path) + if not path or path == "" or path:sub(1, 1) == "/" or path:find("\\", 1, true) then return false end + if not path:match("^[%w%._/%-]+$") then return false end + for component in path:gmatch("[^/]+") do + if component == "." or component == ".." then return false end + end + return not path:find("//", 1, true) and path:sub(-1) ~= "/" +end + +local function validHash(value) + return value and #value == 64 and value:match("^[0-9a-fA-F]+$") ~= nil +end + +local function readLines(path, expectedHeader, onLine) + local offset = 0 + local first = true + while true do + local line, nextOffset = fs.readLineAt(path, offset) + if not line then + if nextOffset then return nil, "Line exceeds 192 bytes" end + break + end + offset = nextOffset + if first then + first = false + if line ~= expectedHeader then return nil, "Unsupported format" end + elseif line ~= "" and line:sub(1, 1) ~= "#" then + local ok, err = onLine(line) + if not ok then return nil, err end + end + end + if first then return nil, "Empty file" end + return true +end + +local function loadRepositories() + if not fs.exists(REPOSITORIES_PATH) then + local defaults = fs.readFile(DEFAULT_REPOSITORIES_PATH) + if not defaults or not fs.writeFile(REPOSITORIES_PATH, defaults) then + return nil, "Missing repositories.txt" + end + end + + local result = {} + local ok, err = readLines(REPOSITORIES_PATH, "XTEINK-REPOSITORIES|1", function(line) + local name, url = line:match("^([^|]+)|(https://[^/|]+/[^|]+)$") + if not name or #name > 48 or #url > 160 or url:find("[?#]") then + return nil, "Invalid repository entry" + end + if #result >= 8 then return nil, "Too many repositories" end + result[#result + 1] = { name = name, url = url } + return true + end) + if not ok then return nil, err end + if #result == 0 then return nil, "No repositories configured" end + repositories = result + return true +end + +local function renderStatus(text) + gui.clear() + gui.drawCenteredText(FONT_UI_12, math.floor(gui.height() / 2), text, COLOR_BLACK, STYLE_BOLD) + gui.refresh(REFRESH_HALF) +end + +local function download(url, destination, maxBytes, expectedSize, hash) + removeFile(destination) + return net.download(url, destination, { + maxBytes = maxBytes, + expectedSize = expectedSize, + sha256 = hash + }) +end + +local function loadCatalog(repository) + apps = {} + collectgarbage("collect") + renderStatus("Downloading catalog...") + local bytes, err = download(repository.url, CATALOG_PATH, MAX_CATALOG_BYTES) + if not bytes then return nil, err end + + local baseUrl = repository.url:match("^(.*)/[^/]+$") + if not baseUrl then return nil, "Invalid catalog URL" end + + local result = {} + local ok + ok, err = readLines(CATALOG_PATH, "XTEINK-CATALOG|1", function(line) + local id, description, manifestPath, sizeText, hash = + line:match("^([%w_-]+)|([^|]+)|([^|]+)|(%d+)|([0-9a-fA-F]+)$") + local size = tonumber(sizeText) + if not id or #id > 48 or #description > 80 or not validRelativePath(manifestPath) or + not size or size < 1 or size > MAX_MANIFEST_BYTES or not validHash(hash) then + return nil, "Invalid catalog entry" + end + if #result >= 64 then return nil, "Too many apps" end + result[#result + 1] = { + id = id, + description = description, + manifestPath = manifestPath, + manifestSize = size, + manifestHash = hash:lower(), + baseUrl = baseUrl + } + return true + end) + removeFile(CATALOG_PATH) + if not ok then return nil, err end + if #result == 0 then return nil, "Repository has no apps" end + apps = result + return true +end + +local function parseManifest(path) + local files = {} + local seenFiles = {} + local seenDirectories = {} + local total = 0 + local hasMain = false + local ok, err = readLines(path, "XTEINK-MANIFEST|1", function(line) + local relative, sizeText, hash = line:match("^([^|]+)|(%d+)|([0-9a-fA-F]+)$") + local size = tonumber(sizeText) + local lower = relative and relative:lower() + if not validRelativePath(relative) or lower == ".manifest.txt" or not size or size < 1 or + size > MAX_FILE_BYTES or not validHash(hash) or seenFiles[lower] or seenDirectories[lower] then + return nil, "Invalid manifest entry" + end + local parent = "" + for component in lower:gmatch("[^/]+") do + if parent ~= "" then + if seenFiles[parent] then return nil, "Manifest path collision" end + seenDirectories[parent] = true + end + parent = parent == "" and component or parent .. "/" .. component + end + if #files >= MAX_FILES or total + size > MAX_APP_BYTES then return nil, "App is too large" end + seenFiles[lower] = true + total = total + size + hasMain = hasMain or relative == "main.lua" + files[#files + 1] = { path = relative, size = size, hash = hash:lower() } + return true + end) + if not ok then return nil, err end + if not hasMain then return nil, "Manifest has no main.lua" end + return files +end + +local function failInstall(stage, installError) + local ok, cleanupError = clearTree(stage) + if not ok then return nil, tostring(installError) .. "; cleanup failed: " .. tostring(cleanupError) end + return nil, installError +end + +local function install(app) + local target = "/plugins/" .. app.id + if fs.exists(target) then return nil, "App is already installed" end + + local stage = "/plugins/.install-" .. app.id + local ok, err = clearTree(stage) + if not ok then return nil, err end + ok, err = fs.mkdir(stage) + if not ok then return nil, err end + + local manifestUrl = app.baseUrl .. "/" .. app.manifestPath + local manifestBase = manifestUrl:match("^(.*)/[^/]+$") + local manifestPath = stage .. "/.manifest.txt" + renderStatus("Downloading " .. app.id .. " manifest...") + local bytes + bytes, err = download(manifestUrl, manifestPath, MAX_MANIFEST_BYTES, app.manifestSize, app.manifestHash) + if not bytes then return failInstall(stage, err) end + + local files + files, err = parseManifest(manifestPath) + if not files then return failInstall(stage, err) end + + for index, file in ipairs(files) do + local parent = file.path:match("^(.*)/[^/]+$") + if parent then + ok, err = fs.mkdir(stage .. "/" .. parent) + if not ok then return failInstall(stage, err) end + end + renderStatus("Installing " .. app.id .. " " .. index .. "/" .. #files) + bytes, err = download(manifestBase .. "/" .. file.path, stage .. "/" .. file.path, + file.size, file.size, file.hash) + if not bytes then return failInstall(stage, err) end + end + ok, err = fs.remove(manifestPath) + if not ok then return failInstall(stage, err) end + + ok, err = fs.rename(stage, target) + if not ok then return failInstall(stage, err) end + return true, app.id .. " installed" +end + +local function showMessage(text, returnScreen) + message = text + screen = "message" + pendingRepository = returnScreen + needsDraw = true +end + +local function drawList(title, items, label, description) + gui.clear() + gui.drawCenteredText(FONT_UI_12, 28, title, COLOR_BLACK, STYLE_BOLD) + local visible = math.max(1, math.floor((gui.height() - 130) / 38)) + local first = math.max(1, math.min(selected - math.floor(visible / 2), #items - visible + 1)) + local last = math.min(#items, first + visible - 1) + local y = 72 + for index = first, last do + local prefix = index == selected and "> " or " " + gui.drawText(FONT_UI_12, 36, y, prefix .. label(items[index]), COLOR_BLACK, + index == selected and STYLE_BOLD or STYLE_NORMAL) + y = y + 38 + end + if items[selected] and description then + gui.drawCenteredText(FONT_UI_10, gui.height() - 82, description(items[selected]), COLOR_BLACK, STYLE_NORMAL) + end + gui.drawButtonHints("Back", "", "Select", "") + gui.refresh(REFRESH_HALF) +end + +local function render() + if screen == "repositories" then + drawList("Repositories", repositories, function(item) return item.name end) + elseif screen == "apps" then + drawList("Apps", apps, function(item) return item.id end, function(item) return item.description end) + elseif screen == "connecting" then + renderStatus("Connecting to Wi-Fi...") + else + gui.clear() + gui.drawCenteredText(FONT_UI_12, math.floor(gui.height() / 2), message:sub(1, 100), COLOR_BLACK, STYLE_BOLD) + gui.drawButtonHints("Back", "", "OK", "") + gui.refresh(REFRESH_HALF) + end + needsDraw = false +end + +local function moveSelection(delta, items) + if #items == 0 then return end + selected = ((selected - 1 + delta) % #items) + 1 + needsDraw = true +end + +function init() + local ok, err = loadRepositories() + if not ok then + showMessage(err, "exit") + return + end +end + +function draw() + if screen == "connecting" then + if input.wasPressed("back") then + net.wifiDisconnect() + pendingRepository = nil + selected = 1 + screen = "repositories" + needsDraw = true + else + local status = net.wifiStatus() + if status == "connected" then + local repository = pendingRepository + pendingRepository = nil + local ok, err = loadCatalog(repository) + if ok then + selected = 1 + screen = "apps" + needsDraw = true + else + showMessage(err, "repositories") + end + elseif status == "failed" then + net.wifiDisconnect() + showMessage("Wi-Fi connection failed", "repositories") + end + end + elseif input.wasPressed("up") then + moveSelection(-1, screen == "apps" and apps or repositories) + elseif input.wasPressed("down") then + moveSelection(1, screen == "apps" and apps or repositories) + elseif input.wasPressed("confirm") then + if screen == "repositories" then + pendingRepository = repositories[selected] + net.wifiConnect() + screen = "connecting" + needsDraw = true + elseif screen == "apps" then + local ok, result = install(apps[selected]) + showMessage(result, "apps") + elseif screen == "message" then + if pendingRepository == "exit" then + net.wifiDisconnect() + sys.exit() + return + end + screen = pendingRepository or "repositories" + pendingRepository = nil + needsDraw = true + end + elseif input.wasPressed("back") then + if screen == "apps" then + selected = 1 + screen = "repositories" + needsDraw = true + elseif screen == "message" and pendingRepository ~= "exit" then + screen = pendingRepository or "repositories" + pendingRepository = nil + needsDraw = true + else + net.wifiDisconnect() + sys.exit() + return + end + end + + if needsDraw then render() end +end diff --git a/apps/AppStore/manifest.txt b/apps/AppStore/manifest.txt new file mode 100644 index 0000000..b1cd8eb --- /dev/null +++ b/apps/AppStore/manifest.txt @@ -0,0 +1,3 @@ +XTEINK-MANIFEST|1 +main.lua|12522|cd61c00facba80e7c0a350e69d2e8701b1e4e4baaac5a6dc26d949225f00747f +repositories.default.txt|108|70e442650788dd8d8632142ef3ff43a4aaee9855da89283facc72a8942e0a7df diff --git a/apps/AppStore/repositories.default.txt b/apps/AppStore/repositories.default.txt new file mode 100644 index 0000000..0e9c932 --- /dev/null +++ b/apps/AppStore/repositories.default.txt @@ -0,0 +1,2 @@ +XTEINK-REPOSITORIES|1 +Xteink Apps|https://gitea.va.reichard.io/evan/xteink-apps/raw/branch/main/catalog.txt diff --git a/apps/HomeAssistant/README.md b/apps/HomeAssistant/README.md new file mode 100644 index 0000000..62b952c --- /dev/null +++ b/apps/HomeAssistant/README.md @@ -0,0 +1,14 @@ +# HomeAssistant + +Displays up to 12 Home Assistant entities as status cards. + +After installation, edit `config.txt`: + +```text +url=https://home-assistant.example.com +token=replace-with-a-long-lived-access-token +entity=weather.home +entity=light.living_room +``` + +Create a long-lived access token from your Home Assistant profile. Keep `config.txt` private; it contains credentials. The repository version contains placeholders only. diff --git a/apps/Wordle/README.md b/apps/Wordle/README.md new file mode 100644 index 0000000..e26d45e --- /dev/null +++ b/apps/Wordle/README.md @@ -0,0 +1,3 @@ +# Wordle + +Guess a five-letter word in six tries. The bundled `words.txt` file contains the candidate answers used by the app. diff --git a/catalog.txt b/catalog.txt index 4c3b850..5b6af85 100644 --- a/catalog.txt +++ b/catalog.txt @@ -1,3 +1,4 @@ XTEINK-CATALOG|1 +AppStore|Install apps from public Xteink repositories|apps/AppStore/manifest.txt|192|71d3be6bdaec97b268bb9b7bf4fe553c23a74587251676fe88d90024fa8c703c HomeAssistant|Home Assistant status cards|apps/HomeAssistant/manifest.txt|177|27aa1f0edc78a5aeded413caf0508788fa597ee8f76a5b8515ec0c40c955d5be Wordle|Guess the five-letter word in six tries|apps/Wordle/manifest.txt|177|9977b49ffdbd67a254701b4fa38407fc9128a017673e3ce73560dea08a6a125e diff --git a/scripts/update-manifests.py b/scripts/update-manifests.py index ebde15e..feda7f1 100755 --- a/scripts/update-manifests.py +++ b/scripts/update-manifests.py @@ -13,7 +13,14 @@ from pathlib import Path ROOT = Path(__file__).resolve().parent.parent APPS_DIR = ROOT / "apps" ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]+$") +PATH_PATTERN = re.compile(r"^[A-Za-z0-9._/-]+$") DESCRIPTION_PATTERN = re.compile(r"^--\s*DESCRIPTION:\s*(.+?)\s*$") +MAX_APPS = 64 +MAX_FILES = 64 +MAX_FILE_BYTES = 8 * 1024 * 1024 +MAX_APP_BYTES = 16 * 1024 * 1024 +MAX_MANIFEST_BYTES = 16384 +MAX_CATALOG_BYTES = 32768 def sha256(data: bytes) -> str: @@ -24,7 +31,7 @@ def payload_files(app_dir: Path): for path in sorted(app_dir.rglob("*")): if path.is_symlink(): raise ValueError(f"symlinks are not supported: {path.relative_to(ROOT)}") - if path.is_file() and path.name != "manifest.txt": + if path.is_file() and path.relative_to(app_dir).as_posix() not in {"manifest.txt", "README.md"}: yield path @@ -40,35 +47,76 @@ def description(app_dir: Path) -> str: raise ValueError(f"missing DESCRIPTION in {app_dir.name}/main.lua") +def encode_lines(lines: list[str]) -> bytes: + for line in lines: + if len(line.encode()) > 192: + raise ValueError(f"generated line exceeds 192 bytes: {line[:40]}") + return ("\n".join(lines) + "\n").encode() + + def build_manifest(app_dir: Path) -> bytes: lines = ["XTEINK-MANIFEST|1"] + seen_files = set() + seen_directories = set() + total = 0 for path in payload_files(app_dir): relative = path.relative_to(app_dir).as_posix() - if "|" in relative: - raise ValueError(f"path contains '|': {relative}") + lower = relative.lower() + if (not PATH_PATTERN.fullmatch(relative) or lower == ".manifest.txt" or + any(part in ("", ".", "..") for part in relative.split("/"))): + raise ValueError(f"unsupported app path: {relative}") + parent = "" + for component in lower.split("/"): + if parent: + if parent in seen_files: + raise ValueError(f"path collision: {relative}") + seen_directories.add(parent) + parent = component if not parent else f"{parent}/{component}" + if lower in seen_files or lower in seen_directories: + raise ValueError(f"path collision: {relative}") data = path.read_bytes() + if not data or len(data) > MAX_FILE_BYTES: + raise ValueError(f"unsupported app file size: {relative}") + total += len(data) + if len(lines) > MAX_FILES or total > MAX_APP_BYTES: + raise ValueError(f"app exceeds package limits: {app_dir.name}") + seen_files.add(lower) lines.append(f"{relative}|{len(data)}|{sha256(data)}") - return ("\n".join(lines) + "\n").encode() + manifest = encode_lines(lines) + if len(manifest) > MAX_MANIFEST_BYTES: + raise ValueError(f"manifest exceeds {MAX_MANIFEST_BYTES} bytes: {app_dir.name}") + return manifest def generated_files(): apps = [path for path in APPS_DIR.iterdir() if path.is_dir() and (path / "main.lua").is_file()] apps.sort(key=lambda path: path.name.casefold()) + if len(apps) > MAX_APPS: + raise ValueError(f"catalog exceeds {MAX_APPS} apps") manifests = {} catalog = ["XTEINK-CATALOG|1"] + seen_ids = set() for app_dir in apps: app_id = app_dir.name - if not ID_PATTERN.fullmatch(app_id): - raise ValueError(f"invalid app id: {app_id}") + lower_id = app_id.lower() + if not ID_PATTERN.fullmatch(app_id) or len(app_id) > 48 or lower_id in seen_ids: + raise ValueError(f"invalid or duplicate app id: {app_id}") + seen_ids.add(lower_id) + app_description = description(app_dir) + if len(app_description.encode()) > 80: + raise ValueError(f"description exceeds 80 bytes: {app_id}") manifest = build_manifest(app_dir) manifest_path = f"apps/{app_id}/manifest.txt" manifests[ROOT / manifest_path] = manifest catalog.append( - f"{app_id}|{description(app_dir)}|{manifest_path}|{len(manifest)}|{sha256(manifest)}" + f"{app_id}|{app_description}|{manifest_path}|{len(manifest)}|{sha256(manifest)}" ) - manifests[ROOT / "catalog.txt"] = ("\n".join(catalog) + "\n").encode() + catalog_data = encode_lines(catalog) + if len(catalog_data) > MAX_CATALOG_BYTES: + raise ValueError(f"catalog exceeds {MAX_CATALOG_BYTES} bytes") + manifests[ROOT / "catalog.txt"] = catalog_data return manifests