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