Skip to main content

openvpn_mgmt_codec/
codec.rs

1use bytes::{BufMut, BytesMut};
2use std::io;
3use tokio_util::codec::{Decoder, Encoder};
4
5use crate::command::{OvpnCommand, ResponseKind};
6use crate::kill_target::KillTarget;
7use crate::message::{Notification, OvpnMessage};
8use crate::proxy_action::ProxyAction;
9use crate::remote_action::RemoteAction;
10use crate::status_format::StatusFormat;
11use crate::unrecognized::UnrecognizedKind;
12
13/// Escape a string value per the OpenVPN config-file lexer rules and
14/// wrap it in double quotes. This is required for any user-supplied
15/// string that might contain whitespace, backslashes, or quotes —
16/// passwords, reason strings, needstr values, etc.
17///
18/// The escaping rules from the "Command Parsing" section:
19///   `\` → `\\`
20///   `"` → `\"`
21fn quote_and_escape(s: &str) -> String {
22    let mut out = String::with_capacity(s.len() + 2);
23    out.push('"');
24    for c in s.chars() {
25        match c {
26            '\\' => out.push_str("\\\\"),
27            '"' => out.push_str("\\\""),
28            '\n' | '\r' | '\0' => {} // Strip — line-oriented protocol targeting C code.
29            _ => out.push(c),
30        }
31    }
32    out.push('"');
33    out
34}
35
36/// Strip `\n`, `\r`, and `\0` from a string that will be interpolated
37/// into a single-line command without quoting.
38fn sanitize_line(s: &str) -> String {
39    if s.contains('\n') || s.contains('\r') || s.contains('\0') {
40        s.chars()
41            .filter(|&c| c != '\n' && c != '\r' && c != '\0')
42            .collect()
43    } else {
44        s.to_string()
45    }
46}
47
48use crate::client_event::ClientEvent;
49use crate::log_level::LogLevel;
50use crate::openvpn_state::OpenVpnState;
51use crate::transport_protocol::TransportProtocol;
52
53/// Internal state for accumulating multi-line `>CLIENT:` notifications.
54#[derive(Debug)]
55struct ClientNotifAccum {
56    event: ClientEvent,
57    cid: u64,
58    kid: Option<u64>,
59    env: Vec<(String, String)>,
60}
61
62/// Controls how many items the decoder will accumulate in a multi-line
63/// response or `>CLIENT:` ENV block before returning an error.
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub enum AccumulationLimit {
66    /// No limit on accumulated items (the default).
67    Unlimited,
68    /// At most this many items before the decoder returns an error.
69    Max(usize),
70}
71
72/// Tokio codec for the OpenVPN management interface.
73///
74/// The encoder serializes typed `OvpnCommand` values into correct wire-format
75/// bytes, including proper escaping and multi-line block framing. The decoder
76/// uses command-tracking state to correctly distinguish single-line from
77/// multi-line responses, and accumulates multi-line `>CLIENT:` notifications
78/// into a single `OvpnMessage` before emitting them.
79///
80/// # Sequential usage
81///
82/// The OpenVPN management protocol is strictly sequential: each command
83/// produces exactly one response, and the server processes commands one
84/// at a time. This codec tracks which response type to expect from the
85/// last encoded command. **You must fully drain [`decode()`] (until it
86/// returns `Ok(None)` or the expected response is received) before
87/// calling [`encode()`] again.** Encoding a new command while a
88/// multi-line response or CLIENT notification is still being accumulated
89/// will overwrite the tracking state and corrupt decoding.
90///
91/// In debug builds, `encode()` asserts that no accumulation is in
92/// progress.
93pub struct OvpnCodec {
94    /// What kind of response we expect from the last command we encoded.
95    /// This resolves the protocol's ambiguity: when we see a line that is
96    /// not `SUCCESS:`, `ERROR:`, or a `>` notification, this field tells
97    /// us whether to treat it as the start of a multi-line block or as a
98    /// standalone value.
99    expected: ResponseKind,
100
101    /// Accumulator for multi-line (END-terminated) command responses.
102    multi_line_buf: Option<Vec<String>>,
103
104    /// Accumulator for multi-line `>CLIENT:` notifications. When this is
105    /// `Some(...)`, the decoder is waiting for `>CLIENT:ENV,END`.
106    client_notif: Option<ClientNotifAccum>,
107
108    /// Maximum lines to accumulate in a multi-line response.
109    max_multi_line_lines: AccumulationLimit,
110
111    /// Maximum ENV entries to accumulate for a `>CLIENT:` notification.
112    max_client_env_entries: AccumulationLimit,
113}
114
115impl OvpnCodec {
116    /// Create a new codec with default state, ready to encode commands and
117    /// decode responses.
118    pub fn new() -> Self {
119        Self {
120            // Before any command is sent, OpenVPN sends a greeting
121            // (`>INFO:...` notification). SuccessOrError is a safe default
122            // because SUCCESS/ERROR/notifications are all self-describing —
123            // this field only matters for ambiguous (non-prefixed) lines.
124            expected: ResponseKind::SuccessOrError,
125            multi_line_buf: None,
126            client_notif: None,
127            max_multi_line_lines: AccumulationLimit::Unlimited,
128            max_client_env_entries: AccumulationLimit::Unlimited,
129        }
130    }
131
132    /// Set the maximum number of lines accumulated in a multi-line
133    /// response before the decoder returns an error.
134    pub fn with_max_multi_line_lines(mut self, limit: AccumulationLimit) -> Self {
135        self.max_multi_line_lines = limit;
136        self
137    }
138
139    /// Set the maximum number of ENV entries accumulated for
140    /// `>CLIENT:` notifications before the decoder returns an error.
141    pub fn with_max_client_env_entries(mut self, limit: AccumulationLimit) -> Self {
142        self.max_client_env_entries = limit;
143        self
144    }
145}
146
147fn check_accumulation_limit(
148    current_len: usize,
149    limit: AccumulationLimit,
150    what: &str,
151) -> Result<(), io::Error> {
152    if let AccumulationLimit::Max(max) = limit
153        && current_len >= max
154    {
155        return Err(io::Error::other(format!(
156            "{what} accumulation limit exceeded ({max})"
157        )));
158    }
159    Ok(())
160}
161
162impl Default for OvpnCodec {
163    fn default() -> Self {
164        Self::new()
165    }
166}
167
168// ── Encoder ───────────────────────────────────────────────────────
169
170impl Encoder<OvpnCommand> for OvpnCodec {
171    type Error = io::Error;
172
173    fn encode(&mut self, item: OvpnCommand, dst: &mut BytesMut) -> Result<(), Self::Error> {
174        debug_assert!(
175            self.multi_line_buf.is_none() && self.client_notif.is_none(),
176            "encode() called while the decoder is mid-accumulation \
177             (multi_line_buf or client_notif is active). \
178             Drain decode() before sending a new command."
179        );
180
181        // Record the expected response kind BEFORE writing, so the decoder
182        // is ready when data starts arriving.
183        self.expected = item.expected_response();
184
185        match item {
186            // ── Informational ────────────────────────────────────
187            OvpnCommand::Status(StatusFormat::V1) => write_line(dst, "status"),
188            OvpnCommand::Status(ref fmt) => write_line(dst, &format!("status {fmt}")),
189            OvpnCommand::State => write_line(dst, "state"),
190            OvpnCommand::StateStream(ref m) => write_line(dst, &format!("state {m}")),
191            OvpnCommand::Version => write_line(dst, "version"),
192            OvpnCommand::Pid => write_line(dst, "pid"),
193            OvpnCommand::Help => write_line(dst, "help"),
194            OvpnCommand::Net => write_line(dst, "net"),
195            OvpnCommand::Verb(Some(n)) => write_line(dst, &format!("verb {n}")),
196            OvpnCommand::Verb(None) => write_line(dst, "verb"),
197            OvpnCommand::Mute(Some(n)) => write_line(dst, &format!("mute {n}")),
198            OvpnCommand::Mute(None) => write_line(dst, "mute"),
199
200            // ── Real-time notification control ───────────────────
201            OvpnCommand::Log(ref m) => write_line(dst, &format!("log {m}")),
202            OvpnCommand::Echo(ref m) => write_line(dst, &format!("echo {m}")),
203            OvpnCommand::ByteCount(n) => write_line(dst, &format!("bytecount {n}")),
204
205            // ── Connection control ───────────────────────────────
206            OvpnCommand::Signal(sig) => write_line(dst, &format!("signal {sig}")),
207            OvpnCommand::Kill(KillTarget::CommonName(ref cn)) => {
208                write_line(dst, &format!("kill {}", sanitize_line(cn)));
209            }
210            OvpnCommand::Kill(KillTarget::Address {
211                ref protocol,
212                ref ip,
213                port,
214            }) => {
215                write_line(
216                    dst,
217                    &format!(
218                        "kill {}:{}:{port}",
219                        sanitize_line(protocol),
220                        sanitize_line(ip)
221                    ),
222                );
223            }
224            OvpnCommand::HoldQuery => write_line(dst, "hold"),
225            OvpnCommand::HoldOn => write_line(dst, "hold on"),
226            OvpnCommand::HoldOff => write_line(dst, "hold off"),
227            OvpnCommand::HoldRelease => write_line(dst, "hold release"),
228
229            // ── Authentication ───────────────────────────────────
230            //
231            // Both username and password values MUST be properly escaped.
232            // The auth type is always double-quoted on the wire.
233            OvpnCommand::Username {
234                ref auth_type,
235                ref value,
236            } => {
237                // Per the doc: username "Auth" foo
238                // Values containing special chars must be quoted+escaped:
239                //   username "Auth" "foo\"bar"
240                let at = quote_and_escape(&auth_type.to_string());
241                let val = quote_and_escape(value);
242                write_line(dst, &format!("username {at} {val}"));
243            }
244            OvpnCommand::Password {
245                ref auth_type,
246                ref value,
247            } => {
248                let at = quote_and_escape(&auth_type.to_string());
249                let val = quote_and_escape(value);
250                write_line(dst, &format!("password {at} {val}"));
251            }
252            OvpnCommand::AuthRetry(mode) => write_line(dst, &format!("auth-retry {mode}")),
253            OvpnCommand::ForgetPasswords => write_line(dst, "forget-passwords"),
254
255            // ── Challenge-response ──────────────────────────────
256            OvpnCommand::ChallengeResponse {
257                ref state_id,
258                ref response,
259            } => {
260                let value = format!("CRV1::{state_id}::{response}");
261                let escaped = quote_and_escape(&value);
262                write_line(dst, &format!("password \"Auth\" {escaped}"));
263            }
264            OvpnCommand::StaticChallengeResponse {
265                ref password_b64,
266                ref response_b64,
267            } => {
268                let value = format!("SCRV1:{password_b64}:{response_b64}");
269                let escaped = quote_and_escape(&value);
270                write_line(dst, &format!("password \"Auth\" {escaped}"));
271            }
272
273            // ── Interactive prompts ──────────────────────────────
274            OvpnCommand::NeedOk { ref name, response } => {
275                write_line(dst, &format!("needok {} {response}", sanitize_line(name)));
276            }
277            OvpnCommand::NeedStr {
278                ref name,
279                ref value,
280            } => {
281                let escaped = quote_and_escape(value);
282                write_line(dst, &format!("needstr {} {escaped}", sanitize_line(name)));
283            }
284
285            // ── PKCS#11 ─────────────────────────────────────────
286            OvpnCommand::Pkcs11IdCount => write_line(dst, "pkcs11-id-count"),
287            OvpnCommand::Pkcs11IdGet(idx) => write_line(dst, &format!("pkcs11-id-get {idx}")),
288
289            // ── External key (multi-line command) ────────────────
290            //
291            // Wire format:
292            //   rsa-sig
293            //   BASE64_LINE_1
294            //   BASE64_LINE_2
295            //   END
296            OvpnCommand::RsaSig { ref base64_lines } => write_block(dst, "rsa-sig", base64_lines),
297
298            // ── Client management ────────────────────────────────
299            //
300            // client-auth is a multi-line command:
301            //   client-auth {CID} {KID}
302            //   push "route 10.0.0.0 255.255.0.0"
303            //   END
304            // An empty config_lines produces header + immediate END.
305            OvpnCommand::ClientAuth {
306                cid,
307                kid,
308                ref config_lines,
309            } => write_block(dst, &format!("client-auth {cid} {kid}"), config_lines),
310
311            OvpnCommand::ClientAuthNt { cid, kid } => {
312                write_line(dst, &format!("client-auth-nt {cid} {kid}"));
313            }
314
315            OvpnCommand::ClientDeny {
316                cid,
317                kid,
318                ref reason,
319                ref client_reason,
320            } => {
321                let r = quote_and_escape(reason);
322                match client_reason {
323                    Some(cr) => {
324                        let cr_esc = quote_and_escape(cr);
325                        write_line(dst, &format!("client-deny {cid} {kid} {r} {cr_esc}"));
326                    }
327                    None => write_line(dst, &format!("client-deny {cid} {kid} {r}")),
328                }
329            }
330
331            OvpnCommand::ClientKill { cid, ref message } => match message {
332                Some(msg) => write_line(dst, &format!("client-kill {cid} {}", sanitize_line(msg))),
333                None => write_line(dst, &format!("client-kill {cid}")),
334            },
335
336            // ── Server statistics ─────────────────────────────────
337            OvpnCommand::LoadStats => write_line(dst, "load-stats"),
338
339            // ── Extended client management ───────────────────────
340            OvpnCommand::ClientPendingAuth {
341                cid,
342                kid,
343                ref extra,
344                timeout,
345            } => write_line(
346                dst,
347                &format!(
348                    "client-pending-auth {cid} {kid} {} {timeout}",
349                    sanitize_line(extra)
350                ),
351            ),
352
353            OvpnCommand::CrResponse { ref response } => {
354                write_line(dst, &format!("cr-response {}", sanitize_line(response)))
355            }
356
357            // ── External certificate ─────────────────────────────
358            OvpnCommand::Certificate { ref pem_lines } => {
359                write_block(dst, "certificate", pem_lines);
360            }
361
362            // ── Remote/Proxy ─────────────────────────────────────
363            OvpnCommand::Remote(RemoteAction::Accept) => write_line(dst, "remote ACCEPT"),
364            OvpnCommand::Remote(RemoteAction::Skip) => write_line(dst, "remote SKIP"),
365            OvpnCommand::Remote(RemoteAction::Modify { ref host, port }) => {
366                write_line(dst, &format!("remote MOD {} {port}", sanitize_line(host)));
367            }
368            OvpnCommand::Proxy(ProxyAction::None) => write_line(dst, "proxy NONE"),
369            OvpnCommand::Proxy(ProxyAction::Http {
370                ref host,
371                port,
372                non_cleartext_only,
373            }) => {
374                let nct = if non_cleartext_only { " nct" } else { "" };
375                write_line(
376                    dst,
377                    &format!("proxy HTTP {} {port}{nct}", sanitize_line(host)),
378                );
379            }
380            OvpnCommand::Proxy(ProxyAction::Socks { ref host, port }) => {
381                write_line(dst, &format!("proxy SOCKS {} {port}", sanitize_line(host)));
382            }
383
384            // ── Management interface auth ─────────────────────────
385            // Bare line, no quoting — the management password protocol
386            // does not use the config-file lexer.
387            OvpnCommand::ManagementPassword(ref pw) => {
388                write_line(dst, &sanitize_line(pw));
389            }
390
391            // ── Lifecycle ────────────────────────────────────────
392            OvpnCommand::Exit => write_line(dst, "exit"),
393            OvpnCommand::Quit => write_line(dst, "quit"),
394
395            // ── Escape hatch ─────────────────────────────────────
396            OvpnCommand::Raw(ref cmd) | OvpnCommand::RawMultiLine(ref cmd) => {
397                write_line(dst, &sanitize_line(cmd));
398            }
399        }
400
401        Ok(())
402    }
403}
404
405/// Write a single line followed by `\n`.
406fn write_line(dst: &mut BytesMut, s: &str) {
407    dst.reserve(s.len() + 1);
408    dst.put_slice(s.as_bytes());
409    dst.put_u8(b'\n');
410}
411
412/// Write a multi-line block: header line, body lines, and a terminating `END`.
413///
414/// Body lines are sanitized: `\n`, `\r`, and `\0` are stripped, and any
415/// line that would be exactly `"END"` is escaped to `" END"` so the
416/// server does not treat it as the block terminator.
417fn write_block(dst: &mut BytesMut, header: &str, lines: &[String]) {
418    let total: usize = header.len() + 1 + lines.iter().map(|l| l.len() + 2).sum::<usize>() + 4;
419    dst.reserve(total);
420    dst.put_slice(header.as_bytes());
421    dst.put_u8(b'\n');
422    for line in lines {
423        let needs_sanitize = line.contains('\n') || line.contains('\r') || line.contains('\0');
424        if !needs_sanitize && line != "END" {
425            dst.put_slice(line.as_bytes());
426        } else {
427            let sanitized: String = if needs_sanitize {
428                line.chars()
429                    .filter(|&c| c != '\n' && c != '\r' && c != '\0')
430                    .collect()
431            } else {
432                line.to_string()
433            };
434            if sanitized == "END" {
435                dst.put_slice(b" END");
436            } else {
437                dst.put_slice(sanitized.as_bytes());
438            }
439        }
440        dst.put_u8(b'\n');
441    }
442    dst.put_slice(b"END\n");
443}
444
445// ── Decoder ───────────────────────────────────────────────────────
446
447impl Decoder for OvpnCodec {
448    type Item = OvpnMessage;
449    type Error = io::Error;
450
451    fn decode(&mut self, src: &mut BytesMut) -> Result<Option<Self::Item>, Self::Error> {
452        loop {
453            // Find the next complete line.
454            let Some(newline_pos) = src.iter().position(|&b| b == b'\n') else {
455                return Ok(None); // Need more data.
456            };
457
458            // Extract the line and advance the buffer past the newline.
459            let line_bytes = src.split_to(newline_pos + 1);
460            let line = match std::str::from_utf8(&line_bytes) {
461                Ok(s) => s,
462                Err(e) => {
463                    // Reset all accumulation state so the decoder doesn't
464                    // remain stuck in a half-finished multi-line block.
465                    self.multi_line_buf = None;
466                    self.client_notif = None;
467                    self.expected = ResponseKind::SuccessOrError;
468                    return Err(io::Error::new(io::ErrorKind::InvalidData, e));
469                }
470            }
471            .trim_end_matches(['\r', '\n'])
472            .to_string();
473
474            // ── Phase 1: Multi-line >CLIENT: accumulation ────────
475            //
476            // When we're accumulating a CLIENT notification, >CLIENT:ENV
477            // lines belong to it. The block terminates with >CLIENT:ENV,END.
478            // The spec guarantees atomicity for CLIENT notifications, so
479            // interleaving here should not occur. Any other line (SUCCESS,
480            // ERROR, other notifications) falls through to normal processing
481            // as a defensive measure.
482            if let Some(ref mut accum) = self.client_notif
483                && let Some(rest) = line.strip_prefix(">CLIENT:ENV,")
484            {
485                if rest == "END" {
486                    let finished = self.client_notif.take().expect("guarded by if-let");
487                    return Ok(Some(OvpnMessage::Notification(Notification::Client {
488                        event: finished.event,
489                        cid: finished.cid,
490                        kid: finished.kid,
491                        env: finished.env,
492                    })));
493                } else {
494                    // Parse "key=value" (value may contain '=').
495                    let (k, v) = rest
496                        .split_once('=')
497                        .map(|(k, v)| (k.to_string(), v.to_string()))
498                        .unwrap_or_else(|| (rest.to_string(), String::new()));
499                    check_accumulation_limit(
500                        accum.env.len(),
501                        self.max_client_env_entries,
502                        "client ENV",
503                    )?;
504                    accum.env.push((k, v));
505                    continue; // Next line.
506                }
507            }
508            // Not a >CLIENT:ENV line — fall through to normal processing.
509            // This handles interleaved notifications or unexpected output.
510
511            // ── Phase 2: Multi-line command response accumulation ─
512            if let Some(ref mut buf) = self.multi_line_buf {
513                if line == "END" {
514                    let lines = self.multi_line_buf.take().expect("guarded by if-let");
515                    return Ok(Some(OvpnMessage::MultiLine(lines)));
516                }
517                // The spec only guarantees atomicity for CLIENT notifications,
518                // not for command responses — real-time notifications (>STATE:,
519                // >LOG:, etc.) can arrive mid-response. Emit them immediately
520                // without breaking the accumulation.
521                if line.starts_with('>') {
522                    if let Some(msg) = self.parse_notification(&line) {
523                        return Ok(Some(msg));
524                    }
525                    // parse_notification returns None when it starts a CLIENT
526                    // accumulation. Loop to read the next line.
527                    continue;
528                }
529                check_accumulation_limit(
530                    buf.len(),
531                    self.max_multi_line_lines,
532                    "multi-line response",
533                )?;
534                buf.push(line);
535                continue; // Next line.
536            }
537
538            // ── Phase 3: Self-describing lines ───────────────────
539            //
540            // SUCCESS: and ERROR: are unambiguous. We match on "SUCCESS:"
541            // without requiring a trailing space — the doc shows
542            // "SUCCESS: [text]" but text could be empty.
543            if let Some(rest) = line.strip_prefix("SUCCESS:") {
544                return Ok(Some(OvpnMessage::Success(
545                    rest.strip_prefix(' ').unwrap_or(rest).to_string(),
546                )));
547            }
548            if let Some(rest) = line.strip_prefix("ERROR:") {
549                return Ok(Some(OvpnMessage::Error(
550                    rest.strip_prefix(' ').unwrap_or(rest).to_string(),
551                )));
552            }
553
554            // Management interface password prompt (no `>` prefix).
555            if line == "ENTER PASSWORD:" {
556                return Ok(Some(OvpnMessage::PasswordPrompt));
557            }
558
559            // Real-time notifications.
560            if line.starts_with('>') {
561                if let Some(msg) = self.parse_notification(&line) {
562                    return Ok(Some(msg));
563                }
564                // Started CLIENT notification accumulation — loop for ENV lines.
565                continue;
566            }
567
568            // ── Phase 4: Ambiguous lines — use command tracking ──
569            //
570            // The line is not self-describing (no SUCCESS/ERROR/> prefix).
571            // Use the expected-response state from the last encoded command
572            // to decide how to frame it.
573            match self.expected {
574                ResponseKind::MultiLine => {
575                    if line == "END" {
576                        // Edge case: empty multi-line block (header-less).
577                        return Ok(Some(OvpnMessage::MultiLine(Vec::new())));
578                    }
579                    self.multi_line_buf = Some(vec![line]);
580                    continue; // Accumulate until END.
581                }
582                ResponseKind::SuccessOrError | ResponseKind::NoResponse => {
583                    return Ok(Some(OvpnMessage::Unrecognized {
584                        line,
585                        kind: UnrecognizedKind::UnexpectedLine,
586                    }));
587                }
588            }
589        }
590    }
591}
592
593impl OvpnCodec {
594    /// Parse a `>` notification line. Returns `Some(msg)` for single-line
595    /// notifications and `None` when a multi-line CLIENT accumulation has
596    /// been started (the caller should continue reading lines).
597    fn parse_notification(&mut self, line: &str) -> Option<OvpnMessage> {
598        let inner = &line[1..]; // Strip leading `>`
599
600        let Some((kind, payload)) = inner.split_once(':') else {
601            // Malformed notification — no colon.
602            return Some(OvpnMessage::Unrecognized {
603                line: line.to_string(),
604                kind: UnrecognizedKind::MalformedNotification,
605            });
606        };
607
608        // >INFO: gets its own message variant for convenience (it's always
609        // the first thing you see on connect).
610        if kind == "INFO" {
611            return Some(OvpnMessage::Info(payload.to_string()));
612        }
613
614        // >CLIENT: may be multi-line. Inspect the sub-type to decide.
615        if kind == "CLIENT" {
616            let (event, args) = payload
617                .split_once(',')
618                .map(|(e, a)| (e.to_string(), a.to_string()))
619                .unwrap_or_else(|| (payload.to_string(), String::new()));
620
621            // ADDRESS notifications are always single-line (no ENV block).
622            if event == "ADDRESS" {
623                let mut parts = args.splitn(3, ',');
624                let cid = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0);
625                let addr = parts.next().unwrap_or("").to_string();
626                let primary = parts.next() == Some("1");
627                return Some(OvpnMessage::Notification(Notification::ClientAddress {
628                    cid,
629                    addr,
630                    primary,
631                }));
632            }
633
634            // CONNECT, REAUTH, ESTABLISHED, DISCONNECT all have ENV blocks.
635            // Parse CID and optional KID from the args (e.g. "0,1" or "5").
636            // Some events (e.g. CR_RESPONSE) have extra trailing data after
637            // CID,KID — we use splitn(3) and only parse the first two.
638            let mut id_parts = args.splitn(3, ',');
639            let cid = id_parts.next().and_then(|s| s.parse().ok()).unwrap_or(0);
640            let kid = id_parts.next().and_then(|s| s.parse().ok());
641
642            // Start accumulation — don't emit anything yet.
643            self.client_notif = Some(ClientNotifAccum {
644                event: ClientEvent::parse(&event),
645                cid,
646                kid,
647                env: Vec::new(),
648            });
649            return None; // Signal to the caller to keep reading.
650        }
651
652        // Dispatch to typed parsers. On parse failure, fall back to Simple.
653        let notification = match kind {
654            "STATE" => parse_state(payload),
655            "BYTECOUNT" => parse_bytecount(payload),
656            "BYTECOUNT_CLI" => parse_bytecount_cli(payload),
657            "LOG" => parse_log(payload),
658            "ECHO" => parse_echo(payload),
659            "HOLD" => Some(Notification::Hold {
660                text: payload.to_string(),
661            }),
662            "FATAL" => Some(Notification::Fatal {
663                message: payload.to_string(),
664            }),
665            "PKCS11ID-COUNT" => parse_pkcs11id_count(payload),
666            "NEED-OK" => parse_need_ok(payload),
667            "NEED-STR" => parse_need_str(payload),
668            "RSA_SIGN" => Some(Notification::RsaSign {
669                data: payload.to_string(),
670            }),
671            "REMOTE" => parse_remote(payload),
672            "PROXY" => parse_proxy(payload),
673            "PASSWORD" => parse_password(payload),
674            "PKCS11ID-ENTRY" => {
675                return parse_pkcs11id_entry_notif(payload).or_else(|| {
676                    Some(OvpnMessage::Notification(Notification::Simple {
677                        kind: kind.to_string(),
678                        payload: payload.to_string(),
679                    }))
680                });
681            }
682            _ => None,
683        };
684
685        Some(OvpnMessage::Notification(notification.unwrap_or(
686            Notification::Simple {
687                kind: kind.to_string(),
688                payload: payload.to_string(),
689            },
690        )))
691    }
692}
693
694// ── Notification parsers ──────────────────────────────────────────
695//
696// Each returns `Option<Notification>`. `None` means "could not parse,
697// fall back to Simple". This is intentional — the protocol varies
698// across OpenVPN versions and we never want a parse failure to
699// produce an error.
700
701fn parse_state(payload: &str) -> Option<Notification> {
702    // Wire format per management-notes.txt:
703    //   (a) timestamp, (b) state, (c) desc, (d) local_ip, (e) remote_ip,
704    //   (f) remote_port, (g) local_addr, (h) local_port, (i) local_ipv6
705    let mut parts = payload.splitn(9, ',');
706    let timestamp = parts.next()?.parse().ok()?;
707    let name = OpenVpnState::parse(parts.next()?);
708    let description = parts.next()?.to_string();
709    let local_ip = parts.next()?.to_string();
710    let remote_ip = parts.next()?.to_string();
711    let remote_port = parts.next().unwrap_or("").to_string();
712    let local_addr = parts.next().unwrap_or("").to_string();
713    let local_port = parts.next().unwrap_or("").to_string();
714    let local_ipv6 = parts.next().unwrap_or("").to_string();
715    Some(Notification::State {
716        timestamp,
717        name,
718        description,
719        local_ip,
720        remote_ip,
721        remote_port,
722        local_addr,
723        local_port,
724        local_ipv6,
725    })
726}
727
728fn parse_bytecount(payload: &str) -> Option<Notification> {
729    let (a, b) = payload.split_once(',')?;
730    Some(Notification::ByteCount {
731        bytes_in: a.parse().ok()?,
732        bytes_out: b.parse().ok()?,
733    })
734}
735
736fn parse_bytecount_cli(payload: &str) -> Option<Notification> {
737    let mut parts = payload.splitn(3, ',');
738    let cid = parts.next()?.parse().ok()?;
739    let bytes_in = parts.next()?.parse().ok()?;
740    let bytes_out = parts.next()?.parse().ok()?;
741    Some(Notification::ByteCountCli {
742        cid,
743        bytes_in,
744        bytes_out,
745    })
746}
747
748fn parse_log(payload: &str) -> Option<Notification> {
749    let (ts_str, rest) = payload.split_once(',')?;
750    let timestamp = ts_str.parse().ok()?;
751    let (level_str, message) = rest.split_once(',')?;
752    Some(Notification::Log {
753        timestamp,
754        level: LogLevel::parse(level_str),
755        message: message.to_string(),
756    })
757}
758
759fn parse_echo(payload: &str) -> Option<Notification> {
760    let (ts_str, param) = payload.split_once(',')?;
761    let timestamp = ts_str.parse().ok()?;
762    Some(Notification::Echo {
763        timestamp,
764        param: param.to_string(),
765    })
766}
767
768fn parse_pkcs11id_count(payload: &str) -> Option<Notification> {
769    let count = payload.trim().parse().ok()?;
770    Some(Notification::Pkcs11IdCount { count })
771}
772
773/// Parse `>PKCS11ID-ENTRY:'idx', ID:'id', BLOB:'blob'` from the notification
774/// payload (after the kind and colon have been stripped).
775fn parse_pkcs11id_entry_notif(payload: &str) -> Option<OvpnMessage> {
776    let rest = payload.strip_prefix('\'')?;
777    let (index, rest) = rest.split_once("', ID:'")?;
778    let (id, rest) = rest.split_once("', BLOB:'")?;
779    let blob = rest.strip_suffix('\'')?;
780    Some(OvpnMessage::Pkcs11IdEntry {
781        index: index.to_string(),
782        id: id.to_string(),
783        blob: blob.to_string(),
784    })
785}
786
787/// Parse `Need 'name' ... MSG:message` from NEED-OK payload.
788fn parse_need_ok(payload: &str) -> Option<Notification> {
789    // Format: Need 'name' confirmation MSG:message
790    let rest = payload.strip_prefix("Need '")?;
791    let (name, rest) = rest.split_once('\'')?;
792    let msg = rest.split_once("MSG:")?.1;
793    Some(Notification::NeedOk {
794        name: name.to_string(),
795        message: msg.to_string(),
796    })
797}
798
799/// Parse `Need 'name' input MSG:message` from NEED-STR payload.
800fn parse_need_str(payload: &str) -> Option<Notification> {
801    let rest = payload.strip_prefix("Need '")?;
802    let (name, rest) = rest.split_once('\'')?;
803    let msg = rest.split_once("MSG:")?.1;
804    Some(Notification::NeedStr {
805        name: name.to_string(),
806        message: msg.to_string(),
807    })
808}
809
810fn parse_remote(payload: &str) -> Option<Notification> {
811    let mut parts = payload.splitn(3, ',');
812    let host = parts.next()?.to_string();
813    let port = parts.next()?.parse().ok()?;
814    let protocol = TransportProtocol::parse(parts.next()?);
815    Some(Notification::Remote {
816        host,
817        port,
818        protocol,
819    })
820}
821
822fn parse_proxy(payload: &str) -> Option<Notification> {
823    // Wire: >PROXY:{index},{type},{host}  (3 fields per init.c)
824    let mut parts = payload.splitn(3, ',');
825    let index = parts.next()?.parse().ok()?;
826    let proxy_type = parts.next()?.to_string();
827    let host = parts.next()?.to_string();
828    Some(Notification::Proxy {
829        index,
830        proxy_type,
831        host,
832    })
833}
834
835use crate::message::PasswordNotification;
836
837use crate::auth::AuthType;
838
839/// Map a wire auth-type string to the typed enum.
840fn parse_auth_type(s: &str) -> AuthType {
841    match s {
842        "Auth" => AuthType::Auth,
843        "Private Key" => AuthType::PrivateKey,
844        "HTTP Proxy" => AuthType::HttpProxy,
845        "SOCKS Proxy" => AuthType::SocksProxy,
846        other => AuthType::Custom(other.to_string()),
847    }
848}
849
850fn parse_password(payload: &str) -> Option<Notification> {
851    // Verification Failed: 'Auth' ['CRV1:flags:state_id:user_b64:challenge']
852    // Verification Failed: 'Auth'
853    if let Some(rest) = payload.strip_prefix("Verification Failed: '") {
854        // Check for CRV1 dynamic challenge data
855        if let Some((auth_part, crv1_part)) = rest.split_once("' ['CRV1:") {
856            let _ = auth_part; // auth type is always "Auth" for CRV1
857            let crv1_data = crv1_part.strip_suffix("']")?;
858            let mut parts = crv1_data.splitn(4, ':');
859            let flags = parts.next()?.to_string();
860            let state_id = parts.next()?.to_string();
861            let username_b64 = parts.next()?.to_string();
862            let challenge = parts.next()?.to_string();
863            return Some(Notification::Password(
864                PasswordNotification::DynamicChallenge {
865                    flags,
866                    state_id,
867                    username_b64,
868                    challenge,
869                },
870            ));
871        }
872        // Bare verification failure
873        let auth_type = rest.strip_suffix('\'')?;
874        return Some(Notification::Password(
875            PasswordNotification::VerificationFailed {
876                auth_type: parse_auth_type(auth_type),
877            },
878        ));
879    }
880
881    // Need 'type' username/password [SC:...]
882    // Need 'type' password
883    let rest = payload.strip_prefix("Need '")?;
884    let (auth_type_str, rest) = rest.split_once('\'')?;
885    let rest = rest.trim_start();
886
887    if let Some(after_up) = rest.strip_prefix("username/password") {
888        let after_up = after_up.trim_start();
889
890        // Static challenge: SC:flag,challenge_text
891        // flag is a multi-bit integer: bit 0 = ECHO, bit 1 = FORMAT/CONCAT
892        if let Some(sc) = after_up.strip_prefix("SC:") {
893            let (flag_str, challenge) = sc.split_once(',')?;
894            let flags: u32 = flag_str.parse().ok()?;
895            return Some(Notification::Password(
896                PasswordNotification::StaticChallenge {
897                    echo: flags & 1 != 0,
898                    response_concat: flags & 2 != 0,
899                    challenge: challenge.to_string(),
900                },
901            ));
902        }
903
904        // Plain username/password request
905        return Some(Notification::Password(PasswordNotification::NeedAuth {
906            auth_type: parse_auth_type(auth_type_str),
907        }));
908    }
909
910    // Need 'type' password
911    if rest.starts_with("password") {
912        return Some(Notification::Password(PasswordNotification::NeedPassword {
913            auth_type: parse_auth_type(auth_type_str),
914        }));
915    }
916
917    None // Unrecognized PASSWORD sub-format — fall back to Simple
918}
919
920#[cfg(test)]
921mod tests {
922    use super::*;
923    use crate::auth::AuthType;
924    use crate::client_event::ClientEvent;
925    use crate::message::PasswordNotification;
926    use crate::signal::Signal;
927    use crate::status_format::StatusFormat;
928    use crate::stream_mode::StreamMode;
929    use bytes::BytesMut;
930    use tokio_util::codec::{Decoder, Encoder};
931
932    /// Helper: encode a command and return the wire bytes as a string.
933    fn encode_to_string(cmd: OvpnCommand) -> String {
934        let mut codec = OvpnCodec::new();
935        let mut buf = BytesMut::new();
936        codec.encode(cmd, &mut buf).unwrap();
937        String::from_utf8(buf.to_vec()).unwrap()
938    }
939
940    /// Helper: feed raw bytes into a fresh codec and collect all decoded messages.
941    fn decode_all(input: &str) -> Vec<OvpnMessage> {
942        let mut codec = OvpnCodec::new();
943        let mut buf = BytesMut::from(input);
944        let mut msgs = Vec::new();
945        while let Some(msg) = codec.decode(&mut buf).unwrap() {
946            msgs.push(msg);
947        }
948        msgs
949    }
950
951    /// Helper: encode a command, then feed raw response bytes, collecting messages.
952    fn encode_then_decode(cmd: OvpnCommand, response: &str) -> Vec<OvpnMessage> {
953        let mut codec = OvpnCodec::new();
954        let mut enc_buf = BytesMut::new();
955        codec.encode(cmd, &mut enc_buf).unwrap();
956        let mut dec_buf = BytesMut::from(response);
957        let mut msgs = Vec::new();
958        while let Some(msg) = codec.decode(&mut dec_buf).unwrap() {
959            msgs.push(msg);
960        }
961        msgs
962    }
963
964    // ── Encoder tests ────────────────────────────────────────────
965
966    #[test]
967    fn encode_status_v1() {
968        assert_eq!(
969            encode_to_string(OvpnCommand::Status(StatusFormat::V1)),
970            "status\n"
971        );
972    }
973
974    #[test]
975    fn encode_status_v3() {
976        assert_eq!(
977            encode_to_string(OvpnCommand::Status(StatusFormat::V3)),
978            "status 3\n"
979        );
980    }
981
982    #[test]
983    fn encode_signal() {
984        assert_eq!(
985            encode_to_string(OvpnCommand::Signal(Signal::SigUsr1)),
986            "signal SIGUSR1\n"
987        );
988    }
989
990    #[test]
991    fn encode_state_on_all() {
992        assert_eq!(
993            encode_to_string(OvpnCommand::StateStream(StreamMode::OnAll)),
994            "state on all\n"
995        );
996    }
997
998    #[test]
999    fn encode_state_recent() {
1000        assert_eq!(
1001            encode_to_string(OvpnCommand::StateStream(StreamMode::Recent(5))),
1002            "state 5\n"
1003        );
1004    }
1005
1006    #[test]
1007    fn encode_password_escaping() {
1008        // A password containing a backslash and a double quote must be
1009        // properly escaped on the wire.
1010        let wire = encode_to_string(OvpnCommand::Password {
1011            auth_type: AuthType::PrivateKey,
1012            value: r#"foo\"bar"#.to_string(),
1013        });
1014        assert_eq!(wire, "password \"Private Key\" \"foo\\\\\\\"bar\"\n");
1015    }
1016
1017    #[test]
1018    fn encode_password_simple() {
1019        let wire = encode_to_string(OvpnCommand::Password {
1020            auth_type: AuthType::Auth,
1021            value: "hunter2".to_string(),
1022        });
1023        assert_eq!(wire, "password \"Auth\" \"hunter2\"\n");
1024    }
1025
1026    #[test]
1027    fn encode_client_auth_with_config() {
1028        let wire = encode_to_string(OvpnCommand::ClientAuth {
1029            cid: 42,
1030            kid: 0,
1031            config_lines: vec![
1032                "push \"route 10.0.0.0 255.255.0.0\"".to_string(),
1033                "push \"dhcp-option DNS 10.0.0.1\"".to_string(),
1034            ],
1035        });
1036        assert_eq!(
1037            wire,
1038            "client-auth 42 0\n\
1039             push \"route 10.0.0.0 255.255.0.0\"\n\
1040             push \"dhcp-option DNS 10.0.0.1\"\n\
1041             END\n"
1042        );
1043    }
1044
1045    #[test]
1046    fn encode_client_auth_empty_config() {
1047        let wire = encode_to_string(OvpnCommand::ClientAuth {
1048            cid: 1,
1049            kid: 0,
1050            config_lines: vec![],
1051        });
1052        assert_eq!(wire, "client-auth 1 0\nEND\n");
1053    }
1054
1055    #[test]
1056    fn encode_client_deny_with_client_reason() {
1057        let wire = encode_to_string(OvpnCommand::ClientDeny {
1058            cid: 5,
1059            kid: 0,
1060            reason: "cert revoked".to_string(),
1061            client_reason: Some("Your access has been revoked.".to_string()),
1062        });
1063        assert_eq!(
1064            wire,
1065            "client-deny 5 0 \"cert revoked\" \"Your access has been revoked.\"\n"
1066        );
1067    }
1068
1069    #[test]
1070    fn encode_rsa_sig() {
1071        let wire = encode_to_string(OvpnCommand::RsaSig {
1072            base64_lines: vec!["AAAA".to_string(), "BBBB".to_string()],
1073        });
1074        assert_eq!(wire, "rsa-sig\nAAAA\nBBBB\nEND\n");
1075    }
1076
1077    #[test]
1078    fn encode_remote_modify() {
1079        let wire = encode_to_string(OvpnCommand::Remote(RemoteAction::Modify {
1080            host: "vpn.example.com".to_string(),
1081            port: 1234,
1082        }));
1083        assert_eq!(wire, "remote MOD vpn.example.com 1234\n");
1084    }
1085
1086    #[test]
1087    fn encode_proxy_http_nct() {
1088        let wire = encode_to_string(OvpnCommand::Proxy(ProxyAction::Http {
1089            host: "proxy.local".to_string(),
1090            port: 8080,
1091            non_cleartext_only: true,
1092        }));
1093        assert_eq!(wire, "proxy HTTP proxy.local 8080 nct\n");
1094    }
1095
1096    #[test]
1097    fn encode_needok() {
1098        use crate::need_ok::NeedOkResponse;
1099        let wire = encode_to_string(OvpnCommand::NeedOk {
1100            name: "token-insertion-request".to_string(),
1101            response: NeedOkResponse::Ok,
1102        });
1103        assert_eq!(wire, "needok token-insertion-request ok\n");
1104    }
1105
1106    #[test]
1107    fn encode_needstr() {
1108        let wire = encode_to_string(OvpnCommand::NeedStr {
1109            name: "name".to_string(),
1110            value: "John".to_string(),
1111        });
1112        assert_eq!(wire, "needstr name \"John\"\n");
1113    }
1114
1115    #[test]
1116    fn encode_forget_passwords() {
1117        assert_eq!(
1118            encode_to_string(OvpnCommand::ForgetPasswords),
1119            "forget-passwords\n"
1120        );
1121    }
1122
1123    #[test]
1124    fn encode_hold_query() {
1125        assert_eq!(encode_to_string(OvpnCommand::HoldQuery), "hold\n");
1126    }
1127
1128    #[test]
1129    fn encode_echo_on_all() {
1130        assert_eq!(
1131            encode_to_string(OvpnCommand::Echo(StreamMode::OnAll)),
1132            "echo on all\n"
1133        );
1134    }
1135
1136    // ── Decoder tests ────────────────────────────────────────────
1137
1138    #[test]
1139    fn decode_success() {
1140        let msgs = decode_all("SUCCESS: pid=12345\n");
1141        assert_eq!(msgs.len(), 1);
1142        assert!(matches!(&msgs[0], OvpnMessage::Success(s) if s == "pid=12345"));
1143    }
1144
1145    #[test]
1146    fn decode_success_bare() {
1147        // Edge case: SUCCESS: with no trailing text.
1148        let msgs = decode_all("SUCCESS:\n");
1149        assert_eq!(msgs.len(), 1);
1150        assert!(matches!(&msgs[0], OvpnMessage::Success(s) if s.is_empty()));
1151    }
1152
1153    #[test]
1154    fn decode_error() {
1155        let msgs = decode_all("ERROR: unknown command\n");
1156        assert_eq!(msgs.len(), 1);
1157        assert!(matches!(&msgs[0], OvpnMessage::Error(s) if s == "unknown command"));
1158    }
1159
1160    #[test]
1161    fn decode_info_notification() {
1162        let msgs = decode_all(">INFO:OpenVPN Management Interface Version 5\n");
1163        assert_eq!(msgs.len(), 1);
1164        assert!(matches!(
1165            &msgs[0],
1166            OvpnMessage::Info(s) if s == "OpenVPN Management Interface Version 5"
1167        ));
1168    }
1169
1170    #[test]
1171    fn decode_state_notification() {
1172        let msgs = decode_all(">STATE:1234567890,CONNECTED,SUCCESS,,10.0.0.1\n");
1173        assert_eq!(msgs.len(), 1);
1174        match &msgs[0] {
1175            OvpnMessage::Notification(Notification::State {
1176                timestamp,
1177                name,
1178                description,
1179                local_ip,
1180                remote_ip,
1181                ..
1182            }) => {
1183                assert_eq!(*timestamp, 1234567890);
1184                assert_eq!(*name, OpenVpnState::Connected);
1185                assert_eq!(description, "SUCCESS");
1186                assert_eq!(local_ip, "");
1187                assert_eq!(remote_ip, "10.0.0.1");
1188            }
1189            other => panic!("unexpected: {other:?}"),
1190        }
1191    }
1192
1193    #[test]
1194    fn decode_multiline_with_command_tracking() {
1195        // After encoding a `status` command, the codec expects a multi-line
1196        // response. Lines that would otherwise be ambiguous are correctly
1197        // accumulated until END.
1198        let msgs = encode_then_decode(
1199            OvpnCommand::Status(StatusFormat::V1),
1200            "OpenVPN CLIENT LIST\nCommon Name,Real Address\ntest,1.2.3.4:1234\nEND\n",
1201        );
1202        assert_eq!(msgs.len(), 1);
1203        match &msgs[0] {
1204            OvpnMessage::MultiLine(lines) => {
1205                assert_eq!(lines.len(), 3);
1206                assert_eq!(lines[0], "OpenVPN CLIENT LIST");
1207                assert_eq!(lines[2], "test,1.2.3.4:1234");
1208            }
1209            other => panic!("unexpected: {other:?}"),
1210        }
1211    }
1212
1213    #[test]
1214    fn decode_hold_query_success() {
1215        // Bare `hold` returns SUCCESS: hold=0 or SUCCESS: hold=1
1216        let msgs = encode_then_decode(OvpnCommand::HoldQuery, "SUCCESS: hold=0\n");
1217        assert_eq!(msgs.len(), 1);
1218        assert!(matches!(&msgs[0], OvpnMessage::Success(s) if s == "hold=0"));
1219    }
1220
1221    #[test]
1222    fn decode_bare_state_multiline() {
1223        // Bare `state` returns state history lines + END
1224        let msgs = encode_then_decode(
1225            OvpnCommand::State,
1226            "1234567890,CONNECTED,SUCCESS,,10.0.0.1,,,,\nEND\n",
1227        );
1228        assert_eq!(msgs.len(), 1);
1229        match &msgs[0] {
1230            OvpnMessage::MultiLine(lines) => {
1231                assert_eq!(lines.len(), 1);
1232                assert!(lines[0].starts_with("1234567890"));
1233            }
1234            other => panic!("unexpected: {other:?}"),
1235        }
1236    }
1237
1238    #[test]
1239    fn decode_notification_during_multiline() {
1240        // A notification can arrive in the middle of a multi-line response.
1241        // It should be emitted immediately without breaking the accumulation.
1242        let msgs = encode_then_decode(
1243            OvpnCommand::Status(StatusFormat::V1),
1244            "header line\n>BYTECOUNT:1000,2000\ndata line\nEND\n",
1245        );
1246        assert_eq!(msgs.len(), 2);
1247        // First emitted message: the interleaved notification.
1248        assert!(matches!(
1249            &msgs[0],
1250            OvpnMessage::Notification(Notification::ByteCount {
1251                bytes_in: 1000,
1252                bytes_out: 2000
1253            })
1254        ));
1255        // Second: the completed multi-line block (notification is not included).
1256        match &msgs[1] {
1257            OvpnMessage::MultiLine(lines) => {
1258                assert_eq!(lines, &["header line", "data line"]);
1259            }
1260            other => panic!("unexpected: {other:?}"),
1261        }
1262    }
1263
1264    #[test]
1265    fn decode_client_connect_multiline_notification() {
1266        let input = "\
1267            >CLIENT:CONNECT,0,1\n\
1268            >CLIENT:ENV,untrusted_ip=1.2.3.4\n\
1269            >CLIENT:ENV,common_name=TestClient\n\
1270            >CLIENT:ENV,END\n";
1271        let msgs = decode_all(input);
1272        assert_eq!(msgs.len(), 1);
1273        match &msgs[0] {
1274            OvpnMessage::Notification(Notification::Client {
1275                event,
1276                cid,
1277                kid,
1278                env,
1279            }) => {
1280                assert_eq!(*event, ClientEvent::Connect);
1281                assert_eq!(*cid, 0);
1282                assert_eq!(*kid, Some(1));
1283                assert_eq!(env.len(), 2);
1284                assert_eq!(env[0], ("untrusted_ip".to_string(), "1.2.3.4".to_string()));
1285                assert_eq!(
1286                    env[1],
1287                    ("common_name".to_string(), "TestClient".to_string())
1288                );
1289            }
1290            other => panic!("unexpected: {other:?}"),
1291        }
1292    }
1293
1294    #[test]
1295    fn decode_client_address_single_line() {
1296        let msgs = decode_all(">CLIENT:ADDRESS,3,10.0.0.5,1\n");
1297        assert_eq!(msgs.len(), 1);
1298        match &msgs[0] {
1299            OvpnMessage::Notification(Notification::ClientAddress { cid, addr, primary }) => {
1300                assert_eq!(*cid, 3);
1301                assert_eq!(addr, "10.0.0.5");
1302                assert!(*primary);
1303            }
1304            other => panic!("unexpected: {other:?}"),
1305        }
1306    }
1307
1308    #[test]
1309    fn decode_client_disconnect() {
1310        let input = "\
1311            >CLIENT:DISCONNECT,5\n\
1312            >CLIENT:ENV,bytes_received=12345\n\
1313            >CLIENT:ENV,bytes_sent=67890\n\
1314            >CLIENT:ENV,END\n";
1315        let msgs = decode_all(input);
1316        assert_eq!(msgs.len(), 1);
1317        match &msgs[0] {
1318            OvpnMessage::Notification(Notification::Client {
1319                event,
1320                cid,
1321                kid,
1322                env,
1323            }) => {
1324                assert_eq!(*event, ClientEvent::Disconnect);
1325                assert_eq!(*cid, 5);
1326                assert_eq!(*kid, None);
1327                assert_eq!(env.len(), 2);
1328            }
1329            other => panic!("unexpected: {other:?}"),
1330        }
1331    }
1332
1333    #[test]
1334    fn decode_password_notification() {
1335        let msgs = decode_all(">PASSWORD:Need 'Auth' username/password\n");
1336        assert_eq!(msgs.len(), 1);
1337        match &msgs[0] {
1338            OvpnMessage::Notification(Notification::Password(PasswordNotification::NeedAuth {
1339                auth_type,
1340            })) => {
1341                assert_eq!(*auth_type, AuthType::Auth);
1342            }
1343            other => panic!("unexpected: {other:?}"),
1344        }
1345    }
1346
1347    #[test]
1348    fn quote_and_escape_special_chars() {
1349        assert_eq!(quote_and_escape(r#"foo"bar"#), r#""foo\"bar""#);
1350        assert_eq!(quote_and_escape(r"a\b"), r#""a\\b""#);
1351        assert_eq!(quote_and_escape("simple"), r#""simple""#);
1352    }
1353
1354    #[test]
1355    fn decode_empty_multiline() {
1356        // Some commands can return an empty multi-line block (just "END").
1357        let msgs = encode_then_decode(OvpnCommand::Status(StatusFormat::V1), "END\n");
1358        assert_eq!(msgs.len(), 1);
1359        assert!(matches!(&msgs[0], OvpnMessage::MultiLine(lines) if lines.is_empty()));
1360    }
1361
1362    #[test]
1363    fn decode_need_ok_notification() {
1364        let msgs = decode_all(
1365            ">NEED-OK:Need 'token-insertion-request' confirmation MSG:Please insert your token\n",
1366        );
1367        assert_eq!(msgs.len(), 1);
1368        match &msgs[0] {
1369            OvpnMessage::Notification(Notification::NeedOk { name, message }) => {
1370                assert_eq!(name, "token-insertion-request");
1371                assert_eq!(message, "Please insert your token");
1372            }
1373            other => panic!("unexpected: {other:?}"),
1374        }
1375    }
1376
1377    #[test]
1378    fn decode_hold_notification() {
1379        let msgs = decode_all(">HOLD:Waiting for hold release\n");
1380        assert_eq!(msgs.len(), 1);
1381        match &msgs[0] {
1382            OvpnMessage::Notification(Notification::Hold { text }) => {
1383                assert_eq!(text, "Waiting for hold release");
1384            }
1385            other => panic!("unexpected: {other:?}"),
1386        }
1387    }
1388
1389    // ── RawMultiLine tests ──────────────────────────────────────
1390
1391    #[test]
1392    fn encode_raw_multiline() {
1393        assert_eq!(
1394            encode_to_string(OvpnCommand::RawMultiLine("custom-cmd arg".to_string())),
1395            "custom-cmd arg\n"
1396        );
1397    }
1398
1399    #[test]
1400    fn raw_multiline_expects_multiline_response() {
1401        let msgs = encode_then_decode(
1402            OvpnCommand::RawMultiLine("custom".to_string()),
1403            "line1\nline2\nEND\n",
1404        );
1405        assert_eq!(msgs.len(), 1);
1406        match &msgs[0] {
1407            OvpnMessage::MultiLine(lines) => {
1408                assert_eq!(lines, &["line1", "line2"]);
1409            }
1410            other => panic!("expected MultiLine, got: {other:?}"),
1411        }
1412    }
1413
1414    #[test]
1415    fn raw_multiline_sanitizes_newlines() {
1416        let wire = encode_to_string(OvpnCommand::RawMultiLine("cmd\ninjected".to_string()));
1417        assert_eq!(wire, "cmdinjected\n");
1418    }
1419
1420    // ── Sequential encode/decode assertion tests ────────────────
1421
1422    #[test]
1423    #[should_panic(expected = "mid-accumulation")]
1424    fn encode_during_multiline_accumulation_panics() {
1425        let mut codec = OvpnCodec::new();
1426        let mut buf = BytesMut::new();
1427        // Encode a command that expects multi-line response.
1428        codec
1429            .encode(OvpnCommand::Status(StatusFormat::V1), &mut buf)
1430            .unwrap();
1431        // Feed partial multi-line response (no END yet).
1432        let mut dec = BytesMut::from("header line\n");
1433        let _ = codec.decode(&mut dec); // starts multi_line_buf accumulation
1434        // Encoding again while accumulating should panic in debug.
1435        codec.encode(OvpnCommand::Pid, &mut buf).unwrap();
1436    }
1437
1438    #[test]
1439    #[should_panic(expected = "mid-accumulation")]
1440    fn encode_during_client_notif_accumulation_panics() {
1441        let mut codec = OvpnCodec::new();
1442        let mut buf = BytesMut::new();
1443        // Feed a CLIENT header — starts client_notif accumulation.
1444        let mut dec = BytesMut::from(">CLIENT:CONNECT,0,1\n");
1445        let _ = codec.decode(&mut dec);
1446        // Encoding while client_notif is active should panic.
1447        codec.encode(OvpnCommand::Pid, &mut buf).unwrap();
1448    }
1449
1450    // ── Accumulation limit tests ────────────────────────────────
1451
1452    #[test]
1453    fn unlimited_accumulation_default() {
1454        let mut codec = OvpnCodec::new();
1455        let mut enc = BytesMut::new();
1456        codec
1457            .encode(OvpnCommand::Status(StatusFormat::V1), &mut enc)
1458            .unwrap();
1459        // Feed 500 lines + END — should succeed with Unlimited default.
1460        let mut data = String::new();
1461        for i in 0..500 {
1462            data.push_str(&format!("line {i}\n"));
1463        }
1464        data.push_str("END\n");
1465        let mut dec = BytesMut::from(data.as_str());
1466        let mut msgs = Vec::new();
1467        while let Some(msg) = codec.decode(&mut dec).unwrap() {
1468            msgs.push(msg);
1469        }
1470        assert_eq!(msgs.len(), 1);
1471        match &msgs[0] {
1472            OvpnMessage::MultiLine(lines) => assert_eq!(lines.len(), 500),
1473            other => panic!("expected MultiLine, got: {other:?}"),
1474        }
1475    }
1476
1477    #[test]
1478    fn multi_line_limit_exceeded() {
1479        let mut codec = OvpnCodec::new().with_max_multi_line_lines(AccumulationLimit::Max(3));
1480        let mut enc = BytesMut::new();
1481        codec
1482            .encode(OvpnCommand::Status(StatusFormat::V1), &mut enc)
1483            .unwrap();
1484        let mut dec = BytesMut::from("a\nb\nc\nd\nEND\n");
1485        let result = loop {
1486            match codec.decode(&mut dec) {
1487                Ok(Some(msg)) => break Ok(msg),
1488                Ok(None) => continue,
1489                Err(e) => break Err(e),
1490            }
1491        };
1492        assert!(result.is_err(), "expected error when limit exceeded");
1493        let err = result.unwrap_err();
1494        assert!(
1495            err.to_string().contains("multi-line response"),
1496            "error should mention multi-line: {err}"
1497        );
1498    }
1499
1500    #[test]
1501    fn multi_line_limit_exact_boundary_passes() {
1502        let mut codec = OvpnCodec::new().with_max_multi_line_lines(AccumulationLimit::Max(3));
1503        let mut enc = BytesMut::new();
1504        codec
1505            .encode(OvpnCommand::Status(StatusFormat::V1), &mut enc)
1506            .unwrap();
1507        // Exactly 3 lines should succeed.
1508        let mut dec = BytesMut::from("a\nb\nc\nEND\n");
1509        let mut msgs = Vec::new();
1510        while let Some(msg) = codec.decode(&mut dec).unwrap() {
1511            msgs.push(msg);
1512        }
1513        assert_eq!(msgs.len(), 1);
1514        match &msgs[0] {
1515            OvpnMessage::MultiLine(lines) => assert_eq!(lines.len(), 3),
1516            other => panic!("expected MultiLine, got: {other:?}"),
1517        }
1518    }
1519
1520    #[test]
1521    fn client_env_limit_exceeded() {
1522        let mut codec = OvpnCodec::new().with_max_client_env_entries(AccumulationLimit::Max(2));
1523        let mut dec = BytesMut::from(
1524            ">CLIENT:CONNECT,0,1\n\
1525             >CLIENT:ENV,a=1\n\
1526             >CLIENT:ENV,b=2\n\
1527             >CLIENT:ENV,c=3\n\
1528             >CLIENT:ENV,END\n",
1529        );
1530        let result = loop {
1531            match codec.decode(&mut dec) {
1532                Ok(Some(msg)) => break Ok(msg),
1533                Ok(None) => continue,
1534                Err(e) => break Err(e),
1535            }
1536        };
1537        assert!(
1538            result.is_err(),
1539            "expected error when client ENV limit exceeded"
1540        );
1541        let err = result.unwrap_err();
1542        assert!(
1543            err.to_string().contains("client ENV"),
1544            "error should mention client ENV: {err}"
1545        );
1546    }
1547
1548    // ── UTF-8 error state reset tests ───────────────────────────
1549
1550    #[test]
1551    fn utf8_error_resets_multiline_state() {
1552        let mut codec = OvpnCodec::new();
1553        let mut enc = BytesMut::new();
1554        codec
1555            .encode(OvpnCommand::Status(StatusFormat::V1), &mut enc)
1556            .unwrap();
1557        // Feed a valid first line to start multi-line accumulation.
1558        let mut dec = BytesMut::from("header\n");
1559        assert!(codec.decode(&mut dec).unwrap().is_none());
1560        // Feed invalid UTF-8.
1561        dec.extend_from_slice(b"bad \xff line\n");
1562        assert!(codec.decode(&mut dec).is_err());
1563        // State should be reset — next valid line should decode cleanly
1564        // as an Unrecognized (since expected was reset to SuccessOrError).
1565        dec.extend_from_slice(b"SUCCESS: recovered\n");
1566        let msg = codec.decode(&mut dec).unwrap();
1567        match msg {
1568            Some(OvpnMessage::Success(ref s)) if s.contains("recovered") => {}
1569            other => panic!("expected Success after UTF-8 reset, got: {other:?}"),
1570        }
1571    }
1572
1573    #[test]
1574    fn utf8_error_resets_client_notif_state() {
1575        let mut codec = OvpnCodec::new();
1576        // Start CLIENT accumulation.
1577        let mut dec = BytesMut::from(">CLIENT:CONNECT,0,1\n");
1578        assert!(codec.decode(&mut dec).unwrap().is_none());
1579        // Feed invalid UTF-8 within the ENV block.
1580        dec.extend_from_slice(b">CLIENT:ENV,\xff\n");
1581        assert!(codec.decode(&mut dec).is_err());
1582        // State should be reset.
1583        dec.extend_from_slice(b"SUCCESS: ok\n");
1584        let msg = codec.decode(&mut dec).unwrap();
1585        match msg {
1586            Some(OvpnMessage::Success(_)) => {}
1587            other => panic!("expected Success after UTF-8 reset, got: {other:?}"),
1588        }
1589    }
1590}