From 170baed7a7e0cd2452fce31d4f232a7927298e26 Mon Sep 17 00:00:00 2001 From: William Hutson <555966+wilrnh@users.noreply.github.com> Date: Sun, 13 Sep 2026 20:17:32 -0400 Subject: [PATCH 01/19] chore: ignore local git worktrees --- .gitignore | 1 + 1 file changed, 1 insertion(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e458ed5 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.worktrees/ From 73fd6f88c30e57aeefc640fdec9a8333d3770ae0 Mon Sep 17 00:00:00 2001 From: William Hutson <555966+wilrnh@users.noreply.github.com> Date: Sun, 13 Sep 2026 20:21:18 -0400 Subject: [PATCH 02/19] chore: scaffold ghostdrive tooling and shared shell helpers --- .editorconfig | 19 +++++++++++++++++++ .gitignore | 2 ++ Makefile | 4 ++++ README.md | 15 +++++++++++++++ scripts/lib/common.sh | 13 +++++++++++++ tests/bats/repo_scaffold.bats | 7 +++++++ 6 files changed, 60 insertions(+) create mode 100644 .editorconfig create mode 100644 Makefile create mode 100644 scripts/lib/common.sh create mode 100644 tests/bats/repo_scaffold.bats diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..d501849 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,19 @@ +root = true + +[*.sh] +indent_style = space +indent_size = 2 +end_of_line = lf +insert_final_newline = true +charset = utf-8 + +[Makefile] +indent_style = tab +end_of_line = lf +insert_final_newline = true +charset = utf-8 + +[*.md] +end_of_line = lf +insert_final_newline = true +charset = utf-8 diff --git a/.gitignore b/.gitignore index e458ed5..066b330 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ .worktrees/ +.env +.env.* diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..62d5b2a --- /dev/null +++ b/Makefile @@ -0,0 +1,4 @@ +.PHONY: test + +test: + bats tests/bats diff --git a/README.md b/README.md index d229a03..5423c16 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,17 @@ # ghostdrive A bootable USB with everything to start hacking + +## Local Development + +Install local test tooling: + +```bash +sudo apt-get update +sudo apt-get install -y bats +``` + +Run local checks: + +```bash +make test +``` diff --git a/scripts/lib/common.sh b/scripts/lib/common.sh new file mode 100644 index 0000000..9cc1a6e --- /dev/null +++ b/scripts/lib/common.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env sh + +require_cmd() { + command -v "$1" >/dev/null 2>&1 +} + +log_info() { + printf '[INFO] %s\n' "$1" +} + +log_error() { + printf '[ERROR] %s\n' "$1" >&2 +} diff --git a/tests/bats/repo_scaffold.bats b/tests/bats/repo_scaffold.bats new file mode 100644 index 0000000..3288817 --- /dev/null +++ b/tests/bats/repo_scaffold.bats @@ -0,0 +1,7 @@ +#!/usr/bin/env bats + +@test "make test target exists" { + run make -n test + [ "$status" -eq 0 ] + [[ "$output" == *"bats tests/bats"* ]] +} From 966de0bb5e46a479e880b17dbf412d8b05813811 Mon Sep 17 00:00:00 2001 From: William Hutson <555966+wilrnh@users.noreply.github.com> Date: Sun, 13 Sep 2026 20:31:27 -0400 Subject: [PATCH 03/19] feat: add config schema contract and validator --- config/defaults.yaml | 3 +++ config/schema.json | 27 +++++++++++++++++++++++++++ scripts/validate-config.sh | 26 ++++++++++++++++++++++++++ tests/bats/config_validation.bats | 12 ++++++++++++ tests/fixtures/invalid-config.yaml | 3 +++ 5 files changed, 71 insertions(+) create mode 100644 config/defaults.yaml create mode 100644 config/schema.json create mode 100755 scripts/validate-config.sh create mode 100644 tests/bats/config_validation.bats create mode 100644 tests/fixtures/invalid-config.yaml diff --git a/config/defaults.yaml b/config/defaults.yaml new file mode 100644 index 0000000..ea93b98 --- /dev/null +++ b/config/defaults.yaml @@ -0,0 +1,3 @@ +ubuntu_release: "24.04" +target_arch: "x86_64" +baseline_profile: "curated-default" \ No newline at end of file diff --git a/config/schema.json b/config/schema.json new file mode 100644 index 0000000..987d18d --- /dev/null +++ b/config/schema.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "required": [ + "ubuntu_release", + "target_arch", + "baseline_profile" + ], + "properties": { + "ubuntu_release": { + "enum": [ + "24.04" + ] + }, + "target_arch": { + "enum": [ + "x86_64" + ] + }, + "baseline_profile": { + "enum": [ + "curated-default" + ] + } + }, + "additionalProperties": true +} \ No newline at end of file diff --git a/scripts/validate-config.sh b/scripts/validate-config.sh new file mode 100755 index 0000000..0129024 --- /dev/null +++ b/scripts/validate-config.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/.." && pwd)" + +# shellcheck source=scripts/lib/common.sh +source "$script_dir/lib/common.sh" + +if ! require_cmd check-jsonschema; then + log_error "check-jsonschema is required but was not found on PATH" + exit 1 +fi + +if [ "$#" -ne 1 ]; then + log_error "usage: scripts/validate-config.sh " + exit 1 +fi + +config_path="$1" +schema_path="$repo_root/config/schema.json" + +if ! check-jsonschema --schemafile "$schema_path" "$config_path" >/dev/null 2>&1; then + log_error "config validation failed for: $config_path" + exit 1 +fi \ No newline at end of file diff --git a/tests/bats/config_validation.bats b/tests/bats/config_validation.bats new file mode 100644 index 0000000..10c884f --- /dev/null +++ b/tests/bats/config_validation.bats @@ -0,0 +1,12 @@ +#!/usr/bin/env bats + +@test "valid config passes schema validation" { + run scripts/validate-config.sh config/defaults.yaml + [ "$status" -eq 0 ] +} + +@test "invalid config fails schema validation" { + run scripts/validate-config.sh tests/fixtures/invalid-config.yaml + [ "$status" -eq 1 ] + [[ "$output" == *"config validation failed"* ]] +} \ No newline at end of file diff --git a/tests/fixtures/invalid-config.yaml b/tests/fixtures/invalid-config.yaml new file mode 100644 index 0000000..293fd90 --- /dev/null +++ b/tests/fixtures/invalid-config.yaml @@ -0,0 +1,3 @@ +ubuntu_release: 22.04 +target_arch: arm64 +baseline_profile: custom \ No newline at end of file From ce0175605fd7655e287dedb947618e6c34255802 Mon Sep 17 00:00:00 2001 From: William Hutson <555966+wilrnh@users.noreply.github.com> Date: Sun, 13 Sep 2026 20:37:47 -0400 Subject: [PATCH 04/19] feat: add install-to-usb orchestration entrypoint --- scripts/build/install_to_usb.sh | 41 ++++++++++++++++++++++++ scripts/build/render_autoinstall_seed.sh | 36 +++++++++++++++++++++ tests/bats/install_to_usb_args.bats | 7 ++++ 3 files changed, 84 insertions(+) create mode 100755 scripts/build/install_to_usb.sh create mode 100755 scripts/build/render_autoinstall_seed.sh create mode 100644 tests/bats/install_to_usb_args.bats diff --git a/scripts/build/install_to_usb.sh b/scripts/build/install_to_usb.sh new file mode 100755 index 0000000..dbbeaeb --- /dev/null +++ b/scripts/build/install_to_usb.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/../.." && pwd)" + +CONFIG="" +DEVICE="" +WORKDIR="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --config) + [[ $# -ge 2 ]] || { echo "missing value for --config"; exit 2; } + CONFIG="$2" + shift 2 + ;; + --device) + [[ $# -ge 2 ]] || { echo "missing value for --device"; exit 2; } + DEVICE="$2" + shift 2 + ;; + --workdir) + [[ $# -ge 2 ]] || { echo "missing value for --workdir"; exit 2; } + WORKDIR="$2" + shift 2 + ;; + *) + echo "unknown argument: $1" + exit 2 + ;; + esac +done + +[[ -n "$DEVICE" ]] || { echo "--device is required"; exit 2; } +[[ -n "$CONFIG" ]] || { echo "--config is required"; exit 2; } + +"$repo_root/scripts/validate-config.sh" "$CONFIG" + +mkdir -p "${WORKDIR:-.ghostdrive-work}" +echo "install orchestration prepared" diff --git a/scripts/build/render_autoinstall_seed.sh b/scripts/build/render_autoinstall_seed.sh new file mode 100755 index 0000000..243e841 --- /dev/null +++ b/scripts/build/render_autoinstall_seed.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail + +CONFIG="" +OUTDIR="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --config) + [[ $# -ge 2 ]] || { echo "missing value for --config"; exit 2; } + CONFIG="$2" + shift 2 + ;; + --outdir) + [[ $# -ge 2 ]] || { echo "missing value for --outdir"; exit 2; } + OUTDIR="$2" + shift 2 + ;; + *) + echo "unknown argument: $1" + exit 2 + ;; + esac +done + +[[ -n "$CONFIG" ]] || { echo "--config is required"; exit 2; } +[[ -n "$OUTDIR" ]] || { echo "--outdir is required"; exit 2; } + +mkdir -p "$OUTDIR" +cat >"$OUTDIR/user-data" <<'EOF' +#cloud-config +autoinstall: + version: 1 +EOF + +echo "seed rendered" diff --git a/tests/bats/install_to_usb_args.bats b/tests/bats/install_to_usb_args.bats new file mode 100644 index 0000000..caca509 --- /dev/null +++ b/tests/bats/install_to_usb_args.bats @@ -0,0 +1,7 @@ +#!/usr/bin/env bats + +@test "install_to_usb rejects missing --device" { + run scripts/build/install_to_usb.sh --config config/defaults.yaml + [ "$status" -eq 2 ] + [[ "$output" == *"--device is required"* ]] +} From 9c536ea137ca5362ca73c301e271a0bf9ac706f4 Mon Sep 17 00:00:00 2001 From: William Hutson <555966+wilrnh@users.noreply.github.com> Date: Sun, 13 Sep 2026 20:42:50 -0400 Subject: [PATCH 05/19] feat: add ansible curated baseline provisioning --- ansible/inventory/local.ini | 2 ++ .../roles/curated_default/defaults/main.yml | 6 ++++ ansible/roles/curated_default/tasks/main.yml | 5 +++ ansible/site.yml | 6 ++++ scripts/provision/apply_baseline.sh | 35 +++++++++++++++++++ tests/bats/ansible_syntax.bats | 6 ++++ 6 files changed, 60 insertions(+) create mode 100644 ansible/inventory/local.ini create mode 100644 ansible/roles/curated_default/defaults/main.yml create mode 100644 ansible/roles/curated_default/tasks/main.yml create mode 100644 ansible/site.yml create mode 100755 scripts/provision/apply_baseline.sh create mode 100644 tests/bats/ansible_syntax.bats diff --git a/ansible/inventory/local.ini b/ansible/inventory/local.ini new file mode 100644 index 0000000..df8b5f6 --- /dev/null +++ b/ansible/inventory/local.ini @@ -0,0 +1,2 @@ +[all] +localhost ansible_connection=local diff --git a/ansible/roles/curated_default/defaults/main.yml b/ansible/roles/curated_default/defaults/main.yml new file mode 100644 index 0000000..238a224 --- /dev/null +++ b/ansible/roles/curated_default/defaults/main.yml @@ -0,0 +1,6 @@ +--- +curated_default_packages: + - git + - curl + - vim + - build-essential diff --git a/ansible/roles/curated_default/tasks/main.yml b/ansible/roles/curated_default/tasks/main.yml new file mode 100644 index 0000000..ef297b0 --- /dev/null +++ b/ansible/roles/curated_default/tasks/main.yml @@ -0,0 +1,5 @@ +--- +- name: Install curated baseline packages + ansible.builtin.package: + name: "{{ curated_default_packages }}" + state: present diff --git a/ansible/site.yml b/ansible/site.yml new file mode 100644 index 0000000..57075d7 --- /dev/null +++ b/ansible/site.yml @@ -0,0 +1,6 @@ +--- +- name: Apply Ghostdrive curated baseline + hosts: all + become: true + roles: + - curated_default diff --git a/scripts/provision/apply_baseline.sh b/scripts/provision/apply_baseline.sh new file mode 100755 index 0000000..b1b3bf3 --- /dev/null +++ b/scripts/provision/apply_baseline.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/../.." && pwd)" + +target_root="" +inventory="$repo_root/ansible/inventory/local.ini" + +while [[ $# -gt 0 ]]; do + case "$1" in + --target-root) + target_root="${2:-}" + shift 2 + ;; + --inventory) + inventory="${2:-}" + shift 2 + ;; + *) + printf 'unknown argument: %s\n' "$1" >&2 + exit 2 + ;; + esac +done + +if [[ -z "$target_root" ]]; then + printf '%s\n' '--target-root is required' >&2 + exit 2 +fi + +ansible-playbook \ + -i "$inventory" \ + "$repo_root/ansible/site.yml" \ + -e "target_root=$target_root" diff --git a/tests/bats/ansible_syntax.bats b/tests/bats/ansible_syntax.bats new file mode 100644 index 0000000..9f87048 --- /dev/null +++ b/tests/bats/ansible_syntax.bats @@ -0,0 +1,6 @@ +#!/usr/bin/env bats + +@test "ansible playbook passes syntax check" { + run ansible-playbook -i ansible/inventory/local.ini ansible/site.yml --syntax-check + [ "$status" -eq 0 ] +} From 34921efb51416204ca4b6e0561734f4044109c3b Mon Sep 17 00:00:00 2001 From: William Hutson <555966+wilrnh@users.noreply.github.com> Date: Sun, 13 Sep 2026 20:46:13 -0400 Subject: [PATCH 06/19] fix: target rootfs provisioning via chroot inventory --- ansible/inventory/local.ini | 4 +- ansible/site.yml | 2 +- scripts/provision/apply_baseline.sh | 42 +++++++++++++++++++- tests/bats/ansible_syntax.bats | 60 +++++++++++++++++++++++++++++ 4 files changed, 103 insertions(+), 5 deletions(-) diff --git a/ansible/inventory/local.ini b/ansible/inventory/local.ini index df8b5f6..15d5ce5 100644 --- a/ansible/inventory/local.ini +++ b/ansible/inventory/local.ini @@ -1,2 +1,2 @@ -[all] -localhost ansible_connection=local +[ghostdrive_target] +target ansible_connection=chroot ansible_host=/ diff --git a/ansible/site.yml b/ansible/site.yml index 57075d7..a1ef0bb 100644 --- a/ansible/site.yml +++ b/ansible/site.yml @@ -1,6 +1,6 @@ --- - name: Apply Ghostdrive curated baseline - hosts: all + hosts: ghostdrive_target become: true roles: - curated_default diff --git a/scripts/provision/apply_baseline.sh b/scripts/provision/apply_baseline.sh index b1b3bf3..a34d924 100755 --- a/scripts/provision/apply_baseline.sh +++ b/scripts/provision/apply_baseline.sh @@ -7,18 +7,36 @@ repo_root="$(cd "$script_dir/../.." && pwd)" target_root="" inventory="$repo_root/ansible/inventory/local.ini" +usage() { + printf '%s\n' 'usage: apply_baseline.sh --target-root [--inventory ]' +} + +require_value() { + local flag="$1" + local value="${2:-}" + + if [[ -z "$value" || "$value" == --* ]]; then + printf 'missing value for %s\n' "$flag" >&2 + usage >&2 + exit 2 + fi +} + while [[ $# -gt 0 ]]; do case "$1" in --target-root) - target_root="${2:-}" + require_value "$1" "${2:-}" + target_root="$2" shift 2 ;; --inventory) - inventory="${2:-}" + require_value "$1" "${2:-}" + inventory="$2" shift 2 ;; *) printf 'unknown argument: %s\n' "$1" >&2 + usage >&2 exit 2 ;; esac @@ -26,10 +44,30 @@ done if [[ -z "$target_root" ]]; then printf '%s\n' '--target-root is required' >&2 + usage >&2 + exit 2 +fi + +if [[ ! -d "$target_root" ]]; then + printf 'target root does not exist: %s\n' "$target_root" >&2 exit 2 fi +if [[ ! -f "$inventory" ]]; then + printf 'inventory does not exist: %s\n' "$inventory" >&2 + exit 2 +fi + +tmp_inventory="$(mktemp "${TMPDIR:-/tmp}/ghostdrive-chroot-inventory.XXXXXX")" +trap 'rm -f "$tmp_inventory"' EXIT + +cat >"$tmp_inventory" <"$stub_bin" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "$@" > "$BATS_TEST_TMPDIR/ansible_stub.log" +for arg in "$@"; do + if [[ "$arg" == *ghostdrive-chroot-inventory.* ]]; then + cat "$arg" > "$BATS_TEST_TMPDIR/generated_inventory.log" + break + fi +done +exit 0 +EOF + chmod +x "$stub_bin" + + export PATH="$BATS_TEST_TMPDIR:$PATH" + + rootfs_dir="$BATS_TEST_TMPDIR/rootfs" + custom_inventory="$BATS_TEST_TMPDIR/custom.ini" + mkdir -p "$rootfs_dir" + printf '%s\n' '[all]' > "$custom_inventory" + + run scripts/provision/apply_baseline.sh --target-root "$rootfs_dir" --inventory "$custom_inventory" + [ "$status" -eq 0 ] + + run grep -F -- "-i" "$stub_log" + [ "$status" -eq 0 ] + + run grep -F -- "$custom_inventory" "$stub_log" + [ "$status" -eq 0 ] + + run awk '/ghostdrive-chroot-inventory\./ { print; exit }' "$stub_log" + [ "$status" -eq 0 ] + + run grep -F -- "ansible_connection=chroot" "$inventory_dump" + [ "$status" -eq 0 ] + + run grep -F -- "ansible_host=$rootfs_dir" "$inventory_dump" + [ "$status" -eq 0 ] + + run grep -F -- "target_root=$rootfs_dir" "$stub_log" + [ "$status" -eq 0 ] +} From fcf4a59d2304e2e9293b9a900f66bcbd5207b573 Mon Sep 17 00:00:00 2001 From: William Hutson <555966+wilrnh@users.noreply.github.com> Date: Sun, 13 Sep 2026 20:50:26 -0400 Subject: [PATCH 07/19] feat: add image generalize capture and restore scripts --- scripts/image/capture.sh | 62 ++++++++++++++++++++++++++ scripts/image/generalize.sh | 56 ++++++++++++++++++++++++ scripts/image/restore.sh | 64 +++++++++++++++++++++++++++ scripts/image/write_manifest.sh | 67 +++++++++++++++++++++++++++++ tests/bats/image_pipeline_args.bats | 7 +++ 5 files changed, 256 insertions(+) create mode 100755 scripts/image/capture.sh create mode 100755 scripts/image/generalize.sh create mode 100755 scripts/image/restore.sh create mode 100755 scripts/image/write_manifest.sh create mode 100644 tests/bats/image_pipeline_args.bats diff --git a/scripts/image/capture.sh b/scripts/image/capture.sh new file mode 100755 index 0000000..f04a7ef --- /dev/null +++ b/scripts/image/capture.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + printf '%s\n' 'usage: capture.sh --device --output ' +} + +require_value() { + local flag="$1" + local value="${2:-}" + + if [[ -z "$value" || "$value" == --* ]]; then + printf 'missing value for %s\n' "$flag" >&2 + usage >&2 + exit 2 + fi +} + +device="" +output="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --device) + require_value "$1" "${2:-}" + device="$2" + shift 2 + ;; + --output) + require_value "$1" "${2:-}" + output="$2" + shift 2 + ;; + *) + printf 'unknown argument: %s\n' "$1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if [[ -z "$device" ]]; then + printf '%s\n' '--device is required' >&2 + usage >&2 + exit 2 +fi + +if [[ -z "$output" ]]; then + printf '%s\n' '--output is required' >&2 + usage >&2 + exit 2 +fi + +if [[ ! -b "$device" ]]; then + printf 'device is not a block device: %s\n' "$device" >&2 + exit 2 +fi + +output_dir="$(dirname "$output")" +mkdir -p "$output_dir" + +dd if="$device" bs=16M status=progress | zstd -19 -T0 -o "$output" diff --git a/scripts/image/generalize.sh b/scripts/image/generalize.sh new file mode 100755 index 0000000..dba537a --- /dev/null +++ b/scripts/image/generalize.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + printf '%s\n' 'usage: generalize.sh --root ' +} + +require_value() { + local flag="$1" + local value="${2:-}" + + if [[ -z "$value" || "$value" == --* ]]; then + printf 'missing value for %s\n' "$flag" >&2 + usage >&2 + exit 2 + fi +} + +root="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --root) + require_value "$1" "${2:-}" + root="$2" + shift 2 + ;; + *) + printf 'unknown argument: %s\n' "$1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if [[ -z "$root" ]]; then + printf '%s\n' '--root is required' >&2 + usage >&2 + exit 2 +fi + +if [[ ! -d "$root" ]]; then + printf 'root path does not exist: %s\n' "$root" >&2 + exit 2 +fi + +root_realpath="$(readlink -f "$root")" +if [[ "$root_realpath" == "/" ]]; then + printf 'refusing to generalize host root: %s\n' "$root_realpath" >&2 + exit 2 +fi + +truncate -s 0 "$root_realpath/etc/machine-id" +rm -f "$root_realpath/var/lib/dbus/machine-id" +rm -f "$root_realpath"/etc/ssh/ssh_host_* +rm -rf "$root_realpath"/var/log/* diff --git a/scripts/image/restore.sh b/scripts/image/restore.sh new file mode 100755 index 0000000..e19a182 --- /dev/null +++ b/scripts/image/restore.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + printf '%s\n' 'usage: restore.sh --image --device ' +} + +require_value() { + local flag="$1" + local value="${2:-}" + + if [[ -z "$value" || "$value" == --* ]]; then + printf 'missing value for %s\n' "$flag" >&2 + usage >&2 + exit 2 + fi +} + +image="" +device="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --image) + require_value "$1" "${2:-}" + image="$2" + shift 2 + ;; + --device) + require_value "$1" "${2:-}" + device="$2" + shift 2 + ;; + *) + printf 'unknown argument: %s\n' "$1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if [[ -z "$image" ]]; then + printf '%s\n' '--image is required' >&2 + usage >&2 + exit 2 +fi + +if [[ -z "$device" ]]; then + printf '%s\n' '--device is required' >&2 + usage >&2 + exit 2 +fi + +if [[ ! -f "$image" ]]; then + printf 'image file does not exist: %s\n' "$image" >&2 + exit 2 +fi + +if [[ ! -b "$device" ]]; then + printf 'device is not a block device: %s\n' "$device" >&2 + exit 2 +fi + +zstd -dc "$image" | dd of="$device" bs=16M status=progress conv=fsync diff --git a/scripts/image/write_manifest.sh b/scripts/image/write_manifest.sh new file mode 100755 index 0000000..d9881b3 --- /dev/null +++ b/scripts/image/write_manifest.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + printf '%s\n' 'usage: write_manifest.sh --image --output ' +} + +require_value() { + local flag="$1" + local value="${2:-}" + + if [[ -z "$value" || "$value" == --* ]]; then + printf 'missing value for %s\n' "$flag" >&2 + usage >&2 + exit 2 + fi +} + +image="" +output="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --image) + require_value "$1" "${2:-}" + image="$2" + shift 2 + ;; + --output) + require_value "$1" "${2:-}" + output="$2" + shift 2 + ;; + *) + printf 'unknown argument: %s\n' "$1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if [[ -z "$image" ]]; then + printf '%s\n' '--image is required' >&2 + usage >&2 + exit 2 +fi + +if [[ -z "$output" ]]; then + printf '%s\n' '--output is required' >&2 + usage >&2 + exit 2 +fi + +if [[ ! -f "$image" ]]; then + printf 'image file does not exist: %s\n' "$image" >&2 + exit 2 +fi + +sha256="$(sha256sum "$image" | awk '{print $1}')" +size_bytes="$(wc -c < "$image" | tr -d ' ')" + +output_dir="$(dirname "$output")" +mkdir -p "$output_dir" + +cat >"$output" < Date: Sun, 13 Sep 2026 20:55:33 -0400 Subject: [PATCH 08/19] feat: add CI checks and boot verification entrypoints --- .github/workflows/ci.yml | 37 +++++++++++ Makefile | 6 +- scripts/verify/boot_smoke_bios.sh | 77 ++++++++++++++++++++++ scripts/verify/boot_smoke_uefi.sh | 104 ++++++++++++++++++++++++++++++ tests/bats/ci_commands.bats | 8 +++ 5 files changed, 231 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ci.yml create mode 100755 scripts/verify/boot_smoke_bios.sh create mode 100755 scripts/verify/boot_smoke_uefi.sh create mode 100644 tests/bats/ci_commands.bats diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1b0a87e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,37 @@ +name: ci + +on: + pull_request: + push: + branches: + - main + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: sudo apt-get update + - run: sudo apt-get install -y python3-pip + - run: python3 -m pip install --user check-jsonschema + - run: ~/.local/bin/check-jsonschema --help + - run: scripts/validate-config.sh config/defaults.yaml + + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: sudo apt-get update + - run: sudo apt-get install -y bats ansible python3-pip + - run: python3 -m pip install --user check-jsonschema + - run: PATH="$HOME/.local/bin:$PATH" make ci-check + + verify-boot: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: sudo apt-get update + - run: sudo apt-get install -y qemu-system-x86 ovmf + - run: truncate -s 128M /tmp/ghostdrive-boot-smoke.img + - run: scripts/verify/boot_smoke_bios.sh --image /tmp/ghostdrive-boot-smoke.img --timeout-seconds 5 + - run: scripts/verify/boot_smoke_uefi.sh --image /tmp/ghostdrive-boot-smoke.img --timeout-seconds 5 diff --git a/Makefile b/Makefile index 62d5b2a..8394f17 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,8 @@ -.PHONY: test +.PHONY: test ci-check test: bats tests/bats + +ci-check: + scripts/validate-config.sh config/defaults.yaml + bats tests/bats diff --git a/scripts/verify/boot_smoke_bios.sh b/scripts/verify/boot_smoke_bios.sh new file mode 100755 index 0000000..8fc047b --- /dev/null +++ b/scripts/verify/boot_smoke_bios.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +set -euo pipefail + +IMAGE="" +TIMEOUT_SECONDS=20 + +usage() { + printf '%s\n' 'usage: boot_smoke_bios.sh --image [--timeout-seconds ]' +} + +require_value() { + local flag="$1" + local value="${2:-}" + + if [[ -z "$value" || "$value" == --* ]]; then + printf 'missing value for %s\n' "$flag" >&2 + usage >&2 + exit 2 + fi +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --image) + require_value "$1" "${2:-}" + IMAGE="$2" + shift 2 + ;; + --timeout-seconds) + require_value "$1" "${2:-}" + TIMEOUT_SECONDS="$2" + shift 2 + ;; + *) + printf 'unknown argument: %s\n' "$1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if [[ -z "$IMAGE" ]]; then + printf '%s\n' '--image is required' >&2 + usage >&2 + exit 2 +fi + +if [[ ! -f "$IMAGE" ]]; then + printf 'image does not exist: %s\n' "$IMAGE" >&2 + exit 2 +fi + +if ! command -v qemu-system-x86_64 >/dev/null 2>&1; then + printf '%s\n' 'qemu-system-x86_64 is required but was not found on PATH' >&2 + exit 1 +fi + +set +e +timeout "$TIMEOUT_SECONDS" qemu-system-x86_64 \ + -machine pc \ + -m 1024 \ + -nographic \ + -serial mon:stdio \ + -display none \ + -no-reboot \ + -snapshot \ + -drive "file=$IMAGE,format=raw,if=virtio" +exit_code=$? +set -e + +if [[ "$exit_code" -eq 0 || "$exit_code" -eq 124 ]]; then + printf 'bios boot smoke completed (exit=%s)\n' "$exit_code" + exit 0 +fi + +printf 'bios boot smoke failed (exit=%s)\n' "$exit_code" >&2 +exit "$exit_code" diff --git a/scripts/verify/boot_smoke_uefi.sh b/scripts/verify/boot_smoke_uefi.sh new file mode 100755 index 0000000..3a6df12 --- /dev/null +++ b/scripts/verify/boot_smoke_uefi.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +set -euo pipefail + +IMAGE="" +TIMEOUT_SECONDS=20 + +usage() { + printf '%s\n' 'usage: boot_smoke_uefi.sh --image [--timeout-seconds ]' +} + +require_value() { + local flag="$1" + local value="${2:-}" + + if [[ -z "$value" || "$value" == --* ]]; then + printf 'missing value for %s\n' "$flag" >&2 + usage >&2 + exit 2 + fi +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --image) + require_value "$1" "${2:-}" + IMAGE="$2" + shift 2 + ;; + --timeout-seconds) + require_value "$1" "${2:-}" + TIMEOUT_SECONDS="$2" + shift 2 + ;; + *) + printf 'unknown argument: %s\n' "$1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if [[ -z "$IMAGE" ]]; then + printf '%s\n' '--image is required' >&2 + usage >&2 + exit 2 +fi + +if [[ ! -f "$IMAGE" ]]; then + printf 'image does not exist: %s\n' "$IMAGE" >&2 + exit 2 +fi + +if ! command -v qemu-system-x86_64 >/dev/null 2>&1; then + printf '%s\n' 'qemu-system-x86_64 is required but was not found on PATH' >&2 + exit 1 +fi + +ovmf_code="" +ovmf_vars="" +for candidate in /usr/share/OVMF/OVMF_CODE.fd /usr/share/edk2/ovmf/OVMF_CODE.fd; do + if [[ -f "$candidate" ]]; then + ovmf_code="$candidate" + break + fi +done + +for candidate in /usr/share/OVMF/OVMF_VARS.fd /usr/share/edk2/ovmf/OVMF_VARS.fd; do + if [[ -f "$candidate" ]]; then + ovmf_vars="$candidate" + break + fi +done + +if [[ -z "$ovmf_code" || -z "$ovmf_vars" ]]; then + printf '%s\n' 'OVMF firmware files were not found; install ovmf/edk2-ovmf packages' >&2 + exit 1 +fi + +vars_copy="$(mktemp "${TMPDIR:-/tmp}/ghostdrive-ovmf-vars.XXXXXX")" +trap 'rm -f "$vars_copy"' EXIT +cp "$ovmf_vars" "$vars_copy" + +set +e +timeout "$TIMEOUT_SECONDS" qemu-system-x86_64 \ + -machine q35 \ + -m 1024 \ + -nographic \ + -serial mon:stdio \ + -display none \ + -no-reboot \ + -snapshot \ + -drive if=pflash,format=raw,readonly=on,file="$ovmf_code" \ + -drive if=pflash,format=raw,file="$vars_copy" \ + -drive "file=$IMAGE,format=raw,if=virtio" +exit_code=$? +set -e + +if [[ "$exit_code" -eq 0 || "$exit_code" -eq 124 ]]; then + printf 'uefi boot smoke completed (exit=%s)\n' "$exit_code" + exit 0 +fi + +printf 'uefi boot smoke failed (exit=%s)\n' "$exit_code" >&2 +exit "$exit_code" diff --git a/tests/bats/ci_commands.bats b/tests/bats/ci_commands.bats new file mode 100644 index 0000000..59b483b --- /dev/null +++ b/tests/bats/ci_commands.bats @@ -0,0 +1,8 @@ +#!/usr/bin/env bats + +@test "make ci-check invokes validation and tests" { + run make -n ci-check + [ "$status" -eq 0 ] + [[ "$output" == *"scripts/validate-config.sh config/defaults.yaml"* ]] + [[ "$output" == *"bats tests/bats"* ]] +} From 770a2eaadb0f12efac666da8b91bcf50fd4c0ace Mon Sep 17 00:00:00 2001 From: William Hutson <555966+wilrnh@users.noreply.github.com> Date: Sun, 13 Sep 2026 20:59:05 -0400 Subject: [PATCH 09/19] fix: require explicit boot signals in smoke verification --- .github/workflows/ci.yml | 10 ++-- scripts/verify/boot_smoke_bios.sh | 25 ++++++++-- scripts/verify/boot_smoke_uefi.sh | 58 ++++++++++++++-------- tests/bats/boot_smoke_signals.bats | 80 ++++++++++++++++++++++++++++++ 4 files changed, 143 insertions(+), 30 deletions(-) create mode 100644 tests/bats/boot_smoke_signals.bats diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1b0a87e..505272a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,7 +8,7 @@ on: jobs: lint: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 - run: sudo apt-get update @@ -18,7 +18,7 @@ jobs: - run: scripts/validate-config.sh config/defaults.yaml test: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 - run: sudo apt-get update @@ -27,11 +27,11 @@ jobs: - run: PATH="$HOME/.local/bin:$PATH" make ci-check verify-boot: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 - run: sudo apt-get update - run: sudo apt-get install -y qemu-system-x86 ovmf - run: truncate -s 128M /tmp/ghostdrive-boot-smoke.img - - run: scripts/verify/boot_smoke_bios.sh --image /tmp/ghostdrive-boot-smoke.img --timeout-seconds 5 - - run: scripts/verify/boot_smoke_uefi.sh --image /tmp/ghostdrive-boot-smoke.img --timeout-seconds 5 + - run: scripts/verify/boot_smoke_bios.sh --image /tmp/ghostdrive-boot-smoke.img --timeout-seconds 10 + - run: scripts/verify/boot_smoke_uefi.sh --image /tmp/ghostdrive-boot-smoke.img --timeout-seconds 10 diff --git a/scripts/verify/boot_smoke_bios.sh b/scripts/verify/boot_smoke_bios.sh index 8fc047b..0e10e70 100755 --- a/scripts/verify/boot_smoke_bios.sh +++ b/scripts/verify/boot_smoke_bios.sh @@ -55,23 +55,38 @@ if ! command -v qemu-system-x86_64 >/dev/null 2>&1; then exit 1 fi +log_file="$(mktemp "${TMPDIR:-/tmp}/ghostdrive-bios-boot-smoke.XXXXXX")" +trap 'rm -f "$log_file"' EXIT + +# Accept firmware/bootloader indicators as proof the VM reached boot code. +boot_signal_pattern='SeaBIOS|Booting from Hard Disk|Boot failed|No bootable device|iPXE' + set +e timeout "$TIMEOUT_SECONDS" qemu-system-x86_64 \ -machine pc \ -m 1024 \ -nographic \ - -serial mon:stdio \ + -serial stdio \ + -monitor none \ -display none \ -no-reboot \ -snapshot \ - -drive "file=$IMAGE,format=raw,if=virtio" + -drive "file=$IMAGE,format=raw,if=virtio" \ + >"$log_file" 2>&1 exit_code=$? set -e -if [[ "$exit_code" -eq 0 || "$exit_code" -eq 124 ]]; then - printf 'bios boot smoke completed (exit=%s)\n' "$exit_code" +if grep -Eiq "$boot_signal_pattern" "$log_file" && [[ "$exit_code" -eq 0 || "$exit_code" -eq 124 ]]; then + printf 'bios boot smoke completed with boot signal (exit=%s)\n' "$exit_code" exit 0 fi -printf 'bios boot smoke failed (exit=%s)\n' "$exit_code" >&2 +if [[ "$exit_code" -eq 124 ]]; then + printf 'bios boot smoke timed out without detecting boot signal\n' >&2 +else + printf 'bios boot smoke failed (exit=%s)\n' "$exit_code" >&2 +fi + +printf '%s\n' 'recent boot log:' >&2 +tail -n 40 "$log_file" >&2 || true exit "$exit_code" diff --git a/scripts/verify/boot_smoke_uefi.sh b/scripts/verify/boot_smoke_uefi.sh index 3a6df12..431f663 100755 --- a/scripts/verify/boot_smoke_uefi.sh +++ b/scripts/verify/boot_smoke_uefi.sh @@ -55,21 +55,26 @@ if ! command -v qemu-system-x86_64 >/dev/null 2>&1; then exit 1 fi -ovmf_code="" -ovmf_vars="" -for candidate in /usr/share/OVMF/OVMF_CODE.fd /usr/share/edk2/ovmf/OVMF_CODE.fd; do - if [[ -f "$candidate" ]]; then - ovmf_code="$candidate" - break - fi -done +ovmf_code="${OVMF_CODE_PATH:-}" +ovmf_vars="${OVMF_VARS_PATH:-}" -for candidate in /usr/share/OVMF/OVMF_VARS.fd /usr/share/edk2/ovmf/OVMF_VARS.fd; do - if [[ -f "$candidate" ]]; then - ovmf_vars="$candidate" - break - fi -done +if [[ -z "$ovmf_code" ]]; then + for candidate in /usr/share/OVMF/OVMF_CODE.fd /usr/share/edk2/ovmf/OVMF_CODE.fd; do + if [[ -f "$candidate" ]]; then + ovmf_code="$candidate" + break + fi + done +fi + +if [[ -z "$ovmf_vars" ]]; then + for candidate in /usr/share/OVMF/OVMF_VARS.fd /usr/share/edk2/ovmf/OVMF_VARS.fd; do + if [[ -f "$candidate" ]]; then + ovmf_vars="$candidate" + break + fi + done +fi if [[ -z "$ovmf_code" || -z "$ovmf_vars" ]]; then printf '%s\n' 'OVMF firmware files were not found; install ovmf/edk2-ovmf packages' >&2 @@ -77,28 +82,41 @@ if [[ -z "$ovmf_code" || -z "$ovmf_vars" ]]; then fi vars_copy="$(mktemp "${TMPDIR:-/tmp}/ghostdrive-ovmf-vars.XXXXXX")" -trap 'rm -f "$vars_copy"' EXIT +log_file="$(mktemp "${TMPDIR:-/tmp}/ghostdrive-uefi-boot-smoke.XXXXXX")" +trap 'rm -f "$vars_copy" "$log_file"' EXIT cp "$ovmf_vars" "$vars_copy" +# Accept firmware/boot manager indicators as proof the VM reached UEFI boot code. +boot_signal_pattern='UEFI|BdsDxe|Boot Manager|iPXE|Shell>' + set +e timeout "$TIMEOUT_SECONDS" qemu-system-x86_64 \ -machine q35 \ -m 1024 \ -nographic \ - -serial mon:stdio \ + -serial stdio \ + -monitor none \ -display none \ -no-reboot \ -snapshot \ -drive if=pflash,format=raw,readonly=on,file="$ovmf_code" \ -drive if=pflash,format=raw,file="$vars_copy" \ - -drive "file=$IMAGE,format=raw,if=virtio" + -drive "file=$IMAGE,format=raw,if=virtio" \ + >"$log_file" 2>&1 exit_code=$? set -e -if [[ "$exit_code" -eq 0 || "$exit_code" -eq 124 ]]; then - printf 'uefi boot smoke completed (exit=%s)\n' "$exit_code" +if grep -Eiq "$boot_signal_pattern" "$log_file" && [[ "$exit_code" -eq 0 || "$exit_code" -eq 124 ]]; then + printf 'uefi boot smoke completed with boot signal (exit=%s)\n' "$exit_code" exit 0 fi -printf 'uefi boot smoke failed (exit=%s)\n' "$exit_code" >&2 +if [[ "$exit_code" -eq 124 ]]; then + printf 'uefi boot smoke timed out without detecting boot signal\n' >&2 +else + printf 'uefi boot smoke failed (exit=%s)\n' "$exit_code" >&2 +fi + +printf '%s\n' 'recent boot log:' >&2 +tail -n 40 "$log_file" >&2 || true exit "$exit_code" diff --git a/tests/bats/boot_smoke_signals.bats b/tests/bats/boot_smoke_signals.bats new file mode 100644 index 0000000..9fea36f --- /dev/null +++ b/tests/bats/boot_smoke_signals.bats @@ -0,0 +1,80 @@ +#!/usr/bin/env bats + +setup() { + TEST_TMPDIR="$(mktemp -d)" + export TEST_TMPDIR + MOCK_BIN="$TEST_TMPDIR/mockbin" + mkdir -p "$MOCK_BIN" + + cat >"$MOCK_BIN/qemu-system-x86_64" <<'EOF' +#!/usr/bin/env bash +exit 0 +EOF + chmod +x "$MOCK_BIN/qemu-system-x86_64" + + cat >"$MOCK_BIN/timeout" <<'EOF' +#!/usr/bin/env bash +printf '%s\n' "${MOCK_TIMEOUT_OUTPUT:-}" +exit "${MOCK_TIMEOUT_EXIT:-124}" +EOF + chmod +x "$MOCK_BIN/timeout" + + IMAGE="$TEST_TMPDIR/disk.img" + truncate -s 16M "$IMAGE" +} + +teardown() { + rm -rf "$TEST_TMPDIR" +} + +@test "bios verify fails when timeout occurs without a boot signal" { + export PATH="$MOCK_BIN:$PATH" + export MOCK_TIMEOUT_EXIT=124 + export MOCK_TIMEOUT_OUTPUT="no recognizable signal" + + run scripts/verify/boot_smoke_bios.sh --image "$IMAGE" --timeout-seconds 1 + [ "$status" -ne 0 ] + [[ "$output" == *"timed out without detecting boot signal"* ]] +} + +@test "bios verify succeeds on timeout when a boot signal is observed" { + export PATH="$MOCK_BIN:$PATH" + export MOCK_TIMEOUT_EXIT=124 + export MOCK_TIMEOUT_OUTPUT="SeaBIOS (version rel-1.16.3)" + + run scripts/verify/boot_smoke_bios.sh --image "$IMAGE" --timeout-seconds 1 + [ "$status" -eq 0 ] + [[ "$output" == *"completed with boot signal"* ]] +} + +@test "uefi verify fails when timeout occurs without a boot signal" { + export PATH="$MOCK_BIN:$PATH" + export MOCK_TIMEOUT_EXIT=124 + export MOCK_TIMEOUT_OUTPUT="still nothing useful" + + OVMF_CODE="$TEST_TMPDIR/OVMF_CODE.fd" + OVMF_VARS="$TEST_TMPDIR/OVMF_VARS.fd" + : >"$OVMF_CODE" + : >"$OVMF_VARS" + + run env OVMF_CODE_PATH="$OVMF_CODE" OVMF_VARS_PATH="$OVMF_VARS" \ + scripts/verify/boot_smoke_uefi.sh --image "$IMAGE" --timeout-seconds 1 + [ "$status" -ne 0 ] + [[ "$output" == *"timed out without detecting boot signal"* ]] +} + +@test "uefi verify succeeds on timeout when a boot signal is observed" { + export PATH="$MOCK_BIN:$PATH" + export MOCK_TIMEOUT_EXIT=124 + export MOCK_TIMEOUT_OUTPUT="BdsDxe: loading Boot0001\nUEFI Interactive Shell" + + OVMF_CODE="$TEST_TMPDIR/OVMF_CODE.fd" + OVMF_VARS="$TEST_TMPDIR/OVMF_VARS.fd" + : >"$OVMF_CODE" + : >"$OVMF_VARS" + + run env OVMF_CODE_PATH="$OVMF_CODE" OVMF_VARS_PATH="$OVMF_VARS" \ + scripts/verify/boot_smoke_uefi.sh --image "$IMAGE" --timeout-seconds 1 + [ "$status" -eq 0 ] + [[ "$output" == *"completed with boot signal"* ]] +} From 7ae2c1f767842860fc7bd621877de44170699a1f Mon Sep 17 00:00:00 2001 From: William Hutson <555966+wilrnh@users.noreply.github.com> Date: Sun, 13 Sep 2026 21:04:20 -0400 Subject: [PATCH 10/19] docs: add user and operator runbooks --- README.md | 94 +++++++++++++++++++++++++++++++++-- docs/architecture.md | 34 +++++++++++++ docs/cloning-workflow.md | 49 ++++++++++++++++++ docs/compatibility-matrix.md | 19 +++++++ docs/provisioning.md | 39 +++++++++++++++ docs/threat-model.md | 37 ++++++++++++++ tests/bats/docs_presence.bats | 9 ++++ 7 files changed, 276 insertions(+), 5 deletions(-) create mode 100644 docs/architecture.md create mode 100644 docs/cloning-workflow.md create mode 100644 docs/compatibility-matrix.md create mode 100644 docs/provisioning.md create mode 100644 docs/threat-model.md create mode 100644 tests/bats/docs_presence.bats diff --git a/README.md b/README.md index 5423c16..6758e8a 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,101 @@ # ghostdrive -A bootable USB with everything to start hacking -## Local Development +Ghostdrive builds a reproducible Ubuntu engineering USB image using an install-to-USB pipeline. -Install local test tooling: +## V1 Scope + +- Linux build host only. +- Ubuntu LTS target only (`24.04`, `x86_64`). +- Install-to-USB architecture (not live+persistence mode). +- Curated baseline provisioning through Ansible. +- Golden image capture and restore workflow. + +## Safety and Security Rules + +- All destructive operations require an explicit `--device` argument. +- Validate configuration before install or CI checks. +- Keep secrets out of the repository. +- Generalize a target filesystem before capture to avoid cloned identity collisions. + +## Prerequisites + +Install required tools on a Linux host: ```bash sudo apt-get update -sudo apt-get install -y bats +sudo apt-get install -y \ + bats \ + ansible \ + qemu-system-x86 \ + ovmf \ + zstd \ + python3-pip +python3 -m pip install --user check-jsonschema +``` + +## Quickstart + +1. Validate config: + +```bash +scripts/validate-config.sh config/defaults.yaml ``` -Run local checks: +2. Prepare install orchestration for a target device: + +```bash +scripts/build/install_to_usb.sh \ + --config config/defaults.yaml \ + --device /dev/sdX \ + --workdir .ghostdrive-work +``` + +3. Apply curated baseline to the mounted target root filesystem: + +```bash +scripts/provision/apply_baseline.sh --target-root /mnt/ghostdrive-root +``` + +4. Generalize and capture a golden image: + +```bash +scripts/image/generalize.sh --root /mnt/ghostdrive-root +scripts/image/capture.sh --device /dev/sdX --output artifacts/ghostdrive.img.zst +scripts/image/write_manifest.sh --image artifacts/ghostdrive.img.zst --output artifacts/ghostdrive.manifest.json +``` + +5. Restore to another USB device: + +```bash +scripts/image/restore.sh --image artifacts/ghostdrive.img.zst --device /dev/sdY +``` + +## Verification + +Run all local checks: ```bash make test ``` + +CI-oriented checks: + +```bash +make ci-check +``` + +Boot smoke checks (BIOS + UEFI): + +```bash +truncate -s 128M /tmp/ghostdrive-boot-smoke.img +scripts/verify/boot_smoke_bios.sh --image /tmp/ghostdrive-boot-smoke.img --timeout-seconds 10 +scripts/verify/boot_smoke_uefi.sh --image /tmp/ghostdrive-boot-smoke.img --timeout-seconds 10 +``` + +## Documentation + +- `docs/architecture.md` +- `docs/provisioning.md` +- `docs/cloning-workflow.md` +- `docs/threat-model.md` +- `docs/compatibility-matrix.md` diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..f70bcf2 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,34 @@ +# Architecture + +Ghostdrive v1 is an install-to-USB pipeline for Ubuntu 24.04 x86_64. + +## Modules + +- Installer Orchestrator: `scripts/build/install_to_usb.sh` +- Seed Renderer: `scripts/build/render_autoinstall_seed.sh` +- Provisioner: `scripts/provision/apply_baseline.sh` and `ansible/` +- Image Pipeline: `scripts/image/generalize.sh`, `scripts/image/capture.sh`, `scripts/image/write_manifest.sh`, `scripts/image/restore.sh` +- Verifier: `scripts/verify/boot_smoke_bios.sh`, `scripts/verify/boot_smoke_uefi.sh`, and Bats tests under `tests/bats/` + +## Data Flow + +1. Validate the user configuration (`scripts/validate-config.sh`) against `config/schema.json`. +2. Prepare install orchestration for a specific target block device. +3. Apply the curated baseline profile with Ansible to the target root filesystem. +4. Generalize machine identity artifacts before capture. +5. Capture a compressed image and write checksum/size metadata. +6. Restore the image to additional USB devices. +7. Verify behavior in CI and with boot smoke checks. + +## Constraints + +- Build host: Linux only. +- Target OS and architecture: Ubuntu 24.04 LTS x86_64. +- Output model: fully installed USB image. +- Repository policy: no secrets in source control. + +## Operational Boundaries + +- Device writes are destructive and require explicit `--device` selection. +- Image scripts require real block devices and image files. +- Boot smoke scripts validate firmware-level boot signals for BIOS and UEFI paths. \ No newline at end of file diff --git a/docs/cloning-workflow.md b/docs/cloning-workflow.md new file mode 100644 index 0000000..a682e96 --- /dev/null +++ b/docs/cloning-workflow.md @@ -0,0 +1,49 @@ +# Cloning Workflow + +This workflow captures a golden image from a prepared USB install and restores it to additional devices. + +## 1. Generalize Before Capture + +Generalize the mounted target root so clones do not share host identity artifacts. + +```bash +scripts/image/generalize.sh --root /mnt/ghostdrive-root +``` + +The generalization step clears machine identity and volatile runtime artifacts: + +- `/etc/machine-id` +- `/var/lib/dbus/machine-id` +- `/etc/ssh/ssh_host_*` +- `/var/log/*` + +## 2. Capture Compressed Image + +```bash +scripts/image/capture.sh --device /dev/sdX --output artifacts/ghostdrive.img.zst +``` + +## 3. Write Artifact Manifest + +```bash +scripts/image/write_manifest.sh --image artifacts/ghostdrive.img.zst --output artifacts/ghostdrive.manifest.json +``` + +Manifest fields include image path, SHA-256 checksum, and size in bytes. + +## 4. Restore to a New USB Device + +```bash +scripts/image/restore.sh --image artifacts/ghostdrive.img.zst --device /dev/sdY +``` + +## 5. Validate Restored Media + +- Confirm device partitions and filesystem mount cleanly. +- Run BIOS and UEFI boot smoke scripts when validating pipeline behavior. +- Perform first-boot checks on hardware before fleet replication. + +## Safety Notes + +- `capture.sh` and `restore.sh` require real block devices. +- Always verify selected `--device` values before running destructive commands. \ No newline at end of file diff --git a/docs/compatibility-matrix.md b/docs/compatibility-matrix.md new file mode 100644 index 0000000..64b3bbd --- /dev/null +++ b/docs/compatibility-matrix.md @@ -0,0 +1,19 @@ +# Compatibility Matrix + +| Area | Supported in v1 | Notes | +|---|---|---| +| Build host OS | Linux | Ubuntu LTS recommended | +| Build host architecture | x86_64 | CI runs on Ubuntu 24.04 x86_64 | +| Target OS | Ubuntu 24.04 LTS | Fixed by config schema | +| Target architecture | x86_64 | Fixed by config schema | +| Output format | Installed USB image | Captured as compressed `.img.zst` | +| Provisioning | Ansible curated baseline | `ansible/site.yml` | +| VM boot verification | BIOS and UEFI smoke checks | QEMU + OVMF | +| Cross-platform builders | No | Out of scope for v1 | +| ARM targets | No | Out of scope for v1 | + +## Validation Sources + +- Config constraints: `config/schema.json` +- CI checks: `.github/workflows/ci.yml` +- Boot smoke scripts: `scripts/verify/boot_smoke_bios.sh`, `scripts/verify/boot_smoke_uefi.sh` \ No newline at end of file diff --git a/docs/provisioning.md b/docs/provisioning.md new file mode 100644 index 0000000..b736bbf --- /dev/null +++ b/docs/provisioning.md @@ -0,0 +1,39 @@ +# Provisioning Runbook + +This runbook applies the curated Ghostdrive baseline to an installed target root filesystem. + +## Inputs + +- Target root mount path (required): `--target-root` +- Optional inventory override: `--inventory` +- Baseline playbook: `ansible/site.yml` +- Baseline profile in config: `curated-default` + +## Prerequisites + +- Linux build host. +- `ansible-playbook` available on `PATH`. +- Target root filesystem mounted (for example `/mnt/ghostdrive-root`). + +## Apply Baseline + +```bash +scripts/provision/apply_baseline.sh --target-root /mnt/ghostdrive-root +``` + +The script creates a temporary chroot inventory and runs: + +```bash +ansible-playbook -i ansible/inventory/local.ini -i ansible/site.yml -e target_root=/mnt/ghostdrive-root +``` + +## Common Failures + +- `--target-root is required`: pass a mount path. +- `target root does not exist`: mount the target filesystem first. +- `inventory does not exist`: check the `--inventory` path. + +## Post-Run Checks + +- Confirm baseline packages and configuration are present on target. +- Continue to generalization and image capture workflow. \ No newline at end of file diff --git a/docs/threat-model.md b/docs/threat-model.md new file mode 100644 index 0000000..d993f12 --- /dev/null +++ b/docs/threat-model.md @@ -0,0 +1,37 @@ +# Threat Model + +## Scope + +Ghostdrive v1 produces cloneable Ubuntu USB images for engineering environments. + +Assumptions: + +- Build host is Linux. +- Target is Ubuntu 24.04 x86_64. +- Repository stores no credentials, keys, or other secrets. + +## Primary Risks + +- Cloned identity collisions from shared machine IDs or SSH host keys. +- Accidental overwrite of the wrong block device during capture or restore. +- Artifact tampering between capture and restore. +- Configuration drift from unvalidated config files. + +## Mitigations + +- Run `scripts/image/generalize.sh` before image capture. +- Require explicit `--device` flags for destructive commands. +- Publish and verify manifest checksums generated by `scripts/image/write_manifest.sh`. +- Enforce config validation with `scripts/validate-config.sh` in local and CI workflows. + +## Operational Controls + +- Keep artifact storage access controlled and auditable. +- Require operator verification of source and destination devices. +- Run boot smoke checks as a pre-release gate. + +## Out of Scope for v1 + +- Windows/macOS build hosts. +- ARM targets. +- Secret injection and secret management inside this repository. \ No newline at end of file diff --git a/tests/bats/docs_presence.bats b/tests/bats/docs_presence.bats new file mode 100644 index 0000000..87564a1 --- /dev/null +++ b/tests/bats/docs_presence.bats @@ -0,0 +1,9 @@ +#!/usr/bin/env bats + +@test "required operator docs exist" { + [ -f docs/architecture.md ] + [ -f docs/provisioning.md ] + [ -f docs/cloning-workflow.md ] + [ -f docs/threat-model.md ] + [ -f docs/compatibility-matrix.md ] +} \ No newline at end of file From b3147f595e85008c0285ca3441303e47e894d5b1 Mon Sep 17 00:00:00 2001 From: William Hutson <555966+wilrnh@users.noreply.github.com> Date: Sun, 13 Sep 2026 21:20:45 -0400 Subject: [PATCH 11/19] docs: strengthen README safety and prereq guidance --- README.md | 7 +++++++ tests/bats/readme_safety_prereqs.bats | 13 +++++++++++++ 2 files changed, 20 insertions(+) create mode 100644 tests/bats/readme_safety_prereqs.bats diff --git a/README.md b/README.md index 6758e8a..cd93a9f 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ Ghostdrive builds a reproducible Ubuntu engineering USB image using an install-t ## Safety and Security Rules - All destructive operations require an explicit `--device` argument. +- For non-CI runs, require an interactive human confirmation after printing the selected block device before any destructive write step. - Validate configuration before install or CI checks. - Keep secrets out of the repository. - Generalize a target filesystem before capture to avoid cloned identity collisions. @@ -31,8 +32,12 @@ sudo apt-get install -y \ zstd \ python3-pip python3 -m pip install --user check-jsonschema +export PATH="$HOME/.local/bin:$PATH" +command -v check-jsonschema ``` +If `command -v check-jsonschema` prints nothing, your shell is missing `$HOME/.local/bin` on PATH. + ## Quickstart 1. Validate config: @@ -50,6 +55,8 @@ scripts/build/install_to_usb.sh \ --workdir .ghostdrive-work ``` +For non-CI usage, review the printed `/dev/sdX` target and complete the interactive confirmation before allowing the install step to proceed. + 3. Apply curated baseline to the mounted target root filesystem: ```bash diff --git a/tests/bats/readme_safety_prereqs.bats b/tests/bats/readme_safety_prereqs.bats new file mode 100644 index 0000000..076b7c1 --- /dev/null +++ b/tests/bats/readme_safety_prereqs.bats @@ -0,0 +1,13 @@ +#!/usr/bin/env bats + +@test "README includes non-CI confirmation safety guidance" { + grep -F "For non-CI runs, require an interactive human confirmation after printing the selected block device before any destructive write step." README.md + grep -F "For non-CI usage, review the printed \`/dev/sdX\` target and complete the interactive confirmation before allowing the install step to proceed." README.md +} + +@test "README clarifies check-jsonschema user PATH requirement" { + grep -F 'python3 -m pip install --user check-jsonschema' README.md + grep -F 'export PATH="$HOME/.local/bin:$PATH"' README.md + grep -F 'command -v check-jsonschema' README.md + grep -F 'If `command -v check-jsonschema` prints nothing, your shell is missing `$HOME/.local/bin` on PATH.' README.md +} From 51fa5b8a2ac1f36db58c0d85d1cddddcfd016a58 Mon Sep 17 00:00:00 2001 From: William Hutson <555966+wilrnh@users.noreply.github.com> Date: Sun, 13 Sep 2026 21:31:21 -0400 Subject: [PATCH 12/19] fix: harden boot checks and destructive image safety --- .github/workflows/ci.yml | 4 +- README.md | 5 +- scripts/build/install_to_usb.sh | 10 ++++ scripts/build/render_autoinstall_seed.sh | 27 ++++++++- scripts/image/capture.sh | 13 +++++ scripts/image/restore.sh | 73 +++++++++++++++++++++++- scripts/lib/common.sh | 48 ++++++++++++++++ scripts/verify/boot_smoke_bios.sh | 18 ++++-- scripts/verify/boot_smoke_uefi.sh | 18 ++++-- tests/bats/boot_smoke_signals.bats | 40 ++++++++++--- tests/bats/image_pipeline_args.bats | 33 +++++++++++ tests/bats/install_to_usb_args.bats | 30 +++++++++- 12 files changed, 296 insertions(+), 23 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 505272a..81c7b0a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,5 +33,5 @@ jobs: - run: sudo apt-get update - run: sudo apt-get install -y qemu-system-x86 ovmf - run: truncate -s 128M /tmp/ghostdrive-boot-smoke.img - - run: scripts/verify/boot_smoke_bios.sh --image /tmp/ghostdrive-boot-smoke.img --timeout-seconds 10 - - run: scripts/verify/boot_smoke_uefi.sh --image /tmp/ghostdrive-boot-smoke.img --timeout-seconds 10 + - run: if scripts/verify/boot_smoke_bios.sh --image /tmp/ghostdrive-boot-smoke.img --timeout-seconds 10; then echo "unexpected BIOS smoke success for blank image"; exit 1; fi + - run: if scripts/verify/boot_smoke_uefi.sh --image /tmp/ghostdrive-boot-smoke.img --timeout-seconds 10; then echo "unexpected UEFI smoke success for blank image"; exit 1; fi diff --git a/README.md b/README.md index cd93a9f..00cc1eb 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,10 @@ scripts/image/write_manifest.sh --image artifacts/ghostdrive.img.zst --output ar 5. Restore to another USB device: ```bash -scripts/image/restore.sh --image artifacts/ghostdrive.img.zst --device /dev/sdY +scripts/image/restore.sh \ + --image artifacts/ghostdrive.img.zst \ + --manifest artifacts/ghostdrive.manifest.json \ + --device /dev/sdY ``` ## Verification diff --git a/scripts/build/install_to_usb.sh b/scripts/build/install_to_usb.sh index dbbeaeb..8979619 100755 --- a/scripts/build/install_to_usb.sh +++ b/scripts/build/install_to_usb.sh @@ -4,9 +4,13 @@ set -euo pipefail script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" repo_root="$(cd "$script_dir/../.." && pwd)" +# shellcheck source=scripts/lib/common.sh +source "$repo_root/scripts/lib/common.sh" + CONFIG="" DEVICE="" WORKDIR="" +YES=0 while [[ $# -gt 0 ]]; do case "$1" in @@ -25,6 +29,10 @@ while [[ $# -gt 0 ]]; do WORKDIR="$2" shift 2 ;; + --yes) + YES=1 + shift + ;; *) echo "unknown argument: $1" exit 2 @@ -37,5 +45,7 @@ done "$repo_root/scripts/validate-config.sh" "$CONFIG" +confirm_destructive_action "$DEVICE" "$YES" "install" || exit $? + mkdir -p "${WORKDIR:-.ghostdrive-work}" echo "install orchestration prepared" diff --git a/scripts/build/render_autoinstall_seed.sh b/scripts/build/render_autoinstall_seed.sh index 243e841..16b20ab 100755 --- a/scripts/build/render_autoinstall_seed.sh +++ b/scripts/build/render_autoinstall_seed.sh @@ -1,9 +1,25 @@ #!/usr/bin/env bash set -euo pipefail +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/../.." && pwd)" + CONFIG="" OUTDIR="" +read_config_value() { + local key="$1" + local value + + value="$(grep -E "^${key}:[[:space:]]*" "$CONFIG" | head -n1 | sed -E "s/^${key}:[[:space:]]*\"?([^\"]+)\"?[[:space:]]*$/\1/")" + if [[ -z "$value" ]]; then + echo "missing required config key: $key" >&2 + exit 1 + fi + + printf '%s\n' "$value" +} + while [[ $# -gt 0 ]]; do case "$1" in --config) @@ -26,9 +42,18 @@ done [[ -n "$CONFIG" ]] || { echo "--config is required"; exit 2; } [[ -n "$OUTDIR" ]] || { echo "--outdir is required"; exit 2; } +"$repo_root/scripts/validate-config.sh" "$CONFIG" + +ubuntu_release="$(read_config_value ubuntu_release)" +target_arch="$(read_config_value target_arch)" +baseline_profile="$(read_config_value baseline_profile)" + mkdir -p "$OUTDIR" -cat >"$OUTDIR/user-data" <<'EOF' +cat >"$OUTDIR/user-data" < --output ' } @@ -18,6 +24,7 @@ require_value() { device="" output="" +yes=0 while [[ $# -gt 0 ]]; do case "$1" in @@ -31,6 +38,10 @@ while [[ $# -gt 0 ]]; do output="$2" shift 2 ;; + --yes) + yes=1 + shift + ;; *) printf 'unknown argument: %s\n' "$1" >&2 usage >&2 @@ -56,6 +67,8 @@ if [[ ! -b "$device" ]]; then exit 2 fi +confirm_destructive_action "$device" "$yes" "capture" || exit $? + output_dir="$(dirname "$output")" mkdir -p "$output_dir" diff --git a/scripts/image/restore.sh b/scripts/image/restore.sh index e19a182..02ac134 100755 --- a/scripts/image/restore.sh +++ b/scripts/image/restore.sh @@ -1,8 +1,14 @@ #!/usr/bin/env bash set -euo pipefail +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/../.." && pwd)" + +# shellcheck source=scripts/lib/common.sh +source "$repo_root/scripts/lib/common.sh" + usage() { - printf '%s\n' 'usage: restore.sh --image --device ' + printf '%s\n' 'usage: restore.sh --image --device [--manifest ] [--sha256 ] [--yes]' } require_value() { @@ -18,6 +24,28 @@ require_value() { image="" device="" +manifest="" +expected_sha="" +yes=0 + +manifest_sha256() { + local manifest_path="$1" + + python3 - "$manifest_path" <<'PY' +import json +import sys + +path = sys.argv[1] +with open(path, "r", encoding="utf-8") as fh: + data = json.load(fh) + +value = data.get("sha256", "") +if not isinstance(value, str) or not value.strip(): + raise SystemExit(1) + +print(value.strip()) +PY +} while [[ $# -gt 0 ]]; do case "$1" in @@ -31,6 +59,20 @@ while [[ $# -gt 0 ]]; do device="$2" shift 2 ;; + --manifest) + require_value "$1" "${2:-}" + manifest="$2" + shift 2 + ;; + --sha256) + require_value "$1" "${2:-}" + expected_sha="$2" + shift 2 + ;; + --yes) + yes=1 + shift + ;; *) printf 'unknown argument: %s\n' "$1" >&2 usage >&2 @@ -56,9 +98,38 @@ if [[ ! -f "$image" ]]; then exit 2 fi +if [[ -n "$manifest" ]]; then + if [[ ! -f "$manifest" ]]; then + printf 'manifest file does not exist: %s\n' "$manifest" >&2 + exit 2 + fi + + if ! manifest_expected_sha="$(manifest_sha256 "$manifest")"; then + printf 'manifest is missing a valid sha256: %s\n' "$manifest" >&2 + exit 2 + fi + + if [[ -n "$expected_sha" && "$expected_sha" != "$manifest_expected_sha" ]]; then + printf 'provided --sha256 does not match manifest sha256\n' >&2 + exit 2 + fi + + expected_sha="$manifest_expected_sha" +fi + +if [[ -n "$expected_sha" ]]; then + actual_sha="$(sha256sum "$image" | awk '{print $1}')" + if [[ "$actual_sha" != "$expected_sha" ]]; then + printf 'image checksum mismatch: expected %s, got %s\n' "$expected_sha" "$actual_sha" >&2 + exit 1 + fi +fi + if [[ ! -b "$device" ]]; then printf 'device is not a block device: %s\n' "$device" >&2 exit 2 fi +confirm_destructive_action "$device" "$yes" "restore" || exit $? + zstd -dc "$image" | dd of="$device" bs=16M status=progress conv=fsync diff --git a/scripts/lib/common.sh b/scripts/lib/common.sh index 9cc1a6e..f9b4071 100644 --- a/scripts/lib/common.sh +++ b/scripts/lib/common.sh @@ -11,3 +11,51 @@ log_info() { log_error() { printf '[ERROR] %s\n' "$1" >&2 } + +is_ci_mode() { + case "${CI:-}" in + 1|true|TRUE|yes|YES) + return 0 + ;; + esac + + case "${GITHUB_ACTIONS:-}" in + 1|true|TRUE|yes|YES) + return 0 + ;; + esac + + return 1 +} + +confirm_destructive_action() { + device="$1" + yes_flag="$2" + action_name="$3" + + log_info "$action_name target device: $device" + + if [ "$yes_flag" = "1" ]; then + log_info "confirmation bypassed via --yes" + return 0 + fi + + if is_ci_mode; then + log_info "CI mode detected; skipping interactive confirmation" + return 0 + fi + + if [ ! -t 0 ] || [ ! -t 1 ]; then + log_error "non-interactive mode requires --yes for destructive operations" + return 2 + fi + + printf 'Type YES to continue: ' + read -r confirmation + if [ "$confirmation" != "YES" ]; then + log_error "confirmation declined; aborting" + return 2 + fi + + return 0 +} diff --git a/scripts/verify/boot_smoke_bios.sh b/scripts/verify/boot_smoke_bios.sh index 0e10e70..3d4039f 100755 --- a/scripts/verify/boot_smoke_bios.sh +++ b/scripts/verify/boot_smoke_bios.sh @@ -58,8 +58,9 @@ fi log_file="$(mktemp "${TMPDIR:-/tmp}/ghostdrive-bios-boot-smoke.XXXXXX")" trap 'rm -f "$log_file"' EXIT -# Accept firmware/bootloader indicators as proof the VM reached boot code. -boot_signal_pattern='SeaBIOS|Booting from Hard Disk|Boot failed|No bootable device|iPXE' +# Require explicit boot progression signals, and fail hard on known negatives. +positive_signal_pattern='GNU GRUB|Welcome to GRUB|ISOLINUX|SYSLINUX|Loading Linux|Linux version' +negative_signal_pattern='No bootable device|Boot failed|Could not read from the boot medium|PXE-E[0-9]+' set +e timeout "$TIMEOUT_SECONDS" qemu-system-x86_64 \ @@ -76,13 +77,20 @@ timeout "$TIMEOUT_SECONDS" qemu-system-x86_64 \ exit_code=$? set -e -if grep -Eiq "$boot_signal_pattern" "$log_file" && [[ "$exit_code" -eq 0 || "$exit_code" -eq 124 ]]; then - printf 'bios boot smoke completed with boot signal (exit=%s)\n' "$exit_code" +if grep -Eiq "$negative_signal_pattern" "$log_file"; then + printf 'bios boot smoke observed negative boot signal\n' >&2 + printf '%s\n' 'recent boot log:' >&2 + tail -n 40 "$log_file" >&2 || true + exit 1 +fi + +if grep -Eiq "$positive_signal_pattern" "$log_file" && [[ "$exit_code" -eq 0 || "$exit_code" -eq 124 ]]; then + printf 'bios boot smoke completed with explicit boot progression signal (exit=%s)\n' "$exit_code" exit 0 fi if [[ "$exit_code" -eq 124 ]]; then - printf 'bios boot smoke timed out without detecting boot signal\n' >&2 + printf 'bios boot smoke timed out without detecting explicit boot progression signal\n' >&2 else printf 'bios boot smoke failed (exit=%s)\n' "$exit_code" >&2 fi diff --git a/scripts/verify/boot_smoke_uefi.sh b/scripts/verify/boot_smoke_uefi.sh index 431f663..558235e 100755 --- a/scripts/verify/boot_smoke_uefi.sh +++ b/scripts/verify/boot_smoke_uefi.sh @@ -86,8 +86,9 @@ log_file="$(mktemp "${TMPDIR:-/tmp}/ghostdrive-uefi-boot-smoke.XXXXXX")" trap 'rm -f "$vars_copy" "$log_file"' EXIT cp "$ovmf_vars" "$vars_copy" -# Accept firmware/boot manager indicators as proof the VM reached UEFI boot code. -boot_signal_pattern='UEFI|BdsDxe|Boot Manager|iPXE|Shell>' +# Require explicit boot progression signals, and fail hard on known negatives. +positive_signal_pattern='GNU GRUB|Welcome to GRUB|ISOLINUX|SYSLINUX|Loading Linux|Linux version|EFI stub:' +negative_signal_pattern='No bootable option or device was found|BdsDxe: failed to load|Could not read from the boot medium|iPXE' set +e timeout "$TIMEOUT_SECONDS" qemu-system-x86_64 \ @@ -106,13 +107,20 @@ timeout "$TIMEOUT_SECONDS" qemu-system-x86_64 \ exit_code=$? set -e -if grep -Eiq "$boot_signal_pattern" "$log_file" && [[ "$exit_code" -eq 0 || "$exit_code" -eq 124 ]]; then - printf 'uefi boot smoke completed with boot signal (exit=%s)\n' "$exit_code" +if grep -Eiq "$negative_signal_pattern" "$log_file"; then + printf 'uefi boot smoke observed negative boot signal\n' >&2 + printf '%s\n' 'recent boot log:' >&2 + tail -n 40 "$log_file" >&2 || true + exit 1 +fi + +if grep -Eiq "$positive_signal_pattern" "$log_file" && [[ "$exit_code" -eq 0 || "$exit_code" -eq 124 ]]; then + printf 'uefi boot smoke completed with explicit boot progression signal (exit=%s)\n' "$exit_code" exit 0 fi if [[ "$exit_code" -eq 124 ]]; then - printf 'uefi boot smoke timed out without detecting boot signal\n' >&2 + printf 'uefi boot smoke timed out without detecting explicit boot progression signal\n' >&2 else printf 'uefi boot smoke failed (exit=%s)\n' "$exit_code" >&2 fi diff --git a/tests/bats/boot_smoke_signals.bats b/tests/bats/boot_smoke_signals.bats index 9fea36f..504f7c8 100644 --- a/tests/bats/boot_smoke_signals.bats +++ b/tests/bats/boot_smoke_signals.bats @@ -30,21 +30,31 @@ teardown() { @test "bios verify fails when timeout occurs without a boot signal" { export PATH="$MOCK_BIN:$PATH" export MOCK_TIMEOUT_EXIT=124 - export MOCK_TIMEOUT_OUTPUT="no recognizable signal" + export MOCK_TIMEOUT_OUTPUT="SeaBIOS (version rel-1.16.3)" run scripts/verify/boot_smoke_bios.sh --image "$IMAGE" --timeout-seconds 1 [ "$status" -ne 0 ] - [[ "$output" == *"timed out without detecting boot signal"* ]] + [[ "$output" == *"timed out without detecting explicit boot progression signal"* ]] } @test "bios verify succeeds on timeout when a boot signal is observed" { export PATH="$MOCK_BIN:$PATH" export MOCK_TIMEOUT_EXIT=124 - export MOCK_TIMEOUT_OUTPUT="SeaBIOS (version rel-1.16.3)" + export MOCK_TIMEOUT_OUTPUT="GNU GRUB 2.06" run scripts/verify/boot_smoke_bios.sh --image "$IMAGE" --timeout-seconds 1 [ "$status" -eq 0 ] - [[ "$output" == *"completed with boot signal"* ]] + [[ "$output" == *"completed with explicit boot progression signal"* ]] +} + +@test "bios verify fails when negative signal appears even with positive token" { + export PATH="$MOCK_BIN:$PATH" + export MOCK_TIMEOUT_EXIT=124 + export MOCK_TIMEOUT_OUTPUT="GNU GRUB\nNo bootable device" + + run scripts/verify/boot_smoke_bios.sh --image "$IMAGE" --timeout-seconds 1 + [ "$status" -ne 0 ] + [[ "$output" == *"observed negative boot signal"* ]] } @test "uefi verify fails when timeout occurs without a boot signal" { @@ -60,13 +70,13 @@ teardown() { run env OVMF_CODE_PATH="$OVMF_CODE" OVMF_VARS_PATH="$OVMF_VARS" \ scripts/verify/boot_smoke_uefi.sh --image "$IMAGE" --timeout-seconds 1 [ "$status" -ne 0 ] - [[ "$output" == *"timed out without detecting boot signal"* ]] + [[ "$output" == *"timed out without detecting explicit boot progression signal"* ]] } @test "uefi verify succeeds on timeout when a boot signal is observed" { export PATH="$MOCK_BIN:$PATH" export MOCK_TIMEOUT_EXIT=124 - export MOCK_TIMEOUT_OUTPUT="BdsDxe: loading Boot0001\nUEFI Interactive Shell" + export MOCK_TIMEOUT_OUTPUT="EFI stub: Booting Linux Kernel" OVMF_CODE="$TEST_TMPDIR/OVMF_CODE.fd" OVMF_VARS="$TEST_TMPDIR/OVMF_VARS.fd" @@ -76,5 +86,21 @@ teardown() { run env OVMF_CODE_PATH="$OVMF_CODE" OVMF_VARS_PATH="$OVMF_VARS" \ scripts/verify/boot_smoke_uefi.sh --image "$IMAGE" --timeout-seconds 1 [ "$status" -eq 0 ] - [[ "$output" == *"completed with boot signal"* ]] + [[ "$output" == *"completed with explicit boot progression signal"* ]] +} + +@test "uefi verify fails when negative signal appears" { + export PATH="$MOCK_BIN:$PATH" + export MOCK_TIMEOUT_EXIT=124 + export MOCK_TIMEOUT_OUTPUT="iPXE initialising devices" + + OVMF_CODE="$TEST_TMPDIR/OVMF_CODE.fd" + OVMF_VARS="$TEST_TMPDIR/OVMF_VARS.fd" + : >"$OVMF_CODE" + : >"$OVMF_VARS" + + run env OVMF_CODE_PATH="$OVMF_CODE" OVMF_VARS_PATH="$OVMF_VARS" \ + scripts/verify/boot_smoke_uefi.sh --image "$IMAGE" --timeout-seconds 1 + [ "$status" -ne 0 ] + [[ "$output" == *"observed negative boot signal"* ]] } diff --git a/tests/bats/image_pipeline_args.bats b/tests/bats/image_pipeline_args.bats index d7c0e61..9355c76 100644 --- a/tests/bats/image_pipeline_args.bats +++ b/tests/bats/image_pipeline_args.bats @@ -5,3 +5,36 @@ [ "$status" -eq 2 ] [[ "$output" == *"--output is required"* ]] } + +@test "restore fails when manifest sha does not match image" { + workdir="$(mktemp -d)" + image="$workdir/test.img.zst" + manifest="$workdir/manifest.json" + printf 'not-an-image' >"$image" + cat >"$manifest" <<'EOF' +{"sha256":"deadbeef"} +EOF + + run scripts/image/restore.sh --image "$image" --device /dev/sdz --manifest "$manifest" --yes + [ "$status" -eq 1 ] + [[ "$output" == *"image checksum mismatch"* ]] + + rm -rf "$workdir" +} + +@test "restore fails when --sha256 conflicts with manifest sha" { + workdir="$(mktemp -d)" + image="$workdir/test.img.zst" + manifest="$workdir/manifest.json" + printf 'not-an-image' >"$image" + actual_sha="$(sha256sum "$image" | awk '{print $1}')" + cat >"$manifest" <"$MOCK_BIN/check-jsonschema" <<'EOF' +#!/usr/bin/env bash +exit 0 +EOF + chmod +x "$MOCK_BIN/check-jsonschema" +} + +teardown() { + rm -rf "$TEST_TMPDIR" +} + @test "install_to_usb rejects missing --device" { - run scripts/build/install_to_usb.sh --config config/defaults.yaml + run env PATH="$MOCK_BIN:$PATH" scripts/build/install_to_usb.sh --config config/defaults.yaml [ "$status" -eq 2 ] [[ "$output" == *"--device is required"* ]] } + +@test "install_to_usb requires --yes for non-interactive non-CI runs" { + run env PATH="$MOCK_BIN:$PATH" scripts/build/install_to_usb.sh --config config/defaults.yaml --device /dev/sdX + [ "$status" -eq 2 ] + [[ "$output" == *"requires --yes"* ]] +} + +@test "install_to_usb allows CI runs without --yes" { + run env PATH="$MOCK_BIN:$PATH" CI=true scripts/build/install_to_usb.sh --config config/defaults.yaml --device /dev/sdX --workdir "$BATS_TEST_TMPDIR/workdir" + [ "$status" -eq 0 ] + [[ "$output" == *"install orchestration prepared"* ]] +} From 98c7cdd33c99bcf41097e4cfaad6005a18e91cc9 Mon Sep 17 00:00:00 2001 From: William Hutson <555966+wilrnh@users.noreply.github.com> Date: Sun, 13 Sep 2026 21:45:57 -0400 Subject: [PATCH 13/19] fix: require restore integrity input and harden guard coverage --- docs/cloning-workflow.md | 4 +- scripts/build/render_autoinstall_seed.sh | 51 ++++++++++++- scripts/image/restore.sh | 8 +- tests/bats/image_pipeline_args.bats | 97 ++++++++++++++++++++++++ tests/bats/install_to_usb_args.bats | 24 ++++++ 5 files changed, 181 insertions(+), 3 deletions(-) diff --git a/docs/cloning-workflow.md b/docs/cloning-workflow.md index a682e96..88a8f20 100644 --- a/docs/cloning-workflow.md +++ b/docs/cloning-workflow.md @@ -34,9 +34,11 @@ Manifest fields include image path, SHA-256 checksum, and size in bytes. ## 4. Restore to a New USB Device ```bash -scripts/image/restore.sh --image artifacts/ghostdrive.img.zst --device /dev/sdY +scripts/image/restore.sh --image artifacts/ghostdrive.img.zst --device /dev/sdY --manifest artifacts/ghostdrive.manifest.json ``` +Restore requires integrity verification on every run. Provide either `--manifest` or `--sha256`. + ## 5. Validate Restored Media - Confirm device partitions and filesystem mount cleanly. diff --git a/scripts/build/render_autoinstall_seed.sh b/scripts/build/render_autoinstall_seed.sh index 16b20ab..ce5bca9 100755 --- a/scripts/build/render_autoinstall_seed.sh +++ b/scripts/build/render_autoinstall_seed.sh @@ -11,7 +11,56 @@ read_config_value() { local key="$1" local value - value="$(grep -E "^${key}:[[:space:]]*" "$CONFIG" | head -n1 | sed -E "s/^${key}:[[:space:]]*\"?([^\"]+)\"?[[:space:]]*$/\1/")" + value="$(awk -v key="$key" ' + function trim(s) { + sub(/^[ \t]+/, "", s) + sub(/[ \t]+$/, "", s) + return s + } + + $0 ~ "^[[:space:]]*" key ":[[:space:]]*" { + line = $0 + sub("^[[:space:]]*" key ":[[:space:]]*", "", line) + + out = "" + in_single = 0 + in_double = 0 + + for (i = 1; i <= length(line); i++) { + ch = substr(line, i, 1) + + if (ch == "\"" && !in_single) { + in_double = !in_double + } else if (ch == "\047" && !in_double) { + in_single = !in_single + } + + if (ch == "#" && !in_single && !in_double) { + break + } + + out = out ch + } + + out = trim(out) + + if ((substr(out, 1, 1) == "\"" && substr(out, length(out), 1) == "\"") || + (substr(out, 1, 1) == "\047" && substr(out, length(out), 1) == "\047")) { + out = substr(out, 2, length(out) - 2) + } + + out = trim(out) + print out + found = 1 + exit + } + + END { + if (!found) { + exit 1 + } + } + ' "$CONFIG")" if [[ -z "$value" ]]; then echo "missing required config key: $key" >&2 exit 1 diff --git a/scripts/image/restore.sh b/scripts/image/restore.sh index 02ac134..1a4433f 100755 --- a/scripts/image/restore.sh +++ b/scripts/image/restore.sh @@ -8,7 +8,7 @@ repo_root="$(cd "$script_dir/../.." && pwd)" source "$repo_root/scripts/lib/common.sh" usage() { - printf '%s\n' 'usage: restore.sh --image --device [--manifest ] [--sha256 ] [--yes]' + printf '%s\n' 'usage: restore.sh --image --device (--manifest | --sha256 ) [--yes]' } require_value() { @@ -117,6 +117,12 @@ if [[ -n "$manifest" ]]; then expected_sha="$manifest_expected_sha" fi +if [[ -z "$manifest" && -z "$expected_sha" ]]; then + printf '%s\n' 'one of --manifest or --sha256 is required' >&2 + usage >&2 + exit 2 +fi + if [[ -n "$expected_sha" ]]; then actual_sha="$(sha256sum "$image" | awk '{print $1}')" if [[ "$actual_sha" != "$expected_sha" ]]; then diff --git a/tests/bats/image_pipeline_args.bats b/tests/bats/image_pipeline_args.bats index 9355c76..949bc8c 100644 --- a/tests/bats/image_pipeline_args.bats +++ b/tests/bats/image_pipeline_args.bats @@ -1,5 +1,46 @@ #!/usr/bin/env bats +setup() { + TEST_TMPDIR="$(mktemp -d)" + MOCK_BIN="$TEST_TMPDIR/mockbin" + mkdir -p "$MOCK_BIN" + + cat >"$MOCK_BIN/zstd" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail + +if [[ "${1:-}" == "-dc" && -n "${2:-}" ]]; then + cat "$2" +else + cat +fi +EOF + chmod +x "$MOCK_BIN/zstd" + + cat >"$MOCK_BIN/dd" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +cat >/dev/null +EOF + chmod +x "$MOCK_BIN/dd" +} + +teardown() { + rm -rf "$TEST_TMPDIR" +} + +find_block_device() { + local candidate + for candidate in /dev/loop* /dev/nvme*n1 /dev/vd* /dev/sd* /dev/ram*; do + if [[ -b "$candidate" ]]; then + printf '%s\n' "$candidate" + return 0 + fi + done + + return 1 +} + @test "capture requires --device and --output" { run scripts/image/capture.sh --device /dev/sdb [ "$status" -eq 2 ] @@ -38,3 +79,59 @@ EOF rm -rf "$workdir" } + +@test "restore requires one integrity input" { + workdir="$(mktemp -d)" + image="$workdir/test.img.zst" + printf 'not-an-image' >"$image" + + run scripts/image/restore.sh --image "$image" --device /dev/sdz --yes + [ "$status" -eq 2 ] + [[ "$output" == *"one of --manifest or --sha256 is required"* ]] + + rm -rf "$workdir" +} + +@test "capture non-CI requires --yes before destructive execution" { + if ! block_device="$(find_block_device)"; then + skip "no block device available for destructive-guard test" + fi + + run env PATH="$MOCK_BIN:$PATH" scripts/image/capture.sh --device "$block_device" --output "$TEST_TMPDIR/capture.img.zst" + [ "$status" -eq 2 ] + [[ "$output" == *"requires --yes"* ]] +} + +@test "restore non-CI requires --yes before destructive execution" { + if ! block_device="$(find_block_device)"; then + skip "no block device available for destructive-guard test" + fi + + workdir="$(mktemp -d)" + image="$workdir/test.img.zst" + printf 'restore-payload' >"$image" + actual_sha="$(sha256sum "$image" | awk '{print $1}')" + + run env PATH="$MOCK_BIN:$PATH" scripts/image/restore.sh --image "$image" --device "$block_device" --sha256 "$actual_sha" + [ "$status" -eq 2 ] + [[ "$output" == *"requires --yes"* ]] + + rm -rf "$workdir" +} + +@test "restore CI permits no --yes when integrity input is provided" { + if ! block_device="$(find_block_device)"; then + skip "no block device available for destructive-guard test" + fi + + workdir="$(mktemp -d)" + image="$workdir/test.img.zst" + printf 'restore-payload' >"$image" + actual_sha="$(sha256sum "$image" | awk '{print $1}')" + + run env PATH="$MOCK_BIN:$PATH" CI=true scripts/image/restore.sh --image "$image" --device "$block_device" --sha256 "$actual_sha" + [ "$status" -eq 0 ] + [[ "$output" == *"CI mode detected; skipping interactive confirmation"* ]] + + rm -rf "$workdir" +} diff --git a/tests/bats/install_to_usb_args.bats b/tests/bats/install_to_usb_args.bats index 4158739..4dc0c2c 100644 --- a/tests/bats/install_to_usb_args.bats +++ b/tests/bats/install_to_usb_args.bats @@ -33,3 +33,27 @@ teardown() { [ "$status" -eq 0 ] [[ "$output" == *"install orchestration prepared"* ]] } + +@test "render_autoinstall_seed parses quoted values with trailing comments" { + config_file="$TEST_TMPDIR/quoted-config.yaml" + outdir="$TEST_TMPDIR/seed" + + cat >"$config_file" <<'EOF' +ubuntu_release: "24.04" # pinned release +target_arch: 'x86_64' # current architecture +baseline_profile: curated-default # unquoted with trailing comment +EOF + + run env PATH="$MOCK_BIN:$PATH" scripts/build/render_autoinstall_seed.sh --config "$config_file" --outdir "$outdir" + [ "$status" -eq 0 ] + [[ "$output" == *"seed rendered"* ]] + + run grep -F "# ghostdrive-config: ubuntu_release=24.04" "$outdir/user-data" + [ "$status" -eq 0 ] + + run grep -F "# ghostdrive-config: target_arch=x86_64" "$outdir/user-data" + [ "$status" -eq 0 ] + + run grep -F "# ghostdrive-config: baseline_profile=curated-default" "$outdir/user-data" + [ "$status" -eq 0 ] +} From 7f8f676532582d11eb56e49f75ea7fc305939d67 Mon Sep 17 00:00:00 2001 From: William Hutson <555966+wilrnh@users.noreply.github.com> Date: Sun, 13 Sep 2026 21:54:22 -0400 Subject: [PATCH 14/19] fix: align CI PATH and harden manifest JSON output --- .github/workflows/ci.yml | 2 +- scripts/image/write_manifest.sh | 17 ++++++++++++++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 81c7b0a..839a077 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: - run: sudo apt-get install -y python3-pip - run: python3 -m pip install --user check-jsonschema - run: ~/.local/bin/check-jsonschema --help - - run: scripts/validate-config.sh config/defaults.yaml + - run: PATH="$HOME/.local/bin:$PATH" scripts/validate-config.sh config/defaults.yaml test: runs-on: ubuntu-24.04 diff --git a/scripts/image/write_manifest.sh b/scripts/image/write_manifest.sh index d9881b3..d7fbd42 100755 --- a/scripts/image/write_manifest.sh +++ b/scripts/image/write_manifest.sh @@ -62,6 +62,17 @@ size_bytes="$(wc -c < "$image" | tr -d ' ')" output_dir="$(dirname "$output")" mkdir -p "$output_dir" -cat >"$output" < Date: Sun, 13 Sep 2026 22:06:37 -0400 Subject: [PATCH 15/19] fix: tighten safety checks and CI validator reliability --- scripts/build/install_to_usb.sh | 5 +++++ scripts/lib/common.sh | 10 +++++++--- scripts/validate-config.sh | 2 +- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/scripts/build/install_to_usb.sh b/scripts/build/install_to_usb.sh index 8979619..144ca7a 100755 --- a/scripts/build/install_to_usb.sh +++ b/scripts/build/install_to_usb.sh @@ -47,5 +47,10 @@ done confirm_destructive_action "$DEVICE" "$YES" "install" || exit $? +if ! is_ci_mode && [[ ! -b "$DEVICE" ]]; then + echo "device is not a block device: $DEVICE" + exit 2 +fi + mkdir -p "${WORKDIR:-.ghostdrive-work}" echo "install orchestration prepared" diff --git a/scripts/lib/common.sh b/scripts/lib/common.sh index f9b4071..ebebbc6 100644 --- a/scripts/lib/common.sh +++ b/scripts/lib/common.sh @@ -1,4 +1,4 @@ -#!/usr/bin/env sh +#!/usr/bin/env bash require_cmd() { command -v "$1" >/dev/null 2>&1 @@ -32,6 +32,7 @@ confirm_destructive_action() { device="$1" yes_flag="$2" action_name="$3" + timeout_seconds="${GHOSTDRIVE_CONFIRM_TIMEOUT_SECONDS:-30}" log_info "$action_name target device: $device" @@ -50,8 +51,11 @@ confirm_destructive_action() { return 2 fi - printf 'Type YES to continue: ' - read -r confirmation + printf 'Type YES to continue within %s seconds: ' "$timeout_seconds" + if ! IFS= read -r -t "$timeout_seconds" confirmation; then + log_error "confirmation timed out or unavailable; aborting" + return 2 + fi if [ "$confirmation" != "YES" ]; then log_error "confirmation declined; aborting" return 2 diff --git a/scripts/validate-config.sh b/scripts/validate-config.sh index 0129024..25d1854 100755 --- a/scripts/validate-config.sh +++ b/scripts/validate-config.sh @@ -20,7 +20,7 @@ fi config_path="$1" schema_path="$repo_root/config/schema.json" -if ! check-jsonschema --schemafile "$schema_path" "$config_path" >/dev/null 2>&1; then +if ! check-jsonschema --schemafile "$schema_path" "$config_path"; then log_error "config validation failed for: $config_path" exit 1 fi \ No newline at end of file From 8e8549d8a9b1bd24583c5f4f16514a022dae2f63 Mon Sep 17 00:00:00 2001 From: William Hutson <555966+wilrnh@users.noreply.github.com> Date: Sun, 13 Sep 2026 22:07:28 -0400 Subject: [PATCH 16/19] test: mock schema validator for deterministic config tests --- tests/bats/config_validation.bats | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/tests/bats/config_validation.bats b/tests/bats/config_validation.bats index 10c884f..a377c6a 100644 --- a/tests/bats/config_validation.bats +++ b/tests/bats/config_validation.bats @@ -1,12 +1,33 @@ #!/usr/bin/env bats +setup() { + TEST_TMPDIR="$(mktemp -d)" + MOCK_BIN="$TEST_TMPDIR/mockbin" + mkdir -p "$MOCK_BIN" + + cat >"$MOCK_BIN/check-jsonschema" <<'EOF' +#!/usr/bin/env bash +target="${@: -1}" +if [[ "$target" == *"invalid-config.yaml" ]]; then + echo "mock schema validation failed" >&2 + exit 1 +fi +exit 0 +EOF + chmod +x "$MOCK_BIN/check-jsonschema" +} + +teardown() { + rm -rf "$TEST_TMPDIR" +} + @test "valid config passes schema validation" { - run scripts/validate-config.sh config/defaults.yaml + run env PATH="$MOCK_BIN:$PATH" scripts/validate-config.sh config/defaults.yaml [ "$status" -eq 0 ] } @test "invalid config fails schema validation" { - run scripts/validate-config.sh tests/fixtures/invalid-config.yaml + run env PATH="$MOCK_BIN:$PATH" scripts/validate-config.sh tests/fixtures/invalid-config.yaml [ "$status" -eq 1 ] [[ "$output" == *"config validation failed"* ]] } \ No newline at end of file From 7eb9a2c9db18980082f768a2db2da36b0e24d559 Mon Sep 17 00:00:00 2001 From: William Hutson <555966+wilrnh@users.noreply.github.com> Date: Sun, 13 Sep 2026 22:31:18 -0400 Subject: [PATCH 17/19] fix: block symlink traversal in image generalization --- scripts/image/generalize.sh | 18 +++++++++++++ tests/bats/generalize_safety.bats | 43 +++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 tests/bats/generalize_safety.bats diff --git a/scripts/image/generalize.sh b/scripts/image/generalize.sh index dba537a..e38b0f4 100755 --- a/scripts/image/generalize.sh +++ b/scripts/image/generalize.sh @@ -16,6 +16,15 @@ require_value() { fi } +ensure_not_symlink() { + local path="$1" + local label="$2" + if [[ -L "$path" ]]; then + printf 'refusing to generalize symlinked path (%s): %s\n' "$label" "$path" >&2 + exit 2 + fi +} + root="" while [[ $# -gt 0 ]]; do @@ -50,6 +59,15 @@ if [[ "$root_realpath" == "/" ]]; then exit 2 fi +ensure_not_symlink "$root_realpath/etc" "etc" +ensure_not_symlink "$root_realpath/etc/ssh" "etc/ssh" +ensure_not_symlink "$root_realpath/var" "var" +ensure_not_symlink "$root_realpath/var/log" "var/log" +ensure_not_symlink "$root_realpath/var/lib" "var/lib" +ensure_not_symlink "$root_realpath/var/lib/dbus" "var/lib/dbus" +ensure_not_symlink "$root_realpath/etc/machine-id" "etc/machine-id" +ensure_not_symlink "$root_realpath/var/lib/dbus/machine-id" "var/lib/dbus/machine-id" + truncate -s 0 "$root_realpath/etc/machine-id" rm -f "$root_realpath/var/lib/dbus/machine-id" rm -f "$root_realpath"/etc/ssh/ssh_host_* diff --git a/tests/bats/generalize_safety.bats b/tests/bats/generalize_safety.bats new file mode 100644 index 0000000..53f6515 --- /dev/null +++ b/tests/bats/generalize_safety.bats @@ -0,0 +1,43 @@ +#!/usr/bin/env bats + +setup() { + ROOT_DIR="$(mktemp -d)" + mkdir -p "$ROOT_DIR/etc/ssh" "$ROOT_DIR/var/lib/dbus" "$ROOT_DIR/var/log" + : >"$ROOT_DIR/etc/machine-id" + : >"$ROOT_DIR/var/lib/dbus/machine-id" + : >"$ROOT_DIR/etc/ssh/ssh_host_rsa_key" + : >"$ROOT_DIR/var/log/messages" +} + +teardown() { + rm -rf "$ROOT_DIR" +} + +@test "generalize rejects symlinked critical paths" { + rm -rf "$ROOT_DIR/var/log" + ln -s /tmp "$ROOT_DIR/var/log" + + run scripts/image/generalize.sh --root "$ROOT_DIR" + [ "$status" -eq 2 ] + [[ "$output" == *"symlinked path"* ]] +} + +@test "generalize clears expected identifiers on regular paths" { + run scripts/image/generalize.sh --root "$ROOT_DIR" + [ "$status" -eq 0 ] + + run test ! -e "$ROOT_DIR/var/lib/dbus/machine-id" + [ "$status" -eq 0 ] + + run test ! -e "$ROOT_DIR/etc/ssh/ssh_host_rsa_key" + [ "$status" -eq 0 ] + + run test ! -e "$ROOT_DIR/var/log/messages" + [ "$status" -eq 0 ] + + run test -f "$ROOT_DIR/etc/machine-id" + [ "$status" -eq 0 ] + + run test ! -s "$ROOT_DIR/etc/machine-id" + [ "$status" -eq 0 ] +} From 77312e4fd2965048a82f7294733d9605ec3117b6 Mon Sep 17 00:00:00 2001 From: William Hutson <555966+wilrnh@users.noreply.github.com> Date: Sun, 13 Sep 2026 22:38:07 -0400 Subject: [PATCH 18/19] test: exercise boot signal paths in verify-boot CI --- .github/workflows/ci.yml | 3 ++- scripts/lib/common.sh | 9 +++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 839a077..b6f1a51 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,7 +31,8 @@ jobs: steps: - uses: actions/checkout@v4 - run: sudo apt-get update - - run: sudo apt-get install -y qemu-system-x86 ovmf + - run: sudo apt-get install -y qemu-system-x86 ovmf bats + - run: bats tests/bats/boot_smoke_signals.bats - run: truncate -s 128M /tmp/ghostdrive-boot-smoke.img - run: if scripts/verify/boot_smoke_bios.sh --image /tmp/ghostdrive-boot-smoke.img --timeout-seconds 10; then echo "unexpected BIOS smoke success for blank image"; exit 1; fi - run: if scripts/verify/boot_smoke_uefi.sh --image /tmp/ghostdrive-boot-smoke.img --timeout-seconds 10; then echo "unexpected UEFI smoke success for blank image"; exit 1; fi diff --git a/scripts/lib/common.sh b/scripts/lib/common.sh index ebebbc6..30fa085 100644 --- a/scripts/lib/common.sh +++ b/scripts/lib/common.sh @@ -29,10 +29,11 @@ is_ci_mode() { } confirm_destructive_action() { - device="$1" - yes_flag="$2" - action_name="$3" - timeout_seconds="${GHOSTDRIVE_CONFIRM_TIMEOUT_SECONDS:-30}" + local device="$1" + local yes_flag="$2" + local action_name="$3" + local timeout_seconds="${GHOSTDRIVE_CONFIRM_TIMEOUT_SECONDS:-30}" + local confirmation="" log_info "$action_name target device: $device" From 69a1e0548925ec121531e7616c5fe1064d2d9e83 Mon Sep 17 00:00:00 2001 From: William Hutson <555966+wilrnh@users.noreply.github.com> Date: Sun, 13 Sep 2026 22:44:35 -0400 Subject: [PATCH 19/19] fix: require explicit target root for ansible inventory --- ansible/inventory/local.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ansible/inventory/local.ini b/ansible/inventory/local.ini index 15d5ce5..5e65f38 100644 --- a/ansible/inventory/local.ini +++ b/ansible/inventory/local.ini @@ -1,2 +1,2 @@ [ghostdrive_target] -target ansible_connection=chroot ansible_host=/ +target ansible_connection=chroot ansible_host=/__ghostdrive_target_root_required__