#!/bin/sh # USM system-package-manager helper for pacman-based systems (Arch). # # Implements the USM SPM contract (see usm/README.md, "System package # manager integration") for Arch-style systems: # # usm-spm-pacman query ... -> contract JSON on STDOUT # usm-spm-pacman install ... -> contract JSONL events on STDOUT # # API choice: pure POSIX shell on purpose -- the arch base group ships # neither python3 nor jq, and the SPM bootstrap must stay at zero extra # packages. bsdtar (libarchive, a hard pacman dependency) decompresses # the repository databases under /var/lib/pacman/sync so provides and # file lists are parsed directly instead of shelling pattern queries # through pacman: # # lib: X -> the sync dbs' %PROVIDES% entries; makepkg auto- # generates soname provides ("libfoo.so=2-64"), so the # "=major-arch" suffix is stripped before matching; # file ownership of /usr/lib/X is the fallback # everything file-backed -> # the opt-in files databases (.files, fetched by # `pacman -Fy` -- the arch analogue of apt-file update) # are parsed into a path -> package map; without them, # only files of INSTALLED packages resolve (via # `pacman -Qo`), the same offline stance as the emerge # helper. No package-name heuristics are used. # # dependency-count / installed-dependency-count model "a solo install on # a from-scratch machine": the closure comes from `pacman -S --print` # run against a throwaway --dbpath whose local database is empty and # whose sync databases symlink the system's, so every dependency # resolves as an install; the overlap with the installed set # (`pacman -Qq`) is installed-dependency-count. If that run cannot # resolve, the counts degrade to a (1, n) estimate with a warning. # # pacman exposes no per-download progress on a non-tty stdout; it names # each package once ("(k/N) installing pkg" in older versions, # "installing pkg..." since pacman 7), so "package" events are emitted # at package granularity (progress 1.0) and begin.total comes from a # `pacman -S --print` pass against the current system. Install runs # with --needed, making repeated installs idempotent. # # No INSTALL_COMPANIONS table is needed: arch does not split toolchain # packages, glibc already ships the crt objects. # # The query subcommand never modifies system state: databases are read # from their normal locations, and the only writes land in a throwaway # temp directory. The install subcommand must run as root; --noconfirm # guarantees pacman never prompts interactively: unresolved targets or # bad signatures fail instead. # # Arch path table (merged-usr layout, no multiarch): /usr/bin (sbin is # a symlink), /usr/lib (lib64 is a symlink), /usr/include, /usr/share # (+/usr/share/pkgconfig), /usr/share/vala*/vapi. set -eu export LC_ALL=C PROG=usm-spm-pacman EXIT_OK=0 EXIT_FAILURE=1 EXIT_USAGE=2 EXIT_RESOLVE=3 EXIT_DOWNLOAD=4 EXIT_TRANSACTION=5 WORK="" SYNC_DIR=/var/lib/pacman/sync REPO_INDEXED=0 cleanup() { if [ -n "$WORK" ]; then rm -rf "$WORK" fi } warn() { printf '%s: %s\n' "$PROG" "$*" >&2 } die_usage() { printf 'usage: %s query ...\n' "$PROG" >&2 printf ' %s install ...\n' "$PROG" >&2 exit "$EXIT_USAGE" } emit() { printf '%s\n' "$1" } json_escape() { printf '%s' "$1" | awk ' { s = $0 gsub(/\\/, "\\\\", s) gsub(/"/, "\\\"", s) gsub(/\t/, "\\t", s) gsub(/[[:cntrl:]]/, "", s) print s }' } mkwork() { WORK=$(mktemp -d "${TMPDIR:-/tmp}/usm-spm-pacman.XXXXXX" 2>/dev/null) || { WORK="${TMPDIR:-/tmp}/usm-spm-pacman.$$" (umask 077 && mkdir "$WORK") } trap cleanup EXIT } not_found_add() { printf '%s\n' "$1" >>"$WORK/nf" } map_add() { printf '%s\t%s\n' "$1" "$2" >>"$WORK/map" } # Parse the sync/files databases ONCE per invocation into flat lookup # tables: $WORK/provides maps "soname-or-virtualpackage" (the # "=major-arch" suffix is stripped from soname provides so lib: refs # carrying the full soname match exactly), $WORK/filemap maps # "path-without-leading-slashpackage" from the %FILES% sections of # the .files databases. Both stay empty (and the lookups degrade to the # installed database) when the databases are missing. build_repo_index() { [ "$REPO_INDEXED" = 0 ] || return 0 REPO_INDEXED=1 : >"$WORK/provides" : >"$WORK/filemap" if ! command -v bsdtar >/dev/null 2>&1; then warn "bsdtar not found in PATH; repository lookups are unavailable" return 0 fi _ri_any=0 for _ri_db in "$SYNC_DIR"/*.db; do [ -f "$_ri_db" ] || continue _ri_any=1 bsdtar -xOf "$_ri_db" 2>>"$WORK/index.err" | awk ' /^%NAME%$/ { mode = "name"; next } /^%PROVIDES%$/ { mode = "prov"; next } /^%/ { mode = ""; next } NF == 0 { next } mode == "name" { pkg = $1; mode = ""; next } mode == "prov" && pkg != "" { p = $1 sub(/=.*$/, "", p) print pkg "\t" p } ' >>"$WORK/provides" || true done for _ri_db in "$SYNC_DIR"/*.files; do [ -f "$_ri_db" ] || continue _ri_any=1 bsdtar -xOf "$_ri_db" 2>>"$WORK/index.err" | awk ' /^%NAME%$/ { mode = "name"; next } /^%FILES%$/ { mode = "files"; next } /^%/ { mode = ""; next } NF == 0 { next } mode == "name" { pkg = $1; mode = ""; next } mode == "files" && pkg != "" { print pkg "\t" $1 } ' >>"$WORK/filemap" || true done if [ "$_ri_any" != 1 ]; then warn "no databases under $SYNC_DIR (run pacman -Sy as root); repository lookups are unavailable" elif [ ! -s "$WORK/filemap" ]; then warn "no files databases under $SYNC_DIR (run pacman -Fy as root); only installed files resolve" fi } # makepkg turns soname "libc.so.6" into the provide "libc.so=6-64", so a # lib: ref's trailing "." is optionally stripped before matching: # "lib:libc.so.6" matches "libc.so" exactly as an unversioned # "lib:libc.so" would. repo_provider_owners() { awk -F'\t' -v p="$1" ' { r = p sub(/\.[0-9]+$/, "", r) if ($2 == p || ($2 == r && r != p)) { print $1 } }' "$WORK/provides" 2>/dev/null } repo_file_owners() { awk -F'\t' -v p="$1" '$2 == p { print $1 }' "$WORK/filemap" 2>/dev/null } repo_file_prefix_owners() { # exact file OR any file below the directory prefix awk -F'\t' -v p="$1" '$2 == p || index($2, p "/") == 1 { print $1 }' \ "$WORK/filemap" 2>/dev/null } # Map every owner name read from STDIN onto _om_ref; succeeds when at # least one name arrived. owners_to_map() { _om_ref=$1 _om_any=0 while IFS= read -r _om_nm; do [ -n "$_om_nm" ] || continue map_add "$_om_nm" "$_om_ref" _om_any=1 done [ "$_om_any" = 1 ] } pacman_diag_line() { [ -r "$1" ] || return 0 sed -n 's/^[[:space:]]*error:[[:space:]]*//p' "$1" | head -n 1 } record_providers() { _rp_ref=$1 _rp_prov=$2 build_repo_index if repo_provider_owners "$_rp_prov" | sort -u | owners_to_map "$_rp_ref"; then return 0 fi not_found_add "$_rp_ref" } record_file_owner() { _fo_ref=$1 shift build_repo_index _fo_hit=0 for _fo_p in "$@"; do _fo_names= if [ -s "$WORK/filemap" ]; then _fo_names=$(repo_file_owners "${_fo_p#/}" | sort -u) fi if [ -z "$_fo_names" ]; then # installed-database fallback, exact for present files only _fo_f= if [ -d "$_fo_p" ]; then _fo_f=$(find "$_fo_p" -type f 2>/dev/null | head -n 1) || _fo_f= elif [ -f "$_fo_p" ]; then _fo_f=$_fo_p fi if [ -n "$_fo_f" ]; then _fo_names=$(pacman -Qo -- "$_fo_f" 2>/dev/null \ | awk '{ print $(NF-1) }') || _fo_names= fi fi if [ -n "$_fo_names" ]; then printf '%s\n' "$_fo_names" | owners_to_map "$_fo_ref" && _fo_hit=1 fi done if [ "$_fo_hit" != 1 ]; then not_found_add "$_fo_ref" fi } record_dir_owner() { # Like record_file_owner, but the ref names a directory (inc:), so # any file at or below the prefix satisfies it. _do_ref=$1 _do_p=$2 build_repo_index if [ -s "$WORK/filemap" ]; then if repo_file_prefix_owners "${_do_p#/}" | sort -u | owners_to_map "$_do_ref"; then return 0 fi fi record_file_owner "$_do_ref" "$_do_p" } handle_ref() { _hr_ref=$1 _hr_type=${_hr_ref%%:*} _hr_res=${_hr_ref#*:} if [ "$_hr_type" = "$_hr_ref" ] || [ -z "$_hr_res" ]; then warn "resource type of \"$_hr_ref\" has no system-package-manager translation" not_found_add "$_hr_ref" return fi case $_hr_res in *[!A-Za-z0-9._+/-]*) warn "resource name of \"$_hr_ref\" has no system-package-manager translation" not_found_add "$_hr_ref" return ;; esac case $_hr_type in bin) record_file_owner "$_hr_ref" "/usr/bin/$_hr_res" ;; sbin) record_file_owner "$_hr_ref" "/usr/bin/$_hr_res" "/usr/sbin/$_hr_res" ;; lib|libres) # soname provides first (exact, suffix-stripped), file second build_repo_index if ! repo_provider_owners "$_hr_res" | sort -u | owners_to_map "$_hr_ref"; then record_file_owner "$_hr_ref" "/usr/lib/$_hr_res" fi ;; pc) record_file_owner "$_hr_ref" \ "/usr/lib/pkgconfig/$_hr_res" "/usr/share/pkgconfig/$_hr_res" ;; libexec) record_file_owner "$_hr_ref" "/usr/libexec/$_hr_res" ;; gir) record_file_owner "$_hr_ref" "/usr/share/gir-1.0/$_hr_res" ;; typelib) record_file_owner "$_hr_ref" "/usr/lib/girepository-1.0/$_hr_res" ;; gio) record_file_owner "$_hr_ref" "/usr/lib/gio/modules/$_hr_res" ;; res) record_file_owner "$_hr_ref" "/usr/share/$_hr_res" ;; cfg) record_file_owner "$_hr_ref" "/etc/$_hr_res" ;; man) record_file_owner "$_hr_ref" "/usr/share/man/$_hr_res" ;; info) record_file_owner "$_hr_ref" "/usr/share/info/$_hr_res" ;; locale) record_file_owner "$_hr_ref" "/usr/share/locale/$_hr_res" ;; inc) record_dir_owner "$_hr_ref" "/usr/include/$_hr_res" ;; vapi) build_repo_index if [ -s "$WORK/filemap" ]; then : >"$WORK/vown" repo_file_owners "usr/share/vala/vapi/$_hr_res" >>"$WORK/vown" awk -F'\t' -v suf="/vapi/$_hr_res" \ 'substr($2, 1, 15) == "usr/share/vala" && substr($2, length($2) - length(suf) + 1) == suf { print $1 }' \ "$WORK/filemap" >>"$WORK/vown" if sort -u "$WORK/vown" | owners_to_map "$_hr_ref"; then return 0 fi fi set -- "/usr/share/vala/vapi/$_hr_res" for _hr_d in /usr/share/vala-*/vapi/"$_hr_res"; do if [ -f "$_hr_d" ]; then set -- "$@" "$_hr_d" fi done record_file_owner "$_hr_ref" "$@" ;; rootpath) record_file_owner "$_hr_ref" "/$_hr_res" ;; tag) _hr_t=${_hr_res%.tag} record_file_owner "$_hr_ref" \ "/usr/share/usm-tags/$(printf '%s' "$_hr_t" | tr '.' '/').tag" ;; *) warn "resource type of \"$_hr_ref\" has no system-package-manager translation" not_found_add "$_hr_ref" return ;; esac } counts_for() { _cf_name=$1 _cf_cached= if [ -s "$WORK/counts" ]; then _cf_cached=$(awk -F'\t' -v n="$_cf_name" '$1 == n { print $2 " " $3; exit }' "$WORK/counts") || _cf_cached= fi if [ -n "$_cf_cached" ]; then printf '%s\n' "$_cf_cached" return fi # Repo-only closure: a throwaway database whose empty local db makes # every package resolve as an install and whose sync symlink reuses # the system's repositories. _cf_root="$WORK/rootdb" if [ ! -d "$_cf_root" ]; then mkdir -p "$_cf_root/local" ln -s "$SYNC_DIR" "$_cf_root/sync" 2>/dev/null || true fi _cf_dep=0 _cf_inst=0 _cf_cl=$(pacman --dbpath "$_cf_root" -S --print --print-format '%n' \ --noconfirm -- "$_cf_name" 2>"$WORK/cnt.err" | grep -E '^[A-Za-z0-9@._+-]+$' || true) if [ -n "$_cf_cl" ]; then printf '%s\n' "$_cf_cl" >"$WORK/cl" _cf_dep=$(awk 'END { print NR }' "$WORK/cl") if [ -s "$WORK/installed" ]; then _cf_inst=$(grep -x -F -f "$WORK/installed" "$WORK/cl" | awk 'END { print NR }') || _cf_inst=0 fi else warn "could not resolve solo install of $_cf_name, estimating dependency counts" if grep -qx -F "$_cf_name" "$WORK/installed" 2>/dev/null; then _cf_dep=1 _cf_inst=1 else _cf_dep=1 _cf_inst=0 fi fi printf '%s\t%s\t%s\n' "$_cf_name" "$_cf_dep" "$_cf_inst" >>"$WORK/counts" printf '%s %s\n' "$_cf_dep" "$_cf_inst" } cmd_query() { [ $# -ge 1 ] || die_usage mkwork pacman -Qq >"$WORK/installed" 2>/dev/null || : >"$WORK/installed" : >"$WORK/map" : >"$WORK/nf" : >"$WORK/counts" : >"$WORK/seen" for _q_ref in "$@"; do if [ -n "$_q_ref" ] && grep -x -F -q "$_q_ref" "$WORK/seen" 2>/dev/null; then continue fi printf '%s\n' "$_q_ref" >>"$WORK/seen" handle_ref "$_q_ref" done _q_out='{"not-found":[' _q_first=1 while IFS= read -r _q_r; do [ -n "$_q_r" ] || continue if [ "$_q_first" = 1 ]; then _q_first=0; else _q_out="$_q_out,"; fi _q_out="$_q_out\"$(json_escape "$_q_r")\"" done <"$WORK/nf" _q_out="$_q_out],\"packages\":[" _q_first=1 for _q_nm in $(awk -F'\t' '{ print $1 }' "$WORK/map" | sort -u); do if [ "$_q_first" = 1 ]; then _q_first=0; else _q_out="$_q_out,"; fi awk -F'\t' -v n="$_q_nm" '$1 == n { print $2 }' "$WORK/map" >"$WORK/tmpres" _q_res="" _q_rf=1 while IFS= read -r _q_rr; do if [ "$_q_rf" = 1 ]; then _q_rf=0; else _q_res="$_q_res,"; fi _q_res="$_q_res\"$(json_escape "$_q_rr")\"" done <"$WORK/tmpres" _q_cnts=$(counts_for "$_q_nm") _q_dep=${_q_cnts%% *} _q_inst=${_q_cnts##* } _q_out="$_q_out{\"name\":\"$(json_escape "$_q_nm")\",\"resources\":[${_q_res}],\"dependency-count\":$_q_dep,\"installed-dependency-count\":$_q_inst}" done _q_out="$_q_out]}" emit "$_q_out" exit "$EXIT_OK" } classify_failure() { if grep -Eqi 'target not found|could not satisfy dependencies|conflicting|unresolvable|failed to prepare transaction|hold onto' "$1" 2>/dev/null; then printf '%s\n' "$EXIT_RESOLVE" elif grep -Eqi 'network|download|retriev|signature|keyring|gpg|invalid or corrupted|checksum|integrity|connection|timed out|mirror' "$1" 2>/dev/null; then printf '%s\n' "$EXIT_DOWNLOAD" else printf '%s\n' "$EXIT_TRANSACTION" fi } cmd_install() { [ $# -ge 1 ] || die_usage for _i_n in "$@"; do case $_i_n in -*) die_usage ;; esac done mkwork _i_simrc=0 _i_sim=$(pacman -S --needed --noconfirm --print --print-format '%n' \ -- "$@" 2>"$WORK/sim.err") || _i_simrc=$? if [ "$_i_simrc" != 0 ]; then _i_msg=$(pacman_diag_line "$WORK/sim.err") [ -n "$_i_msg" ] || _i_msg="pacman --print failed with status $_i_simrc" emit "{\"type\":\"error\",\"message\":\"$(json_escape "$_i_msg")\"}" exit "$(classify_failure "$WORK/sim.err")" fi _i_total=$(printf '%s\n' "$_i_sim" | grep -Ec '^[A-Za-z0-9@._+-]+$') || _i_total=0 emit "{\"type\":\"begin\",\"total\":$_i_total}" { # || keeps set -e from aborting the pipeline group before the # status reaches the rc file _i_status=0 pacman -S --needed --noconfirm --noprogressbar -- "$@" \ 2>"$WORK/inst.err" || _i_status=$? printf '%s\n' "$_i_status" >"$WORK/rc" } | tr '\r' '\n' | { _i_done=0 _i_lastpkg= while IFS= read -r _i_line || [ -n "$_i_line" ]; do # pacman's transaction output names each package exactly # once: older versions redraw "(k/N) installing pkg" lines # (\r-flattened here), pacman 7 pipes plain "installing # pkg..." lines. Dedupe on the package name either way. case $_i_line in \(*\)\ *) _i_kn=${_i_line#"("} _i_kn=${_i_kn%%")"*} _i_rest=${_i_line#*") "} _i_verb=${_i_rest%% *} _i_pkg=${_i_rest#* } _i_pkg=${_i_pkg%% *} _i_k=${_i_kn%%/*} _i_n=${_i_kn##*/} ;; installing\ *|upgrading\ *|downgrading\ *|reinstalling\ *) _i_verb=${_i_line%% *} _i_pkg=${_i_line#* } _i_pkg=${_i_pkg%% *} _i_pkg=${_i_pkg%"..."} _i_k=$((_i_done + 1)) _i_n=$_i_total ;; *) continue ;; esac case $_i_verb in installing|upgrading|downgrading|reinstalling) if [ "$_i_pkg" != "$_i_lastpkg" ]; then _i_lastpkg=$_i_pkg emit "{\"type\":\"package\",\"name\":\"$(json_escape "$_i_pkg")\",\"current\":$_i_k,\"total\":$_i_n,\"progress\":1.0}" emit "{\"type\":\"package-complete\",\"name\":\"$(json_escape "$_i_pkg")\"}" _i_done=$((_i_done + 1)) fi ;; esac done printf '%s\n' "$_i_done" >"$WORK/done" } _i_rc=$(cat "$WORK/rc" 2>/dev/null) || _i_rc=1 if [ "$_i_rc" = 0 ]; then _i_done=$(cat "$WORK/done" 2>/dev/null) || _i_done=0 emit "{\"type\":\"complete\",\"status\":\"ok\",\"installed\":$_i_done}" exit "$EXIT_OK" fi _i_msg=$(pacman_diag_line "$WORK/inst.err") [ -n "$_i_msg" ] || _i_msg="pacman -S failed with status $_i_rc" emit "{\"type\":\"error\",\"message\":\"$(json_escape "$_i_msg")\"}" exit "$(classify_failure "$WORK/inst.err")" } main() { if [ $# -lt 1 ]; then die_usage fi if ! command -v pacman >/dev/null 2>&1; then if [ "$1" = install ]; then emit '{"type":"error","message":"pacman not found in PATH"}' exit "$EXIT_FAILURE" fi warn "pacman not found in PATH" exit "$EXIT_RESOLVE" fi _m_cmd=$1 shift case $_m_cmd in query) cmd_query "$@" ;; install) cmd_install "$@" ;; -h|--help|help) printf 'usage: %s query ...\n' "$PROG" printf ' %s install ...\n' "$PROG" exit "$EXIT_OK" ;; *) die_usage ;; esac } main "$@"