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}