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}