dvb_ci_runtime/linux.rs
1//! Linux `/dev/dvb/adapterN/caM` [`CaDevice`] implementation (the `linux`
2//! feature).
3//!
4//! This is the one place the crate uses `unsafe` — the DVB CA ioctls
5//! (`CA_RESET`, `CA_GET_SLOT_INFO`) via `rustix::ioctl`, confined to the
6//! private `ioctl` submodule below, which exposes safe functions. The ioctl
7//! request numbers are built by `rustix::ioctl::opcode` (the per-architecture
8//! `_IOC` encoding — it differs between x86/arm and mips/powerpc/sparc) from
9//! `include/uapi/linux/dvb/ca.h`'s group/number/type, not hard-coded magic.
10//!
11//! Runtime behaviour requires a real DVB card with a CI slot; it is
12//! compile-checked in CI but exercised only on hardware.
13use std::fs::{File, OpenOptions};
14use std::io::{self, Read, Write};
15use std::os::unix::fs::OpenOptionsExt;
16use std::os::unix::io::AsFd;
17use std::path::Path;
18use std::time::{Duration, Instant};
19
20use rustix::event::{Nsecs, PollFd, PollFlags, Timespec};
21
22use crate::dataplane::{CiDataDevice, PartialWrite, TS_PACKET_LEN};
23use crate::device::{CaDevice, DeviceOpError, SlotInfo};
24
25/// Poll a file descriptor for readability up to `timeout`.
26///
27/// `EINTR` surfaces as an `io::Error` of kind `Interrupted`, exactly as it did
28/// with `libc::poll` on `main` (which also returned `last_os_error()` on `-1`
29/// and did not retry). Nothing retries it further up either: the driver pump
30/// does `self.device.poll(timeout)?`. A stray signal therefore still aborts
31/// one pump call, unchanged by this migration. Only `POLLIN` counts as
32/// readable; `POLLHUP`/`POLLERR` alone report `false`, as before. Unlike the
33/// old millisecond truncation the timeout keeps its sub-millisecond part.
34// `Nsecs` is `i64` on rustix's linux_raw backend (where `From<u32>` would do) but
35// `c_long` (`i32`) under its libc backend on 32-bit targets, where only
36// `TryFrom` exists; keep the portable form.
37fn poll_readable(fd: &impl AsFd, timeout: Duration) -> io::Result<bool> {
38 let mut fds = [PollFd::new(fd, PollFlags::IN)];
39 let ts = Timespec {
40 tv_sec: i64::try_from(timeout.as_secs()).unwrap_or(i64::MAX),
41 tv_nsec: timeout.subsec_nanos() as Nsecs,
42 };
43 rustix::event::poll(&mut fds, Some(&ts))?;
44 Ok(fds[0].revents().contains(PollFlags::IN))
45}
46
47/// Poll a file descriptor for writability (`POLLOUT`) up to `timeout`; the
48/// write-side twin of [`poll_readable`] (same timeout handling, same `EINTR`
49/// behaviour). `POLLERR`/`POLLHUP` alone report `false`.
50fn poll_writable(fd: &impl AsFd, timeout: Duration) -> io::Result<bool> {
51 let mut fds = [PollFd::new(fd, PollFlags::OUT)];
52 let ts = Timespec {
53 tv_sec: i64::try_from(timeout.as_secs()).unwrap_or(i64::MAX),
54 tv_nsec: timeout.subsec_nanos() as Nsecs,
55 };
56 rustix::event::poll(&mut fds, Some(&ts))?;
57 Ok(fds[0].revents().contains(PollFlags::OUT))
58}
59
60/// TS packets per `write(2)` to the CI data device. 21 x 188 = 3948 bytes is
61/// under `PIPE_BUF` (4096), so on a pipe/FIFO stand-in each call is atomic
62/// (all of it or `EAGAIN`, never a torn packet), and a stall can leave at most
63/// one chunk unsent. The real device takes any whole-packet multiple.
64const WRITE_CHUNK_PACKETS: usize = 21;
65/// How long [`CiDataDevice::write`] waits for a full device buffer to drain
66/// before giving up with a [`PartialWrite`] (whole packets already accepted).
67const WRITE_STALL_TIMEOUT: Duration = Duration::from_millis(50);
68/// How long to wait for room to finish a packet the device accepted only
69/// part of — the one situation that cannot be handed back to the caller.
70const TORN_PACKET_TIMEOUT: Duration = Duration::from_secs(2);
71
72/// Write `chunk` (a whole number of TS packets) to `file`, returning how many
73/// bytes the device accepted — always a whole number of packets. A short or
74/// non-packet-aligned write is completed (waiting up to
75/// [`TORN_PACKET_TIMEOUT`] for room) so a packet is never left half written;
76/// only if that wait fails is an error returned.
77fn write_whole_packets(file: &mut File, chunk: &[u8]) -> io::Result<usize> {
78 let mut n = 0;
79 while n < chunk.len() {
80 match file.write(&chunk[n..]) {
81 Ok(0) => {
82 return Err(io::Error::new(
83 io::ErrorKind::WriteZero,
84 "CI data device accepted 0 bytes",
85 ));
86 }
87 Ok(k) => n += k,
88 Err(e) if e.kind() == io::ErrorKind::Interrupted => {}
89 Err(e) if e.kind() == io::ErrorKind::WouldBlock => {
90 if n.is_multiple_of(TS_PACKET_LEN) {
91 // Device full at a packet boundary: nothing torn.
92 return Ok(n);
93 }
94 // Mid-packet: the remainder must go in before anything else.
95 if !poll_writable(file, TORN_PACKET_TIMEOUT)? {
96 return Err(io::Error::new(
97 io::ErrorKind::TimedOut,
98 "CI data device stayed full with a TS packet half written",
99 ));
100 }
101 }
102 Err(e) => return Err(e),
103 }
104 }
105 Ok(n)
106}
107
108// --- DVB CA ioctl request numbers (uapi/linux/dvb/ca.h) --------------------
109//
110// The `_IOC` request encoding is NOT one layout on Linux: x86/arm/arm64/riscv/
111// s390x/... use the asm-generic one (2 direction bits, 14 size bits), but
112// mips/powerpc/sparc use 3 direction bits, 13 size bits and different
113// direction values (`_IOC_NONE` = 1, `_IOC_READ` = 2, `_IOC_WRITE` = 4). A
114// hand-rolled asm-generic encoding therefore issues the WRONG request on those
115// targets (r10-W-1). `rustix::ioctl::opcode` carries the per-architecture
116// table, so the request numbers are derived from it rather than re-encoded
117// here.
118use rustix::ioctl::{Opcode, opcode};
119
120/// The DVB CA ioctl "magic" group, `'o'` (uapi `linux/dvb/ca.h`).
121const DVB_CA_MAGIC: u8 = b'o';
122/// `_IO('o', 128)` (uapi `linux/dvb/ca.h`).
123const CA_RESET: Opcode = opcode::none(DVB_CA_MAGIC, 128);
124/// `_IOR('o', 130, ca_slot_info_t)` (uapi `linux/dvb/ca.h`).
125const CA_GET_SLOT_INFO: Opcode = opcode::read::<CaSlotInfo>(DVB_CA_MAGIC, 130);
126/// `CA_CI_MODULE_PRESENT` — a module (or card) is physically inserted in the
127/// slot (uapi `linux/dvb/ca.h` `ca_slot_info.flags`, bit 0).
128const CA_CI_MODULE_PRESENT: u32 = 1;
129/// `CA_CI_MODULE_READY` — the inserted module has completed its own init and
130/// is usable (uapi `linux/dvb/ca.h` `ca_slot_info.flags`, bit 1). Distinct
131/// from `CA_CI_MODULE_PRESENT`: a module can be present but not yet ready
132/// briefly after insertion.
133const CA_CI_MODULE_READY: u32 = 2;
134
135/// Mirror of the kernel's `struct ca_slot_info` (uapi `linux/dvb/ca.h`):
136/// `int num; int type; unsigned int flags;` — three 4-byte fields.
137#[repr(C)]
138struct CaSlotInfo {
139 num: i32,
140 typ: i32,
141 flags: u32,
142}
143
144/// Size of the kernel's `struct ca_slot_info` (3 x 4 bytes).
145#[cfg(test)]
146const KERNEL_CA_SLOT_INFO_SIZE: usize = 12;
147/// Alignment of the kernel's `struct ca_slot_info` (4-byte `int`s).
148#[cfg(test)]
149const KERNEL_CA_SLOT_INFO_ALIGN: usize = 4;
150
151/// The only `unsafe` in the crate: the two DVB CA ioctls, behind safe fns.
152#[allow(unsafe_code)]
153mod ioctl {
154 use super::{CA_GET_SLOT_INFO, CA_RESET, CaSlotInfo};
155 use rustix::ioctl::{NoArg, Updater, ioctl};
156 use std::io;
157 use std::os::unix::io::BorrowedFd;
158
159 /// `CA_RESET` on `fd`.
160 pub(super) fn ca_reset(fd: BorrowedFd<'_>) -> io::Result<()> {
161 // SAFETY: `CA_RESET` is `_IO('o', 128)` (built by `rustix::ioctl::
162 // opcode::none`, the correct request for this target architecture)
163 // and takes no argument, so there is no memory for the kernel to read
164 // or write. `fd` is a live borrowed descriptor for the whole call.
165 unsafe { ioctl(fd, NoArg::<CA_RESET>::new()) }.map_err(io::Error::from)
166 }
167
168 /// `CA_GET_SLOT_INFO` on `fd`, filling `info` (its `num` selects the slot).
169 pub(super) fn ca_get_slot_info(fd: BorrowedFd<'_>, info: &mut CaSlotInfo) -> io::Result<()> {
170 // SAFETY: the request is `_IOR('o', 130, CaSlotInfo)` (built by
171 // `rustix::ioctl::opcode::read::<CaSlotInfo>`, per-architecture): the
172 // kernel reads `num` and writes the whole `ca_slot_info`, whose
173 // size/alignment `CaSlotInfo` mirrors (`#[repr(C)]`, asserted by the
174 // `ca_slot_info_matches_kernel_layout` test). `Updater` passes a
175 // pointer to the exclusive, fully-initialised `info`, which outlives
176 // the call. `fd` is live for the whole call.
177 unsafe { ioctl(fd, Updater::<CA_GET_SLOT_INFO, CaSlotInfo>::new(info)) }
178 .map_err(io::Error::from)
179 }
180}
181
182/// Settle time after `CA_RESET` before the module is usable. The DD/cxd2099
183/// (and others) only (re)initialise the slot a couple of seconds after reset;
184/// `Create_T_C` sent too early is ignored. 3s is the value validated live
185/// against a DD Octopus cxd2099 + AlphaCrypt module (2s intermittently raced the
186/// module's resource-manager open).
187const RESET_SETTLE: Duration = Duration::from_millis(3000);
188
189/// A [`CaDevice`] backed by a Linux DVB CA character device.
190///
191/// The kernel `dvb_ca_en50221` character device carries a 2-byte link header on
192/// every read/write — `[slot_id, connection_id, <TPDU>]`. This type adds/strips
193/// that header, so the sans-IO transport deals in bare TPDUs. (Writing a raw
194/// TPDU without the header is rejected `EINVAL` by the driver.)
195#[derive(Debug)]
196pub struct LinuxCaDevice {
197 file: File,
198 slot: u8,
199 /// Device path for error context (see [`DeviceOpError`]).
200 label: String,
201}
202
203impl LinuxCaDevice {
204 /// Open `/dev/dvb/adapter{adapter}/ca{ca}` (slot 0).
205 pub fn open(adapter: u32, ca: u32) -> io::Result<Self> {
206 let path = format!("/dev/dvb/adapter{adapter}/ca{ca}");
207 let file = OpenOptions::new()
208 .read(true)
209 .write(true)
210 .open(&path)
211 .map_err(|e| DeviceOpError::wrap("open (O_RDWR)", &path, e))?;
212 Ok(Self {
213 file,
214 slot: 0,
215 label: path,
216 })
217 }
218
219 /// Wrap an already-open CA device file for `slot`.
220 #[must_use]
221 pub fn from_file(file: File, slot: u8) -> Self {
222 Self {
223 file,
224 slot,
225 label: format!("CA device (slot {slot}, from_file)"),
226 }
227 }
228
229 /// The `connection_id` for a TPDU = its `t_c_id`, which follows the tag +
230 /// `length_field`. Falls back to 1 (the single connection) if unparseable.
231 fn connection_id(tpdu: &[u8]) -> u8 {
232 dvb_ci::length::decode(tpdu.get(1..).unwrap_or(&[]))
233 .ok()
234 .and_then(|(_, hdr)| tpdu.get(1 + hdr).copied())
235 .unwrap_or(1)
236 }
237}
238
239/// Largest legal kernel frame `[slot, connection_id, <TPDU>]` this device can
240/// deliver: the 2-byte link header, plus the largest TPDU EN 50221 Table 1
241/// can encode: `tpdu_tag`(1), `length_field`(up to 3 bytes total, its
242/// maximum-value long form: size_indicator plus 2 length bytes), and the
243/// `length_field`'s own max value, 65535, of `t_c_id` plus data (§7
244/// semantics: "length_field codes the length of all following fields", i.e.
245/// t_c_id + data — see `private/specs-verified/dvb/dvb-ci/en_50221/apdu-coding.md`). r10-W-2: a
246/// 4096-byte scratch buffer silently truncated any larger TPDU and returned
247/// `Ok` with the truncated bytes as if they were the whole thing.
248const MAX_CA_FRAME: usize = 2 + 1 + 3 + 65_535;
249
250impl CaDevice for LinuxCaDevice {
251 fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
252 // Read one kernel frame `[slot, connection_id, <TPDU>]` into a scratch
253 // buffer and hand the bare TPDU up. `poll` gates this, so it won't block;
254 // `WouldBlock` is reported as "no data".
255 let mut frame = [0u8; MAX_CA_FRAME];
256 let n = match self.file.read(&mut frame) {
257 Ok(n) => n,
258 Err(e) if e.kind() == io::ErrorKind::WouldBlock => return Ok(0),
259 Err(e) => {
260 return Err(DeviceOpError::wrap(
261 format!("read (<= {} byte frame)", frame.len()),
262 &self.label,
263 e,
264 ));
265 }
266 };
267 if n == frame.len() {
268 // A `read()` reports only how many bytes it actually copied,
269 // never "there was more" — a full buffer means we cannot tell
270 // whether the real frame was exactly this size or the driver
271 // handed us a larger one truncated to fit. Since `MAX_CA_FRAME`
272 // already covers every legal TPDU, treat a full read as a
273 // truncated (and therefore corrupt) frame rather than silently
274 // returning it as complete.
275 return Err(io::Error::new(
276 io::ErrorKind::InvalidData,
277 "CA device frame filled the maximum-size scratch buffer; \
278 it may have been truncated",
279 ));
280 }
281 // Strip the 2-byte link header; anything shorter has no TPDU.
282 let tpdu = frame.get(2..n).unwrap_or(&[]);
283 if tpdu.len() > buf.len() {
284 // The frame is already consumed from the device, so the TPDU cannot
285 // be re-read; handing the stack a truncated one as `Ok` would
286 // corrupt the link layer silently (the failure r10-W-2 removed on
287 // the device side). Report it instead.
288 return Err(DeviceOpError::wrap(
289 format!(
290 "read ({} byte TPDU does not fit the caller's {} byte buffer)",
291 tpdu.len(),
292 buf.len()
293 ),
294 &self.label,
295 io::Error::new(
296 io::ErrorKind::InvalidData,
297 "TPDU larger than the read buffer; dropped, not truncated",
298 ),
299 ));
300 }
301 buf[..tpdu.len()].copy_from_slice(tpdu);
302 Ok(tpdu.len())
303 }
304
305 fn write(&mut self, buf: &[u8]) -> io::Result<()> {
306 // Prepend the `[slot, connection_id]` link header the driver expects.
307 let mut frame = Vec::with_capacity(buf.len() + 2);
308 frame.push(self.slot);
309 frame.push(Self::connection_id(buf));
310 frame.extend_from_slice(buf);
311 // The kernel's `dvb_ca_en50221_io_write` returns EINVAL while the slot
312 // is not in its RUNNING state (e.g. the CAM is still initialising
313 // after a CA_RESET), so name the frame in the error.
314 self.file.write_all(&frame).map_err(|e| {
315 DeviceOpError::wrap(
316 format!(
317 "write ({} byte frame: slot {}, connection {})",
318 frame.len(),
319 frame[0],
320 frame[1]
321 ),
322 &self.label,
323 e,
324 )
325 })
326 }
327
328 fn reset(&mut self) -> io::Result<()> {
329 ioctl::ca_reset(self.file.as_fd())
330 .map_err(|e| DeviceOpError::wrap("ioctl CA_RESET", &self.label, e))?;
331 // The module needs a moment to re-initialise before Create_T_C; the
332 // driver waits that out via `reset_settle` on its own clock rather
333 // than this call blocking the pump (audit r10-O-10).
334 Ok(())
335 }
336
337 fn reset_settle(&self) -> Duration {
338 RESET_SETTLE
339 }
340
341 fn slot_info(&mut self) -> io::Result<SlotInfo> {
342 let mut si = CaSlotInfo {
343 num: i32::from(self.slot),
344 typ: 0,
345 flags: 0,
346 };
347 if let Err(err) = ioctl::ca_get_slot_info(self.file.as_fd(), &mut si) {
348 // r10-W-3: only fall back for the specific errno the "doesn't
349 // implement CA_GET_SLOT_INFO" drivers (DD/cxd2099) return —
350 // EINVAL, or ENOTTY for a device node that doesn't support the
351 // ioctl at all; presence then shows via the TPDU handshake
352 // instead, so assume present+ready. Any OTHER error (EIO,
353 // ENODEV, EBADF, …) is a real fault and must be propagated, not
354 // masked as "the module is present and ready".
355 return match err.raw_os_error() {
356 Some(libc::EINVAL) | Some(libc::ENOTTY) => Ok(SlotInfo {
357 num: self.slot,
358 module_ready: true,
359 module_present: true,
360 }),
361 _ => Err(DeviceOpError::wrap(
362 format!("ioctl CA_GET_SLOT_INFO (slot {})", self.slot),
363 &self.label,
364 err,
365 )),
366 };
367 }
368 Ok(SlotInfo {
369 num: si.num as u8,
370 module_ready: si.flags & CA_CI_MODULE_READY != 0,
371 module_present: si.flags & CA_CI_MODULE_PRESENT != 0,
372 })
373 }
374
375 fn poll(&mut self, timeout: Duration) -> io::Result<bool> {
376 poll_readable(&self.file, timeout)
377 .map_err(|e| DeviceOpError::wrap(format!("poll ({timeout:?})"), &self.label, e))
378 }
379}
380
381/// The file descriptor(s) behind a [`LinuxCiDataDevice`].
382#[derive(Debug)]
383enum CiEndpoints {
384 /// One `O_RDWR` fd used for both directions.
385 Duplex(File),
386 /// Two one-direction fds on the same node: an `O_WRONLY` fd for the
387 /// scrambled TS going in and an `O_RDONLY | O_NONBLOCK` fd for the
388 /// descrambled TS coming out.
389 Split { write: File, read: File },
390}
391
392/// A [`CiDataDevice`] backed by a Linux DVB CI TS data-plane device
393/// (`/dev/dvb/adapterN/ciM`). The host writes scrambled TS and reads the
394/// descrambled TS back; I/O is in whole 188-byte packets.
395///
396/// # Duplex versus split (Digital Devices CXD2099)
397/// Most drivers accept the node opened `O_RDWR`. The Digital Devices
398/// CXD2099 `ci0` (dddvb, Ubuntu 5.15 kernel, verified on hardware) rejects
399/// `O_RDWR` with `EINVAL` and only works as two one-direction descriptors.
400/// [`open`](Self::open) therefore tries `O_RDWR` first and falls back to the
401/// split open on `EINVAL` (and only `EINVAL`); [`open_split`](Self::open_split)
402/// / [`from_split`](Self::from_split) pick it directly.
403#[derive(Debug)]
404pub struct LinuxCiDataDevice {
405 io: CiEndpoints,
406 /// Device path for error context (see [`DeviceOpError`]).
407 label: String,
408}
409
410impl LinuxCiDataDevice {
411 /// Open `path` **non-blocking** (`O_NONBLOCK`) as a duplex (`O_RDWR`) CI
412 /// data-plane device.
413 ///
414 /// # Why non-blocking (#1066)
415 /// The kernel `ts_read()` on the real device sleeps
416 /// (`wait_event_interruptible`) until >=188 bytes are available; it never
417 /// returns `0` to signal "no more data right now" the way a mock's queue
418 /// does. [`CaDescrambler::feed_ts`](crate::descrambler::CaDescrambler::feed_ts)
419 /// loops `read` until it sees `0` to drain whatever the CAM has already
420 /// produced — on a *blocking* fd, the first call that drains the queue
421 /// blocks forever on the next read instead of returning, and the caller
422 /// can never feed more TS to unblock it (a permanent hang). Opening
423 /// `O_NONBLOCK` makes an empty read return `EWOULDBLOCK` immediately,
424 /// which [`read`](Self::read) maps to `Ok(0)` — the same "no more data"
425 /// signal the mock already gives, so the drain loop terminates. See
426 /// `tests::read_on_empty_device_returns_immediately_not_blocking` below,
427 /// which drives this exact function over a real blocking-capable FIFO.
428 fn open_path(path: &Path) -> io::Result<Self> {
429 let file = OpenOptions::new()
430 .read(true)
431 .write(true)
432 .custom_flags(libc::O_NONBLOCK)
433 .open(path)
434 .map_err(|e| DeviceOpError::wrap("open (O_RDWR|O_NONBLOCK)", &path_label(path), e))?;
435 Ok(Self {
436 io: CiEndpoints::Duplex(file),
437 label: path_label(path),
438 })
439 }
440
441 /// Open `path` as two one-direction descriptors: `O_RDONLY | O_NONBLOCK`
442 /// for reads (so an empty device returns `EWOULDBLOCK` — see
443 /// [`open_path`](Self::open_path) for why that matters) and `O_WRONLY` for
444 /// writes, left blocking exactly as the zenith host has always opened it
445 /// (verified on hardware: 150 s, 111k `feed_ts` calls, max 249 us, no
446 /// error). The read side is opened first so that on a FIFO stand-in the
447 /// write open finds a reader.
448 fn open_split_path(path: &Path) -> io::Result<Self> {
449 let label = path_label(path);
450 let read = OpenOptions::new()
451 .read(true)
452 .custom_flags(libc::O_NONBLOCK)
453 .open(path)
454 .map_err(|e| DeviceOpError::wrap("open (O_RDONLY|O_NONBLOCK)", &label, e))?;
455 let write = OpenOptions::new()
456 .write(true)
457 .open(path)
458 .map_err(|e| DeviceOpError::wrap("open (O_WRONLY)", &label, e))?;
459 Ok(Self {
460 io: CiEndpoints::Split { write, read },
461 label,
462 })
463 }
464
465 /// `O_RDWR` first, the split open only if that fails with `EINVAL`
466 /// (`open_duplex` is a parameter so the fallback is testable without a
467 /// device that rejects `O_RDWR`).
468 fn open_with(
469 path: &Path,
470 open_duplex: impl FnOnce(&Path) -> io::Result<Self>,
471 ) -> io::Result<Self> {
472 match open_duplex(path) {
473 Ok(dev) => Ok(dev),
474 Err(e) if DeviceOpError::errno_of(&e) == Some(libc::EINVAL) => {
475 Self::open_split_path(path)
476 }
477 Err(e) => Err(e),
478 }
479 }
480
481 /// Open `/dev/dvb/adapter{adapter}/ci{ci}`: `O_RDWR | O_NONBLOCK`, and on
482 /// `EINVAL` (the Digital Devices CXD2099 `ci0` — see the type docs) the
483 /// [split open](Self::open_split). Any other error is returned as is.
484 pub fn open(adapter: u32, ci: u32) -> io::Result<Self> {
485 let path = format!("/dev/dvb/adapter{adapter}/ci{ci}");
486 Self::open_with(Path::new(&path), Self::open_path)
487 }
488
489 /// Open `/dev/dvb/adapter{adapter}/ci{ci}` as two one-direction
490 /// descriptors (write `O_WRONLY`, read `O_RDONLY | O_NONBLOCK`), for
491 /// drivers that reject `O_RDWR` (Digital Devices CXD2099).
492 pub fn open_split(adapter: u32, ci: u32) -> io::Result<Self> {
493 let path = format!("/dev/dvb/adapter{adapter}/ci{ci}");
494 Self::open_split_path(Path::new(&path))
495 }
496
497 /// Wrap an already-open CI data-plane device file. The caller is
498 /// responsible for having opened it `O_NONBLOCK` — see
499 /// `open_path` for why a blocking fd here hangs
500 /// `feed_ts` (#1066).
501 #[must_use]
502 pub fn from_file(file: File) -> Self {
503 Self {
504 io: CiEndpoints::Duplex(file),
505 label: "CI data device (from_file)".to_owned(),
506 }
507 }
508
509 /// Wrap an already-open pair of one-direction descriptors: `write`
510 /// (`O_WRONLY`) and `read` (`O_RDONLY`). The caller is responsible for
511 /// having opened **`read` with `O_NONBLOCK`** — a blocking read fd makes
512 /// `feed_ts`'s drain loop hang (#1066) — see `open_path`.
513 #[must_use]
514 pub fn from_split(write: File, read: File) -> Self {
515 Self {
516 io: CiEndpoints::Split { write, read },
517 label: "CI data device (from_split)".to_owned(),
518 }
519 }
520
521 fn write_file(&mut self) -> &mut File {
522 match &mut self.io {
523 CiEndpoints::Duplex(f) => f,
524 CiEndpoints::Split { write, .. } => write,
525 }
526 }
527
528 fn read_file(&mut self) -> &mut File {
529 match &mut self.io {
530 CiEndpoints::Duplex(f) => f,
531 CiEndpoints::Split { read, .. } => read,
532 }
533 }
534}
535
536/// Printable device path for [`DeviceOpError`].
537fn path_label(path: &Path) -> String {
538 path.display().to_string()
539}
540
541impl CiDataDevice for LinuxCiDataDevice {
542 fn write(&mut self, ts: &[u8]) -> io::Result<()> {
543 if !ts.len().is_multiple_of(TS_PACKET_LEN) {
544 return Err(io::Error::new(
545 io::ErrorKind::InvalidInput,
546 "write not a multiple of 188 bytes",
547 ));
548 }
549 let label = self.label.clone();
550 let mut done = 0;
551 let mut deadline = None;
552 while done < ts.len() {
553 let end = ts.len().min(done + WRITE_CHUNK_PACKETS * TS_PACKET_LEN);
554 let n = write_whole_packets(self.write_file(), &ts[done..end]).map_err(|e| {
555 DeviceOpError::wrap(format!("write ({} of {} bytes)", done, ts.len()), &label, e)
556 })?;
557 let want = end - done;
558 done += n;
559 if n > 0 {
560 deadline = None;
561 }
562 if n == want {
563 continue;
564 }
565 // The device is full (at a packet boundary). Wait, bounded, for it
566 // to drain; on timeout hand back the whole packets it did take so
567 // the caller can resume after the unsent tail instead of guessing.
568 let limit = *deadline.get_or_insert_with(|| Instant::now() + WRITE_STALL_TIMEOUT);
569 let left = limit.saturating_duration_since(Instant::now());
570 let writable = !left.is_zero()
571 && poll_writable(self.write_file(), left).map_err(|e| {
572 DeviceOpError::wrap(format!("poll write ({left:?})"), &label, e)
573 })?;
574 if !writable {
575 return Err(io::Error::new(
576 io::ErrorKind::WouldBlock,
577 PartialWrite {
578 written: done,
579 requested: ts.len(),
580 device: label,
581 },
582 ));
583 }
584 }
585 Ok(())
586 }
587
588 fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
589 if !buf.len().is_multiple_of(TS_PACKET_LEN) {
590 return Err(io::Error::new(
591 io::ErrorKind::InvalidInput,
592 "read buffer not a multiple of 188 bytes",
593 ));
594 }
595 let cap = buf.len();
596 match self.read_file().read(buf) {
597 Ok(n) => Ok(n),
598 Err(e) if e.kind() == io::ErrorKind::WouldBlock => Ok(0),
599 Err(e) => {
600 let label = self.label.clone();
601 Err(DeviceOpError::wrap(
602 format!("read (<= {cap} bytes)"),
603 &label,
604 e,
605 ))
606 }
607 }
608 }
609
610 fn poll(&mut self, timeout: Duration) -> io::Result<bool> {
611 let label = self.label.clone();
612 poll_readable(self.read_file(), timeout)
613 .map_err(|e| DeviceOpError::wrap(format!("poll ({timeout:?})"), &label, e))
614 }
615}
616
617#[cfg(test)]
618mod tests {
619 use super::*;
620 use std::sync::mpsc;
621
622 /// Create a fresh FIFO (named pipe) under the OS temp dir and return its
623 /// path. `mkfifo` + a FIFO's blocking-read semantics are POSIX/Linux —
624 /// matching why this whole module compiles only under
625 /// `target_os = "linux"` (crate doc) — and, unlike a Unix socketpair
626 /// wrapped in a test-defined `CiDataDevice` (flagged in review as not
627 /// biting), opening the FIFO through `LinuxCiDataDevice::open_path`
628 /// drives the actual production `open`/`read` code under test.
629 fn make_fifo() -> std::path::PathBuf {
630 let path = std::env::temp_dir().join(format!(
631 "dvb-ci-runtime-c1-1066-{}-{}",
632 std::process::id(),
633 std::time::SystemTime::now()
634 .duration_since(std::time::UNIX_EPOCH)
635 .expect("system clock before UNIX_EPOCH")
636 .as_nanos()
637 ));
638 rustix::fs::mkfifoat(
639 rustix::fs::CWD,
640 &path,
641 rustix::fs::Mode::RUSR | rustix::fs::Mode::WUSR,
642 )
643 .expect("mkfifoat failed");
644 path
645 }
646
647 /// #1066: `open_path` must open `O_NONBLOCK`, so an empty read
648 /// returns `Ok(0)` promptly instead of blocking forever — the real bug
649 /// was `CaDescrambler::feed_ts`'s drain loop hanging on its second
650 /// `read`. A FIFO opened `O_RDWR` blocks on an empty read exactly like
651 /// the real `ciM` character device unless `O_NONBLOCK` is set, so this
652 /// is a faithful (if not identical) stand-in, unlike a socketpair with
653 /// its own test-defined `CiDataDevice` impl, which never calls the
654 /// production `open`/`read` at all.
655 ///
656 /// Removing `.custom_flags(libc::O_NONBLOCK)` from `open_path` makes the
657 /// first `recv_timeout` below time out. Verified by hand: with that line
658 /// removed, `cargo test -p dvb-ci-runtime --all-features --locked
659 /// --target x86_64-unknown-linux-gnu` cannot be *run* on macOS at all
660 /// (this module only exists under `target_os = "linux"`, and macOS
661 /// cannot execute a Linux binary), so the removal was checked instead
662 /// with `cargo test -p dvb-ci-runtime --all-features --locked --target
663 /// x86_64-unknown-linux-gnu --no-run` (compiles either way) plus the
664 /// cross-clippy command from CLAUDE.md (lints clean either way — clippy
665 /// cannot see that the missing flag is a bug); real execution proof of
666 /// this specific assertion is Linux CI, which runs this test on every
667 /// push.
668 #[test]
669 fn read_on_empty_device_returns_immediately_not_blocking() {
670 let path = make_fifo();
671 let mut dev = LinuxCiDataDevice::open_path(&path).expect("open_path");
672 let mut writer = OpenOptions::new()
673 .write(true)
674 .open(&path)
675 .expect("open a second, writer-only handle on the same FIFO");
676
677 let (tx, rx) = mpsc::channel();
678 let (tx_go, rx_go) = mpsc::channel::<()>();
679 std::thread::spawn(move || {
680 let mut buf = [0u8; TS_PACKET_LEN];
681 let r0 = dev.read(&mut buf).map(|n| (n, buf));
682 if tx.send(r0).is_err() {
683 return;
684 }
685 if rx_go.recv().is_err() {
686 return;
687 }
688
689 let r1 = dev.read(&mut buf).map(|n| (n, buf));
690 if tx.send(r1).is_err() {
691 return;
692 }
693 if rx_go.recv().is_err() {
694 return;
695 }
696
697 let r2 = dev.read(&mut buf).map(|n| (n, buf));
698 let _ = tx.send(r2);
699 });
700
701 // 1: empty device (writer end open, no data written yet) — must
702 // return `Ok(0)` promptly, not block.
703 let first = rx.recv_timeout(Duration::from_secs(1));
704 let Ok(Ok((n0, _))) = first else {
705 // The reader thread may be stuck in a blocking `read`; write a
706 // byte so it can still unblock and exit, then fail.
707 let _ = writer.write_all(&[0u8; TS_PACKET_LEN]);
708 panic!(
709 "empty-device read did not return promptly (blocked past 1s) — \
710 got {first:?}; this is the #1066 hang O_NONBLOCK fixes"
711 );
712 };
713 assert_eq!(n0, 0, "empty device must read 0 bytes");
714
715 // 2: write one full packet BEFORE letting the reader proceed, so
716 // there is no race between the write and the second `read`.
717 let mut packet = [0xAAu8; TS_PACKET_LEN];
718 packet[0] = 0x47; // MPEG-2 TS sync byte — realism only, not checked by read().
719 writer.write_all(&packet).expect("write one packet");
720 tx_go.send(()).expect("reader thread must still be alive");
721
722 let (n1, buf1) = rx
723 .recv_timeout(Duration::from_secs(1))
724 .expect("reader thread must respond promptly")
725 .expect("read must not error");
726 assert_eq!(n1, TS_PACKET_LEN, "must read exactly one packet's worth");
727 assert_eq!(
728 &buf1[..n1],
729 &packet[..],
730 "must read back the exact bytes written"
731 );
732
733 // 3: drained again — back to `Ok(0)`, not blocking.
734 tx_go.send(()).expect("reader thread must still be alive");
735 let (n2, _) = rx
736 .recv_timeout(Duration::from_secs(1))
737 .expect("empty-again read did not return promptly (blocked past 1s)")
738 .expect("read must not error");
739 assert_eq!(n2, 0, "device must read 0 again once drained");
740
741 let _ = std::fs::remove_file(&path);
742 }
743
744 /// A packet of recognisable bytes.
745 fn packet(fill: u8) -> [u8; TS_PACKET_LEN] {
746 let mut p = [fill; TS_PACKET_LEN];
747 p[0] = 0x47;
748 p
749 }
750
751 /// Run `f` on a thread and fail if it has not finished in `limit` - a
752 /// read that blocks forever (the #1066 hang) must fail the test, not hang
753 /// the suite. A stuck thread is unblocked through `unstick` so it can exit.
754 fn within<T: Send + 'static>(
755 limit: Duration,
756 unstick: impl FnOnce(),
757 f: impl FnOnce() -> T + Send + 'static,
758 ) -> T {
759 let (tx, rx) = mpsc::channel();
760 std::thread::spawn(move || {
761 let _ = tx.send(f());
762 });
763 match rx.recv_timeout(limit) {
764 Ok(v) => v,
765 Err(_) => {
766 unstick();
767 panic!("operation did not return within {limit:?} (blocked)");
768 }
769 }
770 }
771
772 /// CXD2099 `ci0` rejects `O_RDWR`; the device is two one-direction fds.
773 /// The FIFO is blocking-faithful: a read on an empty FIFO whose write end
774 /// is open never returns 0 (it blocks) unless the fd is `O_NONBLOCK`. A
775 /// `feed_ts`-style drain loop (`read` until `0`) over the split read side
776 /// must therefore terminate, deliver exactly what was written, and each
777 /// call must come back promptly.
778 #[test]
779 fn split_read_side_is_nonblocking_and_a_drain_loop_terminates() {
780 let path = make_fifo();
781 // The open itself is bounded too: a blocking read-only open of a FIFO
782 // with no writer yet sleeps, which is the same hang in another place.
783 let open_path = path.clone();
784 let mut dev = within(
785 Duration::from_secs(2),
786 || {},
787 move || LinuxCiDataDevice::open_split_path(&open_path).expect("split open"),
788 );
789 // Loop the FIFO back: what the write fd writes, the read fd reads.
790 dev.write(&packet(0xA1)).expect("write packet 1");
791 dev.write(&packet(0xB2)).expect("write packet 2");
792
793 let drained = within(
794 Duration::from_secs(2),
795 || {},
796 move || {
797 let mut got = Vec::new();
798 let mut buf = [0u8; TS_PACKET_LEN];
799 loop {
800 let n = dev.read(&mut buf).expect("read");
801 if n == 0 {
802 break;
803 }
804 assert_eq!(n, TS_PACKET_LEN);
805 got.push(buf);
806 }
807 // And once more on the now-empty device: still prompt, still 0.
808 assert_eq!(dev.read(&mut buf).expect("read"), 0);
809 got
810 },
811 );
812 assert_eq!(drained, vec![packet(0xA1), packet(0xB2)]);
813 let _ = std::fs::remove_file(&path);
814 }
815
816 /// The control that proves the mock is blocking-faithful and the test
817 /// above bites: the same loopback with a read fd opened WITHOUT
818 /// `O_NONBLOCK` (what `from_split` documents as the caller's mistake)
819 /// blocks on the empty read instead of returning 0.
820 #[test]
821 fn split_with_a_blocking_read_fd_would_hang_the_drain_loop() {
822 let path = make_fifo();
823 // Open the write end first (O_RDWR on a FIFO never blocks) so the
824 // blocking read-only open below finds a writer instead of waiting.
825 let write = OpenOptions::new()
826 .read(true)
827 .write(true)
828 .open(&path)
829 .expect("write open");
830 let read = OpenOptions::new()
831 .read(true)
832 .open(&path)
833 .expect("blocking read-only open");
834 // Keep a handle to unblock the stuck reader afterwards.
835 let unblock = write.try_clone().expect("clone write fd");
836 let mut dev = LinuxCiDataDevice::from_split(write, read);
837 dev.write(&packet(0xC3)).expect("write");
838
839 let (tx, rx) = mpsc::channel();
840 std::thread::spawn(move || {
841 let mut buf = [0u8; TS_PACKET_LEN];
842 let first = dev.read(&mut buf); // the written packet
843 let _ = tx.send(first);
844 let second = dev.read(&mut buf); // empty: a blocking fd sleeps here
845 let _ = tx.send(second);
846 });
847 assert_eq!(
848 rx.recv_timeout(Duration::from_secs(2)).unwrap().unwrap(),
849 TS_PACKET_LEN
850 );
851 assert!(
852 rx.recv_timeout(Duration::from_millis(400)).is_err(),
853 "a blocking read fd must NOT return on an empty FIFO - the mock is not blocking-faithful"
854 );
855 // Unblock the reader thread so the process can exit cleanly.
856 let mut unblock = unblock;
857 let _ = unblock.write_all(&packet(0xD4));
858 let _ = std::fs::remove_file(&path);
859 }
860
861 /// `open` falls back to the split open when `O_RDWR` fails with `EINVAL` -
862 /// and the fallback device really works (loopback through the FIFO).
863 #[test]
864 fn open_falls_back_to_split_on_einval_only() {
865 let path = make_fifo();
866 let mut dev = LinuxCiDataDevice::open_with(&path, |_| {
867 Err(io::Error::from_raw_os_error(libc::EINVAL))
868 })
869 .expect("EINVAL on O_RDWR must fall back to the split open");
870 assert!(matches!(dev.io, CiEndpoints::Split { .. }));
871 dev.write(&packet(0xE5)).expect("write");
872 let mut buf = [0u8; TS_PACKET_LEN];
873 assert_eq!(dev.read(&mut buf).expect("read"), TS_PACKET_LEN);
874 assert_eq!(buf, packet(0xE5));
875
876 // A wrapped EINVAL (what `open_path` produces) triggers it too.
877 let wrapped = LinuxCiDataDevice::open_with(&path, |p| {
878 Err(DeviceOpError::wrap(
879 "open (O_RDWR|O_NONBLOCK)",
880 &path_label(p),
881 io::Error::from_raw_os_error(libc::EINVAL),
882 ))
883 })
884 .expect("wrapped EINVAL must fall back too");
885 assert!(matches!(wrapped.io, CiEndpoints::Split { .. }));
886
887 // Any other error is NOT papered over with a split open.
888 for errno in [libc::ENOENT, libc::EACCES, libc::EBUSY, libc::ENODEV] {
889 let err =
890 LinuxCiDataDevice::open_with(&path, |_| Err(io::Error::from_raw_os_error(errno)))
891 .expect_err("a non-EINVAL open error must propagate");
892 assert_eq!(DeviceOpError::errno_of(&err), Some(errno));
893 }
894 let _ = std::fs::remove_file(&path);
895 }
896
897 /// When `O_RDWR` works (the common case) `open_with` keeps the duplex fd.
898 #[test]
899 fn open_keeps_the_duplex_fd_when_rdwr_works() {
900 let path = make_fifo();
901 let dev =
902 LinuxCiDataDevice::open_with(&path, LinuxCiDataDevice::open_path).expect("duplex open");
903 assert!(matches!(dev.io, CiEndpoints::Duplex(_)));
904 let _ = std::fs::remove_file(&path);
905 }
906
907 /// A failing device operation names the operation, the device and the
908 /// errno, keeps the original `ErrorKind`, and still answers the errno.
909 #[test]
910 fn device_errors_carry_operation_device_and_errno() {
911 let path = std::env::temp_dir().join(format!(
912 "dvb-ci-runtime-h1-ctx-{}-{}",
913 std::process::id(),
914 std::time::SystemTime::now()
915 .duration_since(std::time::UNIX_EPOCH)
916 .expect("system clock before UNIX_EPOCH")
917 .as_nanos()
918 ));
919 // A read-only regular file: writing to it fails (EBADF on Linux).
920 std::fs::write(&path, b"x").expect("stage file");
921 let file = OpenOptions::new()
922 .read(true)
923 .open(&path)
924 .expect("open read-only");
925 let mut dev = LinuxCaDevice::from_file(file, 2);
926 let err = dev
927 .write(&[0xA0, 0x01, 0x01])
928 .expect_err("write to a read-only fd must fail");
929 let text = err.to_string();
930 assert!(
931 text.contains("write (5 byte frame: slot 2, connection 1)"),
932 "{text}"
933 );
934 assert!(text.contains("CA device (slot 2, from_file)"), "{text}");
935 assert!(text.contains("os error"), "{text}");
936 assert_eq!(DeviceOpError::errno_of(&err), Some(libc::EBADF));
937 assert_eq!(err.kind(), io::Error::from_raw_os_error(libc::EBADF).kind());
938 let _ = std::fs::remove_file(&path);
939 }
940
941 /// r10-W-2: a kernel frame that exactly fills [`MAX_CA_FRAME`] must be
942 /// reported as an error (possible truncation), not silently accepted as
943 /// a complete TPDU. A regular file lets us stage exactly that many bytes
944 /// without needing real CI hardware.
945 #[test]
946 fn read_rejects_a_frame_that_fills_the_scratch_buffer() {
947 let path = std::env::temp_dir().join(format!(
948 "dvb-ci-runtime-w2-fullframe-{}-{}",
949 std::process::id(),
950 std::time::SystemTime::now()
951 .duration_since(std::time::UNIX_EPOCH)
952 .expect("system clock before UNIX_EPOCH")
953 .as_nanos()
954 ));
955 std::fs::write(&path, vec![0xAAu8; MAX_CA_FRAME]).expect("stage a MAX_CA_FRAME-byte file");
956 let file = OpenOptions::new()
957 .read(true)
958 .write(true)
959 .open(&path)
960 .expect("open the staged file");
961 let mut dev = LinuxCaDevice::from_file(file, 0);
962 let mut buf = [0u8; 8192];
963 let err = dev
964 .read(&mut buf)
965 .expect_err("a full-buffer read must error");
966 assert_eq!(err.kind(), io::ErrorKind::InvalidData);
967 let _ = std::fs::remove_file(&path);
968 }
969
970 /// REVIEW (failing): r10-W-2 sized the scratch frame so no legal TPDU is
971 /// truncated, but `read` then copies `min(tpdu.len(), buf.len())` into
972 /// the caller's buffer and returns `Ok` — `Driver` passes a 4096-byte
973 /// buffer, so a 5000-byte TPDU reaches the stack silently truncated, the
974 /// exact failure r10-W-2 set out to remove.
975 #[test]
976 fn read_does_not_silently_truncate_a_tpdu_larger_than_the_callers_buffer() {
977 let path = std::env::temp_dir().join(format!(
978 "dvb-ci-runtime-review-trunc-{}-{}",
979 std::process::id(),
980 std::time::SystemTime::now()
981 .duration_since(std::time::UNIX_EPOCH)
982 .expect("system clock before UNIX_EPOCH")
983 .as_nanos()
984 ));
985 let mut frame = vec![0x00, 0x01];
986 frame.extend(std::iter::repeat_n(0xAB, 5000));
987 std::fs::write(&path, &frame).expect("stage a 5000-byte TPDU frame");
988 let file = OpenOptions::new()
989 .read(true)
990 .write(true)
991 .open(&path)
992 .expect("open the staged file");
993 let mut dev = LinuxCaDevice::from_file(file, 0);
994 let mut buf = [0u8; 4096]; // Driver's buffer size
995 let r = dev.read(&mut buf);
996 let _ = std::fs::remove_file(&path);
997 assert!(
998 r.is_err(),
999 "a 5000-byte TPDU read into a 4096-byte buffer must not be returned as Ok({:?})",
1000 r.ok()
1001 );
1002 }
1003
1004 /// REVIEW (failing): the duplex CI data fd is `O_NONBLOCK` (#1066), so
1005 /// `write_all` stops with `WouldBlock` after a *partial* write once the
1006 /// device buffer fills. The error carries no byte count, so the caller
1007 /// cannot resume, and the stream is left misaligned mid-TS-packet. A
1008 /// FIFO's 64 KiB buffer is not a multiple of 188, so 400 packets written
1009 /// at once leave a torn packet behind.
1010 #[test]
1011 fn duplex_write_never_leaves_a_partial_ts_packet_in_the_device() {
1012 let path = make_fifo();
1013 let mut dev = LinuxCiDataDevice::open_path(&path).expect("open_path");
1014 let mut reader = OpenOptions::new()
1015 .read(true)
1016 .custom_flags(libc::O_NONBLOCK)
1017 .open(&path)
1018 .expect("second reader");
1019 let burst: Vec<u8> = (0..400).flat_map(|i| packet(i as u8)).collect();
1020 let res = dev.write(&burst);
1021 let mut got = Vec::new();
1022 let mut chunk = [0u8; 4096];
1023 loop {
1024 match reader.read(&mut chunk) {
1025 Ok(0) => break,
1026 Ok(n) => got.extend_from_slice(&chunk[..n]),
1027 Err(e) if e.kind() == io::ErrorKind::WouldBlock => break,
1028 Err(e) => panic!("{e}"),
1029 }
1030 }
1031 let _ = std::fs::remove_file(&path);
1032 assert_eq!(
1033 got.len() % TS_PACKET_LEN,
1034 0,
1035 "write returned {res:?} after putting {} bytes (not whole packets) on the device",
1036 got.len()
1037 );
1038 }
1039
1040 /// Sanity: an ordinary short frame still reads through unaffected.
1041 #[test]
1042 fn read_still_returns_short_frames_normally() {
1043 let path = std::env::temp_dir().join(format!(
1044 "dvb-ci-runtime-w2-shortframe-{}-{}",
1045 std::process::id(),
1046 std::time::SystemTime::now()
1047 .duration_since(std::time::UNIX_EPOCH)
1048 .expect("system clock before UNIX_EPOCH")
1049 .as_nanos()
1050 ));
1051 // link header (slot, connection_id) + a 3-byte "TPDU".
1052 std::fs::write(&path, [0x00, 0x01, 0xDE, 0xAD, 0xBE]).expect("stage a short file");
1053 let file = OpenOptions::new()
1054 .read(true)
1055 .write(true)
1056 .open(&path)
1057 .expect("open the staged file");
1058 let mut dev = LinuxCaDevice::from_file(file, 0);
1059 let mut buf = [0u8; 16];
1060 let n = dev.read(&mut buf).expect("short read must succeed");
1061 assert_eq!(n, 3);
1062 assert_eq!(&buf[..3], &[0xDE, 0xAD, 0xBE]);
1063 let _ = std::fs::remove_file(&path);
1064 }
1065
1066 /// r10-W-3: `CA_GET_SLOT_INFO` returning `ENOTTY` (the driver — or, here,
1067 /// a plain regular file standing in for one that doesn't support the
1068 /// ioctl at all — has no CI-slot ioctl handling) must still fall back to
1069 /// "present + ready", exactly as the documented EINVAL fallback did. A
1070 /// regular file is a faithful stand-in: `ioctl` on a non-device fd
1071 /// reliably returns `ENOTTY` on Linux, so this exercises the real
1072 /// `libc::ioctl` call and its errno branch, not a mocked one.
1073 #[test]
1074 fn slot_info_falls_back_on_enotty() {
1075 let path = std::env::temp_dir().join(format!(
1076 "dvb-ci-runtime-w3-slotinfo-{}-{}",
1077 std::process::id(),
1078 std::time::SystemTime::now()
1079 .duration_since(std::time::UNIX_EPOCH)
1080 .expect("system clock before UNIX_EPOCH")
1081 .as_nanos()
1082 ));
1083 let file = OpenOptions::new()
1084 .read(true)
1085 .write(true)
1086 .create(true)
1087 .truncate(true)
1088 .open(&path)
1089 .expect("create a regular file to stand in for the CA device");
1090 let mut dev = LinuxCaDevice::from_file(file, 3);
1091 let info = dev.slot_info().expect("ENOTTY must fall back, not error");
1092 assert_eq!(info.num, 3);
1093 assert!(info.module_present);
1094 assert!(info.module_ready);
1095 let _ = std::fs::remove_file(&path);
1096 }
1097 fn poll_file(file: &File, timeout: Duration) -> io::Result<bool> {
1098 poll_readable(file, timeout)
1099 }
1100
1101 /// `poll_readable` on an empty FIFO with a short timeout reports `false`
1102 /// after about that long; with data it reports `true` immediately.
1103 #[test]
1104 fn poll_readable_reports_data_and_honours_the_timeout() {
1105 let path = make_fifo();
1106 let mut dev = LinuxCiDataDevice::open_path(&path).expect("open_path");
1107 let mut writer = OpenOptions::new().write(true).open(&path).expect("writer");
1108
1109 let start = std::time::Instant::now();
1110 assert!(
1111 !dev.poll(Duration::from_millis(80)).unwrap(),
1112 "empty FIFO is not readable"
1113 );
1114 assert!(
1115 start.elapsed() >= Duration::from_millis(70),
1116 "poll returned before its timeout"
1117 );
1118
1119 writer.write_all(&[0x47; TS_PACKET_LEN]).unwrap();
1120 let start = std::time::Instant::now();
1121 assert!(
1122 dev.poll(Duration::from_secs(5)).unwrap(),
1123 "data written: readable"
1124 );
1125 assert!(
1126 start.elapsed() < Duration::from_secs(4),
1127 "must return as soon as readable"
1128 );
1129
1130 let _ = std::fs::remove_file(&path);
1131 }
1132
1133 /// A zero timeout never blocks.
1134 #[test]
1135 fn poll_readable_zero_timeout_does_not_block() {
1136 let path = make_fifo();
1137 let mut dev = LinuxCiDataDevice::open_path(&path).expect("open_path");
1138 let _writer = OpenOptions::new().write(true).open(&path).expect("writer");
1139 let start = std::time::Instant::now();
1140 assert!(!dev.poll(Duration::ZERO).unwrap());
1141 assert!(start.elapsed() < Duration::from_millis(500));
1142 let _ = std::fs::remove_file(&path);
1143 }
1144
1145 /// Sub-millisecond timeouts used to truncate to `poll(.., 0)` (returning
1146 /// at once); they must now sleep their true duration and never error.
1147 #[test]
1148 fn poll_readable_sub_millisecond_timeout_keeps_its_duration() {
1149 let path = make_fifo();
1150 let mut dev = LinuxCiDataDevice::open_path(&path).expect("open_path");
1151 let _writer = OpenOptions::new().write(true).open(&path).expect("writer");
1152 let start = std::time::Instant::now();
1153 assert!(!dev.poll(Duration::from_micros(300)).unwrap());
1154 assert!(
1155 start.elapsed() >= Duration::from_micros(250),
1156 "a 300 us timeout was truncated: returned after {:?}",
1157 start.elapsed()
1158 );
1159 let _ = std::fs::remove_file(&path);
1160 }
1161
1162 /// Hang-up without data (writer closed) is not "readable" for this API:
1163 /// the old code tested `revents & POLLIN` only. The device itself opens
1164 /// the FIFO `O_RDWR` (it is its own writer, so no hang-up ever shows), so
1165 /// this drives `poll_readable` on a separate read-only descriptor, and
1166 /// proves with a raw `rustix::event::poll` that the kernel really reports POLLHUP
1167 /// there (otherwise the assertion would be vacuous).
1168 #[test]
1169 fn poll_readable_ignores_hangup_without_data() {
1170 let path = make_fifo();
1171 let reader = OpenOptions::new()
1172 .read(true)
1173 .custom_flags(libc::O_NONBLOCK)
1174 .open(&path)
1175 .expect("read-only O_NONBLOCK open");
1176 let writer = OpenOptions::new().write(true).open(&path).expect("writer");
1177 drop(writer); // POLLHUP on the read end, no POLLIN, no data
1178
1179 let mut raw = [PollFd::new(&reader, PollFlags::IN)];
1180 let zero = Timespec {
1181 tv_sec: 0,
1182 tv_nsec: 0,
1183 };
1184 let r = rustix::event::poll(&mut raw, Some(&zero)).expect("poll");
1185 assert_eq!(r, 1, "kernel must report an event");
1186 let revents = raw[0].revents();
1187 assert!(
1188 revents.contains(PollFlags::HUP) && !revents.contains(PollFlags::IN),
1189 "precondition: POLLHUP without POLLIN, got revents={revents:?}"
1190 );
1191
1192 assert!(!poll_file(&reader, Duration::from_millis(50)).unwrap());
1193 let _ = std::fs::remove_file(&path);
1194 }
1195
1196 /// Our `CaSlotInfo` must match the kernel's `struct ca_slot_info`
1197 /// (`int num; int type; unsigned int flags;`) exactly, since the ioctl
1198 /// writes it through a raw pointer and the request number encodes its size.
1199 #[test]
1200 fn ca_slot_info_matches_kernel_layout() {
1201 assert_eq!(core::mem::size_of::<CaSlotInfo>(), KERNEL_CA_SLOT_INFO_SIZE);
1202 assert_eq!(
1203 core::mem::align_of::<CaSlotInfo>(),
1204 KERNEL_CA_SLOT_INFO_ALIGN
1205 );
1206 }
1207
1208 // r10-W-1: the request numbers the kernel's `dvb_ca_en50221` `ioctl`
1209 // handler dispatches on, per target architecture. Linux does not have one
1210 // `_IOC` layout: asm-generic targets (x86, arm, aarch64, riscv, s390x, ...)
1211 // use `dir << 30 | size << 16 | type << 8 | nr` with `_IOC_READ = 2`;
1212 // mips/powerpc/sparc use `dir << 29` (3 direction bits, 13 size bits) with
1213 // `_IOC_NONE = 1`, `_IOC_READ = 2`. The expected values are the well-known
1214 // ones for `uapi/linux/dvb/ca.h`'s `_IO('o', 128)` and
1215 // `_IOR('o', 130, ca_slot_info_t)` (12-byte struct). A hand-rolled
1216 // asm-generic encoding fails the second block.
1217 //
1218 // Compile-time asserts, so `cargo check --tests --target <arch>` verifies
1219 // an architecture whose test binary cannot be run on the build host.
1220 #[cfg(not(any(
1221 target_arch = "mips",
1222 target_arch = "mips32r6",
1223 target_arch = "mips64",
1224 target_arch = "mips64r6",
1225 target_arch = "powerpc",
1226 target_arch = "powerpc64",
1227 target_arch = "sparc",
1228 target_arch = "sparc64"
1229 )))]
1230 const _: () = {
1231 assert!(CA_RESET == 0x0000_6F80);
1232 assert!(CA_GET_SLOT_INFO == 0x800C_6F82);
1233 };
1234 #[cfg(any(
1235 target_arch = "mips",
1236 target_arch = "mips32r6",
1237 target_arch = "mips64",
1238 target_arch = "mips64r6",
1239 target_arch = "powerpc",
1240 target_arch = "powerpc64",
1241 target_arch = "sparc",
1242 target_arch = "sparc64"
1243 ))]
1244 const _: () = {
1245 assert!(CA_RESET == 0x2000_6F80);
1246 assert!(CA_GET_SLOT_INFO == 0x400C_6F82);
1247 };
1248}