Packagemanager-LFS-PackageUser-System git · main
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
#
# 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
# 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.11.6"
# 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'
[ -t 1 ] || { C_OK=; C_WARN=; C_ERR=; C_DIM=; C_OFF=; }
# 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`.
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
}
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
# 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
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() {
local r="${SNAP_ROOT%/}"
find "${SNAP_ROOT:-/}" -xdev -type d \
-not -path "$SRCROOT/*" \
-not -path "$r/dev/*" -not -path "$r/proc/*" \
-not -path "$r/sys/*" -not -path "$r/run/*" \
-not -path "$r/tmp/*" -not -path "$r/sources/*" \
-not -path "$r/build/*" \
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%/}"
find "${SNAP_ROOT:-/}" -xdev \( -type f -o -type l \) \
-not -path "$SRCROOT/*" \
-not -path "$r/dev/*" -not -path "$r/proc/*" \
-not -path "$r/sys/*" -not -path "$r/run/*" \
-not -path "$r/tmp/*" -not -path "$r/sources/*" \
-not -path "$r/build/*" \
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" n=0
[ -f "$log" ] || return 0
say ""
fail "--- first errors in the log ---"
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 | while IFS= read -r l; do
say " $l"
done
n=$(grep -cE "\\berror\\b:|\\*\\*\\* \\[" "$log" 2>/dev/null || echo 0)
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 package's script actually define?
script_phases() {
local f; f="$(script_for "$1")"
[ -f "$f" ] || return 0
echo unpack
echo build
echo install
grep -q '^#### CONFIGURE ####' "$f" && echo configure
return 0
}
# 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.
rm -f "$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.
_read_pkgusr_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/.*"pkgusr_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/^pkgusr_prefix=\(.*\)$/\1/p' "$f" | head -n1)"
[ -n "$v" ] && { 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.
PKGUSR_PREFIX="${LFS_PKGUSR_PREFIX-$(_read_pkgusr_prefix || echo p)}"
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"
}
# 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 (everything owned by 'p_perl' -- fewer groups)
# 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.
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
# 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.
if [ ! -r /dev/tty ] || [ ! -w /dev/tty ] || [ "${LFS_ASSUME_YES:-0}" = 1 ]; then
echo "$by_owner"; return 0
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 it?"
say " 1) $by_owner (everything owned by '$owner' -- fewer groups)"
say " 2) $by_dir (just this directory -- more precise)"
say " or type another name"
} > /dev/tty
# 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
# 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
# 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")"
[ -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).
real_chgrp "$grp" "$dir" 2>/dev/null || return 1
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
_tail="$(tail -c 524288 "$log" 2>/dev/null \
| grep -aE "Permission denied|Operation not permitted|cannot (create|remove|touch|open)|couldn't copy|No such file or directory" \
| grep -avE "^[[:space:]]*(File \"|Traceback|at .* line )" \
| head -n 100)"
{ printf '%s\n' "$_tail" | grep -aoE "'[^']+'|\"[^\"]+\"" | tr -d "'\""
printf '%s\n' "$_tail" | grep -aoE "/[A-Za-z0-9._/+-]{6,}"; } \
| while IFS= read -r p; do
case "$p" in /*) ;; *) continue ;; esac
case "$p" in
"${SNAP_ROOT%/}"/*) ;;
/usr/*|/etc/*|/var/*|/opt/*|/lib/*|/lib64/*|/bin/*|/sbin/*|\
/home/*|/root/*|/srv/*|/boot/*|/tools/*|/sources/*|/build/*) ;;
*) continue ;;
esac
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
}
# Grant <user> access to everything in the log it could not write, and say
# which groups that took. Returns 0 if anything was granted.
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:"
# Many failing paths walk up to the SAME tree top, so without this the same
# directory is granted once per path -- nine identical grants in one round.
local seen=""
while IFS= read -r d; do
[ -n "$d" ] || continue
local top; top="$(collector_tree_top "$d")"
case "$seen" in *"|$top|"*) continue ;; esac
seen="$seen|$top|"
grant_dir_access "$d" "$user" && granted=1
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)"
}
# 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)
}
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 (.cc-build-*, .cc-dir-*) -> 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-*' \
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"
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
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
args=(); skipped=""
# -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 ;;
-o*|-g*) skipped="$skipped $1"; shift ;;
-m|--mode)
case "$2" in
[2467][0-7][0-7][0-7]) # setuid/setgid/sticky requested
echo "*** install -m $2 (using 755 instead; set the bit as root if you want it)" >&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
exec "$real" "${args[@]}"
EOF
cat > "$WRAPPERS/chmod" <<'EOF'
#!/bin/bash
# Allow everything except setuid/setgid, which a package user must not set.
real=/usr/bin/chmod
me="$(id -un)"
for a in "$@"; do
case "$a" in
[2467][0-7][0-7][0-7]|*[ug]+s*|*+s*)
echo "*** chmod $* (skipped: refuses to set setuid/setgid; do it as root if needed)" >&2
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
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 root:root "$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
paths="$(tail -c 524288 "$log" 2>/dev/null | grep -aoE "/[A-Za-z0-9._/+-]{6,}" \
| 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"
# 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
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
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.
_claim_earlier_stages() {
local name="$1" owner="$2" man base n=0 p cur
[ -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
# 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
for base in $seen; do
man="$MANIFESTS/$base.files"
[ -s "$man" ] || continue
while IFS= read -r p; do
[ -n "$p" ] || continue
p="$(strip_host_prefix "$p")"
[ -e "$p" ] || [ -L "$p" ] || continue
cur="$(stat -c %U "$p" 2>/dev/null)" || continue
[ "$cur" = "$owner" ] && continue
# only what the build itself left behind -- never another package's
case "$cur" in
root|lfs|UNKNOWN) ;;
*) continue ;;
esac
real_chown -h "$owner:$owner" "$p" 2>/dev/null && n=$((n+1))
done < "$man"
# the directories too, or the package cannot create files in them
man="$MANIFESTS/$base.dirs"
[ -s "$man" ] || continue
while IFS= read -r p; do
[ -n "$p" ] || continue
p="$(strip_host_prefix "$p")"
[ -d "$p" ] || continue
cur="$(stat -c %U "$p" 2>/dev/null)" || continue
[ "$cur" = "$owner" ] && continue
is_install_dir "$p" && continue # shared, leave it alone
case "$cur" in
root|lfs|UNKNOWN) ;;
*) continue ;;
esac
real_chown -h "$owner:$owner" "$p" 2>/dev/null && n=$((n+1))
done < "$man"
done
[ "$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
}
# 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 generated scripts carry "# package: <name>-<version>" in their header.
_version_from_script() {
[ -r "${1:-}" ] || return 0
sed -n 's/^# *package *: *//p' "$1" 2>/dev/null | head -1 \
| sed 's/^.*-\([0-9][0-9.]*\)$/\1/'
}
_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 _force_stage=auto
while [ $# -gt 0 ]; do
case "$1" in
--phase) phase="$2"; 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 ;;
-*) die "unknown option: $1" ;;
*) name="$1"; shift ;;
esac
done
[ -n "$name" ] || die "usage: lfs-helper build <package> [--phase P] [--force]"
# 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; s="$(script_for "$name")"
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
die "no script for '$name' at $s
Either the step order is older than these tools, or the scripts were never
generated. From outside the chroot:
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"
fi
# Clear a stale source tree first, as root -- independent of everything
# below, and it must happen even if staging later has trouble.
case "$phase" in
all|unpack) clean_stale_source "$name" "$s" ;;
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.
local as_root=0 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"
install -m 0755 "$s" "$staged" || die "could not stage the install script"
# 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")"
cmd_add_user "$owner"
stagedir="$(pkgusr_home_for "$owner")"
staged="$stagedir/install_$name"
install -m 0755 -o "$owner" -g "$owner" "$s" "$staged" \
|| die "could not stage the install script"
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" = "all" ] && clear_phases "$name"
# 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
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"
local envpass="LFS_CC_PHASE='$phase' 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
if [ "$as_root" = 1 ]; then
envpass="$envpass PATH='/usr/local/bin:/usr/local/sbin:/usr/bin:/usr/sbin:/bin:/sbin'"
else
# wrappers first in PATH -- see make_wrappers() for why
envpass="$envpass PATH='$WRAPPERS:/usr/local/bin:/usr/local/sbin:/usr/bin:/usr/sbin:/bin:/sbin'"
fi
# the books expect some test failures, so suites only run when asked
[ "$run_tests" = 1 ] && envpass="$envpass 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.
if [ -n "$jobs" ]; then
envpass="$envpass MAKEFLAGS='-j$jobs'"
elif [ -n "${MAKEFLAGS:-}" ]; then
envpass="$envpass MAKEFLAGS='$MAKEFLAGS'"
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.
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
if [ "$as_root" = 1 ]; then
( cd "$stagedir" && eval "env $envpass bash '$staged' '$phase'" ) \
2>&1 | tee "$log"; rc=${PIPESTATUS[0]}
elif have_su; then
# `su -` resets the environment, so the vars go on the command line
su - "$owner" -c "env $envpass bash '$staged' '$phase'" \
2>&1 | tee "$log"; rc=${PIPESTATUS[0]}
else
detail "# no 'su' yet (Shadow not built) -- building as root, then"
detail "# giving the installed files to '$owner'"
chown_after=1
( cd "$stagedir" && eval "env $envpass bash '$staged' '$phase'" ) \
2>&1 | tee "$log"; rc=${PIPESTATUS[0]}
fi
# 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"))"
newdirs="$(LC_ALL=C comm -13 <(printf '%s\n' "$before_dirs") <(snapshot_dirs))"
printf '%s\n' "$after" > "$SNAPSHOT"
printf '%s\n' "$(snapshot_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}"
touched_dirs="$(find "${r:-/}" -xdev -type d -newer "$stamp" \
-not -path "$SRCROOT/*" \
-not -path "$r/sources/*" -not -path "$_bld/*" \
-not -path "$_bld" -not -path "$r/dev/*" \
-not -path "$r/proc/*" -not -path "$r/sys/*" \
-not -path "$r/run/*" -not -path "$r/tmp/*" \
2>/dev/null | sort -u)"
touched="$(find "${r:-/}" -xdev \( -type f -o -type l \) -newer "$stamp" \
-not -path "$SRCROOT/*" \
-not -path "$r/sources/*" -not -path "$_bld/*" \
-not -path "$r/dev/*" \
-not -path "$r/proc/*" -not -path "$r/sys/*" \
-not -path "$r/run/*" -not -path "$r/tmp/*" \
2>/dev/null | 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.
{ [ -f "$man" ] && cat "$man"; printf '%s\n' "$newfiles"
printf '%s\n' "$touched"; } \
| 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'"
# 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
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
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
grep -qiE "permission denied|operation not permitted|cannot (create|remove|change|touch)|couldn't copy|read-only file system" \
"$log" 2>/dev/null && _perm_ish=1
# 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"
auto_grant_from_log "$log" "$owner" || break # nothing left to grant
_round=$((_round + 1))
say ""
say "# retrying $name (phase $phase), round $_round ..."
if [ "$as_root" = 1 ]; then
( cd "$stagedir" && eval "env $envpass bash '$staged' '$phase'" ) \
2>&1 | tee "$log"; rc=${PIPESTATUS[0]}
elif have_su; then
su - "$owner" -c "env $envpass bash '$staged' '$phase'" \
2>&1 | tee "$log"; rc=${PIPESTATUS[0]}
else
( cd "$stagedir" && eval "env $envpass bash '$staged' '$phase'" ) \
2>&1 | tee "$log"; rc=${PIPESTATUS[0]}
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
# An 'all' run executes the phases in order and stops at the first failure,
# so everything before the failing phase did succeed. Record that, or the
# next run wrongly reports unpack/build as "never completed".
if [ "$phase" = "all" ]; then
local _failed _p
_failed="$(grep -oE "phase '[a-z]+' FAILED" "$log" 2>/dev/null \
| head -n1 | cut -d"'" -f2)"
if [ -n "$_failed" ]; then
for _p in unpack build install configure; do
[ "$_p" = "$_failed" ] && break
record_phase "$name" "$_p"
done
fi
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
# 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"
fail "! staged: $staged"
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 $name --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 $name --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 $name --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.
#
# The scripts are regenerated from the book whenever it moves on, so their
# name_version tells you what the BOOK has, not what is on disk. Without a
# record of the latter, "is this up to date?" is unanswerable.
local _nv _home
_nv="$(sed -n 's/^name_version="\(.*\)"$/\1/p' "$s" 2>/dev/null | head -n1)"
_home="$(pkgusr_home_for "$owner")"
if [ -n "$_nv" ] && [ -d "$_home" ]; then
{
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"
# benign: a note in the package's own home, rewritten every install
real_chown "$owner:$owner" "$_home/VERSION" 2>/dev/null || true
fi
if [ "$phase" = "all" ]; then
_write_pkgusr_info "$name" "$owner" "${LFS_PKG_SOURCE:-lfs}" \
"$(_version_from_script "$staged")"
mark_done "$name"
ok "# $name: done"
report_granted_groups
trap - INT TERM
_clear_interrupted "$name"
else
record_phase "$name" "$phase"
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")"
ok "# $name: done (all phases complete)"
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 used during this run:"
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"
}
# 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 ""
say " Nothing recorded means it was installed before file tracking"
say " existed, or outside these tools entirely."
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
while IFS= read -r d; do
[ -n "$d" ] || continue
[ -d "$d" ] || { missing=$((missing+1)); continue; }
[ -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" 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)"
_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
# 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
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
}
_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
local fix=0
[ "${1:-}" = "--fix" ] && fix=1
[ "${1:-}" = "--run" ] && fix=1
_vfy_log_open
local problems=0 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"
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 ------------------------------------- #
_vfy_unclaimed || 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
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_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"
if [ "$fix" = 1 ]; then
ok "repaired $fixed path(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
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.
for d in /usr/bin /usr/sbin; 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
}
# --------------------------------------------------------------------------- #
# 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" ]; }
# 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
_warn_if_strip_was_asked_for
_warn_if_no_login
_say_what_is_yours_to_finish
}
# The two things this tool 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.
#
# 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. Nothing here configures, builds or installs one."
say "# Book 10.3, from inside the chroot:"
say "# cd /sources && tar -xf linux-*.tar.xz && cd linux-*"
say "# make menuconfig && make && make modules_install"
say "# cp -iv arch/x86/boot/bzImage /boot/vmlinuz-lfs"
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. Nothing did it, and nothing will say so.
#
# `lfs build-system session` stores the answer and the prompt says NOT YET
# IMPLEMENTED -- but that was six hours ago, at the top of an interview, and
# the build otherwise finishes as though every answer had been acted on. A
# setting that is collected, carried into the chroot environment, and then
# silently ignored is worse than one that was never offered.
#
# 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
say ""
warn "# you asked for debug symbols to be stripped, and NOTHING DID IT."
warn "#"
warn "# Stripping rewrites every binary, and in this system a file's"
warn "# owner is the record of which package installed it -- so it has"
warn "# to run as that owner, package by package. That is not written"
warn "# yet, and doing it as root would hand the whole tree to root."
warn "#"
warn "# The system is complete and correct; it is only larger than you"
warn "# asked for. Book 8.85 also removes libtool .la files."
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
}
# 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() {
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 5 ] && break
done < <(install_dirs_list)
[ "$probe" -gt 0 ] && [ "$sealed" = "$probe" ]
}
# --------------------------------------------------------------------------- #
# 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
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
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 fix-perms <name> --run grant one package the access it needs
lfs-helper grant-dir <dir> <user> --run
give one user access to one directory
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 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 find-user-site --run find pip installs hidden in ~/.local
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 "$@" ;;
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 "$@" ;;
clean-state) shift; cmd_clean_state "$@" ;;
which-package) shift; cmd_which_package "$@" ;;
grant-dir) shift; cmd_grant_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 "$@" ;;
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 "$@" ;;
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