From b4dc97568200dfcae62926c2108867ef5d90aa0e Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sat, 22 Aug 2026 22:35:15 -0700 Subject: [PATCH] Implement the removal guardrail the README already promised The README's safety model claimed convert "runs an APT simulation and aborts if APT wants to remove critical packages" or if "too many removals are detected (default threshold 40)". Neither existed. There was no --max-removals flag, no critical-package list, and convert ran straight from `apt-get update` to `apt-get -y install` with nothing in between. plan mode does simulate, but it only tees the output to a file and checks the exit code -- it never reads the removals, and convert never calls it. That is the gap that matters here: mixing Mint and Ubuntu repositories is exactly when APT resolves a conflict by proposing to remove a large part of the system, and -y means nothing stops it. simulate_and_gate now runs the same install as a simulation against the live APT configuration -- sources and pinning are already written by that point, so it reflects what the real install would do -- and parses the Remv lines: - any package in CRITICAL_PACKAGES aborts unconditionally. There is no threshold at which removing sudo, systemd, libc6 or the kernel meta package is acceptable. - more than --max-removals (default 40) aborts, listing them. - anything below that is listed as a warning and allowed. - a simulation APT cannot resolve aborts rather than proceeding. The package list moved into mint_stack_packages(), read by both the gate and the installer, so the gate cannot end up vouching for a different set of packages than the one that gets installed. Also fixes usage(), which told users to run `convert --i-accept-theISK`. --- ubuntu-to-mint-convert-v3.sh | 116 ++++++++++++++++++++++++++++++++--- 1 file changed, 107 insertions(+), 9 deletions(-) diff --git a/ubuntu-to-mint-convert-v3.sh b/ubuntu-to-mint-convert-v3.sh index 3cfafce..843b842 100644 --- a/ubuntu-to-mint-convert-v3.sh +++ b/ubuntu-to-mint-convert-v3.sh @@ -77,6 +77,26 @@ UBUNTU_BASE="" DEFAULT_MINT="" ALLOWED_TARGETS=() +# Safety gate for the APT simulation run before the real install. +# Mixing Mint and Ubuntu repositories is exactly the situation where APT +# resolves a conflict by proposing to remove a large slice of the system, +# and the install runs with -y, so nothing would stop it. +MAX_REMOVALS=40 + +# Packages whose removal means the machine will not boot, will not have a +# network, or will not let you log in to fix it. Any of these appearing in +# the simulation aborts unconditionally -- there is no threshold at which +# removing sudo or systemd is acceptable. +CRITICAL_PACKAGES=( + apt dpkg libc6 bash coreutils + systemd systemd-sysv init dbus + sudo passwd login + network-manager netplan.io + grub-common grub-pc grub-efi-amd64 grub-efi-amd64-signed + linux-generic linux-image-generic linux-headers-generic + openssh-server +) + # Key handling MINT_KEYID="A6616109451BBBF2" SYSTEM_KEYRING="/usr/share/keyrings/linuxmint-repo.gpg" @@ -133,7 +153,7 @@ ubuntu-to-mint-convert-v3.sh (v${SCRIPT_VERSION}) Usage (command-first): sudo bash $0 doctor [options] sudo bash $0 plan [options] - sudo bash $0 convert --i-accept-theISK [options] + sudo bash $0 convert --i-accept-the-risk [options] sudo bash $0 rollback /root/ubuntu-to-mint-backup-YYYYMMDD-HHMMSS Usage (options-first ALSO supported): @@ -149,6 +169,9 @@ Options: --keep-ppas Do not disable third-party sources (not recommended) --preserve-snap Keep snapd (default: enabled) --with-recommends Allow recommended packages (default: off) + --no-install-recommends Force --no-install-recommends (this is the default) + --max-removals N Abort if the simulation removes more than N + packages (default: ${MAX_REMOVALS}) --yes Non-interactive / auto-confirm --i-accept-the-risk Required for convert EOF @@ -769,6 +792,83 @@ post_install_sanity() { # ========================= # Install stack with retry (fixes "first run partial, second run completes") # ========================= +# Run the exact install APT is about to perform, but simulated, and refuse +# to continue if the result is destructive. This is the gate the README has +# always described; until now `convert` went straight from `apt-get update` +# to `apt-get -y install` with nothing between them. +# +# Runs against the live APT configuration -- Mint sources and pinning are +# already written by this point -- so the simulation reflects what the real +# install would actually do, not an approximation of it. +simulate_and_gate() { + local meta="$1" + local sim_out="${LOG_DIR}/simulate-$(date +%Y%m%d-%H%M%S).txt" + local -a pkgs=() + mapfile -t pkgs < <(mint_stack_packages "$meta") + + info "Simulating the Mint stack install before touching anything..." + + local rc=0 + # shellcheck disable=SC2046 + DEBIAN_FRONTEND=noninteractive apt-get $(apt_opts_common) -s install "${pkgs[@]}" \ + >"$sim_out" 2>&1 || rc=$? + + if [[ $rc -ne 0 ]]; then + cat "$sim_out" >&2 || true + die "APT could not resolve the Mint stack (exit ${rc}). Nothing has been installed. Full output: ${sim_out}" + fi + + # `Remv [version]` is what apt-get -s prints for each removal. + local -a removals=() + mapfile -t removals < <(awk '$1=="Remv"{print $2}' "$sim_out" | sort -u) + local count=${#removals[@]} + + # Critical packages first: no threshold makes these acceptable. + local -a hits=() + local crit r + for crit in "${CRITICAL_PACKAGES[@]}"; do + for r in "${removals[@]}"; do + [[ "$r" == "$crit" ]] && hits+=("$r") + done + done + + if (( ${#hits[@]} > 0 )); then + warn "APT wants to remove packages this system cannot survive without:" + printf ' %s\n' "${hits[@]}" >&2 + die "Refusing to continue. Nothing has been installed. Full simulation: ${sim_out}" + fi + + if (( count > MAX_REMOVALS )); then + warn "APT wants to remove ${count} packages (limit ${MAX_REMOVALS}):" + printf ' %s\n' "${removals[@]}" | head -n 30 >&2 + (( count > 30 )) && echo " ... and $((count - 30)) more" >&2 + die "Refusing to continue. Raise --max-removals if this is genuinely expected. Full simulation: ${sim_out}" + fi + + if (( count > 0 )); then + warn "Simulation removes ${count} package(s), within the limit of ${MAX_REMOVALS}:" + printf ' %s\n' "${removals[@]}" >&2 + else + ok "Simulation removes nothing." + fi + + ok "Simulation passed. Full output: ${sim_out}" +} + +# The one place the Mint stack is listed. The simulation gate and the real +# install both read it, so they cannot drift apart and have the gate vouch +# for a different set of packages than the one that gets installed. +mint_stack_packages() { + local meta="$1" + printf '%s\n' \ + "$meta" \ + mint-meta-core \ + mintsystem \ + mintupdate \ + mintsources \ + mint-meta-codecs +} + install_mint_stack_with_retry() { local meta="$1" shift || true @@ -776,14 +876,8 @@ install_mint_stack_with_retry() { # Ensure DM choice is preseeded BEFORE meta install (lightdm often pulled as dependency) preseed_lightdm_default_display_manager - local -a pkgs=( - "$meta" - mint-meta-core - mintsystem - mintupdate - mintsources - mint-meta-codecs - ) + local -a pkgs=() + mapfile -t pkgs < <(mint_stack_packages "$meta") apply_mintupdate_icon_diversion @@ -1002,6 +1096,7 @@ convert() { xfce) meta="mint-meta-xfce" ;; esac + simulate_and_gate "$meta" install_mint_stack_with_retry "$meta" if [[ "$PRESERVE_SNAP" == "yes" ]]; then @@ -1049,6 +1144,9 @@ parse_args_any_order() { --mint-mirror) MINT_MIRROR="${2:-}"; shift 2;; --keep-ppas) KEEP_PPAS="yes"; shift 1;; --preserve-snap) PRESERVE_SNAP="yes"; shift 1;; + --max-removals) + [[ "${2:-}" =~ ^[0-9]+$ ]] || die "--max-removals requires a number, got '${2:-}'" + MAX_REMOVALS="${2}"; shift 2;; --with-recommends) WITH_RECOMMENDS="yes"; shift 1;; --yes) ASSUME_YES="yes"; shift 1;; --i-accept-the-risk) I_ACCEPT_RISK="yes"; shift 1;;