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 `//` (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 `/`. * {@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 `/`, created by {@link begin}. */ public string directory { get; private set; } private Vector entries = new Vector(); private Vector undone = new Vector(); 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(); 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(); 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(); 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 files_created = new Vector(); public Vector files_overwritten = new Vector(); public Vector files_removed = new Vector(); 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(); 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 values) { var escaped = new Vector(); foreach(var value in values) { escaped.add(json_string(value)); } return "[" + string.joinv(",", escaped.to_array()) + "]"; } } } }