Sfoglia il codice sorgente

feat: pacman (Arch) support — installer PM + shipped usm-spm-pacman SPM shim

- spm/pacman/usm-spm-pacman: POSIX-shell SPM helper (apk-style, zero
  extra packages): bsdtar parses the sync DBs for soname provides and
  the .files DBs for file queries (pacman -Qo fallback, no naming
  heuristics), counts come from a --print run against a throwaway
  --dbpath with an empty local db, install maps --needed --noconfirm
  onto the JSONL contract (pacman 7 plain 'installing pkg...' lines and
  older '(k/N)' redraws both parsed)
- installer: pm_pacman.sh (no -dev split, glibc ships crt), pm_base
  dispatch, detection message, regenerated install-usm.sh payload
- deploy --spm pacman: merged-usr lib layout, bootstrap RUN installs
  curl and refreshes the files DBs via pacman -Fy
- genconfig detects pacman; MANIFEST provides bin:usm-spm-pacman;
  containers/arch/Dockerfile; README helper table + --spm docs

Tested end-to-end in an archlinux container: installer, genconfig
wiring, query (provides/files/Qo fallback) and install contract against
real pacman 7.
clanker 4 giorni fa
parent
commit
f87bea6630

+ 1 - 0
MANIFEST.usm

@@ -16,6 +16,7 @@
     "bin:usm-spm-apt": "as-expected",
     "bin:usm-spm-apk": "as-expected",
     "bin:usm-spm-emerge": "as-expected",
+    "bin:usm-spm-pacman": "as-expected",
     "res:slopdocs/structure.usm.manifest.md": "source:slopdocs/structure.usm.manifest.md",
     "res:slopdocs/utility.usm.manifest.validate.md": "source:slopdocs/utility.usm.manifest.validate.md",
     "res:slopdocs/structure.usm.manifest.packaging.md": "source:slopdocs/structure.usm.manifest.packaging.md",

+ 4 - 3
README.md

@@ -308,7 +308,7 @@ For detailed information, see `slopdocs/utility.usm.manifest.validate.md`.
 
 ## System package manager integration
 
-USM can delegate missing resources to the system package manager (DNF and friends) before falling back to USM repositories. The integration is configured in `usm.config` and talks to a helper executable through two stable contracts; four reference helpers ship under `spm/` (installed to `bindir` as `usm-spm-<spm>`):
+USM can delegate missing resources to the system package manager (DNF and friends) before falling back to USM repositories. The integration is configured in `usm.config` and talks to a helper executable through two stable contracts; five reference helpers ship under `spm/` (installed to `bindir` as `usm-spm-<spm>`):
 
 | Helper | Distro(s) | Language | Container bootstrap | Limitations |
 |---|---|---|---|---|
@@ -316,8 +316,9 @@ USM can delegate missing resources to the system package manager (DNF and friend
 | `spm/apt/usm-spm-apt` | Debian, Ubuntu | Python 3 + python3-apt | `apt-get update && apt-get install -y curl python3 python3-apt apt-file && apt-file update && apt-get clean` | file queries go through **apt-file only** (no naming heuristics), so the contents index downloaded by `apt-file update` (~100 MB) is mandatory; paths follow Debian multiarch (`/usr/lib/x86_64-linux-gnu`, both pkgconfig dirs) |
 | `spm/apk/usm-spm-apk` | Alpine | POSIX shell (busybox ash) | `apk add --no-cache bash curl` | dependency-free — no python3, no jq; queries ride apk's native `pc:`/`so:` provides and the `/lib` (not lib64) busybox layout |
 | `spm/emerge/usm-spm-emerge` | Gentoo | Python 3 + portage | none — the stage3 base already carries portage + python | offline ebuilds expose no file lists: file queries for not-yet-installed packages resolve only via installed files, virtuals and a small package-name table, otherwise land in `not-found`; dependency counts come from `emerge --pretend` (see the `usm-spm-emerge` docstring for the full limits) |
+| `spm/pacman/usm-spm-pacman` | Arch | POSIX shell (bash) | `pacman -Sy --noconfirm curl && pacman -Fy --noconfirm && rm -rf /var/cache/pacman/pkg/*` | dependency-free like the apk helper: bsdtar (a pacman dependency) parses the sync databases for soname provides and the **files databases** for file queries, so the `pacman -Fy` refresh (~40 MB) is mandatory — without it only installed files resolve (`pacman -Qo`), no naming heuristics; dependency counts come from a `--print` run against a throwaway `--dbpath`; merged-usr `/usr/lib` layout |
 
-All four implement the identical JSON/JSONL contract below; `usm deploy --spm` (see [Container deployment](#container-deployment)) wires the matching one into generated images.
+All five implement the identical JSON/JSONL contract below; `usm deploy --spm` (see [Container deployment](#container-deployment)) wires the matching one into generated images.
 
 ### Configuration
 
@@ -376,7 +377,7 @@ usm deploy example-app-1.0.0.usmc                    # package form
 |---|---|
 | `--exec CMD` | Container command, split on whitespace into the exec-form ENTRYPOINT. Default: the package's single `bin:` provide as `/usr/bin/<name>`; zero or several `bin:` provides without `--exec` is an error reported before anything is generated |
 | `--base IMAGE` | Base image (default `registry.fedoraproject.org/fedora:43`, declared as `DEPLOY_DEFAULT_BASE_IMAGE` in `src/cli/Deploy.vala`) |
-| `--spm dnf\|apt\|apk\|emerge\|none` | System package manager wired into the image: the generated `usm.config` points `system_package_manager` at the `usm-spm-<spm>` helper the installer ships and sets the install `lib` directory per target (`lib` for the musl/Alpine and Debian-multiarch layouts, `lib64` otherwise), and the Containerfile gains that SPM's bootstrap RUN (dnf installs `python3-dnf`; apt installs `curl python3 python3-apt apt-file` and refreshes the apt-file index; apk adds `bash curl`; emerge needs nothing — stage3 carries portage; the curl installs cover base images that ship no fetcher for the USM installer). Default **none**: no `system_package_manager` section and no bootstrap RUN, so the image resolves everything from USM repositories alone (repo-only resolution — anything the repositories cannot provide fails the build) |
+| `--spm dnf\|apt\|apk\|emerge\|pacman\|none` | System package manager wired into the image: the generated `usm.config` points `system_package_manager` at the `usm-spm-<spm>` helper the installer ships and sets the install `lib` directory per target (`lib` for the musl/Alpine, Debian-multiarch and Arch merged-usr layouts, `lib64` otherwise), and the Containerfile gains that SPM's bootstrap RUN (dnf installs `python3-dnf`; apt installs `curl python3 python3-apt apt-file` and refreshes the apt-file index; apk adds `bash curl`; emerge needs nothing — stage3 carries portage; pacman installs `curl` and refreshes the files databases with `pacman -Fy`; the curl installs cover base images that ship no fetcher for the USM installer). Default **none**: no `system_package_manager` section and no bootstrap RUN, so the image resolves everything from USM repositories alone (repo-only resolution — anything the repositories cannot provide fails the build) |
 | `--repository FILE` | Use exactly the given `.usmr` files (repeatable) instead of the machine-configured repositories (`$USM_CONFIGDIR/repos.d`, by default `/etc/usm/repos.d`) |
 | `--no-build` | Stop after generating the context |
 | `--installer-url URL` | Override the canonical USM installer source; the `file://` form is the sanctioned local-testing path (see below) |

+ 14 - 0
containers/arch/Dockerfile

@@ -0,0 +1,14 @@
+FROM archlinux:latest
+
+# Copy the installer
+COPY install-builddir/install-usm.sh /tmp/install-usm.sh
+RUN chmod +x /tmp/install-usm.sh
+
+# Run the installer non-interactively
+RUN /tmp/install-usm.sh -y
+
+# Clean up installer
+RUN rm /tmp/install-usm.sh
+
+# Default command
+CMD ["/usr/bin/usm"]

File diff suppressed because it is too large
+ 67 - 1183
installer/install-usm.sh


+ 1 - 1
installer/main.sh

@@ -191,7 +191,7 @@ main() {
     
     # Detect package manager
     if ! detect_package_manager; then
-        show_error_and_exit "Could not detect a supported package manager (apk, apt, dnf or emerge)"
+        show_error_and_exit "Could not detect a supported package manager (apk, apt, dnf, emerge or pacman)"
     fi
     
     log_info "Detected package manager: ${PM_TYPE}"

+ 1 - 0
installer/meson.build

@@ -28,6 +28,7 @@ installer_sources = files(
     'pm_apt.sh',
     'pm_dnf.sh',
     'pm_emerge.sh',
+    'pm_pacman.sh',
     'build_config.sh',
     'main.sh'
 )

+ 2 - 0
installer/pm_base.sh

@@ -33,6 +33,7 @@ pm_get_missing_deps() {
         apt) pm_apt_get_missing_deps "$@" ;;
         dnf) pm_dnf_get_missing_deps "$@" ;;
         emerge) pm_emerge_get_missing_deps "$@" ;;
+        pacman) pm_pacman_get_missing_deps "$@" ;;
         *)
             log_error "No package manager detected"
             echo ""
@@ -47,6 +48,7 @@ pm_install_missing_deps() {
         apt) pm_apt_install_missing_deps "$@" ;;
         dnf) pm_dnf_install_missing_deps "$@" ;;
         emerge) pm_emerge_install_missing_deps "$@" ;;
+        pacman) pm_pacman_install_missing_deps "$@" ;;
         *)
             log_error "No package manager detected"
             return 1

+ 60 - 0
installer/pm_pacman.sh

@@ -0,0 +1,60 @@
+#!/bin/bash
+# pm_pacman.sh - pacman package manager implementation for Arch systems
+#
+# To add a new package manager, create a file named pm_<name>.sh with:
+#  - pm_<name>_detect: Returns 0 if this PM is available, 1 otherwise
+#  - pm_<name>_get_missing_deps: Prints space-separated list of missing packages
+#  - pm_<name>_install_missing_deps: Installs the missing packages
+#
+# Arch notes:
+#  - PACMAN_DEPS mirror the dnf/apt dependency sets. Arch carries no
+#    -dev split: glib2, json-glib, libgee, gobject-introspection ship
+#    headers, pkg-config, .gir/.typelib and .vapi artifacts themselves,
+#    and glibc already bundles the crt objects gcc needs, so no
+#    companion package (musl-dev style) is required.
+
+# Package names required by USM
+PACMAN_DEPS="vala meson ninja pkgconf gcc glib2 libsodium json-glib libarchive libgee xz gobject-introspection"
+
+# Check if this package manager is available
+pm_pacman_detect() {
+    command -v pacman &>/dev/null
+}
+
+# Get list of missing dependencies
+pm_pacman_get_missing_deps() {
+    local -a missing=()
+
+    for pkg in $PACMAN_DEPS; do
+        if ! pacman -Q "$pkg" &>/dev/null; then
+            missing+=("$pkg")
+        fi
+    done
+
+    echo "${missing[*]}"
+}
+
+# Install missing dependencies
+pm_pacman_install_missing_deps() {
+    local missing=$(pm_pacman_get_missing_deps)
+
+    if [[ -z "$missing" ]]; then
+        log_info "All dependencies are already installed"
+        return 0
+    fi
+
+    log_step "Installing packages via pacman: ${missing}"
+
+    local sudo=""
+    if ! is_root; then
+        sudo=$(get_sudo)
+    fi
+
+    local pacman_opts="--needed --noconfirm"
+
+    if [[ -n "$sudo" ]]; then
+        $sudo pacman -Sy $pacman_opts $missing
+    else
+        pacman -Sy $pacman_opts $missing
+    fi
+}

+ 583 - 0
spm/pacman/usm-spm-pacman

@@ -0,0 +1,583 @@
+#!/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 <usm-ref>...        -> contract JSON on STDOUT
+#     usm-spm-pacman install <native-name>...  -> 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 (<repo>.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 <usm-ref>...\n' "$PROG" >&2
+    printf '       %s install <native-name>...\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-virtual<TAB>package" (the
+# "=major-arch" suffix is stripped from soname provides so lib: refs
+# carrying the full soname match exactly), $WORK/filemap maps
+# "path-without-leading-slash<TAB>package" 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 ".<major>" 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 <usm-ref>...\n' "$PROG"
+            printf '       %s install <native-name>...\n' "$PROG"
+            exit "$EXIT_OK"
+            ;;
+        *)
+            die_usage
+            ;;
+    esac
+}
+
+main "$@"

+ 19 - 8
src/cli/Deploy.vala

@@ -36,6 +36,7 @@ private enum DeploySpm {
     APT,
     APK,
     EMERGE,
+    PACMAN,
     NONE;
 
     /**
@@ -57,11 +58,14 @@ private enum DeploySpm {
             case "emerge":
                 parsed = DeploySpm.EMERGE;
                 return true;
+            case "pacman":
+                parsed = DeploySpm.PACMAN;
+                return true;
             case "none":
                 parsed = DeploySpm.NONE;
                 return true;
             default:
-                printerr(@"\"$value\" is not a valid --spm value (expected dnf, apt, apk, emerge or none)\n");
+                printerr(@"\"$value\" is not a valid --spm value (expected dnf, apt, apk, emerge, pacman or none)\n");
                 return false;
         }
     }
@@ -77,6 +81,8 @@ private enum DeploySpm {
                 return "apk";
             case DeploySpm.EMERGE:
                 return "emerge";
+            case DeploySpm.PACMAN:
+                return "pacman";
             default:
                 return "none";
         }
@@ -84,13 +90,14 @@ private enum DeploySpm {
 
     /**
      * The library directory the generated config installs `lib:`
-     * resources under: apk targets musl/Alpine and apt targets Debian
-     * multiarch, neither of which searches /usr/lib64 (their loaders and
-     * pkg-config defaults resolve /usr/lib); the rpm and portage targets
-     * follow the lib64 convention.
+     * resources under: apk targets musl/Alpine, apt targets Debian
+     * multiarch and pacman targets the merged-usr Arch layout, none of
+     * which searches /usr/lib64 as its native directory (their loaders
+     * and pkg-config defaults resolve /usr/lib); the rpm and portage
+     * targets follow the lib64 convention.
      */
     public string lib_path() {
-        return this == DeploySpm.APK || this == DeploySpm.APT ? "lib" : "lib64";
+        return this == DeploySpm.APK || this == DeploySpm.APT || this == DeploySpm.PACMAN ? "lib" : "lib64";
     }
 }
 
@@ -222,7 +229,7 @@ public int deploy_main(string[] args) {
  *   exec-form ENTRYPOINT. Default: the package's single `bin:` provide
  *   (an error when there are zero or several).
  * - `--base IMAGE`: base image (default {@link DEPLOY_DEFAULT_BASE_IMAGE}).
- * - `--spm dnf|apt|apk|emerge|none`: the system package manager wired into
+ * - `--spm dnf|apt|apk|emerge|pacman|none`: the system package manager wired into
  *   the image via the matching `usm-spm-<spm>` helper and a per-SPM
  *   bootstrap RUN in the Containerfile. Default `none`: no
  *   `system_package_manager` section and no bootstrap RUN, so the image
@@ -469,7 +476,7 @@ public int manifest_deploy(string[] args) {
 
 
 private int deploy_usage() {
-    printerr("USAGE:\n\tusm deploy <package.usmc|directory> [--exec CMD] [--base IMAGE] [--spm dnf|apt|apk|emerge|none] [--repository FILE]... [--no-build] [--installer-url URL] [--verbose]\n");
+    printerr("USAGE:\n\tusm deploy <package.usmc|directory> [--exec CMD] [--base IMAGE] [--spm dnf|apt|apk|emerge|pacman|none] [--repository FILE]... [--no-build] [--installer-url URL] [--verbose]\n");
     return 255;
 }
 
@@ -762,6 +769,10 @@ private string deploy_containerfile(string base_image, string installer_url, boo
             builder.append("# The shell SPM helper needs nothing beyond busybox; bash serves the package's manage: scripts and curl (absent from the alpine base) fetches the installer\n");
             builder.append("RUN apk add --no-cache bash curl\n\n");
             break;
+        case DeploySpm.PACMAN:
+            builder.append("# curl (absent from the base image) fetches the installer; the files databases fetched by pacman -Fy serve the shell SPM helper's file queries\n");
+            builder.append("RUN pacman -Sy --noconfirm curl && pacman -Fy --noconfirm && rm -rf /var/cache/pacman/pkg/*\n\n");
+            break;
         case DeploySpm.EMERGE:
         case DeploySpm.NONE:
             break;

+ 5 - 4
src/cli/GenConfig.vala

@@ -127,12 +127,13 @@ private string detect_lib_dir_fallback() {
 
 /**
  * Detects the system package manager by which supported executable is on
- * PATH (dnf, apt-get, apk, emerge — in that order), returning the matching
- * usm-spm-<name> shim name or null when no supported manager is installed
- * (in which case the config omits the system_package_manager section).
+ * PATH (dnf, apt-get, apk, emerge, pacman — in that order), returning the
+ * matching usm-spm-<name> shim name or null when no supported manager is
+ * installed (in which case the config omits the system_package_manager
+ * section).
  */
 private string? detect_spm() {
-    foreach (var executable in new string[] { "dnf", "apt-get", "apk", "emerge" }) {
+    foreach (var executable in new string[] { "dnf", "apt-get", "apk", "emerge", "pacman" }) {
         if (Environment.find_program_in_path(executable) != null) {
             // apt-get systems use the "apt" shim; the others share their name
             return executable == "apt-get" ? "apt" : executable;

+ 1 - 1
src/meson.build

@@ -4,7 +4,7 @@ vapi_dir = meson.current_source_dir() / 'vapi'
 # The SPM helpers live outside the source root; copying them in keeps the
 # install declarative and preserves the executable bit via install_mode
 spm_shims = []
-foreach shim : ['dnf', 'apt', 'apk', 'emerge']
+foreach shim : ['dnf', 'apt', 'apk', 'emerge', 'pacman']
     spm_shims += configure_file(
         input: '../spm' / shim / ('usm-spm-' + shim),
         output: 'usm-spm-' + shim,

Some files were not shown because too many files changed in this diff