Explorar o código

Merge branch 'goal/dcba' of Tilo15/usm into master

clanker hai 1 mes
pai
achega
b046afedde

+ 27 - 0
PACKAGING_GUIDE.md

@@ -336,3 +336,30 @@ USM_CONFIGDIR=/tmp/kilo/test-config usm install mylib
 ```
 
 Verify that every `provides` entry actually exists at the expected location in the DESTDIR. If it doesn't, either the install script or the manifest is wrong.
+---
+
+## Distributing pre-built artifacts (DCBA)
+
+A DCBA repository lets clients of one platform skip the compile: when a matching pre-built artifact exists, `usm install` downloads it alongside the source and uses it as the package's local build cache. Artifacts are per platform — publish one DCBA repository per architecture you build for.
+
+Producing artifacts today is manual (a `usm dcba build` command may come later):
+
+```bash
+# 1. Build and install the package normally, on the platform the repository serves
+usm install mylib -y
+
+# 2. The cache's build.tar.xz IS the artifact — copy and rename it
+cp /var/usm/packages/mylib-1.0.0/build.tar.xz mylib-1.0.0.dcba
+
+# 3. Publish it in a DCBA repository
+cd /path/to/my-artifacts && usm dcba repository init my-artifacts   # first time only
+usm dcba repository add ../mylib-1.0.0.dcba
+usm dcba repository publish /srv/www/my-artifacts
+```
+
+The filename must be `<name>-<version>.dcba`; `usm dcba repository add` validates that the file is a readable `tar.xz` and that its name carries a parseable version. Adding the same name and version with different content is rejected (identical content is a no-op). `usm dcba repository verify` checks the listing signature and every artifact checksum before you publish.
+
+Clients opt in with `usm dcba add-repo <url-or-path>`; from then on `usm install` uses artifacts automatically whenever one matches the package's name and version exactly.
+
+Only ship artifacts you built and trust: a client verifies the download against the repository's signed listing, then runs the package's normal install from the artifact — same trust model as a source repository, but the client executes no compile step.
+

+ 85 - 1
README.md

@@ -130,6 +130,84 @@ Last line consists of the signatures for the repository
 
 Complete package is simply a tar.xz file containing a MANIFEST.usm file and all the requisite files that would be acquired if the acquire script was run. That is to say, a USMC file could be created by simply running `usm manifest acquire` followed by `tar -cJf ../package.usmc .`.
 
+## DCBA repositories (Distribution Compiled Binary Artifacts)
+
+A DCBA repository is a second repository type next to a USM source repository. Instead of sources it distributes pre-built build artifacts: one `.dcba` file per package and version. A `.dcba` file is a plain `tar.xz` containing only the build artifact USM normally creates per system — exactly what lands in a package cache directory as `build.tar.xz`, renamed.
+
+The repository layout mirrors a source repository:
+
+- `<name>.dcbar` — the repository descriptor, the same JSON shape as `.usmr` (`name`, `summary`, `uris`/`url`, `key` base64 ed25519, `cache_expiration`); a different suffix so the two repository types can never be mistaken for one another
+- `keys/` — the signing keys (`public-key`, `private-key` mode 0600)
+- `public/` — the published `<name>-<version>.dcba` artifacts plus the signed `ARTIFACTS.usml` listing
+
+One repository serves one platform/architecture: a `.dcbar` descriptor states nothing about the platform, the repository is simply built for one system. A different architecture means the user configures a different DCBA repository.
+
+### DCBA artifact listing (ARTIFACTS.usml)
+
+Entries in JSON lines format, one per artifact:
+
+```json
+{
+  "type": "dcba",
+  "name": "my-package",
+  "version": "1.0.5+2",
+  "path": "my-package-1.0.5+2.dcba",
+  "sha512": "0+6QpQ5Wst2bPmAPYKO/RKrFYuq93rcaRs7tqt9lCo9zu8v6jpJsDqd7yPIoCWDt0KpOQ3uLK64Tmn9FYNw3eQ==",
+  "size": 220
+}
+```
+
+The last line is the signatures line, exactly as in `PACKAGES.usml`: the SHA-512 over the concatenated entry lines (as serialised, no newline terminators), ed25519-signed with the repository key.
+
+```json
+{
+  "type": "signatures",
+  "signatures": [
+    {
+      "key": "EDXLsUvOZEne+xcv+huvSaqNBs8TTldCv6hd69GdmYw=",
+      "signature": "0rHQiDGKgumMK2S3B6D9jjU3NLnLLjijYJRvaB5vLbp4zxzjlixfyX8yfvvJUCCd34CC1STzyzawDtb9ZammCTE4zFHFdpnO8eseDS5OY+aKgAyL13oBGizvy/V0zGHVCdv83GQ7QUtgSWje0RKKk1zPGDAIF/prkurlFDgd3Vg="
+    }
+  ]
+}
+```
+
+### Producing artifacts
+
+Producing a `.dcba` file is a manual step for now: build and install the package normally (on the platform the repository serves), then copy the package cache's `build.tar.xz` to `<name>-<version>.dcba`. A `usm dcba build` command may arrive later.
+
+### DCBA Commands
+
+- `usm dcba repository init <name>`: Create the DCBA repository layout in the current directory: `<name>.dcbar`, `keys/` (signing keys, private key mode 0600 and gitignored via `.gitignore`) and an empty `public/` artifacts directory
+- `usm dcba repository add <name-version.dcba>`: Validate the artifact (a readable tar.xz whose filename carries a parseable `<name>-<version>`), copy it into `public/`, then rebuild and re-sign `ARTIFACTS.usml`; the same name and version is rejected unless the content is identical (identical is a no-op)
+- `usm dcba repository remove <name|name-version|filename>`: Delete the matching `.dcba` from `public/` (exact filename, `<name>-<version>`, or a `<name>-` prefix; an ambiguous name lists the candidates and asks for the filename), then rebuild and re-sign
+- `usm dcba repository list [repo.dcbar]`: Print name, version and size of every artifact in the signed listing (from the repository root, or fetched from the given `.dcbar`'s URI)
+- `usm dcba repository verify <repo.dcbar>`: Check the listing signature, then deep-verify every entry's sha512 against the `.dcba` fetched from the repository URI; exits non-zero on any failure
+- `usm dcba repository publish <path>`: Copy the `.dcbar` and `ARTIFACTS.usml` to `<path>` (always overwriting) and each `public/*.dcba` only when the destination copy is missing or older by mtime
+- `usm dcba repository build-list`: Rebuild and re-sign `ARTIFACTS.usml` from whatever `.dcba` files sit in `public/`
+- `usm dcba add-repo <url-or-path>`: Add a DCBA repository (`.dcbar`) to the configuration, refreshing its artifact listing
+
+The `repository` verbs locate the DCBA repository root by searching the current directory and its ancestors for a directory containing a `*.dcbar` file, a `keys/` directory and a `public/` directory — deliberately separate from the source-repository search, so the two can never be confused even when nested in the same tree. Signing keys load from `keys/public-key` and `keys/private-key` in the repository root, falling back to the legacy loose `public-key` and `private-key` files.
+
+```bash
+# on the build machine for the platform the repository serves
+usm install my-package -y
+cp /var/usm/packages/my-package-1.0.5+2/build.tar.xz my-package-1.0.5+2.dcba
+
+usm dcba repository init my-artifacts
+usm dcba repository add my-package-1.0.5+2.dcba
+usm dcba repository publish /srv/www/my-artifacts
+
+# on a client of the same platform
+usm dcba add-repo https://my.software/my-artifacts/my-artifacts.dcbar
+usm install my-package -y
+```
+
+### Client behaviour
+
+During `usm install` (and `usm update`/`usm downgrade`), once a package's `.usmc` has been downloaded and verified, USM looks for an artifact with the same name and version in the configured DCBA repositories — in configuration order — and, on the first match, downloads it, verifies it against the listing's sha512 and writes it directly as `<state>/packages/<name>-<version>/build.tar.xz`. No separate `.dcba` file lingers in the cache. From that point on USM behaves exactly as if a local build cache existed: the transaction's build step restores and reuses it (an incremental build — the build exec still runs, against the restored build tree), install runs from it, and `usm clean builds` cleans it like any build cache. The retrieval is recorded in the package's origin-info (`"dcba"` names the repository) and logged as `Retrieved pre-built artifact ...`.
+
+A package's own local artifacts always win: when the cache directory already holds a build directory or a `build.tar.xz`, nothing is downloaded. Any artifact problem — no DCBA repositories configured, no matching entry, an unreachable repository, a listing that fails signature verification, a checksum mismatch — is a non-fatal warning and the package simply builds from source.
+
 ## .usmignore
 
 `usm manifest package` consults an optional `.usmignore` file at the root of the package (next to `MANIFEST.usm`) to prune files and directories from the produced archive.
@@ -196,6 +274,7 @@ A manifest with the `"dataPackage"` flag ships static files: fonts, icons, share
     - /package.usmc
     - /build-log (when built)
     - /build.tar.xz (when built)
+    - /build.tar.xz.digest (when built)
     - /build (when building)
 - /installed
   - /packagename-0.1.1 (symlink to /var/usm/packages/packagename-0.1.1)
@@ -207,16 +286,21 @@ A manifest with the `"dataPackage"` flag ships static files: fonts, icons, share
   - 2020-03-21T14:28:23.382748
   - 2020-03-23T14:23:43.382748
 
+`build.tar.xz` is the archived build tree a transaction leaves behind, and `build.tar.xz.digest` records a digest of the build tree state that produced it. The two together make re-archiving conditional: in the clean-up stage, when a pre-existing archive is present and the build tree's digest still matches the sidecar, the existing archive is kept byte-identical (mtime untouched) and only the build directory is cleaned — identical bytes are never re-tarred. A missing sidecar (an archive from before digests were recorded), a changed, added or removed file, or changed permissions or symlink targets re-archive as before. A system that restores a downloaded DCBA artifact and builds nothing therefore never churns the archive.
+
 ### Origin info format
 ```json
 {
     "repository": "repositoryname",
     "listfile": "2020-03-20T14:34:42.382748.usml",
     "original_path": "packagename-0.1.1.usmc",
-    "signature_verified": true
+    "signature_verified": true,
+    "dcba": null
 }
 ```
 
+`dcba` names the DCBA repository a pre-built build artifact was downloaded from (`"my-artifacts"` above), or null when the package's `build.tar.xz` was built locally. See [DCBA repositories](#dcba-repositories-distribution-compiled-binary-artifacts).
+
 
 ## Resource Ref Additions
 

+ 32 - 0
SPECIFICATION.md

@@ -140,6 +140,38 @@ In `provides`, only `pc` resource names may carry a constraint, only the `==` op
 
 Version-constrained dependencies are not yet forwarded to system package managers: they resolve from local presence or USM packages only.
 
+## DCBA Repositories
+
+A DCBA (Distribution Compiled Binary Artifact) repository is a second repository type alongside a USM source repository. It distributes pre-built build artifacts — one `.dcba` file per package and version — instead of sources. Its layout mirrors a source repository:
+
+- `<name>.dcbar`: the repository descriptor, the same JSON shape as `.usmr` (`name`, `summary`, `uris`/`url`, `key`, `cache_expiration`). The descriptor states nothing about the platform: one repository serves one platform/architecture, and a different architecture is a different configured repository.
+- `keys/`: the signing keys (`public-key`, `private-key`).
+- `public/`: the published artifacts and the signed listing.
+
+### The .dcba artifact format
+
+A `.dcba` file is a plain `tar.xz` archive containing only the build artifact USM normally creates per system: exactly the contents of a package cache directory's `build.tar.xz`, renamed to `<name>-<version>.dcba`. The filename must carry the package's name and a parseable version, split at the last `-` whose suffix parses as a version (so `lib-base-1.2+3.dcba` is `lib-base` version `1.2+3`).
+
+### DCBA artifact listing (ARTIFACTS.usml)
+
+The listing sits at the repository root as `ARTIFACTS.usml` — JSON lines, one entry per artifact, terminated by a signatures line, the `PACKAGES.usml` counterpart. Each entry has the following properties:
+
+- `type` (string, required): The literal `"dcba"`.
+- `name` (string, required): The name of the package the artifact was built from.
+- `version` (string, required): The version of the package the artifact was built from, exactly as the package's manifest states it. Clients match entries by a plain string comparison of `name` and `version`, so the value never mutates through a version round trip.
+- `path` (string, required): The repository-relative path of the `.dcba` file, e.g. `my-package-1.0.0.dcba`.
+- `sha512` (string, required): The base64-encoded SHA-512 of the artifact file, which clients verify every download against.
+- `size` (integer, optional): The artifact's size in bytes. Listings predating the field carry no `size`; clients must treat a missing `size` as unknown, never as zero bytes of content.
+
+The last line is the signatures line, identical in form to `PACKAGES.usml`:
+
+- `type` (string, required): The literal `"signatures"`.
+- `signatures` (array, required): One object per signing key:
+    - `key` (string, required): The base64-encoded ed25519 public key that made the signature.
+    - `signature` (string, required): The base64-encoded ed25519 signature over the SHA-512 of the concatenated entry lines — each entry's bare serialised JSON, in listing order, with no newline terminators.
+
+A listing that ends without a signatures line is an error. Unknown entry types are ignored, as in `PACKAGES.usml`.
+
 ## Installation Lifecycle Directories
 
 Each of these directories are created by USM at runtime for each package.

+ 326 - 0
scripts/dcba-e2e.sh

@@ -0,0 +1,326 @@
+#!/bin/bash
+#
+# DCBA end-to-end verification — no network, file:// repositories only.
+#
+# Builds USM, then drives two fully isolated USM roots (producer A and
+# consumer B) through the whole DCBA story: a tiny fixture package is
+# packaged, published in a file:// source repository and installed; the
+# resulting build cache is turned into a .dcba artifact published in a
+# file:// DCBA repository; a second root installs from scratch and must
+# receive the pre-built artifact as its build.tar.xz build cache.
+#
+# Assertions (each printed PASS/FAIL, exit non-zero on any failure):
+#   a. <state>/packages/<name>-<version>/build.tar.xz exists right after
+#      the download phase, and no local build ran (the build script's
+#      one-time fingerprint is absent from the artifact)
+#   b. origin-info records the DCBA repository name in "dcba"
+#   c. the package installs successfully
+#   d. usm clean builds removes build.tar.xz and build.tar.xz.digest
+#   e. a second install after removing the cache re-downloads the
+#      artifact (the "Retrieved pre-built artifact" line reappears)
+#   f. a tampered artifact in the repository (content no longer matching
+#      the signed listing checksum) is rejected with a warning and the
+#      install still succeeds via a source build
+#
+# Environment quirks this script works around (keep in mind when
+# running it elsewhere):
+#   * The freshly built usm binary links libusm-0.1.so by SONAME, and the
+#     host carries an older /opt/usm/lib/libusm-0.1.so without the DCBA
+#     symbols — every invocation needs LD_LIBRARY_PATH=<build>/lib or it
+#     silently talks to the stale library.
+#   * meson only finds the Invercargill libraries through
+#     PKG_CONFIG_PATH=/opt/usm/lib/pkgconfig.
+#   * USM never creates the state root itself: each root's
+#     <state>/{lists,packages,installed} must exist before the first
+#     repository refresh/install.
+#   * usm.config must exist per root with is_managed=true and a
+#     "managed"."state_path" pointing at that root's state directory;
+#     USM_CONFIGDIR/USM_DESTDIR (see Paths.override_from_environment)
+#     keep the roots apart.
+#
+# Usage: scripts/dcba-e2e.sh [build-dir]
+#   build-dir defaults to a fresh temp directory; pass an existing meson
+#   build directory to skip the (slow) compile.
+
+set -u
+set -o pipefail
+
+SCRIPT_DIR=$(cd -- "$(dirname -- "$0")" && pwd)
+SOURCE_DIR=$(cd -- "$SCRIPT_DIR/.." && pwd)
+
+WORK=$(mktemp -d /tmp/usm-dcba-e2e.XXXXXX)
+trap 'rm -rf "$WORK"' EXIT
+
+PASS_COUNT=0
+FAIL_COUNT=0
+
+report() {
+    if [ "$1" = 0 ]; then
+        printf 'PASS  %s\n' "$2"
+        PASS_COUNT=$((PASS_COUNT + 1))
+    else
+        printf 'FAIL  %s\n' "$2"
+        FAIL_COUNT=$((FAIL_COUNT + 1))
+    fi
+}
+
+# ---------------------------------------------------------------------------
+# 0. Build
+# ---------------------------------------------------------------------------
+
+BUILD_DIR=${1:-}
+
+if [ -z "$BUILD_DIR" ]; then
+    BUILD_DIR="$WORK/usm-build"
+    printf '== Building USM into %s\n' "$BUILD_DIR"
+    meson setup "$BUILD_DIR" "$SOURCE_DIR/src" >/dev/null 2>&1 \
+        || { echo "meson setup failed"; exit 1; }
+    (cd "$BUILD_DIR" && PKG_CONFIG_PATH=/opt/usm/lib/pkgconfig ninja >/dev/null 2>&1) \
+        || { echo "ninja failed"; exit 1; }
+fi
+
+USM_BIN="$BUILD_DIR/cli/usm"
+[ -x "$USM_BIN" ] || { echo "no usm binary at $USM_BIN"; exit 1; }
+
+# The stale-host-library workaround described in the header
+export LD_LIBRARY_PATH="$BUILD_DIR/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
+
+# ---------------------------------------------------------------------------
+# 1. Two isolated roots
+# ---------------------------------------------------------------------------
+
+write_config() {
+    mkdir -p "$1"
+    cat > "$1/usm.config" <<EOF
+{
+  "is_managed": true,
+  "managed": { "state_path": "$2" },
+  "download_timeout": 300,
+  "paths": {
+    "prefix": "/usr", "bin": "bin", "sbin": "sbin", "lib": "lib64", "canonlib": "lib",
+    "libexec": "libexec", "include": "include", "data": "share", "info": "share/info",
+    "man": "share/man", "locale": "share/locale", "localState": "var", "sharedState": "com",
+    "sysConfig": "etc", "tags": "share/usm-tags"
+  }
+}
+EOF
+}
+
+make_root() {
+    # <root>/config (USM_CONFIGDIR) and <root>/state, with the
+    # subdirectories USM expects to already exist
+    rm -rf "$1" "$2"
+    mkdir -p "$1" "$2/lists" "$2/packages" "$2/installed"
+    write_config "$1" "$2"
+}
+
+ROOT_A_CONFIG="$WORK/rootA/config"
+ROOT_A_STATE="$WORK/rootA/state"
+ROOT_B_CONFIG="$WORK/rootB/config"
+ROOT_B_STATE="$WORK/rootB/state"
+
+make_root "$ROOT_A_CONFIG" "$ROOT_A_STATE"
+make_root "$ROOT_B_CONFIG" "$ROOT_B_STATE"
+
+# usm_a/usm_b/usm_pkg run the built binary against one isolated root
+usm_a() {
+    (cd "$WORK" \
+        && USM_CONFIGDIR="$ROOT_A_CONFIG" USM_DESTDIR="$WORK/rootA/dest" "$USM_BIN" "$@")
+}
+usm_b() {
+    # Root B builds (only in the tamper fallback) carry B's fingerprint
+    (cd "$WORK" \
+        && DCBA_E2E_FINGERPRINT="$FINGERPRINT_B" \
+           USM_CONFIGDIR="$ROOT_B_CONFIG" USM_DESTDIR="$WORK/rootB/dest" "$USM_BIN" "$@")
+}
+
+# ---------------------------------------------------------------------------
+# 2. Root A: package a fixture, publish it, install it
+# ---------------------------------------------------------------------------
+
+PKG="$WORK/pkg"
+mkdir -p "$PKG/usm-scripts"
+
+# Two fingerprints, one per root: the build script embeds the one its
+# environment passes through DCBA_E2E_FINGERPRINT, so root A's artifact
+# and root B's local build are distinguishable — root B reusing the
+# downloaded artifact must contain A's fingerprint and not B's
+# (assertion a), while the tamper fallback build (assertion f) must
+# contain B's.
+FINGERPRINT_A="dcba-e2e-a-$(date +%s)-$RANDOM"
+FINGERPRINT_B="dcba-e2e-b-$(date +%s)-$RANDOM"
+
+cat > "$PKG/MANIFEST.usm" <<EOF
+{
+  "name": "dcba-e2e-demo",
+  "version": "1.0.0",
+  "summary": "tiny dcba end-to-end fixture",
+  "licences": [{ "name": "MIT", "text": "LICENCE", "category": "libre" }],
+  "flags": [],
+  "provides": { "res:dcba-e2e-demo/hello.txt": "build:hello.txt" },
+  "depends": { "runtime": [], "build": [], "manage": [] },
+  "execs": { "build": "usm-scripts/build.sh" }
+}
+EOF
+
+printf 'MIT\n' > "$PKG/LICENCE"
+# The fingerprint rides in through the environment so the very same
+# .usmc (whose bytes must not differ between the roots) builds with a
+# per-root marker. The script is incremental like any real build system:
+# an up-to-date output is left untouched, so a restored build cache means
+# the compile step does nothing — which is exactly what assertion (a)
+# observes (USM always re-runs the build exec; reuse is the build
+# directory being restored beforehand, not the exec being skipped).
+cat > "$PKG/usm-scripts/build.sh" <<'EOF'
+#!/bin/bash
+set -e
+if [ ! -f "$1/hello.txt" ]; then
+    echo "built locally ${DCBA_E2E_FINGERPRINT:-unset}" > "$1/hello.txt"
+fi
+EOF
+chmod +x "$PKG/usm-scripts/build.sh"
+
+printf '== Root A: packaging the fixture\n'
+(cd "$PKG" && USM_CONFIGDIR="$ROOT_A_CONFIG" USM_DESTDIR="$WORK/rootA/dest" \
+    DCBA_E2E_FINGERPRINT="$FINGERPRINT_A" \
+    "$USM_BIN" manifest package >/dev/null 2>&1) || { echo "usm manifest package failed"; exit 1; }
+USMC="$WORK/dcba-e2e-demo-1.0.0.usmc"
+[ -f "$USMC" ] || { echo "no .usmc produced"; exit 1; }
+
+SRC_REPO="$WORK/source-repo"
+mkdir -p "$SRC_REPO"
+printf '== Root A: source repository\n'
+(cd "$SRC_REPO" && USM_CONFIGDIR="$ROOT_A_CONFIG" USM_DESTDIR="$WORK/rootA/dest" \
+    "$USM_BIN" repository init demo >/dev/null 2>&1) || { echo "usm repository init failed"; exit 1; }
+(cd "$SRC_REPO" && USM_CONFIGDIR="$ROOT_A_CONFIG" USM_DESTDIR="$WORK/rootA/dest" \
+    "$USM_BIN" repository add "$USMC" >/dev/null 2>&1) || { echo "usm repository add failed"; exit 1; }
+
+usm_a add-repo -y "$SRC_REPO/demo.usmr" >/dev/null 2>&1 || { echo "usm add-repo failed"; exit 1; }
+DCBA_E2E_FINGERPRINT="$FINGERPRINT_A" \
+    usm_a install dcba-e2e-demo -y >"$WORK/rootA-install.log" 2>&1 \
+    || { echo "root A install failed"; cat "$WORK/rootA-install.log"; exit 1; }
+
+CACHE_A="$ROOT_A_STATE/packages/dcba-e2e-demo-1.0.0"
+[ -f "$CACHE_A/build.tar.xz" ] || { echo "root A produced no build.tar.xz"; exit 1; }
+[ -f "$CACHE_A/build.tar.xz.digest" ] || { echo "root A produced no build.tar.xz.digest"; exit 1; }
+
+# ---------------------------------------------------------------------------
+# 3. Root A: produce the .dcba and run the DCBA repository tooling
+# ---------------------------------------------------------------------------
+
+DCBA_REPO="$WORK/dcba-repo"
+mkdir -p "$DCBA_REPO"
+printf '== Producer: DCBA repository\n'
+(cd "$DCBA_REPO" && USM_CONFIGDIR="$ROOT_A_CONFIG" USM_DESTDIR="$WORK/rootA/dest" \
+    "$USM_BIN" dcba repository init demoart >/dev/null 2>&1) || { echo "usm dcba repository init failed"; exit 1; }
+
+# The manual step: the cache's build.tar.xz IS the artifact, renamed
+cp "$CACHE_A/build.tar.xz" "$DCBA_REPO/dcba-e2e-demo-1.0.0.dcba"
+(cd "$DCBA_REPO" && USM_CONFIGDIR="$ROOT_A_CONFIG" USM_DESTDIR="$WORK/rootA/dest" \
+    "$USM_BIN" dcba repository add dcba-e2e-demo-1.0.0.dcba >/dev/null 2>&1) \
+    || { echo "usm dcba repository add failed"; exit 1; }
+[ -f "$DCBA_REPO/public/ARTIFACTS.usml" ] || { echo "no ARTIFACTS.usml built"; exit 1; }
+
+# The listing and the artifacts it references must verify
+(cd "$DCBA_REPO" && USM_CONFIGDIR="$ROOT_A_CONFIG" USM_DESTDIR="$WORK/rootA/dest" \
+    "$USM_BIN" dcba repository verify demoart.dcbar >/dev/null 2>&1)
+report $? "dcba repository verify (signature + checksums)"
+
+# ---------------------------------------------------------------------------
+# 4. Root B: consume both repositories
+# ---------------------------------------------------------------------------
+
+printf '== Root B: install from scratch with the DCBA repository configured\n'
+usm_b add-repo -y "$SRC_REPO/demo.usmr" >/dev/null 2>&1 || { echo "root B add-repo failed"; exit 1; }
+usm_b dcba add-repo -y "$DCBA_REPO/demoart.dcbar" >"$WORK/rootB-addrepo.log" 2>&1 \
+    || { echo "root B dcba add-repo failed"; cat "$WORK/rootB-addrepo.log"; exit 1; }
+
+usm_b install dcba-e2e-demo -y >"$WORK/rootB-install.log" 2>&1
+INSTALL_EXIT=$?
+report $INSTALL_EXIT "(c) usm install succeeds with a DCBA repository configured"
+
+CACHE_B="$ROOT_B_STATE/packages/dcba-e2e-demo-1.0.0"
+INSTALLED_B="$WORK/rootB/dest/usr/share/dcba-e2e-demo/hello.txt"
+
+# (a) the artifact landed as the build cache, without a local build
+[ -f "$CACHE_B/build.tar.xz" ]
+report $? "(a) build.tar.xz exists in the cache directory after the download"
+
+if tar -xJOf "$CACHE_B/build.tar.xz" ./hello.txt 2>/dev/null | grep -q "$FINGERPRINT_A"; then
+    report 0 "(a) artifact is the producer's (root A fingerprint present)"
+else
+    report 1 "(a) artifact is the producer's (root A fingerprint present)"
+fi
+if tar -xJOf "$CACHE_B/build.tar.xz" ./hello.txt 2>/dev/null | grep -q "$FINGERPRINT_B"; then
+    report 1 "(a) artifact was reused, not rebuilt locally (root B fingerprint absent)"
+else
+    report 0 "(a) artifact was reused, not rebuilt locally (root B fingerprint absent)"
+fi
+
+# (b) provenance
+grep -q '"dcba"[[:space:]]*:[[:space:]]*"demoart"' "$CACHE_B/origin-info"
+report $? "(b) origin-info records the DCBA repository in \"dcba\""
+
+# (c) the install actually installed the resource
+[ -f "$INSTALLED_B" ]
+report $? "(c) the package's resource was installed"
+
+# The download log must show the artifact retrieval
+tr '\r' '\n' < "$WORK/rootB-install.log" | grep -q "Retrieved pre-built artifact"
+report $? "(evidence) install log reports the retrieved pre-built artifact"
+
+# ---------------------------------------------------------------------------
+# 5. (d) usm clean builds clears the artifact cache
+# ---------------------------------------------------------------------------
+
+usm_b clean builds >/dev/null 2>&1
+CLEAN_OK=$?
+[ ! -e "$CACHE_B/build.tar.xz" ] && [ ! -e "$CACHE_B/build.tar.xz.digest" ]
+NO_CACHE=$?
+report $CLEAN_OK "(d) usm clean builds succeeds"
+report $NO_CACHE "(d) usm clean builds removed build.tar.xz and build.tar.xz.digest"
+
+# ---------------------------------------------------------------------------
+# 6. (e) a clean install re-downloads the artifact
+# ---------------------------------------------------------------------------
+
+# Remove the package entirely so the .usmc transfers again — the DCBA
+# fetch only rides along a package download
+rm -rf "$CACHE_B" "$ROOT_B_STATE/installed/dcba-e2e-demo-1.0.0" "$INSTALLED_B"
+usm_b install dcba-e2e-demo -y >"$WORK/rootB-reinstall.log" 2>&1
+REINSTALL_EXIT=$?
+report $REINSTALL_EXIT "(e) reinstall after cache removal succeeds"
+tr '\r' '\n' < "$WORK/rootB-reinstall.log" | grep -q "Retrieved pre-built artifact"
+report $? "(e) reinstall re-downloaded the pre-built artifact"
+[ -f "$CACHE_B/build.tar.xz" ]
+report $? "(e) build.tar.xz is back in the cache directory"
+
+# ---------------------------------------------------------------------------
+# 7. (f) a tampered artifact falls back to a source build
+# ---------------------------------------------------------------------------
+
+# Corrupt the published artifact WITHOUT rebuilding the listing, so the
+# signed listing still carries the honest checksum and the client must
+# reject the download by checksum mismatch — the non-fatal path
+printf 'tampered, not an archive' > "$DCBA_REPO/public/dcba-e2e-demo-1.0.0.dcba"
+rm -rf "$ROOT_B_STATE/lists/demoart"
+
+rm -rf "$CACHE_B" "$ROOT_B_STATE/installed/dcba-e2e-demo-1.0.0" "$INSTALLED_B"
+usm_b install dcba-e2e-demo -y >"$WORK/rootB-tamper.log" 2>&1
+TAMPER_EXIT=$?
+report $TAMPER_EXIT "(f) install with a tampered artifact still succeeds"
+
+tr '\r' '\n' < "$WORK/rootB-tamper.log" \
+    | grep -q 'Warning: DCBA artifact.*could not be used.*building from source instead'
+report $? "(f) a warning explains the rejected artifact"
+
+[ -f "$INSTALLED_B" ] && grep -q "$FINGERPRINT_B" "$INSTALLED_B"
+report $? "(f) the package was built from source (root B fingerprint present)"
+
+# ---------------------------------------------------------------------------
+
+printf '\n%d passed, %d failed\n' "$PASS_COUNT" "$FAIL_COUNT"
+if [ "$FAIL_COUNT" -gt 0 ]; then
+    exit 1
+fi
+exit 0

+ 94 - 37
src/cli/AddRepo.vala

@@ -8,55 +8,90 @@ private int add_repo_main(string[] args) {
 
 namespace Usm.Cli {
 
+    /**
+     * Refreshes the just-added repository's listing and returns the
+     * number of entries it now serves — the `.usmr` side fetches
+     * `PACKAGES.usml` (packages), the `.dcbar` side `ARTIFACTS.usml`
+     * (artifacts).
+     */
+    public delegate int AddRepoRefresh(string destination) throws Error;
+
+    /**
+     * The per-kind parameters of {@link AddRepoRunner}: the descriptor
+     * extension (`.usmr` source repositories, `.dcbar` DCBA artifact
+     * repositories), the noun used in the refresh report and the usage
+     * line. Everything else — download, validation, repos.d placement,
+     * replace prompt — is kind-independent.
+     */
+    public class AddRepoDefinition {
+
+        /** The descriptor file extension, including the leading dot. */
+        public string extension { get; set; }
+
+        /** What one listing entry is called, e.g. "packages"/"artifacts". */
+        public string refresh_noun { get; set; }
+
+        /** The one-line usage form printed on argument errors. */
+        public string usage_line { get; set; }
+
+        /** The closing hint printed after a successful add. */
+        public string followup_line { get; set; }
+
+        /** Fetches the listing of the repository at `destination`. */
+        public owned AddRepoRefresh refresh;
+    }
+
     /**
      * `usm add-repo <url-or-path>` — install a repository definition into
-     * the configured repos.d.
+     * the configured repos.d. The shared implementation behind it and
+     * `usm dcba add-repo`, parameterised by an
+     * {@link AddRepoDefinition} (see {@link AddRepo.run}).
      *
      * The descriptor is an HTTPS/HTTP URL (downloaded through curl, the
      * same `curl -fsSL --max-time 30 -o <tmp> <url>` pattern `usm deploy`
-     * uses) or a path to a local `.usmr` file. The file must parse as a
+     * uses) or a path to a local descriptor file. The file must parse as a
      * {@link Usm.Repository} carrying a name, url and signing key; it is
-     * copied to `<config dir>/repos.d/<name>.usmr`. A repository with the
-     * same name already configured is replaced after a prompt (`-y`/`--yes`
-     * replaces without asking). The listing is refreshed after adding so
-     * the repository's packages are installable immediately; a refresh
-     * that fails (an unenrolled system, a unreachable host) only warns —
-     * the definition itself is already in place.
+     * copied to `<config dir>/repos.d/<name><extension>`. A repository with
+     * the same name already configured is replaced after a prompt
+     * (`-y`/`--yes` replaces without asking). The listing is refreshed
+     * after adding so the repository's contents are usable immediately; a
+     * refresh that fails (an unenrolled system, a unreachable host) only
+     * warns — the definition itself is already in place.
      */
-    public class AddRepo {
+    public class AddRepoRunner {
 
         /**
          * Runs the command against `args` (`args[0]` is the program name,
-         * `args[1]` the command); exactly one URL or path is expected.
-         * Returns 0 when the repository was added, 1 on validation,
-         * download or copy failures and user aborts, and 255 on usage
-         * errors.
+         * `args[1]`/`args[2]` the command path); exactly one URL or path
+         * is expected. Returns 0 when the repository was added, 1 on
+         * validation, download or copy failures and user aborts, and 255
+         * on usage errors.
          */
-        public static int run(string[] args) {
+        public static int run(string[] args, AddRepoDefinition definition, int first_argument = 2) {
             var assume_yes = false;
             string? target = null;
-            for(int i = 2; i < args.length; i++) {
+            for(int i = first_argument; i < args.length; i++) {
                 var argument = args[i];
                 if(argument == "-y" || argument == "--yes") {
                     assume_yes = true;
                 }
                 else if(argument.has_prefix("-")) {
                     printerr(@"Unknown option \"$argument\"\n");
-                    return add_repo_usage();
+                    return add_repo_usage(definition);
                 }
                 else if(target != null) {
                     printerr("Expected exactly one repository URL or path\n");
-                    return add_repo_usage();
+                    return add_repo_usage(definition);
                 }
                 else {
                     target = argument;
                 }
             }
             if(target == null) {
-                return add_repo_usage();
+                return add_repo_usage(definition);
             }
-            if(!target.has_suffix(".usmr")) {
-                printerr("Expected a .usmr repository definition\n");
+            if(!target.has_suffix(definition.extension)) {
+                printerr(@"Expected a $(definition.extension) repository definition\n");
                 return 1;
             }
 
@@ -65,7 +100,7 @@ namespace Usm.Cli {
                 string source_path;
                 if(target.has_prefix("https://") || target.has_prefix("http://")) {
                     temporary_directory = DirUtils.make_tmp("usm-add-repo-XXXXXX");
-                    source_path = Path.build_filename(temporary_directory, "repo.usmr");
+                    source_path = Path.build_filename(temporary_directory, @"repo$(definition.extension)");
                     printerr(@"Downloading repository definition from \"$target\"...\n");
                     var download = new Subprocess.newv(
                         new string[] { "curl", "-fsSL", "--max-time", "30", "-o", source_path, target },
@@ -102,7 +137,7 @@ namespace Usm.Cli {
                 }
 
                 var repos_directory = Path.build_filename(paths.usm_config_dir, "repos.d");
-                var destination = Path.build_filename(repos_directory, @"$(repository.name).usmr");
+                var destination = Path.build_filename(repos_directory, @"$(repository.name)$(definition.extension)");
                 try {
                     var directory = File.new_for_path(repos_directory);
                     if(!directory.query_exists()) {
@@ -114,7 +149,7 @@ namespace Usm.Cli {
                     return 1;
                 }
 
-                var existing = add_repo_existing_path(repos_directory, repository.name);
+                var existing = add_repo_existing_path(repos_directory, repository.name, definition.extension);
                 if(existing != null) {
                     printerr(@"A repository named \"$(repository.name)\" is already configured ($existing)\n");
                     if(!assume_yes && !add_repo_confirm()) {
@@ -133,18 +168,14 @@ namespace Usm.Cli {
                 print(@"Added repository \"$(repository.name)\" to $destination\n");
 
                 try {
-                    var state = new Usm.SystemState(paths);
-                    var added = new Usm.Repository.from_file(destination);
-                    state.refresh_list(added);
-                    var listing = state.get_latest_list(added);
-                    var packages = listing == null ? 0 : listing.entries.count();
-                    print(@"Refreshed: $packages packages available\n");
+                    var count = definition.refresh(destination);
+                    print(@"Refreshed: $count $(definition.refresh_noun) available\n");
                 }
                 catch(Error e) {
                     printerr(@"Warning: could not refresh the repository listing: $(e.message)\n");
                     printerr("Repositories refresh again on the next usm install or search\n");
                 }
-                print("Install packages with: usm install <package-name>\n");
+                print(definition.followup_line);
             }
             catch(Error e) {
                 printerr(@"Error: $(e.message)\n");
@@ -163,24 +194,25 @@ namespace Usm.Cli {
             return 0;
         }
 
-        private static int add_repo_usage() {
-            printerr("USAGE:\n\tusm add-repo [-y|--yes] <url-or-path>\n");
+        private static int add_repo_usage(AddRepoDefinition definition) {
+            printerr(@"USAGE:\n\t$(definition.usage_line)\n");
             return 255;
         }
 
         /**
          * The path of the already-configured repository with this name —
-         * the conventional `<name>.usmr` filename, or any descriptor in
-         * repos.d whose parsed name matches — or null when none exists.
+         * the conventional `<name><extension>` filename, or any descriptor
+         * of this kind in repos.d whose parsed name matches — or null when
+         * none exists.
          */
-        private static string? add_repo_existing_path(string repos_directory, string name) {
-            var direct = Path.build_filename(repos_directory, @"$name.usmr");
+        private static string? add_repo_existing_path(string repos_directory, string name, string extension) {
+            var direct = Path.build_filename(repos_directory, @"$name$extension");
             if(FileUtils.test(direct, FileTest.EXISTS)) {
                 return direct;
             }
             try {
                 foreach(var file in Iterate.directory(repos_directory)) {
-                    if(!file.has_suffix(".usmr")) {
+                    if(!file.has_suffix(extension)) {
                         continue;
                     }
                     var path = Path.build_filename(repos_directory, file);
@@ -228,4 +260,29 @@ namespace Usm.Cli {
             return builder.str;
         }
     }
+
+    /**
+     * `usm add-repo` — adds a USM **source** repository (`.usmr`
+     * descriptor) to the configuration; the {@link AddRepoRunner} shell
+     * around the `.usmr` {@link AddRepoDefinition}.
+     */
+    public class AddRepo {
+
+        public static int run(string[] args) {
+            var definition = new AddRepoDefinition() {
+                extension = ".usmr",
+                refresh_noun = "packages",
+                usage_line = "usm add-repo [-y|--yes] <url-or-path>",
+                followup_line = "Install packages with: usm install <package-name>\n",
+                refresh = destination => {
+                    var state = new Usm.SystemState(paths);
+                    var added = new Usm.Repository.from_file(destination);
+                    state.refresh_list(added);
+                    var listing = state.get_latest_list(added);
+                    return listing == null ? 0 : (int)listing.entries.count();
+                }
+            };
+            return AddRepoRunner.run(args, definition);
+        }
+    }
 }

+ 6 - 3
src/cli/Clean.vala

@@ -117,15 +117,18 @@ private uint64 clean_lists(Usm.SystemState state) throws Error {
 }
 
 /**
- * Deletes build directories and archives. Without {@link name} every
- * package's; with it, only {@link name}'s version directories (other
- * packages sharing the name prefix are never touched).
+ * Deletes build directories and archives together with the
+ * build.tar.xz.digest sidecars describing the trees that produced them.
+ * Without {@link name} every package's; with it, only {@link name}'s
+ * version directories (other packages sharing the name prefix are never
+ * touched).
  */
 private uint64 clean_builds(Usm.SystemState state, string? name) throws Error {
     uint64 freed = 0;
     foreach(var package_dir in clean_package_directories(state, name)) {
         freed += clean_delete(Path.build_filename(package_dir, "build"));
         freed += clean_delete(Path.build_filename(package_dir, "build.tar.xz"));
+        freed += clean_delete(Path.build_filename(package_dir, "build.tar.xz.digest"));
     }
     return freed;
 }

+ 3 - 0
src/cli/Cli.vala

@@ -105,6 +105,9 @@ public static int main(string[] args) {
         if(command == "add-repo") {
             return add_repo_main(dispatch_args);
         }
+        if(command == "dcba") {
+            return dcba_main(dispatch_args);
+        }
         if(command == "deploy") {
             return deploy_main(dispatch_args);
         }

+ 627 - 0
src/cli/Dcba.vala

@@ -0,0 +1,627 @@
+using Invercargill;
+using Invercargill.DataStructures;
+
+private int dcba_main(string[] args) {
+    if(args.length < 3) {
+        return dcba_usage();
+    }
+
+    if(args[2] == "repository") {
+        return dcba_repository_main(args);
+    }
+    if(args[2] == "add-repo") {
+        return Usm.Cli.DcbaAddRepo.run(args);
+    }
+
+    return dcba_usage();
+}
+
+private int dcba_usage() {
+    printerr("USAGE:\n\tusm dcba repository <init|add|remove|list|verify|publish|build-list>\n\tusm dcba add-repo <url-or-path>\n");
+    return 255;
+}
+
+private int dcba_repository_main(string[] args) {
+    if(args.length < 4) {
+        return dcba_repository_usage();
+    }
+
+    if(args[2] != "repository") {
+        return dcba_repository_usage();
+    }
+
+    if(args[3] == "init") {
+        if(args.length != 5) {
+            return dcba_repository_usage();
+        }
+        return dcba_repository_init(args[4]);
+    }
+    if(args[3] == "add") {
+        if(args.length != 5) {
+            return dcba_repository_usage();
+        }
+        return dcba_repository_add(args[4]);
+    }
+    if(args[3] == "remove") {
+        if(args.length != 5) {
+            return dcba_repository_usage();
+        }
+        return dcba_repository_remove(args[4]);
+    }
+    if(args[3] == "list") {
+        if(args.length > 5) {
+            return dcba_repository_usage();
+        }
+        return dcba_repository_list(args.length == 5 ? args[4] : null);
+    }
+    if(args[3] == "verify") {
+        if(args.length != 5) {
+            return dcba_repository_usage();
+        }
+        return dcba_repository_verify(args[4]);
+    }
+    if(args[3] == "publish") {
+        if(args.length != 5) {
+            return dcba_repository_usage();
+        }
+        return dcba_repository_publish(args[4]);
+    }
+    if(args[3] == "build-list") {
+        if(args.length != 4) {
+            return dcba_repository_usage();
+        }
+        return dcba_repository_build_list();
+    }
+
+    return dcba_repository_usage();
+}
+
+private int dcba_repository_usage() {
+    printerr("USAGE:\n\tusm dcba repository init <repository name>\n\tusm dcba repository add <artifact.dcba>\n\tusm dcba repository remove <name|name-version|artifact-filename>\n\tusm dcba repository list [repo.dcbar]\n\tusm dcba repository verify repo.dcbar\n\tusm dcba repository publish <path>\n\tusm dcba repository build-list\n");
+    return 255;
+}
+
+private int dcba_repository_init(string name) {
+    if(dcba_find_repo_file(Environment.get_current_dir()) != null
+        || FileUtils.test(Path.build_filename("keys", "private-key"), FileTest.EXISTS)) {
+        printerr("A DCBA repository has already been initialised in this directory\n");
+        return 1;
+    }
+
+    uint8[] public_key;
+    uint8[] private_key;
+    dcba_generate_keypair(out public_key, out private_key);
+
+    DirUtils.create("keys", 0700);
+    DirUtils.create("public", 0755);
+
+    try {
+        FileUtils.set_data(Path.build_filename("keys", "public-key"), public_key);
+        FileUtils.set_data(Path.build_filename("keys", "private-key"), private_key);
+        FileUtils.chmod(Path.build_filename("keys", "private-key"), 0600);
+
+        var repository = new Usm.Repository();
+        repository.key = Wrap.byte_array(public_key);
+        repository.name = name;
+        repository.url = @"file://$(Environment.get_current_dir())/public";
+        repository.summary = @"My $name DCBA repository";
+
+        var properties = Usm.Repository.get_mapper().map_from(repository);
+        var json = new InvercargillJson.JsonElement.from_properties(properties);
+        json.write_to_file(@"$name.dcbar");
+
+        var gitignore = "keys/private*\n";
+        if(FileUtils.test(".gitignore", FileTest.EXISTS)) {
+            string existing;
+            FileUtils.get_contents(".gitignore", out existing);
+            if(!existing.has_suffix("\n")) {
+                existing += "\n";
+            }
+            if(existing.index_of("keys/private*") == -1) {
+                FileUtils.set_contents(".gitignore", existing + gitignore);
+            }
+        }
+        else {
+            FileUtils.set_contents(".gitignore", gitignore);
+        }
+    }
+    catch(Error e) {
+        printerr(@"Error: $(e.message)\n");
+        return e.code;
+    }
+
+    printerr("DCBA repository initialised, add artifacts with `usm dcba repository add <name-version.dcba>` then publish them with `usm dcba repository publish <path>`\n");
+    return 0;
+}
+
+/**
+ * Locates the nearest DCBA repository root: the first directory at or
+ * above the current directory holding a `*.dcbar` file, a `keys/`
+ * directory and a `public/` directory — the `.dcbar`-suffix counterpart
+ * of `repository_find_root`, deliberately separate so a USM source
+ * repository and a DCBA repository can never be mistaken for one
+ * another even when nested in the same tree.
+ */
+private string? dcba_find_root() {
+    var directory = Environment.get_current_dir();
+    while(true) {
+        if(dcba_find_repo_file(directory) != null
+            && FileUtils.test(Path.build_filename(directory, "keys"), FileTest.IS_DIR)
+            && FileUtils.test(Path.build_filename(directory, "public"), FileTest.IS_DIR)) {
+            return directory;
+        }
+
+        var parent = Path.get_dirname(directory);
+        if(parent == directory) {
+            return null;
+        }
+        directory = parent;
+    }
+}
+
+/**
+ * Finds the first `*.dcbar` file directly inside `root`, or null when
+ * there is none.
+ */
+private string? dcba_find_repo_file(string root) {
+    try {
+        foreach(var file in Iterate.directory(root)) {
+            if(file.has_suffix(".dcbar")) {
+                return Path.build_filename(root, file);
+            }
+        }
+    }
+    catch(Error e) {
+        return null;
+    }
+    return null;
+}
+
+/**
+ * Loads the signing keys from `keys/public-key` and `keys/private-key` in
+ * the DCBA repository root, falling back to the legacy loose
+ * `public-key` and `private-key` files there.
+ */
+private bool dcba_load_keys(string root, out uint8[] public_key, out uint8[] private_key) {
+    public_key = new uint8[0];
+    private_key = new uint8[0];
+
+    var key_dir = Path.build_filename(root, "keys");
+    if(dcba_read_key(Path.build_filename(key_dir, "public-key"), out public_key)
+        && dcba_read_key(Path.build_filename(key_dir, "private-key"), out private_key)) {
+        return true;
+    }
+
+    if(dcba_read_key(Path.build_filename(root, "public-key"), out public_key)
+        && dcba_read_key(Path.build_filename(root, "private-key"), out private_key)) {
+        return true;
+    }
+
+    printerr("No signing keys found in \"keys/\" (or as legacy \"public-key\"/\"private-key\") within the DCBA repository root\n");
+    return false;
+}
+
+private bool dcba_read_key(string path, out uint8[] key) {
+    key = new uint8[0];
+    if(!FileUtils.test(path, FileTest.EXISTS)) {
+        return false;
+    }
+    try {
+        FileUtils.get_data(path, out key);
+        return true;
+    }
+    catch(Error e) {
+        printerr(@"Error: $(e.message)\n");
+        return false;
+    }
+}
+
+private void dcba_generate_keypair(out uint8[] public_key, out uint8[] private_key) {
+    public_key = new uint8[Sodium.Asymmetric.Signing.PUBLIC_KEY_BYTES];
+    private_key = new uint8[Sodium.Asymmetric.Signing.SECRET_KEY_BYTES];
+    Sodium.Asymmetric.Signing.generate_keypair(public_key, private_key);
+}
+
+
+/**
+ * Validates that the file at `path` is a readable tar.xz archive by
+ * listing it through `tar -tf` — the same archive family USM's build
+ * artifacts (`build.tar.xz`) and `.dcba` files use.
+ */
+private void dcba_validate_tar_xz(string path) throws Error {
+    if(!FileUtils.test(path, FileTest.EXISTS)) {
+        throw new IOError.NOT_FOUND(@"\"$path\" does not exist");
+    }
+    var process = new Subprocess.newv(
+        new string[] { "tar", "-tf", path },
+        SubprocessFlags.STDOUT_PIPE | SubprocessFlags.STDERR_PIPE);
+    // Drain both pipes so a large archive listing cannot deadlock on a full buffer
+    var buffer = new uint8[64 * 1024];
+    while(process.get_stdout_pipe().read(buffer) > 0) {
+    }
+    while(process.get_stderr_pipe().read(buffer) > 0) {
+    }
+    try {
+        process.wait_check();
+    }
+    catch(Error e) {
+        throw new IOError.INVALID_ARGUMENT(@"\"$(Path.get_basename(path))\" is not a readable tar.xz archive\n");
+    }
+}
+
+private int dcba_repository_add(string artifact_path) {
+    var root = dcba_find_root();
+    if(root == null) {
+        printerr(@"No DCBA repository root found (a directory containing a *.dcbar file, \"keys/\" and \"public/\" at or above the current directory)\n");
+        return 1;
+    }
+
+    uint8[] public_key;
+    uint8[] private_key;
+    if(!dcba_load_keys(root, out public_key, out private_key)) {
+        return 1;
+    }
+
+    var filename = Path.get_basename(artifact_path);
+    if(!filename.has_suffix(".dcba")) {
+        printerr(@"\"$filename\" is not a .dcba artifact\n");
+        return 1;
+    }
+    var identity = Usm.DcbaArtifact.parse_filename(filename);
+    if(identity == null) {
+        printerr(@"\"$filename\" does not carry a parseable version, rename it to <name>-<version>.dcba (e.g. app-1.0.0.dcba)\n");
+        return 1;
+    }
+
+    Invercargill.BinaryData checksum;
+    try {
+        dcba_validate_tar_xz(artifact_path);
+        checksum = calculate_file_checksum(artifact_path);
+    }
+    catch(Error e) {
+        printerr(@"Invalid artifact \"$artifact_path\": $(e.message)\n");
+        return 1;
+    }
+
+    var public_dir = Path.build_filename(root, "public");
+    try {
+        foreach(var file in Iterate.directory(public_dir)) {
+            if(!file.has_suffix(".dcba")) {
+                continue;
+            }
+
+            var existing_path = Path.build_filename(public_dir, file);
+            var existing_identity = Usm.DcbaArtifact.parse_filename(file);
+            var same_artifact = existing_identity != null && ((!)existing_identity).same_artifact((!)identity);
+            if(!same_artifact && file != filename) {
+                continue;
+            }
+
+            if(calculate_file_checksum(existing_path).equals(checksum)) {
+                printerr(@"\"$(((!)identity).name)-$(((!)identity).version)\" is already present as \"$file\", nothing to add\n");
+                return dcba_rebuild_listing(root, public_key, private_key);
+            }
+
+            printerr(@"\"$file\" already exists with different content, remove it first with \"usm dcba repository remove\"\n");
+            return 1;
+        }
+
+        File.new_for_path(artifact_path).copy(File.new_for_path(Path.build_filename(public_dir, filename)), FileCopyFlags.OVERWRITE);
+    }
+    catch(Error e) {
+        printerr(@"Error: $(e.message)\n");
+        return e.code;
+    }
+
+    printerr(@"Added \"$(((!)identity).name)-$(((!)identity).version)\" as \"$filename\"\n");
+    return dcba_rebuild_listing(root, public_key, private_key);
+}
+
+private int dcba_repository_remove(string selector) {
+    var root = dcba_find_root();
+    if(root == null) {
+        printerr("No DCBA repository root found (a directory containing a *.dcbar file, \"keys/\" and \"public/\" at or above the current directory)\n");
+        return 1;
+    }
+
+    uint8[] public_key;
+    uint8[] private_key;
+    if(!dcba_load_keys(root, out public_key, out private_key)) {
+        return 1;
+    }
+
+    var public_dir = Path.build_filename(root, "public");
+    var matches = new Vector<string>();
+
+    try {
+        var filename = selector.has_suffix(".dcba") ? selector : @"$selector.dcba";
+        if(FileUtils.test(Path.build_filename(public_dir, filename), FileTest.EXISTS)) {
+            matches.add(filename);
+        }
+        else {
+            foreach(var file in Iterate.directory(public_dir)) {
+                if(file.has_suffix(".dcba") && file.has_prefix(@"$selector-")) {
+                    matches.add(file);
+                }
+            }
+        }
+
+        if(matches.length == 0) {
+            printerr(@"No artifact matching \"$selector\" found in \"public/\"\n");
+            return 1;
+        }
+        if(matches.length > 1) {
+            printerr(@"\"$selector\" matches multiple artifacts, specify an exact filename:\n");
+            foreach(var file in matches) {
+                printerr(@"\t$file\n");
+            }
+            return 1;
+        }
+
+        var match = matches.first_or_default();
+        File.new_for_path(Path.build_filename(public_dir, match)).delete();
+    }
+    catch(Error e) {
+        printerr(@"Error: $(e.message)\n");
+        return e.code;
+    }
+
+    printerr(@"Removed \"$(matches.first_or_default())\"\n");
+    return dcba_rebuild_listing(root, public_key, private_key);
+}
+
+private int dcba_repository_list(string? repository_file) {
+    try {
+        Usm.Repository repository;
+        Usm.DcbaListing listing;
+
+        if(repository_file != null) {
+            repository = new Usm.Repository.from_file(repository_file);
+            var stream = File.new_for_uri(repository.url + "/" + Usm.RepositoryClient.DCBA_LISTING_NAME).read();
+            listing = new Usm.DcbaListing.from_stream(new DataInputStream(stream));
+        }
+        else {
+            var root = dcba_find_root();
+            if(root == null) {
+                printerr("No DCBA repository root found (a directory containing a *.dcbar file, \"keys/\" and \"public/\" at or above the current directory)\n");
+                return 1;
+            }
+
+            var repo_file = dcba_find_repo_file(root);
+            if(repo_file == null) {
+                printerr("No *.dcbar file found in the DCBA repository root\n");
+                return 1;
+            }
+            repository = new Usm.Repository.from_file(repo_file);
+
+            var listing_path = Path.build_filename(root, "public", Usm.RepositoryClient.DCBA_LISTING_NAME);
+            if(!FileUtils.test(listing_path, FileTest.EXISTS)) {
+                printerr("No ARTIFACTS.usml in \"public/\", add an artifact with `usm dcba repository add` or run `usm dcba repository build-list` first\n");
+                return 1;
+            }
+            listing = new Usm.DcbaListing.from_stream(new DataInputStream(File.new_for_path(listing_path).read()));
+        }
+
+        if(!listing.valid_signature(repository.key)) {
+            printerr(@"Artifact listing signature verification failed for \"$(repository.name)\"\n");
+            return 1;
+        }
+
+        foreach(var entry in listing.entries) {
+            print(@"$(entry.name) $(entry.version) $(dcba_format_size(entry.size))\n");
+        }
+    }
+    catch(Error e) {
+        printerr(@"Error: $(e.message)\n");
+        return e.code;
+    }
+
+    return 0;
+}
+
+/** Human-readable byte count (`12.0 MiB`, `512 B`); size 0 (listings predating the field) prints `?`. */
+private string dcba_format_size(uint64 size) {
+    if(size == 0) {
+        return "?";
+    }
+    double value = size;
+    foreach(var unit in new string[] { "B", "KiB", "MiB", "GiB", "TiB" }) {
+        if(value < 1024.0 || unit == "TiB") {
+            return unit == "B" ? @"$size B" : "%.1f %s".printf(value, unit);
+        }
+        value /= 1024.0;
+    }
+    assert_not_reached();
+}
+
+private int dcba_repository_verify(string repository_file) {
+    try {
+        var json = new InvercargillJson.JsonElement.from_file(repository_file);
+        var repository = Usm.Repository.get_mapper().materialise(json.as<InvercargillJson.JsonObject>());
+
+        var stream = File.new_for_uri(repository.url + "/" + Usm.RepositoryClient.DCBA_LISTING_NAME).read();
+        var listing = new Usm.DcbaListing.from_stream(new DataInputStream(stream));
+
+        if(!listing.valid_signature(repository.key)) {
+            printerr("Invalid signature.\n");
+            return 1;
+        }
+        printerr(@"Valid signature for \"$(repository.name)\" artifact listing.\n");
+
+        var verified = 0;
+        var failures = 0;
+        foreach(var entry in listing.entries) {
+            var artifact_uri = repository.url.has_suffix("/") ? repository.url + entry.path : repository.url + "/" + entry.path;
+            try {
+                var artifact_stream = File.new_for_uri(artifact_uri).read();
+                if(repository_stream_checksum(artifact_stream).equals(entry.sha512sum)) {
+                    printerr(@"Verified \"$(entry.path)\"\n");
+                    verified++;
+                }
+                else {
+                    printerr(@"Checksum mismatch for \"$(entry.path)\"\n");
+                    failures++;
+                }
+            }
+            catch(Error e) {
+                printerr(@"Could not verify \"$(entry.path)\": $(e.message)\n");
+                failures++;
+            }
+        }
+
+        if(failures > 0) {
+            printerr(@"$failures of $(verified + failures) artifact(s) failed verification\n");
+            return 1;
+        }
+        printerr(@"Deep-verified $verified artifact(s) in \"$(repository.name)\"\n");
+        return 0;
+    }
+    catch(Error e) {
+        printerr(@"Error: $(e.message)\n");
+        return e.code;
+    }
+}
+
+private uint64 dcba_modification_time(string path) throws Error {
+    return File.new_for_path(path).query_info(FileAttribute.TIME_MODIFIED, FileQueryInfoFlags.NONE).get_attribute_uint64(FileAttribute.TIME_MODIFIED);
+}
+
+private int dcba_repository_publish(string destination_path) {
+    var root = dcba_find_root();
+    if(root == null) {
+        printerr("No DCBA repository root found (a directory containing a *.dcbar file, \"keys/\" and \"public/\" at or above the current directory)\n");
+        return 1;
+    }
+
+    var repo_file = dcba_find_repo_file(root);
+    if(repo_file == null) {
+        printerr("No *.dcbar file found in the DCBA repository root\n");
+        return 1;
+    }
+
+    var public_dir = Path.build_filename(root, "public");
+    var listing_path = Path.build_filename(public_dir, Usm.RepositoryClient.DCBA_LISTING_NAME);
+    if(!FileUtils.test(listing_path, FileTest.EXISTS)) {
+        printerr("No ARTIFACTS.usml in \"public/\", add an artifact with `usm dcba repository add` or run `usm dcba repository build-list` first\n");
+        return 1;
+    }
+
+    try {
+        var destination = File.new_for_path(destination_path);
+        if(!destination.query_exists()) {
+            destination.make_directory_with_parents();
+        }
+
+        File.new_for_path(repo_file).copy(destination.get_child(Path.get_basename(repo_file)), FileCopyFlags.OVERWRITE);
+        printerr(@"Published \"$(Path.get_basename(repo_file))\"\n");
+
+        File.new_for_path(listing_path).copy(destination.get_child(Usm.RepositoryClient.DCBA_LISTING_NAME), FileCopyFlags.OVERWRITE);
+        printerr(@"Published \"ARTIFACTS.usml\"\n");
+
+        foreach(var file in Iterate.directory(public_dir)) {
+            if(!file.has_suffix(".dcba")) {
+                continue;
+            }
+
+            var source_path = Path.build_filename(public_dir, file);
+            var destination_file = destination.get_child(file);
+            if(destination_file.query_exists()
+                && dcba_modification_time(destination_file.get_path()) >= dcba_modification_time(source_path)) {
+                printerr(@"Skipped \"$file\" (up to date)\n");
+                continue;
+            }
+
+            File.new_for_path(source_path).copy(destination_file, FileCopyFlags.OVERWRITE);
+            printerr(@"Published \"$file\"\n");
+        }
+    }
+    catch(Error e) {
+        printerr(@"Error: $(e.message)\n");
+        return e.code;
+    }
+
+    return 0;
+}
+
+private int dcba_repository_build_list() {
+    var root = dcba_find_root();
+    if(root == null) {
+        root = Environment.get_current_dir();
+    }
+
+    uint8[] public_key;
+    uint8[] private_key;
+    if(!dcba_load_keys(root, out public_key, out private_key)) {
+        return 1;
+    }
+
+    return dcba_rebuild_listing(root, public_key, private_key);
+}
+
+/**
+ * Rebuilds and re-signs `public/ARTIFACTS.usml` for the DCBA repository
+ * rooted at `root`, the shared implementation behind `add`, `remove`
+ * and `build-list` — a thin CLI shell over
+ * {@link Usm.DcbaListingWriter.write}.
+ */
+private int dcba_rebuild_listing(string root, uint8[] public_key, uint8[] private_key) {
+    try {
+        var public_dir = Path.build_filename(root, "public");
+        var entries = new Vector<Usm.DcbaListingEntry>();
+        foreach(var file in Iterate.directory(public_dir)) {
+            var path = Path.build_filename(public_dir, file);
+            if(!path.has_suffix(".dcba")) {
+                continue;
+            }
+
+            var identity = Usm.DcbaArtifact.parse_filename(file);
+            if(identity == null) {
+                throw new IOError.INVALID_ARGUMENT(@"\"$file\" does not carry a parseable version, rename it to <name>-<version>.dcba\n");
+            }
+
+            entries.add(new Usm.DcbaListingEntry() {
+                name = ((!)identity).name,
+                version = ((!)identity).version,
+                path = file,
+                sha512sum = calculate_file_checksum(path),
+                size = (uint64)File.new_for_path(path).query_info(FileAttribute.STANDARD_SIZE, FileQueryInfoFlags.NONE).get_size()
+            });
+        }
+
+        Usm.DcbaListingWriter.write(Path.build_filename(public_dir, Usm.RepositoryClient.DCBA_LISTING_NAME), entries, public_key, private_key);
+    }
+    catch(Error e) {
+        printerr(@"Error: $(e.message)\n");
+        return e.code;
+    }
+    return 0;
+}
+namespace Usm.Cli {
+
+    /**
+     * `usm dcba add-repo <url-or-path>` — adds a **DCBA** build-artifact
+     * repository (`.dcbar` descriptor) to the configuration; the
+     * {@link AddRepoRunner} shell around the `.dcbar`
+     * {@link AddRepoDefinition}. Mirrors `usm add-repo` verb for verb,
+     * refreshing `ARTIFACTS.usml` instead of `PACKAGES.usml`.
+     */
+    public class DcbaAddRepo {
+
+        public static int run(string[] args) {
+            var definition = new AddRepoDefinition() {
+                extension = ".dcbar",
+                refresh_noun = "artifacts",
+                usage_line = "usm dcba add-repo [-y|--yes] <url-or-path>",
+                followup_line = "Pre-built artifacts are now used automatically by usm install and usm downgrade\n",
+                refresh = destination => {
+                    var state = new Usm.SystemState(paths);
+                    var added = new Usm.Repository.from_file(destination);
+                    state.refresh_dcba_list(added);
+                    var listing = state.get_latest_dcba_list(added);
+                    return listing == null ? 0 : (int)listing.entries.count();
+                }
+            };
+            return AddRepoRunner.run(args, definition, 3);
+        }
+    }
+}

+ 4 - 0
src/cli/Downgrade.vala

@@ -191,6 +191,10 @@ private Usm.CachedPackage? downgrade_explicit(Usm.SystemState state, string pack
             client.download_package(package_path, entry, (f, c, t) => printerr(@"Downloading $f $c/$t bytes\r"));
             client.verify_package(package_path, entry, (f, c, t) => printerr(@"Verifying $f $c/$t bytes\r"));
             printerr("\n");
+            // A pre-built artifact from a configured DCBA repository (when
+            // one lists this exact version) saves the from-source build;
+            // every failure inside is a non-fatal warning
+            install_dcba_fetch(install_dcba_refresh(state), state, package_name, version, path);
             return new Usm.CachedPackage(path);
         }
     }

+ 10 - 0
src/cli/Help.vala

@@ -30,6 +30,16 @@ namespace Usm.Cli {
             { "repository build-list", "Rebuild the signed repository listing" },
             { "add-repo <url>", "Add a remote repository to the configuration" }
         } },
+        { "DCBA ARTIFACTS", {
+            { "dcba repository init <name>", "Initialise a new DCBA artifact repository" },
+            { "dcba repository add <a.dcba>", "Add a build artifact to this DCBA repository" },
+            { "dcba repository remove <name>", "Remove an artifact from this DCBA repository" },
+            { "dcba repository list", "List artifacts (name version size) in a DCBA repository" },
+            { "dcba repository verify", "Verify DCBA listing signature + artifact checksums" },
+            { "dcba repository publish <dir>", "Publish DCBA repository files to a directory" },
+            { "dcba repository build-list", "Rebuild the signed DCBA artifact listing" },
+            { "dcba add-repo <url>", "Add a DCBA artifact repository to the configuration" }
+        } },
         { "SEARCH & QUERY", {
             { "search <query>", "Search repositories and installed packages" },
             { "provides <resource>", "Find which package provides a resource" },

+ 177 - 0
src/cli/Install.vala

@@ -241,6 +241,10 @@ private int install_main(string[] args) {
  * pre-grouping behaviour, kept for callers without a bar).
  */
 private void install_download_packages(Usm.SystemState state, Usm.ResolutionResult resolution, HashSet<string> skip_names, Usm.Cli.ProgressBar? progress = null) throws Error {
+    // DCBA listings are only worth refreshing once a transfer actually
+    // happens, so the sources are built lazily at the first download
+    var dcba_sources = new Vector<InstallDcbaSource>();
+    var dcba_ready = false;
     uint total_downloads = 0;
     if(progress != null) {
         foreach(var package in resolution.install_order) {
@@ -268,10 +272,15 @@ private void install_download_packages(Usm.SystemState state, Usm.ResolutionResu
             cache_dir.make_directory();
         }
         var package_path = Path.build_filename(path, "package.usmc");
+        if(!dcba_ready) {
+            dcba_sources = install_dcba_refresh(state);
+            dcba_ready = true;
+        }
         if(progress == null) {
             client.download_package(package_path, package.repository_entry, (f, c, t) => printerr(@"Downloading $f $c/$t bytes\r"));
             client.verify_package(package_path, package.repository_entry, (f, c, t) => printerr(@"Verifying $f $c/$t bytes\r"));
             printerr("\n");
+            install_dcba_fetch(dcba_sources, state, package.manifest.name, package.manifest.version.to_string(), path);
             continue;
         }
         client.download_package(package_path, package.repository_entry, (f, c, t) => {
@@ -282,6 +291,7 @@ private void install_download_packages(Usm.SystemState state, Usm.ResolutionResu
         completed_downloads++;
         var size = File.new_for_path(package_path).query_info(FileAttribute.STANDARD_SIZE, FileQueryInfoFlags.NONE).get_size();
         progress.phase_log(@"Downloaded $(Path.get_basename(package.repository_entry.path)) ($(install_format_size(size)))");
+        install_dcba_fetch(dcba_sources, state, package.manifest.name, package.manifest.version.to_string(), path, progress);
     }
 
     if(progress != null && total_downloads > 0) {
@@ -289,6 +299,173 @@ private void install_download_packages(Usm.SystemState state, Usm.ResolutionResu
     }
 }
 
+/**
+ * One configured DCBA repository paired with its (verified or not)
+ * artifact listing, built by {@link install_dcba_refresh} and consumed by
+ * {@link install_dcba_fetch}.
+ */
+private class InstallDcbaSource {
+    public Usm.Repository repository;
+    public Usm.DcbaListing? listing;
+
+    public InstallDcbaSource(Usm.Repository repository, Usm.DcbaListing? listing) {
+        this.repository = repository;
+        this.listing = listing;
+    }
+}
+
+/**
+ * The configured DCBA repositories with their listings refreshed and
+ * verified against each repository's key. A repository whose listing
+ * cannot be fetched, cannot be read, or does not carry a valid
+ * signature contributes null — its artifacts are simply unavailable (a
+ * warning says why) — because a missing pre-built artifact must never
+ * fail an install: the package just builds from source later.
+ */
+private Vector<InstallDcbaSource> install_dcba_refresh(Usm.SystemState state) {
+    var sources = new Vector<InstallDcbaSource>();
+    try {
+        foreach(var repository in state.get_dcba_repositories()) {
+            try {
+                state.refresh_dcba_list(repository);
+            }
+            catch(Error e) {
+                printerr(@"Warning: could not refresh DCBA repository \"$(repository.name)\": $(e.message)\n");
+            }
+            Usm.DcbaListing? listing = null;
+            try {
+                listing = state.get_latest_dcba_list(repository);
+                if(listing != null && !listing.valid_signature(repository.key)) {
+                    printerr(@"Warning: DCBA repository \"$(repository.name)\" artifact listing failed signature verification, ignoring its artifacts\n");
+                    listing = null;
+                }
+            }
+            catch(Error e) {
+                printerr(@"Warning: could not read DCBA repository \"$(repository.name)\" artifact listing: $(e.message)\n");
+            }
+            sources.add(new InstallDcbaSource(repository, listing));
+        }
+    }
+    catch(Error e) {
+        printerr(@"Warning: could not enumerate DCBA repositories: $(e.message)\n");
+    }
+    return sources;
+}
+
+/**
+ * Downloads the pre-built build artifact of `<name>-<version>` from the
+ * first DCBA repository in {@link sources} that lists it, checksum
+ * verifies it and moves it into place as `<cache_path>/build.tar.xz` —
+ * from then on USM treats it exactly like a locally built cache
+ * (incremental build reuse, `usm clean builds`). Everything that can go
+ * wrong here is a warning, never an error: with no DCBA repositories
+ * configured, no matching entry, an unreachable repository or a
+ * checksum mismatch the package simply builds from source. Skipped
+ * entirely when the cache directory already holds a build directory or
+ * archive — a local artifact always wins.
+ */
+private void install_dcba_fetch(Vector<InstallDcbaSource> sources, Usm.SystemState state, string name, string version, string cache_path, Usm.Cli.ProgressBar? progress = null) {
+    if(File.new_for_path(Path.build_filename(cache_path, "build")).query_exists()
+        || File.new_for_path(Path.build_filename(cache_path, "build.tar.xz")).query_exists()) {
+        return;
+    }
+
+    foreach(var source in sources) {
+        if(source.listing == null) {
+            continue;
+        }
+        var entry = ((!)source.listing).find(name, version);
+        if(entry == null) {
+            continue;
+        }
+
+        var temp_path = Path.build_filename(cache_path, @"$name-$version.dcba.part");
+        try {
+            var client = source.repository.get_client(state.config);
+            // Without a bar the transfer prints its own byte-level line, the
+            // pre-grouping behaviour the .usmc downloads keep; with one the
+            // retrieval is announced as a log line instead
+            Usm.RepositoryClientProgressCallback? artifact_progress = null;
+            if(progress == null) {
+                artifact_progress = (f, c, t) => printerr(@"Downloading artifact $f $c/$t bytes\r");
+            }
+            client.download_file(((!)entry).path, temp_path, artifact_progress);
+            if(!install_dcba_verify(temp_path, (!)entry)) {
+                throw new IOError.FAILED(@"checksum mismatch for artifact \"$name-$version.dcba\"");
+            }
+            File.new_for_path(temp_path).move(File.new_for_path(Path.build_filename(cache_path, "build.tar.xz")), FileCopyFlags.OVERWRITE);
+        }
+        catch(Error e) {
+            printerr("\n");
+            printerr(@"Warning: DCBA artifact for \"$name\" $version could not be used: $(e.message); building from source instead\n");
+            try {
+                File.new_for_path(temp_path).delete();
+            }
+            catch(Error delete_error) {
+            }
+            return;
+        }
+
+        if(progress != null) {
+            progress.phase_log(@"Retrieved pre-built artifact for $name $version from DCBA repository \"$(source.repository.name)\"");
+        }
+        else {
+            printerr(@"Retrieved pre-built artifact for \"$name\" $version from DCBA repository \"$(source.repository.name)\"\n");
+        }
+        install_dcba_record_origin(cache_path, source.repository.name);
+        return;
+    }
+}
+
+/**
+ * Whether {@link path} hashes to {@link entry}'s listing sha512 — the
+ * same streamed SHA-512 comparison {@link
+ * Usm.RepositoryClient.verify_package} applies to .usmc downloads.
+ */
+private bool install_dcba_verify(string path, Usm.DcbaListingEntry entry) throws Error {
+    var checksum = new Checksum(ChecksumType.SHA512);
+    var stream = File.new_for_path(path).read();
+
+    var buffer = new uint8[1024 * 1024];
+    while(true) {
+        var read = stream.read(buffer);
+        if(read == 0) {
+            break;
+        }
+        checksum.update(buffer, read);
+    }
+
+    var checksum_bytes = new uint8[ChecksumType.SHA512.get_length()];
+    size_t size = checksum_bytes.length;
+    checksum.get_digest(checksum_bytes, ref size);
+
+    return entry.sha512sum.equals(Wrap.array(checksum_bytes));
+}
+
+/**
+ * Records the DCBA provenance of an artifact just moved into a cache
+ * directory: the existing origin-info (if any) keeps every other field
+ * — and gains {@link Usm.OriginInformation.dcba}. Failure is a warning:
+ * provenance is a note, never a gate.
+ */
+private void install_dcba_record_origin(string cache_path, string repository_name) {
+    try {
+        var package = new Usm.CachedPackage(cache_path);
+        Usm.OriginInformation origin;
+        try {
+            origin = package.get_origin_information();
+        }
+        catch(Error e) {
+            origin = new Usm.OriginInformation();
+        }
+        origin.dcba = repository_name;
+        package.update_origin_information(origin);
+    }
+    catch(Error e) {
+        printerr(@"Warning: could not record DCBA provenance: $(e.message)\n");
+    }
+}
+
 /**
  * Whether {@link package} still needs its .usmc transferred: a
  * repository-backed package neither skipped by {@link skip_names} nor

+ 1 - 0
src/cli/meson.build

@@ -15,6 +15,7 @@ sources += files('Confirm.vala')
 sources += files('Search.vala')
 sources += files('Provides.vala')
 sources += files('AddRepo.vala')
+sources += files('Dcba.vala')
 sources += files('Deploy.vala')
 sources += files('Scaffold.vala')
 sources += files('GenConfig.vala')

+ 5 - 1
src/lib/Repository/CurlRepositoryClient.vala

@@ -37,13 +37,17 @@ namespace Usm {
         }
 
         public override void download_repository_listing(string path, RepositoryClientProgressCallback? callback = null) throws Error {
-            download("PACKAGES.usml", path, callback);
+            download(LISTING_NAME, path, callback);
         }
 
         public override void download_package(string path, RepositoryListingEntry package, RepositoryClientProgressCallback? callback = null) throws Error {
             download(package.path, path, callback);
         }
 
+        public override void download_file(string remote_path, string destination, RepositoryClientProgressCallback? callback = null) throws Error {
+            download(remote_path, destination, callback);
+        }
+
         private void download(string remote_path, string destination, RepositoryClientProgressCallback? callback) throws Error {
             var url = get_for_path(remote_path).to_string();
             var filename = Path.get_basename(remote_path);

+ 139 - 0
src/lib/Repository/DcbaArtifact.vala

@@ -0,0 +1,139 @@
+using Invercargill;
+using Invercargill.DataStructures;
+using InvercargillJson;
+
+namespace Usm {
+
+    /**
+     * The `<name>-<version>` identity a `.dcba` artifact filename
+     * carries, and the tooling that turns a directory of artifacts into
+     * a signed `ARTIFACTS.usml` — the library side of the
+     * `usm dcba repository` commands, kept linkable into the test suite.
+     */
+    public class DcbaArtifact {
+
+        public string name { get; set; }
+        public string version { get; set; }
+
+        public DcbaArtifact(string name, string version) {
+            this.name = name;
+            this.version = version;
+        }
+
+        /**
+         * Derives name and version from an artifact filename: strip
+         * `.dcba`, then split at the last `-` whose suffix parses as a
+         * {@link Version} — so `lib-base-1.2+3.dcba` is
+         * lib-base/1.2+3 while a suffix like `x86_64` (no Version form)
+         * sends the search to the next dash further left. Returns null
+         * when no dash splits off a parseable version; the caller asks
+         * for a rename to <name>-<version>.dcba.
+         */
+        public static DcbaArtifact? parse_filename(string filename) {
+            if(!filename.has_suffix(".dcba")) {
+                return null;
+            }
+            var stem = filename.substring(0, filename.length - ".dcba".length);
+            if(stem.length == 0) {
+                return null;
+            }
+
+            while(true) {
+                var dash = stem.last_index_of("-");
+                if(dash < 0) {
+                    return null;
+                }
+                var name = stem.substring(0, dash);
+                var version = stem.substring(dash + 1);
+                if(name.length == 0 || version.length == 0) {
+                    return null;
+                }
+                try {
+                    new Version.from_string(version);
+                    return new DcbaArtifact(name, version);
+                }
+                catch(Error e) {
+                    // Not a version; continue from the prefix ending
+                    // before this dash so a package name containing
+                    // dashes keeps its components
+                    stem = stem.substring(0, dash);
+                    if(stem.length == 0) {
+                        return null;
+                    }
+                }
+            }
+        }
+
+        /**
+         * Whether {@link other} names the same artifact: an exact
+         * name+version pair. A different version of the same package is
+         * a different artifact, not a conflict.
+         */
+        public bool same_artifact(DcbaArtifact other) {
+            return name == other.name && version == other.version;
+        }
+    }
+
+    /**
+     * Writes a signed `ARTIFACTS.usml` from listing entries — the
+     * serialisation half of the DCBA repository tooling.
+     *
+     * The bytes are exactly what {@link DcbaListing} re-parses: one
+     * compact-JSON line per entry (the mapper property order, plus the
+     * leading `type` field), the SHA-512 signature computed over the
+     * bare serialised entry lines (the parser"s read_line strips the
+     * newline), and a terminating signatures line with no trailing
+     * newline.
+     */
+    public class DcbaListingWriter {
+
+        public static void write(string path, Enumerable<DcbaListingEntry> entries, uint8[] public_key, uint8[] private_key) throws Error {
+            var output = new DataOutputStream(File.new_for_path(path).replace(null, false, FileCreateFlags.REPLACE_DESTINATION));
+            try {
+                var checksum = new Checksum(ChecksumType.SHA512);
+                var mapper = DcbaListingEntry.get_mapper();
+
+                foreach(var entry in entries) {
+                    var properties = new JsonElement.from_properties(mapper.map_from(entry)).as<JsonObject>();
+                    properties.set("type", new JsonElement.from_element(new NativeElement<string>("dcba")));
+                    var serialised = dcba_compact_json(properties.as_element());
+                    checksum.update(serialised.data, serialised.data.length);
+                    output.put_string(serialised);
+                    output.put_string("\n");
+                }
+
+                var buffer = new uint8[ChecksumType.SHA512.get_length()];
+                size_t buffer_length = buffer.length;
+                checksum.get_digest(buffer, ref buffer_length);
+                var signed = Sodium.Asymmetric.Signing.sign(buffer, private_key);
+
+                var signature = new RepositoryListingSignature() {
+                    key = Wrap.byte_array(public_key),
+                    signature = Wrap.byte_array(signed)
+                };
+                var signature_entry = new PropertyDictionary();
+                signature_entry.set("type", new NativeElement<string>("signatures"));
+                signature_entry.set("signatures", new NativeElement<Enumerable<Element>>(Iterate.single(RepositoryListingSignature.get_mapper().map_from(signature)).to_elements()));
+
+                output.put_string(dcba_compact_json(new JsonElement.from_properties(signature_entry)));
+                output.close();
+            }
+            catch(Error e) {
+                try {
+                    output.close();
+                }
+                catch(Error close_error) {
+                }
+                throw e;
+            }
+        }
+
+        /** Serialises `element` as compact JSON (no indentation), one listing line per element. */
+        private static string dcba_compact_json(JsonElement element) {
+            var output = new MemoryOutputStream(null);
+            element.write_to_stream(output);
+            output.close();
+            return (string)((!)((MemoryOutputStream)((!)output)).steal_as_bytes().get_data());
+        }
+    }
+}

+ 126 - 0
src/lib/Repository/DcbaListing.vala

@@ -0,0 +1,126 @@
+using Invercargill;
+using Invercargill.Mapping;
+using Invercargill.DataStructures;
+using InvercargillJson;
+
+namespace Usm {
+
+    /**
+     * The artifact listing of a DCBA repository — `ARTIFACTS.usml`,
+     * the `PACKAGES.usml` counterpart distributing build artifacts.
+     *
+     * The format mirrors {@link RepositoryListing} line for line: JSONL
+     * with one entry per `.dcba` file and a terminating `signatures` line.
+     * The signed checksum is the SHA-512 over the concatenated entry
+     * lines exactly as the stream parser feeds them — `read_line` strips
+     * the newline terminator, so the digest covers the bare serialised
+     * JSON of each entry, matching both {@link RepositoryListing} and the
+     * repository tooling's writer. The listing must end with a
+     * signatures line; a stream that stops early is an error.
+     */
+    public class DcbaListing {
+
+        public Enumerable<DcbaListingEntry> entries { get; set; }
+        public Enumerable<RepositoryListingSignature> signatures { get; set; }
+        public BinaryData checksum { get; set; }
+
+        public DcbaListing.from_stream(DataInputStream stream) throws Error {
+            var entries = new Vector<DcbaListingEntry>();
+            this.entries = entries;
+            var checksum = new Checksum(ChecksumType.SHA512);
+            while(true) {
+                var line = stream.read_line();
+                if(line == null) {
+                    break;
+                }
+                var json = new JsonElement.from_string(line).as<JsonObject>();
+                var type = json.get_string("type");
+
+                if(type == "dcba") {
+                    checksum.update(line.data, line.data.length);
+                    entries.add(DcbaListingEntry.get_mapper().materialise(json));
+                    continue;
+                }
+
+                if(type == "signatures") {
+                    var mapper = RepositoryListingSignature.get_mapper();
+                    signatures = json
+                        .get_array("signatures")
+                        .as_objects()
+                        .attempt_select<RepositoryListingSignature>(j => mapper.materialise(j))
+                        .to_vector();
+
+                    var buffer = new uint8[ChecksumType.SHA512.get_length()];
+                    size_t buffer_length = buffer.length;
+                    checksum.get_digest(buffer, ref buffer_length);
+                    this.checksum = Wrap.byte_array(buffer);
+                    return;
+                }
+
+                if(type != null) {
+                    warning(@"Unknown entry type \"$type\" in DCBA artifact listing, ignoring");
+                }
+                else {
+                    warning("Entry with no type found, ignoring");
+                }
+            }
+            throw new IOError.INVALID_ARGUMENT("DCBA artifact listing ended without a signatures line");
+        }
+
+        public bool valid_signature(BinaryData key) {
+            var signature = signatures.first_or_default(s => s.key.equals(key));
+            if(signature == null) {
+                return false;
+            }
+
+            var signed = Sodium.Asymmetric.Signing.verify(signature.signature.to_array(), key.to_array());
+            if(signed == null) {
+                return false;
+            }
+
+            return checksum.equals(Wrap.array(signed));
+        }
+
+        /**
+         * The entry whose name and version match {@link name} and
+         * {@link version} exactly (a plain string comparison on the
+         * version, so listing strings never mutate through a
+         * {@link Version} round trip), or null when this repository
+         * carries no artifact for that package.
+         */
+        public DcbaListingEntry? find(string name, string version) {
+            return entries.first_or_default(e => e.name == name && e.version == version);
+        }
+    }
+
+    /**
+     * One `.dcba` artifact in a {@link DcbaListing}: name and version of
+     * the package it was built from, its repository-relative path, and
+     * the SHA-512/size clients verify the download against.
+     */
+    public class DcbaListingEntry {
+        public string name { get; set; }
+        public string version { get; set; }
+        public string path { get; set; }
+        public BinaryData sha512sum { get; set; }
+
+        /**
+         * The artifact's size in bytes; 0 marks listings built before the
+         * field existed, mirroring {@link RepositoryListingEntry.size}.
+         */
+        public uint64 size { get; set; default = 0; }
+
+
+        public static PropertyMapper<DcbaListingEntry> get_mapper() {
+            return PropertyMapper.build_for<DcbaListingEntry>(cfg => {
+                cfg.map<string>("name", o => o.name, (o, v) => o.name = v);
+                cfg.map<string>("version", o => o.version, (o, v) => o.version = v);
+                cfg.map<string>("path", o => o.path, (o, v) => o.path = v);
+                cfg.map<string>("sha512", o => o.sha512sum.to_base64(), (o, v) => o.sha512sum = Wrap.base64_string(v));
+                cfg.map<uint64?>("size", o => o.size, (o, v) => o.size = v)
+                    .when_undefined(o => o.size = 0);
+                cfg.set_constructor(() => new DcbaListingEntry());
+            });
+        }
+    }
+}

+ 7 - 1
src/lib/Repository/GioRepositoryClient.vala

@@ -7,7 +7,7 @@ namespace Usm {
         }
 
         public override void download_repository_listing (string path, Usm.RepositoryClientProgressCallback? callback = null) throws Error {
-            var filename = "PACKAGES.usml";
+            var filename = LISTING_NAME;
             var file = File.new_for_uri (get_for_path(filename).to_string());
             file.copy(File.new_for_path (path), FileCopyFlags.OVERWRITE, null, (c, t) => call_to(callback, filename, c, t));
         }
@@ -18,6 +18,12 @@ namespace Usm {
             file.copy(File.new_for_path (path), FileCopyFlags.OVERWRITE, null, (c, t) => call_to(callback, filename, c, t));
         }
 
+        public override void download_file (string remote_path, string destination, Usm.RepositoryClientProgressCallback? callback = null) throws Error {
+            var filename = Path.get_basename(remote_path);
+            var file = File.new_for_uri (get_for_path(remote_path).to_string());
+            file.copy(File.new_for_path(destination), FileCopyFlags.OVERWRITE, null, (c, t) => call_to(callback, filename, c, t));
+        }
+
         private Uri get_for_path(string path) throws Error {
             var uri = Uri.parse (repository_config.url, UriFlags.NONE);
             return Uri.build (UriFlags.NONE, uri.get_scheme (), uri.get_userinfo (), uri.get_host (), uri.get_port (), Path.build_filename (uri.get_path (), path), uri.get_query (), uri.get_fragment ());

+ 24 - 0
src/lib/Repository/RepositoryClient.vala

@@ -6,12 +6,36 @@ namespace Usm {
 
     public abstract class RepositoryClient {
 
+        /**
+         * The repository's package listing file every USM source
+         * repository serves at its root (see
+         * {@link download_repository_listing}).
+         */
+        public const string LISTING_NAME = "PACKAGES.usml";
+
+        /**
+         * The artifact listing file a DCBA repository serves at its root
+         * instead of {@link LISTING_NAME}: same JSONL shape and signing
+         * protocol, one `dcba` entry per build artifact (see
+         * {@link DcbaListing}).
+         */
+        public const string DCBA_LISTING_NAME = "ARTIFACTS.usml";
+
         public Repository repository_config { get; protected set; }
 
         public abstract void download_repository_listing(string path, RepositoryClientProgressCallback? callback = null)  throws Error;
 
         public abstract void download_package(string path, RepositoryListingEntry package, RepositoryClientProgressCallback? callback = null)  throws Error;
 
+        /**
+         * Downloads the repository file at {@link remote_path} — relative
+         * to the repository root, e.g. `name-1.0.0.dcba` — onto the local
+         * {@link destination}. The generic by-path counterpart of
+         * {@link download_package}, used for artifacts that are not
+         * .usmc packages (DCBA build artifacts).
+         */
+        public abstract void download_file(string remote_path, string destination, RepositoryClientProgressCallback? callback = null) throws Error;
+
         public virtual bool verify_package(string path, RepositoryListingEntry package, RepositoryClientProgressCallback? callback = null) throws Error {
             var checksum = new Checksum(ChecksumType.SHA512);
             var stream = File.new_for_path(path).read();

+ 61 - 2
src/lib/State/CachedPackage.vala

@@ -83,9 +83,21 @@ namespace Usm {
             }
 
             Util.archive(dir_path, path);
+            write_build_digest(Util.tree_digest(dir_path));
             return path;
         }
 
+        /**
+         * Archives the build directory as the package's build.tar.xz,
+         * re-archiving only when the build tree actually changed since
+         * the archive was created. The tree state that produced an
+         * archive is recorded next to it in build.tar.xz.digest (see
+         * {@link write_build_digest}); a matching digest means the
+         * existing archive is already authoritative, so it is kept
+         * byte-identical (mtime untouched) and only the build directory
+         * is cleaned. A missing sidecar (an archive from before digests
+         * were recorded) or a different tree re-archives as before.
+         */
         public void archive_build(Usm.ProgressDelegate? progress = null) throws Error {
             // An archive with no build directory is already the
             // authoritative artifact: extracting it just to repack the
@@ -95,14 +107,27 @@ namespace Usm {
             }
 
             var build_dir = get_build_directory();
+            var archive_path = build_archive_path();
+            var archive_file = File.new_for_path(archive_path);
 
-            // Delete existing archive, if it exists
-            var archive_file = File.new_for_path(build_archive_path());
             if(archive_file.query_exists()) {
+                var digest = Util.tree_digest(build_dir);
+                if(read_build_digest() == digest) {
+                    // Nothing changed since this archive was created:
+                    // re-tarring would rewrite identical bytes. The
+                    // progress delegate still completes so callers'
+                    // progress bars finish.
+                    if(progress != null) {
+                        progress(1.0f);
+                    }
+                    clean_build_directory();
+                    return;
+                }
                 archive_file.delete();
             }
 
             Util.archive_with_progress(build_dir, archive_file.get_path(), progress);
+            write_build_digest(Util.tree_digest(build_dir));
             clean_build_directory();
         }
 
@@ -129,6 +154,10 @@ namespace Usm {
             if(archive_file.query_exists()) {
                 archive_file.delete();
             }
+            var digest = File.new_for_path(build_digest_path());
+            if(digest.query_exists()) {
+                digest.delete();
+            }
         }
 
         /**
@@ -183,6 +212,36 @@ namespace Usm {
             return Path.build_filename(state_path, "build.tar.xz");
         }
 
+        private string build_digest_path() {
+            return Path.build_filename(state_path, "build.tar.xz.digest");
+        }
+
+        /**
+         * Records {@link digest} — the build-tree digest of the tree that
+         * produced the current archive — in the build.tar.xz.digest
+         * sidecar next to it. Writing it is part of every archive
+         * creation (see {@link archive_build}).
+         */
+        private void write_build_digest(string digest) throws Error {
+            FileUtils.set_contents(build_digest_path(), digest + "\n");
+        }
+
+        /**
+         * The digest recorded for the current archive, or null when no
+         * sidecar exists (an archive from before digests were recorded,
+         * or a cache with no archive at all).
+         */
+        private string? read_build_digest() {
+            string contents = null;
+            try {
+                FileUtils.get_contents(build_digest_path(), out contents);
+            }
+            catch(Error e) {
+                return null;
+            }
+            return contents != null ? contents.strip() : null;
+        }
+
         private string source_directory_path() {
             return Path.build_filename(state_path, "package");
         }

+ 12 - 0
src/lib/State/OriginInformation.vala

@@ -10,6 +10,14 @@ namespace Usm {
         public string original_path { get; set; }
         public bool signature_verified { get; set; }
 
+        /**
+         * The name of the DCBA repository a pre-built artifact for this
+         * package was downloaded from, when one was; null when the
+         * package's build artifact came from a local build (or a record
+         * written before the field existed).
+         */
+        public string dcba { get; set; }
+
         /**
          * Whether the user asked for this package by name (`usm install
          * foo`) rather than receiving it as a resolved dependency; the
@@ -34,6 +42,10 @@ namespace Usm {
                 cfg.map<bool>("signature_verified", o => o.signature_verified, (o, v) => o.signature_verified = v);
                 cfg.map<bool>("explicitly_installed", o => o.explicitly_installed, (o, v) => o.explicitly_installed = v)
                     .when_undefined(o => o.explicitly_installed = false);
+                cfg.map<string>("dcba", o => o.dcba, (o, v) => o.dcba = v)
+                    .null_when(o => o.dcba == null)
+                    .when_null(o => o.dcba = null)
+                    .when_undefined(o => o.dcba = null);
             });
         }
 

+ 74 - 0
src/lib/State/State.vala

@@ -108,8 +108,30 @@ namespace Usm {
             return versions;
         }
 
+        /**
+         * The configured USM source repositories: every `.usmr`
+         * descriptor in `repos.d`. Other extensions (a DCBA repository's
+         * `.dcbar`, stray READMEs) are ignored — the directory holds more
+         * than one repository kind.
+         */
         public Enumerable<Repository> get_repositories() throws Error {
             return Iterate.directory(Path.build_filename(config_path, "repos.d"))
+                .where(d => d.has_suffix(".usmr"))
+                .attempt_select<Repository>(d => new Repository.from_file(Path.build_filename(config_path, "repos.d", d)))
+                .to_series();
+        }
+
+        /**
+         * The configured DCBA repositories — build-artifact
+         * distributors, described by `.dcbar` descriptors in the same
+         * `repos.d` directory with the same shape as `.usmr` — parsed
+         * through the same {@link Repository} mapper. A DCBA repository
+         * is built for exactly one platform; which one is a property of
+         * the artifacts it serves, not of the descriptor.
+         */
+        public Enumerable<Repository> get_dcba_repositories() throws Error {
+            return Iterate.directory(Path.build_filename(config_path, "repos.d"))
+                .where(d => d.has_suffix(".dcbar"))
                 .attempt_select<Repository>(d => new Repository.from_file(Path.build_filename(config_path, "repos.d", d)))
                 .to_series();
         }
@@ -158,6 +180,58 @@ namespace Usm {
             client.download_repository_listing(Path.build_filename(list_path, filename), callback);
         }
 
+        /**
+         * The newest cached `ARTIFACTS.usml` of the DCBA repository
+         * {@link repository}, or null when none has been fetched yet —
+         * the {@link DcbaListing} counterpart of {@link get_latest_list},
+         * sharing the same `lists/<name>/<timestamp>.usml` layout.
+         */
+        public DcbaListing? get_latest_dcba_list(Repository repository) throws Error {
+            var list_path = Path.build_filename(state_path, "lists", repository.name);
+            if(!File.new_for_path(list_path).query_exists()) {
+                return null;
+            }
+
+            string? latest_name = null;
+            DateTime? latest_time = null;
+            foreach(var file in Iterate.directory(list_path)) {
+                if(!file.has_suffix(".usml")) {
+                    continue;
+                }
+                var time = new DateTime.from_iso8601(file.replace(".usml", ""), null);
+                if(time == null) {
+                    continue;
+                }
+                if(latest_time == null || time.compare(latest_time) > 0) {
+                    latest_time = time;
+                    latest_name = file;
+                }
+            }
+            if(latest_name == null) {
+                return null;
+            }
+
+            var stream = new DataInputStream(File.new_build_filename(list_path, latest_name).read());
+            return new DcbaListing.from_stream(stream);
+        }
+
+        /**
+         * Fetches the DCBA repository's remote `ARTIFACTS.usml` into the
+         * local listing cache, the {@link refresh_list} counterpart for
+         * artifact listings.
+         */
+        public void refresh_dcba_list(Repository repository, RepositoryClientProgressCallback? callback = null) throws Error {
+            var client = repository.get_client(config);
+
+            var list_path = Path.build_filename(state_path, "lists", repository.name);
+            if(!File.new_for_path(list_path).query_exists()) {
+                File.new_for_path(list_path).make_directory();
+            }
+
+            var filename = @"$(new DateTime.now_utc().format_iso8601()).usml";
+            client.download_file(RepositoryClient.DCBA_LISTING_NAME, Path.build_filename(list_path, filename), callback);
+        }
+
         /**
          * Cache-directory names (e.g. "statum-0.1", the package's
          * {@link CachedPackage.package_name}) of every INSTALLED package

+ 113 - 1
src/lib/Util.vala

@@ -140,4 +140,116 @@ namespace Usm.Util {
         proc.wait_check();
     }
 
-}
+    /**
+     * A deterministic SHA-512 digest of the directory tree rooted at
+     * `root`, base64-encoded: a canonical description of every entry —
+     * sorted relative paths (byte order), the entry's file type, its
+     * mode where significant (permissions of files and directories, the
+     * target string of a symbolic link) and the full bytes of every
+     * regular file — folded into one checksum. The relative path is part
+     * of each record, so boundaries are unambiguous and a file's
+     * location always contributes to the result.
+     *
+     * The digest is only ever compared with other digests of the same
+     * tree: it identifies *change*, not content. An entry that cannot be
+     * read throws rather than being silently skipped, because a skipped
+     * entry would make an out-of-date archive look up to date.
+     */
+    public static string tree_digest(string root) throws Error {
+        var checksum = new Checksum(ChecksumType.SHA512);
+        digest_tree(root, "", checksum);
+        var buffer = new uint8[ChecksumType.SHA512.get_length()];
+        size_t buffer_length = buffer.length;
+        checksum.get_digest(buffer, ref buffer_length);
+        return Base64.encode(buffer);
+    }
+
+    /**
+     * Feeds {@link checksum} the canonical record of every entry below
+     * `root` at {@link relative}, depth-first and in byte-sorted path
+     * order. Depth-first keeps sibling records contiguous; sorting the
+     * children of each directory yields a deterministic total order.
+     */
+    private static void digest_tree(string root, string relative, Checksum checksum) throws Error {
+        var names = new Vector<string>();
+        var enumerator = File.new_build_filename(root, relative).enumerate_children(
+            FileAttribute.STANDARD_NAME, FileQueryInfoFlags.NOFOLLOW_SYMLINKS);
+        FileInfo child_info = null;
+        while((child_info = enumerator.next_file()) != null) {
+            names.add(child_info.get_name());
+        }
+        // GLib.CompareFunc<string> sorts strcmp-style, i.e. by byte value
+        names.sort((a, b) => { return strcmp(a, b); });
+
+        foreach(var name in names) {
+            var child_relative = relative.length == 0 ? name : Path.build_filename(relative, name);
+            digest_entry(root, child_relative, checksum);
+        }
+    }
+
+    /**
+     * Feeds {@link checksum} one entry's record: a header naming the
+     * relative path, the type tag and the type-significant data (mode
+     * digits for files and directories, the link target string for
+     * symlinks, then the file bytes for a regular file). Every field is
+     * newline-terminated so a name or target containing the separator
+     * cannot be confused with a field boundary.
+     */
+    private static void digest_entry(string root, string relative, Checksum checksum) throws Error {
+        var file = File.new_build_filename(root, relative);
+        var info = file.query_info(
+            FileAttribute.STANDARD_TYPE + "," + FileAttribute.STANDARD_SYMLINK_TARGET + "," + FileAttribute.UNIX_MODE,
+            FileQueryInfoFlags.NOFOLLOW_SYMLINKS);
+
+        var record = new StringBuilder();
+        record.append("path\n");
+        record.append(relative);
+        record.append("\n");
+
+        var type = info.get_file_type();
+        switch(type) {
+            case FileType.DIRECTORY:
+                record.append_printf("directory %04o\n", info.get_attribute_uint32(FileAttribute.UNIX_MODE) & 0777);
+                digest_string(checksum, record.str);
+                digest_tree(root, relative, checksum);
+                return;
+            case FileType.SYMBOLIC_LINK:
+                record.append("symlink\n");
+                record.append(info.get_attribute_byte_string(FileAttribute.STANDARD_SYMLINK_TARGET) ?? "");
+                record.append("\n");
+                digest_string(checksum, record.str);
+                return;
+            case FileType.REGULAR:
+                record.append_printf("file %04o\n", info.get_attribute_uint32(FileAttribute.UNIX_MODE) & 0777);
+                digest_string(checksum, record.str);
+                digest_file(checksum, file);
+                return;
+            default:
+                // FIFOs, sockets and devices carry no portable
+                // description; hash them by name alone
+                record.append_printf("special %04o\n", info.get_attribute_uint32(FileAttribute.UNIX_MODE) & 0777);
+                digest_string(checksum, record.str);
+                return;
+        }
+    }
+
+    /** Feeds one string record to {@link checksum}. */
+    private static void digest_string(Checksum checksum, string record) {
+        checksum.update(record.data, record.data.length);
+    }
+
+    /** Feeds the full bytes of `file` to {@link checksum}. */
+    private static void digest_file(Checksum checksum, File file) throws Error {
+        var stream = file.read();
+        const int chunk_size = 1024 * 1024;
+        var buffer = new uint8[chunk_size];
+        while(true) {
+            var read = stream.read(buffer);
+            if(read == 0) {
+                break;
+            }
+            checksum.update(buffer, read);
+        }
+    }
+
+}

+ 2 - 0
src/lib/meson.build

@@ -23,6 +23,8 @@ sources += files('Ignore/UsmIgnore.vala')
 sources += files('TransactionSummary.vala')
 sources += files('Repository/Repository.vala')
 sources += files('Repository/RepositoryListing.vala')
+sources += files('Repository/DcbaListing.vala')
+sources += files('Repository/DcbaArtifact.vala')
 sources += files('Repository/RepositoryClient.vala')
 sources += files('Repository/GioRepositoryClient.vala')
 sources += files('Repository/CurlRepositoryClient.vala')

+ 602 - 0
src/tests/TestMain.vala

@@ -256,6 +256,253 @@ namespace Usm.Tests {
         check(pre_upgrade.size == 0, "a pre-upgrade entry without size materialises as 0");
     }
 
+    // ---- DCBA artifact listings --------------------------------------------------
+
+    /** Streams {@link lines} (each already newline-free) into a listing parser the way a file read would. */
+    Usm.DcbaListing dcba_listing_from_lines(string[] lines) throws Error {
+        var builder = new StringBuilder();
+        foreach(var line in lines) {
+            builder.append_printf("%s\n", line);
+        }
+        var stream = new DataInputStream(new MemoryInputStream.from_data(builder.str.data));
+        return new Usm.DcbaListing.from_stream(stream);
+    }
+
+    /**
+     * Builds a signed ARTIFACTS.usml body for {@link entry_lines} with the
+     * protocol the repository tooling uses: SHA-512 over the bare
+     * serialised entry lines (no newline), ed25519-signed.
+     */
+    string dcba_signed_listing(string[] entry_lines, uint8[] public_key, uint8[] private_key) throws Error {
+        var checksum = new Checksum(ChecksumType.SHA512);
+        foreach(var line in entry_lines) {
+            checksum.update(line.data, line.data.length);
+        }
+        var buffer = new uint8[ChecksumType.SHA512.get_length()];
+        size_t length = buffer.length;
+        checksum.get_digest(buffer, ref length);
+        var signed = Sodium.Asymmetric.Signing.sign(buffer, private_key);
+
+        var signatures = new StringBuilder();
+        signatures.append("{\"type\":\"signatures\",\"signatures\":[");
+        signatures.append_printf("{\"key\":\"%s\",\"signature\":\"%s\"}",
+            Base64.encode(public_key), Base64.encode(signed));
+        signatures.append("]}");
+
+        var listing = new StringBuilder();
+        foreach(var line in entry_lines) {
+            listing.append_printf("%s\n", line);
+        }
+        listing.append(signatures.str);
+        return listing.str;
+    }
+
+    void test_dcba_listing_parses_entries_and_signatures() throws Error {
+        var public_key = new uint8[Sodium.Asymmetric.Signing.PUBLIC_KEY_BYTES];
+        var private_key = new uint8[Sodium.Asymmetric.Signing.SECRET_KEY_BYTES];
+        Sodium.Asymmetric.Signing.generate_keypair(public_key, private_key);
+
+        var entries = new string[] {
+            "{\"type\":\"dcba\",\"name\":\"app\",\"version\":\"1.0.0\",\"path\":\"app-1.0.0.dcba\",\"sha512\":\"AQID\",\"size\":128}",
+            "{\"type\":\"dcba\",\"name\":\"app\",\"version\":\"1.2\",\"path\":\"app-1.2.dcba\",\"sha512\":\"BQE=\"}"
+        };
+        var listing = dcba_listing_from_lines(
+            new string[] { entries[0], entries[1], "{\"type\":\"signatures\",\"signatures\":[]}" });
+
+        check(listing.entries.count() == 2, "a DCBA listing parses every dcba entry line");
+        var first = listing.entries.first_or_default(e => true);
+        check(first != null && ((!)first).name == "app" && ((!)first).version == "1.0.0"
+            && ((!)first).path == "app-1.0.0.dcba" && ((!)first).size == 128, "entry fields round-trip verbatim");
+        var second = listing.entries.first_or_default(e => e.version == "1.2");
+        check(second != null && ((!)second).size == 0, "an entry without size materialises as 0");
+        check(first != null && ((!)first).sha512sum.to_array().length == 3, "the base64 sha512 decodes to its raw bytes");
+
+        // Sign/verify round trip with the protocol the repository tooling uses
+        var signed = dcba_signed_listing(entries, public_key, private_key);
+        var verified = new Usm.DcbaListing.from_stream(new DataInputStream(new MemoryInputStream.from_data(signed.data)));
+        check(verified.valid_signature(Wrap.byte_array(public_key)), "a signed DCBA listing verifies against its key");
+
+        // Tamper detection: one flipped byte in an entry invalidates the signature
+        var tampered = signed.replace("app-1.2.dcba", "app-1.3.dcba");
+        var broken = new Usm.DcbaListing.from_stream(new DataInputStream(new MemoryInputStream.from_data(tampered.data)));
+        check(!broken.valid_signature(Wrap.byte_array(public_key)), "a flipped byte in an entry fails signature verification");
+
+        // A wrong key never verifies, even untouched
+        var other_public = new uint8[Sodium.Asymmetric.Signing.PUBLIC_KEY_BYTES];
+        var other_private = new uint8[Sodium.Asymmetric.Signing.SECRET_KEY_BYTES];
+        Sodium.Asymmetric.Signing.generate_keypair(other_public, other_private);
+        check(!verified.valid_signature(Wrap.byte_array(other_public)), "an unrelated key fails signature verification");
+
+        // A listing without a signatures line is rejected outright
+        var threw = false;
+        try {
+            dcba_listing_from_lines(new string[] { entries[0] });
+        }
+        catch(Error e) {
+            threw = true;
+        }
+        check(threw, "a DCBA listing ending without a signatures line is an error");
+    }
+
+    void test_dcba_listing_name_version_match() throws Error {
+        var listing = dcba_listing_from_lines(new string[] {
+            "{\"type\":\"dcba\",\"name\":\"app\",\"version\":\"1.0.0\",\"path\":\"app-1.0.0.dcba\",\"sha512\":\"AQID\"}",
+            "{\"type\":\"dcba\",\"name\":\"app\",\"version\":\"1.0\",\"path\":\"app-1.0.dcba\",\"sha512\":\"BQE=\"}",
+            "{\"type\":\"dcba\",\"name\":\"lib\",\"version\":\"1.0.0\",\"path\":\"lib-1.0.0.dcba\",\"sha512\":\"Bwg=\"}",
+            "{\"type\":\"signatures\",\"signatures\":[]}"
+        });
+
+        var exact = listing.find("app", "1.0.0");
+        check(exact != null && ((!)exact).path == "app-1.0.0.dcba", "name+version find the exact entry");
+        var short_version = listing.find("app", "1.0");
+        check(short_version != null && ((!)short_version).path == "app-1.0.dcba", "the version string matches verbatim (1.0 is not 1.0.0)");
+        check(listing.find("app", "2.0.0") == null, "an absent version finds nothing");
+        check(listing.find("other", "1.0.0") == null, "an absent name finds nothing");
+        check(listing.find("lib", "1.0.0") != null, "a different package's entry is found by its own name");
+    }
+
+    /**
+     * A minimal DCBA repository on disk — descriptor, public/ with
+     * ARTIFACTS.usml and one artifact — served through a `file://` URL so
+     * the GIO client exercises the whole download path without a network.
+     */
+    string dcba_make_repository(string scratch, string name, uint8[] public_key, uint8[] private_key,
+        string artifact_name, string artifact_version, uint8[] artifact_bytes, bool checksum_matches) throws Error {
+        var root = Path.build_filename(scratch, @"repo-$name");
+        var public_dir = Path.build_filename(root, "public");
+        DirUtils.create_with_parents(public_dir, 0755);
+
+        var artifact_path = Path.build_filename(public_dir, @"$artifact_name-$artifact_version.dcba");
+        FileUtils.set_data(artifact_path, artifact_bytes);
+
+        var checksum = new Checksum(ChecksumType.SHA512);
+        checksum.update(artifact_bytes, artifact_bytes.length);
+        var digest = new uint8[ChecksumType.SHA512.get_length()];
+        size_t length = digest.length;
+        checksum.get_digest(digest, ref length);
+        if(!checksum_matches) {
+            digest[0] = digest[0] ^ 0xFF;
+        }
+
+        var entry_line = @"{\"type\":\"dcba\",\"name\":\"$artifact_name\",\"version\":\"$artifact_version\",\"path\":\"$(Path.get_basename(artifact_path))\",\"sha512\":\"$(Base64.encode(digest))\",\"size\":$(artifact_bytes.length)}";
+        var listing = dcba_signed_listing(new string[] { entry_line }, public_key, private_key);
+        FileUtils.set_contents(Path.build_filename(public_dir, "ARTIFACTS.usml"), listing);
+
+        var descriptor = @"{
+  \"name\": \"$name\",
+  \"summary\": \"dcba fixture\",
+  \"url\": \"file://$public_dir\",
+  \"key\": \"$(Base64.encode(public_key))\",
+  \"cache_expiration\": 60
+}";
+        FileUtils.set_contents(Path.build_filename(root, @"$name.dcbar"), descriptor);
+        return descriptor;
+    }
+
+    void test_dcba_repository_download_and_verify() throws Error {
+        var scratch = make_scratch();
+        var public_key = new uint8[Sodium.Asymmetric.Signing.PUBLIC_KEY_BYTES];
+        var private_key = new uint8[Sodium.Asymmetric.Signing.SECRET_KEY_BYTES];
+        Sodium.Asymmetric.Signing.generate_keypair(public_key, private_key);
+
+        var artifact = new uint8[] { 1, 2, 3, 4, 5, 6, 7, 8, 9 };
+        var descriptor = dcba_make_repository(scratch, "dcba-good", public_key, private_key, "app", "1.0.0", artifact, true);
+
+        var state = make_state(scratch);
+        DirUtils.create_with_parents(Path.build_filename(scratch, "config", "repos.d"), 0755);
+        FileUtils.set_contents(Path.build_filename(scratch, "config", "repos.d", "dcba-good.dcbar"), descriptor);
+
+        var repos = state.get_dcba_repositories().to_vector();
+        check(repos.length == 1 && repos[0].name == "dcba-good", "a .dcbar descriptor in repos.d parses as a DCBA repository");
+
+        state.refresh_dcba_list(repos[0]);
+        var listing = state.get_latest_dcba_list(repos[0]);
+        check(listing != null, "the refreshed ARTIFACTS.usml is cached and readable");
+        check(listing != null && ((!)listing).valid_signature(repos[0].key), "the cached artifact listing verifies against the repository key");
+        var entry = listing != null ? ((!)listing).find("app", "1.0.0") : null;
+        check(entry != null && ((!)entry).size == artifact.length, "the artifact entry carries its size");
+
+        // The by-path download lands the artifact bytes through the GIO client
+        var client = repos[0].get_client();
+        var downloaded = Path.build_filename(scratch, "downloaded.dcba");
+        string? reported = null;
+        client.download_file(((!)entry).path, downloaded, (f, c, t) => { reported = f; });
+        uint8[] read_back = {};
+        check(FileUtils.get_data(downloaded, out read_back) && read_back.length == artifact.length, "download_file fetches the artifact over file://");
+        check(reported != null && ((!)reported).has_suffix(".dcba"), "the progress callback reports the artifact filename");
+
+        // Checksum verification: happy path and mismatch
+        var checksum = new Checksum(ChecksumType.SHA512);
+        checksum.update(read_back, read_back.length);
+        var digest = new uint8[ChecksumType.SHA512.get_length()];
+        size_t length = digest.length;
+        checksum.get_digest(digest, ref length);
+        check(((!)entry).sha512sum.equals(Wrap.array(digest)), "the downloaded artifact hashes to the listing sha512");
+
+        var tampered_entry = new Usm.DcbaListingEntry() {
+            name = "app", version = "1.0.0", path = ((!)entry).path,
+            sha512sum = Wrap.byte_array(new uint8[] { 1, 2, 3 })
+        };
+        check(!tampered_entry.sha512sum.equals(Wrap.array(digest)), "a mismatching sha512 fails the binary digest comparison");
+
+        Usm.Util.delete_tree(scratch);
+    }
+
+    void test_dcba_repositories_filtered_from_source_repositories() throws Error {
+        var scratch = make_scratch();
+        var state = make_state(scratch);
+
+        var repos_dir = Path.build_filename(scratch, "config", "repos.d");
+        DirUtils.create_with_parents(repos_dir, 0755);
+        FileUtils.set_contents(Path.build_filename(repos_dir, "source.usmr"),
+            "{\"name\": \"source\", \"summary\": \"src\", \"url\": \"file:///tmp\", \"key\": \"AQ==\", \"cache_expiration\": 60}");
+        FileUtils.set_contents(Path.build_filename(repos_dir, "binaries.dcbar"),
+            "{\"name\": \"binaries\", \"summary\": \"bin\", \"url\": \"file:///tmp\", \"key\": \"AQ==\", \"cache_expiration\": 60}");
+        FileUtils.set_contents(Path.build_filename(repos_dir, "notes.txt"), "not a repository");
+
+        var sources = state.get_repositories().to_vector();
+        check(sources.length == 1 && sources[0].name == "source", "get_repositories returns only .usmr descriptors");
+        var binaries = state.get_dcba_repositories().to_vector();
+        check(binaries.length == 1 && binaries[0].name == "binaries", "get_dcba_repositories returns only .dcbar descriptors");
+        check(binaries[0].key.to_array().length == 1, "the base64 key of a .dcbar descriptor decodes");
+
+        Usm.Util.delete_tree(scratch);
+    }
+
+    void test_dcba_origin_information_field() throws Error {
+        var scratch = make_scratch();
+        var cache_path = Path.build_filename(scratch, "dcba-pkg-1.0.0");
+        DirUtils.create(cache_path, 0755);
+        var cached = new Usm.CachedPackage(cache_path);
+
+        var record = new Usm.OriginInformation() {
+            repository = "source",
+            signature_verified = true,
+            dcba = "binaries"
+        };
+        cached.update_origin_information(record);
+        var read = cached.get_origin_information();
+        check(read.dcba == "binaries", "the dcba provenance field survives an origin-info round trip");
+        check(read.repository == "source" && read.signature_verified, "the other provenance fields survive alongside dcba");
+
+        // Read-modify-write keeps the field when a later record updates another field
+        read.explicitly_installed = true;
+        cached.update_origin_information(read);
+        check(cached.get_origin_information().dcba == "binaries" && cached.get_origin_information().explicitly_installed,
+            "a read-modify-written record keeps its dcba provenance");
+
+        FileUtils.set_contents(Path.build_filename(cache_path, "origin-info"),
+            "{\"repository\": \"source\", \"listfile\": null, \"original_path\": \"p.usmc\", \"signature_verified\": true}");
+        check(cached.get_origin_information().dcba == null, "a pre-dcba origin-info file parses back with a null dcba");
+
+        var legacy = new Usm.CachedPackage(cache_path);
+        legacy.update_origin_information(new Usm.OriginInformation());
+        check(cached.get_origin_information().dcba == null && !cached.get_origin_information().explicitly_installed,
+            "a fresh record serialises without inventing dcba provenance");
+
+        Usm.Util.delete_tree(scratch);
+    }
+
     // ---- Dependency-phase model -------------------------------------------------
 
     /** Minimal manifest with the given "depends" section verbatim. */
@@ -1761,6 +2008,234 @@ exit 1
         Usm.Util.delete_tree(scratch);
     }
 
+    // ---- Conditional build-artifact re-archiving --------------------------------
+
+    /**
+     * A CachedPackage over a scratch cache directory holding a build
+     * directory with a fixed tree (a file, a sub-directory with a file
+     * and a symbolic link) — the seed for the re-archive tests.
+     */
+    Usm.CachedPackage make_built_package(string scratch, string name) throws Error {
+        var cache_path = Path.build_filename(scratch, "state", "packages", @"$name-1.0.0");
+        DirUtils.create_with_parents(Path.build_filename(cache_path, "build", "sub"), 0755);
+        FileUtils.set_contents(Path.build_filename(cache_path, "build", "one.txt"), "first\n");
+        FileUtils.set_contents(Path.build_filename(cache_path, "build", "sub", "two.txt"), "second\n");
+        File.new_for_path(Path.build_filename(cache_path, "build", "link"))
+            .make_symbolic_link("one.txt");
+        return new Usm.CachedPackage(cache_path);
+    }
+
+    /** Archives the package's build directory and records its digest sidecar. */
+    void archive_built_package(Usm.CachedPackage package) throws Error {
+        var build_dir = Path.build_filename(package.state_path, "build");
+        var archive_path = Path.build_filename(package.state_path, "build.tar.xz");
+        Usm.Util.archive(build_dir, archive_path);
+        FileUtils.set_contents(Path.build_filename(package.state_path, "build.tar.xz.digest"),
+            Usm.Util.tree_digest(build_dir) + "\n");
+    }
+
+    /** Extracts `archive` into a fresh scratch directory and returns the contents of `member`. */
+    string archived_member(string scratch, string archive, string member) throws Error {
+        var extracted = Path.build_filename(scratch, @"extract-$(Uuid.string_random())");
+        DirUtils.create(extracted, 0755);
+        Usm.Util.unarchive(archive, extracted);
+        string contents = null;
+        FileUtils.get_contents(Path.build_filename(extracted, member), out contents);
+        return contents;
+    }
+
+    /** Modification time of `path` in seconds since the epoch. */
+    int64 file_mtime(string path) {
+        return Stat(path).st_mtime;
+    }
+
+    void test_archive_build_skips_unchanged_tree() throws Error {
+        var scratch = make_scratch();
+        var package = make_built_package(scratch, "skip-pkg");
+        archive_built_package(package);
+
+        var archive_path = Path.build_filename(package.state_path, "build.tar.xz");
+        var digest_path = Path.build_filename(package.state_path, "build.tar.xz.digest");
+        string before = null;
+        FileUtils.get_contents(archive_path, out before);
+        var before_mtime = file_mtime(archive_path);
+
+        var last_fraction = -1.0f;
+        package.archive_build((fraction) => { last_fraction = fraction; });
+
+        string after = null;
+        FileUtils.get_contents(archive_path, out after);
+        check(after == before, "an unchanged build tree keeps the archive bytes");
+        check(file_mtime(archive_path) == before_mtime, "an unchanged build tree keeps the archive mtime");
+        check(!File.new_for_path(Path.build_filename(package.state_path, "build")).query_exists(),
+            "skipping the re-archive still cleans the build directory");
+        check(File.new_for_path(digest_path).query_exists(), "skipping the re-archive keeps the digest sidecar");
+        check(last_fraction == 1.0f, "skipping the re-archive still completes progress");
+
+        Usm.Util.delete_tree(scratch);
+    }
+
+    /** One mutation of a build tree before it is re-archived. */
+    delegate void BuildTreeMutation(string build_dir) throws Error;
+
+    /**
+     * One re-archive-on-change case: seeds a package named <name> with a
+     * build tree, archives it, applies <mutate> (which may throw — a
+     * failed mutation must fail the test, not silently re-test the
+     * unchanged tree), re-archives and asserts the archive was rewritten.
+     */
+    void rearchived_after_change(string scratch, string name, owned BuildTreeMutation mutate) throws Error {
+        var package = make_built_package(scratch, name);
+        archive_built_package(package);
+        var digest_path = Path.build_filename(package.state_path, "build.tar.xz.digest");
+        string digest_before = null;
+        FileUtils.get_contents(digest_path, out digest_before);
+
+        mutate(Path.build_filename(package.state_path, "build"));
+        // Computed before archive_build cleans the directory: the digest
+        // the fresh sidecar has to describe
+        var changed_tree_digest = Usm.Util.tree_digest(Path.build_filename(package.state_path, "build"));
+        package.archive_build(null);
+
+        string digest_after = null;
+        FileUtils.get_contents(digest_path, out digest_after);
+        check(digest_after.strip() != digest_before.strip(),
+            @"a changed build tree ($name) re-archives into a new digest sidecar");
+        check(digest_after.strip() == changed_tree_digest,
+            @"a changed build tree ($name) records its own tree in the fresh sidecar");
+        check(!File.new_for_path(Path.build_filename(package.state_path, "build")).query_exists(),
+            @"a changed build tree ($name) still cleans the build directory");
+
+        Usm.Util.delete_tree(scratch);
+    }
+
+    void test_archive_build_rearchives_changes() throws Error {
+        var scratch = make_scratch();
+
+        rearchived_after_change(scratch, "mod-pkg", (build) => {
+            FileUtils.set_contents(Path.build_filename(build, "one.txt"), "changed\n");
+        });
+        check(true, "a modified file re-archives");
+
+        rearchived_after_change(scratch, "add-pkg", (build) => {
+            FileUtils.set_contents(Path.build_filename(build, "new.txt"), "new\n");
+        });
+        check(true, "an added file re-archives");
+
+        rearchived_after_change(scratch, "rm-pkg", (build) => {
+            File.new_for_path(Path.build_filename(build, "sub", "two.txt")).delete();
+        });
+        check(true, "a removed file re-archives");
+
+        rearchived_after_change(scratch, "mode-pkg", (build) => {
+            FileUtils.chmod(Path.build_filename(build, "one.txt"), 0700);
+        });
+        check(true, "a changed file mode re-archives");
+
+        rearchived_after_change(scratch, "link-pkg", (build) => {
+            File.new_for_path(Path.build_filename(build, "link")).delete();
+            File.new_for_path(Path.build_filename(build, "link")).make_symbolic_link("sub/two.txt");
+        });
+        check(true, "a changed symlink target re-archives");
+    }
+
+    void test_archive_build_rearchives_modified_content() throws Error {
+        var scratch = make_scratch();
+        var package = make_built_package(scratch, "content-pkg");
+        archive_built_package(package);
+
+        var archive_path = Path.build_filename(package.state_path, "build.tar.xz");
+        FileUtils.set_contents(Path.build_filename(package.state_path, "build", "one.txt"), "changed\n");
+        package.archive_build(null);
+
+        check(archived_member(scratch, archive_path, "one.txt") == "changed\n",
+            "a modified file's new content lands in the re-archived archive");
+
+        Usm.Util.delete_tree(scratch);
+    }
+
+    void test_archive_build_rearchives_without_sidecar() throws Error {
+        var scratch = make_scratch();
+        var package = make_built_package(scratch, "legacy-pkg");
+        var build_dir = Path.build_filename(package.state_path, "build");
+        var archive_path = Path.build_filename(package.state_path, "build.tar.xz");
+        Usm.Util.archive(build_dir, archive_path); // legacy: no digest sidecar
+        var tree_digest_before = Usm.Util.tree_digest(build_dir);
+
+        package.archive_build(null);
+
+        string digest = null;
+        FileUtils.get_contents(Path.build_filename(package.state_path, "build.tar.xz.digest"), out digest);
+        check(digest != null && digest.strip() == tree_digest_before,
+            "an archive without a digest sidecar re-archives and records the digest sidecar");
+        check(archived_member(scratch, archive_path, "one.txt") == "first\n",
+            "the re-archived legacy archive holds the build tree's contents");
+
+        Usm.Util.delete_tree(scratch);
+    }
+
+    void test_build_digest_sidecar_lifecycle() throws Error {
+        var scratch = make_scratch();
+        var package = make_built_package(scratch, "cycle-pkg");
+        var cache_path = package.state_path;
+        var archive_path = Path.build_filename(cache_path, "build.tar.xz");
+        var digest_path = Path.build_filename(cache_path, "build.tar.xz.digest");
+
+        // get_build_archive archives a bare build directory
+        var returned = package.get_build_archive();
+        check(returned == archive_path, "get_build_archive creates the archive from the build directory");
+        check(File.new_for_path(digest_path).query_exists(),
+            "get_build_archive records the digest sidecar alongside the archive");
+
+        // An archive-only cache keeps its sidecar; unarchiving leaves it alone
+        FileUtils.set_contents(digest_path, "describes-the-archive-not-the-tree\n");
+        package.unarchive_build();
+        check(File.new_for_path(digest_path).query_exists(),
+            "unarchiving a build artifact leaves the digest sidecar alone");
+
+        // A second archive_build over the restored tree matches the digest
+        var digest = Usm.Util.tree_digest(Path.build_filename(cache_path, "build"));
+        FileUtils.set_contents(digest_path, digest + "\n");
+        var before_mtime = file_mtime(archive_path);
+        package.archive_build(null);
+        check(file_mtime(archive_path) == before_mtime,
+            "an archive restored and re-archived unchanged keeps its bytes");
+
+        // clean_build_cache removes the archive together with the sidecar
+        package.clean_build_cache();
+        check(!File.new_for_path(archive_path).query_exists() && !File.new_for_path(digest_path).query_exists(),
+            "clean_build_cache removes the archive and the digest sidecar together");
+
+        Usm.Util.delete_tree(scratch);
+    }
+
+    void test_tree_digest_determinism() throws Error {
+        var scratch = make_scratch();
+
+        var tree = Path.build_filename(scratch, "tree");
+        DirUtils.create_with_parents(Path.build_filename(tree, "d"), 0755);
+        FileUtils.set_contents(Path.build_filename(tree, "a.txt"), "alpha\n");
+        FileUtils.set_contents(Path.build_filename(tree, "d", "b.txt"), "beta\n");
+        File.new_for_path(Path.build_filename(tree, "d", "link")).make_symbolic_link("../a.txt");
+
+        var first = Usm.Util.tree_digest(tree);
+        check(first == Usm.Util.tree_digest(tree), "hashing the same tree twice yields the same digest");
+
+        // Same content at a different path is a different tree
+        var moved = Path.build_filename(scratch, "moved");
+        DirUtils.create_with_parents(Path.build_filename(moved, "d"), 0755);
+        FileUtils.set_contents(Path.build_filename(moved, "z.txt"), "alpha\n");
+        FileUtils.set_contents(Path.build_filename(moved, "d", "b.txt"), "beta\n");
+        File.new_for_path(Path.build_filename(moved, "d", "link")).make_symbolic_link("../a.txt");
+        check(Usm.Util.tree_digest(moved) != first, "a same-content file at a different path digests differently");
+
+        // A dot-prefixed temp file inside the tree is part of the tree
+        FileUtils.set_contents(Path.build_filename(tree, ".usm-tmp"), "scratch\n");
+        check(Usm.Util.tree_digest(tree) != first, "a temporary file inside the tree changes the digest");
+
+        Usm.Util.delete_tree(scratch);
+    }
+
     void test_build_failure_without_cache_propagates() throws Error {
         var original_dir = Environment.get_current_dir();
         var scratch = make_scratch();
@@ -2035,6 +2510,106 @@ exit 0
         Usm.Util.delete_tree(scratch);
     }
 
+    // ---- DCBA repository CLI helpers --------------------------------------------------
+
+    void test_dcba_artifact_filename_parsing() throws Error {
+        var plain = Usm.DcbaArtifact.parse_filename("app-1.0.0.dcba");
+        check(plain != null && ((!)plain).name == "app" && ((!)plain).version == "1.0.0", "a plain name-version filename splits cleanly");
+
+        var released = Usm.DcbaArtifact.parse_filename("app-1.0.0+2.dcba");
+        check(released != null && ((!)released).name == "app" && ((!)released).version == "1.0.0+2", "a +release suffix stays inside the version");
+
+        var dashed = Usm.DcbaArtifact.parse_filename("lib-base-1.2+3.dcba");
+        check(dashed != null && ((!)dashed).name == "lib-base" && ((!)dashed).version == "1.2+3", "a dashed package name keeps its leading components");
+
+        var short_version = Usm.DcbaArtifact.parse_filename("app-1.dcba");
+        check(short_version != null && ((!)short_version).name == "app" && ((!)short_version).version == "1", "a single-component version parses");
+
+        var arch_tail = Usm.DcbaArtifact.parse_filename("app-1.0.0-x86_64.dcba");
+        check(arch_tail != null && ((!)arch_tail).name == "app", "an unparseable tail falls back to an earlier dash");
+        check(arch_tail != null && ((!)arch_tail).version == "1.0.0", "the unparseable tail is excluded and 1.0.0 is the version");
+
+        check(Usm.DcbaArtifact.parse_filename("no-version.dcba") == null, "a filename without any version is rejected");
+        check(Usm.DcbaArtifact.parse_filename("-1.0.0.dcba") == null, "an empty name is rejected");
+        check(Usm.DcbaArtifact.parse_filename("app.txt") == null, "a non-dcba extension is rejected");
+        check(Usm.DcbaArtifact.parse_filename(".dcba") == null, "an empty stem is rejected");
+
+        var a = new Usm.DcbaArtifact("app", "1.0.0");
+        var b = new Usm.DcbaArtifact("app", "1.0.0");
+        var c = new Usm.DcbaArtifact("app", "2.0");
+        check(a.same_artifact(b), "identical name+version is the same artifact");
+        check(!a.same_artifact(c), "a different version is a different artifact, not a conflict");
+    }
+
+    void test_dcba_listing_writer_round_trip() throws Error {
+        var scratch = make_scratch();
+        var public_key = new uint8[Sodium.Asymmetric.Signing.PUBLIC_KEY_BYTES];
+        var private_key = new uint8[Sodium.Asymmetric.Signing.SECRET_KEY_BYTES];
+        Sodium.Asymmetric.Signing.generate_keypair(public_key, private_key);
+
+        var entries = new Vector<Usm.DcbaListingEntry>();
+        var first_bytes = new uint8[] { 1, 2, 3, 4 };
+        entries.add(new Usm.DcbaListingEntry() {
+            name = "app", version = "1.0.0+2", path = "app-1.0.0+2.dcba",
+            sha512sum = Wrap.byte_array(first_bytes), size = first_bytes.length
+        });
+        var second_bytes = new uint8[] { 9 };
+        entries.add(new Usm.DcbaListingEntry() {
+            name = "lib-base", version = "1.2", path = "lib-base-1.2.dcba",
+            sha512sum = Wrap.byte_array(second_bytes)
+        });
+
+        var listing_path = Path.build_filename(scratch, "ARTIFACTS.usml");
+        Usm.DcbaListingWriter.write(listing_path, entries, public_key, private_key);
+
+        // The written listing parses back with matching entries and a valid signature
+        var listing = new Usm.DcbaListing.from_stream(new DataInputStream(File.new_for_path(listing_path).read()));
+        var parsed = listing.entries.to_vector();
+        check(parsed.length == 2, "every written entry round-trips");
+        check(parsed[0].name == "app" && parsed[0].version == "1.0.0+2" && parsed[0].path == "app-1.0.0+2.dcba", "entry fields survive the write verbatim");
+        check(parsed[0].sha512sum.equals(Wrap.byte_array(first_bytes)), "the sha512 round-trips through base64");
+        check(parsed[0].size == first_bytes.length, "the size round-trips");
+        check(parsed[1].size == 0, "an omitted size materialises as 0");
+        check(listing.valid_signature(Wrap.byte_array(public_key)), "the written listing verifies against its signing key");
+
+        // find() sees the written entries
+        check(listing.find("app", "1.0.0+2") != null && listing.find("lib-base", "1.2") != null, "find locates the written artifacts");
+        check(listing.find("app", "1.0.0") == null, "the version matches verbatim, not numerically");
+
+        // Byte format: compact lines, one \n per entry, signatures line last, no trailing newline
+        string body;
+        FileUtils.get_contents(listing_path, out body);
+        var body_lines = body.split("\n");
+        check(body_lines.length == 3 && body_lines[2].length > 0 && !body.has_suffix("\n"), "the listing is two entry lines plus the signatures line with no trailing newline");
+        check(body_lines[0].contains("\"type\":\"dcba\""), "each entry line carries the dcba type field");
+        check(!body_lines[0].contains(" "), "the entry line is compact JSON without spaces");
+        check(body_lines[2].contains("\"type\":\"signatures\""), "the signatures line terminates the listing");
+
+        // A wrong key does not verify
+        var other_public = new uint8[Sodium.Asymmetric.Signing.PUBLIC_KEY_BYTES];
+        var other_private = new uint8[Sodium.Asymmetric.Signing.SECRET_KEY_BYTES];
+        Sodium.Asymmetric.Signing.generate_keypair(other_public, other_private);
+        check(!listing.valid_signature(Wrap.byte_array(other_public)), "a foreign key does not verify the listing");
+
+        Usm.Util.delete_tree(scratch);
+    }
+
+    void test_dcba_listing_writer_empty_repository() throws Error {
+        var scratch = make_scratch();
+        var public_key = new uint8[Sodium.Asymmetric.Signing.PUBLIC_KEY_BYTES];
+        var private_key = new uint8[Sodium.Asymmetric.Signing.SECRET_KEY_BYTES];
+        Sodium.Asymmetric.Signing.generate_keypair(public_key, private_key);
+
+        var listing_path = Path.build_filename(scratch, "ARTIFACTS.usml");
+        Usm.DcbaListingWriter.write(listing_path, new Vector<Usm.DcbaListingEntry>(), public_key, private_key);
+
+        var listing = new Usm.DcbaListing.from_stream(new DataInputStream(File.new_for_path(listing_path).read()));
+        check(listing.entries.count() == 0, "an empty repository writes an entry-less listing");
+        check(listing.valid_signature(Wrap.byte_array(public_key)), "the empty listing still verifies (empty digest signed)");
+
+        Usm.Util.delete_tree(scratch);
+    }
+
     // ---- Granular progress reporting ----------------------------------------------
 
     /** Whether the reports climb monotonically from start to end. */
@@ -2292,6 +2867,12 @@ echo "Installing libmeson-pkg.so to /usr/lib"
             test_build_archive_restore_and_clean_retry();
             test_build_archive_restored_incrementally();
             test_build_failure_without_cache_propagates();
+            test_archive_build_skips_unchanged_tree();
+            test_archive_build_rearchives_changes();
+            test_archive_build_rearchives_modified_content();
+            test_archive_build_rearchives_without_sidecar();
+            test_build_digest_sidecar_lifecycle();
+            test_tree_digest_determinism();
         }
         catch(Error e) {
             failures++;
@@ -2316,6 +2897,27 @@ echo "Installing libmeson-pkg.so to /usr/lib"
             print("FAIL transaction rollback test threw: %s\n", e.message);
         }
 
+
+        try {
+            test_dcba_listing_parses_entries_and_signatures();
+            test_dcba_listing_name_version_match();
+            test_dcba_repository_download_and_verify();
+            test_dcba_repositories_filtered_from_source_repositories();
+            test_dcba_origin_information_field();
+        }
+        catch(Error e) {
+            failures++;
+            print("FAIL dcba test threw: %s\n", e.message);
+        }
+        try {
+            test_dcba_artifact_filename_parsing();
+            test_dcba_listing_writer_round_trip();
+            test_dcba_listing_writer_empty_repository();
+        }
+        catch(Error e) {
+            failures++;
+            print("FAIL dcba cli test threw: %s\n", e.message);
+        }
         try {
             test_unarchive_with_progress();
             test_resolver_resolution_progress();