#!/bin/bash # --------------------------------------------------------------------------- # # Read ourselves ONCE, before doing anything. # --------------------------------------------------------------------------- # # # bash reads a script INCREMENTALLY, by byte offset, while it runs it. Replace # the file mid-run -- `make install` of a new build in another terminal, a tool # refresh, anything -- and execution resumes at the same offset in the NEW # content, mid-word: # /usr/bin/lfs-helper: line 8312: mmand: command not found # /usr/bin/lfs-helper: line 8314: syntax error near unexpected token `)' # That happened during a package install: the build had finished, the package # was installed, and the tool died on its own source. Nothing about the # package was wrong. # # Everything below is one group command, so bash must parse the WHOLE file # before it executes any of it. After that the file on disk can change freely # -- this run is already in memory. Functions and variables defined inside a # group are global, `exit` still exits, and the group's status is the last # command's, so nothing else changes. { # # lfs-helper -- manage the LFS build from INSIDE the chroot. # # The `lfs` tool is Python, and the chroot has no Python until chapter 8 builds # it. This is the bash stand-in: it tracks session state, sets up the # package-user system, creates package users, runs the phased install scripts, # and records which files each package installed. # # State lives under /usr/src (the same dir `lfs` uses from outside, so both # tools see one shared session). # # The install scripts it runs are the SAME phased format `packagemanager` uses # (unpack_pkg / build_pkg / install_pkg / configure_pkg / test_pkg + a # name_version variable), so once the system is up, `packagemanager` can update # these packages with the scripts already in each package user's home. # # GPLv2-or-later. Part of the pkgusr toolchain. set -u # lfs-helper runs before init-dirs has created /tmp, and `sort` needs a # temporary directory: "sort: cannot create temporary file in '/tmp'". # (from the SysV-book session) [ -d /tmp ] || mkdir -m 1777 -p /tmp 2>/dev/null # Bump on every change that alters behaviour. `lfs-helper --version` makes it # possible to tell at a glance whether the copy inside the chroot is the one # that was just fixed -- guessing at that has wasted a lot of time. LFS_HELPER_VERSION="1.14.371" # A fingerprint of this file's own contents. A hand-maintained version number # goes stale the moment someone forgets to bump it -- and the reason for # printing a version at all is to answer "am I running the code that was just # fixed?". This changes whenever the file does, so it cannot lie. _build_id() { local f="${BASH_SOURCE[0]:-$0}" if [ -r "$f" ] && command -v md5sum >/dev/null 2>&1; then md5sum "$f" 2>/dev/null | cut -c1-7 else echo unknown fi } # --------------------------------------------------------------------------- # # where everything lives # --------------------------------------------------------------------------- # # Root of the filesystem being tracked. Inside the chroot that is "/"; the # test harness points it elsewhere. Defined first because the paths below are # derived from it. SNAP_ROOT="${LFS_SNAP_ROOT:-/}" # Where accounts live. SRCROOT is the PARENT and stays whole-tree, because # tree scans exclude it as one path; the accounts themselves live in # subdirectories by kind, so `ls /usr/src` says what a thing IS: # # /usr/src/pkgusr/p_gcc a package # /usr/src/cfg/cfg_bootscripts a configuration step that installs files # /usr/src/u_firefox an application user # # Flat under /usr/src, a hundred package users and a handful of config steps # were one undifferentiated list. SRCROOT="${LFS_SRC_ROOT:-/usr/src}" PKGUSR_ROOT="${LFS_PKGUSR_ROOT:-$SRCROOT/pkgusr}" CFGUSR_ROOT="${LFS_CFGUSR_ROOT:-$SRCROOT/cfg}" # What the build knows about those accounts -- under /usr/src beside them, not # in a hidden directory at the root of the filesystem. /.lfs-pkgusr sat next to # /boot and /etc as if it were part of the system being built. It is not: it is # this toolchain's working notes ABOUT that system. # # It also means the tree scans exclude ONE path instead of two, because # everything under $SRCROOT already is. # # Sorted, so that opening it tells you what is in it: # # scripts/ one shell script per build step, generated from the book # manifests/ what each package installed # logs/ one log per step, plus verify.log # progress/ what has been built, and how far # groups/ collector groups and the grants that created them # config/ settings you may edit # stage/ staged installs -- only during a build # steps/ staging for steps that have no account # tmp/ scratch -- only during a build STATE="${LFS_PKGUSR_DIR:-${SRCROOT%/}/lfs-pkgusr}" SCRIPTS="$STATE/scripts" # generated build scripts MANIFESTS="$STATE/manifests" # what each package installed LOGS="$STATE/logs" # one log per step, plus verify.log STATE_PROGRESS="$STATE/progress" # how far the build has got STATE_GROUPS="$STATE/groups" # collector groups and their grants STATE_CONF="$STATE/config" # settings a person may edit PROGRESS="$STATE_PROGRESS/steps-built" SNAPSHOT="$STATE_PROGRESS/tree-snapshot" SNAPDIRS="$STATE_PROGRESS/tree-snapshot-dirs" INSTALLDIRS="$STATE_CONF/installdirs.lst" # ONE wrapper directory, and it is the one the profiles already name. # # There were two. make_wrappers wrote to $STATE/wrappers, while # /etc/pkgusr/bash_profile -- and packagemanager's copy of it -- put # /usr/lib/pkgusr first on a package user's PATH. Nothing created that, so a # package user's login shell led with a directory that did not exist, and the # wrappers only ever applied because cmd_build overrides PATH on the command # line. Two names for one decision, which is the species of bug this project # keeps hitting. # # /usr/lib/pkgusr wins over $STATE/wrappers for one reason: it has to outlive # the build. $STATE is build state, and `packagemanager` on the finished system # hands its package users the same profile -- pointing at a path that would no # longer be there. # # It lives under /usr/lib but is NOT an install directory: root:root 0755, never # group-writable, never adopted. A package user that could rewrite the wrappers # could rewrite the rule that constrains it. WRAPPERS="${LFS_WRAPPERS:-${SNAP_ROOT%/}/usr/lib/pkgusr}" ETC="${LFS_ETC:-/etc}" # overridable so the test suite can exercise # user creation without touching the real /etc INSTALL_GID=9999 # 'install' group; package users start at 10000 PKG_UID_MIN=10000 # Collector groups get their OWN range, well clear of the package users. # Sharing one range means a collector group can take the gid that the next # package user's group wants, and then user 10042 has group 10043 while some # unrelated collector group holds 10042 -- the ids stop lining up and a # `chown pkg:pkg` starts meaning something different from what it reads like. COLLECTOR_GID_MIN=90000 # The build user from outside the chroot. Files it created -- /sources, the # tree it handed over at 7.2 -- carry its uid, and the chroot has no passwd # entry for it, so they show as a bare number and every tool calls them # UNKNOWN. Recreating the account here (just below the install group, clear of # the package users at 10000+) makes those files resolve to a name again. LFS_BUILD_UID=9998 # The next free gid in the collector range. next_collector_gid() { local g="$COLLECTOR_GID_MIN" while getent group "$g" >/dev/null 2>&1 \ || { [ -r "$ETC/group" ] && cut -d: -f3 "$ETC/group" | grep -qx "$g"; }; do g=$((g + 1)) done printf '%s' "$g" } # Create a collector group in its own id range. create_collector_group() { local grp="$1" gid getent group "$grp" >/dev/null 2>&1 && return 0 gid="$(next_collector_gid)" if have_shadow_tools; then groupadd -g "$gid" "$grp" 2>/dev/null && return 0 # the gid raced or is taken: let groupadd choose rather than fail groupadd "$grp" 2>/dev/null && return 0 else # no Shadow yet -- same by hand, the way init-pkgusr does it printf '%s:x:%s:\n' "$grp" "$gid" >> "$ETC/group" && return 0 fi getent group "$grp" >/dev/null 2>&1 } # --------------------------------------------------------------------------- # # saying things # --------------------------------------------------------------------------- # # # Four colours, one meaning each, and prose gets none. Colour that appears on # every other line stops carrying information -- if the whole build log is # yellow, nothing in it is a warning. # # green something finished, and finished correctly # yellow something needs your attention # red something failed # dim detail you can skip: provenance, and what to do next # # Use the helper, never the variable. A colour opened in one echo and closed # in another leaves the terminal coloured when the line between them is a # command's own output. C_OK=$'\033[0;32m'; C_WARN=$'\033[0;33m'; C_ERR=$'\033[0;31m' C_DIM=$'\033[2m'; C_OFF=$'\033[0m' # Bold, for the words in a question that the ANSWER depends on: which package # is asking, which directory, and the name being proposed. A prompt where # every word looks the same makes the reader hunt for the three that matter. C_B=$'\033[1m' [ -t 1 ] || { C_OK=; C_WARN=; C_ERR=; C_DIM=; C_OFF=; C_B=; } # prose -- the ordinary running commentary of a build say() { echo "$*"; } # green: something succeeded ok() { echo "${C_OK}$*${C_OFF}"; } # yellow on stderr: something needs attention, and is not part of normal flow warn() { echo "${C_WARN}$*${C_OFF}" >&2; } # `cmd ... 2>/dev/null || true` -- the pattern that has cost this project more # debugging than any other. A whole tree came out with every package home # root:root 755, and not one line of output said why; the reason it took a # filesystem check, an su test and three rounds of guessing was that the # failure had been suppressed at the point it happened. # # There are two honest reasons to write it, and they are different: # # soft -- the command MAY fail and the build carries on, # but the failure is SAID. Use this wherever a # failure changes what the system looks like: # ownership, modes, group membership, backups. # # 2>/dev/null || true only where failing is NORMAL and means nothing: # mkdir -p on a directory that exists, appending # to a log that may not be writable, sorting a # file in place. Every remaining one carries a # comment saying which. soft() { local what="$1"; shift [ "${1:-}" = "--" ] && shift local err rc err="$("$@" 2>&1 >/dev/null)"; rc=$? [ "$rc" = 0 ] && return 0 warn "!! $what" [ -n "$err" ] && warn " $err" return 0 } # yellow on stdout: needs attention, but IS part of the flow -- a retry, a # question, a directory being granted. Same colour because it means the same # thing; stdout because it belongs in sequence with the build output. note() { echo "${C_WARN}$*${C_OFF}"; } # dim: provenance -- which script, which book section, which user detail() { echo "${C_DIM}$*${C_OFF}"; } # dim: what to do next. Never the finding itself, only the remedy. hint() { echo "${C_DIM}$*${C_OFF}"; } # red on stderr, and stop die() { echo "${C_ERR}!! $*${C_OFF}" >&2; exit 1; } # red on stdout: a failure banner that belongs in sequence with the build fail() { echo "${C_ERR}$*${C_OFF}"; } need_root() { [ "$(id -u)" = 0 ] || die "must be run as root (inside the chroot)"; } # --------------------------------------------------------------------------- # # state # --------------------------------------------------------------------------- # # Every state directory, created together so none of them can be missing on # the one path that happens not to create it. # # The top of it gets the same root:install 1775 as everything else under # /usr/src -- one rule for the subtree. What is INSIDE stays root-owned and # unreadable-by-nobody-in-particular: these are the build's own notes, and a # package user has no reason to write to them. mkstate() { mkdir -p "$STATE" "$SCRIPTS" "$MANIFESTS" "$LOGS" \ "$STATE_PROGRESS" "$STATE_GROUPS" "$STATE_CONF" || return 0 if [ "$(id -u)" = 0 ]; then # SUPPRESSED DELIBERATELY. mkstate runs at the top of every command, # including ones from before book 7.6, when the `install` group does # not exist yet and this cannot succeed. Reporting it would print a # failure on every invocation for the first third of a build, which is # how a report becomes noise. seal-install-dirs sets it for real. set_install_dir_owner "$STATE" 2>/dev/null || true # SUPPRESSED DELIBERATELY: same moment, same reason as the line above. real_chmod 1775 "$STATE" 2>/dev/null || true fi } is_done() { [ -f "$PROGRESS" ] && grep -qxF "$1" "$PROGRESS"; } mark_done() { mkstate; is_done "$1" || echo "$1" >> "$PROGRESS"; } unmark() { [ -f "$PROGRESS" ] && sed -i "\|^$1\$|d" "$PROGRESS"; } # The ordered list of steps. Each is "name" and the script is # $SCRIPTS/.sh -- generated by `lfs build-system gen-chroot-scripts`. # Is this name one of the LFS book's steps? A name that is NOT tells the # person something different from one that is: a missing step script is a # generation problem, an unknown name is probably a BLFS package or a typo. in_step_order() { steps | grep -Fxq "$1" } steps() { if [ -f "$STATE_PROGRESS/steporder" ]; then grep -v '^[[:space:]]*$' "$STATE_PROGRESS/steporder" else ls -1 "$SCRIPTS" 2>/dev/null | sed -n 's/\.sh$//p' fi } # --------------------------------------------------------------------------- # # package-user system # --------------------------------------------------------------------------- # # getent comes from glibc (present), but useradd/groupadd come from Shadow, # which is chapter 8 package #26 -- so for most of the build there is no user # management at all. /etc/passwd and /etc/group are plain text though, so we # create accounts by appending to them directly when the tools are missing. have_shadow_tools() { command -v useradd >/dev/null 2>&1 \ && command -v groupadd >/dev/null 2>&1; } # The passwd/group FILES are authoritative: inside the chroot they ARE the user # database, and getent can otherwise answer from another NSS source and mask # what's really in /etc. Only fall back to getent if the file is unreadable. group_exists() { if [ -r "$ETC/group" ]; then grep -q "^$1:" "$ETC/group"; else getent group "$1" >/dev/null 2>&1; fi } user_exists() { if [ -r "$ETC/passwd" ]; then grep -q "^$1:" "$ETC/passwd"; else getent passwd "$1" >/dev/null 2>&1; fi } id_taken() { # Ask the real database as well as the files. A uid can be in use without # matching the ":x::" pattern (a different password field, an entry from # another source), and picking it anyway ends in # useradd: UID 10067 is not unique getent passwd "$1" >/dev/null 2>&1 && return 0 getent group "$1" >/dev/null 2>&1 && return 0 grep -q ":x:$1:" "$ETC/passwd" 2>/dev/null && return 0 grep -q ":x:$1:" "$ETC/group" 2>/dev/null && return 0 return 1 } # append a group / user by hand (no Shadow needed) # remove a user from the install group's member list (handles the member being # first, last, only, or in the middle -- chained seds get this wrong) drop_from_install_group() { local u="$1" line members out="" line="$(grep "^install:" "$ETC/group" 2>/dev/null)" || return 0 members="${line##*:}" local IFS=',' for m in $members; do [ "$m" = "$u" ] && continue [ -n "$m" ] || continue out="${out:+$out,}$m" done unset IFS sed -i "s|^install:.*|install:x:$INSTALL_GID:$out|" "$ETC/group" } raw_groupadd() { echo "$1:x:$2:" >> "$ETC/group"; } raw_useradd() { local name="$1" id="$2" home="$3" echo "$name:x:$id:$id:package $name:$home:/bin/bash" >> "$ETC/passwd" # supplementary membership in the install group if grep -q "^install:" "$ETC/group"; then local line members line="$(grep "^install:" "$ETC/group")" members="${line##*:}" if [ -n "$members" ]; then members="$members,$name"; else members="$name"; fi sed -i "s|^install:.*|install:x:$INSTALL_GID:$members|" "$ETC/group" fi } # The shared "install directories" -- the dirs packages install INTO. These # belong to root:install and are group-writable so any package user can add # files; they are NOT owned by individual packages. (Modelled on # more_control_helpers/installdirs.lst from the package-users hint.) # Directories no package may ever own -- but which are NOT install dirs. # # Two different ideas were conflated. An install directory is shared: it gets # the install group and g+w so any package user can add files. These are # something else entirely -- scratch and people's home directories -- and must # simply never be adopted by a package. Putting them in install_dirs_list made # /root root:install drwxrwx---, handing every package user access to root's # home, which is the opposite of what was wanted. # # The account roots belong here too, and by decision rather than by omission. # They carry the same root:install 1775 as `/usr/src` above them -- one rule for # the subtree rather than a special case to remember. The sticky bit is what # makes that safe: without it any member of `install` could delete or rename # another package's home. Being in never_adopt_list is the other half -- no # package may ever take one of these as its own. See ensure_pkgusr_roots. # # They come from pkgusr_roots, not from a second list of literal paths: the # naming split adds two more roots, and a hardcoded copy here is one more place # that can disagree. never_adopt_list() { cat <<'NALIST' /tmp /var/tmp /root /home /sources /build /usr/src NALIST pkgusr_roots # The wrapper directory. It lives under /usr/lib, which IS an install # directory, so without this it inherits root:install + g+w and any package # user can rewrite the wrappers that stop it changing ownership. printf '%s\n' "$WRAPPERS" } # The REAL chown/chgrp/chmod/install, never the wrapper. # # make_wrappers ships a `chgrp` and a `chown` that print a note and `exit 0`. # They exist so a PACKAGE USER cannot change ownership -- but they are ordinary # files on $PATH, and if that directory is ever ahead of /usr/bin for root, then # root's own privileged calls resolve to them too. They report success and do # nothing. # # That failure is invisible by construction, and it is the worst kind this # project has: `init_pkgusr` then says # # 46 install directories now belong to group 'install' # with an empty failure list, while every one of them is still root:root -- so # no package user can write to /usr/bin, and nothing says why until a build # fails twenty packages later. `verify --fix` cannot repair it either, because # its chown goes through the same wrapper. # # Ownership and mode are root's business. Call the binaries by absolute path so # no PATH can come between the decision and the filesystem. _REAL_CHOWN=""; _REAL_CHGRP=""; _REAL_CHMOD="" _real_tool() { # _real_tool -- absolute path to the genuine binary local n="$1" c for c in "/usr/bin/$n" "/bin/$n" "/usr/sbin/$n" "/sbin/$n"; do [ -x "$c" ] && { printf '%s' "$c"; return 0; } done # Nothing found in the usual places: fall back to the name and let PATH # decide, rather than failing outright on an unusual layout. printf '%s' "$n" } real_chown() { [ -n "$_REAL_CHOWN" ] || _REAL_CHOWN="$(_real_tool chown)"; "$_REAL_CHOWN" "$@"; } real_chgrp() { [ -n "$_REAL_CHGRP" ] || _REAL_CHGRP="$(_real_tool chgrp)"; "$_REAL_CHGRP" "$@"; } real_chmod() { [ -n "$_REAL_CHMOD" ] || _REAL_CHMOD="$(_real_tool chmod)"; "$_REAL_CHMOD" "$@"; } is_never_adopt() { local d="${1%/}" p [ -n "$d" ] || return 1 while IFS= read -r p; do [ -n "$p" ] || continue [ "$d" = "${p%/}" ] && return 0 done < <(never_adopt_list) return 1 } # What an install directory looks like, applied by NUMBER, in one place. # # `chown root:install` needs `root` to RESOLVE. On a chapter 5-6 tree restored # before book 7.6 there is no /etc/passwd at all -- the prompt literally says # "I have no name!" -- so every call failed and init-pkgusr reported: # # 0 install directories are now root:install (group-writable) # # could not set the group on: /usr /usr/bin /usr/lib /usr/share ... # while ensure_install_dirs_writable, three hundred lines away, used `chgrp` # and worked fine. Two rules for one directory, differing in whether they need # an account to exist -- and only one of them ran early. # # Ownership is stored as a NUMBER. Use the number: uid 0 is root whether or not # anything says so, and INSTALL_GID is ours. `install` may exist as a group # before any passwd entry does, because init-pkgusr writes /etc/group by hand. set_install_dir_owner() { real_chown -h "0:$INSTALL_GID" "$1" 2>/dev/null } # ...and the matching test, also by number, for the same reason: `stat -c %U:%G` # reports UNKNOWN when nothing resolves, so a directory that was already correct # would be reported as wrong forever. install_dir_owner_ok() { [ "$(stat -c '%u:%g' "$1" 2>/dev/null)" = "0:$INSTALL_GID" ] } install_dirs_list() { # NOTE: /sources is deliberately NOT here. It is in never_adopt_list # above, and the same conflation that made /root root:install had it in # BOTH lists: `verify --fix` then made it root:install 775 and the seal # made it 1775, dropping the o+w that book 3.1 asks for -- which is # precisely what lets the unprivileged build user unpack into it. # /sources is root:root 1777 with no group at all; `lfs` (host) applies # that in get-sources and restart. if [ -f "$INSTALLDIRS" ]; then # An older installdirs.lst may still carry the /sources line. Drop it # here rather than rewriting the file: this list is consulted by nine # callers, and one of them fixing the file behind the others' back is # how the two rules diverged in the first place. local _ovr _ovr="$(grep -v '^[[:space:]]*\(#\|$\)' "$INSTALLDIRS" \ | grep -vx '[[:space:]]*/sources[[:space:]]*')" # An override that yields NOTHING must not silently disable the whole # install-directory scheme. Every caller walks this list -- init-pkgusr, # is_install_dir, verify, the seal -- so an empty return means no # directory is ever given to the install group, no directory is ever # protected from adoption, and `verify` reports a clean tree because it # checked nothing. Fall back to the built-in list and say so. if [ -n "$_ovr" ]; then printf '%s\n' "$_ovr" return fi warn "# $INSTALLDIRS lists no directories -- using the built-in list" >&2 fi cat <<'IDLIST' /bin /sbin /lib /lib64 /opt /boot /boot/efi /efi /usr/src /etc /etc/opt /etc/sysconfig /usr /usr/bin /usr/sbin /usr/lib /usr/lib64 /usr/lib/pkgconfig /usr/libexec /usr/include /usr/src /usr/share /usr/share/aclocal /usr/share/color /usr/share/dict /usr/share/doc /usr/share/info /usr/share/locale /usr/share/man /usr/share/man/man1 /usr/share/man/man2 /usr/share/man/man3 /usr/share/man/man4 /usr/share/man/man5 /usr/share/man/man6 /usr/share/man/man7 /usr/share/man/man8 /usr/share/misc /usr/share/pkgconfig /usr/share/terminfo /usr/share/zoneinfo /usr/local /usr/local/bin /usr/local/sbin /usr/local/lib /usr/local/include /usr/local/share /usr/local/share/man /usr/local/src /var /var/cache /var/lib /var/local /var/log /var/mail /var/opt /var/spool IDLIST } # /usr/include itself is shared -- every package drops headers in it. Its # SUBTREES are not: /usr/include/c++/ belongs to gcc. Listing those # here made them "shared install directories", so a package that could not # write there got the `install` group applied automatically instead of being # asked which collector group should own it -- silently handing gcc's headers # to everyone. Adoption of that tree is prevented by the contents check in # `lfs-helper verify --fix`, which is the right mechanism for it. # # /tmp and /var/tmp are world-writable scratch space with the sticky bit # already set -- a package that happens to write there must not come to OWN # them (man-pages ended up owning /tmp, which would break every other user of # it). /root and /home belong to people, not packages. # # Note the shape of these: /usr/libexec is shared (every package drops a # subdirectory in it) but /usr/libexec/p11-kit is NOT -- that belongs to # p11-kit. The same goes for /usr/share/zsh/site-functions, which IS shared: # every package drops a completion file straight into it. # # Directories that are shared by their very nature, matched as PATTERNS rather # than listed one by one. Translated man pages and message catalogues create a # directory per language (/usr/share/man/de/man1, /usr/share/locale/pl/ # LC_MESSAGES, ...) and practically every package writes into them, so listing # them individually is hopeless -- there are hundreds, and they appear as # packages are installed. install_dir_patterns() { cat <<'IPAT' */usr/share/man/man[1-9] */usr/share/man/*/man[1-9] */usr/share/man/* */usr/share/locale/*/LC_MESSAGES */usr/share/locale/* */usr/share/info */usr/share/doc */usr/lib/pkgconfig */usr/share/pkgconfig */usr/share/terminfo/* */usr/include */usr/share/bash-completion/completions */usr/share/zsh/site-functions */usr/share/zsh/vendor-completions */usr/share/fish/completions */usr/share/fish/vendor_completions.d */usr/share/applications */usr/share/mime/packages */usr/share/dbus-1/services */usr/share/dbus-1/system-services */usr/share/metainfo */usr/share/appdata */usr/share/icons/hicolor/* */usr/share/pixmaps */usr/share/glib-2.0/schemas */usr/share/vala/vapi */usr/share/gir-1.0 */usr/lib/girepository-1.0 */usr/share/aclocal */usr/share/gtk-doc/html */usr/lib/systemd/system */usr/lib/udev/rules.d */etc/xdg/autostart */usr/libexec IPAT } matches_install_pattern() { local p="${1%/}" pat while IFS= read -r pat; do # shellcheck disable=SC2254 case "$p" in $pat) return 0 ;; esac done < <(install_dir_patterns) return 1 } # is this path one of the shared install dirs (root:install), rather than # something the package created for itself? is_install_dir() { # protected from adoption, though not shared install dirs -- see # never_adopt_list is_never_adopt "$1" && return 0 local raw="${1%/}" p d p="${raw#${SNAP_ROOT%/}}" # make it absolute-in-target [ -n "$p" ] || return 0 while IFS= read -r d; do [ "$p" = "$d" ] && return 0 done < <(install_dirs_list) matches_install_pattern "$raw" && return 0 return 1 } # "Answer for me" has ONE meaning, whoever asked. # # The group-choice prompt honoured LFS_ASSUME_YES; `packagemanager --yes` # sets PM_YES; pm-install reads PM_YES as well. Three spellings of one # decision, so `stack sway --run --yes` still stopped to ask which collector # group to use -- the exact thing --yes exists to prevent. assume_yes() { [ "${LFS_ASSUME_YES:-0}" = 1 ] || [ "${PM_YES:-0}" = 1 ] } pkgusr_ready() { group_exists install; } cmd_init_pkgusr() { need_root; mkstate local run=0; [ "${1:-}" = "--run" ] && run=1 say "Package-user system setup:" say " 1. create the 'install' group (gid $INSTALL_GID)" say " 2. give install directories to group 'install', group-writable" say " NOT sticky yet: an install directory is finally group-writable" say " AND sticky, but during the build the sticky bit would stop package" say " users replacing the temporary-system files still owned by root." say " Add it at the very end with: lfs-helper seal-install-dirs --run" if [ "$run" = 0 ]; then warn "(dry run -- re-run with --run to apply)" return 0 fi if group_exists install; then say "# 'install' group already exists" elif have_shadow_tools; then # Tolerate "already exists": init-pkgusr is run automatically now, and # a step that cannot be repeated safely would break every later run. if groupadd -g "$INSTALL_GID" install 2>/dev/null; then ok "# created group install ($INSTALL_GID)" elif getent group install >/dev/null 2>&1; then say "# 'install' group already exists" else die "groupadd install failed" fi else raw_groupadd install "$INSTALL_GID" ok "# created group install ($INSTALL_GID) by editing /etc/group" say " (Shadow isn't built yet -- no groupadd, so this was done by hand)" fi # Shared index FILES, same rule as the directories. share_shared_files # the shell startup helpers every BLFS profile.d snippet calls install_profile_helpers # Directories packages install into. Anything missing is skipped quietly. local d n=0 failed="" didnt_take="" while IFS= read -r d; do d="${SNAP_ROOT%/}$d" [ -d "$d" ] || continue # root:install, OWNER AND GROUP -- not chgrp alone. # # This set only the group, while _vfy_install_dirs demands root:install # and `verify --fix` applies both. Two rules for one directory, and # they disagreed: chapters 5-6 build as the `lfs` user, book 7.2's # handover only reaches one level below $LFS, so /usr/share stayed # lfs-owned and came out `lfs:install`. The group looked right, and # nothing said the owner was not. if ! set_install_dir_owner "$d"; then # `|| continue` here reported "0 install directories" with no hint # that anything went wrong -- and the build then failed much later # with permission errors nobody could trace back to this. failed="$failed $d" continue fi # CONFIRM it took, rather than trusting the exit code. # # A chgrp that returns 0 and changes nothing is not hypothetical: the # wrapper in $WRAPPERS does exactly that, by design. If it is ever # ahead of /usr/bin on root's PATH, every directory here is reported # as done while staying root:root -- and no package user can write to # /usr/bin for the rest of the build. if ! install_dir_owner_ok "$d"; then didnt_take="$didnt_take $d" continue fi # the hint's definition: chown root:install && chmod g+w,o+t . # o+t (sticky) is deliberately deferred to seal-install-dirs, because # it would stop package users replacing files the temporary system # installed as root. soft "$d is not group-writable -- package users cannot install into it" \ -- real_chmod g+w "$d" n=$((n+1)) done < <(install_dirs_list) ok "# $n install directories are now root:install (group-writable)" local f if [ -n "$failed" ]; then warn "# could not set the group on:" for f in $failed; do warn "# $f"; done warn "# The build will hit permission errors in these later." fi if [ -n "$didnt_take" ]; then warn "!! the group did NOT change, though the command reported success:" for f in $didnt_take; do warn "# $f (still $(stat -c %U:%G "$f" 2>/dev/null))" done warn " Package users cannot write into these, so builds WILL fail." fi # Zero is not a quiet success. install_dirs_list is overridable through # $INSTALLDIRS, and an override that yields nothing turns the whole scheme # into a no-op -- silently, because every later pass, `verify` included, # walks the same empty list and finds nothing wrong. if [ "$n" = 0 ]; then warn "!! NO install directories were set up." warn " Every package user will be unable to write to /usr/bin and" warn " the rest of the shared tree." [ -f "$INSTALLDIRS" ] && \ warn " An override is in effect and yielded nothing: $INSTALLDIRS" fi mark_done "pkgusr-init" say "" say "Next: lfs-helper next" } # add-user -- create a package user the packagemanager way: # own group, home /usr/src/, member of the supplementary 'install' group # The shared package-user environment (the hint's /etc/pkgusr). A user without # it exists but cannot build: no .bash_profile, no .bashrc, no build helper, and # the wrong umask. PKGUSR_ETC="${LFS_PKGUSR_ETC:-${SNAP_ROOT%/}/etc/pkgusr}" ensure_pkgusr_etc() { mkdir -p "$PKGUSR_ETC" 2>/dev/null || return 1 if [ ! -f "$PKGUSR_ETC/bash_profile" ]; then cat > "$PKGUSR_ETC/bash_profile" <<'EOF' # /etc/pkgusr/bash_profile -- shared by every package user (symlinked as # ~/.bash_profile), so every build sees the same environment. umask 022 export LC_ALL=POSIX # /usr/local/bin comes FIRST after the wrappers: locally installed # overrides belong ahead of /usr/bin, and a temporary wrapper there # (the no-verify wget, say) is useless if nothing ever finds it. PATH=@WRAPPERS@:/usr/local/bin:/usr/local/sbin:/usr/bin:/bin:/usr/sbin:/sbin export PATH export PS1='\u:\w\$ ' export PKG_HOME="$HOME" cd "$HOME" 2>/dev/null || true [ -f "$HOME/build.conf" ] && . "$HOME/build.conf" EOF # The profile is written with a QUOTED heredoc -- $HOME and $PS1 must # reach the file untouched -- so the one value that is ours goes in as a # placeholder and is substituted after. Hardcoding /usr/lib/pkgusr here # is what let the profile and make_wrappers name two different # directories for years. sed -i "s|@WRAPPERS@|$WRAPPERS|" "$PKGUSR_ETC/bash_profile" fi if [ ! -f "$PKGUSR_ETC/bashrc" ]; then cat > "$PKGUSR_ETC/bashrc" <<'EOF' # /etc/pkgusr/bashrc -- symlinked as ~/.bashrc. Package users never log in, so # login and non-login shells should behave identically: just source the profile. [ -f "$HOME/.bash_profile" ] && . "$HOME/.bash_profile" EOF fi if [ ! -f "$PKGUSR_ETC/build" ]; then cat > "$PKGUSR_ETC/build" <<'EOF' #!/bin/bash # /etc/pkgusr/build -- symlinked as ~/build. Runs the usual sequence using the # package user's build.conf. set -e conf="${1:-$HOME/build.conf}" [ -f "$conf" ] || { echo "no build.conf at $conf" >&2; exit 1; } . "$conf" : "${SRC_DIR:=$HOME}" : "${CONFIGURE_OPTS:=--prefix=/usr}" : "${MAKE_OPTS:=}" cd "$SRC_DIR" [ -x ./configure ] && ./configure $CONFIGURE_OPTS make $MAKE_OPTS make install EOF real_chmod 755 "$PKGUSR_ETC/build" fi return 0 } init_package_user_home() { local name="$1" home="${2:-$(pkgusr_home_for "$1")}" ensure_pkgusr_etc || return 1 ensure_pkgusr_roots mkdir -p "$home" local link target for link in $(pkgusr_skel_links); do target="${link#*:}"; link="${link%%:*}" ln -sfn "$PKGUSR_ETC/$target" "$home/$link" done if [ ! -f "$home/build.conf" ]; then cat > "$home/build.conf" < "$home/.project" # Give the home to its user, and SAY SO if that fails. # # This used to be `2>/dev/null || true`, and a whole tree came out with # every home root:root 755 -- so no package user could write its own home # -- with not one line of output saying why. Whatever the cause, the # reason it took a filesystem check, an su test and three rounds of # guessing to find is that the failure was suppressed. # # It is not fatal: the build can still proceed, and `verify --fix` can # repair ownership afterwards. But it must be visible. if ! real_chown -R -h "$name:$name" "$home" 2>/tmp/.pkgusr-chown.$$; then warn "!! could not give $home to '$name':" sed 's/^/ /' "/tmp/.pkgusr-chown.$$" >&2 2>/dev/null || true warn " the package user cannot write its own home." warn " Repair with: lfs-helper verify --fix" fi rm -f "/tmp/.pkgusr-chown.$$" # and confirm it actually took -- chown can return 0 and still leave the # directory owned by root if the name resolves to nothing useful local _own _own="$(stat -c %U "$home" 2>/dev/null)" if [ -n "$_own" ] && [ "$_own" != "$name" ]; then warn "!! $home is still owned by '$_own', not '$name'" warn " (chown reported success -- the account may not resolve yet)" fi return 0 } # Steps that are not packages, and must never get a package user. # # init-* and refind are actions, not software: they own no files of their own # and installing "as" them makes no sense. A `last-step` user appeared because # a manifest existed under that name, and everything the step touched -- # including wget's binary, installed as the wget user -- was then handed to it. # # `last-step` itself was removed in 1.10.0 and no tool generates it any more. # It stays in the match below for trees generated before that: their steporder # and manifests still carry the name, and the rule must still say no. # # cfg_* is NOT decidable from the name. Most chapter-9 "configuration" # sections write a file (/etc/hostname, /etc/hosts) and are root's work, but # some are real packages: book 9.2 is titled LFS-Bootscripts-20250827 -- a # versioned tarball that unpacks and installs programs into /etc/rc.d/init.d # and /lib/services. Those files want an owner like any other package's. # # `lfs` already draws that line when it generates the scripts: a versioned # section gets a full phased build (with pkg_glob and unpack_pkg), prose gets # a plain root script. Read the script instead of guessing, so there is one # decision and not two that can disagree. They did disagree, and the build # stopped at 88/99 with # 'cfg_bootscripts' is a build step, not a package -- no user created. # install: invalid user 'cfg_bootscripts' # !! could not stage the install script # -- lfs had deliberately kept it OUT of rootsteps so it would be built as a # package user, and then this refused to create one. _is_not_a_package() { # init-ownership is this tool's own step: it owns no files and gets no # account, exactly like init-dirs and init-files. case "$1" in init-ownership|init-accounts) return 0 ;; esac # Callers pass either a step name (`cfg_bootscripts`) or an owner name # that has already been through pkg_owner_name (`p_cfg_bootscripts`). # Match on the bare name, or the prefix silently disables every rule here. local name; name="$(unprefix_pkg_user "$1")" case "$name" in last-step|init-*|refind) return 0 ;; cfg_*) ;; # decided by the script, below *) return 1 ;; esac local s="$SCRIPTS/$name.sh" [ -f "$s" ] && grep -q '^pkg_glob=' "$s" && return 1 # a real package return 0 } # Keep /etc/passwd and /etc/group in id order as users are created. # # They are appended to, so without this the files drift into build order and # have to be tidied up afterwards -- which only happens if someone remembers. # The files are a few kilobytes; sorting them on each creation costs nothing. # passwd/group are appended to as users are created; keep them in id order. # One rule, shared with `verify` -- see _vfy_user_order. _sort_users_quietly() { _vfy_user_order 1 >/dev/null 2>&1 || true } # An option is not a package name. # # A sanity run found /usr/src/pkgusr/p_--version: a home for an account that # could never exist, left by something that reached the build with "--version" # in the name position. Whatever mis-dispatched it, a leading dash here is # always wrong, and refusing it costs nothing. _refuse_option_as_name() { case "${1:-}" in -*) die "'$1' looks like an option, not a package name" ;; esac } cmd_add_user() { need_root local name="${1:-}"; [ -n "$name" ] || die "usage: lfs-helper add-user " _refuse_option_as_name "$name" if _is_not_a_package "$name"; then warn "'$name' is a build step, not a package -- no user created." return 0 fi # The ACCOUNT name, through the same chokepoint as the home. # # This used the argument raw for the account and pkg_owner_name only for # the home, which is fine while every caller passes an already-prefixed # owner -- cmd_build does. last_build_step.sh does not: it calls # lfs-helper add-user wget # and got an account called `wget` living in /usr/src/pkgusr/p_wget. The # sanity report found both halves and could not connect them: # !! accounts without a known prefix: wget urllib3 requests ... # ? /usr/src/pkgusr/p_wget/ has no matching account # # pkg_owner_name is idempotent, so callers that already prefix are # unaffected -- which is exactly why every name in this file should go # through it rather than only the ones that look like they need it. name="$(pkg_owner_name "$name")" pkgusr_ready || die "run 'lfs-helper init-pkgusr --run' first" if user_exists "$name"; then [ "${_QUIET_EXISTING:-0}" = 1 ] || say "# user '$name' already exists" else if have_shadow_tools; then # Let Shadow allocate the ids. Choosing them by hand means racing # its own idea of what is free -- and losing: # useradd: UID 10067 is not unique # -K UID_MIN/GID_MIN keeps package users in their own range without # us having to track which numbers are already used. if group_exists "$name"; then useradd -c "package $name" -d "$(pkgusr_home_for "$name")" -g "$name" \ -G install -s /bin/bash -K "UID_MIN=$PKG_UID_MIN" "$name" \ || die "useradd $name failed" else # -U creates the private group together with the user, so the # uid and gid match -- creating the group separately lets the # two counters drift apart (uid 10000 / gid 10001). useradd -c "package $name" -d "$(pkgusr_home_for "$name")" -U \ -G install -s /bin/bash \ -K "UID_MIN=$PKG_UID_MIN" -K "GID_MIN=$PKG_UID_MIN" \ "$name" \ || die "useradd $name failed" fi ok "# created package user $name (uid/gid $(id -u "$name")/$(id -g "$name"), +install)" _sort_users_quietly else # No Shadow yet: we write the files ourselves, so we do have to pick # a free id -- one that is free as BOTH a uid and a gid, so the # package user gets a matching private group. local uid=$PKG_UID_MIN while id_taken "$uid"; do uid=$((uid+1)); done group_exists "$name" || raw_groupadd "$name" "$uid" raw_useradd "$name" "$uid" "$(pkgusr_home_for "$name")" ok "# created package user $name (uid/gid $uid, +install) via /etc/passwd" _sort_users_quietly fi fi _h="$(pkgusr_home_for "$name")" init_package_user_home "$name" "$_h" real_chmod 755 "$_h" } # --------------------------------------------------------------------------- # # file tracking (same manifest layout the `lfs` tool writes) # --------------------------------------------------------------------------- # # Build variables (LFS_TGT, MAKEFLAGS) as configured by `lfs config`. The # chroot resets the environment, so they're read from a file inside the tree # rather than inherited -- otherwise a custom LFS_TGT silently reverts. load_env() { [ -f "$STATE_CONF/env" ] && . "$STATE_CONF/env"; return 0; } load_env # Read a stored snapshot, translating paths recorded OUTSIDE the chroot. # Chapters 5-6 ran on the host, where this tree was $LFS/usr/bin/...; in here # the same files are /usr/bin/.... Without translating, the "before" set shares # no paths with the "after" set and the very first in-chroot build looks like it # installed the entire system. load_snapshot() { # Re-sorted, in the same collation snapshot() used. # # The result is fed straight to `comm`, which compares line by line and # trusts both sides to be sorted the same way. Two things here can break # that: the file may have been written under a different locale, and the # sed below rewrites only the lines carrying the host mount prefix -- so a # file holding both forms comes out in an order that is sorted under # neither. comm cannot detect either case; it just reports the wrong # answer, and a package's whole install silently becomes "no new files". local f="$1" m="${LFS_HOST_MOUNT:-}" r="${SNAP_ROOT%/}" if [ -n "$m" ] && grep -q "^$m/" "$f" 2>/dev/null; then sed "s|^$m/|$r/|" "$f" else cat "$f" fi | LC_ALL=C sort } have_su() { command -v su >/dev/null 2>&1; } snapshot_dirs() { # PRUNE, DO NOT FILTER. `-not -path "$SRCROOT/*"` hides a path AFTER find # has walked into it, so every scan still descended /usr/src -- every # package's unpacked source and build tree, LLVM's and WebKit's included # -- and threw the answer away. Each phase ended with five such walks: # nvtop's two-second install sat for minutes on "scanning the tree". # -prune does not descend. The pruned directory itself is still listed, # exactly as the filter listed it. local r="${SNAP_ROOT%/}" find "${SNAP_ROOT:-/}" -xdev \ \( -path "$SRCROOT" -o -path "$r/dev" -o -path "$r/proc" \ -o -path "$r/sys" -o -path "$r/run" -o -path "$r/tmp" \ -o -path "$r/sources" -o -path "$r/build" \ -o -path "$r/root" -o -path "$r/home" \) -type d -print -prune \ -o -type d -print \ 2>/dev/null | LC_ALL=C sort } snapshot() { # Exclusions are anchored to the tracked root -- a bare "*/tmp/*" would also # throw away legitimate files like /usr/share/foo/tmp/bar. # # LC_ALL=C on the sort, because the result is fed to `comm`, which compares # line by line and assumes BOTH sides used the same collation. One # snapshot sorted under a UTF-8 locale and the next under POSIX would make # comm report files as new that are not, and miss files that are -- silently, # since comm cannot tell. A package user's profile sets LC_ALL=POSIX and # root's shell may not, so the two sides really can differ. local r="${SNAP_ROOT%/}" # pruned, not filtered -- see snapshot_dirs find "${SNAP_ROOT:-/}" -xdev \ \( -path "$SRCROOT" -o -path "$r/dev" -o -path "$r/proc" \ -o -path "$r/sys" -o -path "$r/run" -o -path "$r/tmp" \ -o -path "$r/sources" -o -path "$r/build" \ -o -path "$r/root" -o -path "$r/home" \) -type d -prune \ -o \( -type f -o -type l \) -print \ 2>/dev/null | LC_ALL=C sort } # --------------------------------------------------------------------------- # # building # --------------------------------------------------------------------------- # script_for() { echo "$SCRIPTS/$1.sh"; } # Chapter 7's temporary tools are built as root (the book does it that way); # the package-user system takes over for the real chapter 8 packages. # The package USER a step's files belong to. Several steps are really the same # package built more than once -- gcc-pass1, gcc-pass2 and libstdcpp are all # GCC; binutils-pass1/-pass2 are both Binutils -- and chapter 8 rebuilds each of # them under the plain name. Giving each pass its own user would leave the # system with gcc-pass1-owned files that the real `gcc` user can never manage, # so every pass maps to the base package name. pkg_owner_name() { # Lower-case first. User names are case-sensitive on Unix, and the book's # anchors are not consistent: chapter 7 gives "python-tmp" but chapter 8 # gives "Python", which would become two different accounts -- and then the # chapter-8 package cannot overwrite its own chapter-7 files: # install: cannot remove '/usr/lib/python3.13/.../__init__.py': # Permission denied (owned by python, installing as Python) local n; n="$(printf '%s' "$1" | tr 'A-Z' 'a-z')" # Perl module names ("XML::Parser") are not valid user names n="${n//:://}"; n="${n//\//-}" case "$n" in libstdcpp|libstdc++) n=gcc ;; esac # strip a trailing -pass or -tmp n="${n%-tmp}" case "$n" in *-pass[0-9]) n="${n%-pass[0-9]}" ;; esac # Apply the prefix THIS KIND carries -- `p_` for a package, `cfg_` for a # config step -- rather than the package prefix regardless. Applying `p_` # to everything is what produced `p_cfg_bootscripts`: an account wearing # both prefixes, living in the config root and claiming to be a package. # # A legacy stacked name is repaired on the way through: pkgusr_kind strips # the package prefix before it looks, so `p_cfg_bootscripts` is recognised # as a config step, and the line below drops the `p_` it should never have # had. local pfx; pfx="$(pkgusr_prefix_for "$n")" if [ -n "${PKGUSR_PREFIX:-}" ]; then n="${n#"$PKGUSR_PREFIX"}"; fi # Idempotent: this is called on step names AND on values that already went # through it, so a second pass must not produce p_p_gcc. if [ -n "$pfx" ]; then case "$n" in "$pfx"*) ;; *) n="${pfx}${n}" ;; esac fi echo "$n" } # Per-phase progress. A step is only "built" after a full run, but people # legitimately run one phase at a time (retrying just `install` after a failed # test suite, say), so remember which phases finished and let `next` pick up # from there instead of suggesting the whole package again. PHASES="$STATE_PROGRESS/phases" # Pull the actual error out of a build log. With a parallel make the failing # job's message can be thousands of lines above the end, so showing the tail # (what you see on screen) is usually useless. show_build_errors() { local log="$1" hits="" _rootline="" [ -f "$log" ] || return 0 say "" # A COMMAND ONLY ROOT CAN RUN, IN A BUILD THAT IS NOT ROOT. # # mount: /tmp: must be superuser to use mount. # !! phase 'install' FAILED (exit 32) # ...and the report said "no line matched the usual error patterns", # because nothing here looks like a compiler error. It is not a # permission to GRANT either: no collector group makes mount work. The # command does not belong in a package's install at all (it was LVM2's # "remount /tmp so the TESTS can make device nodes"), so say exactly # that and name the line. _rootline="$(grep -m1 -E "must be superuser|only root can|Operation not permitted \(are you root|must be run as root|requires root privileges" \ "$log" 2>/dev/null)" if [ -n "$_rootline" ]; then fail "--- this step tried to do something only root may do ---" say " $_rootline" say " A package builds and installs as its own user. Mounting a" say " filesystem, loading a module or making a device node is the" say " system's business, not a package's -- and no collector group" say " can grant it. Such a command usually comes from a book block" say " that belongs to the TEST suite (blfs 1.14.334 drops those)." say " Regenerate the script, and run the command yourself if you" say " actually want what it does:" say " packagemanager update --reinstall --regenerate --run" fi hits="$(grep -nE "(^|[^-])\\berror\\b:|\\*\\*\\* \\[|Error [0-9]+|No such file or directory|command not found|undefined reference" \ "$log" 2>/dev/null | grep -vE "Error 1$|Error 2$" | head -12)" if [ -n "$hits" ]; then fail "--- first errors in the log ---" printf '%s\n' "$hits" | while IFS= read -r l; do say " $l" done else # An EMPTY section under a heading promising errors is worse than no # section: the reader concludes there is nothing to see. m4 failed # with "no M4-1.4.21.tar.* ... in /sources" -- a perfectly clear # message that matched none of the patterns above, so the report said # "--- first errors ---" and then nothing at all. # # No pattern matched is a statement about the PATTERNS, not about the # log. Show the end of it, which is where a failure that does not # look like a compiler error usually is. fail "--- no line matched the usual error patterns; the end of the log ---" tail -n 12 "$log" 2>/dev/null | while IFS= read -r l; do say " $l" done fi fail "-------------------------------" say " full log: $log" } phase_file() { echo "$PHASES/$1"; } record_phase() { mkdir -p "$PHASES"; grep -qxF "$2" "$(phase_file "$1")" 2>/dev/null \ || echo "$2" >> "$(phase_file "$1")"; } phase_done() { grep -qxF "$2" "$(phase_file "$1")" 2>/dev/null; } clear_phases() { rm -f "$(phase_file "$1")"; } # which phases does this FILE define? The runner tracks phases for scripts # it is handed by path (a packagemanager install runs the script from the # package's home, not from $SCRIPTS), so the answer must come from the file, # not from the name. script_phases_of_file() { [ -f "$1" ] || return 0 echo unpack echo build echo install # a phase is a function (format v18+); older scripts carried a marker grep -qE '^configure_pkg\(\)|^#### CONFIGURE ####' "$1" && echo configure return 0 } # which phases does this package's script actually define? script_phases() { script_phases_of_file "$(script_for "$1")" } # The phase to run next: the one AFTER the furthest phase already finished. # (Not simply the first unfinished one -- after running only `install`, the # earlier unpack/build had clearly happened too, and suggesting "unpack" would # send you backwards.) next_phase_for() { local name="$1" p last="" seen=0 while read -r p; do phase_done "$name" "$p" && { last="$p"; seen=1; } done < <(script_phases "$name") [ "$seen" = 1 ] || { script_phases "$name" | head -n1; return 0; } local after=0 while read -r p; do [ "$after" = 1 ] && { echo "$p"; return 0; } [ "$p" = "$last" ] && after=1 done < <(script_phases "$name") return 1 } # =========================================================================== # # SHARED WITH packagemanager (kept in sync by hand for now) # # These functions exist in both tools -- lfs-helper (bash, drives the chroot # build) and packagemanager/packagemanager_install (drives everything after). # They must behave identically; when one changes, change the other: # # here there # ---------------------------- ------------------------------------------- # unwritable_dirs_from_log packagemanager_install: same name # grant_dir_access packagemanager_install: grant_dir_to_user # is_install_dir + patterns packagemanager: config install_dirs # init_package_user_home packagemanager: init_package_user_home # ensure_pkgusr_etc packagemanager: ensure_pkgusr_etc # count_lines (bash-only) # # The rules they implement, in one place: # * a package's files belong to its package user -- always # * shared install dirs are root:install, group-writable, later sticky # * a directory another package owns is shared via a collector group; # if it already HAS one, join it -- never make a second # * NEVER a collector group for root; root-owned trees are adopted or left # * paths to fix come only from genuine permission-error lines, never from # tracebacks or incidental log text # =========================================================================== # # Count non-empty lines safely. # # `grep -c .` prints 0 AND exits non-zero when there is no match, so the common # `grep -c . file || echo 0` produces "0\n0" and every later numeric test fails # with "integer expression expected". count_lines() { local n if [ -f "$1" ]; then n=$(grep -c . "$1" 2>/dev/null) || n=0 else n=0 fi printf '%s' "${n:-0}" } count_stdin_lines() { local n n=$(grep -c . 2>/dev/null) || n=0 printf '%s' "${n:-0}" } is_root_step() { [ -f "$STATE_PROGRESS/rootsteps" ] && grep -qxF "$1" "$STATE_PROGRESS/rootsteps"; } # Remove a stale unpacked source tree BEFORE the build, as root. # # $BUILD_ROOT is sticky (1777), so only the owner may delete a directory in it. # Chapters 5-6 unpacked their sources as the `lfs` user, and chapter 8 builds # the same packages as their own package user -- which therefore cannot remove # the leftover tree. The script's own `rm -rf` fails, tar extracts on top of # it, and the build dies with a wall of # tar: gcc-15.2.0/README: Cannot open: File exists # tar: gcc-15.2.0: Cannot utime: Operation not permitted # Doing it here, as root, keeps the package user out of the problem entirely. # # Two directories now: the tarball is looked up in SOURCES_DIR (read-only to # builds) and the tree and markers are removed from BUILD_ROOT (scratch). # What init_package_user_home symlinks into every package user's home. # # Named once, because build_root_for has to avoid these names and a second list # would drift from this one. `build` is the hint's build helper script, linked # as ~/build -- which is exactly the name the per-package build tree was first # given, so `mkdir -p ~/build` hit the symlink: # mkdir: cannot create directory '/usr/src/pkgusr/p_gettext/build': File exists # cd: /usr/src/pkgusr/p_gettext/build: Not a directory pkgusr_skel_links() { printf '%s\n' .bash_profile:bash_profile .bashrc:bashrc build:build } # The subdirectory of a package user's home that its sources unpack into. # Must not collide with any name in pkgusr_skel_links -- assert_skel_safe. PKGUSR_BUILD_SUBDIR="${LFS_PKGUSR_BUILD_SUBDIR:-src}" # Where a package unpacks and builds. # # Its OWN package user's home -- /usr/src/pkgusr/p_gcc/src -- not a shared # /build that every package writes into at once. # # `src`, not `build`: ~/build is already the hint's build helper script. # # The shared scratch was a permanent ownership fight. Every build's # touched-file scan swept the whole tree in, so each package's manifest claimed # every other package's sources and chowned them; the next package chowned them # back. A real `verify` reported 3363 wrong-owner and 3348 of them were /build: # /build/bash-5.3.tar.gz is p_shadow want p_acl # /build/bash-5.3.tar.gz is p_zstd want p_acl (after "repairing" it) # No repair could ever settle that, because the question itself was wrong: the # scratch does not belong to a package, and asking which one owns it has no # answer. # # Under the package user's home it is settled by construction. The user creates # the tree, so the user owns it; $SRCROOT is already excluded from every tree # scan, so it can never enter a manifest at all; and removing a package removes # its build tree with it. That is also how the package-users hint does it. # # Steps with no account -- init-*, last-step, refind -- keep the shared scratch. # They run as root, which owns everything there anyway. build_root_for() { if _is_not_a_package "$1"; then printf '%s' "${BUILD_ROOT:-${SNAP_ROOT%/}/build}" else printf '%s/%s' "$(pkgusr_home_for "$1")" "$PKGUSR_BUILD_SUBDIR" fi } clean_stale_source() { local name="$1" script="$2" local srcdir="${SOURCES_DIR:-${SNAP_ROOT%/}/sources}" local blddir; blddir="$(build_root_for "$name")" [ -d "$srcdir" ] || return 0 local glob; glob="$(sed -n 's/^pkg_glob="\(.*\)"$/\1/p' "$script" | head -n1)" [ -n "$glob" ] || return 0 local pkg="" _c ( cd "$srcdir" || exit 0 for _c in $glob; do [ -e "$_c" ] || continue case "$_c" in *-src.*|*-source.*) echo "$_c"; exit 0 ;; esac done for _c in $glob; do [ -e "$_c" ] || continue case "$_c" in *-html.*|*-doc.*|*-docs.*|*-man.*|*-manual.*|*-tests.*) continue ;; esac echo "$_c"; exit 0 done ) > /tmp/.lfs-pkg.$$ 2>/dev/null pkg="$(cat /tmp/.lfs-pkg.$$ 2>/dev/null)"; rm -f /tmp/.lfs-pkg.$$ [ -n "$pkg" ] || return 0 # The build markers are written by whoever built last, and BUILD_ROOT is # sticky, so a marker left by the `lfs` user (chapters 5-6) cannot be # replaced by a chapter-8 package user, and the build dies with # .../.cc-build-ncurses: Permission denied # Clear them here, as root; the build recreates them. # Markers are named for the PACKAGE (name_version) since 1.14.5, and this # build root belongs to one package, so clear whatever is in it -- under # the old name, the older name, or a name_version this step no longer has. rm -f "$blddir"/.pkgusr-*.dir "$blddir"/.pkgusr-*.cwd \ "$blddir/.pkgusr-$name.dir" "$blddir/.pkgusr-$name.cwd" \ "$blddir/.cc-build-$name" "$blddir/.cc-dir-$name" 2>/dev/null local dir dir="$(tar tf "$srcdir/$pkg" 2>/dev/null | head -n1 | cut -d/ -f1)" [ -n "$dir" ] || return 0 [ -d "$blddir/$dir" ] || return 0 detail "# [root] removing stale source tree $blddir/$dir" rm -rf "${blddir:?}/${dir:?}" || warn "could not remove $blddir/$dir" } # --------------------------------------------------------------------------- # collector groups # # The package-user scheme gives every package its own user, but packages do # install into directories another package created -- gcc drops gcc.mo into # /usr/share/locale//LC_MESSAGES, which belongs to whichever package made # those directories. Without help the installing user simply cannot write # there: # install: cannot create regular file '.../gcc.mo': Permission denied # # The fix (same as the packagemanager script uses) is a COLLECTOR GROUP: the # owning package's directory is handed to a group named _, made # group-writable, and every package that needs to install there joins it. # Ownership stays with the owner; the others get exactly the access they need. # One source of truth, in this order: # 1. LFS_COLLECTOR_PREFIX -- set by the generated build env # 2. the lfs config -- what the system was actually built with # 3. packagemanager's own conf -- for a booted system with no lfs config # Disagreeing prefixes mean two parallel sets of groups over the same # directories, with nothing to say so, which is why this is resolved once here # rather than defaulted separately in each tool. _read_shared_prefix() { local v="" for f in /usr/share/lfs/config.json "$HOME/.local/share/lfs/config.json"; do [ -r "$f" ] || continue v="$(sed -n 's/.*"collector_prefix"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' \ "$f" | head -n1)" [ -n "$v" ] && { printf '%s' "$v"; return 0; } done for f in /etc/pkgusr/packagemanager.conf; do [ -r "$f" ] || continue v="$(sed -n 's/^collector_prefix=\(.*\)$/\1/p' "$f" | head -n1)" [ -n "$v" ] && { printf '%s' "$v"; return 0; } done return 1 } COLLECTOR_PREFIX="${LFS_COLLECTOR_PREFIX:-$(_read_shared_prefix || true)}" # Package users carry a prefix of their own: `p_gcc`, not `gcc`. # # Without it a package user and a system account can collide on one name, and # the collision is invisible until it bites. LFS chapter 7.6 creates `mail`, # `news`, `uucp`, `man` and friends, and BLFS daemons bring their own -- a # `sshd`, `nginx` or `postfix` package would want exactly the name its daemon # already uses. Two things then share one uid, and since ownership is stored # as a NUMBER, every file either of them owns reads as belonging to the other. # # The prefix also makes `ls -l` self-explanatory: `p_` is a package, `u_` an # application user, everything else is a real account. # # Resolved in the same order and for the same reason as COLLECTOR_PREFIX -- # two tools disagreeing about it would build two parallel sets of accounts. # PRESENT-AND-EMPTY IS AN ANSWER. # # `"pkgusr_prefix": ""` means NO PREFIX -- the comment two lines below has said # so since prefixes were introduced, and the reader ignored it: an empty value # failed `[ -n "$v" ]`, fell through to the default `p`, and lfs-helper went # on calling the account p_mako while packagemanager (which honours empty) # asked for mako. Two tools, two different names for one account. # # So: does the KEY EXIST? If it does, its value -- empty or not -- is the # answer. _read_pkgusr_prefix() { local f v for f in /usr/share/lfs/config.json "$HOME/.local/share/lfs/config.json"; do [ -r "$f" ] || continue grep -q '"pkgusr_prefix"[[:space:]]*:' "$f" || continue v="$(sed -n 's/.*"pkgusr_prefix"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' \ "$f" | head -n1)" printf '%s' "$v"; return 0 done for f in /etc/pkgusr/packagemanager.conf; do [ -r "$f" ] || continue grep -q '^pkgusr_prefix=' "$f" || continue v="$(sed -n 's/^pkgusr_prefix=\(.*\)$/\1/p' "$f" | head -n1)" printf '%s' "$v"; return 0 done return 1 } # `${VAR-...}` not `${VAR:-...}`: an explicitly EMPTY value means "no prefix", # which is how a tree built before prefixes existed keeps working. With `:-` # an empty value would silently fall through to the default and rename every # package user out from under that tree. # THE CONFIG IS CURRENT; THE ENVIRONMENT MAY BE OLD. # # Both sources can legitimately say "no prefix" -- a tree built without one # keeps working only because empty is meaningful. But they can disagree, and # then age decides: the chroot scripts export LFS_PKGUSR_PREFIX from whatever # the prefix was WHEN THEY WERE WRITTEN, while the config file is what the # system says now. The user's chroot exported an empty value from an earlier # experiment; both config files said "p"; lfs-helper believed the export and # announced a disagreement that did not exist. # # So: a config that NAMES a prefix wins. Otherwise the environment is # honoured, empty included. Otherwise the default. _cfg_prefix="$(_read_pkgusr_prefix || true)" if [ -n "$_cfg_prefix" ]; then PKGUSR_PREFIX="$_cfg_prefix" else PKGUSR_PREFIX="${LFS_PKGUSR_PREFIX-$(_read_pkgusr_prefix || echo p)}" fi unset _cfg_prefix PKGUSR_PREFIX="${PKGUSR_PREFIX%_}" [ -n "$PKGUSR_PREFIX" ] && PKGUSR_PREFIX="${PKGUSR_PREFIX}_" # Config steps carry their own prefix, and only their own. # # They used to be `p_cfg_bootscripts`: the package prefix stacked on top of the # step name's own `cfg_`. Two prefixes on one account means neither one tells # you what the account is -- `p_` claimed it was a package while it lived in the # config root -- and every rule about prefixes had to special-case it. One # account, one prefix: `p_gcc` is a package, `cfg_bootscripts` is a config step, # `u_firefox` is an application user. CFGUSR_PREFIX="${LFS_CFGUSR_PREFIX-cfg}" CFGUSR_PREFIX="${CFGUSR_PREFIX%_}" [ -n "$CFGUSR_PREFIX" ] && CFGUSR_PREFIX="${CFGUSR_PREFIX}_" # What KIND of account is this -- `pkg` or `cfg`? # # This is the one place that decides, and everything about a kind hangs off it: # pkgusr_prefix_for and pkgusr_root_for answer from it, pkg_owner_name builds # the account name with it, pkgusr_home_for places the home with it. Before, # the kind was re-derived by pattern-matching `cfg_*` at each site, which is how # a name could be routed to the config root while being given the package # prefix. # # Takes any of the three: a step name (`cfg_bootscripts`), an account name # (`cfg_bootscripts`, `p_gcc`), or a legacy stacked name (`p_cfg_bootscripts`). # # Note there is no `tmp` kind and no `init` kind. A temporary step is the same # package chapter 8 rebuilds, so it is built as that package's user from the # start -- see pkg_owner_name's -tmp fold. An init step owns no files at all # and gets no account -- see step_stage_dir. pkgusr_kind() { local n="$1" [ -n "$PKGUSR_PREFIX" ] && n="${n#"$PKGUSR_PREFIX"}" case "$n" in "$CFGUSR_PREFIX"*) printf 'cfg' ;; *) printf 'pkg' ;; esac } pkgusr_prefix_for() { case "$(pkgusr_kind "$1")" in cfg) printf '%s' "$CFGUSR_PREFIX" ;; *) printf '%s' "$PKGUSR_PREFIX" ;; esac } pkgusr_root_for() { case "$(pkgusr_kind "$1")" in cfg) printf '%s' "$CFGUSR_ROOT" ;; *) printf '%s' "$PKGUSR_ROOT" ;; esac } # Strip the prefix this account's kind carries: account name -> step name. # # A config step's own prefix is NOT stripped: `cfg_bootscripts` is what the book # step is called, so for that kind the account name and the step name are the # same string. Stripping it would leave `bootscripts`, and every rule that asks # "is this a config step?" reads the name. unprefix_pkg_user() { local n="$1" [ -n "$PKGUSR_PREFIX" ] || { printf '%s' "$n"; return; } printf '%s' "${n#"$PKGUSR_PREFIX"}" } # Home of one account, by kind. Takes a step name or an account name. pkgusr_home_for() { # Resolve to the ACCOUNT's home, whichever kind of name we are handed. # # Callers pass a mixture and always have: build_pkg has the STEP name # ("man-pages", "util-linux-tmp"), the staging code has the OWNER # ("p_man-pages", "p_util-linux"). pkg_owner_name is what maps one to the # other -- it applies the prefix, lowercases, and folds -tmp/-passN back # onto the real package -- and it is idempotent, so an owner passed in # comes back unchanged. # # Routing by kind WITHOUT that mapping is the bug this replaced: the # account home was /usr/src/pkgusr/p_man-pages and the build cd'd to # /usr/src/pkgusr/man-pages, which does not exist: # line 2673: cd: /usr/src/pkgusr/man-pages: No such file or directory # It only bit the package-user branch -- the root-step branch mkdir -p's # its own directory first, so chapter 7 built fine and it surfaced at the # first real package user. local n; n="$(pkg_owner_name "$1")" printf '%s/%s' "$(pkgusr_root_for "$n")" "$n" } # Where a step's install script is staged, and what it is run from. # # A package step runs from its package user's home. A step that is NOT a # package -- init-*, last-step, refind, a prose cfg_* section -- never gets an # account, so asking pkgusr_home_for for its home manufactures a directory # under the package root for a user that will never exist. That is exactly the # stray `p_init-dirs` and `p_init-files` a sanity run found: directories with no # matching account, which every ownership pass then has to report as strays. # # Those steps run as root out of the state directory, beside the scripts they # came from. Nothing there is owned by a package, and nothing pretends to be. step_stage_dir() { if _is_not_a_package "$1"; then printf '%s/steps/%s' "$STATE" "$(unprefix_pkg_user "$1")" else pkgusr_home_for "$1" fi } # Expose the chokepoint to shell scripts. Without this, anything outside # lfs-helper that needs an account's home re-derives the rule -- prefix, kind, # root -- and that third copy is exactly how the two roots drift apart. cmd_pkgusr_home() { [ -n "${1:-}" ] || die "usage: lfs-helper pkgusr-home " pkgusr_home_for "$1" echo } # The other half of the same chokepoint: the ACCOUNT NAME for a package. # # `pkgusr-home` was exposed and this was not, so a shell script outside this # tool could ask where a package lives but not what its user is called -- and # last_build_step.sh did the only thing left, which was assume: # # lfs-helper add-user wget # chown -R "wget:wget" ... -> chown: invalid user: 'wget:wget' # # It had been right by accident while add-user created the account from the raw # name; the moment add-user started applying the prefix (as everything else # already did), the assumption broke. Half a chokepoint is not a chokepoint. cmd_owner_name() { [ -n "${1:-}" ] || die "usage: lfs-helper owner-name " pkg_owner_name "$1" } # --------------------------------------------------------------------------- # # A home that never met its files. # # The hint's add_package_user takes no home argument and creates # /usr/src/. Every tool here computes the home through # pkgusr_home_for -- /usr/src/pkgusr/ -- and the python side's repair # only ran `usermod -d`: it repointed the passwd entry and LEFT THE DIRECTORY # BEHIND. A real bootstrap showed the result: # # /usr/src/p_wget /usr/src/p_requests /usr/src/p_make-ca ... # # husks beside the roots, each holding the skeleton add_package_user copied in, # while the real homes lived under /usr/src/pkgusr/. Three tools each had # part of the repair; this is the whole of it, in one place, and the others # call it. # --------------------------------------------------------------------------- # # The home the user DATABASE says -- $ETC/passwd first, for the same reason # user_exists reads it: inside the chroot the file IS the database. _recorded_home_for() { if [ -r "$ETC/passwd" ]; then awk -F: -v u="$1" '$1==u{print $6; exit}' "$ETC/passwd" else getent passwd "$1" 2>/dev/null | cut -d: -f6 fi } _set_recorded_home() { local acct="$1" home="$2" # CHECK THAT IT TOOK. # # `usermod -d` returned 0 and /etc/passwd still said the old home, so # fix-home printed "# p_n76310: home -> ..." and changed nothing -- three # times in a row, each run reporting success. A write that cannot be # read back did not happen: try usermod, verify, and fall through to the # file rewrite when it silently did nothing. if have_shadow_tools && command -v usermod >/dev/null 2>&1; then usermod -d "$home" "$acct" 2>/dev/null [ "$(_recorded_home_for "$acct")" = "$home" ] && return 0 fi # No usermod (pre-Shadow chroot): edit the file, the way raw_useradd wrote # it. Rewrite to a temp file and cat back, preserving the inode. [ -w "$ETC/passwd" ] || return 1 local tmp; tmp="$(mktemp)" || return 1 awk -F: -v OFS=: -v u="$acct" -v h="$home" '$1==u{$6=h}1' "$ETC/passwd" > "$tmp" && cat "$tmp" > "$ETC/passwd" rm -f "$tmp" # ...and read it back here too: a rewrite that left the line alone (a # name that does not match, a passwd file somewhere else) is a failure, # not a success with an exit code of 0 from `cat`. [ "$(_recorded_home_for "$acct")" = "$home" ] } # Move src's contents into dst, entry by entry: # * missing in dst -> moved # * identical regular file -> the stray copy is deleted # * anything else -> KEPT, so no file is ever destroyed # Removes src if that empties it; returns 1 if anything had to be kept. # What was kept lands in _MERGE_KEPT -- "compare and remove by hand" without # saying WHICH files sends the user diffing eleven directory pairs to find # them. The report must name the work it hands over. # Is $1 under directory $2? An EMPTY $2 means "/" (the whole tree), never # the wildcard that `case "$p" in "$2"/*)` silently becomes -- which is how # cmake's missing directory stayed invisible in every real chroot while # being found in every sandbox. See missing_dirs_from_log. under_dir() { local p="$1" d="${2%/}" case "$p" in /*) ;; *) return 1 ;; esac [ -z "$d" ] && return 0 [ "${p#"$d"/}" != "$p" ] } _MERGE_KEPT="" # The files every package-user home gets when it is created. They carry no # state: whoever made the account wrote them, and the real home's copies are # the ones in use. _is_skeleton_file() { case "$1" in .bashrc|.bash_profile|.project|build|build.conf|.wget-hsts|.bash_history) return 0 ;; esac return 1 } _merge_dir_into() { local src="$1" dst="$2" f rel kept=0 _MERGE_KEPT="" [ -d "$src" ] || return 0 [ "$src" = "$dst" ] && return 0 while IFS= read -r -d '' f; do rel="${f#"$src"/}" if [ ! -e "$dst/$rel" ] && [ ! -L "$dst/$rel" ]; then mkdir -p "$dst/$(dirname "$rel")" 2>/dev/null mv "$f" "$dst/$rel" 2>/dev/null || { kept=$((kept+1)); _MERGE_KEPT="$_MERGE_KEPT$f"$'\n'; } elif [ -f "$f" ] && [ ! -L "$f" ] && [ -f "$dst/$rel" ] && cmp -s "$f" "$dst/$rel"; then rm -f "$f" elif _is_skeleton_file "$rel"; then # THE SKELETON IS NOT DATA. # # .bashrc, .bash_profile, build, build.conf and .project are # created by whoever made the account, so a husk and a real home # have five files that "differ" -- and the merge refused, the # home was never repaired, and `fix-home` reported success while # changing nothing that mattered. The destination's copies are # the current ones; the husk's are a duplicate of the same idea. rm -f "$f" 2>/dev/null || { kept=$((kept+1)); _MERGE_KEPT="$_MERGE_KEPT$f"$'"'"'\n'"'"'; } else kept=$((kept+1)); _MERGE_KEPT="$_MERGE_KEPT$f"$'\n' fi done < <(find "$src" -mindepth 1 \( -type f -o -type l \) -print0 2>/dev/null) find "$src" -depth -mindepth 1 -type d -empty -delete 2>/dev/null rmdir "$src" 2>/dev/null || true [ "$kept" = 0 ] && [ ! -d "$src" ] } # Report what a merge kept: up to five paths, then a count. _report_merge_kept() { local n shown=0 f n="$(printf '%s' "$_MERGE_KEPT" | grep -c .)" [ "$n" -gt 0 ] || return 0 while IFS= read -r f; do [ -n "$f" ] || continue warn " differs: $f" shown=$((shown+1)); [ "$shown" -ge 5 ] && break done <<< "$_MERGE_KEPT" [ "$n" -gt 5 ] && warn " ... and $((n-5)) more" return 0 } # The whole repair for ONE account: recorded home vs computed home, plus the # husk add_package_user leaves beside the roots. Reports always; acts when # fix=1. Returns 0 when nothing was wrong. # $3=1: this account was JUST created, so add_package_user's own home is # expected and not news. It takes no home argument, so EVERY new account is # briefly "misplaced" -- reporting that as a warning meant a normal install # opened with two lines of alarm about a condition the next line repairs. A # misplaced home found any OTHER way is still a warning: then it is a real # account that has been pointing at the wrong directory for who knows how long. # The BLFS package that provides a command, for the handful the books use as # build tools without always listing them. Not a database -- just the ones # that actually bite. _package_providing() { case "$1" in cmake|ccmake|cpack|ctest) echo "cmake" ;; ninja) echo "ninja" ;; meson) echo "meson" ;; cargo|rustc) echo "rust" ;; go|gofmt) echo "go" ;; git) echo "git" ;; nasm) echo "nasm" ;; yasm) echo "yasm" ;; doxygen) echo "doxygen" ;; gperf) echo "gperf" ;; itstool) echo "itstool" ;; xsltproc|xmlcatalog|xmllint) echo "libxslt" ;; pkg-config|pkgconf) echo "pkgconf" ;; wget) echo "wget" ;; curl) echo "curl" ;; unzip|zipinfo) echo "unzip" ;; java|javac) echo "openjdk" ;; ruby) echo "ruby" ;; valac) echo "vala" ;; sassc) echo "sassc" ;; asciidoc|asciidoctor) echo "asciidoc" ;; docbook2*|db2*) echo "docbook-utils" ;; *) echo "" ;; esac } _repair_misplaced_home() { local acct="$1" fix="${2:-0}" fresh="${3:-0}" want got stray rc=0 husk user_exists "$acct" || return 0 want="$(pkgusr_home_for "$acct")" got="$(_recorded_home_for "$acct")" husk="$SRCROOT/$acct" # the same place the husk check below uses if [ -n "$got" ] && [ "$got" != "$want" ]; then rc=1 if [ "$fresh" = 1 ] && [ "$got" = "$husk" ]; then detail " add_package_user put the home at $got; moving it to $want" else warn "misplaced home: $acct is recorded at $got" warn " every tool looks at $want" fi if [ "$fix" = 1 ]; then if [ -d "$got" ] && [ ! -e "$want" ]; then mkdir -p "$(dirname "$want")" 2>/dev/null mv "$got" "$want" 2>/dev/null || cp -a "$got" "$want" elif [ -d "$got" ]; then _merge_dir_into "$got" "$want" \ || { warn " $got kept: these files differ from $want's; compare and remove by hand" _report_merge_kept; } else mkdir -p "$want" 2>/dev/null fi if _set_recorded_home "$acct" "$want"; then soft "could not chown the repaired home to $acct" -- \ real_chown "$acct:$acct" "$want" ok "# $acct: home -> $want" _vfy_n_fixed=$((_vfy_n_fixed + 1)) else warn " could not record $want as $acct's home" fi fi fi # The husk: add_package_user's default location, beside the roots. stray="$SRCROOT/$acct" if [ "$stray" != "$want" ] && [ -d "$stray" ] && [ ! -L "$stray" ]; then rc=1 warn "stray home: $stray (the real home is $want)" if [ "$fix" = 1 ]; then mkdir -p "$want" 2>/dev/null if _merge_dir_into "$stray" "$want"; then soft "could not chown the repaired home to $acct" -- \ real_chown "$acct:$acct" "$want" ok "# merged $stray into $want" _vfy_n_fixed=$((_vfy_n_fixed + 1)) else warn " $stray kept: these files differ from $want's; compare and remove by hand" _report_merge_kept fi fi fi return "$rc" } # `lfs-helper fix-home` -- the one door the other tools call. cmd_fix_home() { need_root local name="" fix=0 while [ $# -gt 0 ]; do case "$1" in --run|--fix) fix=1; shift ;; -h|--help) cmd_help_for fix-home; return 0 ;; -*) die "unknown option: $1 (usage: lfs-helper fix-home [--run])" ;; *) name="$1"; shift ;; esac done [ -n "$name" ] || die "usage: lfs-helper fix-home [--run]" local acct; acct="$(pkg_owner_name "$name")" if _repair_misplaced_home "$acct" "$fix"; then ok "# $acct: home is where every tool looks ($(pkgusr_home_for "$acct"))" return 0 fi if [ "$fix" != 1 ]; then say " nothing changed. Repair with: lfs-helper fix-home $name --run" return 1 fi return 0 } # Every account root, for the passes that walk all of them. pkgusr_roots() { printf '%s\n%s\n' "$PKGUSR_ROOT" "$CFGUSR_ROOT"; } # Create the account roots with the ownership and mode they are meant to have. # # They used to appear as a side effect of `mkdir -p` on the first home, so they # got whatever root's umask gave them. That happened to be right -- # root:root 0755 -- but nothing said so, and nothing would have noticed it # becoming something else. Now it is stated, and applied every time. # # root:root 0755 is the decision: only root creates account homes, so the # install group has no business here, and without a sticky bit a group-writable # root would let any package user delete another package's home. ensure_pkgusr_roots() { local r while IFS= read -r r; do [ -n "$r" ] || continue mkdir -p "$r" 2>/dev/null || { warn "!! could not create the account root $r"; continue; } # root:install 1775, the same as /usr/src above them. # # They were root:root 0755 -- defensible, since only root creates an # account home, but it made /usr/src three directories with two rules: # # drwxrwxr-t root install /usr/src # drwxr-xr-x root root /usr/src/pkgusr # drwxr-xr-x root root /usr/src/cfg # # and a special case is a thing to remember. One rule for the subtree. # # The sticky bit is what makes group-writable safe here, and it is not # optional: without it any member of `install` could delete or rename # another package's home. With it, only the owner can. Group write then # grants only the ability to CREATE, which nothing unprivileged does -- # `add-user` is root-only -- so this is consistency, not capability. # SUPPRESSED DELIBERATELY, same reason as mkstate: the roots are # created before the `install` group exists. `lfs-helper verify` # checks the result afterwards, which is the honest place to find out. set_install_dir_owner "$r" 2>/dev/null || true # SUPPRESSED DELIBERATELY: same moment, same reason as the line above. real_chmod 1775 "$r" 2>/dev/null || true done < <(pkgusr_roots) } GRANTED="$STATE_GROUPS/granted-groups" # dir|group, one per line: the decisions made so far. Exported so a rebuild # can reuse them instead of asking the same questions again. COLLECTOR_MAP="$STATE_GROUPS/collector-map" require_collector_prefix() { [ -n "$COLLECTOR_PREFIX" ] && return 0 die "no collector-group prefix configured. Packages sometimes install into a directory another package owns -- gcc writes gcc.mo into glibc's /usr/share/locale/*/LC_MESSAGES. Such a directory keeps its owner but is handed to a group named _ and made group-writable, and both packages join it. Set the prefix OUTSIDE the chroot, then regenerate: lfs config collector_prefix sysgroup lfs build-system gen-chroot-scripts --run --overwrite" } # A collector group is named for the PACKAGE, not for the account. # # It took the account name straight, so the owner `p_perl` produced # `nimgnu_p_perl` -- two prefixes stacked, and the wrong two: `nimgnu_` says # "collector group" and `p_` says "package user", which it is not. The prompt # offered it as a choice: # 1) nimgnu_p_perl (named after the owner -- reusable, fewer groups) # # NOT "everything owned by p_perl", which is what this said and it was simply # untrue: a collector group grants the directories it is applied to, one at a # time. Choosing the owner-named group only means the NAME can be reused on # the next directory of that owner; it grants nothing extra by itself. # Strip the account's prefix first; the collector prefix is the only one a # collector group carries. Same rule as everywhere else: one name, one prefix. collector_group_for() { echo "${COLLECTOR_PREFIX}_$(unprefix_pkg_user "$1")"; } ensure_group() { local grp="$1" group_exists "$grp" && return 0 if have_shadow_tools; then # groupadd fails if it already exists in the real database (which # group_exists may not see when $ETC is redirected) -- treat that as fine create_collector_group "$grp" || return 1 else local gid=$(( INSTALL_GID - 1 )) while getent group "$gid" >/dev/null 2>&1 \ || grep -q ":x:$gid:" "$ETC/group" 2>/dev/null; do gid=$(( gid - 1 )) done echo "$grp:x:$gid:" >> "$ETC/group" fi } add_to_group() { local user="$1" grp="$2" if have_shadow_tools && command -v usermod >/dev/null 2>&1; then usermod -a -G "$grp" "$user" 2>/dev/null && return 0 fi local line members line="$(grep "^$grp:" "$ETC/group" 2>/dev/null)" || return 1 members="${line##*:}" case ",$members," in *",$user,"*) return 0 ;; esac [ -n "$members" ] && members="$members,$user" || members="$user" local gid; gid="$(echo "$line" | cut -d: -f3)" sed -i "s|^$grp:.*|$grp:x:$gid:$members|" "$ETC/group" } # Group names may only sensibly contain [a-z0-9_-]; keep them short too, since # many tools cap group names at 32 characters. # Is $1 a near miss for $2 -- same string but for one insertion, deletion or # substitution? Used to catch a mistyped collector prefix without second- # guessing a name that was chosen on purpose. _near_miss() { local a="$1" b="$2" [ "$a" = "$b" ] && return 1 local la=${#a} lb=${#b} case $((la - lb)) in 0) local d=0 i c1 c2 for ((i = 0; i < la; i++)); do c1="${a:i:1}"; c2="${b:i:1}" [ "$c1" = "$c2" ] || d=$((d + 1)) [ "$d" -gt 1 ] && return 1 done [ "$d" = 1 ] && return 0 ;; 1|-1) local long short if [ "$la" -gt "$lb" ]; then long="$a"; short="$b" else long="$b"; short="$a"; fi local i for ((i = 0; i < ${#long}; i++)); do [ "${long:0:i}${long:i+1}" = "$short" ] && return 0 done ;; esac return 1 } sanitise_group_name() { # `tr -c` turns EVERY other character into a dash, including spaces -- so # an answer typed with a leading space became "-name", and one with two # became "--name", which groupadd rejects as an option: # could not create group --nimgnu_gcc # Trim first, then collapse runs of dashes, then strip them from the ends: # a group name may not start with a dash, and a trailing one is just noise. local n="$1" n="${n#"${n%%[![:space:]]*}"}" # leading whitespace n="${n%"${n##*[![:space:]]}"}" # trailing whitespace printf '%s' "$n" \ | tr 'A-Z' 'a-z' \ | tr -c 'a-z0-9_-' '-' \ | sed -E 's/-+/-/g; s/^-+//; s/-+$//' \ | cut -c1-32 } # Which collector group should own this directory? # # The default names it after the OWNING package (_binutils), which # groups every directory that package owns under one group. Naming it after # the DIRECTORY instead (_bfd-plugins) is often clearer when the point # is "anyone who installs a linker plugin", so offer both and let the user # decide -- with enough context to choose sensibly. # Is already a member of ? user_in_group() { local u="$1" g="$2" line if [ -r "$ETC/group" ]; then line="$(grep "^$g:" "$ETC/group" 2>/dev/null | head -n1)" else line="$(getent group "$g" 2>/dev/null)" fi [ -n "$line" ] || return 1 case ",${line##*:}," in *",$u,"*) return 0 ;; esac return 1 } choose_collector_group() { local dir="$1" owner="$2" user="$3" local by_owner by_dir by_owner="$(sanitise_group_name "$(collector_group_for "$owner")")" by_dir="$(sanitise_group_name "${COLLECTOR_PREFIX}_$(basename "$dir")")" # already handled? reuse whatever group the directory has local cur; cur="$(stat -c %G "$dir" 2>/dev/null)" case "$cur" in "${COLLECTOR_PREFIX}_"*) echo "$cur"; return 0 ;; esac # AN ANCESTOR'S GROUP ALREADY ANSWERED THIS. # # /usr/lib/python3.14/site-packages is nimgnu_python; a package needing # .../site-packages/gi/overrides was asked to name a group for it, when # the enclosing tree had been settled long ago. A nested directory # inherits the nearest ancestor's collector group -- the decision to # share python's tree was made when that group was created. # # Only ancestors, and only collector groups: an unrelated group on a # parent (root, install) says nothing about sharing. local _anc="$dir" _ag while [ "$_anc" != "/" ] && [ -n "$_anc" ]; do _anc="$(dirname "$_anc")" [ "$_anc" = "/" ] && break _ag="$(stat -c %G "$_anc" 2>/dev/null)" case "$_ag" in "${COLLECTOR_PREFIX}_"*) # STDOUT IS THE RETURN VALUE HERE. # # This function ANSWERS on stdout, and these two lines were # written with plain `say` -- so the caller captured the # explanation as part of the group name: # chgrp "# ... is inside ... nimgnu_gst\n# -- using # that group\nnimgnu_gst" /usr/share/... # which failed, silently, and looked like "nothing to grant". # Everything informational in here goes to stderr; the # prompt body already goes to /dev/tty for the same reason. say "# $dir is inside $_anc, which is shared through $_ag" >&2 say "# -- using that group (no new one needed)" >&2 echo "$_ag"; return 0 ;; esac done # Already a member of the owner's collector group? Then the decision was # made when that group was created: this package may install into the # owner's directories. Asking again for every directory is just noise -- # a package in _python installing into a python-owned directory is # precisely what that group is for. The directory still changes one at a # time; membership does not hand over python's whole tree. if group_exists "$by_owner" && user_in_group "$user" "$by_owner"; then echo "$by_owner" return 0 fi # decided before (this run or an imported list)? don't ask again # An export copied into the tree is a decision already made, even if # `import-groups` has not been run. Consulting it here means the build # never stops to ask a question whose answer is sitting in the tree. if [ -f "$STATE_GROUPS/collector-groups.import" ]; then local _pre _pre="$(awk -F'|' -v d="$dir" \ '$1 == "dir" && $2 == d { print $3; exit }' \ "$STATE_GROUPS/collector-groups.import" 2>/dev/null)" if [ -n "$_pre" ]; then echo "$_pre" return 0 fi fi if [ -f "$COLLECTOR_MAP" ]; then local mapped mapped="$(awk -F'|' -v d="${dir#${SNAP_ROOT%/}}" \ '$1 == d { print $2; exit }' "$COLLECTOR_MAP" 2>/dev/null)" if [ -n "$mapped" ]; then echo "$mapped"; return 0 fi fi # Ask on /dev/tty, not stdin: this runs inside a `while ... done <<< list` # loop, so stdin is the directory list and a plain `read` would silently # consume that instead of waiting for an answer. # --yes ANSWERS A QUESTION; IT DOES NOT NAME THINGS. # # An EXISTING group is a yes/no ("share this directory through it?") and # --yes may answer that. Creating a NEW one is a choice of name that # will sit on the system for good, and the user asked to keep making it: # a run with --yes still stops here when the group does not exist yet and # a terminal is available. With no terminal there is nobody to ask, so # the owner-named choice stands -- loudly. if [ ! -r /dev/tty ] || [ ! -w /dev/tty ]; then warn "granting $dir to group $by_owner (no terminal to ask on)" group_exists "$by_owner" || warn " (this CREATES the group $by_owner)" echo "$by_owner"; return 0 fi if assume_yes && group_exists "$by_owner"; then warn "granting $dir to the existing group $by_owner (--yes)" echo "$by_owner"; return 0 fi if assume_yes; then warn "--yes does not name a NEW collector group -- asking:" fi { say "" say " '$user' needs to install into: $dir" say " that directory belongs to package '$owner'" say " contents: $(ls -A "$dir" 2>/dev/null | wc -l) entry(ies)" say " Which group should share THIS DIRECTORY?" # The two NAMES are the answer to the question; everything around them is # explanation. Unhighlighted, the reader has to hunt for them in eight # lines of prose -- and this prompt appears over and over during a stack. say " 1) ${C_B}${by_owner}${C_OFF} (named after the owner, so the same group can be" say " reused on other directories of '$owner' -- fewer groups overall)" say " 2) ${C_B}${by_dir}${C_OFF} (named after the directory -- clearer, more groups)" say " or type another name" say " Either way it grants $dir and nothing else: a group applies to the" say " directories it is put on, never to everything a package owns." } > /dev/tty # and on the ordinary output too, so a run behind a pipe does not look # hung when it is only waiting warn "WAITING FOR AN ANSWER: which group should share $dir?" # Ask until the answer is usable. Quietly substituting a different group # than the one asked for is worse than asking again -- the whole point of # this prompt is that the choice is the user's. local ans picked attempts=0 while :; do attempts=$((attempts + 1)) printf ' [1/2/name] (default 1): ' > /dev/tty # -e: readline editing, so arrow keys and backspace work while # typing an answer instead of inserting escape codes if ! read -e -r ans < /dev/tty; then # no terminal (a script, a pipe): take the default rather than # looping forever on input that will never come echo "$by_owner" return 0 fi case "$ans" in ""|1) echo "$by_owner"; return 0 ;; 2) echo "$by_dir"; return 0 ;; esac picked="$(sanitise_group_name "$ans")" if [ -n "$picked" ]; then # A TYPO IN A GROUP NAME IS PERMANENT. # # Someone typing their own name meant to type a collector group # and wrote `nimnu_gdb` for `nimgnu_gdb`. It is a valid group # name, so it was created and the directory handed to it -- and # nothing that looks for `nimgnu_*` groups will ever see it again. # A name that is nearly the prefix is almost certainly meant to BE # the prefix, so say so and offer the correction; a name that is # nothing like it is a deliberate choice and is left alone. # ONE DEFAULT, NOT TWO. `lfs` and `packagemanager` both fall back # to "sysgroup"; only this line said "nimgnu". On a system whose # config never set the key, groups were CREATED as nimgnu_* while # packagemanager filtered for sysgroup_* -- so its own collector # groups came out of `verify` as broken packages. local _pfx="${COLLECTOR_PREFIX:-sysgroup}" case "$picked" in "${_pfx}_"*) ;; *_*) local _head="${picked%%_*}" _tail="${picked#*_}" if _near_miss "$_head" "$_pfx"; then echo " '$_head' is not '$_pfx' -- collector groups" \ "start with '${_pfx}_'." > /dev/tty printf " use '%s_%s' instead? [Y/n]: " \ "$_pfx" "$_tail" > /dev/tty local _yn="" read -e -r _yn < /dev/tty || _yn="" case "$_yn" in [Nn]*) ;; *) picked="${_pfx}_${_tail}" ;; esac fi ;; esac # say so when the name had to be adjusted, so it is never a # surprise which group ends up owning the directory [ "$picked" = "$ans" ] \ || echo " using '$picked' (adjusted from '$ans')" > /dev/tty echo "$picked" return 0 fi echo " '$ans' has nothing usable in it -- a group name needs" > /dev/tty echo " letters, digits, '_' or '-'. Try again, or 1 / 2." > /dev/tty if [ "$attempts" -ge 5 ]; then echo " giving up after $attempts tries -- using $by_owner" > /dev/tty echo "$by_owner" return 0 fi done } # The top of the tree a directory belongs to: walk up while the parent has the # same owner and is not one of the shared install directories. Python's # standard library is ~200 directories under /usr/lib/python3.13, so granting # one failure at a time never finishes -- share from the top instead. collector_tree_top() { local top="$1" parent owner owner="$(stat -c %U "$top" 2>/dev/null)" || { echo "$top"; return 0; } while :; do parent="${top%/*}" [ -n "$parent" ] || break [ "$parent" = "${SNAP_ROOT%/}" ] && break [ -d "$parent" ] || break is_install_dir "$parent" && break [ "$(stat -c %U "$parent" 2>/dev/null)" = "$owner" ] || break top="$parent" done echo "$top" } # give the right to install into grant_dir_access() { local dir="$1" user="$2" owner grp [ -d "$dir" ] || return 0 owner="$(stat -c %U "$dir" 2>/dev/null)" || return 0 # A GROUP ON A LEAF IS NO USE IF THE CHAIN IS CLOSED. # # The user put it exactly right: a collector group does not work without # its parents. Writing into .../gst/audio needs SEARCH (x) on every # directory above it -- and those belong to other packages too: # /usr/include/gstreamer-1.0/gst p_gstreamer10:nimgnu_gst drwxrwxr-x # /usr/include/gstreamer-1.0 p_gstreamer10:p_gstreamer10 drwxr-xr-x # Search happens to be open here (o+x), but a package that tightens its # own directory would break every package installing below it. # # So walk up first and make sure each ancestor is at least searchable, # stopping at the first one that already is for everyone. local _up="$dir" while :; do _up="$(dirname "$_up")" case "$_up" in /|/usr|/etc|/var|"") break ;; esac [ -d "$_up" ] || break if su -s /bin/sh -c "test -x '$_up'" "$user" 2>/dev/null; then break # searchable already: so is everything above fi say " $_up is not searchable by '$user' -- opening the path" soft "could not make $_up searchable" -- real_chmod o+x "$_up" done # ALREADY WRITABLE IS NOTHING TO GRANT. # # The caller now offers every destination a meson install announced, not # only the one that failed -- most of which the package can already write # (its own directories, and shared ones it is a member of). Skipping # them keeps that from creating groups nobody needs. if su -s /bin/sh -c "test -w '$dir'" "$user" 2>/dev/null; then return 0 fi # Grant exactly what was asked for. Promoting the request to "the top of # the owner's tree" turned one directory into a whole hierarchy -- and for # a root-owned tree that meant offering to share /sources. The package # asks again for the next directory it needs, which is cheap now that an # existing membership grants immediately (below). # Fast path: the directory already carries a collector group this user # belongs to. The decision was made when the group was created, so making # it again is just noise -- open the directory to the group and move on. local curgrp curgrp="$(stat -c %G "$dir" 2>/dev/null)" if [ -n "$curgrp" ] && [ "$curgrp" != root ] \ && case "$curgrp" in "$COLLECTOR_PREFIX"*) true ;; *) false ;; esac \ && id -nG "$user" 2>/dev/null | tr ' ' '\n' | grep -qx "$curgrp"; then soft "$dir is group $curgrp but not group-writable -- the grant will not take" \ -- real_chmod g+rwx "$dir" say " $dir -> already group $curgrp, and '$user' is a member" return 0 fi # A directory that really is shared infrastructure -- /usr/lib, # /usr/share/man/de/man1 and the rest of installdirs.lst -- gets the install # group and stays root-owned. Nothing else changes here. if is_install_dir "$dir"; then # ...but never on a directory a PACKAGE USER owns. The install group # means "anyone may install here", and applying it to a package's own # directory hands that package's tree to everyone without asking. # If a package owns it, the answer is a collector group, and that is a # decision to put to the user -- fall through. local _cur_owner _cur_owner="$(stat -c %U "$dir" 2>/dev/null)" if [ -n "$_cur_owner" ] && [ "$_cur_owner" != root ] \ && [ "$_cur_owner" != "$user" ] && user_exists "$_cur_owner"; then say " $dir is owned by '$_cur_owner' -- asking rather than" say " opening it to every package." else real_chgrp install "$dir" 2>/dev/null real_chmod g+w "$dir" 2>/dev/null say " $dir -> group install (shared install directory)" return 0 fi # owned by another package: fall through to the collector-group path, # which asks rather than deciding on the user's behalf fi # A ROOT-OWNED tree that is not an install directory is a leftover: chapter # 7 built the -tmp package as root, so /usr/lib/python3.13 and its ~190 # subdirectories all belong to root. That is a package's private tree, not # shared infrastructure -- it should simply belong to the package. Hand the # whole leftover subtree to the package installing into it; if some OTHER # package needs to add files there later, it gets a collector group then, # which is the normal mechanism. if [ "$owner" = "root" ]; then # ONLY this directory -- not the tree under it. # # This used to chown the whole subtree, so a package installing one # file into /usr/lib/python3.13/site-packages took all ~190 python # directories with it. Needing to write into a directory says nothing # about the directories below it, and now that packages record the # directories they create, those already have the right owner. real_chown "$user:$user" "$dir" 2>/dev/null say " $dir -> owned by '$user' (was root, from the temporary system)" return 0 fi if [ "$owner" = "$user" ]; then return 0 # already ours fi # Never invent a collector group for root. "_root" would mean "may # write anywhere root owns", which undoes the whole scheme -- and there is # no user 'root' to put in a collector group anyway. A root-owned # directory is either shared infrastructure or a leftover to adopt, both # handled above; it is never a package's to share. if [ "$owner" = "root" ]; then warn " $dir is owned by root -- not creating a collector group for it" return 1 fi grp="$(choose_collector_group "$dir" "$owner" "$user")" # NEVER LEAVE IT EMPTY, AND NEVER FAIL QUIETLY. # # choose_collector_group prompts when the group does not exist yet. # During a build stdin is not a terminal, so the prompt cannot run and it # returns NOTHING -- then `chgrp "" dir` fails and grant_dir_access # returns 1 with no message, which the caller reports as "nothing to # grant". That is what five rounds of debugging on gst-plugins-bad were # chasing. # # A name that cannot be asked for falls back to the owner's, which is the # recommended answer anyway -- and says so. if [ -z "$grp" ]; then grp="$(sanitise_group_name "$(collector_group_for "${owner#p_}")")" warn " could not ask which group should own $dir" warn " (no terminal here) -- using $grp, named after its owner" fi [ -n "$grp" ] || grp="$(collector_group_for "$owner")" if ! ensure_group "$grp"; then # A bad name should not end the build: say what was wrong with it and # fall back to the default, which is always valid. warn " '$grp' is not a usable group name" grp="$(collector_group_for "$owner")" warn " using '$grp' instead" ensure_group "$grp" || { warn " could not create group $grp" return 1 } fi add_to_group "$owner" "$grp" add_to_group "$user" "$grp" real_chgrp "$grp" "$dir" 2>/dev/null real_chmod g+rwx "$dir" 2>/dev/null # Share exactly the directory that was asked for -- nothing below it. # # A collector group named after a package does NOT mean "may write anywhere # that package owns". It means: when some package needs to install into a # directory this one owns, that directory can be shared. Walking the # owner's whole subtree turned one legitimate request into blanket access # over hundreds of directories, so `nimgnu_python` ended up meaning "full # run of every Python directory". # # Installing into a second directory asks again -- and because the package # is already a member of the group by then, that grant is automatic (see # the fast path at the top of this function). if ! real_chgrp "$grp" "$dir" 2>/dev/null; then warn " chgrp $grp $dir FAILED -- the directory is unchanged" warn " (does the group exist? getent group $grp)" return 1 fi soft "$dir is group $grp but not group-writable -- the grant will not take" \ -- real_chmod g+rwx "$dir" say " $dir -> group $grp (owner $owner, + $user)" mkdir -p "$(dirname "$GRANTED")" grep -qxF "$grp|$user|$owner" "$GRANTED" 2>/dev/null \ || echo "$grp|$user|$owner" >> "$GRANTED" local rel="${dir#${SNAP_ROOT%/}}" grep -qxF "$rel|$grp" "$COLLECTOR_MAP" 2>/dev/null \ || echo "$rel|$grp" >> "$COLLECTOR_MAP" } # Read a failed build's log, find every directory the package could not write # to, and grant access via collector groups. Then the same phase can simply be # re-run -- no editing, no manual chgrp. # Directories mentioned in a log that genuinely cannot write into. unwritable_dirs_from_log() { local log="$1" user="$2" p d [ -f "$log" ] || return 0 # Take paths both quoted ('...' / "...") and bare -- bash and perl report # them unquoted ("...core_perl/perllocal.pod: Permission denied"), and only # matching quoted ones made the recovery silently find nothing to do. # Read only the TAIL of the log, and only lines that mention a permission # problem. A build log is tens of megabytes; extracting every path from all # of it and then running `su ... test -w` on each is minutes of CPU for an # answer that is always in the last few lines. local _tail # The same three wordings as the gate above: ExtUtils::Install's # "Can't create ''" / "Do not have write permissions on ''" # and tar's "Cannot mkdir". Without them this list came back EMPTY for # every Perl module, and the repair had nothing to grant. _tail="$(tail -c 524288 "$log" 2>/dev/null \ | grep -aE "Permission denied|Operation not permitted|cannot (create|remove|touch|open)|[Cc]an'?t create|[Dd]o not have write permissions|[Cc]annot mkdir|couldn't copy|No such file or directory" \ | grep -avE "^[[:space:]]*(File \"|Traceback|at .* line )" \ | head -n 100)" # A RELATIVE PATH NEEDS THE `cd` THAT PRECEDED IT. # # git's install pipes into tar: # (cd '/usr/lib/perl5/5.42/site_perl' && umask 022 && tar xof -) # /usr/bin/tar: ./Git: Cannot mkdir: Permission denied # The path in the error is RELATIVE, so nothing above matched and the # repair found nothing to grant -- while the directory that actually # needs granting is named one line earlier, in the subshell's cd. # # Scan the tail in order, remembering the last absolute `cd`, and emit it # whenever a relative complaint follows. local _cdpaths # MESON SAYS "OSError" AND NAMES NOTHING. # # gst-plugins-bad ended with # Installing .../gstnonstreamaudiodecoder.h to /usr/include/gstreamer-1.0/gst/audio # ERROR: Unhandled python OSError. # -- the directory it could not write is on the line BEFORE the error, # which is the only place meson puts it. Two repair rounds ran with # nothing to grant. # EVERY DESTINATION IT PRINTED, not just the last one. # # Taking only the line before the error granted one directory per round # and the next round failed on the next directory -- and meson installs # through PYTHON, not through our wrappers, so nothing on our side sees # the EACCES to record it. # # What meson does give us is every destination it announced before dying. # Granting all of them that exist and are not already writable costs # nothing (a directory the package can already write is skipped) and # settles the package in ONE round instead of one round per header # directory. local _mesondirs="" if grep -qE "Unhandled python OSError|\[Errno 13\]" "$log" 2>/dev/null; then _mesondirs="$(tail -c 2097152 "$log" 2>/dev/null \ | awk '/^Installing .* to \// { print $NF }' \ | sort -u)" fi # CARGO NAMES THE DESTINATION IN BACKTICKS, ON THE LINE ABOVE THE REASON. # # Error: CliError { error: Some(failed to copy `.../libgstrsrtp.a` to # `/usr/lib/gstreamer-1.0/libgstrsrtp.a` # Caused by: # Permission denied (os error 13) # # No quotes, no "Permission denied" on the same line, and the path that # matters is the SECOND backticked one -- so nothing matched, the repair # ran zero rounds, and the box still said "grant access and retry". local _cargodirs="" if grep -qE "failed to copy \`|os error 13" "$log" 2>/dev/null; then _cargodirs="$(tail -c 524288 "$log" 2>/dev/null \ | grep -aoE 'to `/[^`]+`' \ | sed 's/^to `//; s/`$//' \ | sed 's|/[^/]*$||' \ | sort -u)" fi # AN ABSOLUTE PATH IN THE MESSAGE IS THE ANSWER, AND NOBODY READ IT. # # install: cannot create regular file # '/usr/lib/udev/rules.d/90-backlight.rules': Permission denied # # The file does not exist, so the handover has nothing to give away; the # directory is another package's and not writable, so the grant is what # is needed -- and the `cd`-based extractor below only fires when the # build printed a `cd` and then a RELATIVE path. With an absolute path # right there in the message, nothing was extracted and light's udev # rule stopped the install. # CMAKE WRAPS ITS MESSAGE. # # file INSTALL cannot copy file # "/usr/src/pkgusr/p_nvtop/.../io.github.syllo.nvtop.metainfo.xml" # to "/usr/share/metainfo/io.github.syllo.nvtop.metainfo.xml": Permission # denied. # "Permission" and "denied" on two lines, so no line matched and the second # round found nothing to grant: nvtop stopped after one round with a plain # permission error on the screen. Join each `file INSTALL` block (up to # the blank line CMake ends it with) and read the destination from that. local _cmakedirs="" _cmakedirs="$(tail -c 524288 "$log" 2>/dev/null | awk ' /file INSTALL cannot/ { blk = $0; inb = 1; next } inb && /^[[:space:]]*$/ { print blk; inb = 0; next } inb { blk = blk " " $0 } END { if (inb) print blk }' \ | tr -s ' ' \ | grep -aE 'Permission denied|Operation not permitted' \ | grep -aoE '(to|directory) "/[^"]+"' | sed 's/^[a-z]* "//; s/"$//' \ | while IFS= read -r _cp; do if [ -d "$_cp" ]; then echo "$_cp"; else echo "${_cp%/*}"; fi done | sort -u)" local _instdirs="" _instdirs="$(tail -c 524288 "$log" 2>/dev/null \ | grep -aoE "cannot create (regular file|directory) '/[^']+'" \ | sed "s/.*'\(\/[^']*\)'.*/\1/" \ | sed 's|/[^/]*$||' | sort -u)" # (plain awk: match() with a capture array is a gawk extension) _cdpaths="$(tail -c 524288 "$log" 2>/dev/null | awk ' /cd .\/|cd \x27\// { line = $0 sub(/^.*cd \x27?/, "", line) sub(/\x27.*$/, "", line) sub(/ .*$/, "", line) if (line ~ /^\//) last = line } /Cannot mkdir|Cannot open|Permission denied|cannot create/ { if (last != "" && $0 ~ /(^|[ :])\.\//) print last }' 2>/dev/null | sort -u)" { printf '%s\n' "$_mesondirs" printf '%s\n' "$_cargodirs" printf '%s\n' "$_instdirs" printf '%s\n' "$_cmakedirs" printf '%s\n' "$_cdpaths" printf '%s\n' "$_tail" | grep -aoE "'[^']+'|\"[^\"]+\"" | tr -d "'\"" printf '%s\n' "$_tail" | grep -aoE "/[A-Za-z0-9._/+:-]{6,}" | sed 's/:*$//'; } \ | while IFS= read -r p; do case "$p" in /*) ;; *) continue ;; esac if ! under_dir "$p" "$SNAP_ROOT"; then case "$p" in /usr/*|/etc/*|/var/*|/opt/*|/lib/*|/lib64/*|/bin/*|/sbin/*|\ /home/*|/root/*|/srv/*|/boot/*|/tools/*|/sources/*|/build/*) ;; *) continue ;; esac fi if [ -d "$p" ]; then echo "$p"; else echo "${p%/*}"; fi done | sort -u \ | while IFS= read -r d; do [ -n "$d" ] && [ -d "$d" ] || continue su -s /bin/bash "$user" -c "test -w '$d'" 2>/dev/null && continue echo "$d" done } # A TOOL THE BUILD NEEDS AND THE BOOK'S COMMANDS NEVER MENTION. # # checking for itstool... no # configure: error: itstool not found # # gparted's script says `./configure`; the word "itstool" appears nowhere in # it, so the prereq check (which reads the script) could not know, and the # stack stopped one package short with a one-line answer on the screen. # packagemanager knows which package provides a program -- ask it, install # that, and run the phase again. # # One level only: the install of the provider is itself a build with its own # repair loop, and a loop that can start a loop that can start a loop is not # a repair, it is a fork bomb with good intentions. _missing_tool_from_log() { local log="$1" t [ -f "$log" ] || return 1 t="$(tail -c 262144 "$log" 2>/dev/null | grep -aoE \ "configure: error: [A-Za-z0-9_.+-]+ (not found|is required|missing)|configure: error: (Cannot|cannot) find [A-Za-z0-9_.+-]+|Program [A-Za-z0-9_.+-]+ not found|[A-Za-z0-9_.+-]+: command not found" \ | head -n1)" [ -n "$t" ] || return 1 case "$t" in "configure: error: "*"not found"|"configure: error: "*"is required"|"configure: error: "*"missing") t="${t#configure: error: }"; t="${t%% *}" ;; "configure: error: "*find*) t="${t##*find }" ;; "Program "*) t="${t#Program }"; t="${t%% *}" ;; *": command not found") t="${t%%:*}" ;; *) return 1 ;; esac # a path, a version, an empty match: not a program name case "$t" in ""|/*|[0-9]*) return 1 ;; esac printf '%s' "$t" } _install_missing_tool() { # -- returns 0 when something was installed local log="$1" tool prov [ "${PM_IN_TOOL_REPAIR:-0}" = 1 ] && return 1 # one level only tool="$(_missing_tool_from_log "$log")" || return 1 command -v "$tool" >/dev/null 2>&1 && return 1 # it is here; not this prov="$(packagemanager provider "$tool" 2>/dev/null | head -n1)" if [ -z "$prov" ]; then warn "!! this build needs '$tool', and no BLFS page provides it." warn " Install it yourself, then run the same command again." return 1 fi note "# this build needs '$tool', which the book's commands never name." note "# $prov provides it -- installing that first, then trying again." if PM_IN_TOOL_REPAIR=1 packagemanager install "$prov" --run --recursive --yes; then command -v "$tool" >/dev/null 2>&1 && return 0 warn "!! $prov installed and '$tool' is still not on PATH -- stopping" return 1 fi warn "!! could not install $prov, which provides '$tool'" return 1 } # A BUILD OPTION THE BUILD ITSELF NAMES. # # cairomm 1.14.6 stops at configure: # Run-time dependency Boost found: NO (tried system) # ERROR: Building the test programs requires Boost Test ... and Fontconfig. # Install them or use -Dbuild-tests=false or -Dbuild-tests=if-dependencies-found # Boost is in avoid.stack -- deliberately, it is an hour of build and this # desktop wants none of it -- so the dependency is never coming, and the # option meson prints is the whole answer. A gparted chain that stops for # an hour on a TEST program nobody asked for is the failure; the fix was on # the screen the whole time. # # Only an option the tool suggested, only one that turns something OFF, only # if it is not already there, and the old script is kept beside it. # LDCONFIG BY ABSOLUTE PATH. The ldconfig wrapper (see install_wrappers) # only catches a package that finds ldconfig on PATH. ntfs-3g's configure # looks in /sbin first and writes LDCONFIG=/sbin/ldconfig into its Makefile, # whose install-exec-hook then fails as a package user, after everything else # is installed: # /sbin/ldconfig: Renaming of /etc/ld.so.cache~ to /etc/ld.so.cache # failed: Operation not permitted # The tree is already configured, so the retry overrides the variable for # make itself: a definition in MAKEFLAGS counts as a command-line one and # beats the Makefile's. Root refreshes the cache after the install anyway. # Prints the env pairs to add; the caller sets _LDCONFIG_OVERRIDDEN (this # runs in $(...), where a variable would not survive), so it happens once. _ldconfig_from_log() { # [ -z "${_LDCONFIG_OVERRIDDEN:-}" ] || return 1 grep -qE "ld\.so\.cache~? .*(Operation not permitted|Permission denied)|ldconfig: Can't create temporary cache file" \ "$1" 2>/dev/null || return 1 local _w="$WRAPPERS/ldconfig" _mf [ -x "$_w" ] || return 1 _mf="$(printf '%s' "$(build_env_pairs)${2:-}" | grep -oE "MAKEFLAGS='[^']*'" \ | tail -n1 | sed "s/^MAKEFLAGS='//; s/'\$//")" [ -n "$_mf" ] || _mf="-j$(nproc 2>/dev/null || echo 1)" say "# auto-repair: the Makefile runs ldconfig by full path; retrying with" >&2 say "# LDCONFIG=$_w (the cache is refreshed as root after the install)" >&2 printf " LDCONFIG='%s' MAKEFLAGS='%s LDCONFIG=%s'" "$_w" "$_mf" "$_w" } _meson_opt_from_log() { #