Przeglądaj źródła

feat: transaction rollback with file-level backup + journal; USML size field; progress bar with colours; 100% completion; grouped help text

clanker 1 tydzień temu
rodzic
commit
9b8329d62f

Plik diff jest za duży
+ 1227 - 1183
installer/install-usm.sh


+ 8 - 1
src/cli/Cli.vala

@@ -14,6 +14,13 @@ public static int main(string[] args) {
         arguments.add_all(Invercargill.Wrap.array(args));
         var command = arguments.skip(1).where(a => !a.has_prefix("-")).first_or_default();
 
+        // `--help` with no subcommand (and a bare `help` command) prints the
+        // overview; with a subcommand the option flows to that command's own usage
+        if((command == null && (arguments.contains("--help") || arguments.contains("-h"))) || command == "help") {
+            Usm.Cli.print_help(false);
+            return 0;
+        }
+
         // `--verbose` (anywhere) streams package management-script output to
         // the terminal; USM_VERBOSE is inherited by nested invocations
         if(arguments.contains("--verbose") || arguments.contains("-v")) {
@@ -121,7 +128,7 @@ public static int main(string[] args) {
 }
 
 private void usage() {
-    printerr("USAGE:\n\tusm manifest\n\tusm info\n\tusm repository\n\tusm install [-y|--yes] <packages>\n\tusm update [-y|--yes] [<packages>]\n\tusm remove [-y|--yes] <packages>\n\tusm downgrade [-y|--yes] <package> [<version>]\n\tusm rebuild <package>\n\tusm clean [lists|builds|sources|all]\n\tusm clean [build|source] <package>\n\tusm search <query>\n\tusm provides <resource>\n\tusm add-repo <url-or-path>\n\tusm deploy\n\tusm scaffold\n\tusm genconfig\n\tusm enroll [-y|--yes] [--state-path <path>]\n");
+    Usm.Cli.print_help(true);
 }
 
 

+ 5 - 1
src/cli/Downgrade.vala

@@ -42,6 +42,8 @@ private int downgrade_main(string[] args) {
         return downgrade_usage();
     }
 
+    var progress = new Usm.Cli.ProgressBar();
+
     Usm.SystemState state = null;
     try {
         state = new Usm.SystemState(paths);
@@ -116,10 +118,12 @@ private int downgrade_main(string[] args) {
         }
 
         printerr("\nRunning transaction...\n");
-        transaction.progress_updated.connect(transaction.print_progress);
+        transaction.progress_updated.connect(progress.on_transaction_progress);
         transaction.run();
+        progress.finish();
     }
     catch(Error e) {
+        progress.fail();
         printerr(@"Error: $(e.message)\n");
         return 238;
     }

+ 94 - 0
src/cli/Help.vala

@@ -0,0 +1,94 @@
+namespace Usm.Cli {
+
+    private struct HelpEntry {
+        public string command;
+        public string description;
+    }
+
+    private struct HelpSection {
+        public string title;
+        public HelpEntry[] entries;
+    }
+
+    private static HelpSection[] help_sections() {
+        return {
+        { "INSTALLATION & MANAGEMENT", {
+            { "install <packages>", "Install packages (plan + confirm; -y to skip)" },
+            { "update [packages]", "Update installed packages (or all if none named)" },
+            { "remove <packages>", "Remove packages (cascades + sweeps orphaned deps)" },
+            { "downgrade <pkg> [ver]", "Downgrade to a previous version" },
+            { "rebuild <package>", "Rebuild a package from cached sources" },
+            { "clean [target]", "Clean caches: lists, builds, sources, or all" }
+        } },
+        { "REPOSITORY", {
+            { "repository init <name>", "Initialise a new repository (keys, layout)" },
+            { "repository add <pkg>", "Add a package (.usmc) to this repository" },
+            { "repository remove <name>", "Remove a package from this repository" },
+            { "repository list", "List packages in a repository" },
+            { "repository verify", "Verify repository signature + package checksums" },
+            { "repository publish <dir>", "Publish repository files to a directory" },
+            { "repository build-list", "Rebuild the signed repository listing" },
+            { "add-repo <url>", "Add a remote repository to the configuration" }
+        } },
+        { "SEARCH & QUERY", {
+            { "search <query>", "Search repositories and installed packages" },
+            { "provides <resource>", "Find which package provides a resource" },
+            { "info", "Show installed package information" }
+        } },
+        { "DEPLOYMENT", {
+            { "deploy <pkg|dir>", "Deploy a package as a container image" },
+            { "manifest deploy", "Deploy from a manifest directory" }
+        } },
+        { "MANIFEST & CONFIG", {
+            { "manifest build", "Build a package from its manifest" },
+            { "manifest install", "Install from a manifest directory" },
+            { "manifest package", "Create a .usmc archive" },
+            { "manifest validate", "Validate a manifest" },
+            { "manifest bump [ver]", "Bump version (+ optional --git tag)" },
+            { "manifest acquire", "Run the acquire script" },
+            { "scaffold", "Scaffold a new package" },
+            { "genconfig", "Generate a USM configuration" },
+            { "enroll", "Enroll this system for USM management" }
+        } },
+        { "OPTIONS", {
+            { "-y, --yes", "Skip confirmation prompts" },
+            { "--verbose", "Show detailed output" },
+            { "--spm <name>", "Override system package manager for deploy" },
+            { "--system <name>", "Shorthand for --base + --spm (deploy)" }
+        } }
+        };
+    }
+
+    /**
+     * Prints the grouped command overview — every command with a
+     * one-line description, aligned, category headers in bold cyan when
+     * the destination stream is a terminal and plain text when piped.
+     * {@link to_stderr} selects the stream (and the stream whose
+     * terminal-ness is probed): `usm --help` writes to stdout, misuse
+     * (`usm` with no arguments, unknown commands) to stderr.
+     */
+    public void print_help(bool to_stderr) {
+        var colour = Posix.isatty(to_stderr ? Posix.STDERR_FILENO : Posix.STDOUT_FILENO);
+        unowned var stream = to_stderr ? stderr : stdout;
+        var title_colour = colour ? "\x1b[1m" : "";
+        var header_colour = colour ? "\x1b[1;36m" : "";
+        var reset = colour ? "\x1b[0m" : "";
+
+        var column = 0;
+        foreach(var section in help_sections()) {
+            foreach(var entry in section.entries) {
+                column = int.max(column, entry.command.length);
+            }
+        }
+
+        stream.printf("%sUSM — Universal Source Manifest package manager%s\n\n", title_colour, reset);
+        foreach(var section in help_sections()) {
+            stream.printf("%s%s%s\n", header_colour, section.title, reset);
+            foreach(var entry in section.entries) {
+                stream.printf("  %s%s  %s\n", entry.command, string.nfill(column - entry.command.length, ' '), entry.description);
+            }
+            stream.printf("\n");
+        }
+        stream.printf("Run 'usm <command> --help' for command-specific options.\n");
+    }
+}

+ 11 - 2
src/cli/Install.vala

@@ -22,6 +22,8 @@ private int install_main(string[] args) {
         return install_usage();
     }
 
+    var progress = new Usm.Cli.ProgressBar();
+
     Usm.SystemState state = null;
     try {
         state = new Usm.SystemState(paths);
@@ -151,14 +153,16 @@ private int install_main(string[] args) {
         }
 
         printerr("\nRunning transaction...\n");
-        transaction.progress_updated.connect(transaction.print_progress);
+        transaction.progress_updated.connect(progress.on_transaction_progress);
         transaction.run();
+        progress.finish();
 
 
         // todo cleanup
 
     }
     catch(Error e) {
+        progress.fail();
         printerr(@"Error: $(e.message)\n");
         return 238;
     }
@@ -198,12 +202,17 @@ private void install_download_packages(Usm.SystemState state, Usm.ResolutionResu
 
 /**
  * The download size in bytes of {@link entry} from {@link repository},
- * or null when it cannot be determined: `file` repositories stat the
+ * or null when it cannot be determined: the size recorded in the
+ * listing is authoritative, while listings built before the field
+ * existed (size 0) fall back to probing — `file` repositories stat the
  * package, remote ones read Content-Length off a curl HEAD request (the
  * last header wins, so redirects resolve to the final transfer). Used
  * to show a plan's total download size before any transfer starts.
  */
 private int64? install_package_download_size(Usm.Repository repository, Usm.RepositoryListingEntry entry) {
+    if(entry.size > 0) {
+        return (int64)entry.size;
+    }
     var uri = repository.url.has_suffix("/") ? repository.url + entry.path : repository.url + "/" + entry.path;
     try {
         var scheme = Uri.parse(uri, UriFlags.NONE).get_scheme();

+ 304 - 0
src/cli/ProgressBar.vala

@@ -0,0 +1,304 @@
+namespace Usm.Cli {
+
+    /**
+     * Single-line, in-place progress bar for a running
+     * {@link Usm.Transaction}: the bar redraws on the bottom line while
+     * completed packages scroll above it, coloured per
+     * {@link Usm.TransactionTask} (green unpack/install, blue build,
+     * magenta test, yellow remove/rebuild/clean-up, cyan strategy, red
+     * failure). When stderr is not a terminal the bar degrades to plain
+     * `[42%] Installing invercargill (2/5)` lines on 10% steps with no
+     * ANSI codes.
+     *
+     * Connect {@link on_transaction_progress} to
+     * {@link Usm.Transaction.progress_updated}:
+     *
+     * ```
+     * var progress = new ProgressBar();
+     * transaction.progress_updated.connect(progress.on_transaction_progress);
+     * transaction.run();
+     * progress.finish();
+     * ```
+     */
+    public class ProgressBar : Object {
+
+        private const string COLOUR_RESET = "\x1b[0m";
+        private const string COLOUR_RED = "\x1b[31m";
+        private const string COLOUR_GREEN = "\x1b[32m";
+        private const string COLOUR_YELLOW = "\x1b[33m";
+        private const string COLOUR_BLUE = "\x1b[34m";
+        private const string COLOUR_MAGENTA = "\x1b[35m";
+        private const string COLOUR_CYAN = "\x1b[36m";
+
+        // TIOCGWINSZ request value on Linux; the CLI is already Linux-only
+        // (mount/chroot in Cli.vala)
+        private const ulong TIOCGWINSZ = 0x5413;
+
+        private bool interactive;
+        private int width;
+
+        private string bar_filled_char;
+        private string bar_empty_char;
+        private string success_mark;
+        private string failure_mark;
+
+        private string? current_package;
+        private TransactionTask current_task;
+        private float current_progress;
+        private int current_index;
+        private int current_total;
+
+        /** A package's completion line fires exactly once, at CLEANING_UP's final 1.0 report. */
+        private bool completion_printed;
+        private bool current_was_removal;
+        private bool current_was_rebuild;
+
+        private string plain_key = "";
+        private int plain_decile = -1;
+
+        construct {
+            // Charset probing needs the locale applied before it reflects
+            // the environment; without this the C locale forces the ASCII
+            // bar/mark fallbacks even on UTF-8 terminals
+            Intl.setlocale(LocaleCategory.ALL, "");
+            interactive = Posix.isatty(Posix.STDERR_FILENO);
+            width = detect_terminal_width();
+            unowned string charset;
+            var utf8 = get_charset(out charset);
+            bar_filled_char = utf8 ? "█" : "#";
+            bar_empty_char = utf8 ? "░" : "-";
+            success_mark = utf8 ? "✓" : "+";
+            failure_mark = utf8 ? "✗" : "x";
+        }
+
+        /**
+         * Adapter matching {@link Usm.Transaction.progress_updated}'s
+         * signature: forwards each report to {@link update}.
+         */
+        public void on_transaction_progress(TransactionTask task_type, string subject, uint task, uint total_tasks, float task_progress) {
+            update(task_type, task_progress, subject, (int)task, (int)total_tasks);
+        }
+
+        /**
+         * Records one progress report and redraws the bar in place. A
+         * package whose CLEANING_UP reaches 100% — its last report — gets
+         * a completion line printed above the bar first (see
+         * {@link complete_line}).
+         */
+        public void update(TransactionTask task, float progress, string? package_name, int index, int total) {
+            // A package's CLEANING_UP reaching 100% is its final report;
+            // the completion line prints above the bar — in plain mode
+            // after its own 100% line — so items scroll as they finish
+            var completes_now = task == TransactionTask.CLEANING_UP && progress >= 1.0f
+                && package_name != null && package_name == current_package && !completion_printed;
+            var outcome = current_was_removal ? TransactionTask.REMOVING
+                : current_was_rebuild ? TransactionTask.REBUILDING : TransactionTask.INSTALLING;
+
+            if(package_name != current_package) {
+                current_package = package_name;
+                completion_printed = false;
+                current_was_removal = false;
+                current_was_rebuild = false;
+            }
+            if(task == TransactionTask.REMOVING) {
+                current_was_removal = true;
+            }
+            if(task == TransactionTask.REBUILDING) {
+                current_was_rebuild = true;
+            }
+
+            current_task = task;
+            current_progress = progress < 0.0f ? 0.0f : progress > 1.0f ? 1.0f : progress;
+            current_index = index;
+            current_total = total;
+
+            if(completes_now) {
+                if(!interactive) {
+                    print_plain();
+                }
+                print_completion(package_name, outcome, true, false);
+                completion_printed = true;
+                if(interactive) {
+                    redraw();
+                }
+                return;
+            }
+
+            if(interactive) {
+                redraw();
+            }
+            else {
+                print_plain();
+            }
+        }
+
+        /**
+         * Prints a completion summary line above the bar —
+         * `✓ invercargill-1.1.0 installed` in the task colour, or
+         * `✗ invercargill-1.1.0 failed` in red — then redraws the bar.
+         */
+        public void complete_line(string package_name, TransactionTask task, bool success) {
+            print_completion(package_name, task, success, true);
+        }
+
+        /**
+         * Marks the in-flight package failed (a thrown
+         * {@link Usm.TransactionError} aborts the run): erases the bar
+         * and prints `✗ <package> failed` in red — no redraw, the
+         * transaction is over. Harmless when nothing was drawn yet.
+         */
+        public void fail() {
+            if(interactive) {
+                stderr.printf("\r\x1b[K");
+            }
+            if(current_package == null || completion_printed) {
+                return;
+            }
+            completion_printed = true;
+            if(interactive) {
+                stderr.printf("%s%s %s failed%s\n", COLOUR_RED, failure_mark, current_package, COLOUR_RESET);
+            }
+            else {
+                stderr.printf("%s %s failed\n", failure_mark, current_package);
+            }
+        }
+
+        /**
+         * Erases the bar line, leaving the cursor at column zero of an
+         * empty line — the effect of a closing newline without a stray
+         * blank line when completion lines were printed last.
+         */
+        public void finish() {
+            if(interactive) {
+                stderr.printf("\r\x1b[K");
+            }
+        }
+
+        private void print_completion(string package_name, TransactionTask task, bool success, bool redraw_bar) {
+            var outcome = success ? past_tense(task) : "failed";
+            var mark = success ? success_mark : failure_mark;
+            if(interactive) {
+                stderr.printf("\r\x1b[K%s%s %s %s%s\n", success ? colour_for_task(task) : COLOUR_RED, mark, package_name, outcome, COLOUR_RESET);
+                if(redraw_bar) {
+                    redraw();
+                }
+            }
+            else {
+                stderr.printf("%s %s %s\n", mark, package_name, outcome);
+            }
+        }
+
+        private void redraw() {
+            var label = label_for(current_task, current_package);
+            var percent = (int)(current_progress * 100);
+            var counts = current_total > 0 ? @" ($(current_index + 1)/$current_total)" : "";
+            var tail = @" $percent% $label$counts";
+            var capacity = int.max(10, int.min(width - 40, width - tail.length - 2));
+
+            var filled = (int)(current_progress * capacity);
+            var bar = new StringBuilder();
+            for(var i = 0; i < filled; i++) {
+                bar.append(bar_filled_char);
+            }
+            for(var i = filled; i < capacity; i++) {
+                bar.append(bar_empty_char);
+            }
+
+            stderr.printf("\r\x1b[K%s[%s]%s%s", colour_for_task(current_task), bar.str, tail, COLOUR_RESET);
+        }
+
+        private void print_plain() {
+            var key = @"$current_package|$(current_task)";
+            var decile = (int)(current_progress * 10);
+            if(key == plain_key && decile == plain_decile) {
+                return;
+            }
+            plain_key = key;
+            plain_decile = decile;
+
+            var counts = current_total > 0 ? @" ($(current_index + 1)/$current_total)" : "";
+            stderr.printf("[%d%%] %s%s\n", (int)(current_progress * 100), label_for(current_task, current_package), counts);
+        }
+
+        private string label_for(TransactionTask task, string? package_name) {
+            if(task == TransactionTask.STRATEGISING) {
+                return "Preparing transaction";
+            }
+            var verb = task.get_verb();
+            var capitalised = verb.substring(0, 1).up() + verb.substring(1);
+            return package_name != null ? @"$capitalised $package_name" : capitalised;
+        }
+
+        private string colour_for_task(TransactionTask task) {
+            switch(task) {
+                case TransactionTask.UNPACKING:
+                case TransactionTask.INSTALLING:
+                    return COLOUR_GREEN;
+                case TransactionTask.BUILDING:
+                    return COLOUR_BLUE;
+                case TransactionTask.TESTING:
+                    return COLOUR_MAGENTA;
+                case TransactionTask.REMOVING:
+                case TransactionTask.REBUILDING:
+                case TransactionTask.CLEANING_UP:
+                    return COLOUR_YELLOW;
+                case TransactionTask.STRATEGISING:
+                    return COLOUR_CYAN;
+                default:
+                    return COLOUR_RESET;
+            }
+        }
+
+        private string past_tense(TransactionTask task) {
+            switch(task) {
+                case TransactionTask.INSTALLING:
+                    return "installed";
+                case TransactionTask.REMOVING:
+                    return "removed";
+                case TransactionTask.REBUILDING:
+                    return "rebuilt";
+                case TransactionTask.UNPACKING:
+                    return "unpacked";
+                case TransactionTask.BUILDING:
+                    return "built";
+                case TransactionTask.TESTING:
+                    return "tested";
+                case TransactionTask.CLEANING_UP:
+                    return "cleaned up";
+                default:
+                    return task.get_verb();
+            }
+        }
+
+        [CCode (cname = "winsize", cheader_filename = "sys/ioctl.h", has_type_id = false)]
+        private struct WinSize {
+            public uint16 ws_row;
+            public uint16 ws_col;
+            public uint16 ws_xpixel;
+            public uint16 ws_ypixel;
+        }
+
+        [CCode (cname = "ioctl", cheader_filename = "sys/ioctl.h")]
+        private static extern int terminal_ioctl(int fd, ulong request, void* argument);
+
+        /**
+         * The terminal width of stderr: TIOCGWINSZ first, then
+         * `$COLUMNS`, then 80 — mirroring how shells and common CLI
+         * tooling pick a fallback width.
+         */
+        private int detect_terminal_width() {
+            WinSize window = WinSize();
+            if(terminal_ioctl(Posix.STDERR_FILENO, TIOCGWINSZ, &window) == 0 && window.ws_col > 0) {
+                return window.ws_col;
+            }
+            var columns = Environment.get_variable("COLUMNS");
+            if(columns != null) {
+                var parsed = int.parse(columns);
+                if(parsed > 0) {
+                    return parsed;
+                }
+            }
+            return 80;
+        }
+    }
+}

+ 5 - 1
src/cli/Rebuild.vala

@@ -12,6 +12,8 @@ private int rebuild_main(string[] args) {
     }
     var package_name = args[2];
 
+    var progress = new Usm.Cli.ProgressBar();
+
     Usm.SystemState state = null;
     try {
         state = new Usm.SystemState(paths);
@@ -33,12 +35,14 @@ private int rebuild_main(string[] args) {
             resource_finder = new Usm.ResourceFinder(paths),
             state = state
         };
-        transaction.progress_updated.connect(transaction.print_progress);
+        transaction.progress_updated.connect(progress.on_transaction_progress);
         transaction.rebuild_package(target);
+        progress.finish();
 
         print(@"Rebuilt $(target.package_name) from cached sources\n");
     }
     catch(Error e) {
+        progress.fail();
         printerr(@"Error: $(e.message)\n");
         return 238;
     }

+ 5 - 1
src/cli/Remove.vala

@@ -40,6 +40,8 @@ private int remove_main(string[] args) {
         return remove_usage();
     }
 
+    var progress = new Usm.Cli.ProgressBar();
+
     Usm.SystemState state = null;
     try {
         state = new Usm.SystemState(paths);
@@ -101,8 +103,9 @@ private int remove_main(string[] args) {
         }
 
         printerr("\nRunning transaction...\n");
-        transaction.progress_updated.connect(transaction.print_progress);
+        transaction.progress_updated.connect(progress.on_transaction_progress);
         transaction.run();
+        progress.finish();
 
         // The state directories go only after a successful transaction;
         // other versions' directories are never touched
@@ -113,6 +116,7 @@ private int remove_main(string[] args) {
         print(@"Removed $(closure.length + orphans.length) package(s) ($(removed_packages.to_string(p => p.package_name, ", ")))\n");
     }
     catch(Error e) {
+        progress.fail();
         printerr(@"Error: $(e.message)\n");
         return 238;
     }

+ 2 - 1
src/cli/Repository.vala

@@ -510,7 +510,8 @@ private int repository_rebuild_listing(string root, uint8[] public_key, uint8[]
             var entry = new Usm.RepositoryListingEntry() {
                 path = file,
                 manifest = manifest,
-                sha512sum = calculate_file_checksum(path)
+                sha512sum = calculate_file_checksum(path),
+                size = (uint64)File.new_for_path(path).query_info(FileAttribute.STANDARD_SIZE, FileQueryInfoFlags.NONE).get_size()
             };
 
             var entry_properties = new InvercargillJson.JsonElement.from_properties(mapper.map_from(entry)).as<InvercargillJson.JsonObject>();

+ 5 - 1
src/cli/Update.vala

@@ -35,6 +35,8 @@ private int update_main(string[] args) {
         }
     }
 
+    var progress = new Usm.Cli.ProgressBar();
+
     Usm.SystemState state = null;
     try {
         state = new Usm.SystemState(paths);
@@ -232,10 +234,12 @@ private int update_main(string[] args) {
         }
 
         printerr("\nRunning transaction...\n");
-        transaction.progress_updated.connect(transaction.print_progress);
+        transaction.progress_updated.connect(progress.on_transaction_progress);
         transaction.run();
+        progress.finish();
     }
     catch(Error e) {
+        progress.fail();
         printerr(@"Error: $(e.message)\n");
         return 238;
     }

+ 2 - 0
src/cli/meson.build

@@ -1,7 +1,9 @@
 
 sources = files('Cli.vala')
+sources += files('Help.vala')
 sources += files('Manifest.vala')
 sources += files('ManifestBump.vala')
+sources += files('ProgressBar.vala')
 sources += files('Repository.vala')
 sources += files('Install.vala')
 sources += files('Update.vala')

+ 393 - 0
src/lib/Journal.vala

@@ -0,0 +1,393 @@
+using Invercargill;
+using Invercargill.DataStructures;
+
+namespace Usm {
+
+    /**
+     * Rollback journal for a {@link Transaction}: every install and
+     * remove records what it is about to change ({@link entry_install}
+     * and {@link entry_remove}), every file that would be overwritten or
+     * deleted is copied into the journal's backup area first
+     * ({@link backup_file}), and each operation that succeeded marks
+     * itself done ({@link complete}). When the transaction then fails,
+     * {@link rollback} undoes the recorded operations in reverse —
+     * deleting installed files, restoring overwritten and removed files
+     * from their backups, and re-creating `installed/` marks — while
+     * {@link describe_rollback} narrates what was undone.
+     *
+     * The journal owns `<backup-root>/<transaction-id>/` (with the
+     * default root `/var/usm/backup`, rooted like every other USM
+     * destination through {@link Paths}), holding `journal.json` plus
+     * each backed-up file mirrored at `<package-name>/<original-path>`.
+     * {@link finish} removes the whole directory on the success path; a
+     * rollback deliberately keeps it for inspection.
+     *
+     * What a journal cannot undo:
+     *
+     * * post-install and remove scripts may change the system beyond
+     *   their manifest's resources — system users, enabled services,
+     *   databases — and those side effects persist through a rollback;
+     * * system packages installed through the system package manager
+     *   are not journalable, they belong to that manager;
+     * * parent directories created for new files are left behind, and
+     *   empty `dir` provides are neither backed up nor re-created;
+     * * the `.usmc` downloads and build caches a transaction made stay
+     *   in the cache — harmless, and reusable by the retry;
+     * * a rollback step that itself fails (a missing or unreadable
+     *   backup, for instance) is reported and skipped, never retried.
+     */
+    public class Journal {
+
+        private const string OPERATION_INSTALL = "install";
+        private const string OPERATION_REMOVE = "remove";
+        /** Extension of the sidecar recording a backed-up symlink's target. */
+        private const string LINK_SIDECAR_SUFFIX = ".usm-link-target";
+
+        /** Directory holding every transaction's backup area, e.g. `/var/usm/backup`. */
+        public string backup_root { get; private set; }
+
+        /** This journal's unique directory name beneath {@link backup_root}. */
+        public string transaction_id { get; private set; }
+
+        /** The backup area `<backup-root>/<transaction-id>`, created by {@link begin}. */
+        public string directory { get; private set; }
+
+        private Vector<JournalEntry> entries = new Vector<JournalEntry>();
+        private Vector<string> undone = new Vector<string>();
+
+        public Journal(string backup_root) {
+            this.backup_root = backup_root;
+            transaction_id = @"$(new DateTime.now_utc().format_iso8601())-$(Uuid.string_random())";
+        }
+
+        /**
+         * Creates the backup directory and writes an empty journal: the
+         * first call of a transaction, before any operation runs.
+         */
+        public void begin() throws Error {
+            directory = Path.build_filename(backup_root, transaction_id);
+            File.new_for_path(directory).make_directory_with_parents();
+            write();
+        }
+
+        /**
+         * Records what installing {@link pkg} will do: the
+         * {@link files_created} destination paths do not exist yet, the
+         * {@link files_overwritten} paths do and will have their content
+         * replaced — back those up with {@link backup_file} before the
+         * install executes. {@link state_mark_path} is the `installed/`
+         * symlink {@link SystemState.mark_installed} will create and
+         * {@link previous_state_target} the target that symlink held
+         * before, null when nothing was marked (see
+         * {@link SystemState.read_installed_target}); a rollback
+         * re-links the previous target, or removes a fresh mark.
+         */
+        public void entry_install(CachedPackage pkg, string[] files_created, string[] files_overwritten,
+                string? state_mark_path = null, string? previous_state_target = null) throws Error {
+            var entry = new JournalEntry();
+            entry.operation = OPERATION_INSTALL;
+            entry.package_name = pkg.package_name;
+            entry.state_mark_path = state_mark_path;
+            entry.previous_state_target = previous_state_target;
+            foreach(var path in files_created) {
+                entry.files_created.add(path);
+            }
+            foreach(var path in files_overwritten) {
+                entry.files_overwritten.add(path);
+            }
+            entries.add(entry);
+            write();
+        }
+
+        /**
+         * Records what removing {@link pkg} will do: every
+         * {@link files_removed} path is deleted by the removal — back
+         * them up with {@link backup_file} before it executes.
+         * {@link state_mark_path} is the `installed/` symlink
+         * {@link SystemState.unmark_installed} will delete and
+         * {@link state_target} the target it held (null when nothing was
+         * marked); a rollback re-links it.
+         */
+        public void entry_remove(CachedPackage pkg, string[] files_removed,
+                string? state_mark_path = null, string? state_target = null) throws Error {
+            var entry = new JournalEntry();
+            entry.operation = OPERATION_REMOVE;
+            entry.package_name = pkg.package_name;
+            entry.state_mark_path = state_mark_path;
+            entry.state_target = state_target;
+            foreach(var path in files_removed) {
+                entry.files_removed.add(path);
+            }
+            entries.add(entry);
+            write();
+        }
+
+        /**
+         * Copies {@link file_path} into {@link package_name}'s backup
+         * area, preserving mode and ownership metadata. A symbolic link
+         * is recorded as a sidecar holding its target, so restoration
+         * re-creates the link itself rather than a copy of whatever it
+         * pointed at.
+         */
+        public void backup_file(string file_path, string package_name) throws Error {
+            var backup_path = backup_path_for(file_path, package_name);
+            var parent = File.new_for_path(backup_path).get_parent();
+            if(parent != null && !parent.query_exists()) {
+                parent.make_directory_with_parents();
+            }
+
+            var info = File.new_for_path(file_path)
+                .query_info(FileAttribute.STANDARD_TYPE + "," + FileAttribute.STANDARD_SYMLINK_TARGET, FileQueryInfoFlags.NOFOLLOW_SYMLINKS);
+            if(info.get_file_type() == FileType.SYMBOLIC_LINK) {
+                FileUtils.set_contents(backup_path + LINK_SIDECAR_SUFFIX, info.get_symlink_target() ?? "");
+                return;
+            }
+            File.new_for_path(file_path).copy(File.new_for_path(backup_path), FileCopyFlags.ALL_METADATA);
+        }
+
+        /**
+         * Marks {@link package_name}'s latest uncompleted entry done: the
+         * operation succeeded, so a rollback has to undo it.
+         */
+        public void complete(string package_name) throws Error {
+            for(int i = (int)entries.length - 1; i >= 0; i--) {
+                if(entries[i].package_name == package_name && !entries[i].completed) {
+                    entries[i].completed = true;
+                    break;
+                }
+            }
+            write();
+        }
+
+        /**
+         * Undoes every recorded operation in reverse order — completed
+         * ones plus a trailing half-applied one whose own operation
+         * failed — removing again what was installed and installing
+         * again what was removed. Steps that fail are reported and
+         * skipped, never retried; the backup directory is kept
+         * afterwards for inspection. Follow with
+         * {@link describe_rollback}.
+         */
+        public void rollback() {
+            undone = new Vector<string>();
+            for(int i = (int)entries.length - 1; i >= 0; i--) {
+                var entry = entries[i];
+                try {
+                    if(entry.operation == OPERATION_INSTALL) {
+                        rollback_install(entry);
+                    }
+                    else {
+                        rollback_remove(entry);
+                    }
+                }
+                catch(Error e) {
+                    warning(@"[Usm] Could not roll back the $(entry.operation) of \"$(entry.package_name)\": $(e.message)");
+                }
+            }
+        }
+
+        /**
+         * A human-readable transcript of what the last {@link rollback}
+         * undid, one line per operation; when nothing was ever recorded
+         * the transaction had changed nothing, and the description says
+         * so instead.
+         */
+        public string describe_rollback() {
+            if(undone.length == 0) {
+                return "Nothing had been changed when the transaction failed, so there was nothing to roll back.";
+            }
+            var builder = new StringBuilder("Rolled back after the failure:\n");
+            foreach(var line in undone) {
+                builder.append("  ").append(line).append("\n");
+            }
+            return builder.str;
+        }
+
+        /**
+         * Removes the backup directory entirely — the success path. The
+         * {@link backup_root} goes with it when no other transaction is
+         * leaving a directory behind.
+         */
+        public void finish() throws Error {
+            Util.delete_tree(directory);
+            try {
+                File.new_for_path(backup_root).delete();
+            }
+            catch(Error e) {
+                // a non-empty root belongs to a concurrent transaction
+            }
+        }
+
+        private void rollback_install(JournalEntry entry) throws Error {
+            for(int i = (int)entry.files_created.length - 1; i >= 0; i--) {
+                delete_if_present(entry.files_created[i]);
+            }
+            for(int i = (int)entry.files_overwritten.length - 1; i >= 0; i--) {
+                restore_file(entry.files_overwritten[i], entry.package_name);
+            }
+            if(entry.state_mark_path != null) {
+                if(entry.previous_state_target == null) {
+                    delete_if_present((!)entry.state_mark_path);
+                }
+                else {
+                    relink((!)entry.state_mark_path, (!)entry.previous_state_target);
+                }
+            }
+
+            var parts = new Vector<string>();
+            parts.add(@"removed $(entry.files_created.length) created file(s)");
+            if(entry.files_overwritten.length > 0) {
+                parts.add(@"restored $(entry.files_overwritten.length) overwritten file(s)");
+            }
+            if(entry.state_mark_path != null) {
+                parts.add(entry.previous_state_target == null ? "removed the installed mark" : "restored the previous installed mark");
+            }
+            undone.add(@"uninstalled \"$(entry.package_name)\": $(string.joinv(", ", parts.to_array()))");
+        }
+
+        private void rollback_remove(JournalEntry entry) throws Error {
+            for(int i = (int)entry.files_removed.length - 1; i >= 0; i--) {
+                restore_file(entry.files_removed[i], entry.package_name);
+            }
+            if(entry.state_mark_path != null && entry.state_target != null) {
+                relink((!)entry.state_mark_path, (!)entry.state_target);
+            }
+
+            var parts = new Vector<string>();
+            parts.add(@"restored $(entry.files_removed.length) file(s)");
+            if(entry.state_mark_path != null && entry.state_target != null) {
+                parts.add("re-created the installed mark");
+            }
+            undone.add(@"reinstalled \"$(entry.package_name)\": $(string.joinv(", ", parts.to_array()))");
+        }
+
+        private void delete_if_present(string path) throws Error {
+            try {
+                File.new_for_path(path).delete();
+            }
+            catch(IOError.NOT_FOUND e) {
+            }
+        }
+
+        private void relink(string mark_path, string target) throws Error {
+            delete_if_present(mark_path);
+            File.new_for_path(mark_path).make_symbolic_link(target);
+        }
+
+        private void restore_file(string file_path, string package_name) throws Error {
+            var backup_path = backup_path_for(file_path, package_name);
+            var sidecar_path = backup_path + LINK_SIDECAR_SUFFIX;
+            if(File.new_for_path(sidecar_path).query_exists()) {
+                string target;
+                FileUtils.get_contents(sidecar_path, out target);
+                delete_if_present(file_path);
+                File.new_for_path(file_path).make_symbolic_link(target);
+                return;
+            }
+
+            var dest = File.new_for_path(file_path);
+            var parent = dest.get_parent();
+            if(parent != null && !parent.query_exists()) {
+                parent.make_directory_with_parents();
+            }
+            delete_if_present(file_path);
+            File.new_for_path(backup_path).copy(dest, FileCopyFlags.ALL_METADATA);
+        }
+
+        /** The backup location mirroring {@link file_path} under this journal's area. */
+        private string backup_path_for(string file_path, string package_name) {
+            var mirrored = file_path.has_prefix(Path.DIR_SEPARATOR_S) ? file_path.substring(1) : file_path;
+            return Path.build_filename(directory, package_name, mirrored);
+        }
+
+        private void write() throws Error {
+            var builder = new StringBuilder("[");
+            for(int i = 0; i < entries.length; i++) {
+                if(i > 0) {
+                    builder.append(",");
+                }
+                entries[i].write(builder);
+            }
+            builder.append("]");
+            FileUtils.set_contents(Path.build_filename(directory, "journal.json"), builder.str);
+        }
+
+        /** One recorded operation: everything a rollback needs to undo it. */
+        private class JournalEntry {
+            public string operation = "";
+            public string package_name = "";
+            public bool completed = false;
+            public Vector<string> files_created = new Vector<string>();
+            public Vector<string> files_overwritten = new Vector<string>();
+            public Vector<string> files_removed = new Vector<string>();
+            public string? state_mark_path = null;
+            public string? state_target = null;
+            public string? previous_state_target = null;
+
+            public void write(StringBuilder builder) {
+                var fields = new Vector<string>();
+                fields.add(json_field("operation", json_string(operation)));
+                fields.add(json_field("package", json_string(package_name)));
+                fields.add(json_field("status", json_string(completed ? "completed" : "in-progress")));
+                fields.add(json_field("files_created", json_string_array(files_created)));
+                fields.add(json_field("files_overwritten", json_string_array(files_overwritten)));
+                fields.add(json_field("files_removed", json_string_array(files_removed)));
+                if(state_mark_path != null) {
+                    fields.add(json_field("state_entry", json_string((!)state_mark_path)));
+                }
+                builder.append("{").append(string.joinv(",", fields.to_array())).append("}");
+            }
+
+            private static string json_field(string key, string value) {
+                return json_string(key) + ":" + value;
+            }
+
+            private static string json_string(string value) {
+                var builder = new StringBuilder("\"");
+                for(int i = 0; i < value.length; i++) {
+                    char c = value[i];
+                    switch(c) {
+                        case '"':
+                            builder.append("\\\"");
+                            break;
+                        case '\\':
+                            builder.append("\\\\");
+                            break;
+                        case '\b':
+                            builder.append("\\b");
+                            break;
+                        case '\f':
+                            builder.append("\\f");
+                            break;
+                        case '\n':
+                            builder.append("\\n");
+                            break;
+                        case '\r':
+                            builder.append("\\r");
+                            break;
+                        case '\t':
+                            builder.append("\\t");
+                            break;
+                        default:
+                            if(c < 0x20) {
+                                builder.append_printf("\\u%04x", (int)c);
+                            }
+                            else {
+                                builder.append_c(c);
+                            }
+                            break;
+                    }
+                }
+                return builder.append("\"").str;
+            }
+
+            private static string json_string_array(Vector<string> values) {
+                var escaped = new Vector<string>();
+                foreach(var value in values) {
+                    escaped.add(json_string(value));
+                }
+                return "[" + string.joinv(",", escaped.to_array()) + "]";
+            }
+        }
+    }
+}

+ 58 - 0
src/lib/Manifest.vala

@@ -462,6 +462,44 @@ namespace Usm {
         }
 
         public delegate void ResourceProgressCallback(ResourceRef resource, uint current_resource, uint total_resources, float resource_frac);
+
+        /**
+         * The destination paths {@link install_resources} will affect,
+         * split by what it will do to each: paths collected in
+         * {@link created} do not exist yet and will appear, paths in
+         * {@link overwritten} exist and will have their content
+         * replaced. The same ordering and the same
+         * {@link Paths.get_suggested_path_for_resource} resolution
+         * drive both methods, so a transaction journal can record — and
+         * back up — everything before the install runs. Directory
+         * provides are not reported: an empty directory is neither worth
+         * a backup nor worth deleting on rollback, and parent
+         * directories created implicitly for new files are left behind
+         * the same way.
+         */
+        public void report_install_resources(Paths paths, out Vector<string> created, out Vector<string> overwritten) {
+            created = new Vector<string>();
+            overwritten = new Vector<string>();
+            var seen = new HashSet<string>();
+            var install_order = provides.sort((a, b) => paths.get_suggested_path_for_resource(a.key).length - paths.get_suggested_path_for_resource(b.key).length);
+            foreach(var resource in install_order) {
+                if(resource.value.file_type == Usm.ManifestFileType.DIRECTORY) {
+                    continue;
+                }
+                var path = paths.get_suggested_path_for_resource(resource.key);
+                if(seen.contains(path)) {
+                    continue;
+                }
+                seen.add(path);
+                if(File.new_for_path(path).query_exists()) {
+                    overwritten.add(path);
+                }
+                else {
+                    created.add(path);
+                }
+            }
+        }
+
         public void install_resources(string source_path, string build_path, string? install_path, Paths paths, ResourceProgressCallback callback, bool dry_run = false) throws Error {
             // Install each resource speficied by the manifest
             var resource_count = provides.count();
@@ -559,6 +597,26 @@ namespace Usm {
             }
         }
 
+        /**
+         * The destination paths {@link remove_resources} will delete:
+         * every non-directory provide that currently exists, files and
+         * symbolic links alike, in the same files-before-directories
+         * removal order. Paths whose destination is already gone (or a
+         * dangling link, which {@link remove_resources} cannot see
+         * either) are not reported — there is nothing to back up.
+         */
+        public Vector<string> report_remove_resources(Paths paths) {
+            var removed = new Vector<string>();
+            var non_directories = provides.where(r => r.value.file_type != Usm.ManifestFileType.DIRECTORY);
+            foreach(var resource in non_directories) {
+                var path = paths.get_suggested_path_for_resource(resource.key);
+                if(File.new_for_path(path).query_exists()) {
+                    removed.add(path);
+                }
+            }
+            return removed;
+        }
+
         public void remove_resources(Paths paths, ResourceProgressCallback callback) throws Error {
             var non_directories = provides.where(r => r.value.file_type != Usm.ManifestFileType.DIRECTORY).cache();
             var directories = provides.where(r => r.value.file_type == Usm.ManifestFileType.DIRECTORY).cache();

+ 11 - 1
src/lib/Repository/RepositoryListing.vala

@@ -72,15 +72,25 @@ namespace Usm {
         public Manifest manifest { get; set; }
         public BinaryData sha512sum { get; set; }
 
+        /**
+         * The .usmc's size in bytes, recorded by `usm repository
+         * build-list` when it stats the package; 0 marks listings built
+         * before the field existed, so consumers fall back to probing the
+         * repository for the size.
+         */
+        public uint64 size { get; set; default = 0; }
+
 
         public static PropertyMapper<RepositoryListingEntry> get_mapper() {
             return PropertyMapper.build_for<RepositoryListingEntry>(cfg => {
                 cfg.map<string>("path", o => o.path, (o, v) => o.path = v);
                 cfg.map_properties_with<Manifest>("manifest", o => o.manifest, (o, v) => o.manifest = v, Manifest.get_mapper());
                 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 RepositoryListingEntry());
             });
-        }       
+        }
     }
 
     public class RepositoryListingSignature {

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

@@ -205,6 +205,33 @@ namespace Usm {
             return Path.build_filename(state_path, "packages", filename);
         }
 
+        /**
+         * Absolute path of the package's `installed/` mark symlink — the
+         * file {@link mark_installed} creates and
+         * {@link unmark_installed} deletes — so a transaction journal
+         * can record the mark itself for a rollback to re-create.
+         */
+        public string installed_mark_path(CachedPackage package) {
+            return Path.build_filename(state_path, "installed", Path.get_basename(package.state_path));
+        }
+
+        /**
+         * The mark's current symlink target — the cache directory the
+         * package is installed from — or null when nothing is marked.
+         * Read before {@link unmark_installed} so a rollback can
+         * re-link exactly what was there.
+         */
+        public string? read_installed_target(CachedPackage package) throws Error {
+            try {
+                var info = File.new_for_path(installed_mark_path(package))
+                    .query_info(FileAttribute.STANDARD_SYMLINK_TARGET, FileQueryInfoFlags.NOFOLLOW_SYMLINKS);
+                return info.get_symlink_target();
+            }
+            catch(IOError.NOT_FOUND e) {
+                return null;
+            }
+        }
+
         public void mark_installed(CachedPackage package) throws Error {
             var filename = Path.get_basename(package.state_path);
             var symlink = File.new_build_filename(state_path, "installed", filename);

+ 122 - 29
src/lib/Transaction.vala

@@ -50,6 +50,14 @@ namespace Usm {
         private string current_subject = "transaction";
         private TransactionTask current_task_type = TransactionTask.STRATEGISING;
 
+        /**
+         * Active rollback journal, non-null only for the duration of
+         * {@link run} and {@link rebuild_package}: every install and
+         * remove records its changes before executing them so a failure
+         * can be undone in reverse (see {@link Journal}).
+         */
+        private Journal? journal = null;
+
         /**
          * Working sets computed by {@link strategise}: {@link to_install}
          * plus downgrade targets, and {@link to_remove} plus downgrade
@@ -91,37 +99,54 @@ namespace Usm {
             // 1. Verify the transaction is valid
             strategise();
 
-            var all_packages = planned_removal.concat(planned_install);
-            var rebuild_packages = rebuilds.select<CachedPackage>(r => r.package).to_vector();
+            journal = new Journal(journal_backup_root());
+            try {
+                journal.begin();
 
-            // 2. Unpack packages
-            do_for(all_packages, unpack_package, TransactionTask.UNPACKING);
-            do_for(rebuild_packages, unpack_package, TransactionTask.REBUILDING);
+                var all_packages = planned_removal.concat(planned_install);
+                var rebuild_packages = rebuilds.select<CachedPackage>(r => r.package).to_vector();
 
-            // 3. Remove packages
-            do_for(removal_order, remove_package, TransactionTask.REMOVING);
+                // 2. Unpack packages
+                do_for(all_packages, unpack_package, TransactionTask.UNPACKING);
+                do_for(rebuild_packages, unpack_package, TransactionTask.REBUILDING);
 
-            foreach (var lot in install_lots) {
-                // 3. Build packages
-                do_for(lot, build_package, TransactionTask.BUILDING);
+                // 3. Remove packages
+                do_for(removal_order, remove_package, TransactionTask.REMOVING);
 
-                // 4. Test packages
-                do_for(lot, test_package, TransactionTask.TESTING);
+                foreach (var lot in install_lots) {
+                    // 3. Build packages
+                    do_for(lot, build_package, TransactionTask.BUILDING);
 
-                // 5. Install packages
-                do_for(lot, install_package, TransactionTask.INSTALLING);
-            }
+                    // 4. Test packages
+                    do_for(lot, test_package, TransactionTask.TESTING);
 
-            // Rebuilds run the same pipeline once every flagged package is
-            // installed, so dependants build against the fresh artifacts
-            do_for(rebuild_packages, build_package, TransactionTask.REBUILDING);
-            do_for(rebuild_packages, test_package, TransactionTask.REBUILDING);
-            do_for(rebuild_packages, install_package, TransactionTask.REBUILDING);
+                    // 5. Install packages
+                    do_for(lot, install_package, TransactionTask.INSTALLING);
+                }
+
+                // Rebuilds run the same pipeline once every flagged package is
+                // installed, so dependants build against the fresh artifacts
+                do_for(rebuild_packages, build_package, TransactionTask.REBUILDING);
+                do_for(rebuild_packages, test_package, TransactionTask.REBUILDING);
+                do_for(rebuild_packages, install_package, TransactionTask.REBUILDING);
 
-            // 6. Clean up
-            do_for(all_packages, cleanup_package, TransactionTask.CLEANING_UP);
-            do_for(rebuild_packages, cleanup_package, TransactionTask.CLEANING_UP);
+                // 6. Clean up
+                do_for(all_packages, cleanup_package, TransactionTask.CLEANING_UP);
+                do_for(rebuild_packages, cleanup_package, TransactionTask.CLEANING_UP);
 
+                journal.finish();
+            }
+            catch(TransactionError e) {
+                rollback_and_report();
+                throw e;
+            }
+            catch(Error e) {
+                rollback_and_report();
+                throw new TransactionError.UNKNOWN_ERROR(@"Error running transaction: $(e.message)");
+            }
+            finally {
+                journal = null;
+            }
         }
 
         /**
@@ -141,11 +166,51 @@ namespace Usm {
             current_subject = package.package_name;
             current_task_type = TransactionTask.REBUILDING;
 
-            do_for(packages, unpack_package, TransactionTask.REBUILDING);
-            do_for(packages, build_package, TransactionTask.REBUILDING);
-            do_for(packages, test_package, TransactionTask.REBUILDING);
-            do_for(packages, install_package, TransactionTask.REBUILDING);
-            do_for(packages, cleanup_package, TransactionTask.CLEANING_UP);
+            journal = new Journal(journal_backup_root());
+            try {
+                journal.begin();
+
+                do_for(packages, unpack_package, TransactionTask.REBUILDING);
+                do_for(packages, build_package, TransactionTask.REBUILDING);
+                do_for(packages, test_package, TransactionTask.REBUILDING);
+                do_for(packages, install_package, TransactionTask.REBUILDING);
+                do_for(packages, cleanup_package, TransactionTask.CLEANING_UP);
+
+                journal.finish();
+            }
+            catch(TransactionError e) {
+                rollback_and_report();
+                throw e;
+            }
+            catch(Error e) {
+                rollback_and_report();
+                throw new TransactionError.UNKNOWN_ERROR(@"Error rebuilding package $(package.package_name): $(e.message)");
+            }
+            finally {
+                journal = null;
+            }
+        }
+
+        /**
+         * Rolls the active journal back after a failure and prints what
+         * was undone; a rollback failure is reported by the journal
+         * itself and never masks the original error.
+         */
+        private void rollback_and_report() {
+            if(journal == null) {
+                return;
+            }
+            journal.rollback();
+            printerr(journal.describe_rollback() + "\n");
+        }
+
+        /**
+         * `<destination>/<local-state>/usm/backup` — the journal's home,
+         * rooted like every other destination this transaction writes
+         * (`/var/usm/backup` on an unredirected system).
+         */
+        private string journal_backup_root() {
+            return Path.build_filename(paths.destination, paths.local_state, "usm", "backup");
         }
 
         private string previous_key = "";
@@ -477,6 +542,7 @@ namespace Usm {
                 package.create_build_directory();
                 attempt_build(package);
             }
+            report_progress(current_task_type, 1.0f);
         }
 
         /**
@@ -522,6 +588,9 @@ namespace Usm {
             if(test_proc != null) {
                 test_proc.wait_check();
             }
+            // A package with no test executable still transitions through
+            // the testing task, so completion is reported either way
+            report_progress(current_task_type, 1.0f);
         }
 
         private void remove_package(CachedPackage package) throws Error {
@@ -539,14 +608,24 @@ namespace Usm {
                 ? RemoveType.DOWNGRADE
                 : upgrade_removals.has(package) ? RemoveType.UPGRADE : RemoveType.FINAL;
 
+            // Journal before anything is deleted: every existing resource
+            // file is backed up and the installed mark's target recorded,
+            // so a later failure puts the package back exactly as it was
+            var files_removed = manifest.report_remove_resources(paths);
+            journal.entry_remove(package, files_removed.to_array(), state.installed_mark_path(package), state.read_installed_target(package));
+            foreach(var path in files_removed) {
+                journal.backup_file(path, package.package_name);
+            }
+
             // Run remove process if present
             var build_proc = manifest.run_remove(removal_type, SubprocessFlags.STDOUT_SILENCE);
             if(build_proc != null)
                 build_proc.wait_check();
 
-                manifest.remove_resources( paths, (r, cr, tr, f) => report_progress(current_task_type, ((float)cr + (float)f) / (float)tr));
+            manifest.remove_resources( paths, (r, cr, tr, f) => report_progress(current_task_type, ((float)cr + (float)f) / (float)tr));
 
             state.unmark_installed(package);
+            journal.complete(package.package_name);
         }
 
         private void install_package(CachedPackage package) throws Error {
@@ -563,6 +642,19 @@ namespace Usm {
             // than fresh
             var install_type = downgrade_targets.has(package) ? InstallType.DOWNGRADE : InstallType.FRESH;
 
+            // Journal before anything changes on disk: the destination
+            // set comes from the manifest's provides, every file about to
+            // be overwritten is backed up, and the installed mark's
+            // previous target is recorded so the rollback restores it
+            Vector<string> files_created;
+            Vector<string> files_overwritten;
+            manifest.report_install_resources(paths, out files_created, out files_overwritten);
+            journal.entry_install(package, files_created.to_array(), files_overwritten.to_array(),
+                state.installed_mark_path(package), state.read_installed_target(package));
+            foreach(var path in files_overwritten) {
+                journal.backup_file(path, package.package_name);
+            }
+
             // Run install process if present
             if(manifest.executables.install != null) {
                 install_dir = package.create_install_directory();
@@ -582,6 +674,7 @@ namespace Usm {
             // Update the system state, and cleanup
             state.mark_installed(package);
             record_origin(package, manifest);
+            journal.complete(package.package_name);
         }
 
         /**

+ 1 - 0
src/lib/meson.build

@@ -14,6 +14,7 @@ sources += files('Version.vala')
 sources += files('Resolver.vala')
 sources += files('Util.vala')
 sources += files('Transaction.vala')
+sources += files('Journal.vala')
 sources += files('Configuration.vala')
 sources += files('SystemPackageManager.vala')
 sources += files('Ignore/UsmIgnore.vala')

+ 300 - 0
src/tests/TestMain.vala

@@ -232,6 +232,30 @@ namespace Usm.Tests {
             "the ResourceFinder locates libgiognutls.so in a gio/modules directory");
     }
 
+    // ---- Repository listing size field -------------------------------------------
+
+    void test_repository_listing_entry_size() throws Error {
+        var mapper = Usm.RepositoryListingEntry.get_mapper();
+        var entry = new Usm.RepositoryListingEntry() {
+            path = "sized-pkg-1.0.0.usmc",
+            manifest = manifest_from_json(APP_MANIFEST.printf("sized-pkg", "sized-pkg", depends_with("runtime", "[]"))),
+            sha512sum = Wrap.byte_array({ 1, 2, 3 }),
+            size = 4096
+        };
+
+        var serialised = ((!)new JsonElement.from_properties(mapper.map_from(entry))).stringify_pretty();
+        check(serialised.contains("\"size\"") && serialised.contains("4096"), "size serialises into the listing entry");
+
+        var reparsed = mapper.materialise(new JsonElement.from_string(serialised).as<Invercargill.Properties>());
+        check(reparsed.path == "sized-pkg-1.0.0.usmc" && reparsed.size == 4096, "size round-trips through serialisation");
+
+        JsonElement removed;
+        var legacy = new JsonElement.from_string(serialised).as<JsonObject>();
+        legacy.remove("size", out removed);
+        var pre_upgrade = mapper.materialise(legacy);
+        check(pre_upgrade.size == 0, "a pre-upgrade entry without size materialises as 0");
+    }
+
     // ---- Dependency-phase model -------------------------------------------------
 
     /** Minimal manifest with the given "depends" section verbatim. */
@@ -1263,6 +1287,256 @@ exit 1
         Usm.Util.delete_tree(scratch);
     }
 
+    // ---- Transaction rollback journal --------------------------------------------
+
+    /**
+     * Caches a package whose build script produces the provided `bin:`
+     * payload from the build directory (or fails, or produces nothing,
+     * per {@link script}), optionally marking it installed.
+     */
+    Usm.CachedPackage rollback_fixture_package(string state_dir, string name, string version, string script, string[] build_refs, bool installed) throws Error {
+        var source = Path.build_filename(state_dir, @"src-$name-$version");
+        DirUtils.create_with_parents(source, 0755);
+
+        var refs = new StringBuilder();
+        foreach(var resource_ref in build_refs) {
+            if(refs.len > 0) {
+                refs.append(",");
+            }
+            refs.append_printf("\"%s\"", resource_ref);
+        }
+        FileUtils.set_contents(Path.build_filename(source, "MANIFEST.usm"), """
+        {
+          "name": "%s",
+          "version": "%s",
+          "summary": "rollback fixture",
+          "licences": [],
+          "flags": [],
+          "provides": { "bin:%s": "build:out" },
+          "depends": { "runtime": [], "build": [%s], "manage": [] },
+          "execs": { "build": "build.sh" }
+        }
+        """.printf(name, version, name, refs.str));
+        FileUtils.set_contents(Path.build_filename(source, "build.sh"), script);
+        FileUtils.chmod(Path.build_filename(source, "build.sh"), 0755);
+
+        var cache_path = Path.build_filename(state_dir, "packages", @"$name-$version");
+        DirUtils.create_with_parents(cache_path, 0755);
+        Usm.Util.archive(source, Path.build_filename(cache_path, "package.usmc"));
+        Usm.Util.delete_tree(source);
+
+        if(installed) {
+            File.new_build_filename(state_dir, "installed", @"$name-$version").make_symbolic_link(cache_path);
+        }
+        return new Usm.CachedPackage(cache_path);
+    }
+
+    const string ROLLBACK_FAILING_BUILD = """#!/bin/bash
+exit 1
+""";
+
+    /** Builds cleanly but produces nothing, so the install step fails. */
+    const string ROLLBACK_EMPTY_BUILD = """#!/bin/bash
+exit 0
+""";
+
+    string rollback_payload_build(string payload) {
+        return "#!/bin/bash\nprintf '%s' > \"$1/out\"\n".printf(payload);
+    }
+
+    void test_journal_entry_complete_rollback_roundtrip() throws Error {
+        var scratch = make_scratch();
+
+        var package_root = Path.build_filename(scratch, "cache", "journalled-1.0.0");
+        var upgrade_root = Path.build_filename(scratch, "cache", "journalled-2.0.0");
+        DirUtils.create_with_parents(package_root, 0700);
+        DirUtils.create_with_parents(upgrade_root, 0700);
+        var pkg = new Usm.CachedPackage(package_root);
+        var upgrade = new Usm.CachedPackage(upgrade_root);
+
+        var lib_path = Path.build_filename(scratch, "dest", "usr", "lib", "libjournalled.so");
+        DirUtils.create_with_parents(Path.get_dirname(lib_path), 0700);
+        FileUtils.set_contents(lib_path, "old contents");
+        var mark_path = Path.build_filename(scratch, "state", "installed", "journalled-1.0.0");
+        DirUtils.create_with_parents(Path.get_dirname(mark_path), 0700);
+        File.new_for_path(mark_path).make_symbolic_link(package_root);
+
+        var journal = new Usm.Journal(Path.build_filename(scratch, "backup"));
+        journal.begin();
+        check(File.new_for_path(Path.build_filename(scratch, "backup", journal.transaction_id, "journal.json")).query_exists(),
+            "begin creates the journal file");
+
+        // a completed removal: the file is backed up and the mark target recorded
+        journal.entry_remove(pkg, { lib_path }, mark_path, package_root);
+        journal.backup_file(lib_path, pkg.package_name);
+        File.new_for_path(lib_path).delete();
+        File.new_for_path(mark_path).delete();
+        journal.complete(pkg.package_name);
+
+        // a half-applied install on top: one fresh file, the old path
+        // overwritten, the new mark already linked — never completed
+        var fresh_path = Path.build_filename(scratch, "dest", "usr", "bin", "journalled");
+        DirUtils.create_with_parents(Path.get_dirname(fresh_path), 0700);
+        var upgrade_mark_path = Path.build_filename(scratch, "state", "installed", "journalled-2.0.0");
+        FileUtils.set_contents(lib_path, "new contents");
+        journal.entry_install(upgrade, { fresh_path }, { lib_path }, upgrade_mark_path, null);
+        journal.backup_file(lib_path, upgrade.package_name);
+        FileUtils.set_contents(fresh_path, "binary");
+        File.new_for_path(upgrade_mark_path).make_symbolic_link(upgrade_root);
+
+        journal.rollback();
+
+        check(!File.new_for_path(fresh_path).query_exists(), "rollback removes the failed install's created file");
+        string restored;
+        FileUtils.get_contents(lib_path, out restored);
+        check(restored == "old contents", "rollback restores overwritten files from the backup");
+        check(!File.new_for_path(upgrade_mark_path).query_exists(), "rollback removes a mark the failed install created");
+        check(File.new_for_path(mark_path).query_exists(), "rollback re-creates the removal's installed mark");
+
+        var description = journal.describe_rollback();
+        check(description.contains("uninstalled \"journalled-2.0.0\"") && description.contains("reinstalled \"journalled-1.0.0\""),
+            "the rollback transcript names every undone operation");
+
+        string recorded;
+        FileUtils.get_contents(Path.build_filename(scratch, "backup", journal.transaction_id, "journal.json"), out recorded);
+        check(recorded.contains("\"status\":\"completed\"") && recorded.contains("\"files_removed\":[\"" + lib_path + "\"]"),
+            "journal.json records the operation, its status and its files");
+
+        Usm.Util.delete_tree(scratch);
+    }
+
+    void test_journal_finish_removes_backup_directory() throws Error {
+        var scratch = make_scratch();
+        var backup_root = Path.build_filename(scratch, "backup");
+
+        var journal = new Usm.Journal(backup_root);
+        journal.begin();
+        var transaction_directory = Path.build_filename(backup_root, journal.transaction_id);
+        check(File.new_for_path(transaction_directory).query_exists(), "begin creates the transaction backup directory");
+
+        journal.finish();
+        check(!File.new_for_path(transaction_directory).query_exists(), "finish removes the transaction backup directory");
+        check(!File.new_for_path(backup_root).query_exists(), "finish removes the emptied backup root");
+
+        Usm.Util.delete_tree(scratch);
+    }
+
+    void test_transaction_rollback_on_build_failure() throws Error {
+        var original_dir = Environment.get_current_dir();
+        var scratch = make_scratch();
+        var state = make_state(scratch);
+        var state_dir = Path.build_filename(scratch, "state");
+        var paths = scratch_paths(scratch);
+
+        // rb-b build-depends on rb-a, so rb-a and rb-c install in lot 1
+        // and rb-b builds in lot 2 — after both lot-1 installs completed
+        rollback_fixture_package(state_dir, "rb-a", "1.0.0", rollback_payload_build("payload-a"), {}, false);
+        rollback_fixture_package(state_dir, "rb-b", "1.0.0", ROLLBACK_FAILING_BUILD, { "bin:rb-a" }, false);
+        rollback_fixture_package(state_dir, "rb-c", "1.0.0", rollback_payload_build("payload-c"), {}, false);
+
+        var to_install = new HashSet<Usm.CachedPackage>();
+        foreach(var name in new string[] { "rb-a", "rb-b", "rb-c" }) {
+            to_install.add(new Usm.CachedPackage(Path.build_filename(state_dir, "packages", @"$name-1.0.0")));
+        }
+
+        var transaction = new Usm.Transaction() {
+            paths = paths,
+            resource_finder = new Usm.ResourceFinder(),
+            to_install = to_install,
+            to_remove = new HashSet<Usm.CachedPackage>(),
+            state = state
+        };
+
+        var threw = false;
+        try {
+            transaction.run();
+        }
+        catch(Usm.TransactionError e) {
+            threw = true;
+        }
+        check(threw, "a lot-2 build failure fails the transaction");
+
+        check(!File.new_for_path(paths.get_suggested_path_for_resource(new Usm.ResourceRef("bin:rb-a"))).query_exists(),
+            "the rollback removes the first package's installed file");
+        check(!File.new_for_path(paths.get_suggested_path_for_resource(new Usm.ResourceRef("bin:rb-c"))).query_exists(),
+            "the rollback removes the third package's installed file");
+        check(!File.new_for_path(paths.get_suggested_path_for_resource(new Usm.ResourceRef("bin:rb-b"))).query_exists(),
+            "the failing package's file never appeared");
+        check(!File.new_for_path(Path.build_filename(state_dir, "installed", "rb-a-1.0.0")).query_exists(),
+            "the rollback removes the first package's installed mark");
+        check(!File.new_for_path(Path.build_filename(state_dir, "installed", "rb-c-1.0.0")).query_exists(),
+            "the rollback removes the third package's installed mark");
+
+        var found_journal = false;
+        var backup_root = Path.build_filename(scratch, "dest", "var", "usm", "backup");
+        foreach(var transaction_id in Iterate.directory(backup_root)) {
+            var journal_path = Path.build_filename(backup_root, transaction_id, "journal.json");
+            if(!File.new_for_path(journal_path).query_exists()) {
+                continue;
+            }
+            found_journal = true;
+            string recorded;
+            FileUtils.get_contents(journal_path, out recorded);
+            check(recorded.contains("\"package\":\"rb-a-1.0.0\"") && recorded.contains("\"package\":\"rb-c-1.0.0\""),
+                "the kept journal records both rolled-back installs");
+        }
+        check(found_journal, "a rolled-back transaction keeps its journal for inspection");
+
+        Environment.set_current_dir(original_dir);
+        Usm.Util.delete_tree(scratch);
+    }
+
+    void test_transaction_rollback_restores_previous_version() throws Error {
+        var original_dir = Environment.get_current_dir();
+        var scratch = make_scratch();
+        var state = make_state(scratch);
+        var state_dir = Path.build_filename(scratch, "state");
+        var paths = scratch_paths(scratch);
+
+        rollback_fixture_package(state_dir, "rb-u", "1.0.0", rollback_payload_build("payload-old"), {}, false);
+        var first = new Usm.Transaction() {
+            paths = paths,
+            resource_finder = new Usm.ResourceFinder(),
+            to_install = Iterate.these<Usm.CachedPackage>(new Usm.CachedPackage(Path.build_filename(state_dir, "packages", "rb-u-1.0.0"))).to_hash_set(),
+            to_remove = new HashSet<Usm.CachedPackage>(),
+            state = state
+        };
+        first.run();
+
+        var bin_path = paths.get_suggested_path_for_resource(new Usm.ResourceRef("bin:rb-u"));
+        check(File.new_for_path(bin_path).query_exists(), "the old version installs before the update");
+
+        rollback_fixture_package(state_dir, "rb-u", "2.0.0", ROLLBACK_EMPTY_BUILD, {}, false);
+        var second = new Usm.Transaction() {
+            paths = paths,
+            resource_finder = new Usm.ResourceFinder(),
+            to_install = Iterate.these<Usm.CachedPackage>(new Usm.CachedPackage(Path.build_filename(state_dir, "packages", "rb-u-2.0.0"))).to_hash_set(),
+            to_remove = Iterate.these<Usm.CachedPackage>(new Usm.CachedPackage(Path.build_filename(state_dir, "packages", "rb-u-1.0.0"))).to_hash_set(),
+            state = state
+        };
+
+        var threw = false;
+        try {
+            second.run();
+        }
+        catch(Usm.TransactionError e) {
+            threw = true;
+        }
+        check(threw, "the new version's install failure fails the update");
+
+        string restored;
+        FileUtils.get_contents(bin_path, out restored);
+        check(restored == "payload-old", "the old version's file is restored from the backup");
+        check(state.read_installed_target(new Usm.CachedPackage(Path.build_filename(state_dir, "packages", "rb-u-1.0.0")))
+            == Path.build_filename(state_dir, "packages", "rb-u-1.0.0"),
+            "the old version's installed mark is re-created pointing at its cache directory");
+        check(!File.new_for_path(Path.build_filename(state_dir, "installed", "rb-u-2.0.0")).query_exists(),
+            "the failed version's installed mark never lingers");
+
+        Environment.set_current_dir(original_dir);
+        Usm.Util.delete_tree(scratch);
+    }
+
     int main() {
         test_default_ignore();
         try {
@@ -1288,6 +1562,14 @@ exit 1
             print("FAIL data package test threw: %s\n", e.message);
         }
 
+        try {
+            test_repository_listing_entry_size();
+        }
+        catch(Error e) {
+            failures++;
+            print("FAIL repository listing size test threw: %s\n", e.message);
+        }
+
         try {
             test_dependencies_flat_back_compat();
             test_dependencies_nested();
@@ -1356,6 +1638,24 @@ exit 1
             print("FAIL build cache retry test threw: %s\n", e.message);
         }
 
+        try {
+            test_journal_entry_complete_rollback_roundtrip();
+            test_journal_finish_removes_backup_directory();
+        }
+        catch(Error e) {
+            failures++;
+            print("FAIL journal test threw: %s\n", e.message);
+        }
+
+        try {
+            test_transaction_rollback_on_build_failure();
+            test_transaction_rollback_restores_previous_version();
+        }
+        catch(Error e) {
+            failures++;
+            print("FAIL transaction rollback test threw: %s\n", e.message);
+        }
+
         print("%d passed, %d failed\n", passes, failures);
         return failures == 0 ? 0 : 1;
     }

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików