diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3518796 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,236 @@ +name: SweetLoader CI + +# Every gate in this workflow is a real check, not a smoke test. The job fails on the first +# one that breaks, because a green run is the claim that the library a client will load is the +# library the sources describe. +on: + push: + branches: + - "**" + tags: + - "v*" + pull_request: + branches: + - "**" + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + +jobs: + host-tests: + name: Host tests (C++ suite + sanitisers) + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + + # The runner image ships g++, curl and binutils. setarch is part of util-linux, which the + # ThreadSanitizer runs need: the runner has ASLR on and TSAN refuses to start without + # `setarch -R`, so without this the three TSAN tests would silently skip instead of running. + - name: Install host test dependencies + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install -y --no-install-recommends g++ zlib1g-dev util-linux + + - name: Verify the toolchain the suite needs is present + run: | + set -euo pipefail + for tool in g++ curl setarch; do + command -v "$tool" >/dev/null || { echo "missing required tool: $tool" >&2; exit 1; } + done + g++ --version | head -1 + + - name: Run the C++ host suite + # A skipped TSAN test prints "(skipped: ThreadSanitizer/setarch unavailable)". The runner + # is set up above so none of the three can skip; if one does, that is a regression in the + # environment and it must not read as a pass. + env: + # The suite fetches its nlohmann/json and fmt headers into a cache beside the checkout. + # Point it at the runner's temp dir so nothing is written inside the workspace. + SWEETLOADER_HOST_CACHE: ${{ runner.temp }}/sweetloader-host-cache + run: | + set -euo pipefail + bash tests/cosmetics/run_tests.sh 2>&1 | tee /tmp/host-tests.log + # grep reads the log file, not a pipe: `tee ... | grep -q` under pipefail would report + # tee's SIGPIPE as the pipeline status. + grep -q "skipped: ThreadSanitizer" /tmp/host-tests.log && { + echo "a ThreadSanitizer test skipped; the sanitizer must actually run" >&2 + exit 1 + } + grep -q "all cosmetics host tests passed" /tmp/host-tests.log + + rust: + name: Rust workspace + runs-on: ubuntu-latest + timeout-minutes: 30 + defaults: + run: + working-directory: rust + steps: + - uses: actions/checkout@v4 + + # Pinned: rustfmt and clippy change between releases, so an unpinned toolchain would move the + # gates under the repository without a commit. + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: "1.100.0" + components: clippy, rustfmt + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: rust + + - name: cargo fmt --check + run: cargo fmt --all -- --check + + # Style lints are advisory (the workspace carries ~30), so only the correctness group is a + # hard error. That group is the one that catches a real defect rather than a preference. + - name: cargo clippy (correctness lints are errors) + run: cargo clippy --workspace --all-targets -- -D clippy::correctness + + - name: cargo test --workspace + run: cargo test --workspace + + native-build: + name: Native build (arm64-v8a) + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@v4 + + # r28c is the NDK the client pins (`ndkVersion = "28.2.13676358"`), so the CI build uses the + # same toolchain the shipped library is built with. + - uses: nttld/setup-ndk@v1 + id: ndk + with: + ndk-version: r28c + add-to-path: false + + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: "1.100.0" + targets: aarch64-linux-android + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: rust + + - name: Configure + env: + NDK: ${{ steps.ndk.outputs.ndk-path }} + run: | + set -euo pipefail + cmake -S . -B build -G Ninja \ + -DCMAKE_TOOLCHAIN_FILE="$NDK/build/cmake/android.toolchain.cmake" \ + -DANDROID_ABI=arm64-v8a \ + -DANDROID_PLATFORM=28 \ + -DCMAKE_BUILD_TYPE=Release + + # CMakeLists.txt carries -Wall -Wextra plus -Werror=return-type and -Werror=format, so a + # warning that matters already fails the build here. This step is the only one that compiles + # the majority of the C++ (most of it is not host-compiled). + - name: Build libsweetloader.so + run: cmake --build build --parallel "$(nproc)" + + - name: Verify the built library + env: + NDK: ${{ steps.ndk.outputs.ndk-path }} + run: | + set -euo pipefail + so=build/libsweetloader.so + [ -f "$so" ] || { echo "libsweetloader.so was not produced" >&2; exit 1; } + nm="$(find "$NDK/toolchains/llvm/prebuilt" -name llvm-nm | head -1)" + readelf="$(find "$NDK/toolchains/llvm/prebuilt" -name llvm-readelf | head -1)" + echo "nm=$nm readelf=$readelf" + + # Dump the tables to files once. Every check below greps a file rather than a pipe: under + # `set -o pipefail` a `nm ... | grep -q` reports the producer's SIGPIPE (grep -q exits on + # its first match and closes the pipe) as the pipeline's status, so a symbol that is + # present reads as missing. + "$nm" -D --defined-only "$so" > /tmp/dynsyms.txt + "$readelf" -h "$so" > /tmp/elfheader.txt + "$readelf" -d "$so" > /tmp/elfdyn.txt + + # 1. The ABI is the one the client ships. + grep -q "AArch64" /tmp/elfheader.txt || { + echo "libsweetloader.so is not an AArch64 object" >&2; exit 1; } + + # 2. JNI symbols bind to the org.chimeramc.client package. A rename of the launcher's + # package would otherwise produce a library that loads and then resolves nothing. + jni="$(grep -c 'Java_org_chimeramc_client' /tmp/dynsyms.txt || true)" + echo "JNI exports: $jni" + [ "$jni" -gt 0 ] || { echo "no Java_org_chimeramc_client JNI exports" >&2; exit 1; } + + # 3. The C++ SDK entry points the mod-facing headers declare. The leading space anchors + # the name so a symbol that merely ends in the same text cannot satisfy the check. + for symbol in JNI_OnLoad GetSweetLoaderInput PLRequestDocument; do + grep -q " $symbol\$" /tmp/dynsyms.txt || { + echo "missing exported symbol: $symbol" >&2; exit 1; } + done + + # 4. `--exclude-libs,ALL` keeps the static archives' symbols out of the dynsym. A leak + # here means the link options regressed and a mod's copy could clash. + leaked="$(grep -c '_ZN3fmt\|_ZN7lunasvg\|_ZN8plutovg' /tmp/dynsyms.txt || true)" + [ "$leaked" -eq 0 ] || { + echo "static-archive symbols leaked into the dynamic table: $leaked" >&2; exit 1; } + + # 5. The list of libraries the loader must resolve at dlopen time. + grep NEEDED /tmp/elfdyn.txt + + - name: Cross-compile the Rust cdylibs + env: + ANDROID_NDK_HOME: ${{ steps.ndk.outputs.ndk-path }} + ANDROID_API_LEVEL: "28" + run: bash rust/build-android.sh + + - name: Verify the Rust cdylibs + run: | + set -euo pipefail + # The crate lib names are the file names the JNI loaders expect. A rename shows up here + # rather than as an UnsatisfiedLinkError on a device. + for lib in libcaramel_universe.so libcaramel_singularity.so libsweetloader_nethernet.so; do + f="rust/target/aarch64-linux-android/release/$lib" + test -f "$f" || { echo "missing Rust cdylib: $f" >&2; exit 1; } + echo "$f: $(stat -c %s "$f") bytes" + done + + - name: Upload the native artifacts + uses: actions/upload-artifact@v4 + with: + name: sweetloader-native-arm64-v8a + if-no-files-found: error + retention-days: 14 + path: | + build/libsweetloader.so + rust/target/aarch64-linux-android/release/libcaramel_universe.so + rust/target/aarch64-linux-android/release/libcaramel_singularity.so + rust/target/aarch64-linux-android/release/libsweetloader_nethernet.so + + repo-integrity: + name: Repo integrity + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + + - name: Sources, tests and the build list agree + run: python3 scripts/check_sources_listed.py + + - name: The pinned dependencies are the ones NOTICE describes + run: python3 scripts/check_pinned_deps.py + + - name: The rule generator and the package reader behave + run: | + set -euo pipefail + python3 tools/tests/gen_rules_test.py + python3 scripts/tests/check_sources_listed_test.py + python3 scripts/tests/check_pinned_deps_test.py diff --git a/.gitignore b/.gitignore index 655be68..0a5200f 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,7 @@ docs/.vitepress/dist # Rust build output for the .universe / .singularity engine crates. rust/target + +# Bytecode from the host test and checker scripts under tools/ and scripts/. +__pycache__/ +*.pyc diff --git a/README.md b/README.md index 144fabb..261dfa0 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,37 @@ bash tests/cosmetics/run_tests.sh They need a host C++20 compiler and `zlib` headers. The `nlohmann/json` single header is fetched into a gitignored cache on first run, or point `NLOHMANN_JSON_INCLUDE` at an existing copy. +The three ThreadSanitizer tests need `setarch` (util-linux) so the sanitizer can disable ASLR; +without it they report `skipped` rather than failing, and CI treats that as a failure so a +sanitizer that stopped running cannot read as a pass. + +## Continuous integration + +`.github/workflows/ci.yml` runs on every push and pull request, and a green run is the claim that +the library a client loads is the library the sources describe. Four jobs, each a real check: + +| Job | What it proves | +| --- | --- | +| `host-tests` | The C++ suite (57 tests, including the TSAN ones) passes on the host, with no sanitizer silently skipped. | +| `rust` | `cargo fmt --check`, `cargo clippy -D clippy::correctness` and `cargo test --workspace` (249 tests) are clean on a pinned toolchain. | +| `native-build` | `libsweetloader.so` and the three Rust cdylibs cross-compile for arm64-v8a on the NDK the client pins, and the built `.so` exports the JNI and SDK symbols with no static-archive leakage. | +| `repo-integrity` | No `.cpp` is missing from `SWEETLOADER_SOURCES`, no host test is unregistered, every fetched dependency is pinned to a commit and attributed in `NOTICE`, and the tooling's own tests pass. | + +The two drift checks are also runnable by hand: + +```sh +python3 scripts/check_sources_listed.py +python3 scripts/check_pinned_deps.py +python3 tools/tests/gen_rules_test.py +``` + +`gen_rules.py` derives the RTTI vtable rules from a Minecraft APK. Its test builds a synthetic +stripped-binary-shaped ELF so the `.data.rel.ro` relocation step is exercised without a real +multi-hundred-megabyte download; disabling that step makes three of the eight tests fail. + +The Rust tree was normalized with `rustfmt` when the `fmt --check` gate was added. It had never +been formatted, so the gate is what keeps it that way. + ## Writing a mod against it A mod is a shared library that the client's mod loader `dlopen`s. It includes the public headers diff --git a/rust/caramel-cli/src/lib.rs b/rust/caramel-cli/src/lib.rs index be598bb..f75992a 100644 --- a/rust/caramel-cli/src/lib.rs +++ b/rust/caramel-cli/src/lib.rs @@ -16,11 +16,24 @@ use universe_core::{sha256_hex, Error, Result}; /// A parsed command line. #[derive(Debug, Clone, PartialEq)] pub enum Command { - Pack { source: PathBuf, output: PathBuf }, - Sign { package: PathBuf, private_key_hex: String }, - Verify { package: PathBuf, public_key_hex: String }, - Keygen { output: Option }, - Inspect { package: PathBuf }, + Pack { + source: PathBuf, + output: PathBuf, + }, + Sign { + package: PathBuf, + private_key_hex: String, + }, + Verify { + package: PathBuf, + public_key_hex: String, + }, + Keygen { + output: Option, + }, + Inspect { + package: PathBuf, + }, } impl Command { @@ -101,7 +114,10 @@ const MAX_PACKAGE_BYTES: u64 = 512 * 1024 * 1024; /// Builds a ZIP from a directory, deterministically. pub fn pack(source: &Path, output: &Path) -> Result<()> { if !source.is_dir() { - return Err(Error::new(format!("{} is not a directory", source.display()))); + return Err(Error::new(format!( + "{} is not a directory", + source.display() + ))); } // The manifest must exist and parse, or the package would be refused at load time. let manifest_path = source.join("manifest.json"); @@ -136,7 +152,8 @@ pub fn pack(source: &Path, output: &Path) -> Result<()> { zip.finish() .map_err(|e| Error::new(format!("could not finish the archive: {e}")))?; - let bytes = fs::read(output).map_err(|e| Error::new(format!("could not read back the package: {e}")))?; + let bytes = fs::read(output) + .map_err(|e| Error::new(format!("could not read back the package: {e}")))?; println!("packed {} files into {}", files.len(), output.display()); println!("sha256 {}", sha256_hex(&bytes)); Ok(()) @@ -147,7 +164,8 @@ fn collect_files(root: &Path, dir: &Path, out: &mut Vec<(String, PathBuf)>) -> R let entries = fs::read_dir(dir) .map_err(|e| Error::new(format!("could not read {}: {e}", dir.display())))?; for entry in entries { - let entry = entry.map_err(|e| Error::new(format!("could not read a directory entry: {e}")))?; + let entry = + entry.map_err(|e| Error::new(format!("could not read a directory entry: {e}")))?; let path = entry.path(); let metadata = fs::symlink_metadata(&path) .map_err(|e| Error::new(format!("could not stat {}: {e}", path.display())))?; @@ -215,7 +233,8 @@ pub fn verify(package: &Path, public_key_hex: &str) -> Result<()> { /// `output` (or printed), and the public key is printed in the hex form the trusted-key set uses. pub fn keygen(output: Option<&Path>) -> Result<()> { let mut seed = [0u8; 32]; - getrandom::getrandom(&mut seed).map_err(|e| Error::new(format!("no entropy available: {e}")))?; + getrandom::getrandom(&mut seed) + .map_err(|e| Error::new(format!("no entropy available: {e}")))?; let signing_key = signature::signing_key_from_bytes(&seed)?; let public = signature::public_key_of(&signing_key); let private_hex = universe_core::to_hex(&seed); diff --git a/rust/caramel-cli/src/main.rs b/rust/caramel-cli/src/main.rs index f3ec81a..b9ba353 100644 --- a/rust/caramel-cli/src/main.rs +++ b/rust/caramel-cli/src/main.rs @@ -49,4 +49,3 @@ fn print_usage() { env!("CARGO_PKG_VERSION") ); } - diff --git a/rust/nethernet-core/src/backoff.rs b/rust/nethernet-core/src/backoff.rs index 540e489..d67cbb7 100644 --- a/rust/nethernet-core/src/backoff.rs +++ b/rust/nethernet-core/src/backoff.rs @@ -23,7 +23,11 @@ impl Default for BackoffPolicy { fn default() -> Self { // 500ms, 1s, 2s, 4s, 8s, 16s, then the 20s cap. The launcher's whole reconnect budget is // sized around this cap: a user who cancels is never more than 20s from the next attempt. - Self { base_ms: 500, factor: 2, cap_ms: 20_000 } + Self { + base_ms: 500, + factor: 2, + cap_ms: 20_000, + } } } @@ -33,7 +37,11 @@ impl BackoffPolicy { assert!(base_ms > 0, "backoff base must be non-zero"); assert!(factor >= 1, "backoff factor must be at least 1"); assert!(cap_ms >= base_ms, "backoff cap must not be below the base"); - Self { base_ms, factor, cap_ms } + Self { + base_ms, + factor, + cap_ms, + } } /// The cap, in milliseconds. @@ -106,7 +114,11 @@ mod tests { let p = BackoffPolicy::default(); for attempt in 0..10 { let nominal = p.nominal_ms(attempt); - assert_eq!(p.delay_ms(attempt, 0.0), nominal / 2, "floor at attempt {attempt}"); + assert_eq!( + p.delay_ms(attempt, 0.0), + nominal / 2, + "floor at attempt {attempt}" + ); // The top of the band is nominal - 1 at the very edge, and nominal for any rng < 1. let top = p.delay_ms(attempt, 0.999_999); assert!(top <= nominal, "attempt {attempt}: {top} > {nominal}"); diff --git a/rust/nethernet-core/src/framing.rs b/rust/nethernet-core/src/framing.rs index f13f943..7bb0d41 100644 --- a/rust/nethernet-core/src/framing.rs +++ b/rust/nethernet-core/src/framing.rs @@ -92,22 +92,42 @@ pub struct Frame { impl Frame { /// A reliable data frame. pub fn data(sequence: u32, payload: Vec) -> Self { - Self { kind: FrameKind::Data, sequence, reliable: true, payload } + Self { + kind: FrameKind::Data, + sequence, + reliable: true, + payload, + } } /// An acknowledgement. `sequence` is the number being acknowledged. pub fn ack(sequence: u32) -> Self { - Self { kind: FrameKind::Ack, sequence, reliable: false, payload: Vec::new() } + Self { + kind: FrameKind::Ack, + sequence, + reliable: false, + payload: Vec::new(), + } } /// A negative acknowledgement: "resend from here". pub fn nack(sequence: u32) -> Self { - Self { kind: FrameKind::Nack, sequence, reliable: false, payload: Vec::new() } + Self { + kind: FrameKind::Nack, + sequence, + reliable: false, + payload: Vec::new(), + } } /// A keepalive. pub fn keepalive(sequence: u32) -> Self { - Self { kind: FrameKind::Keepalive, sequence, reliable: false, payload: Vec::new() } + Self { + kind: FrameKind::Keepalive, + sequence, + reliable: false, + payload: Vec::new(), + } } /// Encodes this frame to bytes. Fails only if the payload exceeds [`MAX_FRAME_BYTES`]. @@ -144,8 +164,14 @@ pub enum FrameError { impl std::fmt::Display for FrameError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - Self::FrameTooLarge(n) => write!(f, "frame of {n} bytes exceeds the {MAX_FRAME_BYTES}-byte cap"), - Self::LengthTooSmall(n) => write!(f, "frame length {n} is below the {HEADER_BYTES}-byte header"), + Self::FrameTooLarge(n) => write!( + f, + "frame of {n} bytes exceeds the {MAX_FRAME_BYTES}-byte cap" + ), + Self::LengthTooSmall(n) => write!( + f, + "frame length {n} is below the {HEADER_BYTES}-byte header" + ), Self::UnknownKind(k) => write!(f, "unknown frame kind {k}"), Self::ControlWithPayload(k) => write!(f, "control frame {k:?} carried a payload"), Self::PayloadTooLarge(n) => write!(f, "payload of {n} bytes exceeds the cap"), @@ -177,7 +203,12 @@ pub fn decode_frame(bytes: &[u8]) -> Result { if kind.is_control() && !payload.is_empty() { return Err(FrameError::ControlWithPayload(kind)); } - Ok(Frame { kind, sequence, reliable, payload }) + Ok(Frame { + kind, + sequence, + reliable, + payload, + }) } /// A streaming reader that turns arbitrary chunk boundaries into whole frames. @@ -256,7 +287,9 @@ pub struct ReorderBuffer { impl ReorderBuffer { pub fn new() -> Self { - Self { highest_delivered: None } + Self { + highest_delivered: None, + } } /// True when this sequence number is new and should be delivered. diff --git a/rust/nethernet-core/src/session.rs b/rust/nethernet-core/src/session.rs index 5270dcb..79cb22c 100644 --- a/rust/nethernet-core/src/session.rs +++ b/rust/nethernet-core/src/session.rs @@ -44,7 +44,10 @@ impl TransportState { /// True while the transport is doing work the UI should show progress for. pub fn is_busy(self) -> bool { - matches!(self, Self::Signaling | Self::AwaitingWelcome | Self::Connecting) + matches!( + self, + Self::Signaling | Self::AwaitingWelcome | Self::Connecting + ) } /// True once no further transition can happen without a new connect. @@ -200,7 +203,10 @@ impl Session { /// The budget for a state, if it has one. pub fn budget(&self, state: TransportState) -> Option { - self.budgets.iter().find(|(s, _)| *s == state).map(|(_, d)| *d) + self.budgets + .iter() + .find(|(s, _)| *s == state) + .map(|(_, d)| *d) } /// The legal next states from the current one. @@ -322,7 +328,11 @@ mod tests { fn idle_cannot_jump_straight_to_connected() { let mut s = session(); assert!(!s.transition(TransportState::Connected)); - assert_eq!(s.state(), TransportState::Idle, "a refused transition must not move"); + assert_eq!( + s.state(), + TransportState::Idle, + "a refused transition must not move" + ); } #[test] @@ -372,7 +382,10 @@ mod tests { s.transition(TransportState::Signaling); s.fail(TransportError::Cancelled); assert!(s.transition(TransportState::Idle)); - assert!(s.error().is_none(), "a new connect must clear the previous failure"); + assert!( + s.error().is_none(), + "a new connect must clear the previous failure" + ); } #[test] @@ -382,7 +395,9 @@ mod tests { advance_ms(14_999); assert!(s.check_timeout().is_none(), "must not fire early"); advance_ms(1); - let error = s.check_timeout().expect("the budget must fire at exactly 15s"); + let error = s + .check_timeout() + .expect("the budget must fire at exactly 15s"); assert_eq!(error, TransportError::Timeout("opening the connection")); assert_eq!(s.state(), TransportState::Failed); } @@ -395,7 +410,10 @@ mod tests { s.transition(TransportState::Connecting); s.transition(TransportState::Connected); advance_ms(60 * 60 * 1_000); - assert!(s.check_timeout().is_none(), "a connected session has no budget"); + assert!( + s.check_timeout().is_none(), + "a connected session has no budget" + ); } #[test] @@ -426,7 +444,10 @@ mod tests { TransportState::Closed, TransportState::Failed, ] { - assert!(!(state.is_busy() && state.is_terminal()), "{state:?} is both"); + assert!( + !(state.is_busy() && state.is_terminal()), + "{state:?} is both" + ); } assert!(TransportState::Signaling.is_busy()); assert!(TransportState::AwaitingWelcome.is_busy()); @@ -442,8 +463,16 @@ mod tests { assert!(TransportError::Timeout("x").is_retryable()); assert!(TransportError::PeerClosed(String::new()).is_retryable()); assert!(!TransportError::Cancelled.is_retryable()); - assert!(!TransportError::ProtocolMismatch { client: 1, server: 2 }.is_retryable()); - assert!(!TransportError::ServiceRefused { code: "b".into(), message: String::new() }.is_retryable()); + assert!(!TransportError::ProtocolMismatch { + client: 1, + server: 2 + } + .is_retryable()); + assert!(!TransportError::ServiceRefused { + code: "b".into(), + message: String::new() + } + .is_retryable()); assert!(!TransportError::Malformed(String::new()).is_retryable()); } @@ -451,8 +480,14 @@ mod tests { fn every_error_tag_is_distinct_and_snake_case() { let errors = [ TransportError::SignalingUnreachable(String::new()), - TransportError::ProtocolMismatch { client: 1, server: 2 }, - TransportError::ServiceRefused { code: "c".into(), message: String::new() }, + TransportError::ProtocolMismatch { + client: 1, + server: 2, + }, + TransportError::ServiceRefused { + code: "c".into(), + message: String::new(), + }, TransportError::Timeout("x"), TransportError::PeerClosed(String::new()), TransportError::Cancelled, @@ -462,7 +497,10 @@ mod tests { let unique: std::collections::HashSet<&str> = tags.iter().copied().collect(); assert_eq!(unique.len(), errors.len(), "tags must be distinct"); for tag in tags { - assert!(tag.chars().all(|c| c.is_ascii_lowercase() || c == '_'), "{tag}"); + assert!( + tag.chars().all(|c| c.is_ascii_lowercase() || c == '_'), + "{tag}" + ); } } @@ -470,8 +508,14 @@ mod tests { fn user_copy_never_leaks_a_code_or_a_stack_word() { let errors = [ TransportError::SignalingUnreachable("dns".into()), - TransportError::ProtocolMismatch { client: 1, server: 9 }, - TransportError::ServiceRefused { code: "busy".into(), message: "try later".into() }, + TransportError::ProtocolMismatch { + client: 1, + server: 9, + }, + TransportError::ServiceRefused { + code: "busy".into(), + message: "try later".into(), + }, TransportError::Timeout("connecting"), TransportError::PeerClosed("host left".into()), TransportError::Cancelled, @@ -488,7 +532,11 @@ mod tests { #[test] fn the_protocol_mismatch_copy_names_both_versions() { - let text = TransportError::ProtocolMismatch { client: 1, server: 3 }.user_message(); + let text = TransportError::ProtocolMismatch { + client: 1, + server: 3, + } + .user_message(); assert!(text.contains('3')); assert!(text.contains('1')); } diff --git a/rust/nethernet-core/src/signaling.rs b/rust/nethernet-core/src/signaling.rs index 3d0108b..93a4812 100644 --- a/rust/nethernet-core/src/signaling.rs +++ b/rust/nethernet-core/src/signaling.rs @@ -44,9 +44,7 @@ pub enum ClientSignal { payload: String, }, /// Tear down a session. - Disconnect { - session: String, - }, + Disconnect { session: String }, } /// What the service sends to the client. @@ -65,10 +63,7 @@ pub enum ServerSignal { peer_network_id: String, }, /// A negotiated ICE payload for a session. - Candidate { - session: String, - payload: String, - }, + Candidate { session: String, payload: String }, /// The peer or the service ended the session. Disconnect { session: String, @@ -87,12 +82,27 @@ pub enum ServerSignal { /// A parsed server message, or the reason it could not be used. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Signal { - Welcome { protocol: u32, session: String }, - Connect { session: String, peer_network_id: String }, - Candidate { session: String, payload: String }, - Disconnect { session: String, reason: String }, + Welcome { + protocol: u32, + session: String, + }, + Connect { + session: String, + peer_network_id: String, + }, + Candidate { + session: String, + payload: String, + }, + Disconnect { + session: String, + reason: String, + }, /// The service reported an error. Distinct from a parse failure: this one is a real answer. - ServiceError { code: String, message: String }, + ServiceError { + code: String, + message: String, + }, } /// Why a server message could not be used. @@ -118,7 +128,10 @@ impl std::fmt::Display for SignalError { Self::MissingField(field) => write!(f, "signaling message is missing {field}"), Self::TooLarge(n) => write!(f, "signaling frame of {n} bytes exceeds the cap"), Self::ProtocolMismatch { client, server } => { - write!(f, "signaling protocol mismatch: client {client}, server {server}") + write!( + f, + "signaling protocol mismatch: client {client}, server {server}" + ) } } } @@ -134,8 +147,7 @@ pub fn parse_server_signal(frame: &[u8]) -> Result { if frame.len() > MAX_SIGNAL_FRAME_BYTES { return Err(SignalError::TooLarge(frame.len())); } - let text = std::str::from_utf8(frame) - .map_err(|e| SignalError::MalformedJson(e.to_string()))?; + let text = std::str::from_utf8(frame).map_err(|e| SignalError::MalformedJson(e.to_string()))?; // The message type is read before the full deserialise so an unknown type reports as // `Unsupported` with its name, which is what makes a protocol drift diagnosable from a log. let probe: serde_json::Value = @@ -146,8 +158,8 @@ pub fn parse_server_signal(frame: &[u8]) -> Result { .ok_or(SignalError::MissingField("type"))? .to_string(); - let message: ServerSignal = serde_json::from_value(probe) - .map_err(|_| SignalError::Unsupported(kind.clone()))?; + let message: ServerSignal = + serde_json::from_value(probe).map_err(|_| SignalError::Unsupported(kind.clone()))?; let signal = match message { ServerSignal::Welcome { protocol, session } => { @@ -155,18 +167,27 @@ pub fn parse_server_signal(frame: &[u8]) -> Result { return Err(SignalError::MissingField("session")); } if protocol != PROTOCOL_VERSION { - return Err(SignalError::ProtocolMismatch { client: PROTOCOL_VERSION, server: protocol }); + return Err(SignalError::ProtocolMismatch { + client: PROTOCOL_VERSION, + server: protocol, + }); } Signal::Welcome { protocol, session } } - ServerSignal::Connect { session, peer_network_id } => { + ServerSignal::Connect { + session, + peer_network_id, + } => { if session.is_empty() { return Err(SignalError::MissingField("session")); } if peer_network_id.is_empty() { return Err(SignalError::MissingField("peer_network_id")); } - Signal::Connect { session, peer_network_id } + Signal::Connect { + session, + peer_network_id, + } } ServerSignal::Candidate { session, payload } => { if session.is_empty() { @@ -200,22 +221,32 @@ pub fn encode_client_signal(signal: &ClientSignal) -> String { /// The hello this client sends first. pub fn hello(network_id: &str) -> ClientSignal { - ClientSignal::Hello { protocol: PROTOCOL_VERSION, network_id: network_id.to_string() } + ClientSignal::Hello { + protocol: PROTOCOL_VERSION, + network_id: network_id.to_string(), + } } /// Builds a connect request. pub fn connect(peer_network_id: &str) -> ClientSignal { - ClientSignal::Connect { peer_network_id: peer_network_id.to_string() } + ClientSignal::Connect { + peer_network_id: peer_network_id.to_string(), + } } /// Builds a candidate relay message. pub fn candidate(session: &str, payload: &str) -> ClientSignal { - ClientSignal::Candidate { session: session.to_string(), payload: payload.to_string() } + ClientSignal::Candidate { + session: session.to_string(), + payload: payload.to_string(), + } } /// Builds a disconnect. pub fn disconnect(session: &str) -> ClientSignal { - ClientSignal::Disconnect { session: session.to_string() } + ClientSignal::Disconnect { + session: session.to_string(), + } } #[cfg(test)] @@ -232,14 +263,28 @@ mod tests { #[test] fn a_welcome_with_the_matching_protocol_parses() { - let signal = parse_server_signal(br#"{"type":"welcome","protocol":1,"session":"s-1"}"#).unwrap(); - assert_eq!(signal, Signal::Welcome { protocol: 1, session: "s-1".into() }); + let signal = + parse_server_signal(br#"{"type":"welcome","protocol":1,"session":"s-1"}"#).unwrap(); + assert_eq!( + signal, + Signal::Welcome { + protocol: 1, + session: "s-1".into() + } + ); } #[test] fn a_welcome_with_a_future_protocol_is_a_mismatch_not_a_hang() { - let err = parse_server_signal(br#"{"type":"welcome","protocol":2,"session":"s"}"#).unwrap_err(); - assert_eq!(err, SignalError::ProtocolMismatch { client: 1, server: 2 }); + let err = + parse_server_signal(br#"{"type":"welcome","protocol":2,"session":"s"}"#).unwrap_err(); + assert_eq!( + err, + SignalError::ProtocolMismatch { + client: 1, + server: 2 + } + ); } #[test] @@ -309,7 +354,8 @@ mod tests { #[test] fn an_empty_peer_id_is_rejected() { assert_eq!( - parse_server_signal(br#"{"type":"connect","session":"s","peer_network_id":""}"#).unwrap_err(), + parse_server_signal(br#"{"type":"connect","session":"s","peer_network_id":""}"#) + .unwrap_err(), SignalError::MissingField("peer_network_id") ); } @@ -333,23 +379,34 @@ mod tests { #[test] fn a_disconnect_reason_is_optional_and_defaults_to_empty() { let signal = parse_server_signal(br#"{"type":"disconnect","session":"s"}"#).unwrap(); - assert_eq!(signal, Signal::Disconnect { session: "s".into(), reason: String::new() }); + assert_eq!( + signal, + Signal::Disconnect { + session: "s".into(), + reason: String::new() + } + ); } #[test] fn a_service_error_is_a_real_answer_not_a_parse_failure() { let signal = - parse_server_signal(br#"{"type":"error","code":"busy","message":"try later"}"#).unwrap(); + parse_server_signal(br#"{"type":"error","code":"busy","message":"try later"}"#) + .unwrap(); assert_eq!( signal, - Signal::ServiceError { code: "busy".into(), message: "try later".into() } + Signal::ServiceError { + code: "busy".into(), + message: "try later".into() + } ); } #[test] fn a_wrongly_typed_field_is_unsupported_not_a_panic() { // `session` is a number where the protocol says string. - let err = parse_server_signal(br#"{"type":"welcome","protocol":1,"session":7}"#).unwrap_err(); + let err = + parse_server_signal(br#"{"type":"welcome","protocol":1,"session":7}"#).unwrap_err(); assert_eq!(err, SignalError::Unsupported("welcome".into())); } @@ -374,7 +431,10 @@ mod tests { SignalError::Unsupported("t".into()), SignalError::MissingField("session"), SignalError::TooLarge(10), - SignalError::ProtocolMismatch { client: 1, server: 2 }, + SignalError::ProtocolMismatch { + client: 1, + server: 2, + }, ]; for case in cases { let text = case.to_string(); diff --git a/rust/nethernet-core/tests/framing.rs b/rust/nethernet-core/tests/framing.rs index 827e186..9eb5126 100644 --- a/rust/nethernet-core/tests/framing.rs +++ b/rust/nethernet-core/tests/framing.rs @@ -52,10 +52,14 @@ fn the_header_is_big_endian() { fn the_reliable_bit_is_separate_from_the_kind() { let reliable = Frame::data(1, b"x".to_vec()).encode().unwrap(); assert_eq!(reliable[4], 0x80, "reliable data must set the top bit"); - let unreliable = - Frame { kind: FrameKind::Data, sequence: 1, reliable: false, payload: b"x".to_vec() } - .encode() - .unwrap(); + let unreliable = Frame { + kind: FrameKind::Data, + sequence: 1, + reliable: false, + payload: b"x".to_vec(), + } + .encode() + .unwrap(); assert_eq!(unreliable[4], 0x00); assert!(!decode_frame(&unreliable).unwrap().reliable); } @@ -72,13 +76,19 @@ fn ack_nack_and_keepalive_round_trip() { #[test] fn a_frame_shorter_than_the_header_is_refused() { - assert_eq!(decode_frame(&[0u8; 4]).unwrap_err(), FrameError::LengthTooSmall(4)); + assert_eq!( + decode_frame(&[0u8; 4]).unwrap_err(), + FrameError::LengthTooSmall(4) + ); } #[test] fn a_length_prefix_below_the_fixed_body_is_refused() { let bytes = header(4, 0, 0); - assert_eq!(decode_frame(&bytes).unwrap_err(), FrameError::LengthTooSmall(4)); + assert_eq!( + decode_frame(&bytes).unwrap_err(), + FrameError::LengthTooSmall(4) + ); } #[test] @@ -100,13 +110,19 @@ fn a_payload_exactly_at_the_cap_is_accepted() { #[test] fn encoding_an_oversized_payload_is_refused_rather_than_truncated() { let frame = Frame::data(1, vec![0u8; MAX_FRAME_BYTES + 1]); - assert_eq!(frame.encode().unwrap_err(), FrameError::PayloadTooLarge(MAX_FRAME_BYTES + 1)); + assert_eq!( + frame.encode().unwrap_err(), + FrameError::PayloadTooLarge(MAX_FRAME_BYTES + 1) + ); } #[test] fn an_unknown_kind_byte_is_refused_with_its_value() { let bytes = header(5, 0x7F, 0); - assert_eq!(decode_frame(&bytes).unwrap_err(), FrameError::UnknownKind(0x7F)); + assert_eq!( + decode_frame(&bytes).unwrap_err(), + FrameError::UnknownKind(0x7F) + ); } #[test] @@ -114,7 +130,10 @@ fn a_control_frame_carrying_a_payload_is_refused() { // kind 1 is Ack; give it a one-byte payload. let mut bytes = header(6, 1, 0); bytes.push(0xAA); - assert_eq!(decode_frame(&bytes).unwrap_err(), FrameError::ControlWithPayload(FrameKind::Ack)); + assert_eq!( + decode_frame(&bytes).unwrap_err(), + FrameError::ControlWithPayload(FrameKind::Ack) + ); } #[test] @@ -123,7 +142,10 @@ fn the_reader_yields_nothing_until_a_frame_is_complete() { let mut reader = FrameReader::new(); for byte in &bytes[..bytes.len() - 1] { reader.push(&[*byte]).unwrap(); - assert!(reader.next_frame().is_none(), "a partial frame must not be delivered"); + assert!( + reader.next_frame().is_none(), + "a partial frame must not be delivered" + ); } reader.push(&bytes[bytes.len() - 1..]).unwrap(); assert_eq!(reader.next_frame().unwrap().unwrap().payload, b"payload"); @@ -160,9 +182,14 @@ fn the_reader_reports_a_bad_length_prefix_and_keeps_making_progress() { let mut reader = FrameReader::new(); // A length prefix of 1 is impossible: the fixed body is 5 bytes. reader.push(&header(1, 0, 0)).unwrap(); - assert_eq!(reader.next_frame().unwrap().unwrap_err(), FrameError::LengthTooSmall(1)); + assert_eq!( + reader.next_frame().unwrap().unwrap_err(), + FrameError::LengthTooSmall(1) + ); // The bad header was consumed, so a following valid frame still decodes. - reader.push(&Frame::data(9, b"ok".to_vec()).encode().unwrap()).unwrap(); + reader + .push(&Frame::data(9, b"ok".to_vec()).encode().unwrap()) + .unwrap(); assert_eq!(reader.next_frame().unwrap().unwrap().payload, b"ok"); } @@ -170,7 +197,9 @@ fn the_reader_reports_a_bad_length_prefix_and_keeps_making_progress() { fn the_reader_refuses_a_peer_that_never_completes_a_frame() { let mut reader = FrameReader::new(); // Claim the largest legal frame, then keep sending filler without ever finishing it. - reader.push(&header((MAX_FRAME_BYTES + 5) as u32, 0, 0)).unwrap(); + reader + .push(&header((MAX_FRAME_BYTES + 5) as u32, 0, 0)) + .unwrap(); let chunk = vec![0u8; 64 * 1024]; let mut refused = false; for _ in 0..64 { @@ -179,14 +208,20 @@ fn the_reader_refuses_a_peer_that_never_completes_a_frame() { break; } } - assert!(refused, "the reader must refuse an unbounded frame, not grow forever"); + assert!( + refused, + "the reader must refuse an unbounded frame, not grow forever" + ); } #[test] fn a_duplicate_sequence_number_is_rejected() { let mut reorder = ReorderBuffer::new(); assert!(reorder.accept(1)); - assert!(!reorder.accept(1), "a duplicate must not be delivered twice"); + assert!( + !reorder.accept(1), + "a duplicate must not be delivered twice" + ); assert!(reorder.accept(2)); assert!(!reorder.accept(2)); } @@ -195,7 +230,10 @@ fn a_duplicate_sequence_number_is_rejected() { fn an_out_of_order_older_frame_is_rejected() { let mut reorder = ReorderBuffer::new(); assert!(reorder.accept(10)); - assert!(!reorder.accept(9), "an older frame must not be delivered late"); + assert!( + !reorder.accept(9), + "an older frame must not be delivered late" + ); assert!(reorder.accept(11)); } @@ -208,7 +246,11 @@ fn the_reorder_buffer_reports_its_high_water_mark() { reorder.accept(8); assert_eq!(reorder.highest(), Some(8)); reorder.accept(6); - assert_eq!(reorder.highest(), Some(8), "a rejected frame must not lower the mark"); + assert_eq!( + reorder.highest(), + Some(8), + "a rejected frame must not lower the mark" + ); } #[test] @@ -218,7 +260,10 @@ fn a_sequence_rollover_is_still_ordered() { assert!(reorder.accept(u32::MAX)); assert!(reorder.accept(0), "the sequence must wrap, not reset"); assert!(reorder.accept(1)); - assert!(!reorder.accept(u32::MAX), "the old high frame must still be rejected"); + assert!( + !reorder.accept(u32::MAX), + "the old high frame must still be rejected" + ); } #[test] @@ -227,7 +272,10 @@ fn reset_clears_the_high_water_mark() { reorder.accept(100); reorder.reset(); assert_eq!(reorder.highest(), None); - assert!(reorder.accept(1), "a new connection restarts the sequence space"); + assert!( + reorder.accept(1), + "a new connection restarts the sequence space" + ); } #[test] diff --git a/rust/nethernet-jni/src/lib.rs b/rust/nethernet-jni/src/lib.rs index 056b4d7..5c8eb11 100644 --- a/rust/nethernet-jni/src/lib.rs +++ b/rust/nethernet-jni/src/lib.rs @@ -163,7 +163,10 @@ pub extern "system" fn Java_org_chimeramc_client_core_nethernet_NativeNetherNet_ ) -> jstring { guarded("nativeErrorTag", || { let slot = connection().lock().unwrap_or_else(|e| e.into_inner()); - let tag = slot.as_ref().and_then(|h| h.snapshot().1).map(|e| e.tag().to_string()); + let tag = slot + .as_ref() + .and_then(|h| h.snapshot().1) + .map(|e| e.tag().to_string()); match tag { Some(tag) => to_jstring(&mut env, &tag), None => std::ptr::null_mut(), @@ -179,7 +182,10 @@ pub extern "system" fn Java_org_chimeramc_client_core_nethernet_NativeNetherNet_ ) -> jstring { guarded("nativeErrorMessage", || { let slot = connection().lock().unwrap_or_else(|e| e.into_inner()); - let message = slot.as_ref().and_then(|h| h.snapshot().1).map(|e| e.user_message()); + let message = slot + .as_ref() + .and_then(|h| h.snapshot().1) + .map(|e| e.user_message()); match message { Some(message) => to_jstring(&mut env, &message), None => std::ptr::null_mut(), @@ -272,7 +278,13 @@ pub extern "system" fn Java_org_chimeramc_client_core_nethernet_NativeNetherNet_ _env: JNIEnv, _class: JClass, ) -> jboolean { - guarded("nativeIsCancelled", || if CANCELLED.load(Ordering::Acquire) { 1 } else { 0 }) + guarded("nativeIsCancelled", || { + if CANCELLED.load(Ordering::Acquire) { + 1 + } else { + 0 + } + }) } /// The state-machine budget for a state code, in milliseconds. 0 when the state has none. @@ -310,7 +322,9 @@ pub extern "system" fn Java_org_chimeramc_client_core_nethernet_NativeNetherNet_ _env: JNIEnv, _class: JClass, ) -> jint { - guarded("nativeProtocolVersion", || nethernet_core::signaling::PROTOCOL_VERSION as jint) + guarded("nativeProtocolVersion", || { + nethernet_core::signaling::PROTOCOL_VERSION as jint + }) } /// A self-test the launcher can call at startup to confirm the bridge is live. diff --git a/rust/nethernet-jni/src/transport.rs b/rust/nethernet-jni/src/transport.rs index 6aadfd2..81b08ea 100644 --- a/rust/nethernet-jni/src/transport.rs +++ b/rust/nethernet-jni/src/transport.rs @@ -82,14 +82,20 @@ impl Handle { publish( &worker_shared, TransportState::Failed, - Some(TransportError::Malformed("the connection worker failed".into())), + Some(TransportError::Malformed( + "the connection worker failed".into(), + )), None, ); } }) .expect("spawning the signaling worker cannot fail"); - Self { shared, cancelled, worker: Some(worker) } + Self { + shared, + cancelled, + worker: Some(worker), + } } /// A snapshot of the shared state. @@ -180,7 +186,12 @@ fn run_worker( while socket.is_none() { if cancelled.load(Ordering::Acquire) { session.fail(TransportError::Cancelled); - publish(shared, TransportState::Failed, session.error().cloned(), None); + publish( + shared, + TransportState::Failed, + session.error().cloned(), + None, + ); return; } if attempt > 0 && policy.is_saturated(attempt) { @@ -193,7 +204,12 @@ fn run_worker( let delay = policy.delay_ms(attempt, rng_fraction(attempt)); if delay > 0 && !sleep_cancellable(Duration::from_millis(delay), cancelled) { session.fail(TransportError::Cancelled); - publish(shared, TransportState::Failed, session.error().cloned(), None); + publish( + shared, + TransportState::Failed, + session.error().cloned(), + None, + ); return; } attempt += 1; @@ -207,14 +223,26 @@ fn run_worker( let Some(mut socket) = socket else { session.fail(TransportError::SignalingUnreachable(String::new())); - publish(shared, TransportState::Failed, session.error().cloned(), None); + publish( + shared, + TransportState::Failed, + session.error().cloned(), + None, + ); return; }; // Hello, then await the welcome. - if let Err(e) = socket.send(Message::Text(signaling::encode_client_signal(&signaling::hello(&network_id)))) { + if let Err(e) = socket.send(Message::Text(signaling::encode_client_signal( + &signaling::hello(&network_id), + ))) { session.fail(TransportError::SignalingUnreachable(e.to_string())); - publish(shared, TransportState::Failed, session.error().cloned(), None); + publish( + shared, + TransportState::Failed, + session.error().cloned(), + None, + ); return; } session.transition(TransportState::AwaitingWelcome); @@ -229,7 +257,12 @@ fn run_worker( loop { if cancelled.load(Ordering::Acquire) { session.fail(TransportError::Cancelled); - publish(shared, TransportState::Failed, session.error().cloned(), None); + publish( + shared, + TransportState::Failed, + session.error().cloned(), + None, + ); let _ = socket.close(None); return; } @@ -254,7 +287,12 @@ fn run_worker( } Ok(Message::Close(_)) => { session.fail(TransportError::PeerClosed(String::new())); - publish(shared, TransportState::Failed, session.error().cloned(), None); + publish( + shared, + TransportState::Failed, + session.error().cloned(), + None, + ); return; } Ok(_) => {} @@ -266,12 +304,22 @@ fn run_worker( } Err(tungstenite::Error::ConnectionClosed) => { session.fail(TransportError::PeerClosed(String::new())); - publish(shared, TransportState::Failed, session.error().cloned(), None); + publish( + shared, + TransportState::Failed, + session.error().cloned(), + None, + ); return; } Err(e) => { session.fail(TransportError::SignalingUnreachable(e.to_string())); - publish(shared, TransportState::Failed, session.error().cloned(), None); + publish( + shared, + TransportState::Failed, + session.error().cloned(), + None, + ); return; } } @@ -312,17 +360,32 @@ fn handle_frame( } Ok(Signal::Disconnect { reason, .. }) => { session.fail(TransportError::PeerClosed(reason)); - publish(shared, TransportState::Failed, session.error().cloned(), None); + publish( + shared, + TransportState::Failed, + session.error().cloned(), + None, + ); false } Ok(Signal::ServiceError { code, message }) => { session.fail(TransportError::ServiceRefused { code, message }); - publish(shared, TransportState::Failed, session.error().cloned(), None); + publish( + shared, + TransportState::Failed, + session.error().cloned(), + None, + ); false } Err(nethernet_core::signaling::SignalError::ProtocolMismatch { client, server }) => { session.fail(TransportError::ProtocolMismatch { client, server }); - publish(shared, TransportState::Failed, session.error().cloned(), None); + publish( + shared, + TransportState::Failed, + session.error().cloned(), + None, + ); false } Err(e) => { @@ -394,9 +457,7 @@ mod tests { let thread = std::thread::spawn(move || { let (lock, condvar) = &*waiter; let guard = lock.lock().unwrap(); - let (guard, _) = condvar - .wait_timeout(guard, Duration::from_secs(5)) - .unwrap(); + let (guard, _) = condvar.wait_timeout(guard, Duration::from_secs(5)).unwrap(); guard.state }); std::thread::sleep(Duration::from_millis(50)); @@ -433,7 +494,10 @@ mod tests { let cancelled = Arc::new(AtomicBool::new(true)); let start = Instant::now(); assert!(!sleep_cancellable(Duration::from_secs(30), &cancelled)); - assert!(start.elapsed() < Duration::from_secs(1), "cancel must not wait"); + assert!( + start.elapsed() < Duration::from_secs(1), + "cancel must not wait" + ); } #[test] diff --git a/rust/singularity-core/src/biome.rs b/rust/singularity-core/src/biome.rs index b46d7e3..355ed0c 100644 --- a/rust/singularity-core/src/biome.rs +++ b/rust/singularity-core/src/biome.rs @@ -323,14 +323,20 @@ mod tests { continentalness: 0.5, }; let weight = registry.blend(near_desert, "desert", "jungle", 0.2); - assert!(weight < 0.5, "near desert should weight desert higher: {weight}"); + assert!( + weight < 0.5, + "near desert should weight desert higher: {weight}" + ); let near_jungle = Climate { temperature: 0.9, humidity: 0.95, continentalness: 0.5, }; let weight = registry.blend(near_jungle, "desert", "jungle", 0.2); - assert!(weight > 0.5, "near jungle should weight jungle higher: {weight}"); + assert!( + weight > 0.5, + "near jungle should weight jungle higher: {weight}" + ); } #[test] @@ -375,10 +381,7 @@ mod tests { #[test] fn parses_a_biome_definition_list() { - let biomes = parse_definitions( - r#"[{"id":"a"},{"id":"b","weight":3}]"#, - ) - .unwrap(); + let biomes = parse_definitions(r#"[{"id":"a"},{"id":"b","weight":3}]"#).unwrap(); assert_eq!(biomes.len(), 2); assert_eq!(biomes[1].weight, 3); } diff --git a/rust/singularity-core/src/chunk.rs b/rust/singularity-core/src/chunk.rs index 45cd109..664a711 100644 --- a/rust/singularity-core/src/chunk.rs +++ b/rust/singularity-core/src/chunk.rs @@ -126,7 +126,14 @@ pub fn surface_height(config: &TerrainConfig, world_x: i32, world_z: i32) -> i32 let base = if config.warp > 0.0 { noise::warped3(config.seed, wx / 256.0, 0.0, wz / 256.0, config.warp) } else { - noise::fbm2(config.seed, wx / 256.0, wz / 256.0, config.octaves, 2.0, 0.5) + noise::fbm2( + config.seed, + wx / 256.0, + wz / 256.0, + config.octaves, + 2.0, + 0.5, + ) }; let shaped = (base - 0.5) * 2.0 * config.amplitude as f32; let height = config.base_height as f32 + shaped; @@ -285,7 +292,10 @@ mod tests { for local_x in 0..CHUNK_SIZE { let surface = chunk.heightmap[(local_x + CHUNK_SIZE * local_z) as usize]; assert!(!chunk.is_solid(local_x, surface + 1, local_z)); - assert!(chunk.is_solid(local_x, surface, local_z) || chunk.block(local_x, surface, local_z) == WATER); + assert!( + chunk.is_solid(local_x, surface, local_z) + || chunk.block(local_x, surface, local_z) == WATER + ); } } } diff --git a/rust/singularity-core/src/fluid.rs b/rust/singularity-core/src/fluid.rs index f790f5e..a20fa63 100644 --- a/rust/singularity-core/src/fluid.rs +++ b/rust/singularity-core/src/fluid.rs @@ -104,8 +104,8 @@ pub fn step( if gravity > 0.0 { if let Some((above_kind, above_level)) = grid.get(x, y - 1) { let props = properties(above_kind); - let flow = - ((above_level as f32) * gravity / props.viscosity.max(0.01)).round() as i32; + let flow = ((above_level as f32) * gravity / props.viscosity.max(0.01)) + .round() as i32; if flow >= 1 { inflow = inflow.max(flow); } @@ -115,7 +115,8 @@ pub fn step( if let Some((neighbour_kind, neighbour_level)) = grid.get(x + dx, y + dy) { if neighbour_level > 1 { let props = properties(neighbour_kind); - let flow = (neighbour_level as f32 / props.viscosity.max(0.01)).round() as i32; + let flow = + (neighbour_level as f32 / props.viscosity.max(0.01)).round() as i32; if flow >= 1 { inflow = inflow.max(flow); } @@ -124,7 +125,8 @@ pub fn step( } if inflow > 0 { // The incoming fluid keeps its kind; take it from the strongest contributor. - let kind = strongest_neighbour_kind(grid, properties, x, y).unwrap_or(FluidKind::Water); + let kind = strongest_neighbour_kind(grid, properties, x, y) + .unwrap_or(FluidKind::Water); next.set(x, y, Some((kind, inflow.min(FluidGrid::MAX_LEVEL)))); } continue; @@ -237,7 +239,10 @@ mod tests { let mut source = props(FluidKind::Water); source.infinite_source = true; let stepped = step(&grid, &|_| source, 1.0); - assert_eq!(stepped.get(0, 0), Some((FluidKind::Water, FluidGrid::MAX_LEVEL))); + assert_eq!( + stepped.get(0, 0), + Some((FluidKind::Water, FluidGrid::MAX_LEVEL)) + ); } #[test] @@ -263,7 +268,10 @@ mod tests { let mut grid = FluidGrid::new(4, 8); grid.set(1, 0, Some((FluidKind::Water, 8))); let stepped = step(&grid, &props, 1.0); - assert!(stepped.get(1, 1).is_some(), "water should fall into the cell below"); + assert!( + stepped.get(1, 1).is_some(), + "water should fall into the cell below" + ); } #[test] @@ -291,7 +299,19 @@ mod tests { }; let mut grid = FluidGrid::new(32, 32); grid.set(0, 0, Some((FluidKind::Water, 8))); - assert!(within_source_range(&grid, &source_props, FluidKind::Water, 3, 0)); - assert!(!within_source_range(&grid, &source_props, FluidKind::Water, 20, 0)); + assert!(within_source_range( + &grid, + &source_props, + FluidKind::Water, + 3, + 0 + )); + assert!(!within_source_range( + &grid, + &source_props, + FluidKind::Water, + 20, + 0 + )); } } diff --git a/rust/singularity-core/src/lib.rs b/rust/singularity-core/src/lib.rs index 12d1952..09b2d05 100644 --- a/rust/singularity-core/src/lib.rs +++ b/rust/singularity-core/src/lib.rs @@ -57,4 +57,3 @@ mod tests { assert_ne!(seed_hash("hello", true), seed_hash("hello", false)); } } - diff --git a/rust/singularity-core/src/manifest.rs b/rust/singularity-core/src/manifest.rs index 59735be..c78ce8f 100644 --- a/rust/singularity-core/src/manifest.rs +++ b/rust/singularity-core/src/manifest.rs @@ -185,7 +185,9 @@ impl SingularityManifest { /// Parses and validates manifest JSON. pub fn parse(json: &str) -> Result { let manifest: SingularityManifest = serde_json::from_str(json).map_err(|e| { - Error::new(format!("{MANIFEST_NAME} is not a valid singularity manifest: {e}")) + Error::new(format!( + "{MANIFEST_NAME} is not a valid singularity manifest: {e}" + )) })?; validate(&manifest)?; Ok(manifest) @@ -221,9 +223,7 @@ pub fn validate(manifest: &SingularityManifest) -> Result<()> { .map_err(|e| Error::new(format!("dependency: {e}")))?; } if manifest.terrain.height < 16 || manifest.terrain.height > 2048 { - return Err(Error::new( - "terrain.height must be between 16 and 2048", - )); + return Err(Error::new("terrain.height must be between 16 and 2048")); } if manifest.terrain.amplitude < 0 { return Err(Error::new("terrain.amplitude must not be negative")); @@ -234,13 +234,17 @@ pub fn validate(manifest: &SingularityManifest) -> Result<()> { )); } if manifest.terrain.sea_level < 0 || manifest.terrain.sea_level > manifest.terrain.height { - return Err(Error::new("terrain.sea_level must be inside the chunk height")); + return Err(Error::new( + "terrain.sea_level must be inside the chunk height", + )); } if !(1..=MAX_OCTAVES).contains(&manifest.terrain.octaves) { return Err(Error::new("terrain.octaves is out of range")); } if !manifest.terrain.warp.is_finite() || manifest.terrain.warp < 0.0 { - return Err(Error::new("terrain.warp must be a non-negative finite number")); + return Err(Error::new( + "terrain.warp must be a non-negative finite number", + )); } if manifest.physics.gravity.iter().any(|v| !v.is_finite()) { return Err(Error::new("physics.gravity must be finite")); @@ -278,8 +282,12 @@ mod tests { }"##; let mut value: serde_json::Value = serde_json::from_str(base).unwrap(); if !extra.is_empty() { - let extra_value: serde_json::Value = serde_json::from_str(&format!("{{{extra}}}")).unwrap(); - merge(value.as_object_mut().unwrap(), extra_value.as_object().unwrap()); + let extra_value: serde_json::Value = + serde_json::from_str(&format!("{{{extra}}}")).unwrap(); + merge( + value.as_object_mut().unwrap(), + extra_value.as_object().unwrap(), + ); } value.to_string() } @@ -308,11 +316,21 @@ mod tests { #[test] fn refuses_a_missing_required_field() { - for field in ["id", "version", "min_client_version", "seed_algorithm", "custom_dimensions", "berry_api_dependencies"] { + for field in [ + "id", + "version", + "min_client_version", + "seed_algorithm", + "custom_dimensions", + "berry_api_dependencies", + ] { let json = valid_json(""); let mut value: serde_json::Value = serde_json::from_str(&json).unwrap(); value.as_object_mut().unwrap().remove(field); - assert!(parse(&value.to_string()).is_err(), "missing {field} should be refused"); + assert!( + parse(&value.to_string()).is_err(), + "missing {field} should be refused" + ); } } @@ -330,7 +348,10 @@ mod tests { #[test] fn refuses_a_base_height_outside_the_chunk() { - assert!(parse(&valid_json(r#""terrain": {"base_height": 500, "height": 128}"#)).is_err()); + assert!(parse(&valid_json( + r#""terrain": {"base_height": 500, "height": 128}"# + )) + .is_err()); } #[test] diff --git a/rust/singularity-core/src/mesher.rs b/rust/singularity-core/src/mesher.rs index 26694dc..37fecbb 100644 --- a/rust/singularity-core/src/mesher.rs +++ b/rust/singularity-core/src/mesher.rs @@ -135,7 +135,8 @@ where let mut height = 1; 'outer: while v + height < size_v { for du in 0..width { - if mask[(u + du + size_u * (v + height)) as usize] != Some((block, sign, 1.0)) + if mask[(u + du + size_u * (v + height)) as usize] + != Some((block, sign, 1.0)) { break 'outer; } @@ -211,22 +212,11 @@ fn emit_quad( let corner_uv = [(0, 0), (width, 0), (width, height), (0, height)]; // Winding: flip for the negative faces so both sides face outward. - let order: [usize; 4] = if sign > 0 { - [0, 1, 2, 3] - } else { - [3, 2, 1, 0] - }; + let order: [usize; 4] = if sign > 0 { [0, 1, 2, 3] } else { [3, 2, 1, 0] }; for corner in 0..4 { let (cu, cv) = corner_uv[order[corner]]; - let (x, y, z) = place( - axis, - u_axis, - v_axis, - plane, - u + cu, - v + cv, - ); + let (x, y, z) = place(axis, u_axis, v_axis, plane, u + cu, v + cv); // Ambient occlusion from the three neighbours around this corner in the face plane. let mut ao = 1.0f32; if width > 0 && height > 0 { @@ -238,7 +228,13 @@ fn emit_quad( side_v[v_axis] = sv; let mut sample = |offset: [i32; 3]| -> bool { let (sx, sy, sz) = (x + offset[0], y + offset[1], z + offset[2]); - if sx >= 0 && sx < CHUNK_SIZE && sy >= 0 && sy < chunk.height && sz >= 0 && sz < CHUNK_SIZE { + if sx >= 0 + && sx < CHUNK_SIZE + && sy >= 0 + && sy < chunk.height + && sz >= 0 + && sz < CHUNK_SIZE + { chunk.is_solid(sx, sy, sz) } else { neighbour_solid(sx, sy, sz) @@ -284,7 +280,11 @@ mod tests { warp: 0.0, default_biome: "plains".to_string(), }; - generate_chunk(&config, &crate::biome::BiomeRegistry::new("plains"), ChunkPos { x: 0, z: 0 }) + generate_chunk( + &config, + &crate::biome::BiomeRegistry::new("plains"), + ChunkPos { x: 0, z: 0 }, + ) } #[test] @@ -294,7 +294,10 @@ mod tests { assert!(!mesh.is_empty()); // The top of a flat chunk is one merged quad per face direction set, not 256 quads. let top_vertices = 4; - assert!(mesh.vertex_count() < 256 * 4, "greedy merging should cut the vertex count"); + assert!( + mesh.vertex_count() < 256 * 4, + "greedy merging should cut the vertex count" + ); assert!(mesh.vertex_count() >= top_vertices); } @@ -340,7 +343,11 @@ mod tests { warp: 0.0, default_biome: "plains".to_string(), }; - let chunk = generate_chunk(&config, &crate::biome::BiomeRegistry::new("plains"), ChunkPos { x: 0, z: 0 }); + let chunk = generate_chunk( + &config, + &crate::biome::BiomeRegistry::new("plains"), + ChunkPos { x: 0, z: 0 }, + ); // Bedrock at y=0 still meshes, but only a handful of quads. let mesh = mesh(&chunk, |_, _, _| true); assert!(mesh.vertex_count() <= 6 * 4); diff --git a/rust/singularity-core/src/noise.rs b/rust/singularity-core/src/noise.rs index c5d59a1..f8199dc 100644 --- a/rust/singularity-core/src/noise.rs +++ b/rust/singularity-core/src/noise.rs @@ -143,7 +143,15 @@ pub fn warped3(seed: u64, x: f32, y: f32, z: f32, strength: f32) -> f32 { let wx = value3(seed ^ 0x1111, x * 0.5, y * 0.5, z * 0.5) - 0.5; let wy = value3(seed ^ 0x2222, x * 0.5, y * 0.5, z * 0.5) - 0.5; let wz = value3(seed ^ 0x3333, x * 0.5, y * 0.5, z * 0.5) - 0.5; - fbm3(seed, x + wx * strength, y + wy * strength, z + wz * strength, 4, 2.0, 0.5) + fbm3( + seed, + x + wx * strength, + y + wy * strength, + z + wz * strength, + 4, + 2.0, + 0.5, + ) } #[cfg(test)] @@ -155,7 +163,10 @@ mod tests { for _ in 0..100 { assert_eq!(value3(42, 1.5, 2.5, 3.5), value3(42, 1.5, 2.5, 3.5)); } - assert_eq!(fbm2(7, 10.0, 20.0, 4, 2.0, 0.5), fbm2(7, 10.0, 20.0, 4, 2.0, 0.5)); + assert_eq!( + fbm2(7, 10.0, 20.0, 4, 2.0, 0.5), + fbm2(7, 10.0, 20.0, 4, 2.0, 0.5) + ); } #[test] @@ -190,7 +201,10 @@ mod tests { // Two samples either side of a lattice boundary must be close. let a = value3(9, 3.9999, 0.0, 0.0); let b = value3(9, 4.0001, 0.0, 0.0); - assert!((a - b).abs() < 0.01, "noise jumps at a lattice boundary: {a} vs {b}"); + assert!( + (a - b).abs() < 0.01, + "noise jumps at a lattice boundary: {a} vs {b}" + ); } #[test] diff --git a/rust/singularity-core/src/physics.rs b/rust/singularity-core/src/physics.rs index 566418c..8101f4e 100644 --- a/rust/singularity-core/src/physics.rs +++ b/rust/singularity-core/src/physics.rs @@ -206,7 +206,10 @@ mod tests { let env = Environment::default(); let normal_step = integrate(&normal, &env, &[], FIXED_STEP); let moon_step = integrate(&moon, &env, &[], FIXED_STEP); - assert!(moon_step.velocity.y > normal_step.velocity.y, "moon gravity should fall slower"); + assert!( + moon_step.velocity.y > normal_step.velocity.y, + "moon gravity should fall slower" + ); } #[test] @@ -228,7 +231,10 @@ mod tests { }; let low_step = integrate(&low, &env, &[], FIXED_STEP); let high_step = integrate(&high, &env, &[], FIXED_STEP); - assert!(high_step.velocity.x > low_step.velocity.x, "wind should be stronger higher up"); + assert!( + high_step.velocity.x > low_step.velocity.x, + "wind should be stronger higher up" + ); } #[test] @@ -265,7 +271,10 @@ mod tests { ..Body::default() }; let landed = resolve_landing(&body, Material::new(0.0, 0.9)); - assert!(landed.velocity.y > 0.0, "a bouncy surface should reverse the fall"); + assert!( + landed.velocity.y > 0.0, + "a bouncy surface should reverse the fall" + ); assert!(landed.on_ground); } diff --git a/rust/singularity-core/tests/examples.rs b/rust/singularity-core/tests/examples.rs index 6116852..ea3ab6a 100644 --- a/rust/singularity-core/tests/examples.rs +++ b/rust/singularity-core/tests/examples.rs @@ -23,7 +23,10 @@ fn the_sky_islands_pack_parses() { let manifest_text = fs::read_to_string(root.join("manifest.json")).expect("manifest.json"); let manifest = singularity_core::manifest::parse(&manifest_text).expect("a valid manifest"); assert_eq!(manifest.id, "sky_islands"); - assert_eq!(manifest.seed_algorithm, singularity_core::manifest::SeedAlgorithm::Sha256); + assert_eq!( + manifest.seed_algorithm, + singularity_core::manifest::SeedAlgorithm::Sha256 + ); let mut biome_ids = Vec::new(); for entry in fs::read_dir(root.join("biomes")).expect("biomes/") { @@ -46,12 +49,16 @@ fn the_sky_islands_pack_parses() { if path.extension().and_then(|e| e.to_str()) != Some("json") { continue; } - let rule = singularity_core::structure::parse_definition(&fs::read_to_string(&path).unwrap()) - .unwrap_or_else(|error| panic!("{}: {error}", path.display())); + let rule = + singularity_core::structure::parse_definition(&fs::read_to_string(&path).unwrap()) + .unwrap_or_else(|error| panic!("{}: {error}", path.display())); structure_ids.push(rule.id); } structure_ids.sort(); let mut declared = manifest.structures.clone(); declared.sort(); - assert_eq!(structure_ids, declared, "structures/ and the manifest must agree"); + assert_eq!( + structure_ids, declared, + "structures/ and the manifest must agree" + ); } diff --git a/rust/singularity-jni/src/lib.rs b/rust/singularity-jni/src/lib.rs index 87352b3..c0b90ca 100644 --- a/rust/singularity-jni/src/lib.rs +++ b/rust/singularity-jni/src/lib.rs @@ -155,8 +155,11 @@ pub unsafe extern "system" fn Java_org_chimeramc_client_singularity_SingularityE run_or_message(&mut env, "nativeValidate", || { let text = universe_core::archive::read_manifest_text(&path).map_err(|e| e.to_string())?; let manifest = singularity_core::manifest::parse(&text).map_err(|e| e.to_string())?; - universe_core::version::check_min_client_version(&manifest.min_client_version, CLIENT_VERSION) - .map_err(|e| e.to_string())?; + universe_core::version::check_min_client_version( + &manifest.min_client_version, + CLIENT_VERSION, + ) + .map_err(|e| e.to_string())?; log_info(&format!( "validated {} v{}", manifest.display_name(), @@ -317,7 +320,9 @@ pub unsafe extern "C" fn caramel_singularity_generate_chunk( }; // Read the registered biomes, so a pack's biomes/*.json actually shape the terrain. The read // lock is held only for the generation call; registration takes the write lock once at load. - let registry = biomes().read().unwrap_or_else(|poisoned| poisoned.into_inner()); + let registry = biomes() + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()); let chunk = singularity_core::chunk::generate_chunk( &config, ®istry, @@ -350,7 +355,9 @@ pub unsafe extern "C" fn caramel_singularity_register_biomes(biomes_json: *const let json = cstr(biomes_json); match singularity_core::biome::parse_definitions(&json) { Ok(definitions) => { - let mut registry = biomes().write().unwrap_or_else(|poisoned| poisoned.into_inner()); + let mut registry = biomes() + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()); *registry = singularity_core::biome::BiomeRegistry::new("plains"); for definition in &definitions { registry.register(definition.clone()); @@ -425,18 +432,7 @@ mod tests { fn chunk_generation_writes_the_expected_size() { let mut blocks = vec![0u8; 16 * 16 * 64]; let written = unsafe { - caramel_singularity_generate_chunk( - 42, - 32, - 8, - 30, - 64, - 4, - 0.0, - 0, - 0, - blocks.as_mut_ptr(), - ) + caramel_singularity_generate_chunk(42, 32, 8, 30, 64, 4, 0.0, 0, 0, blocks.as_mut_ptr()) }; assert_eq!(written, (16 * 16 * 64) as i32); } @@ -454,13 +450,22 @@ mod tests { // One test, not two: the registry is process-global, so two tests mutating it would race // under the parallel test runner. let two = CString::new(r#"[{"id":"a"},{"id":"b"}]"#).unwrap(); - assert_eq!(unsafe { caramel_singularity_register_biomes(two.as_ptr()) }, 2); + assert_eq!( + unsafe { caramel_singularity_register_biomes(two.as_ptr()) }, + 2 + ); // A second call replaces rather than accumulates. let one = CString::new(r#"[{"id":"keep"}]"#).unwrap(); - assert_eq!(unsafe { caramel_singularity_register_biomes(one.as_ptr()) }, 1); + assert_eq!( + unsafe { caramel_singularity_register_biomes(one.as_ptr()) }, + 1 + ); // A malformed list is refused and leaves the previous set intact. let bad = CString::new(r#"[{"id":"../evil"}]"#).unwrap(); - assert_eq!(unsafe { caramel_singularity_register_biomes(bad.as_ptr()) }, -1); + assert_eq!( + unsafe { caramel_singularity_register_biomes(bad.as_ptr()) }, + -1 + ); let registry = biomes().read().unwrap(); assert_eq!(registry.len(), 1); diff --git a/rust/universe-core/src/archive.rs b/rust/universe-core/src/archive.rs index b6f75a0..ac1b1d1 100644 --- a/rust/universe-core/src/archive.rs +++ b/rust/universe-core/src/archive.rs @@ -69,7 +69,9 @@ pub fn is_safe_absolute_path(path: &str) -> bool { /// where the filesystem can answer, the canonical one (which catches a symlinked parent). pub fn resolve_within(root: &Path, entry: &str) -> Result { if !is_safe_relative_path(entry) { - return Err(Error::new(format!("archive entry escapes the sandbox: {entry}"))); + return Err(Error::new(format!( + "archive entry escapes the sandbox: {entry}" + ))); } let normalised = entry.replace('\\', "/"); let mut destination = root.to_path_buf(); @@ -118,7 +120,8 @@ pub fn validate_sandbox_root(sandbox_root: &str, allowed_root: &str) -> Result<( // Where both exist, also require the canonical containment, so a symlink cannot point the // sandbox out of the allowed tree. if allowed.exists() && sandbox.exists() { - if let (Ok(real_allowed), Ok(real_sandbox)) = (allowed.canonicalize(), sandbox.canonicalize()) + if let (Ok(real_allowed), Ok(real_sandbox)) = + (allowed.canonicalize(), sandbox.canonicalize()) { if !real_sandbox.starts_with(&real_allowed) { return Err(Error::new( @@ -242,7 +245,8 @@ pub fn extract_with( let mut archive = open_archive(archive_path)?; let root = PathBuf::from(sandbox_root); - fs::create_dir_all(&root).map_err(|e| Error::new(format!("could not create the sandbox: {e}")))?; + fs::create_dir_all(&root) + .map_err(|e| Error::new(format!("could not create the sandbox: {e}")))?; let stage = root.join(format!(".{id}.extracting")); let target = root.join(id); let _ = fs::remove_dir_all(&stage); @@ -342,7 +346,9 @@ mod tests { #[test] fn absolute_paths_are_checked() { - assert!(is_safe_absolute_path("/data/user/0/org.chimeramc.client/files/universe")); + assert!(is_safe_absolute_path( + "/data/user/0/org.chimeramc.client/files/universe" + )); assert!(!is_safe_absolute_path("relative/path")); assert!(!is_safe_absolute_path("/data/../etc")); assert!(!is_safe_absolute_path("")); @@ -351,9 +357,14 @@ mod tests { #[test] fn sandbox_root_must_live_under_the_allowed_root() { let allowed = "/data/user/0/org.chimeramc.client/files"; - assert!(validate_sandbox_root("/data/user/0/org.chimeramc.client/files/universe", allowed).is_ok()); + assert!( + validate_sandbox_root("/data/user/0/org.chimeramc.client/files/universe", allowed) + .is_ok() + ); assert!(validate_sandbox_root("/sdcard/universe", allowed).is_err()); - assert!(validate_sandbox_root("/data/user/0/org.chimeramc.client/files2/x", allowed).is_err()); + assert!( + validate_sandbox_root("/data/user/0/org.chimeramc.client/files2/x", allowed).is_err() + ); assert!(validate_sandbox_root("relative", allowed).is_err()); } diff --git a/rust/universe-core/src/config.rs b/rust/universe-core/src/config.rs index a90c426..2ee1bcc 100644 --- a/rust/universe-core/src/config.rs +++ b/rust/universe-core/src/config.rs @@ -169,7 +169,10 @@ impl Config { /// Builds a sibling path by appending `suffix` to the file name (`config.json` -> `config.json.tmp`). fn sibling(path: &Path, suffix: &str) -> PathBuf { - let mut name = path.file_name().map(|n| n.to_os_string()).unwrap_or_default(); + let mut name = path + .file_name() + .map(|n| n.to_os_string()) + .unwrap_or_default(); name.push("."); name.push(suffix); path.with_file_name(name) @@ -193,7 +196,10 @@ mod tests { fn temp_dir(name: &str) -> PathBuf { let mut dir = std::env::temp_dir(); - dir.push(format!("universe-config-test-{name}-{}", std::process::id())); + dir.push(format!( + "universe-config-test-{name}-{}", + std::process::id() + )); let _ = fs::remove_dir_all(&dir); fs::create_dir_all(&dir).unwrap(); dir @@ -259,9 +265,18 @@ mod tests { } // The live file holds the last round, and the three most recent prior rounds survive. assert_eq!(Config::load(&path).get_int("round", -1), 5); - assert_eq!(Config::load(&sibling(&path, "bak.1")).get_int("round", -1), 4); - assert_eq!(Config::load(&sibling(&path, "bak.2")).get_int("round", -1), 3); - assert_eq!(Config::load(&sibling(&path, "bak.3")).get_int("round", -1), 2); + assert_eq!( + Config::load(&sibling(&path, "bak.1")).get_int("round", -1), + 4 + ); + assert_eq!( + Config::load(&sibling(&path, "bak.2")).get_int("round", -1), + 3 + ); + assert_eq!( + Config::load(&sibling(&path, "bak.3")).get_int("round", -1), + 2 + ); // A fourth backup is never created. assert!(!sibling(&path, "bak.4").exists()); } diff --git a/rust/universe-core/src/manifest.rs b/rust/universe-core/src/manifest.rs index e057837..0ea17b1 100644 --- a/rust/universe-core/src/manifest.rs +++ b/rust/universe-core/src/manifest.rs @@ -95,8 +95,11 @@ impl UniverseManifest { /// Parses and validates manifest JSON. pub fn parse(json: &str) -> Result { - let manifest: UniverseManifest = serde_json::from_str(json) - .map_err(|e| Error::new(format!("{MANIFEST_NAME} is not a valid universe manifest: {e}")))?; + let manifest: UniverseManifest = serde_json::from_str(json).map_err(|e| { + Error::new(format!( + "{MANIFEST_NAME} is not a valid universe manifest: {e}" + )) + })?; validate(&manifest)?; Ok(manifest) } @@ -112,8 +115,7 @@ pub fn validate(manifest: &UniverseManifest) -> Result<()> { if !manifest.name.is_empty() && manifest.name.len() > MAX_ID_LEN { return Err(Error::new("name is too long")); } - version::Version::parse(&manifest.version) - .map_err(|e| Error::new(format!("version: {e}")))?; + version::Version::parse(&manifest.version).map_err(|e| Error::new(format!("version: {e}")))?; version::Version::parse(&manifest.min_client_version) .map_err(|e| Error::new(format!("min_client_version: {e}")))?; if manifest.abi != SUPPORTED_ABI { @@ -132,8 +134,7 @@ pub fn validate(manifest: &UniverseManifest) -> Result<()> { validate_native_lib(lib)?; } for dependency in &manifest.dependencies { - validate_id(dependency) - .map_err(|e| Error::new(format!("dependency: {e}")))?; + validate_id(dependency).map_err(|e| Error::new(format!("dependency: {e}")))?; } if manifest.hooks.len() > MAX_HOOKS { return Err(Error::new("hooks lists too many declarations")); @@ -182,9 +183,9 @@ pub fn is_safe_segment(text: &str) -> bool { if text.is_empty() || text == "." || text == ".." { return false; } - text.chars().all(|c| { - c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') || c == ' ' - }) && !text.contains(['/', '\\', '\0']) + text.chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') || c == ' ') + && !text.contains(['/', '\\', '\0']) } /// Validates a native library path: a safe relative `.so` path inside the package. @@ -196,13 +197,19 @@ fn validate_native_lib(path: &str) -> Result<()> { return Err(Error::new(format!("native lib path '{path}' is too long"))); } if path.contains('\0') || path.contains('\\') { - return Err(Error::new(format!("native lib path '{path}' has an invalid character"))); + return Err(Error::new(format!( + "native lib path '{path}' has an invalid character" + ))); } if path.starts_with('/') { - return Err(Error::new(format!("native lib path '{path}' must be relative"))); + return Err(Error::new(format!( + "native lib path '{path}' must be relative" + ))); } if path.split('/').any(|segment| segment == "..") { - return Err(Error::new(format!("native lib path '{path}' escapes the package"))); + return Err(Error::new(format!( + "native lib path '{path}' escapes the package" + ))); } if !path.to_ascii_lowercase().ends_with(".so") { return Err(Error::new(format!("native lib '{path}' is not a .so file"))); @@ -265,7 +272,10 @@ mod tests { ] { let json = valid_json(""); let mut value: serde_json::Value = serde_json::from_str(&json).unwrap(); - value.as_object_mut().unwrap().remove(&field[1..field.len() - 1]); + value + .as_object_mut() + .unwrap() + .remove(&field[1..field.len() - 1]); let text = value.to_string(); assert!(parse(&text).is_err(), "missing {field} should be refused"); } diff --git a/rust/universe-core/src/patterns.rs b/rust/universe-core/src/patterns.rs index 82ec343..0182a79 100644 --- a/rust/universe-core/src/patterns.rs +++ b/rust/universe-core/src/patterns.rs @@ -266,7 +266,10 @@ mod tests { let haystack = [0xaa, 0xbb, 0xaa]; assert!(pattern.matches_at(&haystack, 0)); assert!(!pattern.matches_at(&haystack, 1)); - assert!(!pattern.matches_at(&haystack, 2), "a truncated tail must not match"); + assert!( + !pattern.matches_at(&haystack, 2), + "a truncated tail must not match" + ); } #[test] diff --git a/rust/universe-core/src/permission.rs b/rust/universe-core/src/permission.rs index f86a18c..8823d64 100644 --- a/rust/universe-core/src/permission.rs +++ b/rust/universe-core/src/permission.rs @@ -131,9 +131,18 @@ mod tests { #[test] fn parses_known_permissions() { - assert_eq!(Permission::parse("read_memory").unwrap(), Permission::ReadMemory); - assert_eq!(Permission::parse("Render_Hook").unwrap(), Permission::RenderHook); - assert_eq!(Permission::parse("filesystem").unwrap(), Permission::FileSystem); + assert_eq!( + Permission::parse("read_memory").unwrap(), + Permission::ReadMemory + ); + assert_eq!( + Permission::parse("Render_Hook").unwrap(), + Permission::RenderHook + ); + assert_eq!( + Permission::parse("filesystem").unwrap(), + Permission::FileSystem + ); } #[test] diff --git a/rust/universe-core/src/report.rs b/rust/universe-core/src/report.rs index a899189..f2457ed 100644 --- a/rust/universe-core/src/report.rs +++ b/rust/universe-core/src/report.rs @@ -142,7 +142,11 @@ mod tests { fn reports_a_cycle() { let json = r#"[{"id":"a","dependencies":["b"]},{"id":"b","dependencies":["a"]}]"#; let report = resolve_load_order_json(json); - assert!(report.contains("\"cycle\":[\"a\",\"b\"]") || report.contains("\"cycle\":[\"b\",\"a\"]"), "{report}"); + assert!( + report.contains("\"cycle\":[\"a\",\"b\"]") + || report.contains("\"cycle\":[\"b\",\"a\"]"), + "{report}" + ); } #[test] @@ -162,8 +166,9 @@ mod tests { #[test] fn parses_mod_specs() { - let specs = parse_mod_specs(r#"[{"id":"a","lib_path":"lib/liba.so","dependencies":["b"]}]"#) - .unwrap(); + let specs = + parse_mod_specs(r#"[{"id":"a","lib_path":"lib/liba.so","dependencies":["b"]}]"#) + .unwrap(); assert_eq!(specs.len(), 1); assert_eq!(specs[0].lib_path, "lib/liba.so"); assert_eq!(specs[0].dependencies, vec!["b"]); diff --git a/rust/universe-core/src/signature.rs b/rust/universe-core/src/signature.rs index f736cce..800b8a8 100644 --- a/rust/universe-core/src/signature.rs +++ b/rust/universe-core/src/signature.rs @@ -125,7 +125,9 @@ impl TrustedKeys { /// True when any trusted key verifies the body. pub fn verifies(&self, body: &[u8], signature: &RawSignature) -> bool { - self.keys.iter().any(|entry| verify(body, signature, &entry.key)) + self.keys + .iter() + .any(|entry| verify(body, signature, &entry.key)) } } diff --git a/rust/universe-core/src/version.rs b/rust/universe-core/src/version.rs index 6c7a490..60c00bb 100644 --- a/rust/universe-core/src/version.rs +++ b/rust/universe-core/src/version.rs @@ -96,11 +96,15 @@ mod tests { let b = Version::parse("v1.26.60.28").unwrap(); assert_eq!(a.compare(&b), Ordering::Equal); assert_eq!( - Version::parse("1.20").unwrap().compare(&Version::parse("1.20.0.0").unwrap()), + Version::parse("1.20") + .unwrap() + .compare(&Version::parse("1.20.0.0").unwrap()), Ordering::Equal ); assert_eq!( - Version::parse("26.51").unwrap().compare(&Version::parse("1.20.0").unwrap()), + Version::parse("26.51") + .unwrap() + .compare(&Version::parse("1.20.0").unwrap()), Ordering::Greater ); } diff --git a/rust/universe-jni/src/berry.rs b/rust/universe-jni/src/berry.rs index 68b94da..f95d0cb 100644 --- a/rust/universe-jni/src/berry.rs +++ b/rust/universe-jni/src/berry.rs @@ -40,7 +40,11 @@ impl BerryApi { if pointer.is_null() { return None; } - Some(unsafe { CStr::from_ptr(pointer) }.to_string_lossy().into_owned()) + Some( + unsafe { CStr::from_ptr(pointer) } + .to_string_lossy() + .into_owned(), + ) } /// True when BerryAPI reports itself attached to the game. diff --git a/rust/universe-jni/src/lib.rs b/rust/universe-jni/src/lib.rs index 501322d..b834c81 100644 --- a/rust/universe-jni/src/lib.rs +++ b/rust/universe-jni/src/lib.rs @@ -100,9 +100,7 @@ fn read_string(env: &mut JNIEnv, value: &JString) -> String { if value.is_null() { return String::new(); } - env.get_string(value) - .map(String::from) - .unwrap_or_default() + env.get_string(value).map(String::from).unwrap_or_default() } /// Builds a Java string from a message; an empty message means success. @@ -311,7 +309,9 @@ pub unsafe extern "C" fn caramel_universe_init(berry_api: *mut c_void) -> *mut c let _ = BERRY_API.set(berry_api as usize); } set_cancelled(false); - log_info(&format!("initialized via C ABI (client version {CLIENT_VERSION})")); + log_info(&format!( + "initialized via C ABI (client version {CLIENT_VERSION})" + )); std::ptr::null_mut() } @@ -394,8 +394,8 @@ pub extern "C" fn caramel_universe_client_version() -> *const c_char { /// The signature is over the whole file, so verification must read it exactly as the packer wrote /// it — no re-zipping, no normalisation. fn read_package_bytes(path: &str) -> Result, String> { - let metadata = std::fs::metadata(path) - .map_err(|e| format!("could not stat the package: {e}"))?; + let metadata = + std::fs::metadata(path).map_err(|e| format!("could not stat the package: {e}"))?; if metadata.len() > universe_core::archive::MAX_ARCHIVE_BYTES { return Err("the package is larger than the size limit".to_string()); } @@ -507,7 +507,9 @@ fn check_permissions(declared_csv: &str, requested_csv: &str, trusted: bool) -> /// # Safety /// `nodes_json` must be null or a valid NUL-terminated string. #[no_mangle] -pub unsafe extern "C" fn caramel_universe_resolve_load_order(nodes_json: *const c_char) -> *mut c_char { +pub unsafe extern "C" fn caramel_universe_resolve_load_order( + nodes_json: *const c_char, +) -> *mut c_char { let json = cstr(nodes_json); message_to_c(universe_core::report::resolve_load_order_json(&json)) } @@ -554,7 +556,12 @@ pub unsafe extern "C" fn caramel_universe_load_mods( let json = cstr(specs_json); let specs = match universe_core::report::parse_mod_specs(&json) { Ok(specs) => specs, - Err(error) => return message_to_c(format!("{{\"error\":\"{}\"}}", universe_core::report::escape_json(&error.to_string()))), + Err(error) => { + return message_to_c(format!( + "{{\"error\":\"{}\"}}", + universe_core::report::escape_json(&error.to_string()) + )) + } }; with_runtime(|runtime| { runtime.set_host(host); @@ -649,13 +656,14 @@ mod tests { let declared = CString::new("read_memory,render_hook").unwrap(); let requested = CString::new("read_memory").unwrap(); // A declared, untrusted capability is granted. - assert!(unsafe { caramel_universe_check_permissions(declared.as_ptr(), requested.as_ptr(), false) } - .is_null()); + assert!(unsafe { + caramel_universe_check_permissions(declared.as_ptr(), requested.as_ptr(), false) + } + .is_null()); // A trust-gated capability is denied without a trusted signature. let write = CString::new("write_memory").unwrap(); - let denied = unsafe { - caramel_universe_check_permissions(declared.as_ptr(), write.as_ptr(), false) - }; + let denied = + unsafe { caramel_universe_check_permissions(declared.as_ptr(), write.as_ptr(), false) }; assert!(!denied.is_null()); unsafe { caramel_universe_free(denied) }; // A capability the mod never declared is denied. @@ -685,7 +693,10 @@ mod tests { let nodes = CString::new(r#"[{"id":"app","dependencies":["lib"]},{"id":"lib"}]"#).unwrap(); let message = unsafe { caramel_universe_resolve_load_order(nodes.as_ptr()) }; assert!(!message.is_null()); - let json = unsafe { CStr::from_ptr(message) }.to_str().unwrap().to_string(); + let json = unsafe { CStr::from_ptr(message) } + .to_str() + .unwrap() + .to_string(); unsafe { caramel_universe_free(message) }; assert!(json.contains("\"order\"")); assert!(json.contains("\"cycle\"")); @@ -696,18 +707,29 @@ mod tests { #[test] fn load_order_reports_a_cycle() { - let nodes = CString::new(r#"[{"id":"a","dependencies":["b"]},{"id":"b","dependencies":["a"]}]"#).unwrap(); + let nodes = + CString::new(r#"[{"id":"a","dependencies":["b"]},{"id":"b","dependencies":["a"]}]"#) + .unwrap(); let message = unsafe { caramel_universe_resolve_load_order(nodes.as_ptr()) }; - let json = unsafe { CStr::from_ptr(message) }.to_str().unwrap().to_string(); + let json = unsafe { CStr::from_ptr(message) } + .to_str() + .unwrap() + .to_string(); unsafe { caramel_universe_free(message) }; - assert!(json.contains("\"cycle\":[\"a\",\"b\"]") || json.contains("\"cycle\":[\"b\",\"a\"]"), "{json}"); + assert!( + json.contains("\"cycle\":[\"a\",\"b\"]") || json.contains("\"cycle\":[\"b\",\"a\"]"), + "{json}" + ); } #[test] fn load_order_reports_bad_json_as_an_error_object() { let nodes = CString::new("not json").unwrap(); let message = unsafe { caramel_universe_resolve_load_order(nodes.as_ptr()) }; - let json = unsafe { CStr::from_ptr(message) }.to_str().unwrap().to_string(); + let json = unsafe { CStr::from_ptr(message) } + .to_str() + .unwrap() + .to_string(); unsafe { caramel_universe_free(message) }; assert!(json.contains("\"error\""), "{json}"); } diff --git a/rust/universe-jni/src/loader.rs b/rust/universe-jni/src/loader.rs index 355d7cd..d13322a 100644 --- a/rust/universe-jni/src/loader.rs +++ b/rust/universe-jni/src/loader.rs @@ -252,7 +252,9 @@ fn dl_error() -> String { if pointer.is_null() { return "no dl error".to_string(); } - unsafe { CStr::from_ptr(pointer) }.to_string_lossy().into_owned() + unsafe { CStr::from_ptr(pointer) } + .to_string_lossy() + .into_owned() } #[cfg(test)] @@ -354,7 +356,11 @@ uint64_t universe_test_frames(void) { return g_frames; } runtime.load(&[spec("badinit", &lib, &[])]); assert_eq!(runtime.live_count(), 0, "a failed init is not loaded"); runtime.tick(0.016); - assert_eq!(read_fixture_frames(&lib), 0, "a refused mod is never ticked"); + assert_eq!( + read_fixture_frames(&lib), + 0, + "a refused mod is never ticked" + ); } /// The fixture's source: a well-formed `.universe` mod that counts its ticks. @@ -397,7 +403,9 @@ uint64_t universe_test_frames(void) { return g_frames; } let name = CString::new(path).unwrap(); let handle = unsafe { dlopen(name.as_ptr(), RTLD_NOW | RTLD_LOCAL) }; assert!(!handle.is_null(), "the fixture should reopen"); - let frames_fn = unsafe { resolve_symbol:: u64>(handle, b"universe_test_frames\0") }; + let frames_fn = unsafe { + resolve_symbol:: u64>(handle, b"universe_test_frames\0") + }; let frames = unsafe { frames_fn.expect("the fixture exports its counter")() }; unsafe { dlclose(handle) }; frames diff --git a/scripts/check_pinned_deps.py b/scripts/check_pinned_deps.py new file mode 100755 index 0000000..6f72d32 --- /dev/null +++ b/scripts/check_pinned_deps.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +"""Verify the fetched dependencies are pinned by commit and match NOTICE. + +CMakeLists.txt fetches five repositories at configure time. Two rules matter: + + * Each must be pinned by a full commit SHA in GIT_TAG. A tag or a branch is a moving target: + the same source tree would then fetch different code on different days, which defeats the + reproducibility the rest of the build goes to some trouble to keep. + * Every pinned dependency, and every crate NOTICE claims to ship, must actually appear where + it is said to be. A dependency that is fetched but not attributed is a licence problem, and + one attributed but no longer fetched is a stale claim. + +The GlossHook archive is vendored rather than fetched, so it is checked for existence only. +""" +from __future__ import annotations + +import argparse +import pathlib +import re +import sys + +GIT_REPOSITORY = re.compile(r"GIT_REPOSITORY\s+(\S+)") +GIT_TAG = re.compile(r"GIT_TAG\s+(\S+)") +FULL_SHA = re.compile(r"^[0-9a-f]{40}$") +# The table rows are indented by two spaces, so the name cannot be anchored at column 0. +NOTICE_HEADER = re.compile(r"^\s*(\S+)\s+([0-9a-f]{40})\s+(\S+)\s*$", re.M) +# re.M: the rows are lines inside the block, so ^ has to match at a line start. +NOTICE_CRATE = re.compile(r"^\s{2}([A-Za-z0-9_-]+)\s+(?:/.*?)?\s+[A-Z]", re.M) +# The literal appears as set(GLOSSHOOK_PATH "..."), so it is not at the start of the line. +GLOSSHOOK = re.compile(r"GLOSSHOOK_PATH\s+\"([^\"]+)\"") +# `name = "x"` in Cargo.lock; a crate appears exactly once per resolved version. +LOCK_NAME = re.compile(r'^name = "([^"]+)"$', re.M) + + +def declared_pins(cmake: pathlib.Path) -> list[tuple[str, str]]: + """Every (repository, tag) pair in the CMakeLists FetchContent declarations.""" + text = cmake.read_text(encoding="utf-8") + repos = GIT_REPOSITORY.findall(text) + tags = GIT_TAG.findall(text) + if len(repos) != len(tags): + raise SystemExit( + f"{cmake}: {len(repos)} GIT_REPOSITORY entries but {len(tags)} GIT_TAG entries" + ) + return list(zip(repos, tags)) + + +def notice_pins(notice: pathlib.Path) -> list[tuple[str, str, str]]: + """The (name, commit, spdx) rows of NOTICE's third-party table.""" + return NOTICE_HEADER.findall(notice.read_text(encoding="utf-8")) + + +def notice_crates(notice: pathlib.Path) -> set[str]: + """The crate names NOTICE lists under "Rust crates".""" + text = notice.read_text(encoding="utf-8") + _, _, tail = text.partition("Rust crates") + return {m.group(1) for m in NOTICE_CRATE.finditer(tail)} + + +def check(root: pathlib.Path) -> list[str]: + problems: list[str] = [] + cmake = root / "CMakeLists.txt" + notice = root / "NOTICE" + + pins = declared_pins(cmake) + for repo, tag in pins: + if not FULL_SHA.match(tag): + problems.append( + f"{repo} is pinned to {tag!r}, which is not a full commit SHA; a tag or a " + f"branch can move under the build" + ) + + # Every fetched repository must be attributed in NOTICE. + named = notice_pins(notice) + notice_by_sha = {sha: name for name, sha, _ in named} + for repo, tag in pins: + if FULL_SHA.match(tag) and tag not in notice_by_sha: + problems.append(f"{repo} pinned at {tag} is not listed in NOTICE") + + # Every commit NOTICE attributes must still be fetched by CMakeLists. + declared_shas = {tag for _, tag in pins} + for name, sha, _ in named: + if sha not in declared_shas: + problems.append( + f"NOTICE attributes {name} at {sha}, but CMakeLists.txt does not fetch that " + f"commit (stale attribution)" + ) + + # The vendored archive must be where CMakeLists says it is. + match = GLOSSHOOK.search(cmake.read_text(encoding="utf-8")) + if match is None: + problems.append("CMakeLists.txt does not define GLOSSHOOK_PATH") + else: + # The path is relative to the CMake source dir, and the literal in CMakeLists is + # ${CMAKE_CURRENT_SOURCE_DIR}/lib/ARM64/libGlossHook.a. + rel = match.group(1).replace("${CMAKE_CURRENT_SOURCE_DIR}/", "") + if not (root / rel).is_file(): + problems.append(f"the vendored archive CMakeLists links does not exist: {rel}") + + # Every crate NOTICE names must appear in Cargo.lock, and vice versa is not required (a + # transitive crate need not be attributed). + lock = root / "rust/Cargo.lock" + if not lock.is_file(): + problems.append("rust/Cargo.lock is missing, so the crate set is not pinned") + else: + in_lock = set(LOCK_NAME.findall(lock.read_text(encoding="utf-8"))) + for crate in sorted(notice_crates(notice)): + if crate not in in_lock: + problems.append( + f"NOTICE lists the crate {crate}, but it is not in rust/Cargo.lock" + ) + + return problems + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--root", + default=str(pathlib.Path(__file__).resolve().parent.parent), + help="the sweetloader checkout to check", + ) + args = parser.parse_args() + + problems = check(pathlib.Path(args.root)) + if problems: + print("pinned-deps: the pins and NOTICE disagree:", file=sys.stderr) + for problem in problems: + print(f" - {problem}", file=sys.stderr) + return 1 + + print("pinned-deps: every fetched dependency is pinned by commit and attributed in NOTICE") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/check_sources_listed.py b/scripts/check_sources_listed.py new file mode 100755 index 0000000..9c3693e --- /dev/null +++ b/scripts/check_sources_listed.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +"""Verify the build list, the source tree and the host test runner all agree. + +Three things drift silently here, and each produces a build that passes while doing less than +it claims: + + 1. A new .cpp that is never added to SWEETLOADER_SOURCES is not compiled into the library. + Nothing fails; the symbol is simply absent at runtime. + 2. A new host test that is never registered in tests/cosmetics/run_tests.sh never runs, so a + pure helper can rot untested while the suite still reports success. + 3. A source listed in CMakeLists.txt that no longer exists fails the configure with a + confusing error instead of a precise one. + +Every disagreement is reported in one run rather than stopping at the first. +""" +from __future__ import annotations + +import argparse +import pathlib +import re +import sys + +CMAKE_LIST = re.compile(r"set\(\s*SWEETLOADER_SOURCES(.*?)\n\s*\)", re.S) +CMAKE_ENTRY = re.compile(r"^\s*(src/[\w./-]+\.cpp)\s*$", re.M) +# Anchored to the start of the line: an unanchored match also hits the prose in a comment +# ("compile_run fixes those first"), which reads as a test named `fixes`. +COMPILE_RUN = re.compile(r"^compile_run\s+(\w+)", re.M) +TSAN_BANNER = re.compile(r'==\s+(\w+Test)\s+==') + + +def read_sources_listed(cmake: pathlib.Path) -> list[str]: + text = cmake.read_text(encoding="utf-8") + match = CMAKE_LIST.search(text) + if match is None: + raise SystemExit(f"{cmake}: no SWEETLOADER_SOURCES list found") + return sorted(CMAKE_ENTRY.findall(match.group(1))) + + +def read_tests_registered(runner: pathlib.Path) -> set[str]: + text = runner.read_text(encoding="utf-8") + return set(COMPILE_RUN.findall(text)) | set(TSAN_BANNER.findall(text)) + + +def check(root: pathlib.Path) -> list[str]: + problems: list[str] = [] + + cmake = root / "CMakeLists.txt" + runner = root / "tests/cosmetics/run_tests.sh" + + listed = read_sources_listed(cmake) + on_disk = sorted( + p.relative_to(root).as_posix() for p in (root / "src").rglob("*.cpp") + ) + + for path in sorted(set(on_disk) - set(listed)): + problems.append( + f"{path} exists but is not in SWEETLOADER_SOURCES, so it is not compiled " + f"into the library" + ) + for path in sorted(set(listed) - set(on_disk)): + problems.append( + f"{path} is in SWEETLOADER_SOURCES but does not exist on disk" + ) + + registered = read_tests_registered(runner) + test_files = sorted( + p.stem for p in (root / "tests/cosmetics").glob("*Test.cpp") + ) + + for name in sorted(set(test_files) - registered): + problems.append( + f"tests/cosmetics/{name}.cpp exists but is never run by " + f"tests/cosmetics/run_tests.sh" + ) + for name in sorted(registered - set(test_files)): + problems.append( + f"run_tests.sh runs {name} but tests/cosmetics/{name}.cpp does not exist" + ) + + return problems + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--root", + default=str(pathlib.Path(__file__).resolve().parent.parent), + help="the sweetloader checkout to check", + ) + args = parser.parse_args() + + problems = check(pathlib.Path(args.root)) + if problems: + print("sources-listed: the build list and the tree disagree:", file=sys.stderr) + for problem in problems: + print(f" - {problem}", file=sys.stderr) + return 1 + + print("sources-listed: sources, tests and the build list agree") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/tests/check_pinned_deps_test.py b/scripts/tests/check_pinned_deps_test.py new file mode 100755 index 0000000..de872a9 --- /dev/null +++ b/scripts/tests/check_pinned_deps_test.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +"""Host tests for scripts/check_pinned_deps.py. + +The checker guards two claims that are easy to get wrong and hard to notice: that every fetched +dependency is pinned to an immutable commit, and that NOTICE still describes what the build +actually fetches. A stale attribution is a licence problem, so the negative cases are the point +of these tests. +""" +from __future__ import annotations + +import importlib.util +import pathlib +import tempfile + +ROOT = pathlib.Path(__file__).resolve().parent.parent.parent + +SHA_A = "9cca280a4d0ccf0c08f47a99aa71d1b0e52f8d03" +SHA_B = "40626af88bd7df9a5fb80be7b25ac85b122d6c21" + +CMAKE = """\ +set(GLOSSHOOK_PATH "${{CMAKE_CURRENT_SOURCE_DIR}}/lib/ARM64/libGlossHook.a") +FetchContent_Declare( + nlohmann_json + GIT_REPOSITORY https://github.com/nlohmann/json.git + GIT_TAG {tag_a} +) +FetchContent_Declare( + fmt + GIT_REPOSITORY https://github.com/fmtlib/fmt.git + GIT_TAG {tag_b} +) +""" + +NOTICE = """\ +Bundled and fetched third-party components +------------------------------------------ + nlohmann/json {sha_a} MIT + fmtlib/fmt {sha_b} MIT + +Rust crates +----------- + serde / serde_json MIT OR Apache-2.0 +""" + +LOCK = """\ +[[package]] +name = "serde" +version = "1.0.0" + +[[package]] +name = "serde_json" +version = "1.0.0" +""" + + +def load(): + spec = importlib.util.spec_from_file_location( + "check_pinned_deps", ROOT / "scripts" / "check_pinned_deps.py") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def make_tree(tmp, tag_a=SHA_A, tag_b=SHA_B, notice=None, lock=LOCK, gloss=True): + tmp = pathlib.Path(tmp) + (tmp / "rust").mkdir(parents=True, exist_ok=True) + (tmp / "CMakeLists.txt").write_text(CMAKE.format(tag_a=tag_a, tag_b=tag_b), encoding="utf-8") + if notice is None: + notice = NOTICE.format(sha_a=SHA_A, sha_b=SHA_B) + (tmp / "NOTICE").write_text(notice, encoding="utf-8") + (tmp / "rust/Cargo.lock").write_text(lock, encoding="utf-8") + if gloss: + (tmp / "lib/ARM64").mkdir(parents=True, exist_ok=True) + (tmp / "lib/ARM64/libGlossHook.a").write_bytes(b"archive") + return tmp + + +def test_agreement_is_clean(): + module = load() + with tempfile.TemporaryDirectory() as tmp: + assert module.check(make_tree(tmp)) == [] + + +def test_branch_pin_is_reported(): + module = load() + with tempfile.TemporaryDirectory() as tmp: + problems = module.check(make_tree(tmp, tag_b="master")) + assert any("not a full commit SHA" in p for p in problems), problems + + +def test_short_sha_is_reported(): + module = load() + with tempfile.TemporaryDirectory() as tmp: + problems = module.check(make_tree(tmp, tag_b=SHA_B[:10])) + assert any("not a full commit SHA" in p for p in problems), problems + + +def test_unattributed_dependency_is_reported(): + module = load() + with tempfile.TemporaryDirectory() as tmp: + # fmt is fetched but has no NOTICE row. The template is formatted first, so the row to + # drop is the one carrying the rendered commit rather than the {sha_b} placeholder. + notice = NOTICE.format(sha_a=SHA_A, sha_b=SHA_B).replace( + f" fmtlib/fmt {SHA_B} MIT\n", "") + problems = module.check(make_tree(tmp, notice=notice)) + assert any("fmtlib/fmt" in p and "not listed in NOTICE" in p for p in problems), problems + + +def test_stale_attribution_is_reported(): + module = load() + with tempfile.TemporaryDirectory() as tmp: + # NOTICE attributes a commit the build no longer fetches. + problems = module.check(make_tree(tmp, tag_b="a" * 40)) + assert any("stale attribution" in p for p in problems), problems + + +def test_missing_vendored_archive_is_reported(): + module = load() + with tempfile.TemporaryDirectory() as tmp: + problems = module.check(make_tree(tmp, gloss=False)) + assert any("libGlossHook.a" in p for p in problems), problems + + +def test_missing_cargo_lock_is_reported(): + module = load() + with tempfile.TemporaryDirectory() as tmp: + root = make_tree(tmp) + (root / "rust/Cargo.lock").unlink() + problems = module.check(root) + assert any("Cargo.lock" in p for p in problems), problems + + +def test_crate_absent_from_the_lock_is_reported(): + module = load() + with tempfile.TemporaryDirectory() as tmp: + notice = NOTICE.format(sha_a=SHA_A, sha_b=SHA_B) + " sha2 MIT OR Apache-2.0\n" + problems = module.check(make_tree(tmp, notice=notice)) + assert any("sha2" in p for p in problems), problems + + +def test_real_tree_is_clean(): + module = load() + assert module.check(ROOT) == [], "the checked-in tree must satisfy its own checker" + + +def main() -> int: + tests = [v for k, v in sorted(globals().items()) if k.startswith("test_")] + failed = 0 + for test in tests: + try: + test() + print(f"PASS {test.__name__}") + except Exception as exc: # noqa: BLE001 - a test harness reports, it does not raise + failed += 1 + print(f"FAIL {test.__name__}: {exc}") + print(f"{len(tests) - failed}/{len(tests)} passed") + return 1 if failed else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/tests/check_sources_listed_test.py b/scripts/tests/check_sources_listed_test.py new file mode 100755 index 0000000..4a0b947 --- /dev/null +++ b/scripts/tests/check_sources_listed_test.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +"""Host tests for scripts/check_sources_listed.py. + +The checker exists because two kinds of drift fail silently: a new .cpp that never reaches +SWEETLOADER_SOURCES is simply absent from the library, and a new host test that is never +registered never runs. A checker that misses either is worse than none, so both are exercised +here against a synthetic tree. +""" +from __future__ import annotations + +import importlib.util +import pathlib +import tempfile + +ROOT = pathlib.Path(__file__).resolve().parent.parent.parent + +CMAKE_TEMPLATE = """\ +cmake_minimum_required(VERSION 3.22) +set(SWEETLOADER_SOURCES + src/pl/Mod.cpp + {extra} +) +""" + +RUNNER_TEMPLATE = """\ +#!/usr/bin/env bash +compile_run ModTest \\ + "$here/ModTest.cpp" +{extra} +""" + + +def load(): + spec = importlib.util.spec_from_file_location( + "check_sources_listed", ROOT / "scripts" / "check_sources_listed.py") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def make_tree(tmp, sources, tests, extra_cmake="", extra_runner=""): + """Lay out a minimal tree the checker can read.""" + tmp = pathlib.Path(tmp) + (tmp / "src/pl").mkdir(parents=True) + (tmp / "tests/cosmetics").mkdir(parents=True) + for source in sources: + path = tmp / source + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("// synthetic\n", encoding="utf-8") + for test in tests: + (tmp / f"tests/cosmetics/{test}.cpp").write_text("// synthetic\n", encoding="utf-8") + listed = "\n ".join(sources) + (tmp / "CMakeLists.txt").write_text( + CMAKE_TEMPLATE.format(extra=f"{listed}\n {extra_cmake}".strip()), + encoding="utf-8") + registered = "".join(f"compile_run {t} \\\n \"$here/{t}.cpp\"\n" for t in tests) + (tmp / "tests/cosmetics/run_tests.sh").write_text( + RUNNER_TEMPLATE.format(extra=registered + extra_runner), encoding="utf-8") + return tmp + + +def test_agreement_is_clean(): + module = load() + with tempfile.TemporaryDirectory() as tmp: + root = make_tree(tmp, ["src/pl/Mod.cpp"], ["ModTest"]) + assert module.check(root) == [] + + +def test_unlisted_source_is_reported(): + module = load() + with tempfile.TemporaryDirectory() as tmp: + # The file is on disk but not in SWEETLOADER_SOURCES. + root = make_tree(tmp, ["src/pl/Mod.cpp"], ["ModTest"]) + (root / "src/pl/Forgotten.cpp").write_text("// new\n", encoding="utf-8") + problems = module.check(root) + assert any("Forgotten.cpp" in p and "not compiled" in p for p in problems), problems + + +def test_missing_source_is_reported(): + module = load() + with tempfile.TemporaryDirectory() as tmp: + # Listed in CMakeLists but deleted: the configure would fail confusingly. + root = make_tree(tmp, ["src/pl/Mod.cpp"], ["ModTest"]) + (root / "src/pl/Mod.cpp").unlink() + problems = module.check(root) + assert any("does not exist on disk" in p for p in problems), problems + + +def test_unregistered_test_is_reported(): + module = load() + with tempfile.TemporaryDirectory() as tmp: + root = make_tree(tmp, ["src/pl/Mod.cpp"], ["ModTest"]) + (root / "tests/cosmetics/NewTest.cpp").write_text("// new\n", encoding="utf-8") + problems = module.check(root) + assert any("NewTest.cpp" in p and "never run" in p for p in problems), problems + + +def test_registered_test_without_a_file_is_reported(): + module = load() + with tempfile.TemporaryDirectory() as tmp: + root = make_tree(tmp, ["src/pl/Mod.cpp"], ["ModTest"]) + (root / "tests/cosmetics/ModTest.cpp").unlink() + problems = module.check(root) + assert any("does not exist" in p for p in problems), problems + + +def test_prose_mentioning_compile_run_is_not_a_test(): + module = load() + with tempfile.TemporaryDirectory() as tmp: + # A comment that says "compile_run fixes those first" must not read as a test named + # `fixes`; an unanchored regex does exactly that and reports a phantom failure. + root = make_tree(tmp, ["src/pl/Mod.cpp"], ["ModTest"], + extra_runner="# compile_run fixes those first\n") + assert module.check(root) == [], "a comment is not a registration" + + +def test_real_tree_is_clean(): + module = load() + assert module.check(ROOT) == [], "the checked-in tree must satisfy its own checker" + + +def main() -> int: + tests = [v for k, v in sorted(globals().items()) if k.startswith("test_")] + failed = 0 + for test in tests: + try: + test() + print(f"PASS {test.__name__}") + except Exception as exc: # noqa: BLE001 - a test harness reports, it does not raise + failed += 1 + print(f"FAIL {test.__name__}: {exc}") + print(f"{len(tests) - failed}/{len(tests)} passed") + return 1 if failed else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/cosmetics/run_tests.sh b/tests/cosmetics/run_tests.sh index 05f4ab7..948a070 100755 --- a/tests/cosmetics/run_tests.sh +++ b/tests/cosmetics/run_tests.sh @@ -15,9 +15,10 @@ if [[ -z "$nlohmann" ]]; then nlohmann="$(find "$root/../../../.cxx" -path '*nlohmann_json-src/single_include' -type d 2>/dev/null | head -1 || true)" fi if [[ -z "$nlohmann" || ! -f "$nlohmann/nlohmann/json.hpp" ]]; then - # Fetch the single-header release into a gitignored cache so the host tests do not depend on a - # previous native Gradle build. Header-only and pinned by tag, so the run is reproducible. - nlohmann="$root/../../../../build/host-tools/nlohmann/include" + # Fetch the single-header release into a cache next to the checkout, so the run is reproducible + # whether this tree is the submodule inside the client or a standalone clone in CI. The cache is + # not written inside the checkout, which a read-only or oddly mounted tree would reject. + nlohmann="${SWEETLOADER_HOST_CACHE:-$root/../.host-tools}/nlohmann/include" if [[ ! -f "$nlohmann/nlohmann/json.hpp" ]]; then mkdir -p "$nlohmann/nlohmann" echo "Fetching nlohmann/json single header..." >&2 @@ -51,8 +52,8 @@ zlib_link="" for candidate in \ /usr/include \ "${ZLIB_INCLUDE:-}" \ - "$(ls -d /opt/android-sdk/ndk/*/toolchains/llvm/prebuilt/*/sysroot/usr/include 2>/dev/null | head -1)" \ - "$(ls -d /workspace/android-sdk/ndk/*/toolchains/llvm/prebuilt/*/sysroot/usr/include 2>/dev/null | head -1)" \ + "$(find /opt/android-sdk/ndk /workspace/android-sdk/ndk -type d \ + -path '*sysroot/usr/include' -print -quit 2>/dev/null)" \ "$root/../libHttpClient/External/zlib"; do if [[ -n "$candidate" && -f "$candidate/zlib.h" ]]; then zlib_source="$candidate" @@ -97,8 +98,9 @@ if [[ -z "$fmt_include" ]]; then fmt_include="$(find "$root/../../../.cxx" -path '*fmt-src/include' -type d 2>/dev/null | head -1 || true)" fi if [[ -z "$fmt_include" || ! -f "$fmt_include/fmt/format.h" ]]; then - # 11.2.0, the commit CMakeLists.txt pins. - fmt_include="$root/../../../../build/host-tools/fmt/include" + # 11.2.0, the commit CMakeLists.txt pins. Cached beside the checkout for the same reason as + # nlohmann/json above. + fmt_include="${SWEETLOADER_HOST_CACHE:-$root/../.host-tools}/fmt/include" if [[ ! -f "$fmt_include/fmt/format.h" ]]; then mkdir -p "$fmt_include" echo "Fetching fmt headers..." >&2 diff --git a/tools/tests/gen_rules_test.py b/tools/tests/gen_rules_test.py new file mode 100755 index 0000000..0e16e92 --- /dev/null +++ b/tools/tests/gen_rules_test.py @@ -0,0 +1,283 @@ +#!/usr/bin/env python3 +"""Host tests for tools/gen_rules.py. + +The generator is what turns a Minecraft build into a signature rule, and it is the only thing +standing between a game update and a rule that detours the wrong address. Its whole premise is +that `.data.rel.ro` holds pre-relocation zeros offline, so the typeinfo and vtable pointers must +be rebuilt from `.rela.dyn` before anything is read. If that step regresses, every lookup quietly +returns nothing and the tool reports "NOT RESOLVED" for seams that are in fact present. + +A real `libminecraftpe.so` is not needed to test that: the reader only uses the ELF header, the +section header table, `.rodata`, `.data.rel.ro`, `.text` and `.rela.dyn`. This builds a synthetic +ELF with the same shape as the stripped game binary and checks the derivation end to end. +""" +from __future__ import annotations + +import importlib.util +import json +import pathlib +import struct +import sys +import tempfile +import zipfile + +ROOT = pathlib.Path(__file__).resolve().parent.parent.parent + +RODATA_VADDR = 0x1000 +DATARELRO_VADDR = 0x2000 +TEXT_VADDR = 0x3000 +TEXT_SIZE = 0x100 +# Enough slots that a seam's index (the largest used is 40) is inside the table. +VTABLE_SLOTS = 64 +R_AARCH64_RELATIVE = 1027 + + +def load(): + spec = importlib.util.spec_from_file_location( + "gen_rules", ROOT / "tools" / "gen_rules.py") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def build_elf(type_specs): + """Build a stripped-binary-shaped ELF64 for the given RTTI types. + + type_specs is a list of (type_name, slot, slot_target) where slot_target is the address the + vtable slot should hold, or None to leave it zero. The pointers are written as zeros and then + supplied by R_AARCH64_RELATIVE relocations, exactly as the real binary is on disk. + """ + rodata = b"" + str_addrs = {} + for name, _, _ in type_specs: + str_addrs[name] = RODATA_VADDR + len(rodata) + rodata += name.encode() + b"\x00" + + words = [] + relocs = [] + + def word_addr(index): + return DATARELRO_VADDR + index * 8 + + for name, slot, slot_target in type_specs: + base = len(words) + words += [0] * (4 + VTABLE_SLOTS) + # The typeinfo struct: [0] its own vtable pointer, [1] the type-name string. + relocs.append((word_addr(base + 1), str_addrs[name])) + # The vtable: [base+2] offset-to-top (must be zero), [base+3] the typeinfo pointer, + # [base+4..] the slots. + relocs.append((word_addr(base + 3), word_addr(base))) + if slot_target is not None: + relocs.append((word_addr(base + 4 + slot), slot_target)) + + datarelro = b"".join(struct.pack(" int: + tests = [v for k, v in sorted(globals().items()) if k.startswith("test_")] + failed = 0 + for test in tests: + try: + test() + print(f"PASS {test.__name__}") + except Exception as exc: # noqa: BLE001 - a test harness reports, it does not raise + failed += 1 + print(f"FAIL {test.__name__}: {exc}", file=sys.stderr) + print(f"{len(tests) - failed}/{len(tests)} passed") + return 1 if failed else 0 + + +if __name__ == "__main__": + raise SystemExit(main())