Audit-driven cleanup pass. End state:
- cargo clippy --release --all-targets: 0 errors (was 4)
- undocumented_unsafe_blocks warnings: 0 (was 67)
- Cargo.toml: undocumented_unsafe_blocks escalated warn -> deny
Clippy correctness errors fixed:
- src/bin/{sw_encode_bench,vaapi_import_bench}.rs: receive_first_frame
rewritten per Oracle plan with total 10s deadline + 200ms wait slice +
while-let drain of all control events. The previous loop body always
exited on first iteration (never_loop); the new version actually retries
and matches production's repeated-poll semantics in state_portal.rs.
- src/avhw.rs: hash_sampled_y_plane tests now use a row_range(row, stride,
width) helper instead of inline stride * N. Preserves the row-index
intent across all sibling tests without tripping erasing_op (row==0) or
identity_op (row==1).
Machine-applicable clippy autofixes applied via 'cargo clippy --fix':
- unnecessary_cast, manual_is_multiple_of, needless_borrows_for_generic_args
- manual_abs_diff, derivable_impls, new_without_default
- unnecessary_map_or, unneeded_struct_pattern, redundant_locals
webrtc_gop_formula test rewritten to wrap the (fps * 2).max(20) formula in
a runtime lambda. The previous clippy --fix pass had constant-folded the
5fps case into assert_eq!(20, 20), silently stripping the floor-case
coverage. The lambda blocks the fold while keeping the formula exercisable.
67 SAFETY comments added across 7 files (cap_portal.rs 26, sw_encode_bench
21, state_portal.rs 7, vaapi_import_bench.rs 6, avhw.rs 5, state.rs 1,
main.rs 1). Two sites carry load-bearing invariant documentation:
- cap_portal.rs:806 process callback documents the PipeWire raw_buf
ownership contract across all 10 exit paths (audited: every path
correctly requeues; fd ownership via dup() is independent and also
exactly-once closed).
- avhw.rs:341 unsafe impl Send for EncState documents the single-thread
exclusivity assumption referenced by AGENTS.md.
All 97 unit tests + 3 integration tests still pass; cargo build --release
finishes clean. Lint escalation to deny freezes the SAFETY baseline: any
future patch adding an unsafe block without a // SAFETY: comment will fail
clippy at compile time.
1299 lines
51 KiB
Rust
1299 lines
51 KiB
Rust
// cap_portal.rs — 通过 XDG Desktop Portal 的 ScreenCast 接口捕获屏幕帧
|
||
//
|
||
// 整体架构:
|
||
// 1. CapPortal::new() 在主线程创建,内部启动一个专用的 PipeWire 捕获线程
|
||
// 2. PipeWire 线程通过 Portal 获取的 fd 和 node_id 连接到 PipeWire,接收 DMA-BUF 帧
|
||
// 3. 帧数据通过 crossbeam channel 从 PipeWire 线程传递给消费者
|
||
// 4. 关闭时通过 eventfd 通知 PipeWire 线程退出,避免 UAF (Use-After-Free)
|
||
//
|
||
// 关键依赖:
|
||
// - ashpd: XDG Desktop Portal 的 Rust 绑定,用于请求屏幕录制权限
|
||
// - pipewire / libspa: PipeWire 的 Rust 绑定,用于接收视频流
|
||
// - crossbeam-channel: 高性能有界通道,用于线程间帧传递
|
||
|
||
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
|
||
use std::path::PathBuf;
|
||
use std::sync::atomic::{AtomicU64, Ordering};
|
||
use std::sync::Arc;
|
||
use std::thread::{self, JoinHandle};
|
||
|
||
use anyhow::Result;
|
||
use crossbeam_channel::{bounded, Receiver, Sender};
|
||
use tokio::runtime::Runtime;
|
||
|
||
use crate::args::Args;
|
||
|
||
/// Portal phase timeout when no user interaction is expected (proxy/session
|
||
/// creation, token-path select/start, PipeWire fd). 5s is generous for
|
||
/// healthy xdg-desktop-portal (<500ms typical) but bounded for fast failure.
|
||
const PORTAL_SERVICE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
|
||
|
||
/// Portal phase timeout when user must click "Allow" in desktop dialog
|
||
/// (select/start without restore token). 30s gives time to find the dialog.
|
||
const PORTAL_USER_DIALOG_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
|
||
|
||
/// Classification of Portal phase timeouts to drive retry behavior.
|
||
#[derive(Debug)]
|
||
enum PortalPhaseTimeout {
|
||
/// Portal service unresponsive; not retried (user should restart service).
|
||
Service,
|
||
/// Timed out in token-dependent phase; retried once after clearing token.
|
||
TokenDependent,
|
||
}
|
||
|
||
impl std::fmt::Display for PortalPhaseTimeout {
|
||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||
match self {
|
||
Self::Service => write!(f, "Portal phase timed out (service)"),
|
||
Self::TokenDependent => write!(f, "Portal phase timed out (token-dependent)"),
|
||
}
|
||
}
|
||
}
|
||
|
||
impl std::error::Error for PortalPhaseTimeout {}
|
||
|
||
/// Log an actionable diagnostic when a Portal phase times out.
|
||
///
|
||
/// Mirrors the message format from `backend_detect.rs::log_portal_unresponsive`
|
||
/// but additionally suggests `--no-persist` when the timeout occurred in a
|
||
/// phase that was using a restore token.
|
||
fn log_portal_phase_timeout(phase: &str, used_restore_token: bool) {
|
||
let persist_hint = if used_restore_token {
|
||
" If this recurs, try: wl-webrtc --no-persist"
|
||
} else {
|
||
""
|
||
};
|
||
tracing::error!(
|
||
"Portal service did not respond within timeout while {phase}. \
|
||
This usually means xdg-desktop-portal or xdg-desktop-portal-kde is stuck. \
|
||
Try: systemctl --user restart xdg-desktop-portal xdg-desktop-portal-kde, \
|
||
then re-run wl-webrtc.{persist_hint}"
|
||
);
|
||
}
|
||
|
||
/// PipeWire DMA-BUF 帧数据
|
||
///
|
||
/// 表示从 PipeWire 流中接收到的一帧视频数据。
|
||
/// 帧的像素数据存储在 DMA-BUF(Linux 的零拷贝 buffer 共享机制)中,
|
||
/// 通过文件描述符 (fd) 引用,消费者通过 mmap 或 DRM 导入来访问像素数据。
|
||
pub struct PwDmaBufFrame {
|
||
/// DMA-BUF 文件描述符,指向 GPU 显存中的帧缓冲区
|
||
pub fd: OwnedFd,
|
||
/// 帧数据在 DMA-BUF 中的字节偏移量
|
||
pub offset: u64,
|
||
/// 每行像素的字节跨度(可能大于 width * bpp,因为可能有对齐填充)
|
||
pub stride: u32,
|
||
/// DRM 格式修饰符,描述 buffer 的内存布局(如线性布局、tiling 等)
|
||
pub modifier: u64,
|
||
/// 帧宽度(像素)
|
||
pub width: u32,
|
||
/// 帧高度(像素)
|
||
pub height: u32,
|
||
/// DRM FourCC 格式标识符(如 BGRA、RGBA 等)
|
||
pub format: u32,
|
||
/// 显示时间戳 (PTS, Presentation Time Stamp),单位为纳秒
|
||
pub pts: i64,
|
||
}
|
||
|
||
/// PipeWire 控制事件枚举
|
||
///
|
||
/// 从 PipeWire 捕获线程发送给消费者的控制事件。
|
||
/// 与帧数据分离,通过独立的 channel 传输,确保控制事件不被帧数据淹没。
|
||
pub enum PwCtrlEvent {
|
||
/// 流已结束(PipeWire 流断开连接或进入错误状态)
|
||
StreamEnded,
|
||
/// Format/dimensions changed mid-stream
|
||
FormatChanged { width: u32, height: u32 },
|
||
/// 发生错误,包含错误描述信息
|
||
Error(String),
|
||
}
|
||
|
||
/// 屏幕捕获门户(Portal)封装
|
||
///
|
||
/// 通过 XDG Desktop Portal 的 ScreenCast 接口实现屏幕捕获。
|
||
/// 内部管理一个 PipeWire 捕获线程,通过 channel 异步提供帧数据。
|
||
///
|
||
/// 生命周期:
|
||
/// 1. new() — 建立 Portal 会话,启动 PipeWire 线程
|
||
/// 2. frame_receiver() — 获取帧接收端,供消费者轮询
|
||
/// 3. Drop — 通过 eventfd 通知 PipeWire 线程安全退出
|
||
pub struct CapPortal {
|
||
shutdown_fd: OwnedFd,
|
||
frame_rx: Receiver<PwDmaBufFrame>,
|
||
event_rx: Receiver<PwCtrlEvent>,
|
||
pw_thread: Option<JoinHandle<()>>,
|
||
rt: Runtime,
|
||
pw_dropped: Arc<AtomicU64>,
|
||
}
|
||
|
||
/// PipeWire 捕获线程的上下文数据
|
||
///
|
||
/// 从主线程传递给 PipeWire 捕获线程的所有必要资源。
|
||
/// 该结构体在线程创建时一次性 move 到线程中使用。
|
||
struct PwThreadCtx {
|
||
frame_tx: Sender<PwDmaBufFrame>,
|
||
event_tx: Sender<PwCtrlEvent>,
|
||
dropped: Arc<AtomicU64>,
|
||
shutdown_read: OwnedFd,
|
||
pw_fd: OwnedFd,
|
||
node_id: u32,
|
||
fps: u32,
|
||
}
|
||
|
||
impl CapPortal {
|
||
/// 创建屏幕捕获实例
|
||
///
|
||
/// 执行流程:
|
||
/// 1. 创建 Tokio 运行时(用于异步 Portal 调用)
|
||
/// 2. 通过 XDG Desktop Portal 请求屏幕录制权限,获取 PipeWire fd 和 node_id
|
||
/// 3. 创建有界通道(容量 1)用于帧传递(最新帧优先,避免队列积压延迟)
|
||
/// 4. 创建 eventfd 对,用于线程安全的关闭信号传递
|
||
/// 5. 启动 PipeWire 捕获线程
|
||
pub fn new(args: &Args) -> Result<Self> {
|
||
let rt = Runtime::new()?;
|
||
|
||
let no_persist = args.no_persist;
|
||
let (pw_fd, node_id) = rt.block_on(async { Self::setup_portal(no_persist).await })?;
|
||
|
||
let (frame_tx, frame_rx) = bounded(1);
|
||
let (event_tx, event_rx) = bounded(8);
|
||
|
||
// SAFETY: eventfd(2) is a POSIX syscall with no preconditions; the init value
|
||
// and flags (CLOEXEC + NONBLOCK) are valid. Returns either a fresh fd (>= 0)
|
||
// or -1 on error, which we check immediately below.
|
||
let efd = unsafe { libc::eventfd(0, libc::EFD_CLOEXEC | libc::EFD_NONBLOCK) };
|
||
if efd < 0 {
|
||
return Err(anyhow::anyhow!(
|
||
"eventfd failed: {}",
|
||
std::io::Error::last_os_error()
|
||
));
|
||
}
|
||
// SAFETY: `efd` is the open eventfd we just created (>= 0 checked above) and
|
||
// own. dup(2) returns either a fresh fd or -1.
|
||
let write_fd = unsafe { libc::dup(efd) };
|
||
if write_fd < 0 {
|
||
let err = std::io::Error::last_os_error();
|
||
// SAFETY: `efd` is still the open eventfd we own; closing on the error
|
||
// path before returning to avoid fd leak.
|
||
unsafe { libc::close(efd) };
|
||
return Err(anyhow::anyhow!("dup eventfd failed: {err}"));
|
||
}
|
||
|
||
let pw_dropped = Arc::new(AtomicU64::new(0));
|
||
|
||
let ctx = PwThreadCtx {
|
||
frame_tx,
|
||
event_tx,
|
||
dropped: pw_dropped.clone(),
|
||
// SAFETY: `efd` is the freshly-created eventfd (>= 0 checked above) and we
|
||
// are its sole owner. OwnedFd::from_raw_fd takes ownership and will close()
|
||
// it on Drop. Ownership transfers into PwThreadCtx and then into the
|
||
// PipeWire thread via pipewire_thread.
|
||
shutdown_read: unsafe { OwnedFd::from_raw_fd(efd) },
|
||
pw_fd,
|
||
node_id,
|
||
fps: args.fps,
|
||
};
|
||
|
||
let pw_thread = thread::Builder::new()
|
||
.name("pipewire-capture".into())
|
||
.spawn(move || {
|
||
pipewire_thread(ctx);
|
||
})
|
||
.map_err(|e| {
|
||
// SAFETY: `write_fd` is the open dup'd eventfd we own (>= 0 checked
|
||
// above); closing on thread-spawn failure to avoid fd leak.
|
||
unsafe { libc::close(write_fd) };
|
||
anyhow::anyhow!("thread spawn failed: {e}")
|
||
})?;
|
||
|
||
Ok(Self {
|
||
// SAFETY: `write_fd` is the freshly-dup'd eventfd (>= 0 checked above) and
|
||
// we are its sole owner. OwnedFd::from_raw_fd takes ownership and will
|
||
// close() it on Drop (which fires when CapPortal is dropped).
|
||
shutdown_fd: unsafe { OwnedFd::from_raw_fd(write_fd) },
|
||
frame_rx,
|
||
event_rx,
|
||
pw_thread: Some(pw_thread),
|
||
rt,
|
||
pw_dropped,
|
||
})
|
||
}
|
||
|
||
pub fn frame_receiver(&self) -> &Receiver<PwDmaBufFrame> {
|
||
&self.frame_rx
|
||
}
|
||
|
||
pub fn event_receiver(&self) -> &Receiver<PwCtrlEvent> {
|
||
&self.event_rx
|
||
}
|
||
|
||
/// Returns the total number of PipeWire frames dropped due to channel backlog.
|
||
pub fn dropped_count(&self) -> u64 {
|
||
self.pw_dropped.load(Ordering::Relaxed)
|
||
}
|
||
|
||
/// Returns the number of frames currently waiting in the capture channel.
|
||
pub fn capture_queue_depth(&self) -> usize {
|
||
self.frame_rx.len()
|
||
}
|
||
|
||
/// 通过 XDG Desktop Portal 建立屏幕录制会话
|
||
///
|
||
/// 与桌面环境的 D-Bus 服务交互,请求用户授权屏幕录制。
|
||
/// 流程:
|
||
/// 1. 创建 Screencast 代理(D-Bus 代理)
|
||
/// 2. 创建 ScreenCast 会话
|
||
/// 3. 配置源选择参数(光标模式、显示器源、不持久化会话)
|
||
/// 4. 启动录制,获取流信息(包含 PipeWire node_id)
|
||
/// 5. 打开 PipeWire 远程连接,获取文件描述符
|
||
///
|
||
/// 返回 (PipeWire fd, node_id),供 PipeWire 线程连接使用
|
||
///
|
||
/// Wraps `_setup_portal_inner` with token-aware retry: on a `TokenDependent`
|
||
/// timeout (phases 3 or 4 with a restore token in use) AND `no_persist ==
|
||
/// false`, clears the cached restore token and retries once with
|
||
/// `no_persist = true`.
|
||
async fn setup_portal(no_persist: bool) -> Result<(OwnedFd, u32)> {
|
||
match Self::_setup_portal_inner(no_persist, false).await {
|
||
Ok(result) => Ok(result),
|
||
Err(e) if e.is::<PortalPhaseTimeout>() => {
|
||
let inner_err = e.downcast_ref::<PortalPhaseTimeout>().unwrap();
|
||
match inner_err {
|
||
PortalPhaseTimeout::TokenDependent if !no_persist => {
|
||
tracing::warn!(
|
||
"Portal timed out during token-using phase. \
|
||
Clearing cached restore token and retrying with fresh authorization."
|
||
);
|
||
delete_restore_token();
|
||
Self::_setup_portal_inner(true, true).await
|
||
}
|
||
_ => Err(e),
|
||
}
|
||
}
|
||
Err(e) => Err(e),
|
||
}
|
||
}
|
||
|
||
/// Inner Portal setup with phased timeouts. See `setup_portal` for the
|
||
/// retry wrapper.
|
||
///
|
||
/// `is_retry == true` disables further retry attempts (max 1 retry).
|
||
async fn _setup_portal_inner(
|
||
no_persist: bool,
|
||
is_retry: bool,
|
||
) -> Result<(OwnedFd, u32)> {
|
||
use ashpd::desktop::screencast::{
|
||
CursorMode, Screencast, SelectSourcesOptions, SourceType,
|
||
};
|
||
use ashpd::desktop::PersistMode;
|
||
|
||
// Phase 1: Screencast proxy (no user interaction).
|
||
let proxy = match tokio::time::timeout(PORTAL_SERVICE_TIMEOUT, Screencast::new()).await {
|
||
Ok(Ok(p)) => p,
|
||
Ok(Err(e)) => return Err(anyhow::anyhow!("Failed to create Screencast proxy: {e}")),
|
||
Err(_) => {
|
||
log_portal_phase_timeout("creating Screencast proxy", false);
|
||
return Err(PortalPhaseTimeout::Service.into());
|
||
}
|
||
};
|
||
|
||
// Phase 2: create_session (no user interaction).
|
||
let session = match tokio::time::timeout(
|
||
PORTAL_SERVICE_TIMEOUT,
|
||
proxy.create_session(Default::default()),
|
||
)
|
||
.await
|
||
{
|
||
Ok(Ok(s)) => s,
|
||
Ok(Err(e)) => return Err(anyhow::anyhow!("Failed to create ScreenCast session: {e}")),
|
||
Err(_) => {
|
||
log_portal_phase_timeout("creating session", false);
|
||
return Err(PortalPhaseTimeout::Service.into());
|
||
}
|
||
};
|
||
|
||
let version_supported = proxy.version() >= 4;
|
||
|
||
let (persist_mode, saved_token) = if !no_persist && version_supported {
|
||
let token = load_restore_token();
|
||
if token.is_some() {
|
||
if is_retry {
|
||
tracing::info!("Re-attempting portal session after token clear");
|
||
} else {
|
||
tracing::info!("Attempting to restore portal session with saved token");
|
||
}
|
||
}
|
||
(PersistMode::ExplicitlyRevoked, token)
|
||
} else {
|
||
(PersistMode::DoNot, None)
|
||
};
|
||
|
||
let mut options = SelectSourcesOptions::default()
|
||
.set_cursor_mode(CursorMode::Embedded)
|
||
.set_sources(ashpd::enumflags2::BitFlags::from(SourceType::Monitor))
|
||
.set_multiple(false)
|
||
.set_persist_mode(persist_mode);
|
||
|
||
if let Some(ref token) = saved_token {
|
||
options = options.set_restore_token(token.as_str());
|
||
}
|
||
|
||
// Phase 3: select_sources — token path is fast (no dialog); fresh
|
||
// authorization may pop a dialog.
|
||
let token_in_use = saved_token.is_some();
|
||
let phase3_timeout = if token_in_use {
|
||
PORTAL_SERVICE_TIMEOUT
|
||
} else {
|
||
PORTAL_USER_DIALOG_TIMEOUT
|
||
};
|
||
match tokio::time::timeout(phase3_timeout, proxy.select_sources(&session, options)).await {
|
||
Ok(Ok(_)) => {}
|
||
Ok(Err(e)) => return Err(anyhow::anyhow!("Screen sharing permission denied: {e}")),
|
||
Err(_) => {
|
||
log_portal_phase_timeout("selecting sources", token_in_use);
|
||
return Err(
|
||
if token_in_use {
|
||
PortalPhaseTimeout::TokenDependent
|
||
} else {
|
||
PortalPhaseTimeout::Service
|
||
}
|
||
.into(),
|
||
);
|
||
}
|
||
}
|
||
|
||
// Phase 4: start + response — same dialog-vs-token reasoning as phase 3.
|
||
let phase4_timeout = if token_in_use {
|
||
PORTAL_SERVICE_TIMEOUT
|
||
} else {
|
||
PORTAL_USER_DIALOG_TIMEOUT
|
||
};
|
||
let start_fut = async {
|
||
proxy
|
||
.start(&session, None, Default::default())
|
||
.await?
|
||
.response()
|
||
};
|
||
let response = match tokio::time::timeout(phase4_timeout, start_fut).await {
|
||
Ok(Ok(r)) => r,
|
||
Ok(Err(e)) => return Err(anyhow::anyhow!("ScreenCast start/response error: {e}")),
|
||
Err(_) => {
|
||
log_portal_phase_timeout("starting session", token_in_use);
|
||
return Err(
|
||
if token_in_use {
|
||
PortalPhaseTimeout::TokenDependent
|
||
} else {
|
||
PortalPhaseTimeout::Service
|
||
}
|
||
.into(),
|
||
);
|
||
}
|
||
};
|
||
|
||
if !no_persist && version_supported {
|
||
if let Some(new_token) = response.restore_token() {
|
||
save_restore_token(new_token);
|
||
}
|
||
}
|
||
|
||
let stream = response
|
||
.streams()
|
||
.first()
|
||
.ok_or_else(|| anyhow::anyhow!("No streams returned from ScreenCast"))?;
|
||
|
||
let node_id = stream.pipe_wire_node_id();
|
||
|
||
// Phase 5: open_pipe_wire_remote (no user interaction).
|
||
let fd = match tokio::time::timeout(
|
||
PORTAL_SERVICE_TIMEOUT,
|
||
proxy.open_pipe_wire_remote(&session, Default::default()),
|
||
)
|
||
.await
|
||
{
|
||
Ok(Ok(f)) => f,
|
||
Ok(Err(e)) => return Err(anyhow::anyhow!("Failed to open PipeWire remote: {e}")),
|
||
Err(_) => {
|
||
log_portal_phase_timeout("opening PipeWire remote", false);
|
||
return Err(PortalPhaseTimeout::Service.into());
|
||
}
|
||
};
|
||
|
||
tracing::info!("Portal session established: node_id={node_id}");
|
||
|
||
Ok((fd, node_id))
|
||
}
|
||
}
|
||
|
||
fn token_path() -> Option<PathBuf> {
|
||
dirs::cache_dir().map(|base| base.join("wl-webrtc").join("portal-restore-token"))
|
||
}
|
||
|
||
/// Verify that `path` is a directory owned by the current user with no group/other permissions.
|
||
/// Rejects symlinks at the path itself (but allows the resolved target to be a real dir).
|
||
fn verify_secure_dir(path: &std::path::Path) -> bool {
|
||
use std::os::unix::fs::{MetadataExt, PermissionsExt};
|
||
|
||
match std::fs::symlink_metadata(path) {
|
||
Ok(meta) => {
|
||
if meta.file_type().is_symlink() {
|
||
tracing::warn!(
|
||
"Token parent dir is a symlink, rejecting: {}",
|
||
path.display()
|
||
);
|
||
return false;
|
||
}
|
||
// Must be a directory
|
||
if !meta.is_dir() {
|
||
tracing::warn!("Token parent path is not a directory: {}", path.display());
|
||
return false;
|
||
}
|
||
// Must be owned by current user
|
||
// SAFETY: libc::getuid has no preconditions and cannot fail; it simply
|
||
// returns the calling process's real user ID.
|
||
// SAFETY: libc::getuid has no preconditions and cannot fail.
|
||
if meta.uid() != unsafe { libc::getuid() } {
|
||
tracing::warn!(
|
||
"Token parent dir not owned by current user: {}",
|
||
path.display()
|
||
);
|
||
return false;
|
||
}
|
||
// No group or other permissions (mode must be 0o700 exactly within the 0o777 mask)
|
||
let mode = meta.permissions().mode() & 0o777;
|
||
if mode != 0o700 {
|
||
tracing::warn!(
|
||
"Token parent dir has insecure permissions {:o}, expected 0700: {}",
|
||
mode,
|
||
path.display()
|
||
);
|
||
return false;
|
||
}
|
||
true
|
||
}
|
||
Err(e) => {
|
||
tracing::warn!("Failed to stat token parent dir: {e}");
|
||
false
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Ensure the parent directory exists with restrictive permissions (0o700).
|
||
/// Returns false if the directory could not be created or is insecure.
|
||
fn ensure_secure_parent(parent: &std::path::Path) -> bool {
|
||
use std::os::unix::fs::{DirBuilderExt, PermissionsExt};
|
||
|
||
if parent.exists() {
|
||
// Directory exists — try to tighten permissions, then verify.
|
||
// set_permissions follows symlinks, which is fine here since
|
||
// we verify with symlink_metadata in verify_secure_dir.
|
||
if let Err(e) = std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700)) {
|
||
tracing::warn!("Failed to set directory permissions: {e}");
|
||
return false;
|
||
}
|
||
return verify_secure_dir(parent);
|
||
}
|
||
|
||
// Create with restrictive mode — DirBuilderExt::mode bypasses umask.
|
||
let mut builder = std::fs::DirBuilder::new();
|
||
builder.recursive(true);
|
||
builder.mode(0o700);
|
||
if let Err(e) = builder.create(parent) {
|
||
tracing::warn!("Failed to create token directory: {e}");
|
||
return false;
|
||
}
|
||
|
||
// Verify after creation (belt-and-suspenders)
|
||
verify_secure_dir(parent)
|
||
}
|
||
|
||
fn load_restore_token() -> Option<String> {
|
||
load_restore_token_from(token_path()?)
|
||
}
|
||
|
||
fn load_restore_token_from(path: PathBuf) -> Option<String> {
|
||
use std::os::unix::fs::{MetadataExt, PermissionsExt};
|
||
|
||
let meta = match std::fs::symlink_metadata(&path) {
|
||
Ok(m) => m,
|
||
Err(_) => return None,
|
||
};
|
||
|
||
if meta.file_type().is_symlink() {
|
||
tracing::warn!(
|
||
"Token file is a symlink, refusing to read: {}",
|
||
path.display()
|
||
);
|
||
return None;
|
||
}
|
||
if !meta.is_file() {
|
||
tracing::warn!("Token path is not a regular file: {}", path.display());
|
||
return None;
|
||
}
|
||
// SAFETY: libc::getuid has no preconditions and cannot fail.
|
||
if meta.uid() != unsafe { libc::getuid() } {
|
||
tracing::warn!("Token file not owned by current user: {}", path.display());
|
||
return None;
|
||
}
|
||
let mode = meta.permissions().mode() & 0o777;
|
||
if mode & 0o077 != 0 {
|
||
tracing::warn!(
|
||
"Token file has insecure permissions {:o}, refusing to read: {}",
|
||
mode,
|
||
path.display()
|
||
);
|
||
return None;
|
||
}
|
||
|
||
let token = std::fs::read_to_string(&path).ok()?;
|
||
let trimmed = token.trim().to_string();
|
||
if trimmed.is_empty() {
|
||
None
|
||
} else {
|
||
Some(trimmed)
|
||
}
|
||
}
|
||
|
||
fn save_restore_token(token: &str) {
|
||
let Some(path) = token_path() else {
|
||
tracing::warn!("No secure cache directory available, skipping token save");
|
||
return;
|
||
};
|
||
save_restore_token_to(token, &path);
|
||
}
|
||
|
||
fn delete_restore_token() {
|
||
let Some(path) = token_path() else {
|
||
return;
|
||
};
|
||
match std::fs::remove_file(&path) {
|
||
Ok(()) => tracing::info!("Deleted stale portal restore token at {}", path.display()),
|
||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||
Err(e) => tracing::warn!("Failed to delete stale restore token at {}: {e}", path.display()),
|
||
}
|
||
}
|
||
|
||
fn save_restore_token_to(token: &str, path: &std::path::Path) {
|
||
use std::fs::OpenOptions;
|
||
use std::io::Write;
|
||
use std::os::unix::fs::OpenOptionsExt;
|
||
|
||
let Some(parent) = path.parent() else {
|
||
tracing::warn!("Token path has no parent directory");
|
||
return;
|
||
};
|
||
|
||
if !ensure_secure_parent(parent) {
|
||
tracing::warn!("Parent directory is insecure, refusing to save token");
|
||
return;
|
||
}
|
||
|
||
// Use a unique temp file to prevent symlink attacks.
|
||
// create_new(true) guarantees exclusive creation — fails if file already exists,
|
||
// and does NOT follow existing symlinks.
|
||
let tmp_path = path.with_extension(format!("{}.tmp", std::process::id()));
|
||
let result = (|| -> std::io::Result<()> {
|
||
let mut f = OpenOptions::new()
|
||
.write(true)
|
||
.create_new(true)
|
||
.mode(0o600)
|
||
.open(&tmp_path)?;
|
||
f.write_all(token.as_bytes())?;
|
||
f.sync_all()?;
|
||
std::fs::rename(&tmp_path, path)?;
|
||
Ok(())
|
||
})();
|
||
match result {
|
||
Ok(()) => tracing::info!("Saved portal restore token"),
|
||
Err(e) => {
|
||
let _ = std::fs::remove_file(&tmp_path);
|
||
tracing::warn!("Failed to save restore token: {e}");
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Drop for CapPortal {
|
||
/// 析构时安全关闭 PipeWire 线程
|
||
///
|
||
/// 通过向 eventfd 写入值来唤醒 PipeWire 事件循环,触发其退出。
|
||
/// 然后等待 PipeWire 线程的 JoinHandle,确保线程完全退出后才返回。
|
||
/// 这种基于 eventfd 的关闭机制避免了以下竞态条件:
|
||
/// - 直接调用 mainloop.quit() 可能在 mainloop 已经销毁后触发(UAF)
|
||
/// - eventfd 回调在 mainloop.run() 的上下文中执行,保证 mainloop 存活
|
||
fn drop(&mut self) {
|
||
// Signal the PipeWire loop to quit via eventfd.
|
||
// eventfd write is a kernel syscall — thread-safe and lock-free.
|
||
let val: u64 = 1u64;
|
||
// SAFETY: shutdown_fd is a valid open eventfd (owned by Self); the buffer is
|
||
// a stack u64 of size 8 bytes which matches the count argument. POSIX write(2)
|
||
// is the standard fd-write syscall; eventfd writes must be exactly 8 bytes.
|
||
let _ = unsafe {
|
||
libc::write(
|
||
self.shutdown_fd.as_raw_fd(),
|
||
&val as *const u64 as *const _,
|
||
std::mem::size_of::<u64>(),
|
||
)
|
||
};
|
||
|
||
// 等待 PipeWire 线程完全退出
|
||
// 这确保 PipeWire 资源在线程中被正确清理后,主线程才继续
|
||
if let Some(handle) = self.pw_thread.take() {
|
||
let _ = handle.join();
|
||
}
|
||
}
|
||
}
|
||
|
||
/// PipeWire 捕获线程主函数
|
||
///
|
||
/// 在独立线程中运行 PipeWire 事件循环,接收来自 Portal 的屏幕捕获帧。
|
||
/// 整体流程:
|
||
/// 1. 初始化 PipeWire 库 (pw::init)
|
||
/// 2. 创建 MainLoop(事件循环)、Context、Core(连接)
|
||
/// 3. 使用 Portal 提供的 fd 和 node_id 创建并连接视频流
|
||
/// 4. 注册事件监听器(状态变化、格式协商、帧处理)
|
||
/// 5. 将 shutdown eventfd 注册到事件循环,实现安全退出
|
||
/// 6. 运行事件循环,直到收到关闭信号
|
||
/// 7. 清理资源,调用 pw::deinit()
|
||
///
|
||
/// 注意: 此函数使用 Rc<Cell<>> 而非 Arc<Mutex<>>,因为 PipeWire 的回调
|
||
/// 都在同一个线程中执行,无需跨线程同步。
|
||
fn pipewire_thread(ctx: PwThreadCtx) {
|
||
use pipewire as pw;
|
||
use pw::properties::properties;
|
||
use pw::spa::param::video::VideoInfoRaw;
|
||
use pw::stream::{StreamBox, StreamFlags};
|
||
use std::cell::Cell;
|
||
use std::rc::Rc;
|
||
|
||
// 初始化 PipeWire 进程全局库。
|
||
//
|
||
// pipewire-rs 内部使用 OnceCell 保护 pw::init(),确保只调用一次。
|
||
// pw::deinit() 是 unsafe 且要求"进程生命周期内仅调用一次,且所有
|
||
// PipeWire 使用已停止"。由于 CapPortal 可被多次创建销毁,此函数
|
||
// 不调用 pw::deinit()——进程退出时全局状态由 OS 回收。
|
||
pw::init();
|
||
|
||
let PwThreadCtx {
|
||
frame_tx,
|
||
event_tx,
|
||
dropped,
|
||
shutdown_read,
|
||
pw_fd,
|
||
node_id,
|
||
fps: _,
|
||
} = ctx;
|
||
|
||
let mainloop = match pw::main_loop::MainLoopBox::new(None) {
|
||
Ok(ml) => ml,
|
||
Err(e) => {
|
||
if let Err(e) =
|
||
event_tx.try_send(PwCtrlEvent::Error(format!("MainLoop::new failed: {e}")))
|
||
{
|
||
tracing::error!("MainLoop::new failed and error channel also failed: {e}");
|
||
}
|
||
return;
|
||
}
|
||
};
|
||
|
||
let context = match pw::context::ContextBox::new(mainloop.loop_(), None) {
|
||
Ok(c) => c,
|
||
Err(e) => {
|
||
if let Err(e) =
|
||
event_tx.try_send(PwCtrlEvent::Error(format!("Context::new failed: {e}")))
|
||
{
|
||
tracing::error!("Context::new failed and error channel also failed: {e}");
|
||
}
|
||
return;
|
||
}
|
||
};
|
||
|
||
let core = match context.connect_fd(pw_fd, None) {
|
||
Ok(c) => c,
|
||
Err(e) => {
|
||
if let Err(e) = event_tx.try_send(PwCtrlEvent::Error(format!("connect_fd failed: {e}")))
|
||
{
|
||
tracing::error!("connect_fd failed and error channel also failed: {e}");
|
||
}
|
||
return;
|
||
}
|
||
};
|
||
|
||
// 创建 PipeWire 视频流
|
||
// 属性配置:
|
||
// - MEDIA_TYPE = "Video": 媒体类型为视频
|
||
// - MEDIA_CATEGORY = "Capture": 类别为捕获(而非回放)
|
||
// - MEDIA_ROLE = "Screen": 角色为屏幕(用于策略管理)
|
||
let stream = match StreamBox::new(
|
||
&core,
|
||
"wl-webrtc",
|
||
properties! {
|
||
*pw::keys::MEDIA_TYPE => "Video",
|
||
*pw::keys::MEDIA_CATEGORY => "Capture",
|
||
*pw::keys::MEDIA_ROLE => "Screen",
|
||
*pw::keys::NODE_FORCE_QUANTUM => "512",
|
||
},
|
||
) {
|
||
Ok(s) => s,
|
||
Err(e) => {
|
||
if let Err(e) =
|
||
event_tx.try_send(PwCtrlEvent::Error(format!("Stream::new failed: {e}")))
|
||
{
|
||
tracing::error!("Stream::new failed and error channel also failed: {e}");
|
||
}
|
||
return;
|
||
}
|
||
};
|
||
|
||
let format_info: Rc<Cell<Option<(u32, u32, u32, u64)>>> = Rc::new(Cell::new(None));
|
||
|
||
let event_tx_state = event_tx.clone();
|
||
let _listener = stream
|
||
.add_local_listener::<()>()
|
||
.state_changed(move |_, _, old, new| {
|
||
tracing::info!("PipeWire stream state: {old:?} -> {new:?}");
|
||
match new {
|
||
pw::stream::StreamState::Error(e) => {
|
||
tracing::error!("PipeWire stream error: {e}");
|
||
let _ = event_tx_state.try_send(PwCtrlEvent::StreamEnded);
|
||
}
|
||
pw::stream::StreamState::Unconnected => {
|
||
let _ = event_tx_state.try_send(PwCtrlEvent::StreamEnded);
|
||
}
|
||
pw::stream::StreamState::Paused => {
|
||
tracing::warn!("PipeWire stream paused (compositor may be switching content)");
|
||
}
|
||
pw::stream::StreamState::Streaming => {
|
||
tracing::info!("PipeWire stream (re)started");
|
||
}
|
||
pw::stream::StreamState::Connecting => {}
|
||
}
|
||
})
|
||
// 参数变化回调(格式协商)
|
||
// PipeWire 在流格式协商完成后触发此回调
|
||
// id 为参数类型,param 包含具体的格式参数(分辨率、像素格式等)
|
||
.param_changed({
|
||
let format_info = format_info.clone();
|
||
let event_tx = event_tx.clone();
|
||
move |_, _, id, param| {
|
||
// 仅处理 Format 类型的参数变化
|
||
let Some(param) = param else { return };
|
||
if id != pw::spa::param::ParamType::Format.as_raw() {
|
||
return;
|
||
}
|
||
// 解析视频格式信息(分辨率、像素格式、修饰符等)
|
||
let mut info = VideoInfoRaw::new();
|
||
if let Err(e) = info.parse(param) {
|
||
tracing::warn!("Failed to parse video format: {e}");
|
||
return;
|
||
}
|
||
let width = info.size().width;
|
||
let height = info.size().height;
|
||
// 将 SPA 视频格式转换为 DRM FourCC 格式标识符
|
||
let drm_format = spa_to_drm_fourcc(info.format());
|
||
// 获取 DRM 修饰符,描述 GPU buffer 的内存布局(如 tiling 模式)
|
||
let modifier = info.modifier();
|
||
let framerate = info.framerate();
|
||
let max_framerate = info.max_framerate();
|
||
// 保存协商后的格式信息,供 process 回调读取
|
||
let previous_format = format_info.get();
|
||
format_info.set(Some((width, height, drm_format, modifier)));
|
||
if let Some((previous_width, previous_height, _, _)) = previous_format {
|
||
if width != previous_width || height != previous_height {
|
||
tracing::warn!(
|
||
"PipeWire dimensions changed: {}x{} (format renegotiation)",
|
||
width,
|
||
height
|
||
);
|
||
let _ = event_tx.try_send(PwCtrlEvent::FormatChanged { width, height });
|
||
}
|
||
}
|
||
tracing::info!(
|
||
"PipeWire format negotiated: {width}x{height}, \
|
||
drm_format={drm_format:#010x}, modifier={modifier:#x}, \
|
||
framerate={}/{}, max_framerate={}/{}",
|
||
framerate.num,
|
||
framerate.denom,
|
||
max_framerate.num,
|
||
max_framerate.denom,
|
||
);
|
||
}
|
||
})
|
||
// 帧处理回调 —— 这是核心的数据路径
|
||
// 每当 PipeWire 有新的帧数据可用时触发
|
||
// 关键操作: 从 buffer 中提取 DMA-BUF fd,dup 后通过 channel 发送给消费者
|
||
.process({
|
||
let format_info = format_info.clone();
|
||
let frame_tx = frame_tx.clone();
|
||
let dropped = dropped;
|
||
move |stream, _| {
|
||
// SAFETY: raw_buf ownership invariant — PipeWire's process callback
|
||
// contract requires that every buffer acquired via `dequeue_raw_buffer`
|
||
// is returned to the queue EXACTLY ONCE via `queue_raw_buffer` before
|
||
// the callback returns — on every exit path, success or error. Failure
|
||
// to requeue leaks the buffer slot and eventually stalls the stream.
|
||
//
|
||
// Audit map of this closure (verified 2026-06-28):
|
||
// - null raw_buf (dequeue returned NULL) → nothing to requeue, return.
|
||
// - null spa_buf / no data / bad fd / null chunk / no format_info /
|
||
// invalid dims / dup_fd < 0 → all requeue before early-return.
|
||
// - success (try_send Ok / Full / Disconnected) → final requeue at end.
|
||
// The fd ownership is independent: dup() creates a fresh fd that lives
|
||
// inside PwDmaBufFrame; on try_send error the frame Drops and closes it.
|
||
let raw_buf = unsafe { stream.dequeue_raw_buffer() };
|
||
if raw_buf.is_null() {
|
||
tracing::trace!("process: null raw_buf");
|
||
return;
|
||
}
|
||
|
||
// 获取 SPA buffer 结构体,包含数据数组、元数据等
|
||
// SAFETY: raw_buf was checked non-null above. `pw_buffer.buffer` is a
|
||
// valid raw pointer for the lifetime of raw_buf (PipeWire keeps the
|
||
// buffer alive until we queue it back).
|
||
let spa_buf = unsafe { (*raw_buf).buffer };
|
||
if spa_buf.is_null() {
|
||
tracing::trace!("process: null spa_buf");
|
||
// SAFETY: raw_buf is the non-null buffer we still own; returning it.
|
||
unsafe { stream.queue_raw_buffer(raw_buf) };
|
||
return;
|
||
}
|
||
|
||
// 获取 buffer 中的数据项数量和数据指针
|
||
// 对于 DMA-BUF 帧,通常只有 1 个数据项(包含 fd)
|
||
// SAFETY: spa_buf checked non-null above; `n_datas` is a plain u32 field.
|
||
let n_datas = unsafe { (*spa_buf).n_datas };
|
||
// SAFETY: same as above; `datas` is a raw pointer field, may be null.
|
||
let datas_ptr = unsafe { (*spa_buf).datas };
|
||
if n_datas == 0 || datas_ptr.is_null() {
|
||
tracing::trace!("process: no data (n_datas={n_datas})");
|
||
// SAFETY: raw_buf still owned, returning it.
|
||
unsafe { stream.queue_raw_buffer(raw_buf) };
|
||
return;
|
||
}
|
||
|
||
// 从第一个数据项中获取 DMA-BUF 文件描述符
|
||
// 通过 libspa 的 Data 包装类型安全地访问 SPA 数据结构
|
||
// SAFETY: datas_ptr is non-null and n_datas > 0 (checked above). We cast
|
||
// to pw::spa::buffer::Data and take a shared borrow; PipeWire does not
|
||
// mutate the data array during a process cycle, so a shared reference
|
||
// for the duration of this callback is sound.
|
||
let data_ref: &pw::spa::buffer::Data =
|
||
unsafe { &*(datas_ptr as *const pw::spa::buffer::Data) };
|
||
let fd = data_ref.fd();
|
||
if fd < 0 {
|
||
tracing::trace!("process: invalid fd={fd}");
|
||
// SAFETY: raw_buf still owned, returning it.
|
||
unsafe { stream.queue_raw_buffer(raw_buf) };
|
||
return;
|
||
}
|
||
|
||
if data_ref.as_raw().chunk.is_null() {
|
||
tracing::trace!("process: null chunk");
|
||
// SAFETY: raw_buf still owned, returning it.
|
||
unsafe { stream.queue_raw_buffer(raw_buf) };
|
||
return;
|
||
}
|
||
let chunk = data_ref.chunk();
|
||
let offset = chunk.offset() as u64;
|
||
let stride = chunk.stride() as u32;
|
||
|
||
// 从 SPA_META_Header 元数据中提取 PTS (显示时间戳)
|
||
// 遍历 buffer 的所有元数据项,查找 Header 类型的元数据
|
||
// PTS 可用于音视频同步和帧率控制
|
||
// SAFETY: spa_buf is non-null. `metas` is checked for null before
|
||
// iteration. We iterate `i in 0..n_metas` reading shared POD fields
|
||
// (type_, size, data) — PipeWire keeps the meta array immutable during
|
||
// a process cycle. The size guard (`meta.size >= size_of::<spa_meta_header>()`)
|
||
// and null-data check before reading ensure we never read past the
|
||
// meta's actual extent.
|
||
let pts: i64 = unsafe {
|
||
let mut pts_val: i64 = 0;
|
||
let n_metas = (*spa_buf).n_metas;
|
||
let metas = (*spa_buf).metas;
|
||
if !metas.is_null() {
|
||
for i in 0..n_metas {
|
||
let meta = &*metas.add(i as usize);
|
||
if meta.type_ == libspa::sys::SPA_META_Header
|
||
&& meta.size as usize
|
||
>= std::mem::size_of::<libspa::sys::spa_meta_header>()
|
||
&& !meta.data.is_null()
|
||
{
|
||
let header = &*(meta.data as *const libspa::sys::spa_meta_header);
|
||
pts_val = header.pts;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
pts_val
|
||
};
|
||
|
||
// 验证格式信息已协商完成,且分辨率和格式有效
|
||
let Some((width, height, format, modifier)) = format_info.get() else {
|
||
// SAFETY: raw_buf still owned, returning it.
|
||
unsafe { stream.queue_raw_buffer(raw_buf) };
|
||
return;
|
||
};
|
||
if width == 0 || height == 0 || format == 0 {
|
||
tracing::trace!("process: invalid dimensions {width}x{height} format={format}");
|
||
// SAFETY: raw_buf still owned, returning it.
|
||
unsafe { stream.queue_raw_buffer(raw_buf) };
|
||
return;
|
||
}
|
||
|
||
// 复制 DMA-BUF 文件描述符
|
||
// 必须 dup,因为原始 fd 由 PipeWire 管理,我们不能持有它
|
||
// dup 后的 fd 由 PwDmaBufFrame 持有,生命周期独立于 PipeWire buffer
|
||
// SAFETY: `fd` is the open DMA-BUF fd reported by PipeWire (>= 0 checked
|
||
// above). libc::dup is the standard POSIX fd duplication call. The
|
||
// original `fd` remains owned by PipeWire (returned with raw_buf later).
|
||
let dup_fd = unsafe { libc::dup(fd) };
|
||
if dup_fd < 0 {
|
||
// SAFETY: raw_buf still owned, returning it. No fd cleanup needed
|
||
// because dup() failed and never returned a new fd.
|
||
unsafe { stream.queue_raw_buffer(raw_buf) };
|
||
return;
|
||
}
|
||
|
||
// 构建帧数据对象,所有必要的帧信息已收集完毕
|
||
// SAFETY: `dup_fd` is a freshly-dup'd open file descriptor (>= 0 checked
|
||
// above) and we are its sole owner. OwnedFd::from_raw_fd takes ownership
|
||
// and will close() it on Drop. The fd's lifecycle is independent of
|
||
// raw_buf: whether try_send succeeds (frame moves into the channel) or
|
||
// fails (Full/Disconnected — the error payload owns the frame and drops
|
||
// it at the end of the match arm), exactly one close() occurs per dup().
|
||
let frame_fd = unsafe { OwnedFd::from_raw_fd(dup_fd) };
|
||
let frame = PwDmaBufFrame {
|
||
fd: frame_fd,
|
||
offset,
|
||
stride,
|
||
modifier,
|
||
width,
|
||
height,
|
||
format,
|
||
pts,
|
||
};
|
||
|
||
match frame_tx.try_send(frame) {
|
||
Ok(()) => {}
|
||
Err(crossbeam_channel::TrySendError::Full(_)) => {
|
||
dropped.fetch_add(1, Ordering::Relaxed);
|
||
}
|
||
Err(crossbeam_channel::TrySendError::Disconnected(_)) => {}
|
||
}
|
||
// SAFETY: final exactly-once requeue of raw_buf. Every path above
|
||
// either returned early with its own requeue, or falls through to here.
|
||
unsafe { stream.queue_raw_buffer(raw_buf) };
|
||
}
|
||
})
|
||
.register();
|
||
|
||
let mut params: [&pw::spa::pod::Pod; 0] = [];
|
||
|
||
if let Err(e) = stream.connect(
|
||
pw::spa::utils::Direction::Input,
|
||
Some(node_id),
|
||
StreamFlags::AUTOCONNECT | StreamFlags::MAP_BUFFERS,
|
||
&mut params,
|
||
) {
|
||
if let Err(e) = event_tx.try_send(PwCtrlEvent::Error(format!("stream.connect failed: {e}")))
|
||
{
|
||
tracing::error!("stream.connect failed and error channel also failed: {e}");
|
||
}
|
||
return;
|
||
}
|
||
|
||
let loop_ = mainloop.loop_();
|
||
|
||
// Register the shutdown eventfd on the PipeWire loop.
|
||
//
|
||
// When CapPortal::drop writes to the eventfd, the loop wakes up and
|
||
// dispatches this callback on the loop thread. Because the callback
|
||
// only fires while mainloop.run() is blocking this thread, mainloop
|
||
// is guaranteed alive — eliminating the UAF that existed with the
|
||
// previous detached helper thread approach.
|
||
// 保存 mainloop 的原始指针,用于在 shutdown 回调中调用 pw_main_loop_quit
|
||
// 这是安全的,因为回调只在 mainloop.run() 阻塞期间执行
|
||
let mainloop_ptr = mainloop.as_raw_ptr();
|
||
|
||
let _shutdown_source = loop_.add_io(
|
||
shutdown_read,
|
||
libspa::support::system::IoFlags::IN,
|
||
move |fd| {
|
||
// Drain the eventfd so it doesn't re-trigger
|
||
let mut buf: u64 = 0;
|
||
// SAFETY: `fd` is the registered eventfd owned by the mainloop source; the
|
||
// buffer is a stack u64 of 8 bytes matching the count argument. POSIX
|
||
// read(2) is the standard fd-read syscall; eventfd semantics require the
|
||
// 8-byte buffer.
|
||
let _ = unsafe {
|
||
libc::read(
|
||
fd.as_raw_fd(),
|
||
&mut buf as *mut u64 as *mut _,
|
||
std::mem::size_of::<u64>(),
|
||
)
|
||
};
|
||
// SAFETY: This callback only executes while mainloop.run() is
|
||
// blocking this thread, so mainloop is guaranteed alive.
|
||
unsafe { pipewire::sys::pw_main_loop_quit(mainloop_ptr) };
|
||
},
|
||
);
|
||
|
||
// 启动 PipeWire 主事件循环
|
||
// 此调用会阻塞当前线程,直到 mainloop.quit() 被调用
|
||
// quit() 由 shutdown eventfd 的 IO 回调触发
|
||
mainloop.run();
|
||
|
||
// run() returned — _shutdown_source drops first (reverse declaration order),
|
||
// which unregisters the callback from the loop. Then mainloop drops.
|
||
// No dangling raw pointers are possible.
|
||
// PipeWire global state is intentionally not deinitialized here — see pw::init() comment above.
|
||
}
|
||
|
||
/// 将四个 ASCII 字符编码为 32 位 FourCC (Four Character Code) 标识符
|
||
///
|
||
/// FourCC 是多媒体领域中广泛使用的像素格式标识方式。
|
||
/// 编码规则: 第一个字符在最低 8 位,依次向高位排列。
|
||
/// 例如: "BGRA" → 0x41524742 (小端序存储为 'B','G','R','A')
|
||
const fn fourcc(a: u8, b: u8, c: u8, d: u8) -> u32 {
|
||
(a as u32) | ((b as u32) << 8) | ((c as u32) << 16) | ((d as u32) << 24)
|
||
}
|
||
|
||
/// 将 PipeWire SPA 视频格式转换为 DRM FourCC 格式
|
||
///
|
||
/// PipeWire 使用自己的 VideoFormat 枚举,而 DRM/KMS 使用 FourCC 格式标识。
|
||
/// 此函数建立了两者之间的映射关系。
|
||
///
|
||
/// 支持的格式:
|
||
/// 不支持的格式返回 0
|
||
/// DRM 格式名描述像素值位布局(大端序),而非内存字节序。
|
||
/// 例如 DRM_FORMAT_ARGB8888 在小端 x86 上内存为 [B,G,R,A] = PipeWire BGRA。
|
||
fn spa_to_drm_fourcc(format: libspa::param::video::VideoFormat) -> u32 {
|
||
use drm_fourcc::DrmFourcc;
|
||
use libspa::param::video::VideoFormat;
|
||
match format {
|
||
VideoFormat::BGRA => DrmFourcc::Argb8888 as u32,
|
||
VideoFormat::BGRx => DrmFourcc::Xrgb8888 as u32,
|
||
VideoFormat::RGBA => DrmFourcc::Abgr8888 as u32,
|
||
VideoFormat::RGBx => DrmFourcc::Xbgr8888 as u32,
|
||
VideoFormat::ARGB => DrmFourcc::Bgra8888 as u32,
|
||
VideoFormat::xRGB => DrmFourcc::Bgrx8888 as u32,
|
||
VideoFormat::ABGR => DrmFourcc::Rgba8888 as u32,
|
||
VideoFormat::xBGR => DrmFourcc::Rgbx8888 as u32,
|
||
_ => 0,
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use drm_fourcc::DrmFourcc;
|
||
use std::os::unix::fs::PermissionsExt;
|
||
|
||
#[test]
|
||
fn spa_to_drm_fourcc_all_32bit() {
|
||
use libspa::param::video::VideoFormat;
|
||
assert_eq!(
|
||
spa_to_drm_fourcc(VideoFormat::BGRA),
|
||
DrmFourcc::Argb8888 as u32
|
||
);
|
||
assert_eq!(
|
||
spa_to_drm_fourcc(VideoFormat::BGRx),
|
||
DrmFourcc::Xrgb8888 as u32
|
||
);
|
||
assert_eq!(
|
||
spa_to_drm_fourcc(VideoFormat::RGBA),
|
||
DrmFourcc::Abgr8888 as u32
|
||
);
|
||
assert_eq!(
|
||
spa_to_drm_fourcc(VideoFormat::RGBx),
|
||
DrmFourcc::Xbgr8888 as u32
|
||
);
|
||
assert_eq!(
|
||
spa_to_drm_fourcc(VideoFormat::ARGB),
|
||
DrmFourcc::Bgra8888 as u32
|
||
);
|
||
assert_eq!(
|
||
spa_to_drm_fourcc(VideoFormat::xRGB),
|
||
DrmFourcc::Bgrx8888 as u32
|
||
);
|
||
assert_eq!(
|
||
spa_to_drm_fourcc(VideoFormat::ABGR),
|
||
DrmFourcc::Rgba8888 as u32
|
||
);
|
||
assert_eq!(
|
||
spa_to_drm_fourcc(VideoFormat::xBGR),
|
||
DrmFourcc::Rgbx8888 as u32
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn spa_to_drm_fourcc_unsupported() {
|
||
use libspa::param::video::VideoFormat;
|
||
assert_eq!(spa_to_drm_fourcc(VideoFormat::NV12), 0);
|
||
}
|
||
|
||
#[test]
|
||
fn token_path_never_uses_tmp() {
|
||
assert!(token_path().is_some(), "token_path should resolve on Linux");
|
||
let path = token_path().unwrap();
|
||
assert!(!path.starts_with("/tmp"), "must not fallback to /tmp");
|
||
}
|
||
|
||
#[test]
|
||
fn verify_secure_dir_rejects_wrong_permissions() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let path = dir.path();
|
||
|
||
// 0o700 should pass
|
||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||
assert!(verify_secure_dir(path));
|
||
|
||
// 0o755 should fail
|
||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||
assert!(!verify_secure_dir(path));
|
||
|
||
// 0o777 should fail
|
||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o777)).unwrap();
|
||
assert!(!verify_secure_dir(path));
|
||
}
|
||
|
||
#[test]
|
||
fn verify_secure_dir_rejects_non_directory() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let file_path = dir.path().join("not-a-dir");
|
||
std::fs::write(&file_path, b"test").unwrap();
|
||
assert!(!verify_secure_dir(&file_path));
|
||
}
|
||
|
||
#[test]
|
||
fn ensure_secure_parent_creates_with_0700() {
|
||
let base = tempfile::tempdir().unwrap();
|
||
let new_dir = base.path().join("wl-test-new-dir");
|
||
assert!(!new_dir.exists());
|
||
|
||
assert!(ensure_secure_parent(&new_dir));
|
||
assert!(new_dir.is_dir());
|
||
|
||
let meta = std::fs::symlink_metadata(&new_dir).unwrap();
|
||
let mode = meta.permissions().mode() & 0o777;
|
||
assert_eq!(
|
||
mode, 0o700,
|
||
"created directory should be 0700, got {mode:o}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn ensure_secure_parent_tightens_existing_dir() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let path = dir.path();
|
||
|
||
// Simulate an existing directory with loose permissions
|
||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||
|
||
assert!(ensure_secure_parent(path));
|
||
|
||
let meta = std::fs::symlink_metadata(path).unwrap();
|
||
let mode = meta.permissions().mode() & 0o777;
|
||
assert_eq!(
|
||
mode, 0o700,
|
||
"tightened directory should be 0700, got {mode:o}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn save_creates_file_with_0600() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let token_path = dir.path().join("portal-restore-token");
|
||
|
||
save_restore_token_to("secret-token-123", &token_path);
|
||
|
||
assert!(token_path.exists());
|
||
let meta = std::fs::symlink_metadata(&token_path).unwrap();
|
||
let mode = meta.permissions().mode() & 0o777;
|
||
assert_eq!(mode, 0o600, "token file should be 0600, got {mode:o}");
|
||
assert_eq!(
|
||
std::fs::read_to_string(&token_path).unwrap(),
|
||
"secret-token-123"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn load_reads_secure_file() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let token_path = dir.path().join("portal-restore-token");
|
||
|
||
// Write a valid 0o600 token file
|
||
use std::os::unix::fs::OpenOptionsExt;
|
||
let mut f = std::fs::OpenOptions::new()
|
||
.write(true)
|
||
.create_new(true)
|
||
.mode(0o600)
|
||
.open(&token_path)
|
||
.unwrap();
|
||
std::io::Write::write_all(&mut f, b"my-secret\n").unwrap();
|
||
|
||
let result = load_restore_token_from(token_path);
|
||
assert_eq!(result, Some("my-secret".to_string()));
|
||
}
|
||
|
||
#[test]
|
||
fn load_rejects_group_readable_file() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let token_path = dir.path().join("portal-restore-token");
|
||
|
||
// Write with 0o640 (group readable) — should be rejected
|
||
use std::os::unix::fs::OpenOptionsExt;
|
||
let mut f = std::fs::OpenOptions::new()
|
||
.write(true)
|
||
.create_new(true)
|
||
.mode(0o640)
|
||
.open(&token_path)
|
||
.unwrap();
|
||
std::io::Write::write_all(&mut f, b"leaked-token\n").unwrap();
|
||
|
||
let result = load_restore_token_from(token_path);
|
||
assert!(result.is_none(), "should reject group-readable token file");
|
||
}
|
||
|
||
#[test]
|
||
fn load_rejects_world_readable_file() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let token_path = dir.path().join("portal-restore-token");
|
||
|
||
use std::os::unix::fs::OpenOptionsExt;
|
||
let mut f = std::fs::OpenOptions::new()
|
||
.write(true)
|
||
.create_new(true)
|
||
.mode(0o604)
|
||
.open(&token_path)
|
||
.unwrap();
|
||
std::io::Write::write_all(&mut f, b"leaked-token\n").unwrap();
|
||
|
||
let result = load_restore_token_from(token_path);
|
||
assert!(result.is_none(), "should reject world-readable token file");
|
||
}
|
||
|
||
#[test]
|
||
fn load_rejects_symlink() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let real_path = dir.path().join("real-file");
|
||
let link_path = dir.path().join("portal-restore-token");
|
||
|
||
std::fs::write(&real_path, b"target-content\n").unwrap();
|
||
std::os::unix::fs::symlink(&real_path, &link_path).unwrap();
|
||
|
||
let result = load_restore_token_from(link_path);
|
||
assert!(result.is_none(), "should reject symlinked token file");
|
||
}
|
||
|
||
#[test]
|
||
fn save_then_load_roundtrip() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let token_path = dir.path().join("portal-restore-token");
|
||
|
||
save_restore_token_to("roundtrip-token", &token_path);
|
||
let loaded = load_restore_token_from(token_path);
|
||
|
||
assert_eq!(loaded, Some("roundtrip-token".to_string()));
|
||
}
|
||
}
|