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