Skip to main content

auth_framework/protocols/
tacacs.rs

1//! TACACS+ (Terminal Access Controller Access-Control System Plus) protocol support.
2//!
3//! Provides TACACS+ packet construction, obfuscation/deobfuscation, and
4//! authentication/authorization/accounting message handling per RFC 8907.
5
6use crate::errors::{AuthError, Result};
7use ring::digest::{Context, SHA256};
8use serde::{Deserialize, Serialize};
9
10/// TACACS+ protocol version.
11const TACACS_MAJOR_VERSION: u8 = 0xC0; // Major version 12 (0xC)
12const TACACS_MINOR_VERSION_DEFAULT: u8 = 0x00;
13
14/// TACACS+ packet types.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
16#[repr(u8)]
17pub enum TacacsPacketType {
18    Authentication = 0x01,
19    Authorization = 0x02,
20    Accounting = 0x03,
21}
22
23/// TACACS+ authentication action.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
25#[repr(u8)]
26pub enum AuthenAction {
27    Login = 0x01,
28    ChangePassword = 0x02,
29    SendAuth = 0x04,
30}
31
32/// TACACS+ authentication type.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
34#[repr(u8)]
35pub enum AuthenType {
36    Ascii = 0x01,
37    Pap = 0x02,
38    Chap = 0x03,
39    MSChap = 0x05,
40    MSChapV2 = 0x06,
41}
42
43/// TACACS+ authentication service.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
45#[repr(u8)]
46pub enum AuthenService {
47    None = 0x00,
48    Login = 0x01,
49    Enable = 0x02,
50    Ppp = 0x03,
51}
52
53/// TACACS+ authentication status (reply).
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
55#[repr(u8)]
56pub enum AuthenStatus {
57    Pass = 0x01,
58    Fail = 0x02,
59    GetData = 0x03,
60    GetUser = 0x04,
61    GetPass = 0x05,
62    Restart = 0x06,
63    Error = 0x07,
64    Follow = 0x21,
65}
66
67/// TACACS+ authorization status.
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
69#[repr(u8)]
70pub enum AuthorStatus {
71    PassAdd = 0x01,
72    PassReplace = 0x02,
73    Fail = 0x10,
74    Error = 0x11,
75    Follow = 0x21,
76}
77
78/// TACACS+ accounting flags.
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
80pub struct AcctFlags(pub u8);
81
82impl AcctFlags {
83    pub const START: Self = Self(0x02);
84    pub const STOP: Self = Self(0x04);
85    pub const WATCHDOG: Self = Self(0x08);
86
87    pub fn is_start(self) -> bool {
88        self.0 & 0x02 != 0
89    }
90    pub fn is_stop(self) -> bool {
91        self.0 & 0x04 != 0
92    }
93    pub fn is_watchdog(self) -> bool {
94        self.0 & 0x08 != 0
95    }
96}
97
98/// TACACS+ packet header (12 bytes).
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct TacacsHeader {
101    pub version: u8,
102    pub packet_type: TacacsPacketType,
103    pub seq_no: u8,
104    pub flags: u8,
105    pub session_id: u32,
106    pub length: u32,
107}
108
109impl TacacsHeader {
110    /// Create a new TACACS+ header.
111    pub fn new(packet_type: TacacsPacketType, seq_no: u8, session_id: u32, body_len: u32) -> Self {
112        Self {
113            version: TACACS_MAJOR_VERSION | TACACS_MINOR_VERSION_DEFAULT,
114            packet_type,
115            seq_no,
116            flags: 0x00, // encrypted (not unencrypted)
117            session_id,
118            length: body_len,
119        }
120    }
121
122    /// Serialize header to 12-byte wire format.
123    pub fn to_bytes(&self) -> [u8; 12] {
124        let mut buf = [0u8; 12];
125        buf[0] = self.version;
126        buf[1] = self.packet_type as u8;
127        buf[2] = self.seq_no;
128        buf[3] = self.flags;
129        buf[4..8].copy_from_slice(&self.session_id.to_be_bytes());
130        buf[8..12].copy_from_slice(&self.length.to_be_bytes());
131        buf
132    }
133
134    /// Parse header from 12-byte wire format.
135    pub fn from_bytes(data: &[u8]) -> Result<Self> {
136        if data.len() < 12 {
137            return Err(AuthError::validation("TACACS+ header too short"));
138        }
139        let packet_type = match data[1] {
140            0x01 => TacacsPacketType::Authentication,
141            0x02 => TacacsPacketType::Authorization,
142            0x03 => TacacsPacketType::Accounting,
143            other => {
144                return Err(AuthError::validation(format!(
145                    "Unknown TACACS+ packet type: {other:#x}"
146                )));
147            }
148        };
149        Ok(Self {
150            version: data[0],
151            packet_type,
152            seq_no: data[2],
153            flags: data[3],
154            session_id: u32::from_be_bytes([data[4], data[5], data[6], data[7]]),
155            length: u32::from_be_bytes([data[8], data[9], data[10], data[11]]),
156        })
157    }
158}
159
160/// TACACS+ authentication START body.
161#[derive(Debug, Clone, Serialize, Deserialize)]
162pub struct AuthenStartBody {
163    pub action: AuthenAction,
164    pub authen_type: AuthenType,
165    pub authen_service: AuthenService,
166    pub user: String,
167    pub port: String,
168    pub remote_address: String,
169    pub data: Vec<u8>,
170}
171
172impl AuthenStartBody {
173    /// Serialize to wire format.
174    pub fn to_bytes(&self) -> Vec<u8> {
175        let user_bytes = self.user.as_bytes();
176        let port_bytes = self.port.as_bytes();
177        let rem_bytes = self.remote_address.as_bytes();
178        let mut buf = Vec::with_capacity(
179            8 + user_bytes.len() + port_bytes.len() + rem_bytes.len() + self.data.len(),
180        );
181
182        buf.push(self.action as u8);
183        buf.push(0x01); // priv_lvl = 1
184        buf.push(self.authen_type as u8);
185        buf.push(self.authen_service as u8);
186        buf.push(user_bytes.len() as u8);
187        buf.push(port_bytes.len() as u8);
188        buf.push(rem_bytes.len() as u8);
189        buf.push(self.data.len() as u8);
190        buf.extend_from_slice(user_bytes);
191        buf.extend_from_slice(port_bytes);
192        buf.extend_from_slice(rem_bytes);
193        buf.extend_from_slice(&self.data);
194
195        buf
196    }
197}
198
199/// TACACS+ authentication REPLY body.
200#[derive(Debug, Clone, Serialize, Deserialize)]
201pub struct AuthenReplyBody {
202    pub status: AuthenStatus,
203    pub flags: u8,
204    pub server_msg: String,
205    pub data: Vec<u8>,
206}
207
208impl AuthenReplyBody {
209    /// Parse from wire-format bytes.
210    pub fn from_bytes(data: &[u8]) -> Result<Self> {
211        if data.len() < 6 {
212            return Err(AuthError::validation("TACACS+ authen reply too short"));
213        }
214        let status = match data[0] {
215            0x01 => AuthenStatus::Pass,
216            0x02 => AuthenStatus::Fail,
217            0x03 => AuthenStatus::GetData,
218            0x04 => AuthenStatus::GetUser,
219            0x05 => AuthenStatus::GetPass,
220            0x06 => AuthenStatus::Restart,
221            0x07 => AuthenStatus::Error,
222            0x21 => AuthenStatus::Follow,
223            other => {
224                return Err(AuthError::validation(format!(
225                    "Unknown authen status: {other:#x}"
226                )));
227            }
228        };
229        let flags = data[1];
230        let server_msg_len = u16::from_be_bytes([data[2], data[3]]) as usize;
231        let data_len = u16::from_be_bytes([data[4], data[5]]) as usize;
232
233        if data.len() < 6 + server_msg_len + data_len {
234            return Err(AuthError::validation("TACACS+ authen reply truncated"));
235        }
236
237        let server_msg = String::from_utf8_lossy(&data[6..6 + server_msg_len]).to_string();
238        let reply_data = data[6 + server_msg_len..6 + server_msg_len + data_len].to_vec();
239
240        Ok(Self {
241            status,
242            flags,
243            server_msg,
244            data: reply_data,
245        })
246    }
247}
248
249// ── Authorization request/reply (RFC 8907 §6) ──────────────────────
250
251/// TACACS+ authorization REQUEST body.
252#[derive(Debug, Clone, Serialize, Deserialize)]
253pub struct AuthorRequestBody {
254    pub authen_method: u8,
255    pub authen_type: AuthenType,
256    pub authen_service: AuthenService,
257    pub user: String,
258    pub port: String,
259    pub remote_address: String,
260    /// Attribute-value pairs (e.g. "service=shell", "cmd=show").
261    pub args: Vec<String>,
262}
263
264impl AuthorRequestBody {
265    /// Serialize to wire format.
266    pub fn to_bytes(&self) -> Vec<u8> {
267        let user_bytes = self.user.as_bytes();
268        let port_bytes = self.port.as_bytes();
269        let rem_bytes = self.remote_address.as_bytes();
270        let arg_count = self.args.len() as u8;
271
272        // Fixed header: 8 bytes + arg_count * 1 (arg lengths) + variable fields
273        let mut buf = Vec::new();
274        buf.push(self.authen_method);
275        buf.push(0x01); // priv_lvl
276        buf.push(self.authen_type as u8);
277        buf.push(self.authen_service as u8);
278        buf.push(user_bytes.len() as u8);
279        buf.push(port_bytes.len() as u8);
280        buf.push(rem_bytes.len() as u8);
281        buf.push(arg_count);
282
283        // Argument lengths
284        for arg in &self.args {
285            buf.push(arg.len() as u8);
286        }
287
288        // Variable fields
289        buf.extend_from_slice(user_bytes);
290        buf.extend_from_slice(port_bytes);
291        buf.extend_from_slice(rem_bytes);
292        for arg in &self.args {
293            buf.extend_from_slice(arg.as_bytes());
294        }
295
296        buf
297    }
298}
299
300/// TACACS+ authorization REPLY body.
301#[derive(Debug, Clone, Serialize, Deserialize)]
302pub struct AuthorReplyBody {
303    pub status: AuthorStatus,
304    pub server_msg: String,
305    pub data: Vec<u8>,
306    pub args: Vec<String>,
307}
308
309impl AuthorReplyBody {
310    /// Parse from wire-format bytes.
311    pub fn from_bytes(data: &[u8]) -> Result<Self> {
312        if data.len() < 6 {
313            return Err(AuthError::validation("TACACS+ author reply too short"));
314        }
315        let status = match data[0] {
316            0x01 => AuthorStatus::PassAdd,
317            0x02 => AuthorStatus::PassReplace,
318            0x10 => AuthorStatus::Fail,
319            0x11 => AuthorStatus::Error,
320            0x21 => AuthorStatus::Follow,
321            other => {
322                return Err(AuthError::validation(format!(
323                    "Unknown author status: {other:#x}"
324                )));
325            }
326        };
327
328        let arg_count = data[1] as usize;
329        let server_msg_len = u16::from_be_bytes([data[2], data[3]]) as usize;
330        let data_len = u16::from_be_bytes([data[4], data[5]]) as usize;
331
332        let mut offset = 6;
333        // Read arg lengths
334        if data.len() < offset + arg_count {
335            return Err(AuthError::validation(
336                "TACACS+ author reply truncated (arg lengths)",
337            ));
338        }
339        let arg_lens: Vec<usize> = data[offset..offset + arg_count]
340            .iter()
341            .map(|&b| b as usize)
342            .collect();
343        offset += arg_count;
344
345        // server_msg
346        if data.len() < offset + server_msg_len {
347            return Err(AuthError::validation(
348                "TACACS+ author reply truncated (msg)",
349            ));
350        }
351        let server_msg =
352            String::from_utf8_lossy(&data[offset..offset + server_msg_len]).to_string();
353        offset += server_msg_len;
354
355        // data
356        if data.len() < offset + data_len {
357            return Err(AuthError::validation(
358                "TACACS+ author reply truncated (data)",
359            ));
360        }
361        let reply_data = data[offset..offset + data_len].to_vec();
362        offset += data_len;
363
364        // args
365        let mut args = Vec::with_capacity(arg_count);
366        for &len in &arg_lens {
367            if data.len() < offset + len {
368                return Err(AuthError::validation(
369                    "TACACS+ author reply truncated (args)",
370                ));
371            }
372            args.push(String::from_utf8_lossy(&data[offset..offset + len]).to_string());
373            offset += len;
374        }
375
376        Ok(Self {
377            status,
378            server_msg,
379            data: reply_data,
380            args,
381        })
382    }
383}
384
385// ── Accounting request/reply (RFC 8907 §7) ──────────────────────────
386
387/// TACACS+ accounting REQUEST body.
388#[derive(Debug, Clone, Serialize, Deserialize)]
389pub struct AcctRequestBody {
390    pub flags: AcctFlags,
391    pub authen_method: u8,
392    pub authen_type: AuthenType,
393    pub authen_service: AuthenService,
394    pub user: String,
395    pub port: String,
396    pub remote_address: String,
397    /// Attribute-value pairs (e.g. "task_id=1", "start_time=now").
398    pub args: Vec<String>,
399}
400
401impl AcctRequestBody {
402    /// Serialize to wire format.
403    pub fn to_bytes(&self) -> Vec<u8> {
404        let user_bytes = self.user.as_bytes();
405        let port_bytes = self.port.as_bytes();
406        let rem_bytes = self.remote_address.as_bytes();
407        let arg_count = self.args.len() as u8;
408
409        let mut buf = Vec::new();
410        buf.push(self.flags.0);
411        buf.push(self.authen_method);
412        buf.push(0x01); // priv_lvl
413        buf.push(self.authen_type as u8);
414        buf.push(self.authen_service as u8);
415        buf.push(user_bytes.len() as u8);
416        buf.push(port_bytes.len() as u8);
417        buf.push(rem_bytes.len() as u8);
418        buf.push(arg_count);
419
420        // Argument lengths
421        for arg in &self.args {
422            buf.push(arg.len() as u8);
423        }
424
425        // Variable fields
426        buf.extend_from_slice(user_bytes);
427        buf.extend_from_slice(port_bytes);
428        buf.extend_from_slice(rem_bytes);
429        for arg in &self.args {
430            buf.extend_from_slice(arg.as_bytes());
431        }
432
433        buf
434    }
435}
436
437/// TACACS+ accounting status.
438#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
439#[repr(u8)]
440pub enum AcctStatus {
441    Success = 0x01,
442    Error = 0x02,
443    Follow = 0x21,
444}
445
446/// TACACS+ accounting REPLY body.
447#[derive(Debug, Clone, Serialize, Deserialize)]
448pub struct AcctReplyBody {
449    pub status: AcctStatus,
450    pub server_msg: String,
451    pub data: Vec<u8>,
452}
453
454impl AcctReplyBody {
455    /// Parse from wire-format bytes.
456    pub fn from_bytes(data: &[u8]) -> Result<Self> {
457        if data.len() < 5 {
458            return Err(AuthError::validation("TACACS+ acct reply too short"));
459        }
460        let server_msg_len = u16::from_be_bytes([data[0], data[1]]) as usize;
461        let data_len = u16::from_be_bytes([data[2], data[3]]) as usize;
462        let status = match data[4] {
463            0x01 => AcctStatus::Success,
464            0x02 => AcctStatus::Error,
465            0x21 => AcctStatus::Follow,
466            other => {
467                return Err(AuthError::validation(format!(
468                    "Unknown acct status: {other:#x}"
469                )));
470            }
471        };
472
473        let offset = 5;
474        if data.len() < offset + server_msg_len + data_len {
475            return Err(AuthError::validation("TACACS+ acct reply truncated"));
476        }
477        let server_msg =
478            String::from_utf8_lossy(&data[offset..offset + server_msg_len]).to_string();
479        let reply_data = data[offset + server_msg_len..offset + server_msg_len + data_len].to_vec();
480
481        Ok(Self {
482            status,
483            server_msg,
484            data: reply_data,
485        })
486    }
487}
488
489/// Obfuscate or deobfuscate a TACACS+ packet body using the shared secret.
490///
491/// The same function is used for both encryption and decryption (XOR-based).
492/// Per RFC 8907 §4.6:
493///   pseudo_pad = MD5(session_id || key || version || seq_no || previous_pad...)
494pub fn obfuscate(header: &TacacsHeader, secret: &[u8], body: &mut [u8]) {
495    if secret.is_empty() || body.is_empty() {
496        return;
497    }
498
499    let mut pad = Vec::new();
500    let session_id_bytes = header.session_id.to_be_bytes();
501
502    // First block
503    let mut ctx = Context::new(&SHA256);
504    ctx.update(&session_id_bytes);
505    ctx.update(secret);
506    ctx.update(&[header.version]);
507    ctx.update(&[header.seq_no]);
508    let digest = ctx.finish();
509    pad.extend_from_slice(digest.as_ref());
510
511    // Subsequent blocks
512    while pad.len() < body.len() {
513        let mut ctx = Context::new(&SHA256);
514        ctx.update(&session_id_bytes);
515        ctx.update(secret);
516        ctx.update(&[header.version]);
517        ctx.update(&[header.seq_no]);
518        let prev_start = pad.len().saturating_sub(32);
519        ctx.update(&pad[prev_start..]);
520        let digest = ctx.finish();
521        pad.extend_from_slice(digest.as_ref());
522    }
523
524    // XOR
525    for (i, b) in body.iter_mut().enumerate() {
526        *b ^= pad[i];
527    }
528}
529
530/// Generate a random TACACS+ session ID.
531pub fn generate_session_id() -> Result<u32> {
532    use ring::rand::{SecureRandom, SystemRandom};
533    let rng = SystemRandom::new();
534    let mut buf = [0u8; 4];
535    rng.fill(&mut buf)
536        .map_err(|_| AuthError::crypto("Failed to generate session ID".to_string()))?;
537    Ok(u32::from_be_bytes(buf))
538}
539
540#[cfg(test)]
541mod tests {
542    use super::*;
543
544    #[test]
545    fn test_header_roundtrip() {
546        let h = TacacsHeader::new(TacacsPacketType::Authentication, 1, 0xDEADBEEF, 42);
547        let bytes = h.to_bytes();
548        assert_eq!(bytes.len(), 12);
549        let h2 = TacacsHeader::from_bytes(&bytes).unwrap();
550        assert_eq!(h2.packet_type, TacacsPacketType::Authentication);
551        assert_eq!(h2.session_id, 0xDEADBEEF);
552        assert_eq!(h2.length, 42);
553        assert_eq!(h2.seq_no, 1);
554    }
555
556    #[test]
557    fn test_header_too_short() {
558        assert!(TacacsHeader::from_bytes(&[0; 5]).is_err());
559    }
560
561    #[test]
562    fn test_header_unknown_type() {
563        let mut bytes = TacacsHeader::new(TacacsPacketType::Authentication, 1, 1, 0).to_bytes();
564        bytes[1] = 0xFF;
565        assert!(TacacsHeader::from_bytes(&bytes).is_err());
566    }
567
568    #[test]
569    fn test_authen_start_body_serialization() {
570        let body = AuthenStartBody {
571            action: AuthenAction::Login,
572            authen_type: AuthenType::Pap,
573            authen_service: AuthenService::Login,
574            user: "admin".to_string(),
575            port: "tty0".to_string(),
576            remote_address: "10.0.0.1".to_string(),
577            data: b"password".to_vec(),
578        };
579        let bytes = body.to_bytes();
580        assert_eq!(bytes[0], AuthenAction::Login as u8);
581        assert_eq!(bytes[2], AuthenType::Pap as u8);
582        assert_eq!(bytes[4], 5); // user len
583    }
584
585    #[test]
586    fn test_authen_reply_parsing() {
587        let mut data = vec![0x01, 0x00]; // Pass, no flags
588        data.extend_from_slice(&3u16.to_be_bytes()); // server_msg_len=3
589        data.extend_from_slice(&0u16.to_be_bytes()); // data_len=0
590        data.extend_from_slice(b"OK!");
591
592        let reply = AuthenReplyBody::from_bytes(&data).unwrap();
593        assert_eq!(reply.status, AuthenStatus::Pass);
594        assert_eq!(reply.server_msg, "OK!");
595        assert!(reply.data.is_empty());
596    }
597
598    #[test]
599    fn test_authen_reply_too_short() {
600        assert!(AuthenReplyBody::from_bytes(&[0x01, 0x00]).is_err());
601    }
602
603    #[test]
604    fn test_obfuscate_deobfuscate_roundtrip() {
605        let header = TacacsHeader::new(TacacsPacketType::Authentication, 1, 12345, 11);
606        let secret = b"shared-secret";
607        let original = b"hello world".to_vec();
608        let mut encrypted = original.clone();
609
610        obfuscate(&header, secret, &mut encrypted);
611        assert_ne!(encrypted, original, "obfuscation should change data");
612
613        obfuscate(&header, secret, &mut encrypted);
614        assert_eq!(
615            encrypted, original,
616            "double obfuscation should restore data"
617        );
618    }
619
620    #[test]
621    fn test_obfuscate_empty_secret_noop() {
622        let header = TacacsHeader::new(TacacsPacketType::Authentication, 1, 1, 5);
623        let mut data = b"hello".to_vec();
624        let orig = data.clone();
625        obfuscate(&header, b"", &mut data);
626        assert_eq!(data, orig);
627    }
628
629    #[test]
630    fn test_generate_session_id() {
631        let id1 = generate_session_id().unwrap();
632        let id2 = generate_session_id().unwrap();
633        // Probabilistically these should differ (2^32 space)
634        // Just check they don't panic
635        assert!(id1 != 0 || id2 != 0);
636    }
637
638    #[test]
639    fn test_acct_flags() {
640        let start = AcctFlags::START;
641        assert!(start.is_start());
642        assert!(!start.is_stop());
643        assert!(!start.is_watchdog());
644
645        let stop = AcctFlags::STOP;
646        assert!(stop.is_stop());
647    }
648
649    #[test]
650    fn test_packet_type_values() {
651        assert_eq!(TacacsPacketType::Authentication as u8, 0x01);
652        assert_eq!(TacacsPacketType::Authorization as u8, 0x02);
653        assert_eq!(TacacsPacketType::Accounting as u8, 0x03);
654    }
655
656    // ── Authorization ───────────────────────────────────────────
657
658    #[test]
659    fn test_author_request_serialization() {
660        let body = AuthorRequestBody {
661            authen_method: 0x06, // TACACS+
662            authen_type: AuthenType::Pap,
663            authen_service: AuthenService::Login,
664            user: "admin".to_string(),
665            port: "tty0".to_string(),
666            remote_address: "10.0.0.1".to_string(),
667            args: vec!["service=shell".to_string(), "cmd=show".to_string()],
668        };
669        let bytes = body.to_bytes();
670        assert_eq!(bytes[0], 0x06); // authen_method
671        assert_eq!(bytes[7], 2); // arg_count
672        // arg lengths follow
673        assert_eq!(bytes[8], 13); // "service=shell".len()
674        assert_eq!(bytes[9], 8); // "cmd=show".len()
675    }
676
677    #[test]
678    fn test_author_reply_parsing() {
679        // Build: status | arg_cnt | server_msg_len | data_len | arg_lens | msg | data | args
680        let mut data = vec![0x01]; // PassAdd
681        data.push(2); // arg_count
682        data.extend_from_slice(&2u16.to_be_bytes()); // server_msg_len
683        data.extend_from_slice(&0u16.to_be_bytes()); // data_len
684        data.push(6); // arg0 len ("priv=1")
685        data.push(6); // arg1 len ("role=a")
686        data.extend_from_slice(b"OK"); // server_msg
687        // no data
688        data.extend_from_slice(b"priv=1role=a"); // args concatenated
689
690        let reply = AuthorReplyBody::from_bytes(&data).unwrap();
691        assert_eq!(reply.status, AuthorStatus::PassAdd);
692        assert_eq!(reply.server_msg, "OK");
693        assert_eq!(reply.args.len(), 2);
694        assert_eq!(reply.args[0], "priv=1");
695        assert_eq!(reply.args[1], "role=a");
696    }
697
698    #[test]
699    fn test_author_reply_too_short() {
700        assert!(AuthorReplyBody::from_bytes(&[0x01]).is_err());
701    }
702
703    // ── Accounting ──────────────────────────────────────────────
704
705    #[test]
706    fn test_acct_request_serialization() {
707        let body = AcctRequestBody {
708            flags: AcctFlags::START,
709            authen_method: 0x06,
710            authen_type: AuthenType::Pap,
711            authen_service: AuthenService::Login,
712            user: "admin".to_string(),
713            port: "tty0".to_string(),
714            remote_address: "10.0.0.1".to_string(),
715            args: vec!["task_id=1".to_string()],
716        };
717        let bytes = body.to_bytes();
718        assert_eq!(bytes[0], AcctFlags::START.0); // flags
719        assert_eq!(bytes[8], 1); // arg_count
720    }
721
722    #[test]
723    fn test_acct_reply_parsing() {
724        let mut data = Vec::new();
725        data.extend_from_slice(&4u16.to_be_bytes()); // server_msg_len
726        data.extend_from_slice(&0u16.to_be_bytes()); // data_len
727        data.push(0x01); // status = Success
728        data.extend_from_slice(b"Done"); // server_msg
729
730        let reply = AcctReplyBody::from_bytes(&data).unwrap();
731        assert_eq!(reply.status, AcctStatus::Success);
732        assert_eq!(reply.server_msg, "Done");
733    }
734
735    #[test]
736    fn test_acct_reply_too_short() {
737        assert!(AcctReplyBody::from_bytes(&[0x00, 0x01]).is_err());
738    }
739}