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.
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 `dvb-ci/docs/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}