diff --git a/lib/API.txt b/lib/API.txt index 1181a97..6a74de5 100644 --- a/lib/API.txt +++ b/lib/API.txt @@ -4,6 +4,7 @@ _configure_pg_cron_preload _cs_apt_install_optional _cs_clean_target_dir _cs_drop_pm_shim +_cs_mounts_under _cs_repair_dpkg _cs_write_cargo_net _cs_write_cargo_profile diff --git a/lib/alpine.func b/lib/alpine.func index e37a3fd..4ef57b0 100644 --- a/lib/alpine.func +++ b/lib/alpine.func @@ -353,9 +353,19 @@ check_for_gh_release() { } # Empty a target directory before a clean install, leaving mount points and -# CLEAN_INSTALL_KEEP paths alone. Same function as in lib/forge.func, which says -# why it is written this way, except for how it spots a mount: busybox mountpoint -# compares device numbers, so it misses a bind mount from the same filesystem. +# CLEAN_INSTALL_KEEP paths alone, as in lib/forge.func. +_cs_mounts_under() { + local dir="${1:?directory}" info="${3:-/proc/self/mountinfo}" mnt _ + local -n _cs_mounts="${2:?array name}" + _cs_mounts=() + [[ -r "$info" ]] || return 1 + while read -r _ _ _ _ mnt _; do + printf -v mnt '%b' "$mnt" + [[ "$mnt" == "$dir"/* ]] && _cs_mounts+=("$mnt") + done <"$info" + return 0 +} + _cs_clean_target_dir() { local dir="${1:?directory}" list entry stash="" [[ -d "$dir" ]] || return 0 @@ -373,14 +383,28 @@ _cs_clean_target_dir() { done fi - if ! command -v mountpoint >/dev/null 2>&1; then + local -a mounts=() prune=() + if ! _cs_mounts_under "$dir" mounts; then + msg_error "Cannot read /proc/self/mountinfo, so ${dir} is not emptied" + return 1 + fi + if ((${#mounts[@]} == 0)); then find "${dir:?}" -mindepth 1 -maxdepth 1 -print0 | xargs -0 rm -rf -- else + # -path takes a pattern, so a literal \ * ? [ in a mount path is escaped. + local bs='\' + for entry in "${mounts[@]}"; do + entry="${entry//"$bs"/"$bs$bs"}" + entry="${entry//"*"/"$bs*"}" + entry="${entry//"?"/"$bs?"}" + entry="${entry//"["/"${bs}["}" + prune+=(-path "$entry" -prune -o) + done list="$(mktemp)" || return 1 - local -a skip_mounts=(\( -type d \( -exec mountpoint -q {} \; -o -exec grep -qF " {} " /proc/self/mountinfo \; \) -prune \)) - find "${dir:?}" -mindepth 1 "${skip_mounts[@]}" -o ! -type d -print0 >"$list" + # Files first, then directories deepest first; rmdir fails on purpose on a parent that holds a mount. + find "${dir:?}" -mindepth 1 "${prune[@]}" ! -type d -print0 >"$list" xargs -0 rm -f -- <"$list" - find "${dir:?}" -mindepth 1 "${skip_mounts[@]}" -o -type d -print0 >"$list" + find "${dir:?}" -mindepth 1 "${prune[@]}" -type d -print0 >"$list" sort -rz "$list" | xargs -0 rmdir -- 2>/dev/null || true rm -f "$list" fi diff --git a/lib/forge.func b/lib/forge.func index 5cb0995..c771879 100644 --- a/lib/forge.func +++ b/lib/forge.func @@ -504,6 +504,19 @@ _download_source_tarball() { return 250 } +# Fills the array named with the mount points strictly below ; returns 1 when mountinfo is unreadable. +_cs_mounts_under() { + local dir="${1:?directory}" info="${3:-/proc/self/mountinfo}" mnt _ + local -n _cs_mounts="${2:?array name}" + _cs_mounts=() + [[ -r "$info" ]] || return 1 + while read -r _ _ _ _ mnt _; do + printf -v mnt '%b' "$mnt" + [[ "$mnt" == "$dir"/* ]] && _cs_mounts+=("$mnt") + done <"$info" + return 0 +} + # Empty a target directory before a clean install, without touching anything # mounted into it. The list is written out in full before anything is removed: # piping find straight into `xargs rm -rf` let rm delete parents while find was @@ -513,7 +526,7 @@ _download_source_tarball() { _cs_clean_target_dir() { local dir="${1:?directory}" list entry stash="" [[ -d "$dir" ]] || return 0 - dir="${dir%/}" + dir="$(realpath "$dir")" # mountinfo lists resolved paths # Moved aside rather than pruned from find: a kept path can be nested, and # removing its parent would take it along. mv also keeps a symlink a symlink. @@ -529,19 +542,30 @@ _cs_clean_target_dir() { done fi - # mountpoint lives in util-linux, which a minimal Alpine may not have. Without - # it the -exec below would fail once per directory, and there is nothing to - # protect anyway -- so drop the top-level entries and let rm recurse. - if ! command -v mountpoint >/dev/null 2>&1; then + local -a mounts=() prune=() + if ! _cs_mounts_under "$dir" mounts; then + msg_error "Cannot read /proc/self/mountinfo, so ${dir} is not emptied" + return 1 + fi + if ((${#mounts[@]} == 0)); then find "${dir:?}" -mindepth 1 -maxdepth 1 -print0 | xargs -0 rm -rf -- else + # -path takes a pattern, so a literal \ * ? [ in a mount path is escaped. + local bs='\' + for entry in "${mounts[@]}"; do + entry="${entry//"$bs"/"$bs$bs"}" + entry="${entry//"*"/"$bs*"}" + entry="${entry//"?"/"$bs?"}" + entry="${entry//"["/"${bs}["}" + prune+=(-path "$entry" -prune -o) + done list="$(mktemp)" || return 1 # Not rm -r on the list: it would hold the parents of a nested mount, and # rm -r on one would empty the mount. Files first, then directories deepest # first. rmdir fails on purpose on a parent that still holds a mount. - find "${dir:?}" -mindepth 1 \( -type d -exec mountpoint -q {} \; -prune \) -o ! -type d -print0 >"$list" + find "${dir:?}" -mindepth 1 "${prune[@]}" ! -type d -print0 >"$list" xargs -0 rm -f -- <"$list" - find "${dir:?}" -mindepth 1 \( -type d -exec mountpoint -q {} \; -prune \) -o -type d -print0 >"$list" + find "${dir:?}" -mindepth 1 "${prune[@]}" -type d -print0 >"$list" sort -rz "$list" | xargs -0 rmdir -- 2>/dev/null || true rm -f "$list" fi diff --git a/tests/alpine-clean-install.sh b/tests/alpine-clean-install.sh index ad02e44..2fce3d2 100644 --- a/tests/alpine-clean-install.sh +++ b/tests/alpine-clean-install.sh @@ -36,13 +36,11 @@ download_with_progress() { cp "$TEST_DIR/$archive" "$2"; } export HOME="$TEST_DIR/home" mkdir -p "$HOME" "$TEST_DIR/bin" -# Any directory called mnt counts as a mount point, so no root is needed. -cat >"$TEST_DIR/bin/mountpoint" <<'EOF' -#!/usr/bin/env bash -[[ "${2##*/}" == "mnt" ]] -EOF -chmod +x "$TEST_DIR/bin/mountpoint" -export PATH="$TEST_DIR/bin:$PATH" +eval "real$(declare -f _cs_mounts_under)" +_cs_mounts_under() { + find "$TEST_DIR" -name 'mnt*' | sed 's/ /\\040/g; s|^|36 25 0:32 / |; s|$| rw - tmpfs tmpfs rw|' >"$TEST_DIR/mountinfo" + real_cs_mounts_under "$1" "$2" "$TEST_DIR/mountinfo" +} mkdir -p "$TEST_DIR/src/app-1.0" "$TEST_DIR/flat" "$TEST_DIR/hollow/app-1.0" echo new >"$TEST_DIR/src/app-1.0/app" @@ -92,19 +90,11 @@ for mode in tarball prebuild; do check "$mode: nested mount survives" "0 ./app ./data/mnt/file " "$(CLEAN_INSTALL=1 deploy "$mode" good.tar.gz)" done -# busybox mountpoint misses a bind mount from the same filesystem. mountinfo has it. -mkdir -p "$TEST_DIR/bin-bind" -printf '#!/usr/bin/env bash\nexit 1\n' >"$TEST_DIR/bin-bind/mountpoint" -cat >"$TEST_DIR/bin-bind/grep" <<'EOF' -#!/usr/bin/env bash -[[ "$2" == *"/data/bind " && "$3" == "/proc/self/mountinfo" ]] -EOF -chmod +x "$TEST_DIR/bin-bind/mountpoint" "$TEST_DIR/bin-bind/grep" setup -mkdir -p "$target/data/bind" -echo mounted >"$target/data/bind/file" -PATH="$TEST_DIR/bin-bind:$PATH" _cs_clean_target_dir "$target" -check "bind mount from the same filesystem survives" "./data/bind/file " "$(listing)" +mkdir -p "$target/data/mnt bind" +echo mounted >"$target/data/mnt bind/file" +_cs_clean_target_dir "$target" +check "bind mount survives" "./data/mnt bind/file " "$(listing)" setup mv "$target" "$TEST_DIR/opt/real" diff --git a/tests/forge-branch-clean-install.sh b/tests/forge-branch-clean-install.sh index c0c5a89..fe4d8c7 100644 --- a/tests/forge-branch-clean-install.sh +++ b/tests/forge-branch-clean-install.sh @@ -2,7 +2,7 @@ set -euo pipefail CORE_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" -eval "$(awk '/^(_cs_clean_target_dir|fetch_and_deploy_gh_branch)\(\) \{/,/^}/' "$CORE_DIR/lib/forge.func")" +eval "$(awk '/^(_cs_mounts_under|_cs_clean_target_dir|fetch_and_deploy_gh_branch)\(\) \{/,/^}/' "$CORE_DIR/lib/forge.func")" TEST_DIR="$(mktemp -d)" trap 'rm -rf -- "$TEST_DIR"' EXIT @@ -34,13 +34,11 @@ export GIT_CONFIG_VALUE_0="https://github.com/" export GIT_AUTHOR_NAME=test GIT_AUTHOR_EMAIL=test@example.com export GIT_COMMITTER_NAME=test GIT_COMMITTER_EMAIL=test@example.com -# Any directory called mnt counts as a mount point, so no root is needed. -cat >"$TEST_DIR/bin/mountpoint" <<'EOF' -#!/usr/bin/env bash -[[ "${2##*/}" == "mnt" ]] -EOF -chmod +x "$TEST_DIR/bin/mountpoint" -export PATH="$TEST_DIR/bin:$PATH" +eval "real$(declare -f _cs_mounts_under)" +_cs_mounts_under() { + find "$TEST_DIR" -name 'mnt*' | sed 's/ /\\040/g; s|^|36 25 0:32 / |; s|$| rw - tmpfs tmpfs rw|' >"$TEST_DIR/mountinfo" + real_cs_mounts_under "$1" "$2" "$TEST_DIR/mountinfo" +} git init -q -b main "$TEST_DIR/remote/owner/app" commit() { diff --git a/tests/forge-clean-install.sh b/tests/forge-clean-install.sh index 8401974..7e4e4fa 100644 --- a/tests/forge-clean-install.sh +++ b/tests/forge-clean-install.sh @@ -2,7 +2,7 @@ set -euo pipefail CORE_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" -eval "$(awk '/^(_cs_clean_target_dir|_deploy_source_tarball|_deploy_unpacked_archive|_forge_workdir|_forge_place|_forge_disk_full)\(\) \{/,/^}/' "$CORE_DIR/lib/forge.func")" +eval "$(awk '/^(_cs_mounts_under|_cs_clean_target_dir|_deploy_source_tarball|_deploy_unpacked_archive|_forge_workdir|_forge_place|_forge_disk_full)\(\) \{/,/^}/' "$CORE_DIR/lib/forge.func")" TEST_DIR="$(mktemp -d)" trap 'rm -rf -- "$TEST_DIR"' EXIT @@ -116,6 +116,42 @@ rc=0 FAKE_AVAIL=900000 _forge_disk_full "$TEST_DIR/dst" || rc=$? check "a failure with space left is not blamed on the disk" 0 "$rc" +cat >"$TEST_DIR/mountinfo" <<'EOF' +22 1 0:20 / / rw - ext4 /dev/root rw +30 22 0:25 / /opt/app rw - zfs tank/app rw +31 30 0:26 / /opt/app/media\040library rw - nfs srv:/media rw +32 30 0:27 /cfg.yml /opt/app/config.yml rw - ext4 /dev/sdb rw +33 22 0:28 / /opt/app2 rw - tmpfs tmpfs rw +EOF +mounts_of() { + local -a found=() + _cs_mounts_under "$1" found "$TEST_DIR/mountinfo" + printf '%s|' "${found[@]}" +} +check "mountinfo: mounts below the target, decoded" "/opt/app/media library|/opt/app/config.yml|" "$(mounts_of /opt/app)" +rc=0 +_cs_mounts_under /opt/app found "$TEST_DIR/no-mountinfo" || rc=$? +check "mountinfo: unreadable is an error" 1 "$rc" + +eval "real$(declare -f _cs_mounts_under)" +_cs_mounts_under() { + find "$TEST_DIR" -name 'mnt*' | sed 's/ /\\040/g; s|^|36 25 0:32 / |; s|$| rw - tmpfs tmpfs rw|' >"$TEST_DIR/mountinfo" + real_cs_mounts_under "$1" "$2" "$TEST_DIR/mountinfo" +} +mkdir -p "$TEST_DIR/bin-count" +printf '#!/usr/bin/env bash\necho x >>"%s"\nexit 1\n' "$TEST_DIR/mountpoint.calls" >"$TEST_DIR/bin-count/mountpoint" +chmod +x "$TEST_DIR/bin-count/mountpoint" +rm -rf "$TEST_DIR/target" +for i in $(seq 40); do mkdir -p "$TEST_DIR/target/node_modules/p$i/lib" && echo x >"$TEST_DIR/target/node_modules/p$i/lib/index.js"; done +mkdir -p "$TEST_DIR/target/data/mnt media" "$TEST_DIR/target/mnt[x]" "$TEST_DIR/target/a*b" +echo mounted >"$TEST_DIR/target/data/mnt media/file" +echo mounted >"$TEST_DIR/target/mnt[x]/file" +echo mounted >"$TEST_DIR/target/mnt.conf" +echo gone >"$TEST_DIR/target/a*b/file" +PATH="$TEST_DIR/bin-count:$PATH" _cs_clean_target_dir "$TEST_DIR/target" +check "mounts: a mounted directory and a mounted file survive, the rest goes" "./data/mnt media/file ./mnt.conf ./mnt[x]/file " "$(listing)" +check "mounts: no process per directory" 0 "$(cat "$TEST_DIR/mountpoint.calls" 2>/dev/null | wc -l | tr -d ' ')" + # The tarball mode used to return 0 whatever cp did. setup mkdir -p "$TEST_DIR/pkg/app-1.0" && echo v1 >"$TEST_DIR/pkg/app-1.0/app"