Files
rustdesk/libs/scrap/src/common/drmtap_dl.rs
RustDesk 9a81c8a138 Drm deb in release workflow (#15776)
* docs(agents): add a comment-length rule

Comments were growing to document rejected alternatives, past bugs and
measurements. That belongs in the commit message, not the source.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ci(drm): build the unattended-wayland deb in the release workflow

The deb was built by a separate drm-capture workflow on a plain runner,
so it diverged from every other Linux deb: different base, different
vcpkg/ffmpeg, different toolchain. Move it into flutter-build.yml as
build-rustdesk-linux-drm, mirroring build-rustdesk-linux's x86_64 path --
same ubuntu18.04 container, same vcpkg install, same rust and flutter.
libdrmtap is built on the runner first and handed to the container via
DRMTAP_PREBUILT_DIR, because bionic's meson is too old to build it.

The job is ungated, so the --drm packaging path is exercised on every PR;
only publishing stays gated on upload-artifact. drm-capture.yml is
deleted along with docs/DRM_CAPTURE_SECURITY.md -- the 29 drm unit tests
that workflow ran are no longer executed by CI.

Three bugs the move exposed:

- build.py anchored the libdrmtap paths on abspath(__file__), which is
  only cwd-independent on Python >= 3.9 (bpo-20443). The packaging
  container runs 3.6 and chdir's into flutter/, so the ABI-gate
  cross-check resolved one directory off and every --drm packaging run
  would have died with FileNotFoundError. Captured as REPO_ROOT at
  import instead.
- DRMTAP_PREBUILT_DIR no longer needs DRMTAP_ALLOW_UNPINNED. A prebuilt
  dir inside the repo's own third_party/libdrmtap at the pinned sha is
  the pinned object, not an override, and is now verified as such.
- The variant's Depends carried a bare libdrm2. libdrmtap needs
  drmModeGetFB2, so it is libdrm2 (>= 2.4.95); below that the package
  installed and could never capture.

The loader also logs the dlerror now instead of discarding it, so a
soname or glibc mismatch is named rather than surfacing as a generic
"libdrmtap not available".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(drm): declare the unattended-wayland deb's real libc6 and libdrm floors

libdrmtap is built on the ubuntu-22.04 runner while the rest of the deb comes
from the ubuntu18.04 container, so the package has a mixed glibc floor and
declared neither half. It installed happily on Ubuntu 20.04 / Debian 11
(glibc 2.31), then dlopen failed on GLIBC_2.34 and capture degraded to the
PipeWire portal -- the one thing this variant exists to avoid. Measure the
floor off the staged objects and put it in Depends, so apt refuses with a
reason instead of handing over a package that can never capture.

Measured rather than written down: the number moves whenever either base does,
and it lands exactly on RHEL/Rocky 9 (glibc 2.34), where one off-by-one decides
whether that whole family can install.

drmModeGetFB2 landed in libdrm 2.4.101, not 2.4.95 -- checked against the
libdrm tags, xf86drmMode.h first declares it in 2.4.101. The old floor admitted
Debian 10 (2.4.97), where the .so is linked -z now and dies on an undefined
symbol at dlopen. libdrmtap's own meson.build carries the same wrong number.

Upload the deb on always(): the run that fails the drm check is the one whose
artifact is most worth downloading. Publish stays gated on success, so an
unverified build still cannot reach a release.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 08:31:09 +08:00

422 lines
18 KiB
Rust

// Runtime loader for libdrmtap.so (the DRM/KMS capture engine), dlopen'd so the binary carries no hard libdrm/libEGL/libGLESv2 dependency.
use hbb_common::{libloading::Library, log};
use std::os::raw::{c_char, c_int, c_void};
use std::sync::OnceLock;
// C ABI structs: must match libdrmtap include/drmtap.h.
#[repr(C)]
pub struct drmtap_ctx {
_private: [u8; 0],
}
#[repr(C)]
pub struct drmtap_config {
pub device_path: *const c_char, // NULL = auto-detect /dev/dri/card*
pub crtc_id: u32, // 0 = auto-select first active CRTC
pub helper_path: *const c_char, // only consulted if the direct DRM export is denied (no CAP_SYS_ADMIN)
pub debug: c_int,
}
impl Default for drmtap_config {
fn default() -> Self {
Self {
device_path: std::ptr::null(),
crtc_id: 0,
helper_path: std::ptr::null(),
debug: 0,
}
}
}
#[repr(C)]
#[derive(Clone, Copy)]
pub struct drmtap_display {
pub crtc_id: u32,
pub connector_id: u32,
pub name: [c_char; 32],
pub x: u32,
pub y: u32,
pub width: u32,
pub height: u32,
pub refresh_hz: u32,
pub active: c_int,
}
#[repr(C)]
#[derive(Clone, Copy)]
pub struct drmtap_device {
pub path: [c_char; 64],
pub render_node: [c_char; 64],
pub driver: [c_char; 32],
pub display_count: u32,
}
#[repr(C)]
pub struct drmtap_frame_info {
pub data: *mut c_void,
pub dma_buf_fd: c_int,
pub width: u32,
pub height: u32,
pub stride: u32,
pub format: u32,
pub modifier: u64,
pub fb_id: u32,
pub _priv: *mut c_void,
}
// Descriptor of an externally-supplied scanout DMA-BUF: the privileged exporter fills it via
// `drmtap_grab_desc`; the converter overwrites `dma_buf_fd` with the fd it got via SCM_RIGHTS.
// Mirrors `drmtap_dmabuf_desc` EXACTLY (field order + widths); a mismatch mis-reads CCS/HDR scanouts.
#[repr(C)]
#[derive(Clone, Copy)]
pub struct drmtap_dmabuf_desc {
pub dma_buf_fd: c_int, // scanout DMA-BUF; -1 for an already-imported fb_id
pub width: u32,
pub height: u32,
pub format: u32, // DRM fourcc of the scanout
pub modifier: u64, // DRM format modifier (tiling/compression)
pub fb_id: u32, // import-once cache key; 0 disables caching
pub num_planes: u32, // used entries in offsets/pitches (1..4); 0 => 1
pub offsets: [u32; 4], // per-plane byte offsets (CCS main+aux+clear-color)
pub pitches: [u32; 4], // per-plane strides; pitches[0] = main stride
pub hdr_eotf: u32, // DRMTAP_EOTF_* (SDR=0, PQ=2, HLG=3)
pub hdr_max_nits: u32, // mastering/content peak luminance cd/m2; 0=unknown
}
impl Default for drmtap_dmabuf_desc {
fn default() -> Self {
Self {
dma_buf_fd: -1,
width: 0,
height: 0,
format: 0,
modifier: 0,
fb_id: 0,
num_planes: 0,
offsets: [0; 4],
pitches: [0; 4],
hdr_eotf: 0,
hdr_max_nits: 0,
}
}
}
#[repr(C)]
pub struct drmtap_cursor_info {
pub x: i32,
pub y: i32,
pub hot_x: i32,
pub hot_y: i32,
pub width: u32,
pub height: u32,
pub pixels: *mut u32,
pub visible: c_int,
pub _priv: *mut c_void,
}
// Resolved symbol typedefs.
type FnVersion = unsafe extern "C" fn() -> c_int;
type FnOpen = unsafe extern "C" fn(*const drmtap_config) -> *mut drmtap_ctx;
type FnClose = unsafe extern "C" fn(*mut drmtap_ctx);
type FnListDisplays = unsafe extern "C" fn(*mut drmtap_ctx, *mut drmtap_display, c_int) -> c_int;
type FnListDevices = unsafe extern "C" fn(*mut drmtap_device, c_int) -> c_int;
type FnGrabMapped = unsafe extern "C" fn(*mut drmtap_ctx, *mut drmtap_frame_info) -> c_int;
type FnFrameRelease = unsafe extern "C" fn(*mut drmtap_ctx, *mut drmtap_frame_info);
type FnGetCursor = unsafe extern "C" fn(*mut drmtap_ctx, *mut drmtap_cursor_info) -> c_int;
type FnCursorRelease = unsafe extern "C" fn(*mut drmtap_ctx, *mut drmtap_cursor_info);
// Split-capture entry points (libdrmtap >= 0.4.10), required: `grab_desc` runs on the privileged
// export side, `open_render`/`convert_dmabuf` on the unprivileged converter side.
type FnGrabDesc =
unsafe extern "C" fn(*mut drmtap_ctx, *mut drmtap_dmabuf_desc, *mut drmtap_frame_info) -> c_int;
type FnOpenRender = unsafe extern "C" fn(*const c_char) -> *mut drmtap_ctx;
// libdrmtap >= 0.4.15; returns a ctx-owned string, or NULL if it has none.
type FnRenderNode = unsafe extern "C" fn(*mut drmtap_ctx) -> *const c_char;
type FnConvertDmabuf =
unsafe extern "C" fn(*mut drmtap_ctx, *const drmtap_dmabuf_desc, *mut drmtap_frame_info) -> c_int;
/// The dlopen'd libdrmtap; the `Library` is kept alive for the process lifetime, so the raw fn pointers stay valid.
pub struct DrmtapLib {
_lib: Library,
pub open: FnOpen,
pub close: FnClose,
pub list_displays: FnListDisplays,
pub list_devices: Option<FnListDevices>,
pub grab_mapped: FnGrabMapped,
pub frame_release: FnFrameRelease,
pub get_cursor: FnGetCursor,
pub cursor_release: FnCursorRelease,
pub grab_desc: FnGrabDesc,
pub open_render: FnOpenRender,
pub convert_dmabuf: FnConvertDmabuf,
pub render_node: Option<FnRenderNode>,
pub version: (c_int, c_int, c_int),
}
// SAFETY: the resolved fn pointers are plain C entry points with no interior mutability;
// libdrmtap contexts are used single-threaded by the caller. The Library handle is never moved out.
unsafe impl Send for DrmtapLib {}
unsafe impl Sync for DrmtapLib {}
const DRMTAP_ABI_MAJOR: c_int = 0;
// Lowest (minor, patch) accepted. 0.5.0 is the floor because it fixes the padded-framebuffer read
// (a scanout whose pitch exceeds width*bpp was decoded at the wrong stride); the whole split API
// has been present since 0.4.10.
const DRMTAP_MIN_MINOR_PATCH: (c_int, c_int) = (5, 0);
// The MINOR series this build's mirrored structs were verified against: libdrmtap's header freezes
// only `drmtap_device` and `drmtap_dmabuf_desc`, so an unverified minor could be read at wrong offsets.
const DRMTAP_ABI_MINOR: c_int = 5;
/// Whether a library reporting `major.minor.patch` may be loaded (major and minor exact, patch at or above the floor).
fn abi_accepted(major: c_int, minor: c_int, patch: c_int) -> bool {
major == DRMTAP_ABI_MAJOR
&& minor == DRMTAP_ABI_MINOR
&& (minor, patch) >= DRMTAP_MIN_MINOR_PATCH
}
impl DrmtapLib {
fn load() -> Option<Self> {
// Absolute path FIRST: the deb bundles the .so privately under /usr/lib/rustdesk and does NOT register that dir with ld.so.
const INSTALLED: &str = "/usr/lib/rustdesk/libdrmtap.so.0";
// Bare sonames exist so an unpackaged development build can load a locally built .so from
// the normal ld.so search path. They are NOT offered when running as root: this is the one
// place where which file happens to be on the load path decides what gets mapped into the
// CAP_SYS_ADMIN process, and the packaged service always finds the absolute path first
// anyway. A root process that reaches the fallback has no bundled library, which is the
// PipeWire-fallback case, not a reason to search.
const DEV_ONLY: [&str; 2] = ["libdrmtap.so.0", "libdrmtap.so"];
let is_root = unsafe { hbb_common::libc::geteuid() } == 0;
let candidates: Vec<&str> = if is_root {
vec![INSTALLED]
} else {
std::iter::once(INSTALLED).chain(DEV_ONLY).collect()
};
unsafe {
let mut errs = Vec::new();
let found = candidates.iter().find_map(|n| match Library::new(*n) {
Ok(l) => Some((l, *n)),
Err(e) => {
errs.push(format!("{n}: {e}"));
None
}
});
let Some((lib, name)) = found else {
// The dlerror names the real cause (a missing soname, a glibc too old for the
// bundled build); the caller only reports that DRM capture is off.
log::warn!("libdrmtap dlopen failed: {}", errs.join("; "));
return None;
};
// Canonicalize the absolute candidate only: `dlopen` does not search the CWD for a bare
// soname, while `canonicalize` resolves a relative name against it.
let real = std::path::Path::new(name)
.is_absolute()
.then(|| std::fs::canonicalize(name).ok())
.flatten();
let version: FnVersion = *lib.get(b"drmtap_version").ok()?;
let v = version();
let (major, minor, patch) = ((v >> 16) & 0xff, (v >> 8) & 0xff, v & 0xff);
if !abi_accepted(major, minor, patch) {
let why = if major != DRMTAP_ABI_MAJOR {
"the struct layouts this build mirrors track the ABI major, so reading a \
frame descriptor through a mismatched one would mis-decode it"
} else if minor != DRMTAP_ABI_MINOR {
"this build mirrors the struct layouts of one minor and only that one; \
under 0.x semver the minor is the breaking axis, so an unverified minor \
could be read at the wrong offsets. Widening it is a deliberate act, done \
with the layouts re-checked field by field"
} else {
"it predates the split-capture API, so its only capture path converts \
in-process, which in the root service means loading the GL stack there"
};
let (min_minor, min_patch) = DRMTAP_MIN_MINOR_PATCH;
log::warn!(
"libdrmtap {name} reports v{major}.{minor}.{patch}, which this build cannot \
use (needs ABI major {DRMTAP_ABI_MAJOR}, minor {DRMTAP_ABI_MINOR}, at least \
v{DRMTAP_ABI_MAJOR}.{min_minor}.{min_patch}): {why}. Refusing to load; \
falling back to PipeWire/portal."
);
return None;
}
let open: FnOpen = *lib.get(b"drmtap_open").ok()?;
let close: FnClose = *lib.get(b"drmtap_close").ok()?;
let list_displays: FnListDisplays = *lib.get(b"drmtap_list_displays").ok()?;
let list_devices: Option<FnListDevices> =
lib.get(b"drmtap_list_devices").ok().map(|s| *s);
let grab_mapped: FnGrabMapped = *lib.get(b"drmtap_grab_mapped").ok()?;
let frame_release: FnFrameRelease = *lib.get(b"drmtap_frame_release").ok()?;
let get_cursor: FnGetCursor = *lib.get(b"drmtap_get_cursor").ok()?;
let cursor_release: FnCursorRelease = *lib.get(b"drmtap_cursor_release").ok()?;
let grab: Option<FnGrabDesc> = lib.get(b"drmtap_grab_desc").ok().map(|s| *s);
let open_r: Option<FnOpenRender> = lib.get(b"drmtap_open_render").ok().map(|s| *s);
let conv: Option<FnConvertDmabuf> =
lib.get(b"drmtap_convert_dmabuf").ok().map(|s| *s);
let (grab_desc, open_render, convert_dmabuf) = match (grab, open_r, conv) {
(Some(g), Some(o), Some(c)) => (g, o, c),
(grab, open_r, conv) => {
let mut missing = Vec::new();
if grab.is_none() {
missing.push("drmtap_grab_desc");
}
if open_r.is_none() {
missing.push("drmtap_open_render");
}
if conv.is_none() {
missing.push("drmtap_convert_dmabuf");
}
log::warn!(
"libdrmtap {name} reports v{major}.{minor}.{patch} but does not export \
{}: it is a stale or pre-release build, not the version it claims. \
Refusing to load; falling back to PipeWire/portal.",
missing.join(", ")
);
return None;
}
};
let render_node: Option<FnRenderNode> =
lib.get(b"drmtap_render_node").ok().map(|s| *s);
// Log the load only now that every required symbol resolved: this fn still returns None on a missing one.
let loaded_from = real
.as_ref()
.map_or_else(|| name.to_owned(), |p| p.display().to_string());
if loaded_from == name {
log::info!("libdrmtap loaded: {name} (v{major}.{minor}.{patch})");
} else {
log::info!("libdrmtap loaded: {name} -> {loaded_from} (v{major}.{minor}.{patch})");
}
let (no_node, no_devices) = (render_node.is_none(), list_devices.is_none());
if (minor, patch) >= (4, 15) && (no_node || no_devices) {
let missing = if no_node && no_devices {
"drmtap_render_node and drmtap_list_devices"
} else if no_node {
"drmtap_render_node"
} else {
"drmtap_list_devices"
};
let effect = if no_node && no_devices {
"Multi-GPU display enumeration and exporting-GPU selection stay disabled."
} else if no_node {
"Exporting-GPU selection stays disabled."
} else {
"Multi-GPU display enumeration stays disabled."
};
log::warn!(
"libdrmtap at {loaded_from} reports v{major}.{minor}.{patch} but is missing \
{missing}: it is a stale or pre-release build. Check what the soname symlink \
points at and remove any leftover libdrmtap.so.0* beside it. {effect}"
);
}
Some(DrmtapLib {
_lib: lib,
open,
close,
list_displays,
list_devices,
grab_mapped,
frame_release,
get_cursor,
cursor_release,
grab_desc,
open_render,
convert_dmabuf,
render_node,
version: (major, minor, patch),
})
}
}
}
static DRMTAP_LIB: OnceLock<Option<DrmtapLib>> = OnceLock::new();
/// The loaded libdrmtap, or None if the .so (or a runtime dep) is absent or its version/exports fall outside the ABI gate. Loaded once; a failure is remembered.
pub fn get() -> Option<&'static DrmtapLib> {
DRMTAP_LIB
.get_or_init(|| {
let lib = DrmtapLib::load();
if lib.is_none() {
log::info!("libdrmtap not available or not usable; DRM capture disabled");
}
lib
})
.as_ref()
}
#[cfg(test)]
mod tests {
use super::{abi_accepted, DRMTAP_ABI_MAJOR, DRMTAP_ABI_MINOR, DRMTAP_MIN_MINOR_PATCH};
#[test]
fn abi_gate_rejects_a_library_from_before_the_split() {
// These are refused because their MINOR differs from the verified one, which is the only
// reason the gate needs. Naming the pre-split releases keeps the intent readable, but do
// not read this as the floor doing the work: see the test below.
for (minor, patch) in [(3, 3), (4, 0), (4, 8), (4, 9)] {
assert!(
!abi_accepted(DRMTAP_ABI_MAJOR, minor, patch),
"v0.{minor}.{patch} is not the verified minor and must be refused"
);
}
}
#[test]
fn the_patch_floor_is_currently_vacuous_and_that_is_deliberate() {
// With MIN_MINOR_PATCH.0 == DRMTAP_ABI_MINOR the floor can never reject anything: the
// minor equality already forces `(minor, patch) >= (minor, 0)`. It is kept because it is
// the mechanism that WOULD do the work the next time a floor lands mid-minor, as (4, 10)
// did for the split API. This test exists so nobody reads the pre-split test above as
// evidence that the floor is live -- if that ever matters, this assert is the tripwire.
let (floor_minor, floor_patch) = DRMTAP_MIN_MINOR_PATCH;
assert_eq!(
floor_minor, DRMTAP_ABI_MINOR,
"the floor is inside the verified minor; a floor in a DIFFERENT minor is unreachable"
);
if floor_patch == 0 {
assert!(
abi_accepted(DRMTAP_ABI_MAJOR, DRMTAP_ABI_MINOR, 0),
"patch 0 of the verified minor must be accepted while the floor is 0"
);
} else {
assert!(!abi_accepted(DRMTAP_ABI_MAJOR, DRMTAP_ABI_MINOR, floor_patch - 1));
}
}
#[test]
fn abi_gate_accepts_the_floor_and_later_patches_of_the_same_minor() {
let (min_minor, min_patch) = DRMTAP_MIN_MINOR_PATCH;
assert!(abi_accepted(DRMTAP_ABI_MAJOR, min_minor, min_patch));
for (minor, patch) in [(DRMTAP_ABI_MINOR, min_patch + 15), (DRMTAP_ABI_MINOR, 200)] {
assert!(
abi_accepted(DRMTAP_ABI_MAJOR, minor, patch),
"v0.{minor}.{patch} is a patch of the verified minor and must be accepted"
);
}
}
#[test]
fn abi_gate_rejects_an_unknown_newer_minor() {
// Relative to DRMTAP_ABI_MINOR, so the next bump cannot leave this test asserting that the
// NEW verified minor must be refused -- which is what a hardcoded list did before.
let verified = DRMTAP_ABI_MINOR;
for (minor, patch) in [
(verified - 1, 99),
(verified + 1, 0),
(verified + 1, 99),
(verified + 4, 9),
] {
assert!(
!abi_accepted(DRMTAP_ABI_MAJOR, minor, patch),
"v0.{minor}.{patch} is an unverified minor and must be refused"
);
}
}
#[test]
fn abi_gate_rejects_another_major_in_both_directions() {
assert!(!abi_accepted(DRMTAP_ABI_MAJOR + 1, 0, 0));
assert!(!abi_accepted(DRMTAP_ABI_MAJOR + 1, 99, 99));
}
}