boot/bootstrap.sh
back to source
#!/bin/sh
# SPDX-License-Identifier: MIT
# NumericalOS bootstrap - POSIX floor.
# Resolves architecture, selects and verifies an init artifact, execs it.
# Fail-closed: every abnormal path halts with a named reason.
set -eu
NUMOS_BASE="${NUMOS_BASE:-https://numericalos.com}"
NUMOS_LIB="${NUMOS_LIB:-$(dirname "$0")/lib}"
. "$NUMOS_LIB/arch_table.sh"
numos_die() {
echo "numos: HALT: $*" >&2
exit 1
}
numos_uname_m() {
if [ -n "${NUMOS_FAKE_UNAME_M:-}" ]; then
echo "$NUMOS_FAKE_UNAME_M"
else
uname -m
fi
}
numos_detect_arch() {
raw="$(numos_uname_m)"
canonical="$(numos_canonical_arch "$raw")" ||
numos_die "unsupported architecture: $raw"
echo "$canonical"
}
numos_sha256() {
[ -f "$1" ] || numos_die "file not found: $1"
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$1" | cut -d' ' -f1
elif command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$1" | cut -d' ' -f1
else
numos_die "no sha256 implementation available"
fi
}
numos_verify_sha256() {
file="$1"
want="$2"
got="$(numos_sha256 "$file")"
[ "$got" = "$want" ] ||
numos_die "hash mismatch for $file: expected $want got $got"
}
# The C record carries the sha256 of every other line. Recompute and compare.
# The body goes through a real temp file rather than /dev/stdin, because
# numos_sha256 requires a regular file and /dev/stdin is not one everywhere.
#
# The whole remainder of the C line is the hash, not just its first
# whitespace-delimited field: taking `cut -f2` would let `C <valid-hash>
# ANYTHING` verify here while numos.state.verify() rejects it, and the tail
# is covered by no hash, no length check and no signature. Same definition
# on both sides, and it must be exactly 64 lowercase hex characters --
# numos/state.py::verify applies the identical rule.
numos_verify_state() {
file="$1"
[ -f "$file" ] || numos_die "state not found: $file"
c_count="$(grep -c '^C ' "$file")" || c_count=0
[ "$c_count" = "1" ] ||
numos_die "state has $c_count C records, expected exactly 1: $file"
c_line="$(grep '^C ' "$file" | head -n 1)"
want="${c_line#C }"
[ "${#want}" = "64" ] ||
numos_die "state C record is not a 64-character sha256: $file"
case "$want" in
*[!0-9a-f]*)
numos_die "state C record is not lowercase hex: $file" ;;
esac
tmp="${TMPDIR:-/tmp}/numos-verify.$$"
trap 'rm -f "$tmp"' EXIT
grep -v '^C ' "$file" > "$tmp"
got="$(numos_sha256 "$tmp")"
trap - EXIT
rm -f "$tmp"
[ "$got" = "$want" ] ||
numos_die "state hash mismatch for $file: expected $want got $got"
}
# The manifest is line-oriented for the same reason numos.state is: this runs
# in a POSIX shell with no JSON parser, and shipping one would contradict the
# whole premise. Format:
# V <version>
# F <name> <sha256> <bytes>
numos_manifest_lookup() {
manifest="$1"
name="$2"
[ -f "$manifest" ] || numos_die "manifest not found: $manifest"
line="$(grep "^F $name " "$manifest" | head -n 1)"
[ -n "$line" ] || numos_die "manifest has no entry for $name: $manifest"
echo "$line" | cut -d' ' -f3-4
}
# Download to a .part file and rename only on success, so a failed or
# interrupted fetch can never leave something that looks like an artifact
# sitting where the next boot will pick it up and trust it.
numos_fetch() {
url="$1"
dest="$2"
tmp="$dest.part"
rm -f "$tmp"
if command -v curl >/dev/null 2>&1; then
curl -fsSL --max-time 60 -o "$tmp" "$url" ||
{ rm -f "$tmp"; numos_die "fetch failed: $url"; }
elif command -v wget >/dev/null 2>&1; then
wget -q -O "$tmp" "$url" ||
{ rm -f "$tmp"; numos_die "fetch failed: $url"; }
else
numos_die "no HTTP client available (need curl or wget)"
fi
mv "$tmp" "$dest"
}
# Prefer what is already on disk. A machine must boot with no network, so the
# local copy is the floor and the network is only ever a fallback.
#
# A local artifact whose hash is wrong halts and is LEFT IN PLACE: silently
# re-fetching over it would erase the only evidence that something tampered
# with the boot path. A freshly fetched artifact whose hash is wrong is
# removed, because there is nothing to preserve and leaving it would poison
# the next boot's local-copy check.
numos_acquire() {
name="$1"
dest="$2"
manifest="$3"
# Two steps, not one pipeline. `x="$(f | cut ...)"` takes the exit status of
# cut, not of f, so a numos_die inside numos_manifest_lookup would be masked
# and $want would silently become empty.
entry="$(numos_manifest_lookup "$manifest" "$name")"
want="${entry%% *}"
if [ -f "$dest" ]; then
numos_verify_sha256 "$dest" "$want"
echo "on-disk $dest"
return 0
fi
[ "${NUMOS_OFFLINE:-0}" != "1" ] ||
numos_die "offline: $name absent at $dest and fetching is disabled"
numos_fetch "$NUMOS_BASE/artifacts/$name" "$dest"
got="$(numos_sha256 "$dest")"
if [ "$got" != "$want" ]; then
rm -f "$dest"
numos_die "hash mismatch for $dest: expected $want got $got"
fi
echo "fetched $dest"
}
numos_exec_init() {
init="$1"
state="$2"
[ -f "$init" ] || numos_die "init not found: $init"
chmod +x "$init" 2>/dev/null || :
NUMOS_STATE="$state"
export NUMOS_STATE
if [ "${NUMOS_NO_EXEC:-0}" = "1" ]; then
echo "numos: would exec $init"
return 0
fi
# exec replaces this process, so the choice has to be made up front: a
# static numinit must run directly, a shell numinit needs an interpreter.
if [ -x "$init" ]; then
exec "$init"
else
exec sh "$init"
fi
}
# Which init artifact will actually be used.
#
# numos_arch_has_static answers "could a static build exist for this arch",
# derived from whether the ArchTarget carries a cross-compilation triple. That
# is NOT the same question as "is one published", and treating it as such made
# the bootstrap announce `static numinit-x86_64` and then fetch numinit.sh.
# The manifest is the only authority on what actually exists to be fetched.
numos_resolve_init() {
arch="$1"
manifest="$2"
if grep -q "^F numinit-$arch " "$manifest" 2>/dev/null; then
echo "numinit-$arch"
else
echo "numinit.sh"
fi
}
numos_main() {
arch="$(numos_detect_arch)"
echo "numos: arch $arch"
prefix="${NUMOS_PREFIX:-/opt/numericalos}"
manifest="$prefix/manifest.txt"
# The manifest is ALWAYS refreshed when online, not just fetched when
# absent. It is the authority on every other artifact's hash, so a stale
# copy in a persistent prefix poisons every future acquisition: the fetch
# brings down a current artifact, the old manifest expects the previous
# one, and the boot halts on a mismatch that is not a real tampering
# signal. Observed in production against an edge-cached manifest. Offline
# keeps whatever is on disk, because that is the whole point of offline.
mkdir -p "$prefix"
if [ "${NUMOS_OFFLINE:-0}" = "1" ]; then
[ -f "$manifest" ] ||
numos_die "offline: manifest absent at $manifest"
else
numos_fetch "$NUMOS_BASE/artifacts/manifest.txt" "$manifest"
fi
name="$(numos_resolve_init "$arch" "$manifest")"
case "$name" in
numinit.sh) echo "numos: init $name (shell floor)" ;;
*) echo "numos: init $name (static)" ;;
esac
init="$prefix/$name"
# NOT `echo "numos: $(numos_acquire ...)"`. A command substitution's exit
# status propagates under `set -e` only in an ASSIGNMENT; inside echo it is
# discarded, so numos_die would kill the subshell, echo would print an empty
# line, and the boot would continue -- and then exec a tampered artifact.
# That is exactly what happened, and it is the same subshell-exit defect
# class this codebase already fixed once in numinit.sh.
acquired="$(numos_acquire "$name" "$init" "$manifest")"
echo "numos: $acquired"
[ -n "${NUMOS_STATE:-}" ] || numos_die "NUMOS_STATE is unset"
numos_verify_state "$NUMOS_STATE"
echo "numos: state verified"
numos_exec_init "$init" "$NUMOS_STATE"
}
if [ "${NUMOS_SOURCE_ONLY:-0}" != "1" ]; then
numos_main "$@"
fi