diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 664ab74..d2adcb0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,4 +21,4 @@ jobs: run: ./tests/test-scripts.sh - name: Run pinned ShellCheck - run: docker run --rm -v "$PWD:/mnt:ro" -w /mnt koalaman/shellcheck@sha256:2097951f02e735b613f4a34de20c40f937a6c8f18ecb170612c88c34517221fb scripts/*.sh tests/*.sh + run: docker run --rm -v "$PWD:/mnt:ro" -w /mnt koalaman/shellcheck@sha256:2097951f02e735b613f4a34de20c40f937a6c8f18ecb170612c88c34517221fb scripts/*.sh tests/*.sh onescript diff --git a/README.md b/README.md index e170fb3..24c5e3e 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,62 @@ # ONEScripts -Independent, reviewed Bash scripts for occasional Linux, VPS, cPanel, and system checks. +Independent, reviewed Bash modules for occasional Linux, VPS, cPanel, and system checks, plus an optional master CLI for selecting and running the installed catalog. -Each script is standalone: download only the check you need, verify its checksum, and run it with one copyable command. No shared library or repository clone is required on the target system. +Every module remains standalone: download only the check you need, verify its checksum, and run it with one copyable command. Users who keep the complete catalog can use `onescript` as a non-interactive CLI or interactive terminal menu. The master executes modules; it does not duplicate their diagnostic logic. ## Safety contract - Active scripts are read-only checks. They do not install packages, restart services, delete mail, modify firewall rules, or rewrite configuration. - Scripts do not intentionally display credentials, message contents, machine identifiers, or unredacted public network addresses. +- The master uses the checked-in `catalog.tsv` allowlist. It never sources modules, evaluates argument strings, downloads code, installs packages, or performs remediation. +- Catalog paths are constrained to regular executable files under a real, non-linked `scripts/` directory; linked catalogs, module directories, and module files are rejected. - Remote commands download a version-tagged file, verify SHA-256, and execute only after verification succeeds. - Never use `curl -k`, `--insecure`, or an unverified `curl | bash` pipeline. - Run with root privileges only when a check needs complete service, process, or mail-queue visibility. -## Script catalog +## Master `onescript` CLI + +The master requires a complete verified checkout containing `onescript`, `catalog.tsv`, `SHA256SUMS`, and `scripts/`. Wave 0 is not part of the existing `v2.0.0` release; a version-pinned installation command will be added only when its release is approved and published. + +Verify the installed catalog: + +```bash +./onescript doctor +``` + +List or inspect modules without opening a menu: + +```bash +./onescript list +``` + +```bash +./onescript info exim-queue-health +``` + +Run one module. Module arguments must follow `--` and are passed without shell evaluation: + +```bash +./onescript run directory-sizes -- --all /var +``` + +Run several modules with their allowlisted safe defaults: + +```bash +./onescript batch exim-queue-health cpanel-followup-triage +``` + +Open the interactive menu: + +```bash +./onescript menu +``` + +Running `./onescript` without arguments also opens the menu when both input and output are terminals. Non-interactive automation must use an explicit command. + +`run` returns the selected module's status. `batch` preflights every selection, runs them in order, continues after module failures, and returns the first nonzero module status. Launcher usage errors return `2`; catalog, installation, and lookup failures return `1`. + +## Module catalog ### `vps-profile.sh` @@ -54,7 +98,7 @@ One-line invocation: (tmp_dir="$(mktemp -d)" && chmod 700 "$tmp_dir" && trap 'rm -rf "$tmp_dir"' EXIT && curl -fsSLo "$tmp_dir/exim-queue-health.sh" 'https://raw.githubusercontent.com/RomeoRaven/ONEScripts/v2.0.0/scripts/exim-queue-health.sh' && printf '%s %s\n' 'cf4606e3cd496dd9d8ca9db1281488507e3abeb2f45f460f1af070297a3fec22' "$tmp_dir/exim-queue-health.sh" | sha256sum -c - && chmod 700 "$tmp_dir/exim-queue-health.sh" && "$tmp_dir/exim-queue-health.sh") ``` -## Local use +## Direct module use Clone the repository and run a script directly: @@ -62,34 +106,37 @@ Clone the repository and run a script directly: git clone https://github.com/RomeoRaven/ONEScripts.git && cd ONEScripts && ./scripts/vps-profile.sh ./vps-profile.txt ``` -Every script supports `--help` and `--version`. +Every module supports `--help` and `--version` and remains directly executable without the master. ## Validation -Run the behavioral suite and checksum verification: +Run the behavioral suites and verify the complete installed catalog: ```bash -./tests/test-scripts.sh +./tests/test-scripts.sh && ./onescript doctor ``` Run ShellCheck: ```bash -shellcheck scripts/*.sh tests/*.sh +shellcheck scripts/*.sh tests/*.sh onescript ``` GitHub Actions runs both checks for every push and pull request. ## Adding scripts -A new active script must: +A new active module must: 1. Be standalone and read-only by default. 2. Support `--help` and `--version`. 3. Avoid credentials, private environment data, insecure TLS, and unverified remote execution. 4. Include behavioral coverage where practical. -5. Pass Bash parsing, ShellCheck, the repository test suite, and checksum verification. -6. Include one version-pinned, checksum-bound invocation in this README. +5. Add one valid `catalog.tsv` row with a stable ID, category, display name, description, requirements summary, path, and `yes|no` safe-default flag. +6. Pass Bash parsing, ShellCheck, the repository test suite, launcher tests, and checksum verification. +7. Add the module checksum to `SHA256SUMS` and include one version-pinned, checksum-bound invocation in this README. + +Ordinary module additions do not require launcher code changes. Use `menu_safe=no` when a module cannot run meaningfully or safely without explicit arguments; it remains available through direct execution and `onescript run`. Mutating or remediation scripts require an explicit design review, safe preview behavior, failure handling, and rollback evidence before inclusion. diff --git a/SHA256SUMS b/SHA256SUMS index 52fa7e7..b39a3fd 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -1,3 +1,5 @@ +877de32b38eaa36295cf16276e1073ddc306f9312663ee5291e5d44aed641c58 onescript +705c2d2342e4a6180a917d3de20e734fb9d312d827aba98cf5eb94507e9fdb0e catalog.tsv 5bb1e14491437c42cf6d5169186a2545176dcbbf5ba4367d84aabf0e851f0a23 scripts/cpanel-followup-triage.sh 156ea405829ec5c27ed7a462a23e2fe9752704467c93fbc652aef205d24ca65d scripts/directory-sizes.sh cf4606e3cd496dd9d8ca9db1281488507e3abeb2f45f460f1af070297a3fec22 scripts/exim-queue-health.sh diff --git a/catalog.tsv b/catalog.tsv new file mode 100644 index 0000000..5299457 --- /dev/null +++ b/catalog.tsv @@ -0,0 +1,5 @@ +# id category display_name description requirements path menu_safe +vps-profile system VPS profile Privacy-reduced VPS and cPanel profile Bash 4+, standard Linux tools; optional cPanel utilities scripts/vps-profile.sh yes +cpanel-followup-triage cpanel cPanel follow-up triage Focused cPanel service, mail queue, and listener triage Bash 4+, systemctl, ss; Exim utilities when available scripts/cpanel-followup-triage.sh yes +directory-sizes storage Directory sizes Immediate subdirectory disk usage, largest first Bash 4+, find, du, sort scripts/directory-sizes.sh yes +exim-queue-health mail Exim queue health Privacy-safe Exim queue counts Bash 4+, Exim; exiqgrep recommended scripts/exim-queue-health.sh yes diff --git a/onescript b/onescript new file mode 100755 index 0000000..34dfa7e --- /dev/null +++ b/onescript @@ -0,0 +1,321 @@ +#!/usr/bin/env bash +set -euo pipefail +export LC_ALL=C + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CATALOG="$ROOT/catalog.tsv" +VERSION="2.1.0" + +usage() { + printf '%s\n' \ + 'Usage: onescript COMMAND [ARGUMENTS]' \ + '' \ + 'Commands:' \ + ' list List installed ONEScripts modules' \ + ' info MODULE_ID Show module metadata' \ + ' run MODULE_ID -- [ARGS...]' \ + ' Execute one module' \ + ' batch MODULE_ID...' \ + ' Execute safe-default modules in order' \ + ' menu Open the interactive module menu' \ + ' doctor Verify catalog, module files, and checksums' +} + +catalog_error() { + local line_number="$1" reason="$2" + printf 'ERROR: invalid catalog (line %s: %s)\n' "$line_number" "$reason" >&2 + return 1 +} + +validate_catalog() { + local line='' line_number=0 field_count=0 + local id category display_name description requirements path menu_safe remainder tab=$'\t' + local seen_ids='|' seen_paths='|' module_count=0 + + if [[ ! -f "$CATALOG" || -L "$CATALOG" || ! -r "$CATALOG" ]]; then + catalog_error 0 'catalog is missing, linked, or unreadable' + return 1 + fi + + while IFS= read -r line || [[ -n "$line" ]]; do + line_number=$((line_number + 1)) + [[ -z "$line" || "$line" == \#* ]] && continue + + field_count=1 + remainder="$line" + while [[ "$remainder" == *"$tab"* ]]; do + remainder="${remainder#*"$tab"}" + field_count=$((field_count + 1)) + done + ((field_count == 7)) || { catalog_error "$line_number" 'expected seven tab-separated fields'; return 1; } + + IFS=$'\t' read -r id category display_name description requirements path menu_safe <<<"$line" + [[ "$id" =~ ^[a-z0-9][a-z0-9-]*$ ]] || { catalog_error "$line_number" 'invalid module ID'; return 1; } + [[ "$category" =~ ^[a-z0-9][a-z0-9-]*$ ]] || { catalog_error "$line_number" 'invalid category'; return 1; } + [[ -n "$display_name" && "$display_name" =~ ^[[:print:]]+$ ]] || { catalog_error "$line_number" 'invalid display name'; return 1; } + [[ -n "$description" && "$description" =~ ^[[:print:]]+$ ]] || { catalog_error "$line_number" 'invalid description'; return 1; } + [[ -n "$requirements" && "$requirements" =~ ^[[:print:]]+$ ]] || { catalog_error "$line_number" 'invalid requirements'; return 1; } + [[ "$path" =~ ^scripts/[a-z0-9][a-z0-9._-]*\.sh$ ]] || { catalog_error "$line_number" 'invalid module path'; return 1; } + [[ "$menu_safe" == 'yes' || "$menu_safe" == 'no' ]] || { catalog_error "$line_number" 'invalid safe-default flag'; return 1; } + [[ "$seen_ids" != *"|$id|"* ]] || { catalog_error "$line_number" 'duplicate module ID'; return 1; } + [[ "$seen_paths" != *"|$path|"* ]] || { catalog_error "$line_number" 'duplicate module path'; return 1; } + seen_ids+="$id|" + seen_paths+="$path|" + module_count=$((module_count + 1)) + done <"$CATALOG" + + ((module_count > 0)) || { catalog_error 0 'catalog has no modules'; return 1; } +} + +load_module() { + local wanted="$1" id category display_name description requirements path menu_safe + while IFS=$'\t' read -r id category display_name description requirements path menu_safe || [[ -n "$id" ]]; do + [[ -z "$id" || "$id" == \#* ]] && continue + if [[ "$id" == "$wanted" ]]; then + MODULE_ID="$id" + MODULE_CATEGORY="$category" + MODULE_DISPLAY_NAME="$display_name" + MODULE_DESCRIPTION="$description" + MODULE_REQUIREMENTS="$requirements" + MODULE_PATH="$path" + MODULE_MENU_SAFE="$menu_safe" + return 0 + fi + done <"$CATALOG" + return 1 +} + +info_module() { + local wanted="$1" + if ! load_module "$wanted"; then + printf 'ERROR: unknown module: %s\n' "$wanted" >&2 + return 1 + fi + printf '%s\n' \ + "module_id=$MODULE_ID" \ + "category=$MODULE_CATEGORY" \ + "display_name=$MODULE_DISPLAY_NAME" \ + "menu_safe=$MODULE_MENU_SAFE" \ + "path=$MODULE_PATH" \ + "requirements=$MODULE_REQUIREMENTS" \ + "description=$MODULE_DESCRIPTION" +} + +module_executable() { + if [[ ! "$MODULE_PATH" =~ ^scripts/[[:alnum:]][[:alnum:]_.-]*\.sh$ ]]; then + printf 'ERROR: unsafe module path for %s\n' "$MODULE_ID" >&2 + return 1 + fi + MODULE_EXECUTABLE="$ROOT/$MODULE_PATH" + if [[ ! -d "$ROOT/scripts" || -L "$ROOT/scripts" || ! -f "$MODULE_EXECUTABLE" || -L "$MODULE_EXECUTABLE" || ! -x "$MODULE_EXECUTABLE" ]]; then + printf 'ERROR: module is missing, linked, or not executable: %s\n' "$MODULE_ID" >&2 + return 1 + fi +} + +run_module() { + local wanted="$1" + shift + if ! load_module "$wanted"; then + printf 'ERROR: unknown module: %s\n' "$wanted" >&2 + return 1 + fi + module_executable || return 1 + if (($# > 0)); then + [[ "$1" == "--" ]] || { printf 'ERROR: module arguments must follow --\n' >&2; return 2; } + shift + fi + "$MODULE_EXECUTABLE" "$@" +} + +batch_modules() { + local wanted status first_status=0 + + for wanted in "$@"; do + if ! load_module "$wanted"; then + printf 'ERROR: unknown module: %s\n' "$wanted" >&2 + return 1 + fi + if [[ "$MODULE_MENU_SAFE" != 'yes' ]]; then + printf 'ERROR: module has no safe default invocation: %s\n' "$wanted" >&2 + return 2 + fi + module_executable || return 1 + done + + for wanted in "$@"; do + load_module "$wanted" + module_executable + printf '=== %s ===\n' "$wanted" + if "$MODULE_EXECUTABLE"; then + status=0 + else + status=$? + fi + if ((first_status == 0 && status != 0)); then + first_status="$status" + fi + done + return "$first_status" +} + +verify_checksum_manifest() { + local line path expected_path allowed + local seen='|' entry_count=0 + local -a expected=('onescript' 'catalog.tsv') + + if [[ ! -f "$ROOT/SHA256SUMS" || -L "$ROOT/SHA256SUMS" || ! -r "$ROOT/SHA256SUMS" ]]; then + return 1 + fi + while IFS=$'\t' read -r MODULE_ID MODULE_CATEGORY MODULE_DISPLAY_NAME MODULE_DESCRIPTION MODULE_REQUIREMENTS MODULE_PATH MODULE_MENU_SAFE || [[ -n "$MODULE_ID" ]]; do + [[ -z "$MODULE_ID" || "$MODULE_ID" == \#* ]] && continue + expected+=("$MODULE_PATH") + done <"$CATALOG" + + while IFS= read -r line || [[ -n "$line" ]]; do + [[ "$line" =~ ^[0-9a-f]{64}[[:space:]][[:space:]](.+)$ ]] || return 1 + path="${BASH_REMATCH[1]}" + allowed='no' + for expected_path in "${expected[@]}"; do + [[ "$path" == "$expected_path" ]] && { allowed='yes'; break; } + done + [[ "$allowed" == 'yes' && "$seen" != *"|$path|"* ]] || return 1 + seen+="$path|" + entry_count=$((entry_count + 1)) + done <"$ROOT/SHA256SUMS" + + ((entry_count == ${#expected[@]})) || return 1 + for expected_path in "${expected[@]}"; do + [[ "$seen" == *"|$expected_path|"* ]] || return 1 + done + command -v sha256sum >/dev/null 2>&1 || return 1 + (cd "$ROOT" && sha256sum -c --strict --quiet SHA256SUMS) >/dev/null 2>&1 +} + +doctor() { + local id category display_name description requirements path menu_safe + local module_count=0 module_files='ok' checksums='ok' result=0 + + if ! validate_catalog >/dev/null 2>&1; then + printf '%s\n' 'catalog=failed' 'modules=0' 'module_files=unknown' 'checksums=unknown' + return 1 + fi + printf 'catalog=ok\n' + + while IFS=$'\t' read -r id category display_name description requirements path menu_safe || [[ -n "$id" ]]; do + [[ -z "$id" || "$id" == \#* ]] && continue + module_count=$((module_count + 1)) + load_module "$id" + if ! module_executable >/dev/null 2>&1; then + module_files='failed' + result=1 + fi + done <"$CATALOG" + if ! verify_checksum_manifest; then + checksums='failed' + result=1 + fi + printf 'modules=%d\nmodule_files=%s\nchecksums=%s\n' "$module_count" "$module_files" "$checksums" + return "$result" +} + +menu() { + local id category display_name description requirements path menu_safe selection selection_number selected status index + local -a ids=() display_names=() descriptions=() + + if [[ ! -t 0 || ! -t 1 ]]; then + printf 'ERROR: menu requires an interactive terminal\n' >&2 + return 2 + fi + while IFS=$'\t' read -r id category display_name description requirements path menu_safe || [[ -n "$id" ]]; do + [[ -z "$id" || "$id" == \#* || "$menu_safe" != 'yes' ]] && continue + ids+=("$id") + display_names+=("$display_name") + descriptions+=("$description") + done <"$CATALOG" + ((${#ids[@]} > 0)) || { printf 'ERROR: no modules have safe default invocations\n' >&2; return 1; } + + while true; do + printf '\nONEScripts modules\n' + for ((index = 0; index < ${#ids[@]}; index++)); do + printf '%d) %s [%s] - %s\n' "$((index + 1))" "${display_names[index]}" "${ids[index]}" "${descriptions[index]}" + done + printf 'q) quit\n' + read -r -p 'Select a module: ' selection || return 1 + [[ "$selection" == 'q' || "$selection" == 'Q' ]] && return 0 + if [[ ! "$selection" =~ ^[0-9]{1,6}$ ]]; then + printf 'Invalid selection.\n' >&2 + continue + fi + selection_number=$((10#$selection)) + if ((selection_number < 1 || selection_number > ${#ids[@]})); then + printf 'Invalid selection.\n' >&2 + continue + fi + selected="${ids[selection_number - 1]}" + printf '=== %s ===\n' "$selected" + if run_module "$selected"; then status=0; else status=$?; fi + printf 'module_exit=%d\n' "$status" + done +} + +list_modules() { + local id category display_name description requirements path menu_safe + printf 'MODULE_ID\tCATEGORY\tMENU_SAFE\tDISPLAY_NAME\tDESCRIPTION\n' + while IFS=$'\t' read -r id category display_name description requirements path menu_safe || [[ -n "$id" ]]; do + [[ -z "$id" || "$id" == \#* ]] && continue + printf '%s\t%s\t%s\t%s\t%s\n' "$id" "$category" "$menu_safe" "$display_name" "$description" + done <"$CATALOG" +} + +case "${1:-}" in + list) + (($# == 1)) || { printf 'ERROR: list accepts no arguments\n' >&2; exit 2; } + validate_catalog || exit 1 + list_modules + ;; + info) + (($# == 2)) || { printf 'ERROR: info requires one module ID\n' >&2; exit 2; } + validate_catalog || exit 1 + info_module "$2" + ;; + run) + (($# >= 2)) || { printf 'ERROR: run requires a module ID\n' >&2; exit 2; } + validate_catalog || exit 1 + shift + run_module "$@" + ;; + batch) + (($# >= 2)) || { printf 'ERROR: batch requires at least one module ID\n' >&2; exit 2; } + validate_catalog || exit 1 + shift + batch_modules "$@" + ;; + menu) + (($# == 1)) || { printf 'ERROR: menu accepts no arguments\n' >&2; exit 2; } + validate_catalog || exit 1 + menu + ;; + doctor) + (($# == 1)) || { printf 'ERROR: doctor accepts no arguments\n' >&2; exit 2; } + doctor + ;; + -h|--help) + (($# == 1)) || { printf 'ERROR: help accepts no arguments\n' >&2; exit 2; } + usage + ;; + --version) + (($# == 1)) || { printf 'ERROR: version accepts no arguments\n' >&2; exit 2; } + printf '%s\n' "$VERSION" + ;; + '') + if [[ -t 0 && -t 1 ]]; then + validate_catalog || exit 1 + menu + else + printf 'ERROR: no command provided; use --help\n' >&2 + exit 2 + fi + ;; + *) printf 'ERROR: unknown command: %s\n' "$1" >&2; usage >&2; exit 2 ;; +esac diff --git a/tests/test-launcher.sh b/tests/test-launcher.sh new file mode 100755 index 0000000..de7a826 --- /dev/null +++ b/tests/test-launcher.sh @@ -0,0 +1,403 @@ +#!/usr/bin/env bash +set -uo pipefail +export LC_ALL=C + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PASS=0 +FAIL=0 +TMP="$(mktemp -d)" +trap 'rm -rf -- "$TMP"' EXIT + +pass() { + printf 'PASS: %s\n' "$1" + PASS=$((PASS + 1)) +} + +fail() { + printf 'FAIL: %s\n' "$1" >&2 + FAIL=$((FAIL + 1)) +} + +assert_contains() { + local name="$1" haystack="$2" needle="$3" + if [[ "$haystack" == *"$needle"* ]]; then + pass "$name" + else + fail "$name (missing '$needle')" + fi +} + +assert_eq() { + local name="$1" expected="$2" actual="$3" + if [[ "$actual" == "$expected" ]]; then + pass "$name" + else + fail "$name (expected '$expected', got '$actual')" + fi +} + +if list_output="$("$ROOT/onescript" list 2>&1)"; then + assert_contains "launcher list includes vps-profile" "$list_output" $'vps-profile\tsystem\tyes\tVPS profile\tPrivacy-reduced VPS and cPanel profile' + assert_contains "launcher list includes cpanel follow-up" "$list_output" $'cpanel-followup-triage\tcpanel\tyes\tcPanel follow-up triage\tFocused cPanel service, mail queue, and listener triage' + assert_contains "launcher list includes directory sizes" "$list_output" $'directory-sizes\tstorage\tyes\tDirectory sizes\tImmediate subdirectory disk usage, largest first' + assert_contains "launcher list includes Exim queue health" "$list_output" $'exim-queue-health\tmail\tyes\tExim queue health\tPrivacy-safe Exim queue counts' +else + fail "launcher list command succeeds ($list_output)" +fi + +if info_output="$("$ROOT/onescript" info exim-queue-health 2>&1)"; then + assert_contains "launcher info reports module ID" "$info_output" 'module_id=exim-queue-health' + assert_contains "launcher info reports category" "$info_output" 'category=mail' + assert_contains "launcher info reports display name" "$info_output" 'display_name=Exim queue health' + assert_contains "launcher info reports safe default" "$info_output" 'menu_safe=yes' + assert_contains "launcher info reports relative path" "$info_output" 'path=scripts/exim-queue-health.sh' + assert_contains "launcher info reports requirements" "$info_output" 'requirements=Bash 4+, Exim; exiqgrep recommended' + assert_contains "launcher info reports description" "$info_output" 'description=Privacy-safe Exim queue counts' +else + fail "launcher info command succeeds ($info_output)" +fi + +eof_fixture="$TMP/eof-fixture" +mkdir -p "$eof_fixture/scripts" +cp "$ROOT/onescript" "$eof_fixture/onescript" +cat >"$eof_fixture/scripts/eof-probe.sh" <<'EOF' +#!/usr/bin/env bash +printf 'eof_probe_output\n' +EOF +chmod 700 "$eof_fixture/onescript" "$eof_fixture/scripts/eof-probe.sh" +printf '%s\n' '# id category display_name description requirements path menu_safe' >"$eof_fixture/catalog.tsv" +printf '%s' $'eof-probe\ttest\tEOF probe\tFinal row without newline\tBash\tscripts/eof-probe.sh\tyes' >>"$eof_fixture/catalog.tsv" +(cd "$eof_fixture" && sha256sum onescript catalog.tsv scripts/eof-probe.sh >SHA256SUMS) +if eof_output="$("$eof_fixture/onescript" list 2>&1)"; then eof_status=0; else eof_status=$?; fi +assert_eq "list accepts a final catalog row without a newline" "0" "$eof_status" +assert_contains "list includes a final catalog row without a newline" "$eof_output" $'eof-probe\ttest\tyes\tEOF probe' +if eof_output="$("$eof_fixture/onescript" info eof-probe 2>&1)"; then eof_status=0; else eof_status=$?; fi +assert_eq "info loads a final catalog row without a newline" "0" "$eof_status" +assert_contains "info reports a final catalog row without a newline" "$eof_output" 'module_id=eof-probe' +if eof_output="$("$eof_fixture/onescript" run eof-probe 2>&1)"; then eof_status=0; else eof_status=$?; fi +assert_eq "run loads a final catalog row without a newline" "0" "$eof_status" +assert_contains "run executes a final catalog row without a newline" "$eof_output" 'eof_probe_output' +if eof_output="$("$eof_fixture/onescript" doctor 2>&1)"; then eof_status=0; else eof_status=$?; fi +assert_eq "doctor verifies a final catalog row without a newline" "0" "$eof_status" +assert_contains "doctor counts a final catalog row without a newline" "$eof_output" 'modules=1' +assert_contains "doctor checksum-covers a final catalog row without a newline" "$eof_output" 'checksums=ok' + +fixture="$TMP/run-fixture" +mkdir -p "$fixture/scripts" +cp "$ROOT/onescript" "$fixture/onescript" +cat >"$fixture/catalog.tsv" <<'EOF' +# id category display_name description requirements path menu_safe +argument-probe test Argument probe Argument forwarding probe Bash scripts/argument-probe.sh yes +EOF +cat >"$fixture/scripts/argument-probe.sh" <<'EOF' +#!/usr/bin/env bash +printf 'argc=%s\n' "$#" +for arg in "$@"; do printf 'arg=%s\n' "$arg"; done +exit 7 +EOF +chmod 700 "$fixture/onescript" "$fixture/scripts/argument-probe.sh" +sentinel="$TMP/injected" +if run_output="$("$fixture/onescript" run argument-probe -- 'hello world' "\$(touch $sentinel)" 2>&1)"; then + run_status=0 +else + run_status=$? +fi +assert_eq "launcher run preserves module exit status" "7" "$run_status" +assert_contains "launcher run forwards argument count" "$run_output" 'argc=2' +assert_contains "launcher run preserves spaces in arguments" "$run_output" 'arg=hello world' +assert_contains "launcher run passes shell syntax literally" "$run_output" "arg=\$(touch $sentinel)" +if [[ ! -e "$sentinel" ]]; then + pass "launcher run does not evaluate module arguments" +else + fail "launcher run does not evaluate module arguments" +fi + +linked_parent_fixture="$TMP/linked-parent-fixture" +linked_parent_target="$TMP/linked-parent-target" +linked_parent_sentinel="$TMP/linked-parent-ran" +mkdir -p "$linked_parent_fixture" "$linked_parent_target" +cp "$ROOT/onescript" "$linked_parent_fixture/onescript" +cat >"$linked_parent_fixture/catalog.tsv" <<'EOF' +# id category display_name description requirements path menu_safe +probe test Linked parent probe Linked module-directory probe Bash scripts/probe.sh yes +EOF +cat >"$linked_parent_target/probe.sh" <SHA256SUMS +) +if "$linked_parent_fixture/onescript" run probe >/dev/null 2>&1; then linked_parent_status=0; else linked_parent_status=$?; fi +assert_eq "run rejects a linked module directory" "1" "$linked_parent_status" +if [[ ! -e "$linked_parent_sentinel" ]]; then + pass "linked module directories cannot route execution outside the package" +else + fail "linked module directories cannot route execution outside the package" +fi +if linked_parent_output="$("$linked_parent_fixture/onescript" doctor 2>&1)"; then linked_parent_status=0; else linked_parent_status=$?; fi +assert_eq "doctor rejects a linked module directory" "1" "$linked_parent_status" +assert_contains "doctor reports linked module directories as file failures" "$linked_parent_output" 'module_files=failed' + +manifest_fixture="$TMP/manifest-fixture" +mkdir -p "$manifest_fixture/scripts" +cp "$ROOT/onescript" "$manifest_fixture/onescript" +cat >"$manifest_fixture/scripts/probe.sh" <<'EOF' +#!/usr/bin/env bash +exit 0 +EOF +chmod 700 "$manifest_fixture/onescript" "$manifest_fixture/scripts/probe.sh" + +cat >"$manifest_fixture/catalog.tsv" <<'EOF' +# id category display_name description requirements path menu_safe +probe test Probe one First probe Bash scripts/probe.sh yes +probe test Probe two SECRET_DUPLICATE_CANARY Bash scripts/other.sh yes +EOF +if manifest_output="$("$manifest_fixture/onescript" list 2>&1)"; then manifest_status=0; else manifest_status=$?; fi +assert_eq "catalog rejects duplicate module IDs" "1" "$manifest_status" +assert_contains "catalog reports a generic validation error" "$manifest_output" 'ERROR: invalid catalog' +if [[ "$manifest_output" != *'SECRET_DUPLICATE_CANARY'* ]]; then + pass "catalog errors do not echo manifest content" +else + fail "catalog errors do not echo manifest content" +fi + +cat >"$manifest_fixture/catalog.tsv" <<'EOF' +# id category display_name description requirements path menu_safe +probe-a test Probe A First probe Bash scripts/probe.sh yes +probe-b test Probe B Second probe Bash scripts/probe.sh yes +EOF +if "$manifest_fixture/onescript" list >/dev/null 2>&1; then manifest_status=0; else manifest_status=$?; fi +assert_eq "catalog rejects duplicate module paths" "1" "$manifest_status" + +cat >"$manifest_fixture/catalog.tsv" <<'EOF' +# id category display_name description requirements path menu_safe +probe test Probe Traversal probe Bash scripts/../probe.sh yes +EOF +if "$manifest_fixture/onescript" list >/dev/null 2>&1; then manifest_status=0; else manifest_status=$?; fi +assert_eq "catalog rejects paths outside scripts" "1" "$manifest_status" + +cat >"$manifest_fixture/catalog.tsv" <<'EOF' +# id category display_name description requirements path menu_safe +probe test Probe Unsafe default flag Bash scripts/probe.sh maybe +EOF +if "$manifest_fixture/onescript" list >/dev/null 2>&1; then manifest_status=0; else manifest_status=$?; fi +assert_eq "catalog rejects invalid safe-default flags" "1" "$manifest_status" + +cat >"$manifest_fixture/catalog.tsv" <<'EOF' +# id category display_name description requirements path menu_safe +Probe_ID test Probe Invalid module identifier Bash scripts/probe.sh yes +EOF +if "$manifest_fixture/onescript" list >/dev/null 2>&1; then manifest_status=0; else manifest_status=$?; fi +assert_eq "catalog rejects invalid module IDs" "1" "$manifest_status" + +cat >"$manifest_fixture/catalog.tsv" <<'EOF' +# id category display_name description requirements path menu_safe +probe test Missing display name Bash scripts/probe.sh yes +EOF +if "$manifest_fixture/onescript" list >/dev/null 2>&1; then manifest_status=0; else manifest_status=$?; fi +assert_eq "catalog rejects missing display names" "1" "$manifest_status" + +cat >"$manifest_fixture/catalog.tsv" <<'EOF' +# id category display_name description requirements path menu_safe +probe test Probe Missing requirements scripts/probe.sh yes +EOF +if "$manifest_fixture/onescript" list >/dev/null 2>&1; then manifest_status=0; else manifest_status=$?; fi +assert_eq "catalog rejects missing requirements" "1" "$manifest_status" + +cat >"$manifest_fixture/catalog.tsv" <<'EOF' +# id category display_name description requirements path menu_safe +probe test Probe Extra field Bash scripts/probe.sh yes SECRET_EXTRA_CANARY +EOF +if "$manifest_fixture/onescript" list >/dev/null 2>&1; then manifest_status=0; else manifest_status=$?; fi +assert_eq "catalog rejects rows with extra fields" "1" "$manifest_status" + +batch_fixture="$TMP/batch-fixture" +mkdir -p "$batch_fixture/scripts" +cp "$ROOT/onescript" "$batch_fixture/onescript" +cat >"$batch_fixture/catalog.tsv" <<'EOF' +# id category display_name description requirements path menu_safe +safe-pass test Safe pass Successful safe module Bash scripts/safe-pass.sh yes +safe-fail test Safe fail Failing safe module Bash scripts/safe-fail.sh yes +safe-after test Safe after Module after a failure Bash scripts/safe-after.sh yes +safe-fail-later test Later failure Second failing module Bash scripts/safe-fail-later.sh yes +needs-args test Needs arguments Module requiring arguments Bash scripts/needs-args.sh no +EOF +cat >"$batch_fixture/scripts/safe-pass.sh" <<'EOF' +#!/usr/bin/env bash +[[ -z "${BATCH_SENTINEL:-}" ]] || touch "$BATCH_SENTINEL" +printf 'safe_pass_output\n' +EOF +cat >"$batch_fixture/scripts/safe-fail.sh" <<'EOF' +#!/usr/bin/env bash +printf 'safe_fail_output\n' +exit 7 +EOF +cat >"$batch_fixture/scripts/safe-after.sh" <<'EOF' +#!/usr/bin/env bash +printf 'safe_after_output\n' +EOF +cat >"$batch_fixture/scripts/safe-fail-later.sh" <<'EOF' +#!/usr/bin/env bash +printf 'safe_fail_later_output\n' +exit 9 +EOF +cat >"$batch_fixture/scripts/needs-args.sh" <<'EOF' +#!/usr/bin/env bash +if (($# > 0)); then + touch "$1" +else + touch "${BATCH_SENTINEL:?}" +fi +EOF +chmod 700 "$batch_fixture/onescript" "$batch_fixture/scripts/"*.sh +if batch_output="$("$batch_fixture/onescript" batch safe-pass safe-fail safe-after 2>&1)"; then batch_status=0; else batch_status=$?; fi +assert_eq "batch returns the first nonzero module status" "7" "$batch_status" +assert_contains "batch runs the first selected module" "$batch_output" 'safe_pass_output' +assert_contains "batch continues after a module failure" "$batch_output" 'safe_fail_output' +assert_contains "batch runs modules selected after a failure" "$batch_output" 'safe_after_output' +assert_contains "batch labels module boundaries" "$batch_output" '=== safe-pass ===' +if batch_output="$("$batch_fixture/onescript" batch safe-fail safe-fail-later 2>&1)"; then batch_status=0; else batch_status=$?; fi +assert_eq "batch preserves the first of multiple failures" "7" "$batch_status" +assert_contains "batch still runs later failing modules" "$batch_output" 'safe_fail_later_output' +unsafe_sentinel="$TMP/unsafe-batch-ran" +if BATCH_SENTINEL="$unsafe_sentinel" "$batch_fixture/onescript" batch needs-args >/dev/null 2>&1; then batch_status=0; else batch_status=$?; fi +assert_eq "batch rejects modules without safe defaults" "2" "$batch_status" +if [[ ! -e "$unsafe_sentinel" ]]; then + pass "batch does not run unsafe-default modules" +else + fail "batch does not run unsafe-default modules" +fi +explicit_sentinel="$TMP/explicit-module-ran" +if "$batch_fixture/onescript" run needs-args -- "$explicit_sentinel" >/dev/null 2>&1; then cli_status=0; else cli_status=$?; fi +assert_eq "run can call modules without safe defaults" "0" "$cli_status" +if [[ -e "$explicit_sentinel" ]]; then + pass "run supplies explicit arguments to non-menu modules" +else + fail "run supplies explicit arguments to non-menu modules" +fi +preflight_sentinel="$TMP/batch-preflight-ran" +if BATCH_SENTINEL="$preflight_sentinel" "$batch_fixture/onescript" batch safe-pass missing-module >/dev/null 2>&1; then batch_status=0; else batch_status=$?; fi +assert_eq "batch rejects an invalid selection before execution" "1" "$batch_status" +if [[ ! -e "$preflight_sentinel" ]]; then + pass "batch preflights every selection before running modules" +else + fail "batch preflights every selection before running modules" +fi + +doctor_fixture="$TMP/doctor-fixture" +mkdir -p "$doctor_fixture/scripts" +cp "$ROOT/onescript" "$doctor_fixture/onescript" +cat >"$doctor_fixture/catalog.tsv" <<'EOF' +# id category display_name description requirements path menu_safe +doctor-probe test Doctor probe Doctor verification probe Bash scripts/doctor-probe.sh yes +EOF +cat >"$doctor_fixture/scripts/doctor-probe.sh" <<'EOF' +#!/usr/bin/env bash +exit 0 +EOF +chmod 700 "$doctor_fixture/onescript" "$doctor_fixture/scripts/doctor-probe.sh" +(cd "$doctor_fixture" && sha256sum onescript catalog.tsv scripts/doctor-probe.sh >SHA256SUMS) +if doctor_output="$("$doctor_fixture/onescript" doctor 2>&1)"; then doctor_status=0; else doctor_status=$?; fi +assert_eq "doctor accepts a complete valid installation" "0" "$doctor_status" +assert_contains "doctor validates catalog" "$doctor_output" 'catalog=ok' +assert_contains "doctor counts modules" "$doctor_output" 'modules=1' +assert_contains "doctor validates module files" "$doctor_output" 'module_files=ok' +assert_contains "doctor validates checksums" "$doctor_output" 'checksums=ok' + +cp "$doctor_fixture/onescript" "$doctor_fixture/onescript.clean" +printf '\n# checksum probe\n' >>"$doctor_fixture/onescript" +if doctor_output="$("$doctor_fixture/onescript" doctor 2>&1)"; then doctor_status=0; else doctor_status=$?; fi +assert_eq "doctor detects launcher changes" "1" "$doctor_status" +assert_contains "doctor checksum-covers the launcher" "$doctor_output" 'checksums=failed' +mv "$doctor_fixture/onescript.clean" "$doctor_fixture/onescript" + +cp "$doctor_fixture/catalog.tsv" "$doctor_fixture/catalog.tsv.clean" +printf '# checksum probe\n' >>"$doctor_fixture/catalog.tsv" +if doctor_output="$("$doctor_fixture/onescript" doctor 2>&1)"; then doctor_status=0; else doctor_status=$?; fi +assert_eq "doctor detects catalog changes" "1" "$doctor_status" +assert_contains "doctor checksum-covers the catalog" "$doctor_output" 'checksums=failed' +mv "$doctor_fixture/catalog.tsv.clean" "$doctor_fixture/catalog.tsv" + +chmod 600 "$doctor_fixture/scripts/doctor-probe.sh" +if doctor_output="$("$doctor_fixture/onescript" doctor 2>&1)"; then doctor_status=0; else doctor_status=$?; fi +assert_eq "doctor rejects non-executable modules" "1" "$doctor_status" +assert_contains "doctor reports non-executable modules" "$doctor_output" 'module_files=failed' +chmod 700 "$doctor_fixture/scripts/doctor-probe.sh" + +mv "$doctor_fixture/scripts/doctor-probe.sh" "$doctor_fixture/doctor-probe.real" +if doctor_output="$("$doctor_fixture/onescript" doctor 2>&1)"; then doctor_status=0; else doctor_status=$?; fi +assert_eq "doctor rejects missing modules" "1" "$doctor_status" +assert_contains "doctor reports missing modules" "$doctor_output" 'module_files=failed' +mv "$doctor_fixture/doctor-probe.real" "$doctor_fixture/scripts/doctor-probe.sh" + +mv "$doctor_fixture/scripts/doctor-probe.sh" "$doctor_fixture/doctor-probe.real" +ln -s "$doctor_fixture/doctor-probe.real" "$doctor_fixture/scripts/doctor-probe.sh" +(cd "$doctor_fixture" && sha256sum onescript catalog.tsv scripts/doctor-probe.sh >SHA256SUMS) +if "$doctor_fixture/onescript" run doctor-probe >/dev/null 2>&1; then doctor_status=0; else doctor_status=$?; fi +assert_eq "run rejects final-component module symlinks" "1" "$doctor_status" +if doctor_output="$("$doctor_fixture/onescript" doctor 2>&1)"; then doctor_status=0; else doctor_status=$?; fi +assert_eq "doctor rejects final-component module symlinks" "1" "$doctor_status" +assert_contains "doctor reports linked module files" "$doctor_output" 'module_files=failed' +rm "$doctor_fixture/scripts/doctor-probe.sh" +mv "$doctor_fixture/doctor-probe.real" "$doctor_fixture/scripts/doctor-probe.sh" +(cd "$doctor_fixture" && sha256sum onescript catalog.tsv scripts/doctor-probe.sh >SHA256SUMS) + +printf '\n# corruption\n' >>"$doctor_fixture/scripts/doctor-probe.sh" +if doctor_output="$("$doctor_fixture/onescript" doctor 2>&1)"; then doctor_status=0; else doctor_status=$?; fi +assert_eq "doctor rejects checksum mismatches" "1" "$doctor_status" +assert_contains "doctor reports checksum failure generically" "$doctor_output" 'checksums=failed' +if [[ "$doctor_output" != *'FAILED'* && "$doctor_output" != *"$doctor_fixture"* ]]; then + pass "doctor suppresses raw checksum errors and local paths" +else + fail "doctor suppresses raw checksum errors and local paths" +fi + +if menu_output="$("$ROOT/onescript" menu 2>&1)"; then menu_status=0; else menu_status=$?; fi +assert_eq "menu rejects non-interactive use" "2" "$menu_status" +assert_contains "menu explains terminal requirement" "$menu_output" 'ERROR: menu requires an interactive terminal' + +if command -v script >/dev/null 2>&1; then + if menu_output="$(printf '1\nq\n' | script -qefc "$batch_fixture/onescript menu" /dev/null 2>&1)"; then menu_status=0; else menu_status=$?; fi + assert_eq "menu runs in an interactive terminal" "0" "$menu_status" + assert_contains "menu displays safe modules" "$menu_output" '1) Safe pass [safe-pass]' + assert_contains "menu executes a selected module" "$menu_output" 'safe_pass_output' + if [[ "$menu_output" != *'needs-args'* ]]; then + pass "menu omits modules without safe defaults" + else + fail "menu omits modules without safe defaults" + fi + if menu_output="$(printf 'q\n' | script -qefc "$batch_fixture/onescript" /dev/null 2>&1)"; then menu_status=0; else menu_status=$?; fi + assert_eq "no-command interactive use opens the menu" "0" "$menu_status" + assert_contains "no-command interactive menu displays modules" "$menu_output" 'ONEScripts modules' + if menu_output="$(printf '08\nq\n' | script -qefc "$batch_fixture/onescript menu" /dev/null 2>&1)"; then menu_status=0; else menu_status=$?; fi + assert_eq "menu handles leading-zero selections without crashing" "0" "$menu_status" + assert_contains "menu rejects out-of-range leading-zero selections" "$menu_output" 'Invalid selection.' +else + fail "interactive menu test prerequisite is available" +fi + +if help_output="$("$ROOT/onescript" --help 2>&1)"; then cli_status=0; else cli_status=$?; fi +assert_eq "help succeeds" "0" "$cli_status" +assert_contains "help documents menu" "$help_output" 'menu' +assert_contains "help documents batch" "$help_output" 'batch MODULE_ID...' +assert_contains "help documents doctor" "$help_output" 'doctor' +if "$ROOT/onescript" --help extra >/dev/null 2>&1; then cli_status=0; else cli_status=$?; fi +assert_eq "help rejects extra arguments" "2" "$cli_status" +if version_output="$("$ROOT/onescript" --version 2>&1)"; then cli_status=0; else cli_status=$?; fi +assert_eq "version succeeds" "0" "$cli_status" +assert_eq "version reports the launcher release" "2.1.0" "$version_output" +if "$ROOT/onescript" --version extra >/dev/null 2>&1; then cli_status=0; else cli_status=$?; fi +assert_eq "version rejects extra arguments" "2" "$cli_status" +if "$ROOT/onescript" >/dev/null 2>&1; then cli_status=0; else cli_status=$?; fi +assert_eq "no-command non-interactive use is an invalid invocation" "2" "$cli_status" +if "$ROOT/onescript" info missing-module >/dev/null 2>&1; then cli_status=0; else cli_status=$?; fi +assert_eq "info reports unknown modules as collection failures" "1" "$cli_status" +if "$ROOT/onescript" run exim-queue-health unexpected-argument >/dev/null 2>&1; then cli_status=0; else cli_status=$?; fi +assert_eq "run requires the argument separator" "2" "$cli_status" + +printf '\nRESULT: %d passed, %d failed\n' "$PASS" "$FAIL" +(( FAIL == 0 )) diff --git a/tests/test-scripts.sh b/tests/test-scripts.sh index 7181b48..1724906 100755 --- a/tests/test-scripts.sh +++ b/tests/test-scripts.sh @@ -56,6 +56,18 @@ for relative in "${SCRIPTS[@]}"; do version_output="$("$path" --version 2>/dev/null || true)" assert_eq "$relative --version" "$VERSION" "$version_output" + + isolated_dir="$TMP/isolated-$(basename "$relative" .sh)" + mkdir -p "$isolated_dir" + cp "$path" "$isolated_dir/module.sh" + chmod 700 "$isolated_dir/module.sh" + if "$isolated_dir/module.sh" --help >/dev/null 2>&1; then + pass "$relative remains runnable outside the catalog" + else + fail "$relative remains runnable outside the catalog" + fi + isolated_version="$("$isolated_dir/module.sh" --version 2>/dev/null || true)" + assert_eq "$relative isolated --version" "$VERSION" "$isolated_version" done PROFILE_SCRIPT="$ROOT/scripts/vps-profile.sh" @@ -389,13 +401,14 @@ CI_FILE="$ROOT/.github/workflows/ci.yml" if [[ -f "$CI_FILE" ]] \ && grep -Fq 'koalaman/shellcheck@sha256:2097951f02e735b613f4a34de20c40f937a6c8f18ecb170612c88c34517221fb' "$CI_FILE" \ && grep -Fq 'persist-credentials: false' "$CI_FILE" \ + && grep -Fq 'scripts/*.sh tests/*.sh onescript' "$CI_FILE" \ && ! grep -Fq 'apt-get install' "$CI_FILE"; then pass "CI pins the exact ShellCheck image" else fail "CI pins the exact ShellCheck image" fi -unsafe_hits="$(grep -RInE 'curl[[:space:]]+(-[^[:space:]]*k|--insecure)|source[[:space:]]*<\(curl|bash[[:space:]]*<\(curl' "$ROOT/scripts" 2>/dev/null || true)" +unsafe_hits="$(grep -HnE 'curl[[:space:]]+(-[^[:space:]]*k|--insecure)|source[[:space:]]*<\(curl|bash[[:space:]]*<\(curl' "$ROOT/onescript" "$ROOT"/scripts/*.sh 2>/dev/null || true)" assert_eq "active scripts contain no insecure remote execution" "" "$unsafe_hits" if [[ -f "$ROOT/SHA256SUMS" ]] && (cd "$ROOT" && sha256sum -c SHA256SUMS >/dev/null 2>&1); then @@ -403,6 +416,13 @@ if [[ -f "$ROOT/SHA256SUMS" ]] && (cd "$ROOT" && sha256sum -c SHA256SUMS >/dev/n else fail "SHA256SUMS verifies" fi +for checksummed in onescript catalog.tsv "${SCRIPTS[@]}"; do + if grep -Eq "^[0-9a-f]{64} ${checksummed//./\\.}$" "$ROOT/SHA256SUMS"; then + pass "SHA256SUMS covers $checksummed" + else + fail "SHA256SUMS covers $checksummed" + fi +done if [[ -f "$ROOT/README.md" ]]; then for relative in "${SCRIPTS[@]}"; do @@ -444,5 +464,11 @@ else fail "legacy executables are absent from the active tree (${legacy_present[*]})" fi +if "$ROOT/tests/test-launcher.sh"; then + pass "master launcher behavioral suite passes" +else + fail "master launcher behavioral suite passes" +fi + printf '\nRESULT: %d passed, %d failed\n' "$PASS" "$FAIL" (( FAIL == 0 ))