From 738399b4528802c2878be0e7d9821907e190b08b Mon Sep 17 00:00:00 2001 From: Emil Date: Wed, 9 Sep 2026 20:25:45 +0300 Subject: [PATCH 1/3] test: preserve signed fixture bytes and canonicalize temporary paths --- .gitattributes | 2 ++ src-tauri/src/neoforge_repair.rs | 6 +++++- 2 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..43d4221 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Signed updater fixtures must retain their exact committed bytes on every OS. +src-tauri/tests/fixtures/updater/* -text diff --git a/src-tauri/src/neoforge_repair.rs b/src-tauri/src/neoforge_repair.rs index d393c15..9a77236 100644 --- a/src-tauri/src/neoforge_repair.rs +++ b/src-tauri/src/neoforge_repair.rs @@ -494,7 +494,11 @@ mod tests { impl Fixture { fn new() -> Self { - let root = Stage::new(&std::env::temp_dir()).unwrap(); + // macOS temporary directories may use the system /var -> /private/var + // alias. Canonicalize only this trusted fixture anchor; production + // safe_path must continue rejecting root/descendant substitutions. + let temporary_root = std::env::temp_dir().canonicalize().unwrap(); + let root = Stage::new(&temporary_root).unwrap(); let game = root.0.join("game"); let cache = root.0.join("cache"); let input = jar_bytes(b"verified vanilla"); From daefc3f5e7c9f59e0df71a408e42d1d84e303687 Mon Sep 17 00:00:00 2001 From: Emil Date: Wed, 9 Sep 2026 20:29:04 +0300 Subject: [PATCH 2/3] test: resolve trusted temporary roots in profile and live fixtures --- src-tauri/src/commands/game.rs | 2 +- src-tauri/src/neoforge.rs | 6 ++++-- src-tauri/src/profile.rs | 14 ++++++++++---- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/src-tauri/src/commands/game.rs b/src-tauri/src/commands/game.rs index f6e3175..892c0ec 100644 --- a/src-tauri/src/commands/game.rs +++ b/src-tauri/src/commands/game.rs @@ -272,7 +272,7 @@ mod tests { #[test] #[ignore = "downloads the real pack/game and executes the official installer; needs network and Java 21"] fn live_cold_install_and_corruption_repair() { - let directory = std::env::temp_dir().join(format!( + let directory = std::env::temp_dir().canonicalize().unwrap().join(format!( "shacraft-cold-install-{}", SystemTime::now() .duration_since(std::time::UNIX_EPOCH) diff --git a/src-tauri/src/neoforge.rs b/src-tauri/src/neoforge.rs index 25b45c5..445123c 100644 --- a/src-tauri/src/neoforge.rs +++ b/src-tauri/src/neoforge.rs @@ -459,8 +459,10 @@ mod tests { use crate::{java, mojang}; let client = Client::builder().build().unwrap(); - let root = - std::env::temp_dir().join(format!("shacraft-neoforge-pipeline-{}", std::process::id())); + let root = std::env::temp_dir() + .canonicalize() + .unwrap() + .join(format!("shacraft-neoforge-pipeline-{}", std::process::id())); let game_dir = root.join("game"); let cache_dir = root.join("cache"); fs::create_dir_all(&cache_dir).unwrap(); diff --git a/src-tauri/src/profile.rs b/src-tauri/src/profile.rs index 2557190..32eeee1 100644 --- a/src-tauri/src/profile.rs +++ b/src-tauri/src/profile.rs @@ -506,6 +506,12 @@ mod tests { time::{SystemTime, UNIX_EPOCH}, }; + fn trusted_temporary_root() -> std::path::PathBuf { + // Resolve only the OS-provided fixture anchor. The profile root, its + // parent and payload descendants retain their production link checks. + std::env::temp_dir().canonicalize().unwrap() + } + fn manifest(hash: String, size: u64) -> Manifest { Manifest { schema_version: 1, @@ -531,7 +537,7 @@ mod tests { #[test] fn reports_missing_and_matching_files() { - let root = std::env::temp_dir().join(format!( + let root = trusted_temporary_root().join(format!( "shacraft-launcher-test-{}-{}", process::id(), SystemTime::now() @@ -559,7 +565,7 @@ mod tests { #[test] fn edited_seed_files_remain_up_to_date() { - let root = std::env::temp_dir().join(format!( + let root = trusted_temporary_root().join(format!( "shacraft-seed-test-{}-{}", process::id(), SystemTime::now() @@ -577,7 +583,7 @@ mod tests { #[test] fn preserves_changed_seed_files_as_current() { - let root = std::env::temp_dir().join(format!( + let root = trusted_temporary_root().join(format!( "shacraft-launcher-seed-test-{}-{}", process::id(), SystemTime::now() @@ -600,7 +606,7 @@ mod tests { #[test] fn refuses_linked_profile_directories() { use std::os::unix::fs::symlink; - let root = std::env::temp_dir().join(format!( + let root = trusted_temporary_root().join(format!( "shacraft-link-test-{}-{}", process::id(), SystemTime::now() From cacd1cd2bdfe30aded5473c64da65d960c481812 Mon Sep 17 00:00:00 2001 From: Emil Date: Wed, 9 Sep 2026 20:41:07 +0300 Subject: [PATCH 3/3] ci: isolate Ubuntu sources and select Git Bash for Windows tests --- .github/workflows/build.yml | 12 ++++-- .github/workflows/check.yml | 12 ++++-- .github/workflows/release.yml | 10 ++++- AGENTS.md | 2 + docs/launcher-architecture.md | 5 ++- scripts/release_gate_test.py | 69 ++++++++++++++++++++++++++++++++--- 6 files changed, 95 insertions(+), 15 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 96d9c94..e20ab2d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -54,13 +54,19 @@ jobs: - name: Install Linux desktop dependencies if: runner.os == 'Linux' run: | - sudo apt-get update - sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf + # Ubuntu 22.04 keeps distro sources here; unrelated vendor repos are not needed. + test -s /etc/apt/sources.list + apt_options=( + -o Dir::Etc::sourcelist=/etc/apt/sources.list + -o Dir::Etc::sourceparts=- + ) + sudo apt-get "${apt_options[@]}" --error-on=any update + sudo apt-get "${apt_options[@]}" install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf - run: npm ci - run: npm test - - run: cargo test --locked --manifest-path src-tauri/Cargo.toml - run: cargo build --locked --release --manifest-path scripts/release-verifier/Cargo.toml - run: python -m unittest discover -s scripts -p 'release_test.py' -v + - run: cargo test --locked --manifest-path src-tauri/Cargo.toml - name: Generate disposable CI key (never a production secret) run: | python scripts/release.py ci-key --directory "$RUNNER_TEMP/ci-updater" diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 5feb871..d096d78 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -27,11 +27,17 @@ jobs: - uses: dtolnay/rust-toolchain@stable - name: Install Linux desktop dependencies run: | - sudo apt-get update - sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf + # Ubuntu 22.04 keeps distro sources here; unrelated vendor repos are not needed. + test -s /etc/apt/sources.list + apt_options=( + -o Dir::Etc::sourcelist=/etc/apt/sources.list + -o Dir::Etc::sourceparts=- + ) + sudo apt-get "${apt_options[@]}" --error-on=any update + sudo apt-get "${apt_options[@]}" install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf - run: npm ci - run: npm test - run: npm run build - - run: cargo test --locked --manifest-path src-tauri/Cargo.toml - run: cargo build --locked --release --manifest-path scripts/release-verifier/Cargo.toml - run: python -m unittest discover -s scripts -p 'release_test.py' -v + - run: cargo test --locked --manifest-path src-tauri/Cargo.toml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9eafe11..755a9b0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -115,8 +115,14 @@ jobs: - name: Install Linux desktop dependencies if: runner.os == 'Linux' run: | - sudo apt-get update - sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf + # Ubuntu 22.04 keeps distro sources here; unrelated vendor repos are not needed. + test -s /etc/apt/sources.list + apt_options=( + -o Dir::Etc::sourcelist=/etc/apt/sources.list + -o Dir::Etc::sourceparts=- + ) + sudo apt-get "${apt_options[@]}" --error-on=any update + sudo apt-get "${apt_options[@]}" install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf - run: npm ci - run: npm test - run: cargo test --locked --manifest-path src-tauri/Cargo.toml diff --git a/AGENTS.md b/AGENTS.md index 7f6112c..b8f87da 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -109,6 +109,8 @@ payload are in `/root/shacraft` on the ShaCraft host; see - `docs/game-trust-boundary.md` — the Mojang/NeoForge/Microsoft/Adoptium trust domains used to install and run the game itself. - `.github/workflows/check.yml` — push/PR UI checks and Linux Rust tests. + Ubuntu 22.04 dependency steps use only the runner main Ubuntu source list; + keep APT signature/hash checks and fail on index errors. Vendor PPAs are not needed. - `.github/workflows/build.yml` — four-platform CI packages with disposable test signing keys, explicitly unusable as production releases. - Release workflows and `scripts/release.py` implement protected draft → publish diff --git a/docs/launcher-architecture.md b/docs/launcher-architecture.md index 087c7ca..997c9eb 100644 --- a/docs/launcher-architecture.md +++ b/docs/launcher-architecture.md @@ -110,7 +110,10 @@ Hostile same-user TOCTOU is outside this protection; it is not an OS sandbox. `npm test` covers asynchronous helpers and state transitions; `npm run build` runs strict TypeScript before Vite. `cargo test --locked` -covers native policy and storage. Push/PR CI repeats checks on Linux. +covers native policy and storage. Push/PR CI repeats checks on Linux. Ubuntu 22.04 +CI installs desktop dependencies from the runner main distro source list only, +without indexing unrelated vendor repositories. APT signature/hash checks remain +mandatory, and any distro index failure stops installation. The package workflow builds and checks Windows x64, Linux x64 and both macOS architectures with disposable test signing keys. These artifacts cannot be published as production updater releases. Separate protected workflows assemble diff --git a/scripts/release_gate_test.py b/scripts/release_gate_test.py index c3291c4..3c16897 100644 --- a/scripts/release_gate_test.py +++ b/scripts/release_gate_test.py @@ -2,10 +2,12 @@ import os import re +import shutil import subprocess import tempfile import unittest from pathlib import Path +from unittest.mock import patch ROOT = Path(os.environ.get("RELEASE_TEST_ROOT", Path(__file__).resolve().parents[1])) @@ -13,6 +15,32 @@ WORKFLOWS = [ROOT / ".github/workflows" / name for name in ("release.yml", "rele GATE = " - name: Resolve tag using trusted workflow Git commands\n" +def gate_bash(windows=os.name == "nt"): + # Native CreateProcess can resolve bare bash to the System32 WSL launcher + # before Git Bash. Bind tests to the Bash installed with the Git we use. + if windows: + git = shutil.which("git") + if git: + directory = Path(git).resolve().parent + candidates = ( + directory / "bash.exe", # Git/bin/git.exe + directory.parent / "bin/bash.exe", # Git/cmd/git.exe + directory.parent.parent / "bin/bash.exe", # Git/mingw64/bin/git.exe + ) + for candidate in candidates: + if candidate.is_file(): + return str(candidate.resolve()) + raise RuntimeError("Git for Windows Bash was not found beside the installed Git") + bash = shutil.which("bash") + if not bash: + raise RuntimeError("Bash is required to execute the release workflow gate") + return str(Path(bash).resolve()) + + +def gate_diagnostic(result): + return f"command={result.args!r}\nstdout={result.stdout!r}\nstderr={result.stderr!r}" + + def workflow_gate(workflow): after = workflow.read_text().split(GATE, 1)[1] lines = after.split(" run: |\n", 1)[1].splitlines() @@ -60,23 +88,52 @@ class ReleaseGateTests(unittest.TestCase): def run_gate(self, workflow, tag): output = self.directory / (workflow.stem + "-output") output.unlink(missing_ok=True) - result = subprocess.run(["bash", "-c", workflow_gate(workflow)], cwd=self.directory, + result = subprocess.run([gate_bash(), "--noprofile", "--norc", "-c", workflow_gate(workflow)], cwd=self.directory, env=dict(self.environment, RELEASE_TAG=tag, GITHUB_OUTPUT=str(output)), capture_output=True, text=True, check=False) return result, output.read_text() if output.exists() else "" + def test_windows_gate_uses_git_bash_even_when_path_contains_wsl_launcher(self): + git_root = self.directory / "Git with spaces" + bash = git_root / "bin/bash.exe" + bash.parent.mkdir(parents=True) + bash.touch() + wsl = self.directory / "System32/bash.exe" + wsl.parent.mkdir() + wsl.touch() + for layout in ("bin", "cmd", "mingw64/bin"): + with self.subTest(layout=layout): + git = git_root / layout / "git.exe" + git.parent.mkdir(parents=True, exist_ok=True) + git.touch() + with patch.object(shutil, "which", side_effect=lambda name: str(git if name == "git" else wsl)) as lookup: + self.assertEqual(gate_bash(windows=True), str(bash.resolve())) + lookup.assert_called_once_with("git") + + def test_windows_gate_never_falls_back_to_unrelated_bash(self): + git = self.directory / "Git/cmd/git.exe" + git.parent.mkdir(parents=True) + git.touch() + wsl = self.directory / "System32/bash.exe" + wsl.parent.mkdir() + wsl.touch() + with patch.object(shutil, "which", side_effect=lambda name: str(git if name == "git" else wsl)) as lookup: + with self.assertRaisesRegex(RuntimeError, "Git for Windows Bash"): + gate_bash(windows=True) + lookup.assert_called_once_with("git") + def test_main_tag_emits_immutable_commit(self): for workflow in WORKFLOWS: with self.subTest(workflow=workflow.name): result, output = self.run_gate(workflow, "v0.2.0") - self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.returncode, 0, gate_diagnostic(result)) self.assertEqual(output, f"commit={self.good}\n") def test_unreviewed_tag_cannot_replace_its_own_ancestry_validator(self): for workflow in WORKFLOWS: with self.subTest(workflow=workflow.name): result, output = self.run_gate(workflow, "v9.9.9") - self.assertNotEqual(result.returncode, 0) + self.assertNotEqual(result.returncode, 0, gate_diagnostic(result)) self.assertEqual(output, "") self.assertFalse((self.directory / "untrusted-code-ran").exists()) @@ -84,15 +141,15 @@ class ReleaseGateTests(unittest.TestCase): for workflow in WORKFLOWS: with self.subTest(workflow=workflow.name): result, output = self.run_gate(workflow, "v8.8.8") - self.assertNotEqual(result.returncode, 0) + self.assertNotEqual(result.returncode, 0, gate_diagnostic(result)) self.assertEqual(output, "") self.git("tag", "-f", "v0.2.0", self.good) result, output = self.run_gate(workflow, "v0.2.0") - self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.returncode, 0, gate_diagnostic(result)) self.git("tag", "-f", "v0.2.0", self.bad) self.assertEqual(output, f"commit={self.good}\n") result, fresh_output = self.run_gate(workflow, "v0.2.0") - self.assertNotEqual(result.returncode, 0) + self.assertNotEqual(result.returncode, 0, gate_diagnostic(result)) self.assertEqual(fresh_output, "") def test_workflows_execute_candidate_code_only_after_gate_and_checkout_sha(self):