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 `libc`, confined to the private
6//! `ioctl` submodule below, which exposes safe functions. The ioctl request
7//! numbers are
8//! computed from the standard Linux `_IOC` encoding (Documentation/userspace-api
9//! + `include/uapi/linux/dvb/ca.h`), 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;
19
20use rustix::event::{Nsecs, PollFd, PollFlags, Timespec};
21
22use crate::dataplane::{CiDataDevice, TS_PACKET_LEN};
23use crate::device::{CaDevice, 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// --- Linux _IOC ioctl encoding (uapi/asm-generic/ioctl.h) ------------------
49const IOC_NRBITS: u32 = 8;
50const IOC_TYPEBITS: u32 = 8;
51const IOC_SIZEBITS: u32 = 14;
52const IOC_NRSHIFT: u32 = 0;
53const IOC_TYPESHIFT: u32 = IOC_NRSHIFT + IOC_NRBITS;
54const IOC_SIZESHIFT: u32 = IOC_TYPESHIFT + IOC_TYPEBITS;
55const IOC_DIRSHIFT: u32 = IOC_SIZESHIFT + IOC_SIZEBITS;
56const IOC_NONE: u32 = 0;
57const IOC_READ: u32 = 2;
58
59const fn ioc(dir: u32, typ: u32, nr: u32, size: u32) -> u64 {
60    ((dir << IOC_DIRSHIFT) | (typ << IOC_TYPESHIFT) | (nr << IOC_NRSHIFT) | (size << IOC_SIZESHIFT))
61        as u64
62}
63
64// DVB CA device (uapi/linux/dvb/ca.h): magic 'o', ca_slot_info, flags bit.
65const DVB_CA_MAGIC: u32 = b'o' as u32;
66const CA_RESET: u64 = ioc(IOC_NONE, DVB_CA_MAGIC, 128, 0);
67const CA_GET_SLOT_INFO: u64 = ioc(
68    IOC_READ,
69    DVB_CA_MAGIC,
70    130,
71    core::mem::size_of::<CaSlotInfo>() as u32,
72);
73/// `CA_CI_MODULE_PRESENT` — a module (or card) is physically inserted in the
74/// slot (uapi `linux/dvb/ca.h` `ca_slot_info.flags`, bit 0).
75const CA_CI_MODULE_PRESENT: u32 = 1;
76/// `CA_CI_MODULE_READY` — the inserted module has completed its own init and
77/// is usable (uapi `linux/dvb/ca.h` `ca_slot_info.flags`, bit 1). Distinct
78/// from `CA_CI_MODULE_PRESENT`: a module can be present but not yet ready
79/// briefly after insertion.
80const CA_CI_MODULE_READY: u32 = 2;
81
82/// Mirror of the kernel's `struct ca_slot_info` (uapi `linux/dvb/ca.h`):
83/// `int num; int type; unsigned int flags;` — three 4-byte fields.
84#[repr(C)]
85struct CaSlotInfo {
86    num: i32,
87    typ: i32,
88    flags: u32,
89}
90
91/// Size of the kernel's `struct ca_slot_info` (3 x 4 bytes).
92#[cfg(test)]
93const KERNEL_CA_SLOT_INFO_SIZE: usize = 12;
94/// Alignment of the kernel's `struct ca_slot_info` (4-byte `int`s).
95#[cfg(test)]
96const KERNEL_CA_SLOT_INFO_ALIGN: usize = 4;
97
98/// The only `unsafe` in the crate: the two DVB CA ioctls, behind safe fns.
99#[allow(unsafe_code)]
100mod ioctl {
101    use super::{CA_GET_SLOT_INFO, CA_RESET, CaSlotInfo};
102    use std::io;
103    use std::os::unix::io::{AsRawFd, BorrowedFd};
104
105    // r10-W-1: `libc::ioctl`'s request parameter is `libc::Ioctl`, which is
106    // `c_ulong` on glibc but `c_int` on musl/uclibc/Android — a hard-coded
107    // `as libc::c_ulong` fails to compile there. The encoded request always
108    // fits in 32 bits, so the narrowing cast on musl/Android is lossless.
109
110    /// `CA_RESET` on `fd`.
111    pub(super) fn ca_reset(fd: BorrowedFd<'_>) -> io::Result<()> {
112        // SAFETY: `fd` is a live borrowed descriptor for the whole call (the
113        // `BorrowedFd` lifetime guarantees it is open); `CA_RESET` is an
114        // `_IO` request that takes no argument, so no pointer is passed and
115        // there is no memory for the kernel to read or write.
116        let r = unsafe { libc::ioctl(fd.as_raw_fd(), CA_RESET as libc::Ioctl) };
117        if r < 0 {
118            return Err(io::Error::last_os_error());
119        }
120        Ok(())
121    }
122
123    /// `CA_GET_SLOT_INFO` on `fd`, filling `info` (its `num` selects the slot).
124    pub(super) fn ca_get_slot_info(fd: BorrowedFd<'_>, info: &mut CaSlotInfo) -> io::Result<()> {
125        // SAFETY: `fd` is live for the whole call (as above). The request is
126        // `_IOR('o', 130, CaSlotInfo)`: the kernel reads `num` and writes the
127        // whole `ca_slot_info`, whose size/alignment `CaSlotInfo` mirrors
128        // (`#[repr(C)]`, asserted by the `ca_slot_info_matches_kernel_layout`
129        // test). `info` is an exclusive `&mut` to a fully-initialised value that
130        // outlives the call, so the pointer is valid, aligned, and unaliased
131        // for the kernel's write.
132        let r = unsafe {
133            libc::ioctl(
134                fd.as_raw_fd(),
135                CA_GET_SLOT_INFO as libc::Ioctl,
136                info as *mut CaSlotInfo,
137            )
138        };
139        if r < 0 {
140            return Err(io::Error::last_os_error());
141        }
142        Ok(())
143    }
144}
145
146/// Settle time after `CA_RESET` before the module is usable. The DD/cxd2099
147/// (and others) only (re)initialise the slot a couple of seconds after reset;
148/// `Create_T_C` sent too early is ignored. 3s is the value validated live
149/// against a DD Octopus cxd2099 + AlphaCrypt module (2s intermittently raced the
150/// module's resource-manager open).
151const RESET_SETTLE: Duration = Duration::from_millis(3000);
152
153/// A [`CaDevice`] backed by a Linux DVB CA character device.
154///
155/// The kernel `dvb_ca_en50221` character device carries a 2-byte link header on
156/// every read/write — `[slot_id, connection_id, <TPDU>]`. This type adds/strips
157/// that header, so the sans-IO transport deals in bare TPDUs. (Writing a raw
158/// TPDU without the header is rejected `EINVAL` by the driver.)
159#[derive(Debug)]
160pub struct LinuxCaDevice {
161    file: File,
162    slot: u8,
163}
164
165impl LinuxCaDevice {
166    /// Open `/dev/dvb/adapter{adapter}/ca{ca}` (slot 0).
167    pub fn open(adapter: u32, ca: u32) -> io::Result<Self> {
168        let path = format!("/dev/dvb/adapter{adapter}/ca{ca}");
169        let file = OpenOptions::new().read(true).write(true).open(path)?;
170        Ok(Self { file, slot: 0 })
171    }
172
173    /// Wrap an already-open CA device file for `slot`.
174    #[must_use]
175    pub fn from_file(file: File, slot: u8) -> Self {
176        Self { file, slot }
177    }
178
179    /// The `connection_id` for a TPDU = its `t_c_id`, which follows the tag +
180    /// `length_field`. Falls back to 1 (the single connection) if unparseable.
181    fn connection_id(tpdu: &[u8]) -> u8 {
182        dvb_ci::length::decode(tpdu.get(1..).unwrap_or(&[]))
183            .ok()
184            .and_then(|(_, hdr)| tpdu.get(1 + hdr).copied())
185            .unwrap_or(1)
186    }
187}
188
189/// Largest legal kernel frame `[slot, connection_id, <TPDU>]` this device can
190/// deliver: the 2-byte link header, plus the largest TPDU EN 50221 Table 1
191/// can encode: `tpdu_tag`(1), `length_field`(up to 3 bytes total, its
192/// maximum-value long form: size_indicator plus 2 length bytes), and the
193/// `length_field`'s own max value, 65535, of `t_c_id` plus data (§7
194/// semantics: "length_field codes the length of all following fields", i.e.
195/// t_c_id + data — see `dvb-ci/docs/en_50221/apdu-coding.md`). r10-W-2: a
196/// 4096-byte scratch buffer silently truncated any larger TPDU and returned
197/// `Ok` with the truncated bytes as if they were the whole thing.
198const MAX_CA_FRAME: usize = 2 + 1 + 3 + 65_535;
199
200impl CaDevice for LinuxCaDevice {
201    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
202        // Read one kernel frame `[slot, connection_id, <TPDU>]` into a scratch
203        // buffer and hand the bare TPDU up. `poll` gates this, so it won't block;
204        // `WouldBlock` is reported as "no data".
205        let mut frame = [0u8; MAX_CA_FRAME];
206        let n = match self.file.read(&mut frame) {
207            Ok(n) => n,
208            Err(e) if e.kind() == io::ErrorKind::WouldBlock => return Ok(0),
209            Err(e) => return Err(e),
210        };
211        if n == frame.len() {
212            // A `read()` reports only how many bytes it actually copied,
213            // never "there was more" — a full buffer means we cannot tell
214            // whether the real frame was exactly this size or the driver
215            // handed us a larger one truncated to fit. Since `MAX_CA_FRAME`
216            // already covers every legal TPDU, treat a full read as a
217            // truncated (and therefore corrupt) frame rather than silently
218            // returning it as complete.
219            return Err(io::Error::new(
220                io::ErrorKind::InvalidData,
221                "CA device frame filled the maximum-size scratch buffer; \
222                 it may have been truncated",
223            ));
224        }
225        // Strip the 2-byte link header; anything shorter has no TPDU.
226        let tpdu = frame.get(2..n).unwrap_or(&[]);
227        let copy = tpdu.len().min(buf.len());
228        buf[..copy].copy_from_slice(&tpdu[..copy]);
229        Ok(copy)
230    }
231
232    fn write(&mut self, buf: &[u8]) -> io::Result<()> {
233        // Prepend the `[slot, connection_id]` link header the driver expects.
234        let mut frame = Vec::with_capacity(buf.len() + 2);
235        frame.push(self.slot);
236        frame.push(Self::connection_id(buf));
237        frame.extend_from_slice(buf);
238        self.file.write_all(&frame)
239    }
240
241    fn reset(&mut self) -> io::Result<()> {
242        ioctl::ca_reset(self.file.as_fd())?;
243        // The module needs a moment to re-initialise before Create_T_C.
244        std::thread::sleep(RESET_SETTLE);
245        Ok(())
246    }
247
248    fn slot_info(&mut self) -> io::Result<SlotInfo> {
249        let mut si = CaSlotInfo {
250            num: i32::from(self.slot),
251            typ: 0,
252            flags: 0,
253        };
254        if let Err(err) = ioctl::ca_get_slot_info(self.file.as_fd(), &mut si) {
255            // r10-W-3: only fall back for the specific errno the "doesn't
256            // implement CA_GET_SLOT_INFO" drivers (DD/cxd2099) return —
257            // EINVAL, or ENOTTY for a device node that doesn't support the
258            // ioctl at all; presence then shows via the TPDU handshake
259            // instead, so assume present+ready. Any OTHER error (EIO,
260            // ENODEV, EBADF, …) is a real fault and must be propagated, not
261            // masked as "the module is present and ready".
262            return match err.raw_os_error() {
263                Some(libc::EINVAL) | Some(libc::ENOTTY) => Ok(SlotInfo {
264                    num: self.slot,
265                    module_ready: true,
266                    module_present: true,
267                }),
268                _ => Err(err),
269            };
270        }
271        Ok(SlotInfo {
272            num: si.num as u8,
273            module_ready: si.flags & CA_CI_MODULE_READY != 0,
274            module_present: si.flags & CA_CI_MODULE_PRESENT != 0,
275        })
276    }
277
278    fn poll(&mut self, timeout: Duration) -> io::Result<bool> {
279        poll_readable(&self.file, timeout)
280    }
281}
282
283/// A [`CiDataDevice`] backed by a Linux DVB CI TS data-plane device
284/// (`/dev/dvb/adapterN/ciM`). The host writes scrambled TS and reads the
285/// descrambled TS back; I/O is in whole 188-byte packets.
286#[derive(Debug)]
287pub struct LinuxCiDataDevice {
288    file: File,
289}
290
291impl LinuxCiDataDevice {
292    /// Open `path` **non-blocking** (`O_NONBLOCK`) as a CI data-plane device.
293    ///
294    /// # Why non-blocking (#1066)
295    /// The kernel `ts_read()` on the real device sleeps
296    /// (`wait_event_interruptible`) until >=188 bytes are available; it never
297    /// returns `0` to signal "no more data right now" the way a mock's queue
298    /// does. [`CaDescrambler::feed_ts`](crate::descrambler::CaDescrambler::feed_ts)
299    /// loops `read` until it sees `0` to drain whatever the CAM has already
300    /// produced — on a *blocking* fd, the first call that drains the queue
301    /// blocks forever on the next read instead of returning, and the caller
302    /// can never feed more TS to unblock it (a permanent hang). Opening
303    /// `O_NONBLOCK` makes an empty read return `EWOULDBLOCK` immediately,
304    /// which [`read`](Self::read) maps to `Ok(0)` — the same "no more data"
305    /// signal the mock already gives, so the drain loop terminates. See
306    /// `tests::read_on_empty_device_returns_immediately_not_blocking` below,
307    /// which drives this exact function over a real blocking-capable FIFO.
308    fn open_path(path: &Path) -> io::Result<Self> {
309        let file = OpenOptions::new()
310            .read(true)
311            .write(true)
312            .custom_flags(libc::O_NONBLOCK)
313            .open(path)?;
314        Ok(Self { file })
315    }
316
317    /// Open `/dev/dvb/adapter{adapter}/ci{ci}` — see `open_path`.
318    pub fn open(adapter: u32, ci: u32) -> io::Result<Self> {
319        let path = format!("/dev/dvb/adapter{adapter}/ci{ci}");
320        Self::open_path(Path::new(&path))
321    }
322
323    /// Wrap an already-open CI data-plane device file. The caller is
324    /// responsible for having opened it `O_NONBLOCK` — see
325    /// `open_path` for why a blocking fd here hangs
326    /// `feed_ts` (#1066).
327    #[must_use]
328    pub fn from_file(file: File) -> Self {
329        Self { file }
330    }
331}
332
333impl CiDataDevice for LinuxCiDataDevice {
334    fn write(&mut self, ts: &[u8]) -> io::Result<()> {
335        if !ts.len().is_multiple_of(TS_PACKET_LEN) {
336            return Err(io::Error::new(
337                io::ErrorKind::InvalidInput,
338                "write not a multiple of 188 bytes",
339            ));
340        }
341        self.file.write_all(ts)
342    }
343
344    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
345        if !buf.len().is_multiple_of(TS_PACKET_LEN) {
346            return Err(io::Error::new(
347                io::ErrorKind::InvalidInput,
348                "read buffer not a multiple of 188 bytes",
349            ));
350        }
351        match self.file.read(buf) {
352            Ok(n) => Ok(n),
353            Err(e) if e.kind() == io::ErrorKind::WouldBlock => Ok(0),
354            Err(e) => Err(e),
355        }
356    }
357
358    fn poll(&mut self, timeout: Duration) -> io::Result<bool> {
359        poll_readable(&self.file, timeout)
360    }
361}
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366    use std::sync::mpsc;
367
368    /// Create a fresh FIFO (named pipe) under the OS temp dir and return its
369    /// path. `mkfifo` + a FIFO's blocking-read semantics are POSIX/Linux —
370    /// matching why this whole module compiles only under
371    /// `target_os = "linux"` (crate doc) — and, unlike a Unix socketpair
372    /// wrapped in a test-defined `CiDataDevice` (flagged in review as not
373    /// biting), opening the FIFO through `LinuxCiDataDevice::open_path`
374    /// drives the actual production `open`/`read` code under test.
375    fn make_fifo() -> std::path::PathBuf {
376        let path = std::env::temp_dir().join(format!(
377            "dvb-ci-runtime-c1-1066-{}-{}",
378            std::process::id(),
379            std::time::SystemTime::now()
380                .duration_since(std::time::UNIX_EPOCH)
381                .expect("system clock before UNIX_EPOCH")
382                .as_nanos()
383        ));
384        rustix::fs::mkfifoat(
385            rustix::fs::CWD,
386            &path,
387            rustix::fs::Mode::RUSR | rustix::fs::Mode::WUSR,
388        )
389        .expect("mkfifoat failed");
390        path
391    }
392
393    /// #1066: `open_path` must open `O_NONBLOCK`, so an empty read
394    /// returns `Ok(0)` promptly instead of blocking forever — the real bug
395    /// was `CaDescrambler::feed_ts`'s drain loop hanging on its second
396    /// `read`. A FIFO opened `O_RDWR` blocks on an empty read exactly like
397    /// the real `ciM` character device unless `O_NONBLOCK` is set, so this
398    /// is a faithful (if not identical) stand-in, unlike a socketpair with
399    /// its own test-defined `CiDataDevice` impl, which never calls the
400    /// production `open`/`read` at all.
401    ///
402    /// Removing `.custom_flags(libc::O_NONBLOCK)` from `open_path` makes the
403    /// first `recv_timeout` below time out. Verified by hand: with that line
404    /// removed, `cargo test -p dvb-ci-runtime --all-features --locked
405    /// --target x86_64-unknown-linux-gnu` cannot be *run* on macOS at all
406    /// (this module only exists under `target_os = "linux"`, and macOS
407    /// cannot execute a Linux binary), so the removal was checked instead
408    /// with `cargo test -p dvb-ci-runtime --all-features --locked --target
409    /// x86_64-unknown-linux-gnu --no-run` (compiles either way) plus the
410    /// cross-clippy command from CLAUDE.md (lints clean either way — clippy
411    /// cannot see that the missing flag is a bug); real execution proof of
412    /// this specific assertion is Linux CI, which runs this test on every
413    /// push.
414    #[test]
415    fn read_on_empty_device_returns_immediately_not_blocking() {
416        let path = make_fifo();
417        let mut dev = LinuxCiDataDevice::open_path(&path).expect("open_path");
418        let mut writer = OpenOptions::new()
419            .write(true)
420            .open(&path)
421            .expect("open a second, writer-only handle on the same FIFO");
422
423        let (tx, rx) = mpsc::channel();
424        let (tx_go, rx_go) = mpsc::channel::<()>();
425        std::thread::spawn(move || {
426            let mut buf = [0u8; TS_PACKET_LEN];
427            let r0 = dev.read(&mut buf).map(|n| (n, buf));
428            if tx.send(r0).is_err() {
429                return;
430            }
431            if rx_go.recv().is_err() {
432                return;
433            }
434
435            let r1 = dev.read(&mut buf).map(|n| (n, buf));
436            if tx.send(r1).is_err() {
437                return;
438            }
439            if rx_go.recv().is_err() {
440                return;
441            }
442
443            let r2 = dev.read(&mut buf).map(|n| (n, buf));
444            let _ = tx.send(r2);
445        });
446
447        // 1: empty device (writer end open, no data written yet) — must
448        // return `Ok(0)` promptly, not block.
449        let first = rx.recv_timeout(Duration::from_secs(1));
450        let Ok(Ok((n0, _))) = first else {
451            // The reader thread may be stuck in a blocking `read`; write a
452            // byte so it can still unblock and exit, then fail.
453            let _ = writer.write_all(&[0u8; TS_PACKET_LEN]);
454            panic!(
455                "empty-device read did not return promptly (blocked past 1s) — \
456                 got {first:?}; this is the #1066 hang O_NONBLOCK fixes"
457            );
458        };
459        assert_eq!(n0, 0, "empty device must read 0 bytes");
460
461        // 2: write one full packet BEFORE letting the reader proceed, so
462        // there is no race between the write and the second `read`.
463        let mut packet = [0xAAu8; TS_PACKET_LEN];
464        packet[0] = 0x47; // MPEG-2 TS sync byte — realism only, not checked by read().
465        writer.write_all(&packet).expect("write one packet");
466        tx_go.send(()).expect("reader thread must still be alive");
467
468        let (n1, buf1) = rx
469            .recv_timeout(Duration::from_secs(1))
470            .expect("reader thread must respond promptly")
471            .expect("read must not error");
472        assert_eq!(n1, TS_PACKET_LEN, "must read exactly one packet's worth");
473        assert_eq!(
474            &buf1[..n1],
475            &packet[..],
476            "must read back the exact bytes written"
477        );
478
479        // 3: drained again — back to `Ok(0)`, not blocking.
480        tx_go.send(()).expect("reader thread must still be alive");
481        let (n2, _) = rx
482            .recv_timeout(Duration::from_secs(1))
483            .expect("empty-again read did not return promptly (blocked past 1s)")
484            .expect("read must not error");
485        assert_eq!(n2, 0, "device must read 0 again once drained");
486
487        let _ = std::fs::remove_file(&path);
488    }
489
490    /// r10-W-2: a kernel frame that exactly fills [`MAX_CA_FRAME`] must be
491    /// reported as an error (possible truncation), not silently accepted as
492    /// a complete TPDU. A regular file lets us stage exactly that many bytes
493    /// without needing real CI hardware.
494    #[test]
495    fn read_rejects_a_frame_that_fills_the_scratch_buffer() {
496        let path = std::env::temp_dir().join(format!(
497            "dvb-ci-runtime-w2-fullframe-{}-{}",
498            std::process::id(),
499            std::time::SystemTime::now()
500                .duration_since(std::time::UNIX_EPOCH)
501                .expect("system clock before UNIX_EPOCH")
502                .as_nanos()
503        ));
504        std::fs::write(&path, vec![0xAAu8; MAX_CA_FRAME]).expect("stage a MAX_CA_FRAME-byte file");
505        let file = OpenOptions::new()
506            .read(true)
507            .write(true)
508            .open(&path)
509            .expect("open the staged file");
510        let mut dev = LinuxCaDevice::from_file(file, 0);
511        let mut buf = [0u8; 8192];
512        let err = dev
513            .read(&mut buf)
514            .expect_err("a full-buffer read must error");
515        assert_eq!(err.kind(), io::ErrorKind::InvalidData);
516        let _ = std::fs::remove_file(&path);
517    }
518
519    /// Sanity: an ordinary short frame still reads through unaffected.
520    #[test]
521    fn read_still_returns_short_frames_normally() {
522        let path = std::env::temp_dir().join(format!(
523            "dvb-ci-runtime-w2-shortframe-{}-{}",
524            std::process::id(),
525            std::time::SystemTime::now()
526                .duration_since(std::time::UNIX_EPOCH)
527                .expect("system clock before UNIX_EPOCH")
528                .as_nanos()
529        ));
530        // link header (slot, connection_id) + a 3-byte "TPDU".
531        std::fs::write(&path, [0x00, 0x01, 0xDE, 0xAD, 0xBE]).expect("stage a short file");
532        let file = OpenOptions::new()
533            .read(true)
534            .write(true)
535            .open(&path)
536            .expect("open the staged file");
537        let mut dev = LinuxCaDevice::from_file(file, 0);
538        let mut buf = [0u8; 16];
539        let n = dev.read(&mut buf).expect("short read must succeed");
540        assert_eq!(n, 3);
541        assert_eq!(&buf[..3], &[0xDE, 0xAD, 0xBE]);
542        let _ = std::fs::remove_file(&path);
543    }
544
545    /// r10-W-3: `CA_GET_SLOT_INFO` returning `ENOTTY` (the driver — or, here,
546    /// a plain regular file standing in for one that doesn't support the
547    /// ioctl at all — has no CI-slot ioctl handling) must still fall back to
548    /// "present + ready", exactly as the documented EINVAL fallback did. A
549    /// regular file is a faithful stand-in: `ioctl` on a non-device fd
550    /// reliably returns `ENOTTY` on Linux, so this exercises the real
551    /// `libc::ioctl` call and its errno branch, not a mocked one.
552    #[test]
553    fn slot_info_falls_back_on_enotty() {
554        let path = std::env::temp_dir().join(format!(
555            "dvb-ci-runtime-w3-slotinfo-{}-{}",
556            std::process::id(),
557            std::time::SystemTime::now()
558                .duration_since(std::time::UNIX_EPOCH)
559                .expect("system clock before UNIX_EPOCH")
560                .as_nanos()
561        ));
562        let file = OpenOptions::new()
563            .read(true)
564            .write(true)
565            .create(true)
566            .truncate(true)
567            .open(&path)
568            .expect("create a regular file to stand in for the CA device");
569        let mut dev = LinuxCaDevice::from_file(file, 3);
570        let info = dev.slot_info().expect("ENOTTY must fall back, not error");
571        assert_eq!(info.num, 3);
572        assert!(info.module_present);
573        assert!(info.module_ready);
574        let _ = std::fs::remove_file(&path);
575    }
576    fn poll_file(file: &File, timeout: Duration) -> io::Result<bool> {
577        poll_readable(file, timeout)
578    }
579
580    /// `poll_readable` on an empty FIFO with a short timeout reports `false`
581    /// after about that long; with data it reports `true` immediately.
582    #[test]
583    fn poll_readable_reports_data_and_honours_the_timeout() {
584        let path = make_fifo();
585        let mut dev = LinuxCiDataDevice::open_path(&path).expect("open_path");
586        let mut writer = OpenOptions::new().write(true).open(&path).expect("writer");
587
588        let start = std::time::Instant::now();
589        assert!(
590            !dev.poll(Duration::from_millis(80)).unwrap(),
591            "empty FIFO is not readable"
592        );
593        assert!(
594            start.elapsed() >= Duration::from_millis(70),
595            "poll returned before its timeout"
596        );
597
598        writer.write_all(&[0x47; TS_PACKET_LEN]).unwrap();
599        let start = std::time::Instant::now();
600        assert!(
601            dev.poll(Duration::from_secs(5)).unwrap(),
602            "data written: readable"
603        );
604        assert!(
605            start.elapsed() < Duration::from_secs(4),
606            "must return as soon as readable"
607        );
608
609        let _ = std::fs::remove_file(&path);
610    }
611
612    /// A zero timeout never blocks.
613    #[test]
614    fn poll_readable_zero_timeout_does_not_block() {
615        let path = make_fifo();
616        let mut dev = LinuxCiDataDevice::open_path(&path).expect("open_path");
617        let _writer = OpenOptions::new().write(true).open(&path).expect("writer");
618        let start = std::time::Instant::now();
619        assert!(!dev.poll(Duration::ZERO).unwrap());
620        assert!(start.elapsed() < Duration::from_millis(500));
621        let _ = std::fs::remove_file(&path);
622    }
623
624    /// Sub-millisecond timeouts used to truncate to `poll(.., 0)` (returning
625    /// at once); they must now sleep their true duration and never error.
626    #[test]
627    fn poll_readable_sub_millisecond_timeout_keeps_its_duration() {
628        let path = make_fifo();
629        let mut dev = LinuxCiDataDevice::open_path(&path).expect("open_path");
630        let _writer = OpenOptions::new().write(true).open(&path).expect("writer");
631        let start = std::time::Instant::now();
632        assert!(!dev.poll(Duration::from_micros(300)).unwrap());
633        assert!(
634            start.elapsed() >= Duration::from_micros(250),
635            "a 300 us timeout was truncated: returned after {:?}",
636            start.elapsed()
637        );
638        let _ = std::fs::remove_file(&path);
639    }
640
641    /// Hang-up without data (writer closed) is not "readable" for this API:
642    /// the old code tested `revents & POLLIN` only. The device itself opens
643    /// the FIFO `O_RDWR` (it is its own writer, so no hang-up ever shows), so
644    /// this drives `poll_readable` on a separate read-only descriptor, and
645    /// proves with a raw `rustix::event::poll` that the kernel really reports POLLHUP
646    /// there (otherwise the assertion would be vacuous).
647    #[test]
648    fn poll_readable_ignores_hangup_without_data() {
649        let path = make_fifo();
650        let reader = OpenOptions::new()
651            .read(true)
652            .custom_flags(libc::O_NONBLOCK)
653            .open(&path)
654            .expect("read-only O_NONBLOCK open");
655        let writer = OpenOptions::new().write(true).open(&path).expect("writer");
656        drop(writer); // POLLHUP on the read end, no POLLIN, no data
657
658        let mut raw = [PollFd::new(&reader, PollFlags::IN)];
659        let zero = Timespec {
660            tv_sec: 0,
661            tv_nsec: 0,
662        };
663        let r = rustix::event::poll(&mut raw, Some(&zero)).expect("poll");
664        assert_eq!(r, 1, "kernel must report an event");
665        let revents = raw[0].revents();
666        assert!(
667            revents.contains(PollFlags::HUP) && !revents.contains(PollFlags::IN),
668            "precondition: POLLHUP without POLLIN, got revents={revents:?}"
669        );
670
671        assert!(!poll_file(&reader, Duration::from_millis(50)).unwrap());
672        let _ = std::fs::remove_file(&path);
673    }
674
675    /// Our `CaSlotInfo` must match the kernel's `struct ca_slot_info`
676    /// (`int num; int type; unsigned int flags;`) exactly, since the ioctl
677    /// writes it through a raw pointer and the request number encodes its size.
678    #[test]
679    fn ca_slot_info_matches_kernel_layout() {
680        assert_eq!(core::mem::size_of::<CaSlotInfo>(), KERNEL_CA_SLOT_INFO_SIZE);
681        assert_eq!(
682            core::mem::align_of::<CaSlotInfo>(),
683            KERNEL_CA_SLOT_INFO_ALIGN
684        );
685    }
686}