Splux Linux

source-based Linux using SPS

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

27f1327


Speed up repository indexing and only pull build tools a compile needs.

Date
Commit27f1327a233371a601d204bb3841bf20282d6810
GitHubmirror

Parents

Stat

 DESIGN.md                      |  14 +++-
 PACKAGE-MAINTAINER.md          |  10 ++-
 bin/mkiso                      |   4 +-
 bin/mkpkg                      |  25 +++++-
 bin/sget                       | 181 +++++++++++++++++++++++++++++++++++++++--
 bin/src                        | 176 +++++++++++++++++++++++++++++++++------
 lib/common.sh                  | 126 +++++++++++++++++++---------
 lib/deps.awk                   |   3 +-
 lib/setup/extras               |  17 +++-
 lib/setup/sets/daily-drive.set |   5 ++
 lib/setup/sets/multimedia.set  |   8 +-
 lib/setup/sets/office.set      |   8 +-
 man/mkpkg.1                    |   8 ++
 man/sget.1                     |   8 ++
 man/src.1                      |  21 +++--
 tests/test_build.sh            |   8 +-
 tests/test_dependencies.sh     |  21 ++++-
 tests/test_mkiso.sh            |   6 +-
 tests/test_repository.sh       |  14 ++++
 tests/test_src_check.sh        |   6 ++
 20 files changed, 573 insertions(+), 96 deletions(-)

Diff

diff --git a/DESIGN.md b/DESIGN.md
index 21da531..c6ffd14 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -116,7 +116,9 @@ Dependency lists are comma-separated. Field values may not contain tabs or
 newlines. The definition digest covers the recipe content and the relative
 paths, contents, and copied modes of its `files/`, `patches/`, and `hooks/`
 inputs, so a changed support file invalidates the index as well as a changed
-recipe.
+recipe. Recipe-only packages hash the same two-line manifest; `src update`
+batches those file hashes and then applies that manifest, so the digest
+matches the per-package hasher.
 
 When more than one configured repository supplies the same package, SPS picks
 the highest numeric priority. Configuration order breaks equal priorities. A
@@ -314,6 +316,16 @@ the upstream version, it must bump the package release.
 Runtime and build dependencies form a directed graph. `sget` computes a stable
 topological order and rejects missing dependencies and cycles.
 
+`sget install` resolves the runtime closure first. Build dependencies are added
+only for packages that will actually be compiled (not already installed at the
+selected version, and not a usable cached archive). Well-known build tools
+already on `PATH` (`cmake`, `ninja`, `meson`, `patch`, `pkgconf`, `gperf`,
+`python`, `go`, `rust`) are used in place of those packages when they are not
+themselves a runtime requirement. That is why `fastfetch` does not install
+`cmake` or `openssl` on a live image that already has cmake.
+
+`sget depends --build` still shows the full declared build graph.
+
 `sget upgrade` starts with the explicit `world` set and resolves its selected
 dependency closure. `sget dependees` shows reverse dependencies. `sget why`
 shows one dependency path between two selected packages.
diff --git a/PACKAGE-MAINTAINER.md b/PACKAGE-MAINTAINER.md
index ffbfd77..b2907c1 100644
--- a/PACKAGE-MAINTAINER.md
+++ b/PACKAGE-MAINTAINER.md
@@ -93,8 +93,10 @@ optional doxygen
 ```
 
 Supported operators are `=`, `<`, `<=`, `>`, and `>=`. Constraints apply to
-the upstream version, not the package release. `sget install` installs build
-dependencies while building a dependency closure. `optional` is informational
+the upstream version, not the package release. `sget install` adds a package's
+build dependencies only when that package will be compiled. Well-known tools
+already on `PATH` (cmake, ninja, meson, patch, pkgconf, python, ...) satisfy a
+builddep without installing the package. `optional` is informational
 and is not installed automatically. Low-level `mkpkg` and `pkin` do not resolve
 dependencies.
 
@@ -184,7 +186,9 @@ A real `files/` directory is copied to `$SRC/files`; a real `patches/` directory
 is copied to `$SRC/patches`. Entries must be ordinary directories or regular
 files. Symlinks, special files, and paths containing tabs or newlines are
 rejected. The repository definition digest includes support paths, contents,
-and copied modes, so run `src update` after any of them changes. Nothing is
+and copied modes, so run `src update` after any of them changes. `src update`
+batches recipe-only hashes; the digest is the same as hashing one package at
+a time. Nothing is
 applied or installed automatically.
 
 For example:
diff --git a/bin/mkiso b/bin/mkiso
index 4a3c4d8..57f5c4d 100755
--- a/bin/mkiso
+++ b/bin/mkiso
@@ -997,8 +997,8 @@ mkiso_verify_plasma()
 }
 
 # setup extras/sets/profiles may only name packages that exist in the
-# recipe trees on this image. An extras checklist entry for mpv with no
-# mpv recipe is how live setup died at "package 'mpv' not found".
+# recipe trees on this image. An extras checklist entry with no matching
+# recipe is how live setup died at "package not found".
 mkiso_verify_setup_catalog()
 {
 	mkiso_setupdir=$SPS_LIBDIR/setup
diff --git a/bin/mkpkg b/bin/mkpkg
index a5ee01a..0171c8f 100755
--- a/bin/mkpkg
+++ b/bin/mkpkg
@@ -520,7 +520,8 @@ download_http()
 	download_url=$1
 	download_dest=$2
 	if command -v curl >/dev/null 2>&1; then
-		curl -fL --progress-bar -o "$download_dest" "$download_url"
+		curl -fL --retry 3 --retry-delay 1 --progress-bar \
+			-o "$download_dest" "$download_url"
 	elif command -v wget >/dev/null 2>&1; then
 		wget -O "$download_dest" "$download_url"
 	else
@@ -725,7 +726,7 @@ write_cc_wrapper()
 	{
 		printf '%s\n' '#!/bin/sh'
 		write_host_ld_restore
-		printf 'exec %s -isystem %s -L%s -L%s -L%s -L%s -Wl,-rpath-link,%s -Wl,-rpath-link,%s "$@"\n' \
+		printf 'exec %s -isystem %s -L%s -L%s -L%s -L%s -Wl,-rpath-link,%s -Wl,-rpath-link,%s -Wl,-rpath-link,%s -Wl,-rpath-link,%s "$@"\n' \
 			"$(shell_quote "$real_cc")" \
 			"$(shell_quote "$target_inc")" \
 			"$(shell_quote "$target_root/usr/lib64")" \
@@ -733,7 +734,9 @@ write_cc_wrapper()
 			"$(shell_quote "$target_root/lib64")" \
 			"$(shell_quote "$target_root/lib")" \
 			"$(shell_quote "$target_root/usr/lib64")" \
-			"$(shell_quote "$target_root/usr/lib")"
+			"$(shell_quote "$target_root/usr/lib")" \
+			"$(shell_quote "$target_root/lib64")" \
+			"$(shell_quote "$target_root/lib")"
 	} >"$wrapper_path"
 	chmod 755 "$wrapper_path"
 }
@@ -818,6 +821,22 @@ if [ -n "${SPS_ROOT:-}" ] && [ "$SPS_ROOT" != / ]; then
 	export CPATH C_INCLUDE_PATH CPLUS_INCLUDE_PATH LIBRARY_PATH \
 		SPS_HOST_LD_LIBRARY_PATH SPS_TARGET_LIBRARY_PATH LD_LIBRARY_PATH
 
+	# OpenSSL 3 looks up providers by a compiled-in MODULESDIR (/usr/lib64
+	# on the live disc). A just-built _ssl.so must load the target's
+	# modules, not the live ones, or CPython renames it *_failed.so.
+	if [ -d "$target_root/usr/lib64/ossl-modules" ]; then
+		OPENSSL_MODULES=$target_root/usr/lib64/ossl-modules
+	elif [ -d "$target_root/usr/lib/ossl-modules" ]; then
+		OPENSSL_MODULES=$target_root/usr/lib/ossl-modules
+	fi
+	if [ -n "${OPENSSL_MODULES:-}" ]; then
+		export OPENSSL_MODULES
+	fi
+	if [ -f "$target_root/etc/ssl/openssl.cnf" ]; then
+		OPENSSL_CONF=$target_root/etc/ssl/openssl.cnf
+		export OPENSSL_CONF
+	fi
+
 	CPPFLAGS="$target_cflags${CPPFLAGS:+ $CPPFLAGS}"
 	CFLAGS="$target_cflags${CFLAGS:+ $CFLAGS}"
 	CXXFLAGS="$target_cflags${CXXFLAGS:+ $CXXFLAGS}"
diff --git a/bin/sget b/bin/sget
index b865166..78437b2 100755
--- a/bin/sget
+++ b/bin/sget
@@ -269,6 +269,176 @@ sget_verify_artifact_identity()
 # Set by the upgrade path.
 sget_upgrade_mode=${sget_upgrade_mode:-0}
 
+# Build-only tools that a live disc already seeds. Skip installing the
+# package when it is not a runtime requirement and the host command exists.
+sget_host_build_tool()
+{
+    case $1 in
+        cmake) command -v cmake >/dev/null 2>&1 ;;
+        ninja) command -v ninja >/dev/null 2>&1 ;;
+        meson) command -v meson >/dev/null 2>&1 ;;
+        patch) command -v patch >/dev/null 2>&1 ;;
+        pkgconf) command -v pkg-config >/dev/null 2>&1 ||
+            command -v pkgconf >/dev/null 2>&1 ;;
+        gperf) command -v gperf >/dev/null 2>&1 ;;
+        python) command -v python3 >/dev/null 2>&1 ;;
+        go) command -v go >/dev/null 2>&1 ;;
+        rust) command -v rustc >/dev/null 2>&1 &&
+            command -v cargo >/dev/null 2>&1 ;;
+        unzip) command -v unzip >/dev/null 2>&1 ;;
+        lzip) command -v lzip >/dev/null 2>&1 ;;
+        *) return 1 ;;
+    esac
+}
+
+sget_selected_index_field()
+{
+    awk -F "$sps_tab" -v pkg="$1" -v col="$2" '
+        $1 == pkg {
+            pri = $9 + 0
+            if (!seen || pri > best) {
+                best = pri
+                val = $col
+                seen = 1
+            }
+        }
+        END { if (seen) print val }
+    ' "$sps_index"
+}
+
+sget_cache_usable()
+{
+    sget_cu_name=$1
+    sget_cu_version=$2
+    sget_cu_release=$3
+    sget_cu_arch=$4
+    sget_cu_hash=$5
+    sget_cu_base=$SPS_CACHE/packages/$sget_cu_name-$sget_cu_version-$sget_cu_release-$sget_cu_arch.pkg.tar
+    for sget_cu_candidate in "$sget_cu_base.zst" "$sget_cu_base.xz" \
+                             "$sget_cu_base.gz" "$sget_cu_base"
+    do
+        [ -f "$sget_cu_candidate" ] && [ ! -L "$sget_cu_candidate" ] || continue
+        sget_verify_artifact_identity "$sget_cu_candidate" "$sget_cu_name" \
+            "$sget_cu_version" "$sget_cu_release" "$sget_cu_arch" \
+            "$sget_cu_hash" >/dev/null 2>&1 || continue
+        return 0
+    done
+    return 1
+}
+
+# True when mkpkg will run for this selected record.
+sget_will_compile()
+{
+    sget_wc_name=$1
+    sget_wc_version=$2
+    sget_wc_release=$3
+    sget_wc_arch=$4
+    sget_wc_hash=$5
+    sget_wc_cache=$sget_work/will-compile/$sget_wc_name
+    if [ -f "$sget_wc_cache" ]; then
+        read -r sget_wc_cached <"$sget_wc_cache"
+        return "$sget_wc_cached"
+    fi
+    if sget_read_installed "$sget_wc_name"; then
+        if ! sget_wc_cmp=$(sget_compare_selected "$sget_wc_version" \
+            "$sget_wc_release" "$sget_installed_version" \
+            "$sget_installed_release")
+        then
+            printf '%s\n' 0 >"$sget_wc_cache"
+            return 0
+        fi
+        if [ "$sget_wc_cmp" -eq 0 ] &&
+           [ "$sget_installed_arch" = "$sget_wc_arch" ] &&
+           [ "$sget_reinstall" -eq 0 ]; then
+            printf '%s\n' 1 >"$sget_wc_cache"
+            return 1
+        fi
+    fi
+    if sget_cache_usable "$sget_wc_name" "$sget_wc_version" \
+        "$sget_wc_release" "$sget_wc_arch" "$sget_wc_hash"
+    then
+        printf '%s\n' 1 >"$sget_wc_cache"
+        return 1
+    fi
+    printf '%s\n' 0 >"$sget_wc_cache"
+    return 0
+}
+
+# Runtime closure first. Add builddeps only for packages that will be
+# compiled, and skip well-known tools already on PATH.
+sget_plan_with_builddeps()
+{
+    sget_all_roots=$sget_roots
+    sget_round=0
+    mkdir -p "$sget_work/will-compile"
+    while :
+    do
+        sget_round=$((sget_round + 1))
+        [ "$sget_round" -lt 32 ] ||
+            sps_die "$SPS_EX_DEPENDENCY" "build-dependency expansion did not settle"
+        if ! awk -v action=plan -v roots="$sget_all_roots" \
+            -v include_build=0 -v program=sget \
+            -v installed="$(sget_installed_names)" \
+            -f "$SPS_LIBDIR/version.awk" -f "$SPS_LIBDIR/deps.awk" \
+            "$sps_index" >"$sget_work/resolution"
+        then
+            sps_die "$SPS_EX_DEPENDENCY" "cannot resolve requested packages"
+        fi
+        : >"$sget_work/new-build-roots"
+        while IFS="$sps_tab" read -r sget_pl_name sget_pl_recipe sget_pl_hash \
+              sget_pl_version sget_pl_release sget_pl_arch sget_pl_bdeps || \
+              [ -n "$sget_pl_name" ]
+        do
+            [ -n "$sget_pl_name" ] || continue
+            sget_will_compile "$sget_pl_name" "$sget_pl_version" \
+                "$sget_pl_release" "$sget_pl_arch" "$sget_pl_hash" || continue
+            [ -n "$sget_pl_bdeps" ] || continue
+            sget_pl_saved_ifs=$IFS
+            IFS=,
+            set -- $sget_pl_bdeps
+            IFS=$sget_pl_saved_ifs
+            for sget_pl_bdep
+            do
+                sget_pl_bdep=$(printf '%s' "$sget_pl_bdep" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
+                [ -n "$sget_pl_bdep" ] || continue
+                case $sget_pl_bdep in
+                    *'>='*) sget_pl_bdep=${sget_pl_bdep%%>=*} ;;
+                    *'<='*) sget_pl_bdep=${sget_pl_bdep%%<=*} ;;
+                    *'='*) sget_pl_bdep=${sget_pl_bdep%%=*} ;;
+                    *'>'*) sget_pl_bdep=${sget_pl_bdep%%>*} ;;
+                    *'<'*) sget_pl_bdep=${sget_pl_bdep%%<*} ;;
+                esac
+                [ -n "$sget_pl_bdep" ] || continue
+                case ,$sget_all_roots, in
+                    *,"$sget_pl_bdep",*) continue ;;
+                esac
+                if sget_host_build_tool "$sget_pl_bdep"; then
+                    sget_note "using host $sget_pl_bdep to build $sget_pl_name"
+                    continue
+                fi
+                printf '%s\n' "$sget_pl_bdep" >>"$sget_work/new-build-roots"
+            done
+        done <"$sget_work/resolution"
+        [ -s "$sget_work/new-build-roots" ] || return 0
+        sget_new_roots=
+        while IFS= read -r sget_pl_bdep || [ -n "$sget_pl_bdep" ]
+        do
+            [ -n "$sget_pl_bdep" ] || continue
+            case ,$sget_all_roots, in
+                *,"$sget_pl_bdep",*) continue ;;
+            esac
+            case ,$sget_new_roots, in
+                *,"$sget_pl_bdep",*) continue ;;
+            esac
+            [ -n "$sget_new_roots" ] &&
+                sget_new_roots=$sget_new_roots,$sget_pl_bdep ||
+                sget_new_roots=$sget_pl_bdep
+        done <"$sget_work/new-build-roots"
+        [ -n "$sget_new_roots" ] || return 0
+        sget_all_roots=$sget_new_roots,$sget_all_roots
+    done
+}
+
 sget_install()
 {
     sget_plan=0
@@ -323,18 +493,13 @@ sget_install()
         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)" \
-            -f "$SPS_LIBDIR/version.awk" -f "$SPS_LIBDIR/deps.awk" \
-            "$sps_index" >"$sget_work/resolution"; then
-            sps_die "$SPS_EX_DEPENDENCY" "cannot resolve requested packages"
-        fi
+        sget_plan_with_builddeps
     fi
 
     sget_count=0
     while IFS="$sps_tab" read -r sget_package sget_recipe sget_recipe_hash \
-          sget_version sget_release sget_arch || [ -n "$sget_package" ]
+          sget_version sget_release sget_arch sget_unused_bdeps || \
+          [ -n "$sget_package" ]
     do
         [ -n "$sget_package" ] || continue
         case $sget_arch in
diff --git a/bin/src b/bin/src
index 7c3d4cf..0cac56a 100755
--- a/bin/src
+++ b/bin/src
@@ -40,6 +40,38 @@ src_progress()
     printf '%s: %s\n' "$sps_prog" "$*" >&2
 }
 
+# True when the package directory has files/, patches/, or hooks/ (including
+# a dangling symlink of those names). Those still need the full definition
+# hasher; recipe-only packages can share one sha256sum batch.
+src_recipe_needs_support()
+{
+	src_support_dir=$1
+	for src_support_name in files patches hooks; do
+		if [ -e "$src_support_dir/$src_support_name" ] ||
+		   [ -L "$src_support_dir/$src_support_name" ]; then
+			return 0
+		fi
+	done
+	return 1
+}
+
+# Print hashing progress. Small repositories still log every recipe; large
+# ones log the first, last, and every 25th so stderr stays useful without
+# becoming the slow path.
+src_progress_hash()
+{
+	src_ph_repo=$1
+	src_ph_n=$2
+	src_ph_total=$3
+	src_ph_label=$4
+	if [ "$src_ph_total" -le 8 ] ||
+	   [ "$src_ph_n" -eq 1 ] ||
+	   [ "$src_ph_n" -eq "$src_ph_total" ] ||
+	   [ $((src_ph_n % 25)) -eq 0 ]; then
+		src_progress "$src_ph_repo $src_ph_n/$src_ph_total $src_ph_label"
+	fi
+}
+
 src_need_index()
 {
     [ -r "$sps_index" ] ||
@@ -55,6 +87,7 @@ src_cleanup_update()
 	if [ -n "${src_work:-}" ]; then
 		rm -f "$src_work/repos.raw" "$src_work/repos" \
 		      "$src_work/recipes.unsorted" "$src_work/recipes" \
+		      "$src_work/simple" "$src_work/simple.raw" \
 		      "$src_work/digests" "$src_work/packages.index"
 		rmdir "$src_work" 2>/dev/null || :
 		src_work=
@@ -101,9 +134,11 @@ src_sync_git()
 		src_progress "cloning '$src_git_name'"
 		# Depth 1 is enough for recipes. Later fetches stay full so
 		# fast-forward and divergence checks keep working.
-		# --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 -- \
+		# --no-tags skips tag objects that would otherwise bloat fetch
+		# on remotes with a long tag history. Branch tips are still
+		# fetched. --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 --no-tags --progress -- \
 			"$src_git_source" "$src_clone_checkout"; then
 			sps_die "$SPS_EX_NETWORK" \
 				"cannot clone repository '$src_git_name'"
@@ -148,7 +183,7 @@ src_sync_git()
 	fi
 
 	src_progress "fetching '$src_git_name'"
-	if ! git -C "$src_git_checkout" fetch --progress origin; then
+	if ! git -C "$src_git_checkout" fetch --no-tags --progress origin; then
 		sps_die "$SPS_EX_NETWORK" \
 			"cannot fetch repository '$src_git_name'"
 	fi
@@ -304,34 +339,92 @@ src_update()
             src_failed=1
             break
         }
+        : > "$src_work/simple" || {
+            printf "%s: cannot create recipe hash list\n" "$sps_prog" >&2
+            src_failed=1
+            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
-            if ! sps_tab_safe "$src_recipe"; then
-                printf "%s: recipe path contains a tab or newline: %s\n" \
+            if src_recipe_needs_support "${src_recipe%/*}"; then
+                continue
+            fi
+            printf '%s\n' "$src_recipe" >> "$src_work/simple" || {
+                printf "%s: cannot record recipe hash list: %s\n" \
                     "$sps_prog" "$src_recipe" >&2
                 src_failed=1
                 break
+            }
+        done < "$src_work/recipes"
+        [ "$src_failed" -eq 0 ] || break
+
+        src_batch_ok=0
+        if command -v sha256sum >/dev/null 2>&1 &&
+           command -v xargs >/dev/null 2>&1 &&
+           [ -s "$src_work/simple" ]; then
+            if tr '\n' '\0' < "$src_work/simple" |
+               xargs -0 sha256sum -- > "$src_work/simple.raw"; then
+                src_simple_n=$(awk 'END { print NR + 0 }' "$src_work/simple")
+                src_hash_n=$(awk 'END { print NR + 0 }' "$src_work/simple.raw")
+                if [ "$src_simple_n" -eq "$src_hash_n" ]; then
+                    src_batch_ok=1
+                fi
             fi
-			src_digest=$(sps_definition_sha256 "$src_recipe") || {
-				printf "%s: cannot hash package definition: %s\n" \
-					"$sps_prog" "$src_recipe" >&2
+        fi
+        if [ "$src_batch_ok" -eq 1 ]; then
+            exec 3< "$src_work/simple.raw" || {
+                printf "%s: cannot read batched recipe hashes\n" \
+                    "$sps_prog" >&2
                 src_failed=1
                 break
             }
+        fi
+        while IFS= read -r src_recipe || [ -n "$src_recipe" ]
+        do
+            [ -n "$src_recipe" ] || continue
+            if ! sps_tab_safe "$src_recipe"; then
+                printf "%s: recipe path contains a tab or newline: %s\n" \
+                    "$sps_prog" "$src_recipe" >&2
+                src_failed=1
+                break
+            fi
+            if [ "$src_batch_ok" -eq 1 ] &&
+               ! src_recipe_needs_support "${src_recipe%/*}"; then
+                read -r src_file_hash src_hash_rest <&3 || {
+                    printf "%s: batched recipe hash missing: %s\n" \
+                        "$sps_prog" "$src_recipe" >&2
+                    src_failed=1
+                    break
+                }
+                src_digest=$(sps_definition_from_recipe_file_hash \
+                    "$src_file_hash") || {
+                    printf "%s: cannot hash package definition: %s\n" \
+                        "$sps_prog" "$src_recipe" >&2
+                    src_failed=1
+                    break
+                }
+            else
+                src_digest=$(sps_definition_sha256 "$src_recipe") || {
+                    printf "%s: cannot hash package definition: %s\n" \
+                        "$sps_prog" "$src_recipe" >&2
+                    src_failed=1
+                    break
+                }
+            fi
             case $src_digest in
                 ''|*[!0123456789abcdefABCDEF]*)
-					printf "%s: invalid package definition SHA-256: %s\n" \
+                    printf "%s: invalid package definition SHA-256: %s\n" \
                         "$sps_prog" "$src_recipe" >&2
                     src_failed=1
                     break
                     ;;
             esac
             if [ "${#src_digest}" -ne 64 ]; then
-				printf "%s: invalid package definition SHA-256: %s\n" \
+                printf "%s: invalid package definition SHA-256: %s\n" \
                     "$sps_prog" "$src_recipe" >&2
                 src_failed=1
                 break
@@ -346,8 +439,12 @@ src_update()
             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"
+            src_progress_hash "$src_repo_name" "$src_repo_packages" \
+                "$src_recipe_total" "$src_recipe_label"
         done < "$src_work/recipes"
+        if [ "$src_batch_ok" -eq 1 ]; then
+            exec 3<&-
+        fi
 
         [ "$src_failed" -eq 0 ] || break
         if [ "$src_repo_packages" -gt 0 ]; then
@@ -469,7 +566,26 @@ src_query()
 src_check()
 {
     src_check_deps=0
-    src_check_recipes=
+    src_check_recipes_file=
+    src_check_work=
+    src_check_cleanup()
+    {
+        [ -n "${src_check_recipes_file:-}" ] &&
+            rm -f "$src_check_recipes_file"
+        src_check_recipes_file=
+        if [ -n "${src_check_work:-}" ]; then
+            rm -f "$src_check_work/packages.index"
+            rmdir "$src_check_work" 2>/dev/null || :
+            src_check_work=
+        fi
+    }
+    src_check_recipes_file=$(mktemp "${TMPDIR:-/tmp}/sps-src-check.XXXXXX") ||
+        exit "$SPS_EX_PERMISSION"
+    trap src_check_cleanup 0
+    trap 'exit 129' 1
+    trap 'exit 130' 2
+    trap 'exit 131' 3
+    trap 'exit 143' 15
     while [ "$#" -gt 0 ]; do
         case $1 in
             --deps)
@@ -477,10 +593,14 @@ src_check()
                 ;;
             --help|-h)
                 printf '%s\n' 'usage: src check RECIPE... [--deps]'
+                src_check_cleanup
+                trap - 0 1 2 3 15
                 exit 0
                 ;;
             -*)
                 src_usage >&2
+                src_check_cleanup
+                trap - 0 1 2 3 15
                 exit "$SPS_EX_USAGE"
                 ;;
             *)
@@ -492,18 +612,23 @@ src_check()
                 if [ ! -f "$src_check_resolve" ] ||
                    [ -L "$src_check_resolve" ] ||
                    [ ! -r "$src_check_resolve" ]; then
+                    src_check_cleanup
+                    trap - 0 1 2 3 15
                     sps_die "$SPS_EX_NOTFOUND" \
                         "no readable recipe file at '$1'"
                 fi
-                src_check_recipes="$src_check_recipes $src_check_resolve"
+                printf '%s\n' "$src_check_resolve" >>"$src_check_recipes_file" ||
+                    sps_die "$SPS_EX_PERMISSION" "cannot record recipe path"
                 ;;
         esac
         shift
     done
-    [ -n "$src_check_recipes" ] || {
+    if [ ! -s "$src_check_recipes_file" ]; then
         printf '%s\n' 'usage: src check RECIPE... [--deps]' >&2
+        src_check_cleanup
+        trap - 0 1 2 3 15
         exit "$SPS_EX_USAGE"
-    }
+    fi
 
     if [ "$src_check_deps" -eq 1 ]; then
         src_check_work=$(sps_mktemp_dir "$SPS_BUILD/.check") || exit $?
@@ -514,7 +639,8 @@ src_check()
     src_check_failed=0
     src_check_count=0
     src_check_arch=${SPS_ARCH:-$(uname -m 2>/dev/null || printf unknown)}
-    for src_check_recipe in $src_check_recipes; do
+    while IFS= read -r src_check_recipe || [ -n "$src_check_recipe" ]
+    do
         [ -n "$src_check_recipe" ] || continue
         src_check_count=$((src_check_count + 1))
         src_check_digest=$(sps_definition_sha256 "$src_check_recipe") || {
@@ -550,11 +676,11 @@ src_check()
                 "$src_check_name" >&2
             src_check_failed=1
         fi
-    done
+    done < "$src_check_recipes_file"
 
     if [ "$src_check_failed" -ne 0 ]; then
-        rm -f "$src_check_index" 2>/dev/null || :
-        rmdir "$src_check_work" 2>/dev/null || :
+        src_check_cleanup
+        trap - 0 1 2 3 15
         sps_die "$SPS_EX_PACKAGE" \
             "$src_check_count recipe(s) checked: at least one failed"
     fi
@@ -562,20 +688,22 @@ src_check()
     if [ "$src_check_deps" -eq 1 ]; then
         if ! awk -v action=validate -v program=src \
                 -f "$SPS_LIBDIR/index.awk" "$src_check_index"; then
-            rmdir "$src_check_work" 2>/dev/null || :
+            src_check_cleanup
+            trap - 0 1 2 3 15
             sps_die "$SPS_EX_PACKAGE" "generated check index is malformed"
         fi
         if ! awk -v action=validate -v program=src \
                 -f "$SPS_LIBDIR/version.awk" -f "$SPS_LIBDIR/deps.awk" \
                 "$src_check_index"; then
-            rmdir "$src_check_work" 2>/dev/null || :
+            src_check_cleanup
+            trap - 0 1 2 3 15
             sps_die "$SPS_EX_DEPENDENCY" "checked dependency graph is invalid"
         fi
-        rm -f "$src_check_index" 2>/dev/null || :
-        rmdir "$src_check_work" 2>/dev/null || :
     fi
 
     printf 'checked %d recipe(s)\n' "$src_check_count"
+    src_check_cleanup
+    trap - 0 1 2 3 15
 }
 
 while [ "$#" -gt 0 ]; do
diff --git a/lib/common.sh b/lib/common.sh
index fb15608..3d1336a 100644
--- a/lib/common.sh
+++ b/lib/common.sh
@@ -88,45 +88,107 @@ sps_require_cmd()
 		sps_die "$SPS_EX_PACKAGE" "required command not found: $1"
 }
 
-# Print only the digest. Avoid an extra awk process here.
-sps_sha256()
+# Resolve the hasher once per process. src update hashes hundreds of recipes.
+sps_sha256_kind=
+
+sps_sha256_detect()
 {
+	[ -n "$sps_sha256_kind" ] && return 0
 	if command -v sha256sum >/dev/null 2>&1; then
-		sps_hash_line=$(sha256sum "$1") || return $?
-		printf '%s\n' "${sps_hash_line%% *}"
+		sps_sha256_kind=sha256sum
 	elif command -v sha256 >/dev/null 2>&1; then
-		sha256 -q "$1"
+		sps_sha256_kind=sha256
 	elif command -v shasum >/dev/null 2>&1; then
-		sps_hash_line=$(shasum -a 256 "$1") || return $?
-		printf '%s\n' "${sps_hash_line%% *}"
+		sps_sha256_kind=shasum
 	elif command -v openssl >/dev/null 2>&1; then
-		sps_hash_line=$(openssl dgst -sha256 "$1") || return $?
-		printf '%s\n' "${sps_hash_line##* }"
+		sps_sha256_kind=openssl
 	else
 		sps_die "$SPS_EX_CHECKSUM" \
 			"no SHA-256 utility found (tried sha256sum, sha256, shasum, openssl)"
 	fi
 }
 
+# Print only the digest. Avoid an extra awk process here.
+sps_sha256()
+{
+	sps_sha256_detect || return $?
+	case $sps_sha256_kind in
+		sha256sum)
+			sps_hash_line=$(sha256sum "$1") || return $?
+			printf '%s\n' "${sps_hash_line%% *}"
+			;;
+		sha256)
+			sha256 -q "$1"
+			;;
+		shasum)
+			sps_hash_line=$(shasum -a 256 "$1") || return $?
+			printf '%s\n' "${sps_hash_line%% *}"
+			;;
+		openssl)
+			sps_hash_line=$(openssl dgst -sha256 "$1") || return $?
+			printf '%s\n' "${sps_hash_line##* }"
+			;;
+		*)
+			sps_die "$SPS_EX_CHECKSUM" \
+				"no SHA-256 utility found (tried sha256sum, sha256, shasum, openssl)"
+			;;
+	esac
+}
+
 # Same digest as sps_sha256, from stdin. Used for the recipe-only definition
 # manifest so src update does not create a throwaway directory per package.
 sps_sha256_stream()
 {
-	if command -v sha256sum >/dev/null 2>&1; then
-		sps_hash_line=$(sha256sum) || return $?
-		printf '%s\n' "${sps_hash_line%% *}"
-	elif command -v sha256 >/dev/null 2>&1; then
-		sha256 -q
-	elif command -v shasum >/dev/null 2>&1; then
-		sps_hash_line=$(shasum -a 256) || return $?
-		printf '%s\n' "${sps_hash_line%% *}"
-	elif command -v openssl >/dev/null 2>&1; then
-		sps_hash_line=$(openssl dgst -sha256) || return $?
-		printf '%s\n' "${sps_hash_line##* }"
-	else
-		sps_die "$SPS_EX_CHECKSUM" \
-			"no SHA-256 utility found (tried sha256sum, sha256, shasum, openssl)"
-	fi
+	sps_sha256_detect || return $?
+	case $sps_sha256_kind in
+		sha256sum)
+			sps_hash_line=$(sha256sum) || return $?
+			printf '%s\n' "${sps_hash_line%% *}"
+			;;
+		sha256)
+			sha256 -q
+			;;
+		shasum)
+			sps_hash_line=$(shasum -a 256) || return $?
+			printf '%s\n' "${sps_hash_line%% *}"
+			;;
+		openssl)
+			sps_hash_line=$(openssl dgst -sha256) || return $?
+			printf '%s\n' "${sps_hash_line##* }"
+			;;
+		*)
+			sps_die "$SPS_EX_CHECKSUM" \
+				"no SHA-256 utility found (tried sha256sum, sha256, shasum, openssl)"
+			;;
+	esac
+}
+
+# Lowercase a SHA-256 hex digest. GNU sha256sum is already lowercase; skip tr.
+sps_hex_normalize()
+{
+	sps_hex_in=$1
+	case $sps_hex_in in
+		*[ABCDEF]*)
+			sps_hex_in=$(printf '%s\n' "$sps_hex_in" |
+				LC_ALL=C tr 'ABCDEF' 'abcdef') || return 1
+			;;
+	esac
+	case $sps_hex_in in
+		''|*[!0123456789abcdef]*) return 1 ;;
+	esac
+	[ "${#sps_hex_in}" -eq 64 ] || return 1
+	printf '%s\n' "$sps_hex_in"
+}
+
+# Recipe-only definition digest from the recipe file's SHA-256. Same two-line
+# manifest the support-tree path would write for a package with no files/,
+# patches/, or hooks/.
+sps_definition_from_recipe_file_hash()
+{
+	sps_rfh=$(sps_hex_normalize "$1") || return 1
+	sps_rfh_out=$(printf 'sps-package-definition\t1\nfile\t-\t%s\trecipe\n' \
+		"$sps_rfh" | sps_sha256_stream) || return 1
+	sps_hex_normalize "$sps_rfh_out"
 }
 
 # Hash every input which makes up one package definition.  The manifest uses
@@ -178,23 +240,11 @@ sps_definition_sha256()
 			sps_warn 'cannot hash package definition recipe'
 			return 1
 		}
-		sps_definition_hash=$(printf '%s\n' "$sps_definition_hash" |
-			LC_ALL=C tr 'ABCDEF' 'abcdef') || return 1
-		case $sps_definition_hash in
-			''|*[!0123456789abcdef]*) return 1 ;;
-		esac
-		[ "${#sps_definition_hash}" -eq 64 ] || return 1
-		sps_definition_out=$(printf 'sps-package-definition\t1\nfile\t-\t%s\trecipe\n' \
-			"$sps_definition_hash" | sps_sha256_stream) || {
+		sps_definition_out=$(sps_definition_from_recipe_file_hash \
+			"$sps_definition_hash") || {
 			sps_warn 'cannot hash package definition manifest'
 			return 1
 		}
-		sps_definition_out=$(printf '%s\n' "$sps_definition_out" |
-			LC_ALL=C tr 'ABCDEF' 'abcdef') || return 1
-		case $sps_definition_out in
-			''|*[!0123456789abcdef]*) return 1 ;;
-		esac
-		[ "${#sps_definition_out}" -eq 64 ] || return 1
 		printf '%s\n' "$sps_definition_out"
 		return 0
 	fi
diff --git a/lib/deps.awk b/lib/deps.awk
index 097ccbf..aa6b643 100644
--- a/lib/deps.awk
+++ b/lib/deps.awk
@@ -270,7 +270,8 @@ END {
             if (action == "plan")
                 print deps_package, deps_recipe[deps_package],
                       deps_recipe_sha256[deps_package], deps_version[deps_package],
-                      deps_release[deps_package], deps_arch[deps_package]
+                      deps_release[deps_package], deps_arch[deps_package],
+                      deps_build[deps_package]
             else
                 print deps_package
         }
diff --git a/lib/setup/extras b/lib/setup/extras
index 19a1062..d425f8f 100644
--- a/lib/setup/extras
+++ b/lib/setup/extras
@@ -1,20 +1,24 @@
 # Extra packages offered by setup(1). One name<TAB>title per line.
 # Names must exist as recipes in sps-core or sps-extra, appear once,
 # and stay in LC_ALL=C sort order. tests/test_setup_catalog.sh and
-# mkiso refuse anything else. Do not list unpackaged names (mpv, GIMP,
+# mkiso refuse anything else. Do not list unpackaged names (GIMP,
 # LibreOffice, and similar); the extra set or a later recipe is the place
 # for those.
 alsa-utils	ALSA utilities
 amd-ucode	AMD CPU microcode
 bat	bat
 bluez	BlueZ Bluetooth
+brightnessctl	brightnessctl
 btop	btop
+chrony	chrony
 cmake	CMake
 cryptsetup	cryptsetup
 cups	CUPS printing
 curl	curl
 dhcpcd	dhcpcd
+dmidecode	dmidecode
 doas	OpenDoas
+dos2unix	dos2unix
 dosfstools	dosfstools
 e2fsprogs	e2fsprogs
 emacs	GNU Emacs
@@ -24,17 +28,20 @@ eza	eza
 falkon	Falkon
 fastfetch	fastfetch
 fd	fd
+feh	feh
 firefox	Firefox
 fish	fish shell
 flatpak	Flatpak
 fzf	fzf
 gdb	GDB
 git	Git
+git-lfs	Git LFS
 gnupg	GnuPG
 go	Go
 gptfdisk	GPT fdisk
 helix	Helix editor
 htop	htop
+imagemagick	ImageMagick
 intel-ucode	Intel CPU microcode
 iperf3	iperf3
 iwd	iwd
@@ -49,6 +56,7 @@ mdadm	Linux MD RAID
 mesa	Mesa
 meson	Meson
 micro	micro editor
+mpv	mpv
 nano	GNU nano
 neovim	Neovim
 networkmanager	NetworkManager
@@ -60,7 +68,9 @@ nouveau	Nouveau (NVIDIA open driver)
 ntfs-3g	NTFS driver
 nvidia-open	NVIDIA open kernel modules
 openssh	OpenSSH
+pandoc	pandoc
 parted	GNU parted
+pass	pass
 perl	Perl
 pipewire	PipeWire
 python	Python
@@ -73,10 +83,15 @@ smartmontools	S.M.A.R.T. tools
 starship	starship prompt
 strace	strace
 sudo	sudo
+syncthing	Syncthing
+tig	tig
 tmux	tmux
 vim	Vim
 vulkan-tools	vulkaninfo
 wget	wget
 wireguard-tools	WireGuard tools
+xdg-utils	xdg-utils
 yq	yq
+yt-dlp	yt-dlp
+zellij	zellij
 zsh	Z shell
diff --git a/lib/setup/sets/daily-drive.set b/lib/setup/sets/daily-drive.set
new file mode 100644
index 0000000..2f6c568
--- /dev/null
+++ b/lib/setup/sets/daily-drive.set
@@ -0,0 +1,5 @@
+name        daily-drive
+title       Daily driver extras
+description Media player, documents, sync, clipboard, and laptop tools on top of the QoL CLI set.
+default     0
+package     daily-drive
diff --git a/lib/setup/sets/multimedia.set b/lib/setup/sets/multimedia.set
index 97f9016..21085c5 100644
--- a/lib/setup/sets/multimedia.set
+++ b/lib/setup/sets/multimedia.set
@@ -1,6 +1,6 @@
 name        multimedia
 title       Multimedia
-description PipeWire, WirePlumber, FFmpeg, and desktop audio/video libraries.
+description PipeWire, WirePlumber, FFmpeg, mpv, and desktop audio/video libraries.
 default     0
 package     pipewire
 package     wireplumber
@@ -11,3 +11,9 @@ package     gst-plugins-good
 package     alsa-lib
 package     alsa-utils
 package     vulkan-loader
+package     mpv
+package     yt-dlp
+package     sox
+package     cmus
+package     libass
+package     dav1d
diff --git a/lib/setup/sets/office.set b/lib/setup/sets/office.set
index aa813b4..3d6c9a2 100644
--- a/lib/setup/sets/office.set
+++ b/lib/setup/sets/office.set
@@ -1,8 +1,14 @@
 name        office
 title       Office and documents
-description Okular, Ark, and related document tools. LibreOffice is not packaged here.
+description Okular, Ark, document viewers, and image tools. LibreOffice is not packaged here.
 default     0
 package     okular
 package     ark
 package     gwenview
 package     spectacle
+package     imagemagick
+package     ghostscript
+package     mupdf
+package     hunspell
+package     pandoc
+package     typst
diff --git a/man/mkpkg.1 b/man/mkpkg.1
index 80f9f36..a5b02ab 100644
--- a/man/mkpkg.1
+++ b/man/mkpkg.1
@@ -164,6 +164,14 @@ pkg-config files
 .BR LIBRARY_PATH ,
 .BR PKG_CONFIG_SYSROOT_DIR ,
 .BR CMAKE_PREFIX_PATH ).
+.B OPENSSL_MODULES
+and
+.B OPENSSL_CONF
+point at providers and
+.B openssl.cnf
+already installed in that root so a just-built Python
+.B _ssl
+module does not load the live disc's OpenSSL.
 Live
 .BR setup (1)
 installs into a mounted target with the live compiler; without this, a
diff --git a/man/sget.1 b/man/sget.1
index ddc3706..b0de196 100644
--- a/man/sget.1
+++ b/man/sget.1
@@ -71,6 +71,14 @@ build dependencies may participate.
 Resolve and install requested packages. Requested roots are explicit; packages
 needed only by the graph are dependency-installed. Exact installed roots are
 promoted to explicit without an unnecessary reinstall.
+Build dependencies are installed only when a package will be compiled.
+If
+.BR cmake (1) ,
+.BR ninja (1) ,
+.BR meson (1) ,
+or another well-known build tool is already on
+.B PATH
+and is not a runtime requirement, that host command is used instead.
 .TP
 .B upgrade
 Resolve the installed explicit world set and upgrade its selected dependency
diff --git a/man/src.1 b/man/src.1
index cdea836..eb946d3 100644
--- a/man/src.1
+++ b/man/src.1
@@ -21,14 +21,19 @@ checkouts together with configured local directory repositories.
 .B update
 Clone absent Git repositories with
 .B --depth 1
-(later fetches stay full), fetch existing clean Git repositories,
-fast-forward safe tracking branches, scan configured trees, validate recipes
-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.
+and
+.B --no-tags
+(later fetches stay full and also skip tags), fetch existing clean Git
+repositories, fast-forward safe tracking branches, scan configured trees,
+validate recipes 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 in one
+.B sha256sum
+batch; a repository is parsed in one AWK process rather than one process
+per recipe. The definition digest is unchanged.
+Progress is written to stderr for each repository, Git clone/fetch, and
+index validation. Recipe hashing logs the first and last package plus every
+25th in large trees so a long update does not look idle.
 .B -q
 keeps only the final summary line on stdout.
 .TP
diff --git a/tests/test_build.sh b/tests/test_build.sh
index 55b3ccb..a3c3cb8 100755
--- a/tests/test_build.sh
+++ b/tests/test_build.sh
@@ -206,7 +206,7 @@ esac
 # installed there. Copying host libasound onto the live ISO would only
 # paper over alsa-utils; the next package would fail the same way.
 if command -v gcc >/dev/null 2>&1; then
-	mkdir -p "$tmp/root/usr/include" "$tmp/root/usr/lib64" \
+	mkdir -p "$tmp/root/usr/include" "$tmp/root/usr/lib64/ossl-modules" \
 		"$tmp/link-recipe" "$tmp/link-out"
 	cat >"$tmp/root/usr/include/spsprobe.h" <<'EOF'
 int sps_probe_lib(void);
@@ -290,11 +290,12 @@ arch        any
 description Confirms just-built binaries load libraries from SPS_ROOT
 configure   printf '%s\n' "$LD_LIBRARY_PATH" >"$WORK/ldpath"
 configure   printf '%s\n' "$SPS_TARGET_LIBRARY_PATH" >"$WORK/targetld"
+configure   printf '%s\n' "${OPENSSL_MODULES-}" >"$WORK/osslmod"
 configure   printf '%s\n' '#include <spsprobe.h>' 'int main(void){return sps_probe_lib();}' >"$WORK/probe.c"
 configure   gcc "$WORK/probe.c" -lspsprobe -o "$WORK/probe"
 configure   "$WORK/probe"
 install     mkdir -p "$PKG/usr/share/target-runtime-lib-probe" "$PKG/usr/bin"
-install     cp "$WORK/ldpath" "$WORK/targetld" "$PKG/usr/share/target-runtime-lib-probe/"
+install     cp "$WORK/ldpath" "$WORK/targetld" "$WORK/osslmod" "$PKG/usr/share/target-runtime-lib-probe/"
 install     cp "$WORK/probe" "$PKG/usr/bin/target-runtime-lib-probe"
 install     chmod 755 "$PKG/usr/bin/target-runtime-lib-probe"
 EOF
@@ -310,6 +311,9 @@ EOF
 	assert_equal "$tmp/root/usr/lib64:$tmp/root/usr/lib:$tmp/root/lib64:$tmp/root/lib" \
 		"$(cat "$tmp/run-extract/usr/share/target-runtime-lib-probe/targetld")" \
 		"SPS_TARGET_LIBRARY_PATH was not the target libdirs"
+	assert_equal "$tmp/root/usr/lib64/ossl-modules" \
+		"$(cat "$tmp/run-extract/usr/share/target-runtime-lib-probe/osslmod")" \
+		"OPENSSL_MODULES was not the target OpenSSL provider directory"
 	[ -x "$tmp/run-extract/usr/bin/target-runtime-lib-probe" ] ||
 		fail "runtime probe binary was not staged"
 fi
diff --git a/tests/test_dependencies.sh b/tests/test_dependencies.sh
index 07569b6..e7e735b 100755
--- a/tests/test_dependencies.sh
+++ b/tests/test_dependencies.sh
@@ -28,6 +28,7 @@ trap 'rm -rf "$tmp"' 0 1 2 3 15
 mkdir -p "$tmp/root" "$tmp/cache" "$tmp/db/installed" "$tmp/build" \
     "$tmp/repo/base" "$tmp/repo/lib" "$tmp/repo/tool" "$tmp/repo/app" \
     "$tmp/repo/left" "$tmp/repo/right" "$tmp/repo/diamond" \
+    "$tmp/repo/openssl" "$tmp/repo/cmake" "$tmp/repo/gui" \
     "$tmp/fakebin"
 : > "$tmp/sps.conf"
 printf 'repo test %s 10\n' "$tmp/repo" > "$tmp/repos.conf"
@@ -58,6 +59,9 @@ make_recipe "$tmp/repo/app" app 4.0 'depend lib>=2.0' 'builddep tool'
 make_recipe "$tmp/repo/left" left 1.0 'depend base'
 make_recipe "$tmp/repo/right" right 1.0 'depend base'
 make_recipe "$tmp/repo/diamond" diamond 1.0 'depend right left'
+make_recipe "$tmp/repo/openssl" openssl 1.0
+make_recipe "$tmp/repo/cmake" cmake 1.0 'depend openssl'
+make_recipe "$tmp/repo/gui" gui 1.0 'depend base' 'builddep cmake'
 mkdir -p "$tmp/repo/app/files" "$tmp/repo/app/patches" \
     "$tmp/repo/app/hooks"
 printf '%s\n' 'default=true' >"$tmp/repo/app/files/app.conf"
@@ -94,10 +98,25 @@ why_path=$("$sget" why diamond base)
 plan=$("$sget" install --plan app)
 plan_names=$(printf '%s\n' "$plan" |
     awk '/^  / { print $2 }')
-expected_names=$(printf '%s\n' base lib tool app)
+expected_names=$(printf '%s\n' tool base lib app)
 [ "$plan_names" = "$expected_names" ] ||
     fail "unexpected dependency order: $plan_names"
 
+if command -v cmake >/dev/null 2>&1; then
+    gui_plan=$("$sget" install --plan gui)
+    case $gui_plan in
+        *cmake*) fail 'host cmake was still planned as a build-only tool' ;;
+        *openssl*) fail 'openssl was pulled only because cmake is a builddep' ;;
+    esac
+    contains "$gui_plan" gui
+    contains "$gui_plan" base
+    gui_err=$("$sget" install --plan gui 2>&1 >/dev/null) || true
+    case $gui_err in
+        *'using host cmake to build gui'*) ;;
+        *) fail "install plan did not report using host cmake: $gui_err" ;;
+    esac
+fi
+
 nodeps_plan=$("$sget" install --plan --nodeps app)
 nodeps_names=$(printf '%s\n' "$nodeps_plan" |
     awk '/^  / { print $2 }')
diff --git a/tests/test_mkiso.sh b/tests/test_mkiso.sh
index 4047d3f..94851eb 100755
--- a/tests/test_mkiso.sh
+++ b/tests/test_mkiso.sh
@@ -357,6 +357,8 @@ grep -q SPS_HOST_LD_LIBRARY_PATH "$project_dir/bin/mkpkg" ||
 	fail 'mkpkg must keep host gcc from loading SPS_ROOT via LD_LIBRARY_PATH'
 grep -q SPS_TARGET_LIBRARY_PATH "$project_dir/bin/mkpkg" ||
 	fail 'mkpkg must put SPS_ROOT on LD_LIBRARY_PATH for just-built test programs'
+grep -q OPENSSL_MODULES "$project_dir/bin/mkpkg" ||
+	fail 'mkpkg must point OpenSSL at SPS_ROOT providers during target builds'
 grep -q CONFIG_SITE "$project_dir/bin/mkpkg" ||
 	fail 'mkpkg must export CONFIG_SITE so autoconf sees SPS_ROOT LDFLAGS'
 grep -q 'sps_say "unpacking' "$project_dir/bin/pkin" ||
@@ -372,8 +374,8 @@ then
 else
 	fail 'extras must not list htop, btop, or tmux more than once'
 fi
-if grep -E '^(mpv|gimp|libreoffice|qemu|screen)[[:space:]]' \
+if grep -E '^(gimp|libreoffice|qemu)[[:space:]]' \
 	"$project_dir/lib/setup/extras"
 then
-	fail 'extras must not offer unpackaged names such as mpv'
+	fail 'extras must not offer unpackaged names such as GIMP or LibreOffice'
 fi
diff --git a/tests/test_repository.sh b/tests/test_repository.sh
index 3ad01a6..c873a49 100755
--- a/tests/test_repository.sh
+++ b/tests/test_repository.sh
@@ -136,6 +136,20 @@ status_output=$("$src" status)
 contains "$status_output" 'core: 2 packages'
 contains "$status_output" 'extra: 1 packages'
 
+# Batched recipe-only hashing and the support-tree hasher must agree with
+# sps_definition_sha256 so a large extra tree does not change index bytes.
+. "$project_dir/lib/common.sh"
+sps_program=test_repository
+tie_digest=$(sps_definition_sha256 "$tmp/core/base/tie/recipe") ||
+    fail 'could not hash recipe-only tie'
+alpha_digest=$(sps_definition_sha256 "$tmp/core/base/alpha/recipe") ||
+    fail 'could not hash support-tree alpha'
+[ "$(awk -F '\t' '$1 == "tie" { print $12; exit }' "$index")" = "$tie_digest" ] ||
+    fail 'indexed tie digest does not match sps_definition_sha256'
+[ "$(awk -F '\t' '$1 == "alpha" && $8 == "core" { print $12; exit }' "$index")" = \
+    "$alpha_digest" ] ||
+    fail 'indexed alpha digest does not match sps_definition_sha256'
+
 old_index=$(cksum "$index")
 cp "$tmp/repos.conf" "$tmp/repos.good"
 
diff --git a/tests/test_src_check.sh b/tests/test_src_check.sh
index aee1ccc..16c7560 100644
--- a/tests/test_src_check.sh
+++ b/tests/test_src_check.sh
@@ -54,6 +54,12 @@ $src check "$tmp/ok/recipe" | grep -q '^check: ok ' ||
 # A directory argument resolves to its recipe file.
 $src check "$tmp/ok/recipe" >/dev/null 2>&1 || fail "file recipe rejected"
 
+# Paths with spaces must not be word-split.
+mkdir -p "$tmp/ok dir"
+cp "$tmp/ok/recipe" "$tmp/ok dir/recipe"
+$src check "$tmp/ok dir" | grep -q '^check: ok ' ||
+    fail "recipe path with a space was not accepted"
+
 # Missing the required install command is a syntax failure.
 cat >"$tmp/bad/recipe" <<'EOF'
 name        badpkg