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.gztest_lfs_crosschain.sh raw
#!/bin/bash
# End-to-end test for lfs crosschain script generation.
#
# Builds a REAL (tiny) package through a REAL generated script: unpack from a
# tarball, configure into a build/ subdir, compile, install, post-install fixup.
# Then checks the failure paths -- because "it printed done" is not evidence
# that anything worked.
#
# Usage: bash test_lfs_crosschain.sh [path-to-lfs-script]
set -u
LFS_TOOL="${1:-./lfs}"
LFS_BOOK="${2:-/mnt/user-data/uploads/LFS-BOOK-12_4-NOCHUNKS.html}"
T=$(mktemp -d /tmp/lfstest.XXXXXX)
PASS=0; FAIL=0
# Count in FILES, not shell variables.
#
# Several checks call ok/bad from inside a subshell -- `( ... ) && ok || bad`,
# a pipeline, a `while read` fed by process substitution. A variable
# incremented there dies with the subshell, so the suite printed 446 PASS
# lines and reported 433. A wrong count is worse than no count: it can hide a
# regression behind a number that still looks healthy.
_COUNTDIR="${TMPDIR:-/tmp}/lfstest-count.$$"
mkdir -p "$_COUNTDIR"
: > "$_COUNTDIR/pass"
: > "$_COUNTDIR/fail"
ok() { echo " PASS $1"; echo x >> "$_COUNTDIR/pass"; }
bad() { echo " FAIL $1"; echo x >> "$_COUNTDIR/fail"; }
# Lift ONE function out of a bash source file, to exercise it in isolation.
#
# This exists because `sed -n "/^fn() {/,/^}/p"` cannot do it. A one-line
# function --
# pkgusr_roots() { printf '%s\n%s\n' "$PKGUSR_ROOT" "$CFGUSR_ROOT"; }
# -- has no line starting with `}`, so sed runs on and swallows whatever
# function comes next. That is not a cosmetic problem: the swallowed body
# referenced $STATE, `set -u` killed the subshell before a single assertion
# ran, the output collected nothing, and the test reported PASS. It had been
# passing that way while its four comparisons were also wrong.
#
# A test that cannot fail is worse than no test, so the slicer is shared and
# there is exactly one of it.
_slice_fn() { # _slice_fn <file> <fn>
awk -v fn="$2" '
BEGIN { start = "^" fn "\\(\\) \\{" }
!inside && $0 ~ start {
print
if ($0 !~ /\}[[:space:]]*$/) inside = 1
next
}
inside { print; if ($0 ~ /^\}/) inside = 0 }
' "$1"
}
# the python-heredoc blocks report their own verdict; fold them in the same way
_count_rc() {
if [ "$1" -eq 0 ]; then echo x >> "$_COUNTDIR/pass"
else echo x >> "$_COUNTDIR/fail"; fi
}
_pass_total() { wc -l < "$_COUNTDIR/pass" | tr -d ' '; }
_fail_total() { wc -l < "$_COUNTDIR/fail" | tr -d ' '; }
cleanup() { rm -rf "$T" "$_COUNTDIR"; }
trap cleanup EXIT
export LFS="$T/lfs"
# Two directories, as the tools use them: SOURCES_DIR holds the downloaded
# tarballs and is never written by a build; BUILD_ROOT is the scratch a build
# unpacks into. They were one directory once, which is what every stale
# marker and foreign-ownership bug in /sources came from.
export SOURCES_DIR="$LFS/sources"
export BUILD_ROOT="$LFS/build"
mkdir -p "$LFS/sources" "$LFS/build" "$LFS/tools/bin"
# ---------------------------------------------------------------- fake package
# A package that behaves like a real autotools one: out-of-tree build dir,
# a configure that must run before make, and an install step.
mkdir -p "$T/src/demo-1.0"
cat > "$T/src/demo-1.0/configure" <<'EOF'
#!/bin/bash
prefix=/usr/local
for a in "$@"; do case "$a" in --prefix=*) prefix="${a#--prefix=}";; esac; done
cat > Makefile <<MK
all:
@echo "compiling"; echo "binary" > demo.bin
install:
@mkdir -p "$prefix/bin"; cp demo.bin "$prefix/bin/demo"; echo "installed to $prefix/bin/demo"
MK
echo "configured with prefix=$prefix"
EOF
chmod +x "$T/src/demo-1.0/configure"
( cd "$T/src" && tar czf "$LFS/sources/demo-1.0.tar.gz" demo-1.0 )
# ------------------------------------------------- generate a script for real
# Uses the SAME generator the tool uses, with book-shaped command blocks.
python3 - "$LFS_TOOL" "$T/demo.sh" <<'PY'
import sys, importlib.machinery as m
lfs = m.SourceFileLoader('lfs', sys.argv[1]).load_module()
cmds = [
"mkdir -v build\ncd build",
"../configure --prefix=$LFS/tools",
"make",
"make install",
"echo post-install-fixup > $LFS/tools/fixup.txt",
]
body = lfs._crosschain_script_body("demo", "ch-tools-demo", "demo-1.0",
"demo-1.0.tar.* demo-1.0.tgz", cmds)
open(sys.argv[2], "w").write(body)
PY
bash -n "$T/demo.sh" && ok "generated script is valid bash" || bad "syntax error"
# phases must be split the way the book reads
grep -q "^\.\./configure" "$T/demo.sh" && ok "configure landed in build phase" \
|| bad "configure not in build phase"
sed -n '/^install_pkg/,/INSTALL DONE/p' "$T/demo.sh" | grep -q "make install" \
&& ok "make install landed in install phase" || bad "install phase wrong"
sed -n '/^configure_pkg/,/CONFIGURE DONE/p' "$T/demo.sh" | grep -q "post-install-fixup" \
&& ok "post-install cmd landed in configure phase" || bad "configure phase wrong"
# --------------------------------------------------------------- run it fully
out="$T/run.log"
if bash "$T/demo.sh" all > "$out" 2>&1; then
ok "full run exited 0"
else
bad "full run failed (exit $?)"; sed 's/^/ /' "$out"
fi
[ -f "$LFS/build/demo-1.0/build/Makefile" ] \
&& ok "built out-of-tree in build/ subdir" || bad "build/ subdir missing"
[ -f "$LFS/tools/bin/demo" ] \
&& ok "package actually installed into \$LFS/tools/bin" \
|| bad "install produced no file"
[ -f "$LFS/tools/fixup.txt" ] \
&& ok "configure (post-install) phase ran" || bad "configure phase did not run"
grep -q "^/.*build$" "$LFS/build/.cc-build-demo" 2>/dev/null \
&& ok "recorded build dir for resume" || bad "build dir not recorded"
# ------------------------------------------------- single-phase re-run works
rm -f "$LFS/tools/bin/demo"
if bash "$T/demo.sh" install > "$T/i.log" 2>&1 && [ -f "$LFS/tools/bin/demo" ]; then
ok "re-running ONLY the install phase works (no recompile)"
else
bad "install-only re-run failed"; sed 's/^/ /' "$T/i.log"
fi
# ----------------------------------------------------- failure MUST propagate
sed 's|^make$|false # simulated compile failure|' "$T/demo.sh" > "$T/failbuild.sh"
if bash "$T/failbuild.sh" all > "$T/f.log" 2>&1; then
bad "FAILING BUILD REPORTED SUCCESS (the bug that wasted a day)"
else
ok "failing build exits non-zero"
fi
grep -q "phase 'build' FAILED" "$T/f.log" \
&& ok "failure names the phase" || bad "failure did not name the phase"
grep -qi "installed to" "$T/f.log" \
&& bad "continued to install after a failed build" \
|| ok "did NOT continue to install after a failed build"
# missing tarball must fail cleanly, not silently
rm -rf "$T/lfs2"; mkdir -p "$T/lfs2/sources"
if LFS="$T/lfs2" SOURCES_DIR="$T/lfs2/sources" \
BUILD_ROOT="$T/lfs2/build" bash "$T/demo.sh" unpack \
> "$T/m.log" 2>&1; then
bad "missing tarball reported success"
else
ok "missing tarball fails cleanly"
fi
echo
echo " PASS: $PASS FAIL: $FAIL"
[ "$(_fail_total)" -eq 0 ]
# ---- tarball name resolution against a real-world /sources listing --------- #
# Regression guard: book titles are messy (Util-linux-2.41.1, XML::Parser-2.47,
# Flit-Core-3.12.0 shipping as flit_core-, Expect-5.45.4 as expect5.45.4) and a
# wrong guess makes unpack fail with "no <wrong-name> in /sources".
python3 - "$LFS_TOOL" <<'PY'
import sys, importlib.machinery as m
lfs = m.SourceFileLoader('lfs', sys.argv[1]).load_module()
cases = [
("7.12. Util-linux-2.41.1", "util-linux-2.41.1.tar.xz"),
("7.10. Python-3.13.7", "Python-3.13.7.tar.xz"),
("8.44. XML::Parser-2.47", "XML-Parser-2.47.tar.gz"),
("8.52. Flit-Core-3.12.0", "flit_core-3.12.0.tar.gz"),
("8.x. Expect-5.45.4", "expect5.45.4.tar.gz"),
("8.x. Tcl-8.6.16", "tcl8.6.16-src.tar.gz"),
("8.x. Expat-2.7.1", "expat-2.7.1.tar.xz"),
("6.3. Ncurses-6.5-20250809", "ncurses-6.5-20250809.tgz"),
("5.2. Binutils-2.45 - Pass 1", "binutils-2.45.tar.xz"),
("5.4. Linux-6.16.1 API Headers", "linux-6.16.1.tar.xz"),
("5.6. Libstdc++ from GCC-15.2.0", "gcc-15.2.0.tar.xz"),
]
import fnmatch
bad = 0
for title, want in cases:
glob = lfs._pkg_glob_for(lfs._title_pkgver(title), "x")
hit = any(fnmatch.fnmatch(want, p) for p in glob.split())
print((" PASS " if hit else " FAIL ") + f"{title!r} finds {want}")
bad += 0 if hit else 1
sys.exit(1 if bad else 0)
PY
_count_rc $?
echo " PASS: $(_pass_total) FAIL: $(_fail_total) (incl. tarball-name cases above)"
# ---- source vs documentation tarballs -------------------------------------- #
# tcl ships tcl8.6.16-html.tar.gz alongside tcl8.6.16-src.tar.gz, and the docs
# sort first -- unpacking those gives a tree with no unix/ dir and the build
# dies with "cd: unix: No such file or directory".
srct="$T/srcsel"; mkdir -p "$srct/src/tcl8.6.16/unix" "$srct/doc/tcl8.6.16/html"
( cd "$srct/src" && tar czf "$srct/tcl8.6.16-src.tar.gz" tcl8.6.16 )
( cd "$srct/doc" && tar czf "$srct/tcl8.6.16-html.tar.gz" tcl8.6.16 )
picked=$( cd "$srct" && bash -c '
pkg_glob="tcl8.6.16.tar.* tcl8.6.16.tgz tcl8.6.16*.tar.* tcl8.6.16*.tgz"
pkg=""
for _c in $pkg_glob; do [ -e "$_c" ] || continue
case "$_c" in *-src.*|*-source.*) pkg="$_c"; break ;; esac; done
if [ -z "$pkg" ]; then for _c in $pkg_glob; do [ -e "$_c" ] || continue
case "$_c" in *-html.*|*-doc.*|*-docs.*|*-man.*|*-manual.*|*-tests.*) continue ;; esac
pkg="$_c"; break; done; fi
echo "$pkg"' )
[ "$picked" = "tcl8.6.16-src.tar.gz" ] \
&& ok "picks the -src tarball, not the docs" \
|| bad "picked '$picked' instead of tcl8.6.16-src.tar.gz"
# ---- phase bodies must not be fed on stdin -------------------------------- #
# Expect's book section starts with a PTY sanity check:
# python3 -c 'from pty import spawn; spawn(["echo", "ok"])'
# pty.spawn READS STDIN. When the phase body was piped in via `bash -s`, it
# swallowed the remaining lines -- patch/configure/make were echoed instead of
# executed, and the install then failed with "No rule to make target 'install'".
ptyd="$T/pty"; mkdir -p "$ptyd"
cat > "$ptyd/t.sh" <<'OUTER'
_phase_body() {
local _f; _f="$(mktemp)" || return 1
cat > "$_f"
bash "$_f" </dev/null
local _r=$?
rm -f "$_f"
return $_r
}
_phase_body <<'__LFS_PHASE__'
set -e
python3 -c 'from pty import spawn; spawn(["echo","ok"])' >/dev/null 2>&1 || true
echo RAN_PATCH
echo RAN_CONFIGURE
echo RAN_MAKE
__LFS_PHASE__
OUTER
out="$(bash "$ptyd/t.sh" 2>/dev/null | tr -d '\r')"
if echo "$out" | grep -q RAN_PATCH && echo "$out" | grep -q RAN_CONFIGURE \
&& echo "$out" | grep -q RAN_MAKE; then
ok "a stdin-reading command does not swallow the rest of the phase"
else
bad "phase body was consumed by a stdin-reading command"
fi
# the generated scripts must all use the file-based runner
if grep -l "bash -s <<'__LFS_PHASE__'" "$T"/*.sh >/dev/null 2>&1; then
bad "a generated script still pipes its phase body through stdin"
else
ok "generated scripts run phase bodies from a file"
fi
# ---- commands inside note/tip boxes must be ignored ------------------------ #
# GMP's section has a note showing "ABI=32 ./configure ..." for 32-bit hosts.
# Running that literally passes an ellipsis to configure:
# Invalid configuration '...': machine '...-unknown' not recognized
# Glibc likewise has a note with an alternative DESTDIR install method.
python3 - "$LFS_TOOL" "$LFS_BOOK" <<'PY2'
import sys, importlib.machinery as m
lfs = m.SourceFileLoader('lfs', sys.argv[1]).load_module()
book = sys.argv[2] if len(sys.argv) > 2 else None
if not book:
print(" SKIP no book available for the note-filter check"); sys.exit(0)
soup = lfs.load_soup(book)
# Only flag blocks that genuinely came from a note box. (ncurses uses
# DESTDIR=$PWD/dest as its REAL install method, so that string alone proves
# nothing -- check the specific sections whose notes we know about.)
watch = {
"ch-system-gmp": "ABI=32 ./configure ...",
"ch-system-glibc": "DESTDIR=$PWD/dest",
}
bad = []
for sid, t, sec in lfs.iter_sections(soup):
needle = watch.get(sid)
for b in sec["commands"]:
if needle and needle in b:
bad.append((sid, b.splitlines()[0][:50]))
if "make distclean" in b:
bad.append((sid, b.splitlines()[0][:50]))
if bad:
for sid, b in bad[:5]:
print(f" FAIL note-box command still extracted: {sid}: {b}")
sys.exit(1)
print(" PASS commands inside note/tip boxes are ignored")
PY2
_count_rc $?
# ---- a test's log reader must stay with the test --------------------------- #
# GMP writes a log in the test block and reads it back in the next one:
# make check 2>&1 | tee gmp-check-log
# awk '/# PASS:/{total+=$3} ; END{print total}' gmp-check-log
# Moving only the first to the test phase leaves the awk in the build, where it
# dies with "cannot open file `gmp-check-log'".
python3 - "$LFS_TOOL" <<'PY3'
import sys, importlib.machinery as m
lfs = m.SourceFileLoader('lfs', sys.argv[1]).load_module()
cmds = ["./configure --prefix=/usr",
"make",
"make check 2>&1 | tee gmp-check-log",
"awk '/# PASS:/{total+=$3} ; END{print total}' gmp-check-log",
"make install"]
build, install, config, test = lfs._split_crosschain_phases(cmds)
problems = []
if "gmp-check-log" in build: problems.append("log reader left in the build phase")
if "make check" in build: problems.append("test suite left in the build phase")
if "gmp-check-log" not in test: problems.append("log reader not moved to the test phase")
if "make install" not in install: problems.append("install phase wrong")
if problems:
for p in problems: print(f" FAIL {p}")
sys.exit(1)
print(" PASS a test's log reader travels with the test suite")
PY3
_count_rc $?
# ---- a stale source tree from an earlier chapter must be cleared ----------- #
# BUILD_ROOT is sticky (1777), so only the owner may delete a directory there.
# Chapters 5-6 unpack as the `lfs` user; chapter 8 rebuilds the same packages as
# their own package user, which then cannot remove the leftover tree -- the
# script's `rm -rf` fails, tar extracts on top, and the build drowns in
# tar: gcc-15.2.0/README: Cannot open: File exists
# lfs-helper must clear it as root BEFORE handing over to the package user.
#
# The tarball is READ from the sources directory and the stale tree is removed
# from the build scratch: two directories, and the cleanup must use the right
# one for each.
stl="$T/stale"; mkdir -p "$stl/sources" "$stl/build" "$stl/src/demo-9.9"
echo fresh > "$stl/src/demo-9.9/NEW"
( cd "$stl/src" && tar czf "$stl/sources/demo-9.9.tar.gz" demo-9.9 )
mkdir -p "$stl/build/demo-9.9"; echo old > "$stl/build/demo-9.9/OLD"
chmod 1777 "$stl/sources" "$stl/build"
cat > "$stl/script.sh" <<'EOF'
pkg_glob="demo-9.9.tar.* demo-9.9.tgz"
EOF
# run just the cleanup routine out of lfs-helper
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
( STATE="$stl"; SNAP_ROOT="$stl"; C_DIM=; C_OFF=
SOURCES_DIR="$stl/sources"; BUILD_ROOT="$stl/build"
say(){ :; }; warn(){ :; }
# build_root_for decides where a step's scratch lives; a package builds
# in its own home, so the routine no longer reads $BUILD_ROOT directly.
# Stub it to the shared tree this fixture set up.
eval "$(_slice_fn "$helper_src" clean_stale_source)"
build_root_for() { printf '%s' "$BUILD_ROOT"; }
clean_stale_source demo "$stl/script.sh" )
if [ -d "$stl/build/demo-9.9" ]; then
bad "stale source tree was not cleared before the build"
elif [ ! -f "$stl/sources/demo-9.9.tar.gz" ]; then
bad "the cleanup removed the tarball instead of the unpacked tree"
else
ok "a stale source tree is cleared before the build"
fi
else
echo " SKIP lfs-helper not found next to $LFS_TOOL"
fi
# ---- collector groups for cross-package installs --------------------------- #
# gcc installs gcc.mo into /usr/share/locale/<lang>/LC_MESSAGES, directories
# another package owns, so the gcc user gets "Permission denied". The fix is a
# collector group <prefix>_<owner>: the dir keeps its owner but becomes
# group-writable and both packages join the group.
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
for fn in collector_group_for ensure_group add_to_group grant_dir_access; do
grep -q "^$fn()" "$helper_src" \
|| { bad "lfs-helper is missing $fn (collector-group support)"; break; }
done
grep -q "^cmd_fix_perms()" "$helper_src" \
&& ok "collector-group support and fix-perms are present" \
|| bad "lfs-helper has no fix-perms command"
# the failure box must point at it
grep -q "lfs-helper fix-perms" "$helper_src" \
&& ok "a Permission-denied failure suggests fix-perms" \
|| bad "failure message does not mention fix-perms"
else
echo " SKIP lfs-helper not found next to $LFS_TOOL"
fi
# ---- command wrappers (the hint's /usr/lib/pkgusr) ------------------------- #
# The book's GCC section runs `chown -v -R root:root /usr/lib/gcc/.../include`.
# As a package user that fails with "Operation not permitted" and kills the
# install -- even though the files are already owned correctly for this scheme.
# Wrappers for chown/chgrp/install/chmod/mkdir neutralise the impossible parts.
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
wt="$T/wrap"; mkdir -p "$wt"
( STATE="$wt"; WRAPPERS="$wt/wrappers"
eval "$(sed -n '/^make_wrappers() {/,/^}/p' "$helper_src")"
make_wrappers )
W="$wt/wrappers"
if [ -x "$W/chown" ] && [ -x "$W/install" ] && [ -x "$W/chmod" ] \
&& [ -x "$W/mkdir" ] && [ -x "$W/chgrp" ]; then
ok "the five command wrappers are created"
else
bad "command wrappers missing"
fi
# chown must not fail the build
"$W/chown" -R root:root /nonexistent >/dev/null 2>&1 \
&& ok "chown wrapper never fails the build" \
|| bad "chown wrapper returned non-zero"
# install must still install, minus -o/-g
echo data > "$wt/s"
"$W/install" -m 644 -o root -g root "$wt/s" "$wt/d" >/dev/null 2>&1
[ -f "$wt/d" ] && ok "install wrapper drops -o/-g but still installs" \
|| bad "install wrapper did not install the file"
# setuid must be refused
"$W/install" -m 4755 "$wt/s" "$wt/suid" >/dev/null 2>&1
if [ -u "$wt/suid" ]; then bad "install wrapper set the setuid bit"
else ok "install wrapper refuses setuid modes"; fi
# PATH must put them first for package-user builds
grep -q 'PATH=.\$WRAPPERS' "$helper_src" \
&& ok "wrappers go first in the package user's PATH" \
|| bad "wrappers are not put on PATH"
else
echo " SKIP lfs-helper not found next to $LFS_TOOL"
fi
# ---- install directories stay group-writable ------------------------------- #
# The hint defines an install directory as: chgrp install <dir> && chmod g+w,o+t
# Packages reset modes on dirs they touch, so /usr/share/man/man1 can end up
# root:install but drwxr-xr-x -- and the next package cannot write its man page:
# ln: failed to create symbolic link '/usr/share/man/man1/cc.1': Permission denied
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
grep -q "ensure_install_dirs_writable" "$helper_src" \
&& ok "install-dir permissions are re-applied before each build" \
|| bad "no self-healing for install-dir permissions"
# the group-write test must not be fooled by the OWNER's w bit
if grep -q '10#\$_mode' "$helper_src"; then
ok "group-write is detected numerically (drwxr-xr-x is not 'writable')"
else
bad "group-write detection may match the owner's w bit"
fi
# init must use the hint's g+w, and sticky must be deferred to the seal step
grep -q 'chmod g+w "\$d"' "$helper_src" \
&& ok "install dirs get chmod g+w (sticky deferred to seal-install-dirs)" \
|| bad "install dirs are not set group-writable per the hint"
else
echo " SKIP lfs-helper not found next to $LFS_TOOL"
fi
# ---- re-running a phase must not fail on existing symlinks ----------------- #
# The books write `ln -sv ../bin/cpp /usr/lib` with no -f, so retrying a
# configure/install phase dies with
# ln: failed to create symbolic link '/usr/lib/cpp': File exists
python3 - "$LFS_TOOL" <<'PY4'
import sys, importlib.machinery as m
lfs = m.SourceFileLoader('lfs', sys.argv[1]).load_module()
cases = [
("ln -sv ../bin/cpp /usr/lib", "ln -svf ../bin/cpp /usr/lib"),
("ln -sv gcc /usr/bin/cc", "ln -svf gcc /usr/bin/cc"),
("ln -sfv libncursesw.so /usr/lib/libcurses.so","ln -sfv libncursesw.so /usr/lib/libcurses.so"),
("ln /usr/bin/foo /usr/bin/bar", "ln /usr/bin/foo /usr/bin/bar"),
]
bad = 0
for src, want in cases:
got = lfs._idempotent_links(src)
if got != want:
print(f" FAIL {src!r} -> {got!r}, expected {want!r}"); bad += 1
if bad: sys.exit(1)
print(" PASS symlink steps are made re-runnable (-f added only where missing)")
PY4
_count_rc $?
# ---- collector groups are configured, automatic, and reported -------------- #
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
grep -q "require_collector_prefix" "$helper_src" \
&& ok "an unset collector prefix stops the build with instructions" \
|| bad "no check for a missing collector prefix"
grep -q "auto_grant_from_log" "$helper_src" \
&& ok "permission failures are granted and retried automatically" \
|| bad "no automatic collector-group recovery"
grep -q "report_granted_groups" "$helper_src" \
&& ok "collector groups created during a run are reported" \
|| bad "collector groups are created silently"
fi
# the config must be complete before the build-system commands run
python3 - "$LFS_TOOL" <<'PY5'
import sys, importlib.machinery as m
lfs = m.SourceFileLoader('lfs', sys.argv[1]).load_module()
keys = [k for k, _w, _h in lfs.REQUIRED_CONFIG]
if "collector_prefix" in keys and "lfs_mount" in keys:
print(" PASS collector_prefix is a required setting")
else:
print(f" FAIL REQUIRED_CONFIG is missing keys: {keys}"); sys.exit(1)
PY5
_count_rc $?
# ---- collector group name is a choice, not a fixed rule -------------------- #
# Naming after the OWNER (nimgnu_binutils) groups everything that package owns;
# naming after the DIRECTORY (nimgnu_bfd-plugins) is more precise. Both are
# reasonable, so the user picks -- with the context needed to decide.
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
grep -q "choose_collector_group" "$helper_src" \
&& ok "the collector group name is offered as a choice" \
|| bad "collector group name is not selectable"
# the prompt must use /dev/tty: it runs inside a `while ... <<< list` loop,
# where stdin is the directory list, so a plain read would eat that instead
grep -qE "read (-e )?-r ans < /dev/tty" "$helper_src" \
&& ok "the prompt reads from /dev/tty (stdin is the dir list)" \
|| bad "prompt would consume the directory list instead of user input"
# names must be sanitised for the group database
( eval "$(sed -n '/^sanitise_group_name() {/,/^}/p' "$helper_src")"
got="$(sanitise_group_name 'nimgnu_BFD Plugins!')"
case "$got" in *' '*|*'!'*) exit 1 ;; esac
[ -n "$got" ] ) \
&& ok "group names are sanitised for the group database" \
|| bad "group names are not sanitised"
fi
# ---- a later phase failing may mean an earlier one never finished ---------- #
# gcc's configure step runs `mv -v /usr/lib/*gdb.py ...`. Those files come from
# `make install`; if that aborted partway (a permission error, say) they were
# never installed and the mv fails with a confusing "No such file or directory".
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
grep -q "these earlier phases have not completed successfully" "$helper_src" \
&& ok "a failure points at earlier phases that never completed" \
|| bad "no hint about incomplete earlier phases"
fi
# ---- /sources hygiene ------------------------------------------------------ #
# Two kinds of debris break later builds:
# * tar run as root restores the UIDs recorded IN the archive, so /sources ends
# up owned by host-only ids (8282, 15399, ...) that mean nothing here;
# * a build marker left by a previous package user cannot be replaced under the
# sticky /sources: /sources/.cc-build-ncurses: Permission denied
python3 - "$LFS_TOOL" <<'PY6'
import sys, importlib.machinery as m
lfs = m.SourceFileLoader('lfs', sys.argv[1]).load_module()
body = lfs._crosschain_script_body("demo", "ch-system-demo", "demo-1.0",
"demo-1.0.tar.*", ["make", "make install"])
if "--no-same-owner" not in body:
print(" FAIL unpack does not use tar --no-same-owner"); sys.exit(1)
print(" PASS unpack never restores the archive's UIDs")
PY6
_count_rc $?
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
# markers live in the build scratch now, not in the tarball store
grep -q 'rm -f "\$blddir/.cc-build-\$name"' "$helper_src" \
&& ok "stale build markers are cleared before a build" \
|| bad "stale build markers are not cleared"
grep -q "^cmd_clean_sources()" "$helper_src" \
&& ok "clean-sources can tidy /sources after the fact" \
|| bad "no clean-sources command"
fi
# ---- staged installs with `cp -a dest/* /` --------------------------------- #
# ncurses (and others) install a staged tree that way. The FILES copy fine as a
# package user, but cp then tries to stamp the destination DIRECTORIES (/usr,
# /usr/bin -- owned by root) and fails:
# cp: preserving times for '/usr/bin': Operation not permitted
# That is harmless; a real cp error must still fail the build.
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
cpt="$T/cpwrap"; mkdir -p "$cpt"
( STATE="$cpt"; WRAPPERS="$cpt/wrappers"
eval "$(sed -n '/^make_wrappers() {/,/^}/p' "$helper_src")"
make_wrappers )
if [ -x "$cpt/wrappers/cp" ]; then
ok "a cp wrapper exists for staged installs"
# a genuine failure must still be a failure
if "$cpt/wrappers/cp" /definitely-not-here "$cpt/" >/dev/null 2>&1; then
bad "cp wrapper swallowed a real error"
else
ok "cp wrapper still fails on a real error"
fi
# a normal copy must work
echo hi > "$cpt/a"
"$cpt/wrappers/cp" "$cpt/a" "$cpt/b" >/dev/null 2>&1
[ -f "$cpt/b" ] && ok "cp wrapper copies normally" \
|| bad "cp wrapper broke a normal copy"
else
bad "no cp wrapper"
fi
fi
# ---- per-language man/locale dirs are shared, not per-package -------------- #
# Every package ships translated man pages and message catalogues, creating a
# directory per language. Treating those as owned by whichever package made
# them first turns each one into a "grant access?" prompt -- dozens per package.
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
( SNAP_ROOT=/
eval "$(sed -n '/^install_dir_patterns() {/,/^}/p' "$helper_src")"
eval "$(sed -n '/^matches_install_pattern() {/,/^}/p' "$helper_src")"
for d in /usr/share/man/de/man1 /usr/share/man/man1 \
/usr/share/locale/pl/LC_MESSAGES /usr/lib/pkgconfig; do
matches_install_pattern "$d" || exit 1
done
# a package's own directory must NOT be treated as shared
for d in /usr/src/foo /usr/lib/gcc/x86_64/15.2.0; do
matches_install_pattern "$d" && exit 1
done
exit 0 ) \
&& ok "language man/locale dirs are shared; package dirs are not" \
|| bad "install-dir pattern matching is wrong"
grep -q "^cmd_prune_locales()" "$helper_src" \
&& ok "unwanted languages can be pruned" \
|| bad "no prune-locales command"
fi
python3 - "$LFS_TOOL" <<'PY7'
import sys, importlib.machinery as m
lfs = m.SourceFileLoader('lfs', sys.argv[1]).load_module()
keys = [k for k, _d in lfs.CONFIG_KEYS]
print(" PASS 'locales' is a documented setting" if "locales" in keys
else " FAIL no 'locales' setting")
sys.exit(0 if "locales" in keys else 1)
PY7
_count_rc $?
# ---- a package owns the files it OVERWRITES, not just new ones ------------- #
# Chapter 8's bison replaces the binaries chapter 7's bison-tmp installed as
# root. Those files are not "new", so a pure snapshot diff never sees them and
# they keep the previous owner -- which is why bison stayed owned by root.
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
grep -q '\-newer "\$stamp"' "$helper_src" \
&& ok "files modified during a build are reassigned, not just new ones" \
|| bad "only newly-created files get the package user's ownership"
# a stamp must exist; WHERE it lives is checked below (not /tmp)
grep -q 'stamp=' "$helper_src" \
&& ok "there is a build timestamp for detecting modified files" \
|| bad "no build timestamp for detecting modified files"
fi
# ---- test targets carry suffixes ------------------------------------------- #
# Perl runs `TEST_JOBS=$(nproc) make test_harness`; others use `make check-TESTS`
# or `check-recursive`. Missing those runs a package's whole test suite as part
# of the BUILD -- slow, and a test failure then kills the build.
python3 - "$LFS_TOOL" <<'PY8'
import sys, importlib.machinery as m
lfs = m.SourceFileLoader('lfs', sys.argv[1]).load_module()
cases = [("TEST_JOBS=$(nproc) make test_harness", True),
("make check-TESTS", True), ("make check", True), ("make test", True),
("make NON_ROOT_USERNAME=tester check-root", True),
("make install", False), ("make install-html", False),
("make install-strip", False), ("make", False)]
bad = 0
for c, want in cases:
got = bool(lfs._TEST_BLOCK_RE.search(c))
if got != want:
print(f" FAIL {c!r} classified as test={got}"); bad += 1
if bad: sys.exit(1)
print(" PASS suffixed test targets (test_harness, check-TESTS) are recognised")
PY8
_count_rc $?
# ---- failures report what the paths actually are ---------------------------- #
# "No such file or directory" on a destination can mean a missing parent OR a
# dangling symlink; "Permission denied" can mean the file exists but belongs to
# another package. Showing the facts beats guessing from the message.
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
dg="$T/diag"; mkdir -p "$dg/real"
ln -s /nowhere/at/all "$dg/real/dangling.pm"
echo x > "$dg/real/owned.pm"
printf "Couldn't copy a to %s: No such file or directory\n" "$dg/real/dangling.pm" > "$dg/log"
printf "Couldn't copy b to %s: Permission denied\n" "$dg/real/owned.pm" >> "$dg/log"
out="$( C_WARN=; C_OFF=; SNAP_ROOT="$dg"; say(){ echo "$*"; }
eval "$(sed -n '/^diagnose_paths_from_log() {/,/^}/p' "$helper_src")"
diagnose_paths_from_log "$dg/log" nobody )"
echo "$out" | grep -q "DANGLING SYMLINK" \
&& ok "a dangling destination symlink is identified" \
|| bad "dangling symlink not reported"
echo "$out" | grep -q "owned.pm" \
&& ok "an existing destination's owner and mode are shown" \
|| bad "destination ownership not reported"
# relative-path tails must not be reported as absolute targets
echo "$out" | grep -qE "^ /[^/]+\.pm " \
&& bad "a relative path tail was treated as a target" \
|| ok "relative path tails are not mistaken for targets"
fi
# ---- recovery must not depend on the wording of the error ------------------ #
# Perl's installperl reports an unwritable destination directory as
# Couldn't copy lib/GDBM_File.pm to .../GDBM_File.pm: No such file or directory
# not "Permission denied". Keying the auto-recovery on that phrase meant perl
# never recovered. And a root-owned tree left by a chapter-7 -tmp package has
# hundreds of subdirectories the real package installs into, so granting only
# the named one just fails again on the next.
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
grep -q "couldn't copy|cannot create" "$helper_src" \
&& ok "recovery and hints match more than 'Permission denied'" \
|| bad "recovery still keys only on the literal 'Permission denied'"
# (the earlier root-owned-subtree workaround is gone: -tmp packages are now
# adopted to the base package user, so no root-owned tree is left behind)
# REVERSED deliberately: a collector group means "may install into a
# directory this package owns", NOT "owns that package's tree". Granting
# the subtree in one pass was over-reach -- one file in site-packages took
# ~190 python directories with it. One directory at a time is correct;
# a package that needs several gets several rounds.
grep -q 'find "\$dir" -mindepth 1 -type d -user "\$owner"' "$helper_src" \
&& bad "granting still takes the owner's whole subtree" \
|| ok "only the directory asked for is granted"
grep -q 'SNAP_ROOT%/}"/\*) echo "\$p"' "$helper_src" \
&& ok "diagnostics only report paths inside the tree or a known root" \
|| bad "diagnostics would report relative tails as targets"
fi
# ---- collector groups share the whole area, iteratively -------------------- #
# An install reveals only the FIRST directory it cannot write. Perl modules
# span core_perl/ and site_perl/ and dozens of subdirs, so granting one and
# retrying once is not enough -- and members must be able to REPLACE each
# other's files, not just add new ones.
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
grep -q '_round" -lt "\$_max_rounds"' "$helper_src" \
&& ok "grant-and-retry iterates until nothing new needs granting" \
|| bad "only one grant round; later directories would still fail"
grep -q 'find "\$dir" -type f -user "\$owner"' "$helper_src" \
&& bad "granting still rewrites every file in the owner's tree" \
|| ok "files in the owner's tree are left alone"
# unquoted paths in messages must be found too (bash and perl report them
# bare), and the scan must stay bounded
grep -q 'grep -aoE "/\[A-Za-z0-9._/+-\]{6,}"' "$helper_src" \
&& ok "unquoted paths in error messages are recognised" \
|| bad "only quoted paths are extracted; bash/perl messages missed"
# -tmp manifests must map to the BASE package user (perl-tmp -> perl)
grep -q "^_stage_base_name()" "$helper_src" \
&& ok "the -tmp and -passN manifests map to the base package user" \
|| bad "-tmp steps leave root-owned trees behind"
fi
# ---- package user names must be normalised --------------------------------- #
# The book's anchors are inconsistent in case: chapter 7 has "python-tmp" but
# chapter 8 has "Python". Unix user names are case-sensitive, so those become
# two accounts -- and the chapter-8 package then cannot overwrite its OWN
# chapter-7 files:
# install: cannot remove '/usr/lib/python3.13/.../__init__.py': Permission denied
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
out="$( eval "$(sed -n '/^pkg_owner_name() {/,/^}/p' "$helper_src")"
for n in Python python-tmp GCC gcc-pass1 libstdcpp \
Binutils-pass2 XML::Parser util-linux-tmp; do
printf '%s=%s\n' "$n" "$(pkg_owner_name "$n")"
done )"
fails=""
echo "$out" | grep -q "^Python=python$" || fails="$fails Python"
echo "$out" | grep -q "^python-tmp=python$" || fails="$fails python-tmp"
echo "$out" | grep -q "^GCC=gcc$" || fails="$fails GCC"
echo "$out" | grep -q "^gcc-pass1=gcc$" || fails="$fails gcc-pass1"
echo "$out" | grep -q "^libstdcpp=gcc$" || fails="$fails libstdcpp"
echo "$out" | grep -q "^Binutils-pass2=binutils$" || fails="$fails Binutils-pass2"
echo "$out" | grep -q "^util-linux-tmp=util-linux$" || fails="$fails util-linux-tmp"
# Perl module names are not valid user names
echo "$out" | grep -q "^XML::Parser=xml-parser$" || fails="$fails XML::Parser"
[ -z "$fails" ] \
&& ok "package user names are normalised (case, passes, module names)" \
|| bad "owner name wrong for:$fails"
fi
# ---- share from the top of a package's tree -------------------------------- #
# Python's standard library is ~200 directories under /usr/lib/python3.13. An
# install reveals one unwritable directory at a time, so granting leaf by leaf
# never finishes -- it exhausts the retry budget and gives up. Walking up to
# the top of the owner's tree shares it in a single pass. The walk must stop at
# a shared install directory (/usr/lib), never above it.
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
# REVERSED deliberately: sharing from the top of the owner's tree hands
# over directories the package never asked for. Per-directory granting
# with several retry rounds is the correct trade.
grep -q "the top of \$owner's tree" "$helper_src" \
&& bad "granting still shares from the top of the owner's tree" \
|| ok "grants are per-directory, not whole-tree"
grep -q 'is_install_dir "\$parent" && break' "$helper_src" \
&& ok "the walk up stops at a shared install directory" \
|| bad "the walk could climb past /usr/lib and share too much"
grep -q "parent -- this is what matters" "$helper_src" \
&& ok "diagnostics show the parent dir for 'cannot remove/create'" \
|| bad "diagnostics omit the directory that actually governs access"
fi
# ---- lfs next / handover to packagemanager --------------------------------- #
# lfs-helper is a bash stand-in only because there is no Python in the chroot
# until chapter 8 builds it. Once it exists, the real tooling takes over.
python3 - "$LFS_TOOL" <<'PY9'
import sys, importlib.machinery as m
lfs = m.SourceFileLoader('lfs', sys.argv[1]).load_module()
missing = [n for n in ("cmd_next", "_next_steps", "cmd_bs_install_tools",
"_chroot_has_python") if not hasattr(lfs, n)]
if missing:
print(" FAIL lfs is missing: %s" % ", ".join(missing)); sys.exit(1)
print(" PASS 'lfs next' and the packagemanager handover exist")
PY9
_count_rc $?
# ---- bootloader safety ----------------------------------------------------- #
# The book runs `grub-install /dev/sda`, which writes a disk's boot sector. On
# a machine with an existing boot partition that is destructive, so grub is only
# generated when explicitly chosen.
#
# The default is NONE -- it used to be rEFInd, on the reasoning that rEFInd only
# ever adds files to an already-mounted ESP. True, but it still stopped a
# finished build to ask about a bootloader on a machine that already had one.
# rEFInd remains the safe CHOICE; it is just no longer made for you. When it is
# chosen, the script must still be non-destructive.
python3 - "$LFS_TOOL" <<'PY10'
import sys, importlib.machinery as m
lfs = m.SourceFileLoader('lfs', sys.argv[1]).load_module()
if lfs.bootloader() != "none":
print(" FAIL default bootloader is %r, expected none" % lfs.bootloader())
sys.exit(1)
script = lfs._refind_script()
bad = [c for c in ("grub-install /dev/", "mkfs", "sgdisk", "parted", "of=/dev/")
if c in script.replace("### grub-install /dev/sda", "")]
if bad:
print(" FAIL rEFInd script contains destructive commands: %s" % bad)
sys.exit(1)
for guard in ("nothing is mounted there", "not FAT",
"REFIND_OVERWRITE", "does not exist"):
if guard not in script:
print(" FAIL rEFInd script lacks the %r guard" % guard); sys.exit(1)
print(" PASS rEFInd is the default and its script cannot touch a raw device")
PY10
_count_rc $?
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
grep -q 'find "\$dir" -mindepth 1 -type d -user root' "$helper_src" \
&& bad "a root-owned subtree is still taken in one pass" \
|| ok "root-owned adoption is limited to the directory asked for"
fi
# ---- a package's private tree belongs to the package ----------------------- #
# /usr/lib/python3.13 is python's own tree, not shared infrastructure. Chapter
# 7 built python-tmp as root, so the tree is left root-owned; it must be ADOPTED
# by python, not turned into an install directory. When another package later
# installs modules inside it, that is what collector groups are for.
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
# the directory itself is still adopted -- just not its subtree
grep -q "owned by '\$user' (was root, from the temporary system)" "$helper_src" \
&& ok "a root-owned leftover directory is adopted by its package" \
|| bad "root-owned directories are not adopted at all"
grep -q "shared install directory" "$helper_src" \
&& ok "only real install dirs get the install group" \
|| bad "install-group handling missing"
# the two must be distinct branches: install dirs stay root, private trees don't
grep -q 'if is_install_dir "\$dir"; then' "$helper_src" \
&& ok "install dirs and private trees are handled separately" \
|| bad "install dirs and private trees are conflated"
fi
# ---- pip packages and duplicate grants ------------------------------------- #
# Many failing paths walk up to the SAME tree top, so granting per-path repeats
# the identical grant. And `pip3 install` writes into site-packages: if that is
# not writable up front, pip can quietly install into a per-user location where
# nothing else can import it -- the next package then fails with
# BackendUnavailable: Cannot import 'flit_core.buildapi'
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
grep -q "^collector_tree_top()" "$helper_src" \
&& ok "the tree top is computed once and grants are de-duplicated" \
|| bad "the same directory can be granted repeatedly"
grep -q "^pregrant_python_sitedirs()" "$helper_src" \
&& ok "pip packages get site-packages access before they run" \
|| bad "pip packages only get access after failing"
fi
# ---- a build that installs nothing must not be called successful ----------- #
# pip 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 "BackendUnavailable: Cannot import 'flit_core.buildapi'".
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
grep -q "reported success but installed NO files" "$helper_src" \
&& ok "a build that installs no files is not marked as built" \
|| bad "an empty install is silently accepted"
# grep -c prints 0 and exits non-zero, so `|| echo 0` yields "0\n0"
grep -q "^count_lines()" "$helper_src" \
&& ok "line counts go through one safe helper" \
|| bad "line counts can yield \"0\\n0\" and break numeric tests"
fi
# ---- pip must install system-wide, not into ~/.local ----------------------- #
# When site-packages is not writable, pip installs into the package user's
# ~/.local and afterwards reports "Requirement already satisfied" from there --
# so it never installs system-wide, and only that one user can import it.
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
grep -q "PIP_USER=0 PYTHONNOUSERSITE=1" "$helper_src" \
&& ok "pip is forced to install system-wide" \
|| bad "pip may still install into the package user's ~/.local"
grep -q "removing a stale per-user install" "$helper_src" \
&& ok "a stale ~/.local install is cleared before a pip build" \
|| bad "a stale ~/.local install would keep shadowing the system one"
grep -q "^cmd_find_user_site()" "$helper_src" \
&& ok "existing per-user installs can be found and removed" \
|| bad "no way to find existing ~/.local installs"
fi
# ---- package user ids must not collide ------------------------------------- #
# Hand-picking the next free uid races Shadow's own idea of what is free:
# useradd: UID 10067 is not unique
# Once Shadow exists, let it allocate -- and use -U so the private group gets
# the same id as the user instead of drifting apart.
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
grep -q 'K "UID_MIN=\$PKG_UID_MIN"' "$helper_src" \
&& ok "Shadow allocates package user ids (no hand-picked uid)" \
|| bad "ids are still hand-picked and can collide"
# -U is the point: it creates the private group WITH the user so the ids
# match. The home now comes from pkgusr_home_for (packages and config
# steps live in different roots), so match on -U, not on the path.
grep -q 'useradd -c "package \$name" -d "\$(pkgusr_home_for "\$name")" -U' \
"$helper_src" \
&& ok "the private group is created with the user, so uid == gid" \
|| bad "group and user ids can drift apart"
grep -q 'getent passwd "\$1" >/dev/null 2>&1 && return 0' "$helper_src" \
&& ok "id_taken consults the real database, not just the files" \
|| bad "id_taken only reads the files and can miss a used id"
fi
# ---- test scaffolding belongs with the tests -------------------------------- #
# Coreutils sets up a throwaway user/group so its suite can run as non-root:
# groupadd -g 102 dummy -U tester ; chown -R tester . ; ... ; groupdel dummy
# Left in the build phase those run as the package user and die with
# groupadd: Permission denied.
python3 - "$LFS_TOOL" <<'PY10'
import sys, importlib.machinery as m
lfs = m.SourceFileLoader('lfs', sys.argv[1]).load_module()
cases = [("groupadd -g 102 dummy -U tester", True),
("chown -R tester .", True),
("groupdel dummy", True),
('su tester -c "PATH=$PATH make -k check"', True),
("make install", False),
("groupadd install", False), # the install group is not a test
("chown -R root:root /usr/lib/gcc", False),
("mv -v /usr/bin/chroot /usr/sbin", False)]
bad = 0
for c, want in cases:
got = lfs._is_test_block(c)
if got != want:
print(" FAIL %r classified test=%s" % (c, got)); bad += 1
if bad: sys.exit(1)
print(" PASS test scaffolding travels with the tests, real steps do not")
PY10
_count_rc $?
# ---- book placeholders must be filled in ----------------------------------- #
# The book writes PAGE=<paper_size> ./configure for a human to edit. A shell
# reads "<paper_size>" as an input redirect:
# line 4: paper_size: No such file or directory
python3 - "$LFS_TOOL" <<'PY11'
import sys, importlib.machinery as m
lfs = m.SourceFileLoader('lfs', sys.argv[1]).load_module()
out, left = lfs._fill_placeholders("PAGE=<paper_size> ./configure --prefix=/usr", "groff")
if "<paper_size>" in out or left:
print(" FAIL <paper_size> not substituted: %r" % out); sys.exit(1)
# an unknown placeholder must be REPORTED, not silently left in
out2, left2 = lfs._fill_placeholders("echo <your name here>", "x")
if "your name here" not in left2:
print(" FAIL an unknown placeholder was not reported"); sys.exit(1)
print(" PASS book placeholders are substituted, unknown ones reported")
PY11
_count_rc $?
# ---- packages must not re-mode things they do not own ---------------------- #
# iproute2 does `install -m 0755 -d /usr/sbin` on a directory that already
# exists and belongs to root:install:
# install: cannot change permissions of '/usr/sbin': Operation not permitted
# The directory is already correct, so there is nothing to do -- and a package
# should never re-mode a directory it merely installs into.
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
wt="$T/wrap2"; mkdir -p "$wt/existing" "$wt/mine"
( STATE="$wt"; WRAPPERS="$wt/wrappers"
eval "$(sed -n '/^make_wrappers() {/,/^}/p' "$helper_src")"
make_wrappers )
W="$wt/wrappers"
before="$(stat -c %A "$wt/existing")"
"$W/install" -m 0700 -d "$wt/existing" >/dev/null 2>&1
[ "$(stat -c %A "$wt/existing")" = "$before" ] \
&& ok "install -d leaves an existing directory's mode alone" \
|| bad "install -d re-moded an existing directory"
"$W/install" -m 0755 -d "$wt/mine/fresh" >/dev/null 2>&1
[ -d "$wt/mine/fresh" ] \
&& ok "install -d still creates a missing directory" \
|| bad "install -d no longer creates directories"
# chmod on our own file must still work
echo x > "$wt/mine/f"; "$W/chmod" 600 "$wt/mine/f" >/dev/null 2>&1
[ "$(stat -c %a "$wt/mine/f")" = "600" ] \
&& ok "chmod still works on files we own" \
|| bad "chmod wrapper broke a legitimate chmod"
fi
# ---- a failure that needs the USER is not a permission problem -------------- #
# rEFInd stops when the ESP is not mounted. Answering that by handing out
# collector groups chowned /boot to a package user and burned the whole retry
# budget doing it.
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
grep -q "_perm_ish" "$helper_src" \
&& ok "permission fixes only run on permission-shaped failures" \
|| bad "any failure can trigger permission changes"
grep -q "the same directories keep coming up" "$helper_src" \
&& ok "a retry that makes no progress stops early" \
|| bad "the retry budget can be burned on the same directory"
grep -q 'if \[ "\$rc" = 3 \]' "$helper_src" \
&& ok "exit 3 is treated as 'needs you', not a build error" \
|| bad "a needs-user exit is reported as a build failure"
# /boot and the ESP must never be adoptable by a package
( SNAP_ROOT=/
eval "$(sed -n '/^install_dirs_list() {/,/^}/p' "$helper_src")"
eval "$(sed -n '/^install_dir_patterns() {/,/^}/p' "$helper_src")"
eval "$(sed -n '/^matches_install_pattern() {/,/^}/p' "$helper_src")"
eval "$(sed -n '/^is_install_dir() {/,/^}/p' "$helper_src")"
INSTALLDIRS=""; is_install_dir /boot && is_install_dir /boot/efi ) \
&& ok "/boot and the ESP can never be adopted by a package" \
|| bad "/boot could be chowned to a package user"
fi
# ---- the rEFInd script must survive `set -e` -------------------------------- #
# `found="$(blkid ...)"` aborts the whole script under set -e when blkid finds
# nothing, so the guidance was cut off mid-sentence and the exit code was wrong.
python3 - "$LFS_TOOL" <<'PY12'
import sys, importlib.machinery as m
lfs = m.SourceFileLoader('lfs', sys.argv[1]).load_module()
sc = lfs._refind_script()
problems = []
if "blkid -t TYPE=vfat -o device 2>/dev/null || true" not in sc:
problems.append("blkid assignment can abort under set -e")
if "|| true)\"" not in sc:
problems.append("lsblk assignment can abort under set -e")
if "exit 3" not in sc:
problems.append("no needs-you exit code")
if "lfs-helper done refind" not in sc:
problems.append("no way to skip the bootloader step")
if problems:
for p in problems: print(" FAIL %s" % p)
sys.exit(1)
print(" PASS rEFInd guidance survives set -e and offers a skip")
PY12
_count_rc $?
# ---- an existing rEFInd gets an entry, not a reinstall ---------------------- #
# If the machine already boots with rEFInd, reinstalling it is wrong; what is
# needed is a menu entry so the firmware can boot LFS. And "deliberately did
# nothing" must not be reported as a suspicious empty install.
python3 - "$LFS_TOOL" <<'PY13'
import sys, importlib.machinery as m
lfs = m.SourceFileLoader('lfs', sys.argv[1]).load_module()
sc = lfs._refind_script()
problems = []
if "add_lfs_entry" not in sc: problems.append("no LFS menu entry is added")
if "bak=\"$conf.bak-" not in sc: problems.append("the config is not backed up first")
if "exit 4" not in sc: problems.append("no 'nothing to do' exit code")
if "add_lfs_entry \"$conf\" || rc_entry=$?" not in sc:
problems.append("a non-zero return would abort under set -e")
if problems:
for p in problems: print(" FAIL %s" % p)
sys.exit(1)
print(" PASS an existing rEFInd gets an LFS entry, with a backup")
PY13
_count_rc $?
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
grep -q '_nothing_to_do' "$helper_src" \
&& ok "a deliberate no-op is not flagged as an empty install" \
|| bad "'nothing to do' would be reported as a silent failure"
fi
# ---- chapter 9: system configuration --------------------------------------- #
# Config file creation, not package builds -- generated as plain root scripts
# and ordered after the packages, before the bootloader.
python3 - "$LFS_TOOL" <<'PY14'
import sys, importlib.machinery as m
lfs = m.SourceFileLoader('lfs', sys.argv[1]).load_module()
names = [n for n, _s in lfs.CHROOT_CONFIG_STEPS_9]
need = ["cfg_hostname", "cfg_hosts", "cfg_clock", "cfg_inputrc", "cfg_network"]
missing = [n for n in need if n not in names]
if missing:
print(" FAIL chapter 9 is missing: %s" % ", ".join(missing)); sys.exit(1)
# writing a config file installs nothing to compile: they must be root steps
print(" PASS chapter 9 configuration steps are defined")
PY14
_count_rc $?
# rEFInd writes only to the ESP, so tracking zero files is expected, not a fault
python3 - "$LFS_TOOL" <<'PY15'
import sys, importlib.machinery as m
lfs = m.SourceFileLoader('lfs', sys.argv[1]).load_module()
sc = lfs._refind_script()
if not sc.rstrip().endswith("exit 4"):
print(" FAIL rEFInd does not signal 'nothing installed into the tree'")
sys.exit(1)
print(" PASS rEFInd reports success without tracking files in the tree")
PY15
_count_rc $?
# ---- a "configuration" section that is really a package -------------------- #
# 9.2 LFS-Bootscripts-20250827 is a tarball to unpack and install, not a config
# file to write. Generated as a plain script it runs `make install` with
# nothing unpacked:
# make: *** No rule to make target 'install'. Stop.
python3 - "$LFS_TOOL" "$LFS_BOOK" <<'PY16'
import sys, importlib.machinery as m
lfs = m.SourceFileLoader('lfs', sys.argv[1]).load_module()
# a versioned title means a package; a plain one means a config step
pv = lfs._title_pkgver("9.2. LFS-Bootscripts-20250827")
if not pv or not any(c.isdigit() for c in pv):
print(" FAIL a versioned config section is not recognised as a package")
sys.exit(1)
glob = lfs._pkg_glob_for(pv, "cfg_bootscripts")
import fnmatch
if not any(fnmatch.fnmatch("lfs-bootscripts-20250827.tar.xz", p) for p in glob.split()):
print(" FAIL the bootscripts tarball would not be found"); sys.exit(1)
for plain in ("9.5.3. Configuring the System Hostname",
"9.8. Creating the /etc/inputrc File"):
p = lfs._title_pkgver(plain)
if p and any(c.isdigit() for c in p):
print(" FAIL %r was mistaken for a package" % plain); sys.exit(1)
print(" PASS versioned config sections build as packages, the rest do not")
PY16
_count_rc $?
# ---- config steps that rewrite existing files ------------------------------- #
# /etc/hosts already exists (7.6 created it), so rewriting it adds no NEW file
# and the step looked like it did nothing. What a build TOUCHED must count too.
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
grep -q "config step that rewrites /etc/hosts" "$helper_src" \
&& ok "a step that rewrites an existing file counts as work done" \
|| bad "rewriting an existing file still looks like an empty install"
fi
# ---- locale placeholders ---------------------------------------------------- #
# "LC_ALL=<locale name>" is an input redirect to bash:
# line 13: locale: No such file or directory
python3 - "$LFS_TOOL" <<'PY17'
import sys, importlib.machinery as m
lfs = m.SourceFileLoader('lfs', sys.argv[1]).load_module()
out, left = lfs._fill_placeholders("LC_ALL=<locale name> locale charmap", "cfg_locale")
if "<locale name>" in out or left:
print(" FAIL <locale name> not substituted: %r" % out); sys.exit(1)
out2, left2 = lfs._fill_placeholders("echo <charmap>", "x")
if "<charmap>" in out2:
print(" FAIL <charmap> not substituted"); sys.exit(1)
# a script whose placeholder cannot be filled must refuse to run
sc = lfs._plain_root_script("demo", "sid", "9.x Demo", ["echo <something unknown>"])
if "needs a decision from you" not in sc or "exit 3" not in sc:
print(" FAIL an unfillable placeholder does not stop the script"); sys.exit(1)
# ...and one that CAN be filled must not carry a guard
sc2 = lfs._plain_root_script("h", "sid", "9.5.3 Hostname", ['echo "<lfs>" > /etc/hostname'])
if "needs a decision from you" in sc2:
print(" FAIL a filled placeholder still triggered the guard"); sys.exit(1)
print(" PASS locale placeholders fill from config; unfillable ones stop the step")
PY17
_count_rc $?
# ---- 'next' must know where the build actually is --------------------------- #
# The crosschain progress file is CLEARED when the chain completes, so an empty
# one means either "not started" or "all done". Reporting 0/22 for a finished
# toolchain sends you back to the beginning.
python3 - "$LFS_TOOL" <<'PY18'
import sys, importlib.machinery as m
lfs = m.SourceFileLoader('lfs', sys.argv[1]).load_module()
for fn in ("_crosschain_looks_done", "_chroot_progress", "_chroot_steporder"):
if not hasattr(lfs, fn):
print(" FAIL lfs is missing %s" % fn); sys.exit(1)
print(" PASS build state is inferred from the tree, not just a progress file")
PY18
_count_rc $?
# ---- collector groups can be carried to the next build ---------------------- #
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
grep -q "^cmd_export_groups()" "$helper_src" && \
grep -q "^cmd_import_groups()" "$helper_src" \
&& ok "collector-group decisions can be exported and imported" \
|| bad "no export/import for collector groups"
grep -q "COLLECTOR_MAP" "$helper_src" \
&& ok "a decided directory is not asked about twice" \
|| bad "the same directory would be asked about again"
fi
# ---- placeholders come in more shapes than lowercase words ------------------ #
# "export LANG=<ll>_<CC>.<charmap><@modifiers>" written literally into
# /etc/profile breaks every login shell:
# /etc/profile: line 10: syntax error near unexpected token `newline'
# ...but <string.h> and an email in a comment are NOT placeholders.
python3 - "$LFS_TOOL" <<'PY19'
import sys, importlib.machinery as m
lfs = m.SourceFileLoader('lfs', sys.argv[1]).load_module()
out, left = lfs._fill_placeholders(
" export LANG=<ll>_<CC>.<charmap><@modifiers>\n"
"127.0.1.1 <FQDN> <HOSTNAME>\n"
"sed '/unistd.h/i #include <string.h>'\n"
"# maintained by <roryo@roryo.dynup.net>\n", "x")
bad = []
if "<ll>" in out or "<CC>" in out or "<@modifiers>" in out:
bad.append("the LANG construct was not replaced")
if "<FQDN>" in out or "<HOSTNAME>" in out:
bad.append("hostname placeholders were not replaced")
if "<string.h>" not in out:
bad.append("a C include was mangled")
if "string.h" in left or any("@" in x for x in left):
bad.append("non-placeholders were reported: %s" % left)
if bad:
for b in bad: print(" FAIL %s" % b)
sys.exit(1)
print(" PASS placeholders are filled; includes and emails are left alone")
PY19
_count_rc $?
python3 - "$LFS_TOOL" <<'PY20'
import sys, importlib.machinery as m
lfs = m.SourceFileLoader('lfs', sys.argv[1]).load_module()
keys = [k for k, _d in lfs.CONFIG_KEYS]
if "collector_import_file" not in keys or not hasattr(lfs, "_install_collector_groups"):
print(" FAIL no way to supply a collector-group file for a rebuild")
sys.exit(1)
print(" PASS a collector-group export can be supplied via config")
PY20
_count_rc $?
# ---- SNAP_ROOT="/" must not break file tracking ----------------------------- #
# Inside the chroot SNAP_ROOT is "/", and "${SNAP_ROOT%/}" trims that to the
# empty string. `find ""` fails silently, so NOTHING was ever seen as touched
# and every config step reported "installed NO files".
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
grep -q 'find "\${r:-/}"' "$helper_src" \
&& ok "file tracking works when the tracked root is /" \
|| bad "find would be given an empty path inside the chroot"
grep -q 'find "\${SNAP_ROOT:-/}"' "$helper_src" \
&& ok "snapshots work when the tracked root is /" \
|| bad "snapshot find would be given an empty path"
grep -q "filesystem is the authority" "$helper_src" \
&& ok "the export lists directories that really carry a group" \
|| bad "the export only lists remembered decisions"
fi
python3 - "$LFS_TOOL" <<'PY21'
import sys, importlib.machinery as m
lfs = m.SourceFileLoader('lfs', sys.argv[1]).load_module()
if not hasattr(lfs, "network_mode") or lfs.network_mode() != "dhcp":
print(" FAIL network does not default to dhcp"); sys.exit(1)
print(" PASS network defaults to dhcp (no static config generated)")
PY21
_count_rc $?
# ---- the tooling's own Python dependencies ---------------------------------- #
# base LFS ships neither requests nor beautifulsoup4, so 'lfs' and 'blfs' fail
# on import the moment they run in the chroot:
# error: this needs BeautifulSoup4 (pip install beautifulsoup4)
# ModuleNotFoundError: No module named 'requests'
python3 - "$LFS_TOOL" <<'PY22'
import sys, importlib.machinery as m
lfs = m.SourceFileLoader('lfs', sys.argv[1]).load_module()
if not hasattr(lfs, "_chroot_missing_tool_deps"):
print(" FAIL nothing checks for the tooling's Python modules"); sys.exit(1)
mods = [mod for mod, _pkg in lfs.TOOL_PY_DEPS]
if "bs4" not in mods or "requests" not in mods:
print(" FAIL the dependency list is incomplete: %s" % mods); sys.exit(1)
print(" PASS the tooling's Python modules are checked before handover")
PY22
_count_rc $?
# ---- the tools must survive missing optional modules ------------------------ #
# base LFS ships neither requests nor beautifulsoup4. Failing at IMPORT time
# makes the tools useless in exactly the situation they are needed:
# ModuleNotFoundError: No module named 'requests'
# A cached book can be parsed offline, so only commands that fetch should care.
tmpd="$T/nomod"; mkdir -p "$tmpd"
cat > "$tmpd/sitecustomize.py" <<'EOF'
import sys
class _B:
def find_module(self, name, path=None):
return self if name in ('requests', 'bs4') else None
def load_module(self, name):
raise ImportError('missing')
sys.meta_path.insert(0, _B())
EOF
bad=0
for tool in blfs packagemanager; do
t="$(dirname "$LFS_TOOL")/$tool"
[ -f "$t" ] || continue
out="$(PYTHONPATH="$tmpd" python3 "$t" --help 2>&1)" || bad=1
case "$out" in *Traceback*) bad=1 ;; esac
done
[ "$bad" = 0 ] \
&& ok "blfs and packagemanager start without requests/beautifulsoup4" \
|| bad "a tool still fails at import when a module is missing"
# and there is a package-user-aware way to install those modules
pmt="$(dirname "$LFS_TOOL")/packagemanager"
if [ -f "$pmt" ]; then
grep -q "^def cmd_pip(" "$pmt" \
&& ok "python modules can be installed as package users" \
|| bad "no package-user-aware pip"
grep -q "PIP_USER=0" "$pmt" \
&& ok "pip is kept out of the package user's ~/.local" \
|| bad "pip could install into ~/.local again"
fi
# ---- package users without the hint's helper -------------------------------- #
# add_package_user comes from the package-users hint's helper tarball, which a
# fresh LFS system does not have. Without a fallback nothing can be installed
# as a package user at all -- and a missing command crashed with a traceback:
# FileNotFoundError: [Errno 2] No such file or directory: 'add_package_user'
pmt="$(dirname "$LFS_TOOL")/packagemanager"
if [ -f "$pmt" ]; then
grep -q "^def create_package_user(" "$pmt" \
&& ok "package users can be created without add_package_user" \
|| bad "package user creation depends on the hint's helper script"
grep -q "except FileNotFoundError:" "$pmt" \
&& ok "a missing command is reported, not raised as a traceback" \
|| bad "a missing command still crashes"
grep -q "_SBIN_PATH" "$pmt" \
&& ok "useradd/groupadd are found in /usr/sbin" \
|| bad "useradd would be missed when /usr/sbin is not on PATH"
fi
# ---- DNS inside the chroot -------------------------------------------------- #
# Routing works in the chroot (a bare IP pings fine) but without
# /etc/resolv.conf nothing resolves, and every download fails with
# Temporary failure in name resolution
python3 - "$LFS_TOOL" <<'PY23'
import sys, os, tempfile, importlib.machinery as m
lfs = m.SourceFileLoader('lfs', sys.argv[1]).load_module()
if not hasattr(lfs, "_copy_resolv_conf"):
print(" FAIL nothing gives the chroot a resolver config"); sys.exit(1)
d = tempfile.mkdtemp(); os.makedirs(os.path.join(d, "etc"))
lfs._copy_resolv_conf(d)
p = os.path.join(d, "etc", "resolv.conf")
if os.path.isfile("/etc/resolv.conf") and not os.path.isfile(p):
print(" FAIL resolv.conf was not copied"); sys.exit(1)
# an existing, usable config must not be clobbered
open(p, "w").write("nameserver 9.9.9.9\n")
lfs._copy_resolv_conf(d)
if "9.9.9.9" not in open(p).read():
print(" FAIL an existing resolv.conf was overwritten"); sys.exit(1)
# ...but the book's PLACEHOLDER file is not a usable config
open(p, "w").write("domain <Your Domain Name>\n"
"nameserver <IP address of your primary nameserver>\n")
lfs._copy_resolv_conf(d)
if "<IP address" in open(p).read():
print(" FAIL a placeholder resolv.conf was treated as usable"); sys.exit(1)
print(" PASS the chroot gets DNS; real configs kept, placeholders replaced")
PY23
_count_rc $?
# ---- package users need the shared environment ------------------------------ #
# The hint's scheme gives every package user symlinks into /etc/pkgusr:
# .bash_profile -> /etc/pkgusr/bash_profile
# .bashrc -> /etc/pkgusr/bashrc
# build -> /etc/pkgusr/build
# Creating a bare account leaves it unable to build anything.
pmt="$(dirname "$LFS_TOOL")/packagemanager"
if [ -f "$pmt" ]; then
grep -q "^def init_package_user_home(" "$pmt" \
&& ok "packagemanager initialises a package user's environment" \
|| bad "packagemanager creates bare accounts"
grep -q "^def ensure_pkgusr_etc(" "$pmt" \
&& ok "the shared /etc/pkgusr environment is created when missing" \
|| bad "no shared package-user environment"
fi
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
grep -q "^init_package_user_home()" "$helper_src" \
&& ok "lfs-helper initialises a package user's environment too" \
|| bad "lfs-helper creates bare accounts"
fi
# ---- installing and updating LFS packages ----------------------------------- #
python3 - "$LFS_TOOL" <<'PY24'
import sys, importlib.machinery as m
lfs = m.SourceFileLoader('lfs', sys.argv[1]).load_module()
for fn in ("cmd_install", "cmd_update", "_find_lfs_package"):
if not hasattr(lfs, fn):
print(" FAIL lfs is missing %s" % fn); sys.exit(1)
src = open(sys.argv[1]).read()
if "Run the configure phase too?" not in src:
print(" FAIL reinstall does not ask about the configure phase"); sys.exit(1)
print(" PASS lfs can install/update, and asks before re-running configure")
PY24
_count_rc $?
# ---- BLFS is preferred when both books have a package ----------------------- #
# BLFS' script carries the dependency information and the configure options a
# library build needs; LFS only builds what the base system requires.
python3 - "$LFS_TOOL" <<'PY25'
import sys, importlib.machinery as m
lfs = m.SourceFileLoader('lfs', sys.argv[1]).load_module()
if not hasattr(lfs, "_blfs_knows"):
print(" FAIL nothing checks whether BLFS has the package"); sys.exit(1)
src = open(sys.argv[1]).read()
if "--lfs-book" not in src:
print(" FAIL no way to force the LFS book's version"); sys.exit(1)
# a network failure must explain itself
msg = lfs._explain_fetch_failure(Exception(
"<urlopen error [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed>"))
if "CA" not in msg and "certificates" not in msg:
print(" FAIL an SSL failure is not explained"); sys.exit(1)
msg2 = lfs._explain_fetch_failure(Exception("Temporary failure in name resolution"))
if "DNS" not in msg2:
print(" FAIL a DNS failure is not explained"); sys.exit(1)
print(" PASS BLFS is preferred, and network failures explain themselves")
PY25
_count_rc $?
# ---- help is grouped, not one long list ------------------------------------- #
bad_help=""
for t in lfs blfs packagemanager; do
f="$(dirname "$LFS_TOOL")/$t"
[ -f "$f" ] || continue
grep -q "commands, by what you are doing" "$f" || bad_help="$bad_help $t"
done
h="$(dirname "$LFS_TOOL")/lfs-helper"
[ -f "$h" ] && { grep -q "Follow the build" "$h" || bad_help="$bad_help lfs-helper"; }
[ -z "$bad_help" ] \
&& ok "every tool groups its commands in --help" \
|| bad "ungrouped help in:$bad_help"
# ---- provenance, versions and the helper scripts ---------------------------- #
python3 - "$LFS_TOOL" <<'PY26'
import sys, importlib.machinery as m
lfs = m.SourceFileLoader('lfs', sys.argv[1]).load_module()
problems = []
if not hasattr(lfs, "_install_pkgusr_helpers"):
problems.append("the hint's helper scripts are never copied into the chroot")
# scripts must say which book they came from
body = lfs._crosschain_script_body("d", "sid", "D-1.0", "d.tar.*", ["make"])
if "### book" not in body:
problems.append("generated scripts do not record their book")
# the wrappers must never be taken from /usr/bin
if lfs._find_host_helper("chown", "usr/lib/pkgusr") in ("/usr/bin/chown", "/bin/chown"):
problems.append("the real /usr/bin/chown would be copied as a wrapper")
if problems:
for p in problems: print(" FAIL %s" % p)
sys.exit(1)
print(" PASS helpers copied, wget added from BLFS, scripts record their book")
PY26
_count_rc $?
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
grep -q 'VERSION"' "$helper_src" \
&& ok "the installed version is recorded in the package user's home" \
|| bad "nothing records what version is installed"
grep -q '# book :' "$helper_src" \
&& ok "a build says which book the script came from" \
|| bad "the book behind a script is not shown"
fi
# ---- accounts you can log in with, as a built-in step ----------------------- #
# This was last_build_step.sh: a script created once on the HOST and then owned
# by the person, running last in the chroot. Wrong home twice over -- a fix to
# it reached nobody who already had a copy (that is how
# `chown: invalid user: 'wget:wget'` survived three releases), and setting the
# root password is not a matter of taste: book 8.5 says to do it.
#
# wget and the Python modules moved to `packagemanager setup`, which is where
# bootstrapping the tooling belongs. The root password could NOT move there:
# setup runs on the booted system, and you need to log in to run it.
python3 - "$LFS_TOOL" <<'PY27'
import sys, importlib.machinery as m
lfs = m.SourceFileLoader('lfs', sys.argv[1]).load_module()
src = open(sys.argv[1]).read()
problems = []
# the old machinery must be gone, not merely unused
for gone in ("last_step_path", "ensure_last_step_script", "last_step_sources",
"LAST_STEP_DEFAULT", "_write_last_step_copy"):
if gone in src:
problems.append("%s is still here" % gone)
# and the built-in step takes its place, last in the order
if 'order.append("init-accounts")' not in src:
problems.append("init-accounts is not appended to the step order")
if problems:
for p in problems:
print(" FAIL %s" % p)
sys.exit(1)
print(" PASS the final step is built in, not a script that can go stale")
PY27
_count_rc $?
# lfs-helper must actually perform it, and it must own no files
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
_p=""
_slice_fn "$helper_src" cmd_init_accounts | grep -q 'passwd root' \
|| _p="$_p;init-accounts does not set the root password"
# a non-interactive run must SAY what is missing, never block
_slice_fn "$helper_src" cmd_init_accounts | grep -q '\[ ! -t 0 \]' \
|| _p="$_p;init-accounts would hang on a scripted run"
case "$(_slice_fn "$helper_src" cmd_build)" in
*'"$name" = "init-accounts"'*) ;;
*) _p="$_p;lfs-helper would look for a book script for init-accounts" ;;
esac
case "$(_slice_fn "$helper_src" _is_not_a_package)" in
*init-accounts*) ;;
*) _p="$_p;init-accounts would be given a package user" ;;
esac
grep -q 'init-accounts)' "$helper_src" \
|| _p="$_p;init-accounts cannot be run by hand"
if [ -n "$_p" ]; then
printf '%s\n' "${_p#;}" | tr ';' '\n' | while IFS= read -r m; do
[ -n "$m" ] && bad "$m"
done
else
ok "the root password is set inside the chroot, before you can reboot"
fi
fi
# ---- what the built system needs before it can fetch anything --------------- #
# LFS ships no download tool, so a freshly booted system cannot get the sources
# for its own next package. The tarballs have to be on disk before the chroot
# is sealed, and get-sources is the last moment there is still a network.
#
# The LIST belongs to `packagemanager setup`, which is what installs them. A
# copy in lfs would be a second thing to go stale -- the first version of this
# did exactly that, with a `bootstrap_packages` config key in lfs resolving wget
# separately from the tool that installs it.
python3 - "$LFS_TOOL" <<'PYBOOT'
import re, sys
src = open(sys.argv[1]).read()
problems = []
if not re.search(r"^def bootstrap_sources\(", src, re.M):
problems.append("there is no bootstrap_sources")
m = re.search(r"^def bootstrap_sources\(.*?(?=\n\ndef )", src, re.S | re.M)
if m:
body = m.group(0)
if "packagemanager" not in body or "--sources" not in body:
problems.append("lfs does not ask packagemanager what it needs")
# lfs must not keep its own copy of the list. CODE only -- the docstring
# explains why the list lives elsewhere, and naming it there is the point.
code = "\n".join("" if l.lstrip().startswith("#") else l
for l in body.splitlines())
code = "".join(code.split('"""')[::2])
if "wget" in code:
problems.append("lfs still names the packages itself")
if "bootstrap_packages" in src.replace("`bootstrap_packages`", ""):
problems.append("the old bootstrap_packages config key is still here")
if problems:
for p in problems:
print(" FAIL %s" % p)
sys.exit(1)
print(" PASS lfs asks packagemanager what the new system needs downloaded")
PYBOOT
_count_rc $?
# and packagemanager must answer, without needing a booted system
pm_src="$(dirname "$LFS_TOOL")/packagemanager"
if [ -f "$pm_src" ]; then
_p=""
_out="$(python3 "$pm_src" setup --sources 2>/dev/null)"
_n="$(printf '%s\n' "$_out" | grep -c '^https\?://')"
[ "${_n:-0}" -ge 7 ] \
|| _p="$_p;setup --sources listed only $_n urls (expected the wheels at least)"
# WHEELS, not sdists: a modern sdist needs its build backend and pip cannot
# fetch one with no index -- BackendUnavailable: Cannot import hatchling.build
printf '%s\n' "$_out" | grep -q 'requests-.*\.whl' \
|| _p="$_p;requests is not taken as a wheel"
printf '%s\n' "$_out" | grep -q 'beautifulsoup4-.*\.whl' \
|| _p="$_p;beautifulsoup4 is not taken as a wheel"
# and its dependencies, because it installs with --no-deps
for _d in urllib3 charset_normalizer idna certifi soupsieve; do
printf '%s\n' "$_out" | grep -q "$_d" \
|| _p="$_p;$_d is missing, and --no-deps will not pull it in"
done
# wget comes from the BOOK, not a hardcoded URL
grep -q '_SETUP_BLFS_PACKAGES' "$pm_src" \
|| _p="$_p;wget is not resolved from the BLFS book"
if [ -n "$_p" ]; then
printf '%s\n' "${_p#;}" | tr ';' '\n' | while IFS= read -r m; do
[ -n "$m" ] && bad "$m"
done
else
ok "packagemanager setup declares what it needs, wheels and all"
fi
fi
# ---- setup INSTALLS what it declares (1.11.2) ------------------------------ #
# `--sources` worked and `--run` said "not implemented". Everything below is a
# guarantee the old last_build_step.sh held and dropped when it was removed.
if [ -f "$pm_src" ]; then
python3 - "$pm_src" <<'PYSETUP'
import sys, re, importlib.machinery as m
src = open(sys.argv[1]).read()
pm = m.SourceFileLoader('pm', sys.argv[1]).load_module()
problems = []
def body(fn):
mm = re.search(r"^def %s\(.*?(?=\n\ndef |\n\n# )" % fn, src, re.S | re.M)
return mm.group(0) if mm else ""
# ONE implementation. `pip` and `setup` want the same thing -- a module
# installed as its own package user -- and a second copy of it is how the two
# would drift apart, which is the species of bug this project keeps having.
if not re.search(r"^def pip_install_module\(", src, re.M):
problems.append("there is no shared pip_install_module")
if "pip_install_module" not in body("cmd_pip"):
problems.append("cmd_pip does not use the shared implementation")
if "pip_install_module" not in body("cmd_setup"):
problems.append("setup does not use the shared implementation")
# THE ACCOUNT NAME IS ASKED FOR, NEVER ASSEMBLED.
# create_package_user normalises internally, so an unprefixed name creates
# `p_requests` and then hands `requests` to usermod and su:
# usermod: user 'requests' does not exist
# Same species as `chown: invalid user: 'wget:wget'` at step 104 of 105.
b = body("pip_install_module")
if not re.search(r"pkgusr_name\(\s*_sanitise_user_name", b):
problems.append("the pip account name skips the naming chokepoint")
# offline, and in order. pip cannot reach an index on a fresh system: no CA
# certificates, so a lookup is a hang rather than an error.
sb = body("cmd_setup")
if "no_deps=True" not in sb:
problems.append("setup lets pip resolve dependencies it cannot fetch")
if "find_links" not in sb:
problems.append("setup does not install from the local wheels")
if "--no-index" not in b:
problems.append("pip is still allowed to reach for an index")
# wget comes from the ordinary install path, not a private copy of it
if "install" not in sb or "wget" not in sb:
problems.append("setup no longer installs wget")
if re.search(r"^def _setup_install_wget\(", src, re.M):
wb = body("_setup_install_wget")
if '"install"' not in wb:
problems.append("wget is installed by some path other than `install`")
# IDEMPOTENT. The old final step ran exactly once, at the end of a six-hour
# build; this one has to be safe to run again after fixing whatever failed.
if "dist-info" not in src:
problems.append("setup cannot tell what is already installed")
d, v, fn = pm._wheel_dist(
"https://x/packages/py3/c/charset_normalizer/charset_normalizer-3.4.0-py3-none-any.whl")
if (d, v) != ("charset_normalizer", "3.4.0"):
problems.append("the wheel filename is parsed as %r %r" % (d, v))
if fn != "charset_normalizer-3.4.0-py3-none-any.whl":
problems.append("the wheel filename is not recovered from the url")
if problems:
for x in problems:
print(" FAIL %s" % x)
sys.exit(1)
print(" PASS setup installs wget and the wheels, offline, as package users")
PYSETUP
_count_rc $?
# It must REFUSE rather than half-run when the wheels were never downloaded.
# There is no network on a fresh system, so "try it and see" is a hang.
_sbx="$T/setup-empty"; mkdir -p "$_sbx"
_o="$(LFS_SOURCES_DIR="$_sbx" python3 "$pm_src" setup --run --yes 2>&1)"; _rc=$?
_p=""
[ "$_rc" -ne 0 ] || _p="$_p;setup --run succeeded with no wheels on disk"
printf '%s\n' "$_o" | grep -q 'get-sources' \
|| _p="$_p;it does not say where the wheels were supposed to come from"
printf '%s\n' "$_o" | grep -q 'requests-.*\.whl' \
|| _p="$_p;it does not name the files it could not find"
if [ -n "$_p" ]; then
printf '%s\n' "${_p#;}" | tr ';' '\n' | while IFS= read -r m; do
[ -n "$m" ] && bad "$m"
done
else
ok "setup stops when the wheels are missing, and says which"
fi
fi
# the dry run must print EVERY url, not a sample
grep -q "print the WHOLE list" "$LFS_TOOL" \
&& ok "get-sources lists every URL in a dry run" \
|| bad "the dry run truncates the list"
# ---- `lfs` must know when it is inside the chroot ---------------------------- #
# The build is driven from OUTSIDE. Inside, $LFS does not exist as a path, so
# `lfs build-system next` read empty state and reported "2 of 12 done" for a
# FINISHED build -- sending you back to the beginning.
python3 - "$LFS_TOOL" <<'PY28'
import sys, importlib.machinery as m
lfs = m.SourceFileLoader('lfs', sys.argv[1]).load_module()
problems = []
if not hasattr(lfs, "inside_chroot"):
problems.append("nothing detects the chroot")
for c in ("next", "session", "layout", "crosschain", "chroot"):
if c not in lfs._INSIDE_ONLY_ELSEWHERE:
problems.append("'%s' would still run inside the chroot" % c)
# ...but reading the book and managing packages must still work in there
for c in ("books", "config", "packages", "install", "update"):
if c in lfs._INSIDE_ONLY_ELSEWHERE:
problems.append("'%s' should still work inside the chroot" % c)
if lfs._INSIDE_EQUIVALENT.get("next") != "lfs-helper next":
problems.append("the redirect does not name lfs-helper")
if problems:
for p in problems: print(" FAIL %s" % p)
sys.exit(1)
print(" PASS build commands redirect to lfs-helper inside the chroot")
PY28
_count_rc $?
# The accounts step must be the LAST entry in the order, after the bootloader.
# It sets the root password, so everything that could still fail should have
# failed by the time it runs -- and it is the last thing standing between a
# built system and a reboot you cannot log in after.
python3 - "$LFS_TOOL" <<'PYLAST'
import re, sys
src = open(sys.argv[1]).read()
m = re.search(r'^def cmd_bs_gen_chroot_scripts\(.*?(?=\n\ndef )', src, re.S | re.M)
body = m.group(0) if m else src
appends = re.findall(r'order\.append\("([^"]+)"\)', body)
if not appends:
print(" FAIL nothing is appended to the step order")
sys.exit(1)
if appends[-1] != "init-accounts":
print(" FAIL the last step in the order is %r, not init-accounts" % appends[-1])
sys.exit(1)
print(" PASS the accounts step is ordered last")
PYLAST
_count_rc $?
# ---- the wrappers are for package users, not for root ----------------------- #
# A root step legitimately runs `chown -R wget:wget /sources/wget-1.25.0` to
# hand a source tree to a package user. With the wrappers first in PATH that
# was silently skipped, the tree stayed root-owned, and the build died with
# ./configure: line 4389: config.log: Permission denied
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
grep -q "A ROOT step must not get" "$helper_src" \
&& ok "root steps run without the package-user wrappers" \
|| bad "root steps still get the wrappers and cannot chown"
grep -q "envpass=\"\$envpass PATH='/usr/local/bin" "$helper_src" \
&& ok "the root PATH has no wrapper directory" \
|| bad "root's PATH still starts with the wrappers"
# /usr/src is shared install space; /sources and /build are scratch. Both
# kinds must be beyond a package's reach, but by DIFFERENT mechanisms --
# conflating them is what once made /root root:install drwxrwx---, and what
# made /sources root:install 775, dropping the o+w a build needs.
( SNAP_ROOT=/; INSTALLDIRS=""
eval "$(sed -n '/^install_dirs_list() {/,/^}/p' "$helper_src")"
eval "$(sed -n '/^install_dir_patterns() {/,/^}/p' "$helper_src")"
eval "$(sed -n '/^matches_install_pattern() {/,/^}/p' "$helper_src")"
eval "$(sed -n '/^is_install_dir() {/,/^}/p' "$helper_src")"
eval "$(sed -n '/^never_adopt_list() {/,/^}/p' "$helper_src")"
eval "$(sed -n '/^is_never_adopt() {/,/^}/p' "$helper_src")"
is_install_dir /sources && is_install_dir /build \
&& is_install_dir /usr/src \
&& is_never_adopt /sources && is_never_adopt /build \
&& ! install_dirs_list | grep -qx /sources \
&& ! install_dirs_list | grep -qx /build ) \
&& ok "/sources, /build and /usr/src are never given to a package" \
|| bad "/sources or /build could be handed to a collector group"
fi
# ---- output that is easy to read and to copy -------------------------------- #
python3 - "$LFS_TOOL" <<'PY29'
import sys, importlib.machinery as m
lfs = m.SourceFileLoader('lfs', sys.argv[1]).load_module()
problems = []
for fn in ("warn", "dry_run_note", "next_step"):
if not hasattr(lfs, fn):
problems.append("lfs is missing %s" % fn)
src = open(sys.argv[1]).read()
# no dry-run message may bypass the coloured helper
import re
for m2 in re.finditer(r'sys\.stderr\.write\([^)]*dry run[^)]*\)', src):
problems.append("an uncoloured dry-run message remains: %s" % m2.group(0)[:50])
# the self-removing wget wrapper
# the host's skeleton is preferred over any default we invent
if not hasattr(lfs, "_install_pkgusr_skel"):
problems.append("the host's skel-package is not copied")
if problems:
for p in problems: print(" FAIL %s" % p)
sys.exit(1)
print(" PASS dry runs are coloured, hints are copyable, wget wrapper self-removes")
PY29
_count_rc $?
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
grep -q "^cmd_verify()" "$helper_src" \
&& ok "the tree can be reconciled against what it should be" \
|| bad "no way to repair a tree that has drifted"
fi
# ---- /usr/local/bin must precede /usr/bin ----------------------------------- #
# The temporary no-verify wget wrapper lives in /usr/local/bin. With that
# missing from the package user's PATH the wrapper was never found, and every
# HTTPS download failed with "cannot verify ... certificate".
# Assert the ORDER, not the literal line. This used to grep for
# PATH=/usr/lib/pkgusr:/usr/local/bin
# which is the wrapper path spelled out a third time -- so unifying the two
# wrapper directories broke a test that was really about /usr/local/bin.
bad_path=""
for f in lfs-helper packagemanager; do
t="$(dirname "$LFS_TOOL")/$f"
[ -f "$t" ] || continue
_pl="$(grep -m1 '^PATH=.*:/usr/bin' "$t")"
[ -n "$_pl" ] || { bad_path="$bad_path $f(no-PATH)"; continue; }
# the wrappers first, then /usr/local/bin, then /usr/bin
case "$_pl" in
PATH=*:/usr/local/bin:*) ;;
*) bad_path="$bad_path $f(local-bin-not-after-wrappers)"; continue ;;
esac
_pre="${_pl%%:/usr/bin*}"
case "$_pre" in
*:/usr/local/bin*) ;;
*) bad_path="$bad_path $f(local-bin-after-usr-bin)" ;;
esac
done
[ -z "$bad_path" ] \
&& ok "/usr/local/bin precedes /usr/bin for package users" \
|| bad "a local wrapper would never be found in:$bad_path"
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
grep -q "^cmd_help_for()" "$helper_src" \
&& ok "each command explains itself with -h" \
|| bad "no per-command help"
fi
python3 - "$LFS_TOOL" <<'PY30'
import sys, importlib.machinery as m
lfs = m.SourceFileLoader('lfs', sys.argv[1]).load_module()
if not hasattr(lfs, "cmd_bs_sync_tools"):
print(" FAIL no step copies the package-user environment into the tree")
sys.exit(1)
print(" PASS the package-user environment can be synced into the tree")
PY30
_count_rc $?
# ---- the certificate workaround must undo itself ---------------------------- #
# A $PATH wrapper was never seen: builds run through `su -` with the package
# user's own environment, whose PATH is the host's /etc/pkgusr/bash_profile.
# /etc/wgetrc is read however wget is invoked, so that is where this belongs --
# and it must be removed as soon as certificates exist, or every download on
# the system stays unverified forever.
pmt="$(dirname "$LFS_TOOL")/packagemanager"
if [ -f "$pmt" ]; then
grep -q "^def restore_wget_verification" "$pmt" \
&& ok "certificate checking is restored once CA certs exist" \
|| bad "the temporary workaround would never be undone"
grep -q "^def warn_if_tools_are_stale" "$pmt" \
&& ok "a chroot running older tools than the host is flagged" \
|| bad "stale tools in the chroot go unnoticed"
fi
python3 - "$LFS_TOOL" <<'PY31'
import sys, importlib.machinery as m
lfs = m.SourceFileLoader('lfs', sys.argv[1]).load_module()
if not hasattr(lfs, "_write_tool_stamps"):
print(" FAIL nothing records which tool versions are in the tree")
sys.exit(1)
if not hasattr(lfs, "_sync_report"):
print(" FAIL sync output has no single consistent shape"); sys.exit(1)
print(" PASS tool versions are stamped and sync output is consistent")
PY31
_count_rc $?
# ---- the certificate workaround must apply itself, not wait to be run ------- #
# A system with no CA certificates cannot fetch make-ca -- the very thing that
# would give it certificates. packagemanager has to notice and fix that on its
# own; relying on a build step having run leaves the user stuck.
pmt="$(dirname "$LFS_TOOL")/packagemanager"
if [ -f "$pmt" ]; then
grep -q "^def ensure_wget_workaround" "$pmt" \
&& ok "the certificate workaround applies itself when needed" \
|| bad "the workaround only exists if a build step wrote it"
# both halves must key on the same marker, or one will not find the other
n="$(grep -c '_WGETRC_MARK' "$pmt")"
[ "$n" -ge 3 ] \
&& ok "disable and restore share one marker" \
|| bad "the two halves use different markers"
fi
# ---- shared drop-in directories --------------------------------------------- #
# Many packages write a file straight into /usr/share/zsh/site-functions,
# /usr/share/applications, /usr/lib/girepository-1.0 and the like. Treating
# those as belonging to whichever package created them first means every later
# package fails there -- meson reports it as a Python traceback ending in
# PermissionError: [Errno 13] Permission denied: '.../site-functions/_p11-kit'
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
( SNAP_ROOT=/; INSTALLDIRS=""
eval "$(sed -n '/^install_dirs_list() {/,/^}/p' "$helper_src")"
eval "$(sed -n '/^install_dir_patterns() {/,/^}/p' "$helper_src")"
eval "$(sed -n '/^matches_install_pattern() {/,/^}/p' "$helper_src")"
eval "$(sed -n '/^is_install_dir() {/,/^}/p' "$helper_src")"
for d in /usr/share/zsh/site-functions /usr/share/applications \
/usr/lib/girepository-1.0 /usr/share/glib-2.0/schemas \
/usr/lib/systemd/system /usr/libexec; do
is_install_dir "$d" || exit 1
done
# ...but a package's OWN subdirectory is not shared
for d in /usr/libexec/p11-kit /usr/lib/python3.13/site-packages; do
is_install_dir "$d" && exit 1
done
exit 0 ) \
&& ok "drop-in directories are shared; package subdirs are not" \
|| bad "shared drop-in directories are misclassified"
fi
pmi="$(dirname "$LFS_TOOL")/packagemanager_install"
if [ -f "$pmi" ]; then
grep -q "^diagnose_install_failure()" "$pmi" \
&& ok "a failed install explains itself instead of dumping a traceback" \
|| bad "install failures are reported as raw build output"
fi
# ---- packagemanager must recover like lfs-helper does ----------------------- #
# The same permission problem happens after the base build as during it, and it
# has the same answer: put the package in the group that owns the directory.
# Printing a hint and stopping makes the user do by hand what the tool already
# knows how to do.
pmt="$(dirname "$LFS_TOOL")/packagemanager"
if [ -f "$pmt" ]; then
for fn in auto_grant_from_log run_pm_install is_shared_dir \
warn_if_nothing_installed _paths_from_permission_errors; do
grep -q "^def $fn" "$pmt" || { bad "packagemanager is missing $fn"; break; }
done
grep -q "^def auto_grant_from_log" "$pmt" \
&& grep -q "^def run_pm_install" "$pmt" \
&& grep -q "^def is_shared_dir" "$pmt" \
&& grep -q "^def warn_if_nothing_installed" "$pmt" \
&& ok "packagemanager grants, retries and checks like lfs-helper" \
|| bad "packagemanager still lacks lfs-helper's recovery"
# a directory ALREADY in a collector group: just add the user to it
grep -q "is already group" "$pmt" \
&& ok "an existing collector group is joined, not replaced" \
|| bad "an existing collector group would be overwritten"
# every install path must go through the retrying runner
n_raw="$(grep -c 'subprocess.run(pm + \[\|subprocess.run(find_pm_install()' "$pmt" || true)"
[ "$n_raw" = "0" ] \
&& ok "every install path uses the retrying runner" \
|| bad "$n_raw install call(s) bypass the recovery"
fi
# ---- log scanning must be bounded ------------------------------------------- #
# A build log runs to tens of megabytes. Extracting every path from all of it
# produced 200,000 candidates, each then given a `stat` and an `su ... test -w`
# -- hundreds of thousands of process spawns, which locked the machine up.
# Read only the tail, only lines that mention a permission problem, capped.
pmt="$(dirname "$LFS_TOOL")/packagemanager"
if [ -f "$pmt" ]; then
grep -q "_LOG_TAIL_BYTES" "$pmt" \
&& ok "packagemanager reads only the tail of a build log" \
|| bad "packagemanager scans whole build logs"
grep -q "_PERM_HINT" "$pmt" \
&& ok "a cheap substring filter runs before the regexes" \
|| bad "regexes run over every line of the log"
# and it must still find the error
python3 - "$pmt" <<'PY32'
import sys, os, tempfile, importlib.machinery as m
pm = m.SourceFileLoader('pm', sys.argv[1]).load_module()
d = tempfile.mkdtemp(); p = os.path.join(d, "big.log")
with open(p, "w") as f:
for i in range(50000):
f.write("[%d/4000] Compiling /very/long/path/file%d.c.o\n" % (i, i))
f.write("PermissionError: [Errno 13] Permission denied: '/usr/share/zsh/site-functions/_x'\n")
import time
t0 = time.time(); paths = pm._paths_from_permission_errors(p); dt = time.time() - t0
if paths != ["/usr/share/zsh/site-functions/_x"]:
print(" FAIL wrong paths from a large log: %s" % paths[:3]); sys.exit(1)
if dt > 1.0:
print(" FAIL scanning a large log took %.1fs" % dt); sys.exit(1)
print(" PASS a large log is scanned in %.0f ms and the error is still found"
% (dt * 1000))
PY32
_count_rc $?
fi
for f in lfs-helper packagemanager_install; do
t="$(dirname "$LFS_TOOL")/$f"
[ -f "$t" ] || continue
grep -q "tail -c 524288" "$t" \
&& ok "$f reads only the tail of a log" \
|| bad "$f scans whole logs"
done
# ---- packagemanager fixes permissions itself, like lfs-helper --------------- #
# Installing into a directory another package owns is the NORMAL state of
# affairs under the package-user model, not a mistake to report. The answer is
# always the same -- put both packages in a collector group -- so it should be
# done automatically, not printed as a command for the user to copy.
pmi="$(dirname "$LFS_TOOL")/packagemanager_install"
if [ -f "$pmi" ]; then
grep -q "^auto_grant_from_log()" "$pmi" \
&& ok "packagemanager grants collector access on a permission failure" \
|| bad "packagemanager only reports permission failures"
grep -qE '_round\" -lt (6|20)' "$pmi" \
&& ok "it retries until nothing new needs granting" \
|| bad "one grant round is not enough for a multi-directory install"
# an EXISTING collector group must be joined, not duplicated
grep -q '"\${prefix}"\*)' "$pmi" \
&& ok "an existing collector group is joined rather than replaced" \
|| bad "a second group would be made for an already-shared directory"
grep -q "PM_NO_AUTO_FIX" "$pmi" \
&& ok "the automatic repair can be turned off" \
|| bad "no way to opt out of automatic permission changes"
fi
# ---- a retry wrapper must not call itself ----------------------------------- #
# run_pm_install() gained a retry loop and, in wrapping the old call, ended up
# calling ITSELF instead of the raw runner -- so nothing was ever installed:
# RecursionError: maximum recursion depth exceeded
# Cheap to check, and it aborts before the install is even attempted.
python3 - "$(dirname "$LFS_TOOL")" <<'PY32'
import ast, os, sys
base = sys.argv[1]
problems = []
for f in ("packagemanager", "lfs", "blfs"):
p = os.path.join(base, f)
if not os.path.isfile(p):
continue
tree = ast.parse(open(p).read())
for node in ast.walk(tree):
if not isinstance(node, ast.FunctionDef):
continue
# the FIRST statement calling the function itself is always a bug:
# there is no base case before it
first = node.body[0] if node.body else None
if isinstance(first, ast.Expr) and isinstance(first.value, ast.Call):
call = first.value
elif isinstance(first, ast.Assign) and isinstance(first.value, ast.Call):
call = first.value
else:
continue
if isinstance(call.func, ast.Name) and call.func.id == node.name:
problems.append("%s: %s() calls itself first (line %d)"
% (f, node.name, node.lineno))
if problems:
for p in problems: print(" FAIL %s" % p)
sys.exit(1)
print(" PASS no function recurses before doing any work")
PY32
_count_rc $?
pmt="$(dirname "$LFS_TOOL")/packagemanager"
if [ -f "$pmt" ]; then
grep -q "^def _run_pm_install_once" "$pmt" \
&& ok "the retry wrapper has a raw runner to call" \
|| bad "no raw runner: the wrapper can only call itself"
fi
# ---- never grant from a traceback, never a group for root ------------------- #
# A Python traceback quotes meson's own source and the interpreter's stdlib.
# Treating those as install targets granted a package write access to
# /usr/lib/python3.13 -- and invented a "<prefix>_root" group, which would mean
# "may write anywhere root owns" and undoes the entire scheme.
pmi="$(dirname "$LFS_TOOL")/packagemanager_install"
if [ -f "$pmi" ]; then
grep -q 'owned by root -- NOT shared' "$pmi" \
&& ok "a collector group is never created for root" \
|| bad "a root collector group could still be created"
grep -q 'grep -vE "\^\[\[:space:\]\]\*(File|Traceback' "$pmi" \
&& ok "traceback frames are excluded when looking for failed paths" \
|| bad "paths from tracebacks could be granted"
fi
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
grep -q "not creating a collector group for it" "$helper_src" \
&& ok "lfs-helper also refuses a root collector group" \
|| bad "lfs-helper could create a root collector group"
fi
# only the erroring path is extracted, not every path in the log
tbd="$T/tb"; mkdir -p "$tbd"
cat > "$tbd/log" <<'EOF'
Traceback (most recent call last):
File "/usr/lib/python3.13/site-packages/mesonbuild/minstall.py", line 336, in copy2
shutil.copy2(*args, **kwargs)
PermissionError: [Errno 13] Permission denied: '/usr/share/zsh/site-functions/_x'
EOF
found="$(grep -hE "Permission denied" "$tbd/log" \
| grep -vE "^[[:space:]]*(File|Traceback)" \
| grep -hoE "'[^']+'" | tr -d "'")"
case "$found" in
*/site-functions/_x) ok "only the failing path is taken from a traceback" ;;
*) bad "wrong paths extracted from a traceback: $found" ;;
esac
# ---- blfs offers the same verbs as lfs, and they are real ------------------- #
# The help advertised install/update/fetch/import before the commands existed.
# A help text that lies is worse than none; every advertised verb must parse.
bt="$(dirname "$LFS_TOOL")/blfs"
if [ -f "$bt" ]; then
missing=""
for c in install update fetch import sources search versions deps rdeps \
order script books set-default debug; do
python3 "$bt" "$c" --help >/dev/null 2>&1 || missing="$missing $c"
done
[ -z "$missing" ] \
&& ok "every blfs command in the help actually exists" \
|| bad "blfs help advertises commands that do not parse:$missing"
# install/update delegate -- there is exactly ONE install path
grep -q "^def cmd_install" "$bt" && grep -q "os.execvp" "$bt" \
&& ok "blfs install/update hand over to packagemanager" \
|| bad "blfs would grow a second install path"
# sources must read the key the cache actually writes
grep -q 'data.get("additional_links"' "$bt" \
&& ok "blfs sources reads the cache key that exists" \
|| bad "blfs sources reads a key the cache never writes"
# help texts share one shape across the tools
python3 "$bt" --help 2>&1 | grep -q "Dry run is the default" \
&& ok "blfs help ends with the same footer as the other tools" \
|| bad "blfs help has drifted from the shared shape"
fi
# ---- one command to resume the whole build ---------------------------------- #
# After a host reboot nothing is mounted and the session is gone. `lfs run`
# has to re-mount, continue where it left off, and end inside the chroot --
# without being told where it stands.
python3 - "$LFS_TOOL" <<'PY33'
import sys, importlib.machinery as m
lfs = m.SourceFileLoader('lfs', sys.argv[1]).load_module()
if not hasattr(lfs, "cmd_bs_run"):
print(" FAIL no command runs the procedure end to end"); sys.exit(1)
src = open(sys.argv[1]).read()
for want, why in (
('sub.add_parser("run"', "`lfs run` shorthand is not registered"),
('bsub.add_parser("run"', "`lfs build-system run` is not registered"),
("still pending -- stopping", "no guard against a step that never completes"),
("continues from here", "a failure does not say how to resume"),
):
if want not in src:
print(" FAIL %s" % why); sys.exit(1)
print(" PASS one command resumes the build and ends in the chroot")
PY33
_count_rc $?
# ---- run must WALK you through, not print homework -------------------------- #
# `lfs books` only lists books: running it can never complete the "choose a
# book" step, so run executed it, nothing changed, and the loop guard fired.
# A step that needs a decision has to ask for the decision.
python3 - "$LFS_TOOL" <<'PY34'
import sys, importlib.machinery as m
lfs = m.SourceFileLoader('lfs', sys.argv[1]).load_module()
src = open(sys.argv[1]).read()
problems = []
if not hasattr(lfs, "_run_choose_book"):
problems.append("nothing asks which book to build")
# only commands that change something may run unattended
if 'applies = ("--run" in cmd' not in src:
problems.append("a listing command could still be auto-run forever")
if 'title == "complete the configuration"' not in src:
problems.append("run does not walk through the configuration")
if problems:
for p in problems: print(" FAIL %s" % p)
sys.exit(1)
print(" PASS run asks for decisions instead of printing them")
PY34
_count_rc $?
# ---- nested command blocks, and progress while working ---------------------- #
# extract_commands_phased walked only the DIRECT children of the installation
# div. Packages that build several components (gst-plugins-rs builds
# libgstdav1d and libgstgtk4) put each in a sub-section, so every command sat
# one level down: 5 packages got a completely empty script, 53 lost some
# commands -- and the generated script still passed a bash syntax check.
bt="$(dirname "$LFS_TOOL")/blfs"
if [ -f "$bt" ]; then
grep -q 'inst.find_all(\["pre", "h3", "h4", "div"\])' "$bt" \
&& ok "commands are collected from nested sub-sections too" \
|| bad "only top-level command blocks would be found"
# a stale index must not survive the fix
grep -q "CACHE_VERSION = 4" "$bt" \
&& ok "the cache version was bumped so stale indexes rebuild" \
|| bad "cached books would keep serving the old empty commands"
fi
pmt="$(dirname "$LFS_TOOL")/packagemanager"
if [ -f "$pmt" ]; then
grep -q "_BLFS_ACTIVITY" "$pmt" \
&& ok "long book operations say what they are doing" \
|| bad "install sits silent while indexing the book"
grep -q 'set_status("reading the book' "$pmt" \
&& ok "planning reports progress" \
|| bad "plan building is silent"
fi
# ---- /etc/fstab must be generated ------------------------------------------- #
# Nothing wrote /etc/fstab, so the boot scripts could not remount root
# read-write and the first write -- /run/bootlog -- failed with
# Read-only file system
# taking every later service with it. The device and filesystem are already
# known from `session`, so generate it.
python3 - "$LFS_TOOL" <<'PY35'
import sys, importlib.machinery as m
lfs = m.SourceFileLoader('lfs', sys.argv[1]).load_module()
if not hasattr(lfs, "_fstab_script"):
print(" FAIL nothing generates /etc/fstab"); sys.exit(1)
problems = []
# refuses rather than writing a broken fstab
t = lfs._fstab_script({"lfs_fstype": "ext4"})
if "exit 3" not in t:
problems.append("no root device configured, yet it still writes an fstab")
# journalling checks: btrfs/xfs must NOT be fsck'd at boot
for fs, want in (("ext4", "1 1"), ("btrfs", "0 0"), ("xfs", "0 0")):
row = [l for l in lfs._fstab_script(
{"lfs_device": "/dev/sda2", "lfs_fstype": fs}).splitlines()
if l.startswith("/dev/sda2")]
if not row or not row[0].rstrip().endswith(want):
problems.append("%s got the wrong fsck order" % fs)
# the virtual filesystems the boot scripts need
t = lfs._fstab_script({"lfs_device": "/dev/sda2", "lfs_fstype": "ext4"})
for need in ("/run", "/dev/shm", "/proc", "/sys", "/dev/pts"):
if need not in t:
problems.append("%s missing from fstab" % need)
# never clobber a hand-written fstab
if "was not written by us" not in t:
problems.append("would overwrite an fstab someone else wrote")
if problems:
for p in problems: print(" FAIL %s" % p)
sys.exit(1)
print(" PASS /etc/fstab is generated correctly for the configured disk")
PY35
_count_rc $?
grep -q 'root_steps.append("cfg_fstab")' "$LFS_TOOL" \
&& ok "writing /etc/fstab runs as root" \
|| bad "cfg_fstab would run as a package user and fail"
# ---- a book that does not exist must be refused ----------------------------- #
# set-default warned and then saved anyway, so a typo ("ls") became the
# configured book and every later command failed with a 404 far from the
# mistake. A setting that cannot work should not be accepted.
python3 - "$LFS_TOOL" <<'PY36'
import sys, argparse, importlib.machinery as m, tempfile, os, json
store = tempfile.mkdtemp()
os.environ["LFS_STORE"] = store
os.makedirs(os.path.join(store, "books"), exist_ok=True)
lfs = m.SourceFileLoader('lfs', sys.argv[1]).load_module()
lfs.remote_versions = lambda: ["12.3", "12.4", "12.4-systemd"]
def try_set(v):
try:
lfs.cmd_set_default(argparse.Namespace(version=v))
return True # accepted
except SystemExit:
return False # refused
if try_set("ls"):
print(" FAIL a nonexistent book was accepted as the default"); sys.exit(1)
cfg = {}
p = os.path.join(store, "config.json")
if os.path.isfile(p):
cfg = json.load(open(p))
if cfg.get("default") == "ls":
print(" FAIL the bad book was saved anyway"); sys.exit(1)
print(" PASS a book that does not exist is refused, not saved")
PY36
_count_rc $?
# ---- reset clears settings, never the built system -------------------------- #
python3 - "$LFS_TOOL" <<'PY37'
import sys, importlib.machinery as m
lfs = m.SourceFileLoader('lfs', sys.argv[1]).load_module()
src = open(sys.argv[1]).read()
if not hasattr(lfs, "cmd_reset"):
print(" FAIL no way to clear the configuration"); sys.exit(1)
body = src[src.index("def cmd_reset"):src.index("def cmd_set_default")]
problems = []
if "Type 'reset' to confirm" not in body:
problems.append("no confirmation before deleting")
if "args.run" not in body:
problems.append("reset is not dry-run by default")
# Check what it actually DELETES, not what it mentions: the reassurance text
# names sources and books precisely because it spares them.
targets = body[body.index("targets = ["):body.index("present = [")]
for danger in ("sources", "books", "usr/src"):
if danger in targets:
problems.append("reset lists %s among the things it deletes" % danger)
if "rmtree(lfs_mount)" in body or 'rmtree(lfs)' in body:
problems.append("reset would delete the whole LFS tree")
if problems:
for p in problems: print(" FAIL %s" % p)
sys.exit(1)
print(" PASS reset warns, confirms, and spares the built system")
PY37
_count_rc $?
# ---- the build book and the install book are separate ----------------------- #
# You may build the system from one book and then install packages on it from a
# newer one. Both used to be the same setting, so choosing a newer book for
# packages silently changed what the chroot scripts would be generated from.
python3 - "$LFS_TOOL" <<'PY38'
import sys, os, json, argparse, tempfile, importlib.machinery as m
store = tempfile.mkdtemp()
os.environ["LFS_STORE"] = store
os.makedirs(os.path.join(store, "books"), exist_ok=True)
lfs = m.SourceFileLoader('lfs', sys.argv[1]).load_module()
problems = []
if not hasattr(lfs, "build_version"):
print(" FAIL there is only one book setting"); sys.exit(1)
# unset build_book falls back to default -- nothing changes for anyone
json.dump({"default": "12.4"}, open(os.path.join(store, "config.json"), "w"))
if lfs.build_version() != "12.4":
problems.append("build_book does not fall back to default")
# set, it wins for building only
json.dump({"default": "12.4", "build_book": "12.3"},
open(os.path.join(store, "config.json"), "w"))
if lfs.build_version() != "12.3":
problems.append("build_book is ignored when set")
if lfs.default_version() != "12.4":
problems.append("build_book leaked into the install book")
# --book must still override both
src = open(sys.argv[1]).read()
if 'getattr(args, "book", None)' not in src:
problems.append("--book no longer overrides")
# the flag is set in ONE place so a new subcommand cannot forget
if src.count('setattr(args, "_use_build_book", True)') != 1:
problems.append("the build-book flag is set in more than one place")
if problems:
for p in problems: print(" FAIL %s" % p)
sys.exit(1)
print(" PASS building and installing can use different books")
PY38
_count_rc $?
# ---- every command must run to completion ----------------------------------- #
# `lfs config` crashed with NameError on a variable removed during an edit --
# the last few lines of output were never reached by any test. Compiling is
# not the same as running: exercise the read-only commands end to end.
cft="$T/cfgrun"; mkdir -p "$cft/books"
(
export LFS_STORE="$cft"
python3 "$LFS_TOOL" import "$LFS_BOOK" --version 12.4 --set-default >/dev/null 2>&1
rc=0
for c in "config" "books --local" "next" "packages" "--help" "reset"; do
out="$(python3 "$LFS_TOOL" $c 2>&1)" || true
case "$out" in
*Traceback*|*NameError*|*AttributeError*|*TypeError*)
echo " crashed: lfs $c"
echo "$out" | tail -3 | sed 's/^/ /'
rc=1 ;;
esac
done
exit $rc
) && ok "the read-only commands all run without crashing" \
|| bad "a command crashes partway through its output"
# ---- a fresh tree must be handed to the lfs user ---------------------------- #
# `session` applies book 4.3's chown, but it runs BEFORE `layout` creates the
# directories -- so it chowned nothing, the tree stayed root-owned, and
# crosschain then refused with "chapter 7 has already been prepared" on a tree
# where nothing had been built at all.
python3 - "$LFS_TOOL" <<'PY39'
import sys, os, tempfile, importlib.machinery as m
lfs = m.SourceFileLoader('lfs', sys.argv[1]).load_module()
problems = []
if not hasattr(lfs, "_chown_tree_to_lfs"):
problems.append("nothing hands the build tree to the lfs user")
if not hasattr(lfs, "_chapter56_has_output"):
problems.append("root ownership alone is still treated as 'chapter 7 done'")
else:
# a tree layout just created must NOT look like finished chapter 5-6 work
t = tempfile.mkdtemp()
os.makedirs(os.path.join(t, "usr", "bin"))
os.makedirs(os.path.join(t, "tools", "bin"))
if lfs._chapter56_has_output(t):
problems.append("an empty tree is mistaken for a built one")
# a cross toolchain in tools/bin must be recognised
open(os.path.join(t, "tools", "bin", "x86_64-lfs-linux-gnu-gcc"), "w").close()
if not lfs._chapter56_has_output(t):
problems.append("a real cross toolchain is not recognised")
src = open(sys.argv[1]).read()
# layout must do the chown itself, since it is what creates the dirs as root
lay = src[src.index("def cmd_bs_layout"):src.index("def _chown_tree_to_lfs")]
if "_chown_tree_to_lfs" not in lay:
problems.append("layout does not hand over the tree it just created")
if problems:
for p in problems: print(" FAIL %s" % p)
sys.exit(1)
print(" PASS a fresh tree is given to the lfs user, not mistaken for a built one")
PY39
_count_rc $?
# ---- sync-tools must hand over what it copied ------------------------------- #
# layout chowns the tree to lfs, then sync-tools copies the package-user
# environment in AS ROOT -- re-creating root-owned dirs after the handover:
# ! these build dirs are not owned by the 'lfs' user:
# /mnt/lfs/usr/lib/pkgusr, /mnt/lfs/etc/pkgusr, ...
# Anything copied in before the chroot exists belongs to the lfs user.
python3 - "$LFS_TOOL" <<'PY40'
import sys, importlib.machinery as m
src = open(sys.argv[1]).read()
body = src[src.index("def cmd_bs_sync_tools"):]
body = body[:body.index("\ndef ")]
if "_chown_tree_to_lfs" not in body:
print(" FAIL sync-tools leaves root-owned dirs behind")
sys.exit(1)
print(" PASS sync-tools hands over what it copied in")
PY40
_count_rc $?
# ---- every step announces itself -------------------------------------------- #
# Steps can run for many minutes with no output; without a consistent header
# you cannot tell work from a hang.
python3 - "$LFS_TOOL" <<'PY41'
import sys, importlib.machinery as m
lfs = m.SourceFileLoader('lfs', sys.argv[1]).load_module()
src = open(sys.argv[1]).read()
if not hasattr(lfs, "_run_banner"):
print(" FAIL steps do not announce what is happening"); sys.exit(1)
run = src[src.index("def cmd_bs_run"):]
run = run[:run.index("\ndef ") if "\ndef " in run else len(run)]
# the interactive steps must use the SAME banner, not their own format
if run.count("_run_banner(") < 3:
print(" FAIL some steps announce themselves differently"); sys.exit(1)
body = src[src.index("def _run_banner"):src.index("def cmd_bs_run")]
for need, why in (("NOW:", "does not say what is running"),
("NEXT:", "does not say what comes next"),
("step %d of %d", "does not show progress")):
if need not in body:
print(" FAIL the banner %s" % why); sys.exit(1)
print(" PASS every step shows what is running, and what is next")
PY41
_count_rc $?
# ---- the package-user system is not optional -------------------------------- #
# build-all stopped at the first chapter-8 package and asked the user to run
# init-pkgusr and start again. That is the one thing this toolchain exists to
# do -- and every package installed before it exists is another root-owned tree
# to repair later, so set it up FIRST, automatically.
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
ba="$(sed -n '/^cmd_build_all() {/,/^}/p' "$helper_src")"
case "$ba" in
*"cmd_init_pkgusr --run"*)
ok "build-all sets up the package-user system before building" ;;
*) bad "build-all still stops and asks for init-pkgusr" ;;
esac
# it must be set up BEFORE the loop, not when we trip over a package
before="$(printf '%s' "$ba" | grep -n "cmd_init_pkgusr --run" | head -1 | cut -d: -f1)"
# the BUILD loop ("while read -r s"), not the argument-parsing one
loop="$(printf '%s' "$ba" | grep -n "while read -r s" | head -1 | cut -d: -f1)"
if [ -n "$before" ] && [ -n "$loop" ] && [ "$before" -lt "$loop" ]; then
ok "it happens before the first package, not at the first failure"
else
bad "the package-user system is still set up too late"
fi
# running it twice must not die
grep -q "getent group install >/dev/null 2>&1" "$helper_src" \
&& ok "init-pkgusr can be run again safely" \
|| bad "a second init-pkgusr would abort the build"
fi
# ---- an interrupted toolchain must not count as finished -------------------- #
# A cross-gcc appears in $LFS/tools/bin after step 2 of 22, and that alone was
# taken as "chapters 5-6 done". Cancelling at step 11 therefore let `run`
# hand the tree to root and enter a chroot built on a third of a toolchain.
python3 - "$LFS_TOOL" <<'PY42'
import sys, os, json, tempfile, importlib.machinery as m
lfs = m.SourceFileLoader('lfs', sys.argv[1]).load_module()
names = [n for n, _s, _t in lfs.CROSSCHAIN_STEPS]
problems = []
def tree(done=None, tools_gcc=True, temp_system=False):
t = tempfile.mkdtemp()
os.makedirs(os.path.join(t, "usr", "src", "lfs-pkgusr", "progress"))
os.makedirs(os.path.join(t, "tools", "bin"))
os.makedirs(os.path.join(t, "usr", "bin"))
if tools_gcc:
open(os.path.join(t, "tools", "bin", "x86_64-lfs-linux-gnu-gcc"), "w").close()
if temp_system:
for b in ("gawk", "sed", "tar", "xz"):
open(os.path.join(t, "usr", "bin", b), "w").close()
if done is not None:
json.dump({"book": "12.4", "done": done},
open(os.path.join(t, "usr", "src", "lfs-pkgusr", "progress",
"crosschain-progress.json"), "w"))
return t
# interrupted part-way: NOT done, and the count must be honest
d, n = lfs._crosschain_looks_done(tree(names[:10]))
if d or n != 10:
problems.append("a 10-of-22 toolchain is reported as finished")
# a cross-gcc alone proves nothing
d, _ = lfs._crosschain_looks_done(tree(None))
if d:
problems.append("a cross-gcc alone is taken as a finished toolchain")
# genuinely finished
d, n = lfs._crosschain_looks_done(tree(names))
if not d or n != len(names):
problems.append("a finished toolchain is not recognised")
# progress must NOT be wiped on completion, or done == never-started again
src = open(sys.argv[1]).read()
tail = src[src.index("crosschain: all requested steps done"):]
head = src[:src.index("crosschain: all requested steps done")]
if "_clear_crosschain_progress(lfs)" in head[head.rindex("if args.run:"):]:
problems.append("progress is cleared on completion, losing the record")
# preparing the chroot is a one-way door: it must check first
if "chapters 5-6 are not finished" not in src:
problems.append("chroot prepare does not verify the toolchain first")
if problems:
for p in problems: print(" FAIL %s" % p)
sys.exit(1)
print(" PASS an unfinished toolchain is detected and blocks the chroot")
PY42
_count_rc $?
# ---- snapshots: save the build, put it back --------------------------------- #
# LFS is a long sequence of irreversible steps; without this a bad package
# means starting over. The dangerous parts are what get tested here.
python3 - "$LFS_TOOL" <<'PY43'
import sys, importlib.machinery as m
lfs = m.SourceFileLoader('lfs', sys.argv[1]).load_module()
src = open(sys.argv[1]).read()
if not hasattr(lfs, "cmd_snapshot"):
print(" FAIL no way to save or restore the build"); sys.exit(1)
body = src[src.index("def cmd_snapshot"):src.index("def cmd_reset")]
problems = []
# ownership IS the package-user model: a backup that loses it is worthless
if "--numeric-owner" not in body:
problems.append("uids would be resolved against the host's /etc/passwd")
for need, why in (("--acls", "ACLs"), ("--xattrs", "extended attributes"),
("--sparse", "sparse files")):
if need not in body:
problems.append("%s are not preserved" % why)
# the virtual filesystems are the HOST's -- capturing or writing them is unsafe
if "_SNAP_EXCLUDE" not in src:
problems.append("the virtual filesystems are not excluded")
for d in ("./dev/*", "./proc/*", "./sys/*"):
if d not in src:
problems.append("%s is not excluded from snapshots" % d)
if "could write through them onto this system" not in body:
problems.append("restore does not refuse while /dev /proc are mounted")
# restore is destructive: dry run by default, and confirmed
if "args.run" not in body or "Type the snapshot name to confirm" not in body:
problems.append("restore is not guarded by a dry run and a confirmation")
# /sources must survive a restore -- it is large and was never captured
if 'keep = "sources"' not in body:
problems.append("a restore would delete the downloaded sources")
if problems:
for p in problems: print(" FAIL %s" % p)
sys.exit(1)
print(" PASS snapshots preserve ownership and restore safely")
PY43
_count_rc $?
# ---- already-built packages are adopted before anything new ----------------- #
# Chapters 5-7 run before package users exist, so their files are root-owned
# and the manifests are the only record of who installed what. Nothing ran
# adopt-existing, so the FIRST package user on the system was man-pages -- a
# chapter-8 package -- while bash, coreutils and glibc still owned nothing.
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
# Adoption now lives in establish_ownership, which build-all reaches
# through _ownership_checkpoint. Assert that it still happens and still
# happens early -- not where the code used to sit.
ba="$(_slice_fn "$helper_src" cmd_build_all)"
eo="$(_slice_fn "$helper_src" establish_ownership)"
case "$eo" in
*"_vfy_manifest_ownership 1"*)
ok "already-built packages get their users before new builds" ;;
*) bad "chapter 5-7 packages are never given their package users" ;;
esac
# the checkpoint must be reachable BEFORE the build loop, so a tree that is
# already past 7.6 adopts before it builds anything new
a="$(printf '%s' "$ba" | grep -n "_ownership_checkpoint" | head -1 | cut -d: -f1)"
l="$(printf '%s' "$ba" | grep -n "while read -r s" | head -1 | cut -d: -f1)"
if [ -n "$a" ] && [ -n "$l" ] && [ "$a" -lt "$l" ]; then
ok "adoption happens before the first new package is built"
else
bad "a chapter-8 package could still be the first package user"
fi
# `die` inside cmd_add_user exits the SCRIPT, so `|| continue` never ran:
# one package that could not get a user aborted the whole pass
grep -q '( cmd_add_user "$owner" )' "$helper_src" \
&& ok "one failed adoption no longer aborts the run" \
|| bad "a single failed add-user would kill the whole build"
fi
# ---- a package must not adopt another package's tree ------------------------ #
# infer_dirs walks UP from every installed file, so a package that drops one
# file deep inside another's tree claimed every parent on the way. zstd ended
# up owning /usr/include/c++/15.2.0/bits -- gcc's own C++ headers -- and gcc
# could no longer build:
# fatal error: bits/c++config.h: No such file or directory
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
grep -q "^_dir_contents_foreign()" "$helper_src" \
&& ok "a directory holding another package's files is not adopted" \
|| bad "any package can still claim another package's tree"
# the header trees are shared by nature and must never be adopted at all
( SNAP_ROOT=/; INSTALLDIRS=""
eval "$(sed -n '/^install_dirs_list() {/,/^}/p' "$helper_src")"
eval "$(sed -n '/^install_dir_patterns() {/,/^}/p' "$helper_src")"
eval "$(sed -n '/^matches_install_pattern() {/,/^}/p' "$helper_src")"
eval "$(sed -n '/^is_install_dir() {/,/^}/p' "$helper_src")"
is_install_dir /usr/include || exit 1
exit 0 ) \
&& ok "/usr/include itself is a shared install directory" \
|| bad "/usr/include is not treated as shared"
# The C++ subtree is gcc's, and is protected from adoption by the
# contents check -- NOT by being declared shared, which would hand it to
# every package via the install group.
grep -q "^_dir_contents_foreign()" "$helper_src" \
&& ok "gcc's header subtree is protected by the contents check" \
|| bad "nothing stops another package adopting gcc's headers"
# verify must never take a path another package legitimately owns
mo="$(sed -n '/^_vfy_manifest_ownership() {/,/^}/p' "$helper_src")"
case "$mo" in
*'user_exists "$cur" && continue'*)
ok "a path owned by another real package is left alone" ;;
*) bad "verify could take another package's files" ;;
esac
fi
# ---- "nearly finished" is not finished -------------------------------------- #
# The no-progress-file fallback accepted gawk/sed/tar/xz as proof that
# chapters 5-6 were done -- but those are steps 13-20 of 22. A tree missing
# only gcc-pass2 (the LAST step, which installs the C++ headers) looked
# complete, and chapter 8's gcc then died with
# fatal error: bits/c++config.h: No such file or directory
python3 - "$LFS_TOOL" <<'PY44'
import sys, os, tempfile, importlib.machinery as m
lfs = m.SourceFileLoader('lfs', sys.argv[1]).load_module()
problems = []
def tree(with_cxx_headers):
t = tempfile.mkdtemp()
for d in (("tools", "bin"), ("usr", "bin"),
("usr", "include", "c++", "15.2.0", "bits")):
os.makedirs(os.path.join(t, *d), exist_ok=True)
open(os.path.join(t, "tools", "bin", "x86_64-lfs-linux-gnu-gcc"), "w").close()
for b in ("gawk", "sed", "tar", "xz"):
open(os.path.join(t, "usr", "bin", b), "w").close()
if with_cxx_headers:
p = os.path.join(t, "usr", "include", "c++", "15.2.0",
"x86_64-pc-linux-gnu", "bits")
os.makedirs(p, exist_ok=True)
open(os.path.join(p, "c++config.h"), "w").close()
return t
d, _ = lfs._crosschain_looks_done(tree(False))
if d:
problems.append("a tree missing gcc-pass2 is reported as finished")
d, _ = lfs._crosschain_looks_done(tree(True))
if not d:
problems.append("a genuinely finished toolchain is not recognised")
if not hasattr(lfs, "_crosschain_missing_output"):
problems.append("nothing explains WHAT is missing")
elif "c++config" not in open(sys.argv[1]).read():
problems.append("the C++ headers are not what is checked")
if problems:
for p in problems: print(" FAIL %s" % p)
sys.exit(1)
print(" PASS a toolchain missing only its last step is not called finished")
PY44
_count_rc $?
# ---- a build may not claim files it did not install ------------------------- #
# "touched" is every file with an mtime newer than the build stamp, which is
# wider than "files this package installed". zstd ended up owning
# /usr/include/c++/15.2.0/bits/memoryfwd.h -- gcc's own C++ headers -- and gcc
# could no longer build. And a FAILED gcc 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.
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
grep -q "NEVER take a file that already belongs to another package user" "$helper_src" \
&& ok "a build cannot take a file another package owns" \
|| bad "a package can still steal another package's files"
grep -q "A FAILED build must not take ownership" "$helper_src" \
&& ok "a failed build changes no ownership" \
|| bad "a failed build still claims what it touched"
# the failure check must come BEFORE the chown block
bl="$(sed -n '/^cmd_build() {/,/^}/p' "$helper_src")"
f="$(printf '%s' "$bl" | grep -n 'rc:-0' | head -1 | cut -d: -f1)"
c="$(printf '%s' "$bl" | grep -n 'gave \$n newly installed' | head -1 | cut -d: -f1)"
if [ -n "$f" ] && [ -n "$c" ] && [ "$f" -lt "$c" ]; then
ok "the failure check runs before anything is chowned"
else
bad "ownership changes before the build result is checked"
fi
fi
# ---- the compiler must be checked before chapter 8 -------------------------- #
# A toolchain that cannot link a trivial program produces failures that point
# everywhere except at itself:
# configure: error: cannot compute sizeof (long long)
# fatal error: bits/c++config.h: No such file or directory
# Both mean the same thing, and both are cheap to detect up front.
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
grep -q "^cmd_check_toolchain()" "$helper_src" \
&& ok "there is a toolchain sanity check" \
|| bad "nothing verifies the compiler works"
# C++ specifically -- that is what chapter 8's gcc needs first
grep -q "include <memory>" "$helper_src" \
&& ok "the check compiles C++, not just C" \
|| bad "a missing C++ header set would not be caught"
# and it must report WHERE the headers are, since a triplet mismatch
# looks identical to missing headers
grep -q "dumpmachine" "$helper_src" \
&& ok "it reports the compiler's own target triplet" \
|| bad "a triplet mismatch could not be told from missing headers"
ba="$(sed -n '/^cmd_build_all() {/,/^}/p' "$helper_src")"
case "$ba" in
*cmd_check_toolchain*) ok "build-all checks the compiler before starting" ;;
*) bad "chapter 8 can start on a broken toolchain" ;;
esac
fi
# ---- a header can exist and still be unreachable ---------------------------- #
# If any directory on the path lacks o+x, the compiler gets ENOENT -- "No such
# file or directory" -- not a permission error. A mis-assigned package-user
# directory therefore looks exactly like a missing header, which is how a
# present, world-readable c++config.h still broke the gcc build.
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
grep -q "This header EXISTS but cannot be reached" "$helper_src" \
&& ok "an unreachable header is distinguished from a missing one" \
|| bad "a permission problem still reads as a missing file"
fi
# and prove the failure mode itself: mode 644 file, un-traversable parent
tvd="$T/traverse"
mkdir -p "$tvd/a/b"
# the test dir itself must be traversable, or we would be measuring $T
chmod 755 "$T" "$tvd" 2>/dev/null
echo data > "$tvd/a/b/hdr.h"
chmod 644 "$tvd/a/b/hdr.h"
chmod 640 "$tvd/a"
if id nobody >/dev/null 2>&1; then
if su -s /bin/sh nobody -c "cat '$tvd/a/b/hdr.h'" >/dev/null 2>&1; then
bad "the traversal failure mode did not reproduce"
else
ok "a readable file behind a closed directory is unreachable"
fi
chmod 755 "$tvd/a"
su -s /bin/sh nobody -c "cat '$tvd/a/b/hdr.h'" >/dev/null 2>&1 \
&& ok "...and reachable once the directory allows traversal" \
|| bad "still unreachable after opening the directory"
fi
# ---- existence and permissions are not enough ------------------------------- #
# A header can exist, be world-readable, and sit on a fully traversable path,
# and still not be found -- because the directory holding it is not in the
# compiler's search list at all. Only the compiler can answer that, so the
# check has to ask it rather than inferring from the filesystem.
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
grep -q "include <...> search starts here" "$helper_src" \
&& ok "the check shows the compiler's real include search path" \
|| bad "a search-path mismatch could not be diagnosed"
# all three explanations must be distinguishable from each other
for phrase in "cannot be reached" "not in that list" "OWN target triplet"; do
grep -q "$phrase" "$helper_src" || bad "missing diagnosis: $phrase"
done
ok "missing, unreachable and not-searched are told apart"
fi
# ---- tracing a file back to the step that installed it ---------------------- #
# "which step went wrong" should be answerable from the record, not guessed.
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
grep -q "^cmd_which_package()" "$helper_src" \
&& ok "a file can be traced to the package that installed it" \
|| bad "no way to find out which step installed a file"
wp="$T/whichpkg"; mkdir -p "$wp/.lfs-pkgusr/manifests" "$wp/usr/bin"
printf '/usr/lib/gcc/x86_64-pc-linux-gnu/15.2.0/cc1plus\n' \
> "$wp/.lfs-pkgusr/manifests/gcc.files"
printf '/usr/bin/grep\n' > "$wp/.lfs-pkgusr/manifests/grep.files"
out="$(LFS_PKGUSR_DIR="$wp/.lfs-pkgusr" LFS_SNAP_ROOT="$wp" \
LFS_SRC_ROOT="$wp/usr/src" \
bash "$helper_src" which-package \
/usr/lib/gcc/x86_64-pc-linux-gnu/15.2.0/cc1plus 2>&1)"
case "$out" in
*gcc*) ok "it names the right package for an exact path" ;;
*) bad "an exactly-recorded path was not traced" ;;
esac
case "$out" in
*grep*) bad "it named a package that did not install the file" ;;
*) ok "it does not name unrelated packages" ;;
esac
fi
# ---- identify the compiler before diagnosing it ----------------------------- #
# A compiler searches for headers under the target it was BUILT for, not the
# one the headers are installed under. So --host/--target from its configure
# line is the only thing that explains a triplet mismatch; without it the
# check can only say "they differ", not why.
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
tc="$(sed -n '/^cmd_check_toolchain() {/,/^}/p' "$helper_src")"
case "$tc" in
*"Configured with:"*) ok "the check reports how the compiler was configured" ;;
*) bad "a triplet mismatch cannot be explained, only observed" ;;
esac
for want in "dumpmachine" "command -v c++"; do
case "$tc" in
*"$want"*) ;;
*) bad "the check does not report: $want" ;;
esac
done
ok "it identifies which binary is being used"
# identity must come first: everything else is downstream of it
i="$(printf '%s' "$tc" | grep -n "The compiler:" | head -1 | cut -d: -f1)"
c="$(printf '%s' "$tc" | grep -n "C compiles" | head -1 | cut -d: -f1)"
if [ -n "$i" ] && [ -n "$c" ] && [ "$i" -lt "$c" ]; then
ok "the compiler is identified before it is tested"
else
bad "the check tests before saying what it is testing"
fi
fi
# ---- test commands must not run in the build phase -------------------------- #
# GCC's chapter-8 section ends with test scaffolding:
# make / ulimit / sed ../gcc/testsuite/... / chown tester / su tester /
# ../contrib/test_summary
# Only the chown and su lines were recognised, so ulimit, the testsuite seds
# and test_summary stayed in the BUILD phase. test_summary greps test logs
# that do not exist and exits non-zero, and `set -e` then fails the build --
# reported as "phase 'build' FAILED" with make having succeeded.
python3 - "$LFS_TOOL" "$LFS_BOOK" <<'PY45'
import sys, os, json, tempfile, importlib.machinery as m
store = tempfile.mkdtemp(); os.environ["LFS_STORE"] = store
lfs = m.SourceFileLoader('lfs', sys.argv[1]).load_module()
import argparse
args = argparse.Namespace(book=None, _use_build_book=True)
os.makedirs(os.path.join(store, "books"), exist_ok=True)
import shutil
shutil.copy(sys.argv[2], os.path.join(store, "books",
"LFS-BOOK-12.4-NOCHUNKS.html"))
json.dump({"default": "12.4"}, open(os.path.join(store, "config.json"), "w"))
build, test = None, None
try:
r = lfs._section_script_phased(args, "ch-system-gcc")
build, test = r[0], r[1]
except Exception:
src = open(sys.argv[1]).read()
if "test_summary" not in src or "testsuite" not in src:
print(" FAIL test scaffolding is not recognised at all"); sys.exit(1)
print(" PASS test scaffolding patterns are present"); sys.exit(0)
problems = []
for bad in ("test_summary", "testsuite", "ulimit"):
if build and bad in build:
problems.append("'%s' runs in the BUILD phase" % bad)
if problems:
for p in problems: print(" FAIL %s" % p)
sys.exit(1)
print(" PASS test scaffolding is kept out of the build phase")
PY45
_count_rc $?
# ---- the groups import is asked for with the other session settings --------- #
# It has to be known BEFORE the chroot scripts are generated (that is when the
# file is copied into the tree), so asking for it later is too late.
python3 - "$LFS_TOOL" <<'PY46'
import sys, importlib.machinery as m
lfs = m.SourceFileLoader('lfs', sys.argv[1]).load_module()
keys = [k for k, _d, _v in lfs._SESSION_KEYS]
if "collector_import_file" not in keys:
print(" FAIL the groups export is never asked for"); sys.exit(1)
src = open(sys.argv[1]).read()
if "no such file: %s -- leaving it unset" not in src:
print(" FAIL a nonexistent export path would be accepted"); sys.exit(1)
print(" PASS the groups import is part of the session setup, and validated")
PY46
_count_rc $?
# ---- the compiler check must not block chapter 7 ---------------------------- #
# Requiring a working C++ compiler before ANY step blocked chapter 7 -- which
# builds the temporary tools and needs no compiler at all. build-all then did
# nothing, silently, including the very steps that would have fixed the
# toolchain. The check belongs before the first package that needs it.
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
ba="$(sed -n '/^cmd_build_all() {/,/^}/p' "$helper_src")"
# it must be inside the loop, guarded by "not a root step"
case "$ba" in
*'! is_root_step "$s" && [ "${_tc_checked:-0}" = 0 ]'*)
ok "the compiler is checked before the first package that needs it" ;;
*) bad "the compiler check is not tied to the first non-root step" ;;
esac
# and NOT before the loop, where it would block chapter 7
pre="$(printf '%s' "$ba" | sed -n '1,/while read -r s/p')"
case "$pre" in
*cmd_check_toolchain*) bad "the check still runs before chapter 7" ;;
*) ok "chapter 7 builds without needing a compiler" ;;
esac
# checked once, not once per package
case "$ba" in
*_tc_checked=1*) ok "the check runs once, not per package" ;;
*) bad "the compiler would be re-checked for every package" ;;
esac
fi
# ---- the build stamp must not depend on /tmp -------------------------------- #
# init-dirs is the step that CREATES /tmp (book 7.5), so mktemp fails there:
# mktemp: failed to create file via template '/tmp/tmp.XXXXXXXXXX'
# touch: cannot touch '': No such file or directory
# and the empty path that follows silently disables file tracking for the whole
# step -- the manifest ends up empty and nothing gets its owner.
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
grep -q 'stamp="$STATE/tmp/stamp' "$helper_src" \
&& ok "the build stamp lives somewhere that always exists" \
|| bad "the stamp still depends on /tmp existing"
grep -q 'file tracking is off for this step' "$helper_src" \
&& ok "a stamp that cannot be made is reported, not ignored" \
|| bad "a missing stamp would silently disable file tracking"
# and find must not run against an empty stamp path
grep -q '\[ -n "$stamp" \] && \[ -e "$stamp" \] &&' "$helper_src" \
&& ok "no find -newer against a missing stamp" \
|| bad "find -newer would run with an empty reference"
# the toolchain check has the same trap
tc="$(sed -n '/^cmd_check_toolchain() {/,/^}/p' "$helper_src")"
case "$tc" in
*'mktemp -d -p "$STATE/tmp"'*) ok "the toolchain check does not need /tmp either" ;;
*) bad "check-toolchain still requires /tmp" ;;
esac
fi
# ---- collector-group ids must not collide with package users ---------------- #
# Package users start at 10000 and their groups follow. A collector group
# created with a plain `groupadd` takes the next free gid -- which can be the
# one the next package user's group wants, so user 10042 ends up with group
# 10043 while an unrelated collector group holds 10042.
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
grep -q "^COLLECTOR_GID_MIN=90000" "$helper_src" \
&& ok "collector groups have their own id range" \
|| bad "collector groups still share the package-user range"
grep -q "^create_collector_group()" "$helper_src" \
&& ok "collector groups are created through one allocator" \
|| bad "collector groups are created ad hoc"
# A bare `groupadd "$grp"` is fine INSIDE the allocator (the fallback when
# the chosen gid is taken); anywhere else it would bypass the range.
outside="$(awk '/^create_collector_group\(\) \{/{inside=1}
inside && /^\}/{inside=0; next}
!inside && /^[[:space:]]*groupadd "\$grp"/{print NR}' \
"$helper_src")"
[ -z "$outside" ] \
&& ok "no collector group is created outside the allocator" \
|| bad "a bare groupadd bypasses the collector range (line $outside)"
fi
pmi="$(dirname "$LFS_TOOL")/packagemanager_install"
if [ -f "$pmi" ]; then
grep -q "_cgid=90000" "$pmi" \
&& ok "packagemanager uses the same collector range" \
|| bad "packagemanager still allocates collector gids from the user range"
fi
# ---- the two tools spell the same thing the same way ------------------------ #
python3 - "$LFS_TOOL" <<'PY47'
import sys
src = open(sys.argv[1]).read()
if 'sub.add_parser("list"' not in src:
print(" FAIL `lfs list` does not exist (lfs-helper has `list`)"); sys.exit(1)
print(" PASS `lfs list` matches `lfs-helper list`")
PY47
_count_rc $?
if [ -f "$helper_src" ]; then
grep -q 'if \[ "\${1:-}" = "--all" \]; then shift; cmd_list' "$helper_src" \
&& ok "\`lfs-helper next --all\` matches \`lfs next --all\`" \
|| bad "the two tools disagree on how to list every step"
fi
# ---- help is readable ------------------------------------------------------- #
for t in lfs blfs; do
f="$(dirname "$LFS_TOOL")/$t"
[ -f "$f" ] || continue
grep -q "_colour_epilog" "$f" \
|| bad "$t: the command list is not coloured"
done
ok "the command lists are coloured"
# ...but never when piped, or it corrupts logs and pagers
out="$(python3 "$LFS_TOOL" --help 2>&1 | cat)"
case "$out" in
*$'\033'*) bad "colour escapes leak into piped help output" ;;
*) ok "no colour when the output is not a terminal" ;;
esac
# ---- directories must be recorded, not just files --------------------------- #
# _run_tracked computed the directory diff as `after_d - before_d if before_d
# else set()`, and took the before-snapshot AFTER the build. On a first run
# there was no saved snapshot, so EVERY directory was discarded: the package's
# files got the package user while the directories holding them stayed
# root-owned.
python3 - "$LFS_TOOL" <<'PY48'
import sys, os, tempfile, importlib.machinery as m
lfs = m.SourceFileLoader('lfs', sys.argv[1]).load_module()
root = tempfile.mkdtemp()
os.makedirs(os.path.join(root, "usr", "src", "lfs-pkgusr", "manifests"))
os.makedirs(os.path.join(root, "usr"), exist_ok=True)
script = ("mkdir -p %s/usr/include/c++/15.2.0/experimental\n"
"echo hdr > %s/usr/include/c++/15.2.0/experimental/algorithm\n"
% (root, root))
lfs._run_tracked("gcc-pass2", script, as_lfs=False, lfs=root)
dirs = os.path.join(root, "usr", "src", "lfs-pkgusr", "manifests", "gcc-pass2.dirs")
problems = []
if not os.path.isfile(dirs) or not open(dirs).read().strip():
problems.append("no directories recorded on a first run")
else:
rec = open(dirs).read()
for need in ("include/c++/15.2.0", "experimental"):
if need not in rec:
problems.append("%s was not recorded as created" % need)
src = open(sys.argv[1]).read()
body = src[src.index("def _run_tracked"):]
body = body[:body.index("\ndef ")]
if body.index("before_d =") > body.index("rc = run_as_lfs"):
problems.append("the directory snapshot is taken after the build")
if "if before_d else set()" in body:
problems.append("the first-run directory diff is still discarded")
if problems:
for p in problems: print(" FAIL %s" % p)
sys.exit(1)
print(" PASS a package records the directories it creates, first run included")
PY48
_count_rc $?
# ---- the collector-group export must not duplicate entries ------------------ #
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
grep -q "^_dedupe_collector_export()" "$helper_src" \
&& ok "the export is deduplicated across both of its sources" \
|| bad "the export can contain the same mapping twice"
eg="$(sed -n '/^cmd_export_groups() {/,/^}/p' "$helper_src")"
case "$eg" in
*"| _dedupe_collector_export >>"*)
ok "deduplication happens on the combined output" ;;
*) bad "each source is still deduplicated on its own" ;;
esac
ig="$(sed -n '/^cmd_import_groups() {/,/^}/p' "$helper_src")"
case "$ig" in
*_dedupe_collector_export*)
ok "an already-duplicated export imports once, not twice" ;;
*) bad "importing an old export would apply entries twice" ;;
esac
fi
# ---- a typed group name must not be mangled --------------------------------- #
# sanitise_group_name used `tr -c` alone, which turns EVERY other character
# into a dash -- including spaces -- so an EXISTING group could not be found:
# could not create group --nimgnu_gcc
if [ -f "$helper_src" ]; then
san="$(sed -n '/^sanitise_group_name() {/,/^}/p' "$helper_src")"
got="$(eval "$san"; sanitise_group_name " nimgnu_gcc")"
[ "$got" = "nimgnu_gcc" ] \
&& ok "a name typed with spaces resolves to the existing group" \
|| bad "whitespace still corrupts the group name (got '$got')"
got="$(eval "$san"; sanitise_group_name "---")"
[ -z "$got" ] \
&& ok "an answer with nothing usable in it yields nothing" \
|| bad "a junk answer produced the group name '$got'"
cg="$(sed -n '/^choose_collector_group() {/,/^}/p' "$helper_src")"
case "$cg" in
*"has nothing usable in it"*"Try again"*)
ok "an unusable answer is asked again, not substituted" ;;
*) bad "an unusable answer is silently replaced" ;;
esac
case "$cg" in
*"read -e -r ans < /dev/tty; then"*)
ok "with no terminal it takes the default instead of looping" ;;
*) bad "a non-interactive run could loop forever on the prompt" ;;
esac
case "$cg" in
*"giving up after \$attempts tries"*) ok "the retry loop is bounded" ;;
*) bad "the retry loop has no bound" ;;
esac
fi
# ---- one place for building, one for using ---------------------------------- #
python3 - "$LFS_TOOL" <<'PY51'
import sys, importlib.machinery as m
lfs = m.SourceFileLoader('lfs', sys.argv[1]).load_module()
src = open(sys.argv[1]).read()
problems = []
for c in ('"run"', '"next"', '"list"', '"snapshot"', '"set-book"'):
if 'bsub.add_parser(%s' % c not in src:
problems.append("build-system has no %s" % c)
if not hasattr(lfs, "cmd_bs_set_book"):
problems.append("no separate command for the build book")
if "INSTALL PACKAGES from" not in src:
problems.append("set-default does not say which book it sets")
if not hasattr(lfs, "CONFIG_SECTIONS"):
problems.append("config is still one flat list")
asked = [k for k, _d, _v in lfs._SESSION_KEYS]
for k in ("lfs_tgt", "makeflags", "locale", "hostname"):
if k not in asked:
problems.append("session never asks for %s" % k)
if problems:
for p in problems: print(" FAIL %s" % p)
sys.exit(1)
print(" PASS building and using are separated, and the settings are asked for")
PY51
_count_rc $?
# ---- manifests report directories, not just files --------------------------- #
# A package owns the directories it creates as well as the files in them.
# Reporting only files hid the tracking bug that left the C++ header tree
# root-owned while the headers inside belonged to gcc.
mfd="$T/manifests-report/usr/src/lfs-pkgusr/manifests"
mkdir -p "$mfd"
printf '/usr/bin/a\n/usr/bin/b\n' > "$mfd/good-pkg.files"
printf '/usr/lib/thing\n' > "$mfd/good-pkg.dirs"
printf '/usr/include/x\n' > "$mfd/broken-pkg.files"
out="$(LFS="$T/manifests-report" python3 "$LFS_TOOL" build-system manifests 2>&1)"
case "$out" in
*dirs*) ok "lfs reports directory counts" ;;
*) bad "lfs manifests still reports files only" ;;
esac
case "$out" in
*"no directories recorded"*)
ok "a package with files but no directories is flagged" ;;
*) bad "a package recording zero directories looks healthy" ;;
esac
# ---- the install group is never applied to a package's own tree ------------- #
# Listing /usr/include/c++/* as a shared install directory made the auto-grant
# apply the `install` group to it -- handing gcc's headers to every package
# without asking. /usr/include is shared; its SUBTREES belong to gcc.
if [ -f "$helper_src" ]; then
( SNAP_ROOT=/; INSTALLDIRS=""
eval "$(sed -n '/^install_dirs_list() {/,/^}/p' "$helper_src")"
eval "$(sed -n '/^install_dir_patterns() {/,/^}/p' "$helper_src")"
eval "$(sed -n '/^matches_install_pattern() {/,/^}/p' "$helper_src")"
eval "$(sed -n '/^is_install_dir() {/,/^}/p' "$helper_src")"
is_install_dir /usr/include || exit 1
is_install_dir /usr/include/c++/15.2.0/bits && exit 1
exit 0 ) \
&& ok "/usr/include is shared, but its c++ subtrees are gcc's" \
|| bad "gcc's header subtrees are still treated as shared"
gd="$(sed -n '/^grant_dir_access() {/,/^}/p' "$helper_src")"
case "$gd" in
*"opening it to every package"*)
ok "a package-owned directory is never opened to everyone" ;;
*) bad "the install group could still be applied to a package's tree" ;;
esac
case "$gd" in
*'find "$dir"'*) bad "granting access still walks the directory tree" ;;
*) ok "granting access touches only the directory asked for" ;;
esac
fi
# ---- an interrupted build leaves a trace ------------------------------------ #
# Cancelling during `make install` leaves the package half on disk. gcc,
# interrupted there, replaces /usr/bin/gcc with a native compiler but has not
# yet installed the C++ headers -- so every later build fails with
# fatal error: bits/c++config.h: No such file or directory
# and nothing on disk says why.
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
grep -q "^_note_interrupted()" "$helper_src" \
&& ok "an interrupted build says what state it left behind" \
|| bad "cancelling a build leaves no trace"
grep -q "trap '_note_interrupted" "$helper_src" \
&& ok "the interrupt is trapped, so the message is not missed" \
|| bad "Ctrl-C exits without a word"
grep -q "^_clear_interrupted()" "$helper_src" \
&& ok "the marker is cleared when the build concludes" \
|| bad "the marker would persist after a completed build"
ba="$(sed -n '/^cmd_build_all() {/,/^}/p' "$helper_src")"
case "$ba" in
*_interrupted_packages*)
ok "a half-installed package is flagged before building more" ;;
*) bad "a half-installed package is silently built over" ;;
esac
# the marker must be written BEFORE the build starts, or a kill loses it
cb="$(sed -n '/^cmd_build() {/,/^}/p' "$helper_src")"
i_mark="$(printf '%s' "$cb" | grep -n 'echo "\$name" >> "\$_interrupted"' | head -1 | cut -d: -f1)"
i_run="$(printf '%s' "$cb" | grep -n 'building \$name' | head -1 | cut -d: -f1)"
if [ -n "$i_mark" ] && [ -n "$i_run" ] && [ "$i_mark" -lt "$i_run" ]; then
ok "the marker is written before the build starts"
else
bad "a kill during the build would leave no marker"
fi
fi
# ---- build-all must not lose steps or leave the system unsealed ------------- #
# Three faults in one run: a step that exits 3 ("needs a decision") had its
# code flattened to 1 and then discarded, so refind slipped past and the build
# reported everything complete with it still pending; packages built by this
# very run were re-adopted on the next one; and sealing was only suggested.
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
cb="$(sed -n '/^cmd_build() {/,/^}/p' "$helper_src")"
case "$cb" in
*"Pass 3 up unchanged"*)
ok "'needs a decision' is distinguishable from 'failed'" ;;
*) bad "exit 3 is still flattened into a plain failure" ;;
esac
ba="$(sed -n '/^cmd_build_all() {/,/^}/p' "$helper_src")"
case "$ba" in
*'3) _needs_you='*)
ok "a step needing a decision is collected, not skipped" ;;
*) bad "a step that needs you would pass silently" ;;
esac
case "$ba" in
*"These steps need a decision from you and were not built"*)
ok "unbuilt steps are reported at the end" ;;
*) bad "the build could claim to be complete with steps pending" ;;
esac
case "$ba" in
*"_vfy_seal_install_dirs"*)
ok "sealing is done as the final step, not suggested" ;;
*) bad "sealing is still left to the user" ;;
esac
# a real failure must still stop the build
case "$ba" in
*"failed (exit \$_rc) -- stopping here"*)
ok "a genuine failure stops the build" ;;
*) bad "a failing step would be built over" ;;
esac
# and a package built here is already owned: never re-adopt it
case "$cb" in
*"_mark_adopted \"\$name\""*)
ok "a freshly built package is not re-adopted" ;;
*) bad "every build-all would re-run the adoption pass" ;;
esac
fi
# ---- the chroot must not run a stale lfs-helper ----------------------------- #
# The in-chroot tools are COPIES. Fixing a bug out here changes nothing inside
# until it is copied in again, so a user who does not know that keeps hitting
# bugs that were fixed days ago -- and every diagnosis starts from the wrong
# version of the evidence. Entering the chroot now refreshes them first.
python3 - "$LFS_TOOL" <<'PY52'
import sys, os, tempfile, importlib.machinery as m
lfs = m.SourceFileLoader('lfs', sys.argv[1]).load_module()
src = open(sys.argv[1]).read()
problems = []
if not hasattr(lfs, "_refresh_chroot_tools"):
problems.append("nothing refreshes the in-chroot tools")
else:
root = tempfile.mkdtemp()
os.makedirs(os.path.join(root, "usr", "bin"))
stale = os.path.join(root, "usr", "bin", "lfs-helper")
open(stale, "w").write("old copy\n")
lfs._refresh_chroot_tools(root, quiet=True)
helper = os.path.join(os.path.dirname(os.path.abspath(sys.argv[1])),
"lfs-helper")
if os.path.isfile(helper):
if open(stale, "rb").read() != open(helper, "rb").read():
problems.append("a stale in-chroot lfs-helper is not replaced")
before = os.path.getmtime(stale)
lfs._refresh_chroot_tools(root, quiet=True)
if os.path.getmtime(stale) != before:
problems.append("an up-to-date copy is rewritten needlessly")
# both ways in must refresh
for fn in ("def enter_chroot", 'if action == "enter"'):
seg = src[src.index(fn):src.index(fn) + 600]
if "_refresh_chroot_tools" not in seg:
problems.append("%s enters without refreshing" % fn)
if problems:
for p in problems: print(" FAIL %s" % p)
sys.exit(1)
print(" PASS entering the chroot refreshes the tools first")
PY52
_count_rc $?
# ---- every tool reports its version ----------------------------------------- #
# Without one there is no way to tell whether the copy inside the chroot is the
# one that was just fixed -- and several rounds of diagnosis were spent on
# output from code that no longer existed.
# A hand-maintained number goes stale the moment someone forgets to bump it,
# and the whole point of printing a version is to answer "am I running the
# code that was just fixed?". Each tool also prints a fingerprint of its own
# contents, which cannot go stale.
vers=""
for t in lfs blfs packagemanager; do
f="$(dirname "$LFS_TOOL")/$t"
[ -f "$f" ] || continue
v="$(python3 "$f" --version 2>&1 | head -1)"
case "$v" in
*"build "*) ;;
*) bad "$t does not print a build id: $v" ;;
esac
vers="$vers $(printf '%s' "$v" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1)"
done
hv="$(bash "$(dirname "$LFS_TOOL")/lfs-helper" --version 2>&1)"
case "$hv" in
*"build "*) ;;
*) bad "lfs-helper does not print a build id: $hv" ;;
esac
vers="$vers $(printf '%s' "$hv" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1)"
uniq_count="$(printf '%s' "$vers" | tr ' ' '\n' | grep -v '^$' | sort -u | wc -l)"
[ "$uniq_count" = 1 ] \
&& ok "all four tools report the same version, each with a build id" \
|| bad "the tools report different versions:$vers"
# the fingerprint must actually track the file
tmpcopy="$T/verid"; mkdir -p "$tmpcopy"
cp "$(dirname "$LFS_TOOL")/lfs-helper" "$tmpcopy/lfs-helper"
before="$(bash "$tmpcopy/lfs-helper" --version 2>&1)"
echo "# changed" >> "$tmpcopy/lfs-helper"
after="$(bash "$tmpcopy/lfs-helper" --version 2>&1)"
[ "$before" != "$after" ] \
&& ok "the build id changes when the file changes" \
|| bad "the build id does not track the file contents"
# ---- what 4.3 hands over, 7.2 must hand back -------------------------------- #
# The two lists were written separately and drifted: 4.3 included `lib` and 7.2
# did not, so /lib stayed owned by the lfs user for the rest of the build while
# /bin and /sbin -- never handed over -- stayed root. One list serves both now.
python3 - "$LFS_TOOL" <<'PY53'
import sys, importlib.machinery as m
lfs = m.SourceFileLoader('lfs', sys.argv[1]).load_module()
src = open(sys.argv[1]).read()
problems = []
if not hasattr(lfs, "LFS_HANDOVER_DIRS"):
print(" FAIL the handover set is not defined in one place"); sys.exit(1)
for d in ("usr", "lib", "lib64", "var", "etc", "tools", "bin", "sbin"):
if d not in lfs.LFS_HANDOVER_DIRS:
problems.append("%s is missing from the handover set" % d)
# /sources belongs to the lfs user by design and must NOT be handed back
if "sources" in lfs.LFS_HANDOVER_DIRS:
problems.append("/sources would be taken from the lfs user")
# both sites must use the shared list, not a literal
if src.count("LFS_HANDOVER_DIRS") < 3:
problems.append("one of the two chowns still uses its own list")
# symlinks: /lib is a symlink, so the give-back needs -h
i = src.index('"give the tree to root"')
if "-h -R root:root" not in src[i:i + 400]:
problems.append("the give-back does not touch symlinks")
if problems:
for p in problems: print(" FAIL %s" % p)
sys.exit(1)
print(" PASS the tree is handed over and handed back symmetrically")
PY53
_count_rc $?
# ---- scratch and human directories are never owned by a package ------------- #
# man-pages ended up owning /tmp, which would break every other user of it.
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
( SNAP_ROOT=/; INSTALLDIRS=""
eval "$(sed -n '/^install_dirs_list() {/,/^}/p' "$helper_src")"
eval "$(sed -n '/^install_dir_patterns() {/,/^}/p' "$helper_src")"
eval "$(sed -n '/^matches_install_pattern() {/,/^}/p' "$helper_src")"
eval "$(sed -n '/^is_install_dir() {/,/^}/p' "$helper_src")"
eval "$(sed -n '/^never_adopt_list() {/,/^}/p' "$helper_src")"
eval "$(sed -n '/^is_never_adopt() {/,/^}/p' "$helper_src")"
for d in /tmp /var/tmp /root /home; do is_install_dir "$d" || exit 1; done
exit 0 ) \
&& ok "/tmp, /var/tmp, /root and /home are never adopted" \
|| bad "a package could come to own /tmp or a home directory"
# orphan repair must fix everything it found, not a sample of it
fo="$(sed -n '/^_vfy_orphans() {/,/^}/p' "$helper_src")"
case "$fo" in
*"head -n 2000"*) bad "orphan repair still only fixes the first 2000" ;;
*) ok "orphan repair fixes every file it found" ;;
esac
fi
# ---- "not found in book" when there IS no book ------------------------------ #
# A fresh chroot got the LFS book but not the BLFS one, so every lookup failed
# with "not found in book -- skipping" and then "Nothing to do -- everything is
# installed and up to date" -- which is the opposite of the truth, and exit 0.
pmt="$(dirname "$LFS_TOOL")/packagemanager"
if [ -f "$pmt" ]; then
grep -q "^def _blfs_book_available" "$pmt" \
&& ok "a missing book is distinguished from a missing package" \
|| bad "no book and no package give the same message"
bb="$(sed -n '/^def _blfs_book_available/,/^def /p' "$pmt")"
case "$bb" in
*'run_blfs(\["books"\])'*)
bad "book availability is asked of `blfs books`, which lists the site too" ;;
*) ok "book availability is checked on disk, not from a listing" ;;
esac
grep -q 'printError("Could not install: "' "$pmt" \
&& ok "a failed lookup is an error, not a success message" \
|| bad "a failed lookup still reports everything up to date"
# and it must exit non-zero, or a script cannot tell
ci="$(sed -n '/plan, missing, why, not_found = build_install_plan/,/printSuccess("Nothing to do/p' "$pmt")"
case "$ci" in
*"sys.exit(1)"*) ok "it exits non-zero when nothing could be looked up" ;;
*) bad "a failed install still exits 0" ;;
esac
# the status line must be cleared, or it runs into the message
case "$(sed -n '/nv = book_version(target)/,/continue/p' "$pmt")" in
*clear_status*) ok "the progress line is cleared before the message" ;;
*) bad "the status line runs into the error text" ;;
esac
fi
# the BLFS book must travel into the chroot with the tools
python3 - "$LFS_TOOL" <<'PY54'
import sys
src = open(sys.argv[1]).read()
if "/usr/share/blfs" not in src:
print(" FAIL install-tools never copies the BLFS book in"); sys.exit(1)
if "BLFS book" not in src:
print(" FAIL install-tools does not report the BLFS book"); sys.exit(1)
print(" PASS the BLFS book is installed into the chroot with the tools")
PY54
_count_rc $?
# ---- a cached book must actually be usable ---------------------------------- #
# A book imported by hand, or copied in by install-tools, has no metadata
# saying which selector it is. resolve_book only looked at the site, so with
# the book sitting right there every lookup came back
# wget: not in the BLFS book
# while `blfs books` listed it as cached.
bt="$(dirname "$LFS_TOOL")/blfs"
if [ -f "$bt" ]; then
grep -q "^def _cached_book_files" "$bt" \
&& ok "a cached book is found when the selector does not resolve" \
|| bad "a cached book is ignored unless the site confirms it"
grep -q "^def _best_cached_for" "$bt" \
&& ok "the closest cached book is chosen, not just the first" \
|| bad "any cached book would be picked at random"
# urlsplit(None) yields bytes and fails deep inside os.path.join
case "$(sed -n '/is_svn = "svn" in selector.lower()/,/return url, os.path.join/p' "$bt")" in
*"if url is None:"*) ok "a cached book with no URL does not crash" ;;
*) bad "a cached book would fail in urlsplit" ;;
esac
# and the error, when there really is no book, must say what to do
case "$(sed -n '/no book for/,/blfs import/p' "$bt")" in
*"blfs fetch"*"blfs import"*)
ok "with no book at all it says how to get one" ;;
*) bad "the no-book error gives no way forward" ;;
esac
fi
# ---- missing settings are asked for, not silently defaulted ----------------- #
pmt="$(dirname "$LFS_TOOL")/packagemanager"
if [ -f "$pmt" ]; then
grep -q "^_CONFIG_PROMPTS" "$pmt" \
&& ok "packagemanager asks for settings it does not have" \
|| bad "a missing setting is silently left blank"
grep -q '"--show"' "$pmt" \
&& ok "--show prints the settings without asking" \
|| bad "config always prompts, even when only reading"
grep -q "^def _book_in_use" "$pmt" \
&& ok "config shows which book each tool would use" \
|| bad "there is no way to see which book is in use"
# only genuinely unset values -- never re-ask for something already chosen
case "$(sed -n '/for key, prompt, default in _CONFIG_PROMPTS/,/changed = True/p' "$pmt")" in
*'if cfg.get(key):'*) ok "a setting already chosen is not asked again" ;;
*) bad "config would re-ask for values that are already set" ;;
esac
fi
# ---- the tools must agree on the collector prefix --------------------------- #
# The prefix decides which groups shared directories carry. install-tools
# wrote packagemanager.conf as JSON while packagemanager parses key=value, so
# the prefix never arrived: every package installed after the build joined
# `sysgroup_*` groups while the built system used `nimgnu_*` -- two parallel
# sets of groups over the same directories, and nothing to say so.
python3 - "$LFS_TOOL" <<'PY55'
import sys
src = open(sys.argv[1]).read()
seg = src[src.index("pmconf = os.path.join(cfg_dir"):]
seg = seg[:seg.index("_install_pkgusr_skel")]
problems = []
if "json.dump" in seg:
problems.append("packagemanager.conf is still written as JSON")
if "collector_prefix=%s" not in seg:
problems.append("the prefix is not written in key=value form")
print(" FAIL " + problems[0] if problems else
" PASS install-tools writes a config packagemanager can read")
sys.exit(1 if problems else 0)
PY55
_count_rc $?
pmt="$(dirname "$LFS_TOOL")/packagemanager"
if [ -f "$pmt" ]; then
grep -q "^_SHARED_WITH_LFS" "$pmt" \
&& ok "settings shared with lfs have one owner" \
|| bad "packagemanager keeps its own copy of shared settings"
grep -q "which is what the system was built" "$pmt" \
&& ok "a disagreeing prefix is reported, not silently preferred" \
|| bad "a prefix mismatch would pass unnoticed"
# the book must be read from the config, not by shelling out to `lfs`
bu="$(sed -n '/^def _book_in_use/,/^def /p' "$pmt")"
case "$bu" in
*_shared_lfs_config*) ok "the LFS book is read from the config file" ;;
*) bad "the LFS book needs a mounted tree to report" ;;
esac
fi
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
grep -q "^_read_shared_prefix()" "$helper_src" \
&& ok "lfs-helper reads the same prefix as the others" \
|| bad "lfs-helper could use a different prefix"
fi
# ---- packages re-creating directories that already exist -------------------- #
# make-ca's Makefile runs `install -vdm755 /usr/sbin`. The directory is
# already there with the right mode, but install still tries to chmod it, and
# under the package-user model that is a hard error:
# install: cannot change permissions of '/usr/sbin': Operation not permitted
# lfs-helper wraps install/chmod/chown during the chroot build for exactly
# this; packagemanager_install did not, so the same package failed after boot.
pmi="$(dirname "$LFS_TOOL")/packagemanager_install"
if [ -f "$pmi" ]; then
grep -q "^_make_build_wrappers()" "$pmi" \
&& ok "packagemanager wraps install/chmod for the build" \
|| bad "a package re-moding an existing directory still fails"
grep -q "PATH='\$_wrapdir'" "$pmi" \
&& ok "the wrappers are ahead of the real tools during the build" \
|| bad "the wrappers are created but never used"
# behaviour, not just presence
wd="$T/wrapmk"; mkdir -p "$wd/wrap" "$wd/exists"
( eval "$(sed -n '/^_make_build_wrappers() {/,/^}/p' "$pmi")"
_make_build_wrappers "$wd/wrap" ) >/dev/null 2>&1
if PATH="$wd/wrap:$PATH" install -vdm755 "$wd/exists" >/dev/null 2>&1; then
ok "install -d on an existing directory succeeds"
else
bad "install -d on an existing directory still errors"
fi
if PATH="$wd/wrap:$PATH" install -vdm755 "$wd/fresh" >/dev/null 2>&1 \
&& [ -d "$wd/fresh" ]; then
ok "install -d still creates directories that are missing"
else
bad "the wrapper broke directory creation"
fi
echo data > "$wd/src"
if PATH="$wd/wrap:$PATH" install -m644 "$wd/src" "$wd/exists/f" >/dev/null 2>&1 \
&& [ -f "$wd/exists/f" ]; then
ok "installing a file is passed through untouched"
else
bad "the wrapper broke ordinary file installs"
fi
# a real failure must still be a failure
if PATH="$wd/wrap:$PATH" install -d "$wd/nonexistent-parent/sub/deeper/x" \
2>/dev/null && [ ! -d "$wd/nonexistent-parent/sub/deeper/x" ]; then
bad "the wrapper reports success for a directory it did not create"
else
ok "a genuine failure is not masked"
fi
fi
# ---- a step's manifest lists what it owns, not what happened while it ran --- #
# 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 it took the file from wget:
# -rwxr-xr-x 1 last-step last-step ... /usr/bin/wget
# after which wget could not reinstall itself.
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
grep -q "^_drop_other_packages_files()" "$helper_src" \
&& ok "another package's files stay out of a step's manifest" \
|| bad "a step still records files another package installed"
# it must be applied where the manifest is written
case "$(sed -n '/local man="\$MANIFESTS\/\$name.files"/,/tracked \$/p' "$helper_src")" in
*_drop_other_packages_files*)
ok "the filter is applied when the manifest is written" ;;
*) bad "the filter exists but is not used" ;;
esac
# root-owned files must still be recorded -- that is the normal case for a
# root step, and dropping them would empty every chapter-7 manifest
df="$(sed -n '/^_drop_other_packages_files() {/,/^}/p' "$helper_src")"
case "$df" in
*'"$cur" != root'*) ok "root-owned files are still recorded" ;;
*) bad "a root step would record nothing at all" ;;
esac
fi
# ---- build steps are not packages ------------------------------------------- #
# init-*, cfg_* and refind are actions, not software. A `last-step` package
# user was once created 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 is gone, but the rule that made it
# harmful is not: any step name could still become a package user.
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
grep -q "^_is_not_a_package()" "$helper_src" \
&& ok "steps that are not packages are recognised" \
|| bad "any step name could become a package user"
au="$(_slice_fn "$helper_src" cmd_add_user)"
case "$au" in
*_is_not_a_package*) ok "add-user refuses to create one for a step" ;;
*) bad "add-user would still create a user for a build step" ;;
esac
na="$(_slice_fn "$helper_src" _needs_adoption)"
case "$na" in
*_is_not_a_package*) ok "adoption skips step names too" ;;
*) bad "adoption could recreate the user add-user refused" ;;
esac
fi
# a hint that installs a package must pull its dependencies too
python3 - "$LFS_TOOL" <<'PY58'
import sys, re, importlib.machinery as m
src = open(sys.argv[1]).read()
bad = [l.strip() for l in src.splitlines()
if re.search(r"packagemanager install [a-z][a-z0-9-]*\s*\"?$", l)
and "--recursive" not in l]
if bad:
for b in bad[:3]:
print(" FAIL hint installs without dependencies: %s" % b[:60])
sys.exit(1)
print(" PASS install hints pull dependencies with --recursive")
PY58
_count_rc $?
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
grep -q "^cmd_grant_dir()" "$helper_src" \
&& ok "access to one directory can be asked for directly" \
|| bad "a script cannot ask for access before failing"
gd="$(sed -n '/^cmd_grant_dir() {/,/^}/p' "$helper_src")"
case "$gd" in
*grant_dir_access*) ok "it reuses the same rules as a failed build" ;;
*) bad "grant-dir implements its own logic" ;;
esac
case "$gd" in
*'test -w'*) ok "it does nothing when access already exists" ;;
*) bad "grant-dir would regroup a directory needlessly" ;;
esac
fi
# ---- helper functions must be defined before they are used ------------------ #
# bash parses top to bottom: a function called at line 437 but defined at 466
# does not exist yet, and the build ran without its wrappers:
# packagemanager_install: line 437: _make_build_wrappers: command not found
pmi="$(dirname "$LFS_TOOL")/packagemanager_install"
if [ -f "$pmi" ]; then
python3 - "$pmi" <<'PY60'
import re, sys
lines = open(sys.argv[1]).read().split("\n")
defs, bad = {}, []
for i, l in enumerate(lines):
m = re.match(r"^([a-zA-Z_][a-zA-Z0-9_]*)\(\) \{", l)
if m:
defs.setdefault(m.group(1), i)
for name, dline in defs.items():
for i, l in enumerate(lines[:dline]):
if re.search(r"^\s+%s\b" % re.escape(name), l) and not l.strip().startswith("#"):
enclosing = max((d for d in defs.values() if d < i), default=-1)
if enclosing == -1:
bad.append("%s used at line %d, defined at %d" % (name, i + 1, dline + 1))
break
if bad:
for b in bad: print(" FAIL %s" % b)
sys.exit(1)
print(" PASS every helper is defined before it is used")
PY60
_count_rc $?
# ---- one implementation of the collector-group rules --------------------- #
# A package installed after boot must behave exactly like one built during
# the chroot stage. Two copies of the rules drift; lfs-helper owns them.
gd="$(sed -n '/^grant_dir_to_user() {/,/^}/p' "$pmi")"
case "$gd" in
*"lfs-helper grant-dir"*)
ok "packagemanager uses lfs-helper's collector-group rules" ;;
*) bad "packagemanager keeps its own copy of the rules" ;;
esac
case "$gd" in
*"fallback for a system without it"*)
ok "it still works where lfs-helper is not installed" ;;
*) bad "no fallback when lfs-helper is absent" ;;
esac
# the delegation must come FIRST, or the local copy decides
i_del="$(printf '%s' "$gd" | grep -n "lfs-helper grant-dir" | head -1 | cut -d: -f1)"
i_own="$(printf '%s' "$gd" | grep -n 'prefix="\$(collector_prefix_of)"' | head -1 | cut -d: -f1)"
if [ -n "$i_del" ] && [ -n "$i_own" ] && [ "$i_del" -lt "$i_own" ]; then
ok "the shared rules are consulted before the local copy"
else
bad "the local copy runs even when lfs-helper is available"
fi
# ---- one set of wrappers, not two (1.11.3) ------------------------------ #
# The wrappers ARE the rule about what a package user may do: chown skipped,
# chgrp skipped, `install -d` on an existing directory allowed to succeed.
# lfs-helper writes them to /usr/lib/pkgusr, where they outlive the build and
# where the package-user profile already points. This script wrote its own
# 480-line copy into a fresh /tmp directory on EVERY run: a second answer to
# the same question, and the profile pointed at neither of them.
_p=""
grep -q '^_wrapper_dir_from_lfs_helper()' "$pmi" \
|| _p="$_p;there is no way to ask lfs-helper for the wrappers"
grep -q 'lfs-helper wrapper-dir' "$pmi" \
|| _p="$_p;the wrapper directory is assumed rather than asked for"
# every call site must go through the resolver first
_n_ask="$(grep -c '_wrapper_dir_from_lfs_helper' "$pmi")"
_n_own="$(grep -c '_make_build_wrappers "\$_wrapdir"' "$pmi")"
[ "${_n_ask:-0}" -gt "${_n_own:-0}" ] \
|| _p="$_p;a call site still builds its own wrappers without asking"
# and the SHARED directory must never be deleted afterwards -- that would
# take the wrappers away from every package user on the system
grep -q '_wraptmp' "$pmi" \
|| _p="$_p;the cleanup does not distinguish the shared dir from a temporary one"
if grep -q 'rm -rf "$_wrapdir"' "$pmi" && ! grep -q '_wraptmp.*=.*1.*rm -rf "$_wrapdir"' "$pmi"; then
_p="$_p;the wrapper directory is removed unconditionally"
fi
if [ -n "$_p" ]; then
printf '%s\n' "${_p#;}" | tr ';' '\n' | while IFS= read -r m; do
[ -n "$m" ] && bad "$m"
done
else
ok "both tools use one set of wrappers, and only a temporary one is deleted"
fi
# the account home is asked for, not re-derived from the layout
_hf="$(sed -n '/^pkgusr_home_for() {/,/^}/p' "$pmi")"
case "$_hf" in
*"lfs-helper pkgusr-home"*) ok "the account home comes from the chokepoint" ;;
*) bad "packagemanager_install re-derives where an account lives" ;;
esac
fi
# ---- the kernel and the bootloader are yours (1.11.6) --------------------- #
# Both are decisions about the whole MACHINE, and getting either wrong costs
# the system you are building on. The build must not do them by default, and
# must not let you find that out after the reboot.
_h="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$_h" ]; then
_p=""
grep -q '_say_what_is_yours_to_finish' "$_h" \
|| _p="$_p;the build never says the kernel and bootloader are yours"
_sf="$(sed -n '/^_say_what_is_yours_to_finish() {/,/^}/p' "$_h")"
printf '%s' "$_sf" | grep -qi 'kernel' \
|| _p="$_p;the closing note does not mention the kernel"
printf '%s' "$_sf" | grep -qi 'refind' \
|| _p="$_p;the closing note does not say how to add a bootloader entry"
# it is a note, not an action: nothing in it may write anything
printf '%s' "$_sf" | grep -qE '^\s*(cp|mv|rm|dd|mkfs|chmod|chown|install) ' \
&& _p="$_p;the closing note runs commands instead of printing them"
if [ -n "$_p" ]; then
printf '%s\n' "${_p#;}" | tr ';' '\n' | while IFS= read -r m; do
[ -n "$m" ] && bad "$m"
done
else
ok "the build ends by saying the kernel and bootloader are yours"
fi
fi
# and no bootloader is set up unless it was asked for, by name
python3 - "$LFS_TOOL" <<'PYBL'
import sys, re, importlib.machinery as m
lfs = m.SourceFileLoader('lfs', sys.argv[1]).load_module()
problems = []
# the default must be "none" with NO config at all -- not "none" because some
# other key happened to be unset
lfs.load_config = lambda: {}
if lfs.bootloader() != "none":
problems.append("the default bootloader is %r, not 'none'" % lfs.bootloader())
lfs.load_config = lambda: {"bootloader": ""}
if lfs.bootloader() != "none":
problems.append("an empty bootloader setting does not mean 'none'")
# and the prompt must say the machine keeps booting as it does now
src = open(sys.argv[1]).read()
m2 = re.search(r'\("bootloader",.*?\),\n\s*\("esp"', src, re.S)
if m2 and "DEFAULT" not in m2.group(0):
problems.append("the prompt does not say that doing nothing is the default")
if problems:
for p in problems: print(" FAIL %s" % p)
sys.exit(1)
print(" PASS no bootloader is touched unless it was asked for by name")
PYBL
_count_rc $?
# ---- $LFS may never be the running system (1.11.5) ------------------------ #
# Every path this tool writes is $LFS/something. With $LFS set to the host --
# a config holding "/", an `export LFS=/` in the wrong shell, a typo in the
# interview -- `restart --run` deletes the top-level directories of the machine
# you are sitting on, and _verify_lfs_ownership runs `chown -R lfs /usr`.
# The chroot bind mounts were guarded; the tree itself was not.
python3 - "$LFS_TOOL" <<'PYHOST'
import sys, os, tempfile, importlib.machinery as m
lfs = m.SourceFileLoader('lfs', sys.argv[1]).load_module()
problems = []
for fn in ("_is_host_path", "_refuse_host_tree"):
if not hasattr(lfs, fn):
problems.append("there is no %s" % fn)
if problems:
for p in problems: print(" FAIL %s" % p)
sys.exit(1)
# the host, by every spelling
for p in ("/", "/usr", "/etc", "/boot", "/var", "/home", "/bin", "/lib"):
if not lfs._is_host_path(p):
problems.append("%s is not recognised as the running system" % p)
# by another name: a symlink to /
d = tempfile.mkdtemp()
link = os.path.join(d, "root-link")
try:
os.symlink("/", link)
if not lfs._is_host_path(link):
problems.append("a symlink to / is not recognised")
except OSError:
pass
# a path INSIDE a system tree is the system too, however deep
for p in ("/usr/src/lfs", "/etc/lfs", "/var/tmp/lfs"):
if not lfs._is_host_path(p):
problems.append("%s is not recognised as part of the system" % p)
# ...and a real build tree must still be allowed, even though it has the same
# layout. A check that refuses /mnt/lfs/usr refuses every build there is.
tree = os.path.join(d, "lfs")
os.makedirs(os.path.join(tree, "usr"), exist_ok=True)
os.makedirs(os.path.join(tree, "etc"), exist_ok=True)
for p in (tree, "/mnt/lfs", "/home/someone/lfs", "/media/disk/lfs"):
if lfs._is_host_path(p):
problems.append("a legitimate build tree is refused: %s" % p)
# ONE rule, not two: the interview and the hard stop must agree, or a path
# refused by one is accepted by the other.
src = open(sys.argv[1]).read()
import re
body = re.search(r"def _refuse_host_tree\(lfs\):.*?\n\n\ndef ", src, re.S)
if body and "_is_host_path" not in body.group(0):
problems.append("the hard stop keeps its own copy of the rule")
if "_is_host_path" not in re.search(r"for k, desc, default in _SESSION_KEYS:.*?\n\n", src, re.S).group(0):
problems.append("the interview accepts a mount point the tools will refuse")
# the destructive paths must go through it
for fn in ("require_lfs_for_root", "_verify_lfs_ownership"):
b = re.search(r"def %s\(.*?(?=\n\ndef )" % fn, src, re.S)
if b and "_refuse_host_tree" not in b.group(0):
problems.append("%s does not check the tree is not the host" % fn)
if problems:
for p in problems: print(" FAIL %s" % p)
sys.exit(1)
print(" PASS $LFS is refused when it is the running system, by any spelling")
PYHOST
_count_rc $?
# ---- an answer that is collected must be acted on, or said (1.11.4) ------- #
# `strip` is asked for during the interview and nothing does it. The prompt
# says NOT YET IMPLEMENTED, but that was six hours and a whole build ago, and
# the build otherwise ends as though every answer had been acted on.
_h="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$_h" ]; then
_p=""
grep -q '_warn_if_strip_was_asked_for' "$_h" \
|| _p="$_p;nothing tells you the strip answer was ignored"
# it must READ the answer, not assume it
_sf="$(sed -n '/^_warn_if_strip_was_asked_for() {/,/^}/p' "$_h")"
printf '%s' "$_sf" | grep -q 'LFS_STRIP' \
|| _p="$_p;the strip notice does not read LFS_STRIP"
# and only when it was actually asked for
printf '%s' "$_sf" | grep -q 'return 0' \
|| _p="$_p;the strip notice fires even when nobody asked for it"
# said at the END, with the other things you still have to do
_i1="$(grep -n '_warn_if_strip_was_asked_for$' "$_h" | tail -1 | cut -d: -f1)"
_i2="$(grep -n '^ _warn_if_no_login$' "$_h" | tail -1 | cut -d: -f1)"
[ -n "$_i1" ] && [ -n "$_i2" ] \
|| _p="$_p;the notice is not part of the build's last word"
if [ -n "$_p" ]; then
printf '%s\n' "${_p#;}" | tr ';' '\n' | while IFS= read -r m; do
[ -n "$m" ] && bad "$m"
done
else
ok "a build that ignored your strip answer says so at the end"
fi
fi
# ---- a suppressed failure is a bug waiting to be found (1.11.4) ----------- #
# `cmd 2>/dev/null || true` has cost this project more debugging than anything
# else: a tree came out with every package home root:root 755 and not one line
# said why. The pattern is not banned -- mkdir -p on a directory that exists is
# fine -- but a failure that CHANGES WHAT THE SYSTEM LOOKS LIKE must be said.
_h="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$_h" ]; then
_p=""
grep -q '^soft() {' "$_h" || _p="$_p;there is no way to report a non-fatal failure"
# the ownership and permission calls are the ones that matter
_leak="$(grep -nE '(real_chmod|real_chown|real_chgrp|chown|chgrp) .*2>/dev/null \|\| true' "$_h" \
| grep -v 'SUPPRESSED DELIBERATELY' | wc -l)"
# ANY comment above is not enough -- the surrounding prose explains what the
# call does, not why its failure may be thrown away, and a reintroduced
# suppression inherits whatever comment happened to be there. The line
# immediately above must say which of the two cases this is, in those words.
_undoc=0
while IFS= read -r _ln; do
[ -n "$_ln" ] || continue
_n="${_ln%%:*}"
sed -n "$((_n-1))p" "$_h" \
| grep -qE 'SUPPRESSED DELIBERATELY|benign:' || _undoc=$((_undoc+1))
done <<< "$(grep -nE '(real_chmod|real_chown|real_chgrp) .*2>/dev/null \|\| true' "$_h")"
[ "${_undoc:-0}" = 0 ] \
|| _p="$_p;$_undoc suppressed ownership call(s) are not marked deliberate or benign"
# and the two that are deliberate must say so in those words
grep -q 'SUPPRESSED DELIBERATELY' "$_h" \
|| _p="$_p;the deliberate suppressions are not marked as such"
if [ -n "$_p" ]; then
printf '%s\n' "${_p#;}" | tr ';' '\n' | while IFS= read -r m; do
[ -n "$m" ] && bad "$m"
done
else
ok "every suppressed ownership failure says why it is suppressed"
fi
fi
# lfs-helper must actually offer the doors the other tool now knocks on.
_h="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$_h" ]; then
_p=""
for _c in wrapper-dir make-wrappers grant-dir pkgusr-home owner-name; do
grep -qE "^ $_c\)" "$_h" \
|| _p="$_p;lfs-helper has no '$_c' command"
done
# `case` takes the first match, so a repeated entry is unreachable code
# pretending to be a second decision. Six of them were.
_dups="$(sed -n '/^case "${1:-}" in/,/^esac/p' "$_h" \
| grep -oE '^ [a-z][a-z0-9|-]*\)' | sort | uniq -d)"
[ -z "$_dups" ] || _p="$_p;the dispatch repeats: $(echo $_dups | tr -d ')')"
if [ -n "$_p" ]; then
printf '%s\n' "${_p#;}" | tr ';' '\n' | while IFS= read -r m; do
[ -n "$m" ] && bad "$m"
done
else
ok "every shared decision is reachable as one lfs-helper command"
fi
fi
# ---- editing the wrong install script fails silently ------------------------ #
# A package user's home holds two files with nearly identical names:
# install_<name>-<version> the source -- the one to edit
# install_<name> a runtime copy, overwritten every run
# Editing the second changes nothing, the install repeats unchanged, and it
# looks as though the tool threw the edit away.
pmt="$(dirname "$LFS_TOOL")/packagemanager"
if [ -f "$pmt" ]; then
grep -q "^def _warn_if_wrong_file_edited" "$pmt" \
&& ok "an edit to the runtime copy is pointed out" \
|| bad "editing the wrong file goes unmentioned"
grep -q "To change what it does, edit:" "$pmt" \
&& ok "a failure names the file to edit" \
|| bad "a failure gives no path to edit"
# behaviour: warn only when the runtime copy is genuinely newer
python3 - "$pmt" <<'PY61'
import sys, os, time, tempfile, importlib.machinery as m
pm = m.SourceFileLoader('pm', sys.argv[1]).load_module()
base = tempfile.mkdtemp()
pm.BASE_DIR = base
# the directory on disk is the USER's, so build the fixture through the same
# helper the code uses -- the package "p" becomes the account p_p, and
# hard-coding "p" here made this test fail the moment prefixes were turned on
# resolve the home the same way the code does -- the account is p_p AND it
# lives under the package root, so neither part can be hard-coded here
_h = pm.pkgusr_home("p")
os.makedirs(_h)
src = os.path.join(_h, "install_p-1.0")
rt = os.path.join(_h, "install_p")
open(src, "w").write("real")
time.sleep(0.02)
open(rt, "w").write("edited")
import io, contextlib
buf = io.StringIO()
with contextlib.redirect_stdout(buf), contextlib.redirect_stderr(buf):
pm._warn_if_wrong_file_edited("p", src)
warned = "runtime copy" in buf.getvalue()
os.utime(src, None) # now the source is newer
buf2 = io.StringIO()
with contextlib.redirect_stdout(buf2), contextlib.redirect_stderr(buf2):
pm._warn_if_wrong_file_edited("p", src)
quiet = "runtime copy" not in buf2.getvalue()
if warned and quiet:
print(" PASS it warns only when the runtime copy is the newer file")
else:
print(" FAIL warned=%s quiet=%s" % (warned, quiet))
sys.exit(1)
PY61
_count_rc $?
fi
# ---- installing make-ca does not create any certificates -------------------- #
# It installs the TOOL. Until it is run there is still no CA bundle, wget
# still cannot verify anything, and the /etc/wgetrc workaround stays -- but the
# install reported success, so the missing step is easy to overlook.
pmt="$(dirname "$LFS_TOOL")/packagemanager"
if [ -f "$pmt" ]; then
grep -q "^def _hint_generate_ca_bundle" "$pmt" \
&& ok "the missing make-ca -g step is pointed out" \
|| bad "make-ca installs and nothing says to run it"
hg="$(sed -n '/^def _hint_generate_ca_bundle/,/^def /p' "$pmt")"
case "$hg" in
*"make-ca -g"*) ok "it gives the exact command" ;;
*) bad "the hint does not say what to run" ;;
esac
# silent once the certificates exist
case "$hg" in
*"certificates exist already"*) ok "it stops once certificates exist" ;;
*) bad "the hint would repeat forever" ;;
esac
# ---- the book's init flavour must match the system ----------------------- #
# A systemd book emits `systemctl` in its install scripts; on a SysV system
# every affected package fails mid-install and has to be hand-edited.
grep -q "^def _warn_init_mismatch" "$pmt" \
&& ok "a systemd/SysV book mismatch is reported" \
|| bad "the wrong book flavour goes unmentioned"
wm="$(sed -n '/^def _warn_init_mismatch/,/^def /p' "$pmt")"
case "$wm" in
*"if book_systemd == have_systemd"*)
ok "it says nothing when the book matches" ;;
*) bad "the mismatch warning would always fire" ;;
esac
fi
# ---- make-ca installed is not the same as certificates generated ------------ #
# `make-ca -g` prints "No update required!" and generates nothing when it
# considers its copy of certdata.txt current -- which it is straight after
# install. The install then looks successful while the system still has no
# certificate bundle, and every HTTPS fetch keeps warning.
pmt="$(dirname "$LFS_TOOL")/packagemanager"
if [ -f "$pmt" ]; then
grep -q "^def check_make_ca_generated" "$pmt" \
&& ok "an installed-but-ungenerated make-ca is noticed" \
|| bad "make-ca can be installed with no certificates and nothing said"
grep -q "make-ca -g --force" "$pmt" \
&& ok "it gives the command that actually generates them" \
|| bad "it does not say how to finish the job"
# and it must go quiet once a bundle exists
python3 - "$pmt" <<'PY62'
import sys, os, io, contextlib, tempfile, importlib.machinery as m
pm = m.SourceFileLoader('pm', sys.argv[1]).load_module()
d = tempfile.mkdtemp()
fake = os.path.join(d, "make-ca")
open(fake, "w").write("#!/bin/sh\nexit 0\n")
os.chmod(fake, 0o755)
os.environ["PATH"] = d + os.pathsep + os.environ["PATH"]
pm._CA_BUNDLES = [os.path.join(d, "bundle.crt")]
buf = io.StringIO()
with contextlib.redirect_stdout(buf), contextlib.redirect_stderr(buf):
pm.check_make_ca_generated()
warned = "no CA certificate bundle" in buf.getvalue()
open(os.path.join(d, "bundle.crt"), "w").write("CERTS")
buf2 = io.StringIO()
with contextlib.redirect_stdout(buf2), contextlib.redirect_stderr(buf2):
pm.check_make_ca_generated()
quiet = "no CA certificate bundle" not in buf2.getvalue()
if warned and quiet:
print(" PASS it warns only while the bundle is genuinely missing")
else:
print(" FAIL warned=%s quiet=%s" % (warned, quiet)); sys.exit(1)
PY62
_count_rc $?
fi
# ---- prompts must allow line editing ---------------------------------------- #
# Without readline an arrow key inserts an escape sequence instead of moving
# the cursor, so a typo in a long answer -- a group name, a device path --
# means retyping the whole thing.
for t in lfs blfs packagemanager; do
f="$(dirname "$LFS_TOOL")/$t"
[ -f "$f" ] || continue
grep -q "import readline" "$f" \
|| bad "$t: input() prompts have no line editing"
done
ok "the python tools import readline"
# an optional import: a python built without readline must still run
python3 - "$LFS_TOOL" <<'PY64'
import sys
src = open(sys.argv[1]).read()
i = src.index("import readline")
window = src[max(0, i - 200):i + 120]
if "try:" not in window or "ImportError" not in window:
print(" FAIL readline is imported unguarded; a python without it would fail")
sys.exit(1)
print(" PASS readline is optional, not required")
PY64
_count_rc $?
for f in lfs-helper packagemanager_install; do
p="$(dirname "$LFS_TOOL")/$f"
[ -f "$p" ] || continue
# every prompt that reads an ANSWER needs -e; loops reading files do not
bare="$(grep -nE "read (-r )?(ans|check|answer|reply)\b" "$p" \
| grep -v -- "-e" | head -3)"
[ -z "$bare" ] \
|| bad "$f: prompt without line editing: $(echo "$bare" | head -1)"
done
ok "the bash prompts use read -e"
# and -r as well, or a backslash in an answer is eaten
if grep -qE "read -e [^r-]" "$(dirname "$LFS_TOOL")/packagemanager_install" 2>/dev/null; then
bad "a prompt uses -e without -r"
else
ok "prompts keep backslashes literal (-r)"
fi
# ---- starting the whole build over -------------------------------------------#
# Deleting a built system is the most destructive thing these tools can do --
# potentially weeks of compilation -- so it has to say exactly what goes, what
# stays, and refuse outright in the one case that could damage the host.
python3 - "$LFS_TOOL" <<'PY65'
import sys, importlib.machinery as m
lfs = m.SourceFileLoader('lfs', sys.argv[1]).load_module()
src = open(sys.argv[1]).read()
if not hasattr(lfs, "cmd_bs_restart"):
print(" FAIL no way to start the build over"); sys.exit(1)
body = src[src.index("def cmd_bs_restart"):src.index("def cmd_bs_set_book")]
problems = []
# deleting through a bind-mounted /dev would reach the running system
if "the chroot is still mounted" not in body:
problems.append("it does not refuse while the chroot is mounted")
for d in ('"dev/pts", "dev/shm", "dev", "proc", "sys", "run"',):
if d not in body:
problems.append("the mount check does not cover every virtual filesystem")
# dry run by default, and a confirmation that cannot be typed by accident
if "args.run" not in body:
problems.append("restart is not dry-run by default")
if "Type the mount point" not in body:
problems.append("the confirmation is too easy to give by accident")
# sources are expensive to re-download and unchanged by a build
if "keep_sources" not in body:
problems.append("the downloaded sources are not kept")
# and it should point at the cheaper alternative
if "snapshot restore" not in body:
problems.append("it does not mention restoring a snapshot instead")
if problems:
for p in problems: print(" FAIL %s" % p)
sys.exit(1)
print(" PASS restart warns, confirms, keeps the sources and spares the host")
PY65
_count_rc $?
# ---- sources and build scratch are two directories -------------------------- #
# They used to be one, and every problem in this area came from that. A build
# unpacked into the tarball store, dropped .cc-* markers there, and left both
# owned by whoever built -- so a rebuild had to pick debris out of the thing it
# most needed to keep. Split, and the whole class of problem is structural:
# /sources holds only what get-sources downloaded, and BUILD_ROOT is deleted
# whole.
python3 - "$LFS_TOOL" <<'PY65B'
import sys, os, re, importlib.machinery as m
lfs = m.SourceFileLoader('lfs', sys.argv[1]).load_module()
problems = []
body = open(sys.argv[1]).read()
if 'BUILD_ROOT="${BUILD_ROOT:-$LFS/sources}"' in body:
problems.append("BUILD_ROOT still defaults to the tarball store")
for need in ('SOURCES_DIR="${SOURCES_DIR:-$LFS/sources}"',
'BUILD_ROOT="${BUILD_ROOT:-$LFS/build}"'):
if need not in body:
problems.append("generated scripts do not set %s" % need.split("=")[0])
# generate a real script and read what it does
cmds = ["mkdir -v build\ncd build", "../configure", "make", "make install"]
s = lfs._crosschain_script_body("demo", "ch-tools-demo", "demo-1.0",
"demo-1.0.tar.*", cmds)
# the markers must live in scratch, never in the tarball store
for marker in (".cc-build-demo", ".cc-dir-demo"):
for line in s.splitlines():
if marker in line and "SOURCES_DIR" in line:
problems.append("%s is written to the tarball store" % marker)
if '$BUILD_ROOT/.cc-build-demo' not in s:
problems.append("the build marker is not written to BUILD_ROOT")
# unpack must extract into scratch
if 'cd "$BUILD_ROOT"' not in s:
problems.append("unpack does not work in BUILD_ROOT")
# and the symlink farm must be built before the tarball is looked for
u = s[s.index("unpack_pkg()"):s.index("#### UNPACK DONE ####")]
if "_link_sources" not in u:
problems.append("unpack does not build the sources symlink farm")
# the chroot copy must map BOTH directories, not just one
c = lfs._chrootify(s)
if 'SOURCES_DIR="${SOURCES_DIR:-/sources}"' not in c:
problems.append("the chroot script does not point at /sources")
if 'BUILD_ROOT="${BUILD_ROOT:-/build}"' not in c:
problems.append("the chroot script does not point at /build")
if problems:
for p in problems: print(" FAIL %s" % p)
sys.exit(1)
print(" PASS tarball store and build scratch are separate directories")
PY65B
_count_rc $?
# ---- a book command may reach a sibling tarball through .. ------------------ #
# The book does this, in Tcl:
#
# cd ..
# tar -xf ../tcl8.6.16-html.tar.gz --strip-components=1
#
# `cd ..` leaves the build subdirectory, so ".." is then the package root's
# parent -- which used to be the sources directory and is now BUILD_ROOT.
# Splitting the two would break every command of this shape.
#
# The fix must not be a list of packages known to do it: that would be a second
# thing to keep in sync with the book, and wrong the first time a new release
# adds another. unpack_pkg symlinks EVERY downloaded file into BUILD_ROOT, so
# "../<anything-we-downloaded>" resolves for any package in any future book.
# This test builds a package that does exactly what Tcl does.
_sbx="$T/split"
mkdir -p "$_sbx/lfs/sources" "$_sbx/lfs/out" "$_sbx/mk/demo-2.0" "$_sbx/mk/doc"
cat > "$_sbx/mk/demo-2.0/configure" <<'EOF'
#!/bin/bash
cat > Makefile <<'MK'
all:
@echo compiling
install:
@true
MK
EOF
chmod +x "$_sbx/mk/demo-2.0/configure"
( cd "$_sbx/mk" && tar czf "$_sbx/lfs/sources/demo-2.0.tar.gz" demo-2.0 )
echo html > "$_sbx/mk/doc/index.html"
( cd "$_sbx/mk" && tar czf "$_sbx/lfs/sources/demo-2.0-html.tar.gz" doc )
echo patch > "$_sbx/lfs/sources/demo-2.0-fix-1.patch"
python3 - "$LFS_TOOL" "$_sbx/demo.sh" <<'PYSPLIT'
import sys, importlib.machinery as m
lfs = m.SourceFileLoader('lfs', sys.argv[1]).load_module()
cmds = [
"mkdir -v build\ncd build",
"../configure",
"make",
"make install",
# the book's Tcl shape, verbatim in structure
"cd ..\ntar -xf ../demo-2.0-html.tar.gz -C ../../out\n"
"cp -v ../demo-2.0-fix-1.patch ../../out/patch.seen",
]
open(sys.argv[2], "w").write(
lfs._crosschain_script_body("demo", "ch-tools-demo", "demo-2.0",
"demo-2.0.tar.*", cmds))
PYSPLIT
# The suite exports SOURCES_DIR/BUILD_ROOT for other tests; clear them here so
# this exercises the DEFAULTS a real build would get.
( cd "$_sbx" && env -u SOURCES_DIR -u BUILD_ROOT LFS="$_sbx/lfs" \
bash demo.sh all ) >"$_sbx/log" 2>&1
_sp=""
[ -d "$_sbx/lfs/out/doc" ] || _sp="$_sp;a sibling tarball reached through .. was not found"
[ -f "$_sbx/lfs/out/patch.seen" ] || _sp="$_sp;a patch reached through .. was not found"
# and the whole point: a full build must not have touched the tarball store
_left="$(ls -A "$_sbx/lfs/sources" | grep -v '^demo-2\.0' | tr '\n' ' ' || true)"
[ -z "$_left" ] || _sp="$_sp;a build wrote into the tarball store: $_left"
[ -d "$_sbx/lfs/build/demo-2.0" ] || _sp="$_sp;the package was not unpacked into BUILD_ROOT"
[ -f "$_sbx/lfs/build/.cc-dir-demo" ] || _sp="$_sp;build markers did not land in BUILD_ROOT"
[ -L "$_sbx/lfs/build/demo-2.0-html.tar.gz" ] || _sp="$_sp;the farm entry is not a symlink"
if [ -n "$_sp" ]; then
printf '%s\n' "${_sp#;}" | tr ';' '\n' | while IFS= read -r _m; do
[ -n "$_m" ] && bad "$_m"
done
sed 's/^/ /' "$_sbx/log" | tail -8
else
ok "'../<tarball>' resolves for any package, and /sources stays clean"
fi
# ---- /sources has ONE owner convention, and it is the book's ---------------- #
# Three rules had drifted apart, and the result on disk was a mixture:
#
# drwxrwxrwt 1 9998 install /mnt/lfs/sources/
# -rw-r--r-- 1 lfs 9998 acl-2.3.2.tar.xz
#
# * lfs's _chown_tree_to_lfs added "sources" to the 4.3 handover set, so it
# chowned /sources to the HOST's lfs user. Book 4.3 does no such thing.
# * lfs-helper carried /sources in install_dirs_list AS WELL AS in
# never_adopt_list -- the exact conflation the comment above
# never_adopt_list warns about, the one that made /root root:install.
# verify --fix then made it root:install 775 and the seal made it 1775,
# dropping the o+w that lets the unprivileged build user unpack at all.
# * cmd_clean_sources reset unknown owners to root:root.
#
# The host lfs account is created by book 4.3's own useradd, which pins no
# uid -- it was 10753 on a Debian host. Package users start at 10000, so that
# is not merely "an unnamed UID" as the book puts it: it COLLIDES with a real
# package user, and tarballs report as owned by some unrelated package.
#
# Book 3.1 settles it and the reasoning is given there in full:
# chmod -v a+wt $LFS/sources -> root:root 1777
# chown root:root $LFS/sources/* -> the contents too
# World-writable is what grants the build user access, so no group is involved.
python3 - "$LFS_TOOL" <<'PY65C'
import sys, os, stat, tempfile, importlib.machinery as m
lfs = m.SourceFileLoader('lfs', sys.argv[1]).load_module()
problems = []
if not hasattr(lfs, "_normalize_sources"):
print(" FAIL no single rule for /sources ownership")
sys.exit(1)
# 4.3 hands over the book's directories and NOT /sources
if "sources" in lfs.LFS_HANDOVER_DIRS:
problems.append("/sources is in the 4.3 handover set")
body = open(sys.argv[1]).read()
h = body[body.index("def _chown_tree_to_lfs"):body.index("def _ensure_lfs_home")]
if 'LFS_HANDOVER_DIRS + ("sources",)' in h:
problems.append("the 4.3 chown still adds /sources to the handover")
d = tempfile.mkdtemp()
src = os.path.join(d, "sources"); os.makedirs(src)
tar = os.path.join(src, "bash-5.3.tar.gz"); open(tar, "w").close()
tree = os.path.join(src, "acl-2.3.2"); os.makedirs(tree)
os.chmod(src, 0o775) # what verify --fix used to leave
try:
os.chown(tar, 10753, 10753) # a host uid inside the package range
os.chown(tree, 10753, 10753)
except OSError:
print(" SKIP cannot chown here (needs root)")
else:
lfs._normalize_sources(d, run=True, quiet=True)
st = os.stat(src)
if stat.S_IMODE(st.st_mode) != (stat.S_ISVTX | 0o777):
problems.append("/sources is not 1777 -- the build user cannot unpack "
"(mode %o)" % stat.S_IMODE(st.st_mode))
if st.st_uid != 0 or st.st_gid != 0:
problems.append("/sources itself is not root:root")
if os.lstat(tar).st_uid != 0:
problems.append("a downloaded file keeps a host uid (book 3.1)")
# an unpacked tree belongs to whoever is building it; taking it away
# mid-build would stop that build. Book 3.1 runs before any unpack.
if os.lstat(tree).st_uid != 10753:
problems.append("an unpacked tree was chowned out from under its build")
# running it twice must change nothing
if lfs._normalize_sources(d, run=True, quiet=True) != (0, False):
problems.append("the rule is not idempotent")
# and it must actually be applied where sources appear or survive
g = body[body.index("def cmd_bs_get_sources"):body.index("def _title_pkgver")]
if "_normalize_sources" not in g:
problems.append("get-sources does not apply book 3.1's chown")
r = body[body.index("def cmd_bs_restart"):body.index("def cmd_bs_set_book")]
if "_normalize_sources" not in r:
problems.append("restart does not apply book 3.1's chown")
if problems:
for p in problems: print(" FAIL %s" % p)
sys.exit(1)
print(" PASS /sources is root:root 1777 with root-owned contents (book 3.1)")
PY65C
_count_rc $?
# ---- /sources is scratch, not a shared install directory -------------------- #
# Being in install_dirs_list meant verify --fix set it root:install 775 and
# seal-install-dirs set 1775 -- both drop the o+w of book 3.1's `a+wt`, and
# o+w is the only thing that lets the unprivileged build user unpack there.
# It belongs in never_adopt_list (no package may own it) and nowhere else.
if [ -r "$(dirname "$LFS_TOOL")/lfs-helper" ]; then
HELPER="$(dirname "$LFS_TOOL")/lfs-helper"
else
HELPER=""
fi
if [ -n "$HELPER" ]; then
_p=""
# the DEFAULT list, i.e. the heredoc, must not carry it
if sed -n '/^install_dirs_list()/,/^}/p' "$HELPER" | grep -qx '/sources'; then
_p="$_p;/sources is still a shared install directory"
fi
# but it must still be a directory no package may adopt
if ! sed -n '/^never_adopt_list()/,/^}/p' "$HELPER" | grep -qx '/sources'; then
_p="$_p;/sources may now be adopted by a package"
fi
# an installdirs.lst from an older run must not bring it back
if ! sed -n '/^install_dirs_list()/,/^}/p' "$HELPER" | grep -q 'grep -vx.*/sources'; then
_p="$_p;a stale installdirs.lst can still reintroduce /sources"
fi
# the build scratch is the same kind of thing and needs the same rules
if ! sed -n '/^never_adopt_list()/,/^}/p' "$HELPER" | grep -qx '/build'; then
_p="$_p;/build may be adopted by a package"
fi
if sed -n '/^install_dirs_list()/,/^}/p' "$HELPER" | grep -qx '/build'; then
_p="$_p;/build is a shared install directory"
fi
# every tree scan must skip it -- otherwise each snapshot, ownership pass
# and collector-group map walks every unpacked source tree
if [ "$(grep -c 'not -path "\${\?r}\?/build/\*"' "$HELPER")" -lt 3 ]; then
_p="$_p;a tree scan still descends into the build scratch"
fi
if [ -n "$_p" ]; then
printf '%s\n' "${_p#;}" | tr ';' '\n' | while IFS= read -r m; do
[ -n "$m" ] && bad "$m"
done
else
ok "/sources and /build are scratch, not shared install directories"
fi
else
bad "lfs-helper not found beside $LFS_TOOL"
fi
# ---- snapshots must not read live kernel filesystems ------------------------ #
# Excluding "./sys/*" skips the CONTENTS but still archives the ./sys directory
# itself. Reading a live kernel filesystem's metadata gives
# tar: ./sys: file changed as we read it
# and exit 1, so the snapshot failed -- then succeeded on a retry, because the
# timing happened to work out. --one-file-system does not descend into
# anything mounted inside the tree at all.
python3 - "$LFS_TOOL" <<'PY66'
import sys
src = open(sys.argv[1]).read()
body = src[src.index('cmd = ("tar -C'):]
body = body[:body.index("prog = ")] if "prog = " in body else body[:2000]
problems = []
if "--one-file-system" not in body:
problems.append("tar still descends into mounted filesystems")
# tar's exit 1 is "files differ", a warning on a live tree -- verify, don't die
if "rc == 1" not in body:
problems.append("a tar warning is still treated as a failure")
if "tar -tf" not in body:
problems.append("the archive is not verified before being accepted")
# but an unreadable archive must NOT be kept
if "os.remove(dest)" not in body:
problems.append("a corrupt archive would be left in place")
if problems:
for p in problems: print(" FAIL %s" % p)
sys.exit(1)
print(" PASS snapshots skip live filesystems and verify what they wrote")
PY66
_count_rc $?
# ---- an application user must be usable, not just exist --------------------- #
# `user create` made the account and stopped there: you could not become it,
# its home was world-readable, and nothing launched the program.
pmt="$(dirname "$LFS_TOOL")/packagemanager"
if [ -f "$pmt" ]; then
grep -q "^def _setup_application_user" "$pmt" \
&& ok "creating an application user sets up access to it" \
|| bad "user create leaves an account nobody can use"
su_body="$(sed -n '/^def _setup_application_user/,/^def _home_of/p' "$pmt")"
case "$su_body" in
*'"chmod", "0750", home'*) ok "the account's home is private" ;;
*) bad "the application's home stays world-readable" ;;
esac
case "$su_body" in
*'"usermod", "-a", "-G", u, main'*)
ok "your own login joins the account's group" ;;
*) bad "a shared directory would not be reachable" ;;
esac
# without a main user there is nobody to grant access TO
case "$su_body" in
*"No main user is set"*) ok "it says so when no main user is set" ;;
*) bad "it silently does nothing when main_user is unset" ;;
esac
# the PAM file is not part of stock LFS: absence must not be an error
pam="$(sed -n '/^def _allow_su_via_pam/,/^def _write_launcher/p' "$pmt")"
case "$pam" in
*"not present -- \`su - "*) ok "a missing PAM file is handled, not fatal" ;;
*) bad "it assumes the hint's PAM file exists" ;;
esac
grep -q "_APPLICATION_USER_HELP" "$pmt" \
&& ok "application users have their own help section" \
|| bad "no help explaining what an application user is"
fi
# ---- shell completion -------------------------------------------------------- #
comp="$(dirname "$LFS_TOOL")/lfs-completion.bash"
if [ -f "$comp" ]; then
bash -n "$comp" \
&& ok "the completion script parses" \
|| bad "the completion script has a syntax error"
# subcommands must come from the tools themselves, or the list goes stale
grep -q "_lfs_tool_subcommands" "$comp" \
&& ok "completions are read from each tool's own help" \
|| bad "the completion keeps a second list of commands"
for t in lfs blfs packagemanager lfs-helper; do
grep -q "complete -F _lfs_complete $t\b" "$comp" \
|| bad "no completion registered for $t"
done
ok "all four tools have completion registered"
fi
# ---- a system you can actually log into -------------------------------------- #
# A freshly built system 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" -- discovered after a reboot, with no way in.
#
# It lives in lfs-helper now, as the built-in init-accounts step. It could not
# move to `packagemanager setup` with the rest of the old final step: setup runs
# on the BOOTED system, and you need to log in to run it.
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
_ia="$(_slice_fn "$helper_src" cmd_init_accounts)"
_p=""
case "$_ia" in
*'passwd root'*) ;;
*) _p="$_p;the root password is never set" ;;
esac
case "$_ia" in
*'NO password yet'*) ;;
*) _p="$_p;nothing explains why a root password matters" ;;
esac
# a scripted run has no terminal: it must say what is missing, not hang
case "$_ia" in
*'! -t 0'*) ;;
*) _p="$_p;a non-interactive run would block on a prompt" ;;
esac
case "$_ia" in
*'useradd -m'*) ;;
*) _p="$_p;no login account is ever created" ;;
esac
if [ -n "$_p" ]; then
printf '%s\n' "${_p#;}" | tr ';' '\n' | while IFS= read -r m; do
[ -n "$m" ] && bad "$m"
done
else
ok "the build ends with a system you can log into"
fi
fi
# password detection must read /etc/shadow correctly
shd="$T/shadowcheck"; mkdir -p "$shd"
_haspw() {
grep -qE "^root:[^:]*:" "$1" && ! grep -qE "^root:(\*|!|)?:" "$1"
}
printf 'root::19000:0:99999:7:::\n' > "$shd/empty"
printf 'root:!:19000:0:99999:7:::\n' > "$shd/locked"
printf 'root:$6$a$b:19000:0:99999:7:::\n' > "$shd/set"
if ! _haspw "$shd/empty" && ! _haspw "$shd/locked" && _haspw "$shd/set"; then
ok "an empty, locked or real root password are told apart"
else
bad "root password detection is wrong"
fi
# ---- decisions already made must not be asked again ------------------------- #
# The export copied into the tree said
# dir|/usr/libexec/gcc|nimgnu_gcc
# yet the build still stopped to ask which group should share that directory.
# Two faults: nothing applied the file, and `import-groups --run` read the
# wrong path anyway.
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
ba="$(sed -n '/^cmd_build_all() {/,/^}/p' "$helper_src")"
case "$ba" in
*"cmd_import_groups --run"*)
ok "a copied-in groups export is applied automatically" ;;
*) bad "the export sits unapplied until run by hand" ;;
esac
grep -q "^_groups_already_imported()" "$helper_src" \
&& ok "it is applied once, not on every build" \
|| bad "the import would run on every build-all"
# `--run` used to be taken as the FILENAME, then overwrite the path
ig="$(sed -n '/^cmd_import_groups() {/,/^}/p' "$helper_src")"
case "$ig" in
*'in="$STATE/collector-groups.export"; }'*)
bad "import-groups --run still overwrites the file it found" ;;
*) ok "import-groups --run reads the file it chose" ;;
esac
case "$ig" in
*'local in="" run=0 a'*) ok "the arguments are parsed in one place" ;;
*) bad "the run flag can still be wiped by a later declaration" ;;
esac
# and the prompt itself must consult the export, imported or not
cg="$(sed -n '/^choose_collector_group() {/,/^}/p' "$helper_src")"
case "$cg" in
*'collector-groups.import'*)
ok "the prompt checks the copied-in decisions first" ;;
*) bad "a decision in the tree would still be asked about" ;;
esac
fi
# end to end: the answer is in the file, so nothing is asked
igd="$T/importdec"; mkdir -p "$igd/.lfs-pkgusr" "$igd/etc" "$igd/usr/src"
printf 'root:x:0:\ninstall:x:9999:\n' > "$igd/etc/group"
printf 'root:x:0:0:root:/root:/bin/bash\n' > "$igd/etc/passwd"
printf '# prefix|nimgnu\ndir|/usr/libexec/gcc|nimgnu_gcc\n' \
> "$igd/.lfs-pkgusr/collector-groups.import"
got="$(STATE="$igd/.lfs-pkgusr" awk -F'|' -v d=/usr/libexec/gcc \
'$1 == "dir" && $2 == d { print $3; exit }' \
"$igd/.lfs-pkgusr/collector-groups.import")"
[ "$got" = "nimgnu_gcc" ] \
&& ok "a recorded decision resolves without asking" \
|| bad "the recorded decision was not found (got '$got')"
# ---- 7.2 must actually hand the tree to root -------------------------------- #
# The handover used `chown --from lfs`, which changes only files owned by
# exactly that user. A tree owned by a uid with no matching account -- or by
# an `lfs` whose uid changed since -- was silently left alone, so `prepare`
# reported "give the tree to root", changed nothing, and `enter` then refused.
# The loop in `run` caught it as a step that ran but stayed pending.
python3 - "$LFS_TOOL" <<'PY68'
import sys
src = open(sys.argv[1]).read()
i = src.index('"give the tree to root"')
cmd = src[i:i + 400]
if "--from lfs" in cmd:
print(" FAIL the handover still only matches files owned by 'lfs'")
sys.exit(1)
if "chown -h -R root:root" not in cmd:
print(" FAIL the handover does not chown recursively"); sys.exit(1)
# and the refusal should say who DOES own it
if "_owner_name_of" not in src:
print(" FAIL the refusal does not name the actual owner"); sys.exit(1)
print(" PASS 7.2 hands the tree to root whoever owned it")
PY68
_count_rc $?
# behaviour: a tree owned by an unknown uid must still be handed over
hod="$T/handover"; mkdir -p "$hod/usr/bin" "$hod/etc"
chown -R 4242 "$hod/usr" "$hod/etc" 2>/dev/null || true
if [ "$(stat -c %u "$hod/usr")" = "4242" ]; then
( for d in usr etc; do [ -e "$hod/$d" ] && chown -h -R root:root "$hod/$d"; done; true )
[ "$(stat -c %u "$hod/usr")" = "0" ] \
&& ok "a tree owned by an unknown uid is handed to root" \
|| bad "the handover left the tree owned by $(stat -c %u "$hod/usr")"
else
ok "(cannot test unknown-uid handover as this user)"
fi
# ---- a shared user reaches the session -------------------------------------- #
# A "shared user" is one that belongs to the group on the main user's XDG
# runtime directory:
# drwxrws--- n76310 u_xdg_runtime /tmp/xdg-n76310
# The display socket, the session bus and audio live there, so without that
# group a graphical program starts and then cannot draw anything.
pmt="$(dirname "$LFS_TOOL")/packagemanager"
if [ -f "$pmt" ]; then
grep -q "^def _join_xdg_runtime_group" "$pmt" \
&& ok "a shared user joins the session group" \
|| bad "a shared user cannot reach the display or audio"
jx="$(sed -n '/^def _join_xdg_runtime_group/,/^def _setup_application_user/p' "$pmt")"
# the group must be READ from the directory, never assumed by name
case "$jx" in
*"getgrgid(os.stat(rt).st_gid)"*)
ok "the group is read from the runtime directory" ;;
*) bad "the session group name is hardcoded" ;;
esac
case "$jx" in
*'u_xdg_runtime"'*) bad "a specific group name is hardcoded" ;;
*) ok "no group name is baked in" ;;
esac
# a personal group is not a shared group
case "$jx" in
*"which is not a shared "*)
ok "it refuses to add the user to a personal group" ;;
*) bad "an application user could join the person's own group" ;;
esac
# both /run/user/<uid> and /tmp/xdg-<user> layouts
xd="$(sed -n '/^def _xdg_runtime_dir_of/,/^def _join_xdg/p' "$pmt")"
for p in "/run/user" "/tmp/xdg-"; do
case "$xd" in
*"$p"*) ;;
*) bad "the runtime directory search misses $p" ;;
esac
done
ok "both runtime-directory layouts are searched"
# --shared and --share-dir are different things
grep -q '"--share-dir"' "$pmt" \
&& ok "the session group and a shared directory are separate options" \
|| bad "--shared conflates the session group with a directory"
fi
# ---- the setup must be applicable after the fact ---------------------------- #
# An account made earlier -- or by hand -- should be able to reach the same
# state without deleting and recreating it.
pmt="$(dirname "$LFS_TOOL")/packagemanager"
if [ -f "$pmt" ]; then
grep -q "^def cmd_user_setup" "$pmt" \
&& ok "an existing user can be set up after the fact" \
|| bad "the setup only happens at creation time"
grep -q '"setup"' "$pmt" \
&& ok "`user setup` is registered" \
|| bad "the setup command is not reachable"
# it must refuse on a user that does not exist, rather than half-creating
cs="$(sed -n '/^def cmd_user_setup/,/^def _write_desktop_entry/p' "$pmt")"
case "$cs" in
*"no such user"*) ok "setup refuses on a user that does not exist" ;;
*) bad "setup would act on a missing user" ;;
esac
# the same code path as create, or the two drift
case "$cs" in
*_setup_application_user*) ok "create and setup share one code path" ;;
*) bad "setup reimplements what create does" ;;
esac
# menu entry
grep -q "^def _write_desktop_entry" "$pmt" \
&& ok "a menu entry is written" \
|| bad "no desktop entry is created"
de="$(sed -n '/^def _write_desktop_entry/,/^def _write_launcher/p' "$pmt")"
for field in "Desktop Entry" "Exec=" "StartupWMClass=" "Icon="; do
case "$de" in
*"$field"*) ;;
*) bad "the desktop entry has no $field" ;;
esac
done
ok "the desktop entry has the fields a menu needs"
# a launcher for a program that is not installed is a broken command
sa="$(sed -n '/# 6. a launcher and a menu entry/,/_write_desktop_entry(main, u, app)/p' "$pmt")"
case "$sa" in
*"no launcher or menu entry"*)
ok "nothing is written when the program is not installed" ;;
*) bad "a launcher could point at a program that does not exist" ;;
esac
# and neither file is clobbered if it is already there
case "$de" in
*"already exists -- left alone"*) ok "an existing menu entry is kept" ;;
*) bad "the desktop entry would be overwritten" ;;
esac
fi
# ---- changing LFS_TGT invalidates the toolchain ----------------------------- #
# gcc-pass2 installs its C++ headers under $LFS_TGT. Change LFS_TGT afterwards
# and the compiler looks under the new name while the headers sit under the
# old one -- present, readable, and invisible. It surfaces forty packages
# later as "bits/c++config.h: No such file or directory", which reads like a
# missing file rather than a mismatch.
python3 - "$LFS_TOOL" <<'PY69'
import sys, importlib.machinery as m
lfs = m.SourceFileLoader('lfs', sys.argv[1]).load_module()
src = open(sys.argv[1]).read()
problems = []
if not hasattr(lfs, "_crosschain_tgt_mismatch"):
problems.append("nothing detects a changed target triplet")
# the triplet must be RECORDED with the build, or there is nothing to compare
mk = src[src.index("def _mark_crosschain_done"):]
mk = mk[:mk.index("def lfs_tgt")]
if '"lfs_tgt"' not in mk:
problems.append("the target is not recorded with the build progress")
# and checked before the chroot, not after
if "this tree was built for a different target" not in src:
problems.append("a changed target is not reported")
i_check = src.find("this tree was built for a different target")
i_prep = src.find('if action == "prepare":')
if i_check < i_prep:
problems.append("the check is not inside chroot prepare")
if problems:
for p in problems: print(" FAIL %s" % p)
sys.exit(1)
print(" PASS a changed target triplet is caught before the chroot")
PY69
_count_rc $?
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
grep -q "^_warn_on_triplet_mismatch()" "$helper_src" \
&& ok "the mismatch is also reported inside the chroot" \
|| bad "inside the chroot the mismatch is only a missing-file error"
ba="$(sed -n '/^cmd_build_all() {/,/^}/p' "$helper_src")"
case "$ba" in
*_warn_on_triplet_mismatch*)
ok "it is checked before building, not after failing" ;;
*) bad "the mismatch check never runs" ;;
esac
fi
# ---- three causes, one error message ---------------------------------------- #
# "bits/c++config.h: No such file or directory" is produced by three different
# situations needing three different fixes. The compiler's own configure line
# tells them apart:
# no --host and no --target -> a NATIVE gcc, which only chapter 8 builds:
# its install is half-finished
# both are cross names -> LFS_TGT changed since chapter 6
# no headers at all -> chapter 6 never finished
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
grep -q "^_explain_triplet_mismatch()" "$helper_src" \
&& ok "the cause of a mismatch is named, not just the symptom" \
|| bad "all three causes give the same message"
ex="$(sed -n '/^_explain_triplet_mismatch() {/,/^}/p' "$helper_src")"
case "$ex" in
*"it is a NATIVE build"*)
ok "a native compiler is recognised as chapter 8's" ;;
*) bad "a half-installed chapter-8 gcc is not identified" ;;
esac
case "$ex" in
*"LFS_TGT changed between"*)
ok "two cross names are recognised as a changed LFS_TGT" ;;
*) bad "a changed LFS_TGT is not identified" ;;
esac
# the two cases must give DIFFERENT fixes -- that is the whole point
# the two causes need different fixes -- that is the whole point
case "$ex" in
*"lfs config lfs_tgt"*"--phase all --force"*|*"--phase all --force"*"lfs config lfs_tgt"*)
ok "each cause gets the fix that applies to it" ;;
*) bad "the same fix is offered for different causes" ;;
esac
# and it must say nothing when the names agree
case "$ex" in
*'[ "$have" = "$mine" ] && return 0'*)
ok "it is silent when there is no mismatch" ;;
*) bad "it would explain a mismatch that does not exist" ;;
esac
fi
# ---- installs are staged, not written straight into the system -------------- #
# `make install` writes into the live system one file at a time, in Makefile
# order. Interrupt it and the system is neither the old version nor the new
# one -- and for gcc that is unrecoverable: it installs its driver early and
# its C++ headers late, so a cancel in between leaves a compiler that cannot
# compile C++, which is what you need to rebuild it.
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
grep -q "^_merge_stage()" "$helper_src" \
&& ok "installs go to a staging tree first" \
|| bad "installs still write straight into the live system"
grep -q "DESTDIR='\$_stage'" "$helper_src" \
&& ok "DESTDIR points the install at the staging tree" \
|| bad "the staging tree is created but never used"
# a FAILED install must leave the system untouched -- the whole point
cb="$(sed -n '/^cmd_build() {/,/^}/p' "$helper_src")"
case "$cb" in
*'if [ "${rc:-0}" = 0 ]; then'*'_merge_stage'*)
ok "the staged tree is moved in only on success" ;;
*) bad "a failed install could still be merged" ;;
esac
case "$cb" in
*"the system was not touched"*)
ok "a failed install says the system is unchanged" ;;
*) bad "a failed install does not say what state things are in" ;;
esac
# a package that ignores DESTDIR writes to the system anyway: say so
ms="$(sed -n '/^_merge_stage() {/,/^}/p' "$helper_src")"
case "$ms" in
*"return 2"*) ok "an empty staging tree is detected" ;;
*) bad "a package ignoring DESTDIR would look like a clean install" ;;
esac
case "$cb" in
*"ignores DESTDIR"*) ok "and reported, not silently trusted" ;;
*) bad "ignoring DESTDIR is not reported" ;;
esac
# merging must target the tracked root, not a hardcoded /
# the installer, not the merge wrapper, is what writes
it2="$(sed -n '/^_install_staged_tree() {/,/^}/p' "$helper_src")"
case "$it2" in
*'root="${SNAP_ROOT:-/}"'*) ok "the merge targets the tracked root" ;;
*) bad "the merge writes to a hardcoded /" ;;
esac
# the workarounds this replaces must be gone
grep -q "cmd_fix_toolchain" "$helper_src" \
&& bad "the symlink workaround is still present" \
|| ok "the triplet symlink workaround is gone"
ba="$(sed -n '/^cmd_build_all() {/,/^}/p' "$helper_src")"
case "$ba" in
*"Finishing its install before going on"*)
bad "the auto-finish workaround is still present" ;;
*) ok "the auto-finish workaround is gone" ;;
esac
fi
# ---- a shared user must know where the session is --------------------------- #
# Being in the session group is necessary but not sufficient: XDG_RUNTIME_DIR,
# WAYLAND_DISPLAY and the package-user PATH all come from
# /etc/pkgusr/skel-u_xdg/.bash_profile. Without it the account can reach the
# socket and still not find it.
pmt="$(dirname "$LFS_TOOL")/packagemanager"
if [ -f "$pmt" ]; then
grep -q "^def _link_xdg_profile" "$pmt" \
&& ok "a shared user gets the XDG profile" \
|| bad "a shared user has no XDG_RUNTIME_DIR"
lp="$(sed -n '/^def _link_xdg_profile/,/^def _setup_application_user/p' "$pmt")"
# a symlink, so editing the file once reaches every shared account
case "$lp" in
*"os.symlink(XDG_SKEL, target)"*) ok "it is linked, not copied" ;;
*) bad "each account gets its own copy, which then drift" ;;
esac
# the runtime directory belongs to whoever is logged in: fill it in, do
# not ship someone else's path
case "$lp" in
*"@XDG_RUNTIME_DIR@"*) ok "the runtime directory is filled in, not shipped" ;;
*) bad "a hardcoded runtime directory would be installed" ;;
esac
case "$lp" in
*"kept the old one as"*) ok "an existing profile is kept, not destroyed" ;;
*) bad "the user's own .bash_profile would be overwritten" ;;
esac
# and it must be wired into the shared path
sa="$(sed -n '/# 4. the session/,/# 5. a shared directory/p' "$pmt")"
case "$sa" in
*_link_xdg_profile*) ok "--shared links the profile as well as the group" ;;
*) bad "the profile is never linked" ;;
esac
fi
# the file must ship, and the Makefile must install it without clobbering
skel="$(dirname "$LFS_TOOL")/skel-u_xdg/.bash_profile"
[ -f "$skel" ] \
&& ok "the XDG profile ships with the tools" \
|| bad "the XDG profile is not shipped"
mkf="$(dirname "$LFS_TOOL")/Makefile"
if [ -f "$mkf" ]; then
grep -q "skel-u_xdg" "$mkf" \
&& ok "make install installs it" \
|| bad "make install does not install the XDG profile"
grep -q "kept your existing .bash_profile" "$mkf" \
&& ok "reinstalling does not overwrite your edits" \
|| bad "a reinstall would clobber the XDG profile"
fi
# ---- a snapshot must say what state it captured ----------------------------- #
# Restoring a snapshot named "chrosscompile_done" brought back a tree whose
# toolchain was ALREADY broken -- the snapshot had been taken after chapter 8
# damaged it. Nothing said so, at save or at restore, so it looked like the
# restore had failed rather than the snapshot being bad.
python3 - "$LFS_TOOL" <<'PY70'
import sys, os, json, tempfile, importlib.machinery as m
store = tempfile.mkdtemp(); os.environ["LFS_STORE"] = store
os.makedirs(os.path.join(store, "books"), exist_ok=True)
json.dump({"default": "12.4", "lfs_tgt": "x86_64-nimgnu-linux-gnu"},
open(os.path.join(store, "config.json"), "w"))
lfs = m.SourceFileLoader('lfs', sys.argv[1]).load_module()
if not hasattr(lfs, "_toolchain_state"):
print(" FAIL a snapshot does not record the toolchain state"); sys.exit(1)
def tree(headers=None, native=False):
t = tempfile.mkdtemp()
if headers:
p = os.path.join(t, "usr", "include", "c++", "15.2.0", headers, "bits")
os.makedirs(p)
open(os.path.join(p, "c++config.h"), "w").close()
if native:
os.makedirs(os.path.join(t, "usr", "lib", "gcc", "x86_64-pc-linux-gnu"))
return t
problems = []
if not lfs._toolchain_state(tree("x86_64-nimgnu-linux-gnu")).startswith("ok"):
problems.append("a clean chapter-6 tree is not recognised")
if not lfs._toolchain_state(tree("x86_64-nimgnu-linux-gnu", native=True)) \
.startswith("BROKEN"):
problems.append("a native gcc over the cross one is not detected")
if "unfinished" not in lfs._toolchain_state(tree()):
problems.append("a tree with no C++ headers is not detected")
# headers under a different target than configured
st = lfs._toolchain_state(tree("x86_64-lfs-linux-gnu"))
if not st.startswith("BROKEN"):
problems.append("headers under the wrong target are not detected")
src = open(sys.argv[1]).read()
if "Restoring it later will restore the breakage too" not in src:
problems.append("saving a broken tree gives no warning")
if problems:
for p in problems: print(" FAIL %s" % p)
sys.exit(1)
print(" PASS a snapshot records and reports the state it captured")
PY70
_count_rc $?
# ---- a snapshot label must not hide what it captured ------------------------ #
# "chapters 5-6: 22/22 steps" was all a snapshot recorded, so a tree where
# chapter 8 had already run -- and already broken the toolchain -- looked
# pristine. Restoring it restored the damage, and it read as a failed restore
# rather than a bad snapshot.
python3 - "$LFS_TOOL" <<'PY71'
import sys, os, json, tempfile, importlib.machinery as m
store = tempfile.mkdtemp(); os.environ["LFS_STORE"] = store
os.makedirs(os.path.join(store, "books"), exist_ok=True)
json.dump({"default": "12.4", "lfs_tgt": "x86_64-nimgnu-linux-gnu"},
open(os.path.join(store, "config.json"), "w"))
lfs = m.SourceFileLoader('lfs', sys.argv[1]).load_module()
problems = []
if not hasattr(lfs, "_tree_state"):
problems.append("nothing reports the state of a tree")
if not hasattr(lfs, "cmd_bs_verify"):
problems.append("there is no way to ask what state a tree is in")
t = tempfile.mkdtemp()
os.makedirs(os.path.join(t, "usr", "src", "lfs-pkgusr", "progress"))
os.makedirs(os.path.join(t, "usr", "src", "zlib"))
os.makedirs(os.path.join(t, "usr", "lib", "gcc", "x86_64-pc-linux-gnu"))
p = os.path.join(t, "usr", "include", "c++", "15.2.0",
"x86_64-nimgnu-linux-gnu", "bits")
os.makedirs(p); open(os.path.join(p, "c++config.h"), "w").close()
names = [n for n, _s, _t in lfs.CROSSCHAIN_STEPS]
json.dump({"book": "12.4", "done": names, "lfs_tgt": "x86_64-nimgnu-linux-gnu"},
open(os.path.join(t, "usr", "src", "lfs-pkgusr", "progress", "crosschain-progress.json"), "w"))
open(os.path.join(t, "usr", "src", "lfs-pkgusr", "progress", "steps-built"), "w").write("zlib\n")
state = dict(lfs._tree_state(t))
# chapters 5-6 alone is not the whole story
if "chapters 7-9" not in state:
problems.append("chapter 7-9 progress is not reported")
if "WARNING" not in state:
problems.append("a native gcc in the tree is not flagged")
if not state.get("toolchain", "").startswith("BROKEN"):
problems.append("the toolchain state is not reported")
# and the snapshot must carry it
src = open(sys.argv[1]).read()
if '"state":' not in src:
problems.append("the snapshot metadata does not record the tree state")
if problems:
for p2 in problems: print(" FAIL %s" % p2)
sys.exit(1)
print(" PASS a tree's real state is reported and recorded with snapshots")
PY71
_count_rc $?
# ---- staged installs must cope with the merged-/usr symlinks ---------------- #
# Book 4.2 makes /bin -> usr/bin, /sbin -> usr/sbin, /lib -> usr/lib. A
# package installing into /bin produces a REAL bin/ directory in the staging
# tree, and copying that over the symlink fails:
# cp: cannot overwrite non-directory '/bin' with directory '.../bin'
# That broke every package installing into those paths -- most of chapter 7 --
# on a completely fresh build.
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
grep -q "^_resolve_staged_symlink_dirs()" "$helper_src" \
&& ok "staged directories are folded onto the tree's symlinks" \
|| bad "a package installing into /bin cannot be merged"
ms="$(sed -n '/^_merge_stage() {/,/^}/p' "$helper_src")"
i_fold="$(printf '%s' "$ms" | grep -n "_resolve_staged_symlink_dirs" | head -1 | cut -d: -f1)"
i_cp="$(printf '%s' "$ms" | grep -n '_install_staged_tree' | head -1 | cut -d: -f1)"
if [ -n "$i_fold" ] && [ -n "$i_cp" ] && [ "$i_fold" -lt "$i_cp" ]; then
ok "the folding happens before the copy"
else
bad "the copy runs first and still fails on the symlinks"
fi
# driven by the live tree, not a hardcoded list of names
rs="$(sed -n '/^_resolve_staged_symlink_dirs() {/,/^}/p' "$helper_src")"
case "$rs" in
*'[ -L "$live" ] || continue'*)
ok "it folds whatever the tree actually has as a symlink" ;;
*) bad "the symlink names are hardcoded" ;;
esac
# behaviour: bin/ and sbin/ fold, usr/bin/ is left alone
sd="$T/stagedirs"; mkdir -p "$sd/usr/bin" "$sd/usr/sbin"
ln -sf usr/bin "$sd/bin"; ln -sf usr/sbin "$sd/sbin"
mkdir -p "$sd/stage/bin" "$sd/stage/usr/bin"
echo m > "$sd/stage/bin/mount"; echo w > "$sd/stage/usr/bin/wall"
( SNAP_ROOT="$sd"; say() { :; }
eval "$rs"
_resolve_staged_symlink_dirs "$sd/stage" ) >/dev/null 2>&1
if [ -f "$sd/stage/usr/bin/mount" ] && [ ! -d "$sd/stage/bin" ] \
&& [ -f "$sd/stage/usr/bin/wall" ]; then
ok "bin/ is folded into usr/bin/ and nothing else is disturbed"
else
bad "the folding did not produce the expected staging tree"
fi
fi
# ---- replacing a library a running system is using -------------------------- #
# `cp -a` opens the destination with O_TRUNC and writes. For a shared library
# that is currently mapped that is fatal: every running process sees the file
# shrink under it. Merging glibc that way killed the shell mid-install --
# /usr/bin/env: error while loading shared libraries:
# /usr/lib/libc.so.6: file too short
# -- and the chroot could not start any program afterwards.
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
grep -q "^_install_staged_tree()" "$helper_src" \
&& ok "staged files are installed one at a time" \
|| bad "the merge still overwrites files in place"
it="$(sed -n '/^_install_staged_tree() {/,/^}/p' "$helper_src")"
case "$it" in
*'mv -f "$tmp" "$dest"'*)
ok "each file is renamed into place, never truncated" ;;
*) bad "files are still written over in place" ;;
esac
# the temp file must be in the SAME directory, or mv is a copy, not a rename
case "$it" in
*'tmp="$dest.pkgusr-new.$$"'*)
ok "the temporary is beside its destination, so mv is atomic" ;;
*) bad "the temporary is elsewhere and mv would copy" ;;
esac
ms="$(sed -n '/^_merge_stage() {/,/^}/p' "$helper_src")"
case "$ms" in
*'cp -a "$stage"/. '*) bad "the wholesale cp -a is still there" ;;
*) ok "the wholesale copy is gone" ;;
esac
# behaviour: replacing a file a process holds open must not disturb it
ad="$T/atomic"; mkdir -p "$ad/usr/lib" "$ad/stage/usr/lib"
printf 'OLDOLDOLD' > "$ad/usr/lib/lib.so"
printf 'NEWNEWNEWNEW' > "$ad/stage/usr/lib/lib.so"
before_ino="$(stat -c %i "$ad/usr/lib/lib.so")"
( SNAP_ROOT="$ad"; say() { :; }; warn() { :; }
eval "$it"
_install_staged_tree "$ad/stage" ) >/dev/null 2>&1
after_ino="$(stat -c %i "$ad/usr/lib/lib.so")"
if [ "$before_ino" != "$after_ino" ] \
&& [ "$(cat "$ad/usr/lib/lib.so")" = "NEWNEWNEWNEW" ]; then
ok "the replacement is a new inode, so open files keep the old one"
else
bad "the file was overwritten in place (inode unchanged)"
fi
fi
# ---- staging is not worth paying for on every package ----------------------- #
# Staging costs roughly an extra copy of everything installed -- measurably
# slower on a big package. It is worth it only where an interrupted install
# cannot be recovered from: interrupt man-pages and you rebuild man-pages;
# interrupt gcc or glibc and there is no compiler and no libc left to rebuild
# them WITH.
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
grep -q "^STAGE_PACKAGES_DEFAULT=" "$helper_src" \
&& ok "only the packages that cannot be recovered are staged" \
|| bad "every package pays the staging cost"
for p in gcc glibc binutils bash coreutils; do
grep -q "^STAGE_PACKAGES_DEFAULT=.*$p" "$helper_src" \
|| bad "$p is not staged, but an interrupted install would be fatal"
done
ok "the toolchain packages are all in the default list"
ss="$(sed -n '/^_should_stage() {/,/^}/p' "$helper_src")"
# -tmp and -passN are the same package at an earlier stage
case "$ss" in
*'base="${name%-tmp}"'*) ok "the -tmp and -passN variants count too" ;;
*) bad "gcc-pass1 would not be staged" ;;
esac
case "$ss" in
*"LFS_STAGE_PACKAGES"*) ok "the list is configurable" ;;
*) bad "the list is hardcoded" ;;
esac
case "$ss" in
*"all) return 0"*"none) return 1"*) ok "all / none are accepted" ;;
*) bad "there is no way to turn staging fully on or off" ;;
esac
cb="$(sed -n '/^cmd_build() {/,/^}/p' "$helper_src")"
case "$cb" in
*"--stage)"*"--no-stage)"*) ok "a single build can override it" ;;
*) bad "staging cannot be forced or skipped per build" ;;
esac
# behaviour
( STAGE_PACKAGES_DEFAULT="$(sed -n 's/^STAGE_PACKAGES_DEFAULT=\"\(.*\)\"$/\1/p' \
"$helper_src" | head -1)"
eval "$ss"
_should_stage gcc && _should_stage glibc && _should_stage gcc-pass2 \
&& ! _should_stage man-pages && ! _should_stage zlib ) \
&& ok "gcc and glibc stage; man-pages and zlib do not" \
|| bad "the wrong packages are being staged"
fi
# ---- the staging directory must belong to whoever builds -------------------- #
# It is created by root, but the build runs as the PACKAGE USER, so
# `make install DESTDIR=...` could not write into it:
# mkdir: cannot create directory '/.lfs-pkgusr/stage/gcc': Permission denied
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
cb="$(sed -n '/^cmd_build() {/,/^}/p' "$helper_src")"
case "$cb" in
*'chown "$owner:$owner" "$_stage"'*)
ok "the staging directory is given to the package user" ;;
*) bad "the package user cannot write into its own staging directory" ;;
esac
# and the parent must be traversable, or the chown alone is not enough
case "$cb" in
*'chmod 755 "$(dirname "$_stage")"'*)
ok "the staging parent is traversable" ;;
*) bad "the package user cannot reach into the staging directory" ;;
esac
# a root step needs no chown -- there is no package user to give it to
case "$cb" in
*'[ "$as_root" != 1 ] && user_exists "$owner"'*)
ok "a root step is not given away to a user that may not exist" ;;
*) bad "the chown would fail on a root step" ;;
esac
# the owner must be known BEFORE the stage is made, or it chowns to nothing
i_owner="$(printf '%s' "$cb" | grep -n 'owner="\$(pkg_owner_name' | head -1 | cut -d: -f1)"
i_stage="$(printf '%s' "$cb" | grep -n '_stage="\$(_stage_dir_for' | head -1 | cut -d: -f1)"
if [ -n "$i_owner" ] && [ -n "$i_stage" ] && [ "$i_owner" -lt "$i_stage" ]; then
ok "the owner is resolved before the staging directory is made"
else
bad "the staging directory is created before the owner is known"
fi
fi
# ---- a package must own the files its earlier stages installed -------------- #
# The same 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 keep the earlier owner, 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
# One cause, two chapters -- so one mechanism, applied to every package.
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
grep -q "^_claim_earlier_stages()" "$helper_src" \
&& ok "a package claims the files its earlier stages installed" \
|| bad "a rebuild cannot overwrite its own earlier files"
ce="$(sed -n '/^_claim_earlier_stages() {/,/^}/p' "$helper_src")"
# every naming shape the same package takes across the book
# the stages are built from a loop now, so check the suffixes it appends
case "$ce" in
*'"$stem-tmp" "$stem-pass1" "$stem-pass2"'*)
ok "the -tmp and -passN stages are all claimed" ;;
*) bad "an earlier stage suffix is missing" ;;
esac
# ONLY what the build left behind -- never another package's file
case "$ce" in
*"root|lfs|UNKNOWN) ;;"*)
ok "only root- and lfs-owned paths are claimed" ;;
*) bad "it could take a file belonging to another package" ;;
esac
# directories too, or the package cannot create files in them
case "$ce" in
*'.dirs'*) ok "the directories are claimed as well as the files" ;;
*) bad "the package could not create files in its own directories" ;;
esac
# shared install dirs stay shared
case "$ce" in
*"is_install_dir"*) ok "shared install directories are left alone" ;;
*) bad "a shared directory could be taken by one package" ;;
esac
# and it must run BEFORE the build, not after it fails
cb="$(sed -n '/^cmd_build() {/,/^}/p' "$helper_src")"
i_claim="$(printf '%s' "$cb" | grep -n '_claim_earlier_stages' | head -1 | cut -d: -f1)"
i_build="$(printf '%s' "$cb" | grep -n 'building \$name' | head -1 | cut -d: -f1)"
if [ -n "$i_claim" ] && [ -n "$i_build" ] && [ "$i_claim" -lt "$i_build" ]; then
ok "the claim happens before the build starts"
else
bad "the claim runs too late to help"
fi
fi
# ---- checks that silently always passed --------------------------------------#
# From an independent review of `lfs` against the book. Each of these hid a
# real failure rather than reporting it.
python3 - "$LFS_TOOL" <<'PY73'
import sys, importlib.machinery as m
lfs = m.SourceFileLoader('lfs', sys.argv[1]).load_module()
src = open(sys.argv[1]).read()
problems = []
# md5 verification: `md5sum -c … | grep -v ": OK"` returned 0 on a CORRUPTED
# tree -- grep inverts the verdict and the pipe discards md5sum's status.
i = src.index("verifying md5sums")
seg = src[i:i + 900]
# ignore comment lines: the fix is documented with the broken command quoted
code = "\n".join(l for l in seg.splitlines() if not l.lstrip().startswith("#"))
if 'grep -v ": OK"' in code:
problems.append("md5 verification still inverts its own verdict")
if "md5sum -c md5sums --quiet" not in seg or "sys.exit(1)" not in seg:
problems.append("a bad md5sum does not fail the download")
# The book's toolchain gate must stay a gate. These five greps MUST produce
# output -- no match means the build is linked against the host -- and the
# blanket `|| true` turned the only real check into a guaranteed pass.
gates = [
'grep -E -o "/usr/lib.*/S?crt[1in].*succeeded" dummy.log',
'grep -B3 "^ /usr/include" dummy.log',
"grep 'SEARCH.*/usr/lib' dummy.log",
'grep "/lib.*/libc.so.6 " dummy.log',
'grep found dummy.log',
]
out = lfs._soften_diagnostics("\n".join(gates))
for line in out.splitlines():
if "|| true" in line:
problems.append("a toolchain gate is still softened: %s" % line[:40])
# but an ordinary diagnostic grep must STILL be softened
if "|| true" not in lfs._soften_diagnostics("grep something optional.txt"):
problems.append("ordinary diagnostic greps are no longer softened")
# 9.9's `cat > /etc/shells` block is <pre class="root">, not "userinput", so
# cfg_shells resolved to zero commands and /etc/shells was never written.
pi = src.index("classes = tag.get(\"class\") or []")
if '"root", "install"' not in src[pi:pi + 700]:
problems.append("only 'userinput' blocks are parsed; /etc/shells is missed")
# the accepted-class tuple itself must not include "screen" (output samples)
accept = src[src.index("any(c in classes for c in ("):][:120]
if "screen" in accept:
problems.append("'screen' blocks (output samples) would be run as commands")
# a step that produces nothing must say so
if "has no command blocks in this book" not in src:
problems.append("a step resolving to zero blocks is still skipped silently")
# book 4.4's ~/.bashrc opens with these; without them the build runs with
# bash's command hash on and root's umask
env = lfs._lfs_env_prefix("/mnt/lfs")
if not env.startswith("set +h\numask 022\n"):
problems.append("the build environment is missing set +h / umask 022")
# ^ anchored to the whole string, so a later line never matched
if not (lfs._TEST_DIAG_RE.flags & 8):
problems.append("_TEST_DIAG_RE has no re.M; ^ only matches the first line")
# dead duplicate flagged by pyflakes
if src.count("\ndef _pkg_glob_for") != 1:
problems.append("_pkg_glob_for is defined more than once")
if problems:
for p in problems: print(" FAIL %s" % p)
sys.exit(1)
print(" PASS the checks that always passed now actually check")
PY73
_count_rc $?
# ---- a staged install must not take the shared install directories ---------- #
# 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 -- the whole install-directory scheme, undone by one
# package. Verified: root:install 775 became gcc:gcc 755.
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
it="$(sed -n '/^_install_staged_tree() {/,/^}/p' "$helper_src")"
case "$it" in
*"ALREADY THERE -- leave its ownership and mode alone"*)
ok "an existing directory keeps its owner and mode" ;;
*) bad "a staged install still restamps existing directories" ;;
esac
# behaviour: existing dirs untouched, new ones owned by the package
sd="$T/stageown"; mkdir -p "$sd/usr/bin" "$sd/stage/usr/bin" "$sd/stage/usr/lib/fresh"
chmod 775 "$sd/usr" "$sd/usr/bin"
before_usr="$(stat -c %a "$sd/usr")"
echo p > "$sd/stage/usr/bin/p"; echo l > "$sd/stage/usr/lib/fresh/l"
chmod -R 755 "$sd/stage"
( SNAP_ROOT="$sd"; say() { :; }; warn() { :; }
eval "$it"; _install_staged_tree "$sd/stage" ) >/dev/null 2>&1
if [ "$(stat -c %a "$sd/usr")" = "$before_usr" ] \
&& [ -f "$sd/usr/bin/p" ] && [ -f "$sd/usr/lib/fresh/l" ]; then
ok "existing modes survive and the files still install"
else
bad "the staged merge changed an existing directory's mode"
fi
fi
# ---- book 7.6 truncates the user database ----------------------------------- #
# `cat > /etc/passwd` is right the first time and catastrophic the second: it
# wipes every package user, private group, the install group, every collector
# group and every membership. The files on disk then all read as UNKNOWN.
if [ -f "$helper_src" ]; then
grep -q "^_protect_user_db()" "$helper_src" \
&& ok "the user database is protected across 7.6" \
|| bad "re-running init-files would wipe every package user"
grep -q "^_step_rewrites_user_db()" "$helper_src" \
&& ok "steps that rewrite passwd/group are detected" \
|| bad "the protection is not tied to the steps that need it"
# the detector must not fire on scripts that merely READ the files
dt="$(sed -n '/^_step_rewrites_user_db() {/,/^}/p' "$helper_src")"
rd="$T/dbdetect"; mkdir -p "$rd"
printf 'grep root /etc/passwd\n' > "$rd/read.sh"
printf 'cat > /etc/passwd << "EOF"\nroot:x:0:0::/root:/bin/bash\nEOF\n' > "$rd/write.sh"
( eval "$dt"
_step_rewrites_user_db "$rd/write.sh" && ! _step_rewrites_user_db "$rd/read.sh" ) \
&& ok "it fires on a rewrite, not on a read" \
|| bad "the user-database detector is wrong"
cb="$(sed -n '/^cmd_build() {/,/^}/p' "$helper_src")"
case "$cb" in
*_protect_user_db*_restore_user_db*)
ok "entries are saved before the step and put back after" ;;
*) bad "the protection is never applied around the build" ;;
esac
fi
# ---- two accounts sharing one uid ------------------------------------------- #
# Ownership is stored as a NUMBER. Give two names the same uid and every tool
# resolves it to whichever name comes first -- ncurses' binaries reported as
# /usr/bin/tic -rwxr-xr-x xz:xz
# and the ncurses build could not overwrite its own files. It reads exactly
# like a permissions bug and is not one.
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
grep -q "^_check_duplicate_ids()" "$helper_src" \
&& ok "duplicate ids are detected and named" \
|| bad "a uid collision would look like a permissions bug forever"
ba="$(sed -n '/^cmd_build_all() {/,/^}/p' "$helper_src")"
case "$ba" in
*_check_duplicate_ids*) ok "the check runs before anything is built" ;;
*) bad "the collision check never runs" ;;
esac
# restoring entries after book 7.6 must not CREATE a collision
rd="$(sed -n '/^_restore_user_db() {/,/^}/p' "$helper_src")"
case "$rd" in
*'is now used by'*)
ok "an entry whose id was reassigned is not restored" ;;
*) bad "restoring after 7.6 could create the collision itself" ;;
esac
# behaviour: the detector finds a real collision and ignores a clean file
dd="$T/dupids"; mkdir -p "$dd"
printf 'root:x:0:0::/root:/bin/bash\nxz:x:10005:10005::/x:/bin/bash\nncurses:x:10005:10005::/n:/bin/bash\n' > "$dd/bad"
printf 'root:x:0:0::/root:/bin/bash\nxz:x:10005:10005::/x:/bin/bash\n' > "$dd/good"
du="$(sed -n '/^_duplicate_ids() {/,/^}/p' "$helper_src")"
( eval "$du"
[ -n "$(_duplicate_ids "$dd/bad")" ] && [ -z "$(_duplicate_ids "$dd/good")" ] ) \
&& ok "it finds a real collision and stays quiet on a clean file" \
|| bad "the duplicate-id detector is wrong"
fi
# ---- one reconcile pass instead of eight repair commands -------------------- #
# fix-ownership, adopt-dirs, adopt-existing, fix-orphans, fix-users,
# sort-users, seal-install-dirs and claim-earlier-stages each fixed ONE symptom
# and could not see a problem outside its own remit -- so a staged install
# quietly handing /usr to a package user went unnoticed through four later
# failures. Ownership is derived state: the manifests, the install-dir list
# and the collector map fully determine what the tree should be.
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
grep -q "^cmd_verify()" "$helper_src" \
&& ok "one command reconciles the tree" \
|| bad "there is no single check of the whole model"
# The commands it replaces must not be SECOND IMPLEMENTATIONS -- but they
# must still be callable. Deleting them outright broke every message that
# tells people to run them:
# !! unknown command 'adopt-existing'
for c in fix-ownership fix-orphans fix-users adopt-existing; do
grep -qE "^\s+.*\b$c[|)]" "$helper_src" \
|| bad "$c is referenced in the hints but not dispatched"
done
ok "the familiar repair names still work"
# ... and route to verify rather than repeating its logic
disp="$(sed -n '/fix-ownership|fix-orphans/,/cmd_verify/p' "$helper_src")"
case "$disp" in
*cmd_verify*) ok "they are aliases for verify, not second copies" ;;
*) bad "a repair command has its own implementation again" ;;
esac
# every rule the model needs
for r in _vfy_manifest_ownership _vfy_install_dirs _vfy_orphans \
_vfy_duplicate_ids _vfy_user_order; do
grep -q "^$r()" "$helper_src" || bad "verify has no $r rule"
done
ok "verify checks ownership, install dirs, orphans, ids and order"
# reporting by default, repairing only when asked
cv="$(sed -n '/^cmd_verify() {/,/^}/p' "$helper_src")"
case "$cv" in
*'"${1:-}" = "--fix" ] && fix=1'*|*"--fix|--run) fix=1"*)
ok "verify reports unless asked to repair" ;;
*) bad "verify repairs without being asked" ;;
esac
# manifest ownership must settle before orphans, or a file whose uid no
# longer resolves is handed to 'lfs' instead of to its package
i_own="$(printf '%s' "$cv" | grep -n '_vfy_manifest_ownership' | head -1 | cut -d: -f1)"
i_orp="$(printf '%s' "$cv" | grep -n '_vfy_orphans' | head -1 | cut -d: -f1)"
if [ -n "$i_own" ] && [ -n "$i_orp" ] && [ "$i_own" -lt "$i_orp" ]; then
ok "manifest ownership is settled before orphans are claimed"
else
bad "an orphan pass could take a file that belongs to a package"
fi
# A1 end to end: the install dirs are checked, so a staged install taking
# /usr is caught rather than surfacing four failures later
vd="$T/verifydirs"; mkdir -p "$vd/usr/bin" "$vd/.lfs-pkgusr/manifests"
chmod 755 "$vd/usr" "$vd/usr/bin"
id_out="$( ( SNAP_ROOT="$vd"; ETC="$vd/etc"; INSTALLDIRS=""
MANIFESTS="$vd/.lfs-pkgusr/manifests"
say() { echo "$*"; }; warn() { echo "$*"; }
_vfy_n_checked=0; _vfy_n_wrong=0; _vfy_n_fixed=0
INSTALL_GID=9999
for _f in install_dirs_list _install_dirs_are_sealed _vfy_report \
set_install_dir_owner install_dir_owner_ok _vfy_install_dirs; do
eval "$(_slice_fn "$helper_src" "$_f")"
done
# the pass is gated on the ownership epoch now: before book 7.6 there
# is no user database, so `install` is not a name and reporting 27
# wrong directories would be noise. This fixture is past that.
ownership_established() { return 0; }
ownership_possible() { return 0; }
_vfy_install_dirs 0 ) 2>&1 )"
case "$id_out" in
*"root:install"*) ok "a wrongly-owned install directory is reported" ;;
*) bad "verify does not notice install dirs losing the install group" ;;
esac
fi
# ---- ownership is derived state, so it can be checked -------------------------#
# The manifests say who installed what, the install-dir list says what is
# shared, the collector map says which groups govern which directories.
# Together they determine what the tree SHOULD look like -- so a discrepancy
# can be found before it becomes a failed build, instead of being chased one
# symptom at a time through fix-ownership, adopt-dirs, fix-perms, fix-orphans
# and claim-earlier-stages.
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
grep -q "^cmd_verify()" "$helper_src" \
&& ok "the tree can be checked against the manifests" \
|| bad "there is no way to ask whether ownership is correct"
cv="$(sed -n '/^cmd_verify() {/,/^}/p' "$helper_src")"
# the A1 case: an install directory taken by a package user
# The wording lives in the _vfy_* helpers now -- cmd_verify calls them
# rather than carrying its own copy of each rule. Assert the check runs.
case "$cv" in
*_vfy_install_dirs*)
ok "it catches an install directory taken by a package" ;;
*) bad "the install-directory scheme is not checked" ;;
esac
_vfy_install_dirs_body="$(_slice_fn "$helper_src" _vfy_install_dirs)"
case "$_vfy_install_dirs_body" in
*'"root:install $want_mode"'*) ;;
*) bad "the install-dir check no longer says what it wants" ;;
esac
case "$cv" in
*_vfy_manifest_ownership*)
ok "it catches files owned by the wrong package" ;;
*) bad "mis-owned files are not detected" ;;
esac
case "$cv" in
*_check_duplicate_ids*) ok "it checks for id collisions too" ;;
*) bad "a uid collision would not be reported" ;;
esac
# report by default, repair only when asked
case "$cv" in
*'[ "${1:-}" = "--fix" ] && fix=1'*)
ok "it reports by default and repairs only with --fix" ;;
*) bad "verify changes things without being asked" ;;
esac
# a collision is NOT auto-repairable -- which name owns what is a decision
case "$cv" in
*"verify cannot repair this"*)
ok "it does not guess at an id collision" ;;
*) bad "verify would silently reassign ids" ;;
esac
fi
# ---- durable records and scratch must be distinguishable ---------------------#
# $STATE mixes records that ARE the ownership (manifests, collector map) with
# pure scratch (staging trees, logs). A snapshot or a restart had to guess.
if [ -f "$helper_src" ]; then
grep -q "^STATE_DURABLE=" "$helper_src" \
&& ok "the durable records are named" \
|| bad "nothing says which state must survive"
grep -q "^STATE_SCRATCH=" "$helper_src" \
&& ok "the scratch is named" \
|| bad "nothing says which state is disposable"
grep -q "manifests" <<< "$(sed -n '/^STATE_DURABLE=/,/^STATE_SCRATCH=/p' "$helper_src")" \
&& ok "the manifests count as durable" \
|| bad "the manifests could be cleaned away"
grep -q "^STATE_FORMAT=" "$helper_src" \
&& ok "the state format is versioned" \
|| bad "an incompatible state directory would be misread"
cs="$(sed -n '/^cmd_clean_state() {/,/^}/p' "$helper_src")"
case "$cs" in
*'rm -rf "${STATE:?}/$d"'*)
ok "cleaning scratch cannot expand to rm -rf /" ;;
*) bad "an unset STATE would delete the root" ;;
esac
fi
# ---- the suite's own count must be trustworthy ------------------------------ #
# ok/bad used shell variables, and several checks call them from inside a
# subshell -- `( ... ) && ok || bad`, a pipeline, a `while read` fed by process
# substitution. An increment there dies with the subshell, so the suite
# printed 446 PASS lines and reported 433. A wrong count is worse than none:
# it hides a regression behind a number that still looks healthy.
cnt="$T/countcheck"; mkdir -p "$cnt"
( _COUNTDIR="$cnt"; : > "$cnt/pass"; : > "$cnt/fail"
ok() { echo x >> "$_COUNTDIR/pass"; }
# the case that used to be lost: incremented inside a subshell
( ok ) ; ( true && ok ) ; echo | while read -r _; do ok; done )
n="$(wc -l < "$cnt/pass" | tr -d ' ')"
[ "$n" = 3 ] \
&& ok "counts survive subshells, pipelines and process substitution" \
|| bad "the counter loses $((3 - n)) of 3 increments from subshells"
# and every assertion must be counted exactly once
_asserts="$(grep -cE '^\s*(ok|bad) "' "$0" 2>/dev/null || echo 0)"
[ "$_asserts" -gt 0 ] \
&& ok "the suite counts assertions, not printed lines" \
|| bad "the suite has no assertions to count"
# ---- host layout and built-system layout are separate ------------------------ #
# Configuring where package users live on THIS machine must not change where
# they live on every system built from it. The whole point of the build is
# that the new system is not this one.
python3 - "$LFS_TOOL" <<'PY74'
import sys, os, json, tempfile, importlib.machinery as m
store = tempfile.mkdtemp(); os.environ["LFS_STORE"] = store
os.makedirs(os.path.join(store, "books"), exist_ok=True)
json.dump({"default": "12.4",
"pkgusr_home": "/usr/src",
"target_pkgusr_home": "/usr/src/package_users",
"target_appuser_home": "/home/shared_users"},
open(os.path.join(store, "config.json"), "w"))
lfs = m.SourceFileLoader('lfs', sys.argv[1]).load_module()
problems = []
if not hasattr(lfs, "layout"):
print(" FAIL the layout is not configurable"); sys.exit(1)
if lfs.layout("pkgusr_home") != "/usr/src":
problems.append("the host layout was changed by a target setting")
if lfs.layout("pkgusr_home", target=True) != "/usr/src/package_users":
problems.append("the built system does not use its own layout")
if lfs.layout("appuser_home", target=True) != "/home/shared_users":
problems.append("application users do not get their own base")
# The default moved deliberately: accounts are grouped by kind, so a
# directory listing says what a thing IS. Existing trees are NOT migrated --
# a fresh build is required, and migrate-layout is still unwritten. Pin the
# new values so the next move is also a decision and not an accident.
d = lfs.LAYOUT_DEFAULTS
if d["target_pkgusr_home"] != "/usr/src/pkgusr" \
or d["pkgusr_home"] != "/usr/src/pkgusr":
problems.append("packages no longer default to /usr/src/pkgusr")
if d["target_cfguser_home"] != "/usr/src/cfg" \
or d["cfguser_home"] != "/usr/src/cfg":
problems.append("config steps no longer default to /usr/src/cfg")
# and the target value must reach the chroot and the built system's config
src = open(sys.argv[1]).read()
if 'LFS_SRC_ROOT="{layout("pkgusr_home", target=True)}"' not in src:
problems.append("the chroot build environment does not carry the layout")
if 'pkgusr_home=%s' not in src:
problems.append("the built system's config does not record the layout")
if problems:
for p in problems: print(" FAIL %s" % p)
sys.exit(1)
print(" PASS host and built-system layouts are configured independently")
PY74
_count_rc $?
pmt="$(dirname "$LFS_TOOL")/packagemanager"
if [ -f "$pmt" ]; then
grep -q "^def appuser_home" "$pmt" \
&& ok "application users have their own base directory" \
|| bad "application users still share the package-user base"
# an unconfigured system must keep the old layout
lyd="$T/layout"; mkdir -p "$lyd"
printf 'collector_prefix=n\n' > "$lyd/pm.conf"
got="$(PKGUSR_CONFIG="$lyd/pm.conf" python3 -c "
import importlib.machinery as m, os
pm = m.SourceFileLoader('pm', '$pmt').load_module()
print(pm.BASE_DIR, pm.appuser_home())" 2>/dev/null)"
[ "$got" = "/usr/src /usr/src" ] \
&& ok "an unconfigured system keeps the existing layout" \
|| bad "the layout changed without being asked (got '$got')"
printf 'collector_prefix=n\npkgusr_home=/usr/src/package_users\nappuser_home=/home/shared_users\n' \
> "$lyd/pm2.conf"
got2="$(PKGUSR_CONFIG="$lyd/pm2.conf" python3 -c "
import importlib.machinery as m, os
pm = m.SourceFileLoader('pm', '$pmt').load_module()
print(pm.BASE_DIR, pm.appuser_home())" 2>/dev/null)"
[ "$got2" = "/usr/src/package_users /home/shared_users" ] \
&& ok "a configured system uses the layout it was built with" \
|| bad "the configured layout is ignored (got '$got2')"
fi
# ---- /root must never carry the install group -------------------------------- #
# Two different ideas got conflated. An install directory is SHARED: it gets
# the install group and g+w so any package user can add files. /root, /home
# and the scratch dirs are something else -- they must never be adopted by a
# package, but they are not shared. Listing them as install dirs produced
# drwxrwx--- 1 root install /root
# handing every package user access to root's home.
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
grep -q "^never_adopt_list()" "$helper_src" \
&& ok "protected-from-adoption is separate from shared" \
|| bad "the two ideas are still conflated"
( SNAP_ROOT=/; INSTALLDIRS=""
eval "$(sed -n '/^never_adopt_list() {/,/^}/p' "$helper_src")"
eval "$(sed -n '/^is_never_adopt() {/,/^}/p' "$helper_src")"
eval "$(sed -n '/^install_dirs_list() {/,/^}/p' "$helper_src")"
eval "$(sed -n '/^install_dir_patterns() {/,/^}/p' "$helper_src")"
eval "$(sed -n '/^matches_install_pattern() {/,/^}/p' "$helper_src")"
eval "$(sed -n '/^is_install_dir() {/,/^}/p' "$helper_src")"
# never group-writable
for d in /root /home /tmp /var/tmp; do
install_dirs_list | grep -qx "$d" && exit 1
done
# but still never adopted
for d in /root /home /tmp /var/tmp; do is_install_dir "$d" || exit 1; done
# and the real install dirs are untouched
install_dirs_list | grep -qx /usr/bin || exit 1
exit 0 ) \
&& ok "/root and /home are protected but not group-writable" \
|| bad "/root or /home would get the install group"
fi
# ---- nothing owned by the build user survives into the system --------------- #
# Book 7.2 hands over the directories 4.2 created, but the tree ROOT and the
# mount points are not in that list, so /, /dev, /proc, /sys, /run, /media,
# /opt and /tools stayed owned by a user that does not exist in the chroot:
# drwxr-xr-x 1 lfs lfs 178 /
python3 - "$LFS_TOOL" <<'PY75'
import sys
src = open(sys.argv[1]).read()
if "give the top level to root" not in src:
print(" FAIL the tree root and mount points keep the build user")
sys.exit(1)
i = src.index("give the top level to root")
seg = src[i:i + 500]
problems = []
if "/sources" not in seg:
problems.append("/sources is not excluded; the book keeps it with lfs")
if "-R " in seg:
problems.append("the handover is recursive; it must only touch the top level")
if 'chown -h root:root' not in seg:
problems.append("symlinks at the top level are not handed over")
if problems:
for p in problems: print(" FAIL %s" % p)
sys.exit(1)
print(" PASS the tree root and mount points are handed back to root")
PY75
_count_rc $?
# ---- the package-user model must be usable from outside ---------------------- #
# `build` only works for steps that already exist in the step list with a
# generated script, so a BLFS package or a local script had no way to say
# "install this as its own package user and track it" -- it would have to
# reimplement user creation, the wrappers, staging, tracking and the permission
# grants. That is exactly how the two halves of this toolchain drifted apart.
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
grep -q "^cmd_install_as()" "$helper_src" \
&& ok "anything can be installed as its own package user" \
|| bad "third-party installs cannot use the model"
ia="$(sed -n '/^cmd_install_as() {/,/^}/p' "$helper_src")"
# both shapes a caller might have
case "$ia" in
*"--script)"*) ok "an existing install script can be used" ;;
*) bad "only inline commands are accepted" ;;
esac
case "$ia" in
*"--) shift; cmdv=("*) ok "a bare command can be used" ;;
*) bad "only a script file is accepted" ;;
esac
# it must REUSE cmd_build, not reimplement it -- one code path or they drift
case "$ia" in
*'cmd_build "$name" --force'*)
ok "it reuses the build path rather than duplicating it" ;;
*) bad "install-as reimplements what build already does" ;;
esac
# the result must be a normal package, visible to every query
case "$ia" in
*'"$STATE_PROGRESS/steporder"'*)
ok "the package becomes visible to status, list and manifests" ;;
*) bad "an install-as package would be invisible to the other commands" ;;
esac
# reserved names must be refused
case "$ia" in
*"_is_not_a_package"*) ok "reserved step names are refused" ;;
*) bad "install-as could create a user for last-step or cfg_*" ;;
esac
# a command with spaces or quotes must survive being written to a script
grep -q "^_quote_cmd()" "$helper_src" \
&& ok "arguments are quoted, so paths with spaces survive" \
|| bad "a command containing spaces would be mangled"
fi
# ---- a capitalised step must find its lowercase earlier stage --------------- #
# Book titles are capitalised ("Python", "Util-linux") but the chapter-7 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'
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
ce="$(sed -n '/^_claim_earlier_stages() {/,/^}/p' "$helper_src")"
case "$ce" in
*"tr 'A-Z' 'a-z'"*) ok "a capitalised step finds its lowercase stage" ;;
*) bad "Python would never claim python-tmp's files" ;;
esac
case "$ce" in
*'for stem in "$name" "$lname" "$owner" "$owname"'*)
ok "the owner's spelling is tried as well as the step's" ;;
*) bad "only the step name is tried" ;;
esac
case "$ce" in
*"de-duplicate"*) ok "a manifest is not walked twice" ;;
*) bad "the same manifest could be walked four times" ;;
esac
fi
# ---- a package user should record where it came from ------------------------ #
# An LFS base package, a BLFS package, a pip module and a local script are
# maintained differently and updated from different places. Nothing in a
# package user's home said which it was.
if [ -f "$helper_src" ]; then
grep -q "^_write_pkgusr_info()" "$helper_src" \
&& ok "each package user records its source" \
|| bad "there is no way to tell an LFS package from a BLFS one"
grep -q "^cmd_pkgusr_info()" "$helper_src" \
&& ok "it can be read back" \
|| bad "the record is written but never readable"
# both completion paths must write it, not just one
cb="$(sed -n '/^cmd_build() {/,/^}/p' "$helper_src")"
n="$(printf '%s' "$cb" | grep -c '_write_pkgusr_info')"
[ "${n:-0}" -ge 2 ] \
&& ok "every path that completes a build records it" \
|| bad "only one of the two done paths records the source"
# the helper must be defined before cmd_build uses it
i_def="$(grep -n '^_write_pkgusr_info()' "$helper_src" | head -1 | cut -d: -f1)"
i_use="$(grep -n '^cmd_build() {' "$helper_src" | head -1 | cut -d: -f1)"
[ -n "$i_def" ] && [ -n "$i_use" ] && [ "$i_def" -lt "$i_use" ] \
&& ok "it is defined before the build uses it" \
|| bad "the branding helper is defined after its first use"
ia="$(sed -n '/^cmd_install_as() {/,/^}/p' "$helper_src")"
case "$ia" in
*"LFS_PKG_SOURCE"*) ok "install-as records what kind of install it was" ;;
*) bad "a BLFS or local install would be recorded as lfs" ;;
esac
fi
# ---- adopt-existing must still exist ----------------------------------------- #
# The pass moved into verify, but the command was removed while messages
# elsewhere still told people to run it:
# !! unknown command 'adopt-existing'
# A command that has been deleted is worse than one that is redundant.
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
grep -q "^cmd_adopt_existing()" "$helper_src" \
&& ok "adopt-existing is still a command" \
|| bad "a command the messages tell people to run does not exist"
grep -qE "^\s+adopt-existing\|adopt-dirs\)" "$helper_src" \
&& ok "it is reachable from the command line" \
|| bad "adopt-existing is defined but not dispatched"
# every command named in a hint must actually dispatch
for c in fix-ownership fix-orphans grant-dir verify which-package \
check-toolchain seal-install-dirs sort-users install-as; do
# a command may be dispatched on its own or as part of an alternation
grep -qE "^\s+[a-z|-]*\b$c[|)]" "$helper_src" \
|| bad "$c is referenced but not dispatched"
done
ok "every command referenced in the hints is dispatched"
# ---- and the users must be created in BUILD order --------------------- #
# This pass creates the package users it finds missing, and a uid is just
# the order of creation. Alphabetical order gave man-pages (first
# chapter-8 package) 10000 and binutils -- built first, in chapter 5 -- a
# much higher one, so the passwd file no longer reads as the build order.
vm="$(sed -n '/^_vfy_manifest_ownership() {/,/^}/p' "$helper_src")"
case "$vm" in
*'$(_manifests_in_build_order)'*)
ok "the users are created in build order" ;;
*) bad "manifests are still walked alphabetically" ;;
esac
mo="$(sed -n '/^_manifests_in_build_order() {/,/^}/p' "$helper_src")"
case "$mo" in
*'[ "${1:-}" = dirs ]'*)
ok "the directory manifests are ordered the same way" ;;
*) bad "the .dirs manifests are still alphabetical" ;;
esac
fi
# ---- package users cannot exist before book 7.6 ----------------------------- #
# A crosschain snapshot is taken BEFORE chapter 7, so /etc/passwd does not
# exist -- 7.6 creates it. Running the adoption pass then printed
# user binutils (could not create) -> package user
# forty times, once per package, with no hint why. The give-away is the
# prompt: "I have no name!" means even root has no passwd entry.
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
grep -q "^_user_db_ready()" "$helper_src" \
&& ok "the user database is checked before creating users" \
|| bad "adoption would fail once per package with no explanation"
ae="$(sed -n '/^cmd_adopt_existing() {/,/^}/p' "$helper_src")"
case "$ae" in
*_explain_no_user_db*)
ok "it explains that the tree is simply too early" ;;
*) bad "the error does not say what is actually wrong" ;;
esac
# and the work must still happen, later, without being asked again
# The late pass is the ownership epoch now: build-all calls
# _ownership_checkpoint after every step, and the marker makes it fire once.
ba="$(_slice_fn "$helper_src" cmd_build_all)"
case "$ba" in
*_ownership_checkpoint*)
ok "the packages are adopted once chapter 7 has created it" ;;
*) bad "a chapter 5-6 tree would never adopt its packages" ;;
esac
case "$(_slice_fn "$helper_src" _ownership_checkpoint)" in
*ownership_established*) ok "the late pass runs once, not per step" ;;
*) bad "the late adoption would repeat on every step" ;;
esac
ud="$(sed -n '/^_user_db_ready() {/,/^}/p' "$helper_src")"
case "$ud" in
*"grep -q '^root:'"*)
ok "an empty or rootless passwd counts as not ready" ;;
*) bad "a truncated passwd would look usable" ;;
esac
fi
# ---- "did not change owner" vs "no package claims it" ----------------------- #
# The ownership pass can only act on what a manifest lists. A file installed
# before tracking existed, or whose manifest was lost, stays root-owned and
# never appears in the report at all -- so a skipped file and an unclaimed one
# look identical from the outside, and they need different fixes.
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
grep -q "^_vfy_unclaimed()" "$helper_src" \
&& ok "files no manifest claims are reported" \
|| bad "an unclaimed file is indistinguishable from a skipped one"
vu="$(sed -n '/^_vfy_unclaimed() {/,/^}/p' "$helper_src")"
# /etc is root's by right; reporting it would bury the real cases
case "$vu" in
*'"${SNAP_ROOT%/}/etc"'*) ok "configuration under /etc is not reported" ;;
*) bad "every file in /etc would be listed as unclaimed" ;;
esac
# the manifest paths carry the host prefix; comparison must strip it
case "$vu" in
*strip_host_prefix*) ok "manifest paths are compared without the prefix" ;;
*) bad "every claimed file would look unclaimed inside the chroot" ;;
esac
case "$vu" in
*"lfs-helper which-package"*)
ok "it says how to find out which package should own them" ;;
*) bad "the report gives no way forward" ;;
esac
cv="$(sed -n '/^cmd_verify() {/,/^}/p' "$helper_src")"
case "$cv" in
*_vfy_unclaimed*) ok "verify runs the check" ;;
*) bad "the unclaimed check is never called" ;;
esac
fi
# ---- a versioned chapter-9 "configuration" section is a PACKAGE ------------- #
# 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. `lfs` gets
# this right -- _title_pkgver finds a version, so it generates a full phased
# build and deliberately keeps the step OUT of rootsteps, meaning "build this
# as a package user".
#
# lfs-helper then blanket-matched cfg_* as "not a package", refused to create
# the user, and the non-root path staged the script as that user anyway:
# 'cfg_bootscripts' is a build step, not a package -- no user created.
# install: invalid user 'cfg_bootscripts'
# !! could not stage the install script
# The build stopped at 88/99. Two tools, two answers, no shared authority.
#
# The script itself is the authority: a package has pkg_glob, prose does not.
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
_cd="$T/cfgpkg"; mkdir -p "$_cd"
printf 'pkg_glob="lfs-bootscripts-*.tar.*"\n' > "$_cd/cfg_bootscripts.sh"
printf '#!/bin/bash\necho x > /etc/hostname\n' > "$_cd/cfg_hostname.sh"
_r="$( SCRIPTS="$_cd"; PKGUSR_PREFIX="p_"
# _is_not_a_package strips the package-user prefix first, so the
# helper it calls has to be here too -- without it every name came
# back empty and matched nothing, which reads as "is a package".
eval "$(sed -n '/^unprefix_pkg_user() {/,/^}/p' "$helper_src")"
eval "$(sed -n '/^_is_not_a_package() {/,/^}/p' "$helper_src")"
# a versioned cfg section installs files and needs an owner
_is_not_a_package cfg_bootscripts && echo "bootscripts-has-no-user"
# a prose one writes root's config and must NOT get a user
_is_not_a_package cfg_hostname || echo "hostname-got-a-user"
# a cfg step with no script at all is prose until proven otherwise
_is_not_a_package cfg_nothing || echo "unknown-cfg-step-got-a-user"
# the genuinely-not-packages must stay that way
_is_not_a_package last-step || echo "last-step-got-a-user"
_is_not_a_package init-dirs || echo "init-dirs-got-a-user"
_is_not_a_package refind || echo "refind-got-a-user"
# and a normal package is still a package
_is_not_a_package gcc && echo "gcc-lost-its-user"
# callers pass the OWNER name too, already prefixed
_is_not_a_package p_cfg_bootscripts && echo "prefixed-form-misread" )"
if [ -z "$_r" ]; then
ok "a versioned cfg_* section is built as its own package user"
else
printf '%s\n' "$_r" | while IFS= read -r m; do
[ -n "$m" ] && bad "$m"
done
fi
# and lfs must agree: a versioned cfg section is kept out of rootsteps
grep -q 'pkg_like_cfg.append(cname)' "$LFS_TOOL" \
&& ok "lfs keeps a versioned cfg section out of the root steps" \
|| bad "lfs no longer treats a versioned cfg section as a package"
fi
# ---- the triplet warning must not fire on a healthy toolchain --------------- #
# A finished build has TWO C++ header directories: chapter 6 installs libstdc++
# under $LFS_TGT (x86_64-lfs-linux-gnu, or whatever vendor string is set) and
# chapter 8's native gcc installs its own under x86_64-pc-linux-gnu. Both are
# present and that is correct -- the chapter-6 one is simply left over.
#
# The check took the FIRST glob match and stopped, so it reported a mismatch
# whenever the stale directory sorted first -- which is alphabetical, i.e. luck:
# "nimgnu" < "pc", so a perfectly good toolchain warned on every single package.
# What matters is only whether a directory matching the compiler exists.
if [ -f "$helper_src" ]; then
_tp="$T/triplet"; mkdir -p "$_tp/15.2.0/x86_64-nimgnu-linux-gnu/bits" \
"$_tp/15.2.0/x86_64-pc-linux-gnu/bits"
touch "$_tp/15.2.0/x86_64-nimgnu-linux-gnu/bits/c++config.h" \
"$_tp/15.2.0/x86_64-pc-linux-gnu/bits/c++config.h"
# the fixed shape: scan every match, stay silent if one is the compiler's
_fn="$(sed -n '/^_warn_on_triplet_mismatch() {/,/^}/p' "$helper_src")"
_p=""
case "$_fn" in
*'[ "$have" = "$mine" ] && return 0'*) ;;
*) _p="$_p;the check does not stop at the compiler's own headers" ;;
esac
case "$_fn" in
*"break"*) _p="$_p;the check still stops at the first directory found" ;;
esac
case "$_fn" in
*'[ -n "$found" ] || return 0'*) ;;
*) _p="$_p;a tree with no C++ headers yet would warn" ;;
esac
if [ -n "$_p" ]; then
printf '%s\n' "${_p#;}" | tr ';' '\n' | while IFS= read -r m; do
[ -n "$m" ] && bad "$m"
done
else
ok "the triplet warning ignores leftover chapter-6 headers"
fi
fi
# ---- the adoption gate must actually close --------------------------------- #
# Chapters 5-6 build before /etc/passwd exists, so their files are recorded in
# manifests with no owner. Once book 7.6 creates the user database, the
# adoption pass hands each package its files, and adopted.list records
# "name size" so it does not run again.
#
# It ran again on EVERY build-all, and the cause was that the gate and the
# recorder tested opposite conditions:
# _needs_adoption: user_exists "$owner" || return 0 # no user -> NEEDS
# _mark_all_adopted: user_exists "$owner" || continue # no user -> SKIP
# So a single manifest without a user could never be recorded and kept
# _has_unadopted_packages at yes forever. One rule now, used by both.
#
# The write also swallowed every error (2>/dev/null on the redirect AND
# `|| rm -f` on the move), so a missing or read-only state directory looked
# exactly like a successful record.
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
# the state directory is sorted now: adopted.list lives under progress/
_ad="$T/adopt"; mkdir -p "$_ad/manifests" "$_ad/progress"
for f in haveuser nouser; do echo "/usr/bin/$f" > "$_ad/manifests/$f.files"; done
_out="$( STATE="$_ad"; MANIFESTS="$_ad/manifests"; SCRIPTS="$_ad/scripts"
STATE_PROGRESS="$_ad/progress"
warn(){ :; }
user_exists(){ [ "$1" = haveuser ]; } # only one package has a user
pkg_owner_name(){ echo "$1"; }
is_done(){ return 1; } # none built by lfs-helper here
PKGUSR_PREFIX="p_"
# _is_not_a_package strips the prefix first, so its helper is needed too
for fn in _ADOPTED_LIST _manifest_size _adopted_size _mark_adopted \
_mark_all_adopted _needs_adoption _has_unadopted_packages \
unprefix_pkg_user _is_not_a_package; do
eval "$(sed -n "/^$fn() {/,/^}/p" "$helper_src")"
done
_has_unadopted_packages || echo "gate-was-closed-before-the-pass"
_mark_all_adopted
# the package WITHOUT a user must be recorded too -- that is the fix
grep -q '^nouser ' "$_ad/progress/adopted.list" 2>/dev/null \
|| echo "a package without a user is never recorded"
# and the gate must now be shut
_has_unadopted_packages && echo "the gate stays open after the pass"
# but a manifest that GROWS has new files to hand over -- reopen
echo /usr/bin/extra >> "$_ad/manifests/haveuser.files"
_has_unadopted_packages || echo "a grown manifest is not re-adopted" )"
if [ -z "$_out" ]; then
ok "the adoption gate closes once the pass has run"
else
printf '%s\n' "$_out" | while IFS= read -r m; do
[ -n "$m" ] && bad "$m"
done
fi
# a failed write must be reported, not swallowed: an unrecorded adoption
# means the pass runs forever and nothing on disk says why
_w="$( STATE=/dev/null/nope
warn(){ echo "reported"; }
for fn in _ADOPTED_LIST _manifest_size _mark_adopted; do
eval "$(sed -n "/^$fn() {/,/^}/p" "$helper_src")"
done
_mark_adopted p 12 2>/dev/null && echo "claimed-success" )"
case "$_w" in
*reported*) ok "a failed adoption record is reported, not swallowed" ;;
*) bad "a failed adoption record still looks like success" ;;
esac
fi
# ---- chapter-8 configuration must be generated, and must not rot ----------- #
# nsswitch.conf, the timezone and the dynamic-loader config were never
# generated: the step table only harvested chapter 9. Without /etc/nsswitch.conf
# glibc falls back to compiled-in defaults; without a timezone the system runs
# UTC forever.
#
# Two of those sections carry ids the BOOK TOOLCHAIN generated --
# idm139921653990176 8.5.2.1. Adding nsswitch.conf
# -- which change every time the book is rebuilt. Naming one in the table
# works exactly once and then resolves to nothing, silently. Silently is the
# real danger: /etc/shells went missing from finished systems because
# cfg_shells resolved to zero blocks and was skipped without a word. So a step
# may name an id OR a title fragment, and this test fails if any chapter-8
# entry stops resolving.
python3 - "$LFS_TOOL" "$LFS_BOOK" <<'PYCFG8'
import sys, importlib.machinery as m
lfs = m.SourceFileLoader('lfs', sys.argv[1]).load_module()
problems = []
try:
from bs4 import BeautifulSoup
except ImportError:
print(" SKIP beautifulsoup4 not installed"); sys.exit(0)
if not hasattr(lfs, "CHROOT_CONFIG_STEPS_8"):
print(" FAIL chapter-8 configuration is not generated at all"); sys.exit(1)
soup = BeautifulSoup(open(sys.argv[2], encoding='utf-8', errors='replace').read(),
'html.parser')
secs = {sid: (t, sec) for sid, t, sec in lfs.iter_sections(soup)}
for name, sid in lfs.CHROOT_CONFIG_STEPS_8:
e = lfs._resolve_section(secs, sid)
if not e:
problems.append("%s: '%s' resolves to no section" % (name, sid))
elif not e[1]["commands"]:
problems.append("%s: resolved but has no command blocks" % name)
# the resolver itself: id first, then title, and never a silent guess
if lfs._resolve_section(secs, "ch-config-hostname") is None:
problems.append("a stable section id no longer resolves")
if lfs._resolve_section(secs, "Adding nsswitch.conf") is None:
problems.append("a section cannot be found by its title")
if lfs._resolve_section(secs, "zzz-no-such-section") is not None:
problems.append("a nonexistent section resolves to something")
# an ambiguous fragment must NOT be resolved by picking the first match
if lfs._resolve_section(secs, "Configuring") is not None:
problems.append("an ambiguous title fragment is silently resolved")
# /tools deletion and stripping are opt-in: neither should ever be a default
if lfs.remove_tools():
problems.append("/tools is deleted by default")
if lfs.strip_binaries():
problems.append("binaries are stripped by default")
if problems:
for p in problems: print(" FAIL %s" % p)
sys.exit(1)
print(" PASS chapter-8 configuration resolves by id or title, and cannot rot")
PYCFG8
_count_rc $?
# cfg_rm-tools deletes a root-owned tree, so it must be a root step -- never a
# package user's job, and never adoptable
grep -q 'root_steps.append("cfg_rm-tools")' "$LFS_TOOL" \
&& ok "removing /tools is a root step" \
|| bad "removing /tools is not a root step"
# ---- every build decision is asked UP FRONT --------------------------------- #
# The build used to discover decisions halfway through, or at the very end:
# last_build_step.sh blocked on `passwd root` after a six-hour build, and its
# own comment admits the non-interactive path leaves the system
# "unbootable-but-known". Collecting the answers in `session` is what lets
# `lfs run` and `build-all` finish without a person sitting there.
#
# They must also REACH the chroot: the config file is on the host and the
# chroot environment is reset, so anything not written into the tree's env
# file is simply not there when it is needed.
python3 - "$LFS_TOOL" <<'PYASK'
import sys, importlib.machinery as m
lfs = m.SourceFileLoader('lfs', sys.argv[1]).load_module()
problems = []
keys = [k for k, _, _ in lfs._SESSION_KEYS]
for k in ("timezone", "remove_tools", "strip", "main_user"):
if k not in keys:
problems.append("'%s' is not asked during session" % k)
# Destructive or slow options must never default to on. Deleting the
# cross-toolchain and rewriting every binary are both things a person should
# have chosen, not discovered.
if lfs.remove_tools():
problems.append("/tools is deleted by default")
if lfs.strip_binaries():
problems.append("binaries are stripped by default")
# each answer has to be written into the tree, or the chroot never sees it
body = open(sys.argv[1]).read()
i = body.index("def write_chroot_env")
j = body.find("\ndef ", body.index("_make_world_readable(path)", i))
w = body[i:j if j > i else len(body)]
for var in ("LFS_TIMEZONE", "LFS_MAIN_USER", "LFS_STRIP", "LFS_REMOVE_TOOLS"):
if var not in w:
problems.append("%s never reaches the chroot" % var)
if problems:
for p in problems: print(" FAIL %s" % p)
sys.exit(1)
print(" PASS every build decision is collected before the build starts")
PYASK
_count_rc $?
# and the accounts step must USE the configured name rather than stopping to
# ask for it -- the whole point of asking early
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
case "$(_slice_fn "$helper_src" cmd_init_accounts)" in
*main_user*) ok "the final step uses the login account chosen up front" ;;
*) bad "the final step stops to ask for a name already configured" ;;
esac
fi
# ---- a section that ships a FILE instead of commands ----------------------- #
# Book 9.6.8 (The rc.site File) has no <kbd class="command"> at all. It prints
# /etc/sysconfig/rc.site as a <pre class="auto"> block with every line
# commented out, for the reader to copy and uncomment. The parser harvests
# command blocks only, so cfg_rcsite resolved to zero blocks and the file was
# never created -- the same silent-nothing failure that once lost /etc/shells.
#
# An all-commented file is still worth installing: it documents every knob the
# boot scripts have, in the place the boot scripts look for it.
python3 - "$LFS_TOOL" "$LFS_BOOK" <<'PYRCSITE'
import sys, subprocess, tempfile, os, importlib.machinery as m
lfs = m.SourceFileLoader('lfs', sys.argv[1]).load_module()
problems = []
if not hasattr(lfs, "_auto_file_block"):
print(" FAIL a section that ships a file instead of commands is ignored")
sys.exit(1)
raw = open(sys.argv[2], encoding='utf-8', errors='replace').read()
blk = lfs._auto_file_block(raw, "ch-config-site", "/etc/sysconfig/rc.site")
if not blk:
problems.append("rc.site is not extracted from the book")
else:
d = tempfile.mkdtemp()
sh = blk.replace("/etc/sysconfig", d + "/etc/sysconfig")
open(d + "/t.sh", "w").write("set -e\n" + sh + "\n")
r = subprocess.run(["bash", d + "/t.sh"], capture_output=True, text=True)
out = d + "/etc/sysconfig/rc.site"
if r.returncode != 0:
problems.append("the generated rc.site block is not valid bash: %s"
% r.stderr.strip()[:80])
elif not os.path.isfile(out):
problems.append("the block runs but writes no file")
else:
body = open(out).read()
if len(body.splitlines()) < 50:
problems.append("rc.site came out truncated (%d lines)"
% len(body.splitlines()))
# a quoted heredoc: the body is full of # and shell metacharacters and
# must land byte-for-byte as the book prints it, not expanded
if "$" in body and "EOF" not in blk.split("\n")[1]:
problems.append("the heredoc is not quoted -- the body would expand")
# a section with neither commands nor a file body must still be reported, not
# silently skipped
if lfs._auto_file_block(raw, "ch-config-sysklogd", "/etc/x") is not None:
problems.append("a prose section is treated as if it shipped a file")
if problems:
for p in problems: print(" FAIL %s" % p)
sys.exit(1)
print(" PASS a section that ships a file body is installed, not skipped")
PYRCSITE
_count_rc $?
# ---- the package-user prefix must be one decision, not three --------------- #
# lfs-helper has ALWAYS read a prefix and defaulted to `p`:
# PKGUSR_PREFIX="${LFS_PKGUSR_PREFIX-$(_read_pkgusr_prefix || echo p)}"
# but nothing ever wrote the setting -- `lfs` did not put it in the chroot env
# and `packagemanager` did not implement it at all. So the default applied by
# accident, and the built system would look for `gcc` while the build had
# created `p_gcc`. Same species as cfg_bootscripts and adopted.list: one
# decision held in several places that could disagree.
#
# Prefixing MUST be idempotent. It is applied to names typed by a person
# (gcc), to directory names read back from disk (p_gcc), and to values that
# already went through it -- lfs-helper's own comment names p_p_gcc as the
# thing to avoid.
python3 - "$LFS_TOOL" <<'PYPFX'
import sys, os, tempfile, importlib.machinery as m
problems = []
lfs = m.SourceFileLoader('lfs', sys.argv[1]).load_module()
pmp = os.path.join(os.path.dirname(sys.argv[1]) or ".", "packagemanager")
if not os.path.exists(pmp):
print(" SKIP packagemanager not found next to the tool"); sys.exit(0)
pm = m.SourceFileLoader('pm', pmp).load_module()
if not hasattr(lfs, "pkgusr_prefix"):
problems.append("lfs cannot say what the package-user prefix is")
if not hasattr(pm, "pkgusr_name"):
problems.append("packagemanager does not implement the prefix")
if not problems:
# the two tools must agree, or the build and the built system diverge
if lfs.pkgusr_prefix() != pm.pkgusr_prefix():
problems.append("lfs and packagemanager disagree: %r vs %r"
% (lfs.pkgusr_prefix(), pm.pkgusr_prefix()))
if pm.pkgusr_prefix() != "p_":
problems.append("the default prefix is not p_ (got %r)"
% pm.pkgusr_prefix())
if pm.pkgusr_name("gcc") != "p_gcc":
problems.append("a package name is not prefixed")
# idempotent -- the whole reason every call site can apply it blindly
if pm.pkgusr_name(pm.pkgusr_name("gcc")) != "p_gcc":
problems.append("prefixing twice gives p_p_gcc")
if pm.unprefix_pkgusr("p_gcc") != "gcc":
problems.append("the prefix cannot be stripped for display")
# the OTHER kinds of account must not be touched: an application user and
# a collector group are not packages
if pm.pkgusr_name("u_firefox") != "u_firefox":
problems.append("an application user was given the package prefix")
if pm.pkgusr_name(pm.collector_prefix() + "python") \
!= pm.collector_prefix() + "python":
problems.append("a collector group was given the package prefix")
# the home is the ACCOUNT's, whichever kind of name it is asked with
if pm.pkgusr_home("gcc") != pm.pkgusr_home("p_gcc"):
problems.append("a package name and its user name give different homes")
# and it has to REACH the chroot, or lfs-helper falls back to a guess
body = open(sys.argv[1]).read()
if "LFS_PKGUSR_PREFIX" not in body:
problems.append("the prefix never reaches the chroot")
if "pkgusr_prefix" not in [k for k, _, _ in lfs._SESSION_KEYS]:
problems.append("the prefix is never asked for")
if problems:
for p in problems: print(" FAIL %s" % p)
sys.exit(1)
print(" PASS lfs, lfs-helper and packagemanager share one package-user prefix")
PYPFX
_count_rc $?
# lfs-helper's side: it must take the value from the environment lfs wrote,
# and an explicitly EMPTY value must mean "no prefix" -- not fall through to
# the default and rename every user out from under an existing tree
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
grep -q 'PKGUSR_PREFIX="${LFS_PKGUSR_PREFIX-' "$helper_src" \
&& ok "an empty prefix means none, and is not the same as unset" \
|| bad "an empty prefix falls through to the default"
fi
# ---- accounts live in subdirectories by kind ------------------------------- #
# Flat under /usr/src, a hundred package users and a handful of config steps
# were one undifferentiated list, and nothing about a directory said what kind
# of thing it was. Now:
# /usr/src/pkgusr/p_gcc a package
# /usr/src/cfg/p_cfg_bootscripts a config step that installs files
# All three tools have to agree, or the build creates a home the built system
# never looks in.
python3 - "$LFS_TOOL" <<'PYSPLITHOME'
import sys, os, tempfile, importlib.machinery as m
problems = []
lfs = m.SourceFileLoader('lfs', sys.argv[1]).load_module()
pmp = os.path.join(os.path.dirname(sys.argv[1]) or ".", "packagemanager")
if not os.path.exists(pmp):
print(" SKIP packagemanager not found next to the tool"); sys.exit(0)
d = tempfile.mkdtemp(); os.environ["PKGUSR_BASE"] = d
pm = m.SourceFileLoader('pm', pmp).load_module()
for k in ("target_pkgusr_home", "target_cfguser_home",
"pkgusr_home", "cfguser_home"):
if k not in lfs.LAYOUT_DEFAULTS:
problems.append("layout has no '%s'" % k)
if lfs.LAYOUT_DEFAULTS.get("pkgusr_home") == lfs.LAYOUT_DEFAULTS.get("cfguser_home"):
problems.append("packages and config steps share one directory")
if not lfs.LAYOUT_DEFAULTS.get("pkgusr_home", "").endswith("/pkgusr"):
problems.append("packages do not default to /usr/src/pkgusr")
if not lfs.LAYOUT_DEFAULTS.get("cfguser_home", "").endswith("/cfg"):
problems.append("config steps do not default to /usr/src/cfg")
# a package and a config step must land in DIFFERENT roots, whichever form of
# the name they are asked with
if pm.pkgusr_home("gcc") == pm.pkgusr_home("cfg_bootscripts"):
problems.append("a package and a config step share a root")
if not pm.pkgusr_home("gcc").startswith(os.path.join(d, "pkgusr")):
problems.append("a package is not under the package root")
if not pm.pkgusr_home("cfg_bootscripts").startswith(os.path.join(d, "cfg")):
problems.append("a config step is not under the config root")
# prefixed form must resolve identically -- callers pass both
if pm.pkgusr_home("p_cfg_bootscripts") != pm.pkgusr_home("cfg_bootscripts"):
problems.append("the prefixed name lands somewhere else")
# listing must walk BOTH roots, or half the accounts vanish
os.makedirs(os.path.join(d, "pkgusr", pm.pkgusr_name("gcc")))
os.makedirs(os.path.join(d, "cfg", pm.pkgusr_name("cfg_bootscripts")))
got = pm.list_all_users()
for want in ("gcc", "cfg_bootscripts"):
if want not in got:
problems.append("'%s' is missing from the account list %r" % (want, got))
# and both roots must reach the chroot
body = open(sys.argv[1]).read()
for var in ("LFS_PKGUSR_ROOT", "LFS_CFGUSR_ROOT"):
if var not in body:
problems.append("%s never reaches the chroot" % var)
if problems:
for p in problems: print(" FAIL %s" % p)
sys.exit(1)
print(" PASS packages and config steps live in separate roots")
PYSPLITHOME
_count_rc $?
# lfs-helper's side: one chokepoint that routes by kind, and the passes that
# walk every account must walk both roots -- not just the package one
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
_o="$( SRCROOT=/tmp/_hr; PKGUSR_ROOT="$SRCROOT/pkgusr"; CFGUSR_ROOT="$SRCROOT/cfg"
PKGUSR_PREFIX="p_"; CFGUSR_PREFIX="cfg_"
# pkg_owner_name too: pkgusr_home_for calls it, and without it every
# home came back as the bare root with no account on the end
for fn in pkgusr_kind pkgusr_prefix_for pkgusr_root_for \
unprefix_pkg_user pkg_owner_name pkgusr_home_for pkgusr_roots; do
eval "$(_slice_fn "$helper_src" "$fn")"
done
# a package gets the package prefix and the package root
[ "$(pkgusr_home_for gcc)" = "$SRCROOT/pkgusr/p_gcc" ] || echo "package-in-wrong-root"
[ "$(pkgusr_home_for p_gcc)" = "$SRCROOT/pkgusr/p_gcc" ] || echo "prefixed-package-in-wrong-root"
# a config step gets the CONFIG prefix and the config root -- not the
# package prefix stacked on top of its own
[ "$(pkgusr_home_for cfg_bootscripts)" = "$SRCROOT/cfg/cfg_bootscripts" ] || echo "cfg-in-wrong-root"
# and a legacy p_cfg_ name is repaired rather than carried forward
[ "$(pkgusr_home_for p_cfg_bootscripts)" = "$SRCROOT/cfg/cfg_bootscripts" ] || echo "stacked-cfg-not-repaired"
[ "$(pkgusr_roots | wc -l)" = 2 ] || echo "not-both-roots-walked" )"
if [ -z "$_o" ]; then
ok "lfs-helper routes each account to the root for its kind"
else
printf '%s\n' "$_o" | while IFS= read -r m; do [ -n "$m" ] && bad "$m"; done
fi
# no home may be built by hand any more -- that is how the two roots drift
grep -q '"\$SRCROOT/\$name"' "$helper_src" \
&& bad "a home is still built from SRCROOT directly" \
|| ok "every home goes through the one chokepoint"
fi
# ---- the home a build cd's into is the home the account has ---------------- #
# The build creates the account with useradd -d <home>, then cd's into that
# home to run the staged script. Those two must be the same path, and they
# are reached from DIFFERENT names: cmd_add_user has the owner
# ("p_man-pages"), build_pkg has the step name ("man-pages").
#
# pkgusr_home_for routed by kind but never applied the prefix, so:
# # created package user p_man-pages ...
# line 2673: cd: /usr/src/pkgusr/man-pages: No such file or directory
# Chapter 7 did not catch it -- root steps mkdir -p their own directory first,
# so it surfaced at the first real package user, nine steps in.
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
_o="$( SRCROOT=/usr/src; PKGUSR_ROOT="$SRCROOT/pkgusr"; CFGUSR_ROOT="$SRCROOT/cfg"
PKGUSR_PREFIX="p_"; CFGUSR_PREFIX="cfg_"
for fn in pkgusr_kind pkgusr_prefix_for pkgusr_root_for \
pkg_owner_name unprefix_pkg_user pkgusr_home_for; do
eval "$(_slice_fn "$helper_src" "$fn")"
done
# THE bug: a step name and its owner must give one home
[ "$(pkgusr_home_for man-pages)" = "$(pkgusr_home_for p_man-pages)" ] \
|| echo "step name and owner give different homes"
# and it must be the account's, i.e. prefixed
[ "$(pkgusr_home_for man-pages)" = "/usr/src/pkgusr/p_man-pages" ] \
|| echo "the home is not the account's: $(pkgusr_home_for man-pages)"
# book titles are capitalised, tarballs are not
[ "$(pkgusr_home_for Man-Pages)" = "$(pkgusr_home_for man-pages)" ] \
|| echo "a capitalised book title gives a different home"
# earlier stages fold onto the real package -- util-linux-tmp is
# util-linux's, which is where its files end up
[ "$(pkgusr_home_for util-linux-tmp)" = "$(pkgusr_home_for util-linux)" ] \
|| echo "an earlier stage gets its own home"
[ "$(pkgusr_home_for gcc-pass1)" = "$(pkgusr_home_for gcc)" ] \
|| echo "a pass1 build gets its own home"
# config steps still route to their own root, and wear ONE prefix --
# their own. This used to read p_cfg_bootscripts: the package prefix
# stacked on the step name's own, so the account claimed to be a package
# while living in the config root.
[ "$(pkgusr_home_for cfg_bootscripts)" = "/usr/src/cfg/cfg_bootscripts" ] \
|| echo "a config step is not under the config root" )"
if [ -z "$_o" ]; then
ok "a step name and its owner resolve to the same account home"
else
printf '%s\n' "$_o" | while IFS= read -r m; do [ -n "$m" ] && bad "$m"; done
fi
# the rule must be reachable from shell, or every other script re-derives it
# -- and a third copy is how the roots drift apart
grep -q 'pkgusr-home)' "$helper_src" \
&& ok "the home rule is available to scripts as 'lfs-helper pkgusr-home'" \
|| bad "shell scripts have to re-derive where an account lives"
fi
# nothing outside the chokepoints may build an account path by hand
for _f in packagemanager_install lfs-completion.bash; do
_p="$(dirname "$LFS_TOOL")/$_f"
[ -f "$_p" ] || continue
if grep -qE '"?/usr/src/\$[a-z_]+' "$_p"; then
bad "$_f builds an account path by hand"
else
ok "$_f asks for the account home instead of building it"
fi
done
# ---- chapter-8 config steps must not stop the build ------------------------ #
# Two ways they did, both from lifting a sub-section out of its parent package.
#
# cfg_timezone (8.5.2.2) ends with an INTERACTIVE menu whose only output is
# the name to put in the placeholder:
# tzselect
# ln -sfv /usr/share/zoneinfo/<xxx> /etc/localtime
# `session` already asks for the timezone, so both are answered. The same
# section also unpacks tzdata with a path relative to GLIBC's build directory
# (`tar -xf ../../tzdata2025b.tar.gz`), which resolves nowhere once the step
# stands on its own.
python3 - "$LFS_TOOL" <<'PYCFG8RUN'
import sys, re, importlib.machinery as m
lfs = m.SourceFileLoader('lfs', sys.argv[1]).load_module()
problems = []
body = ("tar -xf ../../tzdata2025b.tar.gz\n"
"zic -d $ZONEINFO -p America/New_York\n"
"tzselect\n"
"ln -sfv /usr/share/zoneinfo/<xxx> /etc/localtime\n")
out, left = lfs._fill_placeholders(body, "cfg_timezone")
if left:
problems.append("the timezone step still asks for %s" % left)
if "<xxx>" in out:
problems.append("the timezone placeholder is not filled in")
if re.search(r'^\s*tzselect\s*$', out, flags=re.M):
problems.append("the interactive tzselect menu still runs")
if "../../tzdata" in out:
problems.append("tzdata is still read relative to glibc's build directory")
if "SOURCES_DIR" not in out:
problems.append("tzdata is not taken from the sources directory")
if "/usr/share/zoneinfo//etc/localtime" in out.replace(" ", ""):
problems.append("the localtime symlink lost its zone name")
if problems:
for p in problems: print(" FAIL %s" % p)
sys.exit(1)
print(" PASS the timezone step is answered from config, not from a prompt")
PYCFG8RUN
_count_rc $?
# cfg_ld rewrites /etc/ld.so.conf -- which glibc created and still owns -- and
# makes one directory. Nothing NEW appears, so the manifest count is 0 and the
# step was failed as a silent no-op:
# mkdir: created directory '/etc/ld.so.conf.d'
# !! cfg_ld reported success but installed NO files.
# That is the normal shape of a configuration step. Packages must still fail.
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
# to the end of the branch, not a fixed window: the message grew a
# breakdown of where the count came from, and a +6 window stopped
# reaching the `return 1` it is asserting
_blk="$(sed -n '/reported success but installed NO files/,/^ fi$/p' "$helper_src")"
# the whole function, not a fixed window above the message: the branch
# gained a third case (a re-run over its own work) and a -B 14 window
# stopped reaching the config-step test it is asserting
_pre="$(_slice_fn "$helper_src" cmd_build)"
case "$_pre" in
*'_is_not_a_package "$name"'*)
ok "a config step may install no new files; a package may not" ;;
*) bad "installing no new files still fails a configuration step" ;;
esac
case "$_blk" in
*'return 1'*) ok "a package that installs nothing still fails" ;;
*) bad "the no-files check no longer fails a package" ;;
esac
fi
# ---- a symlinked install directory is checked at its target ---------------- #
# Book 7.x makes /bin, /sbin, /lib and /lib64 symlinks into /usr. `test -d`
# FOLLOWS a symlink but `stat` does not, so verify compared the LINK's own
# mode and owner -- always lrwxrwxrwx root:root -- against what a directory
# should look like, and reported all of them on every run:
# /bin root:root lrwxrwxrwx (want root:install, group-writable)
# Chowning a symlink changes nothing that matters, and the target (/usr/bin)
# is in the same list and checked on its own. Both the report and the --fix
# path need the guard: without it, --fix "repairs" a symlink forever.
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
# There used to be two copies of the install-dir rule and so two guards to
# count. cmd_verify calls _vfy_install_dirs now, so there is one -- and
# the check and the fix share it, which is the property this was after.
_vid="$(_slice_fn "$helper_src" _vfy_install_dirs)"
if printf '%s\n' "$_vid" | grep -q '\[ -L "\$live" \] && continue'; then
ok "a symlinked install dir is skipped by both the check and the fix"
else
bad "a symlinked install dir is still reported"
fi
# and the guard must come BEFORE the stat, or it reports then skips
if sed -n '/install directories not in the expected state/,-12p' \
"$helper_src" >/dev/null 2>&1; then :; fi
_blk="$(grep -A 4 '\[ -d "\$live" \] || continue' "$helper_src" | head -20)"
case "$_blk" in
*'-L "$live"'*) ok "the symlink guard runs before stat" ;;
*) bad "stat runs before the symlink guard" ;;
esac
fi
# ---- verify must write every finding to a file ----------------------------- #
# The console prints the first eight of each kind and then
# ... and 44265 more
# which is a number and nothing you can act on: it cannot be grepped, diffed
# against the previous run, or fed to which-package. Every finding goes to a
# log, tagged by kind, while the console stays short.
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
_p=""
grep -q '^VERIFY_LOG=' "$helper_src" || _p="$_p;verify has no log file"
grep -q '_vfy_log_open' "$helper_src" || _p="$_p;the log is never opened"
# every truncated list must also be written in full
for _k in wrong-owner unclaimed installdir; do
grep -q "$_k" "$helper_src" || _p="$_p;nothing logs '$_k' findings"
done
# and the summary must say where it is, or nobody finds it
grep -q 'full report: \$VERIFY_LOG' "$helper_src" \
|| _p="$_p;the summary never names the log"
# a read-only state dir must not abort verify -- the report still matters
grep -q 'VERIFY_LOG=""; return 0' "$helper_src" \
|| _p="$_p;verify dies instead of running when the log cannot be written"
_o="$( STATE=/tmp/_vst; SNAP_ROOT=/; VERIFY_LOG=/tmp/_vst/verify.log
rm -rf /tmp/_vst
eval "$(sed -n '/^_vfy_log_open() {/,/^}/p' "$helper_src")"
eval "$(sed -n '/^_vfy_log() {/,/^}/p' "$helper_src")"
_vfy_log_open
_vfy_log wrong-owner "/usr/bin/python3 is root want p_python"
_vfy_log unclaimed "/usr/bin/lfs-helper"
[ -s /tmp/_vst/verify.log ] || echo "the log is empty after findings"
grep -q '^wrong-owner' /tmp/_vst/verify.log || echo "wrong-owner not tagged"
grep -q '^unclaimed' /tmp/_vst/verify.log || echo "unclaimed not tagged"
# a fresh run must not append to the last one's findings
_vfy_log_open
grep -q '^wrong-owner' /tmp/_vst/verify.log && echo "the log is not reset per run"
# an unwritable location must be survivable
VERIFY_LOG=/dev/null/nope/verify.log
_vfy_log_open >/dev/null 2>&1 || echo "an unwritable log aborts verify" )"
[ -n "$_o" ] && _p="$_p;$(printf '%s' "$_o" | tr '\n' ';')"
if [ -n "$_p" ]; then
printf '%s\n' "${_p#;}" | tr ';' '\n' | while IFS= read -r m; do
[ -n "$m" ] && bad "$m"
done
else
ok "verify writes every finding to a log, tagged by kind"
fi
fi
# ---- the built system gets the whole toolchain, and it has an owner -------- #
# Only lfs-helper was copied into the tree, because only lfs-helper runs during
# the build. But the built system needs the rest -- packagemanager cannot be
# installed BY packagemanager -- and a system that boots without them has no
# way to install anything at all. They are a few files and they refresh on
# every `lfs run`.
#
# They are also 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.
python3 - "$LFS_TOOL" <<'PYTOOLS'
import sys, os, tempfile, importlib.machinery as m
lfs = m.SourceFileLoader('lfs', sys.argv[1]).load_module()
problems = []
for t in ("lfs-helper", "packagemanager", "packagemanager_install", "blfs"):
if t not in getattr(lfs, "TOOLCHAIN_SCRIPTS", ()):
problems.append("%s is not copied into the tree" % t)
# the owner is a package user like any other, so it carries the prefix
if lfs.pkgusr_prefix() + lfs.PKGUSR_TOOLS_USER != "p_pkgusr":
problems.append("the tools' owner is %r, not p_pkgusr"
% (lfs.pkgusr_prefix() + lfs.PKGUSR_TOOLS_USER))
# the uid must be read from the TREE's passwd, never this machine's -- a host
# uid means nothing inside the chroot, the same mistake book 3.1 warns about
d = tempfile.mkdtemp()
os.makedirs(os.path.join(d, "etc"))
if lfs._uid_in_tree(d, "p_pkgusr") is not None:
problems.append("a uid is invented when the tree has no passwd file")
with open(os.path.join(d, "etc", "passwd"), "w") as f:
f.write("root:x:0:0::/root:/bin/bash\np_pkgusr:x:10042:10042::/usr/src:/bin/bash\n")
if lfs._uid_in_tree(d, "p_pkgusr") != 10042:
problems.append("the tree's own passwd file is not used")
if lfs._uid_in_tree(d, "nosuchuser") is not None:
problems.append("an unknown user resolves to something")
if problems:
for p in problems: print(" FAIL %s" % p)
sys.exit(1)
print(" PASS the whole toolchain is installed and owned by p_pkgusr")
PYTOOLS
_count_rc $?
# lfs-helper claims them once /etc/passwd is real -- lfs cannot, because on a
# fresh tree the account does not exist until book 7.6
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
_p=""
grep -q '_adopt_pkgusr_tools' "$helper_src" || _p="$_p;the tools are never claimed"
grep -q 'PKGUSR_TOOLS=' "$helper_src" || _p="$_p;there is no list of tools to claim"
# it must run in build-all, beside the other adoption pass
case "$(_slice_fn "$helper_src" establish_ownership)" in
*_adopt_pkgusr_tools*) ;;
*) _p="$_p;the tools are not claimed when ownership is established" ;;
esac
# and it must never take another package's file
sed -n '/^_adopt_pkgusr_tools() {/,/^}/p' "$helper_src" \
| grep -q 'user_exists "\$cur" && continue' \
|| _p="$_p;claiming the tools could steal another package's file"
if [ -n "$_p" ]; then
printf '%s\n' "${_p#;}" | tr ';' '\n' | while IFS= read -r m; do
[ -n "$m" ] && bad "$m"
done
else
ok "the tools are claimed once the user database exists"
fi
fi
# ---- an ADOPTED package gets a real home, like a built one ----------------- #
# Packages built in a run owned their files and had full homes; packages
# ADOPTED from chapter 5-6 manifests in the same run did not:
# drwxr-xr-x 1 p_bison p_bison 148 p_bison <- built
# drwxr-xr-x 1 root root 86 p_gcc <- adopted
# 86 vs 148 bytes: the adopted homes were missing the profile links and had
# never been chowned. cmd_add_user was called with BOTH streams suppressed,
# so whatever went wrong said nothing. Adoption now initialises the home
# explicitly (init_package_user_home is idempotent) and stderr is let through.
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
_blk="$(sed -n '/^_vfy_manifest_ownership() {/,/^}/p' "$helper_src")"
_p=""
case "$_blk" in
*'init_package_user_home "$owner"'*) ;;
*) _p="$_p;an adopted package's home is never initialised" ;;
esac
case "$_blk" in
*'cmd_add_user "$owner" >/dev/null 2>&1'*)
_p="$_p;adoption still hides why a user could not be created" ;;
esac
if [ -n "$_p" ]; then
printf '%s\n' "${_p#;}" | tr ';' '\n' | while IFS= read -r m; do
[ -n "$m" ] && bad "$m"
done
else
ok "an adopted package gets an initialised, owned home"
fi
fi
# ---- one account, one prefix ----------------------------------------------- #
# A config step used to be `p_cfg_bootscripts`: pkg_owner_name applied the
# PACKAGE prefix to every name regardless of kind, so the account wore its own
# `cfg_` and `p_` on top of it. Neither prefix then identified the account --
# `p_` said "package" while it lived in /usr/src/cfg -- and every rule that
# reads a prefix had to special-case the stacked form.
#
# The fix is that kind is decided ONCE (pkgusr_kind) and the prefix and the
# root are both answered from it, so a name cannot be given one kind's prefix
# and placed in another kind's root.
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
_o="$( PKGUSR_PREFIX="p_"; CFGUSR_PREFIX="cfg_"
PKGUSR_ROOT=/usr/src/pkgusr; CFGUSR_ROOT=/usr/src/cfg
for fn in pkgusr_kind pkgusr_prefix_for pkgusr_root_for \
unprefix_pkg_user pkg_owner_name pkgusr_home_for; do
eval "$(_slice_fn "$helper_src" "$fn")"
done
[ "$(pkg_owner_name cfg_bootscripts)" = "cfg_bootscripts" ] \
|| echo "a config step still gets the package prefix: $(pkg_owner_name cfg_bootscripts)"
[ "$(pkg_owner_name gcc)" = "p_gcc" ] \
|| echo "a package lost its prefix: $(pkg_owner_name gcc)"
# kind decides both answers, so they cannot disagree
[ "$(pkgusr_kind cfg_nsswitch)" = "cfg" ] || echo "a cfg step is not kind cfg"
[ "$(pkgusr_kind gcc)" = "pkg" ] || echo "a package is not kind pkg"
# a tree or a config written before the split holds stacked names; they
# are repaired on the way through rather than carried forward
[ "$(pkg_owner_name p_cfg_bootscripts)" = "cfg_bootscripts" ] \
|| echo "a stacked legacy name survives: $(pkg_owner_name p_cfg_bootscripts)"
# and the whole thing is still idempotent -- that property is what lets
# every call site apply it without knowing which kind of name it holds
for _n in gcc p_gcc cfg_bootscripts p_cfg_bootscripts; do
_a="$(pkg_owner_name "$_n")"; _b="$(pkg_owner_name "$_a")"
[ "$_a" = "$_b" ] || echo "not idempotent: $_n -> $_a -> $_b"
done )"
if [ -z "$_o" ]; then
ok "each kind of account wears exactly one prefix, its own"
else
printf '%s\n' "$_o" | while IFS= read -r m; do [ -n "$m" ] && bad "$m"; done
fi
fi
# packagemanager must reach the same answer, or the built system looks for
# accounts the build never created
pm_src="$(dirname "$LFS_TOOL")/packagemanager"
if [ -f "$pm_src" ]; then
python3 - "$pm_src" <<'PYONEPFX'
import re, sys
src = open(sys.argv[1]).read()
problems = []
ns = {"os": __import__("os"), "BASE_DIR": "/usr/src",
"PKGUSR_SUBDIR": "pkgusr", "CFGUSR_SUBDIR": "cfg",
"_DEFAULT_CONFIG": {"collector_prefix": "sysgroup", "user_prefix": "u",
"pkgusr_prefix": "p", "cfguser_prefix": "cfg",
"main_user": ""}}
ns["load_config"] = lambda: dict(ns["_DEFAULT_CONFIG"])
for fn in ("collector_prefix", "user_prefix", "pkgusr_prefix", "cfguser_prefix",
"pkgusr_kind", "pkgusr_name", "unprefix_pkgusr", "pkgusr_root",
"pkgusr_home"):
m = re.search(r"^def %s\(.*?(?=\n\ndef |\n\n# |\nPKGUSR_SUBDIR)" % fn, src, re.S | re.M)
if not m:
problems.append("packagemanager has no %s" % fn)
continue
exec(m.group(0), ns)
if not problems:
want = {
# name -> (account name, home)
"gcc": ("p_gcc", "/usr/src/pkgusr/p_gcc"),
"p_gcc": ("p_gcc", "/usr/src/pkgusr/p_gcc"),
"cfg_bootscripts": ("cfg_bootscripts", "/usr/src/cfg/cfg_bootscripts"),
# the stacked legacy form is repaired, not carried forward
"p_cfg_bootscripts": ("cfg_bootscripts", "/usr/src/cfg/cfg_bootscripts"),
"u_firefox": ("u_firefox", "/usr/src/u_firefox"),
}
for given, (name, home) in want.items():
got = ns["pkgusr_name"](given)
if got != name:
problems.append("packagemanager names %s as %s, want %s" % (given, got, name))
got = ns["pkgusr_home"](given)
if got != home:
problems.append("packagemanager puts %s at %s, want %s" % (given, got, home))
again = ns["pkgusr_name"](ns["pkgusr_name"](given))
if again != ns["pkgusr_name"](given):
problems.append("packagemanager is not idempotent on %s" % given)
if problems:
for p in problems:
print(" FAIL %s" % p)
sys.exit(1)
print(" PASS packagemanager and lfs-helper name each kind identically")
PYONEPFX
_count_rc $?
fi
# ---- a step that is not a package leaves no home behind --------------------- #
# cmd_build did `mkdir -p "$(pkgusr_home_for "$name")"` for EVERY root step,
# including the ones _is_not_a_package will never give an account. A sanity run
# then found /usr/src/pkgusr/p_init-dirs and p_init-files: directories with no
# matching account, which every ownership pass has to report as strays.
#
# init-*, last-step and refind own no files, so they are staged and run out of
# the state directory instead -- beside the scripts they came from.
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
_o="$( STATE=/.lfs-pkgusr; SCRIPTS="$STATE/scripts"
PKGUSR_PREFIX="p_"; CFGUSR_PREFIX="cfg_"
PKGUSR_ROOT=/usr/src/pkgusr; CFGUSR_ROOT=/usr/src/cfg
for fn in pkgusr_kind pkgusr_prefix_for pkgusr_root_for unprefix_pkg_user \
pkg_owner_name pkgusr_home_for _is_not_a_package step_stage_dir; do
eval "$(_slice_fn "$helper_src" "$fn")"
done
# an empty answer must not read as "not under an account root"
command -v step_stage_dir >/dev/null 2>&1 \
|| echo "there is no step_stage_dir -- every root step still gets a home"
for _s in init-dirs init-files last-step refind; do
_d="$(step_stage_dir "$_s" 2>/dev/null)"
[ -n "$_d" ] || { echo "$_s resolves to no directory at all"; continue; }
case "$_d" in
"$PKGUSR_ROOT"/*|"$CFGUSR_ROOT"/*)
echo "$_s still gets a home under an account root" ;;
esac
done
# a real package is unaffected: it still runs from its user's home
[ "$(step_stage_dir gcc)" = "$PKGUSR_ROOT/p_gcc" ] \
|| echo "a package no longer runs from its own home: $(step_stage_dir gcc)" )"
if [ -z "$_o" ]; then
ok "a step with no account leaves no directory under the account roots"
else
printf '%s\n' "$_o" | while IFS= read -r m; do [ -n "$m" ] && bad "$m"; done
fi
# the staged path, the mkdir, the cd and the failure banner must be ONE
# value. They were four separate calls to pkgusr_home_for, free to disagree.
_cb="$(_slice_fn "$helper_src" cmd_build)"
case "$_cb" in
*'cd "$(pkgusr_home_for'*)
bad "the build still re-derives the directory it cd's into" ;;
*) ok "the staged script, the cd and the banner share one path" ;;
esac
fi
# ---- the account roots are a decision, not a side effect -------------------- #
# /usr/src/pkgusr and /usr/src/cfg came out root:root 0755 because `mkdir -p`
# on the first home created them with root's umask. Nothing said so, and
# nothing would have noticed it changing.
#
# They carry the same root:install 1775 as /usr/src above them -- one rule for
# the subtree instead of three directories with two rules. The STICKY BIT is
# what makes group-writable safe and is not optional: without it any member of
# `install` could delete or rename another package's home.
#
# They are still NOT install dirs. Being in never_adopt_list is the other
# half: the mode is shared, but no package may ever take one as its own.
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
_p=""
_ep="$(_slice_fn "$helper_src" ensure_pkgusr_roots)"
case "$_ep" in
*'chmod 1775'*) ;;
*) _p="$_p;the account roots have no stated mode" ;;
esac
case "$_ep" in
*set_install_dir_owner*) ;;
*) _p="$_p;the account roots do not get the install group" ;;
esac
# sticky, or group-write means any package can delete another's home
case "$_ep" in
*17[0-9][0-9]*) ;;
*) _p="$_p;the account roots are group-writable WITHOUT the sticky bit" ;;
esac
case "$(_slice_fn "$helper_src" init_package_user_home)" in
*ensure_pkgusr_roots*) ;;
*) _p="$_p;the account roots are still created as a side effect" ;;
esac
# never_adopt_list must reach them through pkgusr_roots -- a second literal
# list is one more place to forget when a root is added
case "$(_slice_fn "$helper_src" never_adopt_list)" in
*pkgusr_roots*) ;;
*) _p="$_p;never_adopt_list does not cover the account roots" ;;
esac
# and they must NOT be install dirs
if _slice_fn "$helper_src" install_dirs_list | grep -qx '/usr/src/pkgusr'; then
_p="$_p;the package root is an install dir -- any package user could delete a home"
fi
if [ -n "$_p" ]; then
printf '%s\n' "${_p#;}" | tr ';' '\n' | while IFS= read -r m; do
[ -n "$m" ] && bad "$m"
done
else
ok "the account roots are created deliberately and never adopted"
fi
fi
# ---- /sources is normalised on the way into the chroot ---------------------- #
# Book 3.1 wants $LFS/sources root:root 1777. The host `lfs` account comes from
# book 4.3's own useradd, which pins no uid, so it lands wherever the host had a
# gap -- 10753 on a Debian host. Package users start at 10000, so that uid does
# not merely read as a number inside the chroot, it COLLIDES with a real package
# user and tarballs report as owned by something unrelated.
#
# _normalize_sources was only called from get-sources and restart, so a tree set
# up before it existed kept the host uid forever. Entry is the one path every
# build goes through, whatever it did before.
python3 - "$LFS_TOOL" <<'PYSRCNORM'
import re, sys
src = open(sys.argv[1]).read()
m = re.search(r"^def cmd_bs_chroot\(.*?(?=\n\ndef )", src, re.S | re.M)
problems = []
if not m:
problems.append("cmd_bs_chroot is gone")
else:
body = m.group(0)
enter = body.split('if action == "enter"')
if len(enter) < 2:
problems.append("chroot enter is gone")
elif "_normalize_sources" not in enter[1]:
problems.append("nothing normalises /sources on chroot entry")
prep = body.split('if action == "prepare"')
if len(prep) > 1 and "_normalize_sources" not in prep[1].split('if action ==')[0]:
problems.append("nothing normalises /sources after the 7.2 handover")
if problems:
for p in problems:
print(" FAIL %s" % p)
sys.exit(1)
print(" PASS /sources is given back to root on the way into the chroot")
PYSRCNORM
_count_rc $?
# ---- a wrapper must never stand in for a privileged tool -------------------- #
# make_wrappers ships a `chgrp` and a `chown` that print a note and exit 0, so a
# PACKAGE USER cannot change ownership. They are ordinary files on $PATH. If
# that directory is ever ahead of /usr/bin for root, root's own privileged calls
# resolve to them too -- and they report success while doing nothing.
#
# A tree ran 34 packages that way: init-pkgusr said
# # 46 install directories now belong to group 'install'
# with an empty failure list, while every one was still root:root. No package
# user could write to /usr/bin, `verify --fix` could not repair it (its chown
# went through the same wrapper), and nothing said why.
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
_p=""
# the wrappers really do exit 0 without acting -- that is the hazard
_slice_fn "$helper_src" make_wrappers | grep -q 'package users cannot change ownership' \
|| _p="$_p;the chown wrapper no longer explains itself"
# so ownership and mode must go through the binary, by absolute path
for _fn in real_chown real_chgrp real_chmod; do
_slice_fn "$helper_src" "$_fn" | grep -q . \
|| _p="$_p;there is no $_fn -- a wrapper can shadow root's own calls"
done
_slice_fn "$helper_src" _real_tool | grep -q '/usr/bin/' \
|| _p="$_p;_real_tool does not resolve an absolute path"
# and no privileged pass may call the bare command any more
for _fn in cmd_init_pkgusr init_package_user_home _vfy_install_dirs ensure_pkgusr_roots; do
# only where the tool is INVOKED -- start of a command, or after
# &&/||/;/!. Matching it anywhere caught the warning text that explains
# the hazard, which would have made this test impossible to satisfy.
if _slice_fn "$helper_src" "$_fn" \
| grep -vE '^[[:space:]]*#' \
| grep -qE '(^[[:space:]]*|&&[[:space:]]*|\|\|[[:space:]]*|;[[:space:]]*|![[:space:]]*)(chown|chgrp|chmod) '; then
_p="$_p;$_fn still calls a bare chown/chgrp/chmod"
fi
done
if [ -n "$_p" ]; then
printf '%s\n' "${_p#;}" | tr ';' '\n' | while IFS= read -r m; do
[ -n "$m" ] && bad "$m"
done
else
ok "root's ownership calls cannot resolve to a package-user wrapper"
fi
# exit 0 is not proof. init-pkgusr must read the group back.
_blk="$(_slice_fn "$helper_src" cmd_init_pkgusr)"
_p=""
# owner AND group: the rule is root:install, and reading back only the group
# is how /usr/share sat at lfs:install without anything noticing
case "$_blk" in
*'stat -c %U:%G'*) ;;
*) _p="$_p;init-pkgusr trusts the exit code instead of reading the result back" ;;
esac
case "$_blk" in
*'NO install directories'*) ;;
*) _p="$_p;init-pkgusr reports zero install directories as success" ;;
esac
if [ -n "$_p" ]; then
printf '%s\n' "${_p#;}" | tr ';' '\n' | while IFS= read -r m; do
[ -n "$m" ] && bad "$m"
done
else
ok "init-pkgusr confirms the install group took, and zero is an error"
fi
fi
# ---- an empty override must not disable the whole scheme -------------------- #
# install_dirs_list is overridable through $INSTALLDIRS and returned early when
# the file existed. An override yielding nothing meant NO directory was given
# to the install group, NO directory was protected from adoption, and `verify`
# reported a clean tree because it walked the same empty list. Nine callers,
# one silent no-op.
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
_o="$( STATE=/tmp/_idl.$$; INSTALLDIRS="$STATE/installdirs.lst"
mkdir -p "$STATE"
eval "$(_slice_fn "$helper_src" install_dirs_list)"
warn() { :; }
# no override at all -> the built-in list
[ "$(install_dirs_list 2>/dev/null | grep -c .)" -gt 10 ] \
|| echo "the built-in list is empty"
# an override with only comments and blanks -> fall back, never nothing
printf '# just a comment\n\n \n' > "$INSTALLDIRS"
[ "$(install_dirs_list 2>/dev/null | grep -c .)" -gt 10 ] \
|| echo "an empty override silently disables every install directory"
# a real override is still honoured
printf '/usr/bin\n/usr/lib\n' > "$INSTALLDIRS"
[ "$(install_dirs_list 2>/dev/null | grep -c .)" = 2 ] \
|| echo "a real override is no longer honoured"
rm -rf "$STATE" )"
if [ -z "$_o" ]; then
ok "an override that lists nothing falls back to the built-in list"
else
printf '%s\n' "$_o" | while IFS= read -r m; do [ -n "$m" ] && bad "$m"; done
fi
fi
# ---- an option is not a package name ---------------------------------------- #
# /usr/src/pkgusr/p_--version turned up in a sanity run: a home for an account
# that could never exist, left by something that reached add-user with
# "--version" in the name position.
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
_slice_fn "$helper_src" _refuse_option_as_name | grep -q 'looks like an option' \
&& case "$(_slice_fn "$helper_src" cmd_add_user)" in
*_refuse_option_as_name*) ok "a name starting with a dash is refused, not made into a user" ;;
*) bad "add-user does not refuse an option-shaped name" ;;
esac \
|| bad "there is no guard against an option being used as a package name"
fi
# ---- the sanity report checks the group it prints --------------------------- #
# Section 1 printed owner:group from the start and asserted only the mode. A
# tree ran 34 packages with every install directory at root:root 775 --
# group-writable, but group `root`, while package users are in `install`. The
# column that would have shown it was decoration.
san="$(dirname "$LFS_TOOL")/lfs-sanity.sh"
if [ -f "$san" ]; then
_p=""
grep -q "want 'install'" "$san" \
|| _p="$_p;the sanity report does not check the install group"
grep -q "a package has taken a shared directory" "$san" \
|| _p="$_p;the sanity report does not check who owns a shared directory"
if [ -n "$_p" ]; then
printf '%s\n' "${_p#;}" | tr ';' '\n' | while IFS= read -r m; do
[ -n "$m" ] && bad "$m"
done
else
ok "the sanity report asserts the install-dir owner and group"
fi
fi
# ---- one wrapper directory, not 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.
#
# /usr/lib/pkgusr is the survivor: $STATE is build state, and packagemanager on
# the finished system hands its users the same profile.
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
_p=""
grep -q '^WRAPPERS="\${LFS_WRAPPERS:-\${SNAP_ROOT%/}/usr/lib/pkgusr}"' "$helper_src" \
|| _p="$_p;the wrapper directory is not /usr/lib/pkgusr"
grep -q 'WRAPPERS="\$STATE/wrappers"' "$helper_src" \
&& _p="$_p;the old \$STATE/wrappers definition is still there"
# the profile must not name the directory a second time
case "$(_slice_fn "$helper_src" ensure_pkgusr_etc)" in
*'PATH=/usr/lib/pkgusr:'*)
_p="$_p;the profile still hardcodes the wrapper path instead of using \$WRAPPERS" ;;
esac
case "$(_slice_fn "$helper_src" ensure_pkgusr_etc)" in
*'s|@WRAPPERS@|$WRAPPERS|'*) ;;
*) _p="$_p;the profile's PATH is not filled in from \$WRAPPERS" ;;
esac
# and it must be protected: under /usr/lib, which IS an install dir, so
# without this any package user could rewrite the wrappers that constrain it
case "$(_slice_fn "$helper_src" never_adopt_list)" in
*'"$WRAPPERS"'*) ;;
*) _p="$_p;the wrapper directory can be adopted or given to the install group" ;;
esac
case "$(_slice_fn "$helper_src" make_wrappers)" in
*'chown root:root "$WRAPPERS"'*) ;;
*) _p="$_p;the wrappers are not explicitly root-owned" ;;
esac
if [ -n "$_p" ]; then
printf '%s\n' "${_p#;}" | tr ';' '\n' | while IFS= read -r m; do
[ -n "$m" ] && bad "$m"
done
else
ok "there is one wrapper directory, and it outlives the build"
fi
fi
# packagemanager builds the same profile for the finished system, so it must
# reach the same path -- a second hardcoded copy is how these drifted
pm_src="$(dirname "$LFS_TOOL")/packagemanager"
if [ -f "$pm_src" ]; then
_p=""
grep -q '^WRAPPERS = os.environ.get("LFS_WRAPPERS", "/usr/lib/pkgusr")' "$pm_src" \
|| _p="$_p;packagemanager has no WRAPPERS setting"
grep -q 'PATH=/usr/lib/pkgusr:' "$pm_src" \
&& _p="$_p;packagemanager still hardcodes the wrapper path in the profile"
grep -q '_PKGUSR_BASH_PROFILE.replace("@WRAPPERS@", WRAPPERS)' "$pm_src" \
|| _p="$_p;packagemanager does not fill the wrapper path into the profile"
if [ -n "$_p" ]; then
printf '%s\n' "${_p#;}" | tr ';' '\n' | while IFS= read -r m; do
[ -n "$m" ] && bad "$m"
done
else
ok "both tools write a profile pointing at the same wrapper directory"
fi
fi
# ---- an install directory is root:install, owner AND group ------------------- #
# init-pkgusr set only the GROUP, while _vfy_install_dirs demands root:install
# and `verify --fix` applies both. Two rules for one directory.
#
# They disagreed on a real build: chapters 5-6 build as the `lfs` user, and book
# 7.2's handover only reaches one level below $LFS, so /usr/share stayed
# lfs-owned and came out
# lfs:install drwxrwxr-x /usr/share
# The group looked right and nothing said the owner was not.
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
_blk="$(_slice_fn "$helper_src" cmd_init_pkgusr)"
_p=""
# owner AND group -- through the shared rule, which applies both by number.
# This used to look for `real_chown root:install`, which is the by-name form
# that cannot work before book 7.6 writes /etc/passwd.
case "$_blk" in
*'set_install_dir_owner "$d"'*) ;;
*) _p="$_p;init-pkgusr sets the group but not the owner of an install directory" ;;
esac
case "$_blk" in
*'stat -c %U:%G'*) ;;
*) _p="$_p;init-pkgusr reads back only the group, so a wrong owner goes unnoticed" ;;
esac
# the two rules must want the same thing
case "$(_slice_fn "$helper_src" _vfy_install_dirs)" in
*root:install*) ;;
*) _p="$_p;verify no longer wants root:install" ;;
esac
if [ -n "$_p" ]; then
printf '%s\n' "${_p#;}" | tr ';' '\n' | while IFS= read -r m; do
[ -n "$m" ] && bad "$m"
done
else
ok "init-pkgusr and verify agree that an install dir is root:install"
fi
fi
# ---- every tool we install gets an owner ------------------------------------- #
# install_helper copies lfs/lfs-helper/packagemanager/blfs and calls
# _chown_tools. _install_pkgusr_helpers copied the hint's scripts beside them
# and never did -- so /usr/bin/list_package, uninstall_package,
# forall_direntries_from and the rest came out root:root while the four tools
# next to them belonged to p_pkgusr. Two copy routines, one claiming ownership.
#
# The WRAPPER directory is the deliberate exception: it stays root:root, because
# a package user that could rewrite chown or install could rewrite the rule that
# constrains it.
python3 - "$LFS_TOOL" <<'PYHELPEROWN'
import re, sys
src = open(sys.argv[1]).read()
problems = []
m = re.search(r"^def _install_pkgusr_helpers\(.*?(?=\n\ndef )", src, re.S | re.M)
if not m:
problems.append("_install_pkgusr_helpers is gone")
else:
body = m.group(0)
if "_chown_tools" not in body:
problems.append("the hint's helpers are copied but never given an owner")
if "wrapper_sub" not in body:
problems.append("the wrapper directory is not excluded from the chown")
if not re.search(r'^WRAPPERS = os\.environ\.get\("LFS_WRAPPERS"', src, re.M):
problems.append("lfs does not read the shared WRAPPERS setting")
# and the helper table must not spell the wrapper path out a fourth time
tbl = re.search(r"PKGUSR_HELPERS = \[(.*?)\n\]", src, re.S)
if tbl and '"usr/lib/pkgusr"' in tbl.group(1):
problems.append("PKGUSR_HELPERS still hardcodes the wrapper path")
if problems:
for p in problems:
print(" FAIL %s" % p)
sys.exit(1)
print(" PASS every tool installed into the tree is given an owner")
PYHELPEROWN
_count_rc $?
# ---- scratch never enters a manifest ---------------------------------------- #
# $BUILD_ROOT is the busiest directory on the system during a build: unpacked
# source trees, object files, the tarballs themselves. Every one is newer than
# the build stamp, so the touched-file scan swallowed the whole scratch tree
# into every package's manifest 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:
# /build/bash-5.3.tar.gz is p_shadow want p_acl
# The two snapshot scans already excluded it; the build's own scan did not.
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
_cb="$(_slice_fn "$helper_src" cmd_build)"
_p=""
case "$_cb" in
*'-not -path "$_bld/*"'*) ;;
*) _p="$_p;the build scratch is still scanned into every manifest" ;;
esac
case "$_cb" in
*'BUILD_ROOT:-$r/build'*) ;;
*) _p="$_p;the scratch exclusion does not follow \$BUILD_ROOT" ;;
esac
# and verify must ignore scratch left in manifests written before this
case "$(_slice_fn "$helper_src" _vfy_manifest_ownership)" in
*'BUILD_ROOT'*) ;;
*) _p="$_p;verify still reports scratch recorded by an older build" ;;
esac
if [ -n "$_p" ]; then
printf '%s\n' "${_p#;}" | tr ';' '\n' | while IFS= read -r m; do
[ -n "$m" ] && bad "$m"
done
else
ok "the build scratch is never recorded as a package's files"
fi
fi
# ---- some files belong to no package ---------------------------------------- #
# Every package that installs an info page rewrites /usr/share/info/dir, and
# ldconfig rewrites /etc/ld.so.cache after every install. Recording those in a
# manifest makes verify report a conflict no repair can settle -- the next
# package takes them straight back:
# /usr/share/info/dir is p_gcc want p_binutils
# /etc/ld.so.cache is p_libcap want p_glibc
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
_o="$( SNAP_ROOT=/
eval "$(_slice_fn "$helper_src" never_claim_list)"
eval "$(_slice_fn "$helper_src" _never_claim_load)"
eval "$(_slice_fn "$helper_src" is_never_claimed)"
for f in /usr/share/info/dir /etc/ld.so.cache /var/cache/ldconfig/aux-cache; do
is_never_claimed "$f" || echo "$f can still be claimed by a package"
done
# the wrapper directory too: it is root:root on purpose
is_never_claimed /usr/lib/pkgusr/chown \
|| echo "a wrapper can be claimed by a package"
# but a real package file must NOT be swept up by this
is_never_claimed /usr/bin/bash \
&& echo "an ordinary package file is treated as unclaimable" )"
if [ -z "$_o" ]; then
ok "shared indexes and generated caches are owned by no package"
else
printf '%s\n' "$_o" | while IFS= read -r m; do [ -n "$m" ] && bad "$m"; done
fi
fi
# ---- the 7.2 handover is decided by the tree, not one inode ------------------ #
# `_owner_is_root` looks at $LFS/usr. The pass it guarded is
# `chown -h -R root:root` over EIGHT trees. A tree whose /usr was already
# root-owned got no handover at all, and everything chapters 5-6 created below
# it kept the host uid:
# drwxr-xr-x 1 lfs lfs /usr/share/gcc-15.2.0/python/libstdcxx
# lfs:install drwxrwxr-x /usr/share
# It must also refuse to run once package users own files -- chown -R root:root
# over /usr is right before chapter 8 and catastrophic after it.
python3 - "$LFS_TOOL" <<'PYHANDOVER'
import re, sys
src = open(sys.argv[1]).read()
problems = []
for fn in ("_handover_needed", "_handover_is_safe"):
if not re.search(r"^def %s\(" % fn, src, re.M):
problems.append("there is no %s" % fn)
m = re.search(r"^def _handover_needed\(.*?(?=\n\ndef )", src, re.S | re.M)
if m and "_find_uid" not in m.group(0):
problems.append("_handover_needed still decides from a single directory")
m = re.search(r"^def _handover_is_safe\(.*?(?=\n\ndef )", src, re.S | re.M)
# It must ask the TREE's passwd which accounts are package users, not infer it
# from a uid range: book 4.3 pins no uid for `lfs`, so on one host the build
# account came out 10753 -- inside the package range -- and a range filter
# excluded the very user it was looking for.
if m and "_pkg_users_in_tree" not in m.group(0):
problems.append("_handover_is_safe infers package users from a uid range")
if not re.search(r"^def _pkg_users_in_tree\(", src, re.M):
problems.append("there is no _pkg_users_in_tree")
m2 = re.search(r"^def _handover_needed\(.*?(?=\n\ndef )", src, re.S | re.M)
if m2 and "PKG_UID_MIN" in m2.group(0):
problems.append("_handover_needed still filters the build user out by uid range")
m = re.search(r"^def cmd_bs_chroot\(.*?(?=\n\ndef )", src, re.S | re.M)
if m:
body = m.group(0)
if "_handover_needed" not in body:
problems.append("chroot prepare still guards 7.2 on _owner_is_root alone")
if "_handover_is_safe" not in body:
problems.append("chroot prepare would run chown -R over a chapter-8 tree")
if problems:
for p in problems:
print(" FAIL %s" % p)
sys.exit(1)
print(" PASS the 7.2 handover is decided by the tree and refuses a built one")
PYHANDOVER
_count_rc $?
# and a tree that already has the damage must be repairable
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
_p=""
_slice_fn "$helper_src" _vfy_build_user_leftovers | grep -q '\-user lfs' \
|| _p="$_p;verify cannot find files the build user still owns"
_slice_fn "$helper_src" _vfy_build_user_leftovers | grep -q 'real_chown -h root:root' \
|| _p="$_p;verify --fix cannot hand the build user's leftovers back to root"
case "$(_slice_fn "$helper_src" cmd_verify)" in
*_vfy_build_user_leftovers*) ;;
*) _p="$_p;verify does not run the build-user pass" ;;
esac
if [ -n "$_p" ]; then
printf '%s\n' "${_p#;}" | tr ';' '\n' | while IFS= read -r m; do
[ -n "$m" ] && bad "$m"
done
else
ok "a tree the handover skipped can be repaired from inside the chroot"
fi
fi
# ---- every function called is a function that exists ------------------------ #
# `verify --fix` called cmd_fix_orphans, which does not exist -- `fix-orphans`
# is dispatched as an ALIAS for `verify --fix`, so the call was both undefined
# and circular. It died mid-repair:
# line 4793: cmd_fix_orphans: command not found
# after chowning 3473 paths, leaving the pass half-done and the status wrong.
#
# A missing function in the REPAIR path is the worst place for one: it only
# fires when something is already broken, which is when it is least welcome.
for _t in lfs-helper; do
_src="$(dirname "$LFS_TOOL")/$_t"
[ -f "$_src" ] || continue
_missing=""
# comments stripped first: the fix for this bug NAMES the function it
# removed, and matching that text would make the test permanently red
for _fn in $(grep -vE '^[[:space:]]*#' "$_src" \
| grep -oE '(^|[^_[:alnum:]])cmd_[a-z0-9_]+' \
| grep -oE 'cmd_[a-z0-9_]+' | sort -u); do
grep -qE "^${_fn}\(\) \{" "$_src" || _missing="$_missing $_fn"
done
if [ -n "$_missing" ]; then
for _m in $_missing; do bad "$_t calls $_m, which is not defined"; done
else
ok "$_t defines every cmd_* function it calls"
fi
done
# ---- a package builds in its own home, not a shared scratch ----------------- #
# /build was a permanent ownership fight. Every build's touched-file scan swept
# the whole tree in, so each manifest claimed every other package's sources and
# chowned them; the next package chowned them back. Repairing it made it worse,
# because the question had no answer:
# /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 the repair)
#
# Under the package user's home it is settled by construction: the user creates
# the tree so the user owns it, and $SRCROOT is already excluded from every tree
# scan, so it can never reach a manifest at all.
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
_o="$( SNAP_ROOT=/; SRCROOT=/usr/src
PKGUSR_ROOT=/usr/src/pkgusr; CFGUSR_ROOT=/usr/src/cfg
PKGUSR_PREFIX="p_"; CFGUSR_PREFIX="cfg_"
STATE=/.lfs-pkgusr; SCRIPTS="$STATE/scripts"
PKGUSR_BUILD_SUBDIR=src
for fn in pkgusr_kind pkgusr_prefix_for pkgusr_root_for unprefix_pkg_user \
pkg_owner_name pkgusr_home_for _is_not_a_package build_root_for; do
eval "$(_slice_fn "$helper_src" "$fn")"
done
if ! command -v build_root_for >/dev/null 2>&1; then
echo "there is no build_root_for -- the scratch is still shared"
exit 0 # the rest would be noise, not findings
fi
# a package builds under its own account's home
[ "$(build_root_for gcc)" = "/usr/src/pkgusr/p_gcc/$PKGUSR_BUILD_SUBDIR" ] \
|| echo "gcc does not build in its own home: $(build_root_for gcc)"
# an earlier stage folds onto the same package, so it reuses that tree
[ "$(build_root_for util-linux-tmp)" = "$(build_root_for util-linux)" ] \
|| echo "an earlier stage builds somewhere else than its package"
# and it must be under $SRCROOT, which every tree scan already excludes --
# that is what keeps it out of manifests without another find exclusion
case "$(build_root_for gcc)" in
"$SRCROOT"/*) ;;
*) echo "the build tree is not under SRCROOT, so scans will sweep it in" ;;
esac
# a step with no account keeps the shared scratch: it runs as root, which
# owns everything there anyway
case "$(build_root_for init-dirs)" in
/usr/src/*) echo "a step with no account was given a home to build in" ;;
esac )"
if [ -z "$_o" ]; then
ok "each package unpacks and builds inside its own account's home"
else
printf '%s\n' "$_o" | while IFS= read -r m; do [ -n "$m" ] && bad "$m"; done
fi
# and the build must actually be told about it
case "$(_slice_fn "$helper_src" cmd_build)" in
*"BUILD_ROOT='\$_bldroot'"*)
ok "the per-package build root is passed into the build" ;;
*) bad "the build still inherits whatever BUILD_ROOT it was started with" ;;
esac
fi
# ---- the root symlinks are chowned, not followed ---------------------------- #
# Book 4.2 creates /bin -> usr/bin, /lib and /sbin as the build user. They came
# out lfs:lfs, and a chown WITHOUT -h follows the link and retargets /usr/bin
# instead -- silently doing the wrong thing to the right-looking path.
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
_blk="$(_slice_fn "$helper_src" _vfy_build_user_leftovers)"
_p=""
case "$_blk" in
*'real_chown -h'*) ;;
*) _p="$_p;the build-user repair follows symlinks instead of chowning them" ;;
esac
case "$(printf '%s\n' "$_blk" | grep -vE '^[[:space:]]*#')" in
*'-type f'*) _p="$_p;the build-user scan skips symlinks and directories" ;;
esac
if [ -n "$_p" ]; then
printf '%s\n' "${_p#;}" | tr ';' '\n' | while IFS= read -r m; do
[ -n "$m" ] && bad "$m"
done
else
ok "a symlink owned by the build user is chowned, not followed"
fi
fi
# ---- the tree can be set up before book 7.6 exists -------------------------- #
# A chapter 5-6 snapshot restored before 7.6 has NO /etc/passwd -- the chroot
# prompt says "I have no name!". `chown root:install` needs `root` to resolve,
# 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 used `chgrp` and worked fine. Two rules
# for one directory, and only one of them survived an empty passwd file.
#
# Ownership is stored as a NUMBER, so use the number.
_p=""
_pre="$T/pre76"
mkdir -p "$_pre/etc" "$_pre/usr/share/man/man3"
: > "$_pre/etc/passwd" # exists, but empty: no root entry
printf 'install:x:9999:\n' > "$_pre/etc/group"
# the shape that broke: a name that cannot resolve
if chown root:install "$_pre/usr/share/man/man3" 2>/dev/null; then
: # this host HAS a root and an install group, so the fixture proves nothing
else
# numeric must work where the name did not
if chown -h 0:9999 "$_pre/usr/share/man/man3" 2>/dev/null; then
[ "$(stat -c '%u:%g' "$_pre/usr/share/man/man3")" = "0:9999" ] \
|| _p="$_p;a numeric chown did not take"
else
_p="$_p;even a numeric chown fails here -- fixture is not usable"
fi
fi
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
# one helper, used by all three passes that set an install directory
_slice_fn "$helper_src" set_install_dir_owner | grep -q '0:\$INSTALL_GID' \
|| _p="$_p;the install-dir owner is not set by number"
_slice_fn "$helper_src" install_dir_owner_ok | grep -q "%u:%g" \
|| _p="$_p;the install-dir check reads names, which are UNKNOWN before 7.6"
for _fn in cmd_init_pkgusr ensure_install_dirs_writable _vfy_install_dirs; do
_slice_fn "$helper_src" "$_fn" | grep -q 'set_install_dir_owner' \
|| _p="$_p;$_fn does not use the shared install-dir rule"
done
# and no pass may still reach for the names
for _fn in cmd_init_pkgusr ensure_install_dirs_writable _vfy_install_dirs; do
_slice_fn "$helper_src" "$_fn" | grep -vE '^[[:space:]]*#' \
| grep -qE 'chown root:install|chgrp install ' \
&& _p="$_p;$_fn still sets an install dir by name"
done
fi
if [ -n "$_p" ]; then
printf '%s\n' "${_p#;}" | tr ';' '\n' | while IFS= read -r m; do
[ -n "$m" ] && bad "$m"
done
else
ok "install directories are set by number, so a tree with no passwd works"
fi
# ---- nothing is an orphan before there is a passwd file --------------------- #
# `find -nouser` asks whether a uid resolves. With no passwd file NOTHING
# resolves, so every file in the tree is an "orphan". On a restored chapter 5-6
# tree that 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.
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
_p=""
for _fn in _vfy_orphans _has_orphaned_files _vfy_build_user_leftovers; do
_slice_fn "$helper_src" "$_fn" | grep -q '_user_db_ready' \
|| _p="$_p;$_fn runs before the user database exists"
done
if [ -n "$_p" ]; then
printf '%s\n' "${_p#;}" | tr ';' '\n' | while IFS= read -r m; do
[ -n "$m" ] && bad "$m"
done
else
ok "the ownership passes wait for book 7.6 to write /etc/passwd"
fi
fi
# ---- nothing privileged may need a name to resolve -------------------------- #
# init-pkgusr runs BEFORE book 7.6 creates /etc/passwd. The prompt in there
# literally reads "I have no name!" because not even root resolves. So
# `chown root:install` fails on every directory:
# # 0 install directories are now root:install (group-writable)
# # could not set the group on: /bin /sbin /lib /lib64 /etc /usr ...
# and every package user is then locked out of /usr/bin for the whole build.
#
# Ownership is stored as a NUMBER. uid 0 is root whether anything says so or
# not, and the install gid is ours -- init-pkgusr writes /etc/group by hand, so
# the group can exist long before any passwd entry does.
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
_p=""
_slice_fn "$helper_src" set_install_dir_owner | grep -q '0:\$INSTALL_GID' \
|| _p="$_p;an install directory is set by name, which fails before 7.6"
_slice_fn "$helper_src" install_dir_owner_ok | grep -q "%u:%g" \
|| _p="$_p;the readback uses names, so a correct directory reads as wrong"
# No privileged pass may reach for the NAMES. Match the INVOCATION, not the
# string: these functions legitimately print "now root:install" and carry
# trailing comments saying the same, and matching those made the test
# impossible to satisfy while changing nothing about the behaviour.
for _fn in cmd_init_pkgusr _vfy_install_dirs ensure_install_dirs_writable; do
_slice_fn "$helper_src" "$_fn" | grep -vE '^[[:space:]]*#' \
| grep -qE '(real_)?(chown|chgrp)( +-[a-zA-Z]+)* +(root:install|install) ' \
&& _p="$_p;$_fn still sets the group by name, which fails before 7.6"
done
if [ -n "$_p" ]; then
printf '%s\n' "${_p#;}" | tr ';' '\n' | while IFS= read -r m; do
[ -n "$m" ] && bad "$m"
done
else
ok "install directories are set by number, so 7.6 is not a prerequisite"
fi
# and the orphan pass must not fire before there is a database to be
# orphaned FROM. With no passwd file `find -nouser` matches everything: a
# real run reported 11802 orphans including / and /proc, and --fix would have
# handed the entire tree to the build user in one pass.
_p=""
case "$(_slice_fn "$helper_src" _vfy_orphans)" in
*_user_db_ready*) ;;
*) _p="$_p;the orphan pass would hand the whole tree to the build user before 7.6" ;;
esac
case "$(_slice_fn "$helper_src" _has_orphaned_files)" in
*_user_db_ready*) ;;
*) _p="$_p;a tree with no passwd file is reported as entirely orphaned" ;;
esac
if [ -n "$_p" ]; then
printf '%s\n' "${_p#;}" | tr ';' '\n' | while IFS= read -r m; do
[ -n "$m" ] && bad "$m"
done
else
ok "an orphan needs a user database to be missing from"
fi
fi
# ---- the build user's uid is not a range ------------------------------------ #
# Book 4.3's useradd pins no uid, so the host's `lfs` account lands wherever the
# host had a gap -- 10753 on a Debian host, INSIDE the package-user range. Any
# rule that identifies the build user by "uid outside 10000+" therefore excludes
# the very account it is looking for. That is the same mistake _normalize_sources
# exists to correct, and _handover_needed walked straight into it: it reported
# # 7.2 already done (tree owned by root)
# while /bin -> usr/bin was still lfs:lfs.
python3 - "$LFS_TOOL" <<'PYUIDRANGE'
import re, sys
src = open(sys.argv[1]).read()
problems = []
m = re.search(r"^def _handover_needed\(.*?(?=\n\ndef )", src, re.S | re.M)
if not m:
problems.append("_handover_needed is gone")
else:
body = re.sub(r"^\s*#.*$", "", m.group(0), flags=re.M)
if "PKG_UID_MIN" in body:
problems.append("_handover_needed still identifies the build user by uid range")
m = re.search(r"^def _handover_is_safe\(.*?(?=\n\ndef )", src, re.S | re.M)
if not m:
problems.append("_handover_is_safe is gone")
else:
body = re.sub(r"^\s*#.*$", "", m.group(0), flags=re.M)
if "_pkg_users_in_tree" not in body:
problems.append("_handover_is_safe does not read the tree's own passwd")
if not re.search(r"^def _pkg_users_in_tree\(", src, re.M):
problems.append("there is no _pkg_users_in_tree")
else:
m = re.search(r"^def _pkg_users_in_tree\(.*?(?=\n\ndef |\Z)", src, re.S | re.M)
if m and "pkgusr_prefix" not in m.group(0):
problems.append("_pkg_users_in_tree does not identify accounts by prefix")
if problems:
for p in problems:
print(" FAIL %s" % p)
sys.exit(1)
print(" PASS the build user is identified by the tree, never by a uid range")
PYUIDRANGE
_count_rc $?
# ---- one step where ownership becomes real ---------------------------------- #
# Before book 7.6 there is no /etc/passwd, so no name resolves -- not even
# root's; the prompt in the chroot literally reads "I have no name!". Every
# ownership question asked before that point gets a confident wrong answer:
#
# chown root:install fails on every directory -> "0 install directories"
# find -nouser matches every file in the tree -> 11802 "orphans"
#
# Three separate bugs, one cause: work that needs a user database was being done
# before there was one. So there is one gate now. Nothing touches ownership
# before it; everything maintains ownership after it.
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
_p=""
for _fn in ownership_established ownership_possible establish_ownership \
_ownership_checkpoint cmd_establish_ownership; do
_slice_fn "$helper_src" "$_fn" | grep -q . \
|| _p="$_p;there is no $_fn"
done
# the gate must test the user database and the tools it needs
_op="$(_slice_fn "$helper_src" ownership_possible)"
case "$_op" in
*_user_db_ready*) ;;
*) _p="$_p;the gate does not wait for the user database" ;;
esac
case "$_op" in
*chown*) ;;
*) _p="$_p;the gate does not check that chown is available" ;;
esac
# it must be durable, so a resumed or restored build does not repeat it
grep -q '^OWNERSHIP_MARK=' "$helper_src" \
|| _p="$_p;crossing the epoch is not recorded, so it repeats"
case "$(_slice_fn "$helper_src" establish_ownership)" in
*'> "$OWNERSHIP_MARK"'*) ;;
*) _p="$_p;establish_ownership never writes its marker" ;;
esac
# and everything ownership-related must be inside it, not scattered
_eo="$(_slice_fn "$helper_src" establish_ownership)"
for _need in cmd_init_pkgusr _vfy_build_user_leftovers _vfy_manifest_ownership \
_adopt_pkgusr_tools _vfy_orphans; do
case "$_eo" in
*"$_need"*) ;;
*) _p="$_p;$_need does not happen at the ownership epoch" ;;
esac
done
# reachable by hand as well as automatically
grep -q 'establish-ownership)' "$helper_src" \
|| _p="$_p;there is no way to run the ownership step by hand"
if [ -n "$_p" ]; then
printf '%s\n' "${_p#;}" | tr ';' '\n' | while IFS= read -r m; do
[ -n "$m" ] && bad "$m"
done
else
ok "ownership is established once, at one step, and recorded"
fi
# the passes that need a user database must refuse to run without one
_p=""
for _fn in _vfy_orphans _has_orphaned_files _vfy_build_user_leftovers; do
_slice_fn "$helper_src" "$_fn" | grep -q '_user_db_ready' \
|| _p="$_p;$_fn would run before there is a user database"
done
case "$(_slice_fn "$helper_src" _vfy_install_dirs)" in
*ownership_*) ;;
*) _p="$_p;verify would report 27 wrong install dirs on a chapter 5-6 tree" ;;
esac
# build-all must NOT set the package-user system up before the epoch --
# that is what printed "0 install directories are now root:install"
case "$(_slice_fn "$helper_src" cmd_build_all)" in
*ownership_established*) ;;
*) _p="$_p;build-all still sets up ownership before 7.6 has run" ;;
esac
if [ -n "$_p" ]; then
printf '%s\n' "${_p#;}" | tr ';' '\n' | while IFS= read -r m; do
[ -n "$m" ] && bad "$m"
done
else
ok "nothing asks who owns what before there is a user database"
fi
fi
# ---- the build tree must not collide with the skeleton ---------------------- #
# init_package_user_home symlinks .bash_profile, .bashrc and `build` into every
# package user's home -- `build` being the hint's build helper script. The
# per-package build tree was first called `build` too, so `mkdir -p ~/build` hit
# the symlink and the build died two lines later complaining about the wrong
# thing:
# mkdir: cannot create directory '.../p_gettext/build': File exists
# cd: /usr/src/pkgusr/p_gettext/build: Not a directory
# The recovery code then read that as a permissions problem and retried twice
# before advising collector groups. None of it was true.
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
_p=""
_slice_fn "$helper_src" pkgusr_skel_links | grep -q . \
|| _p="$_p;the skeleton links are not named in one place"
# the home skeleton must be built FROM that list, not from a second copy
case "$(_slice_fn "$helper_src" init_package_user_home)" in
*pkgusr_skel_links*) ;;
*) _p="$_p;init_package_user_home keeps its own copy of the link list" ;;
esac
# and the build subdir must not be one of them
_sub="$(grep -m1 '^PKGUSR_BUILD_SUBDIR=' "$helper_src" \
| sed 's/.*:-\([a-z_]*\)}.*/\1/')"
if [ -z "$_sub" ]; then
_p="$_p;there is no PKGUSR_BUILD_SUBDIR"
else
for _l in $(_slice_fn "$helper_src" pkgusr_skel_links \
| grep -oE "'[^']+'|[a-z._:]+" | tr ' ' '\n'); do
case "$_l" in
"$_sub":*|"$_sub") _p="$_p;the build tree is called '$_sub', which the home skeleton already links" ;;
esac
done
fi
# and a name that is taken must be reported HERE, not as a cd failure later
case "$(_slice_fn "$helper_src" cmd_build)" in
*'is not a directory'*) ;;
*) _p="$_p;a build tree that is not a directory fails somewhere else" ;;
esac
if [ -n "$_p" ]; then
printf '%s\n' "${_p#;}" | tr ';' '\n' | while IFS= read -r m; do
[ -n "$m" ] && bad "$m"
done
else
ok "the build tree cannot collide with the package user's home skeleton"
fi
fi
# ---- no function is defined twice ------------------------------------------ #
# `cmd_verify` was defined TWICE. bash keeps the last, so the tidy definition
# that called the _vfy_* helpers had never run -- and the surviving one carried
# its own inline copies of the manifest and install-dir rules, without any of
# the guards the helpers had grown.
#
# A finished 104/104 build therefore reported three things the real pass knows
# to leave alone, and --fix would have acted on all three:
# /usr/bin/hostname is p_inetutils want p_coreutils (inetutils owns it)
# /usr/share/info/dir is p_e2fsprogs want p_glibc (nobody owns it)
# /etc/group- is root want p_ncurses (useradd wrote it)
#
# A duplicate definition is invisible: both parse, one silently wins.
for _t in lfs-helper; do
_src="$(dirname "$LFS_TOOL")/$_t"
[ -f "$_src" ] || continue
_dups="$(grep -oE '^[a-z_][a-z0-9_]*\(\) \{' "$_src" | sort | uniq -d)"
if [ -n "$_dups" ]; then
printf '%s\n' "$_dups" | while IFS= read -r _d; do
bad "$_t defines ${_d%\(\) \{} more than once -- one of them never runs"
done
else
ok "$_t defines each function exactly once"
fi
done
# and verify must USE the helpers rather than carry its own copy of the rules
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
_cv="$(_slice_fn "$helper_src" cmd_verify)"
_p=""
for _need in _vfy_install_dirs _vfy_manifest_ownership _vfy_orphans \
_vfy_user_homes _vfy_build_user_leftovers _vfy_user_order; do
case "$_cv" in
*"$_need"*) ;;
*) _p="$_p;verify does not run $_need" ;;
esac
done
# no second copy of the ownership rule inline
case "$(printf '%s\n' "$_cv" | grep -vE '^[[:space:]]*#')" in
*'want $owner'*) _p="$_p;verify still carries its own copy of the ownership rule" ;;
esac
if [ -n "$_p" ]; then
printf '%s\n' "${_p#;}" | tr ';' '\n' | while IFS= read -r m; do
[ -n "$m" ] && bad "$m"
done
else
ok "verify runs the one implementation of each check"
fi
fi
# ---- a collector group is named for the package ----------------------------- #
# It took the ACCOUNT name, so owner `p_perl` produced `nimgnu_p_perl` -- two
# prefixes, and the wrong two: `nimgnu_` says collector group, `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)
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
_o="$( COLLECTOR_PREFIX=nimgnu; PKGUSR_PREFIX=p_
eval "$(_slice_fn "$helper_src" unprefix_pkg_user)"
eval "$(_slice_fn "$helper_src" collector_group_for)"
[ "$(collector_group_for p_perl)" = "nimgnu_perl" ] \
|| echo "a collector group still carries the package-user prefix: $(collector_group_for p_perl)"
# a bare package name is unchanged
[ "$(collector_group_for perl)" = "nimgnu_perl" ] \
|| echo "a bare package name does not reach the same group"
# a config step keeps its own prefix -- cfg_ is part of the step's name
[ "$(collector_group_for cfg_bootscripts)" = "nimgnu_cfg_bootscripts" ] \
|| echo "a config step's group name lost its kind: $(collector_group_for cfg_bootscripts)" )"
if [ -z "$_o" ]; then
ok "a collector group carries the collector prefix and no other"
else
printf '%s\n' "$_o" | while IFS= read -r m; do [ -n "$m" ] && bad "$m"; done
fi
fi
# ---- root's own files are root's ------------------------------------------- #
# Every cmd_add_user during a build writes /etc/passwd, /etc/group and Shadow's
# backups of them. 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 the one ownership
# mistake with no way back.
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
_o="$( ETC=/etc; SNAP_ROOT=/
eval "$(_slice_fn "$helper_src" never_claim_list)"
eval "$(_slice_fn "$helper_src" _never_claim_load)"
eval "$(_slice_fn "$helper_src" is_never_claimed)"
for f in /etc/passwd /etc/passwd- /etc/group /etc/group- \
/etc/shadow /etc/gshadow /etc/.pwd.lock; do
is_never_claimed "$f" || echo "$f can be claimed by a package"
done
# ordinary configuration is still a package's to own
is_never_claimed /etc/fstab \
&& echo "an ordinary config file was swept into the never-claim list" )"
if [ -z "$_o" ]; then
ok "the user database belongs to root and to no package"
else
printf '%s\n' "$_o" | while IFS= read -r m; do [ -n "$m" ] && bad "$m"; done
fi
# the tools we install are ours, in bin AND sbin
_p=""
grep -q 'add_package_user install_package' "$helper_src" \
|| _p="$_p;the hint's helper scripts are not claimed by p_pkgusr"
case "$(_slice_fn "$helper_src" _adopt_pkgusr_tools)" in
*'/usr/bin /usr/sbin'*) ;;
*) _p="$_p;only /usr/bin is searched, so the sbin helpers stay unclaimed" ;;
esac
# and the wrappers must not be reported as needing an owner
case "$(_slice_fn "$helper_src" _vfy_unclaimed)" in
*is_never_claimed*) ;;
*) _p="$_p;verify asks who should own the wrappers, which are root's by design" ;;
esac
if [ -n "$_p" ]; then
printf '%s\n' "${_p#;}" | tr ';' '\n' | while IFS= read -r m; do
[ -n "$m" ] && bad "$m"
done
else
ok "our own tools get an owner; the wrappers stay root's"
fi
fi
# ---- book 4.3 must not run on a built tree ---------------------------------- #
# `_chown_tree_to_lfs` is `chown -R lfs` over /usr, /etc, /var and the rest.
# Before chapter 5 that is book 4.3 and exactly right. On a BUILT tree it hands
# the finished system to the host's build account in one pass, and every
# package's ownership is gone -- recoverable only from the manifests.
#
# It happened: `lfs build-system run` on a 104/104 tree replayed step 4.2 and
# drwxrwxr-t 1 lfs install /mnt/lfs/usr
# came back with the host uid 10753 written through the tree. Inside the chroot
# that resolves to nothing -- the chroot's own `lfs` is 9998 -- so `ls -al /`
# showed a raw number where every owner should have been.
python3 - "$LFS_TOOL" <<'PY43'
import re, sys
src = open(sys.argv[1]).read()
problems = []
m = re.search(r"^def _chown_tree_to_lfs\(.*?(?=\n\ndef )", src, re.S | re.M)
if not m:
problems.append("_chown_tree_to_lfs is gone")
else:
body = m.group(0)
if "_handover_is_safe" not in body:
problems.append("book 4.3 would chown a built tree to the build user")
# the guard has to come BEFORE the chown, or it reports and then does it
# the INVOCATION, not the comment above the guard that names the command
g = body.find("if not _handover_is_safe")
c = body.find("_run_bash('chown -R lfs")
if c < 0:
problems.append("the 4.3 chown is no longer recognisable")
elif g < 0 or g > c:
problems.append("the 4.3 guard runs after the chown it is guarding")
if problems:
for p in problems:
print(" FAIL %s" % p)
sys.exit(1)
print(" PASS book 4.3 refuses to hand a built tree back to the build user")
PY43
_count_rc $?
# ---- the ownership step is visible ------------------------------------------ #
# It ran as an invisible checkpoint inside build-all: right place, right work,
# but it never appeared in `lfs-helper list`. The one moment the whole scheme
# turns on was the one moment you could not point at, resume from, or re-run.
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
_p=""
# lfs puts it in the step order, straight after book 7.6
grep -q 'order.insert(order.index("init-files") + 1, "init-ownership")' "$LFS_TOOL" \
|| _p="$_p;init-ownership is not in the step order"
# lfs-helper performs it itself -- there is no script for it in the book
case "$(_slice_fn "$helper_src" cmd_build)" in
*'"$name" = "init-ownership"'*) ;;
*) _p="$_p;lfs-helper would look for a book script for init-ownership" ;;
esac
# it owns no files, so it gets no account and no home
case "$(_slice_fn "$helper_src" _is_not_a_package)" in
*init-ownership*) ;;
*) _p="$_p;init-ownership would be given a package user" ;;
esac
# and running it must record itself, so list and next agree with reality
case "$(_slice_fn "$helper_src" cmd_build)" in
*'mark_done "$name"'*) ;;
*) _p="$_p;running init-ownership does not mark it built" ;;
esac
case "$(_slice_fn "$helper_src" _ownership_checkpoint)" in
*mark_done*) ;;
*) _p="$_p;a tree that crossed the epoch still shows the step as pending" ;;
esac
if [ -n "$_p" ]; then
printf '%s\n' "${_p#;}" | tr ';' '\n' | while IFS= read -r m; do
[ -n "$m" ] && bad "$m"
done
else
ok "the ownership step appears in the list, and can be run on its own"
fi
fi
# ---- a chapter-8 tree can still be taken back from the build user ----------- #
# Refusing the recursive 7.2 chown on a built tree is right -- it would strip
# every package user in one pass. But refusing and stopping there left the tree
# with NO way forward:
#
# 4.3 had taken /usr, /etc, /var and the root symlinks,
# 7.2 said "not touching it, repair from the manifests instead",
# chroot enter said "the tree is not owned by root yet: run chroot prepare"
#
# -- pointing at the command that had just declined. The precise repair is to
# reclaim what the BUILD USER holds, by uid: that leaves every package user's
# files alone, so it is safe at any stage.
python3 - "$LFS_TOOL" <<'PYRECLAIM'
import re, sys
src = open(sys.argv[1]).read()
problems = []
for fn in ("_build_user_uid", "_handover_build_user_only"):
if not re.search(r"^def %s\(" % fn, src, re.M):
problems.append("there is no %s" % fn)
m = re.search(r"^def _handover_build_user_only\(.*?(?=\n\ndef )", src, re.S | re.M)
if m:
body = m.group(0)
# selects by uid, so package users are untouched
if "-uid" not in body:
problems.append("the reclaim does not select on the build user's uid")
# user only: 4.3 chowned the user, so the inverse must not take the group
# as well -- that would strip `install` off every shared directory
# the INVOCATION -- the docstring above it explains why the group is left
# alone, and matching that text made this impossible to satisfy
code = "\n".join(l for l in body.splitlines()
if not l.lstrip().startswith("#")).split('"""')
code = "".join(code[::2])
if "chown -h root:root" in code or "chown -R" in code:
problems.append("the reclaim takes the group too, undoing the install group")
if "chown -h root" not in body:
problems.append("the reclaim follows symlinks instead of chowning them")
m = re.search(r"^def cmd_bs_chroot\(.*?(?=\n\ndef )", src, re.S | re.M)
if m:
body = m.group(0)
if "_handover_build_user_only" not in body:
problems.append("a chapter-8 tree is still left with no way forward")
# and entering must repair rather than point back at prepare
enter = body.split('if action == "enter"')
if len(enter) > 1 and "_handover_build_user_only" not in enter[1]:
problems.append("chroot enter still sends you to a command that declined")
if problems:
for p in problems:
print(" FAIL %s" % p)
sys.exit(1)
print(" PASS a built tree can be taken back from the build user without loss")
PYRECLAIM
_count_rc $?
# end to end: the build user's paths go back to root, the package user's do not
if [ "$(id -u)" = 0 ] && command -v python3 >/dev/null 2>&1; then
_fx="$T/reclaim/mnt/lfs"
mkdir -p "$_fx/usr/bin" "$_fx/etc" "$_fx/var" "$_fx/tools"
( cd "$_fx" && ln -sf usr/bin bin )
echo pkg > "$_fx/usr/bin/owned-by-a-package"
echo cfg > "$_fx/etc/fstab"
# 4242 stands in for a package user, 4243 for the host's build account
chown 4242:4242 "$_fx/usr/bin/owned-by-a-package"
for _d in usr usr/bin etc var tools etc/fstab; do chown 4243 "$_fx/$_d"; done
chown -h 4243 "$_fx/bin"
_out="$(python3 - "$LFS_TOOL" "$_fx" <<'PYFX'
import sys, importlib.util
src = open(sys.argv[1]).read().split('if __name__ ==')[0]
spec = importlib.util.spec_from_loader("t", None)
m = importlib.util.module_from_spec(spec)
exec(compile(src, "lfs", "exec"), m.__dict__)
m._build_user_uid = lambda: 4243
print(m._handover_build_user_only(sys.argv[2], run=True))
PYFX
)"
_bad=""
[ "$(stat -c %u "$_fx/usr")" = 0 ] || _bad="$_bad;/usr was not taken back"
[ "$(stat -c %u "$_fx/bin")" = 0 ] || _bad="$_bad;the root symlink was not taken back"
[ "$(stat -c %u "$_fx/usr/bin/owned-by-a-package")" = 4242 ] \
|| _bad="$_bad;a package user's file was taken from it"
[ -L "$_fx/bin" ] || _bad="$_bad;the symlink was followed and replaced"
if [ -n "$_bad" ]; then
printf '%s\n' "${_bad#;}" | tr ';' '\n' | while IFS= read -r m; do
[ -n "$m" ] && bad "$m"
done
else
ok "reclaiming takes the build user's paths and leaves the packages' alone"
fi
rm -rf "$T/reclaim"
fi
# ---- state lives under /usr/src, and it is sorted --------------------------- #
# It was /.lfs-pkgusr: a hidden directory at the root of the filesystem, sitting
# 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 -- and it holds
# twenty-two loose files at the top level.
#
# Under /usr/src it sits beside the accounts it describes, and the tree scans
# exclude ONE path instead of two, because everything under $SRCROOT already is.
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
_p=""
grep -q '^STATE="\${LFS_PKGUSR_DIR:-\${SRCROOT%/}/lfs-pkgusr}"' "$helper_src" \
|| _p="$_p;lfs-helper state is not under \$SRCROOT"
grep -q '^PKGUSR_DIR = "usr/src/lfs-pkgusr"' "$LFS_TOOL" \
|| _p="$_p;lfs state is not under /usr/src"
# both tools must name the SAME directory -- they read each other's manifests
case "$(grep -m1 '^STATE=' "$helper_src")" in
*lfs-pkgusr*) ;;
*) _p="$_p;the two tools no longer agree on where state lives" ;;
esac
# SRCROOT has to be defined before what derives from it: `set -u` turns a
# forward reference into "SRCROOT: unbound variable" and the tool will not
# even print its version
_isrc="$(grep -n '^SRCROOT=' "$helper_src" | head -1 | cut -d: -f1)"
_ista="$(grep -n '^STATE=' "$helper_src" | head -1 | cut -d: -f1)"
if [ -n "$_isrc" ] && [ -n "$_ista" ] && [ "$_isrc" -gt "$_ista" ]; then
_p="$_p;SRCROOT is defined after the paths derived from it"
fi
# nothing loose at the top: every state file belongs to a named group
for _v in PROGRESS SNAPSHOT SNAPDIRS INSTALLDIRS; do
case "$(grep -m1 "^$_v=" "$helper_src")" in
*'$STATE_PROGRESS/'*|*'$STATE_CONF/'*|*'$STATE_GROUPS/'*) ;;
*) _p="$_p;$_v is still a loose file at the top of the state dir" ;;
esac
done
# and the directories are created together, so no path can find one missing
case "$(_slice_fn "$helper_src" mkstate)" in
*STATE_PROGRESS*STATE_GROUPS*STATE_CONF*) ;;
*) _p="$_p;mkstate does not create every state directory" ;;
esac
if [ -n "$_p" ]; then
printf '%s\n' "${_p#;}" | tr ';' '\n' | while IFS= read -r m; do
[ -n "$m" ] && bad "$m"
done
else
ok "state lives under /usr/src, sorted into named directories"
fi
fi
# ---- colour means one thing --------------------------------------------------#
# Four colours, one meaning each, the same in both tools -- they print into the
# same terminal one after the other, and a log where the same colour means two
# different things is worse than no colour at all. Prose gets none: colour on
# every other line stops carrying information.
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
_p=""
for _fn in say ok warn note detail hint die fail; do
grep -qE "^$_fn\(\) +\{" "$helper_src" \
|| _p="$_p;lfs-helper has no $_fn"
done
# say() is prose and must not paint anything
case "$(grep -m1 '^say()' "$helper_src")" in
*C_*) _p="$_p;plain prose is coloured" ;;
esac
# the vocabulary is the interface: no ad-hoc colour outside those helpers
# the helper DEFINITIONS are where the colour lives; everything else must go
# through them
_adhoc="$(grep -nE '(say|echo) "\$\{C_(DIM|WARN|ERR|OK)\}' "$helper_src" \
| grep -vE '^[0-9]+:(ok|warn|note|detail|hint|die|fail)\(\)' | wc -l)"
[ "${_adhoc:-0}" = 0 ] \
|| _p="$_p;$_adhoc line(s) still paint colour by hand instead of using a helper"
# lfs must offer the same set, or the two halves of a build read differently
for _fn in warn note ok fail hint; do
grep -qE "^def $_fn\(" "$LFS_TOOL" || _p="$_p;lfs has no $_fn"
done
if [ -n "$_p" ]; then
printf '%s\n' "${_p#;}" | tr ';' '\n' | while IFS= read -r m; do
[ -n "$m" ] && bad "$m"
done
else
ok "both tools share one colour vocabulary, and prose has none"
fi
fi
# ---- the collector export must land where the reader looks ------------------ #
# `lfs` copies the export INTO the tree; `lfs-helper` reads it back out. Those
# two paths are the whole mechanism -- nothing else connects them -- and when
# the state directory was sorted, the writer kept using the top level while the
# reader moved into groups/. A rebuild with a perfectly good export then asked
#
# 'p_gcc' needs to install into: /usr/lib/bfd-plugins
# that directory belongs to package 'p_binutils'
# Which group should share it?
#
# for a directory the file already answered. Silent, because a question that
# gets asked looks the same as a question that was never answered.
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
# what the writer writes
_w="$(python3 - "$LFS_TOOL" <<'PYW'
import re, sys
src = open(sys.argv[1]).read()
m = re.search(r"^def _install_collector_groups\(.*?(?=\n\ndef )", src, re.S | re.M)
if m:
d = re.search(r'dst_dir = pkgusr_state\(lfs, "([a-z]+)"\)', m.group(0))
print(d.group(1) if d else "TOPLEVEL")
else:
print("MISSING")
PYW
)"
# what the reader reads
_r="$(grep -o '\$STATE_[A-Z]*/collector-groups\.import' "$helper_src" \
| head -1 | sed 's|\$STATE_\([A-Z]*\)/.*|\1|' | tr 'A-Z' 'a-z')"
_p=""
[ "$_w" = groups ] || _p="$_p;lfs writes the collector export to '$_w', not groups/"
[ "$_r" = groups ] || _p="$_p;lfs-helper reads the collector export from '$_r', not groups/"
[ "$_w" = "$_r" ] || _p="$_p;the two tools disagree about where the export lives"
# an edit on the host has to reach the build, so entry re-copies it
case "$(python3 - "$LFS_TOOL" <<'PYE'
import re, sys
src = open(sys.argv[1]).read()
m = re.search(r"^def cmd_bs_chroot\(.*?(?=\n\ndef )", src, re.S | re.M)
body = m.group(0) if m else ""
enter = body.split('if action == "enter"')
print("yes" if len(enter) > 1 and "_install_collector_groups" in enter[1] else "no")
PYE
)" in
yes) ;;
*) _p="$_p;editing the export on the host never reaches the build" ;;
esac
if [ -n "$_p" ]; then
printf '%s\n' "${_p#;}" | tr ';' '\n' | while IFS= read -r m; do
[ -n "$m" ] && bad "$m"
done
else
ok "the collector export is written where lfs-helper reads it"
fi
# end to end: a decision in the file must not be asked about again
_cg="$T/collgroups"; mkdir -p "$_cg/groups"
printf '# prefix|nimgnu\ndir|/usr/lib/bfd-plugins|nimgnu_bfd-plugins\n' \
> "$_cg/groups/collector-groups.import"
_o="$( STATE="$_cg"; STATE_GROUPS="$_cg/groups"; SNAP_ROOT=/
COLLECTOR_MAP="$_cg/groups/collector-map"; COLLECTOR_PREFIX=nimgnu
PKGUSR_PREFIX="p_"
eval "$(_slice_fn "$helper_src" unprefix_pkg_user)"
eval "$(_slice_fn "$helper_src" collector_group_for)"
# the lookup runs before any of these matter, but they are called on the
# way to it
group_exists() { return 1; }
user_in_group() { return 1; }
eval "$(_slice_fn "$helper_src" sanitise_group_name)"
eval "$(_slice_fn "$helper_src" choose_collector_group)"
# a missing helper must FAIL, not skip: an empty result reading as a
# pass is the trap this suite has fallen into twice
command -v choose_collector_group >/dev/null 2>&1 \
|| { echo "there is no choose_collector_group"; exit 0; }
# no tty, so it can only answer from what it already knows
# <dir> <owner> <user> -- the user is the package asking for access
_g="$(choose_collector_group /usr/lib/bfd-plugins p_binutils p_gcc \
</dev/null 2>/dev/null)"
[ "$_g" = nimgnu_bfd-plugins ] \
|| echo "a directory the export already answered is asked about again: got '$_g'" )"
if [ -z "$_o" ]; then
ok "a directory named in the export is never asked about again"
else
printf '%s\n' "$_o" | while IFS= read -r m; do [ -n "$m" ] && bad "$m"; done
fi
fi
# ---- verify must not report its own state directory ------------------------- #
# The state directory 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 each one 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 two temp files the scan had
# just written for itself:
# unclaimed /usr/src/lfs-pkgusr/tmp/found.13431
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
_o="$( ETC=/etc; SNAP_ROOT=/; STATE=/usr/src/lfs-pkgusr
WRAPPERS=/usr/lib/pkgusr
eval "$(_slice_fn "$helper_src" never_claim_list)"
eval "$(_slice_fn "$helper_src" _never_claim_load)"
eval "$(_slice_fn "$helper_src" is_never_claimed)"
command -v is_never_claimed >/dev/null 2>&1 \
|| { echo "there is no is_never_claimed"; exit 0; }
for f in "$STATE/manifests/gcc.files" "$STATE/scripts/gcc.sh" \
"$STATE/logs/gcc-all.log" "$STATE/progress/steps-built" \
"$STATE/tmp/found.13431"; do
is_never_claimed "$f" || echo "verify still reports $f"
done
# an account home under the SAME parent is a package's, and must not be
# swept up by this
is_never_claimed /usr/src/pkgusr/p_gcc/VERSION \
&& echo "a package user's own file was excluded too" )"
if [ -z "$_o" ]; then
ok "verify does not report the build's own notes as unclaimed files"
else
printf '%s\n' "$_o" | while IFS= read -r m; do [ -n "$m" ] && bad "$m"; done
fi
fi
# ---- the two tools share one tree snapshot ---------------------------------- #
# `lfs` takes the last snapshot outside the chroot at the end of chapter 6;
# `lfs-helper` diffs against it for the first package inside. It is a handover,
# and it works only while both name the same file. 1.9.0 moved the reader to
# progress/tree-snapshot and left the writer on .snapshot at the top level, so
# the handover snapshot was silently lost and the first chroot package diffed
# against nothing.
python3 - "$LFS_TOOL" "$(dirname "$LFS_TOOL")/lfs-helper" <<'PYSNAP'
import re, sys
lfs = open(sys.argv[1]).read()
problems = []
if '".snapshot"' in lfs or '".snapshot-dirs"' in lfs:
problems.append("lfs still writes the snapshot to .snapshot at the top level")
if 'pkgusr_state(lfs, "progress")' not in lfs:
problems.append("lfs does not write the snapshot into progress/")
for want in ('"tree-snapshot"', '"tree-snapshot-dirs"'):
if want not in lfs:
problems.append("lfs does not name %s" % want)
try:
helper = open(sys.argv[2]).read()
except OSError:
helper = ""
if helper:
m = re.search(r'^SNAPSHOT="([^"]+)"', helper, re.M)
if not m or "tree-snapshot" not in m.group(1):
problems.append("lfs-helper reads a different snapshot file")
if problems:
for p in problems:
print(" FAIL %s" % p)
sys.exit(1)
print(" PASS both tools read and write the same tree snapshot")
PYSNAP
_count_rc $?
# ---- add-user names the account through the chokepoint ---------------------- #
# It used its argument raw for the ACCOUNT and pkg_owner_name only for the HOME.
# Fine while every caller passes an already-prefixed owner -- cmd_build does.
# last_build_step.sh did not: `lfs-helper add-user wget` produced an account
# called `wget` living in /usr/src/pkgusr/p_wget, and the sanity report found
# both halves without being able to connect them:
# !! accounts without a known prefix: wget urllib3 requests ...
# ? /usr/src/pkgusr/p_wget/ has no matching account (stray directory)
# !! group wget (gid 10082) is outside every convention
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
_au="$(_slice_fn "$helper_src" cmd_add_user)"
_p=""
case "$(printf '%s\n' "$_au" | grep -vE '^[[:space:]]*#')" in
*'name="$(pkg_owner_name "$name")"'*) ;;
*) _p="$_p;add-user creates the account from the raw name" ;;
esac
# the normalisation must come BEFORE the account is created, or it renames
# nothing
_i1="$(printf '%s\n' "$_au" | grep -n 'name="$(pkg_owner_name' | head -1 | cut -d: -f1)"
_i2="$(printf '%s\n' "$_au" | grep -n 'useradd ' | head -1 | cut -d: -f1)"
if [ -n "$_i1" ] && [ -n "$_i2" ] && [ "$_i1" -gt "$_i2" ]; then
_p="$_p;add-user normalises the name after creating the account"
fi
if [ -n "$_p" ]; then
printf '%s\n' "${_p#;}" | tr ';' '\n' | while IFS= read -r m; do
[ -n "$m" ] && bad "$m"
done
else
ok "add-user gives the account and its home the same name"
fi
# last_build_step.sh was the caller that exposed it -- it passed bare names.
# It is gone (1.10.0) and this checked a file that no longer exists, so it
# never ran again. The requirement outlives it: `packagemanager setup` is
# the next caller to create accounts from outside the tool, and it must go
# through add-user rather than useradd. Assert that when setup lands.
fi
# ---- the sanity report reads the sorted state, and knows when a build is done #
# Two findings on a perfect 105/105 tree, both from the report and neither from
# the build:
#
# adopted.list: MISSING (the adoption gate will never close)
# -- it read $STATE/adopted.list; the file is progress/adopted.list
#
# !! /usr/bin is already STICKY -- sealed before the build finished
# -- the build HAD finished, and seal-install-dirs is supposed to do that
#
# The sticky bit means opposite things on either side of that line: during the
# build it is a bug, afterwards its ABSENCE is.
san="$(dirname "$LFS_TOOL")/lfs-sanity.sh"
if [ -f "$san" ]; then
_p=""
grep -q 'PROG="\$STATE/progress"' "$san" \
|| _p="$_p;the sanity report still reads the flat state layout"
grep -q 'BUILD_FINISHED' "$san" \
|| _p="$_p;the sanity report does not know whether the build finished"
grep -q 'the build finished but the seal never fired' "$san" \
|| _p="$_p;a finished build with no seal is not reported"
# an empty manifest is a config step that installed nothing, not an orphan
grep -q 'grep -q . "\$m" 2>/dev/null || continue' "$san" \
|| _p="$_p;empty manifests are still reported as missing an account"
if [ -n "$_p" ]; then
printf '%s\n' "${_p#;}" | tr ';' '\n' | while IFS= read -r m; do
[ -n "$m" ] && bad "$m"
done
else
ok "the sanity report reads the sorted state and judges the seal in context"
fi
fi
# ---- every tool can say which build it is ----------------------------------- #
# Section 0 of the sanity report exists because a stale copy in the chroot has
# cost more debugging time here than anything else. packagemanager_install had
# no --version, so that row read
# packagemanager_install Please provide an install script with $2
pmi="$(dirname "$LFS_TOOL")/packagemanager_install"
if [ -f "$pmi" ]; then
_v="$(bash "$pmi" --version 2>&1)"
case "$_v" in
packagemanager_install*build*) ok "every tool reports its own build id" ;;
*) bad "packagemanager_install cannot report its build id: $_v" ;;
esac
fi
# ---- no finding that repairing makes worse ---------------------------------- #
# Two passes disagreed about a mail spool, and each undid the other:
#
# 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 build-user pass gave it to root because the build user held it; the
# unclaimed pass then reported that root holds a file no manifest claims.
# Repairing it produced the next run's finding.
#
# Shadow creates /var/mail/<name> for every account it makes, so a spool arrives
# as a side effect of `useradd` and no package ever installs one. It belongs to
# the account it is named for, and neither pass should touch it.
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
_o="$( ETC=/etc; SNAP_ROOT=/; STATE=/usr/src/lfs-pkgusr; WRAPPERS=/usr/lib/pkgusr
eval "$(_slice_fn "$helper_src" never_claim_list)"
eval "$(_slice_fn "$helper_src" _never_claim_load)"
eval "$(_slice_fn "$helper_src" is_never_claimed)"
command -v is_never_claimed >/dev/null 2>&1 \
|| { echo "there is no is_never_claimed"; exit 0; }
for f in /var/mail/lfs /var/mail/p_gcc; do
is_never_claimed "$f" || echo "$f is still fought over by two passes"
done
# not the whole of /var, though -- a package's files there are its own
is_never_claimed /var/lib/something \
&& echo "the exclusion is too broad: it swallowed /var/lib" )"
if [ -z "$_o" ]; then
ok "a mail spool belongs to its account, and no pass claims it"
else
printf '%s\n' "$_o" | while IFS= read -r m; do [ -n "$m" ] && bad "$m"; done
fi
# the general property: nothing the build-user pass hands to root may then be
# reported by the unclaimed pass, or --fix and verify never agree
case "$(_slice_fn "$helper_src" _vfy_build_user_leftovers)" in
*is_never_claimed*) ok "the build-user pass skips files no package may own" ;;
*) bad "the build-user pass can create a finding for the unclaimed pass" ;;
esac
fi
# ---- every door to `chown -R lfs` has the same guard ------------------------ #
# There were FOUR places that hand the build tree to the `lfs` user, and only
# one of them had the check that refuses on a tree past chapter 7:
#
# _chown_tree_to_lfs book 4.3, guarded
# session --run re-applied it by hand, unguarded
# the pre-build check "fixing N dir(s) still owned by root", unguarded
# fix-ownership --run the explicit repair, unguarded
#
# On a finished system any of the other three would have handed the whole thing
# to the build user in one pass -- the exact damage the guard exists to prevent,
# through a door the guard was not on. It already happened once via a replayed
# step 4.2.
python3 - "$LFS_TOOL" <<'PYCHOWN'
import re, sys
raw = open(sys.argv[1]).read()
# Comments name the command they are explaining -- every fix in this file says
# what it fixed -- so scan CODE only. Matching the explanation makes a test
# that cannot pass no matter what the code does.
src = "\n".join("" if l.lstrip().startswith("#") else l
for l in raw.splitlines())
problems = []
# every recursive chown to lfs, and the function it sits in
for m in re.finditer(r"chown -R lfs", src):
start = src.rfind("\ndef ", 0, m.start())
if start < 0:
problems.append("a chown -R lfs sits outside any function")
continue
end = src.find("\ndef ", m.end())
body = src[start:end if end > 0 else len(src)]
name = re.match(r"\ndef ([a-zA-Z_0-9]+)", body).group(1)
# the one that only chowns the state dir is not handing over the tree
if "PKGUSR_DIR" in src[m.start()-120:m.end()+120]:
continue
if "_handover_is_safe" not in body:
problems.append("%s hands the tree to the build user unguarded" % name)
if problems:
for p in sorted(set(problems)):
print(" FAIL %s" % p)
sys.exit(1)
print(" PASS every path that gives the tree to the build user is guarded")
PYCHOWN
_count_rc $?
# ---- session says what to do next ------------------------------------------- #
# It ended on `export LFS=/mnt/lfs # <-- run this in your shell` and stopped.
# Every other command in this tool finishes with a Next: line; this one left you
# configured and guessing.
python3 - "$LFS_TOOL" <<'PYNEXT'
import re, sys
src = open(sys.argv[1]).read()
m = re.search(r"^def cmd_bs_session\(.*?(?=\n\ndef )", src, re.S | re.M)
problems = []
if not m:
problems.append("cmd_bs_session is gone")
else:
body = m.group(0)
# the dry-run branch and the --run branch both end somewhere useful
if body.count('"Next:"') < 2:
problems.append("session does not say what to do next on both paths")
if "lfs run" not in body:
problems.append("session never points at `lfs run`")
if "build-system next" not in body:
problems.append("session never offers `build-system next`")
if problems:
for p in problems:
print(" FAIL %s" % p)
sys.exit(1)
print(" PASS session ends by saying what to run next")
PYNEXT
_count_rc $?
# ---- comm gets sorted input, in one collation ------------------------------- #
# The snapshot diff is `comm -13 <(before) <(after)`. comm compares line by
# line and TRUSTS both sides to be sorted the same way; it cannot detect
# otherwise, it just returns the wrong answer. Two things broke that:
#
# * the two snapshots may have been written under different locales -- a
# package user's profile sets LC_ALL=POSIX and root's shell may not
# * load_snapshot rewrites only the lines carrying the host mount prefix, so
# a file holding both forms comes out sorted under neither
#
# The visible symptom is a package installing forty files and being told it
# installed none.
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
_p=""
for _fn in snapshot snapshot_dirs load_snapshot; do
_slice_fn "$helper_src" "$_fn" | grep -q 'LC_ALL=C sort' \
|| _p="$_p;$_fn does not pin the collation comm depends on"
done
if [ -n "$_p" ]; then
printf '%s\n' "${_p#;}" | tr ';' '\n' | while IFS= read -r m; do
[ -n "$m" ] && bad "$m"
done
else
ok "both sides of the snapshot diff are sorted the same way"
fi
# the mixed-prefix case, end to end
_sd="$T/snapsort"; mkdir -p "$_sd"
printf '%s\n' /mnt/lfs/usr/bin/z /usr/bin/a /mnt/lfs/usr/bin/b > "$_sd/snap"
_got="$( SNAP_ROOT=/; LFS_HOST_MOUNT=/mnt/lfs
eval "$(_slice_fn "$helper_src" load_snapshot)"
load_snapshot "$_sd/snap" | tr '\n' ' ' )"
case "$_got" in
"/usr/bin/a /usr/bin/b /usr/bin/z ") ok "a snapshot holding both path forms comes back sorted" ;;
*) bad "load_snapshot returns unsorted input to comm: [$_got]" ;;
esac
fi
# ---- "installed NO files" has to say which of the three it is --------------- #
# There are exactly three ways to reach zero and they need different fixes:
# nothing was written, it was written somewhere excluded from tracking, or it
# was written over files another package owns and dropped by the filter.
# Saying only "check the log" meant reading a thousand lines of make output to
# find out which.
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
_cb="$(_slice_fn "$helper_src" cmd_build)"
_p=""
case "$_cb" in
*'new files (snapshot diff)'*) ;;
*) _p="$_p;the zero-files failure does not report the snapshot diff count" ;;
esac
case "$_cb" in
*'files touched since stamp'*) ;;
*) _p="$_p;the zero-files failure does not report the touched count" ;;
esac
case "$_cb" in
*'already belongs to another package'*) ;;
*) _p="$_p;the zero-files failure does not distinguish a dropped install" ;;
esac
if [ -n "$_p" ]; then
printf '%s\n' "${_p#;}" | tr ';' '\n' | while IFS= read -r m; do
[ -n "$m" ] && bad "$m"
done
else
ok "a package that installed nothing says which kind of nothing"
fi
fi
# ---- a re-run over its own work is not a silent failure --------------------- #
# The zero-files check counts what THIS RUN wrote, which is the wrong question
# when a package is built a second time. Many installers skip a file that is
# already up to date -- perl's ExtUtils::Install prints "Installing <path>" for
# every target and copies only the stale ones -- so a second run writes nothing:
# the snapshot diff sees nothing new, no mtime beats the stamp, and the count is
# 0 for the best possible reason.
#
# xml-parser hit exactly that: forty files on disk, every one owned by
# p_xml-parser, and the step failed as a no-op. Counting cannot tell that from
# a real failure. The tree can.
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
_p=""
_slice_fn "$helper_src" _package_already_owns_files | grep -q . \
|| _p="$_p;there is no _package_already_owns_files"
case "$(_slice_fn "$helper_src" cmd_build)" in
*_package_already_owns_files*) ;;
*) _p="$_p;a re-run over its own work still fails as a silent no-op" ;;
esac
# it must ask about the OWNER, and only inside install directories -- looking
# at the whole filesystem would be slow and would count build scratch
_pa="$(_slice_fn "$helper_src" _package_already_owns_files)"
case "$_pa" in
*'-user "$owner"'*) ;;
*) _p="$_p;the check does not look for files this package owns" ;;
esac
case "$_pa" in
*install_dirs_list*) ;;
*) _p="$_p;the check scans outside the install directories" ;;
esac
if [ -n "$_p" ]; then
printf '%s\n' "${_p#;}" | tr ';' '\n' | while IFS= read -r m; do
[ -n "$m" ] && bad "$m"
done
else
ok "a package that already owns its files is not failed as a no-op"
fi
# both directions, against a real tree
if [ "$(id -u)" = 0 ]; then
_ow="$T/owns"; mkdir -p "$_ow/usr/lib"
echo x > "$_ow/usr/lib/thing"
_r="$( SNAP_ROOT="$_ow"
user_exists() { [ "$1" = p_test ]; }
install_dirs_list() { printf '%s\n' /usr/lib; }
eval "$(_slice_fn "$helper_src" _package_already_owns_files)"
# owned by root -> this package installed nothing, which IS a failure
_package_already_owns_files p_test && echo "false-pass"
# owned by the package -> it installed, whenever that was
chown 4242 "$_ow/usr/lib/thing" 2>/dev/null
user_exists() { [ "$1" = p_test ]; }
stat -c %U "$_ow/usr/lib/thing" >/dev/null 2>&1 || echo "fixture broken" )"
case "$_r" in
"") ok "a package owning nothing is still reported as a failure" ;;
*) bad "the check passes a package that installed nothing: $_r" ;;
esac
rm -rf "$_ow"
fi
fi
# ---- a script outside the tool can ask for an account NAME ------------------ #
# `pkgusr-home` was exposed and the account name was not, so a shell script
# could ask where a package lives but not what its user is called.
# 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'
#
# Right by accident while add-user used the raw name; broken the moment add-user
# started applying the prefix, as everything else already did. Half a
# chokepoint is not a chokepoint -- and this one failed at step 104 of 105.
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
_p=""
_slice_fn "$helper_src" cmd_owner_name | grep -q 'pkg_owner_name' \
|| _p="$_p;there is no way to ask for a package's account name"
grep -q 'owner-name)' "$helper_src" \
|| _p="$_p;owner-name is not reachable as a command"
grep -q 'lfs-helper owner-name <package>' "$helper_src" \
|| _p="$_p;owner-name is not in the help"
# it must agree with pkgusr-home about the same package
_n="$(bash "$helper_src" owner-name wget 2>/dev/null)"
_h="$(bash "$helper_src" pkgusr-home wget 2>/dev/null)"
case "$_h" in
*/"$_n") ;;
*) _p="$_p;owner-name and pkgusr-home disagree: '$_n' vs '$_h'" ;;
esac
if [ -n "$_p" ]; then
printf '%s\n' "${_p#;}" | tr ';' '\n' | while IFS= read -r m; do
[ -n "$m" ] && bad "$m"
done
else
ok "a package's account name and its home come from the same place"
fi
fi
# The caller that exposed this was last_build_step.sh, which is gone (1.10.0).
# The rule is not: any script outside the tool that needs an account name must
# ASK -- `lfs-helper owner-name` -- instead of gluing a prefix on itself.
# `packagemanager setup` inherits it; assert it there when setup lands.
# ---- the ESP hint prints a path you can actually type ----------------------- #
# Two sources feed it and they disagree about the prefix: `lsblk -rno NAME`
# gives a bare name, `blkid -o device` gives a full path. Prefixing both gave
# /dev//dev/nvme0n1p1
# in the one message whose entire job is to tell you what to type.
python3 - "$LFS_TOOL" <<'PYESP'
import re, sys
src = open(sys.argv[1]).read()
if "s|^| /dev/|" in src:
print(" FAIL the ESP hint prefixes /dev/ onto paths that already have it")
sys.exit(1)
if "/dev/" not in src or "[^/]" not in src:
print(" FAIL the ESP hint no longer normalises the device path")
sys.exit(1)
print(" PASS the ESP hint prints one /dev/ per device, whichever tool found it")
PYESP
_count_rc $?
# ---- a bootloader is opt-in ------------------------------------------------- #
# The default was `refind`, so every build generated a rEFInd step -- and most
# people running this already have a working ESP and a bootloader that finds it.
# The machine booted in order to build LFS at all. So the last stretch of a
# six-hour build became a stop:
#
# == rEFInd needs you to do something first ==
# /boot/efi does not exist, so there is nowhere to install to.
#
# A question nobody asked to be asked. A bootloader is a decision about the
# whole machine, not a package.
python3 - "$LFS_TOOL" <<'PYBL'
import re, sys
src = open(sys.argv[1]).read()
problems = []
m = re.search(r"^def bootloader\(.*?(?=\n\ndef )", src, re.S | re.M)
if not m:
problems.append("bootloader() is gone")
else:
body = "\n".join("" if l.lstrip().startswith("#") else l
for l in m.group(0).splitlines())
body = "".join(body.split('"""')[::2])
if '"refind"' in body:
problems.append("a bootloader step is still generated by default")
if '"none"' not in body:
problems.append("the default is not 'none'")
# and skipping it must not be silent -- someone who DOES need one would reach a
# finished, unbootable system without ever being offered it
if "no bootloader step" not in src:
problems.append("skipping the bootloader says nothing about how to opt in")
if "lfs config bootloader refind" not in src:
problems.append("nothing says how to turn rEFInd on")
# the grub guard must be untouched: its book instructions write a boot sector
if 'if bl != "grub"' not in src:
problems.append("the grub script is no longer withheld unless chosen")
if problems:
for p in problems:
print(" FAIL %s" % p)
sys.exit(1)
print(" PASS no bootloader is set up unless you ask for one")
PYBL
_count_rc $?
# ---- the sort and the comparison must agree --------------------------------- #
# 1.9.6 pinned LC_ALL=C on the sorts feeding comm and stopped there. That is
# half a fix: comm validates ordering in 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
# comm: input is not in sorted order
#
# and the diff it returns is then simply wrong. Pinning either side alone
# guarantees they disagree.
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
_bare="$(grep -nE '(^|[^=[:alnum:]_])comm ' "$helper_src" \
| grep -vE 'LC_ALL=C comm' | grep -vE '^[0-9]+:[[:space:]]*#')"
if [ -n "$_bare" ]; then
printf '%s\n' "$_bare" | while IFS= read -r m; do
bad "comm without LC_ALL=C: ${m%%:*}"
done
else
ok "every comm compares in the locale its input was sorted in"
fi
# and every sort whose output reaches a comm is pinned too
_p=""
for _fn in snapshot snapshot_dirs load_snapshot _vfy_unclaimed; do
_slice_fn "$helper_src" "$_fn" | grep -q 'LC_ALL=C sort' \
|| _p="$_p;$_fn sorts in the ambient locale"
done
if [ -n "$_p" ]; then
printf '%s\n' "${_p#;}" | tr ';' '\n' | while IFS= read -r m; do
[ -n "$m" ] && bad "$m"
done
else
ok "every sort feeding a comm is pinned to the same locale"
fi
fi
# ---- the scripts in the tree must match the tools outside it ---------------- #
# The step order and the generated 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 an 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.
python3 - "$LFS_TOOL" <<'PYSTAMP'
import re, sys
src = open(sys.argv[1]).read()
problems = []
if "generated-by" not in src:
problems.append("gen-chroot-scripts leaves no version stamp")
if "LFS_VERSION" not in src.split("generated-by")[1][:400]:
problems.append("the stamp does not record which version wrote it")
# and a step removed from the order must not leave its script behind to run
m = re.search(r"^def cmd_bs_gen_chroot_scripts\(.*?(?=\n\ndef )", src, re.S | re.M)
body = m.group(0) if m else ""
if "stale" not in body or "os.remove" not in body:
problems.append("scripts for removed steps are left in the tree")
if problems:
for p in problems:
print(" FAIL %s" % p)
sys.exit(1)
print(" PASS generated scripts record the version that wrote them")
PYSTAMP
_count_rc $?
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
_p=""
_slice_fn "$helper_src" _warn_if_scripts_are_stale | grep -q 'LFS_HELPER_VERSION' \
|| _p="$_p;lfs-helper does not compare its version against the scripts'"
case "$(_slice_fn "$helper_src" cmd_build_all)" in
*_warn_if_scripts_are_stale*) ;;
*) _p="$_p;build-all runs an order it never checked" ;;
esac
# a missing script must blame the likely cause, not just offer to regenerate
case "$(_slice_fn "$helper_src" cmd_build)" in
*'no longer exists in this version'*) ;;
*) _p="$_p;a step removed from the tools reports only a missing file" ;;
esac
if [ -n "$_p" ]; then
printf '%s\n' "${_p#;}" | tr ';' '\n' | while IFS= read -r m; do
[ -n "$m" ] && bad "$m"
done
else
ok "a step order older than the tools is reported, not obeyed"
fi
# end to end: an unstamped tree, and a mismatched one
_st="$T/stamp"; mkdir -p "$_st/progress"
_r="$( STATE_PROGRESS="$_st/progress"; LFS_HELPER_VERSION="9.9.9"
eval "$(_slice_fn "$helper_src" _warn_if_scripts_are_stale)"
warn() { echo "WARNED: $*"; }
_warn_if_scripts_are_stale >/dev/null 2>&1 && echo "unstamped-passed"
printf 'version=9.9.9\n' > "$_st/progress/generated-by"
_warn_if_scripts_are_stale >/dev/null 2>&1 || echo "matching-failed"
printf 'version=1.0.0\n' > "$_st/progress/generated-by"
_warn_if_scripts_are_stale >/dev/null 2>&1 && echo "mismatch-passed" )"
case "$_r" in
"") ok "an unstamped or mismatched step order is caught, a matching one is not" ;;
*) printf '%s\n' "$_r" | while IFS= read -r m; do
[ -n "$m" ] && bad "stale-script check is wrong: $m"
done ;;
esac
rm -rf "$_st"
fi
# ---- unpacked sources are scratch, and no scan should walk them ------------- #
# Build trees moved from /build into each package user's home in 1.7.6. The
# snapshot scans followed; the ownership scans did not. So `lfs-helper verify`
# began walking every unpacked source tree in the system -- it ran for minutes
# with no output -- and a finished tree filled the sanity report 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 carry any uid it likes. Unpacked sources are not installed,
# nothing owns them, and asking who does has no answer.
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
_p=""
_slice_fn "$helper_src" scan_prune_paths | grep -q 'PKGUSR_BUILD_SUBDIR' \
|| _p="$_p;the prune list does not cover the per-package build trees"
# ONE list -- there were five and they had already diverged
for _fn in _vfy_orphans _has_orphaned_files _vfy_build_user_leftovers; do
_slice_fn "$helper_src" "$_fn" | grep -q 'SCAN_PRUNE' \
|| _p="$_p;$_fn does not use the shared prune list"
done
# an ARRAY, not a string through eval: the patterns contain *, and eval lets
# the shell expand them before find ever sees them
case "$(_slice_fn "$helper_src" scan_prune_set)" in
*'SCAN_PRUNE+=('*) ;;
*) _p="$_p;the prune list is not built as an array" ;;
esac
if _slice_fn "$helper_src" _vfy_orphans | grep -q 'eval find'; then
_p="$_p;the orphan scan splices its prune list through eval"
fi
if [ -n "$_p" ]; then
printf '%s\n' "${_p#;}" | tr ';' '\n' | while IFS= read -r m; do
[ -n "$m" ] && bad "$m"
done
else
ok "every ownership scan skips the same scratch, from one list"
fi
# end to end: a build tree is pruned, a real file is not
_pt="$T/prune"
mkdir -p "$_pt/usr/src/pkgusr/p_tcl/src/tcl8.6.16/html" "$_pt/usr/bin"
touch "$_pt/usr/src/pkgusr/p_tcl/src/tcl8.6.16/html/Z.htm" "$_pt/usr/bin/real"
if [ "$(id -u)" = 0 ]; then
chown 4321 "$_pt/usr/src/pkgusr/p_tcl/src/tcl8.6.16/html/Z.htm" "$_pt/usr/bin/real"
_r="$( SNAP_ROOT="$_pt"; STATE="$_pt/usr/src/lfs-pkgusr"
PKGUSR_BUILD_SUBDIR=src
PKGUSR_ROOT="$_pt/usr/src/pkgusr"; CFGUSR_ROOT="$_pt/usr/src/cfg"
pkgusr_roots() { printf '%s\n%s\n' "$PKGUSR_ROOT" "$CFGUSR_ROOT"; }
eval "$(_slice_fn "$helper_src" scan_prune_paths)"
eval "$(_slice_fn "$helper_src" scan_prune_set)"
scan_prune_set
find "$_pt" -xdev -nouser "${SCAN_PRUNE[@]}" 2>/dev/null \
| sed "s|$_pt||" | sort | tr '\n' ' ' )"
case "$_r" in
"/usr/bin/real ") ok "an unpacked source tree is skipped, an installed file is not" ;;
*) bad "the prune list is wrong: got [$_r]" ;;
esac
fi
rm -rf "$_pt"
fi
# the sanity report has to skip the same thing, or it reports what verify does not
san="$(dirname "$LFS_TOOL")/lfs-sanity.sh"
if [ -f "$san" ]; then
_p=""
grep -q 'PKGROOT/\*/src' "$san" \
|| _p="$_p;the sanity report still walks the package build trees"
# a config step writing root-owned files is not an orphaned manifest
grep -q 'writes ROOT-owned files' "$san" \
|| _p="$_p;root-owned config files are still reported as missing an account"
if [ -n "$_p" ]; then
printf '%s\n' "${_p#;}" | tr ';' '\n' | while IFS= read -r m; do
[ -n "$m" ] && bad "$m"
done
else
ok "the sanity report skips scratch and does not invent findings"
fi
fi
# ---- verify must not work in silence, or one fork at a time ----------------- #
# `lfs-helper verify` ran for over five minutes printing nothing, on a tree it
# had already verified clean. Two separate faults:
#
# * a fork per path, twice over -- `stat -c %U` and a process substitution
# inside is_never_claimed -- across 66265 manifest paths
# * no output at all until every pass had finished
#
# A command that works for minutes in silence is indistinguishable from one that
# has hung, and was reported as one.
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
_p=""
# the never-claim list is read once, not per call
_slice_fn "$helper_src" is_never_claimed | grep -q '< <(never_claim_list)' \
&& _p="$_p;is_never_claimed forks for every path it is asked about"
_slice_fn "$helper_src" _never_claim_load | grep -q . \
|| _p="$_p;the never-claim list is not cached"
# owners come from one batched stat, not one per path
case "$(_slice_fn "$helper_src" _vfy_manifest_ownership)" in
*'xargs -0 -r stat'*) ;;
*) _p="$_p;the ownership pass still runs one stat per path" ;;
esac
# a REAL tab: stat does not expand \t, so '%U\tname' comes back as one field
# and the map silently stays empty -- correct but slow, the hardest wrong to see
# comments stripped: the fix NAMES the broken format while explaining it
case "$(_slice_fn "$helper_src" _vfy_manifest_ownership \
| grep -vE '^[[:space:]]*#')" in
*'%U\t%n'*) _p="$_p;the stat format uses a literal backslash-t" ;;
esac
# and it must say what it is doing, before it does it
_nd="$(_slice_fn "$helper_src" cmd_verify | grep -c 'detail "# ')"
[ "${_nd:-0}" -ge 5 ] \
|| _p="$_p;verify announces only $_nd of its passes"
case "$(_slice_fn "$helper_src" _vfy_manifest_ownership)" in
*'[%d/%d]'*) ;;
*) _p="$_p;the long pass shows no progress" ;;
esac
if [ -n "$_p" ]; then
printf '%s\n' "${_p#;}" | tr ';' '\n' | while IFS= read -r m; do
[ -n "$m" ] && bad "$m"
done
else
ok "verify says what it is doing, and does it in one pass not 66000"
fi
# the batched lookup must actually map something -- an empty map is the bug
_pf="$T/statmap"; mkdir -p "$_pf"
touch "$_pf/a" "$_pf/b" "$_pf/c"
printf '%s\n' "$_pf/a" "$_pf/b" "$_pf/c" > "$_pf/man"
_n="$( _TAB=$'\t'
declare -A own=()
while IFS="$_TAB" read -r o nm; do
[ -n "$nm" ] && own["$nm"]="$o"
done < <(grep -v '^[[:space:]]*$' "$_pf/man" \
| tr '\n' '\0' | xargs -0 -r stat -c "%U${_TAB}%n" 2>/dev/null)
echo "${#own[@]}" )"
[ "$_n" = 3 ] \
&& ok "the batched owner lookup maps every path it is given" \
|| bad "the batched owner lookup mapped $_n of 3 paths"
rm -rf "$_pf"
fi
# ---- the build must not end quietly with no way to log in ------------------- #
# init-accounts asks for a root password, but it is skippable: a scripted run
# has no terminal, and an interactive one can be answered with a blank line.
# So the LAST thing build-all says is whether anyone can actually log in --
# because it is the last moment it can still be fixed. After the reboot the
# chroot is gone, and the way back is booting the host and mounting the tree.
helper_src="$(dirname "$LFS_TOOL")/lfs-helper"
if [ -f "$helper_src" ]; then
_p=""
_slice_fn "$helper_src" _warn_if_no_login | grep -q . \
|| _p="$_p;nothing checks whether the system can be logged into"
# it must be the LAST thing build-all does
_ba="$(_slice_fn "$helper_src" cmd_build_all)"
case "$_ba" in
*_warn_if_no_login*) ;;
*) _p="$_p;build-all can finish without saying whether you can log in" ;;
esac
_last="$(printf '%s\n' "$_ba" | grep -vE '^[[:space:]]*(#|$)' | tail -3)"
case "$_last" in
*_warn_if_no_login*) ;;
*) _p="$_p;the login check is not the last thing build-all does" ;;
esac
# it must check the RESULT, not that the step ran
case "$(_slice_fn "$helper_src" _warn_if_no_login)" in
*shadow*) ;;
*) _p="$_p;it trusts init-accounts instead of checking for a password" ;;
esac
if [ -n "$_p" ]; then
printf '%s\n' "${_p#;}" | tr ';' '\n' | while IFS= read -r m; do
[ -n "$m" ] && bad "$m"
done
else
ok "the build's last word is whether you can log in afterwards"
fi
# all three outcomes, against a real shadow file
_lg="$T/login"; mkdir -p "$_lg"
_run_login() { # <shadow contents> -> "ok" or "warn"
( ETC="$_lg"; say(){ :; }; ok(){ :; }; warn(){ echo warn; }
printf 'root:x:0:0::/root:/bin/bash\nbob:x:1000:1000::/home/bob:/bin/bash\n' > "$_lg/passwd"
printf '%s' "$1" > "$_lg/shadow"
eval "$(_slice_fn "$helper_src" _warn_if_no_login)"
_warn_if_no_login | head -1 ) 2>/dev/null
}
_bad=""
[ -z "$(_run_login 'root:$6$a:1::::::
bob:!:1::::::')" ] || _bad="$_bad;a root password is not accepted"
[ -z "$(_run_login 'root:*:1::::::
bob:$6$b:1::::::')" ] || _bad="$_bad;a user password is not accepted"
[ -n "$(_run_login 'root:*:1::::::
bob:!:1::::::')" ] || _bad="$_bad;a system nobody can log into is not reported"
if [ -n "$_bad" ]; then
printf '%s\n' "${_bad#;}" | tr ';' '\n' | while IFS= read -r m; do
[ -n "$m" ] && bad "$m"
done
else
ok "a locked-out system is reported; a usable one is not"
fi
rm -rf "$_lg"
fi
echo
echo " PASS: $(_pass_total) FAIL: $(_fail_total) (final)"
[ "$(_fail_total)" -eq 0 ]