#!/usr/bin/env bash set -eo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" TACHYON_LIB="$ROOT_DIR/tachyon/files/usr/lib" TACHYON_BIN="$ROOT_DIR/tachyon/files/usr/bin/tachyon" CLI_UC="$TACHYON_BIN" UPDATER="$ROOT_DIR/tachyon/files/usr/lib/components/updater.uc" UPDATES_UC="$ROOT_DIR/tachyon/files/usr/lib/components/updates.uc" ACTION_UC="$ROOT_DIR/tachyon/files/usr/lib/components/action.uc" WORK_DIR="$(mktemp -d)" ucode() { local has_L=0 for arg in "$@"; do if [ "$arg" = "-L" ]; then has_L=1 break fi done if [ "$has_L" -eq 1 ]; then command ucode "$@" else command ucode -L "$TACHYON_LIB" "$@" fi } cleanup() { rm -rf "$WORK_DIR" } trap cleanup EXIT fail() { printf 'FAIL: %s\n' "$1" >&2 exit 1 } assert_eq() { local expected="$1" local actual="$2" local label="$3" [ "$actual" = "$expected" ] || fail "$label: expected '$expected', got '$actual'" } write_state() { local name="$1" local content="$2" printf '%s\n' "$content" >"$WORK_DIR/$name.json" printf '%s\n' "$WORK_DIR/$name.json" } assert_eq "/tmp/jobs/abc-1_2.json" \ "$(ucode "$UPDATER" updates-job-state-path /tmp/jobs abc-1_2)" \ "valid job state path" if ucode "$UPDATER" updates-job-state-path /tmp/jobs '../bad' >/dev/null 2>&1; then fail "invalid job id should be rejected" fi assert_eq outdated \ "$(ucode -- "$UPDATER" updates-status-from-compare -1)" \ "outdated compare status" assert_eq latest \ "$(ucode -- "$UPDATER" updates-status-from-compare 0)" \ "latest compare status" assert_eq dev \ "$(ucode -- "$UPDATER" updates-status-from-compare 1)" \ "dev compare status" if ucode -- "$UPDATER" updates-status-from-compare invalid >/dev/null 2>&1; then fail "invalid compare status should be rejected" fi assert_eq "$(printf 'Latest version is installed\tcomponent is up to date (1.0)')" \ "$(ucode "$UPDATER" updates-check-result-row component 1.0 1.0 latest)" \ "latest check result" assert_eq "$(printf 'Update is available\tcomponent update is available: 1.0 -> 2.0')" \ "$(ucode "$UPDATER" updates-check-result-row component 1.0 2.0 outdated)" \ "outdated check result" assert_eq "$(printf 'Installed version is newer than release\tcomponent installed version is newer than upstream release: 2.0 -> 1.0')" \ "$(ucode "$UPDATER" updates-check-result-row component 2.0 1.0 dev)" \ "dev check result" if ucode "$UPDATER" updates-check-result-row component 1.0 2.0 invalid >/dev/null 2>&1; then fail "invalid check result status should be rejected" fi [ ! -e "$ROOT_DIR/tachyon/files/usr/lib/updater.sh" ] || fail "updater.sh shell owner must be removed" grep -Fq 'component_action: [ "components/action.uc", "component-action", 2 ]' "$CLI_UC" || fail "service/cli.uc must dispatch direct component_action through components/action.uc" grep -Fq 'component-action-async' "$UPDATES_UC" || fail "components/updates.uc must own component action async" grep -Fq 'component-action-status' "$UPDATES_UC" || fail "components/updates.uc must own component action status" grep -Fq 'components/action.uc' "$UPDATES_UC" || fail "component action worker must execute components/action.uc directly" grep -Fq 'singbox/runtime.uc' "$UPDATES_UC" || fail "components/updates.uc must delegate sing-box service/config work to singbox/runtime.uc" if grep -n -E 'function (configure_sing_box_service|rebuild_sing_box_config|save_sing_box_config_file|service_listen_address|managed_sing_box_service_text)' "$UPDATES_UC" >/dev/null 2>&1; then fail "components/updates.uc must not duplicate singbox/runtime.uc service/config ownership" fi if grep -Fq 'save-sing-box-config-file-fixture' "$UPDATES_UC"; then fail "components/updates.uc must not expose sing-box config save fixtures owned by singbox/runtime.uc" fi grep -Fq 'component_action_async: [ "components/updates.uc", "component-action-async", 2 ]' "$CLI_UC" || fail "service/cli.uc must dispatch component_action_async through components/updates.uc" grep -Fq 'component_action_status: [ "components/updates.uc", "component-action-status", 1 ]' "$CLI_UC" || fail "service/cli.uc must dispatch component_action_status through components/updates.uc" grep -Fq 'require("core.uci")' "$ACTION_UC" || fail "components/action.uc must use core.uci for component UCI mutations" if grep -n -E 'require\("uci"\)\.cursor|uci -q|uci", "-q"|command_exists\("uci"\)' "$ACTION_UC" >/dev/null 2>&1; then fail "components/action.uc must not own direct UCI cursor or CLI calls" fi grep -Fq 'tachyon_status_running_with_timeout()' "$ACTION_UC" || fail "components/action.uc must use bounded Tachyon status checks for component actions" grep -Fq 'restore_sing_box_after_failed_package_install' "$ACTION_UC" || fail "stable and tiny sing-box package installs must restore the previous variant after validation failures" grep -Fq 'let package_spec = package_name + "=" + package_version;' "$ACTION_UC" || fail "APK sing-box installs must pin the concrete package version instead of selecting a provider" grep -Fq 'Package rollback failed; restored the previous sing-box binary backup' "$ACTION_UC" || fail "package rollback must accept a successfully restored binary backup" grep -Fq '!file_exists("/etc/init.d/sing-box") && file_nonempty("/usr/bin/sing-box")' "$ACTION_UC" || fail "binary rollback must restore a managed sing-box service when the package script is missing" grep -Fq 'fail_package_sing_box_install(action, tiny, "package was installed, but sing-box binary is not available"' "$ACTION_UC" || fail "stable and tiny sing-box binary validation failures must use transactional rollback" grep -Fq 'fail_package_sing_box_install(action, tiny, "was installed, but Tachyon did not start cleanly"' "$ACTION_UC" || fail "stable and tiny sing-box startup failures must use transactional rollback" if grep -Fq 'command_success_from_args([ SERVICE_INIT, "status" ])' "$ACTION_UC"; then fail "components/action.uc must not call init.d status without a timeout" fi status_timeout_line="$(grep -n '^function tachyon_status_running_with_timeout()' "$ACTION_UC" | cut -d: -f1)" capture_state_line="$(grep -n '^function capture_tachyon_running_state()' "$ACTION_UC" | cut -d: -f1)" [ -n "$status_timeout_line" ] && [ -n "$capture_state_line" ] && [ "$status_timeout_line" -lt "$capture_state_line" ] || fail "components/action.uc must declare bounded status helper before capture_tachyon_running_state for OpenWrt ucode" init_line="$(grep -n '^function init_tmp_dir()' "$ACTION_UC" | cut -d: -f1)" tmp_file_line="$(grep -n '^function make_tmp_file' "$ACTION_UC" | cut -d: -f1)" [ -n "$init_line" ] && [ -n "$tmp_file_line" ] && [ "$init_line" -lt "$tmp_file_line" ] || fail "components/action.uc must declare init_tmp_dir before make_tmp_file for OpenWrt ucode" cat >"$WORK_DIR/action-command-success.uc" <<'UCODE' let common = require("core.common"); let command_from_args = common.command_from_args; let shell_quote = common.shell_quote; let command_status = common.command_status; function command_success(command) { return command_status("(" + command + ") >/dev/null 2>&1") == 0; } let output_path = ARGV[0] || ""; if (output_path == "" || !command_success(command_from_args([ "printf", "%s", "extracted payload" ]) + " >" + shell_quote(output_path))) exit(1); UCODE command_success_output="$WORK_DIR/command-success-output" ucode "$WORK_DIR/action-command-success.uc" "$command_success_output" || fail "components/action.uc command_success should allow commands with explicit output redirection" assert_eq "extracted payload" "$(cat "$command_success_output")" \ "component command success preserves explicit output redirection" awk ' { sub(/\r$/, "") } prev2 == " remove_file(archive_file);" && prev1 == " stop_tachyon_before_sing_box_change();" && $0 == " let new_version = validate_sing_box_extended_binary(tmp_binary, tmp_dir);" { safe_validation = 1 } { prev2 = prev1; prev1 = $0 } END { exit safe_validation ? 0 : 1 } ' "$ACTION_UC" || fail "compressed sing-box validation must release the archive and stop the running service to avoid OpenWrt OOM" grep -Fq 'install_staged_file(tmp_binary, "/usr/bin/sing-box", "0755")' "$ACTION_UC" || fail "compressed sing-box binary installation must support moving from tmpfs to overlay" grep -Fq 'install_staged_file(tmp_cronet, "/usr/lib/libcronet.so", "0644")' "$ACTION_UC" || fail "compressed libcronet installation must support moving from tmpfs to overlay" if grep -Fq 'fs.rename(tmp_binary, "/usr/bin/sing-box")' "$ACTION_UC"; then fail "compressed sing-box installation must not rename directly across OpenWrt filesystems" fi grep -Fq 'let backup_on_tmpfs = previous_variant == "extended-compressed";' "$ACTION_UC" || fail "package sing-box installs must move compressed backups off overlay before opkg space checks" grep -Fq 'backup_binary = backup_on_tmpfs ? tmp_dir + "/sing-box.tachyon-backup"' "$ACTION_UC" || fail "compressed sing-box backup must use tmpfs while installing a package variant" grep -Fq 'return move_file_portable(backup_binary, "/usr/bin/sing-box");' "$ACTION_UC" && fail "portable sing-box restore must preserve executable permissions explicitly" grep -Fq 'if (!move_file_portable(backup_binary, "/usr/bin/sing-box"))' "$ACTION_UC" || fail "compressed sing-box rollback must restore backups across filesystems" package_runtime_lib="$WORK_DIR/package-runtime-lib" package_runtime_bin="$WORK_DIR/package-runtime-bin" mkdir -p "$package_runtime_lib/components" "$package_runtime_lib/core" "$package_runtime_lib/singbox" "$package_runtime_bin" cp "$UPDATER" "$package_runtime_lib/components/updater.uc" cp "$TACHYON_LIB/core/common.uc" "$package_runtime_lib/core/common.uc" cp "$TACHYON_LIB/core/helpers.uc" "$package_runtime_lib/core/helpers.uc" cat >"$package_runtime_lib/core/constants.uc" <<'UCODE' function module_exports() { return {}; } if (sourcepath(1) != null && sourcepath(1) != "") return module_exports(); UCODE cat >"$package_runtime_lib/core/uci.uc" <<'UCODE' function module_exports() { return { available: function() { return false; } }; } if (sourcepath(1) != null && sourcepath(1) != "") return module_exports(); UCODE cat >"$package_runtime_lib/core/packages.uc" <<'UCODE' let mode = ARGV[0] || ""; if (mode == "opkg-version") exit(0); if (mode == "opkg-installed") exit(1); if (mode == "apk-available-version") { print("1.2.3-r1\n"); exit(0); } if (mode == "apk-version") exit(0); exit(1); UCODE cat >"$package_runtime_lib/singbox/runtime.uc" <<'UCODE' let mode = ARGV[0] || ""; if (mode == "version") exit(0); if (mode == "variant") { print("stable\n"); exit(0); } if (mode == "is-extended" || mode == "is-tiny") exit(1); if (mode == "write-variant-marker" || mode == "write-version-state") exit(0); exit(0); UCODE cat >"$package_runtime_bin/opkg" <<'SH' #!/usr/bin/env sh set -eu printf 'opkg %s\n' "$*" >>"${FAKE_OPKG_LOG:?}" case "${1:-}" in list) if [ -e "${FAKE_OPKG_UPDATED:?}" ]; then printf 'sing-box - 1.2.3 - fake package\n' fi ;; update) : >"${FAKE_OPKG_UPDATED:?}" ;; install|remove) ;; *) exit 1 ;; esac SH cat >"$package_runtime_bin/logger" <<'SH' #!/usr/bin/env sh exit 0 SH chmod +x "$package_runtime_bin/opkg" "$package_runtime_bin/logger" set +e PATH="$package_runtime_bin:$PATH" \ TACHYON_LIB="$package_runtime_lib" \ TACHYON_FORCE_PKG_MANAGER="opkg" \ TACHYON_RUNTIME_STATE_DIR="$WORK_DIR/package-runtime" \ TACHYON_BIN="$WORK_DIR/missing-tachyon" \ TACHYON_SERVICE_INIT="$WORK_DIR/missing-init" \ FAKE_OPKG_LOG="$WORK_DIR/opkg.log" \ FAKE_OPKG_UPDATED="$WORK_DIR/opkg.updated" \ ucode -L "$package_runtime_lib" "$ACTION_UC" component-action sing_box install_stable >/dev/null set -e OPKG_LOG="$WORK_DIR/opkg.log" node - <<'NODE' const fs = require('fs'); const lines = fs.readFileSync(process.env.OPKG_LOG, 'utf8').trim().split(/\n+/); function fail(message) { console.error(`${message}: ${JSON.stringify(lines)}`); process.exit(1); } const firstList = lines.indexOf('opkg list sing-box'); const update = lines.indexOf('opkg update'); const secondList = lines.indexOf('opkg list sing-box', firstList + 1); const install = lines.findIndex(line => line.startsWith('opkg install ') && line.endsWith(' sing-box')); if (firstList < 0) fail('initial stable sing-box package list lookup missing'); if (update <= firstList) fail('package list update must happen after empty initial lookup'); if (secondList <= update) fail('stable sing-box version must be resolved again after package list update'); if (install <= secondList) fail('stable sing-box install must happen after post-update version resolve'); NODE cat >"$package_runtime_bin/apk" <<'SH' #!/usr/bin/env sh set -eu printf 'apk %s\n' "$*" >>"${FAKE_APK_LOG:?}" case "${1:-}" in info) exit 1 ;; update|add|fix|del) exit 0 ;; *) exit 1 ;; esac SH chmod +x "$package_runtime_bin/apk" set +e PATH="$package_runtime_bin:$PATH" \ TACHYON_LIB="$package_runtime_lib" \ TACHYON_RUNTIME_STATE_DIR="$WORK_DIR/apk-package-runtime" \ TACHYON_BIN="$WORK_DIR/missing-tachyon" \ TACHYON_SERVICE_INIT="$WORK_DIR/missing-init" \ FAKE_APK_LOG="$WORK_DIR/apk.log" \ ucode -L "$package_runtime_lib" "$ACTION_UC" component-action sing_box install_stable >/dev/null set -e grep -Fxq 'apk add sing-box=1.2.3-r1' "$WORK_DIR/apk.log" || fail "APK stable action must request the exact sing-box package version" if grep -Fxq 'apk add sing-box' "$WORK_DIR/apk.log"; then fail "APK stable action must not use the provider-selecting unversioned package name" fi release_json="$(cat <<'JSON' { "tag_name": "1.2.3", "html_url": "https://example.com/release", "assets": [ {"name": "tachyon_1.2.3.ipk", "browser_download_url": "https://example.com/backend.ipk"}, {"name": "luci-app-tachyon_1.2.3.ipk", "browser_download_url": "https://example.com/app.ipk"}, {"name": "luci-i18n-tachyon-ru_1.2.3.ipk", "browser_download_url": "https://example.com/i18n.ipk"} ] } JSON )" assert_eq "$(printf 'https://example.com/release\ttachyon_1.2.3.ipk\thttps://example.com/backend.ipk\tluci-app-tachyon_1.2.3.ipk\thttps://example.com/app.ipk\tluci-i18n-tachyon-ru_1.2.3.ipk\thttps://example.com/i18n.ipk')" \ "$(printf '%s' "$release_json" | ucode "$UPDATER" tachyon-release-plan 1.2.3 ipk 1)" \ "tachyon release plan with i18n" assert_eq "$(printf 'https://example.com/release\ttachyon_1.2.3.ipk\thttps://example.com/backend.ipk\tluci-app-tachyon_1.2.3.ipk\thttps://example.com/app.ipk\t\t')" \ "$(printf '%s' "$release_json" | ucode "$UPDATER" tachyon-release-plan 1.2.3 ipk 0)" \ "tachyon release plan without i18n" if printf '%s' "$release_json" | ucode "$UPDATER" tachyon-release-plan 9.9.9 ipk 0 >/dev/null 2>&1; then fail "tachyon release plan should reject tag mismatch" fi missing_i18n_json="$(cat <<'JSON' { "tag_name": "1.2.3", "html_url": "https://example.com/release", "assets": [ {"name": "tachyon_1.2.3.ipk", "browser_download_url": "https://example.com/backend.ipk"}, {"name": "luci-app-tachyon_1.2.3.ipk", "browser_download_url": "https://example.com/app.ipk"} ] } JSON )" if printf '%s' "$missing_i18n_json" | ucode "$UPDATER" tachyon-release-plan 1.2.3 ipk 1 >/dev/null 2>&1; then fail "tachyon release plan should require i18n asset when requested" fi ucode "$UPDATER" tachyon-release-version-valid 1.2.3 || fail "three-part Tachyon release version should be valid" for invalid_version in v1.2.3 1.2 1.2.3.4 1.2.3-1 1.2.3-r1; do if ucode "$UPDATER" tachyon-release-version-valid "$invalid_version" >/dev/null 2>&1; then fail "invalid Tachyon release version was accepted: $invalid_version" fi done assert_eq "-1" "$(ucode "$UPDATER" tachyon-release-version-compare 1.2.3 1.2.4)" \ "three-part Tachyon release version comparison" assert_eq "1.13.14-extended-2.5.0" \ "$(printf 'sing-box version 1.13.14-extended-2.5.0\n\nEnvironment: go1.26.4 linux/amd64\n' | ucode "$UPDATER" stdin-first-line-last-field)" \ "sing-box extended binary version parsing" component_actions_dir="$WORK_DIR/component-actions" fake_lib="$WORK_DIR/lib" mkdir -p "$fake_lib/components" "$fake_lib/config" "$fake_lib/core" cp "$UPDATES_UC" "$fake_lib/components/updates.uc" cp "$TACHYON_LIB/config/connections.uc" "$fake_lib/config/connections.uc" cp "$TACHYON_LIB/core/uci.uc" "$fake_lib/core/uci.uc" cp "$TACHYON_LIB/core/common.uc" "$fake_lib/core/common.uc" cp "$TACHYON_LIB/core/helpers.uc" "$fake_lib/core/helpers.uc" cat >"$fake_lib/components/action.uc" <<'UCODE' #!/usr/bin/env ucode if ((ARGV[0] || "") == "component-action") { printf('{"success":true,"kind":"component","component":"%s","action":"%s","message":"done","current_version":"1.0","latest_version":"1.0","changed":0,"status":"latest"}\n', ARGV[1], ARGV[2]); exit(0); } exit(1); UCODE start_json="$(TACHYON_LIB="$fake_lib" UPDATES_JOB_DIR="$component_actions_dir" \ ucode -L "$TACHYON_LIB" "$UPDATES_UC" component-action-async sing_box check_update)" job_id="$(JSON_VALUE="$start_json" node - <<'NODE' const value = JSON.parse(process.env.JSON_VALUE); if (!value.success || !value.job_id) { process.exit(1); } process.stdout.write(value.job_id); NODE )" [ -n "$job_id" ] || fail "component action async should return a job id" status_json="" for _ in 1 2 3 4 5; do status_json="$(TACHYON_LIB="$fake_lib" UPDATES_JOB_DIR="$component_actions_dir" \ ucode -L "$TACHYON_LIB" "$UPDATES_UC" component-action-status "$job_id")" JSON_VALUE="$status_json" node - <<'NODE' && break || true const value = JSON.parse(process.env.JSON_VALUE); process.exit(value.running === false ? 0 : 1); NODE sleep 1 done JSON_VALUE="$status_json" node - <<'NODE' const value = JSON.parse(process.env.JSON_VALUE); if (value.running !== false || value.success !== true || value.component !== "sing_box" || value.action !== "check_update" || value.message !== "done" || value.exit_code !== 0) { console.error("component action async/status state mismatch"); process.exit(1); } NODE start_json="$(TACHYON_LIB="$fake_lib" UPDATES_JOB_DIR="$component_actions_dir" \ ucode -L "$TACHYON_LIB" "$UPDATES_UC" component-action-async sing-box check_update)" job_id="$(JSON_VALUE="$start_json" node - <<'NODE' const value = JSON.parse(process.env.JSON_VALUE); if (!value.success || !value.job_id) { process.exit(1); } process.stdout.write(value.job_id); NODE )" [ -n "$job_id" ] || fail "component action async should accept sing-box public name" status_json="" for _ in 1 2 3 4 5; do status_json="$(TACHYON_LIB="$fake_lib" UPDATES_JOB_DIR="$component_actions_dir" \ ucode -L "$TACHYON_LIB" "$UPDATES_UC" component-action-status "$job_id")" JSON_VALUE="$status_json" node - <<'NODE' && break || true const value = JSON.parse(process.env.JSON_VALUE); process.exit(value.running === false ? 0 : 1); NODE sleep 1 done JSON_VALUE="$status_json" node - <<'NODE' const value = JSON.parse(process.env.JSON_VALUE); if (value.running !== false || value.success !== true || value.component !== "sing_box" || value.action !== "check_update" || value.message !== "done" || value.exit_code !== 0) { console.error("component action public sing-box alias state mismatch"); process.exit(1); } NODE zapret_release_json="$(cat <<'JSON' { "tag_name": "v70.1", "html_url": "https://example.com/zapret-release", "assets": [ {"name": "zapret_v70.1_aarch64_generic.zip", "browser_download_url": "https://example.com/aarch64.zip"}, {"name": "zapret_v70.1_mipsel_24kc.zip", "browser_download_url": "https://example.com/mipsel.zip"} ] } JSON )" assert_eq "$(printf 'mipsel_24kc\tzapret_v70.1_mipsel_24kc.zip\thttps://example.com/mipsel.zip\thttps://example.com/zapret-release\tv70.1')" \ "$(printf '%s' "$zapret_release_json" | ucode "$UPDATER" release-select-arch-suffix-asset zip 'mipsel_24kc aarch64_generic')" \ "release arch suffix selector" [ -z "$(printf '%s' "$zapret_release_json" | ucode "$UPDATER" release-select-arch-suffix-asset zip 'arm_cortex-a7')" ] || fail "release arch suffix selector should be empty for missing arch" running="$(write_state running '{"running":true,"pid":"123","started_at":100}')" assert_eq "$(printf 'pid\t123\t0')" \ "$(ucode "$UPDATER" updates-job-refresh-plan "$running" 105 15)" \ "alive candidate within grace" assert_eq "$(printf 'pid\t123\t1')" \ "$(ucode "$UPDATER" updates-job-refresh-plan "$running" 120 15)" \ "alive candidate after grace" invalid_pid="$(write_state invalid-pid '{"running":true,"pid":"","started_at":100}')" assert_eq "skip" \ "$(ucode "$UPDATER" updates-job-refresh-plan "$invalid_pid" 105 15)" \ "invalid pid within grace" assert_eq "stale" \ "$(ucode "$UPDATER" updates-job-refresh-plan "$invalid_pid" 120 15)" \ "invalid pid after grace" finished="$(write_state finished '{"running":false,"pid":"123","started_at":100}')" assert_eq "skip" \ "$(ucode "$UPDATER" updates-job-refresh-plan "$finished" 120 15)" \ "finished job refresh" stale_json="$(ucode "$UPDATER" updates-mark-stale-job-state "$invalid_pid")" JSON_VALUE="$stale_json" node - <<'NODE' const value = JSON.parse(process.env.JSON_VALUE); if (value.running !== false || value.success !== false || value.exit_code !== null) { console.error("stale state shape mismatch"); process.exit(1); } NODE printf 'component updater job checks passed\n'