NumericalOS

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