SPS · log · files · refs · tags · releases · people · languages · GitHub · commit
01ad20e
Install onto usr-merge live images and keep shared directories.
Parents
Stat
DESIGN.md | 27 +++++-- MANIFESTO.md | 13 ++- bin/mkpkg | 14 +--- bin/pkcheck | 3 +- bin/pkdel | 7 +- bin/pkin | 165 +++++++++++++++++++++++++++---------- bin/sget | 7 +- bin/src | 55 ++++++++++++- lib/common.sh | 51 ++++++++++++ man/mkpkg.1 | 3 +- man/pkin.1 | 31 +++++++ man/sget.1 | 8 +- man/src.1 | 12 ++- tests/test_common.sh | 24 ++++++ tests/test_dependencies.sh | 6 +- tests/test_git_repository.sh | 2 +- tests/test_output.sh | 4 +- tests/test_packages.sh | 75 +++++++++++++++++ tests/test_repository.sh | 12 ++- tests/test_security.sh | 189 +++++++++++++++++++++++++++++++++++++++++++ 20 files changed, 625 insertions(+), 83 deletions(-)
Diff
diff --git a/DESIGN.md b/DESIGN.md
index 82623f2..21da531 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -256,12 +256,27 @@ index and the target filesystem before writing a payload entry.
An existing unowned file is a conflict unless it is already identical to the
payload: the same symlink target, or a regular file with the same SHA-256
digest. Identical paths are adopted so a live image can install `filesystem`
-over usr-merge links it already created. A different target or digest still
-fails.
-
-Directories are shared and are not stored in the exclusive owner index.
-Existing shared directories are not re-owned or chmodded just because another
-package contains the same directory entry.
+over usr-merge links it already created.
+
+A different digest still fails on an ordinary system. On a live image
+(`$SPS_ROOT/etc/sps/live`), `pkin` replaces an unowned regular file or
+symlink with the packaged leaf so host copies such as `/usr/bin/openssl`
+and busybox applets such as `/usr/bin/clear` can be claimed by their
+packages. Paths under `SPS_PRESERVE` are not overwritten this way. On a live
+image, an unowned preserved file (typically host-seeded `/etc/ssl`) is kept
+as-is, the package takes ownership, and a differing packaged default is
+written beside it as `.sps-new`. The live shell and init (`bin/sh`,
+`sbin/init`, `busybox`), and the usr-merge names `bin`, `sbin`, `lib`,
+and `lib64` are not replaced this way. A different usr-merge symlink
+target remains a conflict.
+
+Shared directories include real directories and conservative in-root
+directory symlinks: usr-merge `bin`, `sbin`, `lib`, and `lib64` when they
+point at the matching `usr/*` directory, and filesystem compat links
+`var/run` -> `../run` and `var/lock` -> `../run/lock`. A package may install
+files below `/lib` or `/var/run` without replacing those links. Existing
+shared directories are not re-owned or chmodded just because another package
+contains the same directory entry.
When installation runs as uid 0, newly installed regular payload files and new
package directories are normalized to uid/gid 0 while retaining their packaged
diff --git a/MANIFESTO.md b/MANIFESTO.md
index 61b9132..310bf4e 100644
--- a/MANIFESTO.md
+++ b/MANIFESTO.md
@@ -173,11 +173,20 @@ Managed files have one package owner. Before installation, `pkin` checks both
the owner index and the target filesystem.
If an unowned file already exists where a package wants to install one, that is
-a conflict. SPS does not silently take it over.
+a conflict. SPS does not silently take it over on an ordinary system.
+
+Live images are the exception. Files copied onto the disc for bootstrap, and
+busybox applets that a later package owns, are replaced when `$SPS_ROOT/etc/sps/live`
+is present. The packaged file becomes the owned copy. Configuration under
+`etc` is kept as-is, the package takes ownership, and a differing packaged
+default is written beside it as `.sps-new`. The live shell and init are not
+replaced.
Directories are shared. They are kept out of the exclusive owner index, and an
existing shared directory is not re-owned or chmodded just because a new
-package lists it.
+package lists it. Usr-merge links (`/bin`, `/sbin`, `/lib`, `/lib64` pointing
+at the matching `usr/*` directory) count as shared directories, so a package
+may install `/lib/udev` without replacing `/lib`.
When `pkin` runs as root, newly installed payload files and new package
directories are normalized to uid/gid 0 while keeping their packaged modes.
diff --git a/bin/mkpkg b/bin/mkpkg
index 031106f..a5ee01a 100755
--- a/bin/mkpkg
+++ b/bin/mkpkg
@@ -519,20 +519,10 @@ download_http()
{
download_url=$1
download_dest=$2
- # Progress belongs to the downloader. Use a TTY progress display when
- # stderr is a terminal; otherwise stay quiet enough for log files.
if command -v curl >/dev/null 2>&1; then
- if [ -t 2 ]; then
- curl -fL -# -o "$download_dest" "$download_url"
- else
- curl -fL -sS -o "$download_dest" "$download_url"
- fi
+ curl -fL --progress-bar -o "$download_dest" "$download_url"
elif command -v wget >/dev/null 2>&1; then
- if [ -t 2 ]; then
- wget -O "$download_dest" "$download_url"
- else
- wget -q -O "$download_dest" "$download_url"
- fi
+ wget -O "$download_dest" "$download_url"
else
die_status 9 "cannot download $download_url: curl or wget is required"
fi
diff --git a/bin/pkcheck b/bin/pkcheck
index 429603b..e383437 100755
--- a/bin/pkcheck
+++ b/bin/pkcheck
@@ -225,7 +225,8 @@ sps_check_files()
sps_target_path "$sps_path"
case $sps_entry in
*/)
- if [ ! -d "$sps_target" ] || [ -L "$sps_target" ]; then
+ if [ ! -d "$sps_target" ] ||
+ { [ -L "$sps_target" ] && ! sps_is_shared_dir_link "$sps_path"; }; then
printf 'missing /%s %s\n' "$sps_path" "$sps_name"
sps_fs_bad=1
fi
diff --git a/bin/pkdel b/bin/pkdel
index f8b5360..3a0850d 100755
--- a/bin/pkdel
+++ b/bin/pkdel
@@ -59,8 +59,11 @@ sps_safe_parents()
sps_prefix=$sps_component
fi
sps_target_path "$sps_prefix"
- if [ -L "$sps_target" ] ||
- { sps_exists "$sps_target" && [ ! -d "$sps_target" ]; }; then
+ if [ -L "$sps_target" ] && ! sps_is_shared_dir_link "$sps_prefix"; then
+ printf '%s: refusing unsafe target path /%s\n' "$sps_prog" "$sps_prefix" >&2
+ return 1
+ fi
+ if sps_exists "$sps_target" && [ ! -d "$sps_target" ]; then
printf '%s: refusing unsafe target path /%s\n' "$sps_prog" "$sps_prefix" >&2
return 1
fi
diff --git a/bin/pkin b/bin/pkin
index 00e2089..824914d 100755
--- a/bin/pkin
+++ b/bin/pkin
@@ -76,10 +76,11 @@ sps_safe_parents()
fi
sps_target_path "$sps_prefix"
if [ -L "$sps_target" ]; then
- printf '%s: unsafe symlink in target path: /%s\n' "$sps_prog" "$sps_prefix" >&2
- return 1
- fi
- if sps_exists "$sps_target" && [ ! -d "$sps_target" ]; then
+ if ! sps_is_shared_dir_link "$sps_prefix"; then
+ printf '%s: unsafe symlink in target path: /%s\n' "$sps_prog" "$sps_prefix" >&2
+ return 1
+ fi
+ elif sps_exists "$sps_target" && [ ! -d "$sps_target" ]; then
printf '%s: non-directory in target path: /%s\n' "$sps_prog" "$sps_prefix" >&2
return 1
fi
@@ -380,6 +381,7 @@ if sps_exists "$SPS_DB/installed"; then
else
mkdir "$SPS_DB/installed" || sps_fail 8 "cannot create installed database"
fi
+sps_installed=$SPS_DB/installed
for sps_db_file in owners world history; do
if sps_exists "$SPS_DB/$sps_db_file"; then
[ -f "$SPS_DB/$sps_db_file" ] && [ ! -L "$SPS_DB/$sps_db_file" ] ||
@@ -526,12 +528,111 @@ sps_payload_matches_existing()
return 1
}
+# Live images seed host copies and busybox applets that later packages own.
+# Replace an unowned leaf there; keep ordinary systems strict.
+sps_may_replace_unowned()
+{
+ sps_repl_path=$1
+ sps_repl_target=$2
+ sps_repl_source=$sps_stage/$sps_repl_path
+ sps_is_live_bootstrap || return 1
+ sps_is_preserved_path "$sps_repl_path" && return 1
+ sps_is_live_critical_path "$sps_repl_path" && return 1
+ # Never claim the four usr-merge names this way. Identical links are
+ # adopted earlier; a different target remains a conflict.
+ case ${sps_repl_path#/} in
+ bin|sbin|lib|lib64) return 1 ;;
+ esac
+ if [ -d "$sps_repl_target" ] && [ ! -L "$sps_repl_target" ]; then
+ return 1
+ fi
+ if [ -d "$sps_repl_source" ] && [ ! -L "$sps_repl_source" ]; then
+ return 1
+ fi
+ { [ -f "$sps_repl_target" ] || [ -L "$sps_repl_target" ]; } || return 1
+ [ -f "$sps_repl_source" ] || [ -L "$sps_repl_source" ]
+}
+
+# Live discs seed /etc (openssl.cnf, CA certs). Those paths are preserved,
+# so they cannot be replaced; they also must not block the install.
+# Keep the running copy, take ownership, and write .sps-new when the
+# packaged default differs. Owned preserved files still use the upgrade path.
+sps_keep_live_unowned_preserved()
+{
+ sps_keep_path=$1
+ sps_keep_target=$2
+ sps_is_live_bootstrap || return 1
+ sps_is_preserved_path "$sps_keep_path" || return 1
+ if [ -d "$sps_keep_target" ] && [ ! -L "$sps_keep_target" ]; then
+ return 1
+ fi
+ sps_exists "$sps_keep_target" || return 1
+ if [ -d "$sps_old_record" ] && [ -f "$sps_old_record/hashes" ] &&
+ awk -F "$sps_tab" -v path="$sps_keep_path" \
+ '$3 == path { found=1 } END { exit !found }' \
+ "$sps_old_record/hashes"
+ then
+ return 1
+ fi
+ return 0
+}
+
+# Write a packaged default beside a kept local file.
+sps_install_sps_new()
+{
+ sps_new_target=$sps_target.sps-new
+ if [ -e "$sps_new_target" ] || [ -L "$sps_new_target" ]; then
+ if [ -f "$sps_new_target" ] && [ ! -L "$sps_new_target" ] &&
+ [ "$(sps_sha256 "$sps_new_target" 2>/dev/null || :)" = "$sps_new_digest" ]; then
+ sps_warn "/$sps_path is locally modified; matching .sps-new already exists"
+ return 0
+ fi
+ sps_new_target=$sps_target.sps-new.$sps_version-$sps_release
+ fi
+ sps_new_parent=${sps_new_target%/*}
+ sps_file_tmp=$(mktemp "$sps_new_parent/.sps-file.XXXXXX") ||
+ sps_fail 8 "cannot stage preserved update for /$sps_path"
+ if ! cp -p "$sps_source" "$sps_file_tmp"; then
+ rm -f "$sps_file_tmp"
+ sps_fail 8 "cannot stage preserved update for /$sps_path"
+ fi
+ if sps_is_root; then
+ sps_file_mode=
+ if [ -u "$sps_source" ] || [ -g "$sps_source" ]; then
+ sps_file_mode=$(sps_mode_of "$sps_source") ||
+ sps_fail 4 "cannot determine file mode for /$sps_path"
+ fi
+ sps_normalize_owner "$sps_file_tmp" ||
+ sps_fail 8 "cannot normalize owner of /$sps_path"
+ if [ -n "$sps_file_mode" ]; then
+ chmod "$sps_file_mode" "$sps_file_tmp" ||
+ sps_fail 8 "cannot restore mode on /$sps_path"
+ fi
+ fi
+ mv -f "$sps_file_tmp" "$sps_new_target" || {
+ rm -f "$sps_file_tmp"
+ sps_fail 8 "cannot write preserved update for /$sps_path"
+ }
+ sps_warn "/$sps_path is locally modified; kept it and wrote ${sps_new_target#${SPS_ROOT%/}}"
+}
+
while IFS= read -r sps_path || [ -n "$sps_path" ]; do
sps_target_path "$sps_path"
if sps_exists "$sps_target"; then
if sps_payload_matches_existing "$sps_path" "$sps_target"; then
continue
fi
+ if sps_may_replace_unowned "$sps_path" "$sps_target"; then
+ sps_say "replacing unowned live file /$sps_path"
+ continue
+ fi
+ if sps_keep_live_unowned_preserved "$sps_path" "$sps_target"; then
+ sps_say "keeping unowned live preserved file /$sps_path"
+ continue
+ fi
+ if sps_is_preserved_path "$sps_path"; then
+ sps_fail 7 "/$sps_path exists, is not owned, and is under a preserved prefix"
+ fi
sps_fail 7 "/$sps_path exists and is not owned by an installed package"
fi
done <"$sps_work/unowned"
@@ -543,7 +644,8 @@ while IFS= read -r sps_entry || [ -n "$sps_entry" ]; do
case $sps_entry in
*/)
if sps_exists "$sps_target" &&
- { [ ! -d "$sps_target" ] || [ -L "$sps_target" ]; }; then
+ { [ ! -d "$sps_target" ] || [ -L "$sps_target" ]; } &&
+ ! sps_is_shared_dir_link "$sps_path"; then
sps_fail 7 "/$sps_path exists and is not a directory"
fi
;;
@@ -593,6 +695,19 @@ while IFS= read -r sps_entry || [ -n "$sps_entry" ]; do
[ -n "$sps_parent" ] || sps_parent=/
mkdir -p "$sps_parent" || sps_fail 8 "cannot create parent of /$sps_path"
sps_verbose_say "/$sps_path"
+ if sps_keep_live_unowned_preserved "$sps_path" "$sps_target"; then
+ if [ -f "$sps_source" ] && [ ! -L "$sps_source" ] &&
+ [ -f "$sps_target" ] && [ ! -L "$sps_target" ]; then
+ sps_new_digest=$(sps_sha256 "$sps_source") ||
+ sps_fail 6 "cannot hash new protected file /$sps_path"
+ sps_cur_digest=$(sps_sha256 "$sps_target") ||
+ sps_fail 6 "cannot hash existing protected file /$sps_path"
+ if [ "$sps_new_digest" != "$sps_cur_digest" ]; then
+ sps_install_sps_new
+ fi
+ fi
+ continue
+ fi
if [ -L "$sps_source" ]; then
sps_link_tmp=$(sps_mktemp_dir "$sps_parent" link) ||
sps_fail 8 "cannot stage symlink /$sps_path"
@@ -615,41 +730,7 @@ while IFS= read -r sps_entry || [ -n "$sps_entry" ]; do
sps_warn "/$sps_path is locally modified; packaged default is unchanged, keeping local file"
continue
fi
- sps_new_target=$sps_target.sps-new
- if [ -e "$sps_new_target" ] || [ -L "$sps_new_target" ]; then
- if [ -f "$sps_new_target" ] && [ ! -L "$sps_new_target" ] &&
- [ "$(sps_sha256 "$sps_new_target" 2>/dev/null || :)" = "$sps_new_digest" ]; then
- sps_warn "/$sps_path is locally modified; matching .sps-new already exists"
- continue
- fi
- sps_new_target=$sps_target.sps-new.$sps_version-$sps_release
- fi
- sps_new_parent=${sps_new_target%/*}
- sps_file_tmp=$(mktemp "$sps_new_parent/.sps-file.XXXXXX") ||
- sps_fail 8 "cannot stage preserved update for /$sps_path"
- if ! cp -p "$sps_source" "$sps_file_tmp"; then
- rm -f "$sps_file_tmp"
- sps_fail 8 "cannot stage preserved update for /$sps_path"
- fi
- if sps_is_root; then
- # chown can clear setuid/setgid, so remember the mode only for those files.
- sps_file_mode=
- if [ -u "$sps_source" ] || [ -g "$sps_source" ]; then
- sps_file_mode=$(sps_mode_of "$sps_source") ||
- sps_fail 4 "cannot determine file mode for /$sps_path"
- fi
- sps_normalize_owner "$sps_file_tmp" ||
- sps_fail 8 "cannot normalize owner of /$sps_path"
- if [ -n "$sps_file_mode" ]; then
- chmod "$sps_file_mode" "$sps_file_tmp" ||
- sps_fail 8 "cannot restore mode on /$sps_path"
- fi
- fi
- mv -f "$sps_file_tmp" "$sps_new_target" || {
- rm -f "$sps_file_tmp"
- sps_fail 8 "cannot write preserved update for /$sps_path"
- }
- sps_warn "/$sps_path is locally modified; kept it and wrote ${sps_new_target#${SPS_ROOT%/}}"
+ sps_install_sps_new
continue
fi
@@ -727,7 +808,9 @@ if [ -d "$sps_old_record" ]; then
' "$sps_stage/.SPS/files"; then
sps_safe_parents "$sps_path" || continue
sps_target_path "$sps_path"
- rmdir "$sps_target" 2>/dev/null || :
+ if [ -d "$sps_target" ] && [ ! -L "$sps_target" ]; then
+ rmdir "$sps_target" 2>/dev/null || :
+ fi
fi
;;
esac
diff --git a/bin/sget b/bin/sget
index a45b904..b865166 100755
--- a/bin/sget
+++ b/bin/sget
@@ -51,7 +51,7 @@ EOF_USAGE
sget_note()
{
- [ "${sget_verbose:-0}" -eq 1 ] || return 0
+ [ "${sget_quiet:-0}" -eq 1 ] && return 0
printf '%s: %s\n' "$sps_prog" "$*" >&2
}
@@ -322,6 +322,7 @@ sget_install()
>>"$sget_work/resolution" || exit $?
done
else
+ sget_note "resolving $sget_roots"
if ! awk -v action=plan -v roots="$sget_roots" \
-v include_build=1 -v program=sget \
-v installed="$(sget_installed_names)" \
@@ -389,6 +390,7 @@ sget_install()
done <"$sget_work/resolution"
sget_mark_count=$(awk 'END { print NR + 0 }' "$sget_work/mark-explicit")
+ sget_note "$sget_count package action(s), $sget_mark_count reason change(s)"
if [ "$sget_plan" -eq 1 ]; then
if [ "$sget_upgrade_mode" -eq 1 ]; then
printf 'Upgrade plan\n\n'
@@ -461,6 +463,7 @@ sget_install()
"$sget_version" "$sget_release"
;;
esac
+ sget_note "checking binary cache for $sget_package"
sget_cache_base=$SPS_CACHE/packages/$sget_package-$sget_version-$sget_release-$sget_arch.pkg.tar
sget_artifact=
for sget_candidate in "$sget_cache_base.zst" "$sget_cache_base.xz" \
@@ -496,7 +499,7 @@ sget_install()
esac
fi
if [ -z "$sget_artifact" ]; then
- sget_note "binary cache miss"
+ sget_note "binary cache miss; building $sget_package"
sget_note "building $sget_recipe_dir"
sget_artifact_file=$sget_work/artifact-$sget_package
sget_build_log=$sget_work/build-$sget_package.log
diff --git a/bin/src b/bin/src
index 8d60b24..7c3d4cf 100755
--- a/bin/src
+++ b/bin/src
@@ -21,9 +21,11 @@ sps_index=$sps_index_dir/packages.index
sps_tab=$(printf '\tX')
sps_tab=${sps_tab%X}
+src_quiet=0
+
src_usage()
{
- printf '%s\n' 'usage: src update'
+ printf '%s\n' 'usage: src [-q|--quiet] update'
printf '%s\n' ' src check RECIPE... [--deps]'
printf '%s\n' ' src search PATTERN [--raw]'
printf '%s\n' ' src show PACKAGE [--raw]'
@@ -32,6 +34,12 @@ src_usage()
printf '%s\n' ' src status [--raw]'
}
+src_progress()
+{
+ [ "${src_quiet:-0}" -eq 0 ] || return 0
+ printf '%s: %s\n' "$sps_prog" "$*" >&2
+}
+
src_need_index()
{
[ -r "$sps_index" ] ||
@@ -90,9 +98,12 @@ src_sync_git()
src_clone_work=$(sps_mktemp_dir "$src_git_parent" \
"clone-$src_git_name")
src_clone_checkout=$src_clone_work/repository
+ src_progress "cloning '$src_git_name'"
# Depth 1 is enough for recipes. Later fetches stay full so
# fast-forward and divergence checks keep working.
- if ! git clone --depth 1 --single-branch -- \
+ # --progress keeps clone/fetch from looking hung when stderr
+ # is not a terminal (setup, logs, scripted live installs).
+ if ! git clone --depth 1 --single-branch --progress -- \
"$src_git_source" "$src_clone_checkout"; then
sps_die "$SPS_EX_NETWORK" \
"cannot clone repository '$src_git_name'"
@@ -136,7 +147,8 @@ src_sync_git()
"repository '$src_git_name' has local modifications; refusing to update $src_git_checkout"
fi
- if ! git -C "$src_git_checkout" fetch origin; then
+ src_progress "fetching '$src_git_name'"
+ if ! git -C "$src_git_checkout" fetch --progress origin; then
sps_die "$SPS_EX_NETWORK" \
"cannot fetch repository '$src_git_name'"
fi
@@ -170,10 +182,12 @@ src_sync_git()
sps_die "$SPS_EX_PACKAGE" \
"cannot read upstream for repository '$src_git_name'"
if [ "$src_git_head" = "$src_git_upstream_head" ]; then
+ src_progress "repository '$src_git_name' is up to date"
return 0
fi
if git -C "$src_git_checkout" merge-base --is-ancestor \
"$src_git_head" "$src_git_upstream_head"; then
+ src_progress "fast-forwarding '$src_git_name'"
if ! git -C "$src_git_checkout" merge --ff-only \
"$src_git_upstream" >&2; then
sps_die "$SPS_EX_CONFLICT" \
@@ -193,11 +207,19 @@ src_sync_git()
src_update()
{
+ while [ "$#" -gt 0 ]; do
+ case $1 in
+ -q|--quiet) src_quiet=1; shift ;;
+ -v|--verbose) src_quiet=0; shift ;;
+ *) break ;;
+ esac
+ done
[ "$#" -eq 0 ] || {
src_usage >&2
exit "$SPS_EX_USAGE"
}
+ src_progress "updating package index"
sps_mkdir "$sps_index_dir"
src_work=
src_clone_work=
@@ -233,6 +255,7 @@ src_update()
src_repo_source src_repo_path src_repo_priority src_repo_order
do
[ -n "$src_repo_name" ] || continue
+ src_progress "repository '$src_repo_name' ($src_repo_kind)"
case $src_repo_kind in
git) src_sync_git "$src_repo_name" "$src_repo_source" "$src_repo_path" ;;
dir) ;;
@@ -257,6 +280,7 @@ src_update()
break
}
+ src_progress "scanning recipes in '$src_repo_name'"
if ! find "$src_repo_absolute" \
\( -type d \( -name .git -o -name files -o -name patches -o \
-name hooks \) -prune \) -o \
@@ -281,6 +305,8 @@ src_update()
break
}
src_repo_packages=0
+ src_recipe_total=$(awk 'END { print NR + 0 }' "$src_work/recipes")
+ src_progress "indexing '$src_repo_name' ($src_recipe_total recipes)"
while IFS= read -r src_recipe || [ -n "$src_recipe" ]
do
[ -n "$src_recipe" ] || continue
@@ -318,10 +344,14 @@ src_update()
break
}
src_repo_packages=$((src_repo_packages + 1))
+ src_recipe_label=${src_recipe%/*}
+ src_recipe_label=${src_recipe_label##*/}
+ src_progress "$src_repo_name $src_repo_packages/$src_recipe_total $src_recipe_label"
done < "$src_work/recipes"
[ "$src_failed" -eq 0 ] || break
if [ "$src_repo_packages" -gt 0 ]; then
+ src_progress "parsing recipes in '$src_repo_name'"
if ! awk -v recipe_repo="$src_repo_name" \
-v recipe_priority="$src_repo_priority" \
-v recipe_digest_map="$src_work/digests" \
@@ -341,11 +371,13 @@ src_update()
sps_die "$SPS_EX_PACKAGE" \
"repository update failed; previous index was preserved"
fi
+ src_progress "validating index"
if ! awk -v action=validate -v program=src \
-f "$SPS_LIBDIR/index.awk" "$src_work/packages.index"; then
sps_die "$SPS_EX_PACKAGE" \
"generated repository index is malformed"
fi
+ src_progress "validating dependency graph"
if ! awk -v action=validate -v include_build=1 -v program=src \
-f "$SPS_LIBDIR/version.awk" -f "$SPS_LIBDIR/deps.awk" \
"$src_work/packages.index"; then
@@ -546,6 +578,23 @@ src_check()
printf 'checked %d recipe(s)\n' "$src_check_count"
}
+while [ "$#" -gt 0 ]; do
+ case $1 in
+ -q|--quiet) src_quiet=1; shift ;;
+ -v|--verbose) src_quiet=0; shift ;;
+ -h|--help)
+ src_usage
+ exit 0
+ ;;
+ --) shift; break ;;
+ -*)
+ src_usage >&2
+ exit "$SPS_EX_USAGE"
+ ;;
+ *) break ;;
+ esac
+done
+
case ${1-} in
update)
shift
diff --git a/lib/common.sh b/lib/common.sh
index 8240fed..fb15608 100644
--- a/lib/common.sh
+++ b/lib/common.sh
@@ -370,6 +370,57 @@ sps_is_preserved_path()
return 1
}
+# True when $1 is a conservative in-root directory symlink that packages
+# may share: usr-merge names, plus filesystem compat links var/run and
+# var/lock. Absolute links, unexpected targets, and any other name fail.
+sps_is_shared_dir_link()
+{
+ sps_share_rel=${1#/}
+ sps_share_rel=${sps_share_rel%/}
+ sps_share_path=$(sps_root_path "/$sps_share_rel")
+ [ -L "$sps_share_path" ] || return 1
+ [ -d "$sps_share_path" ] || return 1
+ sps_share_target=$(readlink "$sps_share_path") || return 1
+ sps_share_target=${sps_share_target%/}
+ case $sps_share_rel in
+ bin) [ "$sps_share_target" = usr/bin ] || return 1 ;;
+ sbin) [ "$sps_share_target" = usr/sbin ] || return 1 ;;
+ lib) [ "$sps_share_target" = usr/lib ] || return 1 ;;
+ lib64) [ "$sps_share_target" = usr/lib64 ] || return 1 ;;
+ var/run) [ "$sps_share_target" = ../run ] || return 1 ;;
+ var/lock) [ "$sps_share_target" = ../run/lock ] || return 1 ;;
+ *) return 1 ;;
+ esac
+ return 0
+}
+
+# True when $1 is a top-level usr-merge directory symlink.
+sps_is_usr_merge_dir()
+{
+ case ${1#/} in
+ bin|sbin|lib|lib64) sps_is_shared_dir_link "$1" ;;
+ *) return 1 ;;
+ esac
+}
+
+# Live images write /etc/sps/live. Only the target root is consulted so a
+# host live marker cannot change pkin policy for a chroot or test root.
+sps_is_live_bootstrap()
+{
+ [ -f "$(sps_root_path /etc/sps/live)" ]
+}
+
+# Replacing these on a live image would break the running disc.
+sps_is_live_critical_path()
+{
+ case ${1#/} in
+ bin/sh|usr/bin/sh|sbin/init|usr/sbin/init|\
+ bin/busybox|usr/bin/busybox|sbin/busybox|usr/sbin/busybox)
+ return 0 ;;
+ esac
+ return 1
+}
+
# Cache the uid check. pkin calls this for many payload entries.
sps_is_root()
{
diff --git a/man/mkpkg.1 b/man/mkpkg.1
index 1109973..80f9f36 100644
--- a/man/mkpkg.1
+++ b/man/mkpkg.1
@@ -180,8 +180,7 @@ SPS status messages go to stderr in the form
Upstream configure, compile, and install commands keep their own stdout and
stderr. Remote fetches print
.B mkpkg: fetching
-then use curl or wget progress when stderr is a terminal, and a quieter
-downloader mode when it is not.
+then use curl or wget progress so a download does not look idle.
.PP
On success,
.B mkpkg
diff --git a/man/pkin.1 b/man/pkin.1
index 6560fcf..8af0234 100644
--- a/man/pkin.1
+++ b/man/pkin.1
@@ -44,6 +44,37 @@ are copied when the package did not ship a file for that init.
When run as uid 0, new payload files and package-created directories are
normalized to uid/gid 0 while keeping their packaged modes. Existing shared
directories keep their current ownership and mode.
+Usr-merge directory symlinks
+.BR /bin ,
+.BR /sbin ,
+.BR /lib ,
+and
+.BR /lib64
+that point at the matching
+.I usr/*
+directory are treated as shared directories, so a package may install files
+below
+.B /lib
+without replacing the link.
+.SH CONFLICTS
+An unowned path that already matches the payload (same regular-file digest or
+same symlink target) is adopted.
+A different unowned file is a conflict, except on a live image
+.RI ( $SPS_ROOT/etc/sps/live ),
+where
+.B pkin
+replaces unowned regular files and symlinks so live-seeded copies
+(for example
+.B /usr/bin/openssl
+and busybox applets) can be claimed.
+Paths under
+.B SPS_PRESERVE
+are not overwritten this way. On a live image an unowned preserved file is
+kept, the package takes ownership, and a differing packaged default is written
+beside it as
+.BR .sps-new .
+The live shell and init, and the usr-merge names themselves are not replaced
+this way.
.SH DATABASE
Installed records live below
.BR $SPS_DB/installed .
diff --git a/man/sget.1 b/man/sget.1
index 7faea8a..ddc3706 100644
--- a/man/sget.1
+++ b/man/sget.1
@@ -88,15 +88,17 @@ requested removal set directly requires it.
.BR -q ", " --quiet
Hide routine
.B mkpkg
-status and upstream build output. Package-level
+status, cache decisions, resolver progress, and upstream build output. Package-level
.B sget
and
.B pkin
lines remain. If a build fails, the captured log is written to stderr.
.TP
.BR -v ", " --verbose
-Print resolver decisions such as selected recipes, already-satisfied
-dependencies, and binary cache hits or misses.
+Same as the default chatter, plus extra resolver detail. Progress such as
+selected recipes and cache hits is printed unless
+.B -q
+is used.
.TP
.B --plan
Print package-level actions without building, installing, removing, or changing
diff --git a/man/src.1 b/man/src.1
index 668b5cf..cdea836 100644
--- a/man/src.1
+++ b/man/src.1
@@ -3,7 +3,7 @@
src \- update and query SPS source repository indexes
.SH SYNOPSIS
.nf
-src update
+src [-q|--quiet] update
src check RECIPE... [--deps]
src search PATTERN [--raw]
src show PACKAGE [--raw]
@@ -27,6 +27,10 @@ and the selected dependency graph, and atomically replace the local package
index. Invalid input leaves the previous index in place. Recipe-only packages
are hashed without a temporary directory; a repository is parsed in one AWK
process rather than one process per recipe.
+Progress is written to stderr for each repository, each recipe hash, Git
+clone/fetch, and index validation so a long update does not look idle.
+.B -q
+keeps only the final summary line on stdout.
.TP
.BI check " recipe ..."
Validate each recipe with the same parser used by
@@ -77,6 +81,12 @@ or an implicit checkout. A clean tracking branch is fast-forwarded only when
safe. Detached heads and branches without an origin upstream remain pinned;
locally ahead branches remain unchanged; divergent branches are refused.
Git prompts are disabled so a missing network fails instead of hanging.
+.SH OPTIONS
+.TP
+.BR -q ", " --quiet
+With
+.B update ,
+hide per-repository and per-recipe progress. The summary line still prints.
.SH FILES
.TP
.B /etc/sps/sps.conf
diff --git a/tests/test_common.sh b/tests/test_common.sh
index c0a4c71..d9a49ce 100755
--- a/tests/test_common.sh
+++ b/tests/test_common.sh
@@ -78,3 +78,27 @@ case $CFLAGS in
esac
printf '%s\n' 'compiler flag configuration tests passed'
+
+mkdir -p "$test_root/usr/bin" "$test_root/usr/lib" "$test_root/run" "$test_root/var"
+ln -sf usr/lib "$test_root/lib"
+ln -sf usr/bin "$test_root/bin"
+ln -s ../run "$test_root/var/run"
+sps_is_usr_merge_dir lib
+sps_is_usr_merge_dir bin
+sps_is_shared_dir_link lib
+sps_is_shared_dir_link var/run
+! sps_is_usr_merge_dir var/run
+! sps_is_usr_merge_dir usr/lib
+! sps_is_usr_merge_dir lib64
+ln -sf /tmp "$test_root/sbin"
+! sps_is_usr_merge_dir sbin
+! sps_is_shared_dir_link sbin
+rm -f "$test_root/sbin"
+printf '%s\n' live >"$test_root/etc/sps/live"
+sps_is_live_bootstrap
+rm -f "$test_root/etc/sps/live"
+! sps_is_live_bootstrap
+sps_is_live_critical_path usr/bin/sh
+! sps_is_live_critical_path usr/bin/openssl
+
+printf '%s\n' 'usr-merge and live helper tests passed'
diff --git a/tests/test_dependencies.sh b/tests/test_dependencies.sh
index b4a4cfc..07569b6 100755
--- a/tests/test_dependencies.sh
+++ b/tests/test_dependencies.sh
@@ -113,7 +113,7 @@ make_recipe "$tmp/repo/broken" broken 1.0 'depend unavailable'
if "$src" update > "$tmp/broken.out" 2> "$tmp/broken.err"; then
fail 'repository with a missing dependency unexpectedly indexed'
fi
-contains "$(sed -n '1,20p' "$tmp/broken.err")" "dependency 'unavailable'"
+contains "$(sed -n '1,80p' "$tmp/broken.err")" "dependency 'unavailable'"
[ "$(cksum "$index")" = "$old_index" ] ||
fail 'missing dependency replaced the previous repository index'
rm -rf "$tmp/repo/broken"
@@ -124,7 +124,7 @@ make_recipe "$tmp/repo/cycle-b" cycle-b 1.0 'depend cycle-a'
if "$src" update > "$tmp/cycle.out" 2> "$tmp/cycle.err"; then
fail 'repository dependency cycle unexpectedly indexed'
fi
-contains "$(sed -n '1,20p' "$tmp/cycle.err")" 'dependency cycle:'
+contains "$(sed -n '1,80p' "$tmp/cycle.err")" 'dependency cycle:'
[ "$(cksum "$index")" = "$old_index" ] ||
fail 'dependency cycle replaced the previous repository index'
rm -rf "$tmp/repo/cycle-a" "$tmp/repo/cycle-b"
@@ -252,7 +252,7 @@ expect_stale_definition()
> "$tmp/stale-$stale_label.out" 2> "$tmp/stale-$stale_label.err"; then
fail "sget reused or built an artifact after a stale $stale_label change"
fi
- contains "$(sed -n '1,20p' "$tmp/stale-$stale_label.err")" \
+ contains "$(sed -n '1,80p' "$tmp/stale-$stale_label.err")" \
'changed since indexing'
[ "$(wc -l < "$TEST_LOG" | awk '{ print $1 }')" = "$old_log_lines" ] ||
fail "mkpkg or pkin ran after stale $stale_label detection"
diff --git a/tests/test_git_repository.sh b/tests/test_git_repository.sh
index 3fb9b6b..4081f24 100755
--- a/tests/test_git_repository.sh
+++ b/tests/test_git_repository.sh
@@ -29,7 +29,7 @@ expect_update_failure()
if "$src" update >"$tmp/failure.out" 2>"$tmp/failure.err"; then
fail "Git update unexpectedly succeeded: $expected"
fi
- contains "$(sed -n '1,40p' "$tmp/failure.err")" "$expected"
+ contains "$(sed -n '1,80p' "$tmp/failure.err")" "$expected"
}
write_seed_recipe()
diff --git a/tests/test_output.sh b/tests/test_output.sh
index e60379a..b79ba28 100755
--- a/tests/test_output.sh
+++ b/tests/test_output.sh
@@ -178,8 +178,8 @@ PATH="$tmp/fakebin:$PATH" run_env "$mkpkg" --artifact-file "$tmp/out/fetch-resul
contains "$(cat "$tmp/fetch.stderr")" 'mkpkg: fetching payload.dat'
contains "$(cat "$tmp/fetch.stderr")" 'mkpkg: checksum ok'
contains "$(cat "$CURL_LOG")" '-fL'
-contains "$(cat "$CURL_LOG")" '-sS'
-omits "$(cat "$CURL_LOG")" ' -# '
+contains "$(cat "$CURL_LOG")" '--progress-bar'
+omits "$(cat "$CURL_LOG")" ' -sS'
[ "$(sed -n '1p' "$tmp/out/fetch-result")" = \
"$tmp/out/fetchpkg-1.0-1-any.pkg.tar" ] ||
fail 'fetched package did not publish its artifact path'
diff --git a/tests/test_packages.sh b/tests/test_packages.sh
index 763da3b..58e5782 100755
--- a/tests/test_packages.sh
+++ b/tests/test_packages.sh
@@ -233,4 +233,79 @@ SPS_LIBDIR=$project_dir/lib \
"$project_dir/bin/pkcheck" --database | grep -qx 'database: ok' ||
fail 'database did not validate after shared-directory removal'
+# Upgrading a package that drops a shared directory must not rmdir it when
+# another installed package still lists that directory in its manifest.
+up_root=$tmp/upgrade-root
+up_db=$tmp/upgrade-db
+up_cache=$tmp/upgrade-cache
+up_build=$tmp/upgrade-build
+mkdir -p "$up_root" "$up_cache" "$up_build" \
+ "$tmp/up-stage-base/.SPS" "$tmp/up-stage-base/usr/share" \
+ "$tmp/up-stage-base/etc" \
+ "$tmp/up-stage-app1/.SPS" "$tmp/up-stage-app1/usr/share/app" \
+ "$tmp/up-stage-app2/.SPS" "$tmp/up-stage-app2/usr/bin"
+printf '%s\n' 'NAME="SPS"' >"$tmp/up-stage-base/etc/os-release"
+printf '%s\n' 'payload' >"$tmp/up-stage-app1/usr/share/app/data"
+printf '%s\n' 'tool' >"$tmp/up-stage-app2/usr/bin/shareapp"
+{
+ printf 'format\t1\n'
+ printf 'name\tsharebase\nversion\t1\nrelease\t1\narch\tany\ndescription\tbase hierarchy\n'
+} >"$tmp/up-stage-base/.SPS/meta"
+{
+ printf 'format\t1\n'
+ printf 'name\tshareapp\nversion\t1\nrelease\t1\narch\tany\ndescription\tapp v1\n'
+} >"$tmp/up-stage-app1/.SPS/meta"
+{
+ printf 'format\t1\n'
+ printf 'name\tshareapp\nversion\t2\nrelease\t1\narch\tany\ndescription\tapp v2\n'
+} >"$tmp/up-stage-app2/.SPS/meta"
+for up_stage in "$tmp/up-stage-base" "$tmp/up-stage-app1" "$tmp/up-stage-app2"; do
+ (
+ CDPATH= cd "$up_stage" &&
+ find . -type d -print | awk '
+ $0 != "." && $0 !~ /^\.\/\.SPS($|\/)/ { sub(/^\.\//, ""); print $0 "/" }'
+ find . ! -type d -print | awk '
+ $0 !~ /^\.\/\.SPS($|\/)/ { sub(/^\.\//, ""); print }'
+ ) | LC_ALL=C sort >"$up_stage/.SPS/files"
+ : >"$up_stage/.SPS/hashes"
+ while IFS= read -r up_entry || [ -n "$up_entry" ]; do
+ case $up_entry in */) continue ;; esac
+ [ -f "$up_stage/$up_entry" ] &&
+ printf 'sha256\t%s\t%s\n' \
+ "$(sha256sum "$up_stage/$up_entry" | awk '{ print $1 }')" \
+ "$up_entry" >>"$up_stage/.SPS/hashes"
+ done <"$up_stage/.SPS/files"
+done
+tar -C "$tmp/up-stage-base" -cf "$tmp/up-base.pkg.tar" .
+tar -C "$tmp/up-stage-app1" -cf "$tmp/up-app1.pkg.tar" .
+tar -C "$tmp/up-stage-app2" -cf "$tmp/up-app2.pkg.tar" .
+SPS_ROOT=$up_root SPS_DB=$up_db SPS_CACHE=$up_cache \
+SPS_BUILD=$up_build SPS_CONFIG=/dev/null SPS_REPOS_CONFIG=/dev/null \
+SPS_LIBDIR=$project_dir/lib \
+ "$project_dir/bin/pkin" --dependency "$tmp/up-base.pkg.tar" >/dev/null ||
+ fail 'upgrade-shared base package failed to install'
+SPS_ROOT=$up_root SPS_DB=$up_db SPS_CACHE=$up_cache \
+SPS_BUILD=$up_build SPS_CONFIG=/dev/null SPS_REPOS_CONFIG=/dev/null \
+SPS_LIBDIR=$project_dir/lib \
+ "$project_dir/bin/pkin" "$tmp/up-app1.pkg.tar" >/dev/null ||
+ fail 'upgrade-shared app v1 failed to install'
+SPS_ROOT=$up_root SPS_DB=$up_db SPS_CACHE=$up_cache \
+SPS_BUILD=$up_build SPS_CONFIG=/dev/null SPS_REPOS_CONFIG=/dev/null \
+SPS_LIBDIR=$project_dir/lib \
+ "$project_dir/bin/pkin" "$tmp/up-app2.pkg.tar" >/dev/null ||
+ fail 'upgrade-shared app v2 failed to install'
+[ -f "$up_root/usr/bin/shareapp" ] ||
+ fail 'upgraded app payload was not installed'
+[ ! -e "$up_root/usr/share/app" ] ||
+ fail 'exclusive app directory was not pruned on upgrade'
+[ -d "$up_root/usr/share" ] ||
+ fail 'upgrade deleted a shared directory still claimed by another package'
+[ -d "$up_root/usr" ] ||
+ fail 'upgrade deleted a base directory still claimed by another package'
+SPS_ROOT=$up_root SPS_DB=$up_db SPS_CACHE=$up_cache \
+SPS_BUILD=$up_build SPS_CONFIG=/dev/null SPS_REPOS_CONFIG=/dev/null \
+SPS_LIBDIR=$project_dir/lib \
+ "$project_dir/bin/pkcheck" --database | grep -qx 'database: ok' ||
+ fail 'database did not validate after shared-directory upgrade'
+
printf '%s\n' 'test_packages: ok'
diff --git a/tests/test_repository.sh b/tests/test_repository.sh
index 8f106e0..3ad01a6 100755
--- a/tests/test_repository.sh
+++ b/tests/test_repository.sh
@@ -24,7 +24,7 @@ expect_update_failure()
if "$src" update >"$tmp/failure.out" 2>"$tmp/failure.err"; then
fail "repository update unexpectedly succeeded: $expected"
fi
- contains "$(sed -n '1,30p' "$tmp/failure.err")" "$expected"
+ contains "$(sed -n '1,80p' "$tmp/failure.err")" "$expected"
}
write_recipe()
@@ -85,8 +85,16 @@ export SPS_ROOT SPS_DB SPS_CACHE SPS_BUILD SPS_REPO_ROOT SPS_CONFIG
export SPS_REPOS_CONFIG
src=$project_dir/bin/src
-update_output=$("$src" update)
+update_output=$("$src" update 2>"$tmp/update.err")
contains "$update_output" 'updated 3 repositories, 4 package records'
+contains "$(cat "$tmp/update.err")" 'updating package index'
+contains "$(cat "$tmp/update.err")" "indexing 'core'"
+contains "$(cat "$tmp/update.err")" 'core 1/'
+"$src" -q update >"$tmp/quiet-update.out" 2>"$tmp/quiet-update.err"
+contains "$(cat "$tmp/quiet-update.out")" 'updated 3 repositories, 4 package records'
+case $(cat "$tmp/quiet-update.err") in
+ *indexing*) fail 'quiet src update still printed indexing progress' ;;
+esac
index=$tmp/cache/indexes/packages.index
[ -r "$index" ] || fail 'aggregate index was not created'
diff --git a/tests/test_security.sh b/tests/test_security.sh
index 2f89df7..fc64a7e 100755
--- a/tests/test_security.sh
+++ b/tests/test_security.sh
@@ -88,6 +88,50 @@ repack()
tar -C "$repack_stage" -cf "$repack_archive" .
}
+write_meta()
+{
+ write_meta_dir=$1
+ write_meta_name=$2
+ write_meta_version=${3:-1.0}
+ mkdir -p "$write_meta_dir/.SPS"
+ {
+ printf 'format\t1\n'
+ printf 'name\t%s\n' "$write_meta_name"
+ printf 'version\t%s\n' "$write_meta_version"
+ printf 'release\t1\n'
+ printf 'arch\tany\n'
+ } >"$write_meta_dir/.SPS/meta"
+}
+
+pack_stage()
+{
+ pack_dir=$1
+ pack_archive=$2
+ mkdir -p "$pack_dir/.SPS"
+ : >"$pack_dir/.SPS/files.tmp"
+ : >"$pack_dir/.SPS/hashes"
+ (CDPATH= cd "$pack_dir" && find . ! -name . ! -path './.SPS' ! -path './.SPS/*' -print) |
+ sed 's#^\./##' | while IFS= read -r package_entry; do
+ if [ -d "$pack_dir/$package_entry" ] && [ ! -L "$pack_dir/$package_entry" ]; then
+ printf '%s/\n' "$package_entry"
+ else
+ printf '%s\n' "$package_entry"
+ fi
+ done >"$pack_dir/.SPS/files.tmp"
+ LC_ALL=C sort "$pack_dir/.SPS/files.tmp" >"$pack_dir/.SPS/files"
+ rm -f "$pack_dir/.SPS/files.tmp"
+ while IFS= read -r package_entry; do
+ case $package_entry in */) continue ;; esac
+ if [ -f "$pack_dir/$package_entry" ] && [ ! -L "$pack_dir/$package_entry" ]; then
+ printf 'sha256\t%s\t%s\n' \
+ "$(sha256sum "$pack_dir/$package_entry" | awk '{ print $1 }')" \
+ "$package_entry" >>"$pack_dir/.SPS/hashes"
+ fi
+ done <"$pack_dir/.SPS/files"
+ LC_ALL=C sort "$pack_dir/.SPS/hashes" -o "$pack_dir/.SPS/hashes"
+ tar -C "$pack_dir" -cf "$pack_archive" .
+}
+
tmp=$(mktemp -d "${TMPDIR:-/tmp}/sps-test-security.XXXXXX") ||
fail 'cannot create temporary directory'
trap 'rm -rf "$tmp"' 0 HUP INT TERM
@@ -284,4 +328,149 @@ tar -C "$stage" -cf "$archive" .SPS link link/pwn
expect_status 4 "$project_dir/bin/pkin" "$archive"
[ "$(cat "$outside/pwn")" = sentinel ] || fail 'archive symlink parent escaped extraction staging'
+# Usr-merge /lib -> usr/lib is a shared directory. Packages that install
+# udev rules under lib/udev must land in usr/lib without replacing the link.
+fresh_root
+stage=$tmp/stage-udevlib
+mkdir -p "$stage/lib/udev/rules.d" "$root/usr/lib"
+ln -s usr/lib "$root/lib"
+printf '%s\n' 'KERNEL=="test"' >"$stage/lib/udev/rules.d/99-test.rules"
+write_meta "$stage" udevlib
+pack_stage "$stage" "$tmp/udevlib.pkg.tar"
+run_sps "$project_dir/bin/pkin" "$tmp/udevlib.pkg.tar" >/dev/null ||
+ fail 'usr-merge /lib should accept package directory lib/'
+[ -L "$root/lib" ] || fail 'usr-merge /lib was replaced'
+[ "$(readlink "$root/lib")" = usr/lib ] || fail 'usr-merge /lib target changed'
+[ -f "$root/usr/lib/udev/rules.d/99-test.rules" ] ||
+ fail 'udev rule was not installed through usr-merge /lib'
+[ "$(cat "$root/usr/lib/udev/rules.d/99-test.rules")" = 'KERNEL=="test"' ] ||
+ fail 'udev rule payload is wrong'
+grep -q 'lib/udev/rules.d/99-test.rules udevlib' "$db/owners" ||
+ fail 'udev rule owner was not recorded'
+run_sps "$project_dir/bin/pkcheck" --all >/dev/null ||
+ fail 'pkcheck should accept usr-merge /lib as a shared directory'
+run_sps "$project_dir/bin/pkdel" udevlib >/dev/null ||
+ fail 'pkdel should remove files below usr-merge /lib'
+[ ! -e "$root/usr/lib/udev/rules.d/99-test.rules" ] ||
+ fail 'pkdel left udev rule behind'
+[ -L "$root/lib" ] || fail 'pkdel removed usr-merge /lib'
+
+# filesystem compat /var/run -> ../run is a shared directory, like usr-merge.
+fresh_root
+stage=$tmp/stage-varrun
+mkdir -p "$stage/var/run" "$root/run" "$root/var"
+ln -s ../run "$root/var/run"
+printf '%s\n' pid >"$stage/var/run/daemon.pid"
+write_meta "$stage" varrun
+pack_stage "$stage" "$tmp/varrun.pkg.tar"
+run_sps "$project_dir/bin/pkin" "$tmp/varrun.pkg.tar" >/dev/null ||
+ fail 'compat /var/run should accept package files'
+[ -L "$root/var/run" ] || fail 'compat /var/run was replaced'
+[ -f "$root/run/daemon.pid" ] || fail 'pid file was not installed through /var/run'
+run_sps "$project_dir/bin/pkcheck" --all >/dev/null ||
+ fail 'pkcheck should accept compat /var/run'
+run_sps "$project_dir/bin/pkdel" varrun >/dev/null ||
+ fail 'pkdel should remove files below /var/run'
+[ ! -e "$root/run/daemon.pid" ] || fail 'pkdel left pid file behind'
+[ -L "$root/var/run" ] || fail 'pkdel removed compat /var/run'
+
+# A /lib symlink that is not usr-merge must still be refused.
+fresh_root
+stage=$tmp/stage-badlib
+outside=$tmp/outside-lib
+mkdir -p "$stage/lib/udev" "$outside"
+ln -s "$outside" "$root/lib"
+printf '%s\n' stolen >"$stage/lib/udev/x"
+write_meta "$stage" badlib
+pack_stage "$stage" "$tmp/badlib.pkg.tar"
+expect_status 7 "$project_dir/bin/pkin" "$tmp/badlib.pkg.tar"
+[ ! -f "$outside/udev/x" ] || fail 'non usr-merge /lib redirected payload'
+
+# Live images may replace unowned seed files and busybox applets.
+fresh_root
+mkdir -p "$root/etc/sps" "$root/usr/bin"
+printf '%s\n' live >"$root/etc/sps/live"
+printf '%s\n' seed >"$root/usr/bin/openssl"
+archive=$(make_basic openssl 1.0 usr/bin/openssl)
+run_sps "$project_dir/bin/pkin" "$archive" >/dev/null 2>"$tmp/live-openssl.err" ||
+ fail 'live unowned openssl should be replaced'
+[ "$(cat "$root/usr/bin/openssl")" = 'openssl 1.0 payload' ] ||
+ fail 'live openssl seed was not replaced'
+grep -q 'replacing unowned live file /usr/bin/openssl' "$tmp/live-openssl.err" ||
+ fail 'live replacement diagnostic is missing'
+grep -qx 'usr/bin/openssl openssl' "$db/owners" ||
+ fail 'replaced live openssl was not owned'
+
+# Without the live marker, a different unowned file is still a conflict.
+fresh_root
+archive=$(make_basic occupied 1.0)
+mkdir -p "$root/usr/bin"
+printf '%s\n' administrator >"$root/usr/bin/occupied"
+expect_status 7 "$project_dir/bin/pkin" "$archive"
+[ "$(cat "$root/usr/bin/occupied")" = administrator ] ||
+ fail 'unowned collision was overwritten without a live marker'
+
+# Busybox-style applet symlink is claimed by the real package on a live image.
+fresh_root
+mkdir -p "$root/etc/sps" "$root/usr/bin"
+printf '%s\n' live >"$root/etc/sps/live"
+ln -s busybox "$root/usr/bin/clear"
+ln -s busybox "$root/usr/bin/tset"
+archive=$(make_basic ncurses 1.0 usr/bin/clear)
+run_sps "$project_dir/bin/pkin" "$archive" >/dev/null ||
+ fail 'live busybox applet should be replaced'
+[ -f "$root/usr/bin/clear" ] && [ ! -L "$root/usr/bin/clear" ] ||
+ fail 'live busybox clear was not replaced with a file'
+[ "$(cat "$root/usr/bin/clear")" = 'ncurses 1.0 payload' ] ||
+ fail 'live clear payload is wrong'
+
+# Unowned preserved etc/ files are kept on a live image; the package still
+# takes ownership so openssl-style /etc/ssl can install.
+fresh_root
+mkdir -p "$root/etc/sps" "$root/etc"
+printf '%s\n' live >"$root/etc/sps/live"
+printf '%s\n' local >"$root/etc/foo"
+archive=$(make_basic conffile 1.0 etc/foo)
+run_sps "$project_dir/bin/pkin" "$archive" >/dev/null 2>"$tmp/live-etc.err" ||
+ fail 'live unowned preserved etc/foo should install without replacing'
+[ "$(cat "$root/etc/foo")" = local ] || fail 'live replace clobbered a preserved file'
+[ -f "$root/etc/foo.sps-new" ] || fail 'live preserved install did not write .sps-new'
+[ "$(cat "$root/etc/foo.sps-new")" = 'conffile 1.0 payload' ] ||
+ fail 'live preserved .sps-new payload is wrong'
+grep -qx 'etc/foo conffile' "$db/owners" ||
+ fail 'kept live preserved file was not owned'
+grep -q 'keeping unowned live preserved file /etc/foo' "$tmp/live-etc.err" ||
+ fail 'live preserved keep diagnostic is missing'
+
+# Without the live marker, a different unowned etc/ file is still a conflict.
+fresh_root
+mkdir -p "$root/etc"
+printf '%s\n' local >"$root/etc/foo"
+archive=$(make_basic conffile 1.0 etc/foo)
+expect_status 7 "$project_dir/bin/pkin" "$archive"
+[ "$(cat "$root/etc/foo")" = local ] || fail 'unowned etc/ file was overwritten'
+
+# A different usr-merge /bin target stays a conflict even on a live image.
+fresh_root
+mkdir -p "$root/etc/sps" "$root/usr/bin" "$root/usr/sbin" \
+ "$tmp/stage-livebin/usr/bin"
+printf '%s\n' live >"$root/etc/sps/live"
+ln -s usr/bin "$tmp/stage-livebin/bin"
+ln -s usr/sbin "$root/bin"
+printf '%s\n' tool >"$tmp/stage-livebin/usr/bin/tool"
+write_meta "$tmp/stage-livebin" livebin
+pack_stage "$tmp/stage-livebin" "$tmp/livebin.pkg.tar"
+expect_status 7 "$project_dir/bin/pkin" "$tmp/livebin.pkg.tar"
+[ "$(readlink "$root/bin")" = usr/sbin ] || fail 'live replace changed usr-merge /bin'
+
+# Live PID 1 and the shell are not claimed by an unowned replacement.
+fresh_root
+mkdir -p "$root/etc/sps" "$root/usr/bin"
+printf '%s\n' live >"$root/etc/sps/live"
+ln -s busybox "$root/usr/bin/sh"
+archive=$(make_basic dash 1.0 usr/bin/sh)
+expect_status 7 "$project_dir/bin/pkin" "$archive"
+[ -L "$root/usr/bin/sh" ] || fail 'live replace claimed /usr/bin/sh'
+[ "$(readlink "$root/usr/bin/sh")" = busybox ] || fail 'live /usr/bin/sh target changed'
+
printf '%s\n' 'test_security: ok'