[RFC PATCH 7/7] drm/vino: add KUnit self-tests for the protocol and crypto paths

From: Mike Lothian

Date: Wed Jun 17 2026 - 11:28:01 EST


Add offline KUnit self-tests for the pure protocol builders/parsers and
the crypto bindings the control plane relies on. The crypto cases are
published known-answer vectors (FIPS-197 AES-128, RFC 4493 AES-CMAC) and
a live seal round-trip; the rest pin wire layout, EDID timing extraction,
the WHT codec stages and the DDC/CI Set-VCP encoding that have no hardware
oracle. Gated behind CONFIG_KUNIT, so they have zero effect on a
production build; run with a KUnit-enabled kernel.

Signed-off-by: Mike Lothian <mike@xxxxxxxxxxxxxx>
Assisted-by: Claude:claude-opus-4-8 [Claude-Code]
---
drivers/gpu/drm/vino/vino.rs | 299 +++++++++++++++++++++++++++++++++++
1 file changed, 299 insertions(+)

diff --git a/drivers/gpu/drm/vino/vino.rs b/drivers/gpu/drm/vino/vino.rs
index ee63ce7e4625..2d22c3f822cd 100644
--- a/drivers/gpu/drm/vino/vino.rs
+++ b/drivers/gpu/drm/vino/vino.rs
@@ -1752,3 +1752,302 @@ fn disconnect<'bound>(intf: &'bound usb::Interface<Core<'_>>, _data: Pin<&Self>)
description: "DisplayLink DL3 (Vino) open driver",
license: "GPL v2",
}
+
+/// Build a minimal valid 128-byte EDID with a 1920x1080@60 detailed timing at base-block
+/// offset `dtd_at` (54 = preferred slot), a correct checksum, and the standard magic.
+#[cfg(CONFIG_KUNIT = "y")]
+fn mk_test_edid(dtd_at: usize) -> [u8; 128] {
+ let mut e = [0u8; 128];
+ e[..8].copy_from_slice(&[0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00]);
+ // 1920x1080@60: pclk 14850 (148.5 MHz, 10 kHz units); hblank 280, vblank 45;
+ // hsync_front 88, hsync_width 44, vsync_front 4, vsync_width 5.
+ let dtd: [u8; 18] = [
+ 0x02, 0x3a, // pixel clock 0x3a02 LE
+ 0x80, 0x18, 0x71, // hactive 1920 / hblank 280 (high nibbles in byte 4)
+ 0x38, 0x2d, 0x40, // vactive 1080 / vblank 45 (high nibbles in byte 7)
+ 0x58, 0x2c, 0x45, 0x00, // hsync/vsync front+width
+ 0, 0, 0, 0, 0, 0, // trailing flags (DTD is 18 bytes total)
+ ];
+ e[dtd_at..dtd_at + 18].copy_from_slice(&dtd);
+ let s = e[..127].iter().fold(0u8, |a, &b| a.wrapping_add(b));
+ e[127] = 0u8.wrapping_sub(s); // base-block checksum: all 128 bytes sum to 0
+ e
+}
+
+/// Offline self-tests for the pure protocol builders/parsers and the crypto bindings the
+/// control plane relies on. Gated behind `CONFIG_KUNIT` (the macro adds the cfg), so they
+/// have zero effect on a production build; run with a KUnit-enabled kernel. The crypto cases
+/// are published known-answer vectors (FIPS-197 AES-128, RFC 4493 AES-CMAC); the seal case is
+/// a live round-trip; the rest pin wire layout and EDID parsing that have no hardware oracle.
+#[kunit_tests(vino_protocol)]
+mod tests {
+ use super::*;
+ use kernel::error::code::EINVAL;
+
+ #[test]
+ fn aes128_ecb_fips197_kat() -> Result {
+ // FIPS-197 / NIST SP800-38A F.1.1 AES-128 ECB known-answer vector.
+ let key = [
+ 0x2b, 0x7e, 0x15, 0x16, 0x28, 0xae, 0xd2, 0xa6, 0xab, 0xf7, 0x15, 0x88, 0x09, 0xcf,
+ 0x4f, 0x3c,
+ ];
+ let pt = [
+ 0x6b, 0xc1, 0xbe, 0xe2, 0x2e, 0x40, 0x9f, 0x96, 0xe9, 0x3d, 0x7e, 0x11, 0x73, 0x93,
+ 0x17, 0x2a,
+ ];
+ assert_eq!(
+ crypto::aes128_ecb(&key, &pt)?,
+ [
+ 0x3a, 0xd7, 0x7b, 0xb4, 0x0d, 0x7a, 0x36, 0x60, 0xa8, 0x9e, 0xca, 0xf3, 0x24, 0x66,
+ 0xef, 0x97,
+ ]
+ );
+ Ok(())
+ }
+
+ #[test]
+ fn aes_cmac_rfc4493_kat() -> Result {
+ // RFC 4493 sec 4 AES-CMAC test vectors (same key as above).
+ let key = [
+ 0x2b, 0x7e, 0x15, 0x16, 0x28, 0xae, 0xd2, 0xa6, 0xab, 0xf7, 0x15, 0x88, 0x09, 0xcf,
+ 0x4f, 0x3c,
+ ];
+ assert_eq!(
+ crypto::aes_cmac(&key, &[])?,
+ [
+ 0xbb, 0x1d, 0x69, 0x29, 0xe9, 0x59, 0x37, 0x28, 0x7f, 0xa3, 0x7d, 0x12, 0x9b, 0x75,
+ 0x67, 0x46,
+ ]
+ );
+ let msg = [
+ 0x6b, 0xc1, 0xbe, 0xe2, 0x2e, 0x40, 0x9f, 0x96, 0xe9, 0x3d, 0x7e, 0x11, 0x73, 0x93,
+ 0x17, 0x2a,
+ ];
+ assert_eq!(
+ crypto::aes_cmac(&key, &msg)?,
+ [
+ 0x07, 0x0a, 0x16, 0xb4, 0x6b, 0x4d, 0x41, 0x44, 0xf7, 0x9b, 0xdd, 0x9d, 0xd0, 0x4a,
+ 0x28, 0x7c,
+ ]
+ );
+ Ok(())
+ }
+
+ #[test]
+ fn seal_livemac_roundtrip() -> Result {
+ // A sealed CP frame must decrypt back to its content under the IN riv, and its
+ // appended tag must equal a fresh Dl3Cmac over the ciphertext (encrypt-then-MAC).
+ let ks = [
+ 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd,
+ 0xee, 0xff,
+ ];
+ let riv = [0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17];
+ let content = [0xa5u8; 32];
+ let mut hdr = [0u8; 16];
+ hdr[12..16].copy_from_slice(&4u32.to_le_bytes()); // wire_seq = 4
+ let frame = cp::seal_livemac(&ks, &riv, &hdr, &content)?;
+ assert_eq!(frame.len(), 16 + 32 + 16);
+ let ct = &frame[16..16 + 32];
+ assert_eq!(&cp::open_in(&ks, &cp::in_riv(&riv), 4, ct)?[..], &content[..]);
+ assert_eq!(&frame[16 + 32..], &cp::dl3cmac_tag(&ks, &riv, 4, ct)?[..]);
+ Ok(())
+ }
+
+ #[test]
+ fn aux_for_id_constants() {
+ // The CP header `aux` field is a per-inner-id constant, not body_len/4.
+ assert_eq!(cp::aux_for_id(0x14, 48), 0x0a);
+ assert_eq!(cp::aux_for_id(0x15, 32), 0x09);
+ assert_eq!(cp::aux_for_id(0x48, 96), 0x06);
+ assert_eq!(cp::aux_for_id(0x99, 40), 10); // unknown id falls back to body_len/4
+ }
+
+ #[test]
+ fn edid_timing_parse_and_validate() {
+ // A well-formed EDID yields the DTD timing; a bad checksum is rejected; a leading
+ // monitor descriptor (pclk 0) does not hide the preferred timing in a later slot.
+ let edid = mk_test_edid(54);
+ let t = cp::timing_from_edid(&edid).expect("valid EDID parses");
+ assert_eq!(t.hactive, 1920);
+ assert_eq!(t.vactive, 1080);
+ assert_eq!(t.refresh_hz, 60);
+ assert_eq!(t.pixel_clock_10khz, 14850);
+
+ let mut bad = edid;
+ bad[127] ^= 0xff;
+ assert!(cp::timing_from_edid(&bad).is_none(), "bad checksum rejected");
+
+ let scanned = mk_test_edid(72); // off54 left as a zero (monitor) descriptor
+ assert_eq!(
+ cp::timing_from_edid(&scanned).expect("scans past off54").hactive,
+ 1920
+ );
+ }
+
+ #[test]
+ fn edid_reply_guards() -> Result {
+ // The pre-decrypt guards reject non-EDID frames without touching the cipher.
+ let ks = [0u8; 16];
+ let riv = [0u8; 8];
+ assert!(cp::parse_edid_from_reply(&ks, &riv, &[0u8; 10])?.is_none());
+ let mut wrong_sub = [0u8; 20];
+ wrong_sub[8] = 0x44; // wire sub != 0x45
+ assert!(cp::parse_edid_from_reply(&ks, &riv, &wrong_sub)?.is_none());
+ Ok(())
+ }
+
+ #[test]
+ fn rgb565_packing() {
+ assert_eq!(video::rgb565(0xff, 0x00, 0x00), 0xf800);
+ assert_eq!(video::rgb565(0x00, 0xff, 0x00), 0x07e0);
+ assert_eq!(video::rgb565(0x00, 0x00, 0xff), 0x001f);
+ let _ = EINVAL; // silence unused import on configs without the assert paths
+ }
+
+ #[test]
+ fn cursor_messages_structure() -> Result {
+ // Create: id=0x1b sub=0x42, `00 02 00` marker + w,h at off20.
+ let c = cp::cursor_create(7, 64, 64)?;
+ assert_eq!(c.len(), 27);
+ assert_eq!(&c[0..6], &[0x1b, 0x00, 0x42, 0x00, 0x07, 0x00]); // id, sub, counter (LE)
+ assert_eq!(&c[20..23], &[0x00, 0x02, 0x00]); // marker
+ assert_eq!(u16::from_le_bytes([c[23], c[24]]), 64); // width
+ assert_eq!(u16::from_le_bytes([c[25], c[26]]), 64); // height
+
+ // Move: id=0x1a sub=0x43, head@22, flag@23, X@24, Y@26 (LE).
+ let m = cp::cursor_move(9, 1, 0x0140, 0x00f0)?;
+ assert_eq!(m.len(), 28);
+ assert_eq!(&m[0..4], &[0x1a, 0x00, 0x43, 0x00]); // id, sub
+ assert_eq!(m[22], 1); // head id
+ assert_eq!(u16::from_le_bytes([m[24], m[25]]), 0x0140); // X
+ assert_eq!(u16::from_le_bytes([m[26], m[27]]), 0x00f0); // Y
+
+ // Image: create-style 27-byte header + w*h*4 BGRA bitmap; wrong-size input rejected.
+ let bitmap = KVec::from_elem(0xabu8, 64 * 64 * 4, GFP_KERNEL)?;
+ let img = cp::cursor_image(3, 64, 64, &bitmap)?;
+ assert_eq!(img.len(), 27 + 64 * 64 * 4);
+ assert_eq!(&img[0..4], &[0x1c, 0x00, 0x41, 0x00]); // id, sub
+ assert_eq!(img[27], 0xab); // bitmap begins right after the 27-byte header
+ assert!(cp::cursor_image(3, 64, 64, &[0u8; 16]).is_err()); // wrong bitmap length
+ Ok(())
+ }
+
+ #[test]
+ fn timing_from_drm_mode_1080p60() {
+ // CEA 1920x1080@60: clock 148.5 MHz, h 2008/2052/2200, v 1084/1089/1125.
+ let mut m = bindings::drm_display_mode::default();
+ m.clock = 148_500; // kHz
+ m.hdisplay = 1920;
+ m.hsync_start = 2008;
+ m.hsync_end = 2052;
+ m.htotal = 2200;
+ m.vdisplay = 1080;
+ m.vsync_start = 1084;
+ m.vsync_end = 1089;
+ m.vtotal = 1125;
+ // SAFETY: `m` is a fully-initialised local drm_display_mode.
+ let t = unsafe { cp::timing_from_drm_mode(&m) };
+ assert_eq!(t.hactive, 1920);
+ assert_eq!(t.hblank, 280); // htotal - hdisplay
+ assert_eq!(t.hsync_front, 88); // hsync_start - hdisplay
+ assert_eq!(t.hsync_width, 44); // hsync_end - hsync_start
+ assert_eq!(t.vactive, 1080);
+ assert_eq!(t.vblank, 45); // vtotal - vdisplay
+ assert_eq!(t.vsync_front, 4);
+ assert_eq!(t.vsync_width, 5);
+ assert_eq!(t.pixel_clock_10khz, 14_850); // clock(kHz) / 10
+ assert_eq!(t.refresh_hz, 60); // via drm_mode_vrefresh
+ }
+
+ #[test]
+ fn rotation_pixel_mapping() {
+ use bindings::{
+ DRM_MODE_REFLECT_X, DRM_MODE_ROTATE_0, DRM_MODE_ROTATE_180, DRM_MODE_ROTATE_270,
+ DRM_MODE_ROTATE_90,
+ };
+ // Source 2x3 (sw=2, sh=3). 0deg is identity; 180deg mirrors both axes.
+ assert_eq!(drm_sink::rot_src(DRM_MODE_ROTATE_0, 0, 0, 2, 3), (0, 0));
+ assert_eq!(drm_sink::rot_src(DRM_MODE_ROTATE_0, 1, 2, 2, 3), (1, 2));
+ assert_eq!(drm_sink::rot_src(DRM_MODE_ROTATE_180, 0, 0, 2, 3), (1, 2));
+ assert_eq!(drm_sink::rot_src(DRM_MODE_ROTATE_180, 1, 2, 2, 3), (0, 0));
+ // 90deg: output dims are (sh,sw)=(3,2); (dx,dy) -> (dy, sh-1-dx).
+ assert_eq!(drm_sink::rot_src(DRM_MODE_ROTATE_90, 0, 0, 2, 3), (0, 2));
+ assert_eq!(drm_sink::rot_src(DRM_MODE_ROTATE_90, 2, 1, 2, 3), (1, 0));
+ // 270deg: (dx,dy) -> (sw-1-dy, dx).
+ assert_eq!(drm_sink::rot_src(DRM_MODE_ROTATE_270, 0, 0, 2, 3), (1, 0));
+ assert_eq!(drm_sink::rot_src(DRM_MODE_ROTATE_270, 2, 1, 2, 3), (0, 2));
+ // Reflect-X composes on top of the rotation (here identity): sx -> sw-1-sx.
+ assert_eq!(drm_sink::rot_src(DRM_MODE_ROTATE_0 | DRM_MODE_REFLECT_X, 0, 0, 2, 3), (1, 0));
+ }
+
+ #[test]
+ fn wht_colour_and_quantize() {
+ use video::wht;
+ // Exact colour transform: white -> Y=16320, achromatic -> Cb=Cr=0.
+ assert_eq!(wht::colour(255, 255, 255), (16320, 0, 0));
+ assert_eq!(wht::colour(128, 128, 128), (128 * 64, 0, 0)); // gray: chroma zero
+ assert_eq!(wht::colour(255, 0, 0), (16 * 255, 64 * 255, 0)); // red: Cb>0, Cr=0
+ // The documented ground-truth vector: white Y_DC=16320 quantizes (DC, position 0) to 1020.
+ assert_eq!(wht::quantize(16320, 0), 1020);
+ // AC clamps to the 12-bit signed long-token range.
+ assert_eq!(wht::quantize(1_000_000, 16), 2047);
+ assert_eq!(wht::quantize(-1_000_000, 16), -2048);
+ }
+
+ #[test]
+ fn wht_transform_uniform() {
+ use video::wht;
+ // A uniform block: DC = the per-pixel value, every AC coefficient = 0 (VIDEO.md invariant).
+ let block = [16320i32; wht::BLOCK];
+ let c = wht::transform(&block);
+ assert_eq!(c[0], 16320); // DC = mean = the uniform value
+ assert!(c[1..].iter().all(|&x| x == 0)); // AC all zero
+ // End-to-end: white pixel -> Y plane -> WHT DC -> quantize -> 1020.
+ let (y, _, _) = wht::colour(255, 255, 255);
+ assert_eq!(wht::quantize(wht::transform(&[y; wht::BLOCK])[0], 0), 1020);
+ }
+
+ #[test]
+ fn wht_token_bitstream() -> Result {
+ use video::wht::TokenWriter;
+ // 16-bit zero pad, then short tokens 5 (00101) and 30 (11110), zero-padded to a byte:
+ // 0000000000000000 00101 11110 000000 = 0x00 0x00 0x2F 0x80.
+ let mut w = TokenWriter::new()?;
+ w.token(5)?;
+ w.token(30)?;
+ assert_eq!(&w.finish()?[..], &[0x00, 0x00, 0x2f, 0x80]);
+ // A value > 30 escapes to a 17-bit long token: 16 pad + 17 + byte-pad = 5 bytes.
+ let mut w = TokenWriter::new()?;
+ w.token(100)?;
+ assert_eq!(w.finish()?.len(), 5);
+ Ok(())
+ }
+
+ #[test]
+ fn ddc_ci_set_vcp_checksum() {
+ // VESA DDC/CI 1.1 sec 4.4 worked example: Set brightness (VCP 0x10) to 50 (0x0032).
+ // Bytes after the 0x6e write address: 51 84 03 10 00 32, checksum = XOR incl. 0x6e.
+ let p = cp::ddc_ci_set_vcp(cp::VCP_BRIGHTNESS, 50);
+ assert_eq!(&p[..6], &[0x51, 0x84, 0x03, 0x10, 0x00, 0x32]);
+ let want = 0x6e ^ 0x51 ^ 0x84 ^ 0x03 ^ 0x10 ^ 0x00 ^ 0x32;
+ assert_eq!(p[6], want);
+ // The checksum makes the XOR of {dest, source, len, opcode, vcp, hi, lo, chk} zero.
+ assert_eq!(0x6eu8 ^ p.iter().fold(0u8, |a, &b| a ^ b), 0);
+ // Contrast (0x12) and the power VCP (0xd6 = off) carry their codes/values verbatim.
+ assert_eq!(cp::ddc_ci_set_vcp(cp::VCP_CONTRAST, 0x0140)[3..6], [0x12, 0x01, 0x40]);
+ assert_eq!(cp::ddc_ci_set_vcp(cp::VCP_POWER_MODE, cp::POWER_OFF)[3..6], [0xd6, 0x00, 0x04]);
+ }
+
+ #[test]
+ fn ddc_set_vcp_message_structure() -> Result {
+ // CP wrapper: id=0x15 sub=0x22, counter (LE) at off4, I2C slave 0x37 + len 7 at off20,
+ // the 7-byte DDC/CI Set-VCP payload at off22, padded to a 32-byte block.
+ let m = cp::ddc_set_vcp(0x11, cp::VCP_BRIGHTNESS, 75)?;
+ assert_eq!(m.len(), 32);
+ assert_eq!(&m[0..6], &[0x15, 0x00, 0x22, 0x00, 0x11, 0x00]); // id, sub, counter (LE)
+ assert_eq!(&m[20..22], &[0x37, 7]); // monitor DDC/CI I2C slave + payload length
+ assert_eq!(&m[22..29], &cp::ddc_ci_set_vcp(cp::VCP_BRIGHTNESS, 75)); // DDC/CI payload
+ assert_eq!(&m[29..32], &[0, 0, 0]); // block padding
+ Ok(())
+ }
+}
--
2.54.0