use std::collections::{HashMap, HashSet, VecDeque};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use std::time::{Duration, Instant};
use frust_core::InspectNode;
use frust_core::event::{
InputEvent, Key, KeyEvent, Modifiers, PointerButton, PointerEvent, PointerPhase, ScrollDelta,
};
use frust_devtools::{AppInfo, BackendError, DevtoolsBackend, Service, ServiceHandle};
#[cfg(all(feature = "hotpatch", unix))]
use frust_devtools_protocol::PatchFile;
#[cfg(feature = "hotpatch")]
use frust_devtools_protocol::{
ApplyPatchParams, Capability, HandshakeInfo, HotpatchInfo, PROTOCOL_VERSION, PatchChunkParams,
PatchOutcome,
};
use frust_devtools_protocol::{
FrameStats, InputScrollParams, InputTapParams, MetricsSnapshot, RectPx, WidgetNode,
WidgetProps, WidgetTreeDump,
};
use kurbo::Point;
use crate::perf::FramePasses;
pub const DEVTOOLS_VAR: &str = "FRUST_DEVTOOLS";
const UI_HOP_DEADLINE: Duration = Duration::from_secs(5);
const MAX_QUEUED_REQUESTS: usize = 64;
enum UiRequest {
Tree(std::sync::mpsc::Sender<Vec<InspectNode>>),
Events(Vec<InputEvent>, std::sync::mpsc::Sender<()>),
#[cfg(feature = "hotpatch")]
RequestFrame,
}
struct Bridge {
queue: Mutex<VecDeque<UiRequest>>,
wake: Option<Box<dyn Fn() + Send + Sync>>,
}
impl Bridge {
fn submit(&self, request: UiRequest) -> bool {
{
let Ok(mut queue) = self.queue.lock() else {
return false;
};
if queue.len() >= MAX_QUEUED_REQUESTS {
return false;
}
queue.push_back(request);
}
if let Some(wake) = self.wake.as_ref() {
wake();
}
true
}
}
static BRIDGE: OnceLock<Arc<Bridge>> = OnceLock::new();
static SERVICE: OnceLock<ServiceHandle> = OnceLock::new();
static FRAME_N: AtomicU64 = AtomicU64::new(0);
pub trait DevtoolsUi {
fn inspect(&self) -> Vec<InspectNode>;
fn dispatch(&mut self, event: InputEvent);
fn request_frame(&mut self) {}
}
pub fn pump(ui: &mut dyn DevtoolsUi) {
let Some(bridge) = BRIDGE.get() else {
return;
};
loop {
let request = {
let Ok(mut queue) = bridge.queue.lock() else {
return;
};
match queue.pop_front() {
Some(request) => request,
None => return,
}
};
match request {
UiRequest::Tree(reply) => {
let _ = reply.send(ui.inspect());
}
UiRequest::Events(events, reply) => {
for event in events {
ui.dispatch(event);
}
let _ = reply.send(());
}
#[cfg(feature = "hotpatch")]
UiRequest::RequestFrame => ui.request_frame(),
}
}
}
#[cfg(feature = "hotpatch")]
struct FrameGate {
waiting: std::sync::atomic::AtomicBool,
parked: Mutex<Vec<std::sync::mpsc::Sender<()>>>,
}
#[cfg(feature = "hotpatch")]
static FRAME_GATE: FrameGate = FrameGate {
waiting: std::sync::atomic::AtomicBool::new(false),
parked: Mutex::new(Vec::new()),
};
#[cfg(feature = "hotpatch")]
fn park_until_next_frame() -> std::sync::mpsc::Receiver<()> {
let (tx, rx) = std::sync::mpsc::channel();
let mut parked = FRAME_GATE
.parked
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
parked.push(tx);
FRAME_GATE.waiting.store(true, Ordering::Release);
rx
}
#[cfg(feature = "hotpatch")]
pub fn frame_submitted() {
if !FRAME_GATE.waiting.load(Ordering::Acquire) {
return;
}
let parked = {
let mut parked = FRAME_GATE
.parked
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
FRAME_GATE.waiting.store(false, Ordering::Release);
std::mem::take(&mut *parked)
};
for answer in parked {
let _ = answer.send(());
}
}
pub fn start(app_name: impl Into<String>, wake: Option<Box<dyn Fn() + Send + Sync>>) {
if kill_switch_engaged(std::env::var(DEVTOOLS_VAR).ok().as_deref()) {
log::info!("frust-devtools: disabled by {DEVTOOLS_VAR}=0");
return;
}
if SERVICE.get().is_some() {
return;
}
let bridge = Arc::new(Bridge {
queue: Mutex::new(VecDeque::new()),
wake,
});
if BRIDGE.set(Arc::clone(&bridge)).is_err() {
return;
}
let backend = ShellBackend {
bridge,
started: Instant::now(),
#[cfg(feature = "hotpatch")]
hot: HotState::default(),
};
let info = AppInfo::new(app_name, env!("CARGO_PKG_VERSION"));
match Service::start(backend, info) {
Ok(handle) => {
let _ = SERVICE.set(handle);
}
Err(err) => log::warn!(
"{}",
frust_devtools_protocol::format_failure_line(&err.to_string())
),
}
}
pub fn port() -> Option<u16> {
SERVICE.get().map(ServiceHandle::port)
}
pub fn token() -> Option<String> {
SERVICE
.get()
.and_then(ServiceHandle::token)
.map(str::to_string)
}
fn kill_switch_engaged(value: Option<&str>) -> bool {
value == Some("0")
}
pub fn app_name_from_process() -> String {
let raw = std::fs::read_to_string("/proc/self/cmdline")
.ok()
.and_then(|cmdline| {
cmdline
.split('\0')
.next()
.filter(|first| !first.is_empty())
.map(str::to_string)
})
.or_else(|| std::env::args().next());
raw.as_deref()
.and_then(|path| path.rsplit(['/', '\\']).next())
.filter(|name| !name.is_empty())
.unwrap_or("frust-app")
.to_string()
}
pub(crate) fn publish_frame(passes: &FramePasses) {
let Some(service) = SERVICE.get() else {
return;
};
let n = FRAME_N.fetch_add(1, Ordering::Relaxed) + 1;
service.publish_frame_stats(FrameStats {
n,
total_us: passes.total().as_micros() as u64,
rebuild_us: passes.rebuild.as_micros() as u64,
layout_us: passes.layout.as_micros() as u64,
paint_us: passes.paint.as_micros() as u64,
encode_us: passes.encode.as_micros() as u64,
acquire_us: passes.acquire.as_micros() as u64,
submit_us: passes.submit.as_micros() as u64,
skipped: passes.skipped,
});
}
struct ShellBackend {
bridge: Arc<Bridge>,
started: Instant,
#[cfg(feature = "hotpatch")]
hot: HotState,
}
impl ShellBackend {
fn hop<T>(
&self,
make_request: impl FnOnce(std::sync::mpsc::Sender<T>) -> UiRequest,
) -> Option<T> {
let (tx, rx) = std::sync::mpsc::channel();
if !self.bridge.submit(make_request(tx)) {
log::warn!("frust-devtools: the UI-thread request queue is full");
return None;
}
rx.recv_timeout(UI_HOP_DEADLINE).ok()
}
}
impl DevtoolsBackend for ShellBackend {
#[cfg(feature = "hotpatch")]
fn handshake_info(&self, app: &AppInfo) -> HandshakeInfo {
HandshakeInfo {
app_name: app.app_name.clone(),
frust_version: app.frust_version.clone(),
protocol_version: PROTOCOL_VERSION,
capabilities: vec![
Capability::WidgetTree,
Capability::FrameStats,
Capability::Input,
Capability::Metrics,
Capability::HotPatch,
],
}
}
#[cfg(feature = "hotpatch")]
fn hotpatch_info(&self) -> Result<HotpatchInfo, BackendError> {
let pending = frust_hotpatch::pending_layout_mismatches();
frust_hotpatch::mark_layout_mismatches_reported(&pending);
let counters = self.hot.lock();
Ok(HotpatchInfo {
anchor_runtime: frust_hotpatch::aslr_reference() as u64,
pid: std::process::id(),
triple: target_triple(),
patches_applied: counters.patches_applied,
patch_bytes_loaded: counters.patch_bytes_loaded,
pending_layout_mismatches: pending.iter().map(ToString::to_string).collect(),
patch_file_hand_off: PATCH_FILE_HAND_OFF,
})
}
#[cfg(feature = "hotpatch")]
fn patch_chunk(&self, chunk: &PatchChunkParams) -> Result<Vec<u8>, BackendError> {
use base64::Engine as _;
let decoding = Instant::now();
let decoded = base64::engine::general_purpose::STANDARD
.decode(&chunk.data_base64)
.map_err(|e| BackendError::invalid_request(format!("patch chunk is not base64: {e}")));
self.hot.decoded(decoding.elapsed());
decoded
}
#[cfg(all(feature = "hotpatch", unix))]
fn patch_file(&self, file: &PatchFile, len: u64) -> Result<Vec<u8>, BackendError> {
let dir = self.hot.patch_dir().ok_or_else(|| {
BackendError::unavailable("no cache directory to check the patch file's owner against")
})?;
let own_uid = effective_uid(&dir).map_err(|e| {
BackendError::internal(format!("could not determine this process's user: {e}"))
})?;
let bytes = read_handed_off_patch(file, len, own_uid)?;
log::debug!("frust-devtools: patch file handed off ({len} bytes, checks passed)");
Ok(bytes)
}
#[cfg(feature = "hotpatch")]
fn apply_patch(
&self,
bytes: Vec<u8>,
params: ApplyPatchParams,
) -> Result<PatchOutcome, BackendError> {
let mut counters = self.hot.lock();
let mut timings = ApplyTimings {
decode: self.hot.take_decoded(),
..ApplyTimings::default()
};
let attempt = self.hot.attempt(&mut counters, bytes, params, &mut timings);
let waiting = Instant::now();
let frame = park_until_next_frame();
if !self.bridge.submit(UiRequest::RequestFrame) {
log::warn!("frust-devtools: could not ask the UI thread for a frame");
}
if frame.recv_timeout(UI_HOP_DEADLINE).is_err() {
log::warn!(
"frust-devtools: no frame followed the patch within {}s; answering anyway",
UI_HOP_DEADLINE.as_secs()
);
}
timings.frame = waiting.elapsed();
log::info!("{timings}");
let Attempt {
applied,
records: mut refusal,
} = attempt?;
for record in frust_hotpatch::pending_layout_mismatches() {
if !refusal.contains(&record) {
refusal.push(record);
}
}
frust_hotpatch::mark_layout_mismatches_reported(&refusal);
let (seam_hits, seam_fall_throughs) = if applied {
let missed = frust_hotpatch::missed_keys()
.into_iter()
.map(|key| frust_devtools_protocol::MissedKey {
image: key.image,
link_address: key.link_address,
})
.collect();
(frust_hotpatch::seam_hits(), missed)
} else {
(0, Vec::new())
};
Ok(PatchOutcome {
applied,
seam_hits,
seam_fall_throughs,
layout_mismatches: refusal.iter().map(ToString::to_string).collect(),
patches_applied: counters.patches_applied,
patch_bytes_loaded: counters.patch_bytes_loaded,
})
}
fn widget_tree(&self) -> WidgetTreeDump {
match self.hop(UiRequest::Tree) {
Some(nodes) => tree_dump(&nodes),
None => WidgetTreeDump { roots: Vec::new() },
}
}
fn widget_props(&self, id: u64) -> Option<WidgetProps> {
let nodes = self.hop(UiRequest::Tree)?;
props_for(&nodes, id)
}
fn metrics_snapshot(&self) -> MetricsSnapshot {
MetricsSnapshot {
rss_bytes: rss_bytes(),
uptime_ms: self.started.elapsed().as_millis() as u64,
}
}
fn inject_tap(&self, params: InputTapParams) -> Result<(), BackendError> {
let position = logical_point(params.x, params.y)?;
self.inject(vec![
InputEvent::Pointer(PointerEvent {
phase: PointerPhase::Down,
position,
button: PointerButton::Primary,
}),
InputEvent::Pointer(PointerEvent {
phase: PointerPhase::Up,
position,
button: PointerButton::Primary,
}),
])
}
fn inject_scroll(&self, params: InputScrollParams) -> Result<(), BackendError> {
let position = logical_point(params.x, params.y)?;
if !params.dx.is_finite() || !params.dy.is_finite() {
return Err(BackendError::invalid_request(
"scroll delta must be finite logical px",
));
}
self.inject(vec![InputEvent::Scroll {
position,
delta: ScrollDelta::Pixels(params.dx, params.dy),
}])
}
fn inject_text(&self, text: &str) -> Result<(), BackendError> {
if text.is_empty() {
return Err(BackendError::invalid_request("text must not be empty"));
}
self.inject(vec![InputEvent::Key(KeyEvent {
key: Key::Character(text.to_string()),
modifiers: Modifiers::default(),
repeat: false,
})])
}
}
impl ShellBackend {
fn inject(&self, events: Vec<InputEvent>) -> Result<(), BackendError> {
match self.hop(|reply| UiRequest::Events(events, reply)) {
Some(()) => Ok(()),
None => Err(BackendError::unavailable(
"the app's UI thread did not process the injected event",
)),
}
}
}
#[cfg(feature = "hotpatch")]
const PATCH_EXT: &str = if cfg!(any(target_os = "macos", target_os = "ios")) {
"dylib"
} else if cfg!(windows) {
"dll"
} else {
"so"
};
#[cfg(feature = "hotpatch")]
const PATCH_FILE_HAND_OFF: bool = cfg!(all(unix, not(target_os = "android")));
#[cfg(feature = "hotpatch")]
#[derive(Default)]
struct HotState {
cache_root: Option<std::path::PathBuf>,
counters: Mutex<HotCounters>,
decode_nanos: AtomicU64,
}
#[cfg(feature = "hotpatch")]
#[derive(Debug, Default, Clone, Copy, PartialEq)]
struct ApplyTimings {
decode: Duration,
write: Duration,
dlopen: Duration,
table: Duration,
frame: Duration,
bytes: u64,
entries: usize,
}
#[cfg(feature = "hotpatch")]
impl std::fmt::Display for ApplyTimings {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let ms = |d: Duration| d.as_millis();
write!(
f,
"frust-hotpatch: apply timings: decode={}ms write={}ms dlopen={}ms table={}ms \
frame={}ms bytes={} entries={}",
ms(self.decode),
ms(self.write),
ms(self.dlopen),
ms(self.table),
ms(self.frame),
self.bytes,
self.entries
)
}
}
#[cfg(feature = "hotpatch")]
#[derive(Default)]
struct HotCounters {
patches_applied: u32,
patch_bytes_loaded: u64,
used_ids: HashSet<u64>,
}
#[cfg(feature = "hotpatch")]
struct Attempt {
applied: bool,
records: Vec<frust_hotpatch::LayoutMismatch>,
}
#[cfg(feature = "hotpatch")]
impl HotState {
fn lock(&self) -> std::sync::MutexGuard<'_, HotCounters> {
self.counters
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn decoded(&self, took: Duration) {
let nanos = u64::try_from(took.as_nanos()).unwrap_or(u64::MAX);
self.decode_nanos.fetch_add(nanos, Ordering::Relaxed);
}
fn take_decoded(&self) -> Duration {
Duration::from_nanos(self.decode_nanos.swap(0, Ordering::Relaxed))
}
fn patch_dir(&self) -> Option<std::path::PathBuf> {
self.cache_root
.clone()
.or_else(frust_paths::cache_dir)
.map(|root| root.join("frust-hotpatch"))
}
fn attempt(
&self,
counters: &mut HotCounters,
bytes: Vec<u8>,
params: ApplyPatchParams,
timings: &mut ApplyTimings,
) -> Result<Attempt, BackendError> {
check_target(
(params.pid, params.anchor_runtime),
(std::process::id(), frust_hotpatch::aslr_reference() as u64),
)?;
let len = bytes.len() as u64;
timings.bytes = len;
timings.entries = params.table.map.len();
if len != params.len {
return Err(BackendError::invalid_request(format!(
"patch is {len} bytes, apply_patch says {}",
params.len
)));
}
if params.table_len != 0 && params.table.encoded_map_len() != params.table_len {
return Err(BackendError::invalid_request(format!(
"the jump table holds {} entries, apply_patch declared {} bytes of them",
params.table.map.len(),
params.table_len
)));
}
if counters.used_ids.contains(¶ms.patch_id) {
return Err(BackendError::invalid_request(format!(
"patch_id {} was already used in this process; send a fresh id",
params.patch_id
)));
}
let dir = self.patch_dir().ok_or_else(|| {
BackendError::unavailable("no cache directory to write the patch into")
})?;
counters.used_ids.insert(params.patch_id);
let name = format!(
"patch-{}-{}.{PATCH_EXT}",
std::process::id(),
params.patch_id
);
let writing = Instant::now();
let written = write_patch_file(&dir, &name, &bytes);
timings.write = writing.elapsed();
let path = written.map_err(|e| {
BackendError::internal(redact(
&format!("could not write the patch: {e}"),
&dir,
&name,
))
})?;
log::debug!(
"frust-devtools: patch {} written to {}",
params.patch_id,
path.display()
);
let table = frust_hotpatch::JumpTable {
lib: path.clone(),
map: params.table.map.into_iter().collect(),
aslr_reference: params.table.aslr_reference,
new_base_address: params.table.new_base_address,
ifunc_count: params.table.ifunc_count,
};
log::debug!(
"frust-devtools: applying patch {} ({len} bytes, {} expected seams)",
params.patch_id,
params.expected_seams
);
let report = frust_hotpatch::apply_from_devtools(&path, table);
timings.dlopen = report.load;
timings.table = report.install;
if let Err(e) = std::fs::remove_file(&path) {
log::debug!("frust-devtools: could not remove {}: {e}", path.display());
}
if let Some(error) = report.error {
return Err(patch_error(&error, &dir, &name));
}
if report.applied {
counters.patches_applied = counters.patches_applied.saturating_add(1);
counters.patch_bytes_loaded = counters.patch_bytes_loaded.saturating_add(len);
}
Ok(Attempt {
applied: report.applied,
records: report.layout_mismatches,
})
}
}
#[cfg(feature = "hotpatch")]
fn check_target(requested: (u32, u64), own: (u32, u64)) -> Result<(), BackendError> {
let ((pid, anchor), (own_pid, own_anchor)) = (requested, own);
if pid != own_pid {
return Err(BackendError::invalid_request(format!(
"patch targets pid {pid}, this process is {own_pid}"
)));
}
if own_anchor == 0 {
return Err(BackendError::unavailable(
"no hot-patch anchor is set in this process; a patch cannot be rebased",
));
}
if anchor != own_anchor {
return Err(BackendError::invalid_request(format!(
"patch targets anchor {anchor:#x}, this process's is {own_anchor:#x}"
)));
}
Ok(())
}
#[cfg(feature = "hotpatch")]
fn write_patch_file(
dir: &std::path::Path,
name: &str,
bytes: &[u8],
) -> std::io::Result<std::path::PathBuf> {
use std::io::Write as _;
std::fs::create_dir_all(dir)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?;
}
let path = dir.join(name);
match std::fs::remove_file(&path) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(e),
}
let mut options = std::fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt as _;
options.mode(0o600);
}
let mut file = options.open(&path)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
file.set_permissions(std::fs::Permissions::from_mode(0o600))?;
}
file.write_all(bytes)?;
Ok(path)
}
#[cfg(all(feature = "hotpatch", unix))]
fn effective_uid(dir: &std::path::Path) -> std::io::Result<u32> {
use std::os::unix::fs::MetadataExt as _;
let name = format!(".uid-probe-{}", std::process::id());
let path = write_patch_file(dir, &name, &[])?;
let uid = std::fs::symlink_metadata(&path).map(|meta| meta.uid());
let _ = std::fs::remove_file(&path);
uid
}
#[cfg(all(feature = "hotpatch", unix))]
fn read_handed_off_patch(
file: &PatchFile,
len: u64,
own_uid: u32,
) -> Result<Vec<u8>, BackendError> {
use sha2::Digest as _;
use std::io::Read as _;
use std::os::unix::fs::{MetadataExt as _, OpenOptionsExt as _};
let refuse = |why: String| BackendError::invalid_request(format!("patch file refused: {why}"));
let well_formed = file.sha256.len() == 64
&& file
.sha256
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
if !well_formed {
return Err(refuse(
"sha256 must be 64 lowercase hex characters".to_string(),
));
}
let path = std::path::Path::new(&file.path);
if !path.is_absolute() {
return Err(refuse("the path is not absolute".to_string()));
}
let mut opened = std::fs::OpenOptions::new()
.read(true)
.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK)
.open(path)
.map_err(|e| refuse(format!("it cannot be opened without following a link: {e}")))?;
let meta = opened
.metadata()
.map_err(|e| refuse(format!("it cannot be inspected: {e}")))?;
if !meta.file_type().is_file() {
return Err(refuse("it is not a regular file".to_string()));
}
if meta.uid() != own_uid {
return Err(refuse(format!(
"it is owned by uid {}, this process runs as uid {own_uid}",
meta.uid()
)));
}
if meta.mode() & 0o077 != 0 {
return Err(refuse(format!(
"its mode {:o} grants group or other access",
meta.mode() & 0o777
)));
}
if meta.len() != len {
return Err(refuse(format!(
"it is {} bytes, apply_patch says {len}",
meta.len()
)));
}
let mut bytes = Vec::with_capacity(usize::try_from(len).unwrap_or(0));
(&mut opened)
.take(len.saturating_add(1))
.read_to_end(&mut bytes)
.map_err(|e| refuse(format!("it cannot be read: {e}")))?;
if bytes.len() as u64 != len {
return Err(refuse("its size changed while it was read".to_string()));
}
let digest = format!("{:x}", sha2::Sha256::digest(&bytes));
if digest != file.sha256 {
return Err(refuse(
"its SHA-256 does not match apply_patch's".to_string(),
));
}
Ok(bytes)
}
#[cfg(feature = "hotpatch")]
fn redact(message: &str, dir: &std::path::Path, name: &str) -> String {
let mut dirs = vec![dir.to_path_buf()];
if let Ok(canonical) = std::fs::canonicalize(dir) {
dirs.push(canonical);
}
let mut out = message.to_string();
for dir in &dirs {
let dir = dir.to_string_lossy();
if !dir.is_empty() {
out = out.replace(dir.as_ref(), "<patch dir>");
}
}
out.replace(name, "<patch file>")
}
#[cfg(feature = "hotpatch")]
fn patch_error(
error: &frust_hotpatch::PatchError,
dir: &std::path::Path,
name: &str,
) -> BackendError {
use frust_hotpatch::PatchError;
let message = redact(&format!("patch not applied: {error}"), dir, name);
match error {
PatchError::AnchorMismatch { .. } => BackendError::invalid_request(message),
PatchError::AnchorUnresolved => BackendError::unavailable(message),
PatchError::ReleaseBuild => BackendError::not_supported(message),
_ => BackendError::internal(message),
}
}
#[cfg(feature = "hotpatch")]
fn target_triple() -> String {
let arch = std::env::consts::ARCH;
let rest = if cfg!(target_os = "macos") {
"apple-darwin"
} else if cfg!(target_os = "ios") {
ios_suffix(cfg!(target_abi = "sim"), cfg!(target_arch = "aarch64"))
} else if cfg!(target_os = "android") {
if arch == "arm" {
"linux-androideabi"
} else {
"linux-android"
}
} else if cfg!(target_os = "linux") {
if cfg!(target_env = "musl") {
"unknown-linux-musl"
} else {
"unknown-linux-gnu"
}
} else if cfg!(windows) {
if cfg!(target_env = "gnu") {
"pc-windows-gnu"
} else {
"pc-windows-msvc"
}
} else {
std::env::consts::OS
};
let arch = if arch == "arm" && cfg!(target_os = "android") {
"armv7"
} else {
arch
};
format!("{arch}-{rest}")
}
#[cfg(feature = "hotpatch")]
fn ios_suffix(simulator: bool, aarch64: bool) -> &'static str {
if simulator && aarch64 {
"apple-ios-sim"
} else {
"apple-ios"
}
}
fn logical_point(x: f64, y: f64) -> Result<Point, BackendError> {
if !x.is_finite() || !y.is_finite() {
return Err(BackendError::invalid_request(
"tap/scroll position must be finite logical px",
));
}
Ok(Point::new(x, y))
}
fn rss_bytes() -> Option<u64> {
#[cfg(any(target_os = "linux", target_os = "android"))]
{
let status = std::fs::read_to_string("/proc/self/status").ok()?;
parse_vm_rss_kb(&status).map(|kb| kb * 1024)
}
#[cfg(not(any(target_os = "linux", target_os = "android")))]
{
None
}
}
#[cfg_attr(
not(any(target_os = "linux", target_os = "android", test)),
allow(dead_code)
)]
fn parse_vm_rss_kb(status: &str) -> Option<u64> {
status
.lines()
.find_map(|line| line.strip_prefix("VmRSS:"))
.and_then(|rest| rest.split_whitespace().next())
.and_then(|kb| kb.parse::<u64>().ok())
}
fn tree_dump(nodes: &[InspectNode]) -> WidgetTreeDump {
let index: HashMap<u64, usize> = nodes
.iter()
.enumerate()
.map(|(slot, node)| (node.id.0, slot))
.collect();
let mut visited = HashSet::new();
let roots = nodes
.iter()
.enumerate()
.filter(|(_, node)| node.parent.is_none())
.filter_map(|(slot, _)| widget_node(nodes, &index, slot, &mut visited))
.collect();
WidgetTreeDump { roots }
}
fn widget_node(
nodes: &[InspectNode],
index: &HashMap<u64, usize>,
slot: usize,
visited: &mut HashSet<u64>,
) -> Option<WidgetNode> {
let node = nodes.get(slot)?;
if !visited.insert(node.id.0) {
return None;
}
let children = node
.children
.iter()
.filter_map(|child| index.get(&child.0).copied())
.filter_map(|child_slot| widget_node(nodes, index, child_slot, visited))
.collect();
Some(WidgetNode {
id: node.id.0,
type_name: node.type_name.to_string(),
debug_label: node.debug_label.as_ref().map(|label| label.to_string()),
bounds: Some(RectPx {
x: node.bounds.x0,
y: node.bounds.y0,
width: node.bounds.width(),
height: node.bounds.height(),
}),
children,
})
}
fn props_for(nodes: &[InspectNode], id: u64) -> Option<WidgetProps> {
let node = nodes.iter().find(|node| node.id.0 == id)?;
let mut entries = vec![("type".to_string(), node.type_name.to_string())];
if let Some(label) = node.debug_label.as_ref() {
entries.push(("debug_label".to_string(), label.to_string()));
}
entries.push(("x".to_string(), format!("{:.1}", node.bounds.x0)));
entries.push(("y".to_string(), format!("{:.1}", node.bounds.y0)));
entries.push(("width".to_string(), format!("{:.1}", node.bounds.width())));
entries.push(("height".to_string(), format!("{:.1}", node.bounds.height())));
entries.push(("depth".to_string(), node.depth.to_string()));
entries.push((
"parent".to_string(),
node.parent
.map(|parent| parent.0.to_string())
.unwrap_or_else(|| "none".to_string()),
));
entries.push(("children".to_string(), node.children.len().to_string()));
Some(WidgetProps { id, entries })
}
#[cfg(test)]
mod tests {
use super::*;
use frust_core::view::WidgetId;
use kurbo::Rect;
fn node(id: u64, parent: Option<u64>, children: &[u64], depth: usize) -> InspectNode {
InspectNode {
id: WidgetId(id),
parent: parent.map(WidgetId),
type_name: "StubWidget",
debug_label: None,
bounds: Rect::new(0.0, 0.0, 10.0, 20.0),
children: children.iter().copied().map(WidgetId).collect(),
depth,
}
}
fn fixture() -> Vec<InspectNode> {
vec![
InspectNode {
bounds: Rect::new(0.0, 0.0, 100.0, 200.0),
debug_label: Some("root".into()),
..node(1, None, &[2], 0)
},
node(2, Some(1), &[3], 1),
node(3, Some(2), &[], 2),
]
}
#[test]
fn tree_dump_nests_the_flat_pre_order_snapshot() {
let dump = tree_dump(&fixture());
assert_eq!(dump.roots.len(), 1);
let root = &dump.roots[0];
assert_eq!(root.id, 1);
assert_eq!(root.type_name, "StubWidget");
assert_eq!(root.debug_label.as_deref(), Some("root"));
assert_eq!(root.children.len(), 1);
assert_eq!(root.children[0].id, 2);
assert_eq!(root.children[0].children[0].id, 3);
assert!(root.children[0].children[0].children.is_empty());
}
#[test]
fn tree_dump_carries_absolute_bounds_in_logical_px() {
let dump = tree_dump(&fixture());
let bounds = dump.roots[0].bounds.expect("bounds always reported");
assert_eq!(bounds.x, 0.0);
assert_eq!(bounds.y, 0.0);
assert_eq!(bounds.width, 100.0);
assert_eq!(bounds.height, 200.0);
}
#[test]
fn tree_dump_of_an_empty_snapshot_is_an_empty_dump() {
assert!(tree_dump(&[]).roots.is_empty());
}
#[test]
fn tree_dump_reports_every_root_of_a_multi_root_snapshot() {
let nodes = vec![node(1, None, &[], 0), node(2, None, &[], 0)];
let dump = tree_dump(&nodes);
assert_eq!(
dump.roots.iter().map(|n| n.id).collect::<Vec<_>>(),
vec![1, 2]
);
}
#[test]
fn tree_dump_terminates_on_a_malformed_cyclic_snapshot() {
let nodes = vec![node(1, None, &[2], 0), node(2, Some(1), &[1], 1)];
let dump = tree_dump(&nodes);
assert_eq!(dump.roots.len(), 1);
assert!(dump.roots[0].children[0].children.is_empty());
}
#[test]
fn tree_dump_skips_a_child_id_with_no_node() {
let nodes = vec![node(1, None, &[2, 9], 0), node(2, Some(1), &[], 1)];
let dump = tree_dump(&nodes);
assert_eq!(dump.roots[0].children.len(), 1);
}
#[test]
fn props_for_reports_geometry_and_lineage() {
let props = props_for(&fixture(), 2).expect("node 2 is in the snapshot");
assert_eq!(props.id, 2);
let entries: HashMap<&str, &str> = props
.entries
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
assert_eq!(entries["type"], "StubWidget");
assert_eq!(entries["width"], "10.0");
assert_eq!(entries["height"], "20.0");
assert_eq!(entries["depth"], "1");
assert_eq!(entries["parent"], "1");
assert_eq!(entries["children"], "1");
assert!(!entries.contains_key("debug_label"));
}
#[test]
fn props_for_reports_a_root_with_no_parent() {
let props = props_for(&fixture(), 1).expect("node 1 is in the snapshot");
let parent = props
.entries
.iter()
.find(|(k, _)| k == "parent")
.map(|(_, v)| v.as_str());
assert_eq!(parent, Some("none"));
}
#[test]
fn props_for_unknown_id_is_none() {
assert!(props_for(&fixture(), 999).is_none());
}
#[test]
fn a_non_finite_injection_coordinate_is_rejected() {
assert!(logical_point(f64::NAN, 0.0).is_err());
assert!(logical_point(0.0, f64::INFINITY).is_err());
assert_eq!(logical_point(3.0, 4.0).map(|p| (p.x, p.y)), Ok((3.0, 4.0)));
}
#[test]
fn kill_switch_engages_only_on_literal_zero() {
assert!(kill_switch_engaged(Some("0")));
assert!(!kill_switch_engaged(None));
assert!(!kill_switch_engaged(Some("1")));
assert!(!kill_switch_engaged(Some("")));
}
#[test]
fn vm_rss_is_parsed_in_kilobytes() {
let status = "Name:\tapp\nVmPeak:\t 100 kB\nVmRSS:\t 4096 kB\nThreads:\t3\n";
assert_eq!(parse_vm_rss_kb(status), Some(4096));
}
#[test]
fn vm_rss_absent_is_none() {
assert_eq!(parse_vm_rss_kb("Name:\tapp\nThreads:\t3\n"), None);
}
#[test]
fn pump_without_a_started_service_is_inert() {
struct NoUi;
impl DevtoolsUi for NoUi {
fn inspect(&self) -> Vec<InspectNode> {
panic!("pump must not touch the UI with nothing queued");
}
fn dispatch(&mut self, _event: InputEvent) {
panic!("pump must not touch the UI with nothing queued");
}
}
pump(&mut NoUi);
}
#[cfg(feature = "hotpatch")]
mod hot {
use super::*;
use frust_devtools_protocol::JumpTableWire;
use std::path::{Path, PathBuf};
static SERIAL: Mutex<()> = Mutex::new(());
fn serial() -> std::sync::MutexGuard<'static, ()> {
SERIAL
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
extern "C" fn test_anchor() {}
fn anchor() -> u64 {
test_anchor as extern "C" fn() as usize as u64
}
fn scratch(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"frust-shell-common-hot-{tag}-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("scratch dir");
dir
}
fn backend(root: &Path) -> ShellBackend {
ShellBackend {
bridge: Arc::new(Bridge {
queue: Mutex::new(VecDeque::new()),
wake: None,
}),
started: Instant::now(),
hot: HotState {
cache_root: Some(root.to_path_buf()),
..HotState::default()
},
}
}
fn params(patch_id: u64, len: u64, pid: u32, anchor_runtime: u64) -> ApplyPatchParams {
ApplyPatchParams {
patch_id,
len,
pid,
anchor_runtime,
table: JumpTableWire {
map: std::collections::HashMap::new(),
aslr_reference: 0,
new_base_address: 0,
ifunc_count: 0,
},
table_len: 0,
expected_seams: 1,
file: None,
}
}
fn apply_with_frame_tick(
backend: ShellBackend,
bytes: Vec<u8>,
params: ApplyPatchParams,
) -> (bool, Result<PatchOutcome, BackendError>) {
let (tx, rx) = std::sync::mpsc::channel();
let worker = std::thread::spawn(move || {
let _ = tx.send(backend.apply_patch(bytes, params));
});
let early = rx.recv_timeout(Duration::from_millis(300)).is_ok();
frame_submitted();
let answer = rx
.recv_timeout(Duration::from_secs(4))
.expect("the frame tick releases the answer");
worker.join().expect("worker");
(early, answer)
}
#[test]
fn the_frame_gate_releases_only_waiters_parked_before_the_tick() {
let _serial = serial();
let before = park_until_next_frame();
assert!(before.try_recv().is_err(), "nothing before a frame");
frame_submitted();
assert!(before.try_recv().is_ok(), "the next frame releases it");
let after = park_until_next_frame();
assert!(after.try_recv().is_err(), "an earlier tick does not count");
frame_submitted();
assert!(after.try_recv().is_ok());
}
#[test]
fn a_frame_with_nothing_parked_is_inert() {
let _serial = serial();
frame_submitted();
frame_submitted();
}
#[test]
fn the_shell_backend_offers_hot_patch() {
let info = backend(&scratch("offer")).handshake_info(&AppInfo::new("a", "0"));
assert!(info.capabilities.contains(&Capability::HotPatch));
assert!(info.capabilities.contains(&Capability::WidgetTree));
}
#[test]
fn pid_and_anchor_must_match_and_a_missing_anchor_fails_closed() {
assert!(check_target((7, 0x10), (7, 0x10)).is_ok());
assert!(matches!(
check_target((8, 0x10), (7, 0x10)),
Err(BackendError::InvalidRequest(_))
));
assert!(matches!(
check_target((7, 0x11), (7, 0x10)),
Err(BackendError::InvalidRequest(_))
));
assert!(matches!(
check_target((7, 0), (7, 0)),
Err(BackendError::Unavailable(_))
));
}
#[test]
fn a_wrong_pid_is_refused_writes_nothing_and_still_answers_after_the_frame() {
let _serial = serial();
frust_hotpatch::set_anchor(anchor() as usize);
let root = scratch("pid");
let wrong_pid = std::process::id().wrapping_add(1);
let (early, answer) = apply_with_frame_tick(
backend(&root),
vec![1, 2, 3],
params(1, 3, wrong_pid, anchor()),
);
assert!(!early, "the answer waits for the frame");
assert!(matches!(answer, Err(BackendError::InvalidRequest(_))));
assert!(!root.join("frust-hotpatch").exists(), "nothing written");
}
#[test]
fn a_wrong_anchor_runtime_is_refused() {
let _serial = serial();
frust_hotpatch::set_anchor(anchor() as usize);
let root = scratch("anchor");
let (_, answer) = apply_with_frame_tick(
backend(&root),
vec![1, 2, 3],
params(1, 3, std::process::id(), anchor() + 1),
);
assert!(matches!(answer, Err(BackendError::InvalidRequest(_))));
assert!(!root.join("frust-hotpatch").exists());
}
#[test]
fn a_table_short_of_its_declared_length_is_refused_and_writes_nothing() {
let _serial = serial();
frust_hotpatch::set_anchor(anchor() as usize);
let root = scratch("table-len");
let mut short = params(2, 3, std::process::id(), anchor());
short.table_len = 16;
let (_, answer) = apply_with_frame_tick(backend(&root), vec![1, 2, 3], short);
assert!(
matches!(&answer, Err(BackendError::InvalidRequest(m)) if m.contains("jump table")),
"{answer:?}"
);
assert!(!root.join("frust-hotpatch").exists(), "nothing written");
}
#[test]
fn a_refused_load_names_no_path_and_leaves_no_file() {
let _serial = serial();
frust_hotpatch::set_anchor(anchor() as usize);
let root = scratch("redact");
let (early, answer) = apply_with_frame_tick(
backend(&root),
vec![0xde, 0xad],
params(42, 2, std::process::id(), anchor()),
);
assert!(!early);
let Err(error) = answer else {
panic!("a table not anchored on this image must be refused");
};
let wire = error.to_rpc_error();
let line = frust_devtools_protocol::encode_line(
&frust_devtools_protocol::Response::error(1, wire),
);
let root_text = root.to_string_lossy().into_owned();
assert!(!line.contains(&root_text), "{line}");
assert!(!line.contains("frust-hotpatch/"), "{line}");
assert!(!line.contains("patch-"), "{line}");
let dir = root.join("frust-hotpatch");
assert_eq!(
std::fs::read_dir(&dir).expect("patch dir").count(),
0,
"the patch file is removed after the attempt"
);
}
#[test]
fn a_reused_patch_id_is_refused() {
let _serial = serial();
frust_hotpatch::set_anchor(anchor() as usize);
let root = scratch("reuse");
let backend = Arc::new(backend(&root));
for expect_reuse_refusal in [false, true] {
let b = Arc::clone(&backend);
let worker = std::thread::spawn(move || {
b.apply_patch(vec![1], params(5, 1, std::process::id(), anchor()))
});
std::thread::sleep(Duration::from_millis(100));
frame_submitted();
let answer = worker.join().expect("worker");
let reused = matches!(&answer, Err(BackendError::InvalidRequest(m)) if m.contains("already used"));
assert_eq!(reused, expect_reuse_refusal, "{answer:?}");
}
}
#[test]
fn unreported_layout_records_refuse_with_applied_false_and_are_then_reported() {
let _serial = serial();
frust_hotpatch::set_anchor(anchor() as usize);
frust_hotpatch::report_layout_mismatch("HotTestState", 4, 8);
let root = scratch("l2");
let (early, answer) = apply_with_frame_tick(
backend(&root),
vec![1, 2, 3, 4],
params(9, 4, std::process::id(), anchor()),
);
assert!(!early);
let outcome = answer.expect("a refusal is an outcome, not an error");
assert!(!outcome.applied);
assert_eq!(
outcome.layout_mismatches,
vec!["HotTestState: stored 4, own 8"]
);
assert_eq!(outcome.seam_hits, 0);
assert_eq!(outcome.patches_applied, 0);
assert!(frust_hotpatch::pending_layout_mismatches().is_empty());
}
#[test]
fn hotpatch_info_reports_this_process_and_carries_pending_records_once() {
let _serial = serial();
frust_hotpatch::set_anchor(anchor() as usize);
frust_hotpatch::report_layout_mismatch("InfoTestState", 1, 2);
let backend = backend(&scratch("info"));
let info = backend.hotpatch_info().expect("info");
assert_eq!(info.pid, std::process::id());
assert_eq!(info.anchor_runtime, anchor());
assert!(info.triple.starts_with(std::env::consts::ARCH));
assert_eq!(info.patches_applied, 0);
assert_eq!(
info.pending_layout_mismatches,
vec!["InfoTestState: stored 1, own 2"]
);
let again = backend.hotpatch_info().expect("info");
assert!(again.pending_layout_mismatches.is_empty(), "reported once");
}
#[test]
fn chunk_decoding_time_accumulates_until_an_apply_takes_it() {
let backend = backend(&scratch("decode-time"));
assert_eq!(backend.hot.take_decoded(), Duration::ZERO);
backend.hot.decoded(Duration::from_millis(3));
backend.hot.decoded(Duration::from_millis(4));
assert_eq!(backend.hot.take_decoded(), Duration::from_millis(7));
assert_eq!(backend.hot.take_decoded(), Duration::ZERO, "taken once");
}
#[test]
fn apply_timings_render_as_one_line_in_milliseconds() {
let timings = ApplyTimings {
decode: Duration::from_millis(93),
write: Duration::from_micros(23_900),
dlopen: Duration::from_millis(26),
table: Duration::from_millis(2),
frame: Duration::from_millis(3),
bytes: 17_843_800,
entries: 68_692,
};
assert_eq!(
timings.to_string(),
"frust-hotpatch: apply timings: decode=93ms write=23ms dlopen=26ms table=2ms \
frame=3ms bytes=17843800 entries=68692"
);
}
#[test]
fn patch_chunk_decodes_base64_and_rejects_anything_else() {
let backend = backend(&scratch("chunk"));
let chunk = |data: &str| PatchChunkParams {
patch_id: 1,
offset: 0,
total_len: 4,
data_base64: data.to_string(),
};
assert_eq!(
backend.patch_chunk(&chunk("AAECAw==")),
Ok(vec![0, 1, 2, 3])
);
assert!(matches!(
backend.patch_chunk(&chunk("not base64!")),
Err(BackendError::InvalidRequest(_))
));
}
#[cfg(unix)]
#[test]
fn the_patch_file_is_0600_in_a_0700_dir() {
use std::os::unix::fs::PermissionsExt as _;
let dir = scratch("mode").join("frust-hotpatch");
let path = write_patch_file(&dir, "patch-1-1.so", b"bytes").expect("write");
let mode = |p: &Path| std::fs::metadata(p).expect("meta").permissions().mode() & 0o777;
assert_eq!(mode(&path), 0o600);
assert_eq!(mode(&dir), 0o700);
assert_eq!(std::fs::read(&path).expect("read"), b"bytes");
}
#[cfg(unix)]
#[test]
fn a_planted_entry_is_replaced_never_written_through() {
let root = scratch("plant");
let dir = root.join("frust-hotpatch");
std::fs::create_dir_all(&dir).expect("dir");
let victim = root.join("victim");
std::fs::write(&victim, b"untouched").expect("victim");
std::os::unix::fs::symlink(&victim, dir.join("patch-1-2.so")).expect("symlink");
let path = write_patch_file(&dir, "patch-1-2.so", b"patch").expect("write");
assert_eq!(std::fs::read(&victim).expect("victim"), b"untouched");
assert!(
!std::fs::symlink_metadata(&path)
.expect("meta")
.file_type()
.is_symlink()
);
assert_eq!(std::fs::read(&path).expect("patch"), b"patch");
}
#[cfg(unix)]
fn handed_off(dir: &Path, name: &str, bytes: &[u8], mode: u32) -> PatchFile {
use sha2::Digest as _;
use std::os::unix::fs::PermissionsExt as _;
let path = dir.join(name);
std::fs::write(&path, bytes).expect("write");
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode)).expect("mode");
PatchFile {
path: path.to_string_lossy().into_owned(),
sha256: format!("{:x}", sha2::Sha256::digest(bytes)),
}
}
#[cfg(unix)]
fn refusal(backend: &ShellBackend, file: &PatchFile, len: u64) -> String {
match backend.patch_file(file, len) {
Err(BackendError::InvalidRequest(message)) => {
assert!(!message.contains(&file.path), "{message}");
message
}
other => panic!("expected an invalid_request refusal, got {other:?}"),
}
}
#[cfg(unix)]
#[test]
fn hotpatch_info_advertises_the_patch_file_hand_off() {
let _serial = serial();
let info = backend(&scratch("advert")).hotpatch_info().expect("info");
assert_eq!(
info.patch_file_hand_off,
!cfg!(target_os = "android"),
"advertised on a shared-filesystem host, never on Android"
);
}
#[cfg(unix)]
#[test]
fn a_private_regular_file_with_the_right_len_and_digest_is_read() {
let root = scratch("handoff-ok");
let backend = backend(&root);
let file = handed_off(&root, "patch-1.so", b"patch bytes", 0o600);
assert_eq!(backend.patch_file(&file, 11), Ok(b"patch bytes".to_vec()));
let probes = std::fs::read_dir(root.join("frust-hotpatch"))
.expect("patch dir")
.count();
assert_eq!(probes, 0, "the uid probe leaves nothing behind");
}
#[cfg(unix)]
#[test]
fn a_symlink_is_refused_even_to_a_valid_patch() {
let root = scratch("handoff-link");
let backend = backend(&root);
let target = handed_off(&root, "real.so", b"patch", 0o600);
let link = root.join("link.so");
std::os::unix::fs::symlink(&target.path, &link).expect("symlink");
let file = PatchFile {
path: link.to_string_lossy().into_owned(),
sha256: target.sha256,
};
let message = refusal(&backend, &file, 5);
assert!(message.contains("following a link"), "{message}");
}
#[cfg(unix)]
#[test]
fn a_directory_is_refused() {
let root = scratch("handoff-dir");
let backend = backend(&root);
let dir = root.join("patch-dir.so");
std::fs::create_dir(&dir).expect("dir");
let file = PatchFile {
path: dir.to_string_lossy().into_owned(),
sha256: "0".repeat(64),
};
let message = refusal(&backend, &file, 0);
assert!(message.contains("not a regular file"), "{message}");
}
#[cfg(unix)]
#[test]
fn a_group_or_world_readable_file_is_refused() {
let root = scratch("handoff-mode");
let backend = backend(&root);
let file = handed_off(&root, "patch-1.so", b"patch", 0o644);
let message = refusal(&backend, &file, 5);
assert!(message.contains("mode 644"), "{message}");
}
#[cfg(unix)]
#[test]
fn a_wrong_len_is_refused() {
let root = scratch("handoff-len");
let backend = backend(&root);
let file = handed_off(&root, "patch-1.so", b"patch", 0o600);
let message = refusal(&backend, &file, 6);
assert!(message.contains("5 bytes, apply_patch says 6"), "{message}");
}
#[cfg(unix)]
#[test]
fn a_wrong_or_malformed_digest_is_refused() {
let root = scratch("handoff-digest");
let backend = backend(&root);
let mut file = handed_off(&root, "patch-1.so", b"patch", 0o600);
let good = file.sha256.clone();
file.sha256 = "0".repeat(64);
let message = refusal(&backend, &file, 5);
assert!(message.contains("SHA-256 does not match"), "{message}");
file.sha256 = good.to_uppercase();
let message = refusal(&backend, &file, 5);
assert!(message.contains("lowercase hex"), "{message}");
}
#[cfg(unix)]
#[test]
fn a_file_owned_by_another_uid_is_refused() {
use std::os::unix::fs::MetadataExt as _;
let root = scratch("handoff-owner");
let file = handed_off(&root, "patch-1.so", b"patch", 0o600);
let own = std::fs::metadata(&file.path).expect("meta").uid();
let Err(BackendError::InvalidRequest(message)) =
read_handed_off_patch(&file, 5, own.wrapping_add(1))
else {
panic!("a foreign owner must be refused");
};
assert!(message.contains("owned by uid"), "{message}");
assert!(!message.contains(&file.path), "{message}");
assert_eq!(effective_uid(&root.join("probe")).expect("uid"), own);
}
#[cfg(unix)]
#[test]
fn a_relative_path_is_refused() {
let backend = backend(&scratch("handoff-rel"));
let file = PatchFile {
path: "patch-1.so".to_string(),
sha256: "0".repeat(64),
};
let message = refusal(&backend, &file, 1);
assert!(message.contains("not absolute"), "{message}");
}
#[test]
fn redaction_masks_the_dir_in_every_spelling_and_the_file_name() {
let dir = scratch("mask").join("frust-hotpatch");
std::fs::create_dir_all(&dir).expect("dir");
let canonical = std::fs::canonicalize(&dir).expect("canonical");
let message = format!(
"dlopen({}/patch-1-3.so) failed; also {}/patch-1-3.so",
dir.display(),
canonical.display()
);
let masked = redact(&message, &dir, "patch-1-3.so");
assert!(!masked.contains(&*dir.to_string_lossy()), "{masked}");
assert!(!masked.contains(&*canonical.to_string_lossy()), "{masked}");
assert!(!masked.contains("patch-1-3.so"), "{masked}");
}
#[test]
fn the_target_triple_names_this_arch_and_os_family() {
let triple = target_triple();
assert!(triple.starts_with(std::env::consts::ARCH), "{triple}");
if cfg!(target_os = "macos") {
assert!(triple.ends_with("-apple-darwin"), "{triple}");
}
if cfg!(all(target_os = "linux", target_env = "gnu")) {
assert!(triple.ends_with("-unknown-linux-gnu"), "{triple}");
}
}
#[test]
fn only_the_arm64_simulator_spells_the_ios_suffix_with_sim() {
assert_eq!(ios_suffix(true, true), "apple-ios-sim");
assert_eq!(ios_suffix(false, true), "apple-ios");
assert_eq!(ios_suffix(true, false), "apple-ios");
assert_eq!(ios_suffix(false, false), "apple-ios");
if cfg!(not(target_os = "ios")) {
assert!(!target_triple().contains("apple-ios"));
}
}
}
}