Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
236 changes: 236 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 28 additions & 9 deletions rust/caramel-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PathBuf> },
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<PathBuf>,
},
Inspect {
package: PathBuf,
},
}

impl Command {
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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(())
Expand All @@ -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())))?;
Expand Down Expand Up @@ -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);
Expand Down
1 change: 0 additions & 1 deletion rust/caramel-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,3 @@ fn print_usage() {
env!("CARGO_PKG_VERSION")
);
}

18 changes: 15 additions & 3 deletions rust/nethernet-core/src/backoff.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}
}

Expand All @@ -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.
Expand Down Expand Up @@ -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}");
Expand Down
Loading
Loading