Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions lib/API.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 31 additions & 7 deletions lib/alpine.func
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
38 changes: 31 additions & 7 deletions lib/forge.func
Original file line number Diff line number Diff line change
Expand Up @@ -504,6 +504,19 @@ _download_source_tarball() {
return 250
}

# Fills the array named <array> with the mount points strictly below <dir>; 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
Expand All @@ -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.
Expand All @@ -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
Expand Down
28 changes: 9 additions & 19 deletions tests/alpine-clean-install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
14 changes: 6 additions & 8 deletions tests/forge-branch-clean-install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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() {
Expand Down
38 changes: 37 additions & 1 deletion tests/forge-clean-install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down
Loading