#!/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 # 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 # F 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