Compare commits

..

3 commits

Author SHA1 Message Date
nigig-ci
76e4d0c391 ci(sms): add the SMS workflow (Phase 0.2-0.7)
Some checks failed
repo hygiene / hygiene (push) Has been cancelled
sms / gates (push) Has been cancelled
sms / robius-sms (push) Has been cancelled
sms / android (push) Has been cancelled
sms / nigig-sms (push) Has been cancelled
sms / supply-chain (push) Has been cancelled
nigig-build (CAD) / supply-chain (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / full-crate-check (push) Has been cancelled
doc-engine / engine (push) Has been cancelled
doc-engine / consumer (push) Has been cancelled
nigig-map / test (push) Has been cancelled
The SMS stack had no CI of any kind. crates/robius-sms (2,019 LOC)
and crates/apps/nigig-sms (5,706 LOC) shipped with two tests, both of
which assert derived trait impls (BulkSubTab::default() and a
PartialEq) and neither of which touches SMS. That is 7,725 lines with
no automated signal, for a feature whose job is to send real, billable
messages.

Five jobs:

  gates         Two source scans, both for defect classes already
                present here and both invisible in review.
  robius-sms    check + clippy -D warnings + test, host.
  android       The job that matters. Everything in sys/android/ --
                all ~600 lines of JNI, every function this crate
                actually performs on a device -- is behind
                #[cfg(target_os = "android")] and is not compiled by
                any host job. The three clippy errors fixed in the
                preceding commit were invisible until this job
                existed.
  nigig-sms     check + test + a scoped clippy ratchet.
  supply-chain  --locked lockfile, whitespace. Only enforceable
                because the preceding commit tracked Cargo.lock.

The android job pins JDK 17 via setup-java. build.rs compiles two
.java files and dexes them with d8, and d8 (build-tools 34) rejects
class file major version > 61. A runner defaulting to JDK 21 fails
inside d8 with "Unsupported class file major version 65", which reads
like a toolchain bug rather than a JDK mismatch. Reproduced locally on
21, green on 17.

Three steps are RATCHETS against a recorded baseline rather than hard
gates, because each has pre-existing violations whose fixes belong to
later phases:

  .lock().unwrap()        19 lines. Poisoning one mutex bricks contact
                          resolution for the process lifetime.
  byte-offset slicing      1. truncate_preview() panics on any inbound
                          SMS containing emoji or non-Latin text, per
                          visible row per frame.
  nigig-sms clippy        50. All mechanical, but `clippy --fix`
                          cannot apply them through Makepad's
                          script_mod! proc macro, so they need hand
                          edits. 34 of the 50 are dead-code reports
                          for the duplicated contact subsystem in
                          inbox/sms_screen.rs.

A ratchet fails if the count RISES, and also if it FALLS without the
baseline being lowered, so progress cannot be silently undone. The
alternative -- a hard gate red on its first run -- gets switched off,
which is the reasoning already recorded in doc-engine.yml for not
gating cargo fmt.

Clippy for nigig-sms is filtered by package id: a plain -D warnings
would also fail on ~89 pre-existing warnings in nigig-core,
nigig-uikit and matrix_client, which are out of scope here.
--no-deps does not help, since those are workspace members rather
than registry deps.

Every job was executed locally against this tree before commit:
toolchain 1.97.1 per rust-toolchain.toml, JDK 17, SDK platform 34 /
build-tools 34.0.0. gates PASS, robius-sms PASS (0 tests),
android PASS, nigig-sms PASS (2 tests), supply-chain PASS.
2026-07-31 20:06:15 +00:00
nigig-ci
7db530b374 fix(sms): clear the three clippy errors on the Android target
These are only visible when cross-compiling: a host build of
robius-sms compiles sys/linux.rs, a stub whose every function returns
PermanentlyUnavailable, so none of sys/android/ is type-checked at
all. Host clippy was already clean; the Android target was not.

  receivers.rs  unnecessary `unsafe` block. The block wrapped nothing
                that needs it -- taking `&mut env` and calling a safe
                Rust fn -- inside an already-`extern "C"` function.
                Removing it does not change what the JNI entry point
                does; it stops the block from implying the body has
                been audited for an invariant it does not have.

  schedule.rs   redundant closure |env, activity| load_schedules(env,
  thread.rs     activity) -> load_schedules.

All three are mechanical. No behaviour change.

Verified with the pinned toolchain (1.97.1), JDK 17, SDK 34:
  cargo clippy -p robius-sms --all-targets -- -D warnings          OK
  cargo clippy -p robius-sms --target aarch64-linux-android
      -- -D warnings                                               OK
2026-07-31 20:06:15 +00:00
nigig-ci
c0143a3af8 build: track the workspace root Cargo.lock (Phase 0.1)
CI already runs with --locked: nigig-build.yml asserts
`cargo metadata --locked` and `git diff --exit-code -- Cargo.lock`,
and doc-engine.yml builds every target with --locked. None of that
can hold while the root lockfile is gitignored -- `--locked` fails
outright with no lockfile to check against, so in practice every
build re-resolved and a transitive dependency could change under CI
without any commit recording it.

The nigig-build workflow comment already claimed this was done
("Blocked until Cargo.lock was committed in Phase 0.2"); it was not.
This commits it.

The ignore rule becomes `**/Cargo.lock` + `!/Cargo.lock` so the root
lock is tracked while incidental nested locks stay ignored. The three
payment-crate locks keep their existing negations and stay tracked.

Generated with the pinned toolchain from rust-toolchain.toml (1.97.1)
against makepad rev a79f0dce; 566 packages. Verified `cargo metadata
--locked` exits 0.
2026-07-31 20:06:15 +00:00
6 changed files with 5839 additions and 7 deletions

341
.forgejo/workflows/sms.yml Normal file
View file

@ -0,0 +1,341 @@
name: sms
# Phase 0 of the SMS remediation plan: make the SMS stack verifiable.
#
# Before this file existed, `crates/robius-sms` (2,019 LOC) and
# `crates/apps/nigig-sms` (5,706 LOC) had NO CI of any kind and two tests,
# both of which assert derived trait impls and neither of which mentions
# SMS. That is 7,725 lines of untested code whose job is to spend the
# user's money by sending real, billable, irreversible messages.
#
# Enforces:
# 1. Both crates compile on the host AND on aarch64-linux-android.
# The host build only ever compiles sys/linux.rs -- a stub whose
# every function returns PermanentlyUnavailable. ALL of the real
# logic (~600 lines of JNI in sys/android/) is behind
# #[cfg(target_os = "android")] and is invisible to a host build.
# A host-only gate would be close to worthless here.
# 2. Clippy stays clean for code these two crates OWN. Warnings from
# path dependencies (nigig-core, nigig-uikit, matrix_client) are
# pre-existing and out of scope for this workflow; they are
# filtered by package id rather than muted, so they still show in
# the log and a future workflow can gate them.
# 3. Tests pass.
# 4. Two defect classes that are invisible in review and have already
# shipped here, as source-scanning gates.
#
# `cargo fmt --check` is deliberately not a gate, matching the reasoning
# already recorded in doc-engine.yml: these crates predate the pinned
# toolchain's rustfmt style and reformatting them wholesale would
# conflict with the remediation work. A step that always fails gets
# ignored, which is worse than no step.
on:
push:
paths:
- 'crates/robius-sms/**'
- 'crates/apps/nigig-sms/**'
- 'Cargo.lock'
- 'Cargo.toml'
- 'rust-toolchain.toml'
- '.forgejo/workflows/sms.yml'
pull_request:
paths:
- 'crates/robius-sms/**'
- 'crates/apps/nigig-sms/**'
- 'Cargo.lock'
- 'Cargo.toml'
- 'rust-toolchain.toml'
- '.forgejo/workflows/sms.yml'
jobs:
# ---------------------------------------------------------------------
# Source-scanning gates. These need no toolchain, so they run first and
# fail fast.
# ---------------------------------------------------------------------
gates:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
# A poisoned mutex turns every later `.lock().unwrap()` into a
# panic. The contact cache in conversations_list.rs is written from
# a spawned worker thread, so one panic anywhere under the lock
# permanently bricks contact resolution for the process lifetime,
# and it surfaces far from the cause.
#
# The codebase already knows the right pattern -- companies_list.rs
# uses `if let Ok(mut s) = ..lock()` -- it is just applied
# inconsistently. This keeps the good pattern from regressing.
#
# RATCHET. There are 19 source lines with these today (bug A6 in the
# plan); fixing them is Phase A work, not Phase 0. A gate that
# fails on its first run gets switched off, so this asserts the
# count never RISES and must be lowered as they are fixed. When it
# reaches 0, replace the whole step with a plain grep that fails on
# any match.
- name: No new .lock().unwrap() in the SMS crates
run: |
set -euo pipefail
BASELINE=19
count=$(grep -rn --include='*.rs' '\.lock()\s*\.unwrap()' \
crates/robius-sms/src crates/apps/nigig-sms/src \
| sed 's/^[^:]*:[0-9]*://' \
| grep -vcE '^[[:space:]]*(//|/\*|\*)' || true)
echo "found $count, baseline $BASELINE"
if [ "$count" -gt "$BASELINE" ]; then
grep -rn --include='*.rs' '\.lock()\s*\.unwrap()' \
crates/robius-sms/src crates/apps/nigig-sms/src || true
echo
echo "ERROR: $count .lock().unwrap() calls, up from $BASELINE."
echo "These panic on a poisoned mutex. One panic while the lock"
echo "is held bricks the cache for the rest of the process. Use:"
echo " if let Ok(guard) = MUTEX.lock() { .. }"
echo "or MUTEX.lock().unwrap_or_else(|e| e.into_inner())"
exit 1
fi
if [ "$count" -lt "$BASELINE" ]; then
echo
echo "Good: down to $count. Lower BASELINE in this file to $count"
echo "so the progress cannot be undone."
exit 1
fi
echo "OK"
# `&s[..n]` where n is a byte offset panics with "byte index N is
# not a char boundary" the moment n lands inside a multi-byte
# character. truncate_preview() in sms_utils.rs did exactly this
# against a 120-BYTE constant, and it runs per visible row per
# frame -- so one inbound SMS containing emoji, Swahili or Arabic
# text panicked the whole conversation list on every frame. That is
# a remote denial of service triggerable by anyone who knows the
# victim's number.
#
# Slicing must go through char_indices()/char_boundary logic.
#
# RATCHET, for the same reason as the step above: the one live
# instance is truncate_preview() in sms_utils.rs, and fixing it is
# bug A3 in the remediation plan. Drop BASELINE to 0 with that fix
# and this becomes a hard gate.
- name: No new byte-offset string slicing in SMS text helpers
run: |
set -euo pipefail
BASELINE=1
count=$(grep -rnE --include='*.rs' \
'&[A-Za-z_][A-Za-z0-9_]*\[\.\.[A-Za-z0-9_]+\]' \
crates/apps/nigig-sms/src \
| sed 's/^[^:]*:[0-9]*://' \
| grep -vcE '^[[:space:]]*(//|/\*|\*)' || true)
echo "found $count, baseline $BASELINE"
if [ "$count" -gt "$BASELINE" ]; then
grep -rnE --include='*.rs' \
'&[A-Za-z_][A-Za-z0-9_]*\[\.\.[A-Za-z0-9_]+\]' \
crates/apps/nigig-sms/src || true
echo
echo "ERROR: $count byte-offset slice(s), up from $BASELINE."
echo "These index a &str by BYTE offset and panic when the"
echo "offset is not a char boundary -- reachable from any"
echo "inbound SMS containing emoji or non-Latin text. Use"
echo "char_indices() to find a real boundary first."
exit 1
fi
if [ "$count" -lt "$BASELINE" ]; then
echo
echo "Good: down to $count. Lower BASELINE in this file to $count."
exit 1
fi
echo "OK"
# ---------------------------------------------------------------------
# robius-sms: the platform layer. Cheap -- no GUI stack.
# ---------------------------------------------------------------------
robius-sms:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
# polkit/gio are pulled in ONLY by the Linux backend, whose every
# function returns PermanentlyUnavailable. They are why
# deny-nigig-build.toml carries RUSTSEC-2024-0370 and
# RUSTSEC-2024-0429 plus an open LGPL-2.1 question. Phase E9 of the
# remediation plan removes the backend and these packages with it;
# until then CI needs the headers to build the stub.
- name: Install native dependencies
run: |
sudo apt-get update -qq
sudo apt-get install -y -qq \
pkg-config libpolkit-gobject-1-dev libpolkit-agent-1-dev \
libglib2.0-dev libsqlite3-dev
- name: Host check
run: cargo check --locked -p robius-sms
- name: Host clippy
run: cargo clippy --locked -p robius-sms --all-targets -- -D warnings
- name: Host tests
run: cargo test --locked -p robius-sms
# ---------------------------------------------------------------------
# The Android target. This is the job that matters: everything in
# sys/android/ is invisible to every other job in this file.
# ---------------------------------------------------------------------
android:
runs-on: ubuntu-latest
timeout-minutes: 40
steps:
- uses: actions/checkout@v4
# d8 (build-tools 34) rejects class file major version > 61, i.e.
# anything newer than Java 17. A runner defaulting to JDK 21 makes
# build.rs fail inside d8 with "Unsupported class file major
# version 65", which reads like a toolchain bug rather than a JDK
# mismatch. Pin the JDK so that failure cannot recur here.
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
- uses: android-actions/setup-android@v3
- name: Install Android SDK packages
run: sdkmanager "platforms;android-34" "build-tools;34.0.0"
- name: Add Rust Android target
run: rustup target add aarch64-linux-android
# robius-sms/src/build.rs compiles two .java files with javac and
# dexes them with d8, so this step exercises the Java toolchain and
# the runtime-dex-loading path, not just the Rust.
- name: Android check
run: cargo check --locked -p robius-sms --target aarch64-linux-android
- name: Android clippy
run: |
cargo clippy --locked -p robius-sms \
--target aarch64-linux-android -- -D warnings
# ---------------------------------------------------------------------
# nigig-sms: the UI layer. Pulls the whole Makepad stack, so it is the
# slow job and needs the GUI system libraries.
# ---------------------------------------------------------------------
nigig-sms:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
- name: Install native dependencies
run: |
sudo apt-get update -qq
sudo apt-get install -y -qq \
pkg-config libwayland-dev libxcursor-dev libxrandr-dev \
libxi-dev libx11-dev libgl1-mesa-dev libasound2-dev \
libpolkit-gobject-1-dev libpolkit-agent-1-dev \
libglib2.0-dev libssl-dev libsqlite3-dev libudev-dev \
libpulse-dev libxkbcommon-dev
- name: Check
run: cargo check --locked -p nigig-sms
- name: Test
run: cargo test --locked -p nigig-sms
# `-D warnings` cannot be applied to the whole `-p nigig-sms` build:
# it also compiles nigig-core, nigig-uikit and matrix_client, which
# carry ~89 pre-existing warnings that are not this workflow's to
# fix. Muting them with --no-deps does not work either -- they are
# workspace members, not registry deps, so clippy still reports
# them.
#
# So: take clippy's JSON, keep only diagnostics whose package id is
# nigig-sms, and count those. Dependency warnings stay visible in
# the log above but do not fail the build. When nigig-core and
# nigig-uikit are cleaned up, this can collapse to a plain
# `-- -D warnings`.
#
# RATCHET at 50. Every one is mechanical (unused imports, dead
# code, map->for_each, needless borrows) and none is a logic
# change, but `cargo clippy --fix` cannot apply them: the crate is
# built around Makepad's script_mod! proc macro and rustfix
# refuses to edit through it. They therefore have to be fixed by
# hand, which is Phase F work, not Phase 0.
#
# 34 of the 50 are dead-code reports for the duplicate contact
# subsystem in inbox/sms_screen.rs -- an entire second copy of the
# cache, its lookup helpers and try_load_contacts(), none of it
# reachable. Deleting that file's dead half clears most of this
# number in one commit.
- name: Clippy ratchet (nigig-sms-owned diagnostics only)
run: |
set -euo pipefail
BASELINE=50
cargo clippy --locked -p nigig-sms --all-targets \
--message-format=json > /tmp/clippy-sms.json 2>/tmp/clippy-sms.err || true
# Surface the human-readable log for debugging.
cat /tmp/clippy-sms.err || true
BASELINE="$BASELINE" python3 - <<'PY'
import json, os, sys
baseline = int(os.environ['BASELINE'])
owned, seen = [], set()
with open('/tmp/clippy-sms.json') as fh:
for line in fh:
try:
m = json.loads(line)
except ValueError:
continue
if m.get('reason') != 'compiler-message':
continue
if 'nigig-sms' not in m.get('package_id', ''):
continue
msg = m['message']
if msg.get('level') not in ('warning', 'error'):
continue
# --all-targets compiles lib and lib-test, duplicating
# every diagnostic; dedupe on rendered text.
key = msg.get('rendered', '')
if key in seen:
continue
seen.add(key)
owned.append(msg)
n = len(owned)
print("found %d nigig-sms diagnostics, baseline %d" % (n, baseline))
if n > baseline:
for msg in owned:
sys.stdout.write(msg.get('rendered', ''))
print()
print("ERROR: %d diagnostics, up from %d." % (n, baseline))
sys.exit(1)
if n < baseline:
print()
print("Good: down to %d. Lower BASELINE in this file to %d "
"so the progress cannot be undone." % (n, n))
sys.exit(1)
print("OK")
PY
# ---------------------------------------------------------------------
# Supply chain. Mirrors the gates already in nigig-build.yml so the SMS
# crates cannot drift from the rest of the workspace.
# ---------------------------------------------------------------------
supply-chain:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
# A lockfile that changes during CI means the committed one was
# stale. This is only enforceable because Phase 0.1 committed the
# root Cargo.lock -- before that it was gitignored and every build
# silently re-resolved.
- name: Lockfile must be committed and current
run: |
set -euo pipefail
test -f Cargo.lock || { echo "missing Cargo.lock at workspace root"; exit 1; }
cargo metadata --locked --format-version 1 > /dev/null
git diff --exit-code -- Cargo.lock
- name: Reject whitespace errors
run: git diff --check

8
.gitignore vendored
View file

@ -1,5 +1,11 @@
target target
Cargo.lock # Phase 0.1: the workspace root lockfile is TRACKED. CI runs with --locked
# (see .forgejo/workflows/*.yml), which cannot work against an ignored
# lockfile: `cargo metadata --locked` fails outright and every build
# re-resolves, so a dependency can change under CI without any commit.
# Nested/vendored lockfiles stay ignored via the pattern below.
**/Cargo.lock
!/Cargo.lock
# Review item 1.1: the payment crates are validated standalone against a # Review item 1.1: the payment crates are validated standalone against a
# checked-in lockfile, so their locks must be tracked. Application crates # checked-in lockfile, so their locks must be tracked. Application crates
# keep using the ignore rule above. # keep using the ignore rule above.

5487
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -29,11 +29,9 @@ pub extern "C" fn Java_robius_sms_SmsBootReceiver_rustRestoreSchedules<'a>(
_: JObject<'a>, _: JObject<'a>,
context: JObject<'a>, context: JObject<'a>,
) { ) {
unsafe {
let mut env = env; let mut env = env;
let _ = super::schedule::restore_all_schedules(&mut env, &context); let _ = super::schedule::restore_all_schedules(&mut env, &context);
} }
}
pub(crate) fn ensure_loaded(env: &mut JNIEnv<'_>) -> Result<()> { pub(crate) fn ensure_loaded(env: &mut JNIEnv<'_>) -> Result<()> {
if ALARM_RECEIVER_CLASS.get().is_some() && BOOT_RECEIVER_CLASS.get().is_some() { if ALARM_RECEIVER_CLASS.get().is_some() && BOOT_RECEIVER_CLASS.get().is_some() {

View file

@ -34,7 +34,7 @@ pub(crate) fn cancel_scheduled_sms(id: i32) -> Result<()> {
} }
pub(crate) fn list_scheduled_sms() -> Result<Vec<ScheduledMessage>> { pub(crate) fn list_scheduled_sms() -> Result<Vec<ScheduledMessage>> {
robius_android_env::with_activity(|env, activity| load_schedules(env, activity)) robius_android_env::with_activity(load_schedules)
.map_err(|_| Error::AndroidEnvironment) .map_err(|_| Error::AndroidEnvironment)
.and_then(|x| x) .and_then(|x| x)
} }

View file

@ -6,7 +6,7 @@ use jni::{
use crate::{Error, Result, SmsThread}; use crate::{Error, Result, SmsThread};
pub(crate) fn list_threads() -> Result<Vec<SmsThread>> { pub(crate) fn list_threads() -> Result<Vec<SmsThread>> {
robius_android_env::with_activity(|env, activity| list_threads_inner(env, activity)) robius_android_env::with_activity(list_threads_inner)
.map_err(|_| Error::AndroidEnvironment) .map_err(|_| Error::AndroidEnvironment)
.and_then(|x| x) .and_then(|x| x)
} }