Files
punktfunk/tools/cursor-probe/src/main.rs
T
enricobuehler df6f270e7b chore(safety): forbid unsafe on the crates that are already at zero
Five permanent ratchets, all free today — the point is that they cannot regress
tomorrow. Each crate was re-measured at the commit, not taken from a survey.

`forbid(unsafe_code)`:

  punktfunk-encode-worker  the binary that carries cap_sys_nice. Its header
                           claims "no Wayland, no D-Bus, no network, no
                           plugins"; this makes the memory-safety half of that
                           claim mechanical. `forbid`, not `deny`, so it cannot
                           be re-opened by an #[allow] further down.
  pf-update-check          parses a signed, network-fetched manifest and its own
                           header says it "owns the part where being wrong is a
                           security bug". Signature checking is worthless if the
                           parser around it can be walked out of bounds.
  pf-vaadec                its header states the design constraint outright — it
                           links no libva and compiles on macOS, "which is the
                           point". The crate is full of hand-declared libva
                           repr(C) mirrors; one raw deref and it stops being the
                           CPU-testable half.
  tools/cursor-probe       free, and a probe is where "just deref it to see" is
                           most tempting.

`deny(unsafe_code)` + one localized allow:

  pf-update                root runs this. Its single unsafe operation, a bare
                           geteuid, moves into a named `effective_uid()` helper
                           carrying the crate's one #[allow(unsafe_code)].

Deliberately NOT rewritten to rustix, contrary to the programme document's first
draft: pf-update's Cargo.toml states that its zero-dependency posture IS a
security invariant of a root helper ("no HTTP client, no TLS, no argument
parsing"), and the extern block says the same. Pulling a general-purpose syscall
crate into a root helper to delete one `unsafe` would trade a real property for
a cosmetic one. The localized allow keeps the ratchet: any NEW unsafe anywhere
in the crate is a build error.

Verified: `cargo check -p pf-vaadec -p pf-update-check` and
`cargo check -p pf-update -p cursor-probe` clean on macOS, plus
`cargo check -p pf-update --target x86_64-unknown-linux-gnu` — pf-update's whole
body is behind `cfg(target_os = "linux")`, so the macOS check does not reach the
line that changed. punktfunk-encode-worker is not built here (pf-encode's C
dependencies do not cross-compile from macOS) and needs the Linux CI leg.
2026-08-11 13:49:41 +02:00

281 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Cursor-as-metadata on-glass probe (see Cargo.toml). Run inside the target user session:
//!
//! ```sh
//! DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/$(id -u)/bus cursor-probe [--seconds 20] [--embedded] [--gpu]
//! ```
//!
//! `--embedded` A/Bs the pre-channel path (compositor embeds the pointer, no metadata expected);
//! `--gpu` takes the zero-copy dmabuf negotiation a real session uses instead of the CPU mmap path.
// A diagnostic that drives the same PipeWire/dmabuf negotiation the host does, but through safe
// wrappers only. Free to lock down, and worth locking down: a probe is exactly the kind of tool
// where "just deref it quickly to see what's there" is tempting.
#![forbid(unsafe_code)]
#[cfg(target_os = "linux")]
fn main() -> anyhow::Result<()> {
linux::run()
}
#[cfg(not(target_os = "linux"))]
fn main() {
eprintln!("cursor-probe is Linux-only (compositor virtual outputs)");
std::process::exit(1);
}
#[cfg(target_os = "linux")]
mod linux {
use anyhow::{Context, Result};
use punktfunk_core::input::{InputEvent, InputKind};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
const W: u32 = 1280;
const H: u32 = 800;
pub fn run() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
)
.init();
let args: Vec<String> = std::env::args().collect();
let arg = |name: &str| {
args.iter()
.skip_while(|a| a.as_str() != name)
.nth(1)
.cloned()
};
let secs: u64 = arg("--seconds").and_then(|s| s.parse().ok()).unwrap_or(20);
let embedded = args.iter().any(|a| a == "--embedded");
let gpu = args.iter().any(|a| a == "--gpu");
let compositor = pf_vdisplay::detect()?;
println!("cursor-probe: compositor = {compositor:?}");
let mut vd = pf_vdisplay::open(compositor)?;
// The cursor-channel session's request: out-of-band cursor (Mutter/KWin metadata mode).
vd.set_hw_cursor(!embedded);
println!(
"cursor-probe: virtual output with {} cursor",
if embedded {
"EMBEDDED (compositor paints it)"
} else {
"METADATA (out-of-band)"
}
);
let vout = vd
.create(pf_vdisplay::Mode {
width: W,
height: H,
refresh_hz: 60,
})
.context("create the virtual output")?;
println!("cursor-probe: node_id = {}", vout.node_id);
// Encode-backend facts don't matter to the metadata question; all-off = CPU-friendly.
let policy = pf_capture::ZeroCopyPolicy {
backend_is_vaapi: false,
backend_is_gpu: gpu,
pyrowave_session: false,
native_nv12_session: false,
pyrowave_modifiers: Vec::new(),
hdr_cuda_ok: false,
};
let mut cap = pf_capture::open_virtual_output(
vout.remote_fd,
vout.node_id,
vout.preferred_mode,
vout.keepalive,
gpu,
false,
false,
policy,
vout.expect_exact_dims,
compositor == pf_vdisplay::Compositor::Kwin,
)
.context("attach the PipeWire capturer")?;
cap.set_active(true);
// A cursor-channel session in capture mode: the HOST composites, so the capturer must
// keep surfacing the overlay (this is a no-op on the Linux portal capturer, but state it
// the way the encode loop does).
cap.set_cursor_forward(false);
// Wiggle the pointer over the virtual output through the production injector (libei on
// GNOME/KWin). The region ladder maps the absolute events onto the output by size match —
// W×H is deliberately distinctive.
let backend = pf_inject::default_backend();
println!("cursor-probe: input backend = {backend:?} (4 s device-resume wait)");
let stop = Arc::new(AtomicBool::new(false));
let stop_inj = stop.clone();
// `dyn InputInjector` isn't Send — build it on the thread that drives it.
let injector = std::thread::spawn(move || {
let mut inj = match pf_inject::open(backend) {
Ok(i) => i,
Err(e) => {
tracing::error!(error = %format!("{e:#}"), "cursor-probe: injector open failed");
return;
}
};
std::thread::sleep(Duration::from_secs(4));
let flags = (W << 16) | (H & 0xffff);
let (cx, cy, r) = ((W / 2) as f64, (H / 2) as f64, 200.0f64);
let mut t = 0.0f64;
let started = Instant::now();
let mut announced_rel = false;
while !stop_inj.load(Ordering::Relaxed) {
// Phase 1 (8 s): ABSOLUTE wiggle — parks the pointer on the virtual output and
// proves the metadata path. Phase 2: RELATIVE circles — what a capture-mode
// (pointer-lock) client sends; watch whether the live meta position keeps moving.
let e = if started.elapsed() < Duration::from_secs(8) {
InputEvent {
kind: InputKind::MouseMoveAbs,
_pad: [0; 3],
code: 0,
x: (cx + r * t.cos()) as i32,
y: (cy + r * t.sin()) as i32,
flags,
}
} else {
if !announced_rel {
announced_rel = true;
println!("cursor-probe: switching to RELATIVE motion (capture-mode model)");
}
InputEvent {
kind: InputKind::MouseMove,
_pad: [0; 3],
code: 0,
x: (12.0 * t.cos()) as i32,
y: (12.0 * t.sin()) as i32,
flags: 0,
}
};
if let Err(err) = inj.inject(&e) {
tracing::warn!(error = %format!("{err:#}"), "cursor-probe: inject failed");
}
t += 0.15;
std::thread::sleep(Duration::from_millis(25));
}
});
let dump = args.iter().any(|a| a == "--dump");
let dump_dir = format!(
"/tmp/pf-probe-frames-{}",
if embedded { "embedded" } else { "metadata" }
);
if dump {
let _ = std::fs::remove_dir_all(&dump_dir);
let _ = std::fs::create_dir_all(&dump_dir);
}
let mut prev: Vec<u8> = Vec::new();
let mut changed = 0u64;
let mut dumped = 0u32;
let deadline = Instant::now() + Duration::from_secs(secs);
let (mut frames, mut with_overlay) = (0u64, 0u64);
let mut last_report = Instant::now();
let mut live_seen = false;
while Instant::now() < deadline {
// Damage-driven source: timeouts (gaps) are normal, hence the missing error arm.
if let Ok(f) = cap.next_frame_within(Duration::from_millis(500)) {
frames += 1;
if let pf_frame::FramePayload::Cpu(data) = &f.payload {
if !prev.is_empty()
&& prev.len() == data.len()
&& prev.as_slice() != data.as_slice()
{
changed += 1;
}
prev.clear();
prev.extend_from_slice(data);
if dump && frames % 15 == 1 && dumped < 16 {
dumped += 1;
let path = format!("{dump_dir}/frame-{frames:05}.ppm");
match dump_ppm(&path, f.width, f.height, f.format, data) {
Ok(()) => println!("cursor-probe: dumped {path}"),
Err(e) => eprintln!("cursor-probe: dump failed: {e:#}"),
}
}
}
if let Some(c) = &f.cursor {
with_overlay += 1;
if with_overlay == 1 {
println!(
"cursor-probe: FIRST frame-attached overlay: pos=({}, {}) \
{}x{} serial={} visible={}",
c.x, c.y, c.w, c.h, c.serial, c.visible
);
}
}
}
if last_report.elapsed() >= Duration::from_secs(2) {
last_report = Instant::now();
let live = cap.cursor();
if live.is_some() {
live_seen = true;
}
match live {
Some(c) => println!(
"cursor-probe: frames={frames} with_overlay={with_overlay} live: \
pos=({}, {}) {}x{} serial={} visible={}",
c.x, c.y, c.w, c.h, c.serial, c.visible
),
None => println!(
"cursor-probe: frames={frames} with_overlay={with_overlay} live: NONE \
(no SPA_META_Cursor bitmap yet)"
),
}
}
}
stop.store(true, Ordering::Relaxed);
let _ = injector.join();
println!("---");
println!(
"cursor-probe: content-changed frames: {changed}/{frames} (a moving embedded \
cursor over a static desktop should change nearly every frame)"
);
if embedded {
println!(
"cursor-probe: EMBEDDED run — no metadata expected; check the dumped frames. \
frames={frames}"
);
} else if live_seen || with_overlay > 0 {
println!(
"cursor-probe: VERDICT: metadata cursor DELIVERED ({with_overlay}/{frames} \
frames carried it; live overlay seen: {live_seen})"
);
} else {
println!(
"cursor-probe: VERDICT: NO cursor metadata after {secs}s of pointer motion over \
the virtual output ({frames} frames) — the compositor is not delivering \
SPA_META_Cursor on this stream"
);
}
Ok(())
}
/// Write a packed 4-bpp frame as a PPM, swapping B/R for the BGR layouts. Enough fidelity
/// to answer "is the pointer in the pixels".
fn dump_ppm(
path: &str,
w: u32,
h: u32,
format: pf_frame::PixelFormat,
data: &[u8],
) -> Result<()> {
use std::io::Write;
let mut out = std::io::BufWriter::new(std::fs::File::create(path)?);
write!(out, "P6\n{w} {h}\n255\n")?;
let (ri, gi, bi) = match format {
pf_frame::PixelFormat::Rgbx | pf_frame::PixelFormat::Rgba => (0usize, 1usize, 2usize),
_ => (2usize, 1usize, 0usize),
};
for px in data.chunks_exact(4).take((w as usize) * (h as usize)) {
out.write_all(&[px[ri], px[gi], px[bi]])?;
}
Ok(())
}
}