#!/usr/bin/env bash # SPDX-License-Identifier: GPL-2.0-or-later # Copyright (C) 2026 Bryan Joshua Pedini # apt.sh - APT e2e fixture for skeptical-updater (Debian/Ubuntu containers). # Copy of discovery/apt/fixture.sh; the only changes are the SU_FIXTURE_STRICT_DEP knob # (su-dummy-a 2.0 depends on plain su-dummy-b by default, like the pacman and rpm # fixtures, so excluding su-dummy-b does not keep su-dummy-a back), SU_FIXTURE_ORPHAN and # SU_FIXTURE_B_AUTO. # # Builds a local *flat* APT repository at /srv/su-repo containing dummy packages # and installs the old (1.0) versions, so that after `apt-get update` the package # manager reports deterministic upgrades: # # su-dummy-a 1.0 -> 2.0 (Depends: su-dummy-b, ships a conffile; # SU_FIXTURE_STRICT_DEP=1: 2.0 Depends: su-dummy-b (>= 2.0)) # su-dummy-b 1.0 -> 2.0 (dependency of su-dummy-a) # su-dummy-c 1.0 -> 2.0 (standalone) # # Optional extras (SU_FIXTURE_EXTRA=1) for "kept back" / removal / virtual-package # scenarios: # su-dummy-d 1.0 -> 2.0 2.0 adds a NEW dependency (su-dummy-newdep, not installed) # => plain `upgrade` keeps it back, `--with-new-pkgs upgrade` # and `dist-upgrade` install it # su-dummy-e 1.0 -> 2.0 2.0 Conflicts: su-dummy-f (installed, no 2.0 exists) # => only `dist-upgrade` can do it (removes su-dummy-f) - except with apt 3.x # solver3 (ubuntu:latest = 26.04) which refuses to remove the manually # installed su-dummy-f and keeps su-dummy-e back even for dist-upgrade # su-dummy-f 1.0 installed, never upgradable # su-dummy-w 1.0 -> 2.0 Depends: su-virtual | su-dummy-alt (virtual + alternative) # su-dummy-v 1.0 Provides: su-virtual # (+ su-dummy-newdep 1.0 and su-dummy-alt 1.0: only in the repo, never pre-installed) # # Environment knobs (all optional): # SU_REPO_DIR=/srv/su-repo where the repo is created (wiped first; must match /srv/su-*, /tmp/su-*, /var/tmp/su-*) # SU_OLD_DIR=/srv/su-old where the 1.0 .debs are kept (NOT in the repo) # SU_FIXTURE_INDEXER=shell shell | scanpackages | ftparchive # shell = no extra tools needed (dpkg-deb -f + sha256sum) # scanpackages = dpkg-scanpackages (installs dpkg-dev if missing) # ftparchive = apt-ftparchive (installs apt-utils if missing) # SU_FIXTURE_OFFLINE=1 disable the distro's real sources (hermetic: no network at all) # SU_FIXTURE_EXTRA=1 add the extra scenario packages listed above # SU_FIXTURE_BAD=1 add su-dummy-bad: 1.0 installed, 2.0 has a postinst that fails # (apt-get upgrade then exits 100) - for error-path tests # SU_FIXTURE_STRICT_DEP=1 su-dummy-a 2.0 depends on "su-dummy-b (>= 2.0)" (as in discovery/apt): # holding su-dummy-b then also keeps su-dummy-a back # SU_FIXTURE_ORPHAN=1 also install su-dummy-orphan 1.0 marked automatically installed with # nothing depending on it (a pre-existing autoremove candidate) # SU_FIXTURE_B_AUTO=1 mark su-dummy-b automatically installed (a dependency of su-dummy-a, # like the pacman fixture's --asdeps) # # Must run as root inside a throwaway container, e.g.: # docker run --rm -i -v "$PWD/discovery/apt:/d:ro" debian:12 bash /d/fixture.sh set -euo pipefail export LC_ALL=C DEBIAN_FRONTEND=noninteractive REPO_DIR=${SU_REPO_DIR:-/srv/su-repo} OLD_DIR=${SU_OLD_DIR:-/srv/su-old} INDEXER=${SU_FIXTURE_INDEXER:-shell} OFFLINE=${SU_FIXTURE_OFFLINE:-0} EXTRA=${SU_FIXTURE_EXTRA:-0} BAD=${SU_FIXTURE_BAD:-0} STRICT_DEP=${SU_FIXTURE_STRICT_DEP:-0} ORPHAN=${SU_FIXTURE_ORPHAN:-0} B_AUTO=${SU_FIXTURE_B_AUTO:-0} SOURCES_FILE=/etc/apt/sources.list.d/su-repo.list T0=$(date +%s) log() { printf '[fixture] %s\n' "$*"; } die() { printf '[fixture] ERROR: %s\n' "$*" >&2; exit 1; } [ "$(id -u)" -eq 0 ] || die "must run as root" command -v apt-get >/dev/null || die "apt-get not found (not a Debian/Ubuntu image?)" command -v dpkg-deb >/dev/null || die "dpkg-deb not found" case $INDEXER in shell|scanpackages|ftparchive) ;; *) die "bad SU_FIXTURE_INDEXER=$INDEXER" ;; esac # the directories are wiped with rm -rf: only accept obviously fixture-owned locations for d in "$REPO_DIR" "$OLD_DIR"; do case $d in /srv/su-*|/tmp/su-*|/var/tmp/su-*) ;; *) die "refusing to wipe '$d' (must match /srv/su-*, /tmp/su-* or /var/tmp/su-*)" ;; esac done # ---------------------------------------------------------------- build debs # mkdeb NAME VERSION OUTDIR [extra control line]... # If $MKDEB_POSTINST is set it becomes the body of the package's postinst script. mkdeb() { local name=$1 ver=$2 out=$3 root line shift 3 root=$(mktemp -d) mkdir -p "$root/DEBIAN" "$root/usr/share/su-dummy" "$root/etc/su-dummy" { printf 'Package: %s\nVersion: %s\nSection: misc\nPriority: optional\n' "$name" "$ver" printf 'Architecture: all\nMaintainer: Skeptical Updater Fixture \n' for line in "$@"; do printf '%s\n' "$line"; done printf 'Description: dummy package %s for skeptical-updater tests\n' "$name" printf ' This package only exists to exercise the package manager.\n' printf ' .\n' printf ' It ships one data file and one conffile (version %s).\n' "$ver" } > "$root/DEBIAN/control" echo "$ver" > "$root/usr/share/su-dummy/$name" echo "version=$ver" > "$root/etc/su-dummy/$name.conf" echo "/etc/su-dummy/$name.conf" > "$root/DEBIAN/conffiles" if [ -n "${MKDEB_POSTINST:-}" ]; then printf '#!/bin/sh\n%s\n' "$MKDEB_POSTINST" > "$root/DEBIAN/postinst" chmod 755 "$root/DEBIAN/postinst" fi mkdir -p "$out" # -Zgzip: readable by every dpkg in the supported images; --root-owner-group: files owned by root dpkg-deb --root-owner-group -Zgzip --build "$root" "$out/${name}_${ver}_all.deb" >/dev/null rm -rf "$root" } rm -rf "$REPO_DIR" "$OLD_DIR" mkdir -p "$REPO_DIR" "$OLD_DIR" # old versions (installed straight away with dpkg, NOT published in the repo) mkdeb su-dummy-a 1.0 "$OLD_DIR" 'Depends: su-dummy-b' mkdeb su-dummy-b 1.0 "$OLD_DIR" mkdeb su-dummy-c 1.0 "$OLD_DIR" # new versions (published in the repo) if [ "$STRICT_DEP" = 1 ]; then mkdeb su-dummy-a 2.0 "$REPO_DIR" 'Depends: su-dummy-b (>= 2.0)' else mkdeb su-dummy-a 2.0 "$REPO_DIR" 'Depends: su-dummy-b' fi mkdeb su-dummy-b 2.0 "$REPO_DIR" mkdeb su-dummy-c 2.0 "$REPO_DIR" if [ "$ORPHAN" = 1 ]; then mkdeb su-dummy-orphan 1.0 "$OLD_DIR" # installed, never upgradable fi if [ "$EXTRA" = 1 ]; then mkdeb su-dummy-d 1.0 "$OLD_DIR" mkdeb su-dummy-d 2.0 "$REPO_DIR" 'Depends: su-dummy-newdep' mkdeb su-dummy-newdep 1.0 "$REPO_DIR" # only in the repo, never pre-installed mkdeb su-dummy-e 1.0 "$OLD_DIR" mkdeb su-dummy-e 2.0 "$REPO_DIR" 'Conflicts: su-dummy-f' mkdeb su-dummy-f 1.0 "$OLD_DIR" mkdeb su-dummy-v 1.0 "$OLD_DIR" 'Provides: su-virtual' mkdeb su-dummy-alt 1.0 "$REPO_DIR" # alternative target, not installed mkdeb su-dummy-w 1.0 "$OLD_DIR" 'Depends: su-virtual | su-dummy-alt' mkdeb su-dummy-w 2.0 "$REPO_DIR" 'Depends: su-virtual | su-dummy-alt' fi if [ "$BAD" = 1 ]; then mkdeb su-dummy-bad 1.0 "$OLD_DIR" # shellcheck disable=SC2016 # the postinst body is expanded by the package's /bin/sh, not here MKDEB_POSTINST='[ "$1" = configure ] && { echo "su-dummy-bad: postinst failing on purpose" >&2; exit 1; }; exit 0' \ mkdeb su-dummy-bad 2.0 "$REPO_DIR" fi # ---------------------------------------------------------------- real sources if [ "$OFFLINE" = 1 ]; then mkdir -p /srv/su-disabled-sources for f in /etc/apt/sources.list /etc/apt/sources.list.d/*.sources /etc/apt/sources.list.d/*.list; do [ -e "$f" ] || continue case $f in "$SOURCES_FILE") continue ;; esac mv "$f" "/srv/su-disabled-sources/$(basename "$f")" done log "OFFLINE: real sources moved to /srv/su-disabled-sources" fi # ---------------------------------------------------------------- indexing install_tool() { # install_tool PACKAGE : needs the real mirrors [ "$OFFLINE" = 1 ] && die "SU_FIXTURE_INDEXER=$INDEXER needs network to install $1; use shell with OFFLINE" log "installing $1 (needs network)" apt-get update -qq >/dev/null apt-get install -y -qq --no-install-recommends "$1" >/dev/null } index_shell() { local f : > Packages for f in ./*.deb; do f=${f#./} dpkg-deb -f "$f" >> Packages printf 'Filename: ./%s\nSize: %s\nMD5sum: %s\nSHA256: %s\n\n' \ "$f" "$(stat -c %s "$f")" "$(md5sum < "$f" | cut -d' ' -f1)" "$(sha256sum < "$f" | cut -d' ' -f1)" >> Packages done { printf 'Origin: su-fixture\nLabel: su-fixture\nSuite: su-repo\nCodename: su-repo\n' printf 'Date: %s\n' "$(date -u '+%a, %d %b %Y %H:%M:%S UTC')" printf 'SHA256:\n %s %s Packages\n' "$(sha256sum < Packages | cut -d' ' -f1)" "$(stat -c %s Packages)" } > Release } index_scanpackages() { command -v dpkg-scanpackages >/dev/null || install_tool dpkg-dev dpkg-scanpackages --multiversion . /dev/null > Packages 2>/dev/null # dpkg-scanpackages does not write a Release file; make a minimal one { printf 'Origin: su-fixture\nLabel: su-fixture\nSuite: su-repo\nCodename: su-repo\n' printf 'Date: %s\n' "$(date -u '+%a, %d %b %Y %H:%M:%S UTC')" printf 'SHA256:\n %s %s Packages\n' "$(sha256sum < Packages | cut -d' ' -f1)" "$(stat -c %s Packages)" } > Release } index_ftparchive() { command -v apt-ftparchive >/dev/null || install_tool apt-utils apt-ftparchive packages . > Packages apt-ftparchive \ -o APT::FTPArchive::Release::Origin=su-fixture \ -o APT::FTPArchive::Release::Label=su-fixture \ -o APT::FTPArchive::Release::Suite=su-repo \ -o APT::FTPArchive::Release::Codename=su-repo \ release . > Release } ( cd "$REPO_DIR" && "index_$INDEXER" ) log "repo $REPO_DIR indexed with '$INDEXER': $(grep -c '^Package:' "$REPO_DIR/Packages") packages" echo "deb [trusted=yes] file:$REPO_DIR ./" > "$SOURCES_FILE" # ---------------------------------------------------------------- install old versions # --force-confnew + /dev/null /dev/null fi if [ "$B_AUTO" = 1 ]; then apt-mark auto su-dummy-b >/dev/null fi log "installed (old): $(dpkg-query -W -f '${db:Status-Abbrev} ${Package}=${Version}\n' 'su-dummy-*' 2>/dev/null | awk '$1 == "ii" {printf "%s ", $2}')" # ---------------------------------------------------------------- refresh + verify # drop stale lists of our own repo first: apt compares mtimes of file: indexes with 1s resolution, # so a repo regenerated within the same second would otherwise be seen as "unchanged" rm -f /var/lib/apt/lists/"$(printf '%s' "$REPO_DIR" | tr / _)"_* apt-get update -qq >/dev/null fail=0 upg=$(apt list --upgradable 2>/dev/null | awk -F/ '/^su-dummy-/{print $1}' | sort | tr '\n' ' ') for p in su-dummy-a su-dummy-b su-dummy-c; do case " $upg" in *" $p "*) ;; *) log "MISSING from upgradable: $p"; fail=1 ;; esac done sim=$(apt-get -s --with-new-pkgs upgrade 2>/dev/null | awk '/^Inst su-dummy-(a|b|c) /{n++} END{print n+0}') [ "$sim" -ge 3 ] || { log "simulated upgrade only covers $sim of 3 core packages"; fail=1; } [ "$fail" -eq 0 ] || die "fixture verification failed" log "upgradable dummy packages: $upg" if [ "$ORPHAN" = 1 ]; then apt-get -s autoremove 2>/dev/null | grep -q '^Remv su-dummy-orphan ' || die "su-dummy-orphan is not an autoremove candidate" log "pre-existing orphan (autoremove candidate): su-dummy-orphan" fi real=$(apt list --upgradable 2>/dev/null | awk -F/ 'NR>1 && $1 !~ /^su-dummy-/{print $1}' | tr '\n' ' ') if [ -n "$real" ]; then log "NOTE: real-world upgradable packages present in this base image: $real" else log "NOTE: no real-world upgradable packages in this base image right now" fi # shellcheck source=/dev/null log "OK in $(( $(date +%s) - T0 ))s (image: $(. /etc/os-release; echo "$PRETTY_NAME"))"