diff --git a/.context/.gitignore b/.context/.gitignore index 1245a9e..c0a6e92 100644 --- a/.context/.gitignore +++ b/.context/.gitignore @@ -1,9 +1,10 @@ # .context/.gitignore - managed by the Mold stamp # -# experimental/ is a persistent scratch workspace for decompiled artifacts, -# pulled device files, and in-flight analysis. Contents are intentionally not -# versioned; commit specific subpaths only when they become stable deliverables -# (then move them out of experimental/ into src/, docs/, etc.). +# experimental/ is a persistent scratch workspace for decompiled +# artifacts, pulled device files, and in-flight analysis. Contents +# are intentionally not versioned; commit specific subpaths only when +# they become stable project deliverables (then move them out of +# experimental/ into the correct location: src/, docs/, etc.). experimental/* !experimental/.gitkeep diff --git a/.context/experimental/.gitkeep b/.context/experimental/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..d72e272 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,32 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 + +[*.{kt,kts}] +# Line-length ceiling 300: the standard across these projects. A generous +# ceiling so the gate fires only on genuinely unreadable lines. Note ktlint +# cannot auto-fix an over-long line (it will not break it for you), so a line +# past 300 must be wrapped by hand — rare at this ceiling. +max_line_length = 300 +# Non-auto-fixable comment-placement rules: ktlint flags these but will not fix +# them, so they cannot belong to a deterministic auto-format gate. Both guard +# legitimate, readable patterns (labelled Java-interop bool args; an explanatory +# comment on a single-line if-guard). Comment placement is a sign-plane (review) +# concern, not a bump-plane (auto-fix) one. +ktlint_standard_comment-wrapping = disabled +ktlint_standard_if-else-wrapping = disabled + +[*.{c,cc,cpp,h,hpp}] +indent_size = 2 + +[*.{yml,yaml,toml,json}] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..ca4d119 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,9 @@ +#!/bin/sh +# Mold casting hook: the fast structural gate (gatesFast) on commit. +# A convenience pre-check, never the wall: `--no-verify` walks past this, the +# release build does not (ADR-0002). gatesFast also runs on every variant build. +set -e +if [ -x ./gradlew ]; then + exec ./gradlew gatesFast --quiet +fi +echo "mold pre-commit: no ./gradlew found; skipping gatesFast" >&2 diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 0000000..9c54d31 --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,10 @@ +#!/bin/sh +# Mold casting hook: the full wall (gatesFull) on push. +# A convenience pre-check, never the wall: `--no-verify` walks past this, the +# release build does not (ADR-0002). gatesFull also binds at assembleRelease / +# bundleRelease. Inert where there is no push target (local-only developer). +set -e +if [ -x ./gradlew ]; then + exec ./gradlew gatesFull --quiet +fi +echo "mold pre-push: no ./gradlew found; skipping gatesFull" >&2 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 44992d5..b15ca3f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,6 +23,32 @@ jobs: - name: Tests run: cargo test -p resetprop + gates: + name: Casting wall (fmt, clippy, supply, comment-hygiene) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Resolve pinned toolchain (rust-toolchain.toml) + run: rustup show + + - name: Format + run: cargo fmt --all --check + + - name: Clippy (workspace, all targets and features) + run: cargo clippy --all-targets --all-features -- -D warnings + + - name: Install cargo-deny + uses: taiki-e/install-action@v2 + with: + tool: cargo-deny + + - name: Supply line (offline, deterministic) + run: cargo deny check licenses bans sources + + - name: Comment hygiene + run: python3 scripts/comment-hygiene.py + cross-compile: runs-on: ubuntu-latest strategy: @@ -32,10 +58,10 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Install Rust - uses: dtolnay/rust-toolchain@stable - with: - targets: ${{ matrix.target }} + - name: Pinned toolchain + Android target + run: | + rustup show + rustup target add ${{ matrix.target }} - name: Install NDK uses: nttld/setup-ndk@v1 @@ -51,16 +77,3 @@ jobs: - name: Build ${{ matrix.target }} run: cargo build --release --target ${{ matrix.target }} - - clippy: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Install Rust - uses: dtolnay/rust-toolchain@stable - with: - components: clippy - - - name: Clippy (library) - run: cargo clippy -p resetprop -- -D warnings diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 684b7f0..6b449b9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,10 +28,10 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Install Rust - uses: dtolnay/rust-toolchain@stable - with: - targets: ${{ matrix.target }} + - name: Pinned toolchain + Android target + run: | + rustup show + rustup target add ${{ matrix.target }} - name: Install NDK uses: nttld/setup-ndk@v1 diff --git a/.gitignore b/.gitignore index 405a0e7..94f9585 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ # seal: regenerable output + agent scratch (keep execution sessions lean) *.bak +.serena/ graphify-out/ target/ .gradle/ diff --git a/.mold/structure-version b/.mold/structure-version new file mode 100644 index 0000000..7ac4e5e --- /dev/null +++ b/.mold/structure-version @@ -0,0 +1 @@ +0.1.13 diff --git a/bucket/resetprop-rs-injectrc-ports/progress.yml b/bucket/resetprop-rs-injectrc-ports/progress.yml index e8fdf0a..1b8a894 100644 --- a/bucket/resetprop-rs-injectrc-ports/progress.yml +++ b/bucket/resetprop-rs-injectrc-ports/progress.yml @@ -1,22 +1,22 @@ plan: resetprop-rs-injectrc-ports -updated: 2026-06-17 +updated: 2026-06-18 tasks: T01: done T02: done T03: done T04: done T05: done - T06: backlog - T07: backlog - T08: backlog - T09: backlog - T10: backlog + T06: done + T07: done + T08: done + T09: done + T10: done T11: cut T12: done T13: done - T14: backlog + T14: done T15: done T16: done - T17: backlog - T18: backlog + T17: done + T18: done T19: done diff --git a/build-logic/build.gradle.kts b/build-logic/build.gradle.kts new file mode 100644 index 0000000..b4d4498 --- /dev/null +++ b/build-logic/build.gradle.kts @@ -0,0 +1,14 @@ +// Generated by tools/cast.py. VERSION-PIN LOCATION #2 of 2: the convention jar so +// the project's convention plugins can apply mold.kotlin-quality and mold.gates by +// id. Keep in lockstep with the root build's pin (both == castingVersion). +plugins { + `kotlin-dsl` +} + +dependencies { + // The mold casting law. The AGP/Kotlin gradlePlugin deps below let Gradle + // generate the type-safe plugin accessors the convention plugins use. + implementation("mold.casting:convention:0.1.13") + // implementation(libs.android.gradlePlugin) + // implementation(libs.kotlin.gradlePlugin) +} diff --git a/build-logic/settings.gradle.kts b/build-logic/settings.gradle.kts new file mode 100644 index 0000000..d7a9d21 --- /dev/null +++ b/build-logic/settings.gradle.kts @@ -0,0 +1,19 @@ +// Generated by tools/cast.py. build-logic is a standalone included build, so it +// does NOT inherit the root's repositories or version catalog: the law repo is wired +// here SECOND (miss it -> "Could not find mold.casting:convention"), and the same +// gradle/libs.versions.toml is imported by path so the catalog stays one SSOT. +dependencyResolutionManagement { + repositories { + maven { url = uri("file:///mnt/companion/president/maven-casting") } // mold casting law repo + gradlePluginPortal() + google() + mavenCentral() + } + versionCatalogs { + create("libs") { + from(files("../gradle/libs.versions.toml")) + } + } +} + +rootProject.name = "build-logic" diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..0a8a823 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,84 @@ +// Generated by tools/cast.py (the Casting plane). +plugins { + id("mold.rust-gates") version "0.1.13" + id("mold.comment-hygiene") version "0.1.13" +} + +// The module-edge dependency guard has NO published plugin (the one gate that is +// project-authored): cast.py emits the task + matrix parser here. Fill in +// gradle/dependency-guard.config with your tier matrix; an illegal edge then fails +// `check` (-> build), not just a review. See casting-adoption.md. +tasks.register("dependencyGuard") { + group = "verification" + description = "Fails any project->project edge outside gradle/dependency-guard.config." + val matrixFile = file("gradle/dependency-guard.config") + val modules = subprojects + doLast { + val matrix = DependencyMatrix.parse(matrixFile.readText()) + val violations = modules.flatMap { module -> + module.configurations + .flatMap { it.dependencies } + .filterIsInstance() + .map { it.path } + .distinct() + .filter { it != module.path } + .filterNot { matrix.permits(module.path, it) } + .map { "${module.path} -> $it" } + }.distinct().sorted() + if (violations.isNotEmpty()) { + throw GradleException( + "Dependency guard: ${violations.size} edge(s) outside the matrix:\n" + + violations.joinToString("\n") { " $it" }, + ) + } + logger.lifecycle("Dependency guard: ${modules.size} modules, every edge within the matrix.") + } +} + +subprojects { + tasks.matching { it.name == "check" }.configureEach { + dependsOn(rootProject.tasks.named("dependencyGuard")) + } +} + +// A contract target matches face-precise (contract:state); any other by bare tier. +class DependencyMatrix(private val allowed: Map>) { + fun permits(source: String, target: String): Boolean { + val targetSegments = target.split(":").filter { it.isNotEmpty() } + val spec = if (targetSegments.firstOrNull() == "contract" && targetSegments.size >= 2) { + "contract:${targetSegments[1]}" + } else { + targetSegments.firstOrNull().orEmpty() + } + return spec in (allowed[tierOf(source)] ?: emptySet()) + } + + private fun tierOf(path: String): String = + path.split(":").firstOrNull { it.isNotEmpty() }.orEmpty() + + companion object { + fun parse(text: String): DependencyMatrix { + val allowed = text.lineSequence() + .map { it.substringBefore('#').trim() } + .filter { it.isNotEmpty() } + .associate { line -> + require("->" in line) { "dependency-guard.config: rule has no '->': `$line`" } + val tier = line.substringBefore("->").trim() + val specs = line.substringAfter("->").trim() + .split(Regex("\\s+")).filter { it.isNotEmpty() }.toSet() + tier to specs + } + return DependencyMatrix(allowed) + } + } +} + +// Cross-wire the cargo-root rust gates into each module's wall: the cargo root is a +// plain project, so rust-gates' in-plugin fold finds no local gatesFast/Full; this +// binding makes a broken crate fail the same wall (enforcement.md, ADR-0010). +subprojects { + tasks.matching { it.name == "gatesFast" } + .configureEach { dependsOn(rootProject.tasks.named("rustGatesFast")) } + tasks.matching { it.name == "gatesFull" } + .configureEach { dependsOn(rootProject.tasks.named("rustGatesFull")) } +} diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml new file mode 100644 index 0000000..33da8da --- /dev/null +++ b/config/detekt/detekt.yml @@ -0,0 +1,35 @@ +# Casting-plane detekt config — the WHOLE active rule set, not an override. +# The mold.kotlin-quality convention plugin sets buildUponDefaultConfig = false, +# so detekt's 200-rule default set is OFF and only the rules turned on below run. +# +# enforcement.md scopes this gate to two things and nothing else: +# - swallowed / empty / over-generic exceptions (bump) +# - a LargeClass / LongMethod hard ceiling (bump at the ceiling only) +# The soft size budget is a sign-plane (judgment) concern, not a deterministic +# gate, so detekt enforces only the hard ceiling here. Formatting belongs to +# Spotless/ktlint, never detekt — no formatting or style rules live in this file. +# +# Per ADR-0007 (certainty over severity): gate only what we are certain about. +# Widening this set is an ADR plus a canary promotion, not an edit. + +# EmptyCatchBlock is in detekt's empty-blocks ruleset, not exceptions. +empty-blocks: + active: true + EmptyCatchBlock: + active: true + +exceptions: + active: true + SwallowedException: + active: true + TooGenericExceptionCaught: + active: true + +complexity: + active: true + LargeClass: + active: true + threshold: 600 + LongMethod: + active: true + threshold: 60 diff --git a/config/license/allowed-licenses.json b/config/license/allowed-licenses.json new file mode 100644 index 0000000..85cf8f7 --- /dev/null +++ b/config/license/allowed-licenses.json @@ -0,0 +1,13 @@ +{ + "_comment": "Casting-plane JVM license allowlist read by the first-party checkLicense gate in mold.jvm-supply: an external dependency whose POM license matches none of these regexes fails gatesFull. Mirrors the deny.toml policy for the Rust half. A dependency whose license the gate cannot read directly (e.g. declared only in a parent POM) is frozen with a per-module entry ({ \"moduleName\": \"group:artifact\" }) — the brownfield mechanism. Widening the license SET (not a per-module freeze) is an ADR plus a canary promotion.", + "allowedLicenses": [ + { "moduleLicense": ".*Apache.*" }, + { "moduleLicense": ".*MIT.*" }, + { "moduleLicense": ".*BSD.*" }, + { "moduleLicense": ".*ISC.*" }, + { "moduleLicense": ".*Unicode.*" }, + { "moduleLicense": ".*CC0.*" }, + { "moduleLicense": ".*Zlib.*" }, + { "moduleLicense": ".*Public Domain.*" } + ] +} diff --git a/crates/propdetect-bionic/Cargo.toml b/crates/propdetect-bionic/Cargo.toml index ef2d439..b276948 100644 --- a/crates/propdetect-bionic/Cargo.toml +++ b/crates/propdetect-bionic/Cargo.toml @@ -1,4 +1,5 @@ [package] +publish = false # internal workspace crate, not a crates.io artifact name = "propdetect-bionic" version = "0.1.0" edition = "2021" diff --git a/crates/propdetect-bionic/src/main.rs b/crates/propdetect-bionic/src/main.rs index b3e45d0..c00db3c 100644 --- a/crates/propdetect-bionic/src/main.rs +++ b/crates/propdetect-bionic/src/main.rs @@ -42,12 +42,24 @@ struct Snapshot { } extern "C" fn foreach_cb(pi: *const prop_info, cookie: *mut c_void) { - - extern "C" fn read_cb(cookie: *mut c_void, name: *const c_char, value: *const c_char, serial: u32) { + extern "C" fn read_cb( + cookie: *mut c_void, + name: *const c_char, + value: *const c_char, + serial: u32, + ) { let collector = unsafe { &*(cookie as *const PropCollector) }; - let name = unsafe { CStr::from_ptr(name) }.to_string_lossy().into_owned(); - let value = unsafe { CStr::from_ptr(value) }.to_string_lossy().into_owned(); - collector.props.lock().unwrap().insert(name, PropValue { value, serial }); + let name = unsafe { CStr::from_ptr(name) } + .to_string_lossy() + .into_owned(); + let value = unsafe { CStr::from_ptr(value) } + .to_string_lossy() + .into_owned(); + collector + .props + .lock() + .unwrap() + .insert(name, PropValue { value, serial }); } unsafe { @@ -75,20 +87,32 @@ fn main() -> ExitCode { Some("--snapshot") => { let path = match args.get(1) { Some(p) => p, - None => { eprintln!("usage: propdetect-bionic --snapshot "); return ExitCode::FAILURE; } + None => { + eprintln!("usage: propdetect-bionic --snapshot "); + return ExitCode::FAILURE; + } }; cmd_snapshot(Path::new(path)) } Some("--diff") => { let (a, b) = match (args.get(1), args.get(2)) { (Some(a), Some(b)) => (a, b), - _ => { eprintln!("usage: propdetect-bionic --diff "); return ExitCode::FAILURE; } + _ => { + eprintln!("usage: propdetect-bionic --diff "); + return ExitCode::FAILURE; + } }; cmd_diff(Path::new(a), Path::new(b)) } - Some("-h" | "--help") => { print_usage(); ExitCode::SUCCESS } + Some("-h" | "--help") => { + print_usage(); + ExitCode::SUCCESS + } None => cmd_detect(), - Some(s) => { eprintln!("unknown arg: {s}"); ExitCode::FAILURE } + Some(s) => { + eprintln!("unknown arg: {s}"); + ExitCode::FAILURE + } } } @@ -121,21 +145,88 @@ fn cmd_detect() -> ExitCode { } let known_prefixes: std::collections::HashSet<&str> = [ - "ro", "persist", "sys", "init", "net", "gsm", "ril", "dalvik", "hw", - "wifi", "bluetooth", "dhcp", "service", "selinux", "debug", "log", - "ctl", "vendor", "config", "security", "cache", "dev", "vold", - "media", "audio", "camera", "pm", "am", "wm", "input", - "telephony", "phone", "ims", "runtime", "libc", "wrap", "drm", - "apex", "adb", "external_storage", - ].into_iter().collect(); + "ro", + "persist", + "sys", + "init", + "net", + "gsm", + "ril", + "dalvik", + "hw", + "wifi", + "bluetooth", + "dhcp", + "service", + "selinux", + "debug", + "log", + "ctl", + "vendor", + "config", + "security", + "cache", + "dev", + "vold", + "media", + "audio", + "camera", + "pm", + "am", + "wm", + "input", + "telephony", + "phone", + "ims", + "runtime", + "libc", + "wrap", + "drm", + "apex", + "adb", + "external_storage", + ] + .into_iter() + .collect(); let numeric_hints: &[&str] = &[ - "debuggable", "secure", "adb", "enabled", "connected", "locked", - "booted", "ready", "completed", "running", "active", "supported", - "present", "available", "configured", "mounted", "visible", - "count", "size", "max", "min", "timeout", "delay", "interval", - "level", "version", "index", "id", "port", "pid", "uid", - "width", "height", "density", "dpi", "fps", "encrypted", + "debuggable", + "secure", + "adb", + "enabled", + "connected", + "locked", + "booted", + "ready", + "completed", + "running", + "active", + "supported", + "present", + "available", + "configured", + "mounted", + "visible", + "count", + "size", + "max", + "min", + "timeout", + "delay", + "interval", + "level", + "version", + "index", + "id", + "port", + "pid", + "uid", + "width", + "height", + "density", + "dpi", + "fps", + "encrypted", ]; for (name, pv) in &props { @@ -157,7 +248,8 @@ fn cmd_detect() -> ExitCode { // serial analysis: counter is bits 1-15 (lower half, excluding dirty bit 0) let counter = (pv.serial >> 1) & 0x7FFF; - let is_init = name.starts_with("ro.") || name.starts_with("dalvik.") || name.starts_with("persist."); + let is_init = + name.starts_with("ro.") || name.starts_with("dalvik.") || name.starts_with("persist."); if !is_init && counter == 0 && pv.value == "0" { println!("[WARN] serial: [{name}]=0 serial=0 (possible hexpatch artifact)"); warnings += 1; @@ -178,10 +270,25 @@ fn cmd_diff(a: &Path, b: &Path) -> ExitCode { serde_json::from_str(&data).map_err(|e| format!("{}: {e}", p.display())) }; - let before = match load(a) { Ok(s) => s, Err(e) => { eprintln!("{e}"); return ExitCode::FAILURE; } }; - let after = match load(b) { Ok(s) => s, Err(e) => { eprintln!("{e}"); return ExitCode::FAILURE; } }; + let before = match load(a) { + Ok(s) => s, + Err(e) => { + eprintln!("{e}"); + return ExitCode::FAILURE; + } + }; + let after = match load(b) { + Ok(s) => s, + Err(e) => { + eprintln!("{e}"); + return ExitCode::FAILURE; + } + }; - println!("=== property diff ({} -> {} props) ===\n", before.total_count, after.total_count); + println!( + "=== property diff ({} -> {} props) ===\n", + before.total_count, after.total_count + ); let mut added = 0u32; let mut removed = 0u32; @@ -190,7 +297,10 @@ fn cmd_diff(a: &Path, b: &Path) -> ExitCode { for (name, old) in &before.props { match after.props.get(name) { - None => { println!("- [{name}] = {}", old.value); removed += 1; } + None => { + println!("- [{name}] = {}", old.value); + removed += 1; + } Some(new) if old.value != new.value => { println!("~ [{name}] {} -> {}", old.value, new.value); changed += 1; @@ -215,7 +325,7 @@ fn cmd_diff(a: &Path, b: &Path) -> ExitCode { fn print_usage() { eprintln!( -"propdetect-bionic - non-root property detector (bionic FFI) + "propdetect-bionic - non-root property detector (bionic FFI) Uses __system_property_foreach, same API as any Android app. diff --git a/crates/propdetect/Cargo.toml b/crates/propdetect/Cargo.toml index 493cd3f..edc123a 100644 --- a/crates/propdetect/Cargo.toml +++ b/crates/propdetect/Cargo.toml @@ -1,4 +1,5 @@ [package] +publish = false # internal workspace crate, not a crates.io artifact name = "propdetect" version = "0.1.0" edition = "2021" diff --git a/crates/propdetect/src/heuristics.rs b/crates/propdetect/src/heuristics.rs index 3bf236e..6311329 100644 --- a/crates/propdetect/src/heuristics.rs +++ b/crates/propdetect/src/heuristics.rs @@ -31,23 +31,97 @@ const COUNT_MIN: usize = 300; const COUNT_MAX: usize = 800; const KNOWN_PREFIXES: &[&str] = &[ - "ro", "persist", "sys", "init", "net", "gsm", "ril", "dalvik", "hw", - "wifi", "bluetooth", "dhcp", "service", "selinux", "debug", "log", - "ctl", "vendor", "config", "security", "cache", "dev", "vold", - "media", "audio", "camera", "pm", "am", "wm", "input", - "telephony", "phone", "ims", "setupwizard", "tombstoned", - "runtime", "heapprofd", "libc", "wrap", "sendbug", "drm", - "keyguard", "crypto", "apex", "adb", "external_storage", + "ro", + "persist", + "sys", + "init", + "net", + "gsm", + "ril", + "dalvik", + "hw", + "wifi", + "bluetooth", + "dhcp", + "service", + "selinux", + "debug", + "log", + "ctl", + "vendor", + "config", + "security", + "cache", + "dev", + "vold", + "media", + "audio", + "camera", + "pm", + "am", + "wm", + "input", + "telephony", + "phone", + "ims", + "setupwizard", + "tombstoned", + "runtime", + "heapprofd", + "libc", + "wrap", + "sendbug", + "drm", + "keyguard", + "crypto", + "apex", + "adb", + "external_storage", ]; const NUMERIC_NAME_HINTS: &[&str] = &[ - "debuggable", "secure", "adb", "enabled", "connected", "locked", - "booted", "ready", "completed", "running", "active", "supported", - "present", "available", "configured", "mounted", "visible", - "encrypted", "verified", "charged", "docked", "usb", "charging", - "count", "size", "max", "min", "timeout", "delay", "interval", - "level", "version", "index", "id", "port", "pid", "uid", - "width", "height", "density", "dpi", "fps", + "debuggable", + "secure", + "adb", + "enabled", + "connected", + "locked", + "booted", + "ready", + "completed", + "running", + "active", + "supported", + "present", + "available", + "configured", + "mounted", + "visible", + "encrypted", + "verified", + "charged", + "docked", + "usb", + "charging", + "count", + "size", + "max", + "min", + "timeout", + "delay", + "interval", + "level", + "version", + "index", + "id", + "port", + "pid", + "uid", + "width", + "height", + "density", + "dpi", + "fps", ]; const INIT_TIME_PREFIXES: &[&str] = &[ @@ -87,7 +161,10 @@ pub fn check_orphan_names(props: &[PropEntry]) -> Vec { if known.contains(prefix) { continue; } - if !prefix.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_') { + if !prefix + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_') + { findings.push(Finding { severity: Severity::Critical, check: "orphan_name", diff --git a/crates/propdetect/src/main.rs b/crates/propdetect/src/main.rs index 06df3ca..8dc7b07 100644 --- a/crates/propdetect/src/main.rs +++ b/crates/propdetect/src/main.rs @@ -86,17 +86,32 @@ fn run_detect(sys: &PropSystem) -> Result<(), String> { findings.extend(heuristics::check_name_coherence(sys.areas())); if findings.is_empty() { - println!("propdetect: no anomalies detected ({} properties scanned)", all_props.len()); + println!( + "propdetect: no anomalies detected ({} properties scanned)", + all_props.len() + ); return Ok(()); } - findings.sort_by(|a, b| b.severity.cmp(&a.severity)); + findings.sort_by_key(|f| std::cmp::Reverse(f.severity)); - println!("=== propdetect report ({} properties scanned) ===\n", all_props.len()); + println!( + "=== propdetect report ({} properties scanned) ===\n", + all_props.len() + ); - let crit = findings.iter().filter(|f| f.severity == heuristics::Severity::Critical).count(); - let warn = findings.iter().filter(|f| f.severity == heuristics::Severity::Warn).count(); - let info = findings.iter().filter(|f| f.severity == heuristics::Severity::Info).count(); + let crit = findings + .iter() + .filter(|f| f.severity == heuristics::Severity::Critical) + .count(); + let warn = findings + .iter() + .filter(|f| f.severity == heuristics::Severity::Warn) + .count(); + let info = findings + .iter() + .filter(|f| f.severity == heuristics::Severity::Info) + .count(); for f in &findings { println!("[{}] {}: {}", f.severity, f.check, f.detail); @@ -119,8 +134,10 @@ fn run_diff(before: &Path, after: &Path) -> Result<(), String> { return Ok(()); } - println!("=== property diff ({} -> {} properties) ===\n", - snap_before.total_count, snap_after.total_count); + println!( + "=== property diff ({} -> {} properties) ===\n", + snap_before.total_count, snap_after.total_count + ); for e in &entries { match &e.kind { @@ -140,12 +157,26 @@ fn run_diff(before: &Path, after: &Path) -> Result<(), String> { } println!(); - let added = entries.iter().filter(|e| matches!(e.kind, snapshot::DiffKind::Added { .. })).count(); - let removed = entries.iter().filter(|e| matches!(e.kind, snapshot::DiffKind::Removed { .. })).count(); - let changed = entries.iter().filter(|e| matches!(e.kind, snapshot::DiffKind::Changed { .. })).count(); - let serial = entries.iter().filter(|e| matches!(e.kind, snapshot::DiffKind::SerialChanged { .. })).count(); - - println!("summary: +{added} added, -{removed} removed, ~{changed} changed, s{serial} serial-only"); + let added = entries + .iter() + .filter(|e| matches!(e.kind, snapshot::DiffKind::Added { .. })) + .count(); + let removed = entries + .iter() + .filter(|e| matches!(e.kind, snapshot::DiffKind::Removed { .. })) + .count(); + let changed = entries + .iter() + .filter(|e| matches!(e.kind, snapshot::DiffKind::Changed { .. })) + .count(); + let serial = entries + .iter() + .filter(|e| matches!(e.kind, snapshot::DiffKind::SerialChanged { .. })) + .count(); + + println!( + "summary: +{added} added, -{removed} removed, ~{changed} changed, s{serial} serial-only" + ); Ok(()) } diff --git a/crates/propdetect/src/snapshot.rs b/crates/propdetect/src/snapshot.rs index 2f75d27..2b65766 100644 --- a/crates/propdetect/src/snapshot.rs +++ b/crates/propdetect/src/snapshot.rs @@ -48,14 +48,13 @@ pub fn capture(sys: &PropSystem) -> Snapshot { } pub fn save(snapshot: &Snapshot, path: &Path) -> Result<(), String> { - let json = serde_json::to_string_pretty(snapshot) - .map_err(|e| format!("serialize: {e}"))?; + let json = serde_json::to_string_pretty(snapshot).map_err(|e| format!("serialize: {e}"))?; std::fs::write(path, json).map_err(|e| format!("write {}: {e}", path.display())) } pub fn load(path: &Path) -> Result { - let data = std::fs::read_to_string(path) - .map_err(|e| format!("read {}: {e}", path.display()))?; + let data = + std::fs::read_to_string(path).map_err(|e| format!("read {}: {e}", path.display()))?; serde_json::from_str(&data).map_err(|e| format!("parse {}: {e}", path.display())) } diff --git a/crates/propdetect/tests/detect_manipulation.rs b/crates/propdetect/tests/detect_manipulation.rs index 0df33de..d978fcf 100644 --- a/crates/propdetect/tests/detect_manipulation.rs +++ b/crates/propdetect/tests/detect_manipulation.rs @@ -68,17 +68,26 @@ fn detect_hexpatch_delete() { let coherence_findings = propdetect_heuristics::check_name_coherence(&areas); println!("\n=== HEXPATCH DETECTION RESULTS ==="); - for f in name_findings.iter().chain(&value_findings).chain(&serial_findings).chain(&coherence_findings) { + for f in name_findings + .iter() + .chain(&value_findings) + .chain(&serial_findings) + .chain(&coherence_findings) + { println!("[{}] {}: {}", f.severity, f.check, f.detail); } // value_anomaly should catch the stealth "0" values - assert!(!value_findings.is_empty(), - "value_anomaly should flag hexpatch stealth values"); + assert!( + !value_findings.is_empty(), + "value_anomaly should flag hexpatch stealth values" + ); // name_coherence should show NO mismatches (hexpatch renames both trie + prop_info) - assert!(coherence_findings.is_empty(), - "hexpatch should maintain trie/prop_info name consistency"); + assert!( + coherence_findings.is_empty(), + "hexpatch should maintain trie/prop_info name consistency" + ); // the original names must be gone assert!(area.get("ro.lineage.version").is_none()); @@ -96,7 +105,11 @@ fn detect_plain_delete() { area.delete("ro.custom.romname").unwrap(); let after_count = count_props(&area); - assert_eq!(before_count - 2, after_count, "plain delete removes from enumeration"); + assert_eq!( + before_count - 2, + after_count, + "plain delete removes from enumeration" + ); let areas = areas_from(&path); @@ -109,7 +122,8 @@ fn detect_plain_delete() { } // trie_structure should detect orphan nodes left behind by delete - let orphan_hits: Vec<_> = trie_findings.iter() + let orphan_hits: Vec<_> = trie_findings + .iter() .filter(|f| f.detail.contains("orphan")) .collect(); println!("orphan findings: {}", orphan_hits.len()); @@ -161,13 +175,19 @@ fn snapshot_diff_catches_hexpatch() { } // hexpatch: original name disappears, new name appears - assert!(removed.contains(&"ro.lineage.version".to_string()), - "original prop name should show as removed in diff"); - assert_eq!(added.len(), 1, - "exactly one new prop name should appear (the renamed one)"); + assert!( + removed.contains(&"ro.lineage.version".to_string()), + "original prop name should show as removed in diff" + ); + assert_eq!( + added.len(), + 1, + "exactly one new prop name should appear (the renamed one)" + ); // the added prop should have value "0" - let added_entry = diffs.iter() + let added_entry = diffs + .iter() .find(|d| matches!(d.kind, propdetect_snapshot::DiffKind::Added { .. })) .unwrap(); if let propdetect_snapshot::DiffKind::Added { value } = &added_entry.kind { @@ -175,8 +195,10 @@ fn snapshot_diff_catches_hexpatch() { } // total count should be unchanged - assert_eq!(snap_before.total_count, snap_after.total_count, - "hexpatch must not change total property count"); + assert_eq!( + snap_before.total_count, snap_after.total_count, + "hexpatch must not change total property count" + ); } #[test] @@ -202,22 +224,29 @@ fn snapshot_diff_catches_plain_delete() { println!("\n=== SNAPSHOT DIFF (PLAIN DELETE) ==="); for d in &diffs { match &d.kind { - propdetect_snapshot::DiffKind::Removed { value } => println!("- [{}] = {value}", d.name), + propdetect_snapshot::DiffKind::Removed { value } => { + println!("- [{}] = {value}", d.name) + } propdetect_snapshot::DiffKind::Added { value } => println!("+ [{}] = {value}", d.name), _ => {} } } - let removed: Vec<_> = diffs.iter() + let removed: Vec<_> = diffs + .iter() .filter(|d| matches!(d.kind, propdetect_snapshot::DiffKind::Removed { .. })) .map(|d| d.name.clone()) .collect(); - let added: Vec<_> = diffs.iter() + let added: Vec<_> = diffs + .iter() .filter(|d| matches!(d.kind, propdetect_snapshot::DiffKind::Added { .. })) .collect(); assert!(removed.contains(&"ro.lineage.version".to_string())); - assert!(added.is_empty(), "plain delete should not add any properties"); + assert!( + added.is_empty(), + "plain delete should not add any properties" + ); assert_eq!(snap_after.total_count, snap_before.total_count - 1); } @@ -234,11 +263,15 @@ fn delete_compact_leaves_no_orphans() { let areas = areas_from(&path); let trie_findings = propdetect_heuristics::check_trie_structure(&areas); - let orphan_hits: Vec<_> = trie_findings.iter() + let orphan_hits: Vec<_> = trie_findings + .iter() .filter(|f| f.detail.contains("orphan")) .collect(); - assert!(orphan_hits.is_empty(), - "found {} orphan findings after delete+compact", orphan_hits.len()); + assert!( + orphan_hits.is_empty(), + "found {} orphan findings after delete+compact", + orphan_hits.len() + ); } fn count_props(area: &PropArea) -> usize { diff --git a/crates/resetprop-cli/Cargo.toml b/crates/resetprop-cli/Cargo.toml index a39ca82..52634bd 100644 --- a/crates/resetprop-cli/Cargo.toml +++ b/crates/resetprop-cli/Cargo.toml @@ -1,4 +1,5 @@ [package] +publish = false # internal workspace crate, not a crates.io artifact name = "resetprop-cli" version = "0.5.0" edition = "2021" diff --git a/crates/resetprop-cli/src/main.rs b/crates/resetprop-cli/src/main.rs index 1f9bf00..db3345b 100644 --- a/crates/resetprop-cli/src/main.rs +++ b/crates/resetprop-cli/src/main.rs @@ -3,6 +3,9 @@ use std::process::ExitCode; use resetprop::{Error, PersistStore, PropSystem}; +/// Default observe-init window when `--duration` is omitted. +const DEFAULT_OBSERVE_SECS: u64 = 5; + fn main() -> ExitCode { match run() { Ok(()) => ExitCode::SUCCESS, @@ -30,7 +33,8 @@ fn run() -> Result<(), String> { let mut wait_name: Option = None; let mut wait_value: Option = None; let mut timeout_secs: Option = None; - let mut seal: Option = None; + let mut seals: Vec<(String, Option)> = Vec::new(); + let mut check = false; let mut seal_arena: Option = None; let mut unseal: Option = None; let mut unseal_arena: Option = None; @@ -38,6 +42,8 @@ fn run() -> Result<(), String> { let mut if_diff = false; let mut if_match: Option = None; let mut delete_if_exist: Option = None; + let mut observe_init = false; + let mut duration_secs: Option = None; let mut positional = Vec::new(); let mut i = 0; @@ -63,8 +69,19 @@ fn run() -> Result<(), String> { "--stealth" | "-st" => stealth = true, "--seal" | "-sl" => { i += 1; - seal = Some(arg_val(&args, i, "--seal")?); + let name = arg_val(&args, i, "--seal")?; + // VALUE is the next arg unless it is another flag (e.g. --check, + // a dry-run that takes only NAME). Repeating --seal builds a + // multi-entry lock list under one process / one HookHandle. + let value = if i + 1 < args.len() && !args[i + 1].starts_with('-') { + i += 1; + Some(args[i].clone()) + } else { + None + }; + seals.push((name, value)); } + "--check" => check = true, "--seal-arena" | "-sla" => { i += 1; seal_arena = Some(arg_val(&args, i, "--seal-arena")?); @@ -107,7 +124,19 @@ fn run() -> Result<(), String> { "--timeout" => { i += 1; let s = arg_val(&args, i, "--timeout")?; - timeout_secs = Some(s.parse::().map_err(|_| "--timeout requires a number".to_string())?); + timeout_secs = Some( + s.parse::() + .map_err(|_| "--timeout requires a number".to_string())?, + ); + } + "--observe-init" => observe_init = true, + "--duration" => { + i += 1; + let s = arg_val(&args, i, "--duration")?; + duration_secs = Some( + s.parse::() + .map_err(|_| "--duration requires a number".to_string())?, + ); } "-h" | "--help" => { print_usage(); @@ -128,20 +157,19 @@ fn run() -> Result<(), String> { || compact || file.is_some() || wait_name.is_some() - || seal.is_some() + || !seals.is_empty() || seal_arena.is_some() || unseal.is_some() || unseal_arena.is_some() - || list_seals; + || list_seals + || observe_init; let any_mode_flag = init || persist || persist_read || stealth || quiet; if if_diff && if_match.is_some() { return Err("--if-diff and --if-match are mutually exclusive".to_string()); } if conditional_set && conditional_delete { - return Err( - "--if-diff/--if-match cannot be combined with --delete-if-exist".to_string(), - ); + return Err("--if-diff/--if-match cannot be combined with --delete-if-exist".to_string()); } if any_conditional && any_top_level_op { return Err( @@ -174,6 +202,20 @@ fn run() -> Result<(), String> { } .map_err(|e| format!("failed to open property system: {e}"))?; + if observe_init { + let duration = + std::time::Duration::from_secs(duration_secs.unwrap_or(DEFAULT_OBSERVE_SECS)); + let stdout = std::io::stdout(); + let mut out = stdout.lock(); + let count = sys + .observe_init(duration, &mut out) + .map_err(|e| format!("observe-init failed: {e}"))?; + if verbose { + eprintln!("observe-init: captured {count} kmsg line(s)"); + } + return Ok(()); + } + if let Some(name) = hexpatch { return bool_op(sys.hexpatch_delete(&name), &name, "hexpatch", verbose); } @@ -226,7 +268,7 @@ fn run() -> Result<(), String> { } let seal_flag_count = [ - seal.is_some(), + !seals.is_empty(), seal_arena.is_some(), unseal.is_some(), unseal_arena.is_some(), @@ -258,29 +300,65 @@ fn run() -> Result<(), String> { return bool_op(sys.unseal_arena(name), name, "unsealed(arena)", verbose); } - if let Some(ref name) = seal { - let value = positional - .first() - .ok_or_else(|| "--seal requires NAME VALUE (VALUE missing)".to_string())?; - return match sys.seal(name, value) { - Ok(record) => { - if verbose { - eprintln!( - "sealed: [{}] tier={:?} arena={}", - record.name, - record.tier, - record.arena_path.display() + if !seals.is_empty() { + if check { + // Dry-run: resolve the Tier B facts in init without poking it. No + // value is written and no trampoline is installed. Single-NAME only; + // the resolution is name-independent. INIT_PID is `1`. + if seals.len() != 1 { + return Err( + "--seal --check is a single-NAME dry-run; pass exactly one --seal NAME" + .to_string(), + ); + } + let (name, _) = &seals[0]; + return match resetprop::seal::hook::check_init_hook(1) { + Ok(r) => { + println!( + "check [{name}]: libc_base={:#x} libc_end={:#x} target_fn={:#x} scratch_pc={:#x}", + r.libc_base, r.libc_end, r.target_fn, r.scratch_pc ); + Ok(()) } - Ok(()) - } - Err(e @ (Error::HookInstallFailed(_) | Error::ElfParse(_) | Error::SymbolNotFound(_))) => { - Err(format!( - "Tier B hook install failed: {e}. Try --seal-arena for Tier A fallback." - )) + Err( + e @ (Error::HookInstallFailed(_) + | Error::ElfParse(_) + | Error::SymbolNotFound(_)), + ) => Err(format!("Tier B dry-run failed: {e}")), + Err(e) => Err(format!("check failed: {e}")), + }; + } + // Seal each prop under one process so the lazily-installed HookHandle is + // shared: the first append installs the trampoline, the rest extend the + // same lock list. The first hard install failure aborts the batch. + for (name, value) in &seals { + let value = value + .as_deref() + .ok_or_else(|| format!("--seal requires NAME VALUE (VALUE missing for {name})"))?; + match sys.seal(name, value) { + Ok(record) => { + if verbose { + eprintln!( + "sealed: [{}] tier={:?} arena={}", + record.name, + record.tier, + record.arena_path.display() + ); + } + } + Err( + e @ (Error::HookInstallFailed(_) + | Error::ElfParse(_) + | Error::SymbolNotFound(_)), + ) => { + return Err(format!( + "Tier B hook install failed for {name}: {e}. Try --seal-arena for Tier A fallback." + )) + } + Err(e) => return Err(format!("seal failed for {name}: {e}")), } - Err(e) => Err(format!("seal failed: {e}")), - }; + } + return Ok(()); } if let Some(ref name) = seal_arena { @@ -417,8 +495,7 @@ fn persist_read_op(positional: &[String]) -> Result<(), String> { } fn load_file(sys: &PropSystem, path: &str, init: bool, verbose: bool) -> Result<(), String> { - let content = - std::fs::read_to_string(path).map_err(|e| format!("cannot read {path}: {e}"))?; + let content = std::fs::read_to_string(path).map_err(|e| format!("cannot read {path}: {e}"))?; let mut count = 0u32; for line in content.lines() { @@ -439,7 +516,10 @@ fn load_file(sys: &PropSystem, path: &str, init: bool, verbose: bool) -> Result< .map_err(|e| format!("failed to set {name}: {e}"))?; count += 1; if verbose { - eprintln!("set{}: [{name}]=[{value}]", if init { "(init)" } else { "" }); + eprintln!( + "set{}: [{name}]=[{value}]", + if init { "(init)" } else { "" } + ); } } @@ -468,6 +548,8 @@ Usage: resetprop --stealth|-st NAME VALUE Set with zeroed serial, no wake signals resetprop --stealth|-st -p NAME VALUE Set stealth + persist to disk resetprop --seal|-sl NAME VALUE Stealth write + Tier B per-prop init hook (default seal) + resetprop --seal A v1 --seal B v2 Seal several props in one run (one multi-entry lock list) + resetprop --seal|-sl NAME --check Dry-run: resolve Tier B in init, write nothing resetprop --seal-arena|-sla NAME VALUE Stealth write + Tier A arena privatize (fallback) resetprop --unseal NAME Remove NAME from the Tier B lock list resetprop --unseal-arena NAME Revert Tier A arena privatization for NAME @@ -479,6 +561,7 @@ Usage: resetprop -f FILE Load properties from file (name=value) resetprop --wait NAME [VALUE] Wait for property to exist or equal VALUE resetprop --timeout SECS Timeout for --wait (default: forever) + resetprop --observe-init [--duration SECS] Trace init's /dev/kmsg writes for a window (aarch64) resetprop --dir PATH Use custom property directory Options: @@ -490,11 +573,14 @@ Options: --if-match NEEDLE Conditional set: write NAME=VALUE only when current value equals NEEDLE --delete-if-exist NAME Conditional delete: no-op when NAME is absent --stealth, -st Stealth write: bionic compose, no futex wake, no global notify - --seal, -sl Tier B seal: stealth write + per-prop hook on __system_property_update in init + --seal, -sl Tier B seal: stealth write + per-prop hook on __system_property_update in init (repeatable: one run, one multi-entry lock list) + --check With --seal: dry-run the Tier B install (resolve only, no ptrace write) --seal-arena, -sla Tier A seal: stealth write + remap init's arena as MAP_PRIVATE|MAP_FIXED --unseal NAME Remove NAME from the in-init Tier B lock list --unseal-arena NAME Revert Tier A privatization for the arena holding NAME --seals List currently active seals for this session + --observe-init Trace init (PID 1) writes to /dev/kmsg and print them (aarch64 only) + --duration SECS With --observe-init: observe window in seconds (default: 5) --compact Reclaim arena space left by deleted properties -v Verbose output -h, --help Show this help" diff --git a/crates/resetprop/src/area.rs b/crates/resetprop/src/area.rs index 7d2cc77..e57a00a 100644 --- a/crates/resetprop/src/area.rs +++ b/crates/resetprop/src/area.rs @@ -38,10 +38,18 @@ impl PropArea { use std::ffi::CString; use std::os::unix::ffi::OsStrExt; - let c_path = CString::new(path.as_os_str().as_bytes()) - .map_err(|_| Error::Io(std::io::Error::new(std::io::ErrorKind::InvalidInput, "invalid path")))?; - - let flags = if writable { libc::O_RDWR } else { libc::O_RDONLY }; + let c_path = CString::new(path.as_os_str().as_bytes()).map_err(|_| { + Error::Io(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "invalid path", + )) + })?; + + let flags = if writable { + libc::O_RDWR + } else { + libc::O_RDONLY + }; let fd = unsafe { libc::open(c_path.as_ptr(), flags | libc::O_NOFOLLOW) }; if fd < 0 { return Err(std::io::Error::last_os_error().into()); @@ -57,7 +65,9 @@ impl PropArea { let file_size = stat.st_size as usize; if file_size < HEADER_SIZE { unsafe { libc::close(fd) }; - return Err(Error::AreaCorrupt(format!("file too small: {file_size} bytes"))); + return Err(Error::AreaCorrupt(format!( + "file too small: {file_size} bytes" + ))); } let prot = if writable { @@ -67,7 +77,14 @@ impl PropArea { }; let ptr = unsafe { - libc::mmap(std::ptr::null_mut(), file_size, prot, libc::MAP_SHARED, fd, 0) + libc::mmap( + std::ptr::null_mut(), + file_size, + prot, + libc::MAP_SHARED, + fd, + 0, + ) }; unsafe { libc::close(fd) }; @@ -143,7 +160,10 @@ impl PropArea { pub(crate) fn atomic_u32(&self, offset: usize) -> &AtomicU32 { assert!(offset + 4 <= self.len); - assert!(offset.is_multiple_of(4), "AtomicU32 requires 4-byte alignment, got offset {offset}"); + assert!( + offset.is_multiple_of(4), + "AtomicU32 requires 4-byte alignment, got offset {offset}" + ); unsafe { AtomicU32::from_ptr(self.base.add(offset) as *mut u32) } } @@ -217,7 +237,12 @@ impl PropArea { return Err(Error::AreaFull); } if bu - .compare_exchange_weak(current, current + aligned as u32, Ordering::AcqRel, Ordering::Acquire) + .compare_exchange_weak( + current, + current + aligned as u32, + Ordering::AcqRel, + Ordering::Acquire, + ) .is_ok() { return Ok(HEADER_SIZE + current as usize); @@ -231,8 +256,12 @@ impl PropArea { use std::ffi::CString; use std::os::unix::ffi::OsStrExt; - let c_path = CString::new(path.as_os_str().as_bytes()) - .map_err(|_| Error::Io(std::io::Error::new(std::io::ErrorKind::InvalidInput, "invalid path")))?; + let c_path = CString::new(path.as_os_str().as_bytes()).map_err(|_| { + Error::Io(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "invalid path", + )) + })?; let fd = unsafe { libc::open(c_path.as_ptr(), libc::O_RDONLY | libc::O_NOFOLLOW) }; if fd < 0 { diff --git a/crates/resetprop/src/bionic.rs b/crates/resetprop/src/bionic.rs index 066caec..1b90b59 100644 --- a/crates/resetprop/src/bionic.rs +++ b/crates/resetprop/src/bionic.rs @@ -14,8 +14,7 @@ mod inner { Option, *mut c_void, ) -> c_int; - type WaitFn = - unsafe extern "C" fn(*const c_void, u32, *mut u32, *const libc::timespec) -> bool; + type WaitFn = unsafe extern "C" fn(*const c_void, u32, *mut u32, *const libc::timespec) -> bool; struct BionicFns { find: FindFn, @@ -140,17 +139,17 @@ mod inner { ctx.results } - pub(crate) fn wait_prop( - name: &str, - old_serial: u32, - timeout: Option, - ) -> Option { + pub(crate) fn wait_prop(name: &str, old_serial: u32, timeout: Option) -> Option { let fns = fns()?; let wait = fns.wait?; let pi = CString::new(name).ok().and_then(|cname| unsafe { let p = (fns.find)(cname.as_ptr()); - if p.is_null() { None } else { Some(p) } + if p.is_null() { + None + } else { + Some(p) + } }); // null pi = wait on global serial (prop doesn't exist yet) @@ -168,7 +167,11 @@ mod inner { let mut new_serial: u32 = 0; let ok = unsafe { (wait)(pi_ptr, old_serial, &mut new_serial, ts_ptr) }; - if ok { Some(new_serial) } else { None } + if ok { + Some(new_serial) + } else { + None + } } } diff --git a/crates/resetprop/src/compact.rs b/crates/resetprop/src/compact.rs index ae87f0a..dda52b1 100644 --- a/crates/resetprop/src/compact.rs +++ b/crates/resetprop/src/compact.rs @@ -28,7 +28,13 @@ pub(crate) fn compact(area: &PropArea) -> Result { }); } - collect(area, area.data_offset(), &mut allocs, &mut trie_offsets, &mut long_props)?; + collect( + area, + area.data_offset(), + &mut allocs, + &mut trie_offsets, + &mut long_props, + )?; allocs.sort_by_key(|a| a.offset); allocs.dedup_by_key(|a| a.offset); @@ -64,7 +70,9 @@ pub(crate) fn compact(area: &PropArea) -> Result { let old_end = area.data_offset() + area.bytes_used().load(AO::Acquire) as usize; if cursor < old_end { - unsafe { std::ptr::write_bytes(area.base().add(cursor), 0, old_end - cursor); } + unsafe { + std::ptr::write_bytes(area.base().add(cursor), 0, old_end - cursor); + } } let new_used = (cursor - area.data_offset()) as u32; @@ -88,7 +96,10 @@ fn collect( (TRIE_HEADER_SIZE + namelen + 1 + 3) & !3 }; - allocs.push(LiveAlloc { offset: node_abs, size: node_size }); + allocs.push(LiveAlloc { + offset: node_abs, + size: node_size, + }); trie_offsets.push(node_abs); let prop_rel = node.prop_offset().load(AO::Acquire); @@ -96,7 +107,10 @@ fn collect( let pi_abs = area.data_offset() + prop_rel as usize; let name_len = strlen_at(area, pi_abs + PROP_INFO_FIXED); let pi_total = (PROP_INFO_FIXED + name_len + 1 + 3) & !3; - allocs.push(LiveAlloc { offset: pi_abs, size: pi_total }); + allocs.push(LiveAlloc { + offset: pi_abs, + size: pi_total, + }); let serial = area.read_u32(pi_abs); if serial & LONG_FLAG != 0 { @@ -104,24 +118,45 @@ fn collect( let long_abs = pi_abs + rel; let val_len = strlen_at(area, long_abs); let aligned = (val_len + 1 + 3) & !3; - allocs.push(LiveAlloc { offset: long_abs, size: aligned }); + allocs.push(LiveAlloc { + offset: long_abs, + size: aligned, + }); long_props.push((pi_abs, long_abs)); } } let left_rel = node.left().load(AO::Acquire); if left_rel != 0 { - collect(area, area.data_offset() + left_rel as usize, allocs, trie_offsets, long_props)?; + collect( + area, + area.data_offset() + left_rel as usize, + allocs, + trie_offsets, + long_props, + )?; } let children_rel = node.children().load(AO::Acquire); if children_rel != 0 { - collect(area, area.data_offset() + children_rel as usize, allocs, trie_offsets, long_props)?; + collect( + area, + area.data_offset() + children_rel as usize, + allocs, + trie_offsets, + long_props, + )?; } let right_rel = node.right().load(AO::Acquire); if right_rel != 0 { - collect(area, area.data_offset() + right_rel as usize, allocs, trie_offsets, long_props)?; + collect( + area, + area.data_offset() + right_rel as usize, + allocs, + trie_offsets, + long_props, + )?; } Ok(()) @@ -163,7 +198,12 @@ fn patch_trie_pointers(area: &PropArea, trie_offsets: &[usize], remap: &HashMap< Err(_) => continue, }; - for field in [node.prop_offset(), node.left(), node.right(), node.children()] { + for field in [ + node.prop_offset(), + node.left(), + node.right(), + node.children(), + ] { let old_rel = field.load(AO::Acquire); if old_rel == 0 { continue; @@ -176,7 +216,11 @@ fn patch_trie_pointers(area: &PropArea, trie_offsets: &[usize], remap: &HashMap< } } -fn patch_long_values(area: &PropArea, long_props: &[(usize, usize)], remap: &HashMap) { +fn patch_long_values( + area: &PropArea, + long_props: &[(usize, usize)], + remap: &HashMap, +) { for &(old_pi, old_lv) in long_props { let new_pi = match remap.get(&old_pi) { Some(&v) => v, @@ -187,7 +231,8 @@ fn patch_long_values(area: &PropArea, long_props: &[(usize, usize)], remap: &Has None => continue, }; let new_rel = (new_lv - new_pi) as u32; - area.atomic_u32(new_pi + 4 + LONG_PROP_ERROR_SIZE).store(new_rel, AO::Release); + area.atomic_u32(new_pi + 4 + LONG_PROP_ERROR_SIZE) + .store(new_rel, AO::Release); } } @@ -201,7 +246,8 @@ fn has_dirty_backup(area: &PropArea) -> bool { return false; } if children == 0 { - return area.bytes_used().load(AO::Acquire) as usize == TRIE_HEADER_SIZE + DIRTY_BACKUP_SIZE; + return area.bytes_used().load(AO::Acquire) as usize + == TRIE_HEADER_SIZE + DIRTY_BACKUP_SIZE; } true } diff --git a/crates/resetprop/src/context.rs b/crates/resetprop/src/context.rs index 455b32c..b4323d1 100644 --- a/crates/resetprop/src/context.rs +++ b/crates/resetprop/src/context.rs @@ -140,7 +140,12 @@ fn child_name(data: &[u8], child_node_off: usize) -> Option<&str> { entry_name(data, &entry) } -fn resolve_binary<'a>(data: &'a [u8], contexts_off: usize, root_off: usize, name: &str) -> Option<&'a str> { +fn resolve_binary<'a>( + data: &'a [u8], + contexts_off: usize, + root_off: usize, + name: &str, +) -> Option<&'a str> { let mut best: Option = None; let mut node_off = root_off; let mut remaining = name; @@ -499,22 +504,22 @@ mod tests { // "build" node (leaf with exact_match) let build_node_off = b.pos(); b.write_u32(entry_build_off); // property_entry - b.write_u32(0); // num_child_nodes - b.write_u32(0); // child_nodes - b.write_u32(0); // num_prefixes - b.write_u32(0); // prefix_entries - b.write_u32(1); // num_exact_matches + b.write_u32(0); // num_child_nodes + b.write_u32(0); // child_nodes + b.write_u32(0); // num_prefixes + b.write_u32(0); // prefix_entries + b.write_u32(1); // num_exact_matches b.write_u32(exact_array_off); // exact_match_entries // "ro" node let ro_node_off = b.pos(); - b.write_u32(entry_ro_off); // property_entry - b.write_u32(1); // num_child_nodes - b.write_u32(ro_children_array_off); // child_nodes - b.write_u32(0); // num_prefixes - b.write_u32(0); // prefix_entries - b.write_u32(0); // num_exact_matches - b.write_u32(0); // exact_match_entries + b.write_u32(entry_ro_off); // property_entry + b.write_u32(1); // num_child_nodes + b.write_u32(ro_children_array_off); // child_nodes + b.write_u32(0); // num_prefixes + b.write_u32(0); // prefix_entries + b.write_u32(0); // num_exact_matches + b.write_u32(0); // exact_match_entries // "persist" node let persist_node_off = b.pos(); @@ -528,13 +533,13 @@ mod tests { // root node let root_off = b.pos(); - b.write_u32(0); // property_entry (none) - b.write_u32(2); // num_child_nodes - b.write_u32(root_children_array_off); // child_nodes - b.write_u32(0); // num_prefixes - b.write_u32(0); // prefix_entries - b.write_u32(0); // num_exact_matches - b.write_u32(0); // exact_match_entries + b.write_u32(0); // property_entry (none) + b.write_u32(2); // num_child_nodes + b.write_u32(root_children_array_off); // child_nodes + b.write_u32(0); // num_prefixes + b.write_u32(0); // prefix_entries + b.write_u32(0); // num_exact_matches + b.write_u32(0); // exact_match_entries // --- Patch child_nodes arrays with actual offsets --- // Root children: sorted by name. "persist" < "ro" alphabetically @@ -597,7 +602,12 @@ mod tests { fn binary_resolve_persist() { let data = build_test_blob(); let header = parse_header(&data).unwrap(); - let result = resolve_binary(&data, header.contexts_off, header.root_off, "persist.sys.timezone"); + let result = resolve_binary( + &data, + header.contexts_off, + header.root_off, + "persist.sys.timezone", + ); assert_eq!(result, Some("u:object_r:persist_prop:s0")); } @@ -631,7 +641,10 @@ mod tests { // Already sorted longest first assert_eq!(resolve_text(&entries, "ro.build.type"), Some("build_ctx")); assert_eq!(resolve_text(&entries, "ro.debuggable"), Some("ro_ctx")); - assert_eq!(resolve_text(&entries, "persist.sys.tz"), Some("persist_ctx")); + assert_eq!( + resolve_text(&entries, "persist.sys.tz"), + Some("persist_ctx") + ); assert_eq!(resolve_text(&entries, "dalvik.vm.heapsize"), None); } diff --git a/crates/resetprop/src/dict.rs b/crates/resetprop/src/dict.rs index 4dc8925..3761bb1 100644 --- a/crates/resetprop/src/dict.rs +++ b/crates/resetprop/src/dict.rs @@ -1,17 +1,58 @@ use std::collections::HashSet; const WORDS_1: &[&[u8]] = &[b"v", b"p", b"a", b"d", b"f", b"m", b"r", b"s", b"t", b"n"]; -const WORDS_2: &[&[u8]] = &[b"bt", b"hw", b"fm", b"ip", b"nv", b"tp", b"sf", b"wl", b"qc", b"pm"]; -const WORDS_3: &[&[u8]] = &[b"sys", b"usb", b"nfc", b"gpu", b"dpi", b"fps", b"vhw", b"cfg", b"dbg"]; -const WORDS_4: &[&[u8]] = &[b"wifi", b"core", b"boot", b"init", b"hdmi", b"dram", b"emmc", b"uart", b"mipi"]; -const WORDS_5: &[&[u8]] = &[b"audio", b"codec", b"power", b"touch", b"panel", b"radio", b"hapti", b"vibra", b"clock"]; -const WORDS_6: &[&[u8]] = &[b"sensor", b"camera", b"modem_", b"memory", b"dalvik", b"vendor", b"kernel", b"source"]; -const WORDS_7: &[&[u8]] = &[b"thermal", b"display", b"network", b"storage", b"battery", b"surface", b"charger", b"encoder"]; -const WORDS_8: &[&[u8]] = &[b"graphics", b"charging", b"firmware", b"recorder", b"platform", b"hardware"]; -const WORDS_9: &[&[u8]] = &[b"telephony", b"accessory", b"proximity", b"touchpads", b"mediacodec"]; -const WORDS_10: &[&[u8]] = &[b"controller", b"configured", b"peripheral", b"background", b"thumbprint"]; -const WORDS_11: &[&[u8]] = &[b"performance", b"temperature", b"sensorhub_t", b"bootanimati"]; -const WORDS_12: &[&[u8]] = &[b"provisioning", b"acceleration", b"notification", b"intermediary"]; +const WORDS_2: &[&[u8]] = &[ + b"bt", b"hw", b"fm", b"ip", b"nv", b"tp", b"sf", b"wl", b"qc", b"pm", +]; +const WORDS_3: &[&[u8]] = &[ + b"sys", b"usb", b"nfc", b"gpu", b"dpi", b"fps", b"vhw", b"cfg", b"dbg", +]; +const WORDS_4: &[&[u8]] = &[ + b"wifi", b"core", b"boot", b"init", b"hdmi", b"dram", b"emmc", b"uart", b"mipi", +]; +const WORDS_5: &[&[u8]] = &[ + b"audio", b"codec", b"power", b"touch", b"panel", b"radio", b"hapti", b"vibra", b"clock", +]; +const WORDS_6: &[&[u8]] = &[ + b"sensor", b"camera", b"modem_", b"memory", b"dalvik", b"vendor", b"kernel", b"source", +]; +const WORDS_7: &[&[u8]] = &[ + b"thermal", b"display", b"network", b"storage", b"battery", b"surface", b"charger", b"encoder", +]; +const WORDS_8: &[&[u8]] = &[ + b"graphics", + b"charging", + b"firmware", + b"recorder", + b"platform", + b"hardware", +]; +const WORDS_9: &[&[u8]] = &[ + b"telephony", + b"accessory", + b"proximity", + b"touchpads", + b"mediacodec", +]; +const WORDS_10: &[&[u8]] = &[ + b"controller", + b"configured", + b"peripheral", + b"background", + b"thumbprint", +]; +const WORDS_11: &[&[u8]] = &[ + b"performance", + b"temperature", + b"sensorhub_t", + b"bootanimati", +]; +const WORDS_12: &[&[u8]] = &[ + b"provisioning", + b"acceleration", + b"notification", + b"intermediary", +]; const WORDS_13: &[&[u8]] = &[b"configuration", b"communication", b"surfaceflinge"]; const WORDS_14: &[&[u8]] = &[b"servicemanager", b"authentication", b"implementation"]; const WORDS_15: &[&[u8]] = &[b"hwservicemanag", b"troubleshooting"]; diff --git a/crates/resetprop/src/error.rs b/crates/resetprop/src/error.rs index 42ce3d6..24659e5 100644 --- a/crates/resetprop/src/error.rs +++ b/crates/resetprop/src/error.rs @@ -9,14 +9,18 @@ pub enum Error { PermissionDenied(std::io::Error), AreaFull, Io(std::io::Error), - ValueTooLong { len: usize }, + ValueTooLong { + len: usize, + }, InvalidKey, PersistCorrupt(String), PtraceAttach(std::io::Error), PtraceOp(std::io::Error), PtraceUnexpectedStatus(i32), PtraceScope, - PtraceTracerBusy { tracer_pid: libc::pid_t }, + PtraceTracerBusy { + tracer_pid: libc::pid_t, + }, ArenaAlreadySealed(PathBuf), ArenaNotMapped(PathBuf), ElfParse(String), diff --git a/crates/resetprop/src/harvest.rs b/crates/resetprop/src/harvest.rs index 75c45cc..a271ab8 100644 --- a/crates/resetprop/src/harvest.rs +++ b/crates/resetprop/src/harvest.rs @@ -6,9 +6,8 @@ use crate::area::PropArea; use crate::dict; const STEMS: &[&[u8]] = &[ - b"hw", b"sv", b"fm", b"nv", b"tp", b"bt", b"qc", b"sf", - b"cfg", b"drv", b"hal", b"dev", b"arm", b"log", b"dsp", - b"svc", b"v8a", b"gpu", b"adb", b"vhw", b"mmc", b"usb", + b"hw", b"sv", b"fm", b"nv", b"tp", b"bt", b"qc", b"sf", b"cfg", b"drv", b"hal", b"dev", b"arm", + b"log", b"dsp", b"svc", b"v8a", b"gpu", b"adb", b"vhw", b"mmc", b"usb", ]; fn rand_index(n: usize) -> usize { @@ -50,11 +49,7 @@ impl SegmentPool { } } -pub(crate) fn replacement( - original: &[u8], - used: &HashSet>, - pool: &SegmentPool, -) -> Vec { +pub(crate) fn replacement(original: &[u8], used: &HashSet>, pool: &SegmentPool) -> Vec { let len = original.len(); if let Some(w) = pool.pick(len, used) { @@ -134,7 +129,9 @@ pub(crate) fn generate_name(area: &PropArea, exclude: &HashSet) -> Strin let fallbacks: &[&str] = &["sys.", "vendor."]; let prefixes: Vec<&str> = match best { - Some(p) => std::iter::once(p).chain(fallbacks.iter().copied()).collect(), + Some(p) => std::iter::once(p) + .chain(fallbacks.iter().copied()) + .collect(), None => fallbacks.to_vec(), }; diff --git a/crates/resetprop/src/info.rs b/crates/resetprop/src/info.rs index 6f1838f..0b01f31 100644 --- a/crates/resetprop/src/info.rs +++ b/crates/resetprop/src/info.rs @@ -230,7 +230,8 @@ impl<'a> PropInfo<'a> { *ptr.add(value.len()) = 0; } - let new_serial = ((serial + 2) & 0x00FFFFFF) | LONG_FLAG | ((value.len() as u32 & 0xFF) << 24); + let new_serial = + ((serial + 2) & 0x00FFFFFF) | LONG_FLAG | ((value.len() as u32 & 0xFF) << 24); std::sync::atomic::fence(Ordering::Release); sa.store(new_serial, Ordering::Release); self.area.futex_wake(self.offset); diff --git a/crates/resetprop/src/inspect.rs b/crates/resetprop/src/inspect.rs index 83251da..5bbf608 100644 --- a/crates/resetprop/src/inspect.rs +++ b/crates/resetprop/src/inspect.rs @@ -35,7 +35,11 @@ impl PropArea { } let value = pi.read_value(); let serial = pi.serial_raw(); - entries.push(PropEntry { name, value, serial }); + entries.push(PropEntry { + name, + value, + serial, + }); } }); entries @@ -46,7 +50,12 @@ impl PropArea { let root = TrieNode::root(self); let children = root.children().load(Ordering::Acquire); if children != 0 { - walk_bst(self, self.data_offset() + children as usize, "", &mut entries); + walk_bst( + self, + self.data_offset() + children as usize, + "", + &mut entries, + ); } entries } diff --git a/crates/resetprop/src/lib.rs b/crates/resetprop/src/lib.rs index 1f446e5..5ba8813 100644 --- a/crates/resetprop/src/lib.rs +++ b/crates/resetprop/src/lib.rs @@ -18,25 +18,25 @@ //! Use [`PropArea`] for single-file, low-level access. //! Use [`PersistStore`] for the on-disk persistent property store. -mod error; +mod appcompat; mod area; -mod trie; -mod info; -mod dict; -mod harvest; +mod bionic; mod compact; mod context; -mod bionic; -mod persist; -mod appcompat; -mod wait; -pub mod seal; +mod dict; +mod error; +mod harvest; +mod info; pub mod inspect; #[cfg(test)] mod mock; +mod persist; +pub mod seal; +mod trie; +mod wait; -pub use error::{Error, Result}; pub use area::PropArea; +pub use error::{Error, Result}; pub use persist::{PersistStore, Record}; pub use seal::{SealRecord, SealTier}; @@ -44,7 +44,7 @@ use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::sync::atomic::Ordering; use std::sync::{Mutex, OnceLock}; -use std::time::SystemTime; +use std::time::{Duration, SystemTime}; const PROP_DIR: &str = "/dev/__properties__"; @@ -196,7 +196,8 @@ impl PropArea { None => { let pi_offset = info::alloc_prop_info(self, name, value)?; let relative = (pi_offset - self.data_offset()) as u32; - self.atomic_u32(node_off + 4).store(relative, Ordering::Release); + self.atomic_u32(node_off + 4) + .store(relative, Ordering::Release); return Ok(()); } } @@ -304,7 +305,8 @@ impl PropArea { return false; } - if let Ok(child) = trie::TrieNode::from_offset(self, self.data_offset() + children as usize) { + if let Ok(child) = trie::TrieNode::from_offset(self, self.data_offset() + children as usize) + { let left = child.left().load(Ordering::Relaxed); let right = child.right().load(Ordering::Relaxed); left != 0 || right != 0 @@ -564,13 +566,17 @@ impl PropSystem { pub fn set(&self, name: &str, value: &str) -> Result<()> { if let Some((idx, area)) = self.find_area(name) { area.set(name, value)?; - self.appcompat_write(idx, |m| { let _ = m.set(name, value); }); + self.appcompat_write(idx, |m| { + let _ = m.set(name, value); + }); self.notify(); return Ok(()); } if let Some((idx, area)) = self.find_writable(name) { area.set(name, value)?; - self.appcompat_write(idx, |m| { let _ = m.set(name, value); }); + self.appcompat_write(idx, |m| { + let _ = m.set(name, value); + }); self.notify(); return Ok(()); } @@ -583,13 +589,17 @@ impl PropSystem { pub fn set_init(&self, name: &str, value: &str) -> Result<()> { if let Some((idx, area)) = self.find_area(name) { area.set_init(name, value)?; - self.appcompat_write(idx, |m| { let _ = m.set_init(name, value); }); + self.appcompat_write(idx, |m| { + let _ = m.set_init(name, value); + }); self.notify(); return Ok(()); } if let Some((idx, area)) = self.find_writable(name) { area.set_init(name, value)?; - self.appcompat_write(idx, |m| { let _ = m.set_init(name, value); }); + self.appcompat_write(idx, |m| { + let _ = m.set_init(name, value); + }); self.notify(); return Ok(()); } @@ -602,12 +612,16 @@ impl PropSystem { pub fn set_stealth(&self, name: &str, value: &str) -> Result<()> { if let Some((idx, area)) = self.find_area(name) { area.set_stealth(name, value)?; - self.appcompat_write(idx, |m| { let _ = m.set_stealth(name, value); }); + self.appcompat_write(idx, |m| { + let _ = m.set_stealth(name, value); + }); return Ok(()); } if let Some((idx, area)) = self.find_writable(name) { area.set_stealth(name, value)?; - self.appcompat_write(idx, |m| { let _ = m.set_stealth(name, value); }); + self.appcompat_write(idx, |m| { + let _ = m.set_stealth(name, value); + }); return Ok(()); } Err(Error::PermissionDenied(std::io::Error::new( @@ -619,12 +633,16 @@ impl PropSystem { pub fn set_quiet(&self, name: &str, value: &str) -> Result<()> { if let Some((idx, area)) = self.find_area(name) { area.set_quiet(name, value)?; - self.appcompat_write(idx, |m| { let _ = m.set_quiet(name, value); }); + self.appcompat_write(idx, |m| { + let _ = m.set_quiet(name, value); + }); return Ok(()); } if let Some((idx, area)) = self.find_writable(name) { area.set_quiet(name, value)?; - self.appcompat_write(idx, |m| { let _ = m.set_quiet(name, value); }); + self.appcompat_write(idx, |m| { + let _ = m.set_quiet(name, value); + }); return Ok(()); } Err(Error::PermissionDenied(std::io::Error::new( @@ -637,7 +655,9 @@ impl PropSystem { if let Some((idx, area)) = self.find_area(name) { let deleted = area.delete(name)?; if deleted { - self.appcompat_write(idx, |m| { let _ = m.delete(name); }); + self.appcompat_write(idx, |m| { + let _ = m.delete(name); + }); self.notify(); } return Ok(deleted); @@ -728,11 +748,7 @@ impl PropSystem { self.seal_write(name, value)?; let mirror_path = self.derive_mirror_path(&primary_path, filename); - seal::arena::seal_arena_with_mirror( - seal::INIT_PID, - &primary_path, - mirror_path.as_deref(), - )?; + seal::arena::seal_arena_with_mirror(seal::INIT_PID, &primary_path, mirror_path.as_deref())?; let record = SealRecord { name: name.to_string(), @@ -779,13 +795,14 @@ impl PropSystem { /// `set_init` otherwise). Runs BEFORE any ptrace work so a /// misconfigured context fails fast. /// 3. Lazily initialise the shared `HookHandle` under the per-process - /// `OnceLock>>`. `install_init_hook` walks - /// init's `/proc/1/maps` + libc ELF, then `install_trampoline` - /// patches the 16-byte prologue. A failure between the two leaves - /// the slot `None` so the next `seal()` can retry cleanly. - /// 4. `seal::hook::seal_prop(handle, name)` appends the name under the - /// hook's atomic-append invariant (entry bytes → trailing sentinel - /// → length counter). + /// `OnceLock>>`. The first seal runs + /// `install_and_seal_first`, which walks init's `/proc/1/maps` + libc + /// ELF, patches the 16-byte prologue, and appends the name — all under + /// one thread-group stop (audit M5). A failure leaves the slot `None` + /// so the next `seal()` can retry cleanly. + /// 4. Subsequent seals call `seal::hook::seal_prop(handle, name)` to append + /// the name under the hook's atomic-append invariant (entry bytes → + /// trailing sentinel → length counter). /// 5. Record the operation in the process-wide `seals_registry()` as /// `SealTier::Prop`; existing entries with the same `(name, tier)` /// have their timestamp refreshed rather than duplicated. @@ -812,14 +829,24 @@ impl PropSystem { poisoned.into_inner() }); if guard.is_none() { - let mut handle = seal::hook::install_init_hook(seal::INIT_PID)?; - seal::hook::install_trampoline(&mut handle)?; - *guard = Some(handle); + // First seal: install + trampoline + first append under one + // thread-group stop on init (audit M5). The handle is recorded + // whenever the trampoline went live — even if the append failed — + // so a retry appends against the live hook instead of re-installing + // over already-trampolined init. + match seal::hook::install_and_seal_first(seal::INIT_PID, name)? { + seal::hook::FirstSeal::Sealed(handle) => *guard = Some(handle), + seal::hook::FirstSeal::InstalledNotSealed(handle, e) => { + *guard = Some(handle); + return Err(e); + } + } + } else { + let handle = guard + .as_mut() + .expect("hook handle present: slot is Some on this branch"); + seal::hook::seal_prop(handle, name)?; } - let handle = guard - .as_mut() - .expect("hook handle initialised above or was already present"); - seal::hook::seal_prop(handle, name)?; drop(guard); let record = SealRecord { @@ -863,6 +890,26 @@ impl PropSystem { Ok(removed) } + /// Observe-init: trace init (PID 1) for `duration`, mirroring every + /// `write(2)` it makes to a `/dev/kmsg` fd to `sink`. Returns the number + /// of kmsg lines captured. + /// + /// Read-only with respect to init's memory: it single-steps the leader + /// with `PTRACE_SYSCALL` for the window, surfacing the kernel-log lines + /// init emits (e.g. while applying a property set) for diagnostics. + /// AArch64-only, matching the seal subsystem's syscall-number convention; + /// other arches return [`Error::Unsupported`]. + pub fn observe_init(&self, duration: Duration, sink: &mut dyn std::io::Write) -> Result { + require_aarch64()?; + let kmsg_fds = seal::kmsg_observer::discover_kmsg_fds(seal::INIT_PID); + seal::kmsg_observer::snoop_kmsg_writes_for_duration( + seal::INIT_PID, + &kmsg_fds, + duration, + sink, + ) + } + /// Returns a snapshot of the process-wide seal registry. The returned /// `Vec` is an owned clone — callers can iterate without holding the /// internal mutex, and mutations in the registry after the call are diff --git a/crates/resetprop/src/mock.rs b/crates/resetprop/src/mock.rs index 66f7d96..bcf6a40 100644 --- a/crates/resetprop/src/mock.rs +++ b/crates/resetprop/src/mock.rs @@ -287,8 +287,16 @@ mod tests { let word = pick.unwrap(); assert_eq!(word.len(), 7); // should be one of the 7-char segments from our area: "thermal", "display", "monitor", "config" (6 != 7) - let valid = [b"thermal".to_vec(), b"display".to_vec(), b"monitor".to_vec()]; - assert!(valid.contains(&word), "unexpected pick: {:?}", String::from_utf8_lossy(&word)); + let valid = [ + b"thermal".to_vec(), + b"display".to_vec(), + b"monitor".to_vec(), + ]; + assert!( + valid.contains(&word), + "unexpected pick: {:?}", + String::from_utf8_lossy(&word) + ); } #[test] @@ -337,7 +345,10 @@ mod tests { area.foreach(|_, _| c += 1); c }; - assert_eq!(before_count, after_count, "prop count changed after hexpatch"); + assert_eq!( + before_count, after_count, + "prop count changed after hexpatch" + ); } #[test] @@ -390,7 +401,8 @@ mod tests { let area = mock.open(); area.set("ro.customromverylongsegment.x", "v").unwrap(); - area.hexpatch_delete("ro.customromverylongsegment.x").unwrap(); + area.hexpatch_delete("ro.customromverylongsegment.x") + .unwrap(); let mut props = Vec::new(); area.foreach(|n, _| props.push(n.to_string())); @@ -421,10 +433,15 @@ mod tests { area.delete("a.b.c").unwrap(); let nodes = area.inspect_trie(); - let orphans: Vec<_> = nodes.iter() + let orphans: Vec<_> = nodes + .iter() .filter(|n| n.prop_offset == 0 && !n.has_children) .collect(); - assert!(orphans.is_empty(), "found {} orphan leaves after prune", orphans.len()); + assert!( + orphans.is_empty(), + "found {} orphan leaves after prune", + orphans.len() + ); } #[test] @@ -457,8 +474,10 @@ mod tests { area.delete("c.d").unwrap(); area.compact().unwrap(); - assert!(area.arena_stats().bytes_used < before, - "bytes_used did not decrease after compact"); + assert!( + area.arena_stats().bytes_used < before, + "bytes_used did not decrease after compact" + ); assert_eq!(area.get("a.b").unwrap(), "1"); assert_eq!(area.get("e.f").unwrap(), "3"); assert!(area.get("c.d").is_none()); @@ -470,7 +489,8 @@ mod tests { let area = mock.open(); for i in 0..20 { - area.set(&format!("test.prop{i}"), &format!("value{i}")).unwrap(); + area.set(&format!("test.prop{i}"), &format!("value{i}")) + .unwrap(); } for i in (0..20).step_by(2) { @@ -487,8 +507,10 @@ mod tests { ); } for i in (0..20).step_by(2) { - assert!(area.get(&format!("test.prop{i}")).is_none(), - "even prop {i} still present after compact"); + assert!( + area.get(&format!("test.prop{i}")).is_none(), + "even prop {i} still present after compact" + ); } let mut count = 0; @@ -529,7 +551,11 @@ mod tests { let mut seen = std::collections::HashSet::new(); for seg in &segments[1..] { assert_eq!(seg.len(), 4); - assert!(seen.insert(*seg), "duplicate segment '{}' in mangled name", seg); + assert!( + seen.insert(*seg), + "duplicate segment '{}' in mangled name", + seg + ); } assert!(area.get(&props[0]).is_some()); @@ -544,7 +570,9 @@ mod tests { // read raw serial before hexpatch via a get (serial encodes length in top byte) let (pi_off, _) = crate::trie::find(&area, "ro.serial.test").unwrap(); - let serial_before = area.atomic_u32(pi_off).load(std::sync::atomic::Ordering::Relaxed); + let serial_before = area + .atomic_u32(pi_off) + .load(std::sync::atomic::Ordering::Relaxed); // counter bits (1-15, 17-23) should be 0 for a freshly created prop let counter_before = serial_before & 0x00FE_FFFE; assert_eq!(counter_before, 0, "counter non-zero before hexpatch"); @@ -558,7 +586,9 @@ mod tests { let (pi_off_after, _) = crate::trie::find(&area, &mangled).unwrap(); assert_eq!(pi_off, pi_off_after, "prop_info moved after hexpatch"); - let serial_after = area.atomic_u32(pi_off).load(std::sync::atomic::Ordering::Relaxed); + let serial_after = area + .atomic_u32(pi_off) + .load(std::sync::atomic::Ordering::Relaxed); let counter_after = serial_after & 0x00FE_FFFE; // Bionic compose: (((0|1)+1) & 0xFFFFFF) = 2. Counter zero would be // a propdetect leak (non-init-prefix + counter=0 + value="0"). @@ -580,9 +610,14 @@ mod tests { let area = mock.open(); let siblings = [ - "ro.build.type", "ro.build.tags", "ro.build.date", - "ro.build.host", "ro.build.user", "ro.build.keys", - "ro.lineage.version", "ro.custom.rom", + "ro.build.type", + "ro.build.tags", + "ro.build.date", + "ro.build.host", + "ro.build.user", + "ro.build.keys", + "ro.lineage.version", + "ro.custom.rom", ]; for &prop in &siblings { area.set(prop, "test").unwrap(); @@ -594,8 +629,10 @@ mod tests { // ALL ro.build.* siblings must still be accessible via trie lookup for &prop in &siblings[..6] { assert_eq!( - area.get(prop).unwrap(), "test", - "BST corrupted: {} not found after hexpatch", prop + area.get(prop).unwrap(), + "test", + "BST corrupted: {} not found after hexpatch", + prop ); } @@ -617,7 +654,9 @@ mod tests { for b in name.bytes() { assert!( b.is_ascii_alphanumeric() || b == b'.' || b == b'_' || b == b'-', - "invalid byte 0x{:02x} in mangled name '{}'", b, name, + "invalid byte 0x{:02x} in mangled name '{}'", + b, + name, ); } assert!(!name.starts_with('.')); @@ -698,8 +737,14 @@ mod tests { assert_eq!(value, "0"); let (pi_off, _) = crate::trie::find(&area, &name).unwrap(); - let serial = area.atomic_u32(pi_off).load(std::sync::atomic::Ordering::Relaxed); - assert_eq!(serial, 1u32 << 24, "replacement serial should be (1<<24) for value '0'"); + let serial = area + .atomic_u32(pi_off) + .load(std::sync::atomic::Ordering::Relaxed); + assert_eq!( + serial, + 1u32 << 24, + "replacement serial should be (1<<24) for value '0'" + ); } #[test] @@ -708,7 +753,8 @@ mod tests { let area = mock.open(); for i in 0..10 { - area.set(&format!("test.prop.p{i}"), &format!("val{i}")).unwrap(); + area.set(&format!("test.prop.p{i}"), &format!("val{i}")) + .unwrap(); } let before = area.arena_stats().bytes_used; @@ -719,7 +765,13 @@ mod tests { let after = area.arena_stats().bytes_used; let growth = after.saturating_sub(before); - assert!(growth < 512, "bytes_used grew too much: {} -> {} (+{})", before, after, growth); + assert!( + growth < 512, + "bytes_used grew too much: {} -> {} (+{})", + before, + after, + growth + ); let mut count = 0; area.foreach(|n, _| { @@ -754,16 +806,26 @@ mod tests { area.set("ro.test.prop", "hello").unwrap(); let (pi_off, _) = crate::trie::find(&area, "ro.test.prop").unwrap(); - let serial_before = area.atomic_u32(pi_off).load(std::sync::atomic::Ordering::Relaxed); + let serial_before = area + .atomic_u32(pi_off) + .load(std::sync::atomic::Ordering::Relaxed); let counter_before = serial_before & 0x00FFFFFF; - assert_eq!(counter_before, 2, "counter after two writes via write_value: 0 -> 2"); + assert_eq!( + counter_before, 2, + "counter after two writes via write_value: 0 -> 2" + ); area.set_stealth("ro.test.prop", "world").unwrap(); // Bionic compose advances 2 -> 4: (((2|1)+1) & 0xFFFFFF) = 4 - let serial_after = area.atomic_u32(pi_off).load(std::sync::atomic::Ordering::Relaxed); + let serial_after = area + .atomic_u32(pi_off) + .load(std::sync::atomic::Ordering::Relaxed); let expected = (5u32 << 24) | 4; - assert_eq!(serial_after, expected, "stealth must bionic-bump counter, not zero it"); + assert_eq!( + serial_after, expected, + "stealth must bionic-bump counter, not zero it" + ); assert_eq!(area.get("ro.test.prop").unwrap(), "world"); } @@ -777,8 +839,14 @@ mod tests { assert_eq!(area.get("vendor.new.prop").unwrap(), "test_val"); let (pi_off, _) = crate::trie::find(&area, "vendor.new.prop").unwrap(); - let serial = area.atomic_u32(pi_off).load(std::sync::atomic::Ordering::Relaxed); - assert_eq!(serial, 8u32 << 24, "serial should be (8<<24) for 8-char value"); + let serial = area + .atomic_u32(pi_off) + .load(std::sync::atomic::Ordering::Relaxed); + assert_eq!( + serial, + 8u32 << 24, + "serial should be (8<<24) for 8-char value" + ); } #[test] @@ -807,20 +875,33 @@ mod tests { area.set("ro.product.brand", "Pixel").unwrap(); let (pi_off, _) = crate::trie::find(&area, "ro.product.brand").unwrap(); - let before = area.atomic_u32(pi_off).load(std::sync::atomic::Ordering::Relaxed); + let before = area + .atomic_u32(pi_off) + .load(std::sync::atomic::Ordering::Relaxed); let counter_before = before & 0x00FF_FFFF; - assert_eq!(counter_before, 2, "two `set` calls (alloc + write_value) produce counter=2"); + assert_eq!( + counter_before, 2, + "two `set` calls (alloc + write_value) produce counter=2" + ); let count = area.normalize_serial().unwrap(); assert_eq!(count, 1); - let after = area.atomic_u32(pi_off).load(std::sync::atomic::Ordering::Relaxed); + let after = area + .atomic_u32(pi_off) + .load(std::sync::atomic::Ordering::Relaxed); let counter_after = after & 0x00FF_FFFF; // init-style bump: (((2 | 1) + 1) & 0xFFFFFF) = ((3+1) & 0xFFFFFF) = 4 - assert_eq!(counter_after, 4, "normalize_serial must bionic-bump counter from 2 to 4"); + assert_eq!( + counter_after, 4, + "normalize_serial must bionic-bump counter from 2 to 4" + ); let len_byte = (after >> 24) & 0xFF; - assert_eq!(len_byte, 5, "length byte preserved (value 'Pixel' = 5 bytes)"); + assert_eq!( + len_byte, 5, + "length byte preserved (value 'Pixel' = 5 bytes)" + ); assert_eq!(after & 1, 0, "dirty bit cleared after normalize_serial"); assert_eq!(after & (1 << 16), 0, "long flag not set"); @@ -841,17 +922,29 @@ mod tests { let (vd_off, _) = crate::trie::find(&area, "vendor.display.config").unwrap(); let (ps_off, _) = crate::trie::find(&area, "persist.sys.timezone").unwrap(); let (dv_off, _) = crate::trie::find(&area, "dalvik.vm.heapsize").unwrap(); - let vd_before = area.atomic_u32(vd_off).load(std::sync::atomic::Ordering::Relaxed); - let ps_before = area.atomic_u32(ps_off).load(std::sync::atomic::Ordering::Relaxed); - let dv_before = area.atomic_u32(dv_off).load(std::sync::atomic::Ordering::Relaxed); + let vd_before = area + .atomic_u32(vd_off) + .load(std::sync::atomic::Ordering::Relaxed); + let ps_before = area + .atomic_u32(ps_off) + .load(std::sync::atomic::Ordering::Relaxed); + let dv_before = area + .atomic_u32(dv_off) + .load(std::sync::atomic::Ordering::Relaxed); let count = area.normalize_serial().unwrap(); assert_eq!(count, 0, "no ro.* props, no rewrites"); // serials must be untouched - let vd_after = area.atomic_u32(vd_off).load(std::sync::atomic::Ordering::Relaxed); - let ps_after = area.atomic_u32(ps_off).load(std::sync::atomic::Ordering::Relaxed); - let dv_after = area.atomic_u32(dv_off).load(std::sync::atomic::Ordering::Relaxed); + let vd_after = area + .atomic_u32(vd_off) + .load(std::sync::atomic::Ordering::Relaxed); + let ps_after = area + .atomic_u32(ps_off) + .load(std::sync::atomic::Ordering::Relaxed); + let dv_after = area + .atomic_u32(dv_off) + .load(std::sync::atomic::Ordering::Relaxed); assert_eq!(vd_before, vd_after); assert_eq!(ps_before, ps_after); assert_eq!(dv_before, dv_after); @@ -881,7 +974,10 @@ mod tests { ("ro.product.brand", "Pixel"), ("ro.product.model", "Pixel 8"), ("ro.build.type", "user"), - ("ro.build.fingerprint", "google/shiba/shiba:14/UQ1A.231205.015/11084887:user/release-keys"), + ( + "ro.build.fingerprint", + "google/shiba/shiba:14/UQ1A.231205.015/11084887:user/release-keys", + ), ]; for (name, value) in &pairs { area.set(name, value).unwrap(); @@ -907,22 +1003,37 @@ mod tests { area.set("ro.product.brand", "Pixel").unwrap(); let (pi_off, _) = crate::trie::find(&area, "ro.product.brand").unwrap(); - let counter_initial = area.atomic_u32(pi_off).load(std::sync::atomic::Ordering::Relaxed) & 0x00FF_FFFF; - assert_eq!(counter_initial, 0, "freshly alloc'd prop starts at counter=0"); + let counter_initial = area + .atomic_u32(pi_off) + .load(std::sync::atomic::Ordering::Relaxed) + & 0x00FF_FFFF; + assert_eq!( + counter_initial, 0, + "freshly alloc'd prop starts at counter=0" + ); // first normalize: 0 -> (((0|1)+1) & 0xFFFFFF) = 2 area.normalize_serial().unwrap(); - let c1 = area.atomic_u32(pi_off).load(std::sync::atomic::Ordering::Relaxed) & 0x00FF_FFFF; + let c1 = area + .atomic_u32(pi_off) + .load(std::sync::atomic::Ordering::Relaxed) + & 0x00FF_FFFF; assert_eq!(c1, 2); // second normalize: 2 -> (((2|1)+1) & 0xFFFFFF) = 4 area.normalize_serial().unwrap(); - let c2 = area.atomic_u32(pi_off).load(std::sync::atomic::Ordering::Relaxed) & 0x00FF_FFFF; + let c2 = area + .atomic_u32(pi_off) + .load(std::sync::atomic::Ordering::Relaxed) + & 0x00FF_FFFF; assert_eq!(c2, 4); // third normalize: 4 -> (((4|1)+1) & 0xFFFFFF) = 6 area.normalize_serial().unwrap(); - let c3 = area.atomic_u32(pi_off).load(std::sync::atomic::Ordering::Relaxed) & 0x00FF_FFFF; + let c3 = area + .atomic_u32(pi_off) + .load(std::sync::atomic::Ordering::Relaxed) + & 0x00FF_FFFF; assert_eq!(c3, 6); } @@ -966,7 +1077,10 @@ mod tests { sys.set("vendor.display.config", "auto").unwrap(); let count = sys.normalize_serial().unwrap(); - assert_eq!(count, 2, "PropSystem::normalize_serial counts the 2 ro.* short props"); + assert_eq!( + count, 2, + "PropSystem::normalize_serial counts the 2 ro.* short props" + ); // values intact through PropSystem read path assert_eq!(sys.get("ro.product.brand").unwrap(), "Pixel"); @@ -1033,9 +1147,7 @@ mod tests { #[test] fn set_if_match_skips_when_absent() { let (_dir, sys) = fresh_sys(); - let acted = sys - .set_if_match("ro.absent", "anything", "value") - .unwrap(); + let acted = sys.set_if_match("ro.absent", "anything", "value").unwrap(); assert!(!acted); assert!(sys.get("ro.absent").is_none()); } diff --git a/crates/resetprop/src/persist/io.rs b/crates/resetprop/src/persist/io.rs index 876bcd0..04fcc8f 100644 --- a/crates/resetprop/src/persist/io.rs +++ b/crates/resetprop/src/persist/io.rs @@ -36,7 +36,9 @@ pub(crate) fn atomic_write(dir: &Path, data: &[u8]) -> Result<()> { let result = write_and_sync(fd, data, &selinux_ctx); - unsafe { libc::close(fd); } + unsafe { + libc::close(fd); + } if let Err(e) = result { let _ = std::fs::remove_file(&tmp); @@ -57,7 +59,11 @@ fn write_and_sync(fd: i32, data: &[u8], selinux_ctx: &Option>) -> Result let mut written = 0usize; while written < data.len() { let n = unsafe { - libc::write(fd, data[written..].as_ptr() as *const libc::c_void, data.len() - written) + libc::write( + fd, + data[written..].as_ptr() as *const libc::c_void, + data.len() - written, + ) }; if n < 0 { return Err(std::io::Error::last_os_error().into()); @@ -111,7 +117,9 @@ fn fsync_dir(dir: &Path) -> Result<()> { return Err(std::io::Error::last_os_error().into()); } let rc = unsafe { libc::fsync(fd) }; - unsafe { libc::close(fd); } + unsafe { + libc::close(fd); + } if rc != 0 { return Err(std::io::Error::last_os_error().into()); } diff --git a/crates/resetprop/src/persist/mod.rs b/crates/resetprop/src/persist/mod.rs index 1d45d89..39580dd 100644 --- a/crates/resetprop/src/persist/mod.rs +++ b/crates/resetprop/src/persist/mod.rs @@ -1,5 +1,5 @@ -pub(crate) mod proto; mod io; +pub(crate) mod proto; use std::path::{Path, PathBuf}; @@ -32,11 +32,15 @@ impl PersistStore { } else { load_legacy(dir) }; - Ok(Self { dir: dir.to_path_buf(), records }) + Ok(Self { + dir: dir.to_path_buf(), + records, + }) } pub fn get(&self, name: &str) -> Option<&str> { - self.records.iter() + self.records + .iter() .find(|r| r.name == name) .map(|r| r.value.as_str()) } @@ -45,7 +49,10 @@ impl PersistStore { if let Some(r) = self.records.iter_mut().find(|r| r.name == name) { r.value = value.to_string(); } else { - self.records.push(Record { name: name.to_string(), value: value.to_string() }); + self.records.push(Record { + name: name.to_string(), + value: value.to_string(), + }); } self.flush() } @@ -85,7 +92,10 @@ fn load_legacy(dir: &Path) -> Vec { continue; } if let Ok(value) = std::fs::read_to_string(entry.path()) { - records.push(Record { name, value: value.trim().to_string() }); + records.push(Record { + name, + value: value.trim().to_string(), + }); } } records diff --git a/crates/resetprop/src/persist/proto.rs b/crates/resetprop/src/persist/proto.rs index b5a003f..8d63f83 100644 --- a/crates/resetprop/src/persist/proto.rs +++ b/crates/resetprop/src/persist/proto.rs @@ -148,7 +148,9 @@ fn skip_field(data: &[u8], mut pos: usize, wire_type: u8) -> Result { 2 => { let len = read_varint(data, &mut pos)? as usize; if pos + len > data.len() { - return Err(Error::PersistCorrupt("truncated length-delimited field".into())); + return Err(Error::PersistCorrupt( + "truncated length-delimited field".into(), + )); } Ok(pos + len) } @@ -158,7 +160,9 @@ fn skip_field(data: &[u8], mut pos: usize, wire_type: u8) -> Result { } Ok(pos + 4) } - _ => Err(Error::PersistCorrupt(format!("unknown wire type {wire_type}"))), + _ => Err(Error::PersistCorrupt(format!( + "unknown wire type {wire_type}" + ))), } } @@ -190,9 +194,18 @@ mod tests { #[test] fn round_trip_multiple() { let records = vec![ - Record { name: "persist.sys.timezone".into(), value: "UTC".into() }, - Record { name: "persist.sys.language".into(), value: "en".into() }, - Record { name: "persist.vendor.test".into(), value: "1".into() }, + Record { + name: "persist.sys.timezone".into(), + value: "UTC".into(), + }, + Record { + name: "persist.sys.language".into(), + value: "en".into(), + }, + Record { + name: "persist.vendor.test".into(), + value: "1".into(), + }, ]; let encoded = encode(&records); let decoded = decode(&encoded).unwrap(); @@ -228,7 +241,10 @@ mod tests { #[test] fn wire_format_field_order() { - let records = vec![Record { name: "persist.a".into(), value: "v".into() }]; + let records = vec![Record { + name: "persist.a".into(), + value: "v".into(), + }]; let encoded = encode(&records); assert_eq!(encoded[0], 0x0A); let mut pos = 1; @@ -243,7 +259,10 @@ mod tests { #[test] fn skips_unknown_fields() { - let records = vec![Record { name: "persist.x".into(), value: "y".into() }]; + let records = vec![Record { + name: "persist.x".into(), + value: "y".into(), + }]; let mut encoded = encode(&records); // append an unknown varint field (field 15, wire type 0, value 42) encoded.push(15 << 3); diff --git a/crates/resetprop/src/seal/arena.rs b/crates/resetprop/src/seal/arena.rs index 2f7f12f..0fd4a83 100644 --- a/crates/resetprop/src/seal/arena.rs +++ b/crates/resetprop/src/seal/arena.rs @@ -10,19 +10,18 @@ use std::path::Path; use super::maps::{parse_maps, MapEntry}; use crate::error::{Error, Result}; -// ───────────────────────────────────────────────────────────────────────────── // Syscall numbers and flag constants (REGISTRY §1 canonical values) -// ───────────────────────────────────────────────────────────────────────────── // Syscall numbers (asm-generic/unistd.h via linux-arm64-abi.md §1) pub(crate) const NR_OPENAT: u64 = 56; pub(crate) const NR_MMAP: u64 = 222; pub(crate) const NR_CLOSE: u64 = 57; pub(crate) const NR_MUNMAP: u64 = 215; +pub(crate) const NR_MEMFD_CREATE: u64 = 279; // fcntl/mman constants (asm-generic/fcntl.h, asm-generic/mman-common.h) pub(crate) const AT_FDCWD: u64 = -100_i64 as u64; // sign-extended to 64 bits -pub(crate) const O_RDONLY: u64 = 0; +pub(crate) const MFD_CLOEXEC: u64 = 0x0001; pub(crate) const O_RDONLY_NOFOLLOW: u64 = 0x20000; pub(crate) const O_RDWR_NOFOLLOW: u64 = 0x20002; pub(crate) const PROT_RW: u64 = 0x3; @@ -149,9 +148,7 @@ pub(crate) fn find_scratch_slot(bytes: &[u8]) -> Option { Some(min.next_multiple_of(8)) } -// ───────────────────────────────────────────────────────────────────────────── // RemapFlags — direction selector for remote_remap_private -// ───────────────────────────────────────────────────────────────────────────── /// Direction of the arena remap — `Private` seals (blocks writes from /// propagating), `Shared` restores init's original view (unseal). @@ -176,9 +173,7 @@ impl RemapFlags { } } -// ───────────────────────────────────────────────────────────────────────────── // verify_init_identity — M1 init-identity guard (runs BEFORE any RemoteAttach) -// ───────────────────────────────────────────────────────────────────────────── /// Read `/proc//comm` — the kernel's per-process command name, written /// with a trailing newline. Authored fresh for the M1 guard; no other @@ -221,9 +216,7 @@ fn check_init_identity(comm: &str, maps: &[MapEntry]) -> Result<()> { Ok(()) } -// ───────────────────────────────────────────────────────────────────────────── // RemoteAttach — RAII guard that detaches on drop -// ───────────────────────────────────────────────────────────────────────────── /// RAII guard that group-stops init's **entire** thread group on construction /// and resumes every frozen thread on Drop. @@ -290,9 +283,7 @@ impl Drop for RemoteAttach { } } -// ───────────────────────────────────────────────────────────────────────────── // remote_remap_private — the core Tier A seal primitive -// ───────────────────────────────────────────────────────────────────────────── /// Remap init's mapping of `arena_path` as `MAP_PRIVATE|MAP_FIXED` /// (Tier A seal) or `MAP_SHARED|MAP_FIXED` (unseal) via remote syscalls @@ -312,11 +303,11 @@ impl Drop for RemoteAttach { /// — before any error-check can `?` — so libc.text is always left /// pristine. /// 6. Write the NUL-terminated arena path to `bootstrap_page` via -/// `write_remote`; `remote_syscall` openat (scratch_pc=bootstrap_page, -/// the fresh RWX page); `remote_syscall` mmap -/// (`MAP_PRIVATE|MAP_FIXED` or `MAP_SHARED|MAP_FIXED` per `flags`) over -/// the arena VMA — must return exactly `mapping.start`; `remote_syscall` -/// close the fd. +/// `write_remote`; `remote_syscall_via_poke` openat +/// (scratch_pc=bootstrap_page, the fresh RWX page); +/// `remote_syscall_via_poke` mmap (`MAP_PRIVATE|MAP_FIXED` or +/// `MAP_SHARED|MAP_FIXED` per `flags`) over the arena VMA, which must +/// return exactly `mapping.start`; `remote_syscall_via_poke` close the fd. /// 7. munmap the bootstrap page (runs on every post-mmap exit), then /// `guard.detach()` returns cleanly. /// @@ -344,16 +335,7 @@ pub(crate) unsafe fn remote_remap_private( // --- Locate a libc.text NOP slide in the tracee ---------------------- let maps_entries = super::maps::parse_maps(pid)?; - let libc_text = maps_entries - .iter() - .find(|e| { - e.perms.starts_with(b"r-x") - && e.path - .as_deref() - .and_then(|p| p.file_name()) - .and_then(|n| n.to_str()) - .is_some_and(|n| n == "libc.so" || n.starts_with("libc.so.")) - }) + let libc_text = super::hook::find_libc_text_row(&maps_entries) .ok_or_else(|| Error::HookInstallFailed("no libc.so r-x mapping in target".into()))?; let scan_len = (libc_text.end - libc_text.start).min(LIBC_SCAN_LIMIT as u64) as usize; @@ -364,84 +346,35 @@ pub(crate) unsafe fn remote_remap_private( // is ptrace-stopped for the duration (guarded by `RemoteAttach` above). unsafe { super::ptrace::read_remote(pid, libc_text.start, &mut scan_buf)? }; - let slide_offset = find_scratch_slot(&scan_buf) - .ok_or_else(|| Error::HookInstallFailed("libc.text scan too small for scratch slot".into()))?; + let slide_offset = find_scratch_slot(&scan_buf).ok_or_else(|| { + Error::HookInstallFailed("libc.text scan too small for scratch slot".into()) + })?; let scratch_pc = libc_text.start + slide_offset as u64; - // --- Bootstrap: POKEDATA an svc+brk blob at scratch_pc --------------- - let saved_bytes = super::ptrace::ptrace_peektext(guard.pid(), scratch_pc)?; - - // trap (low 4 bytes) ; brk (high 4 bytes), little-endian pack - let trap_brk: u64 = (super::ptrace::TRAP_INSN as u64) - | ((super::ptrace::BRK_INSN as u64) << 32); - super::ptrace::ptrace_poketext(guard.pid(), scratch_pc, trap_brk)?; - + // --- Bootstrap: mmap a fresh RW scratch page via the shared injector -- + // mmap(NULL, 4096, PROT_RW, MAP_PRIVATE|MAP_ANON, -1, 0) — data-only, no + // execmem requested. Staged through the libc.text `scratch_pc` by the same + // peek/poke injector every other remote syscall in this function uses. + // + // SAFETY: `scratch_pc` is the libc.text NOP slide located above; the tracee + // is ptrace-stopped via `guard`; `remote_syscall_via_poke` saves and + // restores both the scratch word and the register snapshot internally. let bootstrap_page = { - let saved_regs = super::ptrace::getregset(guard.pid())?; - let mut work = saved_regs; - // mmap(NULL, 4096, PROT_RW, MAP_PRIVATE|MAP_ANON, -1, 0) — page is - // data-only, so no execmem is requested. Staged through the arch- - // neutral interface so no raw register index appears here. - super::ptrace::set_syscall_args( - &mut work, - scratch_pc, - NR_MMAP, - [ - 0, // addr = NULL - BOOTSTRAP_PAGE_SIZE, // len = 4096 - PROT_RW, // prot - MAP_PRIVATE_ANON, // flags - (-1_i64) as u64, // fd = -1 - 0, // offset - ], - ); - - super::ptrace::setregset(guard.pid(), &work)?; - - // SAFETY: `libc::ptrace` FFI; tracee is stopped per RemoteAttach's - // post-wait_stop contract; `addr` / `data` are NULL per the - // PTRACE_CONT contract. - let rc = unsafe { - libc::ptrace( - super::ptrace::PTRACE_CONT as _, + let ret = unsafe { + super::ptrace::remote_syscall_via_poke( guard.pid(), - std::ptr::null_mut::(), - std::ptr::null_mut::(), - ) + scratch_pc, + NR_MMAP, + [ + 0, // addr = NULL + BOOTSTRAP_PAGE_SIZE, // len = 4096 + PROT_RW, // prot + MAP_PRIVATE_ANON, // flags + (-1_i64) as u64, // fd = -1 + 0, // offset + ], + )? }; - if rc == -1 { - // Best-effort restore before propagating, so libc.text isn't - // left with the svc+brk bytes in place. - let _ = super::ptrace::ptrace_poketext(guard.pid(), scratch_pc, saved_bytes); - let _ = super::ptrace::setregset(guard.pid(), &saved_regs); - return Err(Error::PtraceOp(std::io::Error::last_os_error())); - } - - // Always restore scratch bytes + registers before inspecting the - // result. A failure in `wait_stop` or `getregset` must not leave - // libc.text containing live svc+brk or the tracee's saved regs in - // work-state — RemoteAttach::drop would release init into that - // poisoned state and the next thread scheduled at scratch_pc would - // trap on brk #0. On the pre-restore failure paths the errors from - // the restore calls are discarded in favor of the original cause. - let wait_result = super::ptrace::wait_stop(guard.pid(), 0); - if wait_result.is_err() { - let _ = super::ptrace::ptrace_poketext(guard.pid(), scratch_pc, saved_bytes); - let _ = super::ptrace::setregset(guard.pid(), &saved_regs); - } - wait_result?; - - let out_result = super::ptrace::getregset(guard.pid()); - if out_result.is_err() { - let _ = super::ptrace::ptrace_poketext(guard.pid(), scratch_pc, saved_bytes); - let _ = super::ptrace::setregset(guard.pid(), &saved_regs); - } - let out = out_result?; - let ret = super::ptrace::get_syscall_return(&out); - - super::ptrace::ptrace_poketext(guard.pid(), scratch_pc, saved_bytes)?; - super::ptrace::setregset(guard.pid(), &saved_regs)?; - if (-4095..=-1).contains(&ret) { return Err(Error::HookInstallFailed(format!( "bootstrap mmap failed: errno={}", @@ -561,11 +494,9 @@ pub(crate) unsafe fn remote_remap_private( Ok(()) } -// ───────────────────────────────────────────────────────────────────────────── // Tier A orchestrators — thin compositions over find_arena_mapping + // remote_remap_private. These are the public seam consumed by // `PropSystem::seal_arena` / `::unseal_arena` and by the T5 smoke test. -// ───────────────────────────────────────────────────────────────────────────── /// Tier A seal: remap init's writable view of `arena_path` as /// `MAP_PRIVATE|MAP_FIXED`. Thin composition of `find_arena_mapping` + @@ -745,10 +676,11 @@ mod tests { assert_eq!(NR_MMAP, 222); assert_eq!(NR_CLOSE, 57); assert_eq!(NR_MUNMAP, 215); + assert_eq!(NR_MEMFD_CREATE, 279); // fcntl / mman flags assert_eq!(AT_FDCWD, (-100_i64) as u64); - assert_eq!(O_RDONLY, 0); + assert_eq!(MFD_CLOEXEC, 0x0001); assert_eq!(O_RDONLY_NOFOLLOW, 0x20000); assert_eq!(O_RDWR_NOFOLLOW, 0x20002); assert_eq!(PROT_RW, 0x3); @@ -764,7 +696,11 @@ mod tests { } fn libc_row() -> MapEntry { - mk("/apex/com.android.runtime/lib64/bionic/libc.so", b"r-xp", 0x2000) + mk( + "/apex/com.android.runtime/lib64/bionic/libc.so", + b"r-xp", + 0x2000, + ) } #[test] diff --git a/crates/resetprop/src/seal/elf.rs b/crates/resetprop/src/seal/elf.rs index 5815508..2857f54 100644 --- a/crates/resetprop/src/seal/elf.rs +++ b/crates/resetprop/src/seal/elf.rs @@ -23,9 +23,7 @@ use std::ptr; use crate::error::{Error, Result}; -// ----------------------------------------------------------------------------- // Constants -// ----------------------------------------------------------------------------- /// ELF magic — first four bytes of `e_ident`. `/usr/include/elf.h:103-107`. pub const ELFMAG: [u8; 4] = [0x7f, b'E', b'L', b'F']; @@ -86,9 +84,7 @@ pub const STB_WEAK: u8 = 2; /// Reserved section index meaning "undefined". `/usr/include/elf.h:413`. pub const SHN_UNDEF: u16 = 0; -// ----------------------------------------------------------------------------- // ELF64 structs (exact layouts per /usr/include/elf.h) -// ----------------------------------------------------------------------------- /// ELF64 file header. Layout: `/usr/include/elf.h:81-97`. Total: 64 bytes. #[repr(C)] @@ -155,9 +151,7 @@ pub struct Elf64_Sym { } const _: () = assert!(mem::size_of::() == 24); -// ----------------------------------------------------------------------------- // LibcElfView — owned file bytes + resolved dynamic table offsets -// ----------------------------------------------------------------------------- /// Parsed view of an Android arm64 `libc.so`. /// @@ -184,9 +178,7 @@ pub struct LibcElfView { pub(crate) syment: usize, } -// ----------------------------------------------------------------------------- // vaddr → file offset translation -// ----------------------------------------------------------------------------- /// Translate a linking-view virtual address to a file offset using the PT_LOAD /// map. Returns `None` if `vaddr` falls outside every PT_LOAD range. @@ -201,9 +193,7 @@ fn vaddr_to_foff(pt_loads: &[(u64, u64, u64)], vaddr: u64) -> Option { None } -// ----------------------------------------------------------------------------- // Unaligned struct read helper -// ----------------------------------------------------------------------------- /// Read a `#[repr(C)]` POD struct of size `N` from `bytes[off..off+N]` without /// alignment requirements. Returns `Error::ElfParse` on bounds overflow. @@ -214,9 +204,9 @@ fn vaddr_to_foff(pt_loads: &[(u64, u64, u64)], vaddr: u64) -> Option { /// here (`Elf64_Ehdr`, `Elf64_Phdr`, `Elf64_Dyn`, `Elf64_Sym`) satisfy this. fn read_struct(bytes: &[u8], off: usize, what: &str) -> Result { let size = mem::size_of::(); - let end = off.checked_add(size).ok_or_else(|| { - Error::ElfParse(format!("offset overflow reading {what}")) - })?; + let end = off + .checked_add(size) + .ok_or_else(|| Error::ElfParse(format!("offset overflow reading {what}")))?; if end > bytes.len() { return Err(Error::ElfParse(format!( "truncated {what} at offset {off} (need {size}, have {})", @@ -231,9 +221,7 @@ fn read_struct(bytes: &[u8], off: usize, what: &str) -> Result { Ok(unsafe { ptr::read_unaligned(ptr) }) } -// ----------------------------------------------------------------------------- // parse_libc_elf — public entry point for P03 -// ----------------------------------------------------------------------------- /// Parse an Android arm64 `libc.so` from a `File` handle into a [`LibcElfView`]. /// @@ -282,9 +270,7 @@ pub fn parse_libc_elf(file: &File) -> Result { return Err(Error::ElfParse("e_type != ET_DYN".into())); } if (ehdr.e_phentsize as usize) != mem::size_of::() { - return Err(Error::ElfParse( - "e_phentsize != sizeof(Elf64_Phdr)".into(), - )); + return Err(Error::ElfParse("e_phentsize != sizeof(Elf64_Phdr)".into())); } // ---- Program headers ---- @@ -297,9 +283,10 @@ pub fn parse_libc_elf(file: &File) -> Result { for i in 0..phnum { let off = phoff - .checked_add(i.checked_mul(phentsize).ok_or_else(|| { - Error::ElfParse("phdr index overflow".into()) - })?) + .checked_add( + i.checked_mul(phentsize) + .ok_or_else(|| Error::ElfParse("phdr index overflow".into()))?, + ) .ok_or_else(|| Error::ElfParse("phdr offset overflow".into()))?; let phdr: Elf64_Phdr = read_struct(&bytes, off, "Phdr")?; @@ -314,8 +301,8 @@ pub fn parse_libc_elf(file: &File) -> Result { } } - let (_dyn_vaddr, dyn_off, dyn_filesz) = pt_dynamic - .ok_or_else(|| Error::ElfParse("PT_DYNAMIC absent".into()))?; + let (_dyn_vaddr, dyn_off, dyn_filesz) = + pt_dynamic.ok_or_else(|| Error::ElfParse("PT_DYNAMIC absent".into()))?; // ---- Walk PT_DYNAMIC until DT_NULL or filesz exhausted ---- let mut symtab_va: Option = None; @@ -330,9 +317,10 @@ pub fn parse_libc_elf(file: &File) -> Result { for i in 0..dyn_entries { let off = dyn_off - .checked_add(i.checked_mul(mem::size_of::()).ok_or_else(|| { - Error::ElfParse("dyn index overflow".into()) - })?) + .checked_add( + i.checked_mul(mem::size_of::()) + .ok_or_else(|| Error::ElfParse("dyn index overflow".into()))?, + ) .ok_or_else(|| Error::ElfParse("dyn offset overflow".into()))?; let d: Elf64_Dyn = read_struct(&bytes, off, "Dyn")?; @@ -380,9 +368,7 @@ pub fn parse_libc_elf(file: &File) -> Result { }) } -// ----------------------------------------------------------------------------- // GNU_HASH lookup (T2) -// ----------------------------------------------------------------------------- /// Width of a single bloom-filter word, in bits. Matches bionic's /// `kBloomMaskBits = sizeof(ElfW(Addr)) * 8` for arm64. @@ -547,9 +533,7 @@ pub fn gnu_lookup(view: &LibcElfView, name: &str) -> Option { } } -// ----------------------------------------------------------------------------- // Linear symbol-table fallback (T3) -// ----------------------------------------------------------------------------- /// Linear scan of `.dynsym` for `name`, returning the matched symbol's /// `st_value` or `None` on miss / malformed bounds. @@ -578,8 +562,7 @@ pub fn linear_lookup(view: &LibcElfView, name: &str) -> Option { if view.strtab_offset <= view.symtab_offset { return None; } - let entries = - (view.strtab_offset - view.symtab_offset) / mem::size_of::(); + let entries = (view.strtab_offset - view.symtab_offset) / mem::size_of::(); for i in 0..entries { let sym_off = view @@ -628,9 +611,7 @@ pub fn resolve_symbol(view: &LibcElfView, name: &str) -> Result { result.ok_or_else(|| Error::SymbolNotFound(name.into())) } -// ----------------------------------------------------------------------------- // Tests -// ----------------------------------------------------------------------------- #[cfg(test)] impl LibcElfView { @@ -823,13 +804,7 @@ mod tests { // String table. bytes[strtab_offset..strtab_offset + strtab_size].copy_from_slice(&strtab); - LibcElfView::from_parts( - bytes, - symtab_offset, - strtab_offset, - strtab_size, - Some(0), - ) + LibcElfView::from_parts(bytes, symtab_offset, strtab_offset, strtab_size, Some(0)) } /// A chain-matching symbol whose binding is `STB_LOCAL` must be skipped, diff --git a/crates/resetprop/src/seal/hook.rs b/crates/resetprop/src/seal/hook.rs index 29f0502..61fda9a 100644 --- a/crates/resetprop/src/seal/hook.rs +++ b/crates/resetprop/src/seal/hook.rs @@ -23,26 +23,22 @@ //! prefixed message (`"stage-A: : "` / //! `"stage-B: : "`) per the P03 checklist FR-18 / FR-19. +use std::ffi::CString; use std::fs::File; -use std::os::unix::ffi::OsStrExt; -use std::path::{Path, PathBuf}; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::sync::atomic::{AtomicU32, Ordering}; use crate::error::{Error, Result}; use crate::seal; use crate::seal::arena::{ - find_scratch_slot, AT_FDCWD, MAP_PRIVATE, MAP_PRIVATE_ANON, NR_CLOSE, NR_MMAP, NR_MUNMAP, - NR_OPENAT, O_RDONLY, PROT_RW, PROT_RX, + find_scratch_slot, MAP_PRIVATE, MAP_PRIVATE_ANON, MFD_CLOEXEC, NR_CLOSE, NR_MEMFD_CREATE, + NR_MMAP, NR_MUNMAP, PROT_RW, PROT_RX, }; use crate::seal::maps::MapEntry; use crate::seal::ptrace::{ - getregset, ptrace_peektext, ptrace_poketext, read_remote, remote_syscall_via_poke, set_pc, - setregset, wait_stop, write_remote, PTRACE_CONT, + ptrace_poketext, read_remote, remote_syscall_via_poke, run_remote_payload, set_pc, write_remote, }; -// ───────────────────────────────────────────────────────────────────────────── // Stage-B constants (REGISTRY §1 canonical flag values) -// ───────────────────────────────────────────────────────────────────────────── /// 4 KiB — one base page on AArch64. Mirrors `BOOTSTRAP_PAGE_SIZE` in /// `seal::arena` but kept local to keep the stage-B constant block self- @@ -50,13 +46,6 @@ use crate::seal::ptrace::{ /// the value flows straight into `remote_syscall_via_poke` args. const HOOK_PAGE_SIZE: u64 = 4096; -/// Host directory that receives the stage-B hook body before it is mmap'd -/// file-backed into init. Auto-labels as `u:object_r:adb_data_file:s0` on -/// write, which matches init's `adb_data_file:file { execute map }` allow -/// rule and so avoids the `execmem` denial that anonymous `PROT_READ | -/// PROT_EXEC` would hit on OEMs stripping `process:execmem` from init. -const HOOK_FILE_DIR: &str = "/data/adb/resetprop-rs"; - /// Byte offset inside `lock_list_page` where the host-written hook file /// path is staged before the remote `openat`. Lives past /// [`LOCK_LIST_CAPACITY`] = 1024 so the hook body's in-page walker never @@ -69,12 +58,24 @@ const PATH_STAGE_OFFSET: u64 = 2048; /// pipelines share identical scan behaviour. const LIBC_SCAN_LIMIT: usize = 64 * 1024; -// ───────────────────────────────────────────────────────────────────────────── +/// Hard install failures tolerated before [`install_init_hook`] stops +/// attaching for the rest of the process. A seal install pokes init's +/// prologue; a load that keeps failing is a fault that retrying only drives +/// toward a bootloop, so the installer gives up rather than keep hammering +/// PID 1. Mirrors ReZygisk's `MAX_RETRY_COUNT` ptrace-monitor ceiling +/// (loader/src/ptracer/monitor.c). +const MAX_INSTALL_HARD_FAILURES: u32 = 5; + +/// Count of hard [`install_init_hook`] failures this process has seen. Module- +/// static rather than a [`HookHandle`] field because a handle only exists +/// after a *successful* install, so the throttle must outlive the failed +/// attempts that never produce one. +static INSTALL_HARD_FAILURES: AtomicU32 = AtomicU32::new(0); + // P04 T3 — hook-page layout and i-cache sync constants -// ───────────────────────────────────────────────────────────────────────────── /// Hook page byte offset where [`build_hook_body_bytes`]'s 140-byte body -/// lands in the file-backed mapping. Held at 1024 rather than 0 to keep +/// lands in the hook-page mapping. Held at 1024 rather than 0 to keep /// binary compatibility with existing trampoline encoders and tests that /// reference the canonical offset; the preceding 1024 bytes of the hook /// page are zero-padding. @@ -100,6 +101,12 @@ pub(crate) const HOOK_BODY_OFFSET: u64 = 1024; /// `P04-tier-b-part2.md §Operational Envelope`. pub(crate) const LOCK_LIST_CAPACITY: u64 = 1024; +/// A lock-list entry must never reach the install-time path staged at +/// [`PATH_STAGE_OFFSET`], or the in-init walker would read those path bytes +/// as a lock name. Binding the two constants fails the build on a future +/// capacity bump instead of letting the list silently overrun. +const _: () = assert!(LOCK_LIST_CAPACITY < PATH_STAGE_OFFSET); + /// `MEMBARRIER_CMD_REGISTER_PRIVATE_EXPEDITED_SYNC_CORE` — kernel cmd byte /// that registers the calling task's intent to later issue /// `PRIVATE_EXPEDITED_SYNC_CORE`. Without this registration, the SYNC_CORE @@ -144,11 +151,11 @@ pub(crate) const NR_MEMBARRIER: u64 = 283; #[allow(dead_code)] pub struct HookHandle { pub(crate) pid: libc::pid_t, - /// File-backed `PROT_READ | PROT_EXEC` page in the tracee holding the - /// 140-byte hook body at [`HOOK_BODY_OFFSET`]. Source file is - /// [`HOOK_FILE_DIR`]`/hook--.bin`, unlinked immediately - /// after mmap (kernel retains the mapping via the deleted-inode - /// mechanism, same as bionic's seccomp filter page). + /// `PROT_READ | PROT_EXEC` page in the tracee holding the 140-byte hook + /// body at [`HOOK_BODY_OFFSET`], mapped from an anonymous `memfd` the + /// tracee created and this process filled. The fd is closed after mmap; + /// the mapping survives via the fd's anonymous inode, leaving no on-disk + /// residue. pub(crate) hook_page: u64, /// Anonymous `PROT_READ | PROT_WRITE` page in the tracee holding the /// variable-length lock list at offset 0 (up to [`LOCK_LIST_CAPACITY`] @@ -170,6 +177,10 @@ pub struct HookHandle { /// on every property update. P04 reverts the trampoline and unmaps /// both pages explicitly before the handle is dropped. pub(crate) trampoline_installed: bool, + /// `/dev/kmsg` file descriptors init held open at install time, discovered + /// from `/proc//fd` (Port 2). The observe-init snoop reads these to + /// recognise `write(kmsg_fd, …)` traffic among init's syscalls. + pub(crate) kmsg_fds: Vec, } /// Predicate picking the executable `libc.so` mapping out of a parsed maps file. @@ -187,12 +198,21 @@ pub(crate) fn is_libc_row(entry: &MapEntry) -> bool { .is_some_and(|s| s.ends_with("/libc.so")) } +/// Locate the executable `libc.so` row in a parsed maps file via the hardened +/// [`is_libc_row`] gate. Stage-A and the Tier A remap share this so both pin +/// libc.text by the identical rule rather than drifting predicates. +pub(crate) fn find_libc_text_row(entries: &[MapEntry]) -> Option<&MapEntry> { + entries.iter().find(|e| is_libc_row(e)) +} + /// Stage-A of the hook install pipeline — RUN UNDER ATTACH. /// /// Returns `(libc_base, libc_end, target_fn)` where `libc_base` / `libc_end` -/// are the `r-xp` row's `start` / `end` addresses and -/// `target_fn = libc_base + st_value("__system_property_update")` -/// (ET_DYN runtime math per references/android-libc-elf.md §7). +/// are the `r-xp` row's `start` / `end` addresses and `target_fn` is +/// `__system_property_update` resolved against the ELF load bias +/// (`libc_base - r-xp file offset`), not against `libc_base` itself — the +/// executable segment is mapped at a non-zero file offset (ET_DYN runtime math +/// per references/android-libc-elf.md §7). /// /// This MUST be called while the caller holds a live `RemoteAttach` on /// `pid`. Every stage-A observation — `/proc//maps`, the libc row, @@ -208,12 +228,13 @@ fn stage_a_locked(pid: libc::pid_t) -> Result<(u64, u64, u64)> { let entries = seal::maps::parse_maps(pid) .map_err(|e| Error::HookInstallFailed(format!("stage-A: parse_maps: {e}")))?; - let libc_row = entries.iter().find(|e| is_libc_row(e)).ok_or_else(|| { + let libc_row = find_libc_text_row(&entries).ok_or_else(|| { Error::HookInstallFailed(format!("stage-A: libc row not found in /proc/{pid}/maps")) })?; let libc_base = libc_row.start; let libc_end = libc_row.end; + let libc_text_offset = libc_row.offset; // Some Android kernels (observed on Xiaomi 2409BRN2CA, Android 15, kernel // 6.6.58) expose /proc//map_files at VMA granularity while @@ -248,7 +269,17 @@ fn stage_a_locked(pid: libc::pid_t) -> Result<(u64, u64, u64)> { let st_value = seal::elf::resolve_symbol(&view, "__system_property_update") .map_err(|e| Error::HookInstallFailed(format!("stage-A: resolve_symbol: {e}")))?; - let target_fn = libc_base + // A symbol's st_value is a vaddr relative to the ELF load bias, not to the + // executable segment's mapped start. bionic's libc maps its r-xp segment at + // a non-zero file offset (0x44000 on Android 15 bootstrap libc), and for + // these page-aligned PT_LOADs p_vaddr == p_offset, so the load bias is the + // segment's mapped start minus that file offset. Adding st_value to + // libc_base directly overshoots by the file offset and lands the trampoline + // in a neighbouring function. + let load_bias = libc_base + .checked_sub(libc_text_offset) + .ok_or_else(|| Error::HookInstallFailed("stage-A: libc load bias underflow".into()))?; + let target_fn = load_bias .checked_add(st_value) .ok_or_else(|| Error::HookInstallFailed("stage-A: target_fn overflow".into()))?; @@ -298,78 +329,110 @@ unsafe fn derive_libc_scratch_pc(pid: libc::pid_t, libc_base: u64, libc_end: u64 /// /// # Page layout /// -/// * `lock_list_page` — anonymous `PROT_READ | PROT_WRITE`. Holds the +/// * `lock_list_page`: anonymous `PROT_READ | PROT_WRITE`. Holds the /// runtime lock list at offset 0 (up to [`LOCK_LIST_CAPACITY`] bytes) -/// and temporarily stages the hook file path at [`PATH_STAGE_OFFSET`] +/// and temporarily stages the memfd name at [`PATH_STAGE_OFFSET`] /// during this install only. -/// * `hook_page` — file-backed `PROT_READ | PROT_EXEC`, sourced from -/// [`HOOK_FILE_DIR`]`/hook--.bin` (written and unlinked -/// within this function). Contains the 140-byte hook body at +/// * `hook_page`: `PROT_READ | PROT_EXEC`, mapped from an anonymous +/// `memfd` the tracee created. Contains the 140-byte hook body at /// [`HOOK_BODY_OFFSET`]; zero padding elsewhere. /// -/// The file-backed page is what lets stage-B run on OEMs that strip -/// `process:execmem` from init's SELinux domain: init keeps the -/// `adb_data_file:file { execute map }` allow rule regardless, so a -/// file written under [`HOOK_FILE_DIR`] (auto-labels as -/// `adb_data_file`) is mmap'able `PROT_R|X` inside init without an -/// SELinux denial. The host file is unlinked immediately after mmap; -/// the kernel retains the mapping via the deleted-inode mechanism. +/// The memfd relabel is what lets stage-B run on OEMs that strip +/// `process:execmem` from init's SELinux domain: the fd is relabelled to +/// the libc.so context init may execute, via `setfilecon` on +/// `/proc//fd/`, so init maps it `PROT_R|X` without an +/// `execmem` or file-execute denial. Nothing reaches disk, so there is no +/// residue to unlink. /// /// # Error cleanup /// /// Any error after `lock_list_page` is mapped issues a best-effort /// remote `munmap` of `lock_list_page` under the same attach window, -/// so the tracee does not leak a 4 KiB page on cold paths. Host file -/// cleanup happens inside the file-backed installer regardless of -/// outcome. +/// so the tracee does not leak a 4 KiB page on cold paths. The memfd is +/// closed in the tracee inside the installer on every path. /// /// # Latency /// /// The install runs inside one `RemoteAttach` window. Observed /// wall-clock on a modern ARM64 handset (Snapdragon-class SoC, bionic /// libc.so ~1.2 MiB, ~5000 `.dynsym` entries): 20-50 ms for the full -/// sequence — `/proc//maps` + libc ELF parse + GNU_HASH walk + -/// lock-list mmap + prologue snapshot + host file write + remote -/// openat / mmap / close + unlink. Any thread that blocks on init -/// for a property write during this window waits out the full stall. +/// sequence of `/proc//maps`, libc ELF parse, GNU_HASH walk, +/// lock-list mmap, prologue snapshot, remote memfd_create, procfs write, +/// relabel, and remote mmap / close. Any thread that blocks on init for +/// a property write during this window waits out the full stall. +/// +/// # Throttle +/// +/// After [`MAX_INSTALL_HARD_FAILURES`] hard failures in one process the +/// installer refuses further attempts before the M1 identity check or any +/// ptrace attach, so a load that bootloops init cannot be retried into the +/// ground. pub fn install_init_hook(pid: libc::pid_t) -> Result { + if INSTALL_HARD_FAILURES.load(Ordering::Relaxed) >= MAX_INSTALL_HARD_FAILURES { + return Err(Error::HookInstallFailed(format!( + "install throttled after {MAX_INSTALL_HARD_FAILURES} hard failures this boot" + ))); + } + install_init_hook_inner(pid).inspect_err(|_| { + INSTALL_HARD_FAILURES.fetch_add(1, Ordering::Relaxed); + }) +} + +/// Uncounted install body. [`install_init_hook`] wraps this with the per-boot +/// hard-failure throttle; every `Err` it returns advances the counter that +/// eventually trips the guard. +fn install_init_hook_inner(pid: libc::pid_t) -> Result { // M1: reject a non-init PID-1 stand-in before attaching or poking. seal::arena::verify_init_identity(pid)?; - let guard = seal::arena::RemoteAttach::new(pid) + let attach = seal::arena::RemoteAttach::new(pid) .map_err(|e| Error::HookInstallFailed(format!("stage-B: attach: {e}")))?; + let handle = install_init_hook_locked(&attach)?; + + attach + .detach() + .map_err(|e| Error::HookInstallFailed(format!("stage-B: detach: {e}")))?; + + Ok(handle) +} + +/// Stage-B install body under a caller-held [`RemoteAttach`]. +/// +/// The attach/detach lives in the caller so the first-seal path +/// ([`install_and_seal_first`]) can run install + trampoline + first append +/// under one thread-group stop (audit M5) instead of three. The identity guard +/// and the per-boot throttle stay in the public wrappers; this body assumes a +/// live, identity-checked attach on init. +fn install_init_hook_locked(attach: &seal::arena::RemoteAttach) -> Result { + let pid = attach.pid(); + let (libc_base, libc_end, target_fn) = stage_a_locked(pid)?; - // SAFETY: `guard` holds the tracee ptrace-stopped; `libc_base..libc_end` + // SAFETY: `attach` holds the tracee ptrace-stopped; `libc_base..libc_end` // is the `r-xp` row just returned by stage-A on the same process. let scratch_pc = unsafe { derive_libc_scratch_pc(pid, libc_base, libc_end) }?; let lock_list_page = remote_mmap_anon_rw_page(pid, scratch_pc)?; - let saved_prologue = - match write_sentinel_and_snapshot_prologue(pid, lock_list_page, target_fn) { - Ok(p) => p, - Err(e) => { - best_effort_remote_munmap(pid, scratch_pc, lock_list_page); - return Err(e); - } - }; + let saved_prologue = match write_sentinel_and_snapshot_prologue(pid, lock_list_page, target_fn) + { + Ok(p) => p, + Err(e) => { + best_effort_remote_munmap(pid, scratch_pc, lock_list_page); + return Err(e); + } + }; let body_bytes = build_hook_body_bytes(saved_prologue, lock_list_page, target_fn + 16); - let hook_page = - match install_file_backed_hook_page(pid, scratch_pc, lock_list_page, &body_bytes) { - Ok(addr) => addr, - Err(e) => { - best_effort_remote_munmap(pid, scratch_pc, lock_list_page); - return Err(e); - } - }; - - guard - .detach() - .map_err(|e| Error::HookInstallFailed(format!("stage-B: detach: {e}")))?; + let hook_page = match install_memfd_hook_page(pid, scratch_pc, lock_list_page, &body_bytes) { + Ok(addr) => addr, + Err(e) => { + best_effort_remote_munmap(pid, scratch_pc, lock_list_page); + return Err(e); + } + }; Ok(HookHandle { pid, @@ -382,6 +445,163 @@ pub fn install_init_hook(pid: libc::pid_t) -> Result { libc_end, scratch_pc, trampoline_installed: false, + kmsg_fds: seal::kmsg_observer::discover_kmsg_fds(pid), + }) +} + +/// Outcome of [`install_and_seal_first`]. +/// +/// Once [`install_trampoline_locked`] flips the trampoline live, init is +/// modified and the handle MUST be recorded by the caller — even if the first +/// append then fails — so the next seal retries the append against the live +/// hook instead of re-installing over already-trampolined init. A re-install +/// would snapshot the trampoline itself as the "original" prologue (see +/// `write_sentinel_and_snapshot_prologue`) and corrupt the restore path. +pub(crate) enum FirstSeal { + /// Hook installed and the first property appended to the lock list. + Sealed(HookHandle), + /// Hook installed (trampoline live) but the first append failed. The caller + /// must still record the handle; the `Error` is the append failure to + /// surface to the user. + InstalledNotSealed(HookHandle, Error), +} + +/// Install the per-prop hook AND seal the first property under one +/// thread-group stop on `pid` (audit M5). +/// +/// The seal orchestrator used to issue three separate SEIZE/INTERRUPT/DETACH +/// cycles on PID 1 for the first seal — one each for [`install_init_hook`], +/// [`install_trampoline`], and [`seal_prop`]. Folding them under a single +/// [`RemoteAttach`] shrinks the interval init's siblings spend frozen and the +/// number of attach/detach races on PID 1. Subsequent seals (hook already +/// installed) keep using [`seal_prop`] with its own short window. +/// +/// The per-boot install throttle counts failures to *install* the hook +/// (identity, attach, stage-A, trampoline) — the steps that can bootloop init — +/// so a load that bootloops the trampoline patch cannot be retried into the +/// ground. A failure of only the first append does NOT count: the hook is +/// installed and returned via [`FirstSeal::InstalledNotSealed`] so the next +/// seal retries the append. +pub(crate) fn install_and_seal_first(pid: libc::pid_t, name: &str) -> Result { + if name.as_bytes().contains(&0) { + return Err(Error::InvalidKey); + } + if INSTALL_HARD_FAILURES.load(Ordering::Relaxed) >= MAX_INSTALL_HARD_FAILURES { + return Err(Error::HookInstallFailed(format!( + "install throttled after {MAX_INSTALL_HARD_FAILURES} hard failures this boot" + ))); + } + install_and_seal_first_inner(pid, name).inspect_err(|_| { + INSTALL_HARD_FAILURES.fetch_add(1, Ordering::Relaxed); + }) +} + +/// Uncounted first-seal body. [`install_and_seal_first`] wraps this with the +/// per-boot throttle; only an `Err` here (an install failure) advances the +/// counter — an installed-but-unsealed outcome is `Ok`. +fn install_and_seal_first_inner(pid: libc::pid_t, name: &str) -> Result { + // M1: reject a non-init PID-1 stand-in before attaching or poking. + seal::arena::verify_init_identity(pid)?; + + let attach = seal::arena::RemoteAttach::new(pid) + .map_err(|e| Error::HookInstallFailed(format!("stage-B: attach: {e}")))?; + + // Phase 1a — install the hook page. On failure `install_init_hook_locked` + // has already freed its own partial mappings; nothing to preserve. + let mut handle = match install_init_hook_locked(&attach) { + Ok(handle) => handle, + Err(e) => { + if let Err(detach_err) = attach.detach() { + eprintln!("resetprop: detach after first-seal install error failed: {detach_err}"); + } + return Err(e); + } + }; + + // Phase 1b — patch the trampoline. On failure it has reverted its own + // partial write, so init is unmodified. Detach FIRST so the dropped + // handle's `drop_best_effort` can re-attach and free the two install pages + // (its nested attach would fail if init were still seized here), then + // propagate the `Err` (which the throttle counts). + if let Err(e) = install_trampoline_locked(&attach, &mut handle) { + if let Err(detach_err) = attach.detach() { + eprintln!("resetprop: detach after first-seal trampoline error failed: {detach_err}"); + } + return Err(e); + } + + // Phase 2 — first append. The trampoline is live now, so the handle MUST + // reach the caller regardless of the append outcome; a lost handle here + // would make the next seal re-install over trampolined init. Detach runs + // unconditionally so init is never left stopped; a detach failure (init + // frozen) outranks the append result and surfaces as a hard `Err`. + let append = seal_prop_locked(&attach, &mut handle, name); + attach + .detach() + .map_err(|e| Error::HookInstallFailed(format!("stage-B: detach: {e}")))?; + + match append { + Ok(()) => Ok(FirstSeal::Sealed(handle)), + Err(e) => Ok(FirstSeal::InstalledNotSealed(handle, e)), + } +} + +/// Result of a [`check_init_hook`] dry-run: the stage-A / scratch-slot facts +/// that the real install would compute, surfaced for an operator to validate +/// on-device before committing to the trampoline patch. +/// +/// Every field is a pure observation — no page was mapped, no prologue byte +/// was written. `target_fn` is `__system_property_update` resolved in init's +/// running libc; `scratch_pc` is the 8-byte-aligned slot the install would +/// reuse for `remote_syscall_via_poke`. +pub struct CheckReport { + pub libc_base: u64, + pub libc_end: u64, + pub target_fn: u64, + pub scratch_pc: u64, +} + +/// Dry-run the per-prop hook install: resolve everything the real path reads, +/// write nothing. +/// +/// Runs ONLY the no-side-effect sub-ops of [`install_init_hook`] — identity +/// check, stage-A (`/proc//maps` + libc ELF + symbol resolution), and +/// scratch-slot derivation — under the same single `RemoteAttach` window, then +/// detaches. The attach is `PTRACE_SEIZE + INTERRUPT` (a group-stop control +/// op, not a memory write); it exists so `derive_libc_scratch_pc`'s +/// `process_vm_readv` snapshot of libc.text cannot race a concurrent +/// APEX/Mainline hot-swap. No `remote_syscall_via_poke`, `ptrace_poketext`, or +/// `write_remote` runs on this path, so init's text and registers are left +/// byte-for-byte unchanged and no trampoline is installed. +/// +/// Lets an operator confirm Tier B will resolve cleanly (no `SymbolNotFound`, +/// no missing libc row, a usable scratch slot) on a given device before the +/// real `--seal` pokes PID 1. +pub fn check_init_hook(pid: libc::pid_t) -> Result { + // M1: reject a non-init PID-1 stand-in before attaching. + seal::arena::verify_init_identity(pid)?; + + let guard = seal::arena::RemoteAttach::new(pid) + .map_err(|e| Error::HookInstallFailed(format!("stage-B: attach: {e}")))?; + + let (libc_base, libc_end, target_fn) = stage_a_locked(pid)?; + + // SAFETY: `guard` holds the tracee ptrace-stopped; `libc_base..libc_end` + // is the `r-xp` row just returned by stage-A on the same process. + let scratch_pc = unsafe { derive_libc_scratch_pc(pid, libc_base, libc_end) }?; + + // No writes happened — detach and report. (Drop would resume the group + // too, but detaching explicitly keeps the no-side-effect contract obvious + // and surfaces a detach error rather than swallowing it.) + guard + .detach() + .map_err(|e| Error::HookInstallFailed(format!("stage-B: detach: {e}")))?; + + Ok(CheckReport { + libc_base, + libc_end, + target_fn, + scratch_pc, }) } @@ -410,8 +630,9 @@ fn remote_mmap_anon_rw_page(pid: libc::pid_t, scratch_pc: u64) -> Result { Ok(ret as u64) } -/// Write the 4-byte empty-list sentinel to `lock_list_page + 0` and -/// snapshot the 16-byte prologue at `target_fn`. +/// Write the empty-list sentinel to `lock_list_page + 0` (one NUL marks the +/// list empty; the write zeroes a 4-byte word) and snapshot the 16-byte +/// prologue at `target_fn`. fn write_sentinel_and_snapshot_prologue( pid: libc::pid_t, lock_list_page: u64, @@ -428,46 +649,86 @@ fn write_sentinel_and_snapshot_prologue( Ok(saved_prologue) } -/// Materialise the hook body as a file-backed `PROT_READ | PROT_EXEC` -/// mapping in the tracee, using `stage_page + PATH_STAGE_OFFSET` as a -/// scratch buffer for the `openat` pathname. +/// Name attached to the in-init memfd; surfaces only as the basename in +/// `/proc//fd/` and `/proc//maps`. Cosmetic, not a real path. +const MEMFD_NAME: &[u8] = b"resetprop-hook"; + +/// Materialise the hook body as a `PROT_READ | PROT_EXEC` mapping in the +/// tracee, backed by an anonymous in-memory file. No bytes reach disk. /// -/// Host file is always removed before this returns (success or failure); -/// init retains the successful mapping via the deleted-inode mechanism. -fn install_file_backed_hook_page( +/// Ported from injectrc's init_injector: the tracee creates a `memfd`, this +/// process fills it through `/proc//fd/` and relabels it to the +/// libc.so SELinux context, then the tracee maps and closes its own fd. The +/// mapping outlives the close via the fd's anonymous inode, so there is no +/// on-disk residue and no `adb_data_file:file execute` denial to dodge. +fn install_memfd_hook_page( pid: libc::pid_t, scratch_pc: u64, stage_page: u64, body_bytes: &[u8], ) -> Result { - let host_path = write_host_hook_file(pid, body_bytes)?; - let result = mmap_file_backed_in_tracee(pid, scratch_pc, stage_page, &host_path); - let _ = std::fs::remove_file(&host_path); - result + let page = layout_hook_page_bytes(body_bytes)?; + let name_vaddr = stage_cstr_in_tracee(pid, stage_page, MEMFD_NAME)?; + run_memfd_install( + name_vaddr, + // SAFETY: tracee is ptrace-stopped under the caller's attach; + // `scratch_pc` is a libc.text r-xp slot valid for the POKE-driven + // remote syscall ABI. + |syscall_no, args| unsafe { remote_syscall_via_poke(pid, scratch_pc, syscall_no, args) }, + |memfd| publish_page_to_memfd(pid, memfd, &page), + ) } -/// Write the 4 KiB hook page image to disk under [`HOOK_FILE_DIR`]. -/// -/// File layout: zero-padded 4 KiB, with `body_bytes` placed at -/// [`HOOK_BODY_OFFSET`]. Auto-labels as `adb_data_file` so init can -/// open + mmap `PROT_R|X` without an SELinux denial. -fn write_host_hook_file(pid: libc::pid_t, body_bytes: &[u8]) -> Result { - let dir = Path::new(HOOK_FILE_DIR); - std::fs::create_dir_all(dir).map_err(|e| { - Error::HookInstallFailed(format!("stage-B: mkdir_p {}: {e}", dir.display())) - })?; +/// Drive the memfd install sequence: remote `memfd_create`, host-side +/// `publish` (fill + relabel the fd), remote `mmap` `PROT_R|X`, remote +/// `close`. Generic over the remote-syscall executor and `publish` so the +/// sequence is exercised off-device with both mocked. +fn run_memfd_install(name_vaddr: u64, mut remote_syscall: S, publish: P) -> Result +where + S: FnMut(u64, [u64; 6]) -> Result, + P: FnOnce(u64) -> Result<()>, +{ + let memfd = remote_syscall(NR_MEMFD_CREATE, [name_vaddr, MFD_CLOEXEC, 0, 0, 0, 0])?; + if memfd < 0 { + return Err(Error::HookInstallFailed(format!( + "stage-B: memfd_create returned -errno={}", + -memfd + ))); + } + let memfd = memfd as u64; - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map_err(|e| Error::HookInstallFailed(format!("stage-B: clock skew: {e}")))? - .as_nanos(); + publish(memfd)?; - let host_path = dir.join(format!("hook-{pid}-{nanos}.bin")); - let file_bytes = layout_hook_page_bytes(body_bytes)?; - std::fs::write(&host_path, &file_bytes).map_err(|e| { - Error::HookInstallFailed(format!("stage-B: write {}: {e}", host_path.display())) - })?; - Ok(host_path) + let mapped = remote_syscall(NR_MMAP, [0, HOOK_PAGE_SIZE, PROT_RX, MAP_PRIVATE, memfd, 0])?; + if (-4095..=-1).contains(&mapped) { + let _ = remote_syscall(NR_CLOSE, [memfd, 0, 0, 0, 0, 0]); + return Err(Error::HookInstallFailed(format!( + "stage-B: memfd mmap returned -errno={}", + -mapped + ))); + } + + let _ = remote_syscall(NR_CLOSE, [memfd, 0, 0, 0, 0, 0]); + Ok(mapped as u64) +} + +/// `/proc//fd/`: the procfs handle this process writes the page +/// image through and relabels. +fn proc_fd_path(pid: libc::pid_t, fd: u64) -> String { + format!("/proc/{pid}/fd/{fd}") +} + +/// Fill init's `memfd` with the 4 KiB page image through `/proc//fd/` +/// and relabel it to the libc.so SELinux context so init can map it `PROT_R|X`. +/// Runs while the tracee is ptrace-stopped under the caller's attach. +fn publish_page_to_memfd(pid: libc::pid_t, memfd: u64, page: &[u8]) -> Result<()> { + let fd_path = proc_fd_path(pid, memfd); + std::fs::write(&fd_path, page) + .map_err(|e| Error::HookInstallFailed(format!("stage-B: write {fd_path}: {e}")))?; + let c_path = CString::new(fd_path.clone()) + .map_err(|e| Error::HookInstallFailed(format!("stage-B: nul in {fd_path}: {e}")))?; + seal::selinux::relabel_to_libc_context(&c_path) + .map_err(|e| Error::HookInstallFailed(format!("stage-B: relabel memfd: {e}"))) } /// Pure helper: assemble a 4 KiB page image with `body_bytes` placed at @@ -475,9 +736,9 @@ fn write_host_hook_file(pid: libc::pid_t, body_bytes: &[u8]) -> Result /// would overflow the page. fn layout_hook_page_bytes(body_bytes: &[u8]) -> Result> { let body_start = HOOK_BODY_OFFSET as usize; - let body_end = body_start.checked_add(body_bytes.len()).ok_or_else(|| { - Error::HookInstallFailed("stage-B: hook body offset overflow".into()) - })?; + let body_end = body_start + .checked_add(body_bytes.len()) + .ok_or_else(|| Error::HookInstallFailed("stage-B: hook body offset overflow".into()))?; if body_end > HOOK_PAGE_SIZE as usize { return Err(Error::HookInstallFailed(format!( "stage-B: hook body ({} bytes) exceeds page capacity at offset {}", @@ -490,93 +751,30 @@ fn layout_hook_page_bytes(body_bytes: &[u8]) -> Result> { Ok(bytes) } -/// Open the host file inside the tracee, map it `PROT_R|X`, and close -/// the fd. Caller unlinks the host file. -fn mmap_file_backed_in_tracee( - pid: libc::pid_t, - scratch_pc: u64, - stage_page: u64, - host_path: &Path, -) -> Result { - stage_path_in_tracee(pid, stage_page, host_path)?; - let path_vaddr = stage_page + PATH_STAGE_OFFSET; - - let fd = remote_openat_rdonly(pid, scratch_pc, path_vaddr)?; - let mmap_result = remote_mmap_file_backed_rx(pid, scratch_pc, fd); - - // SAFETY: best-effort close; tracee still stopped. Close errors are - // diagnostic-only because the fd leaks to init's fd table at worst, - // which self-reclaims on next exec. - let _ = unsafe { - remote_syscall_via_poke(pid, scratch_pc, NR_CLOSE, [fd, 0, 0, 0, 0, 0]) - }; - mmap_result -} - -/// POKE the NUL-terminated host path into the tracee at -/// `stage_page + PATH_STAGE_OFFSET`. -fn stage_path_in_tracee(pid: libc::pid_t, stage_page: u64, host_path: &Path) -> Result<()> { - let path_bytes = host_path.as_os_str().as_bytes(); +/// POKE a NUL-terminated C string into the tracee at `stage_page + +/// PATH_STAGE_OFFSET` and return its tracee vaddr. Stages the memfd name; the +/// bytes are abandoned once `memfd_create` reads them. The slot sits above +/// [`LOCK_LIST_CAPACITY`] (const-asserted below it), so the in-init lock +/// walker never reaches these bytes. +fn stage_cstr_in_tracee(pid: libc::pid_t, stage_page: u64, bytes: &[u8]) -> Result { let max_bytes = HOOK_PAGE_SIZE .saturating_sub(PATH_STAGE_OFFSET) .saturating_sub(1); - if (path_bytes.len() as u64) > max_bytes { + if (bytes.len() as u64) > max_bytes { return Err(Error::HookInstallFailed(format!( - "stage-B: host path too long for stage slot ({} bytes, max {})", - path_bytes.len(), + "stage-B: staged string too long ({} bytes, max {})", + bytes.len(), max_bytes ))); } - let mut nul_path = Vec::with_capacity(path_bytes.len() + 1); - nul_path.extend_from_slice(path_bytes); - nul_path.push(0); + let vaddr = stage_page + PATH_STAGE_OFFSET; + let mut nul = Vec::with_capacity(bytes.len() + 1); + nul.extend_from_slice(bytes); + nul.push(0); // SAFETY: `stage_page` is the fresh PROT_RW anon page; tracee stopped. - unsafe { write_remote(pid, stage_page + PATH_STAGE_OFFSET, &nul_path) } - .map_err(|e| Error::HookInstallFailed(format!("stage-B: stage path: {e}"))) -} - -/// `openat(AT_FDCWD, path_vaddr, O_RDONLY, 0)` in the tracee. -fn remote_openat_rdonly(pid: libc::pid_t, scratch_pc: u64, path_vaddr: u64) -> Result { - // SAFETY: scratch_pc invariants as in `remote_mmap_anon_rw_page`. - let ret = unsafe { - remote_syscall_via_poke( - pid, - scratch_pc, - NR_OPENAT, - [AT_FDCWD, path_vaddr, O_RDONLY, 0, 0, 0], - ) - } - .map_err(|e| Error::HookInstallFailed(format!("stage-B: openat: {e}")))?; - - if ret < 0 { - return Err(Error::HookInstallFailed(format!( - "stage-B: openat returned -errno={}", - -ret - ))); - } - Ok(ret as u64) -} - -/// `mmap(NULL, 4096, PROT_R|X, MAP_PRIVATE, fd, 0)` in the tracee. -fn remote_mmap_file_backed_rx(pid: libc::pid_t, scratch_pc: u64, fd: u64) -> Result { - // SAFETY: scratch_pc invariants as in `remote_mmap_anon_rw_page`. - let ret = unsafe { - remote_syscall_via_poke( - pid, - scratch_pc, - NR_MMAP, - [0, HOOK_PAGE_SIZE, PROT_RX, MAP_PRIVATE, fd, 0], - ) - } - .map_err(|e| Error::HookInstallFailed(format!("stage-B: mmap file-backed: {e}")))?; - - if (-4095..=-1).contains(&ret) { - return Err(Error::HookInstallFailed(format!( - "stage-B: mmap file-backed returned -errno={}", - -ret - ))); - } - Ok(ret as u64) + unsafe { write_remote(pid, vaddr, &nul) } + .map_err(|e| Error::HookInstallFailed(format!("stage-B: stage string: {e}")))?; + Ok(vaddr) } /// Best-effort remote `munmap` for cleanup paths. Errors are swallowed. @@ -821,9 +1019,7 @@ pub(crate) mod encoder { } } -// ───────────────────────────────────────────────────────────────────────────── // P04 T2 — pure hook-body encoder -// ───────────────────────────────────────────────────────────────────────────── /// Canonical 35-word (140-byte) hook-body layout. /// @@ -965,26 +1161,19 @@ pub fn build_hook_body_bytes( body.iter().flat_map(|w| w.to_le_bytes()).collect() } -// ───────────────────────────────────────────────────────────────────────────── // P04 T3 — trampoline installer + i-cache sync -// ───────────────────────────────────────────────────────────────────────────── /// Execute a single `isb` in the tracee by staging `isb ; brk #0` at -/// `scratch_pc`, flipping `pc`, resuming, waiting for the brk trap, and -/// restoring the original word and registers. -/// -/// Mirrors the structural skeleton of -/// [`crate::seal::ptrace::remote_syscall_via_poke`] (at `ptrace.rs:627-705`) -/// but carries an instruction payload rather than a syscall payload — the -/// tracee never enters the kernel, so there is no `x8`, no args, no `x0` -/// decode. This is the fallback path for `install_trampoline`'s i-cache -/// sync when `membarrier(PRIVATE_EXPEDITED_SYNC_CORE)` returns `EINVAL` -/// (cmd missing) or `EPERM` (registration missing). -/// -/// Errors after the POKE (wait_stop, regs restore) trigger a best-effort -/// restore of both the scratch word and the saved registers before the -/// original cause propagates; this matches the pattern in -/// `remote_syscall_via_poke` so libc.text is never left poisoned. +/// `scratch_pc` through the shared [`run_remote_payload`] skeleton: it flips +/// `pc` to the staged blob, resumes, waits for the brk, and restores the +/// original word and registers (best-effort on every error path). +/// +/// Instruction payload, not a syscall: the tracee never enters the kernel, so +/// there is no syscall-number register, no args, and no return to decode — the +/// post-trap regs are discarded. This is the fallback path for +/// `install_trampoline`'s i-cache sync when +/// `membarrier(PRIVATE_EXPEDITED_SYNC_CORE)` returns `EINVAL` (cmd missing) or +/// `EPERM` (registration missing), and the i-cache resync on the revert path. /// /// # Safety /// @@ -994,47 +1183,11 @@ pub fn build_hook_body_bytes( unsafe fn execute_remote_isb(pid: libc::pid_t, scratch_pc: u64) -> Result<()> { let payload: u64 = (encoder::ISB_SY as u64) | ((encoder::BRK_0 as u64) << 32); - let saved_word = ptrace_peektext(pid, scratch_pc)?; - ptrace_poketext(pid, scratch_pc, payload)?; - - let saved_regs = getregset(pid)?; - let mut work = saved_regs; - set_pc(&mut work, scratch_pc); - setregset(pid, &work)?; - - // SAFETY: `libc::ptrace` FFI; `addr` / `data` are NULL per PTRACE_CONT - // contract; tracee is ptrace-stopped via the caller's RemoteAttach. - let rc = unsafe { - libc::ptrace( - PTRACE_CONT as _, - pid, - std::ptr::null_mut::(), - std::ptr::null_mut::(), - ) - }; - if rc == -1 { - let _ = ptrace_poketext(pid, scratch_pc, saved_word); - let _ = setregset(pid, &saved_regs); - return Err(Error::PtraceOp(std::io::Error::last_os_error())); - } - - let wait_result = wait_stop(pid, 0); - if wait_result.is_err() { - let _ = ptrace_poketext(pid, scratch_pc, saved_word); - let _ = setregset(pid, &saved_regs); - } - wait_result?; - - // Success-path restore is symmetric: capture both results before the - // first `?` so that a failure in `setregset` still triggers the - // `ptrace_poketext` scratch-word restore. The P02 fix at commit - // 910ce69 applied the same pattern to `remote_syscall_via_poke`; - // mirroring it here ensures libc.text cannot be left holding the - // `isb; brk` staged payload if the reg-restore FFI fails. - let reg_res = setregset(pid, &saved_regs); - let poke_res = ptrace_poketext(pid, scratch_pc, saved_word); - reg_res?; - poke_res?; + // SAFETY: forwards the caller's RemoteAttach + scratch_pc contract to the + // shared injector; `set_pc` is the only register staged (no syscall ABI). + unsafe { + run_remote_payload(pid, scratch_pc, payload, |work| set_pc(work, scratch_pc))?; + } Ok(()) } @@ -1043,10 +1196,16 @@ unsafe fn execute_remote_isb(pid: libc::pid_t, scratch_pc: u64) -> Result<()> { /// Called only from `install_trampoline`'s error paths after the 16-byte /// trampoline POKE sequence has begun. Restores the original prologue by /// decoding `saved_prologue` as two little-endian `u64` words and issuing -/// two `PTRACE_POKEDATA` writes. Errors are logged via `eprintln!` and -/// never returned — the caller is already propagating the original cause -/// and a second error would only obscure it. -fn revert_trampoline(pid: libc::pid_t, target_fn: u64, saved_prologue: &[u8; 16]) { +/// two `PTRACE_POKEDATA` writes, then re-syncs the i-cache (L1) so a core +/// cannot keep running the half-written trampoline still cached against the +/// just-restored prologue — the same resync `install_trampoline` runs after a +/// successful write. Errors are logged via `eprintln!` and never returned — +/// the caller is already propagating the original cause and a second error +/// would only obscure it. +/// +/// Caller holds the `RemoteAttach` on `pid` (tracee stopped); `scratch_pc` is +/// the cached libc.text slot the install used for its own i-cache sync. +fn revert_trampoline(pid: libc::pid_t, target_fn: u64, scratch_pc: u64, saved_prologue: &[u8; 16]) { let lo = u64::from_le_bytes([ saved_prologue[0], saved_prologue[1], @@ -1073,6 +1232,16 @@ fn revert_trampoline(pid: libc::pid_t, target_fn: u64, saved_prologue: &[u8; 16] if let Err(e) = ptrace_poketext(pid, target_fn + 8, hi) { eprintln!("resetprop: revert_trampoline hi word failed: {e}"); } + // L1: the writes above restore the prologue bytes, but a core may still + // hold the half-written trampoline in its i-cache; resync so it re-fetches + // the restored prologue. Best-effort — the caller propagates the original + // failure. + // SAFETY: `pid` is ptrace-stopped under the caller's RemoteAttach (this fn + // runs only on `install_trampoline`'s in-attach error path); `scratch_pc` + // is the libc.text slot the install used for its own membarrier/ISB sync. + if let Err(e) = unsafe { execute_remote_isb(pid, scratch_pc) } { + eprintln!("resetprop: revert_trampoline i-cache resync failed: {e}"); + } } /// Compare the trampoline bytes read back from the tracee against the two @@ -1137,16 +1306,44 @@ fn verify_trampoline_readback(word_lo: u64, word_hi: u64, readback: &[u8; 16]) - /// before the error propagates, so the tracee is not left running a /// half-written trampoline. pub fn install_trampoline(handle: &mut HookHandle) -> Result<()> { - // Step 1: compute the trampoline's branch target. The 140-byte hook body - // already lives at `hook_page + HOOK_BODY_OFFSET` — `install_init_hook` - // materialised it as a file-backed PROT_R|X mapping, so no runtime - // body write is required here. - let hook_body_vaddr = handle.hook_page + HOOK_BODY_OFFSET; - - // Step 2: acquire attach RAII guard. let attach = seal::arena::RemoteAttach::new(handle.pid) .map_err(|e| Error::HookInstallFailed(format!("install_trampoline: attach: {e}")))?; + match install_trampoline_locked(&attach, handle) { + Ok(()) => attach + .detach() + .map_err(|e| Error::HookInstallFailed(format!("install_trampoline: detach: {e}"))), + Err(e) => { + // `install_trampoline_locked` already reverted the partial write + // under this attach window; detach and surface the original cause. + if let Err(detach_err) = attach.detach() { + eprintln!("resetprop: detach after install error failed: {detach_err}"); + } + Err(e) + } + } +} + +/// Trampoline-install body under a caller-held [`RemoteAttach`] (audit M5). +/// +/// Writes the 16-byte trampoline and runs the i-cache sync, reverts the partial +/// write on any post-write failure (the caller's attach window stays live for +/// the revert), then flips `handle.trampoline_installed`. The attach/detach is +/// the caller's: [`install_trampoline`] opens one for the standalone path; +/// [`install_and_seal_first`] shares one window across the whole first seal so +/// PID 1 is stopped once, not three times. +fn install_trampoline_locked( + attach: &seal::arena::RemoteAttach, + handle: &mut HookHandle, +) -> Result<()> { + let pid = attach.pid(); + + // Step 1: compute the trampoline's branch target. The 140-byte hook body + // already lives at `hook_page + HOOK_BODY_OFFSET`; `install_init_hook` + // mapped it from init's memfd PROT_R|X, so no runtime body write is + // required here. + let hook_body_vaddr = handle.hook_page + HOOK_BODY_OFFSET; + // Steps 3-5 run under the attach. A failure after the trampoline write // has begun must revert the trampoline before the error unwinds. Using // a closure lets `?` propagate cleanly while a trailing `match` runs @@ -1168,10 +1365,10 @@ pub fn install_trampoline(handle: &mut HookHandle) -> Result<()> { // /proc/1/task tid), so no sibling executes while these words land. let word_lo = (encoder::LDR_X16_PC8 as u64) | ((encoder::BR_X16 as u64) << 32); let word_hi = hook_body_vaddr; - ptrace_poketext(handle.pid, handle.target_fn, word_lo).map_err(|e| { + ptrace_poketext(pid, handle.target_fn, word_lo).map_err(|e| { Error::HookInstallFailed(format!("install_trampoline: poke tramp lo: {e}")) })?; - ptrace_poketext(handle.pid, handle.target_fn + 8, word_hi).map_err(|e| { + ptrace_poketext(pid, handle.target_fn + 8, word_hi).map_err(|e| { Error::HookInstallFailed(format!("install_trampoline: poke tramp hi: {e}")) })?; @@ -1187,7 +1384,7 @@ pub fn install_trampoline(handle: &mut HookHandle) -> Result<()> { // needs read permission only, and the tracee is ptrace-stopped via // `attach` so the read cannot race a concurrent write. let mut readback = [0u8; 16]; - unsafe { read_remote(handle.pid, handle.target_fn, &mut readback) }.map_err(|e| { + unsafe { read_remote(pid, handle.target_fn, &mut readback) }.map_err(|e| { Error::HookInstallFailed(format!("install_trampoline: trampoline read-back: {e}")) })?; verify_trampoline_readback(word_lo, word_hi, &readback)?; @@ -1214,12 +1411,16 @@ pub fn install_trampoline(handle: &mut HookHandle) -> Result<()> { let register_ret = unsafe { remote_syscall_via_poke( - handle.pid, + pid, handle.scratch_pc, NR_MEMBARRIER, [ MEMBARRIER_CMD_REGISTER_PRIVATE_EXPEDITED_SYNC_CORE, - 0, 0, 0, 0, 0, + 0, + 0, + 0, + 0, + 0, ], ) } @@ -1231,7 +1432,7 @@ pub fn install_trampoline(handle: &mut HookHandle) -> Result<()> { // SAFETY: same invariants as the register call above — `attach` // holds the tracee stopped; `handle.scratch_pc` is the cached // libc.text slot. - return unsafe { execute_remote_isb(handle.pid, handle.scratch_pc) }; + return unsafe { execute_remote_isb(pid, handle.scratch_pc) }; } if register_ret != 0 { return Err(Error::HookInstallFailed(format!( @@ -1245,7 +1446,7 @@ pub fn install_trampoline(handle: &mut HookHandle) -> Result<()> { // the cmd) is treated as a hard failure. let sync_ret = unsafe { remote_syscall_via_poke( - handle.pid, + pid, handle.scratch_pc, NR_MEMBARRIER, [MEMBARRIER_CMD_PRIVATE_EXPEDITED_SYNC_CORE, 0, 0, 0, 0, 0], @@ -1257,7 +1458,7 @@ pub fn install_trampoline(handle: &mut HookHandle) -> Result<()> { if sync_ret == einval_neg { // SAFETY: same invariants as the register call above. - return unsafe { execute_remote_isb(handle.pid, handle.scratch_pc) }; + return unsafe { execute_remote_isb(pid, handle.scratch_pc) }; } if sync_ret != 0 { return Err(Error::HookInstallFailed(format!( @@ -1268,27 +1469,22 @@ pub fn install_trampoline(handle: &mut HookHandle) -> Result<()> { })(); if let Err(e) = trampoline_result { - revert_trampoline(handle.pid, handle.target_fn, &handle.saved_prologue); - if let Err(detach_err) = attach.detach() { - eprintln!("resetprop: detach after install error failed: {detach_err}"); - } + // Revert under the caller's still-live attach window; the caller detaches. + revert_trampoline( + pid, + handle.target_fn, + handle.scratch_pc, + &handle.saved_prologue, + ); return Err(e); } - // Step 6: flip typestate so Drop skips the munmap. + // Flip typestate so Drop skips the munmap. handle.trampoline_installed = true; - - // Step 7: explicit detach surfaces any failure here at the install site. - attach - .detach() - .map_err(|e| Error::HookInstallFailed(format!("install_trampoline: detach: {e}")))?; - Ok(()) } -// ───────────────────────────────────────────────────────────────────────────── // P04 T4 — lock-list mechanics (pure helpers + public seal / unseal) -// ───────────────────────────────────────────────────────────────────────────── /// Append `name` + entry-NUL + empty-sentinel-NUL to a lock-list buffer in /// the atomic-append write order. Returns the new `cur_len`, i.e. the byte @@ -1309,9 +1505,11 @@ pub fn install_trampoline(handle: &mut HookHandle) -> Result<()> { /// /// Returns `None` if the new sentinel offset would lie at or past /// `capacity`; callers surface this as [`Error::HookInstallFailed`]. The -/// bounds check uses `>= capacity` rather than `> capacity - 1` because a -/// sentinel at offset `capacity` would be the first byte of the hook body -/// at `HOOK_BODY_OFFSET` and clobber init's trampoline target. +/// bound is `>= capacity` because the sentinel occupies the byte it names, +/// so offset `capacity` is the first byte past the lock list's reserved +/// region. Callers pass [`LOCK_LIST_CAPACITY`], which sits below +/// [`PATH_STAGE_OFFSET`], so a list that respects the bound never lets the +/// in-init walker read the path bytes staged there during install. /// /// Interior-NUL rejection is the caller's responsibility — this helper /// assumes `name` is a validated C-string body. @@ -1385,8 +1583,31 @@ pub fn seal_prop(handle: &mut HookHandle, name: &str) -> Result<()> { let attach = seal::arena::RemoteAttach::new(handle.pid) .map_err(|e| Error::HookInstallFailed(format!("seal_prop: attach: {e}")))?; - // L2 (lock-list write-ordering race) is closed here by T15: `RemoteAttach` - // now freezes init's whole thread group, so no init thread reads this lock + seal_prop_locked(&attach, handle, name)?; + + attach + .detach() + .map_err(|e| Error::HookInstallFailed(format!("seal_prop: detach: {e}")))?; + + Ok(()) +} + +/// Append `name` to the lock list under a caller-held [`RemoteAttach`] (M5). +/// +/// The interior-NUL rejection, attach, and detach live in the caller +/// ([`seal_prop`] for the standalone path, [`install_and_seal_first`] for the +/// folded first seal), so `name` is assumed already validated. The tracer-side +/// `lock_list_len` bump stays inside this body, before the caller detaches, so +/// the handle never lies to the next append. +fn seal_prop_locked( + attach: &seal::arena::RemoteAttach, + handle: &mut HookHandle, + name: &str, +) -> Result<()> { + let pid = attach.pid(); + + // L2 (lock-list write-ordering race) is closed by T15: `RemoteAttach` + // freezes init's whole thread group, so no init thread reads this lock // list during the read-modify-write below. seal_prop's byte ordering // therefore needs no lock-free rewrite. let mut buffer = vec![0u8; LOCK_LIST_CAPACITY as usize]; @@ -1396,7 +1617,7 @@ pub fn seal_prop(handle: &mut HookHandle, name: &str) -> Result<()> { // `LOCK_LIST_CAPACITY` bytes at offset 0 lie inside the 4 KiB page. // Tracee is ptrace-stopped via `attach`, so no concurrent mutation // races the read. - unsafe { read_remote(handle.pid, handle.lock_list_page, &mut buffer) } + unsafe { read_remote(pid, handle.lock_list_page, &mut buffer) } .map_err(|e| Error::HookInstallFailed(format!("seal_prop: read_remote: {e}")))?; let new_len = lock_list_append_bytes( @@ -1422,26 +1643,21 @@ pub fn seal_prop(handle: &mut HookHandle, name: &str) -> Result<()> { // page (`write_end <= LOCK_LIST_CAPACITY`). unsafe { write_remote( - handle.pid, + pid, handle.lock_list_page + write_start as u64, &buffer[write_start..write_end], ) } .map_err(|e| Error::HookInstallFailed(format!("seal_prop: write_remote: {e}")))?; - // Counter-before-detach: once `write_remote` succeeds, the tracee - // observes the extended list. Bumping `handle.lock_list_len` before + // Counter-before-detach: once `write_remote` succeeds, the tracee observes + // the extended list. Bumping `handle.lock_list_len` before the caller's // `attach.detach()` keeps the tracer's view and the tracee's view - // consistent — a panic or signal interrupting the tracer between - // detach and the counter bump would otherwise leave the handle - // lying to the next `seal_prop` and producing an off-by-entry - // overwrite. Addresses Gate 2 round-1 critic MAJOR 3. + // consistent — a panic or signal between detach and the counter bump would + // otherwise leave the handle lying to the next `seal_prop` and producing an + // off-by-entry overwrite. Addresses Gate 2 round-1 critic MAJOR 3. handle.lock_list_len = new_len; - attach - .detach() - .map_err(|e| Error::HookInstallFailed(format!("seal_prop: detach: {e}")))?; - Ok(()) } @@ -1526,6 +1742,7 @@ mod tests { libc_end: 0x7000_0010_0000, scratch_pc: 0x7000_0000_1000, trampoline_installed: false, + kmsg_fds: vec![3, 8], }; assert_eq!(h.pid, 42); assert_eq!(h.hook_page, 0xdeadbeef_cafebabe); @@ -1537,6 +1754,7 @@ mod tests { assert_eq!(h.libc_end, 0x7000_0010_0000); assert_eq!(h.scratch_pc, 0x7000_0000_1000); assert!(!h.trampoline_installed); + assert_eq!(h.kmsg_fds, vec![3, 8]); } /// Exercises `is_libc_row` against the tricky cases called out in the @@ -1601,9 +1819,7 @@ mod tests { _drop_compiles::(); } - // ───────────────────────────────────────────────────────────────────── // P04 T1 — A64 encoder submodule tests - // ───────────────────────────────────────────────────────────────────── /// Round-trips a 16-byte absolute-target trampoline built from the /// encoder helpers against the canonical byte pattern from @@ -1739,9 +1955,7 @@ mod tests { let _ = super::encoder::ldrb_imm(0, 0, 4096); } - // ───────────────────────────────────────────────────────────────────── // P04 T2 — build_hook_body_bytes tests - // ───────────────────────────────────────────────────────────────────── /// Verifies [`build_hook_body_bytes`] serialises the 35-word template /// with the three patch regions filled in the correct byte positions. @@ -1960,9 +2174,7 @@ mod tests { } } - // ───────────────────────────────────────────────────────────────────── // T16 / H1 — independent encoding oracle - // ───────────────────────────────────────────────────────────────────── /// The golden trampoline image, assembled out-of-band from /// `oracle/hook_body.s` by a real ARM64 assembler (`aarch64-linux-gnu-as` @@ -1995,8 +2207,10 @@ mod tests { "oracle blob must be 35 words × 4 = 140 bytes; regenerate via oracle/hook_body.s" ); - let template_bytes: Vec = - HOOK_BODY_TEMPLATE.iter().flat_map(|w| w.to_le_bytes()).collect(); + let template_bytes: Vec = HOOK_BODY_TEMPLATE + .iter() + .flat_map(|w| w.to_le_bytes()) + .collect(); for (i, (tpl, gold)) in template_bytes .chunks_exact(4) @@ -2057,9 +2271,7 @@ mod tests { } } - // ───────────────────────────────────────────────────────────────────── // P04 T4 — lock-list mechanics tests - // ───────────────────────────────────────────────────────────────────── /// Builds a dummy `HookHandle` for NUL-rejection paths that never reach /// ptrace. Both `hook_page == 0` and `lock_list_page == 0` short-circuit @@ -2076,6 +2288,7 @@ mod tests { libc_end: 0, scratch_pc: 0, trampoline_installed: false, + kmsg_fds: Vec::new(), } } @@ -2168,9 +2381,7 @@ mod tests { assert!(matches!(err, Error::InvalidKey)); } - // ───────────────────────────────────────────────────────────────────── // T04 / M2 — verify-after-write read-back gate - // ───────────────────────────────────────────────────────────────────── /// The two words `install_trampoline` POKEs, packed exactly as they land /// in tracee memory: `word_lo` (`ldr x16,[pc,#8]` | `br x16`) then the @@ -2257,4 +2468,158 @@ mod tests { "revert must leave init's prologue byte-for-byte intact" ); } + + /// `check_init_hook`'s remote sub-ops (attach + `process_vm_readv`) need a + /// live aarch64 init and are exercised on-device in P05, not from an + /// x86_64 host — same constraint as `install_init_hook` and the Drop body. + /// What we can pin off-device is the public dry-run report surface: every + /// resolved fact is reachable, matching the `hook_handle_size` field-layout + /// check above. + #[test] + fn check_report_fields_reachable() { + let r = CheckReport { + libc_base: 0x7000_0000_0000, + libc_end: 0x7000_0010_0000, + target_fn: 0x7000_0001_2340, + scratch_pc: 0x7000_0000_1000, + }; + assert_eq!(r.libc_base, 0x7000_0000_0000); + assert_eq!(r.libc_end, 0x7000_0010_0000); + assert_eq!(r.target_fn, 0x7000_0001_2340); + assert_eq!(r.scratch_pc, 0x7000_0000_1000); + } + + // T08 / M4 / R3: per-boot install throttle + + /// After [`MAX_INSTALL_HARD_FAILURES`] hard failures the next + /// `install_init_hook` is refused by the throttle guard, returning the + /// reused [`Error::HookInstallFailed`] before the M1 identity check or + /// any ptrace attach. `pid_t::MAX` names no live process, so every + /// pre-threshold call fails in `verify_init_identity`'s `/proc` read with + /// no remote work, forcing the counter to the ceiling off-device. This is + /// the only test that touches `INSTALL_HARD_FAILURES`; it brackets the + /// body with a reset so a parallel run starts and leaves it clean. + #[test] + fn install_init_hook_throttles_after_max_hard_failures() { + INSTALL_HARD_FAILURES.store(0, Ordering::Relaxed); + + let absent_pid = libc::pid_t::MAX; + for _ in 0..MAX_INSTALL_HARD_FAILURES { + let Err(err) = install_init_hook(absent_pid) else { + panic!("install_init_hook on an absent pid must fail without a live init"); + }; + assert!( + !matches!(&err, Error::HookInstallFailed(m) if m.contains("throttled")), + "pre-threshold failure must be a real install fault, not the throttle: {err:?}" + ); + } + + let Err(err) = install_init_hook(absent_pid) else { + panic!("a throttled install must return Err, not a handle"); + }; + assert!( + matches!(&err, Error::HookInstallFailed(m) if m.contains("throttled")), + "after {MAX_INSTALL_HARD_FAILURES} hard failures the next install must be throttled: {err:?}" + ); + + INSTALL_HARD_FAILURES.store(0, Ordering::Relaxed); + } + + // T09 / Port 1: memfd install round-trip + + /// `proc_fd_path` formats the procfs handle this process fills and relabels. + #[test] + fn proc_fd_path_formats_procfs_link() { + assert_eq!(proc_fd_path(1, 7), "/proc/1/fd/7"); + } + + /// `run_memfd_install` threads the fd from `memfd_create` into the `mmap` + /// and `close`, runs `publish` against it, and returns the mapped address. + /// Mocked remote syscalls return a non-zero address, exercising the + /// sequence off-device. + #[test] + fn run_memfd_install_threads_fd_and_returns_mapped_addr() { + use std::cell::RefCell; + + let fake_memfd: i64 = 7; + let fake_addr: i64 = 0x0000_7f00_0000; + let calls: RefCell> = RefCell::new(Vec::new()); + let published = RefCell::new(None); + + let addr = run_memfd_install( + 0xdead_beef, + |syscall_no, args| { + calls.borrow_mut().push((syscall_no, args)); + match syscall_no { + NR_MEMFD_CREATE => Ok(fake_memfd), + NR_MMAP => Ok(fake_addr), + NR_CLOSE => Ok(0), + other => panic!("unexpected remote syscall {other}"), + } + }, + |memfd| { + *published.borrow_mut() = Some(memfd); + Ok(()) + }, + ) + .expect("memfd install must succeed with mocked syscalls"); + + assert_eq!( + addr, fake_addr as u64, + "returns the mmap address as the hook page" + ); + assert_eq!( + published.borrow().unwrap(), + fake_memfd as u64, + "publish runs against the created fd" + ); + + let calls = calls.borrow(); + assert_eq!(calls[0].0, NR_MEMFD_CREATE); + assert_eq!( + calls[0].1[0], 0xdead_beef, + "memfd_create reads the staged name vaddr" + ); + assert_eq!(calls[0].1[1], MFD_CLOEXEC); + assert_eq!(calls[1].0, NR_MMAP); + assert_eq!(calls[1].1[2], PROT_RX, "hook page is mapped PROT_R|X"); + assert_eq!( + calls[1].1[4], fake_memfd as u64, + "mmap maps the created memfd" + ); + assert_eq!(calls[2].0, NR_CLOSE); + assert_eq!(calls[2].1[0], fake_memfd as u64, "close releases the memfd"); + } + + /// A `-errno` return from `mmap` surfaces as `HookInstallFailed` and still + /// closes the fd, so a failed install leaves no fd leaked in init. + #[test] + fn run_memfd_install_closes_fd_on_mmap_failure() { + use std::cell::RefCell; + + let closed = RefCell::new(false); + let err = run_memfd_install( + 0, + |syscall_no, _args| match syscall_no { + NR_MEMFD_CREATE => Ok(9), + NR_MMAP => Ok(-12), + NR_CLOSE => { + *closed.borrow_mut() = true; + Ok(0) + } + other => panic!("unexpected remote syscall {other}"), + }, + |_memfd| Ok(()), + ) + .unwrap_err(); + + assert!( + matches!(err, Error::HookInstallFailed(ref m) if m.contains("mmap")), + "mmap failure must surface as HookInstallFailed, got {err:?}" + ); + assert!( + *closed.borrow(), + "the memfd must be closed even when mmap fails" + ); + } } diff --git a/crates/resetprop/src/seal/kmsg_observer.rs b/crates/resetprop/src/seal/kmsg_observer.rs new file mode 100644 index 0000000..2bbab7b --- /dev/null +++ b/crates/resetprop/src/seal/kmsg_observer.rs @@ -0,0 +1,384 @@ +//! In-init kmsg observability (Port 2). +//! +//! Discovers the `/dev/kmsg` file descriptors init holds open, then snoops +//! init's `write(kmsg_fd, …)` syscalls for a bounded window so the kernel-log +//! lines init emits become visible without touching its memory. Ported from +//! injectrc `init_injector/injector.cpp:213-232` (kmsg fd discovery) and +//! `:254-310` (the syscall-trace loop). + +use std::fs; +use std::io::{self, Write}; +use std::path::Path; +use std::time::{Duration, Instant}; + +use libc::c_int; + +use super::ptrace::{ + detach_thread_group, getregset, is_thread_gone, nth_syscall_arg, ptrace_interrupt, + ptrace_syscall, read_remote, seize_thread_group, syscall_nr, syscall_stop_op, + PTRACE_EVENT_STOP, PTRACE_SYSCALL_INFO_ENTRY, +}; +use super::Pid; +use crate::error::{Error, Result}; + +/// The symlink target every `/dev/kmsg` fd resolves to under `/proc//fd`. +const KMSG_DEVICE: &str = "/dev/kmsg"; + +/// Discover the `/dev/kmsg` file descriptors open in `pid` by resolving every +/// symlink under `/proc//fd`. +pub(crate) fn discover_kmsg_fds(pid: Pid) -> Vec { + let fd_dir = format!("/proc/{pid}/fd"); + discover_kmsg_fds_in(Path::new(&fd_dir)) +} + +/// Testable core of [`discover_kmsg_fds`]: collect the fd numbers in `fd_dir` +/// whose symlink resolves to `/dev/kmsg`, sorted ascending. +/// +/// An unreadable directory yields an empty list — init with no kmsg fd is an +/// unusual but valid state, not an error to propagate. Non-symlink entries and +/// links to other targets are skipped, mirroring injectrc's `DT_LNK` + +/// target-equality filter. +pub(crate) fn discover_kmsg_fds_in(fd_dir: &Path) -> Vec { + let Ok(entries) = fs::read_dir(fd_dir) else { + return Vec::new(); + }; + let mut fds: Vec = entries + .flatten() + .filter(|entry| { + fs::read_link(entry.path()).is_ok_and(|target| target == Path::new(KMSG_DEVICE)) + }) + .filter_map(|entry| { + entry + .file_name() + .to_str() + .and_then(|name| name.parse::().ok()) + }) + .collect(); + fds.sort_unstable(); + fds +} + +/// AArch64 `__NR_write` (asm-generic syscall table). The snoop is gated to +/// aarch64 at the `lib.rs` boundary (`require_aarch64`), matching the rest of +/// the seal subsystem's aarch64 syscall-number convention. +const NR_WRITE: u64 = 64; + +/// Upper bound on a single kmsg write the snoop mirrors. Kernel log lines sit +/// well under this; the cap stops a pathological length argument from forcing +/// an unbounded allocation. +const MAX_KMSG_WRITE: usize = 8192; + +/// Decide whether a syscall-entry register read is a `write` to one of +/// `kmsg_fds`, returning the `(buf, capped_len)` to mirror. Pure over the +/// extracted register values so the fd-match and length-cap logic is unit +/// testable without a live tracee. +fn write_target(nr: u64, fd: u64, buf: u64, len: u64, kmsg_fds: &[u64]) -> Option<(u64, usize)> { + if nr != NR_WRITE || !kmsg_fds.contains(&fd) { + return None; + } + Some((buf, (len as usize).min(MAX_KMSG_WRITE))) +} + +/// On a syscall-entry stop, mirror a `write(kmsg_fd, buf, len)` from the +/// tracee: read its buffer through the size-asserted register facade and +/// return the line with a single trailing newline stripped. `None` when the +/// stopped syscall is not a write to one of `kmsg_fds`. +fn capture_kmsg_write(pid: Pid, kmsg_fds: &[u64]) -> Result> { + let regs = getregset(pid)?; + let Some((buf, len)) = write_target( + syscall_nr(®s), + nth_syscall_arg(®s, 0), + nth_syscall_arg(®s, 1), + nth_syscall_arg(®s, 2), + kmsg_fds, + ) else { + return Ok(None); + }; + if len == 0 { + return Ok(Some(String::new())); + } + let mut bytes = vec![0u8; len]; + // SAFETY: the tracee is ptrace-stopped at a syscall-entry stop; `buf..buf + + // len` is the userspace buffer init passed to write(2), readable in its + // address space. + unsafe { read_remote(pid, buf, &mut bytes)? }; + let text = String::from_utf8_lossy(&bytes); + Ok(Some(text.strip_suffix('\n').unwrap_or(&text).to_string())) +} + +/// RAII whole-thread-group ptrace attach for the snoop: freezes every thread in +/// `pid`'s group (SEIZE + INTERRUPT + wait per tid, re-scanned to a fixpoint), +/// then the snoop single-steps each across syscalls. Detaches the whole group on +/// drop. Tracing every thread, not just the leader, is required because Android +/// writes persistent properties on a non-leader init thread, so a leader-only +/// trace misses their kmsg lines. Unlike [`super::arena::RemoteAttach`] the group +/// is resumed (`PTRACE_SYSCALL`) rather than held frozen for an atomic poke. +struct GroupTracer { + tids: Vec, + detached: bool, +} + +impl GroupTracer { + fn seize(pid: Pid) -> Result { + let tids = seize_thread_group(pid)?; + Ok(Self { + tids, + detached: false, + }) + } + + fn detach(mut self) -> Result<()> { + self.detached = true; + detach_thread_group(&self.tids) + } +} + +impl Drop for GroupTracer { + fn drop(&mut self) { + if !self.detached { + // Best-effort resume on the error path; the kernel also auto- + // detaches every tracee when this short-lived CLI exits. + if let Err(e) = detach_thread_group(&self.tids) { + eprintln!("resetprop: observe-init group detach during unwind failed: {e}"); + } + } + } +} + +/// Stop every thread in `running` (each was resumed with `PTRACE_SYSCALL`, so it +/// owes exactly one stop) so the group detach acts on a fully-stopped group. +/// Interrupts each, then reaps one status per interrupted thread; a thread that +/// exits in the window is dropped from `tids`. Threads already held at a stop +/// are not in `running` and need no action. +fn settle_running(tids: &mut Vec, running: &[Pid]) -> Result<()> { + let mut pending: Vec = Vec::with_capacity(running.len()); + for &tid in running { + match ptrace_interrupt(tid) { + Ok(()) => pending.push(tid), + Err(e) if is_thread_gone(&e) => tids.retain(|&t| t != tid), + Err(e) => return Err(e), + } + } + while !pending.is_empty() { + let mut status: c_int = 0; + // SAFETY: `status` is stack-local; `-1` + `__WALL` reaps a stop from any + // interrupted init thread. + let rc = unsafe { libc::waitpid(-1, &mut status, libc::__WALL) }; + if rc == -1 { + let err = io::Error::last_os_error(); + if err.raw_os_error() == Some(libc::EINTR) { + continue; + } + return Err(Error::PtraceOp(err)); + } + pending.retain(|&t| t != rc); + if libc::WIFEXITED(status) || libc::WIFSIGNALED(status) { + tids.retain(|&t| t != rc); + } + } + Ok(()) +} + +extern "C" fn handle_alarm(_signum: c_int) {} + +/// RAII SIGALRM arming so a blocking `waitpid` cannot outlast the observe +/// window when init idles in a long syscall. Installs a no-op handler WITHOUT +/// `SA_RESTART` (so the wait returns `EINTR` rather than auto-restarting), +/// restores the previous disposition and cancels the timer on drop. +struct AlarmGuard { + previous: libc::sigaction, +} + +impl AlarmGuard { + fn arm(seconds: u32) -> Result { + // SAFETY: `sigaction` is a C struct that is valid all-zero (empty mask, + // no flags); `mem::zeroed` is the standard way to stage it. + let mut action: libc::sigaction = unsafe { std::mem::zeroed() }; + action.sa_sigaction = handle_alarm as *const () as libc::sighandler_t; + let mut previous: libc::sigaction = unsafe { std::mem::zeroed() }; + // SAFETY: `action`/`previous` are valid sigaction storage for the + // duration of the call; sa_flags is 0 (no SA_RESTART) so the handler + // interrupts a blocked waitpid. + let rc = unsafe { libc::sigaction(libc::SIGALRM, &action, &mut previous) }; + if rc == -1 { + return Err(Error::Io(io::Error::last_os_error())); + } + // SAFETY: arms a one-shot process timer; no memory effect. + unsafe { libc::alarm(seconds) }; + Ok(Self { previous }) + } +} + +impl Drop for AlarmGuard { + fn drop(&mut self) { + // SAFETY: cancel the pending timer and restore the saved disposition. + unsafe { + libc::alarm(0); + libc::sigaction(libc::SIGALRM, &self.previous, std::ptr::null_mut()); + } + } +} + +/// Trace init (`pid`) for `duration`, mirroring every `write(2)` it makes to a +/// `/dev/kmsg` fd in `kmsg_fds` to `sink`. Returns the number of kmsg lines +/// captured. +/// +/// Single-steps each thread across syscall boundaries with `PTRACE_SYSCALL`, +/// dispatching `waitpid(-1, __WALL)` stops by the thread id they report, and +/// uses [`syscall_stop_op`] to act only on entry stops. The entry/exit toggle +/// the injectrc reference uses is unreliable here: the attach interrupts each +/// thread wherever it idles (often inside a blocking syscall), so the first stop +/// is an exit and a toggle would stay inverted for the whole window. A SIGALRM +/// bounds the wait when every thread idles. Ported from injectrc +/// `init_injector/injector.cpp:254-310`. +pub(crate) fn snoop_kmsg_writes_for_duration( + pid: Pid, + kmsg_fds: &[u64], + duration: Duration, + sink: &mut dyn Write, +) -> Result { + if kmsg_fds.is_empty() || duration.is_zero() { + return Ok(0); + } + + let mut tracer = GroupTracer::seize(pid)?; + // Round the alarm up to whole seconds so it never fires before the + // Instant-based deadline, which would risk a wedged wait on an idle init. + let alarm_secs = (duration.as_secs() + u64::from(duration.subsec_nanos() > 0)) + .clamp(1, u64::from(u32::MAX)) as u32; + let _alarm = AlarmGuard::arm(alarm_secs)?; + let deadline = Instant::now() + duration; + let mut captured = 0usize; + + // Begin syscall-stepping every frozen thread; `running` holds the tids we + // have resumed (each owes exactly one stop), which the deadline path settles + // before detaching. A thread that exits between the group-stop and this + // resume is dropped, not an error. + let mut running: Vec = Vec::with_capacity(tracer.tids.len()); + for &tid in &tracer.tids { + match ptrace_syscall(tid, 0) { + Ok(()) => running.push(tid), + Err(e) if is_thread_gone(&e) => {} + Err(e) => return Err(e), + } + } + tracer.tids.retain(|tid| running.contains(tid)); + + while !running.is_empty() { + let mut status: c_int = 0; + // SAFETY: `status` is stack-local; waitpid writes through it only while + // blocked. `-1` + `__WALL` reaps a stop from any seized init thread. + let rc = unsafe { libc::waitpid(-1, &mut status, libc::__WALL) }; + if rc == -1 { + let err = io::Error::last_os_error(); + if err.raw_os_error() == Some(libc::EINTR) { + if Instant::now() >= deadline { + break; + } + continue; + } + return Err(Error::PtraceOp(err)); + } + if libc::WIFEXITED(status) || libc::WIFSIGNALED(status) { + running.retain(|&t| t != rc); + tracer.tids.retain(|&t| t != rc); + continue; + } + if !libc::WIFSTOPPED(status) { + // Not a stop we can resume from (no WCONTINUED requested, so this is + // unreachable in practice). Re-wait rather than poke a thread whose + // stop we did not confirm; its `running` membership is unchanged. + continue; + } + // The thread is held at this stop until we resume it below. + running.retain(|&t| t != rc); + let sig = libc::WSTOPSIG(status); + let is_group_stop = ((status >> 16) & 0xff) as u32 == PTRACE_EVENT_STOP; + if sig == (libc::SIGTRAP | 0x80) { + if syscall_stop_op(rc)? == PTRACE_SYSCALL_INFO_ENTRY { + if let Some(line) = capture_kmsg_write(rc, kmsg_fds)? { + writeln!(sink, "{line}").map_err(Error::Io)?; + captured += 1; + } + } + if Instant::now() >= deadline { + break; + } + ptrace_syscall(rc, 0)?; + running.push(rc); + } else { + // Signal-delivery stop: forward the signal so init's own handling is + // preserved. A group-stop event carries a stop signal that must NOT + // be re-injected, only resumed past. + let inject = if is_group_stop { 0 } else { sig }; + ptrace_syscall(rc, inject)?; + running.push(rc); + } + } + + settle_running(&mut tracer.tids, &running)?; + sink.flush().map_err(Error::Io)?; + tracer.detach()?; + Ok(captured) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::os::unix::fs::symlink; + + /// Only `/dev/kmsg` links are collected; other devices, regular-file links, + /// and the fd numbers are returned sorted. The link targets need not exist: + /// `read_link` reads the link body, not the destination. + #[test] + fn discover_picks_only_kmsg_links_sorted() { + let fd_dir = tempfile::tempdir().expect("tempdir"); + symlink("/dev/kmsg", fd_dir.path().join("7")).unwrap(); + symlink("/dev/null", fd_dir.path().join("4")).unwrap(); + symlink("/dev/kmsg", fd_dir.path().join("3")).unwrap(); + symlink("/data/local/tmp/init.log", fd_dir.path().join("9")).unwrap(); + + assert_eq!(discover_kmsg_fds_in(fd_dir.path()), vec![3, 7]); + } + + /// init holding no kmsg fd yields an empty list rather than an error. + #[test] + fn discover_no_kmsg_links_is_empty() { + let fd_dir = tempfile::tempdir().expect("tempdir"); + symlink("/dev/null", fd_dir.path().join("1")).unwrap(); + + assert!(discover_kmsg_fds_in(fd_dir.path()).is_empty()); + } + + /// A missing fd directory is the empty case, not a panic. + #[test] + fn discover_missing_dir_is_empty() { + assert!(discover_kmsg_fds_in(Path::new("/proc/nonexistent-pid/fd")).is_empty()); + } + + /// A `write` to a tracked kmsg fd yields its `(buf, len)`. + #[test] + fn write_target_matches_kmsg_write() { + assert_eq!( + write_target(NR_WRITE, 7, 0xdead_0000, 64, &[3, 7]), + Some((0xdead_0000, 64)), + ); + } + + /// A non-`write` syscall, or a write to an untracked fd, is ignored. + #[test] + fn write_target_rejects_non_kmsg() { + assert_eq!(write_target(NR_WRITE, 9, 0x1000, 16, &[3, 7]), None); + assert_eq!(write_target(NR_WRITE + 1, 7, 0x1000, 16, &[3, 7]), None); + } + + /// An oversized length argument is capped so the read cannot allocate + /// unboundedly. + #[test] + fn write_target_caps_length() { + assert_eq!( + write_target(NR_WRITE, 3, 0x2000, u64::MAX, &[3]), + Some((0x2000, MAX_KMSG_WRITE)), + ); + } +} diff --git a/crates/resetprop/src/seal/maps.rs b/crates/resetprop/src/seal/maps.rs index 058faad..1cc8eae 100644 --- a/crates/resetprop/src/seal/maps.rs +++ b/crates/resetprop/src/seal/maps.rs @@ -54,9 +54,8 @@ pub(super) fn parse_line(line: &str, pid: libc::pid_t) -> Result Error { - Error::AreaCorrupt(format!("/proc/{pid}/maps: {detail}")) - }; + let corrupt = + |detail: &str| -> Error { Error::AreaCorrupt(format!("/proc/{pid}/maps: {detail}")) }; // Columns: ADDR perms offset dev inode [path]. Split on exactly the first // five single-space separators so any spaces inside the path column @@ -79,7 +78,9 @@ pub(super) fn parse_line(line: &str, pid: libc::pid_t) -> Result Error { Error::PtraceAttach(err) } -// ───────────────────────────────────────────────────────────────────────────── // ptrace primitives -// ───────────────────────────────────────────────────────────────────────────── /// `PTRACE_SEIZE` — attach without stopping the tracee. Sets /// `PTRACE_O_TRACESYSGOOD` atomically via the `data` argument so subsequent @@ -372,9 +381,57 @@ pub fn ptrace_detach(pid: Pid) -> Result<()> { Ok(()) } -// ───────────────────────────────────────────────────────────────────────────── +/// `PTRACE_SYSCALL` — resume `pid` until its next syscall entry/exit stop, +/// delivering `sig` to the tracee (`0` = none). The observe-init snoop passes +/// `0` to step across a syscall boundary and the pending signal number to +/// forward a signal-delivery stop, so init's own signal handling is preserved +/// while it is traced. +pub fn ptrace_syscall(pid: Pid, sig: c_int) -> Result<()> { + // SAFETY: `libc::ptrace` FFI; `addr` is NULL per PTRACE_SYSCALL, `data` + // carries the signal to inject (integer-in-pointer, standard pattern). The + // tracee is ptrace-stopped (caller just observed a stop), so the resume is + // legal. + let rc = unsafe { + libc::ptrace( + PTRACE_SYSCALL as _, + pid, + std::ptr::null_mut::(), + sig as *mut c_void, + ) + }; + if rc == -1 { + return Err(last_ptrace_op_err()); + } + Ok(()) +} + +/// Read the `op` discriminant of [`PTRACE_GET_SYSCALL_INFO`] for the tracee's +/// current syscall-stop: [`PTRACE_SYSCALL_INFO_ENTRY`] vs the exit value. +/// +/// Only the first byte (`op`, offset 0, stable across kernel versions) is +/// requested, so the variable `union` tail — awkward to mirror and not needed +/// here — is never read. The snoop reads register values through the +/// size-asserted [`getregset`] facade instead. +pub fn syscall_stop_op(pid: Pid) -> Result { + let mut op: u8 = 0; + // SAFETY: `libc::ptrace` FFI. For PTRACE_GET_SYSCALL_INFO `addr` is the + // destination buffer size (1 byte) and `data` points at that buffer; the + // kernel writes `min(addr, available)` bytes, so exactly `op` is filled. + let rc = unsafe { + libc::ptrace( + PTRACE_GET_SYSCALL_INFO as _, + pid, + std::ptr::without_provenance_mut::(1), + &mut op as *mut u8 as *mut c_void, + ) + }; + if rc == -1 { + return Err(last_ptrace_op_err()); + } + Ok(op) +} + // Thread-group stop — T15 / Defect B (freeze every init thread before any poke) -// ───────────────────────────────────────────────────────────────────────────── /// Upper bound on re-scan passes before a thread group is declared /// non-convergent. Init carries a small, near-static thread count, so a real @@ -387,7 +444,7 @@ const MAX_GROUP_SCAN_PASSES: u32 = 8; /// `ESRCH` from seize/interrupt/detach and a thread-exit wait status surfaced by /// [`wait_stop`] as [`Error::PtraceUnexpectedStatus`]. Per-tid `ESRCH` tolerance /// (T15): such a tid is skipped, never a hard error. -fn is_thread_gone(err: &Error) -> bool { +pub(crate) fn is_thread_gone(err: &Error) -> bool { match err { Error::PtraceAttach(e) | Error::PtraceOp(e) => e.raw_os_error() == Some(libc::ESRCH), Error::PtraceUnexpectedStatus(status) => { @@ -461,7 +518,11 @@ fn seize_one_tid(tid: Pid) -> Result { // SEIZE took; the kernel auto-detaches on thread death, so a later `ESRCH` // needs no explicit detach — just skip the tid. if let Err(e) = ptrace_interrupt(tid) { - return if is_thread_gone(&e) { Ok(false) } else { Err(e) }; + return if is_thread_gone(&e) { + Ok(false) + } else { + Err(e) + }; } match wait_stop(tid, PTRACE_EVENT_STOP) { Ok(_) => Ok(true), @@ -588,9 +649,7 @@ pub(crate) fn detach_thread_group(tids: &[Pid]) -> Result<()> { first_err.map_or(Ok(()), Err) } -// ───────────────────────────────────────────────────────────────────────────── // Word-granularity tracee memory IO — PTRACE_PEEKDATA / PTRACE_POKEDATA -// ───────────────────────────────────────────────────────────────────────────── /// `PTRACE_PEEKDATA` — read one 64-bit word from `addr` in the tracee. /// @@ -668,9 +727,7 @@ pub fn ptrace_poketext(pid: Pid, addr: u64, value: u64) -> Result<()> { Ok(()) } -// ───────────────────────────────────────────────────────────────────────────── // Cross-process memory IO — process_vm_{readv,writev} partial-transfer loops -// ───────────────────────────────────────────────────────────────────────────── /// Loop `process_vm_readv` until the entire `buf` has been filled from /// `remote_addr` in the tracee, or the kernel returns an error. @@ -763,67 +820,54 @@ pub(crate) unsafe fn write_remote(pid: Pid, remote_addr: u64, buf: &[u8]) -> Res Ok(()) } -// ───────────────────────────────────────────────────────────────────────────── -// remote_syscall — stage `svc #0 ; brk #0` and run one syscall in the tracee -// ───────────────────────────────────────────────────────────────────────────── +// run_remote_payload — the single staged-trap injector skeleton -/// Execute `syscall_no(args...)` inside `pid` by staging an 8-byte -/// `svc #0 ; brk #0` blob at `scratch_pc`, resuming until the `brk` traps, -/// and reading `x0` back. +/// Run one staged-payload trap in the tracee under a live ptrace stop. /// -/// Caller must have already: -/// - invoked [`ptrace_seize`] + [`ptrace_interrupt`] on `pid`; -/// - consumed the initial SEIZE stop via [`wait_stop`]; -/// - ensured `scratch_pc` is 4-byte aligned and points inside an executable -/// mapping in the tracee with at least 8 bytes of readable+writable+ -/// executable room (typically a bootstrap `mmap` page or a located libc -/// padding region, per linux-arm64-abi.md §8). +/// Saves the word at `scratch_pc`, POKEs `payload` (a two-instruction blob +/// packed low-half | high-half, the high half always a `brk` so the tracee +/// re-traps), snapshots the registers, applies `setup` to a work copy, resumes +/// with `PTRACE_CONT`, waits for the `brk`, then restores the registers and the +/// scratch word. Returns the post-trap register snapshot for the caller to +/// decode (or ignore, for a pure-instruction payload). /// -/// Returns the raw `x0` as `i64`; values in `-4095..=-1` are `-errno`. +/// Every error path after the POKE issues a best-effort restore of both the +/// scratch word and the saved registers before propagating, so libc.text is +/// never left holding a live `…; brk` blob and the tracee never detaches with +/// its registers in work-state. This is the one skeleton behind +/// [`remote_syscall_via_poke`] (syscall payload) and +/// `super::hook::execute_remote_isb` (instruction payload); keeping it single +/// is what closed the Defect-A drift surface where four near-identical copies +/// disagreed on the trap constant. /// /// # Safety /// -/// Caller guarantees (a) the tracee is ptrace-stopped at entry, (b) -/// `scratch_pc` satisfies the alignment/mapping contract above, and (c) no -/// other thread in the tracee is racing on those 8 bytes. -pub unsafe fn remote_syscall( +/// Caller holds the tracee ptrace-stopped (typically a `RemoteAttach`); +/// `scratch_pc` is 4-byte aligned and points inside an executable mapping with +/// at least 8 bytes of room, and no other thread races those 8 bytes. +/// PEEK/POKEDATA bypasses VMA write bits, so an `r-xp` libc.text NOP slide is a +/// legal `scratch_pc`. +pub(crate) unsafe fn run_remote_payload( pid: Pid, scratch_pc: u64, - syscall_no: u64, - args: [u64; 6], -) -> Result { - // Payload: trap-then-breakpoint little-endian. Derived from the two - // arch-neutral instruction constants so the encoding cannot drift from - // the per-arch ISA citations. - let trap_bytes = TRAP_INSN.to_le_bytes(); - let brk_bytes = BRK_INSN.to_le_bytes(); - let mut payload = [0u8; 8]; - payload[..4].copy_from_slice(&trap_bytes); - payload[4..].copy_from_slice(&brk_bytes); - - // (§7 step 2) Save the 8 bytes we are about to clobber. - let mut saved_bytes = [0u8; 8]; - // SAFETY: forwards caller's `scratch_pc` readability guarantee. - unsafe { read_remote(pid, scratch_pc, &mut saved_bytes)? }; - - // (§7 step 3) Stage the trap+brk blob. - // SAFETY: forwards caller's `scratch_pc` writability guarantee. - unsafe { write_remote(pid, scratch_pc, &payload)? }; - - // (§7 step 4) Snapshot registers so we can restore on exit. - let saved_regs = getregset(pid)?; + payload: u64, + setup: impl FnOnce(&mut UserPtRegs), +) -> Result { + // Save the 8 bytes we are about to clobber (one PEEKDATA word on LP64). + let saved_word = ptrace_peektext(pid, scratch_pc)?; - // (§7 step 5) Build the work register set via the arch-neutral interface: - // pc=scratch, syscall-number register, arg registers. Stack/flags/link - // are left untouched — the kernel uses its own stack across the trap. - let mut work = saved_regs; - set_syscall_args(&mut work, scratch_pc, syscall_no, args); + // Stage the payload via POKEDATA — bypasses VMA write bits so the scratch + // may be an `r-xp` libc.text NOP slide. + ptrace_poketext(pid, scratch_pc, payload)?; - // (§7 step 6) Install the work regs, then resume. + // Snapshot registers so we can restore on exit, then build the work set. + let saved_regs = getregset(pid)?; + let mut work = saved_regs; + setup(&mut work); setregset(pid, &work)?; - // SAFETY: `libc::ptrace` FFI. `addr`/`data` are NULL per PTRACE_CONT - // contract. Tracee is guaranteed ptrace-stopped by the function's own + // SAFETY: `libc::ptrace` FFI. `addr`/`data` are NULL per the PTRACE_CONT + // contract. The tracee is guaranteed ptrace-stopped by this function's // safety contract, so a CONT is legal here. let rc = unsafe { libc::ptrace( @@ -834,155 +878,81 @@ pub unsafe fn remote_syscall( ) }; if rc == -1 { - // Best-effort restore before propagating: libc.text (or whichever - // scratch VMA the caller picked) must not retain live svc+brk, and - // the tracee's saved regs must not remain in work-state. Any error - // here is discarded — the original cause is more informative. - // SAFETY: forwards caller's `scratch_pc` writability guarantee. - let _ = unsafe { write_remote(pid, scratch_pc, &saved_bytes) }; + // Best-effort restore before propagating: scratch must not retain the + // live payload, and the saved regs must not remain in work-state, or + // RemoteAttach::drop detaches the tracee into a poisoned state and the + // next thread scheduled at scratch_pc traps on the breakpoint. + let _ = ptrace_poketext(pid, scratch_pc, saved_word); let _ = setregset(pid, &saved_regs); return Err(last_ptrace_op_err()); } - // (§7 step 7) Wait for the brk trap. `wait_stop` verifies - // `WIFSTOPPED && WSTOPSIG == SIGTRAP && event == 0` atomically per its - // contract — group-stops (event=128) and syscall-stops (signal=0x85) are - // rejected as `Error::PtraceUnexpectedStatus`. + // Wait for the brk trap. `wait_stop` verifies + // `WIFSTOPPED && WSTOPSIG == SIGTRAP && event == 0` atomically. let wait_result = wait_stop(pid, 0); if wait_result.is_err() { - // SAFETY: forwards caller's `scratch_pc` writability guarantee. - let _ = unsafe { write_remote(pid, scratch_pc, &saved_bytes) }; + let _ = ptrace_poketext(pid, scratch_pc, saved_word); let _ = setregset(pid, &saved_regs); } wait_result?; - // (§7 step 8) Read the syscall return register from the post-trap state. + // Read the post-trap register state for the caller to decode. let out_result = getregset(pid); if out_result.is_err() { - // SAFETY: forwards caller's `scratch_pc` writability guarantee. - let _ = unsafe { write_remote(pid, scratch_pc, &saved_bytes) }; + let _ = ptrace_poketext(pid, scratch_pc, saved_word); let _ = setregset(pid, &saved_regs); } let out = out_result?; - let ret = get_syscall_return(&out); - // (§7 step 9) Restore in order: regs first (so pc points back at the - // caller's resume address), then the scratch bytes (so a subsequent - // `remote_syscall` invocation sees pristine memory to clobber). - setregset(pid, &saved_regs)?; - // SAFETY: forwards caller's `scratch_pc` writability guarantee. - unsafe { write_remote(pid, scratch_pc, &saved_bytes)? }; + // Restore regs first (so pc points back at the caller's resume address), + // then the scratch word. Capture both results before the first `?` so a + // reg-restore failure still triggers the scratch-word restore. + let reg_res = setregset(pid, &saved_regs); + let poke_res = ptrace_poketext(pid, scratch_pc, saved_word); + reg_res?; + poke_res?; - Ok(ret) + Ok(out) } -// ───────────────────────────────────────────────────────────────────────────── -// remote_syscall_via_poke — same as remote_syscall, but PEEK/POKEDATA scratch -// ───────────────────────────────────────────────────────────────────────────── +// remote_syscall_via_poke — syscall specialization of run_remote_payload -/// Execute `syscall_no(args...)` inside `pid` by staging an 8-byte -/// `svc #0 ; brk #0` blob at `scratch_pc` via [`ptrace_peektext`] / -/// [`ptrace_poketext`] (word-granularity PEEK/POKEDATA) rather than -/// [`read_remote`] / [`write_remote`] (process_vm_readv/writev). -/// -/// Rationale: `process_vm_writev` respects VMA write bits and EFAULTs on -/// `r-xp` libc.text; `PTRACE_POKEDATA` bypasses the write bit via the -/// `ptrace_access_vm` kernel path. Use this variant when `scratch_pc` lives -/// inside a libc.text NOP slide (i.e. always, in P02's post-bootstrap flow). +/// Execute `syscall_no(args...)` inside `pid` by staging `svc #0 ; brk #0` at +/// `scratch_pc` and decoding the syscall return register from the post-trap +/// snapshot. /// -/// Behavior and return semantics are otherwise identical to -/// [`remote_syscall`] — caller contract matches verbatim. +/// Returns the raw return register as `i64`; values in `-4095..=-1` are +/// `-errno`. The scratch VMA need not be writable: PEEK/POKEDATA bypass VMA +/// write bits, so an `r-xp` libc.text NOP slide is a legal target. /// /// # Safety /// -/// Caller guarantees (a) the tracee is ptrace-stopped at entry, (b) -/// `scratch_pc` is 4-byte aligned and points inside an executable mapping -/// with at least 8 bytes of readable+executable room, (c) no other thread -/// in the tracee is racing on those 8 bytes. Unlike [`remote_syscall`] the -/// scratch VMA does NOT need to be writable: PEEK/POKEDATA bypass VMA -/// write bits, so an `r-xp` libc.text NOP slide is a legal target. -pub(crate) unsafe fn remote_syscall_via_poke( +/// Same contract as [`run_remote_payload`]: tracee ptrace-stopped at entry, +/// `scratch_pc` 4-byte aligned and inside an executable mapping with at least +/// 8 bytes of room, no other thread racing those 8 bytes. +pub unsafe fn remote_syscall_via_poke( pid: Pid, scratch_pc: u64, syscall_no: u64, args: [u64; 6], ) -> Result { - // Payload: trap-then-breakpoint little-endian packed into one 64-bit word - // (low half = `TRAP_INSN`, high half = `BRK_INSN`). Same byte pattern as - // [`remote_syscall`]; construction differs only in transport. - let trap_brk: u64 = (TRAP_INSN as u64) | ((BRK_INSN as u64) << 32); - - // Save the 8 bytes we are about to clobber (one PEEKDATA word on LP64). - let saved_word = ptrace_peektext(pid, scratch_pc)?; - - // Stage the trap+brk blob via POKEDATA — bypasses VMA write bits so the - // scratch may be an `r-xp` libc.text NOP slide. - ptrace_poketext(pid, scratch_pc, trap_brk)?; - - // Snapshot registers so we can restore on exit. - let saved_regs = getregset(pid)?; - - // Build the work register set via the arch-neutral interface: pc=scratch, - // syscall-number register, arg registers. Stack/flags/link are left - // untouched — the kernel uses its own stack across the trap. - let mut work = saved_regs; - set_syscall_args(&mut work, scratch_pc, syscall_no, args); - - // Install the work regs, then resume. - setregset(pid, &work)?; - - // SAFETY: `libc::ptrace` FFI. `addr`/`data` are NULL per PTRACE_CONT - // contract. Tracee is guaranteed ptrace-stopped by the function's own - // safety contract, so a CONT is legal here. - let rc = unsafe { - libc::ptrace( - PTRACE_CONT as _, - pid, - std::ptr::null_mut::(), - std::ptr::null_mut::(), - ) + // trap (low 4 bytes) ; brk (high 4 bytes), little-endian pack. + let payload: u64 = (TRAP_INSN as u64) | ((BRK_INSN as u64) << 32); + + // SAFETY: forwards this function's caller contract (tracee stopped, + // `scratch_pc` aligned + executable, no racing thread) to the shared + // skeleton; `set_syscall_args` stages pc + syscall-number + arg registers + // through the arch-neutral facade. + let out = unsafe { + run_remote_payload(pid, scratch_pc, payload, |work| { + set_syscall_args(work, scratch_pc, syscall_no, args); + })? }; - if rc == -1 { - // Best-effort restore before propagating: libc.text must not retain - // live trap+brk at scratch_pc, and the tracee's saved regs must not - // remain in work-state (pc=scratch_pc, syscall register set). - // Otherwise RemoteAttach::drop detaches init into a poisoned state and - // the next thread scheduled at scratch_pc traps on the breakpoint. - let _ = ptrace_poketext(pid, scratch_pc, saved_word); - let _ = setregset(pid, &saved_regs); - return Err(last_ptrace_op_err()); - } - - // Wait for the brk trap. `wait_stop` verifies - // `WIFSTOPPED && WSTOPSIG == SIGTRAP && event == 0` atomically. - let wait_result = wait_stop(pid, 0); - if wait_result.is_err() { - let _ = ptrace_poketext(pid, scratch_pc, saved_word); - let _ = setregset(pid, &saved_regs); - } - wait_result?; - - // Read the syscall return register from the post-trap state. - let out_result = getregset(pid); - if out_result.is_err() { - let _ = ptrace_poketext(pid, scratch_pc, saved_word); - let _ = setregset(pid, &saved_regs); - } - let out = out_result?; - let ret = get_syscall_return(&out); - // Restore in order: regs first (so pc points back at the caller's resume - // address), then the scratch word (so a subsequent - // `remote_syscall_via_poke` sees pristine memory to clobber). - setregset(pid, &saved_regs)?; - ptrace_poketext(pid, scratch_pc, saved_word)?; - - Ok(ret) + Ok(get_syscall_return(&out)) } -// ───────────────────────────────────────────────────────────────────────────── // Unit tests -// ───────────────────────────────────────────────────────────────────────────── #[cfg(test)] mod tests { @@ -1039,13 +1009,19 @@ mod tests { fn benign_intermediate_stop_then_reaches_expected_event() { let benign = stopped_status(libc::SIGCHLD, 0); assert!( - matches!(classify_stop(benign, PTRACE_EVENT_STOP), StopVerdict::ContAndRetry), + matches!( + classify_stop(benign, PTRACE_EVENT_STOP), + StopVerdict::ContAndRetry + ), "benign SIGCHLD stop must be re-CONTed, not accepted or faulted" ); let awaited = stopped_status(libc::SIGTRAP, PTRACE_EVENT_STOP); assert!( - matches!(classify_stop(awaited, PTRACE_EVENT_STOP), StopVerdict::Awaited), + matches!( + classify_stop(awaited, PTRACE_EVENT_STOP), + StopVerdict::Awaited + ), "the awaited SIGTRAP + matching event must be accepted as the final stop" ); } @@ -1074,7 +1050,11 @@ mod tests { } } assert_eq!(retried, 2, "both benign stops must be re-CONTed"); - assert_eq!(reached, Some(queue[2]), "loop must land on the awaited brk-trap"); + assert_eq!( + reached, + Some(queue[2]), + "loop must land on the awaited brk-trap" + ); } /// The strict final check is intact: a `SIGTRAP` with the WRONG event, and @@ -1086,7 +1066,10 @@ mod tests { // SIGTRAP but event=0 when the caller awaited the group-stop (128). let wrong_event = stopped_status(libc::SIGTRAP, 0); assert!( - matches!(classify_stop(wrong_event, PTRACE_EVENT_STOP), StopVerdict::Unexpected), + matches!( + classify_stop(wrong_event, PTRACE_EVENT_STOP), + StopVerdict::Unexpected + ), "a SIGTRAP with the wrong event must stay PtraceUnexpectedStatus" ); @@ -1175,12 +1158,12 @@ mod tests { #[test] fn thread_gone_detects_exit_and_esrch_only() { - assert!(is_thread_gone(&Error::PtraceAttach(io::Error::from_raw_os_error( - libc::ESRCH - )))); - assert!(is_thread_gone(&Error::PtraceOp(io::Error::from_raw_os_error( - libc::ESRCH - )))); + assert!(is_thread_gone(&Error::PtraceAttach( + io::Error::from_raw_os_error(libc::ESRCH) + ))); + assert!(is_thread_gone(&Error::PtraceOp( + io::Error::from_raw_os_error(libc::ESRCH) + ))); // WIFEXITED(0) is true (low 7 bits == 0): a clean thread exit status. assert!(libc::WIFEXITED(0)); assert!(is_thread_gone(&Error::PtraceUnexpectedStatus(0))); @@ -1188,9 +1171,9 @@ mod tests { let bad_stop = stopped_status(libc::SIGSEGV, 0); assert!(!is_thread_gone(&Error::PtraceUnexpectedStatus(bad_stop))); // A non-ESRCH op failure is a genuine fault, not a vanished thread. - assert!(!is_thread_gone(&Error::PtraceOp(io::Error::from_raw_os_error( - libc::EIO - )))); + assert!(!is_thread_gone(&Error::PtraceOp( + io::Error::from_raw_os_error(libc::EIO) + ))); } #[test] @@ -1217,7 +1200,11 @@ mod tests { ) .expect("a settling group converges to a fixpoint"); assert_eq!(stopped, vec![10, 11, 12]); - assert_eq!(seized, vec![10, 11, 12], "each live tid seized exactly once"); + assert_eq!( + seized, + vec![10, 11, 12], + "each live tid seized exactly once" + ); } #[test] @@ -1333,7 +1320,9 @@ mod tests { } drop(tx); - let worker_tids: Vec = (0..WORKERS).map(|_| rx.recv().expect("worker tid")).collect(); + let worker_tids: Vec = (0..WORKERS) + .map(|_| rx.recv().expect("worker tid")) + .collect(); let listed = enumerate_thread_group(std::process::id() as Pid).expect("enumerate own task dir"); diff --git a/crates/resetprop/src/seal/ptrace/arch/aarch64.rs b/crates/resetprop/src/seal/ptrace/arch/aarch64.rs index ac10cc7..d4d90c9 100644 --- a/crates/resetprop/src/seal/ptrace/arch/aarch64.rs +++ b/crates/resetprop/src/seal/ptrace/arch/aarch64.rs @@ -75,6 +75,22 @@ pub fn get_syscall_return(regs: &UserPtRegs) -> i64 { regs.regs[0] as i64 } +/// Read the syscall number (`x8`) at a syscall-entry stop. `x8` is preserved +/// across the syscall, so it is also valid at the exit stop. +#[inline] +pub fn syscall_nr(regs: &UserPtRegs) -> u64 { + regs.regs[8] +} + +/// Read syscall argument `n` (`0..6` → `x0..x5`) at a syscall-entry stop. +#[inline] +pub fn nth_syscall_arg(regs: &UserPtRegs, n: usize) -> u64 { + match n { + 0..=5 => regs.regs[n], + _ => panic!("syscall arg index {n} out of range (0..6)"), + } +} + /// Point the program counter (`pc`) at `pc` without touching the syscall /// registers. Used by the trampoline i-cache-sync path, which resumes the /// tracee at a staged instruction blob and exchanges no syscall arguments. diff --git a/crates/resetprop/src/seal/ptrace/arch/arm.rs b/crates/resetprop/src/seal/ptrace/arch/arm.rs index 57851e6..eebb385 100644 --- a/crates/resetprop/src/seal/ptrace/arch/arm.rs +++ b/crates/resetprop/src/seal/ptrace/arch/arm.rs @@ -61,6 +61,22 @@ pub fn get_syscall_return(regs: &UserPtRegs) -> i64 { regs.uregs[0] as i32 as i64 } +/// Read the syscall number (`r7` = `uregs[7]`) at a syscall-entry stop. +#[inline] +pub fn syscall_nr(regs: &UserPtRegs) -> u64 { + regs.uregs[7] as u64 +} + +/// Read syscall argument `n` (`0..6` → `r0..r5` = `uregs[0..6]`) at a +/// syscall-entry stop. +#[inline] +pub fn nth_syscall_arg(regs: &UserPtRegs, n: usize) -> u64 { + match n { + 0..=5 => regs.uregs[n] as u64, + _ => panic!("syscall arg index {n} out of range (0..6)"), + } +} + /// Point the program counter (`uregs[15]`) at `pc` (truncated to the 32-bit /// register width) without touching the syscall registers. Used by the /// trampoline i-cache-sync path, which resumes the tracee at a staged diff --git a/crates/resetprop/src/seal/ptrace/arch/riscv64.rs b/crates/resetprop/src/seal/ptrace/arch/riscv64.rs index 2a0f22d..04e454f 100644 --- a/crates/resetprop/src/seal/ptrace/arch/riscv64.rs +++ b/crates/resetprop/src/seal/ptrace/arch/riscv64.rs @@ -90,6 +90,27 @@ pub fn get_syscall_return(regs: &UserPtRegs) -> i64 { regs.a0 as i64 } +/// Read the syscall number (`a7`) at a syscall-entry stop. Provided for facade +/// totality; the riscv64 runtime path is deferred (see module note). +#[inline] +pub fn syscall_nr(regs: &UserPtRegs) -> u64 { + regs.a7 +} + +/// Read syscall argument `n` (`0..6` → `a0..a5`) at a syscall-entry stop. +#[inline] +pub fn nth_syscall_arg(regs: &UserPtRegs, n: usize) -> u64 { + match n { + 0 => regs.a0, + 1 => regs.a1, + 2 => regs.a2, + 3 => regs.a3, + 4 => regs.a4, + 5 => regs.a5, + _ => panic!("syscall arg index {n} out of range (0..6)"), + } +} + /// Point the program counter (`pc`) at `pc` without touching the syscall /// registers. Provided for facade totality; the riscv64 runtime seal path is /// deferred (see module note), so this is not exercised against a live tracee. diff --git a/crates/resetprop/src/seal/ptrace/arch/x86.rs b/crates/resetprop/src/seal/ptrace/arch/x86.rs index c01cf2f..03fe369 100644 --- a/crates/resetprop/src/seal/ptrace/arch/x86.rs +++ b/crates/resetprop/src/seal/ptrace/arch/x86.rs @@ -75,6 +75,30 @@ pub fn get_syscall_return(regs: &UserPtRegs) -> i64 { regs.eax as i32 as i64 } +/// Read the syscall number at a syscall-entry stop. As on x86-64 the kernel +/// clobbers `eax` to `-ENOSYS` on entry and keeps the real number in +/// `orig_eax`, so the snoop reads `orig_eax`. +#[inline] +pub fn syscall_nr(regs: &UserPtRegs) -> u64 { + regs.orig_eax as u64 +} + +/// Read syscall argument `n` (`0..6` → `ebx, ecx, edx, esi, edi, ebp`) at a +/// syscall-entry stop. +#[inline] +pub fn nth_syscall_arg(regs: &UserPtRegs, n: usize) -> u64 { + let arg = match n { + 0 => regs.ebx, + 1 => regs.ecx, + 2 => regs.edx, + 3 => regs.esi, + 4 => regs.edi, + 5 => regs.ebp, + _ => panic!("syscall arg index {n} out of range (0..6)"), + }; + arg as u64 +} + /// Point the program counter (`eip`) at `pc` (truncated to the 32-bit /// register width) without touching the syscall registers. Used by the /// trampoline i-cache-sync path, which resumes the tracee at a staged diff --git a/crates/resetprop/src/seal/ptrace/arch/x86_64.rs b/crates/resetprop/src/seal/ptrace/arch/x86_64.rs index 4167e5e..978318a 100644 --- a/crates/resetprop/src/seal/ptrace/arch/x86_64.rs +++ b/crates/resetprop/src/seal/ptrace/arch/x86_64.rs @@ -86,6 +86,29 @@ pub fn get_syscall_return(regs: &UserPtRegs) -> i64 { regs.rax as i64 } +/// Read the syscall number at a syscall-entry stop. The kernel clobbers `rax` +/// to `-ENOSYS` on entry and preserves the real number in `orig_rax`, so the +/// snoop reads `orig_rax` rather than the injection-side `rax`. +#[inline] +pub fn syscall_nr(regs: &UserPtRegs) -> u64 { + regs.orig_rax +} + +/// Read syscall argument `n` (`0..6` → `rdi, rsi, rdx, r10, r8, r9`) at a +/// syscall-entry stop. +#[inline] +pub fn nth_syscall_arg(regs: &UserPtRegs, n: usize) -> u64 { + match n { + 0 => regs.rdi, + 1 => regs.rsi, + 2 => regs.rdx, + 3 => regs.r10, + 4 => regs.r8, + 5 => regs.r9, + _ => panic!("syscall arg index {n} out of range (0..6)"), + } +} + /// Point the program counter (`rip`) at `pc` without touching the syscall /// registers. Used by the trampoline i-cache-sync path, which resumes the /// tracee at a staged instruction blob and exchanges no syscall arguments. diff --git a/crates/resetprop/src/seal/selinux.rs b/crates/resetprop/src/seal/selinux.rs new file mode 100644 index 0000000..be091d2 --- /dev/null +++ b/crates/resetprop/src/seal/selinux.rs @@ -0,0 +1,123 @@ +//! Relabel the in-memory hook fd to the on-device libc.so security context so +//! init can map it `PROT_READ | PROT_EXEC` without an SELinux `execute` denial. +//! +//! Ports injectrc's `set_system_con` (init_injector/ptrace_utils.cpp): take the +//! context of the system libc.so and `setfilecon` it onto the staged fd path. +//! +//! Single-dep law: no `selinux-sys` crate. The Android NDK ships no libselinux +//! to link against, so the Android path resolves `getfilecon` / `setfilecon` / +//! `freecon` at runtime via `dlopen`; on-device the loader always finds +//! `/system/lib*/libselinux.so`. Off-device the relabel is meaningless (there +//! is no real init to satisfy), so the host build compiles a shim that reports +//! the operation unsupported. + +use crate::error::{Error, Result}; +use std::ffi::CStr; + +/// Relabel `path` to the SELinux context init is permitted to execute. +#[cfg(not(target_os = "android"))] +pub(crate) fn relabel_to_libc_context(_path: &CStr) -> Result<()> { + Err(Error::Unsupported( + "SELinux relabel only applies against on-device init".into(), + )) +} + +/// Relabel `path` to the SELinux context init is permitted to execute. +#[cfg(target_os = "android")] +pub(crate) fn relabel_to_libc_context(path: &CStr) -> Result<()> { + android::relabel(path) +} + +#[cfg(target_os = "android")] +mod android { + use super::{CStr, Error, Result}; + use std::ffi::CString; + use std::os::raw::{c_char, c_int, c_void}; + + type GetFileCon = unsafe extern "C" fn(*const c_char, *mut *mut c_char) -> c_int; + type SetFileCon = unsafe extern "C" fn(*const c_char, *const c_char) -> c_int; + type FreeCon = unsafe extern "C" fn(*mut c_char); + + struct Libselinux { + getfilecon: GetFileCon, + setfilecon: SetFileCon, + freecon: FreeCon, + } + + /// libc.so always carries a context init may execute; mirror injectrc's + /// fixed-path probe rather than re-deriving init's exact maps row. + const LIBC_PATH: &CStr = c"/system/lib64/libc.so"; + + /// Used when `getfilecon` on libc.so fails (matches injectrc's fallback). + const FALLBACK_CON: &CStr = c"u:object_r:system_file:s0"; + + pub(super) fn relabel(path: &CStr) -> Result<()> { + let lib = load()?; + let con = libc_context(&lib); + // SAFETY: `path` and `con` are valid NUL-terminated C strings; the fn + // pointer was resolved from libselinux by name. + let rc = unsafe { (lib.setfilecon)(path.as_ptr(), con.as_ptr()) }; + if rc != 0 { + return Err(Error::HookInstallFailed(format!( + "setfilecon({}) failed", + path.to_string_lossy() + ))); + } + Ok(()) + } + + fn load() -> Result { + let name = c"libselinux.so"; + // SAFETY: opening a system library by literal name; RTLD_NOW resolves + // every symbol eagerly so a missing export fails here, not mid-call. + let handle = unsafe { libc::dlopen(name.as_ptr(), libc::RTLD_NOW) }; + if handle.is_null() { + return Err(Error::HookInstallFailed( + "dlopen libselinux.so failed".into(), + )); + } + // The handle is intentionally never `dlclose`d: libselinux stays + // resident for the single per-boot install and the process is short. + // SAFETY: each symbol is resolved by name then transmuted to its + // libselinux ABI; a null lookup is rejected before transmute. + unsafe { + Ok(Libselinux { + getfilecon: std::mem::transmute::<*mut c_void, GetFileCon>(sym( + handle, + c"getfilecon", + )?), + setfilecon: std::mem::transmute::<*mut c_void, SetFileCon>(sym( + handle, + c"setfilecon", + )?), + freecon: std::mem::transmute::<*mut c_void, FreeCon>(sym(handle, c"freecon")?), + }) + } + } + + /// SAFETY: `handle` is a live `dlopen` handle. + unsafe fn sym(handle: *mut c_void, name: &CStr) -> Result<*mut c_void> { + let ptr = libc::dlsym(handle, name.as_ptr()); + if ptr.is_null() { + return Err(Error::HookInstallFailed(format!( + "dlsym {} failed", + name.to_string_lossy() + ))); + } + Ok(ptr) + } + + fn libc_context(lib: &Libselinux) -> CString { + let mut con: *mut c_char = std::ptr::null_mut(); + // SAFETY: getfilecon allocates `*con` on success; freed below. + let rc = unsafe { (lib.getfilecon)(LIBC_PATH.as_ptr(), &mut con) }; + if rc < 0 || con.is_null() { + return FALLBACK_CON.to_owned(); + } + // SAFETY: `con` is a NUL-terminated context string owned by libselinux. + let owned = unsafe { CStr::from_ptr(con) }.to_owned(); + // SAFETY: release the libselinux-allocated context. + unsafe { (lib.freecon)(con) }; + owned + } +} diff --git a/crates/resetprop/src/trie.rs b/crates/resetprop/src/trie.rs index 5809aa9..1ad1552 100644 --- a/crates/resetprop/src/trie.rs +++ b/crates/resetprop/src/trie.rs @@ -51,7 +51,11 @@ impl<'a> TrieNode<'a> { } pub(crate) fn name_bytes(&self) -> &[u8] { - let len = (self.namelen() as usize).min(self.area.len().saturating_sub(self.offset + TRIE_NODE_FIXED)); + let len = (self.namelen() as usize).min( + self.area + .len() + .saturating_sub(self.offset + TRIE_NODE_FIXED), + ); let start = self.offset + TRIE_NODE_FIXED; unsafe { std::slice::from_raw_parts(self.area.base().add(start), len) } } @@ -92,7 +96,11 @@ pub(crate) fn find(area: &PropArea, name: &str) -> Result<(usize, usize)> { return Err(Error::NotFound); } - current = bst_find(area, area.data_offset() + children_off as usize, segment.as_bytes())?; + current = bst_find( + area, + area.data_offset() + children_off as usize, + segment.as_bytes(), + )?; match rest { Some(r) => remaining = r, @@ -179,7 +187,11 @@ where } /// BST insert: returns offset of existing or newly inserted node. -pub(crate) fn bst_insert(area: &PropArea, parent_children: &AtomicU32, name: &[u8]) -> Result { +pub(crate) fn bst_insert( + area: &PropArea, + parent_children: &AtomicU32, + name: &[u8], +) -> Result { let root_off = parent_children.load(AO::Acquire); if root_off == 0 { let off = alloc_trie_node(area, name)?; @@ -256,7 +268,11 @@ pub(crate) fn find_path(area: &PropArea, name: &str) -> Result> { return Err(Error::NotFound); } - let found = bst_find(area, area.data_offset() + children_off as usize, segment.as_bytes())?; + let found = bst_find( + area, + area.data_offset() + children_off as usize, + segment.as_bytes(), + )?; path.push(found.offset()); current = found; diff --git a/crates/resetprop/tests/elf_fixture_smoke.rs b/crates/resetprop/tests/elf_fixture_smoke.rs index ddb1bcd..9eebe52 100644 --- a/crates/resetprop/tests/elf_fixture_smoke.rs +++ b/crates/resetprop/tests/elf_fixture_smoke.rs @@ -71,14 +71,9 @@ fn fixture_symbol_resolves() { workspace_root().join("target/release/libelf_fixture.so") } }; - assert!( - so_path.exists(), - "cdylib missing at {}", - so_path.display() - ); + assert!(so_path.exists(), "cdylib missing at {}", so_path.display()); - let file = File::open(&so_path) - .unwrap_or_else(|e| panic!("open {}: {e}", so_path.display())); + let file = File::open(&so_path).unwrap_or_else(|e| panic!("open {}: {e}", so_path.display())); let view = parse_libc_elf(&file).expect("parse_libc_elf on elf_fixture"); let resolved = resolve_symbol(&view, FIXTURE_SYMBOL) diff --git a/crates/resetprop/tests/ptrace_core_smoke.rs b/crates/resetprop/tests/ptrace_core_smoke.rs index 13a9a49..9fc626c 100644 --- a/crates/resetprop/tests/ptrace_core_smoke.rs +++ b/crates/resetprop/tests/ptrace_core_smoke.rs @@ -1,12 +1,13 @@ -//! Integration smoke test for the T4 remote_syscall injector. +//! Integration smoke test for the consolidated `remote_syscall_via_poke` +//! injector. //! //! Forks a child that installs an anonymous RWX scratch page and //! enters `libc::pause()`, then the parent seizes + interrupts the -//! child and round-trips `remote_syscall(NR_GETPID, [0; 6])`. Asserts +//! child and round-trips `remote_syscall_via_poke(NR_GETPID, [0; 6])`. Asserts //! the returned `x0` equals the child's PID. //! //! Architecture gate: the entire file is gated behind -//! `#[cfg(target_arch = "aarch64")]` because `remote_syscall` stages the +//! `#[cfg(target_arch = "aarch64")]` because `remote_syscall_via_poke` stages the //! ARM64 byte sequence `svc #0 ; brk #0`, expects the AArch64 syscall //! calling convention (`x8` = syscall number, `x0..x5` = args), and reads //! results through a 272-byte `UserPtRegs` whose layout is aarch64-only @@ -18,7 +19,7 @@ //! //! Preconditions (aarch64 hosts only): //! - /proc/sys/kernel/yama/ptrace_scope <= 1, OR CAP_SYS_PTRACE. -//! - Linux with process_vm_readv/writev (kernel 3.2+). +//! - Linux ptrace PEEK/POKEDATA (universally available). //! //! Runner invocation (the test is #[ignore]'d by default): //! cargo test -p resetprop --test ptrace_core_smoke -- \ @@ -31,7 +32,7 @@ use resetprop::seal::ptrace::PTRACE_EVENT_STOP; use resetprop::seal::{ - ptrace_detach, ptrace_interrupt, ptrace_seize, remote_syscall, wait_stop, + ptrace_detach, ptrace_interrupt, ptrace_seize, remote_syscall_via_poke, wait_stop, }; /// AArch64 syscall number for `getpid()`; local to this test so the syscall @@ -39,9 +40,7 @@ use resetprop::seal::{ /// source: asm-generic/unistd.h:461 (`__NR_getpid = 172`). const NR_GETPID: u64 = 172; -// ───────────────────────────────────────────────────────────────────────────── // Helpers (verbatim from phases/seal/references/test-harness-patterns.md §3) -// ───────────────────────────────────────────────────────────────────────────── /// Fork a child running `child_body`; return the child pid to the parent. /// Safety: `child_body` must not return (it should `_exit`, `loop`, or `panic!`). @@ -54,7 +53,11 @@ const NR_GETPID: u64 = 172; /// narrower bound is strictly sufficient. unsafe fn fork_child(child_body: fn() -> !) -> libc::pid_t { let pid = libc::fork(); - assert!(pid >= 0, "fork() failed: {}", std::io::Error::last_os_error()); + assert!( + pid >= 0, + "fork() failed: {}", + std::io::Error::last_os_error() + ); if pid == 0 { child_body(); } @@ -89,9 +92,7 @@ impl Drop for ChildGuard { } } -// ───────────────────────────────────────────────────────────────────────────── // Child body — inherits the pre-fork RWX scratch page via COW and blocks -// ───────────────────────────────────────────────────────────────────────────── fn child_body() -> ! { loop { @@ -104,9 +105,7 @@ fn child_body() -> ! { } } -// ───────────────────────────────────────────────────────────────────────────── // Test — round-trip getpid() through remote_syscall -// ───────────────────────────────────────────────────────────────────────────── #[test] #[ignore = "requires ptrace_scope<=1 or CAP_SYS_PTRACE; run with: cargo test -p resetprop --test ptrace_core_smoke -- --ignored --test-threads=1"] @@ -155,8 +154,8 @@ fn remote_getpid_returns_child_pid() { // (event byte == PTRACE_EVENT_STOP == 128). wait_stop verifies // WIFSTOPPED && WSTOPSIG == SIGTRAP && event == expected_event — so the // expected_event argument is how the caller selects which stop kind is - // legal at this point. remote_syscall passes 0 internally to pin its - // own waitpid to brk-traps only. + // legal at this point. remote_syscall_via_poke passes 0 internally to pin + // its own waitpid to brk-traps only. ptrace_seize(guard.pid()).expect("ptrace_seize"); ptrace_interrupt(guard.pid()).expect("ptrace_interrupt"); wait_stop(guard.pid(), PTRACE_EVENT_STOP).expect("wait_stop (initial SEIZE stop)"); @@ -169,10 +168,8 @@ fn remote_getpid_returns_child_pid() { // names an RWX page with 4096 bytes of room — far more than the 8 bytes // the injector stages at `scratch_pc`. The child is single-threaded and // blocked in pause(), so no other thread races on those 8 bytes. - let ret = unsafe { - remote_syscall(guard.pid(), scratch_pc, NR_GETPID, [0; 6]) - } - .expect("remote_syscall"); + let ret = unsafe { remote_syscall_via_poke(guard.pid(), scratch_pc, NR_GETPID, [0; 6]) } + .expect("remote_syscall_via_poke"); assert_eq!( ret, child_pid as i64, diff --git a/crates/resetprop/tests/ptrace_core_smoke_arm.rs b/crates/resetprop/tests/ptrace_core_smoke_arm.rs new file mode 100644 index 0000000..7901be5 --- /dev/null +++ b/crates/resetprop/tests/ptrace_core_smoke_arm.rs @@ -0,0 +1,89 @@ +//! Per-arch encoding smoke test for the 32-bit ARM (AArch32) ptrace facade. +//! +//! Sibling of `ptrace_core_smoke.rs` (the aarch64 live SEIZE round-trip). +//! Where that file forks a tracee and round-trips `getpid()`, this file +//! asserts the *static* arch contract the `remote_syscall` stager depends on: +//! the syscall-trap / breakpoint instruction encodings, the NT_PRSTATUS regset +//! byte size, and the Linux EABI syscall-ABI register wiring. These are +//! compile-time-constant assertions plus one pure register-staging round-trip, +//! so the test needs no ptrace, no fork, and no `#[ignore]`. +//! +//! Architecture gate: the entire file is gated behind +//! `#[cfg(target_arch = "arm")]`. The constants under test +//! (`resetprop::seal::ptrace::{TRAP_INSN, BRK_INSN, NT_PRSTATUS_SIZE, +//! UserPtRegs, set_syscall_args, get_syscall_return}`) are re-exported from the +//! `cfg`-selected active arch module, so they hold the AArch32 values only when +//! the crate is built for arm. On any other host this file compiles to an empty +//! test binary, reporting `0 passed; 0 failed; 0 ignored`. +//! +//! Encoding sources (mirrored from +//! `crates/resetprop/src/seal/ptrace/arch/arm.rs`, grounded against injectrc +//! `init_injector/ptrace_utils.hpp:51-64` REG_* macros): +//! - `svc #0` (ARM enc, `00 00 00 ef`) → TRAP_INSN == 0xef00_0000 (ARM ARM A8.8.361) +//! - `bkpt #0` (ARM enc, `70 00 20 e1`) → BRK_INSN == 0xe120_0070 (ARM ARM A8.8.24) +//! - regset = sizeof(struct pt_regs { long uregs[18]; }) == 72 bytes +//! +//! Runner invocation: +//! cargo test -p resetprop --target armv7-linux-androideabi \ +//! --test ptrace_core_smoke_arm + +#![cfg(target_arch = "arm")] + +use resetprop::seal::ptrace::{ + get_syscall_return, set_syscall_args, UserPtRegs, BRK_INSN, NT_PRSTATUS_SIZE, TRAP_INSN, +}; + +/// The AArch32 trap/breakpoint encodings the gadget stages: `svc #0` then +/// `bkpt #0` (ARM-mode encodings). Pinned little-endian per `arch/arm.rs`. +/// Thumb-mode handling is deferred with the rest of the armv7 runtime port. +#[test] +fn arm_trap_brk_encodings() { + assert_eq!( + TRAP_INSN, 0xef00_0000, + "ARM `svc #0` must encode as 00 00 00 ef" + ); + assert_eq!( + BRK_INSN, 0xe120_0070, + "ARM `bkpt #0` must encode as 70 00 20 e1" + ); +} + +/// The NT_PRSTATUS iovec contract: `UserPtRegs` is exactly +/// `sizeof(struct pt_regs)` == 72 bytes (18 * u32), so a GETREGSET iovec staged +/// from this layout reads the full kernel regset without truncation. +#[test] +fn arm_regset_byte_contract() { + assert_eq!(NT_PRSTATUS_SIZE, 72, "ARM NT_PRSTATUS regset is 18 * u32"); + assert_eq!( + core::mem::size_of::(), + NT_PRSTATUS_SIZE, + "UserPtRegs must match the NT_PRSTATUS byte contract", + ); +} + +/// The Linux EABI syscall ABI wiring: pc in `uregs[15]`, number in `r7` +/// (`uregs[7]`), args in `r0..r5` (`uregs[0..6]`), return read back from `r0` +/// (`uregs[0]`). Args are truncated to the 32-bit register width. Staging then +/// reading round-trips the syscall number through `r7` and confirms each arg +/// lands in its ABI register. +#[test] +fn arm_syscall_abi_register_wiring() { + let mut regs = UserPtRegs::default(); + let args = [11, 22, 33, 44, 55, 66]; + set_syscall_args(&mut regs, 0xdead_beef, 20, args); + + assert_eq!(regs.uregs[15], 0xdead_beef, "pc -> uregs[15]"); + assert_eq!(regs.uregs[7], 20, "syscall number -> r7 (uregs[7])"); + for (i, want) in args.iter().enumerate() { + assert_eq!(regs.uregs[i], *want as u32, "arg{i} -> uregs[{i}]"); + } + + // Post-trap, the kernel writes the return value into r0; the helper reads + // it back sign-extended to i64 so `-errno` returns survive. + regs.uregs[0] = (-14_i32) as u32; + assert_eq!( + get_syscall_return(®s), + -14, + "return read from r0, sign-extended" + ); +} diff --git a/crates/resetprop/tests/ptrace_core_smoke_x86.rs b/crates/resetprop/tests/ptrace_core_smoke_x86.rs new file mode 100644 index 0000000..5c1dffc --- /dev/null +++ b/crates/resetprop/tests/ptrace_core_smoke_x86.rs @@ -0,0 +1,90 @@ +//! Per-arch encoding smoke test for the x86 (i386) ptrace facade. +//! +//! Sibling of `ptrace_core_smoke.rs` (the aarch64 live SEIZE round-trip). +//! Where that file forks a tracee and round-trips `getpid()`, this file +//! asserts the *static* arch contract the `remote_syscall` stager depends on: +//! the syscall-trap / breakpoint instruction encodings, the NT_PRSTATUS regset +//! byte size, and the Linux i386 syscall-ABI register wiring. These are +//! compile-time-constant assertions plus one pure register-staging round-trip, +//! so the test needs no ptrace, no fork, and no `#[ignore]`. +//! +//! Architecture gate: the entire file is gated behind +//! `#[cfg(target_arch = "x86")]`. The constants under test +//! (`resetprop::seal::ptrace::{TRAP_INSN, BRK_INSN, NT_PRSTATUS_SIZE, +//! UserPtRegs, set_syscall_args, get_syscall_return}`) are re-exported from the +//! `cfg`-selected active arch module, so they hold the i386 values only when +//! the crate is built for x86. On any other host this file compiles to an empty +//! test binary, reporting `0 passed; 0 failed; 0 ignored`. +//! +//! Encoding sources (mirrored from +//! `crates/resetprop/src/seal/ptrace/arch/x86.rs`, grounded against injectrc +//! `init_injector/ptrace_utils.hpp:35-42` REG_* macros): +//! - `int $0x80` (`cd 80`) → TRAP_INSN == 0x0000_80cd (Intel SDM Vol 2A) +//! - `int3` (`cc`) → BRK_INSN == 0x0000_00cc (Intel SDM Vol 2A) +//! - regset = sizeof(struct user_regs_struct) == 68 bytes +//! +//! Runner invocation: +//! cargo test -p resetprop --target i686-linux-android \ +//! --test ptrace_core_smoke_x86 + +#![cfg(target_arch = "x86")] + +use resetprop::seal::ptrace::{ + get_syscall_return, set_syscall_args, UserPtRegs, BRK_INSN, NT_PRSTATUS_SIZE, TRAP_INSN, +}; + +/// The i386 trap/breakpoint encodings the gadget stages: `int $0x80` (`cd 80`) +/// then `int3` (`cc`). Pinned little-endian into the low half of the `u32` +/// instruction constants per `arch/x86.rs`. +#[test] +fn x86_trap_brk_encodings() { + assert_eq!( + TRAP_INSN, 0x0000_80cd, + "i386 `int $0x80` must encode as cd 80" + ); + assert_eq!(BRK_INSN, 0x0000_00cc, "i386 `int3` must encode as cc"); +} + +/// The NT_PRSTATUS iovec contract: `UserPtRegs` is exactly +/// `sizeof(struct user_regs_struct)` == 68 bytes (17 * u32), so a GETREGSET +/// iovec staged from this layout reads the full kernel regset without +/// truncation. +#[test] +fn x86_regset_byte_contract() { + assert_eq!(NT_PRSTATUS_SIZE, 68, "i386 NT_PRSTATUS regset is 17 * u32"); + assert_eq!( + core::mem::size_of::(), + NT_PRSTATUS_SIZE, + "UserPtRegs must match the NT_PRSTATUS byte contract", + ); +} + +/// The Linux i386 syscall ABI wiring: number in `eax`, args in +/// `ebx, ecx, edx, esi, edi, ebp`, return read back from `eax`. Args are +/// truncated to the 32-bit register width. Staging then reading round-trips the +/// syscall number through `eax` and confirms each arg lands in its ABI +/// register. +#[test] +fn x86_syscall_abi_register_wiring() { + let mut regs = UserPtRegs::default(); + let args = [11, 22, 33, 44, 55, 66]; + set_syscall_args(&mut regs, 0xdead_beef, 20, args); + + assert_eq!(regs.eip, 0xdead_beef, "pc -> eip"); + assert_eq!(regs.eax, 20, "syscall number -> eax"); + assert_eq!(regs.ebx, 11, "arg0 -> ebx"); + assert_eq!(regs.ecx, 22, "arg1 -> ecx"); + assert_eq!(regs.edx, 33, "arg2 -> edx"); + assert_eq!(regs.esi, 44, "arg3 -> esi"); + assert_eq!(regs.edi, 55, "arg4 -> edi"); + assert_eq!(regs.ebp, 66, "arg5 -> ebp"); + + // Post-trap, the kernel writes the return value into eax; the helper reads + // it back sign-extended to i64 so `-errno` returns survive. + regs.eax = (-14_i32) as u32; + assert_eq!( + get_syscall_return(®s), + -14, + "return read from eax, sign-extended" + ); +} diff --git a/crates/resetprop/tests/ptrace_core_smoke_x86_64.rs b/crates/resetprop/tests/ptrace_core_smoke_x86_64.rs new file mode 100644 index 0000000..dffca29 --- /dev/null +++ b/crates/resetprop/tests/ptrace_core_smoke_x86_64.rs @@ -0,0 +1,91 @@ +//! Per-arch encoding smoke test for the x86-64 ptrace facade. +//! +//! Sibling of `ptrace_core_smoke.rs` (the aarch64 live SEIZE round-trip). +//! Where that file forks a tracee and round-trips `getpid()`, this file +//! asserts the *static* arch contract the `remote_syscall` stager depends on: +//! the syscall-trap / breakpoint instruction encodings, the NT_PRSTATUS regset +//! byte size, and the SysV/Linux x86-64 syscall-ABI register wiring. These are +//! compile-time-constant assertions plus one pure register-staging round-trip, +//! so the test needs no ptrace, no fork, and no `#[ignore]`. +//! +//! Architecture gate: the entire file is gated behind +//! `#[cfg(target_arch = "x86_64")]`. The constants under test +//! (`resetprop::seal::ptrace::{TRAP_INSN, BRK_INSN, NT_PRSTATUS_SIZE, +//! UserPtRegs, set_syscall_args, get_syscall_return}`) are re-exported from the +//! `cfg`-selected active arch module, so they hold the x86-64 values only when +//! the crate is built for x86-64. On any other host this file compiles to an +//! empty test binary, reporting `0 passed; 0 failed; 0 ignored`. +//! +//! Encoding sources (mirrored from +//! `crates/resetprop/src/seal/ptrace/arch/x86_64.rs`, grounded against injectrc +//! `init_injector/ptrace_utils.hpp:25-34` REG_* macros): +//! - `syscall` (`0f 05`) → TRAP_INSN == 0x0000_050f (Intel SDM Vol 2B) +//! - `int3` (`cc`) → BRK_INSN == 0x0000_00cc (Intel SDM Vol 2A) +//! - regset = sizeof(struct user_regs_struct) == 216 bytes +//! +//! Runner invocation: +//! cargo test -p resetprop --target x86_64-linux-android \ +//! --test ptrace_core_smoke_x86_64 + +#![cfg(target_arch = "x86_64")] + +use resetprop::seal::ptrace::{ + get_syscall_return, set_syscall_args, UserPtRegs, BRK_INSN, NT_PRSTATUS_SIZE, TRAP_INSN, +}; + +/// The x86-64 trap/breakpoint encodings the gadget stages: `syscall` (`0f 05`) +/// then `int3` (`cc`). Pinned little-endian into the low half of the `u32` +/// instruction constants per `arch/x86_64.rs`. +#[test] +fn x86_64_trap_brk_encodings() { + assert_eq!( + TRAP_INSN, 0x0000_050f, + "x86-64 `syscall` must encode as 0f 05" + ); + assert_eq!(BRK_INSN, 0x0000_00cc, "x86-64 `int3` must encode as cc"); +} + +/// The NT_PRSTATUS iovec contract: `UserPtRegs` is exactly +/// `sizeof(struct user_regs_struct)` == 216 bytes, so a GETREGSET iovec staged +/// from this layout reads the full kernel regset without truncation. +#[test] +fn x86_64_regset_byte_contract() { + assert_eq!( + NT_PRSTATUS_SIZE, 216, + "x86-64 NT_PRSTATUS regset is 27 * u64" + ); + assert_eq!( + core::mem::size_of::(), + NT_PRSTATUS_SIZE, + "UserPtRegs must match the NT_PRSTATUS byte contract", + ); +} + +/// The SysV/Linux x86-64 syscall ABI wiring: number in `rax`, args in +/// `rdi, rsi, rdx, r10, r8, r9`, return read back from `rax`. Staging then +/// reading round-trips the syscall number through `rax` and confirms each arg +/// lands in its ABI register. +#[test] +fn x86_64_syscall_abi_register_wiring() { + let mut regs = UserPtRegs::default(); + let args = [11, 22, 33, 44, 55, 66]; + set_syscall_args(&mut regs, 0xdead_beef, 39, args); + + assert_eq!(regs.rip, 0xdead_beef, "pc -> rip"); + assert_eq!(regs.rax, 39, "syscall number -> rax"); + assert_eq!(regs.rdi, 11, "arg0 -> rdi"); + assert_eq!(regs.rsi, 22, "arg1 -> rsi"); + assert_eq!(regs.rdx, 33, "arg2 -> rdx"); + assert_eq!(regs.r10, 44, "arg3 -> r10"); + assert_eq!(regs.r8, 55, "arg4 -> r8"); + assert_eq!(regs.r9, 66, "arg5 -> r9"); + + // Post-trap, the kernel writes the return value into rax; the helper reads + // it back as a signed i64 so `-errno` returns survive. + regs.rax = (-14_i64) as u64; + assert_eq!( + get_syscall_return(®s), + -14, + "return read from rax as i64" + ); +} diff --git a/crates/resetprop/tests/tier_a_child_smoke.rs b/crates/resetprop/tests/tier_a_child_smoke.rs index 60128ff..12ee6c5 100644 --- a/crates/resetprop/tests/tier_a_child_smoke.rs +++ b/crates/resetprop/tests/tier_a_child_smoke.rs @@ -51,9 +51,7 @@ use std::path::{Path, PathBuf}; use std::sync::OnceLock; use std::time::Duration; -// ───────────────────────────────────────────────────────────────────────────── // Constants -// ───────────────────────────────────────────────────────────────────────────── const PAGE_SIZE: usize = 4096; const ARENA_PAGES: usize = 4; @@ -62,9 +60,7 @@ const SENTINEL_OFFSET: usize = 128; const SENTINEL_PRE: u8 = 0xAA; const SENTINEL_POST: u8 = 0xBB; -// ───────────────────────────────────────────────────────────────────────────── // Helpers (verbatim from phases/seal/references/test-harness-patterns.md §3) -// ───────────────────────────────────────────────────────────────────────────── /// Fork a child running `child_body`; return the child pid to the parent. /// Safety: `child_body` must not return (it should `_exit`, `loop`, or `panic!`). @@ -78,7 +74,11 @@ const SENTINEL_POST: u8 = 0xBB; /// static initialized by the parent immediately before `fork()`. unsafe fn fork_child(child_body: fn() -> !) -> libc::pid_t { let pid = libc::fork(); - assert!(pid >= 0, "fork() failed: {}", std::io::Error::last_os_error()); + assert!( + pid >= 0, + "fork() failed: {}", + std::io::Error::last_os_error() + ); if pid == 0 { child_body(); } @@ -117,7 +117,6 @@ fn sleep_ms(ms: u64) { std::thread::sleep(Duration::from_millis(ms)); } -// ───────────────────────────────────────────────────────────────────────────── // Child path propagation // // The `fn() -> !` fork bound precludes closure captures, so the child body @@ -125,13 +124,10 @@ fn sleep_ms(ms: u64) { // by the parent immediately before `fork()`. After `fork()`, the child // inherits the same virtual-memory image via COW page tables, so the value // stored in `CHILD_PATH` is visible to the child without any IPC. -// ───────────────────────────────────────────────────────────────────────────── static CHILD_PATH: OnceLock = OnceLock::new(); -// ───────────────────────────────────────────────────────────────────────────── // Child body — mmaps the tempfile MAP_SHARED and write-loops a sentinel -// ───────────────────────────────────────────────────────────────────────────── fn child_body_mmap_loop() -> ! { // SAFETY: All libc calls below are the standard open/mmap/usleep FFI @@ -181,10 +177,8 @@ fn child_body_mmap_loop() -> ! { } } -// ───────────────────────────────────────────────────────────────────────────── // Third-observer read path — independent file handle, never aliases the // child's mapping. Demonstrates the inode's ground-truth state. -// ───────────────────────────────────────────────────────────────────────────── fn read_file_byte(path: &Path, offset: usize) -> u8 { let mut f = OpenOptions::new() @@ -198,9 +192,7 @@ fn read_file_byte(path: &Path, offset: usize) -> u8 { buf[0] } -// ───────────────────────────────────────────────────────────────────────────── // Test -// ───────────────────────────────────────────────────────────────────────────── #[test] #[ignore = "requires ptrace_scope<=1; run with --ignored --test-threads=1"] @@ -263,7 +255,8 @@ fn seal_arena_blocks_child_writes_from_reaching_file() { .expect("open tempfile for post-seal reset"); f.seek(SeekFrom::Start(SENTINEL_OFFSET as u64)) .expect("seek SENTINEL_OFFSET post-seal"); - f.write_all(&[SENTINEL_PRE]).expect("write SENTINEL_PRE post-seal"); + f.write_all(&[SENTINEL_PRE]) + .expect("write SENTINEL_PRE post-seal"); f.sync_all().expect("sync_all post-seal"); } diff --git a/deny.toml b/deny.toml new file mode 100644 index 0000000..e038a73 --- /dev/null +++ b/deny.toml @@ -0,0 +1,42 @@ +# Casting-plane Rust supply gate (enforcement.md: license allowlist + advisories, +# the cargo-deny half of the polyglot supply line). cargo-deny 0.19.x schema. +# Widening the allowlist is an ADR plus a canary promotion, not a silent edit. + +[graph] +all-features = true + +[licenses] +# Allowlist only: a crate whose license is not listed here fails the build. +# This is the deterministic "unlicensed/disallowed crate -> bump" gate. +allow = [ + "MIT", + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "BSD-2-Clause", + "BSD-3-Clause", + "ISC", + "Unicode-3.0", + "Zlib", +] +confidence-threshold = 0.9 +# First-party workspace crates (publish = false) are your own source, not a +# supply-line dependency: gate the licenses of what you pull IN, not what you +# write. Unpublished crates are skipped; an external crate is still gated. +private = { ignore = true } + +[bans] +multiple-versions = "warn" +wildcards = "deny" +# Internal workspace path/git deps legitimately carry no version; exempt them so only +# EXTERNAL wildcard requirements (the real supply risk) fail the ban. +allow-wildcard-paths = true + +[sources] +unknown-registry = "deny" +unknown-git = "deny" + +# [advisories] is intentionally minimal: the RustSec advisory DB is a fetched +# feed (network, like OWASP dependency-check's NVD feed). The license + bans + +# sources checks above are fully offline and deterministic. +[advisories] +yanked = "deny" diff --git a/gradle/dependency-guard.config b/gradle/dependency-guard.config new file mode 100644 index 0000000..deda00b --- /dev/null +++ b/gradle/dependency-guard.config @@ -0,0 +1,10 @@ +# The module-edge tier matrix, made executable. The :dependencyGuard task reads each +# module's tier off its Gradle path and fails any project->project edge this matrix +# does not list. Adding an edge is a recorded decision: it changes here. +# +# Format: -> ... +# - a contract target is matched face-precise: contract:config | contract:state +# - any other target is matched by bare tier: core | domain | ui | app +# +# Greenfield seed: fill in your tiers. An empty file permits NO edges (every +# project->project edge fails) until you list them. A line without '->' is rejected. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..354b292 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,10 @@ +# Greenfield seed. build-logic imports this by path so the version pins stay one +# source of truth. Mirrors the law's enforcement toolchain; uncomment the matching +# implementation lines in build-logic/build.gradle.kts when JVM modules arrive. +[versions] +agp = "9.2.0" +kotlin = "2.3.21" + +[libraries] +android-gradlePlugin = { module = "com.android.tools.build:gradle", version.ref = "agp" } +kotlin-gradlePlugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..a647f70 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,10 @@ +# Single source of truth for the toolchain version: the enforcement wall (rustfmt + +# clippy) and the host checks resolve to this exact version, so a newer rustfmt can +# never silently reformat the tree out from under CI. Pinned to the Casting law's rust +# (casting/gradle/libs.versions.toml: rust = 1.96.0). Cross targets are added at the +# cross-compile sites (build.sh, the CI cross jobs), NOT here: listing them forces +# every host cargo command to fetch the Android std libs before it can run. +[toolchain] +channel = "1.96.0" +profile = "minimal" +components = ["rustfmt", "clippy"] diff --git a/scripts/comment-hygiene.py b/scripts/comment-hygiene.py new file mode 100644 index 0000000..fda1854 --- /dev/null +++ b/scripts/comment-hygiene.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +"""Comment-hygiene gate: the cargo-native mirror of the Casting law's +mold.comment-hygiene (banners-only). Fails on decorative box-drawing or divider +comments in tracked first-party sources, kept in lockstep with the law's Kotlin +rule. A file carrying the token comment-hygiene:allow-file is exempt. +""" +import re +import subprocess +import sys +from pathlib import Path + +MARKER = re.compile(r"^(//+!?|/\*+|\*+/?)\s*") +BANNER_PUNCT = set("=-#*_~+") + + +def is_banner(line): + trimmed = line.strip() + m = MARKER.match(trimmed) + if not m: + return False + body = trimmed[m.end():] + if sum(1 for c in body if c != " ") < 4: + return False + return all(c == " " or c in BANNER_PUNCT or "─" <= c <= "▟" for c in body) + + +def is_third_party(path): + return ( + path.startswith(".claude/") + or "/.claude/" in path + or path.startswith("vendor/") + or "/vendor/" in path + ) + + +def tracked_sources(): + try: + out = subprocess.run( + ["git", "ls-files", "--", "*.kt", "*.kts", "*.java", "*.rs"], + capture_output=True, + text=True, + check=True, + ).stdout + except (subprocess.CalledProcessError, FileNotFoundError): + return None + return [p for p in out.splitlines() if p and not is_third_party(p)] + + +def main(): + files = tracked_sources() + if files is None: + print("comment-hygiene: git unavailable", file=sys.stderr) + return 2 + violations = [] + for rel in files: + text = Path(rel).read_text(encoding="utf-8", errors="replace") + if "comment-hygiene:allow-file" in text: + continue + for i, line in enumerate(text.splitlines(), 1): + if is_banner(line): + violations.append(f"{rel}:{i}: {line.strip()}") + if violations: + print(f"Comment hygiene: {len(violations)} decorative banner comment(s).") + print("Box-drawing and repeated divider runs are banned: write a plain comment,") + print("or add the token comment-hygiene:allow-file to a file that needs one.") + for v in violations: + print(f" {v}") + return 1 + print(f"comment-hygiene: clean ({len(files)} files scanned)") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..f4a8e2e --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,25 @@ +// Generated by tools/cast.py (the Casting plane). The law Maven repo is the FIRST +// pluginManagement repository so the mold.* plugins resolve before any other source. +pluginManagement { + includeBuild("build-logic") + repositories { + maven { url = uri("file:///mnt/companion/president/maven-casting") } // mold casting law repo + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "resetprop-rs" + +// Add your tier graph here, e.g.: +// include(":app") +// include(":core:foo")