فهرست منبع

feat(core): report missing configured GitHub references

Daniel Gibbs 1 هفته پیش
والد
کامیت
3769d79351

+ 33 - 0
.github/workflows/action-branch-validation.yml

@@ -0,0 +1,33 @@
+name: Branch validation tests
+
+on:
+  push:
+    branches:
+      - "*"
+    paths:
+      - linuxgsm.sh
+      - lgsm/modules/core_dl.sh
+      - lgsm/modules/command_update_linuxgsm.sh
+      - tests/test_branch_validation.py
+      - .github/workflows/action-branch-validation.yml
+  pull_request:
+    paths:
+      - linuxgsm.sh
+      - lgsm/modules/core_dl.sh
+      - lgsm/modules/command_update_linuxgsm.sh
+      - tests/test_branch_validation.py
+      - .github/workflows/action-branch-validation.yml
+  workflow_dispatch:
+
+permissions:
+  contents: read
+
+jobs:
+  test:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v7
+        with:
+          persist-credentials: false
+      - name: Test branch validation and download fallback
+        run: python3 tests/test_branch_validation.py

+ 5 - 0
lgsm/modules/command_update_linuxgsm.sh

@@ -23,6 +23,11 @@ fi
 
 fn_script_log_info "Updating LinuxGSM"
 
+# Keep this module compatible with launchers predating the bootstrap branch check.
+if type fn_check_github_branch > /dev/null 2>&1; then
+	fn_check_github_branch
+fi
+
 fn_print_dots "Selecting repo"
 fn_script_log_info "Selecting repo"
 # Select remotereponame

+ 4 - 0
lgsm/modules/core_dl.sh

@@ -432,6 +432,10 @@ fn_fetch_file() {
 
 	# Download file if missing or download forced.
 	if [ ! -f "${local_filedir}/${local_filename}" ] || [ "${forcedl}" == "forcedl" ]; then
+		# Older launchers gain this helper after update-lgsm replaces the script.
+		if type fn_check_github_branch > /dev/null 2>&1 && [[ "${remote_fileurl}" == "https://raw.githubusercontent.com/${githubuser}/${githubrepo}/"* ]]; then
+			fn_check_github_branch
+		fi
 		# If backup fileurl exists include it.
 		if [ -n "${remote_fileurl_backup}" ]; then
 			# counter set to 0 to allow second try

+ 22 - 0
linuxgsm.sh

@@ -80,6 +80,27 @@ fn_bootstrap_exit() {
 	fi
 }
 
+# Validate the configured reference once, without requiring a particular file to exist.
+# The contents API also accepts tags and commits, as do the existing raw download URLs.
+fn_check_github_branch() {
+	local branch_check_key="${githubuser}/${githubrepo}/${githubbranch}"
+	if [ "${github_branch_checked:-}" == "${branch_check_key}" ]; then
+		return
+	fi
+	github_branch_checked="${branch_check_key}"
+	local branch_http_code
+	branch_http_code=$(curl --connect-timeout 3 --max-time 10 --silent --location --head --get --data-urlencode "ref=${githubbranch}" --output /dev/null --write-out "%{http_code}" "https://api.github.com/repos/${githubuser}/${githubrepo}/contents/")
+	# Rate limits, network errors and server failures must leave download fallback intact.
+	if [ "${branch_http_code}" == "404" ]; then
+		printf '[ FAIL ] Branch "%s" not found on %s/%s\n' "${githubbranch}" "${githubuser}" "${githubrepo}"
+		if type fn_script_log_fail > /dev/null 2>&1; then
+			fn_script_log_fail "Branch \"${githubbranch}\" not found on ${githubuser}/${githubrepo}"
+		fi
+		# An exit module could itself require a download from the missing reference.
+		exit 1
+	fi
+}
+
 # Fetches the core modules required before passed off to core_dl.sh.
 fn_bootstrap_fetch_trap() {
 	echo -e ""
@@ -136,6 +157,7 @@ fn_bootstrap_fetch_file() {
 
 	# Download file if missing or download forced.
 	if [ ! -f "${local_filedir}/${local_filename}" ] || [ "${forcedl}" == "forcedl" ]; then
+		fn_check_github_branch
 		# If backup fileurl exists include it.
 		if [ -n "${remote_fileurl_backup}" ]; then
 			# counter set to 0 to allow second try

+ 257 - 0
tests/test_branch_validation.py

@@ -0,0 +1,257 @@
+#!/usr/bin/env python3
+"""Exercise real download functions with deterministic HTTP responses; no network required."""
+
+import os
+import re
+import subprocess
+import tempfile
+import unittest
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+def functions(path):
+    """Load Bash definitions without executing a launcher or module."""
+    return "\n".join(
+        re.findall(r"(?ms)^[\w.]+\(\) \{\n.*?^\}", path.read_text(encoding="utf-8"))
+    )
+
+
+BOOTSTRAP = functions(ROOT / "linuxgsm.sh")
+DOWNLOAD = functions(ROOT / "lgsm/modules/core_dl.sh")
+UPDATE = (
+    (ROOT / "lgsm/modules/command_update_linuxgsm.sh")
+    .read_text()
+    .split("# Check linuxsm.sh", 1)[0]
+)
+
+MOCKS = r"""
+githubuser="GameServerManagers"
+githubrepo="LinuxGSM"
+githubbranch="${TEST_BRANCH:-feature/example}"
+version="v26.2.0"
+rootdir="$TEST_DIR"
+local_filedir="$TEST_DIR"
+modulesdir="$TEST_DIR"
+lgsmlog="$TEST_DIR/log"
+fn_firstcommand_set() { :; }
+fn_print_dots() { :; }
+fn_script_log_info() { :; }
+fn_script_log_fail() { printf '%s\n' "$*" >> "$TEST_DIR/log"; }
+fn_print_fail_nl() { printf '%s\n' "$*"; }
+fn_print_ok_nl() { :; }
+check.sh() { :; }
+info_distro.sh() { :; }
+info_game.sh() { :; }
+curl() {
+    printf '%s\n' "$*" >> "$TEST_DIR/calls"
+    local output="" probe="" url="${!#}"
+    while [ "$#" -gt 0 ]; do
+        case "$1" in
+            --write-out) probe=1; shift ;;
+            -o) output="$2"; shift ;;
+        esac
+        shift
+    done
+    if [ "$probe" ]; then
+        printf '%s' "${HTTP_CODE:-200}"
+        return "${PROBE_EXIT:-0}"
+    fi
+    if [[ "$url" == https://raw.githubusercontent.com/* ]] && [ "${PRIMARY_FAIL:-0}" == 1 ]; then
+        return 22
+    fi
+    if [ "$output" ]; then
+        printf '#!/bin/bash\n' > "$output"
+    fi
+}
+"""
+
+
+class BranchValidation(unittest.TestCase):
+    """Run production Bash with controlled HTTP responses and isolated files."""
+
+    def run_bash(self, code, *, core=False, legacy=False, **env):
+        """Return process results, HTTP calls, log output and download existence."""
+        with tempfile.TemporaryDirectory() as directory:
+            source = BOOTSTRAP + ("\n" + DOWNLOAD if core else "")
+            if legacy:
+                source += "\nunset -f fn_check_github_branch\n"
+            result = subprocess.run(
+                ["bash", "-c", source + "\n" + MOCKS + "\n" + code],
+                env={**os.environ, "TEST_DIR": directory, **env},
+                check=False,
+                capture_output=True,
+                text=True,
+                timeout=10,
+            )
+            calls = Path(directory, "calls")
+            log = Path(directory, "log")
+            return (
+                result,
+                (
+                    calls.read_text(encoding="utf-8").splitlines()
+                    if calls.exists()
+                    else []
+                ),
+                log.read_text(encoding="utf-8") if log.exists() else "",
+                Path(directory, "sample.sh").exists(),
+            )
+
+    def fetch(self, core=False):
+        """Build a call to the real bootstrap or core download entry point."""
+        name = "fn_fetch_file_github" if core else "fn_bootstrap_fetch_file_github"
+        return f'{name} lgsm/modules sample.sh "$TEST_DIR" nochmodx norun noforcedl nohash\n'
+
+    def test_missing_reference_stops_before_bootstrap_download(self):
+        """Missing reference stops before bootstrap download."""
+        result, calls, log, exists = self.run_bash(self.fetch(), HTTP_CODE="404")
+        self.assertEqual(result.returncode, 1, result.stderr)
+        self.assertIn(
+            'Branch "feature/example" not found on GameServerManagers/LinuxGSM',
+            result.stdout,
+        )
+        self.assertIn('Branch "feature/example"', log)
+        self.assertEqual(len(calls), 1)
+        self.assertFalse(exists)
+
+    def test_missing_reference_with_loaded_core_exits_nonzero(self):
+        """Missing reference with loaded core exits nonzero."""
+        code = "core_exit.sh() { echo unexpected-exit-module; exit 0; }\n" + self.fetch(
+            True
+        )
+        result, calls, _, exists = self.run_bash(code, core=True, HTTP_CODE="404")
+        self.assertEqual(result.returncode, 1, result.stderr)
+        self.assertEqual(len(calls), 1)
+        self.assertFalse(exists)
+
+    def test_valid_reference_is_checked_once_across_bootstrap_and_update(self):
+        """Valid reference is checked once across bootstrap and update."""
+        result, calls, _, exists = self.run_bash(self.fetch() + UPDATE)
+        self.assertEqual(result.returncode, 0, result.stderr)
+        probes = [call for call in calls if "api.github.com" in call]
+        self.assertEqual(len(probes), 1)
+        self.assertIn("--connect-timeout 3 --max-time 10", probes[0])
+        self.assertIn("--data-urlencode ref=feature/example", probes[0])
+        self.assertTrue(exists)
+
+    def test_non404_failures_keep_bitbucket_fallback(self):
+        """Non404 failures keep bitbucket fallback."""
+        for core in (False, True):
+            for status, exit_code in (
+                ("403", "0"),
+                ("429", "0"),
+                ("500", "0"),
+                ("503", "0"),
+                ("000", "28"),
+                ("000", "6"),
+            ):
+                with self.subTest(core=core, status=status, exit_code=exit_code):
+                    result, calls, _, exists = self.run_bash(
+                        self.fetch(core),
+                        core=core,
+                        HTTP_CODE=status,
+                        PROBE_EXIT=exit_code,
+                        PRIMARY_FAIL="1",
+                    )
+                    self.assertEqual(result.returncode, 0, result.stderr)
+                    self.assertTrue(any("bitbucket.org" in call for call in calls))
+                    self.assertTrue(exists)
+                    self.assertNotIn("not found", result.stdout)
+
+    def test_cached_file_needs_no_network(self):
+        """Cached file needs no network."""
+        for core in (False, True):
+            with self.subTest(core=core):
+                result, calls, _, _ = self.run_bash(
+                    'touch "$TEST_DIR/sample.sh"\n' + self.fetch(core),
+                    core=core,
+                    HTTP_CODE="404",
+                )
+                self.assertEqual(result.returncode, 0, result.stderr)
+                self.assertEqual(calls, [])
+
+    def test_update_missing_branch_stops_before_repo_selection(self):
+        """Update missing branch stops before repo selection."""
+        result, calls, _, _ = self.run_bash(UPDATE, HTTP_CODE="404")
+        self.assertEqual(result.returncode, 1, result.stderr)
+        self.assertEqual(len(calls), 1)
+        self.assertIn("api.github.com", calls[0])
+
+    def test_update_non404_keeps_repo_fallback(self):
+        """Update non404 keeps repo fallback."""
+        for status in ("429", "500", "000"):
+            with self.subTest(status=status):
+                result, calls, _, _ = self.run_bash(
+                    UPDATE,
+                    HTTP_CODE=status,
+                    PRIMARY_FAIL="1",
+                )
+                self.assertEqual(result.returncode, 0, result.stderr)
+                self.assertTrue(any("bitbucket.org" in call for call in calls))
+
+    def test_upstream_master_still_downloads_version_tag(self):
+        """Upstream master still downloads version tag."""
+        result, calls, _, _ = self.run_bash(self.fetch(), TEST_BRANCH="master")
+        self.assertEqual(result.returncode, 0, result.stderr)
+        self.assertIn("/v26.2.0/lgsm/modules/sample.sh", calls[-1])
+
+    def test_fork_master_is_not_version_pinned(self):
+        """Fork master is not version pinned."""
+        result, calls, _, _ = self.run_bash(
+            'githubuser="Example"\n' + self.fetch(), TEST_BRANCH="master"
+        )
+        self.assertEqual(result.returncode, 0, result.stderr)
+        self.assertIn("/Example/LinuxGSM/master/lgsm/modules/sample.sh", calls[-1])
+
+    def test_ref_change_gets_new_validation(self):
+        """Ref change gets new validation."""
+        result, calls, _, _ = self.run_bash(
+            "fn_check_github_branch\ngithubbranch=other\nfn_check_github_branch"
+        )
+        self.assertEqual(result.returncode, 0, result.stderr)
+        self.assertEqual(len(calls), 2)
+
+    def test_tags_and_commit_refs_are_passed_unchanged(self):
+        """Tags and commit refs are passed unchanged."""
+        for ref in ("v26.2.0", "2a494011fb636642b9a5d1332a79e359586c54f8"):
+            with self.subTest(ref=ref):
+                result, calls, _, _ = self.run_bash(self.fetch(), TEST_BRANCH=ref)
+                self.assertEqual(result.returncode, 0, result.stderr)
+                self.assertIn("ref=" + ref, calls[0])
+                self.assertIn("/" + ref + "/", calls[-1])
+
+    def test_new_modules_support_older_launchers(self):
+        """New modules support older launchers."""
+        for code in (self.fetch(True), UPDATE):
+            with self.subTest(code=code[:30]):
+                result, calls, _, _ = self.run_bash(code, core=True, legacy=True)
+                self.assertEqual(result.returncode, 0, result.stderr)
+                self.assertFalse(any("api.github.com" in call for call in calls))
+
+    def test_forced_cached_download_validates_ref(self):
+        """A forced download must validate even when the destination exists."""
+        for core in (False, True):
+            with self.subTest(core=core):
+                code = 'touch "$TEST_DIR/sample.sh"\n' + self.fetch(core).replace(
+                    "noforcedl", "forcedl"
+                )
+                result, calls, _, _ = self.run_bash(code, core=core, HTTP_CODE="404")
+                self.assertEqual(result.returncode, 1, result.stderr)
+                self.assertEqual(len(calls), 1)
+
+    def test_unrelated_download_does_not_validate_linuxgsm_ref(self):
+        """Other GitHub repositories should not depend on LinuxGSM validation."""
+        code = (
+            "fn_fetch_file https://raw.githubusercontent.com/Other/Repo/main/file "
+            '"" GitHub "" "$TEST_DIR" sample.sh nochmodx norun noforce nohash'
+        )
+        result, calls, _, exists = self.run_bash(code, core=True, HTTP_CODE="404")
+        self.assertEqual(result.returncode, 0, result.stderr)
+        self.assertTrue(exists)
+        self.assertEqual(len(calls), 1)
+        self.assertNotIn("api.github.com", calls[0])
+
+
+if __name__ == "__main__":
+    unittest.main(verbosity=2)