ソースを参照

Add the usm dcba CLI: repository verbs and add-repo

Implements the usm dcba command family on top of the DCBA core library:

- usm dcba repository init <name>: ed25519 keypair, keys/ (0700, private
  key 0600, gitignored), public/ (0755) and a <name>.dcbar descriptor
  pointing at file://$PWD/public; refuses to run twice in one directory.
- usm dcba repository add <file.dcba>: root discovery by *.dcbar + keys/
  + public/ (separate from the .usmr search), name/version derived from
  the filename (last dash whose suffix parses as a Version, so
  lib-base-1.2+3.dcba and 1.0.0+2 releases work), tar.xz validated via
  tar -tf, identical content re-added is a no-op, same name+version with
  different content is rejected, then ARTIFACTS.usml is rebuilt and
  re-signed.
- usm dcba repository remove: exact filename first, then <name>- prefix
  matches; ambiguity lists candidates and exits 1.
- usm dcba repository list [repo.dcbar]: local root or fetched over the
  descriptor url, signature verified, prints 'name version size' with
  human-readable sizes (? for size 0).
- usm dcba repository verify: listing signature plus a deep sha512 check
  of every artifact.
- usm dcba repository publish: descriptor and ARTIFACTS.usml always
  overwritten, .dcba files only when missing or older by mtime.
- usm dcba repository build-list: rebuilds and re-signs ARTIFACTS.usml.
- usm dcba add-repo: .dcbar counterpart of usm add-repo. AddRepo.vala is
  refactored into a shared parameterised runner (extension, refresh
  call, wording) so the existing usm add-repo behaviour is unchanged.

The testable logic lives in the library so the test binary can link it:
Usm.DcbaArtifact (filename parsing and same-artifact classification) and
Usm.DcbaListingWriter (writes ARTIFACTS.usml byte-for-byte compatible
with DcbaListing's parser and signing protocol). The CLI is a thin shell
over both.

Tests: filename parsing including 1.0.0+2 and dashed names, writer
round-trip with valid signature and byte-format checks, empty repository
listing. 374 passes (347 baseline + 27 new); the only failure is the
pre-existing progress-parsing check already failing on goal/dcba.
saddle-Clanker-Saddle 1 ヶ月 前
親
コミット
c7a4f64234

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

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

+ 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" },

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

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

+ 1 - 0
src/lib/meson.build

@@ -24,6 +24,7 @@ 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')

+ 109 - 0
src/tests/TestMain.vala

@@ -2510,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. */
@@ -2809,6 +2909,15 @@ echo "Installing libmeson-pkg.so to /usr/lib"
             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();