diff --git a/electron/native/pipewire-capture/csrc/pw_shim.c b/electron/native/pipewire-capture/csrc/pw_shim.c index 82a8a129e..047eaee69 100644 --- a/electron/native/pipewire-capture/csrc/pw_shim.c +++ b/electron/native/pipewire-capture/csrc/pw_shim.c @@ -31,6 +31,7 @@ #include #include #include +#include #include #include #include @@ -85,6 +86,8 @@ static int osc_debug_enabled(void); * rather than trusting that. */ #define OSC_MAX_DMABUF_MAPS 32 +#define OSC_FRAME_DROP_REPORTS 5 +#define OSC_VIDEO_BYTES_PER_PIXEL 4 struct osc_dmabuf_map { int fd; @@ -183,6 +186,7 @@ struct osc_pw_session { * decides whether buffers arrive as dmabuf fds or shared memory. */ int uses_dmabuf; struct osc_dmabuf_map dmabuf_maps[OSC_MAX_DMABUF_MAPS]; + int frame_drop_reports; /* fd whose DMA_BUF_SYNC_START has not been closed by its END yet, or -1. * The bracket has to span the on_frame callback, not just osc_read_frame, * because the callback is where the pixels are actually read. */ @@ -651,6 +655,7 @@ static void osc_on_param_changed(void *userdata, uint32_t id, const struct spa_p * with them. Re-arm the reports so the instrumentation describes the buffer * set actually in use rather than a set that no longer exists. */ session->buffer_info_reports = 0; + session->frame_drop_reports = 0; api.stream_update_params(session->stream, params, SPA_N_ELEMENTS(params)); } @@ -702,19 +707,21 @@ static void *osc_map_dmabuf(int fd, size_t *len, const char **why) return NULL; } /* - * A DmaBuf plane legitimately carries maxsize = 0: the size of a dmabuf is a - * property of the exporting buffer, not of the SPA descriptor, and wlroots - * leaves it unset. Every dmabuf fd is seekable to its own length, which is - * the documented way to recover it. Without this the mmap was never even - * attempted and the failure was reported as "this driver does not allow CPU - * mapping" — blaming the GPU for a size the producer simply had not filled in. + * A DmaBuf plane can carry an advisory maxsize rather than its allocation + * length. The allocation size is a property of the exporting fd, and every + * dmabuf fd is seekable to that length. Probe unconditionally and prefer a + * larger real allocation, while retaining a meaningful producer bound when + * the fd cannot report one. */ - if (*len == 0) { + { off_t probed = lseek(fd, 0, SEEK_END); - if (probed > 0) { + if (probed > 0 && (uintmax_t)probed <= SIZE_MAX && (size_t)probed > *len) { + size_t advertised = *len; *len = (size_t)probed; if (osc_debug_enabled()) { - fprintf(stderr, "[osc-dmabuf] maxsize=0, recovered %zu bytes via lseek\n", *len); + fprintf(stderr, + "[osc-dmabuf] maxsize=%zu, recovered %zu bytes via lseek\n", + advertised, *len); } } } @@ -740,18 +747,148 @@ static void *osc_map_dmabuf(int fd, size_t *len, const char **why) return ptr == MAP_FAILED ? NULL : ptr; } -static void *osc_find_dmabuf_map(struct osc_pw_session *session, int fd) +static struct osc_dmabuf_map *osc_find_dmabuf_map(struct osc_pw_session *session, int fd) { size_t i; for (i = 0; i < OSC_MAX_DMABUF_MAPS; i++) { if (session->dmabuf_maps[i].ptr != NULL && session->dmabuf_maps[i].fd == fd) { - return session->dmabuf_maps[i].ptr; + return &session->dmabuf_maps[i]; } } return NULL; } +enum osc_frame_bounds_error { + OSC_FRAME_BOUNDS_OK, + OSC_FRAME_BOUNDS_NO_CAPACITY, + OSC_FRAME_BOUNDS_OFFSET, + OSC_FRAME_BOUNDS_CORRUPTED, + OSC_FRAME_BOUNDS_GEOMETRY, + OSC_FRAME_BOUNDS_ROW_TOO_SHORT, + OSC_FRAME_BOUNDS_FRAME_TOO_LARGE, +}; + +static enum osc_frame_bounds_error osc_resolve_frame_bounds( + uint32_t data_type, uint32_t maxsize, size_t mapped_len, uint32_t chunk_offset, + uint32_t chunk_size, int32_t chunk_flags, int32_t stride, int32_t width, int32_t height, + size_t *available_out, size_t *offset_out, size_t *size_out) +{ + size_t available; + size_t offset; + size_t size; + uint64_t row_bytes; + uint64_t frame_bytes; + + /* PipeWire maps MemPtr/MemFd for us and maxsize is their allocation bound. + * DMA-BUF is mapped by osc_on_add_buffer, which recovers the real length + * from the fd when the producer leaves a placeholder in maxsize. */ + available = data_type == SPA_DATA_DmaBuf ? mapped_len : (size_t)maxsize; + *available_out = available; + if (available == 0) { + return OSC_FRAME_BOUNDS_NO_CAPACITY; + } + offset = chunk_offset; + if (offset > available) { + return OSC_FRAME_BOUNDS_OFFSET; + } + if ((chunk_flags & SPA_CHUNK_FLAG_CORRUPTED) != 0) { + return OSC_FRAME_BOUNDS_CORRUPTED; + } + if (data_type == SPA_DATA_DmaBuf) { + /* DMA-BUF capacity belongs to the fd, not to SPA's advisory maxsize or + * chunk size. Backends use different positive placeholders for those + * fields, so keying this path on magic values is both brittle and + * unnecessary. The frame is still accepted only when stride * height + * fits inside the actual mapped allocation below. */ + size = available - offset; + } else { + /* Validate offset first so this subtraction cannot underflow. */ + size = SPA_MIN((size_t)chunk_size, available - offset); + } + if (stride <= 0 || width <= 0 || height <= 0) { + return OSC_FRAME_BOUNDS_GEOMETRY; + } + row_bytes = (uint64_t)width * OSC_VIDEO_BYTES_PER_PIXEL; + if ((uint64_t)stride < row_bytes) { + return OSC_FRAME_BOUNDS_ROW_TOO_SHORT; + } + /* Widen before multiplying: both operands originate outside this process. */ + frame_bytes = (uint64_t)stride * (uint64_t)height; + if (frame_bytes > (uint64_t)size) { + return OSC_FRAME_BOUNDS_FRAME_TOO_LARGE; + } + *offset_out = offset; + *size_out = size; + return OSC_FRAME_BOUNDS_OK; +} + +int osc_pw_frame_bounds_valid(uint32_t data_type, uint32_t maxsize, size_t mapped_len, + uint32_t chunk_offset, uint32_t chunk_size, int32_t chunk_flags, + int32_t stride, int32_t width, int32_t height) +{ + size_t available; + size_t offset; + size_t size; + + return osc_resolve_frame_bounds(data_type, maxsize, mapped_len, chunk_offset, chunk_size, + chunk_flags, stride, width, height, &available, &offset, &size) == + OSC_FRAME_BOUNDS_OK; +} + +static const char *osc_frame_bounds_error_name(enum osc_frame_bounds_error error) +{ + switch (error) { + case OSC_FRAME_BOUNDS_NO_CAPACITY: + return "buffer-length-zero"; + case OSC_FRAME_BOUNDS_OFFSET: + return "chunk-offset-out-of-bounds"; + case OSC_FRAME_BOUNDS_CORRUPTED: + return "producer-marked-frame-corrupted"; + case OSC_FRAME_BOUNDS_GEOMETRY: + return "invalid-frame-geometry"; + case OSC_FRAME_BOUNDS_ROW_TOO_SHORT: + return "stride-shorter-than-row"; + case OSC_FRAME_BOUNDS_FRAME_TOO_LARGE: + return "frame-bytes-exceed-available-chunk"; + case OSC_FRAME_BOUNDS_OK: + return "none"; + } + return "unknown"; +} + +static const char *osc_data_type_name(uint32_t data_type) +{ + switch (data_type) { + case SPA_DATA_MemPtr: + return "MemPtr"; + case SPA_DATA_MemFd: + return "MemFd"; + case SPA_DATA_DmaBuf: + return "DmaBuf"; + default: + return "Unknown"; + } +} + +static void osc_report_frame_drop(struct osc_pw_session *session, const struct spa_data *data, + size_t mapped_len, size_t available, + enum osc_frame_bounds_error error) +{ + if (!osc_debug_enabled() || session->frame_drop_reports >= OSC_FRAME_DROP_REPORTS) { + return; + } + session->frame_drop_reports++; + fprintf(stderr, + "[osc-frame-drop] reason=%s data_type=%s data_type_id=%u fd=%lld maxsize=%u " + "mapped_len=%zu available_len=%zu chunk_offset=%u chunk_size=%u " + "chunk_flags=%d stride=%d width=%u height=%u format=%u\n", + osc_frame_bounds_error_name(error), osc_data_type_name(data->type), data->type, + (long long)data->fd, data->maxsize, mapped_len, available, data->chunk->offset, + data->chunk->size, data->chunk->flags, data->chunk->stride, session->format.size.width, + session->format.size.height, session->format.format); +} + /* * CPU access to a dmabuf has to be bracketed by DMA_BUF_IOCTL_SYNC, or the * driver is under no obligation to have flushed the GPU's writes into the @@ -969,20 +1106,25 @@ static int osc_read_cursor(const struct spa_buffer *buffer, struct osc_pw_cursor * Extracts the pixels of one buffer. Returns 1 when `out` describes a frame, 0 * when this buffer carries none. * - * The offset/size clamping against `maxsize` is the standard PipeWire consumer - * idiom and is not paranoia: `chunk` lives in memory the PRODUCER writes, so its - * fields are untrusted input from another process. A compositor bug — or a - * malicious one — that reports a size past the end of the mapping would - * otherwise be a read straight off the end of the shared memory. + * The offset/size clamping against the mapped length is the standard PipeWire + * consumer idiom and is not paranoia: `chunk` lives in memory the PRODUCER + * writes, so its fields are untrusted input from another process. A compositor + * bug — or a malicious one — that reports a size past the end of the mapping + * would otherwise be a read straight off the end of the shared memory. For + * DMA-BUF, that length comes from dmabuf_maps[] because maxsize may be zero. */ static int osc_read_frame(struct osc_pw_session *session, const struct spa_buffer *buffer, struct osc_pw_frame *out) { struct spa_data *data; + struct osc_dmabuf_map *dmabuf_map = NULL; struct spa_meta_header *header; struct spa_meta_region *region; - uint32_t offset; - uint32_t size; + enum osc_frame_bounds_error bounds_error; + size_t available; + size_t mapped_len; + size_t offset; + size_t size; int32_t stride; int32_t height; @@ -1006,10 +1148,12 @@ static int osc_read_frame(struct osc_pw_session *session, const struct spa_buffe * osc_on_add_buffer. A miss means the mmap failed there — reported at * that point — and there is nothing readable here. */ - base = osc_find_dmabuf_map(session, (int)data->fd); - if (base == NULL) { + dmabuf_map = osc_find_dmabuf_map(session, (int)data->fd); + if (dmabuf_map == NULL) { return 0; } + base = dmabuf_map->ptr; + mapped_len = dmabuf_map->len; } else if (data->data == NULL) { /* * NULL on a shared-memory buffer means it was never mapped, which is the @@ -1018,6 +1162,7 @@ static int osc_read_frame(struct osc_pw_session *session, const struct spa_buffe return 0; } else { base = data->data; + mapped_len = data->maxsize; } /* A zero-sized chunk is how a compositor ships a cursor update with no new * frame attached. Not an error, just not a frame. */ @@ -1025,17 +1170,14 @@ static int osc_read_frame(struct osc_pw_session *session, const struct spa_buffe return 0; } - offset = SPA_MIN(data->chunk->offset, data->maxsize); - size = SPA_MIN(data->chunk->size, data->maxsize - offset); - height = (int32_t)session->format.size.height; stride = data->chunk->stride; - if (stride <= 0 || height <= 0) { - return 0; - } - /* One short row is one row of garbage in the recording; refuse the whole - * frame instead, and let the caller count it as dropped. */ - if ((uint64_t)stride * (uint64_t)height > (uint64_t)size) { + bounds_error = osc_resolve_frame_bounds( + data->type, data->maxsize, mapped_len, data->chunk->offset, data->chunk->size, + data->chunk->flags, stride, (int32_t)session->format.size.width, height, &available, &offset, + &size); + if (bounds_error != OSC_FRAME_BOUNDS_OK) { + osc_report_frame_drop(session, data, mapped_len, available, bounds_error); return 0; } diff --git a/electron/native/pipewire-capture/csrc/pw_shim.h b/electron/native/pipewire-capture/csrc/pw_shim.h index ab6f78305..70a90ab14 100644 --- a/electron/native/pipewire-capture/csrc/pw_shim.h +++ b/electron/native/pipewire-capture/csrc/pw_shim.h @@ -171,6 +171,11 @@ int osc_pw_cursor_meta_accepts_producer_size(uint32_t width, uint32_t height); */ int osc_pw_enum_format_accepts_dmabuf_producer(int with_modifier, int64_t producer_modifier); +/* Test-only frame-bound validation without a live PipeWire buffer. */ +int osc_pw_frame_bounds_valid(uint32_t data_type, uint32_t maxsize, size_t mapped_len, + uint32_t chunk_offset, uint32_t chunk_size, int32_t chunk_flags, + int32_t stride, int32_t width, int32_t height); + struct osc_pw_session; /* diff --git a/electron/native/pipewire-capture/src/main.rs b/electron/native/pipewire-capture/src/main.rs index 1576efced..7f52747f4 100644 --- a/electron/native/pipewire-capture/src/main.rs +++ b/electron/native/pipewire-capture/src/main.rs @@ -940,7 +940,7 @@ fn run( ("metas", metas.clone().into()), ]), }); - if !has_cursor_meta { + if !has_cursor_meta && config.cursor_mode.reports_cursor() { let _ = emitter.emit(&Event::Warning { code: "no-cursor-metadata".to_owned(), message: format!( diff --git a/electron/native/pipewire-capture/src/shim.rs b/electron/native/pipewire-capture/src/shim.rs index 8a4806ca4..ab22f9eee 100644 --- a/electron/native/pipewire-capture/src/shim.rs +++ b/electron/native/pipewire-capture/src/shim.rs @@ -102,6 +102,18 @@ extern "C" { with_modifier: i32, producer_modifier: i64, ) -> i32; + #[cfg(test)] + fn osc_pw_frame_bounds_valid( + data_type: u32, + maxsize: u32, + mapped_len: usize, + chunk_offset: u32, + chunk_size: u32, + chunk_flags: i32, + stride: i32, + width: i32, + height: i32, + ) -> i32; fn osc_pw_start( fd: i32, node_id: u32, @@ -611,6 +623,34 @@ pub fn enum_format_accepts_dmabuf_producer(with_modifier: bool, producer_modifie unsafe { osc_pw_enum_format_accepts_dmabuf_producer(i32::from(with_modifier), producer_modifier) } } +#[cfg(test)] +fn frame_bounds_valid( + data_type: u32, + maxsize: u32, + mapped_len: usize, + chunk_offset: u32, + chunk_size: u32, + chunk_flags: i32, + stride: i32, + width: i32, + height: i32, +) -> bool { + // SAFETY: the C helper performs arithmetic only and owns its output storage. + unsafe { + osc_pw_frame_bounds_valid( + data_type, + maxsize, + mapped_len, + chunk_offset, + chunk_size, + chunk_flags, + stride, + width, + height, + ) != 0 + } +} + /// SPA enum values as compiled from the vendored headers. pub fn constants() -> Constants { let mut out = Constants::default(); @@ -747,9 +787,9 @@ extern "C" fn on_frame(user: *mut c_void, frame: *const RawFrame) { if rows > frame.size { return; } - // SAFETY: the shim clamped `size` against the mapping's `maxsize` before - // the callback, `rows <= size` was just checked, and the mapping stays - // live until this returns. + // SAFETY: the shim clamped `size` against the mapping length before the + // callback, `rows <= size` was just checked, and the mapping stays live + // until this returns. let pixels = unsafe { std::slice::from_raw_parts(frame.data, rows) }; mailbox.put(pixels, frame); (state.sink)(StreamEvent::FrameReady); @@ -929,6 +969,169 @@ mod tests { ); } + #[test] + fn dmabuf_uses_the_mapped_allocation_not_advisory_sizes() { + let constants = constants(); + let stride = 1920 * 4; + + // Portal backends use different placeholders for DMA-BUF sizes. None + // are special: the fd allocation and frame geometry are authoritative. + assert!(frame_bounds_valid( + constants.data_dma_buf, + 0, + 8 * 1024 * 1024, + 0, + 9, + 0, + stride as i32, + 1920, + 1080, + )); + assert!(frame_bounds_valid( + constants.data_dma_buf, + 1, + 8 * 1024 * 1024, + 0, + 1, + 0, + stride as i32, + 1920, + 1080, + )); + assert!(frame_bounds_valid( + constants.data_dma_buf, + 37, + 8 * 1024 * 1024, + 0, + 23, + 0, + stride as i32, + 1920, + 1080, + )); + assert!(!frame_bounds_valid( + constants.data_dma_buf, + u32::MAX, + 4096, + 0, + u32::MAX, + 0, + stride as i32, + 1920, + 1080, + )); + } + + #[test] + fn frame_bounds_reject_invalid_offsets_and_geometry_without_affecting_memfd() { + let constants = constants(); + let stride = 16; + let frame_len = stride * 4; + + assert!(frame_bounds_valid( + constants.data_dma_buf, + frame_len, + frame_len as usize, + 0, + frame_len, + 0, + stride as i32, + 4, + 4, + )); + assert!(!frame_bounds_valid( + constants.data_dma_buf, + frame_len, + frame_len as usize, + frame_len + 1, + frame_len, + 0, + stride as i32, + 4, + 4, + )); + // DMA-BUF advisory sizes cannot extend the actual fd allocation. + assert!(frame_bounds_valid( + constants.data_dma_buf, + frame_len, + frame_len as usize, + 0, + u32::MAX, + 0, + stride as i32, + 4, + 4, + )); + // Shared-memory buffers continue to use PipeWire's maxsize; a separate + // mapped length is meaningful only for DMA-BUF. + assert!(frame_bounds_valid( + constants.data_mem_fd, + frame_len, + 0, + 0, + frame_len, + 0, + stride as i32, + 4, + 4, + )); + assert!(!frame_bounds_valid( + constants.data_mem_fd, + frame_len, + 0, + 0, + frame_len, + 1, + stride as i32, + 4, + 4, + )); + assert!(!frame_bounds_valid( + constants.data_mem_fd, + 0, + frame_len as usize, + 0, + frame_len, + 0, + stride as i32, + 4, + 4, + )); + assert!(!frame_bounds_valid( + constants.data_dma_buf, + u32::MAX, + u32::MAX as usize, + 0, + u32::MAX, + 0, + i32::MAX, + 4, + i32::MAX, + )); + assert!(!frame_bounds_valid( + constants.data_dma_buf, + frame_len, + frame_len as usize, + 0, + frame_len, + 0, + 15, + 4, + 4, + )); + assert!(!frame_bounds_valid( + constants.data_dma_buf, + 0, + frame_len as usize, + 0, + 9, + 1, + stride as i32, + 4, + 4, + )); + } + /// End-to-end exercise of the PipeWire half with NO portal involved. /// /// `pw_context_connect_fd` accepts any socket already connected to a