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`.
This commit is contained in:
2026-08-22 22:35:15 -07:00
parent 5f75b305a7
commit b4dc975682
+107 -9
View File
@@ -77,6 +77,26 @@ UBUNTU_BASE=""
DEFAULT_MINT="" DEFAULT_MINT=""
ALLOWED_TARGETS=() 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 # Key handling
MINT_KEYID="A6616109451BBBF2" MINT_KEYID="A6616109451BBBF2"
SYSTEM_KEYRING="/usr/share/keyrings/linuxmint-repo.gpg" SYSTEM_KEYRING="/usr/share/keyrings/linuxmint-repo.gpg"
@@ -133,7 +153,7 @@ ubuntu-to-mint-convert-v3.sh (v${SCRIPT_VERSION})
Usage (command-first): Usage (command-first):
sudo bash $0 doctor [options] sudo bash $0 doctor [options]
sudo bash $0 plan [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 sudo bash $0 rollback /root/ubuntu-to-mint-backup-YYYYMMDD-HHMMSS
Usage (options-first ALSO supported): Usage (options-first ALSO supported):
@@ -149,6 +169,9 @@ Options:
--keep-ppas Do not disable third-party sources (not recommended) --keep-ppas Do not disable third-party sources (not recommended)
--preserve-snap Keep snapd (default: enabled) --preserve-snap Keep snapd (default: enabled)
--with-recommends Allow recommended packages (default: off) --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 --yes Non-interactive / auto-confirm
--i-accept-the-risk Required for convert --i-accept-the-risk Required for convert
EOF EOF
@@ -769,6 +792,83 @@ post_install_sanity() {
# ========================= # =========================
# Install stack with retry (fixes "first run partial, second run completes") # 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 <name> [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() { install_mint_stack_with_retry() {
local meta="$1" local meta="$1"
shift || true shift || true
@@ -776,14 +876,8 @@ install_mint_stack_with_retry() {
# Ensure DM choice is preseeded BEFORE meta install (lightdm often pulled as dependency) # Ensure DM choice is preseeded BEFORE meta install (lightdm often pulled as dependency)
preseed_lightdm_default_display_manager preseed_lightdm_default_display_manager
local -a pkgs=( local -a pkgs=()
"$meta" mapfile -t pkgs < <(mint_stack_packages "$meta")
mint-meta-core
mintsystem
mintupdate
mintsources
mint-meta-codecs
)
apply_mintupdate_icon_diversion apply_mintupdate_icon_diversion
@@ -1002,6 +1096,7 @@ convert() {
xfce) meta="mint-meta-xfce" ;; xfce) meta="mint-meta-xfce" ;;
esac esac
simulate_and_gate "$meta"
install_mint_stack_with_retry "$meta" install_mint_stack_with_retry "$meta"
if [[ "$PRESERVE_SNAP" == "yes" ]]; then if [[ "$PRESERVE_SNAP" == "yes" ]]; then
@@ -1049,6 +1144,9 @@ parse_args_any_order() {
--mint-mirror) MINT_MIRROR="${2:-}"; shift 2;; --mint-mirror) MINT_MIRROR="${2:-}"; shift 2;;
--keep-ppas) KEEP_PPAS="yes"; shift 1;; --keep-ppas) KEEP_PPAS="yes"; shift 1;;
--preserve-snap) PRESERVE_SNAP="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;; --with-recommends) WITH_RECOMMENDS="yes"; shift 1;;
--yes) ASSUME_YES="yes"; shift 1;; --yes) ASSUME_YES="yes"; shift 1;;
--i-accept-the-risk) I_ACCEPT_RISK="yes"; shift 1;; --i-accept-the-risk) I_ACCEPT_RISK="yes"; shift 1;;