FDS/OS 1.0

This commit is contained in:
2026-09-21 22:29:23 +08:00
commit 99bc3d15c5
430 changed files with 34876 additions and 0 deletions
+141
View File
@@ -0,0 +1,141 @@
#!/usr/bin/env python3
"""Assemble and sign a versioned local release from a verified offline build pair."""
import argparse
import importlib.machinery
import importlib.util
import json
from pathlib import Path
import shutil
import subprocess
import sys
import tarfile
import tempfile
from release_artifacts import artifacts, compare, digest, read_lock
project = Path(__file__).resolve().parents[1]
def packed_tree(source, destination, epoch):
def normalized(info):
info.uid = info.gid = 0
info.uname = info.gname = ''
info.mtime = epoch
info.pax_headers = {}
if not (info.isdir() or info.isfile() or info.issym() or info.islnk()):
raise ValueError(f'Special file in release source: {info.name}')
return info
# Single-threaded Zstandard and ordered normalized tar metadata provide a
# deterministic archive without loading the input snapshot into memory.
with destination.open('xb') as output:
compressor = subprocess.Popen(['zstd', '-q', '-T1', '-10', '-c'], stdin=subprocess.PIPE, stdout=output)
try:
with tarfile.open(fileobj=compressor.stdin, mode='w|', format=tarfile.PAX_FORMAT) as archive:
for path in sorted(source.iterdir()):
archive.add(path, arcname=path.name, filter=normalized)
compressor.stdin.close()
if compressor.wait() != 0:
raise ValueError('Release archive compression failed')
finally:
if compressor.poll() is None:
compressor.kill()
compressor.wait()
def run(args):
snapshot = args.inputs.absolute()
output = args.output_directory.absolute()
key = args.key.absolute()
if key.is_symlink() or not key.is_file():
raise ValueError('Signing key must be a regular file, not a symlink')
if output.exists() or output.is_symlink():
raise ValueError('Release output must be a new directory')
if key.is_relative_to(output) or key.is_relative_to(snapshot):
raise ValueError('Signing key must be outside the release and frozen-input directories')
loader = importlib.machinery.SourceFileLoader('fds_frozen_inputs', str(project / 'tools/frozen-inputs'))
spec = importlib.util.spec_from_loader(loader.name, loader)
frozen = importlib.util.module_from_spec(spec)
loader.exec_module(frozen)
lock = frozen.verify(snapshot)
_, lock_hash = read_lock(project)
if lock_hash != digest(snapshot / 'lock.json'):
raise ValueError('Current build uses a different frozen input snapshot')
# Documentation is part of source identity too. Refuse to sign binaries
# under the identity of a checkout changed after freezing its source.
with tempfile.TemporaryDirectory(prefix='release-source-check.', dir=project / 'out') as temporary:
current = Path(temporary)
for name in frozen.OWNED:
frozen.copy(project / name, current / 'project' / name)
if frozen.source_digest(frozen.inventory(current)) != lock['source_sha256']:
raise ValueError('Current source differs from the frozen release source')
comparison = compare(project, args.comparison_build)
if comparison['status'] != 'passed':
raise ValueError('Independent build differs; run tools/compare-builds for a mismatch report')
for profile in ('cli', 'development', 'recovery'):
root = (project / f'out/rootfs-{profile}.tar').resolve(strict=True).parent
for name in ('build-inputs.sha256', 'fds-tools-inputs.sha256'):
subprocess.run(['sha256sum', '--quiet', '-c', str(root / name)], cwd=project, check=True)
files = artifacts(project)
version, epoch = lock['version'], lock['source_epoch']
signer = project / 'target/x86_64-unknown-linux-gnu/release/fds-release'
if not signer.is_file():
raise ValueError('Build the host signer with make signing first')
output.mkdir(mode=0o700)
for name, source in files.items():
# Rootfs tars remain in the reproducibility record. Their complete
# contents are already represented in the installable images.
if name.startswith('rootfs-'):
continue
if name.endswith('.xbps'):
target = name
elif name.startswith('initramfs.cpio'):
target = f'fds-initramfs-{version}.cpio' + name.removeprefix('initramfs.cpio')
else:
stem, suffix = name.rsplit('.', 1)
target = f'{stem}-{version}.{suffix}'
shutil.copyfile(source, output / target)
packed_tree(snapshot, output / f'fds-build-inputs-{version}.tar.zst', epoch)
packed_tree(snapshot / 'project', output / f'fds-source-{version}.tar.zst', epoch)
(output / 'reproducibility.json').write_text(json.dumps(comparison, indent=2) + '\n')
shutil.copyfile(snapshot / 'lock.json', output / 'build-inputs.json')
for profile in ('cli', 'development', 'recovery'):
root = (project / f'out/rootfs-{profile}.tar').resolve(strict=True).parent
shutil.copyfile(root / 'packages.json', output / f'packages-{profile}.json')
shutil.copyfile(project / 'docs/releases.md', output / 'RELEASE-VERIFICATION.md')
shutil.copyfile(project / 'docs/internal-storage.md', output / 'INSTALLATION.md')
shutil.copyfile(project / 'docs/eeprom.md', output / 'EEPROM.md')
shutil.copyfile(project / 'tools/release-readme.md', output / 'README.md')
shutil.copyfile(project / 'LICENSE', output / 'LICENSE')
subprocess.run([str(signer), 'public-key', str(key), str(output / 'signer.pub')], check=True)
manifest = {'format': 1, 'version': version, 'source_epoch': epoch,
'source_sha256': lock['source_sha256'], 'void_commit': lock['void_commit'],
'hardware_validation': 'deferred', 'files': []}
for path in sorted(output.iterdir()):
manifest['files'].append({'name': path.name, 'bytes': path.stat().st_size, 'sha256': digest(path)})
if len(manifest['files']) > 64:
raise ValueError('Release exceeds the bounded manifest artifact count')
(output / 'manifest.json').write_text(json.dumps(manifest, indent=2) + '\n')
subprocess.run([str(signer), 'sign', str(output), '--key', str(key)], check=True)
subprocess.run([str(signer), 'verify', str(output), '--key', str(output / 'signer.pub')], check=True)
print(f'PASS: signed and verified local FDS/OS {version} release: {output}')
print('Hardware validation is deferred. Publishing and hardware installation are separate actions.')
print('Distribute the public-key fingerprint independently; the bundled key alone does not establish trust.')
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--inputs', type=Path, required=True)
parser.add_argument('--comparison-build', type=Path, required=True)
parser.add_argument('--output-directory', type=Path, required=True)
parser.add_argument('--key', type=Path, required=True)
args = parser.parse_args()
if args.comparison_build.resolve() == project:
parser.error('Comparison must be a second independently restored build tree')
try:
run(args)
except (OSError, ValueError, KeyError, subprocess.CalledProcessError) as error:
sys.exit(f'ERROR: {error}')
if __name__ == '__main__':
main()
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env bash
source "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh"
[[ $# == 0 || ( $# == 1 && $1 == --install-deps ) ]] || die 'Usage: tools/bootstrap-host [--install-deps]'
[[ $(uname -s) == Linux && $(uname -m) == x86_64 ]] || die 'M0 requires an x86_64 Linux build host'
source /etc/os-release
[[ $ID == arch ]] || die 'M0 bootstrap supports Arch Linux; see docs/build-host.md'
(( EUID != 0 )) || die 'Run as a normal user, not root'
[[ ! $FDS_ROOT =~ [[:space:]] ]] || die 'xbps-src requires a checkout path without whitespace'
if [[ ${1:-} == --install-deps ]]; then
sudo pacman -S --needed bash coreutils binutils git curl make file tar xz gzip zstd \
bubblewrap rustup ca-certificates findutils diffutils grep sed gawk util-linux
fi
for cmd in bash git curl make file readelf ldd tar xz gzip zstd sha256sum bwrap rustup \
find diff grep sed awk flock install; do
need "$cmd"
done
# Fail before downloading if unprivileged build containers cannot run.
bwrap --ro-bind / / --unshare-user --uid 0 --gid 0 true || die 'bubblewrap user namespaces are unavailable'
cd "$FDS_ROOT"
mkdir -p out/downloads out/logs .host
if [[ ${FDS_OFFLINE:-0} != 1 ]]; then
git -c http.lowSpeedLimit=1000 -c http.lowSpeedTime=60 submodule update --init --depth 1 vendor/void-packages
fi
check_void_pin
source config/host-tools.conf
archive="out/downloads/${XBPS_STATIC_URL##*/}"
if [[ ! -f $archive ]]; then
[[ ${FDS_OFFLINE:-0} != 1 ]] || die "Missing offline XBPS archive: $archive"
curl -fL --retry 3 --connect-timeout 20 --max-time 900 "$XBPS_STATIC_URL" -o "$archive.part"
printf '%s %s\n' "$XBPS_STATIC_SHA256" "$archive.part" | sha256sum -c -
mv -- "$archive.part" "$archive"
fi
printf '%s %s\n' "$XBPS_STATIC_SHA256" "$archive" | sha256sum -c -
# Verify the archive even on repeat runs; refresh tools from the verified input.
mkdir -p .host/xbps
tar -xJf "$archive" -C .host/xbps
for cmd in xbps-install xbps-query xbps-create xbps-rindex xbps-uhelper; do
tools/verify-elf "$FDS_XBPS/$cmd" x86_64 static
"$FDS_XBPS/$cmd" -V
done
use_xbps
tools/prepare-void
rust_version=$(sed -n 's/^channel = "\([^"]*\)"$/\1/p' rust-toolchain.toml)
[[ $rust_version =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || die 'Rust toolchain must be version pinned'
if [[ ${FDS_OFFLINE:-0} == 1 ]]; then
[[ -f .host/frozen/lock.json ]] || die 'Restore a verified frozen-input snapshot before offline bootstrap'
rustc --version | grep -q "^rustc $rust_version " || die 'Wrong restored Rust toolchain'
rustfmt --version
rustup target list --installed | grep -qx aarch64-unknown-linux-musl || die 'Missing restored ARM Rust target'
else
rustup toolchain install "$rust_version" --profile minimal --component rustfmt --target aarch64-unknown-linux-musl
fi
# Cargo resolves all workspace members even for the dependency-free smoketest.
# Seed the locked Dasung dependencies so later --offline builds work on a new host.
fetch_rust_inputs
(
cd "$FDS_VOID"
xbps_src -A x86_64 binary-bootstrap
) 2>&1 | tee out/logs/xbps-bootstrap.log
[[ $(cat "$FDS_VOID/masterdir-x86_64/.xbps_chroot_init") == x86_64 ]] || die 'Wrong Void build container architecture'
xbps-query -r "$FDS_VOID/masterdir-x86_64" base-chroot >/dev/null
bwrap --ro-bind "$FDS_VOID/masterdir-x86_64" / --dev /dev --proc /proc \
--chdir / /usr/bin/gcc --version
printf '\nPASS: M0 host bootstrap; run make smoke-test to build both aarch64 artifacts\n'
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env bash
# Build every FDS base package from its source overlay and checked static payloads.
source "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh"
[[ $# == 0 ]] || die 'Usage: tools/build-base-packages'
cd "$FDS_ROOT"
use_xbps
check_void_pin
mkdir -p out/logs out/packages out/manifests
exec 8>out/.base-packages.lock
flock -n 8 || die 'Another base package build is already running'
# Refresh the hardware package and every FDS overlay used in this image.
tools/build-fds-package
tools/build-kernel
tools/build-dasungd
input="$FDS_VOID/hostdir/sources/fds-init-0.1.0"
if [[ -d $input ]]; then
previous=$(mktemp -d "$FDS_ROOT/out/init-source.XXXXXX")
mv "$input" "$previous/"
fi
mkdir -p "$input/s6-source/boot/contents.d" "$input/s6-source/dasungd-runtime/dependencies.d"
for service in runtime-fs hostname console getty eudevd udev-trigger test-runtime test-echo machine-config; do
cp -a "s6/source/$service" "$input/s6-source/"
done
for member in runtime-fs hostname getty udev-trigger; do
cp "s6/source/boot/contents.d/$member" "$input/s6-source/boot/contents.d/"
done
cp s6/source/dasungd-runtime/dependencies.d/runtime-fs "$input/s6-source/dasungd-runtime/dependencies.d/"
(
cd "$input"
find . -type f ! -name SHA256SUMS -print0 | sort -z | xargs -0 sha256sum >SHA256SUMS
)
for package in fds-dhcpcd fds-eink fds-base-files fds-init fds-base; do
(
cd "$FDS_VOID"
xbps_src -a aarch64 clean "$package"
xbps_src -f -a aarch64 pkg "$package"
) 2>&1 | tee "out/logs/xbps-$package.log"
cp "$FDS_VOID/hostdir/binpkgs/$package-"*.aarch64.xbps out/packages/
done
XBPS_ARCH=aarch64 xbps-rindex -fa out/packages/fds-*.aarch64.xbps
printf 'PASS: all FDS base packages exported to out/packages/\n'
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env bash
source "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh"
[[ $# == 0 ]] || die 'Usage: tools/build-dasungd'
cd "$FDS_ROOT"
use_xbps
check_void_pin
mkdir -p out/logs out/manifests out/packages
# These are build-container tools; no Arch packages or host services are changed.
missing=()
for package in cross-aarch64-linux-musl pkg-config s6-rc; do
if ! XBPS_ARCH=x86_64 xbps-query -r "$FDS_VOID/masterdir-x86_64" "$package" >/dev/null 2>&1; then
missing+=("$package")
fi
done
if (( ${#missing[@]} )); then
tools/in-void xbps-install -y "${missing[@]}"
fi
# Fetch pinned crates once, then keep the actual build offline.
fetch_rust_inputs
# libusb's vendored backend must not discover host libudev or shared libusb.
tools/in-void env LIBUSB_NO_PKG_CONFIG=1 LIBUDEV_NO_PKG_CONFIG=1 \
CC_aarch64_unknown_linux_musl=aarch64-linux-musl-gcc \
AR_aarch64_unknown_linux_musl=aarch64-linux-musl-ar \
"$FDS_ROOT/tools/cargo-build" --locked --offline --release --target aarch64-unknown-linux-musl \
-p dasungd --features vendored
binary=target/aarch64-unknown-linux-musl/release/dasungd
tools/verify-elf "$binary" aarch64 static
cp -- "$binary" out/dasungd
input="$FDS_VOID/hostdir/sources/fds-dasungd-0.1.0"
mkdir -p "$input"
cp -- "$binary" "$input/dasungd"
cp rust/dasungd/LICENSE rust/dasungd/profiles/paperlike13k-37hz.edid "$input/"
cp docs/dasung.md "$input/"
# Cargo's vendored libusb license accompanies the static binary.
fds_cargo_home=${CARGO_HOME:-$HOME/.cargo}
license=$(find "$fds_cargo_home/registry/src" -path '*/libusb1-sys-0.7.0/libusb/COPYING' -print -quit)
[[ -f $license ]] || die 'Pinned libusb license missing from Cargo source cache'
cp "$license" "$input/libusb-COPYING"
# Replace only our generated service payload, preserving older builds as archives.
if [[ -d $input/s6-source ]]; then
previous=$(mktemp -d "$FDS_ROOT/out/dasung-services.XXXXXX")
mv "$input/s6-source" "$previous/"
fi
mkdir -p "$input/s6-source/boot/contents.d"
cp -a s6/source/{dasungd,dasungd-log,dasungd-runtime} "$input/s6-source/"
cp s6/source/boot/type "$input/s6-source/boot/"
cp s6/source/boot/contents.d/dasungd "$input/s6-source/boot/contents.d/"
# The init package supplies this cross-package dependency. The standalone
# display graph remains runnable in the existing private-container test.
rm -f "$input/s6-source/dasungd-runtime/dependencies.d/runtime-fs"
(
cd "$input"
find . -type f ! -name SHA256SUMS -print0 | sort -z | xargs -0 sha256sum >SHA256SUMS
)
# Compile the actual service source now, never during target boot.
compiled_parent=$(mktemp -d "$FDS_ROOT/out/dasung-s6.XXXXXX")
tools/in-void s6-rc-compile "$compiled_parent/compiled" "$input/s6-source"
printf '%s\n' "$compiled_parent/compiled" >out/manifests/dasung-s6-database.txt
# xbps-src otherwise reuses a same-version package after local source changes.
tools/prepare-void
(
cd "$FDS_VOID"
xbps_src -f -a aarch64 pkg fds-dasungd
) 2>&1 | tee out/logs/xbps-fds-dasungd.log
package=fds-dasungd-0.1.0_1.aarch64.xbps
cp "$FDS_VOID/hostdir/binpkgs/$package" out/packages/
XBPS_ARCH=aarch64 xbps-rindex -fa "out/packages/$package"
sha256sum out/dasungd "out/packages/$package" >out/manifests/dasung-artifacts.sha256
XBPS_ARCH=x86_64 xbps-query -r "$FDS_VOID/masterdir-x86_64" -l >out/manifests/dasung-build-packages.txt
sha256sum Cargo.toml Cargo.lock rust-toolchain.toml .cargo/config.toml tools/cargo-build tools/lib.sh tools/build-dasungd >out/manifests/dasung-rust-inputs.sha256
printf 'PASS: Dasung static ARM executable, base package, and s6 database built\n'
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
source "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh"
[[ $# == 0 ]] || die 'Usage: tools/build-fds'
cd "$FDS_ROOT"
mkdir -p out/logs out/manifests
tools/cargo-build --locked --offline --release --target aarch64-unknown-linux-musl \
-p fds-cli -p fds-stage0 -p fds-boottrace -p fds-cartridged -p fds-burn -p fds-release
for binary in fds fds-stage0 fds-boottrace fds-cartridged fds-profile fds-burn fds-release; do
tools/verify-elf "target/aarch64-unknown-linux-musl/release/$binary" aarch64 static
cp "target/aarch64-unknown-linux-musl/release/$binary" "out/$binary"
done
cp out/fds out/fds-inspect
cp out/fds out/fds-eject
cp out/fds out/fds-power
sha256sum out/fds-release out/fds-burn out/fds-inspect out/fds-eject out/fds-power out/fds out/fds-stage0 out/fds-boottrace out/fds-cartridged out/fds-profile >out/manifests/fds-tools.sha256
find rust/fds-software rust/fds-common rust/fds-cli rust/fds-stage0 rust/fds-boottrace rust/fds-cartridged rust/fds-burn rust/fds-release -type f -print0 | sort -z | \
xargs -0 sha256sum >out/manifests/fds-tools-inputs.sha256
sha256sum Cargo.toml Cargo.lock rust-toolchain.toml .cargo/config.toml tools/cargo-build tools/lib.sh tools/build-fds >>out/manifests/fds-tools-inputs.sha256
printf 'PASS: static ARM FDS CLI and stage0 tools built\n'
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
source "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh"
[[ $# == 0 ]] || die 'Usage: tools/build-fds-package'
cd "$FDS_ROOT"
use_xbps
tools/build-fds
tools/prepare-void
input="$FDS_VOID/hostdir/sources/fds-cli-0.1.0"
mkdir -p "$input" out/packages
cp out/fds out/fds-boottrace out/fds-burn out/fds-inspect out/fds-eject out/fds-power out/fds-release LICENSE "$input/"
notices=$(mktemp -d "$FDS_ROOT/out/rust-notices.XXXXXX")
python3 tools/rust-notices "$notices/RUST-NOTICES.txt"
cp "$notices/RUST-NOTICES.txt" "$input/"
(cd "$input" && sha256sum fds fds-boottrace fds-burn fds-inspect fds-eject fds-power fds-release LICENSE RUST-NOTICES.txt >SHA256SUMS)
(
cd "$FDS_VOID"
xbps_src -a aarch64 clean fds-cli
xbps_src -f -a aarch64 pkg fds-cli
) 2>&1 | tee out/logs/xbps-fds-cli.log
cp "$FDS_VOID/hostdir/binpkgs/fds-cli-0.1.0_1.aarch64.xbps" out/packages/
XBPS_ARCH=aarch64 xbps-rindex -fa out/packages/fds-cli-0.1.0_1.aarch64.xbps
input="$FDS_VOID/hostdir/sources/fds-cartridged-0.1.0"
if [[ -d $input ]]; then
previous=$(mktemp -d "$FDS_ROOT/out/cartridged-source.XXXXXX")
mv "$input" "$previous/"
fi
mkdir -p "$input/s6-source/boot/contents.d"
cp out/fds-cartridged out/fds-profile LICENSE "$input/"
cp -a s6/source/cartridged s6/source/cartridged-runtime s6/source/cartridged-log "$input/s6-source/"
for service in desktop desktop-runtime xserver xserver-log desktop-session desktop-log network network-runtime dhcp network-log; do
cp -a "s6/source/$service" "$input/s6-source/"
done
cp s6/source/boot/contents.d/cartridged "$input/s6-source/boot/contents.d/"
(cd "$input" && find . -type f ! -name SHA256SUMS -print0 | sort -z | xargs -0 sha256sum >SHA256SUMS)
(
cd "$FDS_VOID"
xbps_src -a aarch64 clean fds-cartridged
xbps_src -f -a aarch64 pkg fds-cartridged
) 2>&1 | tee out/logs/xbps-fds-cartridged.log
cp "$FDS_VOID/hostdir/binpkgs/fds-cartridged-0.1.0_1.aarch64.xbps" out/packages/
XBPS_ARCH=aarch64 xbps-rindex -fa out/packages/fds-cartridged-0.1.0_1.aarch64.xbps
printf 'PASS: FDS CLI package built without a dynamic libc dependency\n'
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
source "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh"
[[ $# == 0 || ( $# == 1 && $1 == --rebuild ) ]] || die 'Usage: tools/build-kernel [--rebuild]'
cd "$FDS_ROOT"
use_xbps
check_void_pin
tools/prepare-void
mkdir -p out/logs out/packages out/manifests
package=fds-kernel-6.12.87_1.aarch64.xbps
inputs=$(mktemp "$FDS_ROOT/out/kernel-inputs.XXXXXX")
trap 'rm -f "$inputs"' EXIT
sha256sum VOID_PACKAGES_COMMIT config/xbps-src.conf tools/build-kernel \
image/kernel/dasung.config packages/fds-kernel/template packages/fds-kernel/files/fds.config >"$inputs"
if [[ $# == 0 && -s out/kernel/build-inputs.sha256 && -s out/kernel/payload.sha256 ]] && \
cmp -s "$inputs" out/kernel/build-inputs.sha256 && \
sha256sum -c out/kernel/artifacts.sha256 >/dev/null 2>&1 && \
(cd out/kernel && sha256sum -c payload.sha256 >/dev/null 2>&1); then
XBPS_ARCH=aarch64 xbps-rindex -fa "out/packages/$package"
printf 'PASS: reusing verified Pi kernel build with unchanged recorded inputs\n'
exit 0
fi
(
cd "$FDS_VOID"
if [[ ${1:-} == --rebuild ]]; then
xbps_src -f -a aarch64 pkg fds-kernel
else
xbps_src -a aarch64 pkg fds-kernel
fi
) 2>&1 | tee out/logs/xbps-fds-kernel.log
cp "$FDS_VOID/hostdir/binpkgs/$package" out/packages/
XBPS_ARCH=aarch64 xbps-rindex -fa "out/packages/$package"
work=$(mktemp -d "$FDS_ROOT/out/kernel-build.XXXXXX")
tar -xf "out/packages/$package" -C "$work"
[[ -s $work/boot/kernel_2712.img && -s $work/boot/bcm2712-rpi-5-b.dtb ]] || die 'Missing Pi 5 kernel/DTB'
# Preserve the existing mandatory display-protection requirement.
while IFS= read -r setting; do
[[ $setting == CONFIG_*=* || $setting == '# CONFIG_'*' is not set' ]] || continue
grep -Fqx "$setting" "$work/boot/config-fds" || die "Dasung kernel requirement missing: $setting"
done <image/kernel/dasung.config
sha256sum "out/packages/$package" "$work/boot/kernel_2712.img" "$work/boot/bcm2712-rpi-5-b.dtb" >"$work/artifacts.sha256"
cp "$work/artifacts.sha256" out/manifests/kernel-artifacts.sha256
cp "$inputs" "$work/build-inputs.sha256"
(cd "$work" && find boot usr -type f -print0 | sort -z | xargs -0 sha256sum >payload.sha256)
ln -sfn "${work##*/}" out/kernel
printf 'PASS: pinned Pi 5 kernel, modules, DTBs and boot configuration built\n'
printf 'SKIP: physical Pi boot and display behavior require hardware\n'
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env bash
source "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh"
[[ $# == 1 && $1 =~ ^[a-zA-Z0-9][a-zA-Z0-9+_.-]*$ ]] || die 'Usage: tools/build-package PACKAGE'
package=$1
use_xbps
"$FDS_ROOT/tools/prepare-void"
mkdir -p "$FDS_ROOT/out/logs" "$FDS_ROOT/out/packages"
(
cd "$FDS_VOID"
xbps_src -a aarch64 pkg "$package"
) 2>&1 | tee "$FDS_ROOT/out/logs/xbps-$package.log"
shopt -s nullglob
artifacts=("$FDS_VOID/hostdir/binpkgs/$package-"[0-9]*.aarch64.xbps)
(( ${#artifacts[@]} > 0 )) || die "No aarch64 package produced: $package"
cp -- "${artifacts[@]}" "$FDS_ROOT/out/packages/"
printf 'PASS: aarch64 package exported to out/packages/\n'
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env bash
source "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh"
[[ $# == 0 ]] || die 'Usage: PROFILE=cli tools/build-rootfs'
profile=${PROFILE:-cli}
[[ $profile == cli || $profile == development || $profile == recovery ]] || die 'Supported profiles: cli, development, recovery'
use_xbps
check_void_pin
need python3
python3 -c 'import sys; assert sys.version_info >= (3, 14), "Rootfs assembly requires Python 3.14+ for zstd XBPS archives"'
cd "$FDS_ROOT"
mkdir -p out/logs out/manifests out/packages out/cache/rootfs
exec 9>out/.rootfs.lock
flock -n 9 || die 'Another rootfs build is already running'
missing=()
for package in qemu-user-aarch64 util-linux; do
if ! xbps-query -r "$FDS_VOID/masterdir-x86_64" "$package" >/dev/null 2>&1; then
missing+=("$package")
fi
done
if (( ${#missing[@]} )); then
tools/in-void xbps-install -y "${missing[@]}"
fi
tools/build-base-packages
work=$(mktemp -d "$FDS_ROOT/out/rootfs-build.XXXXXX")
root="$work/root"
printf 'Rootfs build workspace: %s\n' "$work"
# Keep failed workspaces for diagnosis. No partial result replaces a good archive.
mkdir -p "$root"
python3 tools/rootfs-layout "$root" packages/fds-base-files/files/layout.json
mkdir -p "$root/var/db/xbps/keys" "$work/config"
cp "$FDS_VOID/common/repo-keys/"*.plist "$root/var/db/xbps/keys/"
cp packages/fds-base-files/files/10-fds.conf "$work/config/"
mapfile -t packages < <(sed -e 's/#.*//' -e '/^[[:space:]]*$/d' image/base-packages.list "profiles/$profile.list")
(( ${#packages[@]} )) || die 'Empty image/base-packages.list'
for package in "${packages[@]}"; do
[[ $package =~ ^[a-zA-Z0-9][a-zA-Z0-9+_.-]*$ ]] || die "Invalid base package: $package"
done
# Extract with native XBPS in the build user namespace. The on-disk tree belongs
# to the host user; rootfs-archive restores ownership from package headers.
repository=https://repo-default.voidlinux.org/current/aarch64
if [[ ${FDS_OFFLINE:-0} == 1 ]]; then
repository="$FDS_ROOT/.host/frozen/$profile"
[[ -f $repository/aarch64-repodata ]] || die "Missing frozen repository for $profile"
fi
tools/in-void env XBPS_ARCH=aarch64 XBPS_TARGET_ARCH=aarch64 \
xbps-install -r "$root" -C "$work/config" \
-c "$FDS_ROOT/out/cache/rootfs" -i \
-R "$repository" \
-R "$FDS_ROOT/out/packages" -SyU --reproducible "${packages[@]}"
printf '%s\n' "$profile" >"$root/usr/share/fds/image-profile"
install -Dm644 docs/internal-storage.md "$root/usr/share/doc/fds/internal-storage.md"
install -Dm644 docs/recovery.md "$root/usr/share/doc/fds/recovery.md"
install -Dm644 docs/releases.md "$root/usr/share/doc/fds/releases.md"
install -Dm644 tools/capture-hardware "$root/usr/share/fds/capture-hardware"
python3 tools/rootfs-audit "$root" --unconfigured
epoch=$(git -C "$FDS_VOID" show -s --format=%ct HEAD)
tools/in-rootfs "$root" /usr/bin/env SOURCE_DATE_EPOCH="$epoch" /bin/bash /tmp/fds-finalize
# Apply the upstream iputils policy with direct QEMU and a namespace-local
# CAP_SETFCAP; the exported capability must be an actual persisted xattr.
tools/in-rootfs "$root" --direct /usr/bin/setcap CAP_NET_RAW+p /usr/bin/iputils-ping
tools/in-rootfs "$root" --direct /usr/bin/getcap /usr/bin/iputils-ping | grep -qx '/usr/bin/iputils-ping cap_net_raw=p'
python3 tools/rootfs-audit "$root"
tools/rootfs-runtime-check "$root"
# Preserve the exact selected package inputs, independently of future repository
# updates. Local FDS package versions can be rebuilt, so copy them per build.
archive_inputs=(out/cache/rootfs out/packages)
if [[ ${FDS_OFFLINE:-0} == 1 ]]; then
archive_inputs+=("$repository")
fi
python3 tools/rootfs-archive "$root" "$work" "${archive_inputs[@]}"
{
printf 'profile=%s\nvoid_commit=%s\n' "$profile" "$(cat VOID_PACKAGES_COMMIT)"
printf 'emulation=private-user-namespace-binfmt\n'
tools/in-void xbps-query -p pkgver qemu-user-aarch64
date -u '+built_at=%Y-%m-%dT%H:%M:%SZ'
} >"$work/build.txt"
find packages/fds-base packages/fds-base-files packages/fds-init packages/fds-cli packages/fds-cartridged packages/fds-kernel packages/fds-eink packages/fds-dhcpcd profiles s6/source -type f -print0 | sort -z | \
xargs -0 sha256sum >"$work/build-inputs.sha256"
sha256sum image/base-packages.list tools/{build-rootfs,build-base-packages,build-fds,build-fds-package,build-kernel,in-void,in-rootfs,rootfs-namespace,finalize-rootfs,rootfs-audit,rootfs-archive,rootfs-runtime-check,rootfs-layout,rootfs_lib.py,lib.sh} \
>>"$work/build-inputs.sha256"
sha256sum docs/recovery.md docs/internal-storage.md docs/releases.md tools/capture-hardware tools/rust-notices tools/cargo-build .cargo/config.toml >>"$work/build-inputs.sha256"
cp out/manifests/fds-tools*.sha256 "$work/"
# The versioned build directory is the authoritative result; stable links are
# convenience pointers. Rename the archive last, after every acceptance check.
ln -sfn "${work##*/}/root" out/rootfs-aarch64
ln -sfn "../${work##*/}" out/manifests/rootfs-latest
ln -s "${work##*/}/rootfs-aarch64.tar" out/rootfs-aarch64.tar.next
mv -Tf out/rootfs-aarch64.tar.next out/rootfs-aarch64.tar
ln -s "${work##*/}/rootfs-aarch64.tar" "out/rootfs-$profile.tar.next"
mv -Tf "out/rootfs-$profile.tar.next" "out/rootfs-$profile.tar"
printf 'PASS: FDS rootfs built and verified: out/rootfs-aarch64.tar\n'
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
# Native Linux host binaries; independent of the Pi and Void build container.
set -euo pipefail
[[ $# == 0 ]] || { printf 'Usage: tools/build-workstation\n' >&2; exit 2; }
[[ $(uname -s) == Linux ]] || { printf 'A Linux workstation is required\n' >&2; exit 2; }
(( EUID != 0 )) || { printf 'Build as an ordinary user\n' >&2; exit 2; }
cd "$(dirname -- "${BASH_SOURCE[0]}")/.."
command -v cargo >/dev/null || { printf 'Install Rust with Cargo before building workstation tools\n' >&2; exit 2; }
fds_host_target=$(rustc -vV | sed -n 's/^host: //p')
[[ $fds_host_target == *-linux-* ]] || { printf 'Rust must target the native Linux host\n' >&2; exit 2; }
cargo build --locked --release --target "$fds_host_target" -p fds-workstation
mkdir -p out/workstation
install -m755 "target/$fds_host_target/release/fds-cartridge" out/workstation/fds-cartridge
install -m755 "target/$fds_host_target/release/fds-emulator" out/workstation/fds-emulator
printf 'Built native Linux tools in out/workstation/\n'
+42
View File
@@ -0,0 +1,42 @@
#!/bin/bash
# Read-only diagnostic collection, intended to run on the FDS target.
set -euo pipefail
umask 077
if [[ $# != 1 || $1 != /* ]]; then
printf 'Usage: bash capture-hardware /absolute/new/capture-directory\n' >&2
exit 2
fi
output=$1
mkdir -- "$output"
output=$(realpath -- "$output")
trap 'printf "Capture interrupted; partial files retained in %s\n" "$output" >&2' INT TERM
printf 'format=1\ncreated_utc=%s\nuid=%s\n' "$(date -u +%FT%TZ)" "$(id -u)" >"$output/capture.txt"
printf 'command\texit_status\n' >"$output/status.tsv"
collect() {
local name=$1 status=0
shift
"$@" >"$output/$name.txt" 2>"$output/$name.stderr.txt" || status=$?
printf '%s\t%s\n' "$name" "$status" >>"$output/status.tsv"
}
collect identity fds --json info
collect bays fds --json bays
collect topology fds --json topology
collect profiles fds --json profiles
collect power fds --json power status
collect boot-profile fds --json boot-profile
collect kernel uname -a
collect cmdline cat /proc/cmdline
collect kernel-log dmesg --time-format=raw
collect usb-tree lsusb -t
collect usb-devices lsusb
collect block-devices lsblk --json -o NAME,MAJ:MIN,MODEL,SERIAL,SIZE,RO,TYPE,FSTYPE,PARTLABEL,MOUNTPOINTS
collect mounts cat /proc/self/mountinfo
collect interfaces ip -details link
collect memory cat /proc/meminfo
collect os-release cat /etc/os-release
collect package-list xbps-query -l
(
cd -- "$output"
sha256sum -- ./*.txt status.tsv >SHA256SUMS
)
printf 'Collected diagnostics in %s\nRead status.tsv for unavailable or failed commands; this capture is not a hardware pass.\n' "$output"
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# Preserve normal Cargo target/linker flags and normalize embedded source paths.
source "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh"
cd "$FDS_ROOT"
fds_cargo_home=${CARGO_HOME:-$HOME/.cargo}
fds_rustup_home=${RUSTUP_HOME:-$HOME/.rustup}
mappings=("$FDS_ROOT=/usr/src/fds" "$fds_rustup_home=/usr/src/fds/rustup")
for registry in "$fds_cargo_home"/registry/src/*; do
[[ -d $registry ]] || continue
mappings+=("$registry=/usr/src/fds/crates")
done
flags=
for mapping in "${mappings[@]}"; do
# This creates TOML data passed as one argument, not executable shell text.
escaped=${mapping//\\/\\\\}
escaped=${escaped//\"/\\\"}
flags+="\"--remap-path-prefix=$escaped\","
done
# Cargo merges these arrays with the configured target flags, including static
# CRT and self-contained linking. The flags participate in Cargo's cache key.
exec cargo \
--config "target.aarch64-unknown-linux-musl.rustflags=[$flags]" \
--config "target.x86_64-unknown-linux-gnu.rustflags=[$flags]" \
build "$@"
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env python3
"""Compare complete independently restored builds; retain mismatches as evidence."""
import argparse
import json
from pathlib import Path
import sys
from release_artifacts import compare
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('first', type=Path)
parser.add_argument('second', type=Path)
parser.add_argument('--report', required=True, type=Path)
args = parser.parse_args()
try:
if args.first.resolve() == args.second.resolve():
raise ValueError('Specify two independently restored build trees')
if args.report.exists() or args.report.is_symlink():
raise ValueError('Report output already exists')
report = compare(args.first, args.second)
with args.report.open('x') as stream:
stream.write(json.dumps(report, indent=2) + '\n')
for item in report['artifacts']:
print(('PASS' if item['identical'] else 'MISMATCH') + ': ' + item['name'])
if report['status'] != 'passed':
raise ValueError(f'Builds differ; inspect {args.report}')
print(f'PASS: all {len(report["artifacts"])} artifacts are byte-identical: {args.report}')
print('NOTE: this comparison establishes matching bytes; retain each isolated build log as execution evidence')
except (OSError, ValueError, KeyError) as error:
sys.exit(f'ERROR: {error}')
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env python3
"""Measure z/2/3 stage0 builds on the same full ARM VM; never claim Pi timings."""
import json
import os
from pathlib import Path
import statistics
import subprocess
import sys
import tempfile
project = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(project/'tools'))
from image_formats import digest
from vm_test import VM
if len(sys.argv) != 1: sys.exit('Usage: tools/compare-rust-profiles')
work = Path(tempfile.mkdtemp(prefix='rust-profiles.', dir=project/'out'))
image = (project/'out/fds-system-cli.img').resolve(strict=True)
records = {}
for level in ('z', '2', '3'):
build = work/('build-'+level)
environment = dict(os.environ, CARGO_TARGET_DIR=str(build), CARGO_PROFILE_RELEASE_OPT_LEVEL=level)
with (work/('build-'+level+'.log')).open('wb') as log:
subprocess.run([str(project/'tools/cargo-build'), '--locked', '--offline', '--release', '--target',
'aarch64-unknown-linux-musl', '-p', 'fds-stage0'], cwd=project,
env=environment, check=True, stdout=log, stderr=subprocess.STDOUT)
binary = build/'aarch64-unknown-linux-musl/release/fds-stage0'
ramfs = work/('initramfs-'+level)
ramfs.mkdir()
with (work/('initramfs-'+level+'.log')).open('wb') as log:
subprocess.run([str(project/'image/build-initramfs'), '--stage0', str(binary),
'--output-directory', str(ramfs)], check=True, stdout=log, stderr=subprocess.STDOUT)
samples = []
for trial in range(3):
name = f'opt-{level}-run-{trial+1}'
with VM(work, name, image, initramfs=ramfs/'initramfs.cpio') as vm:
vm.expect(rb'FDS> ')
vm.send('printf "\\nFDS_TRACE_BEGIN\\n"; fds --json boot-profile; printf "\\nFDS_TRACE_END\\n"')
capture = vm.expect(rb'^FDS_TRACE_BEGIN\r?\n(\{.*?\})\r?\n\r?\nFDS_TRACE_END\r?$')
report = json.loads(capture.group(1))
assert report['missing_events'] == ['desktop-ready']
assert 'virt' in report['platform'].lower()
(work/(name+'.json')).write_text(json.dumps(report, indent=2)+'\n')
samples.append(report['durations_ns'])
records[level] = {'binary_bytes': binary.stat().st_size, 'binary_sha256': digest(binary),
'samples_ns': samples,
'median_ns': {key: statistics.median(sample[key] for sample in samples) for key in samples[0]}}
print(f'PASS: opt-level={level}, {binary.stat().st_size} bytes, three measured VM boots', flush=True)
result = {'scope': 'QEMU TCG software comparison; not physical Pi performance', 'samples_per_level': 3,
'kernel_sha256': digest(project/'out/kernel/boot/kernel_2712.img'),
'system_sha256': digest(image), 'levels': records,
'decision': 'Retain opt-level=z until physical Pi measurements justify changing it.'}
(work/'comparison.json').write_text(json.dumps(result, indent=2)+'\n')
target = project/'out/rust-profiles-latest'
temporary = target.with_suffix('.next')
temporary.symlink_to(work.name)
temporary.replace(target)
print(f'PASS: stage0 optimization comparison: {work}')
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env python3
"""Prepare and verify reversible Pi 5 EEPROM configuration files; never flash hardware."""
import argparse
import difflib
import json
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
from eeprom_inputs import PROJECT,prepare,sha256
MANAGED={'BOOT_ORDER','BOOT_UART','NET_INSTALL_ENABLED','NET_INSTALL_AT_POWER_ON','POWER_OFF_ON_HALT','WAIT_FOR_POWER_BUTTON'}
def settings(text):
if len(text.encode())>4076 or '\0' in text:raise ValueError('EEPROM configuration must fit one 4076-byte record without NUL bytes')
result={}
for line in text.splitlines():
if line.lstrip().startswith('#') or '=' not in line:continue
key,value=line.split('=',1);key=key.strip();value=value.strip()
if key in MANAGED:
if key in result:raise ValueError(f'Duplicate managed setting: {key}')
result[key]=value
return result
def merge(original,profile):
expected=settings(profile)
if set(expected)!=MANAGED:raise ValueError('Incomplete EEPROM profile')
retained=[]
for line in original.splitlines():
key=line.split('=',1)[0].strip() if '=' in line and not line.lstrip().startswith('#') else None
if key not in MANAGED:retained.append(line)
merged='\n'.join(retained).rstrip()+'\n\n'+profile.strip()+'\n'
if settings(merged)!=expected:raise ValueError('Merged settings mismatch')
return merged
def main():
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--profile',choices=['production','development'],default='production')
parser.add_argument('--base-image',type=Path,help='Regular-file Pi 5 EEPROM image; defaults to the pinned preview firmware')
parser.add_argument('--current-config',type=Path,help='Saved board configuration to preserve instead of the image defaults')
parser.add_argument('--output-directory',type=Path,help='New directory; existing paths are refused')
args=parser.parse_args()
cache,pin=prepare()
base=(args.base_image or cache/'pieeprom.bin').resolve(strict=True)
if not base.is_file() or base.stat().st_size!=2*1024*1024:raise ValueError('Base must be a regular 2 MiB Pi 5 EEPROM image, not a block device')
base_checksum=sha256(base)
tool=[sys.executable,str(cache/'rpi-eeprom-config')]
original=subprocess.check_output([*tool,str(base)]).decode('utf-8')
if args.current_config:
if not args.current_config.is_file() or args.current_config.stat().st_size>4076:raise ValueError('Current configuration must be a regular file of at most 4076 bytes')
original=args.current_config.read_text()
# Validate bounds but preserve existing duplicates/conditional overrides in
# the rollback configuration. All managed occurrences are replaced below.
if len(original.encode())>4076 or '\0' in original:raise ValueError('Invalid original configuration size or NUL byte')
profile=(PROJECT/'config/eeprom'/f'{args.profile}.conf').read_text()
configured=merge(original,profile)
if args.output_directory:
work=args.output_directory.absolute();work.mkdir(mode=0o700)
else:work=Path(tempfile.mkdtemp(prefix=f'eeprom-{args.profile}.',dir=PROJECT/'out'))
shutil.copyfile(base,work/'base.bin')
if sha256(work/'base.bin')!=base_checksum or sha256(base)!=base_checksum:raise ValueError('Base image changed during preparation')
shutil.copyfile(cache/'LICENSE',work/'LICENSE')
(work/'original.conf').write_text(original)
(work/'configured.conf').write_text(configured)
subprocess.run([*tool,'--config',str(work/'original.conf'),'--out',str(work/'rollback.bin'),str(work/'base.bin')],check=True)
subprocess.run([*tool,'--config',str(work/'configured.conf'),'--out',str(work/'configured.bin'),str(work/'base.bin')],check=True)
readback=subprocess.check_output([*tool,str(work/'configured.bin')]).decode()
rollback=subprocess.check_output([*tool,str(work/'rollback.bin')]).decode()
if readback!=configured or rollback!=original:raise ValueError('EEPROM configuration readback mismatch')
(work/'review.diff').write_text(''.join(difflib.unified_diff(original.splitlines(True),configured.splitlines(True),fromfile='original.conf',tofile='configured.conf')))
manifest={'format':1,'profile':args.profile,'upstream_commit':pin['commit'],
'hardware_modified':False,'custom_inputs_provided':bool(args.current_config or args.base_image),
'input_base_sha256':base_checksum,'settings':settings(configured),
'files':{p.name:sha256(p) for p in sorted(work.iterdir()) if p.is_file()}}
(work/'manifest.json').write_text(json.dumps(manifest,indent=2)+'\n')
if not args.output_directory and not args.current_config and not args.base_image:
link=PROJECT/'out'/f'eeprom-{args.profile}-latest.next'
link.symlink_to(work.name)
link.replace(PROJECT/'out'/f'eeprom-{args.profile}-latest')
print(f'Prepared and read-back verified: {work}')
print('No hardware was modified. Review review.diff and retain base.bin, original.conf and rollback.bin.')
if not args.current_config and not args.base_image:print('This uses pinned preview defaults; it is not a backup of your Pi.')
if __name__=='__main__':
try:main()
except (OSError,ValueError,subprocess.CalledProcessError) as error:sys.exit(f'ERROR: {error}')
+34
View File
@@ -0,0 +1,34 @@
"""Pinned official EEPROM configuration tool and Pi 5 preview image."""
import fcntl
import hashlib
import json
import os
from pathlib import Path
import subprocess
import tempfile
PROJECT=Path(__file__).resolve().parents[1]
def sha256(path):
with Path(path).open('rb') as stream:return hashlib.file_digest(stream,'sha256').hexdigest()
def prepare():
pin=json.loads((PROJECT/'config/eeprom/inputs.json').read_text())
cache=PROJECT/'.host/eeprom'/pin['commit'];cache.mkdir(parents=True,exist_ok=True)
with (cache/'.lock').open('a') as lock:
fcntl.flock(lock,fcntl.LOCK_EX)
for item in pin['files']:
target=cache/item['name']
if not target.exists():
if os.environ.get('FDS_OFFLINE')=='1':raise ValueError(f'Missing offline EEPROM input: {target}')
url=f'https://raw.githubusercontent.com/raspberrypi/rpi-eeprom/{pin["commit"]}/{item["path"]}'
fd,name=tempfile.mkstemp(prefix='.download-',dir=cache);os.close(fd)
temporary=Path(name)
try:
subprocess.run(['curl','--fail','--location','--silent','--show-error',url,'--output',str(temporary)],check=True)
if sha256(temporary)!=item['sha256']:raise ValueError(f'EEPROM input checksum mismatch: {item["name"]}')
temporary.replace(target)
finally:temporary.unlink(missing_ok=True)
if not target.is_file() or sha256(target)!=item['sha256']:
raise ValueError(f'Cached EEPROM input changed: {target}')
return cache,pin
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# This script runs inside the ARM root at image build time, never during boot.
set -euo pipefail
trap 'printf "ERROR: rootfs finalization failed at line %s: %s\n" "$LINENO" "$BASH_COMMAND" >&2' ERR
export LC_ALL=C
sed -i 's/^#en_US.UTF-8 UTF-8[[:space:]]*$/en_US.UTF-8 UTF-8/' /etc/default/libc-locales
grep -qx 'en_US.UTF-8 UTF-8' /etc/default/libc-locales
xbps-reconfigure -fa
# The upstream CA package suppresses updater errors and exits successfully.
# Require the actual generator to succeed instead of trusting that wrapper.
update-ca-certificates --fresh
ldconfig
# This auxiliary cache records build-tree inode identities. Runtime linking uses
# /etc/ld.so.cache; retain that cache and discard only the build-specific index.
rm -f /var/cache/ldconfig/aux-cache
# Repository indexes are build inputs, not installed-package metadata. They
# encode remote URLs or absolute local snapshot paths; preserve the package
# database and keys, and omit only these fetched index directories from SYSTEM.
for repository in /var/db/xbps/*; do
if [[ -d $repository && -f $repository/aarch64-repodata ]]; then
rm -rf -- "$repository"
fi
done
# The exported tar normalizes mtimes to this same epoch. Generate caches against
# those directory timestamps so read-only SYSTEM can use them without rescanning.
: "${SOURCE_DATE_EPOCH:?Missing reproducible image epoch}"
printf '%s\n' "$SOURCE_DATE_EPOCH" >/usr/share/fds/build-epoch
find /usr/share/fonts -type d -exec touch -d "@$SOURCE_DATE_EPOCH" {} +
fc-cache -f
find /usr/share/fonts -type d -exec touch -d "@$SOURCE_DATE_EPOCH" {} +
fc-match Terminus | grep -qi terminus
# Compile with the target s6-rc tools so no host database format is assumed.
s6-rc-compile /etc/s6-rc/compiled /etc/s6-rc/source
s6-linux-init-maker -1 -q 0 -c /etc/s6-linux-init/current \
-p /usr/bin:/bin -e LANG=en_US.UTF-8 -e TZ=UTC \
-f /usr/share/fds/init-skel /etc/s6-linux-init/current
for command in init halt poweroff reboot shutdown telinit; do
test ! -e "/usr/bin/$command"
ln -s "../../etc/s6-linux-init/current/bin/$command" "/usr/bin/$command"
done
locale -a | grep -qx en_US.utf8
test -s /etc/ld.so.cache
test -s /etc/ssl/certs/ca-certificates.crt
test -s /etc/udev/hwdb.bin
printf 'PASS: ARM package configuration, locales, certificates, hwdb and s6 database\n'
+316
View File
@@ -0,0 +1,316 @@
#!/usr/bin/env python3
"""Freeze and restore explicit project build inputs; never copy user credentials."""
import argparse
import fcntl
import hashlib
import json
import os
from pathlib import Path
import re
import shutil
import stat
import subprocess
import sys
import tomllib
project = Path(__file__).resolve().parents[1]
OWNED = ('.cargo', '.gitignore', '.gitmodules', 'AGENTS.md', 'Cargo.lock',
'Cargo.toml', 'LICENSE', 'Makefile', 'README.md', 'VOID_PACKAGES_COMMIT',
'config', 'docs', 'image', 'packages', 'profiles', 'rust-toolchain.toml',
'rust', 's6', 'tests', 'tools')
def digest(path):
with path.open('rb') as stream:
return hashlib.file_digest(stream, 'sha256').hexdigest()
def copy(source, destination):
destination.parent.mkdir(parents=True, exist_ok=True)
if source.is_dir() and not source.is_symlink():
shutil.copytree(source, destination, symlinks=True,
ignore=shutil.ignore_patterns('__pycache__', '*.pyc'))
else:
shutil.copy2(source, destination, follow_symlinks=False)
def inventory(root):
result = []
for directory, dirs, files in os.walk(root, followlinks=False):
for name in sorted(dirs + files):
path = Path(directory) / name
relative = path.relative_to(root).as_posix()
if relative == 'lock.json':
continue
metadata = path.lstat()
item = {'path': relative, 'mode': stat.S_IMODE(metadata.st_mode)}
if stat.S_ISLNK(metadata.st_mode):
item.update(type='symlink', target=os.readlink(path))
elif stat.S_ISREG(metadata.st_mode):
item.update(type='file', bytes=metadata.st_size, sha256=digest(path))
elif stat.S_ISDIR(metadata.st_mode):
item.update(type='directory')
else:
raise ValueError(f'Unexpected special build input: {relative}')
result.append(item)
return sorted(result, key=lambda item: item['path'])
def source_digest(items):
source = [dict(item, path=item['path'].removeprefix('project/'))
for item in items if item['path'].startswith('project/')]
return hashlib.sha256(json.dumps(source, sort_keys=True, separators=(',', ':')).encode()).hexdigest()
def new_directory(path):
if path.exists() or path.is_symlink():
raise ValueError(f'Output already exists: {path}')
path.mkdir(mode=0o700, parents=False)
def run(*args, **kwargs):
return subprocess.run(list(map(str, args)), check=True, **kwargs)
def check_pin():
pin = (project / 'VOID_PACKAGES_COMMIT').read_text().strip()
void = project / 'vendor/void-packages'
actual = subprocess.check_output(['git', '-C', str(void), 'rev-parse', 'HEAD'], text=True).strip()
if pin != actual:
raise ValueError('Void checkout differs from its pin')
run('git', '-C', void, 'diff', '--exit-code', 'HEAD', '--')
return void, pin
def add_package(source, destination):
target = destination / source.name
if target.exists():
if digest(target) != digest(source):
raise ValueError(f'Conflicting same-version package inputs: {source.name}')
return
copy(source, target)
def index(directory, arch):
packages = sorted([*directory.glob(f'*.{arch}.xbps'), *directory.glob('*.noarch.xbps')])
if not packages:
raise ValueError(f'No {arch} packages in {directory}')
run(project / '.host/xbps/usr/bin/xbps-rindex', '-fa', *packages,
env=dict(os.environ, XBPS_ARCH=arch))
def create(destination):
void, pin = check_pin()
epoch = int(subprocess.check_output(['git', '-C', str(void), 'show', '-s', '--format=%ct', 'HEAD']))
# All profiles must exist before a freeze. Their exact selected packages
# become independent repositories, excluding locally rebuilt FDS packages.
roots = {}
for profile in ('cli', 'development', 'recovery'):
archive = (project / f'out/rootfs-{profile}.tar').resolve(strict=True)
roots[profile] = archive.parent
if not (archive.parent / 'packages.json').is_file():
raise ValueError(f'Missing selected-package inventory for {profile}')
new_directory(destination)
for name in OWNED:
copy(project / name, destination / 'project' / name)
run('git', '-C', void, 'bundle', 'create', destination / 'void.bundle', 'HEAD')
# Bundles carry commit objects, but do not retain a shallow checkout's
# boundary file. Without it, history-aware commands follow missing parents.
shallow = Path(subprocess.check_output(
['git', '-C', str(void), 'rev-parse', '--git-path', 'shallow'], text=True).strip())
if not shallow.is_absolute():
shallow = void / shallow
if shallow.is_file():
copy(shallow, destination / 'void-shallow')
print('Freezing locked Cargo sources and the selected Rust toolchain', flush=True)
# Fetching is a separate, explicit preparation step. An incomplete cache
# fails here rather than extending the lock during the snapshot.
run('cargo', 'vendor', '--locked', '--offline', '--versioned-dirs',
destination / 'cargo-vendor', cwd=project, stdout=subprocess.DEVNULL)
toolchain = Path(subprocess.check_output(['rustup', 'which', 'rustc'], cwd=project, text=True).strip()).parent.parent
copy(toolchain, destination / 'rust-toolchain')
toolchain_version = tomllib.loads((project / 'rust-toolchain.toml').read_text())['toolchain']['channel']
copy(project / '.host/xbps', destination / 'xbps')
copy(project / '.host/image-tools', destination / 'image-tools')
# This is the explicit, project-local binary build environment. Exclude
# temporary build trees, mounted paths, home directories and download caches.
master = void / 'masterdir-x86_64'
for name in ('usr', 'etc', 'bin', 'sbin', 'lib', 'lib32', 'lib64',
'.xbps_chroot_init', '.xbps_chroot_configured', '.xbps-aarch64-done'):
path = master / name
if path.exists() or path.is_symlink():
copy(path, destination / 'masterdir' / name)
copy(master / 'var/db/xbps', destination / 'masterdir/var/db/xbps')
for name in ('dev', 'sys', 'tmp', 'proc', 'host', 'boot', 'void-packages',
'builddir', 'destdir', 'home', 'var/cache/xbps', 'var/tmp', 'var/log'):
(destination / 'masterdir' / name).mkdir(parents=True, exist_ok=True)
(destination / 'masterdir/tmp').chmod(0o1777)
(destination / 'masterdir/var/tmp').chmod(0o1777)
print('Freezing selected target packages, build dependencies and source archives', flush=True)
package_directories = [void / 'hostdir/repocache-x86_64', void / 'hostdir/repocache-aarch64',
master / 'var/cache/xbps', project / 'out/downloads',
project / 'out/cache/image-tools', project / 'out/cache/rootfs']
build = destination / 'repositories/build'
build.mkdir(parents=True)
for directory in package_directories:
for package in sorted(directory.glob('*.xbps')):
if not package.name.startswith('fds-'):
add_package(package, build)
for profile, root in roots.items():
packages = json.loads((root / 'packages.json').read_text())
repo = destination / 'repositories' / profile
repo.mkdir()
for package in packages:
source = root / 'packages' / package['filename']
if digest(source) != package['sha256']:
raise ValueError(f'Selected {profile} package changed: {source.name}')
if package['name'].startswith('fds-'):
continue
add_package(source, repo)
add_package(source, build)
copy(root / 'packages.json', destination / 'profiles' / f'{profile}.json')
index(repo, 'aarch64')
for arch in ('x86_64', 'aarch64'):
index(build, arch)
source_cache = void / 'hostdir/sources'
for path in sorted(source_cache.rglob('*')):
if path.is_file() and path.name.endswith(('.tar.gz', '.tar.xz', '.tar.bz2', '.tar.zst', '.tgz', '.zip')):
copy(path, destination / 'sources' / path.relative_to(source_cache))
for category in ('boot', 'vm', 'image-tools'):
for path in sorted((project / 'out/cache' / category).glob('*.xbps')):
copy(path, destination / 'cache' / category / path.name)
host_config = dict(line.split('=', 1) for line in (project / 'config/host-tools.conf').read_text().splitlines()
if line and not line.startswith('#'))
host_archive = project / 'out/downloads' / host_config['XBPS_STATIC_URL'].rsplit('/', 1)[1]
if digest(host_archive) != host_config['XBPS_STATIC_SHA256']:
raise ValueError('Static XBPS archive differs from its configured digest')
copy(host_archive, destination / 'downloads' / host_archive.name)
eeprom = json.loads((project / 'config/eeprom/inputs.json').read_text())
for item in eeprom['files']:
path = project / '.host/eeprom' / eeprom['commit'] / item['name']
if digest(path) != item['sha256']:
raise ValueError(f'EEPROM cache differs from pin: {item["name"]}')
copy(path, destination / 'eeprom' / eeprom['commit'] / item['name'])
host = {}
for name, command in [('kernel', ['uname', '-srvmo']), ('python', ['python3', '--version']),
('bubblewrap', ['bwrap', '--version']), ('git', ['git', '--version']),
('tar', ['tar', '--version']), ('rustup', ['rustup', '--version'])]:
host[name] = subprocess.check_output(command, text=True, stderr=subprocess.DEVNULL).splitlines()[0]
files = inventory(destination)
lock = {'format': 1, 'version': '0.1.0', 'void_commit': pin, 'source_epoch': epoch,
'source_sha256': source_digest(files), 'rust_toolchain': toolchain_version,
'host_prerequisites': host, 'files': files}
(destination / 'lock.json').write_text(json.dumps(lock, indent=2) + '\n')
print(f'PASS: frozen {len(files)} entries; source SHA-256 {lock["source_sha256"]}: {destination}')
def verify(directory):
if directory.is_symlink() or not directory.is_dir():
raise ValueError('Frozen input directory must be a real directory')
lockfile = directory / 'lock.json'
if lockfile.is_symlink() or not lockfile.is_file() or lockfile.stat().st_size > 64 * 1024 * 1024:
raise ValueError('Missing or invalid frozen input lock')
lock = json.loads(lockfile.read_text())
if lock.get('format') != 1 or lock.get('version') != '0.1.0':
raise ValueError('Unsupported frozen input format')
if (set(lock) != {'format', 'version', 'void_commit', 'source_epoch', 'source_sha256',
'rust_toolchain', 'host_prerequisites', 'files'}
or not re.fullmatch(r'[0-9a-f]{40}', lock['void_commit'])
or not re.fullmatch(r'[0-9a-f]{64}', lock['source_sha256'])
or not re.fullmatch(r'[0-9]+\.[0-9]+\.[0-9]+', lock['rust_toolchain'])
or not isinstance(lock['source_epoch'], int) or not 0 < lock['source_epoch'] <= 4102444800):
raise ValueError('Invalid frozen input lock metadata')
files = inventory(directory)
if lock['files'] != files or source_digest(files) != lock['source_sha256']:
raise ValueError('Frozen input content, modes, paths or source identity changed')
print(f'PASS: frozen input lock verified: {directory}', flush=True)
return lock
def restore(source, destination):
if any(character.isspace() for character in str(destination)):
raise ValueError('The restored checkout path must not contain whitespace')
lock = verify(source)
new_directory(destination)
for path in sorted((source / 'project').iterdir()):
copy(path, destination / path.name)
void = destination / 'vendor/void-packages'
void.parent.mkdir()
run('git', 'clone', '--quiet', source / 'void.bundle', void)
if (source / 'void-shallow').is_file():
copy(source / 'void-shallow', void / '.git/shallow')
run('git', '-C', void, 'checkout', '--quiet', '--detach', lock['void_commit'])
epoch = int(subprocess.check_output(['git', '-C', str(void), 'show', '-s', '--format=%ct', 'HEAD']))
if epoch != lock['source_epoch']:
raise ValueError('Restored Void commit timestamp differs from the snapshot')
copy(source / 'masterdir', void / 'masterdir-x86_64')
copy(source / 'sources', void / 'hostdir/sources')
copy(source / 'repositories/build', void / 'hostdir/frozen-repository')
(void / 'hostdir/binpkgs').mkdir()
# xbps-src copies custom local repositories to both its host and cross
# package configurations while -N excludes every upstream remote.
custom = void / 'etc/xbps.d/custom'
custom.mkdir(parents=True, exist_ok=True)
# The pinned xbps-src regenerates the cross configuration from *local*.conf
# after initially copying custom files, so the filename must contain local.
(custom / '05-fds-frozen-local.conf').write_text('repository=/host/frozen-repository\n')
conf = void / 'masterdir-x86_64/etc/xbps.d'
shutil.rmtree(conf)
conf.mkdir()
(conf / '00-repository-main.conf').symlink_to('/dev/null')
(conf / '05-fds-frozen-local.conf').write_text('repository=/host/frozen-repository\n')
for name in ('xbps', 'image-tools', 'eeprom'):
copy(source / name, destination / '.host' / name)
for profile in ('cli', 'development', 'recovery'):
copy(source / 'repositories' / profile, destination / '.host/frozen' / profile)
copy(source / 'cache', destination / 'out/cache')
copy(source / 'downloads', destination / 'out/downloads')
copy(source / 'lock.json', destination / '.host/frozen/lock.json')
copy(source / 'cargo-vendor', destination / '.host/repro-cargo/registry/src/fds-frozen')
cargo = destination / '.host/repro-cargo'
vendor = cargo / 'registry/src/fds-frozen'
(cargo / 'config.toml').write_text('[source.crates-io]\nreplace-with="fds-frozen"\n'
f'[source.fds-frozen]\ndirectory={json.dumps(str(vendor))}\n')
rustup = destination / '.host/repro-rustup'
toolchain = lock['rust_toolchain'] + '-x86_64-unknown-linux-gnu'
copy(source / 'rust-toolchain', rustup / 'toolchains' / toolchain)
(rustup / 'settings.toml').write_text(f'version = "12"\ndefault_toolchain = "{toolchain}"\nprofile = "minimal"\n')
(destination / 'out/logs').mkdir(exist_ok=True)
print(f'PASS: fresh isolated build tree restored: {destination}')
def main():
parser = argparse.ArgumentParser(description=__doc__)
subs = parser.add_subparsers(dest='command', required=True)
for name in ('create', 'verify'):
sub = subs.add_parser(name)
sub.add_argument('directory', type=Path)
sub = subs.add_parser('restore')
sub.add_argument('directory', type=Path)
sub.add_argument('destination', type=Path)
args = parser.parse_args()
# Resolve only parent components, so a symlink at the final path remains
# visible to the new-output and verification guards.
directory = args.directory.absolute()
try:
if args.command == 'create':
# Hold the same locks as the composers for the entire snapshot.
# Standalone VM/kernel commands must still be run sequentially.
with (project / 'out/.rootfs.lock').open('a') as root_lock, (project / 'out/.base-packages.lock').open('a') as package_lock:
fcntl.flock(root_lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
fcntl.flock(package_lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
create(directory)
elif args.command == 'verify':
verify(directory)
else:
restore(directory, args.destination.absolute())
except BlockingIOError:
sys.exit('ERROR: a rootfs or base-package build is active; finish it before freezing inputs')
except (OSError, ValueError, KeyError, subprocess.CalledProcessError) as error:
sys.exit(f'ERROR: {error}')
if __name__ == '__main__':
main()
+97
View File
@@ -0,0 +1,97 @@
"""Deterministic newc and GPT writers for host image construction (ordinary files only)."""
import hashlib
from pathlib import Path
import shutil
import struct
import uuid
import zlib
NAMESPACE = uuid.UUID('34c5ce42-2282-4b58-b633-28d410000001')
LINUX_FILESYSTEM = uuid.UUID('0fc63daf-8483-4772-8e79-3d69d8477de4')
EFI_SYSTEM = uuid.UUID('c12a7328-f81f-11d2-ba4b-00a0c93ec93b')
def digest(path):
with Path(path).open('rb') as stream:
return hashlib.file_digest(stream, 'sha256').hexdigest()
def newc(entries, epoch):
"""entries: (relative name, mode, bytes, device major, device minor)."""
result = bytearray()
for inode, (name, mode, data, major, minor) in enumerate([*entries, ('TRAILER!!!', 0, b'', 0, 0)], 1):
if name.startswith('/') or '..' in Path(name).parts or '\0' in name:
raise ValueError(f'Unsafe cpio path: {name}')
encoded = name.encode() + b'\0'
fields = [inode, mode, 0, 0, 1, epoch, len(data), 0, 0, major, minor, len(encoded), 0]
result += b'070701' + ''.join(f'{field:08x}' for field in fields).encode() + encoded
result += b'\0' * (-len(result) % 4)
result += data
result += b'\0' * (-len(result) % 4)
result += b'\0' * (-len(result) % 512)
return bytes(result)
def gpt(output, partitions):
"""Create GPT from [(label, type UUID, payload path)] with 1 MiB alignment."""
if not partitions or len(partitions) > 128:
raise ValueError('GPT needs 1..128 partitions')
names = [name for name, _, _ in partitions]
if len(set(names)) != len(names):
raise ValueError('Duplicate GPT partition names')
identities = []
next_lba = 2048
for name, kind, payload in partitions:
path = Path(payload)
if not path.is_file() or path.is_symlink() or path.stat().st_size == 0:
raise ValueError('GPT payload must be a nonempty regular file')
encoded = name.encode('utf-16le')
if len(encoded) > 72 or '\0' in name:
raise ValueError('Invalid GPT name')
sectors = (path.stat().st_size + 511) // 512
allocated = ((sectors + 2047) // 2048) * 2048
identities.append(dict(name=name, type=str(kind), path=str(path), sha256=digest(path),
start=next_lba, size=allocated, payload_bytes=path.stat().st_size))
next_lba += allocated
total = ((next_lba + 33 + 2047) // 2048) * 2048
disk_id = uuid.uuid5(NAMESPACE, '|'.join(item['name'] + ':' + item['sha256'] for item in identities))
array = bytearray(128 * 128)
for index, item in enumerate(identities):
unique = uuid.uuid5(disk_id, item['name'])
entry = struct.pack('<16s16sQQQ72s', uuid.UUID(item['type']).bytes_le, unique.bytes_le,
item['start'], item['start'] + item['size'] - 1, 0,
item['name'].encode('utf-16le'))
array[index*128:(index+1)*128] = entry
array_crc = zlib.crc32(array)
def header(current, backup, entries):
data = bytearray(struct.pack('<8sIIIIQQQQ16sQIII', b'EFI PART', 0x10000, 92, 0, 0,
current, backup, 34, total-34, disk_id.bytes_le,
entries, 128, 128, array_crc))
struct.pack_into('<I', data, 16, zlib.crc32(data))
return data + bytes(512-len(data))
mbr = bytearray(512)
mbr[446:462] = struct.pack('<B3sB3sII', 0, b'\0\x02\0', 0xee, b'\xff'*3, 1, min(total-1, 0xffffffff))
mbr[510:512] = b'\x55\xaa'
with Path(output).open('xb') as stream:
stream.truncate(total*512)
stream.write(mbr)
stream.write(header(1, total-1, 2))
stream.write(array)
for item in identities:
stream.seek(item['start']*512)
with Path(item['path']).open('rb') as payload:
shutil.copyfileobj(payload, stream)
stream.seek((total-33)*512)
stream.write(array)
stream.write(header(total-1, 1, total-33))
# Verify the written payloads independently of the GPT header calculations.
with Path(output).open('rb') as stream:
for item in identities:
stream.seek(item['start']*512)
remaining = item['payload_bytes']
checksum = hashlib.sha256()
while remaining:
chunk = stream.read(min(1024*1024, remaining))
if not chunk: raise ValueError('Truncated GPT payload')
remaining -= len(chunk)
checksum.update(chunk)
if checksum.hexdigest() != item['sha256']:
raise ValueError('GPT payload verification failed')
return {'disk_uuid': str(disk_id), 'bytes': total*512, 'partitions': identities}
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
# Run inside a restored tree with private Cargo/Rustup caches and no network.
source "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh"
(( $# > 0 )) || die 'Usage: tools/in-frozen-build COMMAND [ARGUMENT...]'
[[ -f $FDS_ROOT/.host/frozen/lock.json ]] || die 'Use tools/frozen-inputs restore first'
(( EUID != 0 )) || die 'Run as an ordinary user'
export CARGO_HOME="$FDS_ROOT/.host/repro-cargo"
export RUSTUP_HOME="$FDS_ROOT/.host/repro-rustup"
export FDS_OFFLINE=1
export CARGO_NET_OFFLINE=true
check_void_pin
rust_bin=$(dirname -- "$(rustup which rustc)")
# Keep the workstation read-only; all writable caches and output stay in the
# restored tree. Network isolation covers xbps-src and every nested child too.
exec bwrap --unshare-user --unshare-net --ro-bind / / --bind "$FDS_ROOT" "$FDS_ROOT" \
--tmpfs /tmp --dev /dev --proc /proc \
--setenv PATH "$rust_bin:$PATH" --chdir "$FDS_ROOT" "$@"
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
source "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh"
(( $# > 0 )) || die 'Usage: tools/in-image-tools TOOL [ARGUMENT...]'
case $1 in mkfs.erofs|fsck.erofs|dump.erofs|mkfs.fat|fsck.fat|mformat|mcopy|mmd|mdir|mke2fs|e2fsck|debugfs) ;; *) die 'Unsupported image tool' ;; esac
root="$FDS_ROOT/.host/image-tools"
[[ -x $root/usr/bin/$1 ]] || die 'Run tools/prepare-image-tools first'
export MTOOLSRC=/dev/null
export MKE2FS_CONFIG="$root/etc/mke2fs.conf"
export GCONV_PATH="$root/usr/lib/gconv"
export LC_ALL=C.UTF-8
exec "$root/usr/lib/ld-linux-x86-64.so.2" --library-path "$root/usr/lib" "$root/usr/bin/$1" "${@:2}"
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# Execute ARM userspace with no host binfmt registration or physical USB access.
source "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh"
(( $# >= 2 )) || die 'Usage: tools/in-rootfs ROOT [--direct] COMMAND [ARGUMENT...]'
root=$(realpath -e -- "$1")
shift
[[ -x $root/usr/bin/bash ]] || die 'Not an assembled FDS rootfs'
check_void_pin
"$FDS_ROOT/tools/verify-elf" "$FDS_VOID/masterdir-x86_64/usr/bin/qemu-aarch64" x86_64 static >/dev/null
# The target is the actual kernel-visible root. Linux 6.7+ gives this new user
# namespace its own binfmt registry; host registration is never changed.
# The native loader and tools are read-only temporary mounts, absent from tar.
host=/tmp/fds-build-host
runner=("$host/usr/lib/ld-linux-x86-64.so.2" --library-path "$host/usr/lib"
"$host/usr/bin/bash" /tmp/fds-rootfs-namespace)
capabilities=(--cap-add CAP_SYS_ADMIN --cap-add CAP_SETPCAP)
# A single ELF needs no child-exec handler.
if [[ ${1:-} == --direct ]]; then
shift
(( $# > 0 )) || die 'Missing command after --direct'
runner=("$host/usr/bin/qemu-aarch64")
capabilities=()
fi
exec bwrap --unshare-user --uid 0 --gid 0 --cap-add CAP_SETFCAP "${capabilities[@]}" --unshare-pid \
--bind "$root" / --dev /dev --proc /proc --tmpfs /tmp --tmpfs /run \
--ro-bind "$FDS_VOID/masterdir-x86_64" "$host" \
--ro-bind "$FDS_ROOT/tools/finalize-rootfs" /tmp/fds-finalize \
--ro-bind "$FDS_ROOT/tools/rootfs-namespace" /tmp/fds-rootfs-namespace \
--chdir / --clearenv --setenv PATH /usr/bin:/bin \
--setenv HOME /root \
--setenv XBPS_ARCH aarch64 --setenv LC_ALL C "${runner[@]}" "$@"
Executable
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# Run an explicit build command inside the existing non-root Void container.
source "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh"
(( $# > 0 )) || die 'Usage: tools/in-void [--isolated-network] COMMAND [ARGUMENT...]'
fds_network_options=()
if [[ $1 == --isolated-network ]]; then
fds_network_options=(--unshare-net)
shift
fi
if [[ ${FDS_OFFLINE:-0} == 1 ]]; then
fds_network_options=(--unshare-net)
fi
(( $# > 0 )) || die 'Missing command'
check_void_pin
[[ -x $FDS_VOID/masterdir-x86_64/usr/bin/xbps-install ]] || die 'Run make bootstrap first'
(( EUID != 0 )) || die 'Run as a normal user, not root'
fds_cargo_home=${CARGO_HOME:-$HOME/.cargo}
fds_rustup_home=${RUSTUP_HOME:-$HOME/.rustup}
fds_rust_bin=$(dirname -- "$(rustup which rustc)")
[[ -d $fds_cargo_home && -d $fds_rustup_home ]] || die 'Rustup directories are missing; run make bootstrap'
exec bwrap --unshare-user --uid 0 --gid 0 "${fds_network_options[@]}" \
--bind "$FDS_VOID/masterdir-x86_64" / --dev /dev --proc /proc \
--bind "$FDS_ROOT" "$FDS_ROOT" --bind "$FDS_VOID/hostdir" /host \
--bind "$fds_cargo_home" "$fds_cargo_home" \
--ro-bind "$fds_rustup_home" "$fds_rustup_home" \
--ro-bind /etc/resolv.conf /etc/resolv.conf \
--setenv CARGO_HOME "$fds_cargo_home" --setenv RUSTUP_HOME "$fds_rustup_home" \
--setenv PATH "$fds_rust_bin:/usr/bin:/bin" --setenv XBPS_ARCH x86_64 \
--chdir "$FDS_ROOT" "$@"
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
# Shared build paths; XBPS state is project-local, Rustup/Cargo also use user caches.
set -euo pipefail
export LC_ALL=C
FDS_ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
FDS_VOID="$FDS_ROOT/vendor/void-packages"
FDS_XBPS="$FDS_ROOT/.host/xbps/usr/bin"
die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; }
need() { command -v "$1" >/dev/null || die "Missing host command: $1 (see docs/build-host.md)"; }
check_void_pin() {
local pin actual
pin=$(cat "$FDS_ROOT/VOID_PACKAGES_COMMIT")
[[ $pin =~ ^[0-9a-f]{40}$ ]] || die 'Invalid VOID_PACKAGES_COMMIT'
[[ -f $FDS_VOID/xbps-src ]] || die 'Void submodule is missing; run make bootstrap'
actual=$(git -C "$FDS_VOID" rev-parse HEAD)
[[ $actual == "$pin" ]] || die "Void checkout mismatch: expected $pin, found $actual"
git -C "$FDS_VOID" diff --quiet HEAD -- || die 'Tracked Void files were changed; keep FDS changes in packages/'
export SOURCE_DATE_EPOCH
SOURCE_DATE_EPOCH=$(git -C "$FDS_VOID" show -s --format=%ct HEAD)
}
# Keep the upstream entry point unchanged. Offline builds retain normal
# dependency resolution, but use only the restored local repositories.
xbps_src() {
if [[ ${FDS_OFFLINE:-0} == 1 ]]; then
[[ -f $FDS_ROOT/.host/frozen/lock.json ]] || die 'Offline builds require restored frozen inputs'
./xbps-src -N -n "$@"
else
./xbps-src "$@"
fi
}
fetch_rust_inputs() {
local flags=()
if [[ ${FDS_OFFLINE:-0} == 1 ]]; then
flags=(--offline)
fi
cargo fetch --locked "${flags[@]}" --target aarch64-unknown-linux-musl --target x86_64-unknown-linux-gnu
}
use_xbps() {
[[ -x $FDS_XBPS/xbps-install ]] || die 'Static XBPS tools are missing; run make bootstrap'
export PATH="$FDS_XBPS:$PATH"
# A musl-linked host tool must still build a glibc host container.
export XBPS_ARCH=x86_64
}
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env python3
"""Build an isolated EROFS boot fixture using the exported s6 graph unchanged."""
import io
from pathlib import Path
import subprocess
import sys
import tarfile
project = Path(__file__).resolve().parents[1]
if len(sys.argv) != 2:
sys.exit('Usage: tools/make-stage0-vm EMPTY_WORK_DIRECTORY')
work = Path(sys.argv[1]).resolve(strict=True)
if not work.is_dir() or any(work.iterdir()):
sys.exit('ERROR: expected an existing empty work directory')
stage2 = 'etc/s6-linux-init/current/scripts/rc.init'
wrapper = (f'#!/bin/bash\nset -euo pipefail\n/{stage2}.fds-base "$@"\n'
'export FDS_TEST_ROOT_TYPE=erofs\n'
'/usr/libexec/fds/m4-guest\n'
'exec /usr/libexec/fds/m2-guest\n').encode()
found = False
with tarfile.open(project/'out/rootfs-cli.tar') as source, tarfile.open(work/'test-rootfs.tar', 'w', format=tarfile.PAX_FORMAT) as output:
for member in source:
if member.name == stage2:
member.name += '.fds-base'
found = True
output.addfile(member, source.extractfile(member) if member.isfile() else None)
if not found:
sys.exit('ERROR: native stage-2 script missing')
hook = tarfile.TarInfo(stage2)
hook.mode = 0o755
hook.size = len(wrapper)
output.addfile(hook, io.BytesIO(wrapper))
def root_owned(info):
info.uid = info.gid = 0
info.uname = info.gname = ''
return info
for name in ('m2-guest', 'm4-guest'):
output.add(project/'tests/integration'/name, arcname='usr/libexec/fds/'+name, filter=root_owned)
(work/'system').mkdir()
subprocess.run([str(project/'image/build-system-cartridge'), '--rootfs', str(work/'test-rootfs.tar'),
'--output-directory', str(work/'system')], check=True)
print(f'PASS: stage0 VM fixture created at {work}')
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env python3
"""Append a test-only stage-2 check while preserving the exported service database."""
import io
import pathlib
import os
import subprocess
import sys
import tarfile
project = pathlib.Path(__file__).resolve().parent.parent
if len(sys.argv) != 2:
sys.exit('Usage: tools/make-vm-image EMPTY_WORK_DIRECTORY')
work = pathlib.Path(sys.argv[1]).resolve()
if not work.is_dir() or any(work.iterdir()):
sys.exit('ERROR: VM image creation requires an existing empty work directory')
rootfs = (project / 'out/rootfs-cli.tar').resolve()
stage2 = 'etc/s6-linux-init/current/scripts/rc.init'
wrapper = (f'#!/bin/bash\nset -euo pipefail\n/{stage2}.fds-base "$@"\n'
'exec /usr/libexec/fds/m2-guest\n').encode()
found_stage2 = False
with tarfile.open(rootfs, 'r') as original, tarfile.open(work / 'test-rootfs.tar', 'w', format=tarfile.PAX_FORMAT) as output:
for member in original:
if member.name == stage2:
# Preserve the actual stage-2 script and run it to completion first.
member.name += '.fds-base'
found_stage2 = True
output.addfile(member, original.extractfile(member) if member.isfile() else None)
if not found_stage2:
sys.exit('ERROR: rootfs has no native stage-2 script; rebuild it first')
def root_owned(info):
info.uid = info.gid = 0
info.uname = info.gname = ''
return info
hook = tarfile.TarInfo(stage2)
hook.mode = 0o755
hook.size = len(wrapper)
output.addfile(hook, io.BytesIO(wrapper))
output.add(project / 'tests/integration/m2-guest', arcname='usr/libexec/fds/m2-guest', filter=root_owned)
subprocess.run(['mkfs.ext4', '-q', '-F', '-b', '4096', '-m', '0', '-L', 'FDS_M2_TEST',
'-E', 'root_owner=0:0,lazy_itable_init=0,lazy_journal_init=0',
'-d', str(work / 'test-rootfs.tar'), str(work / 'rootfs.ext4'), '768M'],
env=dict(os.environ, LC_ALL='C.UTF-8'), check=True)
(work / 'command-line').write_text('console=ttyAMA0 root=/dev/sda rootfstype=ext4 ro rootwait init=/sbin/init panic=-1\n')
print('PASS: isolated VM image created from the exported rootfs')
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# Independent host-tool prefix, so image construction does not mutate xbps-src.
source "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh"
[[ $# == 0 ]] || die 'Usage: tools/prepare-image-tools'
cd "$FDS_ROOT"
use_xbps
check_void_pin
root="$FDS_ROOT/.host/image-tools"
cache="$FDS_ROOT/out/cache/image-tools"
mkdir -p "$root/var/db/xbps/keys" "$root/config" "$cache" out/manifests
exec 9>out/.image-tools.lock
flock 9
cp "$FDS_VOID/common/repo-keys/"*.plist "$root/var/db/xbps/keys/"
if [[ ! -x $root/usr/bin/mkfs.erofs || ! -x $root/usr/bin/mformat || ! -x $root/usr/bin/mkfs.fat || ! -x $root/usr/bin/mke2fs ]]; then
[[ ${FDS_OFFLINE:-0} != 1 ]] || die 'Missing frozen image tools; restore the complete input snapshot'
bwrap --unshare-user --uid 0 --gid 0 --ro-bind / / \
--bind "$root" "$root" --bind "$cache" "$cache" --dev /dev --proc /proc \
env XBPS_ARCH=x86_64 XBPS_TARGET_ARCH=x86_64 \
"$FDS_XBPS/xbps-install" -r "$root" -C "$root/config" -c "$cache" \
-i -R https://repo-default.voidlinux.org/current -SyU erofs-utils dosfstools mtools e2fsprogs
fi
XBPS_ARCH=x86_64 xbps-query -r "$root" -l >out/manifests/image-tool-packages.txt
find "$cache" -maxdepth 1 -name '*.xbps' -print0 | sort -z | xargs -0 sha256sum >out/manifests/image-tool-inputs.sha256
tools/in-image-tools mkfs.erofs --version
printf 'PASS: project-local EROFS and FAT image tools prepared\n'
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
source "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh"
[[ $# == 0 ]] || die 'Usage: tools/prepare-pi-firmware'
cd "$FDS_ROOT"
use_xbps
check_void_pin
source config/pi-firmware.conf
mkdir -p .host/boot-input/{var/db/xbps/keys,config} out/cache/boot
cp "$FDS_VOID/common/repo-keys/"*.plist .host/boot-input/var/db/xbps/keys/
package="out/cache/boot/rpi-firmware-$PI_FIRMWARE_VERSION.aarch64.xbps"
if [[ ! -f $package ]]; then
[[ ${FDS_OFFLINE:-0} != 1 ]] || die "Missing offline Pi firmware: $package"
XBPS_ARCH=aarch64 xbps-install -r "$FDS_ROOT/.host/boot-input" \
-C "$FDS_ROOT/.host/boot-input/config" -c "$FDS_ROOT/out/cache/boot" \
-i -R "$PI_FIRMWARE_REPOSITORY" -SyD "rpi-firmware-$PI_FIRMWARE_VERSION"
fi
printf '%s %s\n' "$PI_FIRMWARE_SHA256" "$package" | sha256sum -c -
printf 'PASS: pinned Pi firmware input verified\n'
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
source "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh"
[[ $# == 0 ]] || die 'Usage: tools/prepare-vm'
use_xbps
check_void_pin
need mkfs.ext4
cd "$FDS_ROOT"
source config/vm-test.conf
mkdir -p .host/vm-input/{var/db/xbps/keys,config} out/cache/vm out/vm-kernel
cp "$FDS_VOID/common/repo-keys/"*.plist .host/vm-input/var/db/xbps/keys/
package="out/cache/vm/$VM_KERNEL_PACKAGE-$VM_KERNEL_VERSION.aarch64.xbps"
if [[ ! -f $package ]]; then
[[ ${FDS_OFFLINE:-0} != 1 ]] || die "Missing offline VM kernel: $package"
XBPS_ARCH=aarch64 xbps-install -r "$FDS_ROOT/.host/vm-input" \
-C "$FDS_ROOT/.host/vm-input/config" -c "$FDS_ROOT/out/cache/vm" \
-i -R "$VM_REPOSITORY" -SyD "$VM_KERNEL_PACKAGE-$VM_KERNEL_VERSION"
fi
printf '%s %s\n' "$VM_KERNEL_SHA256" "$package" | sha256sum -c -
tar -xf "$package" -C out/vm-kernel ./boot
[[ -s out/vm-kernel/boot/vmlinux-$VM_KERNEL_VERSION ]] || die 'Missing VM kernel image'
for symbol in CONFIG_SATA_AHCI CONFIG_EXT4_FS CONFIG_SERIAL_AMBA_PL011_CONSOLE CONFIG_PCI_HOST_GENERIC CONFIG_DEVTMPFS_MOUNT; do
grep -qx "$symbol=y" "out/vm-kernel/boot/config-$VM_KERNEL_VERSION" || die "VM kernel needs built-in $symbol"
done
printf 'PASS: signed, pinned generic ARM test kernel prepared\n'
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
source "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh"
[[ $# == 0 ]] || die 'Usage: tools/prepare-void'
check_void_pin
# xbps-src reads this ignored local configuration instead of user-global settings.
if [[ -f $FDS_VOID/etc/conf ]] && ! cmp -s "$FDS_ROOT/config/xbps-src.conf" "$FDS_VOID/etc/conf"; then
die 'vendor/void-packages/etc/conf differs from config/xbps-src.conf; reconcile it explicitly'
fi
cp -- "$FDS_ROOT/config/xbps-src.conf" "$FDS_VOID/etc/conf"
# Only directories containing a template are active overlays.
# Copies keep the source available inside xbps-src's /void-packages bind mount.
for template in "$FDS_ROOT"/packages/*/template; do
[[ -f $template ]] || continue
package_dir=${template%/template}
package=${package_dir##*/}
[[ $package == fds-* ]] || die "Overlay name must start with fds-: $package"
tracked=$(git -C "$FDS_VOID" ls-tree HEAD "srcpkgs/$package")
[[ -z $tracked ]] || die "Overlay would replace an upstream package: $package"
dest="$FDS_VOID/srcpkgs/$package"
if [[ -e $dest ]]; then
diff -qr "$package_dir" "$dest" >/dev/null || die "Stale overlay $dest; remove that generated copy and retry"
else
cp -a -- "$package_dir" "$dest"
fi
done
+79
View File
@@ -0,0 +1,79 @@
# FDS/OS 0.1.0 local release
This directory contains versioned images, exact build inputs and a signed
manifest. Physical Pi, NVMe, monitor and power validation is **deferred**. This
release has not been installed or published automatically.
## Verify the files first
Use an already trusted build of `fds-release` and a public key obtained through
an independently trusted channel. The bundled `signer.pub` alone does not
establish the publisher's identity. From the parent of this release directory:
```sh
/path/to/trusted/fds-release verify fds-os-0.1.0 --key /path/to/trusted/signer.pub
```
Substitute the actual directory name. Success reports `VERIFIED FDS/OS 0.1.0`
and checks every file listed in `manifest.json`. See `RELEASE-VERIFICATION.md`
for the signature format and how to build the verifier from trusted source.
## Choose the images
| File | Destination/purpose |
| --- | --- |
| `fds-internal-0.1.0.img` | Complete internal NVMe disk: BOOT, independent RECOVERY and machine settings |
| `fds-system-cli-0.1.0.img` | Removable SYSTEM cartridge with the ordinary FDS console and base Dasung controller |
| `fds-system-development-0.1.0.img` | Alternative SYSTEM with native compilers, debugging tools and display diagnostics |
| `fds-boot-0.1.0.img`, `fds-recovery-0.1.0.img` | Component partition payloads; normally use the complete internal disk above |
| `fds-initramfs-0.1.0.cpio*`, kernel and device-tree files | Boot components already included in the internal disk |
| `eeprom-production-*` | Reviewed EEPROM preparation/rollback files; the defaults are not a backup of your Pi |
| `fds-*.xbps` | FDS base packages used to build the images; the running SYSTEM remains immutable |
Use one internal disk and one of the two SYSTEM alternatives. Installation
erases the selected target disks. Follow `INSTALLATION.md` for target identity,
readback and first-boot steps, and `EEPROM.md` for the separately reviewed EEPROM
procedure. The initial bay map is empty until actual wiring is calibrated.
## Read the complete guides
After verifying the release, extract the source archive into a new directory
inside this release directory:
```sh
mkdir source
tar --extract --zstd --same-permissions --no-same-owner \
--file fds-source-0.1.0.tar.zst --directory source
```
Start with [the project overview](source/README.md),
[installation](source/docs/internal-storage.md), and
[everyday cartridge use](source/docs/cartridges.md). The copied standalone guides
retain their source-relative cross-references; the copies under `source/docs/`
provide the complete linked documentation.
## Rebuild from the preserved inputs
`reproducibility.json` records the comparison of the two independent builds.
`build-inputs.json` lists the exact snapshot contents, and `packages-*.json`
records each profile's package versions and hashes. The complete input archive
includes the compiler/toolchain, package files and pinned Void source bundle;
the smaller source archive alone does not provide those dependencies.
On the documented Arch build host, from this release directory:
```sh
mkdir inputs
tar --extract --zstd --same-permissions --no-same-owner \
--file fds-build-inputs-0.1.0.tar.zst --directory inputs
python3 inputs/project/tools/frozen-inputs verify "$PWD/inputs"
python3 inputs/project/tools/frozen-inputs restore "$PWD/inputs" "$PWD/rebuild"
rebuild/tools/in-frozen-build make all >rebuild.log 2>&1
```
Use new directories and paths without whitespace. The restored build has its
recorded modes preserved by `--same-permissions`; `--no-same-owner` keeps the
files owned by the ordinary build user. Its compiler and package environment use
their own caches and have no network access. Host prerequisites and additional comparison
instructions are in [Offline rebuilds](source/docs/reproducible-builds.md).
The private signing key is not included in this release or its input archive.
+66
View File
@@ -0,0 +1,66 @@
"""The complete versioned image/package set used by comparison and release assembly."""
import hashlib
import json
from pathlib import Path
def digest(path):
with Path(path).open('rb') as stream:
return hashlib.file_digest(stream, 'sha256').hexdigest()
def artifacts(project):
project = Path(project).resolve(strict=True)
out = project / 'out'
result = {}
for name in ('fds-system-cli.img', 'fds-system-development.img', 'fds-recovery.img',
'fds-boot.img', 'fds-internal.img'):
result[name] = (out / name).resolve(strict=True)
for profile in ('cli', 'development', 'recovery'):
result[f'rootfs-{profile}.tar'] = (out / f'rootfs-{profile}.tar').resolve(strict=True)
for suffix in ('', '.gz', '.lz4', '.zst'):
name = 'initramfs.cpio' + suffix
result[name] = (out / 'initramfs' / name).resolve(strict=True)
for name in ('kernel_2712.img', 'bcm2712-rpi-5-b.dtb'):
result[name] = (out / 'kernel/boot' / name).resolve(strict=True)
for name in ('configured.bin', 'rollback.bin', 'configured.conf', 'original.conf'):
result['eeprom-production-' + name] = (out / 'eeprom-production-latest' / name).resolve(strict=True)
packages = sorted((out / 'packages').glob('fds-*.aarch64.xbps'))
required = {'fds-base', 'fds-base-files', 'fds-init', 'fds-cli', 'fds-cartridged',
'fds-dasungd', 'fds-kernel', 'fds-dhcpcd', 'fds-eink'}
names = {p.name.rsplit('-', 1)[0] for p in packages}
if names != required or len(packages) != len(required):
raise ValueError('The release requires exactly one version of every FDS base package')
for path in packages:
result[path.name] = path.resolve(strict=True)
for name, path in result.items():
if not path.is_file() or path.stat().st_size == 0:
raise ValueError(f'Missing or empty release artifact: {name}')
return result
def read_lock(project):
path = Path(project) / '.host/frozen/lock.json'
lock = json.loads(path.read_text())
if lock.get('format') != 1 or lock.get('version') != '0.1.0':
raise ValueError('Build does not identify a supported frozen input snapshot')
return lock, digest(path)
def compare(first, second):
left_lock, left_hash = read_lock(first)
right_lock, right_hash = read_lock(second)
if left_hash != right_hash:
raise ValueError('Builds did not use the same frozen input lock')
left, right = artifacts(first), artifacts(second)
if left.keys() != right.keys():
raise ValueError('Builds produced different artifact names')
records = []
for name in sorted(left):
one, two = digest(left[name]), digest(right[name])
records.append({'name': name, 'first_sha256': one, 'second_sha256': two,
'first_bytes': left[name].stat().st_size, 'second_bytes': right[name].stat().st_size,
'identical': one == two and left[name].stat().st_size == right[name].stat().st_size})
return {'format': 1, 'status': 'passed' if all(x['identical'] for x in records) else 'failed',
'version': left_lock['version'], 'source_sha256': left_lock['source_sha256'],
'input_lock_sha256': left_hash, 'hardware_validation': 'deferred', 'artifacts': records}
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env python3
"""Archive rootless staging with package ownership and portable Linux capabilities."""
import json
import os
import pathlib
import shutil
import stat
import struct
import subprocess
import sys
import tarfile
from rootfs_lib import digest, package_db, rooted
root, work, *caches = (pathlib.Path(arg).resolve() for arg in sys.argv[1:])
db = package_db(root)
inputs = work / "packages"
inputs.mkdir()
owners = {}
records = []
scripts = []
for name, props in sorted(db.items()):
filename = f'{props["pkgver"]}.{props["architecture"]}.xbps'
candidates = [cache / filename for cache in caches if (cache / filename).is_file()]
if not candidates:
sys.exit(f"ERROR: selected package input is missing: {filename}")
source = candidates[0]
checksum = digest(source)
expected = props.get("filename-sha256")
if expected and expected != checksum:
sys.exit(f"ERROR: package input changed after installation: {filename}")
shutil.copyfile(source, inputs / filename)
if source.with_suffix(".xbps.sig2").is_file():
shutil.copyfile(source.with_suffix(".xbps.sig2"), inputs / (filename + ".sig2"))
records.append({"name": name, "version": props["pkgver"], "architecture": props["architecture"], "sha256": checksum, "filename": filename})
# Python 3.14 understands XBPS's zstd archives without third-party modules.
with tarfile.open(source, "r:*") as archive:
for member in archive:
path = member.name.removeprefix("./")
if path in ("INSTALL", "REMOVE"):
scripts.append(f"### {filename}: {path}\n" + archive.extractfile(member).read().decode())
if path in ("INSTALL", "REMOVE", "props.plist", "files.plist") or not path:
continue
if path.startswith("/") or ".." in pathlib.PurePosixPath(path).parts:
sys.exit(f"ERROR: unsafe package path: {path}")
# Resolve parent aliases (/bin -> /usr/bin), not a symlink leaf.
parent = rooted(root, str(pathlib.PurePosixPath(path).parent))
canonical = str((parent / pathlib.PurePosixPath(path).name).relative_to(root))
value = (member.uid, member.gid)
if canonical in owners and owners[canonical] != value:
sys.exit(f"ERROR: conflicting package ownership: {canonical}")
owners[canonical] = value
# util-linux's INSTALL assigns tty ownership after extraction. The single-user
# namespace cannot represent every target group; restore this tested policy.
for name in ("usr/bin/wall", "usr/bin/write"):
if (root / name).exists():
owners[name] = (0, 5)
# libutempter's INSTALL assigns utmp after extraction. A single-UID build
# namespace cannot apply that group; exporting SGID root would be incorrect.
if (root / "usr/lib/utempter/utempter").exists():
groups = {line.split(":")[0]: int(line.split(":")[2]) for line in (root / "etc/group").read_text().splitlines() if line and not line.startswith("#")}
owners["usr/lib/utempter/utempter"] = (0, groups["utmp"])
# XBPS restricts its setuid chroot helper to the dedicated xbuilder group.
owners["usr/bin/xbps-uchroot"] = (0, 101)
epoch = int(subprocess.check_output(["git", "-C", "vendor/void-packages", "show", "-s", "--format=%ct", "HEAD"]))
metadata = {}
def normalize(info):
info.uid, info.gid = owners.get(info.name, (0, 0))
info.uname = info.gname = ""
info.mtime = epoch
info.mode = stat.S_IMODE(info.mode)
init_fifo = info.isfifo() and info.name.startswith("etc/s6-linux-init/current/run-image/")
if not (info.isfile() or info.isdir() or info.issym() or info.islnk() or init_fifo):
raise ValueError(f"Unexpected special file in image: {info.name}")
path = root / info.name
if not info.issym() and "security.capability" in os.listxattr(path):
capability = os.getxattr(path, "security.capability")
revision = struct.unpack_from("<I", capability)[0]
# User namespaces store v3 capabilities with the builder's host UID.
# An installed root filesystem needs the equivalent root-owned v2 form.
if revision >> 24 == 3:
capability = struct.pack("<I", (revision & 0xFFFFFF) | 0x02000000) + capability[4:20]
if len(capability) != 20 or struct.unpack_from("<I", capability)[0] >> 24 != 2:
raise ValueError(f"Unsupported capability encoding: {info.name}")
info.pax_headers["SCHILY.xattr.security.capability"] = capability.decode("utf-8", "surrogateescape")
metadata[info.name] = {"uid": info.uid, "gid": info.gid, "mode": oct(info.mode), "capability": info.pax_headers.get("SCHILY.xattr.security.capability", "").encode("utf-8", "surrogateescape").hex()}
return info
output = work / "rootfs-aarch64.tar"
with tarfile.open(output, "w", format=tarfile.PAX_FORMAT) as archive:
# EROFS tar import otherwise synthesizes / using the host UID and 0777.
# Record the filesystem root explicitly, not only its children.
root_info = tarfile.TarInfo('.')
root_info.type = tarfile.DIRTYPE
root_info.mode = 0o755
archive.addfile(normalize(root_info))
for path in sorted(root.iterdir()):
archive.add(path, arcname=path.name, filter=normalize)
(work / "packages.json").write_text(json.dumps(records, indent=2) + "\n")
(work / "archive-metadata.json").write_text(json.dumps(metadata, indent=2) + "\n")
(work / "package-scripts.txt").write_text("\n".join(scripts))
(work / "packages.sha256").write_text("".join(f'{item["sha256"]} packages/{item["filename"]}\n' for item in records))
(work / "rootfs.sha256").write_text(f"{digest(output)} rootfs-aarch64.tar\n")
print(f"PASS: archive with restored ownership and capabilities ({output.stat().st_size} bytes)")
+101
View File
@@ -0,0 +1,101 @@
#!/usr/bin/env python3
"""Fail closed on wrong ABI, forbidden init stacks, or unfinished package setup."""
import json
import os
import pathlib
import stat
import struct
import sys
from rootfs_lib import package_db, rooted
def audit(root, configured=True):
db = package_db(root)
required = "fds-base fds-base-files fds-init fds-dasungd fds-cli fds-cartridged fds-kernel fds-eink fds-dhcpcd xorg-server WindowMaker terminus-font xf86-input-libinput glibc glibc-locales coreutils binutils xbps bash s6 s6-rc s6-linux-init".split()
for name in required:
assert name in db, f"Missing required package: {name}"
forbidden = ("busybox", "runit", "systemd", "musl", "base-system", "base-files")
for name, props in db.items():
assert not any(name == prefix or name.startswith(prefix + "-") for prefix in forbidden), f"Forbidden package: {name}"
assert props["architecture"] in ("aarch64", "noarch"), f"Wrong package ABI: {name}"
if configured:
assert props["state"] == "installed", f"Unconfigured package: {name}"
layout = json.loads((root / "usr/share/fds/layout.json").read_text())
for name, target in layout["symlinks"].items():
assert (root / name).is_symlink() and str((root / name).readlink()) == target, f"Wrong layout link: {name}"
for name, mode in layout["directories"].items():
path = root / name
assert path.is_dir() and not path.is_symlink(), f"Missing directory: {name}"
assert stat.S_IMODE(path.stat().st_mode) == int(mode, 8), f"Wrong directory mode: {name}"
assert 'ID=fds\n' in (root / "usr/lib/os-release").read_text(), "Wrong OS identity"
release = (root / "usr/share/fds/kernel-release").read_text().strip()
assert (root / "usr/lib/modules" / release / "modules.dep").is_file(), "Missing matching kernel module index"
assert (root / "etc/shadow").stat().st_mode & 0o777 == 0o600, "Shadow file is exposed"
assert (root / "etc/shadow").read_text().startswith("root:!:"), "Default root account must be locked"
for name in ("etc/sv", "etc/runit", "etc/systemd/system", "usr/bin/busybox", "usr/bin/runit", "usr/bin/runsv", "usr/bin/systemctl", "usr/lib/systemd/systemd"):
path = root / name
assert not path.exists() and not path.is_symlink(), f"Unexpected runtime: {name}"
profile = (root / "usr/share/fds/image-profile").read_text().strip()
assert profile in ("cli", "development", "recovery"), "Unknown image profile"
for optional in ("desktop", "xserver", "desktop-session", "network", "dhcp"):
assert not (root / f"etc/s6-rc/source/boot/contents.d/{optional}").exists(), "Optional service in boot bundle"
if profile == "development":
assert "xorg-server-xvfb" in db and "xdotool" in db, "Missing development display diagnostics"
for package in "gcc glibc-devel make cmake meson ninja pkg-config rust cargo git gdb strace vim".split():
assert package in db, f"Missing development tool: {package}"
assert (root / "etc/resolv.conf").is_symlink() and str((root / "etc/resolv.conf").readlink()) == "/run/fds/resolv.conf", "DNS must be volatile"
elf_count = 0
for directory, _, files in os.walk(root, followlinks=False):
for name in files:
path = pathlib.Path(directory) / name
if path.is_symlink():
continue
if not path.is_file():
assert stat.S_ISFIFO(path.stat().st_mode) and str(path.relative_to(root)).startswith("etc/s6-linux-init/current/run-image/"), f"Unexpected special file: {path}"
continue
with path.open("rb") as stream:
header = stream.read(64)
if header[:4] != b"\x7fELF":
continue
elf_count += 1
assert header[4:6] == b"\x02\x01" and struct.unpack_from("<H", header, 18)[0] == 183, f"Non-aarch64 ELF: {path.relative_to(root)}"
offset = struct.unpack_from("<Q", header, 32)[0]
size, count = struct.unpack_from("<HH", header, 54)
for i in range(count):
stream.seek(offset + i * size)
ph = stream.read(size)
if struct.unpack_from("<I", ph)[0] == 3:
pos = struct.unpack_from("<Q", ph, 8)[0]
length = struct.unpack_from("<Q", ph, 32)[0]
stream.seek(pos)
interpreter = stream.read(length).rstrip(b"\0").decode()
assert interpreter in ("/lib/ld-linux-aarch64.so.1", "/lib64/ld-linux-aarch64.so.1", "/usr/lib/ld-linux-aarch64.so.1", "/usr/lib64/ld-linux-aarch64.so.1"), f"Wrong ELF interpreter: {path.relative_to(root)}: {interpreter}"
assert rooted(root, interpreter).is_file(), "Missing glibc loader"
assert elf_count > 20, "Rootfs is unexpectedly empty"
assert (root / "etc/s6-rc/source/boot/contents.d/dasungd").is_file(), "Dasung missing from base boot bundle"
if configured:
epoch = (root / "usr/share/fds/build-epoch").read_text().strip()
assert epoch.isdecimal() and 0 < int(epoch) <= 4102444800, "Invalid image clock floor"
assert not (root / "var/cache/ldconfig/aux-cache").exists(), "Build-specific linker auxiliary cache leaked into image"
assert not list((root / "var/db/xbps").glob('*/aarch64-repodata')), "Build repository index leaked into image"
for name in ("etc/ld.so.cache", "etc/udev/hwdb.bin", "etc/ssl/certs/ca-certificates.crt", "usr/lib/locale/locale-archive", "etc/s6-rc/compiled/db"):
assert (root / name).stat().st_size > 0, f"Missing build-time cache: {name}"
assert any((root / "var/cache/fontconfig").glob("*.cache-*")), "Missing build-time font cache"
assert not any((root / "tmp").iterdir()), "Build files left in /tmp"
assert not any((root / "run").iterdir()), "Runtime files leaked into image"
init = rooted(root, "/sbin/init").read_text()
assert init.startswith("#!/usr/bin/execlineb ") and "s6-linux-init " in init, "Init is not the native s6 launcher"
assert '"/etc/s6-linux-init/current"' in init, "Wrong init configuration path"
for service in ("runtime-fs", "hostname", "getty", "udev-trigger", "cartridged"):
assert (root / f"etc/s6-rc/source/boot/contents.d/{service}").is_file(), f"Missing boot service: {service}"
assert not (root / "etc/s6-rc/source/boot/contents.d/test-echo").exists(), "Test service must be opt-in"
assert not (root / "usr/libexec/fds/m2-guest").exists(), "VM checker leaked into base rootfs"
print(f"PASS: rootfs audit ({len(db)} packages, {elf_count} aarch64 ELF files, configured={configured})")
if __name__ == "__main__":
try:
audit(pathlib.Path(sys.argv[1]).resolve(), "--unconfigured" not in sys.argv[2:])
except (AssertionError, OSError, ValueError, KeyError) as error:
sys.exit(f"ERROR: {error}")
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env python3
"""Apply the FDS-owned layout to a new, empty image staging directory."""
import json
import pathlib
import sys
root = pathlib.Path(sys.argv[1])
layout = json.loads(pathlib.Path(sys.argv[2]).read_text())
if any(root.iterdir()):
sys.exit("ERROR: layout must be applied to an empty root")
for path, mode in layout["directories"].items():
target = root / path
target.mkdir(parents=True, exist_ok=True)
target.chmod(int(mode, 8))
for path, target in layout["symlinks"].items():
(root / path).symlink_to(target)
+17
View File
@@ -0,0 +1,17 @@
#!/bin/bash
# Internal entry point, mounted inside an already-created bubblewrap user namespace.
set -euo pipefail
host=/tmp/fds-build-host
native() {
"$host/usr/lib/ld-linux-x86-64.so.2" --library-path "$host/usr/lib" "$host/usr/bin/$1" "${@:2}"
}
native mkdir /tmp/fds-binfmt
native mount -t binfmt_misc binfmt_misc /tmp/fds-binfmt
# Match ELF64 little-endian AArch64 EXEC/DYN. This registry belongs only to the
# new user namespace. F opens the static interpreter while it is mounted here.
printf '%s\n' ':fds-aarch64:M::\x7f\x45\x4c\x46\x02\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\xb7\x00:\xff\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\xfe\xff\xff\xff:/tmp/fds-build-host/usr/bin/qemu-aarch64:F' >/tmp/fds-binfmt/register
export PATH=/usr/bin:/bin HOME=/root LANG=C LC_ALL=C XBPS_ARCH=aarch64
# Package hooks need no mount privilege after the private handler is installed.
exec "$host/usr/lib/ld-linux-x86-64.so.2" --library-path "$host/usr/lib" \
"$host/usr/bin/setpriv" --bounding-set=-sys_admin,-setpcap \
--inh-caps=-sys_admin,-setpcap --ambient-caps=-sys_admin,-setpcap "$@"
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
source "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh"
[[ $# == 1 ]] || die 'Usage: tools/rootfs-runtime-check ROOT'
root=$(realpath -e -- "$1")
[[ -s $root/usr/share/licenses/fds-cli/RUST-NOTICES.txt ]] || die 'Missing Rust dependency license notices'
"$FDS_ROOT/tools/verify-elf" "$root/usr/bin/ls" aarch64 glibc
"$FDS_ROOT/tools/verify-elf" "$root/usr/bin/xbps-query" aarch64 glibc
"$FDS_ROOT/tools/verify-elf" "$root/usr/bin/dasungd" aarch64 static
"$FDS_ROOT/tools/verify-elf" "$root/usr/bin/fds" aarch64 static
"$FDS_ROOT/tools/verify-elf" "$root/usr/bin/fds-boottrace" aarch64 static
"$FDS_ROOT/tools/verify-elf" "$root/usr/bin/fds-cartridged" aarch64 static
"$FDS_ROOT/tools/verify-elf" "$root/usr/bin/fds-profile" aarch64 static
for binary in fds-burn fds-inspect fds-eject fds-power fds-release; do
"$FDS_ROOT/tools/verify-elf" "$root/usr/bin/$binary" aarch64 static
done
"$FDS_ROOT/tools/in-rootfs" "$root" --direct /usr/bin/xbps-pkgdb -a
log=$(mktemp "$FDS_ROOT/out/rootfs-runtime.XXXXXX")
trap 'rm -f "$log"' EXIT
"$FDS_ROOT/tools/in-rootfs" "$root" /bin/bash -euc '
getconf GNU_LIBC_VERSION | grep -Eq "^glibc [0-9]"
ls --version | grep -q "GNU coreutils"
readelf --version | grep -q "GNU readelf"
printf "ARM shell pipeline works\n" | gzip | gzip -d | grep -qx "ARM shell pipeline works"
LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 locale charmap | grep -qx UTF-8
xbps-query -p architecture fds-base | grep -qx aarch64
dasungd --config /etc/dasungd.toml check
fds --json info | grep -q aarch64
fds-cartridged --help | grep -q SYSFS_ROOT
fds-release --version | grep -qx "fds-release 0.1.0"
s6-rc-db -c /etc/s6-rc/compiled list all | grep -qx cartridged
s6-rc-db -c /etc/s6-rc/compiled list all | grep -qx dasungd
s6-rc-db -c /etc/s6-rc/compiled list all | grep -qx test-echo
printf "PASS: ARM glibc, GNU tools, shell pipelines, locale, XBPS and Dasung config under QEMU\n"
' | tee "$log"
# PRoot has returned zero after an internal abort on this host. Require the
# child's final acknowledgement as well as a successful process exit status.
grep -qx 'PASS: ARM glibc, GNU tools, shell pipelines, locale, XBPS and Dasung config under QEMU' "$log" || \
die 'ARM runtime checker did not complete; inspect emulation output'
+42
View File
@@ -0,0 +1,42 @@
"""Shared host-side inspection of FDS root filesystems (standard library only)."""
import hashlib
import pathlib
import plistlib
def package_db(root):
with (root / "var/db/xbps/pkgdb-0.38.plist").open("rb") as stream:
db = plistlib.load(stream)
return {name: props for name, props in db.items() if not name.startswith("_")}
def rooted(root, name):
"""Resolve guest symlinks inside root, including absolute links, never on host."""
todo = list(pathlib.PurePosixPath(name).parts)
parts = []
links = 0
while todo:
part = todo.pop(0)
if part in ("/", "."):
continue
if part == "..":
if parts:
parts.pop()
continue
candidate = root.joinpath(*parts, part)
if candidate.is_symlink():
links += 1
if links > 40:
raise ValueError(f"Symlink loop: {name}")
target = candidate.readlink()
if target.is_absolute():
parts = []
todo = list(target.parts) + todo
else:
parts.append(part)
return root.joinpath(*parts)
def digest(path):
with path.open("rb") as stream:
return hashlib.file_digest(stream, "sha256").hexdigest()
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
source "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh"
[[ $# == 0 ]] || die 'Usage: tools/run-system-vm'
cd "$FDS_ROOT"
for file in out/kernel/boot/kernel_2712.img out/fds-initramfs.img out/fds-system-cli.img; do
[[ -s $file ]] || die "Build required input first: $file (see docs/boot.md)"
done
printf 'FDS user console VM. Press Ctrl-a, then x to close it. Temporary home is discarded.\n'
exec tools/in-void qemu-system-aarch64 \
-machine virt -cpu max -accel tcg -m 1024 -smp 2 \
-nodefaults -display none -nic none -no-reboot \
-chardev stdio,id=console,mux=on,signal=off -serial chardev:console \
-object monitor-hmp,id=monitor,chardev=console,readline=on \
-kernel "$FDS_ROOT/out/kernel/boot/kernel_2712.img" \
-initrd "$FDS_ROOT/out/fds-initramfs.img" \
-append 'console=ttyAMA0 rdinit=/init ro quiet loglevel=3' \
-drive "file=$FDS_ROOT/out/fds-system-cli.img,if=none,id=system,format=raw,readonly=on" \
-device virtio-blk-pci,drive=system
Executable
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
# The virtual machine has no network or host device passthrough.
source "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh"
[[ $# == 1 || ( $# == 2 && $2 == --shell ) ]] || die 'Usage: tools/run-vm TEST_DIRECTORY [--shell]'
work=$(realpath -e -- "$1")
[[ -f $work/rootfs.ext4 && -f $work/command-line ]] || die 'Run make init-test first'
source "$FDS_ROOT/config/vm-test.conf"
command_line=$(cat "$work/command-line")
[[ ${2:-} != --shell ]] || command_line+=' fds.vm_shell=1'
exec "$FDS_ROOT/tools/in-void" env TMPDIR="$work" qemu-system-aarch64 \
-machine virt -cpu cortex-a72 -accel tcg -m 1024 -smp 2 \
-nodefaults -display none -monitor none -serial stdio -nic none -no-reboot \
-kernel "$FDS_ROOT/out/vm-kernel/boot/vmlinux-$VM_KERNEL_VERSION" \
-append "$command_line" \
-device ich9-ahci,id=ahci \
-drive "file=$work/rootfs.ext4,if=none,id=system,format=raw,snapshot=on" \
-device ide-hd,drive=system,bus=ahci.0
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env python3
"""Collect license notices for the locked ARM Rust workspace and vendored C code."""
import argparse
import hashlib
import json
from pathlib import Path
import subprocess
project = Path(__file__).resolve().parents[1]
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('output', type=Path)
args = parser.parse_args()
metadata = json.loads(subprocess.check_output([
'cargo', 'metadata', '--locked', '--offline', '--format-version', '1',
'--filter-platform', 'aarch64-unknown-linux-musl',
], cwd=project))
resolved = {node['id'] for node in metadata['resolve']['nodes']}
notices = ['FDS/OS Rust workspace third-party notices\n',
'Includes build dependencies and vendored native-code license files.\n',
'This inventory does not imply every dependency is linked into every executable.\n']
count = 0
for package in sorted(metadata['packages'], key=lambda p: (p['name'], p['version'])):
if not package['source'] or package['id'] not in resolved:
continue
root = Path(package['manifest_path']).parent
files = {path for path in root.rglob('*') if path.is_file()
and path.name.upper().startswith(('LICENSE', 'COPYING', 'NOTICE'))}
if package['license_file']:
files.add(root / package['license_file'])
if not files:
parser.error(f'No license notice found for {package["name"]} {package["version"]}')
notices.append(f'\n=== {package["name"]} {package["version"]} ===\n'
f'Declared license: {package["license"] or "see license file"}\n'
f'Source: {package["source"]}\n')
for path in sorted(files):
if path.is_symlink() or not path.resolve().is_relative_to(root.resolve()):
parser.error(f'License notice leaves crate source: {package["name"]}')
data = path.read_bytes()
notices.append(f'\n--- {path.relative_to(root)} ---\n'
f'SHA-256: {hashlib.sha256(data).hexdigest()}\n\n')
notices.append(data.decode('utf-8') + '\n')
count += 1
with args.output.open('x') as stream:
stream.write(''.join(notices))
print(f'PASS: license notices for {count} resolved third-party crates: {args.output}')
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
source "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh"
[[ $# == 0 ]] || die 'Usage: tools/smoke-test'
cd "$FDS_ROOT"
use_xbps
check_void_pin
mkdir -p out/logs out/manifests out/packages
printf 'FDS/OS M0 smoke test\nVoid commit: %s\n' "$(cat VOID_PACKAGES_COMMIT)"
rustc --version
cargo --version
xbps-install -V
tools/verify-elf "$FDS_XBPS/xbps-install" x86_64 static
tools/cargo-build --locked --offline --release --target aarch64-unknown-linux-musl -p fds-smoketest
binary=target/aarch64-unknown-linux-musl/release/fds-smoketest
tools/verify-elf "$binary" aarch64 static
# Supplemental diagnostic only: ldd on an x86 host is not an ARM loader test.
ldd_status=0
ldd_output=$(ldd "$binary" 2>&1) || ldd_status=$?
printf 'ldd (host diagnostic, exit %s): %s\n' "$ldd_status" "$ldd_output"
cp -- "$binary" out/fds-smoketest
tools/build-package hello
# Limit lookup to our local output, never a remote prebuilt hello package.
XBPS_ARCH=aarch64 xbps-rindex -a out/packages/hello-*.aarch64.xbps
arch=$(XBPS_ARCH=aarch64 xbps-query -i --repository "$FDS_ROOT/out/packages" -p architecture hello)
[[ $arch == aarch64 ]] || die "Wrong XBPS architecture: $arch"
pkgver=$(XBPS_ARCH=aarch64 xbps-query -i --repository "$FDS_ROOT/out/packages" -p pkgver hello)
package="out/packages/$pkgver.aarch64.xbps"
[[ -f $package ]] || die "Missing package: $package"
scratch=$(mktemp -d "$FDS_ROOT/out/verify-package.XXXXXX")
trap 'rm -rf -- "$scratch"' EXIT
tar -xf "$package" -C "$scratch" ./usr/bin/hello
tools/verify-elf "$scratch/usr/bin/hello" aarch64 glibc
printf 'PASS: XBPS package %s architecture=%s (glibc)\n' "$pkgver" "$arch"
if command -v qemu-aarch64 >/dev/null; then
output=$(qemu-aarch64 "$binary")
[[ $output == 'FDS/OS M0: aarch64 static-musl OK' ]] || die 'Unexpected Rust runtime output'
printf 'PASS: QEMU Rust execution: %s\n' "$output"
else
printf 'SKIP: ARM execution (optional qemu-aarch64 not installed); ELF verification passed\n'
fi
XBPS_ARCH=x86_64 xbps-query -r "$FDS_VOID/masterdir-x86_64" -l >out/manifests/void-build-packages.txt
find vendor/void-packages/hostdir -type f -name '*.xbps' -print0 \
| sort -z | xargs -0 -r sha256sum >out/manifests/void-package-inputs.sha256
sha256sum "$package" out/fds-smoketest >out/manifests/artifacts.sha256
printf 'PASS: M0 smoke test complete\n'
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
# readelf is authoritative: host ldd alone cannot identify a foreign dynamic ELF.
source "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh"
[[ $# == 3 ]] || die 'Usage: tools/verify-elf FILE {aarch64|x86_64} {static|glibc}'
elf=$1
arch=$2
mode=$3
need readelf
need file
[[ -f $elf ]] || die "Missing ELF: $elf"
case $arch in
aarch64) machine=AArch64 ;;
x86_64) machine='Advanced Micro Devices X86-64' ;;
*) die "Unsupported architecture: $arch" ;;
esac
header=$(readelf -hW "$elf")
segments=$(readelf -lW "$elf")
dynamic=$(readelf -dW "$elf")
versions=$(readelf -VW "$elf")
grep -Eq 'Class:[[:space:]]+ELF64' <<<"$header" || die 'Expected ELF64'
grep -Eq 'Data:.*little endian' <<<"$header" || die 'Expected little endian ELF'
grep -Eq "Machine:[[:space:]]+$machine$" <<<"$header" || die "Expected $arch ELF: $elf"
grep -Eq 'Type:[[:space:]]+(EXEC|DYN)' <<<"$header" || die 'Expected executable ELF'
description=$(file -Lb "$elf")
case $mode in
static)
! grep -q 'INTERP' <<<"$segments" || die "Dynamic interpreter found: $elf"
! grep -q '(NEEDED)' <<<"$dynamic" || die "Shared library dependency found: $elf"
! grep -q 'GLIBC_' <<<"$versions" || die "glibc symbol dependency found: $elf"
grep -Eq 'statically linked|static-pie linked' <<<"$description" || die "file did not identify static linkage: $description"
;;
glibc)
[[ $arch == aarch64 ]] || die 'glibc package verification currently requires aarch64'
grep -q '/lib/ld-linux-aarch64.so.1' <<<"$segments" || die 'Expected aarch64 glibc loader'
grep -Eq '\(NEEDED\).*\[libc\.so\.6\]' <<<"$dynamic" || die 'Expected glibc dependency'
! grep -q 'musl' <<<"$segments$dynamic" || die 'Unexpected musl package ABI'
;;
*) die "Unsupported linkage mode: $mode" ;;
esac
printf '%s: %s\nPASS: %s %s ELF\n' "$elf" "$description" "$arch" "$mode"
+107
View File
@@ -0,0 +1,107 @@
"""Event-driven serial/QMP support for isolated ARM integration tests."""
import json
import base64
import binascii
import hashlib
import os
from pathlib import Path
import re
import selectors
import socket
import subprocess
import time
PROJECT = Path(__file__).resolve().parents[1]
class VM:
def __init__(self, work, name, image, initramfs=None, extra=(), system_usb=False):
self.work = Path(work)
self.name = name
self.qmp_path = self.work/(name+'.qmp')
self.log = (self.work/(name+'.log')).open('wb')
self.errors = (self.work/(name+'.stderr.log')).open('wb')
self.data = bytearray()
self.position = 0
command = [str(PROJECT/'tools/in-void'), '--isolated-network', 'qemu-system-aarch64',
'-machine', 'virt', '-cpu', 'max', '-accel', 'tcg', '-m', '1024', '-smp', '2',
'-nodefaults', '-display', 'none', '-serial', 'stdio', '-nic', 'none', '-no-reboot',
'-qmp', f'unix:{self.qmp_path},server=on,wait=off',
'-kernel', str(PROJECT/'out/kernel/boot/kernel_2712.img'),
'-initrd', str(initramfs or PROJECT/'out/fds-initramfs.img'),
'-append', 'console=ttyAMA0 rdinit=/init ro quiet loglevel=3',
'-drive', f'file={image},if=none,id=system,format=raw,readonly=on',
*(['-device', 'qemu-xhci,id=xhci,addr=05.0', '-device', 'usb-storage,id=systemusb,drive=system,bus=xhci.0,port=1'] if system_usb else ['-device', 'virtio-blk-pci,drive=system']), *extra]
self.child = subprocess.Popen(command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=self.errors)
self.selector = selectors.DefaultSelector()
self.selector.register(self.child.stdout, selectors.EVENT_READ)
def expect(self, pattern, timeout=120):
deadline = time.monotonic()+timeout
expression = re.compile(pattern, re.MULTILINE | re.DOTALL)
while True:
match = expression.search(self.data, self.position)
if match:
self.position = match.end()
return match
remaining = deadline-time.monotonic()
if remaining <= 0: raise AssertionError(f'{self.name}: missing {pattern!r}; inspect serial log')
for key, _ in self.selector.select(remaining):
chunk = os.read(key.fd, 65536)
if not chunk: raise AssertionError(f'{self.name}: VM exited before expected output; inspect {self.errors.name}')
self.data.extend(chunk)
self.log.write(chunk)
self.log.flush()
assert len(self.data) < 4*1024*1024, 'Unexpected serial output flood'
assert b'Kernel panic' not in self.data and b'FDS_STAGE0_ERROR' not in self.data, 'Guest boot failed'
def send(self, command):
self.child.stdin.write(command.encode()+b'\n')
self.child.stdin.flush()
def capture(self, command, ok=True, timeout=180):
"""Execute once; retry only the checked serial transfer after log noise."""
self.send('( '+command+' ) >/tmp/fds-test-response 2>&1; printf "%s" "$?" >/tmp/fds-test-status; printf "\\nVM_SAVED\\n"')
self.expect(rb'^VM_SAVED\r?\n', timeout=timeout)
for _ in range(20):
self.send('printf "\\nVM_BEGIN\\n"; base64 -w0 /tmp/fds-test-response; printf "\\n"; sha256sum /tmp/fds-test-response; cat /tmp/fds-test-status; printf "\\nVM_END\\n"')
frame=self.expect(rb'^VM_BEGIN\r?\n(.*?)\r?\nVM_END\r?\n').group(1).decode().replace('\r','')
try:
encoded,checksum,status=frame.split('\n')
payload=base64.b64decode(encoded, validate=True)
if hashlib.sha256(payload).hexdigest()!=checksum.split()[0]:continue
except (ValueError, binascii.Error):continue
assert (status=='0')==ok, (command,status,payload)
return payload.decode().strip()
raise AssertionError('Repeated serial transfer corruption')
def qmp(self, execute, arguments=None):
with socket.socket(socket.AF_UNIX) as control:
control.settimeout(10)
control.connect(str(self.qmp_path))
with control.makefile('rwb') as protocol:
# A device-deletion event can arrive while a fresh monitor
# connection is being greeted. It is not a command response.
while True:
greeting = json.loads(protocol.readline())
if 'QMP' in greeting: break
assert 'event' in greeting, greeting
def request(name, args):
protocol.write((json.dumps({'execute': name, 'arguments': args})+'\n').encode())
protocol.flush()
while True:
result = json.loads(protocol.readline())
if 'event' in result: continue
assert 'return' in result, result
return result['return']
request('qmp_capabilities', {})
return request(execute, arguments or {})
def close(self):
try:
if self.child.poll() is None:
self.qmp('quit')
self.child.wait(timeout=10)
finally:
if self.child.poll() is None:
self.child.kill()
self.child.wait()
self.selector.close()
self.log.close()
self.errors.close()
def __enter__(self): return self
def __exit__(self, *_): self.close()