Skip to main content

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}