28 KiB
LGX Package
Overall Description
LGX is a deterministic package format for distributing multi-platform artifacts. It provides a standardized way to bundle platform-specific binaries, libraries, or other files into a single distributable archive with strong guarantees about reproducibility and integrity.
The format is designed to:
- Support multiple platform variants (e.g.,
linux-amd64,darwin-arm64) in a single package - Produce byte-identical archives given identical inputs (deterministic)
- Ensure cross-platform path compatibility through Unicode NFC normalization (macOS often uses decomposed forms; Linux commonly uses composed - NFC avoids "same name, different bytes" breaking lookups, hashing, and determinism)
- Enforce a strict, predictable internal structure
- Provide tooling for creating, modifying, and validating packages
Definitions & Acronyms
| Term | Definition |
|---|---|
| LGX | Logos Package Format - the package format specified in this document |
| Variant | A platform-specific or configuration-specific build of the package contents (e.g., linux-amd64) |
| NFC | Unicode Normalization Form C - a Unicode normalization form that uses canonical decomposition followed by canonical composition |
| USTAR | Unix Standard TAR - a standardized tar archive format |
| Manifest | The manifest.json file containing package metadata |
| Main | The entry point file for each variant, specified in the manifest |
| Ed25519 | An elliptic-curve digital signature algorithm used for package signing |
| Merkle Tree | A hierarchical hash structure used to verify package content integrity |
Domain Model
Package Structure
An LGX package (.lgx file) is a gzip-compressed tar archive with the following structure. Gzip is used because it's the most universally supported compression with stable tooling on every OS, providing the simplest default for "any platform can unpack".
package.lgx (tar.gz)
├── manifest.json # Required - package metadata
├── manifest.sig # Optional - Ed25519 signature with DID identity
├── variants/ # Required - contains variant directories
│ ├── <variant-1>/ # Variant directory (lowercase name)
│ │ └── ... # Variant contents
│ └── <variant-2>/
│ └── ...
├── docs/ # Optional - documentation
│ └── ...
└── licenses/ # Optional - license files
└── ...
Root Entry Constraints:
- Only
manifest.json,manifest.cose,variants/,docs/, andlicenses/are permitted at root - Any other root entries cause validation failure
- Files directly under
variants/are forbidden (only directories allowed) - This strict structure keeps packages easy to validate and reduces ambiguity
Manifest Schema
The current manifest schema is 0.3.0. It is a UTF-8 encoded JSON file with the following required fields:
{
"manifestVersion": "0.3.0",
"name": "package-name",
"version": "1.2.3",
"description": "Package description",
"author": "Author Name",
"type": "library",
"category": "crypto",
"icon": "icon.png",
"dependencies": [
"simple-dep",
{"name": "ranged-dep", "version": "^1.2.0"},
{"name": "pinned-dep", "version": ">=0.5.0", "signer": "did:jwk:..."}
],
"main": {
"linux-amd64": "path/to/main.so",
"darwin-arm64": "path/to/main.dylib"
}
}
Field Constraints:
| Field | Type | Constraints | Purpose |
|---|---|---|---|
manifestVersion |
string | Semver format; tooling rejects unsupported major versions | Version compatibility |
name |
string | Canonical lowercase; auto-normalized by tooling | Package identity |
version |
string | Package version (semver recommended) | Package identity |
description |
string | Human-readable description | Human metadata |
author |
string | Author/maintainer name | Human metadata |
type |
string | Package type classification | Classification |
category |
string | Package category | Classification |
icon |
string | Relative path to icon file bundled in the package | Display/branding |
dependencies |
array | List of dependency entries — see Dependency entries below | Runtime needs |
main |
object | Map of variant name → relative path to entry point (e.g ) "linux-amd64": "path/to/main.so" means linux-amd64/path/to/main.so |
Entry point resolution |
display_name |
string | Optional. Human-readable label shown by UI consumers (Package Manager, App Manager) and CLI tools (lm metadata, lgx manifest). Falls back to name when absent. |
Display/branding |
All fields except display_name are required to ensure consistent metadata for hosts/registries and applications.
Dependency entries
Each element of the dependencies array is one of:
- Plain string (legacy 0.2.x form, still supported): equivalent to
{"name": <string>}. Means "any version, any signer". - Object with:
name(string, required) — canonical lowercase package name.version(string, optional) — npm/Cargo-style semver range (^1.2.0,~1.2.3,>=1.2 <2.0,1.2.x,*,||for alternatives, ...). Absent ⇒ any version.signer(string, optional) —did:jwk:...DID identifying the trusted publisher. Absent ⇒ any signer. When set, only packages whosemanifest.sigwas produced by that DID match. Used to disambiguate same-named packages from different publishers.
lgx verify syntactically validates that version parses as a semver range and that signer matches the did:jwk: shape. Semantic matching (does the constraint resolve to a real candidate?) is the responsibility of the resolver in logos-package-downloader.
Schema version compatibility
Tooling reads both manifestVersion: "0.2.x" and manifestVersion: "0.3.x". Packages produced by lgx create use 0.3.0. A 0.2.0 manifest with plain-string dependencies round-trips unchanged through tooling — strings are emitted as strings, object-form entries are emitted as objects. Bumping the major version (1.x.x) is reserved for future breaking changes.
ui_qml Contract
For most package types, main remains the per-variant entry point and is required.
For type == "ui_qml", the contract is:
view(required): relative path to the QML entry point. Interpreted from the installed package root and identical across variants.main(optional): per-variant backend Qt plugin library. When present, the host runs it in an isolatedui-hostprocess and bridges it to the QML view; when absent, the QML view is loaded directly in-process.
view must be set for every ui_qml package. main is set only when the module ships a backend C++ plugin.
Example ui_qml manifest with a backend plugin:
{
"manifestVersion": "0.3.0",
"name": "package-manager-ui",
"version": "1.0.0",
"description": "Package manager UI",
"author": "Logos",
"type": "ui_qml",
"category": "ui",
"icon": "modules.png",
"dependencies": [],
"view": "qml/PackageManager.qml",
"main": {
"linux-amd64": "lib/package_manager_ui_plugin.so",
"darwin-arm64": "lib/package_manager_ui_plugin.dylib"
}
}
Example ui_qml manifest without a backend (QML-only):
{
"manifestVersion": "0.3.0",
"name": "calc-ui",
"version": "1.0.0",
"description": "Calculator QML UI",
"author": "Logos",
"type": "ui_qml",
"category": "tools",
"icon": "",
"dependencies": ["calc_module"],
"view": "Main.qml",
"main": {}
}
Main Entry Constraints:
- Keys must be lowercase (auto-normalized)
- Values must be valid relative paths (no absolute paths, no
..segments) - Values must be NFC-normalized
- Each path must resolve to an existing regular file within the variant directory
View Entry Constraints:
viewis required fortype == "ui_qml"; ignored for other typesviewmust be a stringviewmust be a valid relative archive pathviewis interpreted relative to the installed package root
Variant Structure
- Variant names are user-defined strings, stored in lowercase (case-insensitive behavior avoids Windows/macOS filesystem quirks and human typos; canonical lowercase makes matching deterministic)
- Variant directories contain platform-specific files
- The directory structure within a variant is preserved from source
Completeness Constraint:
- Every
mainentry must have a corresponding variant directory - For non-
ui_qmlpackages: every variant directory must have a correspondingmainentry - For
ui_qmlpackages:mainis optional. When present, the samemain/variant correspondence applies; when absent, every variant simply ships the QML view referenced byview - This ensures installers don't have to guess entrypoints
Features & Requirements
Determinism Requirements
LGX packages must be deterministic - identical inputs must produce byte-identical outputs.
Tar Determinism:
- Entries sorted lexicographically by NFC-normalized path bytes
- Fixed metadata:
uid=0,gid=0,uname="",gname=""(tar headers include uid/gid/mtime/mode; normalizing them prevents host-specific differences from changing checksums) - Fixed timestamps:
mtime=0 - Fixed permissions: directories
0755, files0644 - USTAR format
Gzip Determinism:
- Header mtime = 0
- No original filename in header
- Fixed OS byte (0xFF = unknown)
- Gzip headers can embed timestamps/filenames/OS markers; zeroing them prevents two builds from differing despite identical content
Path Safety Rules
All archive paths must satisfy:
- Not absolute (no leading
/) - No
..segments after normalization - Not empty
- No backslash characters (
\) - Unicode NFC-normalized
These rules are enforced both when verifying a package (lgx verify) and at
extraction time. Extraction (lgx extract, lgpm install, and the
lgx_extract C API) re-validates every entry path and additionally checks that
the resolved destination stays inside the output directory before any file or
directory is written. A crafted package whose entry escapes the variant root
(e.g. variants/<variant>/../../etc/...) is rejected with an error and no
files are written outside the target directory — even on the unsigned /
--allow-unsigned path, which does not run full package verification. This
prevents zip-slip / path-traversal arbitrary file writes from an untrusted
.lgx.
Forbidden File Types:
- Symlinks
- Hardlinks
- Device nodes
- FIFOs
These are forbidden for portability and security: links can escape variant roots or behave differently on extract; special files are unsafe/meaningless for plugins.
Decompression Limits
A .lgx is a gzip-compressed tar archive, and DEFLATE can reach compression
ratios on the order of 1000:1. Left unbounded, a small crafted archive could
inflate to gigabytes when loaded and exhaust the host's memory — a
"decompression bomb" that OOM-kills or hangs the process loading it (basecamp
and every in-process module).
To prevent this, decompression enforces a hard cap on total decompressed
output (1 GiB by default). The gzip reader tracks a running total as it
inflates and rejects the stream the moment the output would exceed the cap,
before the excess bytes are allocated — so the cost of an oversized archive is
bounded regardless of how small the compressed input is. Loading an untrusted
.lgx (lgx verify, lgpm install, signature inspection, the lgx_* C API)
runs through this guard, which also bounds the buffer subsequently handed to the
tar reader. A package whose contents exceed the cap is rejected with an error
and no oversized buffer is ever materialized.
The cap applies to the whole archive — the total size of the decompressed
tar (every entry plus tar overhead), not any single file within it. It is
configurable by embedders of the library: globally via
GzipHandler::setDefaultMaxDecompressedSize(bytes) (affects every load that
does not specify its own limit) or per call via the maxOutputSize argument to
decompress / decompressStream. There is no "unlimited" setting — a 0
value is rejected — so the protection cannot be turned off by misconfiguration.
Package Creation Workflow
lgx create <name>
- Normalize
nameto lowercase - Check if
<name>.lgxalready exists; if so, exit with error - Create skeleton manifest with default values:
name: normalized lowercase nameversion:"0.0.1"description:""author:""type:""category:""icon:""dependencies:[]main:{}
- Create empty
variants/directory - Write deterministic tar.gz to
<name>.lgx
Variant Addition Workflow
lgx add <pkg.lgx> --variant <v> --files <path> [--main <relpath>] [--view <relpath>] [-y]
- Load existing package
- Verify package file exists; if not, exit with error
- Verify files path exists; if not, exit with error
- Normalize variant name to lowercase
- Determine effective main path:
- If
--filesis a directory:--mainis required except forui_qmlpackages, whereviewis the required entry point andmainis optional backend metadata - If
--filesis a single file: use basename if--mainnot provided
- If
- Check if confirmation is needed (unless
-y):- If variant exists and will be replaced
- If
main[variant]would change (even if variant doesn't exist yet) - If both variant exists and
mainwould change
- Replace entire variant directory (no merging - a variant is treated as an atomic build output; merge risks stale leftovers and hard-to-debug installs)
- Copy files/directory to
variants/<variant>/- Single file:
variants/<variant>/<filename> - Directory:
variants/<variant>/...(contents placed directly)
- Single file:
- Update
main[variant]entry only when an effective main path exists - Validate and save package
Confirmation Required When:
- Replacing existing variant
- Changing existing
mainentry (even if variant is new) - Both variant replacement and
mainchange
Option Aliases:
-vfor--variant-ffor--files-mfor--main-yfor--yes
Variant Removal Workflow
lgx remove <pkg.lgx> --variant <v> [-y]
lgx remove <pkg.lgx> -v <v> [-y]
- Load existing package
- Verify package file exists; if not, exit with error
- Verify variant exists; if not, exit with error
- Prompt for confirmation (unless
-y) - Remove variant directory entries
- Remove
main[variant]entry - Save package
Option Aliases:
-vfor--variant-yfor--yes
Variant Extraction Workflow
lgx extract <pkg.lgx> [--variant <v>] [--output <dir>]
- Verify package file exists; if not, exit with error
- Load existing package
- If
--variantspecified:- Normalize variant name to lowercase
- Verify variant exists; if not, exit with error
- Extract variant contents to
<output>/<variant>/
- If
--variantnot specified:- Extract all variants to
<output>/<variant>/for each variant
- Extract all variants to
- For each entry, enforce the Path Safety Rules:
reject any entry with an unsafe path (absolute,
..segment, backslash, non-NFC) and reject any entry whose resolved destination would fall outside<output>/<variant>/. On rejection, extraction fails with an error and no file outside the output directory is written. - Create directories as needed
- Write files preserving internal directory structure
Output Structure:
- Each variant is extracted to
<output>/<variant-name>/ - The internal variant structure (from
variants/<variant>/) is preserved - For example,
variants/linux-amd64/lib.soextracts to<output>/linux-amd64/lib.so
Option Aliases:
-vfor--variant-ofor--output
Package Merge Workflow
lgx merge <pkg1.lgx> <pkg2.lgx> ... [-o <output.lgx>] [--skip-duplicates] [-y]
- Verify all input package files exist; if any missing, exit with error
- Load all input packages
- Compare manifests across all inputs, ignoring the
mainfield (which is variant-specific):- All non-variant fields must be identical (
manifestVersion,name,version,description,author,type,category,icon,dependencies,display_name) - If any mismatch is found, report all mismatching fields and exit with error
- All non-variant fields must be identical (
- Check for duplicate variants across all input packages:
- By default, exit with error if any variant appears in more than one input
- With
--skip-duplicates: warn and keep only the first occurrence
- Determine output path:
- Use
--outputif provided - Otherwise default to
<name>.lgx(from the manifest name)
- Use
- If output file exists, prompt for confirmation (unless
-y) - Create a fresh skeleton package with the shared metadata
- For each input package, for each variant:
- Extract variant files to a temporary directory
- Add variant to the output package using the standard
addVariantflow - Preserve the
mainentry from the source package
- Save merged package
- Clean up temporary files
Manifest Comparison:
The merge command compares all manifest fields except main, which is expected to differ across platform-specific builds. This ensures the merged package represents the same logical module across all variants.
Option Aliases:
-ofor--output-yfor--yes
Manifest Inspection Workflow
lgx manifest <pkg.lgx> [--json]
Reads manifest.json from inside the package and prints it.
- Without
--json: human-readable summary including name, version, type, category, author, root hash, variants (the keys ofmain), dependencies, and signer DID (when the package is signed). - With
--json: the rawmanifest.jsonbytes are written to stdout verbatim — byte-identical to the file embedded in the.lgx. This is intended for tooling (e.g.logos-modules-release-action) that needs to capture the manifest exactly as it appears in the package.
Exit code: 0 on success, non-zero if the package or its manifest is missing/malformed.
Verification Workflow
lgx verify <pkg.lgx> [--keyring-dir <dir>]
Options:
--keyring-dir <dir>— Keyring directory for trust lookup (default:~/.config/logos/trusted-keys/)
- Verify package file exists; if not, exit with error
- Load and validate package
Validation Checks:
- Archive is valid tar.gz
- Root layout restrictions enforced
- All manifest required fields present
- Manifest version is supported (major version check)
- All paths are NFC-normalized
- Completeness constraint satisfied (variants ↔ main)
- Each
mainentry points to existing regular file - No forbidden file types present
- Content hashes present and valid (Merkle tree root hash matches recomputed value)
Output:
- Errors: Validation failures that prevent package from being valid
- Warnings: Non-fatal issues that should be noted
- Exit code:
0if valid,1if validation failed
CLI Interface
Global Options
All commands support the following global options:
--help, -h: Show help information (global or command-specific)--version, -V: Show version information
Exit Codes
0: Success1: Error (validation failure, file not found, etc.)
Error Handling
Commands perform validation before operations:
- File existence checks for package files and source paths
- Required option validation with clear error messages
- Confirmation prompts for destructive operations (unless
-yflag is used)
Package Signing
Overview
Packages can be cryptographically signed using Ed25519 via libsodium. Signing creates a manifest.sig file containing the signer's DID (did:jwk:...), the Ed25519 signature over manifest.json, and optional signer metadata.
Content Hashes
Content hashes (Merkle tree) are always present in manifest.json, regardless of whether the package is signed. They are automatically recomputed whenever package content is modified (adding/removing variants). The lgx verify command validates these hashes for all packages.
Sign Command
lgx sign <pkg.lgx> --key <name> [--keys-dir <dir>] [--name "Display Name"] [--url "https://..."]
Signs a package by:
- Validating the package (structure and content hashes)
- Creating an Ed25519 detached signature over the exact bytes of
manifest.json - Writing
manifest.sigwith the signer's DID, signature, and optional metadata
Options:
--key, -k <name>— Name of the signing key (required)--keys-dir, -d <dir>— Directory containing key files (default:~/.config/logos/keys/)--name <display-name>— Signer display name (self-asserted metadata)--url <url>— Signer URL (self-asserted metadata)
The secret key is loaded from <keys-dir>/<name>.jwk.
Keygen Command
lgx keygen --name <name> [--output-dir <dir>]
Generates an Ed25519 signing keypair.
Options:
--name, -n <name>— Name for the keypair (required)--output-dir, -o <dir>— Directory to write key files (default:~/.config/logos/keys/)
Files created:
<dir>/<name>.jwk— Secret key (JWK format, permissions 0600)<dir>/<name>.pub— Public key (SSH public key format)<dir>/<name>.did— DID string (plain textdid:jwk:...)
Prints the did:jwk:... DID to stdout.
Secret Key Format (JWK)
{
"crv": "Ed25519",
"d": "<base64url-encoded 32-byte private seed>",
"kty": "OKP",
"x": "<base64url-encoded 32-byte public key>"
}
Keys are sorted alphabetically for determinism. The d field contains the 32-byte Ed25519 seed (not libsodium's 64-byte expanded key).
Keyring Command
lgx keyring add <name> <did:jwk:...> [--display-name "..."] [--url "..."] [--dir <dir>]
lgx keyring remove <name> [--dir <dir>]
lgx keyring list [--dir <dir>]
Options:
--dir, -d <dir>— Keyring directory (default:~/.config/logos/trusted-keys/)
Manages trusted keys stored as .json files in the keyring directory:
{
"did": "did:jwk:eyJjcnYi...",
"name": "Logos Foundation",
"url": "https://logos.co",
"addedAt": "2026-04-06T12:00:00Z"
}
DID Identity
Signers are identified by DID (Decentralized Identifier) strings using the did:jwk method:
did:jwk:<base64url({"crv":"Ed25519","kty":"OKP","x":"<base64url-pubkey>"})>
Construction:
- Take 32-byte Ed25519 public key
- Base64url-encode it (RFC 4648 §5, no padding) → the
xvalue - Construct minimal JWK:
{"crv":"Ed25519","kty":"OKP","x":"<x>"}(keys sorted alphabetically) - Base64url-encode the JWK JSON string
- Prepend
did:jwk:
The DID is deterministic from the public key — the same key always produces the same DID string.
manifest.sig Format
{
"algorithm": "ed25519",
"did": "did:jwk:eyJjcnYiOiJFZDI1NTE5Iiwia3R5IjoiT0tQIiwieCI6IjExcVlBWUt4Q3JmVlNfN1R5V1FIT2c3aGN2UGFwaU1scndJYWFQY0hVUm8ifQ",
"linkedDids": [],
"signature": "<base64-encoded 64-byte Ed25519 signature>",
"signer": {
"name": "Logos Foundation",
"url": "https://logos.co"
},
"version": 1
}
did— signer identity as adid:jwk:string. The public key is encoded in the DID itself.signer— optional, self-asserted metadata (display name, URL). Not covered by the signature. Used for trust prompts.linkedDids— reserved for futuredid:pkhentries (blockchain-verified identity). Always[]for now. The current type isstring[]as a placeholder; the final schema will evolve to{did, proofType, proof}[]when did:pkh is implemented — each entry will carry a cryptographic proof (e.g., CACAO/EIP-4361) binding the blockchain identity to the signer'sdid:jwk. This is not a breaking change since the field is currently always empty.
Merkle Tree Hashing
The hashes field in manifest.json is a map of directory paths to SHA-256 hex digests. Hashes are always present and kept up to date whenever content changes.
- File hashes: SHA-256 of each file's content
- Leaf directory hashes (e.g.,
variants/darwin-arm64): Sort files by relative path, concatenate(path + '\0' + file_hash + '\n'), SHA-256 the result - Parent directory hashes (e.g.,
variants): Sort child directory names, concatenate(dirname + '\0' + child_hash + '\n'), SHA-256 the result - Root hash: Same algorithm over all top-level entries
Signature Invalidation
Any operation that modifies package content:
- Clears the existing signature (
manifest.sig) - Recomputes content hashes in
manifest.json save()without a valid signature does NOT writemanifest.sig
Verification
lgx verify <pkg.lgx> performs:
- Structural validation (existing checks)
- Content hash validation — recomputes Merkle tree and verifies root hash matches (all packages)
- If
manifest.sigis present: verify Ed25519 signature, report signer DID and trust status
Install-Time Verification (lgpm)
lgpm install verifies signatures before extracting packages:
- Default (warn): Unsigned packages accepted with a warning; signed packages from unknown signers are accepted with trust info in the response
--allow-unsigned: No signature checking--require-signatures: Reject unsigned packages and packages signed by untrusted keys
Keyring management (adding/removing trusted keys) is handled separately via lgx keyring or the package-manager module's addTrustedKey/removeTrustedKey/listTrustedKeys API.
Future Work
did:pkh — Blockchain-Verified Identity
The current DID implementation uses did:jwk exclusively — a self-contained, offline identity where the public key is embedded in the DID string. No DID Documents are produced or consumed; the DID string is used directly as a structured public key encoding. This is sufficient because did:jwk DID Documents are deterministically derivable from the string itself.
When did:pkh support is added, signers will be able to prove they control a blockchain account in addition to their Ed25519 signing key. This provides stronger identity guarantees (the signer is tied to a public, auditable on-chain identity).
linkedDids schema evolution
The linkedDids field in manifest.sig is currently string[] (always empty). When did:pkh is implemented, each entry will become an object carrying both the DID and a cryptographic proof:
"linkedDids": [
{
"did": "did:pkh:eip155:1:0xab16a96D359eC26a11e2C2b3d8f8B8942d5Bfcdb",
"proofType": "cacao",
"proof": "<CACAO/EIP-4361 signature proving the blockchain account authorized this did:jwk>"
}
]
Without a proof, a linkedDids entry is just a claim — anyone could list any blockchain address. The proof cryptographically binds the did:pkh to the signer's did:jwk.
Proof mechanisms
| Mechanism | Description | Offline verification? |
|---|---|---|
| CACAO (EIP-4361) | Blockchain account signs a "Sign-In with Ethereum" message authorizing the did:jwk |
Yes (proof is self-contained) |
| Verifiable Credential | A VC issued by the did:pkh subject attesting ownership of the did:jwk |
Yes (proof is self-contained) |
| On-chain registry | Smart contract mapping blockchain addresses to did:jwk strings |
No (requires chain query) |
| Bidirectional signatures | Ed25519 key signs did:pkh + blockchain key signs did:jwk |
Yes (both proofs stored) |
CACAO/EIP-4361 is the most practical option: standardized, self-contained, and widely supported.
New dependencies
secp256k1library for Ethereum signature verification- CACAO/EIP-4361 message format parsing
- Optional: blockchain RPC client for on-chain registry approaches
- DID Document resolution for
did:pkh(extracting verification methods)
Publish Command
lgx publish <pkg.lgx>
Status: TBC. No-op in v0.1. Always exits with success code 0.
Planned Behavior:
- Publish package to a registry
- Upload package file and metadata
- Registry authentication and authorization
- Version conflict detection and resolution