-- SPDX-FileCopyrightText: © 2026 Vladimir Zorin -- SPDX-License-Identifier: LicenseRef-OWL-1.0-or-later -- Licensed under OWL v1.0+. See LICENSE. --[==[ LILPACK package system for Lilush. Discovers and loads Lua modules from MNEME-backed `.lilpack` databases installed in `~/.local/share/lilush/packages/`. Replaces filesystem-based `package.path` lookups to avoid clashing with system Lua modules. Convention: each `.lilpack` file is a MNEME database with a `__lilpack` manifest keyspace (version, name, modules list, etc.) and a `modules` KV keyspace mapping dot-separated module names to Lua source code. ]==] local std = require("std") local json = require("cjson.safe") local mneme = require("mneme") local crypto = require("crypto") local semver = require("lilpack.semver") local CONVENTION_VERSION = 1 local PACKAGES_DIR = std.home() .. "/.local/share/lilush/packages" local DEFAULT_PUB_KEY = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICEWU0xshVgOIyjzQEOKtjG8sU8sWJPh25CP/ISfJRey" local FINGERPRINT_DISPLAY_LEN = 16 -- Module registry: module_name -> { db_path, pack_name } local module_registry = {} -- Package list: pack_name -> { db_path, manifest fields... } local package_registry = {} -- Lazy-opened MNEME database handles: db_path -> db local open_dbs = {} -- Extensions discovered from manifests local extensions = { builtins = {}, modes = {}, cli = {}, theme = { sections = {}, definitions = {} }, } local initialized = false local get_db = function(db_path) if open_dbs[db_path] then return open_dbs[db_path] end local db, err = mneme.open(db_path, { readonly = true, create = false }) if not db then io.stderr:write("lilpack: failed to open " .. db_path .. ": " .. tostring(err) .. "\n") return nil end open_dbs[db_path] = db return db end local lilpack_searcher = function(name) local entry = module_registry[name] if not entry then return "\n\tno lilpack provides '" .. name .. "'" end local db = get_db(entry.db_path) if not db then return "\n\tlilpack '" .. entry.pack_name .. "' database not accessible" end local ks = db:keyspace("modules") local source = ks:get(name) if not source then return "\n\tlilpack '" .. entry.pack_name .. "' has no module '" .. name .. "'" end local loader, err = loadstring(source, "@lilpack:" .. entry.pack_name .. ":" .. name) if not loader then return "\n\terror loading '" .. name .. "' from lilpack: " .. tostring(err) end return loader end local script_local_searcher = function(name) local path = "./" .. name:gsub("%.", "/") .. ".lua" local f = io.open(path, "r") if not f then path = "./" .. name:gsub("%.", "/") .. "/init.lua" f = io.open(path, "r") end if not f then return "\n\tno file './" .. name:gsub("%.", "/") .. ".lua'" end local src = f:read("*a") f:close() local loader, err = loadstring(src, "@" .. path) if not loader then return "\n\terror loading '" .. path .. "': " .. tostring(err) end return loader end -- Manifest fields included in the canonical digest, in fixed order. local DIGEST_FIELDS = { "version", "name", "pkg_version", "description", "modules", "author", "license", "dependencies", "builtins", "modes", "cli", "theme", "min_lilush_version", } ---! Compute the canonical SHA-256 digest of a package ---@ compute_digest(`db`{.tbl}) -> `digest`{.str} --[===[ Computes a deterministic SHA-256 hash over all package content in a MNEME database. The digest covers manifest fields in fixed order, module sources sorted by name, and CLI script sources sorted by name. The `signature` and `pubkey` fields are excluded from the digest. Used for both signing and verification. ]===] local compute_digest = function(db) local meta = db:keyspace("__lilpack") local mod_ks = db:keyspace("modules") local parts = {} for _, key in ipairs(DIGEST_FIELDS) do local val = meta:get(key) if val ~= nil then parts[#parts + 1] = key .. "=" .. tostring(val) .. "\0" end end local modules_json = meta:get("modules") local module_names = json.decode(modules_json) or {} table.sort(module_names) for _, mod_name in ipairs(module_names) do local source = mod_ks:get(mod_name) if source then parts[#parts + 1] = "module:" .. mod_name .. "=" .. source .. "\0" end end local cli_json = meta:get("cli") if cli_json then local cli_ks = db:keyspace("cli") local cli_names = json.decode(cli_json) or {} table.sort(cli_names) for _, cli_name in ipairs(cli_names) do local source = cli_ks:get(cli_name) if source then parts[#parts + 1] = "cli:" .. cli_name .. "=" .. source .. "\0" end end end return crypto.sha256(table.concat(parts)) end ---! Compute a short fingerprint for a public key ---@ pubkey_fingerprint(`pubkey`{.str}) -> `fingerprint`{.str} local pubkey_fingerprint = function(pubkey) return crypto.ssh_pubkey_fingerprint(pubkey):sub(1, FINGERPRINT_DISPLAY_LEN) end ---! Load the trusted public key for signature verification ---@ load_trusted_pubkey(`key_path`{.str .opt}) -> `pubkey`{.str .or_nil}, `err`{.str .err} --[===[ Loads the trusted Ed25519 public key used for package verification. If `key_path` is provided, reads from that file. Otherwise checks the `LILPACK_PUB_KEY` environment variable. Falls back to the built-in default public key. ]===] local load_trusted_pubkey = function(key_path) local pub_path = key_path or os.getenv("LILPACK_PUB_KEY") if pub_path then local content = std.fs.read_file(pub_path) if not content then return nil, "cannot read public key: " .. pub_path end return crypto.ssh_parse_pubkey(content) end return crypto.ssh_parse_pubkey(DEFAULT_PUB_KEY) end -- Internal: verify signature on an already-open DB handle. -- trusted_pubkey is required; callers must resolve the key beforehand. local verify_signature_db = function(db, trusted_pubkey) if not trusted_pubkey then return nil, "no trusted public key provided" end local meta = db:keyspace("__lilpack") local signature = meta:get("signature") if not signature then return nil, "package is not signed" end local pubkey = meta:get("pubkey") if not pubkey then return nil, "package is signed but missing public key" end if pubkey ~= trusted_pubkey then return nil, "embedded public key does not match trusted public key" end local digest = compute_digest(db) local ok = crypto.ed25519_verify(pubkey, digest, signature) if not ok then return nil, "signature verification failed" end return true, nil, pubkey end ---! Verify the Ed25519 signature of a .lilpack file ---@ verify_signature(`db_path`{.str}, `trusted_pubkey`{.str .opt}) -> `ok`{.bool .or_nil}, `err`{.str .err}, `pubkey`{.str .or_nil} --[===[ Opens the MNEME database at `db_path`, recomputes the canonical digest, and verifies the embedded Ed25519 signature. If `trusted_pubkey` is provided, the signature is verified against it; otherwise falls back to the default trusted key (from `LILPACK_PUB_KEY` env or built-in). Returns `true, nil, pubkey` on success, or `nil, error_message` on failure. ]===] local verify_signature = function(db_path, trusted_pubkey) if not trusted_pubkey then local pk, pk_err = load_trusted_pubkey() if not pk then return nil, "no trusted public key available: " .. tostring(pk_err) end trusted_pubkey = pk end local db, err = mneme.open(db_path, { readonly = true, create = false }) if not db then return nil, "failed to open: " .. tostring(err) end local ok, verr, pubkey = verify_signature_db(db, trusted_pubkey) db:close() return ok, verr, pubkey end -- Internal: read and validate manifest from an already-open DB handle. local read_manifest_db = function(db) local keyspaces = db:keyspaces() local has_manifest = false for _, ks_name in ipairs(keyspaces) do if ks_name == "__lilpack" then has_manifest = true break end end if not has_manifest then return nil, "no __lilpack keyspace" end local ks = db:keyspace("__lilpack") local version = ks:get("version") if not version or version > CONVENTION_VERSION then return nil, "unsupported version: " .. tostring(version) end local name = ks:get("name") if not name then return nil, "missing 'name' key" end local modules_json = ks:get("modules") if not modules_json then return nil, "missing 'modules' key" end local modules = json.decode(modules_json) if type(modules) ~= "table" then return nil, "invalid 'modules' JSON" end local manifest = { version = version, name = name, pkg_version = ks:get("pkg_version") or "0.0.0", description = ks:get("description") or "", modules = modules, author = ks:get("author"), license = ks:get("license"), min_lilush_version = ks:get("min_lilush_version"), } local deps_json = ks:get("dependencies") if deps_json then manifest.dependencies = json.decode(deps_json) end local builtins_json = ks:get("builtins") if builtins_json then manifest.builtins = json.decode(builtins_json) end local modes_json = ks:get("modes") if modes_json then manifest.modes = json.decode(modes_json) end local cli_json = ks:get("cli") if cli_json then manifest.cli = json.decode(cli_json) end local theme_json = ks:get("theme") if theme_json then manifest.theme = json.decode(theme_json) end local signature = ks:get("signature") if not signature then return nil, "package is not signed" end local pubkey = ks:get("pubkey") if not pubkey then return nil, "package is signed but missing public key" end manifest.signature = signature manifest.pubkey = pubkey return manifest end ---! Read and validate a LILPACK manifest from a MNEME database ---@ read_manifest(`db_path`{.str}) -> `manifest`{.tbl .or_nil}, `err`{.str .err} --[===[ Opens the MNEME database at `db_path` and reads the `__lilpack` manifest keyspace. Validates required fields (version, name, modules, signature, pubkey) and decodes optional JSON fields (dependencies, builtins, modes, cli). Returns the manifest table on success, or nil and an error message on failure. Unsigned packages are rejected. ]===] local read_manifest = function(db_path) local db, err = mneme.open(db_path, { readonly = true, create = false }) if not db then return nil, "failed to open: " .. tostring(err) end local manifest, merr = read_manifest_db(db) db:close() if not manifest then return nil, merr end return manifest end -- Warn (but never block) about declared dependencies that are missing or -- whose installed version does not satisfy the constraint. Run once after the -- full registry is built. Enforcement happens at install time; this is just a -- diagnostic so a broken install surfaces clearly at shell startup. local check_dependencies = function() for pack_name, entry in pairs(package_registry) do local deps = entry.manifest.dependencies if deps then for _, dep in ipairs(deps) do local target = dep.name and package_registry[dep.name] if not target then io.stderr:write( "lilpack: '" .. pack_name .. "' requires " .. tostring(dep.name) .. " " .. tostring(dep.version or "") .. ", but it is not installed\n" ) elseif not semver.satisfies(target.manifest.pkg_version, dep.version) then io.stderr:write( "lilpack: '" .. pack_name .. "' requires " .. dep.name .. " " .. tostring(dep.version) .. ", but " .. tostring(target.manifest.pkg_version) .. " is installed\n" ) end end end end end -- Warn (but never block) about installed packs that require a newer lilush than -- the one running. Mirrors check_dependencies: install enforces, load diagnoses. -- LILUSH_VERSION is a global set by the binary at startup; if absent (e.g. under -- plain luajit in tests) the running version is unknown, so the check is skipped. local check_lilush_version = function() local cur = _G.LILUSH_VERSION if not cur then return end for pack_name, entry in pairs(package_registry) do local min = entry.manifest.min_lilush_version if min and not semver.satisfies(cur, min) then io.stderr:write( "lilpack: '" .. pack_name .. "' requires lilush >= " .. tostring(min) .. ", but " .. tostring(cur) .. " is running\n" ) end end end local discover_packages = function() local files = std.fs.list_files(PACKAGES_DIR, "%.lilpack$") if not files then return end local trusted_pubkey, pk_err = load_trusted_pubkey() if not trusted_pubkey then io.stderr:write("lilpack: " .. tostring(pk_err) .. " -- no packages will be loaded\n") return end local sorted = {} for filename, _ in pairs(files) do table.insert(sorted, filename) end table.sort(sorted) for _, filename in ipairs(sorted) do local db_path = PACKAGES_DIR .. "/" .. filename local db, open_err = mneme.open(db_path, { readonly = true, create = false }) local manifest if not db then io.stderr:write("lilpack: skipping " .. filename .. ": " .. tostring(open_err) .. "\n") else local merr manifest, merr = read_manifest_db(db) if not manifest then db:close() io.stderr:write("lilpack: skipping " .. filename .. ": " .. merr .. "\n") else local ok, verr = verify_signature_db(db, trusted_pubkey) if not ok then db:close() io.stderr:write( "lilpack: skipping " .. filename .. ": signature verification failed: " .. tostring(verr) .. "\n" ) manifest = nil else open_dbs[db_path] = db end end end if manifest then local pack_name = manifest.name if package_registry[pack_name] then open_dbs[db_path] = nil db:close() io.stderr:write( "lilpack: duplicate package '" .. pack_name .. "' in " .. filename .. ", already loaded from " .. package_registry[pack_name].db_path .. "\n" ) else package_registry[pack_name] = { db_path = db_path, manifest = manifest, } for _, mod_name in ipairs(manifest.modules) do if module_registry[mod_name] then io.stderr:write( "lilpack: module '" .. mod_name .. "' from '" .. pack_name .. "' shadows same module from '" .. module_registry[mod_name].pack_name .. "'\n" ) else module_registry[mod_name] = { db_path = db_path, pack_name = pack_name, } end end if manifest.builtins then for _, b in ipairs(manifest.builtins) do local dominated = false if b.commands then for _, cmd in ipairs(b.commands) do if extensions.builtin_commands and extensions.builtin_commands[cmd] then io.stderr:write( "lilpack: builtin command '" .. cmd .. "' from '" .. pack_name .. "' shadows same command from '" .. extensions.builtin_commands[cmd] .. "'\n" ) dominated = true end end end if not dominated then table.insert(extensions.builtins, b) if b.commands then extensions.builtin_commands = extensions.builtin_commands or {} for _, cmd in ipairs(b.commands) do extensions.builtin_commands[cmd] = pack_name end end end end end if manifest.modes then for _, m in ipairs(manifest.modes) do extensions.modes[m.name] = m end end if manifest.cli then for _, cmd_name in ipairs(manifest.cli) do if extensions.cli[cmd_name] then io.stderr:write( "lilpack: cli command '" .. cmd_name .. "' from '" .. pack_name .. "' shadows same command from '" .. extensions.cli[cmd_name].pack_name .. "'\n" ) else extensions.cli[cmd_name] = { pack_name = pack_name, db_path = db_path, } end end end if manifest.theme then if manifest.theme.sections then for _, sec in ipairs(manifest.theme.sections) do table.insert(extensions.theme.sections, sec) end end if manifest.theme.definition then table.insert(extensions.theme.definitions, manifest.theme.definition) end end end end end check_dependencies() check_lilush_version() end ---! Initialize the LILPACK package system ---@ init() -> `extensions`{.tbl} --[===[ Initializes the package system: strips standard Lua searchers, discovers and verifies installed packages, and installs the LILPACK and script-local searchers into the require chain. Returns a table of discovered extensions (builtins, modes, cli). Safe to call multiple times; subsequent calls return the cached extensions table. ]===] local init = function() if initialized then return extensions end -- Strip all searchers except the preload searcher (index 1) local searchers = package.searchers or package.loaders while #searchers > 1 do table.remove(searchers) end -- Discover installed packages discover_packages() -- Install LILPACK searcher (index 2) table.insert(searchers, lilpack_searcher) -- Install script-local searcher (index 3) table.insert(searchers, script_local_searcher) -- Clear filesystem paths to prevent any residual lookups package.path = "" package.cpath = "" std.ps.setenv("LUA_PATH", "") initialized = true return extensions end ---! Return the package registry ---@ packages() -> `registry`{.tbl} local packages = function() return package_registry end ---! Return the module registry ---@ modules() -> `registry`{.tbl} local modules = function() return module_registry end ---! Return discovered shell extensions from installed packages ---@ extensions() -> `extensions`{.tbl} local get_extensions = function() return extensions end return { init = init, packages = packages, modules = modules, extensions = get_extensions, read_manifest = read_manifest, compute_digest = compute_digest, pubkey_fingerprint = pubkey_fingerprint, verify_signature = verify_signature, load_trusted_pubkey = load_trusted_pubkey, CONVENTION_VERSION = CONVENTION_VERSION, PACKAGES_DIR = PACKAGES_DIR, }