Skip to content

pm

Build signed recipes into .cpkg archives and run their entrypoints in a Linux sandbox. See the examples for .rhai recipe syntax and signing.

Recipes are Rhai

Recipes are Rhai scripts in *.rhai files that call package(#{ ... }) once, with step(...) for each build step. Rhai has no keyword arguments, so package takes an object map. Loops, functions and conditionals generate steps and commands; evaluation is hermetic (no I/O, no clock, no import or eval), runs under operation and size limits so a runaway loop is an error rather than a hang, and happens before the policy is derived and before anything runs.

package(#{
    name: "hello",
    version: "1.0.0",
    steps: [
        step(Install, "stage", ["install -Dm755 /usr/bin/echo /dest/usr/bin/hello"]),
    ],
});
  • pm generate build.rhai writes a starter file.
  • pm-lsp is a language server (diagnostics, completion, hover) for .rhai recipes, and editors/ has Zed and Neovim integrations that use it. pm-lsp --definitions prints a Rhai definitions file for editors that run a Rhai language server of their own.

Objects

step(...), kernel(...) and Package(name, version) return objects with properties and methods, so a package can be built up before it is declared. Recipes can also define their own objects as object maps with closures, where this is the map:

let autotools = #{
    prefix: "/usr",
    build: |name| step(Build, name, [`./configure --prefix=${this.prefix}`, "make"]),
};

let p = Package("hello", "1.0.0");
p += autotools.build("compile");
p += step(Install, "stage", []).push("make install DESTDIR=/dest");
p.depends_on("../libc/build.rhai");
package(p);

Step has stage, name, run and dl_urls, plus push(command), download(url, sha256) and += command. Package has name, version, dependencies, steps and kernel, plus depends_on, add_step, add_steps, with_kernel, += step and for step in package. Setting a property is checked on the spot, so s.stage = "Deploy" fails on that line.

Plugins in recipes

An installed plugin is a Rhai module under its own name. Its symbols are constants and, when it is built against the recipe-plugin world, its recipe functions are functions that return pm's own types, checked as if the recipe had built them:

let p = Package("units", "1");
p += systemd::install_unit("units/foo.service");   // a Step
p += step(Test, "unitdir", [`test -d /dest${systemd::unitdir}`]);
package(p);

pm plugins lists what each plugin adds. See plugins/README.md.

How recipes run

Recipes are compiled with Rhai's full optimiser (constant folding and inlining of pure calls) and fast operators, lowered to bytecode and run on Rhai's Grain VM. The engine is built once per thread, so a dependency graph of many recipes pays for it once. Strict variables and strict map properties catch misspellings before anything runs.

Some Rhai features are deliberately left out: floating point (no_float; a package has no use for it and it makes evaluation platform-dependent), the clock (no_time), import and eval (a signature covers one file as written), the unchecked build (it removes the operation and size limits), and an on-disk bytecode cache (the bytes that run would no longer be the bytes that were signed).

Moving off Starlark and YAML

Recipes used to be Starlark .package files, and before that YAML. Both still load, so nothing breaks today, but each load logs a deprecation warning and support will be removed in a future release.

  • pm migrate build.package writes build.rhai next to it, describing the same package. pm migrate build.yaml does the same for YAML. -r also converts every Starlark or YAML recipe it depends on and points the dependency paths at the new files; --stdout prints instead of writing.
  • The Starlark is evaluated, not translated, so loops and functions come out as the steps they produced, written out literally. Comments are not carried over.
  • A signature covers one file's bytes, so sign the new file with pm sign.
  • The old file is left in place. Delete it, and its .sig, once the .rhai file is signed and builds.

Shipping a kernel

A package can ship its own Linux kernel. Install the image into DESTDIR with the package's steps and name it in package(#{ ... }):

package(#{
    name: "hello",
    version: "1.0.0",
    steps: [
        step(Install, "stage", [
            "install -Dm644 /path/to/vmlinuz /dest/boot/vmlinuz",
            "install -Dm755 /path/to/hello /dest/usr/bin/hello",
        ]),
    ],
    kernel: kernel("boot/vmlinuz", "mitigations=off"),
});

image is relative to DESTDIR. The build fails if the steps did not install it or if it is not a Linux kernel image (a bzImage, a vmlinux, or an arm64 or RISC-V Image), and the image is never offered as a program. cmdline is optional and goes on the kernel command line ahead of the parameters pm relies on (console, panic, rdinit), so it cannot override them; init= and rdinit= are refused.

pm run boots such a package's kernel in a QEMU virtual machine instead of running it in the namespace jail on the host's kernel:

  • The guest's root filesystem is an initramfs pm assembles for each run. It holds the package at /pkg, the host loader and shared libraries the package's binaries link (at their host paths), and pm-vm-init, which runs the entrypoint, reports its exit code to the host and powers the machine off. Nothing else from the host is visible, the machine has no network device and no disk, and the recorded landlock profile is not applied inside it.
  • The console is your terminal, so the program's output and input work as they do in the jail. Its exit code is pm run's exit code.
  • It needs qemu-system-x86_64 on PATH, and uses KVM when /dev/kvm is usable (emulation otherwise, which is slow). Only x86-64 hosts can boot a package kernel today.
  • The kernel needs initramfs support, an 8250/16550 serial console and ELF support built in, which distribution generic and virtual kernels have.
  • --network and --audit are refused, because the guest has no NIC and a traced host process cannot see into a VM. pm run --host-kernel runs the package in the usual jail and ignores its kernel.

Install pm-vm-init beside pm: it is copied into every initramfs, and is much smaller than pm, which is used in its place when it is missing.

Integration with distribution build tools

Projects such as dichhead/losos-desktop generate recipes and assemble system images from their outputs. Use these interfaces instead of duplicating pm's internal download hashing or parsing its human-readable policy table.

Locate a source download

pm source-path 'https://example.org/project-1.0.tar.xz'
pm source-path --relative 'https://example.org/project-1.0.tar.xz'

The first command prints the source's absolute path inside the build jail (/build/.../project-1.0.tar.xz). The second prints a path relative to the build working directory, useful for unconfined builds. Both print only one path to stdout, perform no downloads, create no files and load no plugins. Use exactly the URL in the recipe's dl_urls map, including its query string. URLs without a usable filename fail with a nonzero exit status.

Rust consumers can use pm::step::Step::download_path(&url). The downloader uses this same function; consumers should not depend on the digest algorithm. Resolve paths with the pm version that will build the generated recipe. Hash verification still happens during the build, not during a path query.

Machine-readable policy gates

pm explain build.yaml --format yaml
pm explain build.yaml --format yaml --permissive

YAML output is a single document on stdout; diagnostics stay on stderr. It contains:

Field Meaning
schema_version Currently 1; reject unsupported versions in consumers.
name, version, dependencies Package name, version components and declared dependency recipe paths.
fingerprint The same policy change-detection digest as the text report, not a cryptographic signature.
capabilities Sorted capability names, including Network when granted.
commands Ordered records with command, fingerprint and boolean matched.
plugins Loaded plugin name, version, sha256 and trust.
symbols Referenced plugin symbols mapped to their expanded values.
downloads Records with step, url, expected sha256 and in-jail path, sorted by URL within each step.

The report covers the specified recipe, not its dependency closure. It expands symbols and derives policy without running build commands or fetching sources. Signature verification and plugin trust rules are identical to text mode. Existing text output remains the default.

Always check the process exit status, even when stdout contains a valid report. With --permissive, unmatched commands are included with matched: false, but the command still exits nonzero. Without it, policy derivation fails before a report is emitted. Missing/untrusted recipes and unusable plugins also fail.

Desktop payloads and current limits

Regular files are binary entrypoints only when at least one Unix executable bit is set. Libraries (.a, .so, .so.N) remain library entrypoints without executable bits. Non-executable systemd units, desktop entries, icons, headers and configuration files remain in the archive but are not offered as programs.

pm is not yet a system package installer or image composer. Dependencies are bundled as nested archives, not automatically merged into a build sysroot or runtime root. An image builder must preserve the complete required payload (including /etc and /var, not only /usr), handle file conflicts and symlinks, and keep build-only pkg-config prefix rewrites out of the final image. Service activation, sysusers, presets, architecture-aware image manifests and transactional system updates remain the distribution's responsibility. These interfaces do not make pm run a replacement for systemd service management.

About

A self-contained package manager written in Rust

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages