#!/usr/bin/env bash
# Layer400's short installer. The immutable signed release performs the
# installation, health checks and rollback; this file only delivers it.
set -Eeuo pipefail
# Root shells and virtual environments may omit system tools or override Python.
# Installation always uses the host system package database and standard library.
export PATH="/usr/sbin:/usr/bin:/sbin:/bin${PATH:+:$PATH}"
unset PYTHONHOME PYTHONPATH PYTHONUSERBASE DPKG_ROOT DPKG_ADMINDIR DPKG_FRONTEND_LOCKED
umask 077

# Remove only download workspaces created by this process, also on failure.
# Durable recovery packages and diagnostics are retained by the signed installer.
DOWNLOAD_DIRS=()
cleanup_downloads() {
  local directory
  for directory in "${DOWNLOAD_DIRS[@]}"; do
    [[ $directory == /var/tmp/layer400-download.* && -d $directory && ! -L $directory ]] && rm -rf -- "$directory"
  done
  return 0
}
trap cleanup_downloads EXIT
trap 'exit 130' INT
trap 'exit 143' TERM

# A stage stays visible while a slow network or package operation is running.
# Never report a made-up percentage or swallow the command's failure status.
run_stage() {
  local label=$1 pulse result
  shift
  printf '\nLayer400 â€” %s\n' "$label"
  (trap - ERR; while sleep 10 >/dev/null 2>&1; do printf '  Still working: %s. Please keep this terminal open.\n' "$label"; done) &
  pulse=$!
  if "$@"; then result=0; else result=$?; fi
  kill "$pulse" 2>/dev/null || true
  wait "$pulse" 2>/dev/null || true
  if ((result)); then
    printf '\nThis step did not complete: %s (exit %s). The details above explain the failure.\n' "$label" "$result" >&2
  fi
  return "$result"
}

# BEGIN REMOVED RECEIVER CACHE RECOVERY
# Kept identical in the short bootstrap and future signed installers. This
# only archives cache for an absent package; it never changes receiver identity.
recover_removed_receiver_cache() {
  python3 - "$1" <<'PY'
import fcntl, os, stat, subprocess, sys, tempfile, time
from pathlib import Path

def private_directory(path):
    info = path.lstat()
    if not stat.S_ISDIR(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o022:
        raise RuntimeError('Unsafe installation cache directory')

def sync(path):
    fd = os.open(path, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
    try:
        os.fsync(fd)
    finally:
        os.close(fd)

try:
    # Hold the same lock as installation, updates and settings changes.
    if sys.argv[1] != 'lock-held':
        fd = os.open('/run/lock/layer400-engineering-update.lock',
                     os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
        info = os.fstat(fd)
        if not stat.S_ISREG(info.st_mode) or info.st_uid != 0 or info.st_nlink != 1 or info.st_mode & 0o022:
            raise RuntimeError('Unsafe installation lock')
        deadline=time.monotonic()+180
        while True:
            try:
                fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
                break
            except BlockingIOError:
                if time.monotonic()>=deadline:raise
                time.sleep(1)
    query = subprocess.run(['dpkg-query', '-W', '-f=${Status}', 'layer400-node'],
                           capture_output=True, text=True, timeout=15)
    # Unknown/error states must never be interpreted as an uninstalled package.
    if query.returncode == 0:
        if query.stdout.strip() not in ('deinstall ok config-files', 'purge ok not-installed'):
            sys.exit(0)
    elif query.returncode != 1 or query.stdout.strip():
        raise RuntimeError('Could not determine receiver installation state')
    root = Path('/var/lib/layer400/update')
    for marker in (root/'bootstrap.json', Path('/var/lib/layer400-update-private/journal.json')):
        if marker.exists() or marker.is_symlink():
            sys.exit(0)  # The signed transaction retains recovery authority.
    if not root.exists() and not root.is_symlink():
        sys.exit(0)
    for parent in (Path('/var'), Path('/var/lib'), root.parent, root):
        private_directory(parent)
    current = root/'current'
    if not current.exists() and not current.is_symlink():
        sys.exit(0)
    private_directory(current)
    if not any(current.iterdir()):
        sys.exit(0)
    # Rename the whole cache without opening/executing any package or deleting
    # anything. A power loss at any step leaves the original bytes recoverable.
    archive = Path(tempfile.mkdtemp(prefix='removed-cache-', dir=root))
    sync(root)
    os.rename(current, archive/'current')
    sync(archive)
    sync(root)
    current.mkdir(mode=0o700)
    sync(root)
except (OSError, RuntimeError, subprocess.SubprocessError):
    raise SystemExit('Installation preparation could not complete safely. Please retry.') from None
PY
}
# END REMOVED RECEIVER CACHE RECOVERY

# BEGIN INTERRUPTED INSTALL SELECTION
select_install_recovery() {
  python3 - "$1" "$2" "$3" <<'PY'
import json, os, re, stat, sys
from pathlib import Path

# Release pins are populated from verified public distribution signatures.
releases = {'0.1.0-23': {'name': 'layer400-receiver-0.1.0-23-arm64.tar.gz', 'sha256': 'aaedf112bc9b072b013dcd5648a2951db7ce113bc228729f5151e4a78520e3c5', 'package_sha256': '08ad3708c5c096887d5735031f37c7f2f5f8ff02f6362461c5a024f8026e6d94'}, '0.1.0-35': {'name': 'layer400-receiver-0.1.0-35-arm64.tar.gz', 'sha256': '3c068bb63a1895aca47db92346929c83b6a7d1b123342ea9fe20f134474503f8', 'package_sha256': '1a1cf75eb443c47475ea21b404feeae991bb6b360a75039acee58a530a8de54e'}, '0.1.0-38': {'name': 'layer400-receiver-0.1.0-38-arm64.tar.gz', 'sha256': 'f7a972405acd3523c1e32bc2a5c822bbeda849acb97bf929c2ef43ab68e0c170', 'package_sha256': '98be0bb87e34d4a8ad869f894a838ed925b689d644f4edf8853f045f9d569717'}, '0.1.0-22': {'name': 'layer400-receiver-0.1.0-22-arm64.tar.gz', 'sha256': 'ac3c2abdec4d846168c0a476a7a6c98aa3b8981d12a0806bc0c5e0278b027ef0', 'package_sha256': '2f2eceeb35e1509fa7fcf803582bc267585a4dee8f35206a95565943487cd61a'}, '0.1.0-26': {'name': 'layer400-receiver-0.1.0-26-arm64.tar.gz', 'sha256': '6193c10fbc18fa09462ff5c104ab372712c38f6ddea74b9caa81e5ab2e035932', 'package_sha256': 'ebd4c65f60bf5ecec87603370968902593cc5bbc3a4376a15cf1b331143f372c'}, '0.1.0-24': {'name': 'layer400-receiver-0.1.0-24-arm64.tar.gz', 'sha256': 'dc6fd21c2930813fa05b4673f900370bf5ecf8de46f9ff98ae175a104b376992', 'package_sha256': '5a122cf5cc7c798b406ae039f5d8b16bec9e8a678a80a50f510ab88fbeb88c60'}, '0.1.0-20': {'name': 'layer400-receiver-0.1.0-20-arm64.tar.gz', 'sha256': 'd0ff419a85504899ec0a010d36065f81775da2c9c4664a4fa06348e1113df272', 'package_sha256': 'dd9353199a36be5577b8fbd0ddf9ed457077fd1e960e3786579a0933826fe3db'}, '0.1.0-46': {'name': 'layer400-receiver-0.1.0-46-arm64.tar.gz', 'sha256': '3aa189b10bc1edcbb44b6473883328049cedfc3e0fbc20c706a5a4ecb2b22aae', 'package_sha256': '53bfd9f300e99ba4dfdfe4a9f36a1e6980683f62c07431adf029ac63aec67f2c'}, '0.1.0-18': {'name': 'layer400-receiver-0.1.0-18-arm64.tar.gz', 'sha256': 'f784d99ccfce50d62c13f1f22e578e5a55c349166efffa301a3f5c7067dedeca', 'package_sha256': '2446d3ff142558a3f4e200a072f6e89db09fb3650918e85641ea00948f98f9fa'}, '0.1.0-36': {'name': 'layer400-receiver-0.1.0-36-arm64.tar.gz', 'sha256': '7a2d7b81fc33604dc5516631dacea8679e3361b976a8ff72e1666769f09211dc', 'package_sha256': '20034a83baa979e2fa517e8ef985d9651ba23737fc2e4da787c0bd031168b543'}, '0.1.0-21': {'name': 'layer400-receiver-0.1.0-21-arm64.tar.gz', 'sha256': '18ef375abf95b7f0ac237db09278eb9df1cee0eb9b4135280cce333aec71e0ce', 'package_sha256': 'd8b8a7b7db7a40177d74086a3ce0346cac73d2cb2acd558158aa3ea7f4e8ca4a'}, '0.1.0-19': {'name': 'layer400-receiver-0.1.0-19-arm64.tar.gz', 'sha256': '8135fcdc1a764e24e2f6399ed989b598293ea0ad565f5375d6f78fa46b975c54', 'package_sha256': '6166dcebbc0c05a78eef6d00a695a792a63afcd40c4e92813bcc80c360be1f1d'}, '0.1.0-39': {'name': 'layer400-receiver-0.1.0-39-arm64.tar.gz', 'sha256': '46ceae31bd7a07711602dae33923c84a89c7948b5def9eede8009d382b2c5895', 'package_sha256': 'b993e8ec846dffff047318a75e3da67e49039fd37bd5866b14bd9c34e648ba68'}, '0.1.0-17': {'name': 'layer400-receiver-0.1.0-17-arm64.tar.gz', 'sha256': '3a08ae702de7f1cfa65cb8dd388d9736fe16ecbcff44a71fff2b404ca1156c8b', 'package_sha256': '9781907391e54e597bc9ad0ac94efb66404aa42a634cd8d38ac27cbbf6ba44af'}, '0.1.0-47': {'name': 'layer400-receiver-0.1.0-47-arm64.tar.gz', 'sha256': '77b5c382f9af5c7a5a587d67a554cdd407877d9eb747f930a309ddb92b7cc1b6', 'package_sha256': '271301520138b8277ea055c30399d42bc7463d6bca5abff92a5d3442c167efa4'}, '0.1.0-48': {'name': 'layer400-receiver-0.1.0-48-arm64.tar.gz', 'sha256': 'd9c0948d4cdbfe58732695f7f78c46f12d4648c86b397f8b8bc7fe4fbf6172eb', 'package_sha256': 'e5df5d3b8e4e990d84cf7446162a17ec2b9bb5f7baba4834e655ceb972adb736'}, '0.1.0-49': {'name': 'layer400-receiver-0.1.0-49-arm64.tar.gz', 'sha256': '044c4941d2f54b16b528408bd08dad6c5e521d12a993ba167b36e80b37434cf1', 'package_sha256': '705cfefb3e7e0c7b0e3526d9ba4c89db7bee5705b609200a23caf5c56ed31dc8'}, '0.1.0-51': {'name': 'layer400-receiver-0.1.0-51-arm64.tar.gz', 'sha256': '45a0a591a37e493e26837be7850049d484204739044bb8df5c6e37a7c713e5cd', 'package_sha256': '9bb548fbe7a82e4bbb1275fbb53f54304106550534cef87d560830671d49b533'}}
current = sys.argv[1:]
record = Path('/var/lib/layer400/update/bootstrap.json')
try:
    if not record.exists() and not record.is_symlink():
        print('\n'.join(current))
        sys.exit(0)
    for parent in (Path('/var'), Path('/var/lib'), record.parent.parent, record.parent):
        info = parent.lstat()
        if not stat.S_ISDIR(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o022:
            raise ValueError('Unsafe recovery directory')
    fd = os.open(record, os.O_RDONLY | os.O_NOFOLLOW)
    with os.fdopen(fd, 'rb') as stream:
        info = os.fstat(stream.fileno())
        if not stat.S_ISREG(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o077 or info.st_nlink != 1 or info.st_size > 8192:
            raise ValueError('Unsafe recovery record')
        value = json.load(stream)
    if value.get('schema') != 'layer400.receiver-bootstrap.v1':
        raise ValueError('Invalid recovery record')
    version = value['target_version'].replace('~rc', '-rc')
    if version not in releases or value.get('target_sha256') != releases[version]['package_sha256']:
        raise ValueError('No matching published recovery release')
    if version == current[0]:
        print('\n'.join(current))
    else:
        release = releases[version]
        print(version)
        print(release['sha256'])
        print('https://layer400.com/software/'+version+'/'+release['name'])
except (OSError, ValueError, TypeError, KeyError, AttributeError):
    raise SystemExit('The interrupted installation could not be verified. Please keep this device powered on and retry.') from None
PY
}
# END INTERRUPTED INSTALL SELECTION

# BEGIN OPTIONAL APT SHORTCUT
register_apt_shortcut() {
  local work=$1 version=$2 target
  # The alias post-install check uses the exact verified package to seed
  # recovery even on receivers updated from an older installer through the UI.
  local cached="/var/cache/apt/archives/layer400-node_${version}_arm64.deb"
  [[ ! -L $cached ]] || { printf 'APT package cache needs checking.\n' >&2; return 2; }
  install -o root -g root -m 0644 "$work/release/layer400-node_${version}_arm64.deb" "$cached" || return
  # Register the scoped archive so the ordinary apt package name works too.
  curl --fail --show-error --location --proto '=https' --proto-redir '=https' \
    --connect-timeout 15 --max-time 60 --retry 2 --max-filesize 16384 \
    --output "$work/layer400.asc" https://layer400.com/apt/layer400-archive-key.asc || return
  printf '%s  %s\n' 4541da2731da4e02f7c65fdbbd700f54b8aa232811c35e62b9b1e46728c5d4ac "$work/layer400.asc" | sha256sum --check --status || return
  printf '%s\n' 'Types: deb' 'URIs: https://layer400.com/apt' 'Suites: stable' 'Components: main' \
    'Architectures: arm64' 'Signed-By: /etc/apt/keyrings/layer400.asc' > "$work/layer400.sources" || return
  for target in /etc/apt/keyrings/layer400.asc /etc/apt/sources.list.d/layer400.sources; do
    [[ ! -L $target ]] || { printf 'Existing Layer400 APT configuration needs checking.\n' >&2; return 2; }
    if [[ -e $target ]]; then
      cmp -s "$target" "$work/$(basename "$target")" || { printf 'A different Layer400 APT configuration already exists; it was left unchanged.\n' >&2; return 2; }
    fi
  done
  install -d -o root -g root -m 0755 /etc/apt/keyrings || return
  install -o root -g root -m 0644 "$work/layer400.asc" /etc/apt/keyrings/layer400.asc || return
  install -o root -g root -m 0644 "$work/layer400.sources" /etc/apt/sources.list.d/layer400.sources || return
  run_stage 'Registering the optional APT shortcut' apt-get -qq -o DPkg::Lock::Timeout=180 -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 update -o Dir::Etc::sourcelist=/etc/apt/sources.list.d/layer400.sources -o Dir::Etc::sourceparts=- -o APT::Get::List-Cleanup=0 || return
  run_stage 'Enabling the optional APT shortcut' env DEBIAN_FRONTEND=noninteractive apt-get -qq -o DPkg::Options::=--force-confold -o DPkg::Lock::Timeout=180 -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y --no-remove --no-install-recommends "layer400=$version" || return
}
# END OPTIONAL APT SHORTCUT

# BEGIN INSTALLER TOOL PREPARATION
# Minimal supported images need these distribution tools before the signed
# release can be inspected. APT still authenticates its normal OS packages.
prepare_installer_tools() {
  local verify=$1
  local -a packages=()
  command -v python3 >/dev/null 2>&1 || packages+=(python3 python3-minimal)
  command -v openssl >/dev/null 2>&1 || packages+=(openssl)
  ((${#packages[@]})) || return 0
  if [[ $verify == true ]]; then
    printf 'Verification requires Python 3 and OpenSSL. Run the normal installer to prepare missing system tools. No packages were changed.\n' >&2
    return 2
  fi
  [[ $(dpkg --print-architecture) == arm64 ]] || {
    printf 'This package requires a 64-bit ARM (arm64) operating system.\n' >&2; return 2;
  }
  command -v apt-get >/dev/null 2>&1 || return 2
  # Cached, authenticated packages can still work when a repository is offline.
  run_stage 'Preparing system package information' apt-get -qq -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 update || true
  run_stage 'Preparing required system tools' env DEBIAN_FRONTEND=noninteractive apt-get \
    -o DPkg::Options::=--force-confold -o DPkg::Lock::Timeout=180 -o Acquire::Retries=3 \
    -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 \
    install -y --reinstall --no-remove --no-install-recommends -- "${packages[@]}" || return
  command -v python3 >/dev/null 2>&1 && command -v openssl >/dev/null 2>&1
}
# END INSTALLER TOOL PREPARATION

main() {
  local version=0.1.0-63
  local archive_sha=5a7704045ccdaa8b24d915999f50c28e996b306dee5dd782e565c8f23e940a74
  local key_pin=35a1d41a912ca8281c758c4824eb1e679319124f4e93ec14661f265be50914ac
  local url=https://layer400.com/software/0.1.0-63/layer400-receiver-0.1.0-63-arm64.tar.gz
  local verify=false supplied_file='' work
  while (($#)); do
    case "$1" in
      --verify-only) verify=true; shift ;;
      --file) [[ $# -ge 2 ]] || return 2; supplied_file=$2; shift 2 ;;
      *) printf 'Usage: bash install [--verify-only] [--file signed-archive.tar.gz]\n' >&2; return 2 ;;
    esac
  done
  [[ $EUID -eq 0 ]] || { printf 'Run this installer with sudo.\n' >&2; return 2; }
  for command in curl sha256sum bash dpkg awk df mktemp; do
    command -v "$command" >/dev/null || { printf 'Required system command missing: %s\n' "$command" >&2; return 2; }
  done
  [[ $(dpkg --print-architecture) == arm64 ]] || { printf 'This download contains an ARM64 binary; a matching package is needed for this CPU architecture.\n' >&2; return 2; }
  prepare_installer_tools "$verify"
  local latest_version=$version
  local selected
  if [[ $verify == false && -z $supplied_file ]]; then
    selected=$(select_install_recovery "$version" "$archive_sha" "$url")
    local -a selected_release
    mapfile -t selected_release <<<"$selected"
    [[ ${#selected_release[@]} -eq 3 ]] || return 2
    version=${selected_release[0]}; archive_sha=${selected_release[1]}; url=${selected_release[2]}
  fi
  work=$(mktemp -d /var/tmp/layer400-download.XXXXXXXX)
  DOWNLOAD_DIRS+=("$work")
  printf 'Preparing Layer400 Receiver %sâ€¦\n' "$version"
  if [[ -n $supplied_file ]]; then
    [[ -f $supplied_file && ! -L $supplied_file ]] || { printf 'Choose a regular signed release archive.\n' >&2; return 2; }
    cp -- "$supplied_file" "$work/release.tar.gz"
  else
    run_stage 'Downloading the signed receiver release' curl --fail --show-error --progress-bar --location --proto '=https' --proto-redir '=https' --tlsv1.2 \
      --connect-timeout 15 --max-time 900 --retry 2 --max-filesize 194162626 \
      --output "$work/release.tar.gz" "$url"
  fi
  printf '%s  %s\n' "$archive_sha" "$work/release.tar.gz" | sha256sum --check --status || {
    printf 'The download did not match the signed release. Layer400 was not installed. Run the command again.\n' >&2; return 2;
  }
  # Do not allow archive paths, links or device nodes outside the owned folder.
  run_stage 'Checking and unpacking the download' python3 - "$work" <<'PY'
import inspect, pathlib, re, shutil, sys, tarfile
root = pathlib.Path(sys.argv[1])
target = root / 'release'
target.mkdir(mode=0o700)
with tarfile.open(root / 'release.tar.gz', 'r:gz') as archive:
    members = archive.getmembers()
    if not 1 <= len(members) <= 128 or len({m.name for m in members}) != len(members):
        raise SystemExit('Invalid release archive')
    for member in members:
        if (not member.isfile() or not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._+~-]{0,159}', member.name)
                or not 0 <= member.size <= 268435456):
            raise SystemExit('Unsafe release archive')
    # Allow the unpacked archive and the installer's immutable verification
    # snapshot to coexist. Size comes from the hash-verified release itself.
    required = 2 * sum(member.size for member in members)
    available = shutil.disk_usage(root).free
    if available < required:
        raise SystemExit(f'Layer400 storage_required: /var/tmp needs {required} free bytes to unpack and verify this release; {available} available. Free space and retry.')
    if 'filter' in inspect.signature(archive.extractall).parameters:
        archive.extractall(target, members=members, filter='data')
    else:
        # Older system Python: never use unrestricted extractall. Every member
        # above is a unique, bounded, flat regular file; the destination is new
        # and private. Copy only content, not archive ownership/mode/link data.
        for member in members:
            with archive.extractfile(member) as source, (target / member.name).open('xb') as output:
                remaining = member.size
                while remaining:
                    chunk = source.read(min(1048576, remaining))
                    if not chunk:
                        raise SystemExit('Truncated release archive')
                    output.write(chunk)
                    remaining -= len(chunk)
PY
  local installer="$work/release/install-signed-receiver-release.sh"
  # This script's identity is bound by the archive hash above. Its own second
  # verification pins the Ed25519 signer and every package member before apt.
  bash "$installer" --bundle "$work/release" --expected-version "$version" --public-key-spki-sha256 "$key_pin" --verify-only
  if [[ $verify == true ]]; then
    printf 'Verified. No packages, receiver settings or services were changed.\n'
  else
    if ! run_stage 'Refreshing system package information â€” this can take a few minutes' apt-get -qq -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 update; then
      # A temporarily unreachable unrelated repository need not block packages
      # already available. The signed APT dependency check remains mandatory.
      printf 'Package information could not fully refresh. Checking whether the required packages are already availableâ€¦\n'
    fi
    # A matching version alone does not prove intact installed files/services.
    # Always let the verified installer repair, resume and check real health.
    run_stage 'Preparing your receiver' recover_removed_receiver_cache acquire
    bash "$installer" --bundle "$work/release" --expected-version "$version" --public-key-spki-sha256 "$key_pin"
    if [[ $version != "$latest_version" ]]; then
      # Finish the exact interrupted transaction first, then install current.
      # The signed installer must have retired the recovery record on success.
      [[ ! -e /var/lib/layer400/update/bootstrap.json && ! -L /var/lib/layer400/update/bootstrap.json ]] || return 2
      [[ $work == /var/tmp/layer400-download.* && -d $work && ! -L $work ]] && rm -rf -- "$work"
      main
      return
    fi
    # The receiver has passed the mandatory signed installation/health gate.
    # The convenience APT alias is optional; a network/configuration failure
    # here must not misreport that already-completed installation as failed.
    if register_apt_shortcut "$work" "$version"; then
      printf '\nLayer400 is installed. The command sudo apt install layer400 is now available.\n'
    else
      printf '\nLayer400 software is installed. The optional APT shortcut could not be registered; rerunning this installer will retry it.\n'
    fi
    if ! /usr/bin/layer400 setup; then
      printf '\nLayer400 software is installed. Local setup is not ready yet.\nCheck progress: sudo layer400 status\nRetry setup: sudo layer400 setup\n'
    fi
  fi
  # Only the exact directory created by this invocation is removed on success.
  [[ $work == /var/tmp/layer400-download.* && -d $work && ! -L $work ]] && rm -rf -- "$work"
}

# Keep execution after the complete function definition: a truncated transfer
# cannot execute a half-downloaded installer body.
main "$@"
