nigig-org/.forgejo/workflows/sms.yml
nigig-ci a38c41c00a
Some checks failed
repo hygiene / hygiene (push) Has been cancelled
doc-engine / engine (push) Has been cancelled
doc-engine / consumer (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
nigig-map / test (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
security(sms): stop persisting message bodies, throttle sends (Phase E)
E1 -- the inbox was written to disk in plaintext.

  offline_store wrote every SMS body to
  app_data_dir/offline_store/sms_messages.json as pretty-printed JSON.
  SMS is the transport for OTPs, banking codes and M-Pesa
  confirmations, so that file was the user's complete authentication
  history sitting in app-private storage -- readable by anything running
  as the same UID, and included in backups.

  This repository already knew the answer. THREAT_MODEL.md T-I2 records
  "Raw SMS persisted to PSV file" as fixed in Phase 0, with raw_message
  omitted from save_to_disk() so it "lives in memory only". The SMS app
  then re-introduced the same defect at larger scale: the entire inbox
  rather than just M-Pesa messages, and with no retention limit until
  D6.

  OfflineSmsMessage.body is now #[serde(skip)]. Dropping the field
  rather than encrypting it is the deliberate choice: every consumer
  already reads the device provider FIRST and writes the cache second
  (nigig-sms fetch_from_device, and the mpesa and pay transaction
  pages), so the provider is the system of record and no body needs to
  survive a restart. Encryption would keep the plaintext reachable to
  anything holding the key. Not writing it removes the asset.

  Two consequences handled: sms_key() no longer hashes the body, since
  a reloaded row has an empty one and dedupe would otherwise never match
  its own cached entry and grow a duplicate per refresh; and the cached
  first paint shows a neutral placeholder rather than a blank preview
  for the instant before the provider read lands.

E9 -- the Linux backend's dependencies were pure cost.

  robius-sms declared polkit =0.17.0 and gio =0.17.0 for target_os
  = "linux". sys/linux.rs references neither: all twelve functions
  return Err(PermanentlyUnavailable). Those two crates dragged in glib
  and proc-macro-error and were the origin of RUSTSEC-2024-0370 and
  RUSTSEC-2024-0429 for every consumer of this crate.

  Deleting the block removes 340 lines from Cargo.lock. polkit, gio,
  glib and proc-macro-error no longer appear in the workspace at all,
  which also closes the LGPL-2.1 linkage question outright rather than
  routing around it as Phase B did for nigig-build alone.

E4 -- ROBIUS_SMS_BOOT_LIB was a code-injection vector.

  build.rs interpolated that environment variable straight into a Java
  string literal, which is then compiled, dexed and loaded at runtime
  with the app's full permissions. A value containing a quote closes the
  literal and injects arbitrary Java that runs on the device at boot.
  Build-time environment is not trusted input. Now validated against
  [A-Za-z0-9_]+ and the build fails loudly otherwise. Tested both ways:
  an exec payload is rejected, a legitimate name builds.

E5 -- undefined behaviour in the dex loader.

  new_direct_byte_buffer was handed RECEIVER_BYTECODE.as_ptr() as
  *mut u8 -- a &'static [u8] in .rodata cast to a mutable pointer, when
  the API is documented as taking writable memory and
  InMemoryDexClassLoader may write through it. Now copies into an owned
  allocation and leaks it, which is correct rather than lazy: the buffer
  backs a ClassLoader cached in a OnceLock for the process lifetime.

E6 -- two bindings for one native method.

  rustRestoreSchedules was both exported #[no_mangle] and registered
  dynamically via register_native_methods. Which one won was
  unspecified. Kept the dynamic one, because the class is loaded from an
  in-memory dex and is not on the JVM's search path, so symbol binding
  is not guaranteed to find it.

E8 -- no send rate limiting.

  Nothing capped send rate, and the bulk UI exists to blast a scraped
  directory. Android's practical throttle is ~30 messages per 30 minutes
  per app, past which sends are silently dropped -- so an unthrottled
  batch both overspends and fails opaquely. Adds SendRateLimiter, a pure
  token bucket taking an explicit clock so it is unit-testable without
  sleeping, wired into the bulk sender. A 200-recipient blast now stops
  at 30 and says why.

E10 -- robius-sms carried no license field, so cargo-deny needed a
  [[licenses.clarify]] override asserting one. Stated in the manifest;
  override removed.

E2 was already satisfied by D6 (retention capped at 5,000).

E7/E11 are documented rather than fixed, which is the honest status:
delivery confirmation needs real PendingIntents plumbed through
(A8 documents that Ok != delivered), and sender validation cannot be
solved client-side. Both are now rows in THREAT_MODEL.md instead of
findings in a markdown report -- along with T-I2b for E1 and T-I4 for
E3, which is NOT done: scheduled message bodies are still plaintext in
SharedPreferences.

CI: adds a gate asserting OfflineSmsMessage.body keeps #[serde(skip)].
Removing that attribute silently resumes writing plaintext and nothing
else would fail. Negative-tested.

deny-nigig-build.toml drops to 2 exemptions (from 5 before Phase B).

Tests: robius-sms 21 -> 25.
Verified: 12/12 CI jobs, clippy -D warnings clean on host and
aarch64-linux-android, cargo deny "advisories ok, bans ok, licenses ok,
sources ok", clippy ratchet holds at 49.
2026-08-01 04:58:46 +00:00

422 lines
18 KiB
YAML

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.
#
# A6 is FIXED: all 25 call sites now go through
# LockRecover::lock_recover() (sms_frame/lock_ext.rs), which
# recovers the guard via PoisonError::into_inner() instead of
# panicking. This was a ratchet at 19 during Phase 0; it is now a
# hard gate, because production code is at zero and any new
# occurrence is a regression.
#
# lock_ext.rs itself is excluded: it documents the old pattern in
# comments and its tests deliberately poison a mutex, which
# requires a real .lock().unwrap() to observe the Err.
- name: No .lock().unwrap() in the SMS crates
run: |
set -euo pipefail
if grep -rn --include='*.rs' '\.lock()\s*\.unwrap()' \
crates/robius-sms/src crates/apps/nigig-sms/src \
--exclude=lock_ext.rs \
| sed 's/^[^:]*:[0-9]*://' \
| grep -vE '^[[:space:]]*(//|/\*|\*)'; then
echo
echo "ERROR: the lock acquisition(s) above panic on a poisoned"
echo "mutex. One panic while the lock is held bricks the cache"
echo "for the rest of the process, and the panic surfaces far"
echo "from the cause. Use:"
echo " use crate::chats::sms_frame::lock_ext::LockRecover;"
echo " MUTEX.lock_recover()"
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.
#
# E1: SMS message bodies must never be persisted.
#
# SMS carries OTPs and banking codes. THREAT_MODEL.md classifies
# raw SMS text as a sensitive asset and records T-I2 -- raw SMS
# persisted to disk -- as fixed in Phase 0 for the payment crate.
# The offline store then re-introduced exactly that defect at
# larger scale: every inbox body, plaintext JSON, uncapped.
#
# OfflineSmsMessage.body is #[serde(skip)]. Removing that attribute
# silently starts writing plaintext bodies again, and nothing else
# would fail.
- name: SMS bodies must not be serialised to disk
run: |
set -euo pipefail
store=crates/nigig-core/src/persistence/offline_store.rs
if ! grep -B1 'pub body: String,' "$store" | grep -q 'serde(skip)'; then
echo "ERROR: OfflineSmsMessage.body has lost its #[serde(skip)]."
echo
echo "That writes every inbox message body to"
echo "app_data_dir/offline_store/sms_messages.json in plaintext."
echo "See THREAT_MODEL.md T-I2b. The device SMS provider is the"
echo "system of record; the cache only needs metadata."
exit 1
fi
echo "OK"
# D1/D2: draw_walk must not perform blocking platform I/O.
#
# fetch_from_device() used to call robius_sms::list_messages()
# -- a blocking cross-process ContentProvider query over Binder --
# straight from draw_walk, on a 5-second wall-clock timer that
# re-armed itself via redraw(). The app never idled, and each cycle
# also rewrote the whole offline JSON store and deep-cloned every
# conversation. It ran whether or not the SMS tab was visible.
#
# The read now happens on a worker thread and is applied through a
# results queue. This gate stops the direct call coming back.
- name: No blocking SMS provider calls in draw_walk
run: |
set -euo pipefail
if awk '
/fn draw_walk/ { in_draw = 1 }
in_draw && /robius_sms::(list_messages|list_threads|list_thread_messages|send_sms|send_bulk_sms)\(/ {
print FILENAME ":" FNR ": " $0
found = 1
}
in_draw && /^ }$/ { in_draw = 0 }
END { exit(found ? 0 : 1) }
' $(find crates/apps/nigig-sms/src -name '*.rs'); then
echo
echo "ERROR: the call(s) above run blocking platform I/O inside"
echo "draw_walk. list_messages() is a Binder round trip to the"
echo "SMS ContentProvider; on a populated inbox that is a"
echo "multi-hundred-millisecond stall on the render thread."
echo "Spawn a worker and deliver the result via SignalToUI,"
echo "as fetch_from_device/drain_pending_sms_fetch do."
exit 1
fi
echo "OK"
# A3 is FIXED. truncate_preview() now counts characters and takes
# its offsets from char_indices()/rfind(), which cannot land
# mid-character.
#
# The two slices it still contains are proven-safe, and a textual
# grep cannot tell a proven-safe offset from an arbitrary one --
# so this step is no longer the real guarantee. That job now
# belongs to `#![deny(clippy::string_slice)]` at the top of
# sms_utils.rs plus the ten unit tests in that module, including a
# sweep that walks the cut offset through every possible position
# inside a multi-byte character.
#
# This gate is kept, pinned at those two known-safe sites, purely
# so a THIRD slice cannot appear in the module without someone
# deliberately editing this number.
#
# The pattern deliberately does NOT require a leading `&`. The
# first version of this gate did, and it missed
# if let Some(pos) = trimmed[..end].rfind(..)
# one line above the `&trimmed[..end]` it did catch -- an
# autoref'd slice panics identically, and being the earlier of the
# two it is the one that actually fired first. A gate that matches
# only the second of two adjacent instances of the same bug is
# worse than none, because it reports a number that looks audited.
- name: No new byte-offset string slicing in SMS text helpers
run: |
set -euo pipefail
BASELINE=2
PATTERN='[A-Za-z_][A-Za-z0-9_]*\[(\.\.=?[A-Za-z0-9_]+|[A-Za-z0-9_]+\.\.)[A-Za-z0-9_]*\]'
count=$(grep -rnE --include='*.rs' "$PATTERN" \
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' "$PATTERN" \
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 49. 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.
#
# 50 -> 49 when Phase C5 deleted the hand-rolled date helpers.
#
# This was briefly 52: Phase 0.7 turned on clippy::string_slice,
# which surfaced the two byte-offset slices behind bug A3. A3 is
# now fixed and that lint is deny-with-a-scoped-allow in
# sms_utils.rs, so the count is back to 50.
#
# 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=49
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