Skip to main content

spvirit_codec/
epics_decode.rs

1// Refer to https://github.com/mdavidsaver/cashark/blob/master/pva.lua
2
3// Lookup table for PVA commands
4// -- application messages
5
6use hex;
7use std::fmt;
8use tracing::debug;
9
10use crate::error::DecodeResult;
11use crate::spvd_decode::{DecodedValue, PvdDecoder, StructureDesc, format_compact_value};
12use crate::spvirit_encode::format_pva_address;
13
14/// Single source of truth for PVA application command codes.
15///
16/// Index == command code.  Any code beyond the table returns `"Unknown"`.
17const PVA_COMMAND_NAMES: &[&str] = &[
18    "BEACON",                // 0
19    "CONNECTION_VALIDATION", // 1
20    "ECHO",                  // 2
21    "SEARCH",                // 3
22    "SEARCH_RESPONSE",       // 4
23    "AUTHNZ",                // 5
24    "ACL_CHANGE",            // 6
25    "CREATE_CHANNEL",        // 7
26    "DESTROY_CHANNEL",       // 8
27    "CONNECTION_VALIDATED",  // 9
28    "GET",                   // 10
29    "PUT",                   // 11
30    "PUT_GET",               // 12
31    "MONITOR",               // 13
32    "ARRAY",                 // 14
33    "DESTROY_REQUEST",       // 15
34    "PROCESS",               // 16
35    "GET_FIELD",             // 17
36    "MESSAGE",               // 18
37    "MULTIPLE_DATA",         // 19
38    "RPC",                   // 20
39    "CANCEL_REQUEST",        // 21
40    "ORIGIN_TAG",            // 22
41];
42
43/// Look up a PVA command name by its numeric code.
44pub fn command_name(code: u8) -> &'static str {
45    PVA_COMMAND_NAMES
46        .get(code as usize)
47        .copied()
48        .unwrap_or("Unknown")
49}
50
51/// Look up a PVA command code by its name.  Returns 255 for unknown names.
52pub fn command_to_integer(command: &str) -> u8 {
53    PVA_COMMAND_NAMES
54        .iter()
55        .position(|&name| name == command)
56        .map(|i| i as u8)
57        .unwrap_or(255)
58}
59
60/// Convenience wrapper that matches the pre-existing `PvaCommands` API.
61/// Prefer calling [`command_name`] directly for new code.
62#[derive(Debug)]
63pub struct PvaCommands;
64
65impl PvaCommands {
66    pub fn new() -> Self {
67        Self
68    }
69
70    pub fn get_command(&self, code: u8) -> &'static str {
71        command_name(code)
72    }
73}
74#[derive(Debug)]
75pub struct PvaControlFlags {
76    pub raw: u8,
77    // bits 0 is specifies application or control message (0 or 1 resprectively)
78    // bits 1,2,3, must always be zero
79    // bits 5 and 4 specify if the message is segmented 00 = not segmented, 01 = first segment, 10 = last segment, 11 = in-the-middle segment
80    // bit 6 specifies the direction of the message (0 = client, 1 = server)
81    // bit 7 specifies the byte order (0 = LSB, 1 = MSB)
82    pub is_application: bool,
83    pub is_control: bool,
84    pub is_segmented: u8,
85    pub is_first_segment: bool,
86    pub is_last_segment: bool,
87    pub is_middle_segment: bool,
88    pub is_client: bool,
89    pub is_server: bool,
90    pub is_lsb: bool,
91    pub is_msb: bool,
92    pub is_valid: bool,
93}
94
95impl PvaControlFlags {
96    pub fn new(raw: u8) -> Self {
97        let is_application = (raw & 0x01) == 0; // Bit 0: 0 for application, 1 for control
98        let is_control = (raw & 0x01) != 0; // Bit 0: 1 for control
99        let is_segmented = (raw & 0x30) >> 4; // Bits 5 and 4
100        let is_first_segment = is_segmented == 0x01; // 01
101        let is_last_segment = is_segmented == 0x02; // 10
102        let is_middle_segment = is_segmented == 0x03; // 11
103        let is_client = (raw & 0x40) == 0; // Bit 6: 0 for client, 1 for server
104        let is_server = (raw & 0x40) != 0; // Bit 6: 1 for server
105        let is_lsb = (raw & 0x80) == 0; // Bit 7: 0 for LSB, 1 for MSB
106        let is_msb = (raw & 0x80) != 0; // Bit 7: 1 for MSB
107        let is_valid = (raw & 0x0E) == 0; // Bits 1,2,3 must be zero
108
109        Self {
110            raw,
111            is_application,
112            is_control,
113            is_segmented,
114            is_first_segment,
115            is_last_segment,
116            is_middle_segment,
117            is_client,
118            is_server,
119            is_lsb,
120            is_msb,
121            is_valid,
122        }
123    }
124    fn is_valid(&self) -> bool {
125        self.is_valid
126    }
127}
128#[derive(Debug)]
129pub struct PvaHeader {
130    pub magic: u8,
131    pub version: u8,
132    pub flags: PvaControlFlags,
133    pub command: u8,
134    pub payload_length: u32,
135}
136
137impl PvaHeader {
138    pub fn new(raw: &[u8]) -> Self {
139        Self::try_new(raw).expect("PVA header requires at least 8 bytes")
140    }
141
142    pub fn try_new(raw: &[u8]) -> Option<Self> {
143        if raw.len() < 8 {
144            return None;
145        }
146        let magic = raw[0];
147        let version = raw[1];
148        let flags = PvaControlFlags::new(raw[2]);
149        let command: u8 = raw[3];
150        let payload_length_bytes: [u8; 4] = raw[4..8]
151            .try_into()
152            .expect("Slice for payload_length has incorrect length");
153        let payload_length = if flags.is_msb {
154            u32::from_be_bytes(payload_length_bytes)
155        } else {
156            u32::from_le_bytes(payload_length_bytes)
157        };
158
159        Some(Self {
160            magic,
161            version,
162            flags,
163            command,
164            payload_length,
165        })
166    }
167    pub fn is_valid(&self) -> bool {
168        self.magic == 0xCA && self.flags.is_valid()
169    }
170}
171
172#[derive(Debug)]
173pub enum PvaPacketCommand {
174    Control(PvaControlPayload),
175    Search(PvaSearchPayload),
176    SearchResponse(PvaSearchResponsePayload),
177    Beacon(PvaBeaconPayload),
178    ConnectionValidation(PvaConnectionValidationPayload),
179    ConnectionValidated(PvaConnectionValidatedPayload),
180    AuthNZ(PvaAuthNzPayload),
181    AclChange(PvaAclChangePayload),
182    Op(PvaOpPayload),
183    CreateChannel(PvaCreateChannelPayload),
184    DestroyChannel(PvaDestroyChannelPayload),
185    GetField(PvaGetFieldPayload),
186    Message(PvaMessagePayload),
187    MultipleData(PvaMultipleDataPayload),
188    CancelRequest(PvaCancelRequestPayload),
189    DestroyRequest(PvaDestroyRequestPayload),
190    OriginTag(PvaOriginTagPayload),
191    Echo(Vec<u8>),
192    Unknown(PvaUnknownPayload),
193}
194#[derive(Debug)]
195pub struct PvaPacket {
196    pub header: PvaHeader,
197    pub payload: Vec<u8>,
198}
199
200impl PvaPacket {
201    pub fn new(raw: &[u8]) -> Self {
202        let header = PvaHeader::new(raw);
203        let payload = raw.to_vec();
204        Self { header, payload }
205    }
206    pub fn decode_payload(&mut self) -> Option<PvaPacketCommand> {
207        let pva_header_size = 8;
208        if self.payload.len() < pva_header_size {
209            debug!("Packet too short to contain a PVA payload beyond the header.");
210            return None;
211        }
212
213        let expected_total_len = if self.header.flags.is_control {
214            pva_header_size
215        } else {
216            pva_header_size + self.header.payload_length as usize
217        };
218        if self.payload.len() < expected_total_len {
219            debug!(
220                "Packet data length {} is less than expected total length {} (header {} + payload_length {})",
221                self.payload.len(),
222                expected_total_len,
223                pva_header_size,
224                self.header.payload_length
225            );
226            return None;
227        }
228
229        let command_payload_slice = &self.payload[pva_header_size..expected_total_len];
230
231        if self.header.flags.is_control {
232            return Some(PvaPacketCommand::Control(PvaControlPayload::new(
233                self.header.command,
234                self.header.payload_length,
235            )));
236        }
237
238        let decoded = match self.header.command {
239            0 => PvaBeaconPayload::new(command_payload_slice, self.header.flags.is_msb)
240                .map(PvaPacketCommand::Beacon),
241            2 => Some(PvaPacketCommand::Echo(command_payload_slice.to_vec())),
242            1 => PvaConnectionValidationPayload::new(
243                command_payload_slice,
244                self.header.flags.is_msb,
245                self.header.flags.is_server,
246            )
247            .map(PvaPacketCommand::ConnectionValidation),
248            3 => PvaSearchPayload::new(command_payload_slice, self.header.flags.is_msb)
249                .map(PvaPacketCommand::Search),
250            4 => PvaSearchResponsePayload::new(command_payload_slice, self.header.flags.is_msb)
251                .map(PvaPacketCommand::SearchResponse),
252            5 => PvaAuthNzPayload::new(command_payload_slice, self.header.flags.is_msb)
253                .map(PvaPacketCommand::AuthNZ),
254            6 => PvaAclChangePayload::new(command_payload_slice, self.header.flags.is_msb)
255                .map(PvaPacketCommand::AclChange),
256            7 => PvaCreateChannelPayload::new(
257                command_payload_slice,
258                self.header.flags.is_msb,
259                self.header.flags.is_server,
260            )
261            .map(PvaPacketCommand::CreateChannel),
262            8 => PvaDestroyChannelPayload::new(command_payload_slice, self.header.flags.is_msb)
263                .map(PvaPacketCommand::DestroyChannel),
264            9 => {
265                PvaConnectionValidatedPayload::new(command_payload_slice, self.header.flags.is_msb)
266                    .map(PvaPacketCommand::ConnectionValidated)
267            }
268            10 | 11 | 12 | 13 | 14 | 16 | 20 => PvaOpPayload::new(
269                command_payload_slice,
270                self.header.flags.is_msb,
271                self.header.flags.is_server,
272                self.header.command,
273            )
274            .map(PvaPacketCommand::Op),
275            15 => PvaDestroyRequestPayload::new(command_payload_slice, self.header.flags.is_msb)
276                .map(PvaPacketCommand::DestroyRequest),
277            17 => PvaGetFieldPayload::new(
278                command_payload_slice,
279                self.header.flags.is_msb,
280                self.header.flags.is_server,
281            )
282            .map(PvaPacketCommand::GetField),
283            18 => PvaMessagePayload::new(command_payload_slice, self.header.flags.is_msb)
284                .map(PvaPacketCommand::Message),
285            19 => PvaMultipleDataPayload::new(command_payload_slice, self.header.flags.is_msb)
286                .map(PvaPacketCommand::MultipleData),
287            21 => PvaCancelRequestPayload::new(command_payload_slice, self.header.flags.is_msb)
288                .map(PvaPacketCommand::CancelRequest),
289            22 => PvaOriginTagPayload::new(command_payload_slice).map(PvaPacketCommand::OriginTag),
290            _ => None,
291        };
292
293        if let Some(cmd) = decoded {
294            Some(cmd)
295        } else {
296            debug!(
297                "Decoding not implemented or unknown command: {}",
298                self.header.command
299            );
300            Some(PvaPacketCommand::Unknown(PvaUnknownPayload::new(
301                self.header.command,
302                false,
303                command_payload_slice.len(),
304            )))
305        }
306    }
307
308    pub fn is_valid(&self) -> bool {
309        self.header.is_valid()
310    }
311}
312
313/// helpers
314pub fn decode_size(raw: &[u8], is_be: bool) -> Option<(usize, usize)> {
315    if raw.is_empty() {
316        return None;
317    }
318
319    match raw[0] {
320        255 => Some((0, 1)),
321        254 => {
322            if raw.len() < 5 {
323                return None;
324            }
325            let size_bytes = &raw[1..5];
326            let size = if is_be {
327                u32::from_be_bytes(size_bytes.try_into().unwrap())
328            } else {
329                u32::from_le_bytes(size_bytes.try_into().unwrap())
330            };
331            Some((size as usize, 5))
332        }
333        short_len => Some((short_len as usize, 1)),
334    }
335}
336
337// decoding string using the above helper
338pub fn decode_string(raw: &[u8], is_be: bool) -> Option<(String, usize)> {
339    let (size, offset) = decode_size(raw, is_be)?;
340    let total_len = offset + size;
341    if raw.len() < total_len {
342        return None;
343    }
344
345    let string_bytes = &raw[offset..total_len];
346    let s = String::from_utf8_lossy(string_bytes).to_string();
347    Some((s, total_len))
348}
349
350pub fn decode_status(raw: &[u8], is_be: bool) -> (Option<PvaStatus>, usize) {
351    if raw.is_empty() {
352        return (None, 0);
353    }
354    let code = raw[0];
355    if code == 0xff {
356        return (None, 1);
357    }
358    let mut idx = 1usize;
359    let mut message: Option<String> = None;
360    let mut stack: Option<String> = None;
361    if let Some((msg, consumed)) = decode_string(&raw[idx..], is_be) {
362        message = Some(msg);
363        idx += consumed;
364        if let Some((st, consumed2)) = decode_string(&raw[idx..], is_be) {
365            stack = Some(st);
366            idx += consumed2;
367        }
368    }
369    (
370        Some(PvaStatus {
371            code,
372            message,
373            stack,
374        }),
375        idx,
376    )
377}
378
379pub fn decode_op_response_status(raw: &[u8], is_be: bool) -> Result<Option<PvaStatus>, String> {
380    let pkt = PvaPacket::new(raw);
381    let payload_len = pkt.header.payload_length as usize;
382    if raw.len() < 8 + payload_len {
383        return Err("op response truncated".to_string());
384    }
385    let payload = &raw[8..8 + payload_len];
386    if payload.len() < 5 {
387        return Err("op response payload too short".to_string());
388    }
389    Ok(decode_status(&payload[5..], is_be).0)
390}
391
392#[derive(Debug)]
393pub struct PvaControlPayload {
394    pub command: u8,
395    pub data: u32,
396}
397
398impl PvaControlPayload {
399    pub fn new(command: u8, data: u32) -> Self {
400        Self { command, data }
401    }
402}
403
404#[derive(Debug)]
405pub struct PvaSearchResponsePayload {
406    pub guid: [u8; 12],
407    pub seq: u32,
408    pub addr: [u8; 16],
409    pub port: u16,
410    pub protocol: String,
411    pub found: bool,
412    pub cids: Vec<u32>,
413}
414
415impl PvaSearchResponsePayload {
416    pub fn new(raw: &[u8], is_be: bool) -> Option<Self> {
417        if raw.len() < 34 {
418            debug!("PvaSearchResponsePayload::new: raw too short {}", raw.len());
419            return None;
420        }
421        let guid: [u8; 12] = raw[0..12].try_into().ok()?;
422        let seq = if is_be {
423            u32::from_be_bytes(raw[12..16].try_into().ok()?)
424        } else {
425            u32::from_le_bytes(raw[12..16].try_into().ok()?)
426        };
427        let addr: [u8; 16] = raw[16..32].try_into().ok()?;
428        let port = if is_be {
429            u16::from_be_bytes(raw[32..34].try_into().ok()?)
430        } else {
431            u16::from_le_bytes(raw[32..34].try_into().ok()?)
432        };
433
434        let mut offset = 34;
435        let (protocol, consumed) = decode_string(&raw[offset..], is_be)?;
436        offset += consumed;
437
438        if raw.len() <= offset {
439            return Some(Self {
440                guid,
441                seq,
442                addr,
443                port,
444                protocol,
445                found: false,
446                cids: vec![],
447            });
448        }
449
450        let found = raw[offset] != 0;
451        offset += 1;
452        let mut cids: Vec<u32> = vec![];
453        if raw.len() >= offset + 2 {
454            let count = if is_be {
455                u16::from_be_bytes(raw[offset..offset + 2].try_into().ok()?)
456            } else {
457                u16::from_le_bytes(raw[offset..offset + 2].try_into().ok()?)
458            };
459            offset += 2;
460            for _ in 0..count {
461                if raw.len() < offset + 4 {
462                    break;
463                }
464                let cid = if is_be {
465                    u32::from_be_bytes(raw[offset..offset + 4].try_into().ok()?)
466                } else {
467                    u32::from_le_bytes(raw[offset..offset + 4].try_into().ok()?)
468                };
469                cids.push(cid);
470                offset += 4;
471            }
472        }
473
474        Some(Self {
475            guid,
476            seq,
477            addr,
478            port,
479            protocol,
480            found,
481            cids,
482        })
483    }
484}
485
486#[derive(Debug)]
487pub struct PvaConnectionValidationPayload {
488    pub is_server: bool,
489    pub buffer_size: u32,
490    pub introspection_registry_size: u16,
491    pub qos: u16,
492    pub authz: Option<String>,
493    pub user: Option<String>,
494    pub host: Option<String>,
495}
496
497impl PvaConnectionValidationPayload {
498    pub fn new(raw: &[u8], is_be: bool, is_server: bool) -> Option<Self> {
499        if raw.len() < 6 {
500            debug!(
501                "PvaConnectionValidationPayload::new: raw too short {}",
502                raw.len()
503            );
504            return None;
505        }
506        let buffer_size = if is_be {
507            u32::from_be_bytes(raw[0..4].try_into().ok()?)
508        } else {
509            u32::from_le_bytes(raw[0..4].try_into().ok()?)
510        };
511        let introspection_registry_size = if is_be {
512            u16::from_be_bytes(raw[4..6].try_into().ok()?)
513        } else {
514            u16::from_le_bytes(raw[4..6].try_into().ok()?)
515        };
516
517        if is_server {
518            // Server→client: buffer_size(u32) + isize(u16) + Size(nauth) + nauth × string
519            // No QoS field.
520            let mut offset = 6;
521            let authz = if offset < raw.len() {
522                if let Some((count, consumed)) = decode_size(&raw[offset..], is_be) {
523                    offset += consumed;
524                    let mut first_method = None;
525                    for _ in 0..count {
526                        if let Some((s, c)) = decode_string(&raw[offset..], is_be) {
527                            if first_method.is_none() && !s.is_empty() {
528                                first_method = Some(s);
529                            }
530                            offset += c;
531                        }
532                    }
533                    first_method
534                } else {
535                    // Fallback: try single string (legacy spvirit servers).
536                    decode_string(&raw[offset..], is_be).map(|(s, _)| s)
537                }
538            } else {
539                None
540            };
541
542            Some(Self {
543                is_server,
544                buffer_size,
545                introspection_registry_size,
546                qos: 0,
547                authz,
548                user: None,
549                host: None,
550            })
551        } else {
552            // Client→server: buffer_size(u32) + isize(u16) + qos(u16) + auth_method(string) [+ FieldDesc cred]
553            if raw.len() < 8 {
554                return None;
555            }
556            let qos = if is_be {
557                u16::from_be_bytes(raw[6..8].try_into().ok()?)
558            } else {
559                u16::from_le_bytes(raw[6..8].try_into().ok()?)
560            };
561            let (authz, user, host) = if raw.len() > 8 {
562                if let Some((s, consumed)) = decode_string(&raw[8..], is_be) {
563                    let off = 8 + consumed;
564                    let (user, host) = if off < raw.len() {
565                        decode_ca_credentials(&raw[off..], is_be)
566                    } else {
567                        (None, None)
568                    };
569                    (Some(s), user, host)
570                } else {
571                    (None, None, None)
572                }
573            } else {
574                (None, None, None)
575            };
576
577            Some(Self {
578                is_server,
579                buffer_size,
580                introspection_registry_size,
581                qos,
582                authz,
583                user,
584                host,
585            })
586        }
587    }
588}
589
590/// Decode the trailing credentials PVStructure of a client ConnectionValidation.
591/// Returns (user, host); either may be None if absent or the struct is unreadable.
592/// Unreadable trailing bytes are tolerated (returns (None, None)) — the auth
593/// method string is authoritative; a malformed credential blob must not fail the
594/// whole ConnectionValidation decode.
595fn decode_ca_credentials(bytes: &[u8], is_be: bool) -> (Option<String>, Option<String>) {
596    let dec = PvdDecoder::new(is_be);
597    // The blob is a FieldDesc followed by the packed value for that structure.
598    let (desc, consumed) = match dec.parse_introspection_with_len(bytes) {
599        Ok(v) => v,
600        Err(_) => return (None, None),
601    };
602    let value_bytes = &bytes[consumed..];
603    match dec.decode_structure(value_bytes, &desc) {
604        Ok((decoded, _)) => (
605            decoded_string_field(&decoded, "user"),
606            decoded_string_field(&decoded, "host"),
607        ),
608        Err(_) => (None, None),
609    }
610}
611
612/// Extract a named string field from a decoded PVStructure value, mirroring the
613/// field-lookup pattern used by `extract_nt_scalar_value` in `spvd_decode.rs`.
614fn decoded_string_field(decoded: &DecodedValue, name: &str) -> Option<String> {
615    if let DecodedValue::Structure(fields) = decoded {
616        for (field_name, value) in fields {
617            if field_name == name
618                && let DecodedValue::String(s) = value
619            {
620                return Some(s.clone());
621            }
622        }
623    }
624    None
625}
626
627#[derive(Debug)]
628pub struct PvaConnectionValidatedPayload {
629    pub status: Option<PvaStatus>,
630}
631
632impl PvaConnectionValidatedPayload {
633    pub fn new(raw: &[u8], is_be: bool) -> Option<Self> {
634        let (status, _consumed) = decode_status(raw, is_be);
635        Some(Self { status })
636    }
637}
638
639#[derive(Debug)]
640pub struct PvaAuthNzPayload {
641    pub raw: Vec<u8>,
642    pub strings: Vec<String>,
643}
644
645impl PvaAuthNzPayload {
646    pub fn new(raw: &[u8], is_be: bool) -> Option<Self> {
647        let mut strings = vec![];
648        if let Some((count, consumed)) = decode_size(raw, is_be) {
649            let mut offset = consumed;
650            for _ in 0..count {
651                if let Some((s, len)) = decode_string(&raw[offset..], is_be) {
652                    strings.push(s);
653                    offset += len;
654                } else {
655                    break;
656                }
657            }
658        }
659        Some(Self {
660            raw: raw.to_vec(),
661            strings,
662        })
663    }
664}
665
666#[derive(Debug)]
667pub struct PvaAclChangePayload {
668    pub status: Option<PvaStatus>,
669    pub raw: Vec<u8>,
670}
671
672impl PvaAclChangePayload {
673    pub fn new(raw: &[u8], is_be: bool) -> Option<Self> {
674        let (status, consumed) = decode_status(raw, is_be);
675        let raw_rem = if raw.len() > consumed {
676            raw[consumed..].to_vec()
677        } else {
678            vec![]
679        };
680        Some(Self {
681            status,
682            raw: raw_rem,
683        })
684    }
685}
686
687#[derive(Debug)]
688pub struct PvaGetFieldPayload {
689    pub is_server: bool,
690    pub cid: u32,
691    pub sid: Option<u32>,
692    pub ioid: Option<u32>,
693    pub field_name: Option<String>,
694    pub status: Option<PvaStatus>,
695    pub introspection: Option<StructureDesc>,
696    pub raw: Vec<u8>,
697}
698
699impl PvaGetFieldPayload {
700    pub fn new(raw: &[u8], is_be: bool, is_server: bool) -> Option<Self> {
701        if !is_server {
702            if raw.len() < 4 {
703                debug!(
704                    "PvaGetFieldPayload::new (client): raw too short {}",
705                    raw.len()
706                );
707                return None;
708            }
709            let cid = if is_be {
710                u32::from_be_bytes(raw[0..4].try_into().ok()?)
711            } else {
712                u32::from_le_bytes(raw[0..4].try_into().ok()?)
713            };
714
715            // Two client-side wire variants are observed for GET_FIELD:
716            // 1) legacy: [cid][field_name]
717            // 2) EPICS pvAccess: [sid][ioid][field_name]
718            let legacy_field = if raw.len() > 4 {
719                decode_string(&raw[4..], is_be)
720                    .and_then(|(s, consumed)| (4 + consumed == raw.len()).then_some(s))
721            } else {
722                None
723            };
724
725            let epics_variant = if raw.len() >= 9 {
726                let ioid = if is_be {
727                    u32::from_be_bytes(raw[4..8].try_into().ok()?)
728                } else {
729                    u32::from_le_bytes(raw[4..8].try_into().ok()?)
730                };
731                decode_string(&raw[8..], is_be)
732                    .and_then(|(s, consumed)| (8 + consumed == raw.len()).then_some((ioid, s)))
733            } else {
734                None
735            };
736
737            let (sid, ioid, field_name) = if let Some((ioid, field)) = epics_variant {
738                (Some(cid), Some(ioid), Some(field))
739            } else {
740                (None, None, legacy_field)
741            };
742
743            return Some(Self {
744                is_server,
745                cid,
746                sid,
747                ioid,
748                field_name,
749                status: None,
750                introspection: None,
751                raw: vec![],
752            });
753        }
754
755        let parse_status_then_intro = |bytes: &[u8]| {
756            let (status, consumed) = decode_status(bytes, is_be);
757            let pvd_raw = if bytes.len() > consumed {
758                bytes[consumed..].to_vec()
759            } else {
760                vec![]
761            };
762            let introspection = if !pvd_raw.is_empty() {
763                let decoder = PvdDecoder::new(is_be);
764                decoder.parse_introspection(&pvd_raw).ok()
765            } else {
766                None
767            };
768            (status, pvd_raw, introspection)
769        };
770
771        // Server GET_FIELD responses are encoded as:
772        // [request_id/cid][status][optional introspection]
773        // Keep cid present for both success and error responses.
774        let (cid, status, pvd_raw, introspection) = if raw.len() >= 4 {
775            let parsed_cid = if is_be {
776                u32::from_be_bytes(raw[0..4].try_into().ok()?)
777            } else {
778                u32::from_le_bytes(raw[0..4].try_into().ok()?)
779            };
780            let (status, pvd_raw, introspection) = parse_status_then_intro(&raw[4..]);
781            (parsed_cid, status, pvd_raw, introspection)
782        } else {
783            let (status, pvd_raw, introspection) = parse_status_then_intro(raw);
784            (0, status, pvd_raw, introspection)
785        };
786
787        Some(Self {
788            is_server,
789            cid,
790            sid: None,
791            ioid: None,
792            field_name: None,
793            status,
794            introspection,
795            raw: pvd_raw,
796        })
797    }
798}
799
800#[derive(Debug)]
801pub struct PvaMessagePayload {
802    pub ioid: u32,
803    pub message_type: u8,
804    pub message: Option<String>,
805    /// Legacy compat: if the payload looks like old Status format, decode that.
806    pub status: Option<PvaStatus>,
807    pub raw: Vec<u8>,
808}
809
810impl PvaMessagePayload {
811    pub fn new(raw: &[u8], is_be: bool) -> Option<Self> {
812        // PVA spec MESSAGE format: ioid(u32) + message_type(u8) + message(string)
813        if raw.len() >= 5 {
814            let ioid = if is_be {
815                u32::from_be_bytes(raw[0..4].try_into().ok()?)
816            } else {
817                u32::from_le_bytes(raw[0..4].try_into().ok()?)
818            };
819            let message_type = raw[4];
820            let message = if raw.len() > 5 {
821                decode_string(&raw[5..], is_be).map(|(s, _)| s)
822            } else {
823                None
824            };
825            // Build a synthetic PvaStatus so existing tests/code that inspect .status still work.
826            let code = match message_type {
827                0 => 0xFF, // info → OK
828                1 => 0x01, // warning
829                2 => 0x02, // error
830                _ => 0x03, // fatal
831            };
832            let status = Some(PvaStatus {
833                code,
834                message: message.clone(),
835                stack: None,
836            });
837            Some(Self {
838                ioid,
839                message_type,
840                message,
841                status,
842                raw: raw.to_vec(),
843            })
844        } else {
845            // Fallback for very short payloads
846            Some(Self {
847                ioid: 0,
848                message_type: 0,
849                message: None,
850                status: None,
851                raw: raw.to_vec(),
852            })
853        }
854    }
855}
856
857#[derive(Debug)]
858pub struct PvaMultipleDataEntry {
859    pub ioid: u32,
860    pub subcmd: u8,
861}
862
863#[derive(Debug)]
864pub struct PvaMultipleDataPayload {
865    pub entries: Vec<PvaMultipleDataEntry>,
866    pub raw: Vec<u8>,
867}
868
869impl PvaMultipleDataPayload {
870    pub fn new(raw: &[u8], is_be: bool) -> Option<Self> {
871        let mut entries: Vec<PvaMultipleDataEntry> = vec![];
872        if let Some((count, consumed)) = decode_size(raw, is_be) {
873            let mut offset = consumed;
874            for _ in 0..count {
875                if raw.len() < offset + 5 {
876                    break;
877                }
878                let ioid = if is_be {
879                    u32::from_be_bytes(raw[offset..offset + 4].try_into().ok()?)
880                } else {
881                    u32::from_le_bytes(raw[offset..offset + 4].try_into().ok()?)
882                };
883                let subcmd = raw[offset + 4];
884                entries.push(PvaMultipleDataEntry { ioid, subcmd });
885                offset += 5;
886            }
887        }
888        Some(Self {
889            entries,
890            raw: raw.to_vec(),
891        })
892    }
893}
894
895#[derive(Debug)]
896pub struct PvaCancelRequestPayload {
897    pub request_id: u32,
898    pub status: Option<PvaStatus>,
899}
900
901impl PvaCancelRequestPayload {
902    pub fn new(raw: &[u8], is_be: bool) -> Option<Self> {
903        if raw.len() < 4 {
904            debug!("PvaCancelRequestPayload::new: raw too short {}", raw.len());
905            return None;
906        }
907        let request_id = if is_be {
908            u32::from_be_bytes(raw[0..4].try_into().ok()?)
909        } else {
910            u32::from_le_bytes(raw[0..4].try_into().ok()?)
911        };
912        let (status, _) = if raw.len() > 4 {
913            decode_status(&raw[4..], is_be)
914        } else {
915            (None, 0)
916        };
917        Some(Self { request_id, status })
918    }
919}
920
921#[derive(Debug)]
922pub struct PvaDestroyRequestPayload {
923    pub sid: u32,
924    pub request_id: u32,
925}
926
927impl PvaDestroyRequestPayload {
928    /// Decode a `destroyRequest` (0x0F) payload.
929    ///
930    /// The PVA spec payload is `serverChannelID (i32)` followed by
931    /// `requestID (i32)`. Older spvirit clients sent only the 4-byte
932    /// requestID; that legacy form is still accepted (with `sid` = 0).
933    pub fn new(raw: &[u8], is_be: bool) -> Option<Self> {
934        let word = |range: std::ops::Range<usize>| -> Option<u32> {
935            let bytes = raw.get(range)?.try_into().ok()?;
936            Some(if is_be {
937                u32::from_be_bytes(bytes)
938            } else {
939                u32::from_le_bytes(bytes)
940            })
941        };
942        if raw.len() >= 8 {
943            Some(Self {
944                sid: word(0..4)?,
945                request_id: word(4..8)?,
946            })
947        } else if raw.len() >= 4 {
948            Some(Self {
949                sid: 0,
950                request_id: word(0..4)?,
951            })
952        } else {
953            debug!("PvaDestroyRequestPayload::new: raw too short {}", raw.len());
954            None
955        }
956    }
957}
958
959#[derive(Debug)]
960pub struct PvaOriginTagPayload {
961    pub address: [u8; 16],
962}
963
964impl PvaOriginTagPayload {
965    pub fn new(raw: &[u8]) -> Option<Self> {
966        if raw.len() < 16 {
967            debug!("PvaOriginTagPayload::new: raw too short {}", raw.len());
968            return None;
969        }
970        let address: [u8; 16] = raw[0..16].try_into().ok()?;
971        Some(Self { address })
972    }
973}
974
975#[derive(Debug)]
976pub struct PvaUnknownPayload {
977    pub command: u8,
978    pub is_control: bool,
979    pub raw_len: usize,
980}
981
982impl PvaUnknownPayload {
983    pub fn new(command: u8, is_control: bool, raw_len: usize) -> Self {
984        Self {
985            command,
986            is_control,
987            raw_len,
988        }
989    }
990}
991
992/// payload decoder
993/// SEARCH
994#[derive(Debug)]
995pub struct PvaSearchPayload {
996    pub seq: u32,
997    pub mask: u8,
998    pub addr: [u8; 16],
999    pub port: u16,
1000    pub protocols: Vec<String>,
1001    pub pv_requests: Vec<(u32, String)>,
1002    pub pv_names: Vec<String>,
1003}
1004
1005impl PvaSearchPayload {
1006    pub fn new(raw: &[u8], is_be: bool) -> Option<Self> {
1007        if raw.is_empty() {
1008            debug!("PvaSearchPayload::new received an empty raw slice.");
1009            return None;
1010        }
1011        const MIN_FIXED_SEARCH_PAYLOAD_SIZE: usize = 26;
1012        if raw.len() < MIN_FIXED_SEARCH_PAYLOAD_SIZE {
1013            debug!(
1014                "PvaSearchPayload::new: raw slice length {} is less than min fixed size {}.",
1015                raw.len(),
1016                MIN_FIXED_SEARCH_PAYLOAD_SIZE
1017            );
1018            return None;
1019        }
1020
1021        let seq = if is_be {
1022            u32::from_be_bytes(raw[0..4].try_into().unwrap())
1023        } else {
1024            u32::from_le_bytes(raw[0..4].try_into().unwrap())
1025        };
1026
1027        let mask = raw[4];
1028        let addr: [u8; 16] = raw[8..24].try_into().unwrap();
1029        let port = if is_be {
1030            u16::from_be_bytes(raw[24..26].try_into().unwrap())
1031        } else {
1032            u16::from_le_bytes(raw[24..26].try_into().unwrap())
1033        };
1034
1035        let mut offset = 26;
1036
1037        let (protocol_count, consumed) = decode_size(&raw[offset..], is_be)?;
1038        offset += consumed;
1039
1040        let mut protocols = vec![];
1041        for _ in 0..protocol_count {
1042            let (protocol, len) = decode_string(&raw[offset..], is_be)?;
1043            protocols.push(protocol);
1044            offset += len;
1045        }
1046
1047        // PV names here
1048        if raw.len() < offset + 2 {
1049            return None;
1050        }
1051        let pv_count = if is_be {
1052            u16::from_be_bytes(raw[offset..offset + 2].try_into().unwrap())
1053        } else {
1054            u16::from_le_bytes(raw[offset..offset + 2].try_into().unwrap())
1055        };
1056        offset += 2;
1057
1058        let mut pv_names = vec![];
1059        let mut pv_requests = vec![];
1060        for _ in 0..pv_count {
1061            if raw.len() < offset + 4 {
1062                debug!(
1063                    "PvaSearchPayload::new: not enough data for PV CID at offset {}. Raw len: {}",
1064                    offset,
1065                    raw.len()
1066                );
1067                return None;
1068            }
1069            let cid = if is_be {
1070                u32::from_be_bytes(raw[offset..offset + 4].try_into().unwrap())
1071            } else {
1072                u32::from_le_bytes(raw[offset..offset + 4].try_into().unwrap())
1073            };
1074            offset += 4;
1075            let (pv_name, len) = decode_string(&raw[offset..], is_be)?;
1076            pv_names.push(pv_name.clone());
1077            pv_requests.push((cid, pv_name));
1078            offset += len;
1079        }
1080
1081        Some(Self {
1082            seq,
1083            mask,
1084            addr,
1085            port,
1086            protocols,
1087            pv_requests,
1088            pv_names,
1089        })
1090    }
1091}
1092
1093/// struct beaconMessage {
1094#[derive(Debug)]
1095pub struct PvaBeaconPayload {
1096    pub guid: [u8; 12],
1097    pub flags: u8,
1098    pub beacon_sequence_id: u8,
1099    pub change_count: u16,
1100    pub server_address: [u8; 16],
1101    pub server_port: u16,
1102    pub protocol: String,
1103    pub server_status_if: String,
1104}
1105
1106impl PvaBeaconPayload {
1107    pub fn new(raw: &[u8], is_be: bool) -> Option<Self> {
1108        // guid(12) + flags(1) + beacon_sequence_id(1) + change_count(2) + server_address(16) + server_port(2)
1109        const MIN_FIXED_BEACON_PAYLOAD_SIZE: usize = 12 + 1 + 1 + 2 + 16 + 2;
1110
1111        if raw.len() < MIN_FIXED_BEACON_PAYLOAD_SIZE {
1112            debug!(
1113                "PvaBeaconPayload::new: raw slice length {} is less than min fixed size {}.",
1114                raw.len(),
1115                MIN_FIXED_BEACON_PAYLOAD_SIZE
1116            );
1117            return None;
1118        }
1119
1120        let guid: [u8; 12] = raw[0..12].try_into().unwrap();
1121        let flags = raw[12];
1122        let beacon_sequence_id = raw[13];
1123        let change_count = if is_be {
1124            u16::from_be_bytes(raw[14..16].try_into().unwrap())
1125        } else {
1126            u16::from_le_bytes(raw[14..16].try_into().unwrap())
1127        };
1128        let server_address: [u8; 16] = raw[16..32].try_into().unwrap();
1129        let server_port = if is_be {
1130            u16::from_be_bytes(raw[32..34].try_into().unwrap())
1131        } else {
1132            u16::from_le_bytes(raw[32..34].try_into().unwrap())
1133        };
1134        let (protocol, len) = decode_string(&raw[34..], is_be)?;
1135        let protocol = protocol;
1136        let server_status_if = if len > 0 {
1137            let (server_status_if, _server_status_len) = decode_string(&raw[34 + len..], is_be)?;
1138            server_status_if
1139        } else {
1140            String::new()
1141        };
1142
1143        Some(Self {
1144            guid,
1145            flags,
1146            beacon_sequence_id,
1147            change_count,
1148            server_address,
1149            server_port,
1150            protocol,
1151            server_status_if,
1152        })
1153    }
1154}
1155
1156/// CREATE_CHANNEL payload (cmd=7)
1157/// Client: count(2), then for each: cid(4), pv_name(string)
1158/// Server: cid(4), sid(4), status
1159#[derive(Debug)]
1160pub struct PvaCreateChannelPayload {
1161    /// Is this from server (response) or client (request)?
1162    pub is_server: bool,
1163    /// For client requests: list of (cid, pv_name) tuples
1164    pub channels: Vec<(u32, String)>,
1165    /// For server response: client channel ID
1166    pub cid: u32,
1167    /// For server response: server channel ID
1168    pub sid: u32,
1169    /// For server response: status
1170    pub status: Option<PvaStatus>,
1171}
1172
1173impl PvaCreateChannelPayload {
1174    pub fn new(raw: &[u8], is_be: bool, is_server: bool) -> Option<Self> {
1175        if raw.is_empty() {
1176            debug!("PvaCreateChannelPayload::new received an empty raw slice.");
1177            return None;
1178        }
1179
1180        if is_server {
1181            // Server response: cid(4), sid(4), status
1182            if raw.len() < 8 {
1183                debug!("CREATE_CHANNEL server response too short: {}", raw.len());
1184                return None;
1185            }
1186
1187            let cid = if is_be {
1188                u32::from_be_bytes(raw[0..4].try_into().unwrap())
1189            } else {
1190                u32::from_le_bytes(raw[0..4].try_into().unwrap())
1191            };
1192
1193            let sid = if is_be {
1194                u32::from_be_bytes(raw[4..8].try_into().unwrap())
1195            } else {
1196                u32::from_le_bytes(raw[4..8].try_into().unwrap())
1197            };
1198
1199            // Decode status if present
1200            let status = if raw.len() > 8 {
1201                let code = raw[8];
1202                if code == 0xff {
1203                    None // OK, no status message
1204                } else {
1205                    let mut idx = 9;
1206                    let message = if idx < raw.len() {
1207                        decode_string(&raw[idx..], is_be).map(|(msg, consumed)| {
1208                            idx += consumed;
1209                            msg
1210                        })
1211                    } else {
1212                        None
1213                    };
1214                    let stack = if idx < raw.len() {
1215                        decode_string(&raw[idx..], is_be).map(|(s, _)| s)
1216                    } else {
1217                        None
1218                    };
1219                    Some(PvaStatus {
1220                        code,
1221                        message,
1222                        stack,
1223                    })
1224                }
1225            } else {
1226                None
1227            };
1228
1229            Some(Self {
1230                is_server: true,
1231                channels: vec![],
1232                cid,
1233                sid,
1234                status,
1235            })
1236        } else {
1237            // Client request: count(2), then for each: cid(4), pv_name(string)
1238            if raw.len() < 2 {
1239                debug!("CREATE_CHANNEL client request too short: {}", raw.len());
1240                return None;
1241            }
1242
1243            let count = if is_be {
1244                u16::from_be_bytes(raw[0..2].try_into().unwrap())
1245            } else {
1246                u16::from_le_bytes(raw[0..2].try_into().unwrap())
1247            };
1248
1249            let mut offset = 2;
1250            let mut channels = Vec::with_capacity(count as usize);
1251
1252            for _ in 0..count {
1253                if raw.len() < offset + 4 {
1254                    debug!(
1255                        "CREATE_CHANNEL: not enough data for CID at offset {}",
1256                        offset
1257                    );
1258                    break;
1259                }
1260
1261                let cid = if is_be {
1262                    u32::from_be_bytes(raw[offset..offset + 4].try_into().unwrap())
1263                } else {
1264                    u32::from_le_bytes(raw[offset..offset + 4].try_into().unwrap())
1265                };
1266                offset += 4;
1267
1268                if let Some((pv_name, consumed)) = decode_string(&raw[offset..], is_be) {
1269                    offset += consumed;
1270                    channels.push((cid, pv_name));
1271                } else {
1272                    debug!(
1273                        "CREATE_CHANNEL: failed to decode PV name at offset {}",
1274                        offset
1275                    );
1276                    break;
1277                }
1278            }
1279
1280            Some(Self {
1281                is_server: false,
1282                channels,
1283                cid: 0,
1284                sid: 0,
1285                status: None,
1286            })
1287        }
1288    }
1289}
1290
1291impl fmt::Display for PvaCreateChannelPayload {
1292    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1293        if self.is_server {
1294            let status_text = if let Some(s) = &self.status {
1295                format!(" status={}", s.code)
1296            } else {
1297                String::new()
1298            };
1299            write!(
1300                f,
1301                "CREATE_CHANNEL(cid={}, sid={}{})",
1302                self.cid, self.sid, status_text
1303            )
1304        } else {
1305            let pv_list: Vec<String> = self
1306                .channels
1307                .iter()
1308                .map(|(cid, name)| format!("{}:'{}'", cid, name))
1309                .collect();
1310            write!(f, "CREATE_CHANNEL({})", pv_list.join(", "))
1311        }
1312    }
1313}
1314
1315/// DESTROY_CHANNEL payload (cmd=8)
1316/// Format: sid(4), cid(4)
1317#[derive(Debug)]
1318pub struct PvaDestroyChannelPayload {
1319    /// Server channel ID
1320    pub sid: u32,
1321    /// Client channel ID
1322    pub cid: u32,
1323}
1324
1325impl PvaDestroyChannelPayload {
1326    pub fn new(raw: &[u8], is_be: bool) -> Option<Self> {
1327        if raw.len() < 8 {
1328            debug!("DESTROY_CHANNEL payload too short: {}", raw.len());
1329            return None;
1330        }
1331
1332        let sid = if is_be {
1333            u32::from_be_bytes(raw[0..4].try_into().unwrap())
1334        } else {
1335            u32::from_le_bytes(raw[0..4].try_into().unwrap())
1336        };
1337
1338        let cid = if is_be {
1339            u32::from_be_bytes(raw[4..8].try_into().unwrap())
1340        } else {
1341            u32::from_le_bytes(raw[4..8].try_into().unwrap())
1342        };
1343
1344        Some(Self { sid, cid })
1345    }
1346}
1347
1348impl fmt::Display for PvaDestroyChannelPayload {
1349    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1350        write!(f, "DESTROY_CHANNEL(sid={}, cid={})", self.sid, self.cid)
1351    }
1352}
1353
1354/// Generic operation payload (GET/PUT/PUT_GET/MONITOR/ARRAY/RPC)
1355#[derive(Debug)]
1356pub struct PvaOpPayload {
1357    pub sid_or_cid: u32,
1358    pub ioid: u32,
1359    pub subcmd: u8,
1360    pub body: Vec<u8>,
1361    pub command: u8,
1362    pub is_server: bool,
1363    pub status: Option<PvaStatus>,
1364    pub pv_names: Vec<String>,
1365    /// Parsed introspection data (for INIT responses)
1366    pub introspection: Option<StructureDesc>,
1367    /// Decoded value (when field_desc is available)
1368    pub decoded_value: Option<DecodedValue>,
1369}
1370
1371// Heuristic extraction of PV-like names from a PVD body.
1372fn extract_pv_names(raw: &[u8]) -> Vec<String> {
1373    let mut names: Vec<String> = Vec::new();
1374    let mut i = 0usize;
1375    while i < raw.len() {
1376        // start with an alphanumeric character
1377        if raw[i].is_ascii_alphanumeric() {
1378            let start = i;
1379            i += 1;
1380            while i < raw.len() {
1381                let b = raw[i];
1382                if b.is_ascii_alphanumeric()
1383                    || b == b':'
1384                    || b == b'.'
1385                    || b == b'_'
1386                    || b == b'-'
1387                    || b == b'/'
1388                {
1389                    i += 1;
1390                } else {
1391                    break;
1392                }
1393            }
1394            let len = i - start;
1395            if len >= 3 && len <= 128 {
1396                if let Ok(s) = std::str::from_utf8(&raw[start..start + len]) {
1397                    // validate candidate contains at least one alphabetic char
1398                    if s.chars().any(|c| c.is_ascii_alphabetic()) {
1399                        if !names.contains(&s.to_string()) {
1400                            names.push(s.to_string());
1401                            if names.len() >= 8 {
1402                                break;
1403                            }
1404                        }
1405                    }
1406                }
1407            }
1408        } else {
1409            i += 1;
1410        }
1411    }
1412    names
1413}
1414
1415impl PvaOpPayload {
1416    pub fn new(raw: &[u8], is_be: bool, is_server: bool, command: u8) -> Option<Self> {
1417        // operation payloads have slightly different fixed offsets depending on client/server
1418        if raw.len() < 5 {
1419            debug!("PvaOpPayload::new: raw too short {}", raw.len());
1420            return None;
1421        }
1422
1423        let (sid_or_cid, ioid, subcmd, offset) = if is_server {
1424            // server op: ioid(4), subcmd(1)
1425            if raw.len() < 5 {
1426                return None;
1427            }
1428            let ioid = if is_be {
1429                u32::from_be_bytes(raw[0..4].try_into().unwrap())
1430            } else {
1431                u32::from_le_bytes(raw[0..4].try_into().unwrap())
1432            };
1433            let subcmd = raw[4];
1434            (0, ioid, subcmd, 5)
1435        } else {
1436            // client op: sid(4), ioid(4), subcmd(1)
1437            if raw.len() < 9 {
1438                return None;
1439            }
1440            let sid = if is_be {
1441                u32::from_be_bytes(raw[0..4].try_into().unwrap())
1442            } else {
1443                u32::from_le_bytes(raw[0..4].try_into().unwrap())
1444            };
1445            let ioid = if is_be {
1446                u32::from_be_bytes(raw[4..8].try_into().unwrap())
1447            } else {
1448                u32::from_le_bytes(raw[4..8].try_into().unwrap())
1449            };
1450            let subcmd = raw[8];
1451            (sid, ioid, subcmd, 9)
1452        };
1453
1454        let body = if raw.len() > offset {
1455            raw[offset..].to_vec()
1456        } else {
1457            vec![]
1458        };
1459
1460        // Status is only present in certain subcmd types:
1461        // Status format (per Lua dissector): first byte = code. If code==0xff (255) -> OK
1462        // shorthand (1 byte only). Otherwise follow with two length-prefixed strings:
1463        // message, stack.
1464        // Server responses carry a status prefix for INIT responses (subcmd & 0x08),
1465        // and for non-INIT responses on GET (10), PUT (11), PUT_GET (12).
1466        // Monitor (13) data updates (non-INIT) do NOT have a status prefix.
1467        let mut status: Option<PvaStatus> = None;
1468        let mut pvd_raw: Vec<u8> = vec![];
1469
1470        let has_status = is_server && ((subcmd & 0x08) != 0 || (command != 13 && command != 14));
1471
1472        if !body.is_empty() {
1473            if has_status {
1474                let (parsed, consumed) = decode_status(&body, is_be);
1475                status = parsed;
1476                pvd_raw = if body.len() > consumed {
1477                    body[consumed..].to_vec()
1478                } else {
1479                    vec![]
1480                };
1481            } else {
1482                pvd_raw = body.clone();
1483            }
1484        }
1485
1486        let pv_names = extract_pv_names(&pvd_raw);
1487
1488        // Try to parse introspection from INIT response (subcmd & 0x08 and is_server)
1489        let introspection = if is_server && (subcmd & 0x08) != 0 && !pvd_raw.is_empty() {
1490            let decoder = PvdDecoder::new(is_be);
1491            decoder.parse_introspection(&pvd_raw).ok()
1492        } else {
1493            None
1494        };
1495
1496        let result = Some(Self {
1497            sid_or_cid,
1498            ioid,
1499            subcmd,
1500            body: pvd_raw,
1501            command,
1502            is_server,
1503            status: status.clone(),
1504            pv_names,
1505            introspection,
1506            decoded_value: None, // Will be set by packet processor with field_desc
1507        });
1508
1509        result
1510    }
1511
1512    /// Decode the body using provided field description.
1513    ///
1514    /// See [`DecodeMode`] for the MONITOR bitset-layout policy. Live
1515    /// connections, where the introspection is known, want
1516    /// [`DecodeMode::Strict`].
1517    pub fn decode_with_field_desc(
1518        &mut self,
1519        field_desc: &StructureDesc,
1520        is_be: bool,
1521        mode: DecodeMode,
1522    ) -> DecodeResult<()> {
1523        if self.body.is_empty() {
1524            return Ok(());
1525        }
1526
1527        let decoder = PvdDecoder::new(is_be);
1528
1529        // For data updates (subcmd == 0x00 or subcmd & 0x40), use bitset decoding
1530        if self.subcmd == 0x00 || (self.subcmd & 0x40) != 0 {
1531            if self.command == 13 {
1532                let update = match mode {
1533                    DecodeMode::Strict => decoder.decode_monitor_update(&self.body, field_desc)?,
1534                    DecodeMode::Lenient => {
1535                        decoder
1536                            .decode_monitor_update_lenient(&self.body, field_desc)?
1537                            .0
1538                    }
1539                };
1540                self.decoded_value = Some(update.value);
1541            } else {
1542                let (value, _) = decoder.decode_structure_with_bitset(&self.body, field_desc)?;
1543                self.decoded_value = Some(value);
1544            }
1545        } else {
1546            // Full structure decode
1547            let (value, _) = decoder.decode_structure(&self.body, field_desc)?;
1548            self.decoded_value = Some(value);
1549        }
1550        Ok(())
1551    }
1552}
1553
1554/// How strictly to interpret a MONITOR body's bitset layout.
1555#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1556pub enum DecodeMode {
1557    /// Specification order only: changed bitset, data, overrun bitset. Use
1558    /// this on live connections, where the introspection is known.
1559    Strict,
1560    /// Try every known layout and pick the most plausible. For mid-stream
1561    /// packet captures where the peer's layout is unknown.
1562    ///
1563    /// Nothing in this workspace uses it; it is public API for out-of-tree
1564    /// consumers talking to implementations that disagree about where the
1565    /// overrun bitset goes.
1566    Lenient,
1567}
1568
1569#[derive(Debug, Clone)]
1570pub struct PvaStatus {
1571    pub code: u8,
1572    pub message: Option<String>,
1573    pub stack: Option<String>,
1574}
1575
1576impl PvaStatus {
1577    pub fn is_error(&self) -> bool {
1578        self.code != 0
1579    }
1580}
1581
1582/// Display implementations
1583// beacon payload display
1584impl fmt::Display for PvaBeaconPayload {
1585    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1586        write!(
1587            f,
1588            "Beacon:GUID=[{}],Flags=[{}],SeqId=[{}],ChangeCount=[{}],ServerAddress=[{}],ServerPort=[{}],Protocol=[{}]",
1589            hex::encode(self.guid),
1590            self.flags,
1591            self.beacon_sequence_id,
1592            self.change_count,
1593            format_pva_address(&self.server_address),
1594            self.server_port,
1595            self.protocol
1596        )
1597    }
1598}
1599
1600// search payload display
1601impl fmt::Display for PvaSearchPayload {
1602    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1603        write!(f, "Search:PVs=[{}]", self.pv_names.join(","))
1604    }
1605}
1606
1607impl fmt::Display for PvaControlPayload {
1608    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1609        let name = match self.command {
1610            0 => "MARK_TOTAL_BYTES_SENT",
1611            1 => "ACK_TOTAL_BYTES_RECEIVED",
1612            2 => "SET_BYTE_ORDER",
1613            3 => "ECHO_REQUEST",
1614            4 => "ECHO_RESPONSE",
1615            _ => "CONTROL",
1616        };
1617        write!(f, "{}(data={})", name, self.data)
1618    }
1619}
1620
1621impl fmt::Display for PvaSearchResponsePayload {
1622    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1623        let found_text = if self.found { "true" } else { "false" };
1624        if self.cids.is_empty() {
1625            write!(
1626                f,
1627                "SearchResponse(found={}, proto={})",
1628                found_text, self.protocol
1629            )
1630        } else {
1631            write!(
1632                f,
1633                "SearchResponse(found={}, proto={}, cids=[{}])",
1634                found_text,
1635                self.protocol,
1636                self.cids
1637                    .iter()
1638                    .map(|c| c.to_string())
1639                    .collect::<Vec<String>>()
1640                    .join(",")
1641            )
1642        }
1643    }
1644}
1645
1646impl fmt::Display for PvaConnectionValidationPayload {
1647    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1648        let dir = if self.is_server { "server" } else { "client" };
1649        let authz = self.authz.as_deref().unwrap_or("");
1650        if authz.is_empty() {
1651            write!(
1652                f,
1653                "ConnectionValidation(dir={}, qsize={}, isize={}, qos=0x{:04x})",
1654                dir, self.buffer_size, self.introspection_registry_size, self.qos
1655            )
1656        } else {
1657            write!(
1658                f,
1659                "ConnectionValidation(dir={}, qsize={}, isize={}, qos=0x{:04x}, authz={})",
1660                dir, self.buffer_size, self.introspection_registry_size, self.qos, authz
1661            )
1662        }
1663    }
1664}
1665
1666impl fmt::Display for PvaStatus {
1667    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1668        write!(
1669            f,
1670            "code={} message={} stack={}",
1671            self.code,
1672            self.message.as_deref().unwrap_or(""),
1673            self.stack.as_deref().unwrap_or("")
1674        )
1675    }
1676}
1677
1678impl fmt::Display for PvaConnectionValidatedPayload {
1679    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1680        match &self.status {
1681            Some(s) => write!(f, "ConnectionValidated(status={})", s.code),
1682            None => write!(f, "ConnectionValidated(status=OK)"),
1683        }
1684    }
1685}
1686
1687impl fmt::Display for PvaAuthNzPayload {
1688    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1689        if !self.strings.is_empty() {
1690            write!(f, "AuthNZ(strings=[{}])", self.strings.join(","))
1691        } else {
1692            write!(f, "AuthNZ(raw_len={})", self.raw.len())
1693        }
1694    }
1695}
1696
1697impl fmt::Display for PvaAclChangePayload {
1698    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1699        match &self.status {
1700            Some(s) => write!(f, "ACL_CHANGE(status={})", s.code),
1701            None => write!(f, "ACL_CHANGE(status=OK)"),
1702        }
1703    }
1704}
1705
1706impl fmt::Display for PvaGetFieldPayload {
1707    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1708        if self.is_server {
1709            let status = self.status.as_ref().map(|s| s.code).unwrap_or(0xff);
1710            write!(f, "GET_FIELD(status={})", status)
1711        } else {
1712            let field = self.field_name.as_deref().unwrap_or("");
1713            if field.is_empty() {
1714                write!(f, "GET_FIELD(cid={})", self.cid)
1715            } else {
1716                write!(f, "GET_FIELD(cid={}, field={})", self.cid, field)
1717            }
1718        }
1719    }
1720}
1721
1722impl fmt::Display for PvaMessagePayload {
1723    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1724        match &self.status {
1725            Some(s) => {
1726                if let Some(msg) = &s.message {
1727                    write!(f, "MESSAGE(status={}, msg='{}')", s.code, msg)
1728                } else {
1729                    write!(f, "MESSAGE(status={})", s.code)
1730                }
1731            }
1732            None => write!(f, "MESSAGE(status=OK)"),
1733        }
1734    }
1735}
1736
1737impl fmt::Display for PvaMultipleDataPayload {
1738    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1739        if self.entries.is_empty() {
1740            write!(f, "MULTIPLE_DATA(raw_len={})", self.raw.len())
1741        } else {
1742            write!(f, "MULTIPLE_DATA(entries={})", self.entries.len())
1743        }
1744    }
1745}
1746
1747impl fmt::Display for PvaCancelRequestPayload {
1748    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1749        let status = self.status.as_ref().map(|s| s.code);
1750        match status {
1751            Some(code) => write!(f, "CANCEL_REQUEST(id={}, status={})", self.request_id, code),
1752            None => write!(f, "CANCEL_REQUEST(id={})", self.request_id),
1753        }
1754    }
1755}
1756
1757impl fmt::Display for PvaDestroyRequestPayload {
1758    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1759        write!(
1760            f,
1761            "DESTROY_REQUEST(sid={}, id={})",
1762            self.sid, self.request_id
1763        )
1764    }
1765}
1766
1767impl fmt::Display for PvaOriginTagPayload {
1768    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1769        write!(f, "ORIGIN_TAG(addr={})", format_pva_address(&self.address))
1770    }
1771}
1772
1773impl fmt::Display for PvaUnknownPayload {
1774    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1775        let kind = if self.is_control {
1776            "CONTROL"
1777        } else {
1778            "APPLICATION"
1779        };
1780        write!(
1781            f,
1782            "UNKNOWN(cmd={}, type={}, raw_len={})",
1783            self.command, kind, self.raw_len
1784        )
1785    }
1786}
1787
1788// generic display for all payloads
1789impl fmt::Display for PvaPacketCommand {
1790    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1791        match self {
1792            PvaPacketCommand::Control(payload) => write!(f, "{}", payload),
1793            PvaPacketCommand::Search(payload) => write!(f, "{}", payload),
1794            PvaPacketCommand::SearchResponse(payload) => write!(f, "{}", payload),
1795            PvaPacketCommand::Beacon(payload) => write!(f, "{}", payload),
1796            PvaPacketCommand::ConnectionValidation(payload) => write!(f, "{}", payload),
1797            PvaPacketCommand::ConnectionValidated(payload) => write!(f, "{}", payload),
1798            PvaPacketCommand::AuthNZ(payload) => write!(f, "{}", payload),
1799            PvaPacketCommand::AclChange(payload) => write!(f, "{}", payload),
1800            PvaPacketCommand::Op(payload) => write!(f, "{}", payload),
1801            PvaPacketCommand::CreateChannel(payload) => write!(f, "{}", payload),
1802            PvaPacketCommand::DestroyChannel(payload) => write!(f, "{}", payload),
1803            PvaPacketCommand::GetField(payload) => write!(f, "{}", payload),
1804            PvaPacketCommand::Message(payload) => write!(f, "{}", payload),
1805            PvaPacketCommand::MultipleData(payload) => write!(f, "{}", payload),
1806            PvaPacketCommand::CancelRequest(payload) => write!(f, "{}", payload),
1807            PvaPacketCommand::DestroyRequest(payload) => write!(f, "{}", payload),
1808            PvaPacketCommand::OriginTag(payload) => write!(f, "{}", payload),
1809            PvaPacketCommand::Echo(bytes) => write!(f, "ECHO ({} bytes)", bytes.len()),
1810            PvaPacketCommand::Unknown(payload) => write!(f, "{}", payload),
1811        }
1812    }
1813}
1814
1815impl fmt::Display for PvaOpPayload {
1816    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1817        let cmd_name = match self.command {
1818            10 => "GET",
1819            11 => "PUT",
1820            12 => "PUT_GET",
1821            13 => "MONITOR",
1822            14 => "ARRAY",
1823            16 => "PROCESS",
1824            20 => "RPC",
1825            _ => "OP",
1826        };
1827
1828        let status_text = if let Some(s) = &self.status {
1829            match &s.message {
1830                Some(m) if !m.is_empty() => format!(" status={} msg='{}'", s.code, m),
1831                _ => format!(" status={}", s.code),
1832            }
1833        } else {
1834            String::new()
1835        };
1836
1837        // Show decoded value if available, otherwise fall back to heuristic strings
1838        let value_text = if let Some(ref decoded) = self.decoded_value {
1839            let formatted = format_compact_value(decoded);
1840            if formatted.is_empty() || formatted == "{}" {
1841                String::new()
1842            } else {
1843                format!(" [{}]", formatted)
1844            }
1845        } else if !self.pv_names.is_empty() {
1846            format!(" data=[{}]", self.pv_names.join(","))
1847        } else {
1848            String::new()
1849        };
1850
1851        if self.is_server {
1852            write!(
1853                f,
1854                "{}(ioid={}, sub=0x{:02x}{}{})",
1855                cmd_name, self.ioid, self.subcmd, status_text, value_text
1856            )
1857        } else {
1858            write!(
1859                f,
1860                "{}(sid={}, ioid={}, sub=0x{:02x}{}{})",
1861                cmd_name, self.sid_or_cid, self.ioid, self.subcmd, status_text, value_text
1862            )
1863        }
1864    }
1865}
1866
1867#[cfg(test)]
1868mod tests {
1869    use super::*;
1870    use crate::spvd_decode::extract_nt_scalar_value;
1871
1872    #[test]
1873    fn destroy_request_decodes_spec_and_legacy_forms() {
1874        // Spec form (pvxs/pvAccessCPP): serverChannelID then requestID.
1875        let mut spec = Vec::new();
1876        spec.extend_from_slice(&7u32.to_le_bytes());
1877        spec.extend_from_slice(&42u32.to_le_bytes());
1878        let p = PvaDestroyRequestPayload::new(&spec, false).unwrap();
1879        assert_eq!((p.sid, p.request_id), (7, 42));
1880
1881        // Legacy 4-byte spvirit form: requestID only.
1882        let legacy = 42u32.to_le_bytes();
1883        let p = PvaDestroyRequestPayload::new(&legacy, false).unwrap();
1884        assert_eq!((p.sid, p.request_id), (0, 42));
1885
1886        assert!(PvaDestroyRequestPayload::new(&[0u8; 3], false).is_none());
1887    }
1888    use crate::spvd_encode::{
1889        encode_nt_payload_bitset_parts, encode_nt_scalar_bitset_parts, encode_size_pvd,
1890        nt_payload_desc, nt_scalar_desc,
1891    };
1892    use crate::spvirit_encode::encode_header;
1893    use spvirit_types::{NtPayload, NtScalar, NtScalarArray, ScalarArrayValue, ScalarValue};
1894
1895    #[test]
1896    fn test_decode_status_ok() {
1897        let raw = [0xff];
1898        let (status, consumed) = decode_status(&raw, false);
1899        assert!(status.is_none());
1900        assert_eq!(consumed, 1);
1901    }
1902
1903    #[test]
1904    fn test_decode_status_message() {
1905        let raw = [1u8, 2, b'h', b'i', 2, b's', b't'];
1906        let (status, consumed) = decode_status(&raw, false);
1907        assert_eq!(consumed, 7);
1908        let status = status.unwrap();
1909        assert_eq!(status.code, 1);
1910        assert_eq!(status.message.as_deref(), Some("hi"));
1911        assert_eq!(status.stack.as_deref(), Some("st"));
1912    }
1913
1914    #[test]
1915    fn test_search_response_decode() {
1916        let mut raw: Vec<u8> = vec![];
1917        raw.extend_from_slice(&[0u8; 12]); // guid
1918        raw.extend_from_slice(&1u32.to_le_bytes()); // seq
1919        raw.extend_from_slice(&[0u8; 16]); // addr
1920        raw.extend_from_slice(&5076u16.to_le_bytes()); // port
1921        raw.push(3); // protocol size
1922        raw.extend_from_slice(b"tcp");
1923        raw.push(1); // found
1924        raw.extend_from_slice(&1u16.to_le_bytes()); // count
1925        raw.extend_from_slice(&42u32.to_le_bytes()); // cid
1926
1927        let decoded = PvaSearchResponsePayload::new(&raw, false).unwrap();
1928        assert!(decoded.found);
1929        assert_eq!(decoded.protocol, "tcp");
1930        assert_eq!(decoded.cids, vec![42u32]);
1931    }
1932
1933    fn build_monitor_packet(ioid: u32, subcmd: u8, body: &[u8]) -> Vec<u8> {
1934        let mut payload = Vec::new();
1935        payload.extend_from_slice(&ioid.to_le_bytes());
1936        payload.push(subcmd);
1937        payload.extend_from_slice(body);
1938        let mut out = encode_header(true, false, false, 2, 13, payload.len() as u32);
1939        out.extend_from_slice(&payload);
1940        out
1941    }
1942
1943    /// Strict mode decodes the specification layout: changed bitset, data,
1944    /// overrun bitset.
1945    #[test]
1946    fn test_monitor_decode_spec_order_strict() {
1947        let nt = NtScalar::from_value(ScalarValue::F64(3.5));
1948        let desc = nt_scalar_desc(&nt.value);
1949        let (changed_bitset, values) = encode_nt_scalar_bitset_parts(&nt, false);
1950
1951        let mut body_spec = Vec::new();
1952        body_spec.extend_from_slice(&changed_bitset);
1953        body_spec.extend_from_slice(&values);
1954        body_spec.extend_from_slice(&encode_size_pvd(0, false));
1955
1956        let pkt = build_monitor_packet(1, 0x00, &body_spec);
1957        let mut pva = PvaPacket::new(&pkt);
1958        let mut cmd = pva.decode_payload().expect("decoded");
1959        if let PvaPacketCommand::Op(ref mut op) = cmd {
1960            op.decode_with_field_desc(&desc, false, DecodeMode::Strict)
1961                .expect("spec-order body decodes");
1962            let decoded = op.decoded_value.as_ref().expect("decoded");
1963            let value = extract_nt_scalar_value(decoded).expect("value");
1964            match value {
1965                DecodedValue::Float64(v) => assert!((*v - 3.5).abs() < 1e-6),
1966                other => panic!("unexpected value {:?}", other),
1967            }
1968        } else {
1969            panic!("unexpected cmd");
1970        }
1971    }
1972
1973    /// The two non-specification layouts this codec used to guess at are now
1974    /// only reachable through the lenient decoder. Ported from the old
1975    /// `test_monitor_decode_overrun_and_legacy`, which relied on
1976    /// `decode_with_field_desc` scoring all three variants.
1977    #[test]
1978    fn test_monitor_decode_non_spec_layouts_via_lenient() {
1979        use crate::monitor::MonitorLayout;
1980        use crate::spvd_decode::PvdDecoder;
1981
1982        let nt = NtScalar::from_value(ScalarValue::F64(3.5));
1983        let desc = nt_scalar_desc(&nt.value);
1984        let (changed_bitset, values) = encode_nt_scalar_bitset_parts(&nt, false);
1985        let decoder = PvdDecoder::new(false);
1986
1987        // changed bitset, overrun bitset, data.
1988        let mut body_overrun = Vec::new();
1989        body_overrun.extend_from_slice(&changed_bitset);
1990        body_overrun.extend_from_slice(&encode_size_pvd(0, false));
1991        body_overrun.extend_from_slice(&values);
1992
1993        let (update, layout) = decoder
1994            .decode_monitor_update_lenient(&body_overrun, &desc)
1995            .expect("overrun-before-data body decodes");
1996        assert_eq!(layout, MonitorLayout::OverrunBeforeData);
1997        match extract_nt_scalar_value(&update.value).expect("value") {
1998            DecodedValue::Float64(v) => assert!((*v - 3.5).abs() < 1e-6),
1999            other => panic!("unexpected value {:?}", other),
2000        }
2001
2002        // changed bitset, data, no overrun bitset.
2003        let mut body_legacy = Vec::new();
2004        body_legacy.extend_from_slice(&changed_bitset);
2005        body_legacy.extend_from_slice(&values);
2006
2007        let (update, layout) = decoder
2008            .decode_monitor_update_lenient(&body_legacy, &desc)
2009            .expect("changed-only body decodes");
2010        assert_eq!(layout, MonitorLayout::ChangedOnly);
2011        match extract_nt_scalar_value(&update.value).expect("value") {
2012            DecodedValue::Float64(v) => assert!((*v - 3.5).abs() < 1e-6),
2013            other => panic!("unexpected value {:?}", other),
2014        }
2015    }
2016
2017    #[test]
2018    fn test_monitor_decode_prefers_spec_order_for_array_payload() {
2019        let payload_value =
2020            NtPayload::ScalarArray(NtScalarArray::from_value(ScalarArrayValue::F64(vec![
2021                1.0, 2.0, 3.0, 4.0,
2022            ])));
2023        let desc = nt_payload_desc(&payload_value);
2024        let (changed_bitset, values) = encode_nt_payload_bitset_parts(&payload_value, false);
2025
2026        let mut body_spec = Vec::new();
2027        body_spec.extend_from_slice(&changed_bitset);
2028        body_spec.extend_from_slice(&values);
2029        body_spec.extend_from_slice(&encode_size_pvd(0, false));
2030
2031        let pkt = build_monitor_packet(11, 0x00, &body_spec);
2032        let mut pva = PvaPacket::new(&pkt);
2033        let mut cmd = pva.decode_payload().expect("decoded");
2034        if let PvaPacketCommand::Op(ref mut op) = cmd {
2035            op.decode_with_field_desc(&desc, false, DecodeMode::Strict)
2036                .expect("spec-order body decodes");
2037            let decoded = op.decoded_value.as_ref().expect("decoded");
2038            let value = extract_nt_scalar_value(decoded).expect("value");
2039            match value {
2040                DecodedValue::Array(items) => {
2041                    assert_eq!(items.len(), 4);
2042                    assert!(matches!(items[0], DecodedValue::Float64(v) if (v - 1.0).abs() < 1e-6));
2043                    assert!(matches!(items[3], DecodedValue::Float64(v) if (v - 4.0).abs() < 1e-6));
2044                }
2045                other => panic!("unexpected value {:?}", other),
2046            }
2047        } else {
2048            panic!("unexpected cmd");
2049        }
2050    }
2051
2052    #[test]
2053    fn pva_status_reports_error_state() {
2054        let ok = PvaStatus {
2055            code: 0,
2056            message: None,
2057            stack: None,
2058        };
2059        let err = PvaStatus {
2060            code: 2,
2061            message: Some("bad".to_string()),
2062            stack: None,
2063        };
2064        assert!(!ok.is_error());
2065        assert!(err.is_error());
2066    }
2067
2068    #[test]
2069    fn pva_status_display_includes_message_and_stack() {
2070        let status = PvaStatus {
2071            code: 2,
2072            message: Some("bad".to_string()),
2073            stack: Some("trace".to_string()),
2074        };
2075        assert_eq!(status.to_string(), "code=2 message=bad stack=trace");
2076    }
2077
2078    #[test]
2079    fn decode_op_response_status_reads_status_from_packet() {
2080        let raw = vec![
2081            0xCA, 0x02, 0x40, 0x0B, 0x0A, 0x00, 0x00, 0x00, 0x11, 0x22, 0x33, 0x44, 0x00, 0x02,
2082            0x03, b'b', b'a', b'd', 0x00,
2083        ];
2084        let status = decode_op_response_status(&raw, false)
2085            .expect("status parse")
2086            .expect("status");
2087        assert!(status.is_error());
2088        assert_eq!(status.message.as_deref(), Some("bad"));
2089    }
2090
2091    #[test]
2092    fn connection_validation_decodes_ca_user_and_host() {
2093        // Client->server ConnectionValidation with method "ca" and a trailing
2094        // credentials structure { string user; string host }.
2095        // Build via the crate encoder to avoid hand-rolling PVD bytes.
2096        let raw = crate::spvirit_encode::encode_connection_validation_client_ca(
2097            0x10000, // buffer_size
2098            1,       // introspection size
2099            0,       // qos
2100            "ca",    // method
2101            "operator1",
2102            "ws-42.lab",
2103            /* is_be = */ true,
2104        );
2105        let p = PvaConnectionValidationPayload::new(&raw, true, false).expect("decode");
2106        assert_eq!(p.authz.as_deref(), Some("ca"));
2107        assert_eq!(p.user.as_deref(), Some("operator1"));
2108        assert_eq!(p.host.as_deref(), Some("ws-42.lab"));
2109    }
2110
2111    #[test]
2112    fn connection_validation_anonymous_has_no_user() {
2113        let raw = crate::spvirit_encode::encode_connection_validation_client_anon(
2114            0x10000, 1, 0, "anonymous", true,
2115        );
2116        let p = PvaConnectionValidationPayload::new(&raw, true, false).expect("decode");
2117        assert_eq!(p.authz.as_deref(), Some("anonymous"));
2118        assert_eq!(p.user, None);
2119        assert_eq!(p.host, None);
2120    }
2121}