Эх сурвалжийг харах

feat: usm enroll command; CurlRepositoryClient with byte-accurate progress, silent transfers, configurable download_timeout

clanker 1 долоо хоног өмнө
parent
commit
f61e4094c4

Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 1 - 583
installer/install-usm.sh


+ 4 - 1
src/cli/Cli.vala

@@ -83,6 +83,9 @@ public static int main(string[] args) {
         if(command == "genconfig") {
             return genconfig_main();
         }
+        if(command == "enroll") {
+            return enroll_main(dispatch_args);
+        }
     }
     catch(Error e) {
         printerr(@"Unhandled error: $(e.message)\n");
@@ -94,7 +97,7 @@ public static int main(string[] args) {
 }
 
 private void usage() {
-    printerr("USAGE:\n\tusm manifest\n\tusm info\n\tusm repository\n\tusm install\n\tusm deploy\n\tusm scaffold\n\tusm genconfig\n");
+    printerr("USAGE:\n\tusm manifest\n\tusm info\n\tusm repository\n\tusm install\n\tusm deploy\n\tusm scaffold\n\tusm genconfig\n\tusm enroll [-y|--yes] [--state-path <path>]\n");
 }
 
 

+ 392 - 0
src/cli/Enroll.vala

@@ -0,0 +1,392 @@
+using Invercargill.DataStructures;
+using InvercargillJson;
+
+/** Default managed state root, matching the layout `usm deploy` writes into images. */
+private const string ENROLL_DEFAULT_STATE_PATH = "/var/usm";
+
+/** Directory mode for every directory enrollment creates. */
+private const int ENROLL_DIRECTORY_MODE = 0755;
+
+
+/**
+ * `usm enroll [-y|--yes] [--state-path <path>]` — transition a system from
+ * unmanaged to managed by USM.
+ *
+ * Shows the exact directories that will be created and the usm.config
+ * change, asks for confirmation (skipped with `-y`/`--yes`), sets
+ * `is_managed: true` plus `managed.state_path` in usm.config (creating or
+ * updating the file, honouring USM_CONFIGDIR) and creates every directory
+ * USM references at runtime: the managed state tree (`packages/`,
+ * `installed/`, `lists/` — see {@link Usm.SystemState}), the configuration
+ * tree (`usm.config`, `repos.d/`) and the destination/prefix directory
+ * layout behind {@link Usm.Paths.get_suggested_base_path_for_type} and
+ * {@link Usm.Paths.set_envs}.
+ *
+ * Idempotent: existing directories are left alone, and a system whose
+ * config is already managed with the same state path reports "already
+ * enrolled" after re-ensuring the directories exist. Without root the
+ * default state path and the system directory tree usually cannot be
+ * written; enrollment warns and suggests a user-local
+ * `--state-path ~/.local/state/usm` instead.
+ */
+public int enroll_main(string[] args) throws Error {
+    var assume_yes = false;
+    string? state_path_flag = null;
+    for(int i = 2; i < args.length; i++) {
+        var argument = args[i];
+        if(argument == "-y" || argument == "--yes") {
+            assume_yes = true;
+        }
+        else if(argument.has_prefix("--state-path=")) {
+            state_path_flag = argument.split("=", 2)[1];
+        }
+        else if(argument == "--state-path") {
+            if(i + 1 >= args.length) {
+                printerr("Expected a path after --state-path\n");
+                return enroll_usage();
+            }
+            state_path_flag = args[++i];
+        }
+        else {
+            printerr(@"Unexpected argument \"$argument\"\n");
+            return enroll_usage();
+        }
+    }
+    if(state_path_flag != null && !Path.is_absolute(state_path_flag)) {
+        state_path_flag = Path.build_filename(Environment.get_current_dir(), state_path_flag);
+    }
+
+    var config_file = Path.build_filename(paths.usm_config_dir, "usm.config");
+    Usm.Configuration? existing_config = null;
+    if(File.new_for_path(config_file).query_exists()) {
+        try {
+            existing_config = new Usm.Configuration.from_paths(paths);
+        }
+        catch(Error e) {
+            printerr(@"The existing usm.config could not be read: $(e.message)\n");
+            return 1;
+        }
+    }
+
+    // An explicit --state-path re-enrols to a new root; otherwise an
+    // already-managed config keeps its own state path
+    var state_path = state_path_flag
+        ?? existing_config?.managed_config?.state_path
+        ?? ENROLL_DEFAULT_STATE_PATH;
+
+    if(existing_config != null && existing_config.is_managed
+            && existing_config.managed_config != null
+            && existing_config.managed_config.state_path == state_path) {
+        print(@"This system is already enrolled; managed state lives in \"$state_path\".\n");
+        var plan = enroll_plan_directories(state_path);
+        var outcome = enroll_ensure_directories(plan);
+        enroll_report(outcome, null);
+        return enroll_report_failures(plan, outcome);
+    }
+
+    // Rendered before the mutation below: updated_config aliases
+    // existing_config, so the "old" side of the diff must be captured first
+    string? existing_config_text = null;
+    if(existing_config != null) {
+        existing_config_text = enroll_render_config(existing_config);
+    }
+
+    var updated_config = existing_config ?? new Usm.Configuration();
+    updated_config.is_managed = true;
+    if(updated_config.managed_config == null) {
+        updated_config.managed_config = new Usm.ManagedConfiguration();
+    }
+    updated_config.managed_config.state_path = state_path;
+
+    var plan = enroll_plan_directories(state_path);
+    print("Enrolling this system with USM will:\n\n");
+    print("Create these directories (existing ones are left alone):\n");
+    enroll_print_directories("State", plan.state_directories);
+    enroll_print_directories("Configuration", plan.config_directories);
+    enroll_print_directories("System", plan.system_directories);
+
+    print("\nChange the configuration:\n");
+    if(existing_config == null) {
+        print(@"  create $config_file:\n");
+        foreach(var line in enroll_render_config(updated_config).split("\n")) {
+            print(@"  + $line\n");
+        }
+    }
+    else {
+        print(@"  $config_file:\n");
+        foreach(var line in enroll_diff_lines(existing_config_text, enroll_render_config(updated_config))) {
+            print(@"  $line\n");
+        }
+    }
+
+    if(Posix.geteuid() != 0) {
+        if(state_path == ENROLL_DEFAULT_STATE_PATH) {
+            printerr(@"\nWarning: not running as root — the default state path \"$state_path\" and the system directories usually require root.\n");
+            printerr("Re-run under sudo, or enrol user-local instead: usm enroll --state-path ~/.local/state/usm\n");
+        }
+        else {
+            printerr("\nWarning: not running as root — the system directories above usually require root and may fail to create.\n");
+        }
+    }
+
+    if(!assume_yes && !enroll_confirm()) {
+        print("Aborted.\n");
+        return 1;
+    }
+
+    var result = enroll_ensure_directories(plan);
+    // A config claiming a state path that could not be created would leave
+    // the system half-managed: fail before writing it
+    var essential_failed = result.failed.any(d => plan.state_directories.contains(d) || plan.config_directories.contains(d));
+    if(essential_failed) {
+        enroll_report(result, null);
+        return enroll_report_failures(plan, result);
+    }
+    try {
+        new JsonElement.from_properties(Usm.Configuration.get_mapper().map_from(updated_config)).write_to_file(config_file);
+    }
+    catch(Error e) {
+        printerr(@"Unable to write \"$config_file\": $(e.message)\n");
+        enroll_report(result, null);
+        return 1;
+    }
+    enroll_report(result, existing_config == null
+        ? @"Created configuration $config_file"
+        : @"Updated configuration $config_file");
+    return enroll_report_failures(plan, result);
+}
+
+private int enroll_usage() {
+    printerr("USAGE:\n\tusm enroll [-y|--yes] [--state-path <path>]\n");
+    return 255;
+}
+
+
+/**
+ * The complete directory plan: managed state tree, configuration tree and
+ * the destination/prefix layout, in creation order.
+ */
+private class EnrollPlan {
+    public Vector<string> state_directories = new Vector<string>();
+    public Vector<string> config_directories = new Vector<string>();
+    public Vector<string> system_directories = new Vector<string>();
+
+    public Vector<string> all() {
+        var every = new Vector<string>();
+        every.add_all(state_directories);
+        every.add_all(config_directories);
+        every.add_all(system_directories);
+        return every;
+    }
+}
+
+/**
+ * Every directory USM references at runtime for the given state path: the
+ * {@link Usm.SystemState} layout (`packages`, `installed`, `lists`),
+ * `usm.config`'s directory plus `repos.d`, and one directory per
+ * {@link Usm.Paths.get_suggested_base_path_for_type} branch plus the
+ * LOCALSTATEDIR/SHAREDSTATEDIR roots {@link Usm.Paths.set_envs} exports.
+ * Equal paths (lib == canonlib, for instance) collapse to one entry.
+ */
+private EnrollPlan enroll_plan_directories(string state_path) {
+    var plan = new EnrollPlan();
+
+    plan.state_directories.add(state_path);
+    foreach(var part in new string[] { "packages", "installed", "lists" }) {
+        plan.state_directories.add(Path.build_filename(state_path, part));
+    }
+
+    plan.config_directories.add(paths.usm_config_dir);
+    plan.config_directories.add(Path.build_filename(paths.usm_config_dir, "repos.d"));
+
+    var system = plan.system_directories;
+    enroll_add_unique(system, paths.get_suggested_base_path_for_type(Usm.ResourceType.ROOT_PATH));
+    enroll_add_unique(system, paths.get_suggested_base_path_for_type(Usm.ResourceType.PATH));
+    enroll_add_unique(system, paths.get_suggested_base_path_for_type(Usm.ResourceType.OPTIONAL));
+    enroll_add_unique(system, paths.get_suggested_base_path_for_type(Usm.ResourceType.RESOURCE));
+    enroll_add_unique(system, paths.get_suggested_base_path_for_type(Usm.ResourceType.CONFIGURATION));
+    enroll_add_unique(system, paths.get_suggested_base_path_for_type(Usm.ResourceType.BINARY));
+    enroll_add_unique(system, paths.get_suggested_base_path_for_type(Usm.ResourceType.SUPER_BINARY));
+    enroll_add_unique(system, paths.get_suggested_base_path_for_type(Usm.ResourceType.LIBRARY));
+    enroll_add_unique(system, paths.get_suggested_base_path_for_type(Usm.ResourceType.LIBRARY_RESOURCE));
+    enroll_add_unique(system, paths.get_suggested_base_path_for_type(Usm.ResourceType.LIBRARY_EXECUTABLE));
+    enroll_add_unique(system, paths.get_suggested_base_path_for_type(Usm.ResourceType.CANONICAL_LIBRARY));
+    enroll_add_unique(system, paths.get_suggested_base_path_for_type(Usm.ResourceType.CANONICAL_LIBRARY_RESOURCE));
+    enroll_add_unique(system, paths.get_suggested_base_path_for_type(Usm.ResourceType.GIO_MODULE));
+    enroll_add_unique(system, paths.get_suggested_base_path_for_type(Usm.ResourceType.INFO_PAGE));
+    enroll_add_unique(system, paths.get_suggested_base_path_for_type(Usm.ResourceType.MANUAL_PAGE));
+    enroll_add_unique(system, paths.get_suggested_base_path_for_type(Usm.ResourceType.LOCALE));
+    enroll_add_unique(system, paths.get_suggested_base_path_for_type(Usm.ResourceType.APPLICATION));
+    enroll_add_unique(system, paths.get_suggested_base_path_for_type(Usm.ResourceType.INCLUDE));
+    enroll_add_unique(system, paths.get_suggested_base_path_for_type(Usm.ResourceType.PKG_CONFIG));
+    enroll_add_unique(system, paths.get_suggested_base_path_for_type(Usm.ResourceType.VALA_API));
+    enroll_add_unique(system, paths.get_suggested_base_path_for_type(Usm.ResourceType.GOBJECT_IR));
+    enroll_add_unique(system, paths.get_suggested_base_path_for_type(Usm.ResourceType.TYPELIB));
+    enroll_add_unique(system, paths.get_suggested_base_path_for_type(Usm.ResourceType.TAG));
+    enroll_add_unique(system, Path.build_filename(paths.destination, paths.local_state));
+    enroll_add_unique(system, Path.build_filename(paths.destination, paths.shared_state));
+    return plan;
+}
+
+private void enroll_add_unique(Vector<string> directories, string directory) {
+    if(!directories.contains(directory)) {
+        directories.add(directory);
+    }
+}
+
+/** What a directory-creation pass did: created, skipped and failed paths. */
+private class EnrollOutcome {
+    public Vector<string> created = new Vector<string>();
+    public Vector<string> existed = new Vector<string>();
+    public Vector<string> failed = new Vector<string>();
+}
+
+private EnrollOutcome enroll_ensure_directories(EnrollPlan plan) {
+    var outcome = new EnrollOutcome();
+    foreach(var directory in plan.all()) {
+        if(File.new_for_path(directory).query_exists()) {
+            outcome.existed.add(directory);
+            continue;
+        }
+        try {
+            DirUtils.create_with_parents(directory, ENROLL_DIRECTORY_MODE);
+            outcome.created.add(directory);
+        }
+        catch(Error e) {
+            outcome.failed.add(directory);
+        }
+    }
+    return outcome;
+}
+
+
+/**
+ * Exit-code half of the outcome report: failures in the state and
+ * configuration trees are fatal (enrollment is not functional without
+ * them), while system-tree failures — typically needing root on a live
+ * system — only warn.
+ */
+private int enroll_report_failures(EnrollPlan plan, EnrollOutcome outcome) {
+    if(outcome.failed.count() == 0) {
+        return 0;
+    }
+    printerr("\nFailed to create:\n");
+    foreach(var directory in outcome.failed) {
+        printerr(@"  $directory\n");
+    }
+    var essential = outcome.failed.any(d => plan.state_directories.contains(d) || plan.config_directories.contains(d));
+    if(essential) {
+        printerr("The state and configuration directories are required; re-run as root or pass a writable --state-path.\n");
+        return 1;
+    }
+    printerr("These system directories need root; the rest of the enrollment is in place.\n");
+    return 0;
+}
+
+private void enroll_report(EnrollOutcome outcome, string? config_action) {
+    foreach(var directory in outcome.created) {
+        print("%-9s %s\n", "Created", directory);
+    }
+    if(outcome.existed.count() > 0) {
+        print(@"$(outcome.existed.count()) directories already existed (left alone)\n");
+    }
+    if(config_action != null) {
+        print(@"$config_action\n");
+    }
+}
+
+private void enroll_print_directories(string title, Vector<string> directories) {
+    print(@"$title:\n");
+    foreach(var directory in directories) {
+        var suffix = File.new_for_path(directory).query_exists() ? " (already exists)" : "";
+        print(@"  $directory$suffix\n");
+    }
+}
+
+/** The yes/no confirmation; anything but an explicit yes (or EOF) declines. */
+private bool enroll_confirm() {
+    print("Enrol this system? [y/N] ");
+    stdout.flush();
+    var reply = enroll_read_line();
+    if(reply == null) {
+        return false;
+    }
+    var answer = reply.strip().down();
+    return answer == "y" || answer == "yes";
+}
+
+/** One line from stdin, or null at EOF; FileStream exposes no read_line. */
+private string? enroll_read_line() {
+    var builder = new StringBuilder();
+    var character = stdin.getc();
+    while(character != -1 && character != '\n') {
+        builder.append_c((char)character);
+        character = stdin.getc();
+    }
+    if(character == -1 && builder.len == 0) {
+        return null;
+    }
+    return builder.str;
+}
+
+/** A {@link Usm.Configuration} rendered exactly as it is written to usm.config. */
+private string enroll_render_config(Usm.Configuration config) throws Error {
+    var properties = Usm.Configuration.get_mapper().map_from(config);
+    return new JsonElement.from_properties(properties).stringify_pretty();
+}
+
+
+/**
+ * Unified-style line diff (" " shared, "-" old-only, "+" new-only) via
+ * longest common subsequence — both sides are mapper-rendered, so the
+ * diff shows semantic changes rather than formatting churn.
+ */
+private string[] enroll_diff_lines(string old_text, string new_text) {
+    var old_lines = enroll_split_lines(old_text);
+    var new_lines = enroll_split_lines(new_text);
+    var old_count = old_lines.length;
+    var new_count = new_lines.length;
+
+    var table = new int[old_count + 1, new_count + 1];
+    for(var i = old_count - 1; i >= 0; i--) {
+        for(var j = new_count - 1; j >= 0; j--) {
+            table[i, j] = old_lines[i] == new_lines[j]
+                ? table[i + 1, j + 1] + 1
+                : int.max(table[i + 1, j], table[i, j + 1]);
+        }
+    }
+
+    var diff = new Vector<string>();
+    var a = 0;
+    var b = 0;
+    while(a < old_count && b < new_count) {
+        if(old_lines[a] == new_lines[b]) {
+            diff.add(@"  $(old_lines[a])");
+            a++;
+            b++;
+        }
+        else if(table[a + 1, b] >= table[a, b + 1]) {
+            diff.add(@"- $(old_lines[a])");
+            a++;
+        }
+        else {
+            diff.add(@"+ $(new_lines[b])");
+            b++;
+        }
+    }
+    while(a < old_count) {
+        diff.add(@"- $(old_lines[a++])");
+    }
+    while(b < new_count) {
+        diff.add(@"+ $(new_lines[b++])");
+    }
+    return diff.to_array();
+}
+
+private string[] enroll_split_lines(string text) {
+    var lines = text.split("\n", -1);
+    if(lines.length > 0 && lines[lines.length - 1] == "") {
+        lines.resize(lines.length - 1);
+    }
+    return lines;
+}

+ 1 - 1
src/cli/Install.vala

@@ -67,7 +67,7 @@ private int install_main(string[] args) {
                 }
                 continue;
             }
-            var client = package.repository.get_client();
+            var client = package.repository.get_client(state.config);
             var path = state.generate_cache_path(package.manifest);
             var cache_dir = File.new_for_path(path);
             if(!cache_dir.query_exists()) {

+ 1 - 0
src/cli/meson.build

@@ -7,6 +7,7 @@ sources += files('Install.vala')
 sources += files('Deploy.vala')
 sources += files('Scaffold.vala')
 sources += files('GenConfig.vala')
+sources += files('Enroll.vala')
 
  deps = dependencies
  deps += usm_dep

+ 10 - 0
src/lib/Configuration.vala

@@ -21,9 +21,19 @@ namespace Usm {
         public Paths? paths { get; set; }
         public SystemPackageManagerConfig? system_package_manager { get; set; }
 
+        /**
+         * Seconds a single repository transfer may run before it is
+         * aborted; consumed by {@link CurlRepositoryClient} through
+         * {@link Repository.get_client}.
+         */
+        public int download_timeout { get; set; default = 300; }
+
         public static PropertyMapper<Configuration> get_mapper() {
             return PropertyMapper.build_for<Configuration>(cfg => {
                 cfg.map<bool>("is_managed", o => o.is_managed, (o, v) => o.is_managed = v);
+                cfg.map<int>("download_timeout", o => o.download_timeout, (o, v) => o.download_timeout = v)
+                    .undefined_when(o => o.download_timeout == 0)
+                    .when_undefined(o => o.download_timeout = 300);
                 cfg.map_properties_with<ManagedConfiguration>("managed", o => o.managed_config, (o, v) => o.managed_config = v, ManagedConfiguration.get_mapper())
                     .undefined_when(o => o.managed_config == null)
                     .when_undefined(o => o.managed_config = null);

+ 233 - 0
src/lib/Repository/CurlRepositoryClient.vala

@@ -0,0 +1,233 @@
+namespace Usm {
+
+    /**
+     * A {@link RepositoryClient} that fetches over HTTP/HTTPS by shelling
+     * out to `curl`.
+     *
+     * GIO cannot fetch `http(s)://` URIs on platforms whose GIO build has
+     * no TLS backend (musl/alpine images report "Operation not supported"),
+     * so {@link Repository.get_client} routes remote schemes here while
+     * `file://` stays on {@link GioRepositoryClient}.
+     *
+     * Each transfer runs `curl -fsSL --max-time {@link timeout_seconds} --
+     * <url>` and streams stdout into a `<destination>.part` file, counting
+     * bytes as they are written: `-s` keeps curl completely silent during
+     * the transfer and `-S` lets genuine failures print one error line to
+     * stderr, which is captured and surfaced through the thrown error.
+     * Progress reports carry the exact byte counts — current from the
+     * pump's own counter, total from a `Content-Length` probe before the
+     * transfer — and the finished part file is moved over the destination
+     * only after curl exits successfully.
+     */
+    public class CurlRepositoryClient : RepositoryClient {
+
+        /** Default {@link timeout_seconds} when no configuration is supplied. */
+        public const int DEFAULT_TIMEOUT_SECONDS = 300;
+
+        /**
+         * Seconds a single transfer (and the size probe before it) may run
+         * before curl aborts it; {@link Repository.get_client} seeds this
+         * from {@link Configuration.download_timeout}.
+         */
+        public int timeout_seconds { get; set; default = DEFAULT_TIMEOUT_SECONDS; }
+
+        public CurlRepositoryClient(Repository repo, int timeout = DEFAULT_TIMEOUT_SECONDS) {
+            repository_config = repo;
+            timeout_seconds = timeout;
+        }
+
+        public override void download_repository_listing(string path, RepositoryClientProgressCallback? callback = null) throws Error {
+            download("PACKAGES.usml", path, callback);
+        }
+
+        public override void download_package(string path, RepositoryListingEntry package, RepositoryClientProgressCallback? callback = null) throws Error {
+            download(package.path, path, 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);
+            var part_path = @"$destination.part";
+
+            // Exact total up front where the server states one; transfers
+            // without Content-Length report progress against 0 until the
+            // final report
+            var total_bytes = content_length(url);
+
+            var launcher = new SubprocessLauncher(SubprocessFlags.STDOUT_PIPE | SubprocessFlags.STDERR_PIPE);
+            var argv = new string[] {
+                "curl", "-fsSL",
+                "--max-time", timeout_seconds.to_string(),
+                "--", url
+            };
+            Subprocess process;
+            try {
+                process = launcher.spawnv(argv);
+            }
+            catch(Error e) {
+                throw new IOError.NOT_FOUND(@"Unable to launch curl to fetch \"$url\" (is curl installed?): $(e.message)");
+            }
+
+            var output = File.new_for_path(part_path).replace(null, false, FileCreateFlags.REPLACE_DESTINATION);
+            var loop = new MainLoop();
+            uint64 downloaded = 0;
+            var failure = "";
+            var errors = new StringBuilder();
+            // Both pipes must drain before curl is waited on: stdout closes
+            // at end-of-body, while an error line can still land on stderr
+            var pumps_running = 2;
+
+            ProgressEmitter report = () => {
+                if(callback == null) {
+                    return;
+                }
+                callback(filename, (int64)downloaded, (int64)total_bytes);
+            };
+
+            ChunkConsumer consume_body = (data, length) => {
+                var chunk = data[0:length];
+                size_t written;
+                try {
+                    output.write_all(chunk, out written);
+                    downloaded += written;
+                    report();
+                }
+                catch(Error e) {
+                    failure = @"writing \"$part_path\": $(e.message)";
+                }
+            };
+
+            Completion finished = () => {
+                pumps_running--;
+                if(pumps_running > 0) {
+                    return;
+                }
+                process.wait_check_async.begin(null, (source, result) => {
+                    try {
+                        process.wait_check_async.end(result);
+                    }
+                    catch(Error e) {
+                        failure = failure.length > 0 ? failure : e.message;
+                    }
+                    loop.quit();
+                });
+            };
+
+            pump(process.get_stderr_pipe(), (data, length) => {
+                // -sS leaves stderr error text only, and only failures talk
+                if(errors.len < 512) {
+                    errors.append_len((string)data, length);
+                }
+            }, finished);
+
+            pump(process.get_stdout_pipe(), consume_body, () => {
+                try {
+                    output.close();
+                }
+                catch(Error e) {
+                    failure = @"closing \"$part_path\": $(e.message)";
+                }
+                finished();
+            });
+
+            loop.run();
+
+            if(failure.length > 0 || errors.len > 0 && !process.get_successful()) {
+                try {
+                    output.close();
+                }
+                catch(Error e) {
+                }
+                try {
+                    File.new_for_path(part_path).delete();
+                }
+                catch(Error e) {
+                }
+                var detail = errors.len > 0 ? ": " + errors.str.strip() : "";
+                throw new IOError.FAILED(@"curl failed while fetching \"$url\": $(failure.length > 0 ? failure : "transfer error")$detail");
+            }
+
+            // A transfer without Content-Length can only state its total
+            // once every byte is on disk
+            if(total_bytes == 0) {
+                total_bytes = downloaded;
+            }
+            report();
+            File.new_for_path(part_path).move(File.new_for_path(destination), FileCopyFlags.OVERWRITE);
+        }
+
+        private delegate void ProgressEmitter();
+
+        private delegate void ChunkConsumer(uint8[] data, ssize_t length);
+
+        private delegate void Completion();
+
+        /** Reads a pipe to EOF in 8 KiB chunks, handing each chunk to {@link consume} before {@link completed}. */
+        private async void pump(owned InputStream pipe, owned ChunkConsumer consume, owned Completion completed) {
+            while(true) {
+                var buffer = new uint8[8192];
+                ssize_t read;
+                try {
+                    read = yield pipe.read_async(buffer);
+                }
+                catch(Error e) {
+                    break;
+                }
+                if(read <= 0) {
+                    break;
+                }
+                consume(buffer, read);
+            }
+            completed();
+        }
+
+        /**
+         * The URL's Content-Length via `curl -I`, or 0 when the server does
+         * not state one. The probe is silent: headers are consumed from the
+         * pipe and any stderr is drained and discarded — a failed probe
+         * merely leaves the total unknown.
+         */
+        private uint64 content_length(string url) {
+            try {
+                var launcher = new SubprocessLauncher(SubprocessFlags.STDOUT_PIPE | SubprocessFlags.STDERR_PIPE);
+                var process = launcher.spawnv(new string[] {
+                    "curl", "-fsSI",
+                    "--max-time", timeout_seconds.to_string(),
+                    "--", url
+                });
+                var headers = new DataInputStream(process.get_stdout_pipe());
+                string? found = null;
+                while(true) {
+                    var header = headers.read_line();
+                    if(header == null) {
+                        break;
+                    }
+                    var separator = header.index_of(":");
+                    if(separator < 1 || header.substring(0, separator).down() != "content-length") {
+                        continue;
+                    }
+                    found = header.substring(separator + 1).strip();
+                }
+                process.get_stderr_pipe().read_bytes(1);
+                if(!process.wait()) {
+                    return 0;
+                }
+                return found != null ? uint64.parse(found) : 0;
+            }
+            catch(Error e) {
+            }
+            return 0;
+        }
+
+        private Uri get_for_path(string path) throws Error {
+            var uri = Uri.parse(repository_config.url, UriFlags.NONE);
+            // A scheme-plus-host URL ("http://h:1") has an empty path, and a
+            // relative joined path would make Uri.build reject the URI
+            var base_path = uri.get_path() ?? "/";
+            if(base_path.length == 0) {
+                base_path = "/";
+            }
+            return Uri.build(UriFlags.NONE, uri.get_scheme(), uri.get_userinfo(), uri.get_host(), uri.get_port(), Path.build_filename(base_path, path), uri.get_query(), uri.get_fragment());
+        }
+    }
+}

+ 12 - 3
src/lib/Repository/Repository.vala

@@ -30,18 +30,27 @@ namespace Usm {
             Repository.get_mapper().map_into(this, element.as<Invercargill.Properties>());
         }
 
-        public RepositoryClient get_client() throws Error {
+        /**
+         * The client for this repository's URL scheme: `http`/`https` via
+         * curl, `file` via GIO, anything else rejected. When a
+         * {@link configuration} is supplied its
+         * {@link Configuration.download_timeout} seeds the curl client's
+         * transfer timeout.
+         */
+        public RepositoryClient get_client(Configuration? configuration = null) throws Error {
             var uri = Uri.parse(url, UriFlags.NONE);
             var scheme = uri.get_scheme();
 
             switch (scheme) {
                 case "http":
                 case "https":
+                    // GIO builds without a TLS backend (musl/alpine) cannot
+                    // fetch remote URIs, so HTTP goes through curl
+                    return new CurlRepositoryClient(this, configuration?.download_timeout ?? CurlRepositoryClient.DEFAULT_TIMEOUT_SECONDS);
                 case "file":
-                case "ftp":
                     return new GioRepositoryClient(this);
                 default:
-                    assert_not_reached();
+                    throw new IOError.NOT_SUPPORTED(@"Repository \"$name\" uses the unsupported scheme \"$scheme\" (expected http, https or file)");
             }
         }
 

+ 1 - 1
src/lib/State/State.vala

@@ -73,7 +73,7 @@ namespace Usm {
         }
 
         public void refresh_list(Repository repository, RepositoryClientProgressCallback? callback = null) throws Error {
-            var client = repository.get_client();
+            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()) {

+ 1 - 0
src/lib/meson.build

@@ -22,6 +22,7 @@ sources += files('Repository/Repository.vala')
 sources += files('Repository/RepositoryListing.vala')
 sources += files('Repository/RepositoryClient.vala')
 sources += files('Repository/GioRepositoryClient.vala')
+sources += files('Repository/CurlRepositoryClient.vala')
 sources += files('State/State.vala')
 sources += files('State/CachedPackage.vala')
 sources += files('State/OriginInformation.vala')

Энэ ялгаанд хэт олон файл өөрчлөгдсөн тул зарим файлыг харуулаагүй болно