Packagemanager-LFS-PackageUser-System git · main
Package-user based package manager for LFS/BLFS
Shell 63.7% Markdown 35.8%git clone https://git.christianimmanuel.de/linux-from-scratch/Packagemanager-LFS-PackageUser-System.gitwget https://git.christianimmanuel.de/linux-from-scratch/Packagemanager-LFS-PackageUser-System/archive/Packagemanager-LFS-PackageUser-System.tar.gzlfs-helper raw
#!/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 <what> -- <cmd ...> 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.
#
# <cmd> 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/<name>.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:<id>:" 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 <name> -- 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++/<version> 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 <dir> && chmod g+w,o+t <dir>.
# 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 <name> -- create a package user the packagemanager way:
# own group, home /usr/src/<name>, 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" <<EOF
# build.conf for package user '$name' -- read by ~/build.
#SRC_DIR="\$HOME/$name-1.0"
CONFIGURE_OPTS="--prefix=/usr"
MAKE_OPTS=""
EOF
fi
[ -f "$home/.project" ] || printf '%s\n\nPackage user for %s.\n' "$name" "$name" > "$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 <name>"
_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<N> 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 <pkg> --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/<lang>/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 <prefix>_<owner>, 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 <name>"
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 <package>"
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/<account>. Every tool here computes the home through
# pkgusr_home_for -- /usr/src/pkgusr/<account> -- 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 <name> [--run])" ;;
*) name="$1"; shift ;;
esac
done
[ -n "$name" ] || die "usage: lfs-helper fix-home <name> [--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 <prefix>_<owner>
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 (<prefix>_binutils), which
# groups every directory that package owns under one group. Naming it after
# the DIRECTORY instead (<prefix>_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 <user> already a member of <group>?
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 <prefix>_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 <user> the right to install into <dir>
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. "<prefix>_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 <user> 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 '<dir>'" / "Do not have write permissions on '<dir>'"
# 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() { # <log> -- 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() { # <log> <envpass-so-far>
[ -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() { # <log> <script> <name>
local log="$1" script="$2" pkg="${3:-}" opt
[ -f "$log" ] && [ -f "$script" ] || return 1
opt="$(grep -ohE "use -D[A-Za-z0-9_.-]+=[A-Za-z0-9_.-]+" "$log" 2>/dev/null \
| head -n1 | sed 's/^use //')"
[ -n "$opt" ] || return 1
case "$opt" in
*=false|*=disabled|*=no|*=if-dependencies-found) ;;
*) return 1 ;; # never turn something ON
esac
grep -qF -- "$opt" "$script" && return 1
grep -q "meson setup" "$script" || return 1
cp -p "$script" "$script.bak-$(date +%Y%m%d-%H%M%S)" || return 1
sed -i "s|meson setup|meson setup $opt|" "$script" || return 1
note "# the build named the option itself -- adding it and trying again:"
note "# $opt"
note "# in $script (the old one is beside it as .bak-<date>)"
note "# A REGENERATED SCRIPT LOSES IT. To keep it, put it in"
note "# /etc/pkgusr/stacks/machine.conf:"
note "# [${pkg:-<package>}]"
note "# ${opt#-D}" | sed 's/=/ = /'
return 0
}
# Grant <user> access to everything in the log it could not write, and say
# which groups that took. Returns 0 if anything was granted.
# A directory that does not exist yet, inside somebody else's tree.
#
# cmake installs into /usr/share/vim/vimfiles/indent. /usr/share/vim belongs
# to vim; vimfiles has never existed; cmake's installer does one mkdir and
# reports
# file INSTALL cannot make directory ".../vimfiles/indent":
# No such file or directory
# A package user cannot create it either, because the parent is another
# package's directory. Granting does not help -- the problem is not that the
# directory is unwritable, it is that it is NOT THERE.
#
# The model already has the answer for a directory two packages share: root
# owns it, group install, group-writable. Nothing was applying that rule ON
# DEMAND, so every package that installs into a new subdirectory of another
# package's tree hits this. Create the missing directories that way, as
# root, and let the retry proceed.
missing_dirs_from_log() {
local log="$1" d parent
[ -f "$log" ] || return 0
# THE PATTERN, NOT A LIST OF WORDINGS.
#
# First version matched cmake's `cannot make directory "X"` and
# coreutils' `cannot create directory 'X'` -- and then missed
# install_libpaper: line 150: /etc/profile.d/libpaper.sh:
# No such file or directory
# which is bash failing to redirect into a directory that is not there.
# Enumerating messages is the same mistake as enumerating verbs was in
# the book guard: every tool words it differently. The pattern is
# "No such file or directory" plus an absolute path.
#
# Two classes, one rule each:
# a directory the tool tried to CREATE -> make that path
# anything else that could not be written -> make its PARENT
# and in both cases only when what is missing really is the directory
# chain: if the parent already exists, the error is about something else
# (a source file the build never produced) and inventing a directory
# would turn a clear message into a mystery.
{
{
grep -oE 'cannot make directory "[^"]+"' "$log" 2>/dev/null | cut -d'"' -f2
grep -oE "cannot create directory .[^']+'" "$log" 2>/dev/null | cut -d"'" -f2
} | while IFS= read -r d; do
case "$d" in /*) [ -e "$d" ] || printf '%s\n' "$d" ;; esac
done
grep "No such file or directory" "$log" 2>/dev/null \
| grep -oE "/[^ '\"()]+" \
| while IFS= read -r d; do
d="${d%:}"
[ -e "$d" ] && continue
parent="$(dirname "$d")"
# the directory chain is what is missing -- not a file inside a
# directory that exists
[ -d "$parent" ] && continue
printf '%s\n' "$parent"
done
} | while IFS= read -r d; do
[ -n "$d" ] || continue
# AN EMPTY VARIABLE IS NOT A PREFIX, IT IS A WILDCARD.
#
# This ran as `case "$d" in "$SRCROOT"/*)` and
# `case "$d" in "${SNAP_ROOT%/}"/*)`. In the chroot SNAP_ROOT is "/",
# so "${SNAP_ROOT%/}" is EMPTY: the "is it under the root" test then
# failed to match anything, while the SRCROOT exclusion became "/*"
# and excluded everything. cmake's missing directory was therefore
# invisible on the only system that matters -- and perfectly visible
# in every sandbox, where the root is a subdirectory. Three sessions
# of "the repair does not run" came from those two patterns.
#
# Prefix tests by string removal, with the empty root meaning "/".
_r="${SNAP_ROOT%/}"
[ -n "$_r" ] && [ "${d#"$_r"/}" = "$d" ] && continue # not under the root
case "$d" in /*) ;; *) continue ;; esac # absolute only
# never invent directories in a build tree, the source store or /tmp:
# a path that failed THERE is the build's own problem to report
for _skip in "$SRCROOT" "$_r/sources" "$_r/tmp" "$_r/build" /build; do
[ -n "$_skip" ] || continue
[ "${d#"$_skip"/}" = "$d" ] || continue 2
done
printf '%s\n' "$d"
done | sort -u
}
# A file the installer may not replace, because another package owns it.
#
# install: cannot remove '/usr/lib/pkgconfig/freetype2.pc':
# Operation not permitted
# /usr/lib/pkgconfig/freetype2.pc -rw-r--r-- p_cairo:p_cairo
# in /usr/lib/pkgconfig drwxrwxr-t root:install
#
# freetype2's OWN file, owned by cairo -- the over-claiming manifests of
# 1.12.11 and earlier, made real by a `verify --fix`. The directory is
# sticky (sealed), so only the owner may replace the file, and EPERM is not
# something a collector group can grant away.
#
# In this model the package that installs a file owns it: that is what
# verify's ownership rule says, and the installer is by definition the one
# installing it now. So hand the file over and let the install proceed --
# loudly, naming the previous owner, because a package taking another's file
# is worth seeing.
takeover_files_from_log() {
local log="$1" user="$2" f owner n=0
[ -f "$log" ] || return 1
while IFS= read -r f; do
[ -n "$f" ] || continue
under_dir "$f" "$SNAP_ROOT" || continue
for _skip in "$SRCROOT" "${SNAP_ROOT%/}/tmp" "${SNAP_ROOT%/}/sources"; do
[ -n "$_skip" ] && under_dir "$f" "$_skip" && continue 2
done
[ -e "$f" ] || [ -L "$f" ] || continue
owner="$(stat -c %U "$f" 2>/dev/null)"
[ -n "$owner" ] || continue
[ "$owner" = "$user" ] && continue
# root's files are not another package's mistake -- leave them; only
# a PACKAGE USER's claim is transferable.
case "$owner" in root|UNKNOWN|"") continue ;; esac
user_exists "$owner" || continue
if real_chown -h "$user:$user" "$f" 2>/dev/null; then
warn "!! $f belonged to $owner -- given to $user, which is installing it"
LAST_TAKEOVER_OWNER="$owner"
n=$((n + 1))
fi
done <<< "$(
{ grep -oE "cannot remove '[^']+'" "$log" 2>/dev/null | cut -d"'" -f2
grep -oE "cannot create regular file '[^']+'" "$log" 2>/dev/null | cut -d"'" -f2
grep -oE "cannot unlink '[^']+'" "$log" 2>/dev/null | cut -d"'" -f2
grep -oE "cannot overwrite '[^']+'" "$log" 2>/dev/null | cut -d"'" -f2
# PERL SAYS IT WITHOUT QUOTES, AND THE PATH CONTAINS COLONS.
#
# Cannot forceunlink /usr/share/man/man3/XML::Parser.3: Permission denied
#
# ExtUtils::Install replaces a file by unlinking it first, and in a
# STICKY directory (/usr/share/man/man3 is drwxrwxr-t) only the
# file's owner may do that -- so granting the directory, which the
# repair did six times, can never help. The file belongs to an
# older account for the same module (p_xml-parser; this one is
# p_perl-xml-parser), and handing it over is the fix.
# The path is taken as a whole token: XML::Parser.3 has colons in
# it, so splitting on ":" loses it.
grep -oE "[Cc]annot forceunlink [^ ]+" "$log" 2>/dev/null \
| sed 's/^[Cc]annot forceunlink //; s/:$//'
grep -oE "[Cc]an'?t (unlink|remove|rename) [^ ]+" "$log" 2>/dev/null \
| sed -E "s/^[Cc]an'?t (unlink|remove|rename) //; s/:\$//"
} | sort -u)"
# AN OLDER ACCOUNT FOR THE SAME PACKAGE. When several files move from
# one package user whose name is this one's with a language prefix
# dropped (xml-parser <- perl-xml-parser), the two are the same upstream
# module installed twice under two book anchors. Taking the files is
# right; leaving the old account to claim them in `verify` is not.
if [ "$n" -ge 2 ] && [ -n "${LAST_TAKEOVER_OWNER:-}" ]; then
local _a="${user#p_}" _b="${LAST_TAKEOVER_OWNER#p_}"
case "$_a" in
perl-"$_b"|python-"$_b"|"$_b"-perl|"$_b"|py-"$_b")
warn "!! $LAST_TAKEOVER_OWNER looks like an older account for the same"
warn " module as $user -- two book anchors, one upstream package."
warn " The files are $user's now. The old account still claims"
warn " them; when this build is done, take it out of the way:"
warn " packagemanager remove ${_b} --purge --run" ;;
esac
fi
[ "$n" -gt 0 ]
}
# Create the missing directories as shared install directories. Returns 0 if
# anything was created, so the caller knows a retry is worth it.
# A new directory in another package's tree: which collector group?
#
# grant_dir_access names the group after the DIRECTORY'S owner, which here is
# the package that just created it -- so vim's tree got nimgnu_cmake:
# /usr/share/vim/vimfiles p_cmake nimgnu_cmake
# The group belongs to the TREE, not to whoever happened to install first.
# And when no such group exists yet, naming it is a decision, not a yes/no:
# --yes means "do not ask me to confirm", it does not mean "pick a name for
# me". So an existing group is reused silently, and a new one is ASKED for
# whenever there is a terminal; without one, the tree owner's name is used --
# never the creator's.
share_new_dir_with_tree_owner() {
local dir="$1" powner="$2" user="$3" grp by_tree by_creator ans
# ITS OWN TREE NEEDS NO GROUP.
#
# p_rust created /opt/rustc-1.93.1/share -- inside p_rust's own tree --
# and was asked which collector group should own it, offering the same
# name twice ("named after the tree" and "named after the package that
# made it" are one name when they are the same package). A question with
# one answer is not a question.
if [ "$powner" = "$user" ]; then
ok "# $dir belongs to $user, which created it -- no group needed"
return 0
fi
# NAME THE PACKAGE, NOT THE ACCOUNT. collector_group_for unprefixes with
# PKGUSR_PREFIX, so when that is empty (or the account was made under a
# different prefix) "p_rust" survived intact and the group came out
# nimgnu_p_rust. Strip the tools' own default prefix as well.
by_tree="$(sanitise_group_name "$(collector_group_for "${powner#p_}")")"
by_creator="$(sanitise_group_name "$(collector_group_for "${user#p_}")")"
# THE PARENT DIRECTORY DECIDES, not the existence of a group somewhere.
#
# If the directory we are creating sits in a directory that already
# carries a collector group, that group is the answer -- the decision was
# made when it was put there, and a subdirectory of a shared directory is
# shared the same way. Only when the parent carries no such group is
# there a new decision to make, and then it is ASKED, like every other
# collector-group question on this system.
local _pgrp
_pgrp="$(stat -c %G "$(dirname "$dir")" 2>/dev/null)"
case "$_pgrp" in
"${COLLECTOR_PREFIX}_"*)
grp="$_pgrp"
ok "# $dir -> group $_pgrp (inherited from its parent directory)"
soft "could not give $dir to group $grp" -- real_chgrp "$grp" "$dir"
soft "$dir is group $grp but not group-writable -- the grant will not take" \
-- real_chmod g+rwx "$dir"
add_to_group "$user" "$grp" >/dev/null 2>&1 || true
return 0 ;;
esac
if [ -r /dev/tty ] && [ -w /dev/tty ]; then
warn "WAITING FOR AN ANSWER: which collector group should own ${C_B}$dir${C_OFF}?"
{
say ""
say " package : ${C_B}$user${C_OFF} (it is doing the installing)"
say " directory: ${C_B}$dir${C_OFF}"
say " in the tree of: ${C_B}$powner${C_OFF}"
say ""
say " Which collector group should own it?"
say " 1) ${C_B}${by_tree}${C_OFF} ${C_OK}<- recommended${C_OFF}:"
say " named after the tree, which is what other packages"
say " installing into $powner's directories will look for"
say " 2) ${C_B}${by_creator}${C_OFF} (named after the package that made it)"
say " or type another name"
} > /dev/tty
printf ' [1]: ' > /dev/tty
read -e -r ans < /dev/tty || ans=""
case "$ans" in
""|1) grp="$by_tree" ;;
2) grp="$by_creator" ;;
*) grp="$(sanitise_group_name "$ans")" ;;
esac
else
grp="$by_tree"
warn "no terminal to ask -- using $grp (named after the tree, not the creator)"
fi
group_exists "$grp" || create_collector_group "$grp" || return 1
soft "could not give $dir to group $grp" -- real_chgrp "$grp" "$dir"
soft "$dir is group $grp but not group-writable -- the grant will not take" \
-- real_chmod g+rwx "$dir"
add_to_group "$powner" "$grp" >/dev/null 2>&1 || true
add_to_group "$user" "$grp" >/dev/null 2>&1 || true
ok "# $dir -> group $grp (owner $user, + $powner)"
}
# Ask before changing the system. Creating a directory the book never
# mentions is a change to someone else's tree, and it must be the user's
# call: the shared-directory rule is right, but applying it unasked is not.
# --yes (assume_yes) is that permission given in advance; without a terminal
# the answer is NO, because silence is not consent.
confirm_change() {
local what="$1" ans
if assume_yes; then
warn "$what (--yes)"
return 0
fi
[ -r /dev/tty ] && [ -w /dev/tty ] || { warn "$what -- NOT done (no terminal to ask)"; return 1; }
# ASK WHERE THE OUTPUT IS. The question goes to /dev/tty so it survives
# the pipes, but the build's output goes through tee -- so a run that
# looked hung was a prompt nobody could see. Say it on BOTH: the log
# shows what is being waited for, the terminal takes the answer.
warn "WAITING FOR AN ANSWER: $what [y/N]"
{ say ""; say " $what"; } > /dev/tty
printf ' do it? [y/N]: ' > /dev/tty
read -e -r ans < /dev/tty || return 1
case "$ans" in y|Y|yes) return 0 ;; *) warn "declined"; return 1 ;; esac
}
create_missing_dirs_from_log() {
local log="$1" user="$2" d made=0 target
while IFS= read -r d; do
[ -n "$d" ] || continue
# missing_dirs_from_log already decided WHICH directory is missing
target="$d"
[ -e "$target" ] && continue
case "$target" in "${SNAP_ROOT%/}"|/|"") continue ;; esac
# WHO SHOULD OWN A DIRECTORY WE CREATE?
#
# It was root:install, and that broke cmake a second way:
# cannot set permissions on "/usr/share/vim/vimfiles/indent":
# Operation not permitted
# chmod requires OWNERSHIP, and an installer sets modes on the
# directories it creates. root:install is the rule for the SHARED
# INSTALL DIRECTORIES (/usr/lib, /usr/share/man/...), which is a
# different thing: those are the system's, listed in installdirs.lst,
# and no package owns them.
#
# This is a new directory inside ANOTHER PACKAGE'S tree
# (/usr/share/vim belongs to p_vim). The model already answers that:
# the package that creates it owns it, and a COLLECTOR GROUP grants
# the other package access -- the same nimgnu_* mechanism, and the
# same question, that every other shared directory goes through.
# Inventing a second rule here is what made vimfiles different from
# everything else on the system.
# WHOSE TREE IS THIS, REALLY?
#
# Asking the nearest existing ancestor was not enough: `mkdir -p`
# creates INTERMEDIATE directories as root:root, so round 1 left
# /usr/share/vim/vimfiles root:root
# and round 2 read that artifact of our own making as "root's tree"
# and applied the system rule to /usr/share/vim/vimfiles/syntax. A
# directory we created a moment ago is not evidence about ownership.
#
# So walk UP until something actually decides: a real install
# directory (the system's rule) or a directory a PACKAGE owns (the
# collector-group rule). A root:root directory that is neither is
# exactly the artifact above -- keep walking.
local _parent _powner
_parent="$(dirname "$target")"
while [ "$_parent" != / ] && [ -n "$_parent" ]; do
if [ -d "$_parent" ]; then
is_install_dir "$_parent" && break
_powner="$(stat -c %U "$_parent" 2>/dev/null)"
[ -n "$_powner" ] && [ "$_powner" != root ] && break
fi
_parent="$(dirname "$_parent")"
done
_powner="$(stat -c %U "$_parent" 2>/dev/null)"
if is_install_dir "$_parent" || [ "$_powner" = root ] || [ -z "$_powner" ]; then
# under the system's own directories: the system's rule applies
confirm_change "create $target (root:install, group-writable) so '$user' can install into it" \
|| continue
if mkdir -p "$target" 2>/dev/null; then
set_install_dir_owner "$target" 2>/dev/null || true
soft "created $target but could not make it group-writable -- the package user still cannot install into it" \
-- real_chmod g+w "$target"
ok "# created shared directory $target (root:install, group-writable)"
made=1
else
warn "could not create $target"
fi
else
confirm_change "create $target inside '$_powner''s tree, owned by '$user', and share it with a collector group" \
|| continue
# EVERY component we create, not just the last one. mkdir -p
# would leave the intermediates root:root -- the artifact that
# misled the ancestor walk above, and equally unusable by the
# package that has to install into them.
local _need="" _p2="$target"
while [ "$_p2" != "$_parent" ] && [ "$_p2" != / ]; do
[ -d "$_p2" ] || _need="$_p2 $_need"
_p2="$(dirname "$_p2")"
done
if mkdir -p "$target" 2>/dev/null; then
local _nd
for _nd in $_need; do
soft "created $_nd but could not give it to $user -- the package cannot set its mode" \
-- real_chown "$user:$user" "$_nd"
ok "# created $_nd (owned by $user, which is installing into it)"
# and let the OWNER of the tree keep access
share_new_dir_with_tree_owner "$_nd" "$_powner" "$user"
done
made=1
else
warn "could not create $target"
fi
fi
done <<< "$(missing_dirs_from_log "$log")"
[ "$made" = 1 ]
}
meson_takeover_from_log() {
# THE DIRECTORY WAS NEVER THE PROBLEM.
#
# /usr/include/gstreamer-1.0/gst/audio -- nothing to grant (already writable)
# .../gst/audio drwxrwxr-x p_gst10-plugins-base:p_gst10-plugins-base
#
# The package CAN write there. What it cannot do is finish installing
# over a FILE that belongs to another package: meson copies the contents
# (allowed -- the directory is writable) and then chmod/chown/utime the
# destination, which only its owner may do. Python raises EPERM and
# meson reports "Unhandled python OSError" with no path, which is how
# this looked like a directory problem for five rounds.
#
# Ownership follows the installer -- the rule this whole system is built
# on. So the files this package is installing over become its own, and
# meson's chmod succeeds. Only files it is actually installing (named on
# meson's own "Installing SRC to DIR" lines) and only where the current
# owner is another PACKAGE USER: root's files are never taken.
local log="$1" user="$2" took=0 src dest f owner _t_owner="" _t_src=""
grep -qE "Unhandled python OSError|\[Errno 13\]|\[Errno 1\]" "$log" 2>/dev/null || return 1
while IFS= read -r line; do
src="${line#Installing }"; src="${src%% to *}"
dest="${line##* to }"
[ -n "$src" ] && [ -d "$dest" ] || continue
f="$dest/$(basename "$src")"
[ -e "$f" ] || continue
owner="$(stat -c %U "$f" 2>/dev/null)" || continue
[ "$owner" = "$user" ] && continue
case "$owner" in
root|"") continue ;; # never take root's files
esac
pkgusr_kind "$owner" >/dev/null 2>&1 || true
say " $f belongs to $owner -- handing it to $user (the installer owns what it installs)"
soft "could not hand $f to $user" -- real_chown "$user:$user" "$f" \
&& { took=$((took+1)); _t_owner="$owner"; _t_src="$src"; }
done <<< "$(tail -c 2097152 "$log" 2>/dev/null | grep '^Installing .* to /')"
# THE WHOLE INSTALL PLAN, NOT THE LINE IT STOPPED ON.
#
# libglvnd replaces Mesa's GL, GLES, EGL and KHR headers and its libGL:
# a legitimate move of dozens of files from p_mesa to p_libglvnd. meson
# stops at the first one, so the log named one file per round, and after
# three the round limit called it a bundled subproject -- it was not.
# meson writes its complete plan to meson-info/intro-installed.json
# (source -> destination) at setup, so once one file has had to change
# hands, every destination still owned by that same package changes
# hands in this round. Same guards: package users only, never root.
# Not for a subproject's file: that is the bundled-copy case below, and
# handing its whole plan over would hide it.
if [ "$took" -ge 1 ] && [ -n "$_t_owner" ] \
&& [ "${_t_src#*/subprojects/}" = "$_t_src" ]; then
local _plan="" _dir _home _n=0 _d _o
# walk up from the file meson was installing to the tree that holds
# meson-info, and no further than the package user's home
_home="$(getent passwd "$user" | cut -d: -f6)"
_dir="$(dirname "$_t_src")"
while [ -n "$_dir" ] && [ "$_dir" != / ]; do
_plan="$(find "$_dir" -maxdepth 3 -path '*/meson-info/intro-installed.json' 2>/dev/null | head -1)"
[ -n "$_plan" ] && break
[ "$_dir" = "$_home" ] && break
_dir="$(dirname "$_dir")"
done
if [ -n "$_plan" ] && command -v python3 >/dev/null 2>&1; then
while IFS= read -r _d; do
[ -e "$_d" ] && [ ! -L "$_d" ] || continue
_o="$(stat -c %U "$_d" 2>/dev/null)" || continue
[ "$_o" = "$_t_owner" ] || continue
real_chown "$user:$user" "$_d" 2>/dev/null && _n=$((_n+1))
done <<< "$(python3 -c 'import json,sys; print("\n".join(json.load(open(sys.argv[1])).values()))' "$_plan" 2>/dev/null)"
if [ "$_n" -gt 0 ]; then
say " ...and $_n more file(s) of $_t_owner that meson's install plan puts there"
took=$((took + _n))
fi
fi
fi
# ONE FILE PER ROUND IS A LOOP, NOT PROGRESS.
#
# meson stops at the FIRST file it may not replace, so its log names only
# that one -- and the next round learns exactly one more. libdisplay-info
# went twelve rounds, re-cloning a subproject each time, handing over one
# .mo file per round. When a package keeps taking a single file from the
# same other package, it is installing a BUNDLED COPY of it, and the fix
# is upstream of the ownership question.
# ...and only a file that comes FROM a subproject is a bundled copy.
if [ "$took" = 1 ] && [ -n "$_t_owner" ] && [ "${_t_src#*/subprojects/}" != "$_t_src" ]; then
LAST_SINGLE_HANDOVER="$_t_owner"
else
LAST_SINGLE_HANDOVER=""
fi
[ "$took" -ge 1 ]
}
# A RULE ON DISK IS NOT A RULE IN FORCE.
#
# light installs 90-backlight.rules, and until udevd is told, nothing has
# changed: /sys/class/backlight/*/brightness keeps its old group, light still
# cannot write it, and the only symptom is "it does not work" -- on a system
# where every file is exactly where it should be. A reboot fixes it, which
# is how this kind of thing stays hidden for a week.
#
# Reload always (cheap, and cannot break a running device), then re-trigger
# ONLY the subsystems the new rules actually mention. A bare `udevadm
# trigger` re-adds every device on the machine; on a desktop that can reset
# input devices and drop a session, which is not a thing a package install
# should do.
_udev_reload_if_rules_changed() { # <manifest>
local man="$1" rules subs
[ -r "$man" ] || return 0
rules="$(grep -E '/udev/rules\.d/[^/]+\.rules$' "$man" 2>/dev/null)"
[ -n "$rules" ] || return 0
if ! command -v udevadm >/dev/null 2>&1; then
note "# installed a udev rule, and there is no udevadm here to load it"
return 0
fi
# IN A CHROOT THERE IS NO udevd TO TELL. Saying so beats a confusing
# "Failed to send reload request".
if [ "$(stat -c %d:%i /proc/1/root/. 2>/dev/null)" \
!= "$(stat -c %d:%i / 2>/dev/null)" ]; then
note "# a udev rule was installed; this is a chroot, so nothing was"
note "# reloaded -- it takes effect when you boot this tree"
return 0
fi
say "# a udev rule changed -- telling udevd:"
if udevadm control --reload 2>/dev/null; then
say "# rules reloaded"
else
warn "udevadm control --reload failed; the rule is on disk but not in force"
return 0
fi
# THE ACTION MATTERS AS MUCH AS THE SUBSYSTEM.
#
# `udevadm trigger` sends **change** unless told otherwise, and light's
# rule is `ACTION=="add", SUBSYSTEM=="backlight", RUN+="chgrp video ..."`
# -- so the trigger fired, udevd re-examined the device, the rule did not
# match, and /sys/class/backlight/*/brightness stayed root:root. Reload
# plus trigger LOOKED like it had done the job.
#
# So take the actions out of the rules too, and trigger each one.
acts="$(printf '%s\n' "$rules" | while IFS= read -r r; do
[ -r "$r" ] || continue
sed -n 's/.*ACTION=="\([a-z]*\)".*/\1/p' "$r"
done | sort -u | tr '\n' ' ')"
[ -n "$acts" ] || acts="change"
subs="$(printf '%s\n' "$rules" | while IFS= read -r r; do
[ -r "$r" ] || continue
sed -n 's/.*SUBSYSTEM=="\([a-z0-9_-]*\)".*/\1/p' "$r"
done | sort -u | tr '\n' ' ')"
if [ -z "$subs" ]; then
say "# no SUBSYSTEM== in the new rule(s), so nothing was re-triggered."
say "# If a device needs it: udevadm trigger --action=add --subsystem-match=<what>"
return 0
fi
for sub in $subs; do
for act in $acts; do
if udevadm trigger --action="$act" --subsystem-match="$sub" 2>/dev/null; then
say "# re-triggered '$sub' with action '$act'"
else
warn "could not re-trigger '$sub' ($act); by hand:"
warn " udevadm trigger --action=$act --subsystem-match=$sub"
fi
done
done
udevadm settle --timeout=10 >/dev/null 2>&1 || true
}
_missing_py_module() { # _missing_py_module <log> -> module name or ""
grep -aoE "Cannot import '[a-zA-Z0-9_.]+'|No module named '[a-zA-Z0-9_.]+'" \
"$1" 2>/dev/null | head -n1 | cut -d"'" -f2
}
# A TEST'S DEPENDENCY IS NOT A BUILD'S DEPENDENCY, and installing it would
# have hidden a generator bug for two releases. numpy's install phase
# wrongly contained its test suite; the missing module was `pytest`, and an
# eager "install what is missing" would have installed pytest, run a
# multi-minute test suite inside an install, and left the real fault --
# a test block in the wrong phase -- undiscovered.
_PY_TEST_ONLY="pytest py.test hypothesis attrs pytest_xdist pytest-xdist
nose coverage tox mock flaky"
_py_module_provider() { # _py_module_provider <module> -> BLFS page or ""
case "$1" in
mesonpy) echo meson_python ;;
setuptools_scm) echo setuptools_scm ;;
Cython|cython) echo Cython ;;
numpy) echo NumPy ;;
flit_core) echo flit_core ;;
hatchling) echo hatchling ;;
scikit_build_core) echo scikit-build-core ;;
poetry.core|poetry_core) echo poetry-core ;;
wheel) echo wheel ;;
setuptools) echo setuptools ;;
*) echo "" ;;
esac
}
# INSTALL A MISSING BUILD BACKEND, IF ASKED TO. Opt-in (--auto-modules or
# PM_AUTO_MODULES=1): a build that cannot proceed without a python backend
# is a dependency problem the tool can fix, but installing packages nobody
# asked for is not something to do by default -- and never for a module
# only the TEST suite wants.
_install_missing_module() { # _install_missing_module <log>
local mod prov
mod="$(_missing_py_module "$1")"
[ -n "$mod" ] || return 1
case " $_PY_TEST_ONLY " in
*" $mod "*)
warn "'$mod' is a TEST dependency, not a build one -- NOT installing it"
note " a test suite in an install phase is the real fault here."
note " Regenerate the script and the test moves to \`test\`:"
note " packagemanager regenerate-script $(unprefix_pkg_user "${name:-}") --overwrite"
return 1 ;;
esac
prov="$(_py_module_provider "$mod")"
if [ -z "$prov" ]; then
warn "no known BLFS page provides the python module '$mod'"
note " find it: packagemanager search $mod"
return 1
fi
say "# '$mod' is missing; installing $prov (asked for with --auto-modules) ..."
if packagemanager install "$prov" --run --yes --recursive; then
printf '%s\n' "$prov ($mod)" >> "${_AUTO_MODULES_LOG:-/dev/null}"
return 0
fi
warn "installing $prov failed; $mod is still missing"
return 1
}
_take_cmake_dest() { # _take_cmake_dest <log> <package user>
# CMAKE NAMES THE DESTINATION ACROSS THREE LINES.
#
# CMake Error at tools/llvm-ar/cmake_install.cmake:52 (file):
# file INSTALL cannot copy file
# ".../build/bin/llvm-ar" to
# "/usr/bin/llvm-ar": Permission denied.
#
# /usr/bin is drwxrwxr-t root:install -- writable by the group and
# STICKY, so only the file's owner may replace it, and /usr/bin/llvm-ar
# belonged to p_rust. No grant fixes that: the directory was never the
# problem. The meson handover next door reads "Installing SRC to DIR"
# lines and needs "Unhandled python OSError", so it saw nothing either,
# and the repair ran zero rounds.
#
# Ownership follows the installer. llvm-ar is llvm's file; take it, and
# only from another PACKAGE USER -- root's files are never taken.
# the log is joined into one line before matching, because the message
# is three lines and the destination is on the third
local log="$1" user="${2:-}" dest owner took=0
[ -r "$log" ] || return 1
[ -n "$user" ] || return 1
# ...AND A SYMLINK IT WILL NOT OVERWRITE AT ALL.
#
# file INSTALL cannot duplicate symlink
# ".../build/libtiff/libtiff.so.6"
# at
# "/usr/lib/libtiff.so.6"
# because: File exists
#
# Round 1 handed the .pc file over and the retry then died on this: the
# ownership was fine, cmake simply refuses to replace an existing
# symlink. Handing it over changes nothing -- it has to GO. Only
# another package user's, never root's, and only after saying whose it
# was.
# THE GATE HAS TO KNOW THE WORDING TOO. The extractor learned meson's
# "Operation not permitted: '<path>'" and this gate did not, so the
# function returned before looking -- the same shape as the 1.14.222
# cmake detector: two places, one fact.
grep -qE "file INSTALL cannot (copy file|duplicate symlink)|Operation not permitted: '" \
"$log" 2>/dev/null || return 1
while IFS= read -r dest; do
[ -n "$dest" ] || continue
case "$dest" in /*) ;; *) continue ;; esac
[ -e "$dest" ] || continue
owner="$(stat -c %U "$dest" 2>/dev/null)" || continue
[ "$owner" = "$user" ] && continue
case "$owner" in
root|"")
# A ROOT FILE THIS PACKAGE'S MANIFEST CLAIMS (a setid bit we
# applied) is handed over BEFORE the phase by the lease, and
# restored if the phase fails. 1.14.261 did it here, after
# the failure, which needed this log's wording to match.
# Nothing root owns is taken here any more.
note "# $dest is root's -- left alone"; continue ;;
esac
if [ -L "$dest" ]; then
# cmake will not overwrite a symlink whatever it is owned by
say " $dest is $owner's symlink -- removing it ($user installs it now)"
rm -f "$dest" 2>/dev/null || {
warn "could not remove $dest"; continue; }
took=1
continue
fi
say " $dest belongs to $owner -- handing it to $user (the installer owns what it installs)"
chown "$user:$user" "$dest" 2>/dev/null || {
warn "could not hand $dest to $user"; continue; }
took=1
done <<EOF
$(tail -c 262144 "$log" 2>/dev/null | tr '\n' ' ' \
| grep -aoE 'file INSTALL cannot copy file[[:space:]]+"[^"]+"[[:space:]]+to[[:space:]]+"[^"]+"' \
| sed 's/.*[[:space:]]to[[:space:]]*"//; s/"$//' | sort -u
tail -c 262144 "$log" 2>/dev/null | tr '\n' ' ' \
| grep -aoE 'cannot duplicate symlink[[:space:]]+[^[:space:]]+[[:space:]]+at[[:space:]]+[^[:space:]]+' \
| awk '{print $NF}' | sort -u
# meson/python say it differently, and EPERM on a sticky directory is the
# same problem: "Operation not permitted: '/usr/sbin/unix_chkpwd'"
tail -c 262144 "$log" 2>/dev/null \
| grep -aoE "Operation not permitted: '[^']+'" \
| sed "s/.*'\([^']*\)'.*/\1/" | sort -u)
EOF
[ "$took" = 1 ]
}
_clear_stale_dest() { # _clear_stale_dest <log> <package user>
# `mv`/`cp` refusing an existing destination is not a permission problem
# and no grant will fix it. Remove the stale copy IF the package being
# installed owns it, and say so; anything else is left alone, because a
# destination owned by someone else is a real conflict.
local log="$1" user="${2:-}" path owner cleared=0
[ -r "$log" ] || return 1
[ -n "$user" ] || return 1
while IFS= read -r path; do
[ -n "$path" ] || continue
case "$path" in /*) ;; *) continue ;; esac
[ -e "$path" ] || continue
owner="$(stat -c %U "$path" 2>/dev/null)"
if [ "$owner" != "$user" ]; then
warn "$path exists and belongs to $owner, not $user --"
warn " leaving it alone: that is a conflict, not stale output"
continue
fi
note "# $path is $user's own output from a previous install;"
note "# removing it so this install can replace it"
rm -rf "$path" || { warn "could not remove $path"; continue; }
cleared=1
done <<EOF
$(tail -c 262144 "$log" 2>/dev/null \
| grep -aoE "cannot overwrite '[^']+'|cannot move '[^']+' to '[^']+': Directory not empty" \
| grep -aoE "'[^']+'" | tr -d "'" | sort -u)
EOF
[ "$cleared" = 1 ]
}
auto_grant_from_log() {
local log="$1" user="$2" need d granted=0
need="$(unwritable_dirs_from_log "$log" "$user")"
[ -n "$need" ] || return 1
require_collector_prefix
say ""
note "# '$user' could not write into some directories -- granting"
note "# access via collector groups and retrying:"
# DEDUP BY DIRECTORY, NOT BY TREE TOP.
#
# The dedup collapsed every failing path to the top of its tree, so once
# ONE directory under /usr/include/gstreamer-1.0 had been granted, every
# other directory under it was skipped -- including the one that actually
# failed:
# /usr/include/gstreamer-1.0/gst/audio drwxrwxr-x p_gst10-plugins-base
# Two repair rounds ran, granted a sibling each time, and the build failed
# on the same directory.
#
# A group applies to the directories it is put on (the prompt says so),
# so each distinct directory needs its own grant. The original problem
# the tree-top dedup solved -- the same directory named by nine paths --
# is solved by deduping on the directory itself.
local seen=""
while IFS= read -r d; do
[ -n "$d" ] || continue
case "$seen" in *"|$d|"*) continue ;; esac
seen="$seen|$d|"
# SAY WHAT HAPPENED TO EACH ONE.
#
# This loop printed a header and then, when every candidate was
# skipped, nothing at all -- so a failing package looked like a
# repair that had not run, and four fixes were made by guessing.
# Every candidate now reports its outcome.
if grant_dir_access "$d" "$user"; then
granted=1
else
say " $d -- nothing to grant (already writable, or not a directory)"
fi
done <<< "$need"
[ "$granted" = 1 ]
}
# Repair /sources after the fact.
#
# Two kinds of debris accumulate there:
# * tarballs and trees owned by UIDs that only exist on the HOST (tar run as
# root restores the ownership recorded in the archive -- 8282, 15399, ...);
# * build markers left by a previous package user which, under the sticky
# /sources, nobody else can replace.
# Keep only the languages you actually want.
#
# Packages install translated man pages and message catalogues for every
# language they ship -- often 30+ directories each, none of which you will read.
# LFS itself suggests trimming them. The set is configurable; the default keeps
# English (always) plus German.
LOCALES_KEEP="${LFS_LOCALES:-en de}"
cmd_prune_locales() {
need_root
local run=0; [ "${1:-}" = "--run" ] && run=1
local r="${SNAP_ROOT%/}"
say "Keep only these languages: $LOCALES_KEEP (plus English, always)"
say " configure with: lfs config locales \"en de fr\" (outside the chroot)"
say ""
local keep_re="^(en|C|POSIX)"
local l
for l in $LOCALES_KEEP; do keep_re="$keep_re|^${l}\$|^${l}[._@]"; done
local d base victims=""
for d in "$r"/usr/share/locale/*/ "$r"/usr/share/man/*/; do
[ -d "$d" ] || continue
base="$(basename "${d%/}")"
# man/man1..man9 are the untranslated pages -- never touch them
case "$base" in man[1-9]|man[1-9]x) continue ;; esac
printf '%s\n' "$base" | grep -qE "$keep_re" && continue
victims="$victims${d%/}
"
done
victims="$(printf '%s' "$victims" | grep -v '^$' || true)"
local n; n=$(printf '%s\n' "$victims" | count_stdin_lines)
if [ "$n" = 0 ]; then
ok " nothing to remove -- only the wanted languages are installed"
return 0
fi
say " $n language director(ies) would be removed, e.g.:"
printf '%s\n' "$victims" | head -6 | sed 's/^/ /'
say ""
if [ "$run" = 0 ]; then
warn "(dry run -- nothing was changed. Add --run to remove them.)"
return 0
fi
printf '%s\n' "$victims" | while IFS= read -r d; do
[ -n "$d" ] && rm -rf "$d"
done
ok " removed $n language director(ies)"
say " (packages will recreate them on reinstall; re-run this when you like)"
}
# Strip debug symbols -- the book's 8.85, done the package-user way.
#
# `strip` does not edit a file: it writes a stripped copy beside it and
# renames the copy into place. Two things follow, and both shaped this:
#
# * The NEW file belongs to whoever ran strip. Run as root, every stripped
# binary would become root's -- and here the owner IS the record of which
# package installed it. So the files are grouped by owner and each group
# is stripped AS that owner, via su. That is also why the shared install
# directories can take it: they are group-writable for `install` and
# sticky, so an owner may replace its own file and nobody else's.
# * A running process keeps its open inode across the rename, so stripping
# the binaries in use right now (bash, strip itself, libc) is safe; the
# book's copy-to-/tmp dance guards against in-place edits, which GNU
# strip does not do.
#
# Scope is the book's: shared objects under /usr/lib and /usr/libexec, and
# every ELF file under /usr/bin, /usr/sbin and /usr/libexec. Libtool .la
# files go too (the book's cleanup) unless --keep-la: left behind, they make
# later BLFS builds link against paths that no longer exist.
cmd_strip() {
need_root
local run=0 keep_la=0
while [ $# -gt 0 ]; do
case "$1" in
--run) run=1 ;;
--keep-la) keep_la=1 ;;
*) die "unknown option: $1 (usage: lfs-helper strip [--run] [--keep-la])" ;;
esac
shift
done
local r="${SNAP_ROOT%/}"
# benign: the directory usually exists already
mkdir -p "$STATE/tmp" 2>/dev/null || true
local all="$STATE/tmp/strip-files.$$"
: > "$all" || die "cannot write to $STATE/tmp"
say "Collecting what can be stripped (this reads every candidate's header) ..."
# The book's candidate set, filtered down to what really is ELF -- scripts,
# data files and already-removed symlink targets fall out here, so nothing
# below has to tolerate strip failing on a non-binary.
local f magic
{
find "$r/usr/lib" "$r/usr/libexec" -xdev -type f -name '*.so*' \
! -name '*.dbg' 2>/dev/null
find "$r/usr/bin" "$r/usr/sbin" "$r/usr/libexec" -xdev -type f \
2>/dev/null
} | LC_ALL=C sort -u | while IFS= read -r f; do
magic=""
IFS= LC_ALL=C read -r -n 4 magic < "$f" 2>/dev/null || true
[ "$magic" = $'\x7fELF' ] && printf '%s\n' "$f"
done > "$all"
local total; total="$(count_stdin_lines < "$all")"
# group by owner: one list per owner, stripped in one su each
local bylist="$STATE/tmp/strip-owners.$$"
xargs -d '\n' -r stat -c '%U|%n' < "$all" > "$bylist" \
|| die "cannot stat the candidate files"
local -a owners=()
local o
while IFS= read -r o; do
[ -n "$o" ] && owners+=("$o")
done < <(cut -d'|' -f1 "$bylist" | LC_ALL=C sort -u)
# libtool archives, the book's cleanup
local lalist="$STATE/tmp/strip-la.$$"
find "$r/usr/lib" "$r/usr/libexec" -xdev -type f -name '*.la' \
2>/dev/null > "$lalist" || true
local nla; nla="$(count_stdin_lines < "$lalist")"
local before
before="$(xargs -d '\n' -r stat -c '%s' < "$all" \
| awk '{s+=$1} END{printf "%d", s/1024/1024}')"
say ""
say " $total ELF file(s), ${before:-0} MB, owned by ${#owners[@]} account(s):"
for o in "${owners[@]}"; do
say " $(grep -c "^$o|" "$bylist") owned by $o"
done | LC_ALL=C sort -rn -k1 | head -8
[ "${#owners[@]}" -gt 8 ] && say " ... and $((${#owners[@]} - 8)) more"
if [ "$keep_la" = 1 ]; then
say " $nla libtool .la file(s) -- kept (--keep-la)"
else
say " $nla libtool .la file(s) to remove"
fi
if [ "$run" = 0 ]; then
warn "(dry run -- nothing was changed. Add --run to strip them.)"
rm -f "$all" "$bylist" "$lalist"
return 0
fi
# The runner each owner executes. A generated file, not an inline -c
# string, so the quoting cannot go wrong and the loop is the same for
# root and for a package user. Failures are printed as marked lines and
# collected by the parent -- never swallowed, never fatal per file.
local runner="$STATE/tmp/strip-run.$$.sh"
cat > "$runner" <<'RUNNER'
#!/bin/bash
# generated by: lfs-helper strip
set -u
while IFS= read -r f; do
out="$(strip --strip-unneeded -- "$f" 2>&1)" \
|| printf 'FAILSTRIP\t%s\t%s\n' "$f" "$out"
done < "$1"
exit 0
RUNNER
chmod 755 "$runner"
local failed="$STATE/tmp/strip-failed.$$"
: > "$failed"
local list n
for o in "${owners[@]}"; do
list="$STATE/tmp/strip-list.$o.$$"
grep "^$o|" "$bylist" | cut -d'|' -f2- > "$list"
chmod 644 "$list"
n="$(count_stdin_lines < "$list")"
if [ "$o" = root ]; then
say " stripping $n file(s) as root ..."
bash "$runner" "$list" | grep '^FAILSTRIP' >> "$failed" || true
elif ! id -u "$o" >/dev/null 2>&1; then
# an owner the user database does not know cannot run anything;
# its files stay exactly as they are, and that is said, not hidden
warn " ${n} file(s) owned by unknown account '$o' -- left alone"
elif have_su; then
say " stripping $n file(s) as $o ..."
su -s /bin/bash "$o" -c "bash '$runner' '$list'" \
| grep '^FAILSTRIP' >> "$failed" || true
else
warn " no 'su' available -- ${n} file(s) owned by '$o' left alone"
fi
rm -f "$list"
done
local nfail; nfail="$(count_stdin_lines < "$failed")"
if [ "$nfail" != 0 ]; then
warn "$nfail file(s) could not be stripped:"
cut -f2,3 "$failed" | head -5 | sed 's/^/ /' >&2
[ "$nfail" -gt 5 ] && warn " ... and $((nfail - 5)) more"
fi
if [ "$keep_la" != 1 ] && [ "$nla" != 0 ]; then
# deleting is a directory operation, not a rewrite: it changes no
# file's owner, so root may do it directly
xargs -d '\n' -r rm -f -- < "$lalist"
say " removed $nla libtool .la file(s)"
fi
local after
after="$(xargs -d '\n' -r stat -c '%s' < "$all" 2>/dev/null \
| awk '{s+=$1} END{printf "%d", s/1024/1024}')"
ok "stripped: ${before:-0} MB -> ${after:-0} MB across $total file(s)"
rm -f "$all" "$bylist" "$lalist" "$runner" "$failed"
return 0
}
# Find (and optionally remove) per-user Python installs.
#
# pip falls back to ~/.local when the system site-packages is not writable. The
# package that installed it can still import it, so its build looks successful,
# but nothing else on the system can -- which surfaces much later as
# BackendUnavailable: Cannot import 'flit_core.buildapi'
# Export / import the collector-group decisions.
#
# Building a second system means answering the same "which group should share
# this directory?" questions again. Exporting the map makes the next build
# reuse the names you already chose -- no prompts, same layout.
# Remove duplicate entries from an export.
#
# Two sources feed it: a filesystem scan (what really carries a group now) and
# the recorded map (what was decided, possibly not yet applied). A directory
# that is in both appeared twice, because each source was only deduplicated
# against itself. `sort -u` across everything would also reorder the file;
# keeping first occurrences preserves the scan-then-map order, which reads
# more naturally and keeps a re-export stable.
_dedupe_collector_export() {
awk '!seen[$0]++'
}
cmd_export_groups() {
local out="${1:-$STATE_GROUPS/collector-groups.export}"
: > "$out"
{
echo "# lfs collector groups -- import with: lfs-helper import-groups <file>"
echo "# prefix|$COLLECTOR_PREFIX"
# The directories that actually carry a collector group right now.
# The recorded map only holds the tree TOPS we were asked about; the
# filesystem is the authority on what really ended up in each group.
local r="${SNAP_ROOT%/}"
find "${r:-/}" -xdev -type d \
-not -path "${r}/proc/*" -not -path "${r}/sys/*" \
-not -path "${r}/dev/*" -not -path "${r}/run/*" \
-not -path "${r}/tmp/*" -not -path "${r}/sources/*" \
-not -path "${r}/build/*" \
-printf '%g|%p\n' 2>/dev/null \
| awk -F'|' -v p="${COLLECTOR_PREFIX}_" -v r="$r" '
index($1, p) == 1 {
d = $2
if (r != "" && index(d, r) == 1) d = substr(d, length(r) + 1)
print "dir|" d "|" $1
}'
# plus anything decided but not yet applied
if [ -f "$COLLECTOR_MAP" ]; then
sed 's/^/dir|/' "$COLLECTOR_MAP"
fi
# group membership, so the same packages end up in the same groups
local line grp members
while IFS=: read -r grp _x _gid members; do
case "$grp" in
"${COLLECTOR_PREFIX}_"*) echo "group|$grp|$members" ;;
esac
done < "$ETC/group"
} | _dedupe_collector_export >> "$out"
ok "exported $(grep -c '^dir|' "$out" 2>/dev/null || echo 0) directory mapping(s)"
say " and $(grep -c '^group|' "$out" 2>/dev/null || echo 0) group(s) to $out"
say ""
say " Copy that file to the next system and run:"
say " lfs-helper import-groups $out"
}
cmd_import_groups() {
need_root
# Parse the arguments ONCE, in one place. This used to set run=1, then
# declare `local run=0` below it (wiping the flag), then reassign $in to
# the export path -- so `import-groups --run` read the wrong file and
# reported "imported 0 directory mapping(s)" while the decisions sat
# unapplied in collector-groups.import.
local in="" run=0 a
for a in "$@"; do
case "$a" in
--run) run=1 ;;
-*) die "usage: lfs-helper import-groups [<file>] [--run]" ;;
*) in="$a" ;;
esac
done
if [ -z "$in" ]; then
# prefer a file placed here by `lfs build-system gen-chroot-scripts`
if [ -f "$STATE_GROUPS/collector-groups.import" ]; then
in="$STATE_GROUPS/collector-groups.import"
else
in="$STATE_GROUPS/collector-groups.export"
fi
fi
[ -f "$in" ] || die "no such file: $in"
say "reading $in"
local ndir=0 ngrp=0 kind a b
while IFS='|' read -r kind a b; do
case "$kind" in
dir) ndir=$((ndir+1))
[ "$run" = 1 ] && { mkdir -p "$(dirname "$COLLECTOR_MAP")"
grep -qxF "$a|$b" "$COLLECTOR_MAP" 2>/dev/null \
|| echo "$a|$b" >> "$COLLECTOR_MAP"; } ;;
group) ngrp=$((ngrp+1))
if [ "$run" = 1 ]; then
ensure_group "$a" || continue
local m
for m in ${b//,/ }; do
user_exists "$m" && add_to_group "$m" "$a"
done
fi ;;
esac
# Dedupe on read as well as on write: an export made before the writer
# was fixed can contain the same mapping twice, and importing it would
# report and apply it twice.
done < <(grep -vE '^\s*(#|$)' "$in" | _dedupe_collector_export)
if [ "$run" = 1 ]; then
ok "imported $ndir directory mapping(s) and $ngrp group(s)"
say " Those directories will not be asked about again."
else
say " would import $ndir directory mapping(s) and $ngrp group(s)"
warn "(dry run -- nothing was changed. Add --run to apply.)"
fi
}
# every package user we know about
pkg_users_list() {
local d name root
while IFS= read -r root; do
for d in "$root"/*; do
[ -d "$d" ] || continue
name="$(basename "$d")"
user_exists "$name" && echo "$name"
done
done < <(pkgusr_roots)
}
# ---------------------------------------------------------------------------
# setuid/setgid bits: recorded during the build, applied by root afterwards.
#
# A PACKAGE USER OWNS FILES, NOT IDENTITY. The install and chmod wrappers
# refuse setid modes, which is right -- a package user CAN set setuid on its
# own file, and the result runs as the PACKAGE USER, not as root. That is
# how `su` came to say
# setgid: Operation not permitted
# instead of working: shadow's `install -m 4755 su` was downgraded to 755,
# and nothing set it afterwards.
#
# So: chown to root FIRST, then the mode. Both, or neither.
# ---------------------------------------------------------------------------
# What upstream installs with a setid bit, for the case where no request was
# recorded (an older build, or a package installed by hand).
# path|mode|group|who asks for it -- checked only if the file exists, and
# always reported with the mode and owner actually found.
#
# `-` IN THE GROUP COLUMN MEANS KEEP WHAT IS THERE, and for a setUID file
# that is the right answer on this system: only the owner has to be root for
# the bit to mean root, and the group records which package installed the
# file. These all said `root`, so this table ran immediately after the
# request table and undid it -- `/usr/bin/su is now 4755 root:p_shadow`
# followed by `/usr/bin/su is now 4755 root:root`, seventeen changes to
# reach the wrong one.
#
# A setGID entry is different: THERE the group is the whole point
# (dbus-daemon-launch-helper must be group messagebus, fcrontab group
# fcron), so those keep a named group.
_setid_known() {
# ONE TABLE: setid.conf. This was a second list, embedded here, and it
# disagreed with setid.conf on the mode -- 4755 here, 4711 there -- so
# `cfg shadow-pam` set 4711 and the next `lfs-helper setid --run` set
# 4755, each correcting the other (the 1.14.260 shape again). Now the
# config file is the list and this reads it. Only root-owned lines are
# setid policy. Output: path|mode|group|why
local conf
if ! conf="$(_setid_conf)"; then
warn "no setid.conf found (looked in \$SETID_CONF, ${PKGUSR_ETC:-/etc/pkgusr}/stacks, beside lfs-helper)"
return 0
fi
awk -F'|' -v src="$(basename "$conf")" '
/^[[:space:]]*(#|$)/ { next }
{ f = $1; m = $2; o = $3; g = $4
gsub(/[[:space:]]/, "", f); gsub(/[[:space:]]/, "", m)
gsub(/[[:space:]]/, "", o); gsub(/[[:space:]]/, "", g)
if (f == "" || m == "") next
if (o != "" && o != "root") next
print f "|" m "|" (g == "" ? "-" : g) "|" src }' "$conf"
}
_setid_state() { # -> "<mode> <owner>:<group>"
stat -c '%a %U:%G' "$1" 2>/dev/null
}
_setid_apply() { # <mode> <group> <path> (root; reads the result back)
local mode="$1" grp="$2" f="$3" got=""
chown "root:$grp" "$f" || { warn " chown root:$grp $f failed"; return 1; }
chmod "$mode" "$f" || { warn " chmod $mode $f failed"; return 1; }
# CONFIRM YOUR OWN EFFECTS: chmod returns 0 on a nosuid mount too, and
# the bit is then not there.
got="$(_setid_state "$f")"
case "$got" in
"${mode#0} root:$grp"|"$mode root:$grp")
ok " $f is now $got"; return 0 ;;
esac
warn " $f still reads '$got', not '$mode root:$grp'"
warn " (a nosuid mount cannot hold a setid bit -- check: findmnt -T $f)"
return 1
}
cmd_setid() {
need_root
local run=0 a
for a in "$@"; do case "$a" in --run) run=1 ;; esac; done
local outstanding=0 applied=0 known=0 line mode grp why got home u f
say "Setuid/setgid bits refused during the build (a package user cannot"
say "set them; root can):"
say ""
while IFS= read -r u; do
home="$(pkgusr_home_for "$u" 2>/dev/null)" || continue
# a ledger left root-owned by an older release blocks the NEXT
# build, not this one, so repair it whenever we are here anyway
for _lf in "$home/.pkgusr-setid.requests" "$home/.pkgusr-setid.applied"; do
[ -e "$_lf" ] || continue
[ "$(stat -c %U "$_lf" 2>/dev/null)" = "$u" ] && continue
chown "$u:$u" "$_lf" 2>/dev/null \
&& note "gave $u back $(basename "$_lf")"
done
[ -s "$home/.pkgusr-setid.requests" ] || continue
say " $u:"
while IFS= read -r line; do
[ -n "$line" ] || continue
# the recorded line is literally `chmod <mode> <path> [path ...]`
set -- $line
[ "${1:-}" = chmod ] || continue
mode="${2:-}"; shift 2
for f in "$@"; do
case "$f" in -*) continue ;; esac
if [ ! -e "$f" ]; then
warn " $f: recorded, but not on disk (package removed?)"
continue
fi
got="$(_setid_state "$f")"
say " $f is $got, asked for $mode"
outstanding=$((outstanding+1))
# KEEP THE GROUP THE FILE ALREADY HAS. Only the USER has
# to be root for a setuid bit to mean root; the group is how
# this system records which package owns a file, and
# root:root threw that away -- `/usr/bin/su is now 4755
# root:root` when it should read root:p_shadow. setid.conf
# uses `-` for exactly this; the request path did not.
_grp_now="$(stat -c '%G' "$f" 2>/dev/null)"
[ -n "$_grp_now" ] || _grp_now=root
# A PATH setid.conf NAMES IS DECIDED THERE. The package asked
# for 4755 on su, the table says 4711; applying both flipped
# the file twice in one run.
if _setid_policy_for "$f" >/dev/null; then
say " (setid.conf decides this one -- see below)"
continue
fi
[ "$run" = 1 ] && { _setid_apply "$mode" "$_grp_now" "$f" \
&& applied=$((applied+1)); }
done
done < "$home/.pkgusr-setid.requests"
if [ "$run" = 1 ]; then
# moved aside, not deleted: what was applied stays answerable
cat "$home/.pkgusr-setid.requests" >> "$home/.pkgusr-setid.applied" 2>/dev/null
: > "$home/.pkgusr-setid.requests" 2>/dev/null || true
# ...and hand both back: they live in the package user's home and
# the next build appends to them as that user
chown "$u:$u" "$home/.pkgusr-setid.requests" \
"$home/.pkgusr-setid.applied" 2>/dev/null || true
fi
done < <(pkg_users_list)
[ "$outstanding" = 0 ] && say " (none recorded)"
say ""
say "Programs upstream installs with a setid bit, as found on disk:"
say ""
while IFS='|' read -r f mode grp why; do
[ -n "$f" ] || continue
[ -e "$f" ] || continue
got="$(_setid_state "$f")"
# `-` MEANS KEEP THE GROUP THE FILE HAS. With `root` hard-coded
# here, this table undid the request table one line later.
if [ "$grp" = "-" ]; then
grp="$(stat -c '%G' "$f" 2>/dev/null)"
[ -n "$grp" ] || grp=root
fi
[ "$got" = "${mode#0} root:$grp" ] && continue
known=$((known+1))
say " $f"
say " is $got"
say " want $mode root:$grp ($why)"
[ "$run" = 1 ] && { _setid_apply "$mode" "$grp" "$f" \
&& applied=$((applied+1)); }
done < <(_setid_known)
[ "$known" = 0 ] && say " (each one is already root-owned with its bit)"
say ""
if [ "$run" = 1 ]; then
say "Applied $applied change(s). Try what was broken: su -"
return 0
fi
if [ $((outstanding + known)) -gt 0 ]; then
say "Nothing was changed. Apply it: lfs-helper setid --run"
return 1
fi
ok "every setid bit this system needs is in place"
return 0
}
cmd_find_user_site() {
need_root
local run=0; [ "${1:-}" = "--run" ] && run=1
local d n=0
say "Per-user Python installs under $SRCROOT:"
say " (pip put these in a package user's home because the system"
say " site-packages was not writable; only that user can import them)"
say ""
while IFS= read -r d; do
[ -n "$d" ] || continue
printf " %s\n" "$d"
n=$((n+1))
[ "$run" = 1 ] && rm -rf "$d"
done < <(find "$SRCROOT" -maxdepth 5 -type d -path '*/.local/lib' 2>/dev/null | sort)
say ""
if [ "$n" = 0 ]; then
ok " none -- every package installed system-wide"
elif [ "$run" = 1 ]; then
ok " removed $n per-user install(s)"
say " Rebuild the affected packages so they install system-wide:"
say " lfs-helper build <package> --force"
else
say " $n found"
warn "(dry run -- nothing was changed. Add --run to remove them.)"
fi
}
cmd_clean_sources() {
need_root
local run=0; [ "${1:-}" = "--run" ] && run=1
local srcdir="${SOURCES_DIR:-${SNAP_ROOT%/}/sources}"
local blddir="${BUILD_ROOT:-${SNAP_ROOT%/}/build}"
[ -d "$srcdir" ] || die "no $srcdir"
say "Two directories, two rules:"
say " $srcdir -- downloaded tarballs only"
say " * the directory itself -> root:root 1777 (book 3.1)"
say " * every file in it -> root:root (book 3.1: a host uid means"
say " nothing here, and one above 10000"
say " collides with a real package user)"
say " $blddir -- build scratch"
say " * leftover build markers (.pkgusr-*, .cc-*) -> removed"
say " (unpacked trees are left alone: a build may be using one. To"
say " drop them all, delete $blddir -- it is rebuilt on demand.)"
say ""
local orphans markers
# Not just -nouser: a uid that DOES resolve here can still be the wrong
# one -- the host's `lfs` is created without a pinned uid, so it may land
# on top of a package user and report a plausible, wrong name.
orphans="$(find "$srcdir" -maxdepth 1 -mindepth 1 -not -type d \
\( -not -user root -o -not -group root \) 2>/dev/null | sort)"
markers="$(find "$srcdir" "$blddir" -maxdepth 1 \( -name '.cc-*' -o -name '.pkgusr-*' \) \
2>/dev/null | sort)"
local n_o n_m
n_o=$(printf '%s\n' "$orphans" | count_stdin_lines)
n_m=$(printf '%s\n' "$markers" | count_stdin_lines)
say " $n_o file(s) not owned by root"
say " $n_m build marker(s)"
if [ "$run" = 0 ]; then
[ "$n_o" -gt 0 ] && printf '%s\n' "$orphans" | head -6 | sed 's/^/ /'
say ""
warn "(dry run -- nothing was changed. Add --run to apply.)"
return 0
fi
real_chown root:root "$srcdir" 2>/dev/null
real_chmod 1777 "$srcdir" 2>/dev/null
[ -d "$blddir" ] && { chown root:root "$blddir" 2>/dev/null
real_chmod 1777 "$blddir" 2>/dev/null; }
[ "$n_o" -gt 0 ] && real_chown -h root:root $orphans 2>/dev/null
[ "$n_m" -gt 0 ] && rm -f $markers
say ""
ok " tidied: $n_o ownership(s) reset, $n_m marker(s) removed"
say " (markers are recreated by the next build; nothing else is lost)"
}
cmd_fix_perms() {
need_root
local name="" phase="all" run=0
while [ $# -gt 0 ]; do
case "$1" in
--run) run=1; shift ;;
--phase) phase="$2"; shift 2 ;;
-*) die "usage: lfs-helper fix-perms <package> [--phase P] [--run]" ;;
*) name="$1"; shift ;;
esac
done
[ -n "$name" ] || die "usage: lfs-helper fix-perms <package> [--run]"
local owner; owner="$(pkg_owner_name "$name")"
local log="$LOGS/$name-$phase.log"
[ -f "$log" ] || die "no log for $name (phase $phase) at $log"
# "install: cannot create regular file '/a/b/c': Permission denied"
# "mkdir: cannot create directory '/a/b': Permission denied"
# "/a/b/c: Permission denied"
local dirs
dirs="$(grep -oE "'[^']+'|\"[^\"]+\"" "$log" 2>/dev/null \
| tr -d "'\"" \
| while IFS= read -r p; do
case "$p" in /*) ;; *) continue ;; esac
if [ -d "$p" ]; then echo "$p"; else echo "${p%/*}"; fi
done | sort -u)"
# only keep ones that really are unwritable for the package user
local need="" d
while IFS= read -r d; do
[ -n "$d" ] || continue
[ -d "$d" ] || continue
su -s /bin/bash "$owner" -c "test -w '$d'" 2>/dev/null && continue
need="$need$d
"
done <<< "$dirs"
need="$(printf '%s' "$need" | grep -v '^$' || true)"
if [ -z "$need" ]; then
say "Nothing to fix: '$owner' can already write every directory"
say "mentioned in $log."
return 0
fi
say "Directories '$owner' cannot write into (from $log):"
printf ' %s\n' $need
say ""
if [ "$run" = 0 ]; then
warn "(dry run -- add --run to grant access via collector groups)"
return 0
fi
while IFS= read -r d; do
[ -n "$d" ] && grant_dir_access "$d" "$owner"
done <<< "$need"
say ""
ok "Access granted. Re-run the build:"
say " lfs-helper build $name --phase $phase --force"
}
# ---------------------------------------------------------------------------
# command wrappers (the package-users hint's /usr/lib/pkgusr)
#
# Packages routinely try to set ownership or permissions a package user simply
# cannot: the LFS book's GCC section runs
# chown -v -R root:root /usr/lib/gcc/<triplet>/<ver>/include{,-fixed}
# and as the `gcc` user that fails with "Operation not permitted", killing the
# install for no good reason -- the files are already owned correctly for this
# scheme. The hint's answer is to put wrappers for the five troublesome
# commands (mkdir, chgrp, chown, chmod, install) first in the package user's
# PATH. They neutralise the impossible parts, report what they skipped, and
# otherwise behave normally.
make_wrappers() {
mkdir -p "$WRAPPERS"
# STAMP THEM. A wrapper is code, and code goes stale: a snapshot restored
# the wrapper from before the bundled-option fix, it existed and was
# executable, and the caller's check -- "missing or incomplete" -- was
# satisfied. A fixed wrapper on the tools side never reached the tree, and
# the exact failure the fix was for came back on the exact same line.
# Same species as a stale chroot copy of the tools, which is why the stamp
# is the same fingerprint --version prints.
# ${VAR:-} and the `command -v` guard: the test suite lifts this function
# out with sed and runs it under `set -u` in a context where neither the
# version variable nor _build_id exists -- and a stamp that kills the
# function it stamps is worse than no stamp.
echo "lfs-helper ${LFS_HELPER_VERSION:-unknown} (build $(command -v _build_id >/dev/null 2>&1 && _build_id || echo unknown))" \
> "$WRAPPERS/.written-by" 2>/dev/null
cat > "$WRAPPERS/chown" <<'EOF'
#!/bin/bash
# Ownership is decided by the package-user scheme, not by the package.
echo "*** chown $* (skipped: package users cannot change ownership)" >&2
exit 0
EOF
cat > "$WRAPPERS/chgrp" <<'EOF'
#!/bin/bash
# Group changes (e.g. setgid tty) need root; do them yourself afterwards.
echo "*** chgrp $* (skipped: package users cannot change group)" >&2
exit 0
EOF
# ldconfig: the LIBRARY CACHE is root's, refreshed after the install.
#
# /etc/ld.so.cache is rewritten by every package that installs a library.
# ldconfig writes ld.so.cache~ and RENAMES it over the old file, and in a
# sticky /etc only the file's owner may do that -- so a package user got
# Renaming of /etc/ld.so.cache~ to /etc/ld.so.cache failed:
# Operation not permitted
# Unsealing /etc to fix that would let every package delete every other
# package's config. The cache is not the package's to write: it is a
# cache, and the tools refresh it as root when the install finishes --
# the same treatment mandb gets.
cat > "$WRAPPERS/ldconfig" <<'EOF'
#!/bin/bash
echo "*** ldconfig: skipped -- the library cache is refreshed as root after this install" >&2
exit 0
EOF
chmod 755 "$WRAPPERS/ldconfig"
cat > "$WRAPPERS/install" <<'EOF'
#!/bin/bash
# Drop -o/-g (ownership), refuse setuid/setgid modes, and never re-create or
# re-mode a directory that already exists. Packages routinely do
# install -m 0755 -d /usr/sbin
# for no good reason; as a package user that fails with
# install: cannot change permissions of '/usr/sbin': Operation not permitted
# The directory is already there and correctly set up, so there is nothing to do.
real=/usr/bin/install
# CHECK BEFORE REDIRECTING. `printf >> file 2>/dev/null || echo ...` does
# not work: the shell itself reports the failed redirection ("Permission
# denied") before printf ever runs, and 2>/dev/null on printf cannot
# suppress it -- which is how the recorder's own message came to fail an
# install that had finished.
_sid_record() { # _sid_record <line>
local f="$HOME/.pkgusr-setid.requests"
if [ -w "$f" ] || { [ ! -e "$f" ] && [ -w "$HOME" ]; }; then
printf '%s\n' "$1" >> "$f"
else
echo "*** (could not record: $1)" >&2
echo "*** apply it as root instead: lfs-helper setid --run" >&2
fi
}
args=(); skipped=""; _setid_mode=""
# BUNDLED SHORT OPTIONS FIRST, or none of the rules below see what they are
# looking for. Everything here matched `-d` only as a whole argument, and
# make-ca's Makefile writes
# install -vdm755 /usr/sbin
# -- one token. The directory rule never fired, the real install ran, and it
# died on the exact message this wrapper's own comment quotes:
# install: cannot change permissions of '/usr/sbin': Operation not permitted
# `install -d` on an EXISTING directory still applies the mode, and chmod needs
# ownership, not write permission -- so no group membership could ever fix it.
#
# Split `-vdm755` into `-v -d -m 755`, so one spelling reaches every rule.
# These are install(1)'s short options that take a value; for them the rest of
# the token is the value (or the next argument, if the token ends there).
_expanded=()
for _a in "$@"; do
case "$_a" in
-[!-]*)
_rest="${_a#-}"
while [ -n "$_rest" ]; do
_c="${_rest%"${_rest#?}"}" # first character
_rest="${_rest#?}"
case "$_c" in
m|o|g|t|S) # takes a value
_expanded+=("-$_c")
# `-m755`: the value is the rest of this token.
# `-m 755`: it is the next argument, which reaches the
# loop on its own and is passed through as a word.
if [ -n "$_rest" ]; then
_expanded+=("$_rest"); _rest=""
fi ;;
*) _expanded+=("-$_c") ;;
esac
done ;;
*)
_expanded+=("$_a") ;;
esac
done
set -- "${_expanded[@]}"
# -d mode: create directories. Skip the ones that already exist.
want_dirs=0
for a in "$@"; do case "$a" in -d|--directory) want_dirs=1 ;; esac; done
if [ "$want_dirs" = 1 ]; then
dirs=(); opts=(); skip_next=0
for a in "$@"; do
if [ "$skip_next" = 1 ]; then skip_next=0; continue; fi
case "$a" in
-d|--directory) ;;
-m|--mode|-o|--owner|-g|--group) skip_next=1 ;;
-*) ;;
*) dirs+=("$a") ;;
esac
done
rc=0
for d in "${dirs[@]}"; do
if [ -d "$d" ]; then
echo "*** install -d $d (already exists -- not re-created or re-moded)" >&2
continue
fi
/usr/bin/mkdir -p "$d" || rc=$?
done
exit $rc
fi
while [ $# -gt 0 ]; do
case "$1" in
-o|--owner|-g|--group) skipped="$skipped $1 $2"; shift 2 ;;
# Still reachable for the long forms (--owner=root) -- the expansion
# above only splits SHORT clusters, so `-oroot` arrives here as
# `-o root` and is caught by the line before this one.
-o*|-g*) skipped="$skipped $1"; shift ;;
-m|--mode)
case "$2" in
[2467][0-7][0-7][0-7]) # setuid/setgid/sticky requested
# WRITE DOWN WHAT WAS REFUSED -- the chmod wrapper does,
# and this is the same refusal. shadow installs su with
# `install -m 4755`, so the bit was dropped here and
# nothing recorded it; months later `su -` failed with
# setgid: Operation not permitted
# which names neither su's mode nor its owner. The
# targets are known after the parse loop, so the mode is
# remembered here and written at the end.
_setid_mode="$2"
echo "*** install -m $2 (using 755 instead; apply it as root: lfs-helper setid --run)" >&2
args+=(-m 755); shift 2 ;;
*) args+=("$1" "$2"); shift 2 ;;
esac ;;
*) args+=("$1"); shift ;;
esac
done
[ -n "$skipped" ] && echo "*** install:$skipped (skipped: ownership is the package user's)" >&2
# AN ARTIFACT THAT WAS NEVER BUILT IS NOT A FAILED INSTALL.
#
# The book installs optional extras with commands the package's own
# `make install` does not run:
# install -v -m644 doc/doxygen/html/*.* (alsa-lib, needs Doxygen)
# install -Dm644 docs/build/man/libuv.1 (libuv, needs sphinx)
# The tool that BUILDS them is optional, so on a system without it the
# sources do not exist and install dies -- taking the whole package with it,
# after a successful build and a successful `make install`.
#
# If every SOURCE argument is missing, there is nothing to install: say so
# and succeed. A partial set still goes to the real install, so a genuinely
# half-built package is not hidden. Sources are relative paths in the build
# tree here; an absolute destination is never treated as a source.
_srcs=(); _n=${#args[@]}
if [ "$_n" -ge 2 ]; then
for ((_i = 0; _i < _n - 1; _i++)); do
case "${args[$_i]}" in
-*) continue ;;
esac
# the value of a value-taking option is not a source
if [ "$_i" -gt 0 ]; then
case "${args[$((_i - 1))]}" in -m|--mode|-o|--owner|-g|--group|-t|-S) continue ;; esac
fi
_srcs+=("${args[$_i]}")
done
if [ "${#_srcs[@]}" -gt 0 ]; then
_have=0
for _s in "${_srcs[@]}"; do
[ -e "$_s" ] && _have=1 && break
done
if [ "$_have" = 0 ]; then
echo "*** install: none of these exist, nothing to install: ${_srcs[*]}" >&2
echo "*** (an optional artifact that was never built -- not a failure)" >&2
exit 0
fi
fi
fi
# A SYSTEMD UNIT ON A SYSTEM WITH NO SYSTEMD.
#
# The systemd book's wpa_supplicant page ends with
# install -m644 ... /usr/lib/systemd/system/
# and on a SysV system that directory does not exist, so install fails and
# takes the package with it -- after wpa_supplicant, wpa_cli and the man
# pages had all installed correctly. This is the same situation the
# systemctl shim was written for: the book assumes an init this machine does
# not run. Say so and succeed, rather than creating a directory of units
# nothing will ever read.
_dest="${args[$((${#args[@]} - 1))]}"
case "$_dest" in
/usr/lib/systemd/*|/lib/systemd/*|/usr/lib/systemd|/lib/systemd)
if [ ! -d /usr/lib/systemd/system ] && ! command -v systemctl >/dev/null 2>&1; then
echo "*** install: skipped $_dest -- this system does not run systemd" >&2
echo "*** (a unit file from a systemd-book page; nothing would read it)" >&2
exit 0
fi ;;
esac
# RECORD THE REFUSED BIT AGAINST THE FILE THAT ACTUALLY LANDED.
#
# Only after the install succeeded: a request naming a file that was never
# installed sends root to chmod something that is not there.
if [ -z "$_setid_mode" ]; then
exec "$real" "${args[@]}"
fi
"$real" "${args[@]}" || exit $?
_f="$HOME/.pkgusr-setid.requests"
_last="${args[$((${#args[@]} - 1))]}"
_srcs2=(); _n2=${#args[@]}
for ((_i = 0; _i < _n2 - 1; _i++)); do
case "${args[$_i]}" in -*) continue ;; esac
if [ "$_i" -gt 0 ]; then
case "${args[$((_i - 1))]}" in -m|--mode|-o|--owner|-g|--group|-t|-S) continue ;; esac
fi
_srcs2+=("${args[$_i]}")
done
if [ -d "$_last" ]; then
for _s in ${_srcs2[@]+"${_srcs2[@]}"}; do
_sid_record "chmod $_setid_mode ${_last%/}/$(basename "$_s")"
done
else
_sid_record "chmod $_setid_mode $_last"
fi
exit 0
EOF
# A SERVICE ACCOUNT IS ROOT'S JOB.
#
# fcron's page starts with
# groupadd -g 22 fcron && useradd ... -u 22 fcron
# and the phase runs as p_fcron, which cannot write /etc/group:
# groupadd: cannot lock /etc/group; try again later
# cmd_build creates these as root BEFORE the phase (see
# create_service_accounts_from_script). These wrappers make the package
# user's own attempt a no-op when the account is already there, so the
# book's command can stay in the script where a reader expects it.
cat > "$WRAPPERS/groupadd" <<'EOF'
#!/bin/bash
real=/usr/sbin/groupadd
_name=""
for a in "$@"; do case "$a" in -*) ;; *) _name="$a" ;; esac; done
if [ -n "$_name" ] && getent group "$_name" >/dev/null 2>&1; then
echo "*** groupadd: group '$_name' already exists -- nothing to do" >&2
exit 0
fi
if [ "$(id -u)" != 0 ]; then
echo "*** groupadd: '$_name' does not exist and a package user cannot" >&2
echo "*** create it. As root: groupadd $*" >&2
exit 1
fi
exec "$real" "$@"
EOF
cat > "$WRAPPERS/useradd" <<'EOF'
#!/bin/bash
real=/usr/sbin/useradd
_name=""
for a in "$@"; do case "$a" in -*) ;; *) _name="$a" ;; esac; done
if [ -n "$_name" ] && getent passwd "$_name" >/dev/null 2>&1; then
echo "*** useradd: user '$_name' already exists -- nothing to do" >&2
exit 0
fi
if [ "$(id -u)" != 0 ]; then
echo "*** useradd: '$_name' does not exist and a package user cannot" >&2
echo "*** create it. As root: useradd $*" >&2
exit 1
fi
exec "$real" "$@"
EOF
cat > "$WRAPPERS/chmod" <<'EOF'
#!/bin/bash
# Allow everything except setuid/setgid, which a package user must not set.
real=/usr/bin/chmod
# CHECK BEFORE REDIRECTING. `printf >> file 2>/dev/null || echo ...` does
# not work: the shell itself reports the failed redirection ("Permission
# denied") before printf ever runs, and 2>/dev/null on printf cannot
# suppress it -- which is how the recorder's own message came to fail an
# install that had finished.
_sid_record() { # _sid_record <line>
local f="$HOME/.pkgusr-setid.requests"
if [ -w "$f" ] || { [ ! -e "$f" ] && [ -w "$HOME" ]; }; then
printf '%s\n' "$1" >> "$f"
else
echo "*** (could not record: $1)" >&2
echo "*** apply it as root instead: lfs-helper setid --run" >&2
fi
}
me="$(id -un)"
for a in "$@"; do
case "$a" in
[2467][0-7][0-7][0-7]|*[ug]+s*|*+s*)
# WRITE DOWN WHAT WAS REFUSED.
#
# fcron installs fcrontab setgid `fcron`; the bit was refused
# here, correctly, and then nothing said so again. Much later
# `fcrontab -z -u systab` failed with
# ERROR could not change egid to 22: Operation not permitted
# -- as ROOT, which makes no sense until you know the binary lost
# its setgid bit at install time. A refusal nobody records is a
# decision that has to be re-derived from its consequences.
echo "*** chmod $* (skipped: refuses to set setuid/setgid; do it as root if needed)" >&2
# A RECORDER THAT CANNOT WRITE IS A NOTE, NOT A FAILURE.
_sid_record "chmod $*"
exit 0 ;;
esac
done
# Never change the mode of something owned by someone else. Packages try to
# re-mode shared directories they merely install into; the package-user scheme
# says those belong to their owner and stay as they are.
# The first non-option argument is the MODE; everything after it is a target.
args=(); skipped=""; targets=0; seen_mode=0
for a in "$@"; do
case "$a" in
-[!0-9]*|--*) args+=("$a"); continue ;;
esac
if [ "$seen_mode" = 0 ]; then
seen_mode=1; args+=("$a"); continue # the mode itself
fi
if [ -e "$a" ] && [ "$(stat -c %U "$a" 2>/dev/null)" != "$me" ]; then
skipped="$skipped $a"
continue
fi
args+=("$a"); targets=$((targets+1))
done
[ -n "$skipped" ] && \
echo "*** chmod:$skipped (skipped: not owned by '$me' -- left as it is)" >&2
# every target was skipped? then there is nothing to do, and that is fine
[ "$targets" -eq 0 ] && exit 0
exec "$real" "${args[@]}"
EOF
# tar, for the same reason as cp.
#
# Git installs its translations with `tar ... | (cd /usr/share/locale && tar
# -x)`, and tar then tries to stamp the destination DIRECTORIES -- which
# belong to other packages and are sticky because we sealed them:
# tar: ./zh_TW: Cannot utime: Operation not permitted
# tar: ./zh_TW: Cannot change mode to rwxr-xr-t: Operation not permitted
# tar: Exiting with failure status due to previous errors
# 104 files had installed correctly. As with cp, the metadata complaint
# about a directory we do not own is harmless; anything else is not.
cat > "$WRAPPERS/tar" <<'EOF'
#!/bin/bash
real=/usr/bin/tar
err="$(mktemp)"
"$real" "$@" 2>"$err"
rc=$?
cat "$err" >&2
if [ $rc -ne 0 ] && [ -s "$err" ]; then
# every complaint must be about metadata on something we do not own
if ! grep -vE "Cannot (utime|change mode|change ownership)|Exiting with failure status" "$err" \
| grep -q .; then
echo "*** tar: could not stamp existing directories (harmless: the files extracted)" >&2
rc=0
fi
fi
rm -f "$err"
exit $rc
EOF
chmod 755 "$WRAPPERS/tar"
cat > "$WRAPPERS/cp" <<'EOF'
#!/bin/bash
# `cp -a dest/* /` is how several packages install a staged tree. As a package
# user, copying the FILES works, but cp then tries to stamp the destination
# DIRECTORIES (/usr, /usr/bin -- owned by root) with the source's timestamps
# and permissions, and fails:
# cp: preserving times for '/usr/bin': Operation not permitted
# The copy itself succeeded, so treat that specific complaint as harmless and
# fail only on real errors.
real=/usr/bin/cp
err="$(mktemp)"
# capture stderr to a file first: with process substitution the check below can
# run before tee has flushed, and the result becomes a coin toss
"$real" "$@" 2>"$err"
rc=$?
cat "$err" >&2
if [ $rc -ne 0 ] && [ -s "$err" ]; then
if ! grep -qvE "preserving (times|permissions|ownership) for" "$err"; then
echo "*** cp: could not stamp existing directories (harmless: the files copied)" >&2
rc=0
fi
fi
rm -f "$err"
exit $rc
EOF
cat > "$WRAPPERS/mkdir" <<'EOF'
#!/bin/bash
# A package recreating an existing system directory must not fail the build.
real=/usr/bin/mkdir
"$real" "$@" && exit 0
rc=$?
for a in "$@"; do
case "$a" in -*) continue ;; esac
[ -d "$a" ] || exit $rc
done
echo "*** mkdir $* (directory already exists -- continuing)" >&2
exit 0
EOF
# plain chmod here on purpose: this function creates the wrappers, and the
# chmod wrapper execs the real binary for everything except setuid. Using
# real_chmod would tie make_wrappers to a helper defined far above it, for
# no gain -- ownership is not being decided here, only the mode of files we
# just wrote ourselves.
chmod 755 "$WRAPPERS"/*
# 0755 root:root, explicitly. This directory sits under /usr/lib, which IS
# an install directory: root:install and group-writable during the build.
# Inheriting that would let any package user rewrite the wrappers -- that
# is, rewrite the rule that stops it changing ownership.
soft "could not set $WRAPPERS to 0755 -- a package user may be able to rewrite the wrappers" \
-- chmod 0755 "$WRAPPERS"
soft "could not give $WRAPPERS to root -- the wrappers are writable by whoever owns them now" \
-- chown 0:0 "$WRAPPERS" "$WRAPPERS"/*
}
# Re-apply the install-directory permissions.
#
# Packages reset modes on directories they touch -- /usr/share/man/man1 ends up
# root:install but drwxr-xr-x, and the next package then cannot write its man
# page:
# ln: failed to create symbolic link '/usr/share/man/man1/cc.1': Permission denied
# Re-asserting group-writability before each build costs nothing and stops that
# drift from becoming a failed install.
ensure_install_dirs_writable() {
pkgusr_ready || return 0
local d fixed=0
while IFS= read -r d; do
d="${SNAP_ROOT%/}$d"
[ -d "$d" ] || continue
# Check the GROUP write bit numerically. Matching "w" in the symbolic
# mode is wrong: drwxr-xr-x contains a "w" -- the owner's.
local _grp _mode
_grp="$(stat -c '%G' "$d" 2>/dev/null)" || continue
_mode="$(stat -c '%a' "$d" 2>/dev/null)" || continue
if install_dir_owner_ok "$d" \
&& [ $(( (10#$_mode / 10) % 10 & 2 )) -ne 0 ]; then
continue # already root:install, g+w
fi
set_install_dir_owner "$d" || continue
real_chmod g+w "$d" 2>/dev/null || continue
fixed=$((fixed+1))
done < <(install_dirs_list)
# ...and the per-language man/locale directories, which come and go as
# packages are installed. Without this every translated man page turns
# into another "grant access?" prompt.
local r="${SNAP_ROOT%/}"
while IFS= read -r d; do
[ -d "$d" ] || continue
local _g _m
_g="$(stat -c '%G' "$d" 2>/dev/null)" || continue
_m="$(stat -c '%a' "$d" 2>/dev/null)" || continue
install_dir_owner_ok "$d" \
&& [ $(( (10#$_m / 10) % 10 & 2 )) -ne 0 ] && continue
set_install_dir_owner "$d" || continue
real_chmod g+w "$d" 2>/dev/null || continue
fixed=$((fixed+1))
done < <(
find "$r/usr/share/man" "$r/usr/share/locale" "$r/usr/share/terminfo" \
-mindepth 1 -maxdepth 2 -type d 2>/dev/null
)
[ "$fixed" -gt 0 ] && \
detail "# re-applied group-writability to $fixed install dir(s)"
return 0
}
# When an install fails, inspect the paths the errors mention and report what
# is actually there. "No such file or directory" on a destination can mean the
# parent directory is missing, or that the target is a dangling symlink -- and
# "Permission denied" can mean the file exists but belongs to another package.
# Guessing from the message alone wastes a lot of time; this shows the facts.
diagnose_paths_from_log() {
local log="$1" user="$2" p shown=0
[ -f "$log" ] || return 0
local paths
# tail only, and cap the results: see unwritable_dirs_from_log for why
# A PERL MAN PAGE HAS COLONS IN ITS NAME.
#
# /usr/share/man/man3/XML::Parser.3 was reported as
# /usr/share/man/man3/XML missing; parent ... is drwxrwxr-t
# -- the class below had no ":" in it, so the name was cut at the first
# one, and the report named a file that has never existed while the one
# that could not be replaced went unmentioned. A trailing colon (the
# message's own "path: Permission denied") is stripped afterwards.
paths="$(tail -c 524288 "$log" 2>/dev/null | grep -aoE "/[A-Za-z0-9._/+:-]{6,}" \
| sed 's/:*$//' \
| grep -vE "^/(proc|sys|dev|tmp)/" | sort -u \
| while IFS= read -r p; do
case "$p" in
*/lib/perl5/*|*/usr/*) echo "$p" ;;
esac
done)"
# only the ones named on an error line
local errpaths
# Require at least two slashes: a bare "/GDBM_File.pm" is the tail of a
# RELATIVE path in the message (lib/GDBM_File.pm), not a real target.
errpaths="$(grep -iE "couldn't|cannot|no such file|permission denied|failed" "$log" 2>/dev/null \
| grep -oE "/[A-Za-z0-9._/+-]{6,}" \
| sort -u \
| while IFS= read -r p; do
# keep real targets: either inside the tree we track, or
# under a known top-level dir. This drops the tails of
# RELATIVE paths in messages ("lib/GDBM_File.pm" ->
# "/GDBM_File.pm", "auto/B/B.so" -> "/auto/B/B.so").
case "$p" in
"${SNAP_ROOT%/}"/*) echo "$p" ;;
/usr/*|/etc/*|/var/*|/opt/*|/lib/*|/lib64/*|/bin/*|\
/sbin/*|/home/*|/root/*|/srv/*|/boot/*|/tools/*|/sources/*|/build/*)
echo "$p" ;;
esac
done | head -8)"
[ -n "$errpaths" ] || return 0
say ""
note "--- what those paths actually are ---"
while IFS= read -r p; do
[ -n "$p" ] || continue
if [ -L "$p" ] && [ ! -e "$p" ]; then
printf " %-52s DANGLING SYMLINK -> %s\n" "$p" "$(readlink "$p")"
elif [ -e "$p" ]; then
printf " %-52s %s %s:%s\n" "$p" \
"$(stat -c %A "$p" 2>/dev/null)" \
"$(stat -c %U "$p" 2>/dev/null)" "$(stat -c %G "$p" 2>/dev/null)"
# "cannot remove"/"cannot create" is really about the DIRECTORY the
# file sits in -- replacing a file needs write permission there, not
# on the file itself. Show it, or the report is misleading.
local pd="${p%/*}"
[ -d "$pd" ] && printf " %-52s %s %s:%s (parent -- this is what matters)\n" \
" in $pd" "$(stat -c %A "$pd" 2>/dev/null)" \
"$(stat -c %U "$pd" 2>/dev/null)" "$(stat -c %G "$pd" 2>/dev/null)"
else
local d="${p%/*}"
if [ -d "$d" ]; then
printf " %-52s missing; parent %s is %s %s:%s\n" "$p" "$d" \
"$(stat -c %A "$d" 2>/dev/null)" \
"$(stat -c %U "$d" 2>/dev/null)" "$(stat -c %G "$d" 2>/dev/null)"
else
printf " %-52s missing, and so is its parent %s\n" "$p" "$d"
fi
fi
shown=$((shown+1))
done <<< "$errpaths"
[ "$shown" -gt 0 ] && note "-------------------------------------"
return 0
}
# Give a pip-based package write access to Python's site-packages BEFORE it
# runs.
#
# `pip3 install` writes into /usr/lib/pythonX.Y/site-packages, which belongs to
# the python package. If that isn't writable, pip does not always fail loudly:
# depending on the build it can fall back to a per-user location under the
# package user's home, where nothing else can import it. The next package that
# needs it then fails with a baffling
# BackendUnavailable: Cannot import 'flit_core.buildapi'
# even though the earlier install "succeeded". Granting up front avoids the
# whole class of problem.
# does this package install with pip?
uses_pip() {
grep -qE '\bpip[0-9]*\s+install|\bpip[0-9]*\s+wheel' "$1" 2>/dev/null
}
pregrant_python_sitedirs() {
local user="$1" script="$2" r="${SNAP_ROOT%/}" d
uses_pip "$script" || return 0
# Remove a stale per-user install.
#
# When the system site-packages is not writable, pip installs into the
# package user's ~/.local instead -- and then reports
# Requirement already satisfied: flit_core in
# /usr/src/flit-core/.local/lib/python3.13/site-packages
# on every later run, so it never installs system-wide. Only that one user
# can import it; every other package fails with
# BackendUnavailable: Cannot import 'flit_core.buildapi'
local ulocal="$(pkgusr_home_for "$user")/.local"
if [ -d "$ulocal/lib" ]; then
note "# removing a stale per-user install in $ulocal"
detail "# (pip put it there because site-packages was not writable;"
detail "# nothing outside '$user' can import from it)"
rm -rf "$ulocal/lib"
fi
for d in "$r"/usr/lib/python*/site-packages "$r"/usr/lib/python*; do
[ -d "$d" ] || continue
su -s /bin/bash "$user" -c "test -w '$d'" 2>/dev/null && continue
detail "# pip package: making sure '$user' can write $d"
grant_dir_access "$d" "$user"
break
done
return 0
}
# A build that was interrupted part-way leaves the package half-installed.
_note_interrupted() {
warn ""
warn "!! interrupted while building '$1'."
warn " The package may be HALF-INSTALLED: some files on disk, some not."
warn " Finish or undo it before building anything else --"
warn " a half-installed toolchain package breaks every build after it:"
warn " lfs-helper build $1 --phase install --force # finish it"
warn " lfs-helper check-toolchain # is cc/c++ ok?"
}
# Packages recorded as mid-install and never finished.
_interrupted_packages() {
[ -f "$STATE_PROGRESS/interrupted" ] || return 1
sort -u "$STATE_PROGRESS/interrupted" 2>/dev/null | grep -v '^$'
}
_clear_interrupted() {
local name="$1" f tmp
f="$STATE_PROGRESS/interrupted"
[ -f "$f" ] || return 0
tmp="$f.$$"
grep -vx "$name" "$f" > "$tmp" 2>/dev/null
mv -f "$tmp" "$f" 2>/dev/null || rm -f "$tmp"
[ -s "$f" ] || rm -f "$f"
}
# Keep out of a step's manifest anything that belongs to a DIFFERENT package.
#
# A step can install packages under other users: last-step builds wget as the
# package user 'wget'. Everything written during the step was recorded as
# last-step's, so /usr/bin/wget landed in last-step's manifest -- and the next
# thing to act on that manifest (adoption, fix-ownership) took the file from
# wget and gave it to last-step. wget could then not reinstall itself.
#
# A manifest should list what this step owns, not everything that happened
# while it ran.
# Files that belong to NO package, however many packages write to them.
#
# These are shared indexes and generated caches: every package that installs an
# info page rewrites /usr/share/info/dir, and ldconfig rewrites /etc/ld.so.cache
# and /var/cache/ldconfig/aux-cache after every install. They are not "owned"
# by whoever touched them last, so recording them in a manifest makes `verify`
# report a conflict that no repair can settle -- the next package will just
# take them again:
# /usr/share/info/dir is p_gcc want p_binutils
# /usr/share/info/dir is p_gcc want p_glibc
# /etc/ld.so.cache is p_libcap want p_glibc
# /var/cache/ldconfig/aux-cache is p_libcap want p_zlib
# Root keeps them, and no manifest claims them.
never_claim_list() {
cat <<'NCLIST'
/usr/share/info/dir
/etc/ld.so.cache
/etc/ld.so.conf
/var/cache/ldconfig/aux-cache
/etc/mtab
/usr/lib/pkgusr
NCLIST
# The user database, and everything Shadow writes beside it.
#
# These are root's, permanently and by definition -- the whole scheme is
# stored in them. They are not "installed" by anything, but every
# cmd_add_user during a build touches them, so whichever package happened to
# be building at the time swept them into its manifest:
# /etc/.pwd.lock is root want p_ncurses
# /etc/group- is root want p_ncurses
# /etc/passwd- is root want p_psmisc
# ncurses did not install /etc/group-; useradd did, while ncurses was
# building. A package owning the file that says which packages exist is
# also the one ownership mistake with no way back.
printf '%s\n' \
"$ETC/passwd" "$ETC/passwd-" "$ETC/shadow" "$ETC/shadow-" \
"$ETC/group" "$ETC/group-" "$ETC/gshadow" "$ETC/gshadow-" \
"$ETC/.pwd.lock" "$ETC/subuid" "$ETC/subgid" \
"$ETC/subuid-" "$ETC/subgid-"
# Mail spools. Shadow creates /var/mail/<name> for every account it makes
# (CREATE_MAIL_SPOOL), so they arrive as a side effect of `useradd` and no
# package ever installs one. Worse, two passes disagreed about this one:
#
# lfs-helper verify --fix builduser /var/mail/lfs lfs -> root
# lfs-helper verify 1 root-owned file(s) that NO manifest claims:
# /var/mail/lfs
#
# The first gave it to root because the build user held it; the second then
# complained that root holds a file no manifest claims. Repairing it made
# the next run report it. A spool belongs to the account it is named for --
# neither pass should touch it.
printf '%s\n' "${SNAP_ROOT%/}/var/mail"
# root's own home. Files appear there as side effects of root BEING IN A
# SHELL while a package builds -- .bash_history, .lesshst, .wget-hsts --
# and the timestamp scan swept them into whichever manifest was open:
# [62/221] sqlite /root/.bash_history root -> p_sqlite
# --fix would have handed root's shell history to p_sqlite. Nothing in
# /root is installed by a package, and anything a cfg step writes there
# must stay root's anyway. (Both snapshot scans now skip /root and /home
# too; this line is what protects the manifests already polluted.)
printf '%s\n' "${SNAP_ROOT%/}/root"
# Generated trust stores and our own configuration.
#
# A real verify found 711 wrong-owner claims of this species: p_libogg
# claiming every /etc/ssl/certs file, p_make-ca claiming /etc/pki
# anchors, p_docutils claiming /etc/pkgusr/stacks and
# /usr/share/lfs-pkgusr. None of them installed those files -- make-ca's
# detached background regeneration and `make install` of these tools ran
# DURING someone else's build, and the timestamp scan sweeps whatever is
# newer than the stamp. --fix would have chowned the system's certificate
# store to a package that has nothing to do with it.
#
# These are all regenerated-on-demand or ours: same class as
# /usr/share/info/dir and the state directory.
printf '%s\n' "${SNAP_ROOT%/}/etc/ssl/certs" "${SNAP_ROOT%/}/etc/pki" \
"${SNAP_ROOT%/}/etc/pkgusr" "${SNAP_ROOT%/}/usr/share/lfs-pkgusr" \
"${SNAP_ROOT%/}/tools"
# The tools' own stores -- same reasoning as $STATE below: books the user
# downloaded, parse caches, tool stamps. Generated at runtime, regrown on
# demand, nobody's install:
# unclaimed /usr/share/blfs/books/BLFS-BOOK-13.0-systemd-nochunks.html
# unclaimed /usr/share/lfs/tool-stamps.json
printf '%s\n' "${SNAP_ROOT%/}/usr/share/lfs" "${SNAP_ROOT%/}/usr/share/blfs"
# The state directory itself.
#
# It moved under /usr/src in 1.9.0, and /usr/src IS an install directory --
# so `verify` began walking its own manifests, scripts and logs and
# reporting every one of them as a root-owned file no package claims. A
# clean 104/104 build came back with
# 448 root-owned file(s) that NO manifest claims
# and all 448 were this directory, including the temp files the scan had
# just written for itself.
#
# Nothing in here is a package's file. It is what the build knows ABOUT
# the packages.
printf '%s\n' "$STATE"
}
# The list, read ONCE into an array.
#
# is_never_claimed used to run `< <(never_claim_list)` on every call, which is a
# process substitution -- a fork -- per path. `verify` calls it for every path
# in every manifest: 66265 forks on a finished system, and that was only half of
# them. The command ran for minutes in silence.
_TAB=$'\t'
_NEVER_CLAIM=()
_never_claim_load() {
[ "${#_NEVER_CLAIM[@]}" -gt 0 ] && return 0
local p
while IFS= read -r p; do
[ -n "$p" ] || continue
p="${SNAP_ROOT%/}${p}"
_NEVER_CLAIM+=( "${p%/}" )
done < <(never_claim_list)
}
is_never_claimed() {
local f="${1%/}" p
[ -n "$f" ] || return 1
_never_claim_load
for p in "${_NEVER_CLAIM[@]}"; do
[ "$f" = "$p" ] && return 0
case "$f" in "$p"/*) return 0 ;; esac
done
return 1
}
_drop_other_packages_files() {
local owner="$1" p cur _sp _tgt _to
while IFS= read -r p; do
[ -n "$p" ] || continue
# a shared index or a generated cache is nobody's file
is_never_claimed "$p" && continue
cur="$(stat -c %U "$p" 2>/dev/null)"
if [ -n "$cur" ] && [ "$cur" != "$owner" ] && [ "$cur" != root ] \
&& [ "$cur" != UNKNOWN ] && user_exists "$cur"; then
continue # another package installed this
fi
# CURRENT OWNERSHIP IS NOT PROOF OF PROVENANCE (1.14.310).
#
# The test above asks who owns the file NOW, and that is circular:
# once the lease had handed /usr/bin/su and six /usr/sbin/iptables*
# links to p_webkitgtk, they read as "the owner's own" and stayed in
# its manifest -- which is the list the NEXT lease reads. The
# manifest fed the lease and the lease fed the manifest, and the
# damage repeated on every rebuild. Two facts break the loop, and
# neither depends on who holds the file today:
#
# * setid.conf names the package that installs a setid file.
# * a symlink is installed by whoever installed its target.
if _sp="$(_setid_pkg_for "$p" 2>/dev/null)" \
&& [ -n "$_sp" ] && [ "$_sp" != "$(unprefix_pkg_user "$owner")" ]; then
continue
fi
if [ -L "$p" ]; then
_tgt="$(readlink -f "$p" 2>/dev/null)"
if [ -n "$_tgt" ] && [ "$_tgt" != "$p" ]; then
_to="$(stat -c %U "$_tgt" 2>/dev/null)"
if [ -n "$_to" ] && [ "$_to" != "$owner" ] && [ "$_to" != root ] \
&& [ "$_to" != UNKNOWN ] && user_exists "$_to"; then
continue
fi
fi
fi
printf '%s\n' "$p"
done
}
# --------------------------------------------------------------------------- #
# Staged installs
# --------------------------------------------------------------------------- #
# `make install` writes straight into the live system, one file at a time, in
# whatever order the Makefile happens to use. Interrupt it and the system is
# neither the old version nor the new one -- and for a toolchain package that
# is unrecoverable: chapter 8's gcc installs its driver early and its C++
# headers late, so a cancel in between leaves a compiler that cannot compile
# C++, which is exactly what is needed to rebuild it.
#
# So install into a staging directory first and move the result into place:
#
# make install DESTDIR=<stage> the long part; a cancel here costs time
# check the staging tree did anything actually land?
# move it into place short, and moves whole files
#
# DESTDIR is honoured by every autotools, meson and cmake install, which is
# what the books use. A script that ignores it writes to the live system
# anyway -- detected below, and reported rather than silently trusted.
_stage_dir_for() { echo "$STATE/stage/$1"; }
# Packages where an interrupted install cannot be recovered from.
#
# Staging costs roughly an extra copy of everything installed, so it is not
# worth paying for every package: if man-pages is interrupted you rebuild
# man-pages. If GCC or Glibc is interrupted you have no compiler and no libc,
# and nothing left to rebuild them WITH -- the system is finished.
#
# These are the packages the rest of the build depends on to build at all.
# Override with `lfs config stage_packages` (a space-separated list), or per
# build with --stage / --no-stage.
STAGE_PACKAGES_DEFAULT="gcc glibc binutils bash coreutils"
_should_stage() {
local name="$1" list item base
list="${LFS_STAGE_PACKAGES:-$STAGE_PACKAGES_DEFAULT}"
case "$list" in
all) return 0 ;;
none) return 1 ;;
esac
# -tmp variants are the same package at an earlier stage
base="${name%-tmp}"
base="${base%-pass1}"
base="${base%-pass2}"
for item in $list; do
[ "$item" = "$name" ] && return 0
[ "$item" = "$base" ] && return 0
done
return 1
}
# Does this phase look like something DESTDIR can stage?
_phase_can_stage() {
local script="$1" phase="$2" name="$3"
# ONLY a lone install phase.
#
# `--phase all` runs build, install AND the book's post-install commands in
# one script, and those operate on the LIVE system:
# mv -v /usr/bin/chroot /usr/sbin
# With DESTDIR set, chroot is still in the staging tree at that point and
# the move fails. Staging cannot span a phase boundary that only exists
# inside the script, so it is limited to the phase where it is safe.
case "$phase" in install) ;; *) return 1 ;; esac
_should_stage "$name" || return 1
grep -qE '^[[:space:]]*(make|ninja|meson)[^|;&]*install' "$script" 2>/dev/null
}
# Move a staging tree into the live system.
#
# cp -a then remove: a rename would fail across filesystems, and merging into
# directories that already exist is the normal case.
# Fold staged directories onto the symlinks the live tree uses.
#
# Where the live tree has a symlink (bin -> usr/bin) and the staging tree has
# a real directory of the same name, move the contents to the symlink's target
# and drop the directory. Without this every package installing into /bin,
# /sbin, /lib or /lib64 fails to merge -- which is most of chapter 7.
#
# Driven by the live tree rather than a hardcoded list: whatever is a symlink
# there is what gets folded.
_resolve_staged_symlink_dirs() {
local stage="$1" d name live target rel dest
for d in "$stage"/*; do
[ -d "$d" ] || continue
[ -L "$d" ] && continue # already a symlink: leave it
name="$(basename "$d")"
live="${SNAP_ROOT%/}/$name"
[ -L "$live" ] || continue # not a symlink here: normal case
target="$(readlink -f "$live" 2>/dev/null)" || continue
[ -d "$target" ] || continue
rel="${target#${SNAP_ROOT%/}/}"
dest="$stage/$rel"
mkdir -p "$dest" || return 1
say "# folding $name/ into $rel/ (the tree has $name as a symlink)"
cp -a "$d"/. "$dest"/ || return 1
rm -rf "$d" || return 1
done
return 0
}
# Put a staging tree into the live system, one file at a time, atomically.
#
# `cp -a` overwrites in place: it opens the destination with O_TRUNC and
# writes. For an ordinary file that is fine. For a shared library that is
# CURRENTLY MAPPED it is fatal -- every running process sees the file shrink
# under it. Copying glibc's libc.so.6 that way killed the shell mid-merge:
#
# /usr/bin/env: error while loading shared libraries:
# /usr/lib/libc.so.6: file too short
#
# and the chroot could no longer start any program at all.
#
# So write each file beside its destination and rename() over it. A rename is
# atomic: processes holding the old file keep the old inode until they exit,
# and nothing ever observes a partial file. This is what package managers do,
# and it is the only safe way to replace a library a running system is using.
_install_staged_tree() {
local stage="$1" root="${SNAP_ROOT:-/}" rel dest tmp rc=0 d f
# directories first, so every destination exists before anything is moved
while IFS= read -r d; do
rel="${d#$stage}"
dest="${root%/}$rel"
if [ -d "$dest" ]; then
# ALREADY THERE -- leave its ownership and mode alone.
#
# The staging tree is created by the package user, so $stage/usr,
# $stage/usr/bin and so on are owned by that user with mode 755.
# Stamping that onto the live tree handed /usr and /usr/bin to
# whichever package was building -- losing the install group and
# g+w, i.e. the entire install-directory scheme, undone by one
# package. Only stamp what this call actually creates.
continue
fi
mkdir -p "$dest" || { rc=1; break; }
# A staged directory that does not inherit the live one's owner and
# mode is how a package quietly takes /usr/bin.
soft "$dest did not inherit the owner of $d" \
-- real_chown --reference="$d" "$dest"
soft "$dest did not inherit the mode of $d" \
-- real_chmod --reference="$d" "$dest"
done < <(find "$stage" -mindepth 1 -type d 2>/dev/null | sort)
[ "$rc" = 0 ] || return 1
# then files and symlinks, each renamed into place
while IFS= read -r f; do
rel="${f#$stage}"
dest="${root%/}$rel"
tmp="$dest.pkgusr-new.$$"
rm -f "$tmp"
if ! cp -a "$f" "$tmp" 2>/dev/null; then
warn "# could not stage $rel"
rc=1
continue
fi
# same directory as the destination, so this is a rename, not a copy
if ! mv -f "$tmp" "$dest" 2>/dev/null; then
warn "# could not replace $rel"
rm -f "$tmp"
rc=1
fi
done < <(find "$stage" -mindepth 1 \( -type f -o -type l \) 2>/dev/null)
return $rc
}
_merge_stage() {
local stage="$1" n=0
[ -d "$stage" ] || return 1
# nothing staged means DESTDIR was ignored -- say so rather than reporting
# a successful install of nothing
n="$(find "$stage" -mindepth 1 \( -type f -o -type l \) 2>/dev/null | wc -l)"
if [ "${n:-0}" = 0 ]; then
return 2
fi
# The live tree has /bin -> usr/bin, /sbin -> usr/sbin and /lib -> usr/lib
# (book 4.2). A package that installs into /bin gets a REAL bin/ directory
# in the staging tree, and copying that over the symlink fails:
# cp: cannot overwrite non-directory '/bin' with directory '.../bin'
# So resolve those first: move the contents to where the symlink points.
_resolve_staged_symlink_dirs "$stage" || return 1
say "# moving $n staged file(s) into place ..."
# into the tracked root, not a hardcoded "/" -- inside the chroot they are
# the same thing, but hardcoding it makes this untestable and would write
# to the host if anything ever ran it from outside
_install_staged_tree "$stage" || return 1
rm -rf "$stage"
return 0
}
# Hand this package its own files from earlier stages of the build.
#
# A package is built more than once: ncurses in chapter 6 (as the lfs user),
# perl-tmp in chapter 7 (as root), then the real one in chapter 8 as its
# package user. The earlier files are still owned by whoever built them, and
# the package user cannot overwrite or re-mode them:
#
# cp: cannot create regular file '/usr/bin/tic': Permission denied
# Couldn't chmod 644 /usr/lib/perl5/.../perl.pod: Operation not permitted
#
# Same cause, different chapter. So before building X as user X, give X every
# path recorded under X, X-tmp, X-pass1 and X-pass2 -- but only paths still
# owned by root or the build user, never one belonging to another package.
# One stat and one chown for the WHOLE manifest, not per path.
#
# The loop this replaces forked three or four times per line
# (strip_host_prefix in a command substitution, stat, chown), which is
# invisible work: nothing is printed between "# user : p_gcc" and
# "# building gcc ...", so a package with big earlier stages looked HUNG for
# ten minutes with a bash process quietly burning a core. GCC is the worst
# case -- gcc-pass1, gcc-pass2 and libstdcpp all claim into the same user.
#
# Prints the paths that are eligible: they exist, and they still belong to
# whoever the build left them to (root, lfs, or a uid with no name) rather
# than to another package.
_claim_candidates() { # <owner> <manifest> ; paths on stdout
local owner="$1" man="$2" m="${LFS_HOST_MOUNT:-}" r="${SNAP_ROOT%/}"
local tmp; tmp="$(mktemp)" || return 0
local p
while IFS= read -r p; do
[ -n "$p" ] || continue
# strip_host_prefix, inline: a command substitution per path is a fork
[ -n "$m" ] && [ "${p#$m/}" != "$p" ] && p="/${p#$m/}"
printf '%s\n' "$r$p"
done < "$man" > "$tmp"
# stat in batches: a path that does not exist simply produces no line
# A SETUID FILE IS NEVER CLAIMED.
#
# ~ > su -
# su: Authentication service cannot retrieve authentication info
#
# This ran before EVERY phase, configure included, and shadow's manifest
# lists /usr/bin/su: "gave 'p_shadow' 16 path(s)" was su, passwd, chage
# and the rest going 4711 root -> 755 p_shadow, because chown clears the
# bit. Nothing put them back -- the lease only sees a file someone
# ELSE owns, and after this p_shadow owned them. Root's setid files are
# the lease's to hand over: it copies them aside, restores them on
# failure and applies setid.conf on success.
xargs -d '\n' -r -a "$tmp" stat -c '%U %A %n' -- 2>/dev/null | awk -v o="$owner" '
{
u = $1; a = $2
i = index($0, " "); rest = substr($0, i + 1)
j = index(rest, " "); path = substr(rest, j + 1)
if (u == o) next
if (u != "root" && u != "lfs" && u != "UNKNOWN") next
if (substr(a, 4, 1) ~ /[sS]/ || substr(a, 7, 1) ~ /[sS]/) next
print path
}'
rm -f "$tmp"
}
_claim_earlier_stages() {
local name="$1" owner="$2" man base n=0 p
[ -n "$owner" ] || return 0
user_exists "$owner" || return 0
# Book titles are capitalised ("Python", "Util-linux") but the earlier
# stage is named from the tarball ("python-tmp"). Matching only the
# step's own spelling meant Python never claimed python-tmp's files, and
# chapter 7's root-owned tree blocked the chapter-8 install:
# install: cannot remove '/usr/lib/python3.13/idlelib/Icons/README.txt'
# Try the step name, the owner name, and both lowercased.
local lname owname
lname="$(printf '%s' "$name" | tr 'A-Z' 'a-z')"
owname="$(printf '%s' "$owner" | tr 'A-Z' 'a-z')"
local -a bases=()
local stem
for stem in "$name" "$lname" "$owner" "$owname"; do
[ -n "$stem" ] || continue
bases+=("$stem" "$stem-tmp" "$stem-pass1" "$stem-pass2")
done
# GCC's first stage is not called gcc: chapter 5 builds Libstdc++ from
# GCC, and pkg_owner_name maps libstdcpp to this same user. Without it
# those files stayed root-owned and chapter 8 could not replace them.
case "$owname" in
*gcc) bases+=("libstdcpp" "libstdc++") ;;
esac
# de-duplicate, so a manifest is not walked twice
local seen="" b
for b in "${bases[@]}"; do
case " $seen " in *" $b "*) continue ;; esac
seen="$seen $b"
done
# SAY IT STARTED, not only that it finished. This runs between
# "# user : p_gcc" and "# building gcc ...", and for a package with big
# earlier stages it is the only thing happening for a while.
detail "# claiming files from earlier stages of $owner ..."
local chownbin; chownbin="$(_real_tool chown)"
local batch; batch="$(mktemp)" || return 0
# AN EARLIER STAGE IS EARLIER THAN THIS PACKAGE'S OWN INSTALLS.
#
# Chapter 7 builds gettext, perl, Python ... as root under the same names
# chapter 8 uses, so the step's OWN manifest holds root-owned leftovers
# the first package-user build must take over. After that first
# successful install, a root-owned entry in its own manifest is not a
# leftover -- it is something recorded by mistake: LLVM's listed
# /usr/bin/lfs, /usr/bin/packagemanager and /etc/pam.d/su_u_user (root
# wrote them during its hours-long install window), and this step
# "gave 'p_llvm' 13 path(s)", making a script root runs on every su
# writable by p_llvm. The -tmp/-pass/libstdcpp stages are always earlier.
local _home_done=""
_home_done="$(pkgusr_home_for "$(unprefix_pkg_user "$owner")" 2>/dev/null)"
local _installed_before=0
[ -n "$_home_done" ] && { [ -s "$_home_done/.pkgusr-installed" ] || [ -s "$_home_done/VERSION" ]; } \
&& _installed_before=1
for base in $seen; do
man="$MANIFESTS/$base.files"
if [ "$_installed_before" = 1 ]; then
case "$base" in
*-tmp|*-pass1|*-pass2|libstdcpp|libstdc++) ;;
*) continue ;;
esac
fi
if [ -s "$man" ]; then
_claim_candidates "$owner" "$man" > "$batch"
if [ -s "$batch" ]; then
n=$((n + $(count_stdin_lines < "$batch")))
xargs -d '\n' -r -a "$batch" "$chownbin" -h "$owner:$owner" -- \
2>/dev/null || true
fi
fi
# the directories too, or the package cannot create files in them
man="$MANIFESTS/$base.dirs"
[ -s "$man" ] || continue
: > "$batch"
while IFS= read -r p; do
[ -d "$p" ] || continue
is_install_dir "$p" && continue # shared, leave it alone
printf '%s\n' "$p" >> "$batch"
done < <(_claim_candidates "$owner" "$man")
if [ -s "$batch" ]; then
n=$((n + $(count_stdin_lines < "$batch")))
xargs -d '\n' -r -a "$batch" "$chownbin" -h "$owner:$owner" -- \
2>/dev/null || true
fi
done
rm -f "$batch"
[ "$n" -gt 0 ] && say "# gave '$owner' $n path(s) from its earlier build stages"
return 0
}
# Book 7.6 rewrites /etc/passwd and /etc/group with a TRUNCATING `>`.
#
# That is correct the first time -- it creates the base accounts in a tree that
# has none. Run it again once package users exist and it wipes every package
# user, every private group, the install group, every collector group and every
# membership. The files on disk then all read as UNKNOWN, and the collector
# map no longer matches reality.
#
# So save the entries this build created, let the step do exactly what the book
# says, and put them back.
_PKGUSR_UID_MIN=10000
_protect_user_db() {
local f keep
for f in passwd group; do
[ -f "$ETC/$f" ] || continue
keep="$STATE/tmp/$f.keep.$$"
mkdir -p "$STATE/tmp" 2>/dev/null || true
# package users, collector groups, and the install group with members
awk -F: -v umin="$_PKGUSR_UID_MIN" -v gmin="$COLLECTOR_GID_MIN" \
'$3 >= umin || $3 >= gmin || $1 == "install" || $1 == "lfs"' \
"$ETC/$f" > "$keep" 2>/dev/null || true
done
}
_restore_user_db() {
local f keep n line name
for f in passwd group; do
keep="$STATE/tmp/$f.keep.$$"
[ -s "$keep" ] || { rm -f "$keep"; continue; }
n=0
while IFS= read -r line; do
name="${line%%:*}"
[ -n "$name" ] || continue
grep -q "^$name:" "$ETC/$f" 2>/dev/null && continue
# never restore an entry whose id has since been handed to someone
# else -- that is exactly how duplicate uids appear
local rid; rid="$(printf '%s' "$line" | cut -d: -f3)"
if [ -n "$rid" ] && awk -F: -v i="$rid" '$3 == i { found = 1 }
END { exit !found }' "$ETC/$f" 2>/dev/null; then
warn "# not restoring '$name': id $rid is now used by "\
"$(awk -F: -v i="$rid" '$3 == i { print $1; exit }' "$ETC/$f")"
continue
fi
printf '%s\n' "$line" >> "$ETC/$f"
n=$((n+1))
done < "$keep"
[ "$n" -gt 0 ] && \
say "# put back $n $f entry(ies) the book's 7.6 block would have wiped"
rm -f "$keep"
done
_vfy_user_order 1 >/dev/null 2>&1 || true
}
# --------------------------------------------------------------------------- #
# The runner -- ONE way to execute a phase as the package user (item 6, 3b)
# --------------------------------------------------------------------------- #
#
# Both drivers ran the install script, and spelled it differently:
# `cmd_build` used `su - "$owner" -c "env ... bash SCRIPT PHASE" | tee`, while
# packagemanager_install used `su - -c "set -o pipefail; PATH=...; bash
# ~/install_<user> ... | tee" <user>`. Same act, two spellings, and the
# differences were exactly where the bugs lived: which PATH the build sees
# (no wrappers => "install: cannot change permissions of '/usr/sbin'"), and
# whether the exit code came from the script or from tee (a failing build
# reporting success cost a day).
#
# Three privilege cases, and they are not interchangeable:
# * as root -- chapter-7 temp tools, and root config steps. NO
# wrappers: root legitimately chowns, and the wrapper
# would silently skip it.
# * as the owner -- the real package-user way, wrappers first in PATH.
# * root, then hand over -- `su` comes from Shadow (chapter 8), so for the
# first packages there is no way to drop privileges; the
# caller chowns what appeared afterwards.
#
# Returns the SCRIPT's status, never tee's.
# Variables the BOOK expects to be exported, kept in one file.
#
# Several pages need one (TEXLIVE_PREFIX, XORG_CONFIG, ...) and the build
# runs as a package user through `su -`, which drops the caller's
# environment -- so exporting them in your shell does not reach the build.
# `packagemanager env` writes them here; every phase run passes them through.
BUILD_ENV_FILE="${LFS_BUILD_ENV:-$ETC/pkgusr/build.env}"
# A VALUE WITH SPACES IS ONE VALUE.
#
# These pairs are pasted into `env $envpass bash ...`, which the shell then
# splits on whitespace -- so XORG_CONFIG's flag list became several words and
# the second one was run as a command:
# env: '--disable-static': No such file or directory
# Each pair is single-quoted, with the one escape single quoting needs.
build_env_pairs() {
[ -r "$BUILD_ENV_FILE" ] || return 0
local line name val
while IFS= read -r line; do
case "$line" in ''|\#*) continue ;; esac
case "$line" in *=*) ;; *) continue ;; esac
name="${line%%=*}"
val="${line#*=}"
printf "%s='%s' " "$name" "$(printf '%s' "$val" | sed "s/'/'\\\\''/g")"
done < "$BUILD_ENV_FILE"
}
LAST_FAILED_PHASE="" # set by run_phase_as: which phase a failed
# `all` run stopped in (read by cmd_build's retry)
create_service_accounts_from_script() {
# A book page that needs a system account says so in its own commands:
# groupadd -g 22 fcron &&
# useradd -d /dev/null -c "Fcron User" -g fcron -s /bin/false -u 22 fcron
# Those run in the install phase, as the PACKAGE user, which cannot write
# /etc/group -- so fcron failed after a complete build with
# groupadd: cannot lock /etc/group; try again later
# Root is running this function; do it here, once, before the phase, and
# let the wrappers turn the package user's own attempt into a no-op.
local script="${1:-}"
[ -n "$script" ] && [ -f "$script" ] || return 0
[ "$(id -u)" = 0 ] || return 0
local line name
while IFS= read -r line; do
case "$line" in \#*) continue ;; esac
# the NAME is the last argument -- stripping "-x value" pairs by hand
# tripped over the quoted comment in
# useradd -d /dev/null -c "Fcron User" -g fcron ... fcron
# and produced 'User" fcron'
name="$(printf '%s' "${line%%&&*}" | awk '{print $NF}')"
[ -n "$name" ] || continue
case "$line" in
*groupadd*)
getent group "$name" >/dev/null 2>&1 && continue
say "# creating system group '$name' (the page asks for it; a package user cannot)"
eval "${line%%&&*}" >/dev/null 2>&1 || warn "could not create group '$name' -- the build will say so" ;;
*useradd*)
getent passwd "$name" >/dev/null 2>&1 && continue
say "# creating system user '$name' (the page asks for it; a package user cannot)"
eval "${line%%&&*}" >/dev/null 2>&1 || warn "could not create user '$name' -- the build will say so" ;;
esac
done <<EOF
$(_join_continued_lines "$script" | grep -E "^[[:space:]]*(group|user)add[[:space:]]")
EOF
return 0
}
# ONE COMMAND, ONE LINE. rsync's page writes
# useradd -c "rsyncd Daemon" -m -d /home/rsync -g rsyncd \
# -s /bin/false -u 48 rsyncd
# and the line-by-line read took the name from the first physical line --
# a backslash -- so the group was made and the user never was:
# *** useradd: 'rsyncd' does not exist and a package user cannot create it
_join_continued_lines() { # <file>
awk '{ if (sub(/\\[[:space:]]*$/, "")) { buf = buf $0 " "; next }
print buf $0; buf = "" }
END { if (buf != "") print buf }' "$1" 2>/dev/null
}
# ---------------------------------------------------------------------------
# THE LEASE: files a phase will overwrite that someone else owns. (1.14.263)
#
# The old way was fail, read the log, repair, retry. Every tool words the
# refusal differently -- cmake, meson, python, a bash redirect -- and each
# needed its own parser. Shadow's configure died on
# line 153: /etc/pam.d/su: Permission denied
# which no parser recognised, so the round granted the DIRECTORY, which was
# never the problem, and stopped. And the setid pass and the 1.14.261
# take-back were two halves of one cycle: root gets the file for its bit,
# the next install cannot replace it, a repair hands it back.
#
# So, around every install/configure phase, as root:
# before copy each file the phase will write that another account owns,
# with its owner and mode, and hand it to the installer. COPY,
# never move: the phase itself starts with `su - p_<pkg>`, and
# su, its pam file and unix_chkpwd must exist at that moment.
# failure put every copy back, owner and mode included, and read it back.
# success a setid.conf file gets its policy; a file the phase did not
# change goes back to its owner; a file it rewrote belongs to the
# installer (the rule everywhere else).
#
# WHICH FILES, before anything runs:
# * install/all/update: the package's own manifest -- ROOT-owned entries
# only. Those are files it installed that root took for a setid bit.
# * configure/all/update: the paths configure_pkg writes, read from the
# script (script_write_targets).
# A root-owned file is leased ONLY when this package's manifest claims it.
# The script is editable by the package user; without that rule, one edited
# line could hand it /etc/shadow. Files of non-package accounts (the main
# user, u_*) are never leased. The first install of a file another package
# owns cannot be known in advance, so the repair loop stays as the fallback.
# ---------------------------------------------------------------------------
LEASES="$STATE/leases"
# script_write_targets <script> [function ...] -> absolute paths, one a line
#
# What a shell function WRITES, read from its text: redirect targets
# (> >> and tee), the destination of install/cp/mv/ln, the files of sed -i.
# A `for VAR in a b c` loop expands ${VAR}/$VAR; any other expansion, a
# glob or a brace is dropped rather than guessed. Comments and heredoc
# BODIES are skipped -- a heredoc body is data, and "prose is not code".
script_write_targets() {
local script="$1"; shift
[ -r "$script" ] || return 1
[ $# -gt 0 ] || set -- configure_pkg
local fn
for fn in "$@"; do
sed -n "/^${fn}() {/,/^}/p" "$script"
done | awk '
function emit(p, n, i, x, v, out) {
gsub(/^["\047]|["\047]$/, "", p)
if (p !~ /^\//) return
if (p ~ /[*?[{}]/) {
# ${VAR} is the one brace we understand
if (p !~ /\$\{[A-Za-z_][A-Za-z_0-9]*\}/) return
tmp = p; gsub(/\$\{[A-Za-z_][A-Za-z_0-9]*\}/, "", tmp)
if (tmp ~ /[*?[{}]/) return
}
if (p ~ /\$/) {
for (v in lists) {
if (index(p, "${" v "}") || p ~ ("\\$" v "([^A-Za-z_0-9]|$)")) {
n = split(lists[v], x, " ")
for (i = 1; i <= n; i++) {
out = p
gsub("\\$\\{" v "\\}", x[i], out)
gsub("\\$" v, x[i], out)
if (out !~ /\$/) emit(out)
}
return
}
}
return
}
if (p ~ /^\/(dev|proc|sys|tmp|run)(\/|$)/) return
print p
}
# skip a heredoc body until its delimiter
hd != "" { d = $0; if (strip) sub(/^\t+/, "", d); if (d == hd) hd = ""; next }
/^[[:space:]]*#/ { next }
{
buf = buf $0
if (sub(/\\$/, "", buf)) next
line = buf; buf = ""
# the opener line still counts; its BODY does not
if (match(line, /<<-?[[:space:]]*["\047]?[A-Za-z_][A-Za-z_0-9]*["\047]?/)) {
h = substr(line, RSTART, RLENGTH)
strip = (h ~ /^<<-/)
gsub(/^<<-?[[:space:]]*|["\047]/, "", h)
hd = h
line = substr(line, 1, RSTART - 1) " " substr(line, RSTART + RLENGTH)
}
# redirect targets (not 2> / &> / <>, and not >&2)
l = line
while (match(l, /(^|[^0-9&<>])>>?[[:space:]]*[^[:space:];|&<>()]+/)) {
t = substr(l, RSTART, RLENGTH); l = substr(l, RSTART + RLENGTH)
sub(/^[^>]*>>?[[:space:]]*/, "", t)
if (t !~ /^&/) emit(t)
}
nseg = split(line, seg, /&&|\|\||;|\|/)
for (s = 1; s <= nseg; s++) {
# drop empty fields: a segment ending in a space splits into
# a trailing "" that would become the "last word"
nn = split(seg[s], ww, /[[:space:]]+/); n = 0
for (j = 1; j <= nn; j++) if (ww[j] != "") w[++n] = ww[j]
k = 1; while (k <= n && (w[k] ~ /^(do|then|else|elif|!|sudo|\{|\()$/)) k++
if (k > n) continue
cmd = w[k]
if (cmd == "for" && k + 2 <= n && w[k+2] == "in") {
v = w[k+1]; lists[v] = ""
for (j = k + 3; j <= n; j++) { x1 = w[j]; sub(/;$/, "", x1); if (x1 != "" && x1 != "do") lists[v] = lists[v] " " x1 }
continue
}
if (cmd ~ /^(install|cp|mv|ln)$/) {
last = ""; isdir = 0; tdir = ""
for (j = k + 1; j <= n; j++) {
if (w[j] ~ /^>/) break
if (w[j] == "-t" && j < n) { tdir = w[j+1]; j++; continue }
if (cmd == "install" && w[j] ~ /^-[A-Za-z]*d/) isdir = 1
if (w[j] ~ /^-/) {
# install -m 644 / -o x / -g y take a value
if (cmd == "install" && w[j] ~ /^-[mog]$/) j++
continue
}
last = w[j]
}
if (!isdir && tdir == "" && last != "") emit(last)
continue
}
if (cmd == "tee") {
for (j = k + 1; j <= n; j++) if (w[j] !~ /^-/) emit(w[j])
continue
}
if (cmd == "sed") {
inplace = 0; havescript = 0
for (j = k + 1; j <= n; j++) {
if (w[j] ~ /^-i/ || w[j] == "--in-place") { inplace = 1; continue }
if (w[j] == "-e" || w[j] == "-f") { havescript = 1; j++; continue }
if (w[j] ~ /^-/) continue
if (!havescript) { havescript = 1; continue }
if (inplace) emit(w[j])
}
}
}
}' | awk '!seen[$0]++'
}
cmd_script_targets() {
local script="${1:-}"
case "$script" in
""|-h|--help)
say "lfs-helper script-targets <install script> [function ...]"
say ""
say "Lists the absolute paths the named functions write (default"
say "configure_pkg): redirects, tee, install/cp/mv/ln destinations,"
say "sed -i. for-loops are expanded; other expansions are dropped."
say "This is the list the lease reads before a phase runs."
[ -n "$script" ]; return ;;
esac
shift
[ -r "$script" ] || die "script-targets: cannot read $script"
script_write_targets "$script" "$@"
}
# the single policy table: setid.conf, wherever this install keeps it
_setid_conf() {
local c
for c in "${SETID_CONF:-}" "${PKGUSR_ETC:-/etc/pkgusr}/stacks/setid.conf" \
"$(dirname "$(readlink -f "$0" 2>/dev/null || echo "$0")")/stacks/setid.conf"; do
[ -n "$c" ] && [ -r "$c" ] && { printf '%s\n' "$c"; return 0; }
done
return 1
}
# _setid_policy_for <path> -> "mode|owner|group" from setid.conf, or nothing
_setid_policy_for() {
local conf; conf="$(_setid_conf)" || return 1
awk -F'|' -v p="$1" '
/^[[:space:]]*(#|$)/ { next }
{ f = $1; gsub(/[[:space:]]/, "", f)
if (f == p) { m = $2; o = $3; g = $4
gsub(/[[:space:]]/, "", m); gsub(/[[:space:]]/, "", o); gsub(/[[:space:]]/, "", g)
print m "|" (o == "" ? "root" : o) "|" (g == "" ? "-" : g); found = 1; exit } }
END { exit !found }' "$conf"
}
# _setid_pkg_for <path> -> the PACKAGE setid.conf names as the file's
# provider (the optional 5th column), or nothing.
#
# WHY A SETID FILE NEEDS AN OWNER'S NAME. The lease hands a root-owned file
# to a package user for the length of a phase, and one of the doors it may
# come through is "the file carries a setid bit". That door had no lock:
# ANY package whose time-window manifest happened to list /usr/bin/su -- and
# a webkitgtk build runs for hours, so its manifest listed whatever root
# wrote meanwhile -- was handed su, 0711 and no longer setuid, until its
# phase ended. `su` is broken for those hours and, if the build is
# interrupted, for good.
#
# Only shadow installs su. With the provider written down, the lease can
# say so: shadow may reclaim it, nobody else may borrow it.
_setid_pkg_for() {
local conf; conf="$(_setid_conf)" || return 1
awk -F'|' -v p="$1" '
/^[[:space:]]*(#|$)/ { next }
{ f = $1; gsub(/[[:space:]]/, "", f)
if (f == p) { k = $5; gsub(/[[:space:]]/, "", k); print k; found = 1; exit } }
END { exit !found }' "$conf"
}
_lease_dir() { printf '%s/%s\n' "$LEASES" "$(unprefix_pkg_user "$1")"; }
# _lease_candidates <owner> <script> <phase>
_lease_candidates() {
local owner="$1" script="$2" phase="$3" pkg man
pkg="$(unprefix_pkg_user "$owner")"
man="$MANIFESTS/$pkg.files"
case "$phase" in
install|all|update)
if [ -r "$man" ]; then
# A PACKAGE RECLAIMS ITS OWN FILES. Every manifest entry
# someone else owns now -- root (a setid bit applied) or
# another package (a later install took it) -- is leased.
# Only root's took this path, so shadow's own file held by
# another package still failed on a bash redirect or cp,
# whose wording no repair parses. lease_files keeps the
# guards: package accounts and manifest-claimed root only.
grep -v '^[[:space:]]*$' "$man" | xargs -r -d '\n' stat -c '%U|%n' 2>/dev/null \
| awk -F'|' -v o="$owner" '$1 != o { sub(/^[^|]*\|/, ""); print }'
fi ;;
esac
case "$phase" in
configure|all|update) script_write_targets "$script" configure_pkg ;;
esac
}
# lease_files <owner> <script> <phase> (root; before the phase)
lease_files() {
local owner="$1" script="$2" phase="$3" dir ledger n=0 p u g m accounts _sp _lt
[ "$(id -u)" = 0 ] || return 0
case "$phase" in install|configure|all|update) ;; *) return 0 ;; esac
user_exists "$owner" || return 0
dir="$(_lease_dir "$owner")"; ledger="$dir/ledger"
# A LEDGER LEFT BEHIND means the last phase never got to settle (killed,
# power cut). Its copies are the only record of what was there: restore
# them first, then lease afresh.
if [ -s "$ledger" ]; then
note "# $owner: a lease from an interrupted run is still open -- restoring it first"
lease_restore "$owner" || {
warn "!! could not restore the old lease in $dir -- not leasing again"
return 1; }
fi
local man="$MANIFESTS/$(unprefix_pkg_user "$owner").files"
accounts="$(pkg_users_list 2>/dev/null)"
mkdir -p "$dir/files" 2>/dev/null && chmod 700 "$LEASES" "$dir" 2>/dev/null
: > "$ledger" || { warn "!! cannot write $ledger -- no lease"; return 1; }
local _cfg_targets=""
case "$phase" in
configure|all|update) _cfg_targets="$(script_write_targets "$script" configure_pkg 2>/dev/null)" ;;
esac
while IFS= read -r p; do
[ -n "$p" ] || continue
[ -e "$p" ] || [ -L "$p" ] || continue
[ -d "$p" ] && [ ! -L "$p" ] && continue
grep -qF "$p|" "$ledger" 2>/dev/null && continue
# NO SYMLINK IN THE WAY. If a directory on the path is a link a
# package user controls, the name the manifest vouches for is not
# the file that would be handed over.
if [ "$(cd "$(dirname "$p")" 2>/dev/null && pwd -P)/$(basename "$p")" != "$p" ]; then
detail "# $p goes through a symlinked directory -- left alone"
continue
fi
u="$(stat -c %U "$p" 2>/dev/null)" || continue
[ "$u" = "$owner" ] && continue
if [ "$u" = root ]; then
if ! grep -qxF "$p" "$man" 2>/dev/null; then
detail "# $p is root's and $owner's manifest does not claim it -- left alone"
continue
fi
# A MANIFEST CLAIM IS NOT ENOUGH FOR ROOT'S FILES. Manifests are
# built by time window, and a long phase swallowed what root
# wrote meanwhile -- LLVM's claimed /usr/bin/lfs and
# /etc/pam.d/su_u_user, and the lease handed them to p_llvm. A
# package's file is root's for two reasons only: a setid bit
# (the policy put it there) or its own configure commands write
# it. Anything else root owns is not the package's to borrow.
if [ -u "$p" ] || [ -g "$p" ] || _setid_policy_for "$p" >/dev/null 2>&1; then
# ...AND "IT IS SETID" IS NOT A REASON EITHER, unless the
# package is the one that installs it (1.14.310).
#
# This was the widest door in the lease and it had no lock.
# /usr/bin/su is root 4711; any package whose time-window
# manifest listed it -- webkitgtk's did, because `cfg
# shadow-pam` ran during its build -- came through here, and
# `chown` to the package user clears the setuid bit as a
# side effect the kernel never reports. Three times over
# three months the answer to "why is su owned by
# p_webkitgtk and 0711" was this line.
#
# setid.conf names the provider (5th column). shadow may
# reclaim its own su; for anyone else a setid file is the
# system's, not a package's.
_sp="$(_setid_pkg_for "$p" 2>/dev/null)"
if [ -z "$_sp" ] || [ "$_sp" != "$(unprefix_pkg_user "$owner")" ]; then
warn "# $p is setuid/setgid and ${_sp:-no package} installs it -- not leasing it to $owner"
continue
fi
elif ! printf '%s\n' "$_cfg_targets" | grep -qxF "$p"; then
detail "# $p is root's, not setid and not written by $owner's configure -- left alone"
continue
fi
elif ! printf '%s\n' "$accounts" | grep -qxF "$u"; then
detail "# $p belongs to $u, not a package account -- left alone"
continue
fi
g="$(stat -c %G "$p")"; m="$(stat -c %a "$p")"
n=$((n + 1))
cp -a "$p" "$dir/files/$n" 2>/dev/null || {
warn "!! could not copy $p aside -- not leasing it"; continue; }
printf '%s|%s|%s|%s|%s\n' "$p" "$m" "$u" "$g" "$n" >> "$ledger"
if chown -h "$owner:$owner" "$p" 2>/dev/null; then
detail "# leased $p ($m $u:$g) to $owner for this phase"
else
warn "!! could not hand $p to $owner"
fi
done < <(_lease_candidates "$owner" "$script" "$phase")
[ -s "$ledger" ] || rm -rf "$dir" 2>/dev/null
return 0
}
# lease_restore <owner> -- failure: every copy back, owner and mode, read back
lease_restore() {
local owner="$1" dir ledger p m u g n bad=0 got want
dir="$(_lease_dir "$owner")"; ledger="$dir/ledger"
[ -s "$ledger" ] || return 0
while IFS='|' read -r p m u g n; do
[ -n "$p" ] || continue
rm -f "$p" 2>/dev/null
if cp -a "$dir/files/$n" "$p" 2>/dev/null; then
chown -h "$u:$g" "$p" 2>/dev/null
[ -L "$p" ] || chmod "$m" "$p" 2>/dev/null
fi
got="$(stat -c '%a %U:%G' "$p" 2>/dev/null)"
want="$m $u:$g"
[ -L "$p" ] && { got="${got#* }"; want="$u:$g"; }
if [ "$got" = "$want" ] && cmp -s "$dir/files/$n" "$p" 2>/dev/null \
|| { [ -L "$p" ] && [ "$(readlink "$p")" = "$(readlink "$dir/files/$n")" ]; }; then
detail "# restored $p ($want)"
else
warn "!! $p did not come back as it was ($got, wanted $want)"
warn " the original is still at $dir/files/$n"
bad=1
fi
done < "$ledger"
[ "$bad" = 0 ] && rm -rf "$dir"
return "$bad"
}
# lease_settle <owner> -- success
lease_settle() {
local owner="$1" dir ledger p m u g n pol pm po pg
dir="$(_lease_dir "$owner")"; ledger="$dir/ledger"
[ -s "$ledger" ] || return 0
while IFS='|' read -r p m u g n; do
[ -n "$p" ] || continue
if [ ! -e "$p" ] && [ ! -L "$p" ]; then
note "# $p was leased and the phase removed it"
continue
fi
if pol="$(_setid_policy_for "$p")"; then
IFS='|' read -r pm po pg <<<"$pol"
[ "$pg" = "-" ] && pg="$(stat -c %G "$p")"
# "-" mode: what the file had before this phase -- the ledger
# recorded it (fcron's setid programs: the bits its own install
# asked for and `lfs-helper setid` applied, not a guess here)
[ "$pm" = "-" ] && pm="$m"
# _setid_apply is root:<group> + mode, read back; the owner
# column is root for every setid line, and anything else is
# not a setid policy this code should apply
if [ "$po" = root ]; then
_setid_apply "$pm" "$pg" "$p" >/dev/null || warn "!! $p: setid.conf policy did not apply"
continue
fi
fi
# A SYMLINK IS COMPARED BY WHERE IT POINTS, NOT BY `cmp` (1.14.310).
#
# `cp -a` copies a symlink AS a symlink, so the copy in the lease
# directory carries the same *relative* target -- and relative to
# $LEASES/<pkg>/files, not to /usr/sbin. /usr/sbin/iptables ->
# xtables-nft-multi copied aside is a dangling link, `cmp` cannot
# open it, and a failed compare reads here as "the phase rewrote
# it". So every leased symlink was declared the package's own and
# kept -- which is how six /usr/sbin/iptables* links, made by `cfg
# iptables-nft`, ended up owned by p_webkitgtk and written into its
# pkg.lst. lease_restore already compared links with readlink; this
# is the same test, on the other side of the branch.
if { [ -L "$p" ] && [ "$(readlink "$p")" = "$(readlink "$dir/files/$n")" ]; } \
|| { [ ! -L "$p" ] && cmp -s "$dir/files/$n" "$p" 2>/dev/null; }; then
# the phase did not change it: the lease was not needed
chown -h "$u:$g" "$p" 2>/dev/null
[ -L "$p" ] || chmod "$m" "$p" 2>/dev/null
detail "# $p unchanged -- back to $u:$g"
else
say "# $p rewritten by $owner -- it owns it now (was $u:$g)"
fi
done < "$ledger"
rm -rf "$dir"
}
report_setid_requests() {
# Printed after the phase, by root, because only root can act on it.
local owner="${1:-}" home f
[ -n "$owner" ] || return 0
home="$(pkgusr_home_for "$owner" 2>/dev/null)"
f="$home/.pkgusr-setid.requests"
[ -s "$f" ] || return 0
# APPLY THEM, DO NOT RECITE THEM.
#
# This function runs AS ROOT, right after the install -- the one moment
# when the thing that is missing can simply be done. It printed nine
# chmod lines for shadow and left `su` non-setuid until somebody typed
# them, which is the same "warn and walk away" that
# /etc/pam.d/su_u_user and the mode of that file were both guilty of.
#
# `lfs-helper setid --run` is the routine that already knows how: it
# applies each recorded request and READS THE MODE BACK. Call it, and
# report what it did rather than what the reader should do.
if [ "$(id -u)" = 0 ]; then
warn ""
warn "This package asked for setuid/setgid bits, which a package user"
warn "cannot set -- applying them now (root):"
while IFS= read -r line; do
[ -n "$line" ] && warn " $line"
done < "$f"
if cmd_setid --run; then
warn " applied. 'lfs-helper setid' lists anything still outstanding."
else
warn " SOME COULD NOT BE APPLIED -- see above; the requests are kept."
warn " retry at any time: lfs-helper setid --run"
fi
return 0
fi
warn ""
warn "This package asked for setuid/setgid bits, which a package user"
warn "cannot set. Nothing else refuses them; the binaries are installed"
warn "and will misbehave without the bit (fcrontab, for one, cannot change"
warn "group and reports 'Operation not permitted' even when run by root)."
warn "Apply as root, if you want the package to work as upstream intends:"
while IFS= read -r line; do
[ -n "$line" ] && warn " $line"
done < "$f"
warn ""
warn "All of them at once, at any later time:"
warn " lfs-helper setid # what is outstanding"
warn " lfs-helper setid --run # apply it"
# NOT CLEARED HERE. Printing once and emptying the file made this a
# notice you had to be watching for: six hours of build output later
# there was no way to ask what had been refused. `setid --run` is what
# clears a line, once it has applied it and read the result back.
}
run_phase_as() {
local owner="$1" script="$2" phase="$3" log="$4" envpass="${5:-}"
LAST_FAILED_PHASE=""
envpass="$(build_env_pairs)$envpass"
local cwd="${6:-}" as_root="${7:-0}" track="${8:-}" rc=0
# Phase tracking lives HERE (item 6, step 4), not in the callers. It was
# cmd_build's private bookkeeping, so a packagemanager install that died
# during `make install` left no record -- next_phase_for knew nothing, and
# "resume" meant starting over. The runner is the one place that KNOWS
# which phase ran and how it ended, whichever tool asked for it.
#
# $track is the record key (the step name). Empty = no tracking, and a
# script without the phased dispatcher is never tracked: `bash script
# unpack` on an old-style script runs everything, so a record of "unpack"
# would claim something the run did not do.
if [ -n "$track" ] && ! grep -q '^install_pkg() {' "$script" 2>/dev/null; then
track=""
fi
# A script written for the shared runner (format v18+) sources lfs-phases
# off $PATH or $PKGUSR_LIB. Say so here, in one place, instead of
# letting `su -` report "lfs-phases: No such file or directory" from
# inside the package user's shell.
if grep -q 'pkgusr_run "\$@"' "$script" 2>/dev/null; then
local _lib="${PKGUSR_LIB:-}"
[ -n "$_lib" ] || _lib="$(command -v lfs-phases 2>/dev/null || true)"
[ -n "$_lib" ] || [ ! -r "${SNAP_ROOT%/}/usr/bin/lfs-phases" ] \
|| _lib="${SNAP_ROOT%/}/usr/bin/lfs-phases"
if [ -z "$_lib" ] || [ ! -r "$_lib" ]; then
die "$script needs the phase runner 'lfs-phases', which is not on PATH -- install lfs-pkgusr (make install) or set PKGUSR_LIB=/path/to/lfs-phases"
fi
envpass="PKGUSR_LIB='$_lib' $envpass"
fi
if [ -n "$track" ]; then
case "$phase" in all|update) clear_phases "$track" ;; esac
fi
mkdir -p "$(dirname "$log")" 2>/dev/null || true
# THE LEASE (1.14.263): files this phase will overwrite that root or
# another package owns are handed to the installer first, and settled or
# restored below. Root writes anything, so a root run needs none.
local _leased=0
if [ "$as_root" != 1 ] && have_su && user_exists "$owner"; then
lease_files "$owner" "$script" "$phase" && _leased=1
fi
if [ "$as_root" = 1 ] || ! have_su || ! user_exists "$owner"; then
( [ -n "$cwd" ] && cd "$cwd"; eval "env $envpass bash '$script' '$phase'" ) \
2>&1 | tee "$log"
rc=${PIPESTATUS[0]}
else
# `su -` resets the environment, so the vars go on the command line
su - "$owner" -c "env $envpass bash '$script' '$phase'" 2>&1 | tee "$log"
rc=${PIPESTATUS[0]}
fi
# A SINGLE-PHASE RUN KNOWS WHICH PHASE FAILED: ITS OWN.
#
# LAST_FAILED_PHASE was only set in the all|update branch below, and it
# is cleared at the top of this function -- so the FIRST retry (which
# runs just `install`) left it empty, and cmd_build's second round fell
# back to `$phase` = all. opencv's round 2 therefore re-cloned and
# rebuilt a tree that had reached 100%, to redo an install that needed
# one more directory grant. Rounds 1 and 2 printed the difference:
# round 1 ... (phase install only -- the build is done)
# round 2 ... (phase all)
if [ "$rc" != 0 ]; then
case "$phase" in
all|update) ;; # parsed from the log below
*) LAST_FAILED_PHASE="$phase" ;;
esac
fi
if [ -n "$track" ]; then
local _p
case "$phase" in
all|update)
if [ "$rc" = 0 ]; then
while read -r _p; do record_phase "$track" "$_p"; done \
< <(script_phases_of_file "$script")
else
# The phases run in order and stop at the first failure,
# so everything before the failing one did succeed.
# Record that, or the next run wrongly reports
# unpack/build as "never completed".
local _failed
_failed="$(grep -oE "phase '[a-z]+' FAILED" "$log" 2>/dev/null \
| head -n1 | cut -d"'" -f2)"
# ONE READER OF THE LOG. cmd_build needs to know which
# phase failed, so it can retry THAT one instead of the
# whole build (1.14.70). It must not grep for itself:
# phase bookkeeping lives here, and a second parser is a
# second thing to keep right.
LAST_FAILED_PHASE="$_failed"
if [ -n "$_failed" ]; then
for _p in unpack build install configure; do
[ "$_p" = "$_failed" ] && break
record_phase "$track" "$_p"
done
fi
fi ;;
*) [ "$rc" = 0 ] && record_phase "$track" "$phase" ;;
esac
fi
# A ZERO EXIT IS NOT PROOF THAT NOTHING FAILED.
#
# alsa-lib's install ended with
# install: cannot create regular file '/usr/include/sys/asoundlib.h':
# Permission denied
# make[4]: *** [Makefile:786: install-data-hook] Error 1
# ... make: *** [Makefile:408: install-recursive] Error 1
# and the run reported "alsa-lib: done. 1 package(s) installed." Whatever
# swallowed the status -- an older script format, a trailing command that
# succeeded -- the LOG said plainly that make had failed, and every
# "installed but not really" in this session began exactly here. So the
# log is checked as well as the status: cheap, and it cannot be bypassed by
# a script that forgets to propagate an exit code.
# THE LOG BELONGS BESIDE THE PACKAGE.
#
# Every failure box ends with "logs: /usr/src/pkgusr/p_<name>/log", and
# for a modern script that directory held one empty .diff: the phase log
# went to the central $LOGS/<name>-<phase>.log and nowhere else. The
# central copy is overwritten by the next package; the package's own
# history is what someone wants three days later, and the box had been
# pointing at an empty directory to find it.
if [ -n "$log" ] && [ -f "$log" ] && [ -n "$owner" ]; then
local _pkghome _pkglog
_pkghome="$(pkgusr_home_for "$owner" 2>/dev/null)"
if [ -n "$_pkghome" ] && [ -d "$_pkghome" ]; then
_pkglog="$_pkghome/log/$(basename "${log%.log}")-$(date +%Y-%m-%d_%H-%M-%S).log"
mkdir -p "$_pkghome/log" 2>/dev/null
if cp -f "$log" "$_pkglog" 2>/dev/null; then
chown "$owner:$owner" "$_pkglog" 2>/dev/null || true
# keep the last 10: a webkitgtk log is megabytes, and a
# package that fails twenty times should not fill the disk
ls -1t "$_pkghome/log"/*.log 2>/dev/null | tail -n +11 \
| while IFS= read -r _old; do rm -f "$_old"; done
fi
fi
fi
if [ "$rc" = 0 ] && [ -n "$log" ] && [ -f "$log" ]; then
local _swallowed=""
# CMAKE SAYS IT DIFFERENTLY, AND IT SAID IT.
#
# CMake Error at CMakeLists.txt:6 (set):
# There is an unterminated variable reference.
# -- Configuring incomplete, errors occurred!
#
# ...and the run printed "llvm: done (all phases complete)". The
# pattern below knows make and ninja; cmake fails before either runs,
# so nothing matched and a configure error became a finished build.
# That is the exact failure this check was written for, in another
# tool's words.
#
# And the `ninja` in the first alternative never matched anything:
# ninja prints `ninja: build stopped: subcommand failed.` with no
# `***`, so it needs its own branch -- the one place this check was
# supposed to work, and did not.
# A SHARED REGISTRY IS NOT THE PACKAGE.
#
# perl-class-inspector installed both its modules and both its man
# pages, and was then failed for this line:
# .../core_perl/perllocal.pod: Permission denied
# which make itself had already marked "(ignored)". perllocal.pod
# is a list of what has been installed, written by every module and
# owned by the first one; not updating it loses a paragraph of
# bookkeeping, not a file of this package's. The same is true of
# every entry in shared_files_list. They are excluded from this
# test -- and reported, because the reason they cannot be written
# is worth fixing (share_shared_files does it on the next repair).
local _ign _shared_hit=""
_ign="$(mktemp)"
{ shared_files_list; printf '%s\n' perllocal.pod; } > "$_ign" 2>/dev/null
_shared_hit="$(grep -Ff "$_ign" "$log" 2>/dev/null \
| grep -m1 -E ": Permission denied$|Operation not permitted")"
_swallowed="$(grep -vFf "$_ign" "$log" 2>/dev/null \
| grep -m1 -E "^(make(\[[0-9]+\])?|ninja): \*\*\* .*(Error [0-9]+|build stopped)|^ninja: build stopped|^install: cannot create|: Permission denied$|^CMake Error|^-- Configuring incomplete|^configure: error:|^meson\.build:[0-9]+:[0-9]+: ERROR:|^ERROR: Unhandled python|^error: could not compile|^fatal: could not read Username|^fatal: repository .* not found|^fatal: Authentication failed|BackendUnavailable: Cannot import|^ModuleNotFoundError: No module named")"
rm -f "$_ign"
if [ -z "$_swallowed" ] && [ -n "$_shared_hit" ]; then
warn "a shared registry file could not be updated:"
warn " $_shared_hit"
note " that file is written by every package of its family and"
note " owned by the first one; this package's OWN files installed."
note " Fixed for the next one: lfs-helper fix-perms $name --run"
share_shared_files
fi
if [ -n "$_swallowed" ]; then
warn "!! this phase exited 0, but its log says otherwise:"
warn " $_swallowed"
warn " Treating it as a FAILURE -- a package that could not write"
warn " its files is not installed, whatever the exit status said."
rc=1
fi
fi
# SETTLE ONLY AFTER THE LOG HAS HAD ITS SAY: a zero exit with "Permission
# denied" in the log is a failure, and a failure puts every leased file
# back.
if [ "$_leased" = 1 ]; then
if [ "$rc" = 0 ]; then
lease_settle "$owner"
else
lease_restore "$owner" \
|| warn "!! some leased files could not be restored -- see above"
fi
fi
return "$rc"
}
# `lfs-helper run-script` -- the same runner, for a caller that is not this
# script. packagemanager_install uses it instead of its own su line.
cmd_run_script() {
need_root
local owner="" script="" phase="all" log="" envpass="" cwd="" as_root=0
local track="" _track_set=0
while [ $# -gt 0 ]; do
case "$1" in
-h|--help)
cat <<'EOF'
lfs-helper run-script --user U --script S [--phase P] [--log F] [--env VARS]
[--cwd DIR] [--as-root]
Runs one phase of an install script as the package user -- the ONE runner
both drivers use, so a build behaves the same whichever tool started it.
--user the account to run as (package name or account name)
--script the install script to run
--phase all (default) | unpack | build | install | configure | test
--log where to tee the output (default: the account's log/ directory)
--name the step name to record phases under (default: derived from
--user; --name '' disables tracking)
--as-root run as root and WITHOUT the wrappers -- for root config steps,
which legitimately chown
Phases that finish are RECORDED (item 6, step 4), so an install that dies
during `make install` can be resumed with `lfs-helper build <name> --phase
install` instead of starting over -- whichever tool started it.
Reports the SCRIPT's exit status, never tee's.
EOF
return 0 ;;
--user) owner="${2:-}"; shift 2 ;;
--script) script="${2:-}"; shift 2 ;;
--phase) phase="${2:-}"; shift 2 ;;
--log) log="${2:-}"; shift 2 ;;
--env) envpass="${2:-}"; shift 2 ;;
--cwd) cwd="${2:-}"; shift 2 ;;
--name) track="${2-}"; _track_set=1; shift 2 ;;
--as-root) as_root=1; shift ;;
*) die "unknown option: $1 (usage: lfs-helper run-script --user U --script S [--phase P] [--log F])" ;;
esac
done
[ -n "$owner" ] || die "run-script: --user is required"
[ -n "$script" ] || die "run-script: --script is required"
[ -f "$script" ] || die "run-script: no such script: $script"
# The record key is the STEP name. The caller hands us the ACCOUNT
# (p_wget); cmd_build records under the step (wget), and two keys for one
# package is the disease this project keeps treating. pkg_owner_name +
# unprefix_pkg_user is the same round trip every other name makes.
if [ "${_track_set:-0}" != 1 ]; then
track="$(unprefix_pkg_user "$(pkg_owner_name "$owner")")"
fi
if [ -z "$log" ]; then
log="$(pkgusr_home_for "$owner")/log/run-$phase-$(date +%Y%m%d_%H%M%S).log"
fi
# wrappers first in PATH, unless this is a root step -- see run_phase_as
if [ -z "$envpass" ] && [ "$as_root" != 1 ]; then
envpass="PATH='$WRAPPERS:/usr/local/bin:/usr/local/sbin:/usr/bin:/usr/sbin:/bin:/sbin'"
fi
run_phase_as "$owner" "$script" "$phase" "$log" "$envpass" "$cwd" "$as_root" "$track"
}
# Does this step rewrite the user database?
_step_rewrites_user_db() {
# any truncating redirect into a passwd or group file, wherever $ETC points
grep -qE '>[[:space:]]*[^>|&]*/(passwd|group)([[:space:]]|$)' "$1" 2>/dev/null
}
# The version, from the one header line every generator writes. This read
# a "# package:" comment no generator ever wrote, so every pkgusr.info
# version was empty -- while its caller read name_version= for the VERSION
# file twenty lines earlier. One fact, one parser.
_version_from_script() {
[ -r "${1:-}" ] || return 0
sed -n "s/^name_version=[\"']\(.*\)[\"']\$/\1/p" "$1" 2>/dev/null | head -1 \
| sed 's/^.*-\([0-9][0-9.]*\)$/\1/'
}
# WHAT IS INSTALLED IS WHAT FINISHED -- ON EVERY PATH THAT FINISHES.
#
# `update` reads the installed version from .pkgusr-installed. It was
# written in ONE branch: a single-phase run completing the last phase. A
# normal `--phase all` install (every packagemanager install) took the other
# branch and never wrote it; and VERSION, beside it, parsed only
# name_version="..." while the generator writes name_version='...'. So no
# record was written on success, the reader fell back to install_last (staged
# BEFORE the build), and a failed build advanced the version. 1.14.273 then
# seeded the marker with the OLD version before a build -- and with nothing
# updating it on success, LLVM 22.1.8 installed and kept reading 21.1.8, and
# every stack run planned it again.
#
# Called after every phase has succeeded, whichever branch got there.
_record_installed() { # <name> <owner> <script>
local name="$1" owner="$2" script="$3" nv home
nv="$(sed -n "s/^name_version=['\"]\([^'\"]*\)['\"].*/\1/p" "$script" 2>/dev/null | head -n1)"
[ -n "$nv" ] || return 0
home="$(pkgusr_home_for "$owner" 2>/dev/null)"
[ -n "$home" ] && [ -d "$home" ] || home="$(pkgusr_home_for "$name" 2>/dev/null)"
[ -n "$home" ] && [ -d "$home" ] || return 0
{
echo "$nv"
echo "# installed $(date '+%Y-%m-%d %H:%M:%S') by lfs-helper"
[ -n "${_book_of_script:-}" ] && echo "# from: $_book_of_script"
} > "$home/VERSION"
printf '%s\n' "$nv" > "$home/.pkgusr-installed"
# benign: notes in the package's own home, rewritten every install
real_chown "$owner:$owner" "$home/VERSION" "$home/.pkgusr-installed" 2>/dev/null || true
}
_write_pkgusr_info() {
local name="$1" owner="$2" source="${3:-lfs}" version="${4:-}"
local home="$(pkgusr_home_for "$name")"
[ -d "$home" ] || return 0
{
printf 'source=%s\n' "$source"
printf 'package=%s\n' "$name"
[ -n "$version" ] && printf 'version=%s\n' "$version"
printf 'installed=%s\n' "$(date '+%Y-%m-%d %H:%M:%S' 2>/dev/null)"
printf 'by=lfs-helper %s (build %s)\n' "$LFS_HELPER_VERSION" "$(_build_id)"
} > "$home/.pkgusr" 2>/dev/null || return 0
[ -n "$owner" ] && real_chown "$owner:$owner" "$home/.pkgusr" 2>/dev/null
real_chmod 644 "$home/.pkgusr" 2>/dev/null
return 0
}
# Does this package already own files in the tree?
#
# The counters answer "what did this RUN write?", which is the wrong question
# when a package is built a second time: an installer that skips up-to-date
# files writes nothing, and nothing is indistinguishable from a silent failure
# by counting alone. The tree knows.
_pkg_owned_n=0
# --------------------------------------------------------------------------- #
# accounts you can actually log in with
# --------------------------------------------------------------------------- #
#
# A built-in step, like init-ownership -- not a user-editable script.
#
# It lived in last_build_step.sh, which is created once on the host and then
# belongs to the person. Wrong home for it twice over: a fix to it reaches
# nobody who already has a copy (that is how `chown: invalid user: 'wget:wget'`
# survived three releases), and this is not a matter of taste anyway. Book 8.5
# says to run `passwd root`.
#
# It cannot move to `packagemanager setup` either: setup runs on the BOOTED
# system, and you need to log in to run it. A fresh LFS has no root password,
# which is either "anyone can log in as root" or "nobody can log in at all" --
# and you find out after a reboot, with no way in. So it happens here, inside
# the chroot, while there is still a working shell.
cmd_init_accounts() {
need_root
local force="${1:-}"
if is_done init-accounts && [ "$force" != "--force" ]; then
ok "# init-accounts: already done"
return 0
fi
# No terminal (a scripted or resumed run): say what is missing rather than
# blocking, and leave the system unbootable-but-KNOWN instead of hanging.
if [ ! -t 0 ]; then
warn ""
warn "!! Not running interactively, so no passwords were set."
warn " Before rebooting, set at least the root password:"
warn " lfs-helper init-accounts (from a terminal, in the chroot)"
warn " or from outside:"
warn " chroot /mnt/lfs /usr/bin/passwd root"
warn ""
return 0
fi
say ""
say "--- accounts ------------------------------------------------"
if grep -qE '^root:[^:]*:' "$ETC/shadow" 2>/dev/null &&
! grep -qE '^root:(\*|!|)?:' "$ETC/shadow" 2>/dev/null; then
ok "root already has a password."
else
note "root has NO password yet. Without one you may not be able to"
note "log in after rebooting."
passwd root || warn "!! setting the root password failed -- do it before rebooting"
fi
# The login account for a person. The name is configured up front, so this
# only has to create it and set a password.
local main
main="${LFS_MAIN_USER:-}"
[ -n "$main" ] || main="$(sed -n 's/^main_user=\(.*\)$/\1/p' \
"$PKGUSR_ETC/packagemanager.conf" 2>/dev/null | head -n1)"
if [ -z "$main" ]; then
say ""
say "No login account is configured. You can add one now, or later with:"
hint " useradd -m -G users <name> && passwd <name>"
hint " (set it up front next time: lfs build-system session --reconfigure)"
printf "Create a login account now? [name, or blank to skip]: "
read -e -r main
fi
if [ -z "$main" ]; then
say ""
mark_done init-accounts
return 0
fi
if id "$main" >/dev/null 2>&1; then
ok "Account '$main' already exists."
else
say "Creating login account '$main' ..."
useradd -m -k /etc/skel -G users -s /bin/bash "$main" \
|| { warn "!! could not create $main"; mark_done init-accounts; return 0; }
fi
if grep -qE "^$main:(\*|!|)?:" "$ETC/shadow" 2>/dev/null; then
say "Set a password for '$main':"
passwd "$main" || warn "!! setting the password for $main failed"
fi
say ""
hint " '$main' can be given access to application users with:"
hint " packagemanager user create <app> --shared --launcher"
say ""
mark_done init-accounts
}
_package_already_owns_files() {
local owner="$1" d
_pkg_owned_n=0
[ -n "$owner" ] || return 1
user_exists "$owner" || return 1
while IFS= read -r d; do
d="${SNAP_ROOT%/}$d"
[ -d "$d" ] || continue
_pkg_owned_n=$(( _pkg_owned_n + $(find "$d" -xdev -user "$owner" \
\( -type f -o -type l \) 2>/dev/null | head -200 | wc -l) ))
[ "$_pkg_owned_n" -gt 0 ] && return 0
done < <(install_dirs_list)
return 1
}
cmd_build() {
need_root
local name="" phase="all" force=0 run_tests=0 jobs="" auto_fix=1
local auto_modules="${PM_AUTO_MODULES:-0}"
local _AUTO_MODULES_LOG=""
local _force_stage=auto script_override="" as_root=0
while [ $# -gt 0 ]; do
case "$1" in
--phase) phase="$2"; shift 2 ;;
# The loop, for a script that is not in $SCRIPTS (item 6, step 5).
# packagemanager_install prepares the script in the package's home
# and used to run its own copy of everything below -- runner,
# grant-and-retry, recorder, version. Now it hands the file over
# and THIS is the one loop.
--script) script_override="${2:?--script needs a path}"; shift 2 ;;
--force) force=1; shift ;;
--stage) _force_stage=yes; shift ;;
--no-stage) _force_stage=no; shift ;;
--tests) run_tests=1; shift ;;
--jobs|-j) jobs="${2:?--jobs needs a number}"; shift 2 ;;
--no-auto-fix) auto_fix=0; shift ;;
--auto-modules) auto_modules=1; shift ;;
# A CONFIGURATION STEP THAT NEEDS ROOT (1.14.79) told people to run
# lfs-helper build fcron --phase configure --force --as-root
# and cmd_build did not take --as-root: the flag existed on
# cmd_run_script, one function away, so the advice was confidently
# wrong. BLFS's "Configuring" sections are root's, so `build` has
# to accept it too.
--as-root) as_root=1; shift ;;
-*) die "unknown option: $1" ;;
*) name="$1"; shift ;;
esac
done
[ -n "$name" ] || die "usage: lfs-helper build <package> [--phase P] [--force] [--as-root]"
# Records are kept under the STEP name. Callers hand over a mixture --
# build-all passes step names, packagemanager passes the ACCOUNT it just
# resolved -- and recording p_wget's build under "p_wget" while lfs
# records it under "wget" is two keys for one package, the disease this
# project keeps treating. Stripping the package prefix is safe on names
# that never carry it (gcc-pass1, cfg_bootscripts come back unchanged).
name="$(unprefix_pkg_user "$name")"
# A step this tool performs itself, with no script in the book.
#
# The ownership epoch used to be an invisible checkpoint inside build-all.
# It was in the right place and did the right thing, but it never appeared
# in `lfs-helper list`, so the one moment the whole scheme turns on was the
# one moment you could not point at, resume from, or re-run on its own.
if [ "$name" = "init-accounts" ]; then
cmd_init_accounts "$([ "$force" = 1 ] && echo --force)"
mark_done "$name"
return 0
fi
if [ "$name" = "init-ownership" ]; then
if is_done "$name" && [ "$force" = 0 ]; then
ok "# init-ownership: already done"
return 0
fi
if ! ownership_possible explain; then
die "init-ownership cannot run yet: book 7.6 (init-files) creates
the user database, and until it exists no name resolves -- not even root's."
fi
establish_ownership 1 || die "could not establish ownership"
mark_done "$name"
return 0
fi
local s
if [ -n "$script_override" ]; then
s="$script_override"
[ -f "$s" ] || die "no such script: $s"
else
s="$(script_for "$name")"
fi
if [ ! -f "$s" ]; then
# NOT EVERY NAME IS AN LFS STEP.
#
# glib2 is a BLFS package: it has no $SCRIPTS/glib2.sh and never will.
# Its script lives in its own package user's home, which is where the
# failure box's own retry hint points -- but `lfs-helper build glib2`
# looked only at the step scripts, announced "no script for 'glib2'"
# and advised regenerating the CHROOT scripts, a command that does not
# even run in here. Advice that cannot work is worse than none.
local _own _ownhome _cand
_own="$(pkg_owner_name "$name" 2>/dev/null)"
if [ -n "$_own" ] && user_exists "$_own"; then
_ownhome="$(pkgusr_home_for "$_own")"
# the canonical install_<Name-Version>, newest first; install_last
# is the record of what ran and is the fallback
_cand="$(ls -1t "$_ownhome"/install_*-[0-9]* 2>/dev/null \
| grep -v '\.edited$\|\.bak$' | head -1)"
[ -n "$_cand" ] || _cand="$(ls -1 "$_ownhome/install_last" 2>/dev/null)"
if [ -n "$_cand" ] && [ -f "$_cand" ]; then
say "# '$name' is not an LFS step; using its own script"
say "# $_cand"
s="$_cand"
fi
fi
fi
if [ ! -f "$s" ]; then
# A step in the order with no script and no built-in handler almost
# always means the ORDER is older than the tools -- the step was
# removed and the tree still lists it. Say that, rather than only
# offering to generate scripts that may already be there.
_warn_if_scripts_are_stale || true
# ONE OF THREE THINGS, AND THEY NEED DIFFERENT ANSWERS.
#
# This assumed the name was an LFS step whose script was missing, and
# said "regenerate the chroot scripts" -- from inside the chroot,
# where that command refuses to run. A name can also be a BLFS
# package (its script lives in its package user's home) or simply a
# typo, and both were told to regenerate the wrong thing.
local _msg="no script for '$name' at $s"
if in_step_order "$name"; then
_msg="$_msg
'$name' IS in the step order, so its script was never generated or was
removed. From OUTSIDE the chroot (type 'exit' first):
lfs build-system gen-chroot-scripts --run --overwrite
If '$name' no longer exists in this version, clear it from the order:
lfs-helper undone $name"
else
_msg="$_msg
'$name' is not an LFS step, and no package user of that name has a script
either. If it is a BLFS package, build it through packagemanager:
packagemanager install $name --run --recursive
or, to re-run one phase of something already unpacked:
packagemanager script install $name
If you meant an LFS step, see the list:
lfs-helper list"
fi
die "$_msg"
fi
# Clear a stale source tree first, as root -- independent of everything
# below, and it must happen even if staging later has trouble.
# DO NOT THROW AWAY A BUILD THAT SUCCEEDED.
#
# `--phase all` cleans the source tree first, which is right for a fresh
# build and wrong for a RETRY: LLVM compiled all 4343 targets, failed on
# a `systemd-run` line at the very end, and the next run wiped the tree
# and compiled it again from scratch. The phase records already say what
# finished (1.11.55), so a retry that has 'build' on record keeps the
# tree and the phases pick up from there.
local _done_phases=""
_done_phases="$(cat "$(phase_file "$name")" 2>/dev/null | tr '\n' ' ')"
case "$phase" in
all|unpack|update)
case " $_done_phases " in
*" build "*)
say "# keeping the source tree: 'build' already finished here"
say "# (rebuild from scratch with: lfs-helper build $name --phase unpack --force)" ;;
*) clean_stale_source "$name" "$s" ;;
esac ;;
esac
if is_done "$name" && [ "$force" = 0 ]; then
if [ -t 0 ]; then
printf "'%s' is already built. Rebuild it? [y/N]: " "$name"
local ans; read -e -r ans
case "$ans" in [Yy]*) ;; *) say "# leaving '$name' as it is"; return 0 ;; esac
else
say "# '$name' is already built (skipping; --force to rebuild)"
return 0
fi
fi
local chown_after=0
# One derivation of the staging directory, held in a variable. The script
# is installed there, the build cd's there, and the failure banner names
# it -- five call sites that used to re-derive it, and so could disagree.
# NOT `local as_root=0`: that shadowed the --as-root the caller passed,
# so the flag parsed cleanly and then did nothing. A root STEP still sets
# it; an explicit --as-root is honoured as well.
local staged stagedir owner="$name"
if is_root_step "$name"; then
as_root=1
stagedir="$(step_stage_dir "$name")"
staged="$stagedir/install_$name"
mkdir -p "$stagedir"
# STAGING A FILE ONTO ITSELF IS NOT AN ERROR, IT IS A NO-OP.
#
# pm-install copies the script into the account's home and then the
# phase runner stages it again to the same name. That was invisible
# while the account's home was MISPLACED (the two paths differed);
# once fix-home repointed the home mid-run they became one file and
# install(1) refused:
# install: '.../install_p_nss' and '.../install_p_nss' are the same file
if ! [ "$s" -ef "$staged" ]; then
install -m 0755 "$s" "$staged" || die "could not stage the install script"
fi
# A *-tmp temporary tool is the SAME package chapter 8 rebuilds, so its
# files should belong to that package user from the outset -- otherwise
# chapter 8 inherits a root-owned tree it cannot write into. The user
# may not exist this early (init-pkgusr runs later), in which case
# adopt-existing assigns them afterwards.
case "$name" in
*-tmp)
owner="$(pkg_owner_name "$name")"
# subshell: a `die` inside would kill the build, not just
# fall back to building this step as root
if pkgusr_ready && ( cmd_add_user "$owner" ) >/dev/null 2>&1; then
chown_after=1
else
owner="$name" # no package users yet; adopt later
fi ;;
esac
else
pkgusr_ready || die "run 'lfs-helper init-pkgusr --run' first"
owner="$(pkg_owner_name "$name")"
# an existing account is the normal case for every build after the
# first -- say nothing about it (`lfs-helper add-user` still does)
_QUIET_EXISTING=1 cmd_add_user "$owner"
stagedir="$(pkgusr_home_for "$owner")"
staged="$stagedir/install_$name"
# ALREADY THERE IS ALREADY STAGED.
#
# A script that lives in the owner's own home needs no second copy
# under a second name. Copying it anyway is what produced
# script: install_p_hatchling
# staged: install_hatchling
# edit : install_Hatchling-1.28.0
# -- three names for one file, and the failure box had to spend three
# lines telling the user which was which.
if [ "${s%/*}" = "${stagedir%/}" ] && [ -f "$s" ]; then
staged="$s"
soft "the install script is not owned by $owner" \
-- real_chown "$owner:$owner" "$staged"
chmod 0755 "$staged" 2>/dev/null || true
elif ! [ "$s" -ef "$staged" ]; then
install -m 0755 -o "$owner" -g "$owner" "$s" "$staged" \
|| die "could not stage the install script"
else
# already in place: make sure it still belongs to the package user
soft "the staged script is not owned by $owner" \
-- real_chown "$owner:$owner" "$staged"
fi
fi
mkstate
local before after before_dirs
if [ -f "$SNAPSHOT" ]; then before="$(load_snapshot "$SNAPSHOT")"
else before="$(snapshot)"; fi
if [ -f "$SNAPDIRS" ]; then before_dirs="$(load_snapshot "$SNAPDIRS")"
else before_dirs="$(snapshot_dirs)"; fi
local _book_of_script
_book_of_script="$(sed -n 's/^### book *: *//p' "$s" 2>/dev/null | head -n1)"
detail "# script : $s"
[ -n "$_book_of_script" ] && \
detail "# book : $_book_of_script"
if [ "$as_root" = 1 ]; then
detail "# user : root (temporary chapter-7 tool) phase: $phase"
else
detail "# user : $owner (package user) phase: $phase"
fi
# Phase records are the runner's job now (item 6, step 4): run_phase_as
# clears on an "all" run and records what finished, for EVERY caller.
# Record that this package is MID-INSTALL. Cancelling here (Ctrl-C during
# `make install`) leaves the package half on disk: gcc, interrupted during
# its install, replaces /usr/bin/gcc with a native compiler but has not yet
# installed the C++ headers -- so the whole toolchain stops working and
# nothing on disk says why. The marker is removed when the build finishes,
# whether it succeeded or failed cleanly.
local _interrupted="$STATE_PROGRESS/interrupted"
mkdir -p "$STATE" 2>/dev/null || true
echo "$name" >> "$_interrupted" 2>/dev/null || true
# Interrupting during an install leaves the package half on disk, and for
# a toolchain package that breaks every later build. We cannot stop you
# pressing Ctrl-C, so record it plainly and make the next run fix it --
# see the auto-finish in cmd_build_all.
trap '_note_interrupted "$name"; exit 130' INT TERM
# An earlier stage of this same package may still own files it is about
# to replace. Claim them first -- see _claim_earlier_stages.
[ "$as_root" = 1 ] || _claim_earlier_stages "$name" "$owner"
# Book 7.6 truncates /etc/passwd and /etc/group. Keep what this build
# created -- see _protect_user_db.
local _guard_userdb=0
if _step_rewrites_user_db "$staged"; then
_guard_userdb=1
_protect_user_db
fi
# This build's grant record starts EMPTY. The file is only cleared by
# the report at the end, so a build that failed (or any path that granted
# without reporting) left its entries behind -- and the next successful
# build printed them under "Collector groups used during this run".
# Building m4 announced that p_urllib3, make-ca and p11-kit had been
# granted access, which they had, weeks earlier. A record whose contents
# outlive the run it describes cannot answer the question it is asked.
mkdir -p "$(dirname "$GRANTED")" 2>/dev/null || true
: > "$GRANTED" 2>/dev/null || true
create_service_accounts_from_script "$staged"
# ROOT MUST NOT LEAVE A FILE THE PACKAGE USER CANNOT WRITE.
#
# This truncation runs as root, so the ledger came out root:root -- and
# then the install wrapper, running as the package user, could not
# append to it:
# /usr/lib/pkgusr/install: .pkgusr-setid.requests: Permission denied
# The swallowed-failure check saw "Permission denied" and failed the
# build, correctly, over the RECORDER rather than the package. Every
# root write to this file hands it back.
_sidf="$(pkgusr_home_for "$owner" 2>/dev/null)/.pkgusr-setid.requests"
if [ -n "${_sidf:-}" ]; then
: > "$_sidf" 2>/dev/null || true
chown "$owner:$owner" "$_sidf" 2>/dev/null || true
fi
say "# building $name ..."
local rc=0
make_wrappers
ensure_install_dirs_writable
[ "$as_root" = 1 ] || pregrant_python_sitedirs "$owner" "$s"
# Stage the install where the package supports it, so a cancel cannot
# leave the live system half-updated.
local _stage=""
if [ "${_force_stage:-auto}" != no ] \
&& { [ "${_force_stage:-auto}" = yes ] \
|| _phase_can_stage "$staged" "$phase" "$name"; }; then
_stage="$(_stage_dir_for "$name")"
rm -rf "$_stage"
if mkdir -p "$_stage" 2>/dev/null; then
# The build runs as the PACKAGE USER, but this directory was just
# created by root -- so `make install DESTDIR=...` could not write
# into it:
# mkdir: cannot create directory '.../stage/gcc': Permission denied
# Hand it to whoever will be building.
if [ "$as_root" != 1 ] && user_exists "$owner"; then
real_chown "$owner:$owner" "$_stage" 2>/dev/null \
|| warn "# could not give $_stage to '$owner'"
fi
soft "$_stage is not 755 -- the package user may not be able to stage into it" \
-- real_chmod 755 "$_stage"
# the parent too, or the package user cannot traverse into it
soft "$(dirname "$_stage") is not 755 -- the package user cannot traverse into the staging tree" \
-- real_chmod 755 "$(dirname "$_stage")"
else
_stage=""
fi
fi
# The scratch this step unpacks and builds in -- its own package user's
# home, not a shared /build. See build_root_for.
local _bldroot; _bldroot="$(build_root_for "$name")"
# A build tree that is not a directory is fatal, and must say so HERE.
#
# `mkdir -p` on an existing symlink fails with "File exists" and the build
# carried on to fail two lines later with a message about the wrong thing:
# mkdir: cannot create directory '.../p_gettext/build': File exists
# cd: /usr/src/pkgusr/p_gettext/build: Not a directory
# which the recovery code then read as a permissions problem and retried,
# twice, before giving up with advice about collector groups. The actual
# cause was a name collision with ~/build, the hint's build helper.
if [ -e "$_bldroot" ] && [ ! -d "$_bldroot" ]; then
die "the build tree for '$name' is not a directory:
$_bldroot ($(stat -c %F "$_bldroot" 2>/dev/null))
Something else already owns that name in the package user's home.
Rename it, or set a different one:
LFS_PKGUSR_BUILD_SUBDIR=<name> lfs-helper build $name"
fi
if ! mkdir -p "$_bldroot" 2>/dev/null; then
die "could not create the build tree for '$name': $_bldroot"
fi
if [ "$as_root" != 1 ] || [ "$owner" != "$name" ]; then
# give it to whoever will be building, so the tree it creates is
# already owned correctly and nothing has to be chowned afterwards
user_exists "$owner" && real_chown "$owner:$owner" "$_bldroot" 2>/dev/null
fi
soft "$_bldroot is not 755 -- the build may not be able to enter its own tree" \
-- real_chmod 755 "$_bldroot"
# A GENERIC NAME IN THE ENVIRONMENT IS A COLLISION WAITING TO HAPPEN.
#
# libvpx's build/make/Makefile says
# BUILD_ROOT?=.
# CFLAGS+=-I$(BUILD_PFX)$(BUILD_ROOT) -I$(SRC_PATH)
# and `?=` yields to the ENVIRONMENT -- so our BUILD_ROOT (the package's
# source directory) replaced libvpx's own "." and every compile looked
# for vpx_config.h in the wrong place. The variable is ours, so it gets
# our prefix; the scripts still see BUILD_ROOT, set from it locally.
local envpass="LFS_CC_PHASE='$phase' LFS_BUILD_ROOT='$_bldroot'"
[ -n "$_stage" ] && envpass="$envpass DESTDIR='$_stage'"
# The wrappers exist to stop a PACKAGE USER doing things it may not: chown,
# setuid modes, re-moding shared directories. A ROOT step must not get
# them. Root legitimately runs `chown -R wget:wget /sources/wget-1.25.0`
# to hand a source tree to a package user, and the wrapper silently skipped
# it -- leaving the tree root-owned, so the build then failed with
# ./configure: line 4389: config.log: Permission denied
# A FIXED PATH LOSES EVERYTHING INSTALLED UNDER /opt.
#
# This replaced PATH with a hard-coded list, so a package that lives in
# /opt -- rust, and everything the book puts there -- was invisible to
# every later build:
# install_p_cargo-c: line 161: cargo: command not found
# while `su - p_cargo-c -c 'command -v cargo'` found it perfectly, because
# a login shell reads /etc/profile.d and this did not.
#
# The extra directories are collected from the profile snippets the
# packages themselves installed (that is what they are for), appended
# after the fixed list so the wrappers and the system tools still come
# first.
# Read the snippets directly rather than starting another shell to ask:
# a login shell here would be slower, and would inherit whatever else the
# profile does. A profile.d snippet that adds a directory names it in a
# pathprepend/pathappend line; that is all we need.
local _extra="" _d
if [ -d "${SNAP_ROOT%/}/etc/profile.d" ]; then
for _d in $(grep -hoE 'path(prepend|append)[[:space:]]+(/[^[:space:]"]+)' \
"${SNAP_ROOT%/}"/etc/profile.d/*.sh 2>/dev/null \
| awk '{print $2}' | sort -u); do
case "$_d" in
/opt/*) [ -d "$_d" ] && _extra="${_extra:+$_extra:}$_d" ;;
esac
done
fi
local _base="/usr/local/bin:/usr/local/sbin:/usr/bin:/usr/sbin:/bin:/sbin"
[ -n "$_extra" ] && _base="$_base:$_extra"
if [ "$as_root" = 1 ]; then
envpass="$envpass PATH='$_base'"
else
# wrappers first in PATH -- see make_wrappers() for why
envpass="$envpass PATH='$WRAPPERS:$_base'"
fi
# the books expect some test failures, so suites only run when asked
[ "$run_tests" = 1 ] && envpass="$envpass PKGUSR_TESTS=1 LFS_RUN_TESTS=1"
[ -n "${LFS_TGT:-}" ] && envpass="$envpass LFS_TGT='$LFS_TGT'"
# MAKEFLAGS: --jobs wins over the configured value (it must be appended
# LAST -- `env A=1 A=2` keeps the last one, so ordering decides). A package
# that races under -j12 usually builds fine with --jobs 1.
# PRECEDENCE: --jobs, then build.env, then whatever this shell happens to
# have. The last assignment on an `env` line wins, and build.env is added
# FIRST, so passing the caller's MAKEFLAGS unconditionally overrode the
# per-machine setting with an ambient one -- a `MAKEFLAGS=-j16` inherited
# from the chroot's config beat the `MAKEFLAGS=-j13` someone had put in
# /etc/pkgusr/build.env, which is the file meant to hold exactly this.
if [ -n "$jobs" ]; then
envpass="$envpass MAKEFLAGS='-j$jobs'"
elif [ -n "${MAKEFLAGS:-}" ] \
&& ! printf '%s' "$envpass" | grep -q "MAKEFLAGS="; then
envpass="$envpass MAKEFLAGS='$MAKEFLAGS'"
fi
# THREE BUILD SYSTEMS, THREE VARIABLES, ONE NUMBER.
#
# make reads MAKEFLAGS; cmake reads CMAKE_BUILD_PARALLEL_LEVEL; cargo
# reads CARGO_BUILD_JOBS; ninja reads NOTHING and defaults to nproc+2,
# which is why machine.conf patches its -j into the script. Setting
# MAKEFLAGS alone limited one of the four, so `--jobs 13` was not a
# limit -- a cmake or rust package still ran wide and got OOM-killed.
# READ THE VALUE THAT WILL ACTUALLY BE PASSED.
#
# This looked at ${MAKEFLAGS:-} -- the AMBIENT one, in root's shell --
# while the number lives in /etc/pkgusr/build.env, which is already in
# $envpass by now. In a chroot root shell MAKEFLAGS is unset, so
# `MAKEFLAGS=-j13` in build.env produced no CARGO_BUILD_JOBS at all and
# rust built on every core.
_jn="$jobs"
[ -n "$_jn" ] || _jn="$(printf '%s' "$envpass" \
| grep -oE "MAKEFLAGS='?-j ?[0-9]+" \
| grep -oE '[0-9]+' | head -n1)"
[ -n "$_jn" ] || _jn="$(printf '%s' "${MAKEFLAGS:-}" \
| grep -oE '\-j ?[0-9]+' | grep -oE '[0-9]+' | head -n1)"
if [ -n "$_jn" ]; then
printf '%s' "$envpass" | grep -q "CMAKE_BUILD_PARALLEL_LEVEL=" \
|| envpass="$envpass CMAKE_BUILD_PARALLEL_LEVEL='$_jn'"
printf '%s' "$envpass" | grep -q "CARGO_BUILD_JOBS=" \
|| envpass="$envpass CARGO_BUILD_JOBS='$_jn'"
fi
# Keep pip out of the package user's ~/.local. PIP_USER=0 stops it
# installing there, and PYTHONNOUSERSITE=1 stops it *seeing* an existing
# per-user copy and concluding the requirement is "already satisfied" --
# which is how flit_core ended up importable by nobody but flit-core.
if uses_pip "$s"; then
envpass="$envpass PIP_USER=0 PYTHONNOUSERSITE=1"
fi
# How the build runs:
# * chapter-7 temp tools -> as root, no package user at all
# * package user + su present -> as that user (the real package-user way)
# * package user, NO su yet -> as root, then hand the freshly installed
# files to the package user. `su` comes from Shadow (chapter 8 #26),
# so for the first 25 packages there is no way to drop privileges --
# but we know exactly which files appeared, so ownership still ends up
# correct.
mkdir -p "$LOGS"
local log="$LOGS/$name-$phase.log"
# Mark the start of the build. The snapshot diff finds files that are NEW,
# but a package very often OVERWRITES files an earlier one installed (the
# chapter-8 bison replaces the chapter-7 bison-tmp binaries). Those aren't
# new, so without a timestamp to compare against they'd keep the previous
# owner -- which is how bison's files stayed owned by root.
# The stamp is the reference for "what did this build touch". It must NOT
# live in /tmp: init-dirs is the step that CREATES /tmp (book 7.5), so
# mktemp fails there with
# mktemp: failed to create file via template '/tmp/tmp.XXXXXXXXXX'
# and the empty path that follows silently disables file tracking for the
# whole step. $STATE always exists -- it is where the scripts came from.
# WAIT FOR THE TREE TO GO QUIET FIRST.
#
# Attribution by timestamp rests on one assumption -- that nothing else
# writes to the tree during a build -- and a real run proved it false.
# make-ca (package 98) was still generating /etc/ssl/certs and
# /etc/pki/anchors at 19:45, after its own scan had closed, while libogg
# (99) was building; libogg's manifest claimed 400 certificates, and
# `verify --fix` would have chowned the system trust store to it. We
# cause part of it ourselves: mandb and record-install are detached with
# setsid so Ctrl-C cannot interrupt them, which puts them squarely inside
# the NEXT package's window.
#
# So the window does not open until the known writers are done. Bounded,
# because a hung mandb must not stop a build -- if it is still going, say
# so, since the manifest that follows may then over-claim.
# Share the indexes BEFORE the build, not after it fails.
#
# init-pkgusr does this too, but /usr/share/info/dir usually does not
# exist yet when the package-user system is set up: it is created by the
# first package that ships an info page, owned by THAT package user, and
# the next package to run install-info cannot replace it. On a fresh LFS
# base build that is a guaranteed failure partway through chapter 8 --
# repaired by the retry loop, but a failure the user still has to watch.
# Idempotent and cheap, so doing it every build costs nothing and removes
# the failure entirely.
pkgusr_ready && share_shared_files
_settle_tree_before_stamp
local stamp
mkdir -p "$STATE/tmp" 2>/dev/null || true
stamp="$STATE/tmp/stamp.$$"
: > "$stamp" 2>/dev/null || stamp="$(mktemp 2>/dev/null || echo "")"
[ -n "$stamp" ] && touch "$stamp" 2>/dev/null || true
if [ -z "$stamp" ] || [ ! -e "$stamp" ]; then
warn "# cannot create a build stamp -- file tracking is off for this step"
fi
# ONE runner (item 6, step 3b). The three privilege cases live in
# run_phase_as now; what stays here is the decision that belongs to the
# BUILD: when there is no `su` yet, the files have to be handed over
# afterwards.
if [ "$as_root" != 1 ] && ! have_su; then
detail "# no 'su' yet (Shadow not built) -- building as root, then"
detail "# giving the installed files to '$owner'"
chown_after=1
fi
run_phase_as "$owner" "$staged" "$phase" "$log" "$envpass" \
"$stagedir" "$as_root" "$name"
rc=$?
# Move the staged tree into place BEFORE looking at what changed, so the
# file tracking sees the finished system. Only on success: a failed
# install must leave the live system untouched, which is the whole point.
if [ -n "${_stage:-}" ]; then
if [ "${rc:-0}" = 0 ]; then
_merge_stage "$_stage"
case $? in
0) ;;
2) warn "# nothing was staged -- this package ignores DESTDIR"
warn "# it installed straight into the system, so an"
warn "# interrupted run here would leave it half-updated"
rm -rf "$_stage" ;;
*) warn "# could not move the staged files into place"
warn "# they are still in $_stage"
rc=1 ;;
esac
else
say "# install failed -- the system was not touched"
say "# (what it managed to build is in $_stage)"
fi
fi
[ "${_guard_userdb:-0}" = 1 ] && _restore_user_db
after="$(snapshot)"
local newfiles newdirs
# LC_ALL=C on comm as well as on the sort that fed it.
#
# 1.9.6 pinned the sort and stopped there, which is half a fix: comm
# validates the ordering using ITS OWN locale, so C-sorted input compared
# under en_GB.UTF-8 is "not sorted" as far as comm is concerned --
# comm: file 1 is not in sorted order
# comm: file 2 is not in sorted order
# and it then reports a diff that is simply wrong. The sort and the
# comparison have to agree; pinning either one alone guarantees they do not.
newfiles="$(LC_ALL=C comm -13 <(printf '%s\n' "$before") <(printf '%s\n' "$after"))"
local _after_dirs; _after_dirs="$(snapshot_dirs)" # one walk, used twice
newdirs="$(LC_ALL=C comm -13 <(printf '%s\n' "$before_dirs") <(printf '%s\n' "$_after_dirs"))"
printf '%s\n' "$after" > "$SNAPSHOT"
printf '%s\n' "$_after_dirs" > "$SNAPDIRS"
# Everything this build wrote: created OR modified since the stamp.
# Always work out what the build TOUCHED, even when we are not going to
# change ownership. A config step rewrites files that already exist
# (/etc/hosts was created back in 7.6), so the snapshot diff sees nothing
# new and the step looks like it did nothing at all.
local touched="" touched_dirs=""
if true; then
local r="${SNAP_ROOT%/}"
# "${r:-/}": SNAP_ROOT is "/" inside the chroot, and "${SNAP_ROOT%/}"
# trims that to the empty string -- `find ""` then fails silently and
# NOTHING is ever seen as touched, so every config step looks like it
# did nothing at all.
# a missing stamp would make `find -newer` fail and silently report
# that the build touched nothing at all
[ -n "$stamp" ] && [ -e "$stamp" ] && \
# DIRECTORIES this build wrote into, not just the ones it created.
# A directory that already existed -- made by root in chapter 7, or by
# an earlier run -- kept root ownership while every file inside it
# became the package's, so a package owned its files but not the
# directory holding them. Shared install dirs and directories another
# package owns are filtered out below, exactly as for files.
# $BUILD_ROOT is excluded, exactly like $LFS/sources.
#
# It was not, and it is the busiest directory on the system during a
# build: unpacked source trees, object files, the tarballs themselves.
# Every one of them is newer than the stamp, so every package's manifest
# swallowed the whole scratch tree and then chowned it -- and the next
# package chowned it right back. A real `verify` came back with
# 3363 wrong-owner
# of which 3348 were /build, all reading
# /build/bash-5.3.tar.gz is p_shadow want p_acl
# Scratch belongs to no package. The two snapshot scans above already
# knew that; this one did not. Same rule, one more place.
local _bld="${BUILD_ROOT:-$r/build}"
say "# scanning the tree for what this step touched (a fresh tree takes a moment) ..."
# ONE pruned walk for both lists (it was two unpruned ones). A
# pruned directory is reported only as itself, as before: $SRCROOT,
# sources, dev, proc, sys, run and tmp when newer; never the build root.
local _tw
_tw="$(find "${r:-/}" -xdev \
\( -path "$_bld" \) -prune \
-o \( -path "$SRCROOT" -o -path "$r/sources" -o -path "$r/dev" \
-o -path "$r/proc" -o -path "$r/sys" -o -path "$r/run" \
-o -path "$r/tmp" \) -type d -newer "$stamp" -printf 'd %p\n' -prune \
-o \( -path "$SRCROOT" -o -path "$r/sources" -o -path "$r/dev" \
-o -path "$r/proc" -o -path "$r/sys" -o -path "$r/run" \
-o -path "$r/tmp" \) -prune \
-o -newer "$stamp" \( -type d -printf 'd %p\n' \
-o \( -type f -o -type l \) -printf 'f %p\n' \) \
2>/dev/null)"
touched_dirs="$(printf '%s\n' "$_tw" | sed -n 's/^d //p' | sort -u)"
touched="$(printf '%s\n' "$_tw" | sed -n 's/^f //p' | sort -u)"
fi
rm -f "$stamp"
local man="$MANIFESTS/$name.files" mand="$MANIFESTS/$name.dirs"
# Record what was touched regardless of whether ownership changed -- a
# config step that rewrites /etc/hosts installs no NEW file but has very
# much done its job.
# A PACKAGE USER CANNOT HAVE WRITTEN A ROOT-OWNED FILE.
#
# Attribution is by time window. LLVM's install phase ran for hours
# (its compile is generated into install_pkg), and in that window root
# ran `make install` of these tools and `cfg app-users` -- so
# /usr/bin/lfs, /usr/bin/packagemanager and /etc/pam.d/su_u_user went
# into LLVM's manifest. The lease then treated them as LLVM's own
# root-owned files and handed su_u_user, a script root runs on every
# su, to p_llvm for the length of each install round.
# When the phase ran AS the package user, it could only have written
# files it can write: whatever is root's now was not its doing. (A
# file it rewrote under a lease is its own by now -- lease_settle ran
# before this scan.) Root-run phases and chown-after builds still record
# root files, which is what they produce.
local _root_ok=0
{ [ "${as_root:-0}" = 1 ] || [ "${chown_after:-0}" = 1 ]; } && _root_ok=1
{ [ -f "$man" ] && cat "$man"
{ printf '%s\n' "$newfiles"; printf '%s\n' "$touched"; } \
| while IFS= read -r _tf; do
[ -n "$_tf" ] || continue
if [ "$_root_ok" != 1 ] && [ "$(stat -c %u "$_tf" 2>/dev/null)" = 0 ]; then
continue
fi
printf '%s\n' "$_tf"
done; } \
| grep -v '^$' | sort -u \
| _drop_other_packages_files "$owner" > "$man.tmp" \
&& mv "$man.tmp" "$man"
{ [ -f "$mand" ] && cat "$mand"; printf '%s\n' "$newdirs"; } \
| grep -v '^$' | sort -u > "$mand.tmp" && mv "$mand.tmp" "$mand"
say "# tracked $(wc -l < "$man") file(s) for '$name'"
_udev_reload_if_rules_changed "$man"
# ...and the owner-scanned record too, once ownership makes it meaningful.
# One door: cmd_record_install decides which strategy applies (item 6).
# ON SUCCESS ONLY. A failed libxml2 run recorded its 4 half-built files
# as pkg.lst, the planner read pkg.lst as "installed", and every later
# plan skipped the package -- docbook then died on the xmlcatalog that
# was never installed. The manifest above is the record of what a step
# TOUCHED and rightly survives failure; pkg.lst and VERSION are the claim
# that it is INSTALLED, and a failed build may not make that claim.
[ "${rc:-1}" = 0 ] && { cmd_record_install "$name" >/dev/null 2>&1 || true; }
# A FAILED build must not take ownership of anything.
#
# gcc failing half-way still "tracked 828 file(s)" and chowned them -- so a
# broken, partial install claimed files from the working temporary system,
# and the next attempt inherited the damage. Ownership is a statement that
# this package installed these files successfully; if it did not, say
# nothing. The manifest is still written (it is the record of what the
# attempt touched, needed for cleanup) but nothing changes hands.
if [ "${rc:-0}" != "0" ] && [ "$chown_after" = 1 ]; then
warn "# build failed -- not changing ownership of the $(printf '%s\n%s\n' "$newfiles" "$touched" | grep -cv '^$') path(s) it touched"
chown_after=0
fi
if [ "$chown_after" = 1 ] && [ -n "$newfiles$newdirs$touched" ]; then
# hand everything this package just installed to its package user.
# Directories that belong to the install group are shared infrastructure
# (root:install) and must stay that way -- only take the ones this
# package created itself.
local n=0 p cur skipped_foreign=0 _sp _lt _lo
while IFS= read -r p; do
[ -n "$p" ] || continue
# NEVER take a file that already belongs to another package user.
#
# "touched" is every file with a newer mtime than the build stamp,
# which is wider than "files this package installed": a build that
# rewrites, re-links or merely re-times something belonging to
# another package would claim it. That is how zstd came to own
# /usr/include/c++/15.2.0/bits/memoryfwd.h
# -- gcc's own C++ headers -- after which gcc could not build.
# Files owned by root are fair game (the temporary system's), files
# owned by another package are not.
cur="$(stat -c %U "$p" 2>/dev/null)"
if [ -n "$cur" ] && [ "$cur" != "$owner" ] && [ "$cur" != "root" ] \
&& [ "$cur" != "UNKNOWN" ] && user_exists "$cur"; then
skipped_foreign=$((skipped_foreign+1))
continue
fi
# A PACKAGE USER CANNOT HAVE WRITTEN A ROOT-OWNED FILE -- HERE TOO.
#
# The manifest above already drops root-owned paths when the phase
# ran AS the package user (_root_ok). This loop did not: it walked
# the RAW lists again, so a file the manifest correctly refused was
# chowned anyway. One rule, applied in one of the two places that
# needed it.
if [ "$_root_ok" != 1 ] && [ "$cur" = root ]; then
skipped_foreign=$((skipped_foreign+1))
continue
fi
# CHOWN DESTROYS A SETUID BIT, AND NOTHING SAYS SO.
#
# Attribution is by TIME WINDOW, and a webkitgtk build runs for
# hours. Anything that touched /usr/bin/su in that window --
# `lfs-helper setid`, a shadow reinstall, `cfg shadow-pam` -- made
# it "newer than the stamp", so this loop took a root-owned file
# that was fair game by every rule above and chowned it:
# -rwx--x--x 1 p_webkitgtk p_webkitgtk /bin/su
# The kernel clears setuid/setgid on chown, so one command changed
# the owner AND removed the bit that makes su work. A file
# carrying either bit is never this sweep's to take, whoever ran
# the phase: the modes in setid.conf are the system's, not a
# package's.
case "$(stat -c %a "$p" 2>/dev/null)" in
[1-7][0-7][0-7][0-7])
warn "# not taking $p -- it is setuid/setgid ($(stat -c %a "$p")); chown would clear that"
skipped_foreign=$((skipped_foreign+1))
continue ;;
esac
# ...AND THE SAME TWO PROVENANCE RULES THE MANIFEST USES
# (1.14.310): a setid file belongs to the package setid.conf
# names, and a symlink belongs to whoever owns its target. The
# mode test above cannot see either -- `stat -c %a` on a symlink
# reports the link's own 777, so no link has ever looked setuid
# to it, and a file whose setuid bit a previous chown already
# cleared does not look setuid either.
if _sp="$(_setid_pkg_for "$p" 2>/dev/null)" \
&& [ -n "$_sp" ] && [ "$_sp" != "$name" ]; then
warn "# not taking $p -- setid.conf says $_sp installs it"
skipped_foreign=$((skipped_foreign+1))
continue
fi
if [ -L "$p" ]; then
_lt="$(readlink -f "$p" 2>/dev/null)"
if [ -n "$_lt" ] && [ "$_lt" != "$p" ]; then
_lo="$(stat -c %U "$_lt" 2>/dev/null)"
if [ -n "$_lo" ] && [ "$_lo" != "$owner" ] && [ "$_lo" != root ] \
&& [ "$_lo" != UNKNOWN ] && user_exists "$_lo"; then
warn "# not taking $p -- it points at $_lo's $_lt"
skipped_foreign=$((skipped_foreign+1))
continue
fi
fi
fi
real_chown -h "$owner:$owner" "$p" 2>/dev/null && n=$((n+1))
done <<< "$(printf '%s\n%s\n' "$newfiles" "$touched" | grep -v '^$' | sort -u)"
while IFS= read -r p; do
[ -n "$p" ] || continue
# shared by nature (/usr/bin, /usr/include, ...): stays root:install
is_install_dir "$p" && continue
cur="$(stat -c %U "$p" 2>/dev/null)"
[ "$cur" = "$owner" ] && continue
# another package's directory: leave it, and let the collector-group
# machinery decide -- never take it silently
if [ -n "$cur" ] && [ "$cur" != root ] && user_exists "$cur"; then
skipped_foreign=$((skipped_foreign+1))
continue
fi
real_chown "$owner:$owner" "$p" 2>/dev/null && n=$((n+1))
done <<< "$(printf '%s\n%s\n' "$newdirs" "$touched_dirs" \
| grep -v '^$' | sort -u)"
say "# gave $n newly installed path(s) to '$owner'"
[ "$skipped_foreign" -gt 0 ] && say "# ($skipped_foreign path(s) left with the package that owns them)"
fi
# Automatic recovery: a "Permission denied" here almost always means the
# package needs to install into a directory another package owns. Grant it
# (collector groups) and retry the phase once, rather than making you run
# fix-perms by hand.
# Do not key this on "Permission denied": perl's installperl reports an
# unwritable destination directory as "No such file or directory", so the
# message is unreliable. auto_grant_from_log TESTS writability as the
# package user and does nothing when there is nothing to grant.
# An install reveals only the FIRST directory it cannot write; granting that
# one and retrying then reveals the next (perl modules span core_perl/,
# site_perl/ and more). So keep granting and retrying while each round
# actually grants something -- bounded, so a genuine build error can't loop.
# Only attempt a permission fix when the failure LOOKS like one. A script
# that stops because the USER must do something -- "mount your EFI System
# Partition first" -- must never be answered by handing directories away.
local _perm_ish=0
# "file INSTALL cannot": CMake wraps "Permission denied" over two lines.
# PERL SAYS IT IN ITS OWN WORDS, AND THEY MATCHED NOTHING.
#
# ERROR: Can't create '/usr/lib/perl5/5.42/site_perl/File'
# Do not have write permissions on '/usr/lib/perl5/5.42/site_perl/File'
# make: *** [Makefile:777: pure_site_install] Error 13
#
# ExtUtils::Install never writes "Permission denied" for this case, so
# the gate said "the failure does not look like a permission problem",
# ran zero rounds, and stopped a plan whose fix is one group.
grep -qiE "permission denied|operation not permitted|cannot (create|make|remove|change|touch)|can(no|')?t (create|make|mkdir)|do not have write permissions|couldn't copy|read-only file system|file INSTALL cannot" \
"$log" 2>/dev/null && _perm_ish=1
# A BOOK COMMAND THAT WORKS ONCE.
#
# libtiff's page ends by moving its html docs into a versioned
# directory. The first install creates
# /usr/share/doc/libtiff-4.7.1/tiff; the second cannot:
# mv: cannot overwrite '/usr/share/doc/libtiff-4.7.1/tiff': Directory not empty
# Nothing is wrong with the system -- the destination is this package's
# own output from last time, and a REINSTALL means replacing it. The
# gate did not recognise it, so 38 rebuilds stopped on a directory the
# package itself had put there.
#
# Only ever the exact path the message names, only when it belongs to
# THIS package, and only for a phase that is reinstalling.
# THE GATE DECIDES; THE LOOP ACTS.
#
# This used to call _clear_stale_dest here. It removed the directory --
# correctly, and said so -- and then the retry loop ran zero rounds,
# because the loop only counts what IT fixed. The repair happened and
# nothing re-ran the phase, so the package failed anyway with the
# obstacle already gone. Detect here, act in the loop.
grep -qE "cannot overwrite '[^']+'|: Directory not empty" "$log" 2>/dev/null \
&& _perm_ish=1
# "cannot make directory ...: No such file or directory" is not a
# permission problem and granting will never fix it -- but it IS
# automatically fixable, so it must reach the retry loop.
[ -n "$(missing_dirs_from_log "$log")" ] && _perm_ish=1
# ...and neither is "use -Dbuild-tests=false", which is the build telling
# us how to finish without a dependency this machine does not have.
grep -qE "use -D[A-Za-z0-9_.-]+=" "$log" 2>/dev/null && _perm_ish=1
# ...nor "configure: error: itstool not found", which names a package to
# install.
grep -qE "configure: error: [A-Za-z0-9_.+-]+ (not found|is required|missing)|configure: error: (Cannot|cannot) find |Program [A-Za-z0-9_.+-]+ not found|: command not found" \
"$log" 2>/dev/null && _perm_ish=1
# SAY WHY THERE WAS NO RETRY. cmake failed twice on the same missing
# directory with no attempt and no explanation, which left the only
# evidence -- "did the loop run?" -- invisible. A decision not to act is
# a decision, and it has to be reviewable.
if [ "$rc" != 0 ] && [ "$auto_fix" = 1 ] && [ "$_perm_ish" != 1 ]; then
detail "# no automatic repair attempted: the failure is not one this"
detail "# tool knows how to fix (a permission, a missing directory,"
detail "# or a build option the build itself names)"
fi
# Grants are per-directory now (a collector group is permission to install
# into a directory, not ownership of a tree), so a package installing into
# several directories needs several rounds. The loop still stops as soon
# as a round grants nothing new, so a higher cap costs nothing when things
# are going well and only helps a package with a deep install layout.
local _round=0 _max_rounds=20 _last_need="" _need_now
while [ "$rc" != 0 ] && [ "$auto_fix" = 1 ] && [ "$_perm_ish" = 1 ] \
&& [ "$_round" -lt "$_max_rounds" ]; do
# If this round would grant exactly what the last one did, granting is
# not the answer -- stop rather than burn the whole retry budget.
_need_now="$(unwritable_dirs_from_log "$log" "$owner" | sort -u)"
if [ -n "$_last_need" ] && [ "$_need_now" = "$_last_need" ]; then
warn "the same directories keep coming up -- permissions are not the problem"
break
fi
_last_need="$_need_now"
# create what is missing FIRST: a directory that is not there cannot
# be granted, and one round often needs both
_fixed_something=0
# a shared index the package must rewrite (install-info on
# /usr/share/info/dir) is not a grant problem: give it the shared
# treatment and let the retry proceed
if grep -qE "Operation not permitted for|Permission denied" "$log" 2>/dev/null; then
_before_share="$(stat -c %U:%G:%a "$(shared_files_list | head -n1)" 2>/dev/null)"
share_shared_files
[ "$_before_share" != "$(stat -c %U:%G:%a "$(shared_files_list | head -n1)" 2>/dev/null)" ] \
&& _fixed_something=1
fi
create_missing_dirs_from_log "$log" "$owner" && _fixed_something=1
_meson_opt_from_log "$log" "$s" "$name" && _fixed_something=1
_ldc_env="$(_ldconfig_from_log "$log" "$envpass")" \
&& { envpass="$envpass$_ldc_env"; _LDCONFIG_OVERRIDDEN=1; _fixed_something=1; }
_install_missing_tool "$log" && _fixed_something=1
# the package's own leftovers from a previous install of the same
# version: removing them IS the fix, and it counts as one, so the
# phase is retried
_clear_stale_dest "$log" "${owner:-}" && _fixed_something=1
# a destination file another package owns, in a sticky directory:
# handing it over is the fix, and it counts as one
_take_cmake_dest "$log" "${owner:-}" && _fixed_something=1
if [ "${auto_modules:-0}" = 1 ]; then
[ -n "$_AUTO_MODULES_LOG" ] \
|| _AUTO_MODULES_LOG="$(mktemp -t pm-auto-modules.XXXXXX)"
_install_missing_module "$log" && _fixed_something=1
fi
# a file another package owns, which THIS package is installing:
# ownership follows the installer (granting cannot fix EPERM)
takeover_files_from_log "$log" "$owner" && _fixed_something=1
auto_grant_from_log "$log" "$owner" && _fixed_something=1
# ...and the other half of a meson install failure: a FILE owned by
# another package that this one is installing over (see
# meson_takeover_from_log). The directory grant does nothing for it.
meson_takeover_from_log "$log" "$owner" && _fixed_something=1
[ "$_fixed_something" = 1 ] || break # nothing left to fix
_round=$((_round + 1))
# a package taking one file per round from the same other package is
# building a bundled copy of it; more rounds will not help
if [ -n "${LAST_SINGLE_HANDOVER:-}" ]; then
_same_owner_rounds=$((${_same_owner_rounds:-0} + 1))
else
_same_owner_rounds=0
fi
if [ "${_same_owner_rounds:-0}" -ge 3 ]; then
fail "!"
fail "! $name has now taken three files, one per round, from"
fail "! '$LAST_SINGLE_HANDOVER'. It is building a BUNDLED COPY of that"
fail "! package -- meson clones it as a subproject and installs its"
fail "! files over the real ones. Handing them over one at a time"
fail "! cannot end: stop the subproject instead."
fail "! /etc/pkgusr/stacks/machine.conf"
fail "! [$(unprefix_pkg_user "$name")]"
fail "! wrap_mode = nodownload"
fail "! then: packagemanager install $(unprefix_pkg_user "$name") --run --regenerate"
break
fi
# RETRY THE PHASE THAT FAILED, NOT THE WHOLE BUILD.
#
# foot took seven rounds to collect its directory grants, and every
# round re-cloned the repository, re-ran meson and recompiled all 150
# targets -- to redo an install that had failed on one completion
# file. Granting a directory cannot invalidate a compile. When a
# `--phase all` run failed in install or configure, the retry runs
# THAT phase; the sources and the build tree are untouched and still
# there, which is exactly why the grant is all that was missing.
local _retry_phase="$phase"
# `update` IS A COMPOUND PHASE TOO.
#
# This mapped only `all`, so a failure inside `update` retried
# `update` -- and poppler's retry, after a grant for
# /usr/share/gir-1.0, re-unpacked the tarball and re-ran cmake from
# scratch. 145 files were already installed; the one that needed
# doing again was the install step. The log says which inner phase
# failed (`!! phase 'install' FAILED`); both compound phases should
# use it.
case "$phase" in
all|update)
case "${LAST_FAILED_PHASE:-}" in
install|configure) _retry_phase="$LAST_FAILED_PHASE" ;;
esac ;;
esac
say ""
if [ "$_retry_phase" != "$phase" ]; then
say "# retrying $name (phase $_retry_phase only -- the build is done), round $_round ..."
else
say "# retrying $name (phase $_retry_phase), round $_round ..."
fi
# the same runner as the first attempt -- this was a third copy of
# the three-branch block, and a retry that ran the build differently
# from the attempt it is retrying is a bug waiting for its day
run_phase_as "$owner" "$staged" "$_retry_phase" "$log" "$envpass" \
"$stagedir" "$as_root" "$name"
rc=$?
# `all` also runs configure after install; when only install was
# retried, that step still owes to be run once it succeeds
if [ "$rc" = 0 ] && [ "$phase" = all ] && [ "$_retry_phase" = install ]; then
run_phase_as "$owner" "$staged" configure "$log" "$envpass" \
"$stagedir" "$as_root" "$name"
rc=$?
fi
after="$(snapshot)"
done
if [ "$_round" -ge "$_max_rounds" ] && [ "$rc" != 0 ]; then
warn "gave up after $_max_rounds permission grants -- something else is wrong"
fi
# Exit 4 means "there was deliberately nothing to do" -- e.g. rEFInd is
# already installed and already has an LFS entry. That is a success, and
# the "installed no files" check below must not treat it as suspicious.
local _nothing_to_do=0
if [ "$rc" = 4 ]; then _nothing_to_do=1; rc=0; fi
# Whatever happened, say what the chmod wrapper refused: only root can
# act on it, root is running this, and the consequence otherwise arrives
# much later as an unexplained "Operation not permitted".
report_setid_requests "$owner"
# Exit 3 means "this needs the user to do something", not a build error.
# Report it plainly: no failure banner, no permission fixing, no retrying.
if [ "$rc" = 3 ]; then
say ""
note "$name is waiting on you -- see the message above."
say " When it's ready: lfs-helper build $name --force"
trap - INT TERM
_clear_interrupted "$name"
# Pass 3 up unchanged. Flattening it to 1 made "needs a decision"
# indistinguishable from "failed", so build-all could not tell the two
# apart -- refind slipped past and the build claimed to be complete
# with that step still pending.
return 3
fi
if [ "$rc" != 0 ]; then
fail ""
fail "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!"
fail "! $name FAILED (phase '$phase', exit $rc)"
fail "! script: $s"
# only when it IS a second file; usually the script is the staged one
[ "$staged" = "$s" ] || fail "! staged: $staged"
# WHAT THE AUTOMATIC REPAIR SAW. cmake failed three times on the
# same missing directory; whether the repair even ran was invisible
# from the outside, and two sessions went into guessing. The box
# now states it: what the gate decided, and what the detector found.
# A SCRIPT OLDER THAN THE GENERATOR CARRIES THE OLD BUGS.
#
# packagemanager warns about this when it RESOLVES a script, at the
# top of a long plan; by the time a package fails, that line is far
# out of sight. The failure is where it matters, so say it here too.
local _sgen _cgen
_sgen="$(sed -n 's/^### generated by blfs \([^ ]*\).*/\1/p' "$s" 2>/dev/null | head -n1)"
_cgen="$(blfs --version 2>/dev/null | sed -n 's/^blfs \([^ ]*\).*/\1/p' | head -n1)"
if [ -n "$_sgen" ] && [ -n "$_cgen" ] && [ "$_sgen" != "$_cgen" ]; then
fail "!"
fail "! This script was generated by blfs $_sgen; the generator is now"
fail "! $_cgen. Fixes made since then are NOT in it -- including any"
fail "! for this very failure. Rebuild it from the book first:"
# The suggestion needs the ANCHOR (mesa), which is what
# `packagemanager install` takes -- not the account (p_mesa).
# unprefix_pkg_user does that from PKGUSR_PREFIX; it printed p_mesa
# anyway, which means the prefix was EMPTY here while packagemanager
# had it set. That is a config disagreement worth naming rather than
# papering over, so say so when it happens.
local _sugg; _sugg="$(unprefix_pkg_user "$name")"
case "$_sugg" in
p_*) fail "! packagemanager install ${_sugg#p_} --run --yes --regenerate"
fail "! (note: this tool read an EMPTY package-user prefix, but the"
fail "! account is '$name'. /usr/share/lfs/config.json and"
fail "! /etc/pkgusr/packagemanager.conf should agree.)" ;;
*) fail "! packagemanager install $_sugg --run --yes --regenerate" ;;
esac
fi
fail "! auto-repair: $([ "$auto_fix" = 1 ] && echo enabled || echo DISABLED)," \
"gate: $([ "$_perm_ish" = 1 ] && echo "recognised" || echo "did not recognise this failure")," \
"rounds run: ${_round:-0}"
# A PYTHON BUILD BACKEND IS A PACKAGE, AND ITS NAME IS NOT THE
# MODULE'S. numpy died on
# BackendUnavailable: Cannot import 'mesonpy'
# and the page that provides `mesonpy` is called meson_python -- not
# guessable, and the traceback is 60 lines of pip internals with the
# one useful word at the bottom.
local _pymod _pyprov
_pymod="$(grep -aoE "Cannot import '[a-zA-Z0-9_.]+'|No module named '[a-zA-Z0-9_.]+'" \
"$log" 2>/dev/null | head -n1 | cut -d"'" -f2)"
if [ -n "$_pymod" ]; then
case "$_pymod" in
mesonpy) _pyprov="meson_python" ;;
setuptools_scm) _pyprov="setuptools_scm" ;;
Cython|cython) _pyprov="Cython" ;;
numpy) _pyprov="NumPy" ;;
flit_core) _pyprov="flit_core" ;;
hatchling) _pyprov="hatchling" ;;
scikit_build_core) _pyprov="scikit-build-core" ;;
poetry.core|poetry_core) _pyprov="poetry-core" ;;
*) _pyprov="" ;;
esac
if [ -n "${_AUTO_MODULES_LOG:-}" ] && [ -s "$_AUTO_MODULES_LOG" ]; then
fail "! installed on the way (--auto-modules), not asked for:"
while IFS= read -r _am; do fail "! $_am"; done < "$_AUTO_MODULES_LOG"
fi
fail "! a python module is missing, not a permission: '$_pymod'"
if [ -n "$_pyprov" ]; then
fail "! the BLFS page that provides it is '$_pyprov':"
fail "! packagemanager install $_pyprov --run --recursive"
else
fail "! find the page that provides it:"
fail "! packagemanager search $_pymod"
fi
fail "! then rebuild this package. A plain \`install\` builds ONE"
fail "! package: --recursive is what builds what it needs."
fi
local _md_now
_md_now="$(missing_dirs_from_log "$log" 2>/dev/null | tr '\n' ' ')"
if [ -n "$_md_now" ]; then
fail "! directories it could create: $_md_now"
fail "! (they are still missing, so creating them did not work --"
fail "! say so with the output above and this line)"
fi
# "Program 'x' not found" from meson/cmake usually means a DEPENDENCY
# that is recorded as installed but is not really there -- a record
# written by a pre-1.12.7 failed build still lies on disk, and the
# planner believes records. Name the missing program and the cure,
# or the user chases the wrong package.
# A COMPILE error is not ours to repair -- but the place to record the
# cure is, and it is easy to miss. cairo 1.18.4 declares its own
# static ctime_r when it thinks glibc lacks one, then collides with
# the real declaration; the answer is one line in machine.conf.
if grep -qE "^[^ ]+:[0-9]+:[0-9]+: error:" "$log" 2>/dev/null; then
fail "!"
fail "! This is a COMPILE error in the package's own source, not a"
fail "! permission or layout problem -- the tools cannot repair it."
fail "! If it needs a build flag, put it where it survives a"
fail "! regenerated script, per package:"
fail "! /etc/pkgusr/stacks/machine.conf"
fail "! [$name]"
fail "! c_args = -DSOMETHING # merged into meson setup"
# A WARNING THAT IS ONLY FATAL BECAUSE OF -Werror.
#
# wlroots 0.19 against libinput 1.31:
# error: enumeration value 'LIBINPUT_SWITCH_KEYPAD_SLIDE' not
# handled in switch [-Werror=switch]
# cc1: all warnings being treated as errors
# The code is fine; a dependency grew a new enum value since it was
# written. Nothing can be "fixed" in the package, and the cure is
# always the same flag -- worth naming, since version skew like this
# is the normal state of a rolling book against pinned git sources.
if grep -q "all warnings being treated as errors" "$log" 2>/dev/null; then
local _werr
_werr="$(grep -oE "\[-Werror=[a-z0-9-]+\]" "$log" 2>/dev/null \
| head -1 | tr -d '[]')"
fail "!"
fail "! This is a WARNING that -Werror turned into an error${_werr:+ ($_werr)}."
fail "! The package builds fine without it; the usual cause is a"
fail "! dependency that grew something new since this version was"
fail "! written. Turn -Werror off for this package:"
fail "! /etc/pkgusr/stacks/machine.conf"
fail "! [$(unprefix_pkg_user "$name")]"
fail "! werror = false # meson"
if [ -n "$_werr" ]; then
fail "! c_args = -Wno-${_werr#-Werror=} # cmake or autotools"
else
fail "! c_args = -Wno-error # cmake or autotools"
fi
fail "! then: packagemanager install $(unprefix_pkg_user "$name") --run --regenerate"
fi
# THE COMMONEST COMPILE ERROR OF THIS DECADE.
#
# error: 'uint8_t' does not name a type
# is not a broken package: GCC 13 stopped including <cstdint> from
# other headers, so code written before that no longer compiles. The
# cure is one flag, and it is the same flag every time -- worth saying
# outright rather than leaving "put a build flag somewhere".
local _stdint
_stdint="$(sed -n "s/.*error: [\`'‘]\(u\?int[0-9_a-z]*_t\|uintptr_t\|intptr_t\|size_t\)['’\`].*does not name a type.*/\1/p" \
"$log" 2>/dev/null | head -1)"
if [ -n "$_stdint" ]; then
local _lang="c_args" _key
grep -qE "\.(cc|cpp|cxx|hpp|hh):" "$log" 2>/dev/null && _lang="cpp_args"
fail "!"
fail "! '$_stdint' does not name a type: this is the GCC 13+ change"
fail "! -- <cstdint> is no longer pulled in by other headers, and"
fail "! code written before it needs the include added. One flag,"
fail "! and the same one every time:"
fail "! /etc/pkgusr/stacks/machine.conf"
fail "! [$(unprefix_pkg_user "$name")]"
if [ "$_lang" = cpp_args ]; then
fail "! cpp_args = -include cstdint"
else
fail "! c_args = -include stdint.h"
fi
fail "! then: packagemanager install $(unprefix_pkg_user "$name") --run --regenerate"
fail "! (an autotools package takes the same thing as"
fail "! configure_args = CXXFLAGS=-include\\ cstdint)"
fi
# AN UNDECLARED SYMBOL USUALLY BELONGS TO SOMEONE ELSE.
#
# cairo stopped on FC_HINT_NONE, FC_RGBA_RGB and ten more, all of them
# fontconfig's -- a DEPENDENCY whose headers are missing or too old,
# not a bug in cairo. The box said "the tools cannot repair it" and
# left the person to work out whose symbols those were. We can look:
# if some installed header declares it, the include path is wrong; if
# none does, the package that should have provided it is not properly
# installed.
local _sym _owner_hdr
_sym="$(sed -n "s/.*error: ['\`‘]\([A-Za-z_][A-Za-z0-9_]*\)['\`’] undeclared.*/\1/p" \
"$log" 2>/dev/null | head -1)"
if [ -n "$_sym" ]; then
_owner_hdr="$(timeout 20 grep -rlm1 -w "$_sym" \
"${SNAP_ROOT%/}/usr/include" 2>/dev/null | head -1)"
fail "!"
if [ -n "$_owner_hdr" ]; then
fail "! '$_sym' IS declared, in:"
fail "! $_owner_hdr"
fail "! so the header is installed but this build did not see"
fail "! it -- an include path or a missing .pc file."
# /usr/include/<pkg>/<file>.h names the package often enough
# to be worth offering; a bare /usr/include/<file>.h does not.
local _hpkg="${_owner_hdr#${SNAP_ROOT%/}/usr/include/}"
case "$_hpkg" in
*/*) _hpkg="${_hpkg%%/*}"
fail "! pkg-config --cflags $_hpkg"
fail "! packagemanager info $_hpkg" ;;
esac
else
fail "! Nothing under /usr/include declares '$_sym', so the"
fail "! package that provides it is missing or out of date."
fail "! Find and rebuild it:"
fail "! packagemanager which-package $_sym"
fail "! grep -rl $_sym /usr/include # nothing = not installed"
fi
fi
fi
# "cannot find -lfoo" is the linker saying a DEPENDENCY did not leave
# its library behind -- the same species as "Program 'x' not found":
# a package the planner believes is installed, is not. Name the
# libraries and the cure, or the user goes looking inside the wrong
# package.
local _missing_libs="" _l _libname
while IFS= read -r _l; do
[ -n "$_l" ] || continue
_libname="lib${_l}"
# Only report the ones that really are absent. One glob per
# directory: `ls a* b* c*` fails when ANY pattern misses, so a
# library that IS installed in the first directory would still be
# reported missing because the other two do not have it.
# WHAT THE LINKER ACTUALLY NEEDS: libfoo.so (or libfoo.a),
# exactly. A glob for libfoo.so* matches libfoo.so.6.20.1 and
# calls the library present -- which is precisely the case that
# fails, since libfoo.so.N without libfoo.so is an install that
# never finished.
_found=0
for _d in /usr/lib /usr/lib64 /lib /lib64 /usr/local/lib; do
if [ -e "${SNAP_ROOT%/}$_d/$_libname.so" ] \
|| [ -e "${SNAP_ROOT%/}$_d/$_libname.a" ]; then
_found=1; break
fi
done
[ "$_found" = 1 ] || case " $_missing_libs " in
*" $_l "*) ;; *) _missing_libs="$_missing_libs $_l" ;;
esac
done < <(grep -oE "cannot find -l[A-Za-z0-9_.+-]+" "$log" 2>/dev/null \
| sed 's/^cannot find -l//' | sort -u)
if [ -n "$_missing_libs" ]; then
fail "!"
fail "! The linker could not find:$(for _l in $_missing_libs; do printf ' lib%s.so' "$_l"; done)"
# TWO DIFFERENT ILLNESSES, TWO DIFFERENT CURES.
#
# libfoo.so.N present, libfoo.so absent
# -> the package IS installed but its install did not finish:
# libfoo.so is the DEVELOPMENT symlink, made by the package's
# own `make install`. (libfoo.so.N can appear without it --
# ldconfig creates the SONAME link from any library it finds.)
# Rebuilding the owner is the cure; its record is not lying.
# nothing at all
# -> a dependency recorded as installed that is not there.
local _l _owner _real
for _l in $_missing_libs; do
_real=""
for _d in /usr/lib /usr/lib64 /lib /lib64 /usr/local/lib; do
set -- "${SNAP_ROOT%/}$_d/lib$_l".so.*
[ -e "$1" ] && { _real="$1"; break; }
done
if [ -n "$_real" ]; then
# The SONAME link (libfoo.so.1) is made by ldconfig, under
# whatever account happened to run it -- it blamed p_libpaper
# for fontconfig. Follow it to the real file, and prefer the
# manifest's answer to any ownership at all.
# WHO OWNS THE FILE beats who CLAIMS it. Asking the
# manifests first told the user to rebuild cairo for a
# fontconfig library, because a failed cairo round had swept
# the file into its manifest. The file's owner is a fact;
# a manifest entry is a claim, and claims have been wrong
# before (see the over-claiming manifests of 1.12.13).
_owner=""
[ -L "$_real" ] && _real="$(dirname "$_real")/$(readlink "$_real")"
_owner="$(stat -c %U "$_real" 2>/dev/null)"
case "$_owner" in
root|UNKNOWN|"")
_owner=""
if [ -d "$MANIFESTS" ]; then
_owner="$(grep -lxF "$_real" "$MANIFESTS"/*.files 2>/dev/null \
| head -n1 | xargs -r basename 2>/dev/null)"
_owner="${_owner%.files}"
fi ;;
esac
_owner="$(unprefix_pkg_user "$_owner")"
fail "! lib$_l: $(basename "$_real") exists, lib$_l.so does not."
fail "! The development symlink is made by the package's own"
fail "! install, so that install did not finish. Rebuild it:"
fail "! lfs-helper build ${_owner:-<pkg>} --phase all --force"
fail "! (from $(basename "$_real"); if that is the wrong"
fail "! package, ask: packagemanager search lib$_l)"
else
fail "! lib$_l: nothing on this system. A dependency recorded as"
fail "! installed is not -- the same species as a missing program:"
fail "! packagemanager search lib$_l"
fail "! lfs-helper verify # names every lying record"
fail "! rm /usr/src/pkgusr/p_<pkg>/pkg.lst /usr/src/pkgusr/p_<pkg>/VERSION"
fail "! packagemanager install <pkg> --run"
fi
done
fi
# THE WRONG BOOK, FOUND BY THE PACKAGE INSTEAD OF BY US.
#
# BlueZ stopped with
# configure: error: systemd system unit directory is required
# on a SysV system. Nothing is broken: the SYSTEMD book's page was
# used, and its configure line omits --disable-systemd because on a
# systemd machine there is nothing to disable. Every run already
# prints "no book matched 'stable-sysv'; using the cached
# BLFS-BOOK-...-systemd", and this is what that note costs when it is
# ignored -- so say it here, where it stopped, with both ways out.
if grep -qiE "systemd (system )?unit (directory|dir) is required|--enable-systemd|systemd.*is required" \
"$log" 2>/dev/null \
&& ! command -v systemctl >/dev/null 2>&1; then
fail "!"
fail "! This is a SysV system, and the page that produced this"
fail "! script came from the SYSTEMD book -- which never passes"
fail "! --disable-systemd, because on a systemd machine there is"
fail "! nothing to disable. Get the right book:"
fail "! blfs fetch stable-sysv"
fail "! packagemanager install $(unprefix_pkg_user "$name") --run --regenerate"
fail "! That fixes every later package too, not just this one."
fail "! If this machine cannot reach the network, disable it per"
fail "! package instead:"
fail "! /etc/pkgusr/stacks/machine.conf"
fail "! [$(unprefix_pkg_user "$name")]"
fail "! configure_args = --disable-systemd"
fi
# A PACKAGE THAT CHECKS ITS OWN FILE OWNERSHIP.
#
# fcron refuses to run unless its files belong to the accounts it
# expects:
# ERROR Conf file (/etc/fcron.conf) must be owned by root:fcron
# ERROR Could not chdir to /var/spool/fcron: Permission denied
# A package-user install cannot produce that: everything it writes is
# owned by p_fcron, and the chown wrapper skips the package's own
# attempts on purpose -- that skipping is the whole model. For a
# daemon that validates ownership before it will start, the last step
# is root's, and the package says exactly what it wants.
local _ownwant _ownfile
_ownwant="$(sed -n 's/.*must be owned by \([A-Za-z0-9_.:-]*\).*/\1/p' \
"$log" 2>/dev/null | head -1)"
_ownfile="$(sed -n 's/.*Conf file (\([^)]*\)).*must be owned.*/\1/p' \
"$log" 2>/dev/null | head -1)"
# ...and the SAME demand, one file at a time. fcron names only the
# file it tripped over: first /etc/fcron.conf, then /etc/fcron.allow,
# and /etc/fcron.deny would have been third. The package created a
# system group for itself (groupadd, in its own script), and every
# config file it installed under /etc wants root:<that group>. Say so
# once, for all of them, instead of one round per file.
if [ -z "$_ownwant" ] \
&& grep -qE "could not open [^:]*: Permission denied|Permission denied" \
"$log" 2>/dev/null; then
local _svcgrp=""
_svcgrp="$(grep -E "^[[:space:]]*groupadd[[:space:]]" "$s" 2>/dev/null \
| head -1 | sed 's/&&.*//' | awk '{print $NF}')"
if [ -n "$_svcgrp" ] && getent group "$_svcgrp" >/dev/null 2>&1; then
# from the MANIFEST, not from the log: the log names the one
# file it tripped over, and the package installed several
local _cfgs _mf
_mf="${SRCROOT:-/usr/src}/$(pkg_owner_name "$name")/pkg.lst"
_cfgs="$( { [ -f "$_mf" ] && grep -E "^/etc/" "$_mf" 2>/dev/null
grep -oE "/etc/[A-Za-z0-9_.+-]+" "$log" 2>/dev/null; } \
| sort -u | while IFS= read -r _f; do
[ -f "$_f" ] || continue
[ "$(stat -c %G "$_f" 2>/dev/null)" = "$_svcgrp" ] && continue
printf '%s ' "$_f"
done)"
if [ -n "$_cfgs" ]; then
fail "!"
fail "! '$(unprefix_pkg_user "$name")' runs under the group '$_svcgrp'"
fail "! (its own script creates it), and reads these as that group:"
fail "! chown root:$_svcgrp $_cfgs"
fail "! chmod 640 $_cfgs"
fail "! It reports ONE file at a time, so fix them together"
fail "! or you will be back here for the next one."
fi
fi
fi
if [ -n "$_ownwant" ]; then
fail "!"
fail "! This package CHECKS who owns its files, and a package-user"
fail "! install cannot satisfy that: everything it wrote belongs to"
fail "! $(pkg_owner_name "$name"), and the chown wrapper skips the package's own"
fail "! chown on purpose. Set it as root, once:"
[ -n "$_ownfile" ] && fail "! chown $_ownwant $_ownfile"
local _spool
_spool="$(sed -n 's/.*Could not chdir to \([^:]*\).*/\1/p' \
"$log" 2>/dev/null | head -1)"
[ -n "$_spool" ] && fail "! chown -R ${_ownwant#*:}:${_ownwant#*:} $_spool"
fail "! (the package's own page says which; these come from its"
fail "! error messages above)"
fail "! Then re-run this phase."
fi
# A CONFIGURATION STEP THAT NEEDS ROOT.
#
# fcron built, installed 74 files, and then its configure phase said
# must be privileged to use -u
# from `fcrontab -u systab`. BLFS's "Configuring" sections say "as
# the root user" -- they are system configuration, not part of
# installing a package, and no directory grant can help: the command
# itself refuses to run unprivileged. --as-root already exists for
# precisely this; say so.
if [ "${LAST_FAILED_PHASE:-$phase}" = configure ] \
&& grep -qiE "must be privileged|only root|Operation not permitted|are you root" \
"$log" 2>/dev/null; then
fail "!"
fail "! This is the CONFIGURATION step, and it needs root: the"
fail "! book's \"Configuring\" section is written for the root user,"
fail "! and no directory grant helps -- the command itself refuses"
fail "! to run unprivileged. The package is already installed;"
fail "! run just this step:"
fail "! lfs-helper build $(unprefix_pkg_user "$name") --phase configure --force --as-root"
fi
# A LIBRARY THAT WILL NOT LINK.
#
# alsa-utils found alsa-lib's headers and then:
# checking for snd_ctl_open in -lasound... no
# configure: error: No linkable libasound was found.
# Headers present but the library unlinkable is a different fault from
# a missing package, and the three ways it happens are told apart by
# what is on disk: no libfoo.so DEVELOPMENT symlink (the runtime
# .so.N is there but nothing to link against), no library at all, or
# both present but absent from the ldconfig cache.
local _lnklib=""
_lnklib="$(sed -n 's/.*No linkable lib\([A-Za-z0-9_+-]*\) was found.*/\1/p' \
"$log" 2>/dev/null | head -1)"
[ -n "$_lnklib" ] || _lnklib="$(sed -n 's/.*cannot find -l\([A-Za-z0-9_+-]*\).*/\1/p' \
"$log" 2>/dev/null | head -1)"
if [ -n "$_lnklib" ]; then
local _libdir="${SNAP_ROOT%/}/usr/lib"
local _dev="" _run=""
[ -e "$_libdir/lib$_lnklib.so" ] && _dev=1
ls "$_libdir/lib$_lnklib".so.* >/dev/null 2>&1 && _run=1
fail "!"
if [ -n "$_dev" ] && [ -n "$_run" ]; then
fail "! lib$_lnklib IS installed, and links should work."
fail "! The usual cause is a stale linker cache:"
fail "! ldconfig && ldconfig -p | grep $_lnklib"
fail "! If that lists it, look in the package's config.log for"
fail "! the REAL link error (a missing second library, or an"
fail "! ABI mismatch) -- configure only reports the last one."
elif [ -n "$_run" ]; then
fail "! lib$_lnklib.so.N is installed but lib$_lnklib.so is NOT --"
fail "! that symlink is what a build links against, and without"
fail "! it the library exists at run time and not at build time."
fail "! The package that owns it installed incompletely:"
fail "! ls -l $_libdir/lib$_lnklib.so*"
fail "! packagemanager which-package lib$_lnklib.so"
fail "! packagemanager install <that package> --run --reinstall"
else
# ASK THE MANIFESTS, AS 1.14.33 DOES FOR A MISSING PROGRAM.
#
# p_alsa-lib had a 1711-byte pkg.lst while /usr/lib had no
# libasound at all. "Nothing provides it" is true and stops
# one step short: SOMEBODY recorded installing it, and that
# package is the one to rebuild.
local _lclaim=""
_lclaim="$(timeout 20 grep -l -- "/lib$_lnklib\.so" \
"${SRCROOT:-/usr/src}"/*/pkg.lst 2>/dev/null | head -1)"
if [ -n "$_lclaim" ]; then
local _lpkg; _lpkg="$(basename "$(dirname "$_lclaim")")"
fail "! No lib$_lnklib on this system -- but '$_lpkg'"
fail "! RECORDS having installed it, so its record is wrong:"
fail "! grep $_lnklib $_lclaim"
fail "! packagemanager install $(unprefix_pkg_user "$_lpkg") --run --reinstall"
else
fail "! Nothing here provides lib$_lnklib at all, and no"
fail "! package records having installed it:"
fail "! packagemanager which-package lib$_lnklib.so"
fail "! packagemanager search $_lnklib"
fi
fi
fi
# A DEPENDENCY THE BUILD SYSTEM COULD NOT FIND.
#
# meson says only
# ERROR: Dependency "libdrm_intel" not found, tried pkgconfig and cmake
# and there are three quite different reasons, which the .pc files on
# disk tell apart. mesa hit the third: libdrm WAS installed, but built
# before libpciaccess existed, so it has no libdrm_intel.pc and never
# will until it is rebuilt. The book only RECOMMENDS Xorg-Libraries
# for libdrm, so nothing had ordered them.
local _dep _pcdir _pcbase
_dep="$(sed -n 's/.*ERROR: Dependency "\([A-Za-z0-9_.+-]*\)" not found.*/\1/p' \
"$log" 2>/dev/null | head -1)"
# meson has a SECOND wording, with single quotes, for a dependency the
# project marks required:
# ERROR: Dependency 'libtiff-4' is required but not found.
# gtk4 and gegl both stop with that one, and it matched none of the
# patterns here -- so the entire .pc diagnosis was skipped and only
# the fallback note (which was the wrong advice for gtk4) was printed.
[ -n "$_dep" ] || _dep="$(sed -n "s/.*ERROR: Dependency '\([A-Za-z0-9_.+-]*\)' is required but not found.*/\1/p" \
"$log" 2>/dev/null | head -1)"
# autoconf's own wording, which is NOT meson's and not pkg-config's:
# configure: error: Package requirements (libgvc >= 2.16) were not met:
# Package 'libgvc' not found
# vala stopped exactly there and matched neither pattern, so the whole
# diagnosis was skipped for every autotools package in the book.
[ -n "$_dep" ] || _dep="$(sed -n "s/.*No package '\([A-Za-z0-9_.+-]*\)' found.*/\1/p" \
"$log" 2>/dev/null | head -1)"
[ -n "$_dep" ] || _dep="$(sed -n "s/.*Package '\([A-Za-z0-9_.+-]*\)' not found.*/\1/p" \
"$log" 2>/dev/null | head -1)"
[ -n "$_dep" ] || _dep="$(sed -n 's/.*Package requirements (\([A-Za-z0-9_.+-]*\)[^)]*) were not met.*/\1/p' \
"$log" 2>/dev/null | head -1)"
local _dep_provider=""
if [ -n "$_dep" ]; then
# WHICH PACKAGE OWNS THIS MODULE NAME.
#
# gtk4 asked for `libtiff-4`; that is libtiff's pkg-config module,
# and libtiff is a BLFS package installed on that system. A
# pkg-config name often carries an API version the package name
# does not (libtiff-4, libxml-2.0, ncursesw6), so try the name as
# given and then with a trailing version dropped.
local _dcand
for _dcand in "$_dep" "${_dep%-[0-9]*}" "${_dep%%[0-9]*}"; do
[ -n "$_dcand" ] || continue
if user_exists "$(pkg_owner_name "$_dcand")" 2>/dev/null; then
_dep_provider="$_dcand"
break
fi
done
# NOT INSTALLED IS NOT THE SAME AS NOT A PACKAGE.
#
# gtk4 wanted `libtiff-4` on a system with no p_libtiff at all, so
# the account check found nothing and the box said "nothing
# provides it -- packagemanager install libtiff-4", naming a
# package the book does not have, and then offered to vendor it.
# A trailing API version that strips to a DIFFERENT name is the
# signature of a pkg-config module belonging to a real package:
# libtiff-4 -> libtiff libxml-2.0 -> libxml
# so name that instead, and let the fallback note stay quiet.
local _dep_guess=""
if [ -z "$_dep_provider" ]; then
_dep_guess="${_dep%-[0-9]*}"
[ "$_dep_guess" = "$_dep" ] && _dep_guess="${_dep%%[0-9]*}"
[ "$_dep_guess" = "$_dep" ] && _dep_guess=""
fi
fail "!"
if [ -n "$_dep_provider" ]; then
fail "! '$_dep' is the pkg-config name of '$_dep_provider',"
fail "! which is RECORDED as installed here."
# ASK THE MANIFEST, DO NOT GUESS.
#
# "If the .pc is absent, it installed without it" left the
# reader to find out which of two very different things had
# happened: a package that installed and then lost a file,
# or one that is recorded as installed and put nothing on
# disk at all (a build that failed after its account and
# home were made). The manifest says which, in one read.
local _php _plst _pn=0 _ppc=""
_php="$(pkgusr_home_for "$_dep_provider" 2>/dev/null)"
_plst="$_php/pkg.lst"
if [ -s "$_plst" ]; then
_pn="$(grep -c . "$_plst" 2>/dev/null || echo 0)"
_ppc="$(grep -E '\.pc$' "$_plst" 2>/dev/null | head -n3)"
fi
if [ "${_pn:-0}" -eq 0 ]; then
fail "! ...but its manifest is EMPTY: nothing of it is on"
fail "! disk. Its last build did not finish, and whatever"
fail "! marked it installed was wrong. Build it again:"
fail "! packagemanager install $_dep_provider --run --reinstall"
elif [ -z "$_ppc" ]; then
fail "! It records $_pn file(s), and NONE of them is a .pc"
fail "! -- so it installed without the pkg-config file this"
fail "! build needs. Rebuild it:"
fail "! packagemanager install $_dep_provider --run --reinstall"
else
fail "! It records $_pn file(s), including:"
printf '%s\n' "$_ppc" | while IFS= read -r _l; do
if [ -e "$_l" ]; then
fail "! $_l (on disk)"
else
fail "! $_l -- MISSING from disk"
fi
done
fail "! If the one this build wants is missing, rebuild it;"
fail "! if it is there, the build did not look where it is:"
fail "! pkg-config --exists --print-errors '$_dep'"
fail "! pkg-config --variable pc_path pkg-config"
fail "! packagemanager install $_dep_provider --run --reinstall"
fi
fi
_pcdir="$(timeout 10 find "${SNAP_ROOT%/}/usr/lib" "${SNAP_ROOT%/}/usr/share" \
-maxdepth 3 -name "$_dep.pc" 2>/dev/null | head -1)"
if [ -n "$_pcdir" ]; then
fail "! '$_dep.pc' EXISTS, at $_pcdir --"
fail "! so the build did not look where it is. Check:"
fail "! pkg-config --variable pc_path pkg-config"
fail "! PKG_CONFIG_PATH in /etc/pkgusr/build.env"
else
# libdrm_intel -> libdrm: the provider is usually the name up
# to the first separator, and its .pc being present while the
# asked-for one is not is the tell.
_pcbase="${_dep%%[-_.]*}"
if [ -n "$_pcbase" ] && [ "$_pcbase" != "$_dep" ] \
&& timeout 10 find "${SNAP_ROOT%/}/usr/lib" -maxdepth 3 \
-name "$_pcbase.pc" 2>/dev/null | grep -q .; then
fail "! '$_dep' is missing, but '$_pcbase' IS installed."
fail "! So $_pcbase was built WITHOUT the part that provides"
fail "! it -- usually because one of ITS dependencies was not"
fail "! installed at the time. Rebuild it, then this:"
fail "! packagemanager install $_pcbase --run --recursive --reinstall"
elif [ -n "$_dep_guess" ]; then
fail "! Nothing here provides '$_dep', and no package of"
fail "! that name exists -- it is the pkg-config module of"
fail "! '$_dep_guess', which is NOT installed:"
fail "! packagemanager install $_dep_guess --run --recursive"
fail "! (if that name is wrong: packagemanager search $_dep_guess)"
elif [ -z "$_dep_provider" ]; then
# (silent when the provider is known -- saying both
# "libtiff is installed" and "nothing provides it" in one
# box is worse than saying neither)
fail "! Nothing on this system provides '$_dep'."
fail "! packagemanager which-package $_dep"
fail "! packagemanager install $_dep --run --recursive"
fi
fi
fi
# A FALLBACK *WE* DISABLED.
#
# blfs adds --wrap-mode=nofallback to every meson setup, so a package
# cannot silently vendor a dependency (cairo once installed its own
# freetype that way). When the subproject has no BLFS page -- gegl
# needs libnsgif and poly2tri-c, which the book does not carry -- that
# protection is what breaks the build, and meson's message says only
# "Use of fallback dependencies is disabled", never who disabled it.
# ...and ONLY when nothing on this system could have supplied it.
# gtk4 wanted `libtiff-4`, which is the pkg-config module of libtiff --
# a BLFS package, installed on that system. Telling the person to
# allow a vendored copy would have built a second libtiff inside gtk4
# and hidden a pkg-config problem. `_dep_provider` (set above) is the
# package the book has for it; when there is one, the advice above is
# the right advice and this must stay quiet.
if grep -q "Use of fallback dependencies is disabled" "$log" 2>/dev/null \
&& [ -z "${_dep_provider:-}" ] && [ -z "${_dep_guess:-}" ]; then
local _fbdep
_fbdep="$(sed -n "s/.*ERROR: Dependency '\([A-Za-z0-9_.+-]*\)' is required but not found.*/\1/p" \
"$log" 2>/dev/null | head -1)"
fail "!"
fail "! The fallback that would have supplied${_fbdep:+ '$_fbdep'} is"
fail "! disabled BY US: blfs adds --wrap-mode=nofallback so a package"
fail "! cannot quietly install its own copy of a library another"
fail "! package owns. When the subproject has no BLFS page, that is"
fail "! the only way to build it. Allow it for this package:"
fail "! /etc/pkgusr/stacks/machine.conf"
fail "! [$(unprefix_pkg_user "$name")]"
fail "! wrap_mode = default"
fail "! then: packagemanager install $(unprefix_pkg_user "$name") --run --regenerate"
fi
# A FEATURE MISSING FROM AN INSTALLED PACKAGE.
#
# pango died on "No Cairo font backends found" with cairo installed
# and working -- because cairo had been built BEFORE freetype2
# existed (an old ordering) and silently has no ft backend. The
# package is fine; the feature is not. Nothing detects that, so say
# what it looks like when a `<pkg>-<feature> found: NO` sits next to
# a `<pkg> found: YES`.
local _featpkg
_featpkg="$(grep -oE "dependency [a-z0-9]+-[a-z0-9]+ found: NO" "$log" 2>/dev/null \
| head -n1 | awk '{print $2}' | cut -d- -f1)"
if [ -n "$_featpkg" ] && \
grep -qE "dependency ${_featpkg} found: YES" "$log" 2>/dev/null; then
fail "!"
fail "! '$_featpkg' is installed, but the feature this package needs"
fail "! is missing from it -- it was probably built before its own"
fail "! optional dependency existed. Rebuild it, then retry:"
fail "! packagemanager install $_featpkg --run --yes --reinstall"
fi
# A VARIABLE THE BUILD ASKED FOR. The generated script stops with
# "this package needs NAME to be set", which says WHAT but not HOW --
# and the answer is one command, so print it.
local _envname
_envname="$(grep -oE "needs [A-Z][A-Z0-9_]{2,} to be set" "$log" 2>/dev/null \
| head -n1 | awk '{print $2}')"
if [ -n "$_envname" ]; then
fail "!"
fail "! $_envname is not set for builds. Set it once and it applies to"
fail "! every package (builds run as a package user, so exporting it in"
fail "! your shell does not reach them):"
fail "! packagemanager env --set $_envname=... # or: packagemanager env --ask"
fi
# A PYTHON MODULE THE BUILD NEEDED.
#
# pip prints a whole traceback and ends with "metadata-generation-
# failed", and the log scanner's first-errors picked exactly the two
# lines that say nothing. The name is in the traceback:
# ModuleNotFoundError: No module named 'pathspec'
# and it is almost always a BLFS package of the same name.
local _pymod
_pymod="$(sed -n "s/.*ModuleNotFoundError: No module named '\([A-Za-z0-9_.-]*\)'.*/\1/p" \
"$log" 2>/dev/null | head -1)"
if [ -n "$_pymod" ]; then
fail "!"
fail "! The build needs the Python module '$_pymod', which is not"
fail "! installed for the Python this system runs."
fail "! packagemanager install $_pymod --run --recursive"
fail "! If that says it is already installed, it was built for an"
fail "! older Python -- check with:"
fail "! python3 -c 'import $_pymod'"
fail "! packagemanager info $_pymod"
fi
local _missing_prog
_missing_prog="$(grep -oE "Program '[A-Za-z0-9._+-]+' not found" "$log" 2>/dev/null \
| head -n1 | cut -d"'" -f2)"
if [ -n "$_missing_prog" ] && ! command -v "$_missing_prog" >/dev/null 2>&1; then
fail "!"
fail "! '$_missing_prog' does not exist on this system."
# THE MANIFESTS KNOW WHO CLAIMED IT.
#
# This said "find the package, purge its record, rebuild it" and left
# the person to find it -- when every installed package has a pkg.lst
# naming the files it installed, and a grep answers in a second. Two
# quite different situations hide behind one missing program:
# somebody's record CLAIMS the file (the record is wrong), or nobody
# does (the package was never installed).
local _claimer=""
# ${SRCROOT:-} -- a failure report runs under `set -u` and must not
# add a second failure to the one it is describing
_claimer="$(timeout 20 grep -l -- "/$_missing_prog\$" \
"${SRCROOT:-/usr/src}"/*/pkg.lst 2>/dev/null | head -1)"
if [ -n "$_claimer" ]; then
local _cpkg; _cpkg="$(basename "$(dirname "$_claimer")")"
fail "! But '$_cpkg' RECORDS having installed it, so its record is"
fail "! wrong -- the build failed after the record was written, or"
fail "! the file was removed since. Rebuild it:"
fail "! packagemanager install $(unprefix_pkg_user "$_cpkg") --run --reinstall"
else
fail "! No installed package records that file, so whatever"
fail "! provides it was never installed here. Find it:"
fail "! packagemanager search $_missing_prog"
fail "! packagemanager install <pkg> --run --recursive"
fi
fi
# THE RETRY HINT MUST BE A COMMAND THAT WORKS.
#
# `lfs-helper build` takes the ACCOUNT; the box printed whatever name
# it was called with, and for a BLFS package that is the anchor:
# lfs-helper build gst10-plugins-bad --phase all --force
# !! no script for 'gst10-plugins-bad' at .../scripts/...sh
# -- so it fell through to the LFS base-step path and blamed step
# scripts from 1.12.30, which have nothing to do with the failure.
# Five rounds of debugging ran past this because the hint looked
# plausible.
#
# RESOLVED HERE, ONCE, FOR EVERY HINT BELOW. It used to be computed
# inside the permission-failure branch and used by three hints outside
# it, so any OTHER kind of failure ended the report with
# /usr/bin/lfs-helper: line 5596: _retry: unbound variable
# -- the error reporter crashing on the one line that tells you how to
# retry, in the middle of reporting someone else's error.
local _retry="$name"
if ! id "$_retry" >/dev/null 2>&1; then
local _acctname; _acctname="$(pkg_owner_name "$name" 2>/dev/null)"
id "$_acctname" >/dev/null 2>&1 && _retry="$_acctname"
fi
# A MISSING COMMAND IS A MISSING PACKAGE.
#
# install_libheif: line 33: cmake: command not found
# ! auto-repair: gate: did not recognise this failure
# -- as recognisable a failure as there is, reported as unrecognised.
# The BOOK does not always list the tool: libheif's page uses cmake in
# its commands and lists no Required section at all, so no resolver
# can plan it from the dependency lists. The log says it outright.
# A SHELL WORD IS NOT A PROGRAM.
#
# enchant's configure has a broken probe that runs the word `not`:
# ./configure: line 26491: not: command not found
# and the box announced "'not' is not installed -- the build needs it",
# sending the reader to `packagemanager search not`. The line was also
# harmless -- configure carried on -- while the REAL failure was fifty
# lines further down. Shell keywords and a handful of English words
# are never packages; skip them and take the next candidate.
local _missing_cmd=""
local _cand
while IFS= read -r _cand; do
case "$_cand" in
not|then|else|fi|do|done|elif|esac|in|the|and|or|is|if|"") continue ;;
esac
_missing_cmd="$_cand"
break
done <<EOF
$(sed -n 's/.*: \([a-zA-Z0-9_.+-]*\): command not found.*/\1/p' "$log" 2>/dev/null)
EOF
if [ -n "$_missing_cmd" ]; then
local _prov; _prov="$(_package_providing "$_missing_cmd")"
fail "! '$_missing_cmd' is not installed -- the build needs it and"
fail "! the book's page for $name does not list it as a dependency."
if [ -n "$_prov" ]; then
fail "! packagemanager install $_prov --run --recursive"
else
fail "! find what provides it: packagemanager search $_missing_cmd"
fi
fail "! Then retry:"
fail "! lfs-helper build $_retry --phase $phase --force"
fi
if grep -qiE "permission denied|couldn't copy|cannot create|cannot remove" \
"$log" 2>/dev/null; then
fail "! The package could not write into a directory owned by another"
fail "! package. Grant access (collector groups) and retry:"
fail "! lfs-helper fix-perms $name --phase $phase --run"
fail "! lfs-helper build $_retry --phase $phase --force"
fi
# A later phase often fails because an EARLIER one never finished --
# gcc's `mv /usr/lib/*gdb.py` finds nothing when `make install` aborted
# partway (e.g. on a permission error) and never installed them.
if [ "$phase" != "all" ] && [ "$phase" != "unpack" ]; then
local _prev _missing=""
for _prev in unpack build install; do
[ "$_prev" = "$phase" ] && break
phase_done "$name" "$_prev" || _missing="$_missing $_prev"
done
if [ -n "$_missing" ]; then
fail "! NOTE: these earlier phases have not completed successfully for"
fail "! this package:$_missing"
fail "! A later phase can fail simply because an earlier one"
fail "! stopped partway and never installed everything. Run:"
for _prev in $_missing; do
fail "! lfs-helper build $name --phase $_prev --force"
done
fi
fi
fail "! Edit the script, then retry just this phase:"
fail "! lfs-helper build $_retry --phase $phase --force"
if [ -z "$jobs" ] && [ "$phase" != "install" ]; then
fail "! If the output ends with no obvious error, a parallel build may"
fail "! have raced -- the real message is far above the tail. Retry"
fail "! serially (slower, but the error appears where it happens):"
fail "! lfs-helper build $_retry --phase $phase --force --jobs 1"
fi
fail "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!"
fail ""
show_build_errors "$log"
diagnose_paths_from_log "$log" "$owner"
exit "$rc"
fi
# A build that installs NOTHING has almost certainly failed quietly. pip
# in particular can report success while writing nowhere useful -- flit-core
# "succeeded" without ever putting flit_core into site-packages, and the
# next package then failed with a baffling
# BackendUnavailable: Cannot import 'flit_core.buildapi'
# Say so loudly rather than marking the step done and moving on.
# grep -c prints 0 AND exits non-zero on no match, so a `|| echo 0` fallback
# produces "0\n0" and breaks the numeric test. Count with wc instead.
local _tracked; _tracked=$(count_lines "$man")
if [ "$_tracked" -eq 0 ] && [ "$_nothing_to_do" = 0 ] \
&& [ "$phase" != "unpack" ] && [ "$phase" != "test" ]; then
# A CONFIGURATION step legitimately installs no new files. cfg_ld
# rewrites /etc/ld.so.conf -- which glibc already created, and still
# owns -- and makes one directory; nothing NEW appears, so the count
# is 0 and the step looked like a silent failure:
# mkdir: created directory '/etc/ld.so.conf.d'
# !! cfg_ld reported success but installed NO files.
# That is the normal shape of a config step, not a fault. Say what
# happened and move on; keep the hard failure for packages, where
# installing nothing really does mean the build did nothing.
if _is_not_a_package "$name"; then
detail "# $name installed no new files -- normal for a"
detail "# configuration step: it writes files another package"
detail "# already owns. Log: $log"
elif _package_already_owns_files "$owner"; then
# A RE-RUN over its own work is not a silent failure.
#
# Many installers skip a file that is already up to date -- perl's
# ExtUtils::Install prints "Installing <path>" for every target and
# then copies only the stale ones. So a second run of a package
# that already installed correctly writes nothing, the snapshot
# diff sees nothing new, no mtime is newer than the stamp, and the
# count is 0 for the best possible reason.
#
# xml-parser hit exactly this: forty files on disk, every one owned
# by p_xml-parser, and the step failed as a no-op. Ask the tree
# rather than the counters -- if this package already owns files,
# the install worked, whenever it happened.
ok "# $name installed no NEW files, but already owns $_pkg_owned_n"
ok "# file(s) on disk -- a re-run over its own work, not a failure."
detail "# Log: $log"
else
warn ""
warn "!! $name reported success but installed NO files."
warn " That normally means the install silently did nothing."
warn ""
# Show the working. There are exactly three ways to reach zero,
# and they need different fixes -- saying only "check the log"
# meant reading a thousand lines of make output to find out which.
local _nf _tc _kept
_nf="$(printf '%s\n' "$newfiles" | grep -cv '^$')"
_tc="$(printf '%s\n' "$touched" | grep -cv '^$')"
_kept="$(count_lines "$man")"
warn " new files (snapshot diff) : $_nf"
warn " files touched since stamp : $_tc"
warn " kept after ownership filter: $_kept"
if [ "$_nf" = 0 ] && [ "$_tc" = 0 ]; then
warn " -> the build wrote nothing this tool could see."
warn " Either the install really did nothing, or it wrote"
warn " somewhere excluded from tracking: $SRCROOT, /sources,"
warn " ${BUILD_ROOT:-/build}, /tmp."
else
warn " -> $((_nf + _tc)) path(s) were seen and then dropped,"
warn " because each already belongs to another package."
warn " That is a re-install over someone else's files."
warn " Which package: lfs-helper which-package <path>"
fi
warn ""
warn " Full log: $log"
warn " The step is NOT marked as built."
warn ""
return 1
fi
fi
# Record what is installed, in the package user's own home: VERSION and
# .pkgusr-installed, both in _record_installed (below), for the branch
# that finished every phase.
if [ "$phase" = "all" ] || [ "$phase" = "update" ]; then
_write_pkgusr_info "$name" "$owner" "${LFS_PKG_SOURCE:-lfs}" \
"$(_version_from_script "$staged")"
_record_installed "$name" "$owner" "$s"
mark_done "$name"
ok "# $name: done"
report_granted_groups
trap - INT TERM
_clear_interrupted "$name"
else
# the runner recorded the phase; what stays here is the guidance
local nxt; nxt="$(next_phase_for "$name" || true)"
if [ -n "$nxt" ]; then
ok "# $name: phase '$phase' finished"
say " still to do: $nxt -> lfs-helper build $name --phase $nxt"
else
# every phase the script defines has now run
_write_pkgusr_info "$name" "$owner" "${LFS_PKG_SOURCE:-lfs}" \
"$(_version_from_script "$staged")"
mark_done "$name"
# This build already gave its files to the package user, so there
# is nothing for the adoption pass to do. Recording it here is
# what stops "Give the already-built packages their own users"
# running again on every build-all.
[ -f "$MANIFESTS/$name.files" ] && \
_mark_adopted "$name" "$(_manifest_size "$MANIFESTS/$name.files")"
_record_installed "$name" "$owner" "$s"
ok "# $name: done (all phases complete)"
# AND WHAT IT INSTALLED ON THE WAY, on the SUCCESS path too: the
# whole point of reporting it is that the run changed more than
# the package that was asked for, and a successful run is
# exactly when nobody goes looking.
if [ -n "${_AUTO_MODULES_LOG:-}" ] && [ -s "$_AUTO_MODULES_LOG" ]; then
say "# installed on the way (--auto-modules), not asked for:"
while IFS= read -r _am; do say "# $_am"; done \
< "$_AUTO_MODULES_LOG"
say "# they are real package users now: packagemanager info <name>"
fi
report_granted_groups
fi
fi
# finished one way or another: no longer mid-install
trap - INT TERM
_clear_interrupted "$name"
}
# Tell the user which collector groups this run had to create. These are real
# changes to the system's group database, so they should never be silent.
report_granted_groups() {
[ -f "$GRANTED" ] || return 0
local n; n=$(count_lines "$GRANTED")
[ "$n" -gt 0 ] || return 0
say ""
note "Collector groups this build needed:"
local grp user owner
while IFS='|' read -r grp user owner; do
[ -n "$grp" ] || continue
printf " %-24s lets '%s' install into '%s' directories\n" \
"$grp" "$user" "$owner"
done < "$GRANTED"
say ""
say " These are permanent: the directory keeps its owner, but the group"
say " owns it group-writable so both packages can install there."
say " Review with: getent group | grep '^${COLLECTOR_PREFIX}_'"
: > "$GRANTED"
}
# Is there a recorded package that has no package user yet? Cheap enough to
# check on every build-all, and it keeps the adoption pass quiet once done.
_has_unadopted_packages() {
local man
for man in "$MANIFESTS"/*.files; do
[ -e "$man" ] || continue
_needs_adoption "$man" && return 0
done
return 1
}
# Which packages have already been adopted, and at what manifest size.
#
# A whole-manifest signature was useless: every build writes a manifest, so it
# changed constantly and adoption ran every time. Recording "name size" per
# package means a package is re-adopted only when ITS OWN manifest grows --
# which is exactly when it has new files to hand over.
_ADOPTED_LIST() { echo "$STATE_PROGRESS/adopted.list"; }
_manifest_size() {
wc -c < "$1" 2>/dev/null | tr -d ' ' || echo 0
}
_adopted_size() {
local name="$1" f
f="$(_ADOPTED_LIST)"
[ -f "$f" ] || { echo ""; return; }
awk -v n="$name" '$1 == n { print $2; found=1 } END { if (!found) print "" }' \
"$f" | head -n1
}
_mark_adopted() {
local name="$1" size="$2" f tmp
f="$(_ADOPTED_LIST)"
mkdir -p "$(dirname "$f")" 2>/dev/null || true
tmp="$f.$$"
# Errors here used to be swallowed twice over -- 2>/dev/null on the write
# AND `|| rm -f` on the move -- so a state directory that was missing or
# read-only looked exactly like a successful record, and the adoption pass
# announced itself on every build-all with nothing on disk to say why.
if ! { [ -f "$f" ] && grep -v "^$name " "$f"; echo "$name $size"; } > "$tmp" 2>/dev/null
then
rm -f "$tmp"; warn "could not write $f (adoption will run again)"; return 1
fi
mv -f "$tmp" "$f" 2>/dev/null || {
rm -f "$tmp"; warn "could not update $f (adoption will run again)"; return 1; }
return 0
}
# A package needs adopting if the pass has not run for its manifest at this
# size.
#
# Record that every package has been through the pass.
#
# It used to record only packages whose user EXISTS -- while _needs_adoption
# reported "needs adopting" for precisely the packages whose user does NOT.
# The two conditions were opposites, so a single manifest without a user (a
# name that is not a package, a user creation that failed) could never be
# recorded and kept _has_unadopted_packages at yes forever. That is the
# "re-announces on every build-all" symptom.
#
# The gate asks "has the pass run for this?", not "did it succeed?". A
# package whose user could not be created answers that the same way as one
# whose user exists: yes, and running it again changes nothing. The same
# principle _needs_adoption already applies to files that cannot be
# reassigned -- tried is tried.
_mark_all_adopted() {
local man name
for man in "$MANIFESTS"/*.files; do
[ -s "$man" ] || continue
name="$(basename "${man%.files}")"
_is_not_a_package "$name" && continue
_mark_adopted "$name" "$(_manifest_size "$man")"
done
}
_needs_adoption() {
local man="$1" name size
[ -s "$man" ] || return 1
# A package that has been TRIED and whose user exists is done, even if some
# of its files could not be reassigned (they belong to another package, or
# no longer exist). Without this the pass reported "the user database
# exists now ..." on every single build-all forever, because one
# unfixable path kept the whole set looking unadopted.
name="$(basename "${man%.files}")"
_is_not_a_package "$name" && return 1
# Adoption exists for ONE thing: packages built before the chroot, whose
# files were recorded but whose ownership could not be assigned at the time
# because no package users existed yet.
#
# The test is whether lfs-helper has BUILT it here -- not whether it
# appears in the step list. Several packages appear in BOTH chapter 6 and
# chapter 8 (ncurses, bash, coreutils, binutils, gcc ...). Skipping
# everything in the step list left their chapter-6 files owned by 'lfs',
# and the chapter-8 build could then not overwrite its own files:
# cp: cannot create regular file '/usr/bin/tic': Permission denied
# /usr/bin/tic -rwxr-xr-x lfs:lfs
# Built here => ownership is already right. Not built here => the manifest
# came from chapters 5-6 and still needs adopting.
if is_done "$name"; then
return 1
fi
# ONE rule, and _mark_all_adopted uses the same one: the pass is needed if
# it has not run for this manifest at this size.
#
# There used to be an extra `user_exists || return 0` here, which is the
# exact case the recorder skipped -- so the two could never agree about a
# package without a user, and the gate stayed open forever. It is also
# redundant: a package that has never been through the pass has no recorded
# size either, so the comparison below already answers "yes".
size="$(_manifest_size "$man")"
[ "$(_adopted_size "$name")" = "$size" ] && return 1
return 0
}
# Give one user access to one directory, using the normal rules.
# The wrappers, on demand.
#
# `packagemanager_install` shipped its own 480-line copy and wrote it to a
# fresh /tmp directory on every run -- a second set of wrappers, with its own
# idea of what `chown` should do, for the same job. This is the door it asks
# through instead. Idempotent: writing them again is how a fixed wrapper
# reaches a system that already has the old one.
cmd_make_wrappers() {
need_root
local run=0
case "${1:-}" in --run) run=1 ;; esac
if [ "$run" != 1 ]; then
say "# would write the package-user wrappers to $WRAPPERS"
say "# $(cd "$WRAPPERS" 2>/dev/null && echo "currently: $(ls | tr '\n' ' ')")"
say "# apply with: lfs-helper make-wrappers --run"
return 0
fi
make_wrappers
ok "wrappers written to $WRAPPERS"
printf '%s\n' "$WRAPPERS"
}
# Where they are, for a caller that only needs the path.
cmd_wrapper_dir() {
printf '%s\n' "$WRAPPERS"
}
cmd_grant_dir() {
need_root
local dir="${1:?usage: lfs-helper grant-dir <dir> <user> [--run]}"
local user="${2:?usage: lfs-helper grant-dir <dir> <user> [--run]}"
shift 2
local run=0
[ "${1:-}" = "--run" ] && run=1
[ -d "$dir" ] || die "no such directory: $dir"
user_exists "$user" || die "no such user: $user"
if su -s /bin/bash "$user" -c "test -w '$dir'" 2>/dev/null; then
say "'$user' can already write into $dir"
return 0
fi
if [ "$run" != 1 ]; then
say "'$user' cannot write into $dir"
say " owner: $(stat -c '%U:%G %A' "$dir" 2>/dev/null)"
warn "(dry run -- nothing was changed. Add --run to grant access.)"
return 0
fi
grant_dir_access "$dir" "$user"
}
# A collector group created under the wrong name.
#
# The prompt asks for a group name and takes what is typed; someone answering
# it wrote `nimnu_gdb` for `nimgnu_gdb`, and a valid-but-wrong group owned the
# directory from then on -- invisible to everything that looks for
# `${COLLECTOR_PREFIX}_*`. Renaming is the whole repair: the GID does not
# change, so every directory already handed to it and every member still
# apply. 1.14.19 makes the typo far less likely; this fixes the ones already
# made.
cmd_rename_group() {
need_root
local old="${1:?usage: lfs-helper rename-group <old> <new> [--run]}"
local new="${2:?usage: lfs-helper rename-group <old> <new> [--run]}"
shift 2
local run=0
[ "${1:-}" = "--run" ] && run=1
group_exists "$old" || die "no such group: $old"
group_exists "$new" && die "'$new' already exists -- pick another name"
local gid dirs
gid="$(getent group "$old" | cut -d: -f3)"
say "$old (gid $gid) -> $new"
dirs="$(find "${SNAP_ROOT%/}/" -xdev -group "$old" -type d 2>/dev/null | head -20)"
if [ -n "$dirs" ]; then
say " directories it owns (gid is unchanged, so these stay granted):"
printf ' %s\n' $dirs
fi
local members; members="$(getent group "$old" | cut -d: -f4)"
[ -n "$members" ] && say " members: $members"
if [ "$run" != 1 ]; then
warn "(dry run -- nothing was changed. Add --run to rename.)"
return 0
fi
"$(_real_tool groupmod)" -n "$new" "$old" || die "could not rename $old"
ok "renamed: $old -> $new"
}
# REGROUP ONE DIRECTORY.
#
# /etc/pam.d p_bash-completion:nimgnu_bash-completion drwxrwxr-x
#
# A directory granted through the wrong group -- here one named after a
# package that has nothing to do with pam -- cannot be fixed with chgrp
# alone: every member of the old group keeps write access to it, and the
# packages that DO install there have to join the new one. So:
#
# 1. who installs INTO it: every package whose manifest or pkg.lst lists a
# direct child, every account owning an entry in it, and its owner
# 2. the new group's name, asked the way a grant asks (owner-named,
# directory-named, or typed)
# 3. which members of the OLD group still need it: those installing into
# another directory that keeps the old group (or owning one)
# 4. the plan, then yes/no -- nothing changes before the answer
#
# The old group itself is left as it is (even when empty): deleting a group
# is a separate decision, and the plan says when it has become unused.
cmd_regroup_dir() {
need_root
local dir="${1:?usage: lfs-helper regroup-dir <directory>}"
dir="$(readlink -f "$dir")" || die "no such directory: $1"
[ -d "$dir" ] || die "not a directory: $dir"
local owner old
owner="$(stat -c %U "$dir")"; old="$(stat -c %G "$dir")"
[ "$owner" = root ] && die "$dir is owned by root -- that is not a package's directory to share"
say "$dir ($(stat -c '%A %U:%G' "$dir"))"
# 1. installers into THIS directory (direct children only)
local installers
installers="$(
{ for man in "$MANIFESTS"/*.files; do
[ -f "$man" ] || continue
if awk -v d="$dir" '{ p = $0; sub(/\/[^\/]*$/, "", p); if (p == d) { f = 1; exit } } END { exit !f }' "$man"; then
pkg_owner_name "$(basename "${man%.files}")"
fi
done
for _r in "$PKGUSR_ROOT" "$CFGUSR_ROOT"; do
[ -d "$_r" ] || continue
for _l in "$_r"/*/pkg.lst; do
[ -f "$_l" ] || continue
awk -v d="$dir" '{ p = $0; sub(/\/[^\/]*$/, "", p); if (p == d) { f = 1; exit } } END { exit !f }' "$_l" \
&& basename "$(dirname "$_l")"
done
done
find "$dir" -mindepth 1 -maxdepth 1 -printf '%u\n' 2>/dev/null
echo "$owner"
} | while IFS= read -r _u; do
[ -n "$_u" ] || continue
# package and config accounts only: never root, a login or a u_ account
case "$_u" in
"${PKGUSR_PREFIX:-p_}"?*|"${CFGUSR_PREFIX:-cfg_}"?*) ;;
*) continue ;;
esac
getent passwd "$_u" >/dev/null 2>&1 && echo "$_u"
done | sort -u)"
# 2. the new name
local by_owner by_dir new ans
by_owner="$(sanitise_group_name "$(collector_group_for "$owner")")"
by_dir="$(sanitise_group_name "${COLLECTOR_PREFIX}_$(basename "$dir")")"
say ""
say " installs into it: $(echo $installers)"
say " current group: $old (members: $(getent group "$old" | cut -d: -f4))"
say ""
say " New group for $dir:"
say " 1) ${C_B}${by_dir}${C_OFF} (named after the directory)"
say " 2) ${C_B}${by_owner}${C_OFF} (named after its owner)"
say " or type another name, or q to cancel"
printf ' [1/2/name/q] (default 1): ' > /dev/tty
read -e -r ans < /dev/tty || ans=q
case "$ans" in
""|1) new="$by_dir" ;;
2) new="$by_owner" ;;
q|Q) say "cancelled -- nothing changed"; return 0 ;;
*) new="$(sanitise_group_name "$ans")"
[ -n "$new" ] || die "'$ans' is not a usable group name -- nothing changed" ;;
esac
[ "$new" = "$old" ] && { say "$dir already has group $new -- nothing to do"; return 0; }
# 3. who still needs the OLD group, for the directories that keep it
local others keep="" drop="" m d2 needs
# EVERY real filesystem, not just /'s: a directory of $old on another
# mount that is not seen here would cost its installers their membership.
local _mnts
_mnts="$(findmnt -rno TARGET -t ext2,ext3,ext4,xfs,btrfs,f2fs,jfs,reiserfs 2>/dev/null | tr '\n' ' ')"
[ -n "$_mnts" ] || _mnts="/"
others="$(find $_mnts -xdev \( -path "$SRCROOT" -o -path /proc -o -path /sys -o -path /dev \
-o -path /run -o -path /tmp \) -prune \
-o -type d -group "$old" -print 2>/dev/null | sort -u | grep -vxF "$dir")"
for m in $(getent group "$old" | cut -d: -f4 | tr ',' ' '); do
needs=""
while IFS= read -r d2; do
[ -n "$d2" ] || continue
if [ "$(stat -c %U "$d2")" = "$m" ] \
|| find "$d2" -mindepth 1 -maxdepth 1 -user "$m" -print -quit 2>/dev/null | grep -q .; then
needs="$d2"; break
fi
for man in "$MANIFESTS/$(unprefix_pkg_user "$m").files" "$PKGUSR_ROOT/$m/pkg.lst" "$CFGUSR_ROOT/$m/pkg.lst"; do
[ -f "$man" ] || continue
if awk -v d="$d2" '{ p = $0; sub(/\/[^\/]*$/, "", p); if (p == d) { f = 1; exit } } END { exit !f }' "$man"; then
needs="$d2"; break 2
fi
done
done <<< "$others"
if [ -n "$needs" ]; then keep="$keep $m"; else drop="$drop $m"; fi
done
# 4. the plan
say ""
say " PLAN"
say " chgrp $new $dir (mode g+rwx kept for the installers)"
group_exists "$new" && say " group $new exists (gid $(getent group "$new" | cut -d: -f3))" \
|| say " CREATE group $new (collector range)"
say " add to $new: $(echo $installers)"
[ -n "$drop" ] && say " remove from $old: $(echo $drop) (no other directory of $old is theirs)"
[ -n "$keep" ] && say " stay in $old: $(echo $keep) (they still install into a directory of $old)"
[ -z "$others" ] && say " $old is then on no directory at all -- delete it yourself if unused: groupdel $old"
printf ' apply? [y/N]: ' > /dev/tty
read -e -r ans < /dev/tty || ans=n
case "$ans" in [Yy]*) ;; *) say "cancelled -- nothing changed"; return 0 ;; esac
ensure_group "$new" || die "could not create group $new -- nothing changed"
for m in $installers; do add_to_group "$m" "$new" || warn "could not add $m to $new"; done
real_chgrp "$new" "$dir" || die "chgrp $new $dir failed"
real_chmod g+rwx "$dir" 2>/dev/null
for m in $drop; do
gpasswd -d "$m" "$old" >/dev/null 2>&1 || warn "could not remove $m from $old"
done
# the records a later grant consults
local rel="${dir#${SNAP_ROOT%/}}"
if [ -f "$COLLECTOR_MAP" ]; then
grep -v "^$rel|" "$COLLECTOR_MAP" > "$COLLECTOR_MAP.tmp" 2>/dev/null; mv "$COLLECTOR_MAP.tmp" "$COLLECTOR_MAP"
fi
mkdir -p "$(dirname "$COLLECTOR_MAP")"; echo "$rel|$new" >> "$COLLECTOR_MAP"
for m in $installers; do
grep -qxF "$new|$m|$owner" "$GRANTED" 2>/dev/null || echo "$new|$m|$owner" >> "$GRANTED"
done
say ""
ok "$dir is now $(stat -c '%A %U:%G' "$dir")"
say " $new: $(getent group "$new" | cut -d: -f4)"
say " $old: $(getent group "$old" | cut -d: -f4)"
say " (membership changes apply to NEW logins and builds; a running shell keeps its groups)"
}
# Which package installed this path?
#
# The manifests are the record of who installed what, so a file that should
# not exist can be traced to the step that created it.
cmd_which_package() {
local target="${1:?usage: lfs-helper which-package <path>}"
local man name hits=0 needle needle2
needle="$target"
needle2="$(strip_host_prefix "$target")"
say "Searching the manifests for: $needle"
say ""
for man in "$MANIFESTS"/*.files "$MANIFESTS"/*.dirs; do
[ -e "$man" ] || continue
if grep -qxF "$needle" "$man" 2>/dev/null \
|| grep -qxF "$needle2" "$man" 2>/dev/null; then
name="$(basename "$man")"; name="${name%.files}"; name="${name%.dirs}"
printf ' %-28s (%s)\n' "$name" "$(basename "$man")"
hits=$((hits+1))
fi
done
if [ "$hits" = 0 ]; then
say " No package recorded that exact path."
say ""
say " Packages that installed something under it:"
local found=0 n
for man in "$MANIFESTS"/*.files; do
[ -e "$man" ] || continue
n="$(grep -c -e "^${needle%/}/" -e "^${needle2%/}/" "$man" 2>/dev/null)"
if [ "${n:-0}" -gt 0 ]; then
name="$(basename "${man%.files}")"
printf ' %-28s %s file(s)\n' "$name" "$n"
found=1
fi
done
if [ "$found" = 0 ]; then
say " (none -- nothing recorded installing it)"
say ""
# TWO REGISTRIES, ONE QUESTION. These manifests cover the BUILD
# steps, chapters 5-9. Anything installed afterwards is recorded
# in the package user's own pkg.lst, which this never looked at --
# so a package installed by these tools, ten minutes ago, was
# reported as "installed outside these tools entirely".
_wp_hit=""
for _r in "$PKGUSR_ROOT" "$CFGUSR_ROOT"; do
[ -d "$_r" ] || continue
for _l in "$_r"/*/pkg.lst; do
[ -f "$_l" ] || continue
if grep -qxF "$target" "$_l" 2>/dev/null; then
_wp_hit="$(basename "$(dirname "$_l")")"
say " Recorded in a package user's own list, not a build"
say " manifest: $_wp_hit ($_l)"
say " That is a package installed after the build, by"
say " packagemanager. Ask it for the detail:"
say " packagemanager which-package $target"
break
fi
done
[ -n "$_wp_hit" ] && break
done
if [ -z "$_wp_hit" ]; then
say " Nothing recorded means it was installed before file tracking"
say " existed, or outside these tools entirely."
say " Packages installed after the build record themselves"
say " separately; check there too:"
say " packagemanager which-package $target"
fi
fi
fi
if [ -e "$target" ]; then
say ""
say " On disk now: $(stat -c '%A %U:%G' "$target" 2>/dev/null)"
fi
}
# Is there anything with no passwd entry? Stops at the first hit, so it costs
# nothing on a tree that is already in order.
_has_orphaned_files() {
local r="${SNAP_ROOT%/}" hit
# see _vfy_orphans: before 7.6 nothing resolves, so everything looks orphaned
_user_db_ready || return 1
scan_prune_set
hit="$(find "${r:-/}" -xdev -nouser "${SCAN_PRUNE[@]}" \
-print -quit 2>/dev/null)"
[ -n "$hit" ]
}
# Name the cause of a compiler/header mismatch.
#
# Three different things produce the same "bits/c++config.h: No such file"
# message, and they need completely different fixes:
#
# 1. a chapter-8 gcc installed on top, half-finished. Chapter 6 always
# configures with --host/--target; a compiler whose configure line has
# NEITHER is a native build, which only chapter 8 does.
# 2. LFS_TGT changed between chapter 6 and now. Both names are cross
# triplets, they just differ.
# 3. chapter 6 never finished. There are no headers at all.
_explain_triplet_mismatch() {
local cfg="$1" mine have hdr
mine="$(c++ -dumpmachine 2>/dev/null)"
for hdr in /usr/include/c++/*/*/bits/c++config.h; do
[ -e "$hdr" ] || continue
have="$(basename "$(dirname "$(dirname "$hdr")")")"
break
done
[ -n "${have:-}" ] || return 0
[ "$have" = "$mine" ] && return 0
say ""
case "$cfg" in
*--host=*|*--target=*)
say " Both are cross-compiler names, so LFS_TGT changed between"
say " chapter 6 and now. The headers under '$have' are the real"
say " ones; the compiler was built for '$mine'."
say ""
say " Set LFS_TGT back on the host and re-enter:"
say " lfs config lfs_tgt $have"
;;
*)
warn " This compiler was configured with no --host and no --target,"
warn " so it is a NATIVE build -- which only chapter 8 does."
warn " Chapter 8's gcc has been installed on top of chapter 6's,"
warn " but did not get as far as installing its C++ headers."
say ""
say " Rebuild it. Installs are staged now, so this cannot leave"
say " the system half-updated again:"
say " lfs-helper build gcc --phase all --force"
;;
esac
say ""
}
cmd_check_toolchain() {
local rc=0 t
# not /tmp: it may not exist yet this early in the chroot
mkdir -p "$STATE/tmp" 2>/dev/null || true
t="$(mktemp -d -p "$STATE/tmp" 2>/dev/null || mktemp -d 2>/dev/null)" \
|| die "cannot create a temp dir"
say "Toolchain check:"
say ""
# Identify the compiler FIRST. Everything else is downstream of which
# binary this is and how it was configured -- and "how it was configured"
# is the only thing that explains a triplet mismatch, since a compiler
# searches for headers under the target it was BUILT for, not the one the
# headers happen to be installed under.
say " The compiler:"
say " c++ : $(command -v c++ 2>/dev/null || echo '(not found)')"
say " gcc : $(command -v gcc 2>/dev/null || echo '(not found)')"
say " dumpmachine: $(c++ -dumpmachine 2>/dev/null || echo '(failed)')"
say " version : $(c++ --version 2>/dev/null | head -n1)"
local cfg
cfg="$(c++ -v 2>&1 | grep -m1 '^Configured with:')"
if [ -n "$cfg" ]; then
# the --host/--target it was built with is the whole story
say " built with :"
printf '%s\n' "$cfg" | tr ' ' '\n' \
| grep -E '^--(host|target|build|prefix|with-gxx-include-dir)=' \
| sed 's/^/ /'
fi
say ""
# 1. C: compile and link
printf 'int main(void){return 0;}\n' > "$t/t.c"
if cc "$t/t.c" -o "$t/t" 2>"$t/cc.err"; then
ok " C compiles and links"
else
warn " C CANNOT compile a trivial program"
sed 's/^/ /' "$t/cc.err" | head -n 5
rc=1
fi
# 2. the interpreter the book checks for
if [ -f "$t/t" ]; then
local interp
interp="$(readelf -l "$t/t" 2>/dev/null | grep -o '/lib64/ld-linux[^]]*' | head -n1)"
if [ -n "$interp" ]; then
say " interpreter: $interp"
fi
fi
# 3. C++: this is what chapter 8's gcc needs and what fails first
printf '#include <memory>\nint main(void){return 0;}\n' > "$t/t.cc"
if c++ "$t/t.cc" -o "$t/tpp" 2>"$t/cxx.err"; then
ok " C++ compiles and links (headers are complete)"
else
warn " C++ CANNOT compile -- the C++ headers are incomplete"
sed 's/^/ /' "$t/cxx.err" | head -n 5
rc=1
say ""
say " Where the C++ headers actually are:"
local d found=0
for d in /usr/include/c++/*/*/bits/c++config.h \
/tools/*/include/c++/*/*/bits/c++config.h; do
[ -e "$d" ] || continue
say " $d"
found=1
done
[ "$found" = 0 ] && say " (none found -- gcc-pass2 did not install them)"
say ""
# A header that exists and is world-readable can still be invisible:
# if any directory on the way to it lacks o+x, the compiler gets
# ENOENT -- "No such file or directory" -- not a permission error.
# That is indistinguishable from a missing file in the message, and it
# is exactly what a mis-assigned package-user directory causes.
local h
for h in /usr/include/c++/*/*/bits/c++config.h; do
[ -e "$h" ] || continue
local part="" comp bad=""
local IFS_SAVE="$IFS"; IFS=/
for comp in $h; do
[ -n "$comp" ] || continue
part="$part/$comp"
if [ ! -r "$part" ] || { [ -d "$part" ] && [ ! -x "$part" ]; }; then
bad="$part"
break
fi
done
IFS="$IFS_SAVE"
if [ -n "$bad" ]; then
say ""
warn " This header EXISTS but cannot be reached:"
say " $h"
say " blocked at: $bad"
say " $(stat -c '%A %U:%G' "$bad" 2>/dev/null)"
say " Every directory on the way needs o+x, or the compiler"
say " gets 'No such file or directory' even though it is there."
say ""
say " Fix it with:"
say " lfs-helper fix-perms --run"
fi
done
# The decisive fact: which directories the compiler actually searches.
# A header can exist, be world-readable, and sit on a fully traversable
# path, and still not be found -- because that directory is not in the
# search list at all. Permissions and existence cannot tell you that;
# only the compiler can.
say ""
say " Where this compiler actually looks for C++ headers:"
printf '' | c++ -x c++ -E -v - 2>&1 \
| sed -n '/#include <...> search starts here/,/End of search list/p' \
| grep -v 'search starts here\|End of search list' \
| sed 's/^/ /'
say ""
say " If the directory holding c++config.h is not in that list, the"
say " compiler was built for a different target than the headers were"
say " installed for -- that is a mismatch, not a missing file."
say ""
# Which of the three causes is it? The compiler's own configure line
# tells them apart, and they need completely different fixes.
_explain_triplet_mismatch "$cfg"
say " The compiler looks for them under its OWN target triplet:"
say " $(cc -dumpmachine 2>/dev/null || echo '(cc not working)')"
say " If the headers above are under a different triplet, chapter 6's"
say " gcc-pass2 was built for a different target than this compiler"
say " reports -- rebuild it with the LFS_TGT this tree was made with."
fi
rm -rf "$t"
say ""
if [ "$rc" = 0 ]; then
ok "Toolchain is usable."
else
warn "Do not start chapter 8 until this is fixed."
fi
return $rc
}
# Has the copied-in export already been applied?
_groups_already_imported() {
local f="$STATE_GROUPS/collector-groups.import" first
[ -f "$f" ] || return 0
[ -f "$COLLECTOR_MAP" ] || return 1
# every dir decision in the file must be in the map
while IFS='|' read -r kind a b; do
[ "$kind" = dir ] || continue
grep -qxF "$a|$b" "$COLLECTOR_MAP" 2>/dev/null || return 1
done < <(grep -vE '^\s*(#|$)' "$f")
return 0
}
# The compiler's own target vs where the C++ headers actually are.
#
# A build has MORE THAN ONE of these directories: chapter 6 installs libstdc++
# under $LFS_TGT (x86_64-lfs-linux-gnu, or whatever vendor string is
# configured) and chapter 8's native gcc installs its own under the triplet it
# reports, usually x86_64-pc-linux-gnu. Both are present afterwards and that
# is correct -- the chapter-6 one is simply left over.
#
# Taking the FIRST glob match and stopping therefore reported a mismatch
# whenever the stale directory happened to sort first, which is alphabetical
# and so pure luck: "nimgnu" < "pc", so a perfectly good toolchain warned on
# every package. What matters is only whether a directory matching the
# compiler exists at all -- if it does, the compiler will find its headers.
_warn_on_triplet_mismatch() {
command -v c++ >/dev/null 2>&1 || return 0
local mine hdr have found=""
mine="$(c++ -dumpmachine 2>/dev/null)" || return 0
[ -n "$mine" ] || return 0
for hdr in /usr/include/c++/*/*/bits/c++config.h; do
[ -e "$hdr" ] || continue
have="$(basename "$(dirname "$(dirname "$hdr")")")"
[ "$have" = "$mine" ] && return 0 # the compiler's own -- fine
found="$found $have"
done
[ -n "$found" ] || return 0 # no C++ headers at all yet
warn ""
warn "!! the compiler and its C++ headers disagree about the target:"
warn " compiler reports : $mine"
warn " headers are under:$found"
warn " Chapter 8's gcc will fail with"
warn " fatal error: bits/c++config.h: No such file or directory"
warn " which looks like a missing file but is a mismatch. See:"
warn " lfs-helper check-toolchain"
warn ""
}
# Two accounts sharing one uid.
#
# Ownership is stored as a NUMBER. Give two names the same uid and every tool
# resolves that number to whichever name it finds first -- so ncurses' files
# report as owned by 'xz', and the ncurses build cannot overwrite its own
# binaries. It reads exactly like a permissions bug and is not one.
#
# It happens when /etc/passwd is truncated (book 7.6) while package users
# already exist: the next user created starts from PKG_UID_MIN again, and any
# entry restored afterwards collides with it.
_duplicate_ids() {
local f="$1"
awk -F: 'NF >= 3 && $3 != "" { c[$3] = c[$3] " " $1; n[$3]++ }
END { for (id in n) if (n[id] > 1) printf " %s -> %s\n", id, c[id] }' \
"$f" 2>/dev/null
}
_check_duplicate_ids() {
local dp dg
dp="$(_duplicate_ids "$ETC/passwd")"
dg="$(_duplicate_ids "$ETC/group")"
[ -z "$dp" ] && [ -z "$dg" ] && return 0
warn ""
warn "!! two accounts share one id. Ownership is stored as a NUMBER, so"
warn " files will report as owned by the wrong name, and a package will"
warn " not be able to overwrite its own files:"
[ -n "$dp" ] && { warn " in $ETC/passwd:"; printf '%s\n' "$dp" >&2; }
[ -n "$dg" ] && { warn " in $ETC/group:"; printf '%s\n' "$dg" >&2; }
warn ""
warn " Decide which name owns which files, then give one of them a free"
warn " id and chown its files to it. Until then any 'Permission denied'"
warn " on a package's own files is this, not a permissions problem."
warn ""
return 1
}
# --------------------------------------------------------------------------- #
# verify -- reconcile the tree against what it should be
# --------------------------------------------------------------------------- #
# Ownership is DERIVED state. The manifests say which package installed which
# path; the install-dir list says which directories are shared; the collector
# map says which shared directories carry which group. Together those three
# fully determine what the tree should look like.
#
# This replaces fix-ownership, adopt-dirs, adopt-existing, fix-orphans,
# fix-users, seal-install-dirs, sort-users and claim-earlier-stages. Each of
# those was added to fix one symptom, they overlapped, and none of them could
# see a problem outside its own narrow remit -- so a staged install quietly
# handing /usr to a package user went unnoticed through four later failures.
#
# One pass, one model, every rule checked every time.
_vfy_n_checked=0
_vfy_n_wrong=0
_vfy_n_fixed=0
# Every finding, in full, in a file.
#
# The console prints the first eight of each kind and "... and 44265 more",
# which tells you a number and nothing you can act on -- you cannot grep it,
# diff it against the last run, or feed it to which-package. The log holds
# every line; the console stays short.
VERIFY_LOG="${VERIFY_LOG:-$STATE/logs/verify.log}"
_vfy_log_open() {
mkdir -p "$(dirname "$VERIFY_LOG")" 2>/dev/null || true
: > "$VERIFY_LOG" 2>/dev/null || { VERIFY_LOG=""; return 0; }
printf '# lfs-helper verify -- %s\n# root: %s\n\n' \
"$(date '+%Y-%m-%d %H:%M:%S')" "${SNAP_ROOT:-/}" >> "$VERIFY_LOG"
}
# _vfy_log <section> <line...>
_vfy_log() {
[ -n "$VERIFY_LOG" ] || return 0
local sec="$1"; shift
printf '%s\t%s\n' "$sec" "$*" >> "$VERIFY_LOG" 2>/dev/null || true
}
_vfy_report() {
# <what> <path> <found> <expected>
_vfy_n_wrong=$((_vfy_n_wrong + 1))
printf ' %-9s %-52s %s -> %s\n' "$1" "$2" "$3" "$4"
}
# --- rule 1: a package owns what its manifests record ---------------------- #
#
# Including every earlier stage of the same package: ncurses is built in
# chapter 6 as the lfs user and again in chapter 8 as its package user, and
# perl-tmp in chapter 7 as root. All of it belongs to the final package user.
# Sort /etc/passwd and /etc/group by id.
#
# Package users are appended as they are created, so the files end up in build
# order rather than id order. Sorting is cosmetic and safe: nothing depends on
# the order of these files. System entries (id < 1000) keep their place.
cmd_sort_users() {
need_root
local run=0
[ "${1:-}" = "--run" ] && run=1
local f
for f in "$ETC/passwd" "$ETC/group"; do
[ -f "$f" ] || continue
local tmp="$f.sorted.$$"
{
awk -F: '$3 < 1000' "$f"
awk -F: '$3 >= 1000' "$f" | sort -t: -k3,3n
} > "$tmp" 2>/dev/null || { rm -f "$tmp"; continue; }
local a b
a="$(wc -l < "$f")"; b="$(wc -l < "$tmp")"
if [ "$a" != "$b" ]; then
warn " $f: refusing to sort ($a lines in, $b out)"
rm -f "$tmp"
continue
fi
if cmp -s "$f" "$tmp"; then
say " $f is already in id order"
rm -f "$tmp"
continue
fi
if [ "$run" = 1 ]; then
soft "no backup of $f -- editing it without one" -- cp -p "$f" "$f.bak"
cat "$tmp" > "$f"
rm -f "$tmp"
ok " $f sorted by id (previous kept as $f.bak)"
else
say " $f would be sorted by id"
rm -f "$tmp"
fi
done
[ "$run" = 1 ] || warn "(dry run -- nothing was changed. Add --run to apply.)"
}
# The LAST step of the chroot stage: make the install directories sticky.
#
# During the build they must be group-writable but NOT sticky, so package users
# can replace files the temporary system installed as root. Once everything is
# built that has to be undone, or any package can delete another's files.
#
# Only the install directories. Collector-group directories are shared on
# purpose and are left alone.
cmd_seal_install_dirs() {
need_root
local run=0
[ "${1:-}" = "--run" ] && run=1
say "Sticky bit (o+t) on the shared install directories."
say ""
say "During the build these are group-writable so package users can replace"
say "each other's. Once sticky, a package user can still create files here"
say "but can no longer modify or delete another package's."
say ""
say "Run this when every package is built (end of chapter 8) -- doing it"
say "earlier will break installs that legitimately overwrite older files."
say ""
local d n=0 missing=0 _shared_dirs
# A directory holding a shared index must never be sealed: sticky means
# only a file's owner may replace it, and every package replaces that
# index (install-info on /usr/share/info/dir).
_shared_dirs="|$(shared_file_dirs | tr '\n' '|')"
while IFS= read -r d; do
[ -n "$d" ] || continue
[ -d "$d" ] || { missing=$((missing+1)); continue; }
case "$_shared_dirs" in
*"|$d|"*) say "# not sealing $d -- it holds a shared index every package rewrites"
continue ;;
esac
[ -k "$d" ] && continue
if [ "$run" = 1 ]; then
real_chmod o+t "$d" 2>/dev/null && n=$((n+1))
else
n=$((n+1))
fi
done < <(
{ while IFS= read -r d; do
printf '%s\n' "${SNAP_ROOT%/}$d"
done < <(install_dirs_list)
find "${SNAP_ROOT:-/}" -xdev -type d -group install \
-not -path "$SRCROOT/*" \
-not -path "${SNAP_ROOT%/}/sources/*" \
-not -path "${SNAP_ROOT%/}/dev/*" \
-not -path "${SNAP_ROOT%/}/proc/*" \
-not -path "${SNAP_ROOT%/}/sys/*" \
-not -path "${SNAP_ROOT%/}/run/*" 2>/dev/null
} | sort -u
)
if [ "$run" = 1 ]; then
ok " sticky bit set on $n install directory(ies)"
else
say " $n install directory(ies) would get the sticky bit"
warn "(dry run -- nothing was changed. Add --run to apply.)"
fi
[ "$missing" -gt 0 ] && say " ($missing listed dirs don't exist here -- skipped)"
return 0
}
# Give the packages built before the chroot their own users.
#
# The same work verify does, under the name it has always had -- the messages
# elsewhere tell people to run this, and a command that has been removed is
# worse than one that is redundant.
# Is there a user database to add package users to?
#
# A crosschain snapshot is taken BEFORE chapter 7, so /etc/passwd does not
# exist yet -- book 7.6 creates it. Running the adoption pass then produces
# "could not create" for every single package, because there is nothing to
# create them in. The give-away is the prompt: "I have no name!" means even
# root has no passwd entry.
#
# Adoption is not wrong here, just early. It has to wait until 7.6 has run.
_user_db_ready() {
[ -s "$ETC/passwd" ] || return 1
grep -q '^root:' "$ETC/passwd" 2>/dev/null || return 1
return 0
}
_explain_no_user_db() {
warn ""
warn "There is no user database in this tree yet: $ETC/passwd"
warn "has no root entry, which is why the prompt says \"I have no name!\"."
warn ""
warn "Book 7.6 creates it. This is a chapter 5-6 tree, so nothing can be"
warn "given a package user until chapter 7 has run:"
warn " lfs-helper build-all"
warn ""
warn "build-all does this in the right order by itself -- the chapter-7"
warn "steps first, then the package users. Nothing is wrong with the tree."
warn ""
}
cmd_adopt_existing() {
need_root
if ! _user_db_ready; then
_explain_no_user_db
return 1
fi
local run=0
[ "${1:-}" = "--run" ] && run=1
say "Give the CHAPTER 5-6 packages their own package users."
say ""
say "Those were built outside the chroot, before any package user existed,"
say "so their files are still root-owned -- only their recorded file lists"
say "say who installed what. Packages built in here already got their"
say "owner as part of the build and are not touched."
say ""
if [ "$run" != 1 ]; then
_vfy_manifest_ownership 0 || true
warn "(dry run -- nothing was changed. Add --run to apply.)"
return 0
fi
_vfy_manifest_ownership 1 || true
cmd_sort_users --run >/dev/null 2>&1 || true
}
_vfy_manifest_ownership() {
local fix="$1" only="${2:-}" man base name owner p cur kind
# In BUILD order, not alphabetical.
#
# This pass creates the package users it finds missing, and a user's uid is
# simply the order it was created in. Walking the manifests alphabetically
# gave man-pages (the first chapter-8 package) uid 10000 and binutils --
# built first, in chapter 5 -- a much higher one, which makes the uids
# useless for reading the build order off the passwd file.
local _mans _nman=0 _ntot
_mans="$(_manifests_in_build_order) $(_manifests_in_build_order dirs)"
# ...or just the one the caller named
if [ -n "$only" ]; then
_mans="$(printf '%s\n' $_mans | grep -E "/(${only}|$(pkg_owner_name "$only"))[./]" || true)"
[ -n "$_mans" ] || warn "no manifest found for '$only' -- nothing to check"
fi
_ntot="$(printf '%s\n' $_mans | grep -c .)"
for man in $_mans; do
[ -e "$man" ] || continue
[ -s "$man" ] || continue
case "$man" in *.dirs) kind=dir ;; *) kind=file ;; esac
name="$(basename "$man")"; name="${name%.files}"; name="${name%.dirs}"
_is_not_a_package "$name" && continue
base="$(_stage_base_name "$name")"
owner="$(pkg_owner_name "$base")"
# A package with a manifest but no user has never been given one --
# every chapter 5-6 package is in that state, because they were built
# before any package user existed. Creating it here is the job the
# old `adopt-existing` did; without it verify would skip them silently
# and their files would stay owned by 'lfs' forever.
if ! user_exists "$owner"; then
[ "$fix" = 1 ] || { _vfy_report user "$owner" "(missing)" \
"package user"; continue; }
# stdout suppressed (one line per package is noise here), but NOT
# stderr: a failure to create the account is the whole reason the
# adoption is happening, and hiding it is how a tree came out with
# every adopted home root-owned and nothing saying why.
( cmd_add_user "$owner" >/dev/null ) || true
# The account may already have existed with an uninitialised home:
# adopted packages came out with 86-byte homes owned by root while
# packages BUILT in the same run had full ones owned by their user.
# init_package_user_home is idempotent, so just run it -- it fills
# in the profile links and hands the home to its user.
user_exists "$owner" && init_package_user_home "$owner" >/dev/null
user_exists "$owner" || {
_vfy_report user "$owner" "(could not create)" "package user"
continue
}
_vfy_n_fixed=$((_vfy_n_fixed + 1))
fi
# Which package, live. This pass walks every path in every manifest,
# and it used to print nothing until it had finished. A command that
# works for minutes in silence is indistinguishable from one that has
# hung -- and was reported as one.
_nman=$((_nman + 1))
[ -t 1 ] && printf '\r [%d/%d] %-24s\033[K' "$_nman" "$_ntot" "$name" >&2
# Every owner in ONE stat call, not one call per path.
#
# `stat -c %U` per path is a fork per path, and `verify` walks every
# path in every manifest -- 66265 of them on a finished system. With
# is_never_claimed forking too that was ~130000 processes, which is
# where the minutes went. xargs batches them into a handful.
# A REAL tab in the format string. `stat -c '%U\t%n'` prints a
# literal backslash-t -- stat does not expand escapes -- so every line
# came back as one field, the map stayed empty, and the loop below fell
# through to the per-path stat it was written to avoid. It would have
# been correct and slow, which is the hardest kind of wrong to notice.
declare -A _own_of=()
while IFS="$_TAB" read -r _o _f; do
[ -n "$_f" ] && _own_of["$_f"]="$_o"
done < <(grep -v '^[[:space:]]*$' "$man" \
| tr '\n' '\0' | xargs -0 -r stat -c "%U${_TAB}%n" 2>/dev/null)
while IFS= read -r p; do
[ -n "$p" ] || continue
p="$(strip_host_prefix "$p")"
[ -e "$p" ] || [ -L "$p" ] || continue
# scratch, and files no package can own. Manifests written before
# these rules existed still carry them, and re-reporting a conflict
# nothing can settle is worse than not reporting it: the next
# package takes the file straight back.
is_never_claimed "$p" && continue
case "$p" in "${BUILD_ROOT:-${SNAP_ROOT%/}/build}"/*) continue ;; esac
_vfy_n_checked=$((_vfy_n_checked + 1))
cur="${_own_of[$p]:-}"
[ -n "$cur" ] || cur="$(stat -c %U "$p" 2>/dev/null)" || continue
[ "$cur" = "$owner" ] && continue
# A shared install directory belongs to root:install, never to a
# package -- even when the package's manifest records creating it.
[ "$kind" = dir ] && is_install_dir "$p" && continue
# A directory holding another package's files is shared in fact,
# whatever the manifest says: taking it would lock the other one
# out. This is what let one package claim gcc's header tree.
if [ "$kind" = dir ] && _dir_contents_foreign "$p" "$owner"; then
continue
fi
# never take a path another package legitimately owns
case "$cur" in
root|lfs|UNKNOWN) ;;
*) user_exists "$cur" && continue ;;
esac
_vfy_report owner "$p" "$cur" "$owner"
if [ "$fix" = 1 ]; then
real_chown -h "$owner:$owner" "$p" 2>/dev/null \
&& _vfy_n_fixed=$((_vfy_n_fixed + 1))
fi
done < "$man"
done
[ -t 1 ] && printf '\r\033[K' >&2
return 0
}
# --- rule 2: shared install directories belong to root:install ------------- #
#
# This is the rule a staged install broke: /usr and /usr/bin became
# gcc:gcc 755, losing the install group and g+w -- the entire scheme, undone
# by one package. Checked every run now.
_vfy_install_dirs() {
local fix="$1" d live owner grp mode want_mode sealed _shared_dirs
_shared_dirs="|$(shared_file_dirs | tr '\n' '|')"
# Before the ownership epoch there is no user database, so `install` is not
# a name and neither is root. Reporting 27 wrong install directories then
# is not a finding -- it is the tree being younger than the question.
if ! ownership_established && ! ownership_possible; then
say " (ownership not established yet -- book 7.6 comes first)"
return 0
fi
sealed=0
_install_dirs_are_sealed && sealed=1
while IFS= read -r d; do
live="${SNAP_ROOT%/}$d"
[ -d "$live" ] || continue
# a symlinked install dir (/bin -> usr/bin) is checked at its target,
# which is in this list too -- see the note in _vfy_install_dirs
[ -L "$live" ] && continue
_vfy_n_checked=$((_vfy_n_checked + 1))
# by number: `stat -c %U` reports UNKNOWN before 7.6 writes /etc/passwd,
# which would report every correct directory as wrong
owner="$(stat -c %u "$live" 2>/dev/null)"
grp="$(stat -c %g "$live" 2>/dev/null)"
mode="$(stat -c %a "$live" 2>/dev/null)"
# sticky only after the build is finished -- during it, package users
# must be able to replace the temporary system's root-owned files
if [ "$sealed" = 1 ]; then want_mode=1775; else want_mode=775; fi
# ...and NEVER for a directory holding a shared index: sticky means
# only a file's owner may replace it, and the whole point of that
# index is that every package replaces it (install-info).
case "$_shared_dirs" in
*"|$live|"*) want_mode=775 ;;
esac
if [ "$owner" != 0 ] || [ "$grp" != "$INSTALL_GID" ] \
|| [ "$mode" != "$want_mode" ]; then
_vfy_report installdir "$live" \
"$(stat -c '%U:%G' "$live" 2>/dev/null) $mode" \
"root:install $want_mode"
if [ "$fix" = 1 ]; then
# COUNTED AS FIXED, so it must not fail in silence: the next
# run would find the same directory and report it again with
# no explanation of why the repair never took.
set_install_dir_owner "$live" || true
soft "could not set $live to $want_mode -- counted as fixed but it did not take" \
-- real_chmod "$want_mode" "$live"
_vfy_n_fixed=$((_vfy_n_fixed + 1))
fi
fi
done < <(install_dirs_list)
}
# --- rule 3: every file has an owner that exists here ---------------------- #
# The paths no ownership scan should walk, as find arguments.
#
# ONE list, because there were five and they had already diverged: the snapshot
# scans excluded the build scratch and the orphan scan did not, so when build
# trees moved from /build into each package user's home (1.7.6) the orphan scan
# started walking every unpacked source tree in the system. `lfs-helper verify`
# went from seconds to minutes with no output, and the sanity report filled up
# with tcl's own documentation:
#
# !! files with no owner (first 40):
# /usr/src/pkgusr/p_tcl/src/tcl8.6.16/html/Keywords/Z.htm
#
# A tarball can contain any uid it likes. Unpacked sources are scratch -- they
# are not installed, nothing owns them, and asking who does has no answer.
scan_prune_paths() {
local r="${SNAP_ROOT%/}"
printf '%s\n' \
"$r/dev" "$r/proc" "$r/sys" "$r/run" "$r/tmp" \
"$r/sources" "${BUILD_ROOT:-$r/build}" "$STATE"
# every package's unpacked source tree, wherever the homes are
local d
while IFS= read -r d; do
[ -n "$d" ] || continue
printf '%s\n' "$d/*/$PKGUSR_BUILD_SUBDIR"
done < <(pkgusr_roots)
}
# The same list as a find argument ARRAY, in SCAN_PRUNE.
#
# An array, not a string spliced through `eval`: the patterns contain `*`, and
# eval lets the shell expand them against the real filesystem before find ever
# sees them. `/usr/src/pkgusr/*/src/*` then becomes a handful of literal
# directory names instead of a wildcard, and the prune silently matches almost
# nothing -- which is exactly how it looked like it was working.
SCAN_PRUNE=()
scan_prune_set() {
local p
SCAN_PRUNE=()
while IFS= read -r p; do
[ -n "$p" ] || continue
SCAN_PRUNE+=( -not -path "$p" -not -path "$p/*" )
done < <(scan_prune_paths)
}
_vfy_orphans() {
local fix="$1" r="${SNAP_ROOT%/}" p n=0
scan_prune_set
# NOT before book 7.6 has written /etc/passwd.
#
# `find -nouser` asks whether a uid resolves to a name. With no passwd file
# NOTHING resolves, so every file in the tree is an "orphan" -- on a
# chapter 5-6 tree restored before 7.6 this reported
# noowner / (no passwd entry) -> lfs
# noowner /dev (no passwd entry) -> lfs
# ... and 11797 more with no owner
# and `--fix` would have handed the ENTIRE tree, root's own directories
# included, to the build user in one pass. The finding is not wrong so much
# as meaningless: there is nothing yet to resolve against.
if ! _user_db_ready; then
say " (no user database yet -- book 7.6 creates it; not checking owners)"
return 0
fi
while IFS= read -r p; do
[ -n "$p" ] || continue
_vfy_n_checked=$((_vfy_n_checked + 1))
n=$((n + 1))
[ "$n" -le 5 ] && _vfy_report noowner "$p" "(no passwd entry)" "lfs"
if [ "$fix" = 1 ]; then
user_exists lfs || _vfy_make_lfs_user
real_chown -h lfs:lfs "$p" 2>/dev/null \
&& _vfy_n_fixed=$((_vfy_n_fixed + 1))
fi
done < <(find "${r:-/}" -xdev -nouser "${SCAN_PRUNE[@]}" 2>/dev/null)
[ "$n" -gt 5 ] && say " ... and $((n - 5)) more with no owner"
[ -n "$VERIFY_LOG" ] && say " all $n: grep ^no-owner $VERIFY_LOG"
}
# --- the build user's leftovers -------------------------------------------- #
#
# Chapters 5-6 run as the `lfs` user OUTSIDE the chroot. Book 7.2 hands the
# tree to root with `chown -R`, but the pass that does it was guarded by a
# single inode -- if $LFS/usr was already root-owned the whole recursive
# handover was skipped, and everything below kept the build uid:
#
# drwxr-xr-x 1 lfs lfs /usr/share/gcc-15.2.0/python/libstdcxx
#
# Those are temporary-system files. They belong to root until a package adopts
# them, so hand them back and let adoption do the rest. Anything a manifest
# claims is left alone: _vfy_manifest_ownership settles those, and it knows
# which package they belong to.
_vfy_build_user_leftovers() {
local fix="$1" r="${SNAP_ROOT%/}" p n=0
scan_prune_set
_user_db_ready || return 0
user_exists lfs || return 0
while IFS= read -r p; do
[ -n "$p" ] || continue
is_never_claimed "$p" && continue
_vfy_n_checked=$((_vfy_n_checked + 1))
n=$((n + 1))
[ "$n" -le 5 ] && _vfy_report builduser "$p" "lfs" "root"
if [ "$fix" = 1 ]; then
real_chown -h root:root "$p" 2>/dev/null \
&& _vfy_n_fixed=$((_vfy_n_fixed + 1))
fi
# -xdev, and NOT -type f: the symlinks book 4.2 creates at the tree root --
# /bin -> usr/bin, /lib, /sbin -- are created by the build user too, and
# came out
# lrwxrwxrwx 1 lfs lfs bin -> usr/bin
# A chown without -h follows the link and retargets /usr/bin instead, which
# is why every chown in this file uses -h.
#
# /build is included deliberately: it is created by the build user outside
# the chroot and should be root's. Its CONTENTS are not -- scratch has no
# owner worth arguing about. The state directory is under $SRCROOT, which
# is excluded wholesale.
done < <(find "${r:-/}" -xdev -user lfs "${SCAN_PRUNE[@]}" \
-not -path "$SRCROOT/*" 2>/dev/null)
[ "$n" -gt 5 ] && say " ... and $((n - 5)) more still owned by the build user"
[ "$n" -gt 0 ] && [ -n "$VERIFY_LOG" ] \
&& say " all $n: grep ^builduser $VERIFY_LOG"
return 0
}
_vfy_make_lfs_user() {
if have_shadow_tools; then
# Failing here is NORMAL on a second run -- the account exists. It
# is not normal on the first, and that case used to look identical.
if ! getent group lfs >/dev/null 2>&1; then
soft "could not create the 'lfs' group" \
-- groupadd -g "$LFS_BUILD_UID" lfs
fi
if ! id lfs >/dev/null 2>&1; then
soft "could not create the 'lfs' build user" \
-- useradd -M -u "$LFS_BUILD_UID" -g lfs -s /bin/bash \
-c "build user from outside the chroot" lfs
fi
else
printf 'lfs:x:%s:\n' "$LFS_BUILD_UID" >> "$ETC/group"
printf 'lfs:x:%s:%s:build user from outside the chroot:/:/bin/bash\n' \
"$LFS_BUILD_UID" "$LFS_BUILD_UID" >> "$ETC/passwd"
fi
}
# --- rule 4: no two accounts share an id ----------------------------------- #
#
# Ownership is stored as a number, so a collision makes files report as owned
# by the wrong name and a package unable to write its own files. Reported
# only: which name should keep the id is a decision, not something to guess.
_vfy_duplicate_ids() {
local f out
for f in passwd group; do
out="$(_duplicate_ids "$ETC/$f")"
[ -n "$out" ] || continue
_vfy_n_wrong=$((_vfy_n_wrong + 1))
warn " duplicate ids in $ETC/$f -- files will report the wrong owner:"
printf '%s\n' "$out" >&2
warn " Not repaired: which name keeps the id is your decision."
done
}
# --- rule 5: passwd and group in id order ---------------------------------- #
_vfy_user_order() {
local fix="$1" f tmp
for f in "$ETC/passwd" "$ETC/group"; do
[ -f "$f" ] || continue
_vfy_n_checked=$((_vfy_n_checked + 1))
tmp="$f.vfy.$$"
{ awk -F: '$3 < 1000' "$f"
awk -F: '$3 >= 1000' "$f" | sort -t: -k3,3n; } > "$tmp" 2>/dev/null
if [ "$(count_lines "$tmp")" != "$(count_lines "$f")" ]; then
rm -f "$tmp"; continue
fi
if ! cmp -s "$f" "$tmp"; then
_vfy_report order "$f" "build order" "id order"
if [ "$fix" = 1 ]; then
soft "no backup of $f -- editing it without one" -- cp -p "$f" "$f.bak"
cat "$tmp" > "$f"
_vfy_n_fixed=$((_vfy_n_fixed + 1))
fi
fi
rm -f "$tmp"
done
}
# The package a manifest name belongs to: gcc-pass2 -> gcc, perl-tmp -> perl.
_stage_base_name() {
local n="$1"
n="${n%-tmp}"; n="${n%-pass1}"; n="${n%-pass2}"
printf '%s' "$n"
}
# Seal the install directories: sticky, so a package user can still create
# files there but no longer replace another package's. Done once, at the end
# of the build -- during it, package users must be able to replace the
# temporary system's root-owned files. After this, verify expects 1775.
# --- rule 6: every package user has a usable home -------------------------- #
#
# A package user's home is its build directory. If it is missing, or owned by
# someone else, the build cannot even unpack. This is what `fix-users` did.
_vfy_user_homes() {
local fix="$1" man name base owner home cur
for man in "$MANIFESTS"/*.files; do
[ -e "$man" ] || continue
name="$(basename "${man%.files}")"
_is_not_a_package "$name" && continue
base="$(_stage_base_name "$name")"
owner="$(pkg_owner_name "$base")"
user_exists "$owner" || continue
home="$(pkgusr_home_for "$owner")"
_vfy_n_checked=$((_vfy_n_checked + 1))
if [ ! -d "$home" ]; then
_vfy_report home "$home" "(missing)" "$owner"
if [ "$fix" = 1 ]; then
mkdir -p "$home" 2>/dev/null \
&& chown "$owner:$owner" "$home" 2>/dev/null \
&& _vfy_n_fixed=$((_vfy_n_fixed + 1))
fi
continue
fi
cur="$(stat -c %U "$home" 2>/dev/null)"
[ "$cur" = "$owner" ] && continue
_vfy_report home "$home" "$cur" "$owner"
if [ "$fix" = 1 ]; then
chown "$owner:$owner" "$home" 2>/dev/null \
&& _vfy_n_fixed=$((_vfy_n_fixed + 1))
fi
done
}
# --- rule 6b: every package account's home is where the tools look --------- #
#
# Rule 6 checks the COMPUTED home exists and is owned right; it never asks
# whether the passwd entry agrees, or whether add_package_user's husk is
# sitting beside the roots. A bootstrap on a fresh tree left ten of those
# (/usr/src/p_wget, /usr/src/p_requests, ...) and verify walked past them.
_vfy_misplaced_homes() {
local fix="$1" acct uid rest bad=0 match
# Prefixes are how a package account is recognised; with none configured
# there is no safe way to tell one from a real user.
[ -n "$PKGUSR_PREFIX$CFGUSR_PREFIX" ] || return 0
while IFS=: read -r acct _ uid rest; do
case "$uid" in ''|*[!0-9]*) continue ;; esac
[ "$uid" -ge "$PKG_UID_MIN" ] || continue
match=0
[ -n "$PKGUSR_PREFIX" ] && case "$acct" in "$PKGUSR_PREFIX"?*) match=1 ;; esac
[ -n "$CFGUSR_PREFIX" ] && case "$acct" in "$CFGUSR_PREFIX"?*) match=1 ;; esac
[ "$match" = 1 ] || continue
_repair_misplaced_home "$acct" "$fix" || bad=$((bad+1))
done < <(if [ -r "$ETC/passwd" ]; then cat "$ETC/passwd"; else getent passwd; fi)
if [ "$bad" -gt 0 ]; then
[ "$fix" = 1 ] \
|| warn " repair with: lfs-helper verify --fix (one package: lfs-helper fix-home <name> --run)"
return 1
fi
return 0
}
# --- rule 3c: a record must describe reality ------------------------------- #
#
# pkg.lst and VERSION are the claim "this package IS installed", and the
# planner believes them. Records written by pre-1.12.7 failed builds lie:
# libxml2 claimed 4 half-built files, the planner skipped it, and three
# packages later shared-mime-info died looking for xmllint. This pass reads
# every pkg.lst and counts how many of its paths exist -- a record whose
# files are mostly gone is named a LIAR, with the purge-and-rebuild cure.
# Report-only: rebuilding is a build decision, not a repair.
_vfy_lying_records() {
local fix="${1:-0}" home lst total missing pct acct pkg bad=0 p
for home in "$PKGUSR_ROOT"/*/; do
[ -d "$home" ] || continue
lst="$home/pkg.lst"
[ -s "$lst" ] || continue
total=0; missing=0
while IFS= read -r p; do
case "$p" in ""|\#*) continue ;; esac
total=$((total+1))
[ -e "$p" ] || [ -L "$p" ] || missing=$((missing+1))
done < "$lst"
[ "$total" -gt 0 ] || continue
pct=$(( missing * 100 / total ))
if [ "$pct" -ge 50 ]; then
bad=$((bad+1))
acct="$(basename "$home")"
pkg="$(unprefix_pkg_user "$acct")"
warn "lying record: $acct claims $total path(s), $missing missing (${pct}%)"
if [ "$fix" = 1 ]; then
# DELETING A LIE IS A REPAIR. The record says the package is
# installed and it is not; while it stands, the planner skips
# the package and every dependent fails somewhere else
# entirely (xmllint, glib, -lfreetype). Rebuilding is a build
# decision and stays the user's; removing the false claim is
# not.
rm -f "$home/pkg.lst" "$home/VERSION" 2>/dev/null
ok "# removed the false record -- the planner will build it again"
warn " rebuild it when convenient: packagemanager install $pkg --run"
_vfy_n_fixed=$((_vfy_n_fixed + 1))
else
warn " the planner believes this and will SKIP the package. Cure:"
warn " rm $home/pkg.lst $home/VERSION"
warn " packagemanager install $pkg --run"
fi
fi
done
[ "$bad" = 0 ]
}
_vfy_seal_install_dirs() {
local d live n=0
while IFS= read -r d; do
live="${SNAP_ROOT%/}$d"
[ -d "$live" ] || continue
[ -k "$live" ] && continue
chmod o+t "$live" 2>/dev/null && n=$((n+1))
done < <(install_dirs_list)
# directories that carry the install group but are not on the list
while IFS= read -r live; do
[ -n "$live" ] || continue
[ -k "$live" ] && continue
chmod o+t "$live" 2>/dev/null && n=$((n+1))
done < <(find "${SNAP_ROOT:-/}" -xdev -type d -group install \
-not -path "$SRCROOT/*" \
-not -path "${SNAP_ROOT%/}/sources/*" 2>/dev/null)
ok "# sticky bit set on $n install directory(ies)"
}
# What in $STATE must survive, and what is scratch.
#
# The state directory mixes two kinds of thing: records that ARE the system's
# ownership (manifests, the collector map, progress) and pure scratch (staging
# trees, logs, temp files). A snapshot, a restore or a restart had to guess
# which was which. Naming them means each can be treated correctly, and the
# format can be versioned so a future tool refuses state it cannot read rather
# than misreading it.
STATE_FORMAT=1
STATE_DURABLE="manifests collector-map collector-groups.import adopted.list
chroot-progress steporder rootsteps interrupted"
STATE_SCRATCH="stage tmp logs"
_write_state_version() {
local f="$STATE_PROGRESS/format"
[ -d "$STATE" ] || return 0
[ -f "$f" ] || printf '%s\n' "$STATE_FORMAT" > "$f" 2>/dev/null || true
}
_check_state_version() {
local f="$STATE_PROGRESS/format" have
[ -f "$f" ] || { _write_state_version; return 0; }
have="$(head -n1 "$f" 2>/dev/null)"
[ "$have" = "$STATE_FORMAT" ] && return 0
warn ""
warn "!! this build state is format $have, these tools write format $STATE_FORMAT."
warn " Reading it could misinterpret the manifests, which decide who owns"
warn " every file. Check the tool versions match before continuing:"
warn " lfs-helper --version (inside)"
warn " lfs --version (on the host)"
warn ""
return 1
}
# Scratch that can always be thrown away.
cmd_clean_state() {
need_root
local run=0; [ "${1:-}" = "--run" ] && run=1
local d n=0 sz
say "Scratch in $STATE (never part of the system):"
for d in $STATE_SCRATCH; do
[ -d "$STATE/$d" ] || continue
sz="$(du -sh "$STATE/$d" 2>/dev/null | cut -f1)"
say " $d ${sz:-?}"
n=$((n+1))
[ "$run" = 1 ] && rm -rf "${STATE:?}/$d"
done
[ "$n" = 0 ] && { ok " nothing to clean"; return 0; }
say ""
say "Kept (these ARE the ownership records):"
for d in $STATE_DURABLE; do
[ -e "$STATE/$d" ] && say " $d"
done
if [ "$run" = 1 ]; then
ok "removed $n scratch directory(ies)"
else
warn "(dry run -- nothing was changed. Add --run to remove them.)"
fi
}
# --------------------------------------------------------------------------- #
# install-as -- the package-user model, for anything
# --------------------------------------------------------------------------- #
# Everything `build` does, for a command that is not a book step: create the
# user, claim what earlier stages of it own, run under the wrappers, stage the
# install where that is wanted, track what lands on disk, and grant directory
# access when the install needs it.
#
# lfs-helper install-as <name> -- <command ...>
# lfs-helper install-as <name> --script <file>
#
# This is the entry point for a BLFS package, a local script, or anything else
# that should be owned like every other package. Without it a third party has
# to reimplement all of the above, which is how the two halves of this
# toolchain drifted apart in the first place.
# Record what a package user IS, in its own home.
#
# A package user's home tells you nothing about where the package came from.
# On a finished system that matters: an LFS base package, a BLFS package, a
# pip module and a local script are maintained differently and updated from
# different places, and the only way to tell them apart today is to remember.
#
# One file, read by eye or by script:
# /usr/src/gcc/.pkgusr
# source=lfs
# package=gcc
# version=15.2.0
# installed=2026-08-25 03:40:11
# by=lfs-helper 1.7.0 (build 0a4b882)
# What kind of package is this? Reads the file above.
cmd_pkgusr_info() {
local name="${1:-}"
if [ -n "$name" ]; then
local f="$(pkgusr_home_for "$name")/.pkgusr"
[ -f "$f" ] || die "no record for '$name' (installed before this was recorded?)"
cat "$f"
return 0
fi
printf '%-24s %-8s %-14s %s\n' PACKAGE SOURCE VERSION INSTALLED
local d n src ver ins root
while IFS= read -r root; do
for d in "$root"/*; do
[ -d "$d" ] || continue
n="$(basename "$d")"
if [ -f "$d/.pkgusr" ]; then
src="$(sed -n 's/^source=//p' "$d/.pkgusr" | head -1)"
ver="$(sed -n 's/^version=//p' "$d/.pkgusr" | head -1)"
ins="$(sed -n 's/^installed=//p' "$d/.pkgusr" | head -1)"
else
src="?"; ver=""; ins=""
fi
printf '%-24s %-8s %-14s %s\n' "$n" "${src:-?}" "${ver:--}" "${ins:--}"
done
done < <(pkgusr_roots)
}
cmd_install_as() {
need_root
local name="" script="" as_root=0
local -a cmdv=()
name="${1:-}"; shift || true
[ -n "$name" ] || die "usage: lfs-helper install-as <name> [--script <f>] [-- <command ...>]"
while [ $# -gt 0 ]; do
case "$1" in
--script) script="${2:?--script needs a file}"; shift 2 ;;
--as-root) as_root=1; shift ;;
--) shift; cmdv=("$@"); break ;;
*) die "unknown option: $1" ;;
esac
done
if [ -z "$script" ] && [ "${#cmdv[@]}" = 0 ]; then
die "give either --script <file> or -- <command ...>"
fi
_is_not_a_package "$name" && die "'$name' is a reserved step name"
# A generated step script is what `build` consumes, so write one and reuse
# the whole path rather than duplicating it here.
mkdir -p "$SCRIPTS" || die "cannot write $SCRIPTS"
local gen="$SCRIPTS/$name.sh"
if [ -n "$script" ]; then
[ -r "$script" ] || die "no such script: $script"
cp "$script" "$gen" || die "cannot copy $script"
else
{
printf '#!/bin/bash\n'
printf '# generated by: lfs-helper install-as %s\n' "$name"
printf 'set -e\n'
printf '%s\n' "$(_quote_cmd "${cmdv[@]}")"
} > "$gen" || die "cannot write $gen"
fi
chmod 755 "$gen"
# make it a known step, so status/list/manifests see it like any other
grep -qxF "$name" "$STATE_PROGRESS/steporder" 2>/dev/null || \
printf '%s\n' "$name" >> "$STATE_PROGRESS/steporder"
if [ "$as_root" = 1 ]; then
grep -qxF "$name" "$STATE_PROGRESS/rootsteps" 2>/dev/null || \
printf '%s\n' "$name" >> "$STATE_PROGRESS/rootsteps"
fi
say "# installing '$name' as a package user"
LFS_PKG_SOURCE="${LFS_PKG_SOURCE:-local}" cmd_build "$name" --force
}
_quote_cmd() {
local a out=""
for a in "$@"; do out="$out $(printf '%q' "$a")"; done
printf '%s' "${out# }"
}
# Files no manifest claims.
#
# The ownership pass can only act on what a manifest lists. A file installed
# before tracking existed, or by a step whose manifest was lost, stays
# root-owned forever and nothing says why -- it simply never appears in the
# report. "It did not change owner" and "no package claims it" look identical
# from the outside, and they need completely different fixes.
_vfy_unclaimed() {
# LC_ALL=C on every sort AND every comm below. comm validates ordering in
# its own locale, so a C-sorted file compared under a UTF-8 one is "not
# sorted" and the diff that comes back is wrong. Pinning the sort alone is
# half a fix -- the two have to agree.
local list="$STATE/tmp/claimed.$$" found="$STATE/tmp/found.$$" n
mkdir -p "$STATE/tmp" 2>/dev/null || return 0
# everything any manifest claims, with the host prefix stripped
: > "$list"
local man p
for man in "$MANIFESTS"/*.files "$MANIFESTS"/*.dirs; do
[ -s "$man" ] || continue
while IFS= read -r p; do
[ -n "$p" ] || continue
strip_host_prefix "$p" >> "$list"
done < "$man"
done
LC_ALL=C sort -u -o "$list" "$list" 2>/dev/null || true
# root-owned files under the shared install directories
: > "$found"
local d
while IFS= read -r d; do
[ -n "$d" ] || continue
d="${SNAP_ROOT%/}$d"
[ -d "$d" ] || continue
# /etc is configuration: root owns it by right, and the book writes
# most of it directly. Reporting those as unclaimed would bury the
# real cases in noise.
case "$d" in "${SNAP_ROOT%/}/etc"|"${SNAP_ROOT%/}/etc/"*) continue ;; esac
find "$d" -xdev -maxdepth 3 -type f -user root 2>/dev/null >> "$found"
done < <(install_dirs_list)
LC_ALL=C sort -u -o "$found" "$found" 2>/dev/null || true
# Drop the ones root owns ON PURPOSE. never_claim_list is the same rule
# the manifests use, and it has to apply here too or the report asks a
# question with no right answer -- the six command wrappers under
# /usr/lib/pkgusr are root:root BY DESIGN, and were listed as
# 14 root-owned file(s) that NO manifest claims
# under a heading that says to go and find out which package should own
# them. None should.
local _keep="$STATE/tmp/keep.$$"
: > "$_keep"
while IFS= read -r p; do
[ -n "$p" ] || continue
is_never_claimed "$p" || printf '%s\n' "$p" >> "$_keep"
done < "$found"
# benign: $_keep is a filtered copy of $found -- if the move fails the
# unfiltered list is still correct, just longer
mv -f "$_keep" "$found" 2>/dev/null || true
n="$(LC_ALL=C comm -23 "$found" "$list" 2>/dev/null | wc -l | tr -d ' ')"
if [ "${n:-0}" = 0 ]; then
rm -f "$list" "$found"
ok "every root-owned file is claimed by some manifest"
return 0
fi
warn "$n root-owned file(s) that NO manifest claims:"
LC_ALL=C comm -23 "$found" "$list" 2>/dev/null | head -8 | sed 's/^/ /' >&2
[ "$n" -gt 8 ] && warn " ... and $((n - 8)) more"
if [ -n "$VERIFY_LOG" ]; then
LC_ALL=C comm -23 "$found" "$list" 2>/dev/null \
| sed 's/^/unclaimed\t/' >> "$VERIFY_LOG" 2>/dev/null || true
warn " all $n: grep ^unclaimed $VERIFY_LOG"
fi
warn " These were installed before tracking, or their manifest was lost."
warn " Nothing can give them an owner: no package says it installed them."
warn " Find out which package should have:"
warn " lfs-helper which-package <path>"
rm -f "$list" "$found"
return 1
}
# NOTE: there were two cmd_verify definitions in this file. The second won --
# bash keeps the last -- so this one, the tidy one that called the _vfy_*
# helpers, had never run. Everything it did is in the surviving one below.
#
# That is why a finished build reported three things the _vfy_* guards already
# knew to leave alone: /usr/bin/hostname (installed by coreutils, then by
# inetutils, which owns it), /usr/share/info/dir and /etc/group- (owned by no
# package at all). The guards existed and were unreachable.
cmd_verify() {
need_root
# ONE PACKAGE, IF ONE IS NAMED.
#
# `lfs-helper verify xorg7-lib --fix` read only $1 for the flag, ignored
# the name, and began walking all 533 packages -- minutes of work to
# answer a question about one of them.
local fix=0 only="" a
for a in "$@"; do
case "$a" in
--fix|--run) fix=1 ;;
-*) ;;
*) [ -z "$only" ] && only="$a" ;;
esac
done
if [ -n "$only" ]; then
local _acct; _acct="$(pkg_owner_name "$only")"
if ! id "$_acct" >/dev/null 2>&1; then
die "no package user for '$only' (looked for '$_acct')"
fi
say "# checking only '$only' (account $_acct)"
fi
_vfy_log_open
local problems=0 fixed=0
_vfy_n_fixed=0
detail "# checking for accounts that share an id ..."
# ---- 1. two accounts sharing one id ----------------------------------- #
# Ownership is a number: a collision makes files report as the wrong name
# and a package unable to write its own files.
if ! _check_duplicate_ids; then
problems=$((problems+1))
warn " (verify cannot repair this: which name owns what is yours to decide)"
fi
detail "# checking the shared install directories ..."
# ---- 2. the shared install directories -------------------------------- #
# root:install, group-writable. A staged install used to stamp the package
# user's ownership onto these, handing /usr and /usr/bin to one package and
# losing the scheme entirely -- silently, until some later build failed.
# _vfy_install_dirs is the implementation; it knows that /bin, /sbin, /lib
# and /lib64 are symlinks into /usr and must be checked at their target,
# and it sets ownership by NUMBER so it works before book 7.6. This used
# to be a second copy that did neither.
_vfy_install_dirs "$fix" || problems=$((problems+1))
detail "# checking every package owns the files it installed ..."
# ---- 3. every manifest's files belong to its package ------------------- #
#
# A SECOND copy of _vfy_manifest_ownership used to live here, inline, and
# it had none of that function's guards. So it reported -- and with --fix
# would have ACTED ON -- three things the real pass knows to leave alone:
#
# /usr/bin/hostname is p_inetutils want p_coreutils
# /usr/share/info/dir is p_e2fsprogs want p_glibc
# /etc/group- is root want p_ncurses
#
# hostname is installed by coreutils and then again by inetutils, which is
# built later and legitimately owns it; taking it back for coreutils breaks
# inetutils. info/dir and the passwd backups belong to no package at all.
# The guards for every one of those already existed -- three hundred lines
# away, in the function this duplicated.
#
# One implementation. This calls it.
local n_wrong=0 checked=0
_vfy_n_checked=0; _vfy_n_wrong=0
_vfy_manifest_ownership "$fix" "$only"
checked="$_vfy_n_checked"; n_wrong="$_vfy_n_wrong"
if [ "$n_wrong" -gt 0 ]; then
[ "$n_wrong" -ge 8 ] && warn " ... and $((n_wrong - 8)) more"
[ -n "$VERIFY_LOG" ] && warn " all $n_wrong: grep ^wrong-owner $VERIFY_LOG"
problems=$((problems+1))
else
ok "package files: $checked path(s) owned by the right package"
fi
detail "# looking for files no manifest claims ..."
# ---- 3b. files no manifest claims ------------------------------------- #
# Refresh our own tools' manifest first (it is rewritten from what is on
# disk, so a file added by a newer `make install` -- the completion file --
# gets claimed here instead of reported below). Only under --fix: a bare
# verify changes nothing, not even its own records.
[ "$fix" = 1 ] && _adopt_pkgusr_tools 2>/dev/null
_vfy_unclaimed || problems=$((problems+1))
if [ "$fix" = 1 ]; then share_shared_files; fi
detail "# checking that install records describe reality ..."
_vfy_lying_records "$fix" || problems=$((problems+1))
detail "# looking for files with no owner ..."
# ---- 4. files with no owner at all ------------------------------------ #
if _has_orphaned_files; then
warn "some files are owned by a uid with no passwd entry"
problems=$((problems+1))
if [ "$fix" = 1 ]; then
# _vfy_orphans, NOT cmd_fix_orphans.
#
# There is no cmd_fix_orphans -- `fix-orphans` is dispatched as an
# alias for `verify --fix`, so calling it here was both undefined
# and circular. It died mid-repair with
# line 4793: cmd_fix_orphans: command not found
# after chowning 3473 paths, leaving the pass half-done and the
# exit status wrong. A missing function in the repair path is the
# worst place for one: it fires only when something is already
# broken.
_vfy_orphans 1 || true
else
warn " repair with: lfs-helper fix-orphans --run"
fi
else
ok "every file has a real owner"
fi
# ---- 3c. setid bits the build refused --------------------------------- #
# A CHECK MUST SAY WHAT IS MISSING RATHER THAN WAIT TO BE ASKED. These
# were printed once, hours earlier, in the middle of a build log.
local _sid=0 _sf _su
while IFS= read -r _su; do
_sf="$(pkgusr_home_for "$_su" 2>/dev/null)/.pkgusr-setid.requests"
[ -s "$_sf" ] && _sid=$((_sid + $(grep -c . "$_sf")))
done < <(pkg_users_list)
if [ "$_sid" -gt 0 ]; then
warn "$_sid setuid/setgid bit(s) were refused during the build and never applied"
warn " what and why: lfs-helper setid"
warn " apply them: lfs-helper setid --run"
problems=$((problems+1))
fi
detail "# checking homes, the build user and the uid order ..."
# ---- 4b. the passes the dead copy had and this one did not ------------- #
# homes owned by their own user, the build user's leftovers, and the uid
# order. They were written, and reachable only from the definition bash
# discarded.
[ "$fix" = 1 ] && ensure_pkgusr_roots 2>/dev/null
_vfy_user_homes "$fix" || true
_vfy_misplaced_homes "$fix" || problems=$((problems+1))
_vfy_build_user_leftovers "$fix" || true
_vfy_user_order "$fix" || true
detail "# looking for left-over staging trees ..."
# ---- 5. staging trees left behind ------------------------------------- #
local st n_stage=0
for st in "$STATE"/stage/*; do
[ -d "$st" ] || continue
n_stage=$((n_stage+1))
warn "left-over staging tree: $st"
warn " that build did not finish; the system was not touched"
done
[ "$n_stage" -gt 0 ] && problems=$((problems+1))
say ""
if [ "$problems" = 0 ]; then
ok "the tree matches what the manifests say it should be."
return 0
fi
[ -n "$VERIFY_LOG" ] && say "full report: $VERIFY_LOG"
fixed=$((fixed + _vfy_n_fixed))
if [ "$fix" = 1 ]; then
ok "repaired $fixed thing(s); $problems area(s) had problems"
say "Run verify again to confirm."
else
warn "$problems area(s) need attention. Repair with:"
say " lfs-helper verify --fix"
fi
return 1
}
# Our own scripts are software installed into /usr/bin, so they get a package
# user like anything else. Without one they are reported forever as
# 1516 root-owned file(s) that NO manifest claims:
# /usr/bin/lfs-helper
# which is true, unhelpful, and buries the cases that matter.
#
# `lfs` copies them in and chowns them when it can, but on a fresh tree it
# cannot: /etc/passwd does not exist until book 7.6. So do it here, once the
# user database is real, and write a manifest so verify and which-package can
# answer for them.
# Everything this toolchain installs into the tree and should own.
#
# The four tools plus packagemanager_install -- and the package-users hint's
# helper scripts, which _install_pkgusr_helpers copies in beside them. Those
# were missing, so a finished build reported eight of them as unclaimed:
# /usr/bin/list_package, uninstall_package, forall_direntries_from,
# grep_all_regular_files_for, both list_suspicious_files,
# /usr/sbin/add_package_user, install_package
# They are ours, they arrived with the tools, and they belong to the same user.
#
# The command WRAPPERS are deliberately not here: they stay root:root so a
# package user cannot rewrite the rule that constrains it. never_claim_list
# covers them, so verify stops asking.
PKGUSR_TOOLS="lfs-helper packagemanager packagemanager_install blfs lfs lfs-phases
add_package_user install_package list_package uninstall_package
forall_direntries_from grep_all_regular_files_for
list_suspicious_files list_suspicious_files_from"
_adopt_pkgusr_tools() {
pkgusr_ready || return 0
local owner; owner="$(pkg_owner_name pkgusr)"
user_exists "$owner" || ( cmd_add_user "$owner" ) >/dev/null 2>&1 || return 0
user_exists "$owner" || return 0
local man="$MANIFESTS/pkgusr.files" n=0 t p d
: > "$man.new" 2>/dev/null || return 0
for t in $PKGUSR_TOOLS; do
# /usr/bin AND /usr/sbin: the hint's add_package_user and
# install_package go to sbin, and looking only in bin is why a finished
# build still reported them as claimed by nobody. The completions dir
# for the same reason one release later: `make install` puts the
# bash completion there, and verify reported it as claimed by nobody.
for d in /usr/bin /usr/sbin /usr/share/bash-completion/completions; do
p="${SNAP_ROOT%/}$d/$t"
[ -e "$p" ] || continue
echo "$p" >> "$man.new"
# never take a file another package installed
local cur; cur="$(stat -c %U "$p" 2>/dev/null)"
case "$cur" in
"$owner") continue ;;
root|lfs|UNKNOWN|"") ;;
*) user_exists "$cur" && continue ;;
esac
real_chown -h "$owner:$owner" "$p" 2>/dev/null && n=$((n+1))
done
done
mv -f "$man.new" "$man" 2>/dev/null || rm -f "$man.new"
[ "$n" -gt 0 ] && detail "# gave $n tool(s) to '$owner'"
return 0
}
# --------------------------------------------------------------------------- #
# pm-install: what was packagemanager_install (item 6, step 7)
# --------------------------------------------------------------------------- #
#
# The fifth tool is gone. Its build loop moved here in 1.11.57 (cmd_build
# --script); this section is the FRONT-MATTER that survived it, transplanted
# rather than rewritten -- the exact bash that carried a full fresh build.
# packagemanager calls `lfs-helper pm-install <account> <script>` where it
# used to exec /usr/bin/packagemanager_install.
#
# What was deliberately dropped, with the user's sign-off (no compatibility
# required): the old-style installBaseScript/vim editing flow, and the
# engine's private runner/retry/wrapper/recorder block -- the one loop does
# all of that, for every caller.
# The desktop user that u_ application accounts serve. Was hardcoded in the
# engine; now read from config, same default so an existing system behaves
# identically.
_pm_home_user() { echo "${LFS_HOME_USER:-n76310}"; }
# Stage an already-downloaded tarball into the account's home, so an install
# never dials out for a file that is sitting in /sources.
_pm_stage_source() {
local _u="$1" _script="$2" _pkg _home _d
[ -f "$_script" ] || return 0
_pkg="$(sed -n 's/^[[:space:]]*pkg=["'"'"']\{0,1\}\([^"'"'"']*\)["'"'"']\{0,1\}[[:space:]]*$/\1/p' \
"$_script" | head -n1)"
case "$_pkg" in ""|*'$'*) return 0 ;; esac
_home="$(_recorded_home_for "$_u")"
[ -n "$_home" ] && [ -d "$_home" ] || return 0
[ -f "$_home/$_pkg" ] && return 0
for _d in "${LFS_SOURCES_DIR:-/sources}" /sources; do
if [ -f "$_d/$_pkg" ]; then
cp "$_d/$_pkg" "$_home/$_pkg" || return 0
chown "$_u:$_u" "$_home/$_pkg" 2>/dev/null
ok "staged $_d/$_pkg -> $_home (already downloaded)"
return 0
fi
done
return 0
}
# Create an account the way the engine did: the hint's add_package_user, then
# the ONE home repair, then the ONE sorter. Used for every pm-install
# account -- p_, nimgnu_ and u_ alike -- because this exact sequence is what
# the fresh build proved.
_pm_add_account() {
need_root
add_package_user "$1" "$1" 10000 20000 "$1" 10000 20000 || die "could not create account $1"
_repair_misplaced_home "$1" 1 1 || true
cmd_sort_users --run >/dev/null 2>&1 || true
}
_pm_user_in_group() {
grep "^$2:" "$ETC/group" 2>/dev/null | sed "s/.*://g;s/,/\n/g" | grep -qw "^$1$"
}
_pm_add_collector_group() {
local group_name="$1"
if ! echo "$group_name" | grep -qw "audio"; then
if ! echo "$group_name" | grep -q "^u_"; then
echo "$group_name" | grep -q "^nimgnu_" ||
group_name="nimgnu_$1"
fi
fi
say ""
say "Create collector group $group_name"
if grep -q "^$group_name:" "$ETC/group" && grep -q "^$group_name:" "$ETC/passwd"; then
ok "group and user exist"
return 0
fi
if ! assume_yes; then
printf 'create collector account %s? [y/N]: ' "$group_name"
local check; read -e -r check
[ "$check" = "y" ] || die "declined creating collector group $group_name"
fi
_pm_add_account "$group_name"
}
_pm_join_group() {
local user="$1" group_name="$2"
if ! echo "$group_name" | grep -qw "audio"; then
if ! echo "$group_name" | grep -q "^u_"; then
echo "$group_name" | grep -q "^nimgnu_" ||
group_name="nimgnu_$2"
fi
fi
_pm_user_in_group "$user" "$group_name" && {
ok "$user is already in $group_name"; return 0; }
usermod -a -G "$group_name" "$user" \
|| warn "could not add $user to $group_name"
_pm_user_in_group "$user" "$group_name" \
&& ok "$user is now in $group_name"
}
# install_groups=( ... ) in the script: offer collector-group membership.
_pm_check_groups() {
local script="$1" user="$2" group check
grep -q "^install_groups=(.*)" "$script" || return 0
[ "${_pm_sourced:-0}" = 1 ] \
|| eval "$(grep --color=never "^install_groups=(.*)" "$script")"
warn "This user may need to be added to collector groups:"
for group in "${install_groups[@]}"; do
[ "$group" = "python-dependencies" ] && group="nimgnu_python-modules"
# resolve to the collector-prefixed group if that is the one that exists
if ! grep -q -E "^$group:" "$ETC/group" \
&& grep -q -E "^nimgnu_$group:" "$ETC/group"; then
group="nimgnu_$group"
fi
if _pm_user_in_group "$user" "$group"; then
ok "$user is already in $group"
continue
fi
grep -q -E "^$group:" "$ETC/group" || _pm_add_collector_group "$group"
if assume_yes; then
warn " usermod -a -G $group $user (--yes)"
_pm_join_group "$user" "$group"
else
warn "By adding to a group the user gets access to all files of the group"
say " usermod -a -G $group $user"
printf '[y/N]: '
read -e -r check
[ "$check" = "y" ] && _pm_join_group "$user" "$group"
fi
done
return 0
}
_pm_groups_of_dir() {
local dir="$1" d
for d in "$dir"/*; do
[ -e "$d" ] && stat -c %G "$d"
done | sort -u
}
# nimgnu_ collector accounts: take over the named directories.
_pm_install_collector_dirs() {
local user="$1" g f old_group check param dir
say ""
say "Install directories for collector group $user"
for dir in "${nimgnu_groups_dir[@]}"; do
old_group="$(stat -c %G "$dir" 2>/dev/null)"
say "Found groups in $dir:"
_pm_groups_of_dir "$dir"
for g in $(_pm_groups_of_dir "$dir"); do
if [ "$g" = "$old_group" ]; then
say "nothing to change"
else
_pm_join_group "$g" "$old_group" >/dev/null 2>&1 || true
fi
done
for f in "$dir"/*; do
[ -e "$f" ] || continue
# give the group the user's own rights on each entry
chmod "g=$(stat -c %A "$f" | cut -c2-4)" "$f"
done
su -c "mkdir -p '$dir'" "$user" 2>/dev/null || mkdir -p "$dir"
if [ -d "$dir" ]; then
param=""
if [ "${nimgnu_groups_dir_recrusive:-}" = "true" ]; then
if assume_yes; then
param="-R"
else
say "change permission recursive for $user on: $dir"
printf '[y/N]: '
read -e -r check
[ "$check" = "y" ] && param="-R"
fi
fi
{ chgrp $param "$user" "$dir" && chmod $param 775 "$dir"; } \
|| warn "could not hand $dir to group $user"
else
warn "$dir: is not a valid directory"
fi
if [ -n "$old_group" ] && [ "$user" != install ] && [ "$user" != root ] \
&& [ "$old_group" != root ] && [ "$user" != "$old_group" ]; then
_pm_join_group "$user" "$old_group"
fi
done
}
# u_ application accounts: pam su rule, shared dir, launcher + .desktop entry.
_pm_install_app_user() {
local user="$1" script="$2" home="$3"
local home_user home_user_dir t linenumber s_dir_nam app
home_user="$(_pm_home_user)"
home_user_dir="/home/$home_user"
say ""
say "Install application user $user (for $home_user)"
if [ -f "$ETC/pam.d/su_u_user" ]; then
linenumber="$(wc -l < "$ETC/pam.d/su_u_user")"
if sed "${linenumber}q;d" "$ETC/pam.d/su_u_user" | grep -q "success=1 default=ignore"; then
t="success=ignore default=1"
else
t="success=1 default=ignore"
fi
grep -q "$user).*USER.*\<$user\>.*\<$home_user\>" "$ETC/pam.d/su_u_user" ||
sed -i "$((1+$(grep -n 'case "$PAM_USER" in' "$ETC/pam.d/su_u_user" | sed "s/\:.*$//g")))i\ \ $user) USERS=\"$user $home_user\"; ;;" "$ETC/pam.d/su_u_user"
say "modified pam.d/su_u_user"
else
warn "$ETC/pam.d/su_u_user does not exist -- su rules not installed"
fi
chmod -R 0750 "$home"
if grep -q "^install_groups=(" "$script"; then
eval "$(grep "^install_groups=(" "$script")"
_pm_check_groups "$script" "$user"
if echo "${install_groups[@]}" | grep -wq u_xdg_runtime; then
rm -f "$home/.bash_profile"
ln -s /etc/pkgusr/skel-u_xdg/.bash_profile "$home/.bash_profile"
chmod 755 "$home/.bash_profile" 2>/dev/null
chown "$user:" "$home/.bash_profile"
fi
fi
if grep -q "^shared=true" "$script"; then
s_dir_nam="$(echo "$user" | sed "s/^u_//g")"
mkdir -p "$home/${s_dir_nam^}_shared"
chmod g+sw "$home/${s_dir_nam^}_shared"
chmod 770 "$home/${s_dir_nam^}_shared"
chown -R "$user:" "$home/${s_dir_nam^}_shared"
ln -sf "$home/${s_dir_nam^}_shared" "$home_user_dir/Shared" 2>/dev/null
chown -R "$home_user:" "$home_user_dir/Shared/${s_dir_nam^}_shared" 2>/dev/null
fi
if grep -q "^app=" "$script"; then
eval "$(grep "^app=" "$script")"
if command -v "$app" >/dev/null 2>&1; then
if [ ! -f "$home_user_dir/bin/$app" ]; then
mkdir -p "$home_user_dir/bin" "$home_user_dir/.local/share/applications" 2>/dev/null
cat > "$home_user_dir/bin/$app" << PMEOF
#!/bin/bash
su - u_$app -c "$app \$(echo "\${@}" | sed "s/\"/\\\\\"/g")"
PMEOF
cat > "$home_user_dir/.local/share/applications/$app.desktop" << PMEOF
[Desktop Entry]
Name=${app^}
Exec=script -c "$home_user_dir/bin/$app" /dev/null
Terminal=false
Type=Application
Icon=${app}
StartupWMClass=${app^}
PMEOF
chown "${home_user}:" "$home_user_dir/bin/$app"
chmod +x "$home_user_dir/bin/$app"
chown "${home_user}:" "$home_user_dir/.local/share/applications/$app.desktop"
ok "created $home_user_dir/bin/$app and its .desktop entry"
fi
else
warn "$app not found -- launcher not created"
fi
fi
usermod -a -G "$user" "$home_user" 2>/dev/null \
|| warn "could not add $home_user to group $user"
}
cmd_pm_install() {
need_root
local user_name="" install_script="" a
for a in "$@"; do
case "$a" in
-h|--help) cmd_help_for pm-install; return 0 ;;
-*) die "unknown option: $a (usage: lfs-helper pm-install <account> <script>)" ;;
*) if [ -z "$user_name" ]; then user_name="$a"
elif [ -z "$install_script" ]; then install_script="$a"
else die "usage: lfs-helper pm-install <account> <script>"; fi ;;
esac
done
[ -n "$user_name" ] && [ -n "$install_script" ] \
|| die "usage: lfs-helper pm-install <account> <script>"
[ -f "$install_script" ] || die "no such install script: $install_script"
# The naming rule is ours; asking is idempotent, so a name that already
# carries its prefix comes back unchanged. nimgnu_/u_ names pass through
# pkg_owner_name untouched by construction.
user_name="$(pkg_owner_name "$user_name")"
# AN ACCOUNT THAT ALREADY EXISTS CAN STILL BE POINTING AT THE WRONG HOME.
#
# The repair runs when an account is CREATED (add_package_user puts the
# home in /usr/src/<name>, one line before it is moved). An account made
# by an older version -- or carried over from a previous system -- was
# never re-examined, so /etc/passwd still said /usr/src/p_elogind while
# every tool used /usr/src/pkgusr/p_elogind. `su -` starts in the PASSWD
# home, so the build ran in one directory and everything else looked in
# the other: two half-populated homes, and a build that could not find its
# own sources.
user_exists "$user_name" && _repair_misplaced_home "$user_name" 1 || true
local home
home="$(pkgusr_home_for "$user_name")"
say ""
say "-------------------------------"
say "Install $user_name"
say "-------------------------------"
say "install file: $install_script"
# "CHECK THAT THIS IS THE RIGHT ACCOUNT" IS A FIRST-INSTALL QUESTION.
#
# It is worth asking once: a typo in the account name on a FIRST install
# scatters a package across the wrong home and is tedious to undo. It is
# worth nothing on an update -- the account exists, it already owns this
# package's files, and the answer was settled the day it was installed.
# Asking anyway turned every `update <pkg> --run` into a keypress, and a
# prompt people press through is a prompt that has stopped protecting
# anything.
local _known=0
if user_exists "$user_name"; then
[ -s "$(pkgusr_home_for "$user_name")/pkg.lst" ] && _known=1
[ -f "$(pkgusr_home_for "$user_name")/.pkgusr-installed" ] && _known=1
fi
if ! assume_yes && [ "$_known" != 1 ]; then
warn "First check the install file for: link, name, wrong commands"
warn "check that \"$user_name\" is the right account"
printf 'Press enter to continue '
read -r -n1 _
say ""
elif [ "$_known" = 1 ]; then
say "# $user_name is an existing package with files recorded -- not asking"
fi
# Phased scripts carry their metadata (install_groups, nimgnu_groups_dir,
# app=, shared=) as top-level assignments; sourcing is how the engine read
# them, so it is how this reads them.
local _pm_sourced=0
if grep -q "^install_pkg() {" "$install_script"; then
_pm_sourced=1
# OUR shell options must not govern THEIR file. We source the script
# only to read its metadata, and this shell runs under set -u; a book
# string that merely LOOKS like a variable then killed the install
# before it started:
# install_texlive...: line 25: TEXLIVE_PREFIX: unbound variable
# The generator no longer emits such strings unquoted (1.12.18), but
# the rule stands on its own: sourcing someone else's file must not
# impose our strictness on it, or every future script that trips one
# of our settings fails for a reason that is ours, not theirs.
local _pm_opts="$-"
set +u +e
# shellcheck disable=SC1090
source "$install_script"
case "$_pm_opts" in *u*) set -u ;; esac
case "$_pm_opts" in *e*) set -e ;; esac
fi
user_exists "$user_name" || _pm_add_account "$user_name"
user_exists "$user_name" || die "the account $user_name could not be created"
# The tarball, now that there is a home to put it in.
_pm_stage_source "$user_name" "$install_script"
# Ask again, now that the account exists -- creating it may have settled
# on a different home than the one computed above.
local _now_home
_now_home="$(_recorded_home_for "$user_name")"
[ -n "$_now_home" ] && home="$_now_home"
mkdir -p "$home/log"
_pm_check_groups "$install_script" "$user_name"
# The canonical, editable script (edit THIS to keep changes across
# re-installs), the runtime copy the loop executes, a read-only record of
# what was last installed, and a diff against it.
# ONE FILE TO EDIT, ONE TO RUN, ONE RECORD.
#
# There used to be a fourth: install_p_<user>, a second runtime copy of
# the very same text. Every failure then had to explain itself --
# "script: install_p_hatchling / staged: install_hatchling / edit:
# install_Hatchling-1.28.0" -- three names for one script, and the one
# that ran was the one the user was told NOT to edit. The canonical
# file is runnable; run it. install_p_<user> is removed when it is a
# copy, and left alone if someone has edited it.
local base; base="$(basename "$install_script")"
diff "$home/install_last" "$install_script" \
>> "$home/log/install_$user_name-$(date +%Y-%m-%d_%H-%M-%S).diff" 2>/dev/null
[ "$install_script" -ef "$home/$base" ] || cp "$install_script" "$home/$base"
chmod 0755 "$home/$base" 2>/dev/null || true
chown "$user_name:" "$home/$base" 2>/dev/null || true
if [ -f "$home/install_$user_name" ]; then
if cmp -s "$home/install_$user_name" "$home/$base"; then
rm -f "$home/install_$user_name"
else
warn "$home/install_$user_name differs from $base and is no longer"
warn " used -- move your edits into $base (or delete the old file)"
fi
fi
# RECORD WHAT IS INSTALLED BEFORE OVERWRITING THE RECORD.
#
# `update` reads the installed version from .pkgusr-installed, written
# only after a successful install -- and, for a package built before that
# marker existed, from install_last, which is replaced HERE, before the
# build. LLVM 21.1.8 (no marker) failed on 22.1.8's patch, and the next
# plan dropped it as current: spirv-llvm-translator then asked for LLVM 22
# and found 21.1.8. So the first time through, the old install_last's
# version becomes the marker, and a failure leaves it there.
if [ ! -s "$home/.pkgusr-installed" ] && [ -f "$home/install_last" ] \
&& ! [ "$install_script" -ef "$home/install_last" ]; then
local _was
_was="$(sed -n "s/^name_version=['\"]\{0,1\}\([^'\"]*\).*/\1/p" "$home/install_last" 2>/dev/null | head -n1)"
if [ -n "$_was" ]; then
printf '%s\n' "$_was" > "$home/.pkgusr-installed" \
&& chown "$user_name:" "$home/.pkgusr-installed" 2>/dev/null || true
fi
fi
if ! [ "$install_script" -ef "$home/install_last" ]; then
rm -f "$home/install_last" 2>/dev/null
cp "$install_script" "$home/install_last"
chmod 0444 "$home/install_last" 2>/dev/null || true
fi
chown "$user_name:" "$home"/install_* 2>/dev/null
chown -R "$user_name:" "$home/log/" 2>/dev/null
# The special installers, keyed on what the account IS.
case "$user_name" in
nimgnu_*) _pm_install_collector_dirs "$user_name" ;;
u_*) _pm_install_app_user "$user_name" "$install_script" "$home" ;;
esac
# THE ONE LOOP. Phased scripts go through cmd_build -- same wrappers,
# same grant-and-retry, same records, same failure box as every other
# install on this system. A rare old-style script (no dispatcher) still
# runs through the one runner, untracked, and is recorded afterwards.
local rc=0
if [ "$_pm_sourced" = 1 ]; then
local _extra=""
[ "${PM_NO_AUTO_FIX:-0}" = "1" ] && _extra="--no-auto-fix"
cmd_build "$user_name" --script "$home/$base" \
--phase "${PM_MODE:-all}" --force $_extra
rc=$?
else
run_phase_as "$user_name" "$home/$base" "" \
"$home/log/pm-install-$(date +%Y-%m-%d_%H-%M-%S).log"
rc=$?
[ "$rc" = 0 ] && [ "${PM_NO_REFRESH:-}" != "1" ] \
&& { cmd_record_install "$user_name" >/dev/null 2>&1 || true; }
fi
[ "$rc" = 0 ] || return "$rc"
# After-care: the man-page db, detached so Ctrl-C cannot interrupt the
# finished install. It runs into the NEXT package's attribution window,
# which is half of why manifests over-claimed -- _settle_tree_before_stamp
# now waits for exactly this before the next window opens, so detaching
# stays safe.
# the library cache first: a package that just installed a library is not
# usable until ldconfig has seen it, and the wrapper skipped it
# -X: REBUILD THE CACHE, TOUCH NO SYMLINKS.
#
# ldconfig does two jobs: it writes /etc/ld.so.cache, and it creates or
# updates SONAME symlinks (libfoo.so.6 -> libfoo.so.6.2.1) in the library
# directories. The second one, run as root, drops root-owned symlinks
# into package-owned trees -- which is exactly how libfreetype.so.6 came
# to be owned by p_libpaper earlier in this build, and how a package's
# own file stops belonging to it. The cache is root's to rebuild; the
# symlinks belong to the package that installs the library, and its own
# `make install` creates them.
soft "could not refresh the library cache (ldconfig -X)" -- ldconfig -X
warn "Installed. Refreshing mandb in the background..."
setsid mandb >/dev/null 2>&1 </dev/null & disown 2>/dev/null || true
return 0
}
# --------------------------------------------------------------------------- #
# the ownership epoch
# --------------------------------------------------------------------------- #
#
# ONE step where ownership becomes real, and one marker that says it has.
#
# Before it, ownership is not merely wrong, it is MEANINGLESS. A chapter 5-6
# tree has no /etc/passwd -- book 7.6 creates it -- so no name resolves, not
# even root's; the prompt in there literally reads "I have no name!". Every
# ownership question asked before that point gets a wrong answer with total
# confidence:
#
# * `chown root:install` fails on every directory, because `root` is not a
# name yet, and init-pkgusr reported
# # 0 install directories are now root:install
# # could not set the group on: /bin /sbin /lib /usr /usr/bin ...
# * `find -nouser` matches EVERY file, because nothing resolves, so a
# verify reported 11802 orphans including / and /proc -- and `--fix` would
# have handed the entire tree to the build user in one pass.
#
# These were three separate bugs with one cause: work that needs a user database
# was being done before there was one. So there is now a single gate. Nothing
# touches ownership before it; everything maintains ownership after it.
#
# It is idempotent and it is resumable -- a snapshot restored from before the
# epoch simply crosses it again.
OWNERSHIP_MARK="$STATE_PROGRESS/ownership-established"
ownership_established() { [ -f "$OWNERSHIP_MARK" ]; }
# --------------------------------------------------------------------------- #
# The install record -- ONE function, two strategies (item 6, step 1)
# --------------------------------------------------------------------------- #
#
# What a package installed was written down twice, by two drivers, in two
# places: lfs-helper's $MANIFESTS/<n>.files (scan by TIMESTAMP) and
# packagemanager_install's ~/pkg.lst (scan by OWNER). Each tool read only its
# own, so `packagemanager update` reported "0 of 97 judged" on a system where
# every one of those packages was recorded -- in the other file.
#
# Both strategies have to stay, because each is the only one that works in its
# own window:
#
# * SCAN BY TIMESTAMP is the build-stage recorder. Early in the LFS build a
# package's files are not owned by its package user yet -- ownership is
# established at one later step -- so an owner scan returns nothing. It is
# also the only one that sees a config step REWRITE an existing file
# without installing a new one.
# * SCAN BY OWNER (list_package) is the default everywhere after that. It is
# self-correcting: it reports the tree as it is now, not as some past run
# recorded it.
#
# So the choice is made ONCE, here, by one named predicate -- not by an inline
# test in each driver, which is the shape of every bug this project has had.
_record_strategy() {
ownership_established && echo owner || echo timestamp
}
# Write ~/pkg.lst for one package by scanning for files it owns.
# Returns 1 (and writes nothing) when the scan finds nothing: a package that
# owns no files has not installed, and an empty record must never overwrite a
# real one. Empty-as-pass has cost this project three separate bugs.
_write_pkg_lst() {
local name="$1" owner home tmp n
owner="$(pkg_owner_name "$name")"
user_exists "$owner" || return 1
home="$(pkgusr_home_for "$name")"
[ -d "$home" ] || return 1
# SCAN_PRUNE is empty until this runs, and an unpruned scan sweeps
# /build, /sources and the unpacked source tree into the record -- the
# exact bug that once put 3348 scratch files into a manifest.
scan_prune_set
tmp="$home/pkg.lst.new"
# /var/mail/<owner> IS NOT AN INSTALLED FILE.
#
# useradd creates a mail spool for every account, so it is owned by the
# package user and the scan picks it up -- and then a package that
# installed NOTHING has a one-line manifest whose one path exists, which
# is the evidence `state` accepts for "installed". That is how Pathspec,
# whose install step had been commented out by a bad filter, was skipped
# by the planner and Hatchling died on `No module named 'pathspec'`.
# An empty record is honest; a record of the account's own furniture is
# not.
find "${SNAP_ROOT%/}/" -xdev -user "$owner" "${SCAN_PRUNE[@]}" \
-not -path "$home" -not -path "$home/*" \
-not -path "${SNAP_ROOT%/}/var/mail/*" 2>/dev/null \
| LC_ALL=C sort -u > "$tmp" || { rm -f "$tmp"; return 1; }
n="$(count_stdin_lines < "$tmp")"
if [ "${n:-0}" -eq 0 ]; then
rm -f "$tmp"
return 1
fi
chown "$owner" "$tmp" 2>/dev/null || true
mv "$tmp" "$home/pkg.lst" || return 1
return 0
}
# THE recorder. Every driver calls this after a successful install.
# record_install <name>
# The manifest is always kept: it holds what the build TOUCHED, which an owner
# scan cannot know, and cleanup needs it. pkg.lst is written whenever
# ownership makes it meaningful, so both files exist and agree.
cmd_record_install() {
local name="${1:-}"
[ -n "$name" ] || die "usage: lfs-helper record-install <package>"
local strategy; strategy="$(_record_strategy)"
case "$strategy" in
owner)
if _write_pkg_lst "$name"; then
say "# recorded $name by owner scan (pkg.lst)"
else
# not fatal, and not silent: the manifest still stands
warn "# $name: owner scan found nothing -- pkg.lst not written"
warn "# (the build-stage manifest is still the record)"
return 1
fi ;;
*)
say "# $name: recorded by the build-stage manifest (ownership is"
say "# not established yet; pkg.lst follows at that step)" ;;
esac
return 0
}
# Give every package a pkg.lst once ownership exists. Run at the ownership
# step, so a package built during the build stage gets its owner-scanned
# record without being rebuilt.
cmd_reconcile_records() {
need_root
ownership_established || {
warn "# ownership is not established yet -- nothing to reconcile."
return 0
}
local run=0; [ "${1:-}" = "--run" ] && run=1
local n=0 miss=0 name
while IFS= read -r name; do
[ -n "$name" ] || continue
local home; home="$(pkgusr_home_for "$name")"
[ -d "$home" ] || continue
[ -s "$home/pkg.lst" ] && continue
if [ "$run" = 0 ]; then
n=$((n+1)); continue
fi
if _write_pkg_lst "$name"; then n=$((n+1)); else miss=$((miss+1)); fi
done < <(steps 2>/dev/null || true)
if [ "$run" = 0 ]; then
say "# $n package(s) have no pkg.lst yet"
warn "(dry run -- re-run with --run to write them)"
else
ok "# wrote pkg.lst for $n package(s)"
[ "$miss" -gt 0 ] && \
warn "# $miss package(s) own no files -- left for verify to explain"
fi
return 0
}
# Can ownership be established yet? Prints why not, when asked to.
ownership_possible() {
local explain="${1:-}" why=""
_user_db_ready || why="there is no user database yet ($ETC/passwd) -- book 7.6 creates it"
if [ -z "$why" ]; then
local t
for t in chown chgrp chmod; do
command -v "$t" >/dev/null 2>&1 || why="$t is not available yet"
done
fi
[ -z "$why" ] || { [ -n "$explain" ] && say " ($why)"; return 1; }
return 0
}
# Cross it. Everything that decides who owns what happens here, in order.
establish_ownership() {
local run="${1:-1}"
if ! ownership_possible explain; then
return 1
fi
if [ "$run" != 1 ]; then
say "Would establish ownership now: install group, install directories,"
say "the build user's leftovers, package users, adoption."
return 0
fi
say ""
say "=============================================================="
say " Establishing ownership"
say ""
say " The user database exists now, so who-owns-what finally has an"
say " answer. Everything before this point ran as root and was not"
say " tracked; everything after it is owned by the package that"
say " installed it."
say "=============================================================="
# 1. the install group and the shared directories
pkgusr_ready || cmd_init_pkgusr --run || true
ensure_install_dirs_writable 2>/dev/null || true
# and the account roots. These were only ever touched when a home was
# created, so a tree built before their mode changed kept the old one and
# nothing repaired it -- ownership work belongs at the ownership step.
ensure_pkgusr_roots
# 2. anything the build user still owns from chapters 5-6. Book 7.2 hands
# the tree to root OUTSIDE the chroot; a restored snapshot, or a tree
# whose /usr already looked root-owned, can arrive here without it --
# /bin -> usr/bin was still lfs:lfs on one.
say ""
say "Handing back anything the build user still owns:"
_vfy_build_user_leftovers 1 || true
# 3. package users for everything already built, in build order, and the
# manifests reconciled against them
if [ -d "$MANIFESTS" ] && _has_unadopted_packages; then
say ""
say "Giving the already-built packages their own users:"
_vfy_manifest_ownership 1 || true
_mark_all_adopted
cmd_sort_users --run >/dev/null 2>&1 || true
fi
# 4. our own tools
_adopt_pkgusr_tools 2>/dev/null || true
# 5. files with no owner -- meaningful only now, for the same reason
if _has_orphaned_files; then
say ""
say "Files with no owner (created outside this chroot):"
_vfy_orphans 1 || true
fi
mkstate
date '+%Y-%m-%d %H:%M:%S' > "$OWNERSHIP_MARK" 2>/dev/null || true
say ""
ok "# ownership established -- every install from here on is tracked"
say "# check it any time with: lfs-helper verify"
say ""
return 0
}
# Called after every step in build-all: cross the epoch the moment it becomes
# possible, and never before. One call site, so it cannot half-happen.
_ownership_checkpoint() {
ownership_established && { mark_done init-ownership; return 0; }
ownership_possible || return 0
establish_ownership 1 || true
# `init-ownership` is a step now, so `list` and `next` must agree with what
# actually happened -- including on a tree whose steporder predates it.
ownership_established && mark_done init-ownership
return 0
}
cmd_establish_ownership() {
need_root
local run=0
[ "${1:-}" = "--run" ] && run=1
if ownership_established; then
ok "# ownership was established on $(cat "$OWNERSHIP_MARK" 2>/dev/null)"
say "# re-run it anyway with: lfs-helper verify --fix"
return 0
fi
if ! ownership_possible explain; then
warn "Not yet. Build the chapter-7 steps first:"
warn " lfs-helper build-all"
return 1
fi
establish_ownership "$run"
}
# Were these scripts generated by the version of the tools now installed?
#
# The step order and the scripts live in the TREE; the tools live outside it.
# Nothing connected the two, so a tree generated by an older version went on
# running steps that version had:
#
# ===== [104/105] last-step =====
# chown: invalid user: 'wget:wget'
#
# under a lfs-helper (1.10.0) that had removed `last-step` entirely and no
# longer knew what it was. The chroot TOOLS already carry a build id for
# exactly this reason -- a stale copy has cost more debugging time here than
# anything else -- and the generated scripts did not.
_warn_if_scripts_are_stale() {
local f="$STATE_PROGRESS/generated-by" gen=""
[ -f "$f" ] && gen="$(sed -n 's/^version=//p' "$f" | head -n1)"
[ "$gen" = "$LFS_HELPER_VERSION" ] && return 0
warn ""
if [ -z "$gen" ]; then
warn "!! These build scripts carry no version stamp, so they predate"
warn " $LFS_HELPER_VERSION. The step order may contain steps this"
warn " version no longer has."
else
warn "!! These build scripts were generated by $gen, but the tools are"
warn " $LFS_HELPER_VERSION. The step order may not match what this"
warn " version knows how to build."
fi
warn " Regenerate them, from OUTSIDE the chroot:"
warn " lfs build-system gen-chroot-scripts --run --overwrite"
warn ""
return 1
}
cmd_build_all() {
need_root
local force=0 from=""
while [ $# -gt 0 ]; do
case "$1" in
--force) force=1; shift ;;
--from) from="${2:?--from needs a step name}"; shift 2 ;;
*) die "usage: lfs-helper build-all [--from <step>] [--force]" ;;
esac
done
if [ -n "$from" ]; then
steps | grep -qxF "$from" \
|| die "no such step: $from (see: lfs-helper list)"
fi
# Ownership is NOT set up here. It used to be -- "before the first package
# is built" sounds right and is wrong: on a chapter 5-6 tree /etc/passwd
# does not exist yet, so `chown root:install` failed on all 27 directories
# and the run reported
# # 0 install directories are now root:install (group-writable)
# then carried on building. It happens at the ownership epoch instead,
# the moment book 7.6 has created the user database -- see
# _ownership_checkpoint, called after every step below.
_warn_if_scripts_are_stale || true
if ownership_established; then
pkgusr_ready || cmd_init_pkgusr --run || true
else
say "Ownership will be established once book 7.6 has created the user"
say "database; until then these steps run as root and are not tracked."
say ""
fi
_check_state_version || true
_check_duplicate_ids || true
# A groups export copied into the tree is a set of decisions already made
# -- apply them before anything can ask the same questions again. Leaving
# this to be run by hand meant the file sat in the tree while the build
# stopped to ask about directories it already had an answer for.
if [ -f "$STATE_GROUPS/collector-groups.import" ] && ! _groups_already_imported; then
say "Applying the collector-group decisions copied into this tree:"
cmd_import_groups --run || true
say ""
fi
# Files carrying the outside build user's uid have no name in here, and
# every ownership check calls them UNKNOWN. Fix that once, up front.
# Gate on the WORK, not on a side effect of starting it.
#
# This used to run only when the lfs user was missing -- but the user is
# created first, so cancelling part-way through the chown left the user in
# place and every later run skipped the repair entirely, with half a
# million files still owned by nobody.
if ownership_established && _has_orphaned_files; then
say "Some files have no owner in this chroot (created outside it)."
_vfy_orphans 1 || true
say ""
fi
# Then hand the ALREADY-built packages to their own users, before building
# anything new. Chapters 5-7 ran before package users existed, so their
# files are root-owned and their manifests are the only record of who
# installed what. Doing this now means:
# * the first package users are the packages actually installed first,
# not whichever chapter-8 package happened to come along;
# * bash, coreutils, glibc and the rest own their files from here on;
# * a chapter-8 package that replaces a chapter-7 file writes into a
# directory its own user already owns, instead of hitting root.
# It is idempotent -- a package that already has its user is skipped.
# Only once there IS a user database. On a chapter 5-6 tree /etc/passwd
# does not exist yet (book 7.6 creates it), and every "create the package
# user" would fail. The chapter-7 steps below run as root and need no
# package users, so this simply happens later -- see _adopt_when_ready.
# A tree that is already past 7.6 -- a resumed build, a restored chapter-8
# snapshot -- crosses the epoch right here instead of mid-loop.
_ownership_checkpoint
local total done_n=0 s _needs_you="" _adopted_late=0
total=$(steps | wc -l)
[ "$total" -gt 0 ] || die "no scripts found in $SCRIPTS
Generate them from outside the chroot:
lfs build-system gen-chroot-scripts --run"
# Work out which steps are in range, and whether any are already built.
# Rather than making you remember --force, just ask.
local in_range=() built=() started=1
[ -n "$from" ] && started=0
while read -r s; do
if [ "$started" = 0 ]; then
[ "$s" = "$from" ] && started=1 || continue
fi
in_range+=("$s")
is_done "$s" && built+=("$s")
done < <(steps)
if [ "${#built[@]}" -gt 0 ] && [ "$force" = 0 ]; then
say "${#built[@]} of the ${#in_range[@]} step(s) in range are already built:"
# When the list is long, collapse the OLD ones into a numbered range and
# spell out only the most recent -- those are the ones you care about.
local _show=6 _n="${#built[@]}"
if [ "$_n" -gt "$_show" ]; then
say " steps 1-$(( _n - _show )) (built earlier)"
say " ... most recently:"
printf ' %s\n' "${built[@]:$(( _n - _show ))}"
else
printf ' %s\n' "${built[@]}"
fi
# Spell out what each answer does -- "Rebuild those too? [y/N]" on its
# own doesn't say what happens when you decline.
local _todo=$(( ${#in_range[@]} - ${#built[@]} ))
say ""
say " y = rebuild all ${#in_range[@]} step(s) from the start of the range"
if [ "$_todo" -gt 0 ]; then
local _first=""
local _s
for _s in "${in_range[@]}"; do
is_done "$_s" && continue
_first="$_s"; break
done
say " N = keep them, build only the $_todo remaining step(s)," \
"starting with '$_first'"
else
say " N = keep them; nothing would be built (everything is done)"
fi
if [ -t 0 ]; then
printf 'Rebuild those too? [y/N]: '
local ans; read -e -r ans
case "$ans" in [Yy]*) force=1; say "# rebuilding everything in range" ;;
*) say "# keeping them; building the $_todo remaining step(s)" ;;
esac
else
say "# not a terminal -- keeping them, building the $_todo remaining step(s)"
fi
say ""
fi
local i=0
started=1; [ -n "$from" ] && started=0
while read -r s; do
i=$((i+1))
# --from: skip everything before the named step, then build onward
if [ "$started" = 0 ]; then
if [ "$s" = "$from" ]; then started=1; else
detail "[$i/$total] $s -- before --from $from, skipping"
continue
fi
fi
if is_done "$s" && [ "$force" = 0 ]; then
detail "[$i/$total] $s -- already built, skipping"
done_n=$((done_n+1))
continue
fi
# A package left half-installed by an interrupted build breaks
# everything after it, and the failure never points back here.
if _interrupted_packages >/dev/null 2>&1; then
warn ""
warn "These packages were interrupted mid-install and may be"
warn "half on disk:"
_interrupted_packages | sed 's/^/ /' >&2
warn ""
warn "Finish one before building anything else:"
warn " lfs-helper build <name> --phase install --force"
warn "Or check what state the toolchain is in:"
warn " lfs-helper check-toolchain"
warn ""
fi
# The chapter-7 steps run as root and create the user database (7.6).
# Once it exists, the chapter 5-6 packages can finally be given their
# users -- before the first package that needs one, and in build order
# so binutils takes the first uid.
# THE ownership epoch. init-files (book 7.6) is the step that creates
# the user database, so this fires immediately after it and exactly
# once. Everything before was root's and untracked; everything after
# belongs to the package that installed it.
_ownership_checkpoint
# The C++ headers live under the target the compiler was built for.
# If those two names disagree the headers are present but invisible,
# and gcc fails with "bits/c++config.h: No such file or directory" --
# which reads like a missing file, not a mismatch. Say which it is.
_warn_on_triplet_mismatch
# The toolchain check belongs HERE, not at the top: chapter 7 builds
# the temporary tools and needs no C++ compiler at all, so checking up
# front blocked the very steps that would fix a broken toolchain --
# build-all simply did nothing, with nothing to say why. Check once,
# before the first package that actually needs a compiler.
if ! is_root_step "$s" && [ "${_tc_checked:-0}" = 0 ]; then
_tc_checked=1
if ! cmd_check_toolchain >/dev/null 2>&1; then
warn ""
warn "Stopping before '$s': this is the first package that"
warn "needs a working C++ compiler, and there is not one."
say ""
cmd_check_toolchain || true
return 1
fi
fi
if ! is_root_step "$s" && ! pkgusr_ready; then
# Set it up rather than stopping. The package-user system is not
# an optional extra here -- it is the whole point of this toolchain,
# and every chapter-8 package needs it. Asking the user to run one
# obvious command and then re-issue build-all is just a detour.
say ""
say "'$s' is the first package that needs the package-user system."
say "Setting it up now (lfs-helper init-pkgusr):"
if cmd_init_pkgusr --run; then
say ""
ok " package-user system ready -- continuing with $s"
say ""
fi
fi
if ! is_root_step "$s" && ! pkgusr_ready; then
warn ""
warn "Stopping: '$s' is a chapter-8 package and needs the"
warn "package-user system. Set it up, then re-run build-all:"
warn " lfs-helper init-pkgusr --run"
warn " lfs-helper build-all"
return 0
fi
# The seal happens once, after the loop -- see the end of this
# function. There used to be a second one here, firing just before
# `last-step` because that step still INSTALLED software (wget) and had
# to do it under the sticky bit. `last-step` is gone; the final step
# is now `init-accounts`, which runs as root and installs nothing, so
# there is nothing left to seal early for.
say ""
say "===== [$i/$total] $s ====="
# pass the rebuild decision down -- otherwise the per-package check
# prompts (or skips) again for something we already agreed to rebuild
# The result matters. It used to be discarded, so a step that exits 3
# ("this one needs you") -- refind, which cannot guess your ESP --
# slipped past silently and the build reported everything complete
# with that step still pending.
local _rc=0
if [ "$force" = 1 ]; then cmd_build "$s" --force || _rc=$?
else cmd_build "$s" || _rc=$?; fi
case "$_rc" in
0|4) ;; # done, or deliberately nothing to do
3) _needs_you="$_needs_you $s" ;;
*) warn ""
warn "'$s' failed (exit $_rc) -- stopping here."
warn "Fix it, then run build-all again; it continues from '$s'."
return "$_rc" ;;
esac
done < <(steps)
report_granted_groups
if [ -n "${_needs_you# }" ]; then
say ""
warn "These steps need a decision from you and were not built:"
local _p
for _p in $_needs_you; do
say " lfs-helper build $_p"
done
say ""
say "Everything else is built. Deal with them, then run build-all"
say "again -- or skip one deliberately with: lfs-helper done <name>"
return 0
fi
ok ""
ok "all steps complete ($total total)."
# The genuinely last step of the chroot stage. Until now the install
# directories were group-writable but NOT sticky, so package users could
# replace the temporary system's root-owned files and each other's -- which
# the build needs. Now that everything is built, seal them: a package user
# can still create files there but no longer modify or delete another
# package's. Only the install directories; collector-group directories are
# shared on purpose and are left alone.
if ! _install_dirs_are_sealed; then
say ""
say "Last step: making the install directories sticky, so packages can"
say "no longer overwrite each other's files. (Collector-group"
say "directories are shared deliberately and are left alone.)"
say ""
_vfy_seal_install_dirs
fi
# Package users are appended as they are created, so by now /etc/passwd is
# in build order. Sorting by id makes it readable -- and it is purely
# cosmetic, so it is safe to do without asking.
say ""
_vfy_user_order 1 || true
_strip_if_asked_for
_warn_if_strip_was_asked_for
_warn_if_no_login
_say_what_is_yours_to_finish
}
# The two things build-all deliberately does not do.
#
# The kernel and the bootloader are decisions about the whole MACHINE, not
# about a package, and getting either wrong costs you the system you are
# building on -- so they are yours, on purpose, and the build ends by saying
# so rather than leaving you to discover it after the reboot. The kernel
# has its own tool (lfs-kernel) precisely so its config can be edited before
# anything is built; the message points there rather than at the book.
#
# rEFInd is opt-in for the same reason and stays that way: the machine booted
# in order to build LFS at all, so it already has a working bootloader, and
# adding a menu entry to it is not something a build should do because nobody
# said no.
_say_what_is_yours_to_finish() {
say ""
say "# Two things are yours to finish, on purpose:"
say "#"
say "# 1. THE KERNEL. build-all does not build one. lfs-kernel does,"
say "# as a package (p_linux), with its firmware and headers -- edit"
say "# /etc/pkgusr/kernel.conf first (VERSION, ESP, KERNEL_NAME,"
say "# FIRMWARE_ONLY), then:"
say "# lfs-kernel # build + install the configured version"
say "# lfs-kernel verify # is everything where the tools expect"
say "# Or by hand, book 10.3, from inside the chroot."
say "#"
say "# 2. THE BOOTLOADER. Nothing was written to any ESP, boot sector"
say "# or partition table, and your current bootloader is untouched."
say "# Add an entry for this system by hand, or let the tools add a"
say "# rEFInd entry beside your existing one:"
say "# lfs config bootloader refind"
say "# lfs config esp /boot/efi"
say "# lfs-helper build refind --force"
say "# That only ever ADDS to a mounted ESP: it never formats, never"
say "# writes a raw device, and never replaces what boots you today."
}
# You asked for stripping, so it is done here -- at the END, when everything
# is built, so nothing that would still be rebuilt gets stripped twice.
#
# `cmd_strip` is the ONE implementation; this only decides whether to call it.
# It cannot fail the build: a system with its debug symbols still in it is
# complete and correct, only larger -- so a strip failure is reported by
# `_warn_if_strip_was_asked_for` below, never by an exit code here.
_strip_if_asked_for() {
[ "${LFS_STRIP:-0}" = 1 ] || return 0
say ""
say "# stripping debug symbols (you answered yes in the interview)"
if cmd_strip --run; then
STRIP_DONE=1
fi
return 0
}
# You asked for stripping and it did not finish.
#
# The answer used to be collected, carried into the chroot, and then ignored
# with a NOT YET IMPLEMENTED notice at the end of a six-hour build. Now the
# work is done above; what remains of the notice is for the one case left --
# the run failed partway -- and it says what to run, not what is missing.
#
# Said at the END, next to the login warning, because that is where the things
# you still have to do yourself are collected.
_warn_if_strip_was_asked_for() {
[ "${LFS_STRIP:-0}" = 1 ] || return 0
[ "${STRIP_DONE:-0}" = 1 ] && return 0
say ""
warn "# you asked for debug symbols to be stripped, and it did not finish."
warn "#"
warn "# The system is complete and correct; it is only larger than you"
warn "# asked for. Run it again yourself:"
warn "# lfs-helper strip --run"
return 1
}
# Can anyone actually log into this system?
#
# THE LAST THING SAID, because it is the last thing that can still be fixed.
# A fresh LFS has root with no password. Depending on the login manager that is
# either "anyone can log in as root" or "nobody can log in at all", and you find
# out after the reboot -- when the chroot is gone and there is no way back in
# short of booting the host again and mounting the tree by hand.
#
# init-accounts asks for it, but it is skippable: a scripted run has no
# terminal, and an interactive one can be answered with a blank line. So this
# checks the RESULT rather than trusting that the step ran.
_warn_if_no_login() {
local root_ok=0 user_ok=0 u
if grep -qE '^root:[^:*!]' "$ETC/shadow" 2>/dev/null; then
root_ok=1
fi
# any non-system account with a real password
while IFS=: read -r u _ uid _; do
case "$uid" in ''|*[!0-9]*) continue ;; esac
[ "$uid" -ge 1000 ] && [ "$uid" -lt 10000 ] || continue
grep -qE "^$u:[^:*!]" "$ETC/shadow" 2>/dev/null && { user_ok=1; break; }
done < "$ETC/passwd"
if [ "$root_ok" = 1 ] || [ "$user_ok" = 1 ]; then
say ""
ok "# you can log in after rebooting:"
[ "$root_ok" = 1 ] && ok "# root has a password"
[ "$user_ok" = 1 ] && ok "# '$u' has a password"
return 0
fi
say ""
warn "=============================================================="
warn "!! NOBODY CAN LOG INTO THIS SYSTEM."
warn ""
warn " root has no password, and no other account has one either."
warn " Depending on the login manager that means either anyone can"
warn " log in as root, or nobody can log in at all -- and you would"
warn " find out after the reboot, with the chroot gone."
warn ""
warn " Fix it NOW, while you are still in here:"
warn " lfs-helper init-accounts --force"
warn ""
warn " or, from outside the chroot:"
warn " chroot /mnt/lfs /usr/bin/passwd root"
warn "=============================================================="
return 1
}
# Wait (bounded) for the background writers this toolchain knows about.
# Named rather than "any activity": guessing at every process on the system
# would hang on anything long-running the user is doing on purpose.
_SETTLE_PROCS="mandb make-ca update-mime-database gtk-update-icon-cache
gdk-pixbuf-query-loaders glib-compile-schemas fc-cache p11-kit trust
list_package"
_settle_tree_before_stamp() {
local waited=0 max="${LFS_SETTLE_SECONDS:-60}" busy
command -v pgrep >/dev/null 2>&1 || return 0
while [ "$waited" -lt "$max" ]; do
busy=""
local pr
for pr in $_SETTLE_PROCS; do
pgrep -x "$pr" >/dev/null 2>&1 && busy="$busy $pr"
done
[ -z "$busy" ] && { [ "$waited" -gt 0 ] && say "# tree is quiet again"; return 0; }
[ "$waited" = 0 ] \
&& say "# waiting for background work to finish:$busy"
sleep 2
waited=$((waited + 2))
done
warn "still running after ${max}s:$busy"
warn " this build's file list may claim files THEY write -- check it"
warn " with: lfs-helper verify (raise LFS_SETTLE_SECONDS to wait longer)"
return 0
}
# The shell startup files BLFS packages assume.
#
# BLFS pages drop snippets into /etc/profile.d and call pathprepend /
# pathappend / pathremove, which the book defines in "The Bash Shell Startup
# Files" (postlfs/profile). LFS alone does not install them, so rust's
# /etc/profile.d/rustc.sh said
# pathprepend: command not found
# on every package-user login -- noise at best, and a failed configure step
# at worst.
#
# We do not copy the book's files: /etc/profile is the administrator's, and
# an existing one is never touched. We add what the SNIPPETS need -- the
# three functions, in a profile.d file of our own that sorts first -- and,
# only if nothing sources profile.d yet, a small /etc/profile that does.
install_profile_helpers() {
local d="${SNAP_ROOT%/}/etc/profile.d" f="${SNAP_ROOT%/}/etc/profile"
mkdir -p "$d" || { warn "could not create $d"; return 1; }
set_install_dir_owner "$d" 2>/dev/null || true
soft "$d is not group-writable -- packages cannot add profile snippets" \
-- real_chmod g+w "$d"
cat > "$d/00-path-functions.sh" <<'PROFEOF'
# Path helpers that BLFS profile.d snippets call.
# Installed by lfs-helper: LFS does not provide them, and every snippet that
# says `pathprepend /opt/foo/bin` needs them to exist before it runs.
pathremove() {
local IFS=':' NEWPATH DIR PATHVARIABLE=${2:-PATH}
for DIR in ${!PATHVARIABLE}; do
[ "$DIR" != "$1" ] && NEWPATH=${NEWPATH:+$NEWPATH:}$DIR
done
export $PATHVARIABLE="$NEWPATH"
}
pathprepend() {
pathremove "$1" "${2:-PATH}"
local PATHVARIABLE=${2:-PATH}
export $PATHVARIABLE="$1${!PATHVARIABLE:+:${!PATHVARIABLE}}"
}
pathappend() {
pathremove "$1" "${2:-PATH}"
local PATHVARIABLE=${2:-PATH}
export $PATHVARIABLE="${!PATHVARIABLE:+${!PATHVARIABLE}:}$1"
}
export -f pathremove pathprepend pathappend
PROFEOF
soft "the path helpers are not world-readable -- logins will not see them" \
-- real_chmod 644 "$d/00-path-functions.sh"
# SAY WHAT WAS WRITTEN. This reported only what it declined to do, so a
# user who had just been given the helpers was told "it did not install
# the files".
ok "# wrote $d/00-path-functions.sh (pathremove, pathprepend, pathappend)"
if [ -f "$f" ] && grep -q 'profile\.d' "$f" 2>/dev/null; then
ok "# your /etc/profile already sources /etc/profile.d -- nothing else to do"
return 0
fi
if [ -f "$f" ]; then
# Offer, do not impose: /etc/profile is the administrator's file, and
# appending to it is a change to the system like any other.
if confirm_change "append a loop to /etc/profile so it reads /etc/profile.d/*.sh (a backup is kept as /etc/profile.before-lfs-helper)"; then
cp -p "$f" "$f.before-lfs-helper" 2>/dev/null || true
cat >> "$f" <<'PROFEOF'
# added by lfs-helper: BLFS packages install snippets into /etc/profile.d
for _s in /etc/profile.d/*.sh; do
[ -r "$_s" ] && . "$_s"
done
unset _s
PROFEOF
ok "# /etc/profile now reads /etc/profile.d (backup: $f.before-lfs-helper)"
else
warn "left /etc/profile alone. Add this to it, or the snippets"
warn "packages install will never run:"
warn " for s in /etc/profile.d/*.sh; do [ -r \"\$s\" ] && . \"\$s\"; done"
fi
return 0
fi
cat > "$f" <<'PROFEOF'
# /etc/profile -- written by lfs-helper because none existed.
# It exists so that the snippets BLFS packages install into /etc/profile.d
# are actually read. Edit freely; it will not be overwritten.
for _s in /etc/profile.d/*.sh; do
[ -r "$_s" ] && . "$_s"
done
unset _s
PROFEOF
soft "/etc/profile is not world-readable" -- real_chmod 644 "$f"
ok "# wrote /etc/profile (it sources /etc/profile.d) and the path helpers"
}
# The rest of what "The Bash Shell Startup Files" sets up.
#
# The book's chapter creates nine files: /etc/profile, /etc/bashrc, four
# profile.d snippets (extrapaths, readline, umask, i18n) and four skeleton
# dotfiles. We had only the path helpers, so a package user's shell had no
# umask policy, no INPUTRC, no locale and no ~/.bashrc.
#
# These are OUR versions, not copies of the book's, and NOTHING that already
# exists is touched -- these files belong to the administrator.
_write_if_absent() {
local path="$1"
if [ -e "$path" ]; then
detail "# kept existing $path"
return 1
fi
cat > "$path" || { warn "could not write $path"; return 1; }
soft "$path is not readable by everyone" -- real_chmod 644 "$path"
ok "# wrote $path"
return 0
}
install_shell_startup_files() {
local d="${SNAP_ROOT%/}/etc/profile.d" skel="${SNAP_ROOT%/}/etc/skel"
mkdir -p "$d" "$skel" 2>/dev/null || true
_write_if_absent "$d/umask.sh" <<'EOF'
# A group-writable umask for members of a group of their own (the LFS
# convention: uid == gid), 022 for everyone else.
if [ "$(id -gn)" = "$(id -un)" ] && [ "$EUID" -gt 99 ]; then
umask 002
else
umask 022
fi
EOF
_write_if_absent "$d/readline.sh" <<'EOF'
# Where readline looks for its configuration.
[ -z "${INPUTRC:-}" ] && [ -f /etc/inputrc ] && export INPUTRC=/etc/inputrc
EOF
# THE LOCALE COMES FROM A FILE, NOT FROM A LINE YOU EDIT INTO A SCRIPT.
#
# This used to write `: # export LANG=en_US.UTF-8` -- commented out, on
# the grounds that an unset LANG is a choice. It is not a choice anyone
# makes: nothing else on the system sets LANG either, so every machine
# built this way sits in the C locale until somebody notices a terminal
# complaining ("foot: 'C' is not a UTF-8 locale"), and the only way to
# fix it is to hand-edit a generated file. BLFS's own version has the
# same shape and the same outcome -- a placeholder nobody fills in.
#
# So it reads /etc/locale.conf, which is where LFS puts the locale and
# what `cfg shell-env` writes from `lfs config locale`. With no
# locale.conf this does nothing, exactly as before.
_write_if_absent "$d/i18n.sh" <<'EOF'
# written by lfs-helper (shell startup files) -- safe to replace
# Locale. LANG lives in /etc/locale.conf, one file for the whole system:
# echo 'LANG=en_US.UTF-8' > /etc/locale.conf
# `packagemanager stack services --only shell-env --run` writes it for you,
# from `lfs config locale`, and checks the locale really exists.
if [ -r /etc/locale.conf ]; then
. /etc/locale.conf
if [ -n "${LANG:-}" ]; then export LANG; fi
if [ -n "${LC_ALL:-}" ]; then export LC_ALL; fi
fi
export LESSCHARSET="${LESSCHARSET:-utf-8}"
EOF
_write_if_absent "$d/extrapaths.sh" <<'EOF'
# Directories that exist on a BLFS system but are not in the base PATH.
# Packages installed under /opt add themselves through their own snippets;
# this covers the common local ones.
[ -d /usr/local/bin ] && pathprepend /usr/local/bin
[ -d /usr/local/sbin ] && [ "$EUID" -eq 0 ] && pathprepend /usr/local/sbin
[ -d /usr/local/lib/pkgconfig ] && pathappend /usr/local/lib/pkgconfig PKG_CONFIG_PATH
EOF
_write_if_absent "${SNAP_ROOT%/}/etc/bashrc" <<'EOF'
# /etc/bashrc -- for INTERACTIVE shells (a login shell reads /etc/profile).
# Aliases and functions belong here; environment variables belong in
# /etc/profile.d.
[ -z "${PS1:-}" ] && return
[ -r /etc/profile.d/readline.sh ] && . /etc/profile.d/readline.sh
[ -r /etc/dircolors ] && eval "$(dircolors -b /etc/dircolors)" 2>/dev/null
alias ls='ls --color=auto'
alias grep='grep --color=auto'
EOF
_write_if_absent "$skel/.bash_profile" <<'EOF'
# A login shell reads this. Keep the login environment in /etc/profile and
# the interactive settings in ~/.bashrc, so both kinds of shell agree.
[ -r /etc/profile ] && . /etc/profile
[ -r ~/.bashrc ] && . ~/.bashrc
EOF
_write_if_absent "$skel/.bashrc" <<'EOF'
[ -r /etc/bashrc ] && . /etc/bashrc
EOF
_write_if_absent "$skel/.bash_logout" <<'EOF'
# Runs when a login shell exits.
EOF
_write_if_absent "$skel/.profile" <<'EOF'
# Read by shells that are not bash.
[ -r /etc/profile ] && . /etc/profile
EOF
return 0
}
# Files every package must be able to rewrite.
#
# /usr/share/info/dir is the info INDEX: every package that installs an info
# page runs install-info against it, and as a package user that is
# install-info: Operation not permitted for /usr/share/info/dir
# which kills nettle after it has already installed its libraries. The file
# is never_claim_list'd -- nobody OWNS it -- but nothing made it writable by
# the people who must update it. A shared directory is root:install and
# group-writable; a shared file is the same rule, one level down.
shared_files_list() {
# perllocal.pod IS THE info/dir OF THE PERL TREE.
#
# Every Perl module's `make install` runs doc_install, which APPENDS a
# paragraph to /usr/lib/perl5/<ver>/core_perl/perllocal.pod. So the
# file belongs to whichever module was installed first -- here
# p_xml-parser, mode 664, group p_xml-parser -- and every module after
# that fails on it:
# /usr/lib/perl5/5.42/core_perl/perllocal.pod: Permission denied
# Granting the DIRECTORY to the perl collector group does not help: the
# file's own group is one package's. It is rewritten by many packages
# and installed by none, which is exactly this list's rule.
# The version is in the path, so it is globbed rather than spelled.
local _p
for _p in "${SNAP_ROOT%/}"/usr/lib/perl5/*/*/perllocal.pod; do
[ -e "$_p" ] && printf '%s\n' "$_p"
done
printf '%s\n' \
"${SNAP_ROOT%/}/usr/share/info/dir" \
"${SNAP_ROOT%/}/etc/xml/catalog" \
"${SNAP_ROOT%/}/etc/xml/docbook" \
"${SNAP_ROOT%/}/usr/share/applications/mimeinfo.cache" \
"${SNAP_ROOT%/}/usr/share/mime/mime.cache" \
"${SNAP_ROOT%/}/usr/share/glib-2.0/schemas/gschemas.compiled" \
"${SNAP_ROOT%/}/usr/share/icons/hicolor/icon-theme.cache"
}
# The rest of that list is the same answer to the same question, reached one
# failure at a time -- so here is the whole family at once, before each one
# costs its own session:
# update-desktop-database -> applications/mimeinfo.cache
# "The databases in [/usr/share/applications] could not be updated."
# update-mime-database -> mime/mime.cache
# glib-compile-schemas -> glib-2.0/schemas/gschemas.compiled
# gtk-update-icon-cache -> icons/hicolor/icon-theme.cache
# Each is REGENERATED from what other packages installed, by whichever
# package happens to run the tool last. None is installed by a single
# package, so none may be owned by one.
# The test for membership, since this list will keep growing:
# is the file REWRITTEN by many packages rather than installed by one?
# /usr/share/info/dir (install-info), /etc/xml/catalog and /etc/xml/docbook
# (xmlcatalog) are indexes: docbook-xml creates the catalog as its own user
# and docbook-xsl then cannot write it --
# could not open /etc/xml/catalog for saving
# -- which is the info-index failure again, one directory over. A file that
# only one package ever writes does NOT belong here; it belongs to that
# package.
# The directories those files live in. They may NOT be sticky -- see below.
shared_file_dirs() {
local f d
while IFS= read -r f; do
[ -n "$f" ] || continue
d="$(dirname "$f")"
# THE FILE IS SHARED; THE DIRECTORY IS NOT.
#
# /usr/lib/perl5/<ver>/core_perl holds perl's OWN modules and is
# p_perl's install directory, recorded in its manifest. Only
# perllocal.pod inside it is written by everyone. Handing the whole
# directory to root:install would take a directory away from the
# package that installed it and make `verify` right to complain.
case "$d" in */usr/lib/perl5/*) continue ;; esac
printf '%s\n' "$d"
done < <(shared_files_list) | sort -u
}
# Apply the rule to whichever of them exist. Idempotent; safe to repeat.
#
# THE FILE'S MODE IS ONLY HALF OF IT. install-info does not write
# /usr/share/info/dir in place: it writes dir.new and renames it over the
# old one. In a STICKY directory only the owner of a file may replace it,
# so a root-owned index in a sealed /usr/share/info gives
# install-info: Operation not permitted for /usr/share/info/dir
# -- EPERM, not EACCES, and no amount of group-write on the file changes it.
# Sealing (sticky, so package users cannot delete each other's files) and
# sharing (everyone must replace this file) are contradictory rules, so a
# directory that holds a shared index is excluded from sealing.
share_shared_files() {
local f d n=0
while IFS= read -r f; do
[ -f "$f" ] || continue
if ! { [ "$(stat -c %U:%G "$f" 2>/dev/null)" = "root:install" ] && [ -w "$f" ]; }; then
real_chown root:install "$f" 2>/dev/null || continue
real_chmod 664 "$f" 2>/dev/null || continue
n=$((n + 1))
fi
done < <(shared_files_list)
while IFS= read -r d; do
[ -d "$d" ] || continue
# GROUP-WRITABLE FOR WHICH GROUP? Only the mode was being fixed, so
# /etc/xml came out root:root 775 -- group-write for group root,
# which helps no package user at all. The group is half of the rule;
# a shared directory is root:INSTALL and group-writable, like every
# other install directory. (/etc/xml is not in install_dirs_list,
# so nothing else was going to give it the group.)
if [ "$(stat -c %G "$d" 2>/dev/null)" != install ] \
|| [ -k "$d" ] || [ ! -w "$d" ]; then
real_chown root:install "$d" 2>/dev/null
real_chmod g+w,o-t "$d" 2>/dev/null && n=$((n + 1))
fi
done < <(shared_file_dirs)
[ "$n" -gt 0 ] && detail "# $n shared path(s) fixed (root:install, group-writable, not sticky)"
return 0
}
# Are the install directories already sticky? Checking a handful of the ones
# that certainly exist is enough to tell whether sealing has been done.
_install_dirs_are_sealed() {
# ANY sticky install directory means sealing has happened.
#
# This demanded that all five probed directories be sticky, which made a
# MIXED tree read as "not sealed at all" -- and verify then proposed
# stripping the sticky bit off ~50 directories that were correctly
# sealed:
# installdir /usr/lib root:install 1775 -> root:install 775
# A tree goes mixed for ordinary reasons: sealing interrupted, or an
# install directory created after the seal (verify itself creates none,
# but packages do). Sealing is a deliberate, one-way, end-of-build act,
# so the conservative reading is that a partially sealed tree is sealed:
# verify then COMPLETES the seal on the stragglers instead of undoing it.
# A never-sealed mid-build tree has no sticky install dirs at all and
# still reads as unsealed, which is what the build needs.
local d probe=0 sealed=0
while IFS= read -r d; do
d="${SNAP_ROOT%/}$d"
[ -d "$d" ] || continue
probe=$((probe + 1))
[ -k "$d" ] && sealed=$((sealed + 1))
[ "$probe" -ge 12 ] && break
done < <(install_dirs_list)
if [ "$probe" -gt 0 ] && [ "$sealed" -gt 0 ] && [ "$sealed" != "$probe" ]; then
warn "install directories are PARTLY sealed ($sealed of $probe probed are sticky)"
warn " treating the tree as sealed and completing it -- a sticky bit"
warn " that seal-install-dirs set is never taken back off here"
fi
[ "$probe" -gt 0 ] && [ "$sealed" -gt 0 ]
}
# --------------------------------------------------------------------------- #
# reporting
# --------------------------------------------------------------------------- #
cmd_status() {
local total done_n
total=$(steps | wc -l)
done_n=$(count_lines "$PROGRESS")
say "LFS chroot session"
say " state dir : $STATE"
say " scripts : $SCRIPTS ($(ls -1 "$SCRIPTS" 2>/dev/null | wc -l) available)"
say " package-user: $(pkgusr_ready && echo 'ready' || echo 'NOT set up')"
if pkgusr_ready; then
local probe="${SNAP_ROOT%/}/usr/bin" sticky="no"
[ -k "$probe" ] && sticky="yes"
say " install dirs : group-writable, sticky=$sticky"
if [ "$sticky" = "no" ]; then
say " (correct while building -- sticky would stop one"
say " package replacing another's files. Add it once"
say " every package is built, to complete the chroot"
say " stage: lfs-helper seal-install-dirs --run)"
fi
fi
say " progress : $done_n / $total step(s) built"
if [ -f "$PROGRESS" ] && [ "$done_n" -gt 0 ]; then
say " last built : $(tail -n1 "$PROGRESS")"
fi
}
cmd_list() {
local total; total=$(steps | wc -l)
[ "$total" -gt 0 ] || { warn "no scripts in $SCRIPTS"; return 0; }
local i=0 s
while read -r s; do
i=$((i+1))
if is_done "$s"; then
printf " %3d. %-22s ${C_OK}built${C_OFF}\n" "$i" "$s"
elif [ -f "$(phase_file "$s")" ]; then
printf " %3d. %-22s ${C_WARN}partial${C_OFF} (%s)\n" "$i" "$s" \
"$(tr '\n' ' ' < "$(phase_file "$s")" | sed 's/ $//')"
else
printf " %3d. %-22s ${C_DIM}pending${C_OFF}\n" "$i" "$s"
fi
done < <(steps)
}
# Are there manifests from the pre-chroot build that nobody owns yet?
needs_adoption() {
is_done "adopt-existing" && return 1
local man name
for man in "$MANIFESTS"/*.files; do
[ -e "$man" ] || continue
[ -s "$man" ] || continue
name="$(basename "${man%.files}")"
case "$name" in init-*) continue ;; esac
user_exists "$(pkg_owner_name "$name")" && continue
return 0
done
return 1
}
cmd_next() {
local s
# walk the list in order; the first unfinished step IS the next step
while read -r s; do
is_done "$s" && continue
case "$s" in
init-dirs)
say "Next step: create the directory tree (book 7.5)"
say " lfs-helper build init-dirs" ;;
init-files)
say "Next step: create /etc/passwd, /etc/group etc. (book 7.6)"
say " lfs-helper build init-files"
say ""
say " (this is why the prompt says \"I have no name!\" -- after"
say " it runs, type: exec /usr/bin/bash --login)" ;;
*)
# a partially-built step: continue where it left off
local _nxt
if [ -f "$(phase_file "$s")" ]; then
_nxt="$(next_phase_for "$s" || true)"
if [ -n "$_nxt" ]; then
say "Next step: continue '$s' -- already done: $(tr '\n' ' ' < "$(phase_file "$s")" | sed 's/ $//')"
say " lfs-helper build $s --phase $_nxt"
say ""
say " (or redo the whole package: lfs-helper build $s --force)"
return 0
fi
fi
# step 3: hand the already-built packages to package users,
# before anything new is compiled
if needs_adoption; then
if ! pkgusr_ready; then
say "Next step: set up the package-user system"
say " lfs-helper init-pkgusr --run"
else
say "Next step: give the already-built packages their"
say "package users (chapters 5-6, from their manifests)"
say " lfs-helper adopt-existing --run"
fi
return 0
fi
if is_root_step "$s"; then
say "Next step: build '$s' (temporary chapter-7 tool, as root)"
say " lfs-helper build $s"
elif ! pkgusr_ready; then
say "Next step: set up the package-user system"
say " lfs-helper init-pkgusr --run"
say ""
say " (chapter 8 starts here -- from now on each package is"
say " built by its own package user)"
return 0
else
local nxt; nxt="$(next_phase_for "$s" || true)"
if [ -f "$(phase_file "$s")" ] && [ -n "$nxt" ]; then
say "Next step: continue '$s' -- $(tr '\n' ' ' < "$(phase_file "$s")")already done"
say " lfs-helper build $s --phase $nxt"
else
say "Next step: build '$s' (as its own package user)"
say " lfs-helper build $s"
fi
fi ;;
esac
say ""
say " (or build everything remaining: lfs-helper build-all)"
return 0
done < <(steps)
ok "Everything in the step list is built."
say " review: lfs-helper list"
# Finishing steps, in order. Skipping these is how a system ends up with
# thousands of root-owned files inside package trees: builds that OVERWROTE
# a chapter-7 file left it owned by root, and only the manifests know who
# it really belongs to.
local _leftover
_leftover="$(find "${SNAP_ROOT%/}/usr/lib" "${SNAP_ROOT%/}/usr/share" \
-maxdepth 2 -type d -user root 2>/dev/null \
| while IFS= read -r d; do is_install_dir "$d" || echo "$d"; done \
| head -n 1)"
if [ -n "$_leftover" ]; then
say ""
say "Next: give every package its files (some are still owned by root,"
say "e.g. $_leftover)"
say " lfs-helper fix-ownership --run"
say " lfs-helper adopt-dirs --run"
return 0
fi
local probe="${SNAP_ROOT%/}/usr/bin"
if pkgusr_ready && [ -d "$probe" ] && [ ! -k "$probe" ]; then
say ""
say "Final step: make the install directories sticky, so a package user"
say "can no longer modify another package's files:"
say " lfs-helper seal-install-dirs --run"
fi
}
cmd_manifests() {
local name="${1:-}"
if [ -n "$name" ]; then
local shown=0
if [ -s "$MANIFESTS/$name.files" ]; then
say "# files"; cat "$MANIFESTS/$name.files"; shown=1
fi
if [ -s "$MANIFESTS/$name.dirs" ]; then
say "# directories"; cat "$MANIFESTS/$name.dirs"; shown=1
fi
[ "$shown" = 1 ] || die "no manifest for $name"
return 0
fi
local total=0 total_d=0 f n
for f in "$MANIFESTS"/*.files; do
[ -e "$f" ] || continue
n=$(wc -l < "$f"); total=$((total+n))
# directories too: a package owns the dirs it creates, and one with
# files but no dirs is a tracking bug worth seeing
local nm nd flag
nm="$(basename "${f%.files}")"
nd=0
[ -f "$MANIFESTS/$nm.dirs" ] && nd="$(count_lines "$MANIFESTS/$nm.dirs")"
flag=""
[ "$n" -gt 0 ] && [ "$nd" = 0 ] && flag=" <- no directories recorded"
printf " %-22s %6d files %5d dirs%s\n" "$nm" "$n" "$nd" "$flag"
total_d=$((total_d + nd))
done
say ""
say " $total file(s) and ${total_d:-0} directory(ies) tracked"
}
# The hint's final chroot-stage step: once everything is built, install
# directories become sticky, so from then on a package user can add files but
# cannot modify or delete files belonging to OTHER packages. Doing this any
# earlier would block package users from overwriting the temporary system.
# Chapters 5-6 were built OUTSIDE the chroot, as the lfs user, and their file
# lists were recorded with host paths ($LFS/usr/bin/...). In here those same
# files are /usr/bin/..., so translate before touching anything.
strip_host_prefix() {
local p="$1" m="${LFS_HOST_MOUNT:-}" r="${SNAP_ROOT%/}"
[ -n "$m" ] && [ "${p#$m/}" != "$p" ] && p="/${p#$m/}"
# anchor to the tracked root: "/" in the real chroot (so unchanged), but the
# test root elsewhere -- without this a stray manifest entry could point at
# the HOST's /usr/bin instead of the tree we're working on
printf '%s\n' "$r$p"
}
# Hand the already-built packages (chapters 5-6) to package users, using the
# manifests recorded during those builds. Run this once, right after 7.6, so
# every package on the system already belongs to its own user before the first
# chapter-8 package is compiled.
# Undo a bad adoption round. Two things went wrong before the fixes above:
# * the first in-chroot build compared against a snapshot recorded OUTSIDE
# the chroot, so it claimed every pre-existing file as its own (init-dirs
# ended up "owning" ~11000 files that belong to gzip, bash, ...);
# * root-only steps (init-dirs/init-files, the *-tmp tools) were given
# package users they should never have had.
# This removes those users, discards the bogus manifests, and puts the affected
# files back to root so a corrected adopt-existing can assign them properly.
# Chapters 5-6 were tracked before directory recording existed, so those
# packages have file lists but no .dirs list -- which is why they all report
# "0 dirs" even though gcc plainly creates /usr/lib/gcc/<triplet>/<version>/.
# Reconstruct the directories from the file lists: take every parent directory
# of every tracked file, drop the shared install directories, and assign a
# directory only when ALL the tracked files under it come from ONE package.
# A directory shared by two packages stays root-owned, which is the safe answer.
# Does <dir> hold files owned by a package user other than <owner>?
# Only the immediate contents are checked: cheap, and enough to catch a
# package reaching into a tree that is not its own.
_dir_contents_foreign() {
local d="$1" owner="$2" e u
[ -d "$d" ] || return 1
for e in "$d"/*; do
[ -e "$e" ] || continue
u="$(stat -c %U "$e" 2>/dev/null)" || continue
case "$u" in
"$owner"|root|UNKNOWN|"") continue ;;
esac
# another PACKAGE user owns something in here -- leave the dir alone
user_exists "$u" && return 0
done
return 1
}
infer_dirs() {
local tmp="$STATE_PROGRESS/.dirinfer.$$"
: > "$tmp"
local man name owner p d r="${SNAP_ROOT%/}"
for man in "$MANIFESTS"/*.files; do
[ -e "$man" ] || continue
[ -s "$man" ] || continue
name="$(basename "${man%.files}")"
is_root_step "$name" && continue
owner="$(pkg_owner_name "$name")"
while IFS= read -r p; do
[ -n "$p" ] || continue
d="$(strip_host_prefix "$p")"
d="${d%/*}"
# walk up to the tracked root
while [ -n "$d" ] && [ "$d" != "$r" ] && [ "$d" != "/" ]; do
printf '%s\t%s\n' "$d" "$owner" >> "$tmp"
d="${d%/*}"
done
done < "$man"
done
# keep only dirs with exactly one owner
sort -u "$tmp" | awk -F'\t' '
{ if ($1 == last) { multi=1 } else { if (last != "" && !multi) print last "\t" lastowner
last=$1; lastowner=$2; multi=0 } }
END { if (last != "" && !multi) print last "\t" lastowner }'
rm -f "$tmp"
}
cmd_repair_adoption() {
need_root
local run=0; [ "${1:-}" = "--run" ] && run=1
say "Repair a bad adoption round."
say ""
[ "$run" = 1 ] || warn "(dry run -- nothing was changed. Add --run to apply.)"
say ""
local man name n p tgt users=0 files=0 owner
for man in "$MANIFESTS"/*.files; do
[ -e "$man" ] || continue
name="$(basename "${man%.files}")"
owner="$(pkg_owner_name "$name")"
# Two kinds of bad user to undo:
# * root-only steps that should own nothing at all
# * per-pass users (gcc-pass1, binutils-pass2, libstdcpp) whose files
# belong to the base package instead
if ! is_root_step "$name" && [ "$owner" = "$name" ]; then
continue
fi
# 1. hand the files back to root (their real owners get them from their
# own manifests when adopt-existing runs again)
n=0
while IFS= read -r p; do
[ -n "$p" ] || continue
tgt="$(strip_host_prefix "$p")"
[ -e "$tgt" ] || [ -L "$tgt" ] || continue
if [ "$run" = 1 ]; then
chown -h root:root "$tgt" 2>/dev/null && n=$((n+1))
else
n=$((n+1))
fi
done < "$man"
# 2. init-dirs/init-files create directories and /etc files; they never
# "install" a package, so their manifests are meaningless -- drop
# them. The *-tmp manifests are real and are kept.
case "$name" in
init-*) [ "$run" = 1 ] && : > "$man"
say " $name: cleared bogus manifest ($n file(s) returned to root)" ;;
*) if [ "$owner" != "$name" ]; then
say " $name: $n file(s) will be re-assigned to '$owner' (manifest kept)"
else
say " $name: $n file(s) returned to root (manifest kept)"
fi ;;
esac
files=$((files+n))
# 3. remove the package user/group it should never have had
if user_exists "$name"; then
if [ "$run" = 1 ]; then
if have_shadow_tools && command -v userdel >/dev/null 2>&1; then
userdel "$name" 2>/dev/null
groupdel "$name" 2>/dev/null
else
sed -i "/^$name:/d" "$ETC/passwd"
sed -i "/^$name:/d" "$ETC/group"
drop_from_install_group "$name"
fi
fi
say " removed package user '$name'"
users=$((users+1))
fi
done
# A user can exist without a manifest (e.g. a step that failed part-way),
# so also sweep the passwd file for package users whose name should never
# have been a user in the first place.
local u
while IFS=: read -r u _x uid _rest; do
[ -n "$u" ] || continue
[ "$uid" -ge "$PKG_UID_MIN" ] 2>/dev/null || continue
if is_root_step "$u" || [ "$(pkg_owner_name "$u")" != "$u" ]; then
if [ "$run" = 1 ]; then
if have_shadow_tools && command -v userdel >/dev/null 2>&1; then
userdel "$u" 2>/dev/null; groupdel "$u" 2>/dev/null
else
sed -i "/^$u:/d" "$ETC/passwd"
sed -i "/^$u:/d" "$ETC/group"
drop_from_install_group "$u"
fi
fi
say " removed stray package user '$u'"
users=$((users+1))
fi
done < "$ETC/passwd"
say ""
if [ "$run" = 1 ]; then
ok " $users bogus user(s) removed, $files file(s) returned to root"
unmark "adopt-existing"
say ""
say "Now assign ownership correctly:"
say " lfs-helper adopt-existing --run"
else
say " would remove $users user(s) and return $files file(s) to root"
fi
return 0
}
# Manifests oldest-first: the order the packages were built in.
_manifests_in_build_order() {
local f pat="*.files"
[ "${1:-}" = dirs ] && pat="*.dirs"
for f in "$MANIFESTS"/$pat; do
[ -e "$f" ] || continue
# %.Y is nanosecond precision: whole seconds tie for packages built
# in the same second, and the order then falls back to alphabetical
printf '%s\t%s\n' "$(stat -c %.Y "$f" 2>/dev/null || echo 0)" "$f"
done | sort -n -k1,1 | cut -f2-
}
# Per-command help. `lfs-helper fix-users -h` should say what the command
# does, not print the whole usage page again.
cmd_help_for() {
case "$1" in
pm-install)
cat <<'EOF'
lfs-helper pm-install <account> <script>
What /usr/bin/packagemanager_install used to be (item 6, step 7): resolve the
account name, confirm, create the account if needed (with the home repair),
stage the tarball from /sources, offer collector-group membership, keep the
install_last/diff records, run the nimgnu_/u_ special installers -- then hand
the script to the ONE build loop (cmd_build --script).
Driven by packagemanager; honours PM_YES, PM_MODE, PM_NO_AUTO_FIX and
LFS_HOME_USER (the desktop user u_ application accounts serve).
EOF
;;
fix-home)
cat <<'EOF'
lfs-helper fix-home <name> [--run]
Puts one account's home where every tool looks (pkgusr-home). The hint's
add_package_user creates /usr/src/<account>; the tools use
/usr/src/pkgusr/<account>. This repoints the passwd entry and merges the
stray directory into the real home -- moving what is missing, deleting what
is identical, and never touching a file that differs.
Without --run it only reports. `lfs-helper verify` runs the same check for
every package account.
EOF
;;
export-groups)
cat <<'EOF'
lfs-helper export-groups [<file>]
Saves the collector-group decisions -- which directory is shared under which
group, and who is in each -- so the next build reuses them instead of asking
again. Writes the directories that really carry a group right now, read from
the filesystem, not just the ones you were asked about.
EOF
;;
import-groups)
cat <<'EOF'
lfs-helper import-groups [<file>] [--run]
Loads a file written by export-groups: recreates the groups with their
members, and records the directory-to-group decisions so those directories are
never asked about again. With no file it looks for one placed in the tree by
`lfs build-system gen-chroot-scripts`.
EOF
;;
build)
cat <<'EOF'
lfs-helper build <name> [--phase all|unpack|build|install|configure|test]
[--force] [--tests] [--jobs N] [--no-auto-fix]
Builds one package as its own package user, tracking every file it installs.
--phase run a single phase instead of all of them
--force rebuild even if the step is already marked built
--tests run the package's test suite (off by default: the books
expect some failures)
--jobs N override MAKEFLAGS for this build
--no-auto-fix do not grant collector-group access on a permission failure
--auto-modules on a missing python build backend, install the BLFS page
that provides it (--recursive) and retry. Never for a
module only a TEST needs (pytest, hypothesis, ...): a test
suite in an install phase is a script bug, not a missing
package. Off by default; PM_AUTO_MODULES=1 also works.
EOF
;;
strip)
cat <<'EOF'
lfs-helper strip [--run] [--keep-la]
Strips debug symbols from the installed system -- the book's 8.85, run so the
package-user record survives it. strip replaces each file with a new one
owned by whoever ran it, so the files are grouped by owner and each group is
stripped as that owner. Libtool .la files are removed too (the book's
cleanup); --keep-la keeps them.
Saves several GB; makes a later crash harder to debug. build-all runs this
automatically when the interview answer `strip` is yes.
EOF
;;
*) return 1 ;;
esac
return 0
}
# Colour the group headings, matching `lfs --help` and `blfs --help`: the
# command list is what people scan, and an unbroken wall of text is hard to
# search. Dropped when stdout is not a terminal.
_colour_usage() {
if [ ! -t 1 ] || [ -n "${NO_COLOR:-}" ]; then cat; return; fi
sed -E \
-e "s/^([A-Za-z][^:]*:)\$/$(printf '\033[1m')\1$(printf '\033[0m')/" \
-e "s/^( )([a-z-]+([ ][a-z<>|-]+)*)/\1$(printf '\033[0;32m')\2$(printf '\033[0m')/"
}
usage() {
_usage_text | _colour_usage
}
_usage_text() {
cat <<EOF
lfs-helper $LFS_HELPER_VERSION (build $(_build_id)) -- builds the LFS system
one package user each.
Follow the build:
lfs-helper next the one command to keep running
lfs-helper status where the session stands
lfs-helper list every step and its state
('next --all' does the same)
lfs-helper check-toolchain can this compiler build anything?
Build:
lfs-helper build <name> build one step as its package user
lfs-helper build-all build everything remaining, in order
lfs-helper done|undone <name> mark a step built / clear that mark
build options: --phase <p> --force --tests --jobs N --no-auto-fix
--stage / --no-stage (stage the install, or do not)
Ownership and permissions (the package-user model):
lfs-helper init-pkgusr --run FIRST: install group + install dirs
lfs-helper add-user <name> create one package user
lfs-helper verify check the tree against what the
manifests say it should be
lfs-helper verify --fix and repair what it can
lfs-helper setid [--run] setuid/setgid bits the build refused
(su, passwd, dbus-launch-helper, ...)
lfs-helper fix-perms <name> --run grant one package the access it needs
lfs-helper grant-dir <dir> <user> --run
lfs-helper rename-group <old> <new> --run # fix a mistyped group name
lfs-helper regroup-dir <directory> # move a directory to another group (asks, shows the plan)
give one user access to one directory
lfs-helper undone steps of the chroot build not yet done
lfs-helper sort-users --run sort passwd and group by id
lfs-helper check-login can root and the main user log in
The chroot build's own steps -- init-accounts, establish-ownership,
seal-install-dirs, install-profile, repair-adoption -- are dispatched by
`build-all` and listed by `lfs-helper list`. They are not wrong to run by
hand, but nothing needs to.
Collector groups (shared directories):
lfs-helper export-groups [<file>] save the decisions for the next build
lfs-helper import-groups --run load them again
Look around / tidy up:
lfs-helper install-as <name> -- <cmd ...>
install anything as its own package user
lfs-helper verify [--fix] does the tree match the manifests?
lfs-helper pkgusr-info [<name>] where each package came from
lfs-helper pkgusr-home <name> print an account's home directory
lfs-helper wrapper-dir print where the build wrappers live
lfs-helper make-wrappers --run (re)write the build wrappers
lfs-helper owner-name <package> print the account name for a package
lfs-helper fix-home <name> [--run] put an account's home where the tools look
lfs-helper pm-install <account> <script> prepare and install one package
(what packagemanager_install used to be)
lfs-helper manifests [<name>] what each package installed
lfs-helper which-package <path> which package installed this file
lfs-helper clean-sources --run tidy /sources
lfs-helper clean-state --run remove build scratch (keeps the records)
lfs-helper prune-locales --run drop unwanted translations
lfs-helper strip --run strip debug symbols, each file as its
owner, so the ownership record holds
lfs-helper record-install <pkg> write the install record (one door: owner
scan once ownership exists, else the
build-stage manifest)
lfs-helper reconcile-records --run
give every package a pkg.lst, once
lfs-helper run-script --user U --script S [--phase P] [--log F]
run one phase as the package user (the
one runner both drivers use)
lfs-helper find-user-site --run find pip installs hidden in ~/.local
lfs-helper script-targets <script> [fn ...]
the files a phase writes (the lease list)
Any command: -h explains it. Dry run is the default; --run applies.
State: $STATE
EOF
}
# `lfs-helper <cmd> -h|--help` -> what that command does
if [ $# -ge 2 ]; then
case "$2" in
-h|--help) cmd_help_for "$1" && exit 0 ;;
esac
fi
case "${1:-}" in
status) shift; cmd_status "$@" ;;
--version|-V) echo "lfs-helper $LFS_HELPER_VERSION (build $(_build_id))"; exit 0 ;;
next)
shift
# `next --all` is how the outside tool spells `list`. Accepting both
# in both tools means never having to remember which one you are in.
if [ "${1:-}" = "--all" ]; then shift; cmd_list "$@"; else cmd_next "$@"; fi
;;
list) shift; cmd_list "$@" ;;
init-pkgusr) shift; cmd_init_pkgusr "$@" ;;
add-user) shift; cmd_add_user "$@" ;;
build) shift; cmd_build "$@" ;;
build-all) shift; cmd_build_all "$@" ;;
manifests) shift; cmd_manifests "$@" ;;
fix-perms) shift; cmd_fix_perms "$@" ;;
clean-sources) shift; cmd_clean_sources "$@" ;;
setid) shift; cmd_setid "$@" ;;
script-targets) shift; cmd_script_targets "$@" ;;
find-user-site) shift; cmd_find_user_site "$@" ;;
check-toolchain) shift; cmd_check_toolchain "$@" ;;
verify) shift; cmd_verify "$@" ;;
install-as) shift; cmd_install_as "$@" ;;
pkgusr-info) shift; cmd_pkgusr_info "$@" ;;
pkgusr-home) shift; cmd_pkgusr_home "$@" ;;
owner-name) shift; cmd_owner_name "$@" ;;
fix-home) shift; cmd_fix_home "$@" ;;
pm-install) shift; cmd_pm_install "$@" ;;
clean-state) shift; cmd_clean_state "$@" ;;
which-package) shift; cmd_which_package "$@" ;;
grant-dir) shift; cmd_grant_dir "$@" ;;
rename-group) shift; cmd_rename_group "$@" ;;
regroup-dir) shift; cmd_regroup_dir "$@" ;;
make-wrappers) shift; cmd_make_wrappers "$@" ;;
wrapper-dir) shift; cmd_wrapper_dir "$@" ;;
export-groups) shift; cmd_export_groups "$@" ;;
import-groups) shift; cmd_import_groups "$@" ;;
prune-locales) shift; cmd_prune_locales "$@" ;;
strip) shift; cmd_strip "$@" ;;
record-install) shift; cmd_record_install "$@" ;;
run-script) shift; cmd_run_script "$@" ;;
reconcile-records) shift; cmd_reconcile_records "$@" ;;
repair-adoption) shift; cmd_repair_adoption "$@" ;;
adopt-existing|adopt-dirs)
shift; cmd_adopt_existing "$@" ;;
sort-users) shift; cmd_sort_users "$@" ;;
# The one step where ownership becomes real. build-all crosses it by
# itself the moment book 7.6 has created the user database; this is for
# doing it by hand, or checking whether it has happened.
establish-ownership) shift; cmd_establish_ownership "$@" ;;
check-login) shift; _warn_if_no_login ;;
init-accounts) shift; cmd_init_accounts "$@" ;;
# The repair commands people were told to run for years. verify is the
# ONE implementation; these are aliases, not second copies -- a command
# that has been deleted is worse than one that is redundant.
fix-ownership|fix-orphans|fix-users|repair-perms)
shift
case "${1:-}" in
--run) set -- --fix ;;
esac
cmd_verify "$@" ;;
seal-install-dirs) shift; cmd_seal_install_dirs "$@" ;;
# the shell startup helpers BLFS profile.d snippets call. Wired into
# cmd_init_pkgusr too, but that is not the command people run -- the
# bootstrap (`packagemanager setup`) is, and it calls this.
install-profile)
shift
# `--yes` here, not only LFS_ASSUME_YES in the environment: this
# command's one question (append the profile.d loop to /etc/profile)
# is the difference between cargo being on PATH and not, and asking
# people to export a variable to answer it is a poor trade.
for _a in "$@"; do
case "$_a" in -y|--yes) LFS_ASSUME_YES=1 ;; esac
done
install_profile_helpers
install_shell_startup_files ;;
done) shift; mark_done "${1:?usage: lfs-helper done <name>}"; ok "marked '$1' built" ;;
undone) shift; unmark "${1:?usage: lfs-helper undone <name>}"; ok "cleared '$1'" ;;
-h|--help|help|"") usage ;;
*) die "unknown command '$1' (try: lfs-helper --help)" ;;
esac
} # end of the read-ourselves-once group