Splux Linux

source-based Linux using SPS

SPS · log · files · refs · tags · releases · people · languages · GitHub · commit

01ad20e


Install onto usr-merge live images and keep shared directories.

Date
Commit01ad20e24e7ffeb76efc8cb9c2a5b258a07344a2
GitHubmirror

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'