Browse Source

Add DCBA artifact listings and client install integration

DCBA (Distribution Compiled Binary Artifact) repositories distribute
pre-built build artifacts alongside USM source repositories. This adds
the library core and the client-side install wiring:

- Usm.DcbaListing / Usm.DcbaListingEntry parse a DCBA repository's
  ARTIFACTS.usml: same JSONL shape and signing protocol as
  PACKAGES.usml (SHA-512 over the bare serialised entry lines,
  ed25519-signed), one entry per .dcba artifact with name, version
  (verbatim strings), path, sha512 and size. find() matches name and
  version exactly. A stream without a signatures line is an error.
- RepositoryClient gains a public abstract download_file(remote_path,
  destination, callback), implemented by both clients (the curl client
  exposes its existing private download, the GIO client copies the
  built URI), plus LISTING_NAME / DCBA_LISTING_NAME constants the
  listing downloads now share.
- SystemState.get_repositories filters repos.d to .usmr descriptors;
  get_dcba_repositories parses .dcbar descriptors through the same
  Repository mapper; get_latest_dcba_list / refresh_dcba_list cache and
  refresh ARTIFACTS.usml in the same lists/<name>/<timestamp>.usml
  layout as source listings.
- OriginInformation gains an optional dcba field (repository name the
  artifact came from), undefined/null-tolerant like the other optional
  provenance fields.
- Install and Downgrade fetch a pre-built artifact after a .usmc is
  downloaded and verified: matching entries are downloaded to a
  .dcba.part temp file, sha512-verified, then moved into place as the
  cache's build.tar.xz so the existing build-cache machinery (build
  reuse, usm clean builds) works unchanged. Every DCBA failure is a
  non-fatal one-line warning; the package just builds from source.
  DCBA listings are refreshed lazily (best-effort) at the first
  download, and provenance is recorded through a read-modify-write of
  the cache's origin-info.
- 29 new unit tests: listing parse, tamper detection, sign/verify
  round trip, name+version matching, .dcbar/.usmr filtering, artifact
  download over file:// plus checksum happy path and mismatch, and the
  origin-info dcba field round trip.
saddle-Clanker-Saddle 1 month ago
parent
commit
4609efd520

+ 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);
         }
     }

+ 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

+ 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);

+ 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();

+ 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

+ 1 - 0
src/lib/meson.build

@@ -23,6 +23,7 @@ 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/RepositoryClient.vala')
 sources += files('Repository/GioRepositoryClient.vala')
 sources += files('Repository/CurlRepositoryClient.vala')

+ 259 - 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. */
@@ -2316,6 +2563,18 @@ 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_unarchive_with_progress();
             test_resolver_resolution_progress();