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