FDS/OS 1.0
This commit is contained in:
Executable
+141
@@ -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()
|
||||
Executable
+64
@@ -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'
|
||||
Executable
+41
@@ -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'
|
||||
Executable
+71
@@ -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'
|
||||
Executable
+19
@@ -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'
|
||||
Executable
+42
@@ -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'
|
||||
Executable
+46
@@ -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'
|
||||
Executable
+16
@@ -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'
|
||||
Executable
+91
@@ -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'
|
||||
Executable
+15
@@ -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'
|
||||
Executable
+42
@@ -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"
|
||||
Executable
+24
@@ -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 "$@"
|
||||
Executable
+30
@@ -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}')
|
||||
Executable
+58
@@ -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}')
|
||||
Executable
+87
@@ -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}')
|
||||
@@ -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
|
||||
Executable
+45
@@ -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'
|
||||
Executable
+316
@@ -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()
|
||||
@@ -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}
|
||||
Executable
+17
@@ -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" "$@"
|
||||
Executable
+11
@@ -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}"
|
||||
Executable
+31
@@ -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
@@ -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" "$@"
|
||||
@@ -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
|
||||
}
|
||||
Executable
+42
@@ -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}')
|
||||
Executable
+44
@@ -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')
|
||||
Executable
+25
@@ -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'
|
||||
Executable
+18
@@ -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'
|
||||
Executable
+24
@@ -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'
|
||||
Executable
+25
@@ -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
|
||||
@@ -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.
|
||||
@@ -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}
|
||||
Executable
+107
@@ -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)")
|
||||
Executable
+101
@@ -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}")
|
||||
Executable
+16
@@ -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)
|
||||
@@ -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 "$@"
|
||||
Executable
+38
@@ -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'
|
||||
@@ -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()
|
||||
Executable
+18
@@ -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
@@ -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
|
||||
Executable
+45
@@ -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}')
|
||||
Executable
+48
@@ -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'
|
||||
Executable
+40
@@ -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"
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user