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; the
244        // driver waits that out via `reset_settle` on its own clock rather
245        // than this call blocking the pump (audit r10-O-10).
246        Ok(())
247    }
248
249    fn reset_settle(&self) -> Duration {
250        RESET_SETTLE
251    }
252
253    fn slot_info(&mut self) -> io::Result<SlotInfo> {
254        let mut si = CaSlotInfo {
255            num: i32::from(self.slot),
256            typ: 0,
257            flags: 0,
258        };
259        if let Err(err) = ioctl::ca_get_slot_info(self.file.as_fd(), &mut si) {
260            // r10-W-3: only fall back for the specific errno the "doesn't
261            // implement CA_GET_SLOT_INFO" drivers (DD/cxd2099) return —
262            // EINVAL, or ENOTTY for a device node that doesn't support the
263            // ioctl at all; presence then shows via the TPDU handshake
264            // instead, so assume present+ready. Any OTHER error (EIO,
265            // ENODEV, EBADF, …) is a real fault and must be propagated, not
266            // masked as "the module is present and ready".
267            return match err.raw_os_error() {
268                Some(libc::EINVAL) | Some(libc::ENOTTY) => Ok(SlotInfo {
269                    num: self.slot,
270                    module_ready: true,
271                    module_present: true,
272                }),
273                _ => Err(err),
274            };
275        }
276        Ok(SlotInfo {
277            num: si.num as u8,
278            module_ready: si.flags & CA_CI_MODULE_READY != 0,
279            module_present: si.flags & CA_CI_MODULE_PRESENT != 0,
280        })
281    }
282
283    fn poll(&mut self, timeout: Duration) -> io::Result<bool> {
284        poll_readable(&self.file, timeout)
285    }
286}
287
288/// A [`CiDataDevice`] backed by a Linux DVB CI TS data-plane device
289/// (`/dev/dvb/adapterN/ciM`). The host writes scrambled TS and reads the
290/// descrambled TS back; I/O is in whole 188-byte packets.
291#[derive(Debug)]
292pub struct LinuxCiDataDevice {
293    file: File,
294}
295
296impl LinuxCiDataDevice {
297    /// Open `path` **non-blocking** (`O_NONBLOCK`) as a CI data-plane device.
298    ///
299    /// # Why non-blocking (#1066)
300    /// The kernel `ts_read()` on the real device sleeps
301    /// (`wait_event_interruptible`) until >=188 bytes are available; it never
302    /// returns `0` to signal "no more data right now" the way a mock's queue
303    /// does. [`CaDescrambler::feed_ts`](crate::descrambler::CaDescrambler::feed_ts)
304    /// loops `read` until it sees `0` to drain whatever the CAM has already
305    /// produced — on a *blocking* fd, the first call that drains the queue
306    /// blocks forever on the next read instead of returning, and the caller
307    /// can never feed more TS to unblock it (a permanent hang). Opening
308    /// `O_NONBLOCK` makes an empty read return `EWOULDBLOCK` immediately,
309    /// which [`read`](Self::read) maps to `Ok(0)` — the same "no more data"
310    /// signal the mock already gives, so the drain loop terminates. See
311    /// `tests::read_on_empty_device_returns_immediately_not_blocking` below,
312    /// which drives this exact function over a real blocking-capable FIFO.
313    fn open_path(path: &Path) -> io::Result<Self> {
314        let file = OpenOptions::new()
315            .read(true)
316            .write(true)
317            .custom_flags(libc::O_NONBLOCK)
318            .open(path)?;
319        Ok(Self { file })
320    }
321
322    /// Open `/dev/dvb/adapter{adapter}/ci{ci}` — see `open_path`.
323    pub fn open(adapter: u32, ci: u32) -> io::Result<Self> {
324        let path = format!("/dev/dvb/adapter{adapter}/ci{ci}");
325        Self::open_path(Path::new(&path))
326    }
327
328    /// Wrap an already-open CI data-plane device file. The caller is
329    /// responsible for having opened it `O_NONBLOCK` — see
330    /// `open_path` for why a blocking fd here hangs
331    /// `feed_ts` (#1066).
332    #[must_use]
333    pub fn from_file(file: File) -> Self {
334        Self { file }
335    }
336}
337
338impl CiDataDevice for LinuxCiDataDevice {
339    fn write(&mut self, ts: &[u8]) -> io::Result<()> {
340        if !ts.len().is_multiple_of(TS_PACKET_LEN) {
341            return Err(io::Error::new(
342                io::ErrorKind::InvalidInput,
343                "write not a multiple of 188 bytes",
344            ));
345        }
346        self.file.write_all(ts)
347    }
348
349    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
350        if !buf.len().is_multiple_of(TS_PACKET_LEN) {
351            return Err(io::Error::new(
352                io::ErrorKind::InvalidInput,
353                "read buffer not a multiple of 188 bytes",
354            ));
355        }
356        match self.file.read(buf) {
357            Ok(n) => Ok(n),
358            Err(e) if e.kind() == io::ErrorKind::WouldBlock => Ok(0),
359            Err(e) => Err(e),
360        }
361    }
362
363    fn poll(&mut self, timeout: Duration) -> io::Result<bool> {
364        poll_readable(&self.file, timeout)
365    }
366}
367
368#[cfg(test)]
369mod tests {
370    use super::*;
371    use std::sync::mpsc;
372
373    /// Create a fresh FIFO (named pipe) under the OS temp dir and return its
374    /// path. `mkfifo` + a FIFO's blocking-read semantics are POSIX/Linux —
375    /// matching why this whole module compiles only under
376    /// `target_os = "linux"` (crate doc) — and, unlike a Unix socketpair
377    /// wrapped in a test-defined `CiDataDevice` (flagged in review as not
378    /// biting), opening the FIFO through `LinuxCiDataDevice::open_path`
379    /// drives the actual production `open`/`read` code under test.
380    fn make_fifo() -> std::path::PathBuf {
381        let path = std::env::temp_dir().join(format!(
382            "dvb-ci-runtime-c1-1066-{}-{}",
383            std::process::id(),
384            std::time::SystemTime::now()
385                .duration_since(std::time::UNIX_EPOCH)
386                .expect("system clock before UNIX_EPOCH")
387                .as_nanos()
388        ));
389        rustix::fs::mkfifoat(
390            rustix::fs::CWD,
391            &path,
392            rustix::fs::Mode::RUSR | rustix::fs::Mode::WUSR,
393        )
394        .expect("mkfifoat failed");
395        path
396    }
397
398    /// #1066: `open_path` must open `O_NONBLOCK`, so an empty read
399    /// returns `Ok(0)` promptly instead of blocking forever — the real bug
400    /// was `CaDescrambler::feed_ts`'s drain loop hanging on its second
401    /// `read`. A FIFO opened `O_RDWR` blocks on an empty read exactly like
402    /// the real `ciM` character device unless `O_NONBLOCK` is set, so this
403    /// is a faithful (if not identical) stand-in, unlike a socketpair with
404    /// its own test-defined `CiDataDevice` impl, which never calls the
405    /// production `open`/`read` at all.
406    ///
407    /// Removing `.custom_flags(libc::O_NONBLOCK)` from `open_path` makes the
408    /// first `recv_timeout` below time out. Verified by hand: with that line
409    /// removed, `cargo test -p dvb-ci-runtime --all-features --locked
410    /// --target x86_64-unknown-linux-gnu` cannot be *run* on macOS at all
411    /// (this module only exists under `target_os = "linux"`, and macOS
412    /// cannot execute a Linux binary), so the removal was checked instead
413    /// with `cargo test -p dvb-ci-runtime --all-features --locked --target
414    /// x86_64-unknown-linux-gnu --no-run` (compiles either way) plus the
415    /// cross-clippy command from CLAUDE.md (lints clean either way — clippy
416    /// cannot see that the missing flag is a bug); real execution proof of
417    /// this specific assertion is Linux CI, which runs this test on every
418    /// push.
419    #[test]
420    fn read_on_empty_device_returns_immediately_not_blocking() {
421        let path = make_fifo();
422        let mut dev = LinuxCiDataDevice::open_path(&path).expect("open_path");
423        let mut writer = OpenOptions::new()
424            .write(true)
425            .open(&path)
426            .expect("open a second, writer-only handle on the same FIFO");
427
428        let (tx, rx) = mpsc::channel();
429        let (tx_go, rx_go) = mpsc::channel::<()>();
430        std::thread::spawn(move || {
431            let mut buf = [0u8; TS_PACKET_LEN];
432            let r0 = dev.read(&mut buf).map(|n| (n, buf));
433            if tx.send(r0).is_err() {
434                return;
435            }
436            if rx_go.recv().is_err() {
437                return;
438            }
439
440            let r1 = dev.read(&mut buf).map(|n| (n, buf));
441            if tx.send(r1).is_err() {
442                return;
443            }
444            if rx_go.recv().is_err() {
445                return;
446            }
447
448            let r2 = dev.read(&mut buf).map(|n| (n, buf));
449            let _ = tx.send(r2);
450        });
451
452        // 1: empty device (writer end open, no data written yet) — must
453        // return `Ok(0)` promptly, not block.
454        let first = rx.recv_timeout(Duration::from_secs(1));
455        let Ok(Ok((n0, _))) = first else {
456            // The reader thread may be stuck in a blocking `read`; write a
457            // byte so it can still unblock and exit, then fail.
458            let _ = writer.write_all(&[0u8; TS_PACKET_LEN]);
459            panic!(
460                "empty-device read did not return promptly (blocked past 1s) — \
461                 got {first:?}; this is the #1066 hang O_NONBLOCK fixes"
462            );
463        };
464        assert_eq!(n0, 0, "empty device must read 0 bytes");
465
466        // 2: write one full packet BEFORE letting the reader proceed, so
467        // there is no race between the write and the second `read`.
468        let mut packet = [0xAAu8; TS_PACKET_LEN];
469        packet[0] = 0x47; // MPEG-2 TS sync byte — realism only, not checked by read().
470        writer.write_all(&packet).expect("write one packet");
471        tx_go.send(()).expect("reader thread must still be alive");
472
473        let (n1, buf1) = rx
474            .recv_timeout(Duration::from_secs(1))
475            .expect("reader thread must respond promptly")
476            .expect("read must not error");
477        assert_eq!(n1, TS_PACKET_LEN, "must read exactly one packet's worth");
478        assert_eq!(
479            &buf1[..n1],
480            &packet[..],
481            "must read back the exact bytes written"
482        );
483
484        // 3: drained again — back to `Ok(0)`, not blocking.
485        tx_go.send(()).expect("reader thread must still be alive");
486        let (n2, _) = rx
487            .recv_timeout(Duration::from_secs(1))
488            .expect("empty-again read did not return promptly (blocked past 1s)")
489            .expect("read must not error");
490        assert_eq!(n2, 0, "device must read 0 again once drained");
491
492        let _ = std::fs::remove_file(&path);
493    }
494
495    /// r10-W-2: a kernel frame that exactly fills [`MAX_CA_FRAME`] must be
496    /// reported as an error (possible truncation), not silently accepted as
497    /// a complete TPDU. A regular file lets us stage exactly that many bytes
498    /// without needing real CI hardware.
499    #[test]
500    fn read_rejects_a_frame_that_fills_the_scratch_buffer() {
501        let path = std::env::temp_dir().join(format!(
502            "dvb-ci-runtime-w2-fullframe-{}-{}",
503            std::process::id(),
504            std::time::SystemTime::now()
505                .duration_since(std::time::UNIX_EPOCH)
506                .expect("system clock before UNIX_EPOCH")
507                .as_nanos()
508        ));
509        std::fs::write(&path, vec![0xAAu8; MAX_CA_FRAME]).expect("stage a MAX_CA_FRAME-byte file");
510        let file = OpenOptions::new()
511            .read(true)
512            .write(true)
513            .open(&path)
514            .expect("open the staged file");
515        let mut dev = LinuxCaDevice::from_file(file, 0);
516        let mut buf = [0u8; 8192];
517        let err = dev
518            .read(&mut buf)
519            .expect_err("a full-buffer read must error");
520        assert_eq!(err.kind(), io::ErrorKind::InvalidData);
521        let _ = std::fs::remove_file(&path);
522    }
523
524    /// Sanity: an ordinary short frame still reads through unaffected.
525    #[test]
526    fn read_still_returns_short_frames_normally() {
527        let path = std::env::temp_dir().join(format!(
528            "dvb-ci-runtime-w2-shortframe-{}-{}",
529            std::process::id(),
530            std::time::SystemTime::now()
531                .duration_since(std::time::UNIX_EPOCH)
532                .expect("system clock before UNIX_EPOCH")
533                .as_nanos()
534        ));
535        // link header (slot, connection_id) + a 3-byte "TPDU".
536        std::fs::write(&path, [0x00, 0x01, 0xDE, 0xAD, 0xBE]).expect("stage a short file");
537        let file = OpenOptions::new()
538            .read(true)
539            .write(true)
540            .open(&path)
541            .expect("open the staged file");
542        let mut dev = LinuxCaDevice::from_file(file, 0);
543        let mut buf = [0u8; 16];
544        let n = dev.read(&mut buf).expect("short read must succeed");
545        assert_eq!(n, 3);
546        assert_eq!(&buf[..3], &[0xDE, 0xAD, 0xBE]);
547        let _ = std::fs::remove_file(&path);
548    }
549
550    /// r10-W-3: `CA_GET_SLOT_INFO` returning `ENOTTY` (the driver — or, here,
551    /// a plain regular file standing in for one that doesn't support the
552    /// ioctl at all — has no CI-slot ioctl handling) must still fall back to
553    /// "present + ready", exactly as the documented EINVAL fallback did. A
554    /// regular file is a faithful stand-in: `ioctl` on a non-device fd
555    /// reliably returns `ENOTTY` on Linux, so this exercises the real
556    /// `libc::ioctl` call and its errno branch, not a mocked one.
557    #[test]
558    fn slot_info_falls_back_on_enotty() {
559        let path = std::env::temp_dir().join(format!(
560            "dvb-ci-runtime-w3-slotinfo-{}-{}",
561            std::process::id(),
562            std::time::SystemTime::now()
563                .duration_since(std::time::UNIX_EPOCH)
564                .expect("system clock before UNIX_EPOCH")
565                .as_nanos()
566        ));
567        let file = OpenOptions::new()
568            .read(true)
569            .write(true)
570            .create(true)
571            .truncate(true)
572            .open(&path)
573            .expect("create a regular file to stand in for the CA device");
574        let mut dev = LinuxCaDevice::from_file(file, 3);
575        let info = dev.slot_info().expect("ENOTTY must fall back, not error");
576        assert_eq!(info.num, 3);
577        assert!(info.module_present);
578        assert!(info.module_ready);
579        let _ = std::fs::remove_file(&path);
580    }
581    fn poll_file(file: &File, timeout: Duration) -> io::Result<bool> {
582        poll_readable(file, timeout)
583    }
584
585    /// `poll_readable` on an empty FIFO with a short timeout reports `false`
586    /// after about that long; with data it reports `true` immediately.
587    #[test]
588    fn poll_readable_reports_data_and_honours_the_timeout() {
589        let path = make_fifo();
590        let mut dev = LinuxCiDataDevice::open_path(&path).expect("open_path");
591        let mut writer = OpenOptions::new().write(true).open(&path).expect("writer");
592
593        let start = std::time::Instant::now();
594        assert!(
595            !dev.poll(Duration::from_millis(80)).unwrap(),
596            "empty FIFO is not readable"
597        );
598        assert!(
599            start.elapsed() >= Duration::from_millis(70),
600            "poll returned before its timeout"
601        );
602
603        writer.write_all(&[0x47; TS_PACKET_LEN]).unwrap();
604        let start = std::time::Instant::now();
605        assert!(
606            dev.poll(Duration::from_secs(5)).unwrap(),
607            "data written: readable"
608        );
609        assert!(
610            start.elapsed() < Duration::from_secs(4),
611            "must return as soon as readable"
612        );
613
614        let _ = std::fs::remove_file(&path);
615    }
616
617    /// A zero timeout never blocks.
618    #[test]
619    fn poll_readable_zero_timeout_does_not_block() {
620        let path = make_fifo();
621        let mut dev = LinuxCiDataDevice::open_path(&path).expect("open_path");
622        let _writer = OpenOptions::new().write(true).open(&path).expect("writer");
623        let start = std::time::Instant::now();
624        assert!(!dev.poll(Duration::ZERO).unwrap());
625        assert!(start.elapsed() < Duration::from_millis(500));
626        let _ = std::fs::remove_file(&path);
627    }
628
629    /// Sub-millisecond timeouts used to truncate to `poll(.., 0)` (returning
630    /// at once); they must now sleep their true duration and never error.
631    #[test]
632    fn poll_readable_sub_millisecond_timeout_keeps_its_duration() {
633        let path = make_fifo();
634        let mut dev = LinuxCiDataDevice::open_path(&path).expect("open_path");
635        let _writer = OpenOptions::new().write(true).open(&path).expect("writer");
636        let start = std::time::Instant::now();
637        assert!(!dev.poll(Duration::from_micros(300)).unwrap());
638        assert!(
639            start.elapsed() >= Duration::from_micros(250),
640            "a 300 us timeout was truncated: returned after {:?}",
641            start.elapsed()
642        );
643        let _ = std::fs::remove_file(&path);
644    }
645
646    /// Hang-up without data (writer closed) is not "readable" for this API:
647    /// the old code tested `revents & POLLIN` only. The device itself opens
648    /// the FIFO `O_RDWR` (it is its own writer, so no hang-up ever shows), so
649    /// this drives `poll_readable` on a separate read-only descriptor, and
650    /// proves with a raw `rustix::event::poll` that the kernel really reports POLLHUP
651    /// there (otherwise the assertion would be vacuous).
652    #[test]
653    fn poll_readable_ignores_hangup_without_data() {
654        let path = make_fifo();
655        let reader = OpenOptions::new()
656            .read(true)
657            .custom_flags(libc::O_NONBLOCK)
658            .open(&path)
659            .expect("read-only O_NONBLOCK open");
660        let writer = OpenOptions::new().write(true).open(&path).expect("writer");
661        drop(writer); // POLLHUP on the read end, no POLLIN, no data
662
663        let mut raw = [PollFd::new(&reader, PollFlags::IN)];
664        let zero = Timespec {
665            tv_sec: 0,
666            tv_nsec: 0,
667        };
668        let r = rustix::event::poll(&mut raw, Some(&zero)).expect("poll");
669        assert_eq!(r, 1, "kernel must report an event");
670        let revents = raw[0].revents();
671        assert!(
672            revents.contains(PollFlags::HUP) && !revents.contains(PollFlags::IN),
673            "precondition: POLLHUP without POLLIN, got revents={revents:?}"
674        );
675
676        assert!(!poll_file(&reader, Duration::from_millis(50)).unwrap());
677        let _ = std::fs::remove_file(&path);
678    }
679
680    /// Our `CaSlotInfo` must match the kernel's `struct ca_slot_info`
681    /// (`int num; int type; unsigned int flags;`) exactly, since the ioctl
682    /// writes it through a raw pointer and the request number encodes its size.
683    #[test]
684    fn ca_slot_info_matches_kernel_layout() {
685        assert_eq!(core::mem::size_of::<CaSlotInfo>(), KERNEL_CA_SLOT_INFO_SIZE);
686        assert_eq!(
687            core::mem::align_of::<CaSlotInfo>(),
688            KERNEL_CA_SLOT_INFO_ALIGN
689        );
690    }
691}