Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Bobr

bobr

Bobr is a content-addressed build system for Linux. It executes a request — a DAG of recipe nodes — and produces objects such as filesystem trees and root-filesystem images.

The accompanying recipe collection builds Linux kernels, initramfs images, and complete root filesystems from declarative Nickel recipes. It also provides helpers for booting the resulting systems under QEMU.

Website and documentation: bobr.build.

Try Bobr

On x86-64 Linux, install the latest release with:

curl -fsSL https://bobr.build/install.sh | bash

Then follow Build and boot a QEMU system. With the public repository of prebuilt objects, the initial download normally takes about 1–2 minutes, depending on the connection.

Watch the terminal demo

Installing Bobr, downloading three QEMU boot artifacts from the public repository, and booting the guest

The installer verifies the release checksum and installs into ~/.local/bin. See Installation for a review-before-running variant, custom destinations, prerequisites, and uninstallation.

Current status

Bobr is under active development and currently targets native x86-64 Linux. Development has so far focused on the build system itself; the included guest systems remain proof-of-concept demos.

Key properties

Every object — a fetched source or a build result — is named by the hash of its content and kept in a content-addressed store. Identical results are deduplicated and reused across builds, so a request rebuilds only what is actually missing and serves everything else from the store. Reproducible builds with the same inputs produce the same content-addressed object. Filesystem trees are assembled by hard-linking files into place, not copying — so they build fast and cheap.

Builders run in Linux user-namespace sandboxes with an explicit root filesystem and a controlled environment, so a build cannot silently depend on the host. Sources are pinned by content hash (HTTP fetches support mirror fallback). A handful of composable builders cover the common needs — assembling and transforming filesystem trees, and extracting OCI images — and a sandbox builder runs arbitrary build steps, from compiling a package to packing a tree into a bootable image. Recipes are written declaratively (in Nickel) and lower to the JSON request bobr executes.

Bobr addresses some of the same system-construction problems as Yocto, but keeps recipes in a separate Nickel layer with contracts and Nix-style overlays. Yocto is centered on cross-compilation for embedded targets; Bobr currently builds ordinary systems natively.

Like Nix, Bobr provides hermetic builds, hash-keyed storage, and extensive reuse. Unlike Nix, Bobr does not prescribe a Nix-style filesystem layout: filesystem structure is selected by recipes. This makes conventional layouts possible, at the cost of not keeping several package versions side by side in the resulting system. Bobr is content-addressed from the start, whereas Nix store paths are input-addressed and content-addressed derivations remain experimental.

Bobr borrows ideas from OSTree — a content-addressed store and an immutable read-only rootfs. But OSTree is about delivering and updating that OS on the machine (atomic upgrades and rollback), where Bobr builds the images.

Bobr HostBundles are a proof of concept for container-like portability without a container runtime. Their practical viability still needs to be evaluated.

Independence and Affiliation

This is a personal open-source project, developed independently rather than derived from an existing codebase. It is not affiliated with or endorsed by Qualcomm or any other organization.

License

Licensed under either of

at your option.

Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.

Documentation

  1. Installation Host requirements, release installation, PATH setup, and uninstallation.

  2. Getting Started Build and boot QEMU systems, reuse results from a local store, customize the package set with overlays, and write recipes for local sources.

  3. Concepts The ideas bobr is built on — content addressing, objects, keys, and recipes — and how a build runs. The mental model behind the reference docs.

  4. Filesystem trees How bobr represents filesystem trees as content-addressed objects: manifests, shared files, and materialization.

  5. Request The request format: the source and builder recipe shapes, the builders, and the source origins.

  6. Recipes in Nickel Authoring recipes in Nickel instead of raw JSON: the package set, overlays, build/runtime dependencies, split outputs, and synthetic builders that expand into a request.

  7. HostBundle Building verified, relocatable host-side application directories: payloads, launchers, wrappers, typed environment, startup verification, and runtime behavior.

  8. Store Content-addressed store, build identity, canonical object records, reuse mappings, and name refs.

  9. Remote repositories Publishing complete stores as authenticated content-addressed caches: signed masters, immutable indexes and content, slot retention, the bobr-repo administration workflow, and garbage collection.

  10. Build logging Logging channels, store-log layout, the structured event record, the closed status vocabulary, and the format guarantees.

  11. Filesystem Object Hashing Structural hashing rules shared by filesystem paths and tar archives.

  12. fs-tree Manifest Canonical manifest format for manifest-addressed fs-tree artifacts.

  13. Development Working on bobr rather than with it: building from a source checkout, and rebuilding the world into a fresh store.

  14. Scheduler Demand-driven DAG realization, exact and reuse resolution, Source acquisition, builder execution, concurrency limits, and cancellation.

  15. TODO Major planned improvements.

Installation

Prerequisites

  • An x86-64 Linux host with Internet access and kernel OverlayFS support.
  • Bash, curl, tar, xz, awk, sed, jq, Nickel, GNU coreutils, and GNU findutils on PATH.
  • A writable filesystem supporting hardlinks, Unix ownership/permissions, and extended attributes (xattrs).

Builds normally run as a regular user. This requires permitted unprivileged user namespaces and sandbox mounts, newuidmap and newgidmap on PATH with their distribution-provided privileges (uidmap / shadow packages), and UID/GID ranges assigned to your account in /etc/subuid and /etc/subgid.

Alternatively, run Bobr through an already configured podman unshare environment, or as root. Rootless Podman still needs working user namespaces and UID/GID mappings; root does not need the mapping helpers.

The release is prebuilt: Rust, Cargo, and a host compiler are not required.

Install bobr

The shortest installation command is:

curl -fsSL https://bobr.build/install.sh | bash

If you prefer to inspect code before running it, download the same script first:

curl -fsSLo bobr-install.sh https://bobr.build/install.sh
less bobr-install.sh
bash bobr-install.sh
rm bobr-install.sh

The installer downloads the latest x86-64 Linux release, verifies its entry in the release’s SHA256SUMS, validates all four commands and their build provenance, and only then replaces the installed files. By default it installs static bobr, bobr-repo, bobr-fsobj-hash, and bobr-sandbox-launcher binaries into ~/.local/bin. It neither invokes sudo nor edits shell startup files.

Use --bin-dir for one invocation, or BOBR_INSTALL_DIR for the environment:

bash bobr-install.sh --bin-dir /path/to/bin
BOBR_INSTALL_DIR=/path/to/bin bash bobr-install.sh

There is no installation database. To uninstall the default installation, remove exactly the four installed commands:

rm -- \
  "${HOME}/.local/bin/bobr" \
  "${HOME}/.local/bin/bobr-repo" \
  "${HOME}/.local/bin/bobr-fsobj-hash" \
  "${HOME}/.local/bin/bobr-sandbox-launcher"

For a custom destination, use that directory instead.

Getting Started

Recipes and build profiles are written in Nickel. Basic familiarity with the language will help you follow the examples below.

Download the recipes

In your working directory, download the bobr-recipes release matching your installed Bobr:

BOBR_VERSION="$(bobr --version | awk '{print $2}')"
curl -fLO "https://github.com/aleksey-makarov/bobr-recipes/archive/refs/tags/v${BOBR_VERSION}.tar.gz"
tar -xf "v${BOBR_VERSION}.tar.gz"
mv "bobr-recipes-${BOBR_VERSION}" bobr-recipes

Build and boot a QEMU system

Create bobr.ncl beside bobr-recipes with this content:

let bobrpkgs = import "bobr-recipes/bobrpkgs.ncl" in
let pkgs = bobrpkgs [] in
(import "bobr-recipes/profiles/bobr-user.ncl") & {
  include pkgs,
  goals = [pkgs.qemu_image, pkgs.initrd, pkgs.linux_bzimage],
  secondaries.providers = [
    import "bobr-recipes/profiles/provider-public.ncl",
  ],
}

bobr.ncl is your build profile. bobr-build.sh evaluates it with Nickel and generates a JSON request for Bobr. This profile imports the package set and user defaults, selects three boot artifacts as goals, and enables a repository of prebuilt objects to speed up the build.

mkdir -p bobr-store
bobr-recipes/bin/bobr-build.sh

This produces the EROFS root filesystem image, initramfs, and kernel. You can find them through the qemu-image, initrd, and linux-bzimage links in bobr-store/object-refs/. Allow roughly 1–2 minutes to download the prebuilt objects, depending on your connection.

To run the script, qemu-system-x86_64 and mkfs.ext4 must be on PATH, and your user must have access to /dev/kvm on an x86-64 Linux host:

bobr-recipes/tools/bobr-run-qemu.sh

The script finds the boot artifacts in the adjacent bobr-store and creates bobr-home.img beside it on first launch: a persistent 1 GiB ext4 disk for /home. It reuses that disk on subsequent launches.

The terminal becomes the guest’s root console, with automatic login. Shut down with poweroff; press Ctrl-A, X to exit QEMU directly. Changes under /home persist in bobr-home.img; changes elsewhere are discarded on reboot.

Build and boot a GNOME system

Using the same profile, build the GNOME image:

bobr-recipes/bin/bobr-build.sh --target qemu_gnome_image

Alternatively, change the profile’s goals to [pkgs.qemu_gnome_image] and run without --target.

Your host QEMU must support SDL/OpenGL and virtio GPU with Venus; the host also needs a working Vulkan driver. See QEMU graphics requirements.

Launch GNOME:

bobr-recipes/tools/bobr-run-qemu-gnome.sh

The desktop opens in a window. The script reuses bobr-home.img; do not run both guests simultaneously.

The systems booted above are still proof-of-concept demos, with much left to improve. For now, Bobr’s primary focus is the build system rather than a complete OS.

Boot QEMU from a host bundle

You can also run programs built by Bobr on your host using a host bundle, which packages a program with its runtime dependencies. Host bundles are still a proof of concept, but host_bundle_qemu includes QEMU and the boot images, so no host QEMU installation is needed. Access to /dev/kvm is still required.

Build and run directly from the store:

bobr-recipes/bin/bobr-build.sh --target host_bundle_qemu
bobr-store/object-refs/host-bundle-qemu/bin/bobr-run-qemu

The runner creates a persistent home.img in the current directory. Use --help for options.

host_bundle_qemu_gnome is also available, but it is a large download and requires a working Vulkan driver on the host.

Build a specific target

List the available targets:

bobr-recipes/bin/bobr-list-pkgs.sh

Build gzip using your existing profile:

bobr-recipes/bin/bobr-build.sh --target gzip

Inspect its filesystem-tree manifest:

cat bobr-store/object-refs/gzip-1.14

The list shows the attribute used by --target and the recipe name used in object-refs/.

The manifest lists paths, file metadata, and content hashes. Regular file contents are stored separately in bobr-store/fs-files/, shared between trees with identical files. The manifest itself does not contain those bytes.

For details, see Filesystem trees.

Reuse results from another local store

After building gzip, create reuse.ncl beside bobr.ncl:

let bobrpkgs = import "bobr-recipes/bobrpkgs.ncl" in
let pkgs = bobrpkgs [] in
(import "bobr-recipes/profiles/bobr-user.ncl") & {
  include pkgs,
  goals = [pkgs.gzip],
  store = "bobr-store.new",
  secondaries.providers = [
    {
      name = "previous",
      mappings = true,
      content = true,
      local = {
        store = "bobr-store",
        transfer = "hardlink",
      },
    },
  ],
}

Build into a new store using this profile:

mkdir bobr-store.new
bobr-recipes/bin/bobr-build.sh reuse.ncl

Bobr finds the build mapping in the previous store and imports the result using hardlinks, without downloading or rebuilding. Both stores must be on the same filesystem.

The result is available through bobr-store.new/object-refs/. Your original bobr.ncl remains unchanged.

Add a recipe with an overlay

An overlay adds or changes recipes without editing bobr-recipes, following the same model as Nix overlays. The package set is defined as a fixed point: recipes refer to the final set, including all overlay changes. Each overlay receives final, that final set, and prev, the set before this overlay, and returns the fields to add or replace.

Create overlay.ncl beside bobr.ncl:

fun final => fun prev => {
  greeting = {
    name = "greeting",
    tag = "Tree",
    config.tree.entries = [
      {
        type = "file",
        path = "etc/greeting.txt",
        text = "Hello from my overlay!\n",
        executable = false,
      },
    ],
    inputs = {},
  },
}

tag selects the builder; Tree in Request describes how this builder creates files and directories from inline data. The nested path makes the result a directory containing etc/greeting.txt.

In bobr.ncl, replace:

let pkgs = bobrpkgs [] in

with:

let pkgs = bobrpkgs [import "./overlay.ncl"] in

Build the new target and read the resulting file:

bobr-recipes/bin/bobr-build.sh --target greeting
cat bobr-store/object-refs/greeting/etc/greeting.txt

Change text in the overlay and build again to update the file.

Import a file into an fs-tree

Add another recipe inside the record returned by overlay.ncl, alongside greeting:

greeting = {
  # Keep the greeting recipe from the previous example.
},
greeting_tree = {
  name = "greeting-tree",
  tag = "FsTreeImport",
  config.install.rules = [
    {
      path = "**",
      attrs = {
        uid = 0,
        gid = 0,
        directory_mode = 0o755,
        regular_file_mode = 0o644,
        executable_file_mode = 0o755,
      },
    },
  ],
  inputs = { input = final.greeting },
},

FsTreeImport imports the directory into an fs-tree, assigning root ownership and file mode 0644. final.greeting refers to the recipe from the final overlaid set.

Build and inspect the manifest:

bobr-recipes/bin/bobr-build.sh --target greeting_tree
cat bobr-store/object-refs/greeting-tree

The manifest records the etc/greeting.txt path, ownership, mode, and fs-file hash. The file contents are stored separately in bobr-store/fs-files/.

The files in fs-files/ already carry their required ownership and permissions. Bobr can assemble a usable filesystem by hardlinking them into the layout described by an fs-tree manifest.

Compile a local program

Create hello-src/ beside bobr.ncl, containing these two files.

hello.c:

#include <stdio.h>

int main(void)
{
    puts("Hello from Bobr!");
    return 0;
}

Makefile:

.RECIPEPREFIX := >

hello: hello.c
>$(CC) $(CFLAGS) -o $@ $<

install: hello
>mkdir -p /usr/bin
>install -m 0755 hello /usr/bin/hello

.PHONY: install

Record the source directory’s absolute path and content hash:

realpath hello-src > hello-source.path
bobr-fsobj-hash hello-src > hello-source.fsobj-hash

Create hello.ncl beside bobr.ncl:

let hello_source = {
  name = "hello-source",
  tag = "Source",
  object_hash =
    std.string.trim (import "./hello-source.fsobj-hash" as 'Text),
  origin = {
    tag = "Path",
    path = std.string.trim (import "./hello-source.path" as 'Text),
  },
}
in
fun pkgs => {
  name = "hello",
  tag = "Makefile",
  deps = {
    build = [],
    runtime = [pkgs.c_runtime],
  },
  config = {},
  inputs.source = hello_source,
}

In bobr.ncl, replace the goals line with:

goals = [(import "./hello.ncl") pkgs],

Goals can refer directly to recipes outside the package set; no overlay is needed to build this recipe.

Build, inspect the stored sources, and read the resulting fs-tree manifest:

bobr-recipes/bin/bobr-build.sh
ls bobr-store/object-refs/hello-source/
cat bobr-store/object-refs/hello

hello-source points to the source directory stored by Bobr; hello points to the resulting fs-tree manifest.

The first compilation requires a substantial download of the build environment. The recipe runs make and make install, producing an fs-tree containing /usr/bin/hello.

After changing either source file, update the source hash before rebuilding:

bobr-fsobj-hash hello-src > hello-source.fsobj-hash

Add a library dependency

A recipe’s result contains only the files installed by that recipe. Bobr does not inspect compiled programs to infer their dependencies. deps.build adds tools, headers, and libraries to the build environment; deps.runtime declares what must accompany the result when it is included in a root filesystem.

Make hello use zlib by replacing hello.c with:

#include <stdio.h>
#include <zlib.h>

int main(void)
{
    printf("Hello from Bobr with zlib %s!\n", zlibVersion());
    return 0;
}

Add -lz to the link command in Makefile:

>$(CC) $(CFLAGS) -o $@ $< -lz

Then change deps in hello.ncl:

deps = {
  build = [pkgs.zlib],
  runtime = [pkgs.c_runtime, pkgs.zlib],
},

pkgs.zlib in build provides zlib.h and the library used by the linker. The entries in runtime cause the C runtime and zlib to accompany hello when the next section adds it to the QEMU root filesystem.

Update the source hash and rebuild:

bobr-fsobj-hash hello-src > hello-source.fsobj-hash
bobr-recipes/bin/bobr-build.sh

Add the program to a QEMU system

Add these fields to the record returned by overlay.ncl:

greeting = {
  # Keep the greeting recipe from the previous example.
},
greeting_tree = {
  # Keep the greeting_tree recipe from the previous example.
},
hello = (import "./hello.ncl") final,
qemu_rootfs = prev.qemu_rootfs & {
  inputs.hello = final.hello,
},

This adds hello and its runtime dependencies to the image’s root filesystem.

Rebuild the image and boot:

bobr-recipes/bin/bobr-build.sh --target qemu_image
bobr-recipes/tools/bobr-run-qemu.sh

Allow several minutes (about six in a test run): changing the rootfs requires downloading its constituent objects and repacking the EROFS image. In the guest, run:

hello

It prints Hello from Bobr with zlib 1.3.1!; the version is the one selected by the recipes.

Rebuild world using repository sources

Use a new working directory containing bobr-recipes, or remove the existing bobr-store and bobr.ncl. Removing the store deletes all previous build results.

Create bobr.ncl:

let bobrpkgs = import "bobr-recipes/bobrpkgs.ncl" in
let pkgs = bobrpkgs [] in
(import "bobr-recipes/profiles/bobr-user.ncl") & {
  include pkgs,
  goals = [pkgs.world],
  secondaries.providers = [
    (import "bobr-recipes/profiles/provider-public.ncl") & {
      mappings = false,
    },
  ],
}

The repository supplies content whose hash is already known, such as source archives. Build-result mappings are disabled, so Bobr builds from source rather than downloading prebuilt results. Missing sources are fetched from their configured origins.

This is useful if you trust the source hashes in the recipes but do not want to trust prebuilt Bobr results. The build still relies on a prebuilt bootstrap environment. You can independently verify those source hashes by downloading the sources from upstream and running bobr-fsobj-hash --mode=direct. For non-executable files, this produces the same hash as sha256sum.

mkdir bobr-store
bobr-recipes/bin/bobr-build.sh

Expect several gigabytes of downloads and many hours of compilation. world builds all shipped images and host bundles.

Build world without secondary stores

Again, use a new working directory containing bobr-recipes, or remove the existing bobr-store and bobr.ncl.

Create bobr.ncl:

let bobrpkgs = import "bobr-recipes/bobrpkgs.ncl" in
let pkgs = bobrpkgs [] in
(import "bobr-recipes/profiles/bobr-user.ncl") & {
  include pkgs,
  goals = [pkgs.world],
  secondaries.providers = [],
}

Sources come from upstream servers or local recipe files.

This bypasses the prebuilt repository. You can also build Bobr itself from source instead of using its prebuilt binaries; see Development.

mkdir bobr-store
bobr-recipes/bin/bobr-build.sh

Expect several gigabytes of downloads and many hours of compilation. Subsequent runs reuse results in this store.

Next steps

  • Concepts — content addressing, objects, keys, and recipes.
  • Request — the request format and the built-in builders.
  • Recipes in Nickel — authoring recipes.
  • HostBundle — relocatable host-side application bundles.
  • Store — how results are stored, named, and reused.

Concepts

The result of a bobr build is an object: an immutable payload — a file or a directory. Its identity is its ObjectHash, the hash of its content, computed by fsobj-hash.

A filesystem tree is represented by a manifest object, with regular-file contents stored separately in fs-files/; see Filesystem trees.

Each object is produced by a builder — a component inside bobr — according to a recipe describing how to build it. There are two kinds of builder:

  • The Source builder uses no inputs: the object’s content is fixed up front, so the recipe declares the ObjectHash to produce and, optionally, an origin saying how to obtain it.
  • Every other builder (Tree, Sandbox, Group, …) builds its object from the recipe’s inputs: the objects of other recipes it depends on.

Every recipe has a BuildKey — its identity. For the Source builder it is just the declared ObjectHash; for any other builder it is computed from the part of the recipe that says how to build the object from its inputs, together with the BuildKeys of those inputs — that is, from everything in the recipe that determines its result.

The recipes to build, together with their dependencies, form a DAG. bobr reads this graph as a JSON request, builds the selected recipes, stores their objects in the store, and prints their ObjectHash values.

To build the recipe, the Source builder first checks whether the declared object is already in the store; if so, there is nothing to fetch. Otherwise it fetches the content — from a local path, an HTTP URL, or an OCI registry — computes its ObjectHash, and stores it. It then checks that hash against the one the recipe declared. If they match, the source is built; if not, the fetched object still stays in the store (under its real ObjectHash), but the source fails — it did not produce the object it promised.

Builder reuse runs on two store mappings: BuildKey → ObjectHash and ReuseKey → ObjectHash. To build the recipe, any other builder first looks up its BuildKey — a hit means that exact recipe was already built, so it reuses the stored object and skips everything below. Otherwise it determines the inputs’ ObjectHash values, computes a ReuseKey from the same build instructions plus those hashes, and looks that up — a stored result can be reused even when different recipes produced the same input objects. If no result can be reused, bobr obtains the complete input objects, runs the builder, stores its output, and adds both mappings.

Glossary

builder — A named component inside bobr that produces a recipe’s object. The Source builder fetches it from an origin; the others (Tree, Sandbox, Group, …) build it from the recipe’s inputs.

BuildKey — A recipe’s identity. For the Source builder it is the ObjectHash; for any other builder it is computed from the part of the recipe that says how to build the object from its inputs, together with the BuildKeys of those inputs.

input — A named dependency of a recipe on another recipe, whose object the builder consumes when it builds.

object — An immutable payload — a file or a directory — that bobr produces and stores, named by its ObjectHash.

ObjectHash — An object’s identity: a 64-character lowercase hex string that names it by the hash of its content. Computed by fsobj-hash.

origin — A named component inside bobr that the Source builder uses to obtain its object: Path, Http, or OciRegistry.

recipe — A description of how to build one object, naming the builder that builds it.

request — The JSON document bobr takes as input; it describes the recipe DAG to build. See Request.

ReuseKey — A content-based identity, used when a recipe has inputs: like its BuildKey, but computed from the ObjectHashes of those inputs instead of their BuildKeys. It lets builds that reach the same input objects share one stored object, even across different graphs.

store — The content-addressed store where bobr keeps objects, along with the mappings BuildKey → ObjectHash and ReuseKey → ObjectHash. See Store.

Filesystem trees

A builder produces one object: an ordinary file or directory, or a filesystem tree (fs-tree) — bobr’s content-addressed representation of a directory tree, named by the hash of its manifest (see Concepts). fs-trees are the building blocks for whole filesystems: the root filesystem of a target system, or the one a build runs inside (a Sandbox container).

A real filesystem needs files with specific owners and modes — root-owned files, set-uid binaries. bobr uses a user namespace to create files with these logical owners and modes without requiring host-root privileges. Regular files in fs-files/ already carry that ownership and mode in the namespace; materialization preserves them by hardlinking, not by restoring them from the manifest.

An fs-tree’s payload is a manifest listing the tree’s entries; regular-file contents live separately as content-addressed fs-files, shared across trees. A complete fs-tree requires both its manifest and all referenced fs-files; the manifest alone is not sufficient to materialize it.

Assembling a real directory from a manifest is fast and space-cheap — identical files are hard-linked into place, sharing one inode — at the cost of one firm rule: such a filesystem is mounted read-only, since writing a shared file would change every tree that references it. Sandboxed builds can write through a separate overlay without changing the read-only lower tree. An fs-tree is immutable.

The manifest lists one entry per path:

  • a directory — with its logical uid, gid, and mode;
  • a symlink — with its logical uid, gid, and target;
  • a regular file — a reference to an fs-file.

A regular file’s bytes, uid, gid, and mode belong to the fs-file it references, identified by its own hash — not the fsobj-hash that names objects; an fs-file hash covers one file and folds in its uid, gid, and mode along with its bytes. Directory and symlink metadata live in the manifest itself; its exact format is the fs-tree Manifest.

Most operations work on the manifest alone — merging or subsetting trees never touches the files — and bobr materializes a real directory only when a builder needs one. fs-trees model only regular files, directories, and symlinks; they deliberately do not preserve inode topology (so st_ino and st_nlink are not part of a tree’s identity), nor xattrs, POSIX ACLs, or file capabilities (not modeled yet).

Among the built-in builders:

  • FsTreeImport turns an ordinary directory object into an fs-tree, OciExtract produces one from an OCI image, and SandboxInstall captures a sandboxed build’s changes as an additive fs-tree layer;
  • TreeMerge, TreeSubset, and TreeMove transform fs-trees at the manifest level, without materializing them;
  • Sandbox and SandboxInstall use a materialized fs-tree as the image their build runs on – which is also how a tree is packed into an image (an EROFS blob, an initramfs cpio), by a recipe running the packer over the tree.

A recipe controls materialization by the input name: an input whose name begins with _ (e.g. _rootfs, _tree) is materialized into a real directory before the builder runs; any other input is passed as the object itself.

See Request for each builder.

Request

bobr reads a JSON document (described below) from standard input or from a file named on the command line. The document is a request: it describes a recipe DAG and its goals. bobr realizes every goal and prints the result: one goal prints its ObjectHash; several goals print an ordered JSON array of { "node", "object_hash" } records. For the model behind requests — objects, recipes, keys — see Concepts.

The request is a single JSON object:

{
  "schema": "bobr-request-v6",
  "store": "/abs/path/to/store",
  "logs": "/abs/path/to/logs/260803120000",
  "work": "/abs/path/to/work/260803120000",
  "run_id": "260803120000",
  "quiet": false,
  "jobs": 8,
  "progress": { "mode": "auto" },
  "limits": {
    "per_host_default": 6,
    "max_connections": 64,
    "max_local_jobs": 4
  },
  "secondaries": {
    "repository_cache": "/abs/path/to/store/repository-cache",
    "providers": []
  },
  "goals": ["root"],
  "nodes": {
    "root": { "...": "..." }
  }
}
  • schema — format version; must be "bobr-request-v6". bobr --build-info reports the schema this build accepts in machine-readable form, so a recipe layer can check compatibility before building rather than finding out from the parse error; bobr --version shows the same schema together with human-readable build provenance
  • store — the store root for this request: an absolute path to an existing directory (see Store)
  • logs — this run’s log directory: an absolute path to an existing directory, which bobr fills with the run log and one subdirectory per subject (see Build logging)
  • work — this run’s work directory: an absolute path to an existing directory, which bobr fills with one scratch directory per subject
  • run_id — what to call this run; recorded in the object records it writes and in each subject’s meta.json. It must start with an ASCII letter or digit and may contain only ASCII letters, digits, ., _, and -, up to 64 characters
  • quiet — optional bool; suppress the live progress log
  • jobs — optional positive integer; limit on parallel builder execution
  • progress — optional terminal presentation policy; defaults to { "mode": "auto" }. The live block always has fetch statistics, build statistics, and a run summary; summary hides only its individual activity rows. fixed requires max_lines >= 4 and caps the complete live block. This field never affects build identity and is ignored for non-TTY output
  • limits — optional shared Source/repository network and local-I/O limits, described below
  • secondaries — optional ordered mapping and content providers, described below
  • goals — ordered, non-empty array of distinct, existing node ids to realize; its order determines the order of multi-goal results, not execution order
  • nodes — the recipe DAG

bobr neither names the run nor creates its two directories: the caller does both, which is also what keeps two runs from writing into one another. bobr writes into what it is given, and refuses to share it: the run’s events.jsonl is created exclusively, so a second run pointed at a log directory already in use (or left by an earlier run) fails immediately instead of interleaving with it.

The work directory must be on the same filesystem as the store. Builders stage their output there, and the store publishes it by renaming it into objects/ and hardlinking its files into fs-files/; neither crosses a filesystem boundary. bobr checks this before building rather than failing on the first import. The log directory has no such constraint.

The recipe DAG is a JSON object: each member’s value is a recipe. Inputs refer to other members by node id. Only nodes reachable from goals are planned; duplicate build identities are shared.

Acquisition limits

All limits fields are optional; every numeric limit, including each per_host value, must be positive. Omitted fields use bobr’s defaults. A typical resolved configuration is:

{
  "per_host_default": 6,
  "per_host": { "gitlab.freedesktop.org": 2 },
  "max_connections": 64,
  "max_local_jobs": 4
}
  • per_host_default — maximum simultaneous Source and repository network operations on one host when per_host has no entry for it; this includes HTTP/OCI downloads and repository metadata and content fetches
  • per_host — per-host overrides of that limit
  • max_connections — total maximum simultaneous Source and repository network operations; the default is one quarter of the process’s soft file-descriptor limit, clamped to 1..64
  • max_local_jobs — one shared bound for local Path source materialization, Source import and hashing, local-repository content transfer and verification, downloaded repository fs-file publication, and builder input path preparation; it limits disk work independently of network connections and builder slots

Secondary providers

secondaries.providers is an ordered list of independent mapping and content capabilities. The low-level request contains one capability per entry; a profile-level repository that enables both is lowered into two consecutive entries backed by the same source.

repository_cache is an absolute path shared by all remote backends. If it is omitted, it defaults to <store>/repository-cache.

A local read-only store can provide mappings, content, or both:

{
  "repository_cache": "/mnt/bobr/store/repository-cache",
  "providers": [
    {
      "name": "previous",
      "capability": "mappings",
      "backend": {
        "kind": "local",
        "store": "/mnt/bobr/previous-store"
      }
    },
    {
      "name": "previous",
      "capability": "content",
      "backend": {
        "kind": "local",
        "store": "/mnt/bobr/previous-store",
        "transfer": "hardlink"
      }
    }
  ]
}
  • capability = "mappings" permits authoritative BuildKey and ReuseKey answers. It carries no content transfer policy.
  • capability = "content" permits acquisition by an already-known ObjectHash. A local content backend requires transfer: either "hardlink" or "copy". "hardlink" shares regular-file inodes; directory structure and symlinks are reproduced without changing their logical contents. "copy" gives ordinary objects and every fs-file in an fs-tree closure independent working-store inodes while preserving the metadata that participates in their identities.

Mapping and content lookup remain separate: bobr may learn a candidate hash from one backend and obtain its bytes from another. Providers are tried in declared order within each capability.

Repository roots must exist and contain a complete bobr store layout. Paths are canonicalized when opened: a repository cannot alias the working store, and the same canonical repository root cannot be repeated within one capability. Complementary entries may share it, and entries with the same name must do so. hardlink requires the repository and working store objects/ directories to share a filesystem, and likewise requires their fs-files/ directories to share a filesystem. These pairs are checked separately before realization starts because either directory may be a mount point. There is no copy fallback after a hardlink-policy error. copy has no shared-filesystem requirement and always creates independent regular-file inodes, even on the same filesystem; it never silently optimizes the transfer into hardlinks. Both modes verify content identity and publish only a complete ordinary object or fs-tree closure.

A remote backend has this normalized shape:

{
  "name": "public",
  "capability": "mappings",
  "backend": {
    "kind": "remote",
    "master_url": "https://repo.example/bobr/master",
    "trusted_keys": ["/etc/bobr/repository-key.pem"],
    "ca_bundle": "/etc/bobr/repository-ca.pem"
  }
}

master_url must be an absolute HTTPS URL without credentials, query, or fragment. trusted_keys is a non-empty array of absolute paths to pinned Ed25519 public keys. ca_bundle is optional and adds private roots to the normal HTTPS roots. Entries that repeat one URL must use identical trust and TLS settings. Remote providers support both mapping lookup and verified content downloads. They authenticate the signed repository master with the pinned keys and fetch metadata lazily, caching it under repository_cache. Authenticating the master does not enable either capability implicitly: each must be requested explicitly.

Within each capability, provider names and physical backends are unique. The same name may occur once for mappings and once for content only when both entries use the same backend. This makes diagnostics stable and prevents an apparently combined provider from naming unrelated sources.

Repository configuration is required only while it is used. After successful import, complete content belongs to the working store; any mappings, records, and refs published for the selected result are working-store entries as well. A later offline request can omit the repository and reuse that content; removing the repository’s directory entries does not invalidate either copied files or hardlinked working-store names. Explicitly configured local stores and all provider settings are validated at startup; remote metadata is fetched only when that provider is needed.

A recipe for the Source builder has this shape:

{
  "name": "linux-src",
  "tag": "Source",
  "object_hash": "0123…abcd",
  "origin": {
    "tag": "Http",
    "url": ["https://example.invalid/linux.tar.xz"],
    "unpack": true
  }
}
  • name — a human-facing name for the result
  • tag — must be "Source"
  • object_hash — the ObjectHash this source must produce
  • origin — how to obtain the object this recipe describes; defined below

A recipe for the Source builder may also omit origin. Then its object must already be available from the working store or a configured content provider under its object_hash; if it is not, the source fails.

An origin is the ordinary case in current bobr-recipes: unified bobr acquires missing Source content lazily while realizing the same DAG. Omitting it is useful for explicitly offline requests and requires the object to be available from the working store or configured content providers.

A recipe for any other builder has this shape:

{
  "name": "tar-1.35",
  "tag": "Sandbox",
  "config": {
    "steps": [
      {
        "name": "build",
        "run_as": "build-user",
        "cwd": "@{build}",
        "argv": ["@{script}", "build"]
      }
    ]
  },
  "inputs": {
    "_rootfs": "rootfs_1",
    "script": "script_1",
    "source": "src_0"
  }
}
  • name — a human-facing name for the result
  • tag — the name of the builder that builds this recipe
  • config — the builder’s configuration; its shape is defined by the builder
  • inputs — dependencies keyed by input name; each value is the key of another member of nodes — the recipe this one depends on

An input whose name begins with _ is materialized: its object must be a filesystem tree and the builder receives the path to a real directory of files. Any other input is passed as the object itself (for an fs-tree, that is the manifest object). This rule is uniform across builders and applies to both the slots a builder declares and any extra inputs it accepts.

Builders

Tree

Realizes an inline description of text files, symlinks, and directories as one object.

Inputs: none.

Config: a tree with a list of entries, each a file, dir, or symlink:

{
  "tree": {
    "entries": [
      { "type": "dir", "path": "etc" },
      { "type": "symlink", "path": "bin", "target": "usr/bin" },
      { "type": "file", "path": "etc/hostname", "text": "bobr\n", "executable": false }
    ]
  }
}
  • a file entry carries UTF-8 text and an executable flag
  • a symlink entry carries a literal target string
  • a dir entry is an explicit directory

Behavior:

  • parent directories for file entries are created automatically
  • if the tree is exactly one top-level file entry, the result is a file object; otherwise it is an ordinary directory object
  • install metadata is not accepted here — use FsTreeImport to attach logical ownership and mode

Limitations: only UTF-8 text files, symlinks, and explicit directories; binary files and richer file-mode control are not yet supported.

Bundle

Collects an arbitrary number of file inputs into one directory object.

Inputs: one or more extra inputs. Each must be a regular file or a directory containing exactly one regular file at its top level. Other directory entries are ignored; symlinks cannot be passed as standalone inputs.

Config: none ({}).

Behavior:

  • writes a plain directory object with one entry per input, a hardlink named by the input key (inputs are hardlinked, never copied); for a single-file directory input, the inner filename does not determine the output name
  • the result is ordinary data with no ownership or mode identity — unlike the fs-tree builders; use it to hand a downstream build many inputs as a single directory (one bind mount) instead of one bind mount per input, e.g. to bundle vendored crate archives

FsTreeImport

Imports one ordinary directory object into an fs-tree, applying install rules that set logical ownership and mode.

Inputs: required input — one ordinary directory object. To import a file object, first place it in a directory with Bundle.

Config: install.rules, evaluated in order; each rule is a glob path plus attrs (any of uid, gid, directory_mode, regular_file_mode, executable_file_mode):

{
  "install": {
    "rules": [
      {
        "path": "**",
        "attrs": {
          "uid": 0,
          "gid": 0,
          "directory_mode": 493,
          "regular_file_mode": 420,
          "executable_file_mode": 493
        }
      }
    ]
  }
}

Behavior:

  • imports the input contents into the store’s shared file storage and writes one fs-tree object
  • later matching rules override earlier attributes field by field
  • rules must provide uid, gid, and the applicable mode for every entry, including the root directory (whose relative path is empty); "**" matches the root as well as its descendants
  • directory, regular-file, and executable-file modes come from install attributes; symlink mode is not represented
  • runs as a namespace function, since importing needs namespace-root access to ownership metadata

FsTreeExport

The inverse of FsTreeImport: extracts selected entries out of an fs-tree into an ordinary object. Its input is an fs-tree, passed as a plain object (so the builder receives the manifest and pulls matched files from shared storage by content hash — it does not materialize the whole tree).

Inputs: required input — one fs-tree object.

Config: copies, a non-empty ordered array of { from, to } commands:

{ "copies": [ { "from": "boot/bzImage", "to": "bzImage" },
              { "from": "usr/lib/*.so.1", "to": "libs" } ] }
  • from is a glob (or literal path) matched against the fs-tree’s paths.
  • to is the destination in the output object. For a literal from naming a single file or symlink, to is the exact output path (allowing rename). For a glob (or a literal directory), to is a directory and each match is placed under it preserving its path relative to the glob’s literal base (for a literal directory, relative to that directory). Directory entries never copy on their own — parent directories are created as needed.
  • A command that matches nothing, or two commands writing the same destination, is rejected.

Behavior:

  • produces a plain-object directory: matched regular files (mode, including the executable bit, preserved), matched symlinks recreated, all owned 0:0
  • runs as a namespace function: reading arbitrary fs-files needs namespace-root (they carry their entries’ logical ownership and mode), and a plain object must be single-owner

TreeMerge

Merges two or more fs-trees into one, with strict conflict checking.

Inputs: two or more fs-tree inputs (as extra inputs), consumed in the standard input order (required inputs, then optional, then extra inputs in lexical name order).

Config: none ({}).

Behavior:

  • reads the input manifests directly and does not materialize them
  • overlapping directory paths are allowed only when uid, gid, and mode match
  • duplicate file paths are allowed only when their FsFileHash values match, including file bytes, ownership, and mode
  • duplicate symlink paths are allowed only when uid, gid, and target match
  • file-vs-directory, symlink-vs-directory, and parent/child leaf conflicts are rejected
  • writes one fs-tree object

TreeSubset

Produces an fs-tree containing only the paths that match its include patterns.

Inputs: required tree — one fs-tree.

Config: include, a non-empty list of glob patterns:

{
  "include": [
    "usr/lib64/libfoo.so*",
    "usr/share/foo/**"
  ]
}

Behavior:

  • reads the input manifest directly and does not materialize it
  • matches include globs against manifest paths; a matched file, symlink, or directory is included together with its parent directories
  • selecting a directory directly includes only that directory; recursive selection needs a pattern such as dir/**
  • individual patterns may match nothing, but the build fails if the final subset has no non-root paths
  • rejects empty include lists, empty patterns, absolute patterns, and patterns containing ..
  • writes one fs-tree object

TreeMove

Re-roots an fs-tree at one of its subdirectories: the strip_prefix directory becomes the new root and its leading path component is dropped from every nested entry. A pure manifest operation — the same fs-files are referenced under shorter paths.

Inputs: required tree — one fs-tree.

Config: strip_prefix, the subdirectory to promote to the root:

{ "strip_prefix": "stage" }

Behavior:

  • reads the input manifest directly and does not materialize it
  • promotes the strip_prefix subtree to the root — each entry keeps its content and metadata, with the leading strip_prefix component removed — and writes one fs-tree object

HostBundle

Builds a verified, relocatable host-side application directory from an already materialized runtime root and a static launcher package. See HostBundle for the complete composition, verification, and runtime model.

Inputs:

  • required _root — the materialized payload fs-tree
  • required _launcher — a materialized fs-tree containing usr/libexec/bobr-bundle-launcher
  • optional overrides — an ordinary directory object copied under the bundle’s overrides/

Config:

{
  "arch": "x86_64",
  "policy": "strict",
  "min_kernel": "4.19",
  "library_dirs": ["usr/lib64", "usr/lib"],
  "public_tools": {
    "demo": {
      "path": "usr/bin/demo",
      "argv0": "demo",
      "argument_prefix": [
        { "value": "--data-dir" },
        { "source": "payload", "path": "usr/share/demo" }
      ],
      "environment": {
        "DEMO_CONFIG": {
          "operation": "replace",
          "paths": [
            { "source": "payload", "path": "etc/demo/config.toml" }
          ]
        }
      }
    }
  },
  "internal_tools": {
    "demo-helper": { "path": "usr/libexec/demo/helper" }
  },
  "environment": {
    "LOCALE_ARCHIVE": {
      "operation": "replace",
      "paths": [
        { "source": "payload", "path": "usr/lib/locale/locale-archive" }
      ]
    }
  }
}

Top-level fields:

  • required arch — "x86_64" or "aarch64"
  • optional policy — "strict" (the default) or "integrated"; the current runtime records and reports this value, but both values use the same launch and verification rules
  • optional min_kernel — MAJOR.MINOR or MAJOR.MINOR.PATCH, default 4.19
  • required library_dirs — ordered safe paths relative to _root; it may be empty only for a completely static configured startup closure
  • required non-empty public_tools — commands exposed in top-level bin/
  • optional internal_tools — commands exposed only through the managed child process PATH
  • optional environment — rules shared by all tools

A tool declaration requires path, a safe path relative to _root resolving to an executable regular file. Optional argv0 defaults to the tool name. Optional argument_prefix inserts entries before caller arguments; each entry is either a literal { "value": "..." } or a path selected from payload or overrides. Optional per-tool environment is applied after the common rules.

Tool names are non-empty UTF-8 basenames: ., .., names containing / or NUL, and the reserved bobr-bundle-launcher name are invalid. A name cannot be both public and internal. argv0 must be non-empty and contain no NUL.

An environment rule has this shape:

{
  "operation": "prepend",
  "paths": [
    { "source": "payload", "path": "usr/share/example" }
  ],
  "values": [],
  "inherit": true,
  "host_default": ["/fallback"]
}

paths, values, inherit, and host_default are optional and default to an empty array or false. paths and values are mutually exclusive; multiple entries are joined with :. Operations are:

  • replace — require configured paths or values and ignore the host; inherit and host_default are invalid
  • prepend / append — require configured paths or values; include the current value only with inherit = true, using host_default when that value is absent
  • unset — remove the variable and accept none of the other fields
  • default — preserve an existing variable, otherwise use configured paths or values, or host_default; inherit is invalid

For prepend and append, host_default requires inherit = true. Variable names must be non-empty and contain neither = nor NUL. PATH is reserved in both common and per-tool rules: the builder always prepends libexec/wrapped-bin to the inherited host PATH.

A typed path has source "payload" or "overrides" and a safe path relative to that source. Paths must be non-empty and relative, with no NUL and no empty, . or .. component. Referencing overrides requires the optional input to be present. Paths are canonicalized and must remain inside the corresponding completed bundle tree. The top-level config, tool declarations, literal arguments, and environment rules reject unknown fields.

Behavior:

  • independently copies the payload to root/, the launcher to libexec/, and optional overrides to overrides/; it creates no hardlinks to input/store objects
  • writes a read-only ordinary directory object with public wrappers in bin/ and wrappers for every public/internal tool in libexec/wrapped-bin/
  • generates the versioned bobr-host-bundle-v2 bundle.toml and records the selected architecture as [platform].arch
  • verifies that the launcher, tools, dynamic loaders, and complete startup DT_NEEDED closure are ELF64 objects for the declared architecture; scripts and their bundled shebang interpreters are verified recursively
  • resolves startup libraries only inside the payload, accepting safe $ORIGIN RPATH/RUNPATH entries and rejecting unsupported or escaping paths
  • removes write, setuid, and setgid bits before publication
  • never executes target code while composing the bundle, so the builder itself is host-architecture-independent; the explicit config and input hashes carry all target-architecture differences into the build key

OciExtract

Extracts one OCI image layout into an fs-tree.

Inputs: required image — an OCI image layout object (for example, a Source/OciRegistry result).

Config: none ({}).

Behavior:

  • extracts the image root filesystem into one fs-tree object
  • the result can be consumed as an fs-tree input by TreeMerge, TreeMove, TreeSubset, Sandbox, or SandboxInstall

Sandbox

Runs an ordered plan of commands on a read-write overlay root — a set of Linux namespaces with no network access, over the materialized _rootfs — and captures the @{out} staging directory as the result: chowned to a single owner and stored as a plain object. Changes the steps make to the root itself are discarded; only @{out} is kept. To keep those changes instead, see SandboxInstall.

Inputs:

  • required _rootfs — one fs-tree; materialized (its name begins with _) and used as the read-only lower layer of the build’s read-write overlay root
  • any number of extra inputs — each made available to the steps through its interpolation name @{name} (read-only). An input name must start with an ASCII letter or _, contain only ASCII letters, digits, or _, and must not be build, out, or config (reserved). An extra whose name begins with _ is materialized into a filesystem tree (see above) and @{name} is that directory; otherwise @{name} is the object itself.

Config:

  • steps — a required, non-empty array of steps
  • script_config — a config tree, available to the steps as @{config} (default {}, an empty config directory)
{
  "script_config": {
    "configure_args": ["--disable-nls"]
  },
  "steps": [
    {
      "name": "build",
      "run_as": "build-user",
      "cwd": "@{build}",
      "argv": ["@{script}", "build"],
      "env": { "CC": "gcc" }
    }
  ]
}

Each step has:

  • name — non-empty (after trimming); used in reports and log names
  • run_as — "build-user" or "root"
  • cwd — non-empty; must resolve to an absolute path
  • argv — a non-empty array of non-empty strings
  • env — optional object whose values are strings

Each step runs with a fixed default environment, which its env extends or overrides: PATH, HOME (the build directory), TMPDIR (/tmp), USER (bobr), LC_ALL and LANG (C), TZ (UTC), SOURCE_DATE_EPOCH (a fixed epoch), and PYTHONHASHSEED (0) — locale, timezone, epoch, and hash seed are pinned for reproducibility. The build, output, config, and inputs directories are also exposed as BOBR_BUILD_DIR, BOBR_OUT_DIR, BOBR_CONFIG_DIR, and BOBR_INPUTS_DIR, and the step’s name as BOBR_STEP_NAME. BOBR_BUILD_SEED carries a deterministic per-build seed (64 lowercase hex chars) for steps that need a reproducible “random” value, such as a filesystem UUID; it is derived from the build’s reuse key, so identical inputs yield an identical seed.

cwd, each argv item, and each env value support @{…} interpolation (name, run_as, env keys, and script_config do not):

  • @{build} — the writable build directory
  • @{out} — the writable output directory; its contents become the result object (Sandbox only)
  • @{config} — the materialized script_config directory
  • @{<input>} — an extra input: the materialized directory if its name begins with _, otherwise the read-only object path

@@{name} escapes to the literal @{name}. Unknown variables and malformed interpolation are invalid config.

script_config may be absent or {} (an empty config directory); otherwise it is a recursive tree: objects become directories, arrays become directories with zero-padded numeric entries (00000000, …) in order, and strings become file contents. Keys must be non-empty, must not be . or .., and may contain only ASCII letters, digits, ., _, and -.

SandboxInstall

The additive counterpart of Sandbox: the same overlay run — identical _rootfs, extra inputs, steps, script_config, environment, and @{…} interpolation — but instead of a separate @{out} it captures the changes the steps make to the root as an additive fs-tree layer: the overlay’s upper layer, an ownership-aware delta over _rootfs meant to be TreeMerged back onto it.

Inputs: required _rootfs plus any number of extra inputs — as Sandbox.

Config: steps and script_config — as Sandbox.

Behavior:

  • runs the steps on the same read-write overlay root, then writes one fs-tree object containing only additions, pruning unchanged directory copy-ups
  • overwriting existing files or symlinks, deleting lower-layer entries, changing existing directory metadata, or hiding existing directory contents fails the build; the result is not a general-purpose modification layer
  • there is no @{out} / BOBR_OUT_DIR; using @{out} is an unknown-variable error — the result is the delta, not a staging directory

Group

Aggregates several otherwise unrelated targets under one completion-marker target.

Inputs: one or more extra inputs (arbitrary).

Config: none ({}).

Behavior: does not merge or inspect its inputs; it stages a constant zero-byte marker once all inputs are realized, so its object is only a completion marker — the meaningful results are the input targets themselves.

Origins

A recipe for the Source builder obtains its object from an origin, one of:

  • Path — origin.path is an absolute host path; origin.unpack (default false) treats it as a tar archive when true.
  • Http — origin.url is one HTTP(S) URL or an ordered list of fallbacks; origin.unpack (default false); origin.archive_format may override archive detection for unpacked sources: "tar-gz", "tar-xz", "tar-bz2", or "zip".
  • OciRegistry — origin.image is the registry image locator, origin.digest the pinned sha256: manifest or index digest, and origin.platform ({ "os": …, "architecture": … }) selects the platform to pull. bobr fetches the pinned manifest (selecting the platform from a manifest list or index), downloads and verifies every blob, and writes an OCI image layout whose canonical form is independent of the registry mirror named by origin.image.

Recipes in Nickel

Writing a request by hand is tedious: a real target pulls in hundreds of packages — each a source plus a build recipe — all wired together by node id. Recipes are instead written in Nickel, a typed configuration language, and lowered to a request. The bobr-recipes repository is a worked example: a Nickel program that builds a whole Linux root filesystem from source, starting from an OCI base image, bootstrapping a self-hosted toolchain, and composing the result into root filesystems and disk images.

bobr itself never sees Nickel — it only receives the JSON request the Nickel layer produces. This chapter describes how that layer works in bobr-recipes.

The package set

bobrpkgs.ncl exports a function from an ordered list of overlays to the recipe set; (import "bobrpkgs.ncl") [] builds the default set. It folds together the package recipes — one module per package, listed in pkgs/pkgs.ncl and living beside it in pkgs/ — with the modules that assemble images, tests, and bundles. Each module is a function of the finished set, so recipes refer to one another by name — pkgs.gcc, pkgs.glibc_libs, pkgs.base_filesystem. The set is a fixed point: every package sees the fully assembled pkgs, which is what lets recipes depend on each other and lets overlays override anything.

A package module returns named recipes:

fun pkgs =>
  let libffi_src = {
    version | default = "3.6.0",
    name = "libffi-src-%{version}",
    tag = "Source",
    object_hash | default = "…",
    origin = { tag = "Http", url = "https://github.com/libffi/libffi/releases/download/v%{version}/libffi-%{version}.tar.gz" },
  } in
  let libffi = {
    name = "libffi-%{inputs.source.version}",
    tag = "Autotools",
    deps = { build = [], runtime = [pkgs.glibc_libs] },
    config = { configure_args = ["--disable-static"] },
    inputs = { source = pkgs.libffi_src },
  } in
  { include [libffi_src, libffi] }

request.ncl ties it together: given the paths of one run, the final package set, and an ordered non-empty array of recipe goals, it lowers their shared DAG to a full request. Callers rarely invoke it directly — bin/bobr-build.sh does, reading everything but the run’s own identity from a build profile (bobr.ncl, shaped by profiles/contracts.ncl). A normal profile imports the maintained profiles/bobr-user.ncl preset instead of copying a template.

The profile is itself the Nickel program that constructs the package set and selects its goals:

let bobrpkgs = import "bobr-recipes/bobrpkgs.ncl" in
let pkgs = bobrpkgs [] in
(import "bobr-recipes/profiles/bobr-user.ncl") & {
  include pkgs,
  goals = [pkgs.world],
}

include pkgs adds the existing local pkgs value as a record field. Multiple goals share one lowered DAG and retain their array order. Repeated --target NAME options replace the profile goals for one invocation; each name is looked up in the profile’s final pkgs.

Profile goals are recipe values, not attribute names. They can also contain a recipe imported from outside pkgs, for example:

goals = [(import "./hello.ncl") pkgs],

Here hello.ncl is a function returning a recipe record. Such a recipe needs no overlay to be built, but cannot be selected by --target unless added to pkgs. See Compile a local program.

A build profile can attach ordered local and remote backends to the Realizer. Capabilities are explicit and independent; enabling both on one profile entry is shorthand for two low-level request entries backed by the same source:

secondaries.providers = [
  {
    name = "previous",
    mappings = true,
    content = true,
    local = {
      store = "../bobr-store.previous",
      transfer = "hardlink",
    },
  },
  {
    name = "public",
    content = true,
    remote = {
      master_url = "https://repo.example/bobr/master",
      trusted_keys = ["./repository-key.pem"],
    },
  },
]

mappings allows authoritative BuildKey and ReuseKey answers; content allows acquisition for an already-known hash. Neither implies the other. Local content requires transfer: hardlink requires the relevant store directories to share filesystems, while copy deliberately creates independent inodes and works across filesystems. Local store paths, remote key paths, an optional CA bundle, and secondaries.repository_cache are resolved relative to the top-level profile. An empty cache setting selects <store>/repository-cache. See Request for the normalized wire contract.

An optional output_repository section enables a post-build publication stage in bobr-build.sh. After a successful non-dry-run build, the driver uploads missing content and prepares an unsigned candidate master when the repository state changes; signing and publishing that master are separate administrator actions. This section is not included in the JSON build request. See Repository CLI for preparation and publication.

Local sources

Local files and directories are Source recipes with a declared content hash. Inside bobr-recipes, RecipePath names a path relative to the recipes checkout; request lowering converts it to Path with an absolute host path. For example, a recipe in pkgs/foo/ can refer to a sibling patch:

{
  name = "foo-fix",
  tag = "Source",
  object_hash = std.string.trim (import "./fix.patch.fsobj-hash" as 'Text),
  origin = {
    tag = "RecipePath",
    path = "pkgs/foo/fix.patch",
  },
}

The hash import is relative to the Nickel file; RecipePath.path is relative to the checkout root. A plain Path origin instead requires an absolute host path, including for sources outside the checkout.

The adjacent .fsobj-hash file pins the source content. After editing the patch, refresh it from the recipes checkout:

bin/bobr-update-fsobj-hashes.sh pkgs/foo/fix.patch

bobr-build.sh checks existing hash locks inside the recipes checkout before lowering; it does not update them or check sources outside that checkout. For external sources, update the declared hash yourself after changes. An object already cached under the declared hash can be reused without rereading its origin. See Compile a local program for an external-source example.

Overlays

An overlay is a function fun final => fun prev => patch, where prev is the package set built so far and final is the finished set. Overlays are applied in order, and each one’s fields replace the matching packages — the same idea as Nix overlays. For example, to pin a different shadow version:

[
  fun final => fun prev => {
    shadow_src = prev.shadow_src & {
      version = "4.18.0",
      object_hash = "add4604d3bc410344433122a819ee4154b79dd8316a56298c60417e637c07608",
    },
  },
]

The version and source archive hash must match. For an override to propagate, consumers of a public recipe must refer to it through pkgs (or final in an overlay), not a module-local binding that still names the original recipe.

Important: Recipe names are also the current lowering layer’s deduplication keys. Multiple consumers may share the same recipe value and name, but different reachable recipe definitions must have different names. Lowering does not currently diagnose a conflict: if two different recipes have the same name, it keeps one definition and makes both references point to it.

This is a limitation of the Nickel recipe layer. Recipe names do not participate in Bobr’s BuildKey.

Replacing a public Source such as shadow_src above leaves only one shared definition, so its version-derived name is sufficient. If an overlay instead replaces the Source input of only one consumer while the original Source remains reachable, the replacement needs an explicitly distinct name:

fun _final => fun prev => {
  gzip = prev.gzip & {
    inputs.source | force = prev.gzip_src & {
      name | force = "gzip-src-1.14-local",
      object_hash = "…",
    },
  },
}

Changing only object_hash does not change a name derived from version. Names need to be unique only among different recipes reachable in the same lowered graph.

Recipes are data, not functions

Values in the recipe set are plain data records, not functions. Modules and factories may be functions returning records; their evaluated results are the recipes. An overlay replaces selected fields of the set and can explicitly merge changes into prev.foo with &. A function value cannot be patched that way. Keeping recipes as records makes their versions, configure flags, and inputs accessible to overlays.

Synthetic recipes

The recipe above uses the tag Autotools, which is not a bobr builder — bobr has no such builder. It is a synthetic recipe: a high-level, Nickel-only tag that stands for a common build pattern and is lowered (expanded) into real builder nodes before the request reaches bobr.

Lowering happens in recipe-lib.ncl’s to_request_goals, which walks the recipe graph and looks each node’s tag up in synthetic/registry.ncl. A node whose tag is already a real builder — Tree, TreeMerge, Sandbox, SandboxInstall, Source, and so on — passes through unchanged. A node with a synthetic tag is expanded into real builder nodes: ordinary build families use SandboxInstall with a build rootfs assembled by TreeMerge; staging variants also use TreeMove. Expansion runs on the already-overlaid package set, so overlays always patch the high-level recipe and expansion sees the result; lowering then assigns node ids and emits the JSON nodes map.

HostBundle is different: it is a real bobr builder, used directly from a Nickel recipe rather than expanded by the synthetic registry. Its inputs and typed configuration are documented in HostBundle.

Build and runtime dependencies

A dependency-aware synthetic recipe carries deps = { build = [...], runtime = [...] }:

  • build — packages needed to build this one. The lowering layer assembles the build environment as a TreeMerge of base_filesystem with the runtime closure of a default toolchain plus these build deps, and injects it as the build sandbox’s _rootfs input.
  • runtime — packages this one needs at run time. They form the package’s own runtime closure, which is followed transitively wherever the package appears in another recipe’s build or runtime list.

So a recipe declares what a package needs, and the root filesystem to build it in is assembled automatically from the runtime closures of its build dependencies.

Split outputs

The unsuffixed attribute (pkgs.glibc) is the full install tree. You can also add recipes for split outputs — suffixed attributes carrying a subset of that tree, so a dependent pulls in only what it needs. A split output is just a TreeSubset over an fs-tree, so it is very cheap: it references the same shared fs-files as the full tree, with nothing copied. The built-in helper recipe.split.libs produces a _libs output from the shared libraries, loader, and SONAME symlinks — pkgs.glibc_libs from pkgs.glibc. Attributes are snake_case; the builder name keeps the version in kebab-case (glibc-libs-2.42).

Depend on the narrowest output that has the files you need — deps.runtime on _libs for a library dependency (pkgs.glibc_libs, not pkgs.glibc), the full attribute only when the whole tree is required.

Other suffixes are reserved for the same idea and added by hand as a TreeSubset when a package needs them; today only _libs has a helper (plus one hand-written systemd_dev):

  • _dev — headers, pkg-config/CMake metadata, and dev symlinks
  • _tools — executable utilities
  • _static — static libraries
  • _doc, _locale, _data — documentation, locale data, other architecture-independent runtime data

Avoid _runtime (already the deps.runtime role) and _closure (used by RootfsClosure). _src denotes a public upstream Source recipe, while _gen1 marks a bootstrap stage.

Synthetic builders

A synthetic builder expands to a sandboxed build (see Request) that unpacks the source and runs it through a build pipeline. The build families here lower to the additive SandboxInstall builder: their result is a fs-tree layer of the files the build installs, destined for the target filesystem and merged into a rootfs like any other package. A build whose result is instead a self-contained artifact — a disk image, an archive — rather than files for the target root lowers to the plain Sandbox builder, which captures a @{out} staging directory as a plain object (as recipe.erofs_image does).

Most build families come in two flavors:

  • the dependency-aware ones (Autotools, Makefile, Meson, Cmake, PerlModule, PythonModule, Cargo, plus the custom-steps SandboxInstallDeps and SandboxStageDeps) take a deps record and build their own rootfs from deps.build; scripted families require source, Cargo additionally requires crates, and custom-step recipes declare only the inputs their steps need;
  • the explicit-rootfs variants (AutotoolsStageRootfs, MakefileStageRootfs, MesonRootfs, CargoRootfs, SandboxStageRootfs) take an explicit _rootfs input instead of deriving it from deps.build; they may still declare deps.runtime for consumers of their output.

New, non-bootstrap recipes should use the plain dependency-aware builders; the *Rootfs and *Stage* variants are bootstrap-chain machinery and are rarely the right choice otherwise.

Cmake, PerlModule, and PythonModule have no explicit-rootfs variant.

The common native toolchain referenced by the defaults below is linux_headers, glibc, binutils, gcc, bash, make, coreutils, gawk, sed, grep, tar, gzip, bzip2, xz, patch, findutils, and diffutils.

How a synthetic build runs

Except for the custom-steps Sandbox* builders, the builders share one pipeline, run as sandbox steps against the build rootfs:

  1. prepare — unpack the source and apply patches (below);
  2. any pre_configure (or, for Makefile, pre_build) hook steps;
  3. configure — the tool’s configure step (Makefile has none);
  4. build — the tool’s build step, as build-user, parallel (-j = CPU count);
  5. install — as root into the build’s live overlay root (--prefix=/usr, no DESTDIR); the additive SandboxInstall capture takes the files the install added as an fs-tree layer, which is the recipe’s result. The *StageRootfs and SandboxStageDeps variants instead install into a virgin /stage, re-rooted /stage → / by TreeMove, for bootstrap recipes where writing into / would touch lower-layer files;
  6. any post_install hook steps.

Source and patches. The source input is unpacked into @{build}/source; if it is a single tarball whose contents sit under one wrapping directory, that wrapper is stripped. Any input whose name begins with patch is then applied from the source root with patch -p1, in sorted input-name order — a .patch file directly, and a directory by applying every *.patch it contains. So patches are added as extra inputs:

inputs = {
  source = foo_src,
  patch0 = foo_fix_configure,   # a .patch Source
  patch1 = foo_extra_patches,   # or a Source directory of *.patch files
}

Config. A recipe’s config fields reach the build script through script_config (materialized at @{config}): configure_args, make_args, setup_args, and perl_args become the arguments of the matching command; env entries are loaded by each synthetic build-script phase; source_subdir builds a subdirectory of the source; and in_tree (Autotools) builds in the source tree instead of a separate build directory. Hooks are independent Sandbox steps, so a hook that needs environment variables must declare its own env.

Hooks. pre_configure / pre_build / post_install are extra Sandbox steps injected into the pipeline — a single step or an array. Each is a normal step (name, run_as, argv, optional env, and cwd, which defaults to the source directory for pre-hooks and the install root for post_install: / for ordinary installs or /stage for staging variants), so a recipe can run arbitrary commands before configuring or after installing without leaving the synthetic builder.

Autotools

  • Steps: ./configure --prefix=/usr <configure_args> → make -j <make_args> → make <make_args> install.
  • Config (all optional): configure_args, make_args, env, in_tree, source_subdir, pre_configure, post_install.
  • Default build tools: the common native toolchain plus autoconf, m4, and perl.

Makefile

  • Steps: no configure; make -j <make_args> → make <make_args> install (skipped when skip_install is set).
  • Config (all optional): make_args, env, source_subdir, pre_build, post_install, skip_install.
  • Default build tools: the common native toolchain.

Meson

  • Steps: meson setup <build-dir> <source> --prefix=/usr <setup_args> → meson compile -j → meson install --destdir /.
  • Config (all optional): setup_args, env, source_subdir, pre_configure, post_install.
  • Default build tools: the common native toolchain plus pkgconf, python, meson, ninja, python_setuptools, and python_packaging.

Cmake

  • Steps: in-source cmake -S . -B . (Unix Makefiles generator; -DCMAKE_INSTALL_PREFIX=/usr and -DCMAKE_BUILD_TYPE=Release baked in) with <cmake_args> → make -j → make install.
  • Config (all optional): cmake_args, make_args, env, source_subdir, pre_configure, post_install.
  • Default build tools: the common native toolchain plus cmake.

PerlModule

  • Steps: perl Makefile.PL <perl_args> → make -j <make_args> → make <make_args> install.
  • Config (all optional): perl_args, make_args, env, source_subdir, pre_configure, post_install.
  • Default build tools: the common native toolchain plus perl.

PythonModule

  • Steps: build a wheel with pip wheel --no-build-isolation --no-deps, then pip install --prefix /usr (no --root) into the overlay root.
  • Config (all optional): env, source_subdir, pre_configure, post_install.
  • Default build tools: the common native toolchain plus python.

Cargo

  • Steps: vendor the crate dependencies offline (below), then run cargo install --path <source> --offline --locked to build and install the selected crate [[bin]] targets into the overlay root.
  • Inputs: required source and crates. crates is normally a public Bundle recipe generated from the upstream Cargo.lock with cargo.vendor_inputs from cargo.ncl.
  • Config (all optional): features, bins, no_default_features, env, source_subdir, post_install.
  • Default build tools: the common native toolchain plus rust_bin.

One typical public dependency bundle is:

let cargo = import "../../cargo.ncl" in
let foo_crates = {
  version | default = "1.2.3",
  cargo_lock | default = import "Cargo.lock" as 'Toml,
  name = "foo-%{version}-crates",
  tag = "Bundle",
  config = {},
  inputs = cargo.vendor_inputs cargo_lock,
} in
# ... inputs = { source = pkgs.foo_src, crates = pkgs.foo_crates }

Vendoring. The sandbox has no network, so a Rust build cannot pull crates from crates.io; its dependencies are provided by the explicit crates input. cargo.vendor_inputs turns a parsed lockfile (import "Cargo.lock" as 'Toml) into one .crate HTTP Source per crates.io entry. Each Source’s object_hash is exactly the checksum recorded in Cargo.lock: the fsobj-hash of a non-executable file equals its SHA-256, and crates.io serves each version as an immutable .crate, so the lockfile content-addresses and verifies every vendored crates.io dependency. Git, path, and alternative-registry dependencies are not fetched by this helper. A built-in step unpacks the bundle into an offline Cargo vendor/ tree before the build.

The source archive and its crates bundle can be separate, overridable recipes and must describe the same release. Recipes exposing such a pair should check their declared versions. Cargo then uses the Cargo.lock shipped by the unpacked source with --locked --offline. A missing dependency, incompatible lockfile, or checksum mismatch therefore fails the build instead of changing the lockfile or accessing the network. Custom Meson or Autotools recipes that invoke Cargo should run cargo.verify_lock_step after preparing the same vendor directory.

CargoRootfs has the same explicit source and crates inputs as Cargo and additionally requires _rootfs.

SandboxInstallDeps, SandboxStageDeps, SandboxStageRootfs

Run your own Sandbox step plan instead of the pipeline above — the same config as the low-level SandboxInstall builder (steps, script_config; see Request). Source unpacking and patching are not automatic here; do them in your own steps. All three capture their result additively:

  • SandboxInstallDeps — build rootfs from deps; the steps install into the live overlay root (/);
  • SandboxStageDeps — build rootfs from deps; the steps must install into a virgin /stage, re-rooted /stage → / by TreeMove;
  • SandboxStageRootfs — the /stage model of SandboxStageDeps, but with an explicit _rootfs input rather than one derived from deps.build.

For a plain-object result — a @{out} staging directory rather than an fs-tree, e.g. a disk image — use the low-level Sandbox builder with an explicit _rootfs directly (as recipe.erofs_image does).

  • Default build tools (the Deps variants): bash, tar, gzip, bzip2, xz, and patch.

RootfsClosure

Not a build: assembles a root filesystem as a TreeMerge of base_filesystem with the runtime closure of its inputs — the standard way to turn a set of packages into a usable rootfs.

  • Inputs: one or more packages (the roots of the closure).
  • Config: none.

Filesystem images

Filesystem images are produced by Sandbox recipes that run a packing tool over a materialized _tree input, in a rootfs assembled from the tool’s runtime closure. The result is an ordinary object containing the image file.

EROFS

To pack a filesystem tree into a reproducible EROFS image, use the recipe.erofs_image helper — it lowers to a low-level Sandbox (with an explicit _rootfs: a RootfsClosure of pkgs.erofs_utils and pkgs.bash) that runs mkfs.erofs over the materialized tree:

let qemu_image = recipe.erofs_image pkgs { name = "qemu-image", tree = pkgs.qemu_rootfs }

The reproducibility flags (--sort=path, a fixed -T) and the constant label (bobr-rootfs) live in the os/mk-erofs.sh build script, which also derives the image UUID (a version-8 UUID) from BOBR_BUILD_SEED — the per-build seed the sandbox exports (see Request). Same inputs give the same seed, so the image stays reproducible while distinct rootfs contents get distinct, non-null UUIDs. Being a plain-object Sandbox, the result is a plain-object directory holding a single erofs-rootfs.erofs file, since the image blob has no per-file ownership of its own.

initramfs

The initrd is packed the same way, in linux/initrd.ncl: a Sandbox over the merged initrd tree that runs linux/mk-initramfs.sh and writes a single initramfs.img, so the boot artifact is <object>/initramfs.img.

The archive is written by the kernel’s own gen_init_cpio (built from the kernel source as pkgs.gen_init_cpio), driven by a spec file the script generates from the tree — the two-stage scheme the kernel uses for CONFIG_INITRAMFS_SOURCE. That split is what makes the image reproducible: the spec carries only path, type, permissions, and ownership, and gen_init_cpio synthesizes the rest (inode numbers from a constant, fixed link counts, one SOURCE_DATE_EPOCH timestamp). Running cpio(1) over the tree instead would copy the on-disk inode numbers, link counts, and mtimes — and a materialized fs-tree hardlinks its files from the store’s shared fs-files, so those vary with what else the store holds.

HostBundle

A HostBundle is a verified, relocatable directory containing a host-side application and the userspace it needs. It can run directly from the bobr store or after the complete directory has been copied elsewhere. It does not require bobr, Nix, root privileges, a container runtime, a mount namespace, or a fixed installation prefix on the machine where it runs.

The bundle is not a container and does not provide a virtual /. The process still sees the host kernel, filesystem, home directory, devices, display and audio sockets, network, and system services. HostBundle controls how declared commands enter the copied userspace; it does not isolate those commands from the host.

HostBundle is still a proof of concept, with further development and testing ahead.

Model

A normal dynamically linked ELF names an absolute interpreter in PT_INTERP, for example /lib64/ld-linux-x86-64.so.2. Executing that ELF directly asks the host kernel to open the host path. That fails on systems without a conventional FHS loader path, such as NixOS, and can select a loader from an unrelated userspace on other distributions.

A HostBundle instead starts through a small statically linked musl executable, bobr-bundle-launcher. The launcher finds the bundle, reads the target ELF’s PT_INTERP, resolves that path below the copied payload, and invokes the bundled glibc loader explicitly:

<bundle>/root/lib64/ld-linux-x86-64.so.2 \
    --inhibit-cache \
    --argv0 demo \
    --library-path <bundle>/root/usr/lib64:<bundle>/root/usr/lib \
    <bundle>/root/usr/bin/demo ...

The launcher itself has neither PT_INTERP nor DT_NEEDED, so it can start without using the host C library. The builder proves the declared startup closure using files under root/ before publishing the bundle.

The current implementation supports Linux/glibc payloads for x86_64 and aarch64. A bundle is architecture-specific. It also declares a minimum Linux kernel version, which defaults to 4.19.

Constructing a bundle in Nickel

HostBundle is a real bobr builder, not a synthetic Nickel builder. A recipe normally constructs the payload with RootfsClosure, selects a launcher package for the same architecture, and passes both materialized trees to HostBundle:

fun pkgs =>
  let payload = {
    name = "demo-host-bundle-payload",
    tag = "RootfsClosure",
    config = {},
    inputs = {
      demo = pkgs.demo,
      locales = pkgs.glibc_locales,
    },
  }
  in

  let demo_host_bundle = {
    name = "demo-host-bundle",
    tag = "HostBundle",
    config = {
      arch = "x86_64",
      policy = "strict",
      min_kernel = "4.19",
      library_dirs = ["usr/lib64", "usr/lib"],

      public_tools = {
        demo = {
          path = "usr/bin/demo",
          argument_prefix = [
            { value = "--data-dir" },
            { source = "payload", path = "usr/share/demo" },
          ],
          environment = {},
        },
      },

      internal_tools = {
        helper = {
          path = "usr/libexec/demo/helper",
        },
      },

      environment = {
        LOCALE_ARCHIVE = {
          operation = "replace",
          paths = [
            {
              source = "payload",
              path = "usr/lib/locale/locale-archive",
            },
          ],
        },
      },
    },
    inputs = {
      _root = payload,
      _launcher = pkgs.bobr_bundle_launcher,
    },
  }
  in

  { include [demo_host_bundle] }

RootfsClosure is useful here because HostBundle does not calculate package dependencies. The recipe layer supplies an already assembled runtime tree; the builder copies and verifies it. A narrower manually composed fs-tree is also valid if it contains everything the application needs.

Inputs

The builder accepts exactly these inputs:

  • _root is a required materialized fs-tree. It becomes the payload under root/.
  • _launcher is a required materialized fs-tree containing usr/libexec/bobr-bundle-launcher. Only that regular file is copied from the launcher package.
  • overrides is an optional ordinary directory object. It is copied under overrides/ without first converting it to an fs-tree.

The leading underscores on _root and _launcher use the normal request rule for materialized inputs. overrides deliberately has no underscore: it is a plain directory object. All three inputs are treated as read-only. The builder does not modify a materialized fs-tree, its fs-files, or an overrides object.

The launcher must be a static executable for the configured architecture. A matching launcher should be built or selected explicitly; the builder never compiles one and never substitutes a launcher from the build host.

Top-level configuration

The complete build-time configuration has these fields:

  • arch is required and is either "x86_64" or "aarch64".
  • policy is optional, defaults to "strict", and also accepts "integrated".
  • min_kernel is optional and defaults to "4.19". It must have the form MAJOR.MINOR or MAJOR.MINOR.PATCH, with decimal components.
  • library_dirs is a required ordered array of paths relative to _root.
  • public_tools is a required non-empty record of commands exposed in the top-level bin/.
  • internal_tools is an optional record of commands exposed only through the managed child-process PATH.
  • environment is an optional record of rules applied to every tool.

The top-level config, tool declarations, literal arguments, and environment rules are strict typed records. The builder owns the generated bundle.toml format version; a recipe cannot select or override it.

policy is currently recorded in bundle.toml and diagnostics, but strict and integrated do not yet select different runtime algorithms. Both use the same path validation, loader-sensitive environment filtering, bundled loader, and startup verifier. Recipes must express intentional host integration through explicit environment rules and tool arguments rather than relying on the policy name to permit a fallback.

Tool declarations

Every entry in public_tools and internal_tools has this shape:

demo = {
  path = "usr/bin/tool",
  argv0 = "tool",
  argument_prefix = [
    { value = "--flag" },
    { source = "payload", path = "usr/share/tool" },
    { source = "overrides", path = "tool/config.json" },
  ],
  environment = {},
}

Only path is required:

  • path is relative to _root. It must resolve to an executable regular file inside the copied root/.
  • argv0 is the logical process name. It defaults to the tool’s record key and must be non-empty and contain no NUL.
  • argument_prefix is an optional ordered array inserted before arguments from the caller.
  • environment is an optional record applied after the common environment.

A tool name is a non-empty UTF-8 basename. Empty names, ., .., names containing / or NUL, and the reserved name bobr-bundle-launcher are rejected. The same name cannot appear in both public and internal records.

A fixed argument is one of:

  • { value = "..." }, a literal UTF-8 argument;
  • { source = "payload", path = "..." }, resolved below root/;
  • { source = "overrides", path = "..." }, resolved below overrides/.

Path arguments are canonicalized when the launcher runs and must remain inside the bundle. An overrides path is invalid unless the recipe supplies the overrides input. Fixed arguments precede all caller-provided arguments.

Safe relative paths

Tool paths, library directories, environment paths, and fixed path arguments are UTF-8 paths relative to their declared source. They must be non-empty, must not begin with /, contain NUL, or contain an empty, . or .. component. The builder also canonicalizes paths in the completed directory and rejects symlinks that escape the required root.

Paths used in colon-separated environment values or --library-path cannot contain : after resolution. Spaces and Unicode are supported.

Environment rules

The launcher starts with the inherited host environment, removes every variable whose name begins with LD_, and removes GLIBC_TUNABLES. It then applies common rules followed by the selected tool’s rules. An explicitly configured rule can set a name that was removed; inherited loader-sensitive values never pass through implicitly.

Each rule contains:

VARIABLE = {
  operation = "replace",
  paths = [{ source = "payload", path = "usr/share/example" }],
  values = [],
  inherit = false,
  host_default = [],
}

operation is required. The other fields default to an empty array or false. paths and values are mutually exclusive. Multiple entries are joined with :; scalar variables therefore normally use one value.

The operations are:

  • replace: set the variable to the configured paths or values. At least one entry is required; inherit and host_default are forbidden.
  • prepend: place the configured value before the current value. The current value participates only when inherit = true. If it is absent, host_default is used when provided.
  • append: the same as prepend, but place the configured value after the current value.
  • unset: remove the variable. It accepts no paths, values, inheritance, or defaults.
  • default: leave an existing variable unchanged; otherwise set it from the configured entries, or from host_default when no entries are configured. inherit is forbidden.

For prepend and append, host_default requires inherit = true. Variable names must be non-empty and contain neither = nor NUL.

PATH is reserved in both common and per-tool environment records. The builder always generates this rule:

prepend <bundle>/libexec/wrapped-bin to inherited PATH

This fixed rule keeps managed wrappers ahead of host commands. A recipe cannot replace it accidentally.

Output layout

The result is one ordinary directory object:

<bundle>/
├── bin/
│   └── demo -> ../libexec/bobr-bundle-launcher
├── libexec/
│   ├── bobr-bundle-launcher
│   └── wrapped-bin/
│       ├── demo   -> ../bobr-bundle-launcher
│       └── helper -> ../bobr-bundle-launcher
├── bundle.toml
├── overrides/                 # present only when supplied
└── root/
    └── ...                    # independent copy of _root

bin/ contains one symlink for every public tool and no internal-only tools. libexec/wrapped-bin/ contains every public and internal tool. The physical payload files retain their original names and locations below root/; the builder does not append .real, rewrite ELF files, patch shebangs, or modify configuration files in the payload.

The generated bundle.toml is the runtime representation consumed by the launcher. Its current format identifier is bobr-host-bundle-v2. It contains bundle-relative paths after the builder has lowered the recipe’s payload and overrides namespaces. It is an internal producer/consumer format, not a file that users are expected to author or edit.

Builder execution

The HostBundle builder performs these phases in order.

1. Validate and lower the declaration

The builder validates the typed config before touching the output tree. It maps payload paths to root/..., overrides paths to overrides/..., inserts the mandatory PATH rule, combines public and internal tools, and constructs the same typed runtime model used by the launcher.

The target architecture is configuration, not a property of the build host. The builder parses target files but does not execute them, so an AArch64 host can compose an x86-64 bundle and an x86-64 host can compose an AArch64 bundle when matching input trees already exist.

2. Copy inputs independently

The builder creates a new staging directory and recursively copies _root, the single launcher file, and optional overrides. It recreates symlinks without following them, visits directory entries deterministically, preserves the copied mode initially, and normalizes ownership to the owner of the output object. Sockets, devices, FIFOs, and other special files are rejected.

Regular files are copied into new inodes. The result has no hardlinks to materialized fs-trees, fs-files, launcher inputs, or overrides inputs. Hardlinks within an input are not currently preserved: each path becomes an independent file in the output.

3. Generate the facade

The builder creates the public and internal launcher symlinks and serializes bundle.toml. Every public tool also receives an internal wrapper, allowing a program to invoke itself or another public command by name through the managed PATH.

4. Verify structure and startup closure

Structural verification checks the launcher, runtime config, wrapper targets, tool paths, library directories, and configured environment paths in the staged directory. Canonical paths must remain inside the expected bundle or payload root.

The startup verifier then walks every public and internal executable without running target code. A failure aborts the builder; an unverified HostBundle is never published as a successful result.

5. Finalize read-only permissions

After every generated file and verification pass is complete, the builder removes all write, setuid, and setgid bits from every non-symlink path. Read and execute bits are otherwise preserved. It walks the result again to verify this invariant before returning the ordinary directory object.

Read-only modes prevent accidental writes by normal execution. They are not a security boundary against the owner of the object, who can deliberately change permissions outside the normal store workflow.

Startup verification

The verifier proves the initial ELF and script dependency graph using only the staged bundle. It never asks the host dynamic loader, ld.so.cache, /lib, or /usr/lib to resolve a dependency.

ELF executables and libraries

All inspected ELF files must be 64-bit, little-endian, and have the e_machine value selected by arch. Configured tools may be static or dynamic.

For a dynamic executable the verifier:

  • reads its absolute PT_INTERP and resolves the corresponding executable below root/;
  • requires the bundled loader to have neither its own PT_INTERP nor DT_NEEDED;
  • reads DT_NEEDED, DT_RPATH, DT_RUNPATH, SONAME, GNU version needs, and GNU version definitions;
  • resolves the complete transitive startup closure;
  • rejects a DT_NEEDED containing /;
  • checks a dependency’s SONAME when it declares one;
  • verifies that each required symbol version is provided by the selected dependency.

library_dirs is the ordered equivalent of the launcher’s explicit --library-path. Each entry must exist as a directory inside root/. The array may be empty only when the complete configured startup closure is static.

The modeled search order follows glibc’s relevant startup rules:

  1. an object’s DT_RPATH, when that object has no DT_RUNPATH, followed by inherited RPATH entries;
  2. configured library_dirs;
  3. that object’s DT_RUNPATH.

The verifier accepts only $ORIGIN and ${ORIGIN} entries whose result stays inside the payload. Absolute paths, plain relative entries, $LIB, $PLATFORM, other substitutions, and unsafe combinations are rejected. The launcher passes --inhibit-cache, and the verified startup closure ensures the loader does not need to reach its standard host fallback directories for the declared initial dependencies.

This proof covers startup DT_NEEDED, not every later dlopen or application plugin lookup. Those require explicit recipe configuration and runtime tests.

Scripts

A configured script must have a safe absolute shebang interpreter. The path is resolved below root/, must be an executable regular file, and is verified recursively. The launcher supports at most four script nodes in one dispatch chain, matching the builder’s limit.

The verifier specially accepts:

#!/usr/bin/env command

only when command is one public or internal tool in the same bundle. The argument must be exactly one command name: options, paths, whitespace, and env -S are currently rejected. At runtime the bundled env finds the managed wrapper first in PATH.

Other shebang interpreters may receive the single optional argument represented by the kernel shebang format. The interpreter and any recursive interpreter are entered using the same static-or-bundled-loader dispatch as a normal tool.

Launcher execution

The real launcher must be at <bundle>/libexec/bobr-bundle-launcher. It resolves /proc/self/exe, verifies that filename and parent directory, and derives the bundle root by moving one directory upward. Consequently normal operation requires a mounted procfs. The algorithm does not depend on the caller’s working directory or on the symlink path used to enter the launcher.

Invocation

Running a facade symlink selects a tool by the basename of argv[0]:

./bin/demo argument

The real launcher also has an explicit interface:

./libexec/bobr-bundle-launcher --run demo -- argument
./libexec/bobr-bundle-launcher --diagnose demo
./libexec/bobr-bundle-launcher --diagnose demo --json

The -- separator is required for --run. Payload arguments are preserved as opaque Unix strings and follow the configured fixed argument prefix.

Before execution the launcher checks that the running OS is Linux, its compiled architecture matches the bundle, and uname(2) reports at least min_kernel. Normal execution fails on a mismatch. Diagnostic mode reports the mismatch but continues preparing the report.

Static, dynamic, and script dispatch

A static ELF, including a static position-independent executable without PT_INTERP, is passed directly to execve.

For a dynamic ELF the launcher canonicalizes the payload interpreter and library directories, verifies their containment and architecture, and replaces itself with the bundled glibc loader. The loader receives, in order:

  • --inhibit-cache;
  • --argv0 and the configured logical name;
  • --library-path and the absolute resolved library directories;
  • the physical target under root/;
  • fixed arguments, then caller arguments.

Scripts are reduced recursively to a final static or dynamic interpreter plan. In every successful case the launcher replaces itself rather than supervising a child process. Signals, wait status, and exit status therefore belong directly to the payload program or bundled loader.

Public commands and child processes

The separate bin/ and libexec/wrapped-bin/ directories serve different interfaces:

  • bin/ is the public interface users add to PATH or invoke directly;
  • wrapped-bin/ is the private interface inherited by bundled processes.

If demo runs execvp("helper", ...), invokes helper through a shell, or uses /usr/bin/env helper, wrapped-bin/helper enters the launcher again. The helper therefore receives the same bundled loader and environment policy.

Putting internal wrappers in the public bin/ would expose implementation details and could shadow host commands for users of the bundle. Conversely, putting only public tools in the managed path would let internal helpers bypass the launcher.

Wrappers cannot intercept a literal execve("/usr/bin/helper", ...) or a program started by its physical path below root/. Such calls follow normal host absolute-path semantics or ask the host kernel to interpret the payload ELF. A required child must be declared as a tool and invoked by name, or the package must be configured to use the wrapper-compatible path.

Overrides and application data

root/ is intentionally an unchanged copy of the supplied rootfs structure. When an application needs bundle-location-specific configuration, the recipe should create an ordinary directory object and pass it as overrides.

For example, a bundle can place a generated fontconfig file at overrides/fontconfig/fonts.conf and set FONTCONFIG_FILE with an overrides environment path. QEMU disk, kernel, and initramfs files can similarly live under overrides/qemu/ and be selected through typed path variables. This keeps adaptations explicit without patching the package payload.

The builder does not generate arbitrary application configuration and does not infer plugin or data paths. The recipe produces those files and declares how a tool finds them.

Diagnostics

--diagnose resolves the same config, paths, environment, executable format, and launch plan as normal execution, but does not call execve. Human output is stable line-oriented text; --json emits a structured report.

The report includes:

  • bundle and payload roots;
  • configured policy;
  • required architecture and minimum kernel, the host kernel, and compatibility;
  • tool name, visibility, target, and resolved fixed arguments;
  • ELF or script format, static/dynamic linkage, PT_INTERP, selected loader, and library path;
  • the final environment and whether each value came from the host, a common rule, or a per-tool rule.

Diagnostics describe the prepared launch. They do not execute an application probe, enumerate the build-time DT_NEEDED graph, or validate devices and session sockets.

Running and copying

The object can be invoked directly from a store ref or hash:

<store>/object-refs/demo-host-bundle/bin/demo
<store>/objects/<hash>/bin/demo

No export is needed for use on the same machine. To move a bundle, copy the complete directory while preserving symlinks and executable modes, for example:

cp -a <store>/objects/<hash> /destination/demo-host-bundle

The copied directory may be renamed and moved again. Do not copy only bin/: the facade, launcher, configuration, payload, and optional overrides form one artifact. The destination filesystem must support ordinary Unix executable files and symlinks.

Adding <bundle>/bin to a shell PATH is sufficient to expose all public commands. Library and plugin variables should not be exported into the shell; the launcher applies them separately to each bundled process.

Portability boundary and current limits

HostBundle provides a verified route into a selected userspace, not universal FHS virtualization. A compatible host currently means:

  • Linux on the declared x86_64 or aarch64 architecture;
  • a kernel at least as new as min_kernel;
  • a CPU capable of running the payload’s chosen instruction baseline;
  • procfs mounted at /proc;
  • access to any devices, sockets, services, and files required by the application.

The launcher checks architecture and kernel version. It does not probe the CPU baseline or declaratively check /dev/kvm, DRM, Wayland, PipeWire, D-Bus, or other application-specific host resources.

The process sees host absolute paths. Bundled glibc may read host /etc, NSS configuration, timezone data, certificates, and other system state. In particular, NSS setups involving host modules built for another glibc need application-specific testing.

The build-time proof covers declared tools, script interpreters, and startup ELF dependencies. It does not yet provide general audits for dlopen, Vulkan ICD JSON, Mesa driver registries, QEMU firmware/modules, or arbitrary embedded data and executable paths. Recipes must select those resources explicitly and test the resulting application behavior.

The current verifier supports only $ORIGIN dynamic string expansion and does not model $LIB, $PLATFORM, or glibc-hwcaps selection. $LIB and $PLATFORM entries are rejected; glibc-hwcaps directories are not audited and current bundles must not rely on them. env -S, an SDK profile with a broader typed PATH, automatic export archives, internal hardlink preservation, and additional filesystem metadata are also outside the current implementation.

These limits define the scope of the proof. Unsupported structured startup constructs such as $LIB are rejected. Mechanisms outside the current startup model, including later dlopen and glibc-hwcaps directory selection, are not certified by a successful HostBundle build and require explicit recipe controls and runtime tests.

Store

Summary

The store contains immutable payloads, user-facing object metadata, the BuildKey → ObjectHash and ReuseKey → ObjectHash reuse mappings, and mutable refs from a name to its object. Run logs and scratch directories are configured separately and conventionally placed under the store. It is a content-addressed store (CAS): payload identity is derived from normalized content, not from the path or name used to reach it.

The store root is the directory named by the request’s store field — an absolute path to an existing directory, used as-is (bobr adds no implicit .bobr/ layer).

For the mental model behind objects, keys, and CAS, see Concepts. This document is the precise reference for the store’s identity model, reuse rules, and on-disk layout.

Identity Model

Concepts introduces the three identities (object_hash, build_key, reuse_key); this section is their normative definition.

Every recipe has a build_key.

For Source, build_key is the declared object_hash reinterpreted as a build key. Source recipes have no inputs.

For a builder recipe, build_key is computed from:

  • builder tag
  • builder implementation-version token
  • canonical config digest, after typed parsing and defaults
  • direct dependency build_keys keyed by input name

The version token includes the common build-semantics version and the builder’s implementation version; architecture-dependent builders also include the build architecture. It is not simply the Bobr release version.

The fingerprint includes both each input name and its hash, in lexical input-name order, independent of JSON field order. This is distinct from the builder’s input consumption order. The key is SHA-256 of the canonical invocation fingerprint.

build_key identifies a recipe; the store maps it to the object the recipe produced (builds/<build_key>). For a builder recipe, a hit on that mapping can be used before looking through the recipe’s direct inputs.

Once the identities of a builder’s direct input objects are known, its reuse_key is computed from:

  • builder tag
  • the same implementation-version token
  • the same canonical config digest
  • direct dependency object_hash values keyed by input name

The same lexical input-name order is used. Build and reuse fingerprints use different invocation schemas to separate the two key domains. Determining an input object’s identity does not require its content to be present locally.

reuse_key is independent from the particular dependency invocations that produced those input objects. Different build graph fragments can therefore reuse a stored result when their direct input payload identities match.

Executing a builder or materializing a source produces one payload object. The payload is addressed by object_hash.

The same object_hash also keys a user-facing object metadata record for that payload. Different builder recipes can share one record when they intentionally stage the same payload. The record is not part of object identity or cache resolution.

Recipe names do not participate in object identity, build_key, or reuse_key; input slot names do.

Reuse Model

For a builder recipe, the working-store lookup starts with an exact build_key hit in builds/. A complete exact hit skips everything below it. On an exact miss, bobr resolves possible object_hash identities of the direct inputs and computes the corresponding reuse_key values. It then checks those keys in reuses/. This identity resolution may happen before input content has been copied into the working store. A mapping supplies a candidate hash, not a guarantee of available content. If no candidate can be reused, bobr realizes complete direct inputs, checks reuse again with their actual hashes, and executes the builder if no reusable result is available.

If a reuse hit provides an object but the current build_key mapping is missing, bobr publishes that mapping and reuses the object. A builder can also publish every reuse key in the current candidate set that resolves to the chosen object.

Local builds/ and reuses/ each hold one current object per key. A key does not prove that a builder is deterministic: publishing a different result for the same key replaces that mapping, while the previous payload remains in the store.

For Source, there is a build_key but no reuse_key.

Source realization asks whether its declared object_hash is complete in the working store, then whether configured content sources can provide it. If not, the Source origin is materialized. Object records are not read during this lookup. On a source hit or successful materialization, bobr writes user-facing metadata and creates or repairs the source’s builds/<object_hash> mapping.

If source materialization produces a different object than the declared object_hash, the actual object is still imported into objects/, but no object metadata or source builds/<object_hash> mapping is written, and the source import fails with the actual hash.

Complete content

An ordinary object is complete when its payload is present as a file or directory in objects/. An fs-tree additionally requires every fs-file referenced by its canonical manifest. Neither an object record nor an already materialized fs-trees/ cache entry is required for completeness.

Working-store completeness checks inspect presence and file types and parse manifests; they do not rehash all payloads on every cache hit. Store content must remain immutable. Secondary transfers verify the expected object and fs-file identities before publishing a complete result.

Secondary provider capabilities

A request can additionally name local or remote backends (see Request). It exposes each backend through one or both independent capabilities:

  • a mapping provider answers BuildKey and ReuseKey queries with candidate ObjectHash values; it supplies identity, not object bytes;
  • a content provider supplies an object’s bytes by ObjectHash. Local providers can hardlink objects and every referenced fs-file, or copy both ordinary objects and complete fs-tree closures into independent working-store inodes. A hardlink import shares regular-file inodes while recreating directory containers and symlinks; a copy import shares no regular inode, even when both stores are on one filesystem. Both transports verify the expected object or fs-file identity and preserve the logical metadata that participates in it before atomic publication. Hardlink repositories require the repository and working-store objects/ directories to share a filesystem, as must their fs-files/ directories; bobr validates the two pairs independently before scheduling realization and never falls back to copy. Remote providers download and verify ordinary objects or complete fs-tree closures, fetching missing fs-files before publishing the imported result in the working store.

Configuration enables the mapping and content-source capabilities explicitly; neither implies the other. Profile syntax may combine both capabilities in one entry, but request lowering separates them again.

The Realizer consults working-store mappings before repository mappings, and checks working-store content before secondary content. Providers are consulted in their declared order within each capability. A hash learned from a mapping provider can be supplied by a different content provider. Mapping lookup never opens object records.

The remote protocol, signed metadata, wire representations, and publication workflow are specified in Remote repositories.

An imported result is complete working-store content, not a borrowed path into the repository. With copy transport this follows from independent inodes. With hardlink transport the repository and working store have independent directory entries naming shared immutable inodes, so unlinking the repository names does not remove the working names. A subsequent offline request may omit the repository entirely. Local store layouts and provider settings are validated at startup even when every requested result is already local; remote metadata is fetched lazily when a provider is consulted.

Store Layout

The filesystem layout mirrors the identity model:

<store>/
  objects/
    <object_hash>
  reuses/
    <reuse_key> -> ../objects/<object_hash>
  builds/
    <build_key> -> ../objects/<object_hash>
  object-records/
    <object_hash>.json
  object-refs/
    <name> -> ../objects/<object_hash>
  fs-files/
    <first-two-hex-digits>/
      <fs-file-hash>
  fs-trees/
    <manifest-object-hash>/
  fs-tree-refs/
    <name> -> ../fs-trees/<manifest-object-hash>

A run’s log and work directories are not part of this layout: the request names them, and the caller creates them (see Request). By convention they live under the store — <store>/logs/<run-id> and <store>/work/<run-id>, which is what bobr-build.sh does by default — and the work directory has to be on the store’s filesystem, since build output is published out of it by renaming and hardlinking. Their contents are:

<logs>/
  context.json
  recipe-catalog.json
  events.jsonl
  index.jsonl
  <00000000>-<tag>[-<name>]/
    meta.json
    events.jsonl
    raw/
<work>/
  <00000000>-<tag>[-<name>]/
  • objects/ holds payloads addressed by object_hash.
  • object-records/ holds user-facing metadata records addressed by object_hash.
  • reuses/ maps a reuse_key to its object (builder recipes only).
  • builds/ maps a build_key to its object.
  • object-refs/ holds human-facing refs from recipe name to the latest successful object for that name.
  • fs-files/ holds regular-file payloads referenced by fs-tree manifest objects. Each file already carries its logical ownership and mode in the build namespace; materialization preserves them through hardlinks. Its hash covers both metadata and bytes; see Filesystem trees.
  • fs-trees/ caches materialized filesystem roots for fs-tree manifest objects.
  • fs-tree-refs/ holds human-facing refs from recipe name to the latest materialized filesystem root for that name.

objects/<object_hash> is the payload itself, either a file or a directory. Concrete directory payload formats are builder-specific. For example, the OCI registry source handler realizes imported images as OCI image layout directories.

Filesystem tree builder results store a canonical fs-tree manifest text file as the object payload. Regular file entries in that manifest reference payloads stored under fs-files/. Materialized roots under fs-trees/ are cache entries created on demand for builders that need a filesystem root path; a fresh build output can also populate this cache while its files are interned. When a named builder input asks for a filesystem root, the runtime also updates fs-tree-refs/<name> -> ../fs-trees/<manifest-object-hash>. These refs are for inspection only. Runtime lookup uses object identity and the fs-trees/ cache directly; it does not read fs-tree-refs/. If the same name is later materialized from a different manifest object, the old current ref is rotated into an mtime-suffixed generation ref before the new current ref is installed.

Remote metadata has a separate auxiliary cache, defaulting to <store>/repository-cache and configurable as secondaries.repository_cache. This is not CAS payload storage: successfully imported objects and fs-files live in the normal directories above. See Request for configuration and Repository cache for its shared client/utility use.

Generic CAS objects may contain non-UTF-8 filesystem names. Such objects can still be imported and addressed by object_hash. Fs-tree objects are UTF-8-only because their manifest paths and symlink targets are JSON strings.

object-records/<object_hash>.json is user-facing metadata for people and store-inspection tools. Its current schema is bobr-object-record-v4; it contains:

  • object_hash — the object the record describes
  • build_key and inputs — producer information supplied by the first successful writer of this record
  • run_id — optional; the store run that recorded it

The record is idempotent: an existing record is retained rather than rewritten. Consequently its producer fields can be neutral metadata written while recording an already-present or imported object; they are not authoritative provenance for every mapping that later reaches this object.

builds/<build_key> and reuses/<reuse_key> are canonical symlinks whose targets encode an object_hash as ../objects/<object_hash>. Mapping lookup validates and reads the hash from the symlink text without following its target or inspecting object content; content is checked separately when realization needs it. Publication creates these mappings only after the target object is complete in the store. Object records remain independent inspection metadata, just as object-refs/ and fs-tree-refs/ are human-facing views rather than lookup inputs.

<logs>/<serial>-<tag>[-<name>]/raw/ stores raw per-subject log files such as captured tool output. <work>/<serial>-<tag>[-<name>]/ is the matching per-subject scratch directory. Scratch directories are removed after execution on a best-effort basis; cleanup failures are logged as warnings and the scratch directory is left in place.

Object Refs

Every recipe carries a name. The name enters no identity computation — not object_hash, build_key, or reuse_key; it is used for the refs described here and for diagnostics and logs.

When a named recipe successfully resolves to an object, the current object ref is updated:

  • object-refs/<name> -> ../objects/<object_hash>

This object-refs/ rule is the same for every object kind. Filesystem tree builder results store the manifest itself as the object payload. The object ref never points directly at fs-files/ or at a materialized fs-trees/ cache directory. When an object has been successfully published, its user-facing metadata is normally available as object-records/<object_hash>.json; object-ref lookup does not depend on that metadata.

Both object-refs/ and fs-tree-refs/ are inspection aids, not cache lookup inputs. The latter are created when a filesystem root is materialized for a named input.

If the current object ref already points at a different object, the old current ref is rotated into a timestamp-suffixed history ref.

Local store lifecycle

Updating a mapping or name ref does not delete the previous object or its fs-files. Bobr currently has no local store garbage collector: old payloads, records, and history refs remain until explicitly removed. Immutable shared files must not be modified in place. Remote repository retention and garbage collection are separate mechanisms described in Repository CLI.

Logging

Each run writes:

  • one run-level structured event log under <logs>/events.jsonl
  • one workspace index under <logs>/index.jsonl
  • per-subject logs under <logs>/<00000000>-<tag>[-<name>]/

When the standard bobr-recipes/bin/bobr-build.sh driver starts the run, it also writes two inspection sidecars at the run-log root:

  • context.json records the ordered goal recipe names, run outcome, bobr --build-info, and the recipes checkout commit and dirty state when Git provenance is available;
  • recipe-catalog.json contains only the reachable recipe names and builder tags needed by store-inspection tools. It deliberately omits builder configs, origins, provider configuration, and credentials.

The driver creates no sidecars for a dry run. It records running before invoking Bobr and atomically replaces that state with success or failed after Bobr returns, so an interrupted process leaves an identifiable incomplete run. These files are diagnostic metadata, not inputs to cache lookup or object identity.

The run id comes from the request; bobr-build.sh derives it from the local <YYMMDDhhmmss> timestamp and appends .1, .2, and so on when a directory of that name is already taken. A subject with an allocated workspace receives a serial number for its log directory name. The serial is an internal allocation detail; the full original tag, recipe name, subject key, and workspace paths are stored in that subject’s meta.json. Recipe subjects use build keys; for Source, that is the declared object hash. Repository metadata and content transfers use their own activity identifiers, not necessarily build keys.

The run-level event log is the full audit log of the run. Subjects with dedicated logs also write their events to their own events.jsonl; events without a bound subject workspace, including repository transfers, appear only in the run-level log. Raw logs created by builders are written under the subject’s raw/ directory. The event record format, the closed status vocabulary, and the format guarantees are specified in Build logging.

Remote repositories

A Bobr remote repository is a public, content-addressed cache of build and reuse results. Its immutable objects can be served by an S3-compatible object store or CDN, while a small signed master authenticates the current repository state. Clients verify mappings, metadata, ordinary objects, and filesystem files before importing them into a working store.

To use a repository during builds, configure providers in a Nickel build profile. The normalized JSON configuration is specified in Request: secondary providers.

The repository protocol and its administration are documented in this section:

  • bobr-repo command-line utility describes preparation from a complete local store, signing on a separate trusted machine, publication, status reporting, and garbage collection.
  • Self-hosting with VersityGW shows how to run an S3-compatible endpoint, configure HTTPS and anonymous object reads, and generate the S3, TLS, and repository-signing credentials.
  • Repository master specifies the authenticated root, immutable mapping indexes and content lists, slots, retention, and the reader algorithm.
  • Ordinary objects specifies the streaming envelope under o/.
  • Filesystem files specifies the streaming envelope under f/.
  • Directory tar profile specifies the restricted canonical tar representation used for directory objects.

The normative CDDL schemas are:

The bobr-repo Rust crate owns these formats and the mechanisms shared by the Bobr client and repository administration utility.

bobr-repo command-line utility

This document specifies the design of the administrative bobr-repo command. The command publishes a complete local Bobr store to an S3-compatible remote repository, signs and commits repository masters, reports repository state, and performs garbage collection.

The remote wire format is specified separately in MASTER.md, OBJECT.md, FS_FILE.md, and TAR.md. The command uses the same library implementation as the Bobr remote-repository client.

Scope

bobr-repo has five subcommands:

bobr-repo init
bobr-repo prepare
bobr-repo publish
bobr-repo status
bobr-repo gc

init creates and configures a dedicated S3 bucket. The first signed master still creates the repository’s logical state. There is no fixed slot-count configuration and no general reconfiguration command. Slots are added and rotated explicitly by prepare.

The utility does not create signing keys. It reads an unencrypted Ed25519 PKCS#8 private key in PEM or DER form, generated by a general-purpose key-management tool such as OpenSSL. The key file must be a regular file with no group or other permission bits. Encrypted PKCS#8 and hardware-backed keys are outside the first implementation. The corresponding public keys use the standard SubjectPublicKeyInfo PEM or DER form and are supplied as trust anchors where a current master must be authenticated.

For example:

openssl genpkey -algorithm Ed25519 -out repository-key.pem
chmod 600 repository-key.pem
openssl pkey -in repository-key.pem -pubout -out repository-key.pub.pem

Deployment and credentials

A normal deployment uses two machines and two independently scoped S3 identities.

The build server runs prepare. Its S3 identity may read the repository and write immutable keys under:

b/
r/
lo/
lf/
o/
f/

It must not be able to replace /master. The build server does not possess the repository signing key.

A trusted workstation runs publish. It holds the Ed25519 private key and an S3 identity that may read the repository and conditionally create or replace only /master. It does not need permission to write or delete immutable data.

An administrative S3 identity used for gc may list and delete immutable keys, but does not need the signing key or permission to replace /master.

An S3 administrator runs init. This identity may create the dedicated bucket and replace its complete bucket policy and bucket-level Public Access Block configuration. It is more privileged than any steady-state publication identity and need not be present on the build server.

Ordinary Bobr clients use anonymous HTTPS and pinned public keys. They have no S3 credentials and no repository secrets.

The separation prevents compromise of the build server from disclosing the long-lived signing key or directly publishing a new repository state. It does not independently establish that software built by a compromised server has the intended semantics; doing that would require an independent rebuild or a separate attestation system.

Repository location

Commands which use the S3 API accept the repository storage prefix as an S3 URI:

--repository s3://bucket/optional-prefix

The mutable master is stored at master below this prefix. All immutable keys use the relative paths defined by MASTER.md.

init is the exception: because it owns the complete bucket policy, it accepts only a bucket root in the form s3://bucket. It rejects a repository prefix.

S3 endpoint, region, credentials, and session tokens come from the standard AWS credential and configuration chain. This permits AWS S3 and other S3-compatible services without a Bobr-specific credentials file. A non-AWS deployment can, for example, set AWS_ENDPOINT_URL_S3 and AWS_REGION in addition to its credentials.

Every command also accepts an optional repository-local CA bundle:

--ca-bundle CA-BUNDLE.pem

The file may contain one or more PEM certificates. They are added to the standard trusted roots rather than replacing them. The same additional roots are used for authenticated S3 requests and anonymous reads through the public master and data URLs. This allows one invocation to use a private-CA S3 endpoint and a publicly certified CDN, or to trust different private CAs listed in the same bundle. Hostname and IP Subject Alternative Name checks remain enabled.

The bundle is a local transport setting. It is not embedded in /master, is not part of repository identity, and is not stored in the metadata cache. Malformed, empty, or unreadable bundles fail before the first network request.

init

init prepares a dedicated bucket for a Bobr repository:

bobr-repo init \
    --repository s3://BUCKET \
    [--ca-bundle CA-BUNDLE.pem]

The command is idempotent. It creates the bucket when it is absent, then owns and converges these two bucket-level settings:

  • Public Access Block has BlockPublicAcls = true, IgnorePublicAcls = true, BlockPublicPolicy = false, and RestrictPublicBuckets = false;
  • the complete bucket policy grants anonymous s3:GetObject only for master, b/*, r/*, lo/*, lf/*, o/*, and f/*.

It grants neither anonymous bucket listing nor anonymous writes. Because the policy is replaced rather than merged, the bucket must be dedicated to one Bobr repository. init does not alter account-level Public Access Block.

Some S3-compatible implementations do not implement bucket-level Public Access Block. A recognized NotImplemented response is reported and does not prevent installation of the explicit bucket policy. Other failures are fatal. A missing bucket is distinguished from an inaccessible bucket: an access denial is never treated as permission to create a replacement.

If bucket creation succeeds but later configuration fails, the bucket is not deleted. A newly created S3 bucket is private at this point, and rerunning init repairs the incomplete configuration. The command does not create a master, a slot, repository content, or signing keys.

Public data location

The public immutable-data URL is a separate value:

--data-base-url https://data.example.org/bobr/

It is embedded in the signed master. It may refer to the bucket’s public HTTPS endpoint or to a CDN or custom domain. Its relative key layout must expose the same immutable objects as the S3 repository prefix.

On the first preparation --data-base-url is required. On subsequent preparations it is recovered from the verified current master. If explicitly provided again, it must match exactly.

Trust anchors and key identifiers

Commands which authenticate an existing master accept one or more pinned Ed25519 public keys. Public-key files are not secret and may be installed on both the build server and the trusted workstation.

The utility derives the COSE kid as the complete SHA-256 digest of the raw 32-byte Ed25519 public key. The publisher derives it from the signing key and a reader derives it from each configured public key. Users therefore do not configure a second independent identifier.

The master remains the trust root. A public key included in repository data is never trusted merely because the repository supplied it.

The local store as publication input

prepare publishes the content and mappings already present in the specified local Bobr store. It does not build recipes or fill in missing parts of their dependency graph.

The publisher includes:

  • every canonical entry under objects/;
  • every canonical entry under fs-files/;
  • every valid build mapping under builds/;
  • every valid reuse mapping under reuses/.

It excludes materialized fs-trees/, logs, object references, filesystem-tree references, object records, temporary files, and other derived or user-convenience state. An fs-tree manifest is included because it is an ordinary object under objects/; its separately materialized tree is not.

bobr-repo does not accept recipe goals or traverse the recipe DAG to select publication content. Use a fresh store to limit publication to the intended build. An ordinary world request does not make every reachable recipe a goal: an exact hit on a high-level target can skip its dependencies. To rebuild the full graph, use a fresh store with content-only providers or no secondaries; this is a build configuration choice, not automatic behavior of prepare.

Before upload, the publisher validates the store layout, every mapping target, every ordinary-object identity, every fs-tree manifest, and the complete set of referenced filesystem files. It constructs an independently closed SlotContents value from that store.

Content encoding and upload

Ordinary objects and filesystem files are encoded using their repository envelopes. For each value, the publisher builds a Zstandard representation in a temporary file and compares its complete encoded size with the identity representation. It stores Zstandard only when it is smaller; otherwise it stores identity encoding.

Encoding and upload are streaming operations. Input or encoded content is not loaded into memory in proportion to its size.

The command encodes content in bounded temporary batches and overlaps a bounded number of S3 uploads. It never prepares the encoded form of the entire store in temporary storage at once.

Repository keys are immutable. The publisher does not replace an existing o/<ObjectHash>, f/<FsFileHash>, or immutable metadata key. A missing value is uploaded with a conditional create operation. A successful retry reuses an already present key. S3 single-object and multipart uploads must be completed before the value is considered available; incomplete multipart uploads are not repository objects.

The publisher applies the HTTP representation metadata required by the wire format, including immutable cache policy and the exact media type.

prepare

prepare runs on the build server. Its conceptual interface is:

bobr-repo prepare \
    --store STORE \
    --repository s3://BUCKET/PREFIX \
    --master-url https://repo.example.org/master \
    --trusted-key PUBLIC-KEY ... \
    [--data-base-url HTTPS-URL] \
    [--cache DIR] \
    [--ca-bundle CA-BUNDLE.pem] \
    [--quiet] \
    [--append | --add-slot | --rotate] \
    [--retention DURATION] \
    --output candidate-master.cbor

--append is the default for an existing repository. The three slot actions are mutually exclusive.

--master-url is the stable public URL by which clients identify the repository. prepare still reads the authoritative master directly through the S3 API; the URL selects the same RepositoryCache namespace that clients and status use. --cache enables that persistent shared cache. Without it, prepare uses a temporary cache.

Empty repository

If /master is absent, the default operation creates one current slot with serial = 1, populated exactly from the input store. The payload has previous_master_hash = null. --data-base-url is required. --add-slot and --rotate are rejected because there is no existing slot to modify.

Append

Append replaces the active current state with the union of its contents and the input store:

  1. The old active state becomes retained until the retention deadline.
  2. Its mappings and content sets are merged with the incoming store.
  3. Incoming mapping candidates take priority over older candidates for the same build or reuse key.
  4. The merged replacement receives serial = max(serial) + 1 and becomes the active current state.

The number of current slots does not change.

Before uploading content or allocating a serial, prepare --append encodes the merged active state and compares its build-index, reuse-index, ordinary-object-list, and filesystem-file-list hashes with the authenticated active slot. If all four hashes match and no expired retained state needs to be pruned, the operation is unchanged: it performs no S3 writes, allocates no serial, and leaves the candidate file untouched.

An expired retained state is a logical change even when the active slot is unchanged. In that case prepare produces a prune-only candidate: it removes the expired state while preserving the active slot and its serial. All immutable values referenced by this candidate already exist, so no content or metadata upload is needed.

Add slot

--add-slot appends a fresh current state containing exactly the input store. No existing current state is retired. The old active state remains current and becomes sealed because the new state has the greatest serial. The number of current slots increases by one.

Rotate

--rotate retires the current state with the smallest serial and appends a fresh current state containing exactly the input store. Other current states remain sealed. The number of current slots does not change.

Retention

The default retention duration is 24 hours. --retention overrides it for one candidate. Append applies the deadline to the replaced active state; rotation applies it to the retired oldest current state. Add-slot retires nothing and rejects an explicitly supplied retention duration.

At the start of every preparation, retained entries whose retain_until deadline has passed are omitted from the candidate. They remain live in the authoritative repository until that candidate is signed and published.

Preparation result

prepare reports exactly one compact JSON value on standard output:

{"result":"candidate"}

or:

{"result":"unchanged"}

Both are successful results. Human-readable progress remains on standard error. --quiet suppresses the live progress display without changing the JSON result or error diagnostics. unchanged is possible only for append to an existing repository; initial publication, add-slot, rotate, and prune-only transitions always return candidate.

For a changed slot, prepare uploads all missing content and its four immutable metadata values before writing the output file. It never writes /master. A prune-only transition needs no immutable uploads.

For candidate, the output file is the exact deterministic-CBOR master payload defined by master-payload.cddl, without a COSE envelope or signature. There is no separate candidate wire format. Its previous_master_hash is:

  • null for the first publication;
  • otherwise SHA-256 of the exact tagged COSE bytes fetched as the current /master during preparation.

The candidate contains no secret. It can be copied to the trusted workstation over an ordinary authenticated transport such as SSH.

The candidate is written to a temporary file in the destination directory, synced, and atomically renamed over the requested path only after every prior step has succeeded. An error or unchanged result leaves any existing file at that path byte-for-byte untouched.

Running prepare again is safe. It may reuse immutable values uploaded by an interrupted or superseded preparation. A newer changed preparation from the same current master supersedes an older candidate; only one of them can pass the predecessor check after either is published.

publish

publish runs on the trusted workstation:

bobr-repo publish \
    --candidate candidate-master.cbor \
    --repository s3://BUCKET/PREFIX \
    --signing-key PRIVATE-KEY \
    [--trusted-key PUBLIC-KEY ...] \
    [--ca-bundle CA-BUNDLE.pem] \
    [--yes]

The command performs these steps in order:

  1. Read the candidate under the master format size limit.
  2. Require its exact deterministic-CBOR representation and validate all master payload constraints.
  3. Fetch /master directly through the S3 API, bypassing public HTTP caches.
  4. For an existing repository, authenticate the current COSE master and require the SHA-256 of its exact bytes to equal previous_master_hash.
  5. For the first publication, require previous_master_hash = null and require /master not to exist.
  6. Display a structured comparison of current and candidate slot states, including data_base_url, added states, newly retained states, pruned states, serials, and deadlines.
  7. Obtain explicit user confirmation unless --yes was supplied.
  8. Derive the protected COSE kid, sign the exact candidate payload with Ed25519, and encode the tagged deterministic COSE_Sign1 master.
  9. Conditionally create or replace /master using the S3 entity validator obtained in step 3. Fail if the object changed in the meantime.
  10. Read the stored object back and require it to be byte-identical to the signed value.

The command does not download and independently verify all immutable content. The signed payload authenticates metadata hashes, and clients validate the complete hash chain lazily when they use it. Semantic verification of build results would require an independent build and is outside this command.

Interactive publication writes its comparison and prompt to the terminal. When standard input is not a terminal, omission of --yes is an error. The flag suppresses only the prompt; it never suppresses format, predecessor, signature, or conditional-write checks.

The private key is never copied to the build server, embedded in the candidate, written to the repository, or included in logs.

Publication and garbage-collection serialization

The repository has one logical writer. prepare, publish, and gc must be serialized as one administrative workflow even though signing occurs on a different machine.

In particular, gc must not run between prepare and either publication or explicit abandonment of its candidate. Content uploaded by prepare is not a live root until the candidate becomes /master; a concurrent collection may therefore delete it. No later candidate may be published after a collection that could have removed its inputs.

The first implementation enforces this operationally and through narrowly scoped credentials; it does not introduce repository leases or mutable pending publication records.

status

status is a read-only administrative command. Its standard output is one pretty-printed JSON document. Progress and diagnostics go to standard error, so standard output remains machine-readable. --compact selects compact JSON.

Logical status

bobr-repo status \
    --master-url https://repo.example.org/master \
    --trusted-key PUBLIC-KEY ... \
    [--cache DIR] \
    [--ca-bundle CA-BUNDLE.pem] \
    [--quiet] \
    [--compact]

The command authenticates /master and loads all four metadata files referenced by every current and retained state: build index, reuse index, object list, and filesystem-file list. The current implementation also loads mapping indexes even though content counts primarily require the lists. With --cache, unchanged metadata is read and verified locally; without it, metadata is downloaded again on each invocation. No o/* or f/* payloads are downloaded. It reports:

  • state = "ready", the number of current slots, and the active slot serial;
  • the master URL, data base URL, signing key identifier, and master hash;
  • active, sealed, and retained states with their serials and deadlines;
  • object and filesystem-file references per state;
  • unique repository content hashes;
  • hashes shared by several current or retained states;
  • hashes exclusive to each state;
  • expired retained states which a future preparation may prune.

This mode does not list S3 and cannot report encoded byte sizes or unreferenced bucket objects. Consequently, the top-level active_slot.content_bytes field is null.

Shared metadata cache

--cache DIR uses DIR as a RepositoryCache root. The directory layout, master-URL namespace, ETag handling, immutable metadata paths, and validation rules are exactly those used by the Bobr remote-repository reader. The Bobr client can therefore use the same directory directly; status does not create a CLI-specific cache format.

The cache is untrusted. The master is revalidated and signature-checked, and immutable metadata is rehashed before use. Without --cache, status uses a temporary cache and removes it on successful or failed exit.

Physical storage scan

bobr-repo status \
    --master-url https://repo.example.org/master \
    --trusted-key PUBLIC-KEY ... \
    --repository s3://BUCKET/PREFIX \
    --scan-storage \
    [--cache DIR] \
    [--ca-bundle CA-BUNDLE.pem] \
    [--quiet]

This mode treats authenticated S3 as the authoritative administrative view. It reads and verifies master directly through S3, fetches the immutable metadata named by that master through the S3 transport, and lists the recognized S3 namespaces. A public HTTP cache therefore cannot make publication decisions from a stale or missing master.

S3 listing returns encoded object sizes, so the command does not download every o/* and f/* value or issue a separate HEAD request for every key.

The stable top-level policy fields for a populated repository are:

{
  "state": "ready",
  "current_slots": 3,
  "active_slot": {
    "serial": 17,
    "content_bytes": 184532000000
  }
}

current_slots excludes retained slots. content_bytes is the sum of the encoded S3 sizes of the distinct o/* and f/* keys advertised by the active slot. It excludes b/*, r/*, lo/*, lf/*, and master. Content shared by several slots is counted in the size of every slot that advertises it; this is a policy measure, not exclusive physical ownership.

An accessible bucket without master is a successful empty result:

{"state":"empty","current_slots":0,"active_slot":null}

A definitive S3 NoSuchBucket is also a successful typed result:

{"state":"missing","current_slots":0,"active_slot":null}

The publication wrapper decides whether missing permits bucket creation. Authorization failures, TLS failures, timeouts, and other ambiguous errors are not converted to missing.

The JSON result additionally reports:

  • total physical bytes and key counts;
  • bytes split among ordinary objects, filesystem files, indexes, lists, and master;
  • referenced bytes per slot, which count shared content in every referencing slot;
  • bytes exclusive to each slot;
  • currently unreferenced immutable keys and bytes;
  • total bytes that become reclaimable after pruning all expired retained states;
  • per-slot encoded content bytes used by publication policy.

If an authenticated master advertises an immutable metadata or content key which the S3 scan cannot find, status fails instead of reporting a truncated size. A master change during the scan causes one complete retry; another change is an error rather than a mixed snapshot.

Per-slot size is never presented as one ambiguous number. The output names referenced_bytes, exclusive_bytes, and reclaimable_bytes_if_pruned_alone separately. The repository-wide reclaimable_after_expired_prune_bytes also counts data shared exclusively by several expired states, which cannot truthfully be assigned to any one state.

The scan is linear in the number of S3 keys and can require one LIST request per service page, commonly one thousand keys. It is intended for occasional administration, not a live monitoring loop. --cache caches authenticated master metadata but does not turn an old S3 listing into authoritative current storage state.

gc

gc runs with the administrative content-delete identity:

bobr-repo gc \
    --repository s3://BUCKET/PREFIX \
    --master-url https://repo.example.org/master \
    --trusted-key PUBLIC-KEY ... \
    [--cache DIR] \
    [--ca-bundle CA-BUNDLE.pem] \
    [--dry-run]

It never modifies or signs /master. Expired retained entries still present in the authoritative master remain live; they are removed only by a separately prepared and published master.

As with prepare, --master-url selects the client-compatible cache namespace while the authoritative master is fetched directly through S3. --cache persists authenticated metadata; without it, gc uses a temporary cache.

The command:

  1. Fetches and authenticates the current /master directly through S3.
  2. Fetches and hash-verifies every build index, reuse index, object list, and filesystem-file list referenced by every current and retained state.
  3. Computes the complete live set for each recognized immutable namespace.
  4. Lists those namespaces in S3.
  5. Produces a deletion plan containing only keys absent from the corresponding live set.
  6. Prints counts and encoded bytes by namespace.
  7. Immediately before mutation, requires the authoritative /master to be unchanged since the live-set analysis.
  8. Deletes the planned keys unless --dry-run is set.

Failure to fetch or validate any live metadata aborts before deletion. Keys outside b/, r/, lo/, lf/, o/, and f/ are never deleted. Interrupted deletion is safe to repeat because every retry recomputes the live sets from a freshly fetched and verified master.

--dry-run performs the same authenticated traversal and S3 listing as a real collection. It differs only by not issuing DELETE operations.

Exit and output rules

Every subcommand returns nonzero on validation, authentication, S3, local I/O, or conditional-publication failure. It does not downgrade a partial upload, stale candidate, missing advertised object, or incomplete garbage-collection analysis to a warning.

Machine-readable results are written to standard output. Human progress, retry notices, prompts, and diagnostics are written to standard error. Secret keys, S3 secrets, authorization headers, and private-key passphrases are never logged.

Interactive prepare and status commands keep one live progress line on a terminal. Redirected standard error receives phase transitions without terminal control sequences. --quiet disables this progress output while preserving machine-readable results, final informational messages, and errors.

All mutating commands support interruption. An interrupted prepare may leave unreferenced immutable keys, and an interrupted gc may leave unreachable keys. Both states are safe. An interrupted publish leaves either the old master or the complete conditionally written new master because S3 object replacement is atomic.

Self-hosting an S3 endpoint with VersityGW

This guide sets up a small S3-compatible server suitable for a Bobr remote repository. It uses:

  • VersityGW with its POSIX backend;
  • a systemd system service;
  • a private certificate authority for the HTTPS endpoint;
  • a public-read, write-authenticated S3 bucket;
  • a separate Ed25519 key for signing the Bobr repository master.

The commands use a DNS name as the server identity and /srv/versitygw as the persistent backend. An IP address can be used instead when it is encoded as an IP Subject Alternative Name in the TLS certificate.

Deployment model

A Bobr remote repository has four logical roles. They may run on separate machines, although a small deployment may combine the builder and hoster.

Builder

The builder realizes the intended Bobr world into a complete local store and runs bobr-repo prepare. Preparation:

  • reads and verifies the currently published master;
  • scans and validates the local store;
  • uploads missing immutable objects, filesystem files, indexes, and lists through the authenticated S3 API;
  • produces an unsigned candidate-master.cbor.

The builder needs S3 credentials which can upload immutable repository data. It does not need and should not possess the repository signing key.

Hoster

The hoster runs the S3-compatible storage service described by this guide. It stores /master and the immutable repository namespaces and serves them to clients over HTTPS. It neither builds packages nor signs repository state.

A compromised hoster can withhold data or return malformed bytes, but clients reject forged content using the signed master and content hashes. The hoster cannot create new authenticated repository state without the repository signing key.

Administrator

The administrator receives candidate-master.cbor from the builder on a trusted machine and runs bobr-repo publish. Publication:

  • verifies the currently published master;
  • verifies that the candidate names it as its predecessor;
  • displays the proposed state transition for review;
  • signs the candidate with the repository Ed25519 private key;
  • conditionally replaces /master through the authenticated S3 API.

The administrator holds the signing key and S3 credentials which can update /master. The complete local build store is not needed on this machine.

Client

A client has neither S3 credentials nor private keys. It downloads /master and immutable repository data anonymously over HTTPS. It pins the repository’s Ed25519 public key, verifies the master signature, and verifies all downloaded indexes and content by their hashes before importing them into its working store.

The complete data flow is:

                         unsigned candidate
Builder --------------------------------------------> Administrator
   |                                                       |
   | authenticated immutable uploads                      | signed /master
   v                                                       v
Hoster <---------------------------------------------------+
   |
   | anonymous verified downloads
   v
Client

The protocol does not require the builder and hoster to share a machine. Even when they do, bobr-repo prepare uses the same authenticated S3 interface as a remote builder. Keeping the administrator separate prevents the builder or hoster from publishing a new signed master without the administrator’s key. The signature records repository state approved by the administrator; by itself it does not prove that the uploaded packages were built correctly or reproducibly.

Keys and credentials

The setup has three independent kinds of key material:

MaterialPurposeWhere its secret is kept
S3 credentialsAuthorize uploads and repository administrationHoster and authorized builder or administrator
TLS CA and server keyAuthenticate and encrypt the HTTPS endpointCA key on a trusted machine; server key on the hoster
Bobr Ed25519 signing keySign the repository /masterTrusted publishing machine only

Do not reuse a key between these roles. In particular, TLS authenticates a network connection, while the Ed25519 signature authenticates repository state.

Private keys must not be committed to source control, embedded in package or machine configuration, or copied into a world-readable build artifact. The systemd unit below refers to private files by path; it does not contain their contents.

Prerequisites

The machine used to administer the hoster needs:

  • OpenSSL;
  • AWS CLI v2;
  • bobr-repo on PATH (see Installation);
  • SSH and SCP access to the hoster.

The hoster needs a filesystem with extended-attribute support. VersityGW’s POSIX backend uses xattrs for S3 metadata. Do not modify its backend directory behind the gateway.

Install the VersityGW executable according to its upstream documentation. The systemd example below expects it at /usr/local/bin/versitygw; replace that path with the result of command -v versitygw on the hoster.

The repository includes three optional setup helpers. From the directory where their out/ results should be created, run tools/server/gen-versitygw-env.sh to generate the S3 credentials and tools/server/gen-versitygw-tls.sh <server-name-or-ip> to generate the private CA and VersityGW certificate described below. Run tools/server/gen-bobr-repository-signing-key.sh to generate the separate Ed25519 repository signing key and its public key.

The examples use these values on the machine administering the hoster:

export BOBR_PKI="$HOME/.config/bobr/pki"
export S3_HOST=s3.example.net
export S3_SSH_HOST="$S3_HOST"
export S3_PORT=7070
export S3_ENDPOINT="https://${S3_HOST}:${S3_PORT}"
export S3_REGION=us-east-1
export S3_BUCKET=bobr-repository

install -d -m 0700 "$BOBR_PKI"

install -d -m 0700 creates the key directory, if necessary, and makes it accessible only to its owner.

Service account and filesystem layout

The remainder of this guide assumes that the hoster provides:

  • an unprivileged system user and group named versitygw;
  • the VersityGW executable at /usr/local/bin/versitygw;
  • a configuration directory at /etc/versitygw;
  • a persistent POSIX-backend directory at /srv/versitygw.

The service account does not need an interactive login. The directories and files created throughout this guide have the following final ownership and permissions:

PathOwnerModePurpose
/etc/versitygwroot:root0755Service configuration
/etc/versitygw/versitygw.envroot:root0600S3 root credentials
/etc/versitygw/tlsroot:versitygw0750TLS material
/etc/versitygw/tls/server.keyroot:versitygw0640TLS private key
/etc/versitygw/tls/server.pemroot:root0644TLS certificate
/srv/versitygwversitygw:versitygw0700POSIX backend

/srv/versitygw may itself be a mount point. Its filesystem must support extended attributes, be writable by the versitygw account, and provide enough space for the repository.

How the account, group, executable, and directories are provisioned is distribution-specific and outside this guide.

Generate S3 root credentials

VersityGW’s root S3 account bypasses normal bucket policy and ACL evaluation. Use it only for initial setup and administration. A production deployment can later introduce narrower identities for preparation, publication, and garbage collection.

Generate an AWS-shaped access key and a 256-bit secret:

access_key="AKIA$(openssl rand -hex 8 | tr 'a-f' 'A-F')"
secret_key="$(openssl rand -hex 32)"

openssl rand obtains bytes from the operating system cryptographic random number generator. The first command produces 64 random bits after the AKIA prefix and restricts the result to characters safe in an AWS access-key ID. The second produces 32 random bytes, encoded as 64 hexadecimal characters.

On the hoster, create /etc/versitygw/versitygw.env:

ROOT_ACCESS_KEY_ID=AKIA...
ROOT_SECRET_ACCESS_KEY=...

Then protect it:

sudo chown root:root /etc/versitygw/versitygw.env
sudo chmod 0600 /etc/versitygw/versitygw.env

The systemd manager reads this file and passes the values to the unprivileged VersityGW service. The service process does not need permission to open the file itself. An environment file keeps the secret out of declarative service configuration and the process command line.

Configure the same credentials on the machine used for initial S3 setup. For example, create ~/.config/bobr/aws-credentials with mode 0600:

[bobr-server]
aws_access_key_id = AKIA...
aws_secret_access_key = ...

Select it without copying credentials into shell history:

export AWS_SHARED_CREDENTIALS_FILE="$HOME/.config/bobr/aws-credentials"
export AWS_PROFILE=bobr-server
export AWS_REGION="$S3_REGION"

This guide uses the root identity to bootstrap and verify a small deployment. Do not routinely share it between all four roles. A production deployment should issue narrower S3 identities: the builder writes immutable namespaces, the administrator reads repository state and updates /master, and garbage collection has only the additional listing and deletion permissions it needs. The bobr-repo command-line guide describes those capability boundaries.

Create a private TLS certificate authority

A private CA is useful for a LAN deployment and for end-to-end testing.

Create an elliptic-curve P-256 private key for the CA:

umask 077

openssl genpkey \
    -algorithm EC \
    -pkeyopt ec_paramgen_curve:P-256 \
    -out "$BOBR_PKI/local-repository-ca.key.pem"

genpkey creates a PKCS#8 private key. P-256 is a widely supported curve for TLS certificate chains. umask 077 prevents newly created files from gaining group or other permissions.

Create a self-signed CA certificate valid for ten years:

openssl req \
    -x509 \
    -new \
    -key "$BOBR_PKI/local-repository-ca.key.pem" \
    -sha256 \
    -days 3650 \
    -subj "/CN=Bobr Local Repository CA" \
    -addext "basicConstraints=critical,CA:TRUE,pathlen:0" \
    -addext "keyUsage=critical,keyCertSign,cRLSign" \
    -addext "subjectKeyIdentifier=hash" \
    -out "$BOBR_PKI/local-repository-ca.cert.pem"

req -x509 emits a certificate rather than a certificate request. Because this is the trust root, it signs itself. basicConstraints marks it as a CA; pathlen:0 permits it to sign server certificates but not subordinate CAs. keyUsage restricts the key to certificate and revocation-list signing.

Keep both the CA key and the serial file created during certificate issuance. The CA certificate is public and may be copied to clients; the CA private key must remain on a trusted machine.

Issue the VersityGW server certificate

Create a separate P-256 private key for the HTTPS server:

openssl genpkey \
    -algorithm EC \
    -pkeyopt ec_paramgen_curve:P-256 \
    -out "$BOBR_PKI/versitygw-server.key.pem"

Create a certificate signing request. The Subject Alternative Name must cover the exact address used by clients:

openssl req \
    -new \
    -key "$BOBR_PKI/versitygw-server.key.pem" \
    -subj "/CN=${S3_HOST}" \
    -addext "subjectAltName=DNS:${S3_HOST}" \
    -addext "keyUsage=critical,digitalSignature" \
    -addext "extendedKeyUsage=serverAuth" \
    -out "$BOBR_PKI/versitygw-server.csr.pem"

The CSR contains the hoster’s public key and requested certificate extensions. Modern TLS clients validate the IP address or hostname against subjectAltName; the Common Name is not a substitute for it.

When clients connect by IP address, use an IP SAN instead:

-addext "subjectAltName=IP:<server-address>"

An IP address encoded as a DNS SAN is not equivalent.

Sign the request with the local CA:

openssl x509 \
    -req \
    -in "$BOBR_PKI/versitygw-server.csr.pem" \
    -CA "$BOBR_PKI/local-repository-ca.cert.pem" \
    -CAkey "$BOBR_PKI/local-repository-ca.key.pem" \
    -CAcreateserial \
    -days 825 \
    -sha256 \
    -copy_extensions copy \
    -out "$BOBR_PKI/versitygw-server.cert.pem"

x509 -req turns the CSR into a certificate. -CA and -CAkey select the issuer, -CAcreateserial creates and maintains its certificate serial file, and -copy_extensions copy preserves the requested SAN and TLS usage constraints.

Verify the resulting chain and inspect its identity:

openssl verify \
    -CAfile "$BOBR_PKI/local-repository-ca.cert.pem" \
    "$BOBR_PKI/versitygw-server.cert.pem"

openssl x509 \
    -in "$BOBR_PKI/versitygw-server.cert.pem" \
    -noout -subject -issuer -ext subjectAltName

The first command must report OK; the second must contain the address by which clients will reach the hoster.

Install the TLS key on the hoster

Only the server certificate and its private key are installed on the hoster. Do not copy the CA key or the Bobr repository signing key there.

ssh "$S3_SSH_HOST" \
    'sudo install -d -o root -g versitygw -m 0750 /etc/versitygw/tls'

scp \
    "$BOBR_PKI/versitygw-server.key.pem" \
    "$BOBR_PKI/versitygw-server.cert.pem" \
    "${S3_SSH_HOST}:/tmp/"

ssh "$S3_SSH_HOST" '
    sudo install -o root -g versitygw -m 0640 \
        /tmp/versitygw-server.key.pem \
        /etc/versitygw/tls/server.key
    sudo install -o root -g root -m 0644 \
        /tmp/versitygw-server.cert.pem \
        /etc/versitygw/tls/server.pem
    rm -f /tmp/versitygw-server.key.pem /tmp/versitygw-server.cert.pem
'

The service group can read the private key; other unprivileged users cannot. The CA private key remains on its trusted issuing machine.

Configure the systemd service

Create /etc/systemd/system/versitygw.service:

[Unit]
Description=Versity S3 Gateway
Wants=network-online.target
After=network-online.target
RequiresMountsFor=/srv/versitygw

[Service]
Type=simple
User=versitygw
Group=versitygw
EnvironmentFile=/etc/versitygw/versitygw.env
ExecStart=/usr/local/bin/versitygw \
    --port :7070 \
    --cert /etc/versitygw/tls/server.pem \
    --key /etc/versitygw/tls/server.key \
    posix /srv/versitygw
Restart=on-failure
UMask=0077
NoNewPrivileges=true
PrivateTmp=true
ProtectHome=true
ProtectSystem=strict
ReadWritePaths=/srv/versitygw

[Install]
WantedBy=multi-user.target

RequiresMountsFor orders the service after the backend mount. --cert and --key enable TLS on the S3 listener; omitting either leaves the listener without a usable TLS configuration. The final posix /srv/versitygw selects the POSIX backend and its root. ProtectSystem=strict makes the rest of the host filesystem read-only to the service, while ReadWritePaths grants the required backend access.

Open TCP port 7070 only to the networks which should reach the service. A public repository needs public HTTPS reads, but its authenticated S3 administration endpoint can instead be restricted by a firewall or separate network path if the deployment provides distinct endpoints.

Reload systemd, enable the service, and inspect its initial startup:

sudo systemctl daemon-reload
sudo systemctl enable --now versitygw
systemctl status versitygw
journalctl -u versitygw -b --no-pager

VersityGW terminates TLS itself; no additional HTTP server is involved.

Verify authenticated S3 access

Tell AWS CLI to trust the private CA:

export AWS_CA_BUNDLE="$BOBR_PKI/local-repository-ca.cert.pem"

List the hoster’s buckets:

aws \
    --endpoint-url "$S3_ENDPOINT" \
    s3api list-buckets

AWS CLI signs this request using the selected profile. A certificate error means the CA or SAN is wrong; InvalidAccessKeyId or SignatureDoesNotMatch means the AWS CLI profile and /etc/versitygw/versitygw.env do not contain the same S3 credentials.

Initialize the repository bucket

Export the endpoint for the standard AWS SDK configuration chain, then let bobr-repo create and configure the dedicated bucket:

export AWS_ENDPOINT_URL_S3="$S3_ENDPOINT"

bobr-repo init \
    --repository "s3://$S3_BUCKET" \
    --ca-bundle "$BOBR_PKI/local-repository-ca.cert.pem"

The operation is idempotent. It owns the bucket’s complete policy, exposes anonymous GetObject only for Bobr’s public namespaces, and configures bucket-level Public Access Block so public ACLs stay disabled while the explicit public-read policy remains usable. VersityGW may report Public Access Block as unsupported; bobr-repo reports this and still installs the policy.

Do not use this bucket for unrelated data. Do not enable S3 object versioning merely for Bobr: immutable repository keys are already content-addressed, while publication of /master uses conditional replacement.

Verify that anonymous listing is denied:

aws \
    --no-sign-request \
    --ca-bundle "$BOBR_PKI/local-repository-ca.cert.pem" \
    --endpoint-url "$S3_ENDPOINT" \
    s3api list-objects-v2 \
    --bucket "$S3_BUCKET"

Generate the Bobr repository signing key

This final key does not belong to S3 or TLS. Bobr uses it to create the COSE_Sign1 signature on /master described in MASTER.md.

Generate an Ed25519 private key:

openssl genpkey \
    -algorithm Ed25519 \
    -out "$BOBR_PKI/bobr-repository-signing.key.pem"

Ed25519 is the signature algorithm fixed by repository format version 1. OpenSSL writes an unencrypted PKCS#8 key, which bobr-repo publish accepts. The file must remain on the trusted publishing machine and must have no group or other permission bits:

chmod 0600 "$BOBR_PKI/bobr-repository-signing.key.pem"

Derive the corresponding public SubjectPublicKeyInfo document:

openssl pkey \
    -in "$BOBR_PKI/bobr-repository-signing.key.pem" \
    -pubout \
    -out "$BOBR_PKI/bobr-repository-signing.pub.pem"

chmod 0644 "$BOBR_PKI/bobr-repository-signing.pub.pem"

pkey -pubout derives only the public key; it does not disclose the private scalar. Distribute this public key to Bobr clients through an authenticated channel. It is their trust anchor for the repository and must not be accepted merely because the repository itself supplied it.

Check that the private and public files describe the same key:

private_public="$BOBR_PKI/bobr-repository-signing.derived.pub.pem"

openssl pkey \
    -in "$BOBR_PKI/bobr-repository-signing.key.pem" \
    -pubout \
    -out "$private_public"

cmp "$private_public" "$BOBR_PKI/bobr-repository-signing.pub.pem"
rm "$private_public"

cmp produces no output and exits successfully when the files are identical.

The hosting service is now ready for the bobr-repo publication workflow. For the example bucket, the repository locations are:

S3 administration: s3://bobr-repository
master URL:         https://s3.example.net:7070/bobr-repository/master
data base URL:      https://s3.example.net:7070/bobr-repository/

Pass the public CA certificate to every bobr-repo command which addresses this repository:

--ca-bundle local-repository-ca.cert.pem

The additional CA is used for both authenticated S3 requests and anonymous reads through the public URLs. It is added to the standard trusted roots, so the same command may still follow repository URLs certified by a public CA. Disabling certificate verification is not an acceptable substitute.

Backup and rotation

Back up these values independently:

  • the VersityGW backend under /srv/versitygw;
  • the repository signing private key;
  • the local CA key and its serial file;
  • S3 credentials required for administration.

Losing the S3 root secret requires rotating the hoster’s credentials. Losing the TLS CA key prevents issuing replacement certificates under the same trust anchor. Losing the Bobr signing key prevents publishing a new master trusted by existing clients; replacing it requires an authenticated trust-anchor rollover outside the repository itself.

Compromise has different consequences. Rotate S3 credentials after an S3 credential leak, replace the HTTPS certificate after a TLS server-key leak, and treat a repository-signing-key leak as compromise of the repository’s authenticated history.

Further reading

Remote repository master

This document specifies the purpose and version 1 wire format of the mutable /master object in a Bobr remote repository. The normative CDDL schema of its payload is in master-payload.cddl.

Purpose

/master is the small, mutable, authenticated root of a remote repository. It does not contain build results or large indexes. It identifies the repository state that a reader must use and authenticates the immutable metadata reachable from that state.

In particular, /master:

  • declares its wire-format version;
  • names the exact signed master from which this publication was prepared;
  • names the HTTPS base URL of immutable repository data;
  • describes the current and temporarily retained slot states without fixing the number of current slots;
  • authenticates one build index, one reuse index, one object list, and one filesystem-file list for every slot state by their SHA-256 digests;
  • carries the retention deadline for each retired slot state;
  • identifies which already-pinned signing key produced the signature.

The master is the publication commit point. A publisher uploads all new immutable content and metadata first and replaces /master only after every referenced object is available. A reader therefore observes either the old complete repository state or the new complete repository state.

Object location and HTTP representation

The configured master URL is stable and need not share an origin with the immutable repository data. Its conventional path is /master.

The HTTP response uses:

Content-Type: application/cose; cose-type="cose-sign1"
Cache-Control: no-cache

no-cache permits local storage but requires revalidation before reuse. An origin should provide an ETag or Last-Modified validator so an unchanged master normally produces a small 304 Not Modified response.

The current Bobr client caches ETag and revalidates with If-None-Match. It does not yet send If-Modified-Since based on Last-Modified; without an ETag, it fetches the master again rather than using a cached response unchecked.

The signed payload contains data_base_url. Immutable object keys are resolved relative to that URL, so moving data does not require changing the stable master URL or the client’s trust configuration. data_base_url must be an absolute HTTPS URL, must end in /, and must not contain user information, a query, or a fragment.

COSE profile

/master is a tagged COSE_Sign1 object as defined by RFC 9052. The CBOR tag 18 is required. Detached payloads are not permitted.

The protected header map contains exactly these parameters:

alg:          EdDSA (-8)
content type: application/vnd.bobr.repository-master+cbor
kid:          opaque non-empty byte string

The signing key is Ed25519. The kid value only selects one of the public keys already pinned in the client configuration. It is not a certificate, does not carry trust by itself, and must never cause a client to download and trust an unconfigured key.

The unprotected header map is empty. Version 1 puts no security-relevant or semantic information in unsigned COSE headers. External additional authenticated data is the empty byte string.

The payload is embedded as a byte string and contains one deterministic-CBOR encoding of bobr-repository-master-payload. The signature is the 64-byte Ed25519 signature produced according to the standard COSE Signature1 construction. Bobr does not define a separate signature field or its own byte concatenation rules.

The complete COSE object, its protected header map, and its payload must satisfy the core deterministic encoding requirements of RFC 8949 section 4.2.1. A version 1 implementation rejects indefinite-length items, non-shortest integer or length encodings, duplicate map keys, incorrectly ordered map keys, and other non-deterministic encodings.

Format resource limits

Repository format version 1 fixes the following interoperability limits. They are part of the format rather than client policy and every reader and publisher enforces them:

encoded master                         4 MiB
one build/reuse index or content list  1 GiB
one encoded o/* or f/* value           1 TiB
one decoded object or fs-file payload  1 TiB
logical entries in one directory tar   4,194,304
effective and normalized tar path      65,536 bytes each
one tar path component                 255 bytes
symbolic-link target                   4,095 bytes
directory-tree depth                   1,024 components
sum of logical path and link bytes     256 MiB
Zstandard decoder window               128 MiB

Implementations may impose runtime limits on concurrency, time, cache storage, or total work, but they must not silently redefine these per-object validity limits for repository format version 1.

Payload

The payload has the following logical structure:

repository_format = 1
previous_master_hash
data_base_url
slots = [slot, ...]

Unknown fields are rejected in repository format version 1. An incompatible payload change requires another repository_format value and a corresponding schema.

Repository format

repository_format describes the remote repository wire format, not the Bobr implementation version. Its value is exactly 1 for this document.

Previous master hash

previous_master_hash is null in the first publication of an empty repository. In every subsequent publication it is the SHA-256 digest of the exact tagged COSE bytes of the immediately preceding /master response.

The field is part of the signed payload. Before publishing a candidate, the writer fetches the authoritative /master and requires its exact digest to equal previous_master_hash. It then uses a conditional single-object write to ensure that /master has not changed between that check and replacement. This prevents a stale candidate from overwriting a newer publication without introducing a separate candidate envelope format.

Readers do not use this field for build or reuse lookup and must not require it to match a locally cached master. A reader may legitimately skip any number of intermediate publications. The link is a writer precondition and an audit record, not a requirement that ordinary readers retrieve master history.

Data base URL

data_base_url is the root for immutable data. Repository keys are relative to it:

b/<lowercase BuildIndexHash hex>
r/<lowercase ReuseIndexHash hex>
lo/<lowercase ObjectListHash hex>
lf/<lowercase FsFileListHash hex>
o/<lowercase ObjectHash hex>
f/<lowercase FsFileHash hex>

Digests are represented as raw 32-byte strings inside CBOR and as lowercase hexadecimal strings only in URLs.

Objects under o/<ObjectHash> use the streaming deterministic-CBOR envelope specified in OBJECT.md and object.cddl. The key is the logical filesystem object hash after decoding, not a digest of the CBOR response bytes. Directory objects inside that envelope use the restricted transport profile specified in TAR.md. Filesystem files under f/<FsFileHash> use the corresponding streaming envelope specified in FS_FILE.md and fs-file.cddl; their identity additionally includes logical uid, gid, and full mode.

Immutable metadata formats

The four immutable metadata formats have no header, magic number, embedded version, record count, padding, or delimiter. Their interpretation follows from the field that references them in a verified master and from repository_format. Any incompatible change to one of these formats requires a new repository format version.

Every response under b/, r/, lo/, or lf/ uses:

Content-Type: application/octet-stream
Cache-Control: public, max-age=31536000, immutable

The repository may use a longer freshness lifetime. Content-Encoding is absent: the response body is the exact byte sequence whose SHA-256 digest is named by the URL and authenticated by the master. HTTP transfer framing does not participate in that digest.

Keys in all four namespaces are write-once. Once a byte sequence exists under a digest-derived key, a publisher reuses it and must never replace it. New metadata bytes are uploaded under their own digest-derived key and become live only when a subsequently published master references them. This makes the URLs safe for immutable shared caching and prevents readers of an older master from observing changed metadata.

All hashes and keys in these files use their raw 32-byte representation. Hex encoding is used only for repository object names. Ordering is unsigned lexicographic ordering of those 32 bytes.

Build index

A build index under b/<BuildIndexHash> is a sequence of fixed-size 64-byte records:

BuildKey[32] ObjectHash[32]
BuildKey[32] ObjectHash[32]
...

Records are ordered by nondecreasing BuildKey, so all records for one key are contiguous. Repeated keys are permitted and associate the key with multiple ObjectHash candidates. Within one key’s group, record order is candidate priority order. An identical (BuildKey, ObjectHash) pair must occur at most once. When a publisher adds a previously unknown candidate to an active slot, it places that candidate before the older candidates for the same key. The file size must be divisible by 64. A zero-length file is the canonical empty build index. BuildIndexHash is the SHA-256 digest of the exact file bytes.

Reuse index

A reuse index under r/<ReuseIndexHash> has the same record representation:

ReuseKey[32] ObjectHash[32]
ReuseKey[32] ObjectHash[32]
...

Records are ordered by nondecreasing ReuseKey, so all records for one key are contiguous. Repeated keys are permitted and associate the key with multiple ObjectHash candidates. Within one key’s group, record order is candidate priority order. An identical (ReuseKey, ObjectHash) pair must occur at most once. When a publisher adds a previously unknown candidate to an active slot, it places that candidate before the older candidates for the same key. The file size must be divisible by 64. A zero-length file is the canonical empty reuse index. ReuseIndexHash is the SHA-256 digest of the exact file bytes.

Mapping candidates across slots

One index and multiple current slots may each provide several results for the same BuildKey or ReuseKey. A reader processes current slots in decreasing serial order, preserves candidate order within each index, and forms one ordered list of distinct ObjectHash candidates for the requested key:

  • the candidate from the greatest applicable serial comes first;
  • candidates from the same slot retain their order in that slot’s index;
  • repeated occurrences of the same ObjectHash, whether in one index or several, are deduplicated without changing that order;
  • different ObjectHash values remain separate candidates.

Different results for one build or reuse key are valid repository data. They normally reveal that the corresponding builder produced nondeterministic output, and implementations report that condition as a diagnostic, but it does not invalidate the repository or the lookup. A reader must not silently replace the candidate set with only the newest result.

Object list

An object list under lo/<ObjectListHash> is a sequence of raw object hashes:

ObjectHash[32]
ObjectHash[32]
...

Hashes are strictly ordered and therefore unique. The file size must be divisible by 32. A zero-length file is the canonical empty object list. ObjectListHash is the SHA-256 digest of the exact file bytes.

Filesystem-file list

A filesystem-file list under lf/<FsFileListHash> is a sequence of raw filesystem-file hashes:

FsFileHash[32]
FsFileHash[32]
...

Hashes are strictly ordered and therefore unique. The file size must be divisible by 32. A zero-length file is the canonical empty filesystem-file list. FsFileListHash is the SHA-256 digest of the exact file bytes.

For normal content lookup, a client uses the union of object lists and the union of filesystem-file lists referenced by current slots. If an ObjectHash is absent from the former, or an FsFileHash is absent from the latter, the repository does not provide that content in the state described by this master. The absence is a definitive miss for this repository, and the client must not issue a speculative request under o/ or f/.

This rule applies even if the corresponding immutable key happens to exist in the backing bucket. Such an unlisted key may be an orphan left by an interrupted publication or content awaiting a later garbage-collection pass; it is not part of the advertised repository state. Content lists are therefore both garbage-collection roots and the authoritative content-availability index seen by readers.

If a listed key cannot be fetched or does not decode to its expected identity, the repository failed to provide advertised content. That is not a content miss. A client may continue with another configured content source, but it reports the failure of this repository rather than treating the list entry as absent.

Slots

slots is a non-empty array of current and temporarily retained slot states. The protocol does not prescribe how many current slots a repository has. Every entry contains:

  • serial: an unsigned 64-bit sequence number, unique within the array;
  • build: SHA-256 of the immutable build index for this slot;
  • reuse: SHA-256 of the immutable reuse index for this slot;
  • object_list: SHA-256 of the immutable object list for this slot;
  • file_list: SHA-256 of the immutable filesystem-file list for this slot;
  • retain_until: null for a current slot, or an absolute Unix timestamp in seconds for a retired slot.

The array is sorted by strictly increasing serial. At least one entry must be current. The active slot is the current entry with the greatest serial; the other current entries are sealed. An ordinary client uses build and reuse indexes from all current entries and ignores retired entries for lookup.

serial identifies one immutable slot state, not a reusable physical slot. Whenever publication changes the active slot’s indexes or lists, the publisher marks the previous active entry as retired and appends its replacement with serial = max(slots.serial) + 1 and retain_until = null. Thus changing a slot never changes the meaning of an existing serial.

An add-slot publication appends a fresh active entry with the next serial and does not retire another current entry. A repository therefore does not have a configured or protocol-fixed slot count. It starts with one current slot and the sole publisher explicitly adds current slots as needed.

At rotation, the publisher retires the current entry with the smallest serial and appends a fresh active entry with the next serial. The formerly active entry remains current and thereby becomes sealed. Rotation therefore preserves the current slot count, whereas add-slot increases it.

Slot closure

Every slot state, whether current or temporarily retained, is independently closed over all content reachable from its mappings. The following invariants apply to each entry in slots:

  1. Every ObjectHash named by its build or reuse index is present in its own object list.
  2. Every ObjectHash in that object list is available under o/<ObjectHash>.
  3. If an object in that object list is an fs-tree manifest, every FsFileHash referenced by the manifest is present in the same slot state’s filesystem- file list.
  4. Every FsFileHash in that filesystem-file list is available under f/<FsFileHash>.

The lists may contain content not reachable from the slot’s mappings, but they must not omit reachable content. A slot must not rely on another current or retained slot to complete one of its mapped objects. This allows any sealed slot to survive rotation of the others and allows every retained state to remain usable throughout its grace period.

Retired slot states and retention

Retirement changes a slot entry’s retain_until from null to the end of the garbage-collection grace period. The timestamp is the earliest time at which a publisher may omit that entry from a newly published master. Reaching the timestamp does not itself make the entry or its content dead: while the entry is still present in the authoritative master, it remains a live root.

Garbage collection treats the build index, reuse index, both lists, every object named by the object list, and every filesystem file named by the filesystem-file list of every entry in slots as live, whether the entry is current or retired. After a retired entry’s deadline, the sole publisher may publish a new master without that entry. Only after that publication may garbage collection remove immutable metadata and content no longer reachable from any remaining entry.

This grace period permits a client that already fetched an older master to finish its operation. Ordinary readers parse retired entries but do not fetch their indexes or lists for normal lookup.

Retention state is part of the signed master rather than separate mutable publisher state. The bucket therefore contains all durable information needed to resume publication and safe garbage collection after loss of local state.

The number of retired entries is proportional to the number of publications within one grace period, not to the number of payload objects. Implementations impose a master size limit and must fail publication rather than silently discard unexpired entries.

Garbage collection procedure

Garbage collection is a two-phase operation. Removing an expired retired entry from the authoritative master makes its roots eligible for collection; the pass that deletes immutable keys happens only afterwards.

The sole publisher performs a collection as follows:

  1. Construct and publish a new master that omits retired entries whose retain_until deadline has passed. Keep every current entry and every retired entry whose deadline has not passed. Publish and sign this master by the normal publication procedure.

  2. Fetch /master again from its authoritative URL, apply normal HTTP revalidation, and verify its COSE signature and payload. Require it to be the state intended for collection. Do not compute the deletion set solely from unpublished local state or from the response used before publication.

  3. Fetch and hash-verify all build indexes, reuse indexes, object lists, and filesystem-file lists referenced by every entry in the verified master. If any referenced metadata is unavailable or invalid, abort without deleting anything.

  4. Compute the live sets exclusively from that verified state:

    live b/ metadata = every slots[*].build
    live r/ metadata = every slots[*].reuse
    live lo/ metadata = every slots[*].object_list
    live lf/ metadata = every slots[*].file_list
    live o/ content = union of all referenced object lists
    live f/ content = union of all referenced filesystem-file lists
    
  5. List the recognized immutable namespaces and delete keys not present in their corresponding live set. Keys outside b/, r/, lo/, lf/, o/, and f/ are outside this procedure and are not deleted.

The publisher serializes publication and garbage collection; no other writer may upload or publish concurrently with this procedure. An interrupted delete pass can only leave unreachable immutable keys and is safe to repeat. Each retry starts by fetching and verifying the then-authoritative master and recomputes the complete live set.

An entry remains a live root for as long as it occurs in /master, even after its retain_until deadline. Conversely, once an expired entry has been omitted by a successfully published master and that state has been reverified, its otherwise-unreferenced metadata and content may be deleted. The grace period is the guarantee for readers already operating from an older master; a reader cannot retain an old master indefinitely and expect its immutable content to remain available.

Verification procedure

A client processing /master performs these operations in order:

  1. Apply the repository format encoded-master limit before parsing.
  2. Require CBOR tag 18 and the COSE_Sign1 structure used by this profile.
  3. Validate deterministic CBOR encoding and reject duplicate or unknown headers.
  4. Require the protected algorithm, content type, and kid; require an empty unprotected map and an embedded payload.
  5. Select an already-pinned Ed25519 public key by exact kid match.
  6. Verify the COSE signature over the original embedded payload bytes. Do not decode and re-encode the payload for signature verification.
  7. Decode the payload and validate it against master-payload.cddl and the semantic constraints in this document.
  8. Treat the verified response from the configured master URL as the authoritative repository state. A byte-identical cached response may be reused after successful HTTP revalidation.
  9. Load current-slot immutable metadata lazily by its authenticated digest when needed: build indexes for build-key lookup, reuse indexes for reuse-key lookup, object lists for object availability, and filesystem-file lists for fs-file availability. A mapping-only client need not load content lists, and a content-only client need not load mapping indexes.
  10. Verify every fetched immutable metadata file before using it.

An ordinary reader uses only current slots. Administrative operations instead load all four metadata files for every current and retained state: prepare reconstructs publication state, status computes statistics, and GC determines the complete live sets. This full traversal uses the same master-signature and metadata-hash checks as the ordinary reader; it does not download all object or fs-file payloads.

No mapping from an unverified master may be used, even when the eventual content would be checked by ObjectHash.

Publication procedure

For one publication the sole publisher:

  1. Reads and verifies the current master.
  2. Omits retired states whose retention deadline has passed, then computes an append, add-slot, or rotation operation and the updated retention deadlines.
  3. Uploads missing immutable content under o/ and f/.
  4. Uploads the new immutable build index, reuse index, object list, and filesystem-file list under b/, r/, lo/, and lf/.
  5. Constructs the next payload, setting previous_master_hash to the digest of the exact current COSE bytes, using the next serial for every newly created slot state, and retaining all unexpired retired states.
  6. Encodes the payload and COSE object deterministically and signs it with the configured Ed25519 key.
  7. Fetches /master again and requires it to remain the predecessor named by the candidate, then replaces it last using a conditional atomic single-object operation.

If the publisher fails before the last step, it may leave unreachable immutable objects but cannot expose a partially committed repository state. If it fails after the replacement, the signed master contains all current and retired roots required to resume operation.

Trust boundaries

The signature authenticates the master and, transitively, the digests of its immutable metadata. It does not make the HTTPS origin trusted for correctness: the origin may still withhold data or serve an older master.

Repository format version 1 deliberately has no monotonic revision or trusted time mechanism. The response obtained from the configured master URL is authoritative. Comparing its bytes or digest with a cached response detects a change but does not distinguish a legitimate older state from a rollback. The previous_master_hash link cannot change that for an ordinary reader which may skip intermediate publications. The signature alone therefore does not prevent replay or freeze attacks by the master origin.

Build and reuse indexes from the repository may be used as trusted BuildKey and ReuseKey answers only when client configuration grants that capability to the repository. Content is independently verified by its logical ObjectHash or FsFileHash after decoding, regardless of mapping trust.

Private signing keys and S3 credentials are not repository objects. They remain outside the public bucket and outside /master.

Standards

  • CBOR and deterministic encoding: RFC 8949.
  • CDDL: RFC 8610.
  • COSE structures and processing: RFC 9052.
  • COSE algorithms, including EdDSA: RFC 9053.
  • Ed25519: RFC 8032.

Remote repository object

This document specifies the repository format version 1 wire format of immutable objects stored under o/<ObjectHash>. The normative CDDL schema is in object.cddl.

Identity

The key has this form:

o/<lowercase ObjectHash hex>

ObjectHash identifies the decoded logical filesystem object. It is not the SHA-256 digest of the CBOR representation, the encoded payload, or the decoded tar bytes. The envelope and compression do not participate in object identity.

The decoded object is one of the ordinary object kinds accepted by Bobr’s filesystem object hashing rules:

  • a regular file, whose identity includes its bytes and executable state;
  • a directory containing regular files, directories, and symbolic links.

Root symbolic links and other filesystem kinds are not supported. The normative filesystem identity and tar normalization rules are specified in Filesystem Object Hashing.

An object key is write-once. A publisher that finds an existing o/<ObjectHash> reuses it and must not replace it with another equivalent encoding or compression choice. This gives the immutable URL stable response bytes and cache validators even though more than one wire representation could decode to the same logical object.

HTTP representation

An object response uses:

Content-Type: application/vnd.bobr.repository-object+cbor
Cache-Control: public, max-age=31536000, immutable
Content-Encoding: absent

The repository may use a longer freshness lifetime. A client must still validate the decoded logical identity before admitting the object to its working store.

CBOR envelope

The response body is one untagged CBOR array with exactly two elements:

[
    metadata,
    payload: bstr,
]

It uses the core deterministic encoding requirements of RFC 8949 section 4.2.1. Indefinite-length arrays, maps, text strings, and byte strings are forbidden. Integers and lengths use their shortest encoding, map keys are in deterministic order, unknown fields are rejected, and no bytes may follow the array.

The payload is a definite-length CBOR byte string and is the final value in the array. Its length is the encoded payload length, before any decompression. A reader can parse the small metadata map and the byte-string length, then stream exactly that many bytes through the selected decoder without holding the payload in memory. A compliant reader must support this streaming path and must not require one allocation proportional to the payload size.

The envelope contains neither an embedded format version nor an embedded ObjectHash. The signed master selects repository_format, and the requested URL supplies the expected identity. Any incompatible change to this format or its CDDL schema requires a new repository format version.

Common metadata

Both object kinds contain:

  • kind: the logical root kind;
  • compression: the representation of the encoded payload;
  • decoded_size: the exact number of payload bytes after decompression.

decoded_size is an unsigned 64-bit integer. It supports progress reporting, resource limits, and exact decoder validation. A reader must not use an untrusted value to allocate that amount of memory. Producing fewer or more decoded bytes than declared is an error.

The encoded payload length is already carried by the definite-length CBOR byte string and is not repeated in metadata.

File object

File metadata has this logical form:

{
    "kind": "file",
    "executable": true | false,
    "compression": "identity" | "zstd",
    "decoded_size": uint64,
}

After decompression, the payload is exactly the regular file’s byte content. The reader computes the file object hash from those bytes, their length, and the declared executable state, and requires it to equal the ObjectHash in the requested key.

For a non-executable file this is exactly the SHA-256 digest of the decoded bytes. An executable file uses Bobr’s tagged file hash. The executable field is required for a file and is forbidden for a directory.

Directory object

Directory metadata has this logical form:

{
    "kind": "directory",
    "archive": "tar",
    "compression": "identity" | "zstd",
    "decoded_size": uint64,
}

After decompression, the payload is one tar stream describing the directory contents. The stream must satisfy the repository directory tar profile. The reader applies that profile to obtain the normalized tree defined by Filesystem Object Hashing, computes its hash, and requires it to equal the ObjectHash in the requested key.

The object identity is independent of tar header layout, entry order, implicit parent directories, ownership, timestamps, and ignored mode bits. A publisher is not required to produce one canonical sequence of tar bytes. The immutable write-once object key selects the first valid wire representation published for that logical object.

Repository format version 1 deliberately accepts only POSIX ustar logical entries and the GNU LongName and LongLink extensions needed for long raw byte strings. PAX, sparse-file formats, and other extensions are rejected. The full entry, path, checksum, padding, termination, and safe-materialization rules are specified in TAR.md. A repository implementation must use the same accepted normalized model for hashing and materialization; invoking a general-purpose system tar -xf is not sufficient.

Compression

Repository format version 1 defines two compression values:

  • identity: the encoded payload is already the file bytes or tar stream;
  • zstd: the encoded payload is exactly one standard Zstandard frame whose decoded bytes are the file bytes or tar stream.

A zstd payload must not require an external dictionary. The decoder must consume the complete encoded payload, finish exactly at its end, and produce exactly decoded_size bytes. A trailing frame or trailing non-frame data is an error.

The publisher chooses compression independently for each object. It can leave already-compressed inputs as identity and use zstd where it provides a useful reduction. The choice does not affect ObjectHash.

A deterministic definite-length byte string requires the publisher to know the encoded payload size before writing the CBOR prefix. For zstd, a publisher may first write the compressed representation to a temporary file and then stream that file into the CBOR object or multipart upload. This requirement consumes temporary disk space but does not require holding the object in memory.

Storage normalization

Successful decoding reconstructs the canonical ordinary-object storage representation. Only metadata represented by ObjectHash is retained from the transported object; all other filesystem metadata is normalized.

For a root file and every regular file in a directory:

  • executable state is false or true according to the file metadata or tar mode expression mode & 0o111 != 0;
  • the normalized mode is 0644 when non-executable and 0755 when executable.

For every directory, the normalized mode is 0755. All entries receive the uid and gid of the user that owns the working store. Symbolic-link targets are preserved literally because they participate in object identity; symbolic-link ownership is normalized to the same user and group. ACLs, extended attributes, set-id bits, and all other transported metadata are discarded.

The canonical timestamp for ordinary objects is:

CANONICAL_TIMESTAMP = 315532800.000000000
                      # 1980-01-01 00:00:00 UTC

Both atime and mtime are set to this value for:

  • a root file object;
  • every regular file in a directory object;
  • every directory, including the root directory object;
  • every symbolic link, without following the link.

Ownership and modes are normalized before timestamps. Directory timestamps are applied after all descendants have been created, in post-order, so subsequent materialization does not change them. Symbolic-link timestamps are applied with no-follow semantics. Failure to apply the canonical timestamps rejects the import.

ctime, birth time, and any other filesystem-maintained timestamps are not controllable portable object metadata and are outside the canonical storage representation. A later access may also update atime according to the working store filesystem’s mount policy; neither such change participates in ObjectHash or invalidates the object.

Normalization is part of ordinary-object storage semantics, not an optional security transformation. Two accepted representations with the same ObjectHash materialize as the same canonical local object.

The same contract applies when an ordinary object reaches the working store through a local content source rather than this remote representation. A copy transport normalizes its staging object before publication. A transport that reuses the source representation directly may do so only when that representation already satisfies this contract; otherwise the transfer is rejected. Content-source choice must not determine the resulting stored modes, ownership, or initial timestamps.

Reader procedure

A reader importing o/<ObjectHash>:

  1. Applies the repository format encoded-content limit before parsing.
  2. Parses and validates the deterministic two-element CBOR envelope and its metadata without allocating the payload size.
  3. Reads exactly the definite byte-string length through the selected decompressor while enforcing resource and decoder-window limits.
  4. Counts decoded bytes and requires the count to equal decoded_size.
  5. For a file, hashes the decoded bytes with the declared executable state and writes a normalized staging file.
  6. For a directory, validates the complete repository tar profile, hashes the normalized tree, and safely materializes a normalized staging directory from the same parsed entries.
  7. Requires the resulting hash to equal the expected ObjectHash.
  8. Requires the payload and enclosing CBOR value to end exactly where declared.
  9. Atomically publishes the verified staging object into the working store.

Any malformed CBOR, unknown metadata, unsupported object kind, unsupported compression, size mismatch, decompression failure, invalid tar entry, unsafe path, trailing data, or object-hash mismatch rejects the complete object.

Fs-tree manifests

An fs-tree manifest is an ordinary non-executable file object in this format. It uses kind = "file", executable = false, and its canonical JSONL manifest bytes as the decoded payload. Once the manifest is verified, its referenced filesystem files are resolved independently through f/<FsFileHash>.

The f/ wire format is outside the scope of this document. Unlike an ordinary object, an fs-file has a separate identity that includes uid, gid, and its full mode. Its wire representation is specified in FS_FILE.md and fs-file.cddl.

Standards

  • CBOR and deterministic encoding: RFC 8949.
  • CDDL: RFC 8610.
  • Zstandard: RFC 8878.
  • Directory transport tar profile: TAR.md.
  • Tar identity and normalization: Bobr Filesystem Object Hashing.

Remote repository filesystem file

This document specifies the repository format version 1 wire format of immutable filesystem files stored under f/<FsFileHash>. The normative CDDL schema is in fs-file.cddl.

Identity

The key has this form:

f/<lowercase FsFileHash hex>

An fs-file is always one regular file. Its identity includes its complete logical ownership and mode as well as its size and byte content. It is distinct from the ObjectHash domain used for ordinary objects.

Repository format version 1 computes FsFileHash as:

sha256(
    b"bobr:fs-file:v1\0"
    || uid:u32be
    || gid:u32be
    || mode:u32be
    || size:u64be
    || sha256(file_bytes)
)

Here mode is in 0..=0o7777, size is the decoded file size, and file_bytes is the decoded payload. All mode bits in that range participate in identity; they are not reduced to an executable flag. The CBOR envelope and compression do not participate in FsFileHash.

An fs-file key is write-once. A publisher that finds an existing f/<FsFileHash> reuses it and must not replace it with another equivalent encoding or compression choice. This gives the immutable URL stable response bytes and cache validators.

HTTP representation

An fs-file response uses:

Content-Type: application/vnd.bobr.repository-fs-file+cbor
Cache-Control: public, max-age=31536000, immutable
Content-Encoding: absent

The repository may use a longer freshness lifetime. A client must still validate the complete decoded identity before admitting the file to its working store.

CBOR envelope

The response body is one untagged CBOR array with exactly two elements:

[
    metadata,
    payload: bstr,
]

It uses the core deterministic encoding requirements of RFC 8949 section 4.2.1. Indefinite-length arrays, maps, text strings, and byte strings are forbidden. Integers and lengths use their shortest encoding, map keys are in deterministic order, unknown fields are rejected, and no bytes may follow the array.

The payload is a definite-length CBOR byte string and is the final value in the array. Its length is the encoded payload length, before any decompression. A reader can parse the small metadata map and byte-string length, then stream exactly that many bytes through the selected decoder without holding the file in memory. A compliant reader must not require one allocation proportional to the payload size.

The envelope contains neither an embedded format version nor an embedded FsFileHash. The signed master selects repository_format, and the requested URL supplies the expected identity. Any incompatible change to this format or its CDDL schema requires a new repository format version.

Metadata

Metadata has this logical form:

{
    "uid": uint32,
    "gid": uint32,
    "mode": 0..0o7777,
    "compression": "identity" | "zstd",
    "decoded_size": uint64,
}

uid, gid, mode, and decoded_size all participate directly in the FsFileHash computation. Producing fewer or more decoded bytes than decoded_size declares is an error.

decoded_size also supports progress reporting and resource limits. A reader must not use an untrusted value to allocate that amount of memory. The encoded payload length is already carried by the definite-length CBOR byte string and is not repeated in metadata.

Payload and compression

After decompression, the payload is exactly the regular file’s byte content. Repository format version 1 defines two compression values:

  • identity: the encoded payload is already the file bytes;
  • zstd: the encoded payload is exactly one standard Zstandard frame whose decoded bytes are the file bytes.

A zstd payload must not require an external dictionary. The decoder must consume the complete encoded payload, finish exactly at its end, and produce exactly decoded_size bytes. A trailing frame or trailing non-frame data is an error.

The publisher chooses compression independently for each fs-file. Small or already-compressed files can remain identity; larger compressible files can use zstd. The choice does not affect FsFileHash.

A deterministic definite-length byte string requires the publisher to know the encoded payload size before writing the CBOR prefix. For zstd, a publisher may first write the compressed representation to a temporary file and then stream that file into the CBOR object or multipart upload. This requires temporary disk space but no allocation proportional to the file size.

Storage representation

Unlike an ordinary object, an fs-file preserves the exact identity metadata declared by the envelope:

  • uid is exactly metadata.uid;
  • gid is exactly metadata.gid;
  • mode is exactly metadata.mode.

This includes all permission, executable, set-user-ID, set-group-ID, and sticky bits representable by 0o7777. They must not be normalized to ordinary-object modes.

Metadata not represented by FsFileHash is normalized. Bobr stamps both atime and mtime to 315532800.000000000, which is 1980-01-01 00:00:00 UTC and the current CANONICAL_TIMESTAMP. Other timestamps, ACLs, extended attributes, file capabilities, and inode topology are not part of fs-file identity.

The normalization order matters when importing ownership that differs from the host user:

  1. Write the decoded bytes to a private staging file.
  2. Set canonical atime and mtime while the staging file is still owned by the importing process.
  3. Change ownership to the declared uid and gid.
  4. Set the declared mode.
  5. Verify the resulting file metadata and identity.
  6. Atomically publish it in the working store.

Applying arbitrary logical ownership normally requires Bobr’s namespace runtime. Network transfer and content hashing may run asynchronously without a namespace, but final normalization and publication must use a namespace-capable local operation when the declared owner differs from the host user.

Reader procedure

A reader importing f/<FsFileHash>:

  1. Applies the repository format encoded-content limit before parsing.
  2. Parses and validates the deterministic two-element CBOR envelope and its metadata without allocating the payload size.
  3. Reads exactly the definite byte-string length through the selected decompressor while enforcing resource and decoder-window limits.
  4. Writes decoded bytes to a private staging file while computing their SHA-256 digest and length.
  5. Requires the decoded length to equal decoded_size.
  6. Computes FsFileHash from the declared uid, gid, mode, decoded size, and content digest.
  7. Requires the result to equal the expected FsFileHash.
  8. Requires the payload and enclosing CBOR value to end exactly where declared.
  9. Applies canonical timestamps and the declared ownership and mode through the appropriate local runtime.
  10. Verifies the staged regular file again and atomically publishes it in the working store.

Any malformed CBOR, unknown metadata, out-of-range mode, unsupported compression, size mismatch, decompression failure, trailing data, metadata application failure, or fs-file-hash mismatch rejects the complete file.

Manifest integration

An fs-tree manifest regular-file entry contains only its path and FsFileHash:

{"p":"usr/bin/tool","t":"f","h":"<FsFileHash>"}

The hash binds the file bytes and the uid, gid, and mode recovered from this envelope. A client first confirms that the hash appears in a current filesystem-file list under lf/<FsFileListHash>, imports and verifies the corresponding f/<FsFileHash>, and then materializes the fs-tree by hardlinking the canonical local fs-file into place.

Standards

Remote repository directory tar profile

This document specifies the tar profile used for directory payloads in Bobr remote repository objects. It is part of repository format version 1 and is referenced by OBJECT.md.

The profile applies only to decoded directory payloads under o/<ObjectHash>. It does not restrict tar archives stored as ordinary file objects, upstream source archives, or the generic archive input accepted by fsobj-hash.

Purpose

Tar is a family of related formats rather than one unambiguous wire format. Different readers may otherwise disagree about PAX overrides, GNU extensions, sparse files, path bytes, or archive termination. A repository reader must not hash one interpretation of an archive and materialize another.

Repository format version 1 therefore uses a deliberately small profile. The publisher controls this transport representation and does not need to preserve the original archive representation of an object.

Base format

Logical entries use POSIX ustar headers. The standard ustar name and prefix fields jointly represent an entry path when it fits. The profile additionally supports the GNU LongName and GNU LongLink extension entries described below.

The following tar extensions are not part of this profile and must be rejected:

  • local and global PAX extended headers;
  • GNU and PAX sparse-file representations;
  • old GNU multi-volume and continuation entries;
  • all other vendor-specific or unknown extension entry types.

The restriction is intentional. PAX string semantics do not provide the raw Unix-byte model required by ObjectHash, while GNU LongName and LongLink are sufficient to represent long raw paths and symbolic-link targets.

Logical entry types

Only these logical tar entry types are accepted:

  • regular file, encoded with type flag NUL or 0;
  • directory, encoded with type flag 5;
  • symbolic link, encoded with type flag 2.

Hard links, block and character devices, FIFOs, sockets, sparse files, and all other logical entry types are rejected.

A regular-file entry contains exactly the number of bytes declared by its effective size. Directory and symbolic-link entries have size zero and no data payload. A symbolic-link target must be non-empty.

GNU long-name extensions

A path that cannot be represented by the standard ustar name and prefix fields is represented by one GNU LongName entry:

  • its type flag is L;
  • its header path is ././@LongLink;
  • its data is the complete raw path followed by exactly one NUL byte;
  • its declared size includes that final NUL;
  • it applies only to the immediately following logical entry, except that one GNU LongLink entry may occur between them.

A symbolic-link target that cannot be represented by the ustar linkname field is represented analogously by one GNU LongLink entry with type flag K. It applies only to the immediately following symbolic-link entry.

When both extensions are needed, the publisher emits LongName first, LongLink second, and the logical symbolic-link entry last. No entry may have more than one extension of either kind. An extension without a following applicable logical entry is invalid.

The extension data contains no embedded NUL. After applying an extension, the corresponding field of the logical entry is ignored for object semantics. A publisher uses an extension only when the value does not fit in the standard ustar fields.

GNU long-name entries are transport metadata. They do not themselves create nodes in the normalized object tree.

Path and string model

Entry paths and symbolic-link targets are raw Unix byte strings. They are not decoded as UTF-8 and undergo no Unicode normalization. NUL cannot occur in either value.

An effective path after applying any GNU LongName extension is limited to 65,536 bytes. It is then normalized using the ordinary-object rules:

  • an effective path beginning with / is rejected before component normalization;
  • one or more leading ./ components are removed;
  • empty components and . components are removed;
  • repeated / bytes are collapsed;
  • a trailing / on a directory entry is ignored;
  • any .. component is rejected;
  • a path that becomes empty is rejected.

Backslash has no separator semantics. / is the only path separator.

A symbolic-link target is preserved literally and participates in ObjectHash. It may be absolute or contain ..; those bytes describe the link rather than an extraction destination. Materialization must nevertheless never follow a symbolic link while resolving the destination of a later entry.

Numeric fields and checksums

Standard ustar octal encoding is accepted for numeric fields. GNU base-256 encoding is additionally accepted for a regular-file size when the value does not fit in the ustar octal field. A publisher uses base-256 only in that case.

Sizes must be non-negative and fit in an unsigned 64-bit integer. Offset and padding calculations must reject integer overflow. Mode and other numeric fields must be syntactically valid even when their values do not participate in object identity.

Every header, including a GNU extension header, must have a valid standard tar checksum. Readers use the unsigned-byte checksum calculation and reject a header that requires a non-standard signed-byte interpretation.

Metadata and normalization

For a regular file, only whether any executable bit is set participates in the logical object:

executable = mode & 0o111 != 0

Directory and symbolic-link modes do not participate in identity. uid, gid, uname, gname, mtime, and all other non-structural header fields are ignored after syntactic validation. ACLs, extended attributes, and other metadata extensions cannot be transported by this profile.

The reader applies the storage-normalization rules specified by OBJECT.md, including canonical atime and mtime for every materialized node. It does not preserve ignored tar metadata.

Tree construction

The tar stream describes the children of an implicit root directory. It does not contain a logical entry for that root.

Entries may occur in any order. Missing parent directories are synthesized. An explicit directory entry may repeat an already explicit or synthesized directory entry and has no additional effect. A repeated regular-file or symbolic-link path is rejected. Any conflict between entry kinds is rejected, including a regular file or symbolic link used as the parent of another path.

After path normalization, child entries are ordered by raw name bytes for ObjectHash computation. Archive entry order does not participate in object identity.

Padding and archive termination

Every header and data area uses standard 512-byte tar blocks. Bytes between the end of an entry’s declared data and the next block boundary must be zero.

The archive ends with exactly two all-zero 512-byte blocks followed immediately by the end of the decoded object payload. One zero block is insufficient. Additional zero blocks, concatenated archives, and any trailing bytes are rejected. An empty directory is represented by the two terminating blocks and no logical entries.

These bytes do not participate directly in ObjectHash, but requiring one termination form prevents ignored or ambiguously interpreted data from being carried by a verified object.

Resource limits

Parsing and hashing are streaming operations. A reader does not need to retain regular-file contents in memory, but it must retain or externally sort enough normalized structural information to compute a directory hash independently of archive entry order.

A reader enforces the exact version 1 limits listed in MASTER.md for:

  • total decoded size;
  • individual regular-file size;
  • number of entries;
  • path and symbolic-link-target lengths;
  • tree depth;
  • in-memory structural metadata.

Limits are checked before allocation where possible and throughout decoding. The structural-byte limit is the sum of the normalized effective path length and, for symbolic links, literal target length over all logical entries. GNU extension headers do not count as additional logical entries. Exceeding any format limit rejects the object without publishing a partial result.

Reader and materializer invariant

One parser applies this profile and produces normalized entries. Those same entries drive both ObjectHash computation and safe staging-tree materialization:

decoded tar bytes
    -> validated normalized entries
        -> ObjectHash
        -> canonical staging directory

Hashing with one tar parser and subsequently invoking a general-purpose tar -xf implementation is not conforming. Extraction uses a private staging directory. Paths are validated against the parsed tree; absolute paths, parent traversal, and entries beneath symbolic links are rejected. The staging tree must remain under the importing process’s exclusive control during extraction and verification.

The staging directory is published only after the complete tar stream, terminator, decoded size, enclosing CBOR value, and expected ObjectHash have all been verified.

Standards

  • POSIX ustar archive format: POSIX.1-1988 and later tar specifications.
  • GNU LongName and LongLink: GNU tar archive extensions.
  • Ordinary-object identity: Bobr Filesystem Object Hashing.

Build logging

bobr records the progress of a build as structured events. This document fixes the contract: the channels, the on-disk layout, the event record, the closed status vocabulary, and the format guarantees tooling may rely on.

Channels

There are three output channels with distinct purposes:

  • Run logs (the request’s logs directory) are the single source of truth for a run, for both humans (rendered) and machines (JSONL). Durable build-significant events go here; transient progress is screen-only. Diagnostics before logging is initialized and logging failures may appear only on stderr. The caller names the directory and creates it; by convention it is <store>/logs/<run-id> (see Request).
  • stderr carries the live UI and human diagnostics. The progress renderer projects structured events onto the screen; CLI diagnostics and logging-error notices may also write there. In an interactive terminal (and not quiet) it draws a live block (run summary, fetch/build statistics, then a bounded activity viewport, with warnings/errors printed above) via indicatif; otherwise (non-TTY, e.g. CI or a pipe, or quiet) it falls back to plain per-line output. Transient progress ticks appear only in the live block — the plain path omits them. How much it shows is a threshold (see Levels and verbosity).
  • stdout carries the machine-readable result: a single goal prints its ObjectHash, while multiple goals print their ordered JSON results. Moving the result into a store file is a related, separate concern and is not part of the logging contract.

Live viewport

The request’s progress policy controls only an interactive TTY. Every live block has this fixed order:

run    <whole-run summary>
fetch  <Source and repository acquisition statistics>
build  <builder statistics>
<N activity rows>

The three status rows always exist; activity rows are additional. Their budget is stable across ordinary activity events and is recomputed on terminal resize.

  • auto caps activity rows at the greater of jobs and four, and at three quarters of the terminal height. The complete block also leaves at least two terminal rows free when space permits. Small terminals can reduce activity capacity below four;
  • fixed caps the complete block at max_lines rows (minimum four), with the same terminal-space and concurrency caps;
  • summary keeps the three fixed rows and hides individual activity rows.

An activity row represents a concrete builder, network acquisition, repository copy, or batched fs-file hardlink import. Empty rows display —. Local Path Sources, working-store hits, and short ordinary-object hardlinks update fetch statistics without taking a row. A potentially long fs-file hardlink import takes one stable row for the batch, not one row per linked file. A network Source waiting for a connection slot updates only fetch statistics. Network activities take a row at their first running network milestone. Repository copies take a row when copying starts. Remote metadata and content transfers use the same activity viewport.

The renderer keeps every activity in its model even when only part fits on screen. There are separate FIFO queues for hidden builders and acquisitions. When a row becomes free, the oldest hidden builder takes it; if there is none, the oldest hidden acquisition does. The policy affects display only, never execution. A visible failed builder remains until the run finishes. Network activities with details.network_lifecycle = true release their row on failure so a failed transfer does not occupy it throughout fallback to another provider; the diagnostic remains above the live block and in the log.

The fetch row reports current transferring, connection-slot waiting, complete, transient retrying, and failed counts. The build row reports workers running, jobs waiting in the bounded BuildExecutor FIFO, complete, and failed. The run row reports whole-run outcomes plus the number of builders and acquisitions hidden only by the viewport.

The initial acquisition total is the number of reachable Source nodes. SecondaryContent and RepositoryMetadata activities extend it when first observed, including individual remote fs-file transfers. Lazy resolution cannot know all repository work at run start. Acquisition counts are therefore not counts of distinct built objects or just upstream source downloads.

The number of visible rows never limits builder execution. A Tokio task listens for SIGWINCH and immediately asks the logger to reflow the viewport; build events update its content independently. On resize, visible activities keep their order as far as possible; removed rows return to their kind’s FIFO and new rows are filled with the same builder-first rule. Non-TTY and quiet output never emit terminal control sequences.

On-disk layout

<logs>/
  context.json                         optional driver-owned run provenance
  recipe-catalog.json                  optional driver-owned name/tag catalog
  events.jsonl                         run-level event log (the full audit log)
  index.jsonl                          workspace allocation index
  <serial>-<tag>[-<name>]/             one directory per built/materialized subject
    meta.json                          subject identity and paths
    events.jsonl                       this subject's events only
    raw/                               raw logs (step stdout/stderr, reports, …)

The run-level events.jsonl is created exclusively: a run given a log directory another run already wrote to fails at once, rather than appending to someone else’s audit trail. It is the full audit log of the run: it contains run-level events plus a copy of every bound subject event. Each bound subject’s own events.jsonl contains only that subject’s events. Its run-level and subject-level copies are byte-identical, so tooling can match them.

Events may carry a subject identity without being bound to a subject workspace. Cache hits and repository network events use this form: they appear only in the run-level log and do not create a subject directory or subject_seq.

context.json and recipe-catalog.json are not part of the event protocol and are not written by the Bobr logger. The standard recipes driver places them beside the event log so inspection tools can associate a store mapping with the Bobr/recipes versions and human-facing recipe labels observed by that run.

Event record

Each line of events.jsonl is one JSON object. The producer (builder, scheduler) supplies only the payload; the logger adds the envelope, so a producer can neither forge nor omit envelope fields.

{
  "schema": "bobr-build-event-v1",   // format version
  "seq": 412,                        // monotonic per run; primary ordering
  "subject_seq": 3,                  // monotonic per bound subject; optional
  "ts": "2026-06-23T21:21:51.229Z",  // UTC, RFC3339, milliseconds, always 'Z'
  "level": "info",                   // info | warn | error
  "status": "done",                  // closed lifecycle enum (see below)
  "op": "sandbox",                   // optional, free-form builder operation
  "subject": {                       // optional identity, even without a workspace
    "tag": "Sandbox",
    "name": "qemu-image",
    "build_key": "<full build key>",
    "object_hash": "<full object hash>"   // optional, when an object identity is known
  },
  "message": "…",
  "raw_log": "00000009-Sandbox-bash/raw/sandbox-result.log",  // optional, run-relative
  "details": { }                     // optional
}

Field notes:

  • run_id is not a field. The request names the run, and by convention its log directory is named after it; the id is constant for the whole run, so it is not repeated per line.
  • seq is the primary order: a run-global monotonic counter, stamped once when the event is emitted. It is reliable regardless of timestamp granularity or parallelism. ts is secondary.
  • subject_seq orders a single subject’s events; it is present only on events bound to a subject.
  • subject.build_key contains the full BuildKey for recipe subjects. For synthetic repository activities, the historical field name holds an opaque activity identifier, which is not necessarily a hash. Tooling must use the subject tag and event context rather than parse every such value as BuildKey.
  • object_hash, when present, contains the full ObjectHash. Display text and synthetic activity names may include 12-character prefixes; those are not substitutes for full identities.
  • raw_log is relative to the run directory; the stderr renderer rejoins the run directory to show an absolute path.

status (closed) vs op (free-form)

status is a fixed, closed enum — the lifecycle axis tooling filters on. op is an optional, free-form builder operation. They are separate fields so that filtering by lifecycle is reliable while builders stay free to name their work.

status values:

valuemeaning
run-startedrun-level: realization started (goals, jobs, reachable counts, progress and repository policies)
run-finishedrun-level: realization finished (goals or error class, counters)
startsubject execution started
cache-missno reusable result at this point; acquisition or execution follows
runningbuilder/source work or repository acquisition is running
cache-hitsubject served from cache (no workspace; run-level)
donerecipe or acquisition completed; object_hash is optional
failedsubject or run failed
cancelledcancelled
cleanuppost-execution cleanup (e.g. temp-dir removal warning)

Builder and Source operations ride inside running and name themselves with op; publication and other lifecycle events may also carry it. Examples include sandbox, fetch, publish, and compose. op is intentionally open; tooling must not assume a closed set.

Run-level events

The run-level events.jsonl is a full audit log, not just an aggregate of subject events. Beyond the fanned-out subject events it carries:

  • run-started: ordered goals, jobs, total reachable nodes, reachable builders and Sources, the selected progress policy, and ordered providers. Each provider records its name, capability, and backend kind: local backends include the canonical store path and content transfer mode, remote backends the master URL. Signing-key contents, credentials, and authorization headers are not included; there is no legacy trusted flag;
  • cache-hit: one per subject served from a reusable object during realization (carries the subject identity and object_hash), including reuse discovered within the current run. A fully exact-cached run records only the resolved boundary, not pruned interior subtrees;
  • run-finished: realized goals or details.error_class, plus exact terminal counters: built, cache_hit, failed, cancelled, downloaded, local, secondary, hardlinked, copied, remote, and already_present. hardlinked, copied, and remote count completed known-object acquisitions using hardlink, copy, or download transport, respectively; they are not counts of individual fs-files or HTTP requests. A mixed fs-tree closure can contribute to several transport counters. Retry totals and logging failures are included when present.

Source terminal/cache events carry details.source_outcome with one of downloaded, local, secondary, or already_present. Network milestones carry details.transfer = "network", host, and optional byte counters; local materialization uses transfer = "local".

Trusted mapping and content provenance use separate events and fields. A secondary-build or secondary-reuse run event carries details.mapping_providers, each with the configured provider name and asserted object_hash; it never claims that the same repository supplied bytes. Physical repository acquisition uses a synthetic SecondaryContent subject. Its running milestones carry content_provider, transfer_mode, and byte, file, and duration measurements. The terminal repository-content event carries the aggregate content_providers, content_outcomes, files, bytes, encoded_bytes, decoded_bytes, and duration_ms. Transfer-mode values are hardlink, copy, or download. This records split closures accurately when the manifest and its fs-files come from different repositories or use different transports.

Remote network events

Repository network lifecycle events have op = "repository-metadata" or "repository-content" and details.network_lifecycle = true. Their synthetic subject tags are RepositoryMetadata for master/index/list requests and SecondaryContent for object/fs-file requests. They are run-level-only events, not one allocated workspace per downloaded fs-file.

Details identify repository, repository_class, repository_purpose, host, and transfer = "network". Start/progress/completion records carry attempt and byte measurements when available; retries carry retry_host, retry_delay_ms, and retry_reason. Cancellation is reported with status = "cancelled". Network byte measurements describe encoded response bodies; aggregate content events distinguish them from decoded content sizes.

Expected lookup misses do not produce warning/error diagnostics or their own activity rows. Failure to retrieve or verify advertised content is reported separately and may be followed by acquisition from another provider.

Levels and verbosity

Levels are progress, info, warn, error (ordered progress < info < warn < error).

progress is a transient, screen-only level for high-frequency ticks (e.g. download byte counts). It appears only in the interactive live block, and is never persisted — FileSink drops it, so the on-disk vocabulary is only info/warn/error, and progress ticks do not consume the durable seq (no gaps in the file). Use info for durable milestones worth keeping (e.g. “fetching X” / “fetched N bytes”); use progress for the noisy in-between.

quiet (a boolean request setting) raises the stderr threshold, rather than being an on/off switch:

  • quiet = true: only warn/error reach stderr; info and progress are silenced.
  • otherwise: info, warn, and error reach stderr, plus — in the interactive live block — transient progress ticks.

warn/error are never suppressed on stderr. The threshold affects only stderr; file logs always record every persisted level (info/warn/ error) regardless of quiet — progress is screen-only by design.

Guarantees

  • Best-effort. A failed log write prints a warning to stderr but never fails the build. Such failures are counted and reported as logging_errors in the run-finished event’s details (a write failure of run-finished itself is not reflected in its own count).
  • Durability. File logs are buffered: routine info events are not flushed per event (fewer syscalls). warn/error events and the terminal run-finished event are flushed immediately so anything diagnostically relevant survives a process crash; run-finished additionally fsyncs the run log so a completed run is durable across power loss. Remaining buffered events are flushed when the logger is dropped (normal exit or panic-unwind); a SIGKILL can lose the unflushed tail.
  • Ordering. seq is authoritative for run order; ts is informational. Per-subject order is subject_seq.
  • Timestamps are honest UTC (RFC3339, milliseconds, trailing Z), never local time.
  • Portability. raw_log paths are relative to the run directory, so logs survive a moved store.
  • Schema. schema is bumped when the record format changes; readers should check it.

Filesystem Object Hashing

This document defines the hashing rules for filesystem objects.

Scope. These rules define how bobr computes the ObjectHash of ordinary objects — regular files, directories, and tar archives. The algorithm deliberately ignores ownership (uid/gid) and every mode bit except the executable bit. Filesystem trees are different: they make ownership and mode part of file identity, so their files are content-addressed by a separate fs-file hash, described in Filesystem trees and the fs-tree Manifest. ObjectHash and FsFileHash are distinct semantic types and must not be interchanged; this does not imply that their digest values can never coincide.

Summary

Filesystem object hashing computes structural hashes for two input sources:

  • a filesystem path, whose root may be a regular file or a directory
  • a tar archive, which always describes a root directory

The invariant is:

hash_path(dir) equals hash_tar_reader(tar(dir)) whenever the tar archive represents the same normalized tree under the rules below.

Supported Object Kinds

Supported root objects:

  • regular file
  • directory

Supported entries inside a directory or tar-described tree:

  • regular file
  • directory
  • symlink

Rejected cases:

  • root symlink
  • device nodes
  • fifo
  • socket
  • tar hardlinks
  • any unsupported tar entry kind

Hash Identity Rules

Regular file

A regular file hash depends on:

  • file bytes
  • executable bit only

A file is executable exactly when (mode & 0o111) != 0: any owner, group, or other execute bit is set. This uses the filesystem mode for path input and the regular-file header mode for tar input. All other mode bits are ignored.

A non-executable regular file hashes to exactly the SHA-256 of its bytes. Its object_hash therefore equals the digest upstreams publish (crates.io and Cargo.lock, sha256sum, release checksums), so a source can be pinned from a published checksum without fetching and re-hashing it first.

An executable regular file uses a tagged hash instead (a SHA-256 over a domain-separation tag, the executable bit, the size, and the SHA-256 of the bytes). Executable and non-executable files with identical bytes therefore use different hash inputs — the executable bit is part of file identity. The asymmetry is deliberate: the “hash equals plain SHA-256” property holds only for non-executable files, which is the common case for source archives and crates. Directory and symlink hashes use their own tags, but these do not separate them from arbitrary non-executable file bytes; see Cross-kind ambiguity.

This is intentionally narrower than install semantics:

  • install metadata controls the final installed uid, gid, and unix mode
  • the payload executable bit is preserved as part of file identity
  • rootfs composition uses that bit only to choose between regular_file_mode and executable_file_mode

A symlink hash depends on:

  • raw symlink target bytes

The target is not resolved. Target existence is irrelevant.

Directory

A directory hash depends on:

  • child entry names
  • child entry kinds
  • child hashes

Child entries are sorted by raw name bytes.

Exact hash input encoding

Let SHA256 return a raw 32-byte digest, || mean byte concatenation, and U64BE(n) encode an unsigned integer as exactly eight bytes in big-endian order. All lengths count bytes. Tags are literal ASCII bytes followed by one NUL byte (\0), not the two characters backslash and zero:

FILE_TAG    = b"fsobj:file:v1\0"
DIR_TAG     = b"fsobj:dir:v1\0"
SYMLINK_TAG = b"fsobj:symlink:v1\0"

non_executable_file(bytes) = SHA256(bytes)
executable_file(bytes)     = SHA256(FILE_TAG || 0x01 || U64BE(len(bytes))
                                   || SHA256(bytes))
symlink(target)           = SHA256(SYMLINK_TAG || U64BE(len(target)) || target)

entry(child) = kind(child) || U64BE(len(name(child))) || name(child) || hash(child)
directory(children) = SHA256(DIR_TAG || entry(child_1) || ... || entry(child_n))

The executable marker is one byte, 0x01. Entry kinds are one ASCII byte: f (0x66) for a file, d (0x64) for a directory, and l (0x6c) for a symlink. A child name is a single raw path component, not its full relative path. Children are ordered lexicographically by unsigned name bytes, with a shorter prefix before a longer name. Child hashes are recursively computed 32-byte digests, not hexadecimal strings. There is no child count, separator, padding, or terminator after the directory entries; an empty directory hashes only DIR_TAG.

Cross-kind ambiguity

The current encoding does not guarantee distinct identities across object kinds. An empty directory and a non-executable file containing exactly the 13 bytes b"fsobj:dir:v1\0" have the same ObjectHash:

b9fc118a1a95607accd6d739f6c0caa8658c415677c522ce48caa9d003f6e077

The file includes a final NUL byte and no newline. This is not a cryptographic SHA-256 collision: both objects supply exactly the same bytes to SHA-256. More generally, a non-executable file can contain the encoded hash input of a tagged object. The tags separate the tagged encodings from each other, but not from the unrestricted bytes of non-executable files. An ObjectHash alone therefore does not uniquely identify an object kind; hash-keyed storage can encounter different kinds with the same identity. This is a limitation of the current algorithm, not a guarantee that such inputs are rejected.

Leaf Hashes

The fsobj-hash library API hash_path_with_leaf_index returns an IndexedPathHash containing the root ObjectHash and a transient LeafIndex. The CLI does not expose this index; it prints only the root ObjectHash. The index records only regular file and symlink node hashes, keyed by object-relative path. Directory entries are intentionally not part of that leaf set.

Current fs-tree manifest objects do not use this leaf index as their object format. The manifest text is a normal store object payload. Regular file payloads referenced by the manifest live under <store>/fs-files/ and use their own fs-file hashes.

Ignored Metadata

The hash ignores:

  • absolute root path
  • uid/gid
  • uname/gname
  • mtime/ctime/atime
  • xattrs and ACLs
  • directory mode
  • symlink mode
  • tar header layout
  • tar entry order

Tar Normalization Rules

These rules describe generic tar hashing through fsobj-hash’s hash_tar_reader and hash_tar_file APIs and the CLI’s tar mode. They are not a transport-validation profile. Remote repository directory payloads must also satisfy the stricter repository tar profile, including its header, extension, padding, termination, and resource limits. An archive accepted by generic hashing need not be valid repository content.

Tar archives are normalized before hashing.

Allowed normalizations:

  • ./foo becomes foo
  • repeated / are collapsed
  • trailing / on directory entries is ignored

Rejected archive paths:

  • absolute paths
  • paths containing ..
  • paths that normalize to empty

Implicit parent directories are synthesized. For example, a/b/c.txt implies the existence of a/ and a/b/ even if the archive does not list them explicitly.

Repeated explicit directory entries are allowed as no-op. Duplicate file or symlink entries are rejected. Any path kind conflict is rejected.

CLI

The fsobj-hash crate also provides a small bobr-fsobj-hash helper binary:

bobr-fsobj-hash <path> [--mode=auto|direct|tar]

It prints a single lowercase hex object_hash to stdout.

bobr-fsobj-hash --help prints the command usage and available modes.

Modes

  • direct
    • hash the given filesystem path with hash_path
  • tar
    • hash the given tar archive with hash_tar_file
  • auto
    • if <path> is a directory, use hash_path
    • if <path> is a regular file whose basename ends with .tar, use hash_tar_file
    • otherwise use hash_path

Tar From Stdin

The CLI supports tar input from stdin only in explicit tar mode:

bobr-fsobj-hash - --mode=tar

- is not accepted in auto or direct mode.

Path Encoding Boundary

The generic fsobj-hash algorithm treats directory entry names and symlink targets as raw Unix bytes. It can hash paths that are not valid UTF-8.

bobr fs-tree manifest paths and symlink targets are UTF-8-only because they are serialized as JSON strings. This is a property of the fs-tree manifest format, not of the generic fsobj hash algorithm.

fs-tree Manifest

Summary

This document is the normative definition of the fs-tree manifest format.

fs-tree manifest is the canonical manifest format for manifest-addressed filesystem trees. It describes filesystem tree structure, directory metadata, symlink metadata, and references to regular file payloads stored in <store>/fs-files/.

The manifest itself is an ordinary non-executable file object, produced by builders such as FsTreeImport, OciExtract, TreeMerge, TreeMove, TreeSubset, and SandboxInstall. Materialized roots are cache entries under <store>/fs-trees/<manifest-object-hash>/, not the payload stored in <store>/objects/.

Canonical JSONL Format

The manifest is UTF-8 JSON Lines. The first line is a mandatory schema header:

{"schema":"bobr-fs-tree-manifest"}

Each following line is one filesystem entry:

{"schema":"bobr-fs-tree-manifest"}
{"p":"","t":"d","u":0,"g":0,"m":493}
{"p":"bin","t":"d","u":0,"g":0,"m":493}
{"p":"bin/tool","t":"f","h":"1111111111111111111111111111111111111111111111111111111111111111"}
{"p":"bin/tool-link","t":"l","u":0,"g":0,"x":"tool"}

Every line ends with \n, including the final line. Empty manifests, empty lines, missing final newlines, extra whitespace, and non-canonical field order are invalid.

Entry Types

Directory entry:

{"p":"usr/bin","t":"d","u":0,"g":0,"m":493}
  • p: relative UTF-8 path.
  • t: entry type, always "d".
  • u: logical uid, unsigned 32-bit integer.
  • g: logical gid, unsigned 32-bit integer.
  • m: mode, unsigned integer in 0..=0o7777.

Regular file entry:

{"p":"usr/bin/tool","t":"f","h":"<fs-file-hash>"}
  • p: relative UTF-8 path.
  • t: entry type, always "f".
  • h: opaque fs-file hash, encoded as exactly 64 lowercase hex digits.

Regular file bytes, uid, gid, and mode are not stored in the manifest. They are part of the referenced fs-file object. The fs-file hash algorithm is not defined by this document.

Symlink entry:

{"p":"bin/sh","t":"l","u":0,"g":0,"x":"busybox"}
  • p: relative UTF-8 path.
  • t: entry type, always "l".
  • u: logical uid, unsigned 32-bit integer.
  • g: logical gid, unsigned 32-bit integer.
  • x: UTF-8 symlink target string.

Symlink entries do not carry an h field. Their target and ownership metadata are stored directly in the manifest.

Canonicalization Rules

The schema header must be byte-for-byte canonical:

{"schema":"bobr-fs-tree-manifest"}

Filesystem entries are sorted by path bytes after UTF-8 encoding. The root directory path is the empty string and therefore sorts first.

Field order is fixed:

  • directory: p, t, u, g, m
  • regular file: p, t, h
  • symlink: p, t, u, g, x

Objects must contain exactly the fields required for their entry type. Unknown fields, missing fields, duplicate fields, alternate key order, and added whitespace are rejected.

Strings are written without optional JSON escapes. Only " and \ are escaped. Control characters are invalid in paths and symlink targets.

Together these rules give a tree exactly one valid byte encoding: the parser accepts only that encoding and rejects any deviation in entry order, field set, field order, whitespace, or escaping. Because the manifest is an ordinary store object, its ObjectHash (see Filesystem Object Hashing) is exactly the SHA-256 of these canonical bytes and is therefore a deterministic function of the tree.

Tree Shape Rules

The manifest must contain at least the root directory entry:

{"p":"","t":"d","u":0,"g":0,"m":493}

The root’s uid, gid, and mode are not fixed by the format; the values above are an example and obey the same constraints as any other directory entry.

Every non-root entry must have an explicit parent directory entry in the same manifest. Duplicate paths are invalid. A file or symlink cannot be the parent of another entry.

The root path is the empty string. Every other path is a relative UTF-8 string:

  • no leading /;
  • no trailing /;
  • no empty components;
  • no . or .. components;
  • no control characters.

Store Integration

Regular file entries reference opaque fs-file hashes. The file bytes and file metadata represented by those hashes live in <store>/fs-files/; the manifest does not inline them. Directory and symlink metadata are stored directly in the manifest.

The runtime materializes a manifest object when a builder input asks for a filesystem root. A fresh build output can also populate the materialized cache while its files are interned. The cache path is <store>/fs-trees/<manifest-object-hash>/. For named materializations, the runtime also updates <store>/fs-tree-refs/<name> to point at that cache root. These refs are user-facing inspection aids; runtime cache lookup does not read them. Builders that only need to read or transform manifests, such as TreeMerge, TreeMove, and TreeSubset, consume the manifest object directly and do not materialize the tree.

Parsing a canonical manifest validates its representation and tree shape, not the presence of referenced fs-files. A complete fs-tree requires those files as well; see Store: complete content.

Scope Boundaries

The manifest format intentionally does not preserve original inode topology. Materialization may hardlink equal fs-file objects to the same inode. st_ino and st_nlink are not semantic fs-tree identity.

The first manifest implementation does not model xattrs, POSIX ACLs, or file capabilities. Those must be added before claiming complete Linux rootfs metadata preservation.

Development

Commands below assume sibling bobr and bobr-recipes checkouts and are run from their parent directory. Recipe builds require the environment described in Installation.

Build and check Bobr

Source builds require Rust/Cargo, a native C toolchain, and rustfmt/clippy. The complete release gate also requires the x86_64-unknown-linux-musl Rust target, musl-gcc, and packaging tools including readelf, strip, tar, xz, and sha256sum. It runs on x86-64 Linux and requires the exact Rust version selected by RUST_TOOLCHAIN in both CI and release workflows.

For an edit-compile-test cycle:

(cd bobr && cargo build --workspace --locked)
(cd bobr && cargo test --workspace --locked)

Additional tests gated by integration-tests require a working Linux sandbox environment (user namespaces and mounts):

(cd bobr && cargo test --workspace --all-features --locked)

CI and the release gate do not currently enable these feature-gated tests.

These commands do not install binaries. Before acceptance testing, run the complete release gate and install its verified archive:

bobr/tools/release-check-and-install.sh

The gate checks formatting, clippy, tests, rustdoc, the installer, and static musl release packaging and smoke tests. It installs bobr, bobr-repo, bobr-fsobj-hash, and bobr-sandbox-launcher only after all checks pass. The bundle launcher is checked but not installed.

A clean checkout is required by default. Use --allow-dirty while developing; the installed bobr --build-info records the commit and dirty state. The destination is $CARGO_INSTALL_ROOT/bin, then $CARGO_HOME/bin, then ~/.cargo/bin; --bin-dir DIR overrides it. Put that directory before other Bobr installations on PATH.

Check recipes

Run the standalone recipe check suite with git, jq, Nickel, patch, Python 3, rg, shellcheck, tar, and bobr-fsobj-hash on PATH:

bobr-recipes/tools/check.sh

After editing local sources in the recipes checkout, refresh their adjacent *.fsobj-hash locks:

bobr-recipes/bin/bobr-update-fsobj-hashes.sh

bobr-build.sh checks existing locks in that checkout; it does not rewrite them or check external user sources automatically. Validate a profile and lower its request without building:

bobr-recipes/bin/bobr-build.sh --dry-run /path/to/profile.ncl

For profile and target selection, see Getting Started.

Acceptance builds

For a cold world build, create an empty store and set the profile’s store to that path. Disable mappings on every secondary provider, or use secondaries.providers = []; an empty working store alone does not prevent prebuilt mapping hits. Content-only local or remote providers are allowed. Leave output_repository unset unless publication is intended.

mkdir /path/to/new-store
bobr-recipes/bin/bobr-build.sh --target world /path/to/profile.ncl
bobr-recipes/bin/bobr-build.sh --target test_all /path/to/profile.ncl

world builds the shipped images and host bundles; test_all runs the separate acceptance recipes and can reuse results from the preceding build. A cold world build takes hours. See Build logging for diagnostics.

Documentation

Build or preview the user documentation with mdBook:

(cd bobr && mdbook build)
(cd bobr && mdbook serve)

Scheduler

This document describes the realization scheduler implemented by bobr. It covers DAG traversal, cache and reuse resolution, Source acquisition, builder execution, concurrency limits, and cancellation. It describes the current algorithm, including its deliberately lazy behavior and its known scheduling consequences.

The scheduler is not one global queue of topologically ready nodes. It is a demand-driven set of asynchronous computations coordinated by the DynamicRealizer, with separate executors for Source acquisition and synchronous builders.

Components

One realization run has these cooperating components:

  • the DynamicRealizer traverses the planned DAG, resolves object identities, decides whether content or a build is required, and coordinates all goals;
  • the Source acquisition engine obtains pinned Path, Http, and OciRegistry content;
  • the asynchronous SecondaryResolver separates explicitly enabled mapping providers from content providers and verified import;
  • the shared NetworkScheduler bounds Source and remote-repository traffic with one global and per-host budget;
  • the shared LocalIoScheduler bounds local provider operations, Source materialization/import, and builder input preparation;
  • the BuildExecutor runs synchronous builders under the jobs limit.

Their relationship is:

request goals
    |
    v
DynamicRealizer
    |-- mapping lookup --------------> SecondaryResolver
    |-- known-hash content lookup ---> SecondaryResolver
    |-- Source origin ---------------> Source acquisition engine
    `-- ready BuilderJob ------------> BuildExecutor
                                           |
                                           `-- staged output -> publication

Source network acquisition and remote providers share the NetworkScheduler. Local provider adapters and selected blocking filesystem operations share the LocalIoScheduler.

Mapping lookup, content acquisition, and builder execution are separate operations. An enabled mapping provider can supply an ObjectHash without providing the bytes of that object, and the hash can participate in a parent ReuseKey before its content is present in the working store.

Before realization, bobr validates the ordered provider configuration and opens its backends. Mapping and content providers are separate projections of that list; each projection retains request order. Complementary capabilities share one backend, identified by canonical store root for local providers or master URL for remote providers. Repeating the same backend capability is rejected.

Every configured local repository is opened and its root canonicalized. Hardlink repositories are rejected at this boundary unless both their objects/ and fs-files/ directories are on the corresponding working-store filesystems. Copy repositories have no same-filesystem requirement. An explicitly configured repository is therefore not an optional cache hint: an absent or malformed local store is a request error even when the goal is already complete locally.

Remote setup loads pinned public keys and optional CA configuration without fetching the repository. The first use authenticates the master and freezes one snapshot shared by that backend’s capabilities for the run. Build/reuse indexes and object/fs-file lists are loaded lazily when needed. A complete working-store hit can therefore avoid all remote requests, though invalid local key or CA files still fail setup. See Store.

Graph planning

The request is validated and converted to a planned graph before realization starts. Planning begins at goals; nodes that are not reachable from a goal are not parsed as executable subjects and do not enter the scheduler.

The graph is keyed internally by BuildKey. Repeated recipe nodes with the same BuildKey therefore denote one scheduled computation. Multiple goals may share any part of the graph.

Each goal starts as an independent Tokio task. Goals may finish in any order, but their results are collected by their original position in the request, so the CLI response preserves request order.

There is no global ready queue

The DynamicRealizer does not maintain a central queue containing every node whose dependencies are ready. Instead, realizing a node creates asynchronous work for its immediate inputs. Those inputs recursively do the same when their content or output identity is actually needed.

This gives the scheduler two important properties:

  • an exact hit can prune a dependency subtree before its inputs are visited;
  • a reuse hit can use hash-only input identities without acquiring all input content.

It also means there is no scheduler-wide priority, critical-path policy, or fairness order between independent DAG branches. Tokio decides which ready tasks are polled; only the bounded resource executors described below impose queues and limits.

Two resolution operations

Every reachable node supports two logically different operations.

Candidate resolution

candidates(BuildKey) returns an ordered, duplicate-free list of possible output ObjectHash values. It does not require those objects to be present in the working store.

Candidate resolution can be cheap when a mapping supplies a hash. On a complete mapping and reuse miss it may recurse and eventually run a builder, because a newly built output hash cannot be known in advance.

Local realization

realize_local(BuildKey) returns one ObjectHash whose complete content is available in the working store. Public goals always use this operation. Builders also use it for their actual inputs once reuse resolution has shown that a build may be necessary.

The separation is load-bearing. Candidate hashes are sufficient to compute possible parent reuse keys. Local realization is deferred until content is actually needed by a goal or a builder execution.

Deduplication

The realizer maintains three asynchronous, per-run result tables:

KeyComputationPurpose
BuildKeycandidate resolutioncompute one candidate list for every node identity
BuildKeylocal realizationrealize shared dependencies and duplicate goals once
ObjectHashcontent lookup/importprevent concurrent imports of the same known object

Each table entry is a Tokio OnceCell shared by every waiter. The mutex around each table protects only creation and lookup of cells; it is not held while the underlying operation runs.

If local realization later produces a hash absent from a node’s cached candidate list, subsequent candidate requests append that hash to the result.

An unavailable known object is not cached permanently in the content table. The failed-to-locate cell is removed, allowing a later Source materialization or another completed operation to make the same hash available.

Candidate resolution order

For one node, candidate resolution follows this order.

  1. Read the working-store builds/<build-key> mapping. The mapping supplies a hash only; its content is not checked at this stage.
  2. On a working mapping miss, query every configured mapping provider for build mappings. Candidate hashes retain provider priority and are deduplicated.
  3. For a Source with no exact mapping, use its declared object_hash.
  4. For a builder with no exact mapping, calculate possible reuse keys from its input candidate sets.
  5. If no reuse mapping supplies a candidate, realize and run the builder to discover its output hash.

A working exact mapping short-circuits secondary exact lookup. If its hash later proves unavailable from the working store and all configured content sources, local realization falls back to the node’s Source or builder path. It does not return to secondary exact mappings to ask for a different hash during candidate fallback. Publication may separately re-query mappings.

Dynamic reuse resolution

Reuse resolution works with possible input identities before requiring input content.

For a builder exact miss, the realizer starts candidate resolution for all immediate inputs concurrently. Input results are restored to deterministic input-name order after the tasks finish.

If an input has multiple candidate hashes, the realizer enumerates the Cartesian product of all input candidate sets. Input slots are ordered by name, candidate order is preserved within each slot, and the last slot changes fastest. Every combination is a name -> ObjectHash map from which the builder computes one ReuseKey.

The lookup order is:

  1. inspect working-store reuse mappings for every candidate combination;
  2. if none of them hit, query mapping providers for reuse mappings in batches of 256 keys;
  3. collect ordered, duplicate-free output hash candidates;
  4. if there is still no reuse candidate, proceed toward a real build.

Any working reuse candidates take priority as a group: secondary reuse mappings are not queried during this candidate pass when at least one working mapping was found.

The Cartesian product can grow exponentially in the number of conflicting input candidates. In the normal case every input has one candidate and exactly one reuse key is computed.

Local realization

Local realization first obtains the node’s candidate list. It then tries each candidate hash in order.

For one known hash, content resolution does the following:

  1. accept it if the object and any fs-tree closure are complete in the working store;
  2. otherwise ask the configured content sources whether they can provide it;
  3. import and verify obtainable content into the working store;
  4. report a content miss if no source can provide a complete object.

Mapping lookup is not repeated during this content pass. The secondary resolver’s mapping and content capabilities are independent. Mapping and availability queries run concurrently across providers, but their results are merged in configuration order, not completion order. Imports try content providers in that order. For an fs-tree, different providers may provide disjoint missing fs-files and the manifest object, but the working manifest is not published until its full closure is complete. Every actual hardlink, copy, or download is verified and atomically published under the working-store name; no result remains dependent on a provider directory entry or continued remote access.

Remote fs-files are fetched concurrently in batches of at most 256 files. After all representations in a batch arrive, one blocking namespace operation verifies and publishes the batch under a local-I/O permit. The next batch then starts. There is no namespace-runtime call per network response, and downloads do not hold a local-I/O permit while waiting for the network.

After a builder candidate becomes local, the realizer publishes the current build mapping and user-facing ref. It also records every reuse key known to resolve to the selected builder output when that publication came through reuse rather than exact identity. Source hits record Source metadata and refs, not builder build/reuse mappings.

If all known candidates miss content, the node-specific fallback runs:

  • a Source is materialized from its origin;
  • a builder realizes its inputs and may eventually execute.

Source scheduling

A Source recipe declares its expected ObjectHash, so candidate resolution does not need to read, download, or unpack its origin. Source content is requested only by realize_local after working and secondary content lookup has failed.

This is lazy acquisition, not prefetching. Merely being reachable from a goal does not place a Source in a download queue. An exact or reuse hit above that Source can prune it without any origin access.

When an origin is required:

  • Http and OciRegistry acquisition runs asynchronously and uses network connection permits;
  • Path acquisition runs in Tokio’s blocking pool and uses a local-source permit;
  • hashing, verification, and publication happen before the Source completes;
  • waiters for the same node share its local-realization computation.

Source acquisition does not consume a builder slot.

Consequence of lazy Source acquisition

The current order can leave the network idle during a cold build. Consider:

parent builder
|-- source tarball
`-- generated rootfs
    |-- OCI image
    `-- OciExtract

The tarball’s hash is known immediately, but the generated rootfs hash is not. The rootfs must first download its OCI image and run OciExtract. Only then can the parent ReuseKey be computed. If the parent has a complete reuse miss, the scheduler finally calls realize_local for the tarball and starts its download.

This behavior avoids downloads that a later reuse hit would make unnecessary, but it does not overlap all knowable network work with CPU and disk work. The current scheduler has no speculative Source prefetch state and does not cancel an optional download after a late reuse hit, because optional downloads are never started.

Builder execution path

When a builder must produce a local result, the realizer follows this path.

  1. Start realize_local for every immediate input concurrently.
  2. Restore the input results to deterministic input-name order.
  3. Compute the actual ReuseKey from those realized hashes.
  4. Check the working reuse mapping and require its content.
  5. Check secondary reuse mappings and try their content candidates.
  6. On a complete reuse miss, prepare filesystem paths for all builder inputs.
  7. Submit a BuilderJob to the BuildExecutor.
  8. Wait asynchronously for a staged output.
  9. Import and publish the staged output outside the synchronous builder call.

The second reuse check is necessary because candidate resolution may have considered several identities, while builder execution uses one concrete set of locally realized inputs. It also closes races with content or mappings that became available while the inputs were being realized.

Inputs whose names begin with _ are materialized as filesystem roots. Ordinary inputs are passed as object paths. Input preparation is blocking filesystem work and runs in Tokio’s blocking pool rather than on an async runtime worker.

BuildExecutor

The BuildExecutor owns the explicit FIFO builder-job queue. It bridges asynchronous DAG realization to synchronous builder code; network and local-I/O work instead waits on semaphores.

At run startup, bobr resolves jobs from the request or from the host’s available parallelism. It creates the executor with:

active worker limit = jobs
submission channel capacity = 2 * jobs
accepted active + waiting limit = 3 * jobs

The dispatcher drains the submission channel into its FIFO queue. A separate accepted-work semaphore bounds active plus queued jobs and applies backpressure to asynchronous submitters; channel capacity alone is not the total queue bound.

The executor consists of:

  • one dedicated dispatcher OS thread;
  • a FIFO queue ordered by command arrival;
  • at most jobs active builder worker threads;
  • one OS worker thread for each active synchronous builder;
  • asynchronous one-shot completion channels back to the realizer.

FIFO order is the order in which concurrent realizer tasks successfully submit commands. It is not a promise of request, graph, or lexical node order.

A worker returns a staged output and notifies the dispatcher that execution has finished. Final store import and publication run separately on Tokio’s blocking pool; the builder worker does not wait for publication before becoming available for another job.

Independent concurrency limits

The request controls several independent resources.

SettingWork boundedQueue or mechanism
jobssynchronous buildersBuildExecutor FIFO queue and worker limit
limits.max_connectionsSource HTTP attempts, OCI pulls, and remote-repository metadata/content requestsshared global network semaphore
limits.per_host_defaultnetwork operations charged to one hostshared per-host semaphore
limits.per_host.<host>network operations charged to a named hostper-host override semaphore
limits.max_local_jobslocal provider mapping/availability queries and imports, Source materialization/import, remote content publication, and builder input preparationone shared local-I/O semaphore

Local provider copy and verification work runs through Tokio’s blocking pool after acquiring the shared local-I/O permit. It therefore neither occupies a Tokio worker nor a builder slot. Local mapping lookup also uses this shared permit. Remote metadata fetches use network permits, not local-I/O permits.

Each remote reader additionally bounds blocking ordinary-object decoding with its own semaphore, currently set to limits.max_local_jobs. These per-backend decoder semaphores are not the shared local-I/O semaphore. Builder-output publication and HTTP archive finalization run on the blocking pool without a shared local-I/O permit. The setting is therefore not a universal limit on all filesystem work.

Network permit acquisition always takes the per-host permit before the global permit. Every network task uses that order, avoiding a lock-order deadlock. Waiting for a permit is cancellation-aware and is not counted as a download attempt or timeout.

For Source HTTP and repository requests, permits cover one attempt and are released before retry backoff. An OCI Source instead holds one permit pair charged to the registry host for its entire pull, including its internal requests and retries. It does not acquire a new scheduler permit per blob.

Ordering and nondeterminism

The scheduler preserves these orders:

  • goal results follow request order;
  • builder inputs follow their deterministic name order;
  • candidate hashes preserve resolution priority;
  • reuse combinations follow deterministic Cartesian-product order;
  • mapping and content provider results follow configuration order;
  • accepted builder jobs start in FIFO submission order, subject to available worker slots.

The following timing is intentionally unspecified:

  • which independent goal or input task finishes first;
  • which concurrent branch submits its builder first;
  • the interleaving of network, local I/O, secondary lookup, and builder work;
  • the order in which progress events from independent work become visible.

The scheduler’s correctness depends on identities, dependency relationships, and publication invariants, not on those completion timings.

Publication

Only complete, verified content is published as a successful local result. Already stored content is checked for completeness, not rehashed on every hit; see Store: complete content. Publication may write:

  • the object and any fs-tree files;
  • user-facing object metadata;
  • builds/<build-key> and applicable reuses/<reuse-key> mappings;
  • object-refs/<name> and, when materialized, fs-tree-refs/<name>.

Mappings encode object identity; they are not used as proof that content is currently complete. Content is checked separately when a result is required locally.

Publication is idempotent and races between computations producing the same content converge on the same content-addressed object.

Failure and cancellation

Expected mapping/content misses are not run failures. An exhausted remote transport failure allows provider fallback and is remembered only for that URL until the next run. It never disables the other resources or capabilities of that backend. Verified hits from readable slots remain usable when another slot’s metadata cannot be fetched; an uncertain miss remains a transport error. Master revalidation failures never fall back to stale cached master bytes. Authentication failures and invalid repository metadata are fatal. A corrupt advertised content representation can fall back to another content provider; already verified fs-files may remain available for that fallback. Local-store failures are not silently converted to provider misses.

An error from any goal fails the whole realization run. The realizer then:

  1. cancels the shared run token;
  2. cancels the shared network scheduler, covering both Source and repository acquisition;
  3. aborts the remaining goal tasks;
  4. shuts down the BuildExecutor.

BuildExecutor shutdown removes queued jobs without starting them and signals cancellation to every active job. It then waits for all active worker threads to finish before acknowledging shutdown. Cancellation of synchronous builder code is cooperative: an already running builder or child process may take time to reach a cancellation-aware boundary.

Network permit waits, retry backoffs, and transfers observe shared run cancellation. Failed or cancelled acquisition keeps partial data in per-run work only where the relevant origin implementation explicitly permits it; partial content is never published as the declared object.

Waiting for the shared local-I/O permit is cancellation-aware. Once a synchronous filesystem or namespace-runtime operation has started, it is not forcibly interrupted: it runs to its atomic publication boundary and the Realizer checks cancellation immediately afterwards. Such a completed transaction may leave verified content and its content-level or Source metadata as a reusable cache hit, but it does not let the cancelled realization continue into dependent work. Repository staging guards remove unpublished partial copies on failure; cancellation never exposes a half-copied object or fs-file.

Progress reporting observes scheduler events but does not make scheduling decisions. Terminal height, visible activity slots, and quiet mode affect only presentation. See Build logging.

Worked cases

Working exact hit

The goal’s build mapping supplies hash X. If X is complete in the working store, the goal is published immediately. Its dependency subtree is never visited.

Hash-only reuse chain

A parent exact miss asks its inputs for candidates. Secondary mappings provide hashes for those inputs without importing them. Their combination produces a reuse key whose mapping supplies the parent’s output hash. Only the final goal content is required locally; intermediate input content remains absent.

Cold builder miss

A builder has no exact or reuse mapping. Its immediate inputs are locally realized in parallel. After the final reuse check misses, input paths are prepared and the builder enters the FIFO executor. It starts when one of the jobs worker slots is free, then its staged output is imported and published.

Current invariants

The current scheduler maintains these central invariants:

  • every public goal resolves to complete content in the working store;
  • identity lookup and content acquisition remain separate;
  • shared nodes and known content acquisition are deduplicated within a run;
  • exact and reuse hits can prune unnecessary dependency content;
  • synchronous builders never block Tokio runtime workers;
  • builders, network transfers, and the shared class of local-I/O operations have explicit, independent bounds;
  • builder inputs are complete before execution;
  • staged output is published only after successful builder completion;
  • one goal failure or external cancellation terminates the whole run;
  • result correctness does not depend on the timing of independent tasks.

TODO

  • Propagate cancellation to runtime workers.
  • Propagate runtime worker logs to Bobr.
  • Support AArch64.
  • Improve OS images and add NixOS-style configuration modules.
  • Evaluate host bundle viability.